Add motion tracking functionality to VideoEditor
This commit introduces a new MotionTracker class for handling motion tracking during video editing. The VideoEditor class has been updated to integrate motion tracking features, including adding and removing tracking points, interpolating positions, and applying tracking offsets during cropping. The user can toggle motion tracking and clear tracking points via keyboard shortcuts. Additionally, the state management has been enhanced to save and load motion tracking data, improving the overall editing experience.
This commit is contained in:
252
croppa/main.py
252
croppa/main.py
@@ -4,7 +4,7 @@ import cv2
|
||||
import argparse
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Tuple
|
||||
import time
|
||||
import re
|
||||
import json
|
||||
@@ -12,6 +12,7 @@ import threading
|
||||
import queue
|
||||
import subprocess
|
||||
import ctypes
|
||||
from tracking import MotionTracker
|
||||
|
||||
class Cv2BufferedCap:
|
||||
"""Buffered wrapper around cv2.VideoCapture that handles frame loading, seeking, and caching correctly"""
|
||||
@@ -596,6 +597,11 @@ class VideoEditor:
|
||||
self.display_needs_update = True
|
||||
self.last_display_state = None
|
||||
|
||||
# Motion tracking
|
||||
self.motion_tracker = MotionTracker()
|
||||
self.tracking_point_radius = 10 # Radius of tracking point circles
|
||||
self.tracking_point_distance = 50 # Max distance to consider for removing points
|
||||
|
||||
# Cached transformations for performance
|
||||
self.cached_transformed_frame = None
|
||||
self.cached_frame_number = None
|
||||
@@ -628,6 +634,9 @@ class VideoEditor:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Get tracking data
|
||||
tracking_data = self.motion_tracker.to_dict()
|
||||
|
||||
state = {
|
||||
'timestamp': time.time(),
|
||||
'current_frame': getattr(self, 'current_frame', 0),
|
||||
@@ -643,7 +652,8 @@ class VideoEditor:
|
||||
'display_offset': self.display_offset,
|
||||
'playback_speed': getattr(self, 'playback_speed', 1.0),
|
||||
'seek_multiplier': getattr(self, 'seek_multiplier', 1.0),
|
||||
'is_playing': getattr(self, 'is_playing', False)
|
||||
'is_playing': getattr(self, 'is_playing', False),
|
||||
'motion_tracking': tracking_data # Add tracking data
|
||||
}
|
||||
|
||||
with open(state_file, 'w') as f:
|
||||
@@ -719,6 +729,11 @@ class VideoEditor:
|
||||
if 'is_playing' in state:
|
||||
self.is_playing = state['is_playing']
|
||||
print(f"Loaded is_playing: {self.is_playing}")
|
||||
|
||||
# Load motion tracking data if available
|
||||
if 'motion_tracking' in state:
|
||||
self.motion_tracker.from_dict(state['motion_tracking'])
|
||||
print(f"Loaded motion tracking data: {len(self.motion_tracker.tracking_points)} keyframes")
|
||||
|
||||
# Validate cut markers against current video length
|
||||
if self.cut_start_frame is not None and self.cut_start_frame >= self.total_frames:
|
||||
@@ -1064,6 +1079,11 @@ class VideoEditor:
|
||||
if frame is None:
|
||||
return None
|
||||
|
||||
# Get tracking offset for crop following if motion tracking is enabled
|
||||
tracking_offset = (0, 0)
|
||||
if self.motion_tracker.tracking_enabled:
|
||||
tracking_offset = self.motion_tracker.get_tracking_offset(self.current_frame)
|
||||
|
||||
# Create a hash of the transformation parameters for caching
|
||||
transform_hash = hash((
|
||||
self.crop_rect,
|
||||
@@ -1071,7 +1091,9 @@ class VideoEditor:
|
||||
self.rotation_angle,
|
||||
self.brightness,
|
||||
self.contrast,
|
||||
tuple(self.display_offset)
|
||||
tuple(self.display_offset),
|
||||
tracking_offset, # Include tracking offset in hash
|
||||
self.motion_tracker.tracking_enabled # Include tracking state in hash
|
||||
))
|
||||
|
||||
# Check if we can use cached transformation during auto-repeat seeking
|
||||
@@ -1090,6 +1112,13 @@ class VideoEditor:
|
||||
# Apply crop
|
||||
if self.crop_rect:
|
||||
x, y, w, h = self.crop_rect
|
||||
|
||||
# Apply tracking offset to crop position if motion tracking is enabled
|
||||
if self.motion_tracker.tracking_enabled:
|
||||
tracking_offset = self.motion_tracker.get_tracking_offset(self.current_frame)
|
||||
x += int(tracking_offset[0])
|
||||
y += int(tracking_offset[1])
|
||||
|
||||
x, y, w, h = int(x), int(y), int(w), int(h)
|
||||
# Ensure crop is within frame bounds
|
||||
x = max(0, min(x, processed_frame.shape[1] - 1))
|
||||
@@ -1134,6 +1163,109 @@ class VideoEditor:
|
||||
self.cached_transformed_frame = None
|
||||
self.cached_frame_number = None
|
||||
self.cached_transform_hash = None
|
||||
|
||||
def transform_point(self, point: Tuple[float, float]) -> Tuple[float, float]:
|
||||
"""Transform a point from original frame coordinates to display coordinates
|
||||
|
||||
This applies the same transformations that are applied to frames:
|
||||
1. Crop
|
||||
2. Rotation
|
||||
3. Zoom
|
||||
"""
|
||||
if point is None:
|
||||
return None
|
||||
|
||||
x, y = point
|
||||
|
||||
# Step 1: Apply crop (adjust point relative to crop origin)
|
||||
if self.crop_rect:
|
||||
crop_x, crop_y, _, _ = self.crop_rect
|
||||
x -= crop_x
|
||||
y -= crop_y
|
||||
|
||||
# Step 2: Apply rotation
|
||||
if self.rotation_angle != 0:
|
||||
# Get dimensions after crop
|
||||
if self.crop_rect:
|
||||
crop_w, crop_h = self.crop_rect[2], self.crop_rect[3]
|
||||
else:
|
||||
if self.current_display_frame is not None:
|
||||
crop_h, crop_w = self.current_display_frame.shape[:2]
|
||||
else:
|
||||
return None
|
||||
|
||||
# Apply rotation to coordinates
|
||||
if self.rotation_angle == 90:
|
||||
# 90° clockwise: (x,y) -> (y, width-x)
|
||||
new_x = y
|
||||
new_y = crop_w - x
|
||||
x, y = new_x, new_y
|
||||
elif self.rotation_angle == 180:
|
||||
# 180° rotation: (x,y) -> (width-x, height-y)
|
||||
x = crop_w - x
|
||||
y = crop_h - y
|
||||
elif self.rotation_angle == 270:
|
||||
# 270° clockwise: (x,y) -> (height-y, x)
|
||||
new_x = crop_h - y
|
||||
new_y = x
|
||||
x, y = new_x, new_y
|
||||
|
||||
# Step 3: Apply zoom
|
||||
if self.zoom_factor != 1.0:
|
||||
x *= self.zoom_factor
|
||||
y *= self.zoom_factor
|
||||
|
||||
return (x, y)
|
||||
|
||||
def untransform_point(self, point: Tuple[float, float]) -> Tuple[float, float]:
|
||||
"""Transform a point from display coordinates back to original frame coordinates
|
||||
|
||||
This reverses the transformations in the opposite order:
|
||||
1. Reverse zoom
|
||||
2. Reverse rotation
|
||||
3. Reverse crop
|
||||
"""
|
||||
if point is None or self.current_display_frame is None:
|
||||
return None
|
||||
|
||||
x, y = point
|
||||
|
||||
# Step 1: Reverse zoom
|
||||
if self.zoom_factor != 1.0:
|
||||
x /= self.zoom_factor
|
||||
y /= self.zoom_factor
|
||||
|
||||
# Step 2: Reverse rotation
|
||||
if self.rotation_angle != 0:
|
||||
# Get dimensions after crop but before rotation
|
||||
if self.crop_rect:
|
||||
crop_w, crop_h = self.crop_rect[2], self.crop_rect[3]
|
||||
else:
|
||||
crop_h, crop_w = self.current_display_frame.shape[:2]
|
||||
|
||||
# Apply inverse rotation to coordinates
|
||||
if self.rotation_angle == 90:
|
||||
# Reverse 90° clockwise: (x,y) -> (width-y, x)
|
||||
new_x = crop_w - y
|
||||
new_y = x
|
||||
x, y = new_x, new_y
|
||||
elif self.rotation_angle == 180:
|
||||
# Reverse 180° rotation: (x,y) -> (width-x, height-y)
|
||||
x = crop_w - x
|
||||
y = crop_h - y
|
||||
elif self.rotation_angle == 270:
|
||||
# Reverse 270° clockwise: (x,y) -> (y, height-x)
|
||||
new_x = y
|
||||
new_y = crop_h - x
|
||||
x, y = new_x, new_y
|
||||
|
||||
# Step 3: Reverse crop (add crop offset)
|
||||
if self.crop_rect:
|
||||
crop_x, crop_y, _, _ = self.crop_rect
|
||||
x += crop_x
|
||||
y += crop_y
|
||||
|
||||
return (x, y)
|
||||
|
||||
|
||||
def apply_rotation(self, frame):
|
||||
@@ -1294,6 +1426,64 @@ class VideoEditor:
|
||||
# Keep project view open but switch focus to video editor
|
||||
# Don't destroy the project view window - just let the user switch between them
|
||||
|
||||
def draw_tracking_points(self, canvas, offset_x, offset_y, scale):
|
||||
"""Draw tracking points and computed tracking position on the canvas
|
||||
|
||||
Args:
|
||||
canvas: The canvas to draw on
|
||||
offset_x: X offset of the frame on the canvas
|
||||
offset_y: Y offset of the frame on the canvas
|
||||
scale: Scale factor of the frame on the canvas
|
||||
"""
|
||||
if self.current_frame is None:
|
||||
return
|
||||
|
||||
# Draw tracking points for the current frame (green circles with white border)
|
||||
tracking_points = self.motion_tracker.get_tracking_points_for_frame(self.current_frame)
|
||||
for point in tracking_points:
|
||||
# Transform point from original frame coordinates to display coordinates
|
||||
display_point = self.transform_point(point)
|
||||
if display_point:
|
||||
# Scale and offset the point to match the canvas
|
||||
x = int(offset_x + display_point[0] * scale)
|
||||
y = int(offset_y + display_point[1] * scale)
|
||||
|
||||
# Draw white border
|
||||
cv2.circle(canvas, (x, y), self.tracking_point_radius + 2, (255, 255, 255), 2)
|
||||
# Draw green circle
|
||||
cv2.circle(canvas, (x, y), self.tracking_point_radius, (0, 255, 0), -1)
|
||||
|
||||
# Draw computed tracking position (blue cross) if tracking is enabled
|
||||
if self.motion_tracker.tracking_enabled:
|
||||
interpolated_pos = self.motion_tracker.get_interpolated_position(self.current_frame)
|
||||
if interpolated_pos:
|
||||
# Transform point from original frame coordinates to display coordinates
|
||||
display_point = self.transform_point(interpolated_pos)
|
||||
if display_point:
|
||||
# Scale and offset the point to match the canvas
|
||||
x = int(offset_x + display_point[0] * scale)
|
||||
y = int(offset_y + display_point[1] * scale)
|
||||
|
||||
# Draw blue cross
|
||||
cross_size = 10
|
||||
cv2.line(canvas, (x - cross_size, y), (x + cross_size, y), (255, 0, 0), 2)
|
||||
cv2.line(canvas, (x, y - cross_size), (x, y + cross_size), (255, 0, 0), 2)
|
||||
|
||||
# Add tracking status to the info overlay if tracking is enabled or points exist
|
||||
if self.motion_tracker.tracking_enabled or self.motion_tracker.has_tracking_points():
|
||||
point_count = sum(len(points) for points in self.motion_tracker.tracking_points.values())
|
||||
status_text = f"Motion: {'ON' if self.motion_tracker.tracking_enabled else 'OFF'} ({point_count} pts)"
|
||||
|
||||
# Calculate position for the text (bottom right corner)
|
||||
text_x = self.window_width - 250
|
||||
text_y = self.window_height - (self.TIMELINE_HEIGHT if not self.is_image_mode else 30)
|
||||
|
||||
# Draw text with shadow
|
||||
cv2.putText(canvas, status_text, (text_x + 2, text_y + 2),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2)
|
||||
cv2.putText(canvas, status_text, (text_x, text_y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 1)
|
||||
|
||||
def draw_feedback_message(self, frame):
|
||||
"""Draw feedback message on frame if visible"""
|
||||
if not self.feedback_message or not self.feedback_message_time:
|
||||
@@ -1757,6 +1947,9 @@ class VideoEditor:
|
||||
# Draw timeline
|
||||
self.draw_timeline(canvas)
|
||||
|
||||
# Draw tracking points and tracking position
|
||||
self.draw_tracking_points(canvas, start_x, start_y, scale)
|
||||
|
||||
# Draw progress bar (if visible)
|
||||
self.draw_progress_bar(canvas)
|
||||
|
||||
@@ -1808,6 +2001,32 @@ class VideoEditor:
|
||||
self.crop_start_point = None
|
||||
self.crop_preview_rect = None
|
||||
|
||||
# Handle tracking points (Right-click)
|
||||
if event == cv2.EVENT_RBUTTONDOWN:
|
||||
# Convert display coordinates to original frame coordinates
|
||||
original_point = self.untransform_point((x, y))
|
||||
|
||||
if original_point:
|
||||
# Check if clicking on an existing tracking point to remove it
|
||||
removed = self.motion_tracker.remove_tracking_point(
|
||||
self.current_frame,
|
||||
original_point[0],
|
||||
original_point[1],
|
||||
self.tracking_point_distance
|
||||
)
|
||||
|
||||
if not removed:
|
||||
# If no point was removed, add a new tracking point
|
||||
self.motion_tracker.add_tracking_point(
|
||||
self.current_frame,
|
||||
original_point[0],
|
||||
original_point[1]
|
||||
)
|
||||
|
||||
# Save state when tracking points change
|
||||
self.save_state()
|
||||
self.display_needs_update = True
|
||||
|
||||
# Handle zoom center (Ctrl + click)
|
||||
if flags & cv2.EVENT_FLAG_CTRLKEY and event == cv2.EVENT_LBUTTONDOWN:
|
||||
self.zoom_center = (x, y)
|
||||
@@ -2772,6 +2991,33 @@ class VideoEditor:
|
||||
else:
|
||||
print(f"DEBUG: File '{self.video_path.stem}' does not contain '_edited_'")
|
||||
print("Enter key only overwrites files with '_edited_' in the name. Use 'n' to create new files.")
|
||||
elif key == ord("v") or key == ord("V"):
|
||||
# Motion tracking controls
|
||||
if key == ord("v"):
|
||||
# Toggle motion tracking
|
||||
if self.motion_tracker.tracking_enabled:
|
||||
self.motion_tracker.stop_tracking()
|
||||
print("Motion tracking disabled")
|
||||
else:
|
||||
# Start tracking with current crop and zoom center
|
||||
base_zoom_center = self.zoom_center
|
||||
if not base_zoom_center and self.current_display_frame is not None:
|
||||
# Use frame center if no zoom center is set
|
||||
h, w = self.current_display_frame.shape[:2]
|
||||
base_zoom_center = (w // 2, h // 2)
|
||||
|
||||
self.motion_tracker.start_tracking(
|
||||
self.crop_rect,
|
||||
base_zoom_center
|
||||
)
|
||||
print("Motion tracking enabled")
|
||||
self.save_state()
|
||||
else: # V - Clear all tracking points
|
||||
self.motion_tracker.clear_tracking_points()
|
||||
print("All tracking points cleared")
|
||||
self.save_state()
|
||||
self.display_needs_update = True
|
||||
|
||||
elif key == ord("t"):
|
||||
# Marker looping only for videos
|
||||
if not self.is_image_mode:
|
||||
|
Reference in New Issue
Block a user