#!/usr/bin/env python3 """ Face identification GUI implementation for PunimTag """ import os import time 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_BATCH_SIZE, DEFAULT_FACE_TOLERANCE from database import DatabaseManager from face_processing import FaceProcessor from gui_core import GUICore class IdentifyGUI: """Handles the face identification GUI interface""" def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0): """Initialize the identify GUI""" self.db = db_manager self.face_processor = face_processor self.verbose = verbose self.gui_core = GUICore() def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, show_faces: bool = False, tolerance: float = DEFAULT_FACE_TOLERANCE, date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int: """Interactive face identification with optimized performance""" # Get unidentified faces from database unidentified = self._get_unidentified_faces(batch_size, date_from, date_to, date_processed_from, date_processed_to) if not unidentified: print("šŸŽ‰ All faces have been identified!") return 0 print(f"\nšŸ‘¤ Found {len(unidentified)} unidentified faces") print("Commands: [name] = identify, 's' = skip, 'q' = quit, 'list' = show people\n") # Pre-fetch all needed data to avoid repeated database queries print("šŸ“Š Pre-fetching data for optimal performance...") identify_data_cache = self._prefetch_identify_data(unidentified) print(f"āœ… Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names") identified_count = 0 # Create the main window root = tk.Tk() root.title("Face Identification") root.resizable(True, True) # Track window state to prevent multiple destroy calls window_destroyed = False selected_person_id = None force_exit = False # Track current face crop path for cleanup current_face_crop_path = None # Hide window initially to prevent flash at corner root.withdraw() # Set up protocol handler for window close button (X) def on_closing(): nonlocal command, waiting_for_input, window_destroyed, current_face_crop_path, force_exit # First check for selected similar faces without person name if not self._validate_navigation(gui_components): return # Cancel close # Check if there are pending identifications pending_identifications = self._get_pending_identifications(face_person_names, face_status) if pending_identifications: # Ask user if they want to save pending identifications result = messagebox.askyesnocancel( "Save Pending Identifications?", f"You have {len(pending_identifications)} pending identifications.\n\n" "Do you want to save them before closing?\n\n" "• Yes: Save all pending identifications and close\n" "• No: Close without saving\n" "• Cancel: Return to identification" ) if result is True: # Yes - Save and close identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache) # Continue to cleanup and close elif result is False: # No - Close without saving # Continue to cleanup and close pass else: # Cancel - Don't close return # Exit without cleanup or closing # Only reach here if user chose Yes, No, or there are no pending identifications # Clean up face crops and caches self.face_processor.cleanup_face_crops(current_face_crop_path) self.db.close_db_connection() if not window_destroyed: window_destroyed = True try: root.destroy() except tk.TclError: pass # Window already destroyed # Force process termination force_exit = True root.quit() root.protocol("WM_DELETE_WINDOW", on_closing) # Set up window size saving saved_size = self.gui_core.setup_window_size_saving(root) # 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) # Left panel main_frame.columnconfigure(1, weight=1) # Right panel for similar faces # Configure row weights to minimize spacing around Unique checkbox main_frame.rowconfigure(2, weight=0) # Unique checkbox row - no expansion main_frame.rowconfigure(3, weight=1) # Main panels row - expandable # Photo info info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold")) info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) # Store face selection states per face ID to preserve selections during navigation face_selection_states = {} # {face_id: {unique_key: bool}} # Store person names per face ID to preserve names during navigation face_person_names = {} # {face_id: person_name} # Process each face with back navigation support # Keep track of original face list and current position original_faces = list(unidentified) # Make a copy of the original list i = 0 face_status = {} # Track which faces have been identified # Button commands command = None waiting_for_input = False # Define quit handler as local function (like in old version) def on_quit(): nonlocal command, waiting_for_input, window_destroyed, force_exit # First check for unsaved changes in the form validation_result = self._validate_navigation(gui_components) if validation_result == 'cancel': return # Cancel quit elif validation_result == 'save_and_continue': # Save the current identification before proceeding # Add the current form data to pending identifications current_face_key = list(face_person_names.keys())[0] if face_person_names else None if current_face_key: first_name = gui_components['first_name_var'].get().strip() last_name = gui_components['last_name_var'].get().strip() date_of_birth = gui_components['date_of_birth_var'].get().strip() if first_name and last_name and date_of_birth: face_person_names[current_face_key] = { 'first_name': first_name, 'last_name': last_name, 'date_of_birth': date_of_birth } face_status[current_face_key] = 'identified' elif validation_result == 'discard_and_continue': # Clear the form but don't save self._clear_form(gui_components) # Check if there are pending identifications (faces with complete data but not yet saved) pending_identifications = self._get_pending_identifications(face_person_names, face_status) if pending_identifications: # Temporarily disable window close handler to prevent interference root.protocol("WM_DELETE_WINDOW", lambda: None) # Ask user if they want to save pending identifications result = messagebox.askyesnocancel( "Save Pending Identifications?", f"You have {len(pending_identifications)} pending identifications.\n\n" "Do you want to save them before quitting?\n\n" "• Yes: Save all pending identifications and quit\n" "• No: Quit without saving\n" "• Cancel: Return to identification" ) # Re-enable window close handler root.protocol("WM_DELETE_WINDOW", on_closing) if result is True: # Yes - Save and quit identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache) command = 'q' waiting_for_input = False elif result is False: # No - Quit without saving command = 'q' waiting_for_input = False else: # Cancel - Don't quit return # Exit without any cleanup or window destruction else: # No pending identifications, quit normally command = 'q' waiting_for_input = False # Only reach here if user chose Yes, No, or there are no pending identifications # Clean up and close window if not window_destroyed: window_destroyed = True try: root.destroy() except tk.TclError: pass # Window already destroyed # Force process termination force_exit = True root.quit() # Create the GUI components with the quit handler gui_components = self._create_gui_components(main_frame, identify_data_cache, date_from, date_to, date_processed_from, date_processed_to, batch_size, on_quit) # Set up command variable for button callbacks self._current_command_var = gui_components['command_var'] # Show the window try: root.deiconify() root.lift() root.focus_force() # Force window to render completely before proceeding root.update_idletasks() root.update() # Small delay to ensure canvas is properly rendered time.sleep(0.1) # Schedule the first image update after the window is fully rendered def update_first_image(): try: if i < len(original_faces): face_id, photo_id, photo_path, filename, location = original_faces[i] # Extract face crop if enabled face_crop_path = None if show_faces: face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) # Update the face image self._update_face_image(gui_components, show_faces, face_crop_path, photo_path) except Exception as e: print(f"āŒ Error updating first image: {e}") # Schedule the update after a short delay root.after(200, update_first_image) except tk.TclError: # Window was destroyed before we could show it return 0 # Main processing loop while not window_destroyed: # Check if current face is identified and update index if needed if not self._update_current_face_index(original_faces, i, face_status): # All faces have been identified print("\nšŸŽ‰ All faces have been identified!") break # Ensure we don't go beyond the bounds if i >= len(original_faces): # Stay on the last face instead of breaking i = len(original_faces) - 1 face_id, photo_id, photo_path, filename, location = original_faces[i] # Check if this face was already identified in this session is_already_identified = face_id in face_status and face_status[face_id] == 'identified' # Reset command and waiting state for each face command = None waiting_for_input = True # Update the display current_pos, total_unidentified = self._get_current_face_position(original_faces, i, face_status) print(f"\n--- Face {current_pos}/{total_unidentified} (unidentified) ---") print(f"šŸ“ Photo: {filename}") print(f"šŸ“ Face location: {location}") # Update title root.title(f"Face Identification - {current_pos}/{total_unidentified} (unidentified)") # Update button states self._update_button_states(gui_components, original_faces, i, face_status) # Update similar faces panel if compare is enabled if gui_components['compare_var'].get(): self._update_similar_faces(gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i) # Update photo info if is_already_identified: # Get the person name for this face person_name = self._get_person_name_for_face(face_id) info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified}) - āœ… Already identified as: {person_name}") print(f"āœ… Already identified as: {person_name}") else: info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified})") # Extract face crop if enabled face_crop_path = None if show_faces: face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) if face_crop_path: print(f"šŸ–¼ļø Face crop saved: {face_crop_path}") current_face_crop_path = face_crop_path # Track for cleanup else: print("šŸ’” Use --show-faces flag to display individual face crops") current_face_crop_path = None print(f"\nšŸ–¼ļø Viewing face {current_pos}/{total_unidentified} from {filename}") # Clear and update image self._update_face_image(gui_components, show_faces, face_crop_path, photo_path) # Set person name input - restore saved name or use database/empty value self._restore_person_name_input(gui_components, face_id, face_person_names, is_already_identified) # Keep compare checkbox state persistent across navigation gui_components['first_name_entry'].focus_set() gui_components['first_name_entry'].icursor(0) # Force GUI update before waiting for input root.update_idletasks() # Wait for user input while waiting_for_input: try: root.update() # Check for command from GUI buttons if gui_components['command_var'].get(): command = gui_components['command_var'].get() gui_components['command_var'].set("") # Clear the command waiting_for_input = False break # Check for unique checkbox changes if hasattr(self, '_last_unique_state'): current_unique_state = gui_components['unique_var'].get() if current_unique_state != self._last_unique_state: # Unique checkbox state changed, apply filtering original_faces = self._on_unique_faces_change( gui_components, original_faces, i, face_status, date_from, date_to, date_processed_from, date_processed_to ) # Reset index to 0 when filtering changes i = 0 self._last_unique_state = current_unique_state # Continue to next iteration to update display continue else: # Initialize the last unique state self._last_unique_state = gui_components['unique_var'].get() # Check for compare checkbox changes if hasattr(self, '_last_compare_state'): current_compare_state = gui_components['compare_var'].get() if current_compare_state != self._last_compare_state: # Compare checkbox state changed, update similar faces panel self._on_compare_change( gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i ) self._last_compare_state = current_compare_state # Continue to next iteration to update display continue else: # Initialize the last compare state self._last_compare_state = gui_components['compare_var'].get() # Small delay to prevent excessive CPU usage time.sleep(0.01) except tk.TclError: # Window was destroyed, break out of loop break # Check if force exit was requested if force_exit: break # Check if force exit was requested (exit immediately) if force_exit: print("Force exit requested...") # Clean up face crops and caches self.face_processor.cleanup_face_crops(face_crop_path) self.db.close_db_connection() return identified_count # Process the command if command is None: # User clicked Cancel command = 'q' else: command = command.strip() if command.lower() == 'q': # Clean up face crops and caches self.face_processor.cleanup_face_crops(face_crop_path) self.db.close_db_connection() if not window_destroyed: window_destroyed = True try: root.destroy() except tk.TclError: pass # Window already destroyed return identified_count elif command.lower() == 's': print("āž”ļø Next") # Save current checkbox states before navigating away self._save_current_face_selection_states(gui_components, original_faces, i, face_selection_states, face_person_names) # Clean up current face crop when moving forward if face_crop_path and os.path.exists(face_crop_path): try: os.remove(face_crop_path) except: pass # Ignore cleanup errors current_face_crop_path = None # Clear tracked path # Find next unidentified face next_found = False for j in range(i + 1, len(original_faces)): if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': i = j next_found = True break if not next_found: print("āš ļø No more unidentified faces - Next button disabled") continue # Clear date of birth field when moving to next face gui_components['date_of_birth_var'].set("") # Clear middle name and maiden name fields when moving to next face gui_components['middle_name_var'].set("") gui_components['maiden_name_var'].set("") self._update_button_states(gui_components, original_faces, i, face_status) # Only update similar faces if compare is enabled if gui_components['compare_var'].get(): self._update_similar_faces(gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i) continue elif command.lower() == 'back': print("ā¬…ļø Going back to previous face") # Save current checkbox states before navigating away self._save_current_face_selection_states(gui_components, original_faces, i, face_selection_states, face_person_names) # Find previous unidentified face prev_found = False for j in range(i - 1, -1, -1): if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': i = j prev_found = True break if not prev_found: print("āš ļø No more unidentified faces - Back button disabled") continue # Repopulate fields with saved data when going back self._restore_person_name_input(gui_components, original_faces[i][0], face_person_names, False) self._update_button_states(gui_components, original_faces, i, face_status) # Only update similar faces if compare is enabled if gui_components['compare_var'].get(): self._update_similar_faces(gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i) continue elif command == 'reload_faces': # Reload faces with new date filters if 'filtered_faces' in gui_components: # Update the original_faces list with filtered results original_faces = list(gui_components['filtered_faces']) # Update the date filter variables date_from = gui_components.get('new_date_from') date_to = gui_components.get('new_date_to') date_processed_from = gui_components.get('new_date_processed_from') date_processed_to = gui_components.get('new_date_processed_to') # Reset to first face i = 0 # Clear the filtered_faces data del gui_components['filtered_faces'] print("šŸ’” Navigate to refresh the display with filtered faces") continue else: print("āš ļø No filtered faces data found") continue elif command.lower() == 'list': self._show_people_list() continue elif command == 'identify': try: # Get form data form_data = self._get_form_data(gui_components) # Validate form data is_valid, error_msg = self._validate_form_data(form_data) if not is_valid: messagebox.showerror("Validation Error", error_msg) continue # Process identification identified_count += self._process_identification_command( form_data, face_id, is_already_identified, face_status, gui_components, identify_data_cache ) # Clear form after successful identification self._clear_form(gui_components) except Exception as e: print(f"āŒ Error: {e}") messagebox.showerror("Error", f"Error processing identification: {e}") # Increment index for normal flow (identification or error) - but not if we're at the last item if i < len(original_faces) - 1: i += 1 self._update_button_states(gui_components, original_faces, i, face_status) # Only update similar faces if compare is enabled if gui_components['compare_var'].get(): self._update_similar_faces(gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i) # Clean up current face crop when moving forward after identification if face_crop_path and os.path.exists(face_crop_path): try: os.remove(face_crop_path) except: pass # Ignore cleanup errors current_face_crop_path = None # Clear tracked path # Continue to next face after processing command continue elif command: try: # Process other identification command (legacy support) identified_count += self._process_identification_command( command, face_id, is_already_identified, face_status, gui_components, identify_data_cache ) except Exception as e: print(f"āŒ Error: {e}") # Increment index for normal flow (identification or error) - but not if we're at the last item if i < len(original_faces) - 1: i += 1 self._update_button_states(gui_components, original_faces, i, face_status) # Only update similar faces if compare is enabled if gui_components['compare_var'].get(): self._update_similar_faces(gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i) # Clean up current face crop when moving forward after identification if face_crop_path and os.path.exists(face_crop_path): try: os.remove(face_crop_path) except: pass # Ignore cleanup errors current_face_crop_path = None # Clear tracked path # Continue to next face after processing command continue else: print("Please enter a name, 's' to skip, 'q' to quit, or use buttons") # Only close the window if user explicitly quit (not when reaching end of faces) if not window_destroyed: # Keep the window open - user can still navigate and quit manually print(f"\nāœ… Identified {identified_count} faces") print("šŸ’” Application remains open - use Quit button to close") # Don't destroy the window - let user quit manually return identified_count print(f"\nāœ… Identified {identified_count} faces") return identified_count def _get_unidentified_faces(self, batch_size: int, date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None): """Get unidentified faces from database with optional date filtering""" with self.db.get_db_connection() as conn: cursor = conn.cursor() # Build the SQL query with optional date filtering query = ''' SELECT f.id, f.photo_id, p.path, p.filename, f.location FROM faces f JOIN photos p ON f.photo_id = p.id WHERE f.person_id IS NULL ''' params = [] # Add date taken filtering if specified if date_from: query += ' AND p.date_taken >= ?' params.append(date_from) if date_to: query += ' AND p.date_taken <= ?' params.append(date_to) # Add date processed filtering if specified if date_processed_from: query += ' AND DATE(p.date_added) >= ?' params.append(date_processed_from) if date_processed_to: query += ' AND DATE(p.date_added) <= ?' params.append(date_processed_to) query += ' LIMIT ?' params.append(batch_size) cursor.execute(query, params) return cursor.fetchall() def _prefetch_identify_data(self, unidentified): """Pre-fetch all needed data to avoid repeated database queries""" identify_data_cache = {} with self.db.get_db_connection() as conn: cursor = conn.cursor() # Pre-fetch all photo paths for unidentified faces photo_ids = [face[1] for face in unidentified] # face[1] is photo_id if photo_ids: placeholders = ','.join('?' * len(photo_ids)) cursor.execute(f'SELECT id, path, filename FROM photos WHERE id IN ({placeholders})', photo_ids) identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()} # Pre-fetch all people names for dropdown cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') people = cursor.fetchall() identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people] # Pre-fetch unique last names for autocomplete (no DB during typing) cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') _last_rows = cursor.fetchall() identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) return identify_data_cache def _create_gui_components(self, main_frame, identify_data_cache, date_from, date_to, date_processed_from, date_processed_to, batch_size, on_quit=None): """Create all GUI components for the identify interface""" components = {} # Create variables for form data components['compare_var'] = tk.BooleanVar() components['unique_var'] = tk.BooleanVar() components['first_name_var'] = tk.StringVar() components['last_name_var'] = tk.StringVar() components['middle_name_var'] = tk.StringVar() components['maiden_name_var'] = tk.StringVar() components['date_of_birth_var'] = tk.StringVar() # Date filter variables components['date_from_var'] = tk.StringVar(value=date_from or "") components['date_to_var'] = tk.StringVar(value=date_to or "") components['date_processed_from_var'] = tk.StringVar(value=date_processed_from or "") components['date_processed_to_var'] = tk.StringVar(value=date_processed_to or "") # Date filter controls - exactly as in original date_filter_frame = ttk.LabelFrame(main_frame, text="Filter", padding="5") date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) date_filter_frame.columnconfigure(1, weight=0) date_filter_frame.columnconfigure(4, weight=0) # Date from ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_from_var'], width=10, state='readonly') components['date_from_entry'].grid(row=0, column=1, sticky=tk.W, padx=(0, 5)) # Calendar button for date from def open_calendar_from(): self._open_date_picker(components['date_from_var']) components['date_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_from) components['date_from_btn'].grid(row=0, column=2, padx=(0, 10)) # Date to ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_to_var'], width=10, state='readonly') components['date_to_entry'].grid(row=0, column=4, sticky=tk.W, padx=(0, 5)) # Calendar button for date to def open_calendar_to(): self._open_date_picker(components['date_to_var']) components['date_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_to) components['date_to_btn'].grid(row=0, column=5, padx=(0, 10)) # Apply filter button def apply_date_filter(): """Apply date filters and reload faces""" # Get current filter values new_date_from = components['date_from_var'].get().strip() or None new_date_to = components['date_to_var'].get().strip() or None new_date_processed_from = components['date_processed_from_var'].get().strip() or None new_date_processed_to = components['date_processed_to_var'].get().strip() or None # Reload faces with new date filter with self.db.get_db_connection() as conn: cursor = conn.cursor() # Build the SQL query with optional date filtering query = ''' SELECT f.id, f.photo_id, p.path, p.filename, f.location FROM faces f JOIN photos p ON f.photo_id = p.id WHERE f.person_id IS NULL ''' params = [] # Add date taken filtering if specified if new_date_from: query += ' AND p.date_taken >= ?' params.append(new_date_from) if new_date_to: query += ' AND p.date_taken <= ?' params.append(new_date_to) # Add date processed filtering if specified if new_date_processed_from: query += ' AND DATE(p.date_added) >= ?' params.append(new_date_processed_from) if new_date_processed_to: query += ' AND DATE(p.date_added) <= ?' params.append(new_date_processed_to) query += ' LIMIT ?' params.append(batch_size) cursor.execute(query, params) filtered_faces = cursor.fetchall() if not filtered_faces: messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") return # Build filter description filters_applied = [] if new_date_from or new_date_to: taken_filter = f"taken: {new_date_from or 'any'} to {new_date_to or 'any'}" filters_applied.append(taken_filter) if new_date_processed_from or new_date_processed_to: processed_filter = f"processed: {new_date_processed_from or 'any'} to {new_date_processed_to or 'any'}" filters_applied.append(processed_filter) filter_desc = " | ".join(filters_applied) if filters_applied else "no filters" print(f"šŸ“… Applied filters: {filter_desc}") print(f"šŸ‘¤ Found {len(filtered_faces)} unidentified faces with date filters") # Set a special command to reload faces components['command_var'].set("reload_faces") # Store the filtered faces for the main loop to use components['filtered_faces'] = filtered_faces components['new_date_from'] = new_date_from components['new_date_to'] = new_date_to components['new_date_processed_from'] = new_date_processed_from components['new_date_processed_to'] = new_date_processed_to components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) components['apply_filter_btn'].grid(row=0, column=6, padx=(10, 0)) # Date processed filter (second row) ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_from_var'], width=10, state='readonly') components['date_processed_from_entry'].grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0)) # Calendar button for date processed from def open_calendar_processed_from(): self._open_date_picker(components['date_processed_from_var']) components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_from) components['date_processed_from_btn'].grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) # Date processed to ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_to_var'], width=10, state='readonly') components['date_processed_to_entry'].grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0)) # Calendar button for date processed to def open_calendar_processed_to(): self._open_date_picker(components['date_processed_to_var']) components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_to) components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) # Unique checkbox under the filter frame def on_unique_change(): # This will be called when the checkbox state changes # We'll handle the actual filtering in the main loop pass components['unique_check'] = ttk.Checkbutton(main_frame, text="Unique faces only", variable=components['unique_var'], command=on_unique_change) components['unique_check'].grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0) # Compare checkbox on the same row as Unique def on_compare_change(): # This will be called when the checkbox state changes # We'll handle the actual panel toggling in the main loop pass components['compare_check'] = ttk.Checkbutton(main_frame, text="Compare similar faces", variable=components['compare_var'], command=on_compare_change) components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(5, 10), pady=0) # Left panel for main face left_panel = ttk.Frame(main_frame) left_panel.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5), pady=(0, 0)) left_panel.columnconfigure(0, weight=1) # Right panel for similar faces components['right_panel'] = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5") components['right_panel'].grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) components['right_panel'].columnconfigure(0, weight=1) components['right_panel'].rowconfigure(0, weight=1) # Make right panel expandable vertically # Right panel is always visible now # Image display (left panel) image_frame = ttk.Frame(left_panel) image_frame.grid(row=0, column=0, pady=(0, 10), sticky=(tk.W, tk.E, tk.N, tk.S)) image_frame.columnconfigure(0, weight=1) image_frame.rowconfigure(0, weight=1) # Create canvas for image display style = ttk.Style() canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' components['canvas'] = tk.Canvas(image_frame, width=400, height=400, bg=canvas_bg_color, highlightthickness=0) components['canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) # Store reference to current image data for redrawing on resize components['canvas'].current_image_data = None # Bind resize event to redraw image def on_canvas_resize(event): if hasattr(components['canvas'], 'current_image_data') and components['canvas'].current_image_data: # Redraw the current image with new dimensions self._redraw_current_image(components['canvas']) components['canvas'].bind('', on_canvas_resize) # Input section (left panel) input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10") input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E)) input_frame.columnconfigure(1, weight=1) input_frame.columnconfigure(3, weight=1) input_frame.columnconfigure(5, weight=1) input_frame.columnconfigure(7, weight=1) # First name input ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) components['first_name_entry'] = ttk.Entry(input_frame, textvariable=components['first_name_var'], width=12) components['first_name_entry'].grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) # Last name input with autocomplete ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10)) components['last_name_entry'] = ttk.Entry(input_frame, textvariable=components['last_name_var'], width=12) components['last_name_entry'].grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) # Middle name input ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) components['middle_name_entry'] = ttk.Entry(input_frame, textvariable=components['middle_name_var'], width=12) components['middle_name_entry'].grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5)) # Date of birth input with calendar chooser ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10)) # Create a frame for the date picker date_frame = ttk.Frame(input_frame) date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10)) # Maiden name input ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10)) components['maiden_name_entry'] = ttk.Entry(input_frame, textvariable=components['maiden_name_var'], width=12) components['maiden_name_entry'].grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) # Date display entry (read-only) components['date_of_birth_entry'] = ttk.Entry(date_frame, textvariable=components['date_of_birth_var'], width=12, state='readonly') components['date_of_birth_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True) # Calendar button components['date_of_birth_btn'] = ttk.Button(date_frame, text="šŸ“…", width=3, command=lambda: self._open_date_picker(components['date_of_birth_var'])) components['date_of_birth_btn'].pack(side=tk.RIGHT, padx=(15, 0)) # Add required field asterisks (like in original) self._add_required_asterisks(main_frame.master, input_frame, components) # Add autocomplete for last name (like in original) self._setup_last_name_autocomplete(main_frame.master, components, identify_data_cache) # Identify button (placed in Person Identification frame) components['identify_btn'] = ttk.Button(input_frame, text="āœ… Identify", command=lambda: self._set_command('identify'), state='disabled') components['identify_btn'].grid(row=2, column=0, pady=(10, 0), sticky=tk.W) # Add event handlers to update Identify button state def update_identify_button_state(*args): self._update_identify_button_state(components) components['first_name_var'].trace('w', update_identify_button_state) components['last_name_var'].trace('w', update_identify_button_state) components['date_of_birth_var'].trace('w', update_identify_button_state) # Handle Enter key def on_enter(event): if components['identify_btn']['state'] == 'normal': self._set_command('identify') components['first_name_entry'].bind('', on_enter) components['last_name_entry'].bind('', on_enter) components['middle_name_entry'].bind('', on_enter) components['maiden_name_entry'].bind('', on_enter) # Create similar faces frame with controls similar_faces_frame = ttk.Frame(components['right_panel']) similar_faces_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) similar_faces_frame.columnconfigure(0, weight=1) similar_faces_frame.rowconfigure(0, weight=0) # Controls row takes minimal space similar_faces_frame.rowconfigure(1, weight=1) # Canvas row expandable # Control buttons for similar faces (Select All / Clear All) similar_controls_frame = ttk.Frame(similar_faces_frame) similar_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) def select_all_similar_faces(): """Select all similar faces checkboxes""" if hasattr(self, '_similar_face_vars'): for face_id, var in self._similar_face_vars: var.set(True) def clear_all_similar_faces(): """Clear all similar faces checkboxes""" if hasattr(self, '_similar_face_vars'): for face_id, var in self._similar_face_vars: var.set(False) components['select_all_btn'] = ttk.Button(similar_controls_frame, text="ā˜‘ļø Select All", command=select_all_similar_faces, state='disabled') components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) components['clear_all_btn'] = ttk.Button(similar_controls_frame, text="☐ Clear All", command=clear_all_similar_faces, state='disabled') components['clear_all_btn'].pack(side=tk.LEFT) # Create canvas for similar faces with scrollbar similar_canvas = tk.Canvas(similar_faces_frame, bg='lightgray', relief='sunken', bd=2) similar_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) # Similar faces scrollbars similar_v_scrollbar = ttk.Scrollbar(similar_faces_frame, orient='vertical', command=similar_canvas.yview) similar_v_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S)) similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set) # Create scrollable frame for similar faces components['similar_scrollable_frame'] = ttk.Frame(similar_canvas) similar_canvas.create_window((0, 0), window=components['similar_scrollable_frame'], anchor='nw') # Store canvas reference for scrolling components['similar_canvas'] = similar_canvas # Add initial message when compare is disabled no_compare_label = ttk.Label(components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces", foreground="gray", font=("Arial", 10)) no_compare_label.pack(pady=20) # Bottom control panel (move to bottom below panels) control_frame = ttk.Frame(main_frame) control_frame.grid(row=4, column=0, columnspan=3, pady=(10, 0), sticky=(tk.E, tk.S)) # Create button references for state management components['control_back_btn'] = ttk.Button(control_frame, text="ā¬…ļø Back", command=lambda: self._set_command('back')) components['control_next_btn'] = ttk.Button(control_frame, text="āž”ļø Next", command=lambda: self._set_command('s')) if on_quit: components['control_quit_btn'] = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) else: components['control_quit_btn'] = ttk.Button(control_frame, text="āŒ Quit", command=lambda: self._set_command('quit')) components['control_back_btn'].pack(side=tk.LEFT, padx=(0, 5)) components['control_next_btn'].pack(side=tk.LEFT, padx=(0, 5)) components['control_quit_btn'].pack(side=tk.LEFT, padx=(5, 0)) # Store command variable for button callbacks components['command_var'] = tk.StringVar() return components def _add_required_asterisks(self, root, input_frame, components): """Add red asterisks to required fields (first name, last name, date of birth)""" # Red asterisks for required fields (overlayed, no layout impact) first_name_asterisk = ttk.Label(root, text="*", foreground="red") first_name_asterisk.place_forget() last_name_asterisk = ttk.Label(root, text="*", foreground="red") last_name_asterisk.place_forget() date_asterisk = ttk.Label(root, text="*", foreground="red") date_asterisk.place_forget() def _position_required_asterisks(event=None): """Position required asterisks at top-right corner of their entries.""" try: root.update_idletasks() input_frame.update_idletasks() components['first_name_entry'].update_idletasks() components['last_name_entry'].update_idletasks() components['date_of_birth_entry'].update_idletasks() # Get absolute coordinates relative to root window first_root_x = components['first_name_entry'].winfo_rootx() first_root_y = components['first_name_entry'].winfo_rooty() first_w = components['first_name_entry'].winfo_width() root_x = root.winfo_rootx() root_y = root.winfo_rooty() # First name asterisk at the true top-right corner of entry first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw') first_name_asterisk.lift() # Last name asterisk at the true top-right corner of entry last_root_x = components['last_name_entry'].winfo_rootx() last_root_y = components['last_name_entry'].winfo_rooty() last_w = components['last_name_entry'].winfo_width() last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw') last_name_asterisk.lift() # Date of birth asterisk at the true top-right corner of date entry dob_root_x = components['date_of_birth_entry'].winfo_rootx() dob_root_y = components['date_of_birth_entry'].winfo_rooty() dob_w = components['date_of_birth_entry'].winfo_width() date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw') date_asterisk.lift() except Exception: pass # Bind repositioning after all entries are created def _bind_asterisk_positioning(): try: input_frame.bind('', _position_required_asterisks) components['first_name_entry'].bind('', _position_required_asterisks) components['last_name_entry'].bind('', _position_required_asterisks) components['date_of_birth_entry'].bind('', _position_required_asterisks) _position_required_asterisks() except Exception: pass root.after(100, _bind_asterisk_positioning) def _setup_last_name_autocomplete(self, root, components, identify_data_cache): """Setup autocomplete functionality for last name field - exact copy from original""" # Create listbox for suggestions (as overlay attached to root, not clipped by frames) last_name_listbox = tk.Listbox(root, height=8) last_name_listbox.place_forget() # Hide initially # Navigation state variables (like in original) navigating_to_listbox = False escape_pressed = False enter_pressed = False def _show_suggestions(): """Show filtered suggestions in listbox""" all_last_names = identify_data_cache.get('last_names', []) typed = components['last_name_var'].get().strip() if not typed: filtered = [] # Show nothing if no typing else: low = typed.lower() # Only show names that start with the typed text filtered = [n for n in all_last_names if n.lower().startswith(low)][:10] # Update listbox last_name_listbox.delete(0, tk.END) for name in filtered: last_name_listbox.insert(tk.END, name) # Show listbox if we have suggestions (as overlay) if filtered: # Ensure geometry is up to date before positioning root.update_idletasks() # Absolute coordinates of entry relative to screen entry_root_x = components['last_name_entry'].winfo_rootx() entry_root_y = components['last_name_entry'].winfo_rooty() entry_height = components['last_name_entry'].winfo_height() # Convert to coordinates relative to root root_origin_x = root.winfo_rootx() root_origin_y = root.winfo_rooty() place_x = entry_root_x - root_origin_x place_y = entry_root_y - root_origin_y + entry_height place_width = components['last_name_entry'].winfo_width() # Calculate how many rows fit to bottom of window available_px = max(60, root.winfo_height() - place_y - 8) # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows approx_row_px = 18 rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) last_name_listbox.configure(height=rows_fit) last_name_listbox.place(x=place_x, y=place_y, width=place_width) last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(0) # Select first item last_name_listbox.activate(0) # Activate first item else: last_name_listbox.place_forget() def _hide_suggestions(): """Hide the suggestions listbox""" last_name_listbox.place_forget() def _on_listbox_select(event=None): """Handle listbox selection and hide list""" selection = last_name_listbox.curselection() if selection: selected_name = last_name_listbox.get(selection[0]) components['last_name_var'].set(selected_name) _hide_suggestions() components['last_name_entry'].focus_set() def _on_listbox_click(event): """Handle mouse click selection""" try: index = last_name_listbox.nearest(event.y) if index is not None and index >= 0: selected_name = last_name_listbox.get(index) components['last_name_var'].set(selected_name) except: pass _hide_suggestions() components['last_name_entry'].focus_set() return 'break' def _on_key_press(event): """Handle key navigation in entry""" nonlocal navigating_to_listbox, escape_pressed, enter_pressed if event.keysym == 'Down': if last_name_listbox.winfo_ismapped(): navigating_to_listbox = True last_name_listbox.focus_set() last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(0) last_name_listbox.activate(0) return 'break' elif event.keysym == 'Escape': escape_pressed = True _hide_suggestions() return 'break' elif event.keysym == 'Return': enter_pressed = True return 'break' def _on_listbox_key(event): """Handle key navigation in listbox""" nonlocal enter_pressed, escape_pressed if event.keysym == 'Return': enter_pressed = True _on_listbox_select(event) return 'break' elif event.keysym == 'Escape': escape_pressed = True _hide_suggestions() components['last_name_entry'].focus_set() return 'break' elif event.keysym == 'Up': selection = last_name_listbox.curselection() if selection and selection[0] > 0: # Move up in listbox last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(selection[0] - 1) last_name_listbox.see(selection[0] - 1) else: # At top, go back to entry field _hide_suggestions() components['last_name_entry'].focus_set() return 'break' elif event.keysym == 'Down': selection = last_name_listbox.curselection() max_index = last_name_listbox.size() - 1 if selection and selection[0] < max_index: # Move down in listbox last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(selection[0] + 1) last_name_listbox.see(selection[0] + 1) return 'break' # Track if we're navigating to listbox to prevent auto-hide navigating_to_listbox = False escape_pressed = False enter_pressed = False def _safe_hide_suggestions(): """Hide suggestions only if not navigating to listbox""" nonlocal navigating_to_listbox if not navigating_to_listbox: _hide_suggestions() navigating_to_listbox = False def _safe_show_suggestions(): """Show suggestions only if escape or enter wasn't just pressed""" nonlocal escape_pressed, enter_pressed if not escape_pressed and not enter_pressed: _show_suggestions() escape_pressed = False enter_pressed = False # Bind events components['last_name_entry'].bind('', lambda e: _safe_show_suggestions()) components['last_name_entry'].bind('', _on_key_press) components['last_name_entry'].bind('', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks last_name_listbox.bind('', _on_listbox_click) last_name_listbox.bind('', _on_listbox_key) last_name_listbox.bind('', _on_listbox_click) def _update_identify_button_state(self, gui_components): """Update the state of the Identify button based on form data""" first_name = gui_components['first_name_var'].get().strip() last_name = gui_components['last_name_var'].get().strip() date_of_birth = gui_components['date_of_birth_var'].get().strip() # Enable button if we have at least first name or last name, and date of birth if (first_name or last_name) and date_of_birth: gui_components['identify_btn'].config(state='normal') else: gui_components['identify_btn'].config(state='disabled') def _update_control_button_states(self, gui_components, i, total_faces): """Update the state of control buttons based on current position""" # Back button - disabled if at first face if i <= 0: gui_components['control_back_btn'].config(state='disabled') else: gui_components['control_back_btn'].config(state='normal') # Next button - disabled if at last face if i >= total_faces - 1: gui_components['control_next_btn'].config(state='disabled') else: gui_components['control_next_btn'].config(state='normal') def _update_select_clear_buttons_state(self, gui_components, similar_face_vars): """Enable/disable Select All and Clear All based on compare state and presence of items""" if gui_components['compare_var'].get() and similar_face_vars: gui_components['select_all_btn'].config(state='normal') gui_components['clear_all_btn'].config(state='normal') else: gui_components['select_all_btn'].config(state='disabled') gui_components['clear_all_btn'].config(state='disabled') def _get_pending_identifications(self, face_person_names, face_status): """Get pending identifications that haven't been saved yet""" pending_identifications = {} for k, v in face_person_names.items(): if k not in face_status or face_status[k] != 'identified': # Handle person data dict format if isinstance(v, dict): first_name = v.get('first_name', '').strip() last_name = v.get('last_name', '').strip() date_of_birth = v.get('date_of_birth', '').strip() # Check if we have complete data (both first and last name, plus date of birth) if first_name and last_name and date_of_birth: pending_identifications[k] = v return pending_identifications def _save_all_pending_identifications(self, face_person_names, face_status, identify_data_cache): """Save all pending identifications from face_person_names""" saved_count = 0 for face_id, person_data in face_person_names.items(): # Handle person data dict format if isinstance(person_data, dict): first_name = person_data.get('first_name', '').strip() last_name = person_data.get('last_name', '').strip() date_of_birth = person_data.get('date_of_birth', '').strip() middle_name = person_data.get('middle_name', '').strip() maiden_name = person_data.get('maiden_name', '').strip() # Only save if we have at least a first or last name if first_name or last_name: try: with self.db.get_db_connection() as conn: cursor = conn.cursor() # Add person if doesn't exist cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) result = cursor.fetchone() person_id = result[0] if result else None # Update people cache if new person was added display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) if display_name not in identify_data_cache['people_names']: identify_data_cache['people_names'].append(display_name) identify_data_cache['people_names'].sort() # Keep sorted # Keep last names cache updated in-session if last_name: if 'last_names' not in identify_data_cache: identify_data_cache['last_names'] = [] if last_name not in identify_data_cache['last_names']: identify_data_cache['last_names'].append(last_name) identify_data_cache['last_names'].sort() # Assign face to person cursor.execute( 'UPDATE faces SET person_id = ? WHERE id = ?', (person_id, face_id) ) # Update person encodings self.face_processor.update_person_encodings(person_id) saved_count += 1 display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) print(f"āœ… Saved identification: {display_name}") except Exception as e: display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) print(f"āŒ Error saving identification for {display_name}: {e}") if saved_count > 0: print(f"šŸ’¾ Saved {saved_count} pending identifications") return saved_count def _update_current_face_index(self, original_faces, i, face_status): """Update the current face index to point to a valid unidentified face""" unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] if not unidentified_faces: # All faces identified, we're done return False # Find the current face in the unidentified list current_face_id = original_faces[i][0] if i < len(original_faces) else None if current_face_id and current_face_id in face_status and face_status[current_face_id] == 'identified': # Current face was just identified, find the next unidentified face if i < len(original_faces) - 1: # Try to find the next unidentified face for j in range(i + 1, len(original_faces)): if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': i = j break else: # No more faces after current, go to previous for j in range(i - 1, -1, -1): if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': i = j break else: # At the end, go to previous unidentified face for j in range(i - 1, -1, -1): if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': i = j break # Ensure index is within bounds if i >= len(original_faces): i = len(original_faces) - 1 if i < 0: i = 0 return True def _get_current_face_position(self, original_faces, i, face_status): """Get current face position among unidentified faces""" unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] current_face_id = original_faces[i][0] if i < len(original_faces) else None # Find position of current face in unidentified list for pos, face in enumerate(unidentified_faces): if face[0] == current_face_id: return pos + 1, len(unidentified_faces) return 1, len(unidentified_faces) # Fallback def _update_button_states(self, gui_components, original_faces, i, face_status): """Update button states based on current position and unidentified faces""" # Update control button states self._update_control_button_states(gui_components, i, len(original_faces)) # Update identify button state self._update_identify_button_state(gui_components) # Update similar faces control buttons state # Get similar face variables if they exist similar_face_vars = getattr(self, '_similar_face_vars', []) self._update_select_clear_buttons_state(gui_components, similar_face_vars) def _update_similar_faces(self, gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i): """Update the similar faces panel when compare is enabled""" try: if not gui_components['compare_var'].get(): return scrollable_frame = gui_components['similar_scrollable_frame'] # Clear existing content for widget in scrollable_frame.winfo_children(): widget.destroy() # Get similar faces using filtered version (includes 40% confidence threshold) similar_faces = self.face_processor._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status) if not similar_faces: no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found", foreground="gray", font=("Arial", 10)) no_faces_label.pack(pady=20) return # Filter out already identified faces if unique checkbox is checked if gui_components['unique_var'].get(): similar_faces = [face for face in similar_faces if face.get('person_id') is None] if not similar_faces: no_faces_label = ttk.Label(scrollable_frame, text="No unique similar faces found", foreground="gray", font=("Arial", 10)) no_faces_label.pack(pady=20) return # Sort by confidence (distance) - highest confidence first (lowest distance) similar_faces.sort(key=lambda x: x['distance']) # Display similar faces using the old version's approach self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id, face_selection_states, identify_data_cache) # Update canvas scroll region canvas = gui_components['similar_canvas'] canvas.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all")) if self.verbose >= 2: print(f" šŸ” Displayed {len(similar_faces)} similar faces") except Exception as e: print(f"āŒ Error updating similar faces: {e}") def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id, face_selection_states, identify_data_cache): """Display similar faces in a panel - based on old version's auto-match display logic""" import tkinter as tk from tkinter import ttk from PIL import Image, ImageTk import os # Store similar face variables for Select All/Clear All functionality similar_face_vars = [] # Create all similar faces using auto-match style display for i, face_data in enumerate(similar_faces_data[:10]): # Limit to 10 faces similar_face_id = face_data['face_id'] filename = face_data['filename'] distance = face_data['distance'] quality = face_data.get('quality_score', 0.5) # Calculate confidence like in auto-match confidence_pct = (1 - distance) * 100 confidence_desc = self._get_confidence_description(confidence_pct) # Create match frame using auto-match style match_frame = ttk.Frame(parent_frame) match_frame.pack(fill=tk.X, padx=5, pady=5) # Checkbox for this match (reusing auto-match checkbox style) match_var = tk.BooleanVar() # Restore previous checkbox state if available (auto-match style) if current_face_id is not None and face_selection_states is not None: unique_key = f"{current_face_id}_{similar_face_id}" if current_face_id in face_selection_states and unique_key in face_selection_states[current_face_id]: saved_state = face_selection_states[current_face_id][unique_key] match_var.set(saved_state) # Add immediate callback to save state when checkbox changes (auto-match style) def make_callback(var, face_id, similar_face_id): def on_checkbox_change(*args): unique_key = f"{face_id}_{similar_face_id}" if face_id not in face_selection_states: face_selection_states[face_id] = {} face_selection_states[face_id][unique_key] = var.get() return on_checkbox_change # Bind the callback to the variable match_var.trace('w', make_callback(match_var, current_face_id, similar_face_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)) # Add to similar face variables list similar_face_vars.append((similar_face_id, match_var)) # Right panel requirement: image immediately to the right of checkbox # Create canvas for face image next to checkbox style = ttk.Style() canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' match_canvas = tk.Canvas(match_frame, width=80, height=80, bg=canvas_bg_color, highlightthickness=0) match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10)) # Create labels container 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)) # Confidence badge badge = self.gui_core.create_confidence_badge(info_container, confidence_pct) badge.pack(anchor=tk.W) filename_label = ttk.Label(info_container, text=f"šŸ“ {filename}", font=("Arial", 8), foreground="gray") filename_label.pack(anchor=tk.W, pady=(2, 0)) # Face image (reusing auto-match image display) try: # Get photo path from cache or database photo_path = None if identify_data_cache and 'photo_paths' in identify_data_cache: # Find photo path by filename in cache for photo_data in identify_data_cache['photo_paths'].values(): if photo_data['filename'] == filename: photo_path = photo_data['path'] break # Fallback to database if not in cache if photo_path is None: with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT path FROM photos WHERE filename = ?', (filename,)) result = cursor.fetchone() photo_path = result[0] if result else None # Extract face crop using existing method face_crop_path = self.face_processor._extract_face_crop(photo_path, face_data['location'], similar_face_id) if face_crop_path and os.path.exists(face_crop_path): # Load and display image pil_image = Image.open(face_crop_path) pil_image.thumbnail((80, 80), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(pil_image) match_canvas.create_image(40, 40, image=photo) match_canvas.image = photo # Keep reference # Add photo icon exactly at the image's top-right corner self.gui_core.create_photo_icon(match_canvas, photo_path, icon_size=15, face_x=0, face_y=0, face_width=80, face_height=80, canvas_width=80, canvas_height=80) # Clean up temporary face crop try: os.remove(face_crop_path) except: pass except Exception as e: if self.verbose >= 1: print(f" āš ļø Error displaying similar face {i}: {e}") continue # Store similar face variables for Select All/Clear All functionality self._similar_face_vars = similar_face_vars def _get_confidence_description(self, confidence_pct): """Get confidence description based on percentage""" if confidence_pct >= 80: return "Very High" elif confidence_pct >= 70: return "High" elif confidence_pct >= 60: return "Medium" elif confidence_pct >= 50: return "Low" else: return "Very Low" def _on_similar_face_select(self, similar_face_id, is_selected): """Handle similar face checkbox selection""" # This would be used to track which similar faces are selected # For now, just a placeholder pass def _get_person_name_for_face(self, face_id): """Get person name for a face that's already identified""" with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT p.first_name, p.last_name FROM people p JOIN faces f ON p.id = f.person_id WHERE f.id = ? ''', (face_id,)) result = cursor.fetchone() if result: first_name, last_name = result if last_name and first_name: return f"{last_name}, {first_name}" elif last_name: return last_name elif first_name: return first_name else: return "Unknown" else: return "Unknown" def _update_face_image(self, gui_components, show_faces, face_crop_path, photo_path): """Update the face image display""" try: canvas = gui_components['canvas'] # Clear existing image canvas.delete("all") # Determine which image to display if show_faces and face_crop_path and os.path.exists(face_crop_path): # Display face crop image_path = face_crop_path image_type = "face crop" elif photo_path and os.path.exists(photo_path): # Display full photo image_path = photo_path image_type = "full photo" else: # No image available canvas.create_text(200, 200, text="No image available", font=("Arial", 12), fill="gray") return # Load and display image try: with Image.open(image_path) as img: # Force canvas to update its dimensions canvas.update_idletasks() # Get canvas dimensions - use configured size if not yet rendered canvas_width = canvas.winfo_width() canvas_height = canvas.winfo_height() # If canvas hasn't been rendered yet, use the configured size if canvas_width <= 1 or canvas_height <= 1: canvas_width = 400 canvas_height = 400 # Set a minimum size to ensure proper rendering canvas.configure(width=canvas_width, height=canvas_height) # Get image dimensions img_width, img_height = img.size # Calculate scale factor to fit image in canvas scale_x = canvas_width / img_width scale_y = canvas_height / img_height scale = min(scale_x, scale_y, 1.0) # Don't scale up # Calculate new dimensions new_width = int(img_width * scale) new_height = int(img_height * scale) # Resize image img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) # Convert to PhotoImage photo = ImageTk.PhotoImage(img_resized) # Center image on canvas x = (canvas_width - new_width) // 2 y = (canvas_height - new_height) // 2 # Create image on canvas canvas.create_image(x, y, anchor='nw', image=photo) # Keep reference to prevent garbage collection canvas.image_ref = photo # Store image data for redrawing on resize canvas.current_image_data = { 'image_path': image_path, 'image_type': image_type, 'original_img': img, 'img_width': img_width, 'img_height': img_height } # Add photo icon using reusable function self.gui_core.create_photo_icon(canvas, photo_path, face_x=x, face_y=y, face_width=new_width, face_height=new_height, canvas_width=canvas_width, canvas_height=canvas_height) # Update canvas scroll region canvas.configure(scrollregion=canvas.bbox("all")) if self.verbose >= 2: print(f" šŸ–¼ļø Displayed {image_type}: {os.path.basename(image_path)} ({new_width}x{new_height})") except Exception as e: canvas.create_text(200, 200, text=f"Error loading image:\n{str(e)}", font=("Arial", 10), fill="red") if self.verbose >= 1: print(f" āš ļø Error loading image {image_path}: {e}") except Exception as e: print(f"āŒ Error updating face image: {e}") def _redraw_current_image(self, canvas): """Redraw the current image when canvas is resized""" try: if not hasattr(canvas, 'current_image_data') or not canvas.current_image_data: return # Clear existing image canvas.delete("all") # Get stored image data data = canvas.current_image_data img = data['original_img'] img_width = data['img_width'] img_height = data['img_height'] # Get current canvas dimensions canvas_width = canvas.winfo_width() canvas_height = canvas.winfo_height() if canvas_width <= 1 or canvas_height <= 1: return # Calculate new scale scale_x = canvas_width / img_width scale_y = canvas_height / img_height scale = min(scale_x, scale_y, 1.0) # Calculate new dimensions new_width = int(img_width * scale) new_height = int(img_height * scale) # Resize image img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) # Convert to PhotoImage photo = ImageTk.PhotoImage(img_resized) # Center image on canvas x = (canvas_width - new_width) // 2 y = (canvas_height - new_height) // 2 # Create image on canvas canvas.create_image(x, y, anchor='nw', image=photo) # Keep reference to prevent garbage collection canvas.image_ref = photo # Add photo icon using reusable function self.gui_core.create_photo_icon(canvas, data['image_path'], face_x=x, face_y=y, face_width=new_width, face_height=new_height, canvas_width=canvas_width, canvas_height=canvas_height) # Update canvas scroll region canvas.configure(scrollregion=canvas.bbox("all")) except Exception as e: print(f"āŒ Error redrawing image: {e}") def _restore_person_name_input(self, gui_components, face_id, face_person_names, is_already_identified): """Restore person name input fields""" try: if is_already_identified: # Get person data from database with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth FROM people p JOIN faces f ON p.id = f.person_id WHERE f.id = ? ''', (face_id,)) result = cursor.fetchone() if result: first_name, last_name, middle_name, maiden_name, date_of_birth = result gui_components['first_name_var'].set(first_name or "") gui_components['last_name_var'].set(last_name or "") gui_components['middle_name_var'].set(middle_name or "") gui_components['maiden_name_var'].set(maiden_name or "") gui_components['date_of_birth_var'].set(date_of_birth or "") else: # Clear all fields if no person found self._clear_form(gui_components) else: # Restore from saved data if available if face_id in face_person_names: person_data = face_person_names[face_id] if isinstance(person_data, dict): gui_components['first_name_var'].set(person_data.get('first_name', '')) gui_components['last_name_var'].set(person_data.get('last_name', '')) gui_components['middle_name_var'].set(person_data.get('middle_name', '')) gui_components['maiden_name_var'].set(person_data.get('maiden_name', '')) gui_components['date_of_birth_var'].set(person_data.get('date_of_birth', '')) else: # Legacy string format gui_components['first_name_var'].set(person_data or "") gui_components['last_name_var'].set("") gui_components['middle_name_var'].set("") gui_components['maiden_name_var'].set("") gui_components['date_of_birth_var'].set("") else: # Clear all fields for new face self._clear_form(gui_components) except Exception as e: print(f"āŒ Error restoring person name input: {e}") # Clear form on error self._clear_form(gui_components) def _save_current_face_selection_states(self, gui_components, original_faces, i, face_selection_states, face_person_names): """Save current checkbox states and person name for the current face""" try: if i >= len(original_faces): return current_face_id = original_faces[i][0] # Save form data form_data = self._get_form_data(gui_components) face_person_names[current_face_id] = form_data # Save checkbox states for similar faces if current_face_id not in face_selection_states: face_selection_states[current_face_id] = {} # Note: Similar face checkbox states would be saved here # This would require tracking the checkbox variables created in _update_similar_faces except Exception as e: print(f"āŒ Error saving face selection states: {e}") def _process_identification_command(self, command_or_data, face_id, is_already_identified, face_status, gui_components, identify_data_cache): """Process an identification command""" try: # Handle form data (new GUI approach) if isinstance(command_or_data, dict): form_data = command_or_data first_name = form_data.get('first_name', '').strip() last_name = form_data.get('last_name', '').strip() middle_name = form_data.get('middle_name', '').strip() maiden_name = form_data.get('maiden_name', '').strip() date_of_birth = form_data.get('date_of_birth', '').strip() else: # Handle legacy string command command = command_or_data.strip() if not command: return 0 # Parse simple name format (legacy support) parts = command.split() if len(parts) >= 2: first_name = parts[0] last_name = ' '.join(parts[1:]) else: first_name = command last_name = "" middle_name = "" maiden_name = "" date_of_birth = "" # Legacy commands don't include date of birth # Add person if doesn't exist with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) result = cursor.fetchone() person_id = result[0] if result else None # Update people cache if new person was added display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) if display_name not in identify_data_cache['people_names']: identify_data_cache['people_names'].append(display_name) identify_data_cache['people_names'].sort() # Keep sorted # Keep last names cache updated in-session if last_name: if 'last_names' not in identify_data_cache: identify_data_cache['last_names'] = [] if last_name not in identify_data_cache['last_names']: identify_data_cache['last_names'].append(last_name) identify_data_cache['last_names'].sort() # Assign face to person with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute( 'UPDATE faces SET person_id = ? WHERE id = ?', (person_id, face_id) ) # Update person encodings self.face_processor.update_person_encodings(person_id) # Mark face as identified face_status[face_id] = 'identified' display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) print(f"āœ… Identified as: {display_name}") return 1 except Exception as e: print(f"āŒ Error processing identification: {e}") return 0 def _show_people_list(self): """Show list of known people""" with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') people = cursor.fetchall() if people: formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last] print("šŸ‘„ Known people:", ", ".join(formatted_names)) else: print("šŸ‘„ No people identified yet") def _open_date_picker(self, date_var): """Open date picker dialog and update the date variable""" current_date = date_var.get() selected_date = self.gui_core.create_calendar_dialog(None, "Select Date", current_date) if selected_date is not None: date_var.set(selected_date) def _toggle_similar_faces_panel(self, components): """Update the similar faces panel content based on compare checkbox state""" # Panel is always visible now, just update content if not components['compare_var'].get(): # Clear the similar faces content when compare is disabled scrollable_frame = components['similar_scrollable_frame'] for widget in scrollable_frame.winfo_children(): widget.destroy() # Show a message that compare is disabled no_compare_label = ttk.Label(scrollable_frame, text="Enable 'Compare similar faces' to see similar faces", foreground="gray", font=("Arial", 10)) no_compare_label.pack(pady=20) def _set_command(self, command): """Set the command variable to trigger the main loop""" # This will be used by button callbacks to set the command # The main loop will check this variable if hasattr(self, '_current_command_var'): self._current_command_var.set(command) def _validate_navigation(self, gui_components): """Validate that navigation is safe (no unsaved changes)""" # Check if there are any unsaved changes in the form first_name = gui_components['first_name_var'].get().strip() last_name = gui_components['last_name_var'].get().strip() date_of_birth = gui_components['date_of_birth_var'].get().strip() # If all three required fields are filled, ask for confirmation if first_name and last_name and date_of_birth: result = messagebox.askyesnocancel( "Unsaved Changes", "You have unsaved changes in the identification form.\n\n" "Do you want to save them before continuing?\n\n" "• Yes: Save current identification and continue\n" "• No: Discard changes and continue\n" "• Cancel: Stay on current face" ) if result is True: # Yes - Save and continue return 'save_and_continue' elif result is False: # No - Discard and continue return 'discard_and_continue' else: # Cancel - Don't navigate return 'cancel' return 'continue' # No changes, safe to continue def _clear_form(self, gui_components): """Clear all form fields""" gui_components['first_name_var'].set("") gui_components['last_name_var'].set("") gui_components['middle_name_var'].set("") gui_components['maiden_name_var'].set("") gui_components['date_of_birth_var'].set("") def _get_form_data(self, gui_components): """Get current form data as a dictionary""" return { 'first_name': gui_components['first_name_var'].get().strip(), 'last_name': gui_components['last_name_var'].get().strip(), 'middle_name': gui_components['middle_name_var'].get().strip(), 'maiden_name': gui_components['maiden_name_var'].get().strip(), 'date_of_birth': gui_components['date_of_birth_var'].get().strip() } def _set_form_data(self, gui_components, form_data): """Set form data from a dictionary""" gui_components['first_name_var'].set(form_data.get('first_name', '')) gui_components['last_name_var'].set(form_data.get('last_name', '')) gui_components['middle_name_var'].set(form_data.get('middle_name', '')) gui_components['maiden_name_var'].set(form_data.get('maiden_name', '')) gui_components['date_of_birth_var'].set(form_data.get('date_of_birth', '')) def _validate_form_data(self, form_data): """Validate that form data is complete enough for identification""" first_name = form_data.get('first_name', '').strip() last_name = form_data.get('last_name', '').strip() date_of_birth = form_data.get('date_of_birth', '').strip() # Need at least first name or last name, and date of birth if not (first_name or last_name): return False, "Please enter at least a first name or last name" if not date_of_birth: return False, "Please enter a date of birth" # Validate date format try: from datetime import datetime datetime.strptime(date_of_birth, '%Y-%m-%d') except ValueError: return False, "Please enter date of birth in YYYY-MM-DD format" return True, "Valid" def _filter_unique_faces_from_list(self, faces_list: List[tuple]) -> List[tuple]: """Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches""" if not faces_list: return faces_list # Extract face IDs from the list face_ids = [face_tuple[0] for face_tuple in faces_list] # Get face encodings from database for all faces face_encodings = {} with self.db.get_db_connection() as conn: cursor = conn.cursor() placeholders = ','.join('?' * len(face_ids)) cursor.execute(f''' SELECT id, encoding FROM faces WHERE id IN ({placeholders}) AND encoding IS NOT NULL ''', face_ids) for face_id, encoding_blob in cursor.fetchall(): try: import numpy as np # Load encoding as numpy array (not pickle) encoding = np.frombuffer(encoding_blob, dtype=np.float64) face_encodings[face_id] = encoding except Exception: continue # If we don't have enough encodings, return original list if len(face_encodings) < 2: return faces_list # Calculate distances between all faces using existing encodings face_distances = {} face_id_list = list(face_encodings.keys()) for i, face_id1 in enumerate(face_id_list): for j, face_id2 in enumerate(face_id_list): if i != j: try: import face_recognition encoding1 = face_encodings[face_id1] encoding2 = face_encodings[face_id2] # Calculate distance distance = face_recognition.face_distance([encoding1], encoding2)[0] face_distances[(face_id1, face_id2)] = distance except Exception: # If calculation fails, assume no match face_distances[(face_id1, face_id2)] = 1.0 # Apply unique faces filtering unique_faces = [] seen_face_groups = set() for face_tuple in faces_list: face_id = face_tuple[0] # Skip if we don't have encoding for this face if face_id not in face_encodings: unique_faces.append(face_tuple) continue # Find all faces that match this one with high/medium confidence matching_face_ids = set([face_id]) # Include self for other_face_id in face_encodings.keys(): if other_face_id != face_id: distance = face_distances.get((face_id, other_face_id), 1.0) confidence_pct = (1 - distance) * 100 # If this face matches with high/medium confidence if confidence_pct >= 60: matching_face_ids.add(other_face_id) # Create a sorted tuple to represent this group of matching faces face_group = tuple(sorted(matching_face_ids)) # Only show this face if we haven't seen this group before if face_group not in seen_face_groups: seen_face_groups.add(face_group) unique_faces.append(face_tuple) return unique_faces def _on_unique_faces_change(self, gui_components, original_faces, i, face_status, date_from, date_to, date_processed_from, date_processed_to): """Handle unique faces checkbox change""" if gui_components['unique_var'].get(): # Show progress message print("šŸ”„ Applying unique faces filter...") # Apply unique faces filtering to the main face list try: filtered_faces = self._filter_unique_faces_from_list(original_faces) print(f"āœ… Filter applied: {len(filtered_faces)} unique faces remaining") # Update the original_faces list with filtered results # We need to return the filtered list to update the caller return filtered_faces except Exception as e: print(f"āš ļø Error applying filter: {e}") # Revert checkbox state gui_components['unique_var'].set(False) return original_faces else: # Reload the original unfiltered face list print("šŸ”„ Reloading all faces...") # Get fresh unfiltered faces from database with self.db.get_db_connection() as conn: cursor = conn.cursor() query = ''' SELECT f.id, f.photo_id, p.path, p.filename, f.location FROM faces f JOIN photos p ON f.photo_id = p.id WHERE f.person_id IS NULL ''' params = [] # Add date taken filtering if specified if date_from: query += ' AND p.date_taken >= ?' params.append(date_from) if date_to: query += ' AND p.date_taken <= ?' params.append(date_to) # Add date processed filtering if specified if date_processed_from: query += ' AND DATE(p.date_added) >= ?' params.append(date_processed_from) if date_processed_to: query += ' AND DATE(p.date_added) <= ?' params.append(date_processed_to) cursor.execute(query, params) unfiltered_faces = cursor.fetchall() print(f"āœ… Reloaded: {len(unfiltered_faces)} total faces") return unfiltered_faces def _on_compare_change(self, gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i): """Handle compare checkbox change""" # Toggle panel visibility self._toggle_similar_faces_panel(gui_components) # Update similar faces if compare is now enabled if gui_components['compare_var'].get(): self._update_similar_faces(gui_components, face_id, tolerance, face_status, face_selection_states, identify_data_cache, original_faces, i)