This commit introduces a compact home icon for quick navigation to the welcome screen, improving user experience across all panels. Additionally, all exit buttons now navigate to the home screen instead of closing the application, ensuring a consistent exit behavior. The README has been updated to reflect these enhancements, emphasizing the improved navigation and user experience in the unified dashboard.
876 lines
41 KiB
Python
876 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Auto-Match Panel for PunimTag Dashboard
|
|
Embeds the full auto-match GUI functionality into the dashboard frame
|
|
"""
|
|
|
|
import os
|
|
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_FACE_TOLERANCE
|
|
from database import DatabaseManager
|
|
from face_processing import FaceProcessor
|
|
from gui_core import GUICore
|
|
|
|
|
|
class AutoMatchPanel:
|
|
"""Integrated auto-match panel that embeds the full auto-match GUI functionality into the dashboard"""
|
|
|
|
def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager,
|
|
face_processor: FaceProcessor, gui_core: GUICore, on_navigate_home=None, verbose: int = 0):
|
|
"""Initialize the auto-match panel"""
|
|
self.parent_frame = parent_frame
|
|
self.db = db_manager
|
|
self.face_processor = face_processor
|
|
self.gui_core = gui_core
|
|
self.on_navigate_home = on_navigate_home
|
|
self.verbose = verbose
|
|
|
|
# Panel state
|
|
self.is_active = False
|
|
self.matches_by_matched = {}
|
|
self.data_cache = {}
|
|
self.current_matched_index = 0
|
|
self.matched_ids = []
|
|
self.filtered_matched_ids = None
|
|
self.identified_faces_per_person = {}
|
|
self.checkbox_states_per_person = {}
|
|
self.original_checkbox_states_per_person = {}
|
|
self.identified_count = 0
|
|
|
|
# GUI components
|
|
self.components = {}
|
|
self.main_frame = None
|
|
|
|
def create_panel(self) -> ttk.Frame:
|
|
"""Create the auto-match 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
|
|
self.main_frame.rowconfigure(0, weight=0) # Configuration row - fixed height
|
|
self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable
|
|
self.main_frame.rowconfigure(2, weight=0) # Control buttons row - fixed height
|
|
|
|
# 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 auto-match interface"""
|
|
# Configuration frame
|
|
config_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="10")
|
|
config_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
# Don't give weight to any column to prevent stretching
|
|
|
|
# Start button (moved to the left)
|
|
start_btn = ttk.Button(config_frame, text="🚀 Start Auto-Match", command=self._start_auto_match)
|
|
start_btn.grid(row=0, column=0, padx=(0, 20))
|
|
|
|
# Tolerance setting
|
|
ttk.Label(config_frame, text="Tolerance:").grid(row=0, column=1, sticky=tk.W, padx=(0, 2))
|
|
self.components['tolerance_var'] = tk.StringVar(value=str(DEFAULT_FACE_TOLERANCE))
|
|
tolerance_entry = ttk.Entry(config_frame, textvariable=self.components['tolerance_var'], width=8)
|
|
tolerance_entry.grid(row=0, column=2, sticky=tk.W, padx=(0, 10))
|
|
ttk.Label(config_frame, text="(lower = stricter matching)").grid(row=0, column=3, sticky=tk.W)
|
|
|
|
def _create_main_panels(self):
|
|
"""Create the main left and right panels"""
|
|
# Left panel for identified person
|
|
self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Identified Person", padding="10")
|
|
self.components['left_panel'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
|
|
|
|
# Right panel for unidentified faces
|
|
self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Unidentified Faces to Match", padding="10")
|
|
self.components['right_panel'].grid(row=1, 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()
|
|
|
|
# Create control buttons
|
|
self._create_control_buttons()
|
|
|
|
def _create_left_panel_content(self):
|
|
"""Create the left panel content for identified person"""
|
|
left_panel = self.components['left_panel']
|
|
|
|
# Search controls for filtering people by last name
|
|
search_frame = ttk.Frame(left_panel)
|
|
search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
search_frame.columnconfigure(0, weight=1)
|
|
|
|
# Search input
|
|
self.components['search_var'] = tk.StringVar()
|
|
search_entry = ttk.Entry(search_frame, textvariable=self.components['search_var'], width=20)
|
|
search_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
|
|
|
# Search buttons
|
|
search_btn = ttk.Button(search_frame, text="Search", width=8, command=self._apply_search_filter)
|
|
search_btn.grid(row=0, column=1, padx=(0, 5))
|
|
|
|
clear_btn = ttk.Button(search_frame, text="Clear", width=6, command=self._clear_search_filter)
|
|
clear_btn.grid(row=0, column=2)
|
|
|
|
# Search help label
|
|
self.components['search_help_label'] = ttk.Label(search_frame, text="Type Last Name",
|
|
font=("Arial", 8), foreground="gray")
|
|
self.components['search_help_label'].grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=(2, 0))
|
|
|
|
# Person info label
|
|
self.components['person_info_label'] = ttk.Label(left_panel, text="", font=("Arial", 10, "bold"))
|
|
self.components['person_info_label'].grid(row=1, column=0, pady=(0, 10), sticky=tk.W)
|
|
|
|
# Person image canvas
|
|
style = ttk.Style()
|
|
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
|
self.components['person_canvas'] = tk.Canvas(left_panel, width=300, height=300,
|
|
bg=canvas_bg_color, highlightthickness=0)
|
|
self.components['person_canvas'].grid(row=2, column=0, pady=(0, 10))
|
|
|
|
# Save button
|
|
self.components['save_btn'] = ttk.Button(left_panel, text="💾 Save Changes",
|
|
command=self._save_changes, state='disabled')
|
|
self.components['save_btn'].grid(row=3, column=0, pady=(0, 10), sticky=(tk.W, tk.E))
|
|
|
|
def _create_right_panel_content(self):
|
|
"""Create the right panel content for unidentified faces"""
|
|
right_panel = self.components['right_panel']
|
|
|
|
# Control buttons for matches (Select All / Clear All)
|
|
matches_controls_frame = ttk.Frame(right_panel)
|
|
matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
|
|
self.components['select_all_btn'] = ttk.Button(matches_controls_frame, text="☑️ Select All",
|
|
command=self._select_all_matches, state='disabled')
|
|
self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
self.components['clear_all_btn'] = ttk.Button(matches_controls_frame, text="☐ Clear All",
|
|
command=self._clear_all_matches, state='disabled')
|
|
self.components['clear_all_btn'].pack(side=tk.LEFT)
|
|
|
|
# Create scrollable frame for matches
|
|
matches_frame = ttk.Frame(right_panel)
|
|
matches_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
matches_frame.columnconfigure(0, weight=1)
|
|
matches_frame.rowconfigure(0, weight=1)
|
|
|
|
# Create canvas and scrollbar for matches
|
|
style = ttk.Style()
|
|
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
|
self.components['matches_canvas'] = tk.Canvas(matches_frame, bg=canvas_bg_color, highlightthickness=0)
|
|
self.components['matches_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
scrollbar = ttk.Scrollbar(matches_frame, orient="vertical", command=self.components['matches_canvas'].yview)
|
|
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
|
self.components['matches_canvas'].configure(yscrollcommand=scrollbar.set)
|
|
|
|
# Configure right panel grid weights
|
|
right_panel.columnconfigure(0, weight=1)
|
|
right_panel.rowconfigure(1, weight=1)
|
|
|
|
def _create_control_buttons(self):
|
|
"""Create the control buttons for navigation"""
|
|
control_frame = ttk.Frame(self.main_frame)
|
|
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
|
|
|
|
self.components['back_btn'] = ttk.Button(control_frame, text="⏮️ Back",
|
|
command=self._go_back, state='disabled')
|
|
self.components['back_btn'].grid(row=0, column=0, padx=(0, 5))
|
|
|
|
self.components['next_btn'] = ttk.Button(control_frame, text="⏭️ Next",
|
|
command=self._go_next, state='disabled')
|
|
self.components['next_btn'].grid(row=0, column=1, padx=5)
|
|
|
|
self.components['quit_btn'] = ttk.Button(control_frame, text="❌ Exit Auto-Match",
|
|
command=self._quit_auto_match)
|
|
self.components['quit_btn'].grid(row=0, column=2, padx=(5, 0))
|
|
|
|
def _start_auto_match(self):
|
|
"""Start the auto-match process"""
|
|
try:
|
|
tolerance = float(self.components['tolerance_var'].get().strip())
|
|
if tolerance < 0 or tolerance > 1:
|
|
raise ValueError
|
|
except Exception:
|
|
messagebox.showerror("Error", "Please enter a valid tolerance value between 0.0 and 1.0.")
|
|
return
|
|
|
|
include_same_photo = False # Always exclude same photo matching
|
|
|
|
# Get all identified faces (one per person) to use as reference faces
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score
|
|
FROM faces f
|
|
JOIN photos p ON f.photo_id = p.id
|
|
WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3
|
|
ORDER BY f.person_id, f.quality_score DESC
|
|
''')
|
|
identified_faces = cursor.fetchall()
|
|
|
|
if not identified_faces:
|
|
messagebox.showinfo("No Identified Faces", "🔍 No identified faces found for auto-matching")
|
|
return
|
|
|
|
# Group by person and get the best quality face per person
|
|
person_faces = {}
|
|
for face in identified_faces:
|
|
person_id = face[1]
|
|
if person_id not in person_faces:
|
|
person_faces[person_id] = face
|
|
|
|
# Convert to ordered list to ensure consistent ordering
|
|
person_faces_list = []
|
|
for person_id, face in person_faces.items():
|
|
# Get person name for ordering
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
first_name, last_name = result
|
|
if last_name and first_name:
|
|
person_name = f"{last_name}, {first_name}"
|
|
elif last_name:
|
|
person_name = last_name
|
|
elif first_name:
|
|
person_name = first_name
|
|
else:
|
|
person_name = "Unknown"
|
|
else:
|
|
person_name = "Unknown"
|
|
person_faces_list.append((person_id, face, person_name))
|
|
|
|
# Sort by person name for consistent, user-friendly ordering
|
|
person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2)
|
|
|
|
# Find similar faces for each identified person
|
|
self.matches_by_matched = {}
|
|
for person_id, reference_face, person_name in person_faces_list:
|
|
reference_face_id = reference_face[0]
|
|
|
|
# Use the same filtering and sorting logic as identify
|
|
similar_faces = self.face_processor._get_filtered_similar_faces(
|
|
reference_face_id, tolerance, include_same_photo, face_status=None)
|
|
|
|
# Convert to auto-match format
|
|
person_matches = []
|
|
for similar_face in similar_faces:
|
|
match = {
|
|
'unidentified_id': similar_face['face_id'],
|
|
'unidentified_photo_id': similar_face['photo_id'],
|
|
'unidentified_filename': similar_face['filename'],
|
|
'unidentified_location': similar_face['location'],
|
|
'matched_id': reference_face_id,
|
|
'matched_photo_id': reference_face[2],
|
|
'matched_filename': reference_face[4],
|
|
'matched_location': reference_face[3],
|
|
'person_id': person_id,
|
|
'distance': similar_face['distance'],
|
|
'quality_score': similar_face['quality_score'],
|
|
'adaptive_tolerance': similar_face.get('adaptive_tolerance', tolerance)
|
|
}
|
|
person_matches.append(match)
|
|
|
|
self.matches_by_matched[person_id] = person_matches
|
|
|
|
# Flatten all matches for counting
|
|
all_matches = []
|
|
for person_matches in self.matches_by_matched.values():
|
|
all_matches.extend(person_matches)
|
|
|
|
if not all_matches:
|
|
messagebox.showinfo("No Matches", "🔍 No similar faces found for auto-identification")
|
|
return
|
|
|
|
# Pre-fetch all needed data
|
|
self.data_cache = self._prefetch_auto_match_data(self.matches_by_matched)
|
|
|
|
# Initialize state
|
|
self.matched_ids = [person_id for person_id, _, _ in person_faces_list
|
|
if person_id in self.matches_by_matched and self.matches_by_matched[person_id]]
|
|
self.filtered_matched_ids = None
|
|
self.current_matched_index = 0
|
|
self.identified_faces_per_person = {}
|
|
self.checkbox_states_per_person = {}
|
|
self.original_checkbox_states_per_person = {}
|
|
self.identified_count = 0
|
|
|
|
# Check if there's only one person - disable search if so
|
|
has_only_one_person = len(self.matched_ids) == 1
|
|
if has_only_one_person:
|
|
self.components['search_var'].set("")
|
|
search_entry = None
|
|
for widget in self.components['left_panel'].winfo_children():
|
|
if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0:
|
|
for child in widget.winfo_children():
|
|
if isinstance(child, ttk.Entry):
|
|
search_entry = child
|
|
break
|
|
if search_entry:
|
|
search_entry.config(state='disabled')
|
|
self.components['search_help_label'].config(text="(Search disabled - only one person found)")
|
|
|
|
# Enable controls
|
|
self._update_control_states()
|
|
|
|
# Show the first person
|
|
self._update_display()
|
|
|
|
self.is_active = True
|
|
|
|
def _prefetch_auto_match_data(self, matches_by_matched: Dict) -> Dict:
|
|
"""Pre-fetch all needed data to avoid repeated database queries"""
|
|
data_cache = {}
|
|
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Pre-fetch all person names and details
|
|
person_ids = list(matches_by_matched.keys())
|
|
if person_ids:
|
|
placeholders = ','.join('?' * len(person_ids))
|
|
cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids)
|
|
data_cache['person_details'] = {}
|
|
for row in cursor.fetchall():
|
|
person_id = row[0]
|
|
first_name = row[1] or ''
|
|
last_name = row[2] or ''
|
|
middle_name = row[3] or ''
|
|
maiden_name = row[4] or ''
|
|
date_of_birth = row[5] or ''
|
|
|
|
# Create full name display
|
|
name_parts = []
|
|
if first_name:
|
|
name_parts.append(first_name)
|
|
if middle_name:
|
|
name_parts.append(middle_name)
|
|
if last_name:
|
|
name_parts.append(last_name)
|
|
if maiden_name:
|
|
name_parts.append(f"({maiden_name})")
|
|
|
|
full_name = ' '.join(name_parts)
|
|
data_cache['person_details'][person_id] = {
|
|
'full_name': full_name,
|
|
'first_name': first_name,
|
|
'last_name': last_name,
|
|
'middle_name': middle_name,
|
|
'maiden_name': maiden_name,
|
|
'date_of_birth': date_of_birth
|
|
}
|
|
|
|
# Pre-fetch all photo paths (both matched and unidentified)
|
|
all_photo_ids = set()
|
|
for person_matches in matches_by_matched.values():
|
|
for match in person_matches:
|
|
all_photo_ids.add(match['matched_photo_id'])
|
|
all_photo_ids.add(match['unidentified_photo_id'])
|
|
|
|
if all_photo_ids:
|
|
photo_ids_list = list(all_photo_ids)
|
|
placeholders = ','.join('?' * len(photo_ids_list))
|
|
cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list)
|
|
data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()}
|
|
|
|
return data_cache
|
|
|
|
def _update_display(self):
|
|
"""Update the display for the current person"""
|
|
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
|
|
|
if self.current_matched_index >= len(active_ids):
|
|
self._finish_auto_match()
|
|
return
|
|
|
|
matched_id = active_ids[self.current_matched_index]
|
|
matches_for_this_person = self.matches_by_matched[matched_id]
|
|
|
|
# Update button states
|
|
self._update_control_states()
|
|
|
|
# Update save button text with person name
|
|
self._update_save_button_text()
|
|
|
|
# Get the first match to get matched person info
|
|
if not matches_for_this_person:
|
|
print(f"❌ Error: No matches found for current person {matched_id}")
|
|
# Skip to next person if available
|
|
if self.current_matched_index < len(active_ids) - 1:
|
|
self.current_matched_index += 1
|
|
self._update_display()
|
|
else:
|
|
self._finish_auto_match()
|
|
return
|
|
|
|
first_match = matches_for_this_person[0]
|
|
|
|
# Use cached data instead of database queries
|
|
person_details = self.data_cache['person_details'].get(first_match['person_id'], {})
|
|
person_name = person_details.get('full_name', "Unknown")
|
|
date_of_birth = person_details.get('date_of_birth', '')
|
|
matched_photo_path = self.data_cache['photo_paths'].get(first_match['matched_photo_id'], None)
|
|
|
|
# Create detailed person info display
|
|
person_info_lines = [f"👤 Person: {person_name}"]
|
|
if date_of_birth:
|
|
person_info_lines.append(f"📅 Born: {date_of_birth}")
|
|
person_info_lines.extend([
|
|
f"📁 Photo: {first_match['matched_filename']}",
|
|
f"📍 Face location: {first_match['matched_location']}"
|
|
])
|
|
|
|
# Update matched person info
|
|
self.components['person_info_label'].config(text="\n".join(person_info_lines))
|
|
|
|
# Display matched person face
|
|
self.components['person_canvas'].delete("all")
|
|
if matched_photo_path:
|
|
matched_crop_path = self.face_processor._extract_face_crop(
|
|
matched_photo_path,
|
|
first_match['matched_location'],
|
|
f"matched_{first_match['person_id']}"
|
|
)
|
|
|
|
if matched_crop_path and os.path.exists(matched_crop_path):
|
|
try:
|
|
pil_image = Image.open(matched_crop_path)
|
|
pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
|
photo = ImageTk.PhotoImage(pil_image)
|
|
self.components['person_canvas'].create_image(150, 150, image=photo)
|
|
self.components['person_canvas'].image = photo
|
|
|
|
# Add photo icon to the matched person face
|
|
actual_width, actual_height = pil_image.size
|
|
top_left_x = 150 - (actual_width // 2)
|
|
top_left_y = 150 - (actual_height // 2)
|
|
self.gui_core.create_photo_icon(self.components['person_canvas'], matched_photo_path, icon_size=20,
|
|
face_x=top_left_x, face_y=top_left_y,
|
|
face_width=actual_width, face_height=actual_height,
|
|
canvas_width=300, canvas_height=300)
|
|
except Exception as e:
|
|
self.components['person_canvas'].create_text(150, 150, text=f"❌ Could not load image: {e}", fill="red")
|
|
else:
|
|
self.components['person_canvas'].create_text(150, 150, text="🖼️ No face crop available", fill="gray")
|
|
|
|
# Clear and populate unidentified faces
|
|
self._update_matches_display(matches_for_this_person, matched_id)
|
|
|
|
def _update_matches_display(self, matches_for_this_person, matched_id):
|
|
"""Update the matches display for the current person"""
|
|
# Clear existing matches
|
|
self.components['matches_canvas'].delete("all")
|
|
self.match_checkboxes = []
|
|
self.match_vars = []
|
|
|
|
# Create frame for unidentified faces inside canvas
|
|
matches_inner_frame = ttk.Frame(self.components['matches_canvas'])
|
|
self.components['matches_canvas'].create_window((0, 0), window=matches_inner_frame, anchor="nw")
|
|
|
|
# Use cached photo paths
|
|
photo_paths = self.data_cache['photo_paths']
|
|
|
|
# Create all checkboxes
|
|
for i, match in enumerate(matches_for_this_person):
|
|
# Get unidentified face info from cached data
|
|
unidentified_photo_path = photo_paths.get(match['unidentified_photo_id'], '')
|
|
|
|
# Calculate confidence
|
|
confidence_pct = (1 - match['distance']) * 100
|
|
confidence_desc = self.face_processor._get_confidence_description(confidence_pct)
|
|
|
|
# Create match frame
|
|
match_frame = ttk.Frame(matches_inner_frame)
|
|
match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5)
|
|
|
|
# Checkbox for this match
|
|
match_var = tk.BooleanVar()
|
|
|
|
# Restore previous checkbox state if available
|
|
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
|
if matched_id in self.checkbox_states_per_person and unique_key in self.checkbox_states_per_person[matched_id]:
|
|
saved_state = self.checkbox_states_per_person[matched_id][unique_key]
|
|
match_var.set(saved_state)
|
|
# Otherwise, pre-select if this face was previously identified for this person
|
|
elif matched_id in self.identified_faces_per_person and match['unidentified_id'] in self.identified_faces_per_person[matched_id]:
|
|
match_var.set(True)
|
|
|
|
self.match_vars.append(match_var)
|
|
|
|
# Capture original state at render time
|
|
if matched_id not in self.original_checkbox_states_per_person:
|
|
self.original_checkbox_states_per_person[matched_id] = {}
|
|
if unique_key not in self.original_checkbox_states_per_person[matched_id]:
|
|
self.original_checkbox_states_per_person[matched_id][unique_key] = match_var.get()
|
|
|
|
# Add callback to save state immediately when checkbox changes
|
|
def on_checkbox_change(var, person_id, face_id):
|
|
unique_key = f"{person_id}_{face_id}"
|
|
if person_id not in self.checkbox_states_per_person:
|
|
self.checkbox_states_per_person[person_id] = {}
|
|
|
|
current_value = var.get()
|
|
self.checkbox_states_per_person[person_id][unique_key] = current_value
|
|
|
|
# Bind the callback to the variable
|
|
current_person_id = matched_id
|
|
current_face_id = match['unidentified_id']
|
|
match_var.trace('w', lambda *args, var=match_var, person_id=current_person_id, face_id=current_face_id: on_checkbox_change(var, person_id, 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
|
|
checkbox = ttk.Checkbutton(match_frame, variable=match_var)
|
|
checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5))
|
|
self.match_checkboxes.append(checkbox)
|
|
|
|
# Unidentified face image
|
|
match_canvas = None
|
|
if unidentified_photo_path:
|
|
style = ttk.Style()
|
|
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
|
match_canvas = tk.Canvas(match_frame, width=100, height=100, bg=canvas_bg_color, highlightthickness=0)
|
|
match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10))
|
|
|
|
unidentified_crop_path = self.face_processor._extract_face_crop(
|
|
unidentified_photo_path,
|
|
match['unidentified_location'],
|
|
f"unid_{match['unidentified_id']}"
|
|
)
|
|
|
|
if unidentified_crop_path and os.path.exists(unidentified_crop_path):
|
|
try:
|
|
pil_image = Image.open(unidentified_crop_path)
|
|
pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS)
|
|
photo = ImageTk.PhotoImage(pil_image)
|
|
match_canvas.create_image(50, 50, image=photo)
|
|
match_canvas.image = photo
|
|
|
|
# Add photo icon
|
|
self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15,
|
|
face_x=0, face_y=0,
|
|
face_width=100, face_height=100,
|
|
canvas_width=100, canvas_height=100)
|
|
except Exception:
|
|
match_canvas.create_text(50, 50, text="❌", fill="red")
|
|
else:
|
|
match_canvas.create_text(50, 50, text="🖼️", fill="gray")
|
|
|
|
# Confidence badge and filename
|
|
info_container = ttk.Frame(match_frame)
|
|
info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E))
|
|
|
|
badge = self.gui_core.create_confidence_badge(info_container, confidence_pct)
|
|
badge.pack(anchor=tk.W)
|
|
|
|
filename_label = ttk.Label(info_container, text=f"📁 {match['unidentified_filename']}",
|
|
font=("Arial", 8), foreground="gray")
|
|
filename_label.pack(anchor=tk.W, pady=(2, 0))
|
|
|
|
# Update Select All / Clear All button states
|
|
self._update_match_control_buttons_state()
|
|
|
|
# Update scroll region
|
|
self.components['matches_canvas'].update_idletasks()
|
|
self.components['matches_canvas'].configure(scrollregion=self.components['matches_canvas'].bbox("all"))
|
|
|
|
def _update_control_states(self):
|
|
"""Update control button states based on current position"""
|
|
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
|
|
|
# Enable/disable Back button
|
|
if self.current_matched_index > 0:
|
|
self.components['back_btn'].config(state='normal')
|
|
else:
|
|
self.components['back_btn'].config(state='disabled')
|
|
|
|
# Enable/disable Next button
|
|
if self.current_matched_index < len(active_ids) - 1:
|
|
self.components['next_btn'].config(state='normal')
|
|
else:
|
|
self.components['next_btn'].config(state='disabled')
|
|
|
|
# Enable save button if we have matches
|
|
if active_ids and self.current_matched_index < len(active_ids):
|
|
self.components['save_btn'].config(state='normal')
|
|
else:
|
|
self.components['save_btn'].config(state='disabled')
|
|
|
|
def _update_save_button_text(self):
|
|
"""Update save button text with current person name"""
|
|
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
|
if self.current_matched_index < len(active_ids):
|
|
matched_id = active_ids[self.current_matched_index]
|
|
matches_for_current_person = self.matches_by_matched[matched_id]
|
|
if matches_for_current_person:
|
|
person_id = matches_for_current_person[0]['person_id']
|
|
person_details = self.data_cache['person_details'].get(person_id, {})
|
|
person_name = person_details.get('full_name', "Unknown")
|
|
self.components['save_btn'].config(text=f"💾 Save changes for {person_name}")
|
|
else:
|
|
self.components['save_btn'].config(text="💾 Save Changes")
|
|
else:
|
|
self.components['save_btn'].config(text="💾 Save Changes")
|
|
|
|
def _update_match_control_buttons_state(self):
|
|
"""Enable/disable Select All / Clear All based on matches presence"""
|
|
if hasattr(self, 'match_vars') and self.match_vars:
|
|
self.components['select_all_btn'].config(state='normal')
|
|
self.components['clear_all_btn'].config(state='normal')
|
|
else:
|
|
self.components['select_all_btn'].config(state='disabled')
|
|
self.components['clear_all_btn'].config(state='disabled')
|
|
|
|
def _select_all_matches(self):
|
|
"""Select all match checkboxes"""
|
|
if hasattr(self, 'match_vars'):
|
|
for var in self.match_vars:
|
|
var.set(True)
|
|
|
|
def _clear_all_matches(self):
|
|
"""Clear all match checkboxes"""
|
|
if hasattr(self, 'match_vars'):
|
|
for var in self.match_vars:
|
|
var.set(False)
|
|
|
|
def _save_changes(self):
|
|
"""Save changes for the current person"""
|
|
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
|
if self.current_matched_index < len(active_ids):
|
|
matched_id = active_ids[self.current_matched_index]
|
|
matches_for_this_person = self.matches_by_matched[matched_id]
|
|
|
|
# Initialize identified faces for this person if not exists
|
|
if matched_id not in self.identified_faces_per_person:
|
|
self.identified_faces_per_person[matched_id] = set()
|
|
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Process all matches (both checked and unchecked)
|
|
for i, (match, var) in enumerate(zip(matches_for_this_person, self.match_vars)):
|
|
if var.get():
|
|
# Face is checked - assign to person
|
|
cursor.execute(
|
|
'UPDATE faces SET person_id = ? WHERE id = ?',
|
|
(match['person_id'], match['unidentified_id'])
|
|
)
|
|
|
|
# Use cached person name
|
|
person_details = self.data_cache['person_details'].get(match['person_id'], {})
|
|
person_name = person_details.get('full_name', "Unknown")
|
|
|
|
# Track this face as identified for this person
|
|
self.identified_faces_per_person[matched_id].add(match['unidentified_id'])
|
|
|
|
print(f"✅ Identified as: {person_name}")
|
|
self.identified_count += 1
|
|
else:
|
|
# Face is unchecked - check if it was previously identified for this person
|
|
if match['unidentified_id'] in self.identified_faces_per_person[matched_id]:
|
|
# This face was previously identified for this person, now unchecking it
|
|
cursor.execute(
|
|
'UPDATE faces SET person_id = NULL WHERE id = ?',
|
|
(match['unidentified_id'],)
|
|
)
|
|
|
|
# Remove from identified faces for this person
|
|
self.identified_faces_per_person[matched_id].discard(match['unidentified_id'])
|
|
|
|
print(f"❌ Unidentified: {match['unidentified_filename']}")
|
|
|
|
# Update person encodings for all affected persons
|
|
for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']):
|
|
self.face_processor.update_person_encodings(person_id)
|
|
|
|
conn.commit()
|
|
|
|
# After saving, set original states to the current UI states
|
|
current_snapshot = {}
|
|
for match, var in zip(matches_for_this_person, self.match_vars):
|
|
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
|
current_snapshot[unique_key] = var.get()
|
|
self.checkbox_states_per_person[matched_id] = dict(current_snapshot)
|
|
self.original_checkbox_states_per_person[matched_id] = dict(current_snapshot)
|
|
|
|
def _go_back(self):
|
|
"""Go back to the previous person"""
|
|
if self.current_matched_index > 0:
|
|
self.current_matched_index -= 1
|
|
self._update_display()
|
|
|
|
def _go_next(self):
|
|
"""Go to the next person"""
|
|
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
|
if self.current_matched_index < len(active_ids) - 1:
|
|
self.current_matched_index += 1
|
|
self._update_display()
|
|
else:
|
|
self._finish_auto_match()
|
|
|
|
def _apply_search_filter(self):
|
|
"""Filter people by last name and update navigation"""
|
|
query = self.components['search_var'].get().strip().lower()
|
|
if query:
|
|
# Filter person_faces_list by last name
|
|
filtered_people = []
|
|
for person_id in self.matched_ids:
|
|
# Get person name from cache
|
|
person_details = self.data_cache['person_details'].get(person_id, {})
|
|
person_name = person_details.get('full_name', '')
|
|
|
|
# Extract last name from person_name
|
|
if ',' in person_name:
|
|
last_name = person_name.split(',')[0].strip().lower()
|
|
else:
|
|
# Try to extract last name from full name
|
|
name_parts = person_name.strip().split()
|
|
if name_parts:
|
|
last_name = name_parts[-1].lower()
|
|
else:
|
|
last_name = ''
|
|
|
|
if query in last_name:
|
|
filtered_people.append(person_id)
|
|
|
|
self.filtered_matched_ids = filtered_people
|
|
else:
|
|
self.filtered_matched_ids = None
|
|
|
|
# Reset to first person in filtered list
|
|
self.current_matched_index = 0
|
|
if self.filtered_matched_ids:
|
|
self._update_display()
|
|
else:
|
|
# No matches - clear display
|
|
self.components['person_info_label'].config(text="No people match filter")
|
|
self.components['person_canvas'].delete("all")
|
|
self.components['person_canvas'].create_text(150, 150, text="No matches found", fill="gray")
|
|
self.components['matches_canvas'].delete("all")
|
|
self._update_control_states()
|
|
|
|
def _clear_search_filter(self):
|
|
"""Clear filter and show all people"""
|
|
self.components['search_var'].set("")
|
|
self.filtered_matched_ids = None
|
|
self.current_matched_index = 0
|
|
self._update_display()
|
|
|
|
def _finish_auto_match(self):
|
|
"""Finish the auto-match process"""
|
|
print(f"\n✅ Auto-identified {self.identified_count} faces")
|
|
messagebox.showinfo("Auto-Match Complete", f"Auto-identified {self.identified_count} faces")
|
|
self._cleanup()
|
|
|
|
def _quit_auto_match(self):
|
|
"""Quit the auto-match process"""
|
|
# Check for unsaved changes before quitting
|
|
if self._has_unsaved_changes():
|
|
result = self.gui_core.create_large_messagebox(
|
|
self.main_frame,
|
|
"Unsaved Changes",
|
|
"You have unsaved changes that will be lost if you quit.\n\n"
|
|
"Yes: Save current changes and quit\n"
|
|
"No: Quit without saving\n"
|
|
"Cancel: Return to auto-match",
|
|
"askyesnocancel"
|
|
)
|
|
if result is None:
|
|
# Cancel
|
|
return
|
|
if result:
|
|
# Save current person's changes, then quit
|
|
self._save_changes()
|
|
|
|
self._cleanup()
|
|
|
|
# Navigate to home if callback is available (dashboard mode)
|
|
if self.on_navigate_home:
|
|
self.on_navigate_home()
|
|
|
|
def _has_unsaved_changes(self):
|
|
"""Check if there are any unsaved changes"""
|
|
for person_id, current_states in self.checkbox_states_per_person.items():
|
|
if person_id in self.original_checkbox_states_per_person:
|
|
original_states = self.original_checkbox_states_per_person[person_id]
|
|
# Check if any checkbox state differs from its original state
|
|
for key, current_value in current_states.items():
|
|
if key not in original_states or original_states[key] != current_value:
|
|
return True
|
|
else:
|
|
# If person has current states but no original states, there are changes
|
|
if any(current_states.values()):
|
|
return True
|
|
return False
|
|
|
|
def _cleanup(self):
|
|
"""Clean up resources and reset state"""
|
|
# Clean up face crops
|
|
self.face_processor.cleanup_face_crops()
|
|
|
|
# Reset state
|
|
self.matches_by_matched = {}
|
|
self.data_cache = {}
|
|
self.current_matched_index = 0
|
|
self.matched_ids = []
|
|
self.filtered_matched_ids = None
|
|
self.identified_faces_per_person = {}
|
|
self.checkbox_states_per_person = {}
|
|
self.original_checkbox_states_per_person = {}
|
|
self.identified_count = 0
|
|
|
|
# Clear displays
|
|
self.components['person_info_label'].config(text="")
|
|
self.components['person_canvas'].delete("all")
|
|
self.components['matches_canvas'].delete("all")
|
|
|
|
# Disable controls
|
|
self.components['back_btn'].config(state='disabled')
|
|
self.components['next_btn'].config(state='disabled')
|
|
self.components['save_btn'].config(state='disabled')
|
|
self.components['select_all_btn'].config(state='disabled')
|
|
self.components['clear_all_btn'].config(state='disabled')
|
|
|
|
# Clear search
|
|
self.components['search_var'].set("")
|
|
self.components['search_help_label'].config(text="Type Last Name")
|
|
|
|
# Re-enable search entry
|
|
search_entry = None
|
|
for widget in self.components['left_panel'].winfo_children():
|
|
if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0:
|
|
for child in widget.winfo_children():
|
|
if isinstance(child, ttk.Entry):
|
|
search_entry = child
|
|
break
|
|
if search_entry:
|
|
search_entry.config(state='normal')
|
|
|
|
self.is_active = False
|
|
|
|
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
|