Land the persistence model and HTTP surface for local face recognition.
Inference still lives in Apollo (Phase 1); this side adds the data home
plus every endpoint Apollo's UI and FileViewer-React will consume.
Schema (new migration 2026-04-29-000000_add_faces):
- persons: visual identities. Optional entity_id bridges to the
existing knowledge-graph entities table; auto-bridging is left to
the management UI (we don't muddy LLM provenance from face rows).
UNIQUE(name COLLATE NOCASE) so 'alice' / 'Alice' fold to one row.
- face_detections: keyed on content_hash (cross-library dedup), with
status='detected' carrying bbox + 512-d embedding BLOB, and
'no_faces' / 'failed' marker rows that tell Phase 3's file watcher
not to re-scan. Marker invariant enforced via CHECK; partial UNIQUE
on content_hash WHERE status='no_faces' guards against double-marks.
Schema regenerated with `diesel print-schema` against a clean migration
run; joinables added for face_detections → libraries / persons and
persons → entities.
face_client.rs (sibling of apollo_client.rs):
- reqwest multipart, 60 s timeout (CPU inference on a backlog can be
slow; bounded threadpool on Apollo serializes calls anyway).
- FaceDetectError::{Permanent, Transient, Disabled} — Phase 3 keys
its marker-row decision on this. 422 → mark failed, 5xx → defer.
- APOLLO_FACE_API_BASE_URL falls back to APOLLO_API_BASE_URL when
unset; both unset = is_enabled() false, callers no-op.
faces.rs (DAO + handlers):
- SqliteFaceDao implements the full FaceDao trait; person face counts
go through sql_query because diesel's BoxedSelectStatement +
group_by trips trait-resolver recursion.
- merge_persons re-points face rows in a transaction, copies notes
when target's are empty, deletes src.
- manual POST /image/faces resolves content_hash through image_exif,
crops the user-drawn bbox with 10% padding (detector wants context
around ears/jaw), POSTs the crop to face_client.embed for a real
ArcFace vector, then inserts source='manual'.
- Cluster-suggest (Phase 6) gets its data from
GET /faces/embeddings — base64-encoded paged BLOBs so Apollo's
DBSCAN can stream them without ImageApi pre-aggregating.
Endpoints registered alongside add_*_services in main.rs:
GET /faces/stats?library=
GET /faces/embeddings?library=&unassigned=&limit=&offset=
GET /image/faces?path=&library=
POST /image/faces (manual create via embed)
PATCH /image/faces/{id}
DELETE /image/faces/{id}
GET /persons?library=
POST /persons
GET /persons/{id}
PATCH /persons/{id}
DELETE /persons/{id}?cascade=set_null|delete (set_null default)
POST /persons/{id}/merge
GET /persons/{id}/faces?library=
The file-watch hook (Phase 3) and the rerun-on-one-photo handler
(Phase 6) live behind the FaceDao methods marked dead_code today —
they're called only when those phases land. Same shape for the trait
methods that aren't reached by Phase 2 routes.
Tests: 3 DAO unit tests cover person CRUD + case-insensitive uniqueness,
marker-row idempotency (mark_status is a no-op when any row exists),
and merge re-pointing faces.
Cargo.toml: reqwest gains the `multipart` feature.
cargo build / cargo test --lib / cargo fmt / cargo clippy --all-targets
all clean for the new code; the two pre-existing test_path_excluder
failures and the pre-existing sort_by clippy warnings are unrelated and
present on master.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
68 lines
3.5 KiB
SQL
68 lines
3.5 KiB
SQL
-- Local face recognition tables.
|
||
--
|
||
-- `persons` are visual identities (the "who" of a face). The optional
|
||
-- `entity_id` bridges to the existing knowledge graph `entities` table —
|
||
-- when set, this person is the visual side of an LLM-extracted entity.
|
||
-- Don't auto-create entities from persons; the entity table represents
|
||
-- LLM-extracted knowledge with its own confidence semantics, and silently
|
||
-- filling it from face detections muddies the provenance.
|
||
--
|
||
-- `face_detections` carries one row per detected face on a content_hash,
|
||
-- plus marker rows with `status='no_faces'` or `status='failed'` so the
|
||
-- file watcher knows not to re-scan a hash. Keying on `content_hash`
|
||
-- (cross-library dedup) rather than `(library_id, rel_path)` means the
|
||
-- same JPEG in two libraries is scanned once. The denormalized `rel_path`
|
||
-- carries the most-recently-seen path — useful for cluster-thumb URL
|
||
-- generation; canonical path lookup goes through image_exif.
|
||
|
||
CREATE TABLE persons (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
cover_face_id INTEGER, -- backfilled when the first face binds
|
||
entity_id INTEGER, -- optional bridge to entities(id)
|
||
created_from_tag BOOLEAN NOT NULL DEFAULT 0,
|
||
notes TEXT,
|
||
created_at BIGINT NOT NULL,
|
||
updated_at BIGINT NOT NULL,
|
||
CONSTRAINT fk_persons_entity FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE SET NULL,
|
||
UNIQUE(name COLLATE NOCASE)
|
||
);
|
||
|
||
CREATE INDEX idx_persons_entity ON persons(entity_id);
|
||
|
||
CREATE TABLE face_detections (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||
library_id INTEGER NOT NULL,
|
||
content_hash TEXT NOT NULL, -- canonical key (cross-library dedup)
|
||
rel_path TEXT NOT NULL, -- denormalized; most recently seen
|
||
bbox_x REAL, -- normalized 0..1; NULL on marker rows
|
||
bbox_y REAL,
|
||
bbox_w REAL,
|
||
bbox_h REAL,
|
||
embedding BLOB, -- 512×f32 = 2048 bytes; NULL on marker rows
|
||
confidence REAL, -- detector score
|
||
source TEXT NOT NULL, -- 'auto' | 'manual'
|
||
person_id INTEGER,
|
||
status TEXT NOT NULL DEFAULT 'detected', -- 'detected' | 'no_faces' | 'failed'
|
||
model_version TEXT NOT NULL, -- e.g. 'buffalo_l'; embedding lineage
|
||
created_at BIGINT NOT NULL,
|
||
CONSTRAINT fk_fd_library FOREIGN KEY (library_id) REFERENCES libraries(id),
|
||
CONSTRAINT fk_fd_person FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE SET NULL,
|
||
-- Detected rows carry geometry + embedding; marker rows ('no_faces',
|
||
-- 'failed') carry neither. CHECK enforces the invariant so manual
|
||
-- inserts can't slip through with half a row.
|
||
CONSTRAINT chk_marker CHECK (
|
||
(status = 'detected' AND bbox_x IS NOT NULL AND embedding IS NOT NULL)
|
||
OR (status IN ('no_faces','failed') AND bbox_x IS NULL AND embedding IS NULL)
|
||
)
|
||
);
|
||
|
||
CREATE INDEX idx_face_detections_hash ON face_detections(content_hash);
|
||
CREATE INDEX idx_face_detections_lib_path ON face_detections(library_id, rel_path);
|
||
CREATE INDEX idx_face_detections_person ON face_detections(person_id);
|
||
CREATE INDEX idx_face_detections_status ON face_detections(status);
|
||
-- One marker row per (content_hash, status='no_faces') so the file watcher
|
||
-- doesn't double-mark when a hash is seen on multiple full-scan passes.
|
||
CREATE UNIQUE INDEX idx_face_detections_no_faces_unique
|
||
ON face_detections(content_hash) WHERE status = 'no_faces';
|