Over-clustering is the normal state of a freshly indexed library — FR-CULL-10 says so — which means one person arrives as several groups and the user names each of them the same thing. Until now that produced several people called Anna and no way to join them: `identity::merge` existed, `faces::merge_people` existed with its redirect tombstone, and `merge-into(int)` sat in identity.slint declared, never emitted and never wired. The screen had a split button and no merge. So a rename that collides now offers one. Type a name another person already carries and a strip appears under the field: "Someone else is already called Anna (14 faces). Merge them?" Offered, not performed. `people.uuid` is the identity and the name is not — the schema comment on that column is explicit that two devices naming the same cluster independently is the case it was built for — so two people sharing a name is legal, and folding them together on a keystroke would be the screen making an identity decision on the user's behalf. That is the thing this screen spends a whole button avoiding. The rename always lands first, and declining leaves it exactly as typed. There is nothing to undo because nothing was done. Details that are not arbitrary: The comparison is trimmed and case-insensitive. "anna" on a phone keyboard and "Anna" on a desktop are one intention, and an offer that appeared only when the capitalisation matched would read as a bug. An empty name collides with nothing. Every unnamed cluster renders as "Unnamed (n faces)"; if that counted as a collision the offer would appear on every cluster in a fresh library, and accepting it would fold the library into one person. The newly-named person folds into the one that already held the name, not the reverse. The older person is the one other devices have seen and the one whose confirmations are more likely to be real. Selection follows the merge, because landing on an empty screen after a successful action reads as a failure. The offer is retired when the person changes, and when a refresh finds its target gone — merged from the other side of a sync, or deleted. An offer left standing would fold whoever happens to be selected now. A merged-away person is not a namesake: `faces::people` already excludes redirects, so the offer does not reappear the instant it is accepted. Four tests over the collision rules, and the existing 467 still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
775 lines
30 KiB
Rust
775 lines
30 KiB
Rust
//! 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<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>>,
|
||
/// 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<Option<Receiver<FaceSweepMessage>>>,
|
||
/// Images visited so far in the current sweep, and the total it announced.
|
||
progress: std::cell::Cell<(usize, usize)>,
|
||
faces_found: std::cell::Cell<usize>,
|
||
/// 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<bool>,
|
||
/// 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<Option<crate::activity::Activity>>,
|
||
/// 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<Option<PersonId>>,
|
||
/// 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<std::collections::HashMap<PersonId, slint::Image>>,
|
||
}
|
||
|
||
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);
|
||
|
||
// 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<PersonId> = view.people.iter().map(|p| p.id).collect();
|
||
ctl.covers.borrow_mut().retain(|id, _| live.contains(id));
|
||
}
|
||
|
||
let rows: Vec<IdentityPerson> = 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::<IdentityFace>::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<RefCell<Option<Catalog>>>,
|
||
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<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)
|
||
}
|
||
|
||
/// 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<S, M, P>(
|
||
window: &AppWindow,
|
||
ctl: Rc<IdentityController>,
|
||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||
activity: Rc<crate::activity::ActivityLog>,
|
||
store: S,
|
||
models: M,
|
||
paths: P,
|
||
) where
|
||
S: Fn() -> Option<Rc<ThumbStore>> + 'static,
|
||
M: Fn() -> Option<ModelPaths> + 'static,
|
||
P: Fn() -> Option<SweepPaths> + 'static,
|
||
{
|
||
let store: Rc<dyn Fn() -> Option<Rc<ThumbStore>>> = Rc::new(store);
|
||
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
|
||
let paths: Rc<dyn Fn() -> Option<SweepPaths>> = 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<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();
|
||
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<Option<slint::Timer>> = 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());
|
||
}
|
||
}
|