This commit introduces the AutoMatchPanel class into the Dashboard GUI, providing a fully integrated interface for automatic face matching. The new panel allows users to start the auto-match process, configure tolerance settings, and visually confirm matches between identified and unidentified faces. It includes features for bulk selection of matches, smart navigation through matched individuals, and a search filter for large databases. The README has been updated to reflect the new functionality and improvements in the auto-match workflow, enhancing the overall user experience in managing photo identifications.
1448 lines
70 KiB
Python
1448 lines
70 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Integrated Identify Panel for PunimTag Dashboard
|
|
Embeds the full identify GUI functionality into the dashboard frame
|
|
"""
|
|
|
|
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 IdentifyPanel:
|
|
"""Integrated identify panel that embeds the full identify GUI functionality into the dashboard"""
|
|
|
|
def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager,
|
|
face_processor: FaceProcessor, gui_core: GUICore, verbose: int = 0):
|
|
"""Initialize the identify panel"""
|
|
self.parent_frame = parent_frame
|
|
self.db = db_manager
|
|
self.face_processor = face_processor
|
|
self.gui_core = gui_core
|
|
self.verbose = verbose
|
|
|
|
# Panel state
|
|
self.is_active = False
|
|
self.current_faces = []
|
|
self.current_face_index = 0
|
|
self.face_status = {}
|
|
self.face_person_names = {}
|
|
self.face_selection_states = {}
|
|
self.identify_data_cache = {}
|
|
self.current_face_crop_path = None
|
|
|
|
# GUI components
|
|
self.components = {}
|
|
self.main_frame = None
|
|
|
|
def create_panel(self) -> ttk.Frame:
|
|
"""Create the identify 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 for similar faces
|
|
self.main_frame.rowconfigure(0, weight=0) # Info label - fixed height
|
|
self.main_frame.rowconfigure(1, weight=0) # Filter row - fixed height
|
|
self.main_frame.rowconfigure(2, weight=0) # Checkboxes row - fixed height
|
|
self.main_frame.rowconfigure(3, weight=0) # Configuration row - fixed height
|
|
self.main_frame.rowconfigure(4, weight=1) # Main panels row - expandable
|
|
|
|
# Photo info with larger font for full screen
|
|
self.components['info_label'] = tk.Label(self.main_frame, text="", font=("Arial", 12, "bold"))
|
|
self.components['info_label'].grid(row=0, column=0, columnspan=2, pady=(0, 15), sticky=tk.W)
|
|
|
|
# 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 identify interface"""
|
|
# Create variables for form data
|
|
self.components['compare_var'] = tk.BooleanVar()
|
|
self.components['unique_var'] = tk.BooleanVar()
|
|
self.components['first_name_var'] = tk.StringVar()
|
|
self.components['last_name_var'] = tk.StringVar()
|
|
self.components['middle_name_var'] = tk.StringVar()
|
|
self.components['maiden_name_var'] = tk.StringVar()
|
|
self.components['date_of_birth_var'] = tk.StringVar()
|
|
|
|
# Date filter variables
|
|
self.components['date_from_var'] = tk.StringVar(value="")
|
|
self.components['date_to_var'] = tk.StringVar(value="")
|
|
self.components['date_processed_from_var'] = tk.StringVar(value="")
|
|
self.components['date_processed_to_var'] = tk.StringVar(value="")
|
|
|
|
# Date filter controls
|
|
date_filter_frame = ttk.LabelFrame(self.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))
|
|
self.components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_from_var'], width=10, state='readonly')
|
|
self.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(self.components['date_from_var'])
|
|
|
|
self.components['date_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_from)
|
|
self.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))
|
|
self.components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_to_var'], width=10, state='readonly')
|
|
self.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(self.components['date_to_var'])
|
|
|
|
self.components['date_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_to)
|
|
self.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"""
|
|
self._apply_date_filters()
|
|
|
|
self.components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter)
|
|
self.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))
|
|
self.components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_from_var'], width=10, state='readonly')
|
|
self.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(self.components['date_processed_from_var'])
|
|
|
|
self.components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_from)
|
|
self.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))
|
|
self.components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_to_var'], width=10, state='readonly')
|
|
self.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(self.components['date_processed_to_var'])
|
|
|
|
self.components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to)
|
|
self.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():
|
|
"""Handle unique faces checkbox change - filter main face list like old implementation"""
|
|
if self.components['unique_var'].get():
|
|
# Show progress message
|
|
print("🔄 Applying unique faces filter...")
|
|
self.main_frame.update() # Update UI to show the message
|
|
|
|
# Apply unique faces filtering to the main face list
|
|
try:
|
|
self.current_faces = self._filter_unique_faces_from_list(self.current_faces)
|
|
print(f"✅ Filter applied: {len(self.current_faces)} unique faces remaining")
|
|
except Exception as e:
|
|
print(f"⚠️ Error applying filter: {e}")
|
|
# Revert checkbox state
|
|
self.components['unique_var'].set(False)
|
|
return
|
|
else:
|
|
# Reload the original unfiltered face list
|
|
print("🔄 Reloading all faces...")
|
|
self.main_frame.update() # Update UI to show the message
|
|
|
|
# Get current date filters
|
|
date_from = self.components['date_from_var'].get().strip() or None
|
|
date_to = self.components['date_to_var'].get().strip() or None
|
|
date_processed_from = self.components['date_processed_from_var'].get().strip() or None
|
|
date_processed_to = self.components['date_processed_to_var'].get().strip() or None
|
|
|
|
# Get batch size
|
|
try:
|
|
batch_size = int(self.components['batch_var'].get().strip())
|
|
except Exception:
|
|
batch_size = DEFAULT_BATCH_SIZE
|
|
|
|
# Reload faces with current filters
|
|
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
|
|
date_processed_from, date_processed_to)
|
|
|
|
print(f"✅ Reloaded: {len(self.current_faces)} faces")
|
|
|
|
# Reset to first face and update display
|
|
self.current_face_index = 0
|
|
if self.current_faces:
|
|
self._update_current_face()
|
|
self._update_button_states()
|
|
|
|
# Update similar faces if compare is enabled
|
|
if self.components['compare_var'].get():
|
|
face_id, _, _, _, _ = self.current_faces[self.current_face_index]
|
|
self._update_similar_faces(face_id)
|
|
|
|
self.components['unique_check'] = ttk.Checkbutton(self.main_frame, text="Unique faces only",
|
|
variable=self.components['unique_var'],
|
|
command=on_unique_change)
|
|
self.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():
|
|
# Toggle the similar faces functionality
|
|
if self.components['compare_var'].get():
|
|
# Enable select all/clear all buttons
|
|
self.components['select_all_btn'].config(state='normal')
|
|
self.components['clear_all_btn'].config(state='normal')
|
|
# Update similar faces if we have a current face
|
|
if self.current_faces and self.current_face_index < len(self.current_faces):
|
|
face_id, _, _, _, _ = self.current_faces[self.current_face_index]
|
|
self._update_similar_faces(face_id)
|
|
else:
|
|
# Disable select all/clear all buttons
|
|
self.components['select_all_btn'].config(state='disabled')
|
|
self.components['clear_all_btn'].config(state='disabled')
|
|
# Clear similar faces content
|
|
scrollable_frame = self.components['similar_scrollable_frame']
|
|
for widget in scrollable_frame.winfo_children():
|
|
widget.destroy()
|
|
# Show 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)
|
|
|
|
self.components['compare_check'] = ttk.Checkbutton(self.main_frame, text="Compare similar faces",
|
|
variable=self.components['compare_var'],
|
|
command=on_compare_change)
|
|
self.components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(0, 5), pady=0)
|
|
|
|
# Command variable for button callbacks
|
|
self.components['command_var'] = tk.StringVar()
|
|
|
|
# Batch size configuration
|
|
batch_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="5")
|
|
batch_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.W)
|
|
|
|
ttk.Label(batch_frame, text="Batch size:").pack(side=tk.LEFT, padx=(0, 5))
|
|
self.components['batch_var'] = tk.StringVar(value=str(DEFAULT_BATCH_SIZE))
|
|
batch_entry = ttk.Entry(batch_frame, textvariable=self.components['batch_var'], width=8)
|
|
batch_entry.pack(side=tk.LEFT, padx=(0, 10))
|
|
|
|
# Start button
|
|
start_btn = ttk.Button(batch_frame, text="🚀 Start Identification", command=self._start_identification)
|
|
start_btn.pack(side=tk.LEFT, padx=(10, 0))
|
|
|
|
def _create_main_panels(self):
|
|
"""Create the main left and right panels"""
|
|
# Left panel for face display and identification
|
|
self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Face Identification", padding="10")
|
|
self.components['left_panel'].grid(row=4, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
|
|
|
|
# Right panel for similar faces (always visible)
|
|
self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Similar Faces", padding="10")
|
|
self.components['right_panel'].grid(row=4, 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()
|
|
|
|
def _create_left_panel_content(self):
|
|
"""Create the left panel content for face identification"""
|
|
left_panel = self.components['left_panel']
|
|
|
|
# Create a main content frame that can expand
|
|
main_content_frame = ttk.Frame(left_panel)
|
|
main_content_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
|
|
|
# Face image display - flexible height for better layout
|
|
self.components['face_canvas'] = tk.Canvas(main_content_frame, width=400, height=400, bg='white', relief='sunken', bd=2)
|
|
self.components['face_canvas'].pack(pady=(0, 15))
|
|
|
|
# Person name fields
|
|
name_frame = ttk.LabelFrame(main_content_frame, text="Person Information", padding="5")
|
|
name_frame.pack(fill=tk.X, pady=(0, 10))
|
|
|
|
# First name
|
|
ttk.Label(name_frame, text="First name *:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5), pady=2)
|
|
first_name_entry = ttk.Entry(name_frame, textvariable=self.components['first_name_var'], width=20)
|
|
first_name_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 10), pady=2)
|
|
|
|
# Last name
|
|
ttk.Label(name_frame, text="Last name *:").grid(row=0, column=2, sticky=tk.W, padx=(0, 5), pady=2)
|
|
last_name_entry = ttk.Entry(name_frame, textvariable=self.components['last_name_var'], width=20)
|
|
last_name_entry.grid(row=0, column=3, sticky=tk.W, pady=2)
|
|
|
|
# Middle name
|
|
ttk.Label(name_frame, text="Middle name:").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=2)
|
|
middle_name_entry = ttk.Entry(name_frame, textvariable=self.components['middle_name_var'], width=20)
|
|
middle_name_entry.grid(row=1, column=1, sticky=tk.W, padx=(0, 10), pady=2)
|
|
|
|
# Maiden name
|
|
ttk.Label(name_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(0, 5), pady=2)
|
|
maiden_name_entry = ttk.Entry(name_frame, textvariable=self.components['maiden_name_var'], width=20)
|
|
maiden_name_entry.grid(row=1, column=3, sticky=tk.W, pady=2)
|
|
|
|
# Date of birth
|
|
dob_frame = ttk.Frame(name_frame)
|
|
dob_frame.grid(row=2, column=0, columnspan=4, sticky=tk.W, pady=2)
|
|
|
|
ttk.Label(dob_frame, text="Date of birth *:").pack(side=tk.LEFT, padx=(0, 5))
|
|
dob_entry = ttk.Entry(dob_frame, textvariable=self.components['date_of_birth_var'], width=15, state='readonly')
|
|
dob_entry.pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
def open_dob_calendar():
|
|
self._open_date_picker(self.components['date_of_birth_var'])
|
|
|
|
dob_calendar_btn = ttk.Button(dob_frame, text="📅", width=3, command=open_dob_calendar)
|
|
dob_calendar_btn.pack(side=tk.LEFT)
|
|
|
|
# Add event handlers to update Identify button state
|
|
def update_identify_button_state(*args):
|
|
self._update_identify_button_state()
|
|
|
|
self.components['first_name_var'].trace('w', update_identify_button_state)
|
|
self.components['last_name_var'].trace('w', update_identify_button_state)
|
|
self.components['date_of_birth_var'].trace('w', update_identify_button_state)
|
|
|
|
# Required field asterisks are now included in the label text
|
|
|
|
# Add autocomplete for last name
|
|
self._setup_last_name_autocomplete(last_name_entry)
|
|
|
|
# Control buttons
|
|
button_frame = ttk.Frame(main_content_frame)
|
|
button_frame.pack(fill=tk.X, pady=(10, 0))
|
|
|
|
self.components['identify_btn'] = ttk.Button(button_frame, text="✅ Identify", command=self._identify_face, state='disabled')
|
|
self.components['identify_btn'].pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
self.components['back_btn'] = ttk.Button(button_frame, text="⬅️ Back", command=self._go_back)
|
|
self.components['back_btn'].pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
self.components['next_btn'] = ttk.Button(button_frame, text="➡️ Next", command=self._go_next)
|
|
self.components['next_btn'].pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
self.components['quit_btn'] = ttk.Button(button_frame, text="❌ Exit Identify Faces", command=self._quit_identification)
|
|
self.components['quit_btn'].pack(side=tk.RIGHT)
|
|
|
|
def _create_right_panel_content(self):
|
|
"""Create the right panel content for similar faces"""
|
|
right_panel = self.components['right_panel']
|
|
|
|
# Select All/Clear All buttons
|
|
select_frame = ttk.Frame(right_panel)
|
|
select_frame.pack(fill=tk.X, pady=(0, 10))
|
|
|
|
self.components['select_all_btn'] = ttk.Button(select_frame, text="Select All", command=self._select_all_similar)
|
|
self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
self.components['clear_all_btn'] = ttk.Button(select_frame, text="Clear All", command=self._clear_all_similar)
|
|
self.components['clear_all_btn'].pack(side=tk.LEFT)
|
|
|
|
# Initially disable these buttons
|
|
self.components['select_all_btn'].config(state='disabled')
|
|
self.components['clear_all_btn'].config(state='disabled')
|
|
|
|
# Create a frame to hold canvas and scrollbar
|
|
canvas_frame = ttk.Frame(right_panel)
|
|
canvas_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Create canvas for similar faces with scrollbar
|
|
similar_canvas = tk.Canvas(canvas_frame, bg='lightgray', relief='sunken', bd=2)
|
|
similar_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
|
|
# Similar faces scrollbars
|
|
similar_v_scrollbar = ttk.Scrollbar(canvas_frame, orient='vertical', command=similar_canvas.yview)
|
|
similar_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set)
|
|
|
|
# Create scrollable frame for similar faces
|
|
self.components['similar_scrollable_frame'] = ttk.Frame(similar_canvas)
|
|
similar_canvas.create_window((0, 0), window=self.components['similar_scrollable_frame'], anchor='nw')
|
|
|
|
# Configure scrollable frame to expand with canvas
|
|
def configure_scroll_region(event):
|
|
similar_canvas.configure(scrollregion=similar_canvas.bbox("all"))
|
|
|
|
self.components['similar_scrollable_frame'].bind('<Configure>', configure_scroll_region)
|
|
|
|
# Store canvas reference for scrolling
|
|
self.components['similar_canvas'] = similar_canvas
|
|
|
|
# Add initial message when compare is disabled
|
|
no_compare_label = ttk.Label(self.components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces",
|
|
foreground="gray", font=("Arial", 10))
|
|
no_compare_label.pack(pady=20)
|
|
|
|
def _start_identification(self):
|
|
"""Start the identification process"""
|
|
try:
|
|
batch_size = int(self.components['batch_var'].get().strip())
|
|
if batch_size <= 0:
|
|
raise ValueError
|
|
except Exception:
|
|
messagebox.showerror("Error", "Please enter a valid positive integer for batch size.")
|
|
return
|
|
|
|
# Get date filters
|
|
date_from = self.components['date_from_var'].get().strip() or None
|
|
date_to = self.components['date_to_var'].get().strip() or None
|
|
date_processed_from = self.components['date_processed_from_var'].get().strip() or None
|
|
date_processed_to = self.components['date_processed_to_var'].get().strip() or None
|
|
|
|
# Get unidentified faces
|
|
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
|
|
date_processed_from, date_processed_to)
|
|
|
|
if not self.current_faces:
|
|
messagebox.showinfo("No Faces", "🎉 All faces have been identified!")
|
|
return
|
|
|
|
# Pre-fetch data for optimal performance
|
|
self.identify_data_cache = self._prefetch_identify_data(self.current_faces)
|
|
|
|
# Reset state
|
|
self.current_face_index = 0
|
|
self.face_status = {}
|
|
self.face_person_names = {}
|
|
self.face_selection_states = {}
|
|
|
|
# Show the first face
|
|
self._update_current_face()
|
|
|
|
# Enable/disable buttons
|
|
self._update_button_states()
|
|
|
|
self.is_active = True
|
|
|
|
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) -> List[Tuple]:
|
|
"""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, faces: List[Tuple]) -> Dict:
|
|
"""Pre-fetch all needed data to avoid repeated database queries"""
|
|
cache = {
|
|
'photo_paths': {},
|
|
'people_names': [],
|
|
'last_names': [],
|
|
'face_encodings': {}
|
|
}
|
|
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Get photo paths
|
|
photo_ids = [face[1] for face in faces]
|
|
if photo_ids:
|
|
placeholders = ','.join(['?' for _ in photo_ids])
|
|
cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids)
|
|
cache['photo_paths'] = dict(cursor.fetchall())
|
|
|
|
# Get people names
|
|
cursor.execute('SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people ORDER BY last_name, first_name')
|
|
cache['people_names'] = cursor.fetchall()
|
|
|
|
# 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()
|
|
cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()})
|
|
|
|
# Get face encodings for similar face matching
|
|
face_ids = [face[0] for face in faces]
|
|
if face_ids:
|
|
placeholders = ','.join(['?' for _ in face_ids])
|
|
cursor.execute(f'SELECT id, encoding FROM faces WHERE id IN ({placeholders})', face_ids)
|
|
cache['face_encodings'] = dict(cursor.fetchall())
|
|
|
|
return cache
|
|
|
|
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 _update_current_face(self):
|
|
"""Update the display for the current face"""
|
|
if not self.current_faces or self.current_face_index >= len(self.current_faces):
|
|
return
|
|
|
|
face_id, photo_id, photo_path, filename, location = self.current_faces[self.current_face_index]
|
|
|
|
# Update info label
|
|
self.components['info_label'].config(text=f"Face {self.current_face_index + 1} of {len(self.current_faces)} - {filename}")
|
|
|
|
# Extract and display face crop (show_faces is always True)
|
|
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id)
|
|
self.current_face_crop_path = face_crop_path
|
|
|
|
# Update face image
|
|
self._update_face_image(face_crop_path, photo_path)
|
|
|
|
# Check if face is already identified
|
|
is_identified = face_id in self.face_status and self.face_status[face_id] == 'identified'
|
|
|
|
# Restore person name input - restore saved name or use database/empty value
|
|
self._restore_person_name_input(face_id, is_identified)
|
|
|
|
# Update similar faces if compare is enabled
|
|
if self.components['compare_var'].get():
|
|
self._update_similar_faces(face_id)
|
|
|
|
def _update_face_image(self, face_crop_path: str, photo_path: str):
|
|
"""Update the face image display"""
|
|
try:
|
|
if face_crop_path and os.path.exists(face_crop_path):
|
|
# Load and display face crop
|
|
image = Image.open(face_crop_path)
|
|
# Resize to exactly fill the 400x400 frame
|
|
image = image.resize((400, 400), Image.Resampling.LANCZOS)
|
|
photo = ImageTk.PhotoImage(image)
|
|
|
|
# Clear canvas and display image
|
|
canvas = self.components['face_canvas']
|
|
canvas.delete("all")
|
|
# Position image at top-left corner like the original
|
|
canvas.create_image(0, 0, image=photo, anchor=tk.NW)
|
|
canvas.image = photo # Keep a reference
|
|
|
|
# Add photo icon exactly at the image's top-right corner
|
|
# Image starts at (0, 0) and is 400x400, so top-right corner is at (400, 0)
|
|
self.gui_core.create_photo_icon(canvas, photo_path, icon_size=25,
|
|
face_x=0, face_y=0,
|
|
face_width=400, face_height=400,
|
|
canvas_width=400, canvas_height=400)
|
|
else:
|
|
# Clear canvas if no image
|
|
canvas = self.components['face_canvas']
|
|
canvas.delete("all")
|
|
canvas.create_text(200, 200, text="No face image", fill="gray")
|
|
except Exception as e:
|
|
print(f"Error updating face image: {e}")
|
|
|
|
|
|
def _restore_person_name_input(self, face_id: int, is_identified: bool):
|
|
"""Restore person name input fields"""
|
|
try:
|
|
if is_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
|
|
self.components['first_name_var'].set(first_name or "")
|
|
self.components['last_name_var'].set(last_name or "")
|
|
self.components['middle_name_var'].set(middle_name or "")
|
|
self.components['maiden_name_var'].set(maiden_name or "")
|
|
self.components['date_of_birth_var'].set(date_of_birth or "")
|
|
else:
|
|
# Clear all fields if no person found
|
|
self._clear_form()
|
|
else:
|
|
# Restore from saved data if available
|
|
if face_id in self.face_person_names:
|
|
person_data = self.face_person_names[face_id]
|
|
if isinstance(person_data, dict):
|
|
self.components['first_name_var'].set(person_data.get('first_name', ''))
|
|
self.components['last_name_var'].set(person_data.get('last_name', ''))
|
|
self.components['middle_name_var'].set(person_data.get('middle_name', ''))
|
|
self.components['maiden_name_var'].set(person_data.get('maiden_name', ''))
|
|
self.components['date_of_birth_var'].set(person_data.get('date_of_birth', ''))
|
|
else:
|
|
# Legacy string format
|
|
self.components['first_name_var'].set(person_data or "")
|
|
self.components['last_name_var'].set("")
|
|
self.components['middle_name_var'].set("")
|
|
self.components['maiden_name_var'].set("")
|
|
self.components['date_of_birth_var'].set("")
|
|
else:
|
|
# Clear all fields for new face
|
|
self._clear_form()
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error restoring person name input: {e}")
|
|
# Clear form on error
|
|
self._clear_form()
|
|
|
|
def _update_identify_button_state(self):
|
|
"""Update the identify button state based on form completion"""
|
|
first_name = self.components['first_name_var'].get().strip()
|
|
last_name = self.components['last_name_var'].get().strip()
|
|
date_of_birth = self.components['date_of_birth_var'].get().strip()
|
|
|
|
# Enable button only if all required fields are filled
|
|
if first_name and last_name and date_of_birth:
|
|
self.components['identify_btn'].config(state='normal')
|
|
else:
|
|
self.components['identify_btn'].config(state='disabled')
|
|
|
|
|
|
def _setup_last_name_autocomplete(self, last_name_entry):
|
|
"""Setup autocomplete functionality for last name field - exactly like old implementation"""
|
|
# Create listbox for suggestions (as overlay attached to main frame, not clipped by frames)
|
|
last_name_listbox = tk.Listbox(self.main_frame, height=8)
|
|
last_name_listbox.place_forget() # Hide initially
|
|
|
|
def _show_suggestions():
|
|
"""Show filtered suggestions in listbox"""
|
|
all_last_names = self.identify_data_cache.get('last_names', [])
|
|
typed = self.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
|
|
self.main_frame.update_idletasks()
|
|
# Absolute coordinates of entry relative to screen
|
|
entry_root_x = last_name_entry.winfo_rootx()
|
|
entry_root_y = last_name_entry.winfo_rooty()
|
|
entry_height = last_name_entry.winfo_height()
|
|
# Convert to coordinates relative to main frame
|
|
main_frame_origin_x = self.main_frame.winfo_rootx()
|
|
main_frame_origin_y = self.main_frame.winfo_rooty()
|
|
place_x = entry_root_x - main_frame_origin_x
|
|
place_y = entry_root_y - main_frame_origin_y + entry_height
|
|
place_width = last_name_entry.winfo_width()
|
|
# Calculate how many rows fit to bottom of window
|
|
available_px = max(60, self.main_frame.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])
|
|
self.components['last_name_var'].set(selected_name)
|
|
_hide_suggestions()
|
|
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)
|
|
self.components['last_name_var'].set(selected_name)
|
|
except:
|
|
pass
|
|
_hide_suggestions()
|
|
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()
|
|
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()
|
|
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
|
|
last_name_entry.bind('<KeyRelease>', lambda e: _safe_show_suggestions())
|
|
last_name_entry.bind('<KeyPress>', _on_key_press)
|
|
last_name_entry.bind('<FocusOut>', lambda e: self.main_frame.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 _clear_form(self):
|
|
"""Clear the identification form"""
|
|
self.components['first_name_var'].set('')
|
|
self.components['last_name_var'].set('')
|
|
self.components['middle_name_var'].set('')
|
|
self.components['maiden_name_var'].set('')
|
|
self.components['date_of_birth_var'].set('')
|
|
|
|
def _update_similar_faces(self, face_id: int):
|
|
"""Update the similar faces panel"""
|
|
# Enable select all/clear all buttons
|
|
self.components['select_all_btn'].config(state='normal')
|
|
self.components['clear_all_btn'].config(state='normal')
|
|
|
|
# Find similar faces using the filtered method like the original
|
|
similar_faces = self.face_processor._get_filtered_similar_faces(face_id, DEFAULT_FACE_TOLERANCE, include_same_photo=False, face_status=self.face_status)
|
|
|
|
# Clear existing similar faces
|
|
scrollable_frame = self.components['similar_scrollable_frame']
|
|
for widget in scrollable_frame.winfo_children():
|
|
widget.destroy()
|
|
|
|
# Display similar faces
|
|
if similar_faces:
|
|
|
|
# Sort by confidence (distance) - highest confidence first (lowest distance)
|
|
similar_faces.sort(key=lambda x: x['distance'])
|
|
|
|
# Ensure photo paths are available for similar faces
|
|
self._ensure_photo_paths_for_similar_faces(similar_faces)
|
|
|
|
# Display similar faces using the original approach
|
|
self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id)
|
|
|
|
# Update canvas scroll region
|
|
canvas = self.components['similar_canvas']
|
|
canvas.update_idletasks()
|
|
canvas.configure(scrollregion=canvas.bbox("all"))
|
|
else:
|
|
no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found",
|
|
foreground="gray", font=("Arial", 10))
|
|
no_faces_label.pack(pady=20)
|
|
|
|
def _ensure_photo_paths_for_similar_faces(self, similar_faces):
|
|
"""Ensure photo paths are available in cache for similar faces"""
|
|
# Get photo IDs from similar faces that are not in cache
|
|
missing_photo_ids = []
|
|
for face_data in similar_faces:
|
|
photo_id = face_data['photo_id']
|
|
if photo_id not in self.identify_data_cache['photo_paths']:
|
|
missing_photo_ids.append(photo_id)
|
|
|
|
# Fetch missing photo paths from database
|
|
if missing_photo_ids:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join(['?' for _ in missing_photo_ids])
|
|
cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', missing_photo_ids)
|
|
missing_photo_paths = dict(cursor.fetchall())
|
|
|
|
# Add to cache
|
|
self.identify_data_cache['photo_paths'].update(missing_photo_paths)
|
|
|
|
def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id):
|
|
"""Display similar faces in a panel - based on original implementation"""
|
|
# 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()
|
|
similar_face_vars.append((similar_face_id, match_var))
|
|
|
|
# Store the variable for later use
|
|
if similar_face_id not in self.face_selection_states:
|
|
self.face_selection_states[similar_face_id] = {}
|
|
self.face_selection_states[similar_face_id]['var'] = match_var
|
|
|
|
# Restore previous checkbox state if available (auto-match style)
|
|
if similar_face_id in self.face_selection_states and 'var' in self.face_selection_states[similar_face_id]:
|
|
prev_var = self.face_selection_states[similar_face_id]['var']
|
|
if hasattr(prev_var, 'get'):
|
|
match_var.set(prev_var.get())
|
|
|
|
# Checkbox
|
|
checkbox = ttk.Checkbutton(match_frame, variable=match_var)
|
|
checkbox.pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
# Face image (moved to be right after checkbox)
|
|
try:
|
|
photo_id = face_data['photo_id']
|
|
location = face_data['location']
|
|
photo_path = self.identify_data_cache['photo_paths'].get(photo_id, '')
|
|
|
|
if photo_path:
|
|
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, similar_face_id)
|
|
if face_crop_path and os.path.exists(face_crop_path):
|
|
image = Image.open(face_crop_path)
|
|
image.thumbnail((50, 50), Image.Resampling.LANCZOS)
|
|
photo = ImageTk.PhotoImage(image)
|
|
|
|
# Create a canvas for the face image to allow photo icon drawing
|
|
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0)
|
|
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
|
|
face_canvas.create_image(25, 25, image=photo, anchor=tk.CENTER)
|
|
face_canvas.image = photo # Keep reference
|
|
|
|
# Add photo icon exactly at the image's top-right corner
|
|
self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15,
|
|
face_x=0, face_y=0,
|
|
face_width=50, face_height=50,
|
|
canvas_width=50, canvas_height=50)
|
|
else:
|
|
# Face crop extraction failed or file doesn't exist
|
|
print(f"Face crop not available for face {similar_face_id}: {face_crop_path}")
|
|
# Create placeholder canvas
|
|
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray')
|
|
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
|
|
face_canvas.create_text(25, 25, text="No\nImage", fill="gray", font=("Arial", 8))
|
|
else:
|
|
# Photo path not found in cache
|
|
print(f"Photo path not found for photo_id {photo_id} in cache")
|
|
# Create placeholder canvas
|
|
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray')
|
|
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
|
|
face_canvas.create_text(25, 25, text="No\nPath", fill="gray", font=("Arial", 8))
|
|
except Exception as e:
|
|
print(f"Error creating similar face widget for face {similar_face_id}: {e}")
|
|
# Create placeholder canvas on error
|
|
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray')
|
|
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
|
|
face_canvas.create_text(25, 25, text="Error", fill="red", font=("Arial", 8))
|
|
|
|
# Confidence label with color coding and description
|
|
confidence_text = f"{confidence_pct:.0f}% {confidence_desc}"
|
|
if confidence_pct >= 80:
|
|
color = "green"
|
|
elif confidence_pct >= 70:
|
|
color = "orange"
|
|
elif confidence_pct >= 60:
|
|
color = "red"
|
|
else:
|
|
color = "gray"
|
|
|
|
confidence_label = ttk.Label(match_frame, text=confidence_text, foreground=color, font=("Arial", 8, "bold"))
|
|
confidence_label.pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
# Filename
|
|
filename_label = ttk.Label(match_frame, text=filename, font=("Arial", 8))
|
|
filename_label.pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
# Store the similar face variables for Select All/Clear All functionality
|
|
self.similar_face_vars = similar_face_vars
|
|
|
|
def _get_confidence_description(self, confidence_pct: float) -> str:
|
|
"""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 _identify_face(self):
|
|
"""Identify the current face"""
|
|
first_name = self.components['first_name_var'].get().strip()
|
|
last_name = self.components['last_name_var'].get().strip()
|
|
date_of_birth = self.components['date_of_birth_var'].get().strip()
|
|
|
|
if not first_name or not last_name or not date_of_birth:
|
|
messagebox.showwarning("Missing Information", "Please fill in first name, last name, and date of birth.")
|
|
return
|
|
|
|
if not self.current_faces or self.current_face_index >= len(self.current_faces):
|
|
return
|
|
|
|
face_id, photo_id, photo_path, filename, location = self.current_faces[self.current_face_index]
|
|
|
|
# Get person data
|
|
person_data = {
|
|
'first_name': first_name,
|
|
'last_name': last_name,
|
|
'middle_name': self.components['middle_name_var'].get().strip(),
|
|
'maiden_name': self.components['maiden_name_var'].get().strip(),
|
|
'date_of_birth': date_of_birth
|
|
}
|
|
|
|
# Store the identification
|
|
self.face_person_names[face_id] = person_data
|
|
self.face_status[face_id] = 'identified'
|
|
|
|
# Save to database
|
|
self._save_identification(face_id, person_data)
|
|
|
|
# If compare mode is active, identify selected similar faces
|
|
if self.components['compare_var'].get():
|
|
self._identify_selected_similar_faces(person_data)
|
|
|
|
# Move to next face
|
|
self._go_next()
|
|
|
|
def _save_identification(self, face_id: int, person_data: Dict):
|
|
"""Save face identification to database"""
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Normalize names to title case for case-insensitive matching
|
|
normalized_data = {
|
|
'first_name': person_data['first_name'].strip().title(),
|
|
'last_name': person_data['last_name'].strip().title(),
|
|
'middle_name': person_data['middle_name'].strip().title() if person_data['middle_name'] else '',
|
|
'maiden_name': person_data['maiden_name'].strip().title() if person_data['maiden_name'] else '',
|
|
'date_of_birth': person_data['date_of_birth'].strip()
|
|
}
|
|
|
|
# Check if person already exists (case-insensitive)
|
|
cursor.execute('''
|
|
SELECT id FROM people
|
|
WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?)
|
|
AND LOWER(COALESCE(middle_name, '')) = LOWER(?) AND LOWER(COALESCE(maiden_name, '')) = LOWER(?)
|
|
AND date_of_birth = ?
|
|
''', (normalized_data['first_name'], normalized_data['last_name'],
|
|
normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth']))
|
|
|
|
person_row = cursor.fetchone()
|
|
if person_row:
|
|
person_id = person_row[0]
|
|
else:
|
|
# Create new person
|
|
cursor.execute('''
|
|
INSERT INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
''', (normalized_data['first_name'], normalized_data['last_name'],
|
|
normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth']))
|
|
person_id = cursor.lastrowid
|
|
|
|
# Update face with person_id
|
|
cursor.execute('UPDATE faces SET person_id = ? WHERE id = ?', (person_id, face_id))
|
|
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
print(f"Error saving identification: {e}")
|
|
messagebox.showerror("Error", f"Failed to save identification: {e}")
|
|
|
|
def _identify_selected_similar_faces(self, person_data: Dict):
|
|
"""Identify selected similar faces with the same person"""
|
|
if hasattr(self, 'similar_face_vars'):
|
|
for face_id, var in self.similar_face_vars:
|
|
if var.get():
|
|
# This face is selected, identify it
|
|
self.face_person_names[face_id] = person_data
|
|
self.face_status[face_id] = 'identified'
|
|
self._save_identification(face_id, person_data)
|
|
|
|
def _go_back(self):
|
|
"""Go back to the previous face"""
|
|
if self.current_face_index > 0:
|
|
# Validate navigation (check for unsaved changes)
|
|
validation_result = self._validate_navigation()
|
|
if validation_result == 'cancel':
|
|
return # Cancel navigation
|
|
elif validation_result == 'save_and_continue':
|
|
# Save the current identification before proceeding
|
|
if self.current_faces and self.current_face_index < len(self.current_faces):
|
|
face_id, _, _, _, _ = self.current_faces[self.current_face_index]
|
|
first_name = self.components['first_name_var'].get().strip()
|
|
last_name = self.components['last_name_var'].get().strip()
|
|
date_of_birth = self.components['date_of_birth_var'].get().strip()
|
|
if first_name and last_name and date_of_birth:
|
|
person_data = {
|
|
'first_name': first_name,
|
|
'last_name': last_name,
|
|
'middle_name': self.components['middle_name_var'].get().strip(),
|
|
'maiden_name': self.components['maiden_name_var'].get().strip(),
|
|
'date_of_birth': date_of_birth
|
|
}
|
|
self.face_person_names[face_id] = person_data
|
|
self.face_status[face_id] = 'identified'
|
|
elif validation_result == 'discard_and_continue':
|
|
# Clear the form but don't save
|
|
self._clear_form()
|
|
|
|
self.current_face_index -= 1
|
|
self._update_current_face()
|
|
self._update_button_states()
|
|
|
|
def _go_next(self):
|
|
"""Go to the next face"""
|
|
if self.current_face_index < len(self.current_faces) - 1:
|
|
# Validate navigation (check for unsaved changes)
|
|
validation_result = self._validate_navigation()
|
|
if validation_result == 'cancel':
|
|
return # Cancel navigation
|
|
elif validation_result == 'save_and_continue':
|
|
# Save the current identification before proceeding
|
|
if self.current_faces and self.current_face_index < len(self.current_faces):
|
|
face_id, _, _, _, _ = self.current_faces[self.current_face_index]
|
|
first_name = self.components['first_name_var'].get().strip()
|
|
last_name = self.components['last_name_var'].get().strip()
|
|
date_of_birth = self.components['date_of_birth_var'].get().strip()
|
|
if first_name and last_name and date_of_birth:
|
|
person_data = {
|
|
'first_name': first_name,
|
|
'last_name': last_name,
|
|
'middle_name': self.components['middle_name_var'].get().strip(),
|
|
'maiden_name': self.components['maiden_name_var'].get().strip(),
|
|
'date_of_birth': date_of_birth
|
|
}
|
|
self.face_person_names[face_id] = person_data
|
|
self.face_status[face_id] = 'identified'
|
|
elif validation_result == 'discard_and_continue':
|
|
# Clear the form but don't save
|
|
self._clear_form()
|
|
|
|
self.current_face_index += 1
|
|
self._update_current_face()
|
|
self._update_button_states()
|
|
else:
|
|
# Check if there are more faces to load
|
|
self._load_more_faces()
|
|
|
|
def _load_more_faces(self):
|
|
"""Load more faces if available"""
|
|
# Get current date filters
|
|
date_from = self.components['date_from_var'].get().strip() or None
|
|
date_to = self.components['date_to_var'].get().strip() or None
|
|
date_processed_from = self.components['date_processed_from_var'].get().strip() or None
|
|
date_processed_to = self.components['date_processed_to_var'].get().strip() or None
|
|
|
|
# Get more faces
|
|
more_faces = self._get_unidentified_faces(DEFAULT_BATCH_SIZE, date_from, date_to,
|
|
date_processed_from, date_processed_to)
|
|
|
|
if more_faces:
|
|
# Add to current faces
|
|
self.current_faces.extend(more_faces)
|
|
self.current_face_index += 1
|
|
self._update_current_face()
|
|
self._update_button_states()
|
|
else:
|
|
# No more faces
|
|
messagebox.showinfo("Complete", "🎉 All faces have been identified!")
|
|
self._quit_identification()
|
|
|
|
def _update_button_states(self):
|
|
"""Update button states based on current position"""
|
|
self.components['back_btn'].config(state='normal' if self.current_face_index > 0 else 'disabled')
|
|
self.components['next_btn'].config(state='normal' if self.current_face_index < len(self.current_faces) - 1 else 'disabled')
|
|
|
|
def _select_all_similar(self):
|
|
"""Select all similar faces"""
|
|
if hasattr(self, 'similar_face_vars'):
|
|
for face_id, var in self.similar_face_vars:
|
|
var.set(True)
|
|
|
|
def _clear_all_similar(self):
|
|
"""Clear all similar face selections"""
|
|
if hasattr(self, 'similar_face_vars'):
|
|
for face_id, var in self.similar_face_vars:
|
|
var.set(False)
|
|
|
|
def _quit_identification(self):
|
|
"""Quit the identification process"""
|
|
# First check for unsaved changes in the current form
|
|
validation_result = self._validate_navigation()
|
|
if validation_result == 'cancel':
|
|
return # Cancel quit
|
|
elif validation_result == 'save_and_continue':
|
|
# Save the current identification before proceeding
|
|
if self.current_faces and self.current_face_index < len(self.current_faces):
|
|
face_id, _, _, _, _ = self.current_faces[self.current_face_index]
|
|
first_name = self.components['first_name_var'].get().strip()
|
|
last_name = self.components['last_name_var'].get().strip()
|
|
date_of_birth = self.components['date_of_birth_var'].get().strip()
|
|
if first_name and last_name and date_of_birth:
|
|
person_data = {
|
|
'first_name': first_name,
|
|
'last_name': last_name,
|
|
'middle_name': self.components['middle_name_var'].get().strip(),
|
|
'maiden_name': self.components['maiden_name_var'].get().strip(),
|
|
'date_of_birth': date_of_birth
|
|
}
|
|
self.face_person_names[face_id] = person_data
|
|
self.face_status[face_id] = 'identified'
|
|
elif validation_result == 'discard_and_continue':
|
|
# Clear the form but don't save
|
|
self._clear_form()
|
|
|
|
# Check for pending identifications
|
|
pending_identifications = self._get_pending_identifications()
|
|
|
|
if 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"
|
|
)
|
|
|
|
if result is True: # Yes - Save and quit
|
|
self._save_all_pending_identifications()
|
|
elif result is False: # No - Quit without saving
|
|
pass
|
|
else: # Cancel - Don't quit
|
|
return
|
|
|
|
# Clean up
|
|
self._cleanup()
|
|
self.is_active = False
|
|
|
|
def _validate_navigation(self):
|
|
"""Validate that navigation is safe (no unsaved changes)"""
|
|
# Check if there are any unsaved changes in the form
|
|
first_name = self.components['first_name_var'].get().strip()
|
|
last_name = self.components['last_name_var'].get().strip()
|
|
date_of_birth = self.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 _get_pending_identifications(self) -> List[int]:
|
|
"""Get list of face IDs with pending identifications"""
|
|
pending = []
|
|
for face_id, person_data in self.face_person_names.items():
|
|
if face_id not in self.face_status or self.face_status[face_id] != 'identified':
|
|
# Check if form has complete data
|
|
if (person_data.get('first_name') and
|
|
person_data.get('last_name') and
|
|
person_data.get('date_of_birth')):
|
|
pending.append(face_id)
|
|
return pending
|
|
|
|
def _save_all_pending_identifications(self):
|
|
"""Save all pending identifications"""
|
|
for face_id in self._get_pending_identifications():
|
|
person_data = self.face_person_names[face_id]
|
|
self._save_identification(face_id, person_data)
|
|
self.face_status[face_id] = 'identified'
|
|
|
|
def _cleanup(self):
|
|
"""Clean up resources"""
|
|
if self.current_face_crop_path:
|
|
self.face_processor.cleanup_face_crops(self.current_face_crop_path)
|
|
|
|
# Clear state
|
|
self.current_faces = []
|
|
self.current_face_index = 0
|
|
self.face_status = {}
|
|
self.face_person_names = {}
|
|
self.face_selection_states = {}
|
|
self.identify_data_cache = {}
|
|
if hasattr(self, 'similar_face_vars'):
|
|
self.similar_face_vars = []
|
|
|
|
# Clear right panel content
|
|
scrollable_frame = self.components['similar_scrollable_frame']
|
|
for widget in scrollable_frame.winfo_children():
|
|
widget.destroy()
|
|
# Show 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)
|
|
|
|
# Clear form
|
|
self._clear_form()
|
|
|
|
# Clear info label
|
|
self.components['info_label'].config(text="")
|
|
|
|
# Clear face canvas
|
|
self.components['face_canvas'].delete("all")
|
|
|
|
def _apply_date_filters(self):
|
|
"""Apply date filters and reload faces"""
|
|
# Get current filter values
|
|
date_from = self.components['date_from_var'].get().strip() or None
|
|
date_to = self.components['date_to_var'].get().strip() or None
|
|
date_processed_from = self.components['date_processed_from_var'].get().strip() or None
|
|
date_processed_to = self.components['date_processed_to_var'].get().strip() or None
|
|
|
|
# Get batch size
|
|
try:
|
|
batch_size = int(self.components['batch_var'].get().strip())
|
|
except Exception:
|
|
batch_size = DEFAULT_BATCH_SIZE
|
|
|
|
# Reload faces with new filters
|
|
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
|
|
date_processed_from, date_processed_to)
|
|
|
|
if not self.current_faces:
|
|
messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.")
|
|
return
|
|
|
|
# Reset state
|
|
self.current_face_index = 0
|
|
self.face_status = {}
|
|
self.face_person_names = {}
|
|
self.face_selection_states = {}
|
|
|
|
# Pre-fetch data
|
|
self.identify_data_cache = self._prefetch_identify_data(self.current_faces)
|
|
|
|
# Show the first face
|
|
self._update_current_face()
|
|
self._update_button_states()
|
|
|
|
self.is_active = True
|
|
|
|
def _open_date_picker(self, date_var: tk.StringVar):
|
|
"""Open date picker dialog"""
|
|
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 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
|
|
|
|
def update_layout(self):
|
|
"""Update panel layout for responsiveness"""
|
|
if hasattr(self, 'components') and 'similar_canvas' in self.components:
|
|
# Update similar faces canvas scroll region
|
|
canvas = self.components['similar_canvas']
|
|
canvas.update_idletasks()
|
|
canvas.configure(scrollregion=canvas.bbox("all"))
|