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