From de23fccf6abeba0a1b0262dc378da146a32714f9 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 9 Oct 2025 15:36:44 -0400 Subject: [PATCH] Integrate Identify Panel into Dashboard GUI for enhanced face identification functionality This commit introduces the IdentifyPanel class into the Dashboard GUI, allowing for a fully integrated face identification interface. The Dashboard now requires a database manager and face processor to create the Identify panel, which includes features for face browsing, identification, and management. Additionally, the DatabaseManager has been updated to support case-insensitive person additions, improving data consistency. The PhotoTagger class has also been modified to accommodate these changes, ensuring seamless interaction between components. --- dashboard_gui.py | 47 +- database.py | 20 +- identify_panel.py | 1426 +++++++++++++++++++++++++++++++++++++++++++++ photo_tagger.py | 21 +- 4 files changed, 1477 insertions(+), 37 deletions(-) create mode 100644 identify_panel.py diff --git a/dashboard_gui.py b/dashboard_gui.py index 0c72e50..6176aae 100644 --- a/dashboard_gui.py +++ b/dashboard_gui.py @@ -11,6 +11,7 @@ from tkinter import ttk, messagebox from typing import Dict, Optional, Callable from gui_core import GUICore +from identify_panel import IdentifyPanel class DashboardGUI: @@ -20,8 +21,10 @@ class DashboardGUI: navigation (menu bar) and content (panels). """ - def __init__(self, gui_core: GUICore, on_scan=None, on_process=None, on_identify=None): + def __init__(self, gui_core: GUICore, db_manager=None, face_processor=None, on_scan=None, on_process=None, on_identify=None): self.gui_core = gui_core + self.db_manager = db_manager + self.face_processor = face_processor self.on_scan = on_scan self.on_process = on_process self.on_identify = on_identify @@ -274,30 +277,36 @@ class DashboardGUI: return panel def _create_identify_panel(self) -> ttk.Frame: - """Create the identify panel (placeholder for now)""" + """Create the identify panel with full functionality""" panel = ttk.Frame(self.content_frame) # Title title_label = ttk.Label(panel, text="👤 Identify Faces", font=("Arial", 18, "bold")) title_label.pack(anchor="w", pady=(0, 20)) - # Placeholder content - placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", padding="20") - placeholder_frame.pack(expand=True, fill=tk.BOTH) - - placeholder_text = ( - "The Identify panel will be integrated here.\n\n" - "This will contain the full face identification interface\n" - "currently available in the separate Identify window.\n\n" - "Features will include:\n" - "• Face browsing and identification\n" - "• Similar face matching\n" - "• Person management\n" - "• Batch processing options" - ) - - placeholder_label = ttk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 12), justify=tk.LEFT) - placeholder_label.pack(expand=True) + # Create the identify panel if we have the required dependencies + if self.db_manager and self.face_processor: + self.identify_panel = IdentifyPanel(panel, self.db_manager, self.face_processor, self.gui_core) + identify_frame = self.identify_panel.create_panel() + identify_frame.pack(fill=tk.BOTH, expand=True) + else: + # Fallback placeholder if dependencies are not available + placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20") + placeholder_frame.pack(expand=True, fill=tk.BOTH) + + placeholder_text = ( + "Identify panel requires database and face processor to be configured.\n\n" + "This will contain the full face identification interface\n" + "currently available in the separate Identify window.\n\n" + "Features will include:\n" + "• Face browsing and identification\n" + "• Similar face matching\n" + "• Person management\n" + "• Batch processing options" + ) + + placeholder_label = ttk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 12), justify=tk.LEFT) + placeholder_label.pack(expand=True) return panel diff --git a/database.py b/database.py index 7876575..20c7f5a 100644 --- a/database.py +++ b/database.py @@ -241,20 +241,28 @@ class DatabaseManager: def add_person(self, first_name: str, last_name: str, middle_name: str = None, maiden_name: str = None, date_of_birth: str = None) -> int: - """Add a person to the database and return their ID""" + """Add a person to the database and return their ID (case-insensitive)""" + # Normalize names to title case for case-insensitive matching + normalized_first = first_name.strip().title() + normalized_last = last_name.strip().title() + normalized_middle = middle_name.strip().title() if middle_name else '' + normalized_maiden = maiden_name.strip().title() if maiden_name else '' + normalized_dob = date_of_birth.strip() if date_of_birth else '' + with self.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)) + ''', (normalized_first, normalized_last, normalized_middle, normalized_maiden, normalized_dob)) - # Get the person ID + # Get the person ID (case-insensitive lookup) 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)) + WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?) + AND LOWER(COALESCE(middle_name, '')) = LOWER(?) AND LOWER(COALESCE(maiden_name, '')) = LOWER(?) + AND date_of_birth = ? + ''', (normalized_first, normalized_last, normalized_middle, normalized_maiden, normalized_dob)) result = cursor.fetchone() return result[0] if result else None diff --git a/identify_panel.py b/identify_panel.py new file mode 100644 index 0000000..76dfdfa --- /dev/null +++ b/identify_panel.py @@ -0,0 +1,1426 @@ +#!/usr/bin/env python3 +""" +Integrated Identify Panel for PunimTag Dashboard +Embeds the full identify GUI functionality into the dashboard frame +""" + +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 IdentifyPanel: + """Integrated identify panel that embeds the full identify GUI functionality into the dashboard""" + + def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager, + face_processor: FaceProcessor, gui_core: GUICore, verbose: int = 0): + """Initialize the identify panel""" + self.parent_frame = parent_frame + self.db = db_manager + self.face_processor = face_processor + self.gui_core = gui_core + self.verbose = verbose + + # Panel state + self.is_active = False + self.current_faces = [] + self.current_face_index = 0 + self.face_status = {} + self.face_person_names = {} + self.face_selection_states = {} + self.identify_data_cache = {} + self.current_face_crop_path = None + + # GUI components + self.components = {} + self.main_frame = None + + def create_panel(self) -> ttk.Frame: + """Create the identify panel with all GUI components""" + self.main_frame = ttk.Frame(self.parent_frame) + + # Configure grid weights + self.main_frame.columnconfigure(0, weight=1) # Left panel + self.main_frame.columnconfigure(1, weight=1) # Right panel for similar faces + self.main_frame.rowconfigure(3, weight=0) # Configuration row - no expansion + self.main_frame.rowconfigure(4, weight=1) # Main panels row - expandable + + # Photo info + self.components['info_label'] = ttk.Label(self.main_frame, text="", font=("Arial", 10, "bold")) + self.components['info_label'].grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) + + # Create all GUI components + self._create_gui_components() + + # Create main content panels + self._create_main_panels() + + return self.main_frame + + def _create_gui_components(self): + """Create all GUI components for the identify interface""" + # Create variables for form data + self.components['compare_var'] = tk.BooleanVar() + self.components['unique_var'] = tk.BooleanVar() + self.components['first_name_var'] = tk.StringVar() + self.components['last_name_var'] = tk.StringVar() + self.components['middle_name_var'] = tk.StringVar() + self.components['maiden_name_var'] = tk.StringVar() + self.components['date_of_birth_var'] = tk.StringVar() + + # Date filter variables + self.components['date_from_var'] = tk.StringVar(value="") + self.components['date_to_var'] = tk.StringVar(value="") + self.components['date_processed_from_var'] = tk.StringVar(value="") + self.components['date_processed_to_var'] = tk.StringVar(value="") + + # Date filter controls + date_filter_frame = ttk.LabelFrame(self.main_frame, text="Filter", padding="5") + date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) + 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)) + self.components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_from_var'], width=10, state='readonly') + self.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(self.components['date_from_var']) + + self.components['date_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_from) + self.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)) + self.components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_to_var'], width=10, state='readonly') + self.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(self.components['date_to_var']) + + self.components['date_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_to) + self.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""" + self._apply_date_filters() + + self.components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) + self.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)) + self.components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_from_var'], width=10, state='readonly') + self.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(self.components['date_processed_from_var']) + + self.components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_from) + self.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)) + self.components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_to_var'], width=10, state='readonly') + self.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(self.components['date_processed_to_var']) + + self.components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to) + self.components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) + + # Unique checkbox under the filter frame + def on_unique_change(): + """Handle unique faces checkbox change - filter main face list like old implementation""" + if self.components['unique_var'].get(): + # Show progress message + print("🔄 Applying unique faces filter...") + self.main_frame.update() # Update UI to show the message + + # Apply unique faces filtering to the main face list + try: + self.current_faces = self._filter_unique_faces_from_list(self.current_faces) + print(f"✅ Filter applied: {len(self.current_faces)} unique faces remaining") + except Exception as e: + print(f"⚠️ Error applying filter: {e}") + # Revert checkbox state + self.components['unique_var'].set(False) + return + else: + # Reload the original unfiltered face list + print("🔄 Reloading all faces...") + self.main_frame.update() # Update UI to show the message + + # Get current date filters + date_from = self.components['date_from_var'].get().strip() or None + date_to = self.components['date_to_var'].get().strip() or None + date_processed_from = self.components['date_processed_from_var'].get().strip() or None + date_processed_to = self.components['date_processed_to_var'].get().strip() or None + + # Get batch size + try: + batch_size = int(self.components['batch_var'].get().strip()) + except Exception: + batch_size = DEFAULT_BATCH_SIZE + + # Reload faces with current filters + self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, + date_processed_from, date_processed_to) + + print(f"✅ Reloaded: {len(self.current_faces)} faces") + + # Reset to first face and update display + self.current_face_index = 0 + if self.current_faces: + self._update_current_face() + self._update_button_states() + + # Update similar faces if compare is enabled + if self.components['compare_var'].get(): + face_id, _, _, _, _ = self.current_faces[self.current_face_index] + self._update_similar_faces(face_id) + + self.components['unique_check'] = ttk.Checkbutton(self.main_frame, text="Unique faces only", + variable=self.components['unique_var'], + command=on_unique_change) + self.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(): + # Toggle the similar faces functionality + if self.components['compare_var'].get(): + # Enable select all/clear all buttons + self.components['select_all_btn'].config(state='normal') + self.components['clear_all_btn'].config(state='normal') + # Update similar faces if we have a current face + if self.current_faces and self.current_face_index < len(self.current_faces): + face_id, _, _, _, _ = self.current_faces[self.current_face_index] + self._update_similar_faces(face_id) + else: + # Disable select all/clear all buttons + self.components['select_all_btn'].config(state='disabled') + self.components['clear_all_btn'].config(state='disabled') + # Clear similar faces content + scrollable_frame = self.components['similar_scrollable_frame'] + for widget in scrollable_frame.winfo_children(): + widget.destroy() + # Show 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) + + self.components['compare_check'] = ttk.Checkbutton(self.main_frame, text="Compare similar faces", + variable=self.components['compare_var'], + command=on_compare_change) + self.components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(0, 5), pady=0) + + # Command variable for button callbacks + self.components['command_var'] = tk.StringVar() + + # Batch size configuration + batch_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="5") + batch_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.W) + + ttk.Label(batch_frame, text="Batch size:").pack(side=tk.LEFT, padx=(0, 5)) + self.components['batch_var'] = tk.StringVar(value=str(DEFAULT_BATCH_SIZE)) + batch_entry = ttk.Entry(batch_frame, textvariable=self.components['batch_var'], width=8) + batch_entry.pack(side=tk.LEFT, padx=(0, 10)) + + # Start button + start_btn = ttk.Button(batch_frame, text="🚀 Start Identification", command=self._start_identification) + start_btn.pack(side=tk.LEFT, padx=(10, 0)) + + def _create_main_panels(self): + """Create the main left and right panels""" + # Left panel for face display and identification + self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Face Identification", padding="10") + self.components['left_panel'].grid(row=4, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) + + # Right panel for similar faces (always visible) + self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Similar Faces", padding="10") + self.components['right_panel'].grid(row=4, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) + + # Create left panel content + self._create_left_panel_content() + + # Create right panel content + self._create_right_panel_content() + + def _create_left_panel_content(self): + """Create the left panel content for face identification""" + left_panel = self.components['left_panel'] + + # Face image display + self.components['face_canvas'] = tk.Canvas(left_panel, width=300, height=300, bg='white', relief='sunken', bd=2) + self.components['face_canvas'].pack(pady=(0, 10)) + + # Person name fields + name_frame = ttk.LabelFrame(left_panel, text="Person Information", padding="5") + name_frame.pack(fill=tk.X, pady=(0, 10)) + + # First name + ttk.Label(name_frame, text="First name *:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5), pady=2) + first_name_entry = ttk.Entry(name_frame, textvariable=self.components['first_name_var'], width=20) + first_name_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 10), pady=2) + + # Last name + ttk.Label(name_frame, text="Last name *:").grid(row=0, column=2, sticky=tk.W, padx=(0, 5), pady=2) + last_name_entry = ttk.Entry(name_frame, textvariable=self.components['last_name_var'], width=20) + last_name_entry.grid(row=0, column=3, sticky=tk.W, pady=2) + + # Middle name + ttk.Label(name_frame, text="Middle name:").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=2) + middle_name_entry = ttk.Entry(name_frame, textvariable=self.components['middle_name_var'], width=20) + middle_name_entry.grid(row=1, column=1, sticky=tk.W, padx=(0, 10), pady=2) + + # Maiden name + ttk.Label(name_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(0, 5), pady=2) + maiden_name_entry = ttk.Entry(name_frame, textvariable=self.components['maiden_name_var'], width=20) + maiden_name_entry.grid(row=1, column=3, sticky=tk.W, pady=2) + + # Date of birth + dob_frame = ttk.Frame(name_frame) + dob_frame.grid(row=2, column=0, columnspan=4, sticky=tk.W, pady=2) + + ttk.Label(dob_frame, text="Date of birth *:").pack(side=tk.LEFT, padx=(0, 5)) + dob_entry = ttk.Entry(dob_frame, textvariable=self.components['date_of_birth_var'], width=15, state='readonly') + dob_entry.pack(side=tk.LEFT, padx=(0, 5)) + + def open_dob_calendar(): + self._open_date_picker(self.components['date_of_birth_var']) + + dob_calendar_btn = ttk.Button(dob_frame, text="📅", width=3, command=open_dob_calendar) + dob_calendar_btn.pack(side=tk.LEFT) + + # Add event handlers to update Identify button state + def update_identify_button_state(*args): + self._update_identify_button_state() + + self.components['first_name_var'].trace('w', update_identify_button_state) + self.components['last_name_var'].trace('w', update_identify_button_state) + self.components['date_of_birth_var'].trace('w', update_identify_button_state) + + # Required field asterisks are now included in the label text + + # Add autocomplete for last name + self._setup_last_name_autocomplete(last_name_entry) + + # Control buttons + button_frame = ttk.Frame(left_panel) + button_frame.pack(fill=tk.X, pady=(10, 0)) + + self.components['identify_btn'] = ttk.Button(button_frame, text="✅ Identify", command=self._identify_face, state='disabled') + self.components['identify_btn'].pack(side=tk.LEFT, padx=(0, 5)) + + self.components['back_btn'] = ttk.Button(button_frame, text="⬅️ Back", command=self._go_back) + self.components['back_btn'].pack(side=tk.LEFT, padx=(0, 5)) + + self.components['next_btn'] = ttk.Button(button_frame, text="➡️ Next", command=self._go_next) + self.components['next_btn'].pack(side=tk.LEFT, padx=(0, 5)) + + self.components['quit_btn'] = ttk.Button(button_frame, text="❌ Quit", command=self._quit_identification) + self.components['quit_btn'].pack(side=tk.RIGHT) + + def _create_right_panel_content(self): + """Create the right panel content for similar faces""" + right_panel = self.components['right_panel'] + + # Select All/Clear All buttons + select_frame = ttk.Frame(right_panel) + select_frame.pack(fill=tk.X, pady=(0, 10)) + + self.components['select_all_btn'] = ttk.Button(select_frame, text="Select All", command=self._select_all_similar) + self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) + + self.components['clear_all_btn'] = ttk.Button(select_frame, text="Clear All", command=self._clear_all_similar) + self.components['clear_all_btn'].pack(side=tk.LEFT) + + # Initially disable these buttons + self.components['select_all_btn'].config(state='disabled') + self.components['clear_all_btn'].config(state='disabled') + + # Create a frame to hold canvas and scrollbar + canvas_frame = ttk.Frame(right_panel) + canvas_frame.pack(fill=tk.BOTH, expand=True) + + # Create canvas for similar faces with scrollbar + similar_canvas = tk.Canvas(canvas_frame, bg='lightgray', relief='sunken', bd=2) + similar_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Similar faces scrollbars + similar_v_scrollbar = ttk.Scrollbar(canvas_frame, orient='vertical', command=similar_canvas.yview) + similar_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set) + + # Create scrollable frame for similar faces + self.components['similar_scrollable_frame'] = ttk.Frame(similar_canvas) + similar_canvas.create_window((0, 0), window=self.components['similar_scrollable_frame'], anchor='nw') + + # Store canvas reference for scrolling + self.components['similar_canvas'] = similar_canvas + + # Add initial message when compare is disabled + no_compare_label = ttk.Label(self.components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces", + foreground="gray", font=("Arial", 10)) + no_compare_label.pack(pady=20) + + def _start_identification(self): + """Start the identification process""" + try: + batch_size = int(self.components['batch_var'].get().strip()) + if batch_size <= 0: + raise ValueError + except Exception: + messagebox.showerror("Error", "Please enter a valid positive integer for batch size.") + return + + # Get date filters + date_from = self.components['date_from_var'].get().strip() or None + date_to = self.components['date_to_var'].get().strip() or None + date_processed_from = self.components['date_processed_from_var'].get().strip() or None + date_processed_to = self.components['date_processed_to_var'].get().strip() or None + + # Get unidentified faces + self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, + date_processed_from, date_processed_to) + + if not self.current_faces: + messagebox.showinfo("No Faces", "🎉 All faces have been identified!") + return + + # Pre-fetch data for optimal performance + self.identify_data_cache = self._prefetch_identify_data(self.current_faces) + + # Reset state + self.current_face_index = 0 + self.face_status = {} + self.face_person_names = {} + self.face_selection_states = {} + + # Show the first face + self._update_current_face() + + # Enable/disable buttons + self._update_button_states() + + self.is_active = True + + 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) -> List[Tuple]: + """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, faces: List[Tuple]) -> Dict: + """Pre-fetch all needed data to avoid repeated database queries""" + cache = { + 'photo_paths': {}, + 'people_names': [], + 'last_names': [], + 'face_encodings': {} + } + + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Get photo paths + photo_ids = [face[1] for face in faces] + if photo_ids: + placeholders = ','.join(['?' for _ in photo_ids]) + cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids) + cache['photo_paths'] = dict(cursor.fetchall()) + + # Get people names + cursor.execute('SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people ORDER BY last_name, first_name') + cache['people_names'] = cursor.fetchall() + + # 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() + cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) + + # Get face encodings for similar face matching + face_ids = [face[0] for face in faces] + if face_ids: + placeholders = ','.join(['?' for _ in face_ids]) + cursor.execute(f'SELECT id, encoding FROM faces WHERE id IN ({placeholders})', face_ids) + cache['face_encodings'] = dict(cursor.fetchall()) + + return cache + + 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 _update_current_face(self): + """Update the display for the current face""" + if not self.current_faces or self.current_face_index >= len(self.current_faces): + return + + face_id, photo_id, photo_path, filename, location = self.current_faces[self.current_face_index] + + # Update info label + self.components['info_label'].config(text=f"Face {self.current_face_index + 1} of {len(self.current_faces)} - {filename}") + + # Extract and display face crop (show_faces is always True) + face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) + self.current_face_crop_path = face_crop_path + + # Update face image + self._update_face_image(face_crop_path, photo_path) + + # Check if face is already identified + is_identified = face_id in self.face_status and self.face_status[face_id] == 'identified' + + # Restore person name input - restore saved name or use database/empty value + self._restore_person_name_input(face_id, is_identified) + + # Update similar faces if compare is enabled + if self.components['compare_var'].get(): + self._update_similar_faces(face_id) + + def _update_face_image(self, face_crop_path: str, photo_path: str): + """Update the face image display""" + try: + if face_crop_path and os.path.exists(face_crop_path): + # Load and display face crop + image = Image.open(face_crop_path) + # Resize to exactly fill the 300x300 frame + image = image.resize((300, 300), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(image) + + # Clear canvas and display image + canvas = self.components['face_canvas'] + canvas.delete("all") + # Position image at top-left corner like the original + canvas.create_image(0, 0, image=photo, anchor=tk.NW) + canvas.image = photo # Keep a reference + + # Add photo icon exactly at the image's top-right corner + # Image starts at (0, 0) and is 300x300, so top-right corner is at (300, 0) + self.gui_core.create_photo_icon(canvas, photo_path, icon_size=20, + face_x=0, face_y=0, + face_width=300, face_height=300, + canvas_width=300, canvas_height=300) + else: + # Clear canvas if no image + canvas = self.components['face_canvas'] + canvas.delete("all") + canvas.create_text(150, 150, text="No face image", fill="gray") + except Exception as e: + print(f"Error updating face image: {e}") + + + def _restore_person_name_input(self, face_id: int, is_identified: bool): + """Restore person name input fields""" + try: + if is_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 + self.components['first_name_var'].set(first_name or "") + self.components['last_name_var'].set(last_name or "") + self.components['middle_name_var'].set(middle_name or "") + self.components['maiden_name_var'].set(maiden_name or "") + self.components['date_of_birth_var'].set(date_of_birth or "") + else: + # Clear all fields if no person found + self._clear_form() + else: + # Restore from saved data if available + if face_id in self.face_person_names: + person_data = self.face_person_names[face_id] + if isinstance(person_data, dict): + self.components['first_name_var'].set(person_data.get('first_name', '')) + self.components['last_name_var'].set(person_data.get('last_name', '')) + self.components['middle_name_var'].set(person_data.get('middle_name', '')) + self.components['maiden_name_var'].set(person_data.get('maiden_name', '')) + self.components['date_of_birth_var'].set(person_data.get('date_of_birth', '')) + else: + # Legacy string format + self.components['first_name_var'].set(person_data or "") + self.components['last_name_var'].set("") + self.components['middle_name_var'].set("") + self.components['maiden_name_var'].set("") + self.components['date_of_birth_var'].set("") + else: + # Clear all fields for new face + self._clear_form() + + except Exception as e: + print(f"❌ Error restoring person name input: {e}") + # Clear form on error + self._clear_form() + + def _update_identify_button_state(self): + """Update the identify button state based on form completion""" + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + + # Enable button only if all required fields are filled + if first_name and last_name and date_of_birth: + self.components['identify_btn'].config(state='normal') + else: + self.components['identify_btn'].config(state='disabled') + + + def _setup_last_name_autocomplete(self, last_name_entry): + """Setup autocomplete functionality for last name field - exactly like old implementation""" + # Create listbox for suggestions (as overlay attached to main frame, not clipped by frames) + last_name_listbox = tk.Listbox(self.main_frame, height=8) + last_name_listbox.place_forget() # Hide initially + + def _show_suggestions(): + """Show filtered suggestions in listbox""" + all_last_names = self.identify_data_cache.get('last_names', []) + typed = self.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 + self.main_frame.update_idletasks() + # Absolute coordinates of entry relative to screen + entry_root_x = last_name_entry.winfo_rootx() + entry_root_y = last_name_entry.winfo_rooty() + entry_height = last_name_entry.winfo_height() + # Convert to coordinates relative to main frame + main_frame_origin_x = self.main_frame.winfo_rootx() + main_frame_origin_y = self.main_frame.winfo_rooty() + place_x = entry_root_x - main_frame_origin_x + place_y = entry_root_y - main_frame_origin_y + entry_height + place_width = last_name_entry.winfo_width() + # Calculate how many rows fit to bottom of window + available_px = max(60, self.main_frame.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]) + self.components['last_name_var'].set(selected_name) + _hide_suggestions() + 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) + self.components['last_name_var'].set(selected_name) + except: + pass + _hide_suggestions() + 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() + 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() + 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 + last_name_entry.bind('', lambda e: _safe_show_suggestions()) + last_name_entry.bind('', _on_key_press) + last_name_entry.bind('', lambda e: self.main_frame.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 _clear_form(self): + """Clear the identification form""" + self.components['first_name_var'].set('') + self.components['last_name_var'].set('') + self.components['middle_name_var'].set('') + self.components['maiden_name_var'].set('') + self.components['date_of_birth_var'].set('') + + def _update_similar_faces(self, face_id: int): + """Update the similar faces panel""" + # Enable select all/clear all buttons + self.components['select_all_btn'].config(state='normal') + self.components['clear_all_btn'].config(state='normal') + + # Find similar faces using the filtered method like the original + similar_faces = self.face_processor._get_filtered_similar_faces(face_id, DEFAULT_FACE_TOLERANCE, include_same_photo=False, face_status=self.face_status) + + # Clear existing similar faces + scrollable_frame = self.components['similar_scrollable_frame'] + for widget in scrollable_frame.winfo_children(): + widget.destroy() + + # Display similar faces + if similar_faces: + + # Sort by confidence (distance) - highest confidence first (lowest distance) + similar_faces.sort(key=lambda x: x['distance']) + + # Ensure photo paths are available for similar faces + self._ensure_photo_paths_for_similar_faces(similar_faces) + + # Display similar faces using the original approach + self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id) + + # Update canvas scroll region + canvas = self.components['similar_canvas'] + canvas.update_idletasks() + canvas.configure(scrollregion=canvas.bbox("all")) + else: + no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found", + foreground="gray", font=("Arial", 10)) + no_faces_label.pack(pady=20) + + def _ensure_photo_paths_for_similar_faces(self, similar_faces): + """Ensure photo paths are available in cache for similar faces""" + # Get photo IDs from similar faces that are not in cache + missing_photo_ids = [] + for face_data in similar_faces: + photo_id = face_data['photo_id'] + if photo_id not in self.identify_data_cache['photo_paths']: + missing_photo_ids.append(photo_id) + + # Fetch missing photo paths from database + if missing_photo_ids: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join(['?' for _ in missing_photo_ids]) + cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', missing_photo_ids) + missing_photo_paths = dict(cursor.fetchall()) + + # Add to cache + self.identify_data_cache['photo_paths'].update(missing_photo_paths) + + def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id): + """Display similar faces in a panel - based on original implementation""" + # 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() + similar_face_vars.append((similar_face_id, match_var)) + + # Store the variable for later use + if similar_face_id not in self.face_selection_states: + self.face_selection_states[similar_face_id] = {} + self.face_selection_states[similar_face_id]['var'] = match_var + + # Restore previous checkbox state if available (auto-match style) + if similar_face_id in self.face_selection_states and 'var' in self.face_selection_states[similar_face_id]: + prev_var = self.face_selection_states[similar_face_id]['var'] + if hasattr(prev_var, 'get'): + match_var.set(prev_var.get()) + + # Checkbox + checkbox = ttk.Checkbutton(match_frame, variable=match_var) + checkbox.pack(side=tk.LEFT, padx=(0, 5)) + + # Face image (moved to be right after checkbox) + try: + photo_id = face_data['photo_id'] + location = face_data['location'] + photo_path = self.identify_data_cache['photo_paths'].get(photo_id, '') + + if photo_path: + face_crop_path = self.face_processor._extract_face_crop(photo_path, location, similar_face_id) + if face_crop_path and os.path.exists(face_crop_path): + image = Image.open(face_crop_path) + image.thumbnail((50, 50), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(image) + + # Create a canvas for the face image to allow photo icon drawing + face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0) + face_canvas.pack(side=tk.LEFT, padx=(0, 5)) + face_canvas.create_image(25, 25, image=photo, anchor=tk.CENTER) + face_canvas.image = photo # Keep reference + + # Add photo icon exactly at the image's top-right corner + self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15, + face_x=0, face_y=0, + face_width=50, face_height=50, + canvas_width=50, canvas_height=50) + else: + # Face crop extraction failed or file doesn't exist + print(f"Face crop not available for face {similar_face_id}: {face_crop_path}") + # Create placeholder canvas + face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') + face_canvas.pack(side=tk.LEFT, padx=(0, 5)) + face_canvas.create_text(25, 25, text="No\nImage", fill="gray", font=("Arial", 8)) + else: + # Photo path not found in cache + print(f"Photo path not found for photo_id {photo_id} in cache") + # Create placeholder canvas + face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') + face_canvas.pack(side=tk.LEFT, padx=(0, 5)) + face_canvas.create_text(25, 25, text="No\nPath", fill="gray", font=("Arial", 8)) + except Exception as e: + print(f"Error creating similar face widget for face {similar_face_id}: {e}") + # Create placeholder canvas on error + face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') + face_canvas.pack(side=tk.LEFT, padx=(0, 5)) + face_canvas.create_text(25, 25, text="Error", fill="red", font=("Arial", 8)) + + # Confidence label with color coding and description + confidence_text = f"{confidence_pct:.0f}% {confidence_desc}" + if confidence_pct >= 80: + color = "green" + elif confidence_pct >= 70: + color = "orange" + elif confidence_pct >= 60: + color = "red" + else: + color = "gray" + + confidence_label = ttk.Label(match_frame, text=confidence_text, foreground=color, font=("Arial", 8, "bold")) + confidence_label.pack(side=tk.LEFT, padx=(0, 5)) + + # Filename + filename_label = ttk.Label(match_frame, text=filename, font=("Arial", 8)) + filename_label.pack(side=tk.LEFT, padx=(0, 5)) + + # Store the similar face variables for Select All/Clear All functionality + self.similar_face_vars = similar_face_vars + + def _get_confidence_description(self, confidence_pct: float) -> str: + """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 - Unlikely)" + + def _identify_face(self): + """Identify the current face""" + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + + if not first_name or not last_name or not date_of_birth: + messagebox.showwarning("Missing Information", "Please fill in first name, last name, and date of birth.") + return + + if not self.current_faces or self.current_face_index >= len(self.current_faces): + return + + face_id, photo_id, photo_path, filename, location = self.current_faces[self.current_face_index] + + # Get person data + person_data = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': self.components['middle_name_var'].get().strip(), + 'maiden_name': self.components['maiden_name_var'].get().strip(), + 'date_of_birth': date_of_birth + } + + # Store the identification + self.face_person_names[face_id] = person_data + self.face_status[face_id] = 'identified' + + # Save to database + self._save_identification(face_id, person_data) + + # If compare mode is active, identify selected similar faces + if self.components['compare_var'].get(): + self._identify_selected_similar_faces(person_data) + + # Move to next face + self._go_next() + + def _save_identification(self, face_id: int, person_data: Dict): + """Save face identification to database""" + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Normalize names to title case for case-insensitive matching + normalized_data = { + 'first_name': person_data['first_name'].strip().title(), + 'last_name': person_data['last_name'].strip().title(), + 'middle_name': person_data['middle_name'].strip().title() if person_data['middle_name'] else '', + 'maiden_name': person_data['maiden_name'].strip().title() if person_data['maiden_name'] else '', + 'date_of_birth': person_data['date_of_birth'].strip() + } + + # Check if person already exists (case-insensitive) + cursor.execute(''' + SELECT id FROM people + WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?) + AND LOWER(COALESCE(middle_name, '')) = LOWER(?) AND LOWER(COALESCE(maiden_name, '')) = LOWER(?) + AND date_of_birth = ? + ''', (normalized_data['first_name'], normalized_data['last_name'], + normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth'])) + + person_row = cursor.fetchone() + if person_row: + person_id = person_row[0] + else: + # Create new person + cursor.execute(''' + INSERT INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) + VALUES (?, ?, ?, ?, ?) + ''', (normalized_data['first_name'], normalized_data['last_name'], + normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth'])) + person_id = cursor.lastrowid + + # Update face with person_id + cursor.execute('UPDATE faces SET person_id = ? WHERE id = ?', (person_id, face_id)) + + conn.commit() + + except Exception as e: + print(f"Error saving identification: {e}") + messagebox.showerror("Error", f"Failed to save identification: {e}") + + def _identify_selected_similar_faces(self, person_data: Dict): + """Identify selected similar faces with the same person""" + if hasattr(self, 'similar_face_vars'): + for face_id, var in self.similar_face_vars: + if var.get(): + # This face is selected, identify it + self.face_person_names[face_id] = person_data + self.face_status[face_id] = 'identified' + self._save_identification(face_id, person_data) + + def _go_back(self): + """Go back to the previous face""" + if self.current_face_index > 0: + # Validate navigation (check for unsaved changes) + validation_result = self._validate_navigation() + if validation_result == 'cancel': + return # Cancel navigation + elif validation_result == 'save_and_continue': + # Save the current identification before proceeding + if self.current_faces and self.current_face_index < len(self.current_faces): + face_id, _, _, _, _ = self.current_faces[self.current_face_index] + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + if first_name and last_name and date_of_birth: + person_data = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': self.components['middle_name_var'].get().strip(), + 'maiden_name': self.components['maiden_name_var'].get().strip(), + 'date_of_birth': date_of_birth + } + self.face_person_names[face_id] = person_data + self.face_status[face_id] = 'identified' + elif validation_result == 'discard_and_continue': + # Clear the form but don't save + self._clear_form() + + self.current_face_index -= 1 + self._update_current_face() + self._update_button_states() + + def _go_next(self): + """Go to the next face""" + if self.current_face_index < len(self.current_faces) - 1: + # Validate navigation (check for unsaved changes) + validation_result = self._validate_navigation() + if validation_result == 'cancel': + return # Cancel navigation + elif validation_result == 'save_and_continue': + # Save the current identification before proceeding + if self.current_faces and self.current_face_index < len(self.current_faces): + face_id, _, _, _, _ = self.current_faces[self.current_face_index] + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + if first_name and last_name and date_of_birth: + person_data = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': self.components['middle_name_var'].get().strip(), + 'maiden_name': self.components['maiden_name_var'].get().strip(), + 'date_of_birth': date_of_birth + } + self.face_person_names[face_id] = person_data + self.face_status[face_id] = 'identified' + elif validation_result == 'discard_and_continue': + # Clear the form but don't save + self._clear_form() + + self.current_face_index += 1 + self._update_current_face() + self._update_button_states() + else: + # Check if there are more faces to load + self._load_more_faces() + + def _load_more_faces(self): + """Load more faces if available""" + # Get current date filters + date_from = self.components['date_from_var'].get().strip() or None + date_to = self.components['date_to_var'].get().strip() or None + date_processed_from = self.components['date_processed_from_var'].get().strip() or None + date_processed_to = self.components['date_processed_to_var'].get().strip() or None + + # Get more faces + more_faces = self._get_unidentified_faces(DEFAULT_BATCH_SIZE, date_from, date_to, + date_processed_from, date_processed_to) + + if more_faces: + # Add to current faces + self.current_faces.extend(more_faces) + self.current_face_index += 1 + self._update_current_face() + self._update_button_states() + else: + # No more faces + messagebox.showinfo("Complete", "🎉 All faces have been identified!") + self._quit_identification() + + def _update_button_states(self): + """Update button states based on current position""" + self.components['back_btn'].config(state='normal' if self.current_face_index > 0 else 'disabled') + self.components['next_btn'].config(state='normal' if self.current_face_index < len(self.current_faces) - 1 else 'disabled') + + def _select_all_similar(self): + """Select all similar faces""" + if hasattr(self, 'similar_face_vars'): + for face_id, var in self.similar_face_vars: + var.set(True) + + def _clear_all_similar(self): + """Clear all similar face selections""" + if hasattr(self, 'similar_face_vars'): + for face_id, var in self.similar_face_vars: + var.set(False) + + def _quit_identification(self): + """Quit the identification process""" + # First check for unsaved changes in the current form + validation_result = self._validate_navigation() + if validation_result == 'cancel': + return # Cancel quit + elif validation_result == 'save_and_continue': + # Save the current identification before proceeding + if self.current_faces and self.current_face_index < len(self.current_faces): + face_id, _, _, _, _ = self.current_faces[self.current_face_index] + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + if first_name and last_name and date_of_birth: + person_data = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': self.components['middle_name_var'].get().strip(), + 'maiden_name': self.components['maiden_name_var'].get().strip(), + 'date_of_birth': date_of_birth + } + self.face_person_names[face_id] = person_data + self.face_status[face_id] = 'identified' + elif validation_result == 'discard_and_continue': + # Clear the form but don't save + self._clear_form() + + # Check for pending identifications + pending_identifications = self._get_pending_identifications() + + if 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" + ) + + if result is True: # Yes - Save and quit + self._save_all_pending_identifications() + elif result is False: # No - Quit without saving + pass + else: # Cancel - Don't quit + return + + # Clean up + self._cleanup() + self.is_active = False + + def _validate_navigation(self): + """Validate that navigation is safe (no unsaved changes)""" + # Check if there are any unsaved changes in the form + first_name = self.components['first_name_var'].get().strip() + last_name = self.components['last_name_var'].get().strip() + date_of_birth = self.components['date_of_birth_var'].get().strip() + + # If 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 _get_pending_identifications(self) -> List[int]: + """Get list of face IDs with pending identifications""" + pending = [] + for face_id, person_data in self.face_person_names.items(): + if face_id not in self.face_status or self.face_status[face_id] != 'identified': + # Check if form has complete data + if (person_data.get('first_name') and + person_data.get('last_name') and + person_data.get('date_of_birth')): + pending.append(face_id) + return pending + + def _save_all_pending_identifications(self): + """Save all pending identifications""" + for face_id in self._get_pending_identifications(): + person_data = self.face_person_names[face_id] + self._save_identification(face_id, person_data) + self.face_status[face_id] = 'identified' + + def _cleanup(self): + """Clean up resources""" + if self.current_face_crop_path: + self.face_processor.cleanup_face_crops(self.current_face_crop_path) + + # Clear state + self.current_faces = [] + self.current_face_index = 0 + self.face_status = {} + self.face_person_names = {} + self.face_selection_states = {} + self.identify_data_cache = {} + if hasattr(self, 'similar_face_vars'): + self.similar_face_vars = [] + + # Clear right panel content + scrollable_frame = self.components['similar_scrollable_frame'] + for widget in scrollable_frame.winfo_children(): + widget.destroy() + # Show 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) + + # Clear form + self._clear_form() + + # Clear info label + self.components['info_label'].config(text="") + + # Clear face canvas + self.components['face_canvas'].delete("all") + + def _apply_date_filters(self): + """Apply date filters and reload faces""" + # Get current filter values + date_from = self.components['date_from_var'].get().strip() or None + date_to = self.components['date_to_var'].get().strip() or None + date_processed_from = self.components['date_processed_from_var'].get().strip() or None + date_processed_to = self.components['date_processed_to_var'].get().strip() or None + + # Get batch size + try: + batch_size = int(self.components['batch_var'].get().strip()) + except Exception: + batch_size = DEFAULT_BATCH_SIZE + + # Reload faces with new filters + self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, + date_processed_from, date_processed_to) + + if not self.current_faces: + messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") + return + + # Reset state + self.current_face_index = 0 + self.face_status = {} + self.face_person_names = {} + self.face_selection_states = {} + + # Pre-fetch data + self.identify_data_cache = self._prefetch_identify_data(self.current_faces) + + # Show the first face + self._update_current_face() + self._update_button_states() + + self.is_active = True + + def _open_date_picker(self, date_var: tk.StringVar): + """Open date picker dialog""" + 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 activate(self): + """Activate the panel""" + self.is_active = True + + def deactivate(self): + """Deactivate the panel""" + if self.is_active: + self._cleanup() + self.is_active = False diff --git a/photo_tagger.py b/photo_tagger.py index 0c3c8b7..b804063 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -50,7 +50,7 @@ class PhotoTagger: self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose) self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose) self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, self.tag_manager, verbose) - self.dashboard_gui = DashboardGUI(self.gui_core, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify) + self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify) # Legacy compatibility - expose some methods directly self._db_connection = None @@ -179,10 +179,10 @@ class PhotoTagger: return self.search_stats.print_statistics() # GUI methods (legacy compatibility - these would need to be implemented) - def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, show_faces: bool = False, tolerance: float = DEFAULT_FACE_TOLERANCE, + def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, 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 GUI""" - return self.identify_gui.identify_faces(batch_size, show_faces, tolerance, + """Interactive face identification with GUI (show_faces is always True)""" + return self.identify_gui.identify_faces(batch_size, True, tolerance, date_from, date_to, date_processed_from, date_processed_to) def tag_management(self) -> int: @@ -211,11 +211,11 @@ class PhotoTagger: return self.process_faces() return self.process_faces(limit=limit_value) - def _dashboard_identify(self, batch_value: Optional[int], show_faces: bool) -> int: - """Callback to identify faces from the dashboard with optional batch and show_faces.""" + def _dashboard_identify(self, batch_value: Optional[int]) -> int: + """Callback to identify faces from the dashboard with optional batch (show_faces is always True).""" if batch_value is None: - return self.identify_faces(show_faces=show_faces) - return self.identify_faces(batch_size=batch_value, show_faces=show_faces) + return self.identify_faces() + return self.identify_faces(batch_size=batch_value) def _setup_window_size_saving(self, root, config_file="gui_config.json"): """Set up window size saving functionality (legacy compatibility)""" @@ -342,8 +342,6 @@ Examples: parser.add_argument('--recursive', action='store_true', help='Scan folders recursively') - parser.add_argument('--show-faces', action='store_true', - help='Show individual face crops during identification') parser.add_argument('--tolerance', type=float, default=DEFAULT_FACE_TOLERANCE, help=f'Face matching tolerance (0.0-1.0, lower = stricter, default: {DEFAULT_FACE_TOLERANCE})') @@ -397,8 +395,7 @@ Examples: tagger.process_faces(args.limit, args.model) elif args.command == 'identify': - show_faces = getattr(args, 'show_faces', False) - tagger.identify_faces(args.batch, show_faces, args.tolerance, + tagger.identify_faces(args.batch, args.tolerance, args.date_from, args.date_to, args.date_processed_from, args.date_processed_to)