diff --git a/README.md b/README.md index 1c6187a..8322f4a 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ python3 photo_tagger.py identify --show-faces --batch 10 # GUI mode without face crops (coordinates only) python3 photo_tagger.py identify --batch 10 -# Auto-match faces across photos with GUI (NEW!) +# Auto-match faces across photos with GUI python3 photo_tagger.py auto-match --show-faces # Auto-identify high-confidence matches @@ -382,7 +382,7 @@ python3 photo_tagger.py stats # 4. Identify faces with GUI interface (ENHANCED!) python3 photo_tagger.py identify --show-faces --batch 10 -# 5. Auto-match faces across photos with GUI (NEW!) +# 5. Auto-match faces across photos with GUI python3 photo_tagger.py auto-match --show-faces # 6. Search for photos of someone @@ -697,6 +697,12 @@ When you click the šŸ“… calendar button, you'll see: ## šŸ†• Recent Improvements +### Auto-Match GUI Migration (Latest) +- **āœ… Complete Migration**: Auto-match GUI fully migrated from legacy version to current architecture +- **šŸ”„ Exact Feature Parity**: All functionality preserved including person-centric view, checkbox selection, and state persistence +- **šŸŽÆ Enhanced Integration**: Seamlessly integrated with new modular architecture while maintaining all original features +- **⚔ Performance Optimized**: Leverages new face processing and database management systems for better performance + ### Auto-Match UX Enhancements (Latest) - **šŸ’¾ Smart Save Button**: "Save changes for [Person Name]" button moved to left panel for better UX - **šŸ”„ State Persistence**: Checkbox selections now preserved when navigating between people diff --git a/photo_tagger.py b/photo_tagger.py index c87b8c8..2a71cef 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -123,11 +123,872 @@ class PhotoTagger: return self.face_processor.find_similar_faces(face_id, tolerance, include_same_photo) 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""" - # This would need to be implemented in the face_processing module - # For now, return 0 - print("āš ļø Auto-identify matches not yet implemented in refactored version") - return 0 + """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 # Tag management methods (delegated) def add_tags(self, photo_pattern: str = None, batch_size: int = DEFAULT_BATCH_SIZE) -> int: @@ -224,6 +1085,323 @@ class PhotoTagger: """Image cache (legacy compatibility)""" return self.face_processor._image_cache + def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None) -> List[Dict]: + """Get similar faces with consistent filtering and sorting logic used by both auto-match and identify""" + # Find similar faces using the core function + similar_faces_data = self.find_similar_faces(face_id, tolerance=tolerance, include_same_photo=include_same_photo) + + # Filter to only show unidentified faces with confidence filtering + filtered_faces = [] + for face in similar_faces_data: + # For auto-match: only filter by database state (keep existing behavior) + # For identify: also filter by current session state + is_identified_in_db = face.get('person_id') is not None + is_identified_in_session = face_status and face.get('face_id') in face_status and face_status[face.get('face_id')] == 'identified' + + # If face_status is provided (identify mode), use both filters + # If face_status is None (auto-match mode), only use database filter + if face_status is not None: + # Identify mode: filter out both database and session identified faces + if not is_identified_in_db and not is_identified_in_session: + # Calculate confidence percentage + confidence_pct = (1 - face['distance']) * 100 + + # Only include matches with reasonable confidence (at least 40%) + if confidence_pct >= 40: + filtered_faces.append(face) + else: + # Auto-match mode: only filter by database state (keep existing behavior) + if not is_identified_in_db: + # Calculate confidence percentage + confidence_pct = (1 - face['distance']) * 100 + + # Only include matches with reasonable confidence (at least 40%) + if confidence_pct >= 40: + filtered_faces.append(face) + + # Sort by confidence (distance) - highest confidence first + filtered_faces.sort(key=lambda x: x['distance']) + + 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"""