Skip to main content

pdf_engine/
layout.rs

1//! Layout detection on the shared ONNX Runtime layer (#641).
2//!
3//! Behind the `ort-layout` cargo feature (off by default). Turns one PDF page
4//! into the layout detector's boxes, exactly as the Python reference does
5//! (Heron-ft ep03, RT-DETRv2, HF transformers 4.57.6 post-processing):
6//!
7//! * [`LAYOUT_EP03`] is the model, loaded through
8//!   [`pdf_ocr::runtime::session`] only.
9//! * [`render_page_rgb`] renders the page at 96 dpi, RGB on white, through the
10//!   same calls as the `render_page_png` example. No new renderer.
11//! * [`preprocess_to_nchw`] resizes to 640x640 non-uniform with PIL BILINEAR
12//!   (triangle support widened on downscale, as PIL does), rescales by 1/255
13//!   with no mean/std normalisation, and lays out NCHW `f32` 1x3x640x640 --
14//!   the flags in the model's `preprocessor_config.json`
15//!   (`RTDetrImageProcessor`, `do_resize`, `do_rescale`, no `do_normalize`,
16//!   no `do_pad`).
17//! * [`layout_boxes`] runs the graph (input/output names read from the ONNX
18//!   session metadata, as exported: `pixel_values` in, `logits` and
19//!   `pred_boxes` out) and post-processes exactly as HF
20//!   `post_process_object_detection` for RT-DETR: sigmoid over the logits,
21//!   top-300 over the flattened scores, `label = index % 17`,
22//!   `query = index / 17`, boxes from normalised cxcywh to xyxy scaled to the
23//!   render size, keeping `score >= 0.05`. Label names are the model's
24//!   `config.json` `id2label`.
25//!
26//! Model failures come back as [`EngineError::Io`](crate::EngineError)
27//! carrying the typed [`pdf_ocr::runtime::ModelError`]
28//! -- no new `EngineError` variants. Callers match that variant and find the
29//! cause with
30//! [`pdf_ocr::runtime::model_error`].
31
32// Copyright (c) 2026 Innovation Trigger B.V.
33//
34// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
35// the PDFluent Commercial Licence. See the LICENSE file in this repository --
36// that file travels with the copy you received, which a URL does not.
37
38use pdf_ocr::runtime::session;
39
40pub use pdf_ocr::runtime::RuntimeConfig;
41
42use crate::{EngineError, PdfDocument, PixelFormat, RenderOptions, Result};
43
44/// The layout model: Heron-ft ep03 (RT-DETRv2), ONNX export. Defined in
45/// [`crate::model`] so builds without the tier name the same spec; re-exported
46/// here so the session path keeps its name.
47pub use crate::model::LAYOUT_EP03;
48
49/// Detector input width in pixels (HF `size.width`).
50const INPUT_WIDTH: u32 = 640;
51
52/// Detector input height in pixels (HF `size.height`).
53const INPUT_HEIGHT: u32 = 640;
54
55/// Render resolution in dots per inch, the `render_page_png` default.
56const RENDER_DPI: f64 = 96.0;
57
58/// Score threshold: HF `post_process_object_detection(threshold=0.05)`.
59///
60/// HF filters with `score > threshold` and this keeps `score >= 0.05`; the
61/// two differ only for a score landing exactly on the boundary.
62const SCORE_THRESHOLD: f32 = 0.05;
63
64/// Queries kept: HF `num_top_queries`, the top-k over the flattened scores.
65const TOP_K: usize = 300;
66
67/// Label names by class id: the `id2label` of the model's `config.json`
68/// (Heron-ft ep03, transformers 4.57.6). The post-processing maps
69/// `label = index % 17` through this table.
70const LABELS: [&str; 17] = [
71    "caption",
72    "footnote",
73    "formula",
74    "list_item",
75    "page_footer",
76    "page_header",
77    "picture",
78    "section_header",
79    "table",
80    "text",
81    "title",
82    "document_index",
83    "code",
84    "checkbox_selected",
85    "checkbox_unselected",
86    "form",
87    "key_value_region",
88];
89
90/// One layout detection: what the region is, where it is on the render.
91#[derive(Debug, Clone)]
92pub struct LayoutBox {
93    /// Label name from the model's `id2label` (e.g. `"text"`, `"table"`).
94    pub label: String,
95    /// Sigmoid score of the winning (query, class) pair, in 0.05..=1.0.
96    pub score: f32,
97    /// Box corners `[x1, y1, x2, y2]` in render pixels at 96 dpi.
98    pub bbox_px: [f32; 4],
99    /// Render width in pixels the box is addressed against.
100    pub render_w: u32,
101    /// Render height in pixels the box is addressed against.
102    pub render_h: u32,
103}
104
105/// An 8-bit RGB page render at 96 dpi, row-major, three bytes per pixel.
106#[derive(Debug, Clone)]
107pub struct RgbPage {
108    /// Interleaved RGB bytes, row-major.
109    pub data: Vec<u8>,
110    /// Width in pixels.
111    pub width: u32,
112    /// Height in pixels.
113    pub height: u32,
114}
115
116/// Render one page at 96 dpi, RGB on white.
117///
118/// Exactly the path of the `render_page_png` example: the same
119/// [`RenderOptions`] (96 dpi, opaque white background), the same
120/// [`PdfDocument::render_page`] call, and the same compositing of the
121/// premultiplied RGBA over white. Page `page_index` counts from zero.
122pub fn render_page_rgb(doc: &PdfDocument, page_index: usize) -> Result<RgbPage> {
123    let opts = RenderOptions {
124        dpi: RENDER_DPI,
125        background: [1.0, 1.0, 1.0, 1.0],
126        ..Default::default()
127    };
128    let rendered = doc.render_page(page_index, &opts)?;
129    if rendered.pixel_format != PixelFormat::Rgba8 {
130        return Err(EngineError::RenderError(
131            "layout render: expected RGBA pixels".to_string(),
132        ));
133    }
134    // Premultiplied RGBA composited over the white background: with an opaque
135    // page this is the channels as they stand; anywhere the alpha is short
136    // the white shows through by exactly the missing share.
137    let mut rgb = Vec::with_capacity(rendered.pixels.len() / 4 * 3);
138    for px in rendered.pixels.chunks_exact(4) {
139        let missing = 255 - px[3];
140        rgb.push(px[0].saturating_add(missing));
141        rgb.push(px[1].saturating_add(missing));
142        rgb.push(px[2].saturating_add(missing));
143    }
144    Ok(RgbPage {
145        data: rgb,
146        width: rendered.width,
147        height: rendered.height,
148    })
149}
150
151/// Preprocess a render into the detector's NCHW `f32` input (1x3x640x640).
152///
153/// Resize to 640x640 non-uniform with PIL BILINEAR (triangle support widened
154/// on downscale, as PIL does), then rescale by 1/255 with no mean/std
155/// normalisation, laid out channel-planar.
156pub fn preprocess_to_nchw(page: &RgbPage) -> Vec<f32> {
157    let resized = resize_rgb_bilinear_pil(
158        &page.data,
159        page.width,
160        page.height,
161        INPUT_WIDTH,
162        INPUT_HEIGHT,
163    );
164    let plane = INPUT_WIDTH as usize * INPUT_HEIGHT as usize;
165    let mut nchw = vec![0.0f32; 3 * plane];
166    for (i, px) in resized.chunks_exact(3).enumerate() {
167        nchw[i] = f32::from(px[0]) / 255.0;
168        nchw[plane + i] = f32::from(px[1]) / 255.0;
169        nchw[2 * plane + i] = f32::from(px[2]) / 255.0;
170    }
171    nchw
172}
173
174/// The layout detector's boxes for one page.
175///
176/// Renders ([`render_page_rgb`]), preprocesses ([`preprocess_to_nchw`]), runs
177/// [`LAYOUT_EP03`] through the shared session, and post-processes as HF does.
178/// Page `page_index` counts from zero. Boxes come back in descending score
179/// order; an empty page reads back as an empty vector, not an error.
180///
181/// Model failures (missing file, checksum, runtime, inference) come back as
182/// [`EngineError::Io`](crate::EngineError) carrying the typed
183/// [`pdf_ocr::runtime::ModelError`].
184pub fn layout_boxes(
185    doc: &PdfDocument,
186    page_index: usize,
187    config: &RuntimeConfig,
188) -> Result<Vec<LayoutBox>> {
189    let page = render_page_rgb(doc, page_index)?;
190    let nchw = preprocess_to_nchw(&page);
191    let shared = session(&LAYOUT_EP03, config).map_err(model_io_error)?;
192    let (logits, boxes_cxcywh) = shared
193        .run(|ort_session| {
194            // Names from the graph, not from the export script: the input key
195            // is whatever the single graph input is called, and the outputs
196            // are selected by name so their order cannot matter.
197            let input_name = {
198                let graph_inputs = ort_session.inputs();
199                if graph_inputs.len() != 1 {
200                    return Err(ort::Error::new(format!(
201                        "layout model has {} inputs, want the single pixel_values input",
202                        graph_inputs.len()
203                    )));
204                }
205                graph_inputs[0].name().to_string()
206            };
207            {
208                let graph_outputs = ort_session.outputs();
209                for want in ["logits", "pred_boxes"] {
210                    if !graph_outputs.iter().any(|o| o.name() == want) {
211                        return Err(ort::Error::new(format!(
212                            "layout model has no {want} output"
213                        )));
214                    }
215                }
216            }
217            let input = ort::value::Tensor::from_array((
218                [1usize, 3, INPUT_HEIGHT as usize, INPUT_WIDTH as usize],
219                nchw,
220            ))?;
221            let outputs = ort_session.run(ort::inputs![input_name => input])?;
222            let logits = outputs
223                .get("logits")
224                .ok_or_else(|| ort::Error::new("layout model returned no logits output"))?;
225            let pred_boxes = outputs
226                .get("pred_boxes")
227                .ok_or_else(|| ort::Error::new("layout model returned no pred_boxes output"))?;
228            let (logits_shape, logits_data) = logits.try_extract_tensor::<f32>()?;
229            let (boxes_shape, boxes_data) = pred_boxes.try_extract_tensor::<f32>()?;
230            if logits_shape.len() != 3
231                || logits_shape[0] != 1
232                || logits_shape[2] != LABELS.len() as i64
233            {
234                return Err(ort::Error::new(format!(
235                    "layout logits have shape {logits_shape:?}, want [1, queries, 17]"
236                )));
237            }
238            let queries = logits_shape[1];
239            if boxes_shape.len() != 3
240                || boxes_shape[0] != 1
241                || boxes_shape[1] != queries
242                || boxes_shape[2] != 4
243            {
244                return Err(ort::Error::new(format!(
245                    "layout pred_boxes have shape {boxes_shape:?}, want [1, {queries}, 4]"
246                )));
247            }
248            Ok((logits_data.to_vec(), boxes_data.to_vec()))
249        })
250        .map_err(model_io_error)?;
251    Ok(postprocess(&logits, &boxes_cxcywh, page.width, page.height))
252}
253
254/// A shared-layer failure as the engine carries it: `EngineError::Io` with
255/// the typed cause, downcastable through
256/// [`pdf_ocr::runtime::model_error`].
257fn model_io_error(e: pdf_ocr::runtime::ModelError) -> EngineError {
258    EngineError::Io(std::io::Error::from(e))
259}
260
261/// HF `post_process_object_detection` for RT-DETR (focal-loss branch).
262///
263/// Sigmoid over the `[queries x 17]` logits, top-[`TOP_K`] over the flattened
264/// scores, `label = index % 17`, `query = index / 17`, boxes from normalised
265/// cxcywh to xyxy scaled to the render size, keeping `score >= 0.05`.
266/// Boxes come back in descending score order. Generic over the query count so
267/// a hand-made tensor tests it; production runs it with 300.
268fn postprocess(
269    logits: &[f32],
270    boxes_cxcywh: &[f32],
271    render_w: u32,
272    render_h: u32,
273) -> Vec<LayoutBox> {
274    let classes = LABELS.len();
275    let queries = (boxes_cxcywh.len() / 4).min(logits.len() / classes);
276    let total = queries * classes;
277    let scores: Vec<f32> = logits[..total]
278        .iter()
279        .map(|&logit| 1.0 / (1.0 + (-logit).exp()))
280        .collect();
281    let mut order: Vec<usize> = (0..total).collect();
282    order.sort_by(|&a, &b| scores[b].total_cmp(&scores[a]));
283    let w = render_w as f32;
284    let h = render_h as f32;
285    let mut out = Vec::new();
286    for index in order.into_iter().take(TOP_K.min(total)) {
287        let score = scores[index];
288        if score < SCORE_THRESHOLD {
289            continue;
290        }
291        let label_id = index % classes;
292        let query = index / classes;
293        let base = query * 4;
294        let (cx, cy, bw, bh) = (
295            boxes_cxcywh[base],
296            boxes_cxcywh[base + 1],
297            boxes_cxcywh[base + 2],
298            boxes_cxcywh[base + 3],
299        );
300        out.push(LayoutBox {
301            label: LABELS[label_id].to_string(),
302            score,
303            bbox_px: [
304                (cx - 0.5 * bw) * w,
305                (cy - 0.5 * bh) * h,
306                (cx + 0.5 * bw) * w,
307                (cy + 0.5 * bh) * h,
308            ],
309            render_w,
310            render_h,
311        });
312    }
313    out
314}
315
316/// One destination sample's contributing source indices and weights.
317type AxisTaps = Vec<(usize, f32)>;
318
319/// PIL BILINEAR's per-axis filter: destination sample `d` reads source
320/// centre `(d + 0.5) * scale - 0.5` with triangle support 1, widened to
321/// `scale` on downscale (PIL antialiases), weights renormalised over the
322/// clipped range so edges do not darken.
323fn axis_taps(src_len: u32, dst_len: u32) -> Vec<AxisTaps> {
324    let src_len = src_len as usize;
325    let scale = f64::from(src_len as u32) / f64::from(dst_len);
326    let support = if scale > 1.0 { scale } else { 1.0 };
327    (0..dst_len as usize)
328        .map(|d| {
329            let centre = (d as f64 + 0.5) * scale - 0.5;
330            let lo = (centre - support).floor() as i64 + 1;
331            let hi = (centre + support).ceil() as i64 - 1;
332            let mut taps = Vec::new();
333            let mut sum = 0.0f64;
334            for i in lo.max(0)..=hi.min(src_len as i64 - 1) {
335                let t = ((i as f64 - centre) / support).abs();
336                let weight = if t < 1.0 { 1.0 - t } else { 0.0 };
337                if weight > 0.0 {
338                    taps.push((i as usize, weight));
339                    sum += weight;
340                }
341            }
342            if sum > 0.0 {
343                taps.into_iter()
344                    .map(|(i, weight)| (i, (weight / sum) as f32))
345                    .collect()
346            } else {
347                // Unreachable: the centre always lands within support of a
348                // source sample. Nearest rather than empty, so no caller can
349                // read a black pixel from a filter that found nothing.
350                let nearest = centre.round().clamp(0.0, f64::from(src_len as u32 - 1)) as usize;
351                vec![(nearest, 1.0)]
352            }
353        })
354        .collect()
355}
356
357/// Resize interleaved RGB with PIL BILINEAR semantics, separable.
358///
359/// Horizontal pass rounded and clipped to bytes, then vertical, as PIL runs
360/// its horizontal resample into a byte image the vertical pass reads. Both
361/// round half up. Matches Pillow 10.2.0's `Image.resize(..., BILINEAR)`
362/// exactly on the reference cases in
363/// `tests/fixtures/ort_layout/pil_resize_reference.py`; a plain 2x2 bilinear
364/// (support 1 on downscale) reads 0/255 high-frequency content instead of its
365/// mean.
366fn resize_rgb_bilinear_pil(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> Vec<u8> {
367    let src_w = src_w as usize;
368    let src_h = src_h as usize;
369    let dst_w = dst_w as usize;
370    let dst_h = dst_h as usize;
371    let horizontal = axis_taps(src_w as u32, dst_w as u32);
372    let mut mid = vec![0u8; dst_w * src_h * 3];
373    for y in 0..src_h {
374        for (x, taps) in horizontal.iter().enumerate() {
375            let mut acc = [0.0f32; 3];
376            for &(i, weight) in taps {
377                let base = (y * src_w + i) * 3;
378                acc[0] += f32::from(src[base]) * weight;
379                acc[1] += f32::from(src[base + 1]) * weight;
380                acc[2] += f32::from(src[base + 2]) * weight;
381            }
382            let base = (y * dst_w + x) * 3;
383            mid[base] = acc[0].round().clamp(0.0, 255.0) as u8;
384            mid[base + 1] = acc[1].round().clamp(0.0, 255.0) as u8;
385            mid[base + 2] = acc[2].round().clamp(0.0, 255.0) as u8;
386        }
387    }
388    let vertical = axis_taps(src_h as u32, dst_h as u32);
389    let mut dst = vec![0u8; dst_w * dst_h * 3];
390    for (y, taps) in vertical.iter().enumerate() {
391        for x in 0..dst_w {
392            let mut acc = [0.0f32; 3];
393            for &(j, weight) in taps {
394                let base = (j * dst_w + x) * 3;
395                acc[0] += f32::from(mid[base]) * weight;
396                acc[1] += f32::from(mid[base + 1]) * weight;
397                acc[2] += f32::from(mid[base + 2]) * weight;
398            }
399            let base = (y * dst_w + x) * 3;
400            dst[base] = acc[0].round().clamp(0.0, 255.0) as u8;
401            dst[base + 1] = acc[1].round().clamp(0.0, 255.0) as u8;
402            dst[base + 2] = acc[2].round().clamp(0.0, 255.0) as u8;
403        }
404    }
405    dst
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn sigmoid(x: f32) -> f32 {
413        1.0 / (1.0 + (-x).exp())
414    }
415
416    #[test]
417    fn the_layout_spec_names_the_ep03_package() {
418        assert_eq!(LAYOUT_EP03.name, "layout-heron-ep03");
419        assert_eq!(LAYOUT_EP03.file, "heron_ep03.onnx");
420        assert_eq!(
421            LAYOUT_EP03.sha256,
422            "b7cc5d31f3223c74cc477a1386b894793618aeb398ca59cf5eb7264ec61d8afa"
423        );
424        assert!(
425            LAYOUT_EP03.install_command.contains("layout"),
426            "the install hint names the model package: {}",
427            LAYOUT_EP03.install_command
428        );
429        assert_eq!(LABELS.len(), 17);
430        assert_eq!(LABELS[9], "text");
431        assert_eq!(LABELS[8], "table");
432    }
433
434    #[test]
435    fn postprocess_top_k_runs_across_classes_with_modulo_labels() {
436        // Two queries, seventeen classes: everything at -10 (sigmoid ~0)
437        // except two classes on query 0 and one class on query 1, plus one
438        // class just under the threshold that must not survive.
439        let mut logits = vec![-10.0f32; 2 * 17];
440        logits[9] = 3.0; // query 0, class 9 ("text")
441        logits[3] = 2.0; // query 0, class 3 ("list_item")
442        logits[17] = 0.0; // query 1, class 0 ("caption")
443        logits[22] = -3.0; // query 1, class 5: sigmoid 0.047 < 0.05
444        let boxes_cxcywh = vec![
445            0.5, 0.5, 0.2, 0.4, // query 0
446            0.25, 0.75, 0.5, 0.5, // query 1
447        ];
448        let boxes = postprocess(&logits, &boxes_cxcywh, 1000, 800);
449        assert_eq!(boxes.len(), 3, "the under-threshold class must not survive");
450        // Descending score: the same query twice, then query 1. Index 17 is
451        // the modulo case: 17 % 17 == 0 ("caption"), 17 / 17 == 1 (query 1).
452        assert_eq!(boxes[0].label, "text");
453        assert_eq!(boxes[1].label, "list_item");
454        assert_eq!(boxes[2].label, "caption");
455        assert!((boxes[0].score - sigmoid(3.0)).abs() < 1e-6);
456        assert!((boxes[1].score - sigmoid(2.0)).abs() < 1e-6);
457        assert!((boxes[2].score - sigmoid(0.0)).abs() < 1e-6);
458        // The top two share query 0's box; the third reads query 1's. The
459        // corners compare with a tolerance: 0.4 is not exact in f32, so
460        // (0.5 - 0.5 * 0.4) * 800 reads 240.00002, as HF's f32 math does.
461        assert_eq!(boxes[0].bbox_px, boxes[1].bbox_px);
462        for (got, want) in boxes[0].bbox_px.iter().zip([400.0, 240.0, 600.0, 560.0]) {
463            assert!((got - want).abs() < 1e-3, "got {got}, want {want}");
464        }
465        for (got, want) in boxes[2].bbox_px.iter().zip([0.0, 400.0, 500.0, 800.0]) {
466            assert!((got - want).abs() < 1e-3, "got {got}, want {want}");
467        }
468        for b in &boxes {
469            assert_eq!((b.render_w, b.render_h), (1000, 800));
470        }
471    }
472
473    #[test]
474    fn postprocess_keeps_the_top_300_queries() {
475        // 300 queries would pass the threshold; the 301st must not survive
476        // even though it too is above it.
477        let mut logits = vec![-10.0f32; 301 * 17];
478        for q in 0..301 {
479            logits[q * 17] = 0.0; // sigmoid 0.5, class 0
480        }
481        let boxes_cxcywh = vec![0.5f32, 0.5, 0.1, 0.1]
482            .into_iter()
483            .cycle()
484            .take(301 * 4)
485            .collect::<Vec<_>>();
486        let boxes = postprocess(&logits, &boxes_cxcywh, 640, 640);
487        assert_eq!(boxes.len(), TOP_K);
488    }
489
490    #[test]
491    fn postprocess_reads_nothing_below_threshold() {
492        let logits = vec![-10.0f32; 4 * 17];
493        let boxes_cxcywh = vec![0.5f32; 4 * 4];
494        assert!(postprocess(&logits, &boxes_cxcywh, 640, 640).is_empty());
495    }
496
497    /// Reference outputs from
498    /// `tests/fixtures/ort_layout/pil_resize_reference.py` (Pillow 10.2.0).
499    /// The resampler matches PIL exactly on these; a plain 2x2 bilinear
500    /// without the widened downscale support fails the checker and edge
501    /// cases (0/255 instead of ~128, 0 instead of 36).
502    #[test]
503    fn resize_matches_pil_on_a_downscaled_ramp() {
504        let mut src = Vec::new();
505        for y in 0..4u8 {
506            for x in 0..4u8 {
507                src.extend_from_slice(&[x * 64, y * 64, 128]);
508            }
509        }
510        assert_eq!(
511            resize_rgb_bilinear_pil(&src, 4, 4, 2, 2),
512            vec![46, 46, 128, 146, 46, 128, 46, 146, 128, 146, 146, 128]
513        );
514    }
515
516    #[test]
517    fn resize_averages_a_downscaled_checkerboard_like_pil() {
518        let mut src = Vec::new();
519        for y in 0..4 {
520            for x in 0..4 {
521                let v = if (x + y) % 2 == 0 { 255 } else { 0 };
522                src.extend_from_slice(&[v, v, v]);
523            }
524        }
525        assert_eq!(
526            resize_rgb_bilinear_pil(&src, 4, 4, 2, 2),
527            vec![130, 130, 130, 125, 125, 125, 125, 125, 125, 130, 130, 130]
528        );
529    }
530
531    #[test]
532    fn resize_matches_pil_on_a_downscaled_edge() {
533        let src = [0u8, 0, 0, 0, 0, 0]
534            .into_iter()
535            .chain([255u8; 18])
536            .collect::<Vec<_>>();
537        assert_eq!(
538            resize_rgb_bilinear_pil(&src, 8, 1, 4, 1),
539            vec![36, 36, 36, 223, 223, 223, 255, 255, 255, 255, 255, 255]
540        );
541    }
542
543    #[test]
544    fn resize_matches_pil_on_an_upscaled_ramp() {
545        let mut src = Vec::new();
546        for y in 0..3u8 {
547            for x in 0..3u8 {
548                src.extend_from_slice(&[x * 100, y * 100, 50]);
549            }
550        }
551        let mut expected = Vec::new();
552        for g in [0, 40, 100, 160, 200] {
553            for r in [0, 40, 100, 160, 200] {
554                expected.extend_from_slice(&[r, g, 50]);
555            }
556        }
557        assert_eq!(resize_rgb_bilinear_pil(&src, 3, 3, 5, 5), expected);
558    }
559
560    #[test]
561    fn preprocess_lays_out_nchw_f32_in_unit_range() {
562        let page = RgbPage {
563            data: vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255],
564            width: 2,
565            height: 2,
566        };
567        let nchw = preprocess_to_nchw(&page);
568        assert_eq!(nchw.len(), 3 * 640 * 640);
569        assert!(nchw.iter().all(|&v| (0.0..=1.0).contains(&v)));
570        // The mean of a half-white image resized to 640x640 stays ~0.5 per
571        // channel; a channel swap or a 0..255 leak would read far from it.
572        for c in 0..3 {
573            let plane = &nchw[c * 640 * 640..(c + 1) * 640 * 640];
574            let mean = plane.iter().sum::<f32>() / plane.len() as f32;
575            assert!(
576                (mean - 0.5).abs() < 0.02,
577                "channel {c} mean {mean} is not ~0.5"
578            );
579        }
580    }
581}