Skip to main content

ppocr_rs/
layout.rs

1//! Layout analysis via PP-DocLayoutV3 (PaddleX `paddle3.0.0`).
2//!
3//! Porting Rust del layout analyzer PP-DocLayoutV3 (PaddleX / PaddleOCR).
4//! Il modello è uno YOLO-style detector che produce 25 classi di regioni
5//! documentali (text/title/table/figure/header/footer/...).
6//!
7//! ## Pipeline
8//!
9//! 1. **Preprocess**: resize letterbox a 800×800 (preserva aspect ratio,
10//!    pad bottom-right con zeri), poi ImageNet normalize
11//!    (mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]). HWC→CHW + batch.
12//! 2. **Inference**: 3 input ONNX:
13//!      - `im_shape`     `[1,2]` = `[800.0, 800.0]`
14//!      - `image`        `[1,3,800,800]` = blob normalizzato
15//!      - `scale_factor` `[1,2]` = `[scale, scale]` (Paddle internamente
16//!        post-processa rescaling delle bbox alle coord originali).
17//! 3. **Output**: `[N, 7]` con `[class_id, score, xmin, ymin, xmax, ymax, read_order]`.
18//! 4. **Postprocess**: confidence threshold + NMS (IoU 0.5) + sort per
19//!    reading order (col 6).
20//!
21//! Il modello PaddleX **rimappa** internamente le bbox alle dimensioni
22//! originali tramite `scale_factor`, quindi le coordinate finali sono già
23//! in pixel sull'immagine input.
24
25use crate::ocr_error::OcrError;
26use ndarray::{Array, Array2, Array4};
27use ort::{
28    inputs,
29    session::{builder::GraphOptimizationLevel, Session},
30    value::Tensor,
31};
32use std::path::Path;
33
34/// Dimensione fissa input PP-DocLayoutV3.
35pub const LAYOUT_INPUT_SIZE: u32 = 800;
36
37/// 25 classi di PP-DocLayoutV3 (config.json del rilascio PaddleX). Mappate
38/// 1:1 dalle output predictions del modello.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub enum LayoutClass {
41    Abstract        = 0,
42    Algorithm       = 1,
43    AsideText       = 2,
44    Chart           = 3,
45    Content         = 4,
46    DisplayFormula  = 5,
47    DocTitle        = 6,
48    FigureTitle     = 7,
49    Footer          = 8,
50    FooterImage     = 9,
51    Footnote        = 10,
52    FormulaNumber   = 11,
53    Header          = 12,
54    HeaderImage     = 13,
55    Image           = 14,
56    InlineFormula   = 15,
57    Number          = 16,
58    ParagraphTitle  = 17,
59    Reference       = 18,
60    ReferenceContent = 19,
61    Seal            = 20,
62    Table           = 21,
63    Text            = 22,
64    VerticalText    = 23,
65    VisionFootnote  = 24,
66}
67
68impl LayoutClass {
69    pub fn from_id(id: usize) -> Option<Self> {
70        Some(match id {
71            0 => Self::Abstract,
72            1 => Self::Algorithm,
73            2 => Self::AsideText,
74            3 => Self::Chart,
75            4 => Self::Content,
76            5 => Self::DisplayFormula,
77            6 => Self::DocTitle,
78            7 => Self::FigureTitle,
79            8 => Self::Footer,
80            9 => Self::FooterImage,
81            10 => Self::Footnote,
82            11 => Self::FormulaNumber,
83            12 => Self::Header,
84            13 => Self::HeaderImage,
85            14 => Self::Image,
86            15 => Self::InlineFormula,
87            16 => Self::Number,
88            17 => Self::ParagraphTitle,
89            18 => Self::Reference,
90            19 => Self::ReferenceContent,
91            20 => Self::Seal,
92            21 => Self::Table,
93            22 => Self::Text,
94            23 => Self::VerticalText,
95            24 => Self::VisionFootnote,
96            _ => return None,
97        })
98    }
99
100    /// Mapping a categoria semantica semplificata coerente col Python
101    /// (`CLASS_MAPPING` in `analyzer.py`). 8 categorie: text/title/list/
102    /// figure/table/header/footer/equation. Usata downstream per markdown
103    /// export / consumer agnostic-rendering.
104    pub fn semantic(self) -> SemanticClass {
105        use LayoutClass::*;
106        match self {
107            DocTitle | ParagraphTitle              => SemanticClass::Title,
108            Header                                 => SemanticClass::Header,
109            Footer                                 => SemanticClass::Footer,
110            Reference                              => SemanticClass::List,
111            Chart | FooterImage | HeaderImage |
112            Image | Seal                           => SemanticClass::Figure,
113            Table                                  => SemanticClass::Table,
114            DisplayFormula | InlineFormula         => SemanticClass::Equation,
115            // Tutto il resto = text (Abstract, Algorithm, AsideText, Content,
116            // FigureTitle, Footnote, FormulaNumber, Number, ReferenceContent,
117            // Text, VerticalText, VisionFootnote).
118            _                                      => SemanticClass::Text,
119        }
120    }
121}
122
123/// Categoria semantica semplificata (8 classi). Usata per markdown export
124/// e per il rendering UI engine-agnostic.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126pub enum SemanticClass {
127    Text,
128    Title,
129    List,
130    Figure,
131    Table,
132    Header,
133    Footer,
134    Equation,
135}
136
137/// Bounding box di una regione layout, coordinate in pixel sull'immagine
138/// di input ORIGINALE (post-rescaling fatto internamente da PaddleX).
139#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
140pub struct LayoutBox {
141    pub x: u32,
142    pub y: u32,
143    pub w: u32,
144    pub h: u32,
145    pub class: LayoutClass,
146    pub score: f32,
147    /// Reading order assegnato dal modello (basso = prima nella lettura).
148    /// `-1` se il modello non lo emette (output shape < 7).
149    pub reading_order: i32,
150}
151
152impl LayoutBox {
153    pub fn xmin(&self) -> u32 { self.x }
154    pub fn ymin(&self) -> u32 { self.y }
155    pub fn xmax(&self) -> u32 { self.x + self.w }
156    pub fn ymax(&self) -> u32 { self.y + self.h }
157
158    /// True se il punto `(px, py)` cade dentro il rettangolo (inclusivo
159    /// sui bordi sinistro/superiore, esclusivo su destro/inferiore).
160    pub fn contains(&self, px: u32, py: u32) -> bool {
161        px >= self.xmin() && px < self.xmax()
162            && py >= self.ymin() && py < self.ymax()
163    }
164
165    /// Distanza euclidea fra il centro di `self` e il punto `(px, py)`.
166    /// Usata per orphan recovery (line outside ALL boxes → nearest box).
167    pub fn distance_to(&self, px: u32, py: u32) -> f32 {
168        let cx = self.x as f32 + self.w as f32 / 2.0;
169        let cy = self.y as f32 + self.h as f32 / 2.0;
170        let dx = cx - px as f32;
171        let dy = cy - py as f32;
172        (dx * dx + dy * dy).sqrt()
173    }
174}
175
176/// Analyzer wrapper che mantiene la `ort::Session` per PP-DocLayoutV3.
177pub struct LayoutAnalyzer {
178    pub session:        Session,
179    pub conf_thresh:    f32,
180    pub nms_iou_thresh: f32,
181}
182
183impl LayoutAnalyzer {
184    /// Carica il modello da file. La session usa `Level3` optimization.
185    pub fn from_path(model_path: impl AsRef<Path>) -> Result<Self, OcrError> {
186        let session = Session::builder()?
187            .with_optimization_level(GraphOptimizationLevel::Level3)?
188            .commit_from_file(model_path)?;
189        Ok(Self {
190            session,
191            conf_thresh:    0.50,
192            nms_iou_thresh: 0.50,
193        })
194    }
195
196    /// Costruttore da Session pre-caricata. Usato in Edge dove la session
197    /// è già nello state Tauri (`OcrState::layout_session`).
198    pub fn from_session(session: Session) -> Self {
199        Self {
200            session,
201            conf_thresh:    0.50,
202            nms_iou_thresh: 0.50,
203        }
204    }
205
206    /// Esegue layout analysis sull'immagine. Ritorna le `LayoutBox`
207    /// filtrate per confidence + NMS + ordinate per reading_order
208    /// (ascending; primo box nella lettura = posizione 0).
209    pub fn analyze(&mut self, image: &image::RgbImage) -> Result<Vec<LayoutBox>, OcrError> {
210        // ── Step 1: preprocess (letterbox + normalize) ──────────────────
211        let (input_blob, scale) = preprocess(image, LAYOUT_INPUT_SIZE);
212
213        // ── Step 2: inference ───────────────────────────────────────────
214        let im_shape: Array2<f32> = ndarray::arr2(&[[
215            LAYOUT_INPUT_SIZE as f32, LAYOUT_INPUT_SIZE as f32,
216        ]]);
217        let scale_factor: Array2<f32> = ndarray::arr2(&[[scale, scale]]);
218
219        // I 3 input names di PP-DocLayoutV3 (in ordine canonico):
220        //   im_shape, image, scale_factor.
221        // Lookup empirico: il Python usa in ordine `[input_names[0], [1], [2]]`
222        // come [im_shape, image, scale_factor]. Ricalchiamo.
223        let inputs = self.session.inputs.iter()
224            .map(|i| i.name.clone())
225            .collect::<Vec<_>>();
226        if inputs.len() < 3 {
227            return Err(OcrError::ModelInput(format!(
228                "PP-DocLayoutV3 si aspetta ≥3 input, trovati {} ({:?})",
229                inputs.len(), inputs,
230            )));
231        }
232        // Heuristic: match per nome se possibile, altrimenti usa l'ordine
233        // tipico Paddle: [im_shape, image, scale_factor].
234        let mut name_im_shape     = inputs[0].clone();
235        let mut name_image        = inputs[1].clone();
236        let mut name_scale_factor = inputs[2].clone();
237        for n in &inputs {
238            if n == "im_shape"     { name_im_shape     = n.clone(); }
239            if n == "image"        { name_image        = n.clone(); }
240            if n == "scale_factor" { name_scale_factor = n.clone(); }
241        }
242
243        let im_shape_t     = Tensor::from_array(im_shape)?;
244        let image_t        = Tensor::from_array(input_blob)?;
245        let scale_factor_t = Tensor::from_array(scale_factor)?;
246
247        let outputs = self.session.run(inputs![
248            name_im_shape     => im_shape_t,
249            name_image        => image_t,
250            name_scale_factor => scale_factor_t,
251        ]?)?;
252
253        // ── Step 3: parse output ────────────────────────────────────────
254        // Output principale: tensor shape (N, 6) o (N, 7) con
255        // [class_id, score, xmin, ymin, xmax, ymax, [reading_order]].
256        let (_, primary) = outputs.iter().next()
257            .ok_or_else(|| OcrError::ModelOutput("PP-DocLayoutV3 non ha emesso output".into()))?;
258
259        let (shape_vec, raw_data) = crate::compat::tensor_extract_with_shape_f32(&primary)?;
260        let n_boxes = shape_vec[0] as usize;
261        let n_cols  = if shape_vec.len() > 1 { shape_vec[1] as usize } else { 0 };
262        if n_cols < 6 {
263            return Err(OcrError::ModelOutput(format!(
264                "PP-DocLayoutV3 output cols={} (atteso ≥6)", n_cols,
265            )));
266        }
267        let has_reading_order = n_cols >= 7;
268
269        // ── Step 4: confidence filter ───────────────────────────────────
270        let mut boxes: Vec<LayoutBox> = Vec::with_capacity(n_boxes);
271        let (img_w, img_h) = (image.width() as i32, image.height() as i32);
272        for i in 0..n_boxes {
273            let off = i * n_cols;
274            let class_id = raw_data[off] as i32;
275            let score    = raw_data[off + 1];
276            if score < self.conf_thresh { continue; }
277            if class_id < 0 { continue; }
278            let xmin = raw_data[off + 2];
279            let ymin = raw_data[off + 3];
280            let xmax = raw_data[off + 4];
281            let ymax = raw_data[off + 5];
282            let read_order = if has_reading_order { raw_data[off + 6] as i32 } else { -1 };
283
284            // Clamp dentro l'immagine
285            let xmin_c = (xmin.max(0.0) as i32).min(img_w);
286            let ymin_c = (ymin.max(0.0) as i32).min(img_h);
287            let xmax_c = (xmax.max(0.0) as i32).min(img_w);
288            let ymax_c = (ymax.max(0.0) as i32).min(img_h);
289            if xmax_c <= xmin_c || ymax_c <= ymin_c { continue; }
290
291            let class = match LayoutClass::from_id(class_id as usize) {
292                Some(c) => c,
293                // class_id fuori range (modello aggiornato con classi nuove):
294                // fallback a Text come fa PaddleX, non scartare il box.
295                None    => LayoutClass::Text,
296            };
297            boxes.push(LayoutBox {
298                x: xmin_c as u32,
299                y: ymin_c as u32,
300                w: (xmax_c - xmin_c) as u32,
301                h: (ymax_c - ymin_c) as u32,
302                class,
303                score,
304                reading_order: read_order,
305            });
306        }
307
308        // ── Step 5: NMS ────────────────────────────────────────────────
309        let kept = nms(&mut boxes, self.nms_iou_thresh);
310
311        // ── Step 6: sort per reading_order (ascending), -1 va in fondo ─
312        let mut sorted = kept;
313        sorted.sort_by(|a, b| {
314            let ka = if a.reading_order < 0 { i32::MAX } else { a.reading_order };
315            let kb = if b.reading_order < 0 { i32::MAX } else { b.reading_order };
316            ka.cmp(&kb)
317        });
318
319        Ok(sorted)
320    }
321}
322
323/// Preprocess: letterbox resize a `target_size × target_size` preservando
324/// l'aspect ratio (pad bottom-right con zeri), ImageNet normalize, HWC→CHW
325/// + batch dim. Ritorna `(blob, scale)` dove `scale` è il rapporto
326/// applicato (uguale per H e W per via dell'aspect-preserve).
327fn preprocess(image: &image::RgbImage, target_size: u32) -> (Array4<f32>, f32) {
328    let (orig_w, orig_h) = (image.width(), image.height());
329    let scale = (target_size as f32 / orig_h as f32).min(target_size as f32 / orig_w as f32);
330    let new_w = (orig_w as f32 * scale).round() as u32;
331    let new_h = (orig_h as f32 * scale).round() as u32;
332
333    // Resize bilinear
334    let resized = image::imageops::resize(
335        image, new_w, new_h, image::imageops::FilterType::Triangle,
336    );
337
338    // Letterbox pad: target_size×target_size, riempie da (0,0) a (new_w,new_h),
339    // resto = zero (in normalized space).
340    let mean = [0.485f32, 0.456, 0.406];
341    let std  = [0.229f32, 0.224, 0.225];
342
343    let target = target_size as usize;
344    let mut blob: Array4<f32> = Array::zeros((1, 3, target, target));
345    for y in 0..new_h as usize {
346        for x in 0..new_w as usize {
347            let pixel = resized.get_pixel(x as u32, y as u32);
348            for c in 0..3 {
349                let v = pixel[c] as f32 / 255.0;
350                blob[[0, c, y, x]] = (v - mean[c]) / std[c];
351            }
352        }
353    }
354    (blob, scale)
355}
356
357/// Non-maximum suppression. Mantiene il box con score più alto, scarta
358/// quelli con IoU > `iou_thresh`. Time O(N²) — sufficiente per N tipico
359/// di PP-DocLayoutV3 (≤100 box per pagina).
360fn nms(boxes: &mut Vec<LayoutBox>, iou_thresh: f32) -> Vec<LayoutBox> {
361    boxes.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
362    let mut keep: Vec<bool> = vec![true; boxes.len()];
363    for i in 0..boxes.len() {
364        if !keep[i] { continue; }
365        for j in (i + 1)..boxes.len() {
366            if !keep[j] { continue; }
367            if iou(&boxes[i], &boxes[j]) > iou_thresh {
368                keep[j] = false;
369            }
370        }
371    }
372    boxes.iter().zip(keep.iter())
373        .filter_map(|(b, &k)| if k { Some(b.clone()) } else { None })
374        .collect()
375}
376
377fn iou(a: &LayoutBox, b: &LayoutBox) -> f32 {
378    let xa = a.xmin().max(b.xmin()) as i32;
379    let ya = a.ymin().max(b.ymin()) as i32;
380    let xb = (a.xmax().min(b.xmax())) as i32;
381    let yb = (a.ymax().min(b.ymax())) as i32;
382    let w = (xb - xa).max(0);
383    let h = (yb - ya).max(0);
384    let inter = (w * h) as f32;
385    let area_a = (a.w * a.h) as f32;
386    let area_b = (b.w * b.h) as f32;
387    let union = area_a + area_b - inter;
388    if union <= 0.0 { 0.0 } else { inter / union }
389}
390
391// ─── XY-Cut reading-order ─────────────────────────────────────────────────────
392
393/// Ordina i layout-box in reading-order usando l'algoritmo XY-Cut ricorsivo.
394///
395/// Ritorna gli indici in `boxes` nell'ordine di lettura corretto.
396///
397/// L'algoritmo:
398/// 1. Cerca un taglio orizzontale (y-gap dove nessun box attraversa la linea).
399///    Se trovato: il gruppo superiore viene letto prima del gruppo inferiore.
400/// 2. Se non esiste taglio orizzontale, cerca un taglio verticale (x-gap).
401///    Se trovato: la colonna sinistra viene letta prima della destra.
402/// 3. Se nessun taglio esiste (boxes si sovrappongono su entrambi gli assi):
403///    fallback a ordinamento per (y_center, x_center).
404///
405/// Gestisce correttamente documenti a una o più colonne, tabelle e layout misti.
406pub fn xy_cut_order(boxes: &[LayoutBox]) -> Vec<usize> {
407    let indices: Vec<usize> = (0..boxes.len()).collect();
408    let mut result = Vec::with_capacity(boxes.len());
409    xy_cut_rec(boxes, &indices, &mut result);
410    result
411}
412
413fn xy_cut_rec(boxes: &[LayoutBox], indices: &[usize], out: &mut Vec<usize>) {
414    match indices.len() {
415        0 => {}
416        1 => out.push(indices[0]),
417        _ => {
418            if let Some((top, bottom)) = find_cut(boxes, indices, Axis::Y) {
419                xy_cut_rec(boxes, &top, out);
420                xy_cut_rec(boxes, &bottom, out);
421            } else if let Some((left, right)) = find_cut(boxes, indices, Axis::X) {
422                xy_cut_rec(boxes, &left, out);
423                xy_cut_rec(boxes, &right, out);
424            } else {
425                // Nessun taglio pulito: ordina per (y_center, x_center)
426                let mut sorted = indices.to_vec();
427                sorted.sort_by(|&a, &b| {
428                    let ay = boxes[a].y as f32 + boxes[a].h as f32 / 2.0;
429                    let by = boxes[b].y as f32 + boxes[b].h as f32 / 2.0;
430                    ay.partial_cmp(&by).unwrap_or(std::cmp::Ordering::Equal)
431                        .then_with(|| {
432                            let ax = boxes[a].x as f32 + boxes[a].w as f32 / 2.0;
433                            let bx = boxes[b].x as f32 + boxes[b].w as f32 / 2.0;
434                            ax.partial_cmp(&bx).unwrap_or(std::cmp::Ordering::Equal)
435                        })
436                });
437                out.extend(sorted);
438            }
439        }
440    }
441}
442
443#[derive(Clone, Copy)]
444enum Axis { X, Y }
445
446/// Cerca un taglio sull'asse specificato. Ritorna `(prima_partizione, seconda_partizione)`.
447/// Il taglio è valido solo se non esiste nessun box che attraversa la linea di taglio.
448fn find_cut(boxes: &[LayoutBox], indices: &[usize], axis: Axis) -> Option<(Vec<usize>, Vec<usize>)> {
449    // Raccoglie eventi (valore_inizio, valore_fine) sull'asse scelto
450    let intervals: Vec<(u32, u32)> = indices.iter()
451        .map(|&i| match axis {
452            Axis::Y => (boxes[i].ymin(), boxes[i].ymax()),
453            Axis::X => (boxes[i].xmin(), boxes[i].xmax()),
454        })
455        .collect();
456
457    // Trova il minimo massimo: il valore più basso tra tutti i "fine" dei box
458    // che NON viene superato dall'inizio di qualche altro box → gap
459    let mut events: Vec<(u32, bool)> = Vec::new(); // (valore, is_start)
460    for &(s, e) in &intervals {
461        events.push((s, true));
462        events.push((e, false));
463    }
464    // Sort: per valore, poi end prima di start allo stesso valore
465    // (box A che finisce a 100 e box B che inizia a 100 → nessun gap)
466    events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
467
468    let mut active = 0i32;
469    let mut cut_end: Option<u32> = None;
470    for &(v, is_start) in &events {
471        if is_start { active += 1; } else { active -= 1; }
472        if active == 0 {
473            cut_end = Some(v);
474            break;
475        }
476    }
477
478    let cut = cut_end?;
479    // Il prossimo evento "start" dopo `cut` indica dove inizia la partizione successiva
480    let next_start = events.iter()
481        .find(|&&(v, is_start)| v > cut && is_start)
482        .map(|&(v, _)| v)?;
483
484    let first: Vec<usize> = indices.iter().cloned()
485        .filter(|&i| match axis {
486            Axis::Y => boxes[i].ymax() <= cut,
487            Axis::X => boxes[i].xmax() <= cut,
488        })
489        .collect();
490    let second: Vec<usize> = indices.iter().cloned()
491        .filter(|&i| match axis {
492            Axis::Y => boxes[i].ymin() >= next_start,
493            Axis::X => boxes[i].xmin() >= next_start,
494        })
495        .collect();
496
497    // Verifica che tutti i box siano stati assegnati a una delle due partizioni
498    if first.len() + second.len() == indices.len() {
499        Some((first, second))
500    } else {
501        None
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    fn lb(x: u32, y: u32, w: u32, h: u32, class: LayoutClass, score: f32, ro: i32) -> LayoutBox {
510        LayoutBox { x, y, w, h, class, score, reading_order: ro }
511    }
512
513    #[test]
514    fn iou_identical_is_one() {
515        let a = lb(0, 0, 100, 50, LayoutClass::Text, 0.9, 0);
516        let b = a.clone();
517        assert!((iou(&a, &b) - 1.0).abs() < 1e-6);
518    }
519
520    #[test]
521    fn iou_disjoint_is_zero() {
522        let a = lb(0,   0, 50, 50, LayoutClass::Text, 0.9, 0);
523        let b = lb(100, 0, 50, 50, LayoutClass::Text, 0.9, 0);
524        assert_eq!(iou(&a, &b), 0.0);
525    }
526
527    #[test]
528    fn nms_keeps_highest_score() {
529        let mut boxes = vec![
530            lb(0, 0, 100, 50, LayoutClass::Text, 0.85, 0),
531            lb(5, 5, 100, 50, LayoutClass::Text, 0.95, 0), // overlap >> thresh
532            lb(200, 200, 50, 50, LayoutClass::DocTitle, 0.70, 1),
533        ];
534        let kept = nms(&mut boxes, 0.5);
535        assert_eq!(kept.len(), 2);
536        // Score più alto (0.95) prima
537        assert!((kept[0].score - 0.95).abs() < 1e-6);
538        assert!((kept[1].score - 0.70).abs() < 1e-6);
539    }
540
541    #[test]
542    fn semantic_mapping() {
543        assert_eq!(LayoutClass::DocTitle.semantic(),       SemanticClass::Title);
544        assert_eq!(LayoutClass::ParagraphTitle.semantic(), SemanticClass::Title);
545        assert_eq!(LayoutClass::Image.semantic(),          SemanticClass::Figure);
546        assert_eq!(LayoutClass::Table.semantic(),          SemanticClass::Table);
547        assert_eq!(LayoutClass::Footer.semantic(),         SemanticClass::Footer);
548        assert_eq!(LayoutClass::DisplayFormula.semantic(), SemanticClass::Equation);
549        assert_eq!(LayoutClass::Text.semantic(),           SemanticClass::Text);
550        assert_eq!(LayoutClass::VerticalText.semantic(),   SemanticClass::Text);
551    }
552
553    #[test]
554    fn layout_box_contains_and_distance() {
555        let b = lb(100, 100, 200, 50, LayoutClass::Text, 0.9, 0);
556        assert!(b.contains(150, 120));
557        assert!(!b.contains(50, 120));
558        assert!(!b.contains(350, 120));
559        // Centroide = (200, 125). Distanza dal centroide a (200, 125) = 0.
560        assert!(b.distance_to(200, 125) < 0.01);
561    }
562
563    #[test]
564    fn preprocess_letterbox_keeps_aspect() {
565        // Immagine 600×300 → su target 800: scale = min(800/300, 800/600) = 800/600 = 1.333
566        // new_w = 800, new_h = 400. Padding bottom = 400 px.
567        let img = image::RgbImage::new(600, 300);
568        let (blob, scale) = preprocess(&img, 800);
569        assert!((scale - 800.0/600.0).abs() < 1e-3);
570        assert_eq!(blob.shape(), &[1, 3, 800, 800]);
571    }
572}