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:
2026-08-26 22:13:41 +02:00
co-authored by Claude Opus 5
parent 3e607222c6
commit 26a1eb7e28
14 changed files with 971 additions and 84 deletions
+157
View File
@@ -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());
}
}