collective

Browse / github.com/storytold/lightcraft / patch

faces: YuNet looks at photos up to 1024 px in their own shape, not 640 x 640

faces: YuNet looks at photos up to 1024 px in their own shape, not 640 x 640 YuNet is fully convolutional, so the network does not have to see each photo squeezed into its file's 640 x 640 square. A photo is now shrunk (never enlarged) until its long edge is at most 1024 px, padded at the right and bottom to a multiple of 32, and the outputs are mapped back per axis. This is how OpenCV's `FaceDetectorYN` runs it and how its WIDER FACE figures were measured (those photos are 1024 px wide). Quality (WIDER FACE validation, 3,226 photos, official protocol): easy / medium / hard AP 88.9 / 87.2 / 76.6 against the published 88.4 / 86.6 / 75.0; the former 640 x 640 square gav

Status
open
Score
0.00
Adopters
0 other operators
Author
sm18lr88 (imported from pull request #3092, open)
Base
bb08a697a41b2561a54f1b740245119503b24758
Size
+496 −97 in 9 files
Review
pending
Shared
2026-10-28
Id
a68a65d70c668fe36a7047d7

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/face_detect.rs crates/engine/src/cmd/face_models.rs crates/engine/src/cmd/masks.rs crates/engine/src/faces_worker.rs crates/faces/src/synthetic.rs crates/faces/src/yunet.rs docs/faces.md docs/parity-checklist.md tools/yunet_reference.py

diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs--- a/crates/engine/src/cmd/face_detect.rs+++ b/crates/engine/src/cmd/face_detect.rs@@ -24,7 +24,7 @@ const C: &str = "faces.detect"; /// Regions made by this command say so in their description; only those are replaced on a new run. pub(crate) const MARK: &str = "Detected by "; pub(crate) const LABEL: &str = "YuNet 2023mar";-/// Long edge of the picture the detector looks at (it shrinks it to its own 640 anyway).+/// Long edge of the picture the detector is given (it shrinks it to its own scan size, 1024 px, anyway). const LOOK_EDGE: usize = 1280; /// YuNet is 232 KB; a file this much larger in its folder is not it. const MAX_MODEL_BYTES: u64 = 8 << 20;@@ -253,6 +253,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {         score: p.get("score").and_then(Value::as_f64).map_or(defaults.score, |v| v as f32),         nms_iou: p.get("nmsIou").and_then(Value::as_f64).map_or(defaults.nms_iou, |v| v as f32),         max_faces: p.get("maxFaces").and_then(Value::as_u64).map_or(defaults.max_faces, |v| usize::try_from(v).unwrap_or(defaults.max_faces)),+        long_edge: defaults.long_edge,     };     let detector = detector(s).map_err(|e| e.into_error(C))?;     let started = Instant::now();@@ -294,7 +295,9 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {         // regions are not written to XMP, so a sidecar must not be rewritten for this         s.skip_auto_write = true;     }-    Ok(json!({"detector": LABEL, "applied": applied, "photos": results, "ms": started.elapsed().as_secs_f64() * 1000.0}))+    Ok(+        json!({"detector": LABEL, "applied": applied, "photos": results, "input": detector.input_mode(), "ms": started.elapsed().as_secs_f64() * 1000.0}),+    ) }  pub fn specs() -> Vec<CommandSpec> {@@ -304,7 +307,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Detect Faces",             ["Photo"],             None,-            "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",+            "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}], input}. `input` says how photos reach the detector (in their own shape, up to 1024 px, or fitted into the model's declared square when it cannot take other shapes). Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",             has_selection,             detect         ),diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs--- a/crates/engine/src/cmd/face_models.rs+++ b/crates/engine/src/cmd/face_models.rs@@ -403,6 +403,12 @@ fn download_cancel(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({"discarded": s.face_downloads.discard(id)})) } +/// What the detector's self-test looks at with: the model's declared square, not the scan's default size.+#[cfg(not(target_arch = "wasm32"))]+fn detector_test_options(side: usize) -> lightcraft_faces::yunet::Options {+    lightcraft_faces::yunet::Options { long_edge: side, ..lightcraft_faces::yunet::Options::default() }+}+ /// Load the model and run its self-test: `Ok(null)` in a build without the recognition runtime (nothing can /// be checked), `Ok(result)` when it passes, `Err(reason)` when it does not load or fails a check. #[cfg(not(target_arch = "wasm32"))]@@ -416,8 +422,11 @@ fn self_test(path: &Path, m: &ModelManifest) -> std::result::Result<Value, Strin         {             return Err("unsupported detector input or output decoder".into());         }-        detector.detect(&[0u8; 12], 2, 2, &lightcraft_faces::yunet::Options::default()).map_err(|e| e.to_string())?;-        return Ok(json!({"ok": true}));+        // a blank photo of the declared side, looked at at that side: the network runs at the shape the file declares+        // (the self-test does not take the scan's 1024 px default, so it stays as cheap as it was)+        let side = detector.input_side();+        detector.detect(&vec![128u8; side * side * 3], side, side, &detector_test_options(side)).map_err(|e| e.to_string())?;+        return Ok(json!({"ok": true, "input": detector.input_mode()}));     }     let embedder = lightcraft_faces::runtime::Embedder::load(path, m).map_err(|e| e.to_string())?;     let t = embedder.self_test();@@ -537,3 +546,35 @@ pub fn specs() -> Vec<CommandSpec> {         cmd!(query "faces.enable", "Face Recognition On/Off", [], None, "{enabled?: bool} (toggles when omitted)", always, enable),     ] }++#[cfg(all(test, not(target_arch = "wasm32")))]+mod tests_detector_self_test {+    use super::*;++    fn run(bytes: &[u8]) -> Value {+        let dir = std::env::temp_dir().join(format!("lc-selftest-{}-{}", std::process::id(), bytes.len()));+        std::fs::create_dir_all(&dir).unwrap();+        let path = dir.join("model.onnx");+        std::fs::write(&path, bytes).unwrap();+        let mut m = lightcraft_faces::synthetic::tiny_detector_manifest();+        m.input.width = 64;+        m.input.height = 64;+        let result = self_test(&path, &m).unwrap();+        let _ = std::fs::remove_dir_all(&dir);+        result+    }++    #[test]+    fn the_self_test_looks_at_the_declared_square_and_reports_how_photos_reach_the_model() {+        // not the scan's 1024 px default+        assert_eq!(detector_test_options(640).long_edge, 640);+        assert_ne!(detector_test_options(640).long_edge, lightcraft_faces::yunet::Options::default().long_edge);+        let flexible = run(&lightcraft_faces::synthetic::tiny_detector_model());+        assert_eq!(flexible["ok"], true);+        assert!(flexible["input"].as_str().unwrap().contains("own shape"), "{flexible}");+        // a shape-fixed model still passes (it is usable in its square) and says so+        let fixed = run(&lightcraft_faces::synthetic::fixed_shape_detector_model());+        assert_eq!(fixed["ok"], true);+        assert!(fixed["input"].as_str().unwrap().contains("fitted into the model's 64 × 64 square"), "{fixed}");+    }+}diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs--- a/crates/engine/src/cmd/masks.rs+++ b/crates/engine/src/cmd/masks.rs@@ -833,8 +833,7 @@ pub fn specs() -> Vec<CommandSpec> {                 let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;                 let img = job.run().rendered.map_err(|e| bad(c, e))?.image;                 let rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();-                let faces =-                    detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;+                let faces = detector.detect(&rgb, img.width, img.height, &blemish_detector_options()).map_err(|e| bad(c, e.to_string()))?;                 let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces                     .iter()                     .map(|f| lightcraft_pipeline::blemish::FaceBox {@@ -1142,6 +1141,30 @@ fn pressure_list(v: Option<&Value>, points: usize) -> Result<Vec<f32>> {         .ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1")) } +/// Long edge the face detector looks at for `spot.findBlemishes`: the photo is 1600 px and a face narrower than 160 px+/// gets no spots, so the faces that matter are at least 64 px at this size, far above YuNet's 10 px floor. The scan's+/// 1024 px would cost more for nothing here, so this keeps the size the command always used.+const BLEMISH_LOOK_EDGE: usize = 640;++fn blemish_detector_options() -> lightcraft_faces::yunet::Options {+    lightcraft_faces::yunet::Options { long_edge: BLEMISH_LOOK_EDGE, ..lightcraft_faces::yunet::Options::default() }+}++#[cfg(test)]+mod tests_blemish_detector {+    use super::*;++    #[test]+    fn blemish_search_keeps_its_own_detector_size() {+        let o = blemish_detector_options();+        assert_eq!(o.long_edge, 640);+        assert_ne!(o.long_edge, lightcraft_faces::yunet::Options::default().long_edge, "the default is the scan's size");+        // the rest is the detector's defaults+        let d = lightcraft_faces::yunet::Options::default();+        assert_eq!((o.score, o.nms_iou, o.max_faces), (d.score, d.nms_iou, d.max_faces));+    }+}+ #[cfg(test)] mod tests_color_range {     use super::*;diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs--- a/crates/engine/src/faces_worker.rs+++ b/crates/engine/src/faces_worker.rs@@ -15,7 +15,7 @@ use std::time::Duration; use lightcraft_catalog::PhotoId; use lightcraft_faces::align::{Rgb, align_to_template, crop_box}; use lightcraft_faces::runtime::Embedder;-use lightcraft_faces::yunet::{Detector, Face, Options};+use lightcraft_faces::yunet::{Detector, Face, Options, SCAN_LONG_EDGE}; use lightcraft_geom::Rect;  use crate::media::{PreviewLoader, RenderJob};@@ -28,6 +28,12 @@ pub(crate) const BOX_PAD: f32 = 1.5; pub(crate) const MATCH_IOU: f32 = 0.3; /// A detection must be this sure to become a face region (the bar `faces.detect` uses). pub(crate) const REGION_SCORE: f32 = 0.6;+/// What the background scan asks the detector for: a lower bar than `faces.detect` (a face the user can then name is+/// better than none), looked at in the detector's scan size (1024 px) so the back rows of a group photo are found.+fn scan_options() -> Options {+    Options { score: 0.5, long_edge: SCAN_LONG_EDGE, ..Options::default() }+}+ /// A raw's embedded preview is used for the scan only when its long edge is at least this (a thumbnail is too small to /// find faces in; the raw itself is decoded then). const MIN_PREVIEW_EDGE: usize = 1000;@@ -207,7 +213,7 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {         let (w, h) = (image.width, image.height);         let rgb: Vec<u8> = image.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();         // the detector's landmarks, where it finds the same face: faces are aligned by them, not cut by a box-        let detected = detector.as_deref().and_then(|d| d.detect(&rgb, w, h, &Options { score: 0.5, ..Options::default() }).ok());+        let detected = detector.as_deref().and_then(|d| d.detect(&rgb, w, h, &scan_options()).ok());         let found = detected.clone().unwrap_or_default();         let img = Rgb { data: &rgb, width: w, height: h };         let (iw, ih) = embedder.input_size();@@ -397,6 +403,13 @@ impl Worker { mod tests {     use super::*; +    #[test]+    fn the_scan_asks_the_detector_for_its_scan_size_and_a_lower_bar() {+        let o = scan_options();+        assert_eq!((o.score, o.long_edge), (0.5, 1024));+        assert!(o.score < REGION_SCORE);+    }+     #[test]     fn the_pace_sets_how_much_of_the_machine_the_scan_may_use() {         use Pace::*;diff --git a/crates/faces/src/synthetic.rs b/crates/faces/src/synthetic.rs--- a/crates/faces/src/synthetic.rs+++ b/crates/faces/src/synthetic.rs@@ -108,6 +108,17 @@ fn node_bytes(op: &str, inputs: &[&str], output: &str, attrs: &[(&str, Attribute     n } +/// A one-dimensional INT64 initializer (a Reshape's target shape).+fn int_tensor_bytes(name: &str, values: &[i64]) -> Vec<u8> {+    let mut t = Vec::new();+    varint_field(1, values.len() as u64, &mut t);+    varint_field(2, 7, &mut t); // INT64+    len_field(8, name.as_bytes(), &mut t);+    let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();+    len_field(9, &raw, &mut t);+    t+}+ fn float_tensor_bytes(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {     let mut t = Vec::new();     for d in dims {@@ -213,18 +224,56 @@ pub(crate) fn norm_overflow_model() -> Vec<u8> {     model } -/// A small YuNet-shaped stand-in that reports no faces, independent of the real downloaded model.+/// A small YuNet-shaped stand-in that reports no faces, independent of the real downloaded model. Like YuNet it is+/// fully convolutional (a zero 1 x 1 convolution per stride, then `[1, cells, components]`), so it runs at any input+/// size that is a multiple of 32. pub fn tiny_detector_model() -> Vec<u8> {+    detector_model(false, false)+}++/// Like [`tiny_detector_model`], but its Reshape targets are literal (the cells of a 64 x 64 input), as in a+/// shape-fixed export: it runs at 64 x 64 and at nothing else.+pub fn fixed_shape_detector_model() -> Vec<u8> {+    detector_model(true, false)+}++/// A stand-in that finds a bright patch: at stride 8 the cell sampled at (8 row, 8 column) of the input scores the mean+/// of its three colour values over 255 (so 1 on white, 0 on black) and boxes a 16 x 16 input-pixel square around the+/// middle of its cell; the other strides find nothing. A white patch in a black photo is thus found where it is.+pub fn bright_patch_detector_model() -> Vec<u8> {+    detector_model(false, true)+}++/// `fixed`: literal Reshape targets for a 64 x 64 input (else `-1` for the cell count). `patch`: the stride 8+/// outputs respond to bright pixels as described at [`bright_patch_detector_model`] (else every weight and bias is zero).+fn detector_model(fixed: bool, patch: bool) -> Vec<u8> {     let mut graph = Vec::new();     len_field(11, &value_info_bytes("input", 1, &[Ok(1), Ok(3), Ok(64), Ok(64)]), &mut graph);     for stride in [8u64, 16, 32] {         let cells = (64 / stride) * (64 / stride);         for (kind, components) in [("cls", 1u64), ("obj", 1), ("bbox", 4), ("kps", 10)] {             let name = format!("{kind}_{stride}");-            let weight = format!("weight_{name}");-            let values = vec![0.0; (cells * components) as usize];-            len_field(5, &float_tensor_bytes(&weight, &[1, cells, components], &values), &mut graph);-            len_field(1, &node_bytes("Identity", &[&weight], &name, &[]), &mut graph);+            let (weight, bias) = (format!("weight_{name}"), format!("bias_{name}"));+            let (shape, conv, nhwc) = (format!("shape_{name}"), format!("conv_{name}"), format!("nhwc_{name}"));+            let responds = patch && stride == 8;+            // cls and obj: the mean of the three colour planes over 255; bbox: centre of the cell, 16 px wide and high+            let mut weights = vec![0.0; (components * 3) as usize];+            let mut biases = vec![0.0; components as usize];+            if responds && (kind == "cls" || kind == "obj") {+                weights = vec![1.0 / (3.0 * 255.0); 3];+            }+            if responds && kind == "bbox" {+                biases = vec![0.5, 0.5, 2.0f32.ln(), 2.0f32.ln()];+            }+            len_field(5, &float_tensor_bytes(&weight, &[components, 3, 1, 1], &weights), &mut graph);+            len_field(5, &float_tensor_bytes(&bias, &[components], &biases), &mut graph);+            let target = if fixed { vec![1, cells as i64, components as i64] } else { vec![1, -1, components as i64] };+            len_field(5, &int_tensor_bytes(&shape, &target), &mut graph);+            let s = stride as i64;+            let attrs = [("kernel_shape", Attribute::Ints(&[1, 1])), ("strides", Attribute::Ints(&[s, s]))];+            len_field(1, &node_bytes("Conv", &["input", &weight, &bias], &conv, &attrs), &mut graph);+            len_field(1, &node_bytes("Transpose", &[&conv], &nhwc, &[("perm", Attribute::Ints(&[0, 2, 3, 1]))]), &mut graph);+            len_field(1, &node_bytes("Reshape", &[&nhwc, &shape], &name, &[]), &mut graph);             len_field(12, &value_info_bytes(&name, 1, &[Ok(1), Ok(cells), Ok(components)]), &mut graph);         }     }diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs--- a/crates/faces/src/yunet.rs+++ b/crates/faces/src/yunet.rs@@ -1,10 +1,15 @@ //! The YuNet face detector (232 KB, MIT, downloaded by the user in Settings ▸ Faces), run by LightCraft's own small //! interpreter ([`crate::net`]): no extra runtime. //!-//! A photo is letterboxed (aspect ratio kept, padded at the right and bottom) into the network's square-//! input, the network gives a score, a box and five landmarks for every cell of three grids (strides 8, 16 and-//! 32), and the cells above the score threshold are decoded, de-duplicated by non-maximum suppression and-//! mapped back to the photo as fractions of its width and height.+//! A photo is shrunk (never enlarged) until its long edge fits [`Options::long_edge`], padded at the right and bottom+//! to a multiple of 32 (the network is fully convolutional, so the input needn't be its file's 640 × 640 square: this+//! is how OpenCV's `FaceDetectorYN` and the published WIDER FACE figures run it). A model is trusted to do that only+//! after it has run at non-square inputs at load time ([`Detector::new`]); one that has not (a shape-fixed operator+//! in a retrained model) is run in its declared square instead, the photo fitted into it, and says so in+//! [`Detector::input_mode`]. The network gives a score, a box+//! and five landmarks for every cell of three grids (strides 8, 16 and 32), and the cells above the score threshold+//! are decoded, de-duplicated by non-maximum suppression and mapped back to the photo as fractions of its width and+//! height. //! //! The pixels come from the caller as plain 8-bit RGB; this module has no image types of its own. @@ -14,8 +19,21 @@ use crate::net::{Net, NetError, Tensor}; const STRIDES: [usize; 3] = [8, 16, 32]; /// Largest photo side we accept. const MAX_SIDE: usize = 65_536;-/// Candidate cells kept before suppression, so a hostile or noisy output cannot make it slow.-const MAX_CANDIDATES: usize = 4000;+/// Candidate cells kept before suppression, so a hostile or noisy output cannot make it slow. 5000 is OpenCV's own+/// `top_k` default ("keep top K bboxes before NMS") in `FaceDetectorYN::create` and in the opencv_zoo YuNet demo+/// (`--top_k`, default 5000), so the decoder keeps what the reference keeps.+const MAX_CANDIDATES: usize = 5000;+/// The non-square shapes (height, width) a model must run at, besides its declared square, to be fed photos in their+/// own shape: checked by shape inference at a size scans reach, and by running at small ones.+const PROBE_SHAPES: [(usize, usize); 5] = [(704, 1024), (1024, 704), (32, 1024), (1024, 32), (32, 32)];+const PROBE_RUNS: [(usize, usize); 4] = [(96, 160), (160, 96), (32, 32), (32, 192)];+/// Largest network input side: memory for the stride-8 feature maps grows with its square.+pub const MAX_INPUT: usize = 4096;+/// The long edge a scan gives the network. YuNet finds faces down to about 10 px, so the input size decides how small+/// a face it can find: squeezed into its file's 640 × 640 square it missed the back rows of group photos (WIDER FACE+/// hard 59.8 against the published 75.0, which OpenCV measures at each photo's own size, 1024 px wide); at 1024 px it+/// reaches the published figures (hard 76.6) for 2 to 2.5 times the time on a CPU (measured, docs/faces.md).+pub const SCAN_LONG_EDGE: usize = 1024;  #[derive(Debug, thiserror::Error)] pub enum DetectError {@@ -39,11 +57,13 @@ pub struct Options {     pub nms_iou: f32,     /// Most faces returned.     pub max_faces: usize,+    /// The network input's longest side: a larger photo is shrunk to fit, a smaller one is looked at as it is.+    pub long_edge: usize, }  impl Default for Options {     fn default() -> Self {-        Options { score: 0.6, nms_iou: 0.3, max_faces: 200 }+        Options { score: 0.6, nms_iou: 0.3, max_faces: 200, long_edge: SCAN_LONG_EDGE }     } } @@ -70,8 +90,46 @@ pub fn probe_pixels(n: usize) -> Vec<f32> {  pub struct Detector {     net: Net,-    /// Side of the square network input.+    /// Side of the square network input the model declares.     side: usize,+    /// Why the model could not be run at non-square inputs; `None` when it can, and photos keep their shape.+    fixed_because: Option<String>,+}++/// Check the output sizes `shapes` (from shape inference, in `outputs` order) against an `h` × `w` input.+fn check_outputs(shapes: &[Vec<usize>], outputs: &[String], h: usize, w: usize) -> Result<(), DetectError> {+    for stride in STRIDES {+        for (kind, components) in [("cls", 1), ("obj", 1), ("bbox", 4), ("kps", 10)] {+            let name = format!("{kind}_{stride}");+            let index = outputs.iter().position(|n| n == &name).ok_or_else(|| DetectError::Model(format!("the model has no output `{name}`")))?;+            let count = shapes.get(index).and_then(|s| s.iter().try_fold(1usize, |n, d| n.checked_mul(*d)));+            if count != Some((h / stride) * (w / stride) * components) {+                return Err(DetectError::Model(format!("the output `{name}` has the wrong shape at {w} × {h}")));+            }+        }+    }+    Ok(())+}++/// Whether `net` gives YuNet's outputs for non-square inputs: by shape inference, then by running it.+fn probe_non_square(net: &Net, outputs: &[String]) -> Result<(), DetectError> {+    for (h, w) in PROBE_SHAPES {+        check_outputs(&net.validate_input(&[1, 3, h, w])?, outputs, h, w)?;+    }+    for (h, w) in PROBE_RUNS {+        let input = Tensor::new(vec![1, 3, h, w], probe_pixels(3 * h * w))?;+        let result = net.run(input)?;+        for stride in STRIDES {+            for (kind, components) in [("cls", 1), ("obj", 1), ("bbox", 4), ("kps", 10)] {+                let name = format!("{kind}_{stride}");+                let ok = result.iter().any(|(n, t)| *n == name && t.data.len() == (h / stride) * (w / stride) * components);+                if !ok {+                    return Err(DetectError::Model(format!("the output `{name}` has the wrong size at {w} × {h}")));+                }+            }+        }+    }+    Ok(()) }  impl Detector {@@ -82,20 +140,31 @@ impl Detector {         let net = Net::new(g)?;         let side = match net.input_shape() {             Some(&[1, 3, h, w]) if h == w && (64..=4096).contains(&h) && h % 32 == 0 => h,-            _ => return Err(DetectError::Model("the model's input is not a fixed square image [1, 3, N, N] with N a multiple of 32".into())),-        };-        let shapes = net.validate_input(&[1, 3, side, side])?;-        for stride in STRIDES {-            for (kind, components) in [("cls", 1), ("obj", 1), ("bbox", 4), ("kps", 10)] {-                let name = format!("{kind}_{stride}");-                let index = outputs.iter().position(|n| n == &name).ok_or_else(|| DetectError::Model(format!("the model has no output `{name}`")))?;-                let count = shapes.get(index).and_then(|s| s.iter().try_fold(1usize, |n, d| n.checked_mul(*d)));-                if count != Some((side / stride) * (side / stride) * components) {-                    return Err(DetectError::Model(format!("the output `{name}` has the wrong shape")));-                }+            other => {+                return Err(DetectError::Model(format!(+                    "the model declares the input {other:?}; it needs a square [1, 3, N, N] with N a multiple of 32 from 64 to 4096 (the side it is checked at, and run at if it cannot take other shapes)"+                )));             }+        };+        check_outputs(&net.validate_input(&[1, 3, side, side])?, &outputs, side, side)?;+        // A square that works says nothing about other shapes: a literal Reshape target or another shape-fixed operator+        // would fail every photo that is not square. Such a model is run in its declared square instead.+        let fixed_because = probe_non_square(&net, &outputs).err().map(|e| e.to_string());+        Ok(Detector { net, side, fixed_because })+    }++    /// How photos reach the network: in their own shape (the model ran at the non-square probes at load time), or+    /// fitted into the model's declared square (it did not, and why).+    pub fn input_mode(&self) -> String {+        match &self.fixed_because {+            None => "in the photo's own shape, up to 1024 px (the model also ran at non-square inputs at load)".into(),+            Some(why) => format!("fitted into the model's {0} × {0} square (it failed the non-square check at load: {why})", self.side),         }-        Ok(Detector { net, side })+    }++    /// Whether photos are looked at in their own shape (see [`Detector::input_mode`]).+    pub fn keeps_photo_shape(&self) -> bool {+        self.fixed_because.is_none()     }      /// The network, e.g. to see which device it runs on.@@ -112,11 +181,29 @@ impl Detector {         self.net.attach_checked(accelerator, &probe, ACCELERATOR_TOLERANCE, require_faster)     } -    /// The square input side, used to check an installed manifest.+    /// The square input side the model file declares, used to check an installed manifest.     pub fn input_side(&self) -> usize {         self.side     } +    /// [`Detector::input_size`] for this model: its own square when it cannot take other shapes.+    fn plan(&self, width: usize, height: usize, long_edge: usize) -> ((usize, usize), (usize, usize)) {+        if self.fixed_because.is_none() {+            return Self::input_size(width, height, long_edge);+        }+        let (fitted, _) = Self::input_size(width, height, long_edge.min(self.side));+        (fitted, (self.side, self.side))+    }++    /// The network input for a `width` × `height` photo with `long_edge`: the photo's size there, and the padded input.+    fn input_size(width: usize, height: usize, long_edge: usize) -> ((usize, usize), (usize, usize)) {+        let long = long_edge.clamp(32, MAX_INPUT);+        let scale = (long as f64 / width.max(height).max(1) as f64).min(1.0);+        let nw = ((width as f64 * scale).round() as usize).clamp(1, long);+        let nh = ((height as f64 * scale).round() as usize).clamp(1, long);+        ((nw, nh), (nw.div_ceil(32) * 32, nh.div_ceil(32) * 32))+    }+     /// Find faces in an upright `width` × `height` photo of 8-bit RGB pixels (`rgb` is `width * height * 3` bytes).     pub fn detect(&self, rgb: &[u8], width: usize, height: usize, opts: &Options) -> Result<Vec<Face>, DetectError> {         if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE {@@ -129,9 +216,12 @@ impl Detector {             score: if opts.score.is_finite() { opts.score.clamp(0.0, 1.0) } else { 0.6 },             nms_iou: if opts.nms_iou.is_finite() { opts.nms_iou.clamp(0.0, 1.0) } else { 0.3 },             max_faces: opts.max_faces.min(10_000),+            long_edge: opts.long_edge,         };-        let scale = (self.side as f32 / width as f32).min(self.side as f32 / height as f32);-        let input = self.letterbox(rgb, width, height, scale)?;+        let ((nw, nh), (iw, ih)) = self.plan(width, height, opts.long_edge);+        // input pixels per photo pixel, along each axis+        let (sx, sy) = (nw as f32 / width as f32, nh as f32 / height as f32);+        let input = letterbox(rgb, width, height, (nw, nh), (iw, ih))?;         let outputs = self.net.run(input)?;         let find = |name: String| {             outputs.iter().find(|(n, _)| *n == name).map(|(_, t)| t).ok_or_else(|| DetectError::Model(format!("the model has no output `{name}`")))@@ -140,8 +230,8 @@ impl Detector {         for stride in STRIDES {             let (cls, obj) = (find(format!("cls_{stride}"))?, find(format!("obj_{stride}"))?);             let (bbox, kps) = (find(format!("bbox_{stride}"))?, find(format!("kps_{stride}"))?);-            let cols = self.side / stride;-            let cells = cols * cols;+            let cols = iw / stride;+            let cells = cols * (ih / stride);             if cls.data.len() != cells || obj.data.len() != cells || bbox.data.len() != cells * 4 || kps.data.len() != cells * 10 {                 return Err(DetectError::Model(format!("the outputs for stride {stride} have the wrong size")));             }@@ -158,13 +248,13 @@ impl Detector {                 let mut landmarks = [(0.0, 0.0); 5];                 for (n, l) in landmarks.iter_mut().enumerate() {                     let (lx, ly) = (k.get(n * 2).copied().unwrap_or(0.0), k.get(n * 2 + 1).copied().unwrap_or(0.0));-                    *l = (((lx + col) * s) / scale / width as f32, ((ly + row) * s) / scale / height as f32);+                    *l = (((lx + col) * s) / sx / width as f32, ((ly + row) * s) / sy / height as f32);                 }                 candidates.push(Face {-                    x0: (cx - w / 2.0) / scale / width as f32,-                    y0: (cy - h / 2.0) / scale / height as f32,-                    x1: (cx + w / 2.0) / scale / width as f32,-                    y1: (cy + h / 2.0) / scale / height as f32,+                    x0: (cx - w / 2.0) / sx / width as f32,+                    y0: (cy - h / 2.0) / sy / height as f32,+                    x1: (cx + w / 2.0) / sx / width as f32,+                    y1: (cy + h / 2.0) / sy / height as f32,                     score,                     landmarks,                 });@@ -186,53 +276,51 @@ impl Detector {         }         Ok(kept)     }+} -    /// The photo scaled by `scale` into the top left of a zero-filled square, as BGR 0..255 (the model's own input).-    fn letterbox(&self, rgb: &[u8], width: usize, height: usize, scale: f32) -> Result<Tensor, DetectError> {-        let side = self.side;-        let nw = ((width as f32 * scale).round() as usize).clamp(1, side);-        let nh = ((height as f32 * scale).round() as usize).clamp(1, side);-        let (xt, yt) = (taps(width, nw), taps(height, nh));-        // horizontal pass: every source row, nw columns, three channels-        let mut mid = vec![0.0f32; height * nw * 3];-        for (src_row, mid_row) in rgb.chunks_exact(width * 3).zip(mid.chunks_exact_mut(nw * 3)) {-            for (x, tap) in xt.iter().enumerate() {-                let mut acc = [0.0f32; 3];-                for (j, wt) in tap.weights.iter().enumerate() {-                    if let Some(px) = src_row.get((tap.start + j) * 3..(tap.start + j) * 3 + 3) {-                        acc[0] += wt * f32::from(px[0]);-                        acc[1] += wt * f32::from(px[1]);-                        acc[2] += wt * f32::from(px[2]);-                    }-                }-                if let Some(o) = mid_row.get_mut(x * 3..x * 3 + 3) {-                    o.copy_from_slice(&acc);+/// The photo resized to `nw` × `nh` into the top left of a zero-filled `iw` × `ih` input, as BGR 0..255 (the model's+/// own input).+fn letterbox(rgb: &[u8], width: usize, height: usize, (nw, nh): (usize, usize), (iw, ih): (usize, usize)) -> Result<Tensor, DetectError> {+    let (xt, yt) = (taps(width, nw), taps(height, nh));+    // horizontal pass: every source row, nw columns, three channels+    let mut mid = vec![0.0f32; height * nw * 3];+    for (src_row, mid_row) in rgb.chunks_exact(width * 3).zip(mid.chunks_exact_mut(nw * 3)) {+        for (x, tap) in xt.iter().enumerate() {+            let mut acc = [0.0f32; 3];+            for (j, wt) in tap.weights.iter().enumerate() {+                if let Some(px) = src_row.get((tap.start + j) * 3..(tap.start + j) * 3 + 3) {+                    acc[0] += wt * f32::from(px[0]);+                    acc[1] += wt * f32::from(px[1]);+                    acc[2] += wt * f32::from(px[2]);                 }             }+            if let Some(o) = mid_row.get_mut(x * 3..x * 3 + 3) {+                o.copy_from_slice(&acc);+            }         }-        // vertical pass into the planes B, G, R-        let plane = side * side;-        let mut data = vec![0.0f32; 3 * plane];-        for (y, tap) in yt.iter().enumerate() {-            for x in 0..nw {-                let mut acc = [0.0f32; 3];-                for (j, wt) in tap.weights.iter().enumerate() {-                    if let Some(px) = mid.get(((tap.start + j) * nw + x) * 3..((tap.start + j) * nw + x) * 3 + 3) {-                        acc[0] += wt * px[0];-                        acc[1] += wt * px[1];-                        acc[2] += wt * px[2];-                    }+    }+    // vertical pass into the planes B, G, R+    let plane = iw * ih;+    let mut data = vec![0.0f32; 3 * plane];+    for (y, tap) in yt.iter().enumerate() {+        for x in 0..nw {+            let mut acc = [0.0f32; 3];+            for (j, wt) in tap.weights.iter().enumerate() {+                if let Some(px) = mid.get(((tap.start + j) * nw + x) * 3..((tap.start + j) * nw + x) * 3 + 3) {+                    acc[0] += wt * px[0];+                    acc[1] += wt * px[1];+                    acc[2] += wt * px[2];                 }-                let at = y * side + x;-                for (c, v) in [acc[2], acc[1], acc[0]].into_iter().enumerate() {-                    if let Some(o) = data.get_mut(c * plane + at) {-                        *o = v;-                    }+            }+            let at = y * iw + x;+            for (c, v) in [acc[2], acc[1], acc[0]].into_iter().enumerate() {+                if let Some(o) = data.get_mut(c * plane + at) {+                    *o = v;                 }             }         }-        Ok(Tensor::new(vec![1, 3, side, side], data)?)     }+    Ok(Tensor::new(vec![1, 3, ih, iw], data)?) }  struct Tap {@@ -295,6 +383,7 @@ mod tests {     fn the_model_loads_and_has_a_square_input() {         let Some(d) = detector() else { return };         assert_eq!(d.side, 640);+        assert!(d.keeps_photo_shape(), "{}", d.input_mode());     }      #[test]@@ -315,7 +404,7 @@ mod tests {         assert!(d.detect(&[0; 12], 2, 3, &o).is_err(), "wrong byte count");         assert!(d.detect(&[0; 3], usize::MAX, 2, &o).is_err(), "overflowing size");         assert!(d.detect(&[0; 3], MAX_SIDE + 1, 1, &o).is_err());-        let nan = Options { score: f32::NAN, nms_iou: f32::INFINITY, max_faces: usize::MAX };+        let nan = Options { score: f32::NAN, nms_iou: f32::INFINITY, max_faces: usize::MAX, long_edge: usize::MAX };         assert!(d.detect(&vec![0u8; 12 * 12 * 3], 12, 12, &nan).is_ok(), "non-finite options fall back to defaults");     } @@ -347,7 +436,10 @@ mod tests {     }      /// Opt-in: `LC_YUNET_REF=<folder> cargo test -p lightcraft-faces -- --ignored --nocapture` compares every output-    /// of the network with ONNX Runtime's on the same input (`input.bin`, `out_<name>.bin`, raw little-endian f32).+    /// of the network with ONNX Runtime's on the same input at five shapes: the declared square (`input.bin`,+    /// `out_<name>.bin`), a non-square 704 high by 1024 wide input (`input_704x1024.bin`, `out_<name>_704x1024.bin`)+    /// and the extremes a scan can feed it: 32 × 1024, 1024 × 32 and 32 × 32 (same naming, height first), all raw+    /// little-endian f32. `tools/yunet_reference.py` writes them.     #[test]     #[ignore = "needs reference files: set LC_YUNET_REF"]     fn the_network_matches_onnx_runtime() {@@ -356,16 +448,107 @@ mod tests {         let floats =             |name: &str| -> Vec<f32> { std::fs::read(dir.join(name)).unwrap().as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() };         let Some(d) = detector() else { return };-        let started = std::time::Instant::now();-        let outs = d.net.run(Tensor::new(vec![1, 3, 640, 640], floats("input.bin")).unwrap()).unwrap();-        println!("network run: {:?}", started.elapsed());-        assert_eq!(outs.len(), 12);-        for (name, t) in outs {-            let want = floats(&format!("out_{name}.bin"));-            assert_eq!(t.data.len(), want.len(), "{name}");-            let worst = t.data.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);-            println!("{name}: {} values, worst difference {worst:e}", want.len());-            assert!(worst < 2e-3, "{name} differs by {worst}");+        assert!(d.keeps_photo_shape(), "the real YuNet must pass the non-square check at load: {}", d.input_mode());+        for (suffix, h, w) in [("", 640, 640), ("_704x1024", 704, 1024), ("_32x1024", 32, 1024), ("_1024x32", 1024, 32), ("_32x32", 32, 32)] {+            let started = std::time::Instant::now();+            let outs = d.net.run(Tensor::new(vec![1, 3, h, w], floats(&format!("input{suffix}.bin"))).unwrap()).unwrap();+            println!("network run at {w} × {h}: {:?}", started.elapsed());+            assert_eq!(outs.len(), 12);+            for (name, t) in outs {+                let want = floats(&format!("out_{name}{suffix}.bin"));+                assert_eq!(t.data.len(), want.len(), "{name} at {w} × {h}");+                let worst = t.data.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);+                println!("{name} at {w} × {h}: {} values, worst difference {worst:e}", want.len());+                assert!(worst < 2e-3, "{name} at {w} × {h} differs by {worst}");+            }+        }+    }++    #[test]+    fn the_input_keeps_the_photo_shape_and_never_enlarges_it() {+        // a 24 MP photo shrinks to the long edge, padded to a multiple of 32+        assert_eq!(Detector::input_size(6000, 4000, 1280), ((1280, 853), (1280, 864)));+        // a WIDER FACE photo smaller than the long edge is looked at at its own size (the published protocol)+        assert_eq!(Detector::input_size(1024, 683, 1280), ((1024, 683), (1024, 704)));+        assert_eq!(Detector::input_size(683, 1024, 640), ((427, 640), (448, 640)));+        // tiny pictures and silly long edges stay inside the network's limits+        assert_eq!(Detector::input_size(1, 1, 1280), ((1, 1), (32, 32)));+        assert_eq!(Detector::input_size(100, 100, 0), ((32, 32), (32, 32)));+        assert_eq!(Detector::input_size(65_536, 10, usize::MAX), ((MAX_INPUT, 1), (MAX_INPUT, 32)));+        // a scan looks at 1024 px+        assert_eq!(Options::default().long_edge, 1024);+    }++    #[test]+    fn every_input_size_runs_and_a_blank_photo_stays_blank() {+        let d = Detector::new(&crate::synthetic::tiny_detector_model()).unwrap();+        for (w, h, long) in [(900, 600, 320), (900, 600, 640), (900, 600, 1280), (600, 900, 1024), (37, 41, 1024)] {+            let o = Options { long_edge: long, ..Options::default() };+            assert!(d.detect(&vec![128u8; w * h * 3], w, h, &o).unwrap().is_empty(), "{w}×{h} at {long}");+        }+    }++    /// Face boxes of `model` on a black `w` × `h` photo with a white patch `x0..x1` × `y0..y1` (photo pixels).+    fn faces_on_patch(model: &[u8], (w, h): (usize, usize), (x0, y0, x1, y1): (usize, usize, usize, usize)) -> Vec<Face> {+        let mut rgb = vec![0u8; w * h * 3];+        for y in y0..y1 {+            rgb[(y * w + x0) * 3..(y * w + x1) * 3].fill(255);+        }+        Detector::new(model).unwrap().detect(&rgb, w, h, &Options::default()).unwrap()+    }++    #[test]+    fn a_box_lands_on_the_bright_patch_in_a_non_square_photo() {+        // a 1500 × 700 photo (3:2 and wider): the patch is at x 1100..1140, y 390..430 of it: a different column than row index, so a swap shows+        let patch = (1100, 390, 1140, 430);+        let faces = faces_on_patch(&crate::synthetic::bright_patch_detector_model(), (1500, 700), patch);+        assert!(!faces.is_empty(), "the patch was not found");+        for f in &faces {+            // the cell sampled inside the patch, boxed 16 input pixels (about 23 photo pixels) wide and high+            let (cx, cy) = ((f.x0 + f.x1) / 2.0 * 1500.0, (f.y0 + f.y1) / 2.0 * 700.0);+            assert!((1088.0..1152.0).contains(&cx) && (378.0..442.0).contains(&cy), "box centre ({cx}, {cy}) is off the patch: {f:?}");+            let (bw, bh) = ((f.x1 - f.x0) * 1500.0, (f.y1 - f.y0) * 700.0);+            assert!((bw - 23.4).abs() < 1.5 && (bh - 23.4).abs() < 1.5, "box {bw} × {bh} px, expected about 23.4 × 23.4: {f:?}");+            let (lx, ly) = (f.landmarks[0].0 * 1500.0, f.landmarks[0].1 * 700.0);+            assert!((1088.0..1152.0).contains(&lx) && (378.0..442.0).contains(&ly), "landmark ({lx}, {ly}) is off the patch");+        }+        // the same patch in a tall photo (the transpose) is found at the transposed place+        let faces = faces_on_patch(&crate::synthetic::bright_patch_detector_model(), (700, 1500), (390, 1100, 430, 1140));+        assert!(!faces.is_empty());+        for f in &faces {+            let (cx, cy) = ((f.x0 + f.x1) / 2.0 * 700.0, (f.y0 + f.y1) / 2.0 * 1500.0);+            assert!((378.0..442.0).contains(&cx) && (1088.0..1152.0).contains(&cy), "tall photo: box centre ({cx}, {cy}) is off the patch");+        }+    }++    #[test]+    fn the_two_axes_scale_by_their_own_factor_on_a_thin_photo() {+        // 4000 × 40 is shrunk to 1024 × 10: horizontally by 0.256, vertically by 0.25, so a mixed-up pair of+        // factors moves a box by about 2.4% of its distance from the left, 48 px here+        let faces = faces_on_patch(&crate::synthetic::bright_patch_detector_model(), (4000, 40), (1900, 0, 1960, 40));+        assert!(!faces.is_empty());+        for f in &faces {+            let cx = (f.x0 + f.x1) / 2.0 * 4000.0;+            assert!((1900.0..1964.0).contains(&cx), "box centre x {cx} is off the patch (1900..1960): {f:?}");+        }+    }++    #[test]+    fn a_model_that_fails_at_other_shapes_is_run_in_its_declared_square() {+        let flexible = Detector::new(&crate::synthetic::tiny_detector_model()).unwrap();+        assert!(flexible.keeps_photo_shape(), "{}", flexible.input_mode());+        assert!(flexible.input_mode().contains("own shape"));+        // the literal Reshape targets fit 64 × 64 and nothing else+        let fixed = Detector::new(&crate::synthetic::fixed_shape_detector_model()).unwrap();+        assert!(!fixed.keeps_photo_shape());+        assert!(fixed.input_mode().contains("fitted into the model's 64 × 64 square"), "{}", fixed.input_mode());+        assert_eq!(fixed.input_side(), 64);+        // so every photo is fitted into 64 × 64 (never the 1024 a flexible model would take) and searching still works+        assert_eq!(fixed.plan(1500, 700, 1024), ((64, 30), (64, 64)));+        assert_eq!(fixed.plan(30, 700, 1024), ((3, 64), (64, 64)));+        assert_eq!(flexible.plan(1500, 700, 1024), ((1024, 478), (1024, 480)));+        for (w, h) in [(1500, 700), (700, 1500), (64, 64), (5, 9)] {+            assert!(fixed.detect(&vec![0u8; w * h * 3], w, h, &Options::default()).unwrap().is_empty(), "{w} × {h}");         }     } diff --git a/docs/faces.md b/docs/faces.md--- a/docs/faces.md+++ b/docs/faces.md@@ -230,7 +230,23 @@ a ResNet-100 (so it is not the accuracy choice its size suggests). InsightFace's unnamed face boxes, in one undo step. A new run replaces earlier detections; boxes that came from XMP, or that you drew or named, are never touched (and a face that already has one is not boxed a second time), and no sidecar is written. It looks at the photo upright and uncropped with default settings, so your edits and crops do not matter. `apply: false` only reports. It finds faces of about 10 pixels and up in a-640-pixel version of the photo (so very small faces in a large group photo can be missed; looking at tiles is planned).+1024-pixel version of the photo, kept in its own shape (YuNet is fully convolutional, so it needn't squeeze the photo+into its file's 640 x 640 square; a photo smaller than 1024 px is looked at as it is). That is how OpenCV runs it for+its published WIDER FACE figures, whose photos are 1024 px wide. On the 3,226 photos of WIDER FACE's validation set,+scored with the official protocol, the average precision (AP) easy / medium / hard is 88.9 / 87.2 / 76.6 against the+published 88.4 / 86.6 / 75.0; the former 640 x 640 square gave 87.8 / 83.7 / 59.8 and missed the back rows of group+photos. Faces still smaller than about 10 pixels at that size can be missed.+It costs time and memory. Measured on an Intel Core i9-13950HX (32 threads, CPU only, release build, other jobs+running), over 200 WIDER FACE validation photos chosen with seed 3092 (0.91 megapixels on average), at score 0.5: the+detector takes 46 to 57 ms a photo at 640 x 640 and 108 to 109 ms at 1024 px (two runs each), so 2 to 2.5 times as+long, and it finds 31% more faces (1,571 against 1,201). Its peak working memory beyond the photo grows from 9 MB to+22 MB for a 1024 x 683 photo and from 14 MB to 24 MB for a 2048 x 1365 one (peak working set, 100 runs of one+synthetic photo; the 232 KB model's weights included). The background scan looks at the 1024 px size; `spot.findBlemishes`+(faces under 160 px of a 1600 px photo get no spots) and the model self-test keep what they always used, 640 px and the+model's declared square. When the model is loaded it is also run at non-square inputs (shape inference at 1024 x 704 and+704 x 1024 and at the extremes 1024 x 32, 32 x 1024 and 32 x 32, runs at 160 x 96, 96 x 160, 32 x 32 and 192 x 32); a model that fails that is run in its declared square with the photo fitted+into it, and `faces.detect` and the model self-test say so (`input`). The real YuNet's outputs at 640 x 640, at+704 x 1024 and at the extremes a scan can feed it (a 1024 x 32 strip, a 32 x 1024 column, the smallest input 32 x 32) match ONNX Runtime's to within 7e-6 (`tools/yunet_reference.py`, test `the_network_matches_onnx_runtime`). The detector's output matches OpenCV's own YuNet on a 45-photo public-domain test set (97 of 99 faces found by both, mean box overlap 0.97), including marble busts and paintings, with no false boxes on the landscape and architecture photos in the set.diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md--- a/docs/parity-checklist.md+++ b/docs/parity-checklist.md@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-LIB-PEOPLE: Detect Faces' time stated as measured, about 110 ms a photo at 1024 px (was 200 ms, unmeasured); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6  The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good@@ -104,7 +104,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) |-| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back |+| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 110 ms a photo on a 32-thread i9-13950HX CPU, 2 to 2.5 times the time of the former 640 x 640 look, see docs/faces.md; matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back; Detect Faces runs YuNet at the photo's own shape up to 1024 px (WIDER FACE val hard AP 76.6, was 59.8 at 640 x 640) | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges (a click expands / collapses the stack, selected or not), expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) |@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | patch | LR-LIB-PEOPLE: Detect Faces' cost stated as measured on an i9-13950HX CPU (200 WIDER FACE photos): about 110 ms a photo at 1024 px, 2 to 2.5 times the 640 x 640 time, replacing the unmeasured "about 200 ms"; a model that fails a non-square check at load is run in its declared square; status unchanged (🟡) | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |diff --git a/tools/yunet_reference.py b/tools/yunet_reference.pynew file mode 100644--- /dev/null+++ b/tools/yunet_reference.py@@ -0,0 +1,70 @@+# Writes ONNX Runtime reference tensors for the YuNet interpreter test+# (crates/faces/src/yunet.rs, `the_network_matches_onnx_runtime`, read from LC_YUNET_REF).+# /// script+# requires-python = ">=3.10"+# dependencies = ["onnxruntime", "numpy", "onnx"]+# ///+"""Runs YuNet (face_detection_yunet_2023mar.onnx) with onnxruntime (CPU, fp32) on a seeded, photo-like input at five+shapes: the declared 640 x 640 square (input.bin, out_<name>.bin) and, as input_<H>x<W>.bin / out_<name>_<H>x<W>.bin+(H high by W wide), a non-square 704x1024 plus the extremes a 1024 px scan can feed it: a thin strip 32x1024, a thin+column 1024x32 and the smallest input 32x32, all raw little-endian float32, NCHW, BGR 0..255 as the model takes+them. A real model run at these shapes is what shows that the interpreter handles non-square and extreme inputs. The file declares+its input as 640 x 640, which onnxruntime enforces, so for the other shape a copy is made with the declared input+(and the shapes it infers for the outputs) changed; if the graph had a shape-fixed operator onnxruntime would then+refuse to run it, which is the check.++Usage: uv run tools/yunet_reference.py <face_detection_yunet_2023mar.onnx> <output folder>+then: LC_YUNET_MODEL=<the same onnx> LC_YUNET_REF=<output folder> cargo test -p lightcraft-faces --lib -- --ignored --nocapture the_network_matches_onnx_runtime+"""+import sys+from pathlib import Path++import numpy as np+import onnx+import onnxruntime as ort++SEED = 3092+SHAPES = (("", (640, 640)), ("_704x1024", (704, 1024)), ("_32x1024", (32, 1024)), ("_1024x32", (1024, 32)), ("_32x32", (32, 32)))+++def photo_like(rng, h, w):+    """Smooth gradients plus texture, 0..255, shape [1, 3, h, w]."""+    y, x = np.mgrid[0:h, 0:w].astype(np.float32)+    planes = []+    for c in range(3):+        base = 127 + 100 * np.sin(x / (40 + 17 * c) + c) * np.cos(y / (55 - 9 * c))+        planes.append(np.clip(base + rng.normal(0, 12, (h, w)), 0, 255))+    return np.stack(planes)[None].astype("<f4")+++def reshaped(model_path, h, w):+    """The model with its input declared as [1, 3, h, w] and no stale output shapes, as bytes."""+    m = onnx.load(model_path)+    dims = m.graph.input[0].type.tensor_type.shape.dim+    dims[2].dim_value, dims[3].dim_value = h, w+    del m.graph.value_info[:]+    for o in m.graph.output:+        for d in o.type.tensor_type.shape.dim:+            d.Clear()+    return m.SerializeToString()+++def main():+    if len(sys.argv) != 3:+        sys.exit("usage: uv run tools/yunet_reference.py <face_detection_yunet_2023mar.onnx> <output folder>")+    model, out = sys.argv[1], Path(sys.argv[2])+    out.mkdir(parents=True, exist_ok=True)+    rng = np.random.default_rng(SEED)+    for suffix, (h, w) in SHAPES:+        session = ort.InferenceSession(model if (h, w) == (640, 640) else reshaped(model, h, w), providers=["CPUExecutionProvider"])+        name = session.get_inputs()[0].name+        x = photo_like(rng, h, w)+        x.tofile(out / f"input{suffix}.bin")+        outputs = session.run(None, {name: x})+        for meta, value in zip(session.get_outputs(), outputs):+            np.ascontiguousarray(value, dtype="<f4").tofile(out / f"out_{meta.name}{suffix}.bin")+        print(f"{w} x {h}: {len(outputs)} outputs", [list(o.shape) for o in outputs][:3], "...")+++if __name__ == "__main__":+    main()