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:
2025-09-17 01:42:08 +02:00
parent b440da3094
commit d478b28e0d

View File

@@ -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)
# 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
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"""