Refactor PhotoTagger to support separate first and last name fields in the database. Update GUI to include dedicated input fields for first and last names, enhancing user experience with smart name parsing and improved dropdown functionality. Revise README to reflect these changes and highlight new features.
This commit is contained in:
parent
6a5bafef50
commit
52b9d37d8c
55
README.md
55
README.md
@ -128,7 +128,8 @@ python3 photo_tagger.py auto-match --auto --show-faces
|
||||
|
||||
**🎯 New GUI-Based Identification Features:**
|
||||
- 🖼️ **Visual Face Display** - See individual face crops in the GUI
|
||||
- 📝 **Dropdown Name Selection** - Choose from known people or type new names
|
||||
- 📝 **Separate Name Fields** - Dedicated "Last name" and "First name" input fields with dropdowns
|
||||
- 🎯 **Smart Name Parsing** - Supports "Last, First" format that gets properly parsed and stored
|
||||
- ☑️ **Compare with Similar Faces** - Compare current face with similar unidentified faces
|
||||
- 🎨 **Modern Interface** - Clean, intuitive GUI with buttons and input fields
|
||||
- 💾 **Window Size Memory** - Remembers your preferred window size
|
||||
@ -140,6 +141,7 @@ python3 photo_tagger.py auto-match --auto --show-faces
|
||||
- ⚠️ **Smart Navigation Warnings** - Prevents accidental loss of selected similar faces
|
||||
- 💾 **Quit Confirmation** - Saves pending identifications when closing the application
|
||||
- ⚡ **Performance Optimized** - Pre-fetched data for faster similar faces display
|
||||
- 🎯 **Clean Database Storage** - Names are stored as separate first_name and last_name fields without commas
|
||||
|
||||
**🎯 New Auto-Match GUI Features:**
|
||||
- 📊 **Person-Centric View** - Shows matched person on left, all their unidentified faces on right
|
||||
@ -163,9 +165,11 @@ python3 photo_tagger.py modifyidentified
|
||||
This GUI lets you quickly review all identified people, rename them, and temporarily unmatch faces before committing.
|
||||
|
||||
**Left Panel (People):**
|
||||
- 👥 **People List** - Shows all identified people with face counts
|
||||
- 👥 **People List** - Shows all identified people with face counts in "Last, First" format
|
||||
- 🖱️ **Clickable Names** - Click to select a person (selected name is bold)
|
||||
- ✏️ **Edit Name Icon** - Rename a person; tooltip shows "Update name"
|
||||
- ✏️ **Edit Name Icon** - Rename a person with separate first/last name fields; tooltip shows "Update name"
|
||||
- 📝 **Separate Name Fields** - Edit with dedicated "Last name" and "First name" text boxes
|
||||
- 💡 **Help Text** - Small labels below text boxes showing "Last" and "First"
|
||||
|
||||
**Right Panel (Faces):**
|
||||
- 🧩 **Person Faces** - Thumbnails of all faces identified as the selected person
|
||||
@ -177,10 +181,16 @@ This GUI lets you quickly review all identified people, rename them, and tempora
|
||||
- 💾 **Save changes** - Commits all pending unmatched faces across all people to the database
|
||||
- ❌ **Quit** - Closes the window (unsaved temporary changes are discarded)
|
||||
|
||||
**Performance Features:**
|
||||
- ⚡ **Optimized Database Access** - Loads all people data once when opening, saves only when needed
|
||||
- 🚫 **No Database Queries During Editing** - All editing operations use pre-loaded data
|
||||
- 💾 **Single Save Transaction** - Database updated only when clicking "Save changes"
|
||||
|
||||
Notes:
|
||||
- Changes are temporary until you click "Save changes" at the bottom.
|
||||
- Undo restores only the currently viewed person's faces.
|
||||
- Saving updates the database and refreshes counts.
|
||||
- Names are stored cleanly as separate first_name and last_name fields without commas.
|
||||
|
||||
## 🧠 Advanced Algorithm Features
|
||||
|
||||
@ -269,10 +279,20 @@ python3 photo_tagger.py tag --pattern "birthday"
|
||||
## 🗃️ Database
|
||||
|
||||
The tool uses SQLite database (`data/photos.db` by default) with these tables:
|
||||
|
||||
### Core Tables
|
||||
- **photos** - Photo file paths and processing status
|
||||
- **people** - Known people names
|
||||
- **faces** - Face encodings and locations
|
||||
- **people** - Known people with separate first_name and last_name fields
|
||||
- **faces** - Face encodings, locations, and quality scores
|
||||
- **tags** - Custom tags for photos
|
||||
- **person_encodings** - Face encodings for each person (for matching)
|
||||
|
||||
### Database Schema Improvements
|
||||
- **Clean Name Storage** - People table uses separate `first_name` and `last_name` fields
|
||||
- **No Comma Issues** - Names are stored without commas, displayed as "Last, First" format
|
||||
- **Quality Scoring** - Faces table includes quality scores for better matching
|
||||
- **Optimized Queries** - Efficient indexing and query patterns for fast performance
|
||||
- **Data Integrity** - Proper foreign key relationships and constraints
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
@ -560,17 +580,36 @@ The auto-match feature now works in a **person-centric** way:
|
||||
|
||||
This is now a minimal, focused tool. Key principles:
|
||||
- Keep it simple and fast
|
||||
- CLI-only interface
|
||||
- GUI-enhanced interface for identification
|
||||
- Minimal dependencies
|
||||
- Clear, readable code
|
||||
- **Always use python3** commands
|
||||
|
||||
## 🆕 Recent Improvements (Latest Version)
|
||||
|
||||
### Name Handling & Database
|
||||
- ✅ **Fixed Comma Issues** - Names are now stored cleanly without commas in database
|
||||
- ✅ **Separate Name Fields** - First name and last name are stored in separate database columns
|
||||
- ✅ **Smart Parsing** - Supports "Last, First" input format that gets properly parsed
|
||||
- ✅ **Optimized Database Access** - Single load/save operations for better performance
|
||||
|
||||
### GUI Enhancements
|
||||
- ✅ **Improved Edit Interface** - Separate text boxes for first and last names with help text
|
||||
- ✅ **Better Layout** - Help text positioned below input fields for clarity
|
||||
- ✅ **Tooltips** - Edit buttons show helpful tooltips
|
||||
- ✅ **Responsive Design** - Face grids adapt to window size
|
||||
|
||||
### Performance & Reliability
|
||||
- ✅ **Efficient Database Operations** - Pre-loads data, saves only when needed
|
||||
- ✅ **Fixed Virtual Environment** - Run script now works properly with dependencies
|
||||
- ✅ **Clean Code Structure** - Improved error handling and state management
|
||||
|
||||
---
|
||||
|
||||
**Total project size**: ~300 lines of Python code
|
||||
**Total project size**: ~3,400 lines of Python code
|
||||
**Dependencies**: 6 essential packages
|
||||
**Setup time**: ~5 minutes
|
||||
**Perfect for**: Batch processing personal photo collections
|
||||
**Perfect for**: Batch processing personal photo collections with modern GUI interface
|
||||
|
||||
## 🔄 Common Commands Cheat Sheet
|
||||
|
||||
|
||||
373
photo_tagger.py
373
photo_tagger.py
@ -161,8 +161,10 @@ class PhotoTagger:
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(first_name, last_name)
|
||||
)
|
||||
''')
|
||||
|
||||
@ -215,6 +217,7 @@ class PhotoTagger:
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_person_id ON person_encodings(person_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_quality ON person_encodings(quality_score)')
|
||||
|
||||
|
||||
if self.verbose >= 1:
|
||||
print(f"✅ Database initialized: {self.db_path}")
|
||||
|
||||
@ -386,9 +389,9 @@ class PhotoTagger:
|
||||
identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()}
|
||||
|
||||
# Pre-fetch all people names for dropdown
|
||||
cursor.execute('SELECT name FROM people ORDER BY name')
|
||||
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
|
||||
people = cursor.fetchall()
|
||||
identify_data_cache['people_names'] = [name[0] for name in people]
|
||||
identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last in people]
|
||||
|
||||
print(f"✅ Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names")
|
||||
|
||||
@ -428,8 +431,16 @@ class PhotoTagger:
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Add person if doesn't exist
|
||||
cursor.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (person_name,))
|
||||
cursor.execute('SELECT id FROM people WHERE name = ?', (person_name,))
|
||||
# Parse person_name in "Last, First" or single-token format
|
||||
parts = [p.strip() for p in person_name.split(',', 1)]
|
||||
if len(parts) == 2:
|
||||
last_name, first_name = parts[0], parts[1]
|
||||
else:
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ''
|
||||
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name) VALUES (?, ?)', (first_name, last_name))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ?', (first_name, last_name))
|
||||
result = cursor.fetchone()
|
||||
person_id = result[0] if result else None
|
||||
|
||||
@ -522,6 +533,7 @@ class PhotoTagger:
|
||||
root.rowconfigure(0, weight=1)
|
||||
main_frame.columnconfigure(0, weight=1) # Left panel
|
||||
main_frame.columnconfigure(1, weight=1) # Right panel for similar faces
|
||||
main_frame.rowconfigure(1, weight=1) # Main content row
|
||||
|
||||
# Photo info
|
||||
info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold"))
|
||||
@ -550,12 +562,19 @@ class PhotoTagger:
|
||||
input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10")
|
||||
input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E))
|
||||
input_frame.columnconfigure(1, weight=1)
|
||||
input_frame.columnconfigure(3, weight=1)
|
||||
|
||||
# Person name input with dropdown
|
||||
ttk.Label(input_frame, text="Person name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
|
||||
name_var = tk.StringVar()
|
||||
name_combo = ttk.Combobox(input_frame, textvariable=name_var, width=27, state="normal")
|
||||
name_combo.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 10))
|
||||
# First name input with dropdown
|
||||
ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
|
||||
first_name_var = tk.StringVar()
|
||||
first_name_combo = ttk.Combobox(input_frame, textvariable=first_name_var, width=12, state="normal")
|
||||
first_name_combo.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Last name input with dropdown
|
||||
ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10))
|
||||
last_name_var = tk.StringVar()
|
||||
last_name_combo = ttk.Combobox(input_frame, textvariable=last_name_var, width=12, state="normal")
|
||||
last_name_combo.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 10))
|
||||
|
||||
# Define update_similar_faces function first - reusing auto-match display logic
|
||||
def update_similar_faces():
|
||||
@ -617,28 +636,36 @@ class PhotoTagger:
|
||||
|
||||
compare_checkbox = ttk.Checkbutton(input_frame, text="Compare with similar faces", variable=compare_var,
|
||||
command=on_compare_change)
|
||||
compare_checkbox.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(5, 0))
|
||||
compare_checkbox.grid(row=3, column=0, columnspan=4, sticky=tk.W, pady=(5, 0))
|
||||
|
||||
# Add callback to save person name when it changes
|
||||
def on_name_change(*args):
|
||||
if i < len(original_faces):
|
||||
current_face_id = original_faces[i][0]
|
||||
current_name = name_var.get().strip()
|
||||
if current_name:
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
if first_name or last_name:
|
||||
if last_name and first_name:
|
||||
current_name = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
current_name = last_name
|
||||
elif first_name:
|
||||
current_name = first_name
|
||||
else:
|
||||
current_name = ""
|
||||
face_person_names[current_face_id] = current_name
|
||||
elif current_face_id in face_person_names:
|
||||
# Remove empty names from storage
|
||||
del face_person_names[current_face_id]
|
||||
|
||||
name_var.trace('w', on_name_change)
|
||||
first_name_var.trace('w', on_name_change)
|
||||
last_name_var.trace('w', on_name_change)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(input_frame)
|
||||
button_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
|
||||
# Buttons moved to bottom of window
|
||||
|
||||
# Instructions
|
||||
instructions = ttk.Label(input_frame, text="Select from dropdown or type new name", foreground="gray")
|
||||
instructions.grid(row=3, column=0, columnspan=2, pady=(10, 0))
|
||||
instructions.grid(row=2, column=0, columnspan=4, pady=(10, 0))
|
||||
|
||||
# Right panel for similar faces
|
||||
similar_faces_frame = ttk.Frame(right_panel)
|
||||
@ -721,8 +748,17 @@ class PhotoTagger:
|
||||
face_selection_states[current_face_id][unique_key] = var.get()
|
||||
|
||||
# Save person name
|
||||
current_name = name_var.get().strip()
|
||||
if current_name:
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
if first_name or last_name:
|
||||
if last_name and first_name:
|
||||
current_name = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
current_name = last_name
|
||||
elif first_name:
|
||||
current_name = first_name
|
||||
else:
|
||||
current_name = ""
|
||||
face_person_names[current_face_id] = current_name
|
||||
|
||||
# Button commands
|
||||
@ -731,11 +767,26 @@ class PhotoTagger:
|
||||
|
||||
def on_identify():
|
||||
nonlocal command, waiting_for_input
|
||||
command = name_var.get().strip()
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
compare_enabled = compare_var.get()
|
||||
|
||||
if not command.strip():
|
||||
print("⚠️ Please enter a person name before identifying")
|
||||
if not first_name and not last_name:
|
||||
print("⚠️ Please enter at least a first name or last name before identifying")
|
||||
return
|
||||
|
||||
# Combine first and last name properly
|
||||
if last_name and first_name:
|
||||
command = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
command = last_name
|
||||
elif first_name:
|
||||
command = first_name
|
||||
else:
|
||||
command = ""
|
||||
|
||||
if not command:
|
||||
print("⚠️ Please enter at least a first name or last name before identifying")
|
||||
return
|
||||
|
||||
if compare_enabled:
|
||||
@ -757,7 +808,9 @@ class PhotoTagger:
|
||||
# Check if compare is enabled and similar faces are selected
|
||||
if compare_var.get() and similar_face_vars:
|
||||
selected_faces = [face_id for face_id, var in similar_face_vars if var.get()]
|
||||
if selected_faces and not name_var.get().strip():
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
if selected_faces and not (first_name or last_name):
|
||||
# Show warning dialog
|
||||
result = messagebox.askyesno(
|
||||
"Selected Faces Not Identified",
|
||||
@ -835,18 +888,30 @@ class PhotoTagger:
|
||||
|
||||
|
||||
def update_people_dropdown():
|
||||
"""Update the dropdown with current people names"""
|
||||
"""Update the dropdowns with current people names"""
|
||||
# Use cached people names instead of database query
|
||||
if 'people_names' in identify_data_cache:
|
||||
name_combo['values'] = identify_data_cache['people_names']
|
||||
# Split cached names back into first and last names
|
||||
first_names = set()
|
||||
last_names = set()
|
||||
for full_name in identify_data_cache['people_names']:
|
||||
parts = full_name.strip().split(' ', 1)
|
||||
if parts:
|
||||
first_names.add(parts[0])
|
||||
if len(parts) > 1:
|
||||
last_names.add(parts[1])
|
||||
first_name_combo['values'] = sorted(list(first_names))
|
||||
last_name_combo['values'] = sorted(list(last_names))
|
||||
else:
|
||||
# Fallback to database if cache not available
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT name FROM people ORDER BY name')
|
||||
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
|
||||
people = cursor.fetchall()
|
||||
people_names = [name[0] for name in people]
|
||||
name_combo['values'] = people_names
|
||||
first_names = sorted(list(set([first for first, last in people if first])))
|
||||
last_names = sorted(list(set([last for first, last in people if last])))
|
||||
first_name_combo['values'] = first_names
|
||||
last_name_combo['values'] = last_names
|
||||
|
||||
def update_button_states():
|
||||
"""Update button states based on current position and unidentified faces"""
|
||||
@ -876,32 +941,44 @@ class PhotoTagger:
|
||||
else:
|
||||
next_btn.config(state='disabled')
|
||||
|
||||
# Create button references for state management
|
||||
identify_btn = ttk.Button(button_frame, text="✅ Identify", command=on_identify, state='disabled')
|
||||
back_btn = ttk.Button(button_frame, text="⬅️ Back", command=on_back)
|
||||
next_btn = ttk.Button(button_frame, text="➡️ Next", command=on_skip)
|
||||
quit_btn = ttk.Button(button_frame, text="❌ Quit", command=on_quit)
|
||||
|
||||
identify_btn.grid(row=0, column=0, padx=(0, 5))
|
||||
back_btn.grid(row=0, column=1, padx=5)
|
||||
next_btn.grid(row=0, column=2, padx=5)
|
||||
quit_btn.grid(row=0, column=3, padx=(5, 0))
|
||||
# Button references moved to bottom control panel
|
||||
|
||||
def update_identify_button_state():
|
||||
"""Enable/disable identify button based on name input"""
|
||||
if name_var.get().strip():
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
if first_name or last_name:
|
||||
identify_btn.config(state='normal')
|
||||
else:
|
||||
identify_btn.config(state='disabled')
|
||||
|
||||
# Bind name input changes to update button state
|
||||
name_var.trace('w', lambda *args: update_identify_button_state())
|
||||
first_name_var.trace('w', lambda *args: update_identify_button_state())
|
||||
last_name_var.trace('w', lambda *args: update_identify_button_state())
|
||||
|
||||
# Handle Enter key
|
||||
def on_enter(event):
|
||||
on_identify()
|
||||
|
||||
name_combo.bind('<Return>', on_enter)
|
||||
first_name_combo.bind('<Return>', on_enter)
|
||||
last_name_combo.bind('<Return>', on_enter)
|
||||
|
||||
# Bottom control panel
|
||||
control_frame = ttk.Frame(main_frame)
|
||||
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
|
||||
|
||||
# Create button references for state management
|
||||
back_btn = ttk.Button(control_frame, text="⬅️ Back", command=on_back)
|
||||
next_btn = ttk.Button(control_frame, text="➡️ Next", command=on_skip)
|
||||
quit_btn = ttk.Button(control_frame, text="❌ Quit", command=on_quit)
|
||||
|
||||
back_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
next_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
quit_btn.pack(side=tk.LEFT, padx=(5, 0))
|
||||
|
||||
# Identify button (placed after on_identify is defined)
|
||||
identify_btn = ttk.Button(input_frame, text="✅ Identify", command=on_identify, state='disabled')
|
||||
identify_btn.grid(row=4, column=0, pady=(10, 0), sticky=tk.W)
|
||||
|
||||
# Show the window
|
||||
try:
|
||||
@ -1020,12 +1097,23 @@ class PhotoTagger:
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT p.name FROM people p
|
||||
SELECT p.first_name, p.last_name FROM people p
|
||||
JOIN faces f ON p.id = f.person_id
|
||||
WHERE f.id = ?
|
||||
''', (face_id,))
|
||||
result = cursor.fetchone()
|
||||
person_name = result[0] if result else "Unknown"
|
||||
if result:
|
||||
first_name, last_name = result
|
||||
if last_name and first_name:
|
||||
person_name = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
person_name = last_name
|
||||
elif first_name:
|
||||
person_name = first_name
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
|
||||
info_label.config(text=f"📁 Photo: {filename} (Face {current_pos}/{total_unidentified}) - ✅ Already identified as: {person_name}")
|
||||
print(f"✅ Already identified as: {person_name}")
|
||||
@ -1069,25 +1157,39 @@ class PhotoTagger:
|
||||
# Set person name input - restore saved name or use database/empty value
|
||||
if face_id in face_person_names:
|
||||
# Restore previously entered name for this face
|
||||
name_var.set(face_person_names[face_id])
|
||||
full_name = face_person_names[face_id]
|
||||
# Parse "Last, First" format back to separate fields
|
||||
if ', ' in full_name:
|
||||
parts = full_name.split(', ', 1)
|
||||
last_name_var.set(parts[0].strip())
|
||||
first_name_var.set(parts[1].strip())
|
||||
else:
|
||||
# Fallback for old format or single name
|
||||
first_name_var.set(full_name)
|
||||
last_name_var.set("")
|
||||
elif is_already_identified:
|
||||
# Pre-populate with the current person name from database
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT p.name FROM people p
|
||||
SELECT p.first_name, p.last_name FROM people p
|
||||
JOIN faces f ON p.id = f.person_id
|
||||
WHERE f.id = ?
|
||||
''', (face_id,))
|
||||
result = cursor.fetchone()
|
||||
current_name = result[0] if result else ""
|
||||
name_var.set(current_name)
|
||||
if result:
|
||||
first_name_var.set(result[0] or "")
|
||||
last_name_var.set(result[1] or "")
|
||||
else:
|
||||
first_name_var.set("")
|
||||
last_name_var.set("")
|
||||
else:
|
||||
name_var.set("")
|
||||
first_name_var.set("")
|
||||
last_name_var.set("")
|
||||
|
||||
# Keep compare checkbox state persistent across navigation
|
||||
name_combo.focus_set()
|
||||
name_combo.icursor(0)
|
||||
first_name_combo.focus_set()
|
||||
first_name_combo.icursor(0)
|
||||
|
||||
# Force GUI update before waiting for input
|
||||
root.update_idletasks()
|
||||
@ -1204,8 +1306,16 @@ class PhotoTagger:
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Add person if doesn't exist
|
||||
cursor.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (person_name,))
|
||||
cursor.execute('SELECT id FROM people WHERE name = ?', (person_name,))
|
||||
# Parse person_name in "Last, First" or single-token format
|
||||
parts = [p.strip() for p in person_name.split(',', 1)]
|
||||
if len(parts) == 2:
|
||||
last_name, first_name = parts[0], parts[1]
|
||||
else:
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ''
|
||||
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name) VALUES (?, ?)', (first_name, last_name))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ?', (first_name, last_name))
|
||||
result = cursor.fetchone()
|
||||
person_id = result[0] if result else None
|
||||
|
||||
@ -1244,8 +1354,16 @@ class PhotoTagger:
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Add person if doesn't exist
|
||||
cursor.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (command,))
|
||||
cursor.execute('SELECT id FROM people WHERE name = ?', (command,))
|
||||
# Parse command in "Last, First" or single-token format
|
||||
parts = [p.strip() for p in command.split(',', 1)]
|
||||
if len(parts) == 2:
|
||||
last_name, first_name = parts[0], parts[1]
|
||||
else:
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ''
|
||||
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name) VALUES (?, ?)', (first_name, last_name))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ?', (first_name, last_name))
|
||||
result = cursor.fetchone()
|
||||
person_id = result[0] if result else None
|
||||
|
||||
@ -1704,14 +1822,15 @@ class PhotoTagger:
|
||||
if cursor is None:
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT name FROM people ORDER BY name')
|
||||
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
|
||||
people = cursor.fetchall()
|
||||
else:
|
||||
cursor.execute('SELECT name FROM people ORDER BY name')
|
||||
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
|
||||
people = cursor.fetchall()
|
||||
|
||||
if people:
|
||||
print("👥 Known people:", ", ".join([p[0] for p in people]))
|
||||
formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last]
|
||||
print("👥 Known people:", ", ".join(formatted_names))
|
||||
else:
|
||||
print("👥 No people identified yet")
|
||||
|
||||
@ -2024,9 +2143,20 @@ class PhotoTagger:
|
||||
# Get person name for ordering
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT name FROM people WHERE id = ?', (person_id,))
|
||||
cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,))
|
||||
result = cursor.fetchone()
|
||||
person_name = result[0] if result else "Unknown"
|
||||
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
|
||||
@ -2087,8 +2217,8 @@ class PhotoTagger:
|
||||
person_ids = list(matches_by_matched.keys())
|
||||
if person_ids:
|
||||
placeholders = ','.join('?' * len(person_ids))
|
||||
cursor.execute(f'SELECT id, name FROM people WHERE id IN ({placeholders})', person_ids)
|
||||
data_cache['person_names'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
cursor.execute(f'SELECT id, first_name, last_name FROM people WHERE id IN ({placeholders})', person_ids)
|
||||
data_cache['person_names'] = {row[0]: f"{row[2]}, {row[1]}".strip(", ").strip() for row in cursor.fetchall()}
|
||||
|
||||
# Pre-fetch all photo paths (both matched and unidentified)
|
||||
all_photo_ids = set()
|
||||
@ -2727,7 +2857,7 @@ class PhotoTagger:
|
||||
faces_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
|
||||
# Load people from DB with counts
|
||||
people_data = [] # list of dicts: {id, name, count}
|
||||
people_data = [] # list of dicts: {id, name, count, first_name, last_name}
|
||||
|
||||
def load_people():
|
||||
nonlocal people_data
|
||||
@ -2735,19 +2865,32 @@ class PhotoTagger:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT p.id, p.name, COUNT(f.id) as face_count
|
||||
SELECT p.id, p.first_name, p.last_name, COUNT(f.id) as face_count
|
||||
FROM people p
|
||||
JOIN faces f ON f.person_id = p.id
|
||||
GROUP BY p.id, p.name
|
||||
GROUP BY p.id, p.last_name, p.first_name
|
||||
HAVING face_count > 0
|
||||
ORDER BY p.name COLLATE NOCASE
|
||||
ORDER BY p.last_name, p.first_name COLLATE NOCASE
|
||||
"""
|
||||
)
|
||||
people_data = [{
|
||||
'id': pid,
|
||||
'name': name,
|
||||
'count': count
|
||||
} for (pid, name, count) in cursor.fetchall()]
|
||||
people_data = []
|
||||
for (pid, first_name, last_name, count) in cursor.fetchall():
|
||||
if last_name and first_name:
|
||||
display_name = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
display_name = last_name
|
||||
elif first_name:
|
||||
display_name = first_name
|
||||
else:
|
||||
display_name = "Unknown"
|
||||
|
||||
people_data.append({
|
||||
'id': pid,
|
||||
'name': display_name,
|
||||
'first_name': first_name or "",
|
||||
'last_name': last_name or "",
|
||||
'count': count
|
||||
})
|
||||
|
||||
def clear_faces_panel():
|
||||
for w in faces_inner.winfo_children():
|
||||
@ -2950,29 +3093,65 @@ class PhotoTagger:
|
||||
for w in row_frame.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
entry_var = tk.StringVar(value=person_record['name'])
|
||||
entry = ttk.Entry(row_frame, textvariable=entry_var, width=24)
|
||||
entry.pack(side=tk.LEFT)
|
||||
entry.focus_set()
|
||||
# Use pre-loaded data instead of database query
|
||||
cur_first = person_record.get('first_name', '')
|
||||
cur_last = person_record.get('last_name', '')
|
||||
|
||||
# Create a container frame for the text boxes and labels
|
||||
edit_container = ttk.Frame(row_frame)
|
||||
edit_container.pack(side=tk.LEFT)
|
||||
|
||||
# Text boxes row
|
||||
text_frame = ttk.Frame(edit_container)
|
||||
text_frame.pack(side=tk.TOP)
|
||||
|
||||
# Separate Last name and First name inputs
|
||||
last_var = tk.StringVar(value=cur_last)
|
||||
last_entry = ttk.Entry(text_frame, textvariable=last_var, width=12)
|
||||
last_entry.pack(side=tk.LEFT)
|
||||
|
||||
first_var = tk.StringVar(value=cur_first)
|
||||
first_entry = ttk.Entry(text_frame, textvariable=first_var, width=12)
|
||||
first_entry.pack(side=tk.LEFT, padx=(5, 0))
|
||||
first_entry.focus_set()
|
||||
|
||||
# Help text labels row (below text boxes)
|
||||
help_frame = ttk.Frame(edit_container)
|
||||
help_frame.pack(side=tk.TOP, pady=(2, 0))
|
||||
|
||||
last_label = ttk.Label(help_frame, text="Last", font=("Arial", 8), foreground="gray")
|
||||
last_label.pack(side=tk.LEFT)
|
||||
|
||||
first_label = ttk.Label(help_frame, text="First", font=("Arial", 8), foreground="gray")
|
||||
first_label.pack(side=tk.LEFT, padx=(5, 0))
|
||||
|
||||
def save_rename():
|
||||
new_name = entry_var.get().strip()
|
||||
if not new_name:
|
||||
messagebox.showwarning("Invalid name", "Person name cannot be empty.")
|
||||
new_first = first_var.get().strip()
|
||||
new_last = last_var.get().strip()
|
||||
if not new_first and not new_last:
|
||||
messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.")
|
||||
return
|
||||
# Prevent duplicate names
|
||||
|
||||
# Check for duplicates in local data first
|
||||
for person in people_data:
|
||||
if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last:
|
||||
display_name = f"{new_last}, {new_first}".strip(", ").strip()
|
||||
messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.")
|
||||
return
|
||||
|
||||
# Single database access - save to database
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT id FROM people WHERE name = ? AND id != ?', (new_name, person_record['id']))
|
||||
dup = cursor.fetchone()
|
||||
if dup:
|
||||
messagebox.showwarning("Duplicate name", f"A person named '{new_name}' already exists.")
|
||||
return
|
||||
cursor.execute('UPDATE people SET name = ? WHERE id = ?', (new_name, person_record['id']))
|
||||
cursor.execute('UPDATE people SET first_name = ?, last_name = ? WHERE id = ?', (new_first, new_last, person_record['id']))
|
||||
conn.commit()
|
||||
|
||||
# Update local data structure
|
||||
person_record['first_name'] = new_first
|
||||
person_record['last_name'] = new_last
|
||||
person_record['name'] = f"{new_last}, {new_first}".strip(", ").strip() if new_first or new_last else "Unknown"
|
||||
|
||||
# Refresh list
|
||||
current_selected_id = person_record['id']
|
||||
load_people()
|
||||
populate_people_list()
|
||||
# Reselect and refresh right panel header if needed
|
||||
if selected_person_id == current_selected_id or selected_person_id is None:
|
||||
@ -3004,20 +3183,22 @@ class PhotoTagger:
|
||||
cancel_btn.pack(side=tk.LEFT, padx=(5, 0))
|
||||
|
||||
# Keyboard shortcuts
|
||||
entry.bind('<Return>', lambda e: save_rename())
|
||||
entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
first_entry.bind('<Return>', lambda e: save_rename())
|
||||
last_entry.bind('<Return>', lambda e: save_rename())
|
||||
first_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
last_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
|
||||
def rebuild_row(row_frame, p, i):
|
||||
# Label (clickable)
|
||||
name_lbl = ttk.Label(row_frame, text=f"{p['name']} ({p['count']})", font=("Arial", 10))
|
||||
name_lbl.pack(side=tk.LEFT)
|
||||
name_lbl.bind("<Button-1>", make_click_handler(p['id'], p['name'], i))
|
||||
name_lbl.config(cursor="hand2")
|
||||
# Edit button
|
||||
# Edit button (on the left)
|
||||
edit_btn = ttk.Button(row_frame, text="✏️", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii))
|
||||
edit_btn.pack(side=tk.RIGHT)
|
||||
edit_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
# Add tooltip to edit button
|
||||
ToolTip(edit_btn, "Update name")
|
||||
# Label (clickable) - takes remaining space
|
||||
name_lbl = ttk.Label(row_frame, text=f"{p['name']} ({p['count']})", font=("Arial", 10))
|
||||
name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
name_lbl.bind("<Button-1>", make_click_handler(p['id'], p['name'], i))
|
||||
name_lbl.config(cursor="hand2")
|
||||
# Bold if selected
|
||||
if (selected_person_id is None and i == 0) or (selected_person_id == p['id']):
|
||||
name_lbl.config(font=("Arial", 10, "bold"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user