//! 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, } 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) } } } /// 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. pub fn load_people( catalog: &Catalog, model_id: &str, ) -> Result { let conn = catalog.connection(); let people = faces::people(conn)? .into_iter() .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. pub fn load_faces( catalog: &Catalog, store: &ThumbStore, person: PersonId, ) -> 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. Where a face has one 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 = 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 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, }); } 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)?; 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(|(id, image, blob, crop_px)| (id, (image, blob, crop_px))) .collect(); let mut candidates = Vec::with_capacity(cells.len()); let mut order = Vec::with_capacity(cells.len()); for c in &cells { let Some((image, blob, crop_px)) = stored.get(&c.face) else { continue; }; let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), blob) else { continue; }; candidates.push(dr_face::Candidate { face: c.face.0, image: image.0, embedding: emb.v.to_vec(), crop_px: *crop_px, 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 dr_catalog::faces::DetectedFace; 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, 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, }; assert_eq!(cell.confidence_label(), "87% likely"); let confirmed = FaceCell { confirmed: true, ..cell }; assert_eq!(confirmed.confidence_label(), "Confirmed"); } #[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" ); } #[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); } }