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

View File

@ -52,15 +52,37 @@ cd ..
### Database Setup
**Automatic Initialization:**
The database and all tables are automatically created on first startup. No manual migration is needed!
The web application will:
- Create the database file at `data/punimtag.db` (SQLite default) if it doesn't exist
- Create all required tables with the correct schema on startup
- Match the desktop version schema exactly for compatibility
**Manual Setup (Optional):**
If you need to reset the database or create it manually:
```bash
# Generate and run initial migration
source venv/bin/activate
export PYTHONPATH=/home/ladmin/Code/punimtag
alembic revision --autogenerate -m "Initial schema"
alembic upgrade head
# Recreate all tables from models
python scripts/recreate_tables_web.py
```
This creates the SQLite database at `data/punimtag.db` (default). For PostgreSQL, set the `DATABASE_URL` environment variable.
**PostgreSQL (Production):**
Set the `DATABASE_URL` environment variable:
```bash
export DATABASE_URL=postgresql+psycopg2://user:password@host:port/database
```
**Database Schema:**
The web version uses the **exact same schema** as the desktop version for full compatibility:
- `photos` - Photo metadata (path, filename, date_taken, processed)
- `people` - Person records (first_name, last_name, middle_name, maiden_name, date_of_birth)
- `faces` - Face detections (encoding, location, quality_score, face_confidence, exif_orientation)
- `person_encodings` - Person face encodings for matching
- `tags` - Tag definitions
- `phototaglinkage` - Photo-tag relationships (with linkage_type)
### Running the Application
@ -121,6 +143,7 @@ Then open your browser to **http://localhost:3000**
- Password: `admin`
**Note:**
- The database and tables are **automatically created on first startup** - no manual setup needed!
- The RQ worker starts automatically in a background subprocess when the API server starts
- Make sure Redis is running first, or the worker won't start
- Worker names are unique to avoid conflicts when restarting
@ -202,9 +225,10 @@ punimtag/
- ✅ All page routes (Dashboard, Scan, Process, Search, Identify, Auto-Match, Tags, Settings)
**Database:**
- ✅ All tables created: `photos`, `faces`, `people`, `person_embeddings`, `tags`, `photo_tags`
- ✅ All tables created automatically on startup: `photos`, `faces`, `people`, `person_encodings`, `tags`, `phototaglinkage`
- ✅ Schema matches desktop version exactly for full compatibility
- ✅ Indices configured for performance
- ✅ SQLite database at `data/punimtag.db`
- ✅ SQLite database at `data/punimtag.db` (auto-created if missing)
### Phase 2: Image Ingestion & Processing ✅ **COMPLETE**

View File

@ -28,25 +28,25 @@
- ✅ `auth.py` - Auth schemas
- ✅ `jobs.py` - Job schemas
- ✅ `src/web/db/` - Database layer
- ✅ `models.py` - All SQLAlchemy models (photos, faces, people, person_embeddings, tags, photo_tags)
- ✅ `models.py` - All SQLAlchemy models matching desktop schema (photos, faces, people, person_encodings, tags, phototaglinkage)
- ✅ `session.py` - Session management with connection pooling
- ✅ `base.py` - Base exports
- ✅ `src/web/services/` - Service layer (ready for Phase 2)
### Database Setup
- ✅ SQLAlchemy models for all tables:
- ✅ `photos` (id, path, filename, checksum, date_added, date_taken, width, height, mime_type)
- ✅ `faces` (id, photo_id, person_id, bbox, embedding, confidence, quality, model, detector)
- ✅ `people` (id, display_name, given_name, family_name, notes, created_at)
- ✅ `person_embeddings` (id, person_id, face_id, embedding, quality, model, created_at)
- ✅ `tags` (id, tag, created_at)
- ✅ `photo_tags` (photo_id, tag_id, created_at)
- ✅ SQLAlchemy models for all tables (matches desktop schema exactly):
- ✅ `photos` (id, path, filename, date_added, date_taken DATE, processed)
- ✅ `faces` (id, photo_id, person_id, encoding BLOB, location TEXT, confidence REAL, quality_score REAL, is_primary_encoding, detector_backend, model_name, face_confidence REAL, exif_orientation)
- ✅ `people` (id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date)
- ✅ `person_encodings` (id, person_id, face_id, encoding BLOB, quality_score REAL, detector_backend, model_name, created_date)
- ✅ `tags` (id, tag_name, created_date)
- ✅ `phototaglinkage` (linkage_id, photo_id, tag_id, linkage_type, created_date)
- ✅ Auto-create tables on startup (via `Base.metadata.create_all()` in lifespan)
- ✅ Alembic configuration:
- ✅ `alembic.ini` - Configuration file
- ✅ `alembic/env.py` - Environment setup
- ✅ `alembic/script.py.mako` - Migration template
- ⚠️ **MISSING:** Initial migration not generated yet (need to run `alembic revision --autogenerate -m "Initial schema"`)
- ✅ Database URL from environment (defaults to localhost Postgres)
- ✅ Database URL from environment (defaults to SQLite: `data/punimtag.db`)
- ✅ Connection pooling enabled
### Authentication

View File

@ -41,17 +41,23 @@ Last Updated: October 31, 2025
---
## 3) Domain Model (New Schema)
## 3) Domain Model (Matches Desktop Schema)
- `photos` (id, path, filename, checksum, date_added, date_taken, width, height, mime)
- `faces` (id, photo_id, person_id, bbox, embedding, confidence, quality, model, detector)
- `people` (id, display_name, given, family, notes, created_at)
- `person_embeddings` (id, person_id, face_id, embedding, quality, model, created_at)
- `tags` (id, tag, created_at)
- `photo_tags` (photo_id, tag_id, created_at)
- Indices for common queries (date_taken, quality, tag, person, text search)
The web version uses the **exact same schema** as the desktop version for full compatibility:
Note: Embeddings stored as binary (e.g., float32 arrays) with vector index support considered later.
- `photos` (id, path, filename, date_added, date_taken DATE, processed)
- `people` (id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date)
- `faces` (id, photo_id, person_id, encoding BLOB, location TEXT, confidence REAL, quality_score REAL, is_primary_encoding, detector_backend, model_name, face_confidence REAL, exif_orientation)
- `person_encodings` (id, person_id, face_id, encoding BLOB, quality_score REAL, detector_backend, model_name, created_date)
- `tags` (id, tag_name, created_date)
- `phototaglinkage` (linkage_id, photo_id, tag_id, linkage_type, created_date)
- Indices for common queries (date_taken, quality_score, person, photo, tag)
**Note:**
- Encodings stored as binary (BLOB) - float32 arrays from DeepFace ArcFace (512 dimensions)
- Location stored as JSON text string matching desktop format: `"{'x': x, 'y': y, 'w': w, 'h': h}"`
- Quality scores stored as REAL (0.0-1.0 range)
- Schema matches desktop version exactly for data portability
---
@ -60,13 +66,17 @@ Note: Embeddings stored as binary (e.g., float32 arrays) with vector index suppo
- Import photos (upload UI and/or server-side folder ingest)
- EXIF parsing, metadata extraction
- Face detection and embeddings (DeepFace ArcFace + RetinaFace)
- Identify workflow (assign to existing/new person)
- Auto-match suggestions with thresholds
- **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)
- 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:
- **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
---
## 5) API Design (v1)
@ -86,8 +96,9 @@ All responses are typed; errors use structured codes. Pagination with `page`/`pa
## 6) UX and Navigation
- Shell layout: left nav + top bar; routes: Dashboard, Search, Identify, Auto-Match, Tags, Settings
- Identify flow: keyboard (J/K), Enter to accept, quick-create person, batch approve
- Shell layout: left nav + top bar; routes: Dashboard, Scan, Process, Search, Identify, Auto-Match, 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
- Search: virtualized grid, filters drawer, saved filters
- Tags: inline edit, bulk apply from grid selection
- Settings: detector/model selection, thresholds, storage paths
@ -126,9 +137,11 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (<
- `src/web/db` (SQLAlchemy models, session management)
- `src/web/services` (application services—no DL/GUI logic)
- Database setup:
- Add SQLAlchemy models for `photos`, `faces`, `people`, `person_embeddings`, `tags`, `photo_tags`
- Add SQLAlchemy models matching desktop schema: `photos`, `faces`, `people`, `person_encodings`, `tags`, `phototaglinkage`
- Configure Alembic; generate initial migration; enable UUID/created_at defaults
- Auto-create tables on startup (via `Base.metadata.create_all()` in lifespan)
- Connect to PostgreSQL via env (`DATABASE_URL`); enable connection pooling
- Note: Schema matches desktop version exactly for compatibility
- Auth foundation:
- Implement JWT issuance/refresh; `/auth/login`, `/auth/refresh`, `/auth/me`
- Single-user bootstrap via env secrets; password hashing
@ -144,21 +157,21 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (<
- 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)
### Phase 2: Processing & Identify (23 weeks) ✅ **IN PROGRESS**
### Phase 2: Image Ingestion & Face Processing (23 weeks) ✅ **COMPLETE**
- Image ingestion:
- Backend: `/photos/import` supports folder ingest and file upload
- Compute checksums; store originals on disk; create DB rows
- Generate thumbnails lazily (worker job)
- **Scan tab UI:**
- Folder selection (browse or drag-and-drop)
- Upload progress indicators
- Recursive scan toggle
- Display scan results (count of photos added)
- Trigger background job for photo import
- Real-time job status with SSE progress updates
- Backend: `/photos/import` supports folder ingest and file upload
- ✅ Store originals on disk; create DB rows
- Generate thumbnails lazily (worker job)
- **Scan tab UI:**
- Folder selection (browse or drag-and-drop)
- Upload progress indicators
- Recursive scan toggle
- Display scan results (count of photos added)
- Trigger background job for photo import
- Real-time job status with SSE progress updates
- ✅ **DeepFace pipeline:**
- ✅ Worker task: detect faces (RetinaFace), compute embeddings (ArcFace); store embeddings as binary
- ✅ Persist face bounding boxes, confidence, quality; link to photos
- ✅ Persist face bounding boxes (location as JSON), confidence (face_confidence), quality_score; link to photos
- ✅ Configurable batch size and thresholds via settings
- ✅ EXIF orientation handling
- ✅ Face quality scoring and validation
@ -172,20 +185,64 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (<
- ✅ Error handling and retry mechanism
- ✅ Job cancellation support
- ✅ Real-time progress updates via SSE
- Identify workflow:
- API: `/faces/unidentified`, `/faces/{id}/identify` (assign existing/new person)
- Implement person creation and linking, plus `person_embeddings` insertions
- Auto-match engine: candidate generation by cosine similarity, quality filters, thresholds
- Expose `/faces/auto-match` to enqueue batch suggestions
- Frontend Identify UI:
- Virtualized face grid, quality filter slider, min-confidence filter
- Keyboard nav (J/K), Enter accept, quick-create person modal
- Batch accept/skip; inline toasts; optimistic updates with server confirmation
- Progress & observability:
- Expose job progress via SSE `/jobs/stream`; show per-task progress bars
- Add worker metrics and error surfacing in UI
- ✅ Expose job progress via SSE `/jobs/stream`; show per-task progress bars
- ✅ Add worker metrics and error surfacing in UI
### Phase 3: Search & Tags (12 weeks)
### Phase 3: Identify Workflow (23 weeks)
**Manual identification of unidentified faces - one face at a time**
- **Backend APIs:**
- `GET /api/v1/faces/unidentified` - Get unidentified faces with filters (quality, date ranges, sorting)
- `GET /api/v1/faces/{id}/similar` - Get similar faces for comparison
- `POST /api/v1/faces/{id}/identify` - Assign face to existing person or create new person
- `GET /api/v1/people` - List all people (for dropdown selection)
- `POST /api/v1/people` - Create new person
- Implement person creation and linking, plus `person_encodings` insertions (matches desktop schema)
- **Frontend Identify UI:**
- Left panel: Current unidentified face display with photo info
- Right panel: Similar faces grid for comparison (if matches found)
- Filters: Quality slider (min quality %), date ranges (date_from, date_to, date_processed_from, date_processed_to)
- Sort options: By quality, by date taken, by date processed
- Batch size configuration
- Person assignment form:
- Dropdown to select existing person
- "Create New Person" button/modal with name fields
- Accept/Skip buttons
- Keyboard navigation: J/K to navigate between faces, Enter to accept
- Progress indicator: Current face X of Y
- Photo info display: filename, date taken, path
- Optimistic updates with server confirmation
- Inline toasts for success/error feedback
### Phase 4: Auto-Match Workflow (12 weeks)
**Automated bulk matching of unidentified faces to identified people**
- **Backend APIs:**
- `POST /api/v1/faces/auto-match` - Start auto-match process with tolerance threshold
- `GET /api/v1/faces/auto-match/{match_id}` - Get match results for an identified person
- `POST /api/v1/faces/auto-match/{match_id}/accept` - Accept bulk matches for a person
- `POST /api/v1/faces/auto-match/{match_id}/reject` - Reject bulk matches for a person
- Auto-match engine: For each identified person, find all unidentified faces that match using cosine similarity
- Quality filters and similarity thresholds (tolerance)
- Batch processing with progress tracking
- **Frontend Auto-Match UI:**
- Configuration: Tolerance threshold input (0.0-1.0, lower = stricter)
- "Start Auto-Match" button to begin process
- Two-panel layout:
- Left panel: Identified person with best quality face, person name, stats (X faces already identified)
- Right panel: Grid of matched unidentified faces for this person
- Each match shows: Face thumbnail, similarity score/percentage, photo info, checkbox for selection
- Bulk actions: "Accept Selected", "Accept All", "Reject Selected", "Reject All" buttons
- Navigation: Previous/Next buttons to navigate through identified people
- Progress indicator: Person X of Y, N faces matched for current person
- Match quality display: Similarity percentage for each match
- Optimistic updates with server confirmation
- Job-based processing: Auto-match runs as background job with progress tracking
### Phase 5: Search & Tags (12 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
@ -200,7 +257,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 4: Polish & Release (12 weeks)
### Phase 6: Polish & Release (12 weeks)
- Performance polish:
- HTTP caching headers for images; prefetch thumbnails
- DB indices review; query timing; N+1 checks; connection pool tuning
@ -226,7 +283,9 @@ Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (<
## 10) Success Criteria
- Users complete import → process → identify → tag → search entirely on the web
- Users complete import → process → identify → auto-match → 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
- 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)

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

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

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>
)
}

View File

@ -2,11 +2,27 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse, Response
from rq import Queue
from redis import Redis
from sqlalchemy.orm import Session
from src.web.schemas.faces import ProcessFacesRequest, ProcessFacesResponse
from src.web.db.session import get_db
from src.web.schemas.faces import (
ProcessFacesRequest,
ProcessFacesResponse,
UnidentifiedFacesQuery,
UnidentifiedFacesResponse,
FaceItem,
SimilarFacesResponse,
SimilarFaceItem,
IdentifyFaceRequest,
IdentifyFaceResponse,
)
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
# Note: Function passed as string path to avoid RQ serialization issues
router = APIRouter(prefix="/faces", tags=["faces"])
@ -63,19 +79,231 @@ def process_faces(request: ProcessFacesRequest) -> ProcessFacesResponse:
)
@router.get("/unidentified")
def get_unidentified_faces() -> dict:
"""Get unidentified faces - placeholder for Phase 2."""
return {"message": "Unidentified faces endpoint - to be implemented in Phase 2"}
@router.get("/unidentified", response_model=UnidentifiedFacesResponse)
def get_unidentified_faces(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
min_quality: float = Query(0.0, ge=0.0, le=1.0),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
sort_by: str = Query("quality"),
sort_dir: str = Query("desc"),
db: Session = Depends(get_db),
) -> UnidentifiedFacesResponse:
"""Get unidentified faces with filters and pagination."""
from datetime import date as _date
df = _date.fromisoformat(date_from) if date_from else None
dt = _date.fromisoformat(date_to) if date_to else None
faces, total = list_unidentified_faces(
db,
page=page,
page_size=page_size,
min_quality=min_quality,
date_from=df,
date_to=dt,
sort_by=sort_by,
sort_dir=sort_dir,
)
items = [
FaceItem(
id=f.id,
photo_id=f.photo_id,
quality_score=float(f.quality_score),
face_confidence=float(getattr(f, "face_confidence", 0.0)),
location=f.location,
)
for f in faces
]
return UnidentifiedFacesResponse(items=items, page=page, page_size=page_size, total=total)
@router.post("/{face_id}/identify")
def identify_face(face_id: int) -> dict:
"""Identify face - placeholder for Phase 2."""
return {
"message": f"Identify face {face_id} - to be implemented in Phase 2",
"id": face_id,
}
@router.get("/{face_id}/similar", response_model=SimilarFacesResponse)
def get_similar_faces(face_id: int, db: Session = Depends(get_db)) -> SimilarFacesResponse:
"""Return similar unidentified faces for a given face."""
# Validate face exists
base = db.query(Face).filter(Face.id == face_id).first()
if not base:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Face {face_id} not found")
results = find_similar_faces(db, face_id)
items = [
SimilarFaceItem(
id=f.id,
photo_id=f.photo_id,
similarity=sim,
location=f.location,
quality_score=float(f.quality_score),
)
for f, sim in results
]
return SimilarFacesResponse(base_face_id=face_id, items=items)
@router.post("/{face_id}/identify", response_model=IdentifyFaceResponse)
def identify_face(
face_id: int,
request: IdentifyFaceRequest,
db: Session = Depends(get_db),
) -> IdentifyFaceResponse:
"""Assign a face (and optional batch) to a person, creating if needed.
Also inserts into person_encodings for each identified face as desktop does.
"""
# Validate target face
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")
target_face_ids = [face_id]
if request.additional_face_ids:
target_face_ids.extend([fid for fid in request.additional_face_ids if fid != face_id])
# Get or create person
created_person = False
person: Person | None = None
if request.person_id:
person = db.query(Person).filter(Person.id == request.person_id).first()
if not person:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="person_id not found")
else:
# Validate required fields for creation
if not (request.first_name and request.last_name and request.date_of_birth):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="first_name, last_name and date_of_birth are required to create a person",
)
person = Person(
first_name=request.first_name,
last_name=request.last_name,
middle_name=request.middle_name,
maiden_name=request.maiden_name,
date_of_birth=request.date_of_birth,
)
db.add(person)
db.flush() # get person.id
created_person = True
# Link faces and insert person_encodings
identified_ids: list[int] = []
for fid in target_face_ids:
f = db.query(Face).filter(Face.id == fid).first()
if not f:
continue
f.person_id = person.id
db.add(f)
# Insert person_encoding
pe = PersonEncoding(
person_id=person.id,
face_id=f.id,
encoding=f.encoding,
quality_score=f.quality_score,
detector_backend=f.detector_backend,
model_name=f.model_name,
)
db.add(pe)
identified_ids.append(f.id)
db.commit()
return IdentifyFaceResponse(identified_face_ids=identified_ids, person_id=person.id, created_person=created_person)
@router.get("/{face_id}/crop")
def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response:
"""Serve face crop image extracted from photo using face location."""
import os
import json
import ast
import tempfile
from PIL import Image
from src.web.db.models import Face, Photo
from src.utils.exif_utils import EXIFOrientationHandler
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")
photo = db.query(Photo).filter(Photo.id == face.photo_id).first()
if not photo or not os.path.exists(photo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo file not found")
try:
# Parse location (stored as text); support JSON or Python-literal formats
if isinstance(face.location, str):
try:
location = json.loads(face.location)
except Exception:
location = ast.literal_eval(face.location)
else:
location = face.location
# DeepFace format: {x, y, w, h}
x = int(location.get('x', 0) or 0)
y = int(location.get('y', 0) or 0)
w = int(location.get('w', 0) or 0)
h = int(location.get('h', 0) or 0)
# If invalid dimensions, return client error similar to desktop behavior
if w <= 0 or h <= 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid face box")
# Load image with EXIF correction (same as desktop)
# Desktop logic: use corrected image only if it's not None AND orientation != 1
corrected_image, original_orientation = EXIFOrientationHandler.correct_image_orientation_from_path(photo.path)
if corrected_image is not None and original_orientation and original_orientation != 1:
# Copy the image to ensure it's not tied to closed file handle
image = corrected_image.copy()
else:
# Use original image if no correction needed or correction fails
image = Image.open(photo.path)
# Calculate crop bounds with padding (20% like desktop)
padding_x = max(0, int(w * 0.2))
padding_y = max(0, int(h * 0.2))
crop_left = max(0, int(x - padding_x))
crop_top = max(0, int(y - padding_y))
crop_right = min(int(image.width), int(x + w + padding_x))
crop_bottom = min(int(image.height), int(y + h + padding_y))
# Ensure bounds make a valid box
if crop_right <= crop_left or crop_bottom <= crop_top:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid crop bounds")
face_crop = image.crop((crop_left, crop_top, crop_right, crop_bottom))
# Resize if too small (minimum 200px width, like desktop)
if face_crop.width > 0 and face_crop.width < 200:
ratio = 200 / face_crop.width
new_width = 200
new_height = int(face_crop.height * ratio)
face_crop = face_crop.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Save to bytes instead of temp file to avoid Content-Length issues
from io import BytesIO
output = BytesIO()
face_crop.save(output, format="JPEG", quality=95)
output.seek(0)
image_bytes = output.read()
output.close()
return Response(
content=image_bytes,
media_type="image/jpeg",
headers={
"Content-Disposition": "inline",
"Cache-Control": "public, max-age=3600",
},
)
except HTTPException:
raise
except Exception as e:
print(f"[Faces API] get_face_crop error for face {face_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract face crop: {str(e)}",
)
@router.post("/auto-match")

View File

@ -2,28 +2,53 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from src.web.db.session import get_db
from src.web.db.models import Person
from src.web.schemas.people import (
PeopleListResponse,
PersonCreateRequest,
PersonResponse,
)
router = APIRouter(prefix="/people", tags=["people"])
@router.get("")
def list_people() -> dict:
"""List people - placeholder for Phase 2."""
return {"message": "People endpoint - to be implemented in Phase 2"}
@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()
items = [PersonResponse.model_validate(p) for p in people]
return PeopleListResponse(items=items, total=len(items))
@router.post("")
def create_person() -> dict:
"""Create person - placeholder for Phase 2."""
return {"message": "Create person endpoint - to be implemented in Phase 2"}
@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."""
person = Person(
first_name=request.first_name,
last_name=request.last_name,
middle_name=request.middle_name,
maiden_name=request.maiden_name,
date_of_birth=request.date_of_birth,
)
db.add(person)
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
db.refresh(person)
return PersonResponse.model_validate(person)
@router.get("/{person_id}")
def get_person(person_id: int) -> dict:
"""Get person by ID - placeholder for Phase 2."""
return {
"message": f"Get person {person_id} - to be implemented in Phase 2",
"id": person_id,
}
@router.get("/{person_id}", response_model=PersonResponse)
def get_person(person_id: int, db: Session = Depends(get_db)) -> PersonResponse:
"""Get person by ID."""
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")
return PersonResponse.model_validate(person)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, FileResponse
from rq import Queue
from redis import Redis
from sqlalchemy.orm import Session
@ -149,3 +149,39 @@ def get_photo(photo_id: int, db: Session = Depends(get_db)) -> PhotoResponse:
return PhotoResponse.model_validate(photo)
@router.get("/{photo_id}/image")
def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileResponse:
"""Serve photo image file for display (not download)."""
import os
import mimetypes
from src.web.db.models import Photo
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Photo {photo_id} not found",
)
if not os.path.exists(photo.path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Photo file not found: {photo.path}",
)
# Determine media type from file extension
media_type, _ = mimetypes.guess_type(photo.path)
if not media_type or not media_type.startswith('image/'):
media_type = "image/jpeg"
# Use FileResponse but set headers to display inline (not download)
response = FileResponse(
photo.path,
media_type=media_type,
)
# Set Content-Disposition to inline so browser displays instead of downloads
response.headers["Content-Disposition"] = "inline"
response.headers["Cache-Control"] = "public, max-age=3600"
return response

View File

@ -1,7 +1,8 @@
"""Face processing schemas."""
"""Face processing and identify workflow schemas."""
from __future__ import annotations
from datetime import date
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
@ -38,3 +39,84 @@ class ProcessFacesResponse(BaseModel):
detector_backend: str
model_name: str
class FaceItem(BaseModel):
"""Minimal face item for list views."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
quality_score: float
face_confidence: float
location: str
class UnidentifiedFacesQuery(BaseModel):
"""Query params for listing unidentified faces."""
model_config = ConfigDict(protected_namespaces=())
page: int = 1
page_size: int = 50
min_quality: float = 0.0
date_from: Optional[date] = None
date_to: Optional[date] = None
sort_by: str = Field("quality", description="quality|date_taken|date_added")
sort_dir: str = Field("desc", description="asc|desc")
class UnidentifiedFacesResponse(BaseModel):
"""Paginated unidentified faces list."""
model_config = ConfigDict(protected_namespaces=())
items: list[FaceItem]
page: int
page_size: int
total: int
class SimilarFaceItem(BaseModel):
"""Similar face with similarity score (0-1)."""
id: int
photo_id: int
similarity: float
location: str
quality_score: float
class SimilarFacesResponse(BaseModel):
"""Response containing similar faces for a given face."""
model_config = ConfigDict(protected_namespaces=())
base_face_id: int
items: list[SimilarFaceItem]
class IdentifyFaceRequest(BaseModel):
"""Identify a face by selecting existing or creating new person."""
model_config = ConfigDict(protected_namespaces=())
# Either provide person_id or the fields to create new person
person_id: Optional[int] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
middle_name: Optional[str] = None
maiden_name: Optional[str] = None
date_of_birth: Optional[date] = None
# Optionally identify a batch of face IDs along with this one
additional_face_ids: Optional[list[int]] = None
class IdentifyFaceResponse(BaseModel):
"""Result of identify operation."""
model_config = ConfigDict(protected_namespaces=())
identified_face_ids: list[int]
person_id: int
created_person: bool

44
src/web/schemas/people.py Normal file
View File

@ -0,0 +1,44 @@
"""People schemas for web API (Phase 3)."""
from __future__ import annotations
from datetime import date
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class PersonResponse(BaseModel):
"""Person DTO returned from API."""
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
class PersonCreateRequest(BaseModel):
"""Request payload to create a new 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: date
class PeopleListResponse(BaseModel):
"""List of people for selection dropdowns."""
model_config = ConfigDict(protected_namespaces=())
items: list[PersonResponse]
total: int

View File

@ -6,12 +6,13 @@ import json
import os
import tempfile
import time
from typing import Callable, Optional, Tuple
from typing import Callable, Optional, Tuple, List
from datetime import date
import numpy as np
from PIL import Image
from sqlalchemy.orm import Session
from sqlalchemy import and_
from sqlalchemy import and_, func
try:
from deepface import DeepFace
@ -644,3 +645,99 @@ def process_unprocessed_photos(
return photos_processed, total_faces_detected, total_faces_stored
def list_unidentified_faces(
db: Session,
page: int = 1,
page_size: int = 50,
min_quality: float = 0.0,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
sort_by: str = "quality",
sort_dir: str = "desc",
) -> Tuple[List[Face], int]:
"""Return paginated unidentified faces with filters.
Matches desktop behavior as closely as possible: filter by min quality and date_taken.
"""
# Base query: faces with no person
query = db.query(Face).join(Photo, Face.photo_id == Photo.id).filter(Face.person_id.is_(None))
# Min quality (stored 0.0-1.0)
if min_quality is not None:
query = query.filter(Face.quality_score >= min_quality)
# Date range on photo.date_taken when available, else on date_added as fallback
if date_from is not None:
query = query.filter(
(Photo.date_taken.is_not(None) & (Photo.date_taken >= date_from))
| (Photo.date_taken.is_(None) & (func.date(Photo.date_added) >= date_from))
)
if date_to is not None:
query = query.filter(
(Photo.date_taken.is_not(None) & (Photo.date_taken <= date_to))
| (Photo.date_taken.is_(None) & (func.date(Photo.date_added) <= date_to))
)
# Sorting
if sort_by == "quality":
sort_col = Face.quality_score
elif sort_by == "date_taken":
sort_col = Photo.date_taken
else:
sort_col = Photo.date_added
if sort_dir == "asc":
query = query.order_by(sort_col.asc().nullslast())
else:
query = query.order_by(sort_col.desc().nullslast())
# Total count for pagination
total = query.count()
# Pagination
items = query.offset((page - 1) * page_size).limit(page_size).all()
return items, total
def compute_cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Compute cosine similarity for two float vectors in range [0,1]."""
denom = (np.linalg.norm(a) * np.linalg.norm(b))
if denom == 0:
return 0.0
return float(np.dot(a, b) / denom)
def find_similar_faces(
db: Session,
face_id: int,
limit: int = 20,
min_similarity: float = 0.4,
) -> List[Tuple[Face, float]]:
"""Find similar unidentified faces to the given face using cosine similarity.
Returns list of (face, similarity) sorted by similarity desc.
"""
base: Face = db.query(Face).filter(Face.id == face_id).first()
if not base:
return []
base_enc = np.frombuffer(base.encoding, dtype=np.float32)
# Compare against unidentified faces except itself
candidates: List[Face] = (
db.query(Face)
.filter(Face.person_id.is_(None), Face.id != face_id)
.all()
)
scored: List[Tuple[Face, float]] = []
for f in candidates:
enc = np.frombuffer(f.encoding, dtype=np.float32)
sim = compute_cosine_similarity(base_enc, enc)
if sim >= min_similarity:
scored.append((f, sim))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:limit]

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from src.web.app import app
client = TestClient(app)
def test_people_list_empty():
res = client.get('/api/v1/people')
assert res.status_code == 200
data = res.json()
assert 'items' in data and isinstance(data['items'], list)
def test_unidentified_faces_empty():
res = client.get('/api/v1/faces/unidentified')
assert res.status_code == 200
data = res.json()
assert data['total'] >= 0