From 6b51726322430efea97dd3be462810400d7d4509 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 19 Sep 2026 11:24:37 +0200 Subject: [PATCH] Read each face's eyes, and whether sunglasses hide them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two MIT classifiers from the same author as the reference pipeline's whole-body detector: OCEC answers P(open) for one 40×24 eye, SGC P(sunglasses) for a 48×48 head. Both load in tract once their batch dimension is pinned by tools/fix-face-model-shapes.sh, like the embedder. The crops come through the same fitted similarity the aligned face does, so an eye window is a constant in template units rather than a second warp, and a tilted head yields an upright eye. Measured on 60 proxies from the reference library: the eye window plateaus at 22×11, the S variant beats M and L (which overfit their own domain), and for sunglasses the aligned face beats a head framing but the higher of the two catches 11 of 12 pairs against 9 for either alone. The reading keeps both eyes and the sunglasses number apart, because a wink averages to the least informative value and a lens of dark glass draws a confident answer from the eye classifier — over a woman in sunglasses it read the right eye 0.97 open. Sunglasses take precedence, and a face behind them is neither open nor a blink. --- core/dr-face/Cargo.toml | 4 + core/dr-face/examples/eyes.rs | 170 ++++++++++++++ core/dr-face/src/align.rs | 412 ++++++++++++++++++++++++++++++++-- core/dr-face/src/classify.rs | 205 +++++++++++++++++ core/dr-face/src/eyes.rs | 139 ++++++++++++ core/dr-face/src/lib.rs | 9 +- docs/traceability.md | 14 +- 7 files changed, 931 insertions(+), 22 deletions(-) create mode 100644 core/dr-face/examples/eyes.rs create mode 100644 core/dr-face/src/classify.rs create mode 100644 core/dr-face/src/eyes.rs diff --git a/core/dr-face/Cargo.toml b/core/dr-face/Cargo.toml index 6c11588..27e5ae0 100644 --- a/core/dr-face/Cargo.toml +++ b/core/dr-face/Cargo.toml @@ -30,6 +30,10 @@ required-features = ["inference"] name = "faces" required-features = ["inference"] +[[example]] +name = "eyes" +required-features = ["inference"] + [features] # Nothing on by default, and in particular **no `embedded-model`**: the weights # are not a build input and never become one (docs/faces.md §2.2). A feature diff --git a/core/dr-face/examples/eyes.rs b/core/dr-face/examples/eyes.rs new file mode 100644 index 0000000..28f279a --- /dev/null +++ b/core/dr-face/examples/eyes.rs @@ -0,0 +1,170 @@ +//! 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. +//! +//! 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 ...] +//! +//! `--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 +//! `tools/fix-face-model-shapes.sh`. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use dr_face::{align, DetectOptions, Detector, EyeModels, Pixels}; + +fn main() { + env_logger::init(); + + let mut args: Vec = std::env::args().skip(1).collect(); + let dump = args.iter().position(|a| a == "--dump").map(|i| { + 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 { + eprintln!( + "usage: eyes DET.onnx OCEC.onnx SGC.onnx [--dump DIR] [--eye W,H] [--head X,Y,W,H] IMAGE.jpg [IMAGE.jpg ...]" + ); + std::process::exit(2); + } + if let Some(d) = &dump { + std::fs::create_dir_all(d).expect("dump dir"); + } + + 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"); + println!("loaded the models in {:?}", t.elapsed()); + + let opts = DetectOptions::default(); + for path in &args[3..] { + let (rgb, w, h) = match load_jpeg(path) { + Ok(v) => v, + Err(e) => { + println!("{path}: {e}"); + continue; + } + }; + let dets = detector.detect(&rgb, w, h, &opts).expect("detect"); + println!("\n{path} ({w}×{h}) {} face(s)", dets.len()); + + let stem = Path::new(path) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + + 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"); + 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)", + d.confidence, + d.width(), + d.height(), + reading.right_open, + reading.left_open, + reading.sunglasses, + reading.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, + ); + } + } + } + } +} + +fn write_ppm(path: &Path, rgb: &[f32], w: usize, h: usize) { + let mut out = format!("P6\n{w} {h}\n255\n").into_bytes(); + out.extend( + rgb.iter() + .map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8), + ); + std::fs::write(path, out).expect("write ppm"); +} + +/// Decode to the tightly packed `f32` RGB `0.0..=1.0` the crate expects. +fn load_jpeg(path: &str) -> Result<(Vec, usize, usize), String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let mut dec = zune_jpeg::JpegDecoder::new(&bytes); + let px = dec.decode().map_err(|e| e.to_string())?; + let info = dec.info().ok_or("no jpeg header")?; + let (w, h) = (info.width as usize, info.height as usize); + + let rgb: Vec = match px.len() / (w * h) { + 3 => px.iter().map(|&v| v as f32 / 255.0).collect(), + 1 => px + .iter() + .flat_map(|&v| { + let g = v as f32 / 255.0; + [g, g, g] + }) + .collect(), + n => return Err(format!("{n} components per pixel, expected 1 or 3")), + }; + Ok((rgb, w, h)) +} diff --git a/core/dr-face/src/align.rs b/core/dr-face/src/align.rs index c2f4033..6ec53aa 100644 --- a/core/dr-face/src/align.rs +++ b/core/dr-face/src/align.rs @@ -351,27 +351,278 @@ pub fn warp_pixels( let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?; let e = ALIGNED_EDGE; - let mut pixels = vec![0.0_f32; e * e * 3]; - for v in 0..e { - for u in 0..e { - // Pixel centres, so the transform is not off by half a pixel — - // which is small enough to survive review and large enough to - // matter on a 40-pixel face. - let (x, y) = m.invert(u as f32 + 0.5, v as f32 + 0.5); - let (x, y) = (x - 0.5, y - 0.5); - let out = (v * e + u) * 3; - sample_bilinear(px, width, height, x, y, &mut pixels[out..out + 3]); - } - } - + let window = TemplateWindow { + x: 0.0, + y: 0.0, + w: e as f32, + h: e as f32, + }; Some(Aligned112 { - pixels, + pixels: sample_window(px, width, height, &m, &window, e, e), // The warp maps `scale` source pixels to one destination pixel, so the // crop spans 112/scale of the source. source_px: ALIGNED_EDGE as f32 / m.scale(), }) } +/// A rectangle in **template** coordinates — the 112-unit frame +/// [`ARCFACE_TEMPLATE`] is written in — that a crop is sampled from. +/// +/// Every crop this module makes is one of these resampled through the same +/// fitted similarity: the aligned face is the window `(0, 0, 112, 112)`, an +/// eye is a small window around its template point, a head is a window larger +/// than the face. Stating them all in one frame is what lets a second crop be +/// added as a constant rather than a second warp, and what keeps them +/// consistent with each other — the eye window sits where the eye landmark +/// lands *after* alignment, so a tilted face gets an upright eye. +#[derive(Debug, Clone, Copy, PartialEq)] +struct TemplateWindow { + x: f32, + y: f32, + w: f32, + h: f32, +} + +/// Resample `window` of the template frame into an `out_w × out_h` RGB buffer. +/// +/// Bilinear, from the source, in one step — the property [`warp`] insists on, +/// and every crop through here inherits it. The output pixel `(u, v)` is placed +/// at its centre in the window, taken back through `m` to source coordinates, +/// and sampled there; the window's aspect is **not** preserved when it differs +/// from the output's, which is deliberate for the eye classifier (it was +/// trained on detector boxes resized the same way) and moot for the others. +fn sample_window( + px: Pixels<'_>, + width: usize, + height: usize, + m: &Similarity, + window: &TemplateWindow, + out_w: usize, + out_h: usize, +) -> Vec { + let mut pixels = vec![0.0_f32; out_w * out_h * 3]; + let sx = window.w / out_w as f32; + let sy = window.h / out_h as f32; + for v in 0..out_h { + for u in 0..out_w { + // Pixel centres, so the transform is not off by half a pixel — + // which is small enough to survive review and large enough to + // matter on a 40-pixel face. + let tx = window.x + (u as f32 + 0.5) * sx; + let ty = window.y + (v as f32 + 0.5) * sy; + let (x, y) = m.invert(tx, ty); + let (x, y) = (x - 0.5, y - 0.5); + let out = (v * out_w + u) * 3; + sample_bilinear(px, width, height, x, y, &mut pixels[out..out + 3]); + } + } + pixels +} + +// ── eyes ────────────────────────────────────────────────────────────────── + +/// Width of an eye crop as the classifier reads it, in pixels. Fixed by the +/// OCEC input (`docs/faces.md` §17): 40 wide, 24 high. +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. +/// +/// 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); + +/// One eye, resampled to the classifier's input. +/// +/// Constructible only by [`eye_patches`], 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. +#[derive(Debug, Clone, PartialEq)] +pub struct EyePatch { + /// `24 × 40 × 3`, row-major RGB in `0.0..=1.0`. + pixels: Vec, +} + +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; + } + 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), + }) +} + +// ── sunglasses ──────────────────────────────────────────────────────────── + +/// Edge of the crop the sunglasses classifier reads. Fixed by the SGC input: +/// 48×48. +pub const SUNGLASSES_EDGE: usize = 48; + +/// The windows read for the sunglasses classifier, in template units: +/// `(x, y, w, h)`. +/// +/// **Two framings, and the classifier's answer is the higher of the two.** +/// It was trained on a whole-body detector's *head* boxes, and a head box +/// is not reproducible from five landmarks: how much hair and hat it took in +/// depended on the person. So it is shown the face twice — once as the +/// aligned crop itself, once shifted up and widened to take in hair and +/// hat at the cost of the chin, which is roughly where a head box falls — +/// and a pair of sunglasses counts if it looks like one in either. +/// +/// Measured over 12 faces in sunglasses and 28 with plainly visible eyes +/// from the reference library (`examples/eyes.rs --head`), at the 0.5 +/// threshold: +/// +/// | window | sunglasses found | clear eyes kept | +/// |---|---|---| +/// | the aligned face, `(0, 0, 112, 112)` | 9 | 28 | +/// | a head, `(-5, -14, 122, 122)` | 6 | 27 | +/// | a larger head, `(-30, -55, 172, 190)` | 6 | 25 | +/// | **the higher of the first two** | **11** | 27 | +/// +/// The face-tight crop alone was the best single framing, which was not the +/// expectation; the head framing found the sunglasses under a cap that the +/// face crop missed. The one clear-eyed face the pair loses wears a cap and +/// clear glasses, at 0.68. Erring towards "sunglasses" is the safe direction +/// for what this feeds: a face called sunglasses is left alone by the +/// eyes-open filter, where a pair of sunglasses missed hands the eye +/// classifier a lens to guess at (docs/faces.md §17). +pub const SUNGLASSES_WINDOWS: [(f32, f32, f32, f32); 2] = + [(0.0, 0.0, 112.0, 112.0), (-5.0, -14.0, 122.0, 122.0)]; + +/// The framings of one face the sunglasses classifier is shown. +/// +/// A newtype for the reason [`EyePatch`] is one. +#[derive(Debug, Clone, PartialEq)] +pub struct HeadViews { + /// Each `48 × 48 × 3`, row-major RGB in `0.0..=1.0`. + views: Vec>, +} + +impl HeadViews { + pub fn views(&self) -> impl Iterator { + self.views.iter().map(Vec::as_slice) + } +} + +/// Cut the [`SUNGLASSES_WINDOWS`] out of the source, aligned, at the +/// classifier's size. +pub fn head_views( + px: Pixels<'_>, + width: usize, + height: usize, + landmarks: &[(f32, f32); 5], +) -> Option { + head_views_in(px, width, height, landmarks, &SUNGLASSES_WINDOWS) +} + +/// [`head_views`] over windows other than [`SUNGLASSES_WINDOWS`]. +/// +/// For measuring them, which is how the constant was chosen +/// (`examples/eyes.rs --head`); production callers use the constant. +pub fn head_views_in( + px: Pixels<'_>, + width: usize, + height: usize, + landmarks: &[(f32, f32); 5], + windows: &[(f32, f32, f32, f32)], +) -> Option { + if !px.fits(width, height) || windows.is_empty() { + return None; + } + let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?; + let views = windows + .iter() + .map(|&(x, y, w, h)| { + let window = TemplateWindow { x, y, w, h }; + sample_window( + px, + width, + height, + &m, + &window, + SUNGLASSES_EDGE, + SUNGLASSES_EDGE, + ) + }) + .collect(); + Some(HeadViews { views }) +} + fn sample_bilinear(px: Pixels<'_>, w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) { let x0 = x.floor(); let y0 = y.floor(); @@ -489,6 +740,139 @@ mod tests { } } + /// A source whose red channel is its x coordinate and green its y, so a + /// crop's mean colour says where in the source it was taken from. + fn coordinate_image(w: usize, h: usize) -> Vec { + let mut rgb = vec![0.0_f32; w * h * 3]; + for y in 0..h { + for x in 0..w { + rgb[(y * w + x) * 3] = x as f32 / w as f32; + rgb[(y * w + x) * 3 + 1] = y as f32 / h as f32; + } + } + rgb + } + + fn mean_channel(px: &[f32], c: usize) -> f32 { + let n = px.len() / 3; + 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. + #[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 + ); + + 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)); + } + + /// 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. + #[test] + fn an_eye_patch_spans_the_window_it_was_asked_for() { + let (w, h) = (224, 224); + 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}"); + } + + /// A tilted face yields upright eyes: the patch's rows run along the + /// interocular line, not along the image's x axis. + #[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}" + ); + } + + /// The second sunglasses framing takes in more than the face — it starts + /// above the template's top edge and ends below its bottom — and the + /// first is the aligned face itself. + #[test] + fn the_head_views_are_the_face_and_a_wider_framing_of_it() { + let (w, h) = (300, 300); + let rgb = coordinate_image(w, h); + let lm = shifted_scaled(1.0, 100.0, 100.0, 0.0); + let head = head_views(Pixels::RgbF32(&rgb), w, h, &lm).unwrap(); + let views: Vec<&[f32]> = head.views().collect(); + let face = warp(&rgb, w, h, &lm).unwrap(); + assert_eq!(views.len(), SUNGLASSES_WINDOWS.len()); + for v in &views { + assert_eq!(v.len(), SUNGLASSES_EDGE * SUNGLASSES_EDGE * 3); + } + + // The face view samples the same region as the aligned crop. + assert!((mean_channel(views[0], 0) - mean_channel(face.pixels(), 0)).abs() < 0.01); + assert!((mean_channel(views[0], 1) - mean_channel(face.pixels(), 1)).abs() < 0.01); + + let (x, y, ww, hh) = SUNGLASSES_WINDOWS[1]; + assert!( + x < 0.0 && y < 0.0, + "the window starts outside the face crop" + ); + assert!(x + ww > ALIGNED_EDGE as f32, "and is wider than it"); + assert!(y + hh < ALIGNED_EDGE as f32, "but stops short of the chin"); + // Centred horizontally on the face, so the two share a mean x. + assert!((mean_channel(views[1], 0) - mean_channel(face.pixels(), 0)).abs() < 0.01); + // Its first row lies above the face's first row. + assert!(views[1][1] < face.pixels()[1]); + } + + #[test] + fn degenerate_landmarks_yield_no_eye_or_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()); + } + #[test] fn out_of_bounds_samples_read_black_rather_than_wrapping() { let rgb = vec![1.0_f32; 32 * 32 * 3]; diff --git a/core/dr-face/src/classify.rs b/core/dr-face/src/classify.rs new file mode 100644 index 0000000..ecd63ea --- /dev/null +++ b/core/dr-face/src/classify.rs @@ -0,0 +1,205 @@ +//! TRACES: FR-CULL-13 +//! The two small classifiers behind a face's eye state (docs/faces.md §17). +//! +//! **OCEC** — *open closed eyes classification*, Hyodo 2025 — reads one +//! 40×24 eye and answers P(open). **SGC** — *sunglasses classification*, +//! Hyodo 2026 — reads a 48×48 head and answers P(sunglasses); it is shown +//! two framings of each face and the higher answer stands, for the reason +//! [`crate::align::SUNGLASSES_WINDOWS`] gives. Both are +//! depthwise-separable CNNs of a few hundred kilobytes, both MIT with their +//! weights, and both were exported with BatchNorm already folded, which is +//! about the friendliest graph tract can be handed. +//! +//! Neither takes a plain buffer. [`EyeClassifier::classify`] takes an +//! [`EyePatch`] and [`SunglassesClassifier::classify`] a [`HeadViews`], each +//! 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. +//! +//! # The graphs must have a fixed batch +//! +//! Both ship with a dynamic batch dimension, which tract will not analyse. +//! `tools/fix-face-model-shapes.sh` pins it to 1, exactly as it does for the +//! embedder; the shipped files are the pinned ones. +//! +//! # Pre-processing +//! +//! Read off the reference demos rather than assumed: RGB, `x / 255`, NCHW, +//! the crop resized to the input with bilinear interpolation and **without** +//! preserving its aspect. [`crate::align`]'s crops arrive already at the +//! input size in `0..=1`, so there is nothing left to do but lay them out. + +use ndarray::Array4; + +use crate::align::{ + EyePatch, EyePatches, HeadViews, EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH, SUNGLASSES_EDGE, +}; +use crate::eyes::EyeReading; +use crate::{install_backend, FaceError}; + +/// A loaded OCEC graph. +pub struct EyeClassifier { + session: ort::session::Session, +} + +/// A loaded SGC graph. +pub struct SunglassesClassifier { + session: ort::session::Session, +} + +/// Open a single-input, single-output classifier and check it is the shape +/// the crop feeding it will be. +/// +/// The check is against the *input*, because that is where these two graphs +/// differ from each other and from everything else in this crate: an SGC file +/// given to the eye classifier would otherwise be resized into by an eye +/// patch, and answer. `expected` names the model in the error. +fn open_classifier( + bytes: &[u8], + expected: &'static str, + (h, w): (usize, usize), +) -> 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, + detail: "model has no inputs".into(), + })?; + let shape: Option> = input.dtype().tensor_shape().map(|s| s.to_vec()); + let want = [1, 3, h as i64, w as i64]; + if shape.as_deref() != Some(&want[..]) { + return Err(FaceError::WrongModel { + expected, + detail: format!( + "input '{}' is {:?}, expected {:?} (batch pinned to 1)", + input.name(), + shape, + want + ), + }); + } + if session.outputs().len() != 1 { + return Err(FaceError::WrongModel { + expected, + detail: format!("{} outputs, expected one", session.outputs().len()), + }); + } + Ok(session) +} + +/// Lay a `h × w` RGB crop out as the `[1, 3, h, w]` tensor both graphs take. +fn to_nchw(pixels: &[f32], h: usize, w: usize) -> Array4 { + let mut input = Array4::::zeros((1, 3, h, w)); + for y in 0..h { + for x in 0..w { + for c in 0..3 { + input[[0, c, y, x]] = pixels[(y * w + x) * 3 + c]; + } + } + } + input +} + +/// Run a one-number classifier and read its sigmoid back, clamped. +fn run_scalar( + session: &mut ort::session::Session, + input: Array4, + expected: &'static str, +) -> Result { + let outputs = 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)?; + let Some(&p) = data.first() else { + return Err(FaceError::WrongModel { + expected, + detail: "empty output".into(), + }); + }; + // The graph ends in a sigmoid, so this is a clamp against rounding and + // nothing more — the reference demo does the same. + Ok(p.clamp(0.0, 1.0)) +} + +impl EyeClassifier { + 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 { + Ok(Self { + session: open_classifier(bytes, "OCEC", (EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH))?, + }) + } + + /// P(open) for one eye. + pub fn classify(&mut self, eye: &EyePatch) -> Result { + let input = to_nchw(eye.pixels(), EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH); + run_scalar(&mut self.session, input, "OCEC") + } +} + +impl SunglassesClassifier { + 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 { + Ok(Self { + session: open_classifier(bytes, "SGC", (SUNGLASSES_EDGE, SUNGLASSES_EDGE))?, + }) + } + + /// P(sunglasses) for one head: the highest answer over its framings. + pub fn classify(&mut self, head: &HeadViews) -> Result { + let mut best = 0.0_f32; + for view in head.views() { + let input = to_nchw(view, SUNGLASSES_EDGE, SUNGLASSES_EDGE); + best = best.max(run_scalar(&mut self.session, input, "SGC")?); + } + Ok(best) + } +} + +/// The two classifiers together, 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. +pub struct EyeModels { + pub eyes: EyeClassifier, + pub sunglasses: SunglassesClassifier, +} + +impl EyeModels { + pub fn from_paths( + eyes: impl AsRef, + sunglasses: impl AsRef, + ) -> Result { + Ok(Self { + 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)?, + }) + } +} diff --git a/core/dr-face/src/eyes.rs b/core/dr-face/src/eyes.rs new file mode 100644 index 0000000..a20ffac --- /dev/null +++ b/core/dr-face/src/eyes.rs @@ -0,0 +1,139 @@ +//! TRACES: FR-CULL-13 +//! What a face's eyes are doing, and how the three 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 +//! screen's label — comes through here, so a threshold lives in exactly one +//! place. +//! +//! # Three 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. +//! +//! 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. + +/// The probabilities the classifiers 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, + /// P(the head wears sunglasses). + pub sunglasses: f32, +} + +/// Above this an eye is open. The classifier's own decision point; its +/// training put the two classes either side of a sigmoid and this is where +/// the sigmoid crosses. +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; + +/// What the reading says, for a screen or a filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EyeState { + /// Both eyes open. + Open, + /// At least one eye 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, +} + +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 { + EyeState::Open + } else { + EyeState::Closed + } + } + + /// 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. + pub fn is_blink(&self) -> bool { + self.state() == EyeState::Closed + } +} + +impl EyeState { + /// The word the People screen puts on the face. + pub fn label(&self) -> &'static str { + match self { + EyeState::Open => "Eyes open", + EyeState::Closed => "Eyes closed", + EyeState::Sunglasses => "Sunglasses", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reading(right: f32, left: f32, sunglasses: f32) -> EyeReading { + EyeReading { + right_open: right, + left_open: left, + sunglasses, + } + } + + #[test] + fn both_eyes_open_is_open() { + assert_eq!(reading(0.9, 0.8, 0.1).state(), EyeState::Open); + assert!(!reading(0.9, 0.8, 0.1).is_blink()); + } + + /// A wink is not "eyes open": one eye closed lands the same place a + /// blink does, and a filter for "nobody blinking" should drop it. + #[test] + fn one_eye_closed_is_closed() { + assert_eq!(reading(0.9, 0.2, 0.1).state(), EyeState::Closed); + assert_eq!(reading(0.2, 0.9, 0.1).state(), EyeState::Closed); + 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. + #[test] + fn sunglasses_override_the_eye_readings_either_way() { + assert_eq!(reading(0.9, 0.9, 0.8).state(), EyeState::Sunglasses); + assert_eq!(reading(0.1, 0.1, 0.8).state(), EyeState::Sunglasses); + assert!(!reading(0.1, 0.1, 0.8).is_blink()); + } + + #[test] + fn the_thresholds_are_inclusive_at_the_decision_point() { + assert_eq!( + reading(EYES_OPEN_THRESHOLD, EYES_OPEN_THRESHOLD, 0.0).state(), + EyeState::Open + ); + assert_eq!( + reading(1.0, 1.0, SUNGLASSES_THRESHOLD).state(), + EyeState::Sunglasses + ); + } +} diff --git a/core/dr-face/src/lib.rs b/core/dr-face/src/lib.rs index de939a0..63374cd 100644 --- a/core/dr-face/src/lib.rs +++ b/core/dr-face/src/lib.rs @@ -34,12 +34,15 @@ pub mod align; pub mod assign; pub mod calibrate; +#[cfg(feature = "inference")] +pub mod classify; pub mod cluster; #[cfg(feature = "inference")] pub mod detect; #[cfg(feature = "inference")] pub mod embed; pub mod embedding; +pub mod eyes; pub mod naming; pub mod neighbours; @@ -65,10 +68,13 @@ pub mod neighbours; pub const MIN_CROP_EDGE: u32 = 1025; pub use align::{ - warp, warp_pixels, Aligned112, Pixels, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE, + eye_patches, head_views, warp, warp_pixels, Aligned112, EyePatch, EyePatches, HeadViews, + Pixels, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE, }; pub use assign::{identity_shares, RIVAL_FLOOR, TOP_MATCHES}; pub use calibrate::{Calibration, Pairs, ReliabilityBand}; +#[cfg(feature = "inference")] +pub use classify::{EyeClassifier, EyeModels, SunglassesClassifier}; pub use cluster::{ cluster, cluster_scored, split, Candidate, Cluster, Grouping, DEFAULT_MERGE_PROBABILITY, }; @@ -79,6 +85,7 @@ 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 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 e7d615b..ffe88aa 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 354 | -| TRACES tags found | 1502 | +| Source files scanned | 357 | +| TRACES tags found | 1504 | | Requirements defined | 171 | | Requirements deferred (post-v1) | 24 | -| Requirements covered | 142 | -| **Coverage** | **83.0%** (142/171) | +| Requirements covered | 143 | +| **Coverage** | **83.6%** (143/171) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 104 | 116 | +| FR | 105 | 116 | | NFR | 34 | 48 | | R | 4 | 7 | @@ -53,6 +53,7 @@ _None._ | FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:447`](../core/dr-catalog/src/schema.rs#L447), [`core/dr-catalog/src/schema.rs:625`](../core/dr-catalog/src/schema.rs#L625), [`core/dr-catalog/src/schema.rs:751`](../core/dr-catalog/src/schema.rs#L751), [`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:751`](../core/dr-catalog/src/schema.rs#L751), [`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:447`](../core/dr-catalog/src/schema.rs#L447), [`core/dr-catalog/src/schema.rs:751`](../core/dr-catalog/src/schema.rs#L751), [`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-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-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) | @@ -208,12 +209,11 @@ Defined in `requirements.md` and marked `(post-v1)` on the defining line. Not in ## Not yet tagged -29 of 171 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +28 of 171 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements - FR-CAT-14 -- FR-CULL-13 - FR-CULL-6 - FR-CULL-7 - FR-CULL-8a