punimtag/identify_panel.py
tanyar09 e5ec0e4aea Enhance Dashboard GUI with full screen and responsive design features
This commit updates the Dashboard GUI to support automatic full screen mode across platforms, ensuring optimal viewing experiences. It introduces a responsive layout that dynamically adjusts components during window resizing, improving usability. Additionally, typography has been enhanced with larger fonts for better readability. The README has been updated to reflect these new features, emphasizing the unified dashboard's capabilities and user experience improvements.
2025-10-10 11:11:58 -04:00

1438 lines
69 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']
# Face image display - larger for full screen
self.components['face_canvas'] = tk.Canvas(left_panel, 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(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 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 - 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
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"))