//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5 //! People and faces: what was detected, who it is, and who said so. //! //! The storage half of docs/faces.md. `dr-face` finds faces and turns them into //! 512 numbers; this module is where those numbers acquire an identity, and //! where the user's corrections outrank the model's guesses. //! //! # Two kinds of fact, never conflated //! //! A face is either **suggested** — the system's inference, recomputable at //! will — or **confirmed**, the user's judgement, which no later indexing pass //! may overwrite (FR-CULL-10). That distinction is a column, not a probability //! of 1.0, because collapsing them would lose the ability to recompute //! suggestions without touching user data. //! //! It runs in both directions. [`reject`] records that a face is *not* someone, //! and that has to be stored rather than inferred from the absence of an //! assignment: without it the next clustering pass re-suggests exactly the face //! the user just pushed away. //! //! # What is derived and what is not //! //! Everything here is rebuildable by re-indexing except the person's name and //! the user's confirmations and rejections. The embeddings are expensive and //! reproducible, which is precisely what ARCH §6.12 says belongs in a //! disposable index; the name is irreplaceable and goes to the sidecar //! (FR-CULL-12), which is not this module's job. //! //! # Privacy is structural here, not policy //! //! NFR-SEC-5 puts face data under a stricter rule than the rest of the catalog: //! it never enters a diagnostics bundle, never reaches a plugin, and is //! deletable in one action ([`delete_all_face_data`]). This module has no //! network dependency and no path that emits an embedding anywhere but back to //! its caller. use rusqlite::{Connection, OptionalExtension}; use dr_types::ImageId; use crate::error::CatalogError; /// A face's row id. Local to this catalog, like [`crate::keywords::KeywordId`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FaceId(pub u64); /// A person's row id. Local; [`Person::uuid`] is what a merge keys on. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PersonId(pub u64); /// A face as detected, before it has an identity. /// /// Coordinates are **normalised to the image's long edge**, so a face outlives /// the proxy it was found on being evicted and regenerated at another size. #[derive(Debug, Clone, PartialEq)] pub struct DetectedFace { pub x: f32, pub y: f32, pub w: f32, pub h: f32, /// Five `(x, y)` pairs, normalised the same way. pub landmarks: [(f32, f32); 5], pub confidence: f32, /// 512 × f16, 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, /// 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, 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 ───────────────────────────────────────────────────────────── /// Replace every face on an image with a fresh detection pass. /// /// Replace rather than append, because `DetectFaces` is coalesced per image /// (FR-CAT-3) and re-running it must be idempotent — appending would double the /// faces on every re-index and silently inflate every cluster. /// /// **Confirmed assignments survive** where the new detection covers the same /// part of the frame: a re-index with a better model must not discard the /// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is /// in the same place even when the box moves a little. pub fn record_detections( conn: &Connection, image_id: ImageId, model_id: &str, source_edge: u32, faces: &[DetectedFace], ) -> Result, CatalogError> { let tx = conn.unchecked_transaction()?; // What the user had asserted, so it can be carried across the replacement. let mut prior: Vec<(f32, f32, f32, f32, i64, f64)> = Vec::new(); { let mut q = tx.prepare( "SELECT f.x, f.y, f.w, f.h, fp.person_id, fp.probability FROM faces f JOIN face_person fp ON fp.face_id = f.id WHERE f.image_id = ?1 AND fp.confirmed = 1", )?; let rows = q.query_map([image_id.0 as i64], |r| { Ok(( r.get::<_, f64>(0)? as f32, r.get::<_, f64>(1)? as f32, r.get::<_, f64>(2)? as f32, r.get::<_, f64>(3)? as f32, r.get::<_, i64>(4)?, r.get::<_, f64>(5)?, )) })?; for row in rows { prior.push(row?); } } tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?; let now = now_secs(); let mut ids = Vec::with_capacity(faces.len()); for f in faces { tx.execute( "INSERT INTO faces (image_id, x, y, w, h, landmarks, detector_confidence, embedding, crop_px, model_id, detected_at, crop, quality) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", 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), ], )?; let id = FaceId(tx.last_insert_rowid() as u64); // Re-attach a confirmation whose box this detection clearly replaces. // 0.5 IoU is loose on purpose: the question is "is this the same face // in the frame", not "is this the same box", and a better detector is // entitled to move the box. if let Some((_, _, _, _, person, prob)) = prior .iter() .filter(|p| iou((p.0, p.1, p.2, p.3), (f.x, f.y, f.w, f.h)) > 0.5) .max_by(|a, b| { iou((a.0, a.1, a.2, a.3), (f.x, f.y, f.w, f.h)) .total_cmp(&iou((b.0, b.1, b.2, b.3), (f.x, f.y, f.w, f.h))) }) { tx.execute( "INSERT INTO face_person (face_id, person_id, probability, confirmed) VALUES (?1, ?2, ?3, 1)", rusqlite::params![id.0 as i64, person, prob], )?; } ids.push(id); } // The run marker, written whether or not anything was found. Zero faces is // a real answer and recording it is what stops the next pass looking at // this photograph again -- see the V9 migration. tx.execute( "INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(image_id, model_id) DO UPDATE SET indexed_at = excluded.indexed_at, faces_found = excluded.faces_found, source_edge = excluded.source_edge", rusqlite::params![ image_id.0 as i64, model_id, now, faces.len() as i64, source_edge as i64, ], )?; tx.commit()?; Ok(ids) } /// A face embedded again from its stored landmarks: the new vector and its /// length. What the measuring pass hands back per face. #[derive(Debug, Clone, PartialEq)] pub struct Measurement { pub face: FaceId, /// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`. pub embedding: Vec, pub quality: f32, } /// Write fresh embeddings over faces that were found before their quality was /// kept, and re-mark the image as indexed. /// /// The cheaper half of what `record_detections` does, for the case schema V14 /// created: the boxes and landmarks are right, the identities are the user's /// work, and only the vector needs doing again. Updating in place is what /// keeps `face_person` and the face ids exactly as they were -- a /// re-detection would carry confirmations across by box overlap and lose /// every suggestion, for no gain. /// /// `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_proxy`), and because a face left with /// no reading would put its image back on the measuring 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 the measured vectors /// reach the other devices. pub fn record_measurements( conn: &Connection, image_id: ImageId, model_id: &str, source_edge: u32, measured: &[Measurement], dropped: &[FaceId], ) -> Result<(), CatalogError> { let tx = conn.unchecked_transaction()?; for m in measured { tx.execute( "UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1", rusqlite::params![m.face.0 as i64, m.embedding, f64::from(m.quality)], )?; } for f in dropped { tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?; } let remaining: i64 = tx.query_row( "SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND model_id = ?2", rusqlite::params![image_id.0 as i64, 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 have no quality reading yet. /// /// The measuring pass's per-image work: every face this model found whose /// vector was stored as a unit one (schema V14), with the landmarks the /// warp is rebuilt from. pub fn unmeasured_on_image( conn: &Connection, image_id: ImageId, model_id: &str, ) -> Result, CatalogError> { Ok(for_image(conn, image_id)? .into_iter() .filter(|f| f.model_id == model_id && f.quality.is_none()) .collect()) } /// How many of a model's faces have no quality reading. /// /// What the measuring pass has left to do, for a screen that wants to say so. pub fn faces_unmeasured(conn: &Connection, model_id: &str) -> Result { conn.query_row( "SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND quality IS NULL", [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), )?; let (indexed, without, faces): (i64, i64, i64) = conn.query_row( "SELECT COUNT(*), COALESCE(SUM(faces_found = 0), 0), COALESCE(SUM(faces_found), 0) FROM face_index fi JOIN images i ON i.id = fi.image_id WHERE fi.model_id = ?1 AND i.trashed_at IS NULL AND i.shadowed_by IS NULL", [model_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), )?; Ok(Coverage { images: images as u64, indexed: indexed as u64, without_faces: without as u64, faces: faces as u64, }) } /// Whether one image has been through this model. pub fn is_indexed( conn: &Connection, image_id: ImageId, model_id: &str, ) -> Result { conn.query_row( "SELECT EXISTS(SELECT 1 FROM face_index WHERE image_id = ?1 AND model_id = ?2)", rusqlite::params![image_id.0 as i64, model_id], |r| r.get(0), ) .map_err(Into::into) } /// Forget that an image was indexed, so the next pass looks again. /// /// What a re-index asks for. Separate from deleting the faces because the two /// are wanted at different times: clearing the marker alone re-detects and /// replaces, which is the ordinary "try again with a better proxy" case. pub fn clear_index_marker( conn: &Connection, image_id: ImageId, model_id: &str, ) -> Result<(), CatalogError> { conn.execute( "DELETE FROM face_index WHERE image_id = ?1 AND model_id = ?2", rusqlite::params![image_id.0 as i64, model_id], )?; Ok(()) } /// Every face on one image. pub fn for_image(conn: &Connection, image_id: ImageId) -> Result, 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 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( "SELECT f.id FROM faces f LEFT JOIN face_person fp ON fp.face_id = f.id WHERE fp.face_id IS NULL AND f.model_id = ?1", )?; let rows = q.query_map([model_id], |r| Ok(FaceId(r.get::<_, i64>(0)? as u64)))?; rows.collect::>().map_err(Into::into) } /// 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. /// /// 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( "SELECT id, image_id, embedding, crop_px, quality FROM faces WHERE model_id = ?1 ORDER BY id", )?; let rows = q.query_map([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> { let mut q = conn.prepare( "SELECT p.id, p.uuid, p.name, COALESCE(SUM(fp.confirmed = 1), 0), COALESCE(SUM(fp.confirmed = 0), 0), p.ignored FROM people p LEFT JOIN face_person fp ON fp.person_id = p.id WHERE p.merged_into IS NULL GROUP BY p.id ORDER BY 4 DESC, 5 DESC, p.name", )?; let rows = q.query_map([], |r| { Ok(Person { id: PersonId(r.get::<_, i64>(0)? as u64), uuid: r.get(1)?, name: r.get(2)?, confirmed_faces: r.get::<_, i64>(3)? as u64, suggested_faces: r.get::<_, i64>(4)? as u64, 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 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 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. pub fn put_calibration( conn: &Connection, model_id: &str, cal: &Calibration, face_set_hash: &str, ) -> Result<(), CatalogError> { conn.execute( "INSERT INTO face_calibration (model_id, a, b, w_size, valid, positive_pairs, negative_pairs, face_set_hash, fitted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(model_id) DO UPDATE SET a = excluded.a, b = excluded.b, w_size = excluded.w_size, valid = excluded.valid, positive_pairs = excluded.positive_pairs, negative_pairs = excluded.negative_pairs, face_set_hash = excluded.face_set_hash, fitted_at = excluded.fitted_at", rusqlite::params![ model_id, cal.a as f64, cal.b as f64, cal.w_size as f64, cal.valid, cal.positive_pairs as i64, cal.negative_pairs as i64, face_set_hash, now_secs(), ], )?; Ok(()) } /// The calibration for a model, and the face set it was fitted from. /// /// Returns `None` when there is no fit at all — distinct from a fit that exists /// and is [`Calibration::valid`]`== false`, which means it was attempted and /// there was not enough evidence. The UI says different things about those two. pub fn calibration( conn: &Connection, model_id: &str, ) -> Result, CatalogError> { conn.query_row( "SELECT a, b, w_size, valid, positive_pairs, negative_pairs, face_set_hash FROM face_calibration WHERE model_id = ?1", [model_id], |r| { Ok(( Calibration { a: r.get::<_, f64>(0)? as f32, b: r.get::<_, f64>(1)? as f32, w_size: r.get::<_, f64>(2)? as f32, valid: r.get(3)?, positive_pairs: r.get::<_, i64>(4)? as u64, negative_pairs: r.get::<_, i64>(5)? as u64, }, r.get::<_, String>(6)?, )) }, ) .optional() .map_err(Into::into) } // ── the delete-everything control ───────────────────────────────────────── /// Delete every face, person, assignment and calibration in the library. /// /// NFR-SEC-5 requires this to exist as **one action**, reachable without /// deleting the catalog or any photograph. It is deliberately not a /// re-indexing trigger and deliberately not selective: a user asking to remove /// their face data is not asking to keep some of it. /// /// Returns the number of faces removed, so the UI can say what happened rather /// than claiming success silently. pub fn delete_all_face_data(conn: &Connection) -> Result { 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( "SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND crop IS NULL", [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), 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), }) } 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)), 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)); } /// The measuring pass writes over the vector and nothing else: the face /// keeps its id, its box and whoever the user said it was. #[test] fn measuring_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!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 2); assert_eq!(unmeasured_on_image(&c, img, "w600k_mbf").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_measurements( &c, img, "w600k_mbf", 6000, &[Measurement { face: ids[0], embedding: vec![9; 1024], quality: 21.5, }], &[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!(faces_unmeasured(&c, "w600k_mbf").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); } /// The invariant FR-CULL-10 turns on: a re-index with a better model must /// not throw away the user's own labelling. #[test] fn re_detection_carries_a_confirmation_across() { let c = db(); let img = image(&c, 1); let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); confirm(&c, ids[0], anna).unwrap(); // Same face, box nudged the way a better detector would nudge it. let mut moved = face(9); moved.x = 0.11; moved.y = 0.105; record_detections(&c, img, "w600k_mbf", 1024, &[moved]).unwrap(); let got = for_image(&c, img).unwrap(); assert_eq!(got.len(), 1); assert_eq!( got[0].person, Some(anna), "confirmation was lost on re-index" ); assert!(got[0].confirmed); } #[test] fn a_suggestion_never_overwrites_a_confirmation() { let c = db(); let img = image(&c, 1); let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); let bob = create_person(&c, "Bob").unwrap(); confirm(&c, ids[0], anna).unwrap(); let changed = suggest(&c, ids[0], bob, 0.99).unwrap(); assert!(!changed, "a later pass overwrote a user confirmation"); assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna)); } #[test] fn a_rejection_stops_the_face_being_suggested_again() { let c = db(); let img = image(&c, 1); let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); suggest(&c, ids[0], anna, 0.8).unwrap(); reject(&c, ids[0], anna).unwrap(); assert_eq!(for_image(&c, img).unwrap()[0].person, None); let again = suggest(&c, ids[0], anna, 0.95).unwrap(); assert!( !again, "clustering re-suggested a face the user pushed away" ); } #[test] fn confirming_overrides_an_earlier_rejection() { let c = db(); let img = image(&c, 1); let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); reject(&c, ids[0], anna).unwrap(); confirm(&c, ids[0], anna).unwrap(); assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna)); } #[test] fn merging_moves_the_faces_and_leaves_a_redirect() { let c = db(); let i1 = image(&c, 1); let i2 = image(&c, 2); let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); let annie = create_person(&c, "Annie").unwrap(); confirm(&c, a[0], anna).unwrap(); confirm(&c, b[0], annie).unwrap(); assert_eq!(merge_people(&c, anna, annie).unwrap(), 1); assert_eq!(for_person(&c, anna, false).unwrap().len(), 2); assert_eq!(resolve_person(&c, annie).unwrap(), anna); // The redirect survives so a sync cannot resurrect the merged person. assert_eq!(people(&c).unwrap().len(), 1); } #[test] fn merging_does_not_duplicate_a_face_both_people_hold() { let c = db(); let img = image(&c, 1); let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let a = create_person(&c, "A").unwrap(); let b = create_person(&c, "B").unwrap(); confirm(&c, ids[0], a).unwrap(); // `face_person` is keyed by face, so B can only hold it after A lets go. unassign(&c, ids[0]).unwrap(); confirm(&c, ids[0], b).unwrap(); confirm(&c, ids[0], a).unwrap(); merge_people(&c, a, b).unwrap(); assert_eq!(for_person(&c, a, false).unwrap().len(), 1); } #[test] fn images_for_person_counts_a_photograph_once() { let c = db(); let img = image(&c, 1); // Two faces of the same person in one frame — a mirror, or a collage. let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); confirm(&c, ids[0], anna).unwrap(); confirm(&c, ids[1], anna).unwrap(); assert_eq!(images_for_person(&c, anna, false).unwrap(), vec![img]); } #[test] fn suggested_faces_are_excluded_from_a_person_by_default() { let c = db(); let i1 = image(&c, 1); let i2 = image(&c, 2); let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); confirm(&c, a[0], anna).unwrap(); suggest(&c, b[0], anna, 0.8).unwrap(); assert_eq!(for_person(&c, anna, false).unwrap().len(), 1); assert_eq!(for_person(&c, anna, true).unwrap().len(), 2); assert_eq!(images_for_person(&c, anna, false).unwrap().len(), 1); assert_eq!(images_for_person(&c, anna, true).unwrap().len(), 2); } #[test] fn people_reports_confirmed_and_suggested_separately() { let c = db(); let i1 = image(&c, 1); let i2 = image(&c, 2); let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); confirm(&c, a[0], anna).unwrap(); suggest(&c, b[0], anna, 0.7).unwrap(); let p = people(&c).unwrap(); assert_eq!(p.len(), 1); assert_eq!(p[0].confirmed_faces, 1); assert_eq!(p[0].suggested_faces, 1); assert_eq!(p[0].name, "Anna"); } #[test] fn renaming_keeps_the_merge_identity() { let c = db(); let anna = create_person(&c, "Anna").unwrap(); let before = people(&c).unwrap()[0].uuid.clone(); rename_person(&c, anna, "Anna Smith").unwrap(); let after = &people(&c).unwrap()[0]; assert_eq!(after.name, "Anna Smith"); assert_eq!(after.uuid, before, "a rename must not change identity"); } #[test] fn deleting_face_data_leaves_the_photographs_alone() { let c = db(); let img = image(&c, 1); let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); confirm(&c, ids[0], anna).unwrap(); reject(&c, ids[1], anna).unwrap(); put_calibration( &c, "w600k_mbf", &Calibration { a: 16.0, b: -4.3, w_size: 0.0, valid: true, positive_pairs: 900, negative_pairs: 90_000, }, "hash", ) .unwrap(); assert_eq!(delete_all_face_data(&c).unwrap(), 2); assert!(for_image(&c, img).unwrap().is_empty()); assert!(people(&c).unwrap().is_empty()); assert!(calibration(&c, "w600k_mbf").unwrap().is_none()); let images: i64 = c .query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0)) .unwrap(); assert_eq!(images, 1, "deleting face data deleted a photograph"); } #[test] fn calibration_round_trips_and_its_boundary_inverts_its_probability() { let c = db(); let cal = Calibration { a: 16.2, b: -4.33, w_size: 0.0, valid: true, positive_pairs: 500, negative_pairs: 50_000, }; put_calibration(&c, "w600k_mbf", &cal, "abc").unwrap(); let (got, hash) = calibration(&c, "w600k_mbf").unwrap().unwrap(); assert_eq!(hash, "abc"); assert!((got.a - 16.2).abs() < 1e-4); assert!(got.valid); for &p in &[0.5_f32, 0.9, 0.99] { let cos = got.boundary_at(p, 112.0, 0.0); let back = got.probability(cos, 112.0, 0.0); assert!((back - p).abs() < 1e-3, "p={p} round-tripped to {back}"); } } /// The reference implementation's fitted MBF curve puts the P=0.5 boundary /// at cosine 0.267 (docs/faces.md §1). Our own first end-to-end run scored /// 0.596 between distinct photographs of one person and 0.05 between /// different people, so those two must land either side. #[test] fn the_reference_calibration_separates_the_measured_cosines() { let cal = Calibration { a: 16.2, b: -16.2 * 0.267, w_size: 0.0, valid: true, positive_pairs: 0, negative_pairs: 0, }; assert!(cal.probability(0.596, 200.0, 0.0) > 0.99); assert!(cal.probability(0.050, 200.0, 0.0) < 0.05); assert!((cal.boundary_at(0.5, 200.0, 0.0) - 0.267).abs() < 1e-3); } #[test] fn unassigned_lists_only_faces_with_no_identity_and_the_right_model() { let c = db(); let i1 = image(&c, 1); let i2 = image(&c, 2); let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let anna = create_person(&c, "Anna").unwrap(); confirm(&c, a[0], anna).unwrap(); assert_eq!(unassigned(&c, "w600k_mbf").unwrap().len(), 1); assert!(unassigned(&c, "some_other_model").unwrap().is_empty()); } /// The bug this table exists for: a photograph with no faces must not look /// like one that has never been indexed, or every landscape in the library /// is re-examined on every pass, for ever. #[test] fn an_image_with_no_faces_still_counts_as_indexed() { let c = db(); let img = image(&c, 1); record_detections(&c, img, "w600k_mbf", 1024, &[]).unwrap(); assert!(is_indexed(&c, img, "w600k_mbf").unwrap()); assert!(for_image(&c, img).unwrap().is_empty()); let cov = coverage(&c, "w600k_mbf").unwrap(); assert_eq!(cov.indexed, 1); assert_eq!(cov.without_faces, 1); assert_eq!(cov.faces, 0); assert_eq!(cov.outstanding(), 0); assert!(cov.is_complete()); } #[test] fn a_model_change_puts_every_image_back_in_the_queue() { let c = db(); let img = image(&c, 1); record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap(); assert!(is_indexed(&c, img, "w600k_mbf").unwrap()); assert!( !is_indexed(&c, img, "some_better_model").unwrap(), "a new model must not inherit the old model's coverage" ); assert_eq!(coverage(&c, "some_better_model").unwrap().outstanding(), 1); } #[test] fn coverage_counts_images_not_faces() { let c = db(); let i1 = image(&c, 1); let i2 = image(&c, 2); image(&c, 3); // Three faces across two images; the third image is untouched. record_detections(&c, i1, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap(); record_detections(&c, i2, "w600k_mbf", 1024, &[face(3)]).unwrap(); let cov = coverage(&c, "w600k_mbf").unwrap(); assert_eq!(cov.images, 3); assert_eq!(cov.indexed, 2); assert_eq!(cov.faces, 3); assert_eq!(cov.outstanding(), 1); assert!(!cov.is_complete()); assert!((cov.fraction() - 2.0 / 3.0).abs() < 1e-6); } #[test] fn 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"); } #[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); } }