collective

Browse / github.com/storytold/lightcraft / patch

xtask: ai-bench measures face recognition on LFW

xtask: ai-bench measures face recognition on LFW Stacked on #3110: its two commits come first here; review only the last commit, "xtask: ai-bench measures face recognition on LFW". **What it adds.** `cargo xtask ai-bench run face-recognition --set lfw`: the verification accuracy of the recognition models the faces crate already knows (SFace 2021dec and AuraFace v1) on LFW's standard 6,000 pairs, 10 folds, with the best cut-off on the other nine folds applied to each. `cargo xtask ai-bench fetch --set lfw` downloads the data and models with pinned SHA-256 sums. **Why.** The faces feature has recognition accuracy claims with no way to reprod

Status
open
Score
0.00
Adopters
0 other operators
Author
sm18lr88 (imported from pull request #3112, open)
Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0
Size
+1827 −1 in 11 files
Review
pending
Shared
2026-10-28
Id
04e68e2a6a838a1fc4d5edab

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock docs/faces.md xtask/Cargo.toml xtask/src/ai_bench/fetch.rs xtask/src/ai_bench/lfw.rs xtask/src/ai_bench/mat.rs xtask/src/ai_bench/metrics.rs xtask/src/ai_bench/mod.rs xtask/src/ai_bench/wider.rs xtask/src/corpus.rs xtask/src/main.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -7486,8 +7486,11 @@ version = "0.14.0" dependencies = [  "brotli",  "flate2",+ "lightcraft-faces",  "serde_json",  "sha2",+ "zune-core",+ "zune-jpeg", ]  [[package]]diff --git a/docs/faces.md b/docs/faces.md--- a/docs/faces.md+++ b/docs/faces.md@@ -235,6 +235,67 @@ The detector's output matches OpenCV's own YuNet on a 45-photo public-domain tes mean box overlap 0.97), including marble busts and paintings, with no false boxes on the landscape and architecture photos in the set. +### And how well does it find faces?++`cargo xtask ai-bench` measures the detector on the **WIDER FACE validation set** (3,226 photos, 39,708 faces) and prints the+average precision (AP) for the benchmark's three settings: easy, medium and hard (hard includes the smallest faces). The photos are the publisher's, not ours: they are licensed CC BY-NC-ND 4.0, for non-commercial research+use only, so the command downloads them to your machine only when you pass `--accept-terms`, and they are never part of+this repository.++```text+cargo xtask ai-bench fetch --accept-terms --set wider-face-val   # WIDER_val.zip, the evaluation toolkit's ground truth+                                                   # and YuNet, into corpus/ai-bench (SHA-256 checked; needs curl and tar or unzip)+cargo xtask ai-bench run face-detection --set wider-face-val+```++`--dir DIR` puts the files elsewhere (the set goes in `DIR/wider-face-val`), `--model FILE` measures another+YuNet-architecture `.onnx`, and `--limit N` scores N photos spread evenly, for a quick look. Detection uses the same+code as **Photo ▸ Detect Faces** (a 640-pixel input), keeps every box scoring at least 0.02 as the protocol expects, and+scores by the benchmark's published procedure: intersection over union of at least 0.5, scores normalised over the whole+set, 1,000 thresholds. The scoring is our own implementation of that description, not the toolkit's MATLAB code.++Measured with the pinned YuNet 2023mar model on an Intel Core i9-13950HX (32 threads, CPU only): **87.8 easy, 83.7 medium,+59.8 hard** AP in 41 s for the 3,226 photos. The published figures for this model are 88.44 / 86.56 / 75.03. The gap is widest on hard, which includes the smallest+faces, and those are what a 640-pixel input resolves worst (the limit noted above).++### And how well does it recognise them?++`cargo xtask ai-bench run face-recognition --set lfw` measures the recognition models LightCraft knows by hash (SFace and+AuraFace) on **LFW** (Labeled Faces in the Wild), the standard face-verification benchmark: 6,000 pairs of photos in ten+folds, half of them the same person. The photos are the University of Massachusetts Amherst's+([vis-www.cs.umass.edu/lfw](http://vis-www.cs.umass.edu/lfw/), read through the+[Internet Archive's copy of the page](https://web.archive.org/web/20191205130041/http://vis-www.cs.umass.edu/lfw/)): news+photos of public figures, published with no licence but for research on face verification only, and the publisher warns+that no result on them shows an algorithm to be suitable for commercial use. Like WIDER FACE they go to your machine only+when you pass `--accept-terms` and are never part of this repository. The publisher's own host no longer resolves, so+`fetch` downloads the Internet Archive's copies of its `lfw.tgz` (173 MB; the md5 it prints, `a17d05bd...`, matches) and+`pairs.txt`, pinned by SHA-256.++```text+cargo xtask ai-bench fetch --accept-terms --set lfw     # lfw.tgz, pairs.txt, YuNet, SFace and AuraFace (about 470 MB)+cargo xtask ai-bench run face-recognition --set lfw+```++Each photo goes through the code **Photo ▸ Detect Faces** and the recognition scan use: YuNet at its default options finds the+faces, the one nearest the centre (LFW photos are centred on the person) is aligned to the ArcFace template from the+detector's five landmarks, and the model's embedding of it is compared by cosine. A photo where YuNet finds no face has its+centre cut out instead, and the count is printed (none were needed). A pair scores the cosine of its two embeddings; for each+fold the best cut-off on the other nine is applied to it, and the result is the mean accuracy over the ten folds. There is+no mirrored-face (flip) test, as the app does none. `--model FILE` measures one recognition `.onnx` LightCraft knows by its+SHA-256, and `--limit N` scores N pairs spread evenly (at least 20, so each of the ten folds has a same-person and a+different-person pair). A pair with a photo that could not be read or embedded gets no score: it is left out when the+cut-off is chosen, counts as wrong in its fold, and is reported.++Measured on an Intel Core i9-13950HX (32 threads, CPU only: LightCraft's pure-Rust engine), all 6,000 pairs of 7,701 photos:++| Model | Accuracy (10 folds) | Published | Wall time |+| --- | --- | --- | --- |+| SFace 2021dec | **99.47 %** (std 0.44) | 99.40 % ([OpenCV Zoo](https://github.com/opencv/opencv_zoo/blob/25f423d0e04c31a17254620e58febd7386da523b/models/face_recognition_sface/README.md), measured with its [LFW evaluation](https://github.com/opencv/opencv_zoo/blob/25f423d0e04c31a17254620e58febd7386da523b/tools/eval/README.md)) | 148 s |+| AuraFace v1 | **99.40 %** (std 0.42) | 99.65 % ([model card](https://huggingface.co/fal/AuraFace-v1/blob/af6d057c9b0ec4071d4c49c80e3539258798b609/README.md)) | 395 s |++SFace matches its published figure. AuraFace is a quarter of a point (about 15 pairs) short of its card, which does not say+how it was aligned or whether it used a flip test; we have not tried to close that gap.+ ## Suggesting who is in a photo  With **Recognise faces** switched on in Settings ▸ Faces and a recognition model chosen (**Use**; the switch cannot bediff --git a/xtask/Cargo.toml b/xtask/Cargo.toml--- a/xtask/Cargo.toml+++ b/xtask/Cargo.toml@@ -10,6 +10,10 @@ workspace = true  [dependencies] serde_json = { workspace = true }+# `cargo xtask ai-bench` (pure Rust): LightCraft's own face detector and JPEG decoder, both already in the lockfile+lightcraft-faces = { workspace = true }+zune-jpeg = { workspace = true }+zune-core = { workspace = true } # corpus download checksums sha2 = { workspace = true } # precompressed web bundle (`cargo xtask web`); pure Rustdiff --git a/xtask/src/ai_bench/fetch.rs b/xtask/src/ai_bench/fetch.rsnew file mode 100644--- /dev/null+++ b/xtask/src/ai_bench/fetch.rs@@ -0,0 +1,192 @@+//! `cargo xtask ai-bench fetch`: download a benchmark set and the models it needs into the benchmark folder, check each+//! file against its pinned SHA-256 and unpack the archives. Needs `curl` and `tar` (or `unzip` for a zip), as+//! `cargo xtask corpus` needs `curl`.++use std::path::Path;+use std::process::Command;++use lightcraft_faces::known;++/// A file to download: name, URL, SHA-256 and whether it is an archive to unpack.+struct File {+    name: &'static str,+    url: &'static str,+    sha256: &'static str,+    archive: bool,+}++/// A benchmark set: its folder name, the terms the user accepts by passing `--accept-terms`, its files and the models+/// (by id in `lightcraft_faces::known`) its benchmark runs.+pub struct Set {+    pub id: &'static str,+    terms: &'static str,+    files: &'static [File],+    pub models: &'static [&'static str],+}++impl Set {+    /// Each file's name and whether it is an archive (unpacked, with a completion marker).+    #[cfg(test)]+    pub fn archives(&self) -> Vec<(&'static str, bool)> {+        self.files.iter().map(|f| (f.name, f.archive)).collect()+    }+}++pub const WIDER: Set = Set {+    id: "wider-face-val",+    terms: "WIDER FACE (http://shuoyang1213.me/WIDERFACE/) is licensed CC BY-NC-ND 4.0 (as its publisher's Hugging Face card states): \+non-commercial research use only, no derivatives. The photos are downloaded to your machine and used only to measure detection accuracy; they \+are never committed or redistributed.",+    files: &[+        File {+            name: "WIDER_val.zip",+            url: "https://huggingface.co/datasets/CUHK-CSE/wider_face/resolve/main/data/WIDER_val.zip",+            sha256: "f9efbd09f28c5d2d884be8c0eaef3967158c866a593fc36ab0413e4b2a58a17a",+            archive: true,+        },+        File {+            name: "eval_tools.zip",+            url: "http://shuoyang1213.me/WIDERFACE/support/eval_script/eval_tools.zip",+            sha256: "1cf49c8243fa8a1632efe7f6aa09fd7a3994ac838c9008bdbc9dd1d547bb234a",+            archive: true,+        },+    ],+    models: &[known::YUNET_ID],+};++/// The publisher's own host (vis-www.cs.umass.edu) no longer resolves, so the Internet Archive's copies of its+/// `lfw.tgz` and `pairs.txt` are used; the SHA-256 sums pin them, and `lfw.tgz` has the md5 the publisher printed.+pub const LFW: Set = Set {+    id: "lfw",+    terms: "LFW (Labeled Faces in the Wild, http://vis-www.cs.umass.edu/lfw/, University of Massachusetts Amherst) is a research benchmark \+of news photos of public figures. Its publisher gives no licence but warns that it is for research on face verification and \+must not be used to conclude that an algorithm is suitable for any commercial purpose. The photos are downloaded to your machine \+and used only to measure recognition accuracy; they are never committed or redistributed.",+    files: &[+        File {+            name: "lfw.tgz",+            url: "https://web.archive.org/web/20200310174752id_/http://vis-www.cs.umass.edu/lfw/lfw.tgz",+            sha256: "055f7d9c632d7370e6fb4afc7468d40f970c34a80d4c6f50ffec63f5a8d536c0",+            archive: true,+        },+        File {+            name: "pairs.txt",+            url: "https://web.archive.org/web/20191202153930id_/http://vis-www.cs.umass.edu/lfw/pairs.txt",+            sha256: "ea42330c62c92989f9d7c03237ed5d591365e89b3e649747777b70e692dc1592",+            archive: false,+        },+    ],+    models: &[known::YUNET_ID, "sface-2021dec", "auraface-v1"],+};++/// `curl` `url` to `dest`, unless `dest` already has `sha256`; a download with another hash is deleted and an error.+fn download(url: &str, dest: &Path, sha256: &str) -> Result<(), String> {+    if crate::corpus::file_sha256(dest).is_ok_and(|h| h == sha256) {+        return Ok(());+    }+    let mut curl = Command::new("curl");+    curl.args(["-fsSL", "--create-dirs", "-o"]).arg(dest).arg(url);+    crate::run(curl, &format!("curl {url}"))?;+    let actual = crate::corpus::file_sha256(dest)?;+    if actual != sha256 {+        std::fs::remove_file(dest).map_err(|e| format!("remove {}: {e}", dest.display()))?;+        return Err(format!("{}: sha256 is {actual}, expected {sha256} (file deleted; source {url})", dest.display()));+    }+    Ok(())+}++/// Unpack `archive` (a zip or a gzipped tar) into `into` with the system's bsdtar (Windows, macOS), else `unzip`.+fn unpack(archive: &Path, into: &Path) -> Result<(), String> {+    std::fs::create_dir_all(into).map_err(|e| format!("create {}: {e}", into.display()))?;+    let mut tar = Command::new("tar");+    tar.arg("-xf").arg(archive).arg("-C").arg(into);+    if crate::run(tar, &format!("tar -xf {}", archive.display())).is_ok() {+        return Ok(());+    }+    let mut unzip = Command::new("unzip");+    unzip.args(["-q", "-o"]).arg(archive).arg("-d").arg(into);+    crate::run(unzip, &format!("unzip {}", archive.display()))+}++/// Unpack `archive` (already checked against `sha256`) with `unpack` unless a completion marker for exactly that archive+/// exists. The marker is written, atomically, only after `unpack` succeeds: the unpacked folders can exist half-filled after an+/// interrupted extraction, so they never count as done and the next fetch extracts again over them.+fn unpack_once(set: &Path, archive: &str, sha256: &str, unpack: impl FnOnce() -> Result<(), String>) -> Result<(), String> {+    let marker = set.join(format!(".{archive}.unpacked"));+    if std::fs::read_to_string(&marker).is_ok_and(|done| done.trim() == sha256) {+        return Ok(());+    }+    unpack()?;+    let pending = set.join(format!(".{archive}.unpacked.tmp"));+    std::fs::write(&pending, sha256).map_err(|e| format!("write {}: {e}", pending.display()))?;+    std::fs::rename(&pending, &marker).map_err(|e| format!("rename {}: {e}", pending.display()))+}++/// Fetch everything the benchmarks of `sets` need under `dir`: each set's folder and `models/`.+pub fn run(dir: &Path, accept_terms: bool, sets: &[&Set]) -> Result<(), String> {+    if !accept_terms {+        let terms: Vec<&str> = sets.iter().map(|s| s.terms).collect();+        return Err(format!("{}\n\nPass --accept-terms to confirm you accept these terms.", terms.join("\n\n")));+    }+    for set in sets {+        let folder = dir.join(set.id);+        for file in set.files {+            download(file.url, &folder.join(file.name), file.sha256)?;+            if file.archive {+                unpack_once(&folder, file.name, file.sha256, || unpack(&folder.join(file.name), &folder))?;+            }+        }+        for id in set.models {+            let model = known::download(id).ok_or_else(|| format!("LightCraft offers no download of {id}"))?;+            download(&model.url, &dir.join("models").join(&model.file_name), &model.sha256)?;+        }+    }+    println!("fetched into {}", dir.display());+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn an_interrupted_extraction_is_redone_and_a_finished_one_is_not() {+        let set = std::env::temp_dir().join(format!("ai-bench-fetch-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&set);+        assert!(std::fs::create_dir_all(&set).is_ok());+        // the extraction dies after creating the folder that used to count as "done"+        let interrupted = unpack_once(&set, "a.zip", "abc", || {+            std::fs::create_dir_all(set.join("WIDER_val").join("images")).map_err(|e| e.to_string())?;+            Err("interrupted".to_string())+        });+        assert!(interrupted.is_err());+        assert!(set.join("WIDER_val").join("images").is_dir());+        let mut runs = 0;+        assert!(+            unpack_once(&set, "a.zip", "abc", || {+                runs += 1;+                Ok(())+            })+            .is_ok()+        );+        assert_eq!(runs, 1, "the next fetch must extract again");+        assert!(+            unpack_once(&set, "a.zip", "abc", || {+                runs += 1;+                Ok(())+            })+            .is_ok()+        );+        assert_eq!(runs, 1, "a finished extraction is not repeated");+        // a different archive (hash) under the same name is extracted again+        assert!(+            unpack_once(&set, "a.zip", "def", || {+                runs += 1;+                Ok(())+            })+            .is_ok()+        );+        assert_eq!(runs, 2);+        let _ = std::fs::remove_dir_all(&set);+    }+}diff --git a/xtask/src/ai_bench/lfw.rs b/xtask/src/ai_bench/lfw.rsnew file mode 100644--- /dev/null+++ b/xtask/src/ai_bench/lfw.rs@@ -0,0 +1,382 @@+//! LFW face verification ("view 2"): 6,000 pairs of photos in ten folds, half of them the same person. Every photo goes+//! through the code Photo ▸ Detect/Recognise Faces uses (the YuNet detector, alignment to the ArcFace template from its+//! landmarks, the installed recognition model) and a pair scores the cosine of its two embeddings.++use std::collections::HashMap;+use std::io::Read;+use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicUsize, Ordering};+use std::time::Instant;++use lightcraft_faces::align::{Rgb, align_to_template, crop_box};+use lightcraft_faces::matching::cosine;+use lightcraft_faces::runtime::Embedder;+use lightcraft_faces::yunet::{Detector, Options};++use super::metrics;+use super::wider::load_rgb;++/// How much wider than the guessed face box the fallback crop is (what the app does for a box without landmarks).+const BOX_PAD: f32 = 1.5;+/// Half the side of the fallback box, as a fraction of the shorter side of the photo (LFW centres the face).+const FALLBACK_HALF: f32 = 0.22;++/// Largest `pairs.txt` accepted (the real file is under 200 KB).+const MAX_PAIRS_BYTES: u64 = 1 << 20;++/// Refuse a `limit` that leaves some fold without a same-person and a different-person pair to evaluate and train on.+fn check_limit(limit: Option<usize>, pairs: &[Pair]) -> Result<(), String> {+    let folds = pairs.iter().map(|p| p.fold).max().map_or(0, |last| last.saturating_add(1));+    let minimum = folds.saturating_mul(2);+    match limit {+        Some(k) if k < minimum => Err(format!(+            "--limit {k} is too small to evaluate {folds} folds: use at least {minimum} pairs (a same-person and a different-person pair per fold)"+        )),+        _ => Ok(()),+    }+}++/// One line of `pairs.txt`.+#[derive(Debug, PartialEq)]+pub struct Pair {+    pub a: PathBuf,+    pub b: PathBuf,+    pub same: bool,+    pub fold: usize,+}++/// Read `dir/pairs.txt` (LFW's view 2) and check that every photo it names is under `dir/lfw`. The file starts with+/// `<folds> <pairs per half fold>`; each fold then lists that many same-person lines (`name n m`) followed by as+/// many different-person lines (`name n other m`).+pub fn load_pairs(dir: &Path) -> Result<Vec<Pair>, String> {+    let list = dir.join("pairs.txt");+    let unreadable = |e: std::io::Error| format!("{}: {e} (run `cargo xtask ai-bench fetch --set lfw`)", list.display());+    let file = std::fs::File::open(&list).map_err(unreadable)?;+    let size = file.metadata().map_err(unreadable)?.len();+    if size > MAX_PAIRS_BYTES {+        return Err(format!("{}: is {size} bytes, more than the {MAX_PAIRS_BYTES} a pairs file can be", list.display()));+    }+    // read one byte past the cap too, in case the file grows while it is read+    let mut text = String::new();+    file.take(MAX_PAIRS_BYTES + 1).read_to_string(&mut text).map_err(unreadable)?;+    if text.len() as u64 > MAX_PAIRS_BYTES {+        return Err(format!("{}: grew past {MAX_PAIRS_BYTES} bytes while being read", list.display()));+    }+    let photos = dir.join("lfw");+    let photo = |name: &str, number: &str| -> Result<PathBuf, String> {+        let number: usize = number.parse().map_err(|_| format!("{}: `{number}` is not a photo number", list.display()))?;+        if name.is_empty() || name.contains(['/', '\\', ':']) || name.contains("..") {+            return Err(format!("{}: `{name}` is not a person's folder name", list.display()));+        }+        Ok(photos.join(name).join(format!("{name}_{number:04}.jpg")))+    };+    let mut lines = text.lines();+    let header: Vec<usize> = lines.next().unwrap_or("").split_whitespace().map_while(|t| t.parse().ok()).collect();+    let [folds, half] = header.as_slice() else {+        return Err(format!("{}: the first line must be `<folds> <pairs per half fold>`", list.display()));+    };+    // the header is data from the file: a damaged one must not overflow+    let too_big = || format!("{}: the header `{folds} {half}` is too large", list.display());+    let per_fold = half.checked_mul(2).filter(|n| *n > 0).ok_or_else(too_big)?;+    let expected = folds.checked_mul(per_fold).ok_or_else(too_big)?;+    let mut pairs = Vec::new();+    for (i, line) in lines.filter(|l| !l.trim().is_empty()).enumerate() {+        let fold = i / per_fold;+        let words: Vec<&str> = line.split_whitespace().collect();+        let (a, b, same) = match words.as_slice() {+            [name, n, m] => (photo(name, n)?, photo(name, m)?, true),+            [name, n, other, m] => (photo(name, n)?, photo(other, m)?, false),+            _ => return Err(format!("{}: pair {} is malformed: `{line}`", list.display(), i + 1)),+        };+        if same != (i % per_fold < *half) {+            let (place, kind) = if same { ("different-person", "three fields") } else { ("same-person", "four fields") };+            return Err(format!("{}: pair {} is in the {place} half of fold {} but has {kind}: `{line}`", list.display(), i + 1, fold + 1));+        }+        pairs.push(Pair { a, b, same, fold });+    }+    if pairs.len() != expected {+        return Err(format!("{}: lists {} pairs, its header says {expected}", list.display(), pairs.len()));+    }+    if let Some(missing) = pairs.iter().flat_map(|p| [&p.a, &p.b]).find(|p| !p.is_file()) {+        return Err(format!("{}: no such photo (run `cargo xtask ai-bench fetch --set lfw`)", missing.display()));+    }+    Ok(pairs)+}++/// Run `work(0..n)` on `threads` threads and give the results in order.+fn parallel<T: Send>(n: usize, threads: usize, work: impl Fn(usize) -> T + Sync) -> Result<Vec<T>, String> {+    let next = AtomicUsize::new(0);+    let finished = AtomicUsize::new(0);+    let started = Instant::now();+    let step = (n / 20).max(1);+    let joined = std::thread::scope(|scope| {+        let workers: Vec<_> = (0..threads.clamp(1, 64))+            .map(|_| {+                scope.spawn(|| {+                    let mut done = Vec::new();+                    loop {+                        let at = next.fetch_add(1, Ordering::Relaxed);+                        if at >= n {+                            break;+                        }+                        done.push((at, work(at)));+                        let count = finished.fetch_add(1, Ordering::Relaxed) + 1;+                        if count.is_multiple_of(step) {+                            eprintln!("  {count}/{n} ({:.1}/s)", count as f64 / started.elapsed().as_secs_f64().max(1e-9));+                        }+                    }+                    done+                })+            })+            .collect();+        workers.into_iter().map(|w| w.join().map_err(|_| "a worker stopped unexpectedly".to_string())).collect::<Result<Vec<_>, _>>()+    })?;+    let mut all: Vec<(usize, T)> = joined.into_iter().flatten().collect();+    all.sort_by_key(|(at, _)| *at);+    Ok(all.into_iter().map(|(_, value)| value).collect())+}++/// The face of one photo as the recogniser wants it, and whether the detector found none (so the centre was cut out).+/// LFW photos are centred on the person, so with several faces the one nearest the centre is the one.+fn face(detector: &Detector, path: &Path, size: (usize, usize)) -> Result<(Vec<u8>, bool), String> {+    let (rgb, w, h) = load_rgb(path)?;+    let faces = detector.detect(&rgb, w, h, &Options::default()).map_err(|e| format!("{}: {e}", path.display()))?;+    let picture = Rgb { data: &rgb, width: w, height: h };+    let (fw, fh) = (w as f32, h as f32);+    let distance = |f: &lightcraft_faces::yunet::Face| ((f.x0 + f.x1) / 2.0 - 0.5).hypot((f.y0 + f.y1) / 2.0 - 0.5);+    let aligned = faces+        .iter()+        .min_by(|a, b| distance(a).total_cmp(&distance(b)))+        .and_then(|f| align_to_template(&picture, &f.landmarks.map(|(x, y)| (x * fw, y * fh)), size.0, size.1));+    if let Some(crop) = aligned {+        return Ok((crop, false));+    }+    let half = fw.min(fh) * FALLBACK_HALF;+    let crop = crop_box(&picture, fw / 2.0 - half, fh / 2.0 - half, fw / 2.0 + half, fh / 2.0 + half, BOX_PAD, size.0, size.1)+        .ok_or_else(|| format!("{}: no face could be cut out", path.display()))?;+    Ok((crop, true))+}++/// A pair's score: the cosine of its two photos' embeddings; a pair with a photo that could not be embedded (or whose+/// embeddings cannot be compared) has none, so it is counted as wrong instead of being given a made-up score.+fn scores(pairs: &[Pair], embeddings: &HashMap<&Path, Vec<f32>>) -> Vec<(Option<f64>, bool, usize)> {+    pairs+        .iter()+        .map(|p| {+            let score = match (embeddings.get(p.a.as_path()), embeddings.get(p.b.as_path())) {+                (Some(a), Some(b)) => cosine(a, b).map(f64::from),+                _ => None,+            };+            (score, p.same, p.fold)+        })+        .collect()+}++pub struct Run {+    pub pairs: usize,+    pub photos: usize,+    /// Photos where the detector found no face and the centre was cut out.+    pub fallbacks: usize,+    /// Photos that could not be read or embedded.+    pub failed: usize,+    /// Pairs with such a photo: they have no score, take no part in choosing a cut-off and count as wrong.+    pub failed_pairs: usize,+    pub seconds: f64,+    /// Mean and standard deviation of the ten folds' accuracy, in percent.+    pub accuracy: (f64, f64),+}++/// Verify `embedder` on the pairs under `dir` (or `limit` of them, spread evenly) on `threads` threads.+pub fn run(dir: &Path, detector: &Detector, embedder: &Embedder, limit: Option<usize>, threads: usize) -> Result<Run, String> {+    let mut pairs = load_pairs(dir)?;+    check_limit(limit, &pairs)?;+    if let Some(k) = limit.filter(|k| *k > 0 && *k < pairs.len()) {+        let n = pairs.len();+        let keep: Vec<usize> = (0..k).map(|i| i * n / k).collect();+        pairs = pairs.into_iter().enumerate().filter(|(i, _)| keep.contains(i)).map(|(_, p)| p).collect();+    }+    let mut photos: Vec<&Path> = pairs.iter().flat_map(|p| [p.a.as_path(), p.b.as_path()]).collect();+    photos.sort();+    photos.dedup();+    let started = Instant::now();+    let size = embedder.input_size();+    let found = parallel(photos.len(), threads, |i| {+        let path = photos.get(i).ok_or("no such photo")?;+        let (crop, fallback) = face(detector, path, size)?;+        let embedding = embedder.embed(&crop).map_err(|e| format!("{}: {e}", path.display()))?;+        Ok::<_, String>((embedding, fallback))+    })?;+    let (mut embeddings, mut fallbacks, mut failed) = (HashMap::new(), 0, 0);+    for (path, result) in photos.iter().zip(found) {+        match result {+            Ok((embedding, fallback)) => {+                fallbacks += usize::from(fallback);+                embeddings.insert(*path, embedding);+            }+            Err(e) => {+                if failed < 5 {+                    eprintln!("  warning: {e}");+                }+                failed += 1;+            }+        }+    }+    let scored = scores(&pairs, &embeddings);+    let failed_pairs = scored.iter().filter(|p| p.0.is_none()).count();+    let (mean, std) = metrics::fold_accuracy(&scored);+    Ok(Run {+        pairs: pairs.len(),+        failed_pairs,+        photos: photos.len(),+        fallbacks,+        failed,+        seconds: started.elapsed().as_secs_f64(),+        accuracy: (mean * 100.0, std * 100.0),+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A scratch directory that is removed afterwards.+    struct Scratch(PathBuf);++    impl Scratch {+        fn new(name: &str) -> Scratch {+            let dir = std::env::temp_dir().join(format!("ai-bench-lfw-{name}-{}", std::process::id()));+            let _ = std::fs::remove_dir_all(&dir);+            assert!(std::fs::create_dir_all(&dir).is_ok());+            Scratch(dir)+        }++        fn pairs(&self, text: &str) {+            assert!(std::fs::write(self.0.join("pairs.txt"), text).is_ok());+        }++        fn photo(&self, name: &str, number: usize) {+            let folder = self.0.join("lfw").join(name);+            assert!(std::fs::create_dir_all(&folder).is_ok());+            assert!(std::fs::write(folder.join(format!("{name}_{number:04}.jpg")), b"x").is_ok());+        }+    }++    impl Drop for Scratch {+        fn drop(&mut self) {+            let _ = std::fs::remove_dir_all(&self.0);+        }+    }++    /// Two folds of one same-person and one different-person pair each.+    const TWO_FOLDS: &str = "2 1\nAnn\t1\t2\nAnn\t1\tBob\t1\nBob\t1\t2\nBob\t2\tAnn\t2\n";++    fn two_folds() -> Scratch {+        let s = Scratch::new(&format!("two-{:?}", std::thread::current().id()).replace(['(', ')'], ""));+        for (name, n) in [("Ann", 1), ("Ann", 2), ("Bob", 1), ("Bob", 2)] {+            s.photo(name, n);+        }+        s.pairs(TWO_FOLDS);+        s+    }++    #[test]+    fn pairs_read_into_folds_of_same_then_different() {+        let s = two_folds();+        let pairs = load_pairs(&s.0).unwrap();+        let lfw = s.0.join("lfw");+        assert_eq!(pairs.len(), 4);+        assert_eq!(pairs[0], Pair { a: lfw.join("Ann/Ann_0001.jpg"), b: lfw.join("Ann/Ann_0002.jpg"), same: true, fold: 0 });+        assert_eq!(pairs[1], Pair { a: lfw.join("Ann/Ann_0001.jpg"), b: lfw.join("Bob/Bob_0001.jpg"), same: false, fold: 0 });+        assert_eq!((pairs[2].same, pairs[2].fold, pairs[3].same, pairs[3].fold), (true, 1, false, 1));+    }++    #[test]+    fn a_pairs_file_over_the_size_cap_is_refused_with_its_size() {+        let s = Scratch::new("big");+        let size = (MAX_PAIRS_BYTES + 1) as usize;+        s.pairs(&" ".repeat(size));+        let e = load_pairs(&s.0).err().unwrap_or_default();+        assert!(e.contains(&size.to_string()) && e.contains(&MAX_PAIRS_BYTES.to_string()), "{e:?}");+    }++    #[test]+    fn a_pair_of_the_wrong_kind_for_its_place_in_the_fold_is_an_error() {+        let s = two_folds();+        for (text, why) in [+            ("2 1\nAnn 1 Bob 1\nAnn 1 2\nBob 1 2\nBob 2 Ann 2\n", "different-person pair in the same-person half"),+            ("2 1\nAnn 1 2\nAnn 1 2\nBob 1 2\nBob 2 Ann 2\n", "same-person pair in the different-person half"),+            ("2 1\nAnn 1 2\nAnn 1 Bob 1\nBob 2 Ann 2\nBob 1 2\n", "halves swapped in the second fold"),+        ] {+            s.pairs(text);+            let e = load_pairs(&s.0).err().unwrap_or_default();+            assert!(e.contains("pair "), "{why}: {e:?}");+        }+    }++    #[test]+    fn a_limit_too_small_for_ten_fold_evaluation_is_refused_and_states_the_minimum() {+        let s = Scratch::new("limit");+        let pair = |fold| Pair { a: s.0.join("a"), b: s.0.join("b"), same: true, fold };+        let pairs: Vec<Pair> = (0..10).map(pair).collect();+        for k in [1, 5, 19] {+            let e = check_limit(Some(k), &pairs).err().unwrap_or_default();+            assert!(e.contains("20") && e.contains("10 folds"), "{k}: {e:?}");+        }+        assert!(check_limit(Some(20), &pairs).is_ok() && check_limit(None, &pairs).is_ok());+    }++    #[test]+    fn a_missing_pairs_file_or_photo_is_an_error_that_says_how_to_fetch_it() {+        let s = Scratch::new("missing");+        assert!(load_pairs(&s.0).err().unwrap().contains("ai-bench fetch"));+        s.pairs(TWO_FOLDS);+        s.photo("Ann", 1);+        let e = load_pairs(&s.0).err().unwrap();+        assert!(e.contains("Ann_0002.jpg") && e.contains("ai-bench fetch"), "{e}");+    }++    #[test]+    fn malformed_pairs_files_are_errors_not_panics() {+        let s = two_folds();+        for (text, why) in [+            ("", "empty"),+            ("garbage\n", "no header"),+            ("2\nAnn 1 2\n", "one-number header"),+            ("18446744073709551615 2\nAnn 1 2\n", "a header that overflows the pair count"),+            ("2 9223372036854775807\nAnn 1 2\n", "a header that overflows the fold size"),+            ("0 0\n", "empty folds"),+            ("2 1\nAnn 1 2\nAnn 1 Bob 1\nBob 1 2\n", "a pair short"),+            ("2 1\nAnn 1 2\nAnn 1 Bob 1\nBob 1 2\nBob 2 Ann 2\nAnn 1 2\n", "a pair too many"),+            ("2 1\nAnn 1\nAnn 1 Bob 1\nBob 1 2\nBob 2 Ann 2\n", "a line of two words"),+            ("2 1\nAnn x 2\nAnn 1 Bob 1\nBob 1 2\nBob 2 Ann 2\n", "a photo number that is not a number"),+            ("2 1\nAnn -1 2\nAnn 1 Bob 1\nBob 1 2\nBob 2 Ann 2\n", "a negative photo number"),+            ("2 1\n../Ann 1 2\nAnn 1 Bob 1\nBob 1 2\nBob 2 Ann 2\n", "a name that climbs out"),+            ("2 1\nC:Ann 1 2\nAnn 1 Bob 1\nBob 1 2\nBob 2 Ann 2\n", "a drive in the name"),+        ] {+            s.pairs(text);+            assert!(load_pairs(&s.0).is_err(), "{why}");+        }+        // a pairs file that is not text+        assert!(std::fs::write(s.0.join("pairs.txt"), [0xff, 0xfe, 0x00]).is_ok());+        assert!(load_pairs(&s.0).is_err());+    }++    #[test]+    fn a_pair_scores_the_cosine_of_its_embeddings_and_a_missing_one_scores_lowest() {+        let s = two_folds();+        let pairs = load_pairs(&s.0).unwrap();+        let lfw = s.0.join("lfw");+        let path = |n: &str| lfw.join(n.split('_').next().unwrap_or("")).join(format!("{n}.jpg"));+        let (a1, a2, b1) = (path("Ann_0001"), path("Ann_0002"), path("Bob_0001"));+        let mut embeddings: HashMap<&Path, Vec<f32>> = HashMap::new();+        embeddings.insert(a1.as_path(), vec![1.0, 0.0]);+        embeddings.insert(a2.as_path(), vec![0.6, 0.8]);+        embeddings.insert(b1.as_path(), vec![0.0, 1.0]);+        let scored = scores(&pairs, &embeddings);+        assert_eq!(scored.len(), 4);+        assert!((scored[0].0.unwrap_or(f64::NAN) - 0.6).abs() < 1e-6 && scored[0].1 && scored[0].2 == 0);+        assert!(scored[1].0.unwrap_or(f64::NAN).abs() < 1e-6 && !scored[1].1);+        // Bob_0002 has no embedding: no score at all, not a low one+        assert_eq!((scored[2].0, scored[3].0), (None, None));+    }+}diff --git a/xtask/src/ai_bench/mat.rs b/xtask/src/ai_bench/mat.rsnew file mode 100644--- /dev/null+++ b/xtask/src/ai_bench/mat.rs@@ -0,0 +1,315 @@+//! A reader for MATLAB level-5 MAT-files (the format of the WIDER FACE ground truth), written from MathWorks' published+//! file-format description: numeric arrays, char arrays and cell arrays, compressed or not, little-endian. Sizes are+//! checked against the data, nesting is bounded, and anything unexpected is an error.++use std::io::Read;++const MAX_DEPTH: usize = 16;+/// Largest decompressed variable.+const MAX_INFLATED: u64 = 1 << 30;+/// Largest single dimension of a matrix (every real variable is far smaller; a hostile one must not size an allocation).+const MAX_DIM: usize = 1 << 30;++#[derive(Clone, Debug, PartialEq)]+pub enum Value {+    /// A numeric matrix, column-major, as f64.+    Num {+        dims: Vec<usize>,+        data: Vec<f64>,+    },+    Char(String),+    Cell(Vec<Value>),+    /// Any other class (struct, sparse, objects): present but not read.+    Other,+}++impl Value {+    pub fn cells(&self) -> Option<&[Value]> {+        match self {+            Value::Cell(items) => Some(items),+            _ => None,+        }+    }++    pub fn text(&self) -> Option<&str> {+        match self {+            Value::Char(text) => Some(text),+            _ => None,+        }+    }++    /// Rows of a numeric matrix (stored column-major); an empty matrix is no rows.+    pub fn rows(&self) -> Option<Vec<Vec<f64>>> {+        let Value::Num { dims, data } = self else { return None };+        let (r, c) = (dims.first().copied().unwrap_or(0), dims.get(1).copied().unwrap_or(1));+        if r.checked_mul(c) != Some(data.len()) {+            return None;+        }+        if data.is_empty() {+            return Some(Vec::new());+        }+        Some((0..r).map(|i| (0..c).filter_map(|j| data.get(i + j * r).copied()).collect()).collect())+    }++    /// All numbers of a numeric matrix.+    pub fn numbers(&self) -> Option<&[f64]> {+        match self {+            Value::Num { data, .. } => Some(data),+            _ => None,+        }+    }+}++fn bad(what: &str) -> String {+    format!("malformed MAT-file: {what}")+}++struct Reader<'a> {+    b: &'a [u8],+    at: usize,+}++impl<'a> Reader<'a> {+    fn bytes(&self, len: usize) -> Option<&'a [u8]> {+        self.b.get(self.at..self.at.checked_add(len)?)+    }++    fn u32(&mut self) -> Result<u32, String> {+        let word = self.bytes(4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated tag"))?;+        self.at += 4;+        Ok(u32::from_le_bytes(word))+    }++    fn done(&self) -> bool {+        self.at >= self.b.len()+    }++    /// The next data element: its type and bytes.+    fn element(&mut self) -> Result<(u32, &'a [u8]), String> {+        let first = self.u32()?;+        if first >> 16 != 0 {+            // small element: type and size in one word, data in the next four bytes+            let (ty, size) = (first & 0xffff, (first >> 16) as usize);+            let data = self.bytes(size.min(4)).ok_or_else(|| bad("truncated small element"))?;+            self.at += 4;+            return Ok((ty, data));+        }+        let size = self.u32()? as usize;+        let data = self.bytes(size).ok_or_else(|| bad("an element runs past the end of the file"))?;+        let end = self.at + size;+        // compressed elements are not padded; everything else is padded to 8 bytes+        self.at = if first == 15 { end } else { end.checked_next_multiple_of(8).ok_or_else(|| bad("size overflow"))? };+        Ok((first, data))+    }+}++/// The values of a numeric data element of MAT type `ty`.+fn numbers(ty: u32, d: &[u8]) -> Result<Vec<f64>, String> {+    fn le<const N: usize>(d: &[u8]) -> impl Iterator<Item = [u8; N]> + '_ {+        d.as_chunks::<N>().0.iter().copied()+    }+    Ok(match ty {+        1 => d.iter().map(|b| f64::from(*b as i8)).collect(),+        2 => d.iter().map(|b| f64::from(*b)).collect(),+        3 => le::<2>(d).map(|c| f64::from(i16::from_le_bytes(c))).collect(),+        4 => le::<2>(d).map(|c| f64::from(u16::from_le_bytes(c))).collect(),+        5 => le::<4>(d).map(|c| f64::from(i32::from_le_bytes(c))).collect(),+        6 => le::<4>(d).map(|c| f64::from(u32::from_le_bytes(c))).collect(),+        7 => le::<4>(d).map(|c| f64::from(f32::from_le_bytes(c))).collect(),+        9 => le::<8>(d).map(f64::from_le_bytes).collect(),+        12 => le::<8>(d).map(|c| i64::from_le_bytes(c) as f64).collect(),+        13 => le::<8>(d).map(|c| u64::from_le_bytes(c) as f64).collect(),+        _ => return Err(bad(&format!("numeric data of type {ty}"))),+    })+}++/// A miMATRIX element's body.+fn matrix(body: &[u8], depth: usize) -> Result<(String, Value), String> {+    if depth > MAX_DEPTH {+        return Err(bad("nested too deeply"));+    }+    if body.is_empty() {+        return Ok((String::new(), Value::Num { dims: vec![0, 0], data: Vec::new() }));+    }+    let mut r = Reader { b: body, at: 0 };+    let (_, flags) = r.element()?;+    let class = flags.first().copied().ok_or_else(|| bad("no array flags"))?;+    let complex = flags.get(1).is_some_and(|f| f & 0x08 != 0);+    let (dty, dims) = r.element()?;+    let dims: Vec<usize> = numbers(dty, dims)?.into_iter().map(|d| if d.is_finite() && d >= 0.0 { d as usize } else { 0 }).collect();+    if dims.iter().any(|d| *d > MAX_DIM) {+        return Err(bad("a dimension larger than any real variable"));+    }+    let count = dims.iter().try_fold(1usize, |n, d| n.checked_mul(*d)).ok_or_else(|| bad("dimensions overflow"))?;+    let (_, name) = r.element()?;+    let name = String::from_utf8_lossy(name).into_owned();+    let value = match class {+        1 => {+            // every item takes at least a tag (8 bytes), which bounds a hostile count by the data+            if count > body.len() / 8 {+                return Err(bad("a cell array larger than its data"));+            }+            let mut items = Vec::with_capacity(count);+            for _ in 0..count {+                let (ty, b) = r.element()?;+                if ty != 14 {+                    return Err(bad("a cell that is not a matrix"));+                }+                items.push(matrix(b, depth + 1)?.1);+            }+            Value::Cell(items)+        }+        4 => {+            let (ty, d) = r.element()?;+            let text = match ty {+                16 => String::from_utf8_lossy(d).into_owned(),+                _ => numbers(ty, d)?.into_iter().map(|c| char::from_u32(c as u32).unwrap_or('\u{fffd}')).collect(),+            };+            Value::Char(text)+        }+        6..=15 => {+            let (ty, d) = r.element()?;+            let data = numbers(ty, d)?;+            if data.len() != count {+                return Err(bad("a matrix whose data does not match its dimensions"));+            }+            if complex && !r.done() {+                r.element()?;+            }+            Value::Num { dims, data }+        }+        _ => Value::Other,+    };+    Ok((name, value))+}++/// The variables in a MAT-file's bytes, by name.+pub fn parse(bytes: &[u8]) -> Result<Vec<(String, Value)>, String> {+    if bytes.len() < 128 {+        return Err(bad("shorter than its header"));+    }+    if bytes.get(126..128) != Some(b"IM") {+        return Err(bad("not a little-endian level-5 MAT-file (v7.3 HDF5 files are not supported)"));+    }+    let mut r = Reader { b: bytes, at: 128 };+    let mut out = Vec::new();+    while !r.done() {+        let (ty, body) = r.element()?;+        match ty {+            14 => out.push(matrix(body, 0)?),+            15 => {+                let mut inflated = Vec::new();+                flate2::read::ZlibDecoder::new(body)+                    .take(MAX_INFLATED)+                    .read_to_end(&mut inflated)+                    .map_err(|e| bad(&format!("compressed variable: {e}")))?;+                let (ity, ibody) = Reader { b: &inflated, at: 0 }.element()?;+                if ity == 14 {+                    out.push(matrix(ibody, 0)?);+                }+            }+            _ => {}+        }+    }+    Ok(out)+}++/// The variable `name` in `vars`.+pub fn var<'a>(vars: &'a [(String, Value)], name: &str) -> Result<&'a Value, String> {+    vars.iter().find(|(n, _)| n == name).map(|(_, v)| v).ok_or_else(|| format!("the MAT-file has no variable `{name}`"))+}++/// Builders for synthetic MAT-files, shared with the ground-truth tests.+#[cfg(test)]+pub(crate) mod tests {+    use super::*;++    fn element(ty: u32, data: &[u8]) -> Vec<u8> {+        let mut out = ty.to_le_bytes().to_vec();+        out.extend_from_slice(&(data.len() as u32).to_le_bytes());+        out.extend_from_slice(data);+        out.resize(out.len().div_ceil(8) * 8, 0);+        out+    }++    fn header(class: u8, dims: &[i32], name: &str) -> Vec<u8> {+        let mut out = element(6, &[class, 0, 0, 0, 0, 0, 0, 0]);+        out.extend(element(5, &dims.iter().flat_map(|d| d.to_le_bytes()).collect::<Vec<_>>()));+        out.extend(element(1, name.as_bytes()));+        out+    }++    /// A double matrix as a miMATRIX element (column-major `data`).+    pub(crate) fn double(name: &str, rows: i32, cols: i32, data: &[f64]) -> Vec<u8> {+        let mut body = header(6, &[rows, cols], name);+        body.extend(element(9, &data.iter().flat_map(|d| d.to_le_bytes()).collect::<Vec<_>>()));+        element(14, &body)+    }++    pub(crate) fn chars(name: &str, text: &str) -> Vec<u8> {+        let mut body = header(4, &[1, text.len() as i32], name);+        body.extend(element(16, text.as_bytes()));+        element(14, &body)+    }++    pub(crate) fn cell(name: &str, items: &[Vec<u8>]) -> Vec<u8> {+        let mut body = header(1, &[items.len() as i32, 1], name);+        for item in items {+            body.extend_from_slice(item);+        }+        element(14, &body)+    }++    /// A MAT-file: the 128-byte header, then `variables`.+    pub(crate) fn file(variables: &[Vec<u8>]) -> Vec<u8> {+        let mut out = vec![0u8; 128];+        out[126] = b'I';+        out[127] = b'M';+        for v in variables {+            out.extend_from_slice(v);+        }+        out+    }++    #[test]+    fn reads_numbers_text_and_nested_cells() {+        let bytes = file(&[double("m", 2, 2, &[1.0, 2.0, 3.0, 4.0]), cell("c", &[chars("", "ab"), cell("", &[double("", 1, 1, &[7.0])])])]);+        let vars = parse(&bytes).unwrap();+        // column-major [1 3; 2 4] has the rows [1, 3] and [2, 4]+        assert_eq!(var(&vars, "m").unwrap().rows(), Some(vec![vec![1.0, 3.0], vec![2.0, 4.0]]));+        let c = var(&vars, "c").unwrap().cells().unwrap();+        assert_eq!(c[0].text(), Some("ab"));+        assert_eq!(c[1].cells().unwrap()[0].numbers(), Some(&[7.0][..]));+        assert!(var(&vars, "absent").is_err());+    }++    #[test]+    fn a_matrix_without_data_has_no_rows_whatever_its_dimensions_claim() {+        // dims [N, 0] hold no data; building N empty rows from a hostile N would exhaust memory+        let empty = Value::Num { dims: vec![1 << 20, 0], data: Vec::new() };+        assert_eq!(empty.rows(), Some(Vec::new()));+        // a dimension no real variable has is refused at parse time+        let hostile = file(&[double("m", i32::MAX, 0, &[])]);+        assert!(parse(&hostile).is_err());+        // an ordinary empty matrix still parses and has no rows+        let vars = parse(&file(&[double("m", 0, 0, &[])])).unwrap();+        assert_eq!(var(&vars, "m").unwrap().rows(), Some(Vec::new()));+    }++    #[test]+    fn malformed_files_are_errors() {+        assert!(parse(b"too short").is_err());+        let mut wrong_endian = file(&[]);+        wrong_endian[126] = b'M';+        wrong_endian[127] = b'I';+        assert!(parse(&wrong_endian).is_err());+        let good = file(&[double("m", 1, 2, &[1.0, 2.0])]);+        for cut in [130, 140, good.len() - 1] {+            assert!(parse(&good[..cut]).is_err(), "cut at {cut}");+        }+        // a cell array that claims far more items than it has bytes+        let mut hostile = header(1, &[i32::MAX, 1], "c");+        hostile.extend(element(14, &[]));+        assert!(parse(&file(&[element(14, &hostile)])).is_err());+    }+}diff --git a/xtask/src/ai_bench/metrics.rs b/xtask/src/ai_bench/metrics.rsnew file mode 100644--- /dev/null+++ b/xtask/src/ai_bench/metrics.rs@@ -0,0 +1,295 @@+//! WIDER FACE average precision, implemented from the published description of the evaluation (Yang et al., "WIDER+//! FACE", CVPR 2016, and the evaluation toolkit's documented procedure; no evaluation code was copied): detection+//! scores are normalised over the whole set, a detection hits a face when their intersection over union is at least 0.5+//! (with the toolkit's inclusive-pixel `+1` convention), faces outside the setting's keep list are neither counted nor+//! punished, precision and recall are taken at 1,000 score thresholds and the area under the curve is the VOC+//! all-point average precision.++/// How many score thresholds the precision/recall curve has.+const THRESHOLDS: usize = 1000;+/// Overlap at which a detection hits a face.+const MIN_IOU: f64 = 0.5;++/// One image: ground-truth boxes `[x, y, w, h]`, the 1-based indices of the faces this setting counts, and detections+/// `[x, y, w, h, score]`, all in pixels.+pub struct Image<'a> {+    pub gt: &'a [[f64; 4]],+    pub keep: &'a [usize],+    pub preds: &'a [[f64; 5]],+}++/// IoU of two `[x1, y1, x2, y2]` boxes, counting pixels inclusively (a box from 0 to 9 is ten pixels wide).+fn iou(a: &[f64; 4], b: &[f64; 4]) -> f64 {+    let iw = a[2].min(b[2]) - a[0].max(b[0]) + 1.0;+    let ih = a[3].min(b[3]) - a[1].max(b[1]) + 1.0;+    if iw <= 0.0 || ih <= 0.0 {+        return 0.0;+    }+    let area = |r: &[f64; 4]| (r[2] - r[0] + 1.0) * (r[3] - r[1] + 1.0);+    let inter = iw * ih;+    let union = area(a) + area(b) - inter;+    if union > 0.0 { inter / union } else { 0.0 }+}++/// VOC all-point average precision of a precision/recall curve (recall ascending).+fn voc_ap(recall: &[f64], precision: &[f64]) -> f64 {+    let mut mrec = vec![0.0];+    mrec.extend_from_slice(recall);+    mrec.push(1.0);+    let mut mpre = vec![0.0];+    mpre.extend_from_slice(precision);+    mpre.push(0.0);+    for i in (1..mpre.len()).rev() {+        mpre[i - 1] = mpre[i - 1].max(mpre[i]);+    }+    mrec.windows(2).zip(mpre.iter().skip(1)).filter(|(r, _)| r[1] != r[0]).map(|(r, p)| (r[1] - r[0]) * p).sum()+}++/// The WIDER FACE average precision (0 to 1) of one setting (easy, medium or hard: the `keep` lists) over `images`.+pub fn wider_ap(images: &[Image]) -> f64 {+    let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);+    for p in images.iter().flat_map(|i| i.preds).filter(|p| p[4].is_finite()) {+        lo = lo.min(p[4]);+        hi = hi.max(p[4]);+    }+    let span = if hi > lo { hi - lo } else { 1.0 };+    // per threshold: detections kept that are not on an uncounted face, and counted faces found+    let mut curve = vec![(0.0f64, 0.0f64); THRESHOLDS];+    let mut faces = 0usize;+    for img in images {+        faces += img.keep.len();+        if img.gt.is_empty() || img.preds.is_empty() {+            continue;+        }+        let gt: Vec<[f64; 4]> = img.gt.iter().map(|g| [g[0], g[1], g[0] + g[2], g[1] + g[3]]).collect();+        let mut preds: Vec<([f64; 4], f64)> = img+            .preds+            .iter()+            .filter(|p| p[4].is_finite())+            .map(|p| ([p[0], p[1], p[0] + p[2], p[1] + p[3]], ((p[4] - lo) / span).clamp(0.0, 1.0)))+            .collect();+        preds.sort_by(|a, b| b.1.total_cmp(&a.1));+        // per face: whether this setting counts it, and 0 unseen / 1 found / -1 matched by a detection that is then ignored+        let mut state: Vec<(bool, i8)> = vec![(false, 0); gt.len()];+        for k in img.keep {+            if let Some(s) = k.checked_sub(1).and_then(|i| state.get_mut(i)) {+                s.0 = true;+            }+        }+        // after each detection (best first): the proposals so far and the counted faces found so far+        let (mut proposals, mut recalled) = (0usize, 0usize);+        let mut after: Vec<(usize, usize)> = Vec::with_capacity(preds.len());+        for (b, _) in &preds {+            let mut best = (0.0f64, None);+            for (j, g) in gt.iter().enumerate() {+                let o = iou(b, g);+                if o > best.0 {+                    best = (o, Some(j));+                }+            }+            let mut ignored = false;+            if let (o, Some(j)) = best+                && o >= MIN_IOU+                && let Some((counted, found)) = state.get_mut(j)+            {+                if !*counted {+                    if *found == 1 {+                        recalled = recalled.saturating_sub(1);+                    }+                    *found = -1;+                    ignored = true;+                } else if *found == 0 {+                    *found = 1;+                    recalled += 1;+                }+            }+            if !ignored {+                proposals += 1;+            }+            after.push((proposals, recalled));+        }+        for (t, point) in curve.iter_mut().enumerate() {+            let threshold = 1.0 - (t + 1) as f64 / THRESHOLDS as f64;+            if let Some((p, r)) = preds.iter().rposition(|p| p.1 >= threshold).and_then(|last| after.get(last)) {+                point.0 += *p as f64;+                point.1 += *r as f64;+            }+        }+    }+    if faces == 0 {+        return 0.0;+    }+    let precision: Vec<f64> = curve.iter().map(|c| if c.0 > 0.0 { c.1 / c.0 } else { 0.0 }).collect();+    let recall: Vec<f64> = curve.iter().map(|c| c.1 / faces as f64).collect();+    voc_ap(&recall, &precision)+}++/// The cut-off (a pair is "same" when its score is at least the cut-off) that gets most of `pairs` (score, same person)+/// right. Non-finite scores are ignored; among equally good cut-offs the lowest wins.+fn best_cutoff(pairs: &[(f64, bool)]) -> f64 {+    let mut sorted: Vec<(f64, bool)> = pairs.iter().copied().filter(|p| p.0.is_finite()).collect();+    sorted.sort_by(|a, b| a.0.total_cmp(&b.0));+    // a cut-off below every score calls every pair "same"+    let mut right = sorted.iter().filter(|p| p.1).count() as i64;+    let mut best = (right, sorted.first().map_or(0.0, |p| p.0 - 1.0));+    let mut groups = sorted.chunk_by(|a, b| a.0 == b.0).peekable();+    while let Some(group) = groups.next() {+        // raising the cut-off past this score turns its pairs into "different"+        right += group.iter().map(|p| if p.1 { -1 } else { 1 }).sum::<i64>();+        let score = group.first().map_or(0.0, |p| p.0);+        let cutoff = groups.peek().and_then(|next| next.first()).map_or(score + 1.0, |n| (score + n.0) / 2.0);+        if right > best.0 {+            best = (right, cutoff);+        }+    }+    best.1+}++/// LFW's verification accuracy: 10-fold cross-validation over `pairs` (score, same person, fold). For each fold the+/// cut-off that is best on the other folds is applied to it; the result is the mean and the standard deviation of the+/// folds' accuracies, as fractions. A pair without a score (`None`: a photo could not be embedded) takes no part in+/// choosing a cut-off and counts as wrong in its own fold.+pub fn fold_accuracy(pairs: &[(Option<f64>, bool, usize)]) -> (f64, f64) {+    let folds: std::collections::BTreeSet<usize> = pairs.iter().map(|p| p.2).collect();+    let mut accuracies = Vec::new();+    for fold in folds {+        let train: Vec<(f64, bool)> = pairs.iter().filter(|p| p.2 != fold).filter_map(|p| Some((p.0?, p.1))).collect();+        let test: Vec<&(Option<f64>, bool, usize)> = pairs.iter().filter(|p| p.2 == fold).collect();+        let cutoff = best_cutoff(&train);+        let right = test.iter().filter(|p| p.0.is_some_and(|score| (score >= cutoff) == p.1)).count();+        accuracies.push(right as f64 / test.len().max(1) as f64);+    }+    if accuracies.is_empty() {+        return (0.0, 0.0);+    }+    let mean = accuracies.iter().sum::<f64>() / accuracies.len() as f64;+    let variance = accuracies.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / accuracies.len() as f64;+    (mean, variance.sqrt())+}++#[cfg(test)]+mod tests {+    use super::*;++    fn close(a: f64, b: f64) {+        assert!((a - b).abs() < 1e-9, "{a} != {b}");+    }++    #[test]+    fn the_best_cutoff_sits_between_the_scores_it_separates() {+        // different 0.2, same 0.3, different 0.7, same 0.8: calling everything "same" gets 2 right, a cut-off in+        // (0.2, 0.3] gets 3, one in (0.7, 0.8] also 3; the lowest of the best is the midpoint 0.25+        close(best_cutoff(&[(0.8, true), (0.2, false), (0.7, false), (0.3, true)]), 0.25);+        // perfectly separable+        close(best_cutoff(&[(0.1, false), (0.2, false), (0.8, true), (0.9, true)]), 0.5);+        // every pair the same person: a cut-off below all of them+        close(best_cutoff(&[(0.4, true), (0.6, true)]), -0.6);+        // every pair a different person: above all of them+        close(best_cutoff(&[(0.4, false), (0.6, false)]), 1.6);+        // equal scores cannot be split: the pair stays together and the cut-off stays at the start+        close(best_cutoff(&[(0.5, true), (0.5, false)]), -0.5);+        // a non-finite score is ignored, and nothing at all has a neutral cut-off+        close(best_cutoff(&[(f64::NAN, true), (0.1, false), (0.9, true)]), 0.5);+        close(best_cutoff(&[]), 0.0);+    }++    /// Three folds of two pairs. The cut-off for fold 0 is trained on folds 1 and 2 (different 0.2, same 0.3, different+    /// 0.7, same 0.8: best 0.25), which gets fold 0's 0.9 same and 0.1 different both right: 1. For fold 1 it is trained+    /// on 0.1 F, 0.3 T, 0.7 F, 0.9 T: best 0.2, which calls 0.8 right and 0.2 "same" wrongly: 1/2. For fold 2 it is+    /// trained on 0.1 F, 0.2 F, 0.8 T, 0.9 T: best 0.5, which gets both of fold 2's inverted pairs wrong: 0.+    /// Mean (1 + 1/2 + 0) / 3 = 1/2; variance (1/4 + 0 + 1/4) / 3 = 1/6.+    #[test]+    fn fold_accuracy_by_hand() {+        let pairs =+            [(Some(0.9), true, 0), (Some(0.1), false, 0), (Some(0.8), true, 1), (Some(0.2), false, 1), (Some(0.3), true, 2), (Some(0.7), false, 2)];+        let (mean, std) = fold_accuracy(&pairs);+        close(mean, 0.5);+        close(std, (1.0f64 / 6.0).sqrt());+        // separable in every fold: all right, no spread+        let clean = [(Some(0.9), true, 0), (Some(0.1), false, 0), (Some(0.8), true, 1), (Some(0.2), false, 1)];+        assert_eq!(fold_accuracy(&clean), (1.0, 0.0));+        // no pairs: zero, not a division by zero+        assert_eq!(fold_accuracy(&[]), (0.0, 0.0));+    }++    #[test]+    fn a_pair_without_a_score_is_wrong_and_does_not_move_the_cut_off() {+        // two separable folds; fold 0 also holds a different-person pair whose photo failed (no score) and fold 1 two same-person ones+        let pairs = [+            (Some(0.9), true, 0),+            (Some(0.1), false, 0),+            (None, false, 0),+            (Some(0.8), true, 1),+            (Some(0.2), false, 1),+            (None, true, 1),+            (None, true, 1),+        ];+        // fold 0: 2 of 3 right (the failed different pair gets no credit); fold 1: 2 of 4 right+        let (mean, std) = fold_accuracy(&pairs);+        close(mean, (2.0 / 3.0 + 0.5) / 2.0);+        close(std, (2.0f64 / 3.0 - 0.5).abs() / 2.0);+        // failures are not training data: without them every fold is separable+        let scored: Vec<_> = pairs.iter().filter(|p| p.0.is_some()).copied().collect();+        close(fold_accuracy(&scored).0, 1.0);+        // nothing scored at all: every pair is wrong+        close(fold_accuracy(&[(None, true, 0), (None, false, 0), (None, true, 1), (None, false, 1)]).0, 0.0);+    }++    #[test]+    fn iou_counts_pixels_inclusively() {+        let a = [0.0, 0.0, 9.0, 9.0]; // 10 x 10 pixels+        close(iou(&a, &a), 1.0);+        close(iou(&a, &[20.0, 20.0, 29.0, 29.0]), 0.0);+        // 5 x 10 shared of 10 x 10 and 10 x 10: 50 / 150+        close(iou(&a, &[5.0, 0.0, 14.0, 9.0]), 1.0 / 3.0);+        // touching at one pixel column still shares a pixel under the inclusive convention+        close(iou(&a, &[9.0, 0.0, 18.0, 9.0]), 10.0 / 190.0);+        // a degenerate pair has no overlap rather than a division by zero+        close(iou(&[5.0, 5.0, 4.0, 4.0], &[5.0, 5.0, 4.0, 4.0]), 0.0);+    }++    #[test]+    fn a_detection_hits_from_half_overlap() {+        let gt = [[0.0, 0.0, 10.0, 10.0]]; // pixels 0..=10 under the +1 convention: 11 x 11+        // 11 x 11 box against an 11 x 6 detection sharing its top rows: 66 / 121 > 0.5 hits+        let hit = [[0.0, 0.0, 10.0, 5.0, 1.0]];+        close(wider_ap(&[Image { gt: &gt, keep: &[1], preds: &hit }]), 1.0);+        // 11 x 5: 55 / 121 < 0.5 misses+        let miss = [[0.0, 0.0, 10.0, 4.0, 1.0]];+        close(wider_ap(&[Image { gt: &gt, keep: &[1], preds: &miss }]), 0.0);+    }++    /// Two faces, three detections: a hit (score .75), a false positive (.5) and a hit (.25). Scores normalise to 1, .5+    /// and 0. At thresholds .999 to .501 only the first detection counts (precision 1, recall 1/2, 499 times); at .500 to+    /// .001 the first two (precision 1/2, recall 1/2, 500 times); at 0 all three (precision 2/3, recall 1). The+    /// precision envelope is 1 up to recall 1/2 and 2/3 beyond it: AP = 1/2 * 1 + 1/2 * 2/3 = 5/6.+    #[test]+    fn average_precision_by_hand() {+        let gt = [[0.0, 0.0, 10.0, 10.0], [100.0, 100.0, 10.0, 10.0]];+        let preds = [[0.0, 0.0, 10.0, 10.0, 0.75], [300.0, 300.0, 10.0, 10.0, 0.5], [100.0, 100.0, 10.0, 10.0, 0.25]];+        close(wider_ap(&[Image { gt: &gt, keep: &[1, 2], preds: &preds }]), 5.0 / 6.0);+        // the same detections without the false positive: a perfect curve+        let clean = [preds[0], preds[2]];+        close(wider_ap(&[Image { gt: &gt, keep: &[1, 2], preds: &clean }]), 1.0);+    }++    #[test]+    fn faces_outside_the_keep_list_are_neither_missed_nor_punished() {+        let gt = [[0.0, 0.0, 10.0, 10.0], [100.0, 100.0, 10.0, 10.0]];+        // only the first face counts; a detection on the second must not count as a false positive+        let both = [[0.0, 0.0, 10.0, 10.0, 0.9], [100.0, 100.0, 10.0, 10.0, 0.8]];+        close(wider_ap(&[Image { gt: &gt, keep: &[1], preds: &both }]), 1.0);+        // and not finding it costs no recall+        close(wider_ap(&[Image { gt: &gt, keep: &[1], preds: &both[..1] }]), 1.0);+    }++    #[test]+    fn nothing_to_find_or_nothing_found_scores_zero() {+        let gt = [[0.0, 0.0, 10.0, 10.0]];+        close(wider_ap(&[]), 0.0);+        close(wider_ap(&[Image { gt: &gt, keep: &[1], preds: &[] }]), 0.0);+        close(wider_ap(&[Image { gt: &gt, keep: &[], preds: &[[0.0, 0.0, 10.0, 10.0, 0.9]] }]), 0.0);+        // a non-finite score is dropped, not propagated+        close(wider_ap(&[Image { gt: &gt, keep: &[1], preds: &[[0.0, 0.0, 10.0, 10.0, f64::NAN]] }]), 0.0);+    }+}diff --git a/xtask/src/ai_bench/mod.rs b/xtask/src/ai_bench/mod.rsnew file mode 100644--- /dev/null+++ b/xtask/src/ai_bench/mod.rs@@ -0,0 +1,226 @@+//! `cargo xtask ai-bench`: measure how well LightCraft's own models do on public benchmarks, so the accuracy figures in the+//! docs can be reproduced. Two benchmarks so far: face detection (YuNet) on the WIDER FACE validation set and face+//! recognition (SFace, AuraFace) on LFW.+//!+//! ```text+//! cargo xtask ai-bench fetch --accept-terms [--set SET] [--dir DIR]+//! cargo xtask ai-bench run face-detection --set wider-face-val [--dir DIR] [--model FILE] [--limit N] [--threads N]+//! cargo xtask ai-bench run face-recognition --set lfw [--dir DIR] [--model FILE] [--limit N] [--threads N]+//! ```++mod fetch;+mod lfw;+mod mat;+mod metrics;+mod wider;++use std::path::{Path, PathBuf};++use lightcraft_faces::runtime::Embedder;+use lightcraft_faces::{known, yunet};++pub const USAGE: &str = "\+  ai-bench fetch --accept-terms [--set wider-face-val|lfw] [--dir DIR]+                  download the benchmark photos and ground truth (WIDER FACE validation: CC BY-NC-ND 4.0; LFW: research+                  use only; both are kept on your machine, never committed) and the models they need into DIR (default+                  corpus/ai-bench), checking pinned SHA-256 sums; without --set both sets+  ai-bench run face-detection --set wider-face-val [--dir DIR] [--model FILE] [--limit N] [--threads N]+                  run YuNet on every validation photo and print the WIDER FACE average precision for the easy,+                  medium and hard settings and the wall time (--limit N: N photos spread evenly, for a quick look;+                  --model: another YuNet-architecture .onnx, default DIR/models/face_detection_yunet_2023mar.onnx)+  ai-bench run face-recognition --set lfw [--dir DIR] [--model FILE] [--limit N] [--threads N]+                  detect, align and embed the photos of LFW's 6,000 pairs the way Photo > Detect/Recognize Faces does and+                  print the 10-fold verification accuracy and the wall time for SFace and AuraFace (--limit N: N pairs+                  spread evenly; --model: one recognition .onnx LightCraft knows by its SHA-256 instead of those two)+";++/// What a recognition model's makers publish for LFW (percent) and the page that says so.+const PUBLISHED_LFW: [(&str, f64, &str); 2] = [+    (+        "sface-2021dec",+        99.40,+        "https://github.com/opencv/opencv_zoo/blob/25f423d0e04c31a17254620e58febd7386da523b/models/face_recognition_sface/README.md",+    ),+    ("auraface-v1", 99.65, "https://huggingface.co/fal/AuraFace-v1/blob/af6d057c9b0ec4071d4c49c80e3539258798b609/README.md"),+];++struct Args {+    dir: PathBuf,+    model: Option<PathBuf>,+    limit: Option<usize>,+    threads: usize,+    accept_terms: bool,+    set: Option<String>,+}++fn parse(root: &Path, args: &[&str]) -> Result<Args, String> {+    let mut a = Args {+        dir: root.join("corpus").join("ai-bench"),+        model: None,+        limit: None,+        threads: std::thread::available_parallelism().map_or(4, |n| n.get()),+        accept_terms: false,+        set: None,+    };+    let mut it = args.iter();+    while let Some(&flag) = it.next() {+        if flag == "--accept-terms" {+            a.accept_terms = true;+            continue;+        }+        let value = it.next().ok_or_else(|| format!("{flag} needs a value"))?;+        let number = |what: &str| value.parse::<usize>().map_err(|_| format!("{what} must be a whole number, not {value}"));+        match flag {+            "--dir" => a.dir = PathBuf::from(value),+            "--model" => a.model = Some(PathBuf::from(value)),+            "--set" => a.set = Some((*value).to_string()),+            "--limit" => match number("--limit")? {+                0 => return Err("--limit must be at least 1 (omit it to use every photo or pair)".to_string()),+                k => a.limit = Some(k),+            },+            "--threads" => a.threads = number("--threads")?,+            _ => return Err(format!("unknown option `{flag}`\n\n{USAGE}")),+        }+    }+    Ok(a)+}++pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {+    match args {+        ["fetch", rest @ ..] => {+            let a = parse(root, rest)?;+            let sets = match a.set.as_deref() {+                None => vec![&fetch::WIDER, &fetch::LFW],+                Some(id) if id == fetch::WIDER.id => vec![&fetch::WIDER],+                Some(id) if id == fetch::LFW.id => vec![&fetch::LFW],+                Some(other) => return Err(format!("no benchmark set `{other}` (wider-face-val or lfw)")),+            };+            fetch::run(&a.dir, a.accept_terms, &sets)+        }+        ["run", "face-detection", rest @ ..] => {+            let a = parse(root, rest)?;+            match a.set.as_deref() {+                Some(id) if id == fetch::WIDER.id => face_detection(&a),+                other => Err(format!("face-detection needs --set {} (the only set so far), not {}", fetch::WIDER.id, other.unwrap_or("nothing"))),+            }+        }+        ["run", "face-recognition", rest @ ..] => {+            let a = parse(root, rest)?;+            match a.set.as_deref() {+                Some(id) if id == fetch::LFW.id => face_recognition(&a),+                other => Err(format!("face-recognition needs --set {} (the only set so far), not {}", fetch::LFW.id, other.unwrap_or("nothing"))),+            }+        }+        _ => Err(format!("usage:\n{USAGE}")),+    }+}++/// The YuNet detector from `DIR/models` (or `model`), and whether it is the pinned 2023mar file.+fn load_detector(a: &Args, model: Option<&Path>) -> Result<(yunet::Detector, PathBuf, bool), String> {+    let file = known::download(known::YUNET_ID).map(|d| d.file_name).ok_or("no YuNet file name")?;+    let model = model.map_or_else(|| a.dir.join("models").join(file), Path::to_path_buf);+    let bytes = std::fs::read(&model).map_err(|e| format!("{}: {e} (run `cargo xtask ai-bench fetch` or pass --model)", model.display()))?;+    let detector = yunet::Detector::new(&bytes).map_err(|e| format!("{}: {e}", model.display()))?;+    let pinned = crate::corpus::file_sha256(&model).is_ok_and(|h| h == known::YUNET_SHA256);+    Ok((detector, model, pinned))+}++fn face_detection(a: &Args) -> Result<(), String> {+    let (detector, model, pinned) = load_detector(a, a.model.as_deref())?;+    let side = detector.input_side();+    println!(+        "YuNet {} ({}), input {side} px, {} thread(s)",+        model.display(),+        if pinned { "the pinned 2023mar model" } else { "NOT the pinned 2023mar model" },+        a.threads+    );+    let r = wider::run(&a.dir.join(fetch::WIDER.id), &detector, a.limit, a.threads)?;+    println!(+        "WIDER FACE validation, {} photos{}",+        r.photos,+        if r.failed > 0 { format!(" ({} failed, scored as no detections)", r.failed) } else { String::new() }+    );+    for (setting, ap) in wider::SETTINGS.iter().zip(r.ap) {+        println!("  AP {setting:<6} {ap:.1}");+    }+    println!("  wall time {:.1} s ({:.0} ms per photo)", r.seconds, r.seconds * 1000.0 / r.photos.max(1) as f64);+    Ok(())+}++/// The recognition models to measure: `--model`'s file, else the downloaded SFace and AuraFace that are in `DIR/models`.+fn recognisers(a: &Args) -> Result<Vec<(PathBuf, lightcraft_faces::ModelManifest)>, String> {+    let known_model = |path: &Path| {+        let sha = crate::corpus::file_sha256(path)?;+        known::lookup(&sha)+            .filter(|m| m.role == lightcraft_faces::Role::Embedder)+            .map(|m| (path.to_path_buf(), m))+            .ok_or_else(|| format!("{}: not a recognition model LightCraft knows by its SHA-256 ({sha})", path.display()))+    };+    if let Some(model) = &a.model {+        return Ok(vec![known_model(model)?]);+    }+    let mut found = Vec::new();+    for (id, _, _) in PUBLISHED_LFW {+        let file = known::download(id).map(|d| d.file_name).ok_or_else(|| format!("no download of {id}"))?;+        let path = a.dir.join("models").join(file);+        if path.is_file() {+            found.push(known_model(&path)?);+        } else {+            println!("skipping {id}: {} is not there (run `cargo xtask ai-bench fetch --set lfw`)", path.display());+        }+    }+    if found.is_empty() {+        return Err(format!("no recognition model in {} (run `cargo xtask ai-bench fetch --set lfw`)", a.dir.join("models").display()));+    }+    Ok(found)+}++fn face_recognition(a: &Args) -> Result<(), String> {+    let (detector, detector_file, pinned) = load_detector(a, None)?;+    println!(+        "device: CPU (LightCraft's pure-Rust engine, one thread per photo), {} worker thread(s)\nYuNet {} ({}), input {} px, default options",+        a.threads,+        detector_file.display(),+        if pinned { "the pinned 2023mar model" } else { "NOT the pinned 2023mar model" },+        detector.input_side()+    );+    for (path, manifest) in recognisers(a)? {+        let embedder = Embedder::load(&path, &manifest).map_err(|e| format!("{}: {e}", path.display()))?;+        let r = lfw::run(&a.dir.join(fetch::LFW.id), &detector, &embedder, a.limit, a.threads)?;+        println!("{} ({}), LFW view 2, {} pairs of {} photos", manifest.name, path.display(), r.pairs, r.photos);+        println!("  10-fold accuracy {:.2} % (standard deviation {:.2})", r.accuracy.0, r.accuracy.1);+        if let Some((_, published, page)) = PUBLISHED_LFW.iter().find(|(id, _, _)| *id == manifest.id) {+            println!("  published: {published:.2} % ({page})");+        }+        if r.fallbacks > 0 {+            println!("  {} photos had no face found: the centre was cut out", r.fallbacks);+        }+        if r.failed > 0 || r.failed_pairs > 0 {+            println!(+                "  {} photos could not be read or embedded: {} of the {} pairs have no score and count as wrong",+                r.failed, r.failed_pairs, r.pairs+            );+        }+        println!("  wall time {:.1} s ({:.0} ms per photo)", r.seconds, r.seconds * 1000.0 / r.photos.max(1) as f64);+    }+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn a_limit_of_zero_is_refused() {+        let e = parse(Path::new("."), &["--limit", "0"]).err().unwrap_or_default();+        assert!(e.contains("--limit") && e.contains("at least 1"), "{e:?}");+        assert!(parse(Path::new("."), &["--limit", "1"]).is_ok());+    }++    #[test]+    fn every_archive_of_every_set_is_unpacked_through_a_completion_marker() {+        // lfw.tgz and both WIDER zips are archives; pairs.txt is the only plain file+        let archives: Vec<(&str, bool)> = [&fetch::WIDER, &fetch::LFW].iter().flat_map(|s| s.archives()).collect();+        assert_eq!(archives, vec![("WIDER_val.zip", true), ("eval_tools.zip", true), ("lfw.tgz", true), ("pairs.txt", false)]);+    }+}diff --git a/xtask/src/ai_bench/wider.rs b/xtask/src/ai_bench/wider.rsnew file mode 100644--- /dev/null+++ b/xtask/src/ai_bench/wider.rs@@ -0,0 +1,343 @@+//! WIDER FACE validation: read the ground truth, run a detector on every photo, score the three settings.++use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicUsize, Ordering};+use std::time::Instant;++use lightcraft_faces::yunet::{Detector, Options};++use super::mat;+use super::metrics::{self, Image};++/// Lowest detection score kept: the protocol wants nearly every detection, the curve then sweeps the threshold.+const MIN_SCORE: f32 = 0.02;+/// Most detections per photo (the densest WIDER crowds hold well over a thousand faces).+const MAX_FACES: usize = 2000;+pub const SETTINGS: [&str; 3] = ["easy", "medium", "hard"];++/// The validation ground truth: per photo, its path and face boxes `[x, y, w, h]`; per setting, the 1-based faces it counts.+pub struct GroundTruth {+    pub images: Vec<PathBuf>,+    pub boxes: Vec<Vec<[f64; 4]>>,+    pub keep: [Vec<Vec<usize>>; 3],+}++fn read_mat(path: &Path) -> Result<Vec<(String, mat::Value)>, String> {+    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e} (run `cargo xtask ai-bench fetch`)", path.display()))?;+    mat::parse(&bytes).map_err(|e| format!("{}: {e}", path.display()))+}++/// Read the ground truth under `dir`: `eval_tools/ground_truth/*.mat` (the evaluation toolkit's) and the photos in+/// `WIDER_val/images/<event>/<name>.jpg`.+pub fn load(dir: &Path) -> Result<GroundTruth, String> {+    let gt = dir.join("eval_tools").join("ground_truth");+    let main = read_mat(&gt.join("wider_face_val.mat"))?;+    let events = mat::var(&main, "event_list")?.cells().ok_or("event_list is not a cell array")?;+    let files = mat::var(&main, "file_list")?.cells().ok_or("file_list is not a cell array")?;+    let boxes = mat::var(&main, "face_bbx_list")?.cells().ok_or("face_bbx_list is not a cell array")?;+    if files.len() != events.len() || boxes.len() != events.len() {+        return Err(format!("event_list has {} events, file_list {} and face_bbx_list {}", events.len(), files.len(), boxes.len()));+    }+    // per event: its name, photo names and box lists, the photo counts checked before anything is paired up+    let mut per_event = Vec::with_capacity(events.len());+    for (e, ((event, files), boxes)) in events.iter().zip(files).zip(boxes).enumerate() {+        let event = event.text().ok_or_else(|| format!("event {e}: event_list entry is not text"))?;+        let files = files.cells().ok_or_else(|| format!("event {e}: file_list entry is not a cell of cells"))?;+        let boxes = boxes.cells().ok_or_else(|| format!("event {e}: face_bbx_list entry is not a cell of cells"))?;+        if files.len() != boxes.len() {+            return Err(format!("event {e}: file_list has {} photos, face_bbx_list {}", files.len(), boxes.len()));+        }+        per_event.push((event, files, boxes));+    }+    let mut keeps: Vec<Vec<Vec<usize>>> = Vec::new();+    for setting in SETTINGS {+        let vars = read_mat(&gt.join(format!("wider_{setting}_val.mat")))?;+        let lists = mat::var(&vars, "gt_list")?.cells().ok_or("gt_list is not a cell array")?;+        if lists.len() != per_event.len() {+            return Err(format!("the {setting} setting lists {} events, the ground truth {}", lists.len(), per_event.len()));+        }+        let mut flat = Vec::new();+        for (e, (event, (_, files, _))) in lists.iter().zip(&per_event).enumerate() {+            let imgs = event.cells().ok_or_else(|| format!("event {e}: the {setting} setting is not a cell of cells"))?;+            if imgs.len() != files.len() {+                return Err(format!("event {e}: the {setting} setting lists {} photos, the ground truth {}", imgs.len(), files.len()));+            }+            for (i, img) in imgs.iter().enumerate() {+                flat.push(face_indices(img, setting, e, i)?);+            }+        }+        keeps.push(flat);+    }+    let root = dir.join("WIDER_val").join("images");+    let mut out_images = Vec::new();+    let mut out_boxes = Vec::new();+    for (e, (event, files, boxes)) in per_event.iter().enumerate() {+        for (file, face) in files.iter().zip(*boxes) {+            let name = file.text().ok_or_else(|| format!("event {e}: file_list entry is not text"))?;+            out_images.push(root.join(event).join(format!("{name}.jpg")));+            let rows = face.rows().ok_or_else(|| format!("event {e}: face_bbx_list entry is not a matrix"))?;+            out_boxes.push(rows.iter().filter_map(|r| <[f64; 4]>::try_from(r.get(..4)?).ok()).collect());+        }+    }+    let [easy, medium, hard]: [Vec<Vec<usize>>; 3] = keeps.try_into().map_err(|_| "three settings expected")?;+    Ok(GroundTruth { images: out_images, boxes: out_boxes, keep: [easy, medium, hard] })+}++/// The 1-based face numbers one photo's `gt_list` entry counts; a fractional, non-positive or absurd number is an error.+fn face_indices(entry: &mat::Value, setting: &str, event: usize, photo: usize) -> Result<Vec<usize>, String> {+    entry+        .numbers()+        .unwrap_or(&[])+        .iter()+        .map(|&v| {+            if v.is_finite() && v.fract() == 0.0 && (1.0..=1e9).contains(&v) {+                Ok(v as usize)+            } else {+                Err(format!("event {event}, photo {photo}: the {setting} setting has the face index {v}, expected a whole number from 1"))+            }+        })+        .collect()+}++/// A photo as 8-bit RGB: `(pixels, width, height)`.+pub(super) fn load_rgb(path: &Path) -> Result<(Vec<u8>, usize, usize), String> {+    use zune_core::bytestream::ZCursor;+    use zune_core::colorspace::ColorSpace;+    use zune_core::options::DecoderOptions;+    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;+    let options = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB);+    let mut decoder = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(&bytes), options);+    let pixels = decoder.decode().map_err(|e| format!("{}: {e:?}", path.display()))?;+    let info = decoder.info().ok_or_else(|| format!("{}: no image information", path.display()))?;+    let (w, h) = (usize::from(info.width), usize::from(info.height));+    match pixels.len().checked_div(w.saturating_mul(h)) {+        Some(3) => Ok((pixels, w, h)),+        Some(1) => Ok((pixels.iter().flat_map(|g| [*g; 3]).collect(), w, h)),+        _ => Err(format!("{}: unexpected pixel layout", path.display())),+    }+}++/// The detections of one photo as `[x, y, w, h, score]` in pixels.+fn detect(detector: &Detector, path: &Path) -> Result<Vec<[f64; 5]>, String> {+    let (rgb, w, h) = load_rgb(path)?;+    // the benchmark's own score, overlap and count limits; any other option keeps the detector's default+    #[allow(clippy::needless_update, reason = "options the detector gains later keep their defaults here")]+    let options = Options { score: MIN_SCORE, nms_iou: nms_iou(), max_faces: MAX_FACES, ..Options::default() };+    let faces = detector.detect(&rgb, w, h, &options).map_err(|e| format!("{}: {e}", path.display()))?;+    let (w, h) = (w as f64, h as f64);+    Ok(faces+        .iter()+        .map(|f| [f64::from(f.x0) * w, f64::from(f.y0) * h, f64::from(f.x1 - f.x0) * w, f64::from(f.y1 - f.y0) * h, f64::from(f.score)])+        .collect())+}++/// The suppression overlap LightCraft ships for YuNet.+fn nms_iou() -> f32 {+    lightcraft_faces::known::yunet().thresholds.nms_iou.unwrap_or(0.4)+}++pub struct Run {+    pub photos: usize,+    /// Photos that could not be read or searched (scored as having no detections).+    pub failed: usize,+    pub seconds: f64,+    /// Average precision in percent for easy, medium and hard.+    pub ap: [f64; 3],+}++/// Detect on every photo (or `limit` of them, spread evenly) on `threads` threads and score the three settings.+pub fn run(dir: &Path, detector: &Detector, limit: Option<usize>, threads: usize) -> Result<Run, String> {+    let truth = load(dir)?;+    let n = truth.images.len();+    let chosen: Vec<usize> = match limit {+        Some(k) if k > 0 && k < n => (0..k).map(|i| i * n / k).collect(),+        _ => (0..n).collect(),+    };+    let next = AtomicUsize::new(0);+    let finished = AtomicUsize::new(0);+    let started = Instant::now();+    let step = (chosen.len() / 20).max(1);+    let joined: Vec<Result<Vec<(usize, Result<Vec<[f64; 5]>, String>)>, String>> = std::thread::scope(|scope| {+        let workers: Vec<_> = (0..threads.clamp(1, 64))+            .map(|_| {+                scope.spawn(|| {+                    let mut done = Vec::new();+                    loop {+                        let at = next.fetch_add(1, Ordering::Relaxed);+                        let Some(path) = chosen.get(at).and_then(|&i| truth.images.get(i)) else { break };+                        done.push((at, detect(detector, path)));+                        let count = finished.fetch_add(1, Ordering::Relaxed) + 1;+                        if count.is_multiple_of(step) {+                            eprintln!("  {count}/{} ({:.1}/s)", chosen.len(), count as f64 / started.elapsed().as_secs_f64().max(1e-9));+                        }+                    }+                    done+                })+            })+            .collect();+        workers.into_iter().map(|w| w.join().map_err(|_| "a detection worker stopped unexpectedly".to_string())).collect()+    });+    let seconds = started.elapsed().as_secs_f64();+    let mut preds: Vec<Vec<[f64; 5]>> = vec![Vec::new(); chosen.len()];+    let mut failed = 0;+    for worker in joined {+        for (at, result) in worker? {+            match (result, preds.get_mut(at)) {+                (Ok(found), Some(slot)) => *slot = found,+                (Err(e), _) => {+                    if failed < 5 {+                        eprintln!("  warning: {e}");+                    }+                    failed += 1;+                }+                (Ok(_), None) => {}+            }+        }+    }+    let mut ap = [0.0; 3];+    for (s, score) in ap.iter_mut().enumerate() {+        let images: Vec<Image> = chosen+            .iter()+            .zip(&preds)+            .filter_map(|(&i, p)| Some(Image { gt: truth.boxes.get(i)?, keep: truth.keep.get(s)?.get(i)?, preds: p }))+            .collect();+        *score = metrics::wider_ap(&images) * 100.0;+    }+    Ok(Run { photos: chosen.len(), failed, seconds, ap })+}++#[cfg(test)]+mod tests {+    use super::super::mat::tests::{cell, chars, double, file};+    use super::*;++    /// A scratch directory that is removed afterwards.+    struct Scratch(PathBuf);++    impl Scratch {+        fn new(name: &str) -> Scratch {+            let dir = std::env::temp_dir().join(format!("ai-bench-{name}-{}", std::process::id()));+            let _ = std::fs::remove_dir_all(&dir);+            assert!(std::fs::create_dir_all(dir.join("eval_tools").join("ground_truth")).is_ok());+            Scratch(dir)+        }++        fn gt(&self, name: &str, bytes: &[u8]) {+            assert!(std::fs::write(self.0.join("eval_tools").join("ground_truth").join(name), bytes).is_ok());+        }+    }++    impl Drop for Scratch {+        fn drop(&mut self) {+            let _ = std::fs::remove_dir_all(&self.0);+        }+    }++    /// One event with one photo "a" holding two faces; the settings count the faces in `keeps`.+    fn write_truth(s: &Scratch, keeps: [&[f64]; 3]) {+        // the boxes [1 2 3 4] and [10 20 30 40], column-major+        let boxes = double("", 2, 4, &[1.0, 10.0, 2.0, 20.0, 3.0, 30.0, 4.0, 40.0]);+        let main = file(&[+            cell("event_list", &[chars("", "0--Parade")]),+            cell("file_list", &[cell("", &[chars("", "a")])]),+            cell("face_bbx_list", &[cell("", &[boxes])]),+        ]);+        s.gt("wider_face_val.mat", &main);+        for (setting, keep) in SETTINGS.iter().zip(keeps) {+            let list = file(&[cell("gt_list", &[cell("", &[double("", keep.len() as i32, 1, keep)])])]);+            s.gt(&format!("wider_{setting}_val.mat"), &list);+        }+    }++    #[test]+    fn ground_truth_reads_boxes_photos_and_the_faces_each_setting_counts() {+        let s = Scratch::new("reads");+        write_truth(&s, [&[1.0], &[1.0, 2.0], &[2.0]]);+        let truth = load(&s.0).unwrap();+        assert_eq!(truth.images, vec![s.0.join("WIDER_val").join("images").join("0--Parade").join("a.jpg")]);+        assert_eq!(truth.boxes, vec![vec![[1.0, 2.0, 3.0, 4.0], [10.0, 20.0, 30.0, 40.0]]]);+        assert_eq!(truth.keep, [vec![vec![1]], vec![vec![1, 2]], vec![vec![2]]]);+    }++    #[test]+    fn a_missing_annotation_file_is_an_error_that_says_how_to_fetch_it() {+        let s = Scratch::new("missing");+        let e = load(&s.0).err().unwrap();+        assert!(e.contains("wider_face_val.mat") && e.contains("ai-bench fetch"), "{e}");+        // the main file alone is not enough: the settings' files are named too+        write_truth(&s, [&[1.0], &[1.0], &[1.0]]);+        assert!(std::fs::remove_file(s.0.join("eval_tools").join("ground_truth").join("wider_hard_val.mat")).is_ok());+        assert!(load(&s.0).err().unwrap().contains("wider_hard_val.mat"));+    }++    #[test]+    fn malformed_annotation_files_are_errors_not_panics() {+        let s = Scratch::new("malformed");+        write_truth(&s, [&[1.0], &[1.0], &[1.0]]);+        // garbage, a truncated file and a valid file without the variable the loader needs+        s.gt("wider_face_val.mat", b"this is not a MAT-file");+        assert!(load(&s.0).is_err());+        write_truth(&s, [&[1.0], &[1.0], &[1.0]]);+        let good = std::fs::read(s.0.join("eval_tools").join("ground_truth").join("wider_easy_val.mat")).unwrap();+        s.gt("wider_easy_val.mat", &good[..good.len() - 5]);+        assert!(load(&s.0).is_err());+        s.gt("wider_easy_val.mat", &file(&[double("other", 1, 1, &[1.0])]));+        assert!(load(&s.0).err().unwrap().contains("gt_list"));+    }++    #[test]+    fn fractional_zero_and_negative_face_indices_are_errors_not_truncated() {+        for bad in [1.5, 0.0, -1.0, f64::NAN] {+            let s = Scratch::new("index");+            write_truth(&s, [&[1.0], &[1.0], &[1.0]]);+            let list = file(&[cell("gt_list", &[cell("", &[double("", 2, 1, &[1.0, bad])])])]);+            s.gt("wider_medium_val.mat", &list);+            let e = load(&s.0).err().unwrap_or_default();+            assert!(e.contains("medium") && e.contains("face index"), "{bad}: {e:?}");+        }+    }++    #[test]+    fn event_and_photo_counts_are_checked_and_errors_name_the_event() {+        // top level: two events, one file list+        let s = Scratch::new("top");+        write_truth(&s, [&[1.0], &[1.0], &[1.0]]);+        let boxes = double("", 1, 4, &[1.0, 2.0, 3.0, 4.0]);+        let main = file(&[+            cell("event_list", &[chars("", "0--A"), chars("", "1--B")]),+            cell("file_list", &[cell("", &[chars("", "a")])]),+            cell("face_bbx_list", &[cell("", std::slice::from_ref(&boxes)), cell("", std::slice::from_ref(&boxes))]),+        ]);+        s.gt("wider_face_val.mat", &main);+        let e = load(&s.0).err().unwrap_or_default();+        assert!(e.contains("event_list") && e.contains("file_list"), "{e:?}");+        // event 1 has two files but one box list+        let main = file(&[+            cell("event_list", &[chars("", "0--A"), chars("", "1--B")]),+            cell("file_list", &[cell("", &[chars("", "a")]), cell("", &[chars("", "b"), chars("", "c")])]),+            cell("face_bbx_list", &[cell("", std::slice::from_ref(&boxes)), cell("", &[boxes])]),+        ]);+        s.gt("wider_face_val.mat", &main);+        let e = load(&s.0).err().unwrap_or_default();+        assert!(e.contains("event 1"), "{e:?}");+    }++    #[test]+    fn a_setting_with_the_wrong_event_count_is_an_error() {+        let s = Scratch::new("events");+        write_truth(&s, [&[1.0], &[1.0], &[1.0]]);+        let item = cell("", &[double("", 1, 1, &[1.0])]);+        s.gt("wider_hard_val.mat", &file(&[cell("gt_list", &[item.clone(), item])]));+        let e = load(&s.0).err().unwrap_or_default();+        assert!(e.contains("hard") && e.contains("2 events"), "{e:?}");+    }++    #[test]+    fn settings_that_disagree_with_the_ground_truth_on_the_photo_count_are_an_error() {+        let s = Scratch::new("count");+        write_truth(&s, [&[1.0], &[1.0], &[1.0]]);+        let two = file(&[cell("gt_list", &[cell("", &[double("", 1, 1, &[1.0]), double("", 1, 1, &[1.0])])])]);+        s.gt("wider_medium_val.mat", &two);+        assert!(load(&s.0).err().unwrap().contains("event 0"));+    }+}diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs--- a/xtask/src/corpus.rs+++ b/xtask/src/corpus.rs@@ -46,7 +46,8 @@ Tests that use a corpus skip cleanly when it is absent.     )) } -fn file_sha256(path: &Path) -> Result<String, String> {+/// SHA-256 of a file as lowercase hex; `ai_bench` reuses it to verify pinned downloads.+pub(crate) fn file_sha256(path: &Path) -> Result<String, String> {     use sha2::{Digest, Sha256};     let mut file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;     let mut hash = Sha256::new();diff --git a/xtask/src/main.rs b/xtask/src/main.rs--- a/xtask/src/main.rs+++ b/xtask/src/main.rs@@ -3,6 +3,7 @@ //! Pure Rust (std + serde_json; flate2/brotli for the web bundle). External tools (`cargo`, `curl`, `tar`) are //! invoked through `std::process::Command`. +mod ai_bench; mod assets; mod bench; mod ci_lock;@@ -33,6 +34,8 @@ usage: cargo xtask <command>  commands:   assets          every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets+  ai-bench fetch|run+                  reproduce the face-detection accuracy on WIDER FACE validation; see docs/faces.md   bench [FILE] [--strict] [--threshold PCT]                   run the render benchmark, append to target/bench/history.jsonl, compare CPU time with                   the previous run (default input: corpus/raw/arw-sony-a7m3-compressed.arw)@@ -75,6 +78,7 @@ fn main() -> ExitCode {         Some("ico") => ico::run(&rest),         Some("version") => version::run(&root(), &rest),         Some("layers") => cmd_layers(),+        Some("ai-bench") => ai_bench::run(&root(), &rest),         Some("assets") => assets::run(&root()),         Some("bench") => bench::run(&root(), &rest),         Some("parity") => parity::run(&root(), rest.contains(&"--write")),