Add AutoMatchGUI for face identification in PhotoTagger

This commit introduces the AutoMatchGUI class, enabling users to automatically identify and match unidentified faces against already identified ones within the PhotoTagger application. The new GUI provides a user-friendly interface for displaying potential matches, selecting identified faces, and saving changes. It integrates seamlessly with existing components, enhancing the overall functionality of the application. The PhotoTagger class is updated to utilize this new feature, streamlining the face identification process. Additionally, relevant documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-10-03 15:39:09 -04:00
parent 5c1d5584a3
commit 38f931a7a7
2 changed files with 897 additions and 1143 deletions

894
auto_match_gui.py Normal file
View File

@ -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('<Return>', 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

File diff suppressed because it is too large Load Diff