Add the Identity screen
A third top-level screen beside the library and develop, because naming a cluster and pulling a stranger out of it are tasks with their own rhythm and need the whole window. The screen is designed around the clustering being wrong, which is FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on parents and children, and on the same person a decade apart. So Split off sits next to Confirm all rather than behind a menu, the confirm/reject pair is on the face itself, and a group the system found is drawn differently from a person the user has vouched for. Splitting rejects before it confirms. Without that the next clustering pass suggests the face straight back and the user's correction becomes an argument they keep having. Face crops come from the proxies the grid already built, one decode per image rather than per face -- a group photograph holding six faces of one family is one JPEG. Where the calibration is not fitted the screen says confidence is unavailable instead of printing a percentage that looks measured, which is FR-CULL-9's rule at the point it becomes visible. The verdict controls use drawn icons, not tick and cross characters: ui/icons.slint exists because those render as tofu on Android. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
//! 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 dr_catalog::faces::{FaceId, PersonId};
|
||||
use dr_catalog::Catalog;
|
||||
use dr_thumbs::ThumbStore;
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
|
||||
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<Vec<PersonRow>>,
|
||||
faces: RefCell<Vec<FaceCell>>,
|
||||
selected: std::cell::Cell<Option<PersonId>>,
|
||||
/// Faces the user has ticked, which is what a split would carry.
|
||||
picked: RefCell<Vec<FaceId>>,
|
||||
}
|
||||
|
||||
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<RefCell<Option<Catalog>>>,
|
||||
store: Option<&ThumbStore>,
|
||||
) {
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else {
|
||||
window.set_identity_people(ModelRc::new(VecModel::from(Vec::<IdentityPerson>::new())));
|
||||
window.set_identity_faces(ModelRc::new(VecModel::from(Vec::<IdentityFace>::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);
|
||||
|
||||
let rows: Vec<IdentityPerson> = view
|
||||
.people
|
||||
.iter()
|
||||
.map(|p| 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,
|
||||
cover: slint::Image::default(),
|
||||
has_cover: false,
|
||||
})
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
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::<IdentityFace>::new())));
|
||||
ctl.faces.borrow_mut().clear();
|
||||
}
|
||||
}
|
||||
|
||||
window.set_identity_picked(ctl.picked.borrow().len() as i32);
|
||||
}
|
||||
|
||||
fn push_faces(window: &AppWindow, ctl: &IdentityController, cells: &[FaceCell]) {
|
||||
let picked = ctl.picked.borrow();
|
||||
let rows: Vec<IdentityFace> = 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::<slint::Rgba8Pixel>::new(width, height);
|
||||
buf.make_mut_bytes().copy_from_slice(rgba);
|
||||
slint::Image::from_rgba8(buf)
|
||||
}
|
||||
|
||||
/// Attach every Identity callback.
|
||||
pub fn wire<S>(
|
||||
window: &AppWindow,
|
||||
ctl: Rc<IdentityController>,
|
||||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||||
store: S,
|
||||
) where
|
||||
S: Fn() -> Option<Rc<ThumbStore>> + 'static,
|
||||
{
|
||||
let store: Rc<dyn Fn() -> Option<Rc<ThumbStore>>> = Rc::new(store);
|
||||
|
||||
// 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();
|
||||
window.on_identity_open(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_show_identity(true);
|
||||
reload!(w, ctl, catalog, store);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.on_identity_close(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
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();
|
||||
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 };
|
||||
if let Some(cat) = catalog.borrow().as_ref() {
|
||||
if let Err(e) = identity::rename(cat, person, &name) {
|
||||
log::warn!("identity: rename: {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_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<FaceId> = 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();
|
||||
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();
|
||||
reload!(w, ctl, catalog, store);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user