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
+5
View File
@@ -126,6 +126,11 @@ pub struct StoredFilter {
/// does not derive serde — and "all of them" is the only thing the second /// does not derive serde — and "all of them" is the only thing the second
/// variant means. /// variant means.
pub people_all: bool, pub people_all: bool,
/// TRACES: FR-CULL-13
/// Whether the grid was narrowed to photographs with nobody blinking.
/// Travels: it is a narrowing like `local_only`, and a record without it
/// — from a build before it existed — reads as off.
pub eyes_open: bool,
} }
/// Where the photographer was, at the moment they were there. /// Where the photographer was, at the moment they were there.
+34 -25
View File
@@ -5,7 +5,7 @@
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it. Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
40 gestures, in 4 places. 41 gestures, in 4 places.
## Develop ## Develop
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
<sub>`ui/dr-ui/ui/app.slint:1980`</sub> <sub>`ui/dr-ui/ui/app.slint:1986`</sub>
### Move a magnified photograph about ### Move a magnified photograph about
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture. Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2071`</sub> <sub>`ui/dr-ui/ui/app.slint:2077`</sub>
### Paint a mask by hand ### Paint a mask by hand
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make. A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2158`</sub> <sub>`ui/dr-ui/ui/app.slint:2164`</sub>
### Take back the last change ### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does. A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2379`</sub> <sub>`ui/dr-ui/ui/app.slint:2385`</sub>
### Do it again after taking it back ### Do it again after taking it back
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header - **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z - **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2392`</sub> <sub>`ui/dr-ui/ui/app.slint:2398`</sub>
### Copy the settings from this photograph ### Copy the settings from this photograph
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way. The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2425`</sub> <sub>`ui/dr-ui/ui/app.slint:2431`</sub>
### Paste the settings onto this photograph ### Paste the settings onto this photograph
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope. The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2437`</sub> <sub>`ui/dr-ui/ui/app.slint:2443`</sub>
### Change which group of adjustments is on screen ### Change which group of adjustments is on screen
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added. The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2465`</sub> <sub>`ui/dr-ui/ui/app.slint:2471`</sub>
### Look at the photograph at 1:1 ### Look at the photograph at 1:1
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
<sub>`ui/dr-ui/ui/app.slint:2500`</sub> <sub>`ui/dr-ui/ui/app.slint:2506`</sub>
### Move to the next or previous photograph ### Move to the next or previous photograph
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
<sub>`ui/dr-ui/ui/app.slint:2552`</sub> <sub>`ui/dr-ui/ui/app.slint:2558`</sub>
### See the photograph before you edited it ### See the photograph before you edited it
@@ -121,7 +121,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards. Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2676`</sub> <sub>`ui/dr-ui/ui/app.slint:2682`</sub>
### Put one control back to its default ### Put one control back to its default
@@ -226,7 +226,7 @@ Double-click is what a file manager and a Lightroom panel use for the same thing
Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent. Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent.
<sub>`ui/dr-ui/ui/identity.slint:161`</sub> <sub>`ui/dr-ui/ui/identity.slint:188`</sub>
### Rule on a suggested face ### Rule on a suggested face
@@ -235,7 +235,7 @@ Grouping over-merges on siblings, on parents and children, and on the same perso
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on. A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on.
<sub>`ui/dr-ui/ui/identity.slint:181`</sub> <sub>`ui/dr-ui/ui/identity.slint:208`</sub>
### See a person's photographs ### See a person's photographs
@@ -244,7 +244,7 @@ A face is either the system's guess or the user's judgement, and the two are nev
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles. This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
<sub>`ui/dr-ui/ui/identity.slint:602`</sub> <sub>`ui/dr-ui/ui/identity.slint:629`</sub>
### Change how faces are grouped ### Change how faces are grouped
@@ -253,7 +253,7 @@ This is the point of having identified anybody. Without it the screen is a filin
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say. The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
<sub>`ui/dr-ui/ui/identity.slint:639`</sub> <sub>`ui/dr-ui/ui/identity.slint:666`</sub>
## Library grid ## Library grid
@@ -301,6 +301,15 @@ This replaced a double tap, which had no visible state and could take forty phot
<sub>`ui/dr-ui/ui/library.slint:1414`</sub> <sub>`ui/dr-ui/ui/library.slint:1414`</sub>
### Take the blinks out of a burst
- **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
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
<sub>`ui/dr-ui/ui/library.slint:2102`</sub>
### Find photographs with two people in them ### Find photographs with two people in them
- **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them" - **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them"
@@ -308,7 +317,7 @@ This replaced a double tap, which had no visible state and could take forty phot
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar. "Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
<sub>`ui/dr-ui/ui/library.slint:2102`</sub> <sub>`ui/dr-ui/ui/library.slint:2131`</sub>
### Resize the thumbnails ### Resize the thumbnails
@@ -317,7 +326,7 @@ This replaced a double tap, which had no visible state and could take forty phot
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach. There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
<sub>`ui/dr-ui/ui/library.slint:2755`</sub> <sub>`ui/dr-ui/ui/library.slint:2785`</sub>
### File photographs in a collection ### File photographs in a collection
@@ -326,7 +335,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture. The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
<sub>`ui/dr-ui/ui/library.slint:2952`</sub> <sub>`ui/dr-ui/ui/library.slint:2982`</sub>
### Open a photograph ### Open a photograph
@@ -335,7 +344,7 @@ The selection is what the drag carries, which is why selecting several is worth
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush. A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
<sub>`ui/dr-ui/ui/library.slint:3220`</sub> <sub>`ui/dr-ui/ui/library.slint:3250`</sub>
### Rate a photograph without opening it ### Rate a photograph without opening it
@@ -345,7 +354,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
A star has to take the press without it also reaching the cell, or every rating throws the user into develop. A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
<sub>`ui/dr-ui/ui/library.slint:3340`</sub> <sub>`ui/dr-ui/ui/library.slint:3370`</sub>
### Choose the frame a folded burst shows ### Choose the frame a folded burst shows
@@ -354,7 +363,7 @@ A star has to take the press without it also reaching the cell, or every rating
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared. A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
<sub>`ui/dr-ui/ui/library.slint:3471`</sub> <sub>`ui/dr-ui/ui/library.slint:3501`</sub>
### Drop the selection but keep selecting ### Drop the selection but keep selecting
@@ -363,7 +372,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away. Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
<sub>`ui/dr-ui/ui/library.slint:4132`</sub> <sub>`ui/dr-ui/ui/library.slint:4162`</sub>
### Select everything the grid is showing ### Select everything the grid is showing
@@ -372,7 +381,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it. A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
<sub>`ui/dr-ui/ui/library.slint:4149`</sub> <sub>`ui/dr-ui/ui/library.slint:4179`</sub>
### Take photographs out of a collection ### Take photographs out of a collection
@@ -381,4 +390,4 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to. The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
<sub>`ui/dr-ui/ui/library.slint:4264`</sub> <sub>`ui/dr-ui/ui/library.slint:4294`</sub>
+72 -72
View File
File diff suppressed because one or more lines are too long
+9 -4
View File
@@ -60,7 +60,7 @@ fn main() {
} }
}; };
let audit = match faces::audit(&catalog, &store, MODEL_ID) { let audit = match faces::audit(&catalog, &store, MODEL_ID, false) {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
eprintln!("coverage check failed: {e}"); eprintln!("coverage check failed: {e}");
@@ -183,8 +183,13 @@ fn main() {
let rx = faces::spawn_store_face_sweep( let rx = faces::spawn_store_face_sweep(
catalog_path, catalog_path,
store_dir, store_dir,
PathBuf::from(detector), dr_ui::FaceModelPaths {
PathBuf::from(embedder), detector: PathBuf::from(detector),
embedder: PathBuf::from(embedder),
// A measurement tool for the detector and embedder; the eye
// models are the library pass's business.
eyes: None,
},
MODEL_ID.to_string(), MODEL_ID.to_string(),
dr_face::DetectOptions::default(), dr_face::DetectOptions::default(),
); );
@@ -222,7 +227,7 @@ fn main() {
} }
} }
if let Ok(a) = faces::audit(&catalog, &store, MODEL_ID) { if let Ok(a) = faces::audit(&catalog, &store, MODEL_ID, false) {
println!("{}", a.summary()); println!("{}", a.summary());
} }
println!("\nrun again with --cluster to group these faces into people."); println!("\nrun again with --cluster to group these faces into people.");
+38 -9
View File
@@ -1,7 +1,7 @@
//! TRACES: FR-CULL-8 | FR-EXP-9 //! TRACES: FR-CULL-8 | FR-EXP-9
//! Measure what indexing at native resolution is actually worth. //! Measure what indexing at native resolution is actually worth.
//! //!
//! cargo run -p dr-ui --example face_native -- DET.onnx EMB.onnx FILE [FILE…] //! cargo run -p dr-ui --example face_native -- DET.onnx EMB.onnx [--export DIR] FILE [FILE…]
//! //!
//! Renders each file once at native resolution, then indexes it twice: the way //! Renders each file once at native resolution, then indexes it twice: the way
//! FR-CULL-8 now specifies, and the way it used to be done — everything, both //! FR-CULL-8 now specifies, and the way it used to be done — everything, both
@@ -13,6 +13,10 @@
//! this is evidence about what the new code does, on the same photographs, with //! this is evidence about what the new code does, on the same photographs, with
//! nothing between the two runs but the resolution. //! nothing between the two runs but the resolution.
//! //!
//! `--export DIR` also writes each native render out as a JPEG, so the
//! model-free tooling in `dr-face`'s examples — `eyes` above all — can be
//! run over native pixels rather than proxies (docs/faces.md §17.4).
//!
//! The models must have had their input dims frozen first; see //! The models must have had their input dims frozen first; see
//! `tools/fix-face-model-shapes.sh`. //! `tools/fix-face-model-shapes.sh`.
@@ -23,11 +27,18 @@ const MODEL_ID: &str = "w600k_mbf";
fn main() { fn main() {
env_logger::init(); env_logger::init();
let args: Vec<String> = std::env::args().skip(1).collect(); let mut args: Vec<String> = std::env::args().skip(1).collect();
let export = args.iter().position(|a| a == "--export").map(|i| {
args.remove(i);
PathBuf::from(args.remove(i))
});
if args.len() < 3 { if args.len() < 3 {
eprintln!("usage: face_native DETECTOR.onnx EMBEDDER.onnx FILE [FILE…]"); eprintln!("usage: face_native DETECTOR.onnx EMBEDDER.onnx [--export DIR] FILE [FILE…]");
std::process::exit(2); std::process::exit(2);
} }
if let Some(d) = &export {
std::fs::create_dir_all(d).expect("export dir");
}
let (detector_model, embedder_model) = (PathBuf::from(&args[0]), PathBuf::from(&args[1])); let (detector_model, embedder_model) = (PathBuf::from(&args[0]), PathBuf::from(&args[1]));
let Some(gpu) = pollster::block_on(dr_gpu::GpuContext::new_headless()).ok() else { let Some(gpu) = pollster::block_on(dr_gpu::GpuContext::new_headless()).ok() else {
@@ -76,10 +87,27 @@ fn main() {
} }
}; };
let (w, h) = (frame.width as usize, frame.height as usize); let (w, h) = (frame.width as usize, frame.height as usize);
if let Some(dir) = &export {
let native = let stem = std::path::Path::new(path)
dr_ui::faces::index_native(&mut detector, &mut embedder, &frame.rgba, w, h, &options) .file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(); .unwrap_or_default();
match dr_thumbs::codec::encode_rgba(frame.width, frame.height, &frame.rgba) {
Ok(bytes) => std::fs::write(dir.join(format!("{stem}.jpg")), bytes).expect("write"),
Err(e) => println!("{path}: export: {e}"),
}
}
let native = dr_ui::faces::index_native(
&mut detector,
&mut embedder,
None,
&frame.rgba,
w,
h,
&options,
)
.unwrap_or_default();
// The old path, reproduced exactly: one buffer at 1024, used for both // The old path, reproduced exactly: one buffer at 1024, used for both
// detection and the crop. // detection and the crop.
@@ -89,9 +117,10 @@ fn main() {
rgba: frame.rgba.clone(), rgba: frame.rgba.clone(),
}; };
small.downscale_to(dr_thumbs::ThumbSize::Large.edge()); small.downscale_to(dr_thumbs::ThumbSize::Large.edge());
let proxy = dr_ui::faces::index_preview(&mut detector, &mut embedder, &small, &options) let proxy =
.map(|(f, _)| f) dr_ui::faces::index_preview(&mut detector, &mut embedder, None, &small, &options)
.unwrap_or_default(); .map(|(f, _)| f)
.unwrap_or_default();
let mean = |v: &[dr_catalog::faces::DetectedFace]| { let mean = |v: &[dr_catalog::faces::DetectedFace]| {
if v.is_empty() { if v.is_empty() {
+152 -7
View File
@@ -28,7 +28,10 @@ use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::faces::{self, DetectedFace}; use dr_catalog::faces::{self, DetectedFace};
use dr_catalog::Catalog; 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_thumbs::{ThumbSize, ThumbStore};
use dr_types::settings::FaceSettings; use dr_types::settings::FaceSettings;
use dr_types::ImageId; use dr_types::ImageId;
@@ -154,6 +157,14 @@ pub struct IndexAudit {
/// Outstanding with no proxy on disk. Costs a range request, same as the /// Outstanding with no proxy on disk. Costs a range request, same as the
/// ones above now do. /// ones above now do.
pub awaiting_proxy: u64, 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 { impl IndexAudit {
@@ -188,8 +199,19 @@ impl IndexAudit {
if outstanding > 0 { if outstanding > 0 {
s.push_str(&format!("; {outstanding} to index")); s.push_str(&format!("; {outstanding} to index"));
} }
if self.unmeasured > 0 {
s.push_str(&format!("; {} face(s) to measure", self.unmeasured));
}
s 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. /// 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", /// 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 /// and the one to run before deciding whether to start a sweep. Cheap: two
/// counts and one indexed scan, no decoding and no inference. /// 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( pub fn audit(
catalog: &Catalog, catalog: &Catalog,
store: &ThumbStore, store: &ThumbStore,
model_id: &str, model_id: &str,
eyes: bool,
) -> Result<IndexAudit, dr_catalog::CatalogError> { ) -> Result<IndexAudit, dr_catalog::CatalogError> {
let conn = catalog.connection(); let conn = catalog.connection();
let coverage = faces::coverage(conn, model_id)?; 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 // Split the outstanding set by whether a proxy exists. This is the query
// `faces_outstanding` runs without the store filter, so the two cannot // `faces_outstanding` runs without the store filter, so the two cannot
@@ -240,9 +268,40 @@ pub fn audit(
coverage, coverage,
ready, ready,
awaiting_proxy: awaiting, 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. /// Detect and embed every face in one decoded proxy.
/// ///
/// Coordinates come back **normalised to the long edge**, which is what the /// Coordinates come back **normalised to the long edge**, which is what the
@@ -256,6 +315,7 @@ pub fn audit(
pub fn index_proxy( pub fn index_proxy(
detector: &mut Detector, detector: &mut Detector,
embedder: &mut Embedder, embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgb: &[f32], rgb: &[f32],
width: usize, width: usize,
height: usize, height: usize,
@@ -310,6 +370,14 @@ pub fn index_proxy(
} }
let embedded = embedder.embed(&aligned)?; 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 { out.push(DetectedFace {
x: d.bbox.0 / long_edge, x: d.bbox.0 / long_edge,
@@ -321,6 +389,7 @@ pub fn index_proxy(
embedding: embedded.to_f16_bytes(), embedding: embedded.to_f16_bytes(),
crop_px: aligned.source_px(), crop_px: aligned.source_px(),
quality: Some(embedded.quality), quality: Some(embedded.quality),
eyes,
model_id: embedder.model().as_str().to_string(), model_id: embedder.model().as_str().to_string(),
// Cut here, while the buffer is still in hand. This is the only // Cut here, while the buffer is still in hand. This is the only
// moment in the whole pipeline where the pixels are free. // moment in the whole pipeline where the pixels are free.
@@ -370,6 +439,7 @@ const DETECT_EDGE: usize = 1600;
pub fn index_native( pub fn index_native(
detector: &mut Detector, detector: &mut Detector,
embedder: &mut Embedder, embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgba: &[u8], rgba: &[u8],
width: usize, width: usize,
height: usize, height: usize,
@@ -426,6 +496,17 @@ pub fn index_native(
let embedded = embedder.embed(&aligned)?; let embedded = embedder.embed(&aligned)?;
let (bx, by) = (d.bbox.0 * sx, d.bbox.1 * sy); let (bx, by) = (d.bbox.0 * sx, d.bbox.1 * sy);
let (bw, bh) = (d.width() * sx, d.height() * 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 { out.push(DetectedFace {
x: bx / long_edge, x: bx / long_edge,
@@ -437,6 +518,7 @@ pub fn index_native(
embedding: embedded.to_f16_bytes(), embedding: embedded.to_f16_bytes(),
crop_px: aligned.source_px(), crop_px: aligned.source_px(),
quality: Some(embedded.quality), quality: Some(embedded.quality),
eyes,
model_id: embedder.model().as_str().to_string(), model_id: embedder.model().as_str().to_string(),
crop: cut_crop_native(native, width, height, (bx, by, bw, bh)).unwrap_or_default(), 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 /// 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 /// quality itself, and a face that would have failed a gate is precisely one
/// that should come out short and stop vouching for anyone. /// 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( pub fn measure_native(
embedder: &mut Embedder, embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgba: &[u8], rgba: &[u8],
width: usize, width: usize,
height: usize, height: usize,
@@ -494,10 +582,29 @@ pub fn measure_native(
continue; continue;
}; };
let embedded = embedder.embed(&aligned)?; 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 { out.measured.push(faces::Measurement {
face: f.id, face: f.id,
embedding: embedded.to_f16_bytes(), embedding: embedded.to_f16_bytes(),
quality: embedded.quality, quality: embedded.quality,
eyes,
}); });
} }
Ok(out) 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( pub fn spawn_store_face_sweep(
catalog_path: PathBuf, catalog_path: PathBuf,
store_dir: PathBuf, store_dir: PathBuf,
detector_model: PathBuf, models: crate::FaceModelPaths,
embedder_model: PathBuf,
model_id: String, model_id: String,
options: DetectOptions, options: DetectOptions,
) -> Receiver<FaceSweepMessage> { ) -> Receiver<FaceSweepMessage> {
@@ -696,7 +802,7 @@ pub fn spawn_store_face_sweep(
// missing. This is also the path a library with face indexing enabled // 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 // but no model downloaded takes (docs/faces.md §2.2), so it must be a
// quiet return rather than an error. // 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, Ok(d) => d,
Err(e) => { Err(e) => {
log::warn!("face sweep: cannot load the detector: {e}"); log::warn!("face sweep: cannot load the detector: {e}");
@@ -705,7 +811,7 @@ pub fn spawn_store_face_sweep(
} }
}; };
let mut embedder = 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, Ok(e) => e,
Err(e) => { Err(e) => {
log::warn!("face sweep: cannot load the embedder: {e}"); log::warn!("face sweep: cannot load the embedder: {e}");
@@ -713,6 +819,7 @@ pub fn spawn_store_face_sweep(
return; return;
} }
}; };
let mut eye_models = models.load_eyes();
// Nothing below this point can succeed, so do not pretend to try. // 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( let faces = match index_proxy(
&mut detector, &mut detector,
&mut embedder, &mut embedder,
eye_models.as_mut(),
&rgb, &rgb,
w as usize, w as usize,
h 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( pub fn index_preview(
detector: &mut Detector, detector: &mut Detector,
embedder: &mut Embedder, embedder: &mut Embedder,
eye_models: Option<&mut EyeModels>,
preview: &dr_decode::Preview, preview: &dr_decode::Preview,
options: &DetectOptions, options: &DetectOptions,
) -> Result<(Vec<DetectedFace>, u32), dr_face::FaceError> { ) -> Result<(Vec<DetectedFace>, u32), dr_face::FaceError> {
@@ -1367,6 +1476,7 @@ pub fn index_preview(
let faces = index_proxy( let faces = index_proxy(
detector, detector,
embedder, embedder,
eye_models,
&rgb, &rgb,
preview.width as usize, preview.width as usize,
preview.height as usize, preview.height as usize,
@@ -1499,6 +1609,7 @@ mod tests {
confidence: 0.9, confidence: 0.9,
crop_px: 280.0, crop_px: 280.0,
quality: None, quality: None,
eyes: None,
model_id: "w600k_mbf".into(), model_id: "w600k_mbf".into(),
person: None, person: None,
probability: 0.0, probability: 0.0,
@@ -1506,7 +1617,7 @@ mod tests {
}; };
let stored_copy = stored.clone(); 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!(out.dropped.is_empty());
assert_eq!(out.measured.len(), 1); assert_eq!(out.measured.len(), 1);
let m = &out.measured[0]; let m = &out.measured[0];
@@ -1526,7 +1637,7 @@ mod tests {
landmarks: [(0.3, 0.3); 5], landmarks: [(0.3, 0.3); 5],
..stored_copy ..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!(out.measured.is_empty());
assert_eq!(out.dropped, vec![faces::FaceId(8)]); assert_eq!(out.dropped, vec![faces::FaceId(8)]);
} }
@@ -1552,6 +1663,7 @@ mod tests {
}, },
ready: 30, ready: 30,
awaiting_proxy: 10, awaiting_proxy: 10,
unmeasured: 0,
}; };
let s = a.summary(); let s = a.summary();
assert!(s.contains("60/100"), "{s}"); assert!(s.contains("60/100"), "{s}");
@@ -1577,11 +1689,40 @@ mod tests {
}, },
ready: 0, ready: 0,
awaiting_proxy: 0, awaiting_proxy: 0,
unmeasured: 0,
}; };
let s = a.summary(); let s = a.summary();
assert!(!s.contains("ready"), "{s}"); assert!(!s.contains("ready"), "{s}");
assert!(!s.contains("awaiting"), "{s}"); assert!(!s.contains("awaiting"), "{s}");
assert!(!s.contains("measure"), "{s}");
assert!(s.contains("10/10"), "{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 /// The figure the real library actually produced: 110 of 23,528 rounds to
@@ -1597,6 +1738,7 @@ mod tests {
}, },
ready: 69, ready: 69,
awaiting_proxy: 23_349, awaiting_proxy: 23_349,
unmeasured: 0,
}; };
let s = a.summary(); let s = a.summary();
assert!(s.contains("0.5%"), "{s}"); assert!(s.contains("0.5%"), "{s}");
@@ -1614,6 +1756,7 @@ mod tests {
}, },
ready: 99_999, ready: 99_999,
awaiting_proxy: 0, awaiting_proxy: 0,
unmeasured: 0,
}; };
assert!(a.summary().contains("<0.1%"), "{}", a.summary()); assert!(a.summary().contains("<0.1%"), "{}", a.summary());
} }
@@ -1663,6 +1806,7 @@ mod tests {
confidence: 0.9, confidence: 0.9,
crop_px: 120.0, crop_px: 120.0,
quality: None, quality: None,
eyes: None,
model_id: "w600k_mbf".into(), model_id: "w600k_mbf".into(),
person: None, person: None,
probability: 0.0, probability: 0.0,
@@ -1781,6 +1925,7 @@ mod tests {
embedding: embedding(identity, cosine), embedding: embedding(identity, cosine),
crop_px: 150.0, crop_px: 150.0,
quality: None, quality: None,
eyes: None,
model_id: TEST_MODEL.to_string(), model_id: TEST_MODEL.to_string(),
crop: Vec::new(), crop: Vec::new(),
}; };
+7
View File
@@ -239,6 +239,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Shift-click the last photograph of the run", pointer: "Shift-click the last photograph of the run",
keys: "", 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 { Gesture {
title: "Find photographs with two people in them", title: "Find photographs with two people in them",
section: "Library grid", 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 /// of its raw embedding (`dr_face::MIN_GALLERY_QUALITY`). `None` for a
/// face indexed before it was kept. /// face indexed before it was kept.
pub quality: Option<f32>, 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 { impl FaceCell {
@@ -144,6 +147,22 @@ impl FaceCell {
pub fn in_gallery(&self) -> bool { pub fn in_gallery(&self) -> bool {
dr_face::in_gallery(self.quality) 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. /// Put a grouping preview into words.
@@ -284,6 +303,7 @@ pub fn load_faces(
probability: f.probability, probability: f.probability,
crop_px: f.crop_px, crop_px: f.crop_px,
quality: f.quality, quality: f.quality,
eyes: f.eyes,
}); });
} }
@@ -787,6 +807,7 @@ mod tests {
embedding: vec![seed; 1024], embedding: vec![seed; 1024],
crop_px: 180.0, crop_px: 180.0,
quality: Some(f32::from(seed) + 10.0), quality: Some(f32::from(seed) + 10.0),
eyes: None,
model_id: "w600k_mbf".into(), model_id: "w600k_mbf".into(),
crop: Vec::new(), crop: Vec::new(),
} }
@@ -835,6 +856,7 @@ mod tests {
probability: 0.87, probability: 0.87,
crop_px: 120.0, crop_px: 120.0,
quality: Some(17.26), quality: Some(17.26),
eyes: None,
}; };
assert_eq!(cell.confidence_label(), "87% likely"); assert_eq!(cell.confidence_label(), "87% likely");
@@ -858,6 +880,7 @@ mod tests {
probability: 0.5, probability: 0.5,
crop_px: 120.0, crop_px: 120.0,
quality: Some(17.26), quality: Some(17.26),
eyes: None,
}; };
assert_eq!(cell.quality_label(), "Quality 17.3"); assert_eq!(cell.quality_label(), "Quality 17.3");
assert!(cell.in_gallery()); 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] #[test]
fn the_people_rail_reports_the_unassigned_pool() { fn the_people_rail_reports_the_unassigned_pool() {
let c = catalog(); let c = catalog();
+73 -25
View File
@@ -153,6 +153,7 @@ pub fn refresh(
catalog: &Rc<RefCell<Option<Catalog>>>, catalog: &Rc<RefCell<Option<Catalog>>>,
store: Option<Rc<ThumbStore>>, store: Option<Rc<ThumbStore>>,
model_id: &str, model_id: &str,
eyes: bool,
) { ) {
let borrow = catalog.borrow(); let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { let Some(cat) = borrow.as_ref() else {
@@ -273,7 +274,7 @@ pub fn refresh(
window.set_identity_picked(ctl.picked.borrow().len() as i32); window.set_identity_picked(ctl.picked.borrow().len() as i32);
drop(borrow); 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. // Last, so a portrait cannot delay anything above it.
if let Some(store) = store { 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 /// Cheap enough to call on every open and after every sweep: two counts and one
/// indexed scan, no decoding and no inference. /// 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( pub fn refresh_coverage(
window: &AppWindow, window: &AppWindow,
catalog: &Rc<RefCell<Option<Catalog>>>, catalog: &Rc<RefCell<Option<Catalog>>>,
store: Option<&ThumbStore>, store: Option<&ThumbStore>,
model_id: &str, model_id: &str,
eyes: bool,
) { ) {
let borrow = catalog.borrow(); let borrow = catalog.borrow();
let (Some(cat), Some(store)) = (borrow.as_ref(), store) else { let (Some(cat), Some(store)) = (borrow.as_ref(), store) else {
window.set_identity_coverage(Default::default()); window.set_identity_coverage(Default::default());
return; return;
}; };
match crate::faces::audit(cat, store, model_id) { match crate::faces::audit(cat, store, model_id, eyes) {
Ok(a) => { Ok(a) => {
window.set_identity_coverage(a.summary().into()); window.set_identity_coverage(a.summary().into());
// Complete means nothing left to index, not "every image has a // Complete means nothing left to index or measure, not "every
// face": most of a library has none, and that is a finding. // image has a face": most of a library has none, and that is a
window.set_identity_coverage_complete(a.coverage.is_complete()); // finding.
window.set_identity_coverage_complete(a.is_complete());
} }
Err(e) => { Err(e) => {
log::warn!("identity: coverage check: {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, crop_px: c.crop_px as i32,
quality: c.quality_label().into(), quality: c.quality_label().into(),
in_gallery: c.in_gallery(), in_gallery: c.in_gallery(),
eyes: c.eyes_label().into(),
picked: picked.contains(&c.face), picked: picked.contains(&c.face),
}) })
.collect(); .collect();
@@ -512,8 +518,9 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
/// `library::spawn_face_sweep`). /// `library::spawn_face_sweep`).
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf); pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
/// The detector and embedder files, when both are present. /// The detector and embedder files, when both are present — and the eye
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf); /// models beside them, when those are.
pub type ModelPaths = crate::library::FaceModelPaths;
/// Put the grouping dials on the screen from the settings record. /// 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, // second-order effects — a merge empties a person, a split creates one,
// a rejection changes two counts — and a model patched by hand would // a rejection changes two counts — and a model patched by hand would
// drift from the catalog in exactly the cases that matter. // 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 { macro_rules! reload {
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr) => { ($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr) => {
refresh(&$w, &$ctl, &$catalog, $store(), &model_id(&$settings)) refresh(
&$w,
&$ctl,
&$catalog,
$store(),
&model_id(&$settings),
$eyes(),
)
}; };
} }
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.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 // 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. // rather than cached: the user may have just put the models there.
w.set_identity_model_missing(models_present().is_none()); 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 weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.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 // The offer was about the person being navigated away from. Left
// up, its "Merge" would fold whoever is selected *now*. // up, its "Merge" would fold whoever is selected *now*.
clear_merge_offer(&w, &ctl); clear_merge_offer(&w, &ctl);
reload!(w, ctl, catalog, store, settings); reload!(w, ctl, catalog, store, settings, eyes_available);
reset_name_field(&w); reset_name_field(&w);
}); });
} }
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.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}"), 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 // `rename` trims; the field should show what was actually stored
// rather than the spacing the user happened to type. // rather than the spacing the user happened to type.
reset_name_field(&w); 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. // redirect behind so neither side of a sync resurrects what was merged.
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -743,7 +771,7 @@ pub fn wire<S, M, P>(
} }
} }
clear_merge_offer(&w, &ctl); clear_merge_offer(&w, &ctl);
reload!(w, ctl, catalog, store, settings); reload!(w, ctl, catalog, store, settings, eyes_available);
reset_name_field(&w); reset_name_field(&w);
}); });
} }
@@ -774,6 +802,7 @@ pub fn wire<S, M, P>(
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -788,12 +817,13 @@ pub fn wire<S, M, P>(
log::warn!("identity: confirm: {e}"); 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 weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -808,7 +838,7 @@ pub fn wire<S, M, P>(
log::warn!("identity: reject: {e}"); 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 weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -851,12 +882,13 @@ pub fn wire<S, M, P>(
Err(e) => log::warn!("identity: confirm all: {e}"), 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 weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -884,7 +916,7 @@ pub fn wire<S, M, P>(
} }
} }
ctl.clear_picks(); 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 store = store.clone();
let paths = paths.clone(); let paths = paths.clone();
let settings_for_regroup = settings.clone(); let settings_for_regroup = settings.clone();
let eyes_available = eyes_available.clone();
window.on_identity_recluster(move || { window.on_identity_recluster(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// One at a time. Two passes over the same faces would each create // 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 catalog_tick = catalog.clone();
let store_tick = store.clone(); let store_tick = store.clone();
let settings_tick = settings_for_regroup.clone(); let settings_tick = settings_for_regroup.clone();
let eyes_tick = eyes_available.clone();
timer.start( timer.start(
slint::TimerMode::Repeated, slint::TimerMode::Repeated,
Duration::from_millis(100), Duration::from_millis(100),
@@ -1041,6 +1075,7 @@ pub fn wire<S, M, P>(
&catalog_tick, &catalog_tick,
store_tick(), store_tick(),
&model_id(&settings_tick), &model_id(&settings_tick),
eyes_tick(),
); );
} }
}, },
@@ -1058,12 +1093,13 @@ pub fn wire<S, M, P>(
let paths = paths.clone(); let paths = paths.clone();
let gpu = gpu.clone(); let gpu = gpu.clone();
let settings_for_sweep = settings.clone(); let settings_for_sweep = settings.clone();
let eyes_available = eyes_available.clone();
window.on_identity_index(move || { window.on_identity_index(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if ctl.sweep.borrow().is_some() { if ctl.sweep.borrow().is_some() {
return; return;
} }
let Some((detector, embedder)) = models() else { let Some(models) = models() else {
w.set_identity_model_missing(true); w.set_identity_model_missing(true);
return; return;
}; };
@@ -1086,8 +1122,7 @@ pub fn wire<S, M, P>(
conn, conn,
catalog_path, catalog_path,
store_dir, store_dir,
detector, models,
embedder,
model_id(&settings_for_sweep), model_id(&settings_for_sweep),
settings_for_sweep settings_for_sweep
.snapshot() .snapshot()
@@ -1114,6 +1149,7 @@ pub fn wire<S, M, P>(
let catalog_tick = catalog.clone(); let catalog_tick = catalog.clone();
let store_tick = store.clone(); let store_tick = store.clone();
let settings_tick = settings_for_sweep.clone(); let settings_tick = settings_for_sweep.clone();
let eyes_tick = eyes_available.clone();
timer.start( timer.start(
slint::TimerMode::Repeated, slint::TimerMode::Repeated,
Duration::from_millis(250), Duration::from_millis(250),
@@ -1202,6 +1238,7 @@ pub fn wire<S, M, P>(
&catalog_tick, &catalog_tick,
store_tick(), store_tick(),
&model_id(&settings_tick), &model_id(&settings_tick),
eyes_tick(),
); );
} }
}, },
@@ -1214,6 +1251,7 @@ pub fn wire<S, M, P>(
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -1228,7 +1266,7 @@ pub fn wire<S, M, P>(
a.finish("stopped"); a.finish("stopped");
} }
w.set_identity_indexing(false); 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 catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
let settings = settings.clone(); let settings = settings.clone();
let eyes_available = eyes_available.clone();
window.on_identity_check_coverage(move || { window.on_identity_check_coverage(move || {
let Some(w) = weak.upgrade() else { return }; 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 weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -1265,12 +1311,13 @@ pub fn wire<S, M, P>(
ctl.selected.set(None); ctl.selected.set(None);
ctl.clear_picks(); ctl.clear_picks();
} }
reload!(w, ctl, catalog, store, settings); reload!(w, ctl, catalog, store, settings, eyes_available);
}); });
} }
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -1278,12 +1325,13 @@ pub fn wire<S, M, P>(
window.on_identity_toggle_show_ignored(move || { window.on_identity_toggle_show_ignored(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.show_ignored.set(!ctl.show_ignored.get()); 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 weak = window.as_weak();
let eyes_available = eyes_available.clone();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
@@ -1299,7 +1347,7 @@ pub fn wire<S, M, P>(
ctl.selected.set(None); ctl.selected.set(None);
ctl.clear_picks(); ctl.clear_picks();
ctl.covers.borrow_mut().clear(); 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 /// APK may carry a bundled copy and has to write it out before any store is
/// opened — see `library::shared_face_models_dir`. /// opened — see `library::shared_face_models_dir`.
pub use 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. /// The scene model's three files, wherever this device keeps them.
/// ///
@@ -3894,18 +3895,17 @@ fn refresh_face_status(
let store = library let store = library
.session() .session()
.and_then(|c| dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()); .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( identity_ui::refresh_coverage(
window, window,
&library.catalog(), &library.catalog(),
store.as_ref(), store.as_ref(),
detector.model_id(), detector.model_id(),
models.as_ref().is_some_and(|m| m.eyes.is_some()),
); );
window.set_identity_model_missing( window.set_identity_model_missing(models.is_none());
library
.session()
.and_then(|c| library::face_models(&c.account, detector))
.is_none(),
);
} }
fn describe_cache_usage(library: &Rc<library_ui::LibraryController>) -> String { 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>, pub people: Vec<u64>,
/// Whether [`RatingFilter::people`] is a union or an intersection. /// Whether [`RatingFilter::people`] is a union or an intersection.
pub people_mode: PeopleMode, 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. /// How several people combine when the grid is narrowed by identity.
@@ -351,6 +371,7 @@ impl RatingFilter {
&& self.captured_from.is_none() && self.captured_from.is_none()
&& self.captured_to.is_none() && self.captured_to.is_none()
&& self.people.is_empty() && self.people.is_empty()
&& !self.eyes_open
} }
/// Whether a date range is narrowing the grid. /// 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() { if !self.people.is_empty() {
// Integers this code owns, like every other term here — the ids // Integers this code owns, like every other term here — the ids
// come from the catalog, never from typed text, so there is // come from the catalog, never from typed text, so there is
@@ -442,7 +498,7 @@ impl RatingFilter {
PeopleMode::Any => format!( PeopleMode::Any => format!(
"EXISTS (SELECT 1 FROM faces f "EXISTS (SELECT 1 FROM faces f
JOIN face_person fp ON fp.face_id = f.id 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 // Counting *distinct* people rather than ANDing one EXISTS per
// person: same result, one subquery instead of n, and it does // person: same result, one subquery instead of n, and it does
@@ -452,10 +508,18 @@ impl RatingFilter {
PeopleMode::All => format!( PeopleMode::All => format!(
"(SELECT COUNT(DISTINCT fp.person_id) FROM faces f "(SELECT COUNT(DISTINCT fp.person_id) FROM faces f
JOIN face_person fp ON fp.face_id = f.id 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() 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 { 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, /// there is no embedding without the pixels. What it saves is the detector,
/// and — the part that matters — every suggestion and confirmation on those /// and — the part that matters — every suggestion and confirmation on those
/// faces, which a re-detection would rebuild from box overlap. /// 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( fn faces_unmeasured(
catalog: &Catalog, catalog: &Catalog,
model_id: &str, model_id: &str,
eyes: bool,
) -> Result<Vec<ThumbnailRequest>, dr_catalog::CatalogError> { ) -> Result<Vec<ThumbnailRequest>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(&format!( let mut stmt = catalog.connection().prepare(&format!(
"SELECT DISTINCT i.id, i.source_ref, r.file_id, i.file_size "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 remote r ON r.image_id = i.id
JOIN faces f ON f.image_id = i.id JOIN faces f ON f.image_id = i.id
WHERE r.file_id IS NOT NULL AND {VISIBLE} 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", ORDER BY i.id",
embedder = dr_catalog::faces::embedder_sql("f.model_id"), embedder = dr_catalog::faces::embedder_sql("f.model_id"),
unmeasured = dr_catalog::faces::unmeasured_sql("f.", eyes),
))?; ))?;
let rows = stmt let rows = stmt
.query_map([dr_catalog::faces::embedder_of(model_id)], |r| { .query_map([dr_catalog::faces::embedder_of(model_id)], |r| {
@@ -4095,8 +4166,7 @@ pub fn spawn_face_sweep(
conn: Connection, conn: Connection,
catalog_path: PathBuf, catalog_path: PathBuf,
store_dir: PathBuf, store_dir: PathBuf,
detector_model: PathBuf, models: FaceModelPaths,
embedder_model: PathBuf,
model_id: String, model_id: String,
supersedes: Vec<String>, supersedes: Vec<String>,
options: dr_face::DetectOptions, options: dr_face::DetectOptions,
@@ -4144,7 +4214,7 @@ pub fn spawn_face_sweep(
// listing twenty thousand images before discovering the weights are // listing twenty thousand images before discovering the weights are
// missing helps nobody. A library with no model installed takes this // missing helps nobody. A library with no model installed takes this
// path, so it is a quiet return rather than an error. // 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, Ok(d) => d,
Err(e) => { Err(e) => {
log::warn!("face sweep: cannot load the detector: {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( let mut embedder = match dr_face::Embedder::from_path(
&embedder_model, &models.embedder,
dr_face::ModelId::new(model_id.clone()), dr_face::ModelId::new(model_id.clone()),
) { ) {
Ok(e) => e, Ok(e) => e,
@@ -4163,6 +4233,9 @@ pub fn spawn_face_sweep(
return; 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 // **Repairs first, and the order is the whole point.** These are the
// images the People screen is drawing *right now* and failing to, and // 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. // gets its quality from that, so it is not queued twice.
let mut queued: std::collections::HashSet<i64> = let mut queued: std::collections::HashSet<i64> =
wanted.iter().map(|(r, _)| r.image_id).collect(); 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) => { Ok(measure) => {
let fresh: Vec<_> = measure let fresh: Vec<_> = measure
.into_iter() .into_iter()
@@ -4373,6 +4446,7 @@ pub fn spawn_face_sweep(
match measure_one_native( match measure_one_native(
&gpu, &gpu,
&mut embedder, &mut embedder,
eye_models.as_mut(),
&catalog, &catalog,
image, image,
&model_id, &model_id,
@@ -4398,7 +4472,14 @@ pub fn spawn_face_sweep(
continue; 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)) => { Ok((faces, edge, proxy)) => {
// Before the detections, so a kill between the two // Before the detections, so a kill between the two
// leaves a proxy with no faces recorded -- which // leaves a proxy with no faces recorded -- which
@@ -4577,6 +4658,7 @@ fn index_one_native(
gpu: &dr_gpu::GpuContext, gpu: &dr_gpu::GpuContext,
detector: &mut dr_face::Detector, detector: &mut dr_face::Detector,
embedder: &mut dr_face::Embedder, embedder: &mut dr_face::Embedder,
eye_models: Option<&mut dr_face::EyeModels>,
bytes: &[u8], bytes: &[u8],
options: &dr_face::DetectOptions, options: &dr_face::DetectOptions,
) -> Result< ) -> Result<
@@ -4594,6 +4676,7 @@ fn index_one_native(
let faces = crate::faces::index_native( let faces = crate::faces::index_native(
detector, detector,
embedder, embedder,
eye_models,
&frame.rgba, &frame.rgba,
frame.width as usize, frame.width as usize,
frame.height as usize, frame.height as usize,
@@ -4647,13 +4730,19 @@ fn index_one_native(
fn measure_one_native( fn measure_one_native(
gpu: &dr_gpu::GpuContext, gpu: &dr_gpu::GpuContext,
embedder: &mut dr_face::Embedder, embedder: &mut dr_face::Embedder,
eye_models: Option<&mut dr_face::EyeModels>,
catalog: &Catalog, catalog: &Catalog,
image: dr_types::ImageId, image: dr_types::ImageId,
model_id: &str, model_id: &str,
bytes: &[u8], bytes: &[u8],
) -> Result<usize, String> { ) -> Result<usize, String> {
let faces = dr_catalog::faces::unmeasured_on_image(catalog.connection(), image, model_id) let faces = dr_catalog::faces::unmeasured_on_image(
.map_err(|e| e.to_string())?; catalog.connection(),
image,
model_id,
eye_models.is_some(),
)
.map_err(|e| e.to_string())?;
if faces.is_empty() { if faces.is_empty() {
return Ok(0); return Ok(0);
} }
@@ -4661,6 +4750,7 @@ fn measure_one_native(
let edge = frame.width.max(frame.height); let edge = frame.width.max(frame.height);
let measured = crate::faces::measure_native( let measured = crate::faces::measure_native(
embedder, embedder,
eye_models,
&frame.rgba, &frame.rgba,
frame.width as usize, frame.width as usize,
frame.height as usize, frame.height as usize,
@@ -5056,7 +5146,53 @@ pub fn shared_face_models_dir() -> PathBuf {
data_root().join("models") 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 /// 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 /// 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 /// 3. **The system directories.** Where a package installs them — the Arch
/// package puts the pair in `/usr/share/darkroom/models`. Last, so anything /// package puts the pair in `/usr/share/darkroom/models`. Last, so anything
/// the user placed themselves outranks what the package shipped. /// the user placed themselves outranks what the package shipped.
pub fn face_models( pub fn face_models(account: &Account, detector: dr_types::FaceDetector) -> Option<FaceModelPaths> {
account: &Account,
detector: dr_types::FaceDetector,
) -> Option<(PathBuf, PathBuf)> {
let pair = |dir: &PathBuf| { 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"); 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()]; let mut searched = vec![face_models_dir(account), shared_face_models_dir()];
searched.extend(system_face_models_dirs()); searched.extend(system_face_models_dirs());
let found = searched.iter().find_map(pair); let found = searched.iter().find_map(pair);
if found.is_none() { match &found {
// The settings page can only say "not installed". This is the line None => {
// that says where it looked, which is the whole of what a user with // The settings page can only say "not installed". This is the line
// the files in the wrong place needs — and the first thing to read // that says where it looked, which is the whole of what a user with
// when a freshly installed package reports no model. // the files in the wrong place needs — and the first thing to read
log::warn!( // when a freshly installed package reports no model.
"face models: no directory holds both {} and arcface_mbf_b1.onnx; searched {}", log::warn!(
detector.file_name(), "face models: no directory holds both {} and arcface_mbf_b1.onnx; searched {}",
searched detector.file_name(),
.iter() searched
.map(|d| d.display().to_string()) .iter()
.collect::<Vec<_>>() .map(|d| d.display().to_string())
.join(", ") .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 found
} }
@@ -6773,6 +6925,7 @@ mod tests {
embedding: vec![0u8; 1024], embedding: vec![0u8; 1024],
crop_px: 120.0, crop_px: 120.0,
quality: None, quality: None,
eyes: None,
crop: Vec::new(), crop: Vec::new(),
model_id: "w600k_mbf".into(), model_id: "w600k_mbf".into(),
}; };
@@ -6823,6 +6976,7 @@ mod tests {
embedding: vec![0u8; 1024], embedding: vec![0u8; 1024],
crop_px: 120.0, crop_px: 120.0,
quality: None, quality: None,
eyes: None,
crop: Vec::new(), crop: Vec::new(),
model_id: "w600k_mbf".into(), model_id: "w600k_mbf".into(),
}; };
@@ -6979,6 +7133,7 @@ mod tests {
embedding: vec![0u8; 1024], embedding: vec![0u8; 1024],
crop_px: 120.0, crop_px: 120.0,
quality, quality,
eyes: None,
crop: Vec::new(), crop: Vec::new(),
model_id: "w600k_mbf".into(), model_id: "w600k_mbf".into(),
}; };
@@ -6996,7 +7151,7 @@ mod tests {
// What V14 does to the first: the marker goes, the face stays. // What V14 does to the first: the marker goes, the face stays.
dr_catalog::faces::clear_index_marker(conn, ids[0], "w600k_mbf").unwrap(); 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.len(), 1);
assert_eq!(measure[0].image_id, ids[0].0 as i64); assert_eq!(measure[0].image_id, ids[0].0 as i64);
assert!(measure[0].full_resolution); assert!(measure[0].full_resolution);
@@ -7006,6 +7161,13 @@ mod tests {
let detect = faces_unindexed(&catalog, "w600k_mbf").unwrap(); let detect = faces_unindexed(&catalog, "w600k_mbf").unwrap();
assert_eq!(detect.len(), 1); assert_eq!(detect.len(), 1);
assert_eq!(detect[0].image_id, ids[2].0 as i64); 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] #[test]
@@ -7968,6 +8130,7 @@ mod tests {
embedding: vec![0u8; 1024], embedding: vec![0u8; 1024],
crop_px: 120.0, crop_px: 120.0,
quality: None, quality: None,
eyes: None,
crop: Vec::new(), crop: Vec::new(),
model_id: "w600k_mbf".into(), model_id: "w600k_mbf".into(),
}; };
@@ -8095,6 +8258,223 @@ mod tests {
assert!(f.is_unfiltered()); assert!(f.is_unfiltered());
assert_eq!(total_images_scoped(&catalog, None, &f).unwrap(), 3); 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 /// 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); window.set_library_local_only(filter.local_only);
ctl.local_only.set(filter.local_only); ctl.local_only.set(filter.local_only);
window.set_library_filter_eyes_open(filter.eyes_open);
push_people_chips(window, ctl); push_people_chips(window, ctl);
} }
@@ -6628,6 +6629,7 @@ pub fn wire<F>(
.borrow_mut() .borrow_mut()
.people .people
.retain(|p| *p != id.max(0) as u64); .retain(|p| *p != id.max(0) as u64);
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl); push_people_chips(&w, &ctl);
refilter(&w, &ctl); refilter(&w, &ctl);
}); });
@@ -6656,11 +6658,27 @@ pub fn wire<F>(
f.people.push(person); f.people.push(person);
} }
} }
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl); push_people_chips(&w, &ctl);
refilter(&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 weak = window.as_weak();
let ctl = ctl.clone(); 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 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 /// the same group does not read one way on one screen and another way on the
/// next. /// 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>) { fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
let ids = ctl.filter.borrow().people.clone(); let ids = ctl.filter.borrow().people.clone();
if ids.is_empty() { if ids.is_empty() {
+4
View File
@@ -31,6 +31,7 @@ impl From<&RatingFilter> for StoredFilter {
captured_to: f.captured_to, captured_to: f.captured_to,
people: f.people.clone(), people: f.people.clone(),
people_all: matches!(f.people_mode, PeopleMode::All), people_all: matches!(f.people_mode, PeopleMode::All),
eyes_open: f.eyes_open,
} }
} }
} }
@@ -50,6 +51,7 @@ impl From<&StoredFilter> for RatingFilter {
} else { } else {
PeopleMode::Any PeopleMode::Any
}, },
eyes_open: s.eyes_open,
} }
} }
} }
@@ -183,6 +185,7 @@ mod tests {
captured_to: Some(2), captured_to: Some(2),
people: vec![7, 9], people: vec![7, 9],
people_all: true, people_all: true,
eyes_open: true,
}, },
} }
} }
@@ -304,6 +307,7 @@ mod tests {
captured_to: Some(20), captured_to: Some(20),
people: vec![3, 5, 8], people: vec![3, 5, 8],
people_mode: PeopleMode::All, people_mode: PeopleMode::All,
eyes_open: true,
}; };
let back: RatingFilter = (&StoredFilter::from(&original)).into(); let back: RatingFilter = (&StoredFilter::from(&original)).into();
assert_eq!(back, original); assert_eq!(back, original);
+6
View File
@@ -698,6 +698,10 @@ export component AppWindow inherits Window {
in property <bool> library-filter-people-all: false; in property <bool> library-filter-people-all: false;
callback library-filter-person-cleared(int); callback library-filter-person-cleared(int);
callback library-filter-people-mode-toggled(); callback library-filter-people-mode-toggled();
/// TRACES: FR-CULL-13
/// Whether the grid is narrowed to photographs with nobody blinking.
in property <bool> library-filter-eyes-open: false;
callback library-filter-eyes-open-toggled();
/// Everyone the library knows, for the filter bar's people tray. /// Everyone the library knows, for the filter bar's people tray.
/// TRACES: FR-UI-4 /// TRACES: FR-UI-4
/// The gesture reference's rows, read from the generated table. /// The gesture reference's rows, read from the generated table.
@@ -1701,6 +1705,7 @@ in property <bool> panel-visible: true;
filter-people: root.library-filter-people; filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all; filter-people-all: root.library-filter-people-all;
filter-eyes-open: root.library-filter-eyes-open;
people: root.library-people; people: root.library-people;
gestures: root.library-gestures; gestures: root.library-gestures;
filter-min-rating: root.library-filter-min-rating; filter-min-rating: root.library-filter-min-rating;
@@ -1739,6 +1744,7 @@ in property <bool> panel-visible: true;
filter-flag-changed(f) => { root.library-filter-flag-changed(f); } filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
filter-person-cleared(id) => { root.library-filter-person-cleared(id); } filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); } filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
filter-eyes-open-toggled() => { root.library-filter-eyes-open-toggled(); }
people-listed() => { root.library-people-listed(); } people-listed() => { root.library-people-listed(); }
filter-person-toggled(id) => { root.library-filter-person-toggled(id); } filter-person-toggled(id) => { root.library-filter-person-toggled(id); }
} }
+27
View File
@@ -60,6 +60,12 @@ export struct IdentityFace {
// group that has gathered none of a person's other photographs has an // group that has gathered none of a person's other photographs has an
// explanation the user can only see if every member's label says it. // explanation the user can only see if every member's label says it.
in-gallery: bool, in-gallery: bool,
// TRACES: FR-CULL-13
// "Eyes closed", "Sunglasses" or "Eyes unclear", or empty — composed in
// Rust like the two above. Empty for open eyes and for a face never
// read, so the mark is only ever the reason a frame is or is not under
// the eyes-open chip.
eyes: string,
// Part of the current multi-select — what a split would carry. // Part of the current multi-select — what a split would carry.
picked: bool, picked: bool,
} }
@@ -141,6 +147,27 @@ component FaceCell inherits Rectangle {
} }
} }
// The eye state, over the head of the crop, and only where it
// is a blink, sunglasses or eyes too soft to read: the readings
// the eyes-open filter treats differently from open. Open eyes
// carry no mark for the reason the confirmed marker below gives.
if face.eyes != "": Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 16px;
background: Theme.ground.with-alpha(0.55);
Text {
text: face.eyes;
color: Theme.warn-ink;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
width: parent.width;
height: parent.height;
}
}
// A confirmed face carries a quiet marker rather than a badge: the // A confirmed face carries a quiet marker rather than a badge: the
// grid is mostly confirmed once the user has worked through it, and // grid is mostly confirmed once the user has worked through it, and
// a loud mark on the common case is just noise. // a loud mark on the common case is just noise.
+32 -2
View File
@@ -1509,6 +1509,11 @@ export component LibraryGrid inherits Rectangle {
in property <bool> filter-people-all: false; in property <bool> filter-people-all: false;
callback filter-person-cleared(int); callback filter-person-cleared(int);
callback filter-people-mode-toggled(); callback filter-people-mode-toggled();
/// TRACES: FR-CULL-13
/// Only photographs in which the chosen people are not blinking. Shown
/// and meaningful only while `filter-people` holds someone.
in property <bool> filter-eyes-open: false;
callback filter-eyes-open-toggled();
callback filter-min-rating-changed(int); callback filter-min-rating-changed(int);
callback filter-unjudged-toggled(bool); callback filter-unjudged-toggled(bool);
callback filter-flag-changed(int); callback filter-flag-changed(int);
@@ -2028,7 +2033,8 @@ export component LibraryGrid inherits Rectangle {
// Hidden while there is nothing to filter: an empty library offering // Hidden while there is nothing to filter: an empty library offering
// six rating buttons is chrome describing data that does not exist. // six rating buttons is chrome describing data that does not exist.
if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged
|| root.filter-flag > 0 || root.filter-people.length > 0: Rectangle { || root.filter-flag > 0 || root.filter-people.length > 0
|| root.filter-eyes-open: Rectangle {
height: 34px; height: 34px;
background: Theme.surface; background: Theme.surface;
@@ -2088,6 +2094,29 @@ export component LibraryGrid inherits Rectangle {
clicked => { root.filter-people-mode-toggled(); } clicked => { root.filter-people-mode-toggled(); }
} }
// Beside the people it applies to, and only with some chosen:
// "Anna, eyes open" is the question, and without a name in
// front of it the chip would be a judgement on everyone in
// the frame. A filter, never a verdict: it hides the blinks
// in a burst and rates nothing (FR-CULL-13).
// GESTURE: Take the blinks out of a burst
// where: 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
// why: Face indexing reads each face's eyes. The chip
// drops frames where the chosen people are caught
// blinking, and leaves sunglasses and eyes it could
// not read alone.
if root.filter-people.length > 0: FilterChip {
icon: "eye";
label: "Eyes open";
active: root.filter-eyes-open;
y: (parent.height - self.height) / 2;
clicked => { root.filter-eyes-open-toggled(); }
}
// The way in to the people tray, and the reason it exists. // The way in to the people tray, and the reason it exists.
// //
// Narrowing to *two* people at once was already possible and // Narrowing to *two* people at once was already possible and
@@ -2241,7 +2270,8 @@ export component LibraryGrid inherits Rectangle {
// single most confusing state a filter can leave behind. // single most confusing state a filter can leave behind.
Caption { Caption {
text: (root.filter-min-rating > 0 || root.filter-unjudged text: (root.filter-min-rating > 0 || root.filter-unjudged
|| root.filter-flag > 0 || root.local-only) || root.filter-flag > 0 || root.local-only
|| root.filter-eyes-open)
? "filtered" : ""; ? "filtered" : "";
emphasised: true; emphasised: true;
vertical-alignment: center; vertical-alignment: center;