Implement date handling features in PhotoTagger. Add 'date_taken' and 'date_added' columns to the photos database, along with EXIF data extraction for photo dates. Enhance the GUI with date filter options for face identification, allowing users to filter by date taken and processed dates. Introduce a calendar dialog for easier date selection, improving user experience and data management.

This commit is contained in:
tanyar09 2025-09-29 15:26:16 -04:00
parent 34aba85fc6
commit e1bed343b6

View File

@ -10,6 +10,7 @@ import argparse
import face_recognition
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from PIL.ExifTags import TAGS
import pickle
import numpy as np
from typing import List, Dict, Tuple, Optional
@ -18,6 +19,7 @@ import tempfile
import subprocess
import threading
import time
from datetime import datetime
from functools import lru_cache
from contextlib import contextmanager
@ -153,6 +155,7 @@ class PhotoTagger:
path TEXT UNIQUE NOT NULL,
filename TEXT NOT NULL,
date_added DATETIME DEFAULT CURRENT_TIMESTAMP,
date_taken DATE,
processed BOOLEAN DEFAULT 0
)
''')
@ -219,11 +222,66 @@ class PhotoTagger:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_quality ON faces(quality_score)')
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)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_added ON photos(date_added)')
# Migration: Add date_taken column to existing photos table if it doesn't exist
try:
cursor.execute('ALTER TABLE photos ADD COLUMN date_taken DATE')
if self.verbose >= 1:
print("✅ Added date_taken column to photos table")
except Exception:
# Column already exists, ignore
pass
# Migration: Add date_added column to existing photos table if it doesn't exist
try:
cursor.execute('ALTER TABLE photos ADD COLUMN date_added DATETIME DEFAULT CURRENT_TIMESTAMP')
if self.verbose >= 1:
print("✅ Added date_added column to photos table")
except Exception:
# Column already exists, ignore
pass
if self.verbose >= 1:
print(f"✅ Database initialized: {self.db_path}")
def _extract_photo_date(self, photo_path: str) -> Optional[str]:
"""Extract date taken from photo EXIF data"""
try:
with Image.open(photo_path) as image:
exifdata = image.getexif()
# Look for date taken in EXIF tags
date_tags = [
306, # DateTime
36867, # DateTimeOriginal
36868, # DateTimeDigitized
]
for tag_id in date_tags:
if tag_id in exifdata:
date_str = exifdata[tag_id]
if date_str:
# Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
try:
date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
return date_obj.strftime('%Y-%m-%d')
except ValueError:
# Try alternative format
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
return date_obj.strftime('%Y-%m-%d')
except ValueError:
continue
return None
except Exception as e:
if self.verbose >= 2:
print(f" ⚠️ Could not extract date from {os.path.basename(photo_path)}: {e}")
return None
def scan_folder(self, folder_path: str, recursive: bool = True) -> int:
"""Scan folder for photos and add to database"""
# BREAKPOINT: Set breakpoint here for debugging
@ -266,14 +324,18 @@ class PhotoTagger:
for photo_path, filename in found_photos:
try:
# Extract date taken from EXIF data
date_taken = self._extract_photo_date(photo_path)
cursor.execute(
'INSERT OR IGNORE INTO photos (path, filename) VALUES (?, ?)',
(photo_path, filename)
'INSERT OR IGNORE INTO photos (path, filename, date_taken) VALUES (?, ?, ?)',
(photo_path, filename, date_taken)
)
if cursor.rowcount > 0:
added_count += 1
if self.verbose >= 2:
print(f" 📸 Added: {filename}")
date_info = f" (taken: {date_taken})" if date_taken else " (no date)"
print(f" 📸 Added: {filename}{date_info}")
elif self.verbose >= 3:
print(f" 📸 Already exists: {filename}")
except Exception as e:
@ -283,6 +345,7 @@ class PhotoTagger:
print(f"📁 Found {len(found_photos)} photos, added {added_count} new photos")
return added_count
def process_faces(self, limit: int = 50, model: str = "hog") -> int:
"""Process unprocessed photos for faces"""
with self.get_db_connection() as conn:
@ -355,18 +418,43 @@ class PhotoTagger:
print(f"✅ Processed {processed_count} photos")
return processed_count
def identify_faces(self, batch_size: int = 20, show_faces: bool = False, tolerance: float = 0.6) -> int:
def identify_faces(self, batch_size: int = 20, show_faces: bool = False, tolerance: float = 0.6,
date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int:
"""Interactive face identification with optimized performance"""
with self.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
# Build the SQL query with optional date filtering
query = '''
SELECT f.id, f.photo_id, p.path, p.filename, f.location
FROM faces f
JOIN photos p ON f.photo_id = p.id
WHERE f.person_id IS NULL
LIMIT ?
''', (batch_size,))
'''
params = []
# Add date taken filtering if specified
if date_from:
query += ' AND p.date_taken >= ?'
params.append(date_from)
if date_to:
query += ' AND p.date_taken <= ?'
params.append(date_to)
# Add date processed filtering if specified
if date_processed_from:
query += ' AND DATE(p.date_added) >= ?'
params.append(date_processed_from)
if date_processed_to:
query += ' AND DATE(p.date_added) <= ?'
params.append(date_processed_to)
query += ' LIMIT ?'
params.append(batch_size)
cursor.execute(query, params)
unidentified = cursor.fetchall()
@ -554,20 +642,354 @@ 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
main_frame.rowconfigure(2, weight=1) # Main content row
# Photo info
info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold"))
info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W)
# Calendar dialog function for date filter
def open_date_calendar(date_var, title):
"""Open a visual calendar dialog to select date"""
from datetime import datetime, date, timedelta
import calendar
# Create calendar window
calendar_window = tk.Toplevel(root)
calendar_window.title(title)
calendar_window.resizable(False, False)
calendar_window.transient(root)
calendar_window.grab_set()
# Calculate center position before showing the window
window_width = 400
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_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 current date
display_year = current_date.year
display_month = current_date.month
selected_date = None
else:
# Default to current date
display_year = current_date.year
display_month = current_date.month
selected_date = None
# Month names
month_names = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
# Create custom style for calendar buttons
style = ttk.Style()
style.configure("Calendar.TButton", padding=(2, 2))
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()
# Month/Year label (created once, updated later)
month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold"))
month_year_label.pack(side=tk.LEFT, padx=10)
def update_calendar():
"""Update the calendar display"""
# Update month/year label
month_year_label.configure(text=f"{month_names[display_month-1]} {display_year}")
# Clear existing calendar
for widget in calendar_frame.winfo_children():
widget.destroy()
# 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):
header_label = ttk.Label(calendar_frame, text=day, font=("Arial", 10, "bold"))
header_label.grid(row=0, column=i, padx=2, pady=2, sticky="nsew")
# Calendar days
for week_num, week in enumerate(cal):
for day_num, day in enumerate(week):
if day == 0:
# Empty cell
empty_label = ttk.Label(calendar_frame, text="")
empty_label.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew")
else:
# Day button
day_date = date(display_year, display_month, day)
is_selected = selected_date == day_date
is_today = day_date == current_date.date()
# Button text and style
button_text = str(day)
if is_today:
button_text = f"{day}" # Mark today
day_btn = ttk.Button(calendar_frame, text=button_text,
style="Calendar.TButton" if not is_selected else "Calendar.TButton",
command=lambda d=day_date: select_date(d))
day_btn.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew")
# Highlight selected date
if is_selected:
day_btn.configure(style="Calendar.TButton")
# Add visual indication of selection
day_btn.configure(text=f"[{day}]")
def select_date(selected_day):
"""Select a date and close calendar"""
nonlocal selected_date
selected_date = selected_day
date_var.set(selected_day.strftime('%Y-%m-%d'))
calendar_window.destroy()
def prev_month():
nonlocal display_month, display_year
display_month -= 1
if display_month < 1:
display_month = 12
display_year -= 1
update_calendar()
def next_month():
nonlocal display_month, display_year
display_month += 1
if display_month > 12:
display_month = 1
display_year += 1
update_calendar()
def prev_year():
nonlocal display_year
display_year -= 1
update_calendar()
def next_year():
nonlocal display_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)
prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month)
prev_month_btn.pack(side=tk.LEFT, padx=(5, 0))
next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month)
next_month_btn.pack(side=tk.LEFT, padx=(5, 0))
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 clear_date():
"""Clear the selected date"""
date_var.set("")
calendar_window.destroy()
# Clear button
clear_btn = ttk.Button(buttons_frame, text="Clear", command=clear_date)
clear_btn.pack(side=tk.LEFT)
# Cancel button
cancel_btn = ttk.Button(buttons_frame, text="Cancel", command=calendar_window.destroy)
cancel_btn.pack(side=tk.RIGHT)
# Initial calendar display
update_calendar()
# Date filter controls
date_filter_frame = ttk.LabelFrame(main_frame, text="Date Filters", padding="5")
date_filter_frame.grid(row=1, column=0, columnspan=2, pady=(0, 10), sticky=(tk.W, tk.E))
date_filter_frame.columnconfigure(1, weight=1)
date_filter_frame.columnconfigure(4, weight=1)
date_filter_frame.columnconfigure(7, weight=1)
date_filter_frame.columnconfigure(10, weight=1)
# Date from
ttk.Label(date_filter_frame, text="From:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5))
date_from_var = tk.StringVar(value=date_from or "")
date_from_entry = ttk.Entry(date_filter_frame, textvariable=date_from_var, width=12, state='readonly')
date_from_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5))
# Calendar button for date from
def open_calendar_from():
open_date_calendar(date_from_var, "Select Start Date")
calendar_from_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_from)
calendar_from_btn.grid(row=0, column=2, padx=(0, 10))
# Date to
ttk.Label(date_filter_frame, text="To:").grid(row=0, column=3, sticky=tk.W, padx=(0, 5))
date_to_var = tk.StringVar(value=date_to or "")
date_to_entry = ttk.Entry(date_filter_frame, textvariable=date_to_var, width=12, state='readonly')
date_to_entry.grid(row=0, column=4, sticky=(tk.W, tk.E), padx=(0, 5))
# Calendar button for date to
def open_calendar_to():
open_date_calendar(date_to_var, "Select End Date")
calendar_to_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_to)
calendar_to_btn.grid(row=0, column=5, padx=(0, 10))
# Apply filter button
def apply_date_filter():
nonlocal date_from, date_to
date_from = date_from_var.get().strip() or None
date_to = date_to_var.get().strip() or None
date_processed_from = date_processed_from_var.get().strip() or None
date_processed_to = date_processed_to_var.get().strip() or None
# Reload faces with new date filter
with self.get_db_connection() as conn:
cursor = conn.cursor()
# Build the SQL query with optional date filtering
query = '''
SELECT f.id, f.photo_id, p.path, p.filename, f.location
FROM faces f
JOIN photos p ON f.photo_id = p.id
WHERE f.person_id IS NULL
'''
params = []
# Add date taken filtering if specified
if date_from:
query += ' AND p.date_taken >= ?'
params.append(date_from)
if date_to:
query += ' AND p.date_taken <= ?'
params.append(date_to)
# Add date processed filtering if specified
if date_processed_from:
query += ' AND DATE(p.date_added) >= ?'
params.append(date_processed_from)
if date_processed_to:
query += ' AND DATE(p.date_added) <= ?'
params.append(date_processed_to)
query += ' LIMIT ?'
params.append(batch_size)
cursor.execute(query, params)
unidentified = cursor.fetchall()
if not unidentified:
messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.")
return
# Update the global unidentified list and reset position
nonlocal current_pos, total_unidentified
current_pos = 0
total_unidentified = len(unidentified)
# Reset to first face - display will update when user navigates
if len(unidentified) > 0:
# Reset to first face
current_pos = 0
# The display will be updated when the user navigates or when the window is shown
# Build filter description
filters_applied = []
if date_from or date_to:
taken_filter = f"taken: {date_from or 'any'} to {date_to or 'any'}"
filters_applied.append(taken_filter)
if date_processed_from or date_processed_to:
processed_filter = f"processed: {date_processed_from or 'any'} to {date_processed_to or 'any'}"
filters_applied.append(processed_filter)
filter_desc = " | ".join(filters_applied) if filters_applied else "no filters"
print(f"📅 Applied filters: {filter_desc}")
print(f"👤 Found {len(unidentified)} unidentified faces with date filters")
print("💡 Navigate to refresh the display with filtered faces")
apply_filter_btn = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter)
apply_filter_btn.grid(row=0, column=6, padx=(10, 0))
# Date processed filter (second row)
ttk.Label(date_filter_frame, text="Processed From:").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0))
date_processed_from_var = tk.StringVar()
date_processed_from_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_from_var, width=12, state='readonly')
date_processed_from_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 5), pady=(10, 0))
# Calendar button for date processed from
def open_calendar_processed_from():
open_date_calendar(date_processed_from_var, "Select Processing Start Date")
calendar_processed_from_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_from)
calendar_processed_from_btn.grid(row=1, column=2, padx=(0, 10), pady=(10, 0))
# Date processed to
ttk.Label(date_filter_frame, text="Processed To:").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0))
date_processed_to_var = tk.StringVar()
date_processed_to_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_to_var, width=12, state='readonly')
date_processed_to_entry.grid(row=1, column=4, sticky=(tk.W, tk.E), padx=(0, 5), pady=(10, 0))
# Calendar button for date processed to
def open_calendar_processed_to():
open_date_calendar(date_processed_to_var, "Select Processing End Date")
calendar_processed_to_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to)
calendar_processed_to_btn.grid(row=1, column=5, padx=(0, 10), pady=(10, 0))
# Left panel for main face
left_panel = ttk.Frame(main_frame)
left_panel.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
left_panel.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
left_panel.columnconfigure(0, weight=1)
# Right panel for similar faces
right_panel = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5")
right_panel.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
right_panel.grid(row=2, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
right_panel.columnconfigure(0, weight=1)
right_panel.rowconfigure(0, weight=1) # Make right panel expandable vertically
@ -1268,7 +1690,7 @@ class PhotoTagger:
# Bottom control panel
control_frame = ttk.Frame(main_frame)
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
control_frame.grid(row=3, 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)
@ -4279,7 +4701,7 @@ class PhotoTagger:
# Control buttons
control_frame = ttk.Frame(main_frame)
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
def on_quit():
nonlocal window_destroyed