from typing import List, Dict, Tuple, Optional, NamedTuple class TrackingPoint(NamedTuple): """Represents a tracking point with both original and display coordinates""" original: Tuple[float, float] # Original frame coordinates (x, y) display: Optional[Tuple[float, float]] = None # Display coordinates after transformation (x, y) def __str__(self): if self.display: return f"TrackingPoint(orig={self.original}, display={self.display})" return f"TrackingPoint(orig={self.original})" class MotionTracker: """Handles motion tracking for crop and pan operations""" def __init__(self): self.tracking_points = {} # {frame_number: [TrackingPoint, ...]} self.tracking_enabled = False self.base_crop_rect = None # Original crop rect when tracking started self.base_zoom_center = None # Original zoom center when tracking started def add_tracking_point(self, frame_number: int, x: float, y: float): """Add a tracking point at the specified frame and coordinates Args: frame_number: The frame number to add the point to x: Original x coordinate y: Original y coordinate """ if frame_number not in self.tracking_points: self.tracking_points[frame_number] = [] # Store only the original coordinates - display coordinates will be calculated fresh each time point = TrackingPoint(original=(float(x), float(y))) print(f"Adding tracking point: {point}") self.tracking_points[frame_number].append(point) def remove_tracking_point(self, frame_number: int, x: float, y: float, radius: int = 50): """Remove a tracking point by frame and proximity to x,y""" if frame_number not in self.tracking_points: return False points = self.tracking_points[frame_number] for i, point in enumerate(points): px, py = point.original # Calculate distance between points distance = ((px - x) ** 2 + (py - y) ** 2) ** 0.5 if distance <= radius: print(f"Removing tracking point: {point}") del points[i] if not points: del self.tracking_points[frame_number] return True return False def clear_tracking_points(self): """Clear all tracking points""" self.tracking_points.clear() def get_tracking_points_for_frame(self, frame_number: int) -> List[TrackingPoint]: """Get all tracking points for a specific frame""" return self.tracking_points.get(frame_number, []) def has_tracking_points(self) -> bool: """Check if any tracking points exist""" return bool(self.tracking_points) def get_interpolated_position(self, frame_number: int) -> Optional[Tuple[float, float]]: """Get interpolated position for a frame based on tracking points""" if not self.tracking_points: return None # Get all frames with tracking points frames = sorted(self.tracking_points.keys()) if not frames: return None # If we have a point at this exact frame, return it if frame_number in self.tracking_points: points = self.tracking_points[frame_number] if points: # Return average of all points at this frame avg_x = sum(p.original[0] for p in points) / len(points) avg_y = sum(p.original[1] for p in points) / len(points) return (avg_x, avg_y) # If frame is before first tracking point if frame_number < frames[0]: points = self.tracking_points[frames[0]] if points: avg_x = sum(p.original[0] for p in points) / len(points) avg_y = sum(p.original[1] for p in points) / len(points) return (avg_x, avg_y) # If frame is after last tracking point if frame_number > frames[-1]: points = self.tracking_points[frames[-1]] if points: avg_x = sum(p.original[0] for p in points) / len(points) avg_y = sum(p.original[1] for p in points) / len(points) return (avg_x, avg_y) # Find the two frames to interpolate between for i in range(len(frames) - 1): if frames[i] <= frame_number <= frames[i + 1]: frame1, frame2 = frames[i], frames[i + 1] points1 = self.tracking_points[frame1] points2 = self.tracking_points[frame2] if not points1 or not points2: continue # Get average positions for each frame avg_x1 = sum(p.original[0] for p in points1) / len(points1) avg_y1 = sum(p.original[1] for p in points1) / len(points1) avg_x2 = sum(p.original[0] for p in points2) / len(points2) avg_y2 = sum(p.original[1] for p in points2) / len(points2) # Linear interpolation t = (frame_number - frame1) / (frame2 - frame1) interp_x = avg_x1 + t * (avg_x2 - avg_x1) interp_y = avg_y1 + t * (avg_y2 - avg_y1) return (interp_x, interp_y) return None def get_tracking_offset(self, frame_number: int) -> Tuple[float, float]: """Get the offset to center the crop on the tracked point""" if not self.tracking_enabled: print(f"get_tracking_offset: tracking not enabled, returning (0,0)") return (0.0, 0.0) if not self.base_zoom_center: print(f"get_tracking_offset: no base_zoom_center, returning (0,0)") return (0.0, 0.0) current_pos = self.get_interpolated_position(frame_number) if not current_pos: print(f"get_tracking_offset: no interpolated position for frame {frame_number}, returning (0,0)") return (0.0, 0.0) # Calculate offset to center the crop on the tracked point # The offset should move the display so the tracked point stays centered offset_x = current_pos[0] - self.base_zoom_center[0] offset_y = current_pos[1] - self.base_zoom_center[1] print(f"get_tracking_offset: frame={frame_number}, base={self.base_zoom_center}, current={current_pos}, offset=({offset_x}, {offset_y})") return (offset_x, offset_y) def start_tracking(self, base_crop_rect: Tuple[int, int, int, int], base_zoom_center: Tuple[int, int]): """Start motion tracking with base positions""" self.tracking_enabled = True self.base_crop_rect = base_crop_rect print(f"start_tracking: base_crop_rect={base_crop_rect}, base_zoom_center={base_zoom_center}") # If no base_zoom_center is provided, use the center of the crop rect if base_zoom_center is None and base_crop_rect is not None: x, y, w, h = base_crop_rect self.base_zoom_center = (x + w//2, y + h//2) print(f"start_tracking: using crop center as base_zoom_center: {self.base_zoom_center}") else: self.base_zoom_center = base_zoom_center print(f"start_tracking: using provided base_zoom_center: {self.base_zoom_center}") def stop_tracking(self): """Stop motion tracking""" self.tracking_enabled = False self.base_crop_rect = None self.base_zoom_center = None def to_dict(self) -> Dict: """Convert to dictionary for serialization""" # Convert TrackingPoint objects to tuples for serialization serialized_points = {} for frame_num, points in self.tracking_points.items(): # Store only the original coordinates for serialization serialized_points[frame_num] = [p.original for p in points] return { 'tracking_points': serialized_points, 'tracking_enabled': self.tracking_enabled, 'base_crop_rect': self.base_crop_rect, 'base_zoom_center': self.base_zoom_center } def from_dict(self, data: Dict): """Load from dictionary for deserialization""" # Convert string keys back to integers for tracking_points tracking_points_data = data.get('tracking_points', {}) self.tracking_points = {} for frame_str, points in tracking_points_data.items(): frame_num = int(frame_str) # Convert string key to integer # Convert tuples to TrackingPoint objects self.tracking_points[frame_num] = [TrackingPoint(original=p) for p in points] self.tracking_enabled = data.get('tracking_enabled', False) self.base_crop_rect = data.get('base_crop_rect', None) self.base_zoom_center = data.get('base_zoom_center', None)