From f5956707e76013c7368e42517c4c114939fb30b8 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 19 Sep 2026 13:52:50 +0200 Subject: [PATCH] Cut the eye box from a landmark contour, and refuse eyes that cannot be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCRFD's eye point places a face, not an eye: on turned and smiling heads the classifier's window had the eye in a corner, and two model-free ways of re-centring it — the darkest blob, the most contrasty window — both lost open eyes (19 → 15 and 19 → 9 of 25). Three landmark models were then run over the same faces; Face Mesh V2 and InsightFace's 2d106det tied at 22 of 25 and 2d106det ships, being the cheapest by far and under the grant the detector and embedder already carry. The eye box is the tight bounding box of its ten lid points, cut upright from the native render, which is what the classifier was trained on. The larger change is that the reading now carries, per eye, the source pixels across the box and the sharpness of the patch — because the commonest wrong answer on the reference library was a soft eye read as closed, and a classifier shown a smear will always say something. An eye under either floor, or narrower than six tenths of its partner (the far eye of a turned head, whose contour collapses), is not asked; a face with no readable eye is a fourth state, Unreadable, that no filter drops. On twenty native renders the one real blink is caught, the laughing faces are closed, the profiles are judged on the near eye, and the one thing left beyond any floor is a face with a pot held over it. --- core/dr-face/examples/eyes.rs | 132 +++++------- core/dr-face/src/align.rs | 389 ++++++++++++++++++---------------- core/dr-face/src/classify.rs | 83 ++++++-- core/dr-face/src/eyes.rs | 184 +++++++++++++--- core/dr-face/src/landmarks.rs | 185 ++++++++++++++++ core/dr-face/src/lib.rs | 20 +- docs/traceability.md | 8 +- 7 files changed, 690 insertions(+), 311 deletions(-) create mode 100644 core/dr-face/src/landmarks.rs diff --git a/core/dr-face/examples/eyes.rs b/core/dr-face/examples/eyes.rs index 28f279a..91fc8f7 100644 --- a/core/dr-face/examples/eyes.rs +++ b/core/dr-face/examples/eyes.rs @@ -1,17 +1,15 @@ //! Detect the faces in a JPEG and read each one's eyes (docs/faces.md §17). //! -//! The thing worth looking at is whether the eye windows land on eyes — so -//! with `--dump DIR` the crops the classifiers were shown are written out as -//! PPMs, one per eye and one per head, named by image and face. +//! The thing worth looking at is whether the eye boxes land on eyes and +//! whether soft ones are refused — so with `--dump DIR` the crops the +//! classifiers were shown are written out as PPMs, one per eye and one per +//! head framing, named by image and face, and every line carries the +//! numbers the readability floors are set from. //! //! cargo run -p dr-face --features inference --example eyes -- \ -//! DET.onnx OCEC.onnx SGC.onnx [--dump DIR] [--eye W,H] [--head X,Y,W,H] \ -//! photo.jpg [photo.jpg ...] +//! DET.onnx 2D106DET.onnx OCEC.onnx SGC.onnx [--dump DIR] photo.jpg [photo.jpg ...] //! -//! `--eye` and `--head` try other crop windows, in template units; they are -//! how `EYE_WINDOW` and `SUNGLASSES_WINDOWS` were chosen. -//! -//! All three models must have had their dynamic dims pinned first; see +//! All four models must have had their dynamic dims pinned first; see //! `tools/fix-face-model-shapes.sh`. use std::path::{Path, PathBuf}; @@ -27,40 +25,9 @@ fn main() { args.remove(i); PathBuf::from(args.remove(i)) }); - // `--head X,Y,W,H` tries a single head window, in template units, in - // place of the shipped pair. - let head_windows: Vec<(f32, f32, f32, f32)> = args - .iter() - .position(|a| a == "--head") - .map(|i| { - args.remove(i); - let spec = args.remove(i); - let v: Vec = spec - .split(',') - .map(|s| s.parse().expect("--head number")) - .collect(); - assert_eq!(v.len(), 4, "--head wants X,Y,W,H"); - vec![(v[0], v[1], v[2], v[3])] - }) - .unwrap_or_else(|| align::SUNGLASSES_WINDOWS.to_vec()); - // `--eye W,H` tries another eye window, in template units. - let eye_window = args - .iter() - .position(|a| a == "--eye") - .map(|i| { - args.remove(i); - let spec = args.remove(i); - let v: Vec = spec - .split(',') - .map(|s| s.parse().expect("--eye number")) - .collect(); - assert_eq!(v.len(), 2, "--eye wants W,H"); - (v[0], v[1]) - }) - .unwrap_or(align::EYE_WINDOW); - if args.len() < 4 { + if args.len() < 5 { eprintln!( - "usage: eyes DET.onnx OCEC.onnx SGC.onnx [--dump DIR] [--eye W,H] [--head X,Y,W,H] IMAGE.jpg [IMAGE.jpg ...]" + "usage: eyes DET.onnx 2D106DET.onnx OCEC.onnx SGC.onnx [--dump DIR] IMAGE.jpg [IMAGE.jpg ...]" ); std::process::exit(2); } @@ -70,11 +37,11 @@ fn main() { let t = Instant::now(); let mut detector = Detector::from_path(&args[0]).expect("load detector"); - let mut models = EyeModels::from_paths(&args[1], &args[2]).expect("load eye models"); + let mut models = EyeModels::from_paths(&args[1], &args[2], &args[3]).expect("load eye models"); println!("loaded the models in {:?}", t.elapsed()); let opts = DetectOptions::default(); - for path in &args[3..] { + for path in &args[4..] { let (rgb, w, h) = match load_jpeg(path) { Ok(v) => v, Err(e) => { @@ -92,46 +59,59 @@ fn main() { for (i, d) in dets.iter().enumerate() { let px = Pixels::RgbF32(&rgb); - let (Some(eyes), Some(head)) = ( - align::eye_patches_in(px, w, h, &d.landmarks, eye_window), - align::head_views_in(px, w, h, &d.landmarks, &head_windows), - ) else { - println!(" [{i}] degenerate landmarks, skipped"); + let t = Instant::now(); + let reading = models + .read(px, w, h, d.bbox, &d.landmarks) + .expect("read eyes"); + let ms = t.elapsed().as_secs_f64() * 1e3; + let Some(r) = reading else { + println!(" [{i}] nothing to cut, skipped"); continue; }; - let t = Instant::now(); - let reading = models.read(&eyes, &head).expect("classify"); - let ms = t.elapsed().as_secs_f64() * 1e3; println!( - " [{i}] conf {:.2} box {:.0}×{:.0} right {:.3} left {:.3} sunglasses {:.3} → {:?} ({ms:.1} ms)", + " [{i}] conf {:.2} box {:.0}×{:.0} right {:.3} ({:.0}px, sharp {:.3}) left {:.3} ({:.0}px, sharp {:.3}) sunglasses {:.3} → {:?} ({ms:.1} ms)", d.confidence, d.width(), d.height(), - reading.right_open, - reading.left_open, - reading.sunglasses, - reading.state(), + r.right.open, + r.right.px, + r.right.sharpness, + r.left.open, + r.left.px, + r.left.sharpness, + r.sunglasses, + r.state(), ); if let Some(dir) = &dump { - write_ppm( - &dir.join(format!("{stem}-{i}-right.ppm")), - eyes.right.pixels(), - align::EYE_PATCH_WIDTH, - align::EYE_PATCH_HEIGHT, - ); - write_ppm( - &dir.join(format!("{stem}-{i}-left.ppm")), - eyes.left.pixels(), - align::EYE_PATCH_WIDTH, - align::EYE_PATCH_HEIGHT, - ); - for (n, view) in head.views().enumerate() { - write_ppm( - &dir.join(format!("{stem}-{i}-head{n}.ppm")), - view, - align::SUNGLASSES_EDGE, - align::SUNGLASSES_EDGE, - ); + // The same crops `EyeModels::read` cut, cut again for the + // sheet: the reading itself carries numbers, not pixels. + if let Some(lm) = models + .landmarks + .landmarks(px, w, h, d.bbox) + .expect("landmarks") + { + for (name, contour) in [("right", lm.right_eye()), ("left", lm.left_eye())] { + if let Some(patch) = + align::eye_box(&contour).and_then(|b| align::eye_patch(px, w, h, b)) + { + write_ppm( + &dir.join(format!("{stem}-{i}-{name}.ppm")), + patch.pixels(), + align::EYE_PATCH_WIDTH, + align::EYE_PATCH_HEIGHT, + ); + } + } + } + if let Some(head) = align::head_views(px, w, h, &d.landmarks) { + for (n, view) in head.views().enumerate() { + write_ppm( + &dir.join(format!("{stem}-{i}-head{n}.ppm")), + view, + align::SUNGLASSES_EDGE, + align::SUNGLASSES_EDGE, + ); + } } } } diff --git a/core/dr-face/src/align.rs b/core/dr-face/src/align.rs index 6ec53aa..c0add26 100644 --- a/core/dr-face/src/align.rs +++ b/core/dr-face/src/align.rs @@ -117,51 +117,56 @@ impl Aligned112 { /// `face_index --quality` prints the joint distribution so the two are /// chosen together rather than each in ignorance of the other. pub fn sharpness(&self) -> f32 { - let e = ALIGNED_EDGE; - let luma: Vec = self - .pixels - .chunks_exact(3) - .map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]) - .collect(); - - let (mut lap_sum, mut lap_sq) = (0.0_f64, 0.0_f64); - let (mut lum_sum, mut lum_sq) = (0.0_f64, 0.0_f64); - let mut n = 0.0_f64; - - for y in 1..e - 1 { - for x in 1..e - 1 { - let i = y * e + x; - // Four-neighbour Laplacian. The 8-neighbour form is more - // sensitive to diagonal detail and also to noise, which on a - // high-ISO frame is exactly the thing that must not read as - // sharpness. - let lap = 4.0 * luma[i] - luma[i - 1] - luma[i + 1] - luma[i - e] - luma[i + e]; - let lap = lap as f64; - lap_sum += lap; - lap_sq += lap * lap; - - let l = luma[i] as f64; - lum_sum += l; - lum_sq += l * l; - n += 1.0; - } - } - - if n == 0.0 { - return 0.0; - } - let lap_var = (lap_sq / n - (lap_sum / n).powi(2)).max(0.0); - let lum_var = (lum_sq / n - (lum_sum / n).powi(2)).max(0.0); - - // A crop with no luma variation has no edges to find either, so the - // ratio is 0/0. Zero is the right answer: nothing there is a face. - if lum_var <= 1e-9 { - return 0.0; - } - (lap_var / lum_var) as f32 + laplacian_ratio(&self.pixels, ALIGNED_EDGE, ALIGNED_EDGE) } } +/// Variance of the four-neighbour Laplacian over the variance of the luma, +/// for a `w × h` RGB crop — the measure [`Aligned112::sharpness`] describes, +/// shared with [`EyePatch::sharpness`]. +fn laplacian_ratio(pixels: &[f32], w: usize, h: usize) -> f32 { + let luma: Vec = pixels + .chunks_exact(3) + .map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]) + .collect(); + + let (mut lap_sum, mut lap_sq) = (0.0_f64, 0.0_f64); + let (mut lum_sum, mut lum_sq) = (0.0_f64, 0.0_f64); + let mut n = 0.0_f64; + + for y in 1..h.saturating_sub(1) { + for x in 1..w.saturating_sub(1) { + let i = y * w + x; + // Four-neighbour Laplacian. The 8-neighbour form is more + // sensitive to diagonal detail and also to noise, which on a + // high-ISO frame is exactly the thing that must not read as + // sharpness. + let lap = 4.0 * luma[i] - luma[i - 1] - luma[i + 1] - luma[i - w] - luma[i + w]; + let lap = lap as f64; + lap_sum += lap; + lap_sq += lap * lap; + + let l = luma[i] as f64; + lum_sum += l; + lum_sq += l * l; + n += 1.0; + } + } + + if n == 0.0 { + return 0.0; + } + let lap_var = (lap_sq / n - (lap_sum / n).powi(2)).max(0.0); + let lum_var = (lum_sq / n - (lum_sum / n).powi(2)).max(0.0); + + // A crop with no luma variation has no edges to find either, so the + // ratio is 0/0. Zero is the right answer: nothing there is a face. + if lum_var <= 1e-9 { + return 0.0; + } + (lap_var / lum_var) as f32 +} + /// A similarity transform: rotation, uniform scale, translation. /// /// Stored as the four independent parameters rather than a 2×3 matrix so that @@ -427,22 +432,51 @@ pub const EYE_PATCH_WIDTH: usize = 40; /// Height of an eye crop as the classifier reads it, in pixels. pub const EYE_PATCH_HEIGHT: usize = 24; -/// The window read around each eye, in template units: width and height. +/// How much an eye's box is grown beyond its lid contour, as a fraction of +/// its width and height on each side. /// -/// The classifier was trained on the *eye* boxes of a whole-body detector — -/// tight boxes round the palpebral fissure, on the reference footage about -/// twice as wide as they are high — and this is that box expressed in the -/// aligned frame, where the two eyes sit 35 template units apart. A human eye -/// is close to half the interocular distance wide, so the first guess was -/// 20×10; measured over 25 clearly open-eyed faces from the reference -/// library (`examples/eyes.rs --eye`), recall was flat from 20×10 to 34×17 -/// and fell off below it, and 22×11 was the best of the plateau. docs/faces.md -/// §17 has the table. -pub const EYE_WINDOW: (f32, f32) = (22.0, 11.0); +/// The classifier was trained on a whole-body detector's *eye* boxes — tight +/// round the palpebral fissure — and measured on 25 open-eyed faces from the +/// reference library, a tight box is what it wants: 22 of 25 read open at +/// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/faces.md §17.2). A tenth, so a +/// contour landing a pixel short of the lashes still holds them. +pub const EYE_BOX_MARGIN: f32 = 0.1; + +/// Height a shut eye's box is given, as a fraction of its width. +/// +/// A closed eye's contour has no height. The box is given the height an +/// open eye of the same width would have, so the classifier sees the same +/// framing either way — which is what it was trained on. +pub const EYE_BOX_MIN_ASPECT: f32 = 0.4; + +/// The box round an eye's lid contour, in the contour's own coordinates: +/// `(x, y, w, h)`. +/// +/// Model-free: the contour is whatever the landmark model gave for the ten +/// (or so) points on the lids, in source pixels. `None` for an empty +/// contour or one with no width, which is what a hidden eye's collapsed +/// contour can come to. +pub fn eye_box(contour: &[(f32, f32)]) -> Option<(f32, f32, f32, f32)> { + let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for &(x, y) in contour { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + let w = x1 - x0; + if contour.is_empty() || !(w > 0.0) { + return None; + } + let h = (y1 - y0).max(w * EYE_BOX_MIN_ASPECT); + let cy = (y0 + y1) / 2.0; + let (mx, my) = (w * EYE_BOX_MARGIN, h * EYE_BOX_MARGIN); + Some((x0 - mx, cy - h / 2.0 - my, w + 2.0 * mx, h + 2.0 * my)) +} /// One eye, resampled to the classifier's input. /// -/// Constructible only by [`eye_patches`], for the reason [`Aligned112`] is +/// Constructible only by [`eye_patch`], for the reason [`Aligned112`] is /// only constructible by [`warp`]: the classifier accepting a plain buffer /// would accept any 40×24 of anything, and its answer would still be a /// plausible probability. @@ -450,79 +484,56 @@ pub const EYE_WINDOW: (f32, f32) = (22.0, 11.0); pub struct EyePatch { /// `24 × 40 × 3`, row-major RGB in `0.0..=1.0`. pixels: Vec, + /// Source pixels across the box the patch was cut from. + source_px: f32, } impl EyePatch { pub fn pixels(&self) -> &[f32] { &self.pixels } -} -/// Both eyes of one face, in the detector's landmark order. -#[derive(Debug, Clone, PartialEq)] -pub struct EyePatches { - /// The subject's **right** eye — image-left, landmark 0. - pub right: EyePatch, - /// The subject's **left** eye — image-right, landmark 1. - pub left: EyePatch, -} - -/// Cut both eyes out of the source, aligned, at the classifier's size. -/// -/// The same similarity [`warp`] fits, so the eyes come out upright whatever -/// the head's tilt, and the same one-step bilinear sampling from the native -/// buffer, so a large face gives the classifier real pixels rather than a -/// re-enlargement of the 112-pixel crop. A face too small for the window to -/// hold a real eye is not refused here: the classifier was trained down to -/// eyes a dozen pixels across, and the caller's size gate has already spoken. -pub fn eye_patches( - px: Pixels<'_>, - width: usize, - height: usize, - landmarks: &[(f32, f32); 5], -) -> Option { - eye_patches_in(px, width, height, landmarks, EYE_WINDOW) -} - -/// [`eye_patches`] over a window other than [`EYE_WINDOW`]. -/// -/// For measuring the window, which is how [`EYE_WINDOW`] was chosen -/// (`examples/eyes.rs --eye`); production callers use the constant. -pub fn eye_patches_in( - px: Pixels<'_>, - width: usize, - height: usize, - landmarks: &[(f32, f32); 5], - window: (f32, f32), -) -> Option { - if !px.fits(width, height) { - return None; + /// Source pixels across the eye box — how much eye there was to read. + /// + /// The classifier was trained down to eyes a dozen pixels wide, and + /// below that a crop is an interpolation of nothing; `crate::eyes` draws + /// the line. Zero when the box had no width, which is a hidden eye. + pub fn source_px(&self) -> f32 { + self.source_px } - let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?; - let (ww, wh) = window; - let eye = |i: usize| { - let (cx, cy) = ARCFACE_TEMPLATE[i]; - let window = TemplateWindow { - x: cx - ww / 2.0, - y: cy - wh / 2.0, - w: ww, - h: wh, - }; - EyePatch { - pixels: sample_window( - px, - width, - height, - &m, - &window, - EYE_PATCH_WIDTH, - EYE_PATCH_HEIGHT, - ), - } - }; - Some(EyePatches { - right: eye(0), - left: eye(1), + + /// How sharp the eye the classifier is about to see actually is — + /// [`Aligned112::sharpness`]'s measure, over the patch. + /// + /// The reason it exists is the reason the face's does: a soft eye is + /// not a closed one, but a classifier shown a smear says "closed" with + /// the same confidence it says anything, and the only defence is to + /// not ask. A face sharp enough to embed can still hold an eye too soft + /// to read — it is a fortieth of the face — so the measure is taken + /// here and not inherited from the crop. + pub fn sharpness(&self) -> f32 { + laplacian_ratio(&self.pixels, EYE_PATCH_WIDTH, EYE_PATCH_HEIGHT) + } +} + +/// Cut an eye out of the source at the classifier's size, from an +/// axis-aligned box in source pixels — [`eye_box`]'s, as a rule. +/// +/// Upright and from the frame, not through the face's alignment: the +/// classifier's training crops were detector boxes, and a landmark model's +/// contour already says where the eye is on a tilted head. Bilinear in one +/// step from the native buffer, so a large face gives real pixels; the +/// box's aspect is not preserved, which is what the training resize did. +pub fn eye_patch( + px: Pixels<'_>, + width: usize, + height: usize, + bbox: (f32, f32, f32, f32), +) -> Option { + let pixels = crop_box(px, width, height, bbox, EYE_PATCH_WIDTH, EYE_PATCH_HEIGHT)?; + Some(EyePatch { + pixels, + source_px: bbox.2, }) } @@ -623,6 +634,36 @@ pub fn head_views_in( Some(HeadViews { views }) } +/// An axis-aligned crop of the source, resampled to `out_w × out_h` RGB. +/// +/// `(x, y, w, h)` in source pixels; the aspect is not preserved when it +/// differs from the output's. Bilinear in one step, like every crop here; +/// pixels outside the source read black. What a landmark model trained on +/// detector boxes wants — upright, from the frame — as against the aligned +/// windows above. +pub fn crop_box( + px: Pixels<'_>, + width: usize, + height: usize, + (x, y, w, h): (f32, f32, f32, f32), + out_w: usize, + out_h: usize, +) -> Option> { + if !px.fits(width, height) || w <= 0.0 || h <= 0.0 { + return None; + } + let identity = Similarity { + a: 1.0, + b: 0.0, + tx: 0.0, + ty: 0.0, + }; + let window = TemplateWindow { x, y, w, h }; + Some(sample_window( + px, width, height, &identity, &window, out_w, out_h, + )) +} + fn sample_bilinear(px: Pixels<'_>, w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) { let x0 = x.floor(); let y0 = y.floor(); @@ -758,75 +799,62 @@ mod tests { px.chunks_exact(3).map(|p| p[c]).sum::() / n as f32 } - /// The eye windows are cut where the landmarks say the eyes are, in the - /// detector's order — subject's right (image-left) first. + /// The box is the contour's bounds, grown by the margin, and a shut + /// eye's flat contour is given an open eye's height. #[test] - fn eye_patches_are_cut_around_each_eye_landmark() { - let (w, h) = (224, 224); - let rgb = coordinate_image(w, h); - // Pure translation by (56, 56): template (x, y) is source (x+56, y+56). - let lm = shifted_scaled(1.0, 56.0, 56.0, 0.0); - let eyes = eye_patches(Pixels::RgbF32(&rgb), w, h, &lm).unwrap(); - assert_eq!( - eyes.right.pixels().len(), - EYE_PATCH_WIDTH * EYE_PATCH_HEIGHT * 3 - ); + fn an_eye_box_holds_its_contour_with_a_margin() { + let open = [(100.0, 50.0), (110.0, 46.0), (120.0, 50.0), (110.0, 54.0)]; + let (x, y, w, h) = eye_box(&open).unwrap(); + assert!((w - 20.0 * (1.0 + 2.0 * EYE_BOX_MARGIN)).abs() < 1e-4); + assert!((h - 8.0 * (1.0 + 2.0 * EYE_BOX_MARGIN)).abs() < 1e-4); + assert!((x + w / 2.0 - 110.0).abs() < 1e-4); + assert!((y + h / 2.0 - 50.0).abs() < 1e-4); - for (patch, (tx, ty)) in [ - (&eyes.right, ARCFACE_TEMPLATE[0]), - (&eyes.left, ARCFACE_TEMPLATE[1]), - ] { - let want_x = (tx + 56.0) / w as f32; - let want_y = (ty + 56.0) / h as f32; - let got_x = mean_channel(patch.pixels(), 0); - let got_y = mean_channel(patch.pixels(), 1); - assert!((got_x - want_x).abs() < 0.01, "x {got_x} vs {want_x}"); - assert!((got_y - want_y).abs() < 0.01, "y {got_y} vs {want_y}"); - } - // And the two are distinct eyes, the right one image-left of the left. - assert!(mean_channel(eyes.right.pixels(), 0) < mean_channel(eyes.left.pixels(), 0)); + let shut = [(100.0, 50.0), (110.0, 50.0), (120.0, 50.0)]; + let (_, _, w2, h2) = eye_box(&shut).unwrap(); + assert!((w2 - w).abs() < 1e-4, "same width"); + assert!((h2 - 20.0 * EYE_BOX_MIN_ASPECT * (1.0 + 2.0 * EYE_BOX_MARGIN)).abs() < 1e-4); + + assert!(eye_box(&[]).is_none()); + assert!(eye_box(&[(5.0, 5.0), (5.0, 9.0)]).is_none(), "no width"); } - /// The window is wider than it is high in the source, and is resampled to - /// the classifier's 40×24 without keeping that aspect — the red channel - /// spans `EYE_WINDOW.0` source pixels across 40 output columns. + /// The patch is cut from the box it was given, upright, and knows how + /// many source pixels it spans. #[test] - fn an_eye_patch_spans_the_window_it_was_asked_for() { - let (w, h) = (224, 224); + fn an_eye_patch_is_the_box_resampled() { + let (w, h) = (200, 200); let rgb = coordinate_image(w, h); - let lm = shifted_scaled(1.0, 56.0, 56.0, 0.0); - let eyes = eye_patches(Pixels::RgbF32(&rgb), w, h, &lm).unwrap(); - let px = eyes.right.pixels(); - let row = |v: usize| &px[v * EYE_PATCH_WIDTH * 3..(v + 1) * EYE_PATCH_WIDTH * 3]; - let first = row(0)[0]; - let last = row(0)[(EYE_PATCH_WIDTH - 1) * 3]; - let span = (last - first) * w as f32; - // 39 pixel-centre steps across a 20-unit window. - let want = EYE_WINDOW.0 * (EYE_PATCH_WIDTH as f32 - 1.0) / EYE_PATCH_WIDTH as f32; - assert!((span - want).abs() < 0.1, "span {span} vs {want}"); + let bbox = (60.0, 90.0, 30.0, 12.0); + let eye = eye_patch(Pixels::RgbF32(&rgb), w, h, bbox).unwrap(); + assert_eq!(eye.pixels().len(), EYE_PATCH_WIDTH * EYE_PATCH_HEIGHT * 3); + assert_eq!(eye.source_px(), 30.0); + let cx = mean_channel(eye.pixels(), 0) * w as f32; + let cy = mean_channel(eye.pixels(), 1) * h as f32; + assert!((cx - 75.0).abs() < 0.6, "{cx}"); + assert!((cy - 96.0).abs() < 0.6, "{cy}"); + // No width, or a buffer that is not the size it claims: nothing. + assert!(eye_patch(Pixels::RgbF32(&rgb), w, h, (60.0, 90.0, 0.0, 12.0)).is_none()); + assert!(eye_patch(Pixels::RgbF32(&rgb), 190, 200, bbox).is_none()); } - /// A tilted face yields upright eyes: the patch's rows run along the - /// interocular line, not along the image's x axis. + /// A soft eye scores lower than the same eye sharp, on the patch itself. #[test] - fn eye_patches_follow_the_heads_tilt() { - let (w, h) = (300, 300); - let rgb = coordinate_image(w, h); - let rot = 0.5_f32; - let lm = shifted_scaled(1.0, 100.0, 60.0, rot); - let eyes = eye_patches(Pixels::RgbF32(&rgb), w, h, &lm).unwrap(); - let px = eyes.left.pixels(); - // Walking one output row moves along the rotated x axis, so both - // source coordinates change, in the ratio the rotation dictates. - let a = &px[0..3]; - let b = &px[(EYE_PATCH_WIDTH - 1) * 3..EYE_PATCH_WIDTH * 3]; - let dx = (b[0] - a[0]) * w as f32; - let dy = (b[1] - a[1]) * h as f32; - let angle = dy.atan2(dx); - assert!( - (angle - rot).abs() < 0.02, - "row runs at {angle}, want {rot}" + fn an_eye_patchs_sharpness_falls_with_blur() { + let edge = 120; + let sharp = image( + edge, + |x, y| if (x / 5 + y / 5) % 2 == 0 { 0.9 } else { 0.1 }, ); + let soft = blur(&blur(&sharp, edge), edge); + let bbox = (20.0, 40.0, 40.0, 24.0); + let a = eye_patch(Pixels::RgbF32(&sharp), edge, edge, bbox) + .unwrap() + .sharpness(); + let b = eye_patch(Pixels::RgbF32(&soft), edge, edge, bbox) + .unwrap() + .sharpness(); + assert!(a > b * 2.0, "sharp {a} should clearly beat blurred {b}"); } /// The second sunglasses framing takes in more than the face — it starts @@ -863,14 +891,13 @@ mod tests { } #[test] - fn degenerate_landmarks_yield_no_eye_or_head_crop() { + fn degenerate_landmarks_yield_no_head_crop() { let rgb = vec![0.5_f32; 64 * 64 * 3]; let degenerate = [(50.0, 50.0); 5]; - assert!(eye_patches(Pixels::RgbF32(&rgb), 64, 64, °enerate).is_none()); assert!(head_views(Pixels::RgbF32(&rgb), 64, 64, °enerate).is_none()); // And a buffer that is not the size it claims. let lm = shifted_scaled(1.0, 0.0, 0.0, 0.0); - assert!(eye_patches(Pixels::RgbF32(&rgb), 60, 60, &lm).is_none()); + assert!(head_views(Pixels::RgbF32(&rgb), 60, 60, &lm).is_none()); } #[test] diff --git a/core/dr-face/src/classify.rs b/core/dr-face/src/classify.rs index ecd63ea..e3483fa 100644 --- a/core/dr-face/src/classify.rs +++ b/core/dr-face/src/classify.rs @@ -15,7 +15,9 @@ //! constructible only by the crop in [`crate::align`] that puts the right //! pixels in it — the same defence [`crate::embed::Embedder`] makes with //! [`crate::align::Aligned112`], for the same reason: a classifier handed the -//! wrong region returns a confident probability of nothing. +//! wrong region returns a confident probability of nothing. Where the eye +//! box comes from is [`crate::landmarks`]; [`EyeModels::read`] is the whole +//! chain. //! //! # The graphs must have a fixed batch //! @@ -33,10 +35,12 @@ use ndarray::Array4; use crate::align::{ - EyePatch, EyePatches, HeadViews, EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH, SUNGLASSES_EDGE, + eye_box, eye_patch, head_views, EyePatch, HeadViews, EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH, + SUNGLASSES_EDGE, }; -use crate::eyes::EyeReading; -use crate::{install_backend, FaceError}; +use crate::eyes::{Eye, EyeReading}; +use crate::landmarks::Landmarker; +use crate::{install_backend, FaceError, Pixels}; /// A loaded OCEC graph. pub struct EyeClassifier { @@ -172,34 +176,83 @@ impl SunglassesClassifier { } } -/// The two classifiers together, which is how every caller holds them. +/// The three models behind a reading, which is how every caller holds them. /// -/// One struct rather than two optional parameters, because half a reading is -/// not a reading: an eye state with no sunglasses number behind it is exactly -/// the beach-photograph failure [`crate::eyes`] describes, so the models load -/// together or not at all. +/// One struct rather than three optional parameters, because a partial +/// reading is not a reading: an eye state with no sunglasses number behind +/// it is exactly the beach-photograph failure [`crate::eyes`] describes, and +/// an eye box without the landmarks is the loose one this module replaced. +/// The models load together or not at all. pub struct EyeModels { + pub landmarks: Landmarker, pub eyes: EyeClassifier, pub sunglasses: SunglassesClassifier, } impl EyeModels { pub fn from_paths( + landmarks: impl AsRef, eyes: impl AsRef, sunglasses: impl AsRef, ) -> Result { Ok(Self { + landmarks: Landmarker::from_path(landmarks)?, eyes: EyeClassifier::from_path(eyes)?, sunglasses: SunglassesClassifier::from_path(sunglasses)?, }) } /// Read one face's eyes. - pub fn read(&mut self, eyes: &EyePatches, head: &HeadViews) -> Result { - Ok(EyeReading { - right_open: self.eyes.classify(&eyes.right)?, - left_open: self.eyes.classify(&eyes.left)?, - sunglasses: self.sunglasses.classify(head)?, - }) + /// + /// `bbox` is the detector's `(x0, y0, x1, y1)` and `landmarks5` its five + /// points, both in source pixels; the buffer is the one the aligned + /// crop was taken from, so an eye is read from the same pixels the + /// embedder saw the face in. `None` where nothing could be cut — a + /// degenerate box or landmarks — which the caller stores as "not read". + pub fn read( + &mut self, + px: Pixels<'_>, + width: usize, + height: usize, + bbox: (f32, f32, f32, f32), + landmarks5: &[(f32, f32); 5], + ) -> Result, FaceError> { + let Some(lm) = self.landmarks.landmarks(px, width, height, bbox)? else { + return Ok(None); + }; + let Some(head) = head_views(px, width, height, landmarks5) else { + return Ok(None); + }; + let mut eye = |contour: &[(f32, f32)]| -> Result { + // A hidden eye's contour can collapse to no width. Its numbers + // are then zero — no pixels, no sharpness — which is what the + // rule in `crate::eyes` reads as "not readable". + let Some(b) = eye_box(contour) else { + return Ok(Eye { + open: 0.0, + px: 0.0, + sharpness: 0.0, + }); + }; + let Some(patch) = eye_patch(px, width, height, b) else { + return Ok(Eye { + open: 0.0, + px: 0.0, + sharpness: 0.0, + }); + }; + Ok(Eye { + open: self.eyes.classify(&patch)?, + px: patch.source_px(), + sharpness: patch.sharpness(), + }) + }; + let right = eye(&lm.right_eye())?; + let left = eye(&lm.left_eye())?; + Ok(Some(EyeReading { + right, + left, + sunglasses: self.sunglasses.classify(&head)?, + })) } } diff --git a/core/dr-face/src/eyes.rs b/core/dr-face/src/eyes.rs index a20ffac..9c33c95 100644 --- a/core/dr-face/src/eyes.rs +++ b/core/dr-face/src/eyes.rs @@ -1,37 +1,55 @@ //! TRACES: FR-CULL-13 -//! What a face's eyes are doing, and how the three numbers behind it are read. +//! What a face's eyes are doing, and how the numbers behind it are read. //! -//! Model-free: the classifiers in [`crate::classify`] produce the numbers, -//! and everything that interprets them — the catalog's filter, the People +//! Model-free: the models in [`crate::classify`] produce the numbers, and +//! everything that interprets them — the catalog's filter, the People //! screen's label — comes through here, so a threshold lives in exactly one //! place. //! -//! # Three numbers, one answer +//! # Seven numbers, one answer //! //! An eye classifier answers "open or closed" for whatever it is shown, and -//! shown a lens of dark glass it answers anyway. Its answer over sunglasses is -//! not *wrong* in any way it can report — it is a confident probability of a -//! state that cannot be seen — and a filter for "eyes open" that trusted it -//! would drop every photograph from the beach. So the reading carries a -//! third number, from a classifier that looks at the whole head, and it takes -//! precedence: a face behind sunglasses is [`EyeState::Sunglasses`], whatever -//! the eye classifier made of the glass. +//! it is shown three things it cannot answer for. **Dark glass**: over +//! sunglasses it answers anyway, confidently, for a state that cannot be +//! seen — so the reading carries P(sunglasses) from a classifier that looks +//! at the whole head, and that takes precedence. **A smear**: a soft eye is +//! not a closed one, but shown a blur the classifier says "closed" with the +//! same confidence it says anything, and on the reference library that was +//! the commonest wrong answer of all — small faces, motion, a proxy where +//! the native render should have been. So each eye carries how many source +//! pixels it spanned and how sharp the patch was, and an eye under either +//! floor is not asked. **A cheek**: a head turned far enough hides its far +//! eye, and the landmark contour of a hidden eye collapses to a sliver; an +//! eye much narrower than its partner is not asked either. //! //! The two eyes are kept apart rather than averaged. A wink is one eye //! closed, and averaging it lands at 0.5 — the one value that says the least. -//! [`EyeState::Open`] requires both. +//! [`EyeState::Open`] requires every eye that *could be read* to be open; +//! a face with no readable eye is [`EyeState::Unreadable`], which is not a +//! blink and not open, and a filter for either leaves it alone. -/// The probabilities the classifiers produced for one face. +/// One eye's numbers. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Eye { + /// P(open), the classifier's sigmoid. + pub open: f32, + /// Source pixels across the eye box — [`crate::align::EyePatch::source_px`]. + pub px: f32, + /// [`crate::align::EyePatch::sharpness`] of the patch the classifier saw. + pub sharpness: f32, +} + +/// The numbers the models produced for one face. /// /// Stored per face, nullable as a whole: a face indexed before the eye models /// existed, or on a device without them, has no reading rather than a /// reading of zeros. #[derive(Debug, Clone, Copy, PartialEq)] pub struct EyeReading { - /// P(open) for the subject's **right** eye — image-left, landmark 0. - pub right_open: f32, - /// P(open) for the subject's **left** eye — image-right, landmark 1. - pub left_open: f32, + /// The subject's **right** eye — image-left. + pub right: Eye, + /// The subject's **left** eye — image-right. + pub left: Eye, /// P(the head wears sunglasses). pub sunglasses: f32, } @@ -44,35 +62,87 @@ pub const EYES_OPEN_THRESHOLD: f32 = 0.5; /// Above this the head wears sunglasses and the eye readings are moot. pub const SUNGLASSES_THRESHOLD: f32 = 0.5; +/// Fewest source pixels across an eye box for the eye to be read. +/// +/// The classifier was trained on eyes down to about a dozen pixels wide +/// (its reference footage averaged 15–21); below that the 40-pixel patch is +/// an interpolation of nothing, and the answer is noise that reads as +/// "closed". docs/faces.md §17.3 has the measurement behind the number. +pub const MIN_EYE_PX: f32 = 12.0; + +/// Least [`Eye::sharpness`] for the eye to be read. +/// +/// The same measure as the face's `min_sharpness`, over the eye patch, and +/// chosen the same way: the value under which the open-eyed faces of the +/// reference sample were being called closed. docs/faces.md §17.3. +pub const MIN_EYE_SHARPNESS: f32 = 0.02; + +/// An eye narrower than this fraction of its partner is the far eye of a +/// turned head, out of view behind the nose, and is not read. +/// +/// A landmark model's contour for a hidden eye collapses towards the nose. +/// Measured on twenty native renders of the reference library +/// (docs/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near +/// one, two three-quarter faces whose far eye read closed sat at 0.54, and +/// every face looking at the camera — winks included, since a shut eye's +/// box keeps its width — sat at 0.78 or more. 0.6 splits the gap. +pub const HIDDEN_EYE_RATIO: f32 = 0.6; + /// What the reading says, for a screen or a filter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EyeState { - /// Both eyes open. + /// Every eye that could be read is open. Open, - /// At least one eye closed — a blink, or a wink. + /// An eye that could be read is closed — a blink, or a wink. Closed, /// The eyes cannot be seen. Neither open nor closed, and a filter for /// either leaves the face alone. Sunglasses, + /// No eye was sharp enough, large enough and in view to read. Neither + /// open nor closed, like sunglasses, and left alone by every filter. + Unreadable, +} + +impl Eye { + /// Whether this eye can be read at all: enough pixels, sharp enough, + /// and not the collapsed contour of a hidden eye — measured against + /// `other`, its partner. + pub fn readable(&self, other: &Eye) -> bool { + self.px >= MIN_EYE_PX + && self.sharpness >= MIN_EYE_SHARPNESS + && self.px >= other.px * HIDDEN_EYE_RATIO + } } impl EyeReading { pub fn state(&self) -> EyeState { if self.sunglasses >= SUNGLASSES_THRESHOLD { - EyeState::Sunglasses - } else if self.right_open >= EYES_OPEN_THRESHOLD && self.left_open >= EYES_OPEN_THRESHOLD { + return EyeState::Sunglasses; + } + let readable = [ + self.right.readable(&self.left).then_some(self.right.open), + self.left.readable(&self.right).then_some(self.left.open), + ]; + let mut any = false; + for open in readable.into_iter().flatten() { + any = true; + if open < EYES_OPEN_THRESHOLD { + return EyeState::Closed; + } + } + if any { EyeState::Open } else { - EyeState::Closed + EyeState::Unreadable } } /// Whether this is a face a "no one blinking" filter should drop. /// - /// The filter's question, rather than [`EyeState`]'s three-way answer, - /// because the two differ on exactly the case that matters: a face behind - /// sunglasses is not open, and it is not a blink either. Only - /// [`EyeState::Closed`] is one. + /// The filter's question, rather than [`EyeState`]'s four-way answer, + /// because the two differ on exactly the cases that matter: a face + /// behind sunglasses, or one whose eyes could not be read, is not open + /// — and it is not a blink either. Only [`EyeState::Closed`] is one. pub fn is_blink(&self) -> bool { self.state() == EyeState::Closed } @@ -85,6 +155,7 @@ impl EyeState { EyeState::Open => "Eyes open", EyeState::Closed => "Eyes closed", EyeState::Sunglasses => "Sunglasses", + EyeState::Unreadable => "Eyes unclear", } } } @@ -93,10 +164,18 @@ impl EyeState { mod tests { use super::*; + fn eye(open: f32) -> Eye { + Eye { + open, + px: 40.0, + sharpness: 0.1, + } + } + fn reading(right: f32, left: f32, sunglasses: f32) -> EyeReading { EyeReading { - right_open: right, - left_open: left, + right: eye(right), + left: eye(left), sunglasses, } } @@ -116,8 +195,8 @@ mod tests { assert!(reading(0.2, 0.9, 0.1).is_blink()); } - /// The whole reason the third number exists: whatever the eye classifier - /// says over dark glass, it is not a reading of the eyes. + /// The whole reason the sunglasses number exists: whatever the eye + /// classifier says over dark glass, it is not a reading of the eyes. #[test] fn sunglasses_override_the_eye_readings_either_way() { assert_eq!(reading(0.9, 0.9, 0.8).state(), EyeState::Sunglasses); @@ -125,6 +204,46 @@ mod tests { assert!(!reading(0.1, 0.1, 0.8).is_blink()); } + /// A soft or tiny eye is not asked; if neither can be, the face is + /// unreadable rather than closed. + #[test] + fn a_soft_or_tiny_eye_is_not_read() { + let mut r = reading(0.1, 0.9, 0.0); + r.right.sharpness = MIN_EYE_SHARPNESS / 2.0; + assert_eq!(r.state(), EyeState::Open, "the soft closed eye is ignored"); + + let mut r = reading(0.1, 0.9, 0.0); + r.right.px = MIN_EYE_PX - 1.0; + assert_eq!(r.state(), EyeState::Open, "the tiny closed eye is ignored"); + + let mut r = reading(0.1, 0.1, 0.0); + r.right.sharpness = 0.0; + r.left.px = 3.0; + assert_eq!(r.state(), EyeState::Unreadable); + assert!(!r.is_blink()); + assert_eq!(r.state().label(), "Eyes unclear"); + } + + /// A profile: the far eye's contour collapses, and the sliver is not + /// read. The near eye still decides. + #[test] + fn a_turned_heads_collapsed_far_eye_is_not_read() { + let mut r = reading(0.05, 0.95, 0.0); + r.right.px = 40.0 * HIDDEN_EYE_RATIO - 1.0; + assert!(!r.right.readable(&r.left)); + assert_eq!(r.state(), EyeState::Open); + + let mut blink = reading(0.95, 0.05, 0.0); + blink.right.px = 40.0 * HIDDEN_EYE_RATIO - 1.0; + assert_eq!(blink.state(), EyeState::Closed); + + // Both eyes narrow but alike is not a turned head: both count. + let mut small = reading(0.05, 0.95, 0.0); + small.right.px = 14.0; + small.left.px = 14.0; + assert_eq!(small.state(), EyeState::Closed); + } + #[test] fn the_thresholds_are_inclusive_at_the_decision_point() { assert_eq!( @@ -135,5 +254,10 @@ mod tests { reading(1.0, 1.0, SUNGLASSES_THRESHOLD).state(), EyeState::Sunglasses ); + let mut r = reading(1.0, 1.0, 0.0); + r.right.px = MIN_EYE_PX; + r.left.px = MIN_EYE_PX; + r.right.sharpness = MIN_EYE_SHARPNESS; + assert!(r.right.readable(&r.left)); } } diff --git a/core/dr-face/src/landmarks.rs b/core/dr-face/src/landmarks.rs new file mode 100644 index 0000000..228f260 --- /dev/null +++ b/core/dr-face/src/landmarks.rs @@ -0,0 +1,185 @@ +//! TRACES: FR-CULL-13 +//! Dense facial landmarks — InsightFace's `2d106det` (docs/faces.md §17.2). +//! +//! SCRFD's five points place a face; they do not place an eye. Its eye +//! point is loose enough that a window centred on it left the eye in a +//! corner on turned and smiling heads, and two model-free ways of +//! re-centring it made things worse. So a second model draws the eye's lid +//! contour, and the eye box is cut from that. +//! +//! **Why this one.** Three were measured on the same faces — MediaPipe Face +//! Mesh V2, PIPNet and this — and tied on what the eye classifier made of +//! their boxes (22 of 25 open eyes read open, against 19 from the SCRFD +//! point). This is the cheapest of the three by a wide margin (5 MB, 106 +//! points, ~24 ms in tract), and it is under the grant the detector and +//! embedder already carry rather than a new one to read. +//! +//! # Pre-processing +//! +//! Ported from InsightFace's `landmark.py`: a square crop centred on the +//! detector box, 1.5× its longer edge, resized to 192; **RGB in 0..255** +//! (the graph carries its own `bn_data` normalisation, so `input_mean` is +//! 0 and `input_std` 1); 106 `(x, y)` in −1..1 mapped back through +//! `(p + 1) · 96`. The graph's batch dimension is the literal `None` and +//! is pinned to 1 by `tools/fix-face-model-shapes.sh`, like the embedder's. +//! +//! # The layout +//! +//! Checked by drawing the points on the reference faces rather than taken +//! from a diagram: the subject's right eye (image-left) is points 33–42, +//! the left 87–96, ten each round the lids. + +use ndarray::Array4; + +use crate::align::crop_box; +use crate::{install_backend, FaceError, Pixels}; + +/// The graph's input edge, in pixels. +pub const INPUT_EDGE: usize = 192; + +/// How many points the model returns. +pub const POINTS: usize = 106; + +/// The crop's edge as a multiple of the detector box's longer edge. +const CROP_SCALE: f32 = 1.5; + +/// Point indices of the subject's right eye's lid contour (image-left). +pub const RIGHT_EYE: [usize; 10] = [33, 34, 35, 36, 37, 38, 39, 40, 41, 42]; +/// Point indices of the subject's left eye's lid contour (image-right). +pub const LEFT_EYE: [usize; 10] = [87, 88, 89, 90, 91, 92, 93, 94, 95, 96]; + +/// The 106 points of one face, in **source pixels**. +#[derive(Debug, Clone, PartialEq)] +pub struct Landmarks { + pub points: [(f32, f32); POINTS], +} + +impl Landmarks { + /// The lid contour of the subject's right eye. + pub fn right_eye(&self) -> [(f32, f32); 10] { + RIGHT_EYE.map(|i| self.points[i]) + } + + /// The lid contour of the subject's left eye. + pub fn left_eye(&self) -> [(f32, f32); 10] { + LEFT_EYE.map(|i| self.points[i]) + } +} + +/// A loaded `2d106det` graph. +pub struct Landmarker { + session: ort::session::Session, +} + +impl Landmarker { + pub fn from_path(path: impl AsRef) -> Result { + let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?; + Self::from_bytes(&bytes) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + install_backend(); + let session = ort::session::Session::builder() + .map_err(FaceError::Inference)? + .commit_from_memory(bytes) + .map_err(FaceError::Inference)?; + + let input = session.inputs().first().ok_or(FaceError::WrongModel { + expected: "2d106det", + detail: "model has no inputs".into(), + })?; + let shape: Option> = input.dtype().tensor_shape().map(|s| s.to_vec()); + let want = [1, 3, INPUT_EDGE as i64, INPUT_EDGE as i64]; + if shape.as_deref() != Some(&want[..]) { + return Err(FaceError::WrongModel { + expected: "2d106det", + detail: format!( + "input '{}' is {:?}, expected {:?} (batch pinned to 1)", + input.name(), + shape, + want + ), + }); + } + let out = session.outputs().first().ok_or(FaceError::WrongModel { + expected: "2d106det", + detail: "model has no outputs".into(), + })?; + let last: Option = out.dtype().tensor_shape().and_then(|d| d.last().copied()); + if last != Some((POINTS * 2) as i64) { + return Err(FaceError::WrongModel { + expected: "2d106det", + detail: format!( + "output '{}' is {:?}-wide, expected {}", + out.name(), + last, + POINTS * 2 + ), + }); + } + Ok(Self { session }) + } + + /// The landmarks of the face in `bbox` — `(x0, y0, x1, y1)` in source + /// pixels, the detector's box — read from the source. + /// + /// `None` for a box with no area or a buffer that is not the size it + /// claims, as every crop here. + pub fn landmarks( + &mut self, + px: Pixels<'_>, + width: usize, + height: usize, + bbox: (f32, f32, f32, f32), + ) -> Result, FaceError> { + let (w, h) = (bbox.2 - bbox.0, bbox.3 - bbox.1); + let side = w.max(h) * CROP_SCALE; + let (cx, cy) = ((bbox.0 + bbox.2) / 2.0, (bbox.1 + bbox.3) / 2.0); + let (x0, y0) = (cx - side / 2.0, cy - side / 2.0); + let Some(crop) = crop_box( + px, + width, + height, + (x0, y0, side, side), + INPUT_EDGE, + INPUT_EDGE, + ) else { + return Ok(None); + }; + + let e = INPUT_EDGE; + let mut input = Array4::::zeros((1, 3, e, e)); + for y in 0..e { + for x in 0..e { + for c in 0..3 { + input[[0, c, y, x]] = crop[(y * e + x) * 3 + c] * 255.0; + } + } + } + let outputs = self + .session + .run(ort::inputs![ + ort::value::Tensor::from_array(input).map_err(FaceError::Inference)? + ]) + .map_err(FaceError::Inference)?; + let (_, data) = outputs[0] + .try_extract_tensor::() + .map_err(FaceError::Inference)?; + if data.len() < POINTS * 2 { + return Err(FaceError::WrongModel { + expected: "2d106det", + detail: format!("got {} values, expected {}", data.len(), POINTS * 2), + }); + } + + // −1..1 in the crop → crop pixels → source pixels. + let scale = side / e as f32; + let half = e as f32 / 2.0; + let mut points = [(0.0_f32, 0.0_f32); POINTS]; + for (i, p) in points.iter_mut().enumerate() { + let (u, v) = ((data[2 * i] + 1.0) * half, (data[2 * i + 1] + 1.0) * half); + *p = (x0 + u * scale, y0 + v * scale); + } + Ok(Some(Landmarks { points })) + } +} diff --git a/core/dr-face/src/lib.rs b/core/dr-face/src/lib.rs index 63374cd..52fea2b 100644 --- a/core/dr-face/src/lib.rs +++ b/core/dr-face/src/lib.rs @@ -1,8 +1,10 @@ //! Faces and identity (S14, docs/faces.md). //! -//! Two models, run over the proxy tier, producing per face a box, five +//! Two models, run over the native render, producing per face a box, five //! landmarks, a confidence and a 512-d embedding (FR-CULL-8) — and then the -//! arithmetic that turns embeddings into people (FR-CULL-9, FR-CULL-10). +//! arithmetic that turns embeddings into people (FR-CULL-9, FR-CULL-10). Two +//! more, optional, read each face's eyes and whether sunglasses hide them +//! (FR-CULL-13, [`classify`] and [`eyes`]). //! //! Like `dr-segment`, this crate is **device-free**: no GPU adapter, no //! Slint, nothing that needs a display. Unlike `dr-segment`, it carries **no @@ -20,7 +22,8 @@ //! runtime; this crate takes bytes and never fetches anything. //! //! docs/faces.md §2 is the full reading, including what would have to change -//! for that to stop being true. +//! for that to stop being true. The eye-state models are the exception: MIT, +//! weights and all, and shipped in `models/face/` (docs/faces.md §17). //! //! # Why the runtime is split behind a feature //! @@ -43,6 +46,8 @@ pub mod detect; pub mod embed; pub mod embedding; pub mod eyes; +#[cfg(feature = "inference")] +pub mod landmarks; pub mod naming; pub mod neighbours; @@ -68,7 +73,7 @@ pub mod neighbours; pub const MIN_CROP_EDGE: u32 = 1025; pub use align::{ - eye_patches, head_views, warp, warp_pixels, Aligned112, EyePatch, EyePatches, HeadViews, + crop_box, eye_box, eye_patch, head_views, warp, warp_pixels, Aligned112, EyePatch, HeadViews, Pixels, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE, }; pub use assign::{identity_shares, RIVAL_FLOOR, TOP_MATCHES}; @@ -85,7 +90,12 @@ pub use embed::{Embedded, Embedder}; pub use embedding::{ in_gallery, read_f16_bytes, Embedding, ModelId, EMBEDDING_DIM, MIN_GALLERY_QUALITY, }; -pub use eyes::{EyeReading, EyeState, EYES_OPEN_THRESHOLD, SUNGLASSES_THRESHOLD}; +pub use eyes::{ + Eye, EyeReading, EyeState, EYES_OPEN_THRESHOLD, HIDDEN_EYE_RATIO, MIN_EYE_PX, + MIN_EYE_SHARPNESS, SUNGLASSES_THRESHOLD, +}; +#[cfg(feature = "inference")] +pub use landmarks::{Landmarker, Landmarks}; pub use naming::{name_for_instance, name_instances, NamedFace}; /// What can go wrong between an image and a face. diff --git a/docs/traceability.md b/docs/traceability.md index 3ca5665..608b340 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 | 357 | -| TRACES tags found | 1505 | +| Source files scanned | 358 | +| TRACES tags found | 1506 | | Requirements defined | 171 | | Requirements deferred (post-v1) | 24 | | Requirements covered | 143 | @@ -53,12 +53,12 @@ _None._ | FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:473`](../core/dr-catalog/src/schema.rs#L473), [`core/dr-catalog/src/schema.rs:651`](../core/dr-catalog/src/schema.rs#L651), [`core/dr-catalog/src/schema.rs:804`](../core/dr-catalog/src/schema.rs#L804), [`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:129`](../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/develop.rs:121`](../ui/dr-ui/src/develop.rs#L121), [`ui/dr-ui/src/develop.rs:130`](../ui/dr-ui/src/develop.rs#L130), [`ui/dr-ui/src/develop.rs:196`](../ui/dr-ui/src/develop.rs#L196), [`ui/dr-ui/src/develop.rs:2416`](../ui/dr-ui/src/develop.rs#L2416), [`ui/dr-ui/src/develop.rs:715`](../ui/dr-ui/src/develop.rs#L715), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2515`](../ui/dr-ui/src/lib.rs#L2515), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:804`](../core/dr-catalog/src/schema.rs#L804), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:295`](../ui/dr-ui/src/library.rs#L295), [`ui/dr-ui/src/library.rs:325`](../ui/dr-ui/src/library.rs#L325), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:473`](../core/dr-catalog/src/schema.rs#L473), [`core/dr-catalog/src/schema.rs:804`](../core/dr-catalog/src/schema.rs#L804), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-13 | [`core/dr-catalog/src/faces.rs:124`](../core/dr-catalog/src/faces.rs#L124), [`core/dr-face/src/classify.rs:1`](../core/dr-face/src/classify.rs#L1), [`core/dr-face/src/eyes.rs:1`](../core/dr-face/src/eyes.rs#L1) | +| FR-CULL-13 | [`core/dr-catalog/src/faces.rs:124`](../core/dr-catalog/src/faces.rs#L124), [`core/dr-face/src/classify.rs:1`](../core/dr-face/src/classify.rs#L1), [`core/dr-face/src/eyes.rs:1`](../core/dr-face/src/eyes.rs#L1), [`core/dr-face/src/landmarks.rs:1`](../core/dr-face/src/landmarks.rs#L1) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | | 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:4137`](../ui/dr-ui/src/develop.rs#L4137), [`ui/dr-ui/src/develop.rs:4150`](../ui/dr-ui/src/develop.rs#L4150), [`ui/dr-ui/src/develop.rs:4195`](../ui/dr-ui/src/develop.rs#L4195), [`ui/dr-ui/src/develop.rs:4206`](../ui/dr-ui/src/develop.rs#L4206), [`ui/dr-ui/src/develop.rs:4212`](../ui/dr-ui/src/develop.rs#L4212), [`ui/dr-ui/src/develop.rs:4229`](../ui/dr-ui/src/develop.rs#L4229), [`ui/dr-ui/src/develop.rs:810`](../ui/dr-ui/src/develop.rs#L810), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/develop.rs:836`](../ui/dr-ui/src/develop.rs#L836), [`ui/dr-ui/src/develop.rs:8734`](../ui/dr-ui/src/develop.rs#L8734), [`ui/dr-ui/src/develop.rs:8798`](../ui/dr-ui/src/develop.rs#L8798), [`ui/dr-ui/src/develop.rs:8821`](../ui/dr-ui/src/develop.rs#L8821), [`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:1871`](../ui/dr-ui/src/lib.rs#L1871), [`ui/dr-ui/src/lib.rs:1979`](../ui/dr-ui/src/lib.rs#L1979), [`ui/dr-ui/src/lib.rs:2092`](../ui/dr-ui/src/lib.rs#L2092), [`ui/dr-ui/src/lib.rs:2133`](../ui/dr-ui/src/lib.rs#L2133), [`ui/dr-ui/src/lib.rs:3626`](../ui/dr-ui/src/lib.rs#L3626), [`ui/dr-ui/src/lib.rs:436`](../ui/dr-ui/src/lib.rs#L436), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1938`](../ui/dr-ui/ui/app.slint#L1938), [`ui/dr-ui/ui/app.slint:2945`](../ui/dr-ui/ui/app.slint#L2945), [`ui/dr-ui/ui/app.slint:87`](../ui/dr-ui/ui/app.slint#L87), [`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:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`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:516`](../core/dr-catalog/src/schema.rs#L516), [`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:6688`](../ui/dr-ui/src/library.rs#L6688) | -| 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:584`](../core/dr-catalog/src/schema.rs#L584), [`core/dr-catalog/src/schema.rs:763`](../core/dr-catalog/src/schema.rs#L763), [`core/dr-catalog/src/schema.rs:804`](../core/dr-catalog/src/schema.rs#L804), [`core/dr-face/src/align.rs:291`](../core/dr-face/src/align.rs#L291), [`core/dr-types/src/settings.rs:176`](../core/dr-types/src/settings.rs#L176), [`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/derived_sync.rs:124`](../ui/dr-ui/src/derived_sync.rs#L124), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:334`](../ui/dr-ui/src/faces.rs#L334), [`ui/dr-ui/src/faces.rs:350`](../ui/dr-ui/src/faces.rs#L350), [`ui/dr-ui/src/faces.rs:457`](../ui/dr-ui/src/faces.rs#L457), [`ui/dr-ui/src/identity_ui.rs:548`](../ui/dr-ui/src/identity_ui.rs#L548), [`ui/dr-ui/src/lib.rs:1479`](../ui/dr-ui/src/lib.rs#L1479), [`ui/dr-ui/src/lib.rs:3881`](../ui/dr-ui/src/lib.rs#L3881), [`ui/dr-ui/src/library.rs:3531`](../ui/dr-ui/src/library.rs#L3531), [`ui/dr-ui/src/library.rs:3797`](../ui/dr-ui/src/library.rs#L3797), [`ui/dr-ui/src/library.rs:4052`](../ui/dr-ui/src/library.rs#L4052), [`ui/dr-ui/src/library.rs:4333`](../ui/dr-ui/src/library.rs#L4333), [`ui/dr-ui/src/library.rs:4522`](../ui/dr-ui/src/library.rs#L4522), [`ui/dr-ui/src/library.rs:4558`](../ui/dr-ui/src/library.rs#L4558), [`ui/dr-ui/src/library.rs:4638`](../ui/dr-ui/src/library.rs#L4638), [`ui/dr-ui/src/library.rs:6855`](../ui/dr-ui/src/library.rs#L6855), [`ui/dr-ui/src/library_ui.rs:406`](../ui/dr-ui/src/library_ui.rs#L406), [`ui/dr-ui/src/library_ui.rs:491`](../ui/dr-ui/src/library_ui.rs#L491), [`ui/dr-ui/src/settings_ui.rs:210`](../ui/dr-ui/src/settings_ui.rs#L210), [`ui/dr-ui/src/settings_ui.rs:552`](../ui/dr-ui/src/settings_ui.rs#L552), [`ui/dr-ui/ui/app.slint:898`](../ui/dr-ui/ui/app.slint#L898), [`ui/dr-ui/ui/settings.slint:457`](../ui/dr-ui/ui/settings.slint#L457), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82), [`ui/dr-ui/ui/settings.slint:94`](../ui/dr-ui/ui/settings.slint#L94) | +| 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:584`](../core/dr-catalog/src/schema.rs#L584), [`core/dr-catalog/src/schema.rs:763`](../core/dr-catalog/src/schema.rs#L763), [`core/dr-catalog/src/schema.rs:804`](../core/dr-catalog/src/schema.rs#L804), [`core/dr-face/src/align.rs:296`](../core/dr-face/src/align.rs#L296), [`core/dr-types/src/settings.rs:176`](../core/dr-types/src/settings.rs#L176), [`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/derived_sync.rs:124`](../ui/dr-ui/src/derived_sync.rs#L124), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:334`](../ui/dr-ui/src/faces.rs#L334), [`ui/dr-ui/src/faces.rs:350`](../ui/dr-ui/src/faces.rs#L350), [`ui/dr-ui/src/faces.rs:457`](../ui/dr-ui/src/faces.rs#L457), [`ui/dr-ui/src/identity_ui.rs:548`](../ui/dr-ui/src/identity_ui.rs#L548), [`ui/dr-ui/src/lib.rs:1479`](../ui/dr-ui/src/lib.rs#L1479), [`ui/dr-ui/src/lib.rs:3881`](../ui/dr-ui/src/lib.rs#L3881), [`ui/dr-ui/src/library.rs:3531`](../ui/dr-ui/src/library.rs#L3531), [`ui/dr-ui/src/library.rs:3797`](../ui/dr-ui/src/library.rs#L3797), [`ui/dr-ui/src/library.rs:4052`](../ui/dr-ui/src/library.rs#L4052), [`ui/dr-ui/src/library.rs:4333`](../ui/dr-ui/src/library.rs#L4333), [`ui/dr-ui/src/library.rs:4522`](../ui/dr-ui/src/library.rs#L4522), [`ui/dr-ui/src/library.rs:4558`](../ui/dr-ui/src/library.rs#L4558), [`ui/dr-ui/src/library.rs:4638`](../ui/dr-ui/src/library.rs#L4638), [`ui/dr-ui/src/library.rs:6855`](../ui/dr-ui/src/library.rs#L6855), [`ui/dr-ui/src/library_ui.rs:406`](../ui/dr-ui/src/library_ui.rs#L406), [`ui/dr-ui/src/library_ui.rs:491`](../ui/dr-ui/src/library_ui.rs#L491), [`ui/dr-ui/src/settings_ui.rs:210`](../ui/dr-ui/src/settings_ui.rs#L210), [`ui/dr-ui/src/settings_ui.rs:552`](../ui/dr-ui/src/settings_ui.rs#L552), [`ui/dr-ui/ui/app.slint:898`](../ui/dr-ui/ui/app.slint#L898), [`ui/dr-ui/ui/settings.slint:457`](../ui/dr-ui/ui/settings.slint#L457), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82), [`ui/dr-ui/ui/settings.slint:94`](../ui/dr-ui/ui/settings.slint#L94) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:651`](../core/dr-catalog/src/schema.rs#L651), [`core/dr-catalog/src/schema.rs:804`](../core/dr-catalog/src/schema.rs#L804), [`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:129`](../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:457`](../ui/dr-ui/src/faces.rs#L457), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:3904`](../ui/dr-ui/src/library.rs#L3904), [`ui/dr-ui/src/library.rs:4638`](../ui/dr-ui/src/library.rs#L4638), [`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:1027`](../core/dr-gpu/src/mask.rs#L1027), [`core/dr-gpu/src/mask.rs:1251`](../core/dr-gpu/src/mask.rs#L1251), [`core/dr-gpu/src/mask.rs:1509`](../core/dr-gpu/src/mask.rs#L1509), [`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:834`](../core/dr-gpu/src/mask.rs#L834), [`core/dr-gpu/src/mask.rs:85`](../core/dr-gpu/src/mask.rs#L85), [`core/dr-gpu/src/mask.rs:913`](../core/dr-gpu/src/mask.rs#L913), [`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:2845`](../core/dr-pipeline/src/mask.rs#L2845), [`core/dr-pipeline/src/mask.rs:2858`](../core/dr-pipeline/src/mask.rs#L2858), [`core/dr-pipeline/src/mask.rs:2877`](../core/dr-pipeline/src/mask.rs#L2877), [`core/dr-pipeline/src/mask.rs:2891`](../core/dr-pipeline/src/mask.rs#L2891), [`core/dr-pipeline/src/mask.rs:2901`](../core/dr-pipeline/src/mask.rs#L2901), [`core/dr-pipeline/src/mask.rs:710`](../core/dr-pipeline/src/mask.rs#L710), [`core/dr-pipeline/src/mask.rs:749`](../core/dr-pipeline/src/mask.rs#L749), [`core/dr-pipeline/src/mask.rs:809`](../core/dr-pipeline/src/mask.rs#L809), [`core/dr-pipeline/src/mask.rs:828`](../core/dr-pipeline/src/mask.rs#L828), [`core/dr-pipeline/src/mask.rs:852`](../core/dr-pipeline/src/mask.rs#L852), [`core/dr-pipeline/src/mask.rs:864`](../core/dr-pipeline/src/mask.rs#L864), [`core/dr-pipeline/src/mask.rs:876`](../core/dr-pipeline/src/mask.rs#L876), [`core/dr-pipeline/src/sidecar.rs:1309`](../core/dr-pipeline/src/sidecar.rs#L1309), [`core/dr-pipeline/src/sidecar.rs:1488`](../core/dr-pipeline/src/sidecar.rs#L1488), [`core/dr-pipeline/src/sidecar.rs:1577`](../core/dr-pipeline/src/sidecar.rs#L1577), [`core/dr-pipeline/src/sidecar.rs:1681`](../core/dr-pipeline/src/sidecar.rs#L1681), [`core/dr-pipeline/src/sidecar.rs:1889`](../core/dr-pipeline/src/sidecar.rs#L1889), [`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:2318`](../ui/dr-ui/src/develop.rs#L2318), [`ui/dr-ui/src/develop.rs:3657`](../ui/dr-ui/src/develop.rs#L3657), [`ui/dr-ui/src/develop.rs:3684`](../ui/dr-ui/src/develop.rs#L3684), [`ui/dr-ui/src/develop.rs:3716`](../ui/dr-ui/src/develop.rs#L3716), [`ui/dr-ui/src/develop.rs:3740`](../ui/dr-ui/src/develop.rs#L3740), [`ui/dr-ui/src/develop.rs:3746`](../ui/dr-ui/src/develop.rs#L3746), [`ui/dr-ui/src/develop.rs:3753`](../ui/dr-ui/src/develop.rs#L3753), [`ui/dr-ui/src/develop.rs:3773`](../ui/dr-ui/src/develop.rs#L3773), [`ui/dr-ui/src/labels.rs:63`](../ui/dr-ui/src/labels.rs#L63), [`ui/dr-ui/src/masks_ui.rs:1150`](../ui/dr-ui/src/masks_ui.rs#L1150), [`ui/dr-ui/src/masks_ui.rs:748`](../ui/dr-ui/src/masks_ui.rs#L748) |