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:
parent
5c1d5584a3
commit
38f931a7a7
894
auto_match_gui.py
Normal file
894
auto_match_gui.py
Normal 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
|
||||
1146
photo_tagger.py
1146
photo_tagger.py
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user