#!/usr/bin/env python3 """ Auto-Match Panel for PunimTag Dashboard Embeds the full auto-match GUI functionality into the dashboard frame """ import os import tkinter as tk from tkinter import ttk, messagebox from PIL import Image, ImageTk from typing import List, Dict, Tuple, Optional from config import DEFAULT_FACE_TOLERANCE from database import DatabaseManager from face_processing import FaceProcessor from gui_core import GUICore class AutoMatchPanel: """Integrated auto-match panel that embeds the full auto-match GUI functionality into the dashboard""" def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager, face_processor: FaceProcessor, gui_core: GUICore, on_navigate_home=None, verbose: int = 0): """Initialize the auto-match panel""" self.parent_frame = parent_frame self.db = db_manager self.face_processor = face_processor self.gui_core = gui_core self.on_navigate_home = on_navigate_home self.verbose = verbose # Panel state self.is_active = False self.matches_by_matched = {} self.data_cache = {} self.current_matched_index = 0 self.matched_ids = [] self.filtered_matched_ids = None self.identified_faces_per_person = {} self.checkbox_states_per_person = {} self.original_checkbox_states_per_person = {} self.identified_count = 0 # GUI components self.components = {} self.main_frame = None def create_panel(self) -> ttk.Frame: """Create the auto-match panel with all GUI components""" self.main_frame = ttk.Frame(self.parent_frame) # Configure grid weights for full screen responsiveness self.main_frame.columnconfigure(0, weight=1) # Left panel self.main_frame.columnconfigure(1, weight=1) # Right panel self.main_frame.rowconfigure(0, weight=0) # Configuration row - fixed height self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable self.main_frame.rowconfigure(2, weight=0) # Control buttons row - fixed height # 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 auto-match interface""" # Configuration frame config_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="10") config_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10)) # Don't give weight to any column to prevent stretching # Start button (moved to the left) start_btn = ttk.Button(config_frame, text="šŸš€ Start Auto-Match", command=self._start_auto_match) start_btn.grid(row=0, column=0, padx=(0, 20)) # Tolerance setting ttk.Label(config_frame, text="Tolerance:").grid(row=0, column=1, sticky=tk.W, padx=(0, 2)) self.components['tolerance_var'] = tk.StringVar(value=str(DEFAULT_FACE_TOLERANCE)) tolerance_entry = ttk.Entry(config_frame, textvariable=self.components['tolerance_var'], width=8) tolerance_entry.grid(row=0, column=2, sticky=tk.W, padx=(0, 10)) ttk.Label(config_frame, text="(lower = stricter matching)").grid(row=0, column=3, sticky=tk.W) def _create_main_panels(self): """Create the main left and right panels""" # Left panel for identified person self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Identified Person", padding="10") self.components['left_panel'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) # Right panel for unidentified faces self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Unidentified Faces to Match", padding="10") self.components['right_panel'].grid(row=1, 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() # Create control buttons self._create_control_buttons() def _create_left_panel_content(self): """Create the left panel content for identified person""" left_panel = self.components['left_panel'] # Search controls for filtering people by last name search_frame = ttk.Frame(left_panel) search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) search_frame.columnconfigure(0, weight=1) # Search input self.components['search_var'] = tk.StringVar() search_entry = ttk.Entry(search_frame, textvariable=self.components['search_var'], width=20) search_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) # Search buttons search_btn = ttk.Button(search_frame, text="Search", width=8, command=self._apply_search_filter) search_btn.grid(row=0, column=1, padx=(0, 5)) clear_btn = ttk.Button(search_frame, text="Clear", width=6, command=self._clear_search_filter) clear_btn.grid(row=0, column=2) # Search help label self.components['search_help_label'] = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray") self.components['search_help_label'].grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=(2, 0)) # Person info label self.components['person_info_label'] = ttk.Label(left_panel, text="", font=("Arial", 10, "bold")) self.components['person_info_label'].grid(row=1, column=0, pady=(0, 10), sticky=tk.W) # Person image canvas style = ttk.Style() canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' self.components['person_canvas'] = tk.Canvas(left_panel, width=300, height=300, bg=canvas_bg_color, highlightthickness=0) self.components['person_canvas'].grid(row=2, column=0, pady=(0, 10)) # Save button self.components['save_btn'] = ttk.Button(left_panel, text="šŸ’¾ Save Changes", command=self._save_changes, state='disabled') self.components['save_btn'].grid(row=3, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) def _create_right_panel_content(self): """Create the right panel content for unidentified faces""" right_panel = self.components['right_panel'] # Control buttons for matches (Select All / Clear All) matches_controls_frame = ttk.Frame(right_panel) matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) self.components['select_all_btn'] = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", command=self._select_all_matches, state='disabled') self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) self.components['clear_all_btn'] = ttk.Button(matches_controls_frame, text="☐ Clear All", command=self._clear_all_matches, state='disabled') self.components['clear_all_btn'].pack(side=tk.LEFT) # Create scrollable frame for matches matches_frame = ttk.Frame(right_panel) matches_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) matches_frame.columnconfigure(0, weight=1) matches_frame.rowconfigure(0, weight=1) # Create canvas and scrollbar for matches style = ttk.Style() canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' self.components['matches_canvas'] = tk.Canvas(matches_frame, bg=canvas_bg_color, highlightthickness=0) self.components['matches_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) scrollbar = ttk.Scrollbar(matches_frame, orient="vertical", command=self.components['matches_canvas'].yview) scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) self.components['matches_canvas'].configure(yscrollcommand=scrollbar.set) # Configure right panel grid weights right_panel.columnconfigure(0, weight=1) right_panel.rowconfigure(1, weight=1) def _create_control_buttons(self): """Create the control buttons for navigation""" control_frame = ttk.Frame(self.main_frame) control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0)) self.components['back_btn'] = ttk.Button(control_frame, text="ā®ļø Back", command=self._go_back, state='disabled') self.components['back_btn'].grid(row=0, column=0, padx=(0, 5)) self.components['next_btn'] = ttk.Button(control_frame, text="ā­ļø Next", command=self._go_next, state='disabled') self.components['next_btn'].grid(row=0, column=1, padx=5) self.components['quit_btn'] = ttk.Button(control_frame, text="āŒ Exit Auto-Match", command=self._quit_auto_match) self.components['quit_btn'].grid(row=0, column=2, padx=(5, 0)) def _start_auto_match(self): """Start the auto-match process""" try: tolerance = float(self.components['tolerance_var'].get().strip()) if tolerance < 0 or tolerance > 1: raise ValueError except Exception: messagebox.showerror("Error", "Please enter a valid tolerance value between 0.0 and 1.0.") return include_same_photo = False # Always exclude same photo matching # Get all identified faces (one per person) to use as reference faces with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score FROM faces f JOIN photos p ON f.photo_id = p.id WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 ORDER BY f.person_id, f.quality_score DESC ''') identified_faces = cursor.fetchall() if not identified_faces: messagebox.showinfo("No Identified Faces", "šŸ” No identified faces found for auto-matching") return # Group by person and get the best quality face per person person_faces = {} for face in identified_faces: person_id = face[1] if person_id not in person_faces: person_faces[person_id] = face # Convert to ordered list to ensure consistent ordering person_faces_list = [] for person_id, face in person_faces.items(): # Get person name for ordering with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,)) result = cursor.fetchone() if result: first_name, last_name = result if last_name and first_name: person_name = f"{last_name}, {first_name}" elif last_name: person_name = last_name elif first_name: person_name = first_name else: person_name = "Unknown" else: person_name = "Unknown" person_faces_list.append((person_id, face, person_name)) # Sort by person name for consistent, user-friendly ordering person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2) # Find similar faces for each identified person self.matches_by_matched = {} for person_id, reference_face, person_name in person_faces_list: reference_face_id = reference_face[0] # Use the same filtering and sorting logic as identify similar_faces = self.face_processor._get_filtered_similar_faces( reference_face_id, tolerance, include_same_photo, face_status=None) # Convert to auto-match format person_matches = [] for similar_face in similar_faces: match = { 'unidentified_id': similar_face['face_id'], 'unidentified_photo_id': similar_face['photo_id'], 'unidentified_filename': similar_face['filename'], 'unidentified_location': similar_face['location'], 'matched_id': reference_face_id, 'matched_photo_id': reference_face[2], 'matched_filename': reference_face[4], 'matched_location': reference_face[3], 'person_id': person_id, 'distance': similar_face['distance'], 'quality_score': similar_face['quality_score'], 'adaptive_tolerance': similar_face.get('adaptive_tolerance', tolerance) } person_matches.append(match) self.matches_by_matched[person_id] = person_matches # Flatten all matches for counting all_matches = [] for person_matches in self.matches_by_matched.values(): all_matches.extend(person_matches) if not all_matches: messagebox.showinfo("No Matches", "šŸ” No similar faces found for auto-identification") return # Pre-fetch all needed data self.data_cache = self._prefetch_auto_match_data(self.matches_by_matched) # Initialize state self.matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in self.matches_by_matched and self.matches_by_matched[person_id]] self.filtered_matched_ids = None self.current_matched_index = 0 self.identified_faces_per_person = {} self.checkbox_states_per_person = {} self.original_checkbox_states_per_person = {} self.identified_count = 0 # Check if there's only one person - disable search if so has_only_one_person = len(self.matched_ids) == 1 if has_only_one_person: self.components['search_var'].set("") search_entry = None for widget in self.components['left_panel'].winfo_children(): if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0: for child in widget.winfo_children(): if isinstance(child, ttk.Entry): search_entry = child break if search_entry: search_entry.config(state='disabled') self.components['search_help_label'].config(text="(Search disabled - only one person found)") # Enable controls self._update_control_states() # Show the first person self._update_display() self.is_active = True def _prefetch_auto_match_data(self, matches_by_matched: Dict) -> Dict: """Pre-fetch all needed data to avoid repeated database queries""" data_cache = {} with self.db.get_db_connection() as conn: cursor = conn.cursor() # Pre-fetch all person names and details person_ids = list(matches_by_matched.keys()) if person_ids: placeholders = ','.join('?' * len(person_ids)) cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids) data_cache['person_details'] = {} for row in cursor.fetchall(): person_id = row[0] first_name = row[1] or '' last_name = row[2] or '' middle_name = row[3] or '' maiden_name = row[4] or '' date_of_birth = row[5] or '' # Create full name display name_parts = [] if first_name: name_parts.append(first_name) if middle_name: name_parts.append(middle_name) if last_name: name_parts.append(last_name) if maiden_name: name_parts.append(f"({maiden_name})") full_name = ' '.join(name_parts) data_cache['person_details'][person_id] = { 'full_name': full_name, 'first_name': first_name, 'last_name': last_name, 'middle_name': middle_name, 'maiden_name': maiden_name, 'date_of_birth': date_of_birth } # Pre-fetch all photo paths (both matched and unidentified) all_photo_ids = set() for person_matches in matches_by_matched.values(): for match in person_matches: all_photo_ids.add(match['matched_photo_id']) all_photo_ids.add(match['unidentified_photo_id']) if all_photo_ids: photo_ids_list = list(all_photo_ids) placeholders = ','.join('?' * len(photo_ids_list)) cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list) data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()} return data_cache def _update_display(self): """Update the display for the current person""" active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids if self.current_matched_index >= len(active_ids): self._finish_auto_match() return matched_id = active_ids[self.current_matched_index] matches_for_this_person = self.matches_by_matched[matched_id] # Update button states self._update_control_states() # Update save button text with person name self._update_save_button_text() # Get the first match to get matched person info if not matches_for_this_person: print(f"āŒ Error: No matches found for current person {matched_id}") # Skip to next person if available if self.current_matched_index < len(active_ids) - 1: self.current_matched_index += 1 self._update_display() else: self._finish_auto_match() return first_match = matches_for_this_person[0] # Use cached data instead of database queries person_details = self.data_cache['person_details'].get(first_match['person_id'], {}) person_name = person_details.get('full_name', "Unknown") date_of_birth = person_details.get('date_of_birth', '') matched_photo_path = self.data_cache['photo_paths'].get(first_match['matched_photo_id'], None) # Create detailed person info display person_info_lines = [f"šŸ‘¤ Person: {person_name}"] if date_of_birth: person_info_lines.append(f"šŸ“… Born: {date_of_birth}") person_info_lines.extend([ f"šŸ“ Photo: {first_match['matched_filename']}", f"šŸ“ Face location: {first_match['matched_location']}" ]) # Update matched person info self.components['person_info_label'].config(text="\n".join(person_info_lines)) # Display matched person face self.components['person_canvas'].delete("all") if matched_photo_path: matched_crop_path = self.face_processor._extract_face_crop( matched_photo_path, first_match['matched_location'], f"matched_{first_match['person_id']}" ) if matched_crop_path and os.path.exists(matched_crop_path): try: pil_image = Image.open(matched_crop_path) pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(pil_image) self.components['person_canvas'].create_image(150, 150, image=photo) self.components['person_canvas'].image = photo # Add photo icon to the matched person face actual_width, actual_height = pil_image.size top_left_x = 150 - (actual_width // 2) top_left_y = 150 - (actual_height // 2) self.gui_core.create_photo_icon(self.components['person_canvas'], matched_photo_path, icon_size=20, face_x=top_left_x, face_y=top_left_y, face_width=actual_width, face_height=actual_height, canvas_width=300, canvas_height=300) except Exception as e: self.components['person_canvas'].create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") else: self.components['person_canvas'].create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") # Clear and populate unidentified faces self._update_matches_display(matches_for_this_person, matched_id) def _update_matches_display(self, matches_for_this_person, matched_id): """Update the matches display for the current person""" # Clear existing matches self.components['matches_canvas'].delete("all") self.match_checkboxes = [] self.match_vars = [] # Create frame for unidentified faces inside canvas matches_inner_frame = ttk.Frame(self.components['matches_canvas']) self.components['matches_canvas'].create_window((0, 0), window=matches_inner_frame, anchor="nw") # Use cached photo paths photo_paths = self.data_cache['photo_paths'] # Create all checkboxes for i, match in enumerate(matches_for_this_person): # Get unidentified face info from cached data unidentified_photo_path = photo_paths.get(match['unidentified_photo_id'], '') # Calculate confidence confidence_pct = (1 - match['distance']) * 100 confidence_desc = self.face_processor._get_confidence_description(confidence_pct) # Create match frame match_frame = ttk.Frame(matches_inner_frame) match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5) # Checkbox for this match match_var = tk.BooleanVar() # Restore previous checkbox state if available unique_key = f"{matched_id}_{match['unidentified_id']}" if matched_id in self.checkbox_states_per_person and unique_key in self.checkbox_states_per_person[matched_id]: saved_state = self.checkbox_states_per_person[matched_id][unique_key] match_var.set(saved_state) # Otherwise, pre-select if this face was previously identified for this person elif matched_id in self.identified_faces_per_person and match['unidentified_id'] in self.identified_faces_per_person[matched_id]: match_var.set(True) self.match_vars.append(match_var) # Capture original state at render time if matched_id not in self.original_checkbox_states_per_person: self.original_checkbox_states_per_person[matched_id] = {} if unique_key not in self.original_checkbox_states_per_person[matched_id]: self.original_checkbox_states_per_person[matched_id][unique_key] = match_var.get() # Add callback to save state immediately when checkbox changes def on_checkbox_change(var, person_id, face_id): unique_key = f"{person_id}_{face_id}" if person_id not in self.checkbox_states_per_person: self.checkbox_states_per_person[person_id] = {} current_value = var.get() self.checkbox_states_per_person[person_id][unique_key] = current_value # Bind the callback to the variable current_person_id = matched_id current_face_id = match['unidentified_id'] match_var.trace('w', lambda *args, var=match_var, person_id=current_person_id, face_id=current_face_id: on_checkbox_change(var, person_id, 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 checkbox = ttk.Checkbutton(match_frame, variable=match_var) checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) self.match_checkboxes.append(checkbox) # Unidentified face image match_canvas = None if unidentified_photo_path: style = ttk.Style() canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' match_canvas = tk.Canvas(match_frame, width=100, height=100, bg=canvas_bg_color, highlightthickness=0) match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10)) unidentified_crop_path = self.face_processor._extract_face_crop( unidentified_photo_path, match['unidentified_location'], f"unid_{match['unidentified_id']}" ) if unidentified_crop_path and os.path.exists(unidentified_crop_path): try: pil_image = Image.open(unidentified_crop_path) pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(pil_image) match_canvas.create_image(50, 50, image=photo) match_canvas.image = photo # Add photo icon self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, face_x=0, face_y=0, face_width=100, face_height=100, canvas_width=100, canvas_height=100) except Exception: match_canvas.create_text(50, 50, text="āŒ", fill="red") else: match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") # Confidence badge and filename info_container = ttk.Frame(match_frame) info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E)) badge = self.gui_core.create_confidence_badge(info_container, confidence_pct) badge.pack(anchor=tk.W) filename_label = ttk.Label(info_container, text=f"šŸ“ {match['unidentified_filename']}", font=("Arial", 8), foreground="gray") filename_label.pack(anchor=tk.W, pady=(2, 0)) # Update Select All / Clear All button states self._update_match_control_buttons_state() # Update scroll region self.components['matches_canvas'].update_idletasks() self.components['matches_canvas'].configure(scrollregion=self.components['matches_canvas'].bbox("all")) def _update_control_states(self): """Update control button states based on current position""" active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids # Enable/disable Back button if self.current_matched_index > 0: self.components['back_btn'].config(state='normal') else: self.components['back_btn'].config(state='disabled') # Enable/disable Next button if self.current_matched_index < len(active_ids) - 1: self.components['next_btn'].config(state='normal') else: self.components['next_btn'].config(state='disabled') # Enable save button if we have matches if active_ids and self.current_matched_index < len(active_ids): self.components['save_btn'].config(state='normal') else: self.components['save_btn'].config(state='disabled') def _update_save_button_text(self): """Update save button text with current person name""" active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids if self.current_matched_index < len(active_ids): matched_id = active_ids[self.current_matched_index] matches_for_current_person = self.matches_by_matched[matched_id] if matches_for_current_person: person_id = matches_for_current_person[0]['person_id'] person_details = self.data_cache['person_details'].get(person_id, {}) person_name = person_details.get('full_name', "Unknown") self.components['save_btn'].config(text=f"šŸ’¾ Save changes for {person_name}") else: self.components['save_btn'].config(text="šŸ’¾ Save Changes") else: self.components['save_btn'].config(text="šŸ’¾ Save Changes") def _update_match_control_buttons_state(self): """Enable/disable Select All / Clear All based on matches presence""" if hasattr(self, 'match_vars') and self.match_vars: self.components['select_all_btn'].config(state='normal') self.components['clear_all_btn'].config(state='normal') else: self.components['select_all_btn'].config(state='disabled') self.components['clear_all_btn'].config(state='disabled') def _select_all_matches(self): """Select all match checkboxes""" if hasattr(self, 'match_vars'): for var in self.match_vars: var.set(True) def _clear_all_matches(self): """Clear all match checkboxes""" if hasattr(self, 'match_vars'): for var in self.match_vars: var.set(False) def _save_changes(self): """Save changes for the current person""" active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids if self.current_matched_index < len(active_ids): matched_id = active_ids[self.current_matched_index] matches_for_this_person = self.matches_by_matched[matched_id] # Initialize identified faces for this person if not exists if matched_id not in self.identified_faces_per_person: self.identified_faces_per_person[matched_id] = set() with self.db.get_db_connection() as conn: cursor = conn.cursor() # Process all matches (both checked and unchecked) for i, (match, var) in enumerate(zip(matches_for_this_person, self.match_vars)): if var.get(): # Face is checked - assign to person cursor.execute( 'UPDATE faces SET person_id = ? WHERE id = ?', (match['person_id'], match['unidentified_id']) ) # Use cached person name person_details = self.data_cache['person_details'].get(match['person_id'], {}) person_name = person_details.get('full_name', "Unknown") # Track this face as identified for this person self.identified_faces_per_person[matched_id].add(match['unidentified_id']) print(f"āœ… Identified as: {person_name}") self.identified_count += 1 else: # Face is unchecked - check if it was previously identified for this person if match['unidentified_id'] in self.identified_faces_per_person[matched_id]: # This face was previously identified for this person, now unchecking it cursor.execute( 'UPDATE faces SET person_id = NULL WHERE id = ?', (match['unidentified_id'],) ) # Remove from identified faces for this person self.identified_faces_per_person[matched_id].discard(match['unidentified_id']) print(f"āŒ Unidentified: {match['unidentified_filename']}") # Update person encodings for all affected persons for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']): self.face_processor.update_person_encodings(person_id) conn.commit() # After saving, set original states to the current UI states current_snapshot = {} for match, var in zip(matches_for_this_person, self.match_vars): unique_key = f"{matched_id}_{match['unidentified_id']}" current_snapshot[unique_key] = var.get() self.checkbox_states_per_person[matched_id] = dict(current_snapshot) self.original_checkbox_states_per_person[matched_id] = dict(current_snapshot) def _go_back(self): """Go back to the previous person""" if self.current_matched_index > 0: self.current_matched_index -= 1 self._update_display() def _go_next(self): """Go to the next person""" active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids if self.current_matched_index < len(active_ids) - 1: self.current_matched_index += 1 self._update_display() else: self._finish_auto_match() def _apply_search_filter(self): """Filter people by last name and update navigation""" query = self.components['search_var'].get().strip().lower() if query: # Filter person_faces_list by last name filtered_people = [] for person_id in self.matched_ids: # Get person name from cache person_details = self.data_cache['person_details'].get(person_id, {}) person_name = person_details.get('full_name', '') # Extract last name from person_name if ',' in person_name: last_name = person_name.split(',')[0].strip().lower() else: # Try to extract last name from full name name_parts = person_name.strip().split() if name_parts: last_name = name_parts[-1].lower() else: last_name = '' if query in last_name: filtered_people.append(person_id) self.filtered_matched_ids = filtered_people else: self.filtered_matched_ids = None # Reset to first person in filtered list self.current_matched_index = 0 if self.filtered_matched_ids: self._update_display() else: # No matches - clear display self.components['person_info_label'].config(text="No people match filter") self.components['person_canvas'].delete("all") self.components['person_canvas'].create_text(150, 150, text="No matches found", fill="gray") self.components['matches_canvas'].delete("all") self._update_control_states() def _clear_search_filter(self): """Clear filter and show all people""" self.components['search_var'].set("") self.filtered_matched_ids = None self.current_matched_index = 0 self._update_display() def _finish_auto_match(self): """Finish the auto-match process""" print(f"\nāœ… Auto-identified {self.identified_count} faces") messagebox.showinfo("Auto-Match Complete", f"Auto-identified {self.identified_count} faces") self._cleanup() def _quit_auto_match(self): """Quit the auto-match process""" # Check for unsaved changes before quitting if self._has_unsaved_changes(): result = self.gui_core.create_large_messagebox( self.main_frame, "Unsaved Changes", "You have unsaved changes that will be lost if you quit.\n\n" "Yes: Save current changes and quit\n" "No: Quit without saving\n" "Cancel: Return to auto-match", "askyesnocancel" ) if result is None: # Cancel return if result: # Save current person's changes, then quit self._save_changes() self._cleanup() # Navigate to home if callback is available (dashboard mode) if self.on_navigate_home: self.on_navigate_home() def _has_unsaved_changes(self): """Check if there are any unsaved changes""" for person_id, current_states in self.checkbox_states_per_person.items(): if person_id in self.original_checkbox_states_per_person: original_states = self.original_checkbox_states_per_person[person_id] # Check if any checkbox state differs from its original state for key, current_value in current_states.items(): if key not in original_states or original_states[key] != current_value: return True else: # If person has current states but no original states, there are changes if any(current_states.values()): return True return False def _cleanup(self): """Clean up resources and reset state""" # Clean up face crops self.face_processor.cleanup_face_crops() # Reset state self.matches_by_matched = {} self.data_cache = {} self.current_matched_index = 0 self.matched_ids = [] self.filtered_matched_ids = None self.identified_faces_per_person = {} self.checkbox_states_per_person = {} self.original_checkbox_states_per_person = {} self.identified_count = 0 # Clear displays self.components['person_info_label'].config(text="") self.components['person_canvas'].delete("all") self.components['matches_canvas'].delete("all") # Disable controls self.components['back_btn'].config(state='disabled') self.components['next_btn'].config(state='disabled') self.components['save_btn'].config(state='disabled') self.components['select_all_btn'].config(state='disabled') self.components['clear_all_btn'].config(state='disabled') # Clear search self.components['search_var'].set("") self.components['search_help_label'].config(text="Type Last Name") # Re-enable search entry search_entry = None for widget in self.components['left_panel'].winfo_children(): if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0: for child in widget.winfo_children(): if isinstance(child, ttk.Entry): search_entry = child break if search_entry: search_entry.config(state='normal') self.is_active = False 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