This commit introduces the AutoMatchPanel class into the Dashboard GUI, providing a fully integrated interface for automatic face matching. The new panel allows users to start the auto-match process, configure tolerance settings, and visually confirm matches between identified and unidentified faces. It includes features for bulk selection of matches, smart navigation through matched individuals, and a search filter for large databases. The README has been updated to reflect the new functionality and improvements in the auto-match workflow, enhancing the overall user experience in managing photo identifications.
869 lines
41 KiB
Python
869 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, 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.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 = messagebox.askyesnocancel(
|
|
"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"
|
|
)
|
|
if result is None:
|
|
# Cancel
|
|
return
|
|
if result:
|
|
# Save current person's changes, then quit
|
|
self._save_changes()
|
|
|
|
self._cleanup()
|
|
|
|
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
|