-
Auto-matching functionality coming in Phase 2.
+
+
🔗 Auto-Match Faces
+
+ {/* Configuration */}
+
+
+
+
+
+ setTolerance(parseFloat(e.target.value) || 0)}
+ disabled={isActive}
+ className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
+ />
+ (lower = stricter matching)
+
+
+
+ {isActive && (
+ <>
+ {/* Main panels */}
+
+ {/* Left panel - Identified Person */}
+
+
Identified Person
+
+ {/* Search controls */}
+
+
+ setSearchQuery(e.target.value)}
+ disabled={people.length === 1}
+ className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm"
+ />
+
+
+ {people.length === 1 && (
+
(Search disabled - only one person found)
+ )}
+
+
+ {/* Person info */}
+ {currentPerson && (
+ <>
+
+
👤 Person: {currentPerson.person_name}
+
+ 📁 Photo: {currentPerson.reference_photo_filename}
+
+
+ 📍 Face location: {currentPerson.reference_location}
+
+
+ 📊 {currentPerson.face_count} faces already identified
+
+
+
+ {/* Person face image */}
+
+

+
+
+ {/* Save button */}
+
+ >
+ )}
+
+
+ {/* Right panel - Unidentified Faces */}
+
+
Unidentified Faces to Match
+
+ {/* Select All / Clear All buttons */}
+
+
+
+
+
+ {/* Matches grid */}
+
+ {currentMatches.length === 0 ? (
+
No matches found
+ ) : (
+
+ {currentMatches.map((match) => (
+
+
handleFaceToggle(match.id)}
+ className="w-4 h-4"
+ />
+

+
+
+ = 70
+ ? 'bg-green-100 text-green-800'
+ : match.similarity >= 60
+ ? 'bg-yellow-100 text-yellow-800'
+ : 'bg-orange-100 text-orange-800'
+ }`}
+ >
+ {Math.round(match.similarity)}% Match
+
+
+
📁 {match.photo_filename}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Navigation controls */}
+
+
+
+
+
+
+ Person {currentIndex + 1} of {activePeople.length}
+ {currentPerson && ` • ${currentPerson.total_matches} matches`}
+
+
+
+ >
+ )}
+
+ {!isActive && (
+
+
Click "Start Auto-Match" to begin matching unidentified faces with identified people.
+
+ )}
)
}
-
diff --git a/frontend/src/pages/Modify.tsx b/frontend/src/pages/Modify.tsx
new file mode 100644
index 0000000..f702bfb
--- /dev/null
+++ b/frontend/src/pages/Modify.tsx
@@ -0,0 +1,548 @@
+import { useEffect, useState, useRef, useCallback } from 'react'
+import peopleApi, { PersonWithFaces, PersonFaceItem, PersonUpdateRequest } from '../api/people'
+import facesApi from '../api/faces'
+
+interface EditDialogProps {
+ person: PersonWithFaces
+ onSave: (personId: number, data: PersonUpdateRequest) => Promise
+ onClose: () => void
+}
+
+function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
+ const [firstName, setFirstName] = useState(person.first_name || '')
+ const [lastName, setLastName] = useState(person.last_name || '')
+ const [middleName, setMiddleName] = useState(person.middle_name || '')
+ const [maidenName, setMaidenName] = useState(person.maiden_name || '')
+ const [dob, setDob] = useState(person.date_of_birth || '')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ const canSave = firstName.trim() && lastName.trim()
+
+ const handleSave = async () => {
+ if (!canSave || busy) return
+
+ setBusy(true)
+ setError(null)
+
+ try {
+ await onSave(person.id, {
+ first_name: firstName.trim(),
+ last_name: lastName.trim(),
+ middle_name: middleName.trim() || undefined,
+ maiden_name: maidenName.trim() || undefined,
+ date_of_birth: dob.trim() || null,
+ })
+ onClose()
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || 'Failed to update person')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && canSave && !busy) {
+ handleSave()
+ } else if (e.key === 'Escape') {
+ onClose()
+ }
+ }
+
+ return (
+
+
+
Edit {person.first_name} {person.last_name}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function Modify() {
+ const [people, setPeople] = useState([])
+ const [lastNameFilter, setLastNameFilter] = useState('')
+ const [selectedPersonId, setSelectedPersonId] = useState(null)
+ const [selectedPersonName, setSelectedPersonName] = useState('')
+ const [faces, setFaces] = useState([])
+ const [unmatchedFaces, setUnmatchedFaces] = useState>(new Set())
+ const [unmatchedByPerson, setUnmatchedByPerson] = useState>>({})
+ const [editDialogPerson, setEditDialogPerson] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+
+ const gridRef = useRef(null)
+
+ // Load people with faces
+ const loadPeople = useCallback(async () => {
+ try {
+ setBusy(true)
+ setError(null)
+ const res = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ setPeople(res.items)
+
+ // Auto-select first person if available and none selected
+ if (res.items.length > 0 && !selectedPersonId) {
+ const firstPerson = res.items[0]
+ setSelectedPersonId(firstPerson.id)
+ setSelectedPersonName(formatPersonName(firstPerson))
+ loadPersonFaces(firstPerson.id)
+ }
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || 'Failed to load people')
+ } finally {
+ setBusy(false)
+ }
+ }, [lastNameFilter, selectedPersonId])
+
+ // Load faces for a person
+ const loadPersonFaces = useCallback(async (personId: number) => {
+ try {
+ setBusy(true)
+ setError(null)
+ const res = await peopleApi.getFaces(personId)
+ // Filter out unmatched faces (show only matched faces)
+ const visibleFaces = res.items.filter((f) => !unmatchedFaces.has(f.id))
+ setFaces(visibleFaces)
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || 'Failed to load faces')
+ } finally {
+ setBusy(false)
+ }
+ }, [unmatchedFaces])
+
+ useEffect(() => {
+ loadPeople()
+ }, [loadPeople])
+
+ // Reload faces when person changes
+ useEffect(() => {
+ if (selectedPersonId) {
+ loadPersonFaces(selectedPersonId)
+ }
+ }, [selectedPersonId, loadPersonFaces])
+
+ const formatPersonName = (person: PersonWithFaces): string => {
+ const parts: string[] = []
+ if (person.first_name) parts.push(person.first_name)
+ if (person.middle_name) parts.push(person.middle_name)
+ if (person.last_name) parts.push(person.last_name)
+ if (person.maiden_name) parts.push(`(${person.maiden_name})`)
+ const name = parts.join(' ') || 'Unknown'
+ if (person.date_of_birth) {
+ return `${name} - Born: ${person.date_of_birth}`
+ }
+ return name
+ }
+
+ const handleSearch = () => {
+ loadPeople()
+ }
+
+ const handleClearSearch = () => {
+ setLastNameFilter('')
+ // loadPeople will be called by useEffect
+ }
+
+ const handlePersonClick = (person: PersonWithFaces) => {
+ setSelectedPersonId(person.id)
+ setSelectedPersonName(formatPersonName(person))
+ loadPersonFaces(person.id)
+ }
+
+ const handleEditPerson = (person: PersonWithFaces) => {
+ setEditDialogPerson(person)
+ }
+
+ const handleSavePerson = async (personId: number, data: PersonUpdateRequest) => {
+ await peopleApi.update(personId, data)
+ // Reload people list
+ await loadPeople()
+ // Reload faces if this is the selected person
+ if (selectedPersonId === personId) {
+ await loadPersonFaces(personId)
+ }
+ setSuccess('Person information updated successfully')
+ setTimeout(() => setSuccess(null), 3000)
+ }
+
+ const handleUnmatchFace = async (faceId: number) => {
+ // Add to unmatched set (temporary, not persisted until save)
+ const newUnmatched = new Set(unmatchedFaces)
+ newUnmatched.add(faceId)
+ setUnmatchedFaces(newUnmatched)
+
+ // Track by person
+ if (selectedPersonId) {
+ const newByPerson = { ...unmatchedByPerson }
+ if (!newByPerson[selectedPersonId]) {
+ newByPerson[selectedPersonId] = new Set()
+ }
+ newByPerson[selectedPersonId].add(faceId)
+ setUnmatchedByPerson(newByPerson)
+ }
+
+ // Immediately refresh display to hide unmatched face
+ if (selectedPersonId) {
+ await loadPersonFaces(selectedPersonId)
+ }
+ }
+
+ const handleUndoChanges = () => {
+ if (!selectedPersonId) return
+
+ const personFaces = unmatchedByPerson[selectedPersonId]
+ if (!personFaces || personFaces.size === 0) return
+
+ // Remove faces for current person from unmatched sets
+ const newUnmatched = new Set(unmatchedFaces)
+ for (const faceId of personFaces) {
+ newUnmatched.delete(faceId)
+ }
+ setUnmatchedFaces(newUnmatched)
+
+ const newByPerson = { ...unmatchedByPerson }
+ delete newByPerson[selectedPersonId]
+ setUnmatchedByPerson(newByPerson)
+
+ // Reload faces to show restored faces
+ if (selectedPersonId) {
+ loadPersonFaces(selectedPersonId)
+ }
+
+ setSuccess(`Undid changes for ${personFaces.size} face(s)`)
+ setTimeout(() => setSuccess(null), 3000)
+ }
+
+ const handleSaveChanges = async () => {
+ if (unmatchedFaces.size === 0) return
+
+ try {
+ setBusy(true)
+ setError(null)
+
+ // Batch unmatch all faces
+ const faceIds = Array.from(unmatchedFaces)
+ await facesApi.batchUnmatch({ face_ids: faceIds })
+
+ // Clear unmatched sets
+ setUnmatchedFaces(new Set())
+ setUnmatchedByPerson({})
+
+ // Reload faces to reflect changes
+ if (selectedPersonId) {
+ await loadPersonFaces(selectedPersonId)
+ }
+
+ // Reload people list to update face counts
+ await loadPeople()
+
+ setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)
+ setTimeout(() => setSuccess(null), 3000)
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || 'Failed to save changes')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const handleExit = () => {
+ if (unmatchedFaces.size > 0) {
+ const confirmed = window.confirm(
+ `You have ${unmatchedFaces.size} unsaved changes.\n\n` +
+ 'Do you want to save them before exiting?\n\n' +
+ 'OK: Save changes and exit\n' +
+ 'Cancel: Return to modify'
+ )
+ if (confirmed) {
+ handleSaveChanges().then(() => {
+ // Navigate to home after save
+ window.location.href = '/'
+ })
+ }
+ } else {
+ window.location.href = '/'
+ }
+ }
+
+ const visibleFaces = faces.filter((f) => !unmatchedFaces.has(f.id))
+ const currentPersonHasUnmatched = selectedPersonId
+ ? Boolean(unmatchedByPerson[selectedPersonId]?.size)
+ : false
+
+ return (
+
+
✏️ Modify Identified
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {success && (
+
+ {success}
+
+ )}
+
+
+ {/* Left panel: People list */}
+
+
+
People
+
+ {/* Search controls */}
+
+
+ setLastNameFilter(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
+ placeholder="Type Last Name"
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
Type Last Name
+
+
+ {/* People list */}
+
+ {busy && people.length === 0 ? (
+
Loading...
+ ) : people.length === 0 ? (
+
No people found
+ ) : (
+
+ {people.map((person) => {
+ const isSelected = selectedPersonId === person.id
+ const name = formatPersonName(person)
+ return (
+
+
+
handlePersonClick(person)}
+ className="flex-1"
+ >
+ {name} ({person.face_count})
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ {/* Right panel: Faces grid */}
+
+
+
Faces
+
+ {selectedPersonId ? (
+
+ {busy && visibleFaces.length === 0 ? (
+
Loading faces...
+ ) : visibleFaces.length === 0 ? (
+
+ {faces.length === 0
+ ? 'No faces found for this person'
+ : 'All faces unmatched'}
+
+ ) : (
+
+ {visibleFaces.map((face) => (
+
+
+

{
+ e.currentTarget.src = '/placeholder.png'
+ }}
+ />
+
+
+
+
+ ))}
+
+ )}
+
+ ) : (
+
+ Select a person to view their faces
+
+ )}
+
+
+
+
+ {/* Control buttons */}
+
+
+
+
+
+
+ {/* Edit person dialog */}
+ {editDialogPerson && (
+
setEditDialogPerson(null)}
+ />
+ )}
+
+ )
+}
+
diff --git a/src/web/api/faces.py b/src/web/api/faces.py
index 251e204..a27f362 100644
--- a/src/web/api/faces.py
+++ b/src/web/api/faces.py
@@ -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,
+ )
+
+
diff --git a/src/web/api/people.py b/src/web/api/people.py
index 305a768..cf70fd1 100644
--- a/src/web/api/people.py
+++ b/src/web/api/people.py
@@ -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,
+ )
+
diff --git a/src/web/schemas/faces.py b/src/web/schemas/faces.py
index fa0d57d..ad87a00 100644
--- a/src/web/schemas/faces.py
+++ b/src/web/schemas/faces.py
@@ -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")
diff --git a/src/web/schemas/people.py b/src/web/schemas/people.py
index 454acf4..8de4bd9 100644
--- a/src/web/schemas/people.py
+++ b/src/web/schemas/people.py
@@ -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
+
+
diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py
index 102cc9e..ab2496e 100644
--- a/src/web/services/face_service.py
+++ b/src/web/services/face_service.py
@@ -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))
+
diff --git a/src/web/services/photo_service.py b/src/web/services/photo_service.py
index c737068..8e49668 100644
--- a/src/web/services/photo_service.py
+++ b/src/web/services/photo_service.py
@@ -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
diff --git a/tests/test_exif_extraction.py b/tests/test_exif_extraction.py
new file mode 100755
index 0000000..b953252
--- /dev/null
+++ b/tests/test_exif_extraction.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""
+Test script to debug EXIF date extraction from photos.
+Run this to see what EXIF data is available in your photos.
+"""
+
+import sys
+import os
+from pathlib import Path
+from PIL import Image
+from datetime import datetime
+
+# Add src to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from src.web.services.photo_service import extract_exif_date
+
+
+def test_exif_extraction(image_path: str):
+ """Test EXIF extraction from a single image."""
+ print(f"\n{'='*60}")
+ print(f"Testing: {image_path}")
+ print(f"{'='*60}")
+
+ if not os.path.exists(image_path):
+ print(f"❌ File not found: {image_path}")
+ return
+
+ # Try to open with PIL
+ try:
+ with Image.open(image_path) as img:
+ print(f"✅ Image opened successfully")
+ print(f" Format: {img.format}")
+ print(f" Size: {img.size}")
+
+ # Try getexif()
+ exifdata = None
+ try:
+ exifdata = img.getexif()
+ print(f"✅ getexif() worked: {len(exifdata) if exifdata else 0} tags")
+ except Exception as e:
+ print(f"❌ getexif() failed: {e}")
+
+ # Try _getexif() (deprecated)
+ old_exif = None
+ try:
+ if hasattr(img, '_getexif'):
+ old_exif = img._getexif()
+ print(f"✅ _getexif() worked: {len(old_exif) if old_exif else 0} tags")
+ else:
+ print(f"⚠️ _getexif() not available")
+ except Exception as e:
+ print(f"❌ _getexif() failed: {e}")
+
+ # Check for specific date tags
+ date_tags = {
+ 306: "DateTime",
+ 36867: "DateTimeOriginal",
+ 36868: "DateTimeDigitized",
+ }
+
+ print(f"\n📅 Checking date tags:")
+ found_any = False
+
+ if exifdata:
+ for tag_id, tag_name in date_tags.items():
+ try:
+ if tag_id in exifdata:
+ value = exifdata[tag_id]
+ print(f" ✅ {tag_name} ({tag_id}): {value}")
+ found_any = True
+ else:
+ print(f" ❌ {tag_name} ({tag_id}): Not found")
+ except Exception as e:
+ print(f" ⚠️ {tag_name} ({tag_id}): Error - {e}")
+
+ # Try EXIF IFD
+ if exifdata and hasattr(exifdata, 'get_ifd'):
+ try:
+ exif_ifd = exifdata.get_ifd(0x8769)
+ if exif_ifd:
+ print(f"\n📋 EXIF IFD found: {len(exif_ifd)} tags")
+ for tag_id, tag_name in date_tags.items():
+ if tag_id in exif_ifd:
+ value = exif_ifd[tag_id]
+ print(f" ✅ {tag_name} ({tag_id}) in IFD: {value}")
+ found_any = True
+ except Exception as e:
+ print(f" ⚠️ EXIF IFD access failed: {e}")
+
+ if not found_any:
+ print(f" ⚠️ No date tags found in EXIF data")
+
+ # Try our extraction function
+ print(f"\n🔍 Testing extract_exif_date():")
+ extracted_date = extract_exif_date(image_path)
+ if extracted_date:
+ print(f" ✅ Extracted date: {extracted_date}")
+ else:
+ print(f" ❌ No date extracted")
+
+ except Exception as e:
+ print(f"❌ Error opening image: {e}")
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python test_exif_extraction.py ")
+ print("\nExample:")
+ print(" python test_exif_extraction.py /path/to/photo.jpg")
+ sys.exit(1)
+
+ image_path = sys.argv[1]
+ test_exif_extraction(image_path)
+