punimtag/auto_match_panel.py
tanyar09 3e88e2cd2c Enhance Dashboard GUI with smart navigation and unified exit behavior
This commit introduces a compact home icon for quick navigation to the welcome screen, improving user experience across all panels. Additionally, all exit buttons now navigate to the home screen instead of closing the application, ensuring a consistent exit behavior. The README has been updated to reflect these enhancements, emphasizing the improved navigation and user experience in the unified dashboard.
2025-10-10 14:47:38 -04:00

876 lines
41 KiB
Python

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