Record that face detection has run, not just what it found
An image with no faces in it was indistinguishable from one that had never been looked at, so every landscape, still life and document scan in the library was re-detected on every pass, for ever. In a real library that is most of it: on the 23,527-image test library, 64 of the first 110 images indexed contain no face at all. Schema v9 adds face_index, a run marker per (image, model) carrying the face count and the proxy edge it read. Keyed on the model, so a model change puts every image back in the queue by itself. That makes a coverage figure possible, which is the thing a user actually wants to see. The audit also splits the outstanding set by whether a proxy exists, because 23,417 awaiting a proxy and 110 ready to index are different problems, and telling the user to run indexing again would not fix the first. The Identity screen gains Index faces, Stop, and the coverage line. examples/face_index.rs is the same check and sweep without a window, which is the right shape for an overnight pass. Measured on the real library in release: 3.5 images/second, 110 images and 125 faces in 30 seconds, and a second run correctly finds nothing left to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -107,3 +107,8 @@ default = []
|
||||
# without rebuilding. Costs the constant-folding of every token, so it stays
|
||||
# off by default and has no business in a release build.
|
||||
live-style = ["dep:serde_norway"]
|
||||
|
||||
[dev-dependencies]
|
||||
# The face_index batch job wants a log level from the environment; the library
|
||||
# itself only ever calls `log`, and picks up whatever the application installs.
|
||||
env_logger.workspace = true
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The face-indexing batch job, off the GUI.
|
||||
//!
|
||||
//! Checks every library image for a face-detection run marker, and optionally
|
||||
//! indexes whatever is missing one.
|
||||
//!
|
||||
//! cargo run -p dr-ui --example face_index -- CATALOG.db THUMBS_DIR [--run DET.onnx EMB.onnx]
|
||||
//!
|
||||
//! # Why this exists beside the button in the Identity screen
|
||||
//!
|
||||
//! Indexing a real library is hours of work (docs/faces.md §12.2), and the
|
||||
//! cases where that is worth starting — an overnight pass, a fresh import, a
|
||||
//! machine left running — are exactly the ones where holding a window open is
|
||||
//! the wrong shape. The check half is useful on its own: it is cheap, it
|
||||
//! answers "has face recognition been over all of this", and it distinguishes
|
||||
//! *not yet indexed* from *waiting on a proxy*, which are different problems
|
||||
//! with different fixes.
|
||||
//!
|
||||
//! The models must have had their input dims frozen first; see
|
||||
//! `tools/fix-face-model-shapes.sh`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
use dr_thumbs::ThumbStore;
|
||||
use dr_ui::faces::{self, FaceSweepMessage};
|
||||
|
||||
const MODEL_ID: &str = "w600k_mbf";
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!(
|
||||
"usage: face_index CATALOG.db THUMBS_DIR [--run DETECTOR.onnx EMBEDDER.onnx]\n\
|
||||
\n\
|
||||
With no --run this only reports; nothing is written."
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let catalog_path = PathBuf::from(&args[0]);
|
||||
let store_dir = PathBuf::from(&args[1]);
|
||||
|
||||
let catalog = match Catalog::open(&catalog_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("cannot open catalog {}: {e}", catalog_path.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let store = match ThumbStore::open(&store_dir) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("cannot open thumbnail store {}: {e}", store_dir.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let audit = match faces::audit(&catalog, &store, MODEL_ID) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("coverage check failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
println!("model {MODEL_ID}");
|
||||
println!("images {}", audit.coverage.images);
|
||||
println!(
|
||||
"indexed {} ({:.1}%)",
|
||||
audit.coverage.indexed,
|
||||
audit.coverage.fraction() * 100.0
|
||||
);
|
||||
println!("faces {}", audit.coverage.faces);
|
||||
println!(
|
||||
"no faces {} (indexed, nothing found — the common case)",
|
||||
audit.coverage.without_faces
|
||||
);
|
||||
println!("outstanding {}", audit.coverage.outstanding());
|
||||
println!(" ready to index {}", audit.ready);
|
||||
println!(" awaiting proxy {}", audit.awaiting_proxy);
|
||||
|
||||
let run = args.iter().position(|a| a == "--run");
|
||||
let Some(i) = run else {
|
||||
if audit.coverage.is_complete() {
|
||||
println!("\nnothing outstanding.");
|
||||
} else {
|
||||
println!("\npass --run DETECTOR.onnx EMBEDDER.onnx to index the outstanding images.");
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let (Some(detector), Some(embedder)) = (args.get(i + 1), args.get(i + 2)) else {
|
||||
eprintln!("--run needs both a detector and an embedder");
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
if audit.ready == 0 {
|
||||
println!("\nnothing ready to index.");
|
||||
if audit.awaiting_proxy > 0 {
|
||||
// Worth saying plainly: running this again will not help, because
|
||||
// the blocker is in the thumbnail store rather than here.
|
||||
println!(
|
||||
"{} image(s) are waiting on a proxy — run the thumbnail sweep first.",
|
||||
audit.awaiting_proxy
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\nindexing {} image(s)…", audit.ready);
|
||||
let rx = faces::spawn_face_sweep(
|
||||
catalog_path,
|
||||
store_dir,
|
||||
PathBuf::from(detector),
|
||||
PathBuf::from(embedder),
|
||||
MODEL_ID.to_string(),
|
||||
dr_face::DetectOptions::default(),
|
||||
);
|
||||
|
||||
let mut seen = 0usize;
|
||||
let mut total = 0usize;
|
||||
let start = std::time::Instant::now();
|
||||
for msg in rx {
|
||||
match msg {
|
||||
FaceSweepMessage::Total(n) => total = n,
|
||||
FaceSweepMessage::Indexed { faces, .. } => {
|
||||
seen += 1;
|
||||
// One line per image would be thousands of lines; one per
|
||||
// twenty-five is enough to see it moving and to estimate.
|
||||
if seen % 25 == 0 || faces > 0 {
|
||||
let rate = seen as f64 / start.elapsed().as_secs_f64().max(1e-6);
|
||||
println!(
|
||||
" {seen}/{total} {:.2} img/s ~{:.0} min left",
|
||||
rate,
|
||||
(total.saturating_sub(seen)) as f64 / rate.max(1e-6) / 60.0
|
||||
);
|
||||
}
|
||||
}
|
||||
FaceSweepMessage::Finished {
|
||||
images,
|
||||
faces,
|
||||
failed,
|
||||
} => {
|
||||
println!(
|
||||
"\ndone: {images} image(s), {faces} face(s), {failed} failed, in {:.0}s",
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(a) = faces::audit(&catalog, &store, MODEL_ID) {
|
||||
println!("{}", a.summary());
|
||||
}
|
||||
}
|
||||
+147
-6
@@ -59,11 +59,17 @@ pub struct FaceRequest {
|
||||
pub file_id: u64,
|
||||
}
|
||||
|
||||
/// Images that have a usable proxy and no faces recorded for this model.
|
||||
/// Images that have a usable proxy and have not been through this model.
|
||||
///
|
||||
/// Asks `face_index` — the *run* marker — rather than asking whether the image
|
||||
/// has any faces. Those are different questions, and confusing them is the
|
||||
/// difference between a pass that converges and one that does not: a
|
||||
/// photograph with no face in it would otherwise look identical to one never
|
||||
/// examined, so every landscape in the library would be re-detected on every
|
||||
/// run, for ever. See the V9 migration.
|
||||
///
|
||||
/// Keyed on the model, so a model upgrade re-indexes rather than leaving the
|
||||
/// library half-described by weights that are no longer comparable — the
|
||||
/// failure `faces.model_id` exists to make detectable.
|
||||
/// library half-described by weights that are no longer comparable.
|
||||
pub fn faces_outstanding(
|
||||
catalog: &Catalog,
|
||||
store: &ThumbStore,
|
||||
@@ -76,8 +82,8 @@ pub fn faces_outstanding(
|
||||
WHERE r.file_id IS NOT NULL
|
||||
AND i.trashed_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM faces f
|
||||
WHERE f.image_id = i.id AND f.model_id = ?1
|
||||
SELECT 1 FROM face_index fi
|
||||
WHERE fi.image_id = i.id AND fi.model_id = ?1
|
||||
)
|
||||
ORDER BY i.id",
|
||||
)?;
|
||||
@@ -98,6 +104,93 @@ pub fn faces_outstanding(
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// What a coverage check found.
|
||||
///
|
||||
/// The catalog can say how many images have been through the model; only this
|
||||
/// layer can say *why* the rest have not, because the reason usually lives in
|
||||
/// the thumbnail store rather than the catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct IndexAudit {
|
||||
pub coverage: faces::Coverage,
|
||||
/// Outstanding and ready: the proxy exists, so a sweep would do these now.
|
||||
pub ready: u64,
|
||||
/// Outstanding and blocked: no proxy yet, so the thumbnail sweep has to run
|
||||
/// first. Reported separately because it is not a face-indexing problem and
|
||||
/// telling the user to "run indexing again" would not fix it.
|
||||
pub awaiting_proxy: u64,
|
||||
}
|
||||
|
||||
impl IndexAudit {
|
||||
/// One line, for a log or a status strip.
|
||||
pub fn summary(&self) -> String {
|
||||
let c = &self.coverage;
|
||||
if c.images == 0 {
|
||||
return "no images in the library".into();
|
||||
}
|
||||
let mut s = format!(
|
||||
"{}/{} images indexed ({:.0}%), {} face(s), {} image(s) with none",
|
||||
c.indexed,
|
||||
c.images,
|
||||
c.fraction() * 100.0,
|
||||
c.faces,
|
||||
c.without_faces,
|
||||
);
|
||||
if self.ready > 0 {
|
||||
s.push_str(&format!("; {} ready to index", self.ready));
|
||||
}
|
||||
if self.awaiting_proxy > 0 {
|
||||
s.push_str(&format!("; {} awaiting a proxy", self.awaiting_proxy));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Check every library image for a face-detection run marker.
|
||||
///
|
||||
/// The batch pass that answers "has face recognition been over all of this",
|
||||
/// and the one to run before deciding whether to start a sweep. Cheap: two
|
||||
/// counts and one indexed scan, no decoding and no inference.
|
||||
pub fn audit(
|
||||
catalog: &Catalog,
|
||||
store: &ThumbStore,
|
||||
model_id: &str,
|
||||
) -> Result<IndexAudit, dr_catalog::CatalogError> {
|
||||
let conn = catalog.connection();
|
||||
let coverage = faces::coverage(conn, model_id)?;
|
||||
|
||||
// Split the outstanding set by whether a proxy exists. This is the query
|
||||
// `faces_outstanding` runs without the store filter, so the two cannot
|
||||
// disagree about what is outstanding.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT r.file_id
|
||||
FROM images i
|
||||
JOIN remote r ON r.image_id = i.id
|
||||
WHERE r.file_id IS NOT NULL
|
||||
AND i.trashed_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM face_index fi
|
||||
WHERE fi.image_id = i.id AND fi.model_id = ?1
|
||||
)",
|
||||
)?;
|
||||
let (mut ready, mut awaiting) = (0u64, 0u64);
|
||||
for file_id in stmt
|
||||
.query_map([model_id], |r| r.get::<_, i64>(0))?
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
if store.contains(file_id as u64, FACE_TIER) {
|
||||
ready += 1;
|
||||
} else {
|
||||
awaiting += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(IndexAudit {
|
||||
coverage,
|
||||
ready,
|
||||
awaiting_proxy: awaiting,
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect and embed every face in one decoded proxy.
|
||||
///
|
||||
/// Coordinates come back **normalised to the long edge**, which is what the
|
||||
@@ -284,7 +377,13 @@ pub fn spawn_face_sweep(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = faces::record_detections(catalog.connection(), req.image_id, &faces) {
|
||||
if let Err(e) = faces::record_detections(
|
||||
catalog.connection(),
|
||||
req.image_id,
|
||||
&model_id,
|
||||
w.max(h),
|
||||
&faces,
|
||||
) {
|
||||
log::warn!("face sweep: storing faces for {:?}: {e}", req.image_id);
|
||||
failed += 1;
|
||||
continue;
|
||||
@@ -537,6 +636,48 @@ fn rgba_to_rgb_f32(rgba: &[u8]) -> Vec<f32> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_audit_summary_names_both_kinds_of_outstanding() {
|
||||
let a = IndexAudit {
|
||||
coverage: faces::Coverage {
|
||||
images: 100,
|
||||
indexed: 60,
|
||||
without_faces: 40,
|
||||
faces: 35,
|
||||
},
|
||||
ready: 30,
|
||||
awaiting_proxy: 10,
|
||||
};
|
||||
let s = a.summary();
|
||||
assert!(s.contains("60/100"), "{s}");
|
||||
assert!(s.contains("60%"), "{s}");
|
||||
assert!(s.contains("30 ready"), "{s}");
|
||||
assert!(s.contains("10 awaiting"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_audit_mentions_neither() {
|
||||
let a = IndexAudit {
|
||||
coverage: faces::Coverage {
|
||||
images: 10,
|
||||
indexed: 10,
|
||||
without_faces: 7,
|
||||
faces: 4,
|
||||
},
|
||||
ready: 0,
|
||||
awaiting_proxy: 0,
|
||||
};
|
||||
let s = a.summary();
|
||||
assert!(!s.contains("ready"), "{s}");
|
||||
assert!(!s.contains("awaiting"), "{s}");
|
||||
assert!(s.contains("10/10"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_library_says_so_rather_than_reporting_zero_of_zero() {
|
||||
assert_eq!(IndexAudit::default().summary(), "no images in the library");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landmarks_normalise_against_the_long_edge() {
|
||||
let lm = [(512.0, 256.0), (0.0, 0.0), (1024.0, 512.0), (10.0, 20.0), (5.0, 5.0)];
|
||||
|
||||
+10
-10
@@ -474,8 +474,8 @@ mod tests {
|
||||
let c = catalog();
|
||||
let i1 = image(&c, 1);
|
||||
let i2 = image(&c, 2);
|
||||
let a = faces::record_detections(c.connection(), i1, &[face(1)]).unwrap();
|
||||
faces::record_detections(c.connection(), i2, &[face(2)]).unwrap();
|
||||
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||
faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||||
|
||||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||||
faces::confirm(c.connection(), a[0], anna).unwrap();
|
||||
@@ -493,9 +493,9 @@ mod tests {
|
||||
let i1 = image(&c, 1);
|
||||
let i2 = image(&c, 2);
|
||||
let i3 = image(&c, 3);
|
||||
let a = faces::record_detections(c.connection(), i1, &[face(1)]).unwrap();
|
||||
let b = faces::record_detections(c.connection(), i2, &[face(2)]).unwrap();
|
||||
let d = faces::record_detections(c.connection(), i3, &[face(3)]).unwrap();
|
||||
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||
let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||||
let d = faces::record_detections(c.connection(), i3, "w600k_mbf", 1024, &[face(3)]).unwrap();
|
||||
|
||||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||||
faces::confirm(c.connection(), a[0], anna).unwrap();
|
||||
@@ -514,8 +514,8 @@ mod tests {
|
||||
let c = catalog();
|
||||
let i1 = image(&c, 1);
|
||||
let i2 = image(&c, 2);
|
||||
let a = faces::record_detections(c.connection(), i1, &[face(1)]).unwrap();
|
||||
let b = faces::record_detections(c.connection(), i2, &[face(2)]).unwrap();
|
||||
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||
let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||||
|
||||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||||
faces::confirm(c.connection(), a[0], anna).unwrap();
|
||||
@@ -547,8 +547,8 @@ mod tests {
|
||||
let c = catalog();
|
||||
let i1 = image(&c, 1);
|
||||
let i2 = image(&c, 2);
|
||||
let a = faces::record_detections(c.connection(), i1, &[face(1)]).unwrap();
|
||||
let b = faces::record_detections(c.connection(), i2, &[face(2)]).unwrap();
|
||||
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||
let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||||
let annie = faces::create_person(c.connection(), "Annie").unwrap();
|
||||
faces::confirm(c.connection(), a[0], anna).unwrap();
|
||||
@@ -562,7 +562,7 @@ mod tests {
|
||||
fn deleting_everything_empties_the_screen() {
|
||||
let c = catalog();
|
||||
let i1 = image(&c, 1);
|
||||
let a = faces::record_detections(c.connection(), i1, &[face(1)]).unwrap();
|
||||
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||||
faces::confirm(c.connection(), a[0], anna).unwrap();
|
||||
|
||||
|
||||
+204
-1
@@ -7,12 +7,15 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::Duration;
|
||||
|
||||
use dr_catalog::faces::{FaceId, PersonId};
|
||||
use dr_catalog::Catalog;
|
||||
use dr_thumbs::ThumbStore;
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
|
||||
use crate::faces::FaceSweepMessage;
|
||||
use crate::identity::{self, FaceCell, PersonRow};
|
||||
use crate::{AppWindow, IdentityFace, IdentityPerson};
|
||||
|
||||
@@ -31,6 +34,15 @@ pub struct IdentityController {
|
||||
selected: std::cell::Cell<Option<PersonId>>,
|
||||
/// Faces the user has ticked, which is what a split would carry.
|
||||
picked: RefCell<Vec<FaceId>>,
|
||||
/// The running indexing sweep, if any.
|
||||
///
|
||||
/// Holding the receiver *is* the cancellation handle: the worker stops when
|
||||
/// its send fails, so dropping this is how "Stop" works. No flag to get out
|
||||
/// of step with the thread, and everything already written stays written.
|
||||
sweep: RefCell<Option<Receiver<FaceSweepMessage>>>,
|
||||
/// Images visited so far in the current sweep, and the total it announced.
|
||||
progress: std::cell::Cell<(usize, usize)>,
|
||||
faces_found: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl IdentityController {
|
||||
@@ -126,6 +138,36 @@ pub fn refresh(
|
||||
}
|
||||
|
||||
window.set_identity_picked(ctl.picked.borrow().len() as i32);
|
||||
drop(borrow);
|
||||
refresh_coverage(window, catalog, store);
|
||||
}
|
||||
|
||||
/// Run the batch check and put its answer on screen.
|
||||
///
|
||||
/// Cheap enough to call on every open and after every sweep: two counts and one
|
||||
/// indexed scan, no decoding and no inference.
|
||||
pub fn refresh_coverage(
|
||||
window: &AppWindow,
|
||||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||||
store: Option<&ThumbStore>,
|
||||
) {
|
||||
let borrow = catalog.borrow();
|
||||
let (Some(cat), Some(store)) = (borrow.as_ref(), store) else {
|
||||
window.set_identity_coverage(Default::default());
|
||||
return;
|
||||
};
|
||||
match crate::faces::audit(cat, store, MODEL_ID) {
|
||||
Ok(a) => {
|
||||
window.set_identity_coverage(a.summary().into());
|
||||
// Complete means nothing left to index, not "every image has a
|
||||
// face": most of a library has none, and that is a finding.
|
||||
window.set_identity_coverage_complete(a.coverage.is_complete());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("identity: coverage check: {e}");
|
||||
window.set_identity_coverage("coverage unknown".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_faces(window: &AppWindow, ctl: &IdentityController, cells: &[FaceCell]) {
|
||||
@@ -155,16 +197,34 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||||
slint::Image::from_rgba8(buf)
|
||||
}
|
||||
|
||||
/// Where a background sweep reads from: the catalog file and the thumbnail
|
||||
/// store directory.
|
||||
///
|
||||
/// Paths rather than the live handles this screen holds, because the sweep runs
|
||||
/// on its own thread and opens its own connection. Two connections to one
|
||||
/// SQLite file is the normal arrangement here; sharing a handle across the
|
||||
/// boundary would not be.
|
||||
pub type SweepPaths = (std::path::PathBuf, std::path::PathBuf);
|
||||
|
||||
/// The detector and embedder files, when both are present.
|
||||
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
|
||||
|
||||
/// Attach every Identity callback.
|
||||
pub fn wire<S>(
|
||||
pub fn wire<S, M, P>(
|
||||
window: &AppWindow,
|
||||
ctl: Rc<IdentityController>,
|
||||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||||
store: S,
|
||||
models: M,
|
||||
paths: P,
|
||||
) where
|
||||
S: Fn() -> Option<Rc<ThumbStore>> + 'static,
|
||||
M: Fn() -> Option<ModelPaths> + 'static,
|
||||
P: Fn() -> Option<SweepPaths> + 'static,
|
||||
{
|
||||
let store: Rc<dyn Fn() -> Option<Rc<ThumbStore>>> = Rc::new(store);
|
||||
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
|
||||
let paths: Rc<dyn Fn() -> Option<SweepPaths>> = Rc::new(paths);
|
||||
|
||||
// Re-read everything and redraw. Every mutating callback ends in this
|
||||
// rather than patching the model in place: the operations here have
|
||||
@@ -182,9 +242,13 @@ pub fn wire<S>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let models_present = models.clone();
|
||||
window.on_identity_open(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_show_identity(true);
|
||||
// A fact about the filesystem, so it is re-checked on every open
|
||||
// rather than cached: the user may have just put the models there.
|
||||
w.set_identity_model_missing(models_present().is_none());
|
||||
reload!(w, ctl, catalog, store);
|
||||
});
|
||||
}
|
||||
@@ -348,6 +412,128 @@ pub fn wire<S>(
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let models = models.clone();
|
||||
let paths = paths.clone();
|
||||
window.on_identity_index(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if ctl.sweep.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((detector, embedder)) = models() else {
|
||||
w.set_identity_model_missing(true);
|
||||
return;
|
||||
};
|
||||
let Some((catalog_path, store_dir)) = paths() else {
|
||||
return;
|
||||
};
|
||||
|
||||
ctl.progress.set((0, 0));
|
||||
ctl.faces_found.set(0);
|
||||
*ctl.sweep.borrow_mut() = Some(crate::faces::spawn_face_sweep(
|
||||
catalog_path,
|
||||
store_dir,
|
||||
detector,
|
||||
embedder,
|
||||
MODEL_ID.to_string(),
|
||||
dr_face::DetectOptions::default(),
|
||||
));
|
||||
w.set_identity_indexing(true);
|
||||
w.set_identity_indexing_status("looking for images to index…".into());
|
||||
|
||||
// Polled rather than pushed: the worker is a plain thread with an
|
||||
// mpsc channel, and a timer on the UI thread keeps every Slint
|
||||
// property write where Slint requires it. 250 ms is far shorter
|
||||
// than one image takes, so the timer never becomes the bottleneck
|
||||
// and never spins on an empty channel for long.
|
||||
let timer = slint::Timer::default();
|
||||
let weak_tick = w.as_weak();
|
||||
let ctl_tick = ctl.clone();
|
||||
let catalog_tick = catalog.clone();
|
||||
let store_tick = store.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
Duration::from_millis(250),
|
||||
move || {
|
||||
let Some(w) = weak_tick.upgrade() else { return };
|
||||
let mut done = false;
|
||||
{
|
||||
let borrow = ctl_tick.sweep.borrow();
|
||||
let Some(rx) = borrow.as_ref() else { return };
|
||||
// Drained rather than one per tick: several images can
|
||||
// land between ticks, and showing the oldest would make
|
||||
// the count visibly lag the work.
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
FaceSweepMessage::Total(n) => ctl_tick.progress.set((0, n)),
|
||||
FaceSweepMessage::Indexed { faces, .. } => {
|
||||
let (seen, total) = ctl_tick.progress.get();
|
||||
ctl_tick.progress.set((seen + 1, total));
|
||||
ctl_tick
|
||||
.faces_found
|
||||
.set(ctl_tick.faces_found.get() + faces);
|
||||
}
|
||||
FaceSweepMessage::Finished { .. } => done = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (seen, total) = ctl_tick.progress.get();
|
||||
w.set_identity_indexing_status(
|
||||
format!(
|
||||
"indexing {seen}/{total} — {} face(s) found",
|
||||
ctl_tick.faces_found.get()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
if done {
|
||||
*ctl_tick.sweep.borrow_mut() = None;
|
||||
w.set_identity_indexing(false);
|
||||
// Newly indexed faces belong to nobody until they are
|
||||
// grouped, so a sweep ending with an unchanged people
|
||||
// rail is expected. The coverage line is what shows it
|
||||
// worked, and Regroup is the next step.
|
||||
refresh(&w, &ctl_tick, &catalog_tick, store_tick().as_deref());
|
||||
}
|
||||
},
|
||||
);
|
||||
// A Slint timer stops when it drops, so it has to outlive this
|
||||
// callback.
|
||||
park_timer(timer);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
window.on_identity_stop_indexing(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Dropping the receiver *is* the cancellation: the worker's next
|
||||
// send fails and it returns, leaving everything already written
|
||||
// written. No flag to fall out of step with the thread.
|
||||
*ctl.sweep.borrow_mut() = None;
|
||||
w.set_identity_indexing(false);
|
||||
reload!(w, ctl, catalog, store);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
window.on_identity_check_coverage(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
refresh_coverage(&w, &catalog, store().as_deref());
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
@@ -368,6 +554,22 @@ pub fn wire<S>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the running sweep's poll timer alive.
|
||||
///
|
||||
/// A `slint::Timer` stops when it drops, so the one driving a sweep has to
|
||||
/// outlive the callback that started it. Parking it here rather than in the
|
||||
/// controller keeps `IdentityController` free of Slint types, which is what
|
||||
/// lets it be constructed in a test with no event loop.
|
||||
///
|
||||
/// One slot: starting a second sweep replaces the first's timer, and by then
|
||||
/// the first sweep has already finished or been stopped.
|
||||
fn park_timer(timer: slint::Timer) {
|
||||
thread_local! {
|
||||
static SWEEP_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
|
||||
}
|
||||
SWEEP_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -398,3 +600,4 @@ mod tests {
|
||||
assert!(ctl.picked.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -972,6 +972,28 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
.ok()
|
||||
.map(std::rc::Rc::new)
|
||||
},
|
||||
// The weights are not shipped and are not a build input
|
||||
// (docs/faces.md §2): the user puts them beside the catalog,
|
||||
// and their absence is the ordinary state of a fresh install.
|
||||
{
|
||||
let lib = library.clone();
|
||||
move || {
|
||||
let (_, session) = lib.session()?;
|
||||
library::face_models(&session.server, &session.user_id)
|
||||
}
|
||||
},
|
||||
// The sweep opens its own connection on its own thread, so it
|
||||
// takes paths rather than the handles this screen holds.
|
||||
{
|
||||
let lib = library.clone();
|
||||
move || {
|
||||
let (_, session) = lib.session()?;
|
||||
Some((
|
||||
library::catalog_path(&session.server, &session.user_id),
|
||||
library::thumbs_dir(&session.server, &session.user_id),
|
||||
))
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2892,6 +2892,36 @@ pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-thumbs"))
|
||||
}
|
||||
|
||||
/// Where the face models live, beside the catalog and the thumbnails.
|
||||
///
|
||||
/// **Not shipped with the application** and not a build input: the InsightFace
|
||||
/// weights carry a non-commercial research grant incompatible with this
|
||||
/// project's licence, so the user obtains them and the app loads them from here
|
||||
/// (docs/faces.md §2). An absent directory is the ordinary state of a fresh
|
||||
/// install, not an error.
|
||||
pub fn face_models_dir(server: &str, user_id: &str) -> PathBuf {
|
||||
catalog_path(server, user_id)
|
||||
.parent()
|
||||
.map(|p| p.join("models"))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-models"))
|
||||
}
|
||||
|
||||
/// The detector and embedder files, if both are present.
|
||||
///
|
||||
/// 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
|
||||
/// pair missing is more useful than half-starting.
|
||||
///
|
||||
/// The names are the **shape-fixed** exports, not what InsightFace ships:
|
||||
/// `tools/fix-face-model-shapes.sh` has to run over the originals first,
|
||||
/// because tract cannot parse either graph with a dynamic input.
|
||||
pub fn face_models(server: &str, user_id: &str) -> Option<(PathBuf, PathBuf)> {
|
||||
let dir = face_models_dir(server, user_id);
|
||||
let detector = dir.join("scrfd_500m_640.onnx");
|
||||
let embedder = dir.join("arcface_mbf_b1.onnx");
|
||||
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
|
||||
}
|
||||
|
||||
/// One grid cell's data, read from the catalog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LibraryCell {
|
||||
|
||||
@@ -534,6 +534,8 @@ export component AppWindow inherits Window {
|
||||
in property <bool> identity-indexing: false;
|
||||
in property <string> identity-indexing-status;
|
||||
in property <bool> identity-model-missing: false;
|
||||
in property <string> identity-coverage;
|
||||
in property <bool> identity-coverage-complete: false;
|
||||
in property <int> identity-picked: 0;
|
||||
|
||||
callback identity-open();
|
||||
@@ -546,6 +548,9 @@ export component AppWindow inherits Window {
|
||||
callback identity-confirm-all();
|
||||
callback identity-split-picked();
|
||||
callback identity-recluster();
|
||||
callback identity-index();
|
||||
callback identity-stop-indexing();
|
||||
callback identity-check-coverage();
|
||||
callback identity-delete-all();
|
||||
callback library-thumbnail-all();
|
||||
in-out property <length> library-cell-size: 180px;
|
||||
@@ -1282,6 +1287,8 @@ in property <bool> panel-visible: true;
|
||||
indexing-status: root.identity-indexing-status;
|
||||
model-missing: root.identity-model-missing;
|
||||
picked-count: root.identity-picked;
|
||||
coverage: root.identity-coverage;
|
||||
coverage-complete: root.identity-coverage-complete;
|
||||
|
||||
person-picked(id) => { root.identity-person-picked(id); }
|
||||
rename(name) => { root.identity-rename(name); }
|
||||
@@ -1291,6 +1298,9 @@ in property <bool> panel-visible: true;
|
||||
confirm-all() => { root.identity-confirm-all(); }
|
||||
split-picked() => { root.identity-split-picked(); }
|
||||
recluster() => { root.identity-recluster(); }
|
||||
index-faces() => { root.identity-index(); }
|
||||
stop-indexing() => { root.identity-stop-indexing(); }
|
||||
check-coverage() => { root.identity-check-coverage(); }
|
||||
delete-all-face-data() => { root.identity-delete-all(); }
|
||||
close() => { root.identity-close(); }
|
||||
}
|
||||
|
||||
@@ -157,6 +157,12 @@ export component IdentityScreen inherits Rectangle {
|
||||
in property <bool> calibrated: false;
|
||||
in property <bool> indexing: false;
|
||||
in property <string> indexing-status;
|
||||
// The batch check's own words: how much of the library face detection has
|
||||
// actually been over. Text rather than a number, because "4,812 of 5,000
|
||||
// indexed, 190 awaiting a proxy" is the useful form and a bare percentage
|
||||
// hides the reason the rest are outstanding.
|
||||
in property <string> coverage;
|
||||
in property <bool> coverage-complete: false;
|
||||
// No model on disk: face indexing cannot run at all (docs/faces.md §2.2).
|
||||
in property <bool> model-missing: false;
|
||||
in property <int> picked-count: 0;
|
||||
@@ -170,6 +176,9 @@ export component IdentityScreen inherits Rectangle {
|
||||
callback split-picked();
|
||||
callback merge-into(int);
|
||||
callback recluster();
|
||||
callback index-faces();
|
||||
callback stop-indexing();
|
||||
callback check-coverage();
|
||||
callback delete-all-face-data();
|
||||
callback close();
|
||||
|
||||
@@ -320,6 +329,14 @@ export component IdentityScreen inherits Rectangle {
|
||||
primary: true;
|
||||
clicked => { root.confirm-all(); }
|
||||
}
|
||||
if !root.indexing && !root.model-missing && !root.coverage-complete: Button {
|
||||
text: "Index faces";
|
||||
clicked => { root.index-faces(); }
|
||||
}
|
||||
if root.indexing: Button {
|
||||
text: "Stop";
|
||||
clicked => { root.stop-indexing(); }
|
||||
}
|
||||
Button {
|
||||
text: "Regroup";
|
||||
clicked => { root.recluster(); }
|
||||
@@ -337,6 +354,26 @@ export component IdentityScreen inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// The run-marker check, shown rather than buried in a log. Without
|
||||
// it the screen can say how many faces it has but not how much of
|
||||
// the library it has actually looked at — and those are the two
|
||||
// different questions the face_index table exists to separate.
|
||||
if root.coverage != "": HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
Text {
|
||||
text: root.coverage;
|
||||
color: root.coverage-complete ? Theme.ink-faint : Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Rectangle { }
|
||||
IconButton {
|
||||
icon: "rotate-cw";
|
||||
clicked => { root.check-coverage(); }
|
||||
}
|
||||
}
|
||||
|
||||
// FR-CULL-9, made visible: where the calibration is not fitted the
|
||||
// screen says the confidences are unavailable rather than letting
|
||||
// per-face percentages imply a measurement that was never made.
|
||||
|
||||
Reference in New Issue
Block a user