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