diff --git a/docs/faces.md b/docs/faces.md index 2a762bd..aac9133 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -1062,6 +1062,47 @@ profile rather than the pipeline. Any future timing of this subsystem is a relea Still unmeasured: the same pass on a phone (NFR-RES-2), which does not follow from this one. +### 12.3 SCRFD-2.5G and 10G against 500M · 2026-09-11 + +§1 chose `500M` on cost; the recall it gives up was never measured. `examples/face_detectors` +in `dr-ui` runs several detectors over the same 400 proxies, spaced evenly through the reference +library, matches boxes at IoU ≥ 0.5 against the first, and writes contact sheets of the +disagreements — because a count of extra faces says nothing until someone has looked at whether +they are faces. Both size gates off, confidence at the production 0.5, release build, reference +desktop. Sizes are the box's shorter edge in proxy pixels; the network sees 0.625 of that. + +| Detector | File | Mean ms | Faces | <16 | 16–32 | 32–64 | ≥64 | +|---|---|---|---|---|---|---|---| +| `scrfd_500m` | 2.5 MB | 157 | 1,490 | 153 | 730 | 399 | 208 | +| `scrfd_2.5g` | 3.3 MB | 177 | 1,698 | 268 | 801 | 418 | 211 | +| `scrfd_10g` | 17 MB | 488 | 1,896 | 357 | 886 | 438 | 215 | + +| Against `500M` | Both | Candidate only | Baseline only | +|---|---|---|---| +| `2.5G` | 1,404 | **294** (242 of them under 32 px) | 86 | +| `10G` | 1,419 | **477** (411 under 32 px) | 71 | + +Two things the sheets settled that the counts cannot: + +- **The candidates' extras are faces.** The hundred smallest `2.5G`-only tiles are people — + soft, small, a few motion-blurred, and almost none of them anything else. These are the group + shots and the figures in the background that §7's table predicted `500M` would lose at 640. +- **`500M`'s "extras" are mostly not.** Of the 86 faces only `500M` found, the sheet shows the + same dog a dozen times, a stop sign, a wheel, two hands, the backs of several heads and one face + upside down. The larger models did not miss these; they declined them. So `500M` is paying + twice — for the faces it cannot find *and* for the non-faces it embeds, which is exactly the + garbage-embedding-bridges-two-clusters failure `DetectOptions::confidence` is set against. + +**`2.5G` is the right detector.** 12% more time for 14% more faces and a cleaner set, in a file +0.8 MB larger; `10G` finds a further 12% for 3.1× the time, which is a desktop-only price and this +is not a desktop-only feature (NFR-RES-2). It loads in tract with the same fix as `500M` +(`--input input.1=1,3,640,640`; its outputs are declared dynamic and tract infers them) and +decodes through the same nine-output path unchanged. Same licence, same `buffalo_m` release page. + +What a swap has to deal with: `face_index` is keyed on the *embedder's* `model_id`, so a new +detector re-queues nothing. The images already indexed under `500M` keep their faces and their +dogs until something re-detects them — that is a re-index policy question, not a detector one. + --- ## 13. Order diff --git a/docs/traceability.md b/docs/traceability.md index 9c39a28..3cde85b 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 349 | -| TRACES tags found | 1367 | +| Source files scanned | 350 | +| TRACES tags found | 1368 | | Requirements defined | 187 | | Requirements covered | 134 | | **Coverage** | **71.7%** (134/187) | @@ -56,7 +56,7 @@ _None._ | FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:4094`](../ui/dr-ui/src/develop.rs#L4094), [`ui/dr-ui/src/develop.rs:4107`](../ui/dr-ui/src/develop.rs#L4107), [`ui/dr-ui/src/develop.rs:4152`](../ui/dr-ui/src/develop.rs#L4152), [`ui/dr-ui/src/develop.rs:4163`](../ui/dr-ui/src/develop.rs#L4163), [`ui/dr-ui/src/develop.rs:4169`](../ui/dr-ui/src/develop.rs#L4169), [`ui/dr-ui/src/develop.rs:4186`](../ui/dr-ui/src/develop.rs#L4186), [`ui/dr-ui/src/develop.rs:789`](../ui/dr-ui/src/develop.rs#L789), [`ui/dr-ui/src/develop.rs:809`](../ui/dr-ui/src/develop.rs#L809), [`ui/dr-ui/src/develop.rs:815`](../ui/dr-ui/src/develop.rs#L815), [`ui/dr-ui/src/develop.rs:8432`](../ui/dr-ui/src/develop.rs#L8432), [`ui/dr-ui/src/develop.rs:8496`](../ui/dr-ui/src/develop.rs#L8496), [`ui/dr-ui/src/develop.rs:8519`](../ui/dr-ui/src/develop.rs#L8519), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1760`](../ui/dr-ui/src/lib.rs#L1760), [`ui/dr-ui/src/lib.rs:1857`](../ui/dr-ui/src/lib.rs#L1857), [`ui/dr-ui/src/lib.rs:1966`](../ui/dr-ui/src/lib.rs#L1966), [`ui/dr-ui/src/lib.rs:2007`](../ui/dr-ui/src/lib.rs#L2007), [`ui/dr-ui/src/lib.rs:3416`](../ui/dr-ui/src/lib.rs#L3416), [`ui/dr-ui/src/lib.rs:429`](../ui/dr-ui/src/lib.rs#L429), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1899`](../ui/dr-ui/ui/app.slint#L1899), [`ui/dr-ui/ui/app.slint:2884`](../ui/dr-ui/ui/app.slint#L2884), [`ui/dr-ui/ui/app.slint:79`](../ui/dr-ui/ui/app.slint#L79), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56), [`ui/dr-ui/ui/peaking.slint:91`](../ui/dr-ui/ui/peaking.slint#L91) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:139`](../core/dr-pipeline/src/sidecar.rs#L139), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500) | | FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:456`](../core/dr-catalog/src/schema.rs#L456), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:210`](../ui/dr-ui/src/library.rs#L210), [`ui/dr-ui/src/library.rs:6020`](../ui/dr-ui/src/library.rs#L6020) | -| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:524`](../core/dr-catalog/src/schema.rs#L524), [`core/dr-catalog/src/schema.rs:646`](../core/dr-catalog/src/schema.rs#L646), [`core/dr-catalog/src/schema.rs:687`](../core/dr-catalog/src/schema.rs#L687), [`core/dr-face/src/align.rs:291`](../core/dr-face/src/align.rs#L291), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:331`](../ui/dr-ui/src/faces.rs#L331), [`ui/dr-ui/src/faces.rs:347`](../ui/dr-ui/src/faces.rs#L347), [`ui/dr-ui/src/faces.rs:454`](../ui/dr-ui/src/faces.rs#L454), [`ui/dr-ui/src/identity_ui.rs:542`](../ui/dr-ui/src/identity_ui.rs#L542), [`ui/dr-ui/src/lib.rs:1379`](../ui/dr-ui/src/lib.rs#L1379), [`ui/dr-ui/src/library.rs:3293`](../ui/dr-ui/src/library.rs#L3293), [`ui/dr-ui/src/library.rs:3451`](../ui/dr-ui/src/library.rs#L3451), [`ui/dr-ui/src/library.rs:3698`](../ui/dr-ui/src/library.rs#L3698), [`ui/dr-ui/src/library.rs:3864`](../ui/dr-ui/src/library.rs#L3864), [`ui/dr-ui/src/library.rs:3900`](../ui/dr-ui/src/library.rs#L3900), [`ui/dr-ui/src/library.rs:3980`](../ui/dr-ui/src/library.rs#L3980), [`ui/dr-ui/ui/settings.slint:427`](../ui/dr-ui/ui/settings.slint#L427), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82) | +| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:524`](../core/dr-catalog/src/schema.rs#L524), [`core/dr-catalog/src/schema.rs:646`](../core/dr-catalog/src/schema.rs#L646), [`core/dr-catalog/src/schema.rs:687`](../core/dr-catalog/src/schema.rs#L687), [`core/dr-face/src/align.rs:291`](../core/dr-face/src/align.rs#L291), [`ui/dr-ui/examples/face_detectors.rs:1`](../ui/dr-ui/examples/face_detectors.rs#L1), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:331`](../ui/dr-ui/src/faces.rs#L331), [`ui/dr-ui/src/faces.rs:347`](../ui/dr-ui/src/faces.rs#L347), [`ui/dr-ui/src/faces.rs:454`](../ui/dr-ui/src/faces.rs#L454), [`ui/dr-ui/src/identity_ui.rs:542`](../ui/dr-ui/src/identity_ui.rs#L542), [`ui/dr-ui/src/lib.rs:1379`](../ui/dr-ui/src/lib.rs#L1379), [`ui/dr-ui/src/library.rs:3293`](../ui/dr-ui/src/library.rs#L3293), [`ui/dr-ui/src/library.rs:3451`](../ui/dr-ui/src/library.rs#L3451), [`ui/dr-ui/src/library.rs:3698`](../ui/dr-ui/src/library.rs#L3698), [`ui/dr-ui/src/library.rs:3864`](../ui/dr-ui/src/library.rs#L3864), [`ui/dr-ui/src/library.rs:3900`](../ui/dr-ui/src/library.rs#L3900), [`ui/dr-ui/src/library.rs:3980`](../ui/dr-ui/src/library.rs#L3980), [`ui/dr-ui/ui/settings.slint:427`](../ui/dr-ui/ui/settings.slint#L427), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:591`](../core/dr-catalog/src/schema.rs#L591), [`core/dr-catalog/src/schema.rs:687`](../core/dr-catalog/src/schema.rs#L687), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:454`](../ui/dr-ui/src/faces.rs#L454), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:3397`](../ui/dr-ui/src/library.rs#L3397), [`ui/dr-ui/src/library.rs:3980`](../ui/dr-ui/src/library.rs#L3980), [`ui/dr-ui/ui/identity.slint:314`](../ui/dr-ui/ui/identity.slint#L314) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | | FR-DEV-10 | [`core/dr-gpu/src/mask.rs:1015`](../core/dr-gpu/src/mask.rs#L1015), [`core/dr-gpu/src/mask.rs:1239`](../core/dr-gpu/src/mask.rs#L1239), [`core/dr-gpu/src/mask.rs:1471`](../core/dr-gpu/src/mask.rs#L1471), [`core/dr-gpu/src/mask.rs:421`](../core/dr-gpu/src/mask.rs#L421), [`core/dr-gpu/src/mask.rs:463`](../core/dr-gpu/src/mask.rs#L463), [`core/dr-gpu/src/mask.rs:58`](../core/dr-gpu/src/mask.rs#L58), [`core/dr-gpu/src/mask.rs:60`](../core/dr-gpu/src/mask.rs#L60), [`core/dr-gpu/src/mask.rs:822`](../core/dr-gpu/src/mask.rs#L822), [`core/dr-gpu/src/mask.rs:85`](../core/dr-gpu/src/mask.rs#L85), [`core/dr-gpu/src/mask.rs:901`](../core/dr-gpu/src/mask.rs#L901), [`core/dr-gpu/src/shaders/mask.wgsl:117`](../core/dr-gpu/src/shaders/mask.wgsl#L117), [`core/dr-gpu/src/shaders/mask.wgsl:59`](../core/dr-gpu/src/shaders/mask.wgsl#L59), [`core/dr-gpu/tests/range_masks.rs:119`](../core/dr-gpu/tests/range_masks.rs#L119), [`core/dr-gpu/tests/range_masks.rs:147`](../core/dr-gpu/tests/range_masks.rs#L147), [`core/dr-gpu/tests/range_masks.rs:175`](../core/dr-gpu/tests/range_masks.rs#L175), [`core/dr-gpu/tests/range_masks.rs:1`](../core/dr-gpu/tests/range_masks.rs#L1), [`core/dr-gpu/tests/range_masks.rs:94`](../core/dr-gpu/tests/range_masks.rs#L94), [`core/dr-pipeline/src/mask.rs:125`](../core/dr-pipeline/src/mask.rs#L125), [`core/dr-pipeline/src/mask.rs:2812`](../core/dr-pipeline/src/mask.rs#L2812), [`core/dr-pipeline/src/mask.rs:2825`](../core/dr-pipeline/src/mask.rs#L2825), [`core/dr-pipeline/src/mask.rs:2844`](../core/dr-pipeline/src/mask.rs#L2844), [`core/dr-pipeline/src/mask.rs:2858`](../core/dr-pipeline/src/mask.rs#L2858), [`core/dr-pipeline/src/mask.rs:2868`](../core/dr-pipeline/src/mask.rs#L2868), [`core/dr-pipeline/src/mask.rs:709`](../core/dr-pipeline/src/mask.rs#L709), [`core/dr-pipeline/src/mask.rs:748`](../core/dr-pipeline/src/mask.rs#L748), [`core/dr-pipeline/src/mask.rs:808`](../core/dr-pipeline/src/mask.rs#L808), [`core/dr-pipeline/src/mask.rs:827`](../core/dr-pipeline/src/mask.rs#L827), [`core/dr-pipeline/src/mask.rs:851`](../core/dr-pipeline/src/mask.rs#L851), [`core/dr-pipeline/src/mask.rs:863`](../core/dr-pipeline/src/mask.rs#L863), [`core/dr-pipeline/src/mask.rs:875`](../core/dr-pipeline/src/mask.rs#L875), [`core/dr-pipeline/src/sidecar.rs:1224`](../core/dr-pipeline/src/sidecar.rs#L1224), [`core/dr-pipeline/src/sidecar.rs:1403`](../core/dr-pipeline/src/sidecar.rs#L1403), [`core/dr-pipeline/src/sidecar.rs:1490`](../core/dr-pipeline/src/sidecar.rs#L1490), [`core/dr-pipeline/src/sidecar.rs:1591`](../core/dr-pipeline/src/sidecar.rs#L1591), [`core/dr-pipeline/src/sidecar.rs:1798`](../core/dr-pipeline/src/sidecar.rs#L1798), [`core/dr-pipeline/tests/mask_sidecar.rs:1157`](../core/dr-pipeline/tests/mask_sidecar.rs#L1157), [`core/dr-pipeline/tests/mask_sidecar.rs:1186`](../core/dr-pipeline/tests/mask_sidecar.rs#L1186), [`core/dr-pipeline/tests/mask_sidecar.rs:1204`](../core/dr-pipeline/tests/mask_sidecar.rs#L1204), [`core/dr-pipeline/tests/mask_sidecar.rs:1234`](../core/dr-pipeline/tests/mask_sidecar.rs#L1234), [`ui/dr-ui/src/develop.rs:2294`](../ui/dr-ui/src/develop.rs#L2294), [`ui/dr-ui/src/develop.rs:3614`](../ui/dr-ui/src/develop.rs#L3614), [`ui/dr-ui/src/develop.rs:3641`](../ui/dr-ui/src/develop.rs#L3641), [`ui/dr-ui/src/develop.rs:3673`](../ui/dr-ui/src/develop.rs#L3673), [`ui/dr-ui/src/develop.rs:3697`](../ui/dr-ui/src/develop.rs#L3697), [`ui/dr-ui/src/develop.rs:3703`](../ui/dr-ui/src/develop.rs#L3703), [`ui/dr-ui/src/develop.rs:3710`](../ui/dr-ui/src/develop.rs#L3710), [`ui/dr-ui/src/develop.rs:3730`](../ui/dr-ui/src/develop.rs#L3730), [`ui/dr-ui/src/labels.rs:60`](../ui/dr-ui/src/labels.rs#L60), [`ui/dr-ui/src/masks_ui.rs:1102`](../ui/dr-ui/src/masks_ui.rs#L1102), [`ui/dr-ui/src/masks_ui.rs:719`](../ui/dr-ui/src/masks_ui.rs#L719) | diff --git a/ui/dr-ui/examples/face_detectors.rs b/ui/dr-ui/examples/face_detectors.rs new file mode 100644 index 0000000..e2a1090 --- /dev/null +++ b/ui/dr-ui/examples/face_detectors.rs @@ -0,0 +1,427 @@ +//! TRACES: FR-CULL-8 +//! Compare face detectors over the same proxies. +//! +//! cargo run --release -p dr-ui --example face_detectors -- \ +//! CATALOG.db THUMBS_DIR BASELINE.onnx CANDIDATE.onnx [CANDIDATE.onnx…] \ +//! [--sample N] [--sheet DIR] +//! +//! Runs every detector over the same sample of stored proxies and reports, per +//! detector, how long it took and how many faces it found by size; then, per +//! candidate, which of those faces the baseline also found and which it did +//! not. `--sheet` writes a contact sheet of the disagreements in each direction, +//! because a count of "extra faces" says nothing until someone has looked at +//! whether they are faces. +//! +//! # What it measures, and what it cannot +//! +//! docs/faces.md §12 M4 is recall against hand-labelled faces. There are no +//! labels here, so this is the cheaper question that decides whether M4 is +//! worth the labelling: *do the detectors disagree, where, and does the +//! disagreement look like faces*. A candidate whose extras are all real faces +//! under 20 px has found the group shots the baseline lost; one whose extras +//! are ears and door handles has found nothing. +//! +//! Both size gates are off, so what is counted is what the detector *emits* +//! above its confidence, not what indexing would keep. The confidence floor +//! is the production one, because a detector that only wins below it has not +//! won anything the pipeline would see. +//! +//! The models must have had their input dims frozen first; see +//! `tools/fix-face-model-shapes.sh`. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use dr_catalog::Catalog; +use dr_face::{DetectOptions, Detection, Detector}; +use dr_thumbs::ThumbStore; +use dr_ui::faces; + +const DEFAULT_SAMPLE: usize = 400; + +/// Two boxes are the same face above this overlap. +const MATCH_IOU: f32 = 0.5; + +/// Size buckets, on the box's shorter edge in **proxy pixels**. The proxy is +/// 1024 on its long edge and the detector sees it letterboxed to 640, so the +/// model's own view is 0.625 of these — the smallest bucket is a face under +/// 10 px to the network. +const BUCKETS: [(f32, &str); 4] = [ + (16.0, "<16"), + (32.0, "16-32"), + (64.0, "32-64"), + (f32::INFINITY, ">=64"), +]; + +/// Tiles on a contact sheet: this many per row, this wide. +const SHEET_COLS: usize = 10; +const SHEET_TILE: usize = 112; +const SHEET_MAX: usize = 100; + +fn main() { + env_logger::init(); + + let args: Vec = std::env::args().skip(1).collect(); + let mut sample = DEFAULT_SAMPLE; + let mut sheet: Option = None; + let mut positional: Vec = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--sample" => { + sample = args + .get(i + 1) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| usage()); + i += 2; + } + "--sheet" => { + sheet = Some(PathBuf::from(args.get(i + 1).unwrap_or_else(|| usage()))); + i += 2; + } + other => { + positional.push(other.to_string()); + i += 1; + } + } + } + if positional.len() < 4 { + usage(); + } + + let catalog = Catalog::open(Path::new(&positional[0])).unwrap_or_else(|e| { + eprintln!("cannot open catalog {}: {e}", positional[0]); + std::process::exit(1); + }); + let store = ThumbStore::open(Path::new(&positional[1])).unwrap_or_else(|e| { + eprintln!("cannot open thumbnail store {}: {e}", positional[1]); + std::process::exit(1); + }); + + let mut detectors: Vec<(String, Detector)> = positional[2..] + .iter() + .map(|p| { + let label = Path::new(p) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| p.clone()); + let t = Instant::now(); + let det = Detector::from_path(p).unwrap_or_else(|e| { + eprintln!("cannot load {p}: {e}"); + std::process::exit(1); + }); + println!( + "{label:<20} loaded in {:>5.0} ms strides {:?}", + t.elapsed().as_secs_f64() * 1e3, + det.strides() + ); + (label, det) + }) + .collect(); + + let file_ids = sample_ids(&catalog, &store, sample); + if file_ids.is_empty() { + println!("no proxies on disk to measure — browse the library first."); + return; + } + println!( + "\n{} image(s), evenly spaced through the library\n", + file_ids.len() + ); + + // Gates off, confidence as shipped: see the module note. + let options = DetectOptions { + min_face_px: 0.0, + min_source_px: 0.0, + min_sharpness: 0.0, + ..Default::default() + }; + + // Per detector: per-image timings, and per-image detections. + let n = detectors.len(); + let mut times: Vec> = vec![Vec::new(); n]; + let mut found: Vec>> = vec![Vec::new(); n]; + // The decoded proxies the sheet will cut from, kept only when asked for. + let mut images: Vec<(u32, u32, Vec)> = Vec::new(); + let mut done = 0usize; + + for id in &file_ids { + let Ok(Some(thumb)) = store.get(*id, faces::FACE_TIER) else { + continue; + }; + let Ok((w, h, rgba)) = dr_thumbs::codec::decode_rgba(&thumb.bytes) else { + continue; + }; + let rgb: Vec = rgba + .chunks_exact(4) + .flat_map(|p| [p[0], p[1], p[2]].map(|c| c as f32 / 255.0)) + .collect(); + + for (k, (_, det)) in detectors.iter_mut().enumerate() { + let t = Instant::now(); + let dets = det + .detect(&rgb, w as usize, h as usize, &options) + .unwrap_or_default(); + times[k].push(t.elapsed().as_secs_f64() * 1e3); + found[k].push(dets); + } + if sheet.is_some() { + images.push((w, h, rgba)); + } + done += 1; + if done.is_multiple_of(50) { + println!(" {done}/{} images", file_ids.len()); + } + } + + println!("\n{done} image(s) measured\n"); + + // ---- per detector: speed and what it emits ------------------------- + + println!( + "{:<20} {:>8} {:>8} {:>7} {}", + "detector", + "mean ms", + "p50 ms", + "faces", + BUCKETS + .iter() + .map(|(_, l)| format!("{l:>7}")) + .collect::() + ); + for k in 0..n { + let mut t = times[k].clone(); + t.sort_by(|a, b| a.total_cmp(b)); + let mean = t.iter().sum::() / t.len().max(1) as f64; + let p50 = t.get(t.len() / 2).copied().unwrap_or(0.0); + let all: Vec<&Detection> = found[k].iter().flatten().collect(); + let counts = bucket_counts(all.iter().copied()); + println!( + "{:<20} {mean:>8.1} {p50:>8.1} {:>7} {}", + detectors[k].0, + all.len(), + counts.iter().map(|c| format!("{c:>7}")).collect::() + ); + } + + // ---- per candidate: agreement with the baseline ---------------------- + + let (base_label, _) = &detectors[0]; + for k in 1..n { + let label = &detectors[k].0; + // (image index, detection) for each side of the disagreement. + let mut matched: Vec<&Detection> = Vec::new(); + let mut only_candidate: Vec<(usize, &Detection)> = Vec::new(); + let mut only_baseline: Vec<(usize, &Detection)> = Vec::new(); + + for (img, (base, cand)) in found[0].iter().zip(&found[k]).enumerate() { + let mut base_used = vec![false; base.len()]; + for c in cand { + let best = base + .iter() + .enumerate() + .filter(|(bi, _)| !base_used[*bi]) + .map(|(bi, b)| (bi, iou(b, c))) + .filter(|(_, v)| *v >= MATCH_IOU) + .max_by(|a, b| a.1.total_cmp(&b.1)); + match best { + Some((bi, _)) => { + base_used[bi] = true; + matched.push(c); + } + None => only_candidate.push((img, c)), + } + } + for (bi, b) in base.iter().enumerate() { + if !base_used[bi] { + only_baseline.push((img, b)); + } + } + } + + println!("\n{label} against {base_label}:"); + println!( + "{:<28} {:>7} {}", + "", + "faces", + BUCKETS + .iter() + .map(|(_, l)| format!("{l:>7}")) + .collect::() + ); + for (name, set) in [ + ("both found", matched.clone()), + ( + "candidate only", + only_candidate.iter().map(|(_, d)| *d).collect(), + ), + ( + "baseline only", + only_baseline.iter().map(|(_, d)| *d).collect(), + ), + ] { + let counts = bucket_counts(set.iter().copied()); + println!( + " {name:<26} {:>7} {} median conf {:.2}", + set.len(), + counts.iter().map(|c| format!("{c:>7}")).collect::(), + median_confidence(&set) + ); + } + + if let Some(dir) = &sheet { + std::fs::create_dir_all(dir).expect("create sheet dir"); + for (suffix, set) in [("extra", &only_candidate), ("missed", &only_baseline)] { + let path = dir.join(format!("{label}-{suffix}.jpg")); + match write_sheet(&path, &images, set) { + Ok(n) => println!(" {suffix:<26} {n} tile(s) -> {}", path.display()), + Err(e) => eprintln!(" {suffix}: {e}"), + } + } + } + } +} + +fn usage() -> ! { + eprintln!( + "usage: face_detectors CATALOG.db THUMBS_DIR BASELINE.onnx CANDIDATE.onnx [CANDIDATE.onnx…] \ + [--sample N] [--sheet DIR]" + ); + std::process::exit(2); +} + +/// Up to `n` file ids with a proxy on disk, evenly spaced through the library +/// rather than its first `n` — the first `n` are one trip. +fn sample_ids(catalog: &Catalog, store: &ThumbStore, n: usize) -> Vec { + let mut stmt = catalog + .connection() + .prepare( + "SELECT r.file_id FROM remote r + JOIN images i ON i.id = r.image_id + WHERE r.file_id IS NOT NULL AND i.trashed_at IS NULL + ORDER BY i.id", + ) + .expect("list images"); + let with_proxy: Vec = stmt + .query_map([], |r| r.get::<_, i64>(0)) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|v| v as u64) + .filter(|id| store.contains(*id, faces::FACE_TIER)) + .collect(); + if with_proxy.len() <= n { + return with_proxy; + } + let step = with_proxy.len() as f64 / n as f64; + (0..n) + .map(|i| with_proxy[(i as f64 * step) as usize]) + .collect() +} + +fn iou(a: &Detection, b: &Detection) -> f32 { + let x0 = a.bbox.0.max(b.bbox.0); + let y0 = a.bbox.1.max(b.bbox.1); + let x1 = a.bbox.2.min(b.bbox.2); + let y1 = a.bbox.3.min(b.bbox.3); + let inter = (x1 - x0).max(0.0) * (y1 - y0).max(0.0); + let union = a.width() * a.height() + b.width() * b.height() - inter; + if union <= 0.0 { + 0.0 + } else { + inter / union + } +} + +fn bucket_counts<'a>(dets: impl Iterator) -> [usize; BUCKETS.len()] { + let mut counts = [0usize; BUCKETS.len()]; + for d in dets { + let edge = d.width().min(d.height()); + let b = BUCKETS + .iter() + .position(|(limit, _)| edge < *limit) + .unwrap_or(BUCKETS.len() - 1); + counts[b] += 1; + } + counts +} + +fn median_confidence(dets: &[&Detection]) -> f32 { + if dets.is_empty() { + return 0.0; + } + let mut c: Vec = dets.iter().map(|d| d.confidence).collect(); + c.sort_by(|a, b| a.total_cmp(b)); + c[c.len() / 2] +} + +/// A grid of face tiles, each the box enlarged by half again so there is +/// context to judge by, resampled to a fixed tile whatever its source size. +/// Smallest faces first: those are the ones the question is about. +fn write_sheet( + path: &Path, + images: &[(u32, u32, Vec)], + set: &[(usize, &Detection)], +) -> Result { + if set.is_empty() { + return Ok(0); + } + let mut ordered: Vec<&(usize, &Detection)> = set.iter().collect(); + ordered.sort_by(|a, b| { + let ea = a.1.width().min(a.1.height()); + let eb = b.1.width().min(b.1.height()); + ea.total_cmp(&eb) + }); + ordered.truncate(SHEET_MAX); + + let rows = ordered.len().div_ceil(SHEET_COLS); + let (sw, sh) = (SHEET_COLS * SHEET_TILE, rows * SHEET_TILE); + let mut sheet = vec![0u8; sw * sh * 4]; + + for (i, (img, d)) in ordered.iter().enumerate() { + let (w, h, rgba) = &images[*img]; + let (w, h) = (*w as usize, *h as usize); + let cx = (d.bbox.0 + d.bbox.2) * 0.5; + let cy = (d.bbox.1 + d.bbox.3) * 0.5; + let half = d.width().max(d.height()) * 0.75; + let (tx0, ty0) = ((i % SHEET_COLS) * SHEET_TILE, (i / SHEET_COLS) * SHEET_TILE); + for ty in 0..SHEET_TILE { + for tx in 0..SHEET_TILE { + let sx = cx - half + (tx as f32 + 0.5) / SHEET_TILE as f32 * half * 2.0; + let sy = cy - half + (ty as f32 + 0.5) / SHEET_TILE as f32 * half * 2.0; + let px = bilinear(rgba, w, h, sx, sy); + let o = ((ty0 + ty) * sw + tx0 + tx) * 4; + sheet[o..o + 4].copy_from_slice(&px); + } + } + } + + let bytes = dr_thumbs::codec::encode_rgba(sw as u32, sh as u32, &sheet) + .map_err(|e| format!("encode: {e}"))?; + std::fs::write(path, bytes).map_err(|e| format!("write {}: {e}", path.display()))?; + Ok(ordered.len()) +} + +/// RGBA sample at a continuous position; black outside the image. +fn bilinear(rgba: &[u8], w: usize, h: usize, x: f32, y: f32) -> [u8; 4] { + if x < 0.0 || y < 0.0 || x >= (w - 1) as f32 || y >= (h - 1) as f32 { + return [0, 0, 0, 255]; + } + let (x0, y0) = (x.floor() as usize, y.floor() as usize); + let (fx, fy) = (x - x0 as f32, y - y0 as f32); + let at = |xx: usize, yy: usize| &rgba[(yy * w + xx) * 4..(yy * w + xx) * 4 + 4]; + let (p00, p10, p01, p11) = ( + at(x0, y0), + at(x0 + 1, y0), + at(x0, y0 + 1), + at(x0 + 1, y0 + 1), + ); + let mut out = [0u8; 4]; + for c in 0..3 { + let top = p00[c] as f32 * (1.0 - fx) + p10[c] as f32 * fx; + let bot = p01[c] as f32 * (1.0 - fx) + p11[c] as f32 * fx; + out[c] = (top * (1.0 - fy) + bot * fy).round() as u8; + } + out[3] = 255; + out +}