Files
DarkRoom/core/dr-catalog/src/faces.rs
T

2869 lines
109 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/dev/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.
//!
//! # One population per embedder, not one per detector
//!
//! `faces.model_id` names a pipeline, `detector+embedder`, and a detector
//! change writes new rows under a new id. What it must *not* do is split the
//! library in two: the People screen, the clustering pass, the sync merge and
//! the shard import all read "the faces" — and the faces are every row whose
//! embedder half matches, because the embedder is what makes two vectors
//! comparable and the detector only decides where the boxes are. Before this
//! rule each of those read the exact id, and choosing a better detector
//! emptied every screen until a 400 GB re-index had run on every device,
//! while a name confirmed on one device could not reach the other because the
//! two held the same face under different ids.
//!
//! So reads key on [`embedder_of`] the id, via [`embedder_sql`]; writes keep
//! the exact id, so which detector drew a box stays recorded; and
//! [`record_detections`] is where the generations meet — an image holds one
//! pipeline's faces at a time, and a re-detection carries the user's
//! confirmations onto the boxes that replace them.
//!
//! # 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;
use dr_face::{Eye, EyeReading};
/// The embedder half of a model id: what makes two faces comparable.
///
/// `scrfd_10g+w600k_mbf` and `w600k_mbf` are the same embedder behind two
/// detectors, and their vectors live in one space. A bare id is its own
/// embedder — the first pipeline was written without a detector prefix, and
/// every library indexed before the choice existed is under that spelling.
pub fn embedder_of(model_id: &str) -> &str {
model_id.rsplit('+').next().unwrap_or(model_id)
}
/// The SQL for [`embedder_of`] over a column, for a `WHERE` that means "the
/// same population as this pipeline" rather than "this exact pipeline".
///
/// Callers pass `embedder_of(model_id)` as the bound value. A `CASE` rather
/// than `LIKE`, so the bare and the qualified spelling compare as the same
/// thing without a wildcard that `w600k_mbf_v2` would also match.
pub fn embedder_sql(column: &str) -> String {
format!(
"CASE WHEN instr({column}, '+') > 0
THEN substr({column}, instr({column}, '+') + 1)
ELSE {column} END"
)
}
/// 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, the raw model output — `dr_face::Embedded::to_f16_bytes`.
///
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
/// the blob and not only beside it. Readers re-normalise on load.
pub embedding: Vec<u8>,
/// Source pixels across the aligned crop (docs/dev/faces.md §7).
pub crop_px: f32,
/// Length of the raw embedding before normalisation — the model's own
/// reading of how recognisable the crop was, and the gate on whether
/// this face may be compared *against* (`dr_face::MIN_GALLERY_QUALITY`).
///
/// `None` where it was never measured: a face indexed, here or by a peer,
/// before raw vectors were stored. The unit vector those builds kept has
/// no length left to read, so the only way to measure one is to embed it
/// again (schema V14).
pub quality: Option<f32>,
/// TRACES: FR-CULL-8a
/// What the eyes are doing — P(open) per eye and P(sunglasses), read by
/// `dr_face::classify` from the same aligned crop. `None` where the eye
/// models were not present at indexing, or the face came from a peer
/// that had none; the measuring pass fills it in (schema V16).
pub eyes: Option<EyeReading>,
/// The 106 dense landmarks the eyes were read from, as
/// `dr_face::Landmarks::to_packed_bytes` — 424 bytes, or empty where
/// the face was never read (schema V18). Kept so a later per-face pass
/// need not fetch the original again.
pub landmarks_dense: Vec<u8>,
/// Which model produced the embedding. Comparing across models is the one
/// mistake that yields plausible garbage rather than an error.
pub model_id: String,
/// The face itself, cut out and encoded, ready to draw.
///
/// Cut at detection time because that is the one moment the pixels are
/// already in memory. The alternative -- and what this replaced -- is
/// re-decoding the whole proxy and cutting the box out again every time
/// the People screen opens, which makes the screen a derivative of a cache
/// that is entitled to evict anything at any time.
///
/// Empty is allowed and means "not cut": a caller with only a box and an
/// embedding, such as a face adopted from a peer's shard that predates
/// crops, stores nothing here and the reader falls back to the proxy.
pub crop: Vec<u8>,
}
/// 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,
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
/// recorded.
pub quality: Option<f32>,
/// See [`DetectedFace::eyes`]. `None` for a face never read.
pub eyes: Option<EyeReading>,
/// See [`DetectedFace::landmarks_dense`]. Empty for a face never read.
pub landmarks_dense: Vec<u8>,
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 user has looked at this group and does not want to identify it.
///
/// Distinct from an empty name, which means *not yet looked at*. Most
/// clusters in a real library are strangers, and without somewhere to put
/// that judgement the screen never gets shorter however much work the user
/// does on it.
pub ignored: bool,
}
/// 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 ─────────────────────────────────────────────────────────────
/// The cosine above which two vectors from one embedder are taken to be
/// the same face in the same photograph, for carrying an identity across a
/// re-detection.
///
/// Set at the reference library's P≈0.95 line (docs/dev/faces.md §9's table:
/// 0.449), which is far above anything two different people in one frame
/// reach and below what one face re-embedded from a better crop of itself
/// does. The number is only ever asked about *overlapping* boxes on *one*
/// image, which is what makes such a loose figure safe: the question is not
/// "is this the same person" but "is this the same face, now that the box
/// has moved".
pub const SAME_FACE_COSINE: f32 = 0.45;
/// What one stored face knew about itself, read before a re-detection
/// replaces it.
struct Prior {
x: f32,
y: f32,
w: f32,
h: f32,
/// Decoded and unit length, under the *embedder* half of its id so a
/// cosine against a new face from the same embedder is defined.
embedding: Option<dr_face::Embedding>,
/// `(person, probability, confirmed)`, where the face had one.
assignment: Option<(i64, f64, bool)>,
rejected: Vec<i64>,
}
/// 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.
///
/// # What survives the replacement
///
/// **The identity of every face the new detection finds again** — the user's
/// confirmations first of all (FR-CULL-10), and with them the suggestions
/// the last grouping pass made and the people the user has said a face is
/// *not*. A re-index with a better model must not discard the user's
/// labelling, and it should not empty the People screen either: a library
/// of thirteen thousand suggestions re-detected into thirteen thousand
/// unassigned faces is correct by the letter of FR-CULL-12 and looks, to
/// the person who spent an afternoon on it, like the work was thrown away.
///
/// A new face is the same face as an old one when the two **overlap in the
/// frame and either the boxes agree or the embeddings do**: an IoU above 0.5,
/// which is loose on purpose because a better detector is entitled to move
/// the box; or a cosine above [`SAME_FACE_COSINE`] between the vectors, for
/// the box a low-resolution pass drew badly enough that overlap alone would
/// not claim it. The embedding is also what breaks a tie — two faces side by
/// side in a group photograph both overlap both new boxes, and the vector
/// says which is which where the rectangles cannot. Each old face is
/// carried onto at most one new one, best pair first.
///
/// **Every model's faces are replaced, and every other model's marker goes
/// with them.** An image holds the faces of whichever pipeline looked at it
/// last, never a mixture — two detectors drawing boxes over the same face is
/// not two opinions but a duplicate. So the replacement is unconditional on
/// `model_id`, and the run markers of the pipelines whose faces were just
/// removed are dropped too: a marker that says "done" over an image with
/// none of that model's faces is exactly the state that made the V12 repair
/// necessary, and a user who switches their detector back would otherwise
/// find those photographs permanently empty.
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()?;
let ids = record_detections_within(&tx, image_id, model_id, source_edge, faces)?;
tx.commit()?;
Ok(ids)
}
/// [`record_detections`] inside a transaction the caller owns.
///
/// For a caller recording many images at once — the shard import adopts
/// fourteen thousand in one pass — where a commit per image is fourteen
/// thousand fsyncs and fourteen thousand turns at the write lock that every
/// read on the UI thread queues behind. `unchecked_transaction` cannot nest,
/// so the batching has to be offered here rather than wrapped from above.
pub fn record_detections_within(
tx: &Connection,
image_id: ImageId,
model_id: &str,
source_edge: u32,
faces: &[DetectedFace],
) -> Result<Vec<FaceId>, CatalogError> {
// Everything the old faces knew, so it can be carried across the
// replacement. Read only when there is something to carry it onto: a
// pass that found nothing has nothing to match, and decoding a vector
// per face for no reader would be the wasted work.
let prior = if faces.is_empty() {
Vec::new()
} else {
read_priors(tx, image_id)?
};
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
tx.execute(
"DELETE FROM face_index WHERE image_id = ?1 AND model_id != ?2",
rusqlite::params![image_id.0 as i64, model_id],
)?;
let carried = match_priors(&prior, faces);
let now = now_secs();
let mut ids = Vec::with_capacity(faces.len());
for (i, f) in faces.iter().enumerate() {
tx.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence,
embedding, crop_px, model_id, detected_at, crop, quality,
eye_right, eye_right_px, eye_right_sharp,
eye_left, eye_left_px, eye_left_sharp, sunglasses, landmarks_dense)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
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,
// NULL rather than an empty blob, so "no crop" is one state in
// the database rather than two the readers each have to know
// about.
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
f.quality.map(f64::from),
f.eyes.map(|e| f64::from(e.right.open)),
f.eyes.map(|e| f64::from(e.right.px)),
f.eyes.map(|e| f64::from(e.right.sharpness)),
f.eyes.map(|e| f64::from(e.left.open)),
f.eyes.map(|e| f64::from(e.left.px)),
f.eyes.map(|e| f64::from(e.left.sharpness)),
f.eyes.map(|e| f64::from(e.sunglasses)),
(!f.landmarks_dense.is_empty()).then_some(f.landmarks_dense.as_slice()),
],
)?;
let id = FaceId(tx.last_insert_rowid() as u64);
if let Some(p) = carried[i].map(|at| &prior[at]) {
if let Some((person, prob, confirmed)) = p.assignment {
tx.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![id.0 as i64, person, prob, confirmed],
)?;
}
for person in &p.rejected {
tx.execute(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
rusqlite::params![id.0 as i64, person],
)?;
}
}
ids.push(id);
}
let lost = prior
.iter()
.enumerate()
.filter(|(at, p)| p.assignment.is_some() && !carried.contains(&Some(*at)))
.count();
if lost > 0 {
log::debug!(
"image {}: {lost} assigned face(s) not found again by the re-detection",
image_id.0
);
}
// 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,
],
)?;
Ok(ids)
}
/// The faces on an image as they stand, with everything a re-detection
/// carries forward.
fn read_priors(tx: &Connection, image_id: ImageId) -> Result<Vec<Prior>, CatalogError> {
let mut out: Vec<(i64, Prior)> = Vec::new();
{
let mut q = tx.prepare(
"SELECT f.id, f.x, f.y, f.w, f.h, f.model_id, f.embedding,
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.id",
)?;
let rows = q.query_map([image_id.0 as i64], |r| {
let model: String = r.get(5)?;
let blob: Vec<u8> = r.get(6)?;
let person: Option<i64> = r.get(7)?;
Ok((
r.get::<_, i64>(0)?,
Prior {
x: r.get::<_, f64>(1)? as f32,
y: r.get::<_, f64>(2)? as f32,
w: r.get::<_, f64>(3)? as f32,
h: r.get::<_, f64>(4)? as f32,
embedding: dr_face::Embedding::from_f16_bytes(
dr_face::ModelId::new(embedder_of(&model).to_string()),
&blob,
),
assignment: match person {
Some(p) => Some((p, r.get::<_, f64>(8)?, r.get::<_, i64>(9)? != 0)),
None => None,
},
rejected: Vec::new(),
},
))
})?;
for row in rows {
out.push(row?);
}
}
{
let mut q = tx.prepare(
"SELECT fr.face_id, fr.person_id
FROM face_person_rejected fr
JOIN faces f ON f.id = fr.face_id
WHERE f.image_id = ?1",
)?;
let rows = q.query_map([image_id.0 as i64], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
})?;
for row in rows {
let (face, person) = row?;
if let Some((_, p)) = out.iter_mut().find(|(id, _)| *id == face) {
p.rejected.push(person);
}
}
}
Ok(out.into_iter().map(|(_, p)| p).collect())
}
/// Which old face, if any, each new detection replaces — indexed like
/// `faces`, holding an index into `prior`.
///
/// One-to-one, best pair first: every qualifying pair is scored by how much
/// the boxes and the vectors agree, and pairs are taken in that order until
/// each side is spoken for. A pair qualifies when the boxes overlap at all
/// and either the overlap alone says so (IoU above 0.5) or the embeddings do
/// ([`SAME_FACE_COSINE`]). Overlap is required on both routes, because two
/// faces of one person in one frame — a mirror, a photograph within the
/// photograph — have the same vector and must not swap names.
fn match_priors(prior: &[Prior], faces: &[DetectedFace]) -> Vec<Option<usize>> {
let mut pairs: Vec<(f32, usize, usize)> = Vec::new();
for (pi, p) in prior.iter().enumerate() {
let old = (p.x, p.y, p.w, p.h);
for (fi, f) in faces.iter().enumerate() {
let overlap = iou(old, (f.x, f.y, f.w, f.h));
if overlap <= 0.0 {
continue;
}
let cosine = p
.embedding
.as_ref()
.and_then(|old| {
let new = dr_face::Embedding::from_f16_bytes(
dr_face::ModelId::new(embedder_of(&f.model_id).to_string()),
&f.embedding,
)?;
old.cosine(&new)
})
.unwrap_or(0.0);
if overlap > 0.5 || cosine >= SAME_FACE_COSINE {
pairs.push((overlap + cosine.max(0.0), pi, fi));
}
}
}
pairs.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut carried = vec![None; faces.len()];
let mut taken = vec![false; prior.len()];
for (_, pi, fi) in pairs {
if taken[pi] || carried[fi].is_some() {
continue;
}
taken[pi] = true;
carried[fi] = Some(pi);
}
carried
}
/// What a per-face pass wants written over one stored face.
///
/// Each field is `Some` where the pass produced it and `None` where it is
/// to be left exactly as it was: a device without the eye models writing a
/// fresh vector must not blank a reading a peer had already made. One
/// struct for every pass rather than one writer per column, so a new
/// per-face field is a field here and a handler in `dr_ui::faces::repairs`,
/// and nothing else.
#[derive(Debug, Clone, PartialEq)]
pub struct FaceUpdate {
pub face: FaceId,
/// The raw vector (`dr_face::Embedded::to_f16_bytes`) and its length.
pub embedding: Option<(Vec<u8>, f32)>,
/// See [`DetectedFace::eyes`], with the dense landmarks it was read from
/// (see [`DetectedFace::landmarks_dense`]; empty stores NULL).
pub eyes: Option<(EyeReading, Vec<u8>)>,
/// See [`DetectedFace::crop`]. An empty crop is not written.
pub crop: Option<Vec<u8>>,
}
impl FaceUpdate {
/// An update that changes nothing yet, for a handler to fill one field of.
pub fn for_face(face: FaceId) -> Self {
Self {
face,
embedding: None,
eyes: None,
crop: None,
}
}
}
/// Write what a per-face pass produced over the faces it read, and re-mark
/// the image as indexed.
///
/// The cheaper half of what `record_detections` does, for a face whose box
/// and landmarks are right and whose identity is the user's work, and which
/// lacks something a later pass can fill from the native render: its
/// quality (schema V14), its eye reading and dense landmarks (V16, V18), its
/// crop. Updating in place is what keeps `face_person` and the face ids
/// exactly as they were -- a re-detection carries identities across by
/// matching old faces to new, and a match is a judgement where an update
/// in place is a fact.
///
/// `dropped` are faces whose landmarks turned out to be degenerate -- the
/// warp could not be built from them. Deleted here, as detection would have
/// refused to store them (`dr_ui::faces::index_native`), and because a face
/// that can never be filled would put its image back on the pass's list on
/// every sweep, at the cost of an original each time.
///
/// The run marker is re-written with a fresh time, and that is not
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
/// marker is newer than the store's copy, which is how what was written
/// here reaches the other devices.
///
/// It is re-written under the pipeline id the **faces carry**, not the one
/// this pass ran as. `model_id` names the pass only through its embedder;
/// the detector half of a marker is a statement about who drew the boxes,
/// and this pass drew none. Every reader takes the two to agree: the export
/// selects an image's faces by the marker's id, `marker_under` takes a
/// marker as proof the detector has been over the image, and the shard
/// store keys each face by it. When the marker was written as
/// `scrfd_10g+w600k_mbf` over faces still spelled `w600k_mbf`, the export
/// found no faces under it and sent the other devices an entry saying the
/// thorough detector had looked and found nothing — over photographs with
/// named faces on them. With no faces left, the pass's own id is the only
/// one there is, and the marker says so.
pub fn record_updates(
conn: &Connection,
image_id: ImageId,
model_id: &str,
source_edge: u32,
updates: &[FaceUpdate],
dropped: &[FaceId],
) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
for u in updates {
if let Some((embedding, quality)) = &u.embedding {
tx.execute(
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
rusqlite::params![u.face.0 as i64, embedding, f64::from(*quality)],
)?;
}
if let Some((e, dense)) = &u.eyes {
tx.execute(
"UPDATE faces
SET eye_right = ?2, eye_right_px = ?3, eye_right_sharp = ?4,
eye_left = ?5, eye_left_px = ?6, eye_left_sharp = ?7,
sunglasses = ?8, landmarks_dense = ?9
WHERE id = ?1",
rusqlite::params![
u.face.0 as i64,
f64::from(e.right.open),
f64::from(e.right.px),
f64::from(e.right.sharpness),
f64::from(e.left.open),
f64::from(e.left.px),
f64::from(e.left.sharpness),
f64::from(e.sunglasses),
(!dense.is_empty()).then_some(dense.as_slice()),
],
)?;
}
if let Some(crop) = u.crop.as_ref().filter(|c| !c.is_empty()) {
tx.execute(
"UPDATE faces SET crop = ?2 WHERE id = ?1",
rusqlite::params![u.face.0 as i64, crop],
)?;
}
}
for f in dropped {
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
}
let (remaining, found_by): (i64, Option<String>) = tx.query_row(
&format!(
"SELECT COUNT(*), MIN(model_id) FROM faces WHERE image_id = ?1 AND {} = ?2",
embedder_sql("model_id")
),
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
let marker = found_by.as_deref().unwrap_or(model_id);
// One marker per embedder: a stale one under another spelling would
// keep saying that detector had been here, which is the claim the
// faces' own id is now making in its place.
tx.execute(
&format!(
"DELETE FROM face_index
WHERE image_id = ?1 AND model_id != ?2 AND {} = ?3",
embedder_sql("model_id")
),
rusqlite::params![image_id.0 as i64, marker, embedder_of(model_id)],
)?;
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,
marker,
now_secs(),
remaining,
source_edge as i64,
],
)?;
tx.commit()?;
Ok(())
}
/// The faces on one image that a per-face pass still owes something to.
///
/// `needs` is SQL over `faces` aliased as `f` -- `f.quality IS NULL`, say --
/// and it comes from the pass, not from here: which columns a face can lack
/// is the business of the handlers that fill them (`dr_ui::faces::repairs`),
/// and the catalog's part is to answer the question exactly as asked, so
/// that the count a screen shows, the list a sweep fetches and the faces a
/// handler is given are one predicate and the pass converges.
///
/// Keyed on the embedder half of `model_id`, like every other reader: a
/// face found by another detector in front of the same embedder is one of
/// this pipeline's faces.
pub fn faces_needing(
conn: &Connection,
image_id: ImageId,
model_id: &str,
needs: &str,
) -> Result<Vec<Face>, CatalogError> {
let mut q = conn.prepare(&format!(
"SELECT f.id FROM faces f
WHERE f.image_id = ?1 AND {} = ?2 AND ({needs})",
embedder_sql("f.model_id")
))?;
let owed: std::collections::HashSet<i64> = q
.query_map(
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|r| r.get::<_, i64>(0),
)?
.collect::<Result<_, _>>()?;
Ok(for_image(conn, image_id)?
.into_iter()
.filter(|f| owed.contains(&(f.id.0 as i64)))
.collect())
}
/// How many of a model's faces a per-face pass still owes something to.
/// `needs` is what it is in [`faces_needing`].
pub fn count_needing(conn: &Connection, model_id: &str, needs: &str) -> Result<u64, CatalogError> {
conn.query_row(
&format!(
"SELECT COUNT(*) FROM faces f WHERE {} = ?1 AND ({needs})",
embedder_sql("f.model_id")
),
[embedder_of(model_id)],
|r| r.get::<_, i64>(0),
)
.map(|n| n as u64)
.map_err(Into::into)
}
/// 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, and not the
/// shadowed half of a RAW+JPEG pair. The same population every sweep's
/// work list is drawn from; see [`coverage`] for why that matters.
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.
///
/// # Shadowed images are not candidates, and the denominator has to agree
///
/// A shadowed image is the JPEG half of a RAW+JPEG pair. It is not a separate
/// photograph — the grid does not show it, and every sweep that builds a work
/// list excludes it. Counting it here anyway is not a rounding error: on the
/// reference library it put 4,424 images into the denominator that no pass is
/// permitted to touch, so "4,593 outstanding" had a floor of 4,424 that no
/// amount of indexing could ever bring down, and [`Coverage::is_complete`]
/// could never once return true. A progress figure that cannot reach its own
/// target reads exactly like a stuck job, which is what it was taken for.
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 AND shadowed_by IS NULL",
[],
|r| r.get(0),
)?;
// One row per image, whichever compatible pipeline wrote it: an image
// holds one pipeline's faces at a time (`record_detections`), so the
// markers of one embedder never double-count a photograph.
let (indexed, without, faces): (i64, i64, i64) = conn.query_row(
&format!(
"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 {} = ?1
AND i.trashed_at IS NULL AND i.shadowed_by IS NULL",
embedder_sql("fi.model_id")
),
[embedder_of(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, or one it shares an
/// embedder with.
pub fn is_indexed(
conn: &Connection,
image_id: ImageId,
model_id: &str,
) -> Result<bool, CatalogError> {
conn.query_row(
&format!(
"SELECT EXISTS(SELECT 1 FROM face_index WHERE image_id = ?1 AND {} = ?2)",
embedder_sql("model_id")
),
rusqlite::params![image_id.0 as i64, embedder_of(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(
&format!(
"DELETE FROM face_index WHERE image_id = ?1 AND {} = ?2",
embedder_sql("model_id")
),
rusqlite::params![image_id.0 as i64, embedder_of(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, f.quality,
f.eye_right, f.eye_right_px, f.eye_right_sharp,
f.eye_left, f.eye_left_px, f.eye_left_sharp, f.sunglasses,
f.landmarks_dense
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(&format!(
"SELECT f.id FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE fp.face_id IS NULL AND {} = ?1",
embedder_sql("f.model_id")
))?;
let rows = q.query_map([embedder_of(model_id)], |r| {
Ok(FaceId(r.get::<_, i64>(0)? as u64))
})?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// How many faces have no identity yet — [`unassigned`] counted rather than
/// listed, for a screen that only shows the number.
pub fn count_unassigned(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
let n: i64 = conn.query_row(
&format!(
"SELECT COUNT(*) FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE fp.face_id IS NULL AND {} = ?1",
embedder_sql("f.model_id")
),
[embedder_of(model_id)],
|r| r.get(0),
)?;
Ok(n as u64)
}
/// One face's stored embedding, as the clustering pass consumes it.
///
/// A struct rather than a tuple because it crosses a crate boundary and "the
/// fourth element" is not a thing anyone should have to remember.
#[derive(Debug, Clone, PartialEq)]
pub struct StoredEmbedding {
pub face: FaceId,
pub image: ImageId,
/// 512 × f16 — `dr_face::Embedding::from_f16_bytes` reads it.
pub embedding: Vec<u8>,
pub crop_px: f32,
/// See [`DetectedFace::quality`].
pub quality: Option<f32>,
}
/// Embeddings for clustering, oldest first so the pass is deterministic.
///
/// Every face this model's embedder produced, whichever detector found it —
/// see the module note. 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(&format!(
"SELECT id, image_id, embedding, crop_px, quality FROM faces
WHERE {} = ?1 ORDER BY id",
embedder_sql("model_id")
))?;
let rows = q.query_map([embedder_of(model_id)], |r| {
Ok(StoredEmbedding {
face: FaceId(r.get::<_, i64>(0)? as u64),
image: ImageId(r.get::<_, i64>(1)? as u64),
embedding: r.get::<_, Vec<u8>>(2)?,
crop_px: r.get::<_, f64>(3)? as f32,
quality: r.get::<_, Option<f64>>(4)?.map(|q| q 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> {
people_where(conn, false)
}
/// Everyone who holds a face, carries a name, or was set aside — the people a
/// screen has a row for.
///
/// The rest are the empty, unnamed groups a regrouping pass leaves behind
/// (`prune_empty_unnamed`), and on the reference library they were 17,000 of
/// 19,000 rows: read, counted, sorted by name and then thrown away by the
/// caller on every redraw. Filtered here, in the query, they are never
/// sorted. The filter is SQL's `trim`, which strips spaces and not every
/// whitespace character, so a name that is only a tab is listed rather than
/// hidden — the safe direction for a row the user typed something into.
pub fn people_in_use(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
people_where(conn, true)
}
/// [`people`], with face counts aggregated once per person *before* the join
/// rather than grouped after it: the face table is joined to the 2,000
/// people it names, not the 19,000 rows of the people table.
fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogError> {
let filter = if in_use {
"AND (c.person_id IS NOT NULL OR trim(p.name) <> '' OR p.ignored)"
} else {
""
};
let mut q = conn.prepare(&format!(
"SELECT p.id, p.uuid, p.name,
COALESCE(c.confirmed, 0),
COALESCE(c.suggested, 0),
p.ignored
FROM people p
LEFT JOIN (SELECT person_id,
SUM(confirmed = 1) AS confirmed,
SUM(confirmed = 0) AS suggested
FROM face_person
GROUP BY person_id) c ON c.person_id = p.id
WHERE p.merged_into IS NULL {filter}
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,
ignored: r.get(5)?,
})
})?;
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 every suggested face of this person is right.
///
/// What "Confirm all" runs, and the reason it is not a loop over [`confirm`]:
/// that is a transaction per face, and on a group of several hundred it was
/// several hundred commits for one click. Two statements, one commit, and the
/// same two rules `confirm` applies face by face — an earlier rejection of
/// the pair is overridden, and a face already confirmed is left alone.
///
/// Returns how many suggestions became confirmations.
pub fn confirm_all(conn: &Connection, person: PersonId) -> Result<u64, CatalogError> {
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM face_person_rejected
WHERE person_id = ?1
AND face_id IN (SELECT face_id FROM face_person
WHERE person_id = ?1 AND confirmed = 0)",
[person.0 as i64],
)?;
let n = tx.execute(
"UPDATE face_person SET confirmed = 1, probability = 1.0
WHERE person_id = ?1 AND confirmed = 0",
[person.0 as i64],
)?;
tx.commit()?;
Ok(n as u64)
}
/// The user says these faces are `to`, not `from`.
///
/// [`reject`] from one and [`confirm`] onto the other, for every face, in one
/// transaction — what a split commits. Rejecting first is what stops the
/// split being undone: without it the next pass sees a face that looks like
/// `from` and suggests it straight back. Confirmed rather than suggested on
/// `to`, because the user has just asserted these belong together.
pub fn reassign(
conn: &Connection,
faces: &[FaceId],
from: PersonId,
to: PersonId,
) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
{
let mut reject = tx.prepare(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
)?;
let mut unrejected =
tx.prepare("DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2")?;
let mut confirm = tx.prepare(
"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",
)?;
for face in faces {
reject.execute(rusqlite::params![face.0 as i64, from.0 as i64])?;
unrejected.execute(rusqlite::params![face.0 as i64, to.0 as i64])?;
confirm.execute(rusqlite::params![face.0 as i64, to.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, f.quality,
f.eye_right, f.eye_right_px, f.eye_right_sharp,
f.eye_left, f.eye_left_px, f.eye_left_sharp, f.sunglasses,
f.landmarks_dense
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.
///
/// Keyed on the embedder: a calibration is a fit over pairwise similarities,
/// and those are the same space for every detector in front of one embedder.
/// Keying it on the full pipeline would discard the fit — and the library's
/// sharpened confidences with it — every time the detector was changed.
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![
embedder_of(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",
[embedder_of(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)
}
/// The stored crops for one person's faces.
///
/// Read separately from [`for_person`] rather than joined onto it, because the
/// boxes are wanted in several places that do not draw anything — clustering
/// anchors, the develop overlay, the segmentation naming pass — and dragging a
/// few hundred KB of JPEG through those would be pure waste.
///
/// A face with no stored crop is simply absent from the map; the caller falls
/// back to cutting one out of the proxy.
pub fn crops_for_person(
conn: &Connection,
person: PersonId,
include_suggested: bool,
) -> Result<std::collections::HashMap<FaceId, Vec<u8>>, CatalogError> {
let mut q = conn.prepare(
"SELECT f.id, f.crop
FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
AND f.crop IS NOT NULL",
)?;
let rows = q.query_map(rusqlite::params![person.0 as i64, include_suggested], |r| {
Ok((FaceId(r.get::<_, i64>(0)? as u64), r.get::<_, Vec<u8>>(1)?))
})?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// One face's stored crop, if it has one.
pub fn crop(conn: &Connection, face: FaceId) -> Result<Option<Vec<u8>>, CatalogError> {
conn.query_row(
"SELECT crop FROM faces WHERE id = ?1",
[face.0 as i64],
|r| r.get::<_, Option<Vec<u8>>>(0),
)
.optional()
.map(Option::flatten)
.map_err(Into::into)
}
/// How many of a model's faces still have no stored crop.
///
/// The measure of how much of the library is still drawing itself the slow way,
/// and what a "re-index to fill these in" prompt would be counting.
pub fn faces_without_crop(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
conn.query_row(
&format!(
"SELECT COUNT(*) FROM faces WHERE {} = ?1 AND crop IS NULL",
embedder_sql("model_id")
),
[embedder_of(model_id)],
|r| r.get::<_, i64>(0),
)
.map(|n| n as u64)
.map_err(Into::into)
}
/// The user does not want to identify this person.
///
/// Reversible, and deliberately not a deletion: deleting the group would only
/// have it rebuilt by the next clustering pass, since the faces are still there
/// and still similar to each other. See the V10 migration note.
pub fn set_ignored(conn: &Connection, person: PersonId, ignored: bool) -> Result<(), CatalogError> {
conn.execute(
"UPDATE people
SET ignored = ?2, revision = revision + 1, modified = ?3
WHERE id = ?1",
rusqlite::params![person.0 as i64, ignored, now_secs()],
)?;
Ok(())
}
/// Whether a person has been set aside.
pub fn is_ignored(conn: &Connection, person: PersonId) -> Result<bool, CatalogError> {
conn.query_row(
"SELECT ignored FROM people WHERE id = ?1",
[person.0 as i64],
|r| r.get(0),
)
.optional()
.map(|v| v.unwrap_or(false))
.map_err(Into::into)
}
/// Delete unnamed people who hold no faces at all.
///
/// Clustering creates a person per unanchored group, and a later pass — a new
/// threshold, more faces indexed, a split undone — can leave the previous run's
/// group with nothing in it. Those are not tombstones and nothing refers to
/// them; left alone they accumulate one rail entry per Regroup, which is what
/// made pressing the button twice look like it had broken the screen.
///
/// **Named people are never touched, however empty.** A name is user data
/// (FR-CULL-12), and a person the user named and then emptied by moving every
/// face elsewhere is still a person they told us about. Nor is a person that
/// something was merged into, which has to outlive its faces to keep
/// redirecting.
///
/// Returns how many were removed.
pub fn prune_empty_unnamed(conn: &Connection) -> Result<usize, CatalogError> {
let n = conn.execute(
"DELETE FROM people
WHERE name = ''
AND merged_into IS NULL
AND ignored = 0
AND NOT EXISTS (SELECT 1 FROM face_person fp WHERE fp.person_id = people.id)
AND NOT EXISTS (SELECT 1 FROM people o WHERE o.merged_into = people.id)",
[],
)?;
Ok(n)
}
// ── 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,
quality: r.get::<_, Option<f64>>(13)?.map(|q| q as f32),
eyes: read_eyes(r, 14)?,
landmarks_dense: r.get::<_, Option<Vec<u8>>>(21)?.unwrap_or_default(),
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),
})
}
/// The seven eye columns at `first` .. `first + 6`, in [`EYE_COLUMNS`]'s
/// order, as one reading.
///
/// All seven or none: they are written together, and a row with one of
/// them NULL is a row nothing in this crate produced. Read as absent rather
/// than invented, which is what a reader of a half-written row deserves.
///
/// [`EYE_COLUMNS`]: crate::schema::EYE_COLUMNS
pub(crate) fn read_eyes(
r: &rusqlite::Row<'_>,
first: usize,
) -> rusqlite::Result<Option<EyeReading>> {
let mut v = [0.0_f32; 7];
for (i, slot) in v.iter_mut().enumerate() {
match r.get::<_, Option<f64>>(first + i)? {
Some(x) => *slot = x as f32,
None => return Ok(None),
}
}
Ok(Some(EyeReading {
right: Eye {
open: v[0],
px: v[1],
sharpness: v[2],
},
left: Eye {
open: v[3],
px: v[4],
sharpness: v[5],
},
sunglasses: v[6],
}))
}
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
}
}
pub(crate) 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,
quality: Some(10.0 + f32::from(seed)),
eyes: Some(EyeReading {
right: Eye {
open: 0.9,
px: 40.0,
sharpness: 0.2,
},
left: Eye {
open: 0.8,
px: 38.0,
sharpness: 0.3,
},
sunglasses: 0.1,
}),
landmarks_dense: vec![7; 424],
model_id: "w600k_mbf".into(),
crop: Vec::new(),
}
}
#[test]
fn migration_reaches_the_current_version() {
let c = db();
let v: i64 = c
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, crate::schema::SCHEMA_VERSION);
}
#[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());
// Both readers carry the quality, and the one for the grouping pass
// carries it as the option it is.
let mut qualities: Vec<Option<f32>> = got.iter().map(|f| f.quality).collect();
qualities.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(qualities, vec![Some(11.0), Some(12.0)]);
let stored = embeddings(&c, "w600k_mbf").unwrap();
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
assert_eq!(stored[1].quality, Some(12.0));
// And the eyes, as one reading.
assert_eq!(got[0].eyes.map(|e| e.left.open), Some(0.8));
assert_eq!(got[0].eyes.map(|e| e.right.sharpness), Some(0.2));
assert_eq!(
got[0].landmarks_dense.len(),
424,
"the dense landmarks ride along"
);
assert_eq!(
got[0].eyes.map(|e| e.state()),
Some(dr_face::EyeState::Open)
);
}
const NEEDS_QUALITY: &str = "f.quality IS NULL";
const NEEDS_EYES: &str = "f.eye_right IS NULL";
/// The seven eye columns are one fact: a face with none of them reads as
/// unread, and a per-face pass asking by predicate is what fills them.
#[test]
fn eyes_are_filled_only_where_a_pass_produced_a_reading() {
let c = db();
let img = image(&c, 1);
let unread = DetectedFace {
eyes: None,
..face(1)
};
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[unread, face(2)]).unwrap();
assert_eq!(for_image(&c, img).unwrap().len(), 2);
// Quality is present on both, so a pass that only fills quality has
// nothing to do here; one that reads eyes has one face.
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0);
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1);
assert_eq!(
faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY)
.unwrap()
.len(),
0
);
let todo = faces_needing(&c, img, "w600k_mbf", NEEDS_EYES).unwrap();
assert_eq!(todo.len(), 1);
assert_eq!(todo[0].id, ids[0]);
// An update with no reading leaves the columns alone …
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[FaceUpdate {
embedding: Some((vec![9; 1024], 21.5)),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1);
// … and one with a reading fills them.
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[FaceUpdate {
eyes: Some((
EyeReading {
right: Eye {
open: 0.2,
px: 40.0,
sharpness: 0.2,
},
left: Eye {
open: 0.9,
px: 40.0,
sharpness: 0.2,
},
sunglasses: 0.0,
},
vec![9; 424],
)),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 0);
assert_eq!(
for_image(&c, img).unwrap()[0].landmarks_dense.len(),
424,
"written with the reading"
);
let got = for_image(&c, img).unwrap();
let read = got.iter().find(|f| f.id == ids[0]).unwrap();
assert_eq!(
read.eyes.map(|e| e.state()),
Some(dr_face::EyeState::Closed)
);
}
/// A crop is filled the same way, and an empty one is not written.
#[test]
fn a_crop_is_filled_in_place() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1);
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[FaceUpdate {
crop: Some(Vec::new()),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1);
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[FaceUpdate {
crop: Some(vec![1, 2, 3]),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 0);
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![1, 2, 3]));
}
/// An update writes over the vector and nothing else: the face keeps its
/// id, its box and whoever the user said it was.
#[test]
fn an_update_replaces_the_vector_and_keeps_the_identity() {
let c = db();
let img = image(&c, 1);
let unmeasured = DetectedFace {
quality: None,
..face(1)
};
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[unmeasured.clone(), unmeasured],
)
.unwrap();
let person = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], person).unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 2);
assert_eq!(
faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY)
.unwrap()
.len(),
2
);
let marked_at: i64 = c
.query_row("SELECT indexed_at FROM face_index", [], |r| r.get(0))
.unwrap();
c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", [])
.unwrap();
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[FaceUpdate {
embedding: Some((vec![9; 1024], 21.5)),
..FaceUpdate::for_face(ids[0])
}],
&[ids[1]],
)
.unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got.len(), 1, "the degenerate face was kept");
assert_eq!(got[0].id, ids[0]);
assert_eq!(got[0].quality, Some(21.5));
assert_eq!(got[0].person, Some(person));
assert!(got[0].confirmed);
let e = embeddings(&c, "w600k_mbf").unwrap();
assert_eq!(e[0].embedding[0], 9);
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0);
// The marker says one face at the native edge, and is fresh — which
// is what makes the sync export it again.
let (found, edge, at): (i64, i64, i64) = c
.query_row(
"SELECT faces_found, source_edge, indexed_at FROM face_index",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!((found, edge), (1, 6000));
assert!(at >= marked_at, "the marker was not refreshed");
}
/// The marker a per-face pass leaves names the detector that drew the
/// boxes, whatever pipeline the pass itself ran as. A marker under the
/// pass's id over faces spelled another way is one the export finds no
/// faces under — and it sent every other device "nothing here".
#[test]
fn an_update_keeps_the_marker_under_the_detector_that_found_the_faces() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[DetectedFace {
quality: None,
..face(1)
}],
)
.unwrap();
// The state V14 leaves: the faces, and no marker at all.
c.execute("DELETE FROM face_index", []).unwrap();
record_updates(
&c,
img,
"scrfd_10g+w600k_mbf",
6000,
&[FaceUpdate {
embedding: Some((vec![9; 1024], 21.5)),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
let markers: Vec<(String, i64)> = c
.prepare("SELECT model_id, faces_found FROM face_index")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(markers, vec![("w600k_mbf".to_string(), 1)]);
// A marker already there under the pass's own id is replaced, not
// kept beside the right one.
c.execute(
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (1, 'scrfd_10g+w600k_mbf', 0, 0, 6000)",
[],
)
.unwrap();
record_updates(
&c,
img,
"scrfd_10g+w600k_mbf",
6000,
&[FaceUpdate {
crop: Some(vec![1, 2, 3]),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
let n: i64 = c
.query_row("SELECT COUNT(*) FROM face_index", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "a second marker survived");
// With every face dropped there is no detector left to name, and
// the pass's own id records that it looked.
record_updates(&c, img, "scrfd_10g+w600k_mbf", 6000, &[], &ids).unwrap();
let marker: (String, i64) = c
.query_row("SELECT model_id, faces_found FROM face_index", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(marker, ("scrfd_10g+w600k_mbf".to_string(), 0));
}
/// 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);
}
#[test]
fn the_embedder_is_the_half_after_the_plus() {
assert_eq!(embedder_of("w600k_mbf"), "w600k_mbf");
assert_eq!(embedder_of("scrfd_10g+w600k_mbf"), "w600k_mbf");
assert_eq!(embedder_of("scrfd_2.5g+w600k_mbf"), "w600k_mbf");
assert_eq!(embedder_of("scrfd_10g+other"), "other");
}
/// A detector change must not split the library: the clustering pass,
/// the coverage figure and the "is this indexed" question all see every
/// generation that shares an embedder, and none of a different one.
#[test]
fn every_detector_in_front_of_one_embedder_is_one_population() {
let c = db();
let old = image(&c, 1);
let new = image(&c, 2);
let foreign = image(&c, 3);
record_detections(&c, old, "w600k_mbf", 1024, &[face(1)]).unwrap();
let mut f = face(2);
f.model_id = "scrfd_10g+w600k_mbf".into();
record_detections(&c, new, "scrfd_10g+w600k_mbf", 1024, &[f]).unwrap();
let mut g = face(3);
g.model_id = "scrfd_10g+other".into();
record_detections(&c, foreign, "scrfd_10g+other", 1024, &[g]).unwrap();
for id in ["w600k_mbf", "scrfd_10g+w600k_mbf", "scrfd_2.5g+w600k_mbf"] {
assert_eq!(embeddings(&c, id).unwrap().len(), 2, "{id}");
assert_eq!(unassigned(&c, id).unwrap().len(), 2, "{id}");
assert_eq!(coverage(&c, id).unwrap().indexed, 2, "{id}");
assert!(is_indexed(&c, old, id).unwrap(), "{id}");
assert!(is_indexed(&c, new, id).unwrap(), "{id}");
assert!(!is_indexed(&c, foreign, id).unwrap(), "{id}");
}
assert_eq!(embeddings(&c, "scrfd_10g+other").unwrap().len(), 1);
assert_eq!(coverage(&c, "scrfd_10g+other").unwrap().indexed, 1);
}
/// The fit is over the embedder's similarity space, so it is the same fit
/// whichever detector is chosen — and a detector change must not discard
/// it.
#[test]
fn a_calibration_outlives_a_detector_change() {
let c = db();
let cal = Calibration {
a: 1.5,
b: -0.5,
w_size: 0.1,
valid: true,
positive_pairs: 40,
negative_pairs: 400,
};
put_calibration(&c, "w600k_mbf", &cal, "abc").unwrap();
let (got, hash) = calibration(&c, "scrfd_10g+w600k_mbf").unwrap().unwrap();
assert_eq!(got, cal);
assert_eq!(hash, "abc");
assert!(calibration(&c, "scrfd_10g+other").unwrap().is_none());
}
/// 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);
}
/// A stored vector pointing along one axis of the embedding space, so a
/// test can say "these two faces are the same" or "orthogonal" exactly.
fn along(axis: usize) -> Vec<u8> {
let mut v = Box::new([0.0_f32; dr_face::EMBEDDING_DIM]);
v[axis] = 1.0;
dr_face::Embedding {
model: dr_face::ModelId::new("w600k_mbf"),
v,
}
.to_f16_bytes()
}
fn face_at(x: f32, y: f32, w: f32, h: f32, axis: usize) -> DetectedFace {
DetectedFace {
x,
y,
w,
h,
embedding: along(axis),
..face(1)
}
}
/// The other half of FR-CULL-10's promise: the suggestions and the
/// rejections are the state of the People screen, and a re-index that
/// dropped them would hand back a library of strangers.
#[test]
fn re_detection_carries_a_suggestion_and_a_rejection_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();
let bob = create_person(&c, "Bob").unwrap();
suggest(&c, ids[0], anna, 0.83).unwrap();
reject(&c, ids[0], bob).unwrap();
let mut moved = face(9);
moved.x = 0.11;
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[moved]).unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].person, Some(anna), "suggestion was lost on re-index");
assert!(
!got[0].confirmed,
"a suggestion came back as a confirmation"
);
assert!((got[0].probability - 0.83).abs() < 1e-6);
assert!(
!suggest(&c, got[0].id, bob, 0.99).unwrap(),
"the rejection of Bob was lost on re-index"
);
}
/// A box the low-resolution pass drew badly enough that overlap alone
/// would not claim it: the vector does.
#[test]
fn a_moved_box_is_claimed_by_its_embedding() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
)
.unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
// Shifted by two thirds of its width: IoU ≈ 0.2, well under the 0.5
// the box route needs, but the boxes still touch.
let again = face_at(0.23, 0.10, 0.20, 0.20, 3);
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[again]).unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got[0].person, Some(anna));
assert!(got[0].confirmed);
// The same shift with a stranger's vector is a different face, and
// the confirmation is not handed to it.
let other = image(&c, 2);
let ids = record_detections(
&c,
other,
"w600k_mbf",
1024,
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
)
.unwrap();
confirm(&c, ids[0], anna).unwrap();
let stranger = face_at(0.23, 0.10, 0.20, 0.20, 4);
record_detections(&c, other, "scrfd_10g+w600k_mbf", 4000, &[stranger]).unwrap();
assert_eq!(for_image(&c, other).unwrap()[0].person, None);
}
/// Two people side by side, boxes overlapping both ways, and the new
/// boxes shifted so that overlap alone would swap them.
#[test]
fn the_embedding_breaks_a_tie_between_neighbouring_faces() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[
face_at(0.10, 0.10, 0.20, 0.20, 1),
face_at(0.25, 0.10, 0.20, 0.20, 2),
],
)
.unwrap();
let anna = create_person(&c, "Anna").unwrap();
let bob = create_person(&c, "Bob").unwrap();
confirm(&c, ids[0], anna).unwrap();
confirm(&c, ids[1], bob).unwrap();
// Anna's new box overlaps Bob's old one more than her own.
let anna_again = face_at(0.20, 0.10, 0.20, 0.20, 1);
let bob_again = face_at(0.35, 0.10, 0.20, 0.20, 2);
let new = record_detections(
&c,
img,
"scrfd_10g+w600k_mbf",
4000,
&[anna_again, bob_again],
)
.unwrap();
let got = for_image(&c, img).unwrap();
let person_of = |id: FaceId| got.iter().find(|f| f.id == id).unwrap().person;
assert_eq!(person_of(new[0]), Some(anna));
assert_eq!(person_of(new[1]), Some(bob));
}
/// The same vector somewhere else in the frame — a mirror, a print on
/// the wall — is not the same face, and overlap is what says so.
#[test]
fn the_same_person_elsewhere_in_the_frame_is_not_claimed() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[face_at(0.10, 0.10, 0.20, 0.20, 5)],
)
.unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
record_detections(
&c,
img,
"w600k_mbf",
1024,
&[face_at(0.60, 0.60, 0.20, 0.20, 5)],
)
.unwrap();
assert_eq!(for_image(&c, img).unwrap()[0].person, None);
}
/// Each old face is carried onto at most one new one: a second box over
/// the same face — a detector that fires twice — does not become a
/// second Anna.
#[test]
fn an_identity_is_carried_onto_one_face_only() {
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();
let mut twin = face(1);
twin.x += 0.02;
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), twin]).unwrap();
let named = for_image(&c, img)
.unwrap()
.iter()
.filter(|f| f.person == Some(anna))
.count();
assert_eq!(named, 1);
}
#[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/dev/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 coverage_leaves_out_the_shadowed_half_of_a_raw_jpeg_pair() {
let c = db();
let raw = image(&c, 1);
let jpeg = image(&c, 2);
// The JPEG beside a RAW is not a separate photograph, and no sweep
// will ever index one.
c.execute(
"UPDATE images SET shadowed_by = ?1 WHERE id = ?2",
rusqlite::params![raw.0 as i64, jpeg.0 as i64],
)
.unwrap();
record_detections(&c, raw, "w600k_mbf", 2560, &[face(1)]).unwrap();
let cov = coverage(&c, "w600k_mbf").unwrap();
// Counting the shadowed one would leave a permanent remainder that no
// amount of indexing could bring down -- 4,424 of them on the
// reference library, which read as a stuck job.
assert_eq!(cov.images, 1);
assert_eq!(cov.outstanding(), 0);
assert!(cov.is_complete());
}
#[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");
}
/// An image holds one pipeline's faces at a time, so the first pipeline's
/// marker goes with its faces — but the image stays indexed for every
/// pipeline sharing the embedder, because the faces behind the marker
/// that remains are the same population. Switching detector and back
/// therefore neither re-queues the image nor leaves a marker with nothing
/// behind it.
#[test]
fn re_indexing_under_another_model_drops_the_first_models_marker() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 2048, &[face(1)]).unwrap();
record_detections(&c, img, "scrfd_2.5g+w600k_mbf", 2048, &[face(2), face(3)]).unwrap();
let markers: Vec<String> = {
let mut q = c
.prepare("SELECT model_id FROM face_index WHERE image_id = ?1")
.unwrap();
q.query_map([img.0 as i64], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
};
assert_eq!(
markers,
vec!["scrfd_2.5g+w600k_mbf".to_string()],
"the first pipeline's marker outlived its faces"
);
assert!(is_indexed(&c, img, "scrfd_2.5g+w600k_mbf").unwrap());
assert!(is_indexed(&c, img, "w600k_mbf").unwrap());
assert_eq!(for_image(&c, img).unwrap().len(), 2);
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 0);
}
#[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].image, img);
assert_eq!(e[0].embedding.len(), 1024);
assert_eq!(e[0].embedding[0], 7);
assert!((e[0].crop_px - 180.0).abs() < 1e-3);
assert_eq!(e[0].quality, Some(17.0));
}
// ── stored crops ──────────────────────────────────────────────────────
fn face_with_crop(seed: u8, crop: Vec<u8>) -> DetectedFace {
DetectedFace { crop, ..face(seed) }
}
#[test]
fn a_crop_stored_with_a_face_comes_back_as_stored() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[face_with_crop(3, vec![9; 128])],
)
.unwrap();
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![9; 128]));
}
/// A face indexed before crops existed has none, and that has to read back
/// as an honest absence rather than an empty image.
#[test]
fn a_face_with_no_crop_reads_back_as_none() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(3)]).unwrap();
assert_eq!(crop(&c, ids[0]).unwrap(), None);
assert_eq!(faces_without_crop(&c, "w600k_mbf").unwrap(), 1);
}
#[test]
fn crops_come_back_per_person_and_skip_the_ones_without() {
let c = db();
let a = image(&c, 1);
let b = image(&c, 2);
let with = record_detections(&c, a, "w600k_mbf", 1024, &[face_with_crop(1, vec![4; 16])])
.unwrap()[0];
let without = record_detections(&c, b, "w600k_mbf", 1024, &[face(2)]).unwrap()[0];
let p = create_person(&c, "Anna").unwrap();
confirm(&c, with, p).unwrap();
confirm(&c, without, p).unwrap();
let crops = crops_for_person(&c, p, true).unwrap();
assert_eq!(crops.len(), 1, "a face with no crop should not appear");
assert_eq!(crops.get(&with), Some(&vec![4; 16]));
}
/// Re-indexing replaces the crop along with everything else, so a better
/// pass over the same photograph updates what the screen draws.
#[test]
fn re_indexing_replaces_the_stored_crop() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![1; 8])]).unwrap();
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![2; 8])])
.unwrap();
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![2; 8]));
}
// ── setting a person aside ────────────────────────────────────────────
#[test]
fn a_person_can_be_ignored_and_un_ignored() {
let c = db();
let p = create_person(&c, "").unwrap();
assert!(!is_ignored(&c, p).unwrap());
set_ignored(&c, p, true).unwrap();
assert!(is_ignored(&c, p).unwrap());
assert!(
people(&c)
.unwrap()
.iter()
.find(|q| q.id == p)
.unwrap()
.ignored
);
set_ignored(&c, p, false).unwrap();
assert!(!is_ignored(&c, p).unwrap());
}
/// Ignoring is a judgement, so it bumps the revision the same way a rename
/// does — a device that syncs has to see that something changed.
#[test]
fn ignoring_a_person_bumps_their_revision() {
let c = db();
let p = create_person(&c, "").unwrap();
let before: i64 = c
.query_row(
"SELECT revision FROM people WHERE id = ?1",
[p.0 as i64],
|r| r.get(0),
)
.unwrap();
set_ignored(&c, p, true).unwrap();
let after: i64 = c
.query_row(
"SELECT revision FROM people WHERE id = ?1",
[p.0 as i64],
|r| r.get(0),
)
.unwrap();
assert!(after > before);
}
// ── pruning what clustering left behind ───────────────────────────────
#[test]
fn an_empty_unnamed_person_is_pruned() {
let c = db();
create_person(&c, "").unwrap();
assert_eq!(prune_empty_unnamed(&c).unwrap(), 1);
assert!(people(&c).unwrap().is_empty());
}
/// The rule that matters: a name is user data and survives whatever else
/// happens to the group.
#[test]
fn an_empty_named_person_is_kept() {
let c = db();
create_person(&c, "Anna").unwrap();
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
assert_eq!(people(&c).unwrap().len(), 1);
}
#[test]
fn an_unnamed_person_with_faces_is_kept() {
let c = db();
let img = image(&c, 1);
let f = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap()[0];
let p = create_person(&c, "").unwrap();
suggest(&c, f, p, 0.95).unwrap();
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
}
/// An ignored group is empty of nothing — the user ruled on it, and
/// deleting it would bring it straight back on the next Regroup.
#[test]
fn an_ignored_person_is_never_pruned() {
let c = db();
let p = create_person(&c, "").unwrap();
set_ignored(&c, p, true).unwrap();
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
}
/// A merge tombstone has to outlive its faces or the device on the other
/// side of the sync resurrects the person it redirects.
#[test]
fn a_merge_target_is_never_pruned() {
let c = db();
let target = create_person(&c, "").unwrap();
let source = create_person(&c, "").unwrap();
c.execute(
"UPDATE people SET merged_into = ?2 WHERE id = ?1",
rusqlite::params![source.0 as i64, target.0 as i64],
)
.unwrap();
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
}
}