Files
DarkRoom/core/dr-catalog/src/faces.rs
T
dtourolleandClaude Opus 5 b846b312b8
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h21m32s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Successful in 25s
Build and test / Android (aarch64) (push) Failing after 33m58s
Run the formatter over the face branch before it reaches CI
The merge of the SCRFD/MobileFaceNet work brought 69 rustfmt diffs across
dr-catalog, dr-face and dr-ui with it, so `cargo fmt --all -- --check` fails
on master and the Desktop job stops at its Format step — before clippy, the
tests or the release build have run at all. That makes the whole desktop
half of CI blind: a real compile error behind this would look exactly the
same from the outside. There was nothing behind it, as it turns out — with
the formatting fixed, clippy, the test suite and the release build all pass.

Every .rs hunk is `cargo fmt --all` on the pinned 1.92.0 toolchain, not a
hand edit, but it is worth being precise about what that moved, because it
is more than whitespace. Besides reflowing signatures and call chains,
rustfmt reordered the `pub mod` and `pub use` items in dr-face/src/lib.rs so
the `#[cfg(feature = "inference")]` entries sort in place, added the trailing
semicolon inside `let ... else { return }` bodies in identity_ui.rs, wrapped
a bare closure body in braces in cluster.rs, adjusted trailing commas, and
dropped a stray blank line at the end of identity_ui.rs. All of it is
semantically inert; none of it changes behaviour.

docs/traceability.md rides along because it has to. The matrix records each
TRACES tag by line number, and reflowing develop.rs, lib.rs, faces.rs,
identity.rs and identity_ui.rs moved them — FR-CAT-8, FR-CAT-9, FR-CULL-10,
FR-DEV-3, FR-DEV-3a and FR-DEV-3c all shift by a line or two. The matrix was
verified up to date on d777f7f before this commit, so this is drift these
formatting changes introduced, not pre-existing staleness being swept up.
Leaving it for a follow-up commit would hand traceability-check.yml a
failure caused entirely by a whitespace change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:12:49 +02:00

