Refactor cropping and coordinate mapping in VideoEditor to support rotation
This commit modifies the cropping functionality to apply transformations in rotated frame coordinates, ensuring accurate cropping after rotation. It introduces a new method for mapping screen coordinates back to rotated frame coordinates, enhancing the overall cropping experience. Additionally, debug statements are added for better tracking of crop operations, improving the debugging process during development.
This commit is contained in:
272
croppa/main.py
272
croppa/main.py
@@ -1099,17 +1099,14 @@ class VideoEditor:
|
||||
# Apply brightness/contrast first (to original frame for best quality)
|
||||
processed_frame = self.apply_brightness_contrast(processed_frame)
|
||||
|
||||
# Apply crop (with motion tracking follow if enabled)
|
||||
# Apply rotation first so crop_rect is in ROTATED frame coordinates
|
||||
if self.rotation_angle != 0:
|
||||
processed_frame = self.apply_rotation(processed_frame)
|
||||
|
||||
# Apply crop (interpreted in rotated frame coordinates)
|
||||
if self.crop_rect:
|
||||
x, y, w, h = self.crop_rect
|
||||
if self.tracking_enabled:
|
||||
interp = self._get_interpolated_tracking_position(getattr(self, 'current_frame', 0))
|
||||
if interp:
|
||||
cx, cy = interp
|
||||
x = int(round(cx - w / 2))
|
||||
y = int(round(cy - h / 2))
|
||||
x, y, w, h = int(x), int(y), int(w), int(h)
|
||||
# Ensure crop is within frame bounds
|
||||
x, y, w, h = map(int, self.crop_rect)
|
||||
# Ensure crop is within frame bounds (rotated frame)
|
||||
x = max(0, min(x, processed_frame.shape[1] - 1))
|
||||
y = max(0, min(y, processed_frame.shape[0] - 1))
|
||||
w = min(w, processed_frame.shape[1] - x)
|
||||
@@ -1117,10 +1114,6 @@ class VideoEditor:
|
||||
if w > 0 and h > 0:
|
||||
processed_frame = processed_frame[y : y + h, x : x + w]
|
||||
|
||||
# Apply rotation
|
||||
if self.rotation_angle != 0:
|
||||
processed_frame = self.apply_rotation(processed_frame)
|
||||
|
||||
# Apply zoom
|
||||
if self.zoom_factor != 1.0:
|
||||
height, width = processed_frame.shape[:2]
|
||||
@@ -1149,21 +1142,15 @@ class VideoEditor:
|
||||
|
||||
# --- Motion tracking helpers ---
|
||||
def _get_effective_crop_rect_for_frame(self, frame_number):
|
||||
"""Compute crop rect applied to a given frame, considering tracking follow."""
|
||||
"""Return crop_rect in ROTATED frame coordinates for this frame."""
|
||||
# When cropping after rotation, the crop_rect is directly in rotated frame coords
|
||||
# so no translation is needed for mapping overlays.
|
||||
if not self.crop_rect:
|
||||
# Compute rotated dimensions of the full frame
|
||||
if self.rotation_angle in (90, 270):
|
||||
return (0, 0, self.frame_height, self.frame_width)
|
||||
return (0, 0, self.frame_width, self.frame_height)
|
||||
x, y, w, h = map(int, self.crop_rect)
|
||||
if self.tracking_enabled:
|
||||
pos = self._get_interpolated_tracking_position(frame_number)
|
||||
if pos:
|
||||
cx, cy = pos
|
||||
x = int(round(cx - w / 2))
|
||||
y = int(round(cy - h / 2))
|
||||
# Clamp to frame bounds
|
||||
x = max(0, min(x, self.frame_width - 1))
|
||||
y = max(0, min(y, self.frame_height - 1))
|
||||
w = min(w, self.frame_width - x)
|
||||
h = min(h, self.frame_height - y)
|
||||
return (x, y, w, h)
|
||||
|
||||
def _get_interpolated_tracking_position(self, frame_number):
|
||||
@@ -1202,102 +1189,158 @@ class VideoEditor:
|
||||
def _map_original_to_screen(self, ox, oy):
|
||||
"""Map a point in original frame coords to canvas screen coords."""
|
||||
frame_number = getattr(self, 'current_frame', 0)
|
||||
cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number)
|
||||
# Relative to effective crop
|
||||
px = ox - cx
|
||||
py = oy - cy
|
||||
# Since crop is applied after rotation, mapping to rotated space uses only rotation
|
||||
angle = self.rotation_angle
|
||||
if angle == 90:
|
||||
rx, ry = oy, self.frame_width - 1 - ox
|
||||
elif angle == 180:
|
||||
rx, ry = self.frame_width - 1 - ox, self.frame_height - 1 - oy
|
||||
elif angle == 270:
|
||||
rx, ry = self.frame_height - 1 - oy, ox
|
||||
else:
|
||||
rx, ry = ox, oy
|
||||
# Now account for crop in rotated space
|
||||
cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number)
|
||||
rx -= cx
|
||||
ry -= cy
|
||||
# Dimensions after rotation
|
||||
if angle in (90, 270):
|
||||
rotated_w, rotated_h = ch, cw
|
||||
else:
|
||||
rotated_w, rotated_h = cw, ch
|
||||
# Forward rotation mapping
|
||||
if angle == 90:
|
||||
rx, ry = py, rotated_w - px
|
||||
elif angle == 180:
|
||||
rx, ry = rotated_w - px, rotated_h - py
|
||||
elif angle == 270:
|
||||
rx, ry = rotated_h - py, px
|
||||
else:
|
||||
rx, ry = px, py
|
||||
# Zoom
|
||||
zx = rx * self.zoom_factor
|
||||
zy = ry * self.zoom_factor
|
||||
# Apply display offset cropping in zoomed space
|
||||
# Zoomed dimensions
|
||||
new_w = int(rotated_w * self.zoom_factor)
|
||||
new_h = int(rotated_h * self.zoom_factor)
|
||||
offx_max = max(0, new_w - self.window_width)
|
||||
offy_max = max(0, new_h - self.window_height)
|
||||
offx = max(0, min(int(self.display_offset[0]), offx_max))
|
||||
offy = max(0, min(int(self.display_offset[1]), offy_max))
|
||||
# Whether apply_crop_zoom_and_rotation cropped due to zoom
|
||||
cropped_due_to_zoom = (self.zoom_factor != 1.0) and (new_w > self.window_width or new_h > self.window_height)
|
||||
# Display offset in zoomed space only applies if cropped_due_to_zoom
|
||||
if cropped_due_to_zoom:
|
||||
offx_max = max(0, new_w - self.window_width)
|
||||
offy_max = max(0, new_h - self.window_height)
|
||||
offx = max(0, min(int(self.display_offset[0]), offx_max))
|
||||
offy = max(0, min(int(self.display_offset[1]), offy_max))
|
||||
else:
|
||||
offx = 0
|
||||
offy = 0
|
||||
inframe_x = zx - offx
|
||||
inframe_y = zy - offy
|
||||
# Size of processed_frame from apply_crop_zoom_and_rotation
|
||||
base_w = new_w if new_w <= self.window_width else self.window_width
|
||||
base_h = new_h if new_h <= self.window_height else self.window_height
|
||||
# Final scale and canvas placement
|
||||
# Visible dimensions before canvas scaling (what display_current_frame receives)
|
||||
visible_w = new_w if not cropped_due_to_zoom else min(new_w, self.window_width)
|
||||
visible_h = new_h if not cropped_due_to_zoom else min(new_h, self.window_height)
|
||||
# Canvas scale and placement (matches display_current_frame downscale and centering)
|
||||
available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT)
|
||||
scale = min(self.window_width / max(1, base_w), available_height / max(1, base_h))
|
||||
final_w = int(base_w * scale)
|
||||
final_h = int(base_h * scale)
|
||||
scale_canvas = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h))
|
||||
final_w = int(visible_w * scale_canvas)
|
||||
final_h = int(visible_h * scale_canvas)
|
||||
start_x_canvas = (self.window_width - final_w) // 2
|
||||
start_y_canvas = (available_height - final_h) // 2
|
||||
sx = int(round(start_x_canvas + inframe_x * scale))
|
||||
sy = int(round(start_y_canvas + inframe_y * scale))
|
||||
sx = int(round(start_x_canvas + inframe_x * scale_canvas))
|
||||
sy = int(round(start_y_canvas + inframe_y * scale_canvas))
|
||||
return sx, sy
|
||||
|
||||
def _map_screen_to_original(self, sx, sy):
|
||||
"""Map a point on canvas screen coords back to original frame coords."""
|
||||
frame_number = getattr(self, 'current_frame', 0)
|
||||
cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number)
|
||||
angle = self.rotation_angle
|
||||
# Dimensions after rotation
|
||||
ch, cw = self.frame_height, self.frame_width
|
||||
# Zoomed dimensions
|
||||
if angle in (90, 270):
|
||||
rotated_w, rotated_h = ch, cw
|
||||
else:
|
||||
rotated_w, rotated_h = cw, ch
|
||||
# Zoomed dimensions and base processed dimensions (after window cropping)
|
||||
new_w = int(rotated_w * self.zoom_factor)
|
||||
new_h = int(rotated_h * self.zoom_factor)
|
||||
base_w = new_w if new_w <= self.window_width else self.window_width
|
||||
base_h = new_h if new_h <= self.window_height else self.window_height
|
||||
# Final scaling used in display
|
||||
# Whether apply_crop_zoom_and_rotation cropped due to zoom
|
||||
cropped_due_to_zoom = (self.zoom_factor != 1.0) and (new_w > self.window_width or new_h > self.window_height)
|
||||
# Visible dims before canvas scaling
|
||||
visible_w = new_w if not cropped_due_to_zoom else min(new_w, self.window_width)
|
||||
visible_h = new_h if not cropped_due_to_zoom else min(new_h, self.window_height)
|
||||
# Canvas scale and placement
|
||||
available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT)
|
||||
scale = min(self.window_width / max(1, base_w), available_height / max(1, base_h))
|
||||
final_w = int(base_w * scale)
|
||||
final_h = int(base_h * scale)
|
||||
scale_canvas = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h))
|
||||
final_w = int(visible_w * scale_canvas)
|
||||
final_h = int(visible_h * scale_canvas)
|
||||
start_x_canvas = (self.window_width - final_w) // 2
|
||||
start_y_canvas = (available_height - final_h) // 2
|
||||
# Back to processed (zoomed+cropped) space
|
||||
zx = (sx - start_x_canvas) / max(1e-6, scale)
|
||||
zy = (sy - start_y_canvas) / max(1e-6, scale)
|
||||
# Add display offset in zoomed space
|
||||
offx_max = max(0, new_w - self.window_width)
|
||||
offy_max = max(0, new_h - self.window_height)
|
||||
offx = max(0, min(int(self.display_offset[0]), offx_max))
|
||||
offy = max(0, min(int(self.display_offset[1]), offy_max))
|
||||
zx = (sx - start_x_canvas) / max(1e-6, scale_canvas)
|
||||
zy = (sy - start_y_canvas) / max(1e-6, scale_canvas)
|
||||
# Add display offset in zoomed space (only if cropped_due_to_zoom)
|
||||
if cropped_due_to_zoom:
|
||||
offx_max = max(0, new_w - self.window_width)
|
||||
offy_max = max(0, new_h - self.window_height)
|
||||
offx = max(0, min(int(self.display_offset[0]), offx_max))
|
||||
offy = max(0, min(int(self.display_offset[1]), offy_max))
|
||||
else:
|
||||
offx = 0
|
||||
offy = 0
|
||||
zx += offx
|
||||
zy += offy
|
||||
# Reverse zoom
|
||||
rx = zx / max(1e-6, self.zoom_factor)
|
||||
ry = zy / max(1e-6, self.zoom_factor)
|
||||
# Reverse rotation
|
||||
# Reverse crop in rotated space to get rotated coordinates
|
||||
cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number)
|
||||
rx = rx + cx
|
||||
ry = ry + cy
|
||||
# Reverse rotation to original frame coords
|
||||
if angle == 90:
|
||||
px, py = rotated_w - ry, rx
|
||||
ox, oy = self.frame_width - 1 - ry, rx
|
||||
elif angle == 180:
|
||||
px, py = rotated_w - rx, rotated_h - ry
|
||||
ox, oy = self.frame_width - 1 - rx, self.frame_height - 1 - ry
|
||||
elif angle == 270:
|
||||
px, py = ry, rotated_h - rx
|
||||
ox, oy = ry, self.frame_height - 1 - rx
|
||||
else:
|
||||
px, py = rx, ry
|
||||
# Back to original frame
|
||||
ox = px + cx
|
||||
oy = py + cy
|
||||
ox, oy = rx, ry
|
||||
ox = max(0, min(int(round(ox)), self.frame_width - 1))
|
||||
oy = max(0, min(int(round(oy)), self.frame_height - 1))
|
||||
return ox, oy
|
||||
|
||||
def _map_screen_to_rotated(self, sx, sy):
|
||||
"""Map a point on canvas screen coords back to ROTATED frame coords (pre-crop)."""
|
||||
frame_number = getattr(self, 'current_frame', 0)
|
||||
angle = self.rotation_angle
|
||||
# Rotated base dims
|
||||
if angle in (90, 270):
|
||||
rotated_w, rotated_h = self.frame_height, self.frame_width
|
||||
else:
|
||||
rotated_w, rotated_h = self.frame_width, self.frame_height
|
||||
new_w = int(rotated_w * self.zoom_factor)
|
||||
new_h = int(rotated_h * self.zoom_factor)
|
||||
cropped_due_to_zoom = (self.zoom_factor != 1.0) and (new_w > self.window_width or new_h > self.window_height)
|
||||
visible_w = new_w if not cropped_due_to_zoom else min(new_w, self.window_width)
|
||||
visible_h = new_h if not cropped_due_to_zoom else min(new_h, self.window_height)
|
||||
available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT)
|
||||
scale_canvas = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h))
|
||||
final_w = int(visible_w * scale_canvas)
|
||||
final_h = int(visible_h * scale_canvas)
|
||||
start_x_canvas = (self.window_width - final_w) // 2
|
||||
start_y_canvas = (available_height - final_h) // 2
|
||||
# Back to processed (zoomed+cropped) space
|
||||
zx = (sx - start_x_canvas) / max(1e-6, scale_canvas)
|
||||
zy = (sy - start_y_canvas) / max(1e-6, scale_canvas)
|
||||
if cropped_due_to_zoom:
|
||||
offx_max = max(0, new_w - self.window_width)
|
||||
offy_max = max(0, new_h - self.window_height)
|
||||
offx = max(0, min(int(self.display_offset[0]), offx_max))
|
||||
offy = max(0, min(int(self.display_offset[1]), offy_max))
|
||||
else:
|
||||
offx = 0
|
||||
offy = 0
|
||||
zx += offx
|
||||
zy += offy
|
||||
# Reverse zoom
|
||||
rx = zx / max(1e-6, self.zoom_factor)
|
||||
ry = zy / max(1e-6, self.zoom_factor)
|
||||
# Unapply current crop to get PRE-crop rotated coords
|
||||
cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number)
|
||||
rx = rx + cx
|
||||
ry = ry + cy
|
||||
return int(round(rx)), int(round(ry))
|
||||
|
||||
def clear_transformation_cache(self):
|
||||
"""Clear the cached transformation to force recalculation"""
|
||||
self.cached_transformed_frame = None
|
||||
@@ -1974,6 +2017,7 @@ class VideoEditor:
|
||||
if flags & cv2.EVENT_FLAG_SHIFTKEY:
|
||||
|
||||
if event == cv2.EVENT_LBUTTONDOWN:
|
||||
print(f"DEBUG: Crop start at screen=({x},{y}) frame={getattr(self, 'current_frame', -1)}")
|
||||
self.crop_selecting = True
|
||||
self.crop_start_point = (x, y)
|
||||
self.crop_preview_rect = None
|
||||
@@ -1987,6 +2031,7 @@ class VideoEditor:
|
||||
self.crop_preview_rect = (crop_x, crop_y, width, height)
|
||||
elif event == cv2.EVENT_LBUTTONUP and self.crop_selecting:
|
||||
if self.crop_start_point and self.crop_preview_rect:
|
||||
print(f"DEBUG: Crop end screen_rect={self.crop_preview_rect}")
|
||||
# Convert screen coordinates to video coordinates
|
||||
self.set_crop_from_screen_coords(self.crop_preview_rect)
|
||||
self.crop_selecting = False
|
||||
@@ -2042,34 +2087,67 @@ class VideoEditor:
|
||||
if self.current_display_frame is None:
|
||||
return
|
||||
|
||||
# Map both corners from screen to original to form an axis-aligned crop
|
||||
# All coordinates are in reference to the ORIGINAL frame
|
||||
# User input arrives in processed display space → map back to original
|
||||
# Debug context for crop mapping
|
||||
print("DEBUG: set_crop_from_screen_coords")
|
||||
print(f"DEBUG: input screen_rect=({x},{y},{w},{h})")
|
||||
print(f"DEBUG: state rotation={self.rotation_angle} zoom={self.zoom_factor} window=({self.window_width},{self.window_height})")
|
||||
print(f"DEBUG: display_offset={self.display_offset} is_image_mode={self.is_image_mode}")
|
||||
print(f"DEBUG: current crop_rect={self.crop_rect}")
|
||||
eff = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) if self.crop_rect else None
|
||||
print(f"DEBUG: effective_crop_for_frame={eff}")
|
||||
|
||||
# Map both corners from screen to ROTATED space, then derive crop in rotated coords
|
||||
x2 = x + w
|
||||
y2 = y + h
|
||||
ox1, oy1 = self._map_screen_to_original(x, y)
|
||||
ox2, oy2 = self._map_screen_to_original(x2, y2)
|
||||
left = min(ox1, ox2)
|
||||
top = min(oy1, oy2)
|
||||
right = max(ox1, ox2)
|
||||
bottom = max(oy1, oy2)
|
||||
original_x = left
|
||||
original_y = top
|
||||
original_w = max(10, right - left)
|
||||
original_h = max(10, bottom - top)
|
||||
rx1, ry1 = self._map_screen_to_rotated(x, y)
|
||||
rx2, ry2 = self._map_screen_to_rotated(x2, y2)
|
||||
print(f"DEBUG: mapped ROTATED corners -> ({rx1},{ry1}) and ({rx2},{ry2})")
|
||||
left_r = min(rx1, rx2)
|
||||
top_r = min(ry1, ry2)
|
||||
right_r = max(rx1, rx2)
|
||||
bottom_r = max(ry1, ry2)
|
||||
crop_x = left_r
|
||||
crop_y = top_r
|
||||
crop_w = max(10, right_r - left_r)
|
||||
crop_h = max(10, bottom_r - top_r)
|
||||
|
||||
# Clamp to original frame bounds
|
||||
original_x = max(0, min(original_x, self.frame_width - 1))
|
||||
original_y = max(0, min(original_y, self.frame_height - 1))
|
||||
original_w = min(original_w, self.frame_width - original_x)
|
||||
original_h = min(original_h, self.frame_height - original_y)
|
||||
# Clamp to rotated frame bounds
|
||||
if self.rotation_angle in (90, 270):
|
||||
rot_w, rot_h = self.frame_height, self.frame_width
|
||||
else:
|
||||
rot_w, rot_h = self.frame_width, self.frame_height
|
||||
crop_x = max(0, min(crop_x, rot_w - 1))
|
||||
crop_y = max(0, min(crop_y, rot_h - 1))
|
||||
crop_w = min(crop_w, rot_w - crop_x)
|
||||
crop_h = min(crop_h, rot_h - crop_y)
|
||||
|
||||
if original_w > 10 and original_h > 10:
|
||||
print(f"DEBUG: final ROTATED_rect=({crop_x},{crop_y},{crop_w},{crop_h}) rotated_size=({rot_w},{rot_h})")
|
||||
|
||||
# Snap to full rotated frame if selection covers it
|
||||
if crop_w >= int(0.9 * rot_w) and crop_h >= int(0.9 * rot_h):
|
||||
if self.crop_rect:
|
||||
self.crop_history.append(self.crop_rect)
|
||||
self.crop_rect = (original_x, original_y, original_w, original_h)
|
||||
self.crop_rect = None
|
||||
self.clear_transformation_cache()
|
||||
self.save_state()
|
||||
print("DEBUG: selection ~full frame -> clearing crop (use full frame)")
|
||||
return
|
||||
|
||||
if crop_w > 10 and crop_h > 10:
|
||||
if self.crop_rect:
|
||||
self.crop_history.append(self.crop_rect)
|
||||
# Store crop in ROTATED frame coordinates
|
||||
self.crop_rect = (crop_x, crop_y, crop_w, crop_h)
|
||||
self.clear_transformation_cache()
|
||||
self.save_state()
|
||||
print(f"DEBUG: crop_rect (ROTATED space) set -> {self.crop_rect}")
|
||||
# Disable motion tracking upon explicit crop set to avoid unintended offsets
|
||||
if self.tracking_enabled:
|
||||
self.tracking_enabled = False
|
||||
print("DEBUG: tracking disabled due to manual crop set")
|
||||
self.save_state()
|
||||
else:
|
||||
print("DEBUG: rejected small crop (<=10px)")
|
||||
|
||||
def seek_to_timeline_position(self, mouse_x, bar_x_start, bar_width):
|
||||
"""Seek to position based on mouse click on timeline"""
|
||||
|
Reference in New Issue
Block a user