Filter the grid to a person with their eyes open

An "Eyes open" chip beside the people chips, offered only while someone
is chosen and dropped when the last person goes, so no term narrows the
grid with nothing on the bar to say so. It compiles the rule in
dr_face::eyes into the person's face subquery — Anna, eyes open, whoever
else is blinking beside her — and drops a frame only on a closed eye that
could be read: sunglasses, eyes too small or soft to read, and faces never
read all pass, so an old library shows everything under the chip until
the measuring pass has run. A test drives the same readings through the
SQL and through the rule and requires them to agree.

The People screen badges a face "Eyes closed", "Sunglasses" or "Eyes
unclear" so the reason a frame is or is not in the grid can be read off
the face; the sweep loads the three models when they are beside the pair
and reads eyes on the indexing and measuring passes from the native
render; the coverage line counts unread faces as work to measure so an
already-indexed library keeps its Index button. The term travels with the
place.
This commit is contained in:
2026-09-19 14:04:35 +02:00
parent 6aae4c3eb0
commit 83f4253b6a
16 changed files with 984 additions and 183 deletions
+73 -25
View File
@@ -153,6 +153,7 @@ pub fn refresh(
catalog: &Rc<RefCell<Option<Catalog>>>,
store: Option<Rc<ThumbStore>>,
model_id: &str,
eyes: bool,
) {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
@@ -273,7 +274,7 @@ pub fn refresh(
window.set_identity_picked(ctl.picked.borrow().len() as i32);
drop(borrow);
refresh_coverage(window, catalog, store.as_deref(), model_id);
refresh_coverage(window, catalog, store.as_deref(), model_id, eyes);
// Last, so a portrait cannot delay anything above it.
if let Some(store) = store {
@@ -380,23 +381,27 @@ fn fill_covers(
///
/// 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 sweep has left (`crate::faces::audit`).
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;
};
match crate::faces::audit(cat, store, model_id) {
match crate::faces::audit(cat, store, model_id, eyes) {
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());
// 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());
}
Err(e) => {
log::warn!("identity: coverage check: {e}");
@@ -486,6 +491,7 @@ fn push_faces(window: &AppWindow, ctl: &IdentityController, cells: &[FaceCell])
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();
@@ -512,8 +518,9 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
/// `library::spawn_face_sweep`).
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
/// The detector and embedder files, when both are present.
pub type ModelPaths = (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.
///
@@ -596,14 +603,32 @@ pub fn wire<S, M, P>(
// 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.
macro_rules! reload {
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr) => {
refresh(&$w, &$ctl, &$catalog, $store(), &model_id(&$settings))
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr) => {
refresh(
&$w,
&$ctl,
&$catalog,
$store(),
&model_id(&$settings),
$eyes(),
)
};
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
@@ -622,7 +647,7 @@ pub fn wire<S, M, P>(
// 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);
reload!(w, ctl, catalog, store, settings, eyes_available);
});
}
@@ -646,6 +671,7 @@ pub fn wire<S, M, P>(
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
@@ -661,13 +687,14 @@ pub fn wire<S, M, P>(
// 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);
reload!(w, ctl, catalog, store, settings, eyes_available);
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();
@@ -706,7 +733,7 @@ pub fn wire<S, M, P>(
Err(e) => log::warn!("identity: looking for a namesake: {e}"),
}
}
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, settings, eyes_available);
// `rename` trims; the field should show what was actually stored
// rather than the spacing the user happened to type.
reset_name_field(&w);
@@ -720,6 +747,7 @@ pub fn wire<S, M, P>(
// 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();
@@ -743,7 +771,7 @@ pub fn wire<S, M, P>(
}
}
clear_merge_offer(&w, &ctl);
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, settings, eyes_available);
reset_name_field(&w);
});
}
@@ -774,6 +802,7 @@ pub fn wire<S, M, P>(
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
@@ -788,12 +817,13 @@ pub fn wire<S, M, P>(
log::warn!("identity: confirm: {e}");
}
}
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, 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();
@@ -808,7 +838,7 @@ pub fn wire<S, M, P>(
log::warn!("identity: reject: {e}");
}
}
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, settings, eyes_available);
});
}
@@ -836,6 +866,7 @@ pub fn wire<S, M, P>(
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
@@ -851,12 +882,13 @@ pub fn wire<S, M, P>(
Err(e) => log::warn!("identity: confirm all: {e}"),
}
}
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, 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();
@@ -884,7 +916,7 @@ pub fn wire<S, M, P>(
}
}
ctl.clear_picks();
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, settings, eyes_available);
});
}
@@ -958,6 +990,7 @@ pub fn wire<S, M, P>(
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
@@ -987,6 +1020,7 @@ pub fn wire<S, M, P>(
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),
@@ -1041,6 +1075,7 @@ pub fn wire<S, M, P>(
&catalog_tick,
store_tick(),
&model_id(&settings_tick),
eyes_tick(),
);
}
},
@@ -1058,12 +1093,13 @@ pub fn wire<S, M, P>(
let paths = paths.clone();
let gpu = gpu.clone();
let settings_for_sweep = settings.clone();
let eyes_available = eyes_available.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 {
let Some(models) = models() else {
w.set_identity_model_missing(true);
return;
};
@@ -1086,8 +1122,7 @@ pub fn wire<S, M, P>(
conn,
catalog_path,
store_dir,
detector,
embedder,
models,
model_id(&settings_for_sweep),
settings_for_sweep
.snapshot()
@@ -1114,6 +1149,7 @@ pub fn wire<S, M, P>(
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),
@@ -1202,6 +1238,7 @@ pub fn wire<S, M, P>(
&catalog_tick,
store_tick(),
&model_id(&settings_tick),
eyes_tick(),
);
}
},
@@ -1214,6 +1251,7 @@ pub fn wire<S, M, P>(
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
@@ -1228,7 +1266,7 @@ pub fn wire<S, M, P>(
a.finish("stopped");
}
w.set_identity_indexing(false);
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, settings, eyes_available);
});
}
@@ -1237,14 +1275,22 @@ pub fn wire<S, M, P>(
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));
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();
@@ -1265,12 +1311,13 @@ pub fn wire<S, M, P>(
ctl.selected.set(None);
ctl.clear_picks();
}
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, 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();
@@ -1278,12 +1325,13 @@ pub fn wire<S, M, P>(
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);
reload!(w, ctl, catalog, store, 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();
@@ -1299,7 +1347,7 @@ pub fn wire<S, M, P>(
ctl.selected.set(None);
ctl.clear_picks();
ctl.covers.borrow_mut().clear();
reload!(w, ctl, catalog, store, settings);
reload!(w, ctl, catalog, store, settings, eyes_available);
});
}
}