From 986fc810055ff429b3e48b668bd52e4d8ecc25f1 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 16 Oct 2025 14:49:00 -0400 Subject: [PATCH] feat: Enhance Identify Panel with quality filtering and navigation improvements This commit introduces a quality filtering feature in the Identify Panel, allowing users to filter faces based on a quality score (0-100%). The panel now includes a slider for adjusting the quality threshold and displays the current quality percentage. Additionally, navigation functions have been updated to skip to the next or previous face that meets the quality criteria, improving the user experience during identification. The README has been updated to reflect these new features and enhancements. --- README.md | 24 ++- src/core/config.py | 2 +- src/gui/auto_match_panel.py | 33 +++- src/gui/gui_core.py | 231 +++++++++++++++++++--- src/gui/identify_panel.py | 376 +++++++++++++++++++++++++++--------- 5 files changed, 532 insertions(+), 134 deletions(-) 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()