Files
ImageApi/migrations/2026-04-29-000000_add_faces/up.sql
Cameron Cordes 860169032b faces: phase 2 — schema + manual face/person CRUD
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>
2026-04-29 18:03:42 +00:00

68 lines
3.5 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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';