feat: Update documentation and API for face identification and people management

This commit enhances the README with detailed instructions on the automatic database initialization and schema compatibility between the web and desktop versions. It also introduces new API endpoints for managing unidentified faces and people, including listing, creating, and identifying faces. The schemas for these operations have been updated to reflect the new data structures. Additionally, tests have been added to ensure the functionality of the new API features, improving overall coverage and reliability.
This commit is contained in:
tanyar09
2025-11-03 12:49:48 -05:00
parent 5174fe0d54
commit 817e95337f
13 changed files with 1281 additions and 91 deletions
+66
View File
@@ -15,6 +15,50 @@ export interface ProcessFacesResponse {
model_name: string
}
export interface FaceItem {
id: number
photo_id: number
quality_score: number
face_confidence: number
location: string
}
export interface UnidentifiedFacesResponse {
items: FaceItem[]
page: number
page_size: number
total: number
}
export interface SimilarFaceItem {
id: number
photo_id: number
similarity: number
location: string
quality_score: number
}
export interface SimilarFacesResponse {
base_face_id: number
items: SimilarFaceItem[]
}
export interface IdentifyFaceRequest {
person_id?: number
first_name?: string
last_name?: string
middle_name?: string
maiden_name?: string
date_of_birth?: string
additional_face_ids?: number[]
}
export interface IdentifyFaceResponse {
identified_face_ids: number[]
person_id: number
created_person: boolean
}
export const facesApi = {
/**
* Start face processing job
@@ -23,6 +67,28 @@ export const facesApi = {
const response = await apiClient.post<ProcessFacesResponse>('/api/v1/faces/process', request)
return response.data
},
getUnidentified: async (params: {
page?: number
page_size?: number
min_quality?: number
date_from?: string
date_to?: string
sort_by?: 'quality' | 'date_taken' | 'date_added'
sort_dir?: 'asc' | 'desc'
}): Promise<UnidentifiedFacesResponse> => {
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
params,
})
return response.data
},
getSimilar: async (faceId: number): Promise<SimilarFacesResponse> => {
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`)
return response.data
},
identify: async (faceId: number, payload: IdentifyFaceRequest): Promise<IdentifyFaceResponse> => {
const response = await apiClient.post<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
return response.data
},
}
export default facesApi
+38
View File
@@ -0,0 +1,38 @@
import apiClient from './client'
export interface Person {
id: number
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
}
export interface PeopleListResponse {
items: Person[]
total: number
}
export interface PersonCreateRequest {
first_name: string
last_name: string
middle_name?: string
maiden_name?: string
date_of_birth: string
}
export const peopleApi = {
list: async (): Promise<PeopleListResponse> => {
const res = await apiClient.get<PeopleListResponse>('/api/v1/people')
return res.data
},
create: async (payload: PersonCreateRequest): Promise<Person> => {
const res = await apiClient.post<Person>('/api/v1/people', payload)
return res.data
},
}
export default peopleApi
+469 -2
View File
@@ -1,11 +1,478 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces'
import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
type SortBy = 'quality' | 'date_taken' | 'date_added'
type SortDir = 'asc' | 'desc'
export default function Identify() {
const [faces, setFaces] = useState<FaceItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(50)
const [minQuality, setMinQuality] = useState(0.0)
const [sortBy, setSortBy] = useState<SortBy>('quality')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [currentIdx, setCurrentIdx] = useState(0)
const currentFace = faces[currentIdx]
const [similar, setSimilar] = useState<SimilarFaceItem[]>([])
const [compareEnabled, setCompareEnabled] = useState(true)
const [selectedSimilar, setSelectedSimilar] = useState<Record<number, boolean>>({})
const [people, setPeople] = useState<Person[]>([])
const [personId, setPersonId] = useState<number | undefined>(undefined)
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [middleName, setMiddleName] = useState('')
const [maidenName, setMaidenName] = useState('')
const [dob, setDob] = useState('')
const [busy, setBusy] = useState(false)
// Store form data per face ID (matching desktop behavior)
const [faceFormData, setFaceFormData] = useState<Record<number, {
personId?: number
firstName: string
lastName: string
middleName: string
maidenName: string
dob: string
}>>({})
// Track previous face ID to save data on navigation
const prevFaceIdRef = useRef<number | undefined>(undefined)
const canIdentify = useMemo(() => {
return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace))
}, [personId, firstName, lastName, dob, currentFace])
const loadFaces = async () => {
const res = await facesApi.getUnidentified({
page,
page_size: pageSize,
min_quality: minQuality,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
sort_by: sortBy,
sort_dir: sortDir,
})
setFaces(res.items)
setTotal(res.total)
setCurrentIdx(0)
}
const loadPeople = async () => {
const res = await peopleApi.list()
setPeople(res.items)
}
const loadSimilar = async (faceId: number) => {
if (!compareEnabled) {
setSimilar([])
return
}
const res = await facesApi.getSimilar(faceId)
setSimilar(res.items)
setSelectedSimilar({})
}
useEffect(() => {
loadFaces()
loadPeople()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo])
useEffect(() => {
if (currentFace) loadSimilar(currentFace.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentFace?.id, compareEnabled])
// Save form data whenever fields change (for current face)
useEffect(() => {
if (!currentFace) return
setFaceFormData((prev) => ({
...prev,
[currentFace.id]: {
personId,
firstName,
lastName,
middleName,
maidenName,
dob,
},
}))
}, [currentFace?.id, personId, firstName, lastName, middleName, maidenName, dob])
// Restore form data when face changes (matching desktop behavior)
useEffect(() => {
if (!currentFace) {
// Clear form when no face
setPersonId(undefined)
setFirstName('')
setLastName('')
setMiddleName('')
setMaidenName('')
setDob('')
prevFaceIdRef.current = undefined
return
}
// Don't restore if we're just setting the initial face
if (prevFaceIdRef.current === currentFace.id) {
return
}
// Restore saved form data for this face
const saved = faceFormData[currentFace.id]
if (saved) {
setPersonId(saved.personId)
setFirstName(saved.firstName)
setLastName(saved.lastName)
setMiddleName(saved.middleName)
setMaidenName(saved.maidenName)
setDob(saved.dob)
} else {
// No saved data - clear form
setPersonId(undefined)
setFirstName('')
setLastName('')
setMiddleName('')
setMaidenName('')
setDob('')
}
prevFaceIdRef.current = currentFace.id
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentFace?.id]) // Only restore when face ID changes
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'j') {
setCurrentIdx((i) => Math.min(i + 1, Math.max(0, faces.length - 1)))
} else if (e.key.toLowerCase() === 'k') {
setCurrentIdx((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter' && canIdentify) {
handleIdentify()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [faces.length, canIdentify])
const handleIdentify = async () => {
if (!currentFace) return
setBusy(true)
const additional = Object.entries(selectedSimilar)
.filter(([, v]) => v)
.map(([k]) => Number(k))
try {
const payload: any = { additional_face_ids: additional }
if (personId) {
payload.person_id = personId
} else {
payload.first_name = firstName
payload.last_name = lastName
payload.middle_name = middleName || undefined
payload.maiden_name = maidenName || undefined
payload.date_of_birth = dob
}
await facesApi.identify(currentFace.id, payload)
// Optimistic: remove identified faces from list
const identifiedSet = new Set([currentFace.id, ...additional])
const remaining = faces.filter((f) => !identifiedSet.has(f.id))
setFaces(remaining)
setCurrentIdx((i) => Math.min(i, Math.max(0, remaining.length - 1)))
setSimilar([])
setSelectedSimilar({})
// Remove form data for identified faces (they're gone from the list)
setFaceFormData((prev) => {
const updated = { ...prev }
identifiedSet.forEach((faceId) => delete updated[faceId])
return updated
})
// Refresh people list if we created a new person
if (!personId) {
loadPeople()
}
// Don't clear form - let the useEffect handle restoring/clearing when face changes
} finally {
setBusy(false)
}
}
const currentInfo = useMemo(() => {
if (!currentFace) return ''
return `Face ${currentIdx + 1} of ${faces.length}`
}, [currentFace, currentIdx, faces.length])
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Identify</h1>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Face identification workflow coming in Phase 2.</p>
<div className="grid grid-cols-12 gap-4">
{/* Left: Controls and current face */}
<div className="col-span-4">
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700">Min Quality</label>
<input type="range" min={0} max={1} step={0.05} value={minQuality}
onChange={(e) => setMinQuality(parseFloat(e.target.value))} className="w-full" />
<div className="text-xs text-gray-500">{(minQuality * 100).toFixed(0)}%</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Batch Size</label>
<select value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value))}
className="mt-1 block w-full border rounded px-2 py-1">
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date From</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date To</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Sort By</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortBy)}
className="mt-1 block w-full border rounded px-2 py-1">
<option value="quality">Quality</option>
<option value="date_taken">Date Taken</option>
<option value="date_added">Date Processed</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Sort Dir</label>
<select value={sortDir} onChange={(e) => setSortDir(e.target.value as SortDir)}
className="mt-1 block w-full border rounded px-2 py-1">
<option value="desc">Desc</option>
<option value="asc">Asc</option>
</select>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-600">{currentInfo}</div>
<div className="space-x-2">
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}>Prev (K)</button>
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next (J)</button>
</div>
</div>
{!currentFace ? (
<div className="text-gray-500">No faces to identify.</div>
) : (
<div>
<div
className="aspect-video bg-gray-100 rounded mb-3 overflow-hidden flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentFace.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
key={currentFace.id}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentFace.id}/crop?t=${Date.now()}`}
alt={`Face ${currentFace.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 error-fallback'
fallback.textContent = `Photo #${currentFace.photo_id}`
parent.appendChild(fallback)
}
}}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Select Existing Person (optional)</label>
<select value={personId ?? ''} onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value) : undefined
setPersonId(val)
// Populate fields with selected person's data
if (val) {
const selectedPerson = people.find(p => p.id === val)
if (selectedPerson) {
setFirstName(selectedPerson.first_name || '')
setLastName(selectedPerson.last_name || '')
setMiddleName(selectedPerson.middle_name || '')
setMaidenName(selectedPerson.maiden_name || '')
setDob(selectedPerson.date_of_birth || '')
}
} else {
// Clear fields when selection is cleared
setFirstName('')
setLastName('')
setMiddleName('')
setMaidenName('')
setDob('')
}
}}
className="mt-1 block w-full border rounded px-2 py-1">
<option value=""> Or create new person below </option>
{people.map((p) => (
<option key={p.id} value={p.id}>{p.last_name}, {p.first_name}</option>
))}
</select>
</div>
<div className="col-span-2 border-t pt-2 mt-1">
<label className="block text-sm font-medium text-gray-700 mb-2">Create New Person</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">First Name *</label>
<input value={firstName} onChange={(e) => {
setFirstName(e.target.value)
setPersonId(undefined) // Clear person selection when typing
}}
className="mt-1 block w-full border rounded px-2 py-1"
readOnly={!!personId}
disabled={!!personId} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
<input value={lastName} onChange={(e) => {
setLastName(e.target.value)
setPersonId(undefined) // Clear person selection when typing
}}
className="mt-1 block w-full border rounded px-2 py-1"
readOnly={!!personId}
disabled={!!personId} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Middle Name</label>
<input value={middleName} onChange={(e) => {
setMiddleName(e.target.value)
setPersonId(undefined) // Clear person selection when typing
}}
className="mt-1 block w-full border rounded px-2 py-1"
readOnly={!!personId}
disabled={!!personId} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Maiden Name</label>
<input value={maidenName} onChange={(e) => {
setMaidenName(e.target.value)
setPersonId(undefined) // Clear person selection when typing
}}
className="mt-1 block w-full border rounded px-2 py-1"
readOnly={!!personId}
disabled={!!personId} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Date of Birth *</label>
<input type="date" value={dob} onChange={(e) => {
setDob(e.target.value)
setPersonId(undefined) // Clear person selection when typing
}}
className="mt-1 block w-full border rounded px-2 py-1"
readOnly={!!personId}
disabled={!!personId} />
</div>
<div className="col-span-2 flex gap-2 mt-2">
<button disabled={!canIdentify || busy}
onClick={handleIdentify}
className={`px-3 py-2 rounded text-white ${canIdentify && !busy ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-400 cursor-not-allowed'}`}>
{busy ? 'Identifying...' : 'Identify (Enter)'}
</button>
<button onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} className="px-3 py-2 rounded border">Back (K)</button>
<button onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))} className="px-3 py-2 rounded border">Next (J)</button>
</div>
</div>
</div>
)}
</div>
</div>
{/* Right: Similar faces */}
<div className="col-span-8">
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<input id="compare" type="checkbox" checked={compareEnabled}
onChange={(e) => setCompareEnabled(e.target.checked)} />
<label htmlFor="compare" className="text-sm text-gray-700">Compare with similar faces</label>
</div>
<div className="space-x-2">
<button className="px-2 py-1 text-sm border rounded"
onClick={() => setSelectedSimilar(Object.fromEntries(similar.map(s => [s.id, true])))}
disabled={!compareEnabled || similar.length === 0}>Select All</button>
<button className="px-2 py-1 text-sm border rounded"
onClick={() => setSelectedSimilar({})}
disabled={!compareEnabled || similar.length === 0}>Clear All</button>
</div>
</div>
{!compareEnabled ? (
<div className="text-gray-500">Comparison disabled.</div>
) : similar.length === 0 ? (
<div className="text-gray-500">No similar faces.</div>
) : (
<div className="grid grid-cols-6 gap-3">
{similar.map((s) => (
<label key={s.id} className="border rounded p-2 flex flex-col gap-2 cursor-pointer">
<div
className="aspect-square bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group"
onClick={(e) => {
e.stopPropagation() // Prevent triggering checkbox
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${s.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop?t=${Date.now()}`}
alt={`Face ${s.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${s.photo_id}`
parent.appendChild(fallback)
}
}}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
</div>
<div className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={!!selectedSimilar[s.id]}
onChange={(e) => setSelectedSimilar((prev) => ({ ...prev, [s.id]: e.target.checked }))} />
<div className="text-gray-600">{Math.round(s.similarity * 100)}%</div>
</div>
</label>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}