//! 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; /// 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, } 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). pub probability: f32, /// Whether that probability means anything — false when the library's /// calibration has not been fitted (FR-CULL-9). pub probability_known: bool, /// 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, or why there is none. /// /// The FR-CULL-9 rule made concrete: where the calibration is not fitted, /// this says so rather than printing an untuned number that looks measured. pub fn confidence_label(&self) -> String { if self.confirmed { "Confirmed".into() } else if !self.probability_known { "Confidence unavailable".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. 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, }) .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, calibrated: bool, ) -> Result, dr_catalog::CatalogError> { let conn = catalog.connection(); let rows = faces::for_person(conn, person, true)?; // 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 entry = decoded .entry(f.image_id) .or_insert_with(|| decode_proxy(catalog, store, f.image_id)); let crop = 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, probability_known: calibrated, crop_px: f.crop_px, }); } Ok(out) } 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() } // ── 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) } /// 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 calibrated = cal.valid; let cells = load_faces(catalog, store, person, calibrated)?; 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(), } } #[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, }; 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, }; assert_eq!(row.display_name(), "Anna"); assert!(!row.is_unconfirmed()); } /// FR-CULL-9's rule at the point it becomes visible: an unfitted /// calibration must not print a number that looks measured. #[test] fn an_uncalibrated_library_says_so_instead_of_showing_a_percentage() { let cell = FaceCell { face: FaceId(1), image: ImageId(1), crop: None, confirmed: false, probability: 0.87, probability_known: false, crop_px: 120.0, }; assert_eq!(cell.confidence_label(), "Confidence unavailable"); let calibrated = FaceCell { probability_known: true, ..cell.clone() }; assert_eq!(calibrated.confidence_label(), "87% likely"); let confirmed = FaceCell { confirmed: true, probability_known: false, ..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 fitted calibration"); } #[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 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); } #[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); } }