From 38f931a7a72458961b80309235c83ec13f88c5ff Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 3 Oct 2025 15:39:09 -0400 Subject: [PATCH] Add AutoMatchGUI for face identification in PhotoTagger This commit introduces the AutoMatchGUI class, enabling users to automatically identify and match unidentified faces against already identified ones within the PhotoTagger application. The new GUI provides a user-friendly interface for displaying potential matches, selecting identified faces, and saving changes. It integrates seamlessly with existing components, enhancing the overall functionality of the application. The PhotoTagger class is updated to utilize this new feature, streamlining the face identification process. Additionally, relevant documentation has been updated to reflect these changes. --- auto_match_gui.py | 894 +++++++++++++++++++++++++++++++++++ photo_tagger.py | 1146 +-------------------------------------------- 2 files changed, 897 insertions(+), 1143 deletions(-) create mode 100644 auto_match_gui.py diff --git a/auto_match_gui.py b/auto_match_gui.py new file mode 100644 index 0000000..52817d0 --- /dev/null +++ b/auto_match_gui.py @@ -0,0 +1,894 @@ +#!/usr/bin/env python3 +""" +Auto-match face identification GUI implementation for PunimTag +""" + +import os +import tkinter as tk +from tkinter import ttk, messagebox +from PIL import Image, ImageTk +from typing import List, Dict, Tuple, Optional + +from config import DEFAULT_FACE_TOLERANCE +from database import DatabaseManager +from face_processing import FaceProcessor +from gui_core import GUICore + + +class AutoMatchGUI: + """Handles the auto-match face identification GUI interface""" + + def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0): + """Initialize the auto-match GUI""" + self.db = db_manager + self.face_processor = face_processor + self.verbose = verbose + self.gui_core = GUICore() + + def auto_identify_matches(self, tolerance: float = DEFAULT_FACE_TOLERANCE, confirm: bool = True, + show_faces: bool = False, include_same_photo: bool = False) -> int: + """Automatically identify faces that match already identified faces using GUI""" + # Get all identified faces (one per person) to use as reference faces + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score + FROM faces f + JOIN photos p ON f.photo_id = p.id + WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 + ORDER BY f.person_id, f.quality_score DESC + ''') + identified_faces = cursor.fetchall() + + if not identified_faces: + print("šŸ” No identified faces found for auto-matching") + return 0 + + # Group by person and get the best quality face per person + person_faces = {} + for face in identified_faces: + person_id = face[1] + if person_id not in person_faces: + person_faces[person_id] = face + + # Convert to ordered list to ensure consistent ordering + # Order by person name for user-friendly consistent results across runs + person_faces_list = [] + for person_id, face in person_faces.items(): + # Get person name for ordering + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,)) + result = cursor.fetchone() + if result: + first_name, last_name = result + if last_name and first_name: + person_name = f"{last_name}, {first_name}" + elif last_name: + person_name = last_name + elif first_name: + person_name = first_name + else: + person_name = "Unknown" + else: + person_name = "Unknown" + person_faces_list.append((person_id, face, person_name)) + + # Sort by person name for consistent, user-friendly ordering + person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2) + + print(f"\nšŸŽÆ Found {len(person_faces)} identified people to match against") + print("šŸ“Š Confidence Guide: 🟢80%+ = Very High, 🟔70%+ = High, 🟠60%+ = Medium, šŸ”“50%+ = Low, ⚫<50% = Very Low") + + # Find similar faces for each identified person using face-to-face comparison + matches_by_matched = {} + for person_id, reference_face, person_name in person_faces_list: + reference_face_id = reference_face[0] + + # Use the same filtering and sorting logic as identify + similar_faces = self.face_processor._get_filtered_similar_faces(reference_face_id, tolerance, include_same_photo, face_status=None) + + # Convert to auto-match format + person_matches = [] + for similar_face in similar_faces: + # Convert to auto-match format + match = { + 'unidentified_id': similar_face['face_id'], + 'unidentified_photo_id': similar_face['photo_id'], + 'unidentified_filename': similar_face['filename'], + 'unidentified_location': similar_face['location'], + 'matched_id': reference_face_id, + 'matched_photo_id': reference_face[2], + 'matched_filename': reference_face[4], + 'matched_location': reference_face[3], + 'person_id': person_id, + 'distance': similar_face['distance'], + 'quality_score': similar_face['quality_score'], + 'adaptive_tolerance': similar_face.get('adaptive_tolerance', tolerance) + } + person_matches.append(match) + + matches_by_matched[person_id] = person_matches + + # Flatten all matches for counting + all_matches = [] + for person_matches in matches_by_matched.values(): + all_matches.extend(person_matches) + + if not all_matches: + print("šŸ” No similar faces found for auto-identification") + return 0 + + print(f"\nšŸŽÆ Found {len(all_matches)} potential matches") + + # Pre-fetch all needed data to avoid repeated database queries in update_display + print("šŸ“Š Pre-fetching data for optimal performance...") + data_cache = self._prefetch_auto_match_data(matches_by_matched) + + print(f"āœ… Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths") + + identified_count = 0 + + # Create the main window + root = tk.Tk() + root.title("Auto-Match Face Identification") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + + # Hide window initially to prevent flash at corner + root.withdraw() + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal window_destroyed + # Clean up face crops and caches + self.face_processor.cleanup_face_crops() + self.db.close_db_connection() + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving with larger default size + saved_size = self.gui_core.setup_window_size_saving(root, "gui_config.json") + # Override with larger size for auto-match window + root.geometry("1000x700") + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + + # Left side - identified person + left_frame = ttk.LabelFrame(main_frame, text="Identified person", padding="10") + left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) + + # Right side - unidentified faces that match this person + right_frame = ttk.LabelFrame(main_frame, text="Unidentified Faces to Match", padding="10") + right_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) + + # Configure row weights + main_frame.rowconfigure(0, weight=1) + + # Check if there's only one person - if so, disable search functionality + # Use matched_ids instead of person_faces_list since we only show people with potential matches + matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] + has_only_one_person = len(matched_ids) == 1 + print(f"DEBUG: person_faces_list length: {len(person_faces_list)}, matched_ids length: {len(matched_ids)}, has_only_one_person: {has_only_one_person}") + + # Search controls for filtering people by last name + last_name_search_var = tk.StringVar() + # Search field with label underneath (like modifyidentified edit section) + search_frame = ttk.Frame(left_frame) + search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + + # Search input on the left + search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20) + search_entry.grid(row=0, column=0, sticky=tk.W) + + # Buttons on the right of the search input + buttons_row = ttk.Frame(search_frame) + buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0)) + + search_btn = ttk.Button(buttons_row, text="Search", width=8) + search_btn.pack(side=tk.LEFT, padx=(0, 5)) + clear_btn = ttk.Button(buttons_row, text="Clear", width=6) + clear_btn.pack(side=tk.LEFT) + + # Helper label directly under the search input + if has_only_one_person: + print("DEBUG: Disabling search functionality - only one person found") + # Disable search functionality if there's only one person + search_entry.config(state='disabled') + search_btn.config(state='disabled') + clear_btn.config(state='disabled') + # Add a label to explain why search is disabled + disabled_label = ttk.Label(search_frame, text="(Search disabled - only one person found)", + font=("Arial", 8), foreground="gray") + disabled_label.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(2, 0)) + else: + print("DEBUG: Search functionality enabled - multiple people found") + # Normal helper label when search is enabled + last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray") + last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0)) + + # Matched person info + matched_info_label = ttk.Label(left_frame, text="", font=("Arial", 10, "bold")) + matched_info_label.grid(row=2, column=0, pady=(0, 10), sticky=tk.W) + + # Matched person image + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + matched_canvas = tk.Canvas(left_frame, width=300, height=300, bg=canvas_bg_color, highlightthickness=0) + matched_canvas.grid(row=3, column=0, pady=(0, 10)) + + # Save button for this person (will be created after function definitions) + save_btn = None + + # Matches scrollable frame + matches_frame = ttk.Frame(right_frame) + matches_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Control buttons for matches (Select All / Clear All) + matches_controls_frame = ttk.Frame(matches_frame) + matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) + + def select_all_matches(): + """Select all match checkboxes""" + for var in match_vars: + var.set(True) + + def clear_all_matches(): + """Clear all match checkboxes""" + for var in match_vars: + var.set(False) + + select_all_matches_btn = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", command=select_all_matches) + select_all_matches_btn.pack(side=tk.LEFT, padx=(0, 5)) + + clear_all_matches_btn = ttk.Button(matches_controls_frame, text="☐ Clear All", command=clear_all_matches) + clear_all_matches_btn.pack(side=tk.LEFT) + + def update_match_control_buttons_state(): + """Enable/disable Select All / Clear All based on matches presence""" + if match_vars: + select_all_matches_btn.config(state='normal') + clear_all_matches_btn.config(state='normal') + else: + select_all_matches_btn.config(state='disabled') + clear_all_matches_btn.config(state='disabled') + + # Create scrollbar for matches + scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=None) + scrollbar.grid(row=0, column=1, rowspan=1, sticky=(tk.N, tk.S)) + + # Create canvas for matches with scrollbar + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + matches_canvas = tk.Canvas(matches_frame, yscrollcommand=scrollbar.set, bg=canvas_bg_color, highlightthickness=0) + matches_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + scrollbar.config(command=matches_canvas.yview) + + # Configure grid weights + right_frame.columnconfigure(0, weight=1) + right_frame.rowconfigure(0, weight=1) + matches_frame.columnconfigure(0, weight=1) + matches_frame.rowconfigure(0, weight=0) # Controls row takes minimal space + matches_frame.rowconfigure(1, weight=1) # Canvas row expandable + + # Control buttons (navigation only) + control_frame = ttk.Frame(main_frame) + control_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0)) + + # Button commands + current_matched_index = 0 + matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] + filtered_matched_ids = None # filtered subset based on last name search + + match_checkboxes = [] + match_vars = [] + identified_faces_per_person = {} # Track which faces were identified for each person + checkbox_states_per_person = {} # Track checkbox states for each person (unsaved selections) + original_checkbox_states_per_person = {} # Track original/default checkbox states for comparison + + def on_confirm_matches(): + nonlocal identified_count, current_matched_index, identified_faces_per_person + if current_matched_index < len(matched_ids): + matched_id = matched_ids[current_matched_index] + matches_for_this_person = matches_by_matched[matched_id] + + # Initialize identified faces for this person if not exists + if matched_id not in identified_faces_per_person: + identified_faces_per_person[matched_id] = set() + + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Process all matches (both checked and unchecked) + for i, (match, var) in enumerate(zip(matches_for_this_person, match_vars)): + if var.get(): + # Face is checked - assign to person + cursor.execute( + 'UPDATE faces SET person_id = ? WHERE id = ?', + (match['person_id'], match['unidentified_id']) + ) + + # Use cached person name instead of database query + person_details = data_cache['person_details'].get(match['person_id'], {}) + person_name = person_details.get('full_name', "Unknown") + + # Track this face as identified for this person + identified_faces_per_person[matched_id].add(match['unidentified_id']) + + print(f"āœ… Identified as: {person_name}") + identified_count += 1 + else: + # Face is unchecked - check if it was previously identified for this person + if match['unidentified_id'] in identified_faces_per_person[matched_id]: + # This face was previously identified for this person, now unchecking it + cursor.execute( + 'UPDATE faces SET person_id = NULL WHERE id = ?', + (match['unidentified_id'],) + ) + + # Remove from identified faces for this person + identified_faces_per_person[matched_id].discard(match['unidentified_id']) + + print(f"āŒ Unidentified: {match['unidentified_filename']}") + + # Update person encodings for all affected persons after database transaction is complete + 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) + + # After saving, set original states to the current UI states so there are no unsaved changes + current_snapshot = {} + for match, var in zip(matches_for_this_person, match_vars): + unique_key = f"{matched_id}_{match['unidentified_id']}" + current_snapshot[unique_key] = var.get() + checkbox_states_per_person[matched_id] = dict(current_snapshot) + original_checkbox_states_per_person[matched_id] = dict(current_snapshot) + + def on_skip_current(): + nonlocal current_matched_index + # Save current checkbox states before navigating away + save_current_checkbox_states() + current_matched_index += 1 + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + if current_matched_index < len(active_ids): + update_display() + else: + finish_auto_match() + + def on_go_back(): + nonlocal current_matched_index + if current_matched_index > 0: + # Save current checkbox states before navigating away + save_current_checkbox_states() + current_matched_index -= 1 + update_display() + + def has_unsaved_changes(): + """Check if there are any unsaved changes by comparing current states with original states""" + for person_id, current_states in checkbox_states_per_person.items(): + if person_id in original_checkbox_states_per_person: + original_states = original_checkbox_states_per_person[person_id] + # Check if any checkbox state differs from its original state + for key, current_value in current_states.items(): + if key not in original_states or original_states[key] != current_value: + return True + else: + # If person has current states but no original states, there are changes + if any(current_states.values()): + return True + return False + + def apply_last_name_filter(): + """Filter people by last name and update navigation""" + nonlocal filtered_matched_ids, current_matched_index + query = last_name_search_var.get().strip().lower() + if query: + # Filter person_faces_list by last name + filtered_people = [] + for person_id, face, person_name in person_faces_list: + # Extract last name from person_name (format: "Last, First") + if ',' in person_name: + last_name = person_name.split(',')[0].strip().lower() + else: + last_name = person_name.strip().lower() + + if query in last_name: + filtered_people.append((person_id, face, person_name)) + + # Get filtered matched_ids + filtered_matched_ids = [person_id for person_id, _, _ in filtered_people if person_id in matches_by_matched and matches_by_matched[person_id]] + else: + filtered_matched_ids = None + + # Reset to first person in filtered list + current_matched_index = 0 + if filtered_matched_ids: + update_display() + else: + # No matches - clear display + matched_info_label.config(text="No people match filter") + matched_canvas.delete("all") + matched_canvas.create_text(150, 150, text="No matches found", fill="gray") + matches_canvas.delete("all") + update_button_states() + + def clear_last_name_filter(): + """Clear filter and show all people""" + nonlocal filtered_matched_ids, current_matched_index + last_name_search_var.set("") + filtered_matched_ids = None + current_matched_index = 0 + update_display() + + def on_quit_auto_match(): + nonlocal window_destroyed + + # Check for unsaved changes before quitting + if has_unsaved_changes(): + # Show warning dialog with custom width + from tkinter import messagebox + + # Create a custom dialog for better width control + dialog = tk.Toplevel(root) + dialog.title("Unsaved Changes") + dialog.geometry("500x250") + dialog.resizable(True, True) + dialog.transient(root) + dialog.grab_set() + + # Center the dialog + dialog.geometry("+%d+%d" % (root.winfo_rootx() + 50, root.winfo_rooty() + 50)) + + # Main message + message_frame = ttk.Frame(dialog, padding="20") + message_frame.pack(fill=tk.BOTH, expand=True) + + # Warning icon and text + icon_label = ttk.Label(message_frame, text="āš ļø", font=("Arial", 16)) + icon_label.pack(anchor=tk.W) + + main_text = ttk.Label(message_frame, + text="You have unsaved changes that will be lost if you quit.", + font=("Arial", 10)) + main_text.pack(anchor=tk.W, pady=(5, 10)) + + # Options + options_text = ttk.Label(message_frame, + text="• Yes: Save current changes and quit\n" + "• No: Quit without saving\n" + "• Cancel: Return to auto-match", + font=("Arial", 9)) + options_text.pack(anchor=tk.W, pady=(0, 10)) + + + # Buttons + button_frame = ttk.Frame(dialog) + button_frame.pack(fill=tk.X, padx=20, pady=(0, 20)) + + result = None + + def on_yes(): + nonlocal result + result = True + dialog.destroy() + + def on_no(): + nonlocal result + result = False + dialog.destroy() + + def on_cancel(): + nonlocal result + result = None + dialog.destroy() + + yes_btn = ttk.Button(button_frame, text="Yes", command=on_yes) + no_btn = ttk.Button(button_frame, text="No", command=on_no) + cancel_btn = ttk.Button(button_frame, text="Cancel", command=on_cancel) + + yes_btn.pack(side=tk.LEFT, padx=(0, 5)) + no_btn.pack(side=tk.LEFT, padx=5) + cancel_btn.pack(side=tk.RIGHT, padx=(5, 0)) + + # Wait for dialog to close + dialog.wait_window() + + if result is None: # Cancel - don't quit + return + elif result: # Yes - save changes first + # Save current checkbox states before quitting + save_current_checkbox_states() + # Note: We don't actually save to database here, just preserve the states + # The user would need to click Save button for each person to persist changes + print("āš ļø Warning: Changes are preserved but not saved to database.") + print(" Click 'Save Changes' button for each person to persist changes.") + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + def finish_auto_match(): + nonlocal window_destroyed + print(f"\nāœ… Auto-identified {identified_count} faces") + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + # Create button references for state management + back_btn = ttk.Button(control_frame, text="ā®ļø Back", command=on_go_back) + next_btn = ttk.Button(control_frame, text="ā­ļø Next", command=on_skip_current) + quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit_auto_match) + + back_btn.grid(row=0, column=0, padx=(0, 5)) + next_btn.grid(row=0, column=1, padx=5) + quit_btn.grid(row=0, column=2, padx=(5, 0)) + + # Create save button now that functions are defined + save_btn = ttk.Button(left_frame, text="šŸ’¾ Save Changes", command=on_confirm_matches) + save_btn.grid(row=4, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) + + def update_button_states(): + """Update button states based on current position""" + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + # Enable/disable Back button based on position + if current_matched_index > 0: + back_btn.config(state='normal') + else: + back_btn.config(state='disabled') + + # Enable/disable Next button based on position + if current_matched_index < len(active_ids) - 1: + next_btn.config(state='normal') + else: + next_btn.config(state='disabled') + + def update_save_button_text(): + """Update save button text with current person name""" + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + if current_matched_index < len(active_ids): + matched_id = active_ids[current_matched_index] + # Get person name from the first match for this person + matches_for_current_person = matches_by_matched[matched_id] + if matches_for_current_person: + person_id = matches_for_current_person[0]['person_id'] + # Use cached person name instead of database query + person_details = data_cache['person_details'].get(person_id, {}) + person_name = person_details.get('full_name', "Unknown") + save_btn.config(text=f"šŸ’¾ Save changes for {person_name}") + else: + save_btn.config(text="šŸ’¾ Save Changes") + else: + save_btn.config(text="šŸ’¾ Save Changes") + + def save_current_checkbox_states(): + """Save current checkbox states for the current person. + Note: Do NOT modify original states here to avoid false positives + when a user toggles and reverts a checkbox. + """ + if current_matched_index < len(matched_ids) and match_vars: + current_matched_id = matched_ids[current_matched_index] + matches_for_current_person = matches_by_matched[current_matched_id] + + if len(match_vars) == len(matches_for_current_person): + if current_matched_id not in checkbox_states_per_person: + checkbox_states_per_person[current_matched_id] = {} + + # Save current checkbox states for this person + for i, (match, var) in enumerate(zip(matches_for_current_person, match_vars)): + unique_key = f"{current_matched_id}_{match['unidentified_id']}" + current_value = var.get() + checkbox_states_per_person[current_matched_id][unique_key] = current_value + + if self.verbose >= 2: + print(f"DEBUG: Saved state for person {current_matched_id}, face {match['unidentified_id']}: {current_value}") + + def update_display(): + nonlocal current_matched_index + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + if current_matched_index >= len(active_ids): + finish_auto_match() + return + + matched_id = active_ids[current_matched_index] + matches_for_this_person = matches_by_matched[matched_id] + + # Update button states + update_button_states() + + # Update save button text with person name + update_save_button_text() + + # Update title + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + root.title(f"Auto-Match Face Identification - {current_matched_index + 1}/{len(active_ids)}") + + # Get the first match to get matched person info + if not matches_for_this_person: + print(f"āŒ Error: No matches found for current person {matched_id}") + # No items on the right panel – disable Select All / Clear All + match_checkboxes.clear() + match_vars.clear() + update_match_control_buttons_state() + # Skip to next person if available + if current_matched_index < len(matched_ids) - 1: + current_matched_index += 1 + update_display() + else: + finish_auto_match() + return + + first_match = matches_for_this_person[0] + + # Use cached data instead of database queries + person_details = data_cache['person_details'].get(first_match['person_id'], {}) + person_name = person_details.get('full_name', "Unknown") + date_of_birth = person_details.get('date_of_birth', '') + matched_photo_path = data_cache['photo_paths'].get(first_match['matched_photo_id'], None) + + # Create detailed person info display + person_info_lines = [f"šŸ‘¤ Person: {person_name}"] + if date_of_birth: + person_info_lines.append(f"šŸ“… Born: {date_of_birth}") + person_info_lines.extend([ + f"šŸ“ Photo: {first_match['matched_filename']}", + f"šŸ“ Face location: {first_match['matched_location']}" + ]) + + # Update matched person info + matched_info_label.config(text="\n".join(person_info_lines)) + + # Display matched person face + matched_canvas.delete("all") + if show_faces: + matched_crop_path = self.face_processor._extract_face_crop( + matched_photo_path, + first_match['matched_location'], + f"matched_{first_match['person_id']}" + ) + + if matched_crop_path and os.path.exists(matched_crop_path): + try: + pil_image = Image.open(matched_crop_path) + pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(pil_image) + matched_canvas.create_image(150, 150, image=photo) + matched_canvas.image = photo + + # Add photo icon to the matched person face - exactly in corner + # Use actual image dimensions instead of assuming 300x300 + actual_width, actual_height = pil_image.size + self.gui_core.create_photo_icon(matched_canvas, matched_photo_path, icon_size=20, + face_x=150, face_y=150, + face_width=actual_width, face_height=actual_height, + canvas_width=300, canvas_height=300) + except Exception as e: + matched_canvas.create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") + else: + matched_canvas.create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") + + # Clear and populate unidentified faces + matches_canvas.delete("all") + match_checkboxes.clear() + match_vars.clear() + update_match_control_buttons_state() + + # Create frame for unidentified faces inside canvas + matches_inner_frame = ttk.Frame(matches_canvas) + matches_canvas.create_window((0, 0), window=matches_inner_frame, anchor="nw") + + # Use cached photo paths instead of database queries + photo_paths = data_cache['photo_paths'] + + # Create all checkboxes + for i, match in enumerate(matches_for_this_person): + # Get unidentified face info from cached data + unidentified_photo_path = photo_paths.get(match['unidentified_photo_id'], '') + + # Calculate confidence + confidence_pct = (1 - match['distance']) * 100 + confidence_desc = self.face_processor._get_confidence_description(confidence_pct) + + # Create match frame + match_frame = ttk.Frame(matches_inner_frame) + match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5) + + # Checkbox for this match + match_var = tk.BooleanVar() + + # Restore previous checkbox state if available + unique_key = f"{matched_id}_{match['unidentified_id']}" + if matched_id in checkbox_states_per_person and unique_key in checkbox_states_per_person[matched_id]: + saved_state = checkbox_states_per_person[matched_id][unique_key] + match_var.set(saved_state) + if self.verbose >= 2: + print(f"DEBUG: Restored state for person {matched_id}, face {match['unidentified_id']}: {saved_state}") + # Otherwise, pre-select if this face was previously identified for this person + elif matched_id in identified_faces_per_person and match['unidentified_id'] in identified_faces_per_person[matched_id]: + match_var.set(True) + if self.verbose >= 2: + print(f"DEBUG: Pre-selected identified face for person {matched_id}, face {match['unidentified_id']}") + + match_vars.append(match_var) + + # Capture original state at render time (once per person per face) + if matched_id not in original_checkbox_states_per_person: + original_checkbox_states_per_person[matched_id] = {} + if unique_key not in original_checkbox_states_per_person[matched_id]: + original_checkbox_states_per_person[matched_id][unique_key] = match_var.get() + + # Add callback to save state immediately when checkbox changes + def on_checkbox_change(var, person_id, face_id): + unique_key = f"{person_id}_{face_id}" + if person_id not in checkbox_states_per_person: + checkbox_states_per_person[person_id] = {} + + current_value = var.get() + checkbox_states_per_person[person_id][unique_key] = current_value + + if self.verbose >= 2: + print(f"DEBUG: Checkbox changed for person {person_id}, face {face_id}: {current_value}") + + # Bind the callback to the variable + match_var.trace('w', lambda *args: on_checkbox_change(match_var, matched_id, match['unidentified_id'])) + + # Configure match frame for grid layout + match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width + match_frame.columnconfigure(1, weight=1) # Text column - expandable + match_frame.columnconfigure(2, weight=0) # Image column - fixed width + + # Checkbox without text + checkbox = ttk.Checkbutton(match_frame, variable=match_var) + checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) + match_checkboxes.append(checkbox) + + # Create labels for confidence and filename + confidence_label = ttk.Label(match_frame, text=f"{confidence_pct:.1f}% {confidence_desc}", font=("Arial", 9, "bold")) + confidence_label.grid(row=0, column=1, sticky=tk.W, padx=(0, 10)) + + filename_label = ttk.Label(match_frame, text=f"šŸ“ {match['unidentified_filename']}", font=("Arial", 8), foreground="gray") + filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10)) + + # Unidentified face image + if show_faces: + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + match_canvas = tk.Canvas(match_frame, width=100, height=100, bg=canvas_bg_color, highlightthickness=0) + match_canvas.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0)) + + unidentified_crop_path = self.face_processor._extract_face_crop( + unidentified_photo_path, + match['unidentified_location'], + f"unid_{match['unidentified_id']}" + ) + + if unidentified_crop_path and os.path.exists(unidentified_crop_path): + try: + pil_image = Image.open(unidentified_crop_path) + pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(pil_image) + match_canvas.create_image(50, 50, image=photo) + match_canvas.image = photo + + # Add photo icon to the unidentified face + self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, + face_x=50, face_y=50, + face_width=100, face_height=100, + canvas_width=100, canvas_height=100) + except Exception as e: + match_canvas.create_text(50, 50, text="āŒ", fill="red") + else: + match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") + + # Update Select All / Clear All button states after populating + update_match_control_buttons_state() + + # Update scroll region + matches_canvas.update_idletasks() + matches_canvas.configure(scrollregion=matches_canvas.bbox("all")) + + # Show the window + try: + root.deiconify() + root.lift() + root.focus_force() + except tk.TclError: + # Window was destroyed before we could show it + return 0 + + # Wire up search controls now that helper functions exist + try: + search_btn.config(command=lambda: apply_last_name_filter()) + clear_btn.config(command=lambda: clear_last_name_filter()) + search_entry.bind('', lambda e: apply_last_name_filter()) + except Exception: + pass + + # Start with first matched person + update_display() + + # Main event loop + try: + root.mainloop() + except tk.TclError: + pass # Window was destroyed + + return identified_count + + def _prefetch_auto_match_data(self, matches_by_matched: Dict) -> Dict: + """Pre-fetch all needed data to avoid repeated database queries""" + data_cache = {} + + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Pre-fetch all person names and details + person_ids = list(matches_by_matched.keys()) + if person_ids: + placeholders = ','.join('?' * len(person_ids)) + cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids) + data_cache['person_details'] = {} + for row in cursor.fetchall(): + person_id = row[0] + first_name = row[1] or '' + last_name = row[2] or '' + middle_name = row[3] or '' + maiden_name = row[4] or '' + date_of_birth = row[5] or '' + + # Create full name display + name_parts = [] + if first_name: + name_parts.append(first_name) + if middle_name: + name_parts.append(middle_name) + if last_name: + name_parts.append(last_name) + if maiden_name: + name_parts.append(f"({maiden_name})") + + full_name = ' '.join(name_parts) + data_cache['person_details'][person_id] = { + 'full_name': full_name, + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': middle_name, + 'maiden_name': maiden_name, + 'date_of_birth': date_of_birth + } + + # Pre-fetch all photo paths (both matched and unidentified) + all_photo_ids = set() + for person_matches in matches_by_matched.values(): + for match in person_matches: + all_photo_ids.add(match['matched_photo_id']) + all_photo_ids.add(match['unidentified_photo_id']) + + if all_photo_ids: + photo_ids_list = list(all_photo_ids) + placeholders = ','.join('?' * len(photo_ids_list)) + cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list) + data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()} + + return data_cache diff --git a/photo_tagger.py b/photo_tagger.py index 2a71cef..3531154 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -22,6 +22,7 @@ from tag_management import TagManager from search_stats import SearchStats from gui_core import GUICore from identify_gui import IdentifyGUI +from auto_match_gui import AutoMatchGUI class PhotoTagger: @@ -41,6 +42,7 @@ class PhotoTagger: self.search_stats = SearchStats(self.db, verbose) self.gui_core = GUICore() self.identify_gui = IdentifyGUI(self.db, self.face_processor, verbose) + self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose) # Legacy compatibility - expose some methods directly self._db_connection = None @@ -124,871 +126,7 @@ class PhotoTagger: def auto_identify_matches(self, tolerance: float = DEFAULT_FACE_TOLERANCE, confirm: bool = True, show_faces: bool = False, include_same_photo: bool = False) -> int: """Automatically identify faces that match already identified faces using GUI""" - # Get all identified faces (one per person) to use as reference faces - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 - ORDER BY f.person_id, f.quality_score DESC - ''') - identified_faces = cursor.fetchall() - - if not identified_faces: - print("šŸ” No identified faces found for auto-matching") - return 0 - - # Group by person and get the best quality face per person - person_faces = {} - for face in identified_faces: - person_id = face[1] - if person_id not in person_faces: - person_faces[person_id] = face - - # Convert to ordered list to ensure consistent ordering - # Order by person name for user-friendly consistent results across runs - person_faces_list = [] - for person_id, face in person_faces.items(): - # Get person name for ordering - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,)) - result = cursor.fetchone() - if result: - first_name, last_name = result - if last_name and first_name: - person_name = f"{last_name}, {first_name}" - elif last_name: - person_name = last_name - elif first_name: - person_name = first_name - else: - person_name = "Unknown" - else: - person_name = "Unknown" - person_faces_list.append((person_id, face, person_name)) - - # Sort by person name for consistent, user-friendly ordering - person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2) - - print(f"\nšŸŽÆ Found {len(person_faces)} identified people to match against") - print("šŸ“Š Confidence Guide: 🟢80%+ = Very High, 🟔70%+ = High, 🟠60%+ = Medium, šŸ”“50%+ = Low, ⚫<50% = Very Low") - - # Find similar faces for each identified person using face-to-face comparison - matches_by_matched = {} - for person_id, reference_face, person_name in person_faces_list: - reference_face_id = reference_face[0] - - # Use the same filtering and sorting logic as identify - similar_faces = self._get_filtered_similar_faces(reference_face_id, tolerance, include_same_photo, face_status=None) - - # Convert to auto-match format - person_matches = [] - for similar_face in similar_faces: - # Convert to auto-match format - match = { - 'unidentified_id': similar_face['face_id'], - 'unidentified_photo_id': similar_face['photo_id'], - 'unidentified_filename': similar_face['filename'], - 'unidentified_location': similar_face['location'], - 'matched_id': reference_face_id, - 'matched_photo_id': reference_face[2], - 'matched_filename': reference_face[4], - 'matched_location': reference_face[3], - 'person_id': person_id, - 'distance': similar_face['distance'], - 'quality_score': similar_face['quality_score'], - 'adaptive_tolerance': similar_face.get('adaptive_tolerance', tolerance) - } - person_matches.append(match) - - matches_by_matched[person_id] = person_matches - - # Flatten all matches for counting - all_matches = [] - for person_matches in matches_by_matched.values(): - all_matches.extend(person_matches) - - if not all_matches: - print("šŸ” No similar faces found for auto-identification") - return 0 - - print(f"\nšŸŽÆ Found {len(all_matches)} potential matches") - - # Pre-fetch all needed data to avoid repeated database queries in update_display - print("šŸ“Š Pre-fetching data for optimal performance...") - data_cache = {} - - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Pre-fetch all person names and details - person_ids = list(matches_by_matched.keys()) - if person_ids: - placeholders = ','.join('?' * len(person_ids)) - cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids) - data_cache['person_details'] = {} - for row in cursor.fetchall(): - person_id = row[0] - first_name = row[1] or '' - last_name = row[2] or '' - middle_name = row[3] or '' - maiden_name = row[4] or '' - date_of_birth = row[5] or '' - - # Create full name display - name_parts = [] - if first_name: - name_parts.append(first_name) - if middle_name: - name_parts.append(middle_name) - if last_name: - name_parts.append(last_name) - if maiden_name: - name_parts.append(f"({maiden_name})") - - full_name = ' '.join(name_parts) - data_cache['person_details'][person_id] = { - 'full_name': full_name, - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': middle_name, - 'maiden_name': maiden_name, - 'date_of_birth': date_of_birth - } - - # Pre-fetch all photo paths (both matched and unidentified) - all_photo_ids = set() - for person_matches in matches_by_matched.values(): - for match in person_matches: - all_photo_ids.add(match['matched_photo_id']) - all_photo_ids.add(match['unidentified_photo_id']) - - if all_photo_ids: - photo_ids_list = list(all_photo_ids) - placeholders = ','.join('?' * len(photo_ids_list)) - cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list) - data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()} - - print(f"āœ… Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths") - - identified_count = 0 - - # Use integrated GUI for auto-matching - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import json - import os - - # Create the main window - root = tk.Tk() - root.title("Auto-Match Face Identification") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Clean up face crops and caches - self._cleanup_face_crops() - self.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving with larger default size - saved_size = self._setup_window_size_saving(root, "gui_config.json") - # Override with larger size for auto-match window - root.geometry("1000x700") - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=1) - - # Left side - identified person - left_frame = ttk.LabelFrame(main_frame, text="Identified person", padding="10") - left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) - - # Right side - unidentified faces that match this person - right_frame = ttk.LabelFrame(main_frame, text="Unidentified Faces to Match", padding="10") - right_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - - # Configure row weights - main_frame.rowconfigure(0, weight=1) - - # Check if there's only one person - if so, disable search functionality - # Use matched_ids instead of person_faces_list since we only show people with potential matches - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - has_only_one_person = len(matched_ids) == 1 - print(f"DEBUG: person_faces_list length: {len(person_faces_list)}, matched_ids length: {len(matched_ids)}, has_only_one_person: {has_only_one_person}") - - # Search controls for filtering people by last name - last_name_search_var = tk.StringVar() - # Search field with label underneath (like modifyidentified edit section) - search_frame = ttk.Frame(left_frame) - search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - - # Search input on the left - search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20) - search_entry.grid(row=0, column=0, sticky=tk.W) - - # Buttons on the right of the search input - buttons_row = ttk.Frame(search_frame) - buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0)) - - search_btn = ttk.Button(buttons_row, text="Search", width=8) - search_btn.pack(side=tk.LEFT, padx=(0, 5)) - clear_btn = ttk.Button(buttons_row, text="Clear", width=6) - clear_btn.pack(side=tk.LEFT) - - # Helper label directly under the search input - if has_only_one_person: - print("DEBUG: Disabling search functionality - only one person found") - # Disable search functionality if there's only one person - search_entry.config(state='disabled') - search_btn.config(state='disabled') - clear_btn.config(state='disabled') - # Add a label to explain why search is disabled - disabled_label = ttk.Label(search_frame, text="(Search disabled - only one person found)", - font=("Arial", 8), foreground="gray") - disabled_label.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(2, 0)) - else: - print("DEBUG: Search functionality enabled - multiple people found") - # Normal helper label when search is enabled - last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray") - last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0)) - - # Matched person info - matched_info_label = ttk.Label(left_frame, text="", font=("Arial", 10, "bold")) - matched_info_label.grid(row=2, column=0, pady=(0, 10), sticky=tk.W) - - # Matched person image - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matched_canvas = tk.Canvas(left_frame, width=300, height=300, bg=canvas_bg_color, highlightthickness=0) - matched_canvas.grid(row=3, column=0, pady=(0, 10)) - - # Save button for this person (will be created after function definitions) - save_btn = None - - # Matches scrollable frame - matches_frame = ttk.Frame(right_frame) - matches_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Control buttons for matches (Select All / Clear All) - matches_controls_frame = ttk.Frame(matches_frame) - matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) - - def select_all_matches(): - """Select all match checkboxes""" - for var in match_vars: - var.set(True) - - def clear_all_matches(): - """Clear all match checkboxes""" - for var in match_vars: - var.set(False) - - select_all_matches_btn = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", command=select_all_matches) - select_all_matches_btn.pack(side=tk.LEFT, padx=(0, 5)) - - clear_all_matches_btn = ttk.Button(matches_controls_frame, text="☐ Clear All", command=clear_all_matches) - clear_all_matches_btn.pack(side=tk.LEFT) - - def update_match_control_buttons_state(): - """Enable/disable Select All / Clear All based on matches presence""" - if match_vars: - select_all_matches_btn.config(state='normal') - clear_all_matches_btn.config(state='normal') - else: - select_all_matches_btn.config(state='disabled') - clear_all_matches_btn.config(state='disabled') - - # Create scrollbar for matches - scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=None) - scrollbar.grid(row=0, column=1, rowspan=1, sticky=(tk.N, tk.S)) - - # Create canvas for matches with scrollbar - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matches_canvas = tk.Canvas(matches_frame, yscrollcommand=scrollbar.set, bg=canvas_bg_color, highlightthickness=0) - matches_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - scrollbar.config(command=matches_canvas.yview) - - # Configure grid weights - right_frame.columnconfigure(0, weight=1) - right_frame.rowconfigure(0, weight=1) - matches_frame.columnconfigure(0, weight=1) - matches_frame.rowconfigure(0, weight=0) # Controls row takes minimal space - matches_frame.rowconfigure(1, weight=1) # Canvas row expandable - - # Control buttons (navigation only) - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0)) - - # Button commands - current_matched_index = 0 - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - filtered_matched_ids = None # filtered subset based on last name search - - match_checkboxes = [] - match_vars = [] - identified_faces_per_person = {} # Track which faces were identified for each person - checkbox_states_per_person = {} # Track checkbox states for each person (unsaved selections) - original_checkbox_states_per_person = {} # Track original/default checkbox states for comparison - - def on_confirm_matches(): - nonlocal identified_count, current_matched_index, identified_faces_per_person - if current_matched_index < len(matched_ids): - matched_id = matched_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Initialize identified faces for this person if not exists - if matched_id not in identified_faces_per_person: - identified_faces_per_person[matched_id] = set() - - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Process all matches (both checked and unchecked) - for i, (match, var) in enumerate(zip(matches_for_this_person, match_vars)): - if var.get(): - # Face is checked - assign to person - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (match['person_id'], match['unidentified_id']) - ) - - # Use cached person name instead of database query - person_details = data_cache['person_details'].get(match['person_id'], {}) - person_name = person_details.get('full_name', "Unknown") - - # Track this face as identified for this person - identified_faces_per_person[matched_id].add(match['unidentified_id']) - - print(f"āœ… Identified as: {person_name}") - identified_count += 1 - else: - # Face is unchecked - check if it was previously identified for this person - if match['unidentified_id'] in identified_faces_per_person[matched_id]: - # This face was previously identified for this person, now unchecking it - cursor.execute( - 'UPDATE faces SET person_id = NULL WHERE id = ?', - (match['unidentified_id'],) - ) - - # Remove from identified faces for this person - identified_faces_per_person[matched_id].discard(match['unidentified_id']) - - print(f"āŒ Unidentified: {match['unidentified_filename']}") - - # Update person encodings for all affected persons after database transaction is complete - for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']): - self._update_person_encodings(person_id) - - # After saving, set original states to the current UI states so there are no unsaved changes - current_snapshot = {} - for match, var in zip(matches_for_this_person, match_vars): - unique_key = f"{matched_id}_{match['unidentified_id']}" - current_snapshot[unique_key] = var.get() - checkbox_states_per_person[matched_id] = dict(current_snapshot) - original_checkbox_states_per_person[matched_id] = dict(current_snapshot) - - def on_skip_current(): - nonlocal current_matched_index - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index += 1 - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - update_display() - else: - finish_auto_match() - - def on_go_back(): - nonlocal current_matched_index - if current_matched_index > 0: - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index -= 1 - update_display() - - def has_unsaved_changes(): - """Check if there are any unsaved changes by comparing current states with original states""" - for person_id, current_states in checkbox_states_per_person.items(): - if person_id in original_checkbox_states_per_person: - original_states = original_checkbox_states_per_person[person_id] - # Check if any checkbox state differs from its original state - for key, current_value in current_states.items(): - if key not in original_states or original_states[key] != current_value: - return True - else: - # If person has current states but no original states, there are changes - if any(current_states.values()): - return True - return False - - def apply_last_name_filter(): - """Filter people by last name and update navigation""" - nonlocal filtered_matched_ids, current_matched_index - query = last_name_search_var.get().strip().lower() - if query: - # Filter person_faces_list by last name - filtered_people = [] - for person_id, face, person_name in person_faces_list: - # Extract last name from person_name (format: "Last, First") - if ',' in person_name: - last_name = person_name.split(',')[0].strip().lower() - else: - last_name = person_name.strip().lower() - - if query in last_name: - filtered_people.append((person_id, face, person_name)) - - # Get filtered matched_ids - filtered_matched_ids = [person_id for person_id, _, _ in filtered_people if person_id in matches_by_matched and matches_by_matched[person_id]] - else: - filtered_matched_ids = None - - # Reset to first person in filtered list - current_matched_index = 0 - if filtered_matched_ids: - update_display() - else: - # No matches - clear display - matched_info_label.config(text="No people match filter") - matched_canvas.delete("all") - matched_canvas.create_text(150, 150, text="No matches found", fill="gray") - matches_canvas.delete("all") - update_button_states() - - def clear_last_name_filter(): - """Clear filter and show all people""" - nonlocal filtered_matched_ids, current_matched_index - last_name_search_var.set("") - filtered_matched_ids = None - current_matched_index = 0 - update_display() - - def on_quit_auto_match(): - nonlocal window_destroyed - - # Check for unsaved changes before quitting - if has_unsaved_changes(): - # Show warning dialog with custom width - from tkinter import messagebox - - # Create a custom dialog for better width control - dialog = tk.Toplevel(root) - dialog.title("Unsaved Changes") - dialog.geometry("500x250") - dialog.resizable(True, True) - dialog.transient(root) - dialog.grab_set() - - # Center the dialog - dialog.geometry("+%d+%d" % (root.winfo_rootx() + 50, root.winfo_rooty() + 50)) - - # Main message - message_frame = ttk.Frame(dialog, padding="20") - message_frame.pack(fill=tk.BOTH, expand=True) - - # Warning icon and text - icon_label = ttk.Label(message_frame, text="āš ļø", font=("Arial", 16)) - icon_label.pack(anchor=tk.W) - - main_text = ttk.Label(message_frame, - text="You have unsaved changes that will be lost if you quit.", - font=("Arial", 10)) - main_text.pack(anchor=tk.W, pady=(5, 10)) - - # Options - options_text = ttk.Label(message_frame, - text="• Yes: Save current changes and quit\n" - "• No: Quit without saving\n" - "• Cancel: Return to auto-match", - font=("Arial", 9)) - options_text.pack(anchor=tk.W, pady=(0, 10)) - - - # Buttons - button_frame = ttk.Frame(dialog) - button_frame.pack(fill=tk.X, padx=20, pady=(0, 20)) - - result = None - - def on_yes(): - nonlocal result - result = True - dialog.destroy() - - def on_no(): - nonlocal result - result = False - dialog.destroy() - - def on_cancel(): - nonlocal result - result = None - dialog.destroy() - - yes_btn = ttk.Button(button_frame, text="Yes", command=on_yes) - no_btn = ttk.Button(button_frame, text="No", command=on_no) - cancel_btn = ttk.Button(button_frame, text="Cancel", command=on_cancel) - - yes_btn.pack(side=tk.LEFT, padx=(0, 5)) - no_btn.pack(side=tk.LEFT, padx=5) - cancel_btn.pack(side=tk.RIGHT, padx=(5, 0)) - - # Wait for dialog to close - dialog.wait_window() - - if result is None: # Cancel - don't quit - return - elif result: # Yes - save changes first - # Save current checkbox states before quitting - save_current_checkbox_states() - # Note: We don't actually save to database here, just preserve the states - # The user would need to click Save button for each person to persist changes - print("āš ļø Warning: Changes are preserved but not saved to database.") - print(" Click 'Save Changes' button for each person to persist changes.") - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - def finish_auto_match(): - nonlocal window_destroyed - print(f"\nāœ… Auto-identified {identified_count} faces") - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Create button references for state management - back_btn = ttk.Button(control_frame, text="ā®ļø Back", command=on_go_back) - next_btn = ttk.Button(control_frame, text="ā­ļø Next", command=on_skip_current) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit_auto_match) - - back_btn.grid(row=0, column=0, padx=(0, 5)) - next_btn.grid(row=0, column=1, padx=5) - quit_btn.grid(row=0, column=2, padx=(5, 0)) - - # Create save button now that functions are defined - save_btn = ttk.Button(left_frame, text="šŸ’¾ Save Changes", command=on_confirm_matches) - save_btn.grid(row=4, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) - - def update_button_states(): - """Update button states based on current position""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - # Enable/disable Back button based on position - if current_matched_index > 0: - back_btn.config(state='normal') - else: - back_btn.config(state='disabled') - - # Enable/disable Next button based on position - if current_matched_index < len(active_ids) - 1: - next_btn.config(state='normal') - else: - next_btn.config(state='disabled') - - def update_save_button_text(): - """Update save button text with current person name""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - matched_id = active_ids[current_matched_index] - # Get person name from the first match for this person - matches_for_current_person = matches_by_matched[matched_id] - if matches_for_current_person: - person_id = matches_for_current_person[0]['person_id'] - # Use cached person name instead of database query - person_details = data_cache['person_details'].get(person_id, {}) - person_name = person_details.get('full_name', "Unknown") - save_btn.config(text=f"šŸ’¾ Save changes for {person_name}") - else: - save_btn.config(text="šŸ’¾ Save Changes") - else: - save_btn.config(text="šŸ’¾ Save Changes") - - def save_current_checkbox_states(): - """Save current checkbox states for the current person. - Note: Do NOT modify original states here to avoid false positives - when a user toggles and reverts a checkbox. - """ - if current_matched_index < len(matched_ids) and match_vars: - current_matched_id = matched_ids[current_matched_index] - matches_for_current_person = matches_by_matched[current_matched_id] - - if len(match_vars) == len(matches_for_current_person): - if current_matched_id not in checkbox_states_per_person: - checkbox_states_per_person[current_matched_id] = {} - - # Save current checkbox states for this person - for i, (match, var) in enumerate(zip(matches_for_current_person, match_vars)): - unique_key = f"{current_matched_id}_{match['unidentified_id']}" - current_value = var.get() - checkbox_states_per_person[current_matched_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Saved state for person {current_matched_id}, face {match['unidentified_id']}: {current_value}") - - def update_display(): - nonlocal current_matched_index - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index >= len(active_ids): - finish_auto_match() - return - - matched_id = active_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Update button states - update_button_states() - - # Update save button text with person name - update_save_button_text() - - # Update title - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - root.title(f"Auto-Match Face Identification - {current_matched_index + 1}/{len(active_ids)}") - - # Get the first match to get matched person info - if not matches_for_this_person: - print(f"āŒ Error: No matches found for current person {matched_id}") - # No items on the right panel – disable Select All / Clear All - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - # Skip to next person if available - if current_matched_index < len(matched_ids) - 1: - current_matched_index += 1 - update_display() - else: - finish_auto_match() - return - - first_match = matches_for_this_person[0] - - # Use cached data instead of database queries - person_details = data_cache['person_details'].get(first_match['person_id'], {}) - person_name = person_details.get('full_name', "Unknown") - date_of_birth = person_details.get('date_of_birth', '') - matched_photo_path = data_cache['photo_paths'].get(first_match['matched_photo_id'], None) - - # Create detailed person info display - person_info_lines = [f"šŸ‘¤ Person: {person_name}"] - if date_of_birth: - person_info_lines.append(f"šŸ“… Born: {date_of_birth}") - person_info_lines.extend([ - f"šŸ“ Photo: {first_match['matched_filename']}", - f"šŸ“ Face location: {first_match['matched_location']}" - ]) - - # Update matched person info - matched_info_label.config(text="\n".join(person_info_lines)) - - # Display matched person face - matched_canvas.delete("all") - if show_faces: - matched_crop_path = self._extract_face_crop( - matched_photo_path, - first_match['matched_location'], - f"matched_{first_match['person_id']}" - ) - - if matched_crop_path and os.path.exists(matched_crop_path): - try: - pil_image = Image.open(matched_crop_path) - pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(pil_image) - matched_canvas.create_image(150, 150, image=photo) - matched_canvas.image = photo - - # Add photo icon to the matched person face - exactly in corner - # Use actual image dimensions instead of assuming 300x300 - actual_width, actual_height = pil_image.size - self._create_photo_icon(matched_canvas, matched_photo_path, icon_size=20, - face_x=150, face_y=150, - face_width=actual_width, face_height=actual_height, - canvas_width=300, canvas_height=300) - except Exception as e: - matched_canvas.create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") - else: - matched_canvas.create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") - - # Clear and populate unidentified faces - matches_canvas.delete("all") - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - - # Create frame for unidentified faces inside canvas - matches_inner_frame = ttk.Frame(matches_canvas) - matches_canvas.create_window((0, 0), window=matches_inner_frame, anchor="nw") - - # Use cached photo paths instead of database queries - photo_paths = data_cache['photo_paths'] - - # Create all checkboxes - for i, match in enumerate(matches_for_this_person): - # Get unidentified face info from cached data - unidentified_photo_path = photo_paths.get(match['unidentified_photo_id'], '') - - # Calculate confidence - confidence_pct = (1 - match['distance']) * 100 - confidence_desc = self._get_confidence_description(confidence_pct) - - # Create match frame - match_frame = ttk.Frame(matches_inner_frame) - match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5) - - # Checkbox for this match - match_var = tk.BooleanVar() - - # Restore previous checkbox state if available - unique_key = f"{matched_id}_{match['unidentified_id']}" - if matched_id in checkbox_states_per_person and unique_key in checkbox_states_per_person[matched_id]: - saved_state = checkbox_states_per_person[matched_id][unique_key] - match_var.set(saved_state) - if self.verbose >= 2: - print(f"DEBUG: Restored state for person {matched_id}, face {match['unidentified_id']}: {saved_state}") - # Otherwise, pre-select if this face was previously identified for this person - elif matched_id in identified_faces_per_person and match['unidentified_id'] in identified_faces_per_person[matched_id]: - match_var.set(True) - if self.verbose >= 2: - print(f"DEBUG: Pre-selected identified face for person {matched_id}, face {match['unidentified_id']}") - - match_vars.append(match_var) - - # Capture original state at render time (once per person per face) - if matched_id not in original_checkbox_states_per_person: - original_checkbox_states_per_person[matched_id] = {} - if unique_key not in original_checkbox_states_per_person[matched_id]: - original_checkbox_states_per_person[matched_id][unique_key] = match_var.get() - - # Add callback to save state immediately when checkbox changes - def on_checkbox_change(var, person_id, face_id): - unique_key = f"{person_id}_{face_id}" - if person_id not in checkbox_states_per_person: - checkbox_states_per_person[person_id] = {} - - current_value = var.get() - checkbox_states_per_person[person_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Checkbox changed for person {person_id}, face {face_id}: {current_value}") - - # Bind the callback to the variable - match_var.trace('w', lambda *args: on_checkbox_change(match_var, matched_id, match['unidentified_id'])) - - # Configure match frame for grid layout - match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width - match_frame.columnconfigure(1, weight=1) # Text column - expandable - match_frame.columnconfigure(2, weight=0) # Image column - fixed width - - # Checkbox without text - checkbox = ttk.Checkbutton(match_frame, variable=match_var) - checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) - match_checkboxes.append(checkbox) - - # Create labels for confidence and filename - confidence_label = ttk.Label(match_frame, text=f"{confidence_pct:.1f}% {confidence_desc}", font=("Arial", 9, "bold")) - confidence_label.grid(row=0, column=1, sticky=tk.W, padx=(0, 10)) - - filename_label = ttk.Label(match_frame, text=f"šŸ“ {match['unidentified_filename']}", font=("Arial", 8), foreground="gray") - filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10)) - - # Unidentified face image - if show_faces: - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - match_canvas = tk.Canvas(match_frame, width=100, height=100, bg=canvas_bg_color, highlightthickness=0) - match_canvas.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0)) - - unidentified_crop_path = self._extract_face_crop( - unidentified_photo_path, - match['unidentified_location'], - f"unid_{match['unidentified_id']}" - ) - - if unidentified_crop_path and os.path.exists(unidentified_crop_path): - try: - pil_image = Image.open(unidentified_crop_path) - pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(pil_image) - match_canvas.create_image(50, 50, image=photo) - match_canvas.image = photo - - # Add photo icon to the unidentified face - self._create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, - face_x=50, face_y=50, - face_width=100, face_height=100, - canvas_width=100, canvas_height=100) - except Exception as e: - match_canvas.create_text(50, 50, text="āŒ", fill="red") - else: - match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") - - # Update Select All / Clear All button states after populating - update_match_control_buttons_state() - - # Update scroll region - matches_canvas.update_idletasks() - matches_canvas.configure(scrollregion=matches_canvas.bbox("all")) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Wire up search controls now that helper functions exist - try: - search_btn.config(command=lambda: apply_last_name_filter()) - clear_btn.config(command=lambda: clear_last_name_filter()) - search_entry.bind('', lambda e: apply_last_name_filter()) - except Exception: - pass - - # Start with first matched person - update_display() - - # Main event loop - try: - root.mainloop() - except tk.TclError: - pass # Window was destroyed - - return identified_count + return self.auto_match_gui.auto_identify_matches(tolerance, confirm, show_faces, include_same_photo) # Tag management methods (delegated) def add_tags(self, photo_pattern: str = None, batch_size: int = DEFAULT_BATCH_SIZE) -> int: @@ -1124,284 +262,6 @@ class PhotoTagger: return filtered_faces - def _get_confidence_description(self, confidence_pct: float) -> str: - """Get human-readable confidence description""" - if confidence_pct >= 80: - return "🟢 (Very High - Almost Certain)" - elif confidence_pct >= 70: - return "🟔 (High - Likely Match)" - elif confidence_pct >= 60: - return "🟠 (Medium - Possible Match)" - elif confidence_pct >= 50: - return "šŸ”“ (Low - Questionable)" - else: - return "⚫ (Very Low - Unlikely)" - - def _extract_face_crop(self, photo_path: str, location: tuple, face_id: int) -> str: - """Extract and save individual face crop for identification with caching""" - import tempfile - from PIL import Image - - try: - # Check cache first - cache_key = f"{photo_path}_{location}_{face_id}" - if cache_key in self._image_cache: - cached_path = self._image_cache[cache_key] - # Verify the cached file still exists - if os.path.exists(cached_path): - return cached_path - else: - # Remove from cache if file doesn't exist - del self._image_cache[cache_key] - - # Parse location tuple from string format - if isinstance(location, str): - location = eval(location) - - top, right, bottom, left = location - - # Load the image - image = Image.open(photo_path) - - # Add padding around the face (20% of face size) - face_width = right - left - face_height = bottom - top - padding_x = int(face_width * 0.2) - padding_y = int(face_height * 0.2) - - # Calculate crop bounds with padding - crop_left = max(0, left - padding_x) - crop_top = max(0, top - padding_y) - crop_right = min(image.width, right + padding_x) - crop_bottom = min(image.height, bottom + padding_y) - - # Crop the face - face_crop = image.crop((crop_left, crop_top, crop_right, crop_bottom)) - - # Create temporary file for the face crop - temp_dir = tempfile.gettempdir() - face_filename = f"face_{face_id}_crop.jpg" - face_path = os.path.join(temp_dir, face_filename) - - # Resize for better viewing (minimum 200px width) - if face_crop.width < 200: - ratio = 200 / face_crop.width - new_width = 200 - new_height = int(face_crop.height * ratio) - face_crop = face_crop.resize((new_width, new_height), Image.Resampling.LANCZOS) - - face_crop.save(face_path, "JPEG", quality=95) - - # Cache the result - self._image_cache[cache_key] = face_path - return face_path - - except Exception as e: - if self.verbose >= 1: - print(f"āš ļø Could not extract face crop: {e}") - return None - - def _create_photo_icon(self, canvas, photo_path, icon_size=20, icon_x=None, icon_y=None, - canvas_width=None, canvas_height=None, face_x=None, face_y=None, - face_width=None, face_height=None): - """Create a reusable photo icon with tooltip on a canvas""" - import tkinter as tk - import subprocess - import platform - import os - - def open_source_photo(event): - """Open the source photo in a properly sized window""" - 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) - elif system == "Darwin": # macOS - # Use Preview with specific window size - subprocess.run(["open", "-a", "Preview", 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]) - except Exception as e: - print(f"āŒ Could not open photo: {e}") - - # Create tooltip for the icon - tooltip = None - - def show_tooltip(event): - nonlocal tooltip - if tooltip: - tooltip.destroy() - tooltip = tk.Toplevel() - tooltip.wm_overrideredirect(True) - tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}") - label = tk.Label(tooltip, text="Show original photo", - background="lightyellow", relief="solid", borderwidth=1, - font=("Arial", 9)) - label.pack() - - def hide_tooltip(event): - nonlocal tooltip - if tooltip: - tooltip.destroy() - tooltip = None - - # Calculate icon position - if icon_x is None or icon_y is None: - if face_x is not None and face_y is not None and face_width is not None and face_height is not None: - # Position relative to face image - exactly in the corner - face_right = face_x + face_width // 2 - face_top = face_y - face_height // 2 - icon_x = face_right - icon_size - icon_y = face_top - else: - # Position relative to canvas - exactly in the corner - if canvas_width is None: - canvas_width = canvas.winfo_width() - if canvas_height is None: - canvas_height = canvas.winfo_height() - icon_x = canvas_width - icon_size - icon_y = 0 - - # Ensure icon stays within canvas bounds - if canvas_width is None: - canvas_width = canvas.winfo_width() - if canvas_height is None: - canvas_height = canvas.winfo_height() - icon_x = min(icon_x, canvas_width - icon_size) - icon_y = max(icon_y, 0) - - # Draw the photo icon - canvas.create_rectangle(icon_x, icon_y, icon_x + icon_size, icon_y + icon_size, - fill="white", outline="black", width=1, tags="photo_icon") - canvas.create_text(icon_x + icon_size//2, icon_y + icon_size//2, - text="šŸ“·", font=("Arial", 10), tags="photo_icon") - - # Bind events to the icon - canvas.tag_bind("photo_icon", "", open_source_photo) - canvas.tag_bind("photo_icon", "", show_tooltip) - canvas.tag_bind("photo_icon", "", hide_tooltip) - - def _setup_window_size_saving(self, root, config_file="gui_config.json"): - """Set up window size saving functionality""" - import json - import tkinter as tk - - # Load saved window size - default_size = "600x500" - saved_size = default_size - - if os.path.exists(config_file): - try: - with open(config_file, 'r') as f: - config = json.load(f) - saved_size = config.get('window_size', default_size) - except: - saved_size = default_size - - # Calculate center position before showing window - try: - width = int(saved_size.split('x')[0]) - height = int(saved_size.split('x')[1]) - x = (root.winfo_screenwidth() // 2) - (width // 2) - y = (root.winfo_screenheight() // 2) - (height // 2) - root.geometry(f"{saved_size}+{x}+{y}") - except tk.TclError: - # Fallback to default geometry if positioning fails - root.geometry(saved_size) - - # Track previous size to detect actual resizing - last_size = None - - def save_window_size(event=None): - nonlocal last_size - if event and event.widget == root: - current_size = f"{root.winfo_width()}x{root.winfo_height()}" - # Only save if size actually changed - if current_size != last_size: - last_size = current_size - try: - config = {'window_size': current_size} - with open(config_file, 'w') as f: - json.dump(config, f) - except: - pass # Ignore save errors - - # Bind resize event - root.bind('', save_window_size) - return saved_size - - def _cleanup_face_crops(self, current_face_crop_path=None): - """Clean up face crop files and caches""" - # Clean up current face crop if provided - if current_face_crop_path and os.path.exists(current_face_crop_path): - try: - os.remove(current_face_crop_path) - except: - pass # Ignore cleanup errors - - # Clean up all cached face crop files - for cache_key, cached_path in list(self._image_cache.items()): - if os.path.exists(cached_path): - try: - os.remove(cached_path) - except: - pass # Ignore cleanup errors - - # Clear caches - self._clear_caches() - - def _update_person_encodings(self, person_id: int): - """Update person encodings when a face is identified""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Get all faces for this person - cursor.execute( - 'SELECT id, encoding, quality_score FROM faces WHERE person_id = ? ORDER BY quality_score DESC', - (person_id,) - ) - faces = cursor.fetchall() - - # Clear existing person encodings - cursor.execute('DELETE FROM person_encodings WHERE person_id = ?', (person_id,)) - - # Add all faces as person encodings - for face_id, encoding, quality_score in faces: - cursor.execute( - 'INSERT INTO person_encodings (person_id, face_id, encoding, quality_score) VALUES (?, ?, ?, ?)', - (person_id, face_id, encoding, quality_score) - ) - - def _clear_caches(self): - """Clear all caches""" - if hasattr(self.face_processor, '_image_cache'): - self.face_processor._image_cache.clear() - def main(): """Main CLI interface"""