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:
parent
5174fe0d54
commit
817e95337f
36
README.md
36
README.md
@ -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**
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (2–3 weeks) ✅ **IN PROGRESS**
|
||||
### Phase 2: Image Ingestion & Face Processing (2–3 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 (1–2 weeks)
|
||||
### Phase 3: Identify Workflow (2–3 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 (1–2 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 (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
|
||||
@ -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 (1–2 weeks)
|
||||
### Phase 6: 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
|
||||
@ -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)
|
||||
|
||||
@ -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
frontend/src/api/people.ts
Normal file
38
frontend/src/api/people.ts
Normal 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
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
44
src/web/schemas/people.py
Normal 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
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
24
tests/test_phase3_identify_api.py
Normal file
24
tests/test_phase3_identify_api.py
Normal 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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user