1286 lines
47 KiB
Rust
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.
//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
//! People and faces: what was detected, who it is, and who said so.
//!
//! The storage half of docs/faces.md. `dr-face` finds faces and turns them into
//! 512 numbers; this module is where those numbers acquire an identity, and
//! where the user's corrections outrank the model's guesses.
//!
//! # Two kinds of fact, never conflated
//!
//! A face is either **suggested** — the system's inference, recomputable at
//! will — or **confirmed**, the user's judgement, which no later indexing pass
//! may overwrite (FR-CULL-10). That distinction is a column, not a probability
//! of 1.0, because collapsing them would lose the ability to recompute
//! suggestions without touching user data.
//!
//! It runs in both directions. [`reject`] records that a face is *not* someone,
//! and that has to be stored rather than inferred from the absence of an
//! assignment: without it the next clustering pass re-suggests exactly the face
//! the user just pushed away.
//!
//! # What is derived and what is not
//!
//! Everything here is rebuildable by re-indexing except the person's name and
//! the user's confirmations and rejections. The embeddings are expensive and
//! reproducible, which is precisely what ARCH §6.12 says belongs in a
//! disposable index; the name is irreplaceable and goes to the sidecar
//! (FR-CULL-12), which is not this module's job.
//!
//! # Privacy is structural here, not policy
//!
//! NFR-SEC-5 puts face data under a stricter rule than the rest of the catalog:
//! it never enters a diagnostics bundle, never reaches a plugin, and is
//! deletable in one action ([`delete_all_face_data`]). This module has no
//! network dependency and no path that emits an embedding anywhere but back to
//! its caller.
use rusqlite::{Connection, OptionalExtension};
use dr_types::ImageId;
use crate::error::CatalogError;
/// A face's row id. Local to this catalog, like [`crate::keywords::KeywordId`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FaceId(pub u64);
/// A person's row id. Local; [`Person::uuid`] is what a merge keys on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PersonId(pub u64);
/// A face as detected, before it has an identity.
///
/// Coordinates are **normalised to the image's long edge**, so a face outlives
/// the proxy it was found on being evicted and regenerated at another size.
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedFace {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
/// Five `(x, y)` pairs, normalised the same way.
pub landmarks: [(f32, f32); 5],
pub confidence: f32,
/// 512 × f16, L2-normalised — `dr_face::Embedding::to_f16_bytes`.
pub embedding: Vec<u8>,
/// Source pixels across the aligned crop (docs/faces.md §7).
pub crop_px: f32,
/// Which model produced the embedding. Comparing across models is the one
/// mistake that yields plausible garbage rather than an error.
pub model_id: String,
}
/// A stored face, with whatever identity it has acquired.
#[derive(Debug, Clone, PartialEq)]
pub struct Face {
pub id: FaceId,
pub image_id: ImageId,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub landmarks: [(f32, f32); 5],
pub confidence: f32,
pub crop_px: f32,
pub model_id: String,
/// `None` when the face belongs to no one yet.
pub person: Option<PersonId>,
/// Calibrated P(this face is this person). Meaningless without `person`.
pub probability: f32,
/// Whether the user asserted the assignment, as against the system guessing.
pub confirmed: bool,
}
/// A person, and how much of them the library holds.
#[derive(Debug, Clone, PartialEq)]
pub struct Person {
pub id: PersonId,
pub uuid: String,
pub name: String,
/// Faces the user has confirmed.
pub confirmed_faces: u64,
/// Faces the system suggests and the user has not ruled on.
pub suggested_faces: u64,
}
/// The FR-CULL-9 calibration, re-exported from where it is fitted.
///
/// Deliberately *not* redefined here. The catalog stores five numbers; what
/// those numbers mean — the sigmoid, the size term, the prior — is
/// [`dr_face::calibrate`]'s business, and two implementations of one
/// probability model is precisely the kind of divergence that yields a
/// plausible number meaning the wrong thing.
pub use dr_face::Calibration;
// ── detection ─────────────────────────────────────────────────────────────
/// Replace every face on an image with a fresh detection pass.
///
/// Replace rather than append, because `DetectFaces` is coalesced per image
/// (FR-CAT-3) and re-running it must be idempotent — appending would double the
/// faces on every re-index and silently inflate every cluster.
///
/// **Confirmed assignments survive** where the new detection covers the same
/// part of the frame: a re-index with a better model must not discard the
/// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is
/// in the same place even when the box moves a little.
pub fn record_detections(
conn: &Connection,
image_id: ImageId,
model_id: &str,
source_edge: u32,
faces: &[DetectedFace],
) -> Result<Vec<FaceId>, CatalogError> {
let tx = conn.unchecked_transaction()?;
// What the user had asserted, so it can be carried across the replacement.
let mut prior: Vec<(f32, f32, f32, f32, i64, f64)> = Vec::new();
{
let mut q = tx.prepare(
"SELECT f.x, f.y, f.w, f.h, fp.person_id, fp.probability
FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = ?1 AND fp.confirmed = 1",
)?;
let rows = q.query_map([image_id.0 as i64], |r| {
Ok((
r.get::<_, f64>(0)? as f32,
r.get::<_, f64>(1)? as f32,
r.get::<_, f64>(2)? as f32,
r.get::<_, f64>(3)? as f32,
r.get::<_, i64>(4)?,
r.get::<_, f64>(5)?,
))
})?;
for row in rows {
prior.push(row?);
}
}
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
let now = now_secs();
let mut ids = Vec::with_capacity(faces.len());
for f in faces {
tx.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence,
embedding, crop_px, model_id, detected_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
image_id.0 as i64,
f.x as f64,
f.y as f64,
f.w as f64,
f.h as f64,
landmarks_to_blob(&f.landmarks),
f.confidence as f64,
f.embedding,
f.crop_px as f64,
f.model_id,
now,
],
)?;
let id = FaceId(tx.last_insert_rowid() as u64);
// Re-attach a confirmation whose box this detection clearly replaces.
// 0.5 IoU is loose on purpose: the question is "is this the same face
// in the frame", not "is this the same box", and a better detector is
// entitled to move the box.
if let Some((_, _, _, _, person, prob)) = prior
.iter()
.filter(|p| iou((p.0, p.1, p.2, p.3), (f.x, f.y, f.w, f.h)) > 0.5)
.max_by(|a, b| {
iou((a.0, a.1, a.2, a.3), (f.x, f.y, f.w, f.h))
.total_cmp(&iou((b.0, b.1, b.2, b.3), (f.x, f.y, f.w, f.h)))
})
{
tx.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, 1)",
rusqlite::params![id.0 as i64, person, prob],
)?;
}
ids.push(id);
}
// The run marker, written whether or not anything was found. Zero faces is
// a real answer and recording it is what stops the next pass looking at
// this photograph again -- see the V9 migration.
tx.execute(
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(image_id, model_id) DO UPDATE SET
indexed_at = excluded.indexed_at,
faces_found = excluded.faces_found,
source_edge = excluded.source_edge",
rusqlite::params![
image_id.0 as i64,
model_id,
now,
faces.len() as i64,
source_edge as i64,
],
)?;
tx.commit()?;
Ok(ids)
}
/// How much of the library has been through face detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Coverage {
/// Images that are candidates at all — present, not trashed.
pub images: u64,
/// Images this model has actually looked at.
pub indexed: u64,
/// Of those, how many had no face in them. Usually most of a library, and
/// worth showing so "0 faces" reads as a finding rather than a failure.
pub without_faces: u64,
/// Faces found across the whole library.
pub faces: u64,
}
impl Coverage {
/// Images still to look at.
pub fn outstanding(&self) -> u64 {
self.images.saturating_sub(self.indexed)
}
pub fn is_complete(&self) -> bool {
self.outstanding() == 0
}
/// Fraction indexed, `0.0..=1.0`. An empty library is complete, not zero:
/// there is nothing outstanding, and reporting 0% would read as a stall.
pub fn fraction(&self) -> f32 {
if self.images == 0 {
1.0
} else {
self.indexed as f32 / self.images as f32
}
}
}
/// Count what has and has not been indexed, for one model.
///
/// The question the batch pass asks before it starts and the UI asks to draw a
/// progress figure. Answerable only because [`record_detections`] writes a run
/// marker: counting `faces` rows would report how many faces exist, which is a
/// different number and never reaches the image count.
pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogError> {
let images: i64 = conn.query_row(
"SELECT COUNT(*) FROM images WHERE trashed_at IS NULL",
[],
|r| r.get(0),
)?;
let (indexed, without, faces): (i64, i64, i64) = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(faces_found = 0), 0), COALESCE(SUM(faces_found), 0)
FROM face_index fi
JOIN images i ON i.id = fi.image_id
WHERE fi.model_id = ?1 AND i.trashed_at IS NULL",
[model_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
Ok(Coverage {
images: images as u64,
indexed: indexed as u64,
without_faces: without as u64,
faces: faces as u64,
})
}
/// Whether one image has been through this model.
pub fn is_indexed(
conn: &Connection,
image_id: ImageId,
model_id: &str,
) -> Result<bool, CatalogError> {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM face_index WHERE image_id = ?1 AND model_id = ?2)",
rusqlite::params![image_id.0 as i64, model_id],
|r| r.get(0),
)
.map_err(Into::into)
}
/// Forget that an image was indexed, so the next pass looks again.
///
/// What a re-index asks for. Separate from deleting the faces because the two
/// are wanted at different times: clearing the marker alone re-detects and
/// replaces, which is the ordinary "try again with a better proxy" case.
pub fn clear_index_marker(
conn: &Connection,
image_id: ImageId,
model_id: &str,
) -> Result<(), CatalogError> {
conn.execute(
"DELETE FROM face_index WHERE image_id = ?1 AND model_id = ?2",
rusqlite::params![image_id.0 as i64, model_id],
)?;
Ok(())
}
/// Every face on one image.
pub fn for_image(conn: &Connection, image_id: ImageId) -> Result<Vec<Face>, CatalogError> {
let mut q = conn.prepare(
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
f.detector_confidence, f.crop_px, f.model_id,
fp.person_id, fp.probability, fp.confirmed
FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = ?1
ORDER BY f.w * f.h DESC",
)?;
let rows = q.query_map([image_id.0 as i64], read_face)?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// Faces with no identity yet, for the clustering pass to work on.
///
/// Rejections are not exclusions here: a face rejected from one person is still
/// unassigned and still belongs in the next clustering round, just not in that
/// person's cluster.
pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, CatalogError> {
let mut q = conn.prepare(
"SELECT f.id FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE fp.face_id IS NULL AND f.model_id = ?1",
)?;
let rows = q.query_map([model_id], |r| Ok(FaceId(r.get::<_, i64>(0)? as u64)))?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// One face's stored embedding, as the clustering pass consumes it.
///
/// A named type rather than a tuple because it crosses a crate boundary and
/// "the third element" is not a thing anyone should have to remember.
pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
/// Embeddings for clustering, oldest first so the pass is deterministic.
///
/// Returned as raw f16 blobs rather than decoded vectors: the caller is
/// `dr-face`, which owns the decoding, and a catalog that widened them here
/// would double the memory of the one operation that holds them all at once.
pub fn embeddings(conn: &Connection, model_id: &str) -> Result<Vec<StoredEmbedding>, CatalogError> {
let mut q = conn.prepare(
"SELECT id, image_id, embedding, crop_px FROM faces
WHERE model_id = ?1 ORDER BY id",
)?;
let rows = q.query_map([model_id], |r| {
Ok((
FaceId(r.get::<_, i64>(0)? as u64),
ImageId(r.get::<_, i64>(1)? as u64),
r.get::<_, Vec<u8>>(2)?,
r.get::<_, f64>(3)? as f32,
))
})?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
// ── people ────────────────────────────────────────────────────────────────
/// Create a person.
///
/// A person is a UUID first and a name second (FR-CULL-10): the name is what
/// the user typed, the uuid is what a cross-device merge keys on, and renaming
/// changes only the former.
pub fn create_person(conn: &Connection, name: &str) -> Result<PersonId, CatalogError> {
let now = now_secs();
conn.execute(
"INSERT INTO people (uuid, name, created, revision, modified)
VALUES (?1, ?2, ?3, 1, ?3)",
rusqlite::params![new_uuid(), name, now],
)?;
Ok(PersonId(conn.last_insert_rowid() as u64))
}
/// Rename a person. The identity is untouched.
pub fn rename_person(conn: &Connection, person: PersonId, name: &str) -> Result<(), CatalogError> {
conn.execute(
"UPDATE people SET name = ?2, revision = revision + 1, modified = ?3
WHERE id = ?1",
rusqlite::params![person.0 as i64, name, now_secs()],
)?;
Ok(())
}
/// Everyone in the library, with their face counts, most-photographed first.
///
/// Merged-away people are excluded: they exist as redirects so a sync does not
/// resurrect them, not as entries in a list.
pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
let mut q = conn.prepare(
"SELECT p.id, p.uuid, p.name,
COALESCE(SUM(fp.confirmed = 1), 0),
COALESCE(SUM(fp.confirmed = 0), 0)
FROM people p
LEFT JOIN face_person fp ON fp.person_id = p.id
WHERE p.merged_into IS NULL
GROUP BY p.id
ORDER BY 4 DESC, 5 DESC, p.name",
)?;
let rows = q.query_map([], |r| {
Ok(Person {
id: PersonId(r.get::<_, i64>(0)? as u64),
uuid: r.get(1)?,
name: r.get(2)?,
confirmed_faces: r.get::<_, i64>(3)? as u64,
suggested_faces: r.get::<_, i64>(4)? as u64,
})
})?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// Fold `source` into `target`, keeping `target`'s identity.
///
/// The source is not deleted. It is left as a redirect, because a device that
/// still holds it would otherwise resurrect it on the next sync — the same
/// hazard collections have, solved the same way (FR-CAT-7).
///
/// Confirmations survive the move: a face the user confirmed as the source
/// person is now a confirmed face of the target, which is what the user meant
/// by saying they are the same person.
pub fn merge_people(
conn: &Connection,
target: PersonId,
source: PersonId,
) -> Result<u64, CatalogError> {
if target == source {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
// A face already assigned to the target must not gain a second row —
// `face_person` is keyed by face. Where both hold the same face, the
// target's row wins and the source's is dropped.
tx.execute(
"DELETE FROM face_person
WHERE person_id = ?2
AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)",
rusqlite::params![target.0 as i64, source.0 as i64],
)?;
let moved = tx.execute(
"UPDATE face_person SET person_id = ?1 WHERE person_id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64],
)?;
tx.execute(
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3
WHERE id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()],
)?;
tx.commit()?;
Ok(moved as u64)
}
/// Follow a merge redirect to the person that outlived it.
pub fn resolve_person(conn: &Connection, person: PersonId) -> Result<PersonId, CatalogError> {
let mut at = person;
// Bounded rather than `loop`: a redirect cycle would otherwise hang the UI
// thread, and a corrupt index is exactly the case this has to survive.
for _ in 0..32 {
let next: Option<i64> = conn
.query_row(
"SELECT merged_into FROM people WHERE id = ?1",
[at.0 as i64],
|r| r.get(0),
)
.optional()?
.flatten();
match next {
Some(n) => at = PersonId(n as u64),
None => return Ok(at),
}
}
Ok(at)
}
// ── assignment ────────────────────────────────────────────────────────────
/// Record the system's guess that a face is a person.
///
/// Never overwrites a confirmation. That is the invariant FR-CULL-10 turns on:
/// a later inference pass may revise every suggestion it likes and may not
/// touch a single thing the user asserted.
pub fn suggest(
conn: &Connection,
face: FaceId,
person: PersonId,
probability: f32,
) -> Result<bool, CatalogError> {
// A rejection is a standing instruction, not a one-off: re-suggesting a
// face the user pushed away from this person is the behaviour that makes
// the feature feel broken.
let rejected: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM face_person_rejected
WHERE face_id = ?1 AND person_id = ?2)",
rusqlite::params![face.0 as i64, person.0 as i64],
|r| r.get(0),
)?;
if rejected {
return Ok(false);
}
let n = conn.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, 0)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = excluded.probability
WHERE face_person.confirmed = 0",
rusqlite::params![face.0 as i64, person.0 as i64, probability as f64],
)?;
Ok(n > 0)
}
/// The user says this face is this person.
pub fn confirm(conn: &Connection, face: FaceId, person: PersonId) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
// Confirming overrides an earlier rejection of the same pair: the user has
// changed their mind, and the newer judgement is the one that counts.
tx.execute(
"DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2",
rusqlite::params![face.0 as i64, person.0 as i64],
)?;
tx.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, 1.0, 1)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = 1.0,
confirmed = 1",
rusqlite::params![face.0 as i64, person.0 as i64],
)?;
tx.commit()?;
Ok(())
}
/// The user says this face is **not** this person.
///
/// Stored rather than implied by removal, so the next clustering pass does not
/// re-suggest it. This is user data in the same sense a confirmation is
/// (FR-CULL-12) — a judgement, just a negative one.
pub fn reject(conn: &Connection, face: FaceId, person: PersonId) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
tx.execute(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
rusqlite::params![face.0 as i64, person.0 as i64],
)?;
tx.execute(
"DELETE FROM face_person WHERE face_id = ?1 AND person_id = ?2",
rusqlite::params![face.0 as i64, person.0 as i64],
)?;
tx.commit()?;
Ok(())
}
/// Detach a face from whoever it is assigned to, without asserting anything.
///
/// Distinct from [`reject`]: this is "I do not know", and the face returns to
/// the pool for the next clustering pass to place. Rejection is "not this
/// person", and is remembered.
pub fn unassign(conn: &Connection, face: FaceId) -> Result<(), CatalogError> {
conn.execute(
"DELETE FROM face_person WHERE face_id = ?1",
[face.0 as i64],
)?;
Ok(())
}
/// Faces belonging to a person.
///
/// `include_suggested` defaults to false at every call site that matters:
/// FR-CULL-11 requires a saved collection not to change membership silently
/// when a later indexing pass revises a guess.
pub fn for_person(
conn: &Connection,
person: PersonId,
include_suggested: bool,
) -> Result<Vec<Face>, CatalogError> {
let mut q = conn.prepare(
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
f.detector_confidence, f.crop_px, f.model_id,
fp.person_id, fp.probability, fp.confirmed
FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
ORDER BY fp.confirmed DESC, fp.probability DESC",
)?;
let rows = q.query_map(
rusqlite::params![person.0 as i64, include_suggested],
read_face,
)?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// The photographs a person appears in — the mapping back to files.
///
/// Distinct images, not faces: a person photographed twice in one frame is one
/// picture of them. This is what [`dr_types::Selector`]'s person term compiles
/// against and what the grid filters on (FR-CULL-11).
pub fn images_for_person(
conn: &Connection,
person: PersonId,
include_suggested: bool,
) -> Result<Vec<ImageId>, CatalogError> {
let mut q = conn.prepare(
"SELECT DISTINCT f.image_id
FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
ORDER BY f.image_id",
)?;
let rows = q.query_map(rusqlite::params![person.0 as i64, include_suggested], |r| {
Ok(ImageId(r.get::<_, i64>(0)? as u64))
})?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
// ── calibration ───────────────────────────────────────────────────────────
/// Store a fitted calibration, replacing any previous fit for the model.
pub fn put_calibration(
conn: &Connection,
model_id: &str,
cal: &Calibration,
face_set_hash: &str,
) -> Result<(), CatalogError> {
conn.execute(
"INSERT INTO face_calibration
(model_id, a, b, w_size, valid, positive_pairs, negative_pairs,
face_set_hash, fitted_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(model_id) DO UPDATE SET
a = excluded.a, b = excluded.b, w_size = excluded.w_size,
valid = excluded.valid,
positive_pairs = excluded.positive_pairs,
negative_pairs = excluded.negative_pairs,
face_set_hash = excluded.face_set_hash,
fitted_at = excluded.fitted_at",
rusqlite::params![
model_id,
cal.a as f64,
cal.b as f64,
cal.w_size as f64,
cal.valid,
cal.positive_pairs as i64,
cal.negative_pairs as i64,
face_set_hash,
now_secs(),
],
)?;
Ok(())
}
/// The calibration for a model, and the face set it was fitted from.
///
/// Returns `None` when there is no fit at all — distinct from a fit that exists
/// and is [`Calibration::valid`]`== false`, which means it was attempted and
/// there was not enough evidence. The UI says different things about those two.
pub fn calibration(
conn: &Connection,
model_id: &str,
) -> Result<Option<(Calibration, String)>, CatalogError> {
conn.query_row(
"SELECT a, b, w_size, valid, positive_pairs, negative_pairs, face_set_hash
FROM face_calibration WHERE model_id = ?1",
[model_id],
|r| {
Ok((
Calibration {
a: r.get::<_, f64>(0)? as f32,
b: r.get::<_, f64>(1)? as f32,
w_size: r.get::<_, f64>(2)? as f32,
valid: r.get(3)?,
positive_pairs: r.get::<_, i64>(4)? as u64,
negative_pairs: r.get::<_, i64>(5)? as u64,
},
r.get::<_, String>(6)?,
))
},
)
.optional()
.map_err(Into::into)
}
// ── the delete-everything control ─────────────────────────────────────────
/// Delete every face, person, assignment and calibration in the library.
///
/// NFR-SEC-5 requires this to exist as **one action**, reachable without
/// deleting the catalog or any photograph. It is deliberately not a
/// re-indexing trigger and deliberately not selective: a user asking to remove
/// their face data is not asking to keep some of it.
///
/// Returns the number of faces removed, so the UI can say what happened rather
/// than claiming success silently.
pub fn delete_all_face_data(conn: &Connection) -> Result<u64, CatalogError> {
let tx = conn.unchecked_transaction()?;
let faces: i64 = tx.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))?;
// Order matters only if foreign keys are off, which they may be on an old
// connection; deleting children first is correct either way.
tx.execute("DELETE FROM face_index", [])?;
tx.execute("DELETE FROM face_person_rejected", [])?;
tx.execute("DELETE FROM face_person", [])?;
tx.execute("DELETE FROM faces", [])?;
tx.execute("DELETE FROM people", [])?;
tx.execute("DELETE FROM face_calibration", [])?;
tx.commit()?;
Ok(faces as u64)
}
// ── helpers ───────────────────────────────────────────────────────────────
fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
let person: Option<i64> = r.get(10)?;
Ok(Face {
id: FaceId(r.get::<_, i64>(0)? as u64),
image_id: ImageId(r.get::<_, i64>(1)? as u64),
x: r.get::<_, f64>(2)? as f32,
y: r.get::<_, f64>(3)? as f32,
w: r.get::<_, f64>(4)? as f32,
h: r.get::<_, f64>(5)? as f32,
landmarks: blob_to_landmarks(&r.get::<_, Vec<u8>>(6)?),
confidence: r.get::<_, f64>(7)? as f32,
crop_px: r.get::<_, f64>(8)? as f32,
model_id: r.get(9)?,
person: person.map(|p| PersonId(p as u64)),
probability: r.get::<_, Option<f64>>(11)?.unwrap_or(0.0) as f32,
confirmed: r.get::<_, Option<bool>>(12)?.unwrap_or(false),
})
}
fn landmarks_to_blob(lm: &[(f32, f32); 5]) -> Vec<u8> {
let mut out = Vec::with_capacity(40);
for &(x, y) in lm {
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
}
out
}
fn blob_to_landmarks(b: &[u8]) -> [(f32, f32); 5] {
let mut out = [(0.0_f32, 0.0_f32); 5];
for (i, o) in out.iter_mut().enumerate() {
let at = i * 8;
if at + 8 <= b.len() {
let x = f32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
let y = f32::from_le_bytes([b[at + 4], b[at + 5], b[at + 6], b[at + 7]]);
*o = (x, y);
}
}
out
}
fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
let ix = (a.0 + a.2).min(b.0 + b.2) - a.0.max(b.0);
let iy = (a.1 + a.3).min(b.1 + b.3) - a.1.max(b.1);
if ix <= 0.0 || iy <= 0.0 {
return 0.0;
}
let inter = ix * iy;
let union = a.2 * a.3 + b.2 * b.3 - inter;
if union <= 0.0 {
0.0
} else {
inter / union
}
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// A v4 UUID from the system clock and address-space entropy.
///
/// The same approach `collections` takes: a merge needs identifiers that do not
/// collide across devices, not cryptographic randomness, and this avoids a
/// dependency for one string per person.
fn new_uuid() -> String {
use std::hash::{BuildHasher, Hasher, RandomState};
let mut h = RandomState::new().build_hasher();
h.write_u64(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0),
);
let a = h.finish();
let mut h2 = RandomState::new().build_hasher();
h2.write_u64(a);
h2.write_usize(&h2 as *const _ as usize);
let b = h2.finish();
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
(a >> 32) as u32,
(a >> 16) as u16,
(a & 0x0fff) as u16,
((b >> 48) as u16 & 0x3fff) | 0x8000,
b & 0xffff_ffff_ffff,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema;
fn db() -> Connection {
let c = Connection::open_in_memory().unwrap();
c.execute_batch("PRAGMA foreign_keys = ON").unwrap();
schema::migrate(&c).unwrap();
c
}
fn image(c: &Connection, n: i64) -> ImageId {
c.execute(
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
rusqlite::params![n, format!("IMG_{n}.CR3")],
)
.unwrap();
ImageId(n as u64)
}
fn face(seed: u8) -> DetectedFace {
DetectedFace {
x: 0.1,
y: 0.1,
w: 0.2,
h: 0.3,
landmarks: [
(0.1, 0.1),
(0.2, 0.1),
(0.15, 0.2),
(0.12, 0.25),
(0.18, 0.25),
],
confidence: 0.9,
embedding: vec![seed; 1024],
crop_px: 180.0,
model_id: "w600k_mbf".into(),
}
}
#[test]
fn migration_reaches_version_nine() {
let c = db();
let v: i64 = c
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 9);
}
#[test]
fn detections_round_trip_with_their_landmarks() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got.len(), 2);
assert_eq!(got[0].model_id, "w600k_mbf");
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5);
assert!(got[0].person.is_none());
}
/// Re-detection is coalesced per image, so it must replace rather than
/// append — otherwise every re-index doubles the library's face count.
#[test]
fn re_detection_replaces_rather_than_appending() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
record_detections(&c, img, "w600k_mbf", 1024, &[face(3)]).unwrap();
assert_eq!(for_image(&c, img).unwrap().len(), 1);
}
/// The invariant FR-CULL-10 turns on: a re-index with a better model must
/// not throw away the user's own labelling.
#[test]
fn re_detection_carries_a_confirmation_across() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
// Same face, box nudged the way a better detector would nudge it.
let mut moved = face(9);
moved.x = 0.11;
moved.y = 0.105;
record_detections(&c, img, "w600k_mbf", 1024, &[moved]).unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(
got[0].person,
Some(anna),
"confirmation was lost on re-index"
);
assert!(got[0].confirmed);
}
#[test]
fn a_suggestion_never_overwrites_a_confirmation() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
let bob = create_person(&c, "Bob").unwrap();
confirm(&c, ids[0], anna).unwrap();
let changed = suggest(&c, ids[0], bob, 0.99).unwrap();
assert!(!changed, "a later pass overwrote a user confirmation");
assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna));
}
#[test]
fn a_rejection_stops_the_face_being_suggested_again() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
suggest(&c, ids[0], anna, 0.8).unwrap();
reject(&c, ids[0], anna).unwrap();
assert_eq!(for_image(&c, img).unwrap()[0].person, None);
let again = suggest(&c, ids[0], anna, 0.95).unwrap();
assert!(
!again,
"clustering re-suggested a face the user pushed away"
);
}
#[test]
fn confirming_overrides_an_earlier_rejection() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
reject(&c, ids[0], anna).unwrap();
confirm(&c, ids[0], anna).unwrap();
assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna));
}
#[test]
fn merging_moves_the_faces_and_leaves_a_redirect() {
let c = db();
let i1 = image(&c, 1);
let i2 = image(&c, 2);
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
let annie = create_person(&c, "Annie").unwrap();
confirm(&c, a[0], anna).unwrap();
confirm(&c, b[0], annie).unwrap();
assert_eq!(merge_people(&c, anna, annie).unwrap(), 1);
assert_eq!(for_person(&c, anna, false).unwrap().len(), 2);
assert_eq!(resolve_person(&c, annie).unwrap(), anna);
// The redirect survives so a sync cannot resurrect the merged person.
assert_eq!(people(&c).unwrap().len(), 1);
}
#[test]
fn merging_does_not_duplicate_a_face_both_people_hold() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
let a = create_person(&c, "A").unwrap();
let b = create_person(&c, "B").unwrap();
confirm(&c, ids[0], a).unwrap();
// `face_person` is keyed by face, so B can only hold it after A lets go.
unassign(&c, ids[0]).unwrap();
confirm(&c, ids[0], b).unwrap();
confirm(&c, ids[0], a).unwrap();
merge_people(&c, a, b).unwrap();
assert_eq!(for_person(&c, a, false).unwrap().len(), 1);
}
#[test]
fn images_for_person_counts_a_photograph_once() {
let c = db();
let img = image(&c, 1);
// Two faces of the same person in one frame — a mirror, or a collage.
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
confirm(&c, ids[1], anna).unwrap();
assert_eq!(images_for_person(&c, anna, false).unwrap(), vec![img]);
}
#[test]
fn suggested_faces_are_excluded_from_a_person_by_default() {
let c = db();
let i1 = image(&c, 1);
let i2 = image(&c, 2);
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, a[0], anna).unwrap();
suggest(&c, b[0], anna, 0.8).unwrap();
assert_eq!(for_person(&c, anna, false).unwrap().len(), 1);
assert_eq!(for_person(&c, anna, true).unwrap().len(), 2);
assert_eq!(images_for_person(&c, anna, false).unwrap().len(), 1);
assert_eq!(images_for_person(&c, anna, true).unwrap().len(), 2);
}
#[test]
fn people_reports_confirmed_and_suggested_separately() {
let c = db();
let i1 = image(&c, 1);
let i2 = image(&c, 2);
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, a[0], anna).unwrap();
suggest(&c, b[0], anna, 0.7).unwrap();
let p = people(&c).unwrap();
assert_eq!(p.len(), 1);
assert_eq!(p[0].confirmed_faces, 1);
assert_eq!(p[0].suggested_faces, 1);
assert_eq!(p[0].name, "Anna");
}
#[test]
fn renaming_keeps_the_merge_identity() {
let c = db();
let anna = create_person(&c, "Anna").unwrap();
let before = people(&c).unwrap()[0].uuid.clone();
rename_person(&c, anna, "Anna Smith").unwrap();
let after = &people(&c).unwrap()[0];
assert_eq!(after.name, "Anna Smith");
assert_eq!(after.uuid, before, "a rename must not change identity");
}
#[test]
fn deleting_face_data_leaves_the_photographs_alone() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
reject(&c, ids[1], anna).unwrap();
put_calibration(
&c,
"w600k_mbf",
&Calibration {
a: 16.0,
b: -4.3,
w_size: 0.0,
valid: true,
positive_pairs: 900,
negative_pairs: 90_000,
},
"hash",
)
.unwrap();
assert_eq!(delete_all_face_data(&c).unwrap(), 2);
assert!(for_image(&c, img).unwrap().is_empty());
assert!(people(&c).unwrap().is_empty());
assert!(calibration(&c, "w600k_mbf").unwrap().is_none());
let images: i64 = c
.query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(images, 1, "deleting face data deleted a photograph");
}
#[test]
fn calibration_round_trips_and_its_boundary_inverts_its_probability() {
let c = db();
let cal = Calibration {
a: 16.2,
b: -4.33,
w_size: 0.0,
valid: true,
positive_pairs: 500,
negative_pairs: 50_000,
};
put_calibration(&c, "w600k_mbf", &cal, "abc").unwrap();
let (got, hash) = calibration(&c, "w600k_mbf").unwrap().unwrap();
assert_eq!(hash, "abc");
assert!((got.a - 16.2).abs() < 1e-4);
assert!(got.valid);
for &p in &[0.5_f32, 0.9, 0.99] {
let cos = got.boundary_at(p, 112.0, 0.0);
let back = got.probability(cos, 112.0, 0.0);
assert!((back - p).abs() < 1e-3, "p={p} round-tripped to {back}");
}
}
/// The reference implementation's fitted MBF curve puts the P=0.5 boundary
/// at cosine 0.267 (docs/faces.md §1). Our own first end-to-end run scored
/// 0.596 between distinct photographs of one person and 0.05 between
/// different people, so those two must land either side.
#[test]
fn the_reference_calibration_separates_the_measured_cosines() {
let cal = Calibration {
a: 16.2,
b: -16.2 * 0.267,
w_size: 0.0,
valid: true,
positive_pairs: 0,
negative_pairs: 0,
};
assert!(cal.probability(0.596, 200.0, 0.0) > 0.99);
assert!(cal.probability(0.050, 200.0, 0.0) < 0.05);
assert!((cal.boundary_at(0.5, 200.0, 0.0) - 0.267).abs() < 1e-3);
}
#[test]
fn unassigned_lists_only_faces_with_no_identity_and_the_right_model() {
let c = db();
let i1 = image(&c, 1);
let i2 = image(&c, 2);
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, a[0], anna).unwrap();
assert_eq!(unassigned(&c, "w600k_mbf").unwrap().len(), 1);
assert!(unassigned(&c, "some_other_model").unwrap().is_empty());
}
/// The bug this table exists for: a photograph with no faces must not look
/// like one that has never been indexed, or every landscape in the library
/// is re-examined on every pass, for ever.
#[test]
fn an_image_with_no_faces_still_counts_as_indexed() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[]).unwrap();
assert!(is_indexed(&c, img, "w600k_mbf").unwrap());
assert!(for_image(&c, img).unwrap().is_empty());
let cov = coverage(&c, "w600k_mbf").unwrap();
assert_eq!(cov.indexed, 1);
assert_eq!(cov.without_faces, 1);
assert_eq!(cov.faces, 0);
assert_eq!(cov.outstanding(), 0);
assert!(cov.is_complete());
}
#[test]
fn a_model_change_puts_every_image_back_in_the_queue() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
assert!(is_indexed(&c, img, "w600k_mbf").unwrap());
assert!(
!is_indexed(&c, img, "some_better_model").unwrap(),
"a new model must not inherit the old model's coverage"
);
assert_eq!(coverage(&c, "some_better_model").unwrap().outstanding(), 1);
}
#[test]
fn coverage_counts_images_not_faces() {
let c = db();
let i1 = image(&c, 1);
let i2 = image(&c, 2);
image(&c, 3);
// Three faces across two images; the third image is untouched.
record_detections(&c, i1, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
record_detections(&c, i2, "w600k_mbf", 1024, &[face(3)]).unwrap();
let cov = coverage(&c, "w600k_mbf").unwrap();
assert_eq!(cov.images, 3);
assert_eq!(cov.indexed, 2);
assert_eq!(cov.faces, 3);
assert_eq!(cov.outstanding(), 1);
assert!(!cov.is_complete());
assert!((cov.fraction() - 2.0 / 3.0).abs() < 1e-6);
}
#[test]
fn re_indexing_one_image_updates_its_marker_rather_than_adding_a_second() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
record_detections(&c, img, "w600k_mbf", 2048, &[face(3)]).unwrap();
let cov = coverage(&c, "w600k_mbf").unwrap();
assert_eq!(cov.indexed, 1, "a second marker row was written");
assert_eq!(cov.faces, 1);
let edge: i64 = c
.query_row(
"SELECT source_edge FROM face_index WHERE image_id = ?1",
[img.0 as i64],
|r| r.get(0),
)
.unwrap();
assert_eq!(edge, 2048, "the marker should record the newer proxy");
}
#[test]
fn clearing_a_marker_puts_that_image_back_in_the_queue() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
clear_index_marker(&c, img, "w600k_mbf").unwrap();
assert!(!is_indexed(&c, img, "w600k_mbf").unwrap());
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 1);
// The faces themselves are untouched: clearing a marker asks for a
// re-detection, not a deletion.
assert_eq!(for_image(&c, img).unwrap().len(), 1);
}
#[test]
fn an_empty_library_is_complete_rather_than_zero_percent() {
let c = db();
let cov = coverage(&c, "w600k_mbf").unwrap();
assert!(cov.is_complete());
assert!((cov.fraction() - 1.0).abs() < 1e-6);
}
/// Deleting face data must clear the run markers too, or the library
/// reports itself fully indexed while holding no faces at all — and the
/// sweep then refuses to rebuild what the user just asked to remove.
#[test]
fn deleting_face_data_clears_the_run_markers() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
delete_all_face_data(&c).unwrap();
assert!(!is_indexed(&c, img, "w600k_mbf").unwrap());
let cov = coverage(&c, "w600k_mbf").unwrap();
assert_eq!(cov.indexed, 0);
assert_eq!(cov.outstanding(), 1);
}
#[test]
fn embeddings_come_back_as_stored() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face(7)]).unwrap();
let e = embeddings(&c, "w600k_mbf").unwrap();
assert_eq!(e.len(), 1);
assert_eq!(e[0].1, img);
assert_eq!(e[0].2.len(), 1024);
assert_eq!(e[0].2[0], 7);
assert!((e[0].3 - 180.0).abs() < 1e-3);
}
}