feat: Implement Modify Identified workflow for person management

This commit introduces the Modify Identified workflow, allowing users to edit person information, view associated faces, and unmatch faces from identified people. The API has been updated with new endpoints for unmatching faces and retrieving faces for specific persons. The frontend includes a new Modify page with a user-friendly interface for managing identified persons, including search and edit functionalities. Documentation and tests have been updated to reflect these changes, ensuring reliability and usability.
This commit is contained in:
tanyar09
2025-11-04 12:00:39 -05:00
parent bb42478c8f
commit 91ee2ce8ab
14 changed files with 1972 additions and 48 deletions
+207 -5
View File
@@ -19,10 +19,23 @@ from src.web.schemas.faces import (
SimilarFaceItem,
IdentifyFaceRequest,
IdentifyFaceResponse,
FaceUnmatchResponse,
BatchUnmatchRequest,
BatchUnmatchResponse,
AutoMatchRequest,
AutoMatchResponse,
AutoMatchPersonItem,
AutoMatchFaceItem,
AcceptMatchesRequest,
)
from src.web.schemas.people import PersonCreateRequest, PersonResponse
from src.web.db.models import Face, Person, PersonEncoding
from src.web.services.face_service import list_unidentified_faces, find_similar_faces
from src.web.services.face_service import (
list_unidentified_faces,
find_similar_faces,
find_auto_match_matches,
accept_auto_match_matches,
)
# Note: Function passed as string path to avoid RQ serialization issues
router = APIRouter(prefix="/faces", tags=["faces"])
@@ -317,8 +330,197 @@ def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response:
)
@router.post("/auto-match")
def auto_match_faces() -> dict:
"""Auto-match faces - placeholder for Phase 2."""
return {"message": "Auto-match endpoint - to be implemented in Phase 2"}
@router.post("/{face_id}/unmatch", response_model=FaceUnmatchResponse)
def unmatch_face(face_id: int, db: Session = Depends(get_db)) -> FaceUnmatchResponse:
"""Unmatch a face from its person (set person_id to NULL)."""
face = db.query(Face).filter(Face.id == face_id).first()
if not face:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Face {face_id} not found")
if face.person_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Face {face_id} is not currently matched to any person",
)
# Store person_id for response message
old_person_id = face.person_id
# Unmatch the face
face.person_id = None
# Also delete associated person_encodings for this face
db.query(PersonEncoding).filter(PersonEncoding.face_id == face_id).delete()
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to unmatch face: {str(e)}",
)
return FaceUnmatchResponse(
face_id=face_id,
message=f"Face {face_id} unlinked from person {old_person_id}",
)
@router.post("/batch-unmatch", response_model=BatchUnmatchResponse)
def batch_unmatch_faces(request: BatchUnmatchRequest, db: Session = Depends(get_db)) -> BatchUnmatchResponse:
"""Batch unmatch multiple faces from their people."""
if not request.face_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="face_ids list cannot be empty",
)
# Validate all faces exist
faces = db.query(Face).filter(Face.id.in_(request.face_ids)).all()
found_ids = {f.id for f in faces}
missing_ids = set(request.face_ids) - found_ids
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Faces not found: {sorted(missing_ids)}",
)
# Filter to only faces that are currently matched
matched_faces = [f for f in faces if f.person_id is not None]
if not matched_faces:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="None of the specified faces are currently matched to any person",
)
# Unmatch all matched faces
face_ids_to_unmatch = [f.id for f in matched_faces]
for face in matched_faces:
face.person_id = None
# Delete associated person_encodings for these faces
db.query(PersonEncoding).filter(PersonEncoding.face_id.in_(face_ids_to_unmatch)).delete(synchronize_session=False)
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to batch unmatch faces: {str(e)}",
)
return BatchUnmatchResponse(
unmatched_face_ids=face_ids_to_unmatch,
count=len(face_ids_to_unmatch),
message=f"Successfully unlinked {len(face_ids_to_unmatch)} face(s)",
)
@router.post("/auto-match", response_model=AutoMatchResponse)
def auto_match_faces(
request: AutoMatchRequest,
db: Session = Depends(get_db),
) -> AutoMatchResponse:
"""Start auto-match process with tolerance threshold.
Matches desktop auto-match workflow exactly:
1. Gets all identified people (one face per person, best quality >= 0.3)
2. For each person, finds similar unidentified faces (confidence >= 40%)
3. Returns matches grouped by person, sorted by person name
"""
from src.web.db.models import Person, Photo
from sqlalchemy import func
# Find matches for all identified people
matches_data = find_auto_match_matches(db, tolerance=request.tolerance)
if not matches_data:
return AutoMatchResponse(
people=[],
total_people=0,
total_matches=0,
)
# Build response matching desktop format
people_items = []
total_matches = 0
for person_id, reference_face_id, reference_face, similar_faces in matches_data:
# Get person details
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
continue
# Build person name (matching desktop)
name_parts = []
if person.first_name:
name_parts.append(person.first_name)
if person.middle_name:
name_parts.append(person.middle_name)
if person.last_name:
name_parts.append(person.last_name)
if person.maiden_name:
name_parts.append(f"({person.maiden_name})")
person_name = ' '.join(name_parts) if name_parts else "Unknown"
# Get face count for this person (matching desktop)
face_count = (
db.query(func.count(Face.id))
.filter(Face.person_id == person_id)
.scalar() or 0
)
# Get reference face photo info
reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first()
if not reference_photo:
continue
# Build matches list
match_items = []
for face, distance, confidence_pct in similar_faces:
# Get photo info for this match
match_photo = db.query(Photo).filter(Photo.id == face.photo_id).first()
if not match_photo:
continue
match_items.append(
AutoMatchFaceItem(
id=face.id,
photo_id=face.photo_id,
photo_filename=match_photo.filename,
location=face.location,
quality_score=float(face.quality_score),
similarity=confidence_pct, # Confidence percentage (0-100)
distance=distance,
)
)
if match_items:
people_items.append(
AutoMatchPersonItem(
person_id=person_id,
person_name=person_name,
reference_face_id=reference_face_id,
reference_photo_id=reference_face.photo_id,
reference_photo_filename=reference_photo.filename,
reference_location=reference_face.location,
face_count=face_count,
matches=match_items,
total_matches=len(match_items),
)
)
total_matches += len(match_items)
return AutoMatchResponse(
people=people_items,
total_people=len(people_items),
total_matches=total_matches,
)
+153 -5
View File
@@ -2,28 +2,89 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.web.db.session import get_db
from src.web.db.models import Person
from src.web.db.models import Person, Face
from src.web.schemas.people import (
PeopleListResponse,
PersonCreateRequest,
PersonResponse,
PersonUpdateRequest,
PersonWithFacesResponse,
PeopleWithFacesListResponse,
)
from src.web.schemas.faces import PersonFacesResponse, PersonFaceItem, AcceptMatchesRequest, IdentifyFaceResponse
from src.web.services.face_service import accept_auto_match_matches
router = APIRouter(prefix="/people", tags=["people"])
@router.get("", response_model=PeopleListResponse)
def list_people(db: Session = Depends(get_db)) -> PeopleListResponse:
"""List all people sorted by last_name, first_name."""
people = db.query(Person).order_by(Person.last_name.asc(), Person.first_name.asc()).all()
def list_people(
last_name: str | None = Query(None, description="Filter by last name (case-insensitive)"),
db: Session = Depends(get_db),
) -> PeopleListResponse:
"""List all people sorted by last_name, first_name.
Optionally filter by last_name if provided (case-insensitive search).
"""
query = db.query(Person)
if last_name:
# Case-insensitive search on last_name
query = query.filter(func.lower(Person.last_name).contains(func.lower(last_name)))
people = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
items = [PersonResponse.model_validate(p) for p in people]
return PeopleListResponse(items=items, total=len(items))
@router.get("/with-faces", response_model=PeopleWithFacesListResponse)
def list_people_with_faces(
last_name: str | None = Query(None, description="Filter by last name (case-insensitive)"),
db: Session = Depends(get_db),
) -> PeopleWithFacesListResponse:
"""List all people with face counts, sorted by last_name, first_name.
Optionally filter by last_name if provided (case-insensitive search).
Only returns people who have at least one face.
"""
# Query people with face counts
query = (
db.query(
Person,
func.count(Face.id).label('face_count')
)
.join(Face, Person.id == Face.person_id)
.group_by(Person.id)
.having(func.count(Face.id) > 0)
)
if last_name:
# Case-insensitive search on last_name
query = query.filter(func.lower(Person.last_name).contains(func.lower(last_name)))
results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
items = [
PersonWithFacesResponse(
id=person.id,
first_name=person.first_name,
last_name=person.last_name,
middle_name=person.middle_name,
maiden_name=person.maiden_name,
date_of_birth=person.date_of_birth,
face_count=face_count,
)
for person, face_count in results
]
return PeopleWithFacesListResponse(items=items, total=len(items))
@router.post("", response_model=PersonResponse, status_code=status.HTTP_201_CREATED)
def create_person(request: PersonCreateRequest, db: Session = Depends(get_db)) -> PersonResponse:
"""Create a new person."""
@@ -52,3 +113,90 @@ def get_person(person_id: int, db: Session = Depends(get_db)) -> PersonResponse:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
return PersonResponse.model_validate(person)
@router.put("/{person_id}", response_model=PersonResponse)
def update_person(
person_id: int,
request: PersonUpdateRequest,
db: Session = Depends(get_db),
) -> PersonResponse:
"""Update person information."""
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
# Update fields
person.first_name = request.first_name.strip()
person.last_name = request.last_name.strip()
person.middle_name = request.middle_name.strip() if request.middle_name else None
person.maiden_name = request.maiden_name.strip() if request.maiden_name else None
person.date_of_birth = request.date_of_birth
try:
db.commit()
db.refresh(person)
except Exception as e:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
return PersonResponse.model_validate(person)
@router.get("/{person_id}/faces", response_model=PersonFacesResponse)
def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFacesResponse:
"""Get all faces for a specific person."""
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
from src.web.db.models import Photo
faces = (
db.query(Face)
.join(Photo, Face.photo_id == Photo.id)
.filter(Face.person_id == person_id)
.order_by(Photo.filename)
.all()
)
items = [
PersonFaceItem(
id=face.id,
photo_id=face.photo_id,
photo_path=face.photo.path,
photo_filename=face.photo.filename,
location=face.location,
face_confidence=float(face.face_confidence),
quality_score=float(face.quality_score),
detector_backend=face.detector_backend,
model_name=face.model_name,
)
for face in faces
]
return PersonFacesResponse(person_id=person_id, items=items, total=len(items))
@router.post("/{person_id}/accept-matches", response_model=IdentifyFaceResponse)
def accept_matches(
person_id: int,
request: AcceptMatchesRequest,
db: Session = Depends(get_db),
) -> IdentifyFaceResponse:
"""Accept auto-match matches for a person.
Matches desktop auto-match save workflow exactly:
1. Identifies selected faces with this person
2. Inserts person_encodings for each identified face
3. Updates person encodings (removes old, adds current)
"""
identified_count, updated_count = accept_auto_match_matches(
db, person_id, request.face_ids
)
return IdentifyFaceResponse(
identified_face_ids=request.face_ids,
person_id=person_id,
created_person=False,
)
+109
View File
@@ -121,3 +121,112 @@ class IdentifyFaceResponse(BaseModel):
identified_face_ids: list[int]
person_id: int
created_person: bool
class FaceUnmatchResponse(BaseModel):
"""Result of unmatch operation."""
model_config = ConfigDict(protected_namespaces=())
face_id: int
message: str
class BatchUnmatchRequest(BaseModel):
"""Request to batch unmatch multiple faces."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., min_items=1)
class BatchUnmatchResponse(BaseModel):
"""Result of batch unmatch operation."""
model_config = ConfigDict(protected_namespaces=())
unmatched_face_ids: list[int]
count: int
message: str
class PersonFaceItem(BaseModel):
"""Face item for person's faces list (includes photo info)."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
photo_path: str
photo_filename: str
location: str
face_confidence: float
quality_score: float
detector_backend: str
model_name: str
class PersonFacesResponse(BaseModel):
"""Response containing all faces for a person."""
model_config = ConfigDict(protected_namespaces=())
person_id: int
items: list[PersonFaceItem]
total: int
class AutoMatchRequest(BaseModel):
"""Request to start auto-match process."""
model_config = ConfigDict(protected_namespaces=())
tolerance: float = Field(0.6, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
class AutoMatchFaceItem(BaseModel):
"""Unidentified face match for a person."""
model_config = ConfigDict(protected_namespaces=())
id: int
photo_id: int
photo_filename: str
location: str
quality_score: float
similarity: float # Confidence percentage (0-100)
distance: float
class AutoMatchPersonItem(BaseModel):
"""Person with matches for auto-match workflow."""
model_config = ConfigDict(protected_namespaces=())
person_id: int
person_name: str
reference_face_id: int
reference_photo_id: int
reference_photo_filename: str
reference_location: str
face_count: int # Number of faces already identified for this person
matches: list[AutoMatchFaceItem]
total_matches: int
class AutoMatchResponse(BaseModel):
"""Response from auto-match start operation."""
model_config = ConfigDict(protected_namespaces=())
people: list[AutoMatchPersonItem]
total_people: int
total_matches: int
class AcceptMatchesRequest(BaseModel):
"""Request to accept matches for a person."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., min_items=0, description="Face IDs to identify with this person")
+35
View File
@@ -42,4 +42,39 @@ class PeopleListResponse(BaseModel):
total: int
class PersonUpdateRequest(BaseModel):
"""Request payload to update a person."""
model_config = ConfigDict(protected_namespaces=())
first_name: str = Field(..., min_length=1)
last_name: str = Field(..., min_length=1)
middle_name: Optional[str] = None
maiden_name: Optional[str] = None
date_of_birth: Optional[date] = None
class PersonWithFacesResponse(BaseModel):
"""Person with face count for modify identified workflow."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
first_name: str
last_name: str
middle_name: Optional[str] = None
maiden_name: Optional[str] = None
date_of_birth: Optional[date] = None
face_count: int
class PeopleWithFacesListResponse(BaseModel):
"""List of people with face counts."""
model_config = ConfigDict(protected_namespaces=())
items: list[PersonWithFacesResponse]
total: int
+170 -2
View File
@@ -6,7 +6,7 @@ import json
import os
import tempfile
import time
from typing import Callable, Optional, Tuple, List
from typing import Callable, Optional, Tuple, List, Dict
from datetime import date
import numpy as np
@@ -28,7 +28,7 @@ from src.core.config import (
MAX_FACE_SIZE,
)
from src.utils.exif_utils import EXIFOrientationHandler
from src.web.db.models import Face, Photo
from src.web.db.models import Face, Photo, Person
def _pre_warm_deepface(
@@ -980,3 +980,171 @@ def find_similar_faces(
# Limit results
return matches[:limit]
def find_auto_match_matches(
db: Session,
tolerance: float = 0.6,
) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
"""Find auto-match matches for all identified people, matching desktop logic exactly.
Desktop flow (from auto_match_panel.py _start_auto_match):
1. Get all identified faces (one per person, best quality >= 0.3)
2. Group by person and get best quality face per person
3. For each person, find similar unidentified faces using _get_filtered_similar_faces
4. Return matches grouped by person
Returns:
List of (person_id, reference_face_id, reference_face, matches) tuples
where matches is list of (face, distance, confidence_pct) tuples
"""
from src.core.config import DEFAULT_FACE_TOLERANCE
if tolerance is None:
tolerance = DEFAULT_FACE_TOLERANCE
# Get all identified faces (one per person) to use as reference faces
# Desktop query:
# SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score,
# f.face_confidence, f.detector_backend, f.model_name
# FROM faces f
# JOIN photos p ON f.photo_id = p.id
# WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3
# ORDER BY f.person_id, f.quality_score DESC
identified_faces: List[Face] = (
db.query(Face)
.join(Photo, Face.photo_id == Photo.id)
.filter(Face.person_id.isnot(None))
.filter(Face.quality_score >= 0.3)
.order_by(Face.person_id, Face.quality_score.desc())
.all()
)
if not identified_faces:
return []
# Group by person and get the best quality face per person (matching desktop)
person_faces: Dict[int, Face] = {}
for face in identified_faces:
person_id = face.person_id
if person_id not in person_faces:
person_faces[person_id] = face
# Convert to ordered list to ensure consistent ordering
# Desktop sorts by person name for consistent, user-friendly ordering
person_faces_list = []
for person_id, face in person_faces.items():
# Get person name for ordering
person = db.query(Person).filter(Person.id == person_id).first()
if person:
if person.last_name and person.first_name:
person_name = f"{person.last_name}, {person.first_name}"
elif person.last_name:
person_name = person.last_name
elif person.first_name:
person_name = person.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 (matching desktop)
person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2)
# Find similar faces for each identified person (matching desktop)
results = []
for person_id, reference_face, person_name in person_faces_list:
reference_face_id = reference_face.id
# Use find_similar_faces which matches desktop _get_filtered_similar_faces logic
# Desktop: similar_faces = self.face_processor._get_filtered_similar_faces(
# reference_face_id, tolerance, include_same_photo=False, face_status=None)
# This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance
similar_faces = find_similar_faces(
db, reference_face_id, limit=1000, tolerance=tolerance
)
if similar_faces:
results.append((person_id, reference_face_id, reference_face, similar_faces))
return results
def accept_auto_match_matches(
db: Session,
person_id: int,
face_ids: List[int],
) -> Tuple[int, int]:
"""Accept auto-match matches for a person, matching desktop logic exactly.
Desktop flow (from auto_match_panel.py _save_changes):
1. For each face_id in face_ids, set person_id on face
2. Insert person_encodings for each identified face
3. Update person encodings (remove old, add current)
Returns:
(identified_count, updated_count) tuple
"""
from src.web.db.models import PersonEncoding
# Validate person exists
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise ValueError(f"Person {person_id} not found")
# Get all faces to identify
faces = db.query(Face).filter(Face.id.in_(face_ids)).all()
if not faces:
return (0, 0)
identified_count = 0
# Process each face
for face in faces:
# Set person_id on face
face.person_id = person_id
db.add(face)
# Insert person_encoding (matching desktop)
pe = PersonEncoding(
person_id=person_id,
face_id=face.id,
encoding=face.encoding,
quality_score=face.quality_score,
detector_backend=face.detector_backend,
model_name=face.model_name,
)
db.add(pe)
identified_count += 1
# Commit changes
db.commit()
# Update person encodings (matching desktop update_person_encodings)
# Desktop: removes old encodings, adds current face encodings
# Delete old encodings
db.query(PersonEncoding).filter(PersonEncoding.person_id == person_id).delete()
# Add current face encodings (quality_score >= 0.3)
current_faces = (
db.query(Face)
.filter(Face.person_id == person_id)
.filter(Face.quality_score >= 0.3)
.all()
)
for face in current_faces:
pe = PersonEncoding(
person_id=person_id,
face_id=face.id,
encoding=face.encoding,
quality_score=face.quality_score,
detector_backend=face.detector_backend,
model_name=face.model_name,
)
db.add(pe)
db.commit()
return (identified_count, len(current_faces))
+75 -21
View File
@@ -15,36 +15,90 @@ from src.web.db.models import Photo
def extract_exif_date(image_path: str) -> Optional[date]:
"""Extract date taken from photo EXIF data - returns Date (not DateTime) to match desktop schema."""
"""Extract date taken from photo EXIF data - returns Date (not DateTime) to match desktop schema.
Tries multiple methods to extract EXIF date:
1. PIL's getexif() (modern method)
2. PIL's _getexif() (deprecated but sometimes more reliable)
3. Access EXIF IFD directly if available
"""
try:
with Image.open(image_path) as image:
exifdata = image.getexif()
exifdata = None
# Try modern getexif() first
try:
exifdata = image.getexif()
except Exception:
pass
# If getexif() didn't work or returned empty, try deprecated _getexif()
if not exifdata or len(exifdata) == 0:
try:
if hasattr(image, '_getexif'):
exifdata = image._getexif()
except Exception:
pass
if not exifdata:
return None
# Look for date taken in EXIF tags
# Priority: DateTimeOriginal (when photo was taken) > DateTimeDigitized > DateTime (file modification)
date_tags = [
306, # DateTime
36867, # DateTimeOriginal
36868, # DateTimeDigitized
36867, # DateTimeOriginal - when photo was actually taken (highest priority)
36868, # DateTimeDigitized - when photo was digitized
306, # DateTime - file modification date (lowest priority)
]
# Try direct access first
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:
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
return dt.date()
except ValueError:
# Try alternative format
try:
if tag_id in exifdata:
date_str = exifdata[tag_id]
if date_str:
# Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
return dt.date()
except ValueError:
continue
except Exception:
pass
# Try alternative format
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
return dt.date()
except ValueError:
continue
except (KeyError, TypeError):
continue
# Try accessing EXIF IFD directly if available (for tags in EXIF IFD like DateTimeOriginal)
try:
if hasattr(exifdata, 'get_ifd'):
# EXIF IFD is at offset 0x8769
exif_ifd = exifdata.get_ifd(0x8769)
if exif_ifd:
for tag_id in date_tags:
if tag_id in exif_ifd:
date_str = exif_ifd[tag_id]
if date_str:
try:
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
return dt.date()
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
return dt.date()
except ValueError:
continue
except Exception:
pass
except Exception as e:
# Log error for debugging (but don't fail the import)
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Failed to extract EXIF date from {image_path}: {e}")
return None