chore: Add configuration and documentation files for project structure and guidelines

This commit introduces several new files to enhance project organization and developer onboarding. The `.cursorignore` and `.cursorrules` files provide guidelines for Cursor AI, while `CONTRIBUTING.md` outlines contribution procedures. Additionally, `IMPORT_FIX_SUMMARY.md`, `RESTRUCTURE_SUMMARY.md`, and `STATUS.md` summarize recent changes and project status. The `README.md` has been updated to reflect the new project focus and structure, ensuring clarity for contributors and users. These additions aim to improve maintainability and facilitate collaboration within the PunimTag project.
This commit is contained in:
tanyar09
2025-10-15 14:43:18 -04:00
parent e49b567afa
commit d300eb1122
61 changed files with 10717 additions and 1100 deletions
+20
View File
@@ -0,0 +1,20 @@
"""
GUI components and panels for PunimTag
"""
from .gui_core import GUICore
from .dashboard_gui import DashboardGUI
from .identify_panel import IdentifyPanel
from .auto_match_panel import AutoMatchPanel
from .modify_panel import ModifyPanel
from .tag_manager_panel import TagManagerPanel
__all__ = [
'GUICore',
'DashboardGUI',
'IdentifyPanel',
'AutoMatchPanel',
'ModifyPanel',
'TagManagerPanel',
]
+875
View File
@@ -0,0 +1,875 @@
#!/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 src.core.config import DEFAULT_FACE_TOLERANCE
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
from src.gui.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
File diff suppressed because it is too large Load Diff
+858
View File
@@ -0,0 +1,858 @@
#!/usr/bin/env python3
"""
Common GUI utilities and widgets for PunimTag
"""
import os
import json
import tempfile
from PIL import Image, ImageTk
from typing import Optional, Dict, Any
from src.core.config import DEFAULT_CONFIG_FILE, DEFAULT_WINDOW_SIZE, ICON_SIZE
class GUICore:
"""Common GUI utilities and helper functions"""
def __init__(self):
"""Initialize GUI core utilities"""
pass
def setup_window_size_saving(self, root, config_file: str = DEFAULT_CONFIG_FILE) -> str:
"""Set up window size saving functionality"""
# Load saved window size
saved_size = DEFAULT_WINDOW_SIZE
if os.path.exists(config_file):
try:
with open(config_file, 'r') as f:
config = json.load(f)
saved_size = config.get('window_size', DEFAULT_WINDOW_SIZE)
except:
saved_size = DEFAULT_WINDOW_SIZE
# Calculate center position before showing window
try:
width = int(saved_size.split('x')[0])
height = int(saved_size.split('x')[1])
x = (root.winfo_screenwidth() // 2) - (width // 2)
y = (root.winfo_screenheight() // 2) - (height // 2)
root.geometry(f"{saved_size}+{x}+{y}")
except:
# Fallback to default geometry if positioning fails
root.geometry(saved_size)
# Track previous size to detect actual resizing
last_size = None
def save_window_size(event=None):
nonlocal last_size
if event and event.widget == root:
current_size = f"{root.winfo_width()}x{root.winfo_height()}"
# Only save if size actually changed
if current_size != last_size:
last_size = current_size
try:
config = {'window_size': current_size}
with open(config_file, 'w') as f:
json.dump(config, f)
except:
pass # Ignore save errors
# Bind resize event
root.bind('<Configure>', save_window_size)
return saved_size
def create_photo_icon(self, canvas, photo_path: str, icon_size: int = ICON_SIZE,
icon_x: int = None, icon_y: int = None,
canvas_width: int = None, canvas_height: int = None,
face_x: int = None, face_y: int = None,
face_width: int = None, face_height: int = None,
callback: callable = None) -> Optional[int]:
"""Create a reusable photo icon with tooltip on a canvas"""
import tkinter as tk
import subprocess
import platform
def open_source_photo(event):
"""Open the source photo in a properly sized window"""
try:
system = platform.system()
if system == "Windows":
# Try to open with a specific image viewer that supports window sizing
try:
subprocess.run(["mspaint", photo_path], check=False)
except:
os.startfile(photo_path)
elif system == "Darwin": # macOS
# Use Preview with specific window size
subprocess.run(["open", "-a", "Preview", photo_path])
else: # Linux and others
# Try common image viewers with window sizing options
viewers_to_try = [
["eog", "--new-window", photo_path], # Eye of GNOME
["gwenview", photo_path], # KDE image viewer
["feh", "--geometry", "800x600", photo_path], # feh with specific size
["gimp", photo_path], # GIMP
["xdg-open", photo_path] # Fallback to default
]
opened = False
for viewer_cmd in viewers_to_try:
try:
result = subprocess.run(viewer_cmd, check=False, capture_output=True)
if result.returncode == 0:
opened = True
break
except:
continue
if not opened:
# Final fallback
subprocess.run(["xdg-open", photo_path])
except Exception as e:
print(f"❌ Could not open photo: {e}")
# Create tooltip for the icon
tooltip = None
def show_tooltip(event):
nonlocal tooltip
if tooltip:
tooltip.destroy()
tooltip = tk.Toplevel()
tooltip.wm_overrideredirect(True)
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
label = tk.Label(tooltip, text="Show original photo",
background="lightyellow", relief="solid", borderwidth=1,
font=("Arial", 9))
label.pack()
def hide_tooltip(event):
nonlocal tooltip
if tooltip:
tooltip.destroy()
tooltip = None
# Calculate icon position
if icon_x is None or icon_y is None:
if face_x is not None and face_y is not None and face_width is not None and face_height is not None:
# Position relative to face image - exactly in the top-right corner
icon_x = face_x + face_width - icon_size
icon_y = face_y
else:
# Position relative to canvas - exactly in the corner
if canvas_width is None:
canvas_width = canvas.winfo_width()
if canvas_height is None:
canvas_height = canvas.winfo_height()
icon_x = canvas_width - icon_size
icon_y = 0
# Ensure icon stays within canvas bounds
if canvas_width is None:
canvas_width = canvas.winfo_width()
if canvas_height is None:
canvas_height = canvas.winfo_height()
icon_x = min(icon_x, canvas_width - icon_size)
icon_y = max(icon_y, 0)
# Draw the photo icon
canvas.create_rectangle(icon_x, icon_y, icon_x + icon_size, icon_y + icon_size,
fill="white", outline="black", width=1, tags="photo_icon")
canvas.create_text(icon_x + icon_size//2, icon_y + icon_size//2,
text="📷", font=("Arial", 10), tags="photo_icon")
# Bind events
canvas.tag_bind("photo_icon", "<Button-1>", open_source_photo)
canvas.tag_bind("photo_icon", "<Enter>", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e)))
canvas.tag_bind("photo_icon", "<Leave>", lambda e: (canvas.config(cursor=""), hide_tooltip(e)))
canvas.tag_bind("photo_icon", "<Motion>", lambda e: (show_tooltip(e) if tooltip else None))
return tooltip # Return tooltip reference for cleanup if needed
def create_confidence_badge(self, parent, confidence_pct: float):
"""Create a colorful confidence badge with percentage and label.
Returns a frame containing a small colored circle (with percent) and a text label.
"""
import tkinter as tk
from tkinter import ttk
# Determine color and label
if confidence_pct >= 80:
color = "#27AE60" # green
label = "Very High"
text_color = "white"
elif confidence_pct >= 70:
color = "#F1C40F" # yellow
label = "High"
text_color = "black"
elif confidence_pct >= 60:
color = "#E67E22" # orange
label = "Medium"
text_color = "white"
elif confidence_pct >= 50:
color = "#E74C3C" # red
label = "Low"
text_color = "white"
else:
color = "#2C3E50" # dark blue/black
label = "Very Low"
text_color = "white"
badge_frame = ttk.Frame(parent)
# Draw circle sized to roughly match the label font height
size = 14
style = ttk.Style()
bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
canvas = tk.Canvas(badge_frame, width=size, height=size, highlightthickness=0, bg=bg_color)
canvas.grid(row=0, column=0, padx=(0, 4))
canvas.create_oval(1, 1, size-1, size-1, fill=color, outline=color)
# Text label right to the circle
label_widget = ttk.Label(badge_frame, text=f"{int(round(confidence_pct))}% {label}", font=("Arial", 9, "bold"))
label_widget.grid(row=0, column=1, sticky="w")
return badge_frame
def create_face_crop_image(self, photo_path: str, face_location: tuple,
face_id: int, crop_size: int = 100) -> Optional[str]:
"""Create a face crop image for display"""
try:
# Parse location tuple from string format
if isinstance(face_location, str):
face_location = eval(face_location)
top, right, bottom, left = face_location
# Load the image
with Image.open(photo_path) as image:
# Add padding around the face
face_width = right - left
face_height = bottom - top
padding_x = int(face_width * 0.2)
padding_y = int(face_height * 0.2)
# Calculate crop bounds with padding
crop_left = max(0, left - padding_x)
crop_top = max(0, top - padding_y)
crop_right = min(image.width, right + padding_x)
crop_bottom = min(image.height, bottom + padding_y)
# Crop the face
face_crop = image.crop((crop_left, crop_top, crop_right, crop_bottom))
# Resize to standard size
face_crop = face_crop.resize((crop_size, crop_size), Image.Resampling.LANCZOS)
# Create temporary file
temp_dir = tempfile.gettempdir()
face_filename = f"face_{face_id}_display.jpg"
face_path = os.path.join(temp_dir, face_filename)
face_crop.save(face_path, "JPEG", quality=95)
return face_path
except Exception as e:
return None
def create_photo_thumbnail(self, photo_path: str, thumbnail_size: int = 150) -> Optional[ImageTk.PhotoImage]:
"""Create a thumbnail for display"""
try:
if not os.path.exists(photo_path):
return None
with Image.open(photo_path) as img:
img.thumbnail((thumbnail_size, thumbnail_size), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(img)
except Exception:
return None
def create_comparison_image(self, unid_crop_path: str, match_crop_path: str,
person_name: str, confidence: float) -> Optional[str]:
"""Create a side-by-side comparison image"""
try:
# Load both face crops
unid_img = Image.open(unid_crop_path)
match_img = Image.open(match_crop_path)
# Resize both to same height for better comparison
target_height = 300
unid_ratio = target_height / unid_img.height
match_ratio = target_height / match_img.height
unid_resized = unid_img.resize((int(unid_img.width * unid_ratio), target_height), Image.Resampling.LANCZOS)
match_resized = match_img.resize((int(match_img.width * match_ratio), target_height), Image.Resampling.LANCZOS)
# Create comparison image
total_width = unid_resized.width + match_resized.width + 20 # 20px gap
comparison = Image.new('RGB', (total_width, target_height + 60), 'white')
# Paste images
comparison.paste(unid_resized, (0, 30))
comparison.paste(match_resized, (unid_resized.width + 20, 30))
# Add labels
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(comparison)
try:
# Try to use a font
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
except:
font = ImageFont.load_default()
draw.text((10, 5), "UNKNOWN", fill='red', font=font)
draw.text((unid_resized.width + 30, 5), f"{person_name.upper()}", fill='green', font=font)
draw.text((10, target_height + 35), f"Confidence: {confidence:.1%}", fill='blue', font=font)
# Save comparison image
temp_dir = tempfile.gettempdir()
comparison_path = os.path.join(temp_dir, f"face_comparison_{person_name}.jpg")
comparison.save(comparison_path, "JPEG", quality=95)
return comparison_path
except Exception as e:
return None
def get_confidence_description(self, confidence_pct: float) -> str:
"""Get human-readable confidence description"""
if confidence_pct >= 80:
return "🟢 (Very High - Almost Certain)"
elif confidence_pct >= 70:
return "🟡 (High - Likely Match)"
elif confidence_pct >= 60:
return "🟠 (Medium - Possible Match)"
elif confidence_pct >= 50:
return "🔴 (Low - Questionable)"
else:
return "⚫ (Very Low)"
def center_window(self, root, width: int = None, height: int = None):
"""Center a window on the screen"""
if width is None:
width = root.winfo_width()
if height is None:
height = root.winfo_height()
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
x = (screen_width - width) // 2
y = (screen_height - height) // 2
root.geometry(f"{width}x{height}+{x}+{y}")
def create_tooltip(self, widget, text: str):
"""Create a tooltip for a widget"""
def show_tooltip(event):
tooltip = tk.Toplevel()
tooltip.wm_overrideredirect(True)
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
label = tk.Label(tooltip, text=text, background="lightyellow",
relief="solid", borderwidth=1, font=("Arial", 9))
label.pack()
widget.tooltip = tooltip
def hide_tooltip(event):
if hasattr(widget, 'tooltip'):
widget.tooltip.destroy()
del widget.tooltip
widget.bind('<Enter>', show_tooltip)
widget.bind('<Leave>', hide_tooltip)
def create_progress_bar(self, parent, text: str = "Processing..."):
"""Create a progress bar dialog"""
import tkinter as tk
from tkinter import ttk
progress_window = tk.Toplevel(parent)
progress_window.title("Progress")
progress_window.resizable(False, False)
# Center the progress window
progress_window.transient(parent)
progress_window.grab_set()
frame = ttk.Frame(progress_window, padding="20")
frame.pack()
label = ttk.Label(frame, text=text)
label.pack(pady=(0, 10))
progress = ttk.Progressbar(frame, mode='indeterminate')
progress.pack(fill='x', pady=(0, 10))
progress.start()
# Center the window
progress_window.update_idletasks()
x = (progress_window.winfo_screenwidth() // 2) - (progress_window.winfo_width() // 2)
y = (progress_window.winfo_screenheight() // 2) - (progress_window.winfo_height() // 2)
progress_window.geometry(f"+{x}+{y}")
return progress_window, progress
def create_confirmation_dialog(self, parent, title: str, message: str) -> bool:
"""Create a confirmation dialog"""
import tkinter as tk
from tkinter import messagebox
result = messagebox.askyesno(title, message, parent=parent)
return result
def create_large_messagebox(self, parent, title: str, message: str, msg_type: str = "warning") -> bool:
"""Create a larger messagebox dialog that fits text without wrapping"""
import tkinter as tk
from tkinter import ttk, messagebox
# Calculate appropriate size based on message length
lines = message.count('\n') + 1
max_line_length = max(len(line) for line in message.split('\n'))
# Set width to accommodate text (minimum 400, maximum 800)
width = max(400, min(800, max_line_length * 8 + 100))
# Set height based on number of lines (minimum 200, maximum 500)
height = max(200, min(500, lines * 25 + 150))
# Calculate center position first
screen_width = parent.winfo_screenwidth() if parent else tk._default_root.winfo_screenwidth()
screen_height = parent.winfo_screenheight() if parent else tk._default_root.winfo_screenheight()
x = (screen_width - width) // 2
y = (screen_height - height) // 2
# Create a custom dialog for better control over size
dialog = tk.Toplevel(parent)
dialog.title(title)
dialog.transient(parent)
dialog.grab_set()
# Set geometry with position in one call to prevent jumping
dialog.geometry(f"{width}x{height}+{x}+{y}")
# Create main frame
main_frame = ttk.Frame(dialog, padding="20")
main_frame.pack(fill=tk.BOTH, expand=True)
# Add message label
message_label = tk.Label(main_frame, text=message, font=("Arial", 10),
justify=tk.LEFT, wraplength=width-100)
message_label.pack(pady=(0, 20))
# Add buttons based on message type
button_frame = ttk.Frame(main_frame)
button_frame.pack()
def close_dialog(result_value):
"""Close dialog and set result"""
dialog._result = result_value
dialog.destroy()
if msg_type == "warning":
ttk.Button(button_frame, text="OK", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
elif msg_type == "askyesno":
ttk.Button(button_frame, text="Yes", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="No", command=lambda: close_dialog(False)).pack(side=tk.LEFT, padx=5)
elif msg_type == "askyesnocancel":
ttk.Button(button_frame, text="Yes", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="No", command=lambda: close_dialog(False)).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Cancel", command=lambda: close_dialog(None)).pack(side=tk.LEFT, padx=5)
# Wait for dialog to close
dialog.wait_window()
return getattr(dialog, '_result', None)
def create_input_dialog(self, parent, title: str, prompt: str, default: str = "") -> Optional[str]:
"""Create an input dialog"""
import tkinter as tk
from tkinter import simpledialog
result = simpledialog.askstring(title, prompt, initialvalue=default, parent=parent)
return result
def create_file_dialog(self, parent, title: str, filetypes: list = None) -> Optional[str]:
"""Create a file dialog"""
import tkinter as tk
from tkinter import filedialog
if filetypes is None:
filetypes = [("Image files", "*.jpg *.jpeg *.png *.gif *.bmp *.tiff")]
result = filedialog.askopenfilename(title=title, filetypes=filetypes, parent=parent)
return result if result else None
def create_directory_dialog(self, parent, title: str) -> Optional[str]:
"""Create a directory dialog"""
import tkinter as tk
from tkinter import filedialog
result = filedialog.askdirectory(title=title, parent=parent)
return result if result else None
def cleanup_temp_files(self, file_paths: list):
"""Clean up temporary files"""
for file_path in file_paths:
try:
if os.path.exists(file_path):
os.remove(file_path)
except:
pass # Ignore cleanup errors
def create_calendar_dialog(self, parent, title: str, initial_date: str = None) -> Optional[str]:
"""Create a calendar dialog for date selection"""
import tkinter as tk
from tkinter import ttk
from datetime import datetime, date
import calendar
# Create calendar window
calendar_window = tk.Toplevel(parent)
calendar_window.title(title)
calendar_window.resizable(False, False)
calendar_window.transient(parent)
calendar_window.grab_set()
# Calculate center position
window_width = 400
window_height = 400
screen_width = calendar_window.winfo_screenwidth()
screen_height = calendar_window.winfo_screenheight()
x = (screen_width // 2) - (window_width // 2)
y = (screen_height // 2) - (window_height // 2)
calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
# Calendar variables
current_date = datetime.now()
selected_date = None
# Create custom styles for calendar buttons
style = ttk.Style()
style.configure("Calendar.TButton", padding=(2, 2))
style.configure("Selected.TButton", background="lightblue")
style.configure("Today.TButton", background="lightyellow")
# Check if there's already a date selected
if initial_date:
try:
selected_date = datetime.strptime(initial_date, '%Y-%m-%d').date()
display_year = selected_date.year
display_month = selected_date.month
except ValueError:
display_year = current_date.year
display_month = current_date.month
selected_date = None
else:
display_year = current_date.year
display_month = current_date.month
# Month names
month_names = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
# Main frame
main_cal_frame = ttk.Frame(calendar_window, padding="10")
main_cal_frame.pack(fill=tk.BOTH, expand=True)
# Header frame with navigation
header_frame = ttk.Frame(main_cal_frame)
header_frame.pack(fill=tk.X, pady=(0, 10))
# Month/Year display and navigation
nav_frame = ttk.Frame(header_frame)
nav_frame.pack()
# Month/Year label
month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold"))
month_year_label.pack(side=tk.LEFT, padx=10)
def update_calendar():
"""Update the calendar display"""
# Update month/year label
month_year_label.configure(text=f"{month_names[display_month-1]} {display_year}")
# Clear existing calendar
for widget in calendar_frame.winfo_children():
widget.destroy()
# Get calendar data
cal = calendar.monthcalendar(display_year, display_month)
# Day headers
day_headers = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
for i, day in enumerate(day_headers):
header_label = ttk.Label(calendar_frame, text=day, font=("Arial", 10, "bold"))
header_label.grid(row=0, column=i, padx=2, pady=2, sticky="nsew")
# Calendar days
for week_num, week in enumerate(cal):
for day_num, day in enumerate(week):
if day == 0:
# Empty cell
empty_label = ttk.Label(calendar_frame, text="")
empty_label.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew")
else:
# Day button
day_date = date(display_year, display_month, day)
is_selected = selected_date == day_date
is_today = day_date == current_date.date()
is_future = day_date > current_date.date()
if is_future:
# Disable future dates
day_btn = ttk.Button(calendar_frame, text=str(day),
state='disabled', style="Calendar.TButton")
else:
# Create day selection handler
def make_day_handler(day_value):
def select_day():
nonlocal selected_date
selected_date = date(display_year, display_month, day_value)
# Reset all buttons to normal calendar style
for widget in calendar_frame.winfo_children():
if isinstance(widget, ttk.Button):
widget.config(style="Calendar.TButton")
# Highlight selected day
for widget in calendar_frame.winfo_children():
if isinstance(widget, ttk.Button) and widget.cget("text") == str(day_value):
widget.config(style="Selected.TButton")
return select_day
day_btn = ttk.Button(calendar_frame, text=str(day),
command=make_day_handler(day),
style="Calendar.TButton")
day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew")
# Apply initial styling
if is_selected:
day_btn.config(style="Selected.TButton")
elif is_today and not is_future:
day_btn.config(style="Today.TButton")
def prev_month():
nonlocal display_month, display_year
display_month -= 1
if display_month < 1:
display_month = 12
display_year -= 1
update_calendar()
def next_month():
nonlocal display_month, display_year
display_month += 1
if display_month > 12:
display_month = 1
display_year += 1
# Don't allow navigation to future months
if date(display_year, display_month, 1) > current_date.date():
display_month -= 1
if display_month < 1:
display_month = 12
display_year -= 1
return
update_calendar()
def prev_year():
nonlocal display_year
display_year -= 1
update_calendar()
def next_year():
nonlocal display_year
display_year += 1
# Don't allow navigation to future years
if display_year > current_date.year:
display_year -= 1
return
update_calendar()
# Navigation buttons
prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year)
prev_year_btn.pack(side=tk.LEFT)
prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month)
prev_month_btn.pack(side=tk.LEFT, padx=(5, 0))
next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month)
next_month_btn.pack(side=tk.LEFT, padx=(5, 0))
next_year_btn = ttk.Button(nav_frame, text=">>", width=3, command=next_year)
next_year_btn.pack(side=tk.LEFT)
# Calendar grid frame
calendar_frame = ttk.Frame(main_cal_frame)
calendar_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Configure grid weights
for i in range(7):
calendar_frame.columnconfigure(i, weight=1)
for i in range(7):
calendar_frame.rowconfigure(i, weight=1)
# Buttons frame
buttons_frame = ttk.Frame(main_cal_frame)
buttons_frame.pack(fill=tk.X)
def select_date():
"""Select the date and close calendar"""
if selected_date:
calendar_window.selected_date = selected_date.strftime('%Y-%m-%d')
else:
calendar_window.selected_date = ""
calendar_window.destroy()
def cancel_selection():
"""Cancel date selection"""
calendar_window.destroy()
# Buttons (matching original layout)
ttk.Button(buttons_frame, text="Select", command=select_date).pack(side=tk.LEFT, padx=(0, 5))
ttk.Button(buttons_frame, text="Cancel", command=cancel_selection).pack(side=tk.LEFT)
# Initialize calendar display
update_calendar()
# Wait for window to close
calendar_window.wait_window()
# Return selected date or None
return getattr(calendar_window, 'selected_date', None)
def create_autocomplete_entry(self, parent, suggestions: list, callback: callable = None):
"""Create an entry widget with autocomplete functionality"""
import tkinter as tk
from tkinter import ttk
# Create entry
entry_var = tk.StringVar()
entry = ttk.Entry(parent, textvariable=entry_var)
# Create listbox for suggestions
listbox = tk.Listbox(parent, height=8)
listbox.place_forget() # Hide initially
def show_suggestions():
"""Show filtered suggestions in listbox"""
typed = entry_var.get().strip()
if not typed:
filtered = [] # Show nothing if no typing
else:
low = typed.lower()
# Only show names that start with the typed text
filtered = [n for n in suggestions if n.lower().startswith(low)][:10]
# Update listbox
listbox.delete(0, tk.END)
for name in filtered:
listbox.insert(tk.END, name)
# Show listbox if we have suggestions
if filtered:
# Position listbox below entry
entry.update_idletasks()
x = entry.winfo_x()
y = entry.winfo_y() + entry.winfo_height()
width = entry.winfo_width()
listbox.place(x=x, y=y, width=width)
listbox.selection_clear(0, tk.END)
listbox.selection_set(0) # Select first item
listbox.activate(0) # Activate first item
else:
listbox.place_forget()
def hide_suggestions():
"""Hide the suggestions listbox"""
listbox.place_forget()
def on_listbox_select(event=None):
"""Handle listbox selection and hide list"""
selection = listbox.curselection()
if selection:
selected_name = listbox.get(selection[0])
entry_var.set(selected_name)
hide_suggestions()
entry.focus_set()
if callback:
callback(selected_name)
def on_listbox_click(event):
"""Handle mouse click selection"""
try:
index = listbox.nearest(event.y)
if index is not None and index >= 0:
selected_name = listbox.get(index)
entry_var.set(selected_name)
except:
pass
hide_suggestions()
entry.focus_set()
if callback:
callback(selected_name)
return 'break'
def on_key_press(event):
"""Handle key navigation in entry"""
if event.keysym == 'Down':
if listbox.winfo_viewable():
listbox.focus_set()
listbox.selection_clear(0, tk.END)
listbox.selection_set(0)
listbox.activate(0)
return 'break'
elif event.keysym == 'Escape':
hide_suggestions()
return 'break'
elif event.keysym == 'Return':
return 'break'
def on_listbox_key(event):
"""Handle key navigation in listbox"""
if event.keysym == 'Return':
on_listbox_select(event)
return 'break'
elif event.keysym == 'Escape':
hide_suggestions()
entry.focus_set()
return 'break'
elif event.keysym == 'Up':
selection = listbox.curselection()
if selection and selection[0] > 0:
# Move up in listbox
listbox.selection_clear(0, tk.END)
listbox.selection_set(selection[0] - 1)
listbox.see(selection[0] - 1)
else:
# At top, go back to entry field
hide_suggestions()
entry.focus_set()
return 'break'
elif event.keysym == 'Down':
selection = listbox.curselection()
max_index = listbox.size() - 1
if selection and selection[0] < max_index:
# Move down in listbox
listbox.selection_clear(0, tk.END)
listbox.selection_set(selection[0] + 1)
listbox.see(selection[0] + 1)
return 'break'
# Bind events
entry.bind('<KeyRelease>', lambda e: show_suggestions())
entry.bind('<KeyPress>', on_key_press)
entry.bind('<FocusOut>', lambda e: parent.after(150, hide_suggestions)) # Delay to allow listbox clicks
listbox.bind('<Button-1>', on_listbox_click)
listbox.bind('<KeyPress>', on_listbox_key)
listbox.bind('<Double-Button-1>', on_listbox_click)
return entry, entry_var, listbox
File diff suppressed because it is too large Load Diff
+740
View File
@@ -0,0 +1,740 @@
#!/usr/bin/env python3
"""
Integrated Modify Panel for PunimTag Dashboard
Embeds the full modify identified 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 src.core.config import DEFAULT_FACE_TOLERANCE
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
from src.gui.gui_core import GUICore
class ToolTip:
"""Simple tooltip implementation"""
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tooltip_window = None
self.widget.bind("<Enter>", self.on_enter)
self.widget.bind("<Leave>", self.on_leave)
def on_enter(self, event=None):
if self.tooltip_window or not self.text:
return
x, y, _, _ = self.widget.bbox("insert") if hasattr(self.widget, 'bbox') else (0, 0, 0, 0)
x += self.widget.winfo_rootx() + 25
y += self.widget.winfo_rooty() + 25
self.tooltip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
font=("tahoma", "8", "normal"))
label.pack(ipadx=1)
def on_leave(self, event=None):
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
class ModifyPanel:
"""Integrated modify panel that embeds the full modify identified 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 modify 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.temp_crops = []
self.right_panel_images = [] # Keep PhotoImage refs alive
self.selected_person_id = None
# Track unmatched faces (temporary changes)
self.unmatched_faces = set() # All face IDs unmatched across people (for global save)
self.unmatched_by_person = {} # person_id -> set(face_id) for per-person undo
self.original_faces_data = [] # store original faces data for potential future use
# People data
self.people_data = [] # list of dicts: {id, name, count, first_name, last_name}
self.people_filtered = None # filtered subset based on last name search
self.current_person_id = None
self.current_person_name = ""
self.resize_job = None
# GUI components
self.components = {}
self.main_frame = None
def create_panel(self) -> ttk.Frame:
"""Create the modify 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=2) # Right panel
self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable
# 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 modify interface"""
# Search controls (Last Name) with label under the input (match auto-match style)
self.components['last_name_search_var'] = tk.StringVar()
# Control buttons
self.components['quit_btn'] = None
self.components['save_btn_bottom'] = None
def _create_main_panels(self):
"""Create the main left and right panels"""
# Left panel: People list
self.components['people_frame'] = ttk.LabelFrame(self.main_frame, text="People", padding="10")
self.components['people_frame'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8))
self.components['people_frame'].columnconfigure(0, weight=1)
# Right panel: Faces for selected person
self.components['faces_frame'] = ttk.LabelFrame(self.main_frame, text="Faces", padding="10")
self.components['faces_frame'].grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S))
self.components['faces_frame'].columnconfigure(0, weight=1)
self.components['faces_frame'].rowconfigure(0, weight=1)
# 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 people list"""
people_frame = self.components['people_frame']
# Search controls
search_frame = ttk.Frame(people_frame)
search_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 6))
# Entry on the left
search_entry = ttk.Entry(search_frame, textvariable=self.components['last_name_search_var'], width=20)
search_entry.grid(row=0, column=0, sticky=tk.W)
# Buttons to the right of the entry
buttons_row = ttk.Frame(search_frame)
buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0))
search_btn = ttk.Button(buttons_row, text="Search", width=8, command=self.apply_last_name_filter)
search_btn.pack(side=tk.LEFT, padx=(0, 5))
clear_btn = ttk.Button(buttons_row, text="Clear", width=6, command=self.clear_last_name_filter)
clear_btn.pack(side=tk.LEFT)
# Helper label directly under the entry
last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray")
last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0))
# People list with scrollbar
people_canvas = tk.Canvas(people_frame, bg='white')
people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview)
self.components['people_list_inner'] = ttk.Frame(people_canvas)
people_canvas.create_window((0, 0), window=self.components['people_list_inner'], anchor="nw")
people_canvas.configure(yscrollcommand=people_scrollbar.set)
self.components['people_list_inner'].bind(
"<Configure>",
lambda e: people_canvas.configure(scrollregion=people_canvas.bbox("all"))
)
people_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
people_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
people_frame.rowconfigure(1, weight=1)
# Store canvas reference
self.components['people_canvas'] = people_canvas
# Bind Enter key for search
search_entry.bind('<Return>', lambda e: self.apply_last_name_filter())
def _create_right_panel_content(self):
"""Create the right panel content for faces display"""
faces_frame = self.components['faces_frame']
# Style configuration
style = ttk.Style()
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
self.components['faces_canvas'] = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0)
faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=self.components['faces_canvas'].yview)
self.components['faces_inner'] = ttk.Frame(self.components['faces_canvas'])
self.components['faces_canvas'].create_window((0, 0), window=self.components['faces_inner'], anchor="nw")
self.components['faces_canvas'].configure(yscrollcommand=faces_scrollbar.set)
self.components['faces_inner'].bind(
"<Configure>",
lambda e: self.components['faces_canvas'].configure(scrollregion=self.components['faces_canvas'].bbox("all"))
)
# Bind resize handler for responsive face grid
self.components['faces_canvas'].bind("<Configure>", self.on_faces_canvas_resize)
self.components['faces_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
faces_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
def _create_control_buttons(self):
"""Create control buttons at the bottom"""
# Control buttons
control_frame = ttk.Frame(self.main_frame)
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
self.components['quit_btn'] = ttk.Button(control_frame, text="❌ Exit Edit Identified", command=self.on_quit)
self.components['quit_btn'].pack(side=tk.RIGHT)
self.components['save_btn_bottom'] = ttk.Button(control_frame, text="💾 Save changes", command=self.on_save_all_changes, state="disabled")
self.components['save_btn_bottom'].pack(side=tk.RIGHT, padx=(0, 10))
self.components['undo_btn'] = ttk.Button(control_frame, text="↶ Undo changes", command=self.undo_changes, state="disabled")
self.components['undo_btn'].pack(side=tk.RIGHT, padx=(0, 10))
def on_faces_canvas_resize(self, event):
"""Handle canvas resize for responsive face grid"""
if self.current_person_id is None:
return
# Debounce re-render on resize
try:
if self.resize_job is not None:
self.main_frame.after_cancel(self.resize_job)
except Exception:
pass
self.resize_job = self.main_frame.after(150, lambda: self.show_person_faces(self.current_person_id, self.current_person_name))
def load_people(self):
"""Load people from database with counts"""
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT p.id, p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth, COUNT(f.id) as face_count
FROM people p
JOIN faces f ON f.person_id = p.id
GROUP BY p.id, p.last_name, p.first_name, p.middle_name, p.maiden_name, p.date_of_birth
HAVING face_count > 0
ORDER BY p.last_name, p.first_name COLLATE NOCASE
"""
)
self.people_data = []
for (pid, first_name, last_name, middle_name, maiden_name, date_of_birth, count) in cursor.fetchall():
# Create full name display with all available information
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) if name_parts else "Unknown"
# Create detailed display with date of birth if available
display_name = full_name
if date_of_birth:
display_name += f" - Born: {date_of_birth}"
self.people_data.append({
'id': pid,
'name': display_name,
'full_name': full_name,
'first_name': first_name or "",
'last_name': last_name or "",
'middle_name': middle_name or "",
'maiden_name': maiden_name or "",
'date_of_birth': date_of_birth or "",
'count': count
})
# Re-apply filter (if any) after loading
try:
self.apply_last_name_filter()
except Exception:
pass
def apply_last_name_filter(self):
"""Apply last name filter to people list"""
query = self.components['last_name_search_var'].get().strip().lower()
if query:
self.people_filtered = [p for p in self.people_data if p.get('last_name', '').lower().find(query) != -1]
else:
self.people_filtered = None
self.populate_people_list()
def clear_last_name_filter(self):
"""Clear the last name filter"""
self.components['last_name_search_var'].set("")
self.people_filtered = None
self.populate_people_list()
def populate_people_list(self):
"""Populate the people list with current data"""
# Clear existing widgets
for widget in self.components['people_list_inner'].winfo_children():
widget.destroy()
# Use filtered data if available, otherwise use all data
people_to_show = self.people_filtered if self.people_filtered is not None else self.people_data
for i, person in enumerate(people_to_show):
row_frame = ttk.Frame(self.components['people_list_inner'])
row_frame.pack(fill=tk.X, padx=2, pady=1)
# Edit button (on the left)
edit_btn = ttk.Button(row_frame, text="✏️", width=3,
command=lambda p=person: self.start_edit_person(p))
edit_btn.pack(side=tk.LEFT, padx=(0, 5))
# Add tooltip to edit button
ToolTip(edit_btn, "Update name")
# Label (clickable) - takes remaining space
name_lbl = ttk.Label(row_frame, text=f"{person['name']} ({person['count']})", font=("Arial", 10))
name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
name_lbl.bind("<Button-1>", lambda e, p=person: self.show_person_faces(p['id'], p['name']))
name_lbl.config(cursor="hand2")
# Bold if selected
if (self.selected_person_id is None and i == 0) or (self.selected_person_id == person['id']):
name_lbl.config(font=("Arial", 10, "bold"))
def start_edit_person(self, person_record):
"""Start editing a person's information"""
# Create a new window for editing
edit_window = tk.Toplevel(self.main_frame)
edit_window.title(f"Edit {person_record['name']}")
edit_window.geometry("500x400")
edit_window.transient(self.main_frame)
edit_window.grab_set()
# Center the window
edit_window.update_idletasks()
x = (edit_window.winfo_screenwidth() // 2) - (edit_window.winfo_width() // 2)
y = (edit_window.winfo_screenheight() // 2) - (edit_window.winfo_height() // 2)
edit_window.geometry(f"+{x}+{y}")
# Create form fields
form_frame = ttk.Frame(edit_window, padding="20")
form_frame.pack(fill=tk.BOTH, expand=True)
# First name
ttk.Label(form_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, pady=5)
first_name_var = tk.StringVar(value=person_record.get('first_name', ''))
first_entry = ttk.Entry(form_frame, textvariable=first_name_var, width=30)
first_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=5)
# Last name
ttk.Label(form_frame, text="Last name:").grid(row=1, column=0, sticky=tk.W, pady=5)
last_name_var = tk.StringVar(value=person_record.get('last_name', ''))
last_entry = ttk.Entry(form_frame, textvariable=last_name_var, width=30)
last_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5)
# Middle name
ttk.Label(form_frame, text="Middle name:").grid(row=2, column=0, sticky=tk.W, pady=5)
middle_name_var = tk.StringVar(value=person_record.get('middle_name', ''))
middle_entry = ttk.Entry(form_frame, textvariable=middle_name_var, width=30)
middle_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5)
# Maiden name
ttk.Label(form_frame, text="Maiden name:").grid(row=3, column=0, sticky=tk.W, pady=5)
maiden_name_var = tk.StringVar(value=person_record.get('maiden_name', ''))
maiden_entry = ttk.Entry(form_frame, textvariable=maiden_name_var, width=30)
maiden_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5)
# Date of birth
ttk.Label(form_frame, text="Date of birth:").grid(row=4, column=0, sticky=tk.W, pady=5)
dob_var = tk.StringVar(value=person_record.get('date_of_birth', ''))
dob_entry = ttk.Entry(form_frame, textvariable=dob_var, width=30, state='readonly')
dob_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=5)
# Calendar button for date of birth
def open_dob_calendar():
selected_date = self.gui_core.create_calendar_dialog(edit_window, "Select Date of Birth", dob_var.get())
if selected_date is not None:
dob_var.set(selected_date)
dob_calendar_btn = ttk.Button(form_frame, text="📅", width=3, command=open_dob_calendar)
dob_calendar_btn.grid(row=4, column=2, padx=(5, 0), pady=5)
# Configure grid weights
form_frame.columnconfigure(1, weight=1)
# Buttons
button_frame = ttk.Frame(edit_window)
button_frame.pack(fill=tk.X, padx=20, pady=10)
def save_rename():
"""Save the renamed person"""
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE people
SET first_name = ?, last_name = ?, middle_name = ?, maiden_name = ?, date_of_birth = ?
WHERE id = ?
""", (
first_name_var.get().strip(),
last_name_var.get().strip(),
middle_name_var.get().strip(),
maiden_name_var.get().strip(),
dob_var.get().strip(),
person_record['id']
))
conn.commit()
# Refresh the people list
self.load_people()
self.populate_people_list()
# Close the edit window
edit_window.destroy()
messagebox.showinfo("Success", "Person information updated successfully.")
except Exception as e:
messagebox.showerror("Error", f"Failed to update person: {e}")
def cancel_edit():
"""Cancel editing"""
edit_window.destroy()
save_btn = ttk.Button(button_frame, text="Save", command=save_rename)
save_btn.pack(side=tk.LEFT, padx=(0, 10))
cancel_btn = ttk.Button(button_frame, text="Cancel", command=cancel_edit)
cancel_btn.pack(side=tk.LEFT)
# Focus on first name field
first_entry.focus_set()
# Add keyboard shortcuts
def try_save():
if save_btn.cget('state') == 'normal':
save_rename()
first_entry.bind('<Return>', lambda e: try_save())
last_entry.bind('<Return>', lambda e: try_save())
middle_entry.bind('<Return>', lambda e: try_save())
maiden_entry.bind('<Return>', lambda e: try_save())
dob_entry.bind('<Return>', lambda e: try_save())
first_entry.bind('<Escape>', lambda e: cancel_edit())
last_entry.bind('<Escape>', lambda e: cancel_edit())
middle_entry.bind('<Escape>', lambda e: cancel_edit())
maiden_entry.bind('<Escape>', lambda e: cancel_edit())
dob_entry.bind('<Escape>', lambda e: cancel_edit())
# Add validation
def validate_save_button():
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
if first_name and last_name:
save_btn.config(state='normal')
else:
save_btn.config(state='disabled')
# Bind validation to all fields
first_name_var.trace('w', lambda *args: validate_save_button())
last_name_var.trace('w', lambda *args: validate_save_button())
middle_name_var.trace('w', lambda *args: validate_save_button())
maiden_name_var.trace('w', lambda *args: validate_save_button())
dob_var.trace('w', lambda *args: validate_save_button())
# Initial validation
validate_save_button()
def show_person_faces(self, person_id, person_name):
"""Show faces for the selected person"""
self.current_person_id = person_id
self.current_person_name = person_name
self.selected_person_id = person_id
# Clear existing face widgets
for widget in self.components['faces_inner'].winfo_children():
widget.destroy()
# Load faces for this person
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT f.id, f.photo_id, p.path, p.filename, f.location
FROM faces f
JOIN photos p ON f.photo_id = p.id
WHERE f.person_id = ?
ORDER BY p.filename
""", (person_id,))
faces = cursor.fetchall()
# Filter out unmatched faces
visible_faces = [face for face in faces if face[0] not in self.unmatched_faces]
if not visible_faces:
if not faces:
no_faces_label = ttk.Label(self.components['faces_inner'],
text="No faces found for this person",
font=("Arial", 12))
else:
no_faces_label = ttk.Label(self.components['faces_inner'],
text="All faces unmatched",
font=("Arial", 12))
no_faces_label.pack(pady=20)
return
# Display faces in a grid
self._display_faces_grid(visible_faces)
# Update people list to show selection
self.populate_people_list()
# Update button states based on unmatched faces
self._update_undo_button_state()
self._update_save_button_state()
def _display_faces_grid(self, faces):
"""Display faces in a responsive grid layout"""
# Calculate grid dimensions based on canvas width
canvas_width = self.components['faces_canvas'].winfo_width()
if canvas_width < 100: # Canvas not yet rendered
canvas_width = 400 # Default width
face_size = 80
padding = 10
faces_per_row = max(1, (canvas_width - padding) // (face_size + padding))
# Clear existing images
self.right_panel_images.clear()
for i, (face_id, photo_id, photo_path, filename, location) in enumerate(faces):
row = i // faces_per_row
col = i % faces_per_row
# Create face frame
face_frame = ttk.Frame(self.components['faces_inner'])
face_frame.grid(row=row, column=col, padx=5, pady=5, sticky=(tk.W, tk.E, tk.N, tk.S))
# Face image
try:
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id)
if face_crop_path and os.path.exists(face_crop_path):
self.temp_crops.append(face_crop_path)
image = Image.open(face_crop_path)
image.thumbnail((face_size, face_size), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(image)
self.right_panel_images.append(photo) # Keep reference
# Create canvas for face image
face_canvas = tk.Canvas(face_frame, width=face_size, height=face_size, highlightthickness=0)
face_canvas.pack()
face_canvas.create_image(face_size//2, face_size//2, image=photo, anchor=tk.CENTER)
# Add photo icon
self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15,
face_x=0, face_y=0,
face_width=face_size, face_height=face_size,
canvas_width=face_size, canvas_height=face_size)
# Unmatch button
unmatch_btn = ttk.Button(face_frame, text="Unmatch",
command=lambda fid=face_id: self.unmatch_face(fid))
unmatch_btn.pack(pady=2)
else:
# Placeholder for missing face crop
placeholder_label = ttk.Label(face_frame, text=f"Face {face_id}",
font=("Arial", 8))
placeholder_label.pack()
except Exception as e:
print(f"Error displaying face {face_id}: {e}")
# Placeholder for error
error_label = ttk.Label(face_frame, text=f"Error {face_id}",
font=("Arial", 8), foreground="red")
error_label.pack()
def unmatch_face(self, face_id):
"""Unmatch a face from its person"""
if face_id not in self.unmatched_faces:
self.unmatched_faces.add(face_id)
if self.current_person_id not in self.unmatched_by_person:
self.unmatched_by_person[self.current_person_id] = set()
self.unmatched_by_person[self.current_person_id].add(face_id)
print(f"Face {face_id} marked for unmatching")
# Immediately refresh the display to hide the unmatched face
if self.current_person_id:
self.show_person_faces(self.current_person_id, self.current_person_name)
# Update button states
self._update_undo_button_state()
self._update_save_button_state()
def _update_undo_button_state(self):
"""Update the undo button state based on unmatched faces for current person"""
if 'undo_btn' in self.components:
current_has_unmatched = bool(self.unmatched_by_person.get(self.current_person_id))
if current_has_unmatched:
self.components['undo_btn'].config(state="normal")
else:
self.components['undo_btn'].config(state="disabled")
def _update_save_button_state(self):
"""Update the save button state based on whether there are any unmatched faces to save"""
if 'save_btn_bottom' in self.components:
if self.unmatched_faces:
self.components['save_btn_bottom'].config(state="normal")
else:
self.components['save_btn_bottom'].config(state="disabled")
def undo_changes(self):
"""Undo all unmatched faces for the current person"""
if self.current_person_id and self.current_person_id in self.unmatched_by_person:
# Remove faces for current person from unmatched sets
person_faces = self.unmatched_by_person[self.current_person_id]
self.unmatched_faces -= person_faces
del self.unmatched_by_person[self.current_person_id]
# Refresh the display to show the restored faces
if self.current_person_id:
self.show_person_faces(self.current_person_id, self.current_person_name)
# Update button states
self._update_undo_button_state()
self._update_save_button_state()
messagebox.showinfo("Undo", f"Undid changes for {len(person_faces)} face(s).")
else:
messagebox.showinfo("No Changes", "No changes to undo for this person.")
def on_quit(self):
"""Handle quit button click"""
# Check for unsaved changes
if self.unmatched_faces:
result = self.gui_core.create_large_messagebox(
self.main_frame,
"Unsaved Changes",
f"You have {len(self.unmatched_faces)} unsaved changes.\n\n"
"Do you want to save them before quitting?\n\n"
"• Yes: Save changes and quit\n"
"• No: Quit without saving\n"
"• Cancel: Return to modify",
"askyesnocancel"
)
if result is True: # Yes - Save and quit
self.on_save_all_changes()
elif result is False: # No - Quit without saving
pass
else: # Cancel - Don't quit
return
# Clean up and deactivate
self._cleanup()
self.is_active = False
# Navigate to home if callback is available (dashboard mode)
if self.on_navigate_home:
self.on_navigate_home()
def on_save_all_changes(self):
"""Save all unmatched faces to database"""
if not self.unmatched_faces:
messagebox.showinfo("No Changes", "No changes to save.")
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
count = 0
for face_id in self.unmatched_faces:
cursor.execute("UPDATE faces SET person_id = NULL WHERE id = ?", (face_id,))
count += 1
conn.commit()
# Clear the unmatched faces
self.unmatched_faces.clear()
self.unmatched_by_person.clear()
# Refresh the display
if self.current_person_id:
self.show_person_faces(self.current_person_id, self.current_person_name)
# Update button states
self._update_undo_button_state()
self._update_save_button_state()
messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).")
except Exception as e:
messagebox.showerror("Error", f"Failed to save changes: {e}")
def _cleanup(self):
"""Clean up resources"""
# Clear temporary crops
for crop_path in self.temp_crops:
try:
if os.path.exists(crop_path):
os.remove(crop_path)
except Exception:
pass
self.temp_crops.clear()
# Clear right panel images
self.right_panel_images.clear()
# Clear state
self.unmatched_faces.clear()
self.unmatched_by_person.clear()
self.original_faces_data.clear()
self.people_data.clear()
self.people_filtered = None
self.current_person_id = None
self.current_person_name = ""
self.selected_person_id = None
def activate(self):
"""Activate the panel"""
self.is_active = True
# Initial load
self.load_people()
self.populate_people_list()
# Show first person's faces by default and mark selected
if self.people_data:
self.selected_person_id = self.people_data[0]['id']
self.show_person_faces(self.people_data[0]['id'], self.people_data[0]['name'])
def deactivate(self):
"""Deactivate the panel"""
if self.is_active:
self._cleanup()
self.is_active = False
def update_layout(self):
"""Update panel layout for responsiveness"""
if hasattr(self, 'components') and 'faces_canvas' in self.components:
# Update faces canvas scroll region
canvas = self.components['faces_canvas']
canvas.update_idletasks()
canvas.configure(scrollregion=canvas.bbox("all"))
File diff suppressed because it is too large Load Diff