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) |