#!/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 # Compute top-left of the centered image for accurate placement actual_width, actual_height = pil_image.size top_left_x = 150 - (actual_width // 2) top_left_y = 150 - (actual_height // 2) self.gui_core.create_photo_icon(matched_canvas, matched_photo_path, icon_size=20, face_x=top_left_x, face_y=top_left_y, 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=0) # Image column - fixed width match_frame.columnconfigure(2, weight=1) # Text column - expandable # 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) # Unidentified face image now immediately after checkbox (right panel face to the right of checkbox) match_canvas = None 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=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10)) 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 exactly at top-right of the face area self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, face_x=0, face_y=0, face_width=100, face_height=100, canvas_width=100, canvas_height=100) except Exception: match_canvas.create_text(50, 50, text="āŒ", fill="red") else: match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") # Confidence badge and filename to the right of image info_container = ttk.Frame(match_frame) info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E)) badge = self.gui_core.create_confidence_badge(info_container, confidence_pct) badge.pack(anchor=tk.W) filename_label = ttk.Label(info_container, text=f"šŸ“ {match['unidentified_filename']}", font=("Arial", 8), foreground="gray") filename_label.pack(anchor=tk.W, pady=(2, 0)) # 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