diff --git a/README.md b/README.md index c804b00..d3ea619 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A powerful desktop application for organizing and tagging photos using **state-o - **👤 Person Identification**: Identify and tag people across your photo collection - **🤖 Smart Auto-Matching**: Intelligent face matching with quality scoring and cosine similarity - **🔍 Advanced Search**: Search by people, dates, tags, and folders +- **🎚️ Quality Filtering**: Filter faces by quality score in Identify panel (0-100%) - **🏷️ Tag Management**: Organize photos with hierarchical tags - **⚡ Batch Processing**: Process thousands of photos efficiently - **🔒 Privacy-First**: All data stored locally, no cloud dependencies @@ -114,7 +115,12 @@ python src/photo_tagger.py scan /path/to/photos Open the dashboard and click "Process Photos" to detect faces. ### 3. Identify People -Use the "Identify" panel to tag faces with names. +Use the "Identify" panel to tag faces with names: +- **Quality Filter**: Adjust the quality slider (0-100%) to filter out low-quality faces +- **Unique Faces**: Enable to hide duplicate faces using cosine similarity +- **Date Filters**: Filter faces by date range +- **Navigation**: Browse through unidentified faces with prev/next buttons +- **Photo Viewer**: Click the photo icon to view the full source image ### 4. Search Use the "Search" panel to find photos by people, dates, or tags. @@ -133,11 +139,12 @@ Use the dashboard to configure DeepFace settings: ### Manual Configuration Edit `src/core/config.py` to customize: -- `DEEPFACE_DETECTOR_BACKEND` - Face detection model -- `DEEPFACE_MODEL_NAME` - Recognition model -- `DEFAULT_FACE_TOLERANCE` - Similarity tolerance (0.4 for DeepFace) -- `DEEPFACE_SIMILARITY_THRESHOLD` - Minimum similarity percentage -- Batch sizes and quality thresholds +- `DEEPFACE_DETECTOR_BACKEND` - Face detection model (default: `retinaface`) +- `DEEPFACE_MODEL_NAME` - Recognition model (default: `ArcFace`) +- `DEFAULT_FACE_TOLERANCE` - Similarity tolerance (default: `0.6` for DeepFace) +- `DEEPFACE_SIMILARITY_THRESHOLD` - Minimum similarity percentage (default: `60`) +- `MIN_FACE_QUALITY` - Minimum face quality score (default: `0.3`) +- Batch sizes and other processing thresholds --- @@ -169,6 +176,11 @@ python tests/test_deepface_gui.py - ✅ Multiple detector/model options (GUI selectable) - ✅ Cosine similarity matching - ✅ Face confidence scores and quality metrics +- ✅ Quality filtering in Identify panel (adjustable 0-100%) +- ✅ Unique faces detection (cosine similarity-based deduplication) +- ✅ Enhanced thumbnail display (100x100px) +- ✅ External system photo viewer integration +- ✅ Improved auto-match save responsiveness - ✅ Metadata display (detector/model info in GUI) - ✅ Enhanced accuracy and reliability - ✅ Comprehensive test coverage (20/20 tests passing) diff --git a/src/core/config.py b/src/core/config.py index 746486d..20b7fc0 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -27,7 +27,7 @@ DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"] DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"] # Face tolerance/threshold settings (adjusted for DeepFace) -DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6 for face_recognition) +DEFAULT_FACE_TOLERANCE = 0.6 # Default tolerance for face matching DEEPFACE_SIMILARITY_THRESHOLD = 60 # Minimum similarity percentage (0-100) # Legacy settings (kept for compatibility until Phase 3 migration) diff --git a/src/gui/auto_match_panel.py b/src/gui/auto_match_panel.py index b57c3ae..e802060 100644 --- a/src/gui/auto_match_panel.py +++ b/src/gui/auto_match_panel.py @@ -657,10 +657,17 @@ class AutoMatchPanel: matched_id = active_ids[self.current_matched_index] matches_for_this_person = self.matches_by_matched[matched_id] + # Show saving message + self.components['save_btn'].config(text="💾 Saving...", state='disabled') + self.main_frame.update_idletasks() + # Initialize identified faces for this person if not exists if matched_id not in self.identified_faces_per_person: self.identified_faces_per_person[matched_id] = set() + # Count changes for feedback + changes_made = 0 + with self.db.get_db_connection() as conn: cursor = conn.cursor() @@ -682,6 +689,7 @@ class AutoMatchPanel: print(f"✅ Identified as: {person_name}") self.identified_count += 1 + changes_made += 1 else: # Face is unchecked - check if it was previously identified for this person if match['unidentified_id'] in self.identified_faces_per_person[matched_id]: @@ -695,13 +703,21 @@ class AutoMatchPanel: self.identified_faces_per_person[matched_id].discard(match['unidentified_id']) print(f"❌ Unidentified: {match['unidentified_filename']}") + changes_made += 1 - # Update person encodings for all affected persons - for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']): - self.face_processor.update_person_encodings(person_id) - + # Commit changes first conn.commit() + # Update person encodings for all affected persons (outside of transaction) + # This can be slow, so we show progress + affected_person_ids = set(match['person_id'] for match in matches_for_this_person if match['person_id']) + if affected_person_ids: + self.components['save_btn'].config(text="💾 Updating encodings...") + self.main_frame.update_idletasks() + + for person_id in affected_person_ids: + self.face_processor.update_person_encodings(person_id) + # After saving, set original states to the current UI states current_snapshot = {} for match, var in zip(matches_for_this_person, self.match_vars): @@ -709,6 +725,15 @@ class AutoMatchPanel: current_snapshot[unique_key] = var.get() self.checkbox_states_per_person[matched_id] = dict(current_snapshot) self.original_checkbox_states_per_person[matched_id] = dict(current_snapshot) + + # Show completion message + if changes_made > 0: + print(f"✅ Saved {changes_made} change(s)") + + # Restore button text and update state + self._update_save_button_text() + self.components['save_btn'].config(state='normal') + self.main_frame.update_idletasks() def _go_back(self): """Go back to the previous person""" diff --git a/src/gui/gui_core.py b/src/gui/gui_core.py index 2c372f7..df8c854 100644 --- a/src/gui/gui_core.py +++ b/src/gui/gui_core.py @@ -76,41 +76,18 @@ class GUICore: import platform def open_source_photo(event): - """Open the source photo in a properly sized window""" + """Open the source photo in the system's default photo viewer""" try: system = platform.system() if system == "Windows": - # Try to open with a specific image viewer that supports window sizing - try: - subprocess.run(["mspaint", photo_path], check=False) - except: - os.startfile(photo_path) + # Windows - use default photo viewer + os.startfile(photo_path) elif system == "Darwin": # macOS - # Use Preview with specific window size - subprocess.run(["open", "-a", "Preview", photo_path]) + # macOS - use default Preview or Photos app + subprocess.Popen(["open", photo_path]) else: # Linux and others - # Try common image viewers with window sizing options - viewers_to_try = [ - ["eog", "--new-window", photo_path], # Eye of GNOME - ["gwenview", photo_path], # KDE image viewer - ["feh", "--geometry", "800x600", photo_path], # feh with specific size - ["gimp", photo_path], # GIMP - ["xdg-open", photo_path] # Fallback to default - ] - - opened = False - for viewer_cmd in viewers_to_try: - try: - result = subprocess.run(viewer_cmd, check=False, capture_output=True) - if result.returncode == 0: - opened = True - break - except: - continue - - if not opened: - # Final fallback - subprocess.run(["xdg-open", photo_path]) + # Linux - use xdg-open which opens with default viewer + subprocess.Popen(["xdg-open", photo_path]) except Exception as e: print(f"❌ Could not open photo: {e}") @@ -855,4 +832,196 @@ class GUICore: listbox.bind('', on_listbox_key) listbox.bind('', on_listbox_click) - return entry, entry_var, listbox \ No newline at end of file + return entry, entry_var, listbox + + def show_image_viewer(self, parent, photo_path: str): + """Show an in-app image viewer dialog with zoom functionality""" + import tkinter as tk + from tkinter import ttk + from PIL import Image, ImageTk + + try: + # Create viewer window + viewer = tk.Toplevel(parent) + viewer.title(f"Photo Viewer - {os.path.basename(photo_path)}") + + # Get screen dimensions + screen_width = viewer.winfo_screenwidth() + screen_height = viewer.winfo_screenheight() + + # Load original image + original_img = Image.open(photo_path) + img_width, img_height = original_img.size + + # Calculate optimal window size (90% of screen, but respecting aspect ratio) + max_width = int(screen_width * 0.9) + max_height = int(screen_height * 0.9) + + # Calculate initial scaling to fit image while maintaining aspect ratio + width_ratio = max_width / img_width + height_ratio = max_height / img_height + initial_scale = min(width_ratio, height_ratio, 1.0) # Don't upscale initially + + # Window size based on initial scale + window_width = min(int(img_width * initial_scale) + 40, max_width) + window_height = min(int(img_height * initial_scale) + 150, max_height) + + # Center window on screen + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + viewer.geometry(f"{window_width}x{window_height}+{x}+{y}") + + # Create main frame with padding + main_frame = ttk.Frame(viewer, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Zoom controls frame + zoom_frame = ttk.Frame(main_frame) + zoom_frame.pack(fill=tk.X, pady=(0, 5)) + + # Create canvas with scrollbars for large images + canvas_frame = ttk.Frame(main_frame) + canvas_frame.pack(fill=tk.BOTH, expand=True) + + # Canvas + canvas = tk.Canvas(canvas_frame, bg='gray25', highlightthickness=0) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Scrollbars + v_scrollbar = ttk.Scrollbar(canvas_frame, orient='vertical', command=canvas.yview) + v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + h_scrollbar = ttk.Scrollbar(main_frame, orient='horizontal', command=canvas.xview) + h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) + + canvas.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set) + + # Zoom state + current_zoom = initial_scale + zoom_levels = [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0] + + # Info label + info_label = ttk.Label(main_frame, text="", font=("Arial", 9)) + info_label.pack(pady=(5, 0)) + + def update_image(zoom_scale): + """Update the displayed image with the given zoom scale""" + nonlocal current_zoom + current_zoom = zoom_scale + + # Calculate new dimensions + new_width = int(img_width * zoom_scale) + new_height = int(img_height * zoom_scale) + + # Resize image + img_resized = original_img.resize((new_width, new_height), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img_resized) + + # Clear canvas and create new image + canvas.delete("all") + canvas.create_image(new_width // 2, new_height // 2, + image=photo, anchor=tk.CENTER, tags="image") + canvas.image = photo # Keep reference + + # Update scroll region + canvas.configure(scrollregion=(0, 0, new_width, new_height)) + + # Update info label + info_text = f"Image: {os.path.basename(photo_path)} | " \ + f"Original: {img_width}×{img_height}px | " \ + f"Zoom: {zoom_scale*100:.0f}% | " \ + f"Display: {new_width}×{new_height}px" + info_label.config(text=info_text) + + # Update zoom buttons state + zoom_in_btn.config(state='normal' if zoom_scale < zoom_levels[-1] else 'disabled') + zoom_out_btn.config(state='normal' if zoom_scale > zoom_levels[0] else 'disabled') + zoom_reset_btn.config(state='normal' if zoom_scale != 1.0 else 'disabled') + + def zoom_in(): + """Zoom in to the next level""" + next_zoom = min([z for z in zoom_levels if z > current_zoom], default=current_zoom) + if next_zoom > current_zoom: + update_image(next_zoom) + + def zoom_out(): + """Zoom out to the previous level""" + prev_zoom = max([z for z in zoom_levels if z < current_zoom], default=current_zoom) + if prev_zoom < current_zoom: + update_image(prev_zoom) + + def zoom_fit(): + """Zoom to fit window""" + update_image(initial_scale) + + def zoom_100(): + """Zoom to 100% (actual size)""" + update_image(1.0) + + def on_mouse_wheel(event): + """Handle mouse wheel zoom""" + # Get mouse position relative to canvas + canvas_x = canvas.canvasx(event.x) + canvas_y = canvas.canvasy(event.y) + + # Determine zoom direction + if event.delta > 0 or event.num == 4: # Zoom in + zoom_in() + elif event.delta < 0 or event.num == 5: # Zoom out + zoom_out() + + # Zoom control buttons + ttk.Label(zoom_frame, text="Zoom:", font=("Arial", 9, "bold")).pack(side=tk.LEFT, padx=(0, 5)) + + zoom_out_btn = ttk.Button(zoom_frame, text="−", width=3, command=zoom_out) + zoom_out_btn.pack(side=tk.LEFT, padx=2) + + zoom_in_btn = ttk.Button(zoom_frame, text="+", width=3, command=zoom_in) + zoom_in_btn.pack(side=tk.LEFT, padx=2) + + zoom_reset_btn = ttk.Button(zoom_frame, text="Fit", width=5, command=zoom_fit) + zoom_reset_btn.pack(side=tk.LEFT, padx=2) + + zoom_100_btn = ttk.Button(zoom_frame, text="100%", width=5, command=zoom_100) + zoom_100_btn.pack(side=tk.LEFT, padx=2) + + ttk.Label(zoom_frame, text="(Use mouse wheel to zoom)", + font=("Arial", 8), foreground="gray").pack(side=tk.LEFT, padx=(10, 0)) + + # Close button + button_frame = ttk.Frame(main_frame) + button_frame.pack(pady=(5, 0)) + + close_btn = ttk.Button(button_frame, text="Close", command=viewer.destroy) + close_btn.pack() + + # Bind mouse wheel events (different on different platforms) + canvas.bind("", on_mouse_wheel) # Windows/Mac + canvas.bind("", on_mouse_wheel) # Linux scroll up + canvas.bind("", on_mouse_wheel) # Linux scroll down + + # Keyboard shortcuts + viewer.bind('', lambda e: viewer.destroy()) + viewer.bind('', lambda e: viewer.destroy()) + viewer.bind('', lambda e: zoom_in()) + viewer.bind('', lambda e: zoom_out()) + viewer.bind('', lambda e: zoom_in()) # + without shift + viewer.bind('', lambda e: zoom_in()) # Numpad + + viewer.bind('', lambda e: zoom_out()) # Numpad - + viewer.bind('f', lambda e: zoom_fit()) + viewer.bind('F', lambda e: zoom_fit()) + viewer.bind('1', lambda e: zoom_100()) + + # Initialize image at initial scale + update_image(initial_scale) + + # Make modal (wait for window to be visible before grabbing) + viewer.transient(parent) + viewer.update_idletasks() # Ensure window is rendered + viewer.deiconify() # Ensure window is shown + viewer.wait_visibility() # Wait for window to become visible + viewer.grab_set() # Now safe to grab focus + + except Exception as e: + import tkinter.messagebox as messagebox + messagebox.showerror("Error", f"Could not display image:\n{e}", parent=parent) \ No newline at end of file diff --git a/src/gui/identify_panel.py b/src/gui/identify_panel.py index c018cdf..0232441 100644 --- a/src/gui/identify_panel.py +++ b/src/gui/identify_panel.py @@ -86,6 +86,9 @@ class IdentifyPanel: self.components['date_processed_from_var'] = tk.StringVar(value="") self.components['date_processed_to_var'] = tk.StringVar(value="") + # Quality filter variable (0-100%) + self.components['quality_filter_var'] = tk.IntVar(value=0) + # Date filter controls date_filter_frame = ttk.LabelFrame(self.main_frame, text="Filter", padding="5") date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) @@ -148,20 +151,99 @@ class IdentifyPanel: self.components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to) self.components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) + # Quality filter (third row) + ttk.Label(date_filter_frame, text="Min quality:").grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) + + # Quality slider frame + quality_slider_frame = ttk.Frame(date_filter_frame) + quality_slider_frame.grid(row=2, column=1, columnspan=5, sticky=tk.W, pady=(10, 0)) + + # Quality slider + self.components['quality_slider'] = tk.Scale( + quality_slider_frame, + from_=0, + to=100, + orient=tk.HORIZONTAL, + variable=self.components['quality_filter_var'], + length=250, + tickinterval=25, + resolution=5, + showvalue=0 + ) + self.components['quality_slider'].pack(side=tk.LEFT, padx=(0, 10)) + + # Quality value label + self.components['quality_value_label'] = ttk.Label( + quality_slider_frame, + text=f"{self.components['quality_filter_var'].get()}%", + font=("Arial", 10, "bold"), + width=6 + ) + self.components['quality_value_label'].pack(side=tk.LEFT, padx=(0, 5)) + + # Update label when slider changes + def update_quality_label(*args): + quality = self.components['quality_filter_var'].get() + self.components['quality_value_label'].config(text=f"{quality}%") + + # Color code the label + if quality == 0: + color = "gray" + elif quality < 30: + color = "orange" + elif quality < 60: + color = "#E67E22" # orange + else: + color = "green" + self.components['quality_value_label'].config(foreground=color) + + self.components['quality_filter_var'].trace('w', update_quality_label) + update_quality_label() # Initialize + + # Quality filter help text + ttk.Label( + quality_slider_frame, + text="(0 = all faces)", + font=("Arial", 8), + foreground="gray" + ).pack(side=tk.LEFT) + # Unique checkbox under the filter frame def on_unique_change(): """Handle unique faces checkbox change - filter main face list like old implementation""" if self.components['unique_var'].get(): + # Check if identification has started + if not self.current_faces: + messagebox.showinfo("Start Identification First", + "Please click 'Start Identification' before applying the unique faces filter.") + self.components['unique_var'].set(False) + return + # Show progress message print("🔄 Applying unique faces filter...") self.main_frame.update() # Update UI to show the message + # Store original count + original_count = len(self.current_faces) + # Apply unique faces filtering to the main face list try: self.current_faces = self._filter_unique_faces_from_list(self.current_faces) - print(f"✅ Filter applied: {len(self.current_faces)} unique faces remaining") + filtered_count = len(self.current_faces) + removed_count = original_count - filtered_count + + print(f"✅ Filter applied: {filtered_count} unique faces remaining ({removed_count} duplicates hidden)") + + if removed_count > 0: + messagebox.showinfo("Unique Faces Filter Applied", + f"Showing {filtered_count} unique faces.\n" + f"Hidden {removed_count} duplicate faces (≥60% match confidence).") + else: + messagebox.showinfo("No Duplicates Found", + "All faces appear to be unique - no duplicates to hide.") except Exception as e: print(f"⚠️ Error applying filter: {e}") + messagebox.showerror("Filter Error", f"Error applying unique faces filter:\n{e}") # Revert checkbox state self.components['unique_var'].set(False) return @@ -409,7 +491,7 @@ class IdentifyPanel: date_processed_from = self.components['date_processed_from_var'].get().strip() or None date_processed_to = self.components['date_processed_to_var'].get().strip() or None - # Get unidentified faces + # Get unidentified faces (without quality filter - we'll filter in display) self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, date_processed_from, date_processed_to) @@ -469,6 +551,9 @@ class IdentifyPanel: query += ' AND DATE(p.date_added) <= ?' params.append(date_processed_to) + # Order by quality score (highest first) to show best quality faces first + query += ' ORDER BY f.quality_score DESC' + query += ' LIMIT ?' params.append(batch_size) @@ -552,15 +637,23 @@ class IdentifyPanel: for j, face_id2 in enumerate(face_id_list): if i != j: try: - import face_recognition + import numpy as np encoding1 = face_encodings[face_id1] encoding2 = face_encodings[face_id2] - # Calculate distance - distance = face_recognition.face_distance([encoding1], encoding2)[0] + # Calculate distance using cosine similarity (DeepFace compatible) + # Normalize encodings + encoding1_norm = encoding1 / np.linalg.norm(encoding1) + encoding2_norm = encoding2 / np.linalg.norm(encoding2) + + # Cosine distance = 1 - cosine similarity + cosine_similarity = np.dot(encoding1_norm, encoding2_norm) + distance = 1.0 - cosine_similarity + face_distances[(face_id1, face_id2)] = distance - except Exception: + except Exception as e: # If calculation fails, assume no match + print(f"⚠️ Error calculating distance between faces {face_id1} and {face_id2}: {e}") face_distances[(face_id1, face_id2)] = 1.0 # Apply unique faces filtering @@ -995,40 +1088,40 @@ class IdentifyPanel: face_crop_path = self.face_processor._extract_face_crop(photo_path, location, similar_face_id) if face_crop_path and os.path.exists(face_crop_path): image = Image.open(face_crop_path) - image.thumbnail((50, 50), Image.Resampling.LANCZOS) + image.thumbnail((100, 100), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(image) # Create a canvas for the face image to allow photo icon drawing - face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0) + face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0) face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_image(25, 25, image=photo, anchor=tk.CENTER) + face_canvas.create_image(50, 50, image=photo, anchor=tk.CENTER) face_canvas.image = photo # Keep reference # Add photo icon exactly at the image's top-right corner - self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15, + self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=20, face_x=0, face_y=0, - face_width=50, face_height=50, - canvas_width=50, canvas_height=50) + face_width=100, face_height=100, + canvas_width=100, canvas_height=100) else: # Face crop extraction failed or file doesn't exist print(f"Face crop not available for face {similar_face_id}: {face_crop_path}") # Create placeholder canvas - face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') + face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray') face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_text(25, 25, text="No\nImage", fill="gray", font=("Arial", 8)) + face_canvas.create_text(50, 50, text="No\nImage", fill="gray", font=("Arial", 10)) else: # Photo path not found in cache print(f"Photo path not found for photo_id {photo_id} in cache") # Create placeholder canvas - face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') + face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray') face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_text(25, 25, text="No\nPath", fill="gray", font=("Arial", 8)) + face_canvas.create_text(50, 50, text="No\nPath", fill="gray", font=("Arial", 10)) except Exception as e: print(f"Error creating similar face widget for face {similar_face_id}: {e}") # Create placeholder canvas on error - face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') + face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray') face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_text(25, 25, text="Error", fill="red", font=("Arial", 8)) + face_canvas.create_text(50, 50, text="Error", fill="red", font=("Arial", 10)) # Confidence label with color coding and description confidence_text = f"{confidence_pct:.0f}% {confidence_desc}" @@ -1158,70 +1251,104 @@ class IdentifyPanel: self._save_identification(face_id, person_data) def _go_back(self): - """Go back to the previous face""" - if self.current_face_index > 0: - # Validate navigation (check for unsaved changes) - validation_result = self._validate_navigation() - if validation_result == 'cancel': - return # Cancel navigation - elif validation_result == 'save_and_continue': - # Save the current identification before proceeding - if self.current_faces and self.current_face_index < len(self.current_faces): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - if first_name and last_name and date_of_birth: - person_data = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': self.components['middle_name_var'].get().strip(), - 'maiden_name': self.components['maiden_name_var'].get().strip(), - 'date_of_birth': date_of_birth - } - self.face_person_names[face_id] = person_data - self.face_status[face_id] = 'identified' - elif validation_result == 'discard_and_continue': - # Clear the form but don't save - self._clear_form() + """Go back to the previous face that meets quality criteria""" + # Validate navigation (check for unsaved changes) + validation_result = self._validate_navigation() + if validation_result == 'cancel': + return # Cancel navigation + elif validation_result == 'save_and_continue': + # Save the current identification before proceeding + if self.current_faces and self.current_face_index < len(self.current_faces): + face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + if first_name and last_name and date_of_birth: + person_data = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': self.components['middle_name_var'].get().strip(), + 'maiden_name': self.components['maiden_name_var'].get().strip(), + 'date_of_birth': date_of_birth + } + self.face_person_names[face_id] = person_data + self.face_status[face_id] = 'identified' + elif validation_result == 'discard_and_continue': + # Clear the form but don't save + self._clear_form() + + # Get quality filter + min_quality = self.components['quality_filter_var'].get() + min_quality_score = min_quality / 100.0 + + # Find previous qualifying face + found = False + + for i in range(self.current_face_index - 1, -1, -1): + _, _, _, _, _, _, quality, _, _ = self.current_faces[i] + quality_score = quality if quality is not None else 0.0 - self.current_face_index -= 1 - self._update_current_face() - self._update_button_states() - - def _go_next(self): - """Go to the next face""" - if self.current_face_index < len(self.current_faces) - 1: - # Validate navigation (check for unsaved changes) - validation_result = self._validate_navigation() - if validation_result == 'cancel': - return # Cancel navigation - elif validation_result == 'save_and_continue': - # Save the current identification before proceeding - if self.current_faces and self.current_face_index < len(self.current_faces): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - if first_name and last_name and date_of_birth: - person_data = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': self.components['middle_name_var'].get().strip(), - 'maiden_name': self.components['maiden_name_var'].get().strip(), - 'date_of_birth': date_of_birth - } - self.face_person_names[face_id] = person_data - self.face_status[face_id] = 'identified' - elif validation_result == 'discard_and_continue': - # Clear the form but don't save - self._clear_form() - - self.current_face_index += 1 + if quality_score >= min_quality_score: + self.current_face_index = i + found = True + break + + if found: self._update_current_face() self._update_button_states() else: - # Check if there are more faces to load + messagebox.showinfo("No Previous Face", + f"No previous face with quality >= {min_quality}%.") + + def _go_next(self): + """Go to the next face that meets quality criteria""" + # Validate navigation (check for unsaved changes) + validation_result = self._validate_navigation() + if validation_result == 'cancel': + return # Cancel navigation + elif validation_result == 'save_and_continue': + # Save the current identification before proceeding + if self.current_faces and self.current_face_index < len(self.current_faces): + face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + if first_name and last_name and date_of_birth: + person_data = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': self.components['middle_name_var'].get().strip(), + 'maiden_name': self.components['maiden_name_var'].get().strip(), + 'date_of_birth': date_of_birth + } + self.face_person_names[face_id] = person_data + self.face_status[face_id] = 'identified' + elif validation_result == 'discard_and_continue': + # Clear the form but don't save + self._clear_form() + + # Get quality filter + min_quality = self.components['quality_filter_var'].get() + min_quality_score = min_quality / 100.0 + + # Find next qualifying face + original_index = self.current_face_index + found = False + + for i in range(self.current_face_index + 1, len(self.current_faces)): + _, _, _, _, _, _, quality, _, _ = self.current_faces[i] + quality_score = quality if quality is not None else 0.0 + + if quality_score >= min_quality_score: + self.current_face_index = i + found = True + break + + if found: + self._update_current_face() + self._update_button_states() + else: + # No more qualifying faces in current batch, try to load more self._load_more_faces() def _load_more_faces(self): @@ -1403,22 +1530,48 @@ class IdentifyPanel: self.components['face_canvas'].delete("all") def _apply_date_filters(self): - """Apply date filters and reload faces""" - # Get current filter values - date_from = self.components['date_from_var'].get().strip() or None - date_to = self.components['date_to_var'].get().strip() or None - date_processed_from = self.components['date_processed_from_var'].get().strip() or None - date_processed_to = self.components['date_processed_to_var'].get().strip() or None + """Apply date and quality filters by jumping to next qualifying face""" + # Get quality filter + min_quality = self.components['quality_filter_var'].get() - # Get batch size - try: - batch_size = int(self.components['batch_var'].get().strip()) - except Exception: - batch_size = DEFAULT_BATCH_SIZE + # If we have faces loaded, find the next face that meets quality criteria + if self.current_faces: + # Start from current position and find next qualifying face + self._find_next_qualifying_face(min_quality) + else: + # No faces loaded, need to reload + date_from = self.components['date_from_var'].get().strip() or None + date_to = self.components['date_to_var'].get().strip() or None + date_processed_from = self.components['date_processed_from_var'].get().strip() or None + date_processed_to = self.components['date_processed_to_var'].get().strip() or None + + # Get batch size + try: + batch_size = int(self.components['batch_var'].get().strip()) + except Exception: + batch_size = DEFAULT_BATCH_SIZE + + # Reload faces with new filters + self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, + date_processed_from, date_processed_to) + + if not self.current_faces: + messagebox.showinfo("No Faces Found", "No unidentified faces found with the current filters.") + return + + # Reset state + self.current_face_index = 0 + self.face_status = {} + self.face_person_names = {} + self.face_selection_states = {} + + # Pre-fetch data + self.identify_data_cache = self._prefetch_identify_data(self.current_faces) + + # Find first qualifying face + self._find_next_qualifying_face(min_quality) - # Reload faces with new filters - self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, - date_processed_from, date_processed_to) + self.is_active = True if not self.current_faces: messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") @@ -1439,6 +1592,45 @@ class IdentifyPanel: self.is_active = True + def _find_next_qualifying_face(self, min_quality: int): + """Find the next face that meets the quality criteria""" + if not self.current_faces: + return + + min_quality_score = min_quality / 100.0 # Convert percentage to 0-1 scale + + # Search from current index forward + original_index = self.current_face_index + found = False + + for i in range(self.current_face_index, len(self.current_faces)): + _, _, _, _, _, _, quality, _, _ = self.current_faces[i] + quality_score = quality if quality is not None else 0.0 + + if quality_score >= min_quality_score: + self.current_face_index = i + found = True + break + + # If not found forward, search from beginning + if not found: + for i in range(0, original_index): + _, _, _, _, _, _, quality, _, _ = self.current_faces[i] + quality_score = quality if quality is not None else 0.0 + + if quality_score >= min_quality_score: + self.current_face_index = i + found = True + break + + if found: + self._update_current_face() + self._update_button_states() + else: + messagebox.showinfo("No Qualifying Faces", + f"No faces found with quality >= {min_quality}%.\n" + f"Lower the quality filter to see more faces.") + def _open_date_picker(self, date_var: tk.StringVar): """Open date picker dialog""" current_date = date_var.get()