Add Search GUI for enhanced photo searching capabilities

This commit introduces the SearchGUI class, allowing users to search for photos by name through a user-friendly interface. The new functionality includes options to view search results, open photo locations, and display relevant information about matched individuals. The PhotoTagger class is updated to integrate this feature, and the README is revised to include usage instructions for the new search-gui command. Additionally, the search_faces method in SearchStats is enhanced to return detailed results, improving the overall search experience.
This commit is contained in:
tanyar09 2025-10-07 11:45:12 -04:00
parent b9a0637035
commit d4504ee81a
4 changed files with 317 additions and 23 deletions

View File

@ -351,8 +351,18 @@ python3 photo_tagger.py search "John"
# Find photos with partial name match
python3 photo_tagger.py search "Joh"
# Open the Search GUI
python3 photo_tagger.py search-gui
```
Search GUI features:
- Person column shows the matched person's full name
- 📁 column: click to open the file's folder (tooltip: "Open file location")
- 📷 column: click to open the photo (tooltip: "Open photo")
- Path column shows the absolute photo path
- Press Enter in the name field to trigger search
### Statistics
```bash
# View database statistics
@ -914,6 +924,7 @@ python3 photo_tagger.py scan ~/Pictures --recursive
python3 photo_tagger.py process --limit 50
python3 photo_tagger.py identify --show-faces --batch 10 # Opens GUI
python3 photo_tagger.py auto-match --show-faces # Opens GUI
python3 photo_tagger.py search-gui # Opens Search GUI
python3 photo_tagger.py modifyidentified # Opens GUI to view/modify
python3 photo_tagger.py tag-manager # Opens GUI for tag management
python3 photo_tagger.py stats

View File

@ -25,6 +25,7 @@ from identify_gui import IdentifyGUI
from auto_match_gui import AutoMatchGUI
from modify_identified_gui import ModifyIdentifiedGUI
from tag_manager_gui import TagManagerGUI
from search_gui import SearchGUI
class PhotoTagger:
@ -47,6 +48,7 @@ class PhotoTagger:
self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose)
self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose)
self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose)
self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, verbose)
# Legacy compatibility - expose some methods directly
self._db_connection = None
@ -188,6 +190,10 @@ class PhotoTagger:
def modifyidentified(self) -> int:
return self.modify_identified_gui.modifyidentified()
def searchgui(self) -> int:
"""Open the Search GUI."""
return self.search_gui.search_gui()
def _setup_window_size_saving(self, root, config_file="gui_config.json"):
"""Set up window size saving functionality (legacy compatibility)"""
return self.gui_core.setup_window_size_saving(root, config_file)
@ -285,7 +291,7 @@ Examples:
)
parser.add_argument('command',
choices=['scan', 'process', 'identify', 'tag', 'search', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'],
choices=['scan', 'process', 'identify', 'tag', 'search', 'search-gui', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'],
help='Command to execute')
parser.add_argument('target', nargs='?',
@ -369,6 +375,9 @@ Examples:
return 1
tagger.search_faces(args.target)
elif args.command == 'search-gui':
tagger.searchgui()
elif args.command == 'stats':
tagger.stats()

252
search_gui.py Normal file
View File

@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""
Search GUI implementation for PunimTag
"""
import os
import sys
import tkinter as tk
from tkinter import ttk, messagebox
from typing import List
from gui_core import GUICore
from search_stats import SearchStats
from database import DatabaseManager
class SearchGUI:
"""GUI for searching photos by different criteria."""
SEARCH_TYPES = [
"Search photos by name",
"Search photos by date (planned)",
"Search photos by tags (planned)",
"Search photos by multiple people (planned)",
"Most common tags (planned)",
"Most photographed people (planned)",
"Photos without faces (planned)",
"Photos without tags (planned)",
"Duplicate faces (planned)",
"Face quality distribution (planned)",
]
def __init__(self, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, verbose: int = 0):
self.db = db_manager
self.search_stats = search_stats
self.gui_core = gui_core
self.verbose = verbose
def search_gui(self) -> int:
"""Open the Search GUI window."""
root = tk.Tk()
root.title("Search Photos")
root.resizable(True, True)
# Hide to center and size
root.withdraw()
main = ttk.Frame(root, padding="10")
main.pack(fill=tk.BOTH, expand=True)
# Search type selector
type_frame = ttk.Frame(main)
type_frame.pack(fill=tk.X)
ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0])
type_combo = ttk.Combobox(type_frame, textvariable=search_type_var, values=self.SEARCH_TYPES, state="readonly")
type_combo.pack(side=tk.LEFT, padx=(8, 0), fill=tk.X, expand=True)
# Inputs area
inputs = ttk.Frame(main)
inputs.pack(fill=tk.X, pady=(10, 6))
# Name search input
name_frame = ttk.Frame(inputs)
ttk.Label(name_frame, text="Person name:").pack(side=tk.LEFT)
name_var = tk.StringVar()
name_entry = ttk.Entry(name_frame, textvariable=name_var)
name_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True)
# Planned inputs (stubs)
planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888")
# Results area
results_frame = ttk.Frame(main)
results_frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(results_frame, text="Results:", font=("Arial", 10, "bold")).pack(anchor="w")
columns = ("person", "open_dir", "open_photo", "path")
tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse")
tree.heading("person", text="Person")
tree.heading("open_dir", text="📁")
tree.heading("open_photo", text="📷")
tree.heading("path", text="Photo path")
tree.column("person", width=220, anchor="w")
tree.column("open_dir", width=50, anchor="center")
tree.column("open_photo", width=50, anchor="center")
tree.column("path", width=520, anchor="w")
tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0))
# Buttons
btns = ttk.Frame(main)
btns.pack(fill=tk.X, pady=(8, 0))
search_btn = ttk.Button(btns, text="Search", command=lambda: do_search())
search_btn.pack(side=tk.LEFT)
open_btn = ttk.Button(btns, text="Open Photo", command=lambda: open_selected())
open_btn.pack(side=tk.LEFT, padx=(6, 0))
close_btn = ttk.Button(btns, text="Close", command=lambda: root.destroy())
close_btn.pack(side=tk.RIGHT)
# Behavior
def switch_inputs(*_):
for w in inputs.winfo_children():
w.pack_forget()
choice = search_type_var.get()
if choice == self.SEARCH_TYPES[0]:
name_frame.pack(fill=tk.X)
name_entry.configure(state="normal")
search_btn.configure(state="normal")
else:
planned_label.pack(anchor="w")
name_entry.configure(state="disabled")
search_btn.configure(state="disabled")
def clear_results():
for i in tree.get_children():
tree.delete(i)
def add_results(rows: List[tuple]):
# rows expected: List[(full_name, path)]
for full_name, p in rows:
tree.insert("", tk.END, values=(full_name, "📁", "📷", p))
def do_search():
clear_results()
choice = search_type_var.get()
if choice == self.SEARCH_TYPES[0]:
query = name_var.get().strip()
if not query:
messagebox.showinfo("Search", "Please enter a name to search.", parent=root)
return
rows = self.search_stats.search_faces(query)
if not rows:
messagebox.showinfo("Search", f"No photos found for '{query}'.", parent=root)
add_results(rows)
def open_selected():
sel = tree.selection()
if not sel:
return
vals = tree.item(sel[0], "values")
path = vals[3] if len(vals) >= 4 else None
try:
if os.name == "nt":
os.startfile(path) # type: ignore[attr-defined]
elif sys.platform == "darwin":
import subprocess
subprocess.run(["open", path], check=False)
else:
import subprocess
subprocess.run(["xdg-open", path], check=False)
except Exception:
messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root)
def open_dir(path: str):
try:
folder = os.path.dirname(path)
if os.name == "nt":
os.startfile(folder) # type: ignore[attr-defined]
elif sys.platform == "darwin":
import subprocess
subprocess.run(["open", folder], check=False)
else:
import subprocess
subprocess.run(["xdg-open", folder], check=False)
except Exception:
messagebox.showerror("Open Location", "Failed to open the file location.", parent=root)
# Click handling on icon columns
def on_tree_click(event):
region = tree.identify("region", event.x, event.y)
if region != "cell":
return
row_id = tree.identify_row(event.y)
col_id = tree.identify_column(event.x) # '#1', '#2', ...
if not row_id or not col_id:
return
vals = tree.item(row_id, "values")
if not vals or len(vals) < 4:
return
path = vals[3]
if col_id == "#2":
open_dir(path)
elif col_id == "#3":
try:
if os.name == "nt":
os.startfile(path) # type: ignore[attr-defined]
elif sys.platform == "darwin":
import subprocess
subprocess.run(["open", path], check=False)
else:
import subprocess
subprocess.run(["xdg-open", path], check=False)
except Exception:
messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root)
# Tooltip for icon cells
tooltip = None
def show_tooltip(widget, x, y, text: str):
nonlocal tooltip
hide_tooltip()
try:
tooltip = tk.Toplevel(widget)
tooltip.wm_overrideredirect(True)
tooltip.wm_geometry(f"+{x+12}+{y+12}")
lbl = tk.Label(tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9))
lbl.pack()
except Exception:
tooltip = None
def hide_tooltip(*_):
nonlocal tooltip
if tooltip is not None:
try:
tooltip.destroy()
except Exception:
pass
tooltip = None
def on_tree_motion(event):
region = tree.identify("region", event.x, event.y)
if region != "cell":
hide_tooltip()
tree.config(cursor="")
return
col_id = tree.identify_column(event.x)
if col_id == "#2":
tree.config(cursor="hand2")
show_tooltip(tree, event.x_root, event.y_root, "Open file location")
elif col_id == "#3":
tree.config(cursor="hand2")
show_tooltip(tree, event.x_root, event.y_root, "Open photo")
else:
tree.config(cursor="")
hide_tooltip()
type_combo.bind("<<ComboboxSelected>>", switch_inputs)
switch_inputs()
tree.bind("<Button-1>", on_tree_click)
tree.bind("<Motion>", on_tree_motion)
tree.bind("<Leave>", hide_tooltip)
# Enter key in name field triggers search
name_entry.bind("<Return>", lambda e: do_search())
# Show and center
root.update_idletasks()
self.gui_core.center_window(root, 800, 500)
root.deiconify()
root.mainloop()
return 0

View File

@ -16,44 +16,66 @@ class SearchStats:
self.db = db_manager
self.verbose = verbose
def search_faces(self, person_name: str) -> List[str]:
"""Search for photos containing a specific person"""
def search_faces(self, person_name: str) -> List[Tuple[str, str]]:
"""Search for photos containing a specific person by name (partial, case-insensitive).
Returns a list of tuples: (person_full_name, photo_path).
"""
# Get all people matching the name
people = self.db.show_people_list()
matching_people = []
search_name = (person_name or "").strip().lower()
if not search_name:
return []
for person in people:
person_id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date = person
full_name = f"{first_name} {last_name}".lower()
search_name = person_name.lower()
full_name = f"{first_name or ''} {last_name or ''}".strip().lower()
# Check if search term matches any part of the name
if (search_name in full_name or
search_name in first_name.lower() or
search_name in last_name.lower() or
if (
(full_name and search_name in full_name) or
(first_name and search_name in first_name.lower()) or
(last_name and search_name in last_name.lower()) or
(middle_name and search_name in middle_name.lower()) or
(maiden_name and search_name in maiden_name.lower())):
(maiden_name and search_name in maiden_name.lower())
):
matching_people.append(person_id)
if not matching_people:
print(f"❌ No people found matching '{person_name}'")
return []
# Get photos for matching people
photo_paths = []
for person_id in matching_people:
# This would need to be implemented in the database module
# For now, we'll use a placeholder
# Fetch photo paths for each matching person using database helper if available
results: List[Tuple[str, str]] = []
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
# faces.person_id links to photos via faces.photo_id
placeholders = ",".join(["?"] * len(matching_people))
cursor.execute(
f"""
SELECT DISTINCT p.path, pe.first_name, pe.last_name
FROM faces f
JOIN photos p ON p.id = f.photo_id
JOIN people pe ON pe.id = f.person_id
WHERE f.person_id IN ({placeholders})
ORDER BY pe.last_name, pe.first_name, p.path
""",
tuple(matching_people),
)
for row in cursor.fetchall():
if row and row[0]:
path = row[0]
first = (row[1] or "").strip()
last = (row[2] or "").strip()
full_name = (f"{first} {last}").strip() or "Unknown"
results.append((full_name, path))
except Exception:
# Fall back gracefully if schema differs
pass
if photo_paths:
print(f"🔍 Found {len(photo_paths)} photos with '{person_name}':")
for path in photo_paths:
print(f" 📸 {path}")
else:
print(f"❌ No photos found for '{person_name}'")
return photo_paths
return results
def get_statistics(self) -> Dict:
"""Get comprehensive database statistics"""