punimtag/identify_gui.py
tanyar09 b75e12816c Refactor AutoMatchGUI layout and enhance confidence display
This commit refines the layout of the AutoMatchGUI by adjusting the positioning of elements for better usability. The photo icon placement is now calculated based on the actual image dimensions, ensuring accurate positioning. Additionally, a new confidence badge feature is introduced, providing a visual representation of confidence levels alongside filenames. The layout adjustments improve the overall user experience by ensuring that images and related information are displayed more intuitively. The IdentifyGUI is also updated to reflect similar layout enhancements for consistency across the application.
2025-10-06 11:53:35 -04:00

2229 lines
109 KiB
Python

#!/usr/bin/env python3
"""
Face identification GUI implementation for PunimTag
"""
import os
import time
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_BATCH_SIZE, DEFAULT_FACE_TOLERANCE
from database import DatabaseManager
from face_processing import FaceProcessor
from gui_core import GUICore
class IdentifyGUI:
"""Handles the face identification GUI interface"""
def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0):
"""Initialize the identify GUI"""
self.db = db_manager
self.face_processor = face_processor
self.verbose = verbose
self.gui_core = GUICore()
def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, show_faces: bool = False,
tolerance: float = DEFAULT_FACE_TOLERANCE,
date_from: str = None, date_to: str = None,
date_processed_from: str = None, date_processed_to: str = None) -> int:
"""Interactive face identification with optimized performance"""
# Get unidentified faces from database
unidentified = self._get_unidentified_faces(batch_size, date_from, date_to,
date_processed_from, date_processed_to)
if not unidentified:
print("🎉 All faces have been identified!")
return 0
print(f"\n👤 Found {len(unidentified)} unidentified faces")
print("Commands: [name] = identify, 's' = skip, 'q' = quit, 'list' = show people\n")
# Pre-fetch all needed data to avoid repeated database queries
print("📊 Pre-fetching data for optimal performance...")
identify_data_cache = self._prefetch_identify_data(unidentified)
print(f"✅ Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names")
identified_count = 0
# Create the main window
root = tk.Tk()
root.title("Face Identification")
root.resizable(True, True)
# Track window state to prevent multiple destroy calls
window_destroyed = False
selected_person_id = None
force_exit = False
# Track current face crop path for cleanup
current_face_crop_path = None
# Hide window initially to prevent flash at corner
root.withdraw()
# Set up protocol handler for window close button (X)
def on_closing():
nonlocal command, waiting_for_input, window_destroyed, current_face_crop_path, force_exit
# First check for selected similar faces without person name
if not self._validate_navigation(gui_components):
return # Cancel close
# Check if there are pending identifications
pending_identifications = self._get_pending_identifications(face_person_names, face_status)
if pending_identifications:
# Ask user if they want to save pending identifications
result = messagebox.askyesnocancel(
"Save Pending Identifications?",
f"You have {len(pending_identifications)} pending identifications.\n\n"
"Do you want to save them before closing?\n\n"
"• Yes: Save all pending identifications and close\n"
"• No: Close without saving\n"
"• Cancel: Return to identification"
)
if result is True: # Yes - Save and close
identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache)
# Continue to cleanup and close
elif result is False: # No - Close without saving
# Continue to cleanup and close
pass
else: # Cancel - Don't close
return # Exit without cleanup or closing
# Only reach here if user chose Yes, No, or there are no pending identifications
# Clean up face crops and caches
self.face_processor.cleanup_face_crops(current_face_crop_path)
self.db.close_db_connection()
if not window_destroyed:
window_destroyed = True
try:
root.destroy()
except tk.TclError:
pass # Window already destroyed
# Force process termination
force_exit = True
root.quit()
root.protocol("WM_DELETE_WINDOW", on_closing)
# Set up window size saving
saved_size = self.gui_core.setup_window_size_saving(root)
# 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) # Left panel
main_frame.columnconfigure(1, weight=1) # Right panel for similar faces
# Configure row weights to minimize spacing around Unique checkbox
main_frame.rowconfigure(2, weight=0) # Unique checkbox row - no expansion
main_frame.rowconfigure(3, weight=1) # Main panels row - expandable
# Photo info
info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold"))
info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W)
# Store face selection states per face ID to preserve selections during navigation
face_selection_states = {} # {face_id: {unique_key: bool}}
# Store person names per face ID to preserve names during navigation
face_person_names = {} # {face_id: person_name}
# Process each face with back navigation support
# Keep track of original face list and current position
original_faces = list(unidentified) # Make a copy of the original list
i = 0
face_status = {} # Track which faces have been identified
# Button commands
command = None
waiting_for_input = False
# Define quit handler as local function (like in old version)
def on_quit():
nonlocal command, waiting_for_input, window_destroyed, force_exit
# First check for unsaved changes in the form
validation_result = self._validate_navigation(gui_components)
if validation_result == 'cancel':
return # Cancel quit
elif validation_result == 'save_and_continue':
# Save the current identification before proceeding
# Add the current form data to pending identifications
current_face_key = list(face_person_names.keys())[0] if face_person_names else None
if current_face_key:
first_name = gui_components['first_name_var'].get().strip()
last_name = gui_components['last_name_var'].get().strip()
date_of_birth = gui_components['date_of_birth_var'].get().strip()
if first_name and last_name and date_of_birth:
face_person_names[current_face_key] = {
'first_name': first_name,
'last_name': last_name,
'date_of_birth': date_of_birth
}
face_status[current_face_key] = 'identified'
elif validation_result == 'discard_and_continue':
# Clear the form but don't save
self._clear_form(gui_components)
# Check if there are pending identifications (faces with complete data but not yet saved)
pending_identifications = self._get_pending_identifications(face_person_names, face_status)
if pending_identifications:
# Temporarily disable window close handler to prevent interference
root.protocol("WM_DELETE_WINDOW", lambda: None)
# Ask user if they want to save pending identifications
result = messagebox.askyesnocancel(
"Save Pending Identifications?",
f"You have {len(pending_identifications)} pending identifications.\n\n"
"Do you want to save them before quitting?\n\n"
"• Yes: Save all pending identifications and quit\n"
"• No: Quit without saving\n"
"• Cancel: Return to identification"
)
# Re-enable window close handler
root.protocol("WM_DELETE_WINDOW", on_closing)
if result is True: # Yes - Save and quit
identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache)
command = 'q'
waiting_for_input = False
elif result is False: # No - Quit without saving
command = 'q'
waiting_for_input = False
else: # Cancel - Don't quit
return # Exit without any cleanup or window destruction
else:
# No pending identifications, quit normally
command = 'q'
waiting_for_input = False
# Only reach here if user chose Yes, No, or there are no pending identifications
# Clean up and close window
if not window_destroyed:
window_destroyed = True
try:
root.destroy()
except tk.TclError:
pass # Window already destroyed
# Force process termination
force_exit = True
root.quit()
# Create the GUI components with the quit handler
gui_components = self._create_gui_components(main_frame, identify_data_cache,
date_from, date_to, date_processed_from, date_processed_to, batch_size, on_quit)
# Set up command variable for button callbacks
self._current_command_var = gui_components['command_var']
# Show the window
try:
root.deiconify()
root.lift()
root.focus_force()
# Force window to render completely before proceeding
root.update_idletasks()
root.update()
# Small delay to ensure canvas is properly rendered
time.sleep(0.1)
# Schedule the first image update after the window is fully rendered
def update_first_image():
try:
if i < len(original_faces):
face_id, photo_id, photo_path, filename, location = original_faces[i]
# Extract face crop if enabled
face_crop_path = None
if show_faces:
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id)
# Update the face image
self._update_face_image(gui_components, show_faces, face_crop_path, photo_path)
except Exception as e:
print(f"❌ Error updating first image: {e}")
# Schedule the update after a short delay
root.after(200, update_first_image)
except tk.TclError:
# Window was destroyed before we could show it
return 0
# Main processing loop
while not window_destroyed:
# Check if current face is identified and update index if needed
if not self._update_current_face_index(original_faces, i, face_status):
# All faces have been identified
print("\n🎉 All faces have been identified!")
break
# Ensure we don't go beyond the bounds
if i >= len(original_faces):
# Stay on the last face instead of breaking
i = len(original_faces) - 1
face_id, photo_id, photo_path, filename, location = original_faces[i]
# Check if this face was already identified in this session
is_already_identified = face_id in face_status and face_status[face_id] == 'identified'
# Reset command and waiting state for each face
command = None
waiting_for_input = True
# Update the display
current_pos, total_unidentified = self._get_current_face_position(original_faces, i, face_status)
print(f"\n--- Face {current_pos}/{total_unidentified} (unidentified) ---")
print(f"📁 Photo: {filename}")
print(f"📍 Face location: {location}")
# Update title
root.title(f"Face Identification - {current_pos}/{total_unidentified} (unidentified)")
# Update button states
self._update_button_states(gui_components, original_faces, i, face_status)
# Update similar faces panel if compare is enabled
if gui_components['compare_var'].get():
self._update_similar_faces(gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i)
# Update photo info
if is_already_identified:
# Get the person name for this face
person_name = self._get_person_name_for_face(face_id)
info_label.config(text=f"📁 Photo: {filename} (Face {current_pos}/{total_unidentified}) - ✅ Already identified as: {person_name}")
print(f"✅ Already identified as: {person_name}")
else:
info_label.config(text=f"📁 Photo: {filename} (Face {current_pos}/{total_unidentified})")
# Extract face crop if enabled
face_crop_path = None
if show_faces:
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id)
if face_crop_path:
print(f"🖼️ Face crop saved: {face_crop_path}")
current_face_crop_path = face_crop_path # Track for cleanup
else:
print("💡 Use --show-faces flag to display individual face crops")
current_face_crop_path = None
print(f"\n🖼️ Viewing face {current_pos}/{total_unidentified} from {filename}")
# Clear and update image
self._update_face_image(gui_components, show_faces, face_crop_path, photo_path)
# Set person name input - restore saved name or use database/empty value
self._restore_person_name_input(gui_components, face_id, face_person_names, is_already_identified)
# Keep compare checkbox state persistent across navigation
gui_components['first_name_entry'].focus_set()
gui_components['first_name_entry'].icursor(0)
# Force GUI update before waiting for input
root.update_idletasks()
# Wait for user input
while waiting_for_input:
try:
root.update()
# Check for command from GUI buttons
if gui_components['command_var'].get():
command = gui_components['command_var'].get()
gui_components['command_var'].set("") # Clear the command
waiting_for_input = False
break
# Check for unique checkbox changes
if hasattr(self, '_last_unique_state'):
current_unique_state = gui_components['unique_var'].get()
if current_unique_state != self._last_unique_state:
# Unique checkbox state changed, apply filtering
original_faces = self._on_unique_faces_change(
gui_components, original_faces, i, face_status,
date_from, date_to, date_processed_from, date_processed_to
)
# Reset index to 0 when filtering changes
i = 0
self._last_unique_state = current_unique_state
# Continue to next iteration to update display
continue
else:
# Initialize the last unique state
self._last_unique_state = gui_components['unique_var'].get()
# Check for compare checkbox changes
if hasattr(self, '_last_compare_state'):
current_compare_state = gui_components['compare_var'].get()
if current_compare_state != self._last_compare_state:
# Compare checkbox state changed, update similar faces panel
self._on_compare_change(
gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i
)
self._last_compare_state = current_compare_state
# Continue to next iteration to update display
continue
else:
# Initialize the last compare state
self._last_compare_state = gui_components['compare_var'].get()
# Small delay to prevent excessive CPU usage
time.sleep(0.01)
except tk.TclError:
# Window was destroyed, break out of loop
break
# Check if force exit was requested
if force_exit:
break
# Check if force exit was requested (exit immediately)
if force_exit:
print("Force exit requested...")
# Clean up face crops and caches
self.face_processor.cleanup_face_crops(face_crop_path)
self.db.close_db_connection()
return identified_count
# Process the command
if command is None: # User clicked Cancel
command = 'q'
else:
command = command.strip()
if command.lower() == 'q':
# Clean up face crops and caches
self.face_processor.cleanup_face_crops(face_crop_path)
self.db.close_db_connection()
if not window_destroyed:
window_destroyed = True
try:
root.destroy()
except tk.TclError:
pass # Window already destroyed
return identified_count
elif command.lower() == 's':
print("➡️ Next")
# Save current checkbox states before navigating away
self._save_current_face_selection_states(gui_components, original_faces, i,
face_selection_states, face_person_names)
# Clean up current face crop when moving forward
if face_crop_path and os.path.exists(face_crop_path):
try:
os.remove(face_crop_path)
except:
pass # Ignore cleanup errors
current_face_crop_path = None # Clear tracked path
# Find next unidentified face
next_found = False
for j in range(i + 1, len(original_faces)):
if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified':
i = j
next_found = True
break
if not next_found:
print("⚠️ No more unidentified faces - Next button disabled")
continue
# Clear date of birth field when moving to next face
gui_components['date_of_birth_var'].set("")
# Clear middle name and maiden name fields when moving to next face
gui_components['middle_name_var'].set("")
gui_components['maiden_name_var'].set("")
self._update_button_states(gui_components, original_faces, i, face_status)
# Only update similar faces if compare is enabled
if gui_components['compare_var'].get():
self._update_similar_faces(gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i)
continue
elif command.lower() == 'back':
print("⬅️ Going back to previous face")
# Save current checkbox states before navigating away
self._save_current_face_selection_states(gui_components, original_faces, i,
face_selection_states, face_person_names)
# Find previous unidentified face
prev_found = False
for j in range(i - 1, -1, -1):
if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified':
i = j
prev_found = True
break
if not prev_found:
print("⚠️ No more unidentified faces - Back button disabled")
continue
# Repopulate fields with saved data when going back
self._restore_person_name_input(gui_components, original_faces[i][0], face_person_names, False)
self._update_button_states(gui_components, original_faces, i, face_status)
# Only update similar faces if compare is enabled
if gui_components['compare_var'].get():
self._update_similar_faces(gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i)
continue
elif command == 'reload_faces':
# Reload faces with new date filters
if 'filtered_faces' in gui_components:
# Update the original_faces list with filtered results
original_faces = list(gui_components['filtered_faces'])
# Update the date filter variables
date_from = gui_components.get('new_date_from')
date_to = gui_components.get('new_date_to')
date_processed_from = gui_components.get('new_date_processed_from')
date_processed_to = gui_components.get('new_date_processed_to')
# Reset to first face
i = 0
# Clear the filtered_faces data
del gui_components['filtered_faces']
print("💡 Navigate to refresh the display with filtered faces")
continue
else:
print("⚠️ No filtered faces data found")
continue
elif command.lower() == 'list':
self._show_people_list()
continue
elif command == 'identify':
try:
# Get form data
form_data = self._get_form_data(gui_components)
# Validate form data
is_valid, error_msg = self._validate_form_data(form_data)
if not is_valid:
messagebox.showerror("Validation Error", error_msg)
continue
# Process identification
identified_count += self._process_identification_command(
form_data, face_id, is_already_identified, face_status,
gui_components, identify_data_cache
)
# Clear form after successful identification
self._clear_form(gui_components)
except Exception as e:
print(f"❌ Error: {e}")
messagebox.showerror("Error", f"Error processing identification: {e}")
# Increment index for normal flow (identification or error) - but not if we're at the last item
if i < len(original_faces) - 1:
i += 1
self._update_button_states(gui_components, original_faces, i, face_status)
# Only update similar faces if compare is enabled
if gui_components['compare_var'].get():
self._update_similar_faces(gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i)
# Clean up current face crop when moving forward after identification
if face_crop_path and os.path.exists(face_crop_path):
try:
os.remove(face_crop_path)
except:
pass # Ignore cleanup errors
current_face_crop_path = None # Clear tracked path
# Continue to next face after processing command
continue
elif command:
try:
# Process other identification command (legacy support)
identified_count += self._process_identification_command(
command, face_id, is_already_identified, face_status,
gui_components, identify_data_cache
)
except Exception as e:
print(f"❌ Error: {e}")
# Increment index for normal flow (identification or error) - but not if we're at the last item
if i < len(original_faces) - 1:
i += 1
self._update_button_states(gui_components, original_faces, i, face_status)
# Only update similar faces if compare is enabled
if gui_components['compare_var'].get():
self._update_similar_faces(gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i)
# Clean up current face crop when moving forward after identification
if face_crop_path and os.path.exists(face_crop_path):
try:
os.remove(face_crop_path)
except:
pass # Ignore cleanup errors
current_face_crop_path = None # Clear tracked path
# Continue to next face after processing command
continue
else:
print("Please enter a name, 's' to skip, 'q' to quit, or use buttons")
# Only close the window if user explicitly quit (not when reaching end of faces)
if not window_destroyed:
# Keep the window open - user can still navigate and quit manually
print(f"\n✅ Identified {identified_count} faces")
print("💡 Application remains open - use Quit button to close")
# Don't destroy the window - let user quit manually
return identified_count
print(f"\n✅ Identified {identified_count} faces")
return identified_count
def _get_unidentified_faces(self, batch_size: int, date_from: str = None, date_to: str = None,
date_processed_from: str = None, date_processed_to: str = None):
"""Get unidentified faces from database with optional date filtering"""
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
# Build the SQL query with optional date filtering
query = '''
SELECT f.id, f.photo_id, p.path, p.filename, f.location
FROM faces f
JOIN photos p ON f.photo_id = p.id
WHERE f.person_id IS NULL
'''
params = []
# Add date taken filtering if specified
if date_from:
query += ' AND p.date_taken >= ?'
params.append(date_from)
if date_to:
query += ' AND p.date_taken <= ?'
params.append(date_to)
# Add date processed filtering if specified
if date_processed_from:
query += ' AND DATE(p.date_added) >= ?'
params.append(date_processed_from)
if date_processed_to:
query += ' AND DATE(p.date_added) <= ?'
params.append(date_processed_to)
query += ' LIMIT ?'
params.append(batch_size)
cursor.execute(query, params)
return cursor.fetchall()
def _prefetch_identify_data(self, unidentified):
"""Pre-fetch all needed data to avoid repeated database queries"""
identify_data_cache = {}
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
# Pre-fetch all photo paths for unidentified faces
photo_ids = [face[1] for face in unidentified] # face[1] is photo_id
if photo_ids:
placeholders = ','.join('?' * len(photo_ids))
cursor.execute(f'SELECT id, path, filename FROM photos WHERE id IN ({placeholders})', photo_ids)
identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()}
# Pre-fetch all people names for dropdown
cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name')
people = cursor.fetchall()
identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people]
# Pre-fetch unique last names for autocomplete (no DB during typing)
cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""')
_last_rows = cursor.fetchall()
identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()})
return identify_data_cache
def _create_gui_components(self, main_frame, identify_data_cache, date_from, date_to,
date_processed_from, date_processed_to, batch_size, on_quit=None):
"""Create all GUI components for the identify interface"""
components = {}
# Create variables for form data
components['compare_var'] = tk.BooleanVar()
components['unique_var'] = tk.BooleanVar()
components['first_name_var'] = tk.StringVar()
components['last_name_var'] = tk.StringVar()
components['middle_name_var'] = tk.StringVar()
components['maiden_name_var'] = tk.StringVar()
components['date_of_birth_var'] = tk.StringVar()
# Date filter variables
components['date_from_var'] = tk.StringVar(value=date_from or "")
components['date_to_var'] = tk.StringVar(value=date_to or "")
components['date_processed_from_var'] = tk.StringVar(value=date_processed_from or "")
components['date_processed_to_var'] = tk.StringVar(value=date_processed_to or "")
# Date filter controls - exactly as in original
date_filter_frame = ttk.LabelFrame(main_frame, text="Filter", padding="5")
date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W)
date_filter_frame.columnconfigure(1, weight=0)
date_filter_frame.columnconfigure(4, weight=0)
# Date from
ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5))
components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_from_var'], width=10, state='readonly')
components['date_from_entry'].grid(row=0, column=1, sticky=tk.W, padx=(0, 5))
# Calendar button for date from
def open_calendar_from():
self._open_date_picker(components['date_from_var'])
components['date_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_from)
components['date_from_btn'].grid(row=0, column=2, padx=(0, 10))
# Date to
ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5))
components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_to_var'], width=10, state='readonly')
components['date_to_entry'].grid(row=0, column=4, sticky=tk.W, padx=(0, 5))
# Calendar button for date to
def open_calendar_to():
self._open_date_picker(components['date_to_var'])
components['date_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_to)
components['date_to_btn'].grid(row=0, column=5, padx=(0, 10))
# Apply filter button
def apply_date_filter():
"""Apply date filters and reload faces"""
# Get current filter values
new_date_from = components['date_from_var'].get().strip() or None
new_date_to = components['date_to_var'].get().strip() or None
new_date_processed_from = components['date_processed_from_var'].get().strip() or None
new_date_processed_to = components['date_processed_to_var'].get().strip() or None
# Reload faces with new date filter
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
# Build the SQL query with optional date filtering
query = '''
SELECT f.id, f.photo_id, p.path, p.filename, f.location
FROM faces f
JOIN photos p ON f.photo_id = p.id
WHERE f.person_id IS NULL
'''
params = []
# Add date taken filtering if specified
if new_date_from:
query += ' AND p.date_taken >= ?'
params.append(new_date_from)
if new_date_to:
query += ' AND p.date_taken <= ?'
params.append(new_date_to)
# Add date processed filtering if specified
if new_date_processed_from:
query += ' AND DATE(p.date_added) >= ?'
params.append(new_date_processed_from)
if new_date_processed_to:
query += ' AND DATE(p.date_added) <= ?'
params.append(new_date_processed_to)
query += ' LIMIT ?'
params.append(batch_size)
cursor.execute(query, params)
filtered_faces = cursor.fetchall()
if not filtered_faces:
messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.")
return
# Build filter description
filters_applied = []
if new_date_from or new_date_to:
taken_filter = f"taken: {new_date_from or 'any'} to {new_date_to or 'any'}"
filters_applied.append(taken_filter)
if new_date_processed_from or new_date_processed_to:
processed_filter = f"processed: {new_date_processed_from or 'any'} to {new_date_processed_to or 'any'}"
filters_applied.append(processed_filter)
filter_desc = " | ".join(filters_applied) if filters_applied else "no filters"
print(f"📅 Applied filters: {filter_desc}")
print(f"👤 Found {len(filtered_faces)} unidentified faces with date filters")
# Set a special command to reload faces
components['command_var'].set("reload_faces")
# Store the filtered faces for the main loop to use
components['filtered_faces'] = filtered_faces
components['new_date_from'] = new_date_from
components['new_date_to'] = new_date_to
components['new_date_processed_from'] = new_date_processed_from
components['new_date_processed_to'] = new_date_processed_to
components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter)
components['apply_filter_btn'].grid(row=0, column=6, padx=(10, 0))
# Date processed filter (second row)
ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0))
components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_from_var'], width=10, state='readonly')
components['date_processed_from_entry'].grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0))
# Calendar button for date processed from
def open_calendar_processed_from():
self._open_date_picker(components['date_processed_from_var'])
components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_from)
components['date_processed_from_btn'].grid(row=1, column=2, padx=(0, 10), pady=(10, 0))
# Date processed to
ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0))
components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_to_var'], width=10, state='readonly')
components['date_processed_to_entry'].grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0))
# Calendar button for date processed to
def open_calendar_processed_to():
self._open_date_picker(components['date_processed_to_var'])
components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to)
components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0))
# Unique checkbox under the filter frame
def on_unique_change():
# This will be called when the checkbox state changes
# We'll handle the actual filtering in the main loop
pass
components['unique_check'] = ttk.Checkbutton(main_frame, text="Unique faces only",
variable=components['unique_var'],
command=on_unique_change)
components['unique_check'].grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0)
# Compare checkbox on the same row as Unique
def on_compare_change():
# This will be called when the checkbox state changes
# We'll handle the actual panel toggling in the main loop
pass
components['compare_check'] = ttk.Checkbutton(main_frame, text="Compare similar faces",
variable=components['compare_var'],
command=on_compare_change)
components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(5, 10), pady=0)
# Left panel for main face
left_panel = ttk.Frame(main_frame)
left_panel.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5), pady=(0, 0))
left_panel.columnconfigure(0, weight=1)
# Right panel for similar faces
components['right_panel'] = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5")
components['right_panel'].grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
components['right_panel'].columnconfigure(0, weight=1)
components['right_panel'].rowconfigure(0, weight=1) # Make right panel expandable vertically
# Right panel is always visible now
# Image display (left panel)
image_frame = ttk.Frame(left_panel)
image_frame.grid(row=0, column=0, pady=(0, 10), sticky=(tk.W, tk.E, tk.N, tk.S))
image_frame.columnconfigure(0, weight=1)
image_frame.rowconfigure(0, weight=1)
# Create canvas for image display
style = ttk.Style()
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
components['canvas'] = tk.Canvas(image_frame, width=400, height=400, bg=canvas_bg_color, highlightthickness=0)
components['canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# Store reference to current image data for redrawing on resize
components['canvas'].current_image_data = None
# Bind resize event to redraw image
def on_canvas_resize(event):
if hasattr(components['canvas'], 'current_image_data') and components['canvas'].current_image_data:
# Redraw the current image with new dimensions
self._redraw_current_image(components['canvas'])
components['canvas'].bind('<Configure>', on_canvas_resize)
# Input section (left panel)
input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10")
input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E))
input_frame.columnconfigure(1, weight=1)
input_frame.columnconfigure(3, weight=1)
input_frame.columnconfigure(5, weight=1)
input_frame.columnconfigure(7, weight=1)
# First name input
ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
components['first_name_entry'] = ttk.Entry(input_frame, textvariable=components['first_name_var'], width=12)
components['first_name_entry'].grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5))
# Last name input with autocomplete
ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10))
components['last_name_entry'] = ttk.Entry(input_frame, textvariable=components['last_name_var'], width=12)
components['last_name_entry'].grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5))
# Middle name input
ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10))
components['middle_name_entry'] = ttk.Entry(input_frame, textvariable=components['middle_name_var'], width=12)
components['middle_name_entry'].grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5))
# Date of birth input with calendar chooser
ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10))
# Create a frame for the date picker
date_frame = ttk.Frame(input_frame)
date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10))
# Maiden name input
ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10))
components['maiden_name_entry'] = ttk.Entry(input_frame, textvariable=components['maiden_name_var'], width=12)
components['maiden_name_entry'].grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5))
# Date display entry (read-only)
components['date_of_birth_entry'] = ttk.Entry(date_frame, textvariable=components['date_of_birth_var'], width=12, state='readonly')
components['date_of_birth_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True)
# Calendar button
components['date_of_birth_btn'] = ttk.Button(date_frame, text="📅", width=3,
command=lambda: self._open_date_picker(components['date_of_birth_var']))
components['date_of_birth_btn'].pack(side=tk.RIGHT, padx=(15, 0))
# Add required field asterisks (like in original)
self._add_required_asterisks(main_frame.master, input_frame, components)
# Add autocomplete for last name (like in original)
self._setup_last_name_autocomplete(main_frame.master, components, identify_data_cache)
# Identify button (placed in Person Identification frame)
components['identify_btn'] = ttk.Button(input_frame, text="✅ Identify", command=lambda: self._set_command('identify'), state='disabled')
components['identify_btn'].grid(row=2, column=0, pady=(10, 0), sticky=tk.W)
# Add event handlers to update Identify button state
def update_identify_button_state(*args):
self._update_identify_button_state(components)
components['first_name_var'].trace('w', update_identify_button_state)
components['last_name_var'].trace('w', update_identify_button_state)
components['date_of_birth_var'].trace('w', update_identify_button_state)
# Handle Enter key
def on_enter(event):
if components['identify_btn']['state'] == 'normal':
self._set_command('identify')
components['first_name_entry'].bind('<Return>', on_enter)
components['last_name_entry'].bind('<Return>', on_enter)
components['middle_name_entry'].bind('<Return>', on_enter)
components['maiden_name_entry'].bind('<Return>', on_enter)
# Create similar faces frame with controls
similar_faces_frame = ttk.Frame(components['right_panel'])
similar_faces_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
similar_faces_frame.columnconfigure(0, weight=1)
similar_faces_frame.rowconfigure(0, weight=0) # Controls row takes minimal space
similar_faces_frame.rowconfigure(1, weight=1) # Canvas row expandable
# Control buttons for similar faces (Select All / Clear All)
similar_controls_frame = ttk.Frame(similar_faces_frame)
similar_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0))
def select_all_similar_faces():
"""Select all similar faces checkboxes"""
if hasattr(self, '_similar_face_vars'):
for face_id, var in self._similar_face_vars:
var.set(True)
def clear_all_similar_faces():
"""Clear all similar faces checkboxes"""
if hasattr(self, '_similar_face_vars'):
for face_id, var in self._similar_face_vars:
var.set(False)
components['select_all_btn'] = ttk.Button(similar_controls_frame, text="☑️ Select All",
command=select_all_similar_faces, state='disabled')
components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5))
components['clear_all_btn'] = ttk.Button(similar_controls_frame, text="☐ Clear All",
command=clear_all_similar_faces, state='disabled')
components['clear_all_btn'].pack(side=tk.LEFT)
# Create canvas for similar faces with scrollbar
similar_canvas = tk.Canvas(similar_faces_frame, bg='lightgray', relief='sunken', bd=2)
similar_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# Similar faces scrollbars
similar_v_scrollbar = ttk.Scrollbar(similar_faces_frame, orient='vertical', command=similar_canvas.yview)
similar_v_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set)
# Create scrollable frame for similar faces
components['similar_scrollable_frame'] = ttk.Frame(similar_canvas)
similar_canvas.create_window((0, 0), window=components['similar_scrollable_frame'], anchor='nw')
# Store canvas reference for scrolling
components['similar_canvas'] = similar_canvas
# Add initial message when compare is disabled
no_compare_label = ttk.Label(components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces",
foreground="gray", font=("Arial", 10))
no_compare_label.pack(pady=20)
# Bottom control panel (move to bottom below panels)
control_frame = ttk.Frame(main_frame)
control_frame.grid(row=4, column=0, columnspan=3, pady=(10, 0), sticky=(tk.E, tk.S))
# Create button references for state management
components['control_back_btn'] = ttk.Button(control_frame, text="⬅️ Back", command=lambda: self._set_command('back'))
components['control_next_btn'] = ttk.Button(control_frame, text="➡️ Next", command=lambda: self._set_command('s'))
if on_quit:
components['control_quit_btn'] = ttk.Button(control_frame, text="❌ Quit", command=on_quit)
else:
components['control_quit_btn'] = ttk.Button(control_frame, text="❌ Quit", command=lambda: self._set_command('quit'))
components['control_back_btn'].pack(side=tk.LEFT, padx=(0, 5))
components['control_next_btn'].pack(side=tk.LEFT, padx=(0, 5))
components['control_quit_btn'].pack(side=tk.LEFT, padx=(5, 0))
# Store command variable for button callbacks
components['command_var'] = tk.StringVar()
return components
def _add_required_asterisks(self, root, input_frame, components):
"""Add red asterisks to required fields (first name, last name, date of birth)"""
# Red asterisks for required fields (overlayed, no layout impact)
first_name_asterisk = ttk.Label(root, text="*", foreground="red")
first_name_asterisk.place_forget()
last_name_asterisk = ttk.Label(root, text="*", foreground="red")
last_name_asterisk.place_forget()
date_asterisk = ttk.Label(root, text="*", foreground="red")
date_asterisk.place_forget()
def _position_required_asterisks(event=None):
"""Position required asterisks at top-right corner of their entries."""
try:
root.update_idletasks()
input_frame.update_idletasks()
components['first_name_entry'].update_idletasks()
components['last_name_entry'].update_idletasks()
components['date_of_birth_entry'].update_idletasks()
# Get absolute coordinates relative to root window
first_root_x = components['first_name_entry'].winfo_rootx()
first_root_y = components['first_name_entry'].winfo_rooty()
first_w = components['first_name_entry'].winfo_width()
root_x = root.winfo_rootx()
root_y = root.winfo_rooty()
# First name asterisk at the true top-right corner of entry
first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw')
first_name_asterisk.lift()
# Last name asterisk at the true top-right corner of entry
last_root_x = components['last_name_entry'].winfo_rootx()
last_root_y = components['last_name_entry'].winfo_rooty()
last_w = components['last_name_entry'].winfo_width()
last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw')
last_name_asterisk.lift()
# Date of birth asterisk at the true top-right corner of date entry
dob_root_x = components['date_of_birth_entry'].winfo_rootx()
dob_root_y = components['date_of_birth_entry'].winfo_rooty()
dob_w = components['date_of_birth_entry'].winfo_width()
date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw')
date_asterisk.lift()
except Exception:
pass
# Bind repositioning after all entries are created
def _bind_asterisk_positioning():
try:
input_frame.bind('<Configure>', _position_required_asterisks)
components['first_name_entry'].bind('<Configure>', _position_required_asterisks)
components['last_name_entry'].bind('<Configure>', _position_required_asterisks)
components['date_of_birth_entry'].bind('<Configure>', _position_required_asterisks)
_position_required_asterisks()
except Exception:
pass
root.after(100, _bind_asterisk_positioning)
def _setup_last_name_autocomplete(self, root, components, identify_data_cache):
"""Setup autocomplete functionality for last name field - exact copy from original"""
# Create listbox for suggestions (as overlay attached to root, not clipped by frames)
last_name_listbox = tk.Listbox(root, height=8)
last_name_listbox.place_forget() # Hide initially
# Navigation state variables (like in original)
navigating_to_listbox = False
escape_pressed = False
enter_pressed = False
def _show_suggestions():
"""Show filtered suggestions in listbox"""
all_last_names = identify_data_cache.get('last_names', [])
typed = components['last_name_var'].get().strip()
if not typed:
filtered = [] # Show nothing if no typing
else:
low = typed.lower()
# Only show names that start with the typed text
filtered = [n for n in all_last_names if n.lower().startswith(low)][:10]
# Update listbox
last_name_listbox.delete(0, tk.END)
for name in filtered:
last_name_listbox.insert(tk.END, name)
# Show listbox if we have suggestions (as overlay)
if filtered:
# Ensure geometry is up to date before positioning
root.update_idletasks()
# Absolute coordinates of entry relative to screen
entry_root_x = components['last_name_entry'].winfo_rootx()
entry_root_y = components['last_name_entry'].winfo_rooty()
entry_height = components['last_name_entry'].winfo_height()
# Convert to coordinates relative to root
root_origin_x = root.winfo_rootx()
root_origin_y = root.winfo_rooty()
place_x = entry_root_x - root_origin_x
place_y = entry_root_y - root_origin_y + entry_height
place_width = components['last_name_entry'].winfo_width()
# Calculate how many rows fit to bottom of window
available_px = max(60, root.winfo_height() - place_y - 8)
# Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows
approx_row_px = 18
rows_fit = max(3, min(len(filtered), available_px // approx_row_px))
last_name_listbox.configure(height=rows_fit)
last_name_listbox.place(x=place_x, y=place_y, width=place_width)
last_name_listbox.selection_clear(0, tk.END)
last_name_listbox.selection_set(0) # Select first item
last_name_listbox.activate(0) # Activate first item
else:
last_name_listbox.place_forget()
def _hide_suggestions():
"""Hide the suggestions listbox"""
last_name_listbox.place_forget()
def _on_listbox_select(event=None):
"""Handle listbox selection and hide list"""
selection = last_name_listbox.curselection()
if selection:
selected_name = last_name_listbox.get(selection[0])
components['last_name_var'].set(selected_name)
_hide_suggestions()
components['last_name_entry'].focus_set()
def _on_listbox_click(event):
"""Handle mouse click selection"""
try:
index = last_name_listbox.nearest(event.y)
if index is not None and index >= 0:
selected_name = last_name_listbox.get(index)
components['last_name_var'].set(selected_name)
except:
pass
_hide_suggestions()
components['last_name_entry'].focus_set()
return 'break'
def _on_key_press(event):
"""Handle key navigation in entry"""
nonlocal navigating_to_listbox, escape_pressed, enter_pressed
if event.keysym == 'Down':
if last_name_listbox.winfo_ismapped():
navigating_to_listbox = True
last_name_listbox.focus_set()
last_name_listbox.selection_clear(0, tk.END)
last_name_listbox.selection_set(0)
last_name_listbox.activate(0)
return 'break'
elif event.keysym == 'Escape':
escape_pressed = True
_hide_suggestions()
return 'break'
elif event.keysym == 'Return':
enter_pressed = True
return 'break'
def _on_listbox_key(event):
"""Handle key navigation in listbox"""
nonlocal enter_pressed, escape_pressed
if event.keysym == 'Return':
enter_pressed = True
_on_listbox_select(event)
return 'break'
elif event.keysym == 'Escape':
escape_pressed = True
_hide_suggestions()
components['last_name_entry'].focus_set()
return 'break'
elif event.keysym == 'Up':
selection = last_name_listbox.curselection()
if selection and selection[0] > 0:
# Move up in listbox
last_name_listbox.selection_clear(0, tk.END)
last_name_listbox.selection_set(selection[0] - 1)
last_name_listbox.see(selection[0] - 1)
else:
# At top, go back to entry field
_hide_suggestions()
components['last_name_entry'].focus_set()
return 'break'
elif event.keysym == 'Down':
selection = last_name_listbox.curselection()
max_index = last_name_listbox.size() - 1
if selection and selection[0] < max_index:
# Move down in listbox
last_name_listbox.selection_clear(0, tk.END)
last_name_listbox.selection_set(selection[0] + 1)
last_name_listbox.see(selection[0] + 1)
return 'break'
# Track if we're navigating to listbox to prevent auto-hide
navigating_to_listbox = False
escape_pressed = False
enter_pressed = False
def _safe_hide_suggestions():
"""Hide suggestions only if not navigating to listbox"""
nonlocal navigating_to_listbox
if not navigating_to_listbox:
_hide_suggestions()
navigating_to_listbox = False
def _safe_show_suggestions():
"""Show suggestions only if escape or enter wasn't just pressed"""
nonlocal escape_pressed, enter_pressed
if not escape_pressed and not enter_pressed:
_show_suggestions()
escape_pressed = False
enter_pressed = False
# Bind events
components['last_name_entry'].bind('<KeyRelease>', lambda e: _safe_show_suggestions())
components['last_name_entry'].bind('<KeyPress>', _on_key_press)
components['last_name_entry'].bind('<FocusOut>', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks
last_name_listbox.bind('<Button-1>', _on_listbox_click)
last_name_listbox.bind('<KeyPress>', _on_listbox_key)
last_name_listbox.bind('<Double-Button-1>', _on_listbox_click)
def _update_identify_button_state(self, gui_components):
"""Update the state of the Identify button based on form data"""
first_name = gui_components['first_name_var'].get().strip()
last_name = gui_components['last_name_var'].get().strip()
date_of_birth = gui_components['date_of_birth_var'].get().strip()
# Enable button if we have at least first name or last name, and date of birth
if (first_name or last_name) and date_of_birth:
gui_components['identify_btn'].config(state='normal')
else:
gui_components['identify_btn'].config(state='disabled')
def _update_control_button_states(self, gui_components, i, total_faces):
"""Update the state of control buttons based on current position"""
# Back button - disabled if at first face
if i <= 0:
gui_components['control_back_btn'].config(state='disabled')
else:
gui_components['control_back_btn'].config(state='normal')
# Next button - disabled if at last face
if i >= total_faces - 1:
gui_components['control_next_btn'].config(state='disabled')
else:
gui_components['control_next_btn'].config(state='normal')
def _update_select_clear_buttons_state(self, gui_components, similar_face_vars):
"""Enable/disable Select All and Clear All based on compare state and presence of items"""
if gui_components['compare_var'].get() and similar_face_vars:
gui_components['select_all_btn'].config(state='normal')
gui_components['clear_all_btn'].config(state='normal')
else:
gui_components['select_all_btn'].config(state='disabled')
gui_components['clear_all_btn'].config(state='disabled')
def _get_pending_identifications(self, face_person_names, face_status):
"""Get pending identifications that haven't been saved yet"""
pending_identifications = {}
for k, v in face_person_names.items():
if k not in face_status or face_status[k] != 'identified':
# Handle person data dict format
if isinstance(v, dict):
first_name = v.get('first_name', '').strip()
last_name = v.get('last_name', '').strip()
date_of_birth = v.get('date_of_birth', '').strip()
# Check if we have complete data (both first and last name, plus date of birth)
if first_name and last_name and date_of_birth:
pending_identifications[k] = v
return pending_identifications
def _save_all_pending_identifications(self, face_person_names, face_status, identify_data_cache):
"""Save all pending identifications from face_person_names"""
saved_count = 0
for face_id, person_data in face_person_names.items():
# Handle person data dict format
if isinstance(person_data, dict):
first_name = person_data.get('first_name', '').strip()
last_name = person_data.get('last_name', '').strip()
date_of_birth = person_data.get('date_of_birth', '').strip()
middle_name = person_data.get('middle_name', '').strip()
maiden_name = person_data.get('maiden_name', '').strip()
# Only save if we have at least a first or last name
if first_name or last_name:
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
# Add person if doesn't exist
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth))
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth))
result = cursor.fetchone()
person_id = result[0] if result else None
# Update people cache if new person was added
display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name)
if display_name not in identify_data_cache['people_names']:
identify_data_cache['people_names'].append(display_name)
identify_data_cache['people_names'].sort() # Keep sorted
# Keep last names cache updated in-session
if last_name:
if 'last_names' not in identify_data_cache:
identify_data_cache['last_names'] = []
if last_name not in identify_data_cache['last_names']:
identify_data_cache['last_names'].append(last_name)
identify_data_cache['last_names'].sort()
# Assign face to person
cursor.execute(
'UPDATE faces SET person_id = ? WHERE id = ?',
(person_id, face_id)
)
# Update person encodings
self.face_processor.update_person_encodings(person_id)
saved_count += 1
display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name)
print(f"✅ Saved identification: {display_name}")
except Exception as e:
display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name)
print(f"❌ Error saving identification for {display_name}: {e}")
if saved_count > 0:
print(f"💾 Saved {saved_count} pending identifications")
return saved_count
def _update_current_face_index(self, original_faces, i, face_status):
"""Update the current face index to point to a valid unidentified face"""
unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified']
if not unidentified_faces:
# All faces identified, we're done
return False
# Find the current face in the unidentified list
current_face_id = original_faces[i][0] if i < len(original_faces) else None
if current_face_id and current_face_id in face_status and face_status[current_face_id] == 'identified':
# Current face was just identified, find the next unidentified face
if i < len(original_faces) - 1:
# Try to find the next unidentified face
for j in range(i + 1, len(original_faces)):
if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified':
i = j
break
else:
# No more faces after current, go to previous
for j in range(i - 1, -1, -1):
if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified':
i = j
break
else:
# At the end, go to previous unidentified face
for j in range(i - 1, -1, -1):
if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified':
i = j
break
# Ensure index is within bounds
if i >= len(original_faces):
i = len(original_faces) - 1
if i < 0:
i = 0
return True
def _get_current_face_position(self, original_faces, i, face_status):
"""Get current face position among unidentified faces"""
unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified']
current_face_id = original_faces[i][0] if i < len(original_faces) else None
# Find position of current face in unidentified list
for pos, face in enumerate(unidentified_faces):
if face[0] == current_face_id:
return pos + 1, len(unidentified_faces)
return 1, len(unidentified_faces) # Fallback
def _update_button_states(self, gui_components, original_faces, i, face_status):
"""Update button states based on current position and unidentified faces"""
# Update control button states
self._update_control_button_states(gui_components, i, len(original_faces))
# Update identify button state
self._update_identify_button_state(gui_components)
# Update similar faces control buttons state
# Get similar face variables if they exist
similar_face_vars = getattr(self, '_similar_face_vars', [])
self._update_select_clear_buttons_state(gui_components, similar_face_vars)
def _update_similar_faces(self, gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i):
"""Update the similar faces panel when compare is enabled"""
try:
if not gui_components['compare_var'].get():
return
scrollable_frame = gui_components['similar_scrollable_frame']
# Clear existing content
for widget in scrollable_frame.winfo_children():
widget.destroy()
# Get similar faces using filtered version (includes 40% confidence threshold)
similar_faces = self.face_processor._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status)
if not similar_faces:
no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found",
foreground="gray", font=("Arial", 10))
no_faces_label.pack(pady=20)
return
# Filter out already identified faces if unique checkbox is checked
if gui_components['unique_var'].get():
similar_faces = [face for face in similar_faces
if face.get('person_id') is None]
if not similar_faces:
no_faces_label = ttk.Label(scrollable_frame, text="No unique similar faces found",
foreground="gray", font=("Arial", 10))
no_faces_label.pack(pady=20)
return
# Sort by confidence (distance) - highest confidence first (lowest distance)
similar_faces.sort(key=lambda x: x['distance'])
# Display similar faces using the old version's approach
self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id,
face_selection_states, identify_data_cache)
# Update canvas scroll region
canvas = gui_components['similar_canvas']
canvas.update_idletasks()
canvas.configure(scrollregion=canvas.bbox("all"))
if self.verbose >= 2:
print(f" 🔍 Displayed {len(similar_faces)} similar faces")
except Exception as e:
print(f"❌ Error updating similar faces: {e}")
def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id,
face_selection_states, identify_data_cache):
"""Display similar faces in a panel - based on old version's auto-match display logic"""
import tkinter as tk
from tkinter import ttk
from PIL import Image, ImageTk
import os
# Store similar face variables for Select All/Clear All functionality
similar_face_vars = []
# Create all similar faces using auto-match style display
for i, face_data in enumerate(similar_faces_data[:10]): # Limit to 10 faces
similar_face_id = face_data['face_id']
filename = face_data['filename']
distance = face_data['distance']
quality = face_data.get('quality_score', 0.5)
# Calculate confidence like in auto-match
confidence_pct = (1 - distance) * 100
confidence_desc = self._get_confidence_description(confidence_pct)
# Create match frame using auto-match style
match_frame = ttk.Frame(parent_frame)
match_frame.pack(fill=tk.X, padx=5, pady=5)
# Checkbox for this match (reusing auto-match checkbox style)
match_var = tk.BooleanVar()
# Restore previous checkbox state if available (auto-match style)
if current_face_id is not None and face_selection_states is not None:
unique_key = f"{current_face_id}_{similar_face_id}"
if current_face_id in face_selection_states and unique_key in face_selection_states[current_face_id]:
saved_state = face_selection_states[current_face_id][unique_key]
match_var.set(saved_state)
# Add immediate callback to save state when checkbox changes (auto-match style)
def make_callback(var, face_id, similar_face_id):
def on_checkbox_change(*args):
unique_key = f"{face_id}_{similar_face_id}"
if face_id not in face_selection_states:
face_selection_states[face_id] = {}
face_selection_states[face_id][unique_key] = var.get()
return on_checkbox_change
# Bind the callback to the variable
match_var.trace('w', make_callback(match_var, current_face_id, similar_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 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))
# Add to similar face variables list
similar_face_vars.append((similar_face_id, match_var))
# Right panel requirement: image immediately to the right of checkbox
# Create canvas for face image next to checkbox
style = ttk.Style()
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
match_canvas = tk.Canvas(match_frame, width=80, height=80, bg=canvas_bg_color, highlightthickness=0)
match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10))
# Create labels container to the right of image
info_container = ttk.Frame(match_frame)
info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E))
# Confidence badge
badge = self.gui_core.create_confidence_badge(info_container, confidence_pct)
badge.pack(anchor=tk.W)
filename_label = ttk.Label(info_container, text=f"📁 {filename}", font=("Arial", 8), foreground="gray")
filename_label.pack(anchor=tk.W, pady=(2, 0))
# Face image (reusing auto-match image display)
try:
# Get photo path from cache or database
photo_path = None
if identify_data_cache and 'photo_paths' in identify_data_cache:
# Find photo path by filename in cache
for photo_data in identify_data_cache['photo_paths'].values():
if photo_data['filename'] == filename:
photo_path = photo_data['path']
break
# Fallback to database if not in cache
if photo_path is None:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT path FROM photos WHERE filename = ?', (filename,))
result = cursor.fetchone()
photo_path = result[0] if result else None
# Extract face crop using existing method
face_crop_path = self.face_processor._extract_face_crop(photo_path, face_data['location'], similar_face_id)
if face_crop_path and os.path.exists(face_crop_path):
# Load and display image
pil_image = Image.open(face_crop_path)
pil_image.thumbnail((80, 80), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(pil_image)
match_canvas.create_image(40, 40, image=photo)
match_canvas.image = photo # Keep reference
# Add photo icon exactly at the image's top-right corner
self.gui_core.create_photo_icon(match_canvas, photo_path, icon_size=15,
face_x=0, face_y=0,
face_width=80, face_height=80,
canvas_width=80, canvas_height=80)
# Clean up temporary face crop
try:
os.remove(face_crop_path)
except:
pass
except Exception as e:
if self.verbose >= 1:
print(f" ⚠️ Error displaying similar face {i}: {e}")
continue
# Store similar face variables for Select All/Clear All functionality
self._similar_face_vars = similar_face_vars
def _get_confidence_description(self, confidence_pct):
"""Get confidence description based on percentage"""
if confidence_pct >= 80:
return "Very High"
elif confidence_pct >= 70:
return "High"
elif confidence_pct >= 60:
return "Medium"
elif confidence_pct >= 50:
return "Low"
else:
return "Very Low"
def _on_similar_face_select(self, similar_face_id, is_selected):
"""Handle similar face checkbox selection"""
# This would be used to track which similar faces are selected
# For now, just a placeholder
pass
def _get_person_name_for_face(self, face_id):
"""Get person name for a face that's already identified"""
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT p.first_name, p.last_name FROM people p
JOIN faces f ON p.id = f.person_id
WHERE f.id = ?
''', (face_id,))
result = cursor.fetchone()
if result:
first_name, last_name = result
if last_name and first_name:
return f"{last_name}, {first_name}"
elif last_name:
return last_name
elif first_name:
return first_name
else:
return "Unknown"
else:
return "Unknown"
def _update_face_image(self, gui_components, show_faces, face_crop_path, photo_path):
"""Update the face image display"""
try:
canvas = gui_components['canvas']
# Clear existing image
canvas.delete("all")
# Determine which image to display
if show_faces and face_crop_path and os.path.exists(face_crop_path):
# Display face crop
image_path = face_crop_path
image_type = "face crop"
elif photo_path and os.path.exists(photo_path):
# Display full photo
image_path = photo_path
image_type = "full photo"
else:
# No image available
canvas.create_text(200, 200, text="No image available",
font=("Arial", 12), fill="gray")
return
# Load and display image
try:
with Image.open(image_path) as img:
# Force canvas to update its dimensions
canvas.update_idletasks()
# Get canvas dimensions - use configured size if not yet rendered
canvas_width = canvas.winfo_width()
canvas_height = canvas.winfo_height()
# If canvas hasn't been rendered yet, use the configured size
if canvas_width <= 1 or canvas_height <= 1:
canvas_width = 400
canvas_height = 400
# Set a minimum size to ensure proper rendering
canvas.configure(width=canvas_width, height=canvas_height)
# Get image dimensions
img_width, img_height = img.size
# Calculate scale factor to fit image in canvas
scale_x = canvas_width / img_width
scale_y = canvas_height / img_height
scale = min(scale_x, scale_y, 1.0) # Don't scale up
# Calculate new dimensions
new_width = int(img_width * scale)
new_height = int(img_height * scale)
# Resize image
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Convert to PhotoImage
photo = ImageTk.PhotoImage(img_resized)
# Center image on canvas
x = (canvas_width - new_width) // 2
y = (canvas_height - new_height) // 2
# Create image on canvas
canvas.create_image(x, y, anchor='nw', image=photo)
# Keep reference to prevent garbage collection
canvas.image_ref = photo
# Store image data for redrawing on resize
canvas.current_image_data = {
'image_path': image_path,
'image_type': image_type,
'original_img': img,
'img_width': img_width,
'img_height': img_height
}
# Add photo icon using reusable function
self.gui_core.create_photo_icon(canvas, photo_path,
face_x=x, face_y=y,
face_width=new_width, face_height=new_height,
canvas_width=canvas_width, canvas_height=canvas_height)
# Update canvas scroll region
canvas.configure(scrollregion=canvas.bbox("all"))
if self.verbose >= 2:
print(f" 🖼️ Displayed {image_type}: {os.path.basename(image_path)} ({new_width}x{new_height})")
except Exception as e:
canvas.create_text(200, 200, text=f"Error loading image:\n{str(e)}",
font=("Arial", 10), fill="red")
if self.verbose >= 1:
print(f" ⚠️ Error loading image {image_path}: {e}")
except Exception as e:
print(f"❌ Error updating face image: {e}")
def _redraw_current_image(self, canvas):
"""Redraw the current image when canvas is resized"""
try:
if not hasattr(canvas, 'current_image_data') or not canvas.current_image_data:
return
# Clear existing image
canvas.delete("all")
# Get stored image data
data = canvas.current_image_data
img = data['original_img']
img_width = data['img_width']
img_height = data['img_height']
# Get current canvas dimensions
canvas_width = canvas.winfo_width()
canvas_height = canvas.winfo_height()
if canvas_width <= 1 or canvas_height <= 1:
return
# Calculate new scale
scale_x = canvas_width / img_width
scale_y = canvas_height / img_height
scale = min(scale_x, scale_y, 1.0)
# Calculate new dimensions
new_width = int(img_width * scale)
new_height = int(img_height * scale)
# Resize image
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Convert to PhotoImage
photo = ImageTk.PhotoImage(img_resized)
# Center image on canvas
x = (canvas_width - new_width) // 2
y = (canvas_height - new_height) // 2
# Create image on canvas
canvas.create_image(x, y, anchor='nw', image=photo)
# Keep reference to prevent garbage collection
canvas.image_ref = photo
# Add photo icon using reusable function
self.gui_core.create_photo_icon(canvas, data['image_path'],
face_x=x, face_y=y,
face_width=new_width, face_height=new_height,
canvas_width=canvas_width, canvas_height=canvas_height)
# Update canvas scroll region
canvas.configure(scrollregion=canvas.bbox("all"))
except Exception as e:
print(f"❌ Error redrawing image: {e}")
def _restore_person_name_input(self, gui_components, face_id, face_person_names, is_already_identified):
"""Restore person name input fields"""
try:
if is_already_identified:
# Get person data from database
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth
FROM people p
JOIN faces f ON p.id = f.person_id
WHERE f.id = ?
''', (face_id,))
result = cursor.fetchone()
if result:
first_name, last_name, middle_name, maiden_name, date_of_birth = result
gui_components['first_name_var'].set(first_name or "")
gui_components['last_name_var'].set(last_name or "")
gui_components['middle_name_var'].set(middle_name or "")
gui_components['maiden_name_var'].set(maiden_name or "")
gui_components['date_of_birth_var'].set(date_of_birth or "")
else:
# Clear all fields if no person found
self._clear_form(gui_components)
else:
# Restore from saved data if available
if face_id in face_person_names:
person_data = face_person_names[face_id]
if isinstance(person_data, dict):
gui_components['first_name_var'].set(person_data.get('first_name', ''))
gui_components['last_name_var'].set(person_data.get('last_name', ''))
gui_components['middle_name_var'].set(person_data.get('middle_name', ''))
gui_components['maiden_name_var'].set(person_data.get('maiden_name', ''))
gui_components['date_of_birth_var'].set(person_data.get('date_of_birth', ''))
else:
# Legacy string format
gui_components['first_name_var'].set(person_data or "")
gui_components['last_name_var'].set("")
gui_components['middle_name_var'].set("")
gui_components['maiden_name_var'].set("")
gui_components['date_of_birth_var'].set("")
else:
# Clear all fields for new face
self._clear_form(gui_components)
except Exception as e:
print(f"❌ Error restoring person name input: {e}")
# Clear form on error
self._clear_form(gui_components)
def _save_current_face_selection_states(self, gui_components, original_faces, i,
face_selection_states, face_person_names):
"""Save current checkbox states and person name for the current face"""
try:
if i >= len(original_faces):
return
current_face_id = original_faces[i][0]
# Save form data
form_data = self._get_form_data(gui_components)
face_person_names[current_face_id] = form_data
# Save checkbox states for similar faces
if current_face_id not in face_selection_states:
face_selection_states[current_face_id] = {}
# Note: Similar face checkbox states would be saved here
# This would require tracking the checkbox variables created in _update_similar_faces
except Exception as e:
print(f"❌ Error saving face selection states: {e}")
def _process_identification_command(self, command_or_data, face_id, is_already_identified,
face_status, gui_components, identify_data_cache):
"""Process an identification command"""
try:
# Handle form data (new GUI approach)
if isinstance(command_or_data, dict):
form_data = command_or_data
first_name = form_data.get('first_name', '').strip()
last_name = form_data.get('last_name', '').strip()
middle_name = form_data.get('middle_name', '').strip()
maiden_name = form_data.get('maiden_name', '').strip()
date_of_birth = form_data.get('date_of_birth', '').strip()
else:
# Handle legacy string command
command = command_or_data.strip()
if not command:
return 0
# Parse simple name format (legacy support)
parts = command.split()
if len(parts) >= 2:
first_name = parts[0]
last_name = ' '.join(parts[1:])
else:
first_name = command
last_name = ""
middle_name = ""
maiden_name = ""
date_of_birth = "" # Legacy commands don't include date of birth
# Add person if doesn't exist
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)',
(first_name, last_name, middle_name, maiden_name, date_of_birth))
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?',
(first_name, last_name, middle_name, maiden_name, date_of_birth))
result = cursor.fetchone()
person_id = result[0] if result else None
# Update people cache if new person was added
display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name)
if display_name not in identify_data_cache['people_names']:
identify_data_cache['people_names'].append(display_name)
identify_data_cache['people_names'].sort() # Keep sorted
# Keep last names cache updated in-session
if last_name:
if 'last_names' not in identify_data_cache:
identify_data_cache['last_names'] = []
if last_name not in identify_data_cache['last_names']:
identify_data_cache['last_names'].append(last_name)
identify_data_cache['last_names'].sort()
# Assign face to person
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE faces SET person_id = ? WHERE id = ?',
(person_id, face_id)
)
# Update person encodings
self.face_processor.update_person_encodings(person_id)
# Mark face as identified
face_status[face_id] = 'identified'
display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name)
print(f"✅ Identified as: {display_name}")
return 1
except Exception as e:
print(f"❌ Error processing identification: {e}")
return 0
def _show_people_list(self):
"""Show list of known people"""
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
people = cursor.fetchall()
if people:
formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last]
print("👥 Known people:", ", ".join(formatted_names))
else:
print("👥 No people identified yet")
def _open_date_picker(self, date_var):
"""Open date picker dialog and update the date variable"""
current_date = date_var.get()
selected_date = self.gui_core.create_calendar_dialog(None, "Select Date", current_date)
if selected_date is not None:
date_var.set(selected_date)
def _toggle_similar_faces_panel(self, components):
"""Update the similar faces panel content based on compare checkbox state"""
# Panel is always visible now, just update content
if not components['compare_var'].get():
# Clear the similar faces content when compare is disabled
scrollable_frame = components['similar_scrollable_frame']
for widget in scrollable_frame.winfo_children():
widget.destroy()
# Show a message that compare is disabled
no_compare_label = ttk.Label(scrollable_frame, text="Enable 'Compare similar faces' to see similar faces",
foreground="gray", font=("Arial", 10))
no_compare_label.pack(pady=20)
def _set_command(self, command):
"""Set the command variable to trigger the main loop"""
# This will be used by button callbacks to set the command
# The main loop will check this variable
if hasattr(self, '_current_command_var'):
self._current_command_var.set(command)
def _validate_navigation(self, gui_components):
"""Validate that navigation is safe (no unsaved changes)"""
# Check if there are any unsaved changes in the form
first_name = gui_components['first_name_var'].get().strip()
last_name = gui_components['last_name_var'].get().strip()
date_of_birth = gui_components['date_of_birth_var'].get().strip()
# If all three required fields are filled, ask for confirmation
if first_name and last_name and date_of_birth:
result = messagebox.askyesnocancel(
"Unsaved Changes",
"You have unsaved changes in the identification form.\n\n"
"Do you want to save them before continuing?\n\n"
"• Yes: Save current identification and continue\n"
"• No: Discard changes and continue\n"
"• Cancel: Stay on current face"
)
if result is True: # Yes - Save and continue
return 'save_and_continue'
elif result is False: # No - Discard and continue
return 'discard_and_continue'
else: # Cancel - Don't navigate
return 'cancel'
return 'continue' # No changes, safe to continue
def _clear_form(self, gui_components):
"""Clear all form fields"""
gui_components['first_name_var'].set("")
gui_components['last_name_var'].set("")
gui_components['middle_name_var'].set("")
gui_components['maiden_name_var'].set("")
gui_components['date_of_birth_var'].set("")
def _get_form_data(self, gui_components):
"""Get current form data as a dictionary"""
return {
'first_name': gui_components['first_name_var'].get().strip(),
'last_name': gui_components['last_name_var'].get().strip(),
'middle_name': gui_components['middle_name_var'].get().strip(),
'maiden_name': gui_components['maiden_name_var'].get().strip(),
'date_of_birth': gui_components['date_of_birth_var'].get().strip()
}
def _set_form_data(self, gui_components, form_data):
"""Set form data from a dictionary"""
gui_components['first_name_var'].set(form_data.get('first_name', ''))
gui_components['last_name_var'].set(form_data.get('last_name', ''))
gui_components['middle_name_var'].set(form_data.get('middle_name', ''))
gui_components['maiden_name_var'].set(form_data.get('maiden_name', ''))
gui_components['date_of_birth_var'].set(form_data.get('date_of_birth', ''))
def _validate_form_data(self, form_data):
"""Validate that form data is complete enough for identification"""
first_name = form_data.get('first_name', '').strip()
last_name = form_data.get('last_name', '').strip()
date_of_birth = form_data.get('date_of_birth', '').strip()
# Need at least first name or last name, and date of birth
if not (first_name or last_name):
return False, "Please enter at least a first name or last name"
if not date_of_birth:
return False, "Please enter a date of birth"
# Validate date format
try:
from datetime import datetime
datetime.strptime(date_of_birth, '%Y-%m-%d')
except ValueError:
return False, "Please enter date of birth in YYYY-MM-DD format"
return True, "Valid"
def _filter_unique_faces_from_list(self, faces_list: List[tuple]) -> List[tuple]:
"""Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches"""
if not faces_list:
return faces_list
# Extract face IDs from the list
face_ids = [face_tuple[0] for face_tuple in faces_list]
# Get face encodings from database for all faces
face_encodings = {}
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join('?' * len(face_ids))
cursor.execute(f'''
SELECT id, encoding
FROM faces
WHERE id IN ({placeholders}) AND encoding IS NOT NULL
''', face_ids)
for face_id, encoding_blob in cursor.fetchall():
try:
import numpy as np
# Load encoding as numpy array (not pickle)
encoding = np.frombuffer(encoding_blob, dtype=np.float64)
face_encodings[face_id] = encoding
except Exception:
continue
# If we don't have enough encodings, return original list
if len(face_encodings) < 2:
return faces_list
# Calculate distances between all faces using existing encodings
face_distances = {}
face_id_list = list(face_encodings.keys())
for i, face_id1 in enumerate(face_id_list):
for j, face_id2 in enumerate(face_id_list):
if i != j:
try:
import face_recognition
encoding1 = face_encodings[face_id1]
encoding2 = face_encodings[face_id2]
# Calculate distance
distance = face_recognition.face_distance([encoding1], encoding2)[0]
face_distances[(face_id1, face_id2)] = distance
except Exception:
# If calculation fails, assume no match
face_distances[(face_id1, face_id2)] = 1.0
# Apply unique faces filtering
unique_faces = []
seen_face_groups = set()
for face_tuple in faces_list:
face_id = face_tuple[0]
# Skip if we don't have encoding for this face
if face_id not in face_encodings:
unique_faces.append(face_tuple)
continue
# Find all faces that match this one with high/medium confidence
matching_face_ids = set([face_id]) # Include self
for other_face_id in face_encodings.keys():
if other_face_id != face_id:
distance = face_distances.get((face_id, other_face_id), 1.0)
confidence_pct = (1 - distance) * 100
# If this face matches with high/medium confidence
if confidence_pct >= 60:
matching_face_ids.add(other_face_id)
# Create a sorted tuple to represent this group of matching faces
face_group = tuple(sorted(matching_face_ids))
# Only show this face if we haven't seen this group before
if face_group not in seen_face_groups:
seen_face_groups.add(face_group)
unique_faces.append(face_tuple)
return unique_faces
def _on_unique_faces_change(self, gui_components, original_faces, i, face_status,
date_from, date_to, date_processed_from, date_processed_to):
"""Handle unique faces checkbox change"""
if gui_components['unique_var'].get():
# Show progress message
print("🔄 Applying unique faces filter...")
# Apply unique faces filtering to the main face list
try:
filtered_faces = self._filter_unique_faces_from_list(original_faces)
print(f"✅ Filter applied: {len(filtered_faces)} unique faces remaining")
# Update the original_faces list with filtered results
# We need to return the filtered list to update the caller
return filtered_faces
except Exception as e:
print(f"⚠️ Error applying filter: {e}")
# Revert checkbox state
gui_components['unique_var'].set(False)
return original_faces
else:
# Reload the original unfiltered face list
print("🔄 Reloading all faces...")
# Get fresh unfiltered faces from database
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
query = '''
SELECT f.id, f.photo_id, p.path, p.filename, f.location
FROM faces f
JOIN photos p ON f.photo_id = p.id
WHERE f.person_id IS NULL
'''
params = []
# Add date taken filtering if specified
if date_from:
query += ' AND p.date_taken >= ?'
params.append(date_from)
if date_to:
query += ' AND p.date_taken <= ?'
params.append(date_to)
# Add date processed filtering if specified
if date_processed_from:
query += ' AND DATE(p.date_added) >= ?'
params.append(date_processed_from)
if date_processed_to:
query += ' AND DATE(p.date_added) <= ?'
params.append(date_processed_to)
cursor.execute(query, params)
unfiltered_faces = cursor.fetchall()
print(f"✅ Reloaded: {len(unfiltered_faces)} total faces")
return unfiltered_faces
def _on_compare_change(self, gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i):
"""Handle compare checkbox change"""
# Toggle panel visibility
self._toggle_similar_faces_panel(gui_components)
# Update similar faces if compare is now enabled
if gui_components['compare_var'].get():
self._update_similar_faces(gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i)