//! TRACES: FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | NFR-SEC-5 //! The Identity screen's wiring: catalog state onto Slint models and back. //! //! [`crate::identity`] holds the decisions and is testable without a window; //! this file is the part that cannot be, and it is deliberately thin. Anything //! here that starts making a judgement belongs over there. use std::cell::RefCell; use std::rc::Rc; use std::sync::mpsc::Receiver; use std::time::Duration; use dr_catalog::faces::{FaceId, PersonId}; use dr_catalog::Catalog; use dr_thumbs::ThumbStore; use slint::{ComponentHandle, ModelRc, VecModel}; use crate::faces::FaceSweepMessage; use crate::identity::{self, FaceCell, PersonRow}; use crate::{AppWindow, IdentityFace, IdentityPerson}; /// Which model's faces the screen is looking at. /// /// A constant for now because exactly one model is supported at a time; it is /// named rather than inlined because every query in this file is keyed on it, /// and a library indexed across a model change holds faces from both. pub const MODEL_ID: &str = "w600k_mbf"; /// Screen state that outlives a single callback. #[derive(Default)] pub struct IdentityController { people: RefCell>, faces: RefCell>, selected: std::cell::Cell>, /// Faces the user has ticked, which is what a split would carry. picked: RefCell>, /// The running indexing sweep, if any. /// /// Holding the receiver *is* the cancellation handle: the worker stops when /// its send fails, so dropping this is how "Stop" works. No flag to get out /// of step with the thread, and everything already written stays written. sweep: RefCell>>, /// Images visited so far in the current sweep, and the total it announced. progress: std::cell::Cell<(usize, usize)>, faces_found: std::cell::Cell, /// Whether opening this screen left the library rather than develop. /// /// Recorded so leaving puts the user back where they were. The screen is /// reachable from both other modes, and returning a photographer to the /// grid when they came from an open photograph loses their place for no /// reason. came_from_library: std::cell::Cell, /// The activity row for the running sweep. /// /// Face indexing is an hours-long background pass, and the Settings page /// promises its progress will appear in the list above the button. Without /// a row it would not, and the button would be the only sign it was /// running — invisible from every other screen. activity: RefCell>, /// The person a namesake merge is currently being offered against. /// /// Held here rather than read back off the window because the window /// carries the *name* for the strip to print, and a name is not a key — /// two people called Anna are exactly the case this feature exists for, so /// re-deriving the target from the displayed text could pick the wrong one. merge_offer: std::cell::Cell>, /// Rail portraits, kept between refreshes. /// /// Every mutating action reloads the whole screen, and cutting a portrait /// costs a JPEG decode per person. Without this, confirming one face would /// re-decode a proxy for every person in the library — and the rail does /// not change when a suggestion is accepted. covers: RefCell>, } impl IdentityController { pub fn new() -> Self { Self::default() } /// Clear the multi-select. /// /// Called on every person change: a pick set that survived navigating to /// another person would make the next "Split off" act on faces the user /// can no longer see, which is the kind of surprise that loses data. fn clear_picks(&self) { self.picked.borrow_mut().clear(); } } /// Push the people rail and the face grid into the window. pub fn refresh( window: &AppWindow, ctl: &IdentityController, catalog: &Rc>>, store: Option<&ThumbStore>, ) { let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { window.set_identity_people(ModelRc::new(VecModel::from(Vec::::new()))); window.set_identity_faces(ModelRc::new(VecModel::from(Vec::::new()))); return; }; let view = match identity::load_people(cat, MODEL_ID) { Ok(v) => v, Err(e) => { log::warn!("identity: reading people: {e}"); return; } }; window.set_identity_unassigned(view.unassigned as i32); window.set_identity_calibrated(view.calibrated); // Drop portraits for people who no longer exist, so a long session that // merges and splits repeatedly does not accumulate them. { let live: std::collections::HashSet = view.people.iter().map(|p| p.id).collect(); ctl.covers.borrow_mut().retain(|id, _| live.contains(id)); } let rows: Vec = view .people .iter() .map(|p| { let cover = store.and_then(|store| { if let Some(img) = ctl.covers.borrow().get(&p.id) { return Some(img.clone()); } let crop = identity::load_cover(cat, store, p.id).ok().flatten()?; let img = to_slint_image(crop.width, crop.height, &crop.rgba); ctl.covers.borrow_mut().insert(p.id, img.clone()); Some(img) }); IdentityPerson { id: p.id.0 as i32, label: p.display_name().into(), unconfirmed: p.is_unconfirmed(), confirmed_faces: p.confirmed_faces as i32, suggested_faces: p.suggested_faces as i32, has_cover: cover.is_some(), cover: cover.unwrap_or_default(), } }) .collect(); window.set_identity_people(ModelRc::new(VecModel::from(rows))); *ctl.people.borrow_mut() = view.people; // The selected person may have just been merged away or deleted. if let Some(sel) = ctl.selected.get() { if !ctl.people.borrow().iter().any(|p| p.id == sel) { ctl.selected.set(None); ctl.clear_picks(); } } // So may the person an offer points at — a sync, or a merge performed from // the other side. An offer whose target no longer exists would put a button // on screen that cannot do anything. if let Some(target) = ctl.merge_offer.get() { if !ctl.people.borrow().iter().any(|p| p.id == target) { clear_merge_offer(window, ctl); } } match (ctl.selected.get(), store) { (Some(person), Some(store)) => { let cells = identity::load_faces(cat, store, person, view.calibrated).unwrap_or_else(|e| { log::warn!("identity: reading faces: {e}"); Vec::new() }); push_faces(window, ctl, &cells); *ctl.faces.borrow_mut() = cells; let name = ctl .people .borrow() .iter() .find(|p| p.id == person) .map(|p| p.name.clone()) .unwrap_or_default(); window.set_identity_selected(person.0 as i32); window.set_identity_selected_name(name.into()); } _ => { window.set_identity_selected(-1); window.set_identity_selected_name(Default::default()); window.set_identity_faces(ModelRc::new(VecModel::from(Vec::::new()))); ctl.faces.borrow_mut().clear(); } } window.set_identity_picked(ctl.picked.borrow().len() as i32); drop(borrow); refresh_coverage(window, catalog, store); } /// Run the batch check and put its answer on screen. /// /// Cheap enough to call on every open and after every sweep: two counts and one /// indexed scan, no decoding and no inference. pub fn refresh_coverage( window: &AppWindow, catalog: &Rc>>, store: Option<&ThumbStore>, ) { let borrow = catalog.borrow(); let (Some(cat), Some(store)) = (borrow.as_ref(), store) else { window.set_identity_coverage(Default::default()); return; }; match crate::faces::audit(cat, store, MODEL_ID) { Ok(a) => { window.set_identity_coverage(a.summary().into()); // Complete means nothing left to index, not "every image has a // face": most of a library has none, and that is a finding. window.set_identity_coverage_complete(a.coverage.is_complete()); } Err(e) => { log::warn!("identity: coverage check: {e}"); window.set_identity_coverage("coverage unknown".into()); } } } /// Take the namesake offer off the screen. /// /// The name is what the strip keys on being visible, so emptying it is what /// hides the strip; the target is cleared alongside so a later accept cannot /// act on an offer the user can no longer see. fn clear_merge_offer(window: &AppWindow, ctl: &IdentityController) { ctl.merge_offer.set(None); window.set_identity_merge_offer_name(Default::default()); window.set_identity_merge_offer_faces(0); } fn push_faces(window: &AppWindow, ctl: &IdentityController, cells: &[FaceCell]) { let picked = ctl.picked.borrow(); let rows: Vec = cells .iter() .map(|c| IdentityFace { id: c.face.0 as i32, crop: c .crop .as_ref() .map(|p| to_slint_image(p.width, p.height, &p.rgba)) .unwrap_or_default(), has_crop: c.crop.is_some(), confirmed: c.confirmed, confidence: c.confidence_label().into(), crop_px: c.crop_px as i32, picked: picked.contains(&c.face), }) .collect(); window.set_identity_faces(ModelRc::new(VecModel::from(rows))); } fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { let mut buf = slint::SharedPixelBuffer::::new(width, height); buf.make_mut_bytes().copy_from_slice(rgba); slint::Image::from_rgba8(buf) } /// Where a background sweep reads from: the catalog file and the thumbnail /// store directory. /// /// Paths rather than the live handles this screen holds, because the sweep runs /// on its own thread and opens its own connection. Two connections to one /// SQLite file is the normal arrangement here; sharing a handle across the /// boundary would not be. pub type SweepPaths = (std::path::PathBuf, std::path::PathBuf); /// The detector and embedder files, when both are present. pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf); /// Attach every Identity callback. pub fn wire( window: &AppWindow, ctl: Rc, catalog: Rc>>, activity: Rc, store: S, models: M, paths: P, ) where S: Fn() -> Option> + 'static, M: Fn() -> Option + 'static, P: Fn() -> Option + 'static, { let store: Rc Option>> = Rc::new(store); let models: Rc Option> = Rc::new(models); let paths: Rc Option> = Rc::new(paths); // Re-read everything and redraw. Every mutating callback ends in this // rather than patching the model in place: the operations here have // second-order effects — a merge empties a person, a split creates one, // a rejection changes two counts — and a model patched by hand would // drift from the catalog in exactly the cases that matter. macro_rules! reload { ($w:expr, $ctl:expr, $catalog:expr, $store:expr) => { refresh(&$w, &$ctl, &$catalog, $store().as_deref()) }; } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); let models_present = models.clone(); window.on_identity_open(move || { let Some(w) = weak.upgrade() else { return }; let from_library = w.get_show_library(); ctl.came_from_library.set(from_library); w.set_identity_back_label(if from_library { "‹ Library".into() } else { "‹ Develop".into() }); w.set_show_identity(true); // A fact about the filesystem, so it is re-checked on every open // rather than cached: the user may have just put the models there. w.set_identity_model_missing(models_present().is_none()); reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_identity_close(move || { let Some(w) = weak.upgrade() else { return }; // Back to whichever screen this was opened from. The develop // session was never torn down — it was only hidden — so returning // to it costs nothing and keeps the photographer's place. w.set_show_library(ctl.came_from_library.get()); w.set_show_identity(false); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_person_picked(move |id| { let Some(w) = weak.upgrade() else { return }; ctl.selected.set(Some(PersonId(id as u64))); ctl.clear_picks(); // The offer was about the person being navigated away from. Left // up, its "Merge" would fold whoever is selected *now*. clear_merge_offer(&w, &ctl); reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_rename(move |name| { let Some(w) = weak.upgrade() else { return }; let Some(person) = ctl.selected.get() else { return; }; // The rename always happens. The merge is a second question, asked // afterwards, and answering "keep separate" must leave the name the // user typed exactly where they typed it. clear_merge_offer(&w, &ctl); if let Some(cat) = catalog.borrow().as_ref() { if let Err(e) = identity::rename(cat, person, &name) { log::warn!("identity: rename: {e}"); } match identity::namesake(cat, person, &name) { Ok(Some(other)) => { log::info!( "identity: {:?} is now called {:?}, which {:?} already is", person, other.name, other.id ); ctl.merge_offer.set(Some(other.id)); w.set_identity_merge_offer_name(other.name.as_str().into()); w.set_identity_merge_offer_faces( (other.confirmed_faces + other.suggested_faces) as i32, ); } Ok(None) => {} Err(e) => log::warn!("identity: looking for a namesake: {e}"), } } reload!(w, ctl, catalog, store); }); } // Accepting the namesake offer. The person the user just named is folded // **into** the one that already had the name, not the other way round: the // older person is the one other devices have already seen and the one whose // confirmations are more likely to be real, and `merge_people` leaves a // redirect behind so neither side of a sync resurrects what was merged. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_merge_accept(move || { let Some(w) = weak.upgrade() else { return }; let (Some(target), Some(source)) = (ctl.merge_offer.get(), ctl.selected.get()) else { return; }; if let Some(cat) = catalog.borrow().as_ref() { match identity::merge(cat, target, source) { Ok(moved) => { log::info!("identity: merged {source:?} into {target:?} ({moved} faces)"); // Follow the merge. The group the user was looking at // no longer exists, and landing on an empty screen // after a successful action reads as a failure. ctl.selected.set(Some(target)); ctl.clear_picks(); } Err(e) => log::warn!("identity: merge: {e}"), } } clear_merge_offer(&w, &ctl); reload!(w, ctl, catalog, store); }); } // Declining it. Nothing to undo — the rename already happened — so this // only takes the strip away, and the library keeps two people with one // name, which is allowed. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_identity_merge_decline(move || { let Some(w) = weak.upgrade() else { return }; clear_merge_offer(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_confirm_face(move |id| { let Some(w) = weak.upgrade() else { return }; let Some(person) = ctl.selected.get() else { return; }; if let Some(cat) = catalog.borrow().as_ref() { if let Err(e) = identity::confirm(cat, FaceId(id as u64), person) { log::warn!("identity: confirm: {e}"); } } reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_reject_face(move |id| { let Some(w) = weak.upgrade() else { return }; let Some(person) = ctl.selected.get() else { return; }; if let Some(cat) = catalog.borrow().as_ref() { if let Err(e) = identity::reject(cat, FaceId(id as u64), person) { log::warn!("identity: reject: {e}"); } } reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_identity_toggle_pick(move |id| { let Some(w) = weak.upgrade() else { return }; let face = FaceId(id as u64); { let mut picked = ctl.picked.borrow_mut(); match picked.iter().position(|&f| f == face) { Some(i) => { picked.remove(i); } None => picked.push(face), } } // Only the pick flags changed, so this is the one case that does // not re-read the catalog: nothing in it moved. push_faces(&w, &ctl, &ctl.faces.borrow()); w.set_identity_picked(ctl.picked.borrow().len() as i32); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_confirm_all(move || { let Some(w) = weak.upgrade() else { return }; let Some(person) = ctl.selected.get() else { return; }; if let Some(cat) = catalog.borrow().as_ref() { match identity::confirm_all(cat, person) { Ok(n) => log::info!("identity: confirmed {n} suggestion(s)"), Err(e) => log::warn!("identity: confirm all: {e}"), } } reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_split_picked(move || { let Some(w) = weak.upgrade() else { return }; let Some(person) = ctl.selected.get() else { return; }; let members: Vec = ctl.picked.borrow().clone(); if members.is_empty() { return; } if let Some(cat) = catalog.borrow().as_ref() { // Unnamed, so the user names it themselves — the same rule the // clustering pass follows. A split that guessed a name would // be presenting an inference as a fact (FR-CULL-10). match identity::split_off(cat, person, &members, "") { Ok(new) => log::info!( "identity: split {} face(s) onto person {:?}", members.len(), new ), Err(e) => log::warn!("identity: split: {e}"), } } ctl.clear_picks(); reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_recluster(move || { let Some(w) = weak.upgrade() else { return }; if let Some(cat) = catalog.borrow().as_ref() { match crate::faces::recluster(cat, MODEL_ID, dr_face::DEFAULT_MERGE_PROBABILITY) { Ok((s, c)) => log::info!("identity: {s} suggestion(s), {c} new group(s)"), Err(e) => log::warn!("identity: recluster: {e}"), } } reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); let models = models.clone(); let paths = paths.clone(); window.on_identity_index(move || { let Some(w) = weak.upgrade() else { return }; if ctl.sweep.borrow().is_some() { return; } let Some((detector, embedder)) = models() else { w.set_identity_model_missing(true); return; }; let Some((catalog_path, store_dir)) = paths() else { return; }; ctl.progress.set((0, 0)); ctl.faces_found.set(0); *ctl.activity.borrow_mut() = Some(activity.begin(crate::activity::Kind::Index, "Indexing faces")); *ctl.sweep.borrow_mut() = Some(crate::faces::spawn_face_sweep( catalog_path, store_dir, detector, embedder, MODEL_ID.to_string(), dr_face::DetectOptions::default(), )); w.set_identity_indexing(true); w.set_identity_indexing_status("looking for images to index…".into()); // Polled rather than pushed: the worker is a plain thread with an // mpsc channel, and a timer on the UI thread keeps every Slint // property write where Slint requires it. 250 ms is far shorter // than one image takes, so the timer never becomes the bottleneck // and never spins on an empty channel for long. let timer = slint::Timer::default(); let weak_tick = w.as_weak(); let ctl_tick = ctl.clone(); let catalog_tick = catalog.clone(); let store_tick = store.clone(); timer.start( slint::TimerMode::Repeated, Duration::from_millis(250), move || { let Some(w) = weak_tick.upgrade() else { return }; let mut done = false; { let borrow = ctl_tick.sweep.borrow(); let Some(rx) = borrow.as_ref() else { return }; // Drained rather than one per tick: several images can // land between ticks, and showing the oldest would make // the count visibly lag the work. while let Ok(msg) = rx.try_recv() { match msg { FaceSweepMessage::Total(n) => ctl_tick.progress.set((0, n)), FaceSweepMessage::Indexed { faces, .. } => { let (seen, total) = ctl_tick.progress.get(); ctl_tick.progress.set((seen + 1, total)); ctl_tick.faces_found.set(ctl_tick.faces_found.get() + faces); } FaceSweepMessage::Finished { .. } => done = true, } } } let (seen, total) = ctl_tick.progress.get(); w.set_identity_indexing_status( format!( "indexing {seen}/{total} — {} face(s) found", ctl_tick.faces_found.get() ) .into(), ); if let Some(a) = ctl_tick.activity.borrow().as_ref() { a.progress(seen, total); } if done { *ctl_tick.sweep.borrow_mut() = None; if let Some(a) = ctl_tick.activity.borrow_mut().take() { a.finish(format!( "{} face(s) in {seen} image(s)", ctl_tick.faces_found.get() )); } w.set_identity_indexing(false); // Newly indexed faces belong to nobody until they are // grouped, so a sweep ending with an unchanged people // rail is expected. The coverage line is what shows it // worked, and Regroup is the next step. refresh(&w, &ctl_tick, &catalog_tick, store_tick().as_deref()); } }, ); // A Slint timer stops when it drops, so it has to outlive this // callback. park_timer(timer); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_stop_indexing(move || { let Some(w) = weak.upgrade() else { return }; // Dropping the receiver *is* the cancellation: the worker's next // send fails and it returns, leaving everything already written // written. No flag to fall out of step with the thread. *ctl.sweep.borrow_mut() = None; if let Some(a) = ctl.activity.borrow_mut().take() { a.finish("stopped"); } w.set_identity_indexing(false); reload!(w, ctl, catalog, store); }); } { let weak = window.as_weak(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_check_coverage(move || { let Some(w) = weak.upgrade() else { return }; refresh_coverage(&w, &catalog, store().as_deref()); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); window.on_identity_delete_all(move || { let Some(w) = weak.upgrade() else { return }; if let Some(cat) = catalog.borrow().as_ref() { match identity::delete_all(cat) { Ok(n) => log::info!("identity: deleted {n} face(s) and every person"), Err(e) => log::warn!("identity: delete all: {e}"), } } ctl.selected.set(None); ctl.clear_picks(); ctl.covers.borrow_mut().clear(); reload!(w, ctl, catalog, store); }); } } /// Keep the running sweep's poll timer alive. /// /// A `slint::Timer` stops when it drops, so the one driving a sweep has to /// outlive the callback that started it. Parking it here rather than in the /// controller keeps `IdentityController` free of Slint types, which is what /// lets it be constructed in a test with no event loop. /// /// One slot: starting a second sweep replaces the first's timer, and by then /// the first sweep has already finished or been stopped. fn park_timer(timer: slint::Timer) { thread_local! { static SWEEP_TIMER: RefCell> = const { RefCell::new(None) }; } SWEEP_TIMER.with(|slot| *slot.borrow_mut() = Some(timer)); } #[cfg(test)] mod tests { use super::*; #[test] fn a_pick_toggles_on_and_off() { let ctl = IdentityController::new(); let f = FaceId(7); ctl.picked.borrow_mut().push(f); assert_eq!(ctl.picked.borrow().len(), 1); let pos = ctl.picked.borrow().iter().position(|&x| x == f); assert_eq!(pos, Some(0)); ctl.picked.borrow_mut().remove(0); assert!(ctl.picked.borrow().is_empty()); } /// Changing person must not carry a stale pick set: a later "Split off" /// would otherwise act on faces the user can no longer see. #[test] fn changing_person_clears_the_pick_set() { let ctl = IdentityController::new(); ctl.picked.borrow_mut().push(FaceId(1)); ctl.picked.borrow_mut().push(FaceId(2)); ctl.selected.set(Some(PersonId(1))); ctl.selected.set(Some(PersonId(2))); ctl.clear_picks(); assert!(ctl.picked.borrow().is_empty()); } }