Enhance PhotoTagger by adding date of birth support in the database and GUI. Update data handling to accommodate new input format, including validation for date selection. Revise identification logic to ensure complete data is saved, improving user experience and data integrity.

This commit is contained in:
tanyar09 2025-09-25 14:39:05 -04:00
parent 52b9d37d8c
commit 2fcd200cd0

View File

@ -163,8 +163,9 @@ class PhotoTagger:
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
date_of_birth DATE,
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(first_name, last_name)
UNIQUE(first_name, last_name, date_of_birth)
)
''')
@ -389,9 +390,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 first_name, last_name FROM people ORDER BY first_name, last_name')
cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name')
people = cursor.fetchall()
identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last in people]
identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob 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")
@ -425,8 +426,16 @@ class PhotoTagger:
nonlocal identified_count
saved_count = 0
for face_id, person_name in face_person_names.items():
if person_name.strip():
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 = ''
if person_name:
try:
with self.get_db_connection() as conn:
cursor = conn.cursor()
@ -439,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) VALUES (?, ?)', (first_name, last_name))
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ?', (first_name, 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))
result = cursor.fetchone()
person_id = result[0] if result else None
@ -477,11 +486,27 @@ class PhotoTagger:
if not validate_navigation():
return # Cancel close
# Check if there are pending identifications (faces with names but not yet saved)
pending_identifications = {
k: v for k, v in face_person_names.items()
if v.strip() and (k not in face_status or face_status[k] != 'identified')
}
# Check if there are pending identifications (faces with complete data but not yet saved)
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
if pending_identifications:
# Ask user if they want to save pending identifications
@ -563,6 +588,7 @@ class PhotoTagger:
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)
input_frame.columnconfigure(5, weight=1)
# First name input with dropdown
ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
@ -574,7 +600,256 @@ class PhotoTagger:
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))
last_name_combo.grid(row=0, column=3, 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))
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 display entry (read-only)
date_of_birth_entry = ttk.Entry(date_frame, textvariable=date_of_birth_var, width=12, state='readonly')
date_of_birth_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Calendar button
calendar_btn = ttk.Button(date_frame, text="📅", width=3, command=lambda: open_calendar())
calendar_btn.pack(side=tk.RIGHT, padx=(5, 0))
def open_calendar():
"""Open a visual calendar dialog to select date of birth"""
from datetime import datetime, date, timedelta
import calendar
# Create calendar window
calendar_window = tk.Toplevel(root)
calendar_window.title("Select Date of Birth")
calendar_window.resizable(False, False)
calendar_window.transient(root)
calendar_window.grab_set()
# Calculate center position before showing the window
window_width = 350
window_height = 400
screen_width = calendar_window.winfo_screenwidth()
screen_height = calendar_window.winfo_screenheight()
x = (screen_width // 2) - (window_width // 2)
y = (screen_height // 2) - (window_height // 2)
# Set geometry with center position before showing
calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
# Calendar variables
current_date = datetime.now()
# Check if there's already a date selected
existing_date_str = date_of_birth_var.get().strip()
if existing_date_str:
try:
existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date()
display_year = existing_date.year
display_month = existing_date.month
selected_date = existing_date
except ValueError:
# If existing date is invalid, use default
display_year = current_date.year - 25
display_month = 1
selected_date = None
else:
# Default to 25 years ago
display_year = current_date.year - 25
display_month = 1
selected_date = None
# Month names
month_names = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
# Configure custom styles for better visual highlighting
style = ttk.Style()
# Selected date style - bright blue background with white text
style.configure("Selected.TButton",
background="#0078d4",
foreground="white",
font=("Arial", 9, "bold"),
relief="raised",
borderwidth=2)
style.map("Selected.TButton",
background=[("active", "#106ebe")],
relief=[("pressed", "sunken")])
# Today's date style - orange background
style.configure("Today.TButton",
background="#ff8c00",
foreground="white",
font=("Arial", 9, "bold"),
relief="raised",
borderwidth=1)
style.map("Today.TButton",
background=[("active", "#e67e00")],
relief=[("pressed", "sunken")])
# Calendar-specific normal button style (don't affect global TButton)
style.configure("Calendar.TButton",
font=("Arial", 9),
relief="flat")
style.map("Calendar.TButton",
background=[("active", "#e1e1e1")],
relief=[("pressed", "sunken")])
# Main frame
main_cal_frame = ttk.Frame(calendar_window, padding="10")
main_cal_frame.pack(fill=tk.BOTH, expand=True)
# Header frame with navigation
header_frame = ttk.Frame(main_cal_frame)
header_frame.pack(fill=tk.X, pady=(0, 10))
# Month/Year display and navigation
nav_frame = ttk.Frame(header_frame)
nav_frame.pack()
def update_calendar():
"""Update the calendar display"""
# Clear existing calendar
for widget in calendar_frame.winfo_children():
widget.destroy()
# Update header
month_year_label.config(text=f"{month_names[display_month-1]} {display_year}")
# Get calendar data
cal = calendar.monthcalendar(display_year, display_month)
# Day headers
day_headers = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
for i, day in enumerate(day_headers):
label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold"))
label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew")
# Calendar days
for week_num, week in enumerate(cal):
for day_num, day in enumerate(week):
if day == 0:
# Empty cell
label = ttk.Label(calendar_frame, text="")
label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew")
else:
# Day button
def make_day_handler(day_value):
def select_day():
nonlocal selected_date
selected_date = date(display_year, display_month, day_value)
# Reset all buttons to normal calendar style
for widget in calendar_frame.winfo_children():
if isinstance(widget, ttk.Button):
widget.config(style="Calendar.TButton")
# Highlight selected day with prominent style
for widget in calendar_frame.winfo_children():
if isinstance(widget, ttk.Button) and widget.cget("text") == str(day_value):
widget.config(style="Selected.TButton")
return select_day
day_btn = ttk.Button(calendar_frame, text=str(day),
command=make_day_handler(day),
width=3, style="Calendar.TButton")
day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew")
# Check if this day should be highlighted
is_today = (display_year == current_date.year and
display_month == current_date.month and
day == current_date.day)
is_selected = (selected_date and
selected_date.year == display_year and
selected_date.month == display_month and
selected_date.day == day)
if is_selected:
day_btn.config(style="Selected.TButton")
elif is_today:
day_btn.config(style="Today.TButton")
# Navigation functions
def prev_year():
nonlocal display_year
display_year = max(1900, display_year - 1)
update_calendar()
def next_year():
nonlocal display_year
display_year = min(current_date.year, display_year + 1)
update_calendar()
def prev_month():
nonlocal display_month, display_year
if display_month > 1:
display_month -= 1
else:
display_month = 12
display_year = max(1900, display_year - 1)
update_calendar()
def next_month():
nonlocal display_month, display_year
if display_month < 12:
display_month += 1
else:
display_month = 1
display_year = min(current_date.year, display_year + 1)
update_calendar()
# Navigation buttons
prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year)
prev_year_btn.pack(side=tk.LEFT, padx=(0, 2))
prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month)
prev_month_btn.pack(side=tk.LEFT, padx=(0, 5))
month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold"))
month_year_label.pack(side=tk.LEFT, padx=5)
next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month)
next_month_btn.pack(side=tk.LEFT, padx=(5, 2))
next_year_btn = ttk.Button(nav_frame, text=">>", width=3, command=next_year)
next_year_btn.pack(side=tk.LEFT)
# Calendar grid frame
calendar_frame = ttk.Frame(main_cal_frame)
calendar_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Configure grid weights
for i in range(7):
calendar_frame.columnconfigure(i, weight=1)
for i in range(7):
calendar_frame.rowconfigure(i, weight=1)
# Buttons frame
buttons_frame = ttk.Frame(main_cal_frame)
buttons_frame.pack(fill=tk.X)
def select_date():
"""Select the date and close calendar"""
if selected_date:
date_str = selected_date.strftime('%Y-%m-%d')
date_of_birth_var.set(date_str)
calendar_window.destroy()
else:
messagebox.showwarning("No Date Selected", "Please select a date from the calendar.")
def cancel_selection():
"""Cancel date selection"""
calendar_window.destroy()
# Buttons
ttk.Button(buttons_frame, text="Select", command=select_date).pack(side=tk.LEFT, padx=(0, 5))
ttk.Button(buttons_frame, text="Cancel", command=cancel_selection).pack(side=tk.LEFT)
# Initialize calendar
update_calendar()
# Define update_similar_faces function first - reusing auto-match display logic
def update_similar_faces():
@ -747,9 +1022,11 @@ class PhotoTagger:
unique_key = f"{current_face_id}_{similar_face_id}"
face_selection_states[current_face_id][unique_key] = var.get()
# Save person name
# Save person name and date of birth
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
date_of_birth = date_of_birth_var.get().strip()
if first_name or last_name:
if last_name and first_name:
current_name = f"{last_name}, {first_name}"
@ -759,7 +1036,11 @@ class PhotoTagger:
current_name = first_name
else:
current_name = ""
face_person_names[current_face_id] = current_name
# Store both name and date of birth
face_person_names[current_face_id] = {
'name': current_name,
'date_of_birth': date_of_birth
}
# Button commands
command = None
@ -769,10 +1050,27 @@ class PhotoTagger:
nonlocal command, waiting_for_input
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
date_of_birth = date_of_birth_var.get().strip()
compare_enabled = compare_var.get()
if not first_name and not last_name:
print("⚠️ Please enter at least a first name or last name before identifying")
if not first_name:
print("⚠️ Please enter a first name before identifying")
return
if not last_name:
print("⚠️ Please enter a last name before identifying")
return
if not date_of_birth:
print("⚠️ Please select a date of birth before identifying")
return
# Validate date format (YYYY-MM-DD) - should always be valid from calendar
try:
from datetime import datetime
datetime.strptime(date_of_birth, '%Y-%m-%d')
except ValueError:
print("⚠️ Invalid date format. Please use the calendar to select a date.")
return
# Combine first and last name properly
@ -843,12 +1141,27 @@ class PhotoTagger:
if not validate_navigation():
return # Cancel quit
# Check if there are pending identifications (faces with names but not yet saved)
pending_identifications = {
k: v for k, v in face_person_names.items()
if v.strip() and (k not in face_status or face_status[k] != 'identified')
}
# Check if there are pending identifications (faces with complete data but not yet saved)
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
if pending_identifications:
# Ask user if they want to save pending identifications
@ -944,10 +1257,11 @@ class PhotoTagger:
# Button references moved to bottom control panel
def update_identify_button_state():
"""Enable/disable identify button based on name input"""
"""Enable/disable identify button based on first name, last name, and date of birth"""
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
if first_name or last_name:
date_of_birth = date_of_birth_var.get().strip()
if first_name and last_name and date_of_birth:
identify_btn.config(state='normal')
else:
identify_btn.config(state='disabled')
@ -955,6 +1269,7 @@ class PhotoTagger:
# Bind name input changes to update button state
first_name_var.trace('w', lambda *args: update_identify_button_state())
last_name_var.trace('w', lambda *args: update_identify_button_state())
date_of_birth_var.trace('w', lambda *args: update_identify_button_state())
# Handle Enter key
def on_enter(event):
@ -1186,6 +1501,7 @@ class PhotoTagger:
else:
first_name_var.set("")
last_name_var.set("")
date_of_birth_var.set("")
# Keep compare checkbox state persistent across navigation
first_name_combo.focus_set()
@ -1263,6 +1579,9 @@ class PhotoTagger:
print("⚠️ No more unidentified faces - Next button disabled")
continue
# Clear date of birth field when moving to next face
date_of_birth_var.set("")
update_button_states()
update_similar_faces()
continue
@ -1285,6 +1604,37 @@ class PhotoTagger:
print("⚠️ No more unidentified faces - Back button disabled")
continue
# Repopulate fields with saved data when going back
current_face_id = original_faces[i][0]
if current_face_id in face_person_names:
person_data = face_person_names[current_face_id]
if isinstance(person_data, dict):
person_name = person_data.get('name', '').strip()
date_of_birth = person_data.get('date_of_birth', '').strip()
# Parse "Last, First" format back to separate fields
if ', ' in person_name:
parts = person_name.split(', ', 1)
last_name_var.set(parts[0].strip())
first_name_var.set(parts[1].strip())
else:
# Fallback for single name
first_name_var.set(person_name)
last_name_var.set("")
# Repopulate date of birth
date_of_birth_var.set(date_of_birth)
else:
# Old format - clear fields
first_name_var.set("")
last_name_var.set("")
date_of_birth_var.set("")
else:
# No saved data - clear fields
first_name_var.set("")
last_name_var.set("")
date_of_birth_var.set("")
update_button_states()
update_similar_faces()
continue
@ -1314,8 +1664,8 @@ class PhotoTagger:
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))
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))
result = cursor.fetchone()
person_id = result[0] if result else None
@ -1362,8 +1712,8 @@ class PhotoTagger:
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))
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))
result = cursor.fetchone()
person_id = result[0] if result else None