Skip to main content

ppocr_rs/
cell_detection.rs

1//! RT-DETR-L Cell Detection per tabelle (PaddleOCR PP-StructureV3 v2).
2//!
3//! Modello: `RT-DETR-L_wired_table_cell_det.onnx` da `kreuzberg-dev/paddle-to-onnx`.
4//! Identica architettura a PP-DocLayoutV3 (entrambi RT-DETR family) ma:
5//!  - 1 sola classe (cell, no semantic categories)
6//!  - input fixed 640×640 (vs 800×800 di PP-DocLayoutV3)
7//!  - output `[N, 6]` = `[class_id, score, x1, y1, x2, y2]` (no reading_order)
8//!
9//! ## Pipeline
10//!
11//! 1. Letterbox resize a 640×640 preservando aspect ratio (pad bottom/right
12//!    con zeri normalize-space).
13//! 2. ImageNet normalize (mean=[.485,.456,.406] std=[.229,.224,.225] scale=1/255).
14//! 3. Inference con 3 input: `im_shape`, `image`, `scale_factor`.
15//!    Il modello internamente rescala le bbox alle coords originali.
16//! 4. Filtra per `conf_thresh` + NMS IoU 0.5.
17//!
18//! ## Uso architetturale
19//!
20//! Unico modulo attivo per il riconoscimento tabelle. La struttura (righe /
21//! colonne) è dedotta geometricamente da `derive_grid` + `grid_to_gfm` senza
22//! un modello di structure recognition separato: funziona bene su tabelle
23//! business/legali italiane dove SLANet_plus (PP-StructureV3 v1) produceva
24//! skeleton vuoti per distribuzione fuori-dominio.
25
26use crate::ocr_error::OcrError;
27use ndarray::{Array, Array2, Array4};
28use ort::{
29    inputs,
30    session::{builder::GraphOptimizationLevel, Session},
31    value::Tensor,
32};
33use std::path::Path;
34
35/// Lato dell'input del modello RT-DETR-L cell det (fixed dal grafo ONNX).
36pub const CELL_DETECT_INPUT_SIZE: u32 = 640;
37
38#[derive(Debug, Clone)]
39pub struct CellBbox {
40    /// Pixel coords sull'immagine input ORIGINALE (post-rescaling automatico
41    /// del modello tramite `scale_factor`).
42    pub left:   i32,
43    pub top:    i32,
44    pub right:  i32,
45    pub bottom: i32,
46    /// Confidence 0..=1 emessa dal detector.
47    pub score:  f32,
48}
49
50impl CellBbox {
51    pub fn width(&self)  -> i32 { (self.right  - self.left).max(0) }
52    pub fn height(&self) -> i32 { (self.bottom - self.top).max(0) }
53    pub fn cx(&self) -> i32 { (self.left + self.right) / 2 }
54    pub fn cy(&self) -> i32 { (self.top  + self.bottom) / 2 }
55}
56
57pub struct CellDetector {
58    session:        Session,
59    pub conf_thresh: f32,
60    pub nms_iou:     f32,
61}
62
63impl CellDetector {
64    pub fn from_path(model_path: impl AsRef<Path>) -> Result<Self, OcrError> {
65        let session = Session::builder()?
66            .with_optimization_level(GraphOptimizationLevel::Level3)?
67            .commit_from_file(model_path)?;
68        Ok(Self { session, conf_thresh: 0.3, nms_iou: 0.5 })
69    }
70
71    pub fn from_session(session: Session) -> Self {
72        Self { session, conf_thresh: 0.3, nms_iou: 0.5 }
73    }
74
75    /// Esegue cell detection su un'immagine (di solito il crop di una
76    /// regione tabella identificata da PP-DocLayoutV3).
77    pub fn detect(&mut self, image: &image::RgbImage) -> Result<Vec<CellBbox>, OcrError> {
78        // ── Preprocess letterbox + ImageNet normalize ──────────────────
79        let (input_blob, scale) = preprocess(image, CELL_DETECT_INPUT_SIZE);
80
81        let im_shape: Array2<f32> = ndarray::arr2(&[[
82            CELL_DETECT_INPUT_SIZE as f32, CELL_DETECT_INPUT_SIZE as f32,
83        ]]);
84        let scale_factor: Array2<f32> = ndarray::arr2(&[[scale, scale]]);
85
86        // Risolvi i nomi degli input via match-by-name (default ordine
87        // canonico Paddle se i nomi non matchano).
88        let inputs_meta: Vec<String> = self.session.inputs.iter()
89            .map(|i| i.name.clone()).collect();
90        if inputs_meta.len() < 3 {
91            return Err(OcrError::ModelInput(format!(
92                "RT-DETR-L cell det si aspetta ≥3 input, trovati {} ({:?})",
93                inputs_meta.len(), inputs_meta,
94            )));
95        }
96        let mut name_im_shape     = inputs_meta[0].clone();
97        let mut name_image        = inputs_meta[1].clone();
98        let mut name_scale_factor = inputs_meta[2].clone();
99        for n in &inputs_meta {
100            if n == "im_shape"     { name_im_shape     = n.clone(); }
101            if n == "image"        { name_image        = n.clone(); }
102            if n == "scale_factor" { name_scale_factor = n.clone(); }
103        }
104
105        let outputs = self.session.run(inputs![
106            name_im_shape     => Tensor::from_array(im_shape)?,
107            name_image        => Tensor::from_array(input_blob)?,
108            name_scale_factor => Tensor::from_array(scale_factor)?,
109        ]?)?;
110
111        // Output canonical: shape `[N, 6]` Float32 con
112        //   [class_id, score, x1, y1, x2, y2]
113        let (_, primary) = outputs.iter().next()
114            .ok_or_else(|| OcrError::ModelOutput("RT-DETR-L cell det no output".into()))?;
115        let (shape_vec, raw) = crate::compat::tensor_extract_with_shape_f32(&primary)?;
116        let n = shape_vec[0] as usize;
117        let cols = if shape_vec.len() > 1 { shape_vec[1] as usize } else { 0 };
118        if cols < 6 {
119            return Err(OcrError::ModelOutput(format!(
120                "RT-DETR-L cell det output cols={cols} (atteso ≥6)",
121            )));
122        }
123        let (img_w, img_h) = (image.width() as i32, image.height() as i32);
124        let mut cells: Vec<CellBbox> = Vec::with_capacity(n);
125        for i in 0..n {
126            let off = i * cols;
127            let class_id = raw[off] as i32;
128            let score    = raw[off + 1];
129            if score < self.conf_thresh { continue; }
130            if class_id < 0 { continue; }
131            let x1 = raw[off + 2];
132            let y1 = raw[off + 3];
133            let x2 = raw[off + 4];
134            let y2 = raw[off + 5];
135            let left   = (x1.max(0.0) as i32).min(img_w);
136            let top    = (y1.max(0.0) as i32).min(img_h);
137            let right  = (x2.max(0.0) as i32).min(img_w);
138            let bottom = (y2.max(0.0) as i32).min(img_h);
139            if right <= left || bottom <= top { continue; }
140            cells.push(CellBbox { left, top, right, bottom, score });
141        }
142        // NMS
143        nms(&mut cells, self.nms_iou);
144        Ok(cells)
145    }
146}
147
148/// Letterbox preprocess: resize a `target×target` preservando aspect ratio
149/// (pad bottom/right con zeri normalize-space). Ritorna `(blob, scale)`.
150/// Identico a `layout::preprocess` (RT-DETR family family).
151fn preprocess(image: &image::RgbImage, target_size: u32) -> (Array4<f32>, f32) {
152    let (orig_w, orig_h) = (image.width(), image.height());
153    let scale = (target_size as f32 / orig_h as f32).min(target_size as f32 / orig_w as f32);
154    let new_w = (orig_w as f32 * scale).round() as u32;
155    let new_h = (orig_h as f32 * scale).round() as u32;
156    let resized = image::imageops::resize(
157        image, new_w, new_h, image::imageops::FilterType::Triangle,
158    );
159    let mean = [0.485f32, 0.456, 0.406];
160    let std  = [0.229f32, 0.224, 0.225];
161    let target = target_size as usize;
162    let mut blob: Array4<f32> = Array::zeros((1, 3, target, target));
163    for y in 0..new_h as usize {
164        for x in 0..new_w as usize {
165            let pixel = resized.get_pixel(x as u32, y as u32);
166            for c in 0..3 {
167                let v = pixel[c] as f32 / 255.0;
168                blob[[0, c, y, x]] = (v - mean[c]) / std[c];
169            }
170        }
171    }
172    (blob, scale)
173}
174
175/// In-place NMS by score DESC, keeps boxes with IoU < `thresh` to higher-scoring.
176fn nms(cells: &mut Vec<CellBbox>, thresh: f32) {
177    cells.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
178    let mut keep = vec![true; cells.len()];
179    for i in 0..cells.len() {
180        if !keep[i] { continue; }
181        for j in (i + 1)..cells.len() {
182            if !keep[j] { continue; }
183            if iou(&cells[i], &cells[j]) > thresh { keep[j] = false; }
184        }
185    }
186    let mut idx = 0;
187    cells.retain(|_| { let k = keep[idx]; idx += 1; k });
188}
189
190fn iou(a: &CellBbox, b: &CellBbox) -> f32 {
191    let x_min = a.left.max(b.left);
192    let y_min = a.top.max(b.top);
193    let x_max = a.right.min(b.right);
194    let y_max = a.bottom.min(b.bottom);
195    let w = (x_max - x_min).max(0);
196    let h = (y_max - y_min).max(0);
197    let inter = (w * h) as f32;
198    let area_a = (a.width() * a.height()) as f32;
199    let area_b = (b.width() * b.height()) as f32;
200    let union = area_a + area_b - inter;
201    if union <= 0.0 { 0.0 } else { inter / union }
202}
203
204// ─── Geometric grid derivation ─────────────────────────────────────────
205
206/// Converte una lista di `CellBbox` (output del detector) in una griglia
207/// logica `Vec<Vec<CellBbox>>` (rows × cols), usando clustering centroid-Y
208/// per identificare le righe.
209///
210/// ## Algoritmo
211///
212/// 1. Sort cells per `cy()` (centroid Y).
213/// 2. Calcola la **mediana** dell'altezza cella → `median_h`.
214/// 3. `row_threshold = max(median_h * 0.6, 8)`. Due celle finiscono nella
215///    stessa riga se il loro `cy` è entro `row_threshold`.
216/// 4. Centroid clustering: assegnazione greedy con running mean del cy.
217/// 5. Sort righe per centroid Y, sort celle in ogni riga per `left`.
218///
219/// ## Perché centroid-Y invece di overlap-top/bottom
220///
221/// Le tabelle reali hanno celle con altezze MOLTO diverse (es. cella con 1
222/// linea vs cella con 3 linee accanto). Il vecchio algoritmo basato su
223/// overlap richiedeva > 50% intersezione, che falliva su questi casi: la
224/// cella corta (1 linea) e quella alta (3 linee) potevano avere overlap
225/// < 50% e finire in righe separate, anche se logicamente sono nella
226/// stessa riga.
227///
228/// Il centroid Y è invece **invariante alla altezza**: due celle in righe
229/// adiacenti hanno cy diversi di ~`median_h`, mentre due celle nella
230/// stessa riga (anche con altezze diverse) hanno cy entro `0.6*median_h`.
231///
232/// ## Limitazioni
233///
234/// - Niente colspan/rowspan inference.
235/// - Se una cella ha bbox malformato (cell-detector emette frammento di
236///   linea invece di cella intera) può comparire come riga "extra" con
237///   1-2 cell. Mitigato a `grid_to_gfm` che pad le righe corte alla colonna
238///   max della tabella.
239pub fn derive_grid(cells: Vec<CellBbox>) -> Vec<Vec<CellBbox>> {
240    if cells.is_empty() { return Vec::new(); }
241    let mut sorted = cells;
242    sorted.sort_by_key(|c| c.cy());
243
244    // Mediana altezza per il threshold di row-clustering.
245    let mut hs: Vec<i32> = sorted.iter().map(|c| c.height()).collect();
246    hs.sort();
247    let median_h = hs.get(hs.len() / 2).copied().unwrap_or(1).max(1);
248    let row_threshold = ((median_h as f32) * 0.6).max(8.0) as i32;
249
250    let mut rows: Vec<Vec<CellBbox>> = Vec::new();
251    let mut row_centroids: Vec<i32> = Vec::new();
252    for cell in sorted {
253        let cy = cell.cy();
254        let mut placed_idx: Option<usize> = None;
255        for (i, &rc) in row_centroids.iter().enumerate() {
256            if (cy - rc).abs() < row_threshold {
257                placed_idx = Some(i);
258                break;
259            }
260        }
261        match placed_idx {
262            Some(i) => {
263                let n = rows[i].len() as i32;
264                rows[i].push(cell);
265                // running mean del centroid: aggiorna senza resetta
266                row_centroids[i] = (row_centroids[i] * n + cy) / (n + 1);
267            }
268            None => {
269                rows.push(vec![cell]);
270                row_centroids.push(cy);
271            }
272        }
273    }
274
275    // Sort righe per centroid Y crescente (top-down reading order).
276    let mut indexed: Vec<(Vec<CellBbox>, i32)> = rows.into_iter()
277        .zip(row_centroids.into_iter())
278        .collect();
279    indexed.sort_by_key(|x| x.1);
280    let mut result: Vec<Vec<CellBbox>> = indexed.into_iter().map(|(r, _)| r).collect();
281    // Sort cells in each row by left (left-right reading order).
282    for row in result.iter_mut() {
283        row.sort_by_key(|c| c.left);
284    }
285    result
286}
287
288/// Renderizza una griglia di celle come tabella GFM (Markdown).
289/// La prima riga viene trattata come header GFM (separator `| --- |`).
290///
291/// `cell_text(row_idx, col_idx) -> String` è la closure di lookup del
292/// contenuto cella. Permette al caller di iniettare il testo OCR-ato per
293/// ogni cella senza accoppiare il modulo a una specifica pipeline OCR.
294/// Restituire `String::new()` produce cella vuota (rendering scheleton).
295///
296/// Sanitizzazione del testo cella per GFM:
297///   - `|` (pipe) escaped in `\|` per non rompere il rendering
298///   - newline interni rimpiazzati con spazio (NER-friendly: una stessa
299///     entità non viene spezzata su più righe del MD output)
300pub fn grid_to_gfm<F>(grid: &[Vec<CellBbox>], mut cell_text: F) -> String
301where
302    F: FnMut(usize, usize) -> String,
303{
304    if grid.is_empty() { return String::new(); }
305    let max_cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
306    if max_cols == 0 { return String::new(); }
307    let mut out = String::new();
308    for (row_idx, row) in grid.iter().enumerate() {
309        out.push('|');
310        for col_idx in 0..max_cols {
311            let txt = if col_idx < row.len() {
312                let raw = cell_text(row_idx, col_idx);
313                sanitize_cell(&raw)
314            } else { String::new() };
315            out.push(' ');
316            out.push_str(&txt);
317            out.push(' ');
318            out.push('|');
319        }
320        out.push('\n');
321        // Separator GFM dopo la prima riga (header convention)
322        if row_idx == 0 {
323            out.push('|');
324            for _ in 0..max_cols { out.push_str(" --- |"); }
325            out.push('\n');
326        }
327    }
328    out
329}
330
331/// Sanitizza il testo di una cella per inserimento in GFM:
332/// - escape pipe `|` → `\|`
333/// - flatten newline → spazio (preserva continuità per NER)
334/// - trim whitespace ai bordi
335fn sanitize_cell(s: &str) -> String {
336    let mut out = String::with_capacity(s.len());
337    let mut prev_space = true; // per skip leading whitespace
338    for ch in s.chars() {
339        match ch {
340            '|' => { out.push('\\'); out.push('|'); prev_space = false; }
341            '\n' | '\r' | '\t' => {
342                if !prev_space { out.push(' '); prev_space = true; }
343            }
344            ' ' => {
345                if !prev_space { out.push(' '); prev_space = true; }
346            }
347            c => { out.push(c); prev_space = false; }
348        }
349    }
350    // Trim trailing whitespace
351    while out.ends_with(' ') { out.pop(); }
352    out
353}