//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5 //! People and faces: what was detected, who it is, and who said so. //! //! The storage half of docs/faces.md. `dr-face` finds faces and turns them into //! 512 numbers; this module is where those numbers acquire an identity, and //! where the user's corrections outrank the model's guesses. //! //! # Two kinds of fact, never conflated //! //! A face is either **suggested** — the system's inference, recomputable at //! will — or **confirmed**, the user's judgement, which no later indexing pass //! may overwrite (FR-CULL-10). That distinction is a column, not a probability //! of 1.0, because collapsing them would lose the ability to recompute //! suggestions without touching user data. //! //! It runs in both directions. [`reject`] records that a face is *not* someone, //! and that has to be stored rather than inferred from the absence of an //! assignment: without it the next clustering pass re-suggests exactly the face //! the user just pushed away. //! //! # What is derived and what is not //! //! Everything here is rebuildable by re-indexing except the person's name and //! the user's confirmations and rejections. The embeddings are expensive and //! reproducible, which is precisely what ARCH §6.12 says belongs in a //! disposable index; the name is irreplaceable and goes to the sidecar //! (FR-CULL-12), which is not this module's job. //! //! # 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, /// Source pixels across the aligned crop (docs/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, /// 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, /// 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, /// 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, } /// 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, /// See [`DetectedFace::eyes`]. `None` for a face never read. pub eyes: Option, /// See [`DetectedFace::landmarks_dense`]. Empty for a face never read. pub landmarks_dense: Vec, pub model_id: String, /// `None` when the face belongs to no one yet. pub person: Option, /// 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/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, /// `(person, probability, confirmed)`, where the face had one. assignment: Option<(i64, f64, bool)>, rejected: Vec, } /// 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, CatalogError> { let tx = conn.unchecked_transaction()?; // 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, ], )?; tx.commit()?; 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, 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 = r.get(6)?; let person: Option = 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> { 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, f32)>, /// See [`DetectedFace::eyes`], with the dense landmarks it was read from /// (see [`DetectedFace::landmarks_dense`]; empty stores NULL). pub eyes: Option<(EyeReading, Vec)>, /// See [`DetectedFace::crop`]. An empty crop is not written. pub crop: Option>, } 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. 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: i64 = tx.query_row( &format!( "SELECT COUNT(*) FROM faces 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), )?; 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_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, 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 = q .query_map( rusqlite::params![image_id.0 as i64, embedder_of(model_id)], |r| r.get::<_, i64>(0), )? .collect::>()?; 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 { 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 { 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 { 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, 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::>().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, 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::>().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 { 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, pub crop_px: f32, /// See [`DetectedFace::quality`]. pub quality: Option, } /// 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, 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>(2)?, crop_px: r.get::<_, f64>(3)? as f32, quality: r.get::<_, Option>(4)?.map(|q| q as f32), }) })?; rows.collect::>().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 { 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, 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, 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, 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::>().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 { 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 { 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 = 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 { // 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 { 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, 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::>().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, 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::>().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, 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 { 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>, 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>(1)?)) })?; rows.collect::>().map_err(Into::into) } /// One face's stored crop, if it has one. pub fn crop(conn: &Connection, face: FaceId) -> Result>, CatalogError> { conn.query_row( "SELECT crop FROM faces WHERE id = ?1", [face.0 as i64], |r| r.get::<_, Option>>(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 { 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 { 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 { 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 { let person: Option = 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>(6)?), confidence: r.get::<_, f64>(7)? as f32, crop_px: r.get::<_, f64>(8)? as f32, quality: r.get::<_, Option>(13)?.map(|q| q as f32), eyes: read_eyes(r, 14)?, landmarks_dense: r.get::<_, Option>>(21)?.unwrap_or_default(), model_id: r.get(9)?, person: person.map(|p| PersonId(p as u64)), probability: r.get::<_, Option>(11)?.unwrap_or(0.0) as f32, confirmed: r.get::<_, Option>(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> { let mut v = [0.0_f32; 7]; for (i, slot) in v.iter_mut().enumerate() { match r.get::<_, Option>(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 { let mut out = Vec::with_capacity(40); for &(x, y) in lm { out.extend_from_slice(&x.to_le_bytes()); out.extend_from_slice(&y.to_le_bytes()); } out } fn blob_to_landmarks(b: &[u8]) -> [(f32, f32); 5] { let mut out = [(0.0_f32, 0.0_f32); 5]; for (i, o) in out.iter_mut().enumerate() { let at = i * 8; if at + 8 <= b.len() { let x = f32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]); let y = f32::from_le_bytes([b[at + 4], b[at + 5], b[at + 6], b[at + 7]]); *o = (x, y); } } out } fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 { let ix = (a.0 + a.2).min(b.0 + b.2) - a.0.max(b.0); let iy = (a.1 + a.3).min(b.1 + b.3) - a.1.max(b.1); if ix <= 0.0 || iy <= 0.0 { return 0.0; } let inter = ix * iy; let union = a.2 * a.3 + b.2 * b.3 - inter; if union <= 0.0 { 0.0 } else { inter / union } } fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// A v4 UUID from the system clock and address-space entropy. /// /// The same approach `collections` takes: a merge needs identifiers that do not /// collide across devices, not cryptographic randomness, and this avoids a /// dependency for one string per person. fn new_uuid() -> String { use std::hash::{BuildHasher, Hasher, RandomState}; let mut h = RandomState::new().build_hasher(); h.write_u64( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0), ); let a = h.finish(); let mut h2 = RandomState::new().build_hasher(); h2.write_u64(a); h2.write_usize(&h2 as *const _ as usize); let b = h2.finish(); format!( "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", (a >> 32) as u32, (a >> 16) as u16, (a & 0x0fff) as u16, ((b >> 48) as u16 & 0x3fff) | 0x8000, b & 0xffff_ffff_ffff, ) } #[cfg(test)] mod tests { use super::*; use crate::schema; fn db() -> Connection { let c = Connection::open_in_memory().unwrap(); c.execute_batch("PRAGMA foreign_keys = ON").unwrap(); schema::migrate(&c).unwrap(); c } fn image(c: &Connection, n: i64) -> ImageId { c.execute( "INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", [], ) .unwrap(); c.execute( "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)", rusqlite::params![n, format!("IMG_{n}.CR3")], ) .unwrap(); ImageId(n as u64) } fn face(seed: u8) -> DetectedFace { DetectedFace { x: 0.1, y: 0.1, w: 0.2, h: 0.3, landmarks: [ (0.1, 0.1), (0.2, 0.1), (0.15, 0.2), (0.12, 0.25), (0.18, 0.25), ], confidence: 0.9, embedding: vec![seed; 1024], crop_px: 180.0, 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> = 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"); } /// 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 { 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/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 = { 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::>() .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) -> 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); } }