//! TRACES: FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5 //! The Identity screen's model: who the library knows, and how the user fixes it. //! //! A third top-level screen beside the library and develop, because it is a //! *place you go to work*, not a panel you glance at: naming a cluster, pulling //! a stranger out of it, and merging two halves of the same person are tasks //! with their own rhythm, and they need the whole window. //! //! # The screen exists because the clustering is wrong //! //! That is not pessimism, it is FR-CULL-10: clustering will over-merge on //! siblings, on parents and children, and on the same person a decade apart. A //! tool that could only merge would make its own errors permanent, so //! **splitting is as prominent as merging here**, and every suggestion is //! visibly a suggestion until the user says otherwise. //! //! # What this module is, and is not //! //! It builds a view model from the catalog and applies the user's decisions //! back to it. It holds no Slint types, so the whole of it is testable against //! an in-memory catalog with no window open — which matters because the //! operations it performs are the ones that touch user data. use dr_catalog::faces::{self, FaceId, PersonId}; use dr_catalog::Catalog; use dr_thumbs::ThumbStore; use dr_types::ImageId; use crate::faces::{crop_face, FaceCrop, FACE_TIER}; /// Edge of a face crop in the grid, in pixels. /// /// Generous for a thumbnail because the judgement being asked for — *is this /// the same person* — is one the user cannot make from a postage stamp. pub const FACE_CROP_EDGE: u32 = 128; /// Edge of the portrait beside a name in the people rail. /// /// Small on purpose: the rail is for *recognising* a person you already know, /// not for judging a likeness, and forty of them at grid size would be a /// second grid competing with the real one. pub const COVER_CROP_EDGE: u32 = 80; /// A row in the people rail. #[derive(Debug, Clone, PartialEq)] pub struct PersonRow { pub id: PersonId, /// Empty for a cluster the system found and the user has not named. pub name: String, pub confirmed_faces: u64, pub suggested_faces: u64, /// The face to show as this person's portrait, if any is loaded. pub cover: Option, /// Set aside by the user — see `faces::set_ignored`. pub ignored: bool, } impl PersonRow { /// What to draw when the person has no name yet. /// /// Deliberately not a guessed name. FR-CULL-10 has the *user* name a group, /// and a system-chosen name would be an inference wearing a fact's clothes /// — the exact conflation the confirmed/suggested split exists to prevent. pub fn display_name(&self) -> String { if self.name.is_empty() { format!( "Unnamed ({} faces)", self.confirmed_faces + self.suggested_faces ) } else { self.name.clone() } } /// Whether this group is still entirely the system's opinion. pub fn is_unconfirmed(&self) -> bool { self.confirmed_faces == 0 } } /// One face in the grid. #[derive(Debug, Clone, PartialEq)] pub struct FaceCell { pub face: FaceId, pub image: ImageId, /// `None` while the crop is still being cut. pub crop: Option, /// The user asserted this, as against the system guessing it. pub confirmed: bool, /// Calibrated P(this face is this person). /// /// Always meaningful, and always shown. Where the library has no fit of /// its own the number comes from the reference curve — a published /// operating point, not an invention — and the screen says so once, at the /// top, rather than blanking every face (FR-CULL-9). pub probability: f32, /// Source pixels across the aligned crop. Small faces embed worse, and the /// user deserves to know which of a bad suggestion's causes is in play. pub crop_px: f32, /// The model's own reading of how recognisable the crop was — the length /// of its raw embedding (`dr_face::MIN_GALLERY_QUALITY`). `None` for a /// face indexed before it was kept. pub quality: Option, /// TRACES: FR-CULL-8a | FR-CULL-13 /// What the eyes are doing, where they were read. pub eyes: Option, } impl FaceCell { /// Confidence as text. /// /// FR-CULL-9's rule is about *provenance*, not about silence: the number /// must not be passed off as measured when it is not. Withholding it /// entirely was the wrong reading — it left the user ranking forty /// suggestions with nothing to rank them by, on every library that has not /// yet earned a fit, which is most of them. The number is shown; where the /// curve is the built-in one the screen says so above the grid. pub fn confidence_label(&self) -> String { if self.confirmed { "Confirmed".into() } else { format!("{:.0}% likely", self.probability * 100.0) } } /// The quality as text — "Quality 17.3", or "Quality —" where it was never /// measured. /// /// Called *quality* and not *norm* on the screen, because that is what the /// number is used as and what the user can act on: a low one is the model /// saying it could not make the face out, and the fix is a better /// photograph. One decimal, because the gate sits at a whole number and /// the faces worth a second look are the ones just either side of it. pub fn quality_label(&self) -> String { match self.quality { Some(q) => format!("Quality {q:.1}"), None => "Quality —".into(), } } /// Whether this face is good enough to be compared *against*. /// /// What the screen dims the quality for: a face below the floor is still /// somebody and still placed, but it vouches for no one else, and a user /// wondering why a person's group did not gather the rest of them should /// be able to see that none of its members can. pub fn in_gallery(&self) -> bool { dr_face::in_gallery(self.quality) } /// The eye state as a badge — "Eyes closed", "Sunglasses" or "Eyes /// unclear" — or nothing. /// /// Nothing for open eyes and nothing for a face never read, on the rule /// the confirmed marker follows: the common case carries no mark, so the /// marks that appear mean something. The three that do appear are the /// states the eyes-open filter treats differently from open — one it /// drops, two it lets through — and a user asking why a frame is or is /// not in the grid can read the answer off the face. pub fn eyes_label(&self) -> &'static str { match self.eyes.map(|e| e.state()) { Some(dr_face::EyeState::Open) | None => "", Some(state) => state.label(), } } } /// Put a grouping preview into words. /// /// **The group count leads, not the grouped-face count.** They move in opposite /// directions on either side of the right setting, and only one of them says /// which side you are on: loosening gathers fragments into people, so the group /// count climbs — until it starts welding separate people together, at which /// point it *falls* while the grouped faces keep rising. `dr_face::cluster` /// records that measurement at length. A line that led with "1,340 of 1,813 /// faces grouped" would make the over-merged setting look like the best one. /// /// The largest group is here for the same reason: it is where over-merging /// shows up first and most legibly, because a user who knows their own library /// knows whether anyone in it has been photographed six hundred times. pub fn preview_label(p: &crate::faces::GroupingPreview) -> String { if p.faces == 0 { return "No faces indexed yet, so there is nothing to group.".into(); } format!( "{} group(s), holding {} of {} faces. Largest: {}.", p.groups, p.grouped, p.faces, p.largest ) } /// Everything the Identity screen draws. #[derive(Debug, Clone, Default, PartialEq)] pub struct IdentityView { pub people: Vec, pub selected: Option, pub faces: Vec, /// Faces belonging to nobody — the pool the user can pull a new person out /// of, and the honest answer to "why is this photo not under anyone". pub unassigned: usize, /// Whether the library's similarity calibration has been fitted from its /// own faces, as against the built-in reference curve. Not a gate on /// showing confidences — only on how the screen describes them. pub calibrated: bool, } /// Read the people rail. /// /// **A group holding no faces is not a group, and does not appear.** Only the /// unnamed ones, which is exactly the set `faces::prune_empty_unnamed` treats /// as debris: a pass creates a person per cluster, the next pass regroups those /// faces elsewhere, and the person it emptied survives until something prunes /// it. Nothing prunes on this path, so they accumulate — the reference library /// reached 11,739 of them against 2,525 real people, and the rail was 85 per /// cent rows that named nobody and could do nothing. /// /// Filtered rather than deleted, because this is a screen being drawn and not a /// catalog being repaired. A row is withheld; nothing is lost, a sync cannot /// resurrect what was never removed, and the prune stays the one place that /// decides these are disposable. /// /// An empty group with a *name* still shows. That one is not debris, it is the /// symptom of a real failure — a named person whose faces were regrouped out /// from under them (see `Population::read` on why naming has to anchor) — and /// the user cannot merge it back into the group that took them if the rail has /// hidden it. pub fn load_people( catalog: &Catalog, model_id: &str, ) -> Result { let conn = catalog.connection(); let people = faces::people(conn)? .into_iter() .filter(|p| { p.confirmed_faces + p.suggested_faces > 0 || !p.name.trim().is_empty() || p.ignored }) .map(|p| PersonRow { id: p.id, name: p.name, confirmed_faces: p.confirmed_faces, suggested_faces: p.suggested_faces, cover: None, ignored: p.ignored, }) .collect(); Ok(IdentityView { people, selected: None, faces: Vec::new(), unassigned: faces::unassigned(conn, model_id)?.len(), calibrated: faces::calibration(conn, model_id)?.is_some_and(|(c, _)| c.valid), }) } /// Read one person's faces, cutting a crop for each. /// /// Suggestions are included and marked, never hidden: the whole purpose of this /// screen is ruling on them, and a screen that showed only confirmed faces /// would give the user nothing to do. /// /// Crops come from the proxy the grid already built. An image whose proxy has /// been evicted yields a cell with no crop rather than being dropped — the face /// is still real, still counted, and still confirmable from its filename. /// /// `cut` is whatever the previous load of this grid had already decoded, /// keyed by face, and is consumed: a crop found there is moved into the new /// cell and neither read from the catalog nor decoded again. A confirm or a /// reject changes one face's row and redraws the whole grid, and without this /// the redraw re-read four megabytes of JPEG and decoded seven hundred of /// them — 300 ms on the reference library's largest person, per click, to /// arrive at pixels the screen was already showing. Face ids are global, so /// a map from another person's grid is merely useless, never wrong. pub fn load_faces( catalog: &Catalog, store: &ThumbStore, person: PersonId, mut cut: std::collections::HashMap, ) -> Result, dr_catalog::CatalogError> { let conn = catalog.connection(); let rows = faces::for_person(conn, person, true)?; // The crops kept at detection time, in one query — but only when a face // is not already in hand. The common redraw has every face cached and // skips the blob read entirely; a face the cache lacks (a regroup, a // fresh sweep) costs the read for the whole person once, and it is // cached from then on. // // Where a face has a stored crop this is the whole cost of drawing it — // no proxy, no full-size JPEG decode, and no dependence on the thumbnail // cache still holding the photograph. let stored = if rows.iter().all(|f| cut.contains_key(&f.id)) { Default::default() } else { faces::crops_for_person(conn, person, true)? }; // The fallback path, for faces indexed before crops were kept. One decode // per *image*, not per face: a group photograph holding six faces of one // family is one JPEG, and decoding it six times is the kind of waste that // only shows up on a slow machine. let mut decoded: std::collections::HashMap)>> = std::collections::HashMap::new(); let mut out = Vec::with_capacity(rows.len()); for f in rows { let crop = match cut .remove(&f.id) .or_else(|| stored.get(&f.id).and_then(|b| decode_crop(b))) { Some(c) => Some(c), None => { let entry = decoded .entry(f.image_id) .or_insert_with(|| decode_proxy(catalog, store, f.image_id)); entry .as_ref() .and_then(|(w, h, rgba)| crop_face(rgba, *w, *h, &f, FACE_CROP_EDGE)) } }; out.push(FaceCell { face: f.id, image: f.image_id, crop, confirmed: f.confirmed, probability: f.probability, crop_px: f.crop_px, quality: f.quality, eyes: f.eyes, }); } Ok(out) } /// The face to show as a person's portrait. /// /// Confirmed faces first, then the largest — `crop_px` is the number of source /// pixels the face actually occupied, so the largest is the one the user has /// the best chance of recognising. Falling back to a suggestion means a /// freshly clustered group still has a face beside it, which is the moment the /// portrait matters most: the rail is how the user decides which unnamed group /// to open first. pub fn load_cover( catalog: &Catalog, store: &ThumbStore, person: PersonId, ) -> Result, dr_catalog::CatalogError> { let mut rows = faces::for_person(catalog.connection(), person, true)?; if rows.is_empty() { return Ok(None); } // Confirmed first, then largest. rows.sort_by(|a, b| { b.confirmed .cmp(&a.confirmed) .then(b.crop_px.total_cmp(&a.crop_px)) }); // Cut the best few and take the first legible one. // // Size alone picked some very dark crops on the reference library: the // largest face in a group is often the one nearest the camera in a badly // lit frame, and a black square beside a name identifies nobody. Bounded // at a handful because each candidate costs a JPEG decode, and the list is // already in preference order — so this gives up size only when the // preferred face is genuinely too dark to recognise. let mut fallback: Option = None; let conn = catalog.connection(); for face in rows.iter().take(COVER_CANDIDATES) { // A stored crop costs a small JPEG decode; the fallback costs a full // proxy decode, which is what made the rail expensive to build. let crop = match faces::crop(conn, face.id) .ok() .flatten() .and_then(|b| decode_crop(&b)) { Some(c) => c, None => { let Some((w, h, rgba)) = decode_proxy(catalog, store, face.image_id) else { continue; }; let Some(c) = crop_face(&rgba, w, h, face, COVER_CROP_EDGE) else { continue; }; c } }; if mean_luma(&crop) >= MIN_COVER_LUMA { return Ok(Some(crop)); } fallback.get_or_insert(crop); } // Everything this person has is dark. Their largest face is still the best // answer available, and showing it beats showing nothing. Ok(fallback) } /// How many faces to cut before settling for the largest. const COVER_CANDIDATES: usize = 4; /// Mean luma a cover must reach to be preferred over a larger, darker one. /// /// Low: this rejects the near-black, not the moody. A crop at 0.18 is a /// legible face in a dim room; one at 0.05 is a silhouette. const MIN_COVER_LUMA: f32 = 0.18; /// Rec. 709 luma, averaged over the crop, ignoring transparent margin. fn mean_luma(crop: &FaceCrop) -> f32 { let mut sum = 0.0_f32; let mut n = 0_u32; for px in crop.rgba.chunks_exact(4) { // Out-of-frame margin is transparent and black; counting it would make // every edge-of-frame face look darker than it is. if px[3] == 0 { continue; } sum += (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32) / 255.0; n += 1; } if n == 0 { 0.0 } else { sum / n as f32 } } /// Turn a stored crop back into pixels. /// /// Returned at whatever size it was stored (`faces::STORED_CROP_EDGE`) rather /// than resampled down to the cell: the grid and the rail scale it themselves, /// and doing it here would mean two resamples where one will do — and a soft /// portrait for the trouble. fn decode_crop(bytes: &[u8]) -> Option { let (w, h, rgba) = dr_thumbs::codec::decode_rgba(bytes).ok()?; Some(FaceCrop { width: w, height: h, rgba, }) } fn decode_proxy( catalog: &Catalog, store: &ThumbStore, image: ImageId, ) -> Option<(u32, u32, Vec)> { let file_id: i64 = catalog .connection() .query_row( "SELECT file_id FROM remote WHERE image_id = ?1", [image.0 as i64], |r| r.get(0), ) .ok()?; let thumb = store.get(file_id as u64, FACE_TIER).ok()??; dr_thumbs::codec::decode_rgba(&thumb.bytes).ok() } /// A named face box normalised to the image's long edge: `(x, y, w, h, name)`. /// /// Named because it crosses three layers — catalog, develop session, /// segmentation job — and "the fourth float" is not something anyone should /// have to count out at each one. pub type NormalisedNamedBox = (f32, f32, f32, f32, String); /// The confirmed, named faces in one image, **normalised to the long edge**. /// /// The form a develop session carries, because the proxy a segmentation run /// settles on is not known when the image opens — so the scaling happens at /// the far end, in [`crate::develop::SegmentationJob`]. /// /// **Confirmed names only.** A suggestion is the system's guess, and printing /// a guessed name onto a mask region would launder it into a fact — the exact /// conflation the confirmed/suggested split exists to prevent (FR-CULL-10). An /// unrecognised or merely-suggested person leaves the region as "person", /// which is honest. pub fn named_boxes_normalised( catalog: &Catalog, image: ImageId, ) -> Result, dr_catalog::CatalogError> { let conn = catalog.connection(); let mut names: std::collections::HashMap = std::collections::HashMap::new(); for p in faces::people(conn)? { if !p.name.is_empty() { names.insert(p.id, p.name); } } if names.is_empty() { return Ok(Vec::new()); } Ok(faces::for_image(conn, image)? .into_iter() .filter(|f| f.confirmed) .filter_map(|f| { let name = f.person.and_then(|p| names.get(&p))?.clone(); Some((f.x, f.y, f.w, f.h, name)) }) .collect()) } /// A named face box for one image, in proxy pixels. /// /// Owned rather than borrowed because it crosses from a catalog read into a /// segmentation pass that outlives the query. #[derive(Debug, Clone, PartialEq)] pub struct NamedBox { pub bbox: (f32, f32, f32, f32), pub name: String, } /// The named faces in one image, ready to label its segmented regions. /// /// **Confirmed names only.** A suggestion is the system's guess, and printing /// a guessed name on a mask region would launder it into a fact — the exact /// conflation the confirmed/suggested split exists to prevent (FR-CULL-10). /// An unrecognised or merely-suggested person leaves the region as "person", /// which is honest. /// /// Boxes come back in the proxy pixel space the caller names, because that is /// what `Segmentation` works in — the catalog stores them normalised to the /// long edge precisely so this conversion is possible at any resolution. pub fn named_boxes_for_image( catalog: &Catalog, image: ImageId, proxy_w: usize, proxy_h: usize, ) -> Result, dr_catalog::CatalogError> { let conn = catalog.connection(); let long_edge = proxy_w.max(proxy_h) as f32; if long_edge <= 0.0 { return Ok(Vec::new()); } let mut names: std::collections::HashMap = std::collections::HashMap::new(); for p in faces::people(conn)? { if !p.name.is_empty() { names.insert(p.id, p.name); } } Ok(faces::for_image(conn, image)? .into_iter() .filter(|f| f.confirmed) .filter_map(|f| { let name = f.person.and_then(|p| names.get(&p))?.clone(); Some(NamedBox { bbox: ( f.x * long_edge, f.y * long_edge, (f.x + f.w) * long_edge, (f.y + f.h) * long_edge, ), name, }) }) .collect()) } // ── the user's decisions ────────────────────────────────────────────────── /// Name a group, or rename a person. /// /// The identity is untouched: FR-CULL-12 makes a *name* the user data that /// travels to the sidecar, while the person's UUID is what a cross-device merge /// keys on, and renaming must not create a second person on the other device. pub fn rename( catalog: &Catalog, person: PersonId, name: &str, ) -> Result<(), dr_catalog::CatalogError> { faces::rename_person(catalog.connection(), person, name.trim()) } /// Confirm every suggestion in a group at once. /// /// The operation the screen exists to make cheap. A cluster that is simply /// right — the common case for a well-photographed person — should cost one /// click, not forty. pub fn confirm_all(catalog: &Catalog, person: PersonId) -> Result { let conn = catalog.connection(); let mut n = 0; for f in faces::for_person(conn, person, true)? { if !f.confirmed { faces::confirm(conn, f.id, person)?; n += 1; } } Ok(n) } /// The user says this face is this person. pub fn confirm( catalog: &Catalog, face: FaceId, person: PersonId, ) -> Result<(), dr_catalog::CatalogError> { faces::confirm(catalog.connection(), face, person) } /// The user says this face is not this person. /// /// Remembered, so the next clustering pass does not put it straight back — /// which is the difference between a correction and a recurring argument. pub fn reject( catalog: &Catalog, face: FaceId, person: PersonId, ) -> Result<(), dr_catalog::CatalogError> { faces::reject(catalog.connection(), face, person) } /// Another person already carrying this name, if there is one. /// /// A name is not an identity here — `people.uuid` is, which is what lets two /// devices name the same cluster independently and still merge cleanly /// (catalog.md, `people.uuid`). So this reports a *collision* for the screen to /// offer a merge on and does nothing else: two people are allowed to share a /// name, and a library with two Annas in it is a real library, not a mistake to /// be corrected without being asked. /// /// Compared case-insensitively and trimmed. "anna" typed on a phone keyboard /// and "Anna" typed on a desktop are one intention, and an offer that appeared /// only when the capitalisation happened to match would read as a bug. /// /// Merged-away people cannot come back: `faces::people` already excludes them, /// so a name freed by an earlier merge collides with nothing. pub fn namesake( catalog: &Catalog, person: PersonId, name: &str, ) -> Result, dr_catalog::CatalogError> { let key = name.trim().to_lowercase(); // An unnamed cluster is not a namesake of every other unnamed cluster. // They all render as "Unnamed (n faces)" and folding them together on that // basis would merge the whole library into one person. if key.is_empty() { return Ok(None); } Ok(dr_catalog::faces::people(catalog.connection())? .into_iter() .find(|p| p.id != person && p.name.trim().to_lowercase() == key) .map(|p| PersonRow { id: p.id, name: p.name, confirmed_faces: p.confirmed_faces, suggested_faces: p.suggested_faces, cover: None, ignored: p.ignored, })) } /// Fold one person into another. pub fn merge( catalog: &Catalog, target: PersonId, source: PersonId, ) -> Result { faces::merge_people(catalog.connection(), target, source) } /// What a split would produce, without performing it. /// /// Offered as a preview because a split is the destructive-feeling half of /// FR-CULL-10 and the user should see the groups before agreeing to them. It /// re-agglomerates this person's faces at a stricter threshold; a person who is /// genuinely one person comes back as one group and the UI can say so instead /// of splitting nothing. pub fn preview_split( catalog: &Catalog, store: &ThumbStore, person: PersonId, model_id: &str, strictness: f32, ) -> Result>, dr_catalog::CatalogError> { let conn = catalog.connection(); let cal = faces::calibration(conn, model_id)? .map(|(c, _)| c) .unwrap_or_default(); let cells = load_faces(catalog, store, person, Default::default())?; if cells.len() < 2 { return Ok(vec![cells]); } let model = dr_face::ModelId::new(model_id.to_string()); let stored: std::collections::HashMap<_, _> = faces::embeddings(conn, model_id)? .into_iter() .map(|e| (e.face, e)) .collect(); let mut candidates = Vec::with_capacity(cells.len()); let mut order = Vec::with_capacity(cells.len()); for c in &cells { let Some(e) = stored.get(&c.face) else { continue; }; let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &e.embedding) else { continue; }; candidates.push(dr_face::Candidate { face: c.face.0, image: e.image.0, embedding: emb.v.to_vec(), crop_px: e.crop_px, quality: e.quality, confirmed_person: None, }); order.push(c.clone()); } let groups = dr_face::split(&candidates, &cal, strictness); Ok(groups .into_iter() .map(|g| g.members.into_iter().map(|m| order[m].clone()).collect()) .collect()) } /// Pull a set of faces out of a person and onto a new one. /// /// The commit half of [`preview_split`], and also what "these three are /// actually someone else" does. The faces are **confirmed** onto the new /// person, because the user has just asserted they belong together — leaving /// them as suggestions would invite the next clustering pass to undo the /// correction. pub fn split_off( catalog: &Catalog, from: PersonId, members: &[FaceId], name: &str, ) -> Result { let conn = catalog.connection(); let new_person = faces::create_person(conn, name.trim())?; for &face in members { // 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, and the user's correction becomes an argument they keep having. faces::reject(conn, face, from)?; faces::confirm(conn, face, new_person)?; } Ok(new_person) } /// Detach a face from everyone, asserting nothing. /// /// Distinct from [`reject`]: this is "I do not know", and the face returns to /// the pool for the next clustering pass to place. Needed for the case where /// the user can see a crop is a lamp, not a person — rejecting it from *this* /// person would leave it free to be suggested for the next one. pub fn unassign(catalog: &Catalog, face: FaceId) -> Result<(), dr_catalog::CatalogError> { faces::unassign(catalog.connection(), face) } /// Delete every face, person and embedding in the library (NFR-SEC-5). /// /// Surfaced from this screen because this is where the user's face data /// visibly lives, and a control to remove it that is hidden in a settings page /// three levels down is a control that does not really exist. pub fn delete_all(catalog: &Catalog) -> Result { faces::delete_all_face_data(catalog.connection()) } #[cfg(test)] mod tests { use super::*; use crate::faces::GroupingPreview; use dr_catalog::faces::DetectedFace; #[test] fn a_preview_leads_with_the_group_count() { let line = preview_label(&GroupingPreview { faces: 1813, groups: 328, grouped: 1341, largest: 69, }); // The group count is the number that says which side of the right // setting you are on, so it is the number the sentence starts with. assert!(line.starts_with("328 group"), "{line}"); assert!(line.contains("1341 of 1813"), "{line}"); assert!(line.contains("69"), "{line}"); } /// The ordinary state of a library nobody has run the indexer over — and /// "0 group(s), holding 0 of 0 faces" would read as a failure of the dials /// rather than as an absence of input. #[test] fn a_preview_of_nothing_says_there_is_nothing() { let line = preview_label(&GroupingPreview::default()); assert!(line.contains("No faces indexed"), "{line}"); } fn catalog() -> Catalog { let dir = std::env::temp_dir().join(format!( "dr-identity-{}-{:?}", std::process::id(), std::thread::current().id() )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("catalog.db"); let _ = std::fs::remove_file(&path); Catalog::open(&path).unwrap() } fn image(c: &Catalog, n: i64) -> ImageId { let conn = c.connection(); conn.execute( "INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", [], ) .unwrap(); conn.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); 5], confidence: 0.9, embedding: vec![seed; 1024], crop_px: 180.0, quality: Some(f32::from(seed) + 10.0), eyes: None, landmarks_dense: Vec::new(), model_id: "w600k_mbf".into(), crop: Vec::new(), } } #[test] fn an_unnamed_group_says_how_many_faces_it_holds_rather_than_guessing_a_name() { let row = PersonRow { id: PersonId(1), name: String::new(), confirmed_faces: 0, suggested_faces: 12, cover: None, ignored: false, }; assert_eq!(row.display_name(), "Unnamed (12 faces)"); assert!(row.is_unconfirmed()); } #[test] fn a_named_person_shows_their_name() { let row = PersonRow { id: PersonId(1), name: "Anna".into(), confirmed_faces: 4, suggested_faces: 2, cover: None, ignored: false, }; assert_eq!(row.display_name(), "Anna"); assert!(!row.is_unconfirmed()); } /// FR-CULL-9 at the point it becomes visible. The rule is that an unfitted /// curve must not be *described* as measured — not that the number is /// withheld, which left the user with forty unrankable suggestions on every /// library too young to have earned a fit. The percentage is always shown; /// the provenance is said once, at the screen level. #[test] fn every_suggestion_shows_a_percentage_whatever_the_curve_was_fitted_from() { let cell = FaceCell { face: FaceId(1), image: ImageId(1), crop: None, confirmed: false, probability: 0.87, crop_px: 120.0, quality: Some(17.26), eyes: None, }; assert_eq!(cell.confidence_label(), "87% likely"); let confirmed = FaceCell { confirmed: true, ..cell }; assert_eq!(confirmed.confidence_label(), "Confirmed"); } /// The number is the embedding's length, and the screen calls it what it /// is used as. A face from before it was kept says so rather than showing /// a zero that would read as the worst face in the library. #[test] fn the_quality_is_labelled_as_such_and_dimmed_below_the_floor() { let cell = FaceCell { face: FaceId(1), image: ImageId(1), crop: None, confirmed: false, probability: 0.5, crop_px: 120.0, quality: Some(17.26), eyes: None, }; assert_eq!(cell.quality_label(), "Quality 17.3"); assert!(cell.in_gallery()); let poor = FaceCell { quality: Some(9.4), ..cell.clone() }; assert_eq!(poor.quality_label(), "Quality 9.4"); assert!(!poor.in_gallery()); let unmeasured = FaceCell { quality: None, ..cell }; assert_eq!(unmeasured.quality_label(), "Quality —"); assert!( unmeasured.in_gallery(), "an unmeasured face is not a poor one" ); } /// Only the two states the filter treats differently from open are /// badged; open and unread carry no mark. #[test] fn the_eye_badge_names_a_blink_or_sunglasses_and_nothing_else() { let eye = |open| dr_face::Eye { open, px: 40.0, sharpness: 0.2, }; let reading = |right, left, sunglasses| { Some(dr_face::EyeReading { right: eye(right), left: eye(left), sunglasses, }) }; let cell = FaceCell { face: FaceId(1), image: ImageId(1), crop: None, confirmed: false, probability: 0.5, crop_px: 120.0, quality: Some(17.26), eyes: None, }; assert_eq!(cell.eyes_label(), ""); let open = FaceCell { eyes: reading(0.9, 0.9, 0.0), ..cell.clone() }; assert_eq!(open.eyes_label(), ""); let blink = FaceCell { eyes: reading(0.9, 0.2, 0.0), ..cell.clone() }; assert_eq!(blink.eyes_label(), "Eyes closed"); let shades = FaceCell { eyes: reading(0.9, 0.2, 0.9), ..cell.clone() }; assert_eq!(shades.eyes_label(), "Sunglasses"); let mut soft = reading(0.1, 0.1, 0.0).unwrap(); soft.right.sharpness = 0.0; soft.left.sharpness = 0.0; let unclear = FaceCell { eyes: Some(soft), ..cell }; assert_eq!(unclear.eyes_label(), "Eyes unclear"); } #[test] fn the_people_rail_reports_the_unassigned_pool() { let c = catalog(); let i1 = image(&c, 1); let i2 = image(&c, 2); let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); faces::confirm(c.connection(), a[0], anna).unwrap(); let view = load_people(&c, "w600k_mbf").unwrap(); assert_eq!(view.people.len(), 1); assert_eq!(view.people[0].name, "Anna"); assert_eq!(view.unassigned, 1); assert!( !view.calibrated, "a fresh library has no fit of its own, and says so — it still shows confidences" ); } /// The rail withholds the debris a regrouping pass leaves behind, and only /// that: the same rows `prune_empty_unnamed` treats as disposable. /// /// Not a nicety. Every pass creates a person per cluster and the next pass /// may empty it, nothing on this path prunes, and the reference library /// reached 11,739 of them against 2,525 real people — a rail that was 85 /// per cent rows naming nobody, each one costing a query and a built row. #[test] fn the_rail_withholds_empty_unnamed_groups_and_nothing_else() { let c = catalog(); let i1 = image(&c, 1); let held = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let with_faces = faces::create_person(c.connection(), "Anna").unwrap(); faces::confirm(c.connection(), held[0], with_faces).unwrap(); // The three that hold nothing. Only the first is debris. faces::create_person(c.connection(), "").unwrap(); let named = faces::create_person(c.connection(), "Bob").unwrap(); let set_aside = faces::create_person(c.connection(), "").unwrap(); faces::set_ignored(c.connection(), set_aside, true).unwrap(); let shown: Vec<_> = load_people(&c, "w600k_mbf") .unwrap() .people .into_iter() .map(|p| p.id) .collect(); assert!(shown.contains(&with_faces), "a group with faces is a group"); assert!( shown.contains(&named), "an empty group with a name is the visible symptom of a regroup that \ emptied it, and hiding it takes away the only way to merge it back" ); assert!( shown.contains(&set_aside), "set aside is a judgement the user made, and the prune spares it too" ); assert_eq!( shown.len(), 3, "the empty unnamed group is the one that goes" ); } #[test] fn confirm_all_promotes_every_suggestion_and_nothing_else() { let c = catalog(); let i1 = image(&c, 1); let i2 = image(&c, 2); let i3 = image(&c, 3); let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let d = faces::record_detections(c.connection(), i3, "w600k_mbf", 1024, &[face(3)]).unwrap(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); faces::confirm(c.connection(), a[0], anna).unwrap(); faces::suggest(c.connection(), b[0], anna, 0.8).unwrap(); faces::suggest(c.connection(), d[0], anna, 0.7).unwrap(); assert_eq!(confirm_all(&c, anna).unwrap(), 2); assert_eq!(confirm_all(&c, anna).unwrap(), 0, "should be idempotent"); assert_eq!( faces::for_person(c.connection(), anna, false) .unwrap() .len(), 3 ); } /// The correction must stick. Splitting a face off and then re-running /// clustering must not put it back where the user took it from. #[test] fn a_split_face_is_not_suggested_back_to_the_person_it_left() { let c = catalog(); let i1 = image(&c, 1); let i2 = image(&c, 2); let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); faces::confirm(c.connection(), a[0], anna).unwrap(); faces::suggest(c.connection(), b[0], anna, 0.95).unwrap(); let bob = split_off(&c, anna, &[b[0]], "Bob").unwrap(); assert_eq!( faces::for_person(c.connection(), bob, false).unwrap().len(), 1 ); assert_eq!( faces::for_person(c.connection(), anna, true).unwrap().len(), 1 ); // The next clustering pass tries again and is refused. assert!( !faces::suggest(c.connection(), b[0], anna, 0.99).unwrap(), "the split was undone by the next inference pass" ); } #[test] fn renaming_trims_and_keeps_the_person() { let c = catalog(); let anna = faces::create_person(c.connection(), "").unwrap(); rename(&c, anna, " Anna Smith ").unwrap(); let people = faces::people(c.connection()).unwrap(); assert_eq!(people[0].name, "Anna Smith"); assert_eq!(people[0].id, anna); } #[test] fn a_namesake_is_found_whatever_the_capitalisation() { let c = catalog(); let anna = faces::create_person(c.connection(), "Anna Smith").unwrap(); let other = faces::create_person(c.connection(), "").unwrap(); rename(&c, other, " anna smith ").unwrap(); let found = namesake(&c, other, " anna smith ") .unwrap() .expect("a namesake"); assert_eq!(found.id, anna); assert_eq!( found.name, "Anna Smith", "the existing spelling is reported" ); } #[test] fn a_person_is_not_their_own_namesake() { let c = catalog(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); assert!( namesake(&c, anna, "Anna").unwrap().is_none(), "renaming someone to the name they already have offered a merge with themselves" ); } /// Every unnamed cluster renders as "Unnamed (n faces)". If an empty name /// counted as a collision, the offer would appear on every cluster in a /// freshly indexed library and accepting it would fold the library into one /// person. #[test] fn an_empty_name_collides_with_nothing() { let c = catalog(); faces::create_person(c.connection(), "").unwrap(); let second = faces::create_person(c.connection(), "").unwrap(); assert!(namesake(&c, second, "").unwrap().is_none()); assert!(namesake(&c, second, " ").unwrap().is_none()); } /// A merge leaves a redirect behind rather than deleting the row, so the /// name it carried must not keep colliding — otherwise the offer would /// reappear immediately after being accepted, pointing at a person the /// rail no longer shows. #[test] fn a_merged_away_person_is_not_a_namesake() { let c = catalog(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); let dup = faces::create_person(c.connection(), "Anna").unwrap(); merge(&c, anna, dup).unwrap(); let third = faces::create_person(c.connection(), "").unwrap(); rename(&c, third, "Anna").unwrap(); let found = namesake(&c, third, "Anna").unwrap().expect("a namesake"); assert_eq!( found.id, anna, "the offer pointed at the merged-away person" ); } #[test] fn merging_folds_one_person_into_the_other() { let c = catalog(); let i1 = image(&c, 1); let i2 = image(&c, 2); let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); let annie = faces::create_person(c.connection(), "Annie").unwrap(); faces::confirm(c.connection(), a[0], anna).unwrap(); faces::confirm(c.connection(), b[0], annie).unwrap(); assert_eq!(merge(&c, anna, annie).unwrap(), 1); assert_eq!(load_people(&c, "w600k_mbf").unwrap().people.len(), 1); } /// The segmentation link: a confirmed face inside a `person` region should /// put that person's name on it. #[test] fn named_boxes_come_back_in_proxy_pixels() { let c = catalog(); let img = image(&c, 1); let ids = faces::record_detections(c.connection(), img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); faces::confirm(c.connection(), ids[0], anna).unwrap(); // The fixture's face is x=0.1 y=0.1 w=0.2 h=0.3, normalised to the // long edge — so at 1024×683 it spans 102.4..307.2 across. let boxes = named_boxes_for_image(&c, img, 1024, 683).unwrap(); assert_eq!(boxes.len(), 1); assert_eq!(boxes[0].name, "Anna"); assert!((boxes[0].bbox.0 - 102.4).abs() < 0.1, "{:?}", boxes[0].bbox); assert!((boxes[0].bbox.2 - 307.2).abs() < 0.1, "{:?}", boxes[0].bbox); // And the same face at another proxy size lands proportionally — the // reason the catalog stores these normalised. let bigger = named_boxes_for_image(&c, img, 2048, 1366).unwrap(); assert!((bigger[0].bbox.0 - 204.8).abs() < 0.1); } /// A suggestion is the system's guess. Printing a guessed name onto a mask /// region would launder it into a fact. #[test] fn a_suggested_person_does_not_name_a_region() { let c = catalog(); let img = image(&c, 1); let ids = faces::record_detections(c.connection(), img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); faces::suggest(c.connection(), ids[0], anna, 0.95).unwrap(); assert!(named_boxes_for_image(&c, img, 1024, 683) .unwrap() .is_empty()); // Confirming it makes the name appear. faces::confirm(c.connection(), ids[0], anna).unwrap(); assert_eq!(named_boxes_for_image(&c, img, 1024, 683).unwrap().len(), 1); } /// An unnamed cluster has nothing to say about a region. #[test] fn an_unnamed_group_does_not_name_a_region() { let c = catalog(); let img = image(&c, 1); let ids = faces::record_detections(c.connection(), img, "w600k_mbf", 1024, &[face(1)]).unwrap(); let nameless = faces::create_person(c.connection(), "").unwrap(); faces::confirm(c.connection(), ids[0], nameless).unwrap(); assert!(named_boxes_for_image(&c, img, 1024, 683) .unwrap() .is_empty()); } #[test] fn mean_luma_ignores_the_transparent_margin() { // Half opaque white, half transparent black. Counting the margin would // report 0.5; ignoring it reports 1.0, which is what the face is. let mut rgba = vec![0u8; 4 * 4 * 4]; for (i, px) in rgba.chunks_exact_mut(4).enumerate() { if i < 8 { px.copy_from_slice(&[255, 255, 255, 255]); } } let crop = FaceCrop { width: 4, height: 4, rgba, }; assert!( (mean_luma(&crop) - 1.0).abs() < 1e-3, "{}", mean_luma(&crop) ); } #[test] fn a_dark_crop_falls_below_the_cover_threshold_and_a_lit_one_clears_it() { let solid = |v: u8| FaceCrop { width: 2, height: 2, rgba: vec![v, v, v, 255, v, v, v, 255, v, v, v, 255, v, v, v, 255], }; assert!(mean_luma(&solid(10)) < MIN_COVER_LUMA); assert!(mean_luma(&solid(120)) >= MIN_COVER_LUMA); } #[test] fn deleting_everything_empties_the_screen() { let c = catalog(); let i1 = image(&c, 1); let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap(); let anna = faces::create_person(c.connection(), "Anna").unwrap(); faces::confirm(c.connection(), a[0], anna).unwrap(); assert_eq!(delete_all(&c).unwrap(), 1); let view = load_people(&c, "w600k_mbf").unwrap(); assert!(view.people.is_empty()); assert_eq!(view.unassigned, 0); } }