Store faces and the people they belong to
Schema v8: people, faces, face_person, face_person_rejected, and the per-library calibration. Follows catalog.md 10.1 with two additions the spec work turned up. crop_px, because at the 1024px proxy tier a group shot reaches the embedder at ~50 source pixels upsampled to 112 and a portrait at 340. FR-CULL-9 names face size as an axis along which an uncalibrated similarity misbehaves, so it is a stored feature rather than a UI hint. face_person_rejected, because rejection is not the absence of an assignment. Without it the next clustering pass re-suggests exactly the face the user just pushed away, and the tool feels broken. record_detections replaces rather than appends, since DetectFaces is coalesced per image -- and carries confirmations across the replacement by box overlap, so re-indexing with a better model cannot discard the user's own labelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
|
||||
//! - [`collections`] — the collection tree and membership the UI edits
|
||||
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
||||
//! - [`faces`] — detected faces, the people they belong to, and who said so
|
||||
//! - [`jobs`] — the durable background work queue
|
||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||
@@ -36,6 +37,7 @@ pub mod cache;
|
||||
pub mod collections;
|
||||
pub mod dedup;
|
||||
pub mod error;
|
||||
pub mod faces;
|
||||
pub mod jobs;
|
||||
pub mod keywords;
|
||||
pub mod merge;
|
||||
@@ -51,6 +53,7 @@ pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
||||
pub use collections::{Collection, CollectionKind, TreeRow};
|
||||
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
||||
pub use error::CatalogError;
|
||||
pub use faces::{Calibration, DetectedFace, Face, FaceId, Person, PersonId};
|
||||
pub use jobs::{Job, JobKind, Priority};
|
||||
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
|
||||
pub use merge::MergeReport;
|
||||
|
||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
pub const SCHEMA_VERSION: i64 = 8;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -78,6 +78,12 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 8 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V8)?;
|
||||
tx.pragma_update(None, "user_version", 8)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
@@ -327,6 +333,114 @@ fn stem_of(path: &str) -> &str {
|
||||
/// Both narrow the walk rather than reorder it, so the index still supplies the
|
||||
/// ordering and SQLite tests the extra predicate per row. That is the cheap
|
||||
/// direction: the expensive part was never the filtering, it was the sort.
|
||||
const V8: &str = r#"
|
||||
-- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
||||
-- People and faces (docs/faces.md, docs/catalog.md §10).
|
||||
--
|
||||
-- Everything here is **derived data** except one column. Faces, landmarks,
|
||||
-- embeddings, cluster assignments and suggestions are all reproducible by
|
||||
-- re-indexing and are never written to a sidecar (FR-CULL-12); a person's
|
||||
-- *name*, once the user has confirmed it, is a human judgement of the same
|
||||
-- class as a rating and travels with the photograph.
|
||||
--
|
||||
-- That asymmetry is the whole design: deleting the catalog costs an afternoon
|
||||
-- of re-indexing and loses nothing the user typed (ARCH §6.12).
|
||||
|
||||
CREATE TABLE people (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- Merge identity, not the name. Two devices that independently name the
|
||||
-- same cluster produce two people; merging them keys on this, exactly as
|
||||
-- collections do (FR-CAT-7, ARCH §6.3).
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
-- Tombstone-by-redirect. A merged person must outlive its merge or a
|
||||
-- device that still holds it resurrects it on the next sync -- the same
|
||||
-- hazard collections have, solved the same way.
|
||||
merged_into INTEGER REFERENCES people(id) ON DELETE SET NULL,
|
||||
created INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
modified INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE faces (
|
||||
id INTEGER PRIMARY KEY,
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
-- Normalised to the image's long edge, so a face survives the proxy it was
|
||||
-- found on being evicted and regenerated at another resolution. Storing
|
||||
-- pixels would bind a face to a resolution the cache is entitled to change.
|
||||
x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL,
|
||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 x f16, L2-normalised
|
||||
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
|
||||
--
|
||||
-- Not cosmetic: it is the honest quality signal for the UI, a feature in
|
||||
-- the §8 calibration -- FR-CULL-9 names face size as an axis along which an
|
||||
-- uncalibrated similarity misbehaves -- and the selector a later
|
||||
-- higher-resolution re-embedding pass would run on.
|
||||
crop_px REAL NOT NULL,
|
||||
-- Which model produced this embedding.
|
||||
--
|
||||
-- The one mistake in this subsystem that yields plausible-looking garbage
|
||||
-- rather than an error: embeddings from different models are not
|
||||
-- comparable. Storing the model with the vector makes a model change
|
||||
-- detectable and re-indexable instead of quietly poisoning every
|
||||
-- similarity in the library.
|
||||
model_id TEXT NOT NULL,
|
||||
detected_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX faces_image ON faces(image_id);
|
||||
CREATE INDEX faces_model ON faces(model_id);
|
||||
|
||||
CREATE TABLE face_person (
|
||||
face_id INTEGER PRIMARY KEY REFERENCES faces(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
-- Calibrated P(this face is this person), never a raw cosine (FR-CULL-9).
|
||||
probability REAL NOT NULL,
|
||||
-- The user said so. Never overwritten by a later inference pass.
|
||||
--
|
||||
-- A column rather than a probability of 1.0, because a confirmation is a
|
||||
-- different kind of fact from a confident guess and collapsing them loses
|
||||
-- the ability to recompute suggestions without touching user data.
|
||||
confirmed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX face_person_person ON face_person(person_id, confirmed);
|
||||
|
||||
-- Faces the user has explicitly said are NOT a given person.
|
||||
--
|
||||
-- Needed because rejection is not the absence of an assignment: without it,
|
||||
-- the next clustering pass re-suggests exactly the face the user just pushed
|
||||
-- away, and the tool feels broken. Same reasoning as `confirmed` -- a
|
||||
-- judgement is user data (FR-CULL-12) whichever direction it points.
|
||||
CREATE TABLE face_person_rejected (
|
||||
face_id INTEGER NOT NULL REFERENCES faces(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (face_id, person_id)
|
||||
);
|
||||
|
||||
-- The FR-CULL-9 calibration, fitted from this library's own faces.
|
||||
--
|
||||
-- One row per model, because the fit is a property of the embedding space and
|
||||
-- a library indexed across a model change holds two. `face_set_hash` is what
|
||||
-- makes a stale fit detectable: a materially changed library recomputes rather
|
||||
-- than trusting numbers derived from a set that no longer exists.
|
||||
CREATE TABLE face_calibration (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
-- P(same) = sigmoid(a*cos + b + w_size*log2(min(crop_px)) + log_prior_odds)
|
||||
a REAL NOT NULL,
|
||||
b REAL NOT NULL,
|
||||
w_size REAL NOT NULL DEFAULT 0.0,
|
||||
-- Whether the fit is usable at all. When it is not, the UI says the
|
||||
-- confidence is unavailable; it does not present an untuned default as
|
||||
-- though it were measured (FR-CULL-9).
|
||||
valid INTEGER NOT NULL DEFAULT 0,
|
||||
positive_pairs INTEGER NOT NULL DEFAULT 0,
|
||||
negative_pairs INTEGER NOT NULL DEFAULT 0,
|
||||
face_set_hash TEXT NOT NULL,
|
||||
fitted_at INTEGER NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
const V7: &str = r#"
|
||||
CREATE INDEX images_grid_order
|
||||
ON images(captured_at IS NULL, captured_at, source_ref)
|
||||
|
||||
Reference in New Issue
Block a user