diff --git a/docs/WEBSITE_MIGRATION_PLAN.md b/docs/WEBSITE_MIGRATION_PLAN.md index 3b44395..02ecada 100644 --- a/docs/WEBSITE_MIGRATION_PLAN.md +++ b/docs/WEBSITE_MIGRATION_PLAN.md @@ -68,14 +68,16 @@ The web version uses the **exact same schema** as the desktop version for full c - Face detection and embeddings (DeepFace ArcFace + RetinaFace) - **Identify workflow** (manual identification - one face at a time, assign to existing/new person) - **Auto-match workflow** (automated bulk matching - matches unidentified faces to identified people with tolerance thresholds) +- **Modify Identified workflow** (edit person information, unmatch faces from people, undo/save changes) - Tags: CRUD and bulk tagging - Search: by people, tags, date range, folder; infinite scroll grid - Thumbnail generation and caching (100×100, 256×256) - Real-time job progress (SSE/WebSocket) -**Note:** Identify and Auto-Match are **separate features** with distinct UIs and workflows: +**Note:** Identify, Auto-Match, and Modify Identified are **separate features** with distinct UIs and workflows: - **Identify**: Manual process, one unidentified face at a time, user assigns to person - **Auto-Match**: Automated process, shows identified person on left, matched unidentified faces on right, bulk accept/reject +- **Modify Identified**: Edit identified people, view faces per person, unmatch faces, edit person information (names, date of birth) --- @@ -85,8 +87,8 @@ Base URL: `/api/v1` - Auth: POST `/auth/login`, POST `/auth/refresh`, GET `/auth/me` - Photos: GET `/photos`, POST `/photos/import` (upload or folder), GET `/photos/{id}`, GET `/photos/{id}/image` -- Faces: POST `/faces/process`, GET `/faces/unidentified`, POST `/faces/{id}/identify`, POST `/faces/auto-match` -- People: GET `/people`, POST `/people`, GET `/people/{id}` +- Faces: POST `/faces/process`, GET `/faces/unidentified`, POST `/faces/{id}/identify`, POST `/faces/auto-match`, POST `/faces/{id}/unmatch` +- People: GET `/people`, POST `/people`, GET `/people/{id}`, PUT `/people/{id}`, GET `/people/{id}/faces` - Tags: GET `/tags`, POST `/tags`, POST `/photos/{id}/tags` - Jobs: GET `/jobs/{id}`, GET `/jobs/stream` (SSE) @@ -96,9 +98,10 @@ All responses are typed; errors use structured codes. Pagination with `page`/`pa ## 6) UX and Navigation -- Shell layout: left nav + top bar; routes: Dashboard, Scan, Process, Search, Identify, Auto-Match, Tags, Settings +- Shell layout: left nav + top bar; routes: Dashboard, Scan, Process, Search, Identify, Auto-Match, Modify, Tags, Settings - **Identify flow**: Manual identification - one face at a time, keyboard (J/K), Enter to accept, quick-create person, similar faces for comparison - **Auto-Match flow**: Automated bulk matching - identified person on left, matched faces on right, bulk accept/reject, tolerance threshold configuration +- **Modify Identified flow**: People list with search, face grid per person, edit person info, unmatch faces, undo/save changes - Search: virtualized grid, filters drawer, saved filters - Tags: inline edit, bulk apply from grid selection - Settings: detector/model selection, thresholds, storage paths @@ -155,7 +158,7 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (< - Frontend scaffold: - Vite + React + TypeScript + Tailwind; base layout (left nav + top bar) - Auth flow (login page, token storage), API client with interceptors - - Routes: Dashboard (placeholder), Scan (placeholder), Process (placeholder), Search (placeholder), Identify (placeholder), Auto-Match (placeholder), Tags (placeholder), Settings (placeholder) + - Routes: Dashboard (placeholder), Scan (placeholder), Process (placeholder), Search (placeholder), Identify (placeholder), Auto-Match (placeholder), Modify (placeholder), Tags (placeholder), Settings (placeholder) ### Phase 2: Image Ingestion & Face Processing (2–3 weeks) ✅ **COMPLETE** - Image ingestion: @@ -242,7 +245,48 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (< - Optimistic updates with server confirmation - Job-based processing: Auto-match runs as background job with progress tracking -### Phase 5: Search & Tags (1–2 weeks) +### Phase 5: Modify Identified Workflow (1–2 weeks) +**Edit identified people, view faces per person, unmatch faces, edit person information** + +- **Backend APIs:** + - `GET /api/v1/people` - Get all identified people with face counts (filtered by last name if provided) + - `GET /api/v1/people/{id}/faces` - Get all faces for a specific person with photo info + - `PUT /api/v1/people/{id}` - Update person information (first_name, last_name, middle_name, maiden_name, date_of_birth) + - `POST /api/v1/faces/{id}/unmatch` - Unmatch a face from its person (set person_id to NULL) + - `POST /api/v1/faces/batch-unmatch` - Batch unmatch multiple faces (accepts array of face IDs) + - Implement person update with validation (require first_name and last_name) + - Support filtering people by last name (case-insensitive search) + - Return face thumbnails and photo metadata for face grid display + +- **Frontend Modify Identified UI:** + - Two-panel layout: + - Left panel: People list with search/filter by last name + - Right panel: Face grid for selected person + - People list features: + - Search input for filtering by last name (case-insensitive) + - Display person name with face count: "John Doe (15)" + - Click person to load their faces in right panel + - Edit button (✏️) next to each person to open edit dialog + - Face grid features: + - Responsive grid layout (adapts to panel width) + - Face thumbnails with photo icon overlay + - "Unmatch" button for each face + - Visual feedback when faces are unmatched (hidden until save) + - Edit person dialog: + - Form fields: First name, Last name, Middle name, Maiden name, Date of birth + - Date picker for date of birth (calendar widget) + - Validation: Require first_name and last_name + - Save/Cancel buttons with keyboard shortcuts (Enter to save, Escape to cancel) + - Change management: + - Track unmatched faces (temporary state, not persisted until save) + - "Undo changes" button (undoes all unmatched faces for current person) + - "Save changes" button (persists all unmatched faces to database) + - "Exit Edit Identified" button (warns if unsaved changes exist) + - Optimistic updates with server confirmation + - Inline toasts for success/error feedback + - Keyboard navigation support + +### Phase 6: Search & Tags (1–2 weeks) - Search APIs: - `/photos` with filters: person_ids, tag_ids, date_from/to, min_quality, sort, page/page_size - Stable sorting (date_taken desc, id tie-breaker); efficient indices @@ -257,7 +301,7 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (< - People list with search; merge people flow (optional) - Display representative face and stats per person -### Phase 6: Polish & Release (1–2 weeks) +### Phase 7: Polish & Release (1–2 weeks) - Performance polish: - HTTP caching headers for images; prefetch thumbnails - DB indices review; query timing; N+1 checks; connection pool tuning @@ -283,9 +327,10 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (< ## 10) Success Criteria -- Users complete import → process → identify → auto-match → tag → search entirely on the web +- Users complete import → process → identify → auto-match → modify → tag → search entirely on the web - Identify workflow: Manual identification of faces one-at-a-time with similar faces comparison - Auto-match workflow: Automated bulk matching with tolerance thresholds and bulk accept/reject +- Modify Identified workflow: Edit person information, view faces per person, unmatch faces with undo/save functionality - Smooth UX with live job progress; no perceived UI blocking - Clean, documented OpenAPI; type-safe FE client; >80% test coverage (API + workers) - Production deployment artifacts ready (Docker, env, reverse proxy) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64b3aa7..b05b073 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Scan from './pages/Scan' import Process from './pages/Process' import Identify from './pages/Identify' import AutoMatch from './pages/AutoMatch' +import Modify from './pages/Modify' import Tags from './pages/Tags' import Settings from './pages/Settings' import Layout from './components/Layout' @@ -37,6 +38,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index a772b7d..01f5bb8 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -59,6 +59,57 @@ export interface IdentifyFaceResponse { created_person: boolean } +export interface FaceUnmatchResponse { + face_id: number + message: string +} + +export interface BatchUnmatchRequest { + face_ids: number[] +} + +export interface BatchUnmatchResponse { + unmatched_face_ids: number[] + count: number + message: string +} + +export interface AutoMatchRequest { + tolerance: number +} + +export interface AutoMatchFaceItem { + id: number + photo_id: number + photo_filename: string + location: string + quality_score: number + similarity: number // Confidence percentage (0-100) + distance: number +} + +export interface AutoMatchPersonItem { + person_id: number + person_name: string + reference_face_id: number + reference_photo_id: number + reference_photo_filename: string + reference_location: string + face_count: number + matches: AutoMatchFaceItem[] + total_matches: number +} + +export interface AutoMatchResponse { + people: AutoMatchPersonItem[] + total_people: number + total_matches: number +} + +export interface AcceptMatchesRequest { + face_ids: number[] +} + export const facesApi = { /** * Start face processing job @@ -89,6 +140,18 @@ export const facesApi = { const response = await apiClient.post(`/api/v1/faces/${faceId}/identify`, payload) return response.data }, + unmatch: async (faceId: number): Promise => { + const response = await apiClient.post(`/api/v1/faces/${faceId}/unmatch`) + return response.data + }, + batchUnmatch: async (payload: BatchUnmatchRequest): Promise => { + const response = await apiClient.post('/api/v1/faces/batch-unmatch', payload) + return response.data + }, + autoMatch: async (request: AutoMatchRequest): Promise => { + const response = await apiClient.post('/api/v1/faces/auto-match', request) + return response.data + }, } export default facesApi diff --git a/frontend/src/api/people.ts b/frontend/src/api/people.ts index 7e9bdf4..1b6c0e1 100644 --- a/frontend/src/api/people.ts +++ b/frontend/src/api/people.ts @@ -14,6 +14,15 @@ export interface PeopleListResponse { total: number } +export interface PersonWithFaces extends Person { + face_count: number +} + +export interface PeopleWithFacesListResponse { + items: PersonWithFaces[] + total: number +} + export interface PersonCreateRequest { first_name: string last_name: string @@ -22,15 +31,65 @@ export interface PersonCreateRequest { date_of_birth: string } +export interface PersonUpdateRequest { + first_name: string + last_name: string + middle_name?: string + maiden_name?: string + date_of_birth?: string | null +} + export const peopleApi = { - list: async (): Promise => { - const res = await apiClient.get('/api/v1/people') + list: async (lastName?: string): Promise => { + const params = lastName ? { last_name: lastName } : {} + const res = await apiClient.get('/api/v1/people', { params }) + return res.data + }, + listWithFaces: async (lastName?: string): Promise => { + const params = lastName ? { last_name: lastName } : {} + const res = await apiClient.get('/api/v1/people/with-faces', { params }) return res.data }, create: async (payload: PersonCreateRequest): Promise => { const res = await apiClient.post('/api/v1/people', payload) return res.data }, + update: async (personId: number, payload: PersonUpdateRequest): Promise => { + const res = await apiClient.put(`/api/v1/people/${personId}`, payload) + return res.data + }, + getFaces: async (personId: number): Promise => { + const res = await apiClient.get(`/api/v1/people/${personId}/faces`) + return res.data + }, + acceptMatches: async (personId: number, faceIds: number[]): Promise => { + const res = await apiClient.post(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds }) + return res.data + }, +} + +export interface IdentifyFaceResponse { + identified_face_ids: number[] + person_id: number + created_person: boolean +} + +export interface PersonFaceItem { + id: number + photo_id: number + photo_path: string + photo_filename: string + location: string + face_confidence: number + quality_score: number + detector_backend: string + model_name: string +} + +export interface PersonFacesResponse { + person_id: number + items: PersonFaceItem[] + total: number } export default peopleApi diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 215c6df..0891bb6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -12,6 +12,7 @@ export default function Layout() { { path: '/search', label: 'Search', icon: '🔍' }, { path: '/identify', label: 'Identify', icon: '👤' }, { path: '/auto-match', label: 'Auto-Match', icon: '🤖' }, + { path: '/modify', label: 'Modify', icon: '✏️' }, { path: '/tags', label: 'Tags', icon: '🏷️' }, { path: '/settings', label: 'Settings', icon: '⚙️' }, ] diff --git a/frontend/src/pages/AutoMatch.tsx b/frontend/src/pages/AutoMatch.tsx index d56df74..81e2acf 100644 --- a/frontend/src/pages/AutoMatch.tsx +++ b/frontend/src/pages/AutoMatch.tsx @@ -1,11 +1,386 @@ +import { useState, useEffect, useMemo } from 'react' +import facesApi, { AutoMatchResponse, AutoMatchPersonItem, AutoMatchFaceItem } from '../api/faces' +import peopleApi from '../api/people' +import { apiClient } from '../api/client' + +const DEFAULT_TOLERANCE = 0.6 + export default function AutoMatch() { + const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE) + const [isActive, setIsActive] = useState(false) + const [people, setPeople] = useState([]) + const [filteredPeople, setFilteredPeople] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [searchQuery, setSearchQuery] = useState('') + const [selectedFaces, setSelectedFaces] = useState>({}) + const [originalSelectedFaces, setOriginalSelectedFaces] = useState>({}) + const [busy, setBusy] = useState(false) + const [saving, setSaving] = useState(false) + + const currentPerson = useMemo(() => { + const activePeople = filteredPeople.length > 0 ? filteredPeople : people + return activePeople[currentIndex] + }, [filteredPeople, people, currentIndex]) + + const currentMatches = useMemo(() => { + return currentPerson?.matches || [] + }, [currentPerson]) + + // Apply search filter + useEffect(() => { + if (!searchQuery.trim()) { + setFilteredPeople([]) + return + } + + const query = searchQuery.trim().toLowerCase() + const filtered = people.filter(person => { + // Extract last name from person name (matching desktop logic) + let lastName = '' + if (person.person_name.includes(',')) { + lastName = person.person_name.split(',')[0].trim().toLowerCase() + } else { + const nameParts = person.person_name.trim().split(' ') + if (nameParts.length > 0) { + lastName = nameParts[nameParts.length - 1].toLowerCase() + } + } + return lastName.includes(query) + }) + + setFilteredPeople(filtered) + setCurrentIndex(0) + }, [searchQuery, people]) + + const startAutoMatch = async () => { + if (tolerance < 0 || tolerance > 1) { + alert('Please enter a valid tolerance value between 0.0 and 1.0.') + return + } + + setBusy(true) + try { + const response = await facesApi.autoMatch({ tolerance }) + + if (response.people.length === 0) { + alert('🔍 No similar faces found for auto-identification') + setBusy(false) + return + } + + setPeople(response.people) + setFilteredPeople([]) + setCurrentIndex(0) + setSelectedFaces({}) + setOriginalSelectedFaces({}) + setIsActive(true) + } catch (error) { + console.error('Auto-match failed:', error) + alert('Failed to start auto-match. Please try again.') + } finally { + setBusy(false) + } + } + + const handleFaceToggle = (faceId: number) => { + setSelectedFaces(prev => ({ + ...prev, + [faceId]: !prev[faceId], + })) + } + + const selectAll = () => { + const newSelected: Record = {} + currentMatches.forEach(match => { + newSelected[match.id] = true + }) + setSelectedFaces(newSelected) + } + + const clearAll = () => { + const newSelected: Record = {} + currentMatches.forEach(match => { + newSelected[match.id] = false + }) + setSelectedFaces(newSelected) + } + + const saveChanges = async () => { + if (!currentPerson) return + + setSaving(true) + try { + const faceIds = currentMatches + .filter(match => selectedFaces[match.id] === true) + .map(match => match.id) + + await peopleApi.acceptMatches(currentPerson.person_id, faceIds) + + // Update original selected faces to current state + const newOriginal: Record = {} + currentMatches.forEach(match => { + newOriginal[match.id] = selectedFaces[match.id] || false + }) + setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal })) + + alert(`✅ Saved ${faceIds.length} change(s)`) + } catch (error) { + console.error('Save failed:', error) + alert('Failed to save changes. Please try again.') + } finally { + setSaving(false) + } + } + + // Restore selected faces when navigating to a different person + useEffect(() => { + if (currentPerson) { + const restored: Record = {} + currentPerson.matches.forEach(match => { + restored[match.id] = originalSelectedFaces[match.id] || false + }) + setSelectedFaces(restored) + } + }, [currentIndex, filteredPeople.length, people.length]) // Only when person changes + + const goBack = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1) + } + } + + const goNext = () => { + const activePeople = filteredPeople.length > 0 ? filteredPeople : people + if (currentIndex < activePeople.length - 1) { + setCurrentIndex(currentIndex + 1) + } + } + + const clearSearch = () => { + setSearchQuery('') + setFilteredPeople([]) + setCurrentIndex(0) + } + + const activePeople = filteredPeople.length > 0 ? filteredPeople : people + const canGoBack = currentIndex > 0 + const canGoNext = currentIndex < activePeople.length - 1 + return ( -
-

Auto-Match

-
-

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 */} +
+ Reference face +
+ + {/* 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" + /> + Match face +
+
+ = 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} +
+ )} + +
+
+ + setFirstName(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> +
+ +
+ + setLastName(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setMiddleName(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setMaidenName(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setDob(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ + +
+
+
+ ) +} + +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) => ( +
+
+ {`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) +