Enhance PhotoTagger by adding support for middle and maiden names in the database and GUI. Update data handling to accommodate new input fields, ensuring comprehensive data capture during identification. Revise database queries and improve user interface for better data entry experience.
This commit is contained in:
parent
2fcd200cd0
commit
ee3638b929
265
photo_tagger.py
265
photo_tagger.py
@ -163,9 +163,11 @@ class PhotoTagger:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
middle_name TEXT,
|
||||
maiden_name TEXT,
|
||||
date_of_birth DATE,
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(first_name, last_name, date_of_birth)
|
||||
UNIQUE(first_name, last_name, middle_name, maiden_name, date_of_birth)
|
||||
)
|
||||
''')
|
||||
|
||||
@ -427,13 +429,11 @@ class PhotoTagger:
|
||||
saved_count = 0
|
||||
|
||||
for face_id, person_data in face_person_names.items():
|
||||
# Handle both old format (string) and new format (dict)
|
||||
if isinstance(person_data, dict):
|
||||
person_name = person_data.get('name', '').strip()
|
||||
date_of_birth = person_data.get('date_of_birth', '').strip()
|
||||
else:
|
||||
person_name = person_data.strip() if person_data else ''
|
||||
date_of_birth = ''
|
||||
# Handle person data dict format
|
||||
person_name = person_data.get('name', '').strip()
|
||||
date_of_birth = person_data.get('date_of_birth', '').strip()
|
||||
middle_name = person_data.get('middle_name', '').strip()
|
||||
maiden_name = person_data.get('maiden_name', '').strip()
|
||||
|
||||
if person_name:
|
||||
try:
|
||||
@ -448,8 +448,8 @@ class PhotoTagger:
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ''
|
||||
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, date_of_birth) VALUES (?, ?, ?)', (first_name, last_name, date_of_birth))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND date_of_birth = ?', (first_name, last_name, date_of_birth))
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth))
|
||||
result = cursor.fetchone()
|
||||
person_id = result[0] if result else None
|
||||
|
||||
@ -490,23 +490,19 @@ class PhotoTagger:
|
||||
pending_identifications = {}
|
||||
for k, v in face_person_names.items():
|
||||
if k not in face_status or face_status[k] != 'identified':
|
||||
# Handle both old format (string) and new format (dict)
|
||||
if isinstance(v, dict):
|
||||
person_name = v.get('name', '').strip()
|
||||
date_of_birth = v.get('date_of_birth', '').strip()
|
||||
# Check if name has both first and last name
|
||||
if person_name and date_of_birth:
|
||||
# Parse name to check for both first and last name
|
||||
if ',' in person_name:
|
||||
last_name, first_name = person_name.split(',', 1)
|
||||
if last_name.strip() and first_name.strip():
|
||||
pending_identifications[k] = v
|
||||
else:
|
||||
# Single name format - not complete
|
||||
pass
|
||||
else:
|
||||
# Old string format - not complete (missing date of birth)
|
||||
pass
|
||||
# Handle person data dict format
|
||||
person_name = v.get('name', '').strip()
|
||||
date_of_birth = v.get('date_of_birth', '').strip()
|
||||
# Check if name has both first and last name
|
||||
if person_name and date_of_birth:
|
||||
# Parse name to check for both first and last name
|
||||
if ',' in person_name:
|
||||
last_name, first_name = person_name.split(',', 1)
|
||||
if last_name.strip() and first_name.strip():
|
||||
pending_identifications[k] = v
|
||||
else:
|
||||
# Single name format - not complete
|
||||
pass
|
||||
|
||||
if pending_identifications:
|
||||
# Ask user if they want to save pending identifications
|
||||
@ -589,26 +585,39 @@ class PhotoTagger:
|
||||
input_frame.columnconfigure(1, weight=1)
|
||||
input_frame.columnconfigure(3, weight=1)
|
||||
input_frame.columnconfigure(5, weight=1)
|
||||
input_frame.columnconfigure(7, weight=1)
|
||||
|
||||
# First name input with dropdown
|
||||
# First name input
|
||||
ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
|
||||
first_name_var = tk.StringVar()
|
||||
first_name_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))
|
||||
first_name_entry = ttk.Entry(input_frame, textvariable=first_name_var, width=12)
|
||||
first_name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Last name input with dropdown
|
||||
# Last name input
|
||||
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, 5))
|
||||
last_name_entry = ttk.Entry(input_frame, textvariable=last_name_var, width=12)
|
||||
last_name_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Middle name input
|
||||
ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10))
|
||||
middle_name_var = tk.StringVar()
|
||||
middle_name_entry = ttk.Entry(input_frame, textvariable=middle_name_var, width=12)
|
||||
middle_name_entry.grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Date of birth input with calendar chooser
|
||||
ttk.Label(input_frame, text="Date of birth:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10))
|
||||
ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10))
|
||||
date_of_birth_var = tk.StringVar()
|
||||
|
||||
# Create a frame for the date picker
|
||||
date_frame = ttk.Frame(input_frame)
|
||||
date_frame.grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 10))
|
||||
date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10))
|
||||
|
||||
# Maiden name input
|
||||
ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10))
|
||||
maiden_name_var = tk.StringVar()
|
||||
maiden_name_entry = ttk.Entry(input_frame, textvariable=maiden_name_var, width=12)
|
||||
maiden_name_entry.grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Date display entry (read-only)
|
||||
date_of_birth_entry = ttk.Entry(date_frame, textvariable=date_of_birth_var, width=12, state='readonly')
|
||||
@ -1025,6 +1034,8 @@ class PhotoTagger:
|
||||
# Save person name and date of birth
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
middle_name = middle_name_var.get().strip()
|
||||
maiden_name = maiden_name_var.get().strip()
|
||||
date_of_birth = date_of_birth_var.get().strip()
|
||||
|
||||
if first_name or last_name:
|
||||
@ -1036,9 +1047,11 @@ class PhotoTagger:
|
||||
current_name = first_name
|
||||
else:
|
||||
current_name = ""
|
||||
# Store both name and date of birth
|
||||
# Store all fields
|
||||
face_person_names[current_face_id] = {
|
||||
'name': current_name,
|
||||
'middle_name': middle_name,
|
||||
'maiden_name': maiden_name,
|
||||
'date_of_birth': date_of_birth
|
||||
}
|
||||
|
||||
@ -1050,6 +1063,8 @@ class PhotoTagger:
|
||||
nonlocal command, waiting_for_input
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
middle_name = middle_name_var.get().strip()
|
||||
maiden_name = maiden_name_var.get().strip()
|
||||
date_of_birth = date_of_birth_var.get().strip()
|
||||
compare_enabled = compare_var.get()
|
||||
|
||||
@ -1083,6 +1098,13 @@ class PhotoTagger:
|
||||
else:
|
||||
command = ""
|
||||
|
||||
# Store the additional fields for database insertion
|
||||
# We'll pass them through the command structure
|
||||
if middle_name or maiden_name:
|
||||
command += f"|{middle_name}|{maiden_name}|{date_of_birth}"
|
||||
else:
|
||||
command += f"|||{date_of_birth}"
|
||||
|
||||
if not command:
|
||||
print("⚠️ Please enter at least a first name or last name before identifying")
|
||||
return
|
||||
@ -1145,23 +1167,19 @@ class PhotoTagger:
|
||||
pending_identifications = {}
|
||||
for k, v in face_person_names.items():
|
||||
if k not in face_status or face_status[k] != 'identified':
|
||||
# Handle both old format (string) and new format (dict)
|
||||
if isinstance(v, dict):
|
||||
person_name = v.get('name', '').strip()
|
||||
date_of_birth = v.get('date_of_birth', '').strip()
|
||||
# Check if name has both first and last name
|
||||
if person_name and date_of_birth:
|
||||
# Parse name to check for both first and last name
|
||||
if ',' in person_name:
|
||||
last_name, first_name = person_name.split(',', 1)
|
||||
if last_name.strip() and first_name.strip():
|
||||
pending_identifications[k] = v
|
||||
else:
|
||||
# Single name format - not complete
|
||||
pass
|
||||
else:
|
||||
# Old string format - not complete (missing date of birth)
|
||||
pass
|
||||
# Handle person data dict format
|
||||
person_name = v.get('name', '').strip()
|
||||
date_of_birth = v.get('date_of_birth', '').strip()
|
||||
# Check if name has both first and last name
|
||||
if person_name and date_of_birth:
|
||||
# Parse name to check for both first and last name
|
||||
if ',' in person_name:
|
||||
last_name, first_name = person_name.split(',', 1)
|
||||
if last_name.strip() and first_name.strip():
|
||||
pending_identifications[k] = v
|
||||
else:
|
||||
# Single name format - not complete
|
||||
pass
|
||||
|
||||
if pending_identifications:
|
||||
# Ask user if they want to save pending identifications
|
||||
@ -1200,31 +1218,6 @@ class PhotoTagger:
|
||||
root.quit()
|
||||
|
||||
|
||||
def update_people_dropdown():
|
||||
"""Update the dropdowns with current people names"""
|
||||
# Use cached people names instead of database query
|
||||
if 'people_names' in identify_data_cache:
|
||||
# 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 first_name, last_name FROM people ORDER BY first_name, last_name')
|
||||
people = cursor.fetchall()
|
||||
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"""
|
||||
@ -1275,8 +1268,8 @@ class PhotoTagger:
|
||||
def on_enter(event):
|
||||
on_identify()
|
||||
|
||||
first_name_combo.bind('<Return>', on_enter)
|
||||
last_name_combo.bind('<Return>', on_enter)
|
||||
first_name_entry.bind('<Return>', on_enter)
|
||||
last_name_entry.bind('<Return>', on_enter)
|
||||
|
||||
# Bottom control panel
|
||||
control_frame = ttk.Frame(main_frame)
|
||||
@ -1305,8 +1298,6 @@ class PhotoTagger:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
# Initialize the people dropdown
|
||||
update_people_dropdown()
|
||||
|
||||
|
||||
# Process each face with back navigation support
|
||||
@ -1403,8 +1394,9 @@ class PhotoTagger:
|
||||
# Update button states
|
||||
update_button_states()
|
||||
|
||||
# Update similar faces panel
|
||||
update_similar_faces()
|
||||
# Update similar faces panel if compare is enabled
|
||||
if compare_var.get():
|
||||
update_similar_faces()
|
||||
|
||||
# Update photo info
|
||||
if is_already_identified:
|
||||
@ -1479,7 +1471,7 @@ class PhotoTagger:
|
||||
last_name_var.set(parts[0].strip())
|
||||
first_name_var.set(parts[1].strip())
|
||||
else:
|
||||
# Fallback for old format or single name
|
||||
# Single name format
|
||||
first_name_var.set(full_name)
|
||||
last_name_var.set("")
|
||||
elif is_already_identified:
|
||||
@ -1487,7 +1479,7 @@ class PhotoTagger:
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT p.first_name, p.last_name FROM people p
|
||||
SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth FROM people p
|
||||
JOIN faces f ON p.id = f.person_id
|
||||
WHERE f.id = ?
|
||||
''', (face_id,))
|
||||
@ -1495,17 +1487,25 @@ class PhotoTagger:
|
||||
if result:
|
||||
first_name_var.set(result[0] or "")
|
||||
last_name_var.set(result[1] or "")
|
||||
middle_name_var.set(result[2] or "")
|
||||
maiden_name_var.set(result[3] or "")
|
||||
date_of_birth_var.set(result[4] or "")
|
||||
else:
|
||||
first_name_var.set("")
|
||||
last_name_var.set("")
|
||||
middle_name_var.set("")
|
||||
maiden_name_var.set("")
|
||||
date_of_birth_var.set("")
|
||||
else:
|
||||
first_name_var.set("")
|
||||
last_name_var.set("")
|
||||
middle_name_var.set("")
|
||||
maiden_name_var.set("")
|
||||
date_of_birth_var.set("")
|
||||
|
||||
# Keep compare checkbox state persistent across navigation
|
||||
first_name_combo.focus_set()
|
||||
first_name_combo.icursor(0)
|
||||
first_name_entry.focus_set()
|
||||
first_name_entry.icursor(0)
|
||||
|
||||
# Force GUI update before waiting for input
|
||||
root.update_idletasks()
|
||||
@ -1581,9 +1581,14 @@ class PhotoTagger:
|
||||
|
||||
# Clear date of birth field when moving to next face
|
||||
date_of_birth_var.set("")
|
||||
# Clear middle name and maiden name fields when moving to next face
|
||||
middle_name_var.set("")
|
||||
maiden_name_var.set("")
|
||||
|
||||
update_button_states()
|
||||
update_similar_faces()
|
||||
# Only update similar faces if compare is enabled
|
||||
if compare_var.get():
|
||||
update_similar_faces()
|
||||
continue
|
||||
|
||||
elif command.lower() == 'back':
|
||||
@ -1611,6 +1616,8 @@ class PhotoTagger:
|
||||
if isinstance(person_data, dict):
|
||||
person_name = person_data.get('name', '').strip()
|
||||
date_of_birth = person_data.get('date_of_birth', '').strip()
|
||||
middle_name = person_data.get('middle_name', '').strip()
|
||||
maiden_name = person_data.get('maiden_name', '').strip()
|
||||
|
||||
# Parse "Last, First" format back to separate fields
|
||||
if ', ' in person_name:
|
||||
@ -1618,25 +1625,33 @@ class PhotoTagger:
|
||||
last_name_var.set(parts[0].strip())
|
||||
first_name_var.set(parts[1].strip())
|
||||
else:
|
||||
# Fallback for single name
|
||||
# Single name format
|
||||
first_name_var.set(person_name)
|
||||
last_name_var.set("")
|
||||
|
||||
# Repopulate date of birth
|
||||
# Repopulate all fields
|
||||
date_of_birth_var.set(date_of_birth)
|
||||
middle_name_var.set(middle_name)
|
||||
maiden_name_var.set(maiden_name)
|
||||
else:
|
||||
# Old format - clear fields
|
||||
# Clear fields
|
||||
first_name_var.set("")
|
||||
last_name_var.set("")
|
||||
middle_name_var.set("")
|
||||
maiden_name_var.set("")
|
||||
date_of_birth_var.set("")
|
||||
else:
|
||||
# No saved data - clear fields
|
||||
first_name_var.set("")
|
||||
last_name_var.set("")
|
||||
middle_name_var.set("")
|
||||
maiden_name_var.set("")
|
||||
date_of_birth_var.set("")
|
||||
|
||||
update_button_states()
|
||||
update_similar_faces()
|
||||
# Only update similar faces if compare is enabled
|
||||
if compare_var.get():
|
||||
update_similar_faces()
|
||||
continue
|
||||
|
||||
elif command.lower() == 'list':
|
||||
@ -1657,15 +1672,18 @@ class PhotoTagger:
|
||||
cursor = conn.cursor()
|
||||
# Add person if doesn't exist
|
||||
# Parse person_name in "Last, First" or single-token format
|
||||
parts = [p.strip() for p in person_name.split(',', 1)]
|
||||
# Parse person_name with additional fields (middle_name|maiden_name|date_of_birth)
|
||||
name_part, middle_name, maiden_name, date_of_birth = person_name.split('|', 3)
|
||||
parts = [p.strip() for p in name_part.split(',', 1)]
|
||||
|
||||
if len(parts) == 2:
|
||||
last_name, first_name = parts[0], parts[1]
|
||||
else:
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ''
|
||||
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, date_of_birth) VALUES (?, ?, ?)', (first_name, last_name, None))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND date_of_birth IS NULL', (first_name, last_name))
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth))
|
||||
result = cursor.fetchone()
|
||||
person_id = result[0] if result else None
|
||||
|
||||
@ -1692,8 +1710,6 @@ class PhotoTagger:
|
||||
print(f"✅ Identified current face and {len(selected_face_ids)} similar faces as: {person_name}")
|
||||
identified_count += 1 + len(selected_face_ids)
|
||||
|
||||
# Update the people dropdown to include the new person
|
||||
update_people_dropdown()
|
||||
|
||||
# Update person encodings after database transaction is complete
|
||||
self._update_person_encodings(person_id)
|
||||
@ -1705,15 +1721,18 @@ class PhotoTagger:
|
||||
cursor = conn.cursor()
|
||||
# Add person if doesn't exist
|
||||
# Parse command in "Last, First" or single-token format
|
||||
parts = [p.strip() for p in command.split(',', 1)]
|
||||
# Parse command with additional fields (middle_name|maiden_name|date_of_birth)
|
||||
name_part, middle_name, maiden_name, date_of_birth = command.split('|', 3)
|
||||
parts = [p.strip() for p in name_part.split(',', 1)]
|
||||
|
||||
if len(parts) == 2:
|
||||
last_name, first_name = parts[0], parts[1]
|
||||
else:
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ''
|
||||
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, date_of_birth) VALUES (?, ?, ?)', (first_name, last_name, None))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND date_of_birth IS NULL', (first_name, last_name))
|
||||
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth))
|
||||
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth))
|
||||
result = cursor.fetchone()
|
||||
person_id = result[0] if result else None
|
||||
|
||||
@ -1737,30 +1756,33 @@ class PhotoTagger:
|
||||
# Mark this face as identified in our tracking
|
||||
face_status[face_id] = 'identified'
|
||||
|
||||
# Update the people dropdown to include the new person
|
||||
update_people_dropdown()
|
||||
|
||||
# Update person encodings after database transaction is complete
|
||||
self._update_person_encodings(person_id)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
# Increment index for normal flow (identification or error) - but not if we're at the last item
|
||||
if i < len(original_faces) - 1:
|
||||
i += 1
|
||||
update_button_states()
|
||||
# Only update similar faces if compare is enabled
|
||||
if compare_var.get():
|
||||
update_similar_faces()
|
||||
|
||||
# Clean up current face crop when moving forward after identification
|
||||
if face_crop_path and os.path.exists(face_crop_path):
|
||||
try:
|
||||
os.remove(face_crop_path)
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
current_face_crop_path = None # Clear tracked path
|
||||
|
||||
# Continue to next face after processing command
|
||||
continue
|
||||
else:
|
||||
print("Please enter a name, 's' to skip, 'q' to quit, or use buttons")
|
||||
|
||||
# Increment index for normal flow (identification or error) - but not if we're at the last item
|
||||
if i < len(original_faces) - 1:
|
||||
i += 1
|
||||
update_button_states()
|
||||
update_similar_faces()
|
||||
|
||||
# Clean up current face crop when moving forward after identification
|
||||
if face_crop_path and os.path.exists(face_crop_path):
|
||||
try:
|
||||
os.remove(face_crop_path)
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
current_face_crop_path = None # Clear tracked path
|
||||
|
||||
# Only close the window if user explicitly quit (not when reaching end of faces)
|
||||
if not window_destroyed:
|
||||
@ -2260,7 +2282,14 @@ class PhotoTagger:
|
||||
|
||||
# Top people
|
||||
cursor.execute('''
|
||||
SELECT p.name, COUNT(f.id) as face_count
|
||||
SELECT
|
||||
CASE
|
||||
WHEN p.last_name AND p.first_name THEN p.last_name || ', ' || p.first_name
|
||||
WHEN p.first_name THEN p.first_name
|
||||
WHEN p.last_name THEN p.last_name
|
||||
ELSE 'Unknown'
|
||||
END as full_name,
|
||||
COUNT(f.id) as face_count
|
||||
FROM people p
|
||||
LEFT JOIN faces f ON p.id = f.person_id
|
||||
GROUP BY p.id
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user