diff --git a/croppa/main.py b/croppa/main.py index 979419a..0fc75b8 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -871,6 +871,15 @@ class VideoEditor: # Template matching modes self.template_matching_full_frame = False # Toggle for full frame vs cropped template matching + # Frame difference for interesting point detection + self.frame_difference_threshold = 10.0 # Percentage threshold for frame difference (10% default) + self.frame_difference_gap = 10 # Number of frames between comparisons (default 10) + + # Search state for interesting point detection + self.searching_interesting_point = False + self.search_progress_text = "" + self.search_progress_percent = 0.0 + # Project view mode self.project_view_mode = False self.project_view = None @@ -918,6 +927,8 @@ class VideoEditor: 'tracking_points': {str(k): v for k, v in self.tracking_points.items()}, 'feature_tracker': self.feature_tracker.get_state_dict(), 'template_matching_full_frame': self.template_matching_full_frame, + 'frame_difference_threshold': self.frame_difference_threshold, + 'frame_difference_gap': self.frame_difference_gap, 'templates': [{ 'start_frame': start_frame, 'region': region @@ -1012,6 +1023,16 @@ class VideoEditor: # Load template matching state if 'template_matching_full_frame' in state: self.template_matching_full_frame = state['template_matching_full_frame'] + + # Load frame difference threshold + if 'frame_difference_threshold' in state: + self.frame_difference_threshold = state['frame_difference_threshold'] + print(f"Loaded frame difference threshold: {self.frame_difference_threshold:.1f}%") + + # Load frame difference gap + if 'frame_difference_gap' in state: + self.frame_difference_gap = state['frame_difference_gap'] + print(f"Loaded frame difference gap: {self.frame_difference_gap} frames") # Load simple templates state if 'templates' in state: @@ -1465,6 +1486,116 @@ class VideoEditor: print(f"DEBUG: Jump next tracking to last marker from {current} -> {target}; tracking_frames={tracking_frames}") self.seek_to_frame(target) + def go_to_next_interesting_point(self): + """Go to the next frame where the difference from the previous frame exceeds the threshold""" + if self.is_image_mode: + return + + self.stop_auto_repeat_seek() + + if self.current_frame >= self.total_frames - 1: + print("Already at last frame") + return + + # Store current frame for comparison + current_frame_backup = self.current_frame + current_display_frame = self.current_display_frame.copy() if self.current_display_frame is not None else None + + print(f"Searching for next interesting point from frame {current_frame_backup + 1} with threshold {self.frame_difference_threshold:.1f}% (gap: {self.frame_difference_gap} frames)") + + # Start searching from the next frame + target_frame = current_frame_backup + 1 + search_cancelled = False + frames_checked = 0 + total_frames_to_check = self.total_frames - target_frame + + # Enable search mode for OSD display + self.searching_interesting_point = True + + # Fast search using N-frame gap comparisons + try: + # Performance optimization: sample frames for faster processing + sample_size = (320, 240) # Small sample size for fast difference calculation + update_interval = 10 # Update OSD every 10 comparisons + + # Read the first frame to start comparisons + self.cap.cap.set(cv2.CAP_PROP_POS_FRAMES, current_frame_backup) + ret, base_frame = self.cap.cap.read() + if not ret: + search_cancelled = True + raise Exception("Could not read base frame") + + base_frame_num = current_frame_backup + + while target_frame < self.total_frames: + # Check for cancellation key (less frequent checks for speed) + if target_frame % 10 == 0: + key = cv2.waitKey(1) & 0xFF + if key == ord(";"): + search_cancelled = True + print("Search cancelled") + break + + # Read comparison frame that's N frames ahead + comparison_frame_num = min(target_frame + self.frame_difference_gap, self.total_frames - 1) + self.cap.cap.set(cv2.CAP_PROP_POS_FRAMES, comparison_frame_num) + ret, comparison_frame = self.cap.cap.read() + if not ret: + break + + frames_checked += 1 + + # Fast difference calculation using downsampled frames + base_small = cv2.resize(base_frame, sample_size) + comparison_small = cv2.resize(comparison_frame, sample_size) + + # Calculate frame difference between frames N apart + diff_percentage = self.calculate_frame_difference(base_small, comparison_small) + + # Update OSD less frequently for speed + if frames_checked % update_interval == 0 or diff_percentage >= self.frame_difference_threshold: + progress_percent = (frames_checked / max(1, (self.total_frames - current_frame_backup) // self.frame_difference_gap)) * 100 + self.search_progress_percent = progress_percent + self.search_progress_text = f"Gap search: {base_frame_num}↔{comparison_frame_num} ({diff_percentage:.1f}% change, gap: {self.frame_difference_gap}) - Press ; to cancel" + + # Force display update to show search progress + self.display_needs_update = True + self.display_current_frame() + + # Check if difference exceeds threshold + if diff_percentage >= self.frame_difference_threshold: + # Re-calculate with full resolution for accuracy + full_diff = self.calculate_frame_difference(base_frame, comparison_frame) + print(f"Found interesting point between frames {base_frame_num} and {comparison_frame_num} ({full_diff:.1f}% change)") + self.show_feedback_message(f"Interesting: {full_diff:.1f}% change over {self.frame_difference_gap} frames") + + # Go to the later frame in the comparison + self.current_frame = comparison_frame_num + self.current_display_frame = comparison_frame + break + + # Move base frame forward for next comparison + target_frame += self.frame_difference_gap + base_frame_num = target_frame + base_frame = comparison_frame.copy() if comparison_frame is not None else None + + except Exception as e: + print(f"Error during search: {e}") + search_cancelled = True + + # Disable search mode + self.searching_interesting_point = False + self.search_progress_text = "" + + # If no interesting point found or search was cancelled, go back to original frame + if search_cancelled: + self.seek_to_frame(current_frame_backup) + self.show_feedback_message("Search cancelled") + elif target_frame >= self.total_frames: + print(f"No interesting point found within threshold in remaining frames") + self.seek_to_frame(current_frame_backup) + self.show_feedback_message("No interesting point found") + def _get_previous_tracking_point(self): """Get the tracking point from the previous frame that has tracking points.""" if self.is_image_mode or not self.tracking_points: @@ -1613,6 +1744,48 @@ class VideoEditor: return processed_frame + def calculate_frame_difference(self, frame1, frame2) -> float: + """Calculate percentage difference between two frames""" + if frame1 is None or frame2 is None: + return 0.0 + + try: + # Ensure frames are the same size + if frame1.shape != frame2.shape: + # Resize frame2 to match frame1 + frame2 = cv2.resize(frame2, (frame1.shape[1], frame1.shape[0])) + + # Convert to grayscale for difference calculation + if len(frame1.shape) == 3: + gray1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY) + else: + gray1 = frame1 + + if len(frame2.shape) == 3: + gray2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY) + else: + gray2 = frame2 + + # Calculate absolute difference + diff = cv2.absdiff(gray1, gray2) + + # Calculate percentage of pixels that changed significantly + # Use threshold to ignore minor noise + _, thresh_diff = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) + + # Count changed pixels + changed_pixels = cv2.countNonZero(thresh_diff) + total_pixels = gray1.size + + # Calculate percentage + difference_percentage = (changed_pixels / total_pixels) * 100.0 + + return difference_percentage + + except Exception as e: + print(f"Error calculating frame difference: {e}") + return 0.0 + # --- Motion tracking helpers --- def _get_effective_crop_rect_for_frame(self, frame_number): """Return EFFECTIVE crop_rect in ROTATED frame coords for this frame (applies tracking follow).""" @@ -2998,6 +3171,18 @@ class VideoEditor: 1, ) + # Draw frame difference threshold info + threshold_text = f"Interesting: {self.frame_difference_threshold:.0f}% (gap: {self.frame_difference_gap})" + cv2.putText( + frame, + threshold_text, + (bar_x_start, bar_y - 15), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + (200, 200, 200), + 1, + ) + def display_current_frame(self): """Display the current frame with all overlays""" if self.current_display_frame is None: @@ -3324,6 +3509,45 @@ class VideoEditor: # Draw feedback message (if visible) self.draw_feedback_message(canvas) + # Draw search progress (if searching for interesting point) + if self.searching_interesting_point and self.search_progress_text: + # Draw search progress overlay + height, width = canvas.shape[:2] + + # Background for search progress + text_size = cv2.getTextSize(self.search_progress_text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0] + padding = 10 + bg_x = (width - text_size[0]) // 2 - padding + bg_y = height // 2 - 50 + bg_w = text_size[0] + 2 * padding + bg_h = 30 + + # Semi-transparent background + overlay = canvas.copy() + cv2.rectangle(overlay, (bg_x, bg_y), (bg_x + bg_w, bg_y + bg_h), (0, 0, 0), -1) + cv2.addWeighted(overlay, 0.7, canvas, 0.3, 0, canvas) + + # Border + cv2.rectangle(canvas, (bg_x, bg_y), (bg_x + bg_w, bg_y + bg_h), (255, 255, 0), 2) + + # Text + cv2.putText(canvas, self.search_progress_text, (bg_x + padding, bg_y + 20), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) + + # Progress bar + bar_width = 200 + bar_height = 6 + bar_x = (width - bar_width) // 2 + bar_y = bg_y + bg_h + 5 + + # Background + cv2.rectangle(canvas, (bar_x, bar_y), (bar_x + bar_width, bar_y + bar_height), (100, 100, 100), -1) + + # Progress fill + fill_width = int(bar_width * (self.search_progress_percent / 100.0)) + if fill_width > 0: + cv2.rectangle(canvas, (bar_x, bar_y), (bar_x + fill_width, bar_y + bar_height), (0, 255, 0), -1) + window_title = "Image Editor" if self.is_image_mode else "Video Editor" cv2.imshow(window_title, canvas) @@ -4519,6 +4743,25 @@ class VideoEditor: if not self.is_image_mode and self.cut_end_frame is not None: self.seek_to_frame(self.cut_end_frame) print(f"Jumped to cut end marker at frame {self.cut_end_frame}") + elif key == ord(";"): # ; - Go to next interesting point + if not self.is_image_mode: + self.go_to_next_interesting_point() + elif key == ord("0"): # 0 - Decrease frame difference threshold + self.frame_difference_threshold = max(1.0, self.frame_difference_threshold - 1.0) + print(f"Frame difference threshold: {self.frame_difference_threshold:.1f}%") + self.show_feedback_message(f"Threshold: {self.frame_difference_threshold:.1f}%") + elif key == ord("9"): # 9 - Increase frame difference threshold + self.frame_difference_threshold = min(100.0, self.frame_difference_threshold + 1.0) + print(f"Frame difference threshold: {self.frame_difference_threshold:.1f}%") + self.show_feedback_message(f"Threshold: {self.frame_difference_threshold:.1f}%") + elif key == ord("7"): # 7 - Decrease frame difference gap + self.frame_difference_gap = max(1, self.frame_difference_gap - 1) + print(f"Frame difference gap: {self.frame_difference_gap} frames") + self.show_feedback_message(f"Gap: {self.frame_difference_gap} frames") + elif key == ord("8"): # 8 - Increase frame difference gap + self.frame_difference_gap = min(100, self.frame_difference_gap + 1) + print(f"Frame difference gap: {self.frame_difference_gap} frames") + self.show_feedback_message(f"Gap: {self.frame_difference_gap} frames") elif key == ord("N"): if len(self.video_files) > 1: self.previous_video()