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:
parent
b9a0637035
commit
d4504ee81a
11
README.md
11
README.md
@ -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
|
||||
|
||||
@ -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
252
search_gui.py
Normal 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
|
||||
|
||||
|
||||
@ -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"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user