feat: Implement Modify Identified workflow for person management
This commit introduces the Modify Identified workflow, allowing users to edit person information, view associated faces, and unmatch faces from identified people. The API has been updated with new endpoints for unmatching faces and retrieving faces for specific persons. The frontend includes a new Modify page with a user-friendly interface for managing identified persons, including search and edit functionalities. Documentation and tests have been updated to reflect these changes, ensuring reliability and usability.
This commit is contained in:
parent
bb42478c8f
commit
91ee2ce8ab
@ -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)
|
||||
|
||||
@ -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() {
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="identify" element={<Identify />} />
|
||||
<Route path="auto-match" element={<AutoMatch />} />
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
|
||||
@ -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<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
|
||||
return response.data
|
||||
},
|
||||
unmatch: async (faceId: number): Promise<FaceUnmatchResponse> => {
|
||||
const response = await apiClient.post<FaceUnmatchResponse>(`/api/v1/faces/${faceId}/unmatch`)
|
||||
return response.data
|
||||
},
|
||||
batchUnmatch: async (payload: BatchUnmatchRequest): Promise<BatchUnmatchResponse> => {
|
||||
const response = await apiClient.post<BatchUnmatchResponse>('/api/v1/faces/batch-unmatch', payload)
|
||||
return response.data
|
||||
},
|
||||
autoMatch: async (request: AutoMatchRequest): Promise<AutoMatchResponse> => {
|
||||
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default facesApi
|
||||
|
||||
@ -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<PeopleListResponse> => {
|
||||
const res = await apiClient.get<PeopleListResponse>('/api/v1/people')
|
||||
list: async (lastName?: string): Promise<PeopleListResponse> => {
|
||||
const params = lastName ? { last_name: lastName } : {}
|
||||
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
|
||||
return res.data
|
||||
},
|
||||
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
|
||||
const params = lastName ? { last_name: lastName } : {}
|
||||
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
|
||||
return res.data
|
||||
},
|
||||
create: async (payload: PersonCreateRequest): Promise<Person> => {
|
||||
const res = await apiClient.post<Person>('/api/v1/people', payload)
|
||||
return res.data
|
||||
},
|
||||
update: async (personId: number, payload: PersonUpdateRequest): Promise<Person> => {
|
||||
const res = await apiClient.put<Person>(`/api/v1/people/${personId}`, payload)
|
||||
return res.data
|
||||
},
|
||||
getFaces: async (personId: number): Promise<PersonFacesResponse> => {
|
||||
const res = await apiClient.get<PersonFacesResponse>(`/api/v1/people/${personId}/faces`)
|
||||
return res.data
|
||||
},
|
||||
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
|
||||
const res = await apiClient.post<IdentifyFaceResponse>(`/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
|
||||
|
||||
@ -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: '⚙️' },
|
||||
]
|
||||
|
||||
@ -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<AutoMatchPersonItem[]>([])
|
||||
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
|
||||
const [originalSelectedFaces, setOriginalSelectedFaces] = useState<Record<number, boolean>>({})
|
||||
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<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
newSelected[match.id] = true
|
||||
})
|
||||
setSelectedFaces(newSelected)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const newSelected: Record<number, boolean> = {}
|
||||
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<number, boolean> = {}
|
||||
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<number, boolean> = {}
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Auto-Match</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Auto-matching functionality coming in Phase 2.</p>
|
||||
<div className="flex flex-col h-full">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">🔗 Auto-Match Faces</h1>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={startAutoMatch}
|
||||
disabled={busy || isActive}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? 'Processing...' : '🚀 Start Auto-Match'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={tolerance}
|
||||
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
|
||||
disabled={isActive}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<>
|
||||
{/* Main panels */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 mb-4">
|
||||
{/* Left panel - Identified Person */}
|
||||
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Identified Person</h2>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type Last Name"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
disabled={people.length === 1}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
disabled={people.length === 1}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{people.length === 1 && (
|
||||
<p className="text-xs text-gray-500">(Search disabled - only one person found)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Person info */}
|
||||
{currentPerson && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="font-semibold">👤 Person: {currentPerson.person_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📁 Photo: {currentPerson.reference_photo_filename}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📍 Face location: {currentPerson.reference_location}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📊 {currentPerson.face_count} faces already identified
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Person face image */}
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img
|
||||
src={`/api/v1/faces/${currentPerson.reference_face_id}/crop`}
|
||||
alt="Reference face"
|
||||
className="max-w-[300px] max-h-[300px] rounded border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? '💾 Saving...' : `💾 Save changes for ${currentPerson.person_name}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel - Unidentified Faces */}
|
||||
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Unidentified Faces to Match</h2>
|
||||
|
||||
{/* Select All / Clear All buttons */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={currentMatches.length === 0}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
☑️ Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={currentMatches.length === 0}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
☐ Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Matches grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{currentMatches.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">No matches found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{currentMatches.map((match) => (
|
||||
<div
|
||||
key={match.id}
|
||||
className="flex items-center gap-3 p-2 border border-gray-200 rounded hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces[match.id] || false}
|
||||
onChange={() => handleFaceToggle(match.id)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<img
|
||||
src={`/api/v1/faces/${match.id}/crop`}
|
||||
alt="Match face"
|
||||
className="w-20 h-20 object-cover rounded border border-gray-300"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||
match.similarity >= 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
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">📁 {match.photo_filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation controls */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow p-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
⏮️ Back
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
⏭️ Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
{currentPerson && ` • ${currentPerson.total_matches} matches`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to exit auto-match?')) {
|
||||
setIsActive(false)
|
||||
setPeople([])
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
setSelectedFaces({})
|
||||
setOriginalSelectedFaces({})
|
||||
setSearchQuery('')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
❌ Exit Auto-Match
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center text-gray-500">
|
||||
<p>Click "Start Auto-Match" to begin matching unidentified faces with identified people.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
548
frontend/src/pages/Modify.tsx
Normal file
548
frontend/src/pages/Modify.tsx
Normal file
@ -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<void>
|
||||
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<string | null>(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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Edit {person.first_name} {person.last_name}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Middle name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={middleName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Maiden name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={maidenName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Date of birth</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dob}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || busy}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Modify() {
|
||||
const [people, setPeople] = useState<PersonWithFaces[]>([])
|
||||
const [lastNameFilter, setLastNameFilter] = useState('')
|
||||
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null)
|
||||
const [selectedPersonName, setSelectedPersonName] = useState('')
|
||||
const [faces, setFaces] = useState<PersonFaceItem[]>([])
|
||||
const [unmatchedFaces, setUnmatchedFaces] = useState<Set<number>>(new Set())
|
||||
const [unmatchedByPerson, setUnmatchedByPerson] = useState<Record<number, Set<number>>>({})
|
||||
const [editDialogPerson, setEditDialogPerson] = useState<PersonWithFaces | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">✏️ Modify Identified</h1>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded text-green-700">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Left panel: People list */}
|
||||
<div className="col-span-1">
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">People</h2>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
value={lastNameFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Type Last Name</p>
|
||||
</div>
|
||||
|
||||
{/* People list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{busy && people.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading...</div>
|
||||
) : people.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">No people found</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{people.map((person) => {
|
||||
const isSelected = selectedPersonId === person.id
|
||||
const name = formatPersonName(person)
|
||||
return (
|
||||
<div
|
||||
key={person.id}
|
||||
className={`flex items-center gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer ${
|
||||
isSelected ? 'bg-blue-50 font-semibold' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditPerson(person)
|
||||
}}
|
||||
className="text-sm px-2 py-1 hover:bg-gray-200 rounded"
|
||||
title="Update name"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<div
|
||||
onClick={() => handlePersonClick(person)}
|
||||
className="flex-1"
|
||||
>
|
||||
{name} ({person.face_count})
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Faces grid */}
|
||||
<div className="col-span-2">
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Faces</h2>
|
||||
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{busy && visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading faces...</div>
|
||||
) : visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{faces.length === 0
|
||||
? 'No faces found for this person'
|
||||
: 'All faces unmatched'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
{visibleFaces.map((face) => (
|
||||
<div key={face.id} className="flex flex-col items-center">
|
||||
<div className="relative w-20 h-20 mb-2">
|
||||
<img
|
||||
src={`/api/v1/faces/${face.id}/crop`}
|
||||
alt={`Face ${face.id}`}
|
||||
className="w-full h-full object-cover rounded"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder.png'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Open photo in new window (similar to desktop photo icon)
|
||||
window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
|
||||
}}
|
||||
className="absolute top-0 right-0 bg-white bg-opacity-80 hover:bg-opacity-100 rounded p-1 text-xs"
|
||||
title="Show original photo"
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnmatchFace(face.id)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Unmatch
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their faces
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleUndoChanges}
|
||||
disabled={!currentPersonHasUnmatched || busy}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
↶ Undo changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={unmatchedFaces.size === 0 || busy}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
💾 Save changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
❌ Exit Edit Identified
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit person dialog */}
|
||||
{editDialogPerson && (
|
||||
<EditPersonDialog
|
||||
person={editDialogPerson}
|
||||
onSave={handleSavePerson}
|
||||
onClose={() => setEditDialogPerson(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
115
tests/test_exif_extraction.py
Executable file
115
tests/test_exif_extraction.py
Executable file
@ -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 <image_path>")
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user