diff --git a/archive/auto_match_gui.py b/archive/auto_match_gui.py deleted file mode 100644 index 4ba6c38..0000000 --- a/archive/auto_match_gui.py +++ /dev/null @@ -1,840 +0,0 @@ -#!/usr/bin/env python3 -""" -Auto-match face identification GUI implementation for PunimTag -""" - -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 AutoMatchGUI: - """Handles the auto-match face identification GUI interface""" - - def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0): - """Initialize the auto-match GUI""" - self.db = db_manager - self.face_processor = face_processor - self.verbose = verbose - self.gui_core = GUICore() - - def auto_identify_matches(self, tolerance: float = DEFAULT_FACE_TOLERANCE, confirm: bool = True, - show_faces: bool = False, include_same_photo: bool = False) -> int: - """Automatically identify faces that match already identified faces using GUI""" - # 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: - print("šŸ” No identified faces found for auto-matching") - return 0 - - # 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 - # Order by person name for user-friendly consistent results across runs - 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) - - print(f"\nšŸŽÆ Found {len(person_faces)} identified people to match against") - print("šŸ“Š Confidence Guide: 🟢80%+ = Very High, 🟔70%+ = High, 🟠60%+ = Medium, šŸ”“50%+ = Low, ⚫<50% = Very Low") - - # Find similar faces for each identified person using face-to-face comparison - 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: - # Convert to auto-match format - 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) - - matches_by_matched[person_id] = person_matches - - # Flatten all matches for counting - all_matches = [] - for person_matches in matches_by_matched.values(): - all_matches.extend(person_matches) - - if not all_matches: - print("šŸ” No similar faces found for auto-identification") - return 0 - - print(f"\nšŸŽÆ Found {len(all_matches)} potential matches") - - # Pre-fetch all needed data to avoid repeated database queries in update_display - print("šŸ“Š Pre-fetching data for optimal performance...") - data_cache = self._prefetch_auto_match_data(matches_by_matched) - - print(f"āœ… Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths") - - identified_count = 0 - - # Create the main window - root = tk.Tk() - root.title("Auto-Match Face Identification") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Clean up face crops and caches - self.face_processor.cleanup_face_crops() - self.db.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving with larger default size - saved_size = self.gui_core.setup_window_size_saving(root, "gui_config.json") - # Override with larger size for auto-match window - root.geometry("1000x700") - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=1) - - # Left side - identified person - left_frame = ttk.LabelFrame(main_frame, text="Identified person", padding="10") - left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) - - # Right side - unidentified faces that match this person - right_frame = ttk.LabelFrame(main_frame, text="Unidentified Faces to Match", padding="10") - right_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - - # Configure row weights - main_frame.rowconfigure(0, weight=1) - - # Check if there's only one person - if so, disable search functionality - # Use matched_ids instead of person_faces_list since we only show people with potential matches - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - has_only_one_person = len(matched_ids) == 1 - print(f"DEBUG: person_faces_list length: {len(person_faces_list)}, matched_ids length: {len(matched_ids)}, has_only_one_person: {has_only_one_person}") - - # Search controls for filtering people by last name - last_name_search_var = tk.StringVar() - # Search field with label underneath (like modifyidentified edit section) - search_frame = ttk.Frame(left_frame) - search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - - # Search input on the left - search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20) - search_entry.grid(row=0, column=0, sticky=tk.W) - - # Buttons on the right of the search input - 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) - search_btn.pack(side=tk.LEFT, padx=(0, 5)) - clear_btn = ttk.Button(buttons_row, text="Clear", width=6) - clear_btn.pack(side=tk.LEFT) - - # Helper label directly under the search input - if has_only_one_person: - print("DEBUG: Disabling search functionality - only one person found") - # Disable search functionality if there's only one person - search_entry.config(state='disabled') - search_btn.config(state='disabled') - clear_btn.config(state='disabled') - # Add a label to explain why search is disabled - disabled_label = ttk.Label(search_frame, text="(Search disabled - only one person found)", - font=("Arial", 8), foreground="gray") - disabled_label.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(2, 0)) - else: - print("DEBUG: Search functionality enabled - multiple people found") - # Normal helper label when search is enabled - 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)) - - # Matched person info - matched_info_label = ttk.Label(left_frame, text="", font=("Arial", 10, "bold")) - matched_info_label.grid(row=2, column=0, pady=(0, 10), sticky=tk.W) - - # Matched person image - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matched_canvas = tk.Canvas(left_frame, width=300, height=300, bg=canvas_bg_color, highlightthickness=0) - matched_canvas.grid(row=3, column=0, pady=(0, 10)) - - # Save button for this person (will be created after function definitions) - save_btn = None - - # Matches scrollable frame - matches_frame = ttk.Frame(right_frame) - matches_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Control buttons for matches (Select All / Clear All) - matches_controls_frame = ttk.Frame(matches_frame) - matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) - - def select_all_matches(): - """Select all match checkboxes""" - for var in match_vars: - var.set(True) - - def clear_all_matches(): - """Clear all match checkboxes""" - for var in match_vars: - var.set(False) - - select_all_matches_btn = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", command=select_all_matches) - select_all_matches_btn.pack(side=tk.LEFT, padx=(0, 5)) - - clear_all_matches_btn = ttk.Button(matches_controls_frame, text="☐ Clear All", command=clear_all_matches) - clear_all_matches_btn.pack(side=tk.LEFT) - - def update_match_control_buttons_state(): - """Enable/disable Select All / Clear All based on matches presence""" - if match_vars: - select_all_matches_btn.config(state='normal') - clear_all_matches_btn.config(state='normal') - else: - select_all_matches_btn.config(state='disabled') - clear_all_matches_btn.config(state='disabled') - - # Create scrollbar for matches - scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=None) - scrollbar.grid(row=0, column=1, rowspan=1, sticky=(tk.N, tk.S)) - - # Create canvas for matches with scrollbar - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matches_canvas = tk.Canvas(matches_frame, yscrollcommand=scrollbar.set, bg=canvas_bg_color, highlightthickness=0) - matches_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - scrollbar.config(command=matches_canvas.yview) - - # Configure grid weights - right_frame.columnconfigure(0, weight=1) - right_frame.rowconfigure(0, weight=1) - matches_frame.columnconfigure(0, weight=1) - matches_frame.rowconfigure(0, weight=0) # Controls row takes minimal space - matches_frame.rowconfigure(1, weight=1) # Canvas row expandable - - # Control buttons (navigation only) - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0)) - - # Button commands - current_matched_index = 0 - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - filtered_matched_ids = None # filtered subset based on last name search - - match_checkboxes = [] - match_vars = [] - identified_faces_per_person = {} # Track which faces were identified for each person - checkbox_states_per_person = {} # Track checkbox states for each person (unsaved selections) - original_checkbox_states_per_person = {} # Track original/default checkbox states for comparison - - def on_confirm_matches(): - nonlocal identified_count, current_matched_index, identified_faces_per_person - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - matched_id = active_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Initialize identified faces for this person if not exists - if matched_id not in identified_faces_per_person: - 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, 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 instead of database query - person_details = data_cache['person_details'].get(match['person_id'], {}) - person_name = person_details.get('full_name', "Unknown") - - # Track this face as identified for this person - identified_faces_per_person[matched_id].add(match['unidentified_id']) - - print(f"āœ… Identified as: {person_name}") - identified_count += 1 - else: - # Face is unchecked - check if it was previously identified for this person - if match['unidentified_id'] in 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 - identified_faces_per_person[matched_id].discard(match['unidentified_id']) - - print(f"āŒ Unidentified: {match['unidentified_filename']}") - - # Update person encodings for all affected persons after database transaction is complete - 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) - - # After saving, set original states to the current UI states so there are no unsaved changes - current_snapshot = {} - for match, var in zip(matches_for_this_person, match_vars): - unique_key = f"{matched_id}_{match['unidentified_id']}" - current_snapshot[unique_key] = var.get() - checkbox_states_per_person[matched_id] = dict(current_snapshot) - original_checkbox_states_per_person[matched_id] = dict(current_snapshot) - - def on_skip_current(): - nonlocal current_matched_index - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index += 1 - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - update_display() - else: - finish_auto_match() - - def on_go_back(): - nonlocal current_matched_index - if current_matched_index > 0: - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index -= 1 - update_display() - - def has_unsaved_changes(): - """Check if there are any unsaved changes by comparing current states with original states""" - for person_id, current_states in checkbox_states_per_person.items(): - if person_id in original_checkbox_states_per_person: - original_states = 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 apply_last_name_filter(): - """Filter people by last name and update navigation""" - nonlocal filtered_matched_ids, current_matched_index - query = last_name_search_var.get().strip().lower() - if query: - # Filter person_faces_list by last name - filtered_people = [] - for person_id, face, person_name in person_faces_list: - # Extract last name from person_name (format: "Last, First") - if ',' in person_name: - last_name = person_name.split(',')[0].strip().lower() - else: - last_name = person_name.strip().lower() - - if query in last_name: - filtered_people.append((person_id, face, person_name)) - - # Get filtered matched_ids - filtered_matched_ids = [person_id for person_id, _, _ in filtered_people if person_id in matches_by_matched and matches_by_matched[person_id]] - else: - filtered_matched_ids = None - - # Reset to first person in filtered list - current_matched_index = 0 - if filtered_matched_ids: - update_display() - else: - # No matches - clear display - matched_info_label.config(text="No people match filter") - matched_canvas.delete("all") - matched_canvas.create_text(150, 150, text="No matches found", fill="gray") - matches_canvas.delete("all") - update_button_states() - - def clear_last_name_filter(): - """Clear filter and show all people""" - nonlocal filtered_matched_ids, current_matched_index - last_name_search_var.set("") - filtered_matched_ids = None - current_matched_index = 0 - update_display() - - def on_quit_auto_match(): - nonlocal window_destroyed - # Check for unsaved changes before quitting - if has_unsaved_changes(): - result = messagebox.askyesnocancel( - "Unsaved Changes", - "You have unsaved changes that will be lost if you quit.\n\n" - "Yes: Save current changes and quit\n" - "No: Quit without saving\n" - "Cancel: Return to auto-match" - ) - if result is None: - # Cancel - return - if result: - # Save current person's changes, then quit - save_current_checkbox_states() - on_confirm_matches() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - def finish_auto_match(): - nonlocal window_destroyed - print(f"\nāœ… Auto-identified {identified_count} faces") - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Create button references for state management - back_btn = ttk.Button(control_frame, text="ā®ļø Back", command=on_go_back) - next_btn = ttk.Button(control_frame, text="ā­ļø Next", command=on_skip_current) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit_auto_match) - - back_btn.grid(row=0, column=0, padx=(0, 5)) - next_btn.grid(row=0, column=1, padx=5) - quit_btn.grid(row=0, column=2, padx=(5, 0)) - - # Create save button now that functions are defined - save_btn = ttk.Button(left_frame, text="šŸ’¾ Save Changes", command=on_confirm_matches) - save_btn.grid(row=4, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) - - def update_button_states(): - """Update button states based on current position""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - # Enable/disable Back button based on position - if current_matched_index > 0: - back_btn.config(state='normal') - else: - back_btn.config(state='disabled') - - # Enable/disable Next button based on position - if current_matched_index < len(active_ids) - 1: - next_btn.config(state='normal') - else: - next_btn.config(state='disabled') - - def update_save_button_text(): - """Update save button text with current person name""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - matched_id = active_ids[current_matched_index] - # Get person name from the first match for this person - matches_for_current_person = matches_by_matched[matched_id] - if matches_for_current_person: - person_id = matches_for_current_person[0]['person_id'] - # Use cached person name instead of database query - person_details = data_cache['person_details'].get(person_id, {}) - person_name = person_details.get('full_name', "Unknown") - save_btn.config(text=f"šŸ’¾ Save changes for {person_name}") - else: - save_btn.config(text="šŸ’¾ Save Changes") - else: - save_btn.config(text="šŸ’¾ Save Changes") - - def save_current_checkbox_states(): - """Save current checkbox states for the current person. - Note: Do NOT modify original states here to avoid false positives - when a user toggles and reverts a checkbox. - """ - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids) and match_vars: - current_matched_id = active_ids[current_matched_index] - matches_for_current_person = matches_by_matched[current_matched_id] - - if len(match_vars) == len(matches_for_current_person): - if current_matched_id not in checkbox_states_per_person: - checkbox_states_per_person[current_matched_id] = {} - - # Save current checkbox states for this person - for i, (match, var) in enumerate(zip(matches_for_current_person, match_vars)): - unique_key = f"{current_matched_id}_{match['unidentified_id']}" - current_value = var.get() - checkbox_states_per_person[current_matched_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Saved state for person {current_matched_id}, face {match['unidentified_id']}: {current_value}") - - def update_display(): - nonlocal current_matched_index - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index >= len(active_ids): - finish_auto_match() - return - - matched_id = active_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Update button states - update_button_states() - - # Update save button text with person name - update_save_button_text() - - # Update title - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - root.title(f"Auto-Match Face Identification - {current_matched_index + 1}/{len(active_ids)}") - - # 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}") - # No items on the right panel – disable Select All / Clear All - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - # Skip to next person if available - if current_matched_index < len(matched_ids) - 1: - current_matched_index += 1 - update_display() - else: - finish_auto_match() - return - - first_match = matches_for_this_person[0] - - # Use cached data instead of database queries - person_details = 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 = 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 - matched_info_label.config(text="\n".join(person_info_lines)) - - # Display matched person face - matched_canvas.delete("all") - if show_faces: - 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) - matched_canvas.create_image(150, 150, image=photo) - matched_canvas.image = photo - - # Add photo icon to the matched person face - exactly in corner - # Compute top-left of the centered image for accurate placement - 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(matched_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: - matched_canvas.create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") - else: - matched_canvas.create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") - - # Clear and populate unidentified faces - matches_canvas.delete("all") - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - - # Create frame for unidentified faces inside canvas - matches_inner_frame = ttk.Frame(matches_canvas) - matches_canvas.create_window((0, 0), window=matches_inner_frame, anchor="nw") - - # Use cached photo paths instead of database queries - photo_paths = 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 checkbox_states_per_person and unique_key in checkbox_states_per_person[matched_id]: - saved_state = checkbox_states_per_person[matched_id][unique_key] - match_var.set(saved_state) - if self.verbose >= 2: - print(f"DEBUG: Restored state for person {matched_id}, face {match['unidentified_id']}: {saved_state}") - # Otherwise, pre-select if this face was previously identified for this person - elif matched_id in identified_faces_per_person and match['unidentified_id'] in identified_faces_per_person[matched_id]: - match_var.set(True) - if self.verbose >= 2: - print(f"DEBUG: Pre-selected identified face for person {matched_id}, face {match['unidentified_id']}") - - match_vars.append(match_var) - - # Capture original state at render time (once per person per face) - if matched_id not in original_checkbox_states_per_person: - original_checkbox_states_per_person[matched_id] = {} - if unique_key not in original_checkbox_states_per_person[matched_id]: - 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 checkbox_states_per_person: - checkbox_states_per_person[person_id] = {} - - current_value = var.get() - checkbox_states_per_person[person_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Checkbox changed for person {person_id}, face {face_id}: {current_value}") - - # Bind the callback to the variable (avoid late-binding by capturing ids) - 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 without text - checkbox = ttk.Checkbutton(match_frame, variable=match_var) - checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) - match_checkboxes.append(checkbox) - - # Unidentified face image now immediately after checkbox (right panel face to the right of checkbox) - match_canvas = None - if show_faces: - 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 exactly at top-right of the face area - 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 to the right of image - 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 after populating - update_match_control_buttons_state() - - # Update scroll region - matches_canvas.update_idletasks() - matches_canvas.configure(scrollregion=matches_canvas.bbox("all")) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Wire up search controls now that helper functions exist - try: - search_btn.config(command=lambda: apply_last_name_filter()) - clear_btn.config(command=lambda: clear_last_name_filter()) - search_entry.bind('', lambda e: apply_last_name_filter()) - except Exception: - pass - - # Start with first matched person - update_display() - - # Main event loop - try: - root.mainloop() - except tk.TclError: - pass # Window was destroyed - - return identified_count - - 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 - diff --git a/archive/dashboard_gui_backup.py b/archive/dashboard_gui_backup.py deleted file mode 100644 index 8a0c431..0000000 --- a/archive/dashboard_gui_backup.py +++ /dev/null @@ -1,2885 +0,0 @@ -#!/usr/bin/env python3 -""" -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 SearchPanel: - """Search panel with full functionality from search_gui.py""" - - SEARCH_TYPES = [ - "Search photos by name", - "Search photos by date", - "Search photos by tags", - "Search photos by multiple people (planned)", - "Most common tags (planned)", - "Most photographed people (planned)", - "Photos without faces", - "Photos without tags", - "Duplicate faces (planned)", - "Face quality distribution (planned)", - ] - - def __init__(self, parent_frame, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, tag_manager: TagManager = None, verbose: int = 0): - self.parent_frame = parent_frame - self.db = db_manager - self.search_stats = search_stats - self.gui_core = gui_core - self.tag_manager = tag_manager or TagManager(db_manager, verbose) - self.verbose = verbose - - # Sorting state - self.sort_column = None - self.sort_reverse = False - - # Selection tracking - self.selected_photos = {} # photo_path -> photo_data - - # Cache for photo tags to avoid database access during updates - self.photo_tags_cache = {} # photo_path -> list of tag names - - def create_panel(self) -> ttk.Frame: - """Create the search panel with all functionality""" - panel = ttk.Frame(self.parent_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: results area (row 3) should expand, buttons (row 4) should not - panel.rowconfigure(3, weight=1) - panel.rowconfigure(4, weight=0) - - # Search type selector - type_frame = ttk.Frame(panel) - type_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - type_frame.columnconfigure(1, weight=1) - - ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky=tk.W) - self.search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0]) - type_combo = ttk.Combobox(type_frame, textvariable=self.search_type_var, values=self.SEARCH_TYPES, state="readonly") - type_combo.grid(row=0, column=1, padx=(8, 0), sticky=(tk.W, tk.E)) - - # Filters area with expand/collapse functionality - filters_container = ttk.LabelFrame(panel, text="", padding="8") - filters_container.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - filters_container.columnconfigure(0, weight=1) - - # Filters header with toggle text - filters_header = ttk.Frame(filters_container) - filters_header.grid(row=0, column=0, sticky=(tk.W, tk.E)) - - # Toggle text for expand/collapse - self.filters_expanded = tk.BooleanVar(value=False) # Start collapsed - - def toggle_filters(): - if self.filters_expanded.get(): - # Collapse filters - filters_content.grid_remove() - toggle_text.config(text="+") - self.filters_expanded.set(False) - update_toggle_tooltip() - else: - # Expand filters - filters_content.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0)) - toggle_text.config(text="-") - self.filters_expanded.set(True) - update_toggle_tooltip() - - def update_toggle_tooltip(): - """Update tooltip text based on current state""" - if self.filters_expanded.get(): - tooltip_text = "Click to collapse filters" - else: - tooltip_text = "Click to expand filters" - toggle_text.tooltip_text = tooltip_text - - filters_label = ttk.Label(filters_header, text="Filters", font=("Arial", 10, "bold")) - filters_label.grid(row=0, column=0, sticky=tk.W) - - toggle_text = ttk.Label(filters_header, text="+", font=("Arial", 10, "bold"), cursor="hand2") - toggle_text.grid(row=0, column=1, padx=(6, 0)) - toggle_text.bind("", lambda e: toggle_filters()) - - # Initialize tooltip - toggle_text.tooltip_text = "Click to expand filters" - update_toggle_tooltip() - - # Filters content area (start hidden) - filters_content = ttk.Frame(filters_container) - filters_content.columnconfigure(0, weight=1) - - # Folder location filter - folder_filter_frame = ttk.Frame(filters_content) - folder_filter_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 4)) - folder_filter_frame.columnconfigure(1, weight=1) - - ttk.Label(folder_filter_frame, text="Folder location:").grid(row=0, column=0, sticky=tk.W) - self.folder_var = tk.StringVar() - folder_entry = ttk.Entry(folder_filter_frame, textvariable=self.folder_var, width=40) - folder_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E)) - ttk.Label(folder_filter_frame, text="(optional - filter by folder path)").grid(row=0, column=2, padx=(6, 0)) - - # Browse button for folder selection - def browse_folder(): - from tkinter import filedialog - from path_utils import normalize_path - folder_path = filedialog.askdirectory(title="Select folder to filter by") - if folder_path: - try: - # Normalize to absolute path - normalized_path = normalize_path(folder_path) - self.folder_var.set(normalized_path) - except ValueError as e: - messagebox.showerror("Invalid Path", f"Invalid folder path: {e}", parent=panel) - - browse_btn = ttk.Button(folder_filter_frame, text="Browse", command=browse_folder) - browse_btn.grid(row=0, column=3, padx=(6, 0)) - - # Clear folder filter button - def clear_folder_filter(): - self.folder_var.set("") - - clear_folder_btn = ttk.Button(folder_filter_frame, text="Clear", command=clear_folder_filter) - clear_folder_btn.grid(row=0, column=4, padx=(6, 0)) - - # Apply filters button - apply_filters_btn = ttk.Button(filters_content, text="Apply filters", command=lambda: self.do_search()) - apply_filters_btn.grid(row=1, column=0, pady=(8, 0)) - - # Inputs area - inputs = ttk.Frame(panel) - inputs.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - inputs.columnconfigure(0, weight=1) - - # Name search input - self.name_frame = ttk.Frame(inputs) - ttk.Label(self.name_frame, text="Person name:").grid(row=0, column=0, sticky=tk.W) - self.name_var = tk.StringVar() - self.name_entry = ttk.Entry(self.name_frame, textvariable=self.name_var) - self.name_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E)) - self.name_frame.columnconfigure(1, weight=1) - - # Tag search input - self.tag_frame = ttk.Frame(inputs) - ttk.Label(self.tag_frame, text="Tags:").grid(row=0, column=0, sticky=tk.W) - self.tag_var = tk.StringVar() - self.tag_entry = ttk.Entry(self.tag_frame, textvariable=self.tag_var) - self.tag_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E)) - self.tag_frame.columnconfigure(1, weight=1) - - # Help icon for available tags - self.tag_help_icon = ttk.Label(self.tag_frame, text="ā“", font=("Arial", 10), cursor="hand2") - self.tag_help_icon.grid(row=0, column=2, padx=(6, 0)) - - ttk.Label(self.tag_frame, text="(comma-separated)").grid(row=0, column=3, padx=(6, 0)) - - # Tag search mode - self.tag_mode_frame = ttk.Frame(inputs) - ttk.Label(self.tag_mode_frame, text="Match mode:").grid(row=0, column=0, sticky=tk.W) - self.tag_mode_var = tk.StringVar(value="ANY") - self.tag_mode_combo = ttk.Combobox(self.tag_mode_frame, textvariable=self.tag_mode_var, - values=["ANY", "ALL"], state="readonly", width=8) - self.tag_mode_combo.grid(row=0, column=1, padx=(6, 0)) - ttk.Label(self.tag_mode_frame, text="(ANY = photos with any tag, ALL = photos with all tags)").grid(row=0, column=2, padx=(6, 0)) - - # Date search inputs - self.date_frame = ttk.Frame(inputs) - ttk.Label(self.date_frame, text="From date:").grid(row=0, column=0, sticky=tk.W) - self.date_from_var = tk.StringVar() - self.date_from_entry = ttk.Entry(self.date_frame, textvariable=self.date_from_var, width=12, state="readonly") - self.date_from_entry.grid(row=0, column=1, padx=(6, 0)) - - # Calendar button for date from - def open_calendar_from(): - current_date = self.date_from_var.get() - selected_date = self.gui_core.create_calendar_dialog(panel, "Select From Date", current_date) - if selected_date is not None: - self.date_from_var.set(selected_date) - - self.date_from_btn = ttk.Button(self.date_frame, text="šŸ“…", width=3, command=open_calendar_from) - self.date_from_btn.grid(row=0, column=2, padx=(6, 0)) - ttk.Label(self.date_frame, text="(YYYY-MM-DD)").grid(row=0, column=3, padx=(6, 0)) - - self.date_to_frame = ttk.Frame(inputs) - ttk.Label(self.date_to_frame, text="To date:").grid(row=0, column=0, sticky=tk.W) - self.date_to_var = tk.StringVar() - self.date_to_entry = ttk.Entry(self.date_to_frame, textvariable=self.date_to_var, width=12, state="readonly") - self.date_to_entry.grid(row=0, column=1, padx=(6, 0)) - - # Calendar button for date to - def open_calendar_to(): - current_date = self.date_to_var.get() - selected_date = self.gui_core.create_calendar_dialog(panel, "Select To Date", current_date) - if selected_date is not None: - self.date_to_var.set(selected_date) - - self.date_to_btn = ttk.Button(self.date_to_frame, text="šŸ“…", width=3, command=open_calendar_to) - self.date_to_btn.grid(row=0, column=2, padx=(6, 0)) - ttk.Label(self.date_to_frame, text="(YYYY-MM-DD, optional)").grid(row=0, column=3, padx=(6, 0)) - - # Planned inputs (stubs) - self.planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888") - - # Results area - results_frame = ttk.Frame(panel) - results_frame.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10)) - results_frame.columnconfigure(0, weight=1) - results_frame.rowconfigure(1, weight=1) - - # Results header with count - results_header = ttk.Frame(results_frame) - results_header.grid(row=0, column=0, sticky=(tk.W, tk.E)) - results_label = ttk.Label(results_header, text="Results:", font=("Arial", 10, "bold")) - results_label.grid(row=0, column=0, sticky=tk.W) - self.results_count_label = ttk.Label(results_header, text="(0 items)", font=("Arial", 10), foreground="gray") - self.results_count_label.grid(row=0, column=1, padx=(6, 0)) - - columns = ("select", "person", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - self.tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse") - self.tree.heading("select", text="ā˜‘") - self.tree.heading("person", text="Person", command=lambda: self.sort_treeview("person")) - self.tree.heading("tags", text="Tags", command=lambda: self.sort_treeview("tags")) - self.tree.heading("processed", text="Processed", command=lambda: self.sort_treeview("processed")) - self.tree.heading("open_dir", text="šŸ“") - self.tree.heading("open_photo", text="šŸ‘¤") - self.tree.heading("path", text="Photo path", command=lambda: self.sort_treeview("path")) - self.tree.heading("date_taken", text="Date Taken", command=lambda: self.sort_treeview("date_taken")) - self.tree.column("select", width=50, anchor="center") - self.tree.column("person", width=180, anchor="w") - self.tree.column("tags", width=200, anchor="w") - self.tree.column("processed", width=80, anchor="center") - self.tree.column("open_dir", width=50, anchor="center") - self.tree.column("open_photo", width=50, anchor="center") - self.tree.column("path", width=400, anchor="w") - self.tree.column("date_taken", width=100, anchor="center") - - # Add vertical scrollbar for the treeview - tree_v_scrollbar = ttk.Scrollbar(results_frame, orient="vertical", command=self.tree.yview) - self.tree.configure(yscrollcommand=tree_v_scrollbar.set) - - # Pack treeview and scrollbar - self.tree.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(4, 0)) - tree_v_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S), pady=(4, 0)) - - # Buttons - btns = ttk.Frame(panel) - btns.grid(row=4, column=0, sticky=(tk.W, tk.E), pady=(8, 0)) - search_btn = ttk.Button(btns, text="Search", command=lambda: self.do_search()) - search_btn.grid(row=0, column=0, sticky=tk.W) - tag_btn = ttk.Button(btns, text="Tag selected photos", command=lambda: self.tag_selected_photos()) - tag_btn.grid(row=0, column=1, padx=(6, 0), sticky=tk.W) - clear_btn = ttk.Button(btns, text="Clear all selected", command=lambda: self.clear_all_selected()) - clear_btn.grid(row=0, column=2, padx=(6, 0), sticky=tk.W) - - # Set up event handlers - type_combo.bind("<>", self.switch_inputs) - self.switch_inputs() - self.tree.bind("", self.on_tree_click) - self.tree.bind("", self.on_tree_motion) - self.tree.bind("", self.hide_tooltip) - - # Enter key bindings - self.name_entry.bind("", lambda e: self.do_search()) - self.tag_entry.bind("", lambda e: self.do_search()) - folder_entry.bind("", lambda e: self.do_search()) - - # Initialize tooltip system - self.tooltip = None - - # Set up help icon tooltip - self._setup_help_icon_tooltip() - - return panel - - def _setup_help_icon_tooltip(self): - """Set up tooltip for the help icon""" - def show_available_tags_tooltip(event): - # Get all available tags from database - try: - tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings() - available_tags = sorted(tag_name_to_id.keys()) - - if available_tags: - # Create tooltip with tags in a column format - tag_list = "\n".join(available_tags) - tooltip_text = f"Available tags:\n{tag_list}" - else: - tooltip_text = "No tags available in database" - - self.show_tooltip(self.tag_help_icon, event.x_root, event.y_root, tooltip_text) - except Exception: - self.show_tooltip(self.tag_help_icon, event.x_root, event.y_root, "Error loading tags") - - # Bind tooltip events to help icon - self.tag_help_icon.bind("", show_available_tags_tooltip) - self.tag_help_icon.bind("", self.hide_tooltip) - - def switch_inputs(self, *_): - """Switch input fields based on search type""" - # Clear results when search type changes - self.clear_results() - - for w in self.name_frame.master.winfo_children(): - if w != self.name_frame.master: # Don't hide the inputs frame itself - w.grid_remove() - - choice = self.search_type_var.get() - if choice == self.SEARCH_TYPES[0]: # Search photos by name - self.name_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - self.name_entry.configure(state="normal") - self.tag_entry.configure(state="disabled") - self.tag_mode_combo.configure(state="disabled") - self.date_from_entry.configure(state="disabled") - self.date_to_entry.configure(state="disabled") - self.date_from_btn.configure(state="disabled") - self.date_to_btn.configure(state="disabled") - # Show person column for name search - self.tree.column("person", width=180, minwidth=50, anchor="w") - self.tree.heading("person", text="Person", command=lambda: self.sort_treeview("person")) - # Restore people icon column for name search - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Restore all columns to display (hide processed column for name search) - self.tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[1]: # Search photos by date - self.date_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - self.date_to_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0)) - self.name_entry.configure(state="disabled") - self.tag_entry.configure(state="disabled") - self.tag_mode_combo.configure(state="disabled") - self.date_from_entry.configure(state="readonly") - self.date_to_entry.configure(state="readonly") - self.date_from_btn.configure(state="normal") - self.date_to_btn.configure(state="normal") - # Hide person column for date search - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Restore people icon column for date search - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for date search - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[2]: # Search photos by tags - self.tag_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - self.tag_mode_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0)) - self.name_entry.configure(state="disabled") - self.tag_entry.configure(state="normal") - self.tag_mode_combo.configure(state="readonly") - self.date_from_entry.configure(state="disabled") - self.date_to_entry.configure(state="disabled") - self.date_from_btn.configure(state="disabled") - self.date_to_btn.configure(state="disabled") - # Hide person column completely for tag search - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Restore people icon column for tag search - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Also hide the column from display - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[6]: # Photos without faces - # No input needed for this search type - # Hide person column since photos without faces won't have person info - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Hide the people icon column since there are no faces/people - self.tree.column("open_photo", width=0, minwidth=0, anchor="center") - self.tree.heading("open_photo", text="") - # Also hide the columns from display - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "path", "date_taken") - # Auto-run search for photos without faces - self.do_search() - elif choice == self.SEARCH_TYPES[7]: # Photos without tags - # No input needed for this search type - # Hide person column for photos without tags search - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Show the people icon column since there might be faces/people - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for photos without tags search - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - # Auto-run search for photos without tags - self.do_search() - else: - self.planned_label.grid(row=0, column=0, sticky=tk.W) - self.name_entry.configure(state="disabled") - self.tag_entry.configure(state="disabled") - self.tag_mode_combo.configure(state="disabled") - self.date_from_entry.configure(state="disabled") - self.date_to_entry.configure(state="disabled") - self.date_from_btn.configure(state="disabled") - self.date_to_btn.configure(state="disabled") - # Hide person column for other search types - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Restore people icon column for other search types - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for other search types - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - - def filter_results_by_folder(self, results, folder_path): - """Filter search results by folder path if specified.""" - if not folder_path or not folder_path.strip(): - return results - - folder_path = folder_path.strip() - filtered_results = [] - - for result in results: - if len(result) >= 1: - # Extract photo path from result tuple (always at index 0) - photo_path = result[0] - - # Check if photo path starts with the specified folder path - if photo_path.startswith(folder_path): - filtered_results.append(result) - - return filtered_results - - def clear_results(self): - """Clear all results from the treeview""" - for i in self.tree.get_children(): - self.tree.delete(i) - # Reset sorting state for new search - self.sort_column = None - self.sort_reverse = False - # Clear selection tracking - self.selected_photos.clear() - # Clear tag cache - self.photo_tags_cache.clear() - # Reset results count - self.results_count_label.config(text="(0 items)") - self.update_header_display() - - def add_results(self, rows): - """Add search results to the treeview""" - # rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search or List[(path, date_taken)] for date search - for row in rows: - if len(row) == 2: - if self.search_type_var.get() == self.SEARCH_TYPES[1]: # Date search - # For date search: (path, date_taken) - hide person column - path, date_taken = row - photo_tags = self.get_photo_tags_for_display(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - elif self.search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search - # For tag search: (path, tag_info) - hide person column - # Show ALL tags for the photo, not just matching ones - path, tag_info = row - photo_tags = self.get_photo_tags_for_display(path) - date_taken = self.get_photo_date_taken(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - elif self.search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces - # For photos without faces: (path, tag_info) - hide person and people icon columns - path, tag_info = row - photo_tags = self.get_photo_tags_for_display(path) - date_taken = self.get_photo_date_taken(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "", path, date_taken)) - elif self.search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags - # For photos without tags: (path, filename) - hide person column - path, filename = row - photo_tags = self.get_photo_tags_for_display(path) # Will be "No tags" - date_taken = self.get_photo_date_taken(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - else: - # For name search: (path, full_name) - show person column - p, full_name = row - # Get tags for this photo - photo_tags = self.get_photo_tags_for_display(p) - date_taken = self.get_photo_date_taken(p) - processed_status = self.get_photo_processed_status(p) - self.tree.insert("", tk.END, values=("☐", full_name, photo_tags, processed_status, "šŸ“", "šŸ‘¤", p, date_taken)) - - # Sort by appropriate column by default when results are first loaded - if rows and self.sort_column is None: - if self.search_type_var.get() == self.SEARCH_TYPES[1]: # Date search - # Sort by date_taken column for date search - self.sort_column = "date_taken" - elif self.search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search - # Sort by tags column for tag search - self.sort_column = "tags" - elif self.search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces - # Sort by path column for photos without faces - self.sort_column = "path" - elif self.search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags - # Sort by path column for photos without tags (person column is hidden) - self.sort_column = "path" - else: - # Sort by person column for name search - self.sort_column = "person" - - self.sort_reverse = False - # Get all items and sort them directly - items = [(self.tree.set(child, self.sort_column), child) for child in self.tree.get_children('')] - if self.sort_column == 'date_taken': - # Sort by date, handling "No date" entries - def date_sort_key(item): - date_str = item[0] - if date_str == "No date": - return "9999-12-31" # Put "No date" entries at the end - return date_str - items.sort(key=date_sort_key, reverse=False) # Ascending - else: - items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending - # Reorder items in treeview - for index, (val, child) in enumerate(items): - self.tree.move(child, '', index) - # Update header display - self.update_header_display() - - # Update results count - item_count = len(self.tree.get_children()) - self.results_count_label.config(text=f"({item_count} items)") - - def do_search(self): - """Perform the search based on current search type and parameters""" - self.clear_results() - choice = self.search_type_var.get() - folder_filter = self.folder_var.get().strip() - - if choice == self.SEARCH_TYPES[0]: # Search photos by name - query = self.name_var.get().strip() - if not query: - messagebox.showinfo("Search", "Please enter a name to search.", parent=self.parent_frame) - return - rows = self.search_stats.search_faces(query) - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found for '{query}'{folder_msg}.", parent=self.parent_frame) - self.add_results(rows) - elif choice == self.SEARCH_TYPES[1]: # Search photos by date - date_from = self.date_from_var.get().strip() - date_to = self.date_to_var.get().strip() - - # Validate date format if provided - if date_from: - try: - from datetime import datetime - datetime.strptime(date_from, '%Y-%m-%d') - except ValueError: - messagebox.showerror("Invalid Date", "From date must be in YYYY-MM-DD format.", parent=self.parent_frame) - return - - if date_to: - try: - from datetime import datetime - datetime.strptime(date_to, '%Y-%m-%d') - except ValueError: - messagebox.showerror("Invalid Date", "To date must be in YYYY-MM-DD format.", parent=self.parent_frame) - return - - # Check if at least one date is provided - if not date_from and not date_to: - messagebox.showinfo("Search", "Please enter at least one date (from date or to date).", parent=self.parent_frame) - return - - rows = self.search_stats.search_photos_by_date(date_from if date_from else None, - date_to if date_to else None) - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - date_range_text = "" - if date_from and date_to: - date_range_text = f" between {date_from} and {date_to}" - elif date_from: - date_range_text = f" from {date_from}" - elif date_to: - date_range_text = f" up to {date_to}" - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found{date_range_text}{folder_msg}.", parent=self.parent_frame) - else: - # Convert to the format expected by add_results: (path, date_taken) - formatted_rows = [(path, date_taken) for path, date_taken in rows] - self.add_results(formatted_rows) - elif choice == self.SEARCH_TYPES[2]: # Search photos by tags - tag_query = self.tag_var.get().strip() - if not tag_query: - messagebox.showinfo("Search", "Please enter tags to search for.", parent=self.parent_frame) - return - - # Parse comma-separated tags - tags = [tag.strip() for tag in tag_query.split(',') if tag.strip()] - if not tags: - messagebox.showinfo("Search", "Please enter valid tags to search for.", parent=self.parent_frame) - return - - # Determine match mode - match_all = (self.tag_mode_var.get() == "ALL") - - rows = self.search_stats.search_photos_by_tags(tags, match_all) - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - mode_text = "all" if match_all else "any" - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found with {mode_text} of the tags: {', '.join(tags)}{folder_msg}", parent=self.parent_frame) - self.add_results(rows) - elif choice == self.SEARCH_TYPES[6]: # Photos without faces - rows = self.search_stats.get_photos_without_faces() - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos without faces found{folder_msg}.", parent=self.parent_frame) - else: - # Convert to the format expected by add_results: (path, tag_info) - # For photos without faces, we don't have person info, so we use empty string - formatted_rows = [(path, "") for path, filename in rows] - self.add_results(formatted_rows) - elif choice == self.SEARCH_TYPES[7]: # Photos without tags - rows = self.search_stats.get_photos_without_tags() - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos without tags found{folder_msg}.", parent=self.parent_frame) - else: - # Convert to the format expected by add_results: (path, filename) - # For photos without tags, we have both path and filename - formatted_rows = [(path, filename) for path, filename in rows] - self.add_results(formatted_rows) - - def sort_treeview(self, col: str): - """Sort the treeview by the specified column.""" - # Get all items and their values - items = [(self.tree.set(child, col), child) for child in self.tree.get_children('')] - - # Determine sort direction - if self.sort_column == col: - # Same column clicked - toggle direction - self.sort_reverse = not self.sort_reverse - else: - # Different column clicked - start with ascending - self.sort_reverse = False - self.sort_column = col - - # Sort the items - # For person, tags, and path columns, sort alphabetically - # For date_taken column, sort by date - # For processed column, sort by processed status (Yes/No) - # For icon columns, maintain original order - if col in ['person', 'tags', 'path']: - items.sort(key=lambda x: x[0].lower(), reverse=self.sort_reverse) - elif col == 'date_taken': - # Sort by date, handling "No date" entries - def date_sort_key(item): - date_str = item[0] - if date_str == "No date": - return "9999-12-31" # Put "No date" entries at the end - return date_str - items.sort(key=date_sort_key, reverse=self.sort_reverse) - elif col == 'processed': - # Sort by processed status (Yes comes before No) - def processed_sort_key(item): - processed_str = item[0] - if processed_str == "Yes": - return "0" # Yes comes first - else: - return "1" # No comes second - items.sort(key=processed_sort_key, reverse=self.sort_reverse) - else: - # For icon columns, just reverse if clicking same column - if self.sort_column == col and self.sort_reverse: - items.reverse() - - # Reorder items in treeview - for index, (val, child) in enumerate(items): - self.tree.move(child, '', index) - - # Update header display - self.update_header_display() - - def update_header_display(self): - """Update header display to show sort indicators.""" - # Reset all headers - self.tree.heading("person", text="Person") - self.tree.heading("tags", text="Tags") - self.tree.heading("processed", text="Processed") - self.tree.heading("path", text="Photo path") - self.tree.heading("date_taken", text="Date Taken") - - # Add sort indicator to current sort column - if self.sort_column == "person": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("person", text="Person" + indicator) - elif self.sort_column == "tags": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("tags", text="Tags" + indicator) - elif self.sort_column == "processed": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("processed", text="Processed" + indicator) - elif self.sort_column == "path": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("path", text="Photo path" + indicator) - elif self.sort_column == "date_taken": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("date_taken", text="Date Taken" + indicator) - - def on_tree_click(self, event): - """Handle clicks on the treeview""" - region = self.tree.identify("region", event.x, event.y) - if region != "cell": - return - row_id = self.tree.identify_row(event.y) - col_id = self.tree.identify_column(event.x) # '#1', '#2', ... - if not row_id or not col_id: - return - vals = self.tree.item(row_id, "values") - if not vals or len(vals) < 6: - return - - # Determine column offsets based on search type - is_name_search = (self.search_type_var.get() == self.SEARCH_TYPES[0]) - is_photos_without_faces = (self.search_type_var.get() == self.SEARCH_TYPES[6]) - - if is_name_search: - # Name search: all columns visible including person (processed column hidden) - select_col = "#1" # select is column 1 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array (still same since processed is hidden from display) - elif is_photos_without_faces: - # Photos without faces: person and people icon columns are hidden - select_col = "#1" # select is column 1 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 (but hidden) - path_col = "#5" # path is column 5 (since people icon is hidden) - path_index = 6 # path is at index 6 in values array - else: - # All other searches: person column is hidden, people icon visible - select_col = "#1" # select is column 1 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array - - path = vals[path_index] # Photo path - if col_id == open_dir_col: # Open directory column - self.open_dir(path) - elif col_id == face_col: # Face icon column - # No popup needed, just tooltip - pass - elif col_id == path_col: # Photo path column - clickable to open photo - try: - import os - import sys - if os.name == "nt": - os.startfile(path) # type: ignore[attr-defined] - elif sys.platform == "darwin": - import subprocess - subprocess.run(["open", path], check=False) - else: - import subprocess - subprocess.run(["xdg-open", path], check=False) - except Exception: - messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=self.parent_frame) - elif col_id == select_col: # Checkbox column - self.toggle_photo_selection(row_id, vals) - - def on_tree_motion(self, event): - """Handle mouse motion over the treeview for tooltips""" - region = self.tree.identify("region", event.x, event.y) - if region != "cell": - self.hide_tooltip() - self.tree.config(cursor="") - return - col_id = self.tree.identify_column(event.x) - row_id = self.tree.identify_row(event.y) - - # Determine column offsets based on search type - is_name_search = (self.search_type_var.get() == self.SEARCH_TYPES[0]) - is_photos_without_faces = (self.search_type_var.get() == self.SEARCH_TYPES[6]) - - if is_name_search: - # Name search: all columns visible including person (processed column hidden) - tags_col = "#3" # tags is column 3 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array (still same since processed is hidden from display) - elif is_photos_without_faces: - # Photos without faces: person and people icon columns are hidden - tags_col = "#2" # tags is column 2 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 (but hidden) - path_col = "#5" # path is column 5 (since people icon is hidden) - path_index = 6 # path is at index 6 in values array - else: - # All other searches: person column is hidden, people icon visible - tags_col = "#2" # tags is column 2 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array - - if col_id == tags_col: # Tags column - self.tree.config(cursor="") - # Show tags tooltip - if row_id: - vals = self.tree.item(row_id, "values") - if len(vals) >= 3: - # Tags are at index 2 for all search types (after select, person is hidden in most) - tags_text = vals[2] - self.show_tooltip(self.tree, event.x_root, event.y_root, f"Tags: {tags_text}") - elif col_id == open_dir_col: # Open directory column - self.tree.config(cursor="hand2") - self.show_tooltip(self.tree, event.x_root, event.y_root, "Open file location") - elif col_id == face_col: # Face icon column - self.tree.config(cursor="hand2") - # Show people tooltip - if row_id: - vals = self.tree.item(row_id, "values") - if len(vals) >= 5: - path = vals[path_index] - people_text = self.get_photo_people_tooltip(path) - self.show_tooltip(self.tree, event.x_root, event.y_root, people_text) - elif col_id == path_col: # Photo path column - self.tree.config(cursor="hand2") - self.show_tooltip(self.tree, event.x_root, event.y_root, "Open photo") - else: - self.tree.config(cursor="") - self.hide_tooltip() - - def show_tooltip(self, widget, x, y, text: str): - """Show a tooltip""" - self.hide_tooltip() - try: - self.tooltip = tk.Toplevel(widget) - self.tooltip.wm_overrideredirect(True) - self.tooltip.wm_geometry(f"+{x+12}+{y+12}") - lbl = tk.Label(self.tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9)) - lbl.pack() - except Exception: - self.tooltip = None - - def hide_tooltip(self, *_): - """Hide the current tooltip""" - if self.tooltip is not None: - try: - self.tooltip.destroy() - except Exception: - pass - self.tooltip = None - - def open_dir(self, path: str): - """Open the directory containing the photo""" - try: - import os - import sys - folder = os.path.dirname(path) - if os.name == "nt": - os.startfile(folder) # type: ignore[attr-defined] - elif sys.platform == "darwin": - import subprocess - subprocess.run(["open", folder], check=False) - else: - import subprocess - subprocess.run(["xdg-open", folder], check=False) - except Exception: - messagebox.showerror("Open Location", "Failed to open the file location.", parent=self.parent_frame) - - def toggle_photo_selection(self, row_id, vals): - """Toggle checkbox selection for a photo.""" - if len(vals) < 7: - return - current_state = vals[0] # Checkbox is now in column 0 (first) - path = vals[6] # Photo path is now in column 6 (last) - if current_state == "☐": - # Select photo - new_state = "ā˜‘" - self.selected_photos[path] = { - 'person': vals[1], # Person is now in column 1 - 'path': path - } - else: - # Deselect photo - new_state = "☐" - if path in self.selected_photos: - del self.selected_photos[path] - - # Update the treeview - new_vals = list(vals) - new_vals[0] = new_state - self.tree.item(row_id, values=new_vals) - - def tag_selected_photos(self): - """Open linkage dialog for selected photos.""" - if not self.selected_photos: - messagebox.showinfo("Tag Photos", "Please select photos to tag first.", parent=self.parent_frame) - return - - # Get photo IDs for selected photos - selected_photo_ids = [] - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for path in self.selected_photos.keys(): - cursor.execute('SELECT id FROM photos WHERE path = ?', (path,)) - result = cursor.fetchone() - if result: - selected_photo_ids.append(result[0]) - - if not selected_photo_ids: - messagebox.showerror("Tag Photos", "Could not find photo IDs for selected photos.", parent=self.parent_frame) - return - - # Open the linkage dialog - self.open_linkage_dialog(selected_photo_ids) - - def clear_all_selected(self): - """Clear all selected photos and update checkboxes.""" - if not self.selected_photos: - return - - # Clear the selection tracking - self.selected_photos.clear() - - # Update all checkboxes to unselected state - for item in self.tree.get_children(): - vals = self.tree.item(item, "values") - if len(vals) >= 7 and vals[0] == "ā˜‘": - new_vals = list(vals) - new_vals[0] = "☐" - self.tree.item(item, values=new_vals) - - def get_photo_tags_for_display(self, photo_path): - """Get tags for a photo to display in the tags column.""" - # Check cache first - if photo_path in self.photo_tags_cache: - tag_names = self.photo_tags_cache[photo_path] - else: - # Load from database and cache - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if not result: - return "No photo found" - - photo_id = result[0] - cursor.execute(''' - SELECT t.tag_name - FROM tags t - JOIN phototaglinkage ptl ON t.id = ptl.tag_id - WHERE ptl.photo_id = ? - ORDER BY t.tag_name - ''', (photo_id,)) - tag_names = [row[0] for row in cursor.fetchall()] - self.photo_tags_cache[photo_path] = tag_names - except Exception: - return "No tags" - - # Format for display - show all tags - if tag_names: - return ', '.join(tag_names) - else: - return "No tags" - - def get_photo_date_taken(self, photo_path): - """Get date_taken for a photo to display in the date_taken column.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT date_taken FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result and result[0]: - return result[0] # Return the date as stored in database - else: - return "No date" # No date_taken available - except Exception: - return "No date" - - def get_photo_processed_status(self, photo_path): - """Get processed status for a photo to display in the processed column.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT processed FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result and result[0] is not None: - return "Yes" if result[0] else "No" - else: - return "No" # Default to not processed - except Exception: - return "No" - - def get_photo_people_tooltip(self, photo_path): - """Get people information for a photo to display in tooltip.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT DISTINCT pe.first_name, pe.last_name, pe.middle_name, pe.maiden_name - FROM photos p - JOIN faces f ON p.id = f.photo_id - JOIN people pe ON f.person_id = pe.id - WHERE p.path = ? AND f.person_id IS NOT NULL - ORDER BY pe.last_name, pe.first_name - ''', (photo_path,)) - people = cursor.fetchall() - - if not people: - return "No people identified" - - people_names = [] - for person in people: - first = (person[0] or "").strip() - last = (person[1] or "").strip() - middle = (person[2] or "").strip() - maiden = (person[3] or "").strip() - - # Build full name - name_parts = [] - if first: - name_parts.append(first) - if middle: - name_parts.append(middle) - if last: - name_parts.append(last) - if maiden and maiden != last: - name_parts.append(f"({maiden})") - - full_name = " ".join(name_parts) if name_parts else "Unknown" - people_names.append(full_name) - - if people_names: - if len(people_names) <= 3: - return f"People: {', '.join(people_names)}" - else: - return f"People: {', '.join(people_names[:3])}... (+{len(people_names)-3} more)" - else: - return "No people identified" - except Exception: - pass - return "No people identified" - - def open_linkage_dialog(self, photo_ids): - """Open the linkage dialog for selected photos using tag manager functionality.""" - popup = tk.Toplevel(self.parent_frame) - popup.title("Tag Selected Photos") - popup.transient(self.parent_frame) - popup.grab_set() - popup.geometry("500x400") - popup.resizable(True, True) - - # Track tag changes for updating results - tags_added = set() # tag names that were added - tags_removed = set() # tag names that were removed - - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - popup.columnconfigure(0, weight=1) - popup.rowconfigure(1, weight=1) - - ttk.Label(top_frame, text=f"Selected Photos: {len(photo_ids)}").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6)) - ttk.Label(top_frame, text="Add tag:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W) - - # Get existing tags using tag manager - tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings() - existing_tags = sorted(tag_name_to_id.keys()) - - tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=tag_var, values=existing_tags, width=30) - combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - def add_selected_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - - # Resolve or create tag id (case-insensitive) - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in tag_name_to_id: - tag_id = tag_name_to_id[normalized_tag_name] - else: - # Create new tag in database using the database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - # Update mappings - tag_name_to_id[normalized_tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - # Update the combobox values to include the new tag - combo['values'] = existing_tags - else: - messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup) - return - - # Add tag to all selected photos with single linkage type (0) - affected = 0 - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - # Check if tag already exists for this photo - cursor.execute('SELECT linkage_id FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - if not cursor.fetchone(): - # Add the tag with single linkage type (0) - cursor.execute('INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) VALUES (?, ?, 0)', (photo_id, tag_id)) - affected += 1 - - # Track that this tag was added - if affected > 0: - tags_added.add(tag_name) - - # Refresh the tag list to show the new tag - refresh_tag_list() - tag_var.set("") - - ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=1, column=2, padx=(8, 0)) - - # Allow Enter key to add tag - combo.bind('', lambda e: add_selected_tag()) - - # Create scrollable tag list - canvas = tk.Canvas(list_frame, height=200) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - selected_tag_vars = {} - - def refresh_tag_list(): - for widget in scrollable_frame.winfo_children(): - widget.destroy() - selected_tag_vars.clear() - - # Get tags that exist in ALL selected photos - # First, get all tags for each photo - photo_tags = {} # photo_id -> set of tag_ids - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - photo_tags[photo_id] = set() - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,)) - for row in cursor.fetchall(): - photo_tags[photo_id].add(row[0]) - - # Find intersection - tags that exist in ALL selected photos - if not photo_tags: - ttk.Label(scrollable_frame, text="No tags linked to selected photos", foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Start with tags from first photo, then intersect with others - common_tag_ids = set(photo_tags[photo_ids[0]]) - for photo_id in photo_ids[1:]: - common_tag_ids = common_tag_ids.intersection(photo_tags[photo_id]) - - if not common_tag_ids: - ttk.Label(scrollable_frame, text="No common tags found across all selected photos", foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Get linkage type information for common tags - # For tags that exist in all photos, we need to determine the linkage type - # If a tag has different linkage types across photos, we'll show the most restrictive - common_tag_data = {} # tag_id -> {linkage_type, photo_count} - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id IN ({})'.format(','.join('?' * len(common_tag_ids))), [photo_id] + list(common_tag_ids)) - for row in cursor.fetchall(): - tag_id = row[0] - linkage_type = int(row[1]) if row[1] is not None else 0 - if tag_id not in common_tag_data: - common_tag_data[tag_id] = {'linkage_type': linkage_type, 'photo_count': 0} - common_tag_data[tag_id]['photo_count'] += 1 - # If we find a bulk linkage type (1), use that as it's more restrictive - if linkage_type == 1: - common_tag_data[tag_id]['linkage_type'] = 1 - - # Sort tags by name for consistent display - for tag_id in sorted(common_tag_data.keys()): - tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}") - var = tk.BooleanVar() - selected_tag_vars[tag_name] = var - frame = ttk.Frame(scrollable_frame) - frame.pack(fill=tk.X, pady=1) - - # Determine if this tag can be selected for deletion - # In single linkage dialog, only allow deleting single linkage type (0) tags - linkage_type = common_tag_data[tag_id]['linkage_type'] - can_select = (linkage_type == 0) # Only single linkage type can be deleted - - cb = ttk.Checkbutton(frame, variable=var) - if not can_select: - try: - cb.state(["disabled"]) # disable selection for bulk tags - except Exception: - pass - cb.pack(side=tk.LEFT, padx=(0, 5)) - - # Display tag name with status information - type_label = 'single' if linkage_type == 0 else 'bulk' - photo_count = common_tag_data[tag_id]['photo_count'] - status_text = f" (saved {type_label})" - status_color = "black" if can_select else "gray" - ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT) - - def remove_selected_tags(): - tag_ids_to_remove = [] - tag_names_to_remove = [] - for tag_name, var in selected_tag_vars.items(): - if var.get() and tag_name in tag_name_to_id: - tag_ids_to_remove.append(tag_name_to_id[tag_name]) - tag_names_to_remove.append(tag_name) - - if not tag_ids_to_remove: - return - - # Only remove single linkage type tags (bulk tags should be disabled anyway) - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - for tag_id in tag_ids_to_remove: - # Double-check that this is a single linkage type before deleting - cursor.execute('SELECT linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - result = cursor.fetchone() - if result and int(result[0]) == 0: # Only delete single linkage type - cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - - # Track that these tags were removed - tags_removed.update(tag_names_to_remove) - - refresh_tag_list() - - def update_search_results(): - """Update the search results to reflect tag changes without database access.""" - if not tags_added and not tags_removed: - return # No changes to apply - - # Get photo paths for the affected photos from selected_photos - affected_photo_paths = set(self.selected_photos.keys()) - - # Update cache for affected photos - for photo_path in affected_photo_paths: - if photo_path in self.photo_tags_cache: - # Update cached tags based on changes - current_tags = set(self.photo_tags_cache[photo_path]) - # Add new tags - current_tags.update(tags_added) - # Remove deleted tags - current_tags.difference_update(tags_removed) - # Update cache with sorted list - self.photo_tags_cache[photo_path] = sorted(list(current_tags)) - - # Update each affected row in the search results - for item in self.tree.get_children(): - vals = self.tree.item(item, "values") - if len(vals) >= 7: - photo_path = vals[6] # Photo path is at index 6 - if photo_path in affected_photo_paths: - # Get current tags for this photo from cache - current_tags = self.get_photo_tags_for_display(photo_path) - # Update the tags column (index 2) - new_vals = list(vals) - new_vals[2] = current_tags - self.tree.item(item, values=new_vals) - - def close_dialog(): - """Close dialog and update search results if needed.""" - update_search_results() - popup.destroy() - - ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8)) - ttk.Button(bottom_frame, text="Close", command=close_dialog).pack(side=tk.RIGHT) - refresh_tag_list() - - -class DashboardGUI: - """Unified Dashboard with menu bar and content area for all features. - - Designed to be web-migration friendly with clear separation between - navigation (menu bar) and content (panels). - """ - - def __init__(self, gui_core: GUICore, db_manager=None, face_processor=None, on_scan=None, on_process=None, on_identify=None, search_stats=None, tag_manager=None): - self.gui_core = gui_core - self.db_manager = db_manager - self.face_processor = face_processor - self.on_scan = on_scan - self.on_process = on_process - self.on_identify = on_identify - self.search_stats = search_stats - self.tag_manager = tag_manager - - # Panel management for future web migration - self.panels: Dict[str, ttk.Frame] = {} - self.current_panel: Optional[str] = None - self.root: Optional[tk.Tk] = None - - def open(self) -> int: - """Open the unified dashboard with menu bar and content area""" - self.root = tk.Tk() - self.root.title("PunimTag - Unified Dashboard") - self.root.resizable(True, True) - self.root.withdraw() - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - panel.columnconfigure(1, weight=2) - panel.rowconfigure(1, weight=1) - - # Left panel: People list - people_frame = ttk.LabelFrame(panel, text="People", padding="10") - people_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8)) - people_frame.columnconfigure(0, weight=1) - - # Search controls (Last Name) with label under the input (match auto-match style) - self.last_name_search_var = tk.StringVar() - 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.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.people_list_inner = ttk.Frame(people_canvas) - people_canvas.create_window((0, 0), window=self.people_list_inner, anchor="nw") - people_canvas.configure(yscrollcommand=people_scrollbar.set) - - self.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) - - # Right panel: Faces for selected person - faces_frame = ttk.LabelFrame(panel, text="Faces", padding="10") - faces_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) - faces_frame.columnconfigure(0, weight=1) - faces_frame.rowconfigure(0, weight=1) - - # Style configuration - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - # Match auto-match UI: set gray background for left canvas and remove highlight border - try: - people_canvas.configure(bg=canvas_bg_color, highlightthickness=0) - except Exception: - pass - self.faces_canvas = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0) - faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=self.faces_canvas.yview) - self.faces_inner = ttk.Frame(self.faces_canvas) - self.faces_canvas.create_window((0, 0), window=self.faces_inner, anchor="nw") - self.faces_canvas.configure(yscrollcommand=faces_scrollbar.set) - - self.faces_inner.bind( - "", - lambda e: self.faces_canvas.configure(scrollregion=self.faces_canvas.bbox("all")) - ) - - # Bind resize handler for responsive face grid - self.faces_canvas.bind("", self.on_faces_canvas_resize) - - self.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)) - - # Control buttons - control_frame = ttk.Frame(panel) - control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) - - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=self.on_quit) - quit_btn.pack(side=tk.RIGHT) - save_btn_bottom = ttk.Button(control_frame, text="šŸ’¾ Save changes", command=self.on_save_all_changes) - save_btn_bottom.pack(side=tk.RIGHT, padx=(0, 10)) - - # Bind Enter key for search - search_entry.bind('', lambda e: self.apply_last_name_filter()) - - # 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']) - - return panel - - 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.parent_frame.after_cancel(self.resize_job) - except Exception: - pass - self.resize_job = self.parent_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.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() - # Update right panel based on filtered results - source = self.people_filtered if self.people_filtered is not None else self.people_data - if source: - # Load faces for the first person in the list - first = source[0] - try: - # Update selection state - for child in self.people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Bold the first label if present - first_row = self.people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - # Show faces for the first person - self.show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - # No matches: clear faces panel - self.clear_faces_panel() - - def clear_last_name_filter(self): - """Clear last name filter""" - self.last_name_search_var.set("") - self.people_filtered = None - self.populate_people_list() - # After clearing, load faces for the first available person if any - if self.people_data: - first = self.people_data[0] - try: - for child in self.people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - first_row = self.people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - self.show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - self.clear_faces_panel() - - def clear_faces_panel(self): - """Clear the faces panel and cleanup resources""" - for w in self.faces_inner.winfo_children(): - w.destroy() - # Cleanup temp crops - for crop in list(self.temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - self.temp_crops.clear() - self.right_panel_images.clear() - - def unmatch_face(self, face_id: int): - """Temporarily unmatch a face from the current person""" - self.unmatched_faces.add(face_id) - # Track per-person for Undo - person_set = self.unmatched_by_person.get(self.current_person_id) - if person_set is None: - person_set = set() - self.unmatched_by_person[self.current_person_id] = person_set - person_set.add(face_id) - # Refresh the display - self.show_person_faces(self.current_person_id, self.current_person_name) - - def undo_changes(self): - """Undo all temporary changes for current person""" - if self.current_person_id in self.unmatched_by_person: - for fid in list(self.unmatched_by_person[self.current_person_id]): - self.unmatched_faces.discard(fid) - self.unmatched_by_person[self.current_person_id].clear() - # Refresh the display - self.show_person_faces(self.current_person_id, self.current_person_name) - - def show_person_faces(self, person_id: int, person_name: str): - """Show faces for the selected person""" - self.current_person_id = person_id - self.current_person_name = person_name - self.clear_faces_panel() - - # Determine how many columns fit the available width - available_width = self.faces_canvas.winfo_width() - if available_width <= 1: - available_width = 400 # Default width if canvas not yet rendered - tile_width = 150 # approx tile + padding - cols = max(1, available_width // tile_width) - - # Header row - header = ttk.Label(self.faces_inner, text=f"Faces for: {person_name}", font=("Arial", 12, "bold")) - header.grid(row=0, column=0, columnspan=cols, sticky=tk.W, pady=(0, 5)) - - # Control buttons row - button_frame = ttk.Frame(self.faces_inner) - button_frame.grid(row=1, column=0, columnspan=cols, sticky=tk.W, pady=(0, 10)) - - # Enable Undo only if current person has unmatched faces - current_has_unmatched = bool(self.unmatched_by_person.get(self.current_person_id)) - undo_btn = ttk.Button(button_frame, text="↶ Undo changes", - command=lambda: self.undo_changes(), - state="disabled" if not current_has_unmatched else "normal") - undo_btn.pack(side=tk.LEFT, padx=(0, 10)) - - # Query faces for this person - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """ - SELECT f.id, f.location, ph.path, ph.filename - FROM faces f - JOIN photos ph ON ph.id = f.photo_id - WHERE f.person_id = ? - ORDER BY f.id DESC - """, - (person_id,) - ) - rows = cursor.fetchall() - - # Filter out unmatched faces - visible_rows = [row for row in rows if row[0] not in self.unmatched_faces] - - if not visible_rows: - ttk.Label(self.faces_inner, text="No faces found." if not rows else "All faces unmatched.", foreground="gray").grid(row=2, column=0, sticky=tk.W) - return - - # Grid thumbnails with responsive column count - row_index = 2 # Start after header and buttons - col_index = 0 - for face_id, location, photo_path, filename in visible_rows: - crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) - thumb = None - if crop_path and os.path.exists(crop_path): - try: - from PIL import Image, ImageTk - img = Image.open(crop_path) - img.thumbnail((130, 130), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - self.temp_crops.append(crop_path) - self.right_panel_images.append(photo_img) - thumb = photo_img - except Exception: - thumb = None - - tile = ttk.Frame(self.faces_inner, padding="5") - tile.grid(row=row_index, column=col_index, padx=5, pady=5, sticky=tk.N) - - # Create a frame for the face image with X button overlay - face_frame = ttk.Frame(tile) - face_frame.grid(row=0, column=0) - - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - canvas = tk.Canvas(face_frame, width=130, height=130, bg=canvas_bg_color, highlightthickness=0) - canvas.grid(row=0, column=0) - if thumb is not None: - canvas.create_image(65, 65, image=thumb) - else: - canvas.create_text(65, 65, text="šŸ–¼ļø", fill="gray") - - # X button to unmatch face - pin exactly to the canvas' top-right corner - x_canvas = tk.Canvas(face_frame, width=12, height=12, bg='red', - highlightthickness=0, relief="flat") - x_canvas.create_text(6, 6, text="āœ–", fill="white", font=("Arial", 8, "bold")) - # Click handler - x_canvas.bind("", lambda e, fid=face_id: self.unmatch_face(fid)) - # Hover highlight: change bg, show white outline, and hand cursor - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2")) - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="red", highlightthickness=0, cursor="")) - # Anchor to the canvas' top-right regardless of layout/size - try: - x_canvas.place(in_=canvas, relx=1.0, x=0, y=0, anchor='ne') - except Exception: - # Fallback to absolute coords if relative placement fails - x_canvas.place(x=118, y=0) - - ttk.Label(tile, text=f"ID {face_id}", foreground="gray").grid(row=1, column=0) - - col_index += 1 - if col_index >= cols: - col_index = 0 - row_index += 1 - - def populate_people_list(self): - """Populate the people list with current data""" - for w in self.people_list_inner.winfo_children(): - w.destroy() - source = self.people_filtered if self.people_filtered is not None else self.people_data - if not source: - empty_label = ttk.Label(self.people_list_inner, text="No people match filter", foreground="gray") - empty_label.grid(row=0, column=0, sticky=tk.W, pady=4) - return - for idx, person in enumerate(source): - row = ttk.Frame(self.people_list_inner) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=4) - # Freeze per-row values to avoid late-binding issues - row_person = person - row_idx = idx - - # Make person name clickable - def make_click_handler(p_id, p_name, p_idx): - def on_click(event): - # Reset all labels to normal font - for child in self.people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Set clicked label to bold - event.widget.config(font=("Arial", 10, "bold")) - self.selected_person_id = p_id - # Show faces for this person - self.show_person_faces(p_id, p_name) - return on_click - - # Edit (rename) button - def start_edit_person(row_frame, person_record, row_index): - for w in row_frame.winfo_children(): - w.destroy() - - # Use pre-loaded data instead of database query - cur_first = person_record.get('first_name', '') - cur_last = person_record.get('last_name', '') - cur_middle = person_record.get('middle_name', '') - cur_maiden = person_record.get('maiden_name', '') - cur_dob = person_record.get('date_of_birth', '') - - # Create a larger container frame for the text boxes and labels - edit_container = ttk.Frame(row_frame) - edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # First name field with label - first_frame = ttk.Frame(edit_container) - first_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - first_var = tk.StringVar(value=cur_first) - first_entry = ttk.Entry(first_frame, textvariable=first_var, width=15) - first_entry.pack(side=tk.TOP) - first_entry.focus_set() - - first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray") - first_label.pack(side=tk.TOP, pady=(2, 0)) - - # Last name field with label - last_frame = ttk.Frame(edit_container) - last_frame.grid(row=0, column=1, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - last_var = tk.StringVar(value=cur_last) - last_entry = ttk.Entry(last_frame, textvariable=last_var, width=15) - last_entry.pack(side=tk.TOP) - - last_label = ttk.Label(last_frame, text="Last Name", font=("Arial", 8), foreground="gray") - last_label.pack(side=tk.TOP, pady=(2, 0)) - - # Middle name field with label - middle_frame = ttk.Frame(edit_container) - middle_frame.grid(row=0, column=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - middle_var = tk.StringVar(value=cur_middle) - middle_entry = ttk.Entry(middle_frame, textvariable=middle_var, width=15) - middle_entry.pack(side=tk.TOP) - - middle_label = ttk.Label(middle_frame, text="Middle Name", font=("Arial", 8), foreground="gray") - middle_label.pack(side=tk.TOP, pady=(2, 0)) - - # Maiden name field with label - maiden_frame = ttk.Frame(edit_container) - maiden_frame.grid(row=1, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - maiden_var = tk.StringVar(value=cur_maiden) - maiden_entry = ttk.Entry(maiden_frame, textvariable=maiden_var, width=15) - maiden_entry.pack(side=tk.TOP) - - maiden_label = ttk.Label(maiden_frame, text="Maiden Name", font=("Arial", 8), foreground="gray") - maiden_label.pack(side=tk.TOP, pady=(2, 0)) - - # Date of birth field with label and calendar button - dob_frame = ttk.Frame(edit_container) - dob_frame.grid(row=1, column=1, columnspan=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - # Create a frame for the date picker - date_picker_frame = ttk.Frame(dob_frame) - date_picker_frame.pack(side=tk.TOP) - - dob_var = tk.StringVar(value=cur_dob) - dob_entry = ttk.Entry(date_picker_frame, textvariable=dob_var, width=20, state='readonly') - dob_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Calendar button - calendar_btn = ttk.Button(date_picker_frame, text="šŸ“…", width=3, command=lambda: self.open_calendar(dob_var)) - calendar_btn.pack(side=tk.RIGHT, padx=(5, 0)) - - dob_label = ttk.Label(dob_frame, text="Date of Birth", font=("Arial", 8), foreground="gray") - dob_label.pack(side=tk.TOP, pady=(2, 0)) - - def save_rename(): - new_first = first_var.get().strip() - new_last = last_var.get().strip() - new_middle = middle_var.get().strip() - new_maiden = maiden_var.get().strip() - new_dob = dob_var.get().strip() - - if not new_first and not new_last: - messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.") - return - - # Check for duplicates in local data first (based on first and last name only) - for person in self.people_data: - if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last: - display_name = f"{new_last}, {new_first}".strip(", ").strip() - messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.") - return - - # Single database access - save to database - 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 = ?', - (new_first, new_last, new_middle, new_maiden, new_dob, person_record['id'])) - conn.commit() - - # Update local data structure - person_record['first_name'] = new_first - person_record['last_name'] = new_last - person_record['middle_name'] = new_middle - person_record['maiden_name'] = new_maiden - person_record['date_of_birth'] = new_dob - - # Recreate the full display name with all available information - name_parts = [] - if new_first: - name_parts.append(new_first) - if new_middle: - name_parts.append(new_middle) - if new_last: - name_parts.append(new_last) - if new_maiden: - name_parts.append(f"({new_maiden})") - - full_name = ' '.join(name_parts) if name_parts else "Unknown" - - # Create detailed display with date of birth if available - display_name = full_name - if new_dob: - display_name += f" - Born: {new_dob}" - - person_record['name'] = display_name - person_record['full_name'] = full_name - - # Refresh list - current_selected_id = person_record['id'] - self.populate_people_list() - # Reselect and refresh right panel header if needed - if self.selected_person_id == current_selected_id or self.selected_person_id is None: - # Find updated name - updated = next((p for p in self.people_data if p['id'] == current_selected_id), None) - if updated: - # Bold corresponding label - for child in self.people_list_inner.winfo_children(): - # child is row frame: contains label and button - widgets = child.winfo_children() - if not widgets: - continue - lbl = widgets[0] - if isinstance(lbl, ttk.Label) and lbl.cget('text').startswith(updated['name'] + " ("): - lbl.config(font=("Arial", 10, "bold")) - break - # Update right panel header by re-showing faces - self.show_person_faces(updated['id'], updated['name']) - - def cancel_edit(): - # Rebuild the row back to label + edit - for w in row_frame.winfo_children(): - w.destroy() - rebuild_row(row_frame, person_record, row_index) - - save_btn = ttk.Button(row_frame, text="šŸ’¾", width=3, command=save_rename) - save_btn.pack(side=tk.LEFT, padx=(5, 0)) - cancel_btn = ttk.Button(row_frame, text="āœ–", width=3, command=cancel_edit) - cancel_btn.pack(side=tk.LEFT, padx=(5, 0)) - - # Configure custom disabled button style for better visibility - style = ttk.Style() - style.configure("Disabled.TButton", - background="#d3d3d3", # Light gray background - foreground="#808080", # Dark gray text - relief="flat", - borderwidth=1) - - def validate_save_button(): - """Enable/disable save button based on required fields""" - first_val = first_var.get().strip() - last_val = last_var.get().strip() - dob_val = dob_var.get().strip() - - # Enable save button only if both name fields and date of birth are provided - has_first = bool(first_val) - has_last = bool(last_val) - has_dob = bool(dob_val) - - if has_first and has_last and has_dob: - save_btn.config(state="normal") - # Reset to normal styling when enabled - save_btn.config(style="TButton") - else: - save_btn.config(state="disabled") - # Apply custom disabled styling for better visibility - save_btn.config(style="Disabled.TButton") - - # Set up validation callbacks for all input fields - first_var.trace('w', lambda *args: validate_save_button()) - last_var.trace('w', lambda *args: validate_save_button()) - middle_var.trace('w', lambda *args: validate_save_button()) - maiden_var.trace('w', lambda *args: validate_save_button()) - dob_var.trace('w', lambda *args: validate_save_button()) - - # Initial validation - validate_save_button() - - # Keyboard shortcuts (only work when save button is enabled) - 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()) - - def rebuild_row(row_frame, p, i): - # Edit button (on the left) - edit_btn = ttk.Button(row_frame, text="āœļø", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii)) - edit_btn.pack(side=tk.LEFT, padx=(0, 5)) - # Label (clickable) - takes remaining space - name_lbl = ttk.Label(row_frame, text=f"{p['name']} ({p['count']})", font=("Arial", 10)) - name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) - name_lbl.bind("", make_click_handler(p['id'], p['name'], i)) - name_lbl.config(cursor="hand2") - # Bold if selected - if (self.selected_person_id is None and i == 0) or (self.selected_person_id == p['id']): - name_lbl.config(font=("Arial", 10, "bold")) - - # Build row contents with edit button - rebuild_row(row, row_person, row_idx) - - def open_calendar(self, dob_var): - """Open a visual calendar dialog to select date of birth""" - from datetime import datetime, date - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(self.parent_frame) - calendar_window.title("Select Date of Birth") - calendar_window.resizable(False, False) - calendar_window.transient(self.parent_frame) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = dob_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use default - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - else: - # Default to 25 years ago - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Configure custom styles for better visual highlighting - style = ttk.Style() - - # Selected date style - bright blue background with white text - style.configure("Selected.TButton", - background="#0078d4", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=2) - style.map("Selected.TButton", - background=[("active", "#106ebe")], - relief=[("pressed", "sunken")]) - - # Today's date style - orange background - style.configure("Today.TButton", - background="#ff8c00", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=1) - style.map("Today.TButton", - background=[("active", "#e67e00")], - relief=[("pressed", "sunken")]) - - # Calendar-specific normal button style (don't affect global TButton) - style.configure("Calendar.TButton", - font=("Arial", 9), - relief="flat") - style.map("Calendar.TButton", - background=[["active", "#e1e1e1"]], - relief=[["pressed", "sunken"]]) - - # 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() - - def update_calendar(): - """Update the calendar display""" - # Clear existing calendar - for widget in calendar_frame.winfo_children(): - widget.destroy() - - # Update header - month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") - - # 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): - label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) - label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") - - # Calendar days - for week_num, week in enumerate(cal): - for day_num, day in enumerate(week): - if day == 0: - # Empty cell - label = ttk.Label(calendar_frame, text="") - label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - else: - # Day button - 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 with prominent style - 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), - width=3, style="Calendar.TButton") - day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - - # Check if this day should be highlighted - is_today = (display_year == current_date.year and - display_month == current_date.month and - day == current_date.day) - is_selected = (selected_date and - selected_date.year == display_year and - selected_date.month == display_month and - selected_date.day == day) - - if is_selected: - day_btn.config(style="Selected.TButton") - elif is_today: - day_btn.config(style="Today.TButton") - - # Navigation functions - def prev_year(): - nonlocal display_year - display_year = max(1900, display_year - 1) - update_calendar() - - def next_year(): - nonlocal display_year - display_year = min(current_date.year, display_year + 1) - update_calendar() - - def prev_month(): - nonlocal display_month, display_year - if display_month > 1: - display_month -= 1 - else: - display_month = 12 - display_year = max(1900, display_year - 1) - update_calendar() - - def next_month(): - nonlocal display_month, display_year - if display_month < 12: - display_month += 1 - else: - display_month = 1 - display_year = min(current_date.year, display_year + 1) - update_calendar() - - # Navigation buttons - prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) - prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) - - prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) - prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) - - month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) - month_year_label.pack(side=tk.LEFT, padx=5) - - next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) - next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) - - 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: - date_str = selected_date.strftime('%Y-%m-%d') - dob_var.set(date_str) - calendar_window.destroy() - else: - messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") - - def cancel_selection(): - """Cancel date selection""" - calendar_window.destroy() - - # Buttons - 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 - update_calendar() - - def on_quit(self): - """Handle quit with unsaved changes warning""" - # Warn if there are pending unmatched faces (unsaved changes) - try: - if self.unmatched_faces: - result = messagebox.askyesnocancel( - "Unsaved Changes", - "You have pending changes that are not saved.\n\n" - "Yes: Save and quit\n" - "No: Quit without saving\n" - "Cancel: Return to window" - ) - if result is None: - # Cancel - return - if result is True: - # Save then quit - self.on_save_all_changes() - # If result is False, fall through and quit without saving - except Exception: - # If any issue occurs, proceed to normal quit - pass - # Cleanup temp crops - for crop in list(self.temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - self.temp_crops.clear() - - def on_save_all_changes(self): - """Save all unmatched faces to database""" - # Use global unmatched_faces set; commit all across people - if not self.unmatched_faces: - messagebox.showinfo("Nothing to Save", "There are no pending changes to save.") - return - result = messagebox.askyesno( - "Confirm Save", - f"Unlink {len(self.unmatched_faces)} face(s) across all people?\n\nThis will make them unidentified." - ) - if not result: - return - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for face_id in self.unmatched_faces: - cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) - conn.commit() - count = len(self.unmatched_faces) - self.unmatched_faces.clear() - # Refresh people list and right panel for current selection - self.load_people() - self.populate_people_list() - if self.current_person_id is not None and self.current_person_name: - self.show_person_faces(self.current_person_id, self.current_person_name) - messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).") - - -class DashboardGUI: - """Unified Dashboard with menu bar and content area for all features. - - Designed to be web-migration friendly with clear separation between - navigation (menu bar) and content (panels). - """ - - def __init__(self, gui_core: GUICore, db_manager=None, face_processor=None, on_scan=None, on_process=None, on_identify=None, search_stats=None, tag_manager=None): - self.gui_core = gui_core - self.db_manager = db_manager - self.face_processor = face_processor - self.on_scan = on_scan - self.on_process = on_process - self.on_identify = on_identify - self.search_stats = search_stats - self.tag_manager = tag_manager - - # Panel management for future web migration - self.panels: Dict[str, ttk.Frame] = {} - self.current_panel: Optional[str] = None - self.root: Optional[tk.Tk] = None - - def open(self) -> int: - """Open the unified dashboard with menu bar and content area""" - self.root = tk.Tk() - self.root.title("PunimTag - Unified Dashboard") - self.root.resizable(True, True) - self.root.withdraw() - - # Make window full screen - use geometry instead of state for better compatibility - try: - # Try Windows-style maximized state first - self.root.state('zoomed') - except tk.TclError: - try: - # Try Linux-style maximized attribute - self.root.attributes('-zoomed', True) - except tk.TclError: - # Fallback: set geometry to screen size - screen_width = self.root.winfo_screenwidth() - screen_height = self.root.winfo_screenheight() - self.root.geometry(f"{screen_width}x{screen_height}+0+0") - - # Get screen dimensions for dynamic sizing - screen_width = self.root.winfo_screenwidth() - screen_height = self.root.winfo_screenheight() - - # Set minimum window size - self.root.minsize(800, 600) - - # Create main container with proper grid configuration - main_container = ttk.Frame(self.root) - main_container.pack(fill=tk.BOTH, expand=True) - - # Configure main container grid weights for responsiveness - main_container.columnconfigure(0, weight=1) - main_container.rowconfigure(0, weight=0) # Menu bar - fixed height - main_container.rowconfigure(1, weight=0) # Separator - fixed height - main_container.rowconfigure(2, weight=1) # Content area - expandable - - # Add window resize handler for dynamic responsiveness - self.root.bind('', self._on_window_resize) - - # Create menu bar - self._create_menu_bar(main_container) - - # Create content area - self._create_content_area(main_container) - - # Initialize panels - self._initialize_panels() - - # Show default panel - self.show_panel("home") - - # Show window - self.root.deiconify() - self.root.mainloop() - return 0 - - def _create_menu_bar(self, parent: ttk.Frame): - """Create the top menu bar with all functionality buttons""" - menu_frame = ttk.Frame(parent) - menu_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=15, pady=10) - menu_frame.columnconfigure(0, weight=0) # Title - fixed width - menu_frame.columnconfigure(1, weight=1) # Buttons - expandable - menu_frame.columnconfigure(2, weight=0) # Status - fixed width - - # Title with larger font for full screen - title_label = tk.Label(menu_frame, text="PunimTag", font=("Arial", 20, "bold")) - title_label.grid(row=0, column=0, padx=(0, 30), sticky=tk.W) - - # Create buttons frame for better organization - buttons_frame = ttk.Frame(menu_frame) - buttons_frame.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=20) - - # Menu buttons with larger size for full screen - menu_buttons = [ - ("šŸ“ Scan", "scan", "Scan folders and add photos"), - ("šŸ” Process", "process", "Detect faces in photos"), - ("šŸ‘¤ 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"), - ("šŸ·ļø Tags", "tags", "Manage photo tags"), - ] - - for i, (text, panel_name, tooltip) in enumerate(menu_buttons): - btn = ttk.Button( - buttons_frame, - text=text, - command=lambda p=panel_name: self.show_panel(p), - width=12 # Fixed width for consistent layout - ) - btn.grid(row=0, column=i, padx=3, sticky=tk.W) - - # Add tooltip functionality - self._add_tooltip(btn, tooltip) - - # Status/Info area with better styling - status_frame = ttk.Frame(menu_frame) - status_frame.grid(row=0, column=2, sticky=tk.E, padx=(20, 0)) - - self.status_label = tk.Label(status_frame, text="Ready", foreground="#666", font=("Arial", 10)) - self.status_label.pack(side=tk.RIGHT) - - # Add a subtle separator line below the menu - separator = ttk.Separator(parent, orient='horizontal') - separator.grid(row=1, column=0, sticky=(tk.W, tk.E), padx=15, pady=(0, 5)) - - def _create_content_area(self, parent: ttk.Frame): - """Create the main content area where panels will be displayed""" - self.content_frame = ttk.Frame(parent) - self.content_frame.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=15, pady=(0, 15)) - - # Configure content frame to expand both horizontally and vertically - self.content_frame.columnconfigure(0, weight=1) - self.content_frame.rowconfigure(0, weight=1) - - # Add a subtle border - self.content_frame.configure(relief='sunken', borderwidth=1) - - def _initialize_panels(self): - """Initialize all panels (currently placeholders)""" - # Home panel (default) - self.panels["home"] = self._create_home_panel() - - # Functional panels (placeholders for now) - self.panels["scan"] = self._create_scan_panel() - self.panels["process"] = self._create_process_panel() - self.panels["identify"] = self._create_identify_panel() - self.panels["auto_match"] = self._create_auto_match_panel() - self.panels["search"] = self._create_search_panel() - self.panels["modify"] = self._create_modify_panel() - self.panels["tags"] = self._create_tags_panel() - - def show_panel(self, panel_name: str): - """Show the specified panel in the content area""" - if panel_name not in self.panels: - messagebox.showerror("Error", f"Panel '{panel_name}' not found", parent=self.root) - return - - # Deactivate current panel if it has activation/deactivation methods - if self.current_panel: - self.panels[self.current_panel].grid_remove() - # Deactivate identify panel if it's active - if hasattr(self, 'identify_panel') and self.identify_panel and self.current_panel == "identify": - self.identify_panel.deactivate() - # 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() - - # 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) - self.current_panel = panel_name - - # Activate new panel if it has activation/deactivation methods - if panel_name == "identify" and hasattr(self, 'identify_panel') and self.identify_panel: - 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() - - # Update status - self.status_label.config(text=f"Viewing: {panel_name.replace('_', ' ').title()}") - - def _add_tooltip(self, widget, text): - """Add a simple tooltip to 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 = ttk.Label(tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1) - label.pack() - widget.tooltip = tooltip - - def hide_tooltip(event): - if hasattr(widget, 'tooltip'): - widget.tooltip.destroy() - del widget.tooltip - - widget.bind("", show_tooltip) - widget.bind("", hide_tooltip) - - def _on_window_resize(self, event): - """Handle window resize events for dynamic responsiveness""" - # Only handle resize events for the main window, not child widgets - if event.widget == self.root: - # Update status with current window size - width = self.root.winfo_width() - height = self.root.winfo_height() - self.status_label.config(text=f"Ready - {width}x{height}") - - # Force update of all panels to ensure proper resizing - if hasattr(self, 'identify_panel') and self.identify_panel: - # Update identify panel layout if it's active - if self.current_panel == "identify": - self.identify_panel.update_layout() - - def _create_home_panel(self) -> ttk.Frame: - """Create the home/welcome panel""" - 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 - - # Welcome content - welcome_frame = ttk.Frame(panel) - welcome_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N), padx=20, pady=20) - welcome_frame.columnconfigure(0, weight=1) - # Remove weight=1 to prevent vertical centering - # welcome_frame.rowconfigure(0, weight=1) - - # Content starts at the top instead of being centered - center_frame = ttk.Frame(welcome_frame) - center_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N)) - center_frame.columnconfigure(0, weight=1) - - # Title with larger font for full screen - title_label = tk.Label(center_frame, text="Welcome to PunimTag", font=("Arial", 32, "bold")) - title_label.grid(row=0, column=0, pady=(0, 30)) - - # Description with larger font - desc_text = ( - "PunimTag is a powerful photo face recognition and tagging system.\n\n" - "Use the menu above to access different features:\n\n" - "• šŸ“ Scan - Add photos to your collection\n" - "• šŸ” Process - Detect faces in photos\n" - "• šŸ‘¤ Identify - Identify people in photos\n" - "• šŸ”— Auto-Match - Find matching faces automatically\n" - "• šŸ”Ž Search - Find photos by person name\n" - "• āœļø Modify - Edit face identifications\n" - "• šŸ·ļø Tags - Manage photo tags\n\n" - "Select a feature from the menu to get started!" - ) - - desc_label = tk.Label(center_frame, text=desc_text, font=("Arial", 14), justify=tk.LEFT) - desc_label.grid(row=1, column=0, pady=(0, 20)) - - return panel - - def _create_scan_panel(self) -> ttk.Frame: - """Create the scan panel (migrated from original dashboard)""" - 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 - - # Title with larger font for full screen - title_label = tk.Label(panel, text="šŸ“ Scan Photos", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Scan form - form_frame = ttk.LabelFrame(panel, text="Scan Configuration", padding="20") - form_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 20)) - form_frame.columnconfigure(0, weight=1) - - # Folder selection - folder_frame = ttk.Frame(form_frame) - folder_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 15)) - folder_frame.columnconfigure(0, weight=1) - - tk.Label(folder_frame, text="Folder to scan:", font=("Arial", 12)).grid(row=0, column=0, sticky=tk.W) - - folder_input_frame = ttk.Frame(folder_frame) - folder_input_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(5, 0)) - folder_input_frame.columnconfigure(0, weight=1) - - self.folder_var = tk.StringVar() - folder_entry = tk.Entry(folder_input_frame, textvariable=self.folder_var, font=("Arial", 11)) - folder_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 10)) - - def browse_folder(): - from tkinter import filedialog - folder_path = filedialog.askdirectory(title="Select folder to scan for photos") - if folder_path: - self.folder_var.set(folder_path) - - browse_btn = ttk.Button(folder_input_frame, text="Browse", command=browse_folder) - browse_btn.grid(row=0, column=1) - - # Recursive option - self.recursive_var = tk.BooleanVar(value=True) - recursive_check = tk.Checkbutton( - form_frame, - text="Include photos in sub-folders", - variable=self.recursive_var, - font=("Arial", 11) - ) - recursive_check.grid(row=1, column=0, sticky=tk.W, pady=(15, 0)) - - # Action button - scan_btn = ttk.Button(form_frame, text="šŸ” Start Scan", command=self._run_scan) - scan_btn.grid(row=2, column=0, sticky=tk.W, pady=(20, 0)) - - return panel - - def _create_process_panel(self) -> ttk.Frame: - """Create the process panel (migrated from original dashboard)""" - 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 - - # Title with larger font for full screen - title_label = tk.Label(panel, text="šŸ” Process Faces", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Process form - form_frame = ttk.LabelFrame(panel, text="Processing Configuration", padding="20") - form_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 20)) - form_frame.columnconfigure(0, weight=1) - - # Limit option - limit_frame = ttk.Frame(form_frame) - limit_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 15)) - - self.limit_enabled = tk.BooleanVar(value=False) - limit_check = tk.Checkbutton(limit_frame, text="Limit processing to", variable=self.limit_enabled, font=("Arial", 11)) - limit_check.grid(row=0, column=0, sticky=tk.W) - - self.limit_var = tk.StringVar(value="50") - limit_entry = tk.Entry(limit_frame, textvariable=self.limit_var, width=8, font=("Arial", 11)) - limit_entry.grid(row=0, column=1, padx=(10, 5)) - - tk.Label(limit_frame, text="photos", font=("Arial", 11)).grid(row=0, column=2, sticky=tk.W) - - # Action button - process_btn = ttk.Button(form_frame, text="šŸš€ Start Processing", command=self._run_process) - process_btn.grid(row=1, column=0, sticky=tk.W, pady=(20, 0)) - - return panel - - def _create_identify_panel(self) -> ttk.Frame: - """Create the identify panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: title (row 0) fixed, identify 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="šŸ‘¤ Identify Faces", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Create the identify panel if we have the required dependencies - if self.db_manager and self.face_processor: - self.identify_panel = IdentifyPanel(panel, self.db_manager, self.face_processor, self.gui_core) - identify_frame = self.identify_panel.create_panel() - identify_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) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = ( - "Identify panel requires database and face processor to be configured.\n\n" - "This will contain the full face identification interface\n" - "currently available in the separate Identify window.\n\n" - "Features will include:\n" - "• Face browsing and identification\n" - "• Similar face matching\n" - "• Person management\n" - "• Batch processing options" - ) - - 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 - - def _create_auto_match_panel(self) -> ttk.Frame: - """Create the auto-match panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: title (row 0) fixed, auto-match 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="šŸ”— Auto-Match Faces", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Create the auto-match panel if we have the required dependencies - if self.db_manager and self.face_processor: - self.auto_match_panel = AutoMatchPanel(panel, self.db_manager, self.face_processor, self.gui_core) - auto_match_frame = self.auto_match_panel.create_panel() - auto_match_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) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = ( - "Auto-Match panel requires database and face processor to be configured.\n\n" - "This will contain the full auto-match interface\n" - "currently available in the separate Auto-Match window.\n\n" - "Features will include:\n" - "• Person-centric matching workflow\n" - "• Visual confirmation of matches\n" - "• Batch identification of similar faces\n" - "• Search and filter by person name\n" - "• Smart pre-selection of previously identified faces" - ) - - 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 - - def _create_search_panel(self) -> ttk.Frame: - """Create the search panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: title (row 0) fixed, search 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="šŸ”Ž Search Photos", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Create the search panel if we have the required dependencies - if self.db_manager and self.search_stats: - self.search_panel = SearchPanel(panel, self.db_manager, self.search_stats, self.gui_core, self.tag_manager) - search_frame = self.search_panel.create_panel() - search_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) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = ( - "Search panel requires database and search stats to be configured.\n\n" - "This will contain the full search interface\n" - "currently available in the separate Search window.\n\n" - "Features will include:\n" - "• Search photos by person name\n" - "• Search photos by date range\n" - "• Search photos by tags\n" - "• Find photos without faces\n" - "• Find photos without tags\n" - "• Advanced filtering options" - ) - - 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 - - def _create_modify_panel(self) -> ttk.Frame: - """Create the modify panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # 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)) - - # 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 - - def _create_tags_panel(self) -> ttk.Frame: - """Create the tags panel (placeholder)""" - 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 - - title_label = tk.Label(panel, text="šŸ·ļø Tag Manager", 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 = "Tag management 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)) - - return panel - - def _run_scan(self): - """Run the scan operation (migrated from original dashboard)""" - folder = self.folder_var.get().strip() - recursive = bool(self.recursive_var.get()) - - if not folder: - messagebox.showwarning("Scan", "Please enter a folder path.", parent=self.root) - return - - # Validate folder path using path utilities - from path_utils import validate_path_exists, normalize_path - try: - folder = normalize_path(folder) - if not validate_path_exists(folder): - messagebox.showerror("Scan", f"Folder does not exist or is not accessible: {folder}", parent=self.root) - return - except ValueError as e: - messagebox.showerror("Scan", f"Invalid folder path: {e}", parent=self.root) - return - - if not callable(self.on_scan): - messagebox.showinfo("Scan", "Scan functionality is not wired yet.", parent=self.root) - return - - def worker(): - try: - self.status_label.config(text="Scanning...") - result = self.on_scan(folder, recursive) - messagebox.showinfo("Scan", f"Scan completed. Result: {result}", parent=self.root) - self.status_label.config(text="Ready") - except Exception as e: - messagebox.showerror("Scan", f"Error during scan: {e}", parent=self.root) - self.status_label.config(text="Ready") - - threading.Thread(target=worker, daemon=True).start() - - def _run_process(self): - """Run the process operation (migrated from original dashboard)""" - if not callable(self.on_process): - messagebox.showinfo("Process", "Process functionality is not wired yet.", parent=self.root) - return - - limit_value = None - if self.limit_enabled.get(): - try: - limit_value = int(self.limit_var.get().strip()) - if limit_value <= 0: - raise ValueError - except Exception: - messagebox.showerror("Process", "Please enter a valid positive integer for limit.", parent=self.root) - return - - def worker(): - try: - self.status_label.config(text="Processing...") - result = self.on_process(limit_value) - messagebox.showinfo("Process", f"Processing completed. Result: {result}", parent=self.root) - self.status_label.config(text="Ready") - except Exception as e: - messagebox.showerror("Process", f"Error during processing: {e}", parent=self.root) - self.status_label.config(text="Ready") - - threading.Thread(target=worker, daemon=True).start() - - diff --git a/archive/desktop/README.md b/archive/desktop/README.md deleted file mode 100644 index bccb3df..0000000 --- a/archive/desktop/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Desktop Version Archive - -This directory contains all files related to the desktop GUI version of PunimTag, which has been archived as the project has migrated to a web-based interface. - -## Archived Files - -### GUI Components (gui/) -- dashboard_gui.py - Main unified dashboard GUI -- gui_core.py - Core GUI utilities -- identify_panel.py - Face identification panel -- modify_panel.py - Modify identified faces panel -- auto_match_panel.py - Auto-matching panel -- tag_manager_panel.py - Tag management panel - -### Entry Points -- run_dashboard.py - Desktop dashboard launcher -- run_deepface_gui.sh - DeepFace GUI test script -- photo_tagger.py - CLI entry point for desktop version - -### Test Files (tests/) -- test_deepface_gui.py - DeepFace GUI test application -- test_simple_gui.py - Simple GUI test -- test_thumbnail_sizes.py - Thumbnail size test -- show_large_thumbnails.py - Large thumbnails demo - -### Documentation -- README_DESKTOP.md - Desktop version documentation - -## Migration Date -November 2025 - -## Status -These files are archived and no longer maintained. The project now uses a web-based interface accessible via the API and frontend. diff --git a/archive/desktop/README_DESKTOP.md b/archive/desktop/README_DESKTOP.md deleted file mode 100644 index d3ea619..0000000 --- a/archive/desktop/README_DESKTOP.md +++ /dev/null @@ -1,304 +0,0 @@ -# PunimTag - -**Photo Management and Facial Recognition System** - -A powerful desktop application for organizing and tagging photos using **state-of-the-art DeepFace AI** with ArcFace recognition model. - ---- - -## šŸŽÆ Features - -- **šŸ”„ DeepFace AI**: State-of-the-art face detection with RetinaFace and ArcFace models -- **šŸŽÆ Superior Accuracy**: 512-dimensional embeddings (4x more detailed than face_recognition) -- **āš™ļø Multiple Detectors**: Choose from RetinaFace, MTCNN, OpenCV, or SSD detectors -- **šŸŽØ Flexible Models**: Select ArcFace, Facenet, Facenet512, or VGG-Face recognition models -- **šŸ“Š Rich Metadata**: Face confidence scores, quality metrics, detector/model info displayed in GUI -- **šŸ‘¤ Person Identification**: Identify and tag people across your photo collection -- **šŸ¤– Smart Auto-Matching**: Intelligent face matching with quality scoring and cosine similarity -- **šŸ” Advanced Search**: Search by people, dates, tags, and folders -- **šŸŽšļø Quality Filtering**: Filter faces by quality score in Identify panel (0-100%) -- **šŸ·ļø Tag Management**: Organize photos with hierarchical tags -- **⚔ Batch Processing**: Process thousands of photos efficiently -- **šŸ”’ Privacy-First**: All data stored locally, no cloud dependencies -- **āœ… Production Ready**: Complete migration with 20/20 tests passing - ---- - -## šŸš€ Quick Start - -### Prerequisites -- Python 3.12 or higher -- pip package manager -- Virtual environment (recommended) - -### Installation - -```bash -# Clone the repository -git clone -cd punimtag - -# Create and activate virtual environment -python -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install dependencies -pip install -r requirements.txt -``` - -### Running the Application - -#### GUI Dashboard (Recommended) -```bash -python run_dashboard.py -``` - -Or: -```bash -python src/gui/dashboard_gui.py -``` - -#### CLI Interface -```bash -python src/photo_tagger.py --help -``` - -### First-Time Setup - -If you have an existing database from before the DeepFace migration, you need to migrate: - -```bash -# IMPORTANT: This will delete all existing data! -python scripts/migrate_to_deepface.py -``` - -Then re-add your photos and process them with DeepFace. - ---- - -## šŸ“– Documentation - -- **[Architecture](docs/ARCHITECTURE.md)**: System design and technical details -- **[Demo Guide](docs/DEMO.md)**: Step-by-step tutorial -- **[Dashboard Guide](docs/README_UNIFIED_DASHBOARD.md)**: GUI reference -- **[Contributing](CONTRIBUTING.md)**: How to contribute - ---- - -## šŸ—ļø Project Structure - -``` -punimtag/ -ā”œā”€ā”€ src/ # Source code -│ ā”œā”€ā”€ core/ # Business logic -│ ā”œā”€ā”€ gui/ # GUI components -│ └── utils/ # Utilities -ā”œā”€ā”€ tests/ # Test suite -ā”œā”€ā”€ docs/ # Documentation -ā”œā”€ā”€ .notes/ # Project notes -└── data/ # Application data -``` - -See [Directory Structure](.notes/directory_structure.md) for details. - ---- - -## šŸŽ® Usage - -### 1. Import Photos -```bash -# Add photos from a folder -python src/photo_tagger.py scan /path/to/photos -``` - -### 2. Process Faces -Open the dashboard and click "Process Photos" to detect faces. - -### 3. Identify People -Use the "Identify" panel to tag faces with names: -- **Quality Filter**: Adjust the quality slider (0-100%) to filter out low-quality faces -- **Unique Faces**: Enable to hide duplicate faces using cosine similarity -- **Date Filters**: Filter faces by date range -- **Navigation**: Browse through unidentified faces with prev/next buttons -- **Photo Viewer**: Click the photo icon to view the full source image - -### 4. Search -Use the "Search" panel to find photos by people, dates, or tags. - ---- - -## šŸ”§ Configuration - -### GUI Configuration (Recommended) -Use the dashboard to configure DeepFace settings: -1. Open the dashboard: `python run_dashboard.py` -2. Click "šŸ” Process" -3. Select your preferred: - - **Face Detector**: RetinaFace (best), MTCNN, OpenCV, or SSD - - **Recognition Model**: ArcFace (best), Facenet, Facenet512, or VGG-Face - -### Manual Configuration -Edit `src/core/config.py` to customize: -- `DEEPFACE_DETECTOR_BACKEND` - Face detection model (default: `retinaface`) -- `DEEPFACE_MODEL_NAME` - Recognition model (default: `ArcFace`) -- `DEFAULT_FACE_TOLERANCE` - Similarity tolerance (default: `0.6` for DeepFace) -- `DEEPFACE_SIMILARITY_THRESHOLD` - Minimum similarity percentage (default: `60`) -- `MIN_FACE_QUALITY` - Minimum face quality score (default: `0.3`) -- Batch sizes and other processing thresholds - ---- - -## 🧪 Testing - -```bash -# Run all migration tests (20 tests total) -python tests/test_phase1_schema.py # Phase 1: Database schema (5 tests) -python tests/test_phase2_config.py # Phase 2: Configuration (5 tests) -python tests/test_phase3_deepface.py # Phase 3: Core processing (5 tests) -python tests/test_phase4_gui.py # Phase 4: GUI integration (5 tests) -python tests/test_deepface_integration.py # Phase 6: Integration tests (5 tests) - -# Run DeepFace GUI test (working example) -python tests/test_deepface_gui.py - -# All tests should pass āœ… (20/20 passing) -``` - ---- - -## šŸ—ŗļø Roadmap - -### Current (v1.1 - DeepFace Edition) āœ… -- āœ… Complete DeepFace migration (all 6 phases) -- āœ… Unified dashboard interface -- āœ… ArcFace recognition model (512-dim embeddings) -- āœ… RetinaFace detection (state-of-the-art) -- āœ… Multiple detector/model options (GUI selectable) -- āœ… Cosine similarity matching -- āœ… Face confidence scores and quality metrics -- āœ… Quality filtering in Identify panel (adjustable 0-100%) -- āœ… Unique faces detection (cosine similarity-based deduplication) -- āœ… Enhanced thumbnail display (100x100px) -- āœ… External system photo viewer integration -- āœ… Improved auto-match save responsiveness -- āœ… Metadata display (detector/model info in GUI) -- āœ… Enhanced accuracy and reliability -- āœ… Comprehensive test coverage (20/20 tests passing) - -### Next (v1.2) -- šŸ“‹ GPU acceleration for faster processing -- šŸ“‹ Performance optimization -- šŸ“‹ Enhanced GUI features -- šŸ“‹ Batch processing improvements - -### Future (v2.0+) -- Web interface -- Cloud storage integration -- Mobile app -- Video face detection -- Face clustering (unsupervised) -- Age estimation -- Emotion detection - ---- - -## šŸ¤ Contributing - -We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -### Quick Contribution Guide -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Add tests -5. Submit a pull request - ---- - -## šŸ“Š Current Status - -- **Version**: 1.1 (DeepFace Edition) -- **Face Detection**: DeepFace with RetinaFace (state-of-the-art) -- **Recognition Model**: ArcFace (512-dimensional embeddings) -- **Database**: SQLite with DeepFace schema and metadata columns -- **GUI**: Tkinter with model selection and metadata display -- **Platform**: Cross-platform (Linux, Windows, macOS) -- **Migration Status**: āœ… Complete (all 6 phases done, 20/20 tests passing) -- **Test Coverage**: 100% (20 tests across 6 phases) -- **Production Ready**: Yes āœ… - ---- - -## šŸ› Known Limitations - -- Processing ~2-3x slower than old face_recognition (but much more accurate!) -- Large databases (>50K photos) may experience slowdown -- No GPU acceleration yet (CPU-only processing) -- First run downloads models (~100MB+) -- Existing databases require migration (data will be lost) - -See [Task List](.notes/task_list.md) for all tracked issues. - -## šŸ“¦ Model Downloads - -On first run, DeepFace will download required models: -- ArcFace model (~100MB) -- RetinaFace detector (~1.5MB) -- Models stored in `~/.deepface/weights/` -- Requires internet connection for first run only - ---- - -## šŸ“ License - -[Add your license here] - ---- - -## šŸ‘„ Authors - -PunimTag Development Team - ---- - -## šŸ™ Acknowledgments - -- **DeepFace** library by Sefik Ilkin Serengil - Modern face recognition framework -- **ArcFace** - Additive Angular Margin Loss for Deep Face Recognition -- **RetinaFace** - State-of-the-art face detection -- TensorFlow, OpenCV, NumPy, and Pillow teams -- All contributors and users - -## šŸ“š Technical Details - -### Face Recognition Technology -- **Detection**: RetinaFace (default), MTCNN, OpenCV, or SSD -- **Model**: ArcFace (512-dim), Facenet (128-dim), Facenet512 (512-dim), or VGG-Face (2622-dim) -- **Similarity**: Cosine similarity (industry standard for deep learning embeddings) -- **Accuracy**: Significantly improved over previous face_recognition library - -### Migration Documentation -- [Phase 1: Database Schema](PHASE1_COMPLETE.md) - Database updates with DeepFace columns -- [Phase 2: Configuration](PHASE2_COMPLETE.md) - Configuration settings for DeepFace -- [Phase 3: Core Processing](PHASE3_COMPLETE.md) - Face processing with DeepFace -- [Phase 4: GUI Integration](PHASE4_COMPLETE.md) - GUI updates and metadata display -- [Phase 5 & 6: Dependencies and Testing](PHASE5_AND_6_COMPLETE.md) - Final validation -- [Complete Migration Summary](DEEPFACE_MIGRATION_COMPLETE_SUMMARY.md) - Full overview -- [Original Migration Plan](.notes/deepface_migration_plan.md) - Detailed plan - ---- - -## šŸ“§ Contact - -[Add contact information] - ---- - -## ⭐ Star History - -If you find this project useful, please consider giving it a star! - ---- - -**Made with ā¤ļø for photo enthusiasts** - diff --git a/archive/desktop/gui/__init__.py b/archive/desktop/gui/__init__.py deleted file mode 100644 index c9d3833..0000000 --- a/archive/desktop/gui/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -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', -] - diff --git a/archive/desktop/gui/auto_match_panel.py b/archive/desktop/gui/auto_match_panel.py deleted file mode 100644 index 3a21b86..0000000 --- a/archive/desktop/gui/auto_match_panel.py +++ /dev/null @@ -1,893 +0,0 @@ -#!/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="šŸš€ Run 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() - self.components['search_entry'] = ttk.Entry(search_frame, textvariable=self.components['search_var'], width=20, state='disabled') - self.components['search_entry'].grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Search buttons - self.components['search_btn'] = ttk.Button(search_frame, text="Search", width=8, command=self._apply_search_filter, state='disabled') - self.components['search_btn'].grid(row=0, column=1, padx=(0, 5)) - - self.components['clear_btn'] = ttk.Button(search_frame, text="Clear", width=6, command=self._clear_search_filter, state='disabled') - self.components['clear_btn'].grid(row=0, column=2) - - # Search help label - self.components['search_help_label'] = ttk.Label(search_frame, text="Search disabled - click 'Start Auto-Match' first", - 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, - f.face_confidence, f.detector_backend, f.model_name - 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 - - # Enable search controls now that auto-match has started - self.components['search_entry'].config(state='normal') - self.components['search_btn'].config(state='normal') - self.components['clear_btn'].config(state='normal') - - # 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("") - self.components['search_entry'].config(state='disabled') - self.components['search_btn'].config(state='disabled') - self.components['clear_btn'].config(state='disabled') - self.components['search_help_label'].config(text="(Search disabled - only one person found)") - else: - self.components['search_help_label'].config(text="Type Last Name") - - # 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 calibrated confidence (actual match probability) - confidence_pct, confidence_desc = self.face_processor._get_calibrated_confidence(match['distance']) - - # 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] - - # Show saving message - self.components['save_btn'].config(text="šŸ’¾ Saving...", state='disabled') - self.main_frame.update_idletasks() - - # 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() - - # Count changes for feedback - changes_made = 0 - - 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 - changes_made += 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']}") - changes_made += 1 - - # Commit changes first - conn.commit() - - # Update person encodings for all affected persons (outside of transaction) - # This can be slow, so we show progress - affected_person_ids = set(match['person_id'] for match in matches_for_this_person if match['person_id']) - if affected_person_ids: - self.components['save_btn'].config(text="šŸ’¾ Updating encodings...") - self.main_frame.update_idletasks() - - for person_id in affected_person_ids: - self.face_processor.update_person_encodings(person_id) - - # 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) - - # Show completion message - if changes_made > 0: - print(f"āœ… Saved {changes_made} change(s)") - - # Restore button text and update state - self._update_save_button_text() - self.components['save_btn'].config(state='normal') - self.main_frame.update_idletasks() - - 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 and disable search controls - self.components['search_var'].set("") - self.components['search_entry'].config(state='disabled') - self.components['search_btn'].config(state='disabled') - self.components['clear_btn'].config(state='disabled') - self.components['search_help_label'].config(text="Search disabled - click 'Start Auto-Match' first") - - 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 diff --git a/archive/desktop/gui/dashboard_gui.py b/archive/desktop/gui/dashboard_gui.py deleted file mode 100644 index 7a5e503..0000000 --- a/archive/desktop/gui/dashboard_gui.py +++ /dev/null @@ -1,2059 +0,0 @@ -#!/usr/bin/env python3 -""" -Unified Dashboard GUI for PunimTag features -Designed with web migration in mind - single window with menu bar and content area -""" - -import os -import warnings -import threading -import time -import tkinter as tk -from tkinter import ttk, messagebox -from typing import Dict, Optional, Callable - -# Suppress TensorFlow warnings (must be before DeepFace import) -os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' -warnings.filterwarnings('ignore') - -from src.gui.gui_core import GUICore -from src.gui.identify_panel import IdentifyPanel -from src.gui.modify_panel import ModifyPanel -from src.gui.auto_match_panel import AutoMatchPanel -from src.gui.tag_manager_panel import TagManagerPanel -from src.core.search_stats import SearchStats -from src.core.database import DatabaseManager -from src.core.tag_management import TagManager -from src.core.face_processing import FaceProcessor -class SearchPanel: - """Search panel with full functionality from search_gui.py""" - - SEARCH_TYPES = [ - "Search photos by name", - "Search photos by date", - "Search photos by tags", - "Search photos by multiple people (planned)", - "Most common tags (planned)", - "Most photographed people (planned)", - "Photos without faces", - "Photos without tags", - "Duplicate faces (planned)", - "Face quality distribution (planned)", - ] - - def __init__(self, parent_frame, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, tag_manager: TagManager = None, verbose: int = 0): - self.parent_frame = parent_frame - self.db = db_manager - self.search_stats = search_stats - self.gui_core = gui_core - self.tag_manager = tag_manager or TagManager(db_manager, verbose) - self.verbose = verbose - - # Sorting state - self.sort_column = None - self.sort_reverse = False - - # Selection tracking - self.selected_photos = {} # photo_path -> photo_data - - # Cache for photo tags to avoid database access during updates - self.photo_tags_cache = {} # photo_path -> list of tag names - - def create_panel(self) -> ttk.Frame: - """Create the search panel with all functionality""" - panel = ttk.Frame(self.parent_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: results area (row 3) should expand, buttons (row 4) should not - panel.rowconfigure(3, weight=1) - panel.rowconfigure(4, weight=0) - - # Search type selector - type_frame = ttk.Frame(panel) - type_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - type_frame.columnconfigure(1, weight=1) - - ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky=tk.W) - self.search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0]) - type_combo = ttk.Combobox(type_frame, textvariable=self.search_type_var, values=self.SEARCH_TYPES, state="readonly") - type_combo.grid(row=0, column=1, padx=(8, 0), sticky=(tk.W, tk.E)) - - # Filters area with expand/collapse functionality - filters_container = ttk.LabelFrame(panel, text="", padding="8") - filters_container.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - filters_container.columnconfigure(0, weight=1) - - # Filters header with toggle text - filters_header = ttk.Frame(filters_container) - filters_header.grid(row=0, column=0, sticky=(tk.W, tk.E)) - - # Toggle text for expand/collapse - self.filters_expanded = tk.BooleanVar(value=False) # Start collapsed - - def toggle_filters(): - if self.filters_expanded.get(): - # Collapse filters - filters_content.grid_remove() - toggle_text.config(text="+") - self.filters_expanded.set(False) - update_toggle_tooltip() - else: - # Expand filters - filters_content.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0)) - toggle_text.config(text="-") - self.filters_expanded.set(True) - update_toggle_tooltip() - - def update_toggle_tooltip(): - """Update tooltip text based on current state""" - if self.filters_expanded.get(): - tooltip_text = "Click to collapse filters" - else: - tooltip_text = "Click to expand filters" - toggle_text.tooltip_text = tooltip_text - - filters_label = ttk.Label(filters_header, text="Filters", font=("Arial", 10, "bold")) - filters_label.grid(row=0, column=0, sticky=tk.W) - - toggle_text = ttk.Label(filters_header, text="+", font=("Arial", 10, "bold"), cursor="hand2") - toggle_text.grid(row=0, column=1, padx=(6, 0)) - toggle_text.bind("", lambda e: toggle_filters()) - - # Initialize tooltip - toggle_text.tooltip_text = "Click to expand filters" - update_toggle_tooltip() - - # Filters content area (start hidden) - filters_content = ttk.Frame(filters_container) - filters_content.columnconfigure(0, weight=1) - - # Folder location filter - folder_filter_frame = ttk.Frame(filters_content) - folder_filter_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 4)) - folder_filter_frame.columnconfigure(1, weight=1) - - ttk.Label(folder_filter_frame, text="Folder location:").grid(row=0, column=0, sticky=tk.W) - self.folder_var = tk.StringVar() - folder_entry = ttk.Entry(folder_filter_frame, textvariable=self.folder_var, width=40) - folder_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E)) - ttk.Label(folder_filter_frame, text="(optional - filter by folder path)").grid(row=0, column=2, padx=(6, 0)) - - # Browse button for folder selection - def browse_folder(): - from tkinter import filedialog - from src.utils.path_utils import normalize_path - folder_path = filedialog.askdirectory(title="Select folder to filter by") - if folder_path: - try: - # Normalize to absolute path - normalized_path = normalize_path(folder_path) - self.folder_var.set(normalized_path) - except ValueError as e: - messagebox.showerror("Invalid Path", f"Invalid folder path: {e}", parent=panel) - - browse_btn = ttk.Button(folder_filter_frame, text="Browse", command=browse_folder) - browse_btn.grid(row=0, column=3, padx=(6, 0)) - - # Clear folder filter button - def clear_folder_filter(): - self.folder_var.set("") - - clear_folder_btn = ttk.Button(folder_filter_frame, text="Clear", command=clear_folder_filter) - clear_folder_btn.grid(row=0, column=4, padx=(6, 0)) - - # Apply filters button - apply_filters_btn = ttk.Button(filters_content, text="Apply filters", command=lambda: self.do_search()) - apply_filters_btn.grid(row=1, column=0, pady=(8, 0)) - - # Inputs area - inputs = ttk.Frame(panel) - inputs.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - inputs.columnconfigure(0, weight=1) - - # Name search input - self.name_frame = ttk.Frame(inputs) - ttk.Label(self.name_frame, text="Person name:").grid(row=0, column=0, sticky=tk.W) - self.name_var = tk.StringVar() - self.name_entry = ttk.Entry(self.name_frame, textvariable=self.name_var) - self.name_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E)) - self.name_frame.columnconfigure(1, weight=1) - - # Tag search input - self.tag_frame = ttk.Frame(inputs) - ttk.Label(self.tag_frame, text="Tags:").grid(row=0, column=0, sticky=tk.W) - self.tag_var = tk.StringVar() - self.tag_entry = ttk.Entry(self.tag_frame, textvariable=self.tag_var) - self.tag_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E)) - self.tag_frame.columnconfigure(1, weight=1) - - # Help icon for available tags - self.tag_help_icon = ttk.Label(self.tag_frame, text="ā“", font=("Arial", 10), cursor="hand2") - self.tag_help_icon.grid(row=0, column=2, padx=(6, 0)) - - ttk.Label(self.tag_frame, text="(comma-separated)").grid(row=0, column=3, padx=(6, 0)) - - # Tag search mode - self.tag_mode_frame = ttk.Frame(inputs) - ttk.Label(self.tag_mode_frame, text="Match mode:").grid(row=0, column=0, sticky=tk.W) - self.tag_mode_var = tk.StringVar(value="ANY") - self.tag_mode_combo = ttk.Combobox(self.tag_mode_frame, textvariable=self.tag_mode_var, - values=["ANY", "ALL"], state="readonly", width=8) - self.tag_mode_combo.grid(row=0, column=1, padx=(6, 0)) - ttk.Label(self.tag_mode_frame, text="(ANY = photos with any tag, ALL = photos with all tags)").grid(row=0, column=2, padx=(6, 0)) - - # Date search inputs - self.date_frame = ttk.Frame(inputs) - ttk.Label(self.date_frame, text="From date:").grid(row=0, column=0, sticky=tk.W) - self.date_from_var = tk.StringVar() - self.date_from_entry = ttk.Entry(self.date_frame, textvariable=self.date_from_var, width=12, state="readonly") - self.date_from_entry.grid(row=0, column=1, padx=(6, 0)) - - # Calendar button for date from - def open_calendar_from(): - current_date = self.date_from_var.get() - selected_date = self.gui_core.create_calendar_dialog(panel, "Select From Date", current_date) - if selected_date is not None: - self.date_from_var.set(selected_date) - - self.date_from_btn = ttk.Button(self.date_frame, text="šŸ“…", width=3, command=open_calendar_from) - self.date_from_btn.grid(row=0, column=2, padx=(6, 0)) - ttk.Label(self.date_frame, text="(YYYY-MM-DD)").grid(row=0, column=3, padx=(6, 0)) - - self.date_to_frame = ttk.Frame(inputs) - ttk.Label(self.date_to_frame, text="To date:").grid(row=0, column=0, sticky=tk.W) - self.date_to_var = tk.StringVar() - self.date_to_entry = ttk.Entry(self.date_to_frame, textvariable=self.date_to_var, width=12, state="readonly") - self.date_to_entry.grid(row=0, column=1, padx=(6, 0)) - - # Calendar button for date to - def open_calendar_to(): - current_date = self.date_to_var.get() - selected_date = self.gui_core.create_calendar_dialog(panel, "Select To Date", current_date) - if selected_date is not None: - self.date_to_var.set(selected_date) - - self.date_to_btn = ttk.Button(self.date_to_frame, text="šŸ“…", width=3, command=open_calendar_to) - self.date_to_btn.grid(row=0, column=2, padx=(6, 0)) - ttk.Label(self.date_to_frame, text="(YYYY-MM-DD, optional)").grid(row=0, column=3, padx=(6, 0)) - - # Planned inputs (stubs) - self.planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888") - - # Results area - results_frame = ttk.Frame(panel) - results_frame.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10)) - results_frame.columnconfigure(0, weight=1) - results_frame.rowconfigure(1, weight=1) - - # Results header with count - results_header = ttk.Frame(results_frame) - results_header.grid(row=0, column=0, sticky=(tk.W, tk.E)) - results_label = ttk.Label(results_header, text="Results:", font=("Arial", 10, "bold")) - results_label.grid(row=0, column=0, sticky=tk.W) - self.results_count_label = ttk.Label(results_header, text="(0 items)", font=("Arial", 10), foreground="gray") - self.results_count_label.grid(row=0, column=1, padx=(6, 0)) - - columns = ("select", "person", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - self.tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse") - self.tree.heading("select", text="ā˜‘") - self.tree.heading("person", text="Person", command=lambda: self.sort_treeview("person")) - self.tree.heading("tags", text="Tags", command=lambda: self.sort_treeview("tags")) - self.tree.heading("processed", text="Processed", command=lambda: self.sort_treeview("processed")) - self.tree.heading("open_dir", text="šŸ“") - self.tree.heading("open_photo", text="šŸ‘¤") - self.tree.heading("path", text="Photo path", command=lambda: self.sort_treeview("path")) - self.tree.heading("date_taken", text="Date Taken", command=lambda: self.sort_treeview("date_taken")) - self.tree.column("select", width=50, anchor="center") - self.tree.column("person", width=180, anchor="w") - self.tree.column("tags", width=200, anchor="w") - self.tree.column("processed", width=80, anchor="center") - self.tree.column("open_dir", width=50, anchor="center") - self.tree.column("open_photo", width=50, anchor="center") - self.tree.column("path", width=400, anchor="w") - self.tree.column("date_taken", width=100, anchor="center") - - # Add vertical scrollbar for the treeview - tree_v_scrollbar = ttk.Scrollbar(results_frame, orient="vertical", command=self.tree.yview) - self.tree.configure(yscrollcommand=tree_v_scrollbar.set) - - # Pack treeview and scrollbar - self.tree.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(4, 0)) - tree_v_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S), pady=(4, 0)) - - # Buttons - btns = ttk.Frame(panel) - btns.grid(row=4, column=0, sticky=(tk.W, tk.E), pady=(8, 0)) - search_btn = ttk.Button(btns, text="Search", command=lambda: self.do_search()) - search_btn.grid(row=0, column=0, sticky=tk.W) - tag_btn = ttk.Button(btns, text="Tag selected photos", command=lambda: self.tag_selected_photos()) - tag_btn.grid(row=0, column=1, padx=(6, 0), sticky=tk.W) - clear_btn = ttk.Button(btns, text="Clear all selected", command=lambda: self.clear_all_selected()) - clear_btn.grid(row=0, column=2, padx=(6, 0), sticky=tk.W) - - # Set up event handlers - type_combo.bind("<>", self.switch_inputs) - self.switch_inputs() - self.tree.bind("", self.on_tree_click) - self.tree.bind("", self.on_tree_motion) - self.tree.bind("", self.hide_tooltip) - - # Enter key bindings - self.name_entry.bind("", lambda e: self.do_search()) - self.tag_entry.bind("", lambda e: self.do_search()) - folder_entry.bind("", lambda e: self.do_search()) - - # Initialize tooltip system - self.tooltip = None - - # Set up help icon tooltip - self._setup_help_icon_tooltip() - - return panel - - def _setup_help_icon_tooltip(self): - """Set up tooltip for the help icon""" - def show_available_tags_tooltip(event): - # Get all available tags from database - try: - tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings() - available_tags = sorted(tag_name_to_id.keys()) - - if available_tags: - # Create tooltip with tags in a column format - tag_list = "\n".join(available_tags) - tooltip_text = f"Available tags:\n{tag_list}" - else: - tooltip_text = "No tags available in database" - - self.show_tooltip(self.tag_help_icon, event.x_root, event.y_root, tooltip_text) - except Exception: - self.show_tooltip(self.tag_help_icon, event.x_root, event.y_root, "Error loading tags") - - # Bind tooltip events to help icon - self.tag_help_icon.bind("", show_available_tags_tooltip) - self.tag_help_icon.bind("", self.hide_tooltip) - - def switch_inputs(self, *_): - """Switch input fields based on search type""" - # Clear results when search type changes - self.clear_results() - - for w in self.name_frame.master.winfo_children(): - if w != self.name_frame.master: # Don't hide the inputs frame itself - w.grid_remove() - - choice = self.search_type_var.get() - if choice == self.SEARCH_TYPES[0]: # Search photos by name - self.name_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - self.name_entry.configure(state="normal") - self.tag_entry.configure(state="disabled") - self.tag_mode_combo.configure(state="disabled") - self.date_from_entry.configure(state="disabled") - self.date_to_entry.configure(state="disabled") - self.date_from_btn.configure(state="disabled") - self.date_to_btn.configure(state="disabled") - # Show person column for name search - self.tree.column("person", width=180, minwidth=50, anchor="w") - self.tree.heading("person", text="Person", command=lambda: self.sort_treeview("person")) - # Restore people icon column for name search - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Restore all columns to display (hide processed column for name search) - self.tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[1]: # Search photos by date - self.date_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - self.date_to_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0)) - self.name_entry.configure(state="disabled") - self.tag_entry.configure(state="disabled") - self.tag_mode_combo.configure(state="disabled") - self.date_from_entry.configure(state="readonly") - self.date_to_entry.configure(state="readonly") - self.date_from_btn.configure(state="normal") - self.date_to_btn.configure(state="normal") - # Hide person column for date search - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Restore people icon column for date search - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for date search - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[2]: # Search photos by tags - self.tag_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - self.tag_mode_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0)) - self.name_entry.configure(state="disabled") - self.tag_entry.configure(state="normal") - self.tag_mode_combo.configure(state="readonly") - self.date_from_entry.configure(state="disabled") - self.date_to_entry.configure(state="disabled") - self.date_from_btn.configure(state="disabled") - self.date_to_btn.configure(state="disabled") - # Hide person column completely for tag search - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Restore people icon column for tag search - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Also hide the column from display - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[6]: # Photos without faces - # No input needed for this search type - # Hide person column since photos without faces won't have person info - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Hide the people icon column since there are no faces/people - self.tree.column("open_photo", width=0, minwidth=0, anchor="center") - self.tree.heading("open_photo", text="") - # Also hide the columns from display - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "path", "date_taken") - # Auto-run search for photos without faces - self.do_search() - elif choice == self.SEARCH_TYPES[7]: # Photos without tags - # No input needed for this search type - # Hide person column for photos without tags search - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Show the people icon column since there might be faces/people - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for photos without tags search - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - # Auto-run search for photos without tags - self.do_search() - else: - self.planned_label.grid(row=0, column=0, sticky=tk.W) - self.name_entry.configure(state="disabled") - self.tag_entry.configure(state="disabled") - self.tag_mode_combo.configure(state="disabled") - self.date_from_entry.configure(state="disabled") - self.date_to_entry.configure(state="disabled") - self.date_from_btn.configure(state="disabled") - self.date_to_btn.configure(state="disabled") - # Hide person column for other search types - self.tree.column("person", width=0, minwidth=0, anchor="w") - self.tree.heading("person", text="") - # Restore people icon column for other search types - self.tree.column("open_photo", width=50, minwidth=50, anchor="center") - self.tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for other search types - self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - - def filter_results_by_folder(self, results, folder_path): - """Filter search results by folder path if specified.""" - if not folder_path or not folder_path.strip(): - return results - - folder_path = folder_path.strip() - filtered_results = [] - - for result in results: - if len(result) >= 1: - # Extract photo path from result tuple (always at index 0) - photo_path = result[0] - - # Check if photo path starts with the specified folder path - if photo_path.startswith(folder_path): - filtered_results.append(result) - - return filtered_results - - def clear_results(self): - """Clear all results from the treeview""" - for i in self.tree.get_children(): - self.tree.delete(i) - # Reset sorting state for new search - self.sort_column = None - self.sort_reverse = False - # Clear selection tracking - self.selected_photos.clear() - # Clear tag cache - self.photo_tags_cache.clear() - # Reset results count - self.results_count_label.config(text="(0 items)") - self.update_header_display() - - def add_results(self, rows): - """Add search results to the treeview""" - # rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search or List[(path, date_taken)] for date search - for row in rows: - if len(row) == 2: - if self.search_type_var.get() == self.SEARCH_TYPES[1]: # Date search - # For date search: (path, date_taken) - hide person column - path, date_taken = row - photo_tags = self.get_photo_tags_for_display(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - elif self.search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search - # For tag search: (path, tag_info) - hide person column - # Show ALL tags for the photo, not just matching ones - path, tag_info = row - photo_tags = self.get_photo_tags_for_display(path) - date_taken = self.get_photo_date_taken(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - elif self.search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces - # For photos without faces: (path, tag_info) - hide person and people icon columns - path, tag_info = row - photo_tags = self.get_photo_tags_for_display(path) - date_taken = self.get_photo_date_taken(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "", path, date_taken)) - elif self.search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags - # For photos without tags: (path, filename) - hide person column - path, filename = row - photo_tags = self.get_photo_tags_for_display(path) # Will be "No tags" - date_taken = self.get_photo_date_taken(path) - processed_status = self.get_photo_processed_status(path) - self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - else: - # For name search: (path, full_name) - show person column - p, full_name = row - # Get tags for this photo - photo_tags = self.get_photo_tags_for_display(p) - date_taken = self.get_photo_date_taken(p) - processed_status = self.get_photo_processed_status(p) - self.tree.insert("", tk.END, values=("☐", full_name, photo_tags, processed_status, "šŸ“", "šŸ‘¤", p, date_taken)) - - # Sort by appropriate column by default when results are first loaded - if rows and self.sort_column is None: - if self.search_type_var.get() == self.SEARCH_TYPES[1]: # Date search - # Sort by date_taken column for date search - self.sort_column = "date_taken" - elif self.search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search - # Sort by tags column for tag search - self.sort_column = "tags" - elif self.search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces - # Sort by path column for photos without faces - self.sort_column = "path" - elif self.search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags - # Sort by path column for photos without tags (person column is hidden) - self.sort_column = "path" - else: - # Sort by person column for name search - self.sort_column = "person" - - self.sort_reverse = False - # Get all items and sort them directly - items = [(self.tree.set(child, self.sort_column), child) for child in self.tree.get_children('')] - if self.sort_column == 'date_taken': - # Sort by date, handling "No date" entries - def date_sort_key(item): - date_str = item[0] - if date_str == "No date": - return "9999-12-31" # Put "No date" entries at the end - return date_str - items.sort(key=date_sort_key, reverse=False) # Ascending - else: - items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending - # Reorder items in treeview - for index, (val, child) in enumerate(items): - self.tree.move(child, '', index) - # Update header display - self.update_header_display() - - # Update results count - item_count = len(self.tree.get_children()) - self.results_count_label.config(text=f"({item_count} items)") - - def do_search(self): - """Perform the search based on current search type and parameters""" - self.clear_results() - choice = self.search_type_var.get() - folder_filter = self.folder_var.get().strip() - - if choice == self.SEARCH_TYPES[0]: # Search photos by name - query = self.name_var.get().strip() - if not query: - messagebox.showinfo("Search", "Please enter a name to search.", parent=self.parent_frame) - return - rows = self.search_stats.search_faces(query) - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found for '{query}'{folder_msg}.", parent=self.parent_frame) - self.add_results(rows) - elif choice == self.SEARCH_TYPES[1]: # Search photos by date - date_from = self.date_from_var.get().strip() - date_to = self.date_to_var.get().strip() - - # Validate date format if provided - if date_from: - try: - from datetime import datetime - datetime.strptime(date_from, '%Y-%m-%d') - except ValueError: - messagebox.showerror("Invalid Date", "From date must be in YYYY-MM-DD format.", parent=self.parent_frame) - return - - if date_to: - try: - from datetime import datetime - datetime.strptime(date_to, '%Y-%m-%d') - except ValueError: - messagebox.showerror("Invalid Date", "To date must be in YYYY-MM-DD format.", parent=self.parent_frame) - return - - # Check if at least one date is provided - if not date_from and not date_to: - messagebox.showinfo("Search", "Please enter at least one date (from date or to date).", parent=self.parent_frame) - return - - rows = self.search_stats.search_photos_by_date(date_from if date_from else None, - date_to if date_to else None) - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - date_range_text = "" - if date_from and date_to: - date_range_text = f" between {date_from} and {date_to}" - elif date_from: - date_range_text = f" from {date_from}" - elif date_to: - date_range_text = f" up to {date_to}" - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found{date_range_text}{folder_msg}.", parent=self.parent_frame) - else: - # Convert to the format expected by add_results: (path, date_taken) - formatted_rows = [(path, date_taken) for path, date_taken in rows] - self.add_results(formatted_rows) - elif choice == self.SEARCH_TYPES[2]: # Search photos by tags - tag_query = self.tag_var.get().strip() - if not tag_query: - messagebox.showinfo("Search", "Please enter tags to search for.", parent=self.parent_frame) - return - - # Parse comma-separated tags - tags = [tag.strip() for tag in tag_query.split(',') if tag.strip()] - if not tags: - messagebox.showinfo("Search", "Please enter valid tags to search for.", parent=self.parent_frame) - return - - # Determine match mode - match_all = (self.tag_mode_var.get() == "ALL") - - rows = self.search_stats.search_photos_by_tags(tags, match_all) - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - mode_text = "all" if match_all else "any" - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found with {mode_text} of the tags: {', '.join(tags)}{folder_msg}", parent=self.parent_frame) - self.add_results(rows) - elif choice == self.SEARCH_TYPES[6]: # Photos without faces - rows = self.search_stats.get_photos_without_faces() - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos without faces found{folder_msg}.", parent=self.parent_frame) - else: - # Convert to the format expected by add_results: (path, tag_info) - # For photos without faces, we don't have person info, so we use empty string - formatted_rows = [(path, "") for path, filename in rows] - self.add_results(formatted_rows) - elif choice == self.SEARCH_TYPES[7]: # Photos without tags - rows = self.search_stats.get_photos_without_tags() - # Apply folder filter - rows = self.filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos without tags found{folder_msg}.", parent=self.parent_frame) - else: - # Convert to the format expected by add_results: (path, filename) - # For photos without tags, we have both path and filename - formatted_rows = [(path, filename) for path, filename in rows] - self.add_results(formatted_rows) - - def sort_treeview(self, col: str): - """Sort the treeview by the specified column.""" - # Get all items and their values - items = [(self.tree.set(child, col), child) for child in self.tree.get_children('')] - - # Determine sort direction - if self.sort_column == col: - # Same column clicked - toggle direction - self.sort_reverse = not self.sort_reverse - else: - # Different column clicked - start with ascending - self.sort_reverse = False - self.sort_column = col - - # Sort the items - # For person, tags, and path columns, sort alphabetically - # For date_taken column, sort by date - # For processed column, sort by processed status (Yes/No) - # For icon columns, maintain original order - if col in ['person', 'tags', 'path']: - items.sort(key=lambda x: x[0].lower(), reverse=self.sort_reverse) - elif col == 'date_taken': - # Sort by date, handling "No date" entries - def date_sort_key(item): - date_str = item[0] - if date_str == "No date": - return "9999-12-31" # Put "No date" entries at the end - return date_str - items.sort(key=date_sort_key, reverse=self.sort_reverse) - elif col == 'processed': - # Sort by processed status (Yes comes before No) - def processed_sort_key(item): - processed_str = item[0] - if processed_str == "Yes": - return "0" # Yes comes first - else: - return "1" # No comes second - items.sort(key=processed_sort_key, reverse=self.sort_reverse) - else: - # For icon columns, just reverse if clicking same column - if self.sort_column == col and self.sort_reverse: - items.reverse() - - # Reorder items in treeview - for index, (val, child) in enumerate(items): - self.tree.move(child, '', index) - - # Update header display - self.update_header_display() - - def update_header_display(self): - """Update header display to show sort indicators.""" - # Reset all headers - self.tree.heading("person", text="Person") - self.tree.heading("tags", text="Tags") - self.tree.heading("processed", text="Processed") - self.tree.heading("path", text="Photo path") - self.tree.heading("date_taken", text="Date Taken") - - # Add sort indicator to current sort column - if self.sort_column == "person": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("person", text="Person" + indicator) - elif self.sort_column == "tags": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("tags", text="Tags" + indicator) - elif self.sort_column == "processed": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("processed", text="Processed" + indicator) - elif self.sort_column == "path": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("path", text="Photo path" + indicator) - elif self.sort_column == "date_taken": - indicator = " ↓" if self.sort_reverse else " ↑" - self.tree.heading("date_taken", text="Date Taken" + indicator) - - def on_tree_click(self, event): - """Handle clicks on the treeview""" - region = self.tree.identify("region", event.x, event.y) - if region != "cell": - return - row_id = self.tree.identify_row(event.y) - col_id = self.tree.identify_column(event.x) # '#1', '#2', ... - if not row_id or not col_id: - return - vals = self.tree.item(row_id, "values") - if not vals or len(vals) < 6: - return - - # Get the actual column name from the displaycolumns - display_columns = self.tree["displaycolumns"] - col_index = int(col_id[1:]) - 1 # Convert '#1' to 0, '#2' to 1, etc. - - if col_index < 0 or col_index >= len(display_columns): - return - - column_name = display_columns[col_index] - path = vals[6] # Photo path is always at index 6 in values array - - if column_name == "open_dir": # Open directory column - self.open_dir(path) - elif column_name == "open_photo": # Face icon column - # No popup needed, just tooltip - pass - elif column_name == "path": # Photo path column - clickable to open photo - try: - import os - import sys - if os.name == "nt": - os.startfile(path) # type: ignore[attr-defined] - elif sys.platform == "darwin": - import subprocess - subprocess.run(["open", path], check=False) - else: - import subprocess - subprocess.run(["xdg-open", path], check=False) - except Exception: - messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=self.parent_frame) - elif column_name == "select": # Checkbox column - self.toggle_photo_selection(row_id, vals) - - def on_tree_motion(self, event): - """Handle mouse motion over the treeview for tooltips""" - region = self.tree.identify("region", event.x, event.y) - if region != "cell": - self.hide_tooltip() - self.tree.config(cursor="") - return - col_id = self.tree.identify_column(event.x) - row_id = self.tree.identify_row(event.y) - - # Get the actual column name from the displaycolumns - display_columns = self.tree["displaycolumns"] - col_index = int(col_id[1:]) - 1 # Convert '#1' to 0, '#2' to 1, etc. - - if col_index < 0 or col_index >= len(display_columns): - self.tree.config(cursor="") - self.hide_tooltip() - return - - column_name = display_columns[col_index] - - if column_name == "tags": # Tags column - self.tree.config(cursor="") - # Show tags tooltip - if row_id: - vals = self.tree.item(row_id, "values") - if len(vals) >= 3: - # Tags are at index 2 for all search types (after select, person is hidden in most) - tags_text = vals[2] - self.show_tooltip(self.tree, event.x_root, event.y_root, f"Tags: {tags_text}") - elif column_name == "open_dir": # Open directory column - self.tree.config(cursor="hand2") - self.show_tooltip(self.tree, event.x_root, event.y_root, "Open file location") - elif column_name == "open_photo": # Face icon column - self.tree.config(cursor="hand2") - # Show people tooltip - if row_id: - vals = self.tree.item(row_id, "values") - if len(vals) >= 6: - path = vals[6] # Photo path is always at index 6 - people_text = self.get_photo_people_tooltip(path) - self.show_tooltip(self.tree, event.x_root, event.y_root, people_text) - elif column_name == "path": # Photo path column - self.tree.config(cursor="hand2") - self.show_tooltip(self.tree, event.x_root, event.y_root, "Open photo") - else: - self.tree.config(cursor="") - self.hide_tooltip() - - def show_tooltip(self, widget, x, y, text: str): - """Show a tooltip""" - self.hide_tooltip() - try: - self.tooltip = tk.Toplevel(widget) - self.tooltip.wm_overrideredirect(True) - self.tooltip.wm_geometry(f"+{x+12}+{y+12}") - lbl = tk.Label(self.tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9)) - lbl.pack() - except Exception: - self.tooltip = None - - def hide_tooltip(self, *_): - """Hide the current tooltip""" - if self.tooltip is not None: - try: - self.tooltip.destroy() - except Exception: - pass - self.tooltip = None - - def open_dir(self, path: str): - """Open the directory containing the photo""" - try: - import os - import sys - folder = os.path.dirname(path) - if os.name == "nt": - os.startfile(folder) # type: ignore[attr-defined] - elif sys.platform == "darwin": - import subprocess - subprocess.run(["open", folder], check=False) - else: - import subprocess - subprocess.run(["xdg-open", folder], check=False) - except Exception: - messagebox.showerror("Open Location", "Failed to open the file location.", parent=self.parent_frame) - - def toggle_photo_selection(self, row_id, vals): - """Toggle checkbox selection for a photo.""" - if len(vals) < 7: - return - current_state = vals[0] # Checkbox is now in column 0 (first) - path = vals[6] # Photo path is now in column 6 (last) - if current_state == "☐": - # Select photo - new_state = "ā˜‘" - self.selected_photos[path] = { - 'person': vals[1], # Person is now in column 1 - 'path': path - } - else: - # Deselect photo - new_state = "☐" - if path in self.selected_photos: - del self.selected_photos[path] - - # Update the treeview - new_vals = list(vals) - new_vals[0] = new_state - self.tree.item(row_id, values=new_vals) - - def tag_selected_photos(self): - """Open linkage dialog for selected photos.""" - if not self.selected_photos: - messagebox.showinfo("Tag Photos", "Please select photos to tag first.", parent=self.parent_frame) - return - - # Get photo IDs for selected photos - selected_photo_ids = [] - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for path in self.selected_photos.keys(): - cursor.execute('SELECT id FROM photos WHERE path = ?', (path,)) - result = cursor.fetchone() - if result: - selected_photo_ids.append(result[0]) - - if not selected_photo_ids: - messagebox.showerror("Tag Photos", "Could not find photo IDs for selected photos.", parent=self.parent_frame) - return - - # Open the linkage dialog - self.open_linkage_dialog(selected_photo_ids) - - def clear_all_selected(self): - """Clear all selected photos and update checkboxes.""" - if not self.selected_photos: - return - - # Clear the selection tracking - self.selected_photos.clear() - - # Update all checkboxes to unselected state - for item in self.tree.get_children(): - vals = self.tree.item(item, "values") - if len(vals) >= 7 and vals[0] == "ā˜‘": - new_vals = list(vals) - new_vals[0] = "☐" - self.tree.item(item, values=new_vals) - - def get_photo_tags_for_display(self, photo_path): - """Get tags for a photo to display in the tags column.""" - # Check cache first - if photo_path in self.photo_tags_cache: - tag_names = self.photo_tags_cache[photo_path] - else: - # Load from database and cache - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if not result: - return "No photo found" - - photo_id = result[0] - cursor.execute(''' - SELECT t.tag_name - FROM tags t - JOIN phototaglinkage ptl ON t.id = ptl.tag_id - WHERE ptl.photo_id = ? - ORDER BY t.tag_name - ''', (photo_id,)) - tag_names = [row[0] for row in cursor.fetchall()] - self.photo_tags_cache[photo_path] = tag_names - except Exception: - return "No tags" - - # Format for display - show all tags - if tag_names: - return ', '.join(tag_names) - else: - return "No tags" - - def get_photo_date_taken(self, photo_path): - """Get date_taken for a photo to display in the date_taken column.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT date_taken FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result and result[0]: - return result[0] # Return the date as stored in database - else: - return "No date" # No date_taken available - except Exception: - return "No date" - - def get_photo_processed_status(self, photo_path): - """Get processed status for a photo to display in the processed column.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT processed FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result and result[0] is not None: - return "Yes" if result[0] else "No" - else: - return "No" # Default to not processed - except Exception: - return "No" - - def get_photo_people_tooltip(self, photo_path): - """Get people information for a photo to display in tooltip.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT DISTINCT pe.first_name, pe.last_name, pe.middle_name, pe.maiden_name - FROM photos p - JOIN faces f ON p.id = f.photo_id - JOIN people pe ON f.person_id = pe.id - WHERE p.path = ? AND f.person_id IS NOT NULL - ORDER BY pe.last_name, pe.first_name - ''', (photo_path,)) - people = cursor.fetchall() - - if not people: - return "No people identified" - - people_names = [] - for person in people: - first = (person[0] or "").strip() - last = (person[1] or "").strip() - middle = (person[2] or "").strip() - maiden = (person[3] or "").strip() - - # Build full name - name_parts = [] - if first: - name_parts.append(first) - if middle: - name_parts.append(middle) - if last: - name_parts.append(last) - if maiden and maiden != last: - name_parts.append(f"({maiden})") - - full_name = " ".join(name_parts) if name_parts else "Unknown" - people_names.append(full_name) - - if people_names: - if len(people_names) <= 3: - return f"People: {', '.join(people_names)}" - else: - return f"People: {', '.join(people_names[:3])}... (+{len(people_names)-3} more)" - else: - return "No people identified" - except Exception: - pass - return "No people identified" - - def open_linkage_dialog(self, photo_ids): - """Open the linkage dialog for selected photos using tag manager functionality.""" - popup = tk.Toplevel(self.parent_frame) - popup.title("Tag Selected Photos") - popup.transient(self.parent_frame) - popup.grab_set() - popup.geometry("500x400") - popup.resizable(True, True) - - # Track tag changes for updating results - tags_added = set() # tag names that were added - tags_removed = set() # tag names that were removed - - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - popup.columnconfigure(0, weight=1) - popup.rowconfigure(1, weight=1) - - ttk.Label(top_frame, text=f"Selected Photos: {len(photo_ids)}").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6)) - ttk.Label(top_frame, text="Add tag:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W) - - # Get existing tags using tag manager - tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings() - existing_tags = sorted(tag_name_to_id.keys()) - - tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=tag_var, values=existing_tags, width=30) - combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - def add_selected_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - - # Resolve or create tag id (case-insensitive) - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in tag_name_to_id: - tag_id = tag_name_to_id[normalized_tag_name] - else: - # Create new tag in database using the database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - # Update mappings - tag_name_to_id[normalized_tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - # Update the combobox values to include the new tag - combo['values'] = existing_tags - else: - messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup) - return - - # Add tag to all selected photos with single linkage type (0) - affected = 0 - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - # Check if tag already exists for this photo - cursor.execute('SELECT linkage_id FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - if not cursor.fetchone(): - # Add the tag with single linkage type (0) - cursor.execute('INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) VALUES (?, ?, 0)', (photo_id, tag_id)) - affected += 1 - - # Track that this tag was added - if affected > 0: - tags_added.add(tag_name) - - # Refresh the tag list to show the new tag - refresh_tag_list() - tag_var.set("") - - ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=1, column=2, padx=(8, 0)) - - # Allow Enter key to add tag - combo.bind('', lambda e: add_selected_tag()) - - # Create scrollable tag list - canvas = tk.Canvas(list_frame, height=200) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - selected_tag_vars = {} - - def refresh_tag_list(): - for widget in scrollable_frame.winfo_children(): - widget.destroy() - selected_tag_vars.clear() - - # Get tags that exist in ALL selected photos - # First, get all tags for each photo - photo_tags = {} # photo_id -> set of tag_ids - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - photo_tags[photo_id] = set() - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,)) - for row in cursor.fetchall(): - photo_tags[photo_id].add(row[0]) - - # Find intersection - tags that exist in ALL selected photos - if not photo_tags: - ttk.Label(scrollable_frame, text="No tags linked to selected photos", foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Start with tags from first photo, then intersect with others - common_tag_ids = set(photo_tags[photo_ids[0]]) - for photo_id in photo_ids[1:]: - common_tag_ids = common_tag_ids.intersection(photo_tags[photo_id]) - - if not common_tag_ids: - ttk.Label(scrollable_frame, text="No common tags found across all selected photos", foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Get linkage type information for common tags - # For tags that exist in all photos, we need to determine the linkage type - # If a tag has different linkage types across photos, we'll show the most restrictive - common_tag_data = {} # tag_id -> {linkage_type, photo_count} - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id IN ({})'.format(','.join('?' * len(common_tag_ids))), [photo_id] + list(common_tag_ids)) - for row in cursor.fetchall(): - tag_id = row[0] - linkage_type = int(row[1]) if row[1] is not None else 0 - if tag_id not in common_tag_data: - common_tag_data[tag_id] = {'linkage_type': linkage_type, 'photo_count': 0} - common_tag_data[tag_id]['photo_count'] += 1 - # If we find a bulk linkage type (1), use that as it's more restrictive - if linkage_type == 1: - common_tag_data[tag_id]['linkage_type'] = 1 - - # Sort tags by name for consistent display - for tag_id in sorted(common_tag_data.keys()): - tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}") - var = tk.BooleanVar() - selected_tag_vars[tag_name] = var - frame = ttk.Frame(scrollable_frame) - frame.pack(fill=tk.X, pady=1) - - # Determine if this tag can be selected for deletion - # In single linkage dialog, only allow deleting single linkage type (0) tags - linkage_type = common_tag_data[tag_id]['linkage_type'] - can_select = (linkage_type == 0) # Only single linkage type can be deleted - - cb = ttk.Checkbutton(frame, variable=var) - if not can_select: - try: - cb.state(["disabled"]) # disable selection for bulk tags - except Exception: - pass - cb.pack(side=tk.LEFT, padx=(0, 5)) - - # Display tag name with status information - type_label = 'single' if linkage_type == 0 else 'bulk' - photo_count = common_tag_data[tag_id]['photo_count'] - status_text = f" (saved {type_label})" - status_color = "black" if can_select else "gray" - ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT) - - def remove_selected_tags(): - tag_ids_to_remove = [] - tag_names_to_remove = [] - for tag_name, var in selected_tag_vars.items(): - if var.get() and tag_name in tag_name_to_id: - tag_ids_to_remove.append(tag_name_to_id[tag_name]) - tag_names_to_remove.append(tag_name) - - if not tag_ids_to_remove: - return - - # Only remove single linkage type tags (bulk tags should be disabled anyway) - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - for tag_id in tag_ids_to_remove: - # Double-check that this is a single linkage type before deleting - cursor.execute('SELECT linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - result = cursor.fetchone() - if result and int(result[0]) == 0: # Only delete single linkage type - cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - - # Track that these tags were removed - tags_removed.update(tag_names_to_remove) - - refresh_tag_list() - - def update_search_results(): - """Update the search results to reflect tag changes without database access.""" - if not tags_added and not tags_removed: - return # No changes to apply - - # Get photo paths for the affected photos from selected_photos - affected_photo_paths = set(self.selected_photos.keys()) - - # Update cache for affected photos - for photo_path in affected_photo_paths: - if photo_path in self.photo_tags_cache: - # Update cached tags based on changes - current_tags = set(self.photo_tags_cache[photo_path]) - # Add new tags - current_tags.update(tags_added) - # Remove deleted tags - current_tags.difference_update(tags_removed) - # Update cache with sorted list - self.photo_tags_cache[photo_path] = sorted(list(current_tags)) - - # Update each affected row in the search results - for item in self.tree.get_children(): - vals = self.tree.item(item, "values") - if len(vals) >= 7: - photo_path = vals[6] # Photo path is at index 6 - if photo_path in affected_photo_paths: - # Get current tags for this photo from cache - current_tags = self.get_photo_tags_for_display(photo_path) - # Update the tags column (index 2) - new_vals = list(vals) - new_vals[2] = current_tags - self.tree.item(item, values=new_vals) - - def close_dialog(): - """Close dialog and update search results if needed.""" - update_search_results() - popup.destroy() - - ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8)) - 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 - -class DashboardGUI: - """Unified Dashboard with menu bar and content area for all features. - - Designed to be web-migration friendly with clear separation between - navigation (menu bar) and content (panels). - """ - - def __init__(self, gui_core: GUICore, db_manager=None, face_processor=None, on_scan=None, on_process=None, on_identify=None, search_stats=None, tag_manager=None): - self.gui_core = gui_core - self.db_manager = db_manager - self.face_processor = face_processor - self.on_scan = on_scan - self.on_process = on_process - self.on_identify = on_identify - self.search_stats = search_stats - self.tag_manager = tag_manager - - # Panel management for future web migration - self.panels: Dict[str, ttk.Frame] = {} - self.current_panel: Optional[str] = None - self.root: Optional[tk.Tk] = None - - def open(self) -> int: - """Open the unified dashboard with menu bar and content area""" - self.root = tk.Tk() - self.root.title("PunimTag - Unified Dashboard") - self.root.resizable(True, True) - self.root.withdraw() - - # Make window full screen - use geometry instead of state for better compatibility - try: - # Try Windows-style maximized state first - self.root.state('zoomed') - except tk.TclError: - try: - # Try Linux-style maximized attribute - self.root.attributes('-zoomed', True) - except tk.TclError: - # Fallback: set geometry to screen size - screen_width = self.root.winfo_screenwidth() - screen_height = self.root.winfo_screenheight() - self.root.geometry(f"{screen_width}x{screen_height}+0+0") - - # Get screen dimensions for dynamic sizing - screen_width = self.root.winfo_screenwidth() - screen_height = self.root.winfo_screenheight() - - # Set minimum window size - self.root.minsize(800, 600) - - # Create main container with proper grid configuration - main_container = ttk.Frame(self.root) - main_container.pack(fill=tk.BOTH, expand=True) - - # Configure main container grid weights for responsiveness - main_container.columnconfigure(0, weight=1) - main_container.rowconfigure(0, weight=0) # Menu bar - fixed height - main_container.rowconfigure(1, weight=0) # Separator - fixed height - main_container.rowconfigure(2, weight=1) # Content area - expandable - - # Add window resize handler for dynamic responsiveness - self.root.bind('', self._on_window_resize) - - # Create menu bar - self._create_menu_bar(main_container) - - # Create content area - self._create_content_area(main_container) - - # Initialize panels - self._initialize_panels() - - # Show default panel - self.show_panel("home") - - # Show window - self.root.deiconify() - self.root.mainloop() - return 0 - - def _create_menu_bar(self, parent: ttk.Frame): - """Create the top menu bar with all functionality buttons""" - menu_frame = ttk.Frame(parent) - menu_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=15, pady=10) - menu_frame.columnconfigure(0, weight=0) # Title - fixed width - menu_frame.columnconfigure(1, weight=1) # Buttons - expandable - menu_frame.columnconfigure(2, weight=0) # Status - fixed width - - # Title with larger font for full screen - title_label = tk.Label(menu_frame, text="PunimTag", font=("Arial", 20, "bold")) - title_label.grid(row=0, column=0, padx=(0, 30), sticky=tk.W) - - # Create buttons frame for better organization - buttons_frame = ttk.Frame(menu_frame) - buttons_frame.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=20) - - # Menu buttons with larger size for full screen - menu_buttons = [ - ("šŸ ", "home", "Go to the welcome screen"), - ("šŸ“ Scan", "scan", "Scan folders and add photos"), - ("šŸ” Process", "process", "Detect faces in photos"), - ("šŸ‘¤ Identify", "identify", "Identify faces in photos"), - ("šŸŽÆ Auto-Match", "auto_match", "Find and confirm matching faces"), - ("šŸ”Ž Search", "search", "Search photos by people, dates, tags, and more"), - ("āœļø Edit Identified", "modify", "View and modify identified faces"), - ("šŸ·ļø Tag Photos", "tags", "Manage photo tags"), - ] - - for i, (text, panel_name, tooltip) in enumerate(menu_buttons): - # Make home button smaller than other buttons - if panel_name == "home": - btn_width = 4 # Smaller width for icon-only home button - else: - btn_width = 16 # Standard width for other buttons - - btn = ttk.Button( - buttons_frame, - text=text, - command=lambda p=panel_name: self.show_panel(p), - width=btn_width - ) - btn.grid(row=0, column=i, padx=3, sticky=tk.W) - - # Add tooltip functionality - self._add_tooltip(btn, tooltip) - - # Status/Info area with better styling - status_frame = ttk.Frame(menu_frame) - status_frame.grid(row=0, column=2, sticky=tk.E, padx=(20, 0)) - - self.status_label = tk.Label(status_frame, text="Ready", foreground="#666", font=("Arial", 10)) - self.status_label.pack(side=tk.RIGHT) - - # Add a subtle separator line below the menu - separator = ttk.Separator(parent, orient='horizontal') - separator.grid(row=1, column=0, sticky=(tk.W, tk.E), padx=15, pady=(0, 5)) - - def _create_content_area(self, parent: ttk.Frame): - """Create the main content area where panels will be displayed""" - self.content_frame = ttk.Frame(parent) - self.content_frame.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=15, pady=(0, 15)) - - # Configure content frame to expand both horizontally and vertically - self.content_frame.columnconfigure(0, weight=1) - self.content_frame.rowconfigure(0, weight=1) - - # Add a subtle border - self.content_frame.configure(relief='sunken', borderwidth=1) - - def _initialize_panels(self): - """Initialize all panels (currently placeholders)""" - # Home panel (default) - self.panels["home"] = self._create_home_panel() - - # Functional panels (placeholders for now) - self.panels["scan"] = self._create_scan_panel() - self.panels["process"] = self._create_process_panel() - self.panels["identify"] = self._create_identify_panel() - self.panels["auto_match"] = self._create_auto_match_panel() - self.panels["search"] = self._create_search_panel() - self.panels["modify"] = self._create_modify_panel() - self.panels["tags"] = self._create_tags_panel() - - def show_panel(self, panel_name: str): - """Show the specified panel in the content area""" - if panel_name not in self.panels: - messagebox.showerror("Error", f"Panel '{panel_name}' not found", parent=self.root) - return - - # Deactivate current panel if it has activation/deactivation methods - if self.current_panel: - self.panels[self.current_panel].grid_remove() - # Deactivate identify panel if it's active - if hasattr(self, 'identify_panel') and self.identify_panel and self.current_panel == "identify": - self.identify_panel.deactivate() - # 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() - # Deactivate tag manager panel if it's active - if hasattr(self, 'tag_manager_panel') and self.tag_manager_panel and self.current_panel == "tags": - self.tag_manager_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) - self.current_panel = panel_name - - # Activate new panel if it has activation/deactivation methods - if panel_name == "identify" and hasattr(self, 'identify_panel') and self.identify_panel: - 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() - elif panel_name == "tags" and hasattr(self, 'tag_manager_panel') and self.tag_manager_panel: - self.tag_manager_panel.activate() - - # Update status - self.status_label.config(text=f"Viewing: {panel_name.replace('_', ' ').title()}") - - def _add_tooltip(self, widget, text): - """Add a simple tooltip to 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 = ttk.Label(tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1) - label.pack() - widget.tooltip = tooltip - - def hide_tooltip(event): - if hasattr(widget, 'tooltip'): - widget.tooltip.destroy() - del widget.tooltip - - widget.bind("", show_tooltip) - widget.bind("", hide_tooltip) - - def _on_window_resize(self, event): - """Handle window resize events for dynamic responsiveness""" - # Only handle resize events for the main window, not child widgets - if event.widget == self.root: - # Update status with current window size - width = self.root.winfo_width() - height = self.root.winfo_height() - self.status_label.config(text=f"Ready - {width}x{height}") - - # Force update of all panels to ensure proper resizing - if hasattr(self, 'identify_panel') and self.identify_panel: - # Update identify panel layout if it's active - if self.current_panel == "identify": - self.identify_panel.update_layout() - if hasattr(self, 'tag_manager_panel') and self.tag_manager_panel: - # Update tag manager panel layout if it's active - if self.current_panel == "tags": - self.tag_manager_panel.update_layout() - - def _create_home_panel(self) -> ttk.Frame: - """Create the home/welcome panel""" - 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 - - # Welcome content - welcome_frame = ttk.Frame(panel) - welcome_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N), padx=20, pady=20) - welcome_frame.columnconfigure(0, weight=1) - # Remove weight=1 to prevent vertical centering - # welcome_frame.rowconfigure(0, weight=1) - - # Content starts at the top instead of being centered - center_frame = ttk.Frame(welcome_frame) - center_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N)) - center_frame.columnconfigure(0, weight=1) - - # Title with larger font for full screen - title_label = tk.Label(center_frame, text="Welcome to PunimTag", font=("Arial", 32, "bold")) - title_label.grid(row=0, column=0, pady=(0, 30)) - - # Description with larger font - desc_text = ( - "PunimTag is a powerful photo face recognition and tagging system.\n\n" - "Use the menu above to access different features:\n\n" - "• šŸ“ Scan - Add photos to your collection\n" - "• šŸ” Process - Detect faces in photos\n" - "• šŸ‘¤ Identify - Identify people in photos\n" - "• šŸŽÆ Auto-Match - Find matching faces automatically\n" - "• šŸ”Ž Search - Search photos by people, dates, tags, and more\n" - "• āœļø Edit Identified - Edit face identifications\n" - "• šŸ·ļø Tag Photos - Manage photo tags\n\n" - "Select a feature from the menu to get started!" - ) - - desc_label = tk.Label(center_frame, text=desc_text, font=("Arial", 14), justify=tk.LEFT) - desc_label.grid(row=1, column=0, pady=(0, 20)) - - return panel - - def _create_scan_panel(self) -> ttk.Frame: - """Create the scan panel (migrated from original dashboard)""" - 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 - - # Title with larger font for full screen - title_label = tk.Label(panel, text="šŸ“ Scan Photos", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Scan form - form_frame = ttk.LabelFrame(panel, text="Scan Configuration", padding="20") - form_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 20)) - form_frame.columnconfigure(0, weight=1) - - # Folder selection - folder_frame = ttk.Frame(form_frame) - folder_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 15)) - folder_frame.columnconfigure(0, weight=1) - - tk.Label(folder_frame, text="Folder to scan:", font=("Arial", 12)).grid(row=0, column=0, sticky=tk.W) - - folder_input_frame = ttk.Frame(folder_frame) - folder_input_frame.grid(row=1, column=0, sticky=tk.W, pady=(5, 0)) - # Don't configure column weight since we want fixed-width text box - - self.folder_var = tk.StringVar() - folder_entry = tk.Entry(folder_input_frame, textvariable=self.folder_var, font=("Arial", 11), width=50) - folder_entry.grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) - - def browse_folder(): - from tkinter import filedialog - folder_path = filedialog.askdirectory(title="Select folder to scan for photos") - if folder_path: - self.folder_var.set(folder_path) - - browse_btn = ttk.Button(folder_input_frame, text="Browse", command=browse_folder) - browse_btn.grid(row=0, column=1, sticky=tk.W) - - # Recursive option - self.recursive_var = tk.BooleanVar(value=True) - recursive_check = tk.Checkbutton( - form_frame, - text="Include photos in sub-folders", - variable=self.recursive_var, - font=("Arial", 11) - ) - recursive_check.grid(row=1, column=0, sticky=tk.W, pady=(15, 0)) - - # Action button - scan_btn = ttk.Button(form_frame, text="šŸ” Start Scan", command=self._run_scan) - scan_btn.grid(row=2, column=0, sticky=tk.W, pady=(20, 0)) - - return panel - - def _create_process_panel(self) -> ttk.Frame: - """Create the process panel (migrated from original dashboard)""" - from src.core.config import DEEPFACE_DETECTOR_OPTIONS, DEEPFACE_MODEL_OPTIONS - from src.core.config import DEEPFACE_DETECTOR_BACKEND, DEEPFACE_MODEL_NAME - - 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 - - # Title with larger font for full screen - title_label = tk.Label(panel, text="šŸ” Process Faces", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Process form - form_frame = ttk.LabelFrame(panel, text="Processing Configuration", padding="20") - form_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 20)) - form_frame.columnconfigure(0, weight=1) - - # DeepFace Settings Section - deepface_frame = ttk.LabelFrame(form_frame, text="DeepFace Settings", padding="15") - deepface_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 15)) - deepface_frame.columnconfigure(1, weight=1) - - # Detector Backend Selection - tk.Label(deepface_frame, text="Face Detector:", font=("Arial", 11)).grid(row=0, column=0, sticky=tk.W, pady=(0, 10)) - self.detector_var = tk.StringVar(value=DEEPFACE_DETECTOR_BACKEND) - detector_combo = ttk.Combobox(deepface_frame, textvariable=self.detector_var, - values=DEEPFACE_DETECTOR_OPTIONS, - state="readonly", width=12, font=("Arial", 10)) - detector_combo.grid(row=0, column=1, sticky=tk.W, padx=(10, 0), pady=(0, 10)) - tk.Label(deepface_frame, text="(RetinaFace recommended for accuracy)", - font=("Arial", 9), fg="gray").grid(row=0, column=2, sticky=tk.W, padx=(10, 0), pady=(0, 10)) - - # Model Selection - tk.Label(deepface_frame, text="Recognition Model:", font=("Arial", 11)).grid(row=1, column=0, sticky=tk.W) - self.model_var = tk.StringVar(value=DEEPFACE_MODEL_NAME) - model_combo = ttk.Combobox(deepface_frame, textvariable=self.model_var, - values=DEEPFACE_MODEL_OPTIONS, - state="readonly", width=12, font=("Arial", 10)) - model_combo.grid(row=1, column=1, sticky=tk.W, padx=(10, 0)) - tk.Label(deepface_frame, text="(ArcFace provides best accuracy)", - font=("Arial", 9), fg="gray").grid(row=1, column=2, sticky=tk.W, padx=(10, 0)) - - # Limit option - limit_frame = ttk.Frame(form_frame) - limit_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 15)) - - self.limit_enabled = tk.BooleanVar(value=False) - limit_check = tk.Checkbutton(limit_frame, text="Limit processing to", variable=self.limit_enabled, font=("Arial", 11)) - limit_check.grid(row=0, column=0, sticky=tk.W) - - self.limit_var = tk.StringVar(value="50") - limit_entry = tk.Entry(limit_frame, textvariable=self.limit_var, width=8, font=("Arial", 11)) - limit_entry.grid(row=0, column=1, padx=(10, 5)) - - tk.Label(limit_frame, text="photos", font=("Arial", 11)).grid(row=0, column=2, sticky=tk.W) - - # Action button - self.process_btn = ttk.Button(form_frame, text="šŸš€ Start Processing", command=self._run_process) - self.process_btn.grid(row=2, column=0, sticky=tk.W, pady=(20, 0)) - - # Cancel button (initially hidden/disabled) - self.cancel_btn = tk.Button(form_frame, text="āœ– Cancel", command=self._cancel_process, state="disabled") - self.cancel_btn.grid(row=2, column=0, sticky=tk.E, pady=(20, 0)) - - # Progress bar - self.progress_var = tk.DoubleVar() - self.progress_bar = ttk.Progressbar(form_frame, variable=self.progress_var, - maximum=100, length=400, mode='determinate') - self.progress_bar.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=(15, 0)) - - # Progress status label - self.progress_status_var = tk.StringVar(value="Ready to process") - progress_status_label = tk.Label(form_frame, textvariable=self.progress_status_var, - font=("Arial", 11), fg="gray") - progress_status_label.grid(row=4, column=0, sticky=tk.W, pady=(5, 0)) - - return panel - - def _create_identify_panel(self) -> ttk.Frame: - """Create the identify panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: title (row 0) fixed, identify 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="šŸ‘¤ Identify Faces", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Create the identify panel if we have the required dependencies - if self.db_manager and self.face_processor: - self.identify_panel = IdentifyPanel(panel, self.db_manager, self.face_processor, self.gui_core, on_navigate_home=lambda: self.show_panel("home")) - identify_frame = self.identify_panel.create_panel() - identify_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) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = ( - "Identify panel requires database and face processor to be configured.\n\n" - "This will contain the full face identification interface\n" - "currently available in the separate Identify window.\n\n" - "Features will include:\n" - "• Face browsing and identification\n" - "• Similar face matching\n" - "• Person management\n" - "• Batch processing options" - ) - - 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 - - def _create_auto_match_panel(self) -> ttk.Frame: - """Create the auto-match panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: title (row 0) fixed, auto-match 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="šŸ”— Auto-Match Faces", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Create the auto-match panel if we have the required dependencies - if self.db_manager and self.face_processor: - self.auto_match_panel = AutoMatchPanel(panel, self.db_manager, self.face_processor, self.gui_core, on_navigate_home=lambda: self.show_panel("home")) - auto_match_frame = self.auto_match_panel.create_panel() - auto_match_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) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = ( - "Auto-Match panel requires database and face processor to be configured.\n\n" - "This will contain the full auto-match interface\n" - "currently available in the separate Auto-Match window.\n\n" - "Features will include:\n" - "• Person-centric matching workflow\n" - "• Visual confirmation of matches\n" - "• Batch identification of similar faces\n" - "• Search and filter by person name\n" - "• Smart pre-selection of previously identified faces" - ) - - 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 - - def _create_search_panel(self) -> ttk.Frame: - """Create the search panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: title (row 0) fixed, search 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="šŸ”Ž Search Photos", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Create the search panel if we have the required dependencies - if self.db_manager and self.search_stats: - self.search_panel = SearchPanel(panel, self.db_manager, self.search_stats, self.gui_core, self.tag_manager) - search_frame = self.search_panel.create_panel() - search_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) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = ( - "Search panel requires database and search stats to be configured.\n\n" - "This will contain the full search interface\n" - "currently available in the separate Search window.\n\n" - "Features will include:\n" - "• Search photos by person name\n" - "• Search photos by date range\n" - "• Search photos by tags\n" - "• Find photos without faces\n" - "• Find photos without tags\n" - "• Advanced filtering options" - ) - - 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 - - def _create_modify_panel(self) -> ttk.Frame: - """Create the modify panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # 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)) - - # 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, on_navigate_home=lambda: self.show_panel("home")) - 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 - - def _create_tags_panel(self) -> ttk.Frame: - """Create the tags panel with full functionality""" - panel = ttk.Frame(self.content_frame) - - # Configure panel grid for responsiveness - panel.columnconfigure(0, weight=1) - # Configure rows: title (row 0) fixed, tag manager 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="šŸ·ļø Tag Manager", font=("Arial", 24, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - - # Create the tag manager panel if we have the required dependencies - if self.db_manager and self.tag_manager and self.face_processor: - self.tag_manager_panel = TagManagerPanel(panel, self.db_manager, self.gui_core, self.tag_manager, self.face_processor, on_navigate_home=lambda: self.show_panel("home")) - tag_manager_frame = self.tag_manager_panel.create_panel() - tag_manager_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 = ( - "Tag manager panel requires database, tag manager, and face processor to be configured.\n\n" - "This will contain the full tag management interface\n" - "currently available in the separate Tag Manager window.\n\n" - "Features will include:\n" - "• Photo explorer with folder grouping\n" - "• Tag management and bulk operations\n" - "• Multiple view modes (list, icons, compact)\n" - "• Tag creation, editing, and deletion\n" - "• Bulk tag linking to folders\n" - "• Photo preview and people identification" - ) - - 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 - - def _run_scan(self): - """Run the scan operation (migrated from original dashboard)""" - folder = self.folder_var.get().strip() - recursive = bool(self.recursive_var.get()) - - if not folder: - self.gui_core.create_large_messagebox(self.root, "Scan", "Please enter a folder path.", "warning") - return - - # Validate folder path using path utilities - from src.utils.path_utils import validate_path_exists, normalize_path - try: - folder = normalize_path(folder) - if not validate_path_exists(folder): - messagebox.showerror("Scan", f"Folder does not exist or is not accessible: {folder}", parent=self.root) - return - except ValueError as e: - messagebox.showerror("Scan", f"Invalid folder path: {e}", parent=self.root) - return - - if not callable(self.on_scan): - messagebox.showinfo("Scan", "Scan functionality is not wired yet.", parent=self.root) - return - - def worker(): - try: - self.status_label.config(text="Scanning...") - result = self.on_scan(folder, recursive) - messagebox.showinfo("Scan", f"Scan completed. Result: {result}", parent=self.root) - self.status_label.config(text="Ready") - except Exception as e: - messagebox.showerror("Scan", f"Error during scan: {e}", parent=self.root) - self.status_label.config(text="Ready") - - threading.Thread(target=worker, daemon=True).start() - - def _run_process(self): - """Run the process operation (migrated from original dashboard)""" - if not callable(self.on_process): - messagebox.showinfo("Process", "Process functionality is not wired yet.", parent=self.root) - return - - limit_value = None - if self.limit_enabled.get(): - try: - limit_value = int(self.limit_var.get().strip()) - if limit_value <= 0: - raise ValueError - except Exception: - messagebox.showerror("Process", "Please enter a valid positive integer for limit.", parent=self.root) - return - - # Allow cancellation across worker thread - self._process_stop_event = threading.Event() - - def worker(): - try: - # Disable the button and initialize progress - self.process_btn.config(state="disabled", text="ā³ Processing...") - self.cancel_btn.config(state="normal") - self.progress_var.set(0) - self.progress_status_var.set("Starting processing...") - self.status_label.config(text="Processing...") - - # Prepare real progress callback to be invoked per photo - def progress_callback(index, total, filename): - try: - percent = int((index / max(total, 1)) * 100) - def _update(): - self.progress_var.set(percent) - self.progress_status_var.set(f"Processing {index}/{total}: {filename}") - self.root.after(0, _update) - except Exception: - pass - - # Get selected detector and model settings - detector = getattr(self, 'detector_var', None) - model = getattr(self, 'model_var', None) - detector_backend = detector.get() if detector else None - model_name = model.get() if model else None - - # Run the actual processing with real progress updates, stop event, and DeepFace settings - result = self.on_process(limit_value, self._process_stop_event, progress_callback, - detector_backend, model_name) - - # Ensure progress reaches 100% at the end - self.progress_var.set(100) - self.progress_status_var.set("Processing completed successfully!") - - messagebox.showinfo("Process", f"Processing completed. Result: {result}", parent=self.root) - self.status_label.config(text="Ready") - - except Exception as e: - self.progress_var.set(0) - self.progress_status_var.set("Processing failed") - messagebox.showerror("Process", f"Error during processing: {e}", parent=self.root) - self.status_label.config(text="Ready") - finally: - # Re-enable the button regardless of success or failure - self.process_btn.config(state="normal", text="šŸš€ Start Processing") - self.cancel_btn.config(state="disabled") - # Clear stop event - self._process_stop_event = None - - threading.Thread(target=worker, daemon=True).start() - - - def _cancel_process(self): - """Signal the running process to stop, if any.""" - try: - if getattr(self, "_process_stop_event", None) is not None: - self._process_stop_event.set() - # Inform the user via UI immediately - def _update(): - self.progress_status_var.set("Cancelling...") - self.cancel_btn.config(state="disabled") - self.root.after(0, _update) - except Exception: - pass - diff --git a/archive/desktop/gui/gui_core.py b/archive/desktop/gui/gui_core.py deleted file mode 100644 index 7f68dcc..0000000 --- a/archive/desktop/gui/gui_core.py +++ /dev/null @@ -1,1039 +0,0 @@ -#!/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('', 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 the system's default photo viewer""" - try: - system = platform.system() - if system == "Windows": - # Windows - use default photo viewer - os.startfile(photo_path) - elif system == "Darwin": # macOS - # macOS - use default Preview or Photos app - subprocess.Popen(["open", photo_path]) - else: # Linux and others - # Linux - use xdg-open which opens with default viewer - subprocess.Popen(["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", "", open_source_photo) - canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e))) - canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor=""), hide_tooltip(e))) - canvas.tag_bind("photo_icon", "", 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: dict, - face_id: int, crop_size: int = 100) -> Optional[str]: - """Create a face crop image for display""" - try: - # Parse location from string format (DeepFace format only) - if isinstance(face_location, str): - face_location = eval(face_location) - - # DeepFace format: {x, y, w, h} - if not isinstance(face_location, dict): - raise ValueError(f"Expected DeepFace dict format, got {type(face_location)}") - - x = face_location.get('x', 0) - y = face_location.get('y', 0) - w = face_location.get('w', 0) - h = face_location.get('h', 0) - - left = x - top = y - right = x + w - bottom = y + h - - # 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)" - elif confidence_pct >= 70: - return "🟔 (High)" - elif confidence_pct >= 60: - return "🟠 (Medium)" - elif confidence_pct >= 50: - return "šŸ”“ (Low)" - 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('', show_tooltip) - widget.bind('', 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('', lambda e: show_suggestions()) - entry.bind('', on_key_press) - entry.bind('', lambda e: parent.after(150, hide_suggestions)) # Delay to allow listbox clicks - listbox.bind('', on_listbox_click) - listbox.bind('', on_listbox_key) - listbox.bind('', on_listbox_click) - - return entry, entry_var, listbox - - def show_image_viewer(self, parent, photo_path: str): - """Show an in-app image viewer dialog with zoom functionality""" - import tkinter as tk - from tkinter import ttk - from PIL import Image, ImageTk - - try: - # Create viewer window - viewer = tk.Toplevel(parent) - viewer.title(f"Photo Viewer - {os.path.basename(photo_path)}") - - # Get screen dimensions - screen_width = viewer.winfo_screenwidth() - screen_height = viewer.winfo_screenheight() - - # Load original image - original_img = Image.open(photo_path) - img_width, img_height = original_img.size - - # Calculate optimal window size (90% of screen, but respecting aspect ratio) - max_width = int(screen_width * 0.9) - max_height = int(screen_height * 0.9) - - # Calculate initial scaling to fit image while maintaining aspect ratio - width_ratio = max_width / img_width - height_ratio = max_height / img_height - initial_scale = min(width_ratio, height_ratio, 1.0) # Don't upscale initially - - # Window size based on initial scale - window_width = min(int(img_width * initial_scale) + 40, max_width) - window_height = min(int(img_height * initial_scale) + 150, max_height) - - # Center window on screen - x = (screen_width - window_width) // 2 - y = (screen_height - window_height) // 2 - - viewer.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Create main frame with padding - main_frame = ttk.Frame(viewer, padding="10") - main_frame.pack(fill=tk.BOTH, expand=True) - - # Zoom controls frame - zoom_frame = ttk.Frame(main_frame) - zoom_frame.pack(fill=tk.X, pady=(0, 5)) - - # Create canvas with scrollbars for large images - canvas_frame = ttk.Frame(main_frame) - canvas_frame.pack(fill=tk.BOTH, expand=True) - - # Canvas - canvas = tk.Canvas(canvas_frame, bg='gray25', highlightthickness=0) - canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - # Scrollbars - v_scrollbar = ttk.Scrollbar(canvas_frame, orient='vertical', command=canvas.yview) - v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - h_scrollbar = ttk.Scrollbar(main_frame, orient='horizontal', command=canvas.xview) - h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) - - canvas.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set) - - # Zoom state - current_zoom = initial_scale - zoom_levels = [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0] - - # Info label - info_label = ttk.Label(main_frame, text="", font=("Arial", 9)) - info_label.pack(pady=(5, 0)) - - def update_image(zoom_scale): - """Update the displayed image with the given zoom scale""" - nonlocal current_zoom - current_zoom = zoom_scale - - # Calculate new dimensions - new_width = int(img_width * zoom_scale) - new_height = int(img_height * zoom_scale) - - # Resize image - img_resized = original_img.resize((new_width, new_height), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(img_resized) - - # Clear canvas and create new image - canvas.delete("all") - canvas.create_image(new_width // 2, new_height // 2, - image=photo, anchor=tk.CENTER, tags="image") - canvas.image = photo # Keep reference - - # Update scroll region - canvas.configure(scrollregion=(0, 0, new_width, new_height)) - - # Update info label - info_text = f"Image: {os.path.basename(photo_path)} | " \ - f"Original: {img_width}Ɨ{img_height}px | " \ - f"Zoom: {zoom_scale*100:.0f}% | " \ - f"Display: {new_width}Ɨ{new_height}px" - info_label.config(text=info_text) - - # Update zoom buttons state - zoom_in_btn.config(state='normal' if zoom_scale < zoom_levels[-1] else 'disabled') - zoom_out_btn.config(state='normal' if zoom_scale > zoom_levels[0] else 'disabled') - zoom_reset_btn.config(state='normal' if zoom_scale != 1.0 else 'disabled') - - def zoom_in(): - """Zoom in to the next level""" - next_zoom = min([z for z in zoom_levels if z > current_zoom], default=current_zoom) - if next_zoom > current_zoom: - update_image(next_zoom) - - def zoom_out(): - """Zoom out to the previous level""" - prev_zoom = max([z for z in zoom_levels if z < current_zoom], default=current_zoom) - if prev_zoom < current_zoom: - update_image(prev_zoom) - - def zoom_fit(): - """Zoom to fit window""" - update_image(initial_scale) - - def zoom_100(): - """Zoom to 100% (actual size)""" - update_image(1.0) - - def on_mouse_wheel(event): - """Handle mouse wheel zoom""" - # Get mouse position relative to canvas - canvas_x = canvas.canvasx(event.x) - canvas_y = canvas.canvasy(event.y) - - # Determine zoom direction - if event.delta > 0 or event.num == 4: # Zoom in - zoom_in() - elif event.delta < 0 or event.num == 5: # Zoom out - zoom_out() - - # Zoom control buttons - ttk.Label(zoom_frame, text="Zoom:", font=("Arial", 9, "bold")).pack(side=tk.LEFT, padx=(0, 5)) - - zoom_out_btn = ttk.Button(zoom_frame, text="āˆ’", width=3, command=zoom_out) - zoom_out_btn.pack(side=tk.LEFT, padx=2) - - zoom_in_btn = ttk.Button(zoom_frame, text="+", width=3, command=zoom_in) - zoom_in_btn.pack(side=tk.LEFT, padx=2) - - zoom_reset_btn = ttk.Button(zoom_frame, text="Fit", width=5, command=zoom_fit) - zoom_reset_btn.pack(side=tk.LEFT, padx=2) - - zoom_100_btn = ttk.Button(zoom_frame, text="100%", width=5, command=zoom_100) - zoom_100_btn.pack(side=tk.LEFT, padx=2) - - ttk.Label(zoom_frame, text="(Use mouse wheel to zoom)", - font=("Arial", 8), foreground="gray").pack(side=tk.LEFT, padx=(10, 0)) - - # Close button - button_frame = ttk.Frame(main_frame) - button_frame.pack(pady=(5, 0)) - - close_btn = ttk.Button(button_frame, text="Close", command=viewer.destroy) - close_btn.pack() - - # Bind mouse wheel events (different on different platforms) - canvas.bind("", on_mouse_wheel) # Windows/Mac - canvas.bind("", on_mouse_wheel) # Linux scroll up - canvas.bind("", on_mouse_wheel) # Linux scroll down - - # Keyboard shortcuts - viewer.bind('', lambda e: viewer.destroy()) - viewer.bind('', lambda e: viewer.destroy()) - viewer.bind('', lambda e: zoom_in()) - viewer.bind('', lambda e: zoom_out()) - viewer.bind('', lambda e: zoom_in()) # + without shift - viewer.bind('', lambda e: zoom_in()) # Numpad + - viewer.bind('', lambda e: zoom_out()) # Numpad - - viewer.bind('f', lambda e: zoom_fit()) - viewer.bind('F', lambda e: zoom_fit()) - viewer.bind('1', lambda e: zoom_100()) - - # Initialize image at initial scale - update_image(initial_scale) - - # Make modal (wait for window to be visible before grabbing) - viewer.transient(parent) - viewer.update_idletasks() # Ensure window is rendered - viewer.deiconify() # Ensure window is shown - viewer.wait_visibility() # Wait for window to become visible - viewer.grab_set() # Now safe to grab focus - - except Exception as e: - import tkinter.messagebox as messagebox - messagebox.showerror("Error", f"Could not display image:\n{e}", parent=parent) \ No newline at end of file diff --git a/archive/desktop/gui/identify_panel.py b/archive/desktop/gui/identify_panel.py deleted file mode 100644 index fd4dd67..0000000 --- a/archive/desktop/gui/identify_panel.py +++ /dev/null @@ -1,1996 +0,0 @@ -#!/usr/bin/env python3 -""" -Integrated Identify Panel for PunimTag Dashboard -Embeds the full identify GUI functionality into the dashboard frame -""" - -import os -import time -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_BATCH_SIZE, DEFAULT_FACE_TOLERANCE -from src.core.database import DatabaseManager -from src.core.face_processing import FaceProcessor -from src.gui.gui_core import GUICore - - -class IdentifyPanel: - """Integrated identify panel that embeds the full identify 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 identify 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.current_faces = [] - self.current_face_index = 0 - self.face_status = {} - self.face_person_names = {} - self.face_selection_states = {} - self.identify_data_cache = {} - self.current_face_crop_path = None - - # Caching system for all faces data - self.all_faces_cache = [] # Cache all faces from database - self.cache_loaded = False # Flag to track if cache is loaded - - # GUI components - self.components = {} - self.main_frame = None - - def create_panel(self) -> ttk.Frame: - """Create the identify 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 for similar faces - self.main_frame.rowconfigure(0, weight=0) # Info label - fixed height - self.main_frame.rowconfigure(1, weight=0) # Filter row - fixed height - self.main_frame.rowconfigure(2, weight=0) # Checkboxes row - fixed height - self.main_frame.rowconfigure(3, weight=0) # Configuration row - fixed height - self.main_frame.rowconfigure(4, weight=1) # Main panels row - expandable - - # Photo info with larger font for full screen - self.components['info_label'] = tk.Label(self.main_frame, text="", font=("Arial", 12, "bold")) - self.components['info_label'].grid(row=0, column=0, columnspan=2, pady=(0, 15), sticky=tk.W) - - # Create all GUI components - self._create_gui_components() - - # Create main content panels - self._create_main_panels() - - return self.main_frame - - def _calculate_face_canvas_size(self) -> int: - """Calculate responsive face canvas size based on available window space""" - try: - # Get the main window to determine available space - root = self.main_frame.winfo_toplevel() - root.update_idletasks() # Ensure geometry is calculated - - # Try to get the actual window height first, fall back to screen height - try: - window_height = root.winfo_height() - if window_height <= 1: # Window not yet rendered - window_height = root.winfo_screenheight() - except: - window_height = root.winfo_screenheight() - - # Estimate space used by other UI elements (in pixels) - # Title bar + menu: ~80px, filters: ~120px, config: ~60px, form: ~120px, buttons: ~40px, padding: ~40px - used_height = 460 - - # Calculate available height for face canvas - available_height = window_height - used_height - - # Define size constraints - min_size = 200 # Minimum usable size - max_size = 400 # Maximum size (original) - preferred_size = 300 # Good balance for most screens - - # Calculate responsive size based on available height - if available_height < 500: # Very small window (laptop with small resolution) - face_size = min_size - elif available_height < 600: # Small laptop screen - face_size = min(max_size, max(min_size, available_height // 2)) - elif available_height < 800: # Medium laptop screen - face_size = min(max_size, max(min_size, available_height // 2.5)) - else: # Large screen - face_size = preferred_size - - # Ensure it's a reasonable size - face_size = max(min_size, min(max_size, int(face_size))) - - if self.verbose > 0: - print(f"šŸ“ Window height: {window_height}px, Available: {available_height}px, Face canvas: {face_size}x{face_size}px") - - return face_size - - except Exception as e: - # Fallback to default size if calculation fails - if self.verbose > 0: - print(f"āš ļø Error calculating face canvas size: {e}, using default 300px") - return 300 - - def _update_face_canvas_size(self): - """Update face canvas size dynamically (for window resize events)""" - try: - if 'face_canvas' not in self.components: - return - - # Calculate new size - new_size = self._calculate_face_canvas_size() - canvas = self.components['face_canvas'] - - # Get current size - current_width = canvas.winfo_width() - current_height = canvas.winfo_height() - - # Only update if size has changed significantly (avoid constant updates) - if abs(current_width - new_size) > 10 or abs(current_height - new_size) > 10: - # Update canvas size - canvas.configure(width=new_size, height=new_size) - - # Refresh the current face image if there's one displayed - if self.current_faces and self.current_face_index < len(self.current_faces): - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model = self.current_faces[self.current_face_index] - if self.current_face_crop_path: - self._update_face_image(self.current_face_crop_path, photo_path) - - if self.verbose > 0: - print(f"šŸ“ Updated face canvas size to {new_size}x{new_size}px") - - except Exception as e: - if self.verbose > 0: - print(f"āš ļø Error updating face canvas size: {e}") - - def _create_gui_components(self): - """Create all GUI components for the identify interface""" - # Create variables for form data - self.components['compare_var'] = tk.BooleanVar() - self.components['unique_var'] = tk.BooleanVar() - self.components['first_name_var'] = tk.StringVar() - self.components['last_name_var'] = tk.StringVar() - self.components['middle_name_var'] = tk.StringVar() - self.components['maiden_name_var'] = tk.StringVar() - self.components['date_of_birth_var'] = tk.StringVar() - - # Date filter variables - self.components['date_from_var'] = tk.StringVar(value="") - self.components['date_to_var'] = tk.StringVar(value="") - self.components['date_processed_from_var'] = tk.StringVar(value="") - self.components['date_processed_to_var'] = tk.StringVar(value="") - - # Quality filter variable (0-100%) - self.components['quality_filter_var'] = tk.IntVar(value=0) - - # Date filter controls - date_filter_frame = ttk.LabelFrame(self.main_frame, text="Filter", padding="5") - date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) - date_filter_frame.columnconfigure(1, weight=0) - date_filter_frame.columnconfigure(4, weight=0) - - # Date from - ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) - self.components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_from_var'], width=10, state='readonly') - self.components['date_from_entry'].grid(row=0, column=1, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date from - def open_calendar_from(): - self._open_date_picker(self.components['date_from_var']) - - self.components['date_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_from) - self.components['date_from_btn'].grid(row=0, column=2, padx=(0, 10)) - - # Date to - ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) - self.components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_to_var'], width=10, state='readonly') - self.components['date_to_entry'].grid(row=0, column=4, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date to - def open_calendar_to(): - self._open_date_picker(self.components['date_to_var']) - - self.components['date_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_to) - self.components['date_to_btn'].grid(row=0, column=5, padx=(0, 10)) - - # Apply filter button - def apply_date_filter(): - """Apply date filters and reload faces""" - self._apply_date_filters() - - self.components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) - self.components['apply_filter_btn'].grid(row=0, column=6, padx=(10, 0)) - - # Date processed filter (second row) - ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - self.components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_from_var'], width=10, state='readonly') - self.components['date_processed_from_entry'].grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed from - def open_calendar_processed_from(): - self._open_date_picker(self.components['date_processed_from_var']) - - self.components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_from) - self.components['date_processed_from_btn'].grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) - - # Date processed to - ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - self.components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_to_var'], width=10, state='readonly') - self.components['date_processed_to_entry'].grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed to - def open_calendar_processed_to(): - self._open_date_picker(self.components['date_processed_to_var']) - - self.components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_to) - self.components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) - - # Quality filter (third row) - ttk.Label(date_filter_frame, text="Min quality:").grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Quality slider frame - quality_slider_frame = ttk.Frame(date_filter_frame) - quality_slider_frame.grid(row=2, column=1, columnspan=5, sticky=tk.W, pady=(10, 0)) - - # Quality slider - self.components['quality_slider'] = tk.Scale( - quality_slider_frame, - from_=0, - to=100, - orient=tk.HORIZONTAL, - variable=self.components['quality_filter_var'], - length=250, - tickinterval=25, - resolution=5, - showvalue=0 - ) - self.components['quality_slider'].pack(side=tk.LEFT, padx=(0, 10)) - - # Quality value label - self.components['quality_value_label'] = ttk.Label( - quality_slider_frame, - text=f"{self.components['quality_filter_var'].get()}%", - font=("Arial", 10, "bold"), - width=6 - ) - self.components['quality_value_label'].pack(side=tk.LEFT, padx=(0, 5)) - - # Update label when slider changes - def update_quality_label(*args): - quality = self.components['quality_filter_var'].get() - self.components['quality_value_label'].config(text=f"{quality}%") - - # Color code the label - if quality == 0: - color = "gray" - elif quality < 30: - color = "orange" - elif quality < 60: - color = "#E67E22" # orange - else: - color = "green" - self.components['quality_value_label'].config(foreground=color) - - self.components['quality_filter_var'].trace('w', update_quality_label) - update_quality_label() # Initialize - - # Quality filter help text - ttk.Label( - quality_slider_frame, - text="(0 = all faces)", - font=("Arial", 8), - foreground="gray" - ).pack(side=tk.LEFT) - - # Unique checkbox under the filter frame - def on_unique_change(): - """Handle unique faces checkbox change - filter main face list like old implementation""" - if self.components['unique_var'].get(): - # Check if identification has started - if not self.current_faces: - messagebox.showinfo("Start Identification First", - "Please click 'Start Identification' before applying the unique faces filter.") - self.components['unique_var'].set(False) - return - - # Show progress message - print("šŸ”„ Applying unique faces filter...") - self.main_frame.update() # Update UI to show the message - - # Store original count - original_count = len(self.current_faces) - - # Apply unique faces filtering to the main face list - try: - self.current_faces = self._filter_unique_faces_from_list(self.current_faces) - filtered_count = len(self.current_faces) - removed_count = original_count - filtered_count - - print(f"āœ… Filter applied: {filtered_count} unique faces remaining ({removed_count} duplicates hidden)") - - if removed_count > 0: - messagebox.showinfo("Unique Faces Filter Applied", - f"Showing {filtered_count} unique faces.\n" - f"Hidden {removed_count} duplicate faces (≄60% match confidence).") - else: - messagebox.showinfo("No Duplicates Found", - "All faces appear to be unique - no duplicates to hide.") - except Exception as e: - print(f"āš ļø Error applying filter: {e}") - messagebox.showerror("Filter Error", f"Error applying unique faces filter:\n{e}") - # Revert checkbox state - self.components['unique_var'].set(False) - return - else: - # Reload the original unfiltered face list from cache - print("šŸ”„ Reloading all faces from cache...") - self.main_frame.update() # Update UI to show the message - - # Get batch size - try: - batch_size = int(self.components['batch_var'].get().strip()) - except Exception: - batch_size = DEFAULT_BATCH_SIZE - - # Get quality filter - min_quality = self.components['quality_filter_var'].get() - min_quality_score = min_quality / 100.0 - - # Get sort option - sort_display = self.components['sort_var'].get() - sort_by = self.sort_value_map.get(sort_display, "quality") - - # Reload faces with current filters and sort option from cache - if self.cache_loaded: - self.current_faces = self._filter_cached_faces(min_quality_score, sort_by, batch_size) - print(f"āœ… Reloaded: {len(self.current_faces)} faces from cache") - else: - print("āš ļø Cache not loaded - please click 'Start Identification' first") - - # Reset to first face and update display - self.current_face_index = 0 - if self.current_faces: - self._update_current_face() - self._update_button_states() - - # Update similar faces if compare is enabled - if self.components['compare_var'].get(): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - self._update_similar_faces(face_id) - - self.components['unique_check'] = ttk.Checkbutton(self.main_frame, text="Unique faces only", - variable=self.components['unique_var'], - command=on_unique_change) - self.components['unique_check'].grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0) - - # Compare checkbox on the same row as Unique - def on_compare_change(): - # Toggle the similar faces functionality - if self.components['compare_var'].get(): - # Enable select all/clear all buttons - self.components['select_all_btn'].config(state='normal') - self.components['clear_all_btn'].config(state='normal') - # Update similar faces if we have a current face - if self.current_faces and self.current_face_index < len(self.current_faces): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - self._update_similar_faces(face_id) - else: - # Disable select all/clear all buttons - self.components['select_all_btn'].config(state='disabled') - self.components['clear_all_btn'].config(state='disabled') - # Clear similar faces content - scrollable_frame = self.components['similar_scrollable_frame'] - for widget in scrollable_frame.winfo_children(): - widget.destroy() - # Show message that compare is disabled - no_compare_label = ttk.Label(scrollable_frame, text="Enable 'Compare similar faces' to see similar faces", - foreground="gray", font=("Arial", 10)) - no_compare_label.pack(pady=20) - - self.components['compare_check'] = ttk.Checkbutton(self.main_frame, text="Compare similar faces", - variable=self.components['compare_var'], - command=on_compare_change) - self.components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(0, 5), pady=0) - - # Command variable for button callbacks - self.components['command_var'] = tk.StringVar() - - # Batch size configuration - batch_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="5") - batch_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.W) - - ttk.Label(batch_frame, text="Batch size:").pack(side=tk.LEFT, padx=(0, 5)) - self.components['batch_var'] = tk.StringVar(value=str(DEFAULT_BATCH_SIZE)) - batch_entry = ttk.Entry(batch_frame, textvariable=self.components['batch_var'], width=8) - batch_entry.pack(side=tk.LEFT, padx=(0, 10)) - - # Sort option - ttk.Label(batch_frame, text="Sort by:").pack(side=tk.LEFT, padx=(10, 5)) - self.components['sort_var'] = tk.StringVar(value="quality") - sort_options = [ - ("Quality (Best First)", "quality"), - ("Quality (Worst First)", "quality_asc"), - ("Date Taken (Newest First)", "date_taken"), - ("Date Taken (Oldest First)", "date_taken_asc"), - ("Date Added (Newest First)", "date_added"), - ("Date Added (Oldest First)", "date_added_asc"), - ("Filename (A-Z)", "filename"), - ("Filename (Z-A)", "filename_desc"), - ("Detection Confidence (High First)", "confidence"), - ("Detection Confidence (Low First)", "confidence_asc") - ] - sort_combo = ttk.Combobox(batch_frame, textvariable=self.components['sort_var'], - values=[opt[0] for opt in sort_options], state="readonly", width=25) - sort_combo.pack(side=tk.LEFT, padx=(0, 10)) - - # Map display names to sort values - self.sort_value_map = {opt[0]: opt[1] for opt in sort_options} - - # Add sort change handler - def on_sort_change(event=None): - """Handle sort option change - refresh face list if identification is active""" - if self.is_active and self.cache_loaded: - # Show progress message - print("šŸ”„ Refreshing face list with new sort order...") - self.main_frame.update() - - # Get batch size - try: - batch_size = int(self.components['batch_var'].get().strip()) - except Exception: - batch_size = DEFAULT_BATCH_SIZE - - # Get quality filter - min_quality = self.components['quality_filter_var'].get() - min_quality_score = min_quality / 100.0 - - # Get new sort option - sort_display = self.components['sort_var'].get() - sort_by = self.sort_value_map.get(sort_display, "quality") - - # Apply new sort order to cached data - self.current_faces = self._filter_cached_faces(min_quality_score, sort_by, batch_size) - - # Reset to first face and update display - self.current_face_index = 0 - if self.current_faces: - self._update_current_face() - self._update_button_states() - - # Update similar faces if compare is enabled - if self.components['compare_var'].get(): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - self._update_similar_faces(face_id) - - print(f"āœ… Refreshed: {len(self.current_faces)} faces with new sort order") - else: - print("āš ļø No faces found with current filters and sort order") - - sort_combo.bind('<>', on_sort_change) - - # Start button - start_btn = ttk.Button(batch_frame, text="šŸš€ Start Identification", command=self._start_identification) - start_btn.pack(side=tk.LEFT, padx=(10, 0)) - - def _create_main_panels(self): - """Create the main left and right panels""" - # Left panel for face display and identification - self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Face Identification", padding="10") - self.components['left_panel'].grid(row=4, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) - - # Right panel for similar faces (always visible) - self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Similar Faces", padding="10") - self.components['right_panel'].grid(row=4, 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() - - def _create_left_panel_content(self): - """Create the left panel content for face identification""" - left_panel = self.components['left_panel'] - - # Create a main content frame that can expand - main_content_frame = ttk.Frame(left_panel) - main_content_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - - # Calculate responsive face canvas size based on available space - face_canvas_size = self._calculate_face_canvas_size() - - # Face image display - responsive size for better layout on different screens - self.components['face_canvas'] = tk.Canvas(main_content_frame, width=face_canvas_size, height=face_canvas_size, bg='white', relief='sunken', bd=2) - self.components['face_canvas'].pack(pady=(0, 15)) - - # Person name fields - name_frame = ttk.LabelFrame(main_content_frame, text="Person Information", padding="5") - name_frame.pack(fill=tk.X, pady=(0, 10)) - - # First name - ttk.Label(name_frame, text="First name *:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5), pady=2) - first_name_entry = ttk.Entry(name_frame, textvariable=self.components['first_name_var'], width=20) - first_name_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 10), pady=2) - - # Last name - ttk.Label(name_frame, text="Last name *:").grid(row=0, column=2, sticky=tk.W, padx=(0, 5), pady=2) - last_name_entry = ttk.Entry(name_frame, textvariable=self.components['last_name_var'], width=20) - last_name_entry.grid(row=0, column=3, sticky=tk.W, pady=2) - - # Middle name - ttk.Label(name_frame, text="Middle name:").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=2) - middle_name_entry = ttk.Entry(name_frame, textvariable=self.components['middle_name_var'], width=20) - middle_name_entry.grid(row=1, column=1, sticky=tk.W, padx=(0, 10), pady=2) - - # Maiden name - ttk.Label(name_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(0, 5), pady=2) - maiden_name_entry = ttk.Entry(name_frame, textvariable=self.components['maiden_name_var'], width=20) - maiden_name_entry.grid(row=1, column=3, sticky=tk.W, pady=2) - - # Date of birth - dob_frame = ttk.Frame(name_frame) - dob_frame.grid(row=2, column=0, columnspan=4, sticky=tk.W, pady=2) - - ttk.Label(dob_frame, text="Date of birth *:").pack(side=tk.LEFT, padx=(0, 5)) - dob_entry = ttk.Entry(dob_frame, textvariable=self.components['date_of_birth_var'], width=15, state='readonly') - dob_entry.pack(side=tk.LEFT, padx=(0, 5)) - - def open_dob_calendar(): - self._open_date_picker(self.components['date_of_birth_var']) - - dob_calendar_btn = ttk.Button(dob_frame, text="šŸ“…", width=3, command=open_dob_calendar) - dob_calendar_btn.pack(side=tk.LEFT) - - # Add event handlers to update Identify button state - def update_identify_button_state(*args): - self._update_identify_button_state() - - self.components['first_name_var'].trace('w', update_identify_button_state) - self.components['last_name_var'].trace('w', update_identify_button_state) - self.components['date_of_birth_var'].trace('w', update_identify_button_state) - - # Required field asterisks are now included in the label text - - # Add autocomplete for last name - self._setup_last_name_autocomplete(last_name_entry) - - # Control buttons - button_frame = ttk.Frame(main_content_frame) - button_frame.pack(fill=tk.X, pady=(10, 0)) - - self.components['identify_btn'] = ttk.Button(button_frame, text="āœ… Identify", command=self._identify_face, state='disabled') - self.components['identify_btn'].pack(side=tk.LEFT, padx=(0, 5)) - - self.components['back_btn'] = ttk.Button(button_frame, text="ā¬…ļø Back", command=self._go_back) - self.components['back_btn'].pack(side=tk.LEFT, padx=(0, 5)) - - self.components['next_btn'] = ttk.Button(button_frame, text="āž”ļø Next", command=self._go_next) - self.components['next_btn'].pack(side=tk.LEFT, padx=(0, 5)) - - self.components['quit_btn'] = ttk.Button(button_frame, text="āŒ Exit Identify Faces", command=self._quit_identification) - self.components['quit_btn'].pack(side=tk.RIGHT) - - def _create_right_panel_content(self): - """Create the right panel content for similar faces""" - right_panel = self.components['right_panel'] - - # Select All/Clear All buttons - select_frame = ttk.Frame(right_panel) - select_frame.pack(fill=tk.X, pady=(0, 10)) - - self.components['select_all_btn'] = ttk.Button(select_frame, text="Select All", command=self._select_all_similar) - self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) - - self.components['clear_all_btn'] = ttk.Button(select_frame, text="Clear All", command=self._clear_all_similar) - self.components['clear_all_btn'].pack(side=tk.LEFT) - - # Initially disable these buttons - self.components['select_all_btn'].config(state='disabled') - self.components['clear_all_btn'].config(state='disabled') - - # Create a frame to hold canvas and scrollbar - canvas_frame = ttk.Frame(right_panel) - canvas_frame.pack(fill=tk.BOTH, expand=True) - - # Create canvas for similar faces with scrollbar - similar_canvas = tk.Canvas(canvas_frame, bg='lightgray', relief='sunken', bd=2) - similar_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - # Similar faces scrollbars - similar_v_scrollbar = ttk.Scrollbar(canvas_frame, orient='vertical', command=similar_canvas.yview) - similar_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set) - - # Create scrollable frame for similar faces - self.components['similar_scrollable_frame'] = ttk.Frame(similar_canvas) - similar_canvas.create_window((0, 0), window=self.components['similar_scrollable_frame'], anchor='nw') - - # Configure scrollable frame to expand with canvas - def configure_scroll_region(event): - similar_canvas.configure(scrollregion=similar_canvas.bbox("all")) - - self.components['similar_scrollable_frame'].bind('', configure_scroll_region) - - # Store canvas reference for scrolling - self.components['similar_canvas'] = similar_canvas - - # Add initial message when compare is disabled - no_compare_label = ttk.Label(self.components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces", - foreground="gray", font=("Arial", 10)) - no_compare_label.pack(pady=20) - - def _start_identification(self): - """Start the identification process - loads all faces into cache and applies initial filtering""" - try: - batch_size = int(self.components['batch_var'].get().strip()) - if batch_size <= 0: - raise ValueError - except Exception: - messagebox.showerror("Error", "Please enter a valid positive integer for batch size.") - return - - # Get date filters - date_from = self.components['date_from_var'].get().strip() or None - date_to = self.components['date_to_var'].get().strip() or None - date_processed_from = self.components['date_processed_from_var'].get().strip() or None - date_processed_to = self.components['date_processed_to_var'].get().strip() or None - - # Get quality filter - min_quality = self.components['quality_filter_var'].get() - min_quality_score = min_quality / 100.0 - - # Get sort option - sort_display = self.components['sort_var'].get() - sort_by = self.sort_value_map.get(sort_display, "quality") - - # Load all faces into cache (only database access point) - print("šŸ”„ Loading faces from database...") - self._load_all_faces_cache(date_from, date_to, date_processed_from, date_processed_to) - - if not self.all_faces_cache: - messagebox.showinfo("No Faces", "šŸŽ‰ All faces have been identified!") - return - - # Apply quality filtering and sorting to cached data - print("šŸ”„ Applying quality filter and sorting...") - self.current_faces = self._filter_cached_faces(min_quality_score, sort_by, batch_size) - - if not self.current_faces: - messagebox.showinfo("No Faces", f"No faces found with quality >= {min_quality}%.\nTry lowering the quality filter.") - return - - # Pre-fetch data for optimal performance - self.identify_data_cache = self._prefetch_identify_data(self.current_faces) - - # Reset state - self.current_face_index = 0 - self.face_status = {} - self.face_person_names = {} - self.face_selection_states = {} - - # Show the first face - self._update_current_face() - - # Enable/disable buttons - self._update_button_states() - - self.is_active = True - print(f"āœ… Started identification with {len(self.current_faces)} faces (from {len(self.all_faces_cache)} total cached)") - - def _get_unidentified_faces(self, batch_size: int, date_from: str = None, date_to: str = None, - date_processed_from: str = None, date_processed_to: str = None, - min_quality_score: float = 0.0, sort_by: str = "quality") -> List[Tuple]: - """Get unidentified faces from database with optional date filtering (no quality filtering at DB level)""" - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering only - # Include DeepFace metadata: face_confidence, quality_score, detector_backend, model_name - query = ''' - SELECT f.id, f.photo_id, p.path, p.filename, f.location, - f.face_confidence, f.quality_score, f.detector_backend, f.model_name - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.person_id IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - # Order by selected sort option - sort_clause = self._get_sort_clause(sort_by) - query += f' ORDER BY {sort_clause}' - - query += ' LIMIT ?' - params.append(batch_size) - - cursor.execute(query, params) - return cursor.fetchall() - - def _get_sort_clause(self, sort_by: str) -> str: - """Get SQL ORDER BY clause based on sort option""" - sort_clauses = { - "quality": "f.quality_score DESC", - "quality_asc": "f.quality_score ASC", - "date_taken": "p.date_taken DESC", - "date_taken_asc": "p.date_taken ASC", - "date_added": "p.date_added DESC", - "date_added_asc": "p.date_added ASC", - "filename": "p.filename ASC", - "filename_desc": "p.filename DESC", - "confidence": "f.face_confidence DESC", - "confidence_asc": "f.face_confidence ASC" - } - return sort_clauses.get(sort_by, "f.quality_score DESC") # Default to quality DESC - - def _load_all_faces_cache(self, date_from: str = None, date_to: str = None, - date_processed_from: str = None, date_processed_to: str = None) -> None: - """Load all unidentified faces into cache with date filtering only""" - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering only - query = ''' - SELECT f.id, f.photo_id, p.path, p.filename, f.location, - f.face_confidence, f.quality_score, f.detector_backend, f.model_name, - p.date_taken, p.date_added - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.person_id IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - # Order by quality DESC by default (can be re-sorted later) - query += ' ORDER BY f.quality_score DESC' - - cursor.execute(query, params) - self.all_faces_cache = cursor.fetchall() - self.cache_loaded = True - - if self.verbose > 0: - print(f"šŸ“¦ Loaded {len(self.all_faces_cache)} faces into cache") - - def _filter_cached_faces(self, min_quality_score: float = 0.0, sort_by: str = "quality", - batch_size: int = None) -> List[Tuple]: - """Filter cached faces by quality and apply sorting""" - if not self.cache_loaded: - return [] - - # Filter by quality - filtered_faces = [] - for face_data in self.all_faces_cache: - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model, date_taken, date_added = face_data - quality_score = quality if quality is not None else 0.0 - - if quality_score >= min_quality_score: - # Return in the same format as the original method (without date_taken, date_added) - filtered_faces.append((face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model)) - - # Apply sorting - if sort_by == "quality": - filtered_faces.sort(key=lambda x: x[6] if x[6] is not None else 0.0, reverse=True) - elif sort_by == "quality_asc": - filtered_faces.sort(key=lambda x: x[6] if x[6] is not None else 0.0, reverse=False) - elif sort_by == "date_taken": - # Need to get date_taken from cache for sorting - filtered_faces_with_dates = [] - for face_data in self.all_faces_cache: - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model, date_taken, date_added = face_data - quality_score = quality if quality is not None else 0.0 - if quality_score >= min_quality_score: - filtered_faces_with_dates.append((face_data, date_taken)) - - filtered_faces_with_dates.sort(key=lambda x: x[1] or "", reverse=True) - filtered_faces = [x[0][:9] for x in filtered_faces_with_dates] # Remove date fields - elif sort_by == "date_taken_asc": - filtered_faces_with_dates = [] - for face_data in self.all_faces_cache: - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model, date_taken, date_added = face_data - quality_score = quality if quality is not None else 0.0 - if quality_score >= min_quality_score: - filtered_faces_with_dates.append((face_data, date_taken)) - - filtered_faces_with_dates.sort(key=lambda x: x[1] or "", reverse=False) - filtered_faces = [x[0][:9] for x in filtered_faces_with_dates] - elif sort_by == "date_added": - filtered_faces_with_dates = [] - for face_data in self.all_faces_cache: - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model, date_taken, date_added = face_data - quality_score = quality if quality is not None else 0.0 - if quality_score >= min_quality_score: - filtered_faces_with_dates.append((face_data, date_added)) - - filtered_faces_with_dates.sort(key=lambda x: x[1] or "", reverse=True) - filtered_faces = [x[0][:9] for x in filtered_faces_with_dates] - elif sort_by == "date_added_asc": - filtered_faces_with_dates = [] - for face_data in self.all_faces_cache: - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model, date_taken, date_added = face_data - quality_score = quality if quality is not None else 0.0 - if quality_score >= min_quality_score: - filtered_faces_with_dates.append((face_data, date_added)) - - filtered_faces_with_dates.sort(key=lambda x: x[1] or "", reverse=False) - filtered_faces = [x[0][:9] for x in filtered_faces_with_dates] - elif sort_by == "filename": - filtered_faces.sort(key=lambda x: x[3] or "", reverse=False) - elif sort_by == "filename_desc": - filtered_faces.sort(key=lambda x: x[3] or "", reverse=True) - elif sort_by == "confidence": - filtered_faces.sort(key=lambda x: x[5] if x[5] is not None else 0.0, reverse=True) - elif sort_by == "confidence_asc": - filtered_faces.sort(key=lambda x: x[5] if x[5] is not None else 0.0, reverse=False) - - # Apply batch size limit if specified - if batch_size and batch_size > 0: - filtered_faces = filtered_faces[:batch_size] - - return filtered_faces - - def _prefetch_identify_data(self, faces: List[Tuple]) -> Dict: - """Pre-fetch all needed data to avoid repeated database queries""" - cache = { - 'photo_paths': {}, - 'people_names': [], - 'last_names': [], - 'face_encodings': {} - } - - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - - # Get photo paths - photo_ids = [face[1] for face in faces] - if photo_ids: - placeholders = ','.join(['?' for _ in photo_ids]) - cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids) - cache['photo_paths'] = dict(cursor.fetchall()) - - # Get people names - cursor.execute('SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people ORDER BY last_name, first_name') - cache['people_names'] = cursor.fetchall() - - # Pre-fetch unique last names for autocomplete (no DB during typing) - cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') - _last_rows = cursor.fetchall() - cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) - - # Get face encodings for similar face matching - face_ids = [face[0] for face in faces] - if face_ids: - placeholders = ','.join(['?' for _ in face_ids]) - cursor.execute(f'SELECT id, encoding FROM faces WHERE id IN ({placeholders})', face_ids) - cache['face_encodings'] = dict(cursor.fetchall()) - - return cache - - def _filter_unique_faces_from_list(self, faces_list: List[Tuple]) -> List[Tuple]: - """Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches""" - if not faces_list: - return faces_list - - # Extract face IDs from the list - face_ids = [face_tuple[0] for face_tuple in faces_list] - - # Get face encodings from database for all faces - face_encodings = {} - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - placeholders = ','.join('?' * len(face_ids)) - cursor.execute(f''' - SELECT id, encoding - FROM faces - WHERE id IN ({placeholders}) AND encoding IS NOT NULL - ''', face_ids) - - for face_id, encoding_blob in cursor.fetchall(): - try: - import numpy as np - # Load encoding as numpy array (not pickle) - encoding = np.frombuffer(encoding_blob, dtype=np.float64) - face_encodings[face_id] = encoding - except Exception: - continue - - # If we don't have enough encodings, return original list - if len(face_encodings) < 2: - return faces_list - - # Calculate distances between all faces using existing encodings - face_distances = {} - face_id_list = list(face_encodings.keys()) - - for i, face_id1 in enumerate(face_id_list): - for j, face_id2 in enumerate(face_id_list): - if i != j: - try: - import numpy as np - encoding1 = face_encodings[face_id1] - encoding2 = face_encodings[face_id2] - - # Calculate distance using cosine similarity (DeepFace compatible) - # Normalize encodings - encoding1_norm = encoding1 / np.linalg.norm(encoding1) - encoding2_norm = encoding2 / np.linalg.norm(encoding2) - - # Cosine distance = 1 - cosine similarity - cosine_similarity = np.dot(encoding1_norm, encoding2_norm) - distance = 1.0 - cosine_similarity - - face_distances[(face_id1, face_id2)] = distance - except Exception as e: - # If calculation fails, assume no match - print(f"āš ļø Error calculating distance between faces {face_id1} and {face_id2}: {e}") - face_distances[(face_id1, face_id2)] = 1.0 - - # Apply unique faces filtering - unique_faces = [] - seen_face_groups = set() - - for face_tuple in faces_list: - face_id = face_tuple[0] - - # Skip if we don't have encoding for this face - if face_id not in face_encodings: - unique_faces.append(face_tuple) - continue - - # Find all faces that match this one with high/medium confidence - matching_face_ids = set([face_id]) # Include self - for other_face_id in face_encodings.keys(): - if other_face_id != face_id: - distance = face_distances.get((face_id, other_face_id), 1.0) - confidence_pct, _ = self.face_processor._get_calibrated_confidence(distance) - - # If this face matches with high/medium confidence - if confidence_pct >= 60: - matching_face_ids.add(other_face_id) - - # Create a sorted tuple to represent this group of matching faces - face_group = tuple(sorted(matching_face_ids)) - - # Only show this face if we haven't seen this group before - if face_group not in seen_face_groups: - seen_face_groups.add(face_group) - unique_faces.append(face_tuple) - - return unique_faces - - def _update_current_face(self): - """Update the display for the current face""" - if not self.current_faces or self.current_face_index >= len(self.current_faces): - return - - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model = self.current_faces[self.current_face_index] - - # Update info label with DeepFace metadata - info_text = f"Face {self.current_face_index + 1} of {len(self.current_faces)} - {filename}" - if face_conf is not None and face_conf > 0: - info_text += f" | Detection: {face_conf*100:.1f}%" - if quality is not None: - info_text += f" | Quality: {quality*100:.0f}%" - if detector: - info_text += f" | {detector}/{model}" if model else f" | {detector}" - self.components['info_label'].config(text=info_text) - - # Extract and display face crop (show_faces is always True) - face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) - self.current_face_crop_path = face_crop_path - - # Update face image - self._update_face_image(face_crop_path, photo_path) - - # Check if face is already identified - is_identified = face_id in self.face_status and self.face_status[face_id] == 'identified' - - # Restore person name input - restore saved name or use database/empty value - self._restore_person_name_input(face_id, is_identified) - - # Update similar faces if compare is enabled - if self.components['compare_var'].get(): - self._update_similar_faces(face_id) - - def _update_face_image(self, face_crop_path: str, photo_path: str): - """Update the face image display""" - try: - if face_crop_path and os.path.exists(face_crop_path): - # Get current canvas size - canvas = self.components['face_canvas'] - canvas_width = canvas.winfo_width() - canvas_height = canvas.winfo_height() - - # Use actual canvas size if available, otherwise use configured size - if canvas_width <= 1 or canvas_height <= 1: - # Canvas not yet rendered, use configured size - canvas_width = canvas['width'] - canvas_height = canvas['height'] - - # Load and display face crop - image = Image.open(face_crop_path) - # Resize to exactly fill the current canvas frame - image = image.resize((canvas_width, canvas_height), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(image) - - # Clear canvas and display image - canvas.delete("all") - # Position image at top-left corner like the original - canvas.create_image(0, 0, image=photo, anchor=tk.NW) - canvas.image = photo # Keep a reference - - # Add photo icon exactly at the image's top-right corner - # Image starts at (0, 0) and fills the canvas - self.gui_core.create_photo_icon(canvas, photo_path, icon_size=25, - face_x=0, face_y=0, - face_width=canvas_width, face_height=canvas_height, - canvas_width=canvas_width, canvas_height=canvas_height) - else: - # Clear canvas if no image - canvas = self.components['face_canvas'] - canvas.delete("all") - # Center the "No face image" text - canvas_width = canvas.winfo_width() if canvas.winfo_width() > 1 else canvas['width'] - canvas_height = canvas.winfo_height() if canvas.winfo_height() > 1 else canvas['height'] - canvas.create_text(canvas_width//2, canvas_height//2, text="No face image", fill="gray") - except Exception as e: - print(f"Error updating face image: {e}") - - - def _restore_person_name_input(self, face_id: int, is_identified: bool): - """Restore person name input fields""" - try: - if is_identified: - # Get person data from database - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth - FROM people p - JOIN faces f ON p.id = f.person_id - WHERE f.id = ? - ''', (face_id,)) - result = cursor.fetchone() - - if result: - first_name, last_name, middle_name, maiden_name, date_of_birth = result - self.components['first_name_var'].set(first_name or "") - self.components['last_name_var'].set(last_name or "") - self.components['middle_name_var'].set(middle_name or "") - self.components['maiden_name_var'].set(maiden_name or "") - self.components['date_of_birth_var'].set(date_of_birth or "") - else: - # Clear all fields if no person found - self._clear_form() - else: - # Restore from saved data if available - if face_id in self.face_person_names: - person_data = self.face_person_names[face_id] - if isinstance(person_data, dict): - self.components['first_name_var'].set(person_data.get('first_name', '')) - self.components['last_name_var'].set(person_data.get('last_name', '')) - self.components['middle_name_var'].set(person_data.get('middle_name', '')) - self.components['maiden_name_var'].set(person_data.get('maiden_name', '')) - self.components['date_of_birth_var'].set(person_data.get('date_of_birth', '')) - else: - # Legacy string format - self.components['first_name_var'].set(person_data or "") - self.components['last_name_var'].set("") - self.components['middle_name_var'].set("") - self.components['maiden_name_var'].set("") - self.components['date_of_birth_var'].set("") - else: - # Clear all fields for new face - self._clear_form() - - except Exception as e: - print(f"āŒ Error restoring person name input: {e}") - # Clear form on error - self._clear_form() - - def _update_identify_button_state(self): - """Update the identify button state based on form completion""" - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - - # Enable button only if all required fields are filled - if first_name and last_name and date_of_birth: - self.components['identify_btn'].config(state='normal') - else: - self.components['identify_btn'].config(state='disabled') - - - def _setup_last_name_autocomplete(self, last_name_entry): - """Setup autocomplete functionality for last name field - exactly like old implementation""" - # Create listbox for suggestions (as overlay attached to main frame, not clipped by frames) - last_name_listbox = tk.Listbox(self.main_frame, height=8) - last_name_listbox.place_forget() # Hide initially - - def _show_suggestions(): - """Show filtered suggestions in listbox""" - all_last_names = self.identify_data_cache.get('last_names', []) - typed = self.components['last_name_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 all_last_names if n.lower().startswith(low)][:10] - - # Update listbox - last_name_listbox.delete(0, tk.END) - for name in filtered: - last_name_listbox.insert(tk.END, name) - - # Show listbox if we have suggestions (as overlay) - if filtered: - # Ensure geometry is up to date before positioning - self.main_frame.update_idletasks() - # Absolute coordinates of entry relative to screen - entry_root_x = last_name_entry.winfo_rootx() - entry_root_y = last_name_entry.winfo_rooty() - entry_height = last_name_entry.winfo_height() - # Convert to coordinates relative to main frame - main_frame_origin_x = self.main_frame.winfo_rootx() - main_frame_origin_y = self.main_frame.winfo_rooty() - place_x = entry_root_x - main_frame_origin_x - place_y = entry_root_y - main_frame_origin_y + entry_height - place_width = last_name_entry.winfo_width() - # Calculate how many rows fit to bottom of window - available_px = max(60, self.main_frame.winfo_height() - place_y - 8) - # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows - approx_row_px = 18 - rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) - last_name_listbox.configure(height=rows_fit) - last_name_listbox.place(x=place_x, y=place_y, width=place_width) - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) # Select first item - last_name_listbox.activate(0) # Activate first item - else: - last_name_listbox.place_forget() - - def _hide_suggestions(): - """Hide the suggestions listbox""" - last_name_listbox.place_forget() - - def _on_listbox_select(event=None): - """Handle listbox selection and hide list""" - selection = last_name_listbox.curselection() - if selection: - selected_name = last_name_listbox.get(selection[0]) - self.components['last_name_var'].set(selected_name) - _hide_suggestions() - last_name_entry.focus_set() - - def _on_listbox_click(event): - """Handle mouse click selection""" - try: - index = last_name_listbox.nearest(event.y) - if index is not None and index >= 0: - selected_name = last_name_listbox.get(index) - self.components['last_name_var'].set(selected_name) - except: - pass - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - - def _on_key_press(event): - """Handle key navigation in entry""" - nonlocal navigating_to_listbox, escape_pressed, enter_pressed - if event.keysym == 'Down': - if last_name_listbox.winfo_ismapped(): - navigating_to_listbox = True - last_name_listbox.focus_set() - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) - last_name_listbox.activate(0) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - return 'break' - elif event.keysym == 'Return': - enter_pressed = True - return 'break' - - def _on_listbox_key(event): - """Handle key navigation in listbox""" - nonlocal enter_pressed, escape_pressed - if event.keysym == 'Return': - enter_pressed = True - _on_listbox_select(event) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - elif event.keysym == 'Up': - selection = last_name_listbox.curselection() - if selection and selection[0] > 0: - # Move up in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] - 1) - last_name_listbox.see(selection[0] - 1) - else: - # At top, go back to entry field - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - elif event.keysym == 'Down': - selection = last_name_listbox.curselection() - max_index = last_name_listbox.size() - 1 - if selection and selection[0] < max_index: - # Move down in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] + 1) - last_name_listbox.see(selection[0] + 1) - return 'break' - - # Track if we're navigating to listbox to prevent auto-hide - navigating_to_listbox = False - escape_pressed = False - enter_pressed = False - - def _safe_hide_suggestions(): - """Hide suggestions only if not navigating to listbox""" - nonlocal navigating_to_listbox - if not navigating_to_listbox: - _hide_suggestions() - navigating_to_listbox = False - - def _safe_show_suggestions(): - """Show suggestions only if escape or enter wasn't just pressed""" - nonlocal escape_pressed, enter_pressed - if not escape_pressed and not enter_pressed: - _show_suggestions() - escape_pressed = False - enter_pressed = False - - # Bind events - last_name_entry.bind('', lambda e: _safe_show_suggestions()) - last_name_entry.bind('', _on_key_press) - last_name_entry.bind('', lambda e: self.main_frame.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks - last_name_listbox.bind('', _on_listbox_click) - last_name_listbox.bind('', _on_listbox_key) - last_name_listbox.bind('', _on_listbox_click) - - def _clear_form(self): - """Clear the identification form""" - self.components['first_name_var'].set('') - self.components['last_name_var'].set('') - self.components['middle_name_var'].set('') - self.components['maiden_name_var'].set('') - self.components['date_of_birth_var'].set('') - - def _update_similar_faces(self, face_id: int): - """Update the similar faces panel""" - # Enable select all/clear all buttons - self.components['select_all_btn'].config(state='normal') - self.components['clear_all_btn'].config(state='normal') - - # Find similar faces using the filtered method like the original - similar_faces = self.face_processor._get_filtered_similar_faces(face_id, DEFAULT_FACE_TOLERANCE, include_same_photo=False, face_status=self.face_status) - - # Clear existing similar faces - scrollable_frame = self.components['similar_scrollable_frame'] - for widget in scrollable_frame.winfo_children(): - widget.destroy() - - # Display similar faces - if similar_faces: - - # Sort by confidence (distance) - highest confidence first (lowest distance) - similar_faces.sort(key=lambda x: x['distance']) - - # Ensure photo paths are available for similar faces - self._ensure_photo_paths_for_similar_faces(similar_faces) - - # Display similar faces using the original approach - self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id) - - # Update canvas scroll region - canvas = self.components['similar_canvas'] - canvas.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) - else: - no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found", - foreground="gray", font=("Arial", 10)) - no_faces_label.pack(pady=20) - - def _ensure_photo_paths_for_similar_faces(self, similar_faces): - """Ensure photo paths are available in cache for similar faces""" - # Get photo IDs from similar faces that are not in cache - missing_photo_ids = [] - for face_data in similar_faces: - photo_id = face_data['photo_id'] - if photo_id not in self.identify_data_cache['photo_paths']: - missing_photo_ids.append(photo_id) - - # Fetch missing photo paths from database - if missing_photo_ids: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - placeholders = ','.join(['?' for _ in missing_photo_ids]) - cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', missing_photo_ids) - missing_photo_paths = dict(cursor.fetchall()) - - # Add to cache - self.identify_data_cache['photo_paths'].update(missing_photo_paths) - - def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id): - """Display similar faces in a panel - based on original implementation""" - # Store similar face variables for Select All/Clear All functionality - similar_face_vars = [] - - # Create all similar faces using auto-match style display - for i, face_data in enumerate(similar_faces_data[:10]): # Limit to 10 faces - similar_face_id = face_data['face_id'] - filename = face_data['filename'] - distance = face_data['distance'] - quality = face_data.get('quality_score', 0.5) - - # Calculate calibrated confidence (actual match probability) - confidence_pct, confidence_desc = self.face_processor._get_calibrated_confidence(distance) - - # Create match frame using auto-match style - match_frame = ttk.Frame(parent_frame) - match_frame.pack(fill=tk.X, padx=5, pady=5) - - # Checkbox for this match (reusing auto-match checkbox style) - match_var = tk.BooleanVar() - similar_face_vars.append((similar_face_id, match_var)) - - # Store the variable for later use - if similar_face_id not in self.face_selection_states: - self.face_selection_states[similar_face_id] = {} - self.face_selection_states[similar_face_id]['var'] = match_var - - # Restore previous checkbox state if available (auto-match style) - if similar_face_id in self.face_selection_states and 'var' in self.face_selection_states[similar_face_id]: - prev_var = self.face_selection_states[similar_face_id]['var'] - if hasattr(prev_var, 'get'): - match_var.set(prev_var.get()) - - # Checkbox - checkbox = ttk.Checkbutton(match_frame, variable=match_var) - checkbox.pack(side=tk.LEFT, padx=(0, 5)) - - # Face image (moved to be right after checkbox) - try: - photo_id = face_data['photo_id'] - location = face_data['location'] - photo_path = self.identify_data_cache['photo_paths'].get(photo_id, '') - - if photo_path: - face_crop_path = self.face_processor._extract_face_crop(photo_path, location, similar_face_id) - if face_crop_path and os.path.exists(face_crop_path): - image = Image.open(face_crop_path) - image.thumbnail((100, 100), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(image) - - # Create a canvas for the face image to allow photo icon drawing - face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0) - face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_image(50, 50, image=photo, anchor=tk.CENTER) - face_canvas.image = photo # Keep reference - - # Add photo icon exactly at the image's top-right corner - self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=20, - face_x=0, face_y=0, - face_width=100, face_height=100, - canvas_width=100, canvas_height=100) - else: - # Face crop extraction failed or file doesn't exist - print(f"Face crop not available for face {similar_face_id}: {face_crop_path}") - # Create placeholder canvas - face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray') - face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_text(50, 50, text="No\nImage", fill="gray", font=("Arial", 10)) - else: - # Photo path not found in cache - print(f"Photo path not found for photo_id {photo_id} in cache") - # Create placeholder canvas - face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray') - face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_text(50, 50, text="No\nPath", fill="gray", font=("Arial", 10)) - except Exception as e: - print(f"Error creating similar face widget for face {similar_face_id}: {e}") - # Create placeholder canvas on error - face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray') - face_canvas.pack(side=tk.LEFT, padx=(0, 5)) - face_canvas.create_text(50, 50, text="Error", fill="red", font=("Arial", 10)) - - # Confidence label with color coding and description - confidence_text = f"{confidence_pct:.0f}% {confidence_desc}" - if confidence_pct >= 80: - color = "green" - elif confidence_pct >= 70: - color = "orange" - elif confidence_pct >= 60: - color = "red" - else: - color = "gray" - - confidence_label = ttk.Label(match_frame, text=confidence_text, foreground=color, font=("Arial", 8, "bold")) - confidence_label.pack(side=tk.LEFT, padx=(0, 5)) - - # Filename - filename_label = ttk.Label(match_frame, text=filename, font=("Arial", 8)) - filename_label.pack(side=tk.LEFT, padx=(0, 5)) - - # Store the similar face variables for Select All/Clear All functionality - self.similar_face_vars = similar_face_vars - - def _get_confidence_description(self, confidence_pct: float) -> str: - """Get confidence description based on percentage""" - if confidence_pct >= 80: - return "(Very High)" - elif confidence_pct >= 70: - return "(High)" - elif confidence_pct >= 60: - return "(Medium)" - elif confidence_pct >= 50: - return "(Low)" - else: - return "(Very Low)" - - def _identify_face(self): - """Identify the current face""" - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - - if not first_name or not last_name or not date_of_birth: - self.gui_core.create_large_messagebox(self.main_frame, "Missing Information", "Please fill in first name, last name, and date of birth.", "warning") - return - - if not self.current_faces or self.current_face_index >= len(self.current_faces): - return - - face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model = self.current_faces[self.current_face_index] - - # Get person data - person_data = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': self.components['middle_name_var'].get().strip(), - 'maiden_name': self.components['maiden_name_var'].get().strip(), - 'date_of_birth': date_of_birth - } - - # Store the identification - self.face_person_names[face_id] = person_data - self.face_status[face_id] = 'identified' - - # Save to database - self._save_identification(face_id, person_data) - - # If compare mode is active, identify selected similar faces - if self.components['compare_var'].get(): - self._identify_selected_similar_faces(person_data) - - # Clear the form after successful identification - self._clear_form() - - # Move to next face - self._go_next() - - def _save_identification(self, face_id: int, person_data: Dict): - """Save face identification to database""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - - # Normalize names to title case for case-insensitive matching - normalized_data = { - 'first_name': person_data['first_name'].strip().title(), - 'last_name': person_data['last_name'].strip().title(), - 'middle_name': person_data['middle_name'].strip().title() if person_data['middle_name'] else '', - 'maiden_name': person_data['maiden_name'].strip().title() if person_data['maiden_name'] else '', - 'date_of_birth': person_data['date_of_birth'].strip() - } - - # Check if person already exists (case-insensitive) - cursor.execute(''' - SELECT id FROM people - WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?) - AND LOWER(COALESCE(middle_name, '')) = LOWER(?) AND LOWER(COALESCE(maiden_name, '')) = LOWER(?) - AND date_of_birth = ? - ''', (normalized_data['first_name'], normalized_data['last_name'], - normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth'])) - - person_row = cursor.fetchone() - if person_row: - person_id = person_row[0] - else: - # Create new person - cursor.execute(''' - INSERT INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) - VALUES (?, ?, ?, ?, ?) - ''', (normalized_data['first_name'], normalized_data['last_name'], - normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth'])) - person_id = cursor.lastrowid - - # Update face with person_id - cursor.execute('UPDATE faces SET person_id = ? WHERE id = ?', (person_id, face_id)) - - conn.commit() - - except Exception as e: - print(f"Error saving identification: {e}") - messagebox.showerror("Error", f"Failed to save identification: {e}") - - def _identify_selected_similar_faces(self, person_data: Dict): - """Identify selected similar faces with the same person""" - if hasattr(self, 'similar_face_vars'): - for face_id, var in self.similar_face_vars: - if var.get(): - # This face is selected, identify it - self.face_person_names[face_id] = person_data - self.face_status[face_id] = 'identified' - self._save_identification(face_id, person_data) - - def _go_back(self): - """Go back to the previous face that meets quality criteria""" - # Validate navigation (check for unsaved changes) - validation_result = self._validate_navigation() - if validation_result == 'cancel': - return # Cancel navigation - elif validation_result == 'save_and_continue': - # Save the current identification before proceeding - if self.current_faces and self.current_face_index < len(self.current_faces): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - if first_name and last_name and date_of_birth: - person_data = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': self.components['middle_name_var'].get().strip(), - 'maiden_name': self.components['maiden_name_var'].get().strip(), - 'date_of_birth': date_of_birth - } - self.face_person_names[face_id] = person_data - self.face_status[face_id] = 'identified' - elif validation_result == 'discard_and_continue': - # Clear the form but don't save - self._clear_form() - - # Get quality filter - min_quality = self.components['quality_filter_var'].get() - min_quality_score = min_quality / 100.0 - - # Find previous qualifying face - found = False - - for i in range(self.current_face_index - 1, -1, -1): - _, _, _, _, _, _, quality, _, _ = self.current_faces[i] - quality_score = quality if quality is not None else 0.0 - - if quality_score >= min_quality_score: - self.current_face_index = i - found = True - break - - if found: - self._update_current_face() - self._update_button_states() - else: - messagebox.showinfo("No Previous Face", - f"No previous face with quality >= {min_quality}%.") - - def _go_next(self): - """Go to the next face that meets quality criteria""" - # Validate navigation (check for unsaved changes) - validation_result = self._validate_navigation() - if validation_result == 'cancel': - return # Cancel navigation - elif validation_result == 'save_and_continue': - # Save the current identification before proceeding - if self.current_faces and self.current_face_index < len(self.current_faces): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - if first_name and last_name and date_of_birth: - person_data = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': self.components['middle_name_var'].get().strip(), - 'maiden_name': self.components['maiden_name_var'].get().strip(), - 'date_of_birth': date_of_birth - } - self.face_person_names[face_id] = person_data - self.face_status[face_id] = 'identified' - elif validation_result == 'discard_and_continue': - # Clear the form but don't save - self._clear_form() - - # Get quality filter - min_quality = self.components['quality_filter_var'].get() - min_quality_score = min_quality / 100.0 - - # Find next qualifying face - original_index = self.current_face_index - found = False - - for i in range(self.current_face_index + 1, len(self.current_faces)): - _, _, _, _, _, _, quality, _, _ = self.current_faces[i] - quality_score = quality if quality is not None else 0.0 - - if quality_score >= min_quality_score: - self.current_face_index = i - found = True - break - - if found: - self._update_current_face() - self._update_button_states() - else: - # No more qualifying faces in current batch, try to load more - self._load_more_faces() - - def _load_more_faces(self): - """Load more faces from cache if available""" - if not self.cache_loaded: - messagebox.showinfo("Complete", "šŸŽ‰ All faces have been identified!") - self._quit_identification() - return - - # Get quality filter - min_quality = self.components['quality_filter_var'].get() - min_quality_score = min_quality / 100.0 - - # Get sort option - sort_display = self.components['sort_var'].get() - sort_by = self.sort_value_map.get(sort_display, "quality") - - # Get batch size - try: - batch_size = int(self.components['batch_var'].get().strip()) - except Exception: - batch_size = DEFAULT_BATCH_SIZE - - # Get more faces from cache (extend current batch) - current_batch_size = len(self.current_faces) - new_batch_size = current_batch_size + batch_size - - more_faces = self._filter_cached_faces(min_quality_score, sort_by, new_batch_size) - - if len(more_faces) > current_batch_size: - # Add new faces to current faces - new_faces = more_faces[current_batch_size:] - self.current_faces.extend(new_faces) - self.current_face_index += 1 - self._update_current_face() - self._update_button_states() - print(f"āœ… Loaded {len(new_faces)} more faces from cache") - else: - # No more faces - messagebox.showinfo("Complete", "šŸŽ‰ All faces have been identified!") - self._quit_identification() - - def _update_button_states(self): - """Update button states based on current position""" - self.components['back_btn'].config(state='normal' if self.current_face_index > 0 else 'disabled') - self.components['next_btn'].config(state='normal' if self.current_face_index < len(self.current_faces) - 1 else 'disabled') - - def _select_all_similar(self): - """Select all similar faces""" - if hasattr(self, 'similar_face_vars'): - for face_id, var in self.similar_face_vars: - var.set(True) - - def _clear_all_similar(self): - """Clear all similar face selections""" - if hasattr(self, 'similar_face_vars'): - for face_id, var in self.similar_face_vars: - var.set(False) - - def _quit_identification(self): - """Quit the identification process""" - # First check for unsaved changes in the current form - validation_result = self._validate_navigation() - if validation_result == 'cancel': - return # Cancel quit - elif validation_result == 'save_and_continue': - # Save the current identification before proceeding - if self.current_faces and self.current_face_index < len(self.current_faces): - face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index] - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - if first_name and last_name and date_of_birth: - person_data = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': self.components['middle_name_var'].get().strip(), - 'maiden_name': self.components['maiden_name_var'].get().strip(), - 'date_of_birth': date_of_birth - } - self.face_person_names[face_id] = person_data - self.face_status[face_id] = 'identified' - elif validation_result == 'discard_and_continue': - # Clear the form but don't save - self._clear_form() - - # Check for pending identifications - pending_identifications = self._get_pending_identifications() - - if pending_identifications: - result = self.gui_core.create_large_messagebox( - self.main_frame, - "Save Pending Identifications?", - f"You have {len(pending_identifications)} pending identifications.\n\n" - "Do you want to save them before quitting?\n\n" - "• Yes: Save all pending identifications and quit\n" - "• No: Quit without saving\n" - "• Cancel: Return to identification", - "askyesnocancel" - ) - - if result is True: # Yes - Save and quit - self._save_all_pending_identifications() - elif result is False: # No - Quit without saving - pass - else: # Cancel - Don't quit - return - - # Clean up - 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 _validate_navigation(self): - """Validate that navigation is safe (no unsaved changes)""" - # Check if there are any unsaved changes in the form - first_name = self.components['first_name_var'].get().strip() - last_name = self.components['last_name_var'].get().strip() - date_of_birth = self.components['date_of_birth_var'].get().strip() - - # If all three required fields are filled, ask for confirmation - if first_name and last_name and date_of_birth: - result = self.gui_core.create_large_messagebox( - self.main_frame, - "Unsaved Changes", - "You have unsaved changes in the identification form.\n\n" - "Do you want to save them before continuing?\n\n" - "• Yes: Save current identification and continue\n" - "• No: Discard changes and continue\n" - "• Cancel: Stay on current face", - "askyesnocancel" - ) - - if result is True: # Yes - Save and continue - return 'save_and_continue' - elif result is False: # No - Discard and continue - return 'discard_and_continue' - else: # Cancel - Don't navigate - return 'cancel' - - return 'continue' # No changes, safe to continue - - def _get_pending_identifications(self) -> List[int]: - """Get list of face IDs with pending identifications""" - pending = [] - for face_id, person_data in self.face_person_names.items(): - if face_id not in self.face_status or self.face_status[face_id] != 'identified': - # Check if form has complete data - if (person_data.get('first_name') and - person_data.get('last_name') and - person_data.get('date_of_birth')): - pending.append(face_id) - return pending - - def _save_all_pending_identifications(self): - """Save all pending identifications""" - for face_id in self._get_pending_identifications(): - person_data = self.face_person_names[face_id] - self._save_identification(face_id, person_data) - self.face_status[face_id] = 'identified' - - def _cleanup(self): - """Clean up resources""" - if self.current_face_crop_path: - self.face_processor.cleanup_face_crops(self.current_face_crop_path) - - # Clear state - self.current_faces = [] - self.current_face_index = 0 - self.face_status = {} - self.face_person_names = {} - self.face_selection_states = {} - self.identify_data_cache = {} - if hasattr(self, 'similar_face_vars'): - self.similar_face_vars = [] - - # Clear cache - self.all_faces_cache = [] - self.cache_loaded = False - - # Clear right panel content - scrollable_frame = self.components['similar_scrollable_frame'] - for widget in scrollable_frame.winfo_children(): - widget.destroy() - # Show message that compare is disabled - no_compare_label = ttk.Label(scrollable_frame, text="Enable 'Compare similar faces' to see similar faces", - foreground="gray", font=("Arial", 10)) - no_compare_label.pack(pady=20) - - # Clear form - self._clear_form() - - # Clear info label - self.components['info_label'].config(text="") - - # Clear face canvas - self.components['face_canvas'].delete("all") - - def _apply_date_filters(self): - """Apply quality and sort filters to cached data (no database access)""" - # Check if cache is loaded - if not self.cache_loaded: - messagebox.showinfo("Start Identification First", - "Please click 'Start Identification' to load faces before applying filters.") - return - - # Get quality filter - min_quality = self.components['quality_filter_var'].get() - min_quality_score = min_quality / 100.0 - - # Get sort option - sort_display = self.components['sort_var'].get() - sort_by = self.sort_value_map.get(sort_display, "quality") - - # Get batch size - try: - batch_size = int(self.components['batch_var'].get().strip()) - except Exception: - batch_size = DEFAULT_BATCH_SIZE - - # Apply filtering to cached data - print("šŸ”„ Applying filters to cached data...") - self.current_faces = self._filter_cached_faces(min_quality_score, sort_by, batch_size) - - if not self.current_faces: - messagebox.showinfo("No Faces Found", - f"No faces found with quality >= {min_quality}%.\n" - f"Try lowering the quality filter or click 'Start Identification' to reload from database.") - return - - # Reset state - self.current_face_index = 0 - self.face_status = {} - self.face_person_names = {} - self.face_selection_states = {} - - # Pre-fetch data - self.identify_data_cache = self._prefetch_identify_data(self.current_faces) - - # Show the first face - self._update_current_face() - self._update_button_states() - - self.is_active = True - print(f"āœ… Applied filters: {len(self.current_faces)} faces (from {len(self.all_faces_cache)} total cached)") - - def _find_next_qualifying_face(self, min_quality: int): - """Find the next face that meets the quality criteria""" - if not self.current_faces: - return - - min_quality_score = min_quality / 100.0 # Convert percentage to 0-1 scale - - # Search from current index forward - original_index = self.current_face_index - found = False - - for i in range(self.current_face_index, len(self.current_faces)): - _, _, _, _, _, _, quality, _, _ = self.current_faces[i] - quality_score = quality if quality is not None else 0.0 - - if quality_score >= min_quality_score: - self.current_face_index = i - found = True - break - - # If not found forward, search from beginning - if not found: - for i in range(0, original_index): - _, _, _, _, _, _, quality, _, _ = self.current_faces[i] - quality_score = quality if quality is not None else 0.0 - - if quality_score >= min_quality_score: - self.current_face_index = i - found = True - break - - if found: - self._update_current_face() - self._update_button_states() - else: - messagebox.showinfo("No Qualifying Faces", - f"No faces found with quality >= {min_quality}%.\n" - f"Lower the quality filter to see more faces.") - - def _open_date_picker(self, date_var: tk.StringVar): - """Open date picker dialog""" - current_date = date_var.get() - selected_date = self.gui_core.create_calendar_dialog(None, "Select Date", current_date) - if selected_date is not None: - date_var.set(selected_date) - - 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 - - def update_layout(self): - """Update panel layout for responsiveness""" - if hasattr(self, 'components') and 'similar_canvas' in self.components: - # Update similar faces canvas scroll region - canvas = self.components['similar_canvas'] - canvas.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) - - # Update face canvas size if needed (for window resize events) - if hasattr(self, 'components') and 'face_canvas' in self.components: - self._update_face_canvas_size() diff --git a/archive/desktop/gui/modify_panel.py b/archive/desktop/gui/modify_panel.py deleted file mode 100644 index e70829f..0000000 --- a/archive/desktop/gui/modify_panel.py +++ /dev/null @@ -1,741 +0,0 @@ -#!/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("", 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, 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( - "", - 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("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('', 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, - f.face_confidence, f.quality_score, f.detector_backend, f.model_name - 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, face_conf, quality, detector, model) 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")) diff --git a/archive/desktop/gui/tag_manager_panel.py b/archive/desktop/gui/tag_manager_panel.py deleted file mode 100644 index 32ad871..0000000 --- a/archive/desktop/gui/tag_manager_panel.py +++ /dev/null @@ -1,1528 +0,0 @@ -#!/usr/bin/env python3 -""" -Integrated Tag Manager Panel for PunimTag Dashboard -Embeds the full tag manager GUI functionality into the dashboard frame -""" - -import os -import tkinter as tk -from tkinter import ttk, messagebox, simpledialog -from PIL import Image -try: - from PIL import ImageTk -except ImportError: - # Fallback for older PIL versions - import ImageTk -from typing import List, Dict, Tuple, Optional -import sys -import subprocess - -from src.core.database import DatabaseManager -from src.gui.gui_core import GUICore -from src.core.tag_management import TagManager -from src.core.face_processing import FaceProcessor - - -class TagManagerPanel: - """Integrated tag manager panel that embeds the full tag manager GUI functionality into the dashboard""" - - def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager, - gui_core: GUICore, tag_manager: TagManager, face_processor: FaceProcessor, on_navigate_home=None, verbose: int = 0): - """Initialize the tag manager panel""" - self.parent_frame = parent_frame - self.db = db_manager - self.gui_core = gui_core - self.tag_manager = tag_manager - self.face_processor = face_processor - self.on_navigate_home = on_navigate_home - self.verbose = verbose - - # Panel state - self.is_active = False - self.temp_crops: List[str] = [] - self.photo_images: List[ImageTk.PhotoImage] = [] # Keep PhotoImage refs alive - self.folder_states: Dict[str, bool] = {} - - # Track pending tag changes/removals using tag IDs - self.pending_tag_changes: Dict[int, List[int]] = {} - self.pending_tag_removals: Dict[int, List[int]] = {} - # Track linkage type for pending additions: 0=single, 1=bulk - self.pending_tag_linkage_type: Dict[int, Dict[int, int]] = {} - - # Tag data - self.existing_tags: List[str] = [] - self.tag_id_to_name: Dict[int, str] = {} - self.tag_name_to_id: Dict[str, int] = {} - - # Photo data - self.photos_data: List[Dict] = [] - self.people_names_cache: Dict[int, List[str]] = {} # {photo_id: [list of people names]} - - # View configuration - self.view_mode_var = tk.StringVar(value="list") - self.column_visibility = { - 'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'compact': {'filename': True, 'faces': True, 'tags': True} - } - - # Column resizing state - self.resize_start_x = 0 - self.resize_start_widths: List[int] = [] - self.current_visible_cols: List[Dict] = [] - self.is_resizing = False - - self.column_config = { - 'list': [ - {'key': 'id', 'label': 'ID', 'width': 60, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 250, 'weight': 1}, - {'key': 'path', 'label': 'Path', 'width': 400, 'weight': 2}, - {'key': 'processed', 'label': 'Processed', 'width': 100, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 140, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 80, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 300, 'weight': 1} - ], - 'icons': [ - {'key': 'thumbnail', 'label': 'Thumbnail', 'width': 200, 'weight': 0}, - {'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 300, 'weight': 1}, - {'key': 'processed', 'label': 'Processed', 'width': 100, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 140, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 80, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 300, 'weight': 1} - ], - 'compact': [ - {'key': 'filename', 'label': 'Filename', 'width': 400, 'weight': 1}, - {'key': 'faces', 'label': 'Faces', 'width': 80, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 500, 'weight': 1} - ] - } - - # GUI components - self.components = {} - self.main_frame = None - - def create_panel(self) -> ttk.Frame: - """Create the tag manager 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) - self.main_frame.rowconfigure(0, weight=0) # Header - fixed height - self.main_frame.rowconfigure(1, weight=1) # Content area - expandable - self.main_frame.rowconfigure(2, weight=0) # Bottom buttons - fixed height - - # Create all GUI components - self._create_header() - self._create_content_area() - self._create_bottom_buttons() - - # Load initial data - self._load_existing_tags() - self._load_photos() - self._switch_view_mode("list") - - return self.main_frame - - def _create_header(self): - """Create the header with title and view controls""" - header_frame = ttk.Frame(self.main_frame) - header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - header_frame.columnconfigure(1, weight=1) - - # Title - title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W) - - # View controls - view_frame = ttk.Frame(header_frame) - view_frame.grid(row=0, column=1, sticky=tk.E) - ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="List", variable=self.view_mode_var, value="list", - command=lambda: self._switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Icons", variable=self.view_mode_var, value="icons", - command=lambda: self._switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Compact", variable=self.view_mode_var, value="compact", - command=lambda: self._switch_view_mode("compact")).pack(side=tk.LEFT) - - # Manage Tags button - manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=self._open_manage_tags_dialog) - manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0)) - - self.components['header_frame'] = header_frame - self.components['view_frame'] = view_frame - self.components['manage_tags_btn'] = manage_tags_btn - - def _create_content_area(self): - """Create the main content area with scrollable canvas""" - content_frame = ttk.Frame(self.main_frame) - content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - content_frame.columnconfigure(0, weight=1) - content_frame.rowconfigure(0, weight=1) - - # Get canvas background color - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - - # Create scrollable content area - self.components['content_canvas'] = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0) - self.components['content_scrollbar'] = ttk.Scrollbar(content_frame, orient="vertical", - command=self.components['content_canvas'].yview) - self.components['content_inner'] = ttk.Frame(self.components['content_canvas']) - - self.components['content_canvas'].create_window((0, 0), window=self.components['content_inner'], anchor="nw") - self.components['content_canvas'].configure(yscrollcommand=self.components['content_scrollbar'].set) - self.components['content_inner'].bind("", - lambda e: self.components['content_canvas'].configure( - scrollregion=self.components['content_canvas'].bbox("all"))) - - self.components['content_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - self.components['content_scrollbar'].grid(row=0, column=1, sticky=(tk.N, tk.S)) - - # Bind mousewheel scrolling - self._bind_mousewheel_scrolling() - - self.components['content_frame'] = content_frame - - def _create_bottom_buttons(self): - """Create the bottom button area""" - bottom_frame = ttk.Frame(self.main_frame) - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) - - # Save button - self.components['save_button'] = ttk.Button(bottom_frame, text="Save Tagging", - command=self._save_tagging_changes) - self.components['save_button'].pack(side=tk.RIGHT, padx=10, pady=5) - - # Quit button (for standalone mode - not used in dashboard) - self.components['quit_button'] = ttk.Button(bottom_frame, text="Exit Tag Manager", - command=self._quit_with_warning) - self.components['quit_button'].pack(side=tk.RIGHT, padx=(0, 10), pady=5) - - self.components['bottom_frame'] = bottom_frame - - def _bind_mousewheel_scrolling(self): - """Bind mousewheel scrolling to the content canvas""" - def on_mousewheel(event): - self.components['content_canvas'].yview_scroll(int(-1 * (event.delta / 120)), "units") - - # Bind to the main frame and all its children - self.main_frame.bind_all("", on_mousewheel) - self.main_frame.bind_all("", self._global_mouse_release) - - # Store reference for cleanup - self._mousewheel_handler = on_mousewheel - - def _start_resize(self, event, col_idx): - """Start column resizing""" - self.is_resizing = True - self.resize_start_x = event.x_root - self.resize_start_widths = [col['width'] for col in self.current_visible_cols] - self.main_frame.configure(cursor="sb_h_double_arrow") - - def _do_resize(self, event, col_idx): - """Handle column resizing during drag""" - if not self.is_resizing or not self.resize_start_widths or not self.current_visible_cols: - return - delta_x = event.x_root - self.resize_start_x - if col_idx < len(self.current_visible_cols) and col_idx + 1 < len(self.current_visible_cols): - new_width_left = max(50, self.resize_start_widths[col_idx] + delta_x) - new_width_right = max(50, self.resize_start_widths[col_idx + 1] - delta_x) - self.current_visible_cols[col_idx]['width'] = new_width_left - self.current_visible_cols[col_idx + 1]['width'] = new_width_right - - # Update column config - for i, col in enumerate(self.column_config['list']): - if col['key'] == self.current_visible_cols[col_idx]['key']: - self.column_config['list'][i]['width'] = new_width_left - elif col['key'] == self.current_visible_cols[col_idx + 1]['key']: - self.column_config['list'][i]['width'] = new_width_right - - # Update grid configuration - try: - header_frame_ref = None - row_frames = [] - for widget in self.components['content_inner'].winfo_children(): - if isinstance(widget, ttk.Frame): - if header_frame_ref is None: - header_frame_ref = widget - else: - row_frames.append(widget) - - if header_frame_ref is not None: - header_frame_ref.columnconfigure(col_idx * 2, weight=self.current_visible_cols[col_idx]['weight'], minsize=new_width_left) - header_frame_ref.columnconfigure((col_idx + 1) * 2, weight=self.current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right) - - for rf in row_frames: - rf.columnconfigure(col_idx, weight=self.current_visible_cols[col_idx]['weight'], minsize=new_width_left) - rf.columnconfigure(col_idx + 1, weight=self.current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right) - - self.main_frame.update_idletasks() - except Exception: - pass - - def _stop_resize(self, event): - """Stop column resizing""" - self.is_resizing = False - self.main_frame.configure(cursor="") - - def _global_mouse_release(self, event): - """Handle global mouse release for column resizing""" - if self.is_resizing: - self._stop_resize(event) - - def _show_column_context_menu(self, event, view_mode): - """Show column context menu for show/hide columns""" - popup = tk.Toplevel(self.main_frame) - popup.wm_overrideredirect(True) - popup.wm_geometry(f"+{event.x_root}+{event.y_root}") - popup.configure(bg='white', relief='flat', bd=0) - menu_frame = tk.Frame(popup, bg='white') - menu_frame.pack(padx=2, pady=2) - checkbox_vars: Dict[str, tk.BooleanVar] = {} - protected_columns = {'icons': ['thumbnail'], 'compact': ['filename'], 'list': ['filename']} - - def close_popup(): - try: - popup.destroy() - except Exception: - pass - - def close_on_click_outside(e): - if e.widget != popup: - try: - popup.winfo_exists() - close_popup() - except tk.TclError: - pass - - for col in self.column_config[view_mode]: - key = col['key'] - label = col['label'] - is_visible = self.column_visibility[view_mode][key] - is_protected = key in protected_columns.get(view_mode, []) - item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0) - item_frame.pack(fill=tk.X, pady=1) - var = tk.BooleanVar(value=is_visible) - checkbox_vars[key] = var - - def make_toggle_command(col_key, var_ref): - def toggle_column(): - if col_key in protected_columns.get(view_mode, []): - return - self.column_visibility[view_mode][col_key] = var_ref.get() - self._switch_view_mode(view_mode) - return toggle_column - - if is_protected: - cb = tk.Checkbutton(item_frame, text=label, variable=var, state='disabled', - bg='white', fg='gray', font=("Arial", 9), relief='flat', - bd=0, highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', - font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5)) - else: - cb = tk.Checkbutton(item_frame, text=label, variable=var, - command=make_toggle_command(key, var), bg='white', - font=("Arial", 9), relief='flat', bd=0, highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - - self.main_frame.bind("", close_on_click_outside) - self.main_frame.bind("", close_on_click_outside) - self.components['content_canvas'].bind("", close_on_click_outside) - self.components['content_canvas'].bind("", close_on_click_outside) - popup.focus_set() - - def _unbind_mousewheel_scrolling(self): - """Unbind mousewheel scrolling and mouse events""" - try: - self.main_frame.unbind_all("") - self.main_frame.unbind_all("") - except Exception: - pass - - def _load_existing_tags(self): - """Load existing tags from database""" - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') - self.existing_tags = [] - self.tag_id_to_name = {} - self.tag_name_to_id = {} - for row in cursor.fetchall(): - tag_id, tag_name = row - self.existing_tags.append(tag_name) - self.tag_id_to_name[tag_id] = tag_name - self.tag_name_to_id[tag_name] = tag_id - - def _load_photos(self): - """Load photos data from database""" - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added, - (SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count, - (SELECT GROUP_CONCAT(DISTINCT t.tag_name) - FROM phototaglinkage ptl - JOIN tags t ON t.id = ptl.tag_id - WHERE ptl.photo_id = p.id) as tags - FROM photos p - ORDER BY p.date_taken DESC, p.filename - ''') - self.photos_data = [] - for row in cursor.fetchall(): - self.photos_data.append({ - 'id': row[0], - 'filename': row[1], - 'path': row[2], - 'processed': row[3], - 'date_taken': row[4], - 'date_added': row[5], - 'face_count': row[6] or 0, - 'tags': row[7] or "" - }) - - # Cache people names for each photo - self.people_names_cache.clear() - cursor.execute(''' - SELECT f.photo_id, pe.first_name, pe.last_name - FROM faces f - JOIN people pe ON pe.id = f.person_id - WHERE f.person_id IS NOT NULL - ORDER BY pe.last_name, pe.first_name - ''') - cache_rows = cursor.fetchall() - for row in cache_rows: - photo_id, first_name, last_name = row - if photo_id not in self.people_names_cache: - self.people_names_cache[photo_id] = [] - # Format as "Last, First" or just "First" if no last name - if last_name and first_name: - name = f"{last_name}, {first_name}" - else: - name = first_name or last_name or "Unknown" - if name not in self.people_names_cache[photo_id]: - self.people_names_cache[photo_id].append(name) - - def _get_saved_tag_types_for_photo(self, photo_id: int) -> Dict[int, int]: - """Get saved linkage types for a photo {tag_id: type_int}""" - types: Dict[int, int] = {} - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,)) - for row in cursor.fetchall(): - try: - types[row[0]] = int(row[1]) if row[1] is not None else 0 - except Exception: - types[row[0]] = 0 - except Exception: - pass - return types - - def _get_people_names_for_photo(self, photo_id: int) -> str: - """Get people names for a photo as a formatted string for tooltip""" - people_names = self.people_names_cache.get(photo_id, []) - if not people_names: - return "No people identified" - - # Remove commas from names (convert "Last, First" to "Last First") - formatted_names = [] - for name in people_names: - if ', ' in name: - # Convert "Last, First" to "Last First" - formatted_name = name.replace(', ', ' ') - else: - formatted_name = name - formatted_names.append(formatted_name) - - if len(formatted_names) <= 5: - return f"People: {', '.join(formatted_names)}" - else: - return f"People: {', '.join(formatted_names[:5])}... (+{len(formatted_names)-5} more)" - - def _open_photo(self, photo_path: str): - """Open a photo in a preview window""" - if not os.path.exists(photo_path): - try: - messagebox.showerror("File not found", f"Photo does not exist:\n{photo_path}") - except Exception: - pass - return - try: - # Open in an in-app preview window sized reasonably compared to the main GUI - img = Image.open(photo_path) - screen_w = self.main_frame.winfo_screenwidth() - screen_h = self.main_frame.winfo_screenheight() - max_w = int(min(1000, screen_w * 0.6)) - max_h = int(min(800, screen_h * 0.6)) - preview = tk.Toplevel(self.main_frame) - preview.title(os.path.basename(photo_path)) - preview.transient(self.main_frame) - # Resize image to fit nicely while keeping aspect ratio - img_copy = img.copy() - img_copy.thumbnail((max_w, max_h), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img_copy) - self.photo_images.append(photo_img) - pad = 12 - w, h = photo_img.width(), photo_img.height() - # Center the window roughly relative to screen - x = int((screen_w - (w + pad)) / 2) - y = int((screen_h - (h + pad)) / 2) - preview.geometry(f"{w + pad}x{h + pad}+{max(x,0)}+{max(y,0)}") - canvas = tk.Canvas(preview, width=w, height=h, highlightthickness=0) - canvas.pack(padx=pad//2, pady=pad//2) - canvas.create_image(w // 2, h // 2, image=photo_img) - preview.focus_set() - except Exception: - # Fallback to system default opener if preview fails for any reason - try: - if sys.platform.startswith('linux'): - subprocess.Popen(['xdg-open', photo_path]) - elif sys.platform == 'darwin': - subprocess.Popen(['open', photo_path]) - elif os.name == 'nt': - os.startfile(photo_path) # type: ignore[attr-defined] - else: - Image.open(photo_path).show() - except Exception as e: - try: - messagebox.showerror("Error", f"Failed to open photo:\n{e}") - except Exception: - pass - - def _open_manage_tags_dialog(self): - """Open the manage tags dialog""" - dialog = tk.Toplevel(self.main_frame) - dialog.title("Manage Tags") - dialog.transient(self.main_frame) - dialog.grab_set() - dialog.geometry("600x600") - - top_frame = ttk.Frame(dialog, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(dialog, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(dialog, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - - dialog.columnconfigure(0, weight=1) - dialog.rowconfigure(1, weight=1) - - new_tag_var = tk.StringVar() - new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30) - new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E)) - - def add_new_tag(): - tag_name = new_tag_var.get().strip() - if not tag_name: - return - try: - # Use database method for case-insensitive tag creation - tag_id = self.db.add_tag(tag_name) - if tag_id: - new_tag_var.set("") - refresh_tag_list() - self._load_existing_tags() - self._switch_view_mode(self.view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to add tag: {e}") - - add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag) - add_btn.grid(row=0, column=1, sticky=tk.W) - top_frame.columnconfigure(0, weight=1) - - canvas = tk.Canvas(list_frame, highlightthickness=0) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - rows_container = ttk.Frame(canvas) - canvas.create_window((0, 0), window=rows_container, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - list_frame.columnconfigure(0, weight=1) - list_frame.rowconfigure(0, weight=1) - rows_container.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - - selected_tag_vars: Dict[int, tk.BooleanVar] = {} - current_tags: List[Dict] = [] - - def refresh_tag_list(): - for child in list(rows_container.winfo_children()): - child.destroy() - selected_tag_vars.clear() - current_tags.clear() - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE') - for row in cursor.fetchall(): - current_tags.append({'id': row[0], 'tag_name': row[1]}) - except Exception as e: - messagebox.showerror("Error", f"Failed to load tags: {e}") - return - head = ttk.Frame(rows_container) - head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6)) - ttk.Label(head, text="Delete").pack(side=tk.LEFT, padx=(0, 10)) - ttk.Label(head, text="Tag name", width=30).pack(side=tk.LEFT) - ttk.Label(head, text="Edit", width=6).pack(side=tk.LEFT, padx=(10, 0)) - - for idx, tag in enumerate(current_tags, start=1): - row = ttk.Frame(rows_container) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2) - var = tk.BooleanVar(value=False) - selected_tag_vars[tag['id']] = var - ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 10)) - name_label = ttk.Label(row, text=tag['tag_name'], width=30) - name_label.pack(side=tk.LEFT) - - def make_edit_handler(tag_id, name_widget): - def handler(): - new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", - initialvalue=name_widget.cget('text'), parent=dialog) - if new_name is None: - return - new_name = new_name.strip() - if not new_name: - return - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id)) - conn.commit() - except Exception as e: - messagebox.showerror("Error", f"Failed to rename tag: {e}") - return - refresh_tag_list() - self._load_existing_tags() - self._switch_view_mode(self.view_mode_var.get()) - return handler - - ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name_label)).pack(side=tk.LEFT, padx=(10, 0)) - - refresh_tag_list() - - def delete_selected(): - ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()] - if not ids_to_delete: - return - if not self.gui_core.create_large_messagebox(self.main_frame, "Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos.", "askyesno"): - return - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - conn.commit() - - for photo_id in list(self.pending_tag_changes.keys()): - self.pending_tag_changes[photo_id] = [tid for tid in self.pending_tag_changes[photo_id] if tid not in ids_to_delete] - if not self.pending_tag_changes[photo_id]: - del self.pending_tag_changes[photo_id] - for photo_id in list(self.pending_tag_removals.keys()): - self.pending_tag_removals[photo_id] = [tid for tid in self.pending_tag_removals[photo_id] if tid not in ids_to_delete] - if not self.pending_tag_removals[photo_id]: - del self.pending_tag_removals[photo_id] - - refresh_tag_list() - self._load_existing_tags() - self._load_photos() - self._switch_view_mode(self.view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to delete tags: {e}") - - ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected).pack(side=tk.LEFT) - ttk.Button(bottom_frame, text="Close", command=dialog.destroy).pack(side=tk.RIGHT) - new_tag_entry.focus_set() - - def _save_tagging_changes(self): - """Save pending tag changes to database""" - if not self.pending_tag_changes and not self.pending_tag_removals: - messagebox.showinfo("Info", "No tag changes to save.") - return - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id, tag_ids in self.pending_tag_changes.items(): - for tag_id in tag_ids: - lt = 0 - try: - lt = self.pending_tag_linkage_type.get(photo_id, {}).get(tag_id, 0) - except Exception: - lt = 0 - cursor.execute(''' - INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) - VALUES (?, ?, ?) - ON CONFLICT(photo_id, tag_id) DO UPDATE SET linkage_type=excluded.linkage_type, created_date=CURRENT_TIMESTAMP - ''', (photo_id, tag_id, lt)) - for photo_id, tag_ids in self.pending_tag_removals.items(): - for tag_id in tag_ids: - cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - conn.commit() - saved_additions = len(self.pending_tag_changes) - saved_removals = len(self.pending_tag_removals) - self.pending_tag_changes.clear() - self.pending_tag_removals.clear() - self.pending_tag_linkage_type.clear() - self._load_existing_tags() - self._load_photos() - self._switch_view_mode(self.view_mode_var.get()) - self._update_save_button_text() - message = f"Saved {saved_additions} tag additions" - if saved_removals > 0: - message += f" and {saved_removals} tag removals" - message += "." - messagebox.showinfo("Success", message) - except Exception as e: - messagebox.showerror("Error", f"Failed to save tags: {str(e)}") - - def _update_save_button_text(self): - """Update the save button text to show pending changes""" - total_additions = sum(len(tags) for tags in self.pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in self.pending_tag_removals.values()) - total_changes = total_additions + total_removals - if total_changes > 0: - self.components['save_button'].configure(text=f"Save Tagging ({total_changes} pending)") - else: - self.components['save_button'].configure(text="Save Tagging") - - def _quit_with_warning(self): - """Quit with warning about unsaved changes (for standalone mode)""" - has_pending_changes = bool(self.pending_tag_changes or self.pending_tag_removals) - if has_pending_changes: - total_additions = sum(len(tags) for tags in self.pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in self.pending_tag_removals.values()) - changes_text = [] - if total_additions > 0: - changes_text.append(f"{total_additions} tag addition(s)") - if total_removals > 0: - changes_text.append(f"{total_removals} tag removal(s)") - changes_summary = " and ".join(changes_text) - result = self.gui_core.create_large_messagebox( - self.main_frame, - "Unsaved Changes", - f"You have unsaved changes: {changes_summary}.\n\nDo you want to save your changes before quitting?\n\nYes = Save and quit\nNo = Quit without saving\nCancel = Stay in dialog", - "askyesnocancel" - ) - if result is True: - self._save_tagging_changes() - # In dashboard mode, we don't actually quit the window - elif result is False: - # In dashboard mode, we don't actually quit the window - pass - # In dashboard mode, we don't actually quit the window - # Navigate to home if callback is available (dashboard mode) - if self.on_navigate_home: - self.on_navigate_home() - - def _switch_view_mode(self, mode: str): - """Switch between different view modes""" - if mode == "list": - self._show_list_view() - elif mode == "icons": - self._show_icon_view() - elif mode == "compact": - self._show_compact_view() - - def _clear_content(self): - """Clear the content area""" - for widget in self.components['content_inner'].winfo_children(): - widget.destroy() - for crop in list(self.temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - self.temp_crops.clear() - self.photo_images.clear() - - def _prepare_folder_grouped_data(self): - """Prepare data grouped by folders""" - from collections import defaultdict - folder_groups = defaultdict(list) - for photo in self.photos_data: - folder_path = os.path.dirname(photo['path']) - folder_groups[folder_path].append(photo) - sorted_folders = [] - for folder_path in sorted(folder_groups.keys()): - folder_name = os.path.basename(folder_path) if folder_path else "Root" - photos_in_folder = sorted(folder_groups[folder_path], key=lambda x: x['date_taken'] or '', reverse=True) - if folder_path not in self.folder_states: - # Collapse folders by default on first load - self.folder_states[folder_path] = False - sorted_folders.append({ - 'folder_path': folder_path, - 'folder_name': folder_name, - 'photos': photos_in_folder, - 'photo_count': len(photos_in_folder) - }) - return sorted_folders - - def _create_folder_header(self, parent, folder_info, current_row, col_count, view_mode): - """Create a folder header with expand/collapse and bulk operations""" - folder_header_frame = ttk.Frame(parent) - folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5)) - folder_header_frame.configure(relief='raised', borderwidth=1) - - def open_bulk_link_dialog(): - # Bulk tagging dialog: add selected tag to all photos in this folder (pending changes only) - popup = tk.Toplevel(self.main_frame) - popup.title("Bulk Link Tags to Folder") - popup.transient(self.main_frame) - popup.grab_set() - popup.geometry("650x500") - - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - status_frame = ttk.Frame(popup, padding="8") - status_frame.grid(row=1, column=0, sticky=(tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - # Place bottom frame below the list to keep actions at the bottom - bottom_frame.grid(row=4, column=0, sticky=(tk.W, tk.E)) - popup.columnconfigure(0, weight=1) - - ttk.Label(top_frame, text=f"Folder: {folder_info['folder_name']} ({folder_info['photo_count']} photos)").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6)) - ttk.Label(top_frame, text="Add tag to all photos:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W) - - bulk_tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=bulk_tag_var, values=self.existing_tags, width=30, state='readonly') - combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - result_var = tk.StringVar(value="") - ttk.Label(status_frame, textvariable=result_var, foreground="gray").grid(row=0, column=0, sticky=tk.W) - - def add_bulk_tag(): - tag_name = bulk_tag_var.get().strip() - if not tag_name: - return - # Resolve or create tag id - # Case-insensitive tag lookup - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in self.tag_name_to_id: - tag_id = self.tag_name_to_id[normalized_tag_name] - else: - # Create new tag using database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - self.tag_name_to_id[normalized_tag_name] = tag_id - self.tag_id_to_name[tag_id] = tag_name - if tag_name not in self.existing_tags: - self.existing_tags.append(tag_name) - self.existing_tags.sort() - else: - return # Failed to create tag - - affected = 0 - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - saved_types = self._get_saved_tag_types_for_photo(photo_id) - existing_tag_ids = list(saved_types.keys()) - pending_tag_ids = self.pending_tag_changes.get(photo_id, []) - # Case 1: not present anywhere → add as pending bulk - if tag_id not in existing_tag_ids and tag_id not in pending_tag_ids: - if photo_id not in self.pending_tag_changes: - self.pending_tag_changes[photo_id] = [] - self.pending_tag_changes[photo_id].append(tag_id) - if photo_id not in self.pending_tag_linkage_type: - self.pending_tag_linkage_type[photo_id] = {} - self.pending_tag_linkage_type[photo_id][tag_id] = 1 - affected += 1 - # Case 2: already pending as single → upgrade pending type to bulk - elif tag_id in pending_tag_ids: - if photo_id not in self.pending_tag_linkage_type: - self.pending_tag_linkage_type[photo_id] = {} - prev_type = self.pending_tag_linkage_type[photo_id].get(tag_id) - if prev_type != 1: - self.pending_tag_linkage_type[photo_id][tag_id] = 1 - affected += 1 - # Case 3: saved as single → schedule an upgrade by adding to pending and setting type to bulk - elif saved_types.get(tag_id) == 0: - if photo_id not in self.pending_tag_changes: - self.pending_tag_changes[photo_id] = [] - if tag_id not in self.pending_tag_changes[photo_id]: - self.pending_tag_changes[photo_id].append(tag_id) - if photo_id not in self.pending_tag_linkage_type: - self.pending_tag_linkage_type[photo_id] = {} - self.pending_tag_linkage_type[photo_id][tag_id] = 1 - affected += 1 - # Case 4: saved as bulk → nothing to do - - self._update_save_button_text() - # Refresh main view to reflect updated pending tags in each row - self._switch_view_mode(view_mode) - result_var.set(f"Added pending tag to {affected} photo(s)") - bulk_tag_var.set("") - # Refresh the bulk list to immediately reflect pending adds - try: - refresh_bulk_tag_list() - except Exception: - pass - - ttk.Button(top_frame, text="Add", command=add_bulk_tag).grid(row=1, column=2) - - # Section: Remove bulk-linked tags across this folder - ttk.Separator(status_frame, orient='horizontal').grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(8, 6)) - - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=3, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - popup.rowconfigure(3, weight=1) - list_canvas = tk.Canvas(list_frame, highlightthickness=0) - list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=list_canvas.yview) - list_inner = ttk.Frame(list_canvas) - list_canvas.create_window((0, 0), window=list_inner, anchor="nw") - list_canvas.configure(yscrollcommand=list_scroll.set) - list_canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - list_scroll.grid(row=0, column=1, sticky=(tk.N, tk.S)) - list_frame.columnconfigure(0, weight=1) - list_frame.rowconfigure(0, weight=1) - list_inner.bind("", lambda e: list_canvas.configure(scrollregion=list_canvas.bbox("all"))) - - bulk_tag_vars: Dict[int, tk.BooleanVar] = {} - - def refresh_bulk_tag_list(): - for child in list(list_inner.winfo_children()): - child.destroy() - bulk_tag_vars.clear() - # Aggregate bulk-linked tags across all photos in this folder - tag_id_counts: Dict[int, int] = {} - pending_add_counts: Dict[int, int] = {} - for photo in folder_info.get('photos', []): - saved_types = self._get_saved_tag_types_for_photo(photo['id']) - for tid, ltype in saved_types.items(): - if ltype == 1: - tag_id_counts[tid] = tag_id_counts.get(tid, 0) + 1 - # Count pending additions (bulk type) for this photo - for tid in self.pending_tag_changes.get(photo['id'], []): - if self.pending_tag_linkage_type.get(photo['id'], {}).get(tid) == 1: - pending_add_counts[tid] = pending_add_counts.get(tid, 0) + 1 - # Include tags that exist only in pending adds - all_tag_ids = set(tag_id_counts.keys()) | set(pending_add_counts.keys()) - if not all_tag_ids: - ttk.Label(list_inner, text="No bulk-linked tags in this folder", foreground="gray").pack(anchor=tk.W, pady=5) - return - for tid in sorted(all_tag_ids, key=lambda x: self.tag_id_to_name.get(x, "")): - row = ttk.Frame(list_inner) - row.pack(fill=tk.X, pady=1) - var = tk.BooleanVar(value=False) - bulk_tag_vars[tid] = var - ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 6)) - pend_suffix = " (pending)" if pending_add_counts.get(tid, 0) > 0 else "" - ttk.Label(row, text=f"{self.tag_id_to_name.get(tid, 'Unknown')}{pend_suffix}").pack(side=tk.LEFT) - - def remove_selected_bulk_tags(): - selected_tids = [tid for tid, v in bulk_tag_vars.items() if v.get()] - if not selected_tids: - return - affected = 0 - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - saved_types = self._get_saved_tag_types_for_photo(photo_id) - for tid in selected_tids: - # If it's pending add (bulk), cancel the pending change - if tid in self.pending_tag_changes.get(photo_id, []) and self.pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1: - self.pending_tag_changes[photo_id] = [x for x in self.pending_tag_changes[photo_id] if x != tid] - if not self.pending_tag_changes[photo_id]: - del self.pending_tag_changes[photo_id] - if photo_id in self.pending_tag_linkage_type and tid in self.pending_tag_linkage_type[photo_id]: - del self.pending_tag_linkage_type[photo_id][tid] - if not self.pending_tag_linkage_type[photo_id]: - del self.pending_tag_linkage_type[photo_id] - affected += 1 - # Else if it's a saved bulk linkage, mark for removal - elif saved_types.get(tid) == 1: - if photo_id not in self.pending_tag_removals: - self.pending_tag_removals[photo_id] = [] - if tid not in self.pending_tag_removals[photo_id]: - self.pending_tag_removals[photo_id].append(tid) - affected += 1 - self._update_save_button_text() - self._switch_view_mode(view_mode) - result_var.set(f"Marked bulk tag removals affecting {affected} linkage(s)") - refresh_bulk_tag_list() - - ttk.Button(bottom_frame, text="Remove selected bulk tags", command=remove_selected_bulk_tags).pack(side=tk.LEFT) - ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT) - refresh_bulk_tag_list() - - is_expanded = self.folder_states.get(folder_info['folder_path'], True) - toggle_text = "ā–¼" if is_expanded else "ā–¶" - toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1, - command=lambda: self._toggle_folder(folder_info['folder_path'], view_mode), - font=("Arial", 8), relief='flat', bd=1) - toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5) - - folder_label = ttk.Label(folder_header_frame, text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos)", font=("Arial", 11, "bold")) - folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5) - - # Bulk linkage icon (applies selected tag to all photos in this folder) - bulk_link_btn = tk.Button(folder_header_frame, text="šŸ”—", width=2, command=open_bulk_link_dialog) - bulk_link_btn.pack(side=tk.LEFT, padx=(6, 6)) - - # Compute and show bulk tags for this folder - def compute_folder_bulk_tags() -> str: - bulk_tag_ids = set() - # Gather saved bulk tags - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - saved_types = self._get_saved_tag_types_for_photo(photo_id) - for tid, ltype in saved_types.items(): - if ltype == 1: - bulk_tag_ids.add(tid) - # Include pending bulk adds; exclude pending removals - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - for tid in self.pending_tag_changes.get(photo_id, []): - if self.pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1: - if tid not in self.pending_tag_removals.get(photo_id, []): - bulk_tag_ids.add(tid) - # Exclude any saved bulk tags marked for removal - for tid in self.pending_tag_removals.get(photo_id, []): - if tid in bulk_tag_ids: - bulk_tag_ids.discard(tid) - names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in sorted(bulk_tag_ids, key=lambda x: self.tag_id_to_name.get(x, ""))] - return ", ".join(names) if names else "None" - - # Append bulk tags to the folder label so it's clearly visible - try: - tags_str = compute_folder_bulk_tags() - if tags_str and tags_str != "None": - folder_label.configure(text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos) — Tags: {tags_str}") - else: - folder_label.configure(text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos)") - except Exception: - pass - - return folder_header_frame - - def _toggle_folder(self, folder_path, view_mode): - """Toggle folder expand/collapse state""" - self.folder_states[folder_path] = not self.folder_states.get(folder_path, True) - self._switch_view_mode(view_mode) - - def _create_tag_buttons_frame(self, parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0): - """Create tag management buttons for a photo""" - tags_frame = ttk.Frame(parent) - existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) - pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])] - pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_removals.get(photo_id, [])] - all_tags = existing_tags_list + pending_tag_names - unique_tags = self.tag_manager.deduplicate_tags(all_tags) - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_display = ", ".join(unique_tags) if unique_tags else "None" - tags_text = ttk.Label(tags_frame, text=current_display) - tags_text.pack(side=tk.LEFT) - add_btn = tk.Button(tags_frame, text="šŸ”—", width=2, command=self._create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags, 0)) - add_btn.pack(side=tk.LEFT, padx=(6, 0)) - if use_grid: - tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W) - else: - tags_frame.pack(side=tk.LEFT, padx=5) - return tags_frame - - def _create_add_tag_handler(self, photo_id, label_widget, photo_tags, available_tags, allowed_delete_type: int = 0): - """Create a tag management dialog handler for a photo""" - def handler(): - popup = tk.Toplevel(self.main_frame) - popup.title("Manage Photo Tags") - popup.transient(self.main_frame) - popup.grab_set() - popup.geometry("600x500") - popup.resizable(True, True) - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - popup.columnconfigure(0, weight=1) - popup.rowconfigure(1, weight=1) - ttk.Label(top_frame, text="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W) - tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly') - combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - def _update_tags_label_in_main_list(): - # Recompute combined display for the main list label (saved + pending minus removals) - existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) - pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])] - pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_removals.get(photo_id, [])] - all_tags = existing_tags_list + pending_tag_names - unique_tags = self.tag_manager.deduplicate_tags(all_tags) - # Remove tags marked for removal from display - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_tags = ", ".join(unique_tags) if unique_tags else "None" - try: - label_widget.configure(text=current_tags) - except Exception: - pass - - def add_selected_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - # Case-insensitive tag lookup - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in self.tag_name_to_id: - tag_id = self.tag_name_to_id[normalized_tag_name] - else: - # Create new tag using database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - self.tag_name_to_id[normalized_tag_name] = tag_id - self.tag_id_to_name[tag_id] = tag_name - if tag_name not in self.existing_tags: - self.existing_tags.append(tag_name) - self.existing_tags.sort() - else: - return # Failed to create tag - saved_types = self._get_saved_tag_types_for_photo(photo_id) - existing_tag_ids = list(saved_types.keys()) - pending_tag_ids = self.pending_tag_changes.get(photo_id, []) - all_existing_tag_ids = existing_tag_ids + pending_tag_ids - if tag_id not in all_existing_tag_ids: - if photo_id not in self.pending_tag_changes: - self.pending_tag_changes[photo_id] = [] - self.pending_tag_changes[photo_id].append(tag_id) - # mark pending type as single (0) - if photo_id not in self.pending_tag_linkage_type: - self.pending_tag_linkage_type[photo_id] = {} - self.pending_tag_linkage_type[photo_id][tag_id] = 0 - refresh_tag_list() - self._update_save_button_text() - _update_tags_label_in_main_list() - tag_var.set("") - - ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=0, column=2, padx=(0, 8)) - - canvas = tk.Canvas(list_frame, height=200) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - selected_tag_vars: Dict[str, tk.BooleanVar] = {} - - def refresh_tag_list(): - for widget in scrollable_frame.winfo_children(): - widget.destroy() - selected_tag_vars.clear() - saved_types = self._get_saved_tag_types_for_photo(photo_id) - existing_tag_ids = list(saved_types.keys()) - pending_tag_ids = self.pending_tag_changes.get(photo_id, []) - pending_removal_ids = self.pending_tag_removals.get(photo_id, []) - all_tag_ids = existing_tag_ids + pending_tag_ids - unique_tag_ids = list(set(all_tag_ids)) - unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids] - unique_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids] - if not unique_tag_names: - ttk.Label(scrollable_frame, text="No tags linked to this photo", foreground="gray").pack(anchor=tk.W, pady=5) - return - for i, tag_id in enumerate(unique_tag_ids): - tag_name = self.tag_id_to_name.get(tag_id, f"Unknown {tag_id}") - var = tk.BooleanVar() - selected_tag_vars[tag_name] = var - frame = ttk.Frame(scrollable_frame) - frame.pack(fill=tk.X, pady=1) - is_pending = tag_id in pending_tag_ids - saved_type = saved_types.get(tag_id) - pending_type = self.pending_tag_linkage_type.get(photo_id, {}).get(tag_id) - # In single-photo dialog, only allow selecting pending if pending type is single (0) - is_pending_single = is_pending and pending_type == 0 - can_select = is_pending_single or (saved_type == allowed_delete_type) - cb = ttk.Checkbutton(frame, variable=var) - if not can_select: - try: - cb.state(["disabled"]) # disable selection for disallowed types - except Exception: - pass - cb.pack(side=tk.LEFT, padx=(0, 5)) - type_label = 'single' if saved_type == 0 else ('bulk' if saved_type == 1 else '?') - pending_label = "pending bulk" if (is_pending and pending_type == 1) else "pending" - status_text = f" ({pending_label})" if is_pending else f" (saved {type_label})" - status_color = "blue" if is_pending else ("black" if can_select else "gray") - ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT) - - def remove_selected_tags(): - tag_ids_to_remove = [] - for tag_name, var in selected_tag_vars.items(): - if var.get() and tag_name in self.tag_name_to_id: - tag_ids_to_remove.append(self.tag_name_to_id[tag_name]) - if not tag_ids_to_remove: - return - if photo_id in self.pending_tag_changes: - self.pending_tag_changes[photo_id] = [tid for tid in self.pending_tag_changes[photo_id] if tid not in tag_ids_to_remove] - if not self.pending_tag_changes[photo_id]: - del self.pending_tag_changes[photo_id] - saved_types = self._get_saved_tag_types_for_photo(photo_id) - for tag_id in tag_ids_to_remove: - if saved_types.get(tag_id) == allowed_delete_type: - if photo_id not in self.pending_tag_removals: - self.pending_tag_removals[photo_id] = [] - if tag_id not in self.pending_tag_removals[photo_id]: - self.pending_tag_removals[photo_id].append(tag_id) - refresh_tag_list() - self._update_save_button_text() - _update_tags_label_in_main_list() - - ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8)) - ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT) - refresh_tag_list() - - def on_close(): - existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) - pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])] - pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_removals.get(photo_id, [])] - all_tags = existing_tags_list + pending_tag_names - unique_tags = self.tag_manager.deduplicate_tags(all_tags) - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_tags = ", ".join(unique_tags) if unique_tags else "None" - label_widget.configure(text=current_tags) - popup.destroy() - - popup.protocol("WM_DELETE_WINDOW", on_close) - return handler - - def _show_list_view(self): - """Show the list view""" - self._clear_content() - self.current_visible_cols = [col.copy() for col in self.column_config['list'] if self.column_visibility['list'][col['key']]] - col_count = len(self.current_visible_cols) - if col_count == 0: - ttk.Label(self.components['content_inner'], text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure columns - for i, col in enumerate(self.current_visible_cols): - self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header with resizing separators - header_frame = ttk.Frame(self.components['content_inner']) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - for i, col in enumerate(self.current_visible_cols): - header_frame.columnconfigure(i * 2, weight=col['weight'], minsize=col['width']) - if i < len(self.current_visible_cols) - 1: - header_frame.columnconfigure(i * 2 + 1, weight=0, minsize=1) - - for i, col in enumerate(self.current_visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i * 2, padx=5, sticky=tk.W) - header_label.bind("", lambda e, mode='list': self._show_column_context_menu(e, mode)) - - # Add resizing separator between columns - if i < len(self.current_visible_cols) - 1: - separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow") - separator_frame.grid(row=0, column=i * 2 + 1, sticky='ns', padx=0) - separator_frame.grid_propagate(False) - inner_line = tk.Frame(separator_frame, bg='darkred', width=2) - inner_line.pack(fill=tk.Y, expand=True) - - # Bind resize events - separator_frame.bind("", lambda e, col_idx=i: self._start_resize(e, col_idx)) - separator_frame.bind("", lambda e, col_idx=i: self._do_resize(e, col_idx)) - separator_frame.bind("", self._stop_resize) - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred'))) - - inner_line.bind("", lambda e, col_idx=i: self._start_resize(e, col_idx)) - inner_line.bind("", lambda e, col_idx=i: self._do_resize(e, col_idx)) - inner_line.bind("", self._stop_resize) - - header_frame.bind("", lambda e, mode='list': self._show_column_context_menu(e, mode)) - ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Create folder grouped data and display - folder_data = self._prepare_folder_grouped_data() - current_row = 2 - for folder_info in folder_data: - self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'list') - current_row += 1 - if self.folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(self.components['content_inner']) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - for i, col in enumerate(self.current_visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - for i, col in enumerate(self.current_visible_cols): - key = col['key'] - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'path': - text = photo['path'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=i) - continue - - # Render text wrapped to header width - if key == 'filename': - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", - wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=i, padx=5, sticky=tk.W) - lbl.bind("", lambda e, p=photo['path']: self._open_photo(p)) - elif key == 'faces' and photo['face_count'] > 0: - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", - wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=i, padx=5, sticky=tk.W) - # Show people names in tooltip on hover - try: - people_tooltip_text = self._get_people_names_for_photo(photo['id']) - self._create_tooltip(lbl, people_tooltip_text) - except Exception: - pass - else: - ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=i, padx=5, sticky=tk.W) - current_row += 1 - - def _show_icon_view(self): - """Show the icon view""" - self._clear_content() - visible_cols = [col for col in self.column_config['icons'] if self.column_visibility['icons'][col['key']]] - col_count = len(visible_cols) - if col_count == 0: - ttk.Label(self.components['content_inner'], text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure columns - for i, col in enumerate(visible_cols): - self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header - header_frame = ttk.Frame(self.components['content_inner']) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - header_label.bind("", lambda e, mode='icons': self._show_column_context_menu(e, mode)) - - header_frame.bind("", lambda e, mode='icons': self._show_column_context_menu(e, mode)) - ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Create folder grouped data and display - folder_data = self._prepare_folder_grouped_data() - current_row = 2 - for folder_info in folder_data: - self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'icons') - current_row += 1 - if self.folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(self.components['content_inner']) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2) - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - col_idx = 0 - for col in visible_cols: - key = col['key'] - if key == 'thumbnail': - thumbnail_frame = ttk.Frame(row_frame) - thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - try: - if os.path.exists(photo['path']): - img = Image.open(photo['path']) - img.thumbnail((150, 150), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - self.photo_images.append(photo_img) - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', highlightthickness=0) - canvas.pack() - canvas.create_image(75, 75, image=photo_img) - else: - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="šŸ–¼ļø", fill="gray", font=("Arial", 24)) - except Exception: - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="āŒ", fill="red", font=("Arial", 24)) - else: - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - - # Render text wrapped to header width - if key == 'filename': - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", - wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - lbl.bind("", lambda e, p=photo['path']: self._open_photo(p)) - elif key == 'faces' and photo['face_count'] > 0: - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", - wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - # Show people names in tooltip on hover - try: - people_tooltip_text = self._get_people_names_for_photo(photo['id']) - self._create_tooltip(lbl, people_tooltip_text) - except Exception: - pass - else: - ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W) - col_idx += 1 - current_row += 1 - - def _show_compact_view(self): - """Show the compact view""" - self._clear_content() - visible_cols = [col for col in self.column_config['compact'] if self.column_visibility['compact'][col['key']]] - col_count = len(visible_cols) - if col_count == 0: - ttk.Label(self.components['content_inner'], text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure columns - for i, col in enumerate(visible_cols): - self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header - header_frame = ttk.Frame(self.components['content_inner']) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - header_label.bind("", lambda e, mode='compact': self._show_column_context_menu(e, mode)) - - header_frame.bind("", lambda e, mode='compact': self._show_column_context_menu(e, mode)) - ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Create folder grouped data and display - folder_data = self._prepare_folder_grouped_data() - current_row = 2 - for folder_info in folder_data: - self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'compact') - current_row += 1 - if self.folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(self.components['content_inner']) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - col_idx = 0 - for col in visible_cols: - key = col['key'] - if key == 'filename': - text = photo['filename'] - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - - # Render text wrapped to header width - if key == 'filename': - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", - wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - lbl.bind("", lambda e, p=photo['path']: self._open_photo(p)) - elif key == 'faces' and photo['face_count'] > 0: - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", - wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - # Show people names in tooltip on hover - try: - people_tooltip_text = self._get_people_names_for_photo(photo['id']) - self._create_tooltip(lbl, people_tooltip_text) - except Exception: - pass - else: - ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W) - col_idx += 1 - current_row += 1 - - def _create_tooltip(self, widget, text: str): - """Create a simple 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 = ttk.Label(tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1) - label.pack() - widget.tooltip = tooltip - - def hide_tooltip(event): - if hasattr(widget, 'tooltip'): - widget.tooltip.destroy() - del widget.tooltip - - widget.bind("", show_tooltip) - widget.bind("", hide_tooltip) - - def activate(self): - """Activate the panel""" - self.is_active = True - # Reload photos data when activating the panel - self._load_existing_tags() - self._load_photos() - self._switch_view_mode(self.view_mode_var.get()) - # Rebind mousewheel scrolling when activated - self._bind_mousewheel_scrolling() - - def deactivate(self): - """Deactivate the panel""" - if self.is_active: - self._cleanup() - self.is_active = False - - def _cleanup(self): - """Clean up resources""" - # Unbind mousewheel scrolling - self._unbind_mousewheel_scrolling() - - # Clean up temp crops - for crop in list(self.temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - self.temp_crops.clear() - - # Clear photo images - self.photo_images.clear() - - # Clear state - self.pending_tag_changes.clear() - self.pending_tag_removals.clear() - self.pending_tag_linkage_type.clear() - self.folder_states.clear() - - def update_layout(self): - """Update panel layout for responsiveness""" - if hasattr(self, 'components') and 'content_canvas' in self.components: - # Update content canvas scroll region - canvas = self.components['content_canvas'] - canvas.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) diff --git a/archive/desktop/photo_tagger.py b/archive/desktop/photo_tagger.py deleted file mode 100644 index c676873..0000000 --- a/archive/desktop/photo_tagger.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -""" -PunimTag CLI - Minimal Photo Face Tagger (Refactored) -Simple command-line tool for face recognition and photo tagging -""" - -import os -import sys -import warnings -import argparse -import threading -from typing import List, Dict, Tuple, Optional - -# Suppress TensorFlow warnings (must be before DeepFace import) -os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' -warnings.filterwarnings('ignore') - -# Import our new modules -from src.core.config import ( - DEFAULT_DB_PATH, DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE, - DEFAULT_BATCH_SIZE, DEFAULT_PROCESSING_LIMIT -) -from src.core.database import DatabaseManager -from src.core.face_processing import FaceProcessor -from src.core.photo_management import PhotoManager -from src.core.tag_management import TagManager -from src.core.search_stats import SearchStats -from src.gui.gui_core import GUICore -from src.gui.dashboard_gui import DashboardGUI - - -class PhotoTagger: - """Main PhotoTagger class - orchestrates all functionality""" - - def __init__(self, db_path: str = DEFAULT_DB_PATH, verbose: int = 0, debug: bool = False): - """Initialize the photo tagger with database and all managers""" - self.db_path = db_path - self.verbose = verbose - self.debug = debug - - # Initialize all managers - self.db = DatabaseManager(db_path, verbose) - self.face_processor = FaceProcessor(self.db, verbose) - self.photo_manager = PhotoManager(self.db, verbose) - self.tag_manager = TagManager(self.db, verbose) - self.search_stats = SearchStats(self.db, verbose) - self.gui_core = GUICore() - self.identify_gui = IdentifyGUI(self.db, self.face_processor, verbose) - self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose) - self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose) - self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose) - self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, self.tag_manager, verbose) - self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify, search_stats=self.search_stats, tag_manager=self.tag_manager) - - # Legacy compatibility - expose some methods directly - self._db_connection = None - self._db_lock = threading.Lock() - - def cleanup(self): - """Clean up resources and close connections""" - self.face_processor.cleanup_face_crops() - self.db.close_db_connection() - - # Database methods (delegated) - def get_db_connection(self): - """Get database connection (legacy compatibility)""" - return self.db.get_db_connection() - - def close_db_connection(self): - """Close database connection (legacy compatibility)""" - self.db.close_db_connection() - - def init_database(self): - """Initialize database (legacy compatibility)""" - self.db.init_database() - - # Photo management methods (delegated) - def scan_folder(self, folder_path: str, recursive: bool = True) -> int: - """Scan folder for photos and add to database""" - return self.photo_manager.scan_folder(folder_path, recursive) - - def _extract_photo_date(self, photo_path: str) -> Optional[str]: - """Extract date taken from photo EXIF data (legacy compatibility)""" - return self.photo_manager.extract_photo_date(photo_path) - - # Face processing methods (delegated) - def process_faces(self, limit: Optional[int] = None, model: str = DEFAULT_FACE_DETECTION_MODEL, progress_callback=None, stop_event=None) -> int: - """Process unprocessed photos for faces with optional progress and cancellation - - Args: - limit: Maximum number of photos to process. If None, process all unprocessed photos. - """ - return self.face_processor.process_faces(limit, model, progress_callback, stop_event) - - def _extract_face_crop(self, photo_path: str, location: dict, face_id: int) -> str: - """Extract and save individual face crop for identification (legacy compatibility)""" - return self.face_processor._extract_face_crop(photo_path, location, face_id) - - def _create_comparison_image(self, unid_crop_path: str, match_crop_path: str, person_name: str, confidence: float) -> str: - """Create a side-by-side comparison image (legacy compatibility)""" - return self.face_processor._create_comparison_image(unid_crop_path, match_crop_path, person_name, confidence) - - def _calculate_face_quality_score(self, image, face_location: dict) -> float: - """Calculate face quality score (legacy compatibility)""" - return self.face_processor._calculate_face_quality_score(image, face_location) - - def _add_person_encoding(self, person_id: int, face_id: int, encoding, quality_score: float): - """Add a face encoding to a person's encoding collection (legacy compatibility)""" - self.face_processor.add_person_encoding(person_id, face_id, encoding, quality_score) - - def _get_person_encodings(self, person_id: int, min_quality: float = 0.3): - """Get all high-quality encodings for a person (legacy compatibility)""" - return self.face_processor.get_person_encodings(person_id, min_quality) - - def _update_person_encodings(self, person_id: int): - """Update person encodings when a face is identified (legacy compatibility)""" - self.face_processor.update_person_encodings(person_id) - - def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float: - """Calculate adaptive tolerance (legacy compatibility)""" - return self.face_processor._calculate_adaptive_tolerance(base_tolerance, face_quality, match_confidence) - - def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None): - """Get similar faces with filtering (legacy compatibility)""" - return self.face_processor._get_filtered_similar_faces(face_id, tolerance, include_same_photo, face_status) - - def _filter_unique_faces(self, faces: List[Dict]): - """Filter faces to show only unique ones (legacy compatibility)""" - return self.face_processor._filter_unique_faces(faces) - - def _filter_unique_faces_from_list(self, faces_list: List[tuple]): - """Filter face list to show only unique ones (legacy compatibility)""" - return self.face_processor._filter_unique_faces_from_list(faces_list) - - def find_similar_faces(self, face_id: int = None, tolerance: float = DEFAULT_FACE_TOLERANCE, include_same_photo: bool = False): - """Find similar faces across all photos""" - return self.face_processor.find_similar_faces(face_id, tolerance, include_same_photo) - - def auto_identify_matches(self, tolerance: float = DEFAULT_FACE_TOLERANCE, confirm: bool = True, show_faces: bool = False, include_same_photo: bool = False) -> int: - """Automatically identify faces that match already identified faces using GUI""" - return self.auto_match_gui.auto_identify_matches(tolerance, confirm, show_faces, include_same_photo) - - # Tag management methods (delegated) - def add_tags(self, photo_pattern: str = None, batch_size: int = DEFAULT_BATCH_SIZE) -> int: - """Add custom tags to photos""" - return self.tag_manager.add_tags_to_photos(photo_pattern, batch_size) - - def _deduplicate_tags(self, tag_list): - """Remove duplicate tags from a list (legacy compatibility)""" - return self.tag_manager.deduplicate_tags(tag_list) - - def _parse_tags_string(self, tags_string): - """Parse a comma-separated tags string (legacy compatibility)""" - return self.tag_manager.parse_tags_string(tags_string) - - def _get_tag_id_by_name(self, tag_name, tag_name_to_id_map): - """Get tag ID by name (legacy compatibility)""" - return self.db.get_tag_id_by_name(tag_name, tag_name_to_id_map) - - def _get_tag_name_by_id(self, tag_id, tag_id_to_name_map): - """Get tag name by ID (legacy compatibility)""" - return self.db.get_tag_name_by_id(tag_id, tag_id_to_name_map) - - def _load_tag_mappings(self): - """Load tag name to ID and ID to name mappings (legacy compatibility)""" - return self.db.load_tag_mappings() - - def _get_existing_tag_ids_for_photo(self, photo_id): - """Get list of tag IDs for a photo (legacy compatibility)""" - return self.db.get_existing_tag_ids_for_photo(photo_id) - - def _show_people_list(self, cursor=None): - """Show list of people in database (legacy compatibility)""" - return self.db.show_people_list(cursor) - - # Search and statistics methods (delegated) - def search_faces(self, person_name: str): - """Search for photos containing a specific person""" - return self.search_stats.search_faces(person_name) - - def stats(self): - """Show database statistics""" - return self.search_stats.print_statistics() - - # GUI methods (legacy compatibility - these would need to be implemented) - def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, tolerance: float = DEFAULT_FACE_TOLERANCE, - date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int: - """Interactive face identification with GUI (show_faces is always True)""" - return self.identify_gui.identify_faces(batch_size, True, tolerance, - date_from, date_to, date_processed_from, date_processed_to) - - def tag_management(self) -> int: - """Tag management GUI""" - return self.tag_manager_gui.tag_management() - - def modifyidentified(self) -> int: - return self.modify_identified_gui.modifyidentified() - - def searchgui(self) -> int: - """Open the Search GUI.""" - return self.search_gui.search_gui() - - def dashboard(self) -> int: - """Open the Dashboard GUI (placeholders only).""" - return self.dashboard_gui.open() - - # Dashboard callbacks - def _dashboard_scan(self, folder_path: str, recursive: bool) -> int: - """Callback to scan a folder from the dashboard.""" - return self.scan_folder(folder_path, recursive) - - def _dashboard_process(self, limit_value: Optional[int], progress_callback=None, stop_event=None) -> int: - """Callback to process faces from the dashboard with optional limit, progress, cancel.""" - if limit_value is None: - return self.process_faces(progress_callback=progress_callback, stop_event=stop_event) - return self.process_faces(limit=limit_value, progress_callback=progress_callback, stop_event=stop_event) - - def _dashboard_identify(self, batch_value: Optional[int]) -> int: - """Callback to identify faces from the dashboard with optional batch (show_faces is always True).""" - if batch_value is None: - return self.identify_faces() - return self.identify_faces(batch_size=batch_value) - - def _setup_window_size_saving(self, root, config_file="gui_config.json"): - """Set up window size saving functionality (legacy compatibility)""" - return self.gui_core.setup_window_size_saving(root, config_file) - - def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None): - """Display similar faces in panel (legacy compatibility)""" - print("āš ļø Similar faces panel not yet implemented in refactored version") - return None - - def _create_photo_icon(self, canvas, photo_path, icon_size=20, icon_x=None, icon_y=None, callback=None): - """Create a small photo icon on a canvas (legacy compatibility)""" - return self.gui_core.create_photo_icon(canvas, photo_path, icon_size, icon_x, icon_y, callback) - - def _get_confidence_description(self, confidence_pct: float) -> str: - """Get human-readable confidence description (legacy compatibility)""" - return self.face_processor._get_confidence_description(confidence_pct) - - # Cache management (legacy compatibility) - def _clear_caches(self): - """Clear all caches to free memory (legacy compatibility)""" - self.face_processor._clear_caches() - - def _cleanup_face_crops(self, current_face_crop_path=None): - """Clean up face crop files and caches (legacy compatibility)""" - self.face_processor.cleanup_face_crops(current_face_crop_path) - - @property - def _face_encoding_cache(self): - """Face encoding cache (legacy compatibility)""" - return self.face_processor._face_encoding_cache - - @property - def _image_cache(self): - """Image cache (legacy compatibility)""" - return self.face_processor._image_cache - - def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None) -> List[Dict]: - """Get similar faces with consistent filtering and sorting logic used by both auto-match and identify""" - # Find similar faces using the core function - similar_faces_data = self.find_similar_faces(face_id, tolerance=tolerance, include_same_photo=include_same_photo) - - # Filter to only show unidentified faces with confidence filtering - filtered_faces = [] - for face in similar_faces_data: - # For auto-match: only filter by database state (keep existing behavior) - # For identify: also filter by current session state - is_identified_in_db = face.get('person_id') is not None - is_identified_in_session = face_status and face.get('face_id') in face_status and face_status[face.get('face_id')] == 'identified' - - # If face_status is provided (identify mode), use both filters - # If face_status is None (auto-match mode), only use database filter - if face_status is not None: - # Identify mode: filter out both database and session identified faces - if not is_identified_in_db and not is_identified_in_session: - # Calculate confidence percentage - confidence_pct, _ = self.face_processor._get_calibrated_confidence(face['distance']) - - # Only include matches with reasonable confidence (at least 40%) - if confidence_pct >= 40: - filtered_faces.append(face) - else: - # Auto-match mode: only filter by database state (keep existing behavior) - if not is_identified_in_db: - # Calculate confidence percentage - confidence_pct, _ = self.face_processor._get_calibrated_confidence(face['distance']) - - # Only include matches with reasonable confidence (at least 40%) - if confidence_pct >= 40: - filtered_faces.append(face) - - # Sort by confidence (distance) - highest confidence first - filtered_faces.sort(key=lambda x: x['distance']) - - return filtered_faces - - -def main(): - """Main CLI interface""" - # Suppress TensorFlow and other deprecation warnings from DeepFace dependencies - import warnings - warnings.filterwarnings("ignore", message="pkg_resources is deprecated", category=UserWarning) - - parser = argparse.ArgumentParser( - description="PunimTag CLI - Simple photo face tagger (Refactored)", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - photo_tagger_refactored.py scan /path/to/photos # Scan folder for photos - photo_tagger_refactored.py process --limit 20 # Process 20 photos for faces - photo_tagger_refactored.py identify --batch 10 # Identify 10 faces interactively - photo_tagger_refactored.py auto-match # Auto-identify matching faces - photo_tagger_refactored.py modifyidentified # Show and Modify identified faces - photo_tagger_refactored.py match 15 # Find faces similar to face ID 15 - photo_tagger_refactored.py tag --pattern "vacation" # Tag photos matching pattern - photo_tagger_refactored.py search "John" # Find photos with John - photo_tagger_refactored.py tag-manager # Open tag management GUI - photo_tagger_refactored.py stats # Show statistics - """ - ) - - parser.add_argument('command', - choices=['scan', 'process', 'identify', 'tag', 'search', 'search-gui', 'dashboard', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'], - help='Command to execute') - - parser.add_argument('target', nargs='?', - help='Target folder (scan), person name (search), or pattern (tag)') - - parser.add_argument('--db', default=DEFAULT_DB_PATH, - help=f'Database file path (default: {DEFAULT_DB_PATH})') - - parser.add_argument('--limit', type=int, default=DEFAULT_PROCESSING_LIMIT, - help=f'Batch size limit for processing (default: {DEFAULT_PROCESSING_LIMIT})') - - parser.add_argument('--batch', type=int, default=DEFAULT_BATCH_SIZE, - help=f'Batch size for identification (default: {DEFAULT_BATCH_SIZE})') - - parser.add_argument('--pattern', - help='Pattern for filtering photos when tagging') - - parser.add_argument('--model', choices=['hog', 'cnn'], default=DEFAULT_FACE_DETECTION_MODEL, - help=f'Face detection model: hog (faster) or cnn (more accurate) (default: {DEFAULT_FACE_DETECTION_MODEL})') - - parser.add_argument('--recursive', action='store_true', - help='Scan folders recursively') - - - parser.add_argument('--tolerance', type=float, default=DEFAULT_FACE_TOLERANCE, - help=f'Face matching tolerance (0.0-1.0, lower = stricter, default: {DEFAULT_FACE_TOLERANCE})') - - parser.add_argument('--auto', action='store_true', - help='Auto-identify high-confidence matches without confirmation') - - parser.add_argument('--include-twins', action='store_true', - help='Include same-photo matching (for twins or multiple instances)') - - parser.add_argument('--date-from', - help='Filter by photo taken date (from) in YYYY-MM-DD format') - - parser.add_argument('--date-to', - help='Filter by photo taken date (to) in YYYY-MM-DD format') - - parser.add_argument('--date-processed-from', - help='Filter by photo processed date (from) in YYYY-MM-DD format') - - parser.add_argument('--date-processed-to', - help='Filter by photo processed date (to) in YYYY-MM-DD format') - - parser.add_argument('-v', '--verbose', action='count', default=0, - help='Increase verbosity (-v, -vv, -vvv for more detail)') - - parser.add_argument('--debug', action='store_true', - help='Enable line-by-line debugging with pdb') - - args = parser.parse_args() - - # Initialize tagger - tagger = PhotoTagger(args.db, args.verbose, args.debug) - - try: - if args.command == 'scan': - if not args.target: - print("āŒ Please specify a folder to scan") - return 1 - - # Normalize path to absolute path - from path_utils import normalize_path - try: - normalized_path = normalize_path(args.target) - print(f"šŸ“ Scanning folder: {normalized_path}") - tagger.scan_folder(normalized_path, args.recursive) - except ValueError as e: - print(f"āŒ Invalid path: {e}") - return 1 - - elif args.command == 'process': - tagger.process_faces(args.limit, args.model) - - elif args.command == 'identify': - tagger.identify_faces(args.batch, args.tolerance, - args.date_from, args.date_to, - args.date_processed_from, args.date_processed_to) - - elif args.command == 'tag': - tagger.add_tags(args.pattern or args.target, args.batch) - - elif args.command == 'search': - if not args.target: - print("āŒ Please specify a person name to search for") - return 1 - tagger.search_faces(args.target) - - elif args.command == 'search-gui': - tagger.searchgui() - - elif args.command == 'dashboard': - tagger.dashboard() - - elif args.command == 'stats': - tagger.stats() - - elif args.command == 'match': - if args.target and args.target.isdigit(): - face_id = int(args.target) - matches = tagger.find_similar_faces(face_id, args.tolerance) - if matches: - print(f"\nšŸŽÆ Found {len(matches)} similar faces:") - for match in matches: - person_name = "Unknown" if match.get('person_id') is None else f"Person ID {match.get('person_id')}" - print(f" šŸ“ø {match.get('filename', 'Unknown')} - {person_name} (confidence: {(1-match.get('distance', 1)):.1%})") - else: - print("šŸ” No similar faces found") - else: - print("āŒ Please specify a face ID number to find matches for") - - elif args.command == 'auto-match': - show_faces = getattr(args, 'show_faces', False) - include_twins = getattr(args, 'include_twins', False) - tagger.auto_identify_matches(args.tolerance, not args.auto, show_faces, include_twins) - - elif args.command == 'modifyidentified': - tagger.modifyidentified() - - elif args.command == 'tag-manager': - tagger.tag_management() - - return 0 - - except KeyboardInterrupt: - print("\n\nāš ļø Interrupted by user") - return 1 - except Exception as e: - print(f"āŒ Error: {e}") - if args.debug: - import traceback - traceback.print_exc() - return 1 - finally: - # Always cleanup resources - tagger.cleanup() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/archive/desktop/run_dashboard.py b/archive/desktop/run_dashboard.py deleted file mode 100755 index 44a20ce..0000000 --- a/archive/desktop/run_dashboard.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -""" -Launcher script for PunimTag Dashboard -Adds project root to Python path and launches the dashboard -""" - -import os -import sys -import warnings -from pathlib import Path - -# Suppress TensorFlow warnings (must be before DeepFace import) -os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' -warnings.filterwarnings('ignore') - -# Add project root to Python path -project_root = Path(__file__).parent -sys.path.insert(0, str(project_root)) - -# Now import required modules -from src.gui.dashboard_gui import DashboardGUI -from src.gui.gui_core import GUICore -from src.core.database import DatabaseManager -from src.core.face_processing import FaceProcessor -from src.core.photo_management import PhotoManager -from src.core.tag_management import TagManager -from src.core.search_stats import SearchStats -from src.core.config import DEFAULT_DB_PATH - -if __name__ == "__main__": - # Initialize all required components - gui_core = GUICore() - db_manager = DatabaseManager(DEFAULT_DB_PATH, verbose=0) - # Initialize face_processor without detector/model (will be updated by GUI) - face_processor = FaceProcessor(db_manager, verbose=0) - photo_manager = PhotoManager(db_manager, verbose=0) - tag_manager = TagManager(db_manager, verbose=0) - search_stats = SearchStats(db_manager) - - # Define callback functions for scan and process operations - def on_scan(folder, recursive): - """Callback for scanning photos""" - return photo_manager.scan_folder(folder, recursive) - - def on_process(limit=None, stop_event=None, progress_callback=None, - detector_backend=None, model_name=None): - """Callback for processing faces with DeepFace settings""" - # Update face_processor settings if provided - if detector_backend: - face_processor.detector_backend = detector_backend - if model_name: - face_processor.model_name = model_name - - return face_processor.process_faces( - limit=limit, # Pass None if no limit is specified - stop_event=stop_event, - progress_callback=progress_callback - ) - - # Create and run dashboard - app = DashboardGUI( - gui_core=gui_core, - db_manager=db_manager, - face_processor=face_processor, - on_scan=on_scan, - on_process=on_process, - search_stats=search_stats, - tag_manager=tag_manager - ) - app.open() - diff --git a/archive/desktop/run_deepface_gui.sh b/archive/desktop/run_deepface_gui.sh deleted file mode 100755 index 98bc60c..0000000 --- a/archive/desktop/run_deepface_gui.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# Run DeepFace GUI Test Application - -cd "$(dirname "$0")" - -# Activate virtual environment if it exists -if [ -d "venv" ]; then - source venv/bin/activate -fi - -# Run the GUI application -python test_deepface_gui.py diff --git a/archive/desktop/tests/show_large_thumbnails.py b/archive/desktop/tests/show_large_thumbnails.py deleted file mode 100644 index 449cb14..0000000 --- a/archive/desktop/tests/show_large_thumbnails.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -""" -Show large thumbnails directly from the test images -""" - -import tkinter as tk -from tkinter import ttk -from PIL import Image, ImageTk -import os -from pathlib import Path - -# Suppress TensorFlow warnings -os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' - -def show_large_thumbnails(): - root = tk.Tk() - root.title("Large Thumbnails Demo") - root.geometry("1600x1000") - - frame = ttk.Frame(root, padding="20") - frame.pack(fill=tk.BOTH, expand=True) - - # Get test images - test_folder = Path("demo_photos/testdeepface/") - if not test_folder.exists(): - ttk.Label(frame, text="Test folder not found: demo_photos/testdeepface/", - font=("Arial", 16, "bold"), foreground="red").pack(pady=20) - root.mainloop() - return - - image_files = list(test_folder.glob("*.jpg")) - - if not image_files: - ttk.Label(frame, text="No images found in test folder", - font=("Arial", 16, "bold"), foreground="red").pack(pady=20) - root.mainloop() - return - - # Show first few images with large thumbnails - ttk.Label(frame, text="Large Thumbnails Demo (400x400 pixels)", - font=("Arial", 18, "bold")).pack(pady=10) - - for i, img_path in enumerate(image_files[:4]): # Show first 4 images - try: - # Load and resize image - image = Image.open(img_path) - image.thumbnail((400, 400), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(image) - - # Create frame for this image - img_frame = ttk.Frame(frame) - img_frame.pack(pady=10) - - # Image label - img_label = ttk.Label(img_frame, image=photo) - img_label.image = photo # Keep a reference - img_label.pack() - - # Text label - text_label = ttk.Label(img_frame, text=f"{img_path.name} (400x400)", - font=("Arial", 12, "bold")) - text_label.pack() - - except Exception as e: - ttk.Label(frame, text=f"Error loading {img_path.name}: {e}", - font=("Arial", 12), foreground="red").pack() - - # Add instruction - instruction = ttk.Label(frame, text="These are 400x400 pixel thumbnails - the same size the GUI will use!", - font=("Arial", 14, "bold"), foreground="green") - instruction.pack(pady=20) - - root.mainloop() - -if __name__ == "__main__": - show_large_thumbnails() diff --git a/archive/desktop/tests/test_deepface_gui.py b/archive/desktop/tests/test_deepface_gui.py deleted file mode 100644 index 0ae2257..0000000 --- a/archive/desktop/tests/test_deepface_gui.py +++ /dev/null @@ -1,724 +0,0 @@ -#!/usr/bin/env python3 -""" -DeepFace GUI Test Application - -GUI version of test_deepface_only.py that shows face comparison results -with left panel for reference faces and right panel for comparison faces with confidence scores. -""" - -import os -import sys -import time -import tkinter as tk -from tkinter import ttk, messagebox, filedialog -from pathlib import Path -from typing import List, Dict, Tuple, Optional -import numpy as np -from PIL import Image, ImageTk - -# Suppress TensorFlow warnings and CUDA errors -os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' -import warnings -warnings.filterwarnings('ignore') - -# DeepFace library -from deepface import DeepFace - -# Face recognition library -import face_recognition - -# Supported image formats -SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'} - - -class FaceComparisonGUI: - """GUI application for DeepFace face comparison testing""" - - def __init__(self): - self.root = tk.Tk() - self.root.title("Face Comparison Test - DeepFace vs face_recognition") - self.root.geometry("2000x1000") - self.root.minsize(1200, 800) - - # Data storage - self.deepface_faces = [] # DeepFace faces from all images - self.facerec_faces = [] # face_recognition faces from all images - self.deepface_similarities = [] # DeepFace similarity results - self.facerec_similarities = [] # face_recognition similarity results - self.processing_times = {} # Timing information for each photo - - # GUI components - self.setup_gui() - - def setup_gui(self): - """Set up the GUI layout""" - # Main frame - main_frame = ttk.Frame(self.root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - self.root.columnconfigure(0, weight=1) - self.root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.rowconfigure(2, weight=1) # Make the content area expandable - - # Title - title_label = ttk.Label(main_frame, text="Face Comparison Test - DeepFace vs face_recognition", - font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, columnspan=3, pady=(0, 10)) - - # Control panel - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Folder selection - ttk.Label(control_frame, text="Test Folder:").grid(row=0, column=0, padx=(0, 5)) - self.folder_var = tk.StringVar(value="demo_photos/testdeepface/") - folder_entry = ttk.Entry(control_frame, textvariable=self.folder_var, width=40) - folder_entry.grid(row=0, column=1, padx=(0, 5)) - - browse_btn = ttk.Button(control_frame, text="Browse", command=self.browse_folder) - browse_btn.grid(row=0, column=2, padx=(0, 10)) - - # Reference image selection - ttk.Label(control_frame, text="Reference Image:").grid(row=0, column=3, padx=(10, 5)) - self.reference_var = tk.StringVar(value="2019-11-22_0011.JPG") - reference_entry = ttk.Entry(control_frame, textvariable=self.reference_var, width=20) - reference_entry.grid(row=0, column=4, padx=(0, 5)) - - # Face detector selection - ttk.Label(control_frame, text="Detector:").grid(row=0, column=5, padx=(10, 5)) - self.detector_var = tk.StringVar(value="retinaface") - detector_combo = ttk.Combobox(control_frame, textvariable=self.detector_var, - values=["retinaface", "mtcnn", "opencv", "ssd"], - state="readonly", width=10) - detector_combo.grid(row=0, column=6, padx=(0, 5)) - - # Similarity threshold - ttk.Label(control_frame, text="Threshold:").grid(row=0, column=7, padx=(10, 5)) - self.threshold_var = tk.StringVar(value="60") - threshold_entry = ttk.Entry(control_frame, textvariable=self.threshold_var, width=8) - threshold_entry.grid(row=0, column=8, padx=(0, 5)) - - # Process button - process_btn = ttk.Button(control_frame, text="Process Images", - command=self.process_images, style="Accent.TButton") - process_btn.grid(row=0, column=9, padx=(10, 0)) - - # Progress bar - self.progress_var = tk.DoubleVar() - self.progress_bar = ttk.Progressbar(control_frame, variable=self.progress_var, - maximum=100, length=200) - self.progress_bar.grid(row=1, column=0, columnspan=10, sticky=(tk.W, tk.E), pady=(5, 0)) - - # Status label - self.status_var = tk.StringVar(value="Ready to process images") - status_label = ttk.Label(control_frame, textvariable=self.status_var) - status_label.grid(row=2, column=0, columnspan=10, pady=(5, 0)) - - # Main content area with three panels - content_frame = ttk.Frame(main_frame) - content_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(10, 0)) - content_frame.columnconfigure(0, weight=1) - content_frame.columnconfigure(1, weight=1) - content_frame.columnconfigure(2, weight=1) - content_frame.rowconfigure(0, weight=1) - - # Left panel - DeepFace results - left_frame = ttk.LabelFrame(content_frame, text="DeepFace Results", padding="5") - left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) - left_frame.columnconfigure(0, weight=1) - left_frame.rowconfigure(0, weight=1) - - # Left panel scrollable area - left_canvas = tk.Canvas(left_frame, bg="white") - left_scrollbar = ttk.Scrollbar(left_frame, orient="vertical", command=left_canvas.yview) - self.left_scrollable_frame = ttk.Frame(left_canvas) - - self.left_scrollable_frame.bind( - "", - lambda e: left_canvas.configure(scrollregion=left_canvas.bbox("all")) - ) - - left_canvas.create_window((0, 0), window=self.left_scrollable_frame, anchor="nw") - left_canvas.configure(yscrollcommand=left_scrollbar.set) - - left_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - left_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - - # Middle panel - face_recognition results - middle_frame = ttk.LabelFrame(content_frame, text="face_recognition Results", padding="5") - middle_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 5)) - middle_frame.columnconfigure(0, weight=1) - middle_frame.rowconfigure(0, weight=1) - - # Right panel - Comparison Results - right_frame = ttk.LabelFrame(content_frame, text="Comparison Results", padding="5") - right_frame.grid(row=0, column=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - right_frame.columnconfigure(0, weight=1) - right_frame.rowconfigure(0, weight=1) - - # Middle panel scrollable area - middle_canvas = tk.Canvas(middle_frame, bg="white") - middle_scrollbar = ttk.Scrollbar(middle_frame, orient="vertical", command=middle_canvas.yview) - self.middle_scrollable_frame = ttk.Frame(middle_canvas) - - self.middle_scrollable_frame.bind( - "", - lambda e: middle_canvas.configure(scrollregion=middle_canvas.bbox("all")) - ) - - middle_canvas.create_window((0, 0), window=self.middle_scrollable_frame, anchor="nw") - middle_canvas.configure(yscrollcommand=middle_scrollbar.set) - - middle_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - middle_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - - # Right panel scrollable area - right_canvas = tk.Canvas(right_frame, bg="white") - right_scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=right_canvas.yview) - self.right_scrollable_frame = ttk.Frame(right_canvas) - - self.right_scrollable_frame.bind( - "", - lambda e: right_canvas.configure(scrollregion=right_canvas.bbox("all")) - ) - - right_canvas.create_window((0, 0), window=self.right_scrollable_frame, anchor="nw") - right_canvas.configure(yscrollcommand=right_scrollbar.set) - - right_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - right_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - - # Bind mousewheel to all canvases - def _on_mousewheel(event): - left_canvas.yview_scroll(int(-1*(event.delta/120)), "units") - middle_canvas.yview_scroll(int(-1*(event.delta/120)), "units") - right_canvas.yview_scroll(int(-1*(event.delta/120)), "units") - - left_canvas.bind("", _on_mousewheel) - middle_canvas.bind("", _on_mousewheel) - right_canvas.bind("", _on_mousewheel) - - def browse_folder(self): - """Browse for folder containing test images""" - folder = filedialog.askdirectory(initialdir="demo_photos/") - if folder: - self.folder_var.set(folder) - - def update_status(self, message: str): - """Update status message""" - self.status_var.set(message) - self.root.update_idletasks() - - def update_progress(self, value: float): - """Update progress bar""" - self.progress_var.set(value) - self.root.update_idletasks() - - def get_image_files(self, folder_path: str) -> List[str]: - """Get all supported image files from folder""" - folder = Path(folder_path) - if not folder.exists(): - raise FileNotFoundError(f"Folder not found: {folder_path}") - - image_files = [] - for file_path in folder.rglob("*"): - if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_FORMATS: - image_files.append(str(file_path)) - - return sorted(image_files) - - def process_with_deepface(self, image_path: str, detector: str = "retinaface") -> Dict: - """Process image with DeepFace library""" - try: - # Use DeepFace.represent() to get proper face detection with regions - # Using selected detector for face detection - results = DeepFace.represent( - img_path=image_path, - model_name='ArcFace', # Best accuracy model - detector_backend=detector, # User-selected detector - enforce_detection=False, # Don't fail if no faces - align=True # Face alignment for better accuracy - ) - - if not results: - print(f"No faces found in {Path(image_path).name}") - return {'faces': [], 'encodings': []} - - print(f"Found {len(results)} faces in {Path(image_path).name}") - - # Convert to our format - faces = [] - encodings = [] - - for i, result in enumerate(results): - try: - # Extract face region info from DeepFace result - # DeepFace uses 'facial_area' instead of 'region' - facial_area = result.get('facial_area', {}) - face_confidence = result.get('face_confidence', 0.0) - - # Create face data with proper bounding box - face_data = { - 'image_path': image_path, - 'face_id': f"df_{Path(image_path).stem}_{i}", - 'location': (facial_area.get('y', 0), facial_area.get('x', 0) + facial_area.get('w', 0), - facial_area.get('y', 0) + facial_area.get('h', 0), facial_area.get('x', 0)), - 'bbox': facial_area, - 'encoding': np.array(result['embedding']), - 'confidence': face_confidence - } - faces.append(face_data) - encodings.append(np.array(result['embedding'])) - - print(f"Face {i}: facial_area={facial_area}, confidence={face_confidence:.2f}, embedding shape={np.array(result['embedding']).shape}") - - except Exception as e: - print(f"Error processing face {i}: {e}") - continue - - return { - 'faces': faces, - 'encodings': encodings - } - - except Exception as e: - print(f"DeepFace error on {image_path}: {e}") - return {'faces': [], 'encodings': []} - - def process_with_face_recognition(self, image_path: str) -> Dict: - """Process image with face_recognition library""" - try: - # Load image - image = face_recognition.load_image_file(image_path) - - # Find face locations - face_locations = face_recognition.face_locations(image, model="hog") # Use HOG model for speed - - if not face_locations: - print(f"No faces found in {Path(image_path).name} (face_recognition)") - return {'faces': [], 'encodings': []} - - print(f"Found {len(face_locations)} faces in {Path(image_path).name} (face_recognition)") - - # Get face encodings - face_encodings = face_recognition.face_encodings(image, face_locations) - - # Convert to our format - faces = [] - encodings = [] - - for i, (face_location, face_encoding) in enumerate(zip(face_locations, face_encodings)): - try: - # DeepFace returns {x, y, w, h} format - if isinstance(face_location, dict): - x = face_location.get('x', 0) - y = face_location.get('y', 0) - w = face_location.get('w', 0) - h = face_location.get('h', 0) - top, right, bottom, left = y, x + w, y + h, x - else: - # Legacy format - should not be used - top, right, bottom, left = face_location - - # Create face data with proper bounding box - face_data = { - 'image_path': image_path, - 'face_id': f"fr_{Path(image_path).stem}_{i}", - 'location': face_location, - 'bbox': {'x': left, 'y': top, 'w': right - left, 'h': bottom - top}, - 'encoding': np.array(face_encoding), - 'confidence': 1.0 # face_recognition doesn't provide confidence scores - } - faces.append(face_data) - encodings.append(np.array(face_encoding)) - - print(f"Face {i}: location={face_location}, encoding shape={np.array(face_encoding).shape}") - - except Exception as e: - print(f"Error processing face {i}: {e}") - continue - - return { - 'faces': faces, - 'encodings': encodings - } - - except Exception as e: - print(f"face_recognition error on {image_path}: {e}") - return {'faces': [], 'encodings': []} - - def extract_face_thumbnail(self, face_data: Dict, size: Tuple[int, int] = (150, 150)) -> ImageTk.PhotoImage: - """Extract face thumbnail from image""" - try: - # Load original image - image = Image.open(face_data['image_path']) - - # Extract face region - bbox = face_data['bbox'] - left = bbox.get('x', 0) - top = bbox.get('y', 0) - right = left + bbox.get('w', 0) - bottom = top + bbox.get('h', 0) - - # Add padding - padding = 20 - left = max(0, left - padding) - top = max(0, top - padding) - right = min(image.width, right + padding) - bottom = min(image.height, bottom + padding) - - # Crop face - face_crop = image.crop((left, top, right, bottom)) - - # FORCE resize to exact size (don't use thumbnail which maintains aspect ratio) - face_crop = face_crop.resize(size, Image.Resampling.LANCZOS) - - print(f"DEBUG: Created thumbnail of size {face_crop.size} for {face_data['face_id']}") - - # Convert to PhotoImage - return ImageTk.PhotoImage(face_crop) - - except Exception as e: - print(f"Error extracting thumbnail for {face_data['face_id']}: {e}") - # Return a placeholder image - placeholder = Image.new('RGB', size, color='lightgray') - return ImageTk.PhotoImage(placeholder) - - def calculate_face_similarity(self, encoding1: np.ndarray, encoding2: np.ndarray) -> float: - """Calculate similarity between two face encodings using cosine similarity""" - try: - # Ensure encodings are numpy arrays - enc1 = np.array(encoding1).flatten() - enc2 = np.array(encoding2).flatten() - - # Check if encodings have the same length - if len(enc1) != len(enc2): - print(f"Warning: Encoding length mismatch: {len(enc1)} vs {len(enc2)}") - return 0.0 - - # Normalize encodings - enc1_norm = enc1 / (np.linalg.norm(enc1) + 1e-8) # Add small epsilon to avoid division by zero - enc2_norm = enc2 / (np.linalg.norm(enc2) + 1e-8) - - # Calculate cosine similarity - cosine_sim = np.dot(enc1_norm, enc2_norm) - - # Clamp cosine similarity to valid range [-1, 1] - cosine_sim = np.clip(cosine_sim, -1.0, 1.0) - - # Convert to confidence percentage (0-100) - # For face recognition, we typically want values between 0-100% - # where higher values mean more similar faces - confidence = max(0, min(100, (cosine_sim + 1) * 50)) # Scale from [-1,1] to [0,100] - - return confidence - - except Exception as e: - print(f"Error calculating similarity: {e}") - return 0.0 - - def process_images(self): - """Process all images and perform face comparison""" - try: - # Clear previous results - self.deepface_faces = [] - self.facerec_faces = [] - self.deepface_similarities = [] - self.facerec_similarities = [] - self.processing_times = {} - - # Clear GUI panels - for widget in self.left_scrollable_frame.winfo_children(): - widget.destroy() - for widget in self.middle_scrollable_frame.winfo_children(): - widget.destroy() - for widget in self.right_scrollable_frame.winfo_children(): - widget.destroy() - - folder_path = self.folder_var.get() - threshold = float(self.threshold_var.get()) - - if not folder_path: - messagebox.showerror("Error", "Please specify folder path") - return - - self.update_status("Getting image files...") - self.update_progress(10) - - # Get all image files - image_files = self.get_image_files(folder_path) - if not image_files: - messagebox.showerror("Error", "No image files found in the specified folder") - return - - # Get selected detector - detector = self.detector_var.get() - - self.update_status(f"Processing all images with both DeepFace and face_recognition...") - self.update_progress(20) - - # Process all images with both libraries - for i, image_path in enumerate(image_files): - filename = Path(image_path).name - self.update_status(f"Processing {filename}...") - progress = 20 + (i / len(image_files)) * 50 - self.update_progress(progress) - - # Process with DeepFace - start_time = time.time() - deepface_result = self.process_with_deepface(image_path, detector) - deepface_time = time.time() - start_time - - # Process with face_recognition - start_time = time.time() - facerec_result = self.process_with_face_recognition(image_path) - facerec_time = time.time() - start_time - - # Store timing information - self.processing_times[filename] = { - 'deepface_time': deepface_time, - 'facerec_time': facerec_time, - 'total_time': deepface_time + facerec_time - } - - # Store results - self.deepface_faces.extend(deepface_result['faces']) - self.facerec_faces.extend(facerec_result['faces']) - - print(f"Processed {filename}: DeepFace={deepface_time:.2f}s, face_recognition={facerec_time:.2f}s") - - if not self.deepface_faces and not self.facerec_faces: - messagebox.showwarning("Warning", "No faces found in any images") - return - - self.update_status("Calculating face similarities...") - self.update_progress(75) - - # Calculate similarities for DeepFace - for i, face1 in enumerate(self.deepface_faces): - similarities = [] - for j, face2 in enumerate(self.deepface_faces): - if i != j: # Don't compare face with itself - confidence = self.calculate_face_similarity( - face1['encoding'], face2['encoding'] - ) - if confidence >= threshold: # Only include faces above threshold - similarities.append({ - 'face': face2, - 'confidence': confidence - }) - - # Sort by confidence (highest first) - similarities.sort(key=lambda x: x['confidence'], reverse=True) - self.deepface_similarities.append({ - 'face': face1, - 'similarities': similarities - }) - - # Calculate similarities for face_recognition - for i, face1 in enumerate(self.facerec_faces): - similarities = [] - for j, face2 in enumerate(self.facerec_faces): - if i != j: # Don't compare face with itself - confidence = self.calculate_face_similarity( - face1['encoding'], face2['encoding'] - ) - if confidence >= threshold: # Only include faces above threshold - similarities.append({ - 'face': face2, - 'confidence': confidence - }) - - # Sort by confidence (highest first) - similarities.sort(key=lambda x: x['confidence'], reverse=True) - self.facerec_similarities.append({ - 'face': face1, - 'similarities': similarities - }) - - self.update_status("Displaying results...") - self.update_progress(95) - - # Display results in GUI - self.display_results() - - total_deepface_faces = len(self.deepface_faces) - total_facerec_faces = len(self.facerec_faces) - avg_deepface_time = sum(t['deepface_time'] for t in self.processing_times.values()) / len(self.processing_times) - avg_facerec_time = sum(t['facerec_time'] for t in self.processing_times.values()) / len(self.processing_times) - - self.update_status(f"Complete! DeepFace: {total_deepface_faces} faces ({avg_deepface_time:.2f}s avg), face_recognition: {total_facerec_faces} faces ({avg_facerec_time:.2f}s avg)") - self.update_progress(100) - - except Exception as e: - messagebox.showerror("Error", f"Processing failed: {str(e)}") - self.update_status("Error occurred during processing") - print(f"Error: {e}") - import traceback - traceback.print_exc() - - def display_results(self): - """Display the face comparison results in the GUI panels""" - # Display DeepFace results in left panel - self.display_library_results(self.deepface_similarities, self.left_scrollable_frame, "DeepFace") - - # Display face_recognition results in middle panel - self.display_library_results(self.facerec_similarities, self.middle_scrollable_frame, "face_recognition") - - # Display timing comparison in right panel - self.display_timing_comparison() - - def display_library_results(self, similarities_list: List[Dict], parent_frame, library_name: str): - """Display results for a specific library""" - for i, result in enumerate(similarities_list): - face = result['face'] - - # Create frame for this face - face_frame = ttk.Frame(parent_frame) - face_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5, padx=5) - - # Face thumbnail - thumbnail = self.extract_face_thumbnail(face, size=(80, 80)) - thumbnail_label = ttk.Label(face_frame, image=thumbnail) - thumbnail_label.image = thumbnail # Keep a reference - thumbnail_label.grid(row=0, column=0, padx=5, pady=5) - - # Face info - info_frame = ttk.Frame(face_frame) - info_frame.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) - - ttk.Label(info_frame, text=f"Face {i+1}", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky=tk.W, pady=1) - ttk.Label(info_frame, text=f"ID: {face['face_id']}", font=("Arial", 8)).grid(row=1, column=0, sticky=tk.W, pady=1) - ttk.Label(info_frame, text=f"Image: {Path(face['image_path']).name}", font=("Arial", 8)).grid(row=2, column=0, sticky=tk.W, pady=1) - - # Show number of similar faces - similar_count = len(result['similarities']) - ttk.Label(info_frame, text=f"Similar: {similar_count}", font=("Arial", 8, "bold")).grid(row=3, column=0, sticky=tk.W, pady=1) - - def display_timing_comparison(self): - """Display timing comparison between libraries""" - if not self.processing_times: - return - - # Create summary frame - summary_frame = ttk.LabelFrame(self.right_scrollable_frame, text="Processing Times Summary") - summary_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5, padx=5) - - # Calculate averages - total_deepface_time = sum(t['deepface_time'] for t in self.processing_times.values()) - total_facerec_time = sum(t['facerec_time'] for t in self.processing_times.values()) - avg_deepface_time = total_deepface_time / len(self.processing_times) - avg_facerec_time = total_facerec_time / len(self.processing_times) - - # Summary statistics - ttk.Label(summary_frame, text=f"Total Images: {len(self.processing_times)}", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky=tk.W, pady=2) - ttk.Label(summary_frame, text=f"DeepFace Avg: {avg_deepface_time:.2f}s", font=("Arial", 9)).grid(row=1, column=0, sticky=tk.W, pady=1) - ttk.Label(summary_frame, text=f"face_recognition Avg: {avg_facerec_time:.2f}s", font=("Arial", 9)).grid(row=2, column=0, sticky=tk.W, pady=1) - - speed_ratio = avg_deepface_time / avg_facerec_time if avg_facerec_time > 0 else 0 - if speed_ratio > 1: - faster_lib = "face_recognition" - speed_text = f"{speed_ratio:.1f}x faster" - else: - faster_lib = "DeepFace" - speed_text = f"{1/speed_ratio:.1f}x faster" - - ttk.Label(summary_frame, text=f"{faster_lib} is {speed_text}", font=("Arial", 9, "bold"), foreground="green").grid(row=3, column=0, sticky=tk.W, pady=2) - - # Individual photo timings - timing_frame = ttk.LabelFrame(self.right_scrollable_frame, text="Per-Photo Timing") - timing_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=5, padx=5) - - row = 0 - for filename, times in sorted(self.processing_times.items()): - ttk.Label(timing_frame, text=f"{filename[:20]}...", font=("Arial", 8)).grid(row=row, column=0, sticky=tk.W, pady=1) - ttk.Label(timing_frame, text=f"DF: {times['deepface_time']:.2f}s", font=("Arial", 8)).grid(row=row, column=1, sticky=tk.W, pady=1, padx=(5,0)) - ttk.Label(timing_frame, text=f"FR: {times['facerec_time']:.2f}s", font=("Arial", 8)).grid(row=row, column=2, sticky=tk.W, pady=1, padx=(5,0)) - row += 1 - - def display_comparison_faces(self, ref_index: int, similarities: List[Dict]): - """Display comparison faces for a specific reference face""" - # Create frame for this reference face's comparisons - comp_frame = ttk.LabelFrame(self.right_scrollable_frame, - text=f"Matches for Reference Face {ref_index + 1}") - comp_frame.grid(row=ref_index, column=0, sticky=(tk.W, tk.E), pady=10, padx=10) - - # Display top matches (limit to avoid too much clutter) - max_matches = min(8, len(similarities)) - - for i in range(max_matches): - sim_data = similarities[i] - face = sim_data['face'] - confidence = sim_data['confidence'] - - # Create frame for this comparison face - face_frame = ttk.Frame(comp_frame) - face_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5, padx=10) - - # Face thumbnail - thumbnail = self.extract_face_thumbnail(face, size=(120, 120)) - thumbnail_label = ttk.Label(face_frame, image=thumbnail) - thumbnail_label.image = thumbnail # Keep a reference - thumbnail_label.grid(row=0, column=0, padx=10, pady=5) - - # Face info with confidence - info_frame = ttk.Frame(face_frame) - info_frame.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=10) - - # Confidence with color coding - confidence_text = f"{confidence:.1f}%" - if confidence >= 80: - confidence_color = "green" - elif confidence >= 60: - confidence_color = "orange" - else: - confidence_color = "red" - - ttk.Label(info_frame, text=confidence_text, - font=("Arial", 14, "bold"), foreground=confidence_color).grid(row=0, column=0, sticky=tk.W, pady=2) - ttk.Label(info_frame, text=f"ID: {face['face_id']}", font=("Arial", 10)).grid(row=1, column=0, sticky=tk.W, pady=2) - ttk.Label(info_frame, text=f"Image: {Path(face['image_path']).name}", font=("Arial", 10)).grid(row=2, column=0, sticky=tk.W, pady=2) - - def run(self): - """Start the GUI application""" - self.root.mainloop() - - -def main(): - """Main entry point""" - # Check dependencies - try: - from deepface import DeepFace - except ImportError as e: - print(f"Error: Missing required dependency: {e}") - print("Please install with: pip install deepface") - sys.exit(1) - - try: - import face_recognition - except ImportError as e: - print(f"Error: Missing required dependency: {e}") - print("Please install with: pip install face_recognition") - sys.exit(1) - - # Suppress TensorFlow warnings and errors - import os - os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' # Suppress TensorFlow warnings - import warnings - warnings.filterwarnings('ignore') - - try: - # Create and run GUI - app = FaceComparisonGUI() - app.run() - except Exception as e: - print(f"GUI Error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/archive/desktop/tests/test_simple_gui.py b/archive/desktop/tests/test_simple_gui.py deleted file mode 100644 index bf53e62..0000000 --- a/archive/desktop/tests/test_simple_gui.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple test to verify thumbnail sizes work -""" - -import tkinter as tk -from tkinter import ttk -from PIL import Image, ImageTk -import os - -# Suppress TensorFlow warnings -os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' - -def test_thumbnails(): - root = tk.Tk() - root.title("Thumbnail Size Test") - root.geometry("1000x600") - - frame = ttk.Frame(root, padding="20") - frame.pack(fill=tk.BOTH, expand=True) - - # Create test images with different sizes - sizes = [ - (100, 100, "Small (100x100)"), - (200, 200, "Medium (200x200)"), - (300, 300, "Large (300x300)"), - (400, 400, "HUGE (400x400)") - ] - - for i, (width, height, label) in enumerate(sizes): - # Create a colored rectangle - test_image = Image.new('RGB', (width, height), color='red') - photo = ImageTk.PhotoImage(test_image) - - # Create label with image - img_label = ttk.Label(frame, image=photo) - img_label.image = photo # Keep a reference - img_label.grid(row=0, column=i, padx=10, pady=10) - - # Create label with text - text_label = ttk.Label(frame, text=label, font=("Arial", 12, "bold")) - text_label.grid(row=1, column=i, padx=10) - - # Add instruction - instruction = ttk.Label(frame, text="This shows the difference in thumbnail sizes. The GUI will use 400x400 and 350x350 pixels!", - font=("Arial", 14, "bold"), foreground="blue") - instruction.grid(row=2, column=0, columnspan=4, pady=20) - - # Add button to test DeepFace GUI - def open_deepface_gui(): - root.destroy() - import subprocess - subprocess.Popen(['python', 'test_deepface_gui.py']) - - test_btn = ttk.Button(frame, text="Open DeepFace GUI", command=open_deepface_gui) - test_btn.grid(row=3, column=0, columnspan=4, pady=20) - - root.mainloop() - -if __name__ == "__main__": - test_thumbnails() diff --git a/archive/desktop/tests/test_thumbnail_sizes.py b/archive/desktop/tests/test_thumbnail_sizes.py deleted file mode 100644 index ed0e274..0000000 --- a/archive/desktop/tests/test_thumbnail_sizes.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to show thumbnail size differences -""" - -from PIL import Image, ImageTk -import tkinter as tk -from tkinter import ttk - -def create_test_thumbnails(): - """Create test thumbnails to show size differences""" - root = tk.Tk() - root.title("Thumbnail Size Test") - root.geometry("800x600") - - # Create a test image (colored rectangle) - test_image = Image.new('RGB', (100, 100), color='red') - - # Create different sized thumbnails - sizes = [ - (100, 100, "Original (100x100)"), - (200, 200, "Medium (200x200)"), - (300, 300, "Large (300x300)"), - (400, 400, "HUGE (400x400)") - ] - - frame = ttk.Frame(root, padding="20") - frame.pack(fill=tk.BOTH, expand=True) - - for i, (width, height, label) in enumerate(sizes): - # Resize the test image - resized = test_image.resize((width, height), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(resized) - - # Create label with image - img_label = ttk.Label(frame, image=photo) - img_label.image = photo # Keep a reference - img_label.grid(row=0, column=i, padx=10, pady=10) - - # Create label with text - text_label = ttk.Label(frame, text=label, font=("Arial", 12, "bold")) - text_label.grid(row=1, column=i, padx=10) - - # Add instruction - instruction = ttk.Label(frame, text="This shows the difference in thumbnail sizes. The GUI will use 400x400 and 350x350 pixels!", - font=("Arial", 14, "bold"), foreground="blue") - instruction.grid(row=2, column=0, columnspan=4, pady=20) - - root.mainloop() - -if __name__ == "__main__": - create_test_thumbnails() diff --git a/archive/identify_gui.py b/archive/identify_gui.py deleted file mode 100644 index 62149cb..0000000 --- a/archive/identify_gui.py +++ /dev/null @@ -1,2264 +0,0 @@ -#!/usr/bin/env python3 -""" -Face identification GUI implementation for PunimTag -""" - -import os -import time -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_BATCH_SIZE, DEFAULT_FACE_TOLERANCE -from database import DatabaseManager -from face_processing import FaceProcessor -from gui_core import GUICore - - -class IdentifyGUI: - """Handles the face identification GUI interface""" - - def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0): - """Initialize the identify GUI""" - self.db = db_manager - self.face_processor = face_processor - self.verbose = verbose - self.gui_core = GUICore() - - def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, show_faces: bool = False, - tolerance: float = DEFAULT_FACE_TOLERANCE, - date_from: str = None, date_to: str = None, - date_processed_from: str = None, date_processed_to: str = None) -> int: - """Interactive face identification with optimized performance""" - - # Get unidentified faces from database - unidentified = self._get_unidentified_faces(batch_size, date_from, date_to, - date_processed_from, date_processed_to) - - if not unidentified: - print("šŸŽ‰ All faces have been identified!") - return 0 - - print(f"\nšŸ‘¤ Found {len(unidentified)} unidentified faces") - print("Commands: [name] = identify, 's' = skip, 'q' = quit, 'list' = show people\n") - - # Pre-fetch all needed data to avoid repeated database queries - print("šŸ“Š Pre-fetching data for optimal performance...") - identify_data_cache = self._prefetch_identify_data(unidentified) - - print(f"āœ… Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names") - - identified_count = 0 - - # Create the main window - root = tk.Tk() - root.title("Face Identification") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - selected_person_id = None - force_exit = False - - # Track current face crop path for cleanup - current_face_crop_path = None - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal command, waiting_for_input, window_destroyed, current_face_crop_path, force_exit - - - # First check for selected similar faces without person name - if not self._validate_navigation(gui_components): - return # Cancel close - - # Check if there are pending identifications - pending_identifications = self._get_pending_identifications(face_person_names, face_status) - - if pending_identifications: - # Ask user if they want to save pending identifications - result = messagebox.askyesnocancel( - "Save Pending Identifications?", - f"You have {len(pending_identifications)} pending identifications.\n\n" - "Do you want to save them before closing?\n\n" - "• Yes: Save all pending identifications and close\n" - "• No: Close without saving\n" - "• Cancel: Return to identification" - ) - - if result is True: # Yes - Save and close - identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache) - # Continue to cleanup and close - elif result is False: # No - Close without saving - # Continue to cleanup and close - pass - else: # Cancel - Don't close - return # Exit without cleanup or closing - - # Only reach here if user chose Yes, No, or there are no pending identifications - # Clean up face crops and caches - self.face_processor.cleanup_face_crops(current_face_crop_path) - self.db.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Force process termination - force_exit = True - root.quit() - - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self.gui_core.setup_window_size_saving(root) - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) # Left panel - main_frame.columnconfigure(1, weight=1) # Right panel for similar faces - # Configure row weights to minimize spacing around Unique checkbox - main_frame.rowconfigure(2, weight=0) # Unique checkbox row - no expansion - main_frame.rowconfigure(3, weight=1) # Main panels row - expandable - - # Photo info - info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold")) - info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) - - - # Store face selection states per face ID to preserve selections during navigation - face_selection_states = {} # {face_id: {unique_key: bool}} - - # Store person names per face ID to preserve names during navigation - face_person_names = {} # {face_id: person_name} - - # Process each face with back navigation support - # Keep track of original face list and current position - original_faces = list(unidentified) # Make a copy of the original list - i = 0 - face_status = {} # Track which faces have been identified - - # Button commands - command = None - waiting_for_input = False - - # Define quit handler as local function (like in old version) - def on_quit(): - nonlocal command, waiting_for_input, window_destroyed, force_exit - - - # First check for unsaved changes in the form - validation_result = self._validate_navigation(gui_components) - if validation_result == 'cancel': - return # Cancel quit - elif validation_result == 'save_and_continue': - # Save the current identification before proceeding - # Add the current form data to pending identifications - current_face_key = list(face_person_names.keys())[0] if face_person_names else None - if current_face_key: - first_name = gui_components['first_name_var'].get().strip() - last_name = gui_components['last_name_var'].get().strip() - date_of_birth = gui_components['date_of_birth_var'].get().strip() - if first_name and last_name and date_of_birth: - face_person_names[current_face_key] = { - 'first_name': first_name, - 'last_name': last_name, - 'date_of_birth': date_of_birth - } - face_status[current_face_key] = 'identified' - elif validation_result == 'discard_and_continue': - # Clear the form but don't save - self._clear_form(gui_components) - - # Check if there are pending identifications (faces with complete data but not yet saved) - pending_identifications = self._get_pending_identifications(face_person_names, face_status) - - if pending_identifications: - # Temporarily disable window close handler to prevent interference - root.protocol("WM_DELETE_WINDOW", lambda: None) - - # Ask user if they want to save pending identifications - result = messagebox.askyesnocancel( - "Save Pending Identifications?", - f"You have {len(pending_identifications)} pending identifications.\n\n" - "Do you want to save them before quitting?\n\n" - "• Yes: Save all pending identifications and quit\n" - "• No: Quit without saving\n" - "• Cancel: Return to identification" - ) - - # Re-enable window close handler - root.protocol("WM_DELETE_WINDOW", on_closing) - - - if result is True: # Yes - Save and quit - identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache) - command = 'q' - waiting_for_input = False - elif result is False: # No - Quit without saving - command = 'q' - waiting_for_input = False - else: # Cancel - Don't quit - return # Exit without any cleanup or window destruction - else: - # No pending identifications, quit normally - command = 'q' - waiting_for_input = False - - # Only reach here if user chose Yes, No, or there are no pending identifications - # Clean up and close window - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Force process termination - force_exit = True - root.quit() - - # Create the GUI components with the quit handler - gui_components = self._create_gui_components(main_frame, identify_data_cache, - date_from, date_to, date_processed_from, date_processed_to, batch_size, on_quit) - - # Set up command variable for button callbacks - self._current_command_var = gui_components['command_var'] - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - - # Force window to render completely before proceeding - root.update_idletasks() - root.update() - - # Small delay to ensure canvas is properly rendered - time.sleep(0.1) - - # Schedule the first image update after the window is fully rendered - def update_first_image(): - try: - if i < len(original_faces): - face_id, photo_id, photo_path, filename, location = original_faces[i] - - # Extract face crop if enabled - face_crop_path = None - if show_faces: - face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) - - # Update the face image - self._update_face_image(gui_components, show_faces, face_crop_path, photo_path) - except Exception as e: - print(f"āŒ Error updating first image: {e}") - - # Schedule the update after a short delay - root.after(200, update_first_image) - - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Main processing loop - while not window_destroyed: - # Check if current face is identified and update index if needed - has_faces, i = self._update_current_face_index(original_faces, i, face_status) - if not has_faces: - # All faces have been identified - print("\nšŸŽ‰ All faces have been identified!") - break - - # Ensure we don't go beyond the bounds - if i >= len(original_faces): - # Stay on the last face instead of breaking - i = len(original_faces) - 1 - - face_id, photo_id, photo_path, filename, location = original_faces[i] - - # Check if this face was already identified in this session - is_already_identified = face_id in face_status and face_status[face_id] == 'identified' - - # Reset command and waiting state for each face - command = None - waiting_for_input = True - - # Update the display - current_pos, total_unidentified = self._get_current_face_position(original_faces, i, face_status) - print(f"\n--- Face {current_pos}/{total_unidentified} (unidentified) ---") - print(f"šŸ“ Photo: {filename}") - print(f"šŸ“ Face location: {location}") - - # Update title - root.title(f"Face Identification - {current_pos}/{total_unidentified} (unidentified)") - - # Update button states - self._update_button_states(gui_components, original_faces, i, face_status) - - # Update similar faces panel if compare is enabled - if gui_components['compare_var'].get(): - self._update_similar_faces(gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i) - - # Update photo info - if is_already_identified: - # Get the person name for this face - person_name = self._get_person_name_for_face(face_id) - info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified}) - āœ… Already identified as: {person_name}") - print(f"āœ… Already identified as: {person_name}") - else: - info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified})") - - # Extract face crop if enabled - face_crop_path = None - if show_faces: - face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) - if face_crop_path: - print(f"šŸ–¼ļø Face crop saved: {face_crop_path}") - current_face_crop_path = face_crop_path # Track for cleanup - else: - print("šŸ’” Use --show-faces flag to display individual face crops") - current_face_crop_path = None - - print(f"\nšŸ–¼ļø Viewing face {current_pos}/{total_unidentified} from {filename}") - - # Clear and update image - self._update_face_image(gui_components, show_faces, face_crop_path, photo_path) - - # Set person name input - restore saved name or use database/empty value - self._restore_person_name_input(gui_components, face_id, face_person_names, is_already_identified) - - # Keep compare checkbox state persistent across navigation - gui_components['first_name_entry'].focus_set() - gui_components['first_name_entry'].icursor(0) - - # Force GUI update before waiting for input - root.update_idletasks() - - # Wait for user input - while waiting_for_input: - try: - root.update() - # Check for command from GUI buttons - if gui_components['command_var'].get(): - command = gui_components['command_var'].get() - gui_components['command_var'].set("") # Clear the command - waiting_for_input = False - break - - # Check for unique checkbox changes - if hasattr(self, '_last_unique_state'): - current_unique_state = gui_components['unique_var'].get() - if current_unique_state != self._last_unique_state: - # Unique checkbox state changed, apply filtering - original_faces = self._on_unique_faces_change( - gui_components, original_faces, i, face_status, - date_from, date_to, date_processed_from, date_processed_to - ) - # Reset index to 0 when filtering changes - i = 0 - self._last_unique_state = current_unique_state - # Continue to next iteration to update display - continue - else: - # Initialize the last unique state - self._last_unique_state = gui_components['unique_var'].get() - - # Check for compare checkbox changes - if hasattr(self, '_last_compare_state'): - current_compare_state = gui_components['compare_var'].get() - if current_compare_state != self._last_compare_state: - # Compare checkbox state changed, update similar faces panel - self._on_compare_change( - gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i - ) - self._last_compare_state = current_compare_state - # Continue to next iteration to update display - continue - else: - # Initialize the last compare state - self._last_compare_state = gui_components['compare_var'].get() - - # Small delay to prevent excessive CPU usage - time.sleep(0.01) - except tk.TclError: - # Window was destroyed, break out of loop - break - - # Check if force exit was requested - if force_exit: - break - - # Check if force exit was requested (exit immediately) - if force_exit: - print("Force exit requested...") - # Clean up face crops and caches - self.face_processor.cleanup_face_crops(face_crop_path) - self.db.close_db_connection() - return identified_count - - # Process the command - if command is None: # User clicked Cancel - command = 'q' - else: - command = command.strip() - - if command.lower() == 'q': - # Clean up face crops and caches - self.face_processor.cleanup_face_crops(face_crop_path) - self.db.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - return identified_count - - elif command.lower() == 's': - print("āž”ļø Next") - - # Save current checkbox states before navigating away - self._save_current_face_selection_states(gui_components, original_faces, i, - face_selection_states, face_person_names) - - # Clean up current face crop when moving forward - if face_crop_path and os.path.exists(face_crop_path): - try: - os.remove(face_crop_path) - except: - pass # Ignore cleanup errors - current_face_crop_path = None # Clear tracked path - - # Find next unidentified face - next_found = False - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - next_found = True - break - - if not next_found: - print("āš ļø No more unidentified faces - Next button disabled") - continue - - # Clear date of birth field when moving to next face - gui_components['date_of_birth_var'].set("") - # Clear middle name and maiden name fields when moving to next face - gui_components['middle_name_var'].set("") - gui_components['maiden_name_var'].set("") - - self._update_button_states(gui_components, original_faces, i, face_status) - # Only update similar faces if compare is enabled - if gui_components['compare_var'].get(): - self._update_similar_faces(gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i) - continue - - elif command.lower() == 'back': - print("ā¬…ļø Going back to previous face") - - # Save current checkbox states before navigating away - self._save_current_face_selection_states(gui_components, original_faces, i, - face_selection_states, face_person_names) - - # Find previous unidentified face - prev_found = False - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - prev_found = True - break - - if not prev_found: - print("āš ļø No more unidentified faces - Back button disabled") - continue - - # Repopulate fields with saved data when going back - self._restore_person_name_input(gui_components, original_faces[i][0], face_person_names, False) - - self._update_button_states(gui_components, original_faces, i, face_status) - # Only update similar faces if compare is enabled - if gui_components['compare_var'].get(): - self._update_similar_faces(gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i) - continue - - elif command == 'reload_faces': - # Reload faces with new date filters - if 'filtered_faces' in gui_components: - # Update the original_faces list with filtered results - original_faces = list(gui_components['filtered_faces']) - - # Update the date filter variables - date_from = gui_components.get('new_date_from') - date_to = gui_components.get('new_date_to') - date_processed_from = gui_components.get('new_date_processed_from') - date_processed_to = gui_components.get('new_date_processed_to') - - # Reset to first face - i = 0 - - # Clear the filtered_faces data - del gui_components['filtered_faces'] - - print("šŸ’” Navigate to refresh the display with filtered faces") - continue - else: - print("āš ļø No filtered faces data found") - continue - - elif command.lower() == 'list': - self._show_people_list() - continue - - elif command == 'identify': - try: - # Get form data - form_data = self._get_form_data(gui_components) - - # Validate form data - is_valid, error_msg = self._validate_form_data(form_data) - if not is_valid: - messagebox.showerror("Validation Error", error_msg) - continue - - # Process identification - identified_count += self._process_identification_command( - form_data, face_id, is_already_identified, face_status, - gui_components, identify_data_cache - ) - - # Clear form after successful identification - self._clear_form(gui_components) - - # Update face index to move to next unidentified face - has_faces, i = self._update_current_face_index(original_faces, i, face_status) - if not has_faces: - # All faces have been identified - print("\nšŸŽ‰ All faces have been identified!") - break - - except Exception as e: - print(f"āŒ Error: {e}") - messagebox.showerror("Error", f"Error processing identification: {e}") - - # Clean up current face crop when moving forward after identification - if face_crop_path and os.path.exists(face_crop_path): - try: - os.remove(face_crop_path) - except: - pass # Ignore cleanup errors - current_face_crop_path = None # Clear tracked path - - # Continue to next face after processing command - continue - - elif command: - try: - # Process other identification command (legacy support) - identified_count += self._process_identification_command( - command, face_id, is_already_identified, face_status, - gui_components, identify_data_cache - ) - - except Exception as e: - print(f"āŒ Error: {e}") - - # Increment index for normal flow (identification or error) - but not if we're at the last item - if i < len(original_faces) - 1: - i += 1 - self._update_button_states(gui_components, original_faces, i, face_status) - # Only update similar faces if compare is enabled - if gui_components['compare_var'].get(): - self._update_similar_faces(gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i) - - # Clean up current face crop when moving forward after identification - if face_crop_path and os.path.exists(face_crop_path): - try: - os.remove(face_crop_path) - except: - pass # Ignore cleanup errors - current_face_crop_path = None # Clear tracked path - - # Continue to next face after processing command - continue - else: - print("Please enter a name, 's' to skip, 'q' to quit, or use buttons") - - # Only close the window if user explicitly quit (not when reaching end of faces) - if not window_destroyed: - # Keep the window open - user can still navigate and quit manually - print(f"\nāœ… Identified {identified_count} faces") - print("šŸ’” Application remains open - use Quit button to close") - # Don't destroy the window - let user quit manually - return identified_count - - print(f"\nāœ… Identified {identified_count} faces") - return identified_count - - def _get_unidentified_faces(self, batch_size: int, date_from: str = None, date_to: str = None, - date_processed_from: str = None, date_processed_to: str = None): - """Get unidentified faces from database with optional date filtering""" - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - query += ' LIMIT ?' - params.append(batch_size) - - cursor.execute(query, params) - return cursor.fetchall() - - def _prefetch_identify_data(self, unidentified): - """Pre-fetch all needed data to avoid repeated database queries""" - identify_data_cache = {} - - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - - # Pre-fetch all photo paths for unidentified faces - photo_ids = [face[1] for face in unidentified] # face[1] is photo_id - if photo_ids: - placeholders = ','.join('?' * len(photo_ids)) - cursor.execute(f'SELECT id, path, filename FROM photos WHERE id IN ({placeholders})', photo_ids) - identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()} - - # Pre-fetch all people names for dropdown - cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people] - # Pre-fetch unique last names for autocomplete (no DB during typing) - cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') - _last_rows = cursor.fetchall() - identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) - - return identify_data_cache - - def _create_gui_components(self, main_frame, identify_data_cache, date_from, date_to, - date_processed_from, date_processed_to, batch_size, on_quit=None): - """Create all GUI components for the identify interface""" - components = {} - - # Create variables for form data - components['compare_var'] = tk.BooleanVar() - components['unique_var'] = tk.BooleanVar() - components['first_name_var'] = tk.StringVar() - components['last_name_var'] = tk.StringVar() - components['middle_name_var'] = tk.StringVar() - components['maiden_name_var'] = tk.StringVar() - components['date_of_birth_var'] = tk.StringVar() - - # Date filter variables - components['date_from_var'] = tk.StringVar(value=date_from or "") - components['date_to_var'] = tk.StringVar(value=date_to or "") - components['date_processed_from_var'] = tk.StringVar(value=date_processed_from or "") - components['date_processed_to_var'] = tk.StringVar(value=date_processed_to or "") - - # Date filter controls - exactly as in original - date_filter_frame = ttk.LabelFrame(main_frame, text="Filter", padding="5") - date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) - date_filter_frame.columnconfigure(1, weight=0) - date_filter_frame.columnconfigure(4, weight=0) - - # Date from - ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) - components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_from_var'], width=10, state='readonly') - components['date_from_entry'].grid(row=0, column=1, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date from - def open_calendar_from(): - self._open_date_picker(components['date_from_var']) - - components['date_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_from) - components['date_from_btn'].grid(row=0, column=2, padx=(0, 10)) - - # Date to - ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) - components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_to_var'], width=10, state='readonly') - components['date_to_entry'].grid(row=0, column=4, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date to - def open_calendar_to(): - self._open_date_picker(components['date_to_var']) - - components['date_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_to) - components['date_to_btn'].grid(row=0, column=5, padx=(0, 10)) - - # Apply filter button - def apply_date_filter(): - """Apply date filters and reload faces""" - # Get current filter values - new_date_from = components['date_from_var'].get().strip() or None - new_date_to = components['date_to_var'].get().strip() or None - new_date_processed_from = components['date_processed_from_var'].get().strip() or None - new_date_processed_to = components['date_processed_to_var'].get().strip() or None - - # Reload faces with new date filter - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if new_date_from: - query += ' AND p.date_taken >= ?' - params.append(new_date_from) - - if new_date_to: - query += ' AND p.date_taken <= ?' - params.append(new_date_to) - - # Add date processed filtering if specified - if new_date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(new_date_processed_from) - - if new_date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(new_date_processed_to) - - query += ' LIMIT ?' - params.append(batch_size) - - cursor.execute(query, params) - filtered_faces = cursor.fetchall() - - if not filtered_faces: - messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") - return - - # Build filter description - filters_applied = [] - if new_date_from or new_date_to: - taken_filter = f"taken: {new_date_from or 'any'} to {new_date_to or 'any'}" - filters_applied.append(taken_filter) - if new_date_processed_from or new_date_processed_to: - processed_filter = f"processed: {new_date_processed_from or 'any'} to {new_date_processed_to or 'any'}" - filters_applied.append(processed_filter) - - filter_desc = " | ".join(filters_applied) if filters_applied else "no filters" - - print(f"šŸ“… Applied filters: {filter_desc}") - print(f"šŸ‘¤ Found {len(filtered_faces)} unidentified faces with date filters") - - # Set a special command to reload faces - components['command_var'].set("reload_faces") - - # Store the filtered faces for the main loop to use - components['filtered_faces'] = filtered_faces - components['new_date_from'] = new_date_from - components['new_date_to'] = new_date_to - components['new_date_processed_from'] = new_date_processed_from - components['new_date_processed_to'] = new_date_processed_to - - components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) - components['apply_filter_btn'].grid(row=0, column=6, padx=(10, 0)) - - # Date processed filter (second row) - ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_from_var'], width=10, state='readonly') - components['date_processed_from_entry'].grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed from - def open_calendar_processed_from(): - self._open_date_picker(components['date_processed_from_var']) - - components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_from) - components['date_processed_from_btn'].grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) - - # Date processed to - ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_to_var'], width=10, state='readonly') - components['date_processed_to_entry'].grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed to - def open_calendar_processed_to(): - self._open_date_picker(components['date_processed_to_var']) - - components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_to) - components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) - - # Unique checkbox under the filter frame - def on_unique_change(): - # This will be called when the checkbox state changes - # We'll handle the actual filtering in the main loop - pass - - components['unique_check'] = ttk.Checkbutton(main_frame, text="Unique faces only", - variable=components['unique_var'], - command=on_unique_change) - components['unique_check'].grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0) - - # Compare checkbox on the same row as Unique - def on_compare_change(): - # This will be called when the checkbox state changes - # We'll handle the actual panel toggling in the main loop - pass - - components['compare_check'] = ttk.Checkbutton(main_frame, text="Compare similar faces", - variable=components['compare_var'], - command=on_compare_change) - components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(5, 10), pady=0) - - # Left panel for main face - left_panel = ttk.Frame(main_frame) - left_panel.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5), pady=(0, 0)) - left_panel.columnconfigure(0, weight=1) - - # Right panel for similar faces - components['right_panel'] = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5") - components['right_panel'].grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - components['right_panel'].columnconfigure(0, weight=1) - components['right_panel'].rowconfigure(0, weight=1) # Make right panel expandable vertically - - # Right panel is always visible now - - # Image display (left panel) - image_frame = ttk.Frame(left_panel) - image_frame.grid(row=0, column=0, pady=(0, 10), sticky=(tk.W, tk.E, tk.N, tk.S)) - image_frame.columnconfigure(0, weight=1) - image_frame.rowconfigure(0, weight=1) - - # Create canvas for image display - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - components['canvas'] = tk.Canvas(image_frame, width=400, height=400, bg=canvas_bg_color, highlightthickness=0) - components['canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Store reference to current image data for redrawing on resize - components['canvas'].current_image_data = None - - # Bind resize event to redraw image - def on_canvas_resize(event): - if hasattr(components['canvas'], 'current_image_data') and components['canvas'].current_image_data: - # Redraw the current image with new dimensions - self._redraw_current_image(components['canvas']) - - components['canvas'].bind('', on_canvas_resize) - - # Input section (left panel) - input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10") - input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E)) - input_frame.columnconfigure(1, weight=1) - input_frame.columnconfigure(3, weight=1) - input_frame.columnconfigure(5, weight=1) - input_frame.columnconfigure(7, weight=1) - - # First name input - ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) - components['first_name_entry'] = ttk.Entry(input_frame, textvariable=components['first_name_var'], width=12) - components['first_name_entry'].grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Last name input with autocomplete - ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10)) - components['last_name_entry'] = ttk.Entry(input_frame, textvariable=components['last_name_var'], width=12) - components['last_name_entry'].grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Middle name input - ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) - components['middle_name_entry'] = ttk.Entry(input_frame, textvariable=components['middle_name_var'], width=12) - components['middle_name_entry'].grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Date of birth input with calendar chooser - ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10)) - - # Create a frame for the date picker - date_frame = ttk.Frame(input_frame) - date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10)) - - # Maiden name input - ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10)) - components['maiden_name_entry'] = ttk.Entry(input_frame, textvariable=components['maiden_name_var'], width=12) - components['maiden_name_entry'].grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Date display entry (read-only) - components['date_of_birth_entry'] = ttk.Entry(date_frame, textvariable=components['date_of_birth_var'], width=12, state='readonly') - components['date_of_birth_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Calendar button - components['date_of_birth_btn'] = ttk.Button(date_frame, text="šŸ“…", width=3, - command=lambda: self._open_date_picker(components['date_of_birth_var'])) - components['date_of_birth_btn'].pack(side=tk.RIGHT, padx=(15, 0)) - - # Add required field asterisks (like in original) - self._add_required_asterisks(main_frame.master, input_frame, components) - - # Add autocomplete for last name (like in original) - self._setup_last_name_autocomplete(main_frame.master, components, identify_data_cache) - - # Identify button (placed in Person Identification frame) - components['identify_btn'] = ttk.Button(input_frame, text="āœ… Identify", command=lambda: self._set_command('identify'), state='disabled') - components['identify_btn'].grid(row=2, column=0, pady=(10, 0), sticky=tk.W) - - # Add event handlers to update Identify button state - def update_identify_button_state(*args): - self._update_identify_button_state(components) - - components['first_name_var'].trace('w', update_identify_button_state) - components['last_name_var'].trace('w', update_identify_button_state) - components['date_of_birth_var'].trace('w', update_identify_button_state) - - # Handle Enter key - def on_enter(event): - if components['identify_btn']['state'] == 'normal': - self._set_command('identify') - - components['first_name_entry'].bind('', on_enter) - components['last_name_entry'].bind('', on_enter) - components['middle_name_entry'].bind('', on_enter) - components['maiden_name_entry'].bind('', on_enter) - - - # Create similar faces frame with controls - similar_faces_frame = ttk.Frame(components['right_panel']) - similar_faces_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - similar_faces_frame.columnconfigure(0, weight=1) - similar_faces_frame.rowconfigure(0, weight=0) # Controls row takes minimal space - similar_faces_frame.rowconfigure(1, weight=1) # Canvas row expandable - - # Control buttons for similar faces (Select All / Clear All) - similar_controls_frame = ttk.Frame(similar_faces_frame) - similar_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) - - def select_all_similar_faces(): - """Select all similar faces checkboxes""" - if hasattr(self, '_similar_face_vars'): - for face_id, var in self._similar_face_vars: - var.set(True) - - def clear_all_similar_faces(): - """Clear all similar faces checkboxes""" - if hasattr(self, '_similar_face_vars'): - for face_id, var in self._similar_face_vars: - var.set(False) - - components['select_all_btn'] = ttk.Button(similar_controls_frame, text="ā˜‘ļø Select All", - command=select_all_similar_faces, state='disabled') - components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) - - components['clear_all_btn'] = ttk.Button(similar_controls_frame, text="☐ Clear All", - command=clear_all_similar_faces, state='disabled') - components['clear_all_btn'].pack(side=tk.LEFT) - - # Create canvas for similar faces with scrollbar - similar_canvas = tk.Canvas(similar_faces_frame, bg='lightgray', relief='sunken', bd=2) - similar_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Similar faces scrollbars - similar_v_scrollbar = ttk.Scrollbar(similar_faces_frame, orient='vertical', command=similar_canvas.yview) - similar_v_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S)) - similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set) - - # Create scrollable frame for similar faces - components['similar_scrollable_frame'] = ttk.Frame(similar_canvas) - similar_canvas.create_window((0, 0), window=components['similar_scrollable_frame'], anchor='nw') - - # Store canvas reference for scrolling - components['similar_canvas'] = similar_canvas - - # Add initial message when compare is disabled - no_compare_label = ttk.Label(components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces", - foreground="gray", font=("Arial", 10)) - no_compare_label.pack(pady=20) - - # Bottom control panel (move to bottom below panels) - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=4, column=0, columnspan=3, pady=(10, 0), sticky=(tk.E, tk.S)) - - # Create button references for state management - components['control_back_btn'] = ttk.Button(control_frame, text="ā¬…ļø Back", command=lambda: self._set_command('back')) - components['control_next_btn'] = ttk.Button(control_frame, text="āž”ļø Next", command=lambda: self._set_command('s')) - if on_quit: - components['control_quit_btn'] = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) - else: - components['control_quit_btn'] = ttk.Button(control_frame, text="āŒ Quit", command=lambda: self._set_command('quit')) - - components['control_back_btn'].pack(side=tk.LEFT, padx=(0, 5)) - components['control_next_btn'].pack(side=tk.LEFT, padx=(0, 5)) - components['control_quit_btn'].pack(side=tk.LEFT, padx=(5, 0)) - - # Store command variable for button callbacks - components['command_var'] = tk.StringVar() - - return components - - def _add_required_asterisks(self, root, input_frame, components): - """Add red asterisks to required fields (first name, last name, date of birth)""" - # Red asterisks for required fields (overlayed, no layout impact) - first_name_asterisk = ttk.Label(root, text="*", foreground="red") - first_name_asterisk.place_forget() - - last_name_asterisk = ttk.Label(root, text="*", foreground="red") - last_name_asterisk.place_forget() - - date_asterisk = ttk.Label(root, text="*", foreground="red") - date_asterisk.place_forget() - - def _position_required_asterisks(event=None): - """Position required asterisks at top-right corner of their entries.""" - try: - root.update_idletasks() - input_frame.update_idletasks() - components['first_name_entry'].update_idletasks() - components['last_name_entry'].update_idletasks() - components['date_of_birth_entry'].update_idletasks() - - # Get absolute coordinates relative to root window - first_root_x = components['first_name_entry'].winfo_rootx() - first_root_y = components['first_name_entry'].winfo_rooty() - first_w = components['first_name_entry'].winfo_width() - root_x = root.winfo_rootx() - root_y = root.winfo_rooty() - - # First name asterisk at the true top-right corner of entry - first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw') - first_name_asterisk.lift() - - # Last name asterisk at the true top-right corner of entry - last_root_x = components['last_name_entry'].winfo_rootx() - last_root_y = components['last_name_entry'].winfo_rooty() - last_w = components['last_name_entry'].winfo_width() - last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw') - last_name_asterisk.lift() - - # Date of birth asterisk at the true top-right corner of date entry - dob_root_x = components['date_of_birth_entry'].winfo_rootx() - dob_root_y = components['date_of_birth_entry'].winfo_rooty() - dob_w = components['date_of_birth_entry'].winfo_width() - date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw') - date_asterisk.lift() - except Exception: - pass - - # Bind repositioning after all entries are created - def _bind_asterisk_positioning(): - try: - input_frame.bind('', _position_required_asterisks) - components['first_name_entry'].bind('', _position_required_asterisks) - components['last_name_entry'].bind('', _position_required_asterisks) - components['date_of_birth_entry'].bind('', _position_required_asterisks) - _position_required_asterisks() - except Exception: - pass - root.after(100, _bind_asterisk_positioning) - - def _setup_last_name_autocomplete(self, root, components, identify_data_cache): - """Setup autocomplete functionality for last name field - exact copy from original""" - # Create listbox for suggestions (as overlay attached to root, not clipped by frames) - last_name_listbox = tk.Listbox(root, height=8) - last_name_listbox.place_forget() # Hide initially - - # Navigation state variables (like in original) - navigating_to_listbox = False - escape_pressed = False - enter_pressed = False - - def _show_suggestions(): - """Show filtered suggestions in listbox""" - all_last_names = identify_data_cache.get('last_names', []) - typed = components['last_name_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 all_last_names if n.lower().startswith(low)][:10] - - # Update listbox - last_name_listbox.delete(0, tk.END) - for name in filtered: - last_name_listbox.insert(tk.END, name) - - # Show listbox if we have suggestions (as overlay) - if filtered: - # Ensure geometry is up to date before positioning - root.update_idletasks() - # Absolute coordinates of entry relative to screen - entry_root_x = components['last_name_entry'].winfo_rootx() - entry_root_y = components['last_name_entry'].winfo_rooty() - entry_height = components['last_name_entry'].winfo_height() - # Convert to coordinates relative to root - root_origin_x = root.winfo_rootx() - root_origin_y = root.winfo_rooty() - place_x = entry_root_x - root_origin_x - place_y = entry_root_y - root_origin_y + entry_height - place_width = components['last_name_entry'].winfo_width() - # Calculate how many rows fit to bottom of window - available_px = max(60, root.winfo_height() - place_y - 8) - # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows - approx_row_px = 18 - rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) - last_name_listbox.configure(height=rows_fit) - last_name_listbox.place(x=place_x, y=place_y, width=place_width) - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) # Select first item - last_name_listbox.activate(0) # Activate first item - else: - last_name_listbox.place_forget() - - def _hide_suggestions(): - """Hide the suggestions listbox""" - last_name_listbox.place_forget() - - def _on_listbox_select(event=None): - """Handle listbox selection and hide list""" - selection = last_name_listbox.curselection() - if selection: - selected_name = last_name_listbox.get(selection[0]) - components['last_name_var'].set(selected_name) - _hide_suggestions() - components['last_name_entry'].focus_set() - - def _on_listbox_click(event): - """Handle mouse click selection""" - try: - index = last_name_listbox.nearest(event.y) - if index is not None and index >= 0: - selected_name = last_name_listbox.get(index) - components['last_name_var'].set(selected_name) - except: - pass - _hide_suggestions() - components['last_name_entry'].focus_set() - return 'break' - - def _on_key_press(event): - """Handle key navigation in entry""" - nonlocal navigating_to_listbox, escape_pressed, enter_pressed - if event.keysym == 'Down': - if last_name_listbox.winfo_ismapped(): - navigating_to_listbox = True - last_name_listbox.focus_set() - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) - last_name_listbox.activate(0) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - return 'break' - elif event.keysym == 'Return': - enter_pressed = True - return 'break' - - def _on_listbox_key(event): - """Handle key navigation in listbox""" - nonlocal enter_pressed, escape_pressed - if event.keysym == 'Return': - enter_pressed = True - _on_listbox_select(event) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - components['last_name_entry'].focus_set() - return 'break' - elif event.keysym == 'Up': - selection = last_name_listbox.curselection() - if selection and selection[0] > 0: - # Move up in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] - 1) - last_name_listbox.see(selection[0] - 1) - else: - # At top, go back to entry field - _hide_suggestions() - components['last_name_entry'].focus_set() - return 'break' - elif event.keysym == 'Down': - selection = last_name_listbox.curselection() - max_index = last_name_listbox.size() - 1 - if selection and selection[0] < max_index: - # Move down in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] + 1) - last_name_listbox.see(selection[0] + 1) - return 'break' - - # Track if we're navigating to listbox to prevent auto-hide - navigating_to_listbox = False - escape_pressed = False - enter_pressed = False - - def _safe_hide_suggestions(): - """Hide suggestions only if not navigating to listbox""" - nonlocal navigating_to_listbox - if not navigating_to_listbox: - _hide_suggestions() - navigating_to_listbox = False - - def _safe_show_suggestions(): - """Show suggestions only if escape or enter wasn't just pressed""" - nonlocal escape_pressed, enter_pressed - if not escape_pressed and not enter_pressed: - _show_suggestions() - escape_pressed = False - enter_pressed = False - - # Bind events - components['last_name_entry'].bind('', lambda e: _safe_show_suggestions()) - components['last_name_entry'].bind('', _on_key_press) - components['last_name_entry'].bind('', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks - last_name_listbox.bind('', _on_listbox_click) - last_name_listbox.bind('', _on_listbox_key) - last_name_listbox.bind('', _on_listbox_click) - - def _update_identify_button_state(self, gui_components): - """Update the state of the Identify button based on form data""" - first_name = gui_components['first_name_var'].get().strip() - last_name = gui_components['last_name_var'].get().strip() - date_of_birth = gui_components['date_of_birth_var'].get().strip() - - # Enable button if we have at least first name or last name, and date of birth - if (first_name or last_name) and date_of_birth: - gui_components['identify_btn'].config(state='normal') - else: - gui_components['identify_btn'].config(state='disabled') - - def _update_control_button_states(self, gui_components, i, total_faces): - """Update the state of control buttons based on current position""" - # Back button - disabled if at first face - if i <= 0: - gui_components['control_back_btn'].config(state='disabled') - else: - gui_components['control_back_btn'].config(state='normal') - - # Next button - disabled if at last face - if i >= total_faces - 1: - gui_components['control_next_btn'].config(state='disabled') - else: - gui_components['control_next_btn'].config(state='normal') - - def _update_select_clear_buttons_state(self, gui_components, similar_face_vars): - """Enable/disable Select All and Clear All based on compare state and presence of items""" - if gui_components['compare_var'].get() and similar_face_vars: - gui_components['select_all_btn'].config(state='normal') - gui_components['clear_all_btn'].config(state='normal') - else: - gui_components['select_all_btn'].config(state='disabled') - gui_components['clear_all_btn'].config(state='disabled') - - def _get_pending_identifications(self, face_person_names, face_status): - """Get pending identifications that haven't been saved yet""" - pending_identifications = {} - for k, v in face_person_names.items(): - if k not in face_status or face_status[k] != 'identified': - # Handle person data dict format - if isinstance(v, dict): - first_name = v.get('first_name', '').strip() - last_name = v.get('last_name', '').strip() - date_of_birth = v.get('date_of_birth', '').strip() - - # Check if we have complete data (both first and last name, plus date of birth) - if first_name and last_name and date_of_birth: - pending_identifications[k] = v - return pending_identifications - - def _save_all_pending_identifications(self, face_person_names, face_status, identify_data_cache): - """Save all pending identifications from face_person_names""" - saved_count = 0 - - for face_id, person_data in face_person_names.items(): - # Handle person data dict format - if isinstance(person_data, dict): - first_name = person_data.get('first_name', '').strip() - last_name = person_data.get('last_name', '').strip() - date_of_birth = person_data.get('date_of_birth', '').strip() - middle_name = person_data.get('middle_name', '').strip() - maiden_name = person_data.get('maiden_name', '').strip() - - # Only save if we have at least a first or last name - if first_name or last_name: - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - # Add person if doesn't exist - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - if display_name not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(display_name) - identify_data_cache['people_names'].sort() # Keep sorted - # Keep last names cache updated in-session - if last_name: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if last_name not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(last_name) - identify_data_cache['last_names'].sort() - - # Assign face to person - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, face_id) - ) - - # Update person encodings - self.face_processor.update_person_encodings(person_id) - saved_count += 1 - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - print(f"āœ… Saved identification: {display_name}") - - except Exception as e: - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - print(f"āŒ Error saving identification for {display_name}: {e}") - - if saved_count > 0: - print(f"šŸ’¾ Saved {saved_count} pending identifications") - - return saved_count - - def _update_current_face_index(self, original_faces, i, face_status): - """Update the current face index to point to a valid unidentified face""" - unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] - if not unidentified_faces: - # All faces identified, we're done - return False, i - - # Find the current face in the unidentified list - current_face_id = original_faces[i][0] if i < len(original_faces) else None - if current_face_id and current_face_id in face_status and face_status[current_face_id] == 'identified': - # Current face was just identified, find the next unidentified face - if i < len(original_faces) - 1: - # Try to find the next unidentified face - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - else: - # No more faces after current, go to previous - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - else: - # At the end, go to previous unidentified face - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - - # Ensure index is within bounds - if i >= len(original_faces): - i = len(original_faces) - 1 - if i < 0: - i = 0 - - return True, i - - def _get_current_face_position(self, original_faces, i, face_status): - """Get current face position among unidentified faces""" - unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] - current_face_id = original_faces[i][0] if i < len(original_faces) else None - - # Find position of current face in unidentified list - for pos, face in enumerate(unidentified_faces): - if face[0] == current_face_id: - return pos + 1, len(unidentified_faces) - - return 1, len(unidentified_faces) # Fallback - - def _update_button_states(self, gui_components, original_faces, i, face_status): - """Update button states based on current position and unidentified faces""" - # Update control button states - self._update_control_button_states(gui_components, i, len(original_faces)) - - # Update identify button state - self._update_identify_button_state(gui_components) - - # Update similar faces control buttons state - # Get similar face variables if they exist - similar_face_vars = getattr(self, '_similar_face_vars', []) - self._update_select_clear_buttons_state(gui_components, similar_face_vars) - - def _update_similar_faces(self, gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i): - """Update the similar faces panel when compare is enabled""" - try: - if not gui_components['compare_var'].get(): - return - - scrollable_frame = gui_components['similar_scrollable_frame'] - - # Clear existing content - for widget in scrollable_frame.winfo_children(): - widget.destroy() - - # Get similar faces using filtered version (includes 40% confidence threshold) - similar_faces = self.face_processor._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status) - - if not similar_faces: - no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found", - foreground="gray", font=("Arial", 10)) - no_faces_label.pack(pady=20) - return - - # Filter out already identified faces if unique checkbox is checked - if gui_components['unique_var'].get(): - similar_faces = [face for face in similar_faces - if face.get('person_id') is None] - - if not similar_faces: - no_faces_label = ttk.Label(scrollable_frame, text="No unique similar faces found", - foreground="gray", font=("Arial", 10)) - no_faces_label.pack(pady=20) - return - - # Sort by confidence (distance) - highest confidence first (lowest distance) - similar_faces.sort(key=lambda x: x['distance']) - - # Display similar faces using the old version's approach - self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id, - face_selection_states, identify_data_cache) - - # Update canvas scroll region - canvas = gui_components['similar_canvas'] - canvas.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) - - if self.verbose >= 2: - print(f" šŸ” Displayed {len(similar_faces)} similar faces") - - except Exception as e: - print(f"āŒ Error updating similar faces: {e}") - - def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id, - face_selection_states, identify_data_cache): - """Display similar faces in a panel - based on old version's auto-match display logic""" - import tkinter as tk - from tkinter import ttk - from PIL import Image, ImageTk - import os - - # Store similar face variables for Select All/Clear All functionality - similar_face_vars = [] - - # Create all similar faces using auto-match style display - for i, face_data in enumerate(similar_faces_data[:10]): # Limit to 10 faces - similar_face_id = face_data['face_id'] - filename = face_data['filename'] - distance = face_data['distance'] - quality = face_data.get('quality_score', 0.5) - - # Calculate confidence like in auto-match - confidence_pct = (1 - distance) * 100 - confidence_desc = self._get_confidence_description(confidence_pct) - - # Create match frame using auto-match style - match_frame = ttk.Frame(parent_frame) - match_frame.pack(fill=tk.X, padx=5, pady=5) - - # Checkbox for this match (reusing auto-match checkbox style) - match_var = tk.BooleanVar() - - # Restore previous checkbox state if available (auto-match style) - if current_face_id is not None and face_selection_states is not None: - unique_key = f"{current_face_id}_{similar_face_id}" - if current_face_id in face_selection_states and unique_key in face_selection_states[current_face_id]: - saved_state = face_selection_states[current_face_id][unique_key] - match_var.set(saved_state) - - # Add immediate callback to save state when checkbox changes (auto-match style) - def make_callback(var, face_id, similar_face_id): - def on_checkbox_change(*args): - unique_key = f"{face_id}_{similar_face_id}" - if face_id not in face_selection_states: - face_selection_states[face_id] = {} - face_selection_states[face_id][unique_key] = var.get() - return on_checkbox_change - - # Bind the callback to the variable - match_var.trace('w', make_callback(match_var, current_face_id, similar_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 without text - checkbox = ttk.Checkbutton(match_frame, variable=match_var) - checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) - - # Add to similar face variables list - similar_face_vars.append((similar_face_id, match_var)) - - # Right panel requirement: image immediately to the right of checkbox - # Create canvas for face image next to checkbox - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - match_canvas = tk.Canvas(match_frame, width=80, height=80, bg=canvas_bg_color, highlightthickness=0) - match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10)) - - # Create labels container to the right of image - info_container = ttk.Frame(match_frame) - info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E)) - - # Confidence badge - badge = self.gui_core.create_confidence_badge(info_container, confidence_pct) - badge.pack(anchor=tk.W) - - filename_label = ttk.Label(info_container, text=f"šŸ“ {filename}", font=("Arial", 8), foreground="gray") - filename_label.pack(anchor=tk.W, pady=(2, 0)) - - # Face image (reusing auto-match image display) - try: - # Get photo path from cache or database - photo_path = None - if identify_data_cache and 'photo_paths' in identify_data_cache: - # Find photo path by filename in cache - for photo_data in identify_data_cache['photo_paths'].values(): - if photo_data['filename'] == filename: - photo_path = photo_data['path'] - break - - # Fallback to database if not in cache - if photo_path is None: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT path FROM photos WHERE filename = ?', (filename,)) - result = cursor.fetchone() - photo_path = result[0] if result else None - - # Extract face crop using existing method - face_crop_path = self.face_processor._extract_face_crop(photo_path, face_data['location'], similar_face_id) - if face_crop_path and os.path.exists(face_crop_path): - # Load and display image - pil_image = Image.open(face_crop_path) - pil_image.thumbnail((80, 80), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(pil_image) - match_canvas.create_image(40, 40, image=photo) - match_canvas.image = photo # Keep reference - - # Add photo icon exactly at the image's top-right corner - self.gui_core.create_photo_icon(match_canvas, photo_path, icon_size=15, - face_x=0, face_y=0, - face_width=80, face_height=80, - canvas_width=80, canvas_height=80) - - # Clean up temporary face crop - try: - os.remove(face_crop_path) - except: - pass - - except Exception as e: - if self.verbose >= 1: - print(f" āš ļø Error displaying similar face {i}: {e}") - continue - - # Store similar face variables for Select All/Clear All functionality - self._similar_face_vars = similar_face_vars - - def _get_confidence_description(self, confidence_pct): - """Get confidence description based on percentage""" - if confidence_pct >= 80: - return "Very High" - elif confidence_pct >= 70: - return "High" - elif confidence_pct >= 60: - return "Medium" - elif confidence_pct >= 50: - return "Low" - else: - return "Very Low" - - def _on_similar_face_select(self, similar_face_id, is_selected): - """Handle similar face checkbox selection""" - # This would be used to track which similar faces are selected - # For now, just a placeholder - pass - - def _get_person_name_for_face(self, face_id): - """Get person name for a face that's already identified""" - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.first_name, p.last_name FROM people p - JOIN faces f ON p.id = f.person_id - WHERE f.id = ? - ''', (face_id,)) - result = cursor.fetchone() - if result: - first_name, last_name = result - if last_name and first_name: - return f"{last_name}, {first_name}" - elif last_name: - return last_name - elif first_name: - return first_name - else: - return "Unknown" - else: - return "Unknown" - - def _update_face_image(self, gui_components, show_faces, face_crop_path, photo_path): - """Update the face image display""" - try: - canvas = gui_components['canvas'] - - # Clear existing image - canvas.delete("all") - - # Determine which image to display - if show_faces and face_crop_path and os.path.exists(face_crop_path): - # Display face crop - image_path = face_crop_path - image_type = "face crop" - elif photo_path and os.path.exists(photo_path): - # Display full photo - image_path = photo_path - image_type = "full photo" - else: - # No image available - canvas.create_text(200, 200, text="No image available", - font=("Arial", 12), fill="gray") - return - - # Load and display image - try: - with Image.open(image_path) as img: - # Force canvas to update its dimensions - canvas.update_idletasks() - - # Get canvas dimensions - use configured size if not yet rendered - canvas_width = canvas.winfo_width() - canvas_height = canvas.winfo_height() - - # If canvas hasn't been rendered yet, use the configured size - if canvas_width <= 1 or canvas_height <= 1: - canvas_width = 400 - canvas_height = 400 - # Set a minimum size to ensure proper rendering - canvas.configure(width=canvas_width, height=canvas_height) - - # Get image dimensions - img_width, img_height = img.size - - # Calculate scale factor to fit image in canvas - scale_x = canvas_width / img_width - scale_y = canvas_height / img_height - scale = min(scale_x, scale_y, 1.0) # Don't scale up - - # Calculate new dimensions - new_width = int(img_width * scale) - new_height = int(img_height * scale) - - # Resize image - img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # Convert to PhotoImage - photo = ImageTk.PhotoImage(img_resized) - - # Center image on canvas - x = (canvas_width - new_width) // 2 - y = (canvas_height - new_height) // 2 - - # Create image on canvas - canvas.create_image(x, y, anchor='nw', image=photo) - - # Keep reference to prevent garbage collection - canvas.image_ref = photo - - # Store image data for redrawing on resize - canvas.current_image_data = { - 'image_path': image_path, - 'image_type': image_type, - 'original_img': img, - 'img_width': img_width, - 'img_height': img_height - } - - # Add photo icon using reusable function - self.gui_core.create_photo_icon(canvas, photo_path, - face_x=x, face_y=y, - face_width=new_width, face_height=new_height, - canvas_width=canvas_width, canvas_height=canvas_height) - - # Update canvas scroll region - canvas.configure(scrollregion=canvas.bbox("all")) - - if self.verbose >= 2: - print(f" šŸ–¼ļø Displayed {image_type}: {os.path.basename(image_path)} ({new_width}x{new_height})") - - except Exception as e: - canvas.create_text(200, 200, text=f"Error loading image:\n{str(e)}", - font=("Arial", 10), fill="red") - if self.verbose >= 1: - print(f" āš ļø Error loading image {image_path}: {e}") - - except Exception as e: - print(f"āŒ Error updating face image: {e}") - - def _redraw_current_image(self, canvas): - """Redraw the current image when canvas is resized""" - try: - if not hasattr(canvas, 'current_image_data') or not canvas.current_image_data: - return - - # Clear existing image - canvas.delete("all") - - # Get stored image data - data = canvas.current_image_data - img = data['original_img'] - img_width = data['img_width'] - img_height = data['img_height'] - - # Get current canvas dimensions - canvas_width = canvas.winfo_width() - canvas_height = canvas.winfo_height() - - if canvas_width <= 1 or canvas_height <= 1: - return - - # Calculate new scale - scale_x = canvas_width / img_width - scale_y = canvas_height / img_height - scale = min(scale_x, scale_y, 1.0) - - # Calculate new dimensions - new_width = int(img_width * scale) - new_height = int(img_height * scale) - - # Resize image - img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # Convert to PhotoImage - photo = ImageTk.PhotoImage(img_resized) - - # Center image on canvas - x = (canvas_width - new_width) // 2 - y = (canvas_height - new_height) // 2 - - # Create image on canvas - canvas.create_image(x, y, anchor='nw', image=photo) - - # Keep reference to prevent garbage collection - canvas.image_ref = photo - - # Add photo icon using reusable function - self.gui_core.create_photo_icon(canvas, data['image_path'], - face_x=x, face_y=y, - face_width=new_width, face_height=new_height, - canvas_width=canvas_width, canvas_height=canvas_height) - - # Update canvas scroll region - canvas.configure(scrollregion=canvas.bbox("all")) - - except Exception as e: - print(f"āŒ Error redrawing image: {e}") - - def _restore_person_name_input(self, gui_components, face_id, face_person_names, is_already_identified): - """Restore person name input fields""" - try: - if is_already_identified: - # Get person data from database - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth - FROM people p - JOIN faces f ON p.id = f.person_id - WHERE f.id = ? - ''', (face_id,)) - result = cursor.fetchone() - - if result: - first_name, last_name, middle_name, maiden_name, date_of_birth = result - gui_components['first_name_var'].set(first_name or "") - gui_components['last_name_var'].set(last_name or "") - gui_components['middle_name_var'].set(middle_name or "") - gui_components['maiden_name_var'].set(maiden_name or "") - gui_components['date_of_birth_var'].set(date_of_birth or "") - else: - # Clear all fields if no person found - self._clear_form(gui_components) - else: - # Restore from saved data if available - if face_id in face_person_names: - person_data = face_person_names[face_id] - if isinstance(person_data, dict): - gui_components['first_name_var'].set(person_data.get('first_name', '')) - gui_components['last_name_var'].set(person_data.get('last_name', '')) - gui_components['middle_name_var'].set(person_data.get('middle_name', '')) - gui_components['maiden_name_var'].set(person_data.get('maiden_name', '')) - gui_components['date_of_birth_var'].set(person_data.get('date_of_birth', '')) - else: - # Legacy string format - gui_components['first_name_var'].set(person_data or "") - gui_components['last_name_var'].set("") - gui_components['middle_name_var'].set("") - gui_components['maiden_name_var'].set("") - gui_components['date_of_birth_var'].set("") - else: - # Clear all fields for new face - self._clear_form(gui_components) - - except Exception as e: - print(f"āŒ Error restoring person name input: {e}") - # Clear form on error - self._clear_form(gui_components) - - def _save_current_face_selection_states(self, gui_components, original_faces, i, - face_selection_states, face_person_names): - """Save current checkbox states and person name for the current face""" - try: - if i >= len(original_faces): - return - - current_face_id = original_faces[i][0] - - # Save form data - form_data = self._get_form_data(gui_components) - face_person_names[current_face_id] = form_data - - # Save checkbox states for similar faces - if current_face_id not in face_selection_states: - face_selection_states[current_face_id] = {} - - # Note: Similar face checkbox states would be saved here - # This would require tracking the checkbox variables created in _update_similar_faces - - except Exception as e: - print(f"āŒ Error saving face selection states: {e}") - - def _process_identification_command(self, command_or_data, face_id, is_already_identified, - face_status, gui_components, identify_data_cache): - """Process an identification command""" - try: - # Handle form data (new GUI approach) - if isinstance(command_or_data, dict): - form_data = command_or_data - first_name = form_data.get('first_name', '').strip() - last_name = form_data.get('last_name', '').strip() - middle_name = form_data.get('middle_name', '').strip() - maiden_name = form_data.get('maiden_name', '').strip() - date_of_birth = form_data.get('date_of_birth', '').strip() - else: - # Handle legacy string command - command = command_or_data.strip() - if not command: - return 0 - - # Parse simple name format (legacy support) - parts = command.split() - if len(parts) >= 2: - first_name = parts[0] - last_name = ' '.join(parts[1:]) - else: - first_name = command - last_name = "" - middle_name = "" - maiden_name = "" - date_of_birth = "" # Legacy commands don't include date of birth - - # Add person if doesn't exist - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', - (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', - (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - if display_name not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(display_name) - identify_data_cache['people_names'].sort() # Keep sorted - - # Keep last names cache updated in-session - if last_name: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if last_name not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(last_name) - identify_data_cache['last_names'].sort() - - # Get selected similar faces - selected_similar_faces = self._get_selected_similar_faces() - - # Assign main face to person - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, face_id) - ) - - # Assign selected similar faces to the same person - similar_faces_identified = 0 - if selected_similar_faces: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for similar_face_id in selected_similar_faces: - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, similar_face_id) - ) - similar_faces_identified += 1 - - # Update person encodings - self.face_processor.update_person_encodings(person_id) - - # Mark main face as identified - face_status[face_id] = 'identified' - - # Mark selected similar faces as identified - for similar_face_id in selected_similar_faces: - face_status[similar_face_id] = 'identified' - - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - if similar_faces_identified > 0: - print(f"āœ… Identified as: {display_name} (main face + {similar_faces_identified} similar faces)") - else: - print(f"āœ… Identified as: {display_name}") - - return 1 + similar_faces_identified - - except Exception as e: - print(f"āŒ Error processing identification: {e}") - return 0 - - def _show_people_list(self): - """Show list of known people""" - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - - if people: - formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last] - print("šŸ‘„ Known people:", ", ".join(formatted_names)) - else: - print("šŸ‘„ No people identified yet") - - def _open_date_picker(self, date_var): - """Open date picker dialog and update the date variable""" - current_date = date_var.get() - selected_date = self.gui_core.create_calendar_dialog(None, "Select Date", current_date) - if selected_date is not None: - date_var.set(selected_date) - - def _toggle_similar_faces_panel(self, components): - """Update the similar faces panel content based on compare checkbox state""" - # Panel is always visible now, just update content - if not components['compare_var'].get(): - # Clear the similar faces content when compare is disabled - scrollable_frame = components['similar_scrollable_frame'] - for widget in scrollable_frame.winfo_children(): - widget.destroy() - - # Show a message that compare is disabled - no_compare_label = ttk.Label(scrollable_frame, text="Enable 'Compare similar faces' to see similar faces", - foreground="gray", font=("Arial", 10)) - no_compare_label.pack(pady=20) - - def _set_command(self, command): - """Set the command variable to trigger the main loop""" - # This will be used by button callbacks to set the command - # The main loop will check this variable - if hasattr(self, '_current_command_var'): - self._current_command_var.set(command) - - - def _validate_navigation(self, gui_components): - """Validate that navigation is safe (no unsaved changes)""" - # Check if there are any unsaved changes in the form - first_name = gui_components['first_name_var'].get().strip() - last_name = gui_components['last_name_var'].get().strip() - date_of_birth = gui_components['date_of_birth_var'].get().strip() - - # If all three required fields are filled, ask for confirmation - if first_name and last_name and date_of_birth: - result = messagebox.askyesnocancel( - "Unsaved Changes", - "You have unsaved changes in the identification form.\n\n" - "Do you want to save them before continuing?\n\n" - "• Yes: Save current identification and continue\n" - "• No: Discard changes and continue\n" - "• Cancel: Stay on current face" - ) - - if result is True: # Yes - Save and continue - return 'save_and_continue' - elif result is False: # No - Discard and continue - return 'discard_and_continue' - else: # Cancel - Don't navigate - return 'cancel' - - return 'continue' # No changes, safe to continue - - def _clear_form(self, gui_components): - """Clear all form fields and similar face selections""" - gui_components['first_name_var'].set("") - gui_components['last_name_var'].set("") - gui_components['middle_name_var'].set("") - gui_components['maiden_name_var'].set("") - gui_components['date_of_birth_var'].set("") - - # Clear selected similar faces - if hasattr(self, '_similar_face_vars'): - for face_id, var in self._similar_face_vars: - var.set(False) - - def _get_form_data(self, gui_components): - """Get current form data as a dictionary""" - return { - 'first_name': gui_components['first_name_var'].get().strip(), - 'last_name': gui_components['last_name_var'].get().strip(), - 'middle_name': gui_components['middle_name_var'].get().strip(), - 'maiden_name': gui_components['maiden_name_var'].get().strip(), - 'date_of_birth': gui_components['date_of_birth_var'].get().strip() - } - - def _get_selected_similar_faces(self): - """Get list of selected similar face IDs""" - selected_faces = [] - if hasattr(self, '_similar_face_vars'): - for face_id, var in self._similar_face_vars: - if var.get(): # If checkbox is checked - selected_faces.append(face_id) - return selected_faces - - def _set_form_data(self, gui_components, form_data): - """Set form data from a dictionary""" - gui_components['first_name_var'].set(form_data.get('first_name', '')) - gui_components['last_name_var'].set(form_data.get('last_name', '')) - gui_components['middle_name_var'].set(form_data.get('middle_name', '')) - gui_components['maiden_name_var'].set(form_data.get('maiden_name', '')) - gui_components['date_of_birth_var'].set(form_data.get('date_of_birth', '')) - - def _validate_form_data(self, form_data): - """Validate that form data is complete enough for identification""" - first_name = form_data.get('first_name', '').strip() - last_name = form_data.get('last_name', '').strip() - date_of_birth = form_data.get('date_of_birth', '').strip() - - # Need at least first name or last name, and date of birth - if not (first_name or last_name): - return False, "Please enter at least a first name or last name" - - if not date_of_birth: - return False, "Please enter a date of birth" - - # Validate date format - try: - from datetime import datetime - datetime.strptime(date_of_birth, '%Y-%m-%d') - except ValueError: - return False, "Please enter date of birth in YYYY-MM-DD format" - - return True, "Valid" - - def _filter_unique_faces_from_list(self, faces_list: List[tuple]) -> List[tuple]: - """Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches""" - if not faces_list: - return faces_list - - # Extract face IDs from the list - face_ids = [face_tuple[0] for face_tuple in faces_list] - - # Get face encodings from database for all faces - face_encodings = {} - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - placeholders = ','.join('?' * len(face_ids)) - cursor.execute(f''' - SELECT id, encoding - FROM faces - WHERE id IN ({placeholders}) AND encoding IS NOT NULL - ''', face_ids) - - for face_id, encoding_blob in cursor.fetchall(): - try: - import numpy as np - # Load encoding as numpy array (not pickle) - encoding = np.frombuffer(encoding_blob, dtype=np.float64) - face_encodings[face_id] = encoding - except Exception: - continue - - # If we don't have enough encodings, return original list - if len(face_encodings) < 2: - return faces_list - - # Calculate distances between all faces using existing encodings - face_distances = {} - face_id_list = list(face_encodings.keys()) - - for i, face_id1 in enumerate(face_id_list): - for j, face_id2 in enumerate(face_id_list): - if i != j: - try: - import face_recognition - encoding1 = face_encodings[face_id1] - encoding2 = face_encodings[face_id2] - - # Calculate distance - distance = face_recognition.face_distance([encoding1], encoding2)[0] - face_distances[(face_id1, face_id2)] = distance - except Exception: - # If calculation fails, assume no match - face_distances[(face_id1, face_id2)] = 1.0 - - # Apply unique faces filtering - unique_faces = [] - seen_face_groups = set() - - for face_tuple in faces_list: - face_id = face_tuple[0] - - # Skip if we don't have encoding for this face - if face_id not in face_encodings: - unique_faces.append(face_tuple) - continue - - # Find all faces that match this one with high/medium confidence - matching_face_ids = set([face_id]) # Include self - for other_face_id in face_encodings.keys(): - if other_face_id != face_id: - distance = face_distances.get((face_id, other_face_id), 1.0) - confidence_pct = (1 - distance) * 100 - - # If this face matches with high/medium confidence - if confidence_pct >= 60: - matching_face_ids.add(other_face_id) - - # Create a sorted tuple to represent this group of matching faces - face_group = tuple(sorted(matching_face_ids)) - - # Only show this face if we haven't seen this group before - if face_group not in seen_face_groups: - seen_face_groups.add(face_group) - unique_faces.append(face_tuple) - - return unique_faces - - def _on_unique_faces_change(self, gui_components, original_faces, i, face_status, - date_from, date_to, date_processed_from, date_processed_to): - """Handle unique faces checkbox change""" - if gui_components['unique_var'].get(): - # Show progress message - print("šŸ”„ Applying unique faces filter...") - - # Apply unique faces filtering to the main face list - try: - filtered_faces = self._filter_unique_faces_from_list(original_faces) - print(f"āœ… Filter applied: {len(filtered_faces)} unique faces remaining") - - # Update the original_faces list with filtered results - # We need to return the filtered list to update the caller - return filtered_faces - - except Exception as e: - print(f"āš ļø Error applying filter: {e}") - # Revert checkbox state - gui_components['unique_var'].set(False) - return original_faces - else: - # Reload the original unfiltered face list - print("šŸ”„ Reloading all faces...") - - # Get fresh unfiltered faces from database - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - cursor.execute(query, params) - unfiltered_faces = cursor.fetchall() - - print(f"āœ… Reloaded: {len(unfiltered_faces)} total faces") - return unfiltered_faces - - def _on_compare_change(self, gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i): - """Handle compare checkbox change""" - # Toggle panel visibility - self._toggle_similar_faces_panel(gui_components) - - # Update similar faces if compare is now enabled - if gui_components['compare_var'].get(): - self._update_similar_faces(gui_components, face_id, tolerance, face_status, - face_selection_states, identify_data_cache, original_faces, i) \ No newline at end of file diff --git a/archive/modify_identified_gui.py b/archive/modify_identified_gui.py deleted file mode 100644 index 5b94b73..0000000 --- a/archive/modify_identified_gui.py +++ /dev/null @@ -1,1094 +0,0 @@ -#!/usr/bin/env python3 -""" -Modify Identified Faces GUI implementation for PunimTag -""" - -import os -import tkinter as tk -from tkinter import ttk, messagebox -from PIL import Image, ImageTk - -from config import DEFAULT_FACE_TOLERANCE -from database import DatabaseManager -from face_processing import FaceProcessor -from gui_core import GUICore - - -class ModifyIdentifiedGUI: - """Handles the View and Modify Identified Faces GUI interface""" - - def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0): - self.db = db_manager - self.face_processor = face_processor - self.verbose = verbose - self.gui_core = GUICore() - - def modifyidentified(self) -> int: - """Open the View and Modify Identified Faces window""" - # Simple tooltip implementation - class ToolTip: - 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 - - # Create the main window - root = tk.Tk() - root.title("View and Modify Identified Faces") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - temp_crops = [] - right_panel_images = [] # Keep PhotoImage refs alive - selected_person_id = None - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - temp_crops.clear() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self.gui_core.setup_window_size_saving(root, "gui_config.json") - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=2) - main_frame.rowconfigure(1, weight=1) - - # Title label - title_label = ttk.Label(main_frame, text="View and Modify Identified Faces", font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) - - # Left panel: People list - people_frame = ttk.LabelFrame(main_frame, text="People", padding="10") - people_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8)) - people_frame.columnconfigure(0, weight=1) - - # Search controls (Last Name) with label under the input (match auto-match style) - last_name_search_var = tk.StringVar() - 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=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) - search_btn.pack(side=tk.LEFT, padx=(0, 5)) - clear_btn = ttk.Button(buttons_row, text="Clear", width=6) - 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_canvas = tk.Canvas(people_frame, bg='white') - people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview) - people_list_inner = ttk.Frame(people_canvas) - people_canvas.create_window((0, 0), window=people_list_inner, anchor="nw") - people_canvas.configure(yscrollcommand=people_scrollbar.set) - - 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) - - # Right panel: Faces for selected person - faces_frame = ttk.LabelFrame(main_frame, text="Faces", padding="10") - faces_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) - faces_frame.columnconfigure(0, weight=1) - faces_frame.rowconfigure(0, weight=1) - - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - # Match auto-match UI: set gray background for left canvas and remove highlight border - try: - people_canvas.configure(bg=canvas_bg_color, highlightthickness=0) - except Exception: - pass - faces_canvas = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0) - faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=faces_canvas.yview) - faces_inner = ttk.Frame(faces_canvas) - faces_canvas.create_window((0, 0), window=faces_inner, anchor="nw") - faces_canvas.configure(yscrollcommand=faces_scrollbar.set) - - faces_inner.bind( - "", - lambda e: faces_canvas.configure(scrollregion=faces_canvas.bbox("all")) - ) - - # Track current person for responsive face grid - current_person_id = None - current_person_name = "" - resize_job = None - - # Track unmatched faces (temporary changes) - unmatched_faces = set() # All face IDs unmatched across people (for global save) - unmatched_by_person = {} # person_id -> set(face_id) for per-person undo - original_faces_data = [] # store original faces data for potential future use - - def on_faces_canvas_resize(event): - nonlocal resize_job - if current_person_id is None: - return - # Debounce re-render on resize - try: - if resize_job is not None: - root.after_cancel(resize_job) - except Exception: - pass - resize_job = root.after(150, lambda: show_person_faces(current_person_id, current_person_name)) - - faces_canvas.bind("", on_faces_canvas_resize) - - 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)) - - # Load people from DB with counts - people_data = [] # list of dicts: {id, name, count, first_name, last_name} - people_filtered = None # filtered subset based on last name search - - def load_people(): - nonlocal people_data - 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 - """ - ) - 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}" - - 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: - apply_last_name_filter() - except Exception: - pass - - # Wire up search controls now that helper functions exist - try: - search_btn.config(command=lambda: apply_last_name_filter()) - clear_btn.config(command=lambda: clear_last_name_filter()) - search_entry.bind('', lambda e: apply_last_name_filter()) - except Exception: - pass - - def apply_last_name_filter(): - nonlocal people_filtered - query = last_name_search_var.get().strip().lower() - if query: - people_filtered = [p for p in people_data if p.get('last_name', '').lower().find(query) != -1] - else: - people_filtered = None - populate_people_list() - # Update right panel based on filtered results - source = people_filtered if people_filtered is not None else people_data - if source: - # Load faces for the first person in the list - first = source[0] - try: - # Update selection state - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Bold the first label if present - first_row = people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - # Show faces for the first person - show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - # No matches: clear faces panel - clear_faces_panel() - - def clear_last_name_filter(): - nonlocal people_filtered - last_name_search_var.set("") - people_filtered = None - populate_people_list() - # After clearing, load faces for the first available person if any - if people_data: - first = people_data[0] - try: - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - first_row = people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - clear_faces_panel() - - def clear_faces_panel(): - for w in faces_inner.winfo_children(): - w.destroy() - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - temp_crops.clear() - right_panel_images.clear() - - def unmatch_face(face_id: int): - """Temporarily unmatch a face from the current person""" - nonlocal unmatched_faces, unmatched_by_person - unmatched_faces.add(face_id) - # Track per-person for Undo - person_set = unmatched_by_person.get(current_person_id) - if person_set is None: - person_set = set() - unmatched_by_person[current_person_id] = person_set - person_set.add(face_id) - # Refresh the display - show_person_faces(current_person_id, current_person_name) - - def undo_changes(): - """Undo all temporary changes""" - nonlocal unmatched_faces, unmatched_by_person - if current_person_id in unmatched_by_person: - for fid in list(unmatched_by_person[current_person_id]): - unmatched_faces.discard(fid) - unmatched_by_person[current_person_id].clear() - # Refresh the display - show_person_faces(current_person_id, current_person_name) - - def save_changes(): - """Save unmatched faces to database""" - if not unmatched_faces: - return - - # Confirm with user - result = messagebox.askyesno( - "Confirm Changes", - f"Are you sure you want to unlink {len(unmatched_faces)} face(s) from '{current_person_name}'?\n\n" - "This will make these faces unidentified again." - ) - - if not result: - return - - # Update database - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for face_id in unmatched_faces: - cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) - conn.commit() - - # Store count for message before clearing - unlinked_count = len(unmatched_faces) - - # Clear unmatched faces and refresh - unmatched_faces.clear() - original_faces_data.clear() - - # Refresh people list to update counts - load_people() - populate_people_list() - - # Refresh faces display - show_person_faces(current_person_id, current_person_name) - - messagebox.showinfo("Changes Saved", f"Successfully unlinked {unlinked_count} face(s) from '{current_person_name}'.") - - def show_person_faces(person_id: int, person_name: str): - nonlocal current_person_id, current_person_name, unmatched_faces, original_faces_data - current_person_id = person_id - current_person_name = person_name - clear_faces_panel() - - # Determine how many columns fit the available width - available_width = faces_canvas.winfo_width() - if available_width <= 1: - available_width = faces_frame.winfo_width() - tile_width = 150 # approx tile + padding - cols = max(1, available_width // tile_width) - - # Header row - header = ttk.Label(faces_inner, text=f"Faces for: {person_name}", font=("Arial", 12, "bold")) - header.grid(row=0, column=0, columnspan=cols, sticky=tk.W, pady=(0, 5)) - - # Control buttons row - button_frame = ttk.Frame(faces_inner) - button_frame.grid(row=1, column=0, columnspan=cols, sticky=tk.W, pady=(0, 10)) - - # Enable Undo only if current person has unmatched faces - current_has_unmatched = bool(unmatched_by_person.get(current_person_id)) - undo_btn = ttk.Button(button_frame, text="↶ Undo changes", - command=lambda: undo_changes(), - state="disabled" if not current_has_unmatched else "normal") - undo_btn.pack(side=tk.LEFT, padx=(0, 10)) - - # Note: Save button moved to bottom control bar - - # Query faces for this person - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """ - SELECT f.id, f.location, ph.path, ph.filename - FROM faces f - JOIN photos ph ON ph.id = f.photo_id - WHERE f.person_id = ? - ORDER BY f.id DESC - """, - (person_id,) - ) - rows = cursor.fetchall() - - # Filter out unmatched faces - visible_rows = [row for row in rows if row[0] not in unmatched_faces] - - if not visible_rows: - ttk.Label(faces_inner, text="No faces found." if not rows else "All faces unmatched.", foreground="gray").grid(row=2, column=0, sticky=tk.W) - return - - # Grid thumbnails with responsive column count - row_index = 2 # Start after header and buttons - col_index = 0 - for face_id, location, photo_path, filename in visible_rows: - crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) - thumb = None - if crop_path and os.path.exists(crop_path): - try: - img = Image.open(crop_path) - img.thumbnail((130, 130), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - temp_crops.append(crop_path) - right_panel_images.append(photo_img) - thumb = photo_img - except Exception: - thumb = None - - tile = ttk.Frame(faces_inner, padding="5") - tile.grid(row=row_index, column=col_index, padx=5, pady=5, sticky=tk.N) - - # Create a frame for the face image with X button overlay - face_frame = ttk.Frame(tile) - face_frame.grid(row=0, column=0) - - canvas = tk.Canvas(face_frame, width=130, height=130, bg=canvas_bg_color, highlightthickness=0) - canvas.grid(row=0, column=0) - if thumb is not None: - canvas.create_image(65, 65, image=thumb) - else: - canvas.create_text(65, 65, text="šŸ–¼ļø", fill="gray") - - # X button to unmatch face - pin exactly to the canvas' top-right corner - x_canvas = tk.Canvas(face_frame, width=12, height=12, bg='red', - highlightthickness=0, relief="flat") - x_canvas.create_text(6, 6, text="āœ–", fill="white", font=("Arial", 8, "bold")) - # Click handler - x_canvas.bind("", lambda e, fid=face_id: unmatch_face(fid)) - # Hover highlight: change bg, show white outline, and hand cursor - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2")) - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="red", highlightthickness=0, cursor="")) - # Anchor to the canvas' top-right regardless of layout/size - try: - x_canvas.place(in_=canvas, relx=1.0, x=0, y=0, anchor='ne') - except Exception: - # Fallback to absolute coords if relative placement fails - x_canvas.place(x=118, y=0) - - ttk.Label(tile, text=f"ID {face_id}", foreground="gray").grid(row=1, column=0) - - col_index += 1 - if col_index >= cols: - col_index = 0 - row_index += 1 - - def populate_people_list(): - for w in people_list_inner.winfo_children(): - w.destroy() - source = people_filtered if people_filtered is not None else people_data - if not source: - empty_label = ttk.Label(people_list_inner, text="No people match filter", foreground="gray") - empty_label.grid(row=0, column=0, sticky=tk.W, pady=4) - return - for idx, person in enumerate(source): - row = ttk.Frame(people_list_inner) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=4) - # Freeze per-row values to avoid late-binding issues - row_person = person - row_idx = idx - - # Make person name clickable - def make_click_handler(p_id, p_name, p_idx): - def on_click(event): - nonlocal selected_person_id - # Reset all labels to normal font - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Set clicked label to bold - event.widget.config(font=("Arial", 10, "bold")) - selected_person_id = p_id - # Show faces for this person - show_person_faces(p_id, p_name) - return on_click - - # Edit (rename) button - def start_edit_person(row_frame, person_record, row_index): - for w in row_frame.winfo_children(): - w.destroy() - - # Use pre-loaded data instead of database query - cur_first = person_record.get('first_name', '') - cur_last = person_record.get('last_name', '') - cur_middle = person_record.get('middle_name', '') - cur_maiden = person_record.get('maiden_name', '') - cur_dob = person_record.get('date_of_birth', '') - - # Create a larger container frame for the text boxes and labels - edit_container = ttk.Frame(row_frame) - edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # First name field with label - first_frame = ttk.Frame(edit_container) - first_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - first_var = tk.StringVar(value=cur_first) - first_entry = ttk.Entry(first_frame, textvariable=first_var, width=15) - first_entry.pack(side=tk.TOP) - first_entry.focus_set() - - first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray") - first_label.pack(side=tk.TOP, pady=(2, 0)) - - # Last name field with label - last_frame = ttk.Frame(edit_container) - last_frame.grid(row=0, column=1, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - last_var = tk.StringVar(value=cur_last) - last_entry = ttk.Entry(last_frame, textvariable=last_var, width=15) - last_entry.pack(side=tk.TOP) - - last_label = ttk.Label(last_frame, text="Last Name", font=("Arial", 8), foreground="gray") - last_label.pack(side=tk.TOP, pady=(2, 0)) - - # Middle name field with label - middle_frame = ttk.Frame(edit_container) - middle_frame.grid(row=0, column=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - middle_var = tk.StringVar(value=cur_middle) - middle_entry = ttk.Entry(middle_frame, textvariable=middle_var, width=15) - middle_entry.pack(side=tk.TOP) - - middle_label = ttk.Label(middle_frame, text="Middle Name", font=("Arial", 8), foreground="gray") - middle_label.pack(side=tk.TOP, pady=(2, 0)) - - # Maiden name field with label - maiden_frame = ttk.Frame(edit_container) - maiden_frame.grid(row=1, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - maiden_var = tk.StringVar(value=cur_maiden) - maiden_entry = ttk.Entry(maiden_frame, textvariable=maiden_var, width=15) - maiden_entry.pack(side=tk.TOP) - - maiden_label = ttk.Label(maiden_frame, text="Maiden Name", font=("Arial", 8), foreground="gray") - maiden_label.pack(side=tk.TOP, pady=(2, 0)) - - # Date of birth field with label and calendar button - dob_frame = ttk.Frame(edit_container) - dob_frame.grid(row=1, column=1, columnspan=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - # Create a frame for the date picker - date_picker_frame = ttk.Frame(dob_frame) - date_picker_frame.pack(side=tk.TOP) - - dob_var = tk.StringVar(value=cur_dob) - dob_entry = ttk.Entry(date_picker_frame, textvariable=dob_var, width=20, state='readonly') - dob_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Calendar button - calendar_btn = ttk.Button(date_picker_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) - calendar_btn.pack(side=tk.RIGHT, padx=(5, 0)) - - dob_label = ttk.Label(dob_frame, text="Date of Birth", font=("Arial", 8), foreground="gray") - dob_label.pack(side=tk.TOP, pady=(2, 0)) - - def open_calendar(): - """Open a visual calendar dialog to select date of birth""" - from datetime import datetime, date - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(root) - calendar_window.title("Select Date of Birth") - calendar_window.resizable(False, False) - calendar_window.transient(root) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = dob_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use default - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - else: - # Default to 25 years ago - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Configure custom styles for better visual highlighting - style = ttk.Style() - - # Selected date style - bright blue background with white text - style.configure("Selected.TButton", - background="#0078d4", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=2) - style.map("Selected.TButton", - background=[("active", "#106ebe")], - relief=[("pressed", "sunken")]) - - # Today's date style - orange background - style.configure("Today.TButton", - background="#ff8c00", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=1) - style.map("Today.TButton", - background=[("active", "#e67e00")], - relief=[("pressed", "sunken")]) - - # Calendar-specific normal button style (don't affect global TButton) - style.configure("Calendar.TButton", - font=("Arial", 9), - relief="flat") - style.map("Calendar.TButton", - background=[["active", "#e1e1e1"]], - relief=[["pressed", "sunken"]]) - - # 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() - - def update_calendar(): - """Update the calendar display""" - # Clear existing calendar - for widget in calendar_frame.winfo_children(): - widget.destroy() - - # Update header - month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") - - # 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): - label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) - label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") - - # Calendar days - for week_num, week in enumerate(cal): - for day_num, day in enumerate(week): - if day == 0: - # Empty cell - label = ttk.Label(calendar_frame, text="") - label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - else: - # Day button - 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 with prominent style - 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), - width=3, style="Calendar.TButton") - day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - - # Check if this day should be highlighted - is_today = (display_year == current_date.year and - display_month == current_date.month and - day == current_date.day) - is_selected = (selected_date and - selected_date.year == display_year and - selected_date.month == display_month and - selected_date.day == day) - - if is_selected: - day_btn.config(style="Selected.TButton") - elif is_today: - day_btn.config(style="Today.TButton") - - # Navigation functions - def prev_year(): - nonlocal display_year - display_year = max(1900, display_year - 1) - update_calendar() - - def next_year(): - nonlocal display_year - display_year = min(current_date.year, display_year + 1) - update_calendar() - - def prev_month(): - nonlocal display_month, display_year - if display_month > 1: - display_month -= 1 - else: - display_month = 12 - display_year = max(1900, display_year - 1) - update_calendar() - - def next_month(): - nonlocal display_month, display_year - if display_month < 12: - display_month += 1 - else: - display_month = 1 - display_year = min(current_date.year, display_year + 1) - update_calendar() - - # Navigation buttons - prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) - prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) - - prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) - prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) - - month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) - month_year_label.pack(side=tk.LEFT, padx=5) - - next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) - next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) - - 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: - date_str = selected_date.strftime('%Y-%m-%d') - dob_var.set(date_str) - calendar_window.destroy() - else: - messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") - - def cancel_selection(): - """Cancel date selection""" - calendar_window.destroy() - - # Buttons - 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 - update_calendar() - - def save_rename(): - new_first = first_var.get().strip() - new_last = last_var.get().strip() - new_middle = middle_var.get().strip() - new_maiden = maiden_var.get().strip() - new_dob = dob_var.get().strip() - - if not new_first and not new_last: - messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.") - return - - # Check for duplicates in local data first (based on first and last name only) - for person in people_data: - if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last: - display_name = f"{new_last}, {new_first}".strip(", ").strip() - messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.") - return - - # Single database access - save to database - 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 = ?', - (new_first, new_last, new_middle, new_maiden, new_dob, person_record['id'])) - conn.commit() - - # Update local data structure - person_record['first_name'] = new_first - person_record['last_name'] = new_last - person_record['middle_name'] = new_middle - person_record['maiden_name'] = new_maiden - person_record['date_of_birth'] = new_dob - - # Recreate the full display name with all available information - name_parts = [] - if new_first: - name_parts.append(new_first) - if new_middle: - name_parts.append(new_middle) - if new_last: - name_parts.append(new_last) - if new_maiden: - name_parts.append(f"({new_maiden})") - - full_name = ' '.join(name_parts) if name_parts else "Unknown" - - # Create detailed display with date of birth if available - display_name = full_name - if new_dob: - display_name += f" - Born: {new_dob}" - - person_record['name'] = display_name - person_record['full_name'] = full_name - - # Refresh list - current_selected_id = person_record['id'] - populate_people_list() - # Reselect and refresh right panel header if needed - if selected_person_id == current_selected_id or selected_person_id is None: - # Find updated name - updated = next((p for p in people_data if p['id'] == current_selected_id), None) - if updated: - # Bold corresponding label - for child in people_list_inner.winfo_children(): - # child is row frame: contains label and button - widgets = child.winfo_children() - if not widgets: - continue - lbl = widgets[0] - if isinstance(lbl, ttk.Label) and lbl.cget('text').startswith(updated['name'] + " ("): - lbl.config(font=("Arial", 10, "bold")) - break - # Update right panel header by re-showing faces - show_person_faces(updated['id'], updated['name']) - - def cancel_edit(): - # Rebuild the row back to label + edit - for w in row_frame.winfo_children(): - w.destroy() - rebuild_row(row_frame, person_record, row_index) - - save_btn = ttk.Button(row_frame, text="šŸ’¾", width=3, command=save_rename) - save_btn.pack(side=tk.LEFT, padx=(5, 0)) - cancel_btn = ttk.Button(row_frame, text="āœ–", width=3, command=cancel_edit) - cancel_btn.pack(side=tk.LEFT, padx=(5, 0)) - - # Configure custom disabled button style for better visibility - style = ttk.Style() - style.configure("Disabled.TButton", - background="#d3d3d3", # Light gray background - foreground="#808080", # Dark gray text - relief="flat", - borderwidth=1) - - def validate_save_button(): - """Enable/disable save button based on required fields""" - first_val = first_var.get().strip() - last_val = last_var.get().strip() - dob_val = dob_var.get().strip() - - # Enable save button only if both name fields and date of birth are provided - has_first = bool(first_val) - has_last = bool(last_val) - has_dob = bool(dob_val) - - if has_first and has_last and has_dob: - save_btn.config(state="normal") - # Reset to normal styling when enabled - save_btn.config(style="TButton") - else: - save_btn.config(state="disabled") - # Apply custom disabled styling for better visibility - save_btn.config(style="Disabled.TButton") - - # Set up validation callbacks for all input fields - first_var.trace('w', lambda *args: validate_save_button()) - last_var.trace('w', lambda *args: validate_save_button()) - middle_var.trace('w', lambda *args: validate_save_button()) - maiden_var.trace('w', lambda *args: validate_save_button()) - dob_var.trace('w', lambda *args: validate_save_button()) - - # Initial validation - validate_save_button() - - # Keyboard shortcuts (only work when save button is enabled) - 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()) - - def rebuild_row(row_frame, p, i): - # Edit button (on the left) - edit_btn = ttk.Button(row_frame, text="āœļø", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii)) - 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"{p['name']} ({p['count']})", font=("Arial", 10)) - name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) - name_lbl.bind("", make_click_handler(p['id'], p['name'], i)) - name_lbl.config(cursor="hand2") - # Bold if selected - if (selected_person_id is None and i == 0) or (selected_person_id == p['id']): - name_lbl.config(font=("Arial", 10, "bold")) - - # Build row contents with edit button - rebuild_row(row, row_person, row_idx) - - # Initial load - load_people() - populate_people_list() - - # Show first person's faces by default and mark selected - if people_data: - selected_person_id = people_data[0]['id'] - show_person_faces(people_data[0]['id'], people_data[0]['name']) - - # Control buttons - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) - - def on_quit(): - nonlocal window_destroyed - # Warn if there are pending unmatched faces (unsaved changes) - try: - if unmatched_faces: - result = messagebox.askyesnocancel( - "Unsaved Changes", - "You have pending changes that are not saved.\n\n" - "Yes: Save and quit\n" - "No: Quit without saving\n" - "Cancel: Return to window" - ) - if result is None: - # Cancel - return - if result is True: - # Save then quit - on_save_all_changes() - # If result is False, fall through and quit without saving - except Exception: - # If any issue occurs, proceed to normal quit - pass - on_closing() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - def on_save_all_changes(): - # Use global unmatched_faces set; commit all across people - nonlocal unmatched_faces - if not unmatched_faces: - messagebox.showinfo("Nothing to Save", "There are no pending changes to save.") - return - result = messagebox.askyesno( - "Confirm Save", - f"Unlink {len(unmatched_faces)} face(s) across all people?\n\nThis will make them unidentified." - ) - if not result: - return - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for face_id in unmatched_faces: - cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) - conn.commit() - count = len(unmatched_faces) - unmatched_faces.clear() - # Refresh people list and right panel for current selection - load_people() - populate_people_list() - if current_person_id is not None and current_person_name: - show_person_faces(current_person_id, current_person_name) - messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).") - - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) - quit_btn.pack(side=tk.RIGHT) - save_btn_bottom = ttk.Button(control_frame, text="šŸ’¾ Save changes", command=on_save_all_changes) - save_btn_bottom.pack(side=tk.RIGHT, padx=(0, 10)) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Main event loop - try: - root.mainloop() - except tk.TclError: - pass # Window was destroyed - - return 0 - - diff --git a/archive/photo_tagger_original_backup.py b/archive/photo_tagger_original_backup.py deleted file mode 100644 index 340c2f5..0000000 --- a/archive/photo_tagger_original_backup.py +++ /dev/null @@ -1,7070 +0,0 @@ -#!/usr/bin/env python3 -""" -PunimTag CLI - Minimal Photo Face Tagger -Simple command-line tool for face recognition and photo tagging -""" - -import os -import sqlite3 -import argparse -import face_recognition -from pathlib import Path -from PIL import Image, ImageDraw, ImageFont -from PIL.ExifTags import TAGS -import pickle -import numpy as np -from typing import List, Dict, Tuple, Optional -import sys -import tempfile -import subprocess -import threading -import time -from datetime import datetime -from functools import lru_cache -from contextlib import contextmanager - - -class PhotoTagger: - def __init__(self, db_path: str = "data/photos.db", verbose: int = 0, debug: bool = False): - """Initialize the photo tagger with database""" - self.db_path = db_path - self.verbose = verbose - self.debug = debug - self._face_encoding_cache = {} - self._image_cache = {} - self._db_connection = None - self._db_lock = threading.Lock() - self.init_database() - - @contextmanager - def get_db_connection(self): - """Context manager for database connections with connection pooling""" - with self._db_lock: - if self._db_connection is None: - self._db_connection = sqlite3.connect(self.db_path) - self._db_connection.row_factory = sqlite3.Row - try: - yield self._db_connection - except Exception: - self._db_connection.rollback() - raise - else: - self._db_connection.commit() - - def close_db_connection(self): - """Close database connection""" - with self._db_lock: - if self._db_connection: - self._db_connection.close() - self._db_connection = None - - @lru_cache(maxsize=1000) - def _get_cached_face_encoding(self, face_id: int, encoding_bytes: bytes) -> np.ndarray: - """Cache face encodings to avoid repeated numpy conversions""" - return np.frombuffer(encoding_bytes, dtype=np.float64) - - def _clear_caches(self): - """Clear all caches to free memory""" - self._face_encoding_cache.clear() - self._image_cache.clear() - self._get_cached_face_encoding.cache_clear() - - def cleanup(self): - """Clean up resources and close connections""" - self._clear_caches() - self.close_db_connection() - - def _cleanup_face_crops(self, current_face_crop_path=None): - """Clean up face crop files and caches""" - # Clean up current face crop if provided - if current_face_crop_path and os.path.exists(current_face_crop_path): - try: - os.remove(current_face_crop_path) - except: - pass # Ignore cleanup errors - - # Clean up all cached face crop files - for cache_key, cached_path in list(self._image_cache.items()): - if os.path.exists(cached_path): - try: - os.remove(cached_path) - except: - pass # Ignore cleanup errors - - # Clear caches - self._clear_caches() - - def _deduplicate_tags(self, tag_list): - """Remove duplicate tags from a list while preserving order (case insensitive)""" - seen = set() - unique_tags = [] - for tag in tag_list: - if tag.lower() not in seen: - seen.add(tag.lower()) - unique_tags.append(tag) - return unique_tags - - def _parse_tags_string(self, tags_string): - """Parse a comma-separated tags string into a list, handling empty strings and whitespace""" - if not tags_string or tags_string.strip() == "": - return [] - # Split by comma and strip whitespace from each tag - tags = [tag.strip() for tag in tags_string.split(",")] - # Remove empty strings that might result from splitting - return [tag for tag in tags if tag] - - def _get_tag_id_by_name(self, tag_name, tag_name_to_id_map): - """Get tag ID by name, creating the tag if it doesn't exist""" - if tag_name in tag_name_to_id_map: - return tag_name_to_id_map[tag_name] - return None - - def _get_tag_name_by_id(self, tag_id, tag_id_to_name_map): - """Get tag name by ID""" - return tag_id_to_name_map.get(tag_id, f"Unknown Tag {tag_id}") - - def _load_tag_mappings(self): - """Load tag name to ID and ID to name mappings from database""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') - tag_id_to_name = {} - tag_name_to_id = {} - for row in cursor.fetchall(): - tag_id, tag_name = row - tag_id_to_name[tag_id] = tag_name - tag_name_to_id[tag_name] = tag_id - return tag_id_to_name, tag_name_to_id - - def _get_existing_tag_ids_for_photo(self, photo_id): - """Get list of tag IDs for a photo from database""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT ptl.tag_id - FROM phototaglinkage ptl - WHERE ptl.photo_id = ? - ORDER BY ptl.created_date - ''', (photo_id,)) - return [row[0] for row in cursor.fetchall()] - - def _setup_window_size_saving(self, root, config_file="gui_config.json"): - """Set up window size saving functionality""" - import json - import tkinter as tk - - # Load saved window size - default_size = "600x500" - saved_size = default_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_size) - except: - saved_size = default_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 tk.TclError: - # 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('', save_window_size) - return saved_size - - def init_database(self): - """Create database tables if they don't exist""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Photos table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT UNIQUE NOT NULL, - filename TEXT NOT NULL, - date_added DATETIME DEFAULT CURRENT_TIMESTAMP, - date_taken DATE, - processed BOOLEAN DEFAULT 0 - ) - ''') - - # People table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS people ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - middle_name TEXT, - maiden_name TEXT, - date_of_birth DATE, - created_date DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(first_name, last_name, middle_name, maiden_name, date_of_birth) - ) - ''') - - # Faces table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS faces ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - photo_id INTEGER NOT NULL, - person_id INTEGER, - encoding BLOB NOT NULL, - location TEXT NOT NULL, - confidence REAL DEFAULT 0.0, - quality_score REAL DEFAULT 0.0, - is_primary_encoding BOOLEAN DEFAULT 0, - FOREIGN KEY (photo_id) REFERENCES photos (id), - FOREIGN KEY (person_id) REFERENCES people (id) - ) - ''') - - # Person encodings table for multiple encodings per person - cursor.execute(''' - CREATE TABLE IF NOT EXISTS person_encodings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - person_id INTEGER NOT NULL, - face_id INTEGER NOT NULL, - encoding BLOB NOT NULL, - quality_score REAL DEFAULT 0.0, - created_date DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (person_id) REFERENCES people (id), - FOREIGN KEY (face_id) REFERENCES faces (id) - ) - ''') - - # Tags table - holds only tag information - cursor.execute(''' - CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tag_name TEXT UNIQUE NOT NULL, - created_date DATETIME DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Photo-Tag linkage table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS phototaglinkage ( - linkage_id INTEGER PRIMARY KEY AUTOINCREMENT, - photo_id INTEGER NOT NULL, - tag_id INTEGER NOT NULL, - created_date DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (photo_id) REFERENCES photos (id), - FOREIGN KEY (tag_id) REFERENCES tags (id), - UNIQUE(photo_id, tag_id) - ) - ''') - - # Add indexes for better performance - cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_person_id ON faces(person_id)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_photo_id ON faces(photo_id)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_processed ON photos(processed)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_quality ON faces(quality_score)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_person_id ON person_encodings(person_id)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_quality ON person_encodings(quality_score)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_added ON photos(date_added)') - - # Migration: Add date_taken column to existing photos table if it doesn't exist - try: - cursor.execute('ALTER TABLE photos ADD COLUMN date_taken DATE') - if self.verbose >= 1: - print("āœ… Added date_taken column to photos table") - except Exception: - # Column already exists, ignore - pass - - # Migration: Add date_added column to existing photos table if it doesn't exist - try: - cursor.execute('ALTER TABLE photos ADD COLUMN date_added DATETIME DEFAULT CURRENT_TIMESTAMP') - if self.verbose >= 1: - print("āœ… Added date_added column to photos table") - except Exception: - # Column already exists, ignore - pass - - - if self.verbose >= 1: - print(f"āœ… Database initialized: {self.db_path}") - - def _extract_photo_date(self, photo_path: str) -> Optional[str]: - """Extract date taken from photo EXIF data""" - try: - with Image.open(photo_path) as image: - exifdata = image.getexif() - - # Look for date taken in EXIF tags - date_tags = [ - 306, # DateTime - 36867, # DateTimeOriginal - 36868, # DateTimeDigitized - ] - - for tag_id in date_tags: - if tag_id in exifdata: - date_str = exifdata[tag_id] - if date_str: - # Parse EXIF date format (YYYY:MM:DD HH:MM:SS) - try: - date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S') - return date_obj.strftime('%Y-%m-%d') - except ValueError: - # Try alternative format - try: - date_obj = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') - return date_obj.strftime('%Y-%m-%d') - except ValueError: - continue - - return None - except Exception as e: - if self.verbose >= 2: - print(f" āš ļø Could not extract date from {os.path.basename(photo_path)}: {e}") - return None - - def scan_folder(self, folder_path: str, recursive: bool = True) -> int: - """Scan folder for photos and add to database""" - # BREAKPOINT: Set breakpoint here for debugging - - - if not os.path.exists(folder_path): - print(f"āŒ Folder not found: {folder_path}") - return 0 - - photo_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff'} - - found_photos = [] - - # BREAKPOINT: Set breakpoint here for debugging - - if recursive: - for root, dirs, files in os.walk(folder_path): - for file in files: - file_ext = Path(file).suffix.lower() - if file_ext in photo_extensions: - photo_path = os.path.join(root, file) - found_photos.append((photo_path, file)) - else: - for file in os.listdir(folder_path): - file_ext = Path(file).suffix.lower() - if file_ext in photo_extensions: - photo_path = os.path.join(folder_path, file) - found_photos.append((photo_path, file)) - - if not found_photos: - print(f"šŸ“ No photos found in {folder_path}") - return 0 - - # Add to database - # BREAKPOINT: Set breakpoint here for debugging - - with self.get_db_connection() as conn: - cursor = conn.cursor() - added_count = 0 - - for photo_path, filename in found_photos: - try: - # Extract date taken from EXIF data - date_taken = self._extract_photo_date(photo_path) - - cursor.execute( - 'INSERT OR IGNORE INTO photos (path, filename, date_taken) VALUES (?, ?, ?)', - (photo_path, filename, date_taken) - ) - if cursor.rowcount > 0: - added_count += 1 - if self.verbose >= 2: - date_info = f" (taken: {date_taken})" if date_taken else " (no date)" - print(f" šŸ“ø Added: {filename}{date_info}") - elif self.verbose >= 3: - print(f" šŸ“ø Already exists: {filename}") - except Exception as e: - print(f"āš ļø Error adding {filename}: {e}") - - - print(f"šŸ“ Found {len(found_photos)} photos, added {added_count} new photos") - return added_count - - - def process_faces(self, limit: int = 50, model: str = "hog") -> int: - """Process unprocessed photos for faces""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - cursor.execute( - 'SELECT id, path, filename FROM photos WHERE processed = 0 LIMIT ?', - (limit,) - ) - unprocessed = cursor.fetchall() - - if not unprocessed: - print("āœ… No unprocessed photos found") - return 0 - - print(f"šŸ” Processing {len(unprocessed)} photos for faces...") - processed_count = 0 - - for photo_id, photo_path, filename in unprocessed: - if not os.path.exists(photo_path): - print(f"āŒ File not found: {filename}") - cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,)) - continue - - try: - # Load image and find faces - if self.verbose >= 1: - print(f"šŸ“ø Processing: {filename}") - elif self.verbose == 0: - print(".", end="", flush=True) - - if self.verbose >= 2: - print(f" šŸ” Loading image: {photo_path}") - - image = face_recognition.load_image_file(photo_path) - face_locations = face_recognition.face_locations(image, model=model) - - if face_locations: - face_encodings = face_recognition.face_encodings(image, face_locations) - if self.verbose >= 1: - print(f" šŸ‘¤ Found {len(face_locations)} faces") - - # Save faces to database with quality scores - for i, (encoding, location) in enumerate(zip(face_encodings, face_locations)): - # Calculate face quality score - quality_score = self._calculate_face_quality_score(image, location) - - cursor.execute( - 'INSERT INTO faces (photo_id, encoding, location, quality_score) VALUES (?, ?, ?, ?)', - (photo_id, encoding.tobytes(), str(location), quality_score) - ) - if self.verbose >= 3: - print(f" Face {i+1}: {location} (quality: {quality_score:.2f})") - else: - if self.verbose >= 1: - print(f" šŸ‘¤ No faces found") - elif self.verbose >= 2: - print(f" šŸ‘¤ {filename}: No faces found") - - # Mark as processed - cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,)) - processed_count += 1 - - except Exception as e: - print(f"āŒ Error processing {filename}: {e}") - cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,)) - - if self.verbose == 0: - print() # New line after dots - print(f"āœ… Processed {processed_count} photos") - return processed_count - - def identify_faces(self, batch_size: int = 20, show_faces: bool = False, tolerance: float = 0.6, - date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int: - """Interactive face identification with optimized performance""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - query += ' LIMIT ?' - params.append(batch_size) - - cursor.execute(query, params) - - unidentified = cursor.fetchall() - - if not unidentified: - print("šŸŽ‰ All faces have been identified!") - return 0 - - print(f"\nšŸ‘¤ Found {len(unidentified)} unidentified faces") - print("Commands: [name] = identify, 's' = skip, 'q' = quit, 'list' = show people\n") - - # Pre-fetch all needed data to avoid repeated database queries - print("šŸ“Š Pre-fetching data for optimal performance...") - identify_data_cache = {} - - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Pre-fetch all photo paths for unidentified faces - photo_ids = [face[1] for face in unidentified] # face[1] is photo_id - if photo_ids: - placeholders = ','.join('?' * len(photo_ids)) - cursor.execute(f'SELECT id, path, filename FROM photos WHERE id IN ({placeholders})', photo_ids) - identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()} - - # Pre-fetch all people names for dropdown - cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people] - # Pre-fetch unique last names for autocomplete (no DB during typing) - cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') - _last_rows = cursor.fetchall() - identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) - - print(f"āœ… Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names") - - identified_count = 0 - - # Use integrated GUI with image and input - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import json - import os - - # Create the main window once - root = tk.Tk() - root.title("Face Identification") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - selected_person_id = None - force_exit = False - - # Track current face crop path for cleanup - current_face_crop_path = None - - # Hide window initially to prevent flash at corner - root.withdraw() - - def save_all_pending_identifications(): - """Save all pending identifications from face_person_names""" - nonlocal identified_count - saved_count = 0 - - for face_id, person_data in face_person_names.items(): - # Handle person data dict format - if isinstance(person_data, dict): - first_name = person_data.get('first_name', '').strip() - last_name = person_data.get('last_name', '').strip() - date_of_birth = person_data.get('date_of_birth', '').strip() - middle_name = person_data.get('middle_name', '').strip() - maiden_name = person_data.get('maiden_name', '').strip() - - # Only save if we have at least a first or last name - if first_name or last_name: - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Add person if doesn't exist - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - if display_name not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(display_name) - identify_data_cache['people_names'].sort() # Keep sorted - # Keep last names cache updated in-session - if last_name: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if last_name not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(last_name) - identify_data_cache['last_names'].sort() - - # Assign face to person - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, face_id) - ) - - # Update person encodings - self._update_person_encodings(person_id) - saved_count += 1 - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - print(f"āœ… Saved identification: {display_name}") - - except Exception as e: - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - print(f"āŒ Error saving identification for {display_name}: {e}") - else: - # Handle legacy string format - skip for now as it doesn't have complete data - pass - - if saved_count > 0: - identified_count += saved_count - print(f"šŸ’¾ Saved {saved_count} pending identifications") - - return saved_count - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal command, waiting_for_input, window_destroyed, current_face_crop_path, force_exit - - # First check for selected similar faces without person name - if not validate_navigation(): - return # Cancel close - - # Check if there are pending identifications (faces with complete data but not yet saved) - pending_identifications = {} - for k, v in face_person_names.items(): - if k not in face_status or face_status[k] != 'identified': - # Handle person data dict format - if isinstance(v, dict): - first_name = v.get('first_name', '').strip() - last_name = v.get('last_name', '').strip() - date_of_birth = v.get('date_of_birth', '').strip() - - # Check if we have complete data (both first and last name, plus date of birth) - if first_name and last_name and date_of_birth: - pending_identifications[k] = v - else: - # Handle legacy string format - not considered complete without date of birth - pass - - if pending_identifications: - # Ask user if they want to save pending identifications - result = messagebox.askyesnocancel( - "Save Pending Identifications?", - f"You have {len(pending_identifications)} pending identifications.\n\n" - "Do you want to save them before closing?\n\n" - "• Yes: Save all pending identifications and close\n" - "• No: Close without saving\n" - "• Cancel: Return to identification" - ) - - if result is True: # Yes - Save and close - save_all_pending_identifications() - command = 'q' - waiting_for_input = False - elif result is False: # No - Close without saving - command = 'q' - waiting_for_input = False - else: # Cancel - Don't close - return - - # Clean up face crops and caches - self._cleanup_face_crops(current_face_crop_path) - self.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Force process termination - force_exit = True - root.quit() - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self._setup_window_size_saving(root) - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) # Left panel - main_frame.columnconfigure(1, weight=1) # Right panel for similar faces - # Configure row weights to minimize spacing around Unique checkbox - main_frame.rowconfigure(2, weight=0) # Unique checkbox row - no expansion - main_frame.rowconfigure(3, weight=1) # Main panels row - expandable - - # Photo info - info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold")) - info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) - - # Calendar dialog function for date filter - def open_date_calendar(date_var, title): - """Open a visual calendar dialog to select date""" - from datetime import datetime, date, timedelta - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(root) - calendar_window.title(title) - calendar_window.resizable(False, False) - calendar_window.transient(root) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = date_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use current date - display_year = current_date.year - display_month = current_date.month - selected_date = None - else: - # Default to current date - display_year = current_date.year - display_month = current_date.month - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Create custom style for calendar buttons - style = ttk.Style() - style.configure("Calendar.TButton", padding=(2, 2)) - style.map("Calendar.TButton", - background=[("active", "#e1e1e1")], - relief=[("pressed", "sunken")]) - - # 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 (created once, updated later) - 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() - - # Button text and style - button_text = str(day) - if is_today: - button_text = f"•{day}•" # Mark today - - day_btn = ttk.Button(calendar_frame, text=button_text, - style="Calendar.TButton" if not is_selected else "Calendar.TButton", - command=lambda d=day_date: select_date(d)) - day_btn.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew") - - # Highlight selected date - if is_selected: - day_btn.configure(style="Calendar.TButton") - # Add visual indication of selection - day_btn.configure(text=f"[{day}]") - - def select_date(selected_day): - """Select a date and close calendar""" - nonlocal selected_date - selected_date = selected_day - date_var.set(selected_day.strftime('%Y-%m-%d')) - calendar_window.destroy() - - 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 - update_calendar() - - def prev_year(): - nonlocal display_year - display_year -= 1 - update_calendar() - - def next_year(): - nonlocal display_year - display_year += 1 - 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 clear_date(): - """Clear the selected date""" - date_var.set("") - calendar_window.destroy() - - # Clear button - clear_btn = ttk.Button(buttons_frame, text="Clear", command=clear_date) - clear_btn.pack(side=tk.LEFT) - - # Cancel button - cancel_btn = ttk.Button(buttons_frame, text="Cancel", command=calendar_window.destroy) - cancel_btn.pack(side=tk.RIGHT) - - # Initial calendar display - update_calendar() - - # Unique faces only checkbox variable (must be defined before widgets that use it) - unique_faces_var = tk.BooleanVar() - - # Define update_similar_faces function first - reusing auto-match display logic - def update_similar_faces(): - """Update the similar faces panel when compare is enabled - reuses auto-match display logic""" - nonlocal similar_faces_data, similar_face_vars, similar_face_images, similar_face_crops, face_selection_states - - # Note: Selection states are now saved automatically via callbacks (auto-match style) - - # Clear existing similar faces - for widget in similar_scrollable_frame.winfo_children(): - widget.destroy() - similar_face_vars.clear() - similar_face_images.clear() - - # Clean up existing face crops - for crop_path in similar_face_crops: - try: - if os.path.exists(crop_path): - os.remove(crop_path) - except: - pass - similar_face_crops.clear() - - if compare_var.get(): - # Use the same filtering and sorting logic as auto-match (no unique faces filter for similar faces) - unidentified_similar_faces = self._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status) - - if unidentified_similar_faces: - # Get current face_id for selection state management - current_face_id = original_faces[i][0] # Get current face_id - - # Reuse auto-match display logic for similar faces - self._display_similar_faces_in_panel(similar_scrollable_frame, unidentified_similar_faces, - similar_face_vars, similar_face_images, similar_face_crops, - current_face_id, face_selection_states, identify_data_cache) - - # Note: Selection states are now restored automatically during checkbox creation (auto-match style) - else: - # No similar unidentified faces found - no_faces_label = ttk.Label(similar_scrollable_frame, text="No similar unidentified faces found", - foreground="gray", font=("Arial", 10)) - no_faces_label.pack(pady=20) - else: - # Compare disabled - clear the panel - clear_label = ttk.Label(similar_scrollable_frame, text="Enable 'Compare with similar faces' to see matches", - foreground="gray", font=("Arial", 10)) - clear_label.pack(pady=20) - - # Update button states based on compare checkbox and list contents - update_select_clear_buttons_state() - - # Unique faces change handler (must be defined before checkbox that uses it) - def on_unique_faces_change(): - """Handle unique faces checkbox change""" - nonlocal original_faces, i - - if unique_faces_var.get(): - # Show progress message - print("šŸ”„ Applying unique faces filter...") - root.update() # Update UI to show the message - - # Apply unique faces filtering to the main face list - try: - original_faces = self._filter_unique_faces_from_list(original_faces) - print(f"āœ… Filter applied: {len(original_faces)} unique faces remaining") - except Exception as e: - print(f"āš ļø Error applying filter: {e}") - # Revert checkbox state - unique_faces_var.set(False) - return - else: - # Reload the original unfiltered face list - print("šŸ”„ Reloading all faces...") - root.update() # Update UI to show the message - - with self.get_db_connection() as conn: - cursor = conn.cursor() - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - query += ' ORDER BY f.id' - cursor.execute(query, params) - original_faces = list(cursor.fetchall()) - - print(f"āœ… Reloaded: {len(original_faces)} faces") - - # Reset to first face and update display - i = 0 - update_similar_faces() - - # Compare checkbox variable and handler (must be defined before widgets that use it) - compare_var = tk.BooleanVar() - - def on_compare_change(): - """Handle compare checkbox change""" - update_similar_faces() - update_select_clear_buttons_state() - - # Date filter controls - date_filter_frame = ttk.LabelFrame(main_frame, text="Filter", padding="5") - date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) - date_filter_frame.columnconfigure(1, weight=0) - date_filter_frame.columnconfigure(4, weight=0) - - # Date from - ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) - date_from_var = tk.StringVar(value=date_from or "") - date_from_entry = ttk.Entry(date_filter_frame, textvariable=date_from_var, width=10, state='readonly') - date_from_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date from - def open_calendar_from(): - open_date_calendar(date_from_var, "Select Start Date") - - calendar_from_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_from) - calendar_from_btn.grid(row=0, column=2, padx=(0, 10)) - - # Date to - ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) - date_to_var = tk.StringVar(value=date_to or "") - date_to_entry = ttk.Entry(date_filter_frame, textvariable=date_to_var, width=10, state='readonly') - date_to_entry.grid(row=0, column=4, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date to - def open_calendar_to(): - open_date_calendar(date_to_var, "Select End Date") - - calendar_to_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_to) - calendar_to_btn.grid(row=0, column=5, padx=(0, 10)) - - # Apply filter button - def apply_date_filter(): - nonlocal date_from, date_to - date_from = date_from_var.get().strip() or None - date_to = date_to_var.get().strip() or None - date_processed_from = date_processed_from_var.get().strip() or None - date_processed_to = date_processed_to_var.get().strip() or None - - # Reload faces with new date filter - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - query += ' LIMIT ?' - params.append(batch_size) - - cursor.execute(query, params) - unidentified = cursor.fetchall() - - if not unidentified: - messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") - return - - # Update the global unidentified list and reset position - nonlocal current_pos, total_unidentified - current_pos = 0 - total_unidentified = len(unidentified) - - # Reset to first face - display will update when user navigates - if len(unidentified) > 0: - # Reset to first face - current_pos = 0 - # The display will be updated when the user navigates or when the window is shown - - # Build filter description - filters_applied = [] - if date_from or date_to: - taken_filter = f"taken: {date_from or 'any'} to {date_to or 'any'}" - filters_applied.append(taken_filter) - if date_processed_from or date_processed_to: - processed_filter = f"processed: {date_processed_from or 'any'} to {date_processed_to or 'any'}" - filters_applied.append(processed_filter) - - filter_desc = " | ".join(filters_applied) if filters_applied else "no filters" - - print(f"šŸ“… Applied filters: {filter_desc}") - print(f"šŸ‘¤ Found {len(unidentified)} unidentified faces with date filters") - print("šŸ’” Navigate to refresh the display with filtered faces") - - # Apply filter button (inside filter frame) - apply_filter_btn = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) - apply_filter_btn.grid(row=0, column=6, padx=(10, 0)) - - # Date processed filter (second row) - ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - date_processed_from_var = tk.StringVar() - date_processed_from_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_from_var, width=10, state='readonly') - date_processed_from_entry.grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed from - def open_calendar_processed_from(): - open_date_calendar(date_processed_from_var, "Select Processing Start Date") - - calendar_processed_from_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_from) - calendar_processed_from_btn.grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) - - # Date processed to - ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - date_processed_to_var = tk.StringVar() - date_processed_to_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_to_var, width=10, state='readonly') - date_processed_to_entry.grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed to - def open_calendar_processed_to(): - open_date_calendar(date_processed_to_var, "Select Processing End Date") - - calendar_processed_to_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_to) - calendar_processed_to_btn.grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) - - # Unique checkbox under the filter frame - unique_faces_checkbox = ttk.Checkbutton(main_frame, text="Unique faces only", - variable=unique_faces_var, command=on_unique_faces_change) - unique_faces_checkbox.grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0) - - # Compare checkbox on the same row as Unique - compare_checkbox = ttk.Checkbutton(main_frame, text="Compare similar faces", variable=compare_var, - command=on_compare_change) - compare_checkbox.grid(row=2, column=1, sticky=tk.W, padx=(5, 10), pady=0) - - # Left panel for main face - left_panel = ttk.Frame(main_frame) - left_panel.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5), pady=(0, 0)) - left_panel.columnconfigure(0, weight=1) - - # Right panel for similar faces - right_panel = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5") - right_panel.grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - right_panel.columnconfigure(0, weight=1) - right_panel.rowconfigure(0, weight=1) # Make right panel expandable vertically - - # Image display (left panel) - image_frame = ttk.Frame(left_panel) - image_frame.grid(row=0, column=0, pady=(0, 10), sticky=(tk.W, tk.E, tk.N, tk.S)) - image_frame.columnconfigure(0, weight=1) - image_frame.rowconfigure(0, weight=1) - - # Create canvas for image display - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - canvas = tk.Canvas(image_frame, width=400, height=400, bg=canvas_bg_color, highlightthickness=0) - canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Input section (left panel) - input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10") - input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E)) - input_frame.columnconfigure(1, weight=1) - input_frame.columnconfigure(3, weight=1) - input_frame.columnconfigure(5, weight=1) - input_frame.columnconfigure(7, weight=1) - - # First name input - ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) - first_name_var = tk.StringVar() - first_name_entry = ttk.Entry(input_frame, textvariable=first_name_var, width=12) - first_name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Red asterisk for required first name field (overlayed, no layout impact) - first_name_asterisk = ttk.Label(root, text="*", foreground="red") - first_name_asterisk.place_forget() - - # Last name input (with live listbox autocomplete) - ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10)) - last_name_var = tk.StringVar() - last_name_entry = ttk.Entry(input_frame, textvariable=last_name_var, width=12) - last_name_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Red asterisk for required last name field (overlayed, no layout impact) - last_name_asterisk = ttk.Label(root, text="*", foreground="red") - last_name_asterisk.place_forget() - - def _position_required_asterisks(event=None): - """Position required asterisks at top-right corner of their entries.""" - try: - root.update_idletasks() - input_frame.update_idletasks() - first_name_entry.update_idletasks() - last_name_entry.update_idletasks() - date_of_birth_entry.update_idletasks() - - # Get absolute coordinates relative to root window - first_root_x = first_name_entry.winfo_rootx() - first_root_y = first_name_entry.winfo_rooty() - first_w = first_name_entry.winfo_width() - root_x = root.winfo_rootx() - root_y = root.winfo_rooty() - - # First name asterisk at the true top-right corner of entry - first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw') - first_name_asterisk.lift() - - # Last name asterisk at the true top-right corner of entry - last_root_x = last_name_entry.winfo_rootx() - last_root_y = last_name_entry.winfo_rooty() - last_w = last_name_entry.winfo_width() - last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw') - last_name_asterisk.lift() - - # Date of birth asterisk at the true top-right corner of date entry - dob_root_x = date_of_birth_entry.winfo_rootx() - dob_root_y = date_of_birth_entry.winfo_rooty() - dob_w = date_of_birth_entry.winfo_width() - date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw') - date_asterisk.lift() - except Exception: - pass - - # Bind repositioning after all entries are created - def _bind_asterisk_positioning(): - try: - input_frame.bind('', _position_required_asterisks) - first_name_entry.bind('', _position_required_asterisks) - last_name_entry.bind('', _position_required_asterisks) - date_of_birth_entry.bind('', _position_required_asterisks) - _position_required_asterisks() - except Exception: - pass - root.after(100, _bind_asterisk_positioning) - - # Create listbox for suggestions (as overlay attached to root, not clipped by frames) - last_name_listbox = tk.Listbox(root, height=8) - last_name_listbox.place_forget() # Hide initially - - def _show_suggestions(): - """Show filtered suggestions in listbox""" - all_last_names = identify_data_cache.get('last_names', []) - typed = last_name_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 all_last_names if n.lower().startswith(low)][:10] - - # Update listbox - last_name_listbox.delete(0, tk.END) - for name in filtered: - last_name_listbox.insert(tk.END, name) - - # Show listbox if we have suggestions (as overlay) - if filtered: - # Ensure geometry is up to date before positioning - root.update_idletasks() - # Absolute coordinates of entry relative to screen - entry_root_x = last_name_entry.winfo_rootx() - entry_root_y = last_name_entry.winfo_rooty() - entry_height = last_name_entry.winfo_height() - # Convert to coordinates relative to root - root_origin_x = root.winfo_rootx() - root_origin_y = root.winfo_rooty() - place_x = entry_root_x - root_origin_x - place_y = entry_root_y - root_origin_y + entry_height - place_width = last_name_entry.winfo_width() - # Calculate how many rows fit to bottom of window - available_px = max(60, root.winfo_height() - place_y - 8) - # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows - approx_row_px = 18 - rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) - last_name_listbox.configure(height=rows_fit) - last_name_listbox.place(x=place_x, y=place_y, width=place_width) - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) # Select first item - last_name_listbox.activate(0) # Activate first item - else: - last_name_listbox.place_forget() - - def _hide_suggestions(): - """Hide the suggestions listbox""" - last_name_listbox.place_forget() - - def _on_listbox_select(event=None): - """Handle listbox selection and hide list""" - selection = last_name_listbox.curselection() - if selection: - selected_name = last_name_listbox.get(selection[0]) - last_name_var.set(selected_name) - _hide_suggestions() - last_name_entry.focus_set() - - def _on_listbox_click(event): - """Handle mouse click selection""" - try: - index = last_name_listbox.nearest(event.y) - if index is not None and index >= 0: - selected_name = last_name_listbox.get(index) - last_name_var.set(selected_name) - except: - pass - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - - def _on_key_press(event): - """Handle key navigation in entry""" - nonlocal navigating_to_listbox, escape_pressed, enter_pressed - if event.keysym == 'Down': - if last_name_listbox.winfo_ismapped(): - navigating_to_listbox = True - last_name_listbox.focus_set() - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) - last_name_listbox.activate(0) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - return 'break' - elif event.keysym == 'Return': - enter_pressed = True - return 'break' - - def _on_listbox_key(event): - """Handle key navigation in listbox""" - nonlocal enter_pressed, escape_pressed - if event.keysym == 'Return': - enter_pressed = True - _on_listbox_select(event) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - elif event.keysym == 'Up': - selection = last_name_listbox.curselection() - if selection and selection[0] > 0: - # Move up in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] - 1) - last_name_listbox.see(selection[0] - 1) - else: - # At top, go back to entry field - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - elif event.keysym == 'Down': - selection = last_name_listbox.curselection() - max_index = last_name_listbox.size() - 1 - if selection and selection[0] < max_index: - # Move down in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] + 1) - last_name_listbox.see(selection[0] + 1) - return 'break' - - # Track if we're navigating to listbox to prevent auto-hide - navigating_to_listbox = False - escape_pressed = False - enter_pressed = False - - def _safe_hide_suggestions(): - """Hide suggestions only if not navigating to listbox""" - nonlocal navigating_to_listbox - if not navigating_to_listbox: - _hide_suggestions() - navigating_to_listbox = False - - def _safe_show_suggestions(): - """Show suggestions only if escape or enter wasn't just pressed""" - nonlocal escape_pressed, enter_pressed - if not escape_pressed and not enter_pressed: - _show_suggestions() - escape_pressed = False - enter_pressed = False - - # Bind events - last_name_entry.bind('', lambda e: _safe_show_suggestions()) - last_name_entry.bind('', _on_key_press) - last_name_entry.bind('', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks - last_name_listbox.bind('', _on_listbox_click) - last_name_listbox.bind('', _on_listbox_key) - last_name_listbox.bind('', _on_listbox_click) - - # Middle name input - ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) - middle_name_var = tk.StringVar() - middle_name_entry = ttk.Entry(input_frame, textvariable=middle_name_var, width=12) - middle_name_entry.grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Date of birth input with calendar chooser - ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10)) - date_of_birth_var = tk.StringVar() - - # Create a frame for the date picker - date_frame = ttk.Frame(input_frame) - date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10)) - - # Maiden name input - ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10)) - maiden_name_var = tk.StringVar() - maiden_name_entry = ttk.Entry(input_frame, textvariable=maiden_name_var, width=12) - maiden_name_entry.grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Date display entry (read-only) - date_of_birth_entry = ttk.Entry(date_frame, textvariable=date_of_birth_var, width=12, state='readonly') - date_of_birth_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Red asterisk for required date of birth field (overlayed, no layout impact) - date_asterisk = ttk.Label(root, text="*", foreground="red") - date_asterisk.place_forget() - - # Calendar button - calendar_btn = ttk.Button(date_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) - calendar_btn.pack(side=tk.RIGHT, padx=(15, 0)) - - def open_calendar(): - """Open a visual calendar dialog to select date of birth""" - from datetime import datetime, date, timedelta - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(root) - calendar_window.title("Select Date of Birth") - calendar_window.resizable(False, False) - calendar_window.transient(root) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = date_of_birth_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use default - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - else: - # Default to 25 years ago - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Configure custom styles for better visual highlighting - style = ttk.Style() - - # Selected date style - bright blue background with white text - style.configure("Selected.TButton", - background="#0078d4", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=2) - style.map("Selected.TButton", - background=[("active", "#106ebe")], - relief=[("pressed", "sunken")]) - - # Today's date style - orange background - style.configure("Today.TButton", - background="#ff8c00", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=1) - style.map("Today.TButton", - background=[("active", "#e67e00")], - relief=[("pressed", "sunken")]) - - # Calendar-specific normal button style (don't affect global TButton) - style.configure("Calendar.TButton", - font=("Arial", 9), - relief="flat") - style.map("Calendar.TButton", - background=[("active", "#e1e1e1")], - relief=[("pressed", "sunken")]) - - # 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() - - def update_calendar(): - """Update the calendar display""" - # Clear existing calendar - for widget in calendar_frame.winfo_children(): - widget.destroy() - - # Update header - month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") - - # 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): - label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) - label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") - - # Calendar days - for week_num, week in enumerate(cal): - for day_num, day in enumerate(week): - if day == 0: - # Empty cell - label = ttk.Label(calendar_frame, text="") - label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - else: - # Day button - 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 with prominent style - 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), - width=3, style="Calendar.TButton") - day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - - # Check if this day should be highlighted - is_today = (display_year == current_date.year and - display_month == current_date.month and - day == current_date.day) - is_selected = (selected_date and - selected_date.year == display_year and - selected_date.month == display_month and - selected_date.day == day) - - if is_selected: - day_btn.config(style="Selected.TButton") - elif is_today: - day_btn.config(style="Today.TButton") - - # Navigation functions - def prev_year(): - nonlocal display_year - display_year = max(1900, display_year - 1) - update_calendar() - - def next_year(): - nonlocal display_year - display_year = min(current_date.year, display_year + 1) - update_calendar() - - def prev_month(): - nonlocal display_month, display_year - if display_month > 1: - display_month -= 1 - else: - display_month = 12 - display_year = max(1900, display_year - 1) - update_calendar() - - def next_month(): - nonlocal display_month, display_year - if display_month < 12: - display_month += 1 - else: - display_month = 1 - display_year = min(current_date.year, display_year + 1) - update_calendar() - - # Navigation buttons - prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) - prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) - - prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) - prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) - - month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) - month_year_label.pack(side=tk.LEFT, padx=5) - - next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) - next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) - - 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: - date_str = selected_date.strftime('%Y-%m-%d') - date_of_birth_var.set(date_str) - calendar_window.destroy() - else: - messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") - - def cancel_selection(): - """Cancel date selection""" - calendar_window.destroy() - - # Buttons - 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 - update_calendar() - - # (moved) unique_faces_var is defined earlier before date filter widgets - - # (moved) update_similar_faces function is defined earlier before on_unique_faces_change - - # (moved) Compare checkbox is now inside date_filter_frame to the right of dates - - # (moved) on_unique_faces_change function is defined earlier before date filter widgets - - - # Add callback to save person name when it changes - def on_name_change(*args): - if i < len(original_faces): - current_face_id = original_faces[i][0] - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - middle_name = middle_name_var.get().strip() - maiden_name = maiden_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - - if first_name or last_name or date_of_birth: - # Store as dictionary to maintain consistency - face_person_names[current_face_id] = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': middle_name, - 'maiden_name': maiden_name, - 'date_of_birth': date_of_birth - } - elif current_face_id in face_person_names: - # Remove empty names from storage - del face_person_names[current_face_id] - - first_name_var.trace('w', on_name_change) - last_name_var.trace('w', on_name_change) - date_of_birth_var.trace('w', on_name_change) - - # Buttons moved to bottom of window - - - # Right panel for similar faces - similar_faces_frame = ttk.Frame(right_panel) - similar_faces_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - similar_faces_frame.columnconfigure(0, weight=1) - similar_faces_frame.rowconfigure(0, weight=0) # Controls row takes minimal space - similar_faces_frame.rowconfigure(1, weight=1) # Canvas row expandable - - # Control buttons for similar faces (Select All / Clear All) - similar_controls_frame = ttk.Frame(similar_faces_frame) - similar_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) - - def select_all_similar_faces(): - """Select all similar faces checkboxes""" - for face_id, var in similar_face_vars: - var.set(True) - - def clear_all_similar_faces(): - """Clear all similar faces checkboxes""" - for face_id, var in similar_face_vars: - var.set(False) - - select_all_btn = ttk.Button(similar_controls_frame, text="ā˜‘ļø Select All", command=select_all_similar_faces, state='disabled') - select_all_btn.pack(side=tk.LEFT, padx=(0, 5)) - - clear_all_btn = ttk.Button(similar_controls_frame, text="☐ Clear All", command=clear_all_similar_faces, state='disabled') - clear_all_btn.pack(side=tk.LEFT) - - def update_select_clear_buttons_state(): - """Enable/disable Select All and Clear All based on compare state and presence of items""" - if compare_var.get() and similar_face_vars: - select_all_btn.config(state='normal') - clear_all_btn.config(state='normal') - else: - select_all_btn.config(state='disabled') - clear_all_btn.config(state='disabled') - - # Create canvas for similar faces with scrollbar - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - similar_canvas = tk.Canvas(similar_faces_frame, bg=canvas_bg_color, highlightthickness=0) - similar_scrollbar = ttk.Scrollbar(similar_faces_frame, orient="vertical", command=similar_canvas.yview) - similar_scrollable_frame = ttk.Frame(similar_canvas) - - similar_scrollable_frame.bind( - "", - lambda e: similar_canvas.configure(scrollregion=similar_canvas.bbox("all")) - ) - - similar_canvas.create_window((0, 0), window=similar_scrollable_frame, anchor="nw") - similar_canvas.configure(yscrollcommand=similar_scrollbar.set) - - # Pack canvas and scrollbar - similar_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - similar_scrollbar.grid(row=0, column=1, rowspan=2, sticky=(tk.N, tk.S)) - - # Variables for similar faces - similar_faces_data = [] - similar_face_vars = [] - similar_face_images = [] - similar_face_crops = [] - - # Store face selection states per face ID to preserve selections during navigation (auto-match style) - face_selection_states = {} # {face_id: {unique_key: bool}} - - # Store person names per face ID to preserve names during navigation - face_person_names = {} # {face_id: person_name} - - def save_current_face_selection_states(): - """Save current checkbox states and person name for the current face (auto-match style backup)""" - if i < len(original_faces): - current_face_id = original_faces[i][0] - - # Save checkbox states - if similar_face_vars: - if current_face_id not in face_selection_states: - face_selection_states[current_face_id] = {} - - # Save current checkbox states using unique keys - for similar_face_id, var in similar_face_vars: - unique_key = f"{current_face_id}_{similar_face_id}" - face_selection_states[current_face_id][unique_key] = var.get() - - # Save person name and date of birth - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - middle_name = middle_name_var.get().strip() - maiden_name = maiden_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - - if first_name or last_name: - # Store all fields - face_person_names[current_face_id] = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': middle_name, - 'maiden_name': maiden_name, - 'date_of_birth': date_of_birth - } - - # Button commands - command = None - waiting_for_input = False - - def on_identify(): - nonlocal command, waiting_for_input - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - middle_name = middle_name_var.get().strip() - maiden_name = maiden_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - compare_enabled = compare_var.get() - - if not first_name: - print("āš ļø Please enter a first name before identifying") - return - - if not last_name: - print("āš ļø Please enter a last name before identifying") - return - - if not date_of_birth: - print("āš ļø Please select a date of birth before identifying") - return - - # Validate date format (YYYY-MM-DD) - should always be valid from calendar - try: - from datetime import datetime - datetime.strptime(date_of_birth, '%Y-%m-%d') - except ValueError: - print("āš ļø Invalid date format. Please use the calendar to select a date.") - return - - # Combine first and last name properly - if last_name and first_name: - command = f"{last_name}, {first_name}" - elif last_name: - command = last_name - elif first_name: - command = first_name - else: - command = "" - - # Store the additional fields for database insertion - # We'll pass them through the command structure - if middle_name or maiden_name: - command += f"|{middle_name}|{maiden_name}|{date_of_birth}" - else: - command += f"|||{date_of_birth}" - - if not command: - print("āš ļø Please enter at least a first name or last name before identifying") - return - - if compare_enabled: - # Get selected similar faces - selected_face_ids = [face_id for face_id, var in similar_face_vars if var.get()] - if selected_face_ids: - # Create compare command with selected face IDs - command = f"compare:{command}:{','.join(map(str, selected_face_ids))}" - # If no similar faces selected, just identify the current face - else: - # Regular identification - pass - - waiting_for_input = False - - - def validate_navigation(): - """Check if navigation is allowed (no selected similar faces without person name)""" - # Check if compare is enabled and similar faces are selected - if compare_var.get() and similar_face_vars: - selected_faces = [face_id for face_id, var in similar_face_vars if var.get()] - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - if selected_faces and not (first_name or last_name): - # Show warning dialog - result = messagebox.askyesno( - "Selected Faces Not Identified", - f"You have {len(selected_faces)} similar face(s) selected but no person name entered.\n\n" - "These faces will not be identified if you continue.\n\n" - "Do you want to continue anyway?", - icon='warning' - ) - return result # True = continue, False = cancel - return True # No validation issues, allow navigation - - def on_back(): - nonlocal command, waiting_for_input - if not validate_navigation(): - return # Cancel navigation - command = 'back' - waiting_for_input = False - - def on_skip(): - nonlocal command, waiting_for_input - if not validate_navigation(): - return # Cancel navigation - command = 's' - waiting_for_input = False - - def on_quit(): - nonlocal command, waiting_for_input, window_destroyed, force_exit - - # First check for selected similar faces without person name - if not validate_navigation(): - return # Cancel quit - - # Check if there are pending identifications (faces with complete data but not yet saved) - pending_identifications = {} - for k, v in face_person_names.items(): - if k not in face_status or face_status[k] != 'identified': - # Handle person data dict format - if isinstance(v, dict): - first_name = v.get('first_name', '').strip() - last_name = v.get('last_name', '').strip() - date_of_birth = v.get('date_of_birth', '').strip() - - # Check if we have complete data (both first and last name, plus date of birth) - if first_name and last_name and date_of_birth: - pending_identifications[k] = v - else: - # Handle legacy string format - person_name = v.strip() - date_of_birth = '' # Legacy format doesn't have date_of_birth - # Legacy format is not considered complete without date of birth - pass - - if pending_identifications: - # Ask user if they want to save pending identifications - result = messagebox.askyesnocancel( - "Save Pending Identifications?", - f"You have {len(pending_identifications)} pending identifications.\n\n" - "Do you want to save them before quitting?\n\n" - "• Yes: Save all pending identifications and quit\n" - "• No: Quit without saving\n" - "• Cancel: Return to identification" - ) - - if result is True: # Yes - Save and quit - save_all_pending_identifications() - command = 'q' - waiting_for_input = False - elif result is False: # No - Quit without saving - command = 'q' - waiting_for_input = False - else: # Cancel - Don't quit - return - else: - # No pending identifications, quit normally - command = 'q' - waiting_for_input = False - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Force process termination - force_exit = True - root.quit() - - - - def update_button_states(): - """Update button states based on current position and unidentified faces""" - # Check if there are previous unidentified faces - has_prev_unidentified = False - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - has_prev_unidentified = True - break - - # Check if there are next unidentified faces - has_next_unidentified = False - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - has_next_unidentified = True - break - - # Enable/disable Back button - if has_prev_unidentified: - back_btn.config(state='normal') - else: - back_btn.config(state='disabled') - - # Enable/disable Next button - if has_next_unidentified: - next_btn.config(state='normal') - else: - next_btn.config(state='disabled') - - # Button references moved to bottom control panel - - def update_identify_button_state(): - """Enable/disable identify button based on first name, last name, and date of birth""" - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - if first_name and last_name and date_of_birth: - identify_btn.config(state='normal') - else: - identify_btn.config(state='disabled') - - # Bind name input changes to update button state - first_name_var.trace('w', lambda *args: update_identify_button_state()) - last_name_var.trace('w', lambda *args: update_identify_button_state()) - date_of_birth_var.trace('w', lambda *args: update_identify_button_state()) - - # Handle Enter key - def on_enter(event): - on_identify() - - first_name_entry.bind('', on_enter) - last_name_entry.bind('', on_enter) - - # Bottom control panel (move to bottom below panels) - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=4, column=0, columnspan=3, pady=(10, 0), sticky=(tk.E, tk.S)) - - # Create button references for state management - back_btn = ttk.Button(control_frame, text="ā¬…ļø Back", command=on_back) - next_btn = ttk.Button(control_frame, text="āž”ļø Next", command=on_skip) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) - - back_btn.pack(side=tk.LEFT, padx=(0, 5)) - next_btn.pack(side=tk.LEFT, padx=(0, 5)) - quit_btn.pack(side=tk.LEFT, padx=(5, 0)) - - # Identify button (placed after on_identify is defined) - identify_btn = ttk.Button(input_frame, text="āœ… Identify", command=on_identify, state='disabled') - identify_btn.grid(row=4, column=0, pady=(10, 0), sticky=tk.W) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - conn.close() - return 0 - - - - # Process each face with back navigation support - # Keep track of original face list and current position - original_faces = list(unidentified) # Make a copy of the original list - i = 0 - face_status = {} # Track which faces have been identified - - def get_unidentified_faces(): - """Get list of faces that haven't been identified yet""" - return [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] - - def get_current_face_position(): - """Get current face position among unidentified faces""" - unidentified_faces = get_unidentified_faces() - current_face_id = original_faces[i][0] if i < len(original_faces) else None - - # Find position of current face in unidentified list - for pos, face in enumerate(unidentified_faces): - if face[0] == current_face_id: - return pos + 1, len(unidentified_faces) - - return 1, len(unidentified_faces) # Fallback - - def update_current_face_index(): - """Update the current face index to point to a valid unidentified face""" - nonlocal i - unidentified_faces = get_unidentified_faces() - if not unidentified_faces: - # All faces identified, we're done - return False - - # Find the current face in the unidentified list - current_face_id = original_faces[i][0] if i < len(original_faces) else None - if current_face_id and current_face_id in face_status and face_status[current_face_id] == 'identified': - # Current face was just identified, find the next unidentified face - if i < len(original_faces) - 1: - # Try to find the next unidentified face - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - else: - # No more faces after current, go to previous - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - else: - # At the end, go to previous unidentified face - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - - # Ensure index is within bounds - if i >= len(original_faces): - i = len(original_faces) - 1 - if i < 0: - i = 0 - - return True - - while not window_destroyed: - # Check if current face is identified and update index if needed - if not update_current_face_index(): - # All faces have been identified - print("\nšŸŽ‰ All faces have been identified!") - break - - # Ensure we don't go beyond the bounds - if i >= len(original_faces): - # Stay on the last face instead of breaking - i = len(original_faces) - 1 - - face_id, photo_id, photo_path, filename, location = original_faces[i] - - # Check if this face was already identified in this session - is_already_identified = face_id in face_status and face_status[face_id] == 'identified' - - # Reset command and waiting state for each face - command = None - waiting_for_input = True - - # Update the display - current_pos, total_unidentified = get_current_face_position() - print(f"\n--- Face {current_pos}/{total_unidentified} (unidentified) ---") - print(f"šŸ“ Photo: {filename}") - print(f"šŸ“ Face location: {location}") - - # Update title - root.title(f"Face Identification - {current_pos}/{total_unidentified} (unidentified)") - - # Update button states - update_button_states() - - # Update similar faces panel if compare is enabled - if compare_var.get(): - update_similar_faces() - - # Update photo info - if is_already_identified: - # Get the person name for this face - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.first_name, p.last_name FROM people p - JOIN faces f ON p.id = f.person_id - WHERE f.id = ? - ''', (face_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" - - info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified}) - āœ… Already identified as: {person_name}") - print(f"āœ… Already identified as: {person_name}") - else: - info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified})") - - # Extract face crop if enabled - face_crop_path = None - if show_faces: - face_crop_path = self._extract_face_crop(photo_path, location, face_id) - if face_crop_path: - print(f"šŸ–¼ļø Face crop saved: {face_crop_path}") - current_face_crop_path = face_crop_path # Track for cleanup - else: - print("šŸ’” Use --show-faces flag to display individual face crops") - current_face_crop_path = None - - print(f"\nšŸ–¼ļø Viewing face {current_pos}/{total_unidentified} from {filename}") - - # Clear and update image - canvas.delete("all") - if show_faces and face_crop_path and os.path.exists(face_crop_path): - try: - # Load and display the face crop image - pil_image = Image.open(face_crop_path) - - # Get canvas dimensions - canvas_width = canvas.winfo_width() - canvas_height = canvas.winfo_height() - - # If canvas hasn't been rendered yet, force update and use actual size - if canvas_width <= 1 or canvas_height <= 1: - # Force the canvas to update its geometry - canvas.update_idletasks() - canvas_width = canvas.winfo_width() - canvas_height = canvas.winfo_height() - - # If still not rendered, use default size - if canvas_width <= 1: - canvas_width = 400 - if canvas_height <= 1: - canvas_height = 400 - - # Calculate scaling to fit within the canvas while maintaining aspect ratio - img_width, img_height = pil_image.size - scale_x = canvas_width / img_width - scale_y = canvas_height / img_height - # Allow slight upscaling (up to 1.2x) for better visibility, but cap to avoid excessive blurriness - max_scale = min(1.2, max(scale_x, scale_y)) - scale = min(scale_x, scale_y, max_scale) - - # Resize image to fill canvas - new_width = int(img_width * scale) - new_height = int(img_height * scale) - pil_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS) - - photo = ImageTk.PhotoImage(pil_image) - - # Center the image in the canvas - x = canvas_width // 2 - y = canvas_height // 2 - canvas.create_image(x, y, image=photo) - - # Keep a reference to prevent garbage collection - canvas.image = photo - - # Add photo icon using reusable function - self._create_photo_icon(canvas, photo_path, - face_x=x, face_y=y, - face_width=new_width, face_height=new_height, - canvas_width=canvas_width, canvas_height=canvas_height) - - except Exception as e: - canvas.create_text(200, 200, text=f"āŒ Could not load image: {e}", fill="red") - else: - canvas.create_text(200, 200, text="šŸ–¼ļø No face crop available", fill="gray") - - # Set person name input - restore saved name or use database/empty value - if face_id in face_person_names: - # Restore previously entered name for this face - person_data = face_person_names[face_id] - if isinstance(person_data, dict): - # Handle dictionary format - use individual field values for proper restoration - first_name = person_data.get('first_name', '').strip() - last_name = person_data.get('last_name', '').strip() - middle_name = person_data.get('middle_name', '').strip() - maiden_name = person_data.get('maiden_name', '').strip() - date_of_birth = person_data.get('date_of_birth', '').strip() - - # Restore all fields directly - first_name_var.set(first_name) - last_name_var.set(last_name) - middle_name_var.set(middle_name) - maiden_name_var.set(maiden_name) - date_of_birth_var.set(date_of_birth) - else: - # Handle legacy string format (for backward compatibility) - full_name = person_data - # Parse "Last, First" format back to separate fields - if ', ' in full_name: - parts = full_name.split(', ', 1) - last_name_var.set(parts[0].strip()) - first_name_var.set(parts[1].strip()) - else: - # Single name format - first_name_var.set(full_name) - last_name_var.set("") - elif is_already_identified: - # Pre-populate with the current person name from database - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth FROM people p - JOIN faces f ON p.id = f.person_id - WHERE f.id = ? - ''', (face_id,)) - result = cursor.fetchone() - if result: - first_name_var.set(result[0] or "") - last_name_var.set(result[1] or "") - middle_name_var.set(result[2] or "") - maiden_name_var.set(result[3] or "") - date_of_birth_var.set(result[4] or "") - else: - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - else: - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - - # Keep compare checkbox state persistent across navigation - first_name_entry.focus_set() - first_name_entry.icursor(0) - - # Force GUI update before waiting for input - root.update_idletasks() - - # Wait for user input - while waiting_for_input: - try: - root.update() - # Small delay to prevent excessive CPU usage - time.sleep(0.01) - except tk.TclError: - # Window was destroyed, break out of loop - break - - # Check if force exit was requested - if force_exit: - break - - # Check if force exit was requested (exit immediately) - if force_exit: - print("Force exit requested...") - # Clean up face crops and caches - self._cleanup_face_crops(face_crop_path) - self.close_db_connection() - return identified_count - - # Process the command - if command is None: # User clicked Cancel - command = 'q' - else: - command = command.strip() - - if command.lower() == 'q': - print("Quitting...") - # Clean up face crops and caches - self._cleanup_face_crops(face_crop_path) - self.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - return identified_count - - - elif command.lower() == 's': - print("āž”ļø Next") - - # Save current checkbox states before navigating away (auto-match style backup) - save_current_face_selection_states() - - # Clean up current face crop when moving forward - if face_crop_path and os.path.exists(face_crop_path): - try: - os.remove(face_crop_path) - except: - pass # Ignore cleanup errors - current_face_crop_path = None # Clear tracked path - - # Find next unidentified face - next_found = False - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - next_found = True - break - - if not next_found: - print("āš ļø No more unidentified faces - Next button disabled") - continue - - # Clear date of birth field when moving to next face - date_of_birth_var.set("") - # Clear middle name and maiden name fields when moving to next face - middle_name_var.set("") - maiden_name_var.set("") - - update_button_states() - # Only update similar faces if compare is enabled - if compare_var.get(): - update_similar_faces() - continue - - elif command.lower() == 'back': - print("ā¬…ļø Going back to previous face") - - # Save current checkbox states before navigating away (auto-match style backup) - save_current_face_selection_states() - - # Find previous unidentified face - prev_found = False - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - prev_found = True - break - - if not prev_found: - print("āš ļø No more unidentified faces - Back button disabled") - continue - - # Repopulate fields with saved data when going back - current_face_id = original_faces[i][0] - if current_face_id in face_person_names: - person_data = face_person_names[current_face_id] - if isinstance(person_data, dict): - # Use individual field values for proper restoration - first_name = person_data.get('first_name', '').strip() - last_name = person_data.get('last_name', '').strip() - middle_name = person_data.get('middle_name', '').strip() - maiden_name = person_data.get('maiden_name', '').strip() - date_of_birth = person_data.get('date_of_birth', '').strip() - - # Restore all fields directly - first_name_var.set(first_name) - last_name_var.set(last_name) - middle_name_var.set(middle_name) - maiden_name_var.set(maiden_name) - date_of_birth_var.set(date_of_birth) - else: - # Clear fields - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - else: - # No saved data - clear fields - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - - update_button_states() - # Only update similar faces if compare is enabled - if compare_var.get(): - update_similar_faces() - continue - - elif command.lower() == 'list': - self._show_people_list() - continue - - elif command: - try: - # Check if this is a compare command - if command.startswith('compare:'): - # Parse compare command: compare:person_name:face_id1,face_id2,face_id3 - parts = command.split(':', 2) - if len(parts) == 3: - person_name = parts[1] - selected_face_ids = [int(fid.strip()) for fid in parts[2].split(',') if fid.strip()] - - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Add person if doesn't exist - # Parse person_name in "Last, First" or single-token format - # Parse person_name with additional fields (middle_name|maiden_name|date_of_birth) - name_part, middle_name, maiden_name, date_of_birth = person_name.split('|', 3) - parts = [p.strip() for p in name_part.split(',', 1)] - - if len(parts) == 2: - last_name, first_name = parts[0], parts[1] - else: - first_name = parts[0] if parts else '' - last_name = '' - - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - if person_name not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(person_name) - identify_data_cache['people_names'].sort() # Keep sorted - # Update last names cache from person_name ("Last, First" or single) - inferred_last = person_name.split(',')[0].strip() if ',' in person_name else person_name.strip() - if inferred_last: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if inferred_last not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(inferred_last) - identify_data_cache['last_names'].sort() - - # Identify all selected faces (including current face) - all_face_ids = [face_id] + selected_face_ids - for fid in all_face_ids: - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, fid) - ) - - # Mark all faces as identified in our tracking - for fid in all_face_ids: - face_status[fid] = 'identified' - - if is_already_identified: - print(f"āœ… Re-identified current face and {len(selected_face_ids)} similar faces as: {person_name}") - else: - print(f"āœ… Identified current face and {len(selected_face_ids)} similar faces as: {person_name}") - identified_count += 1 + len(selected_face_ids) - - - # Update person encodings after database transaction is complete - self._update_person_encodings(person_id) - else: - print("āŒ Invalid compare command format") - else: - # Regular identification - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Add person if doesn't exist - # Parse command in "Last, First" or single-token format - # Parse command with additional fields (middle_name|maiden_name|date_of_birth) - name_part, middle_name, maiden_name, date_of_birth = command.split('|', 3) - parts = [p.strip() for p in name_part.split(',', 1)] - - if len(parts) == 2: - last_name, first_name = parts[0], parts[1] - else: - first_name = parts[0] if parts else '' - last_name = '' - - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - if command not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(command) - identify_data_cache['people_names'].sort() # Keep sorted - # Update last names cache from command ("Last, First" or single) - inferred_last = command.split(',')[0].strip() if ',' in command else command.strip() - if inferred_last: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if inferred_last not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(inferred_last) - identify_data_cache['last_names'].sort() - - # Assign face to person - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, face_id) - ) - - if is_already_identified: - print(f"āœ… Re-identified as: {command}") - else: - print(f"āœ… Identified as: {command}") - identified_count += 1 - - # Mark this face as identified in our tracking - face_status[face_id] = 'identified' - - - # Update person encodings after database transaction is complete - self._update_person_encodings(person_id) - - except Exception as e: - print(f"āŒ Error: {e}") - - # Increment index for normal flow (identification or error) - but not if we're at the last item - if i < len(original_faces) - 1: - i += 1 - update_button_states() - # Only update similar faces if compare is enabled - if compare_var.get(): - update_similar_faces() - - # Clean up current face crop when moving forward after identification - if face_crop_path and os.path.exists(face_crop_path): - try: - os.remove(face_crop_path) - except: - pass # Ignore cleanup errors - current_face_crop_path = None # Clear tracked path - - # Continue to next face after processing command - continue - else: - print("Please enter a name, 's' to skip, 'q' to quit, or use buttons") - - # Only close the window if user explicitly quit (not when reaching end of faces) - if not window_destroyed: - # Keep the window open - user can still navigate and quit manually - print(f"\nāœ… Identified {identified_count} faces") - print("šŸ’” Application remains open - use Quit button to close") - # Don't destroy the window - let user quit manually - return identified_count - - print(f"\nāœ… Identified {identified_count} faces") - return identified_count - - def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None): - """Display similar faces in a panel - reuses auto-match display logic""" - import tkinter as tk - from tkinter import ttk - from PIL import Image, ImageTk - import os - - # Create all similar faces using auto-match style display - for i, face_data in enumerate(similar_faces_data[:10]): # Limit to 10 faces - similar_face_id = face_data['face_id'] - filename = face_data['filename'] - distance = face_data['distance'] - quality = face_data.get('quality_score', 0.5) - - # Calculate confidence like in auto-match - confidence_pct = (1 - distance) * 100 - confidence_desc = self._get_confidence_description(confidence_pct) - - # Create match frame using auto-match style - match_frame = ttk.Frame(parent_frame) - match_frame.pack(fill=tk.X, padx=5, pady=5) - - # Checkbox for this match (reusing auto-match checkbox style) - match_var = tk.BooleanVar() - face_vars.append((similar_face_id, match_var)) - - # Restore previous checkbox state if available (auto-match style) - if current_face_id is not None and face_selection_states is not None: - unique_key = f"{current_face_id}_{similar_face_id}" - if current_face_id in face_selection_states and unique_key in face_selection_states[current_face_id]: - saved_state = face_selection_states[current_face_id][unique_key] - match_var.set(saved_state) - - # Add immediate callback to save state when checkbox changes (auto-match style) - def make_callback(var, face_id, similar_face_id): - def on_checkbox_change(*args): - unique_key = f"{face_id}_{similar_face_id}" - if face_id not in face_selection_states: - face_selection_states[face_id] = {} - face_selection_states[face_id][unique_key] = var.get() - return on_checkbox_change - - # Bind the callback to the variable - match_var.trace('w', make_callback(match_var, current_face_id, similar_face_id)) - - # Configure match frame for grid layout - match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width - match_frame.columnconfigure(1, weight=1) # Text column - expandable - match_frame.columnconfigure(2, weight=0) # Image column - fixed width - - # Checkbox without text - checkbox = ttk.Checkbutton(match_frame, variable=match_var) - checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) - - # Create labels for confidence and filename - confidence_label = ttk.Label(match_frame, text=f"{confidence_pct:.1f}% {confidence_desc}", font=("Arial", 9, "bold")) - confidence_label.grid(row=0, column=1, sticky=tk.W, padx=(0, 10)) - - filename_label = ttk.Label(match_frame, text=f"šŸ“ {filename}", font=("Arial", 8), foreground="gray") - filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10)) - - # Face image (reusing auto-match image display) - try: - # Get photo path from cache or database - photo_path = None - if data_cache and 'photo_paths' in data_cache: - # Find photo path by filename in cache - for photo_data in data_cache['photo_paths'].values(): - if photo_data['filename'] == filename: - photo_path = photo_data['path'] - break - - # Fallback to database if not in cache - if photo_path is None: - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT path FROM photos WHERE filename = ?', (filename,)) - result = cursor.fetchone() - photo_path = result[0] if result else None - - # Extract face crop using existing method - face_crop_path = self._extract_face_crop(photo_path, face_data['location'], similar_face_id) - if face_crop_path and os.path.exists(face_crop_path): - face_crops.append(face_crop_path) - - # Create canvas for face image (like in auto-match) - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - match_canvas = tk.Canvas(match_frame, width=80, height=80, bg=canvas_bg_color, highlightthickness=0) - match_canvas.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0)) - - # Load and display image (reusing auto-match image loading) - pil_image = Image.open(face_crop_path) - pil_image.thumbnail((80, 80), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(pil_image) - match_canvas.create_image(40, 40, image=photo) - match_canvas.image = photo # Keep reference - face_images.append(photo) - - # Add photo icon to the similar face - self._create_photo_icon(match_canvas, photo_path, icon_size=15, - face_x=40, face_y=40, - face_width=80, face_height=80, - canvas_width=80, canvas_height=80) - else: - # No image available - match_canvas = tk.Canvas(match_frame, width=80, height=80, bg='white') - match_canvas.pack(side=tk.LEFT, padx=(10, 0)) - match_canvas.create_text(40, 40, text="šŸ–¼ļø", fill="gray") - except Exception as e: - # Error loading image - match_canvas = tk.Canvas(match_frame, width=80, height=80, bg='white') - match_canvas.pack(side=tk.LEFT, padx=(10, 0)) - match_canvas.create_text(40, 40, text="āŒ", fill="red") - - def _create_photo_icon(self, canvas, photo_path, icon_size=20, icon_x=None, icon_y=None, - canvas_width=None, canvas_height=None, face_x=None, face_y=None, - face_width=None, face_height=None): - """Create a reusable photo icon with tooltip on a canvas""" - import tkinter as tk - import subprocess - import platform - import os - - 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 corner - face_right = face_x + face_width // 2 - face_top = face_y - face_height // 2 - icon_x = face_right - icon_size - icon_y = face_top - 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", "", open_source_photo) - canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e))) - canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor=""), hide_tooltip(e))) - canvas.tag_bind("photo_icon", "", lambda e: (show_tooltip(e) if tooltip else None)) - - return tooltip # Return tooltip reference for cleanup if needed - - def _extract_face_crop(self, photo_path: str, location: tuple, face_id: int) -> str: - """Extract and save individual face crop for identification with caching""" - try: - # Check cache first - cache_key = f"{photo_path}_{location}_{face_id}" - if cache_key in self._image_cache: - cached_path = self._image_cache[cache_key] - # Verify the cached file still exists - if os.path.exists(cached_path): - return cached_path - else: - # Remove from cache if file doesn't exist - del self._image_cache[cache_key] - - # Parse location tuple from string format - if isinstance(location, str): - location = eval(location) - - top, right, bottom, left = location - - # Load the image - image = Image.open(photo_path) - - # Add padding around the face (20% of face size) - 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)) - - # Create temporary file for the face crop - temp_dir = tempfile.gettempdir() - face_filename = f"face_{face_id}_crop.jpg" - face_path = os.path.join(temp_dir, face_filename) - - # Resize for better viewing (minimum 200px width) - if face_crop.width < 200: - ratio = 200 / face_crop.width - new_width = 200 - new_height = int(face_crop.height * ratio) - face_crop = face_crop.resize((new_width, new_height), Image.Resampling.LANCZOS) - - face_crop.save(face_path, "JPEG", quality=95) - - # Cache the result - self._image_cache[cache_key] = face_path - return face_path - - except Exception as e: - if self.verbose >= 1: - print(f"āš ļø Could not extract face crop: {e}") - return None - - def _create_comparison_image(self, unid_crop_path: str, match_crop_path: str, person_name: str, confidence: float) -> 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 - 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: - if self.verbose >= 1: - print(f"āš ļø Could not create comparison image: {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 - Unlikely)" - - def _calculate_face_quality_score(self, image: np.ndarray, face_location: tuple) -> float: - """Calculate face quality score based on multiple factors""" - try: - top, right, bottom, left = face_location - face_height = bottom - top - face_width = right - left - - # Basic size check - faces too small get lower scores - min_face_size = 50 - size_score = min(1.0, (face_height * face_width) / (min_face_size * min_face_size)) - - # Extract face region - face_region = image[top:bottom, left:right] - if face_region.size == 0: - return 0.0 - - # Convert to grayscale for analysis - if len(face_region.shape) == 3: - gray_face = np.mean(face_region, axis=2) - else: - gray_face = face_region - - # Calculate sharpness (Laplacian variance) - laplacian_var = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32)) - if laplacian_var > 0: - sharpness = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32)) - else: - sharpness = 0.0 - sharpness_score = min(1.0, sharpness / 1000.0) # Normalize sharpness - - # Calculate brightness and contrast - mean_brightness = np.mean(gray_face) - brightness_score = 1.0 - abs(mean_brightness - 128) / 128.0 # Prefer middle brightness - - contrast = np.std(gray_face) - contrast_score = min(1.0, contrast / 64.0) # Prefer good contrast - - # Calculate aspect ratio (faces should be roughly square) - aspect_ratio = face_width / face_height if face_height > 0 else 1.0 - aspect_score = 1.0 - abs(aspect_ratio - 1.0) # Prefer square faces - - # Calculate position in image (centered faces are better) - image_height, image_width = image.shape[:2] - center_x = (left + right) / 2 - center_y = (top + bottom) / 2 - position_x_score = 1.0 - abs(center_x - image_width / 2) / (image_width / 2) - position_y_score = 1.0 - abs(center_y - image_height / 2) / (image_height / 2) - position_score = (position_x_score + position_y_score) / 2.0 - - # Weighted combination of all factors - quality_score = ( - size_score * 0.25 + - sharpness_score * 0.25 + - brightness_score * 0.15 + - contrast_score * 0.15 + - aspect_score * 0.10 + - position_score * 0.10 - ) - - return max(0.0, min(1.0, quality_score)) - - except Exception as e: - if self.verbose >= 2: - print(f"āš ļø Error calculating face quality: {e}") - return 0.5 # Default medium quality on error - - def _add_person_encoding(self, person_id: int, face_id: int, encoding: np.ndarray, quality_score: float): - """Add a face encoding to a person's encoding collection""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - 'INSERT INTO person_encodings (person_id, face_id, encoding, quality_score) VALUES (?, ?, ?, ?)', - (person_id, face_id, encoding.tobytes(), quality_score) - ) - - def _get_person_encodings(self, person_id: int, min_quality: float = 0.3) -> List[Tuple[np.ndarray, float]]: - """Get all high-quality encodings for a person""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - 'SELECT encoding, quality_score FROM person_encodings WHERE person_id = ? AND quality_score >= ? ORDER BY quality_score DESC', - (person_id, min_quality) - ) - results = cursor.fetchall() - return [(np.frombuffer(encoding, dtype=np.float64), quality_score) for encoding, quality_score in results] - - def _update_person_encodings(self, person_id: int): - """Update person encodings when a face is identified""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Get all faces for this person - cursor.execute( - 'SELECT id, encoding, quality_score FROM faces WHERE person_id = ? ORDER BY quality_score DESC', - (person_id,) - ) - faces = cursor.fetchall() - - # Clear existing person encodings - cursor.execute('DELETE FROM person_encodings WHERE person_id = ?', (person_id,)) - - # Add all faces as person encodings - for face_id, encoding, quality_score in faces: - cursor.execute( - 'INSERT INTO person_encodings (person_id, face_id, encoding, quality_score) VALUES (?, ?, ?, ?)', - (person_id, face_id, encoding, quality_score) - ) - - def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float: - """Calculate adaptive tolerance based on face quality and match confidence""" - # Start with base tolerance - tolerance = base_tolerance - - # Adjust based on face quality (higher quality = stricter tolerance) - # More conservative: range 0.9 to 1.1 instead of 0.8 to 1.2 - quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1 - tolerance *= quality_factor - - # If we have match confidence, adjust further - if match_confidence is not None: - # Higher confidence matches can use stricter tolerance - # More conservative: range 0.95 to 1.05 instead of 0.9 to 1.1 - confidence_factor = 0.95 + (match_confidence * 0.1) # Range: 0.95 to 1.05 - tolerance *= confidence_factor - - # Ensure tolerance stays within reasonable bounds - return max(0.3, min(0.8, tolerance)) # Reduced max from 0.9 to 0.8 - - def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None) -> List[Dict]: - """Get similar faces with consistent filtering and sorting logic used by both auto-match and identify""" - # Find similar faces using the core function - similar_faces_data = self.find_similar_faces(face_id, tolerance=tolerance, include_same_photo=include_same_photo) - - # Filter to only show unidentified faces with confidence filtering - filtered_faces = [] - for face in similar_faces_data: - # For auto-match: only filter by database state (keep existing behavior) - # For identify: also filter by current session state - is_identified_in_db = face.get('person_id') is not None - is_identified_in_session = face_status and face.get('face_id') in face_status and face_status[face.get('face_id')] == 'identified' - - # If face_status is provided (identify mode), use both filters - # If face_status is None (auto-match mode), only use database filter - if face_status is not None: - # Identify mode: filter out both database and session identified faces - if not is_identified_in_db and not is_identified_in_session: - # Calculate confidence percentage - confidence_pct = (1 - face['distance']) * 100 - - # Only include matches with reasonable confidence (at least 40%) - if confidence_pct >= 40: - filtered_faces.append(face) - else: - # Auto-match mode: only filter by database state (keep existing behavior) - if not is_identified_in_db: - # Calculate confidence percentage - confidence_pct = (1 - face['distance']) * 100 - - # Only include matches with reasonable confidence (at least 40%) - if confidence_pct >= 40: - filtered_faces.append(face) - - # Sort by confidence (distance) - highest confidence first - filtered_faces.sort(key=lambda x: x['distance']) - - return filtered_faces - - def _filter_unique_faces(self, faces: List[Dict]) -> List[Dict]: - """Filter faces to show only unique ones, hiding duplicates with high/medium confidence matches""" - if not faces: - return faces - - unique_faces = [] - seen_face_groups = set() # Track face groups that have been seen - - for face in faces: - face_id = face['face_id'] - confidence_pct = (1 - face['distance']) * 100 - - # Only consider high (>=70%) or medium (>=60%) confidence matches for grouping - if confidence_pct >= 60: - # Find all faces that match this one with high/medium confidence - matching_face_ids = set() - for other_face in faces: - other_face_id = other_face['face_id'] - other_confidence_pct = (1 - other_face['distance']) * 100 - - # If this face matches the current face with high/medium confidence - if other_confidence_pct >= 60: - matching_face_ids.add(other_face_id) - - # Create a sorted tuple to represent this group of matching faces - face_group = tuple(sorted(matching_face_ids)) - - # Only show this face if we haven't seen this group before - if face_group not in seen_face_groups: - seen_face_groups.add(face_group) - unique_faces.append(face) - else: - # For low confidence matches, always show them (they're likely different people) - unique_faces.append(face) - - return unique_faces - - def _filter_unique_faces_from_list(self, faces_list: List[tuple]) -> List[tuple]: - """Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches""" - if not faces_list: - return faces_list - - # Extract face IDs from the list - face_ids = [face_tuple[0] for face_tuple in faces_list] - - # Get face encodings from database for all faces - face_encodings = {} - with self.get_db_connection() as conn: - cursor = conn.cursor() - placeholders = ','.join('?' * len(face_ids)) - cursor.execute(f''' - SELECT id, encoding - FROM faces - WHERE id IN ({placeholders}) AND encoding IS NOT NULL - ''', face_ids) - - for face_id, encoding_blob in cursor.fetchall(): - try: - import numpy as np - # Load encoding as numpy array (not pickle) - encoding = np.frombuffer(encoding_blob, dtype=np.float64) - face_encodings[face_id] = encoding - except Exception: - continue - - # If we don't have enough encodings, return original list - if len(face_encodings) < 2: - return faces_list - - # Calculate distances between all faces using existing encodings - face_distances = {} - face_id_list = list(face_encodings.keys()) - - for i, face_id1 in enumerate(face_id_list): - for j, face_id2 in enumerate(face_id_list): - if i != j: - try: - import face_recognition - encoding1 = face_encodings[face_id1] - encoding2 = face_encodings[face_id2] - - # Calculate distance - distance = face_recognition.face_distance([encoding1], encoding2)[0] - face_distances[(face_id1, face_id2)] = distance - except Exception: - # If calculation fails, assume no match - face_distances[(face_id1, face_id2)] = 1.0 - - # Apply unique faces filtering - unique_faces = [] - seen_face_groups = set() - - for face_tuple in faces_list: - face_id = face_tuple[0] - - # Skip if we don't have encoding for this face - if face_id not in face_encodings: - unique_faces.append(face_tuple) - continue - - # Find all faces that match this one with high/medium confidence - matching_face_ids = set([face_id]) # Include self - for other_face_id in face_encodings.keys(): - if other_face_id != face_id: - distance = face_distances.get((face_id, other_face_id), 1.0) - confidence_pct = (1 - distance) * 100 - - # If this face matches with high/medium confidence - if confidence_pct >= 60: - matching_face_ids.add(other_face_id) - - # Create a sorted tuple to represent this group of matching faces - face_group = tuple(sorted(matching_face_ids)) - - # Only show this face if we haven't seen this group before - if face_group not in seen_face_groups: - seen_face_groups.add(face_group) - unique_faces.append(face_tuple) - - return unique_faces - - def _show_people_list(self, cursor=None): - """Show list of known people""" - if cursor is None: - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - else: - cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - - if people: - formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last] - print("šŸ‘„ Known people:", ", ".join(formatted_names)) - else: - print("šŸ‘„ No people identified yet") - - def add_tags(self, photo_pattern: str = None, batch_size: int = 10) -> int: - """Add custom tags to photos""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - if photo_pattern: - cursor.execute( - 'SELECT id, filename FROM photos WHERE filename LIKE ? LIMIT ?', - (f'%{photo_pattern}%', batch_size) - ) - else: - cursor.execute('SELECT id, filename FROM photos LIMIT ?', (batch_size,)) - - photos = cursor.fetchall() - - if not photos: - print("No photos found") - return 0 - - print(f"šŸ·ļø Tagging {len(photos)} photos (enter comma-separated tags)") - tagged_count = 0 - - for photo_id, filename in photos: - print(f"\nšŸ“ø {filename}") - tags_input = input("šŸ·ļø Tags: ").strip() - - if tags_input.lower() == 'q': - break - - if tags_input: - tags = [tag.strip() for tag in tags_input.split(',') if tag.strip()] - for tag_name in tags: - # First, insert or get the tag_id from tags table - cursor.execute( - 'INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', - (tag_name,) - ) - cursor.execute( - 'SELECT id FROM tags WHERE tag_name = ?', - (tag_name,) - ) - tag_id = cursor.fetchone()[0] - - # Then, insert the linkage (ignore if already exists due to UNIQUE constraint) - cursor.execute( - 'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', - (photo_id, tag_id) - ) - print(f" āœ… Added {len(tags)} tags") - tagged_count += 1 - - print(f"āœ… Tagged {tagged_count} photos") - return tagged_count - - def stats(self) -> Dict: - """Show database statistics""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - stats = {} - - # Basic counts - cursor.execute('SELECT COUNT(*) FROM photos') - result = cursor.fetchone() - stats['total_photos'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM photos WHERE processed = 1') - result = cursor.fetchone() - stats['processed_photos'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM faces') - result = cursor.fetchone() - stats['total_faces'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL') - result = cursor.fetchone() - stats['identified_faces'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM people') - result = cursor.fetchone() - stats['total_people'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM tags') - result = cursor.fetchone() - stats['unique_tags'] = result[0] if result else 0 - - # Top people - cursor.execute(''' - SELECT - CASE - WHEN p.last_name AND p.first_name THEN p.last_name || ', ' || p.first_name - WHEN p.first_name THEN p.first_name - WHEN p.last_name THEN p.last_name - ELSE 'Unknown' - END as full_name, - COUNT(f.id) as face_count - FROM people p - LEFT JOIN faces f ON p.id = f.person_id - GROUP BY p.id - ORDER BY face_count DESC - LIMIT 15 - ''') - stats['top_people'] = cursor.fetchall() - - # Display stats - print(f"\nšŸ“Š Database Statistics") - print("=" * 40) - print(f"Photos: {stats['processed_photos']}/{stats['total_photos']} processed") - print(f"Faces: {stats['identified_faces']}/{stats['total_faces']} identified") - print(f"People: {stats['total_people']} unique") - print(f"Tags: {stats['unique_tags']} unique") - - if stats['top_people']: - print(f"\nšŸ‘„ Top People:") - for name, count in stats['top_people']: - print(f" {name}: {count} faces") - - unidentified = stats['total_faces'] - stats['identified_faces'] - if unidentified > 0: - print(f"\nāš ļø {unidentified} faces still need identification") - - return stats - - def search_faces(self, person_name: str) -> List[str]: - """Search for photos containing a specific person""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - cursor.execute(''' - SELECT DISTINCT p.filename, p.path - FROM photos p - JOIN faces f ON p.id = f.photo_id - JOIN people pe ON f.person_id = pe.id - WHERE pe.name LIKE ? - ''', (f'%{person_name}%',)) - - results = cursor.fetchall() - - if results: - print(f"\nšŸ” Found {len(results)} photos with '{person_name}':") - for filename, path in results: - print(f" šŸ“ø {filename}") - else: - print(f"šŸ” No photos found with '{person_name}'") - - return [path for filename, path in results] - - def find_similar_faces(self, face_id: int = None, tolerance: float = 0.6, include_same_photo: bool = False) -> List[Dict]: - """Find similar faces across all photos with improved multi-encoding and quality scoring""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - if face_id: - # Find faces similar to a specific face - cursor.execute(''' - SELECT id, photo_id, encoding, location, quality_score - FROM faces - WHERE id = ? - ''', (face_id,)) - target_face = cursor.fetchone() - - if not target_face: - print(f"āŒ Face ID {face_id} not found") - return [] - - target_encoding = self._get_cached_face_encoding(face_id, target_face[2]) - target_quality = target_face[4] if len(target_face) > 4 else 0.5 - - # Get all other faces with quality scores - cursor.execute(''' - SELECT f.id, f.photo_id, f.encoding, f.location, p.filename, f.person_id, f.quality_score - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.id != ? AND f.quality_score >= 0.2 - ''', (face_id,)) - - else: - # Find all unidentified faces and try to match them with identified ones - cursor.execute(''' - SELECT f.id, f.photo_id, f.encoding, f.location, p.filename, f.person_id, f.quality_score - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.quality_score >= 0.2 - ORDER BY f.quality_score DESC, f.id - ''') - - all_faces = cursor.fetchall() - matches = [] - - if face_id: - # Compare target face with all other faces using adaptive tolerance - for face_data in all_faces: - other_id, other_photo_id, other_encoding, other_location, other_filename, other_person_id, other_quality = face_data - other_enc = self._get_cached_face_encoding(other_id, other_encoding) - - # Calculate adaptive tolerance based on both face qualities - avg_quality = (target_quality + other_quality) / 2 - adaptive_tolerance = self._calculate_adaptive_tolerance(tolerance, avg_quality) - - distance = face_recognition.face_distance([target_encoding], other_enc)[0] - if distance <= adaptive_tolerance: - matches.append({ - 'face_id': other_id, - 'photo_id': other_photo_id, - 'filename': other_filename, - 'location': other_location, - 'distance': distance, - 'person_id': other_person_id, - 'quality_score': other_quality, - 'adaptive_tolerance': adaptive_tolerance - }) - - # Get target photo info - cursor.execute('SELECT filename FROM photos WHERE id = ?', (target_face[1],)) - result = cursor.fetchone() - target_filename = result[0] if result else "Unknown" - - print(f"\nšŸ” Finding faces similar to face in: {target_filename}") - print(f"šŸ“ Target face location: {target_face[3]}") - - else: - # Auto-match unidentified faces with identified ones using multi-encoding - identified_faces = [f for f in all_faces if f[5] is not None] # person_id is not None - unidentified_faces = [f for f in all_faces if f[5] is None] # person_id is None - - print(f"\nšŸ” Auto-matching {len(unidentified_faces)} unidentified faces with {len(identified_faces)} known faces...") - - # Group identified faces by person (simplified for now) - person_encodings = {} - for id_face in identified_faces: - person_id = id_face[5] - if person_id not in person_encodings: - # Use single encoding per person for now (simplified) - id_enc = self._get_cached_face_encoding(id_face[0], id_face[2]) - person_encodings[person_id] = [(id_enc, id_face[6])] - - for unid_face in unidentified_faces: - unid_id, unid_photo_id, unid_encoding, unid_location, unid_filename, _, unid_quality = unid_face - unid_enc = self._get_cached_face_encoding(unid_id, unid_encoding) - - best_match = None - best_distance = float('inf') - best_person_id = None - - # Compare with all person encodings - for person_id, encodings in person_encodings.items(): - for person_enc, person_quality in encodings: - # Calculate adaptive tolerance based on both face qualities - avg_quality = (unid_quality + person_quality) / 2 - adaptive_tolerance = self._calculate_adaptive_tolerance(tolerance, avg_quality) - - distance = face_recognition.face_distance([unid_enc], person_enc)[0] - - # Skip if same photo (unless specifically requested for twins detection) - # Note: Same photo check is simplified for performance - if not include_same_photo: - # For now, we'll skip this check to avoid performance issues - # TODO: Implement efficient same-photo checking - pass - - if distance <= adaptive_tolerance and distance < best_distance: - best_distance = distance - best_person_id = person_id - - # Get the best matching face info for this person - cursor.execute(''' - SELECT f.id, f.photo_id, f.location, p.filename - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.person_id = ? AND f.quality_score >= ? - ORDER BY f.quality_score DESC - LIMIT 1 - ''', (person_id, 0.3)) - - best_face_info = cursor.fetchone() - if best_face_info: - best_match = { - 'unidentified_id': unid_id, - 'unidentified_photo_id': unid_photo_id, - 'unidentified_filename': unid_filename, - 'unidentified_location': unid_location, - 'matched_id': best_face_info[0], - 'matched_photo_id': best_face_info[1], - 'matched_filename': best_face_info[3], - 'matched_location': best_face_info[2], - 'person_id': person_id, - 'distance': distance, - 'quality_score': unid_quality, - 'adaptive_tolerance': adaptive_tolerance - } - - if best_match: - matches.append(best_match) - - return matches - - def auto_identify_matches(self, tolerance: float = 0.6, confirm: bool = True, show_faces: bool = False, include_same_photo: bool = False) -> int: - """Automatically identify faces that match already identified faces using GUI""" - # Get all identified faces (one per person) to use as reference faces - with self.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: - print("šŸ” No identified faces found for auto-matching") - return 0 - - # 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 - # Order by person name for user-friendly consistent results across runs - person_faces_list = [] - for person_id, face in person_faces.items(): - # Get person name for ordering - with self.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) - - print(f"\nšŸŽÆ Found {len(person_faces)} identified people to match against") - print("šŸ“Š Confidence Guide: 🟢80%+ = Very High, 🟔70%+ = High, 🟠60%+ = Medium, šŸ”“50%+ = Low, ⚫<50% = Very Low") - - # Find similar faces for each identified person using face-to-face comparison - 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._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: - # Convert to auto-match format - 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) - - matches_by_matched[person_id] = person_matches - - # Flatten all matches for counting - all_matches = [] - for person_matches in matches_by_matched.values(): - all_matches.extend(person_matches) - - if not all_matches: - print("šŸ” No similar faces found for auto-identification") - return 0 - - print(f"\nšŸŽÆ Found {len(all_matches)} potential matches") - - # Pre-fetch all needed data to avoid repeated database queries in update_display - print("šŸ“Š Pre-fetching data for optimal performance...") - data_cache = {} - - with self.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()} - - print(f"āœ… Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths") - - identified_count = 0 - - # Use integrated GUI for auto-matching - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import json - import os - - # Create the main window - root = tk.Tk() - root.title("Auto-Match Face Identification") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Clean up face crops and caches - self._cleanup_face_crops() - self.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving with larger default size - saved_size = self._setup_window_size_saving(root, "gui_config.json") - # Override with larger size for auto-match window - root.geometry("1000x700") - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=1) - - # Left side - identified person - left_frame = ttk.LabelFrame(main_frame, text="Identified person", padding="10") - left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) - - # Right side - unidentified faces that match this person - right_frame = ttk.LabelFrame(main_frame, text="Unidentified Faces to Match", padding="10") - right_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - - # Configure row weights - main_frame.rowconfigure(0, weight=1) - - # Check if there's only one person - if so, disable search functionality - # Use matched_ids instead of person_faces_list since we only show people with potential matches - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - has_only_one_person = len(matched_ids) == 1 - print(f"DEBUG: person_faces_list length: {len(person_faces_list)}, matched_ids length: {len(matched_ids)}, has_only_one_person: {has_only_one_person}") - - # Search controls for filtering people by last name - last_name_search_var = tk.StringVar() - # Search field with label underneath (like modifyidentified edit section) - search_frame = ttk.Frame(left_frame) - search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - - # Search input on the left - search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20) - search_entry.grid(row=0, column=0, sticky=tk.W) - - # Buttons on the right of the search input - 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) - search_btn.pack(side=tk.LEFT, padx=(0, 5)) - clear_btn = ttk.Button(buttons_row, text="Clear", width=6) - clear_btn.pack(side=tk.LEFT) - - # Helper label directly under the search input - if has_only_one_person: - print("DEBUG: Disabling search functionality - only one person found") - # Disable search functionality if there's only one person - search_entry.config(state='disabled') - search_btn.config(state='disabled') - clear_btn.config(state='disabled') - # Add a label to explain why search is disabled - disabled_label = ttk.Label(search_frame, text="(Search disabled - only one person found)", - font=("Arial", 8), foreground="gray") - disabled_label.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(2, 0)) - else: - print("DEBUG: Search functionality enabled - multiple people found") - # Normal helper label when search is enabled - 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)) - - # Matched person info - matched_info_label = ttk.Label(left_frame, text="", font=("Arial", 10, "bold")) - matched_info_label.grid(row=2, column=0, pady=(0, 10), sticky=tk.W) - - # Matched person image - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matched_canvas = tk.Canvas(left_frame, width=300, height=300, bg=canvas_bg_color, highlightthickness=0) - matched_canvas.grid(row=3, column=0, pady=(0, 10)) - - # Save button for this person (will be created after function definitions) - save_btn = None - - # Matches scrollable frame - matches_frame = ttk.Frame(right_frame) - matches_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Control buttons for matches (Select All / Clear All) - matches_controls_frame = ttk.Frame(matches_frame) - matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) - - def select_all_matches(): - """Select all match checkboxes""" - for var in match_vars: - var.set(True) - - def clear_all_matches(): - """Clear all match checkboxes""" - for var in match_vars: - var.set(False) - - select_all_matches_btn = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", command=select_all_matches) - select_all_matches_btn.pack(side=tk.LEFT, padx=(0, 5)) - - clear_all_matches_btn = ttk.Button(matches_controls_frame, text="☐ Clear All", command=clear_all_matches) - clear_all_matches_btn.pack(side=tk.LEFT) - - def update_match_control_buttons_state(): - """Enable/disable Select All / Clear All based on matches presence""" - if match_vars: - select_all_matches_btn.config(state='normal') - clear_all_matches_btn.config(state='normal') - else: - select_all_matches_btn.config(state='disabled') - clear_all_matches_btn.config(state='disabled') - - # Create scrollbar for matches - scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=None) - scrollbar.grid(row=0, column=1, rowspan=1, sticky=(tk.N, tk.S)) - - # Create canvas for matches with scrollbar - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matches_canvas = tk.Canvas(matches_frame, yscrollcommand=scrollbar.set, bg=canvas_bg_color, highlightthickness=0) - matches_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - scrollbar.config(command=matches_canvas.yview) - - # Configure grid weights - right_frame.columnconfigure(0, weight=1) - right_frame.rowconfigure(0, weight=1) - matches_frame.columnconfigure(0, weight=1) - matches_frame.rowconfigure(0, weight=0) # Controls row takes minimal space - matches_frame.rowconfigure(1, weight=1) # Canvas row expandable - - # Control buttons (navigation only) - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0)) - - # Button commands - current_matched_index = 0 - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - filtered_matched_ids = None # filtered subset based on last name search - - match_checkboxes = [] - match_vars = [] - identified_faces_per_person = {} # Track which faces were identified for each person - checkbox_states_per_person = {} # Track checkbox states for each person (unsaved selections) - original_checkbox_states_per_person = {} # Track original/default checkbox states for comparison - - def on_confirm_matches(): - nonlocal identified_count, current_matched_index, identified_faces_per_person - if current_matched_index < len(matched_ids): - matched_id = matched_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Initialize identified faces for this person if not exists - if matched_id not in identified_faces_per_person: - identified_faces_per_person[matched_id] = set() - - with self.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, 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 instead of database query - person_details = data_cache['person_details'].get(match['person_id'], {}) - person_name = person_details.get('full_name', "Unknown") - - # Track this face as identified for this person - identified_faces_per_person[matched_id].add(match['unidentified_id']) - - print(f"āœ… Identified as: {person_name}") - identified_count += 1 - else: - # Face is unchecked - check if it was previously identified for this person - if match['unidentified_id'] in 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 - identified_faces_per_person[matched_id].discard(match['unidentified_id']) - - print(f"āŒ Unidentified: {match['unidentified_filename']}") - - # Update person encodings for all affected persons after database transaction is complete - for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']): - self._update_person_encodings(person_id) - - # After saving, set original states to the current UI states so there are no unsaved changes - current_snapshot = {} - for match, var in zip(matches_for_this_person, match_vars): - unique_key = f"{matched_id}_{match['unidentified_id']}" - current_snapshot[unique_key] = var.get() - checkbox_states_per_person[matched_id] = dict(current_snapshot) - original_checkbox_states_per_person[matched_id] = dict(current_snapshot) - - def on_skip_current(): - nonlocal current_matched_index - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index += 1 - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - update_display() - else: - finish_auto_match() - - def on_go_back(): - nonlocal current_matched_index - if current_matched_index > 0: - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index -= 1 - update_display() - - def has_unsaved_changes(): - """Check if there are any unsaved changes by comparing current states with original states""" - for person_id, current_states in checkbox_states_per_person.items(): - if person_id in original_checkbox_states_per_person: - original_states = 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 apply_last_name_filter(): - """Filter people by last name and update navigation""" - nonlocal filtered_matched_ids, current_matched_index - query = last_name_search_var.get().strip().lower() - if query: - # Filter person_faces_list by last name - filtered_people = [] - for person_id, face, person_name in person_faces_list: - # Extract last name from person_name (format: "Last, First") - if ',' in person_name: - last_name = person_name.split(',')[0].strip().lower() - else: - last_name = person_name.strip().lower() - - if query in last_name: - filtered_people.append((person_id, face, person_name)) - - # Get filtered matched_ids - filtered_matched_ids = [person_id for person_id, _, _ in filtered_people if person_id in matches_by_matched and matches_by_matched[person_id]] - else: - filtered_matched_ids = None - - # Reset to first person in filtered list - current_matched_index = 0 - if filtered_matched_ids: - update_display() - else: - # No matches - clear display - matched_info_label.config(text="No people match filter") - matched_canvas.delete("all") - matched_canvas.create_text(150, 150, text="No matches found", fill="gray") - matches_canvas.delete("all") - update_button_states() - - def clear_last_name_filter(): - """Clear filter and show all people""" - nonlocal filtered_matched_ids, current_matched_index - last_name_search_var.set("") - filtered_matched_ids = None - current_matched_index = 0 - update_display() - - def on_quit_auto_match(): - nonlocal window_destroyed - - # Check for unsaved changes before quitting - if has_unsaved_changes(): - # Show warning dialog with custom width - from tkinter import messagebox - - # Create a custom dialog for better width control - dialog = tk.Toplevel(root) - dialog.title("Unsaved Changes") - dialog.geometry("500x250") - dialog.resizable(True, True) - dialog.transient(root) - dialog.grab_set() - - # Center the dialog - dialog.geometry("+%d+%d" % (root.winfo_rootx() + 50, root.winfo_rooty() + 50)) - - # Main message - message_frame = ttk.Frame(dialog, padding="20") - message_frame.pack(fill=tk.BOTH, expand=True) - - # Warning icon and text - icon_label = ttk.Label(message_frame, text="āš ļø", font=("Arial", 16)) - icon_label.pack(anchor=tk.W) - - main_text = ttk.Label(message_frame, - text="You have unsaved changes that will be lost if you quit.", - font=("Arial", 10)) - main_text.pack(anchor=tk.W, pady=(5, 10)) - - # Options - options_text = ttk.Label(message_frame, - text="• Yes: Save current changes and quit\n" - "• No: Quit without saving\n" - "• Cancel: Return to auto-match", - font=("Arial", 9)) - options_text.pack(anchor=tk.W, pady=(0, 10)) - - - # Buttons - button_frame = ttk.Frame(dialog) - button_frame.pack(fill=tk.X, padx=20, pady=(0, 20)) - - result = None - - def on_yes(): - nonlocal result - result = True - dialog.destroy() - - def on_no(): - nonlocal result - result = False - dialog.destroy() - - def on_cancel(): - nonlocal result - result = None - dialog.destroy() - - yes_btn = ttk.Button(button_frame, text="Yes", command=on_yes) - no_btn = ttk.Button(button_frame, text="No", command=on_no) - cancel_btn = ttk.Button(button_frame, text="Cancel", command=on_cancel) - - yes_btn.pack(side=tk.LEFT, padx=(0, 5)) - no_btn.pack(side=tk.LEFT, padx=5) - cancel_btn.pack(side=tk.RIGHT, padx=(5, 0)) - - # Wait for dialog to close - dialog.wait_window() - - if result is None: # Cancel - don't quit - return - elif result: # Yes - save changes first - # Save current checkbox states before quitting - save_current_checkbox_states() - # Note: We don't actually save to database here, just preserve the states - # The user would need to click Save button for each person to persist changes - print("āš ļø Warning: Changes are preserved but not saved to database.") - print(" Click 'Save Changes' button for each person to persist changes.") - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - def finish_auto_match(): - nonlocal window_destroyed - print(f"\nāœ… Auto-identified {identified_count} faces") - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Create button references for state management - back_btn = ttk.Button(control_frame, text="ā®ļø Back", command=on_go_back) - next_btn = ttk.Button(control_frame, text="ā­ļø Next", command=on_skip_current) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit_auto_match) - - back_btn.grid(row=0, column=0, padx=(0, 5)) - next_btn.grid(row=0, column=1, padx=5) - quit_btn.grid(row=0, column=2, padx=(5, 0)) - - # Create save button now that functions are defined - save_btn = ttk.Button(left_frame, text="šŸ’¾ Save Changes", command=on_confirm_matches) - save_btn.grid(row=4, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) - - def update_button_states(): - """Update button states based on current position""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - # Enable/disable Back button based on position - if current_matched_index > 0: - back_btn.config(state='normal') - else: - back_btn.config(state='disabled') - - # Enable/disable Next button based on position - if current_matched_index < len(active_ids) - 1: - next_btn.config(state='normal') - else: - next_btn.config(state='disabled') - - def update_save_button_text(): - """Update save button text with current person name""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - matched_id = active_ids[current_matched_index] - # Get person name from the first match for this person - matches_for_current_person = matches_by_matched[matched_id] - if matches_for_current_person: - person_id = matches_for_current_person[0]['person_id'] - # Use cached person name instead of database query - person_details = data_cache['person_details'].get(person_id, {}) - person_name = person_details.get('full_name', "Unknown") - save_btn.config(text=f"šŸ’¾ Save changes for {person_name}") - else: - save_btn.config(text="šŸ’¾ Save Changes") - else: - save_btn.config(text="šŸ’¾ Save Changes") - - def save_current_checkbox_states(): - """Save current checkbox states for the current person. - Note: Do NOT modify original states here to avoid false positives - when a user toggles and reverts a checkbox. - """ - if current_matched_index < len(matched_ids) and match_vars: - current_matched_id = matched_ids[current_matched_index] - matches_for_current_person = matches_by_matched[current_matched_id] - - if len(match_vars) == len(matches_for_current_person): - if current_matched_id not in checkbox_states_per_person: - checkbox_states_per_person[current_matched_id] = {} - - # Save current checkbox states for this person - for i, (match, var) in enumerate(zip(matches_for_current_person, match_vars)): - unique_key = f"{current_matched_id}_{match['unidentified_id']}" - current_value = var.get() - checkbox_states_per_person[current_matched_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Saved state for person {current_matched_id}, face {match['unidentified_id']}: {current_value}") - - def update_display(): - nonlocal current_matched_index - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index >= len(active_ids): - finish_auto_match() - return - - matched_id = active_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Update button states - update_button_states() - - # Update save button text with person name - update_save_button_text() - - # Update title - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - root.title(f"Auto-Match Face Identification - {current_matched_index + 1}/{len(active_ids)}") - - # 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}") - # No items on the right panel – disable Select All / Clear All - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - # Skip to next person if available - if current_matched_index < len(matched_ids) - 1: - current_matched_index += 1 - update_display() - else: - finish_auto_match() - return - - first_match = matches_for_this_person[0] - - # Use cached data instead of database queries - person_details = 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 = 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 - matched_info_label.config(text="\n".join(person_info_lines)) - - # Display matched person face - matched_canvas.delete("all") - if show_faces: - matched_crop_path = self._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) - matched_canvas.create_image(150, 150, image=photo) - matched_canvas.image = photo - - # Add photo icon to the matched person face - exactly in corner - # Use actual image dimensions instead of assuming 300x300 - actual_width, actual_height = pil_image.size - self._create_photo_icon(matched_canvas, matched_photo_path, icon_size=20, - face_x=150, face_y=150, - face_width=actual_width, face_height=actual_height, - canvas_width=300, canvas_height=300) - except Exception as e: - matched_canvas.create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") - else: - matched_canvas.create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") - - # Clear and populate unidentified faces - matches_canvas.delete("all") - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - - # Create frame for unidentified faces inside canvas - matches_inner_frame = ttk.Frame(matches_canvas) - matches_canvas.create_window((0, 0), window=matches_inner_frame, anchor="nw") - - # Use cached photo paths instead of database queries - photo_paths = 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._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 checkbox_states_per_person and unique_key in checkbox_states_per_person[matched_id]: - saved_state = checkbox_states_per_person[matched_id][unique_key] - match_var.set(saved_state) - if self.verbose >= 2: - print(f"DEBUG: Restored state for person {matched_id}, face {match['unidentified_id']}: {saved_state}") - # Otherwise, pre-select if this face was previously identified for this person - elif matched_id in identified_faces_per_person and match['unidentified_id'] in identified_faces_per_person[matched_id]: - match_var.set(True) - if self.verbose >= 2: - print(f"DEBUG: Pre-selected identified face for person {matched_id}, face {match['unidentified_id']}") - - match_vars.append(match_var) - - # Capture original state at render time (once per person per face) - if matched_id not in original_checkbox_states_per_person: - original_checkbox_states_per_person[matched_id] = {} - if unique_key not in original_checkbox_states_per_person[matched_id]: - 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 checkbox_states_per_person: - checkbox_states_per_person[person_id] = {} - - current_value = var.get() - checkbox_states_per_person[person_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Checkbox changed for person {person_id}, face {face_id}: {current_value}") - - # Bind the callback to the variable - match_var.trace('w', lambda *args: on_checkbox_change(match_var, matched_id, match['unidentified_id'])) - - # Configure match frame for grid layout - match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width - match_frame.columnconfigure(1, weight=1) # Text column - expandable - match_frame.columnconfigure(2, weight=0) # Image column - fixed width - - # Checkbox without text - checkbox = ttk.Checkbutton(match_frame, variable=match_var) - checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) - match_checkboxes.append(checkbox) - - # Create labels for confidence and filename - confidence_label = ttk.Label(match_frame, text=f"{confidence_pct:.1f}% {confidence_desc}", font=("Arial", 9, "bold")) - confidence_label.grid(row=0, column=1, sticky=tk.W, padx=(0, 10)) - - filename_label = ttk.Label(match_frame, text=f"šŸ“ {match['unidentified_filename']}", font=("Arial", 8), foreground="gray") - filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10)) - - # Unidentified face image - if show_faces: - 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=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0)) - - unidentified_crop_path = self._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 to the unidentified face - self._create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, - face_x=50, face_y=50, - face_width=100, face_height=100, - canvas_width=100, canvas_height=100) - except Exception as e: - match_canvas.create_text(50, 50, text="āŒ", fill="red") - else: - match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") - - # Update Select All / Clear All button states after populating - update_match_control_buttons_state() - - # Update scroll region - matches_canvas.update_idletasks() - matches_canvas.configure(scrollregion=matches_canvas.bbox("all")) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Wire up search controls now that helper functions exist - try: - search_btn.config(command=lambda: apply_last_name_filter()) - clear_btn.config(command=lambda: clear_last_name_filter()) - search_entry.bind('', lambda e: apply_last_name_filter()) - except Exception: - pass - - # Start with first matched person - update_display() - - # Main event loop - try: - root.mainloop() - except tk.TclError: - pass # Window was destroyed - - return identified_count - - def tag_management(self) -> int: - """Tag management GUI - file explorer-like interface for managing photo tags""" - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import os - - # Create the main window - root = tk.Tk() - root.title("Tag Management - Photo Explorer") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - temp_crops = [] - photo_images = [] # Keep PhotoImage refs alive - - # Track folder expand/collapse states - folder_states = {} # folder_path -> is_expanded - - # Track pending tag changes (photo_id -> list of tag IDs) - pending_tag_changes = {} - # Track pending tag removals (photo_id -> list of tag IDs to remove) - pending_tag_removals = {} - existing_tags = [] # Cache of existing tag names from database (for UI display) - tag_id_to_name = {} # Cache of tag ID to name mapping - tag_name_to_id = {} # Cache of tag name to ID mapping - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self._setup_window_size_saving(root) - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.rowconfigure(1, weight=1) - main_frame.rowconfigure(2, weight=0) - - # Title and controls frame - header_frame = ttk.Frame(main_frame) - header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - header_frame.columnconfigure(1, weight=1) - - # Title label - title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W) - - # View mode controls - view_frame = ttk.Frame(header_frame) - view_frame.grid(row=0, column=1, sticky=tk.E) - - view_mode_var = tk.StringVar(value="list") - ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list", - command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons", - command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact", - command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT) - - # Manage Tags button - def open_manage_tags_dialog(): - """Open a dialog to manage tags: list, edit, add, and delete.""" - import tkinter as tk - from tkinter import ttk, messagebox, simpledialog - - # Dialog window - dialog = tk.Toplevel(root) - dialog.title("Manage Tags") - dialog.transient(root) - dialog.grab_set() - dialog.geometry("500x500") - - # Layout frames - top_frame = ttk.Frame(dialog, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(dialog, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(dialog, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - - dialog.columnconfigure(0, weight=1) - dialog.rowconfigure(1, weight=1) - - # Add tag controls (top) - new_tag_var = tk.StringVar() - new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30) - new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E)) - - def add_new_tag(): - tag_name = new_tag_var.get().strip() - if not tag_name: - return - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - conn.commit() - new_tag_var.set("") - refresh_tag_list() - load_existing_tags() - # Refresh main view to reflect new tag options - switch_view_mode(view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to add tag: {e}") - - add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag) - add_btn.grid(row=0, column=1, sticky=tk.W) - top_frame.columnconfigure(0, weight=1) - - # Scrollable tag list (center) - canvas = tk.Canvas(list_frame, highlightthickness=0) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - rows_container = ttk.Frame(canvas) - canvas.create_window((0, 0), window=rows_container, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - list_frame.columnconfigure(0, weight=1) - list_frame.rowconfigure(0, weight=1) - - rows_container.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - - # Selection tracking - selected_tag_vars = {} - current_tags = [] # list of dicts: {id, tag_name} - - def refresh_tag_list(): - # Clear rows - for child in list(rows_container.winfo_children()): - child.destroy() - selected_tag_vars.clear() - current_tags.clear() - # Load tags - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE') - for row in cursor.fetchall(): - current_tags.append({'id': row[0], 'tag_name': row[1]}) - except Exception as e: - messagebox.showerror("Error", f"Failed to load tags: {e}") - return - # Build header - head = ttk.Frame(rows_container) - head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6)) - chk_lbl = ttk.Label(head, text="Delete") - chk_lbl.pack(side=tk.LEFT, padx=(0, 10)) - name_lbl = ttk.Label(head, text="Tag name", width=30) - name_lbl.pack(side=tk.LEFT) - act_lbl = ttk.Label(head, text="Edit", width=6) - act_lbl.pack(side=tk.LEFT, padx=(10, 0)) - - # Populate rows - for idx, tag in enumerate(current_tags, start=1): - row = ttk.Frame(rows_container) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2) - var = tk.BooleanVar(value=False) - selected_tag_vars[tag['id']] = var - chk = ttk.Checkbutton(row, variable=var) - chk.pack(side=tk.LEFT, padx=(0, 10)) - name = ttk.Label(row, text=tag['tag_name'], width=30) - name.pack(side=tk.LEFT) - - def make_edit_handler(tag_id, name_label): - def handler(): - new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_label.cget('text'), parent=dialog) - if new_name is None: - return - new_name = new_name.strip() - if not new_name: - return - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Ensure name is unique - cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id)) - conn.commit() - except Exception as e: - messagebox.showerror("Error", f"Failed to rename tag: {e}") - return - # Update UI and caches - refresh_tag_list() - load_existing_tags() - switch_view_mode(view_mode_var.get()) - return handler - - edit_btn = ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name)) - edit_btn.pack(side=tk.LEFT, padx=(10, 0)) - - refresh_tag_list() - - # Bottom buttons - def delete_selected(): - ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()] - if not ids_to_delete: - return - if not messagebox.askyesno("Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos."): - return - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Remove linkages first to maintain integrity - cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - # Delete tags - cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - conn.commit() - - # Clean up pending tag changes for deleted tags - for photo_id in list(pending_tag_changes.keys()): - pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in ids_to_delete] - if not pending_tag_changes[photo_id]: - del pending_tag_changes[photo_id] - - # Clean up pending tag removals for deleted tags - for photo_id in list(pending_tag_removals.keys()): - pending_tag_removals[photo_id] = [tid for tid in pending_tag_removals[photo_id] if tid not in ids_to_delete] - if not pending_tag_removals[photo_id]: - del pending_tag_removals[photo_id] - - refresh_tag_list() - load_existing_tags() - load_photos() # Refresh photo data to reflect deleted tags - switch_view_mode(view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to delete tags: {e}") - - delete_btn = ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected) - delete_btn.pack(side=tk.LEFT) - quit_btn = ttk.Button(bottom_frame, text="Quit", command=dialog.destroy) - quit_btn.pack(side=tk.RIGHT) - - # Keyboard focus - new_tag_entry.focus_set() - - manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog) - manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0)) - - # Main content area - content_frame = ttk.Frame(main_frame) - content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - content_frame.columnconfigure(0, weight=1) - content_frame.rowconfigure(0, weight=1) - - # Style for consistent gray background - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - - # Create canvas and scrollbar for content - content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0) - content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview) - content_inner = ttk.Frame(content_canvas) - content_canvas.create_window((0, 0), window=content_inner, anchor="nw") - content_canvas.configure(yscrollcommand=content_scrollbar.set) - - content_inner.bind( - "", - lambda e: content_canvas.configure(scrollregion=content_canvas.bbox("all")) - ) - - content_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - content_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - - # Bottom frame for save button - bottom_frame = ttk.Frame(main_frame) - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) - - # Save tagging button (function will be defined later) - save_button = ttk.Button(bottom_frame, text="Save Tagging") - save_button.pack(side=tk.RIGHT, padx=10, pady=5) - - # Quit button with warning for pending changes - def quit_with_warning(): - """Quit the dialog, but warn if there are pending changes""" - # Check for pending changes - has_pending_changes = bool(pending_tag_changes or pending_tag_removals) - - if has_pending_changes: - total_additions = sum(len(tags) for tags in pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in pending_tag_removals.values()) - - changes_text = [] - if total_additions > 0: - changes_text.append(f"{total_additions} tag addition(s)") - if total_removals > 0: - changes_text.append(f"{total_removals} tag removal(s)") - - changes_summary = " and ".join(changes_text) - - result = messagebox.askyesnocancel( - "Unsaved Changes", - f"You have unsaved changes: {changes_summary}.\n\n" - "Do you want to save your changes before quitting?\n\n" - "Yes = Save and quit\n" - "No = Quit without saving\n" - "Cancel = Stay in dialog" - ) - - if result is True: # Yes - Save and quit - save_tagging_changes() - root.destroy() - elif result is False: # No - Quit without saving - root.destroy() - # If result is None (Cancel), do nothing - stay in dialog - else: - # No pending changes, just quit - root.destroy() - - quit_button = ttk.Button(bottom_frame, text="Quit", command=quit_with_warning) - quit_button.pack(side=tk.RIGHT, padx=(0, 10), pady=5) - - # Enable mouse scroll anywhere in the dialog - def on_mousewheel(event): - content_canvas.yview_scroll(int(-1*(event.delta/120)), "units") - - # Column resizing variables - resize_start_x = 0 - resize_start_widths = [] - current_visible_cols = [] - is_resizing = False - - def start_resize(event, col_idx): - """Start column resizing""" - nonlocal resize_start_x, resize_start_widths, is_resizing - print(f"DEBUG: start_resize called for column {col_idx}, event.x_root={event.x_root}") # Debug output - is_resizing = True - resize_start_x = event.x_root - # Store current column widths - resize_start_widths = [] - for i, col in enumerate(current_visible_cols): - resize_start_widths.append(col['width']) - print(f"DEBUG: Stored widths: {resize_start_widths}") # Debug output - # Change cursor globally - root.configure(cursor="sb_h_double_arrow") - - def do_resize(event, col_idx): - """Perform column resizing""" - nonlocal resize_start_x, resize_start_widths, is_resizing - print(f"DEBUG: do_resize called for column {col_idx}, is_resizing={is_resizing}") # Debug output - if not is_resizing or not resize_start_widths or not current_visible_cols: - return - - # Calculate width change - delta_x = event.x_root - resize_start_x - - # Update column widths - if col_idx < len(current_visible_cols) and col_idx + 1 < len(current_visible_cols): - # Resize current and next column - new_width_left = max(50, resize_start_widths[col_idx] + delta_x) - new_width_right = max(50, resize_start_widths[col_idx + 1] - delta_x) - - # Update column configuration - current_visible_cols[col_idx]['width'] = new_width_left - current_visible_cols[col_idx + 1]['width'] = new_width_right - - # Update the actual column configuration in the global config - for i, col in enumerate(column_config['list']): - if col['key'] == current_visible_cols[col_idx]['key']: - column_config['list'][i]['width'] = new_width_left - elif col['key'] == current_visible_cols[col_idx + 1]['key']: - column_config['list'][i]['width'] = new_width_right - - # Force immediate visual update by reconfiguring grid weights - try: - header_frame_ref = None - row_frames = [] - for widget in content_inner.winfo_children(): - # First frame is header, subsequent frames are data rows - if isinstance(widget, ttk.Frame): - if header_frame_ref is None: - header_frame_ref = widget - else: - row_frames.append(widget) - - # Update header columns (accounting for separator columns) - if header_frame_ref is not None: - # Update both minsize and weight to force resize - header_frame_ref.columnconfigure(col_idx*2, - weight=current_visible_cols[col_idx]['weight'], - minsize=new_width_left) - header_frame_ref.columnconfigure((col_idx+1)*2, - weight=current_visible_cols[col_idx+1]['weight'], - minsize=new_width_right) - print(f"DEBUG: Updated header columns {col_idx*2} and {(col_idx+1)*2} with widths {new_width_left} and {new_width_right}") - - # Update each data row frame columns (no separators, direct indices) - for rf in row_frames: - rf.columnconfigure(col_idx, - weight=current_visible_cols[col_idx]['weight'], - minsize=new_width_left) - rf.columnconfigure(col_idx+1, - weight=current_visible_cols[col_idx+1]['weight'], - minsize=new_width_right) - - # Force update of the display - root.update_idletasks() - - except Exception as e: - print(f"DEBUG: Error during resize update: {e}") # Debug output - pass # Ignore errors during resize - - def stop_resize(event): - """Stop column resizing""" - nonlocal is_resizing - if is_resizing: - print(f"DEBUG: stop_resize called, was resizing={is_resizing}") # Debug output - is_resizing = False - root.configure(cursor="") - - # Bind mouse wheel to the entire window - root.bind_all("", on_mousewheel) - - # Global mouse release handler that only stops resize if we're actually resizing - def global_mouse_release(event): - if is_resizing: - stop_resize(event) - root.bind_all("", global_mouse_release) - - # Unbind when window is destroyed - def cleanup_mousewheel(): - try: - root.unbind_all("") - root.unbind_all("") - except: - pass - - root.bind("", lambda e: cleanup_mousewheel()) - - # Load photos from database - photos_data = [] - - # Column visibility state - column_visibility = { - 'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'compact': {'filename': True, 'faces': True, 'tags': True} - } - - # Column order and configuration - column_config = { - 'list': [ - {'key': 'id', 'label': 'ID', 'width': 50, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 150, 'weight': 1}, - {'key': 'path', 'label': 'Path', 'width': 200, 'weight': 2}, - {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ], - 'icons': [ - {'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0}, - {'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, - {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ], - 'compact': [ - {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ] - } - - def load_photos(): - nonlocal photos_data - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added, - COUNT(f.id) as face_count, - GROUP_CONCAT(DISTINCT t.tag_name) as tags - FROM photos p - LEFT JOIN faces f ON f.photo_id = p.id - LEFT JOIN phototaglinkage ptl ON ptl.photo_id = p.id - LEFT JOIN tags t ON t.id = ptl.tag_id - GROUP BY p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added - ORDER BY p.date_taken DESC, p.filename - ''') - photos_data = [] - for row in cursor.fetchall(): - photos_data.append({ - 'id': row[0], - 'filename': row[1], - 'path': row[2], - 'processed': row[3], - 'date_taken': row[4], - 'date_added': row[5], - 'face_count': row[6] or 0, - 'tags': row[7] or "" - }) - - def prepare_folder_grouped_data(): - """Prepare photo data grouped by folders""" - import os - from collections import defaultdict - - # Group photos by folder - folder_groups = defaultdict(list) - for photo in photos_data: - folder_path = os.path.dirname(photo['path']) - folder_name = os.path.basename(folder_path) if folder_path else "Root" - folder_groups[folder_path].append(photo) - - # Sort folders by path and photos within each folder by date_taken - sorted_folders = [] - for folder_path in sorted(folder_groups.keys()): - folder_name = os.path.basename(folder_path) if folder_path else "Root" - photos_in_folder = sorted(folder_groups[folder_path], - key=lambda x: x['date_taken'] or '', reverse=True) - - # Initialize folder state if not exists (default to expanded) - if folder_path not in folder_states: - folder_states[folder_path] = True - - sorted_folders.append({ - 'folder_path': folder_path, - 'folder_name': folder_name, - 'photos': photos_in_folder, - 'photo_count': len(photos_in_folder) - }) - - return sorted_folders - - def create_folder_header(parent, folder_info, current_row, col_count, view_mode): - """Create a collapsible folder header with toggle button""" - # Create folder header frame - folder_header_frame = ttk.Frame(parent) - folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5)) - folder_header_frame.configure(relief='raised', borderwidth=1) - - # Create toggle button - is_expanded = folder_states.get(folder_info['folder_path'], True) - toggle_text = "ā–¼" if is_expanded else "ā–¶" - toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1, - command=lambda: toggle_folder(folder_info['folder_path'], view_mode), - font=("Arial", 8), relief='flat', bd=1) - toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5) - - # Create folder label - folder_label = ttk.Label(folder_header_frame, - text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos)", - font=("Arial", 11, "bold")) - folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5) - - return folder_header_frame - - def toggle_folder(folder_path, view_mode): - """Toggle folder expand/collapse state and refresh view""" - folder_states[folder_path] = not folder_states.get(folder_path, True) - switch_view_mode(view_mode) - - def load_existing_tags(): - """Load existing tags from database""" - nonlocal existing_tags, tag_id_to_name, tag_name_to_id - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') - existing_tags = [] - tag_id_to_name = {} - tag_name_to_id = {} - for row in cursor.fetchall(): - tag_id, tag_name = row - existing_tags.append(tag_name) - tag_id_to_name[tag_id] = tag_name - tag_name_to_id[tag_name] = tag_id - - def create_tagging_widget(parent, photo_id, current_tags=""): - """Create a tagging widget with dropdown and text input""" - import tkinter as tk - from tkinter import ttk - - # Create frame for tagging widget - tagging_frame = ttk.Frame(parent) - - # Create combobox for tag selection/input - tag_var = tk.StringVar() - tag_combo = ttk.Combobox(tagging_frame, textvariable=tag_var, width=12) - tag_combo['values'] = existing_tags - tag_combo.pack(side=tk.LEFT, padx=2, pady=2) - - # Create label to show current pending tags - pending_tags_var = tk.StringVar() - pending_tags_label = ttk.Label(tagging_frame, textvariable=pending_tags_var, - font=("Arial", 8), foreground="blue", width=20) - pending_tags_label.pack(side=tk.LEFT, padx=2, pady=2) - - # Initialize pending tags display - if photo_id in pending_tag_changes: - # Convert tag IDs to names for display - pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - else: - pending_tags_var.set(current_tags or "") - - # Add button to add tag - def add_tag(): - tag_name = tag_var.get().strip() - if tag_name: - # Get or create tag ID - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] - else: - # Create new tag in database - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - tag_id = cursor.fetchone()[0] - # Update mappings - tag_name_to_id[tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - - # Check if tag already exists (compare tag IDs) before adding to pending changes - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - all_existing_tag_ids = existing_tag_ids + pending_tag_ids - - if tag_id not in all_existing_tag_ids: - # Only add to pending changes if tag is actually new - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - pending_tag_changes[photo_id].append(tag_id) - # Update display - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - tag_var.set("") # Clear the input field - - add_button = tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag) - add_button.pack(side=tk.LEFT, padx=2, pady=2) - - # Remove button to remove last tag - def remove_tag(): - if photo_id in pending_tag_changes and pending_tag_changes[photo_id]: - pending_tag_changes[photo_id].pop() - if pending_tag_changes[photo_id]: - # Convert tag IDs to names for display - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - else: - pending_tags_var.set("") - del pending_tag_changes[photo_id] - - remove_button = tk.Button(tagging_frame, text="-", width=2, height=1, command=remove_tag) - remove_button.pack(side=tk.LEFT, padx=2, pady=2) - - return tagging_frame - - def save_tagging_changes(): - """Save all pending tag changes to database""" - if not pending_tag_changes and not pending_tag_removals: - messagebox.showinfo("Info", "No tag changes to save.") - return - - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Handle tag additions - for photo_id, tag_ids in pending_tag_changes.items(): - for tag_id in tag_ids: - # Insert linkage (ignore if already exists) - cursor.execute( - 'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', - (photo_id, tag_id) - ) - - # Handle tag removals - for photo_id, tag_ids in pending_tag_removals.items(): - for tag_id in tag_ids: - # Remove linkage - cursor.execute( - 'DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', - (photo_id, tag_id) - ) - - conn.commit() - - # Store counts before clearing - saved_additions = len(pending_tag_changes) - saved_removals = len(pending_tag_removals) - - # Clear pending changes and reload data - pending_tag_changes.clear() - pending_tag_removals.clear() - load_existing_tags() - load_photos() - switch_view_mode(view_mode_var.get()) - update_save_button_text() - - message = f"Saved {saved_additions} tag additions" - if saved_removals > 0: - message += f" and {saved_removals} tag removals" - message += "." - messagebox.showinfo("Success", message) - - except Exception as e: - messagebox.showerror("Error", f"Failed to save tags: {str(e)}") - - def update_save_button_text(): - """Update save button text to show pending changes count""" - total_additions = sum(len(tags) for tags in pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in pending_tag_removals.values()) - total_changes = total_additions + total_removals - - if total_changes > 0: - save_button.configure(text=f"Save Tagging ({total_changes} pending)") - else: - save_button.configure(text="Save Tagging") - - # Configure the save button command now that the function is defined - save_button.configure(command=save_tagging_changes) - - def clear_content(): - for widget in content_inner.winfo_children(): - widget.destroy() - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - photo_images.clear() - - def show_column_context_menu(event, view_mode): - """Show context menu for column visibility""" - # Create a custom popup window instead of a menu - popup = tk.Toplevel(root) - popup.wm_overrideredirect(True) - popup.wm_geometry(f"+{event.x_root}+{event.y_root}") - popup.configure(bg='white', relief='flat', bd=0) - - # Define columns that cannot be hidden - protected_columns = { - 'icons': ['thumbnail'], - 'compact': ['filename'], - 'list': ['filename'] - } - - # Create frame for menu items - menu_frame = tk.Frame(popup, bg='white') - menu_frame.pack(padx=2, pady=2) - - # Variables to track checkbox states - checkbox_vars = {} - - for col in column_config[view_mode]: - key = col['key'] - label = col['label'] - is_visible = column_visibility[view_mode][key] - is_protected = key in protected_columns.get(view_mode, []) - - # Create frame for this menu item - item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0) - item_frame.pack(fill=tk.X, pady=1) - - # Create checkbox variable - var = tk.BooleanVar(value=is_visible) - checkbox_vars[key] = var - - def make_toggle_command(col_key, var_ref): - def toggle_column(): - if col_key in protected_columns.get(view_mode, []): - return - # The checkbox has already toggled its state automatically - # Just sync it with our column visibility - column_visibility[view_mode][col_key] = var_ref.get() - # Refresh the view - switch_view_mode(view_mode) - return toggle_column - - if is_protected: - # Protected columns - disabled checkbox - cb = tk.Checkbutton(item_frame, text=label, variable=var, - state='disabled', bg='white', fg='gray', - font=("Arial", 9), relief='flat', bd=0, - highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', - font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5)) - else: - # Regular columns - clickable checkbox - cb = tk.Checkbutton(item_frame, text=label, variable=var, - command=make_toggle_command(key, var), - bg='white', font=("Arial", 9), relief='flat', bd=0, - highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - - # Function to close popup - def close_popup(): - try: - popup.destroy() - except: - pass - - # Bind events to close popup - def close_on_click_outside(event): - # Close popup when clicking anywhere in the main window - # Check if the click is not on the popup itself - if event.widget != popup: - try: - # Check if popup still exists - popup.winfo_exists() - # If we get here, popup exists, so close it - close_popup() - except tk.TclError: - # Popup was already destroyed, do nothing - pass - - root.bind("", close_on_click_outside) - root.bind("", close_on_click_outside) - - # Also bind to the main content area - content_canvas.bind("", close_on_click_outside) - content_canvas.bind("", close_on_click_outside) - - # Focus the popup - popup.focus_set() - - # Shared tag linking functions for all view modes - def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags): - """Create a handler function for adding tags to a photo""" - def handler(): - # Create popup window for tag management - popup = tk.Toplevel(root) - popup.title("Manage Photo Tags") - popup.transient(root) - popup.grab_set() - popup.geometry("500x400") - popup.resizable(True, True) - - # Layout frames - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - - popup.columnconfigure(0, weight=1) - popup.rowconfigure(1, weight=1) - - # Top frame - dropdown to select tag to add - ttk.Label(top_frame, text="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W) - tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly') - combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - def add_selected_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - - # Get or create tag ID - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] - else: - # Create new tag in database - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - tag_id = cursor.fetchone()[0] - # Update mappings - tag_name_to_id[tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - - # Check if tag already exists (compare tag IDs) before adding to pending changes - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - all_existing_tag_ids = existing_tag_ids + pending_tag_ids - - if tag_id not in all_existing_tag_ids: - # Only add to pending changes if tag is actually new - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - pending_tag_changes[photo_id].append(tag_id) - refresh_tag_list() - update_save_button_text() - - tag_var.set("") # Clear the dropdown - - add_btn = ttk.Button(top_frame, text="Add", command=add_selected_tag) - add_btn.grid(row=0, column=2, padx=(0, 8)) - - # List frame - show all linked tags (existing + pending) with checkboxes - ttk.Label(list_frame, text="Linked tags (check to remove):", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=(0, 5)) - - # Create scrollable frame for tags - canvas = tk.Canvas(list_frame, height=200) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - - scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # Variables to track selected tags for removal - selected_tag_vars = {} - - def refresh_tag_list(): - # Clear existing widgets - for widget in scrollable_frame.winfo_children(): - widget.destroy() - selected_tag_vars.clear() - - # Get existing tags for this photo - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - - # Get pending tags for this photo - pending_tag_ids = pending_tag_changes.get(photo_id, []) - - # Get pending removals for this photo - pending_removal_ids = pending_tag_removals.get(photo_id, []) - - # Combine and deduplicate tag IDs, but exclude tags marked for removal - all_tag_ids = existing_tag_ids + pending_tag_ids - unique_tag_ids = list(set(all_tag_ids)) # Remove duplicates - # Remove tags that are marked for removal - unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids] - - # Convert to names for display - unique_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids] - - if not unique_tag_names: - ttk.Label(scrollable_frame, text="No tags linked to this photo", - foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Create checkboxes for each tag - for i, tag_id in enumerate(unique_tag_ids): - tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}") - var = tk.BooleanVar() - selected_tag_vars[tag_name] = var - - # Determine if this is a pending tag - is_pending = tag_id in pending_tag_ids - status_text = " (pending)" if is_pending else " (saved)" - status_color = "blue" if is_pending else "black" - - frame = ttk.Frame(scrollable_frame) - frame.pack(fill=tk.X, pady=1) - - checkbox = ttk.Checkbutton(frame, variable=var) - checkbox.pack(side=tk.LEFT, padx=(0, 5)) - - label = ttk.Label(frame, text=tag_name + status_text, foreground=status_color) - label.pack(side=tk.LEFT) - - def remove_selected_tags(): - # Get tag IDs to remove (convert names to IDs) - tag_ids_to_remove = [] - for tag_name, var in selected_tag_vars.items(): - if var.get() and tag_name in tag_name_to_id: - tag_ids_to_remove.append(tag_name_to_id[tag_name]) - - if not tag_ids_to_remove: - return - - # Remove from pending changes (using IDs) - if photo_id in pending_tag_changes: - pending_tag_changes[photo_id] = [ - tid for tid in pending_tag_changes[photo_id] - if tid not in tag_ids_to_remove - ] - if not pending_tag_changes[photo_id]: - del pending_tag_changes[photo_id] - - # Track removals for saved tags (using IDs) - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - for tag_id in tag_ids_to_remove: - if tag_id in existing_tag_ids: - # This is a saved tag, add to pending removals - if photo_id not in pending_tag_removals: - pending_tag_removals[photo_id] = [] - if tag_id not in pending_tag_removals[photo_id]: - pending_tag_removals[photo_id].append(tag_id) - - refresh_tag_list() - update_save_button_text() - - # Bottom frame - buttons - remove_btn = ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags) - remove_btn.pack(side=tk.LEFT, padx=(0, 8)) - - close_btn = ttk.Button(bottom_frame, text="Close", command=popup.destroy) - close_btn.pack(side=tk.RIGHT) - - # Initial load - refresh_tag_list() - - # Update main display when dialog closes - def on_close(): - # Update the main display - existing_tags_list = self._parse_tags_string(photo_tags) - - # Get pending tag IDs and convert to names - pending_tag_names = [] - if photo_id in pending_tag_changes: - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - - # Get pending removal IDs and convert to names - pending_removal_names = [] - if photo_id in pending_tag_removals: - pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] - - all_tags = existing_tags_list + pending_tag_names - unique_tags = self._deduplicate_tags(all_tags) - # Remove tags marked for removal - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_tags = ", ".join(unique_tags) if unique_tags else "None" - label_widget.configure(text=current_tags) - popup.destroy() - - popup.protocol("WM_DELETE_WINDOW", on_close) - - return handler - - - def create_tag_buttons_frame(parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0): - """Create a frame with tag display and add button that can be used in any view mode""" - tags_frame = ttk.Frame(parent) - - # Display current tags - existing_tags_list = self._parse_tags_string(photo_tags) - - # Get pending tag names - pending_tag_names = [] - if photo_id in pending_tag_changes: - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - - # Get pending removal names - pending_removal_names = [] - if photo_id in pending_tag_removals: - pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] - - all_tags = existing_tags_list + pending_tag_names - unique_tags = self._deduplicate_tags(all_tags) - # Remove tags marked for removal - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_display = ", ".join(unique_tags) if unique_tags else "None" - - tags_text = ttk.Label(tags_frame, text=current_display) - tags_text.pack(side=tk.LEFT) - - # Add button with linkage icon - add_btn = tk.Button(tags_frame, text="šŸ”—", width=2, - command=create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags)) - add_btn.pack(side=tk.LEFT, padx=(6, 0)) - - # Pack or grid the frame based on the view mode - if use_grid: - tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W) - else: - tags_frame.pack(side=tk.LEFT, padx=5) - - return tags_frame - - def show_list_view(): - clear_content() - - # Get visible columns and store globally for resize functions - nonlocal current_visible_cols - current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]] - col_count = len(current_visible_cols) - - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure column weights for visible columns - for i, col in enumerate(current_visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header row - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Configure header frame columns (accounting for separators) - for i, col in enumerate(current_visible_cols): - header_frame.columnconfigure(i*2, weight=col['weight'], minsize=col['width']) - if i < len(current_visible_cols) - 1: - header_frame.columnconfigure(i*2+1, weight=0, minsize=1) # Separator column - - # Create header labels with right-click context menu and resizable separators - for i, col in enumerate(current_visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i*2, padx=5, sticky=tk.W) - # Bind right-click to each label as well - header_label.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) - - # Add resizable vertical separator after each column (except the last one) - if i < len(current_visible_cols) - 1: - # Create a more visible separator frame with inner dark line - separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow") # Bright red for debugging - separator_frame.grid(row=0, column=i*2+1, sticky='ns', padx=0) - separator_frame.grid_propagate(False) # Maintain fixed width - # Inner dark line for better contrast - inner_line = tk.Frame(separator_frame, bg='darkred', width=2) # Dark red for debugging - inner_line.pack(fill=tk.Y, expand=True) - - # Make separator resizable - separator_frame.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) - separator_frame.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) - separator_frame.bind("", stop_resize) - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) # Orange for debugging - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred'))) # Red for debugging - - # Also bind to the inner line for better hit detection - inner_line.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) - inner_line.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) - inner_line.bind("", stop_resize) - - # Bind right-click to the entire header frame - header_frame.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) - - # Add separator - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Get folder-grouped data - folder_data = prepare_folder_grouped_data() - - # Add folder sections and photo rows - current_row = 2 - for folder_info in folder_data: - # Add collapsible folder header - create_folder_header(content_inner, folder_info, current_row, col_count, 'list') - current_row += 1 - - # Add photos in this folder only if expanded - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - - # Configure row frame columns (no separators in data rows) - for i, col in enumerate(current_visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - for i, col in enumerate(current_visible_cols): - key = col['key'] - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'path': - text = photo['path'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - # Use shared tag buttons frame for list view - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i) - continue - - ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W) - - current_row += 1 - - def show_icon_view(): - clear_content() - - # Get visible columns - visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]] - col_count = len(visible_cols) - - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure column weights for visible columns - for i, col in enumerate(visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header row - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header labels with right-click context menu - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - # Bind right-click to each label as well - header_label.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) - - # Bind right-click to the entire header frame - header_frame.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) - - # Add separator - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Get folder-grouped data - folder_data = prepare_folder_grouped_data() - - # Show photos grouped by folders - current_row = 2 - for folder_info in folder_data: - # Add collapsible folder header - create_folder_header(content_inner, folder_info, current_row, col_count, 'icons') - current_row += 1 - - # Add photos in this folder only if expanded - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2) - - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - col_idx = 0 - for col in visible_cols: - key = col['key'] - - if key == 'thumbnail': - # Thumbnail column - thumbnail_frame = ttk.Frame(row_frame) - thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - - try: - if os.path.exists(photo['path']): - img = Image.open(photo['path']) - img.thumbnail((150, 150), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - photo_images.append(photo_img) - - # Create canvas for image - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_image(75, 75, image=photo_img) - else: - # Placeholder for missing image - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="šŸ–¼ļø", fill="gray", font=("Arial", 24)) - except Exception: - # Error loading image - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="āŒ", fill="red", font=("Arial", 24)) - else: - # Data columns - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - # Use shared tag buttons frame for icon view - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - - ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) - - col_idx += 1 - - current_row += 1 - - def show_compact_view(): - clear_content() - - # Get visible columns - visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]] - col_count = len(visible_cols) - - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure column weights for visible columns - for i, col in enumerate(visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header labels with right-click context menu - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - # Bind right-click to each label as well - header_label.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) - - # Bind right-click to the entire header frame - header_frame.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) - - # Add separator - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Get folder-grouped data - folder_data = prepare_folder_grouped_data() - - # Add folder sections and photo rows - current_row = 2 - for folder_info in folder_data: - # Add collapsible folder header - create_folder_header(content_inner, folder_info, current_row, col_count, 'compact') - current_row += 1 - - # Add photos in this folder only if expanded - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - col_idx = 0 - for col in visible_cols: - key = col['key'] - if key == 'filename': - text = photo['filename'] - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - # Use shared tag buttons frame for compact view - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - - ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) - col_idx += 1 - - current_row += 1 - - def switch_view_mode(mode): - if mode == "list": - show_list_view() - elif mode == "icons": - show_icon_view() - elif mode == "compact": - show_compact_view() - - # No need for canvas resize handler since icon view is now single column - - # Load initial data and show default view - load_existing_tags() - load_photos() - show_list_view() - - # Show window - root.deiconify() - root.mainloop() - - return 0 - - def modifyidentified(self) -> int: - """Modify identified faces interface - empty window with Quit button for now""" - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import os - - # Simple tooltip implementation - class ToolTip: - 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 - - # Create the main window - root = tk.Tk() - root.title("View and Modify Identified Faces") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - temp_crops = [] - right_panel_images = [] # Keep PhotoImage refs alive - selected_person_id = None - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self._setup_window_size_saving(root) - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=2) - main_frame.rowconfigure(1, weight=1) - - # Title label - title_label = ttk.Label(main_frame, text="View and Modify Identified Faces", font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) - - # Left panel: People list - people_frame = ttk.LabelFrame(main_frame, text="People", padding="10") - people_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8)) - people_frame.columnconfigure(0, weight=1) - - # Search controls (Last Name) with label under the input (match auto-match style) - last_name_search_var = tk.StringVar() - 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=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) - search_btn.pack(side=tk.LEFT, padx=(0, 5)) - clear_btn = ttk.Button(buttons_row, text="Clear", width=6) - 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_canvas = tk.Canvas(people_frame, bg='white') - people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview) - people_list_inner = ttk.Frame(people_canvas) - people_canvas.create_window((0, 0), window=people_list_inner, anchor="nw") - people_canvas.configure(yscrollcommand=people_scrollbar.set) - - 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) - - # Right panel: Faces for selected person - faces_frame = ttk.LabelFrame(main_frame, text="Faces", padding="10") - faces_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) - faces_frame.columnconfigure(0, weight=1) - faces_frame.rowconfigure(0, weight=1) - - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - # Match auto-match UI: set gray background for left canvas and remove highlight border - try: - people_canvas.configure(bg=canvas_bg_color, highlightthickness=0) - except Exception: - pass - faces_canvas = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0) - faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=faces_canvas.yview) - faces_inner = ttk.Frame(faces_canvas) - faces_canvas.create_window((0, 0), window=faces_inner, anchor="nw") - faces_canvas.configure(yscrollcommand=faces_scrollbar.set) - - faces_inner.bind( - "", - lambda e: faces_canvas.configure(scrollregion=faces_canvas.bbox("all")) - ) - - # Track current person for responsive face grid - current_person_id = None - current_person_name = "" - resize_job = None - - # Track unmatched faces (temporary changes) - unmatched_faces = set() # All face IDs unmatched across people (for global save) - unmatched_by_person = {} # person_id -> set(face_id) for per-person undo - original_faces_data = [] # store original faces data for potential future use - - def on_faces_canvas_resize(event): - nonlocal resize_job - if current_person_id is None: - return - # Debounce re-render on resize - try: - if resize_job is not None: - root.after_cancel(resize_job) - except Exception: - pass - resize_job = root.after(150, lambda: show_person_faces(current_person_id, current_person_name)) - - faces_canvas.bind("", on_faces_canvas_resize) - - 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)) - - # Load people from DB with counts - people_data = [] # list of dicts: {id, name, count, first_name, last_name} - people_filtered = None # filtered subset based on last name search - - def load_people(): - nonlocal people_data - with self.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 - """ - ) - 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}" - - 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: - apply_last_name_filter() - except Exception: - pass - - # Wire up search controls now that helper functions exist - try: - search_btn.config(command=lambda: apply_last_name_filter()) - clear_btn.config(command=lambda: clear_last_name_filter()) - search_entry.bind('', lambda e: apply_last_name_filter()) - except Exception: - pass - - def apply_last_name_filter(): - nonlocal people_filtered - query = last_name_search_var.get().strip().lower() - if query: - people_filtered = [p for p in people_data if p.get('last_name', '').lower().find(query) != -1] - else: - people_filtered = None - populate_people_list() - # Update right panel based on filtered results - source = people_filtered if people_filtered is not None else people_data - if source: - # Load faces for the first person in the list - first = source[0] - try: - # Update selection state - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Bold the first label if present - first_row = people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - # Show faces for the first person - show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - # No matches: clear faces panel - clear_faces_panel() - - def clear_last_name_filter(): - nonlocal people_filtered - last_name_search_var.set("") - people_filtered = None - populate_people_list() - # After clearing, load faces for the first available person if any - if people_data: - first = people_data[0] - try: - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - first_row = people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - clear_faces_panel() - - def clear_faces_panel(): - for w in faces_inner.winfo_children(): - w.destroy() - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - right_panel_images.clear() - - def unmatch_face(face_id: int): - """Temporarily unmatch a face from the current person""" - nonlocal unmatched_faces, unmatched_by_person - unmatched_faces.add(face_id) - # Track per-person for Undo - person_set = unmatched_by_person.get(current_person_id) - if person_set is None: - person_set = set() - unmatched_by_person[current_person_id] = person_set - person_set.add(face_id) - # Refresh the display - show_person_faces(current_person_id, current_person_name) - - def undo_changes(): - """Undo all temporary changes""" - nonlocal unmatched_faces, unmatched_by_person - if current_person_id in unmatched_by_person: - for fid in list(unmatched_by_person[current_person_id]): - unmatched_faces.discard(fid) - unmatched_by_person[current_person_id].clear() - # Refresh the display - show_person_faces(current_person_id, current_person_name) - - def save_changes(): - """Save unmatched faces to database""" - if not unmatched_faces: - return - - # Confirm with user - result = messagebox.askyesno( - "Confirm Changes", - f"Are you sure you want to unlink {len(unmatched_faces)} face(s) from '{current_person_name}'?\n\n" - "This will make these faces unidentified again." - ) - - if not result: - return - - # Update database - with self.get_db_connection() as conn: - cursor = conn.cursor() - for face_id in unmatched_faces: - cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) - conn.commit() - - # Store count for message before clearing - unlinked_count = len(unmatched_faces) - - # Clear unmatched faces and refresh - unmatched_faces.clear() - original_faces_data.clear() - - # Refresh people list to update counts - load_people() - populate_people_list() - - # Refresh faces display - show_person_faces(current_person_id, current_person_name) - - messagebox.showinfo("Changes Saved", f"Successfully unlinked {unlinked_count} face(s) from '{current_person_name}'.") - - def show_person_faces(person_id: int, person_name: str): - nonlocal current_person_id, current_person_name, unmatched_faces, original_faces_data - current_person_id = person_id - current_person_name = person_name - clear_faces_panel() - - # Determine how many columns fit the available width - available_width = faces_canvas.winfo_width() - if available_width <= 1: - available_width = faces_frame.winfo_width() - tile_width = 150 # approx tile + padding - cols = max(1, available_width // tile_width) - - # Header row - header = ttk.Label(faces_inner, text=f"Faces for: {person_name}", font=("Arial", 12, "bold")) - header.grid(row=0, column=0, columnspan=cols, sticky=tk.W, pady=(0, 5)) - - # Control buttons row - button_frame = ttk.Frame(faces_inner) - button_frame.grid(row=1, column=0, columnspan=cols, sticky=tk.W, pady=(0, 10)) - - # Enable Undo only if current person has unmatched faces - current_has_unmatched = bool(unmatched_by_person.get(current_person_id)) - undo_btn = ttk.Button(button_frame, text="↶ Undo changes", - command=lambda: undo_changes(), - state="disabled" if not current_has_unmatched else "normal") - undo_btn.pack(side=tk.LEFT, padx=(0, 10)) - - # Note: Save button moved to bottom control bar - - # Query faces for this person - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """ - SELECT f.id, f.location, ph.path, ph.filename - FROM faces f - JOIN photos ph ON ph.id = f.photo_id - WHERE f.person_id = ? - ORDER BY f.id DESC - """, - (person_id,) - ) - rows = cursor.fetchall() - - # Filter out unmatched faces - visible_rows = [row for row in rows if row[0] not in unmatched_faces] - - if not visible_rows: - ttk.Label(faces_inner, text="No faces found." if not rows else "All faces unmatched.", foreground="gray").grid(row=2, column=0, sticky=tk.W) - return - - # Grid thumbnails with responsive column count - row_index = 2 # Start after header and buttons - col_index = 0 - for face_id, location, photo_path, filename in visible_rows: - crop_path = self._extract_face_crop(photo_path, location, face_id) - thumb = None - if crop_path and os.path.exists(crop_path): - try: - img = Image.open(crop_path) - img.thumbnail((130, 130), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - temp_crops.append(crop_path) - right_panel_images.append(photo_img) - thumb = photo_img - except Exception: - thumb = None - - tile = ttk.Frame(faces_inner, padding="5") - tile.grid(row=row_index, column=col_index, padx=5, pady=5, sticky=tk.N) - - # Create a frame for the face image with X button overlay - face_frame = ttk.Frame(tile) - face_frame.grid(row=0, column=0) - - canvas = tk.Canvas(face_frame, width=130, height=130, bg=canvas_bg_color, highlightthickness=0) - canvas.grid(row=0, column=0) - if thumb is not None: - canvas.create_image(65, 65, image=thumb) - else: - canvas.create_text(65, 65, text="šŸ–¼ļø", fill="gray") - - # X button to unmatch face - pin exactly to the canvas' top-right corner - x_canvas = tk.Canvas(face_frame, width=12, height=12, bg='red', - highlightthickness=0, relief="flat") - x_canvas.create_text(6, 6, text="āœ–", fill="white", font=("Arial", 8, "bold")) - # Click handler - x_canvas.bind("", lambda e, fid=face_id: unmatch_face(fid)) - # Hover highlight: change bg, show white outline, and hand cursor - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2")) - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="red", highlightthickness=0, cursor="")) - # Anchor to the canvas' top-right regardless of layout/size - try: - x_canvas.place(in_=canvas, relx=1.0, x=0, y=0, anchor='ne') - except Exception: - # Fallback to absolute coords if relative placement fails - x_canvas.place(x=118, y=0) - - ttk.Label(tile, text=f"ID {face_id}", foreground="gray").grid(row=1, column=0) - - col_index += 1 - if col_index >= cols: - col_index = 0 - row_index += 1 - - def populate_people_list(): - for w in people_list_inner.winfo_children(): - w.destroy() - source = people_filtered if people_filtered is not None else people_data - if not source: - empty_label = ttk.Label(people_list_inner, text="No people match filter", foreground="gray") - empty_label.grid(row=0, column=0, sticky=tk.W, pady=4) - return - for idx, person in enumerate(source): - row = ttk.Frame(people_list_inner) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=4) - # Freeze per-row values to avoid late-binding issues - row_person = person - row_idx = idx - - # Make person name clickable - def make_click_handler(p_id, p_name, p_idx): - def on_click(event): - nonlocal selected_person_id - # Reset all labels to normal font - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Set clicked label to bold - event.widget.config(font=("Arial", 10, "bold")) - selected_person_id = p_id - # Show faces for this person - show_person_faces(p_id, p_name) - return on_click - - - # Edit (rename) button - def start_edit_person(row_frame, person_record, row_index): - for w in row_frame.winfo_children(): - w.destroy() - - # Use pre-loaded data instead of database query - cur_first = person_record.get('first_name', '') - cur_last = person_record.get('last_name', '') - cur_middle = person_record.get('middle_name', '') - cur_maiden = person_record.get('maiden_name', '') - cur_dob = person_record.get('date_of_birth', '') - - # Create a larger container frame for the text boxes and labels - edit_container = ttk.Frame(row_frame) - edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Create a grid layout for better organization - # First name field with label - first_frame = ttk.Frame(edit_container) - first_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - first_var = tk.StringVar(value=cur_first) - first_entry = ttk.Entry(first_frame, textvariable=first_var, width=15) - first_entry.pack(side=tk.TOP) - first_entry.focus_set() - - first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray") - first_label.pack(side=tk.TOP, pady=(2, 0)) - - # Last name field with label - last_frame = ttk.Frame(edit_container) - last_frame.grid(row=0, column=1, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - last_var = tk.StringVar(value=cur_last) - last_entry = ttk.Entry(last_frame, textvariable=last_var, width=15) - last_entry.pack(side=tk.TOP) - - last_label = ttk.Label(last_frame, text="Last Name", font=("Arial", 8), foreground="gray") - last_label.pack(side=tk.TOP, pady=(2, 0)) - - # Middle name field with label - middle_frame = ttk.Frame(edit_container) - middle_frame.grid(row=0, column=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - middle_var = tk.StringVar(value=cur_middle) - middle_entry = ttk.Entry(middle_frame, textvariable=middle_var, width=15) - middle_entry.pack(side=tk.TOP) - - middle_label = ttk.Label(middle_frame, text="Middle Name", font=("Arial", 8), foreground="gray") - middle_label.pack(side=tk.TOP, pady=(2, 0)) - - # Maiden name field with label - maiden_frame = ttk.Frame(edit_container) - maiden_frame.grid(row=1, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - maiden_var = tk.StringVar(value=cur_maiden) - maiden_entry = ttk.Entry(maiden_frame, textvariable=maiden_var, width=15) - maiden_entry.pack(side=tk.TOP) - - maiden_label = ttk.Label(maiden_frame, text="Maiden Name", font=("Arial", 8), foreground="gray") - maiden_label.pack(side=tk.TOP, pady=(2, 0)) - - # Date of birth field with label and calendar button - dob_frame = ttk.Frame(edit_container) - dob_frame.grid(row=1, column=1, columnspan=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - # Create a frame for the date picker - date_picker_frame = ttk.Frame(dob_frame) - date_picker_frame.pack(side=tk.TOP) - - dob_var = tk.StringVar(value=cur_dob) - dob_entry = ttk.Entry(date_picker_frame, textvariable=dob_var, width=20, state='readonly') - dob_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Calendar button - calendar_btn = ttk.Button(date_picker_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) - calendar_btn.pack(side=tk.RIGHT, padx=(5, 0)) - - dob_label = ttk.Label(dob_frame, text="Date of Birth", font=("Arial", 8), foreground="gray") - dob_label.pack(side=tk.TOP, pady=(2, 0)) - - def open_calendar(): - """Open a visual calendar dialog to select date of birth""" - from datetime import datetime, date, timedelta - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(root) - calendar_window.title("Select Date of Birth") - calendar_window.resizable(False, False) - calendar_window.transient(root) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = dob_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use default - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - else: - # Default to 25 years ago - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Configure custom styles for better visual highlighting - style = ttk.Style() - - # Selected date style - bright blue background with white text - style.configure("Selected.TButton", - background="#0078d4", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=2) - style.map("Selected.TButton", - background=[("active", "#106ebe")], - relief=[("pressed", "sunken")]) - - # Today's date style - orange background - style.configure("Today.TButton", - background="#ff8c00", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=1) - style.map("Today.TButton", - background=[("active", "#e67e00")], - relief=[("pressed", "sunken")]) - - # Calendar-specific normal button style (don't affect global TButton) - style.configure("Calendar.TButton", - font=("Arial", 9), - relief="flat") - style.map("Calendar.TButton", - background=[("active", "#e1e1e1")], - relief=[("pressed", "sunken")]) - - # 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() - - def update_calendar(): - """Update the calendar display""" - # Clear existing calendar - for widget in calendar_frame.winfo_children(): - widget.destroy() - - # Update header - month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") - - # 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): - label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) - label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") - - # Calendar days - for week_num, week in enumerate(cal): - for day_num, day in enumerate(week): - if day == 0: - # Empty cell - label = ttk.Label(calendar_frame, text="") - label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - else: - # Day button - 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 with prominent style - 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), - width=3, style="Calendar.TButton") - day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - - # Check if this day should be highlighted - is_today = (display_year == current_date.year and - display_month == current_date.month and - day == current_date.day) - is_selected = (selected_date and - selected_date.year == display_year and - selected_date.month == display_month and - selected_date.day == day) - - if is_selected: - day_btn.config(style="Selected.TButton") - elif is_today: - day_btn.config(style="Today.TButton") - - # Navigation functions - def prev_year(): - nonlocal display_year - display_year = max(1900, display_year - 1) - update_calendar() - - def next_year(): - nonlocal display_year - display_year = min(current_date.year, display_year + 1) - update_calendar() - - def prev_month(): - nonlocal display_month, display_year - if display_month > 1: - display_month -= 1 - else: - display_month = 12 - display_year = max(1900, display_year - 1) - update_calendar() - - def next_month(): - nonlocal display_month, display_year - if display_month < 12: - display_month += 1 - else: - display_month = 1 - display_year = min(current_date.year, display_year + 1) - update_calendar() - - # Navigation buttons - prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) - prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) - - prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) - prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) - - month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) - month_year_label.pack(side=tk.LEFT, padx=5) - - next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) - next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) - - 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: - date_str = selected_date.strftime('%Y-%m-%d') - dob_var.set(date_str) - calendar_window.destroy() - else: - messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") - - def cancel_selection(): - """Cancel date selection""" - calendar_window.destroy() - - # Buttons - 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 - update_calendar() - - def save_rename(): - new_first = first_var.get().strip() - new_last = last_var.get().strip() - new_middle = middle_var.get().strip() - new_maiden = maiden_var.get().strip() - new_dob = dob_var.get().strip() - - if not new_first and not new_last: - messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.") - return - - # Check for duplicates in local data first (based on first and last name only) - for person in people_data: - if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last: - display_name = f"{new_last}, {new_first}".strip(", ").strip() - messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.") - return - - # Single database access - save to database - with self.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 = ?', - (new_first, new_last, new_middle, new_maiden, new_dob, person_record['id'])) - conn.commit() - - # Update local data structure - person_record['first_name'] = new_first - person_record['last_name'] = new_last - person_record['middle_name'] = new_middle - person_record['maiden_name'] = new_maiden - person_record['date_of_birth'] = new_dob - - # Recreate the full display name with all available information - name_parts = [] - if new_first: - name_parts.append(new_first) - if new_middle: - name_parts.append(new_middle) - if new_last: - name_parts.append(new_last) - if new_maiden: - name_parts.append(f"({new_maiden})") - - full_name = ' '.join(name_parts) if name_parts else "Unknown" - - # Create detailed display with date of birth if available - display_name = full_name - if new_dob: - display_name += f" - Born: {new_dob}" - - person_record['name'] = display_name - person_record['full_name'] = full_name - - # Refresh list - current_selected_id = person_record['id'] - populate_people_list() - # Reselect and refresh right panel header if needed - if selected_person_id == current_selected_id or selected_person_id is None: - # Find updated name - updated = next((p for p in people_data if p['id'] == current_selected_id), None) - if updated: - # Bold corresponding label - for child in people_list_inner.winfo_children(): - # child is row frame: contains label and button - widgets = child.winfo_children() - if not widgets: - continue - lbl = widgets[0] - if isinstance(lbl, ttk.Label) and lbl.cget('text').startswith(updated['name'] + " ("): - lbl.config(font=("Arial", 10, "bold")) - break - # Update right panel header by re-showing faces - show_person_faces(updated['id'], updated['name']) - - def cancel_edit(): - # Rebuild the row back to label + edit - for w in row_frame.winfo_children(): - w.destroy() - rebuild_row(row_frame, person_record, row_index) - - save_btn = ttk.Button(row_frame, text="šŸ’¾", width=3, command=save_rename) - save_btn.pack(side=tk.LEFT, padx=(5, 0)) - cancel_btn = ttk.Button(row_frame, text="āœ–", width=3, command=cancel_edit) - cancel_btn.pack(side=tk.LEFT, padx=(5, 0)) - - # Configure custom disabled button style for better visibility - style = ttk.Style() - style.configure("Disabled.TButton", - background="#d3d3d3", # Light gray background - foreground="#808080", # Dark gray text - relief="flat", - borderwidth=1) - - def validate_save_button(): - """Enable/disable save button based on required fields""" - first_val = first_var.get().strip() - last_val = last_var.get().strip() - dob_val = dob_var.get().strip() - - # Enable save button only if both name fields and date of birth are provided - has_first = bool(first_val) - has_last = bool(last_val) - has_dob = bool(dob_val) - - if has_first and has_last and has_dob: - save_btn.config(state="normal") - # Reset to normal styling when enabled - save_btn.config(style="TButton") - else: - save_btn.config(state="disabled") - # Apply custom disabled styling for better visibility - save_btn.config(style="Disabled.TButton") - - # Set up validation callbacks for all input fields - first_var.trace('w', lambda *args: validate_save_button()) - last_var.trace('w', lambda *args: validate_save_button()) - middle_var.trace('w', lambda *args: validate_save_button()) - maiden_var.trace('w', lambda *args: validate_save_button()) - dob_var.trace('w', lambda *args: validate_save_button()) - - # Initial validation - validate_save_button() - - # Keyboard shortcuts (only work when save button is enabled) - 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()) - - def rebuild_row(row_frame, p, i): - # Edit button (on the left) - edit_btn = ttk.Button(row_frame, text="āœļø", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii)) - 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"{p['name']} ({p['count']})", font=("Arial", 10)) - name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) - name_lbl.bind("", make_click_handler(p['id'], p['name'], i)) - name_lbl.config(cursor="hand2") - # Bold if selected - if (selected_person_id is None and i == 0) or (selected_person_id == p['id']): - name_lbl.config(font=("Arial", 10, "bold")) - - # Build row contents with edit button - rebuild_row(row, row_person, row_idx) - - # Initial load - load_people() - populate_people_list() - - # Show first person's faces by default and mark selected - if people_data: - selected_person_id = people_data[0]['id'] - show_person_faces(people_data[0]['id'], people_data[0]['name']) - - # Control buttons - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) - - def on_quit(): - nonlocal window_destroyed - on_closing() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - def on_save_all_changes(): - # Use global unmatched_faces set; commit all across people - nonlocal unmatched_faces - if not unmatched_faces: - messagebox.showinfo("Nothing to Save", "There are no pending changes to save.") - return - result = messagebox.askyesno( - "Confirm Save", - f"Unlink {len(unmatched_faces)} face(s) across all people?\n\nThis will make them unidentified." - ) - if not result: - return - with self.get_db_connection() as conn: - cursor = conn.cursor() - for face_id in unmatched_faces: - cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) - conn.commit() - count = len(unmatched_faces) - unmatched_faces.clear() - # Refresh people list and right panel for current selection - load_people() - populate_people_list() - if current_person_id is not None and current_person_name: - show_person_faces(current_person_id, current_person_name) - messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).") - - save_btn_bottom = ttk.Button(control_frame, text="šŸ’¾ Save changes", command=on_save_all_changes) - save_btn_bottom.pack(side=tk.RIGHT, padx=(0, 10)) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) - quit_btn.pack(side=tk.RIGHT) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Main event loop - try: - root.mainloop() - except tk.TclError: - pass # Window was destroyed - - return 0 - - -def main(): - """Main CLI interface""" - parser = argparse.ArgumentParser( - description="PunimTag CLI - Simple photo face tagger", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - photo_tagger.py scan /path/to/photos # Scan folder for photos - photo_tagger.py process --limit 20 # Process 20 photos for faces - photo_tagger.py identify --batch 10 # Identify 10 faces interactively - photo_tagger.py auto-match # Auto-identify matching faces - photo_tagger.py modifyidentified # Show and Modify identified faces - photo_tagger.py match 15 # Find faces similar to face ID 15 - photo_tagger.py tag --pattern "vacation" # Tag photos matching pattern - photo_tagger.py search "John" # Find photos with John - photo_tagger.py tag-manager # Open tag management GUI - photo_tagger.py stats # Show statistics - """ - ) - - parser.add_argument('command', - choices=['scan', 'process', 'identify', 'tag', 'search', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'], - help='Command to execute') - - parser.add_argument('target', nargs='?', - help='Target folder (scan), person name (search), or pattern (tag)') - - parser.add_argument('--db', default='data/photos.db', - help='Database file path (default: data/photos.db)') - - parser.add_argument('--limit', type=int, default=50, - help='Batch size limit for processing (default: 50)') - - parser.add_argument('--batch', type=int, default=20, - help='Batch size for identification (default: 20)') - - parser.add_argument('--pattern', - help='Pattern for filtering photos when tagging') - - parser.add_argument('--model', choices=['hog', 'cnn'], default='hog', - help='Face detection model: hog (faster) or cnn (more accurate)') - - parser.add_argument('--recursive', action='store_true', - help='Scan folders recursively') - - parser.add_argument('--show-faces', action='store_true', - help='Show individual face crops during identification') - - parser.add_argument('--tolerance', type=float, default=0.5, - help='Face matching tolerance (0.0-1.0, lower = stricter, default: 0.5)') - - parser.add_argument('--auto', action='store_true', - help='Auto-identify high-confidence matches without confirmation') - - parser.add_argument('--include-twins', action='store_true', - help='Include same-photo matching (for twins or multiple instances)') - - parser.add_argument('-v', '--verbose', action='count', default=0, - help='Increase verbosity (-v, -vv, -vvv for more detail)') - - parser.add_argument('--debug', action='store_true', - help='Enable line-by-line debugging with pdb') - - args = parser.parse_args() - - # Initialize tagger - tagger = PhotoTagger(args.db, args.verbose, args.debug) - - try: - if args.command == 'scan': - if not args.target: - print("āŒ Please specify a folder to scan") - return 1 - tagger.scan_folder(args.target, args.recursive) - - elif args.command == 'process': - tagger.process_faces(args.limit, args.model) - - elif args.command == 'identify': - show_faces = getattr(args, 'show_faces', False) - tagger.identify_faces(args.batch, show_faces, args.tolerance) - - elif args.command == 'tag': - tagger.add_tags(args.pattern or args.target, args.batch) - - elif args.command == 'search': - if not args.target: - print("āŒ Please specify a person name to search for") - return 1 - tagger.search_faces(args.target) - - elif args.command == 'stats': - tagger.stats() - - elif args.command == 'match': - if args.target and args.target.isdigit(): - face_id = int(args.target) - matches = tagger.find_similar_faces(face_id, args.tolerance) - if matches: - print(f"\nšŸŽÆ Found {len(matches)} similar faces:") - for match in matches: - person_name = "Unknown" if match['person_id'] is None else f"Person ID {match['person_id']}" - print(f" šŸ“ø {match['filename']} - {person_name} (confidence: {(1-match['distance']):.1%})") - else: - print("šŸ” No similar faces found") - else: - print("āŒ Please specify a face ID number to find matches for") - - elif args.command == 'auto-match': - show_faces = getattr(args, 'show_faces', False) - include_twins = getattr(args, 'include_twins', False) - tagger.auto_identify_matches(args.tolerance, not args.auto, show_faces, include_twins) - - elif args.command == 'modifyidentified': - tagger.modifyidentified() - - elif args.command == 'tag-manager': - tagger.tag_management() - - return 0 - - except KeyboardInterrupt: - print("\n\nāš ļø Interrupted by user") - return 1 - except Exception as e: - print(f"āŒ Error: {e}") - return 1 - finally: - # Always cleanup resources - tagger.cleanup() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/archive/search_gui.py b/archive/search_gui.py deleted file mode 100644 index c265682..0000000 --- a/archive/search_gui.py +++ /dev/null @@ -1,1459 +0,0 @@ -#!/usr/bin/env python3 -""" -Search GUI implementation for PunimTag -""" - -import os -import sys -import tkinter as tk -from tkinter import ttk, messagebox -from typing import List - -from gui_core import GUICore -from search_stats import SearchStats -from database import DatabaseManager -from tag_management import TagManager - - -class SearchGUI: - """GUI for searching photos by different criteria.""" - - SEARCH_TYPES = [ - "Search photos by name", - "Search photos by date", - "Search photos by tags", - "Search photos by multiple people (planned)", - "Most common tags (planned)", - "Most photographed people (planned)", - "Photos without faces", - "Photos without tags", - "Duplicate faces (planned)", - "Face quality distribution (planned)", - ] - - def __init__(self, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, tag_manager: TagManager = None, verbose: int = 0): - self.db = db_manager - self.search_stats = search_stats - self.gui_core = gui_core - self.tag_manager = tag_manager or TagManager(db_manager, verbose) - self.verbose = verbose - - # Sorting state - self.sort_column = None - self.sort_reverse = False - - # Selection tracking - self.selected_photos = {} # photo_path -> photo_data - - # Cache for photo tags to avoid database access during updates - self.photo_tags_cache = {} # photo_path -> list of tag names - - def search_gui(self) -> int: - """Open the Search GUI window.""" - root = tk.Tk() - root.title("Search Photos") - root.resizable(True, True) - - # Hide to center and size - root.withdraw() - - main = ttk.Frame(root, padding="10") - main.pack(fill=tk.BOTH, expand=True) - - # Search type selector - type_frame = ttk.Frame(main) - type_frame.pack(fill=tk.X) - ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).pack(side=tk.LEFT) - search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0]) - type_combo = ttk.Combobox(type_frame, textvariable=search_type_var, values=self.SEARCH_TYPES, state="readonly") - type_combo.pack(side=tk.LEFT, padx=(8, 0), fill=tk.X, expand=True) - - # Filters area with expand/collapse functionality - filters_container = ttk.LabelFrame(main, text="", padding="8") - filters_container.pack(fill=tk.X, pady=(10, 6)) - - # Filters header with toggle text - filters_header = ttk.Frame(filters_container) - filters_header.pack(fill=tk.X) - - # Toggle text for expand/collapse - filters_expanded = tk.BooleanVar(value=False) # Start collapsed - - def toggle_filters(): - if filters_expanded.get(): - # Collapse filters - filters_content.pack_forget() - toggle_text.config(text="+") - filters_expanded.set(False) - update_toggle_tooltip() - else: - # Expand filters - filters_content.pack(fill=tk.X, pady=(4, 0)) - toggle_text.config(text="-") - filters_expanded.set(True) - update_toggle_tooltip() - - def update_toggle_tooltip(): - """Update tooltip text based on current state""" - if filters_expanded.get(): - tooltip_text = "Click to collapse filters" - else: - tooltip_text = "Click to expand filters" - toggle_text.tooltip_text = tooltip_text - - filters_label = ttk.Label(filters_header, text="Filters", font=("Arial", 10, "bold")) - filters_label.pack(side=tk.LEFT) - - toggle_text = ttk.Label(filters_header, text="+", font=("Arial", 10, "bold"), cursor="hand2") - toggle_text.pack(side=tk.LEFT, padx=(6, 0)) - toggle_text.bind("", lambda e: toggle_filters()) - - # Initialize tooltip - toggle_text.tooltip_text = "Click to expand filters" - update_toggle_tooltip() - - # Filters content area (start hidden) - filters_content = ttk.Frame(filters_container) - - # Folder location filter - folder_filter_frame = ttk.Frame(filters_content) - folder_filter_frame.pack(fill=tk.X, pady=(0, 4)) - ttk.Label(folder_filter_frame, text="Folder location:").pack(side=tk.LEFT) - folder_var = tk.StringVar() - folder_entry = ttk.Entry(folder_filter_frame, textvariable=folder_var, width=40) - folder_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) - ttk.Label(folder_filter_frame, text="(optional - filter by folder path)").pack(side=tk.LEFT, padx=(6, 0)) - - # Browse button for folder selection - def browse_folder(): - from tkinter import filedialog - from path_utils import normalize_path - folder_path = filedialog.askdirectory(title="Select folder to filter by") - if folder_path: - try: - # Normalize to absolute path - normalized_path = normalize_path(folder_path) - folder_var.set(normalized_path) - except ValueError as e: - messagebox.showerror("Invalid Path", f"Invalid folder path: {e}", parent=root) - - browse_btn = ttk.Button(folder_filter_frame, text="Browse", command=browse_folder) - browse_btn.pack(side=tk.LEFT, padx=(6, 0)) - - # Clear folder filter button - def clear_folder_filter(): - folder_var.set("") - - clear_folder_btn = ttk.Button(folder_filter_frame, text="Clear", command=clear_folder_filter) - clear_folder_btn.pack(side=tk.LEFT, padx=(6, 0)) - - # Apply filters button - apply_filters_btn = ttk.Button(filters_content, text="Apply filters", command=lambda: do_search()) - apply_filters_btn.pack(pady=(8, 0)) - - # Inputs area - inputs = ttk.Frame(main) - inputs.pack(fill=tk.X, pady=(10, 6)) - - # Name search input - name_frame = ttk.Frame(inputs) - ttk.Label(name_frame, text="Person name:").pack(side=tk.LEFT) - name_var = tk.StringVar() - name_entry = ttk.Entry(name_frame, textvariable=name_var) - name_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) - - # Tag search input - tag_frame = ttk.Frame(inputs) - ttk.Label(tag_frame, text="Tags:").pack(side=tk.LEFT) - tag_var = tk.StringVar() - tag_entry = ttk.Entry(tag_frame, textvariable=tag_var) - tag_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) - - # Help icon for available tags (will be defined after tooltip functions) - tag_help_icon = ttk.Label(tag_frame, text="ā“", font=("Arial", 10), cursor="hand2") - tag_help_icon.pack(side=tk.LEFT, padx=(6, 0)) - - ttk.Label(tag_frame, text="(comma-separated)").pack(side=tk.LEFT, padx=(6, 0)) - - # Tag search mode - tag_mode_frame = ttk.Frame(inputs) - ttk.Label(tag_mode_frame, text="Match mode:").pack(side=tk.LEFT) - tag_mode_var = tk.StringVar(value="ANY") - tag_mode_combo = ttk.Combobox(tag_mode_frame, textvariable=tag_mode_var, - values=["ANY", "ALL"], state="readonly", width=8) - tag_mode_combo.pack(side=tk.LEFT, padx=(6, 0)) - ttk.Label(tag_mode_frame, text="(ANY = photos with any tag, ALL = photos with all tags)").pack(side=tk.LEFT, padx=(6, 0)) - - # Date search inputs - date_frame = ttk.Frame(inputs) - ttk.Label(date_frame, text="From date:").pack(side=tk.LEFT) - date_from_var = tk.StringVar() - date_from_entry = ttk.Entry(date_frame, textvariable=date_from_var, width=12, state="readonly") - date_from_entry.pack(side=tk.LEFT, padx=(6, 0)) - - # Calendar button for date from - def open_calendar_from(): - current_date = date_from_var.get() - selected_date = self.gui_core.create_calendar_dialog(root, "Select From Date", current_date) - if selected_date is not None: - date_from_var.set(selected_date) - - date_from_btn = ttk.Button(date_frame, text="šŸ“…", width=3, command=open_calendar_from) - date_from_btn.pack(side=tk.LEFT, padx=(6, 0)) - ttk.Label(date_frame, text="(YYYY-MM-DD)").pack(side=tk.LEFT, padx=(6, 0)) - - date_to_frame = ttk.Frame(inputs) - ttk.Label(date_to_frame, text="To date:").pack(side=tk.LEFT) - date_to_var = tk.StringVar() - date_to_entry = ttk.Entry(date_to_frame, textvariable=date_to_var, width=12, state="readonly") - date_to_entry.pack(side=tk.LEFT, padx=(6, 0)) - - # Calendar button for date to - def open_calendar_to(): - current_date = date_to_var.get() - selected_date = self.gui_core.create_calendar_dialog(root, "Select To Date", current_date) - if selected_date is not None: - date_to_var.set(selected_date) - - date_to_btn = ttk.Button(date_to_frame, text="šŸ“…", width=3, command=open_calendar_to) - date_to_btn.pack(side=tk.LEFT, padx=(6, 0)) - ttk.Label(date_to_frame, text="(YYYY-MM-DD, optional)").pack(side=tk.LEFT, padx=(6, 0)) - - # Planned inputs (stubs) - planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888") - - # Results area - results_frame = ttk.Frame(main) - results_frame.pack(fill=tk.BOTH, expand=True) - - # Results header with count - results_header = ttk.Frame(results_frame) - results_header.pack(fill=tk.X) - results_label = ttk.Label(results_header, text="Results:", font=("Arial", 10, "bold")) - results_label.pack(side=tk.LEFT) - results_count_label = ttk.Label(results_header, text="(0 items)", font=("Arial", 10), foreground="gray") - results_count_label.pack(side=tk.LEFT, padx=(6, 0)) - - columns = ("select", "person", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse") - tree.heading("select", text="ā˜‘") - tree.heading("person", text="Person", command=lambda: sort_treeview("person")) - tree.heading("tags", text="Tags", command=lambda: sort_treeview("tags")) - tree.heading("processed", text="Processed", command=lambda: sort_treeview("processed")) - tree.heading("open_dir", text="šŸ“") - tree.heading("open_photo", text="šŸ‘¤") - tree.heading("path", text="Photo path", command=lambda: sort_treeview("path")) - tree.heading("date_taken", text="Date Taken", command=lambda: sort_treeview("date_taken")) - tree.column("select", width=50, anchor="center") - tree.column("person", width=180, anchor="w") - tree.column("tags", width=200, anchor="w") - tree.column("processed", width=80, anchor="center") - tree.column("open_dir", width=50, anchor="center") - tree.column("open_photo", width=50, anchor="center") - tree.column("path", width=400, anchor="w") - tree.column("date_taken", width=100, anchor="center") - - # Add vertical scrollbar for the treeview - tree_v_scrollbar = ttk.Scrollbar(results_frame, orient="vertical", command=tree.yview) - tree.configure(yscrollcommand=tree_v_scrollbar.set) - - # Pack treeview and scrollbar - tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, pady=(4, 0)) - tree_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y, pady=(4, 0)) - - # Buttons - btns = ttk.Frame(main) - btns.pack(fill=tk.X, pady=(8, 0)) - search_btn = ttk.Button(btns, text="Search", command=lambda: do_search()) - search_btn.pack(side=tk.LEFT) - tag_btn = ttk.Button(btns, text="Tag selected photos", command=lambda: tag_selected_photos()) - tag_btn.pack(side=tk.LEFT, padx=(6, 0)) - clear_btn = ttk.Button(btns, text="Clear all selected", command=lambda: clear_all_selected()) - clear_btn.pack(side=tk.LEFT, padx=(6, 0)) - close_btn = ttk.Button(btns, text="Close", command=lambda: root.destroy()) - close_btn.pack(side=tk.RIGHT) - - # Sorting functionality - def sort_treeview(col: str): - """Sort the treeview by the specified column.""" - # Get all items and their values - items = [(tree.set(child, col), child) for child in tree.get_children('')] - - # Determine sort direction - if self.sort_column == col: - # Same column clicked - toggle direction - self.sort_reverse = not self.sort_reverse - else: - # Different column clicked - start with ascending - self.sort_reverse = False - self.sort_column = col - - # Sort the items - # For person, tags, and path columns, sort alphabetically - # For date_taken column, sort by date - # For processed column, sort by processed status (Yes/No) - # For icon columns, maintain original order - if col in ['person', 'tags', 'path']: - items.sort(key=lambda x: x[0].lower(), reverse=self.sort_reverse) - elif col == 'date_taken': - # Sort by date, handling "No date" entries - def date_sort_key(item): - date_str = item[0] - if date_str == "No date": - return "9999-12-31" # Put "No date" entries at the end - return date_str - items.sort(key=date_sort_key, reverse=self.sort_reverse) - elif col == 'processed': - # Sort by processed status (Yes comes before No) - def processed_sort_key(item): - processed_str = item[0] - if processed_str == "Yes": - return "0" # Yes comes first - else: - return "1" # No comes second - items.sort(key=processed_sort_key, reverse=self.sort_reverse) - else: - # For icon columns, just reverse if clicking same column - if self.sort_column == col and self.sort_reverse: - items.reverse() - - # Reorder items in treeview - for index, (val, child) in enumerate(items): - tree.move(child, '', index) - - # Update header display - update_header_display() - - def update_header_display(): - """Update header display to show sort indicators.""" - # Reset all headers - tree.heading("person", text="Person") - tree.heading("tags", text="Tags") - tree.heading("processed", text="Processed") - tree.heading("path", text="Photo path") - tree.heading("date_taken", text="Date Taken") - - # Add sort indicator to current sort column - if self.sort_column == "person": - indicator = " ↓" if self.sort_reverse else " ↑" - tree.heading("person", text="Person" + indicator) - elif self.sort_column == "tags": - indicator = " ↓" if self.sort_reverse else " ↑" - tree.heading("tags", text="Tags" + indicator) - elif self.sort_column == "processed": - indicator = " ↓" if self.sort_reverse else " ↑" - tree.heading("processed", text="Processed" + indicator) - elif self.sort_column == "path": - indicator = " ↓" if self.sort_reverse else " ↑" - tree.heading("path", text="Photo path" + indicator) - elif self.sort_column == "date_taken": - indicator = " ↓" if self.sort_reverse else " ↑" - tree.heading("date_taken", text="Date Taken" + indicator) - - # Behavior - def switch_inputs(*_): - # Clear results when search type changes - clear_results() - - for w in inputs.winfo_children(): - w.pack_forget() - choice = search_type_var.get() - if choice == self.SEARCH_TYPES[0]: # Search photos by name - name_frame.pack(fill=tk.X) - name_entry.configure(state="normal") - tag_entry.configure(state="disabled") - tag_mode_combo.configure(state="disabled") - date_from_entry.configure(state="disabled") - date_to_entry.configure(state="disabled") - date_from_btn.configure(state="disabled") - date_to_btn.configure(state="disabled") - search_btn.configure(state="normal") - # Show person column for name search - tree.column("person", width=180, minwidth=50, anchor="w") - tree.heading("person", text="Person", command=lambda: sort_treeview("person")) - # Restore people icon column for name search - tree.column("open_photo", width=50, minwidth=50, anchor="center") - tree.heading("open_photo", text="šŸ‘¤") - # Restore all columns to display (hide processed column for name search) - tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[1]: # Search photos by date - date_frame.pack(fill=tk.X) - date_to_frame.pack(fill=tk.X, pady=(4, 0)) - name_entry.configure(state="disabled") - tag_entry.configure(state="disabled") - tag_mode_combo.configure(state="disabled") - date_from_entry.configure(state="readonly") - date_to_entry.configure(state="readonly") - date_from_btn.configure(state="normal") - date_to_btn.configure(state="normal") - search_btn.configure(state="normal") - # Hide person column for date search - tree.column("person", width=0, minwidth=0, anchor="w") - tree.heading("person", text="") - # Restore people icon column for date search - tree.column("open_photo", width=50, minwidth=50, anchor="center") - tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for date search - tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[2]: # Search photos by tags - tag_frame.pack(fill=tk.X) - tag_mode_frame.pack(fill=tk.X, pady=(4, 0)) - name_entry.configure(state="disabled") - tag_entry.configure(state="normal") - tag_mode_combo.configure(state="readonly") - date_from_entry.configure(state="disabled") - date_to_entry.configure(state="disabled") - date_from_btn.configure(state="disabled") - date_to_btn.configure(state="disabled") - search_btn.configure(state="normal") - # Hide person column completely for tag search - tree.column("person", width=0, minwidth=0, anchor="w") - tree.heading("person", text="") - # Restore people icon column for tag search - tree.column("open_photo", width=50, minwidth=50, anchor="center") - tree.heading("open_photo", text="šŸ‘¤") - # Also hide the column from display - tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - elif choice == self.SEARCH_TYPES[6]: # Photos without faces - # No input needed for this search type - search_btn.configure(state="normal") - # Hide person column since photos without faces won't have person info - tree.column("person", width=0, minwidth=0, anchor="w") - tree.heading("person", text="") - # Hide the people icon column since there are no faces/people - tree.column("open_photo", width=0, minwidth=0, anchor="center") - tree.heading("open_photo", text="") - # Also hide the columns from display - tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "path", "date_taken") - # Auto-run search for photos without faces - do_search() - elif choice == self.SEARCH_TYPES[7]: # Photos without tags - # No input needed for this search type - search_btn.configure(state="normal") - # Hide person column for photos without tags search - tree.column("person", width=0, minwidth=0, anchor="w") - tree.heading("person", text="") - # Show the people icon column since there might be faces/people - tree.column("open_photo", width=50, minwidth=50, anchor="center") - tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for photos without tags search - tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - # Auto-run search for photos without tags - do_search() - else: - planned_label.pack(anchor="w") - name_entry.configure(state="disabled") - tag_entry.configure(state="disabled") - tag_mode_combo.configure(state="disabled") - date_from_entry.configure(state="disabled") - date_to_entry.configure(state="disabled") - date_from_btn.configure(state="disabled") - date_to_btn.configure(state="disabled") - search_btn.configure(state="disabled") - # Hide person column for other search types - tree.column("person", width=0, minwidth=0, anchor="w") - tree.heading("person", text="") - # Restore people icon column for other search types - tree.column("open_photo", width=50, minwidth=50, anchor="center") - tree.heading("open_photo", text="šŸ‘¤") - # Show all columns except person for other search types - tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken") - - def filter_results_by_folder(results, folder_path): - """Filter search results by folder path if specified.""" - if not folder_path or not folder_path.strip(): - return results - - folder_path = folder_path.strip() - filtered_results = [] - - for result in results: - if len(result) >= 1: - # Extract photo path from result tuple (always at index 0) - photo_path = result[0] - - # Check if photo path starts with the specified folder path - if photo_path.startswith(folder_path): - filtered_results.append(result) - - return filtered_results - - def clear_results(): - for i in tree.get_children(): - tree.delete(i) - # Reset sorting state for new search - self.sort_column = None - self.sort_reverse = False - # Clear selection tracking - self.selected_photos.clear() - # Clear tag cache - self.photo_tags_cache.clear() - # Reset results count - results_count_label.config(text="(0 items)") - update_header_display() - - def add_results(rows: List[tuple]): - # rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search or List[(path, date_taken)] for date search - for row in rows: - if len(row) == 2: - if search_type_var.get() == self.SEARCH_TYPES[1]: # Date search - # For date search: (path, date_taken) - hide person column - path, date_taken = row - photo_tags = get_photo_tags_for_display(path) - processed_status = get_photo_processed_status(path) - tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - elif search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search - # For tag search: (path, tag_info) - hide person column - # Show ALL tags for the photo, not just matching ones - path, tag_info = row - photo_tags = get_photo_tags_for_display(path) - date_taken = get_photo_date_taken(path) - processed_status = get_photo_processed_status(path) - tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - elif search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces - # For photos without faces: (path, tag_info) - hide person and people icon columns - path, tag_info = row - photo_tags = get_photo_tags_for_display(path) - date_taken = get_photo_date_taken(path) - processed_status = get_photo_processed_status(path) - tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "", path, date_taken)) - elif search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags - # For photos without tags: (path, filename) - hide person column - path, filename = row - photo_tags = get_photo_tags_for_display(path) # Will be "No tags" - date_taken = get_photo_date_taken(path) - processed_status = get_photo_processed_status(path) - tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "šŸ“", "šŸ‘¤", path, date_taken)) - else: - # For name search: (path, full_name) - show person column - p, full_name = row - # Get tags for this photo - photo_tags = get_photo_tags_for_display(p) - date_taken = get_photo_date_taken(p) - processed_status = get_photo_processed_status(p) - tree.insert("", tk.END, values=("☐", full_name, photo_tags, processed_status, "šŸ“", "šŸ‘¤", p, date_taken)) - - # Sort by appropriate column by default when results are first loaded - if rows and self.sort_column is None: - if search_type_var.get() == self.SEARCH_TYPES[1]: # Date search - # Sort by date_taken column for date search - self.sort_column = "date_taken" - elif search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search - # Sort by tags column for tag search - self.sort_column = "tags" - elif search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces - # Sort by path column for photos without faces - self.sort_column = "path" - elif search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags - # Sort by path column for photos without tags (person column is hidden) - self.sort_column = "path" - else: - # Sort by person column for name search - self.sort_column = "person" - - self.sort_reverse = False - # Get all items and sort them directly - items = [(tree.set(child, self.sort_column), child) for child in tree.get_children('')] - if self.sort_column == 'date_taken': - # Sort by date, handling "No date" entries - def date_sort_key(item): - date_str = item[0] - if date_str == "No date": - return "9999-12-31" # Put "No date" entries at the end - return date_str - items.sort(key=date_sort_key, reverse=False) # Ascending - else: - items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending - # Reorder items in treeview - for index, (val, child) in enumerate(items): - tree.move(child, '', index) - # Update header display - update_header_display() - - # Update results count - item_count = len(tree.get_children()) - results_count_label.config(text=f"({item_count} items)") - - def do_search(): - clear_results() - choice = search_type_var.get() - folder_filter = folder_var.get().strip() - - if choice == self.SEARCH_TYPES[0]: # Search photos by name - query = name_var.get().strip() - if not query: - messagebox.showinfo("Search", "Please enter a name to search.", parent=root) - return - rows = self.search_stats.search_faces(query) - # Apply folder filter - rows = filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found for '{query}'{folder_msg}.", parent=root) - add_results(rows) - elif choice == self.SEARCH_TYPES[1]: # Search photos by date - date_from = date_from_var.get().strip() - date_to = date_to_var.get().strip() - - # Validate date format if provided - if date_from: - try: - from datetime import datetime - datetime.strptime(date_from, '%Y-%m-%d') - except ValueError: - messagebox.showerror("Invalid Date", "From date must be in YYYY-MM-DD format.", parent=root) - return - - if date_to: - try: - from datetime import datetime - datetime.strptime(date_to, '%Y-%m-%d') - except ValueError: - messagebox.showerror("Invalid Date", "To date must be in YYYY-MM-DD format.", parent=root) - return - - # Check if at least one date is provided - if not date_from and not date_to: - messagebox.showinfo("Search", "Please enter at least one date (from date or to date).", parent=root) - return - - rows = self.search_stats.search_photos_by_date(date_from if date_from else None, - date_to if date_to else None) - # Apply folder filter - rows = filter_results_by_folder(rows, folder_filter) - if not rows: - date_range_text = "" - if date_from and date_to: - date_range_text = f" between {date_from} and {date_to}" - elif date_from: - date_range_text = f" from {date_from}" - elif date_to: - date_range_text = f" up to {date_to}" - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found{date_range_text}{folder_msg}.", parent=root) - else: - # Convert to the format expected by add_results: (path, date_taken) - formatted_rows = [(path, date_taken) for path, date_taken in rows] - add_results(formatted_rows) - elif choice == self.SEARCH_TYPES[2]: # Search photos by tags - tag_query = tag_var.get().strip() - if not tag_query: - messagebox.showinfo("Search", "Please enter tags to search for.", parent=root) - return - - # Parse comma-separated tags - tags = [tag.strip() for tag in tag_query.split(',') if tag.strip()] - if not tags: - messagebox.showinfo("Search", "Please enter valid tags to search for.", parent=root) - return - - # Determine match mode - match_all = (tag_mode_var.get() == "ALL") - - rows = self.search_stats.search_photos_by_tags(tags, match_all) - # Apply folder filter - rows = filter_results_by_folder(rows, folder_filter) - if not rows: - mode_text = "all" if match_all else "any" - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos found with {mode_text} of the tags: {', '.join(tags)}{folder_msg}", parent=root) - add_results(rows) - elif choice == self.SEARCH_TYPES[6]: # Photos without faces - rows = self.search_stats.get_photos_without_faces() - # Apply folder filter - rows = filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos without faces found{folder_msg}.", parent=root) - else: - # Convert to the format expected by add_results: (path, tag_info) - # For photos without faces, we don't have person info, so we use empty string - formatted_rows = [(path, "") for path, filename in rows] - add_results(formatted_rows) - elif choice == self.SEARCH_TYPES[7]: # Photos without tags - rows = self.search_stats.get_photos_without_tags() - # Apply folder filter - rows = filter_results_by_folder(rows, folder_filter) - if not rows: - folder_msg = f" in folder '{folder_filter}'" if folder_filter else "" - messagebox.showinfo("Search", f"No photos without tags found{folder_msg}.", parent=root) - else: - # Convert to the format expected by add_results: (path, filename) - # For photos without tags, we have both path and filename - formatted_rows = [(path, filename) for path, filename in rows] - add_results(formatted_rows) - - def open_dir(path: str): - try: - folder = os.path.dirname(path) - if os.name == "nt": - os.startfile(folder) # type: ignore[attr-defined] - elif sys.platform == "darwin": - import subprocess - subprocess.run(["open", folder], check=False) - else: - import subprocess - subprocess.run(["xdg-open", folder], check=False) - except Exception: - messagebox.showerror("Open Location", "Failed to open the file location.", parent=root) - - def toggle_photo_selection(row_id, vals): - """Toggle checkbox selection for a photo.""" - if len(vals) < 7: - return - current_state = vals[0] # Checkbox is now in column 0 (first) - path = vals[6] # Photo path is now in column 6 (last) - if current_state == "☐": - # Select photo - new_state = "ā˜‘" - self.selected_photos[path] = { - 'person': vals[1], # Person is now in column 1 - 'path': path - } - else: - # Deselect photo - new_state = "☐" - if path in self.selected_photos: - del self.selected_photos[path] - - # Update the treeview - new_vals = list(vals) - new_vals[0] = new_state - tree.item(row_id, values=new_vals) - - def tag_selected_photos(): - """Open linkage dialog for selected photos.""" - if not self.selected_photos: - messagebox.showinfo("Tag Photos", "Please select photos to tag first.", parent=root) - return - - # Get photo IDs for selected photos - selected_photo_ids = [] - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for path in self.selected_photos.keys(): - cursor.execute('SELECT id FROM photos WHERE path = ?', (path,)) - result = cursor.fetchone() - if result: - selected_photo_ids.append(result[0]) - - if not selected_photo_ids: - messagebox.showerror("Tag Photos", "Could not find photo IDs for selected photos.", parent=root) - return - - # Open the linkage dialog - open_linkage_dialog(selected_photo_ids) - - def clear_all_selected(): - """Clear all selected photos and update checkboxes.""" - if not self.selected_photos: - return - - # Clear the selection tracking - self.selected_photos.clear() - - # Update all checkboxes to unselected state - for item in tree.get_children(): - vals = tree.item(item, "values") - if len(vals) >= 7 and vals[0] == "ā˜‘": - new_vals = list(vals) - new_vals[0] = "☐" - tree.item(item, values=new_vals) - - - def show_photo_tags(photo_path): - """Show tags for a specific photo in a popup.""" - # Get photo ID - photo_id = None - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result: - photo_id = result[0] - - if not photo_id: - messagebox.showerror("Error", "Could not find photo ID", parent=root) - return - - # Get tags for this photo - tag_names = [] - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT t.tag_name - FROM tags t - JOIN phototaglinkage ptl ON t.id = ptl.tag_id - WHERE ptl.photo_id = ? - ORDER BY t.tag_name - ''', (photo_id,)) - tag_names = [row[0] for row in cursor.fetchall()] - - # Create popup - popup = tk.Toplevel(root) - popup.title("Photo Tags") - popup.transient(root) - popup.grab_set() - popup.geometry("300x200") - - # Center the popup - popup.update_idletasks() - x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2) - y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2) - popup.geometry(f"+{x}+{y}") - - frame = ttk.Frame(popup, padding="10") - frame.pack(fill=tk.BOTH, expand=True) - - # Photo filename - filename = os.path.basename(photo_path) - ttk.Label(frame, text=f"Tags for: {filename}", font=("Arial", 10, "bold")).pack(anchor="w", pady=(0, 10)) - - if tag_names: - # Create scrollable list - canvas = tk.Canvas(frame, height=100) - scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - for tag_name in tag_names: - ttk.Label(scrollable_frame, text=f"• {tag_name}").pack(anchor="w", pady=1) - else: - ttk.Label(frame, text="No tags found for this photo", foreground="gray").pack(anchor="w") - - # Close button - ttk.Button(frame, text="Close", command=popup.destroy).pack(pady=(10, 0)) - - def get_person_name_for_photo(photo_path): - """Get person name for a photo (if any faces are identified).""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT DISTINCT pe.first_name, pe.last_name - FROM photos p - JOIN faces f ON p.id = f.photo_id - JOIN people pe ON f.person_id = pe.id - WHERE p.path = ? AND f.person_id IS NOT NULL - LIMIT 1 - ''', (photo_path,)) - result = cursor.fetchone() - if result: - first = (result[0] or "").strip() - last = (result[1] or "").strip() - return f"{first} {last}".strip() or "Unknown" - except Exception: - pass - return "No person identified" - - def get_photo_tags_for_display(photo_path): - """Get tags for a photo to display in the tags column.""" - # Check cache first - if photo_path in self.photo_tags_cache: - tag_names = self.photo_tags_cache[photo_path] - else: - # Load from database and cache - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if not result: - return "No photo found" - - photo_id = result[0] - cursor.execute(''' - SELECT t.tag_name - FROM tags t - JOIN phototaglinkage ptl ON t.id = ptl.tag_id - WHERE ptl.photo_id = ? - ORDER BY t.tag_name - ''', (photo_id,)) - tag_names = [row[0] for row in cursor.fetchall()] - self.photo_tags_cache[photo_path] = tag_names - except Exception: - return "No tags" - - # Format for display - show all tags - if tag_names: - return ', '.join(tag_names) - else: - return "No tags" - - def get_photo_date_taken(photo_path): - """Get date_taken for a photo to display in the date_taken column.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT date_taken FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result and result[0]: - return result[0] # Return the date as stored in database - else: - return "No date" # No date_taken available - except Exception: - return "No date" - - def get_photo_processed_status(photo_path): - """Get processed status for a photo to display in the processed column.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT processed FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result and result[0] is not None: - return "Yes" if result[0] else "No" - else: - return "No" # Default to not processed - except Exception: - return "No" - - def get_photo_people_tooltip(photo_path): - """Get people information for a photo to display in tooltip.""" - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT DISTINCT pe.first_name, pe.last_name, pe.middle_name, pe.maiden_name - FROM photos p - JOIN faces f ON p.id = f.photo_id - JOIN people pe ON f.person_id = pe.id - WHERE p.path = ? AND f.person_id IS NOT NULL - ORDER BY pe.last_name, pe.first_name - ''', (photo_path,)) - people = cursor.fetchall() - - if not people: - return "No people identified" - - people_names = [] - for person in people: - first = (person[0] or "").strip() - last = (person[1] or "").strip() - middle = (person[2] or "").strip() - maiden = (person[3] or "").strip() - - # Build full name - name_parts = [] - if first: - name_parts.append(first) - if middle: - name_parts.append(middle) - if last: - name_parts.append(last) - if maiden and maiden != last: - name_parts.append(f"({maiden})") - - full_name = " ".join(name_parts) if name_parts else "Unknown" - people_names.append(full_name) - - if people_names: - if len(people_names) <= 3: - return f"People: {', '.join(people_names)}" - else: - return f"People: {', '.join(people_names[:3])}... (+{len(people_names)-3} more)" - else: - return "No people identified" - except Exception: - pass - return "No people identified" - - def get_photo_tags_tooltip(photo_path): - """Get tags for a photo to display in tooltip.""" - # Get photo ID - photo_id = None - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,)) - result = cursor.fetchone() - if result: - photo_id = result[0] - - if not photo_id: - return "No photo found" - - # Get tags for this photo - tag_names = [] - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT t.tag_name - FROM tags t - JOIN phototaglinkage ptl ON t.id = ptl.tag_id - WHERE ptl.photo_id = ? - ORDER BY t.tag_name - ''', (photo_id,)) - tag_names = [row[0] for row in cursor.fetchall()] - - if tag_names: - if len(tag_names) <= 5: - return f"Tags: {', '.join(tag_names)}" - else: - return f"Tags: {', '.join(tag_names[:5])}... (+{len(tag_names)-5} more)" - else: - return "No tags" - - def open_linkage_dialog(photo_ids): - """Open the linkage dialog for selected photos using tag manager functionality.""" - popup = tk.Toplevel(root) - popup.title("Tag Selected Photos") - popup.transient(root) - popup.grab_set() - popup.geometry("500x400") - popup.resizable(True, True) - - # Track tag changes for updating results - tags_added = set() # tag names that were added - tags_removed = set() # tag names that were removed - - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - popup.columnconfigure(0, weight=1) - popup.rowconfigure(1, weight=1) - - ttk.Label(top_frame, text=f"Selected Photos: {len(photo_ids)}").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6)) - ttk.Label(top_frame, text="Add tag:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W) - - # Get existing tags using tag manager - tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings() - existing_tags = sorted(tag_name_to_id.keys()) - - tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=tag_var, values=existing_tags, width=30) - combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - def get_saved_tag_types_for_photo(photo_id: int): - """Get saved linkage types for a photo {tag_id: type_int}""" - types = {} - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,)) - for row in cursor.fetchall(): - try: - types[row[0]] = int(row[1]) if row[1] is not None else 0 - except Exception: - types[row[0]] = 0 - except Exception: - pass - return types - - def add_selected_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - - # Resolve or create tag id (case-insensitive) - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in tag_name_to_id: - tag_id = tag_name_to_id[normalized_tag_name] - else: - # Create new tag in database using the database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - # Update mappings - tag_name_to_id[normalized_tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - # Update the combobox values to include the new tag - combo['values'] = existing_tags - else: - messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup) - return - - # Add tag to all selected photos with single linkage type (0) - affected = 0 - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - # Check if tag already exists for this photo - cursor.execute('SELECT linkage_id FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - if not cursor.fetchone(): - # Add the tag with single linkage type (0) - cursor.execute('INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) VALUES (?, ?, 0)', (photo_id, tag_id)) - affected += 1 - - # Track that this tag was added - if affected > 0: - tags_added.add(tag_name) - - # Refresh the tag list to show the new tag - refresh_tag_list() - tag_var.set("") - - ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=1, column=2, padx=(8, 0)) - - # Allow Enter key to add tag - combo.bind('', lambda e: add_selected_tag()) - - # Create scrollable tag list - canvas = tk.Canvas(list_frame, height=200) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - selected_tag_vars = {} - - def refresh_tag_list(): - for widget in scrollable_frame.winfo_children(): - widget.destroy() - selected_tag_vars.clear() - - # Get tags that exist in ALL selected photos - # First, get all tags for each photo - photo_tags = {} # photo_id -> set of tag_ids - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - photo_tags[photo_id] = set() - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,)) - for row in cursor.fetchall(): - photo_tags[photo_id].add(row[0]) - - # Find intersection - tags that exist in ALL selected photos - if not photo_tags: - ttk.Label(scrollable_frame, text="No tags linked to selected photos", foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Start with tags from first photo, then intersect with others - common_tag_ids = set(photo_tags[photo_ids[0]]) - for photo_id in photo_ids[1:]: - common_tag_ids = common_tag_ids.intersection(photo_tags[photo_id]) - - if not common_tag_ids: - ttk.Label(scrollable_frame, text="No common tags found across all selected photos", foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Get linkage type information for common tags - # For tags that exist in all photos, we need to determine the linkage type - # If a tag has different linkage types across photos, we'll show the most restrictive - common_tag_data = {} # tag_id -> {linkage_type, photo_count} - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id IN ({})'.format(','.join('?' * len(common_tag_ids))), [photo_id] + list(common_tag_ids)) - for row in cursor.fetchall(): - tag_id = row[0] - linkage_type = int(row[1]) if row[1] is not None else 0 - if tag_id not in common_tag_data: - common_tag_data[tag_id] = {'linkage_type': linkage_type, 'photo_count': 0} - common_tag_data[tag_id]['photo_count'] += 1 - # If we find a bulk linkage type (1), use that as it's more restrictive - if linkage_type == 1: - common_tag_data[tag_id]['linkage_type'] = 1 - - # Sort tags by name for consistent display - for tag_id in sorted(common_tag_data.keys()): - tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}") - var = tk.BooleanVar() - selected_tag_vars[tag_name] = var - frame = ttk.Frame(scrollable_frame) - frame.pack(fill=tk.X, pady=1) - - # Determine if this tag can be selected for deletion - # In single linkage dialog, only allow deleting single linkage type (0) tags - linkage_type = common_tag_data[tag_id]['linkage_type'] - can_select = (linkage_type == 0) # Only single linkage type can be deleted - - cb = ttk.Checkbutton(frame, variable=var) - if not can_select: - try: - cb.state(["disabled"]) # disable selection for bulk tags - except Exception: - pass - cb.pack(side=tk.LEFT, padx=(0, 5)) - - # Display tag name with status information - type_label = 'single' if linkage_type == 0 else 'bulk' - photo_count = common_tag_data[tag_id]['photo_count'] - status_text = f" (saved {type_label})" - status_color = "black" if can_select else "gray" - ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT) - - def remove_selected_tags(): - tag_ids_to_remove = [] - tag_names_to_remove = [] - for tag_name, var in selected_tag_vars.items(): - if var.get() and tag_name in tag_name_to_id: - tag_ids_to_remove.append(tag_name_to_id[tag_name]) - tag_names_to_remove.append(tag_name) - - if not tag_ids_to_remove: - return - - # Only remove single linkage type tags (bulk tags should be disabled anyway) - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id in photo_ids: - for tag_id in tag_ids_to_remove: - # Double-check that this is a single linkage type before deleting - cursor.execute('SELECT linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - result = cursor.fetchone() - if result and int(result[0]) == 0: # Only delete single linkage type - cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - - # Track that these tags were removed - tags_removed.update(tag_names_to_remove) - - refresh_tag_list() - - def update_search_results(): - """Update the search results to reflect tag changes without database access.""" - if not tags_added and not tags_removed: - return # No changes to apply - - # Get photo paths for the affected photos from selected_photos - affected_photo_paths = set(self.selected_photos.keys()) - - # Update cache for affected photos - for photo_path in affected_photo_paths: - if photo_path in self.photo_tags_cache: - # Update cached tags based on changes - current_tags = set(self.photo_tags_cache[photo_path]) - # Add new tags - current_tags.update(tags_added) - # Remove deleted tags - current_tags.difference_update(tags_removed) - # Update cache with sorted list - self.photo_tags_cache[photo_path] = sorted(list(current_tags)) - - # Update each affected row in the search results - for item in tree.get_children(): - vals = tree.item(item, "values") - if len(vals) >= 7: - photo_path = vals[6] # Photo path is at index 6 - if photo_path in affected_photo_paths: - # Get current tags for this photo from cache - current_tags = get_photo_tags_for_display(photo_path) - # Update the tags column (index 2) - new_vals = list(vals) - new_vals[2] = current_tags - tree.item(item, values=new_vals) - - def close_dialog(): - """Close dialog and update search results if needed.""" - update_search_results() - popup.destroy() - - ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8)) - ttk.Button(bottom_frame, text="Close", command=close_dialog).pack(side=tk.RIGHT) - refresh_tag_list() - - # Click handling on icon columns - def on_tree_click(event): - region = tree.identify("region", event.x, event.y) - if region != "cell": - return - row_id = tree.identify_row(event.y) - col_id = tree.identify_column(event.x) # '#1', '#2', ... - if not row_id or not col_id: - return - vals = tree.item(row_id, "values") - if not vals or len(vals) < 6: - return - - # Determine column offsets based on search type - is_name_search = (search_type_var.get() == self.SEARCH_TYPES[0]) - is_photos_without_faces = (search_type_var.get() == self.SEARCH_TYPES[6]) - - if is_name_search: - # Name search: all columns visible including person (processed column hidden) - select_col = "#1" # select is column 1 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array (still same since processed is hidden from display) - elif is_photos_without_faces: - # Photos without faces: person and people icon columns are hidden - select_col = "#1" # select is column 1 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 (but hidden) - path_col = "#5" # path is column 5 (since people icon is hidden) - path_index = 6 # path is at index 6 in values array - else: - # All other searches: person column is hidden, people icon visible - select_col = "#1" # select is column 1 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array - - path = vals[path_index] # Photo path - if col_id == open_dir_col: # Open directory column - open_dir(path) - elif col_id == face_col: # Face icon column - # No popup needed, just tooltip - pass - elif col_id == path_col: # Photo path column - clickable to open photo - try: - if os.name == "nt": - os.startfile(path) # type: ignore[attr-defined] - elif sys.platform == "darwin": - import subprocess - subprocess.run(["open", path], check=False) - else: - import subprocess - subprocess.run(["xdg-open", path], check=False) - except Exception: - messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root) - elif col_id == select_col: # Checkbox column - toggle_photo_selection(row_id, vals) - - # Tooltip for icon cells and toggle text - tooltip = None - def show_tooltip(widget, x, y, text: str): - nonlocal tooltip - hide_tooltip() - try: - tooltip = tk.Toplevel(widget) - tooltip.wm_overrideredirect(True) - tooltip.wm_geometry(f"+{x+12}+{y+12}") - lbl = tk.Label(tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9)) - lbl.pack() - except Exception: - tooltip = None - - def hide_tooltip(*_): - nonlocal tooltip - if tooltip is not None: - try: - tooltip.destroy() - except Exception: - pass - tooltip = None - - # Tooltip functionality for toggle text - def on_toggle_enter(event): - if hasattr(toggle_text, 'tooltip_text'): - show_tooltip(toggle_text, event.x_root, event.y_root, toggle_text.tooltip_text) - - def on_toggle_leave(event): - hide_tooltip() - - # Bind tooltip events to toggle text - toggle_text.bind("", on_toggle_enter) - toggle_text.bind("", on_toggle_leave) - - # Help icon functionality for available tags - def show_available_tags_tooltip(event): - # Get all available tags from database - try: - tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings() - available_tags = sorted(tag_name_to_id.keys()) - - if available_tags: - # Create tooltip with tags in a column format - tag_list = "\n".join(available_tags) - tooltip_text = f"Available tags:\n{tag_list}" - else: - tooltip_text = "No tags available in database" - - show_tooltip(tag_help_icon, event.x_root, event.y_root, tooltip_text) - except Exception: - show_tooltip(tag_help_icon, event.x_root, event.y_root, "Error loading tags") - - # Bind tooltip events to help icon - tag_help_icon.bind("", show_available_tags_tooltip) - tag_help_icon.bind("", hide_tooltip) - - def on_tree_motion(event): - region = tree.identify("region", event.x, event.y) - if region != "cell": - hide_tooltip() - tree.config(cursor="") - return - col_id = tree.identify_column(event.x) - row_id = tree.identify_row(event.y) - - # Determine column offsets based on search type - is_name_search = (search_type_var.get() == self.SEARCH_TYPES[0]) - is_photos_without_faces = (search_type_var.get() == self.SEARCH_TYPES[6]) - - if is_name_search: - # Name search: all columns visible including person (processed column hidden) - tags_col = "#3" # tags is column 3 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array (still same since processed is hidden from display) - elif is_photos_without_faces: - # Photos without faces: person and people icon columns are hidden - tags_col = "#2" # tags is column 2 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 (but hidden) - path_col = "#5" # path is column 5 (since people icon is hidden) - path_index = 6 # path is at index 6 in values array - else: - # All other searches: person column is hidden, people icon visible - tags_col = "#2" # tags is column 2 - open_dir_col = "#4" # open_dir is column 4 - face_col = "#5" # open_photo is column 5 - path_col = "#6" # path is column 6 - path_index = 6 # path is at index 6 in values array - - if col_id == tags_col: # Tags column - tree.config(cursor="") - # Show tags tooltip - if row_id: - vals = tree.item(row_id, "values") - if len(vals) >= 3: - # Tags are at index 2 for all search types (after select, person is hidden in most) - tags_text = vals[2] - show_tooltip(tree, event.x_root, event.y_root, f"Tags: {tags_text}") - elif col_id == open_dir_col: # Open directory column - tree.config(cursor="hand2") - show_tooltip(tree, event.x_root, event.y_root, "Open file location") - elif col_id == face_col: # Face icon column - tree.config(cursor="hand2") - # Show people tooltip - if row_id: - vals = tree.item(row_id, "values") - if len(vals) >= 5: - path = vals[path_index] - people_text = get_photo_people_tooltip(path) - show_tooltip(tree, event.x_root, event.y_root, people_text) - elif col_id == path_col: # Photo path column - tree.config(cursor="hand2") - show_tooltip(tree, event.x_root, event.y_root, "Open photo") - else: - tree.config(cursor="") - hide_tooltip() - - type_combo.bind("<>", switch_inputs) - switch_inputs() - tree.bind("", on_tree_click) - tree.bind("", on_tree_motion) - tree.bind("", hide_tooltip) - - # Enter key in name field triggers search - name_entry.bind("", lambda e: do_search()) - # Enter key in tag field triggers search - tag_entry.bind("", lambda e: do_search()) - # Note: Date fields are read-only, so no Enter key binding needed - # Enter key in folder filter field triggers search - folder_entry.bind("", lambda e: do_search()) - - # Show and center - root.update_idletasks() - # Widened to ensure all columns are visible by default, including the new processed column - self.gui_core.center_window(root, 1300, 520) - root.deiconify() - root.mainloop() - return 0 - - diff --git a/archive/tag_manager_gui.py b/archive/tag_manager_gui.py deleted file mode 100644 index 6f32a35..0000000 --- a/archive/tag_manager_gui.py +++ /dev/null @@ -1,1524 +0,0 @@ -""" -Legacy Tag Manager GUI ported intact into the refactored architecture. - -This module preserves the original GUI and functionality exactly as in the old version. -""" - -from typing import List, Dict - - -class TagManagerGUI: - """GUI for managing tags, preserved from legacy implementation.""" - - def __init__(self, db_manager, gui_core, tag_manager, face_processor, verbose: int = 0): - self.db = db_manager - self.gui_core = gui_core - self.tag_manager = tag_manager - self.face_processor = face_processor - self.verbose = verbose - - def tag_management(self) -> int: - """Tag management GUI - file explorer-like interface for managing photo tags (legacy UI).""" - import tkinter as tk - from tkinter import ttk, messagebox, simpledialog - from PIL import Image, ImageTk - import os - import sys - import subprocess - - # Create the main window - root = tk.Tk() - root.title("Tag Management - Photo Explorer") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - temp_crops: List[str] = [] - photo_images: List[ImageTk.PhotoImage] = [] # Keep PhotoImage refs alive - - # Track folder expand/collapse states - folder_states: Dict[str, bool] = {} - - # Track pending tag changes/removals using tag IDs - pending_tag_changes: Dict[int, List[int]] = {} - pending_tag_removals: Dict[int, List[int]] = {} - # Track linkage type for pending additions: 0=single, 1=bulk - pending_tag_linkage_type: Dict[int, Dict[int, int]] = {} - - # Helper: get saved linkage types for a photo {tag_id: type_int} - def get_saved_tag_types_for_photo(photo_id: int) -> Dict[int, int]: - types: Dict[int, int] = {} - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,)) - for row in cursor.fetchall(): - try: - types[row[0]] = int(row[1]) if row[1] is not None else 0 - except Exception: - types[row[0]] = 0 - except Exception: - pass - return types - existing_tags: List[str] = [] - tag_id_to_name: Dict[int, str] = {} - tag_name_to_id: Dict[str, int] = {} - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Simple tooltip utility (local to this GUI) - class _ToolTip: - def __init__(self, widget, text: str): - self.widget = widget - self.text = text - self._tip = None - widget.bind("", self._on) - widget.bind("", self._off) - - def _on(self, event=None): - if self._tip or not self.text: - return - try: - x = self.widget.winfo_rootx() + 20 - y = self.widget.winfo_rooty() + 20 - self._tip = tw = tk.Toplevel(self.widget) - tw.wm_overrideredirect(True) - tw.wm_geometry(f"+{x}+{y}") - lbl = tk.Label(tw, text=self.text, justify=tk.LEFT, - background="#ffffe0", relief=tk.SOLID, borderwidth=1, - font=("tahoma", "8", "normal")) - lbl.pack(ipadx=4, ipady=2) - except Exception: - self._tip = None - - def _off(self, event=None): - if self._tip: - try: - self._tip.destroy() - except Exception: - pass - self._tip = None - - # Close handler - def on_closing(): - nonlocal window_destroyed - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - temp_crops.clear() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Window size saving (legacy behavior) - try: - self.gui_core.setup_window_size_saving(root, "gui_config.json") - except Exception: - pass - - # Main containers - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.rowconfigure(1, weight=1) - main_frame.rowconfigure(2, weight=0) - - header_frame = ttk.Frame(main_frame) - header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - header_frame.columnconfigure(1, weight=1) - - title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W) - - # View controls - view_frame = ttk.Frame(header_frame) - view_frame.grid(row=0, column=1, sticky=tk.E) - view_mode_var = tk.StringVar(value="list") - ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list", command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons", command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact", command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT) - - # Manage Tags button (legacy dialog) - def open_manage_tags_dialog(): - dialog = tk.Toplevel(root) - dialog.title("Manage Tags") - dialog.transient(root) - dialog.grab_set() - dialog.geometry("500x500") - - top_frame = ttk.Frame(dialog, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(dialog, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(dialog, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - - dialog.columnconfigure(0, weight=1) - dialog.rowconfigure(1, weight=1) - - new_tag_var = tk.StringVar() - new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30) - new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E)) - - def add_new_tag(): - tag_name = new_tag_var.get().strip() - if not tag_name: - return - try: - # Use database method for case-insensitive tag creation - tag_id = self.db.add_tag(tag_name) - if tag_id: - new_tag_var.set("") - refresh_tag_list() - load_existing_tags() - switch_view_mode(view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to add tag: {e}") - - add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag) - add_btn.grid(row=0, column=1, sticky=tk.W) - top_frame.columnconfigure(0, weight=1) - - canvas = tk.Canvas(list_frame, highlightthickness=0) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - rows_container = ttk.Frame(canvas) - canvas.create_window((0, 0), window=rows_container, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - list_frame.columnconfigure(0, weight=1) - list_frame.rowconfigure(0, weight=1) - rows_container.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - - selected_tag_vars: Dict[int, tk.BooleanVar] = {} - current_tags: List[Dict] = [] - - def refresh_tag_list(): - for child in list(rows_container.winfo_children()): - child.destroy() - selected_tag_vars.clear() - current_tags.clear() - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE') - for row in cursor.fetchall(): - current_tags.append({'id': row[0], 'tag_name': row[1]}) - except Exception as e: - messagebox.showerror("Error", f"Failed to load tags: {e}") - return - head = ttk.Frame(rows_container) - head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6)) - ttk.Label(head, text="Delete").pack(side=tk.LEFT, padx=(0, 10)) - ttk.Label(head, text="Tag name", width=30).pack(side=tk.LEFT) - ttk.Label(head, text="Edit", width=6).pack(side=tk.LEFT, padx=(10, 0)) - - for idx, tag in enumerate(current_tags, start=1): - row = ttk.Frame(rows_container) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2) - var = tk.BooleanVar(value=False) - selected_tag_vars[tag['id']] = var - ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 10)) - name_label = ttk.Label(row, text=tag['tag_name'], width=30) - name_label.pack(side=tk.LEFT) - - def make_edit_handler(tag_id, name_widget): - def handler(): - new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_widget.cget('text'), parent=dialog) - if new_name is None: - return - new_name = new_name.strip() - if not new_name: - return - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id)) - conn.commit() - except Exception as e: - messagebox.showerror("Error", f"Failed to rename tag: {e}") - return - refresh_tag_list() - load_existing_tags() - switch_view_mode(view_mode_var.get()) - return handler - - ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name_label)).pack(side=tk.LEFT, padx=(10, 0)) - - refresh_tag_list() - - def delete_selected(): - ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()] - if not ids_to_delete: - return - if not messagebox.askyesno("Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos."): - return - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - conn.commit() - - for photo_id in list(pending_tag_changes.keys()): - pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in ids_to_delete] - if not pending_tag_changes[photo_id]: - del pending_tag_changes[photo_id] - for photo_id in list(pending_tag_removals.keys()): - pending_tag_removals[photo_id] = [tid for tid in pending_tag_removals[photo_id] if tid not in ids_to_delete] - if not pending_tag_removals[photo_id]: - del pending_tag_removals[photo_id] - - refresh_tag_list() - load_existing_tags() - load_photos() - switch_view_mode(view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to delete tags: {e}") - - ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected).pack(side=tk.LEFT) - ttk.Button(bottom_frame, text="Quit", command=dialog.destroy).pack(side=tk.RIGHT) - new_tag_entry.focus_set() - - ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog).grid(row=0, column=2, sticky=tk.E, padx=(10, 0)) - - # Content area - content_frame = ttk.Frame(main_frame) - content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - content_frame.columnconfigure(0, weight=1) - content_frame.rowconfigure(0, weight=1) - - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - - content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0) - content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview) - content_inner = ttk.Frame(content_canvas) - content_canvas.create_window((0, 0), window=content_inner, anchor="nw") - content_canvas.configure(yscrollcommand=content_scrollbar.set) - content_inner.bind("", lambda e: content_canvas.configure(scrollregion=content_canvas.bbox("all"))) - content_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - content_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - - bottom_frame = ttk.Frame(main_frame) - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) - save_button = ttk.Button(bottom_frame, text="Save Tagging") - save_button.pack(side=tk.RIGHT, padx=10, pady=5) - - def open_photo(photo_path: str): - if not os.path.exists(photo_path): - try: - messagebox.showerror("File not found", f"Photo does not exist:\n{photo_path}") - except Exception: - pass - return - try: - # Open in an in-app preview window sized reasonably compared to the main GUI - img = Image.open(photo_path) - screen_w = root.winfo_screenwidth() - screen_h = root.winfo_screenheight() - max_w = int(min(1000, screen_w * 0.6)) - max_h = int(min(800, screen_h * 0.6)) - preview = tk.Toplevel(root) - preview.title(os.path.basename(photo_path)) - preview.transient(root) - # Resize image to fit nicely while keeping aspect ratio - img_copy = img.copy() - img_copy.thumbnail((max_w, max_h), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img_copy) - photo_images.append(photo_img) - pad = 12 - w, h = photo_img.width(), photo_img.height() - # Center the window roughly relative to screen - x = int((screen_w - (w + pad)) / 2) - y = int((screen_h - (h + pad)) / 2) - preview.geometry(f"{w + pad}x{h + pad}+{max(x,0)}+{max(y,0)}") - canvas = tk.Canvas(preview, width=w, height=h, highlightthickness=0) - canvas.pack(padx=pad//2, pady=pad//2) - canvas.create_image(w // 2, h // 2, image=photo_img) - try: - _ToolTip(canvas, os.path.basename(photo_path)) - except Exception: - pass - preview.focus_set() - except Exception: - # Fallback to system default opener if preview fails for any reason - try: - if sys.platform.startswith('linux'): - subprocess.Popen(['xdg-open', photo_path]) - elif sys.platform == 'darwin': - subprocess.Popen(['open', photo_path]) - elif os.name == 'nt': - os.startfile(photo_path) # type: ignore[attr-defined] - else: - Image.open(photo_path).show() - except Exception as e: - try: - messagebox.showerror("Error", f"Failed to open photo:\n{e}") - except Exception: - pass - - def quit_with_warning(): - has_pending_changes = bool(pending_tag_changes or pending_tag_removals) - if has_pending_changes: - total_additions = sum(len(tags) for tags in pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in pending_tag_removals.values()) - changes_text = [] - if total_additions > 0: - changes_text.append(f"{total_additions} tag addition(s)") - if total_removals > 0: - changes_text.append(f"{total_removals} tag removal(s)") - changes_summary = " and ".join(changes_text) - result = messagebox.askyesnocancel( - "Unsaved Changes", - f"You have unsaved changes: {changes_summary}.\n\nDo you want to save your changes before quitting?\n\nYes = Save and quit\nNo = Quit without saving\nCancel = Stay in dialog" - ) - if result is True: - save_tagging_changes() - root.destroy() - elif result is False: - root.destroy() - else: - root.destroy() - - ttk.Button(bottom_frame, text="Quit", command=quit_with_warning).pack(side=tk.RIGHT, padx=(0, 10), pady=5) - - def on_mousewheel(event): - content_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") - - resize_start_x = 0 - resize_start_widths: List[int] = [] - current_visible_cols: List[Dict] = [] - is_resizing = False - - def start_resize(event, col_idx): - nonlocal resize_start_x, resize_start_widths, is_resizing - is_resizing = True - resize_start_x = event.x_root - resize_start_widths = [col['width'] for col in current_visible_cols] - root.configure(cursor="sb_h_double_arrow") - - def do_resize(event, col_idx): - nonlocal resize_start_x, resize_start_widths, is_resizing - if not is_resizing or not resize_start_widths or not current_visible_cols: - return - delta_x = event.x_root - resize_start_x - if col_idx < len(current_visible_cols) and col_idx + 1 < len(current_visible_cols): - new_width_left = max(50, resize_start_widths[col_idx] + delta_x) - new_width_right = max(50, resize_start_widths[col_idx + 1] - delta_x) - current_visible_cols[col_idx]['width'] = new_width_left - current_visible_cols[col_idx + 1]['width'] = new_width_right - for i, col in enumerate(column_config['list']): - if col['key'] == current_visible_cols[col_idx]['key']: - column_config['list'][i]['width'] = new_width_left - elif col['key'] == current_visible_cols[col_idx + 1]['key']: - column_config['list'][i]['width'] = new_width_right - try: - header_frame_ref = None - row_frames = [] - for widget in content_inner.winfo_children(): - if isinstance(widget, ttk.Frame): - if header_frame_ref is None: - header_frame_ref = widget - else: - row_frames.append(widget) - if header_frame_ref is not None: - header_frame_ref.columnconfigure(col_idx * 2, weight=current_visible_cols[col_idx]['weight'], minsize=new_width_left) - header_frame_ref.columnconfigure((col_idx + 1) * 2, weight=current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right) - for rf in row_frames: - rf.columnconfigure(col_idx, weight=current_visible_cols[col_idx]['weight'], minsize=new_width_left) - rf.columnconfigure(col_idx + 1, weight=current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right) - root.update_idletasks() - except Exception: - pass - - def stop_resize(event): - nonlocal is_resizing - is_resizing = False - root.configure(cursor="") - - root.bind_all("", on_mousewheel) - - def global_mouse_release(event): - if is_resizing: - stop_resize(event) - root.bind_all("", global_mouse_release) - - def cleanup_mousewheel(): - try: - root.unbind_all("") - root.unbind_all("") - except Exception: - pass - - root.bind("", lambda e: cleanup_mousewheel()) - - photos_data: List[Dict] = [] - people_names_cache: Dict[int, List[str]] = {} # {photo_id: [list of people names]} - - column_visibility = { - 'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'compact': {'filename': True, 'faces': True, 'tags': True} - } - - column_config = { - 'list': [ - {'key': 'id', 'label': 'ID', 'width': 50, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 150, 'weight': 1}, - {'key': 'path', 'label': 'Path', 'width': 200, 'weight': 2}, - {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ], - 'icons': [ - {'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0}, - {'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, - {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ], - 'compact': [ - {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ] - } - - def get_people_names_for_photo(photo_id: int) -> str: - """Get people names for a photo as a formatted string for tooltip""" - nonlocal people_names_cache - people_names = people_names_cache.get(photo_id, []) - if not people_names: - return "No people identified" - - # Remove commas from names (convert "Last, First" to "Last First") - formatted_names = [] - for name in people_names: - if ', ' in name: - # Convert "Last, First" to "Last First" - formatted_name = name.replace(', ', ' ') - else: - formatted_name = name - formatted_names.append(formatted_name) - - if len(formatted_names) <= 5: - return f"People: {', '.join(formatted_names)}" - else: - return f"People: {', '.join(formatted_names[:5])}... (+{len(formatted_names)-5} more)" - - def show_people_names_popup(photo_id: int, photo_filename: str): - """Show a popup window with the names of people identified in this photo""" - nonlocal people_names_cache - people_names = people_names_cache.get(photo_id, []) - if not people_names: - try: - messagebox.showinfo("No People Identified", f"No people have been identified in {photo_filename}") - except Exception: - pass - return - - popup = tk.Toplevel(root) - popup.title(f"People in {photo_filename}") - popup.transient(root) - popup.geometry("400x300") - popup.resizable(True, True) - # Don't use grab_set() as it can cause issues - # popup.grab_set() - - # Header - header_frame = ttk.Frame(popup, padding="10") - header_frame.pack(fill=tk.X) - ttk.Label(header_frame, text=f"People identified in:", font=("Arial", 12, "bold")).pack(anchor=tk.W) - ttk.Label(header_frame, text=photo_filename, font=("Arial", 10)).pack(anchor=tk.W, pady=(2, 0)) - - # List of people - list_frame = ttk.Frame(popup, padding="10") - list_frame.pack(fill=tk.BOTH, expand=True) - - canvas = tk.Canvas(list_frame, highlightthickness=0) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - for i, name in enumerate(people_names): - ttk.Label(scrollable_frame, text=f"• {name}", font=("Arial", 11)).pack(anchor=tk.W, pady=2) - - # Close button - button_frame = ttk.Frame(popup, padding="10") - button_frame.pack(fill=tk.X) - ttk.Button(button_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT) - - popup.focus_set() - - def load_photos(): - nonlocal photos_data, people_names_cache - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added, - (SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count, - (SELECT GROUP_CONCAT(DISTINCT t.tag_name) - FROM phototaglinkage ptl - JOIN tags t ON t.id = ptl.tag_id - WHERE ptl.photo_id = p.id) as tags - FROM photos p - ORDER BY p.date_taken DESC, p.filename - ''') - photos_data = [] - for row in cursor.fetchall(): - photos_data.append({ - 'id': row[0], - 'filename': row[1], - 'path': row[2], - 'processed': row[3], - 'date_taken': row[4], - 'date_added': row[5], - 'face_count': row[6] or 0, - 'tags': row[7] or "" - }) - - # Cache people names for each photo - people_names_cache.clear() - cursor.execute(''' - SELECT f.photo_id, pe.first_name, pe.last_name - FROM faces f - JOIN people pe ON pe.id = f.person_id - WHERE f.person_id IS NOT NULL - ORDER BY pe.last_name, pe.first_name - ''') - cache_rows = cursor.fetchall() - for row in cache_rows: - photo_id, first_name, last_name = row - if photo_id not in people_names_cache: - people_names_cache[photo_id] = [] - # Format as "Last, First" or just "First" if no last name - if last_name and first_name: - name = f"{last_name}, {first_name}" - else: - name = first_name or last_name or "Unknown" - if name not in people_names_cache[photo_id]: - people_names_cache[photo_id].append(name) - - def prepare_folder_grouped_data(): - from collections import defaultdict - folder_groups = defaultdict(list) - for photo in photos_data: - folder_path = os.path.dirname(photo['path']) - folder_groups[folder_path].append(photo) - sorted_folders = [] - for folder_path in sorted(folder_groups.keys()): - folder_name = os.path.basename(folder_path) if folder_path else "Root" - photos_in_folder = sorted(folder_groups[folder_path], key=lambda x: x['date_taken'] or '', reverse=True) - if folder_path not in folder_states: - # Collapse folders by default on first load - folder_states[folder_path] = False - sorted_folders.append({ - 'folder_path': folder_path, - 'folder_name': folder_name, - 'photos': photos_in_folder, - 'photo_count': len(photos_in_folder) - }) - return sorted_folders - - def create_folder_header(parent, folder_info, current_row, col_count, view_mode): - folder_header_frame = ttk.Frame(parent) - folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5)) - folder_header_frame.configure(relief='raised', borderwidth=1) - - def open_bulk_link_dialog(): - # Bulk tagging dialog: add selected tag to all photos in this folder (pending changes only) - popup = tk.Toplevel(root) - popup.title("Bulk Link Tags to Folder") - popup.transient(root) - popup.grab_set() - popup.geometry("520x420") - - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - status_frame = ttk.Frame(popup, padding="8") - status_frame.grid(row=1, column=0, sticky=(tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - # Place bottom frame below the list to keep actions at the bottom - bottom_frame.grid(row=4, column=0, sticky=(tk.W, tk.E)) - popup.columnconfigure(0, weight=1) - - ttk.Label(top_frame, text=f"Folder: {folder_info['folder_name']} ({folder_info['photo_count']} photos)").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6)) - ttk.Label(top_frame, text="Add tag to all photos:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W) - - bulk_tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=bulk_tag_var, values=existing_tags, width=30, state='readonly') - combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - result_var = tk.StringVar(value="") - ttk.Label(status_frame, textvariable=result_var, foreground="gray").grid(row=0, column=0, sticky=tk.W) - - def add_bulk_tag(): - tag_name = bulk_tag_var.get().strip() - if not tag_name: - return - # Resolve or create tag id - # Case-insensitive tag lookup - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in tag_name_to_id: - tag_id = tag_name_to_id[normalized_tag_name] - else: - # Create new tag using database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - tag_name_to_id[normalized_tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - else: - return # Failed to create tag - - affected = 0 - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - saved_types = get_saved_tag_types_for_photo(photo_id) - existing_tag_ids = list(saved_types.keys()) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - # Case 1: not present anywhere → add as pending bulk - if tag_id not in existing_tag_ids and tag_id not in pending_tag_ids: - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - pending_tag_changes[photo_id].append(tag_id) - if photo_id not in pending_tag_linkage_type: - pending_tag_linkage_type[photo_id] = {} - pending_tag_linkage_type[photo_id][tag_id] = 1 - affected += 1 - # Case 2: already pending as single → upgrade pending type to bulk - elif tag_id in pending_tag_ids: - if photo_id not in pending_tag_linkage_type: - pending_tag_linkage_type[photo_id] = {} - prev_type = pending_tag_linkage_type[photo_id].get(tag_id) - if prev_type != 1: - pending_tag_linkage_type[photo_id][tag_id] = 1 - affected += 1 - # Case 3: saved as single → schedule an upgrade by adding to pending and setting type to bulk - elif saved_types.get(tag_id) == 0: - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - if tag_id not in pending_tag_changes[photo_id]: - pending_tag_changes[photo_id].append(tag_id) - if photo_id not in pending_tag_linkage_type: - pending_tag_linkage_type[photo_id] = {} - pending_tag_linkage_type[photo_id][tag_id] = 1 - affected += 1 - # Case 4: saved as bulk → nothing to do - - update_save_button_text() - # Refresh main view to reflect updated pending tags in each row - switch_view_mode(view_mode) - result_var.set(f"Added pending tag to {affected} photo(s)") - bulk_tag_var.set("") - # Refresh the bulk list to immediately reflect pending adds - try: - refresh_bulk_tag_list() - except Exception: - pass - - ttk.Button(top_frame, text="Add", command=add_bulk_tag).grid(row=1, column=2) - - # Section: Remove bulk-linked tags across this folder - ttk.Separator(status_frame, orient='horizontal').grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(8, 6)) - # removed helper label per request - - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=3, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - popup.rowconfigure(3, weight=1) - list_canvas = tk.Canvas(list_frame, highlightthickness=0) - list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=list_canvas.yview) - list_inner = ttk.Frame(list_canvas) - list_canvas.create_window((0, 0), window=list_inner, anchor="nw") - list_canvas.configure(yscrollcommand=list_scroll.set) - list_canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - list_scroll.grid(row=0, column=1, sticky=(tk.N, tk.S)) - list_frame.columnconfigure(0, weight=1) - list_frame.rowconfigure(0, weight=1) - list_inner.bind("", lambda e: list_canvas.configure(scrollregion=list_canvas.bbox("all"))) - - bulk_tag_vars: Dict[int, tk.BooleanVar] = {} - - def refresh_bulk_tag_list(): - for child in list(list_inner.winfo_children()): - child.destroy() - bulk_tag_vars.clear() - # Aggregate bulk-linked tags across all photos in this folder - tag_id_counts: Dict[int, int] = {} - pending_add_counts: Dict[int, int] = {} - for photo in folder_info.get('photos', []): - saved_types = get_saved_tag_types_for_photo(photo['id']) - for tid, ltype in saved_types.items(): - if ltype == 1: - tag_id_counts[tid] = tag_id_counts.get(tid, 0) + 1 - # Count pending additions (bulk type) for this photo - for tid in pending_tag_changes.get(photo['id'], []): - if pending_tag_linkage_type.get(photo['id'], {}).get(tid) == 1: - pending_add_counts[tid] = pending_add_counts.get(tid, 0) + 1 - # Include tags that exist only in pending adds - all_tag_ids = set(tag_id_counts.keys()) | set(pending_add_counts.keys()) - if not all_tag_ids: - ttk.Label(list_inner, text="No bulk-linked tags in this folder", foreground="gray").pack(anchor=tk.W, pady=5) - return - for tid in sorted(all_tag_ids, key=lambda x: tag_id_to_name.get(x, "")): - row = ttk.Frame(list_inner) - row.pack(fill=tk.X, pady=1) - var = tk.BooleanVar(value=False) - bulk_tag_vars[tid] = var - ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 6)) - pend_suffix = " (pending)" if pending_add_counts.get(tid, 0) > 0 else "" - ttk.Label(row, text=f"{tag_id_to_name.get(tid, 'Unknown')}{pend_suffix}").pack(side=tk.LEFT) - - def remove_selected_bulk_tags(): - selected_tids = [tid for tid, v in bulk_tag_vars.items() if v.get()] - if not selected_tids: - return - affected = 0 - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - saved_types = get_saved_tag_types_for_photo(photo_id) - for tid in selected_tids: - # If it's pending add (bulk), cancel the pending change - if tid in pending_tag_changes.get(photo_id, []) and pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1: - pending_tag_changes[photo_id] = [x for x in pending_tag_changes[photo_id] if x != tid] - if not pending_tag_changes[photo_id]: - del pending_tag_changes[photo_id] - if photo_id in pending_tag_linkage_type and tid in pending_tag_linkage_type[photo_id]: - del pending_tag_linkage_type[photo_id][tid] - if not pending_tag_linkage_type[photo_id]: - del pending_tag_linkage_type[photo_id] - affected += 1 - # Else if it's a saved bulk linkage, mark for removal - elif saved_types.get(tid) == 1: - if photo_id not in pending_tag_removals: - pending_tag_removals[photo_id] = [] - if tid not in pending_tag_removals[photo_id]: - pending_tag_removals[photo_id].append(tid) - affected += 1 - update_save_button_text() - switch_view_mode(view_mode) - result_var.set(f"Marked bulk tag removals affecting {affected} linkage(s)") - refresh_bulk_tag_list() - - ttk.Button(bottom_frame, text="Remove selected bulk tags", command=remove_selected_bulk_tags).pack(side=tk.LEFT) - ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT) - refresh_bulk_tag_list() - - is_expanded = folder_states.get(folder_info['folder_path'], True) - toggle_text = "ā–¼" if is_expanded else "ā–¶" - toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1, - command=lambda: toggle_folder(folder_info['folder_path'], view_mode), - font=("Arial", 8), relief='flat', bd=1) - toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5) - - folder_label = ttk.Label(folder_header_frame, text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos)", font=("Arial", 11, "bold")) - folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5) - - # Bulk linkage icon (applies selected tag to all photos in this folder) - bulk_link_btn = tk.Button(folder_header_frame, text="šŸ”—", width=2, command=open_bulk_link_dialog) - bulk_link_btn.pack(side=tk.LEFT, padx=(6, 6)) - try: - _ToolTip(bulk_link_btn, "Bulk link tags to all photos in this folder") - except Exception: - pass - - # Compute and show bulk tags for this folder - def compute_folder_bulk_tags() -> str: - bulk_tag_ids = set() - # Gather saved bulk tags - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - saved_types = get_saved_tag_types_for_photo(photo_id) - for tid, ltype in saved_types.items(): - if ltype == 1: - bulk_tag_ids.add(tid) - # Include pending bulk adds; exclude pending removals - for photo in folder_info.get('photos', []): - photo_id = photo['id'] - for tid in pending_tag_changes.get(photo_id, []): - if pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1: - if tid not in pending_tag_removals.get(photo_id, []): - bulk_tag_ids.add(tid) - # Exclude any saved bulk tags marked for removal - for tid in pending_tag_removals.get(photo_id, []): - if tid in bulk_tag_ids: - bulk_tag_ids.discard(tid) - names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in sorted(bulk_tag_ids, key=lambda x: tag_id_to_name.get(x, ""))] - return ", ".join(names) if names else "None" - - # Append bulk tags to the folder label so it's clearly visible - try: - tags_str = compute_folder_bulk_tags() - if tags_str and tags_str != "None": - folder_label.configure(text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos) — Tags: {tags_str}") - else: - folder_label.configure(text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos)") - except Exception: - pass - - return folder_header_frame - - def toggle_folder(folder_path, view_mode): - folder_states[folder_path] = not folder_states.get(folder_path, True) - switch_view_mode(view_mode) - - def load_existing_tags(): - nonlocal existing_tags, tag_id_to_name, tag_name_to_id - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') - existing_tags = [] - tag_id_to_name = {} - tag_name_to_id = {} - for row in cursor.fetchall(): - tag_id, tag_name = row - existing_tags.append(tag_name) - tag_id_to_name[tag_id] = tag_name - tag_name_to_id[tag_name] = tag_id - - def create_tagging_widget(parent, photo_id, current_tags=""): - tag_var = tk.StringVar() - tagging_frame = ttk.Frame(parent) - tag_combo = ttk.Combobox(tagging_frame, textvariable=tag_var, width=12) - tag_combo['values'] = existing_tags - tag_combo.pack(side=tk.LEFT, padx=2, pady=2) - pending_tags_var = tk.StringVar() - ttk.Label(tagging_frame, textvariable=pending_tags_var, font=("Arial", 8), foreground="blue", width=20).pack(side=tk.LEFT, padx=2, pady=2) - if photo_id in pending_tag_changes: - pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - else: - pending_tags_var.set(current_tags or "") - - def add_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - # Case-insensitive tag lookup - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in tag_name_to_id: - tag_id = tag_name_to_id[normalized_tag_name] - else: - # Create new tag using database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - tag_name_to_id[normalized_tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - existing_tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - all_existing_tag_ids = existing_tag_ids + pending_tag_ids - if tag_id not in all_existing_tag_ids: - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - pending_tag_changes[photo_id].append(tag_id) - # record linkage type as single - if photo_id not in pending_tag_linkage_type: - pending_tag_linkage_type[photo_id] = {} - pending_tag_linkage_type[photo_id][tag_id] = 0 - pending_tags_var.set( - ", ".join([tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]]) - ) - tag_var.set("") - - tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag).pack(side=tk.LEFT, padx=2, pady=2) - - def remove_tag(): - if photo_id in pending_tag_changes and pending_tag_changes[photo_id]: - removed_id = pending_tag_changes[photo_id].pop() - try: - if photo_id in pending_tag_linkage_type and removed_id in pending_tag_linkage_type[photo_id]: - del pending_tag_linkage_type[photo_id][removed_id] - if not pending_tag_linkage_type[photo_id]: - del pending_tag_linkage_type[photo_id] - except Exception: - pass - if pending_tag_changes[photo_id]: - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - else: - pending_tags_var.set("") - del pending_tag_changes[photo_id] - - tk.Button(tagging_frame, text="-", width=2, height=1, command=remove_tag).pack(side=tk.LEFT, padx=2, pady=2) - return tagging_frame - - def save_tagging_changes(): - if not pending_tag_changes and not pending_tag_removals: - messagebox.showinfo("Info", "No tag changes to save.") - return - try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - for photo_id, tag_ids in pending_tag_changes.items(): - for tag_id in tag_ids: - lt = 0 - try: - lt = pending_tag_linkage_type.get(photo_id, {}).get(tag_id, 0) - except Exception: - lt = 0 - cursor.execute(''' - INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) - VALUES (?, ?, ?) - ON CONFLICT(photo_id, tag_id) DO UPDATE SET linkage_type=excluded.linkage_type, created_date=CURRENT_TIMESTAMP - ''', (photo_id, tag_id, lt)) - for photo_id, tag_ids in pending_tag_removals.items(): - for tag_id in tag_ids: - cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) - conn.commit() - saved_additions = len(pending_tag_changes) - saved_removals = len(pending_tag_removals) - pending_tag_changes.clear() - pending_tag_removals.clear() - pending_tag_linkage_type.clear() - load_existing_tags() - load_photos() - switch_view_mode(view_mode_var.get()) - update_save_button_text() - message = f"Saved {saved_additions} tag additions" - if saved_removals > 0: - message += f" and {saved_removals} tag removals" - message += "." - messagebox.showinfo("Success", message) - except Exception as e: - messagebox.showerror("Error", f"Failed to save tags: {str(e)}") - - def update_save_button_text(): - total_additions = sum(len(tags) for tags in pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in pending_tag_removals.values()) - total_changes = total_additions + total_removals - if total_changes > 0: - save_button.configure(text=f"Save Tagging ({total_changes} pending)") - else: - save_button.configure(text="Save Tagging") - - save_button.configure(command=save_tagging_changes) - - def clear_content(): - for widget in content_inner.winfo_children(): - widget.destroy() - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except Exception: - pass - temp_crops.clear() - photo_images.clear() - - def show_column_context_menu(event, view_mode): - popup = tk.Toplevel(root) - popup.wm_overrideredirect(True) - popup.wm_geometry(f"+{event.x_root}+{event.y_root}") - popup.configure(bg='white', relief='flat', bd=0) - menu_frame = tk.Frame(popup, bg='white') - menu_frame.pack(padx=2, pady=2) - checkbox_vars: Dict[str, tk.BooleanVar] = {} - protected_columns = {'icons': ['thumbnail'], 'compact': ['filename'], 'list': ['filename']} - - def close_popup(): - try: - popup.destroy() - except Exception: - pass - - def close_on_click_outside(e): - if e.widget != popup: - try: - popup.winfo_exists() - close_popup() - except tk.TclError: - pass - - for col in column_config[view_mode]: - key = col['key'] - label = col['label'] - is_visible = column_visibility[view_mode][key] - is_protected = key in protected_columns.get(view_mode, []) - item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0) - item_frame.pack(fill=tk.X, pady=1) - var = tk.BooleanVar(value=is_visible) - checkbox_vars[key] = var - - def make_toggle_command(col_key, var_ref): - def toggle_column(): - if col_key in protected_columns.get(view_mode, []): - return - column_visibility[view_mode][col_key] = var_ref.get() - switch_view_mode(view_mode) - return toggle_column - - if is_protected: - cb = tk.Checkbutton(item_frame, text=label, variable=var, state='disabled', bg='white', fg='gray', font=("Arial", 9), relief='flat', bd=0, highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5)) - else: - cb = tk.Checkbutton(item_frame, text=label, variable=var, command=make_toggle_command(key, var), bg='white', font=("Arial", 9), relief='flat', bd=0, highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - - root.bind("", close_on_click_outside) - root.bind("", close_on_click_outside) - content_canvas.bind("", close_on_click_outside) - content_canvas.bind("", close_on_click_outside) - popup.focus_set() - - def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags, allowed_delete_type: int = 0): - def handler(): - popup = tk.Toplevel(root) - popup.title("Manage Photo Tags") - popup.transient(root) - popup.grab_set() - popup.geometry("500x400") - popup.resizable(True, True) - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - popup.columnconfigure(0, weight=1) - popup.rowconfigure(1, weight=1) - ttk.Label(top_frame, text="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W) - tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly') - combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - def _update_tags_label_in_main_list(): - # Recompute combined display for the main list label (saved + pending minus removals) - existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])] - pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])] - all_tags = existing_tags_list + pending_tag_names - unique_tags = self.tag_manager.deduplicate_tags(all_tags) - # Remove tags marked for removal from display - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_tags = ", ".join(unique_tags) if unique_tags else "None" - try: - label_widget.configure(text=current_tags) - except Exception: - pass - - def add_selected_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - # Case-insensitive tag lookup - normalized_tag_name = tag_name.lower().strip() - if normalized_tag_name in tag_name_to_id: - tag_id = tag_name_to_id[normalized_tag_name] - else: - # Create new tag using database method - tag_id = self.db.add_tag(tag_name) - if tag_id: - tag_name_to_id[normalized_tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - else: - return # Failed to create tag - saved_types = get_saved_tag_types_for_photo(photo_id) - existing_tag_ids = list(saved_types.keys()) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - all_existing_tag_ids = existing_tag_ids + pending_tag_ids - if tag_id not in all_existing_tag_ids: - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - pending_tag_changes[photo_id].append(tag_id) - # mark pending type as single (0) - if photo_id not in pending_tag_linkage_type: - pending_tag_linkage_type[photo_id] = {} - pending_tag_linkage_type[photo_id][tag_id] = 0 - refresh_tag_list() - update_save_button_text() - _update_tags_label_in_main_list() - tag_var.set("") - - ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=0, column=2, padx=(0, 8)) - - canvas = tk.Canvas(list_frame, height=200) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - selected_tag_vars: Dict[str, tk.BooleanVar] = {} - - def refresh_tag_list(): - for widget in scrollable_frame.winfo_children(): - widget.destroy() - selected_tag_vars.clear() - saved_types = get_saved_tag_types_for_photo(photo_id) - existing_tag_ids = list(saved_types.keys()) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - pending_removal_ids = pending_tag_removals.get(photo_id, []) - all_tag_ids = existing_tag_ids + pending_tag_ids - unique_tag_ids = list(set(all_tag_ids)) - unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids] - unique_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids] - if not unique_tag_names: - ttk.Label(scrollable_frame, text="No tags linked to this photo", foreground="gray").pack(anchor=tk.W, pady=5) - return - for i, tag_id in enumerate(unique_tag_ids): - tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}") - var = tk.BooleanVar() - selected_tag_vars[tag_name] = var - frame = ttk.Frame(scrollable_frame) - frame.pack(fill=tk.X, pady=1) - is_pending = tag_id in pending_tag_ids - saved_type = saved_types.get(tag_id) - pending_type = pending_tag_linkage_type.get(photo_id, {}).get(tag_id) - # In single-photo dialog, only allow selecting pending if pending type is single (0) - is_pending_single = is_pending and pending_type == 0 - can_select = is_pending_single or (saved_type == allowed_delete_type) - cb = ttk.Checkbutton(frame, variable=var) - if not can_select: - try: - cb.state(["disabled"]) # disable selection for disallowed types - except Exception: - pass - cb.pack(side=tk.LEFT, padx=(0, 5)) - type_label = 'single' if saved_type == 0 else ('bulk' if saved_type == 1 else '?') - pending_label = "pending bulk" if (is_pending and pending_type == 1) else "pending" - status_text = f" ({pending_label})" if is_pending else f" (saved {type_label})" - status_color = "blue" if is_pending else ("black" if can_select else "gray") - ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT) - - def remove_selected_tags(): - tag_ids_to_remove = [] - for tag_name, var in selected_tag_vars.items(): - if var.get() and tag_name in tag_name_to_id: - tag_ids_to_remove.append(tag_name_to_id[tag_name]) - if not tag_ids_to_remove: - return - if photo_id in pending_tag_changes: - pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in tag_ids_to_remove] - if not pending_tag_changes[photo_id]: - del pending_tag_changes[photo_id] - saved_types = get_saved_tag_types_for_photo(photo_id) - for tag_id in tag_ids_to_remove: - if saved_types.get(tag_id) == allowed_delete_type: - if photo_id not in pending_tag_removals: - pending_tag_removals[photo_id] = [] - if tag_id not in pending_tag_removals[photo_id]: - pending_tag_removals[photo_id].append(tag_id) - refresh_tag_list() - update_save_button_text() - _update_tags_label_in_main_list() - - ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8)) - ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT) - refresh_tag_list() - - def on_close(): - existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])] - pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])] - all_tags = existing_tags_list + pending_tag_names - unique_tags = self.tag_manager.deduplicate_tags(all_tags) - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_tags = ", ".join(unique_tags) if unique_tags else "None" - label_widget.configure(text=current_tags) - popup.destroy() - - popup.protocol("WM_DELETE_WINDOW", on_close) - return handler - - def create_tag_buttons_frame(parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0): - tags_frame = ttk.Frame(parent) - existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])] - pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])] - all_tags = existing_tags_list + pending_tag_names - unique_tags = self.tag_manager.deduplicate_tags(all_tags) - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_display = ", ".join(unique_tags) if unique_tags else "None" - tags_text = ttk.Label(tags_frame, text=current_display) - tags_text.pack(side=tk.LEFT) - add_btn = tk.Button(tags_frame, text="šŸ”—", width=2, command=create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags, 0)) - add_btn.pack(side=tk.LEFT, padx=(6, 0)) - try: - _ToolTip(add_btn, "Manage tags for this photo") - except Exception: - pass - if use_grid: - tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W) - else: - tags_frame.pack(side=tk.LEFT, padx=5) - return tags_frame - - def show_list_view(): - clear_content() - nonlocal current_visible_cols - current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]] - col_count = len(current_visible_cols) - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - for i, col in enumerate(current_visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - for i, col in enumerate(current_visible_cols): - header_frame.columnconfigure(i * 2, weight=col['weight'], minsize=col['width']) - if i < len(current_visible_cols) - 1: - header_frame.columnconfigure(i * 2 + 1, weight=0, minsize=1) - for i, col in enumerate(current_visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i * 2, padx=5, sticky=tk.W) - header_label.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) - if i < len(current_visible_cols) - 1: - separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow") - separator_frame.grid(row=0, column=i * 2 + 1, sticky='ns', padx=0) - separator_frame.grid_propagate(False) - inner_line = tk.Frame(separator_frame, bg='darkred', width=2) - inner_line.pack(fill=tk.Y, expand=True) - separator_frame.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) - separator_frame.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) - separator_frame.bind("", stop_resize) - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred'))) - inner_line.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) - inner_line.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) - inner_line.bind("", stop_resize) - header_frame.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - folder_data = prepare_folder_grouped_data() - current_row = 2 - for folder_info in folder_data: - create_folder_header(content_inner, folder_info, current_row, col_count, 'list') - current_row += 1 - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - for i, col in enumerate(current_visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - for i, col in enumerate(current_visible_cols): - key = col['key'] - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'path': - text = photo['path'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i) - continue - # Render text wrapped to header width; do not auto-resize columns - if key == 'filename': - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=i, padx=5, sticky=tk.W) - lbl.bind("", lambda e, p=photo['path']: open_photo(p)) - elif key == 'faces' and photo['face_count'] > 0: - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=i, padx=5, sticky=tk.W) - # Show people names in tooltip on hover instead of popup on click - try: - people_tooltip_text = get_people_names_for_photo(photo['id']) - _ToolTip(lbl, people_tooltip_text) - except Exception: - pass - else: - ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=i, padx=5, sticky=tk.W) - current_row += 1 - - def show_icon_view(): - clear_content() - visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]] - col_count = len(visible_cols) - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - for i, col in enumerate(visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - header_label.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) - header_frame.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - folder_data = prepare_folder_grouped_data() - current_row = 2 - for folder_info in folder_data: - create_folder_header(content_inner, folder_info, current_row, col_count, 'icons') - current_row += 1 - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2) - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - col_idx = 0 - for col in visible_cols: - key = col['key'] - if key == 'thumbnail': - thumbnail_frame = ttk.Frame(row_frame) - thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - try: - if os.path.exists(photo['path']): - img = Image.open(photo['path']) - img.thumbnail((150, 150), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - photo_images.append(photo_img) - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_image(75, 75, image=photo_img) - else: - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="šŸ–¼ļø", fill="gray", font=("Arial", 24)) - except Exception: - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="āŒ", fill="red", font=("Arial", 24)) - else: - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - # Render text wrapped to header width; do not auto-resize columns - if key == 'filename': - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - lbl.bind("", lambda e, p=photo['path']: open_photo(p)) - elif key == 'faces' and photo['face_count'] > 0: - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - # Show people names in tooltip on hover instead of popup on click - try: - people_tooltip_text = get_people_names_for_photo(photo['id']) - _ToolTip(lbl, people_tooltip_text) - except Exception: - pass - else: - ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W) - col_idx += 1 - current_row += 1 - - def show_compact_view(): - clear_content() - visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]] - col_count = len(visible_cols) - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - for i, col in enumerate(visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - header_label.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) - header_frame.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - folder_data = prepare_folder_grouped_data() - current_row = 2 - for folder_info in folder_data: - create_folder_header(content_inner, folder_info, current_row, col_count, 'compact') - current_row += 1 - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - col_idx = 0 - for col in visible_cols: - key = col['key'] - if key == 'filename': - text = photo['filename'] - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - # Render text wrapped to header width; do not auto-resize columns - if key == 'filename': - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - lbl.bind("", lambda e, p=photo['path']: open_photo(p)) - elif key == 'faces' and photo['face_count'] > 0: - lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') - lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - # Show people names in tooltip on hover instead of popup on click - try: - people_tooltip_text = get_people_names_for_photo(photo['id']) - _ToolTip(lbl, people_tooltip_text) - except Exception: - pass - else: - ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W) - col_idx += 1 - current_row += 1 - - def switch_view_mode(mode): - if mode == "list": - show_list_view() - elif mode == "icons": - show_icon_view() - elif mode == "compact": - show_compact_view() - - load_existing_tags() - load_photos() - show_list_view() - root.deiconify() - root.mainloop() - return 0 - - diff --git a/data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG b/data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG deleted file mode 100644 index 476a875..0000000 Binary files a/data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG and /dev/null differ diff --git a/data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4 b/data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4 deleted file mode 100644 index 3b931f1..0000000 Binary files a/data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4 and /dev/null differ diff --git a/data/uploads/c55d21f2-6c1c-4ee5-a38b-633451ee946a.JPG b/data/uploads/c55d21f2-6c1c-4ee5-a38b-633451ee946a.JPG deleted file mode 100644 index 25e8e21..0000000 Binary files a/data/uploads/c55d21f2-6c1c-4ee5-a38b-633451ee946a.JPG and /dev/null differ diff --git a/data/uploads/c601752a-2f7c-40f5-b2c0-bf723827a104.JPG b/data/uploads/c601752a-2f7c-40f5-b2c0-bf723827a104.JPG deleted file mode 100644 index 7894da7..0000000 Binary files a/data/uploads/c601752a-2f7c-40f5-b2c0-bf723827a104.JPG and /dev/null differ diff --git a/data/uploads/ec9d2425-94d2-4f12-8b19-0906d94a1f36.JPG b/data/uploads/ec9d2425-94d2-4f12-8b19-0906d94a1f36.JPG deleted file mode 100644 index c06058a..0000000 Binary files a/data/uploads/ec9d2425-94d2-4f12-8b19-0906d94a1f36.JPG and /dev/null differ diff --git a/data/uploads/f8ad7854-8dab-4cb1-b941-b591878cfbef.JPG b/data/uploads/f8ad7854-8dab-4cb1-b941-b591878cfbef.JPG deleted file mode 100644 index 86e8468..0000000 Binary files a/data/uploads/f8ad7854-8dab-4cb1-b941-b591878cfbef.JPG and /dev/null differ diff --git a/demo_photos/DEMO_INSTRUCTIONS.md b/demo_photos/DEMO_INSTRUCTIONS.md deleted file mode 100644 index 85aaf37..0000000 --- a/demo_photos/DEMO_INSTRUCTIONS.md +++ /dev/null @@ -1,92 +0,0 @@ -# šŸŽÆ Enhanced Demo Photo Setup Instructions - -To run the enhanced demo with visual face recognition, you need sample photos in this folder structure. - -## šŸ“ø Quick Setup for Enhanced Demo - -1. **Find 6-12 photos** with clear faces from your collection -2. **Copy them** into the subfolders below: - -``` -demo_photos/ -ā”œā”€ā”€ family/ ← 3-5 photos with family members (SOME PEOPLE IN MULTIPLE PHOTOS) -ā”œā”€ā”€ friends/ ← 2-3 photos with friends -└── events/ ← 2-4 photos from events/gatherings -``` - -## šŸŽ­ Ideal Demo Photos for Enhanced Features: - -- **Clear faces**: Well-lit, not too small (face recognition works better) -- **Multiple people**: 2-5 people per photo works best -- **⭐ REPEAT appearances**: Same people in multiple photos (for auto-matching demo!) -- **Mix of scenarios**: Group photos + individual portraits -- **Different lighting/angles**: Shows robustness of cross-photo matching - -## šŸš€ Enhanced Demo Test Commands: - -### Basic Setup Test: -```bash -# Test the scan -source venv/bin/activate -python3 photo_tagger.py scan demo_photos --recursive --db demo.db -v - -# Should output something like: -# šŸ“ Found 8 photos, added 8 new photos -``` - -### Face Detection Test: -```bash -python3 photo_tagger.py process --db demo.db -v - -# Should output: -# šŸ” Processing 8 photos for faces... -# šŸ“ø Processing: family_photo1.jpg -# šŸ‘¤ Found 4 faces -``` - -### Enhanced Identification Test: -```bash -# Test individual face display -python3 photo_tagger.py identify --show-faces --batch 2 --db demo.db - -# Should show individual face crops automatically -``` - -## šŸŽŖ Enhanced Demo Success Criteria: - -After adding photos, you should be able to demonstrate: - -1. āœ… **Scan** finds your photos -2. āœ… **Process** detects faces in all photos -3. āœ… **Individual face display** shows cropped faces during identification -4. āœ… **Cross-photo matching** finds same people in different photos -5. āœ… **Confidence scoring** with color-coded quality levels -6. āœ… **Visual comparison** with side-by-side face images -7. āœ… **Search** finds photos by person name -8. āœ… **Smart filtering** only shows logical matches - -## šŸ“Š Expected Demo Results: - -With good demo photos, you should see: -- **15-30 faces detected** across all photos -- **3-8 unique people** identified -- **2-5 cross-photo matches** found by auto-matching -- **60-80% confidence** for good matches -- **Individual face crops** displayed automatically - -## šŸŽÆ Pro Tips for Best Demo: - -1. **Include repeat people**: Same person in 2-3 different photos -2. **Vary conditions**: Indoor/outdoor, different lighting -3. **Group + individual**: Mix of group photos and portraits -4. **Clear faces**: Avoid sunglasses, hats, or poor lighting -5. **Multiple angles**: Front-facing and slight profile views - ---- - -**Ready for Enhanced Demo!** šŸŽ‰ - -Your demo will showcase: -- **Visual face recognition** with individual face display -- **Intelligent cross-photo matching** with confidence scoring -- **Privacy-first local processing** with professional features \ No newline at end of file diff --git a/demo_photos/README.md b/demo_photos/README.md deleted file mode 100644 index 1ab590a..0000000 --- a/demo_photos/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# šŸ“ø Demo Photos Setup - -## šŸŽÆ Quick Setup for Demo - -1. **Add 6-10 photos with faces** to these folders: - - `family/` - Family photos (3-4 photos) - - `friends/` - Friend photos (2-3 photos) - - `events/` - Event photos (2-3 photos) - -2. **Important**: Include some people in multiple photos for auto-matching demo - -3. **Run demo**: See main `DEMO.md` file - -## āœ… Current Status - -- **3 photos** in `family/` folder -- **20 faces detected** -- **14 people identified** -- **Ready for demo!** - ---- - -For complete setup instructions: `DEMO_INSTRUCTIONS.md` \ No newline at end of file diff --git a/demo_photos/events/demo_events.txt b/demo_photos/events/demo_events.txt deleted file mode 100644 index 16f7d4e..0000000 --- a/demo_photos/events/demo_events.txt +++ /dev/null @@ -1 +0,0 @@ -Demo placeholder - replace with actual photos diff --git a/demo_photos/more_photos/imgres.html b/demo_photos/more_photos/imgres.html deleted file mode 100644 index 3ef9709..0000000 --- a/demo_photos/more_photos/imgres.html +++ /dev/null @@ -1,36 +0,0 @@ -family pictures - Google Search
\ No newline at end of file diff --git a/demo_photos/more_photos/trwert b/demo_photos/more_photos/trwert deleted file mode 100644 index 0c2c9ee..0000000 Binary files a/demo_photos/more_photos/trwert and /dev/null differ diff --git a/demo_photos/testdeepface/P1010063.JPG b/demo_photos/testdeepface/P1010063.JPG deleted file mode 100644 index dd6b108..0000000 Binary files a/demo_photos/testdeepface/P1010063.JPG and /dev/null differ