This commit introduces the IdentifyPanel class into the Dashboard GUI, allowing for a fully integrated face identification interface. The Dashboard now requires a database manager and face processor to create the Identify panel, which includes features for face browsing, identification, and management. Additionally, the DatabaseManager has been updated to support case-insensitive person additions, improving data consistency. The PhotoTagger class has also been modified to accommodate these changes, ensuring seamless interaction between components.
1427 lines
68 KiB
Python
1427 lines
68 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
|
|
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(3, weight=0) # Configuration row - no expansion
|
|
self.main_frame.rowconfigure(4, weight=1) # Main panels row - expandable
|
|
|
|
# Photo info
|
|
self.components['info_label'] = ttk.Label(self.main_frame, text="", font=("Arial", 10, "bold"))
|
|
self.components['info_label'].grid(row=0, column=0, columnspan=2, pady=(0, 10), 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']
|
|
|
|
# Face image display
|
|
self.components['face_canvas'] = tk.Canvas(left_panel, width=300, height=300, bg='white', relief='sunken', bd=2)
|
|
self.components['face_canvas'].pack(pady=(0, 10))
|
|
|
|
# Person name fields
|
|
name_frame = ttk.LabelFrame(left_panel, 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(left_panel)
|
|
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="❌ Quit", 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')
|
|
|
|
# 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 300x300 frame
|
|
image = image.resize((300, 300), 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 300x300, so top-right corner is at (300, 0)
|
|
self.gui_core.create_photo_icon(canvas, photo_path, icon_size=20,
|
|
face_x=0, face_y=0,
|
|
face_width=300, face_height=300,
|
|
canvas_width=300, canvas_height=300)
|
|
else:
|
|
# Clear canvas if no image
|
|
canvas = self.components['face_canvas']
|
|
canvas.delete("all")
|
|
canvas.create_text(150, 150, 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 - Unlikely)"
|
|
|
|
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
|