A confirm or a reject changes one row and redraws the whole grid, and the redraw re-read every crop blob of the selected person (4 MB for the largest) and decoded every one — 316 ms per click on the reference library's 754-face person, to arrive at the pixels already on screen. `load_faces` now takes the crops the previous load decoded, keyed by face, and moves each into its new cell; the blob read is skipped when every face is already in hand. `refresh` drains the old cells into it rather than cloning them. The redraw is 2.6 ms.
1512 lines
64 KiB
Rust
1512 lines
64 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, Model as _, 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.
|
||
///
|
||
/// Every query in this file is keyed on it, and a library indexed across a
|
||
/// detector change holds faces from both pipelines: the screen shows the one
|
||
/// the settings page currently names (`FaceDetector::model_id`), read at each
|
||
/// use rather than captured once, because the page can change it while the
|
||
/// screen is open.
|
||
pub fn model_id(settings: &crate::settings_ui::SettingsController) -> String {
|
||
crate::inference::model_id(settings.snapshot().faces.detector).to_string()
|
||
}
|
||
|
||
/// 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>,
|
||
/// Images the running sweep could not get through.
|
||
///
|
||
/// On screen because a pass that fails everything used to be
|
||
/// indistinguishable from one that succeeded at everything: both ended
|
||
/// with a tidy "0 face(s) in 0 image(s)".
|
||
sweep_failed: 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 name being typed, and who it is being typed for.
|
||
///
|
||
/// `None` means the field has not been touched, which is **not** the same
|
||
/// as an empty draft: a user who cleared the field means to clear the name,
|
||
/// and a user who never typed means to leave it alone. Collapsing those two
|
||
/// would erase a name by navigating past it.
|
||
///
|
||
/// Carries the person because a reload can move the selection underneath a
|
||
/// half-typed name — a merge from the other side of a sync, a deletion —
|
||
/// and applying it to whoever is selected *now* would rename a stranger.
|
||
draft: RefCell<Option<(PersonId, String)>>,
|
||
/// 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>>,
|
||
/// The running regrouping pass, if any.
|
||
///
|
||
/// Same shape as `sweep`, and for the same reason: holding the receiver is
|
||
/// the handle, and clustering a real library is not something a Slint
|
||
/// callback may do on the UI thread.
|
||
regroup: RefCell<Option<Receiver<crate::faces::ReclusterMessage>>>,
|
||
/// The running read-only preview of the grouping dials, if any.
|
||
///
|
||
/// Separate from `regroup` because the two are allowed to be about
|
||
/// different things at once and neither blocks the other: a preview writes
|
||
/// nothing, so there is nothing for a concurrent pass to corrupt.
|
||
preview: RefCell<Option<Receiver<crate::faces::PreviewMessage>>>,
|
||
/// Whether the rail is showing the people the user has set aside.
|
||
show_ignored: std::cell::Cell<bool>,
|
||
/// 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();
|
||
}
|
||
|
||
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||
/// Drop the decoded rail portraits.
|
||
///
|
||
/// The one in-memory image cache in this crate that is unbounded by
|
||
/// anything but the library: one decoded portrait per person, kept for as
|
||
/// long as the person exists. On a library with a few hundred named people
|
||
/// that is worth tens of megabytes of nothing but a saved decode.
|
||
///
|
||
/// Costless to lose. The next reload hands whatever it does not find to
|
||
/// `fill_covers`, so the only consequence is the JPEG decode this cache
|
||
/// exists to skip — paid off the blocking path, a slice at a time.
|
||
pub fn clear_covers(&self) {
|
||
self.covers.borrow_mut().clear();
|
||
}
|
||
}
|
||
|
||
/// What a reload has to re-read, named by what just changed.
|
||
///
|
||
/// The coverage line is the expensive half of a redraw: it lists every
|
||
/// repair's outstanding images to count them, which is several scans of the
|
||
/// whole `images` table (`crate::repairs::counts`). A confirm, a reject, a
|
||
/// rename or a merge moves faces between people and cannot change how many
|
||
/// images have been indexed, so a redraw for one of those must not pay for
|
||
/// it — that was 200 ms of the half-second every click on the face grid used
|
||
/// to cost on the reference library.
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
pub enum Changed {
|
||
/// Who the faces belong to. The rail and the grid are re-read; the
|
||
/// coverage line is left as it was.
|
||
Identities,
|
||
/// Which faces exist: a sweep finished or was stopped, the face data was
|
||
/// deleted, the screen was opened onto a catalog another device may have
|
||
/// indexed. Everything is re-read, the coverage line included.
|
||
Library,
|
||
}
|
||
|
||
/// Push the people rail and the face grid into the window.
|
||
///
|
||
/// **Draws with the portraits it already has and cuts the rest afterwards.**
|
||
/// Cutting one costs a JPEG decode, and where the face predates the stored-crop
|
||
/// column it costs a whole 1024px proxy decode — 3.2 seconds of them on the
|
||
/// reference library, every one of which used to be spent between the click on
|
||
/// "Identity" and the screen appearing. `fill_covers` does that work a slice at
|
||
/// a time, in rail order, so the rail is on screen immediately and fills in
|
||
/// from the top.
|
||
pub fn refresh(
|
||
window: &AppWindow,
|
||
ctl: &Rc<IdentityController>,
|
||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||
store: Option<Rc<ThumbStore>>,
|
||
model_id: &str,
|
||
eyes: bool,
|
||
changed: Changed,
|
||
) {
|
||
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));
|
||
}
|
||
|
||
// Filtered here rather than by the row hiding itself, which is what the
|
||
// rail used to do. A row that collapses to 0px is a height that reads the
|
||
// model, and the `ListView` drawing the rail cannot virtualise past one —
|
||
// see the rule in `widgets.slint`. The toggle reloads either way, so this
|
||
// costs a pass over a list that was already in hand.
|
||
let show_ignored = ctl.show_ignored.get();
|
||
// Whoever the rail is missing a portrait for, as (row, person) in rail
|
||
// order — which is the order `fill_covers` works in, so the rows the user
|
||
// is looking at are cut first.
|
||
let mut pending: Vec<(usize, PersonId)> = Vec::new();
|
||
let rows: Vec<IdentityPerson> = view
|
||
.people
|
||
.iter()
|
||
.filter(|p| show_ignored || !p.ignored)
|
||
.enumerate()
|
||
.map(|(row, p)| {
|
||
// The cache only. Cutting a portrait here is what used to hold the
|
||
// screen shut; anything not already in hand is left to `fill_covers`.
|
||
let cover = ctl.covers.borrow().get(&p.id).cloned();
|
||
if cover.is_none() && store.is_some() {
|
||
pending.push((row, p.id));
|
||
}
|
||
|
||
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(),
|
||
ignored: p.ignored,
|
||
}
|
||
})
|
||
.collect();
|
||
window.set_identity_people(ModelRc::new(VecModel::from(rows)));
|
||
window.set_identity_ignored_count(view.people.iter().filter(|p| p.ignored).count() as i32);
|
||
window.set_identity_show_ignored(ctl.show_ignored.get());
|
||
*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.as_deref()) {
|
||
(Some(person), Some(store)) => {
|
||
// The crops the grid is showing now, handed over to be reused
|
||
// rather than decoded again — see `load_faces`. Drained, not
|
||
// cloned: a crop is 64 KB of pixels and the largest groups hold
|
||
// hundreds.
|
||
let cut = ctl
|
||
.faces
|
||
.borrow_mut()
|
||
.drain(..)
|
||
.filter_map(|c| Some((c.face, c.crop?)))
|
||
.collect();
|
||
let cells = identity::load_faces(cat, store, person, cut).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_ignored(
|
||
ctl.people
|
||
.borrow()
|
||
.iter()
|
||
.find(|p| p.id == person)
|
||
.is_some_and(|p| p.ignored),
|
||
);
|
||
}
|
||
_ => {
|
||
window.set_identity_selected(-1);
|
||
window.set_identity_selected_name(Default::default());
|
||
window.set_identity_selected_ignored(false);
|
||
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);
|
||
if changed == Changed::Library {
|
||
refresh_coverage(window, catalog, store.as_deref(), model_id, eyes);
|
||
}
|
||
|
||
// Last, so a portrait cannot delay anything above it.
|
||
if let Some(store) = store {
|
||
fill_covers(window, ctl, catalog, store, pending);
|
||
}
|
||
}
|
||
|
||
/// How long one slice of portrait-cutting may hold the UI thread.
|
||
///
|
||
/// Under half a 60Hz frame. The unit of work is one portrait and it is not
|
||
/// divisible, so a slice overruns by whatever the last one cost — a stored crop
|
||
/// is well under a millisecond, and the proxy fallback is about three and a
|
||
/// half, which is a frame that renders late rather than a frame that is missed.
|
||
const COVER_SLICE: Duration = Duration::from_millis(8);
|
||
|
||
/// Cut the rail portraits that were not already cached, a slice per tick.
|
||
///
|
||
/// # Why a timer and not a thread
|
||
///
|
||
/// The work is a `Catalog` query and a decode against a `ThumbStore`, and both
|
||
/// handles live on this thread — a worker would need its own connection to the
|
||
/// same SQLite file, which is what the sweep and the regrouping pass do because
|
||
/// they run for minutes and would otherwise be unbounded. This is seconds of
|
||
/// small, independent pieces, so slicing it is the cheaper answer to the same
|
||
/// question: nothing here ever holds the thread for longer than a frame.
|
||
///
|
||
/// # Ordering
|
||
///
|
||
/// `pending` arrives in rail order, and the rail is sorted by confirmed faces,
|
||
/// so the portraits the user is looking at are cut first and the tail fills in
|
||
/// behind them. With the rail virtualised, the rows past the fold are not even
|
||
/// drawn until they are scrolled to.
|
||
///
|
||
/// # Why it patches rather than reloads
|
||
///
|
||
/// A portrait changes one cell. Calling `refresh` for it would re-read the
|
||
/// catalog and rebuild the model on every tick, and the model being replaced
|
||
/// underneath the `ListView` is exactly what the slicing exists to avoid.
|
||
fn fill_covers(
|
||
window: &AppWindow,
|
||
ctl: &Rc<IdentityController>,
|
||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||
store: Rc<ThumbStore>,
|
||
pending: Vec<(usize, PersonId)>,
|
||
) {
|
||
if pending.is_empty() {
|
||
// Parking `None` stops whatever the last refresh left running: its
|
||
// pending list is about a model that no longer exists.
|
||
park_cover_timer(None);
|
||
return;
|
||
}
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
// Where the last tick got to. The list is only read forwards.
|
||
let mut next = 0usize;
|
||
|
||
timer.start(slint::TimerMode::Repeated, COVER_SLICE, move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let borrow = catalog.borrow();
|
||
let Some(cat) = borrow.as_ref() else {
|
||
park_cover_timer(None);
|
||
return;
|
||
};
|
||
let rows = w.get_identity_people();
|
||
|
||
let started = std::time::Instant::now();
|
||
while next < pending.len() && started.elapsed() < COVER_SLICE {
|
||
let (row, person) = pending[next];
|
||
next += 1;
|
||
|
||
// The row index came from the model this fill was started for, and
|
||
// a reload between ticks replaces that model. Checked rather than
|
||
// trusted, because the failure it prevents is silent and wrong: a
|
||
// face drawn beside somebody else's name. A mismatch — or a model
|
||
// too short to hold the row — means this fill is about a rail that
|
||
// no longer exists, and the reload that replaced it started its own.
|
||
let Some(mut cell) = rows.row_data(row).filter(|c| c.id == person.0 as i32) else {
|
||
park_cover_timer(None);
|
||
return;
|
||
};
|
||
|
||
let Ok(Some(crop)) = identity::load_cover(cat, &store, person) else {
|
||
continue;
|
||
};
|
||
let img = to_slint_image(crop.width, crop.height, &crop.rgba);
|
||
ctl.covers.borrow_mut().insert(person, img.clone());
|
||
|
||
cell.has_cover = true;
|
||
cell.cover = img;
|
||
rows.set_row_data(row, cell);
|
||
}
|
||
|
||
if next >= pending.len() {
|
||
park_cover_timer(None);
|
||
}
|
||
});
|
||
park_cover_timer(Some(timer));
|
||
}
|
||
|
||
/// 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.
|
||
/// `eyes` is whether this device has the eye models: with them, faces with
|
||
/// no eye reading are work the job has left (`crate::faces::audit`), and
|
||
/// the registry the count is taken from is the one the job would run.
|
||
pub fn refresh_coverage(
|
||
window: &AppWindow,
|
||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||
store: Option<&ThumbStore>,
|
||
model_id: &str,
|
||
eyes: bool,
|
||
) {
|
||
let borrow = catalog.borrow();
|
||
let (Some(cat), Some(store)) = (borrow.as_ref(), store) else {
|
||
window.set_identity_coverage(Default::default());
|
||
return;
|
||
};
|
||
let detector = dr_types::FaceDetector::for_model_id(model_id).unwrap_or_default();
|
||
let repairs = crate::repairs::registry(
|
||
crate::repairs::Scope::Outstanding,
|
||
model_id,
|
||
detector,
|
||
crate::repairs::Capabilities {
|
||
gpu: true,
|
||
face_models: true,
|
||
eye_models: eyes,
|
||
},
|
||
);
|
||
match crate::faces::audit(cat, store, model_id, &repairs) {
|
||
Ok(a) => {
|
||
window.set_identity_coverage(a.summary().into());
|
||
// Complete means nothing left to index or measure, not "every
|
||
// image has a face": most of a library has none, and that is a
|
||
// finding.
|
||
window.set_identity_coverage_complete(a.is_complete());
|
||
// Detection done, readings outstanding: the button names the
|
||
// pass it will run rather than promising to index.
|
||
window.set_identity_coverage_read_only(a.coverage.is_complete() && a.has_repairs());
|
||
}
|
||
Err(e) => {
|
||
log::warn!("identity: coverage check: {e}");
|
||
window.set_identity_coverage("coverage unknown".into());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Write a name that was typed but never submitted.
|
||
///
|
||
/// The screen's primary action is naming a cluster, and its primary *gesture*
|
||
/// is naming one and moving straight to the next — which is not the gesture
|
||
/// `accepted` reports, because that one requires Enter. Without this, the
|
||
/// common case silently discards the work.
|
||
///
|
||
/// Nothing is offered here. A namesake merge is a question, and asking it about
|
||
/// the person the user has just navigated away from would be answering for a
|
||
/// screen they are no longer looking at; the offer stays on the explicit
|
||
/// submit.
|
||
fn commit_draft(ctl: &IdentityController, catalog: &Rc<RefCell<Option<Catalog>>>) {
|
||
let Some((person, draft)) = ctl.draft.borrow_mut().take() else {
|
||
return;
|
||
};
|
||
// Unchanged text is not an edit. Comparing against what the catalog holds
|
||
// rather than tracking dirtiness keeps this correct when a reload has
|
||
// already written the same name.
|
||
let stored = ctl
|
||
.people
|
||
.borrow()
|
||
.iter()
|
||
.find(|p| p.id == person)
|
||
.map(|p| p.name.clone());
|
||
let Some(stored) = stored else {
|
||
// The person went away while the name was being typed. Writing it
|
||
// would resurrect a row the rail no longer shows.
|
||
return;
|
||
};
|
||
if draft.trim() == stored {
|
||
return;
|
||
}
|
||
let borrow = catalog.borrow();
|
||
let Some(cat) = borrow.as_ref() else { return };
|
||
if let Err(e) = identity::rename(cat, person, &draft) {
|
||
log::warn!("identity: committing a typed name: {e}");
|
||
} else {
|
||
log::info!(
|
||
"identity: {person:?} named {:?} on leaving it",
|
||
draft.trim()
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Put the newly-selected person's name back in the field.
|
||
///
|
||
/// A counter the screen watches, because `Field.text` is two-way bound to its
|
||
/// entry: the first keystroke replaces the binding to `selected-name`, and from
|
||
/// then on the field follows nothing at all.
|
||
fn reset_name_field(window: &AppWindow) {
|
||
window.set_identity_name_revision(window.get_identity_name_revision().wrapping_add(1));
|
||
}
|
||
|
||
/// 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,
|
||
quality: c.quality_label().into(),
|
||
in_gallery: c.in_gallery(),
|
||
eyes: c.eyes_label().into(),
|
||
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.
|
||
/// What the whole-library face pass needs to reach the server.
|
||
///
|
||
/// A connection and not just paths, because the pass fetches its own pixels: an
|
||
/// image with no proxy is the ordinary case, not one to skip (see
|
||
/// `repairs::spawn`).
|
||
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
|
||
|
||
/// The detector and embedder files, when both are present — and the eye
|
||
/// models beside them, when those are.
|
||
pub type ModelPaths = crate::library::FaceModelPaths;
|
||
|
||
/// Put the grouping dials on the screen from the settings record.
|
||
///
|
||
/// Read back out of the controller rather than echoed from the callback's
|
||
/// argument, because `Settings::sanitise` may have moved the number: a slider
|
||
/// showing 40% while the file held the clamped 50% would be a control that
|
||
/// silently disagreed with what the next Regroup was going to do.
|
||
fn push_grouping(window: &AppWindow, settings: &crate::settings_ui::SettingsController) {
|
||
let s = settings.snapshot();
|
||
window.set_identity_merge_probability(s.faces.merge_probability * 100.0);
|
||
window.set_identity_min_group_size(s.faces.min_group_size as i32);
|
||
}
|
||
|
||
/// Attach every Identity callback.
|
||
///
|
||
/// Eight arguments because the screen has eight distinct dependencies and no
|
||
/// two of them belong together: three ways of reaching the library, two
|
||
/// controllers, the window, the activity log and the settings record. Bundling
|
||
/// them into a parameter struct would name a thing that does not exist — the
|
||
/// same reason every other `wire` in this file's neighbourhood carries the
|
||
/// allow.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn wire<S, M, P>(
|
||
window: &AppWindow,
|
||
ctl: Rc<IdentityController>,
|
||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||
activity: Rc<crate::activity::ActivityLog>,
|
||
settings: Rc<crate::settings_ui::SettingsController>,
|
||
store: S,
|
||
models: M,
|
||
paths: P,
|
||
// TRACES: FR-CULL-8
|
||
// `None` on a build with no adapter. Indexing needs a native render and a
|
||
// native render needs the GPU, so the button reports that the same way it
|
||
// reports a missing model rather than starting a pass that cannot produce
|
||
// anything.
|
||
gpu: Option<dr_gpu::GpuContext>,
|
||
) where
|
||
S: Fn() -> Option<Rc<ThumbStore>> + 'static,
|
||
M: Fn() -> Option<ModelPaths> + 'static,
|
||
P: Fn() -> Option<SweepPaths> + 'static,
|
||
{
|
||
let gpu = Rc::new(gpu);
|
||
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);
|
||
|
||
// The dials start where the settings file left them, once, rather than on
|
||
// every open: the screen writes them back through the two callbacks below,
|
||
// and re-pushing them mid-drag would fight the slider's own live value.
|
||
push_grouping(window, &settings);
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let settings = settings.clone();
|
||
window.on_identity_merge_probability_changed(move |percent| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
settings.edit(|s| s.faces.merge_probability = percent / 100.0);
|
||
push_grouping(&w, &settings);
|
||
// The answer on screen was about the old value. Left there it would
|
||
// be read as a description of the new one, which is worse than
|
||
// having no preview at all.
|
||
w.set_identity_grouping_preview(Default::default());
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let settings = settings.clone();
|
||
window.on_identity_min_group_size_changed(move |n| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
settings.edit(|s| s.faces.min_group_size = n.max(0) as u32);
|
||
push_grouping(&w, &settings);
|
||
w.set_identity_grouping_preview(Default::default());
|
||
});
|
||
}
|
||
|
||
// 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.
|
||
// Whether the eye models are on this machine, asked each time rather
|
||
// than once: it is two `is_file` checks, and a user who drops the files
|
||
// in while the app is open should see the faces become work to measure.
|
||
let eyes_available: Rc<dyn Fn() -> bool> = {
|
||
let models = models.clone();
|
||
Rc::new(move || models().is_some_and(|m| m.eyes.is_some()))
|
||
};
|
||
// The availability closure is an argument rather than named in the
|
||
// body: macro hygiene would bind the name to this scope's `Rc`, which the
|
||
// first `move` closure would then take with it.
|
||
// The last argument is a `Changed` variant, named bare so the call
|
||
// stays on one line.
|
||
macro_rules! reload {
|
||
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr, $changed:ident) => {
|
||
refresh(
|
||
&$w,
|
||
&$ctl,
|
||
&$catalog,
|
||
$store(),
|
||
&model_id(&$settings),
|
||
$eyes(),
|
||
Changed::$changed,
|
||
)
|
||
};
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Library);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
window.on_identity_close(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// The other way out of a half-typed name: leaving the screen
|
||
// entirely. Same rule as changing person — the work was done, so it
|
||
// is written.
|
||
commit_draft(&ctl, &catalog);
|
||
// 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 eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.clone();
|
||
window.on_identity_person_picked(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// Before the selection moves: a name typed into the field and never
|
||
// submitted belongs to the person being left, and this is the last
|
||
// moment it can be written.
|
||
commit_draft(&ctl, &catalog);
|
||
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, settings, eyes_available, Identities);
|
||
reset_name_field(&w);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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);
|
||
// Submitted explicitly, so the pending draft is spent — leaving it
|
||
// would rewrite this same name on the way out of the person.
|
||
ctl.draft.borrow_mut().take();
|
||
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, settings, eyes_available, Identities);
|
||
// `rename` trims; the field should show what was actually stored
|
||
// rather than the spacing the user happened to type.
|
||
reset_name_field(&w);
|
||
});
|
||
}
|
||
|
||
// 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 eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Identities);
|
||
reset_name_field(&w);
|
||
});
|
||
}
|
||
|
||
// Every keystroke in the name field. Kept rather than written, because a
|
||
// rename per character would be a revision per character in the sidecar.
|
||
{
|
||
let ctl = ctl.clone();
|
||
window.on_identity_name_edited(move |text| {
|
||
let Some(person) = ctl.selected.get() else {
|
||
return;
|
||
};
|
||
*ctl.draft.borrow_mut() = Some((person, text.to_string()));
|
||
});
|
||
}
|
||
|
||
// 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 eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Identities);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Identities);
|
||
});
|
||
}
|
||
|
||
{
|
||
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 eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Identities);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Identities);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let paths = paths.clone();
|
||
let settings = settings.clone();
|
||
window.on_identity_preview_grouping(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// One at a time, like every other pass here. Two previews would
|
||
// race to write the same line and the loser's answer would win.
|
||
if ctl.preview.borrow().is_some() {
|
||
return;
|
||
}
|
||
let Some((_, catalog_path, _)) = paths() else {
|
||
return;
|
||
};
|
||
|
||
w.set_identity_previewing(true);
|
||
*ctl.preview.borrow_mut() = Some(crate::faces::spawn_grouping_preview(
|
||
catalog_path,
|
||
model_id(&settings),
|
||
settings.snapshot().faces,
|
||
));
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak_tick = w.as_weak();
|
||
let ctl_tick = ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
Duration::from_millis(100),
|
||
move || {
|
||
let Some(w) = weak_tick.upgrade() else { return };
|
||
let mut done = false;
|
||
{
|
||
let borrow = ctl_tick.preview.borrow();
|
||
let Some(rx) = borrow.as_ref() else { return };
|
||
while let Ok(msg) = rx.try_recv() {
|
||
match msg {
|
||
crate::faces::PreviewMessage::Ready(p) => {
|
||
w.set_identity_grouping_preview(
|
||
identity::preview_label(&p).into(),
|
||
);
|
||
done = true;
|
||
}
|
||
crate::faces::PreviewMessage::Failed(e) => {
|
||
log::warn!("identity: preview: {e}");
|
||
w.set_identity_grouping_preview(
|
||
format!("could not work it out: {e}").into(),
|
||
);
|
||
done = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if done {
|
||
*ctl_tick.preview.borrow_mut() = None;
|
||
w.set_identity_previewing(false);
|
||
}
|
||
},
|
||
);
|
||
park_preview_timer(timer);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let paths = paths.clone();
|
||
let settings_for_regroup = settings.clone();
|
||
let eyes_available = eyes_available.clone();
|
||
window.on_identity_recluster(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// One at a time. Two passes over the same faces would each create
|
||
// their own groups for the same clusters, and the second would
|
||
// undo the first's pruning.
|
||
if ctl.regroup.borrow().is_some() {
|
||
return;
|
||
}
|
||
let Some((_, catalog_path, _)) = paths() else {
|
||
return;
|
||
};
|
||
|
||
w.set_identity_regrouping(true);
|
||
w.set_identity_regroup_status("regrouping…".into());
|
||
*ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster(
|
||
catalog_path,
|
||
model_id(&settings_for_regroup),
|
||
settings_for_regroup.snapshot().faces,
|
||
));
|
||
|
||
// Polled from the UI thread, like the indexing sweep: the worker
|
||
// is a plain thread with a channel, and every Slint property write
|
||
// has to happen where Slint requires it.
|
||
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();
|
||
let settings_tick = settings_for_regroup.clone();
|
||
let eyes_tick = eyes_available.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
Duration::from_millis(100),
|
||
move || {
|
||
let Some(w) = weak_tick.upgrade() else { return };
|
||
let mut done = false;
|
||
{
|
||
let borrow = ctl_tick.regroup.borrow();
|
||
let Some(rx) = borrow.as_ref() else { return };
|
||
while let Ok(msg) = rx.try_recv() {
|
||
match msg {
|
||
crate::faces::ReclusterMessage::Started { faces } => {
|
||
w.set_identity_regroup_status(
|
||
format!("regrouping {faces} face(s)…").into(),
|
||
);
|
||
}
|
||
crate::faces::ReclusterMessage::Finished {
|
||
suggested,
|
||
created,
|
||
} => {
|
||
log::info!(
|
||
"identity: {suggested} suggestion(s), {created} new group(s)"
|
||
);
|
||
w.set_identity_regroup_status(
|
||
format!(
|
||
"{created} new group(s), {suggested} suggestion(s)"
|
||
)
|
||
.into(),
|
||
);
|
||
done = true;
|
||
}
|
||
crate::faces::ReclusterMessage::Failed(e) => {
|
||
log::warn!("identity: recluster: {e}");
|
||
w.set_identity_regroup_status(
|
||
format!("regrouping failed: {e}").into(),
|
||
);
|
||
done = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if done {
|
||
*ctl_tick.regroup.borrow_mut() = None;
|
||
w.set_identity_regrouping(false);
|
||
// Portraits are keyed on the person, and reclustering
|
||
// makes new people; a stale cache would draw the
|
||
// previous pass's faces beside the new groups.
|
||
ctl_tick.covers.borrow_mut().clear();
|
||
refresh(
|
||
&w,
|
||
&ctl_tick,
|
||
&catalog_tick,
|
||
store_tick(),
|
||
&model_id(&settings_tick),
|
||
eyes_tick(),
|
||
Changed::Identities,
|
||
);
|
||
}
|
||
},
|
||
);
|
||
park_timer(timer);
|
||
});
|
||
}
|
||
|
||
// One launcher behind two buttons. "Index faces" and "Re-index every
|
||
// face" differ only in which images the pass visits (`FaceSweepScope`);
|
||
// the models, the progress, the activity row and the Stop button are the
|
||
// same, and a second copy of this closure would be a second place for
|
||
// them to disagree.
|
||
let launch: Rc<dyn Fn(crate::repairs::Scope)> = {
|
||
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();
|
||
let gpu = gpu.clone();
|
||
let settings_for_sweep = settings.clone();
|
||
let eyes_available = eyes_available.clone();
|
||
let activity = activity.clone();
|
||
Rc::new(move |scope: crate::repairs::Scope| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if ctl.sweep.borrow().is_some() {
|
||
return;
|
||
}
|
||
let Some(models) = models() else {
|
||
w.set_identity_model_missing(true);
|
||
return;
|
||
};
|
||
let Some((conn, catalog_path, store_dir)) = paths() else {
|
||
return;
|
||
};
|
||
// Same reporting path as a missing model: both mean the pass
|
||
// cannot run, and both are states a fresh install can be in.
|
||
let Some(gpu) = gpu.as_ref().clone() else {
|
||
w.set_identity_model_missing(true);
|
||
return;
|
||
};
|
||
|
||
let reindex = scope == crate::repairs::Scope::Reindex;
|
||
ctl.progress.set((0, 0));
|
||
ctl.faces_found.set(0);
|
||
ctl.sweep_failed.set(0);
|
||
*ctl.activity.borrow_mut() = Some(activity.begin(
|
||
crate::activity::Kind::Index,
|
||
if reindex {
|
||
"Re-indexing faces"
|
||
} else {
|
||
"Indexing faces"
|
||
},
|
||
));
|
||
*ctl.sweep.borrow_mut() = Some(crate::repairs::spawn(
|
||
conn,
|
||
catalog_path,
|
||
store_dir,
|
||
Some(models),
|
||
model_id(&settings_for_sweep),
|
||
settings_for_sweep.snapshot().faces.detector,
|
||
scope,
|
||
dr_face::DetectOptions::default(),
|
||
Some(gpu),
|
||
));
|
||
w.set_identity_indexing(true);
|
||
w.set_identity_indexing_status(
|
||
if reindex {
|
||
"looking for images to detect again…"
|
||
} else {
|
||
"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();
|
||
let settings_tick = settings_for_sweep.clone();
|
||
let eyes_tick = eyes_available.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::Failed { images } => {
|
||
// Advances the same counter. Work that
|
||
// failed is still work the pass got
|
||
// through, and a bar that only moves on
|
||
// success reports a run of pure failure as
|
||
// no progress at all.
|
||
let (seen, total) = ctl_tick.progress.get();
|
||
ctl_tick.progress.set((seen + images, total));
|
||
ctl_tick
|
||
.sweep_failed
|
||
.set(ctl_tick.sweep_failed.get() + images);
|
||
}
|
||
FaceSweepMessage::Finished { failed, .. } => {
|
||
// The sweep's own tallies win over the
|
||
// running ones: a cancelled or offline
|
||
// pass can end without every batch having
|
||
// been reported.
|
||
ctl_tick.sweep_failed.set(failed);
|
||
done = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let (seen, total) = ctl_tick.progress.get();
|
||
let mut status = format!(
|
||
"indexing {seen}/{total} — {} face(s) found",
|
||
ctl_tick.faces_found.get()
|
||
);
|
||
if ctl_tick.sweep_failed.get() > 0 {
|
||
status.push_str(&format!(", {} failed", ctl_tick.sweep_failed.get()));
|
||
}
|
||
w.set_identity_indexing_status(status.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() {
|
||
// Say what did not work. A pass that failed every
|
||
// image used to finish with the same sentence as
|
||
// one that succeeded at every image, which is how
|
||
// 169 consecutive failures went unnoticed for a
|
||
// day.
|
||
let mut msg = format!(
|
||
"{} face(s) in {seen} image(s)",
|
||
ctl_tick.faces_found.get()
|
||
);
|
||
if ctl_tick.sweep_failed.get() > 0 {
|
||
msg.push_str(&format!(
|
||
", {} could not be read",
|
||
ctl_tick.sweep_failed.get()
|
||
));
|
||
}
|
||
a.finish(msg);
|
||
}
|
||
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(),
|
||
&model_id(&settings_tick),
|
||
eyes_tick(),
|
||
Changed::Library,
|
||
);
|
||
}
|
||
},
|
||
);
|
||
// A Slint timer stops when it drops, so it has to outlive this
|
||
// callback.
|
||
park_timer(timer);
|
||
})
|
||
};
|
||
|
||
{
|
||
let launch = launch.clone();
|
||
window.on_identity_index(move || launch(crate::repairs::Scope::Outstanding));
|
||
}
|
||
|
||
{
|
||
// TRACES: FR-CULL-8 | FR-CULL-10
|
||
let launch = launch.clone();
|
||
window.on_identity_reindex(move || launch(crate::repairs::Scope::Reindex));
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Library);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.clone();
|
||
let eyes_available = eyes_available.clone();
|
||
window.on_identity_check_coverage(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
refresh_coverage(
|
||
&w,
|
||
&catalog,
|
||
store().as_deref(),
|
||
&model_id(&settings),
|
||
eyes_available(),
|
||
);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.clone();
|
||
window.on_identity_ignore_person(move |id, on| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let person = PersonId(id.max(0) as u64);
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
if let Err(e) = dr_catalog::faces::set_ignored(cat.connection(), person, on) {
|
||
log::warn!("identity: setting a person aside: {e}");
|
||
return;
|
||
}
|
||
}
|
||
// Move off the person just set aside, or the screen sits on a
|
||
// group the rail no longer shows — which reads as the button
|
||
// having done nothing.
|
||
if on && ctl.selected.get() == Some(person) {
|
||
ctl.selected.set(None);
|
||
ctl.clear_picks();
|
||
}
|
||
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.clone();
|
||
window.on_identity_toggle_show_ignored(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
ctl.show_ignored.set(!ctl.show_ignored.get());
|
||
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let eyes_available = eyes_available.clone();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let settings = settings.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, settings, eyes_available, Library);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 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));
|
||
}
|
||
|
||
/// Keep the grouping preview's poll timer alive.
|
||
///
|
||
/// **A slot of its own, and that is the whole point.** A preview and a
|
||
/// regrouping pass are allowed to be in flight together — the preview writes
|
||
/// nothing — so parking both in [`park_timer`]'s single slot would have the
|
||
/// second to start drop the first's timer. The visible symptom would be a
|
||
/// Regroup that finished on its worker and never told the screen: the button
|
||
/// stuck on "Regrouping…" for the life of the window.
|
||
fn park_preview_timer(timer: slint::Timer) {
|
||
thread_local! {
|
||
static PREVIEW_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
|
||
}
|
||
PREVIEW_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
|
||
}
|
||
|
||
/// Keep the portrait fill's slice timer alive — and stop it.
|
||
///
|
||
/// A third slot, because a fill runs at the same time as anything else the
|
||
/// screen is doing: it starts on every reload, and a reload is how a sweep and
|
||
/// a regrouping pass report progress.
|
||
///
|
||
/// Takes an `Option` where the others do not, because this is the one that ends
|
||
/// on its own. `None` drops the parked timer, which stops it — including from
|
||
/// inside its own callback, which is where the last slice does it. Slint
|
||
/// supports that directly: a timer removed while its callback is running is
|
||
/// marked and dropped afterwards.
|
||
fn park_cover_timer(timer: Option<slint::Timer>) {
|
||
thread_local! {
|
||
static COVER_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
|
||
}
|
||
COVER_TIMER.with(|slot| *slot.borrow_mut() = 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());
|
||
}
|
||
}
|