From f40b3db868d604da2a426e760fb9f591d07b8e77 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 10 Oct 2025 13:37:42 -0400 Subject: [PATCH] Integrate Modify Panel into Dashboard GUI for enhanced face editing functionality This commit introduces the ModifyPanel class into the Dashboard GUI, providing a fully integrated interface for editing identified faces. Users can now view and modify person details, unmatch faces, and perform bulk operations with visual confirmation. The panel includes a responsive layout, search functionality for filtering people by last name, and a calendar interface for date selection. The README has been updated to reflect the new capabilities of the Modify Panel, emphasizing its full functionality and improved user experience in managing photo identifications. --- README_UNIFIED_DASHBOARD.md | 20 +- dashboard_gui.py | 69 +++- modify_panel.py | 733 ++++++++++++++++++++++++++++++++++++ 3 files changed, 800 insertions(+), 22 deletions(-) create mode 100644 modify_panel.py diff --git a/README_UNIFIED_DASHBOARD.md b/README_UNIFIED_DASHBOARD.md index 0480aaf..5227098 100644 --- a/README_UNIFIED_DASHBOARD.md +++ b/README_UNIFIED_DASHBOARD.md @@ -56,7 +56,7 @@ python3 photo_tagger.py dashboard # 👤 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) +# ✏️ Modify - Edit face identifications (✅ Fully Functional) # 🏷️ Tags - Manage photo tags (🚧 Coming Soon) ``` @@ -191,11 +191,15 @@ The auto-match feature works in a **person-centric** way: - **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 -- **Edit Names**: Rename people across all photos -- **Unmatch Faces**: Temporarily remove face associations -- **Bulk Operations**: Handle multiple changes efficiently +#### **✏️ Modify Panel** *(Fully Functional)* +- **Review Identifications**: View all identified people with face counts +- **Edit Names**: Rename people with full name fields (first, last, middle, maiden, date of birth) +- **Unmatch Faces**: Temporarily remove face associations with visual confirmation +- **Bulk Operations**: Handle multiple changes efficiently with undo functionality +- **Search People**: Filter people by last name for large databases +- **Visual Calendar**: Date of birth selection with intuitive calendar interface +- **Responsive Layout**: Face grid adapts to window resizing +- **Unsaved Changes Protection**: Warns before losing unsaved work #### **🏷️ Tags Panel** *(Coming Soon)* - **File Explorer Interface**: Browse photos like a file manager @@ -354,7 +358,7 @@ python3 photo_tagger.py dashboard # 👤 Identify - Identify people (✅ Fully Functional) # 🔗 Auto-Match - Find matches (✅ Fully Functional) # 🔎 Search - Find photos (✅ Fully Functional) -# ✏️ Modify - Edit identifications (🚧 Coming Soon) +# ✏️ Modify - Edit identifications (✅ Fully Functional) # 🏷️ Tags - Manage tags (🚧 Coming Soon) # Legacy command line usage @@ -385,7 +389,7 @@ python3 photo_tagger.py stats - [x] Identify panel integration (fully functional) - [x] Auto-Match panel integration (fully functional) - [x] Search panel integration (fully functional) -- [ ] Modify panel integration +- [x] Modify panel integration (fully functional) - [ ] Tags panel integration ### Phase 3: Web Migration Preparation diff --git a/dashboard_gui.py b/dashboard_gui.py index a3c5276..acda0b2 100644 --- a/dashboard_gui.py +++ b/dashboard_gui.py @@ -12,12 +12,12 @@ from typing import Dict, Optional, Callable from gui_core import GUICore from identify_panel import IdentifyPanel +from modify_panel import ModifyPanel from auto_match_panel import AutoMatchPanel from search_stats import SearchStats from database import DatabaseManager from tag_management import TagManager - - +from face_processing import FaceProcessor class SearchPanel: """Search panel with full functionality from search_gui.py""" @@ -1315,7 +1315,25 @@ class SearchPanel: ttk.Button(bottom_frame, text="Close", command=close_dialog).pack(side=tk.RIGHT) refresh_tag_list() +""" +Unified Dashboard GUI for PunimTag features +Designed with web migration in mind - single window with menu bar and content area +""" +import os +import threading +import tkinter as tk +from tkinter import ttk, messagebox +from typing import Dict, Optional, Callable + +from gui_core import GUICore +from identify_panel import IdentifyPanel +from modify_panel import ModifyPanel +from auto_match_panel import AutoMatchPanel +from search_stats import SearchStats +from database import DatabaseManager +from tag_management import TagManager +from face_processing import FaceProcessor class DashboardGUI: """Unified Dashboard with menu bar and content area for all features. @@ -1419,7 +1437,7 @@ class DashboardGUI: ("👤 Identify", "identify", "Identify faces in photos"), ("🔗 Auto-Match", "auto_match", "Find and confirm matching faces"), ("🔎 Search", "search", "Search photos by person name"), - ("✏️ Modify", "modify", "View and modify identified faces"), + ("✏️ Edit Identified", "modify", "View and modify identified faces"), ("🏷️ Tags", "tags", "Manage photo tags"), ] @@ -1487,6 +1505,9 @@ class DashboardGUI: # Deactivate auto-match panel if it's active if hasattr(self, 'auto_match_panel') and self.auto_match_panel and self.current_panel == "auto_match": self.auto_match_panel.deactivate() + # Deactivate modify panel if it's active + if hasattr(self, 'modify_panel') and self.modify_panel and self.current_panel == "modify": + self.modify_panel.deactivate() # Show new panel - expand both horizontally and vertically self.panels[panel_name].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=15, pady=15) @@ -1497,6 +1518,8 @@ class DashboardGUI: self.identify_panel.activate() elif panel_name == "auto_match" and hasattr(self, 'auto_match_panel') and self.auto_match_panel: self.auto_match_panel.activate() + elif panel_name == "modify" and hasattr(self, 'modify_panel') and self.modify_panel: + self.modify_panel.activate() # Update status self.status_label.config(text=f"Viewing: {panel_name.replace('_', ' ').title()}") @@ -1804,25 +1827,43 @@ class DashboardGUI: return panel def _create_modify_panel(self) -> ttk.Frame: - """Create the modify panel (placeholder)""" + """Create the modify panel with full functionality""" panel = ttk.Frame(self.content_frame) # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - # Remove weight=1 from row to prevent empty space expansion + # Configure rows: title (row 0) fixed, modify content (row 1) should expand + panel.rowconfigure(0, weight=0) + panel.rowconfigure(1, weight=1) + # Title with larger font for full screen title_label = tk.Label(panel, text="✏️ Modify Identified", font=("Arial", 24, "bold")) title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", padding="20") - placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) - placeholder_frame.columnconfigure(0, weight=1) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = "Modify functionality will be integrated here." - placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14)) - placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N)) + # Create the modify panel if we have the required dependencies + if self.db_manager and self.face_processor: + self.modify_panel = ModifyPanel(panel, self.db_manager, self.face_processor, self.gui_core) + modify_frame = self.modify_panel.create_panel() + modify_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + else: + # Fallback placeholder if dependencies are not available + placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20") + placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) + placeholder_frame.columnconfigure(0, weight=1) + + placeholder_text = ( + "Modify panel requires database and face processor to be configured.\n\n" + "This will contain the full modify interface\n" + "currently available in the separate Modify window.\n\n" + "Features will include:\n" + "• View and edit identified people\n" + "• Rename people across all photos\n" + "• Unmatch faces from people\n" + "• Bulk operations for face management" + ) + + placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT) + placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) return panel diff --git a/modify_panel.py b/modify_panel.py new file mode 100644 index 0000000..ccd0d3d --- /dev/null +++ b/modify_panel.py @@ -0,0 +1,733 @@ +#!/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 config import DEFAULT_FACE_TOLERANCE +from database import DatabaseManager +from face_processing import FaceProcessor +from 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("", self.on_enter) + self.widget.bind("", 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, 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.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( + "", + 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('', 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( + "", + 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("", 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("", 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("400x300") + 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('', lambda e: try_save()) + last_entry.bind('', lambda e: try_save()) + middle_entry.bind('', lambda e: try_save()) + maiden_entry.bind('', lambda e: try_save()) + dob_entry.bind('', lambda e: try_save()) + first_entry.bind('', lambda e: cancel_edit()) + last_entry.bind('', lambda e: cancel_edit()) + middle_entry.bind('', lambda e: cancel_edit()) + maiden_entry.bind('', lambda e: cancel_edit()) + dob_entry.bind('', 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 = messagebox.askyesnocancel( + "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" + ) + + 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 + + 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"))