diff --git a/README.md b/README.md index 464acb2..fd9bf2b 100644 --- a/README.md +++ b/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** diff --git a/docs/PHASE1_CHECKLIST.md b/docs/PHASE1_CHECKLIST.md index dd73d38..77b1204 100644 --- a/docs/PHASE1_CHECKLIST.md +++ b/docs/PHASE1_CHECKLIST.md @@ -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 diff --git a/docs/WEBSITE_MIGRATION_PLAN.md b/docs/WEBSITE_MIGRATION_PLAN.md index abb7cfd..3b44395 100644 --- a/docs/WEBSITE_MIGRATION_PLAN.md +++ b/docs/WEBSITE_MIGRATION_PLAN.md @@ -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) diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index 2f8fc83..c981d62 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -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('/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 => { + const response = await apiClient.get('/api/v1/faces/unidentified', { + params, + }) + return response.data + }, + getSimilar: async (faceId: number): Promise => { + const response = await apiClient.get(`/api/v1/faces/${faceId}/similar`) + return response.data + }, + identify: async (faceId: number, payload: IdentifyFaceRequest): Promise => { + const response = await apiClient.post(`/api/v1/faces/${faceId}/identify`, payload) + return response.data + }, } export default facesApi diff --git a/frontend/src/api/people.ts b/frontend/src/api/people.ts new file mode 100644 index 0000000..f7dc73e --- /dev/null +++ b/frontend/src/api/people.ts @@ -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 => { + const res = await apiClient.get('/api/v1/people') + return res.data + }, + create: async (payload: PersonCreateRequest): Promise => { + const res = await apiClient.post('/api/v1/people', payload) + return res.data + }, +} + +export default peopleApi + + diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 388de55..2213649 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -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([]) + 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('quality') + const [sortDir, setSortDir] = useState('desc') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + + const [currentIdx, setCurrentIdx] = useState(0) + const currentFace = faces[currentIdx] + + const [similar, setSimilar] = useState([]) + const [compareEnabled, setCompareEnabled] = useState(true) + const [selectedSimilar, setSelectedSimilar] = useState>({}) + + const [people, setPeople] = useState([]) + const [personId, setPersonId] = useState(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>({}) + + // Track previous face ID to save data on navigation + const prevFaceIdRef = useRef(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 (

Identify

-
-

Face identification workflow coming in Phase 2.

+
+ {/* Left: Controls and current face */} +
+
+
+
+ + setMinQuality(parseFloat(e.target.value))} className="w-full" /> +
{(minQuality * 100).toFixed(0)}%
+
+
+ + +
+
+ + setDateFrom(e.target.value)} + className="mt-1 block w-full border rounded px-2 py-1" /> +
+
+ + setDateTo(e.target.value)} + className="mt-1 block w-full border rounded px-2 py-1" /> +
+
+ + +
+
+ + +
+
+
+ +
+
+
{currentInfo}
+
+ + +
+
+ {!currentFace ? ( +
No faces to identify.
+ ) : ( +
+
{ + const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentFace.photo_id}/image` + window.open(photoUrl, '_blank') + }} + title="Click to open full photo" + > + {`Face { + 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) + } + }} + /> +
+
+
+ + +
+
+ +
+
+ + { + 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} /> +
+
+ + { + 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} /> +
+
+ + { + 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} /> +
+
+ + { + 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} /> +
+
+ + { + 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} /> +
+
+ + + +
+
+
+ )} +
+
+ + {/* Right: Similar faces */} +
+
+
+
+ setCompareEnabled(e.target.checked)} /> + +
+
+ + +
+
+ {!compareEnabled ? ( +
Comparison disabled.
+ ) : similar.length === 0 ? ( +
No similar faces.
+ ) : ( +
+ {similar.map((s) => ( +
+
) } + diff --git a/src/web/api/faces.py b/src/web/api/faces.py index 2c0c083..8508712 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -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") diff --git a/src/web/api/people.py b/src/web/api/people.py index 06fd3e1..305a768 100644 --- a/src/web/api/people.py +++ b/src/web/api/people.py @@ -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) diff --git a/src/web/api/photos.py b/src/web/api/photos.py index 5417583..1dacb2f 100644 --- a/src/web/api/photos.py +++ b/src/web/api/photos.py @@ -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 + diff --git a/src/web/schemas/faces.py b/src/web/schemas/faces.py index df302d4..23fcc4a 100644 --- a/src/web/schemas/faces.py +++ b/src/web/schemas/faces.py @@ -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 diff --git a/src/web/schemas/people.py b/src/web/schemas/people.py new file mode 100644 index 0000000..afff839 --- /dev/null +++ b/src/web/schemas/people.py @@ -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 + + diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index 0bd5255..23f3d77 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -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] + diff --git a/tests/test_phase3_identify_api.py b/tests/test_phase3_identify_api.py new file mode 100644 index 0000000..d0e9571 --- /dev/null +++ b/tests/test_phase3_identify_api.py @@ -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 + +