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
+152 -7
View File
@@ -28,7 +28,10 @@ use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::faces::{self, DetectedFace};
use dr_catalog::Catalog;
use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId};
use dr_face::{
align, Calibration, DetectOptions, Detection, Detector, Embedder, EyeModels, EyeReading,
ModelId,
};
use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::settings::FaceSettings;
use dr_types::ImageId;
@@ -154,6 +157,14 @@ pub struct IndexAudit {
/// Outstanding with no proxy on disk. Costs a range request, same as the
/// ones above now do.
pub awaiting_proxy: u64,
/// TRACES: FR-CULL-13
/// Faces this model found that the measuring pass still has to read:
/// stored without their quality, or — on a device with the eye models —
/// without an eye reading. Work that is not visible in the coverage
/// figure, since every one of these images carries its run marker, and
/// that has to be counted here or the screen calls the library finished
/// and takes the button away that would finish it.
pub unmeasured: u64,
}
impl IndexAudit {
@@ -188,8 +199,19 @@ impl IndexAudit {
if outstanding > 0 {
s.push_str(&format!("; {outstanding} to index"));
}
if self.unmeasured > 0 {
s.push_str(&format!("; {} face(s) to measure", self.unmeasured));
}
s
}
/// Whether the sweep has nothing left to do — nothing to index *and*
/// nothing to measure. The screen hides the button on this, so it has to
/// be false while the measuring pass has work, or the eye readings of an
/// already-indexed library could never be filled in.
pub fn is_complete(&self) -> bool {
self.coverage.is_complete() && self.unmeasured == 0
}
}
/// Check every library image for a face-detection run marker.
@@ -197,13 +219,19 @@ impl IndexAudit {
/// The batch pass that answers "has face recognition been over all of this",
/// and the one to run before deciding whether to start a sweep. Cheap: two
/// counts and one indexed scan, no decoding and no inference.
///
/// `eyes` is whether this device has the eye models, and it decides whether
/// a face without an eye reading counts as work — see
/// `dr_catalog::faces::faces_unmeasured`.
pub fn audit(
catalog: &Catalog,
store: &ThumbStore,
model_id: &str,
eyes: bool,
) -> Result<IndexAudit, dr_catalog::CatalogError> {
let conn = catalog.connection();
let coverage = faces::coverage(conn, model_id)?;
let unmeasured = faces::faces_unmeasured(conn, model_id, eyes)?;
// Split the outstanding set by whether a proxy exists. This is the query
// `faces_outstanding` runs without the store filter, so the two cannot
@@ -240,9 +268,40 @@ pub fn audit(
coverage,
ready,
awaiting_proxy: awaiting,
unmeasured,
})
}
/// TRACES: FR-CULL-13
/// Read one face's eyes from the buffer its crop came from, if this device
/// can.
///
/// From the same pixels the embedder saw the face in, so what the models
/// see is the eye at the resolution the crop had — and `dr_face::eyes`'
/// readability floors are judged against real pixels rather than a proxy's
/// idea of them. `bbox` is the detector's `(x0, y0, x1, y1)` and `landmarks`
/// its five points, both in this buffer's pixels. `None` on a device without
/// the models, which is the ordinary state of one that has not been given
/// them, and `None` — logged — where the models refuse: a face the embedder
/// could use is not lost for the want of an eye reading.
fn read_eyes(
models: Option<&mut EyeModels>,
px: dr_face::Pixels<'_>,
width: usize,
height: usize,
bbox: (f32, f32, f32, f32),
landmarks: &[(f32, f32); 5],
) -> Option<EyeReading> {
let models = models?;
match models.read(px, width, height, bbox, landmarks) {
Ok(r) => r,
Err(e) => {
log::debug!("eye reading failed: {e}");
None
}
}
}
/// Detect and embed every face in one decoded proxy.
///
/// Coordinates come back **normalised to the long edge**, which is what the
@@ -256,6 +315,7 @@ pub fn audit(
pub fn index_proxy(
detector: &mut Detector,
embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgb: &[f32],
width: usize,
height: usize,
@@ -310,6 +370,14 @@ pub fn index_proxy(
}
let embedded = embedder.embed(&aligned)?;
let eyes = read_eyes(
eye_models.as_deref_mut(),
dr_face::Pixels::RgbF32(rgb),
width,
height,
d.bbox,
&d.landmarks,
);
out.push(DetectedFace {
x: d.bbox.0 / long_edge,
@@ -321,6 +389,7 @@ pub fn index_proxy(
embedding: embedded.to_f16_bytes(),
crop_px: aligned.source_px(),
quality: Some(embedded.quality),
eyes,
model_id: embedder.model().as_str().to_string(),
// Cut here, while the buffer is still in hand. This is the only
// moment in the whole pipeline where the pixels are free.
@@ -370,6 +439,7 @@ const DETECT_EDGE: usize = 1600;
pub fn index_native(
detector: &mut Detector,
embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgba: &[u8],
width: usize,
height: usize,
@@ -426,6 +496,17 @@ pub fn index_native(
let embedded = embedder.embed(&aligned)?;
let (bx, by) = (d.bbox.0 * sx, d.bbox.1 * sy);
let (bw, bh) = (d.width() * sx, d.height() * sy);
// From the native buffer, box and landmarks scaled like the crop's
// — the eye is a fortieth of the face, and it is here that the
// native render pays for itself twice.
let eyes = read_eyes(
eye_models.as_deref_mut(),
native,
width,
height,
(bx, by, bx + bw, by + bh),
&landmarks,
);
out.push(DetectedFace {
x: bx / long_edge,
@@ -437,6 +518,7 @@ pub fn index_native(
embedding: embedded.to_f16_bytes(),
crop_px: aligned.source_px(),
quality: Some(embedded.quality),
eyes,
model_id: embedder.model().as_str().to_string(),
crop: cut_crop_native(native, width, height, (bx, by, bw, bh)).unwrap_or_default(),
});
@@ -469,8 +551,14 @@ pub struct Measured {
/// established now is how much the model can make of each, which is the
/// quality itself, and a face that would have failed a gate is precisely one
/// that should come out short and stop vouching for anyone.
///
/// The eyes are read on the same pass where the device has the models, for
/// the faces that have no reading yet (schema V16): the pixels are in hand,
/// and the eye is a window on the same landmarks. A face that already has
/// one keeps it.
pub fn measure_native(
embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgba: &[u8],
width: usize,
height: usize,
@@ -494,10 +582,29 @@ pub fn measure_native(
continue;
};
let embedded = embedder.embed(&aligned)?;
let eyes = if f.eyes.is_some() {
None
} else {
let bbox = (
f.x * long_edge,
f.y * long_edge,
(f.x + f.w) * long_edge,
(f.y + f.h) * long_edge,
);
read_eyes(
eye_models.as_deref_mut(),
native,
width,
height,
bbox,
&landmarks,
)
};
out.measured.push(faces::Measurement {
face: f.id,
embedding: embedded.to_f16_bytes(),
quality: embedded.quality,
eyes,
});
}
Ok(out)
@@ -658,8 +765,7 @@ fn normalise_landmarks(lm: &[(f32, f32); 5], long_edge: f32) -> [(f32, f32); 5]
pub fn spawn_store_face_sweep(
catalog_path: PathBuf,
store_dir: PathBuf,
detector_model: PathBuf,
embedder_model: PathBuf,
models: crate::FaceModelPaths,
model_id: String,
options: DetectOptions,
) -> Receiver<FaceSweepMessage> {
@@ -696,7 +802,7 @@ pub fn spawn_store_face_sweep(
// missing. This is also the path a library with face indexing enabled
// but no model downloaded takes (docs/faces.md §2.2), so it must be a
// quiet return rather than an error.
let mut detector = match Detector::from_path(&detector_model) {
let mut detector = match Detector::from_path(&models.detector) {
Ok(d) => d,
Err(e) => {
log::warn!("face sweep: cannot load the detector: {e}");
@@ -705,7 +811,7 @@ pub fn spawn_store_face_sweep(
}
};
let mut embedder =
match Embedder::from_path(&embedder_model, ModelId::new(model_id.clone())) {
match Embedder::from_path(&models.embedder, ModelId::new(model_id.clone())) {
Ok(e) => e,
Err(e) => {
log::warn!("face sweep: cannot load the embedder: {e}");
@@ -713,6 +819,7 @@ pub fn spawn_store_face_sweep(
return;
}
};
let mut eye_models = models.load_eyes();
// Nothing below this point can succeed, so do not pretend to try.
//
@@ -782,6 +889,7 @@ pub fn spawn_store_face_sweep(
let faces = match index_proxy(
&mut detector,
&mut embedder,
eye_models.as_mut(),
&rgb,
w as usize,
h as usize,
@@ -1360,6 +1468,7 @@ fn cut_crop(rgb: &[f32], width: usize, height: usize, d: &Detection) -> Option<V
pub fn index_preview(
detector: &mut Detector,
embedder: &mut Embedder,
eye_models: Option<&mut EyeModels>,
preview: &dr_decode::Preview,
options: &DetectOptions,
) -> Result<(Vec<DetectedFace>, u32), dr_face::FaceError> {
@@ -1367,6 +1476,7 @@ pub fn index_preview(
let faces = index_proxy(
detector,
embedder,
eye_models,
&rgb,
preview.width as usize,
preview.height as usize,
@@ -1499,6 +1609,7 @@ mod tests {
confidence: 0.9,
crop_px: 280.0,
quality: None,
eyes: None,
model_id: "w600k_mbf".into(),
person: None,
probability: 0.0,
@@ -1506,7 +1617,7 @@ mod tests {
};
let stored_copy = stored.clone();
let out = measure_native(&mut embedder, &rgba, w, h, &[stored]).expect("measure");
let out = measure_native(&mut embedder, None, &rgba, w, h, &[stored]).expect("measure");
assert!(out.dropped.is_empty());
assert_eq!(out.measured.len(), 1);
let m = &out.measured[0];
@@ -1526,7 +1637,7 @@ mod tests {
landmarks: [(0.3, 0.3); 5],
..stored_copy
};
let out = measure_native(&mut embedder, &rgba, w, h, &[junk]).expect("measure");
let out = measure_native(&mut embedder, None, &rgba, w, h, &[junk]).expect("measure");
assert!(out.measured.is_empty());
assert_eq!(out.dropped, vec![faces::FaceId(8)]);
}
@@ -1552,6 +1663,7 @@ mod tests {
},
ready: 30,
awaiting_proxy: 10,
unmeasured: 0,
};
let s = a.summary();
assert!(s.contains("60/100"), "{s}");
@@ -1577,11 +1689,40 @@ mod tests {
},
ready: 0,
awaiting_proxy: 0,
unmeasured: 0,
};
let s = a.summary();
assert!(!s.contains("ready"), "{s}");
assert!(!s.contains("awaiting"), "{s}");
assert!(!s.contains("measure"), "{s}");
assert!(s.contains("10/10"), "{s}");
assert!(a.is_complete());
}
/// TRACES: FR-CULL-13
/// A fully indexed library whose faces have not been read is not
/// finished: the screen hides the button on `is_complete`, and this is
/// the only way the readings of an old library ever get filled in.
#[test]
fn faces_left_to_measure_keep_the_audit_incomplete() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 10,
indexed: 10,
without_faces: 7,
faces: 4,
},
ready: 0,
awaiting_proxy: 0,
unmeasured: 4,
};
assert!(a.coverage.is_complete());
assert!(!a.is_complete());
assert!(
a.summary().contains("4 face(s) to measure"),
"{}",
a.summary()
);
}
/// The figure the real library actually produced: 110 of 23,528 rounds to
@@ -1597,6 +1738,7 @@ mod tests {
},
ready: 69,
awaiting_proxy: 23_349,
unmeasured: 0,
};
let s = a.summary();
assert!(s.contains("0.5%"), "{s}");
@@ -1614,6 +1756,7 @@ mod tests {
},
ready: 99_999,
awaiting_proxy: 0,
unmeasured: 0,
};
assert!(a.summary().contains("<0.1%"), "{}", a.summary());
}
@@ -1663,6 +1806,7 @@ mod tests {
confidence: 0.9,
crop_px: 120.0,
quality: None,
eyes: None,
model_id: "w600k_mbf".into(),
person: None,
probability: 0.0,
@@ -1781,6 +1925,7 @@ mod tests {
embedding: embedding(identity, cosine),
crop_px: 150.0,
quality: None,
eyes: None,
model_id: TEST_MODEL.to_string(),
crop: Vec::new(),
};
+7
View File
@@ -239,6 +239,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Shift-click the last photograph of the run",
keys: "",
},
Gesture {
title: "Take the blinks out of a burst",
section: "Library grid",
touch: "Narrow to a person, then tap \"Eyes open\" beside their name on the filter bar",
pointer: "Narrow to a person, then click \"Eyes open\" beside their name on the filter bar",
keys: "",
},
Gesture {
title: "Find photographs with two people in them",
section: "Library grid",
+75
View File
@@ -101,6 +101,9 @@ pub struct FaceCell {
/// of its raw embedding (`dr_face::MIN_GALLERY_QUALITY`). `None` for a
/// face indexed before it was kept.
pub quality: Option<f32>,
/// TRACES: FR-CULL-13
/// What the eyes are doing, where they were read.
pub eyes: Option<dr_face::EyeReading>,
}
impl FaceCell {
@@ -144,6 +147,22 @@ impl FaceCell {
pub fn in_gallery(&self) -> bool {
dr_face::in_gallery(self.quality)
}
/// The eye state as a badge — "Eyes closed", "Sunglasses" or "Eyes
/// unclear" — or nothing.
///
/// Nothing for open eyes and nothing for a face never read, on the rule
/// the confirmed marker follows: the common case carries no mark, so the
/// marks that appear mean something. The three that do appear are the
/// states the eyes-open filter treats differently from open — one it
/// drops, two it lets through — and a user asking why a frame is or is
/// not in the grid can read the answer off the face.
pub fn eyes_label(&self) -> &'static str {
match self.eyes.map(|e| e.state()) {
Some(dr_face::EyeState::Open) | None => "",
Some(state) => state.label(),
}
}
}
/// Put a grouping preview into words.
@@ -284,6 +303,7 @@ pub fn load_faces(
probability: f.probability,
crop_px: f.crop_px,
quality: f.quality,
eyes: f.eyes,
});
}
@@ -787,6 +807,7 @@ mod tests {
embedding: vec![seed; 1024],
crop_px: 180.0,
quality: Some(f32::from(seed) + 10.0),
eyes: None,
model_id: "w600k_mbf".into(),
crop: Vec::new(),
}
@@ -835,6 +856,7 @@ mod tests {
probability: 0.87,
crop_px: 120.0,
quality: Some(17.26),
eyes: None,
};
assert_eq!(cell.confidence_label(), "87% likely");
@@ -858,6 +880,7 @@ mod tests {
probability: 0.5,
crop_px: 120.0,
quality: Some(17.26),
eyes: None,
};
assert_eq!(cell.quality_label(), "Quality 17.3");
assert!(cell.in_gallery());
@@ -880,6 +903,58 @@ mod tests {
);
}
/// Only the two states the filter treats differently from open are
/// badged; open and unread carry no mark.
#[test]
fn the_eye_badge_names_a_blink_or_sunglasses_and_nothing_else() {
let eye = |open| dr_face::Eye {
open,
px: 40.0,
sharpness: 0.2,
};
let reading = |right, left, sunglasses| {
Some(dr_face::EyeReading {
right: eye(right),
left: eye(left),
sunglasses,
})
};
let cell = FaceCell {
face: FaceId(1),
image: ImageId(1),
crop: None,
confirmed: false,
probability: 0.5,
crop_px: 120.0,
quality: Some(17.26),
eyes: None,
};
assert_eq!(cell.eyes_label(), "");
let open = FaceCell {
eyes: reading(0.9, 0.9, 0.0),
..cell.clone()
};
assert_eq!(open.eyes_label(), "");
let blink = FaceCell {
eyes: reading(0.9, 0.2, 0.0),
..cell.clone()
};
assert_eq!(blink.eyes_label(), "Eyes closed");
let shades = FaceCell {
eyes: reading(0.9, 0.2, 0.9),
..cell.clone()
};
assert_eq!(shades.eyes_label(), "Sunglasses");
let mut soft = reading(0.1, 0.1, 0.0).unwrap();
soft.right.sharpness = 0.0;
soft.left.sharpness = 0.0;
let unclear = FaceCell {
eyes: Some(soft),
..cell
};
assert_eq!(unclear.eyes_label(), "Eyes unclear");
}
#[test]
fn the_people_rail_reports_the_unassigned_pool() {
let c = catalog();
+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);
});
}
}
+6 -6
View File
@@ -76,6 +76,7 @@ pub use develop::DevelopSession;
/// APK may carry a bundled copy and has to write it out before any store is
/// opened — see `library::shared_face_models_dir`.
pub use library::shared_face_models_dir;
pub use library::FaceModelPaths;
/// The scene model's three files, wherever this device keeps them.
///
@@ -3894,18 +3895,17 @@ fn refresh_face_status(
let store = library
.session()
.and_then(|c| dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok());
let models = library
.session()
.and_then(|c| library::face_models(&c.account, detector));
identity_ui::refresh_coverage(
window,
&library.catalog(),
store.as_ref(),
detector.model_id(),
models.as_ref().is_some_and(|m| m.eyes.is_some()),
);
window.set_identity_model_missing(
library
.session()
.and_then(|c| library::face_models(&c.account, detector))
.is_none(),
);
window.set_identity_model_missing(models.is_none());
}
fn describe_cache_usage(library: &Rc<library_ui::LibraryController>) -> String {
+413 -33
View File
@@ -318,6 +318,26 @@ pub struct RatingFilter {
pub people: Vec<u64>,
/// Whether [`RatingFilter::people`] is a union or an intersection.
pub people_mode: PeopleMode,
/// TRACES: FR-CULL-13
/// Only photographs in which the chosen people are not caught blinking.
///
/// A term *on* [`RatingFilter::people`]: Anna with her eyes open, whoever
/// else is in the frame. The bar offers it only while someone is chosen
/// and drops it when the last person goes, so the predicate below still
/// handles the empty case — every face in the frame — but nothing on the
/// screen reaches it. Either way the term drops a face only on a reading
/// that says **closed** from an eye that could be read — a face behind
/// sunglasses, one whose eyes were too small or soft to read, or one
/// never read, is left alone, because the filter's job is to take the
/// blinks out of a burst and not to insist on a verdict it does not
/// have. An old library with no readings at all therefore shows
/// everything under this chip rather than nothing, which is the honest
/// answer until the measuring pass has run.
///
/// A filter and not a judgement: it narrows the grid the way a person or
/// a star count does, and nothing here rejects or rates a frame — the
/// line §3.9.1 draws.
pub eyes_open: bool,
}
/// How several people combine when the grid is narrowed by identity.
@@ -351,6 +371,7 @@ impl RatingFilter {
&& self.captured_from.is_none()
&& self.captured_to.is_none()
&& self.people.is_empty()
&& !self.eyes_open
}
/// Whether a date range is narrowing the grid.
@@ -424,6 +445,41 @@ impl RatingFilter {
);
}
// A face the eyes-open term drops: read, not behind sunglasses, and
// a *readable* eye under the threshold — one with enough pixels,
// sharp enough, and not the collapsed contour of a turned head's far
// eye. Constants from `dr_face`, formatted like the integers above;
// the rule is `dr_face::EyeReading::state`, and the thresholds live
// there so this and the People screen's label cannot disagree.
let readable = |eye: &str, other: &str| {
format!(
"(f.{eye}_px >= {px} AND f.{eye}_sharp >= {sharp}
AND f.{eye}_px >= f.{other}_px * {ratio})",
px = dr_face::MIN_EYE_PX,
sharp = dr_face::MIN_EYE_SHARPNESS,
ratio = dr_face::HIDDEN_EYE_RATIO,
)
};
let blink = format!(
"(f.eye_right IS NOT NULL AND f.eye_left IS NOT NULL
AND f.eye_right_px IS NOT NULL AND f.eye_left_px IS NOT NULL
AND f.eye_right_sharp IS NOT NULL AND f.eye_left_sharp IS NOT NULL
AND coalesce(f.sunglasses, 0) < {sg}
AND (({right_ok} AND f.eye_right < {open})
OR ({left_ok} AND f.eye_left < {open})))",
sg = dr_face::SUNGLASSES_THRESHOLD,
open = dr_face::EYES_OPEN_THRESHOLD,
right_ok = readable("eye_right", "eye_left"),
left_ok = readable("eye_left", "eye_right"),
);
// Appended to the face predicates below: with the chip on, a face
// counts only if it is not a blink.
let not_blinking = if self.eyes_open {
format!(" AND NOT {blink}")
} else {
String::new()
};
if !self.people.is_empty() {
// Integers this code owns, like every other term here — the ids
// come from the catalog, never from typed text, so there is
@@ -442,7 +498,7 @@ impl RatingFilter {
PeopleMode::Any => format!(
"EXISTS (SELECT 1 FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = i.id AND fp.person_id IN ({ids}))"
WHERE f.image_id = i.id AND fp.person_id IN ({ids}){not_blinking})"
),
// Counting *distinct* people rather than ANDing one EXISTS per
// person: same result, one subquery instead of n, and it does
@@ -452,10 +508,18 @@ impl RatingFilter {
PeopleMode::All => format!(
"(SELECT COUNT(DISTINCT fp.person_id) FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = i.id AND fp.person_id IN ({ids})) = {}",
WHERE f.image_id = i.id AND fp.person_id IN ({ids}){not_blinking}) = {}",
self.people.len()
),
});
} else if self.eyes_open {
// Nobody in particular: no face in the frame may be a blink. A
// photograph with no faces at all passes — there is no one in it
// to have blinked, and hiding the landscapes would make the chip
// read as "only people".
terms.push(format!(
"NOT EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {blink})"
));
}
if let Some(flag) = self.flag {
@@ -3915,9 +3979,15 @@ fn faces_without_proxy(
/// there is no embedding without the pixels. What it saves is the detector,
/// and — the part that matters — every suggestion and confirmation on those
/// faces, which a re-detection would rebuild from box overlap.
///
/// With `eyes` — this device has the eye models — the list also names every
/// image holding a face with no eye reading (schema V16), which the same
/// pass reads from the same render. Without them it does not, or a device
/// that cannot read eyes would fetch every original to do nothing to it.
fn faces_unmeasured(
catalog: &Catalog,
model_id: &str,
eyes: bool,
) -> Result<Vec<ThumbnailRequest>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT DISTINCT i.id, i.source_ref, r.file_id, i.file_size
@@ -3925,9 +3995,10 @@ fn faces_unmeasured(
JOIN remote r ON r.image_id = i.id
JOIN faces f ON f.image_id = i.id
WHERE r.file_id IS NOT NULL AND {VISIBLE}
AND {embedder} = ?1 AND f.quality IS NULL
AND {embedder} = ?1 AND {unmeasured}
ORDER BY i.id",
embedder = dr_catalog::faces::embedder_sql("f.model_id"),
unmeasured = dr_catalog::faces::unmeasured_sql("f.", eyes),
))?;
let rows = stmt
.query_map([dr_catalog::faces::embedder_of(model_id)], |r| {
@@ -4095,8 +4166,7 @@ pub fn spawn_face_sweep(
conn: Connection,
catalog_path: PathBuf,
store_dir: PathBuf,
detector_model: PathBuf,
embedder_model: PathBuf,
models: FaceModelPaths,
model_id: String,
supersedes: Vec<String>,
options: dr_face::DetectOptions,
@@ -4144,7 +4214,7 @@ pub fn spawn_face_sweep(
// listing twenty thousand images before discovering the weights are
// missing helps nobody. A library with no model installed takes this
// path, so it is a quiet return rather than an error.
let mut detector = match dr_face::Detector::from_path(&detector_model) {
let mut detector = match dr_face::Detector::from_path(&models.detector) {
Ok(d) => d,
Err(e) => {
log::warn!("face sweep: cannot load the detector: {e}");
@@ -4153,7 +4223,7 @@ pub fn spawn_face_sweep(
}
};
let mut embedder = match dr_face::Embedder::from_path(
&embedder_model,
&models.embedder,
dr_face::ModelId::new(model_id.clone()),
) {
Ok(e) => e,
@@ -4163,6 +4233,9 @@ pub fn spawn_face_sweep(
return;
}
};
// Optional, and loaded after the two that are not: a library
// without them indexes faces and simply has no eye readings.
let mut eye_models = models.load_eyes();
// **Repairs first, and the order is the whole point.** These are the
// images the People screen is drawing *right now* and failing to, and
@@ -4194,7 +4267,7 @@ pub fn spawn_face_sweep(
// gets its quality from that, so it is not queued twice.
let mut queued: std::collections::HashSet<i64> =
wanted.iter().map(|(r, _)| r.image_id).collect();
match faces_unmeasured(&catalog, &model_id) {
match faces_unmeasured(&catalog, &model_id, eye_models.is_some()) {
Ok(measure) => {
let fresh: Vec<_> = measure
.into_iter()
@@ -4373,6 +4446,7 @@ pub fn spawn_face_sweep(
match measure_one_native(
&gpu,
&mut embedder,
eye_models.as_mut(),
&catalog,
image,
&model_id,
@@ -4398,7 +4472,14 @@ pub fn spawn_face_sweep(
continue;
}
match index_one_native(&gpu, &mut detector, &mut embedder, &bytes, &options) {
match index_one_native(
&gpu,
&mut detector,
&mut embedder,
eye_models.as_mut(),
&bytes,
&options,
) {
Ok((faces, edge, proxy)) => {
// Before the detections, so a kill between the two
// leaves a proxy with no faces recorded -- which
@@ -4577,6 +4658,7 @@ fn index_one_native(
gpu: &dr_gpu::GpuContext,
detector: &mut dr_face::Detector,
embedder: &mut dr_face::Embedder,
eye_models: Option<&mut dr_face::EyeModels>,
bytes: &[u8],
options: &dr_face::DetectOptions,
) -> Result<
@@ -4594,6 +4676,7 @@ fn index_one_native(
let faces = crate::faces::index_native(
detector,
embedder,
eye_models,
&frame.rgba,
frame.width as usize,
frame.height as usize,
@@ -4647,13 +4730,19 @@ fn index_one_native(
fn measure_one_native(
gpu: &dr_gpu::GpuContext,
embedder: &mut dr_face::Embedder,
eye_models: Option<&mut dr_face::EyeModels>,
catalog: &Catalog,
image: dr_types::ImageId,
model_id: &str,
bytes: &[u8],
) -> Result<usize, String> {
let faces = dr_catalog::faces::unmeasured_on_image(catalog.connection(), image, model_id)
.map_err(|e| e.to_string())?;
let faces = dr_catalog::faces::unmeasured_on_image(
catalog.connection(),
image,
model_id,
eye_models.is_some(),
)
.map_err(|e| e.to_string())?;
if faces.is_empty() {
return Ok(0);
}
@@ -4661,6 +4750,7 @@ fn measure_one_native(
let edge = frame.width.max(frame.height);
let measured = crate::faces::measure_native(
embedder,
eye_models,
&frame.rgba,
frame.width as usize,
frame.height as usize,
@@ -5056,7 +5146,53 @@ pub fn shared_face_models_dir() -> PathBuf {
data_root().join("models")
}
/// The detector and embedder files, if both are present.
/// The names of the three eye-state models, as shipped in `models/face/`.
///
/// The shape-fixed exports, like the face pair: `tools/fix-face-model-shapes.sh`
/// pins each one's batch dimension to 1 before tract will analyse it.
pub const LANDMARK_MODEL: &str = "2d106det_b1.onnx";
pub const EYE_MODEL: &str = "ocec_s_b1.onnx";
pub const SUNGLASSES_MODEL: &str = "sgc_l_48_b1.onnx";
/// Where the face models are on this machine.
///
/// The detector and the embedder are required — see [`face_models`] — and
/// the eye pair is not: a library indexes people without it and simply has
/// no eye readings, which every reader treats as "unknown" rather than as a
/// verdict (`dr_face::eyes`). Found beside the pair, in the same directory,
/// so a hand-placed pair with no eye models beside it does not pick up the
/// package's eye models from a directory it otherwise outranks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FaceModelPaths {
pub detector: PathBuf,
pub embedder: PathBuf,
/// `(landmarks, eyes, sunglasses)` — [`LANDMARK_MODEL`], [`EYE_MODEL`]
/// and [`SUNGLASSES_MODEL`] — where all three are present, or `None`.
/// All or none: a partial reading is not a reading
/// (`dr_face::classify::EyeModels`).
pub eyes: Option<(PathBuf, PathBuf, PathBuf)>,
}
impl FaceModelPaths {
/// Load the eye models, if there are any.
///
/// A pair that is present but refuses to load is logged and treated as
/// absent: a broken eye model must not stop the detector and embedder,
/// which are the ones the People screen cannot do without.
pub fn load_eyes(&self) -> Option<dr_face::EyeModels> {
let (landmarks, eyes, sunglasses) = self.eyes.as_ref()?;
match dr_face::EyeModels::from_paths(landmarks, eyes, sunglasses) {
Ok(m) => Some(m),
Err(e) => {
log::warn!("face models: eye models present but unusable, indexing without: {e}");
None
}
}
}
}
/// The detector and embedder files, if both are present — and the eye
/// models beside them, if those are.
///
/// Both or neither: an embedder with no detector has nothing to embed, and a
/// detector with no embedder finds faces it cannot tell apart. Reporting the
@@ -5077,32 +5213,48 @@ pub fn shared_face_models_dir() -> PathBuf {
/// 3. **The system directories.** Where a package installs them — the Arch
/// package puts the pair in `/usr/share/darkroom/models`. Last, so anything
/// the user placed themselves outranks what the package shipped.
pub fn face_models(
account: &Account,
detector: dr_types::FaceDetector,
) -> Option<(PathBuf, PathBuf)> {
pub fn face_models(account: &Account, detector: dr_types::FaceDetector) -> Option<FaceModelPaths> {
let pair = |dir: &PathBuf| {
let detector = dir.join(detector.file_name());
let det = dir.join(detector.file_name());
let embedder = dir.join("arcface_mbf_b1.onnx");
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
(det.is_file() && embedder.is_file()).then(|| {
let landmarks = dir.join(LANDMARK_MODEL);
let eyes = dir.join(EYE_MODEL);
let sunglasses = dir.join(SUNGLASSES_MODEL);
FaceModelPaths {
detector: det,
embedder,
eyes: (landmarks.is_file() && eyes.is_file() && sunglasses.is_file())
.then_some((landmarks, eyes, sunglasses)),
}
})
};
let mut searched = vec![face_models_dir(account), shared_face_models_dir()];
searched.extend(system_face_models_dirs());
let found = searched.iter().find_map(pair);
if found.is_none() {
// The settings page can only say "not installed". This is the line
// that says where it looked, which is the whole of what a user with
// the files in the wrong place needs — and the first thing to read
// when a freshly installed package reports no model.
log::warn!(
"face models: no directory holds both {} and arcface_mbf_b1.onnx; searched {}",
detector.file_name(),
searched
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
match &found {
None => {
// The settings page can only say "not installed". This is the line
// that says where it looked, which is the whole of what a user with
// the files in the wrong place needs — and the first thing to read
// when a freshly installed package reports no model.
log::warn!(
"face models: no directory holds both {} and arcface_mbf_b1.onnx; searched {}",
detector.file_name(),
searched
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
Some(m) if m.eyes.is_none() => {
log::info!(
"face models: no {LANDMARK_MODEL}, {EYE_MODEL} and {SUNGLASSES_MODEL} beside {}; indexing without eye readings",
m.detector.display()
);
}
Some(_) => {}
}
found
}
@@ -6773,6 +6925,7 @@ mod tests {
embedding: vec![0u8; 1024],
crop_px: 120.0,
quality: None,
eyes: None,
crop: Vec::new(),
model_id: "w600k_mbf".into(),
};
@@ -6823,6 +6976,7 @@ mod tests {
embedding: vec![0u8; 1024],
crop_px: 120.0,
quality: None,
eyes: None,
crop: Vec::new(),
model_id: "w600k_mbf".into(),
};
@@ -6979,6 +7133,7 @@ mod tests {
embedding: vec![0u8; 1024],
crop_px: 120.0,
quality,
eyes: None,
crop: Vec::new(),
model_id: "w600k_mbf".into(),
};
@@ -6996,7 +7151,7 @@ mod tests {
// What V14 does to the first: the marker goes, the face stays.
dr_catalog::faces::clear_index_marker(conn, ids[0], "w600k_mbf").unwrap();
let measure = faces_unmeasured(&catalog, "w600k_mbf").unwrap();
let measure = faces_unmeasured(&catalog, "w600k_mbf", false).unwrap();
assert_eq!(measure.len(), 1);
assert_eq!(measure[0].image_id, ids[0].0 as i64);
assert!(measure[0].full_resolution);
@@ -7006,6 +7161,13 @@ mod tests {
let detect = faces_unindexed(&catalog, "w600k_mbf").unwrap();
assert_eq!(detect.len(), 1);
assert_eq!(detect[0].image_id, ids[2].0 as i64);
// Neither face has an eye reading. A device that can read eyes has
// both images to measure; one that cannot still has only the first
// — it must not fetch an original to do nothing to it.
let with_eyes = faces_unmeasured(&catalog, "w600k_mbf", true).unwrap();
assert_eq!(with_eyes.len(), 2);
assert_eq!(faces_unindexed(&catalog, "w600k_mbf").unwrap().len(), 1);
}
#[test]
@@ -7968,6 +8130,7 @@ mod tests {
embedding: vec![0u8; 1024],
crop_px: 120.0,
quality: None,
eyes: None,
crop: Vec::new(),
model_id: "w600k_mbf".into(),
};
@@ -8095,6 +8258,223 @@ mod tests {
assert!(f.is_unfiltered());
assert_eq!(total_images_scoped(&catalog, None, &f).unwrap(), 3);
}
// ── eyes open ─────────────────────────────────────────────────────────
/// Write an eye reading onto every face of `person` on `image`: two
/// large, sharp eyes.
fn read_eyes(
catalog: &Catalog,
image: dr_types::ImageId,
person: dr_catalog::faces::PersonId,
(right, left, sunglasses): (f32, f32, f32),
) {
read_eyes_full(
catalog,
image,
person,
[(right, 40.0, 0.2), (left, 40.0, 0.2)],
sunglasses,
);
}
/// The whole reading: per eye `(open, px, sharpness)`.
fn read_eyes_full(
catalog: &Catalog,
image: dr_types::ImageId,
person: dr_catalog::faces::PersonId,
eyes: [(f32, f32, f32); 2],
sunglasses: f32,
) {
catalog
.connection()
.execute(
"UPDATE faces
SET eye_right = ?3, eye_right_px = ?4, eye_right_sharp = ?5,
eye_left = ?6, eye_left_px = ?7, eye_left_sharp = ?8,
sunglasses = ?9
WHERE image_id = ?1
AND id IN (SELECT face_id FROM face_person WHERE person_id = ?2)",
rusqlite::params![
image.0 as i64,
person.0 as i64,
f64::from(eyes[0].0),
f64::from(eyes[0].1),
f64::from(eyes[0].2),
f64::from(eyes[1].0),
f64::from(eyes[1].1),
f64::from(eyes[1].2),
f64::from(sunglasses),
],
)
.unwrap();
}
/// TRACES: FR-CULL-13
/// With a person chosen, the chip asks about *that* person's eyes: Anna
/// blinking hides the frame, and Bob blinking beside her does not.
#[test]
fn eyes_open_asks_about_the_chosen_persons_eyes() {
let catalog = with_images(5);
let ids = image_ids(&catalog);
let anna = dr_catalog::faces::create_person(catalog.connection(), "Anna").unwrap();
let bob = dr_catalog::faces::create_person(catalog.connection(), "Bob").unwrap();
assign(&catalog, anna, &[ids[0], ids[1], ids[2], ids[3]]);
assign(&catalog, bob, &[ids[1], ids[4]]);
// 0: Anna open. 1: Anna open, Bob blinking. 2: Anna blinking.
// 3: Anna never read. 4: Bob alone, open.
read_eyes(&catalog, ids[0], anna, (0.9, 0.9, 0.0));
read_eyes(&catalog, ids[1], anna, (0.9, 0.9, 0.0));
read_eyes(&catalog, ids[1], bob, (0.1, 0.9, 0.0));
read_eyes(&catalog, ids[2], anna, (0.2, 0.9, 0.0));
read_eyes(&catalog, ids[4], bob, (0.9, 0.9, 0.0));
let count = |people: Vec<u64>, mode: PeopleMode, eyes_open: bool| {
let f = RatingFilter {
people,
people_mode: mode,
eyes_open,
..Default::default()
};
total_images_scoped(&catalog, None, &f).unwrap()
};
assert_eq!(count(vec![anna.0], PeopleMode::Any, false), 4);
// Drops only the frame where Anna herself is blinking; the unread
// one stays, and Bob's blink beside her is not her problem.
assert_eq!(count(vec![anna.0], PeopleMode::Any, true), 3);
// Bob: the frame he blinks in goes.
assert_eq!(count(vec![bob.0], PeopleMode::Any, true), 1);
// Both of them together: only frame 1, and Bob blinks in it.
assert_eq!(count(vec![anna.0, bob.0], PeopleMode::All, false), 1);
assert_eq!(count(vec![anna.0, bob.0], PeopleMode::All, true), 0);
// Either of them: 0, 1 (Anna's face is open there), 3, 4.
assert_eq!(count(vec![anna.0, bob.0], PeopleMode::Any, true), 4);
}
/// TRACES: FR-CULL-13
/// With nobody chosen the chip asks about every face, and a face behind
/// sunglasses is not a blink.
#[test]
fn eyes_open_alone_drops_any_frame_with_a_blink_in_it() {
let catalog = with_images(5);
let ids = image_ids(&catalog);
let anna = dr_catalog::faces::create_person(catalog.connection(), "Anna").unwrap();
assign(&catalog, anna, &[ids[0], ids[1], ids[2], ids[3]]);
// 0: open. 1: a wink. 2: sunglasses over a reading that says closed.
// 3: never read. 4: no faces at all.
read_eyes(&catalog, ids[0], anna, (0.9, 0.9, 0.0));
read_eyes(&catalog, ids[1], anna, (0.9, 0.3, 0.0));
read_eyes(&catalog, ids[2], anna, (0.1, 0.1, 0.9));
let f = RatingFilter {
eyes_open: true,
..Default::default()
};
assert!(!f.is_unfiltered());
assert_eq!(total_images_scoped(&catalog, None, &f).unwrap(), 4);
let shown: Vec<i64> = read_cells_scoped(&catalog, None, &f, 0, 10)
.unwrap()
.into_iter()
.map(|c| c.image_id)
.collect();
assert!(!shown.contains(&(ids[1].0 as i64)), "the wink is hidden");
assert!(
shown.contains(&(ids[2].0 as i64)),
"sunglasses are not a blink"
);
assert!(shown.contains(&(ids[3].0 as i64)), "unread is not a blink");
assert!(
shown.contains(&(ids[4].0 as i64)),
"a landscape has no one to blink"
);
}
/// TRACES: FR-CULL-13
/// An eye that cannot be read is not a blink, in SQL as in
/// `dr_face::EyeReading::state` — the two must agree, or the badge on
/// the People screen would say one thing and the grid another.
#[test]
fn an_unreadable_eye_does_not_hide_the_frame() {
let catalog = with_images(5);
let ids = image_ids(&catalog);
let anna = dr_catalog::faces::create_person(catalog.connection(), "Anna").unwrap();
assign(&catalog, anna, &[ids[0], ids[1], ids[2], ids[3], ids[4]]);
let (px, sharp) = (dr_face::MIN_EYE_PX, dr_face::MIN_EYE_SHARPNESS);
// 0: a profile — the far (right) eye's contour collapsed to a fifth
// of the near one and reads closed, the near eye open. Open.
// 1: a soft closed eye beside a sharp open one. Open.
// 2: both eyes too small to read. Unreadable — not a blink.
// 3: a large, sharp closed eye. A blink.
// 4: a small closed eye whose partner is just as small — not a
// hidden eye, but still under the pixel floor. Unreadable.
read_eyes_full(
&catalog,
ids[0],
anna,
[(0.05, 8.0, 0.3), (0.95, 40.0, 0.3)],
0.0,
);
read_eyes_full(
&catalog,
ids[1],
anna,
[(0.05, 40.0, sharp / 2.0), (0.95, 40.0, 0.3)],
0.0,
);
read_eyes_full(
&catalog,
ids[2],
anna,
[(0.05, px - 1.0, 0.3), (0.05, px - 1.0, 0.3)],
0.0,
);
read_eyes_full(
&catalog,
ids[3],
anna,
[(0.05, 40.0, 0.3), (0.95, 40.0, 0.3)],
0.0,
);
read_eyes_full(
&catalog,
ids[4],
anna,
[(0.05, 10.0, 0.3), (0.95, 10.0, 0.3)],
0.0,
);
let f = RatingFilter {
eyes_open: true,
..Default::default()
};
let shown: Vec<i64> = read_cells_scoped(&catalog, None, &f, 0, 10)
.unwrap()
.into_iter()
.map(|c| c.image_id)
.collect();
let want = [true, true, true, false, true];
for (img, keep) in want.iter().enumerate() {
assert_eq!(
shown.contains(&(ids[img].0 as i64)),
*keep,
"image {img} in the grid"
);
// And the same through the rule the badge uses.
let face = &dr_catalog::faces::for_image(catalog.connection(), ids[img]).unwrap()[0];
assert_eq!(
face.eyes.map(|e| !e.is_blink()),
Some(*keep),
"image {img} by rule"
);
}
let unclear = &dr_catalog::faces::for_image(catalog.connection(), ids[2]).unwrap()[0];
assert_eq!(
unclear.eyes.map(|e| e.state()),
Some(dr_face::EyeState::Unreadable)
);
}
}
/// TRACES: FR-CAT-8 | FR-NC-9
+31
View File
@@ -5671,6 +5671,7 @@ fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
});
window.set_library_local_only(filter.local_only);
ctl.local_only.set(filter.local_only);
window.set_library_filter_eyes_open(filter.eyes_open);
push_people_chips(window, ctl);
}
@@ -6628,6 +6629,7 @@ pub fn wire<F>(
.borrow_mut()
.people
.retain(|p| *p != id.max(0) as u64);
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
@@ -6656,11 +6658,27 @@ pub fn wire<F>(
f.people.push(person);
}
}
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_eyes_open_toggled(move || {
let Some(w) = weak.upgrade() else { return };
let on = {
let mut f = ctl.filter.borrow_mut();
f.eyes_open = !f.eyes_open;
f.eyes_open
};
w.set_library_filter_eyes_open(on);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
@@ -6988,6 +7006,19 @@ pub fn wire<F>(
/// The label is the People screen's own wording, unnamed groups included, so
/// the same group does not read one way on one screen and another way on the
/// next.
/// Drop the eyes-open term when the last person leaves the filter.
///
/// The chip lives beside the people chips and goes with them; a term that
/// stayed on after its chip had gone would narrow the grid with nothing on
/// the bar to say so, which is the one state a filter bar must never be in.
fn drop_eyes_open_without_people(window: &AppWindow, ctl: &Rc<LibraryController>) {
let mut f = ctl.filter.borrow_mut();
if f.people.is_empty() && f.eyes_open {
f.eyes_open = false;
window.set_library_filter_eyes_open(false);
}
}
fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
let ids = ctl.filter.borrow().people.clone();
if ids.is_empty() {
+4
View File
@@ -31,6 +31,7 @@ impl From<&RatingFilter> for StoredFilter {
captured_to: f.captured_to,
people: f.people.clone(),
people_all: matches!(f.people_mode, PeopleMode::All),
eyes_open: f.eyes_open,
}
}
}
@@ -50,6 +51,7 @@ impl From<&StoredFilter> for RatingFilter {
} else {
PeopleMode::Any
},
eyes_open: s.eyes_open,
}
}
}
@@ -183,6 +185,7 @@ mod tests {
captured_to: Some(2),
people: vec![7, 9],
people_all: true,
eyes_open: true,
},
}
}
@@ -304,6 +307,7 @@ mod tests {
captured_to: Some(20),
people: vec![3, 5, 8],
people_mode: PeopleMode::All,
eyes_open: true,
};
let back: RatingFilter = (&StoredFilter::from(&original)).into();
assert_eq!(back, original);