Add Auto-Match Panel to Dashboard GUI for enhanced face matching functionality

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.
This commit is contained in:
tanyar09 2025-10-10 12:40:24 -04:00
parent e5ec0e4aea
commit 8ce538c508
7 changed files with 2352 additions and 76 deletions

View File

@ -51,13 +51,13 @@ python3 setup.py # Installs system deps + Python packages
python3 photo_tagger.py dashboard
# 3. Use the menu bar to access all features:
# 📁 Scan - Add photos to your collection
# 🔍 Process - Detect faces in photos
# 👤 Identify - Identify people in photos
# 🔗 Auto-Match - Find matching faces automatically
# 🔎 Search - Find photos by person name
# ✏️ Modify - Edit face identifications
# 🏷️ Tags - Manage photo tags
# 📁 Scan - Add photos to your collection (✅ Fully Functional)
# 🔍 Process - Detect faces in photos (✅ Fully Functional)
# 👤 Identify - Identify people in photos (✅ Fully Functional)
# 🔗 Auto-Match - Find matching faces automatically (✅ Fully Functional)
# 🔎 Search - Find photos by person name (✅ Fully Functional)
# ✏️ Modify - Edit face identifications (🚧 Coming Soon)
# 🏷️ Tags - Manage photo tags (🚧 Coming Soon)
```
## 📦 Installation
@ -149,17 +149,47 @@ The dashboard automatically opens in full screen mode and provides a fully respo
- **Responsive Layout**: Adapts to window resizing with dynamic updates
- **Enhanced Navigation**: Back/Next buttons with unsaved changes protection
#### **🔗 Auto-Match Panel** *(Coming Soon)*
- **Person-Centric View**: Show matched person with potential matches
- **Confidence Scoring**: Color-coded match confidence levels
- **Bulk Selection**: Select multiple faces for identification
- **Smart Navigation**: Efficient browsing through matches
#### **🔗 Auto-Match Panel** *(Fully Functional)*
- **Person-Centric Workflow**: Groups faces by already identified people
- **Visual Confirmation**: Left panel shows identified person, right panel shows potential matches
- **Confidence Scoring**: Color-coded match confidence levels with detailed descriptions
- **Bulk Selection**: Select multiple faces for identification with Select All/Clear All
- **Smart Navigation**: Back/Next buttons to move between different people
- **Search Functionality**: Filter people by last name for large databases
- **Pre-selection**: Previously identified faces are automatically checked
- **Unsaved Changes Protection**: Warns before losing unsaved work
- **Database Integration**: Proper transactions and face encoding updates
#### **🔎 Search Panel** *(Coming Soon)*
- **Advanced Search**: Find photos by person name
- **Multiple Search Types**: Name-based, tag-based, date-based searches
- **Results Display**: Grid view with face thumbnails
- **Export Options**: Save search results
##### **Auto-Match Workflow**
The auto-match feature works in a **person-centric** way:
1. **Group by Person**: Faces are grouped by already identified people (not unidentified faces)
2. **Show Matched Person**: Left side shows the identified person and their face
3. **Show Unidentified Faces**: Right side shows all unidentified faces that match this person
4. **Select and Save**: Check the faces you want to identify with this person, then click "Save Changes"
5. **Navigate**: Use Back/Next to move between different people
6. **Correct Mistakes**: Go back and uncheck faces to unidentify them
7. **Pre-selected Checkboxes**: Previously identified faces are automatically checked when you go back
**Key Benefits:**
- **1-to-Many**: One person can have multiple unidentified faces matched to them
- **Visual Confirmation**: See exactly what you're identifying before saving
- **Easy Corrections**: Go back and fix mistakes by unchecking faces
- **Smart Tracking**: Previously identified faces are pre-selected for easy review
##### **Auto-Match Configuration**
- **Tolerance Setting**: Adjust matching sensitivity (lower = stricter matching)
- **Start Button**: Prominently positioned on the left for easy access
- **Search Functionality**: Filter people by last name for large databases
- **Exit Button**: "Exit Auto-Match" with unsaved changes protection
#### **🔎 Search Panel** *(Fully Functional)*
- **Multiple Search Types**: Search photos by name, date, tags, and special categories
- **Advanced Filtering**: Filter by folder location with browse functionality
- **Results Display**: Sortable table with person names, tags, processed status, and dates
- **Interactive Results**: Click to open photos, browse folders, and view people
- **Tag Management**: Add and remove tags from selected photos
- **Responsive Layout**: Adapts to window resizing with proper scrolling
#### **✏️ Modify Panel** *(Coming Soon)*
- **Review Identifications**: View all identified people
@ -319,13 +349,13 @@ source venv/bin/activate
python3 photo_tagger.py dashboard
# Then use the menu bar in the dashboard:
# 📁 Scan - Add photos
# 🔍 Process - Detect faces
# 👤 Identify - Identify people
# 🔗 Auto-Match - Find matches
# 🔎 Search - Find photos
# ✏️ Modify - Edit identifications
# 🏷️ Tags - Manage tags
# 📁 Scan - Add photos (✅ Fully Functional)
# 🔍 Process - Detect faces (✅ Fully Functional)
# 👤 Identify - Identify people (✅ Fully Functional)
# 🔗 Auto-Match - Find matches (✅ Fully Functional)
# 🔎 Search - Find photos (✅ Fully Functional)
# ✏️ Modify - Edit identifications (🚧 Coming Soon)
# 🏷️ Tags - Manage tags (🚧 Coming Soon)
# Legacy command line usage
python3 photo_tagger.py scan ~/Pictures --recursive
@ -353,8 +383,8 @@ python3 photo_tagger.py stats
### Phase 2: GUI Panel Integration (In Progress)
- [x] Identify panel integration (fully functional)
- [ ] Auto-Match panel integration
- [ ] Search panel integration
- [x] Auto-Match panel integration (fully functional)
- [x] Search panel integration (fully functional)
- [ ] Modify panel integration
- [ ] Tags panel integration

868
auto_match_panel.py Normal file
View File

@ -0,0 +1,868 @@
#!/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

File diff suppressed because it is too large Load Diff

View File

@ -308,7 +308,7 @@ class FaceProcessor:
elif confidence_pct >= 50:
return "🔴 (Low - Questionable)"
else:
return "⚫ (Very Low - Unlikely)"
return "⚫ (Very Low)"
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
"""Calculate adaptive tolerance based on face quality and match confidence"""
@ -637,7 +637,7 @@ class FaceProcessor:
elif confidence_pct >= 50:
return "🔴 (Low - Questionable)"
else:
return "⚫ (Very Low - Unlikely)"
return "⚫ (Very Low)"
def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None):
"""Display similar faces in a panel - reuses auto-match display logic"""

View File

@ -328,7 +328,7 @@ class GUICore:
elif confidence_pct >= 50:
return "🔴 (Low - Questionable)"
else:
return "⚫ (Very Low - Unlikely)"
return "⚫ (Very Low)"
def center_window(self, root, width: int = None, height: int = None):
"""Center a window on the screen"""

View File

@ -268,12 +268,16 @@ class IdentifyPanel:
"""Create the left panel content for face identification"""
left_panel = self.components['left_panel']
# Face image display - larger for full screen
self.components['face_canvas'] = tk.Canvas(left_panel, width=400, height=400, bg='white', relief='sunken', bd=2)
# Create a main content frame that can expand
main_content_frame = ttk.Frame(left_panel)
main_content_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Face image display - flexible height for better layout
self.components['face_canvas'] = tk.Canvas(main_content_frame, width=400, height=400, bg='white', relief='sunken', bd=2)
self.components['face_canvas'].pack(pady=(0, 15))
# Person name fields
name_frame = ttk.LabelFrame(left_panel, text="Person Information", padding="5")
name_frame = ttk.LabelFrame(main_content_frame, text="Person Information", padding="5")
name_frame.pack(fill=tk.X, pady=(0, 10))
# First name
@ -324,7 +328,7 @@ class IdentifyPanel:
self._setup_last_name_autocomplete(last_name_entry)
# Control buttons
button_frame = ttk.Frame(left_panel)
button_frame = ttk.Frame(main_content_frame)
button_frame.pack(fill=tk.X, pady=(10, 0))
self.components['identify_btn'] = ttk.Button(button_frame, text="✅ Identify", command=self._identify_face, state='disabled')
@ -336,7 +340,7 @@ class IdentifyPanel:
self.components['next_btn'] = ttk.Button(button_frame, text="➡️ Next", command=self._go_next)
self.components['next_btn'].pack(side=tk.LEFT, padx=(0, 5))
self.components['quit_btn'] = ttk.Button(button_frame, text="Quit", command=self._quit_identification)
self.components['quit_btn'] = ttk.Button(button_frame, text="Exit Identify Faces", command=self._quit_identification)
self.components['quit_btn'].pack(side=tk.RIGHT)
def _create_right_panel_content(self):
@ -374,6 +378,12 @@ class IdentifyPanel:
self.components['similar_scrollable_frame'] = ttk.Frame(similar_canvas)
similar_canvas.create_window((0, 0), window=self.components['similar_scrollable_frame'], anchor='nw')
# Configure scrollable frame to expand with canvas
def configure_scroll_region(event):
similar_canvas.configure(scrollregion=similar_canvas.bbox("all"))
self.components['similar_scrollable_frame'].bind('<Configure>', configure_scroll_region)
# Store canvas reference for scrolling
self.components['similar_canvas'] = similar_canvas
@ -1042,7 +1052,7 @@ class IdentifyPanel:
elif confidence_pct >= 50:
return "(Low)"
else:
return "(Very Low - Unlikely)"
return "(Very Low)"
def _identify_face(self):
"""Identify the current face"""

View File

@ -50,7 +50,7 @@ class PhotoTagger:
self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose)
self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose)
self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, self.tag_manager, verbose)
self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify)
self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify, search_stats=self.search_stats, tag_manager=self.tag_manager)
# Legacy compatibility - expose some methods directly
self._db_connection = None