From 8ce538c50876327a45a5cbd03f10b93bfc7a2f7b Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 10 Oct 2025 12:40:24 -0400 Subject: [PATCH] Add Auto-Match Panel to Dashboard GUI for enhanced face matching functionality This commit introduces the AutoMatchPanel class into the Dashboard GUI, providing a fully integrated interface for automatic face matching. The new panel allows users to start the auto-match process, configure tolerance settings, and visually confirm matches between identified and unidentified faces. It includes features for bulk selection of matches, smart navigation through matched individuals, and a search filter for large databases. The README has been updated to reflect the new functionality and improvements in the auto-match workflow, enhancing the overall user experience in managing photo identifications. --- README_UNIFIED_DASHBOARD.md | 82 +- auto_match_panel.py | 868 +++++++++++++++++++++ dashboard_gui.py | 1448 ++++++++++++++++++++++++++++++++++- face_processing.py | 4 +- gui_core.py | 2 +- identify_panel.py | 22 +- photo_tagger.py | 2 +- 7 files changed, 2352 insertions(+), 76 deletions(-) create mode 100644 auto_match_panel.py diff --git a/README_UNIFIED_DASHBOARD.md b/README_UNIFIED_DASHBOARD.md index 067d900..0480aaf 100644 --- a/README_UNIFIED_DASHBOARD.md +++ b/README_UNIFIED_DASHBOARD.md @@ -51,13 +51,13 @@ python3 setup.py # Installs system deps + Python packages python3 photo_tagger.py dashboard # 3. Use the menu bar to access all features: -# šŸ“ Scan - Add photos to your collection -# šŸ” Process - Detect faces in photos -# šŸ‘¤ Identify - Identify people in photos -# šŸ”— Auto-Match - Find matching faces automatically -# šŸ”Ž Search - Find photos by person name -# āœļø Modify - Edit face identifications -# šŸ·ļø Tags - Manage photo tags +# šŸ“ Scan - Add photos to your collection (āœ… Fully Functional) +# šŸ” Process - Detect faces in photos (āœ… Fully Functional) +# šŸ‘¤ Identify - Identify people in photos (āœ… Fully Functional) +# šŸ”— Auto-Match - Find matching faces automatically (āœ… Fully Functional) +# šŸ”Ž Search - Find photos by person name (āœ… Fully Functional) +# āœļø Modify - Edit face identifications (🚧 Coming Soon) +# šŸ·ļø Tags - Manage photo tags (🚧 Coming Soon) ``` ## šŸ“¦ Installation @@ -149,17 +149,47 @@ The dashboard automatically opens in full screen mode and provides a fully respo - **Responsive Layout**: Adapts to window resizing with dynamic updates - **Enhanced Navigation**: Back/Next buttons with unsaved changes protection -#### **šŸ”— Auto-Match Panel** *(Coming Soon)* -- **Person-Centric View**: Show matched person with potential matches -- **Confidence Scoring**: Color-coded match confidence levels -- **Bulk Selection**: Select multiple faces for identification -- **Smart Navigation**: Efficient browsing through matches +#### **šŸ”— Auto-Match Panel** *(Fully Functional)* +- **Person-Centric Workflow**: Groups faces by already identified people +- **Visual Confirmation**: Left panel shows identified person, right panel shows potential matches +- **Confidence Scoring**: Color-coded match confidence levels with detailed descriptions +- **Bulk Selection**: Select multiple faces for identification with Select All/Clear All +- **Smart Navigation**: Back/Next buttons to move between different people +- **Search Functionality**: Filter people by last name for large databases +- **Pre-selection**: Previously identified faces are automatically checked +- **Unsaved Changes Protection**: Warns before losing unsaved work +- **Database Integration**: Proper transactions and face encoding updates -#### **šŸ”Ž Search Panel** *(Coming Soon)* -- **Advanced Search**: Find photos by person name -- **Multiple Search Types**: Name-based, tag-based, date-based searches -- **Results Display**: Grid view with face thumbnails -- **Export Options**: Save search results +##### **Auto-Match Workflow** +The auto-match feature works in a **person-centric** way: + +1. **Group by Person**: Faces are grouped by already identified people (not unidentified faces) +2. **Show Matched Person**: Left side shows the identified person and their face +3. **Show Unidentified Faces**: Right side shows all unidentified faces that match this person +4. **Select and Save**: Check the faces you want to identify with this person, then click "Save Changes" +5. **Navigate**: Use Back/Next to move between different people +6. **Correct Mistakes**: Go back and uncheck faces to unidentify them +7. **Pre-selected Checkboxes**: Previously identified faces are automatically checked when you go back + +**Key Benefits:** +- **1-to-Many**: One person can have multiple unidentified faces matched to them +- **Visual Confirmation**: See exactly what you're identifying before saving +- **Easy Corrections**: Go back and fix mistakes by unchecking faces +- **Smart Tracking**: Previously identified faces are pre-selected for easy review + +##### **Auto-Match Configuration** +- **Tolerance Setting**: Adjust matching sensitivity (lower = stricter matching) +- **Start Button**: Prominently positioned on the left for easy access +- **Search Functionality**: Filter people by last name for large databases +- **Exit Button**: "Exit Auto-Match" with unsaved changes protection + +#### **šŸ”Ž Search Panel** *(Fully Functional)* +- **Multiple Search Types**: Search photos by name, date, tags, and special categories +- **Advanced Filtering**: Filter by folder location with browse functionality +- **Results Display**: Sortable table with person names, tags, processed status, and dates +- **Interactive Results**: Click to open photos, browse folders, and view people +- **Tag Management**: Add and remove tags from selected photos +- **Responsive Layout**: Adapts to window resizing with proper scrolling #### **āœļø Modify Panel** *(Coming Soon)* - **Review Identifications**: View all identified people @@ -319,13 +349,13 @@ source venv/bin/activate python3 photo_tagger.py dashboard # Then use the menu bar in the dashboard: -# šŸ“ Scan - Add photos -# šŸ” Process - Detect faces -# šŸ‘¤ Identify - Identify people -# šŸ”— Auto-Match - Find matches -# šŸ”Ž Search - Find photos -# āœļø Modify - Edit identifications -# šŸ·ļø Tags - Manage tags +# šŸ“ Scan - Add photos (āœ… Fully Functional) +# šŸ” Process - Detect faces (āœ… Fully Functional) +# šŸ‘¤ Identify - Identify people (āœ… Fully Functional) +# šŸ”— Auto-Match - Find matches (āœ… Fully Functional) +# šŸ”Ž Search - Find photos (āœ… Fully Functional) +# āœļø Modify - Edit identifications (🚧 Coming Soon) +# šŸ·ļø Tags - Manage tags (🚧 Coming Soon) # Legacy command line usage python3 photo_tagger.py scan ~/Pictures --recursive @@ -353,8 +383,8 @@ python3 photo_tagger.py stats ### Phase 2: GUI Panel Integration (In Progress) - [x] Identify panel integration (fully functional) -- [ ] Auto-Match panel integration -- [ ] Search panel integration +- [x] Auto-Match panel integration (fully functional) +- [x] Search panel integration (fully functional) - [ ] Modify panel integration - [ ] Tags panel integration diff --git a/auto_match_panel.py b/auto_match_panel.py new file mode 100644 index 0000000..7bc9b0d --- /dev/null +++ b/auto_match_panel.py @@ -0,0 +1,868 @@ +#!/usr/bin/env python3 +""" +Auto-Match Panel for PunimTag Dashboard +Embeds the full auto-match GUI functionality into the dashboard frame +""" + +import os +import tkinter as tk +from tkinter import ttk, messagebox +from PIL import Image, ImageTk +from typing import List, Dict, Tuple, Optional + +from config import DEFAULT_FACE_TOLERANCE +from database import DatabaseManager +from face_processing import FaceProcessor +from gui_core import GUICore + + +class AutoMatchPanel: + """Integrated auto-match panel that embeds the full auto-match GUI functionality into the dashboard""" + + def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager, + face_processor: FaceProcessor, gui_core: GUICore, verbose: int = 0): + """Initialize the auto-match panel""" + self.parent_frame = parent_frame + self.db = db_manager + self.face_processor = face_processor + self.gui_core = gui_core + self.verbose = verbose + + # Panel state + self.is_active = False + self.matches_by_matched = {} + self.data_cache = {} + self.current_matched_index = 0 + self.matched_ids = [] + self.filtered_matched_ids = None + self.identified_faces_per_person = {} + self.checkbox_states_per_person = {} + self.original_checkbox_states_per_person = {} + self.identified_count = 0 + + # GUI components + self.components = {} + self.main_frame = None + + def create_panel(self) -> ttk.Frame: + """Create the auto-match panel with all GUI components""" + self.main_frame = ttk.Frame(self.parent_frame) + + # Configure grid weights for full screen responsiveness + self.main_frame.columnconfigure(0, weight=1) # Left panel + self.main_frame.columnconfigure(1, weight=1) # Right panel + self.main_frame.rowconfigure(0, weight=0) # Configuration row - fixed height + self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable + self.main_frame.rowconfigure(2, weight=0) # Control buttons row - fixed height + + # Create all GUI components + self._create_gui_components() + + # Create main content panels + self._create_main_panels() + + return self.main_frame + + def _create_gui_components(self): + """Create all GUI components for the auto-match interface""" + # Configuration frame + config_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="10") + config_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10)) + # Don't give weight to any column to prevent stretching + + # Start button (moved to the left) + start_btn = ttk.Button(config_frame, text="šŸš€ Start Auto-Match", command=self._start_auto_match) + start_btn.grid(row=0, column=0, padx=(0, 20)) + + # Tolerance setting + ttk.Label(config_frame, text="Tolerance:").grid(row=0, column=1, sticky=tk.W, padx=(0, 2)) + self.components['tolerance_var'] = tk.StringVar(value=str(DEFAULT_FACE_TOLERANCE)) + tolerance_entry = ttk.Entry(config_frame, textvariable=self.components['tolerance_var'], width=8) + tolerance_entry.grid(row=0, column=2, sticky=tk.W, padx=(0, 10)) + ttk.Label(config_frame, text="(lower = stricter matching)").grid(row=0, column=3, sticky=tk.W) + + def _create_main_panels(self): + """Create the main left and right panels""" + # Left panel for identified person + self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Identified Person", padding="10") + self.components['left_panel'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) + + # Right panel for unidentified faces + self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Unidentified Faces to Match", padding="10") + self.components['right_panel'].grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) + + # Create left panel content + self._create_left_panel_content() + + # Create right panel content + self._create_right_panel_content() + + # Create control buttons + self._create_control_buttons() + + def _create_left_panel_content(self): + """Create the left panel content for identified person""" + left_panel = self.components['left_panel'] + + # Search controls for filtering people by last name + search_frame = ttk.Frame(left_panel) + search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + search_frame.columnconfigure(0, weight=1) + + # Search input + self.components['search_var'] = tk.StringVar() + search_entry = ttk.Entry(search_frame, textvariable=self.components['search_var'], width=20) + search_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Search buttons + search_btn = ttk.Button(search_frame, text="Search", width=8, command=self._apply_search_filter) + search_btn.grid(row=0, column=1, padx=(0, 5)) + + clear_btn = ttk.Button(search_frame, text="Clear", width=6, command=self._clear_search_filter) + clear_btn.grid(row=0, column=2) + + # Search help label + self.components['search_help_label'] = ttk.Label(search_frame, text="Type Last Name", + font=("Arial", 8), foreground="gray") + self.components['search_help_label'].grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=(2, 0)) + + # Person info label + self.components['person_info_label'] = ttk.Label(left_panel, text="", font=("Arial", 10, "bold")) + self.components['person_info_label'].grid(row=1, column=0, pady=(0, 10), sticky=tk.W) + + # Person image canvas + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + self.components['person_canvas'] = tk.Canvas(left_panel, width=300, height=300, + bg=canvas_bg_color, highlightthickness=0) + self.components['person_canvas'].grid(row=2, column=0, pady=(0, 10)) + + # Save button + self.components['save_btn'] = ttk.Button(left_panel, text="šŸ’¾ Save Changes", + command=self._save_changes, state='disabled') + self.components['save_btn'].grid(row=3, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) + + def _create_right_panel_content(self): + """Create the right panel content for unidentified faces""" + right_panel = self.components['right_panel'] + + # Control buttons for matches (Select All / Clear All) + matches_controls_frame = ttk.Frame(right_panel) + matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + + self.components['select_all_btn'] = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", + command=self._select_all_matches, state='disabled') + self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) + + self.components['clear_all_btn'] = ttk.Button(matches_controls_frame, text="☐ Clear All", + command=self._clear_all_matches, state='disabled') + self.components['clear_all_btn'].pack(side=tk.LEFT) + + # Create scrollable frame for matches + matches_frame = ttk.Frame(right_panel) + matches_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + matches_frame.columnconfigure(0, weight=1) + matches_frame.rowconfigure(0, weight=1) + + # Create canvas and scrollbar for matches + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + self.components['matches_canvas'] = tk.Canvas(matches_frame, bg=canvas_bg_color, highlightthickness=0) + self.components['matches_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + scrollbar = ttk.Scrollbar(matches_frame, orient="vertical", command=self.components['matches_canvas'].yview) + scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) + self.components['matches_canvas'].configure(yscrollcommand=scrollbar.set) + + # Configure right panel grid weights + right_panel.columnconfigure(0, weight=1) + right_panel.rowconfigure(1, weight=1) + + def _create_control_buttons(self): + """Create the control buttons for navigation""" + control_frame = ttk.Frame(self.main_frame) + control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0)) + + self.components['back_btn'] = ttk.Button(control_frame, text="ā®ļø Back", + command=self._go_back, state='disabled') + self.components['back_btn'].grid(row=0, column=0, padx=(0, 5)) + + self.components['next_btn'] = ttk.Button(control_frame, text="ā­ļø Next", + command=self._go_next, state='disabled') + self.components['next_btn'].grid(row=0, column=1, padx=5) + + self.components['quit_btn'] = ttk.Button(control_frame, text="āŒ Exit Auto-Match", + command=self._quit_auto_match) + self.components['quit_btn'].grid(row=0, column=2, padx=(5, 0)) + + def _start_auto_match(self): + """Start the auto-match process""" + try: + tolerance = float(self.components['tolerance_var'].get().strip()) + if tolerance < 0 or tolerance > 1: + raise ValueError + except Exception: + messagebox.showerror("Error", "Please enter a valid tolerance value between 0.0 and 1.0.") + return + + include_same_photo = False # Always exclude same photo matching + + # Get all identified faces (one per person) to use as reference faces + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score + FROM faces f + JOIN photos p ON f.photo_id = p.id + WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 + ORDER BY f.person_id, f.quality_score DESC + ''') + identified_faces = cursor.fetchall() + + if not identified_faces: + messagebox.showinfo("No Identified Faces", "šŸ” No identified faces found for auto-matching") + return + + # Group by person and get the best quality face per person + person_faces = {} + for face in identified_faces: + person_id = face[1] + if person_id not in person_faces: + person_faces[person_id] = face + + # Convert to ordered list to ensure consistent ordering + person_faces_list = [] + for person_id, face in person_faces.items(): + # Get person name for ordering + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,)) + result = cursor.fetchone() + if result: + first_name, last_name = result + if last_name and first_name: + person_name = f"{last_name}, {first_name}" + elif last_name: + person_name = last_name + elif first_name: + person_name = first_name + else: + person_name = "Unknown" + else: + person_name = "Unknown" + person_faces_list.append((person_id, face, person_name)) + + # Sort by person name for consistent, user-friendly ordering + person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2) + + # Find similar faces for each identified person + self.matches_by_matched = {} + for person_id, reference_face, person_name in person_faces_list: + reference_face_id = reference_face[0] + + # Use the same filtering and sorting logic as identify + similar_faces = self.face_processor._get_filtered_similar_faces( + reference_face_id, tolerance, include_same_photo, face_status=None) + + # Convert to auto-match format + person_matches = [] + for similar_face in similar_faces: + match = { + 'unidentified_id': similar_face['face_id'], + 'unidentified_photo_id': similar_face['photo_id'], + 'unidentified_filename': similar_face['filename'], + 'unidentified_location': similar_face['location'], + 'matched_id': reference_face_id, + 'matched_photo_id': reference_face[2], + 'matched_filename': reference_face[4], + 'matched_location': reference_face[3], + 'person_id': person_id, + 'distance': similar_face['distance'], + 'quality_score': similar_face['quality_score'], + 'adaptive_tolerance': similar_face.get('adaptive_tolerance', tolerance) + } + person_matches.append(match) + + self.matches_by_matched[person_id] = person_matches + + # Flatten all matches for counting + all_matches = [] + for person_matches in self.matches_by_matched.values(): + all_matches.extend(person_matches) + + if not all_matches: + messagebox.showinfo("No Matches", "šŸ” No similar faces found for auto-identification") + return + + # Pre-fetch all needed data + self.data_cache = self._prefetch_auto_match_data(self.matches_by_matched) + + # Initialize state + self.matched_ids = [person_id for person_id, _, _ in person_faces_list + if person_id in self.matches_by_matched and self.matches_by_matched[person_id]] + self.filtered_matched_ids = None + self.current_matched_index = 0 + self.identified_faces_per_person = {} + self.checkbox_states_per_person = {} + self.original_checkbox_states_per_person = {} + self.identified_count = 0 + + # Check if there's only one person - disable search if so + has_only_one_person = len(self.matched_ids) == 1 + if has_only_one_person: + self.components['search_var'].set("") + search_entry = None + for widget in self.components['left_panel'].winfo_children(): + if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0: + for child in widget.winfo_children(): + if isinstance(child, ttk.Entry): + search_entry = child + break + if search_entry: + search_entry.config(state='disabled') + self.components['search_help_label'].config(text="(Search disabled - only one person found)") + + # Enable controls + self._update_control_states() + + # Show the first person + self._update_display() + + self.is_active = True + + def _prefetch_auto_match_data(self, matches_by_matched: Dict) -> Dict: + """Pre-fetch all needed data to avoid repeated database queries""" + data_cache = {} + + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Pre-fetch all person names and details + person_ids = list(matches_by_matched.keys()) + if person_ids: + placeholders = ','.join('?' * len(person_ids)) + cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids) + data_cache['person_details'] = {} + for row in cursor.fetchall(): + person_id = row[0] + first_name = row[1] or '' + last_name = row[2] or '' + middle_name = row[3] or '' + maiden_name = row[4] or '' + date_of_birth = row[5] or '' + + # Create full name display + name_parts = [] + if first_name: + name_parts.append(first_name) + if middle_name: + name_parts.append(middle_name) + if last_name: + name_parts.append(last_name) + if maiden_name: + name_parts.append(f"({maiden_name})") + + full_name = ' '.join(name_parts) + data_cache['person_details'][person_id] = { + 'full_name': full_name, + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': middle_name, + 'maiden_name': maiden_name, + 'date_of_birth': date_of_birth + } + + # Pre-fetch all photo paths (both matched and unidentified) + all_photo_ids = set() + for person_matches in matches_by_matched.values(): + for match in person_matches: + all_photo_ids.add(match['matched_photo_id']) + all_photo_ids.add(match['unidentified_photo_id']) + + if all_photo_ids: + photo_ids_list = list(all_photo_ids) + placeholders = ','.join('?' * len(photo_ids_list)) + cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list) + data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()} + + return data_cache + + def _update_display(self): + """Update the display for the current person""" + active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids + + if self.current_matched_index >= len(active_ids): + self._finish_auto_match() + return + + matched_id = active_ids[self.current_matched_index] + matches_for_this_person = self.matches_by_matched[matched_id] + + # Update button states + self._update_control_states() + + # Update save button text with person name + self._update_save_button_text() + + # Get the first match to get matched person info + if not matches_for_this_person: + print(f"āŒ Error: No matches found for current person {matched_id}") + # Skip to next person if available + if self.current_matched_index < len(active_ids) - 1: + self.current_matched_index += 1 + self._update_display() + else: + self._finish_auto_match() + return + + first_match = matches_for_this_person[0] + + # Use cached data instead of database queries + person_details = self.data_cache['person_details'].get(first_match['person_id'], {}) + person_name = person_details.get('full_name', "Unknown") + date_of_birth = person_details.get('date_of_birth', '') + matched_photo_path = self.data_cache['photo_paths'].get(first_match['matched_photo_id'], None) + + # Create detailed person info display + person_info_lines = [f"šŸ‘¤ Person: {person_name}"] + if date_of_birth: + person_info_lines.append(f"šŸ“… Born: {date_of_birth}") + person_info_lines.extend([ + f"šŸ“ Photo: {first_match['matched_filename']}", + f"šŸ“ Face location: {first_match['matched_location']}" + ]) + + # Update matched person info + self.components['person_info_label'].config(text="\n".join(person_info_lines)) + + # Display matched person face + self.components['person_canvas'].delete("all") + if matched_photo_path: + matched_crop_path = self.face_processor._extract_face_crop( + matched_photo_path, + first_match['matched_location'], + f"matched_{first_match['person_id']}" + ) + + if matched_crop_path and os.path.exists(matched_crop_path): + try: + pil_image = Image.open(matched_crop_path) + pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(pil_image) + self.components['person_canvas'].create_image(150, 150, image=photo) + self.components['person_canvas'].image = photo + + # Add photo icon to the matched person face + actual_width, actual_height = pil_image.size + top_left_x = 150 - (actual_width // 2) + top_left_y = 150 - (actual_height // 2) + self.gui_core.create_photo_icon(self.components['person_canvas'], matched_photo_path, icon_size=20, + face_x=top_left_x, face_y=top_left_y, + face_width=actual_width, face_height=actual_height, + canvas_width=300, canvas_height=300) + except Exception as e: + self.components['person_canvas'].create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") + else: + self.components['person_canvas'].create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") + + # Clear and populate unidentified faces + self._update_matches_display(matches_for_this_person, matched_id) + + def _update_matches_display(self, matches_for_this_person, matched_id): + """Update the matches display for the current person""" + # Clear existing matches + self.components['matches_canvas'].delete("all") + self.match_checkboxes = [] + self.match_vars = [] + + # Create frame for unidentified faces inside canvas + matches_inner_frame = ttk.Frame(self.components['matches_canvas']) + self.components['matches_canvas'].create_window((0, 0), window=matches_inner_frame, anchor="nw") + + # Use cached photo paths + photo_paths = self.data_cache['photo_paths'] + + # Create all checkboxes + for i, match in enumerate(matches_for_this_person): + # Get unidentified face info from cached data + unidentified_photo_path = photo_paths.get(match['unidentified_photo_id'], '') + + # Calculate confidence + confidence_pct = (1 - match['distance']) * 100 + confidence_desc = self.face_processor._get_confidence_description(confidence_pct) + + # Create match frame + match_frame = ttk.Frame(matches_inner_frame) + match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5) + + # Checkbox for this match + match_var = tk.BooleanVar() + + # Restore previous checkbox state if available + unique_key = f"{matched_id}_{match['unidentified_id']}" + if matched_id in self.checkbox_states_per_person and unique_key in self.checkbox_states_per_person[matched_id]: + saved_state = self.checkbox_states_per_person[matched_id][unique_key] + match_var.set(saved_state) + # Otherwise, pre-select if this face was previously identified for this person + elif matched_id in self.identified_faces_per_person and match['unidentified_id'] in self.identified_faces_per_person[matched_id]: + match_var.set(True) + + self.match_vars.append(match_var) + + # Capture original state at render time + if matched_id not in self.original_checkbox_states_per_person: + self.original_checkbox_states_per_person[matched_id] = {} + if unique_key not in self.original_checkbox_states_per_person[matched_id]: + self.original_checkbox_states_per_person[matched_id][unique_key] = match_var.get() + + # Add callback to save state immediately when checkbox changes + def on_checkbox_change(var, person_id, face_id): + unique_key = f"{person_id}_{face_id}" + if person_id not in self.checkbox_states_per_person: + self.checkbox_states_per_person[person_id] = {} + + current_value = var.get() + self.checkbox_states_per_person[person_id][unique_key] = current_value + + # Bind the callback to the variable + current_person_id = matched_id + current_face_id = match['unidentified_id'] + match_var.trace('w', lambda *args, var=match_var, person_id=current_person_id, face_id=current_face_id: on_checkbox_change(var, person_id, face_id)) + + # Configure match frame for grid layout + match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width + match_frame.columnconfigure(1, weight=0) # Image column - fixed width + match_frame.columnconfigure(2, weight=1) # Text column - expandable + + # Checkbox + checkbox = ttk.Checkbutton(match_frame, variable=match_var) + checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) + self.match_checkboxes.append(checkbox) + + # Unidentified face image + match_canvas = None + if unidentified_photo_path: + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + match_canvas = tk.Canvas(match_frame, width=100, height=100, bg=canvas_bg_color, highlightthickness=0) + match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10)) + + unidentified_crop_path = self.face_processor._extract_face_crop( + unidentified_photo_path, + match['unidentified_location'], + f"unid_{match['unidentified_id']}" + ) + + if unidentified_crop_path and os.path.exists(unidentified_crop_path): + try: + pil_image = Image.open(unidentified_crop_path) + pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(pil_image) + match_canvas.create_image(50, 50, image=photo) + match_canvas.image = photo + + # Add photo icon + self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, + face_x=0, face_y=0, + face_width=100, face_height=100, + canvas_width=100, canvas_height=100) + except Exception: + match_canvas.create_text(50, 50, text="āŒ", fill="red") + else: + match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") + + # Confidence badge and filename + info_container = ttk.Frame(match_frame) + info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E)) + + badge = self.gui_core.create_confidence_badge(info_container, confidence_pct) + badge.pack(anchor=tk.W) + + filename_label = ttk.Label(info_container, text=f"šŸ“ {match['unidentified_filename']}", + font=("Arial", 8), foreground="gray") + filename_label.pack(anchor=tk.W, pady=(2, 0)) + + # Update Select All / Clear All button states + self._update_match_control_buttons_state() + + # Update scroll region + self.components['matches_canvas'].update_idletasks() + self.components['matches_canvas'].configure(scrollregion=self.components['matches_canvas'].bbox("all")) + + def _update_control_states(self): + """Update control button states based on current position""" + active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids + + # Enable/disable Back button + if self.current_matched_index > 0: + self.components['back_btn'].config(state='normal') + else: + self.components['back_btn'].config(state='disabled') + + # Enable/disable Next button + if self.current_matched_index < len(active_ids) - 1: + self.components['next_btn'].config(state='normal') + else: + self.components['next_btn'].config(state='disabled') + + # Enable save button if we have matches + if active_ids and self.current_matched_index < len(active_ids): + self.components['save_btn'].config(state='normal') + else: + self.components['save_btn'].config(state='disabled') + + def _update_save_button_text(self): + """Update save button text with current person name""" + active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids + if self.current_matched_index < len(active_ids): + matched_id = active_ids[self.current_matched_index] + matches_for_current_person = self.matches_by_matched[matched_id] + if matches_for_current_person: + person_id = matches_for_current_person[0]['person_id'] + person_details = self.data_cache['person_details'].get(person_id, {}) + person_name = person_details.get('full_name', "Unknown") + self.components['save_btn'].config(text=f"šŸ’¾ Save changes for {person_name}") + else: + self.components['save_btn'].config(text="šŸ’¾ Save Changes") + else: + self.components['save_btn'].config(text="šŸ’¾ Save Changes") + + def _update_match_control_buttons_state(self): + """Enable/disable Select All / Clear All based on matches presence""" + if hasattr(self, 'match_vars') and self.match_vars: + self.components['select_all_btn'].config(state='normal') + self.components['clear_all_btn'].config(state='normal') + else: + self.components['select_all_btn'].config(state='disabled') + self.components['clear_all_btn'].config(state='disabled') + + def _select_all_matches(self): + """Select all match checkboxes""" + if hasattr(self, 'match_vars'): + for var in self.match_vars: + var.set(True) + + def _clear_all_matches(self): + """Clear all match checkboxes""" + if hasattr(self, 'match_vars'): + for var in self.match_vars: + var.set(False) + + def _save_changes(self): + """Save changes for the current person""" + active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids + if self.current_matched_index < len(active_ids): + matched_id = active_ids[self.current_matched_index] + matches_for_this_person = self.matches_by_matched[matched_id] + + # Initialize identified faces for this person if not exists + if matched_id not in self.identified_faces_per_person: + self.identified_faces_per_person[matched_id] = set() + + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Process all matches (both checked and unchecked) + for i, (match, var) in enumerate(zip(matches_for_this_person, self.match_vars)): + if var.get(): + # Face is checked - assign to person + cursor.execute( + 'UPDATE faces SET person_id = ? WHERE id = ?', + (match['person_id'], match['unidentified_id']) + ) + + # Use cached person name + person_details = self.data_cache['person_details'].get(match['person_id'], {}) + person_name = person_details.get('full_name', "Unknown") + + # Track this face as identified for this person + self.identified_faces_per_person[matched_id].add(match['unidentified_id']) + + print(f"āœ… Identified as: {person_name}") + self.identified_count += 1 + else: + # Face is unchecked - check if it was previously identified for this person + if match['unidentified_id'] in self.identified_faces_per_person[matched_id]: + # This face was previously identified for this person, now unchecking it + cursor.execute( + 'UPDATE faces SET person_id = NULL WHERE id = ?', + (match['unidentified_id'],) + ) + + # Remove from identified faces for this person + self.identified_faces_per_person[matched_id].discard(match['unidentified_id']) + + print(f"āŒ Unidentified: {match['unidentified_filename']}") + + # Update person encodings for all affected persons + for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']): + self.face_processor.update_person_encodings(person_id) + + conn.commit() + + # After saving, set original states to the current UI states + current_snapshot = {} + for match, var in zip(matches_for_this_person, self.match_vars): + unique_key = f"{matched_id}_{match['unidentified_id']}" + current_snapshot[unique_key] = var.get() + self.checkbox_states_per_person[matched_id] = dict(current_snapshot) + self.original_checkbox_states_per_person[matched_id] = dict(current_snapshot) + + def _go_back(self): + """Go back to the previous person""" + if self.current_matched_index > 0: + self.current_matched_index -= 1 + self._update_display() + + def _go_next(self): + """Go to the next person""" + active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids + if self.current_matched_index < len(active_ids) - 1: + self.current_matched_index += 1 + self._update_display() + else: + self._finish_auto_match() + + def _apply_search_filter(self): + """Filter people by last name and update navigation""" + query = self.components['search_var'].get().strip().lower() + if query: + # Filter person_faces_list by last name + filtered_people = [] + for person_id in self.matched_ids: + # Get person name from cache + person_details = self.data_cache['person_details'].get(person_id, {}) + person_name = person_details.get('full_name', '') + + # Extract last name from person_name + if ',' in person_name: + last_name = person_name.split(',')[0].strip().lower() + else: + # Try to extract last name from full name + name_parts = person_name.strip().split() + if name_parts: + last_name = name_parts[-1].lower() + else: + last_name = '' + + if query in last_name: + filtered_people.append(person_id) + + self.filtered_matched_ids = filtered_people + else: + self.filtered_matched_ids = None + + # Reset to first person in filtered list + self.current_matched_index = 0 + if self.filtered_matched_ids: + self._update_display() + else: + # No matches - clear display + self.components['person_info_label'].config(text="No people match filter") + self.components['person_canvas'].delete("all") + self.components['person_canvas'].create_text(150, 150, text="No matches found", fill="gray") + self.components['matches_canvas'].delete("all") + self._update_control_states() + + def _clear_search_filter(self): + """Clear filter and show all people""" + self.components['search_var'].set("") + self.filtered_matched_ids = None + self.current_matched_index = 0 + self._update_display() + + def _finish_auto_match(self): + """Finish the auto-match process""" + print(f"\nāœ… Auto-identified {self.identified_count} faces") + messagebox.showinfo("Auto-Match Complete", f"Auto-identified {self.identified_count} faces") + self._cleanup() + + def _quit_auto_match(self): + """Quit the auto-match process""" + # Check for unsaved changes before quitting + if self._has_unsaved_changes(): + result = messagebox.askyesnocancel( + "Unsaved Changes", + "You have unsaved changes that will be lost if you quit.\n\n" + "Yes: Save current changes and quit\n" + "No: Quit without saving\n" + "Cancel: Return to auto-match" + ) + if result is None: + # Cancel + return + if result: + # Save current person's changes, then quit + self._save_changes() + + self._cleanup() + + def _has_unsaved_changes(self): + """Check if there are any unsaved changes""" + for person_id, current_states in self.checkbox_states_per_person.items(): + if person_id in self.original_checkbox_states_per_person: + original_states = self.original_checkbox_states_per_person[person_id] + # Check if any checkbox state differs from its original state + for key, current_value in current_states.items(): + if key not in original_states or original_states[key] != current_value: + return True + else: + # If person has current states but no original states, there are changes + if any(current_states.values()): + return True + return False + + def _cleanup(self): + """Clean up resources and reset state""" + # Clean up face crops + self.face_processor.cleanup_face_crops() + + # Reset state + self.matches_by_matched = {} + self.data_cache = {} + self.current_matched_index = 0 + self.matched_ids = [] + self.filtered_matched_ids = None + self.identified_faces_per_person = {} + self.checkbox_states_per_person = {} + self.original_checkbox_states_per_person = {} + self.identified_count = 0 + + # Clear displays + self.components['person_info_label'].config(text="") + self.components['person_canvas'].delete("all") + self.components['matches_canvas'].delete("all") + + # Disable controls + self.components['back_btn'].config(state='disabled') + self.components['next_btn'].config(state='disabled') + self.components['save_btn'].config(state='disabled') + self.components['select_all_btn'].config(state='disabled') + self.components['clear_all_btn'].config(state='disabled') + + # Clear search + self.components['search_var'].set("") + self.components['search_help_label'].config(text="Type Last Name") + + # Re-enable search entry + search_entry = None + for widget in self.components['left_panel'].winfo_children(): + if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0: + for child in widget.winfo_children(): + if isinstance(child, ttk.Entry): + search_entry = child + break + if search_entry: + search_entry.config(state='normal') + + self.is_active = False + + def activate(self): + """Activate the panel""" + self.is_active = True + + def deactivate(self): + """Deactivate the panel""" + if self.is_active: + self._cleanup() + self.is_active = False diff --git a/dashboard_gui.py b/dashboard_gui.py index 42252ac..a3c5276 100644 --- a/dashboard_gui.py +++ b/dashboard_gui.py @@ -12,6 +12,1308 @@ from typing import Dict, Optional, Callable from gui_core import GUICore from identify_panel import IdentifyPanel +from auto_match_panel import AutoMatchPanel +from search_stats import SearchStats +from database import DatabaseManager +from tag_management import TagManager + + +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: @@ -21,13 +1323,15 @@ class DashboardGUI: 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): + 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] = {} @@ -69,7 +1373,8 @@ class DashboardGUI: # 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=1) # Content area - expandable + 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) @@ -146,7 +1451,7 @@ class DashboardGUI: 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 + # Configure content frame to expand both horizontally and vertically self.content_frame.columnconfigure(0, weight=1) self.content_frame.rowconfigure(0, weight=1) @@ -173,14 +1478,26 @@ class DashboardGUI: messagebox.showerror("Error", f"Panel '{panel_name}' not found", parent=self.root) return - # Hide current panel + # 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 + # 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()}") @@ -223,17 +1540,18 @@ class DashboardGUI: # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - panel.rowconfigure(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, tk.S), padx=20, pady=20) + welcome_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N), padx=20, pady=20) welcome_frame.columnconfigure(0, weight=1) - welcome_frame.rowconfigure(0, weight=1) + # Remove weight=1 to prevent vertical centering + # welcome_frame.rowconfigure(0, weight=1) - # Center the content + # 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, tk.S)) + 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 @@ -265,7 +1583,7 @@ class DashboardGUI: # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - panel.rowconfigure(1, 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")) @@ -322,7 +1640,7 @@ class DashboardGUI: # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - panel.rowconfigure(1, 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")) @@ -359,6 +1677,8 @@ class DashboardGUI: # 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 @@ -373,9 +1693,10 @@ class DashboardGUI: 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, tk.S)) + placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) placeholder_frame.columnconfigure(0, weight=1) - placeholder_frame.rowconfigure(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" @@ -394,46 +1715,91 @@ class DashboardGUI: return panel def _create_auto_match_panel(self) -> ttk.Frame: - """Create the auto-match panel (placeholder)""" + """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)) - placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", padding="20") - placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - placeholder_frame.columnconfigure(0, weight=1) - placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = "Auto-Match 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, tk.S)) + # 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 (placeholder)""" + """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)) - placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", padding="20") - placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - placeholder_frame.columnconfigure(0, weight=1) - placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = "Search 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, tk.S)) + # 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 @@ -443,19 +1809,20 @@ class DashboardGUI: # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - panel.rowconfigure(1, weight=1) + # Remove weight=1 from row to prevent empty space expansion title_label = tk.Label(panel, text="āœļø Modify Identified", font=("Arial", 24, "bold")) title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", padding="20") - placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) placeholder_frame.columnconfigure(0, weight=1) - placeholder_frame.rowconfigure(0, weight=1) + # Remove weight=1 to prevent vertical centering + # placeholder_frame.rowconfigure(0, weight=1) placeholder_text = "Modify functionality will be integrated here." placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14)) - placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N)) return panel @@ -465,19 +1832,20 @@ class DashboardGUI: # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - panel.rowconfigure(1, 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, tk.S)) + placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) placeholder_frame.columnconfigure(0, weight=1) - placeholder_frame.rowconfigure(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, tk.S)) + placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N)) return panel diff --git a/face_processing.py b/face_processing.py index b2fe562..9487b85 100644 --- a/face_processing.py +++ b/face_processing.py @@ -308,7 +308,7 @@ class FaceProcessor: elif confidence_pct >= 50: return "šŸ”“ (Low - Questionable)" else: - return "⚫ (Very Low - Unlikely)" + return "⚫ (Very Low)" def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float: """Calculate adaptive tolerance based on face quality and match confidence""" @@ -637,7 +637,7 @@ class FaceProcessor: elif confidence_pct >= 50: return "šŸ”“ (Low - Questionable)" else: - return "⚫ (Very Low - Unlikely)" + return "⚫ (Very Low)" def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None): """Display similar faces in a panel - reuses auto-match display logic""" diff --git a/gui_core.py b/gui_core.py index dce7fc4..8089fee 100644 --- a/gui_core.py +++ b/gui_core.py @@ -328,7 +328,7 @@ class GUICore: elif confidence_pct >= 50: return "šŸ”“ (Low - Questionable)" else: - return "⚫ (Very Low - Unlikely)" + return "⚫ (Very Low)" def center_window(self, root, width: int = None, height: int = None): """Center a window on the screen""" diff --git a/identify_panel.py b/identify_panel.py index 19ccfd0..2a102e2 100644 --- a/identify_panel.py +++ b/identify_panel.py @@ -268,12 +268,16 @@ class IdentifyPanel: """Create the left panel content for face identification""" left_panel = self.components['left_panel'] - # Face image display - larger for full screen - self.components['face_canvas'] = tk.Canvas(left_panel, width=400, height=400, bg='white', relief='sunken', bd=2) + # Create a main content frame that can expand + main_content_frame = ttk.Frame(left_panel) + main_content_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # Face image display - flexible height for better layout + self.components['face_canvas'] = tk.Canvas(main_content_frame, width=400, height=400, bg='white', relief='sunken', bd=2) self.components['face_canvas'].pack(pady=(0, 15)) # Person name fields - name_frame = ttk.LabelFrame(left_panel, text="Person Information", padding="5") + name_frame = ttk.LabelFrame(main_content_frame, text="Person Information", padding="5") name_frame.pack(fill=tk.X, pady=(0, 10)) # First name @@ -324,7 +328,7 @@ class IdentifyPanel: self._setup_last_name_autocomplete(last_name_entry) # Control buttons - button_frame = ttk.Frame(left_panel) + button_frame = ttk.Frame(main_content_frame) button_frame.pack(fill=tk.X, pady=(10, 0)) self.components['identify_btn'] = ttk.Button(button_frame, text="āœ… Identify", command=self._identify_face, state='disabled') @@ -336,7 +340,7 @@ class IdentifyPanel: self.components['next_btn'] = ttk.Button(button_frame, text="āž”ļø Next", command=self._go_next) self.components['next_btn'].pack(side=tk.LEFT, padx=(0, 5)) - self.components['quit_btn'] = ttk.Button(button_frame, text="āŒ Quit", command=self._quit_identification) + self.components['quit_btn'] = ttk.Button(button_frame, text="āŒ Exit Identify Faces", command=self._quit_identification) self.components['quit_btn'].pack(side=tk.RIGHT) def _create_right_panel_content(self): @@ -374,6 +378,12 @@ class IdentifyPanel: self.components['similar_scrollable_frame'] = ttk.Frame(similar_canvas) similar_canvas.create_window((0, 0), window=self.components['similar_scrollable_frame'], anchor='nw') + # Configure scrollable frame to expand with canvas + def configure_scroll_region(event): + similar_canvas.configure(scrollregion=similar_canvas.bbox("all")) + + self.components['similar_scrollable_frame'].bind('', configure_scroll_region) + # Store canvas reference for scrolling self.components['similar_canvas'] = similar_canvas @@ -1042,7 +1052,7 @@ class IdentifyPanel: elif confidence_pct >= 50: return "(Low)" else: - return "(Very Low - Unlikely)" + return "(Very Low)" def _identify_face(self): """Identify the current face""" diff --git a/photo_tagger.py b/photo_tagger.py index b804063..4ffed55 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -50,7 +50,7 @@ class PhotoTagger: self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose) self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose) self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, self.tag_manager, verbose) - self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify) + self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify, search_stats=self.search_stats, tag_manager=self.tag_manager) # Legacy compatibility - expose some methods directly self._db_connection = None