Skip to main content

ppocr_rs/
table_structure.rs

1//! SLANeXt table structure recognition (PP-StructureV3 v2).
2//!
3//! Converte un'immagine di tabella in skeleton HTML + bbox delle celle.
4//!
5//! ## Modelli
6//!
7//! | Variante   | HF repo                            | Acc     | Uso                  |
8//! |------------|------------------------------------|---------|----------------------|
9//! | Wired      | `PaddlePaddle/SLANeXt_wired_onnx`  | 69.65%  | Tabelle con bordi    |
10//! | Wireless   | `PaddlePaddle/SLANeXt_wireless_onnx`|        | Tabelle senza bordi  |
11//!
12//! ## Pipeline
13//!
14//! 1. Resize proporzionale con `max_len=512` + pad a `512×512`.
15//! 2. Normalizzazione ImageNet (mean=[0.485,.456,.406], std=[0.229,.224,.225]).
16//! 3. Inference → `structure_probs [1, T, V]` + `loc_preds [1, T, 4]`.
17//! 4. Decode: argmax su V → token HTML; le celle `<td`-like hanno bbox in `loc_preds`.
18//! 5. Denormalizzazione bbox: coords relative a `512×512` corrette per resize ratio.
19//!
20//! ## Formato output
21//!
22//! - `html_tokens`: stringa di token HTML concatenati (senza testo cella).
23//!   Es. `"<tbody><tr><td></td><td></td></tr></tbody>"`.
24//! - `cell_boxes`: lista di [`TableCellBox`] (una per ogni token `<td*>`) in
25//!   coordinate dell'immagine originale.
26//!
27//! ## Vocabolario
28//!
29//! SLANeXt usa un dizionario di ~30-150 token HTML. Se disponibile, viene
30//! caricato dall'`inference.yml` del modello (come fa `ModelHub` per i modelli
31//! rec PP-OCRv6). Fallback: dizionario inglese embedded (29 token).
32
33use crate::ocr_error::OcrError;
34use ndarray::{Array, Array4};
35use ort::{
36    inputs,
37    session::{builder::GraphOptimizationLevel, Session},
38    value::Tensor,
39};
40use std::path::Path;
41
42/// Dimensione fissa dell'input SLANeXt (512×512 px).
43pub const SLANEXT_INPUT_SIZE: u32 = 512;
44
45/// Token HTML del dizionario inglese standard di SLANeXt.
46///
47/// Fonte: `PaddleOCR/ppocr/utils/dict/table_structure_dict.txt`.
48/// Indice 0 è riservato a `<pad>` (token silenzioso), poi i 29 token seguenti,
49/// infine `<eos>` all'ultimo posto.
50const EN_DICT_TOKENS: &[&str] = &[
51    "<thead>",
52    "<tr>",
53    "<td>",
54    "</td>",
55    "</tr>",
56    "</thead>",
57    "<tbody>",
58    "</tbody>",
59    "<td",
60    r#" colspan="5""#,
61    r#" colspan="2""#,
62    r#" colspan="3""#,
63    r#" rowspan="2""#,
64    r#" colspan="4""#,
65    r#" colspan="6""#,
66    r#" rowspan="3""#,
67    r#" colspan="9""#,
68    r#" colspan="10""#,
69    r#" colspan="7""#,
70    r#" rowspan="4""#,
71    r#" rowspan="5""#,
72    r#" rowspan="9""#,
73    r#" colspan="8""#,
74    r#" rowspan="8""#,
75    r#" rowspan="6""#,
76    r#" rowspan="7""#,
77    r#" rowspan="10""#,
78    ">",
79    "<td></td>",
80];
81
82// ─── Output types ─────────────────────────────────────────────────────────────
83
84/// Bbox di una cella tabella (coordinate immagine originale, pixel float).
85#[derive(Debug, Clone)]
86pub struct TableCellBox {
87    pub x1: f32,
88    pub y1: f32,
89    pub x2: f32,
90    pub y2: f32,
91}
92
93impl TableCellBox {
94    pub fn width(&self)  -> f32 { (self.x2 - self.x1).max(0.0) }
95    pub fn height(&self) -> f32 { (self.y2 - self.y1).max(0.0) }
96    pub fn cx(&self) -> f32 { (self.x1 + self.x2) * 0.5 }
97    pub fn cy(&self) -> f32 { (self.y1 + self.y2) * 0.5 }
98}
99
100/// Risultato del riconoscimento struttura tabella.
101#[derive(Debug, Clone)]
102pub struct TableStructure {
103    /// Sequenza di token HTML (struttura senza contenuto celle).
104    /// Inserisci il testo OCR delle celle per ottenere la tabella completa.
105    pub html_tokens: String,
106    /// Confidence media sul token sequence.
107    pub score: f32,
108    /// Bbox per ogni token `<td*>`, in coordinate immagine originale.
109    /// Allineato 1:1 con le celle presenti in `html_tokens`.
110    pub cell_boxes: Vec<TableCellBox>,
111}
112
113// ─── Recognizer ───────────────────────────────────────────────────────────────
114
115pub struct TableStructureRecognizer {
116    session:    Session,
117    /// Vocabolario HTML: indice → token string.
118    /// Posizione 0 = `<pad>` (silenzioso). Ultima posizione = `<eos>`.
119    token_dict: Vec<String>,
120    /// Indice del token di fine sequenza (`</tbody>` o `<eos>`).
121    end_idx:    usize,
122    /// Dimensione canvas quadrato dell'input (default=512 per SLANeXt, 488 per SLANet_plus).
123    input_size: u32,
124}
125
126impl TableStructureRecognizer {
127    /// Carica il modello con il dizionario embedded inglese e input 512×512 (SLANeXt).
128    pub fn from_path(model_path: impl AsRef<Path>) -> Result<Self, OcrError> {
129        Self::from_path_with_dict(model_path, None)
130    }
131
132    /// Carica il modello con dizionario opzionale da file (una riga per token).
133    ///
134    /// Se `dict_path` è `None`, usa il dizionario inglese embedded (29 token).
135    /// Input size default = `SLANEXT_INPUT_SIZE` (512). Per SLANet/SLANet_plus usare
136    /// [`with_input_size`](Self::with_input_size) dopo la costruzione per impostare 488.
137    pub fn from_path_with_dict(
138        model_path: impl AsRef<Path>,
139        dict_path:  Option<&Path>,
140    ) -> Result<Self, OcrError> {
141        let session = Session::builder()?
142            .with_optimization_level(GraphOptimizationLevel::Level3)?
143            .commit_from_file(model_path)?;
144
145        let token_dict = build_token_dict(dict_path)?;
146        let end_idx = find_end_idx(&token_dict);
147
148        Ok(Self { session, token_dict, end_idx, input_size: SLANEXT_INPUT_SIZE })
149    }
150
151    /// Sovrascrive la dimensione input canvas (default=512). Usare 488 per SLANet_plus.
152    pub fn with_input_size(mut self, size: u32) -> Self {
153        self.input_size = size;
154        self
155    }
156
157    /// Riconosce la struttura di una tabella nel crop immagine.
158    ///
159    /// `image` deve essere il crop della regione tabella (da `LayoutAnalyzer`
160    /// o crop manuale). Per tabelle wired usa `SLANeXt_wired_onnx`;
161    /// per wireless usa `SLANeXt_wireless_onnx`.
162    pub fn recognize(
163        &self,
164        image: &image::RgbImage,
165    ) -> Result<TableStructure, OcrError> {
166        let orig_w = image.width() as f32;
167        let orig_h = image.height() as f32;
168
169        // ── Preprocess ─────────────────────────────────────────────────────
170        let (blob, ratio_w, ratio_h) = preprocess_slanext(image, self.input_size);
171
172        // ── Inference ──────────────────────────────────────────────────────
173        let input_name = self.session.inputs[0].name.clone();
174        let outputs = self.session.run(
175            inputs![input_name => Tensor::from_array(blob)?]?
176        )?;
177
178        // ── Match output → structure_probs (sp) e loc_preds (lp) ──────────────
179        // Priorità:
180        // 1. Nome: "structure"/"prob" → sp; "loc"/"bbox"/"pred" → lp
181        // 2. Shape: last_dim == vocab_size → sp  (SLANet_plus: "fetch_name_1" [T,V])
182        // 3. Posizionale: primo unassigned → sp, secondo → lp
183        let vocab = self.token_dict.len() as i64;
184
185        // Raccoglie tutti gli output con shape+dati
186        let mut all_out: Vec<(String, Vec<i64>, Vec<f32>)> = Vec::new();
187        for (name, val) in &outputs {
188            let (sh, d) = crate::compat::tensor_extract_with_shape_f32(&val)?;
189            all_out.push((name.to_lowercase(), sh, d));
190        }
191
192        let mut sp_idx: Option<usize> = None;
193        let mut lp_idx: Option<usize> = None;
194
195        // Pass 1: nome
196        for (i, (n, _, _)) in all_out.iter().enumerate() {
197            if sp_idx.is_none() && (n.contains("structure") || n.contains("prob")) {
198                sp_idx = Some(i);
199            } else if lp_idx.is_none() && (n.contains("loc") || n.contains("bbox") || n.contains("pred")) {
200                lp_idx = Some(i);
201            }
202        }
203        // Pass 2: shape (last_dim == vocab_size → sp)
204        if sp_idx.is_none() || lp_idx.is_none() {
205            for (i, (_, sh, _)) in all_out.iter().enumerate() {
206                if Some(i) == sp_idx || Some(i) == lp_idx { continue; }
207                let last = sh.last().copied().unwrap_or(0);
208                if sp_idx.is_none() && last == vocab {
209                    sp_idx = Some(i);
210                } else if lp_idx.is_none() {
211                    lp_idx = Some(i);
212                }
213            }
214        }
215        // Pass 3: posizionale (rimpianti)
216        if sp_idx.is_none() {
217            sp_idx = all_out.iter().enumerate()
218                .find(|(i, _)| Some(*i) != lp_idx)
219                .map(|(i, _)| i);
220        }
221        if lp_idx.is_none() {
222            lp_idx = all_out.iter().enumerate()
223                .find(|(i, _)| Some(*i) != sp_idx)
224                .map(|(i, _)| i);
225        }
226
227        let mut sp_shape: Vec<i64> = Vec::new();
228        let mut sp_data:  Vec<f32> = Vec::new();
229        let mut lp_shape: Vec<i64> = Vec::new();
230        let mut lp_data:  Vec<f32> = Vec::new();
231
232        if let Some(i) = sp_idx {
233            sp_shape = all_out[i].1.clone();
234            sp_data  = all_out[i].2.clone();
235        }
236        if let Some(i) = lp_idx {
237            lp_shape = all_out[i].1.clone();
238            lp_data  = all_out[i].2.clone();
239        }
240
241        if sp_shape.len() < 3 {
242            return Err(OcrError::ModelOutput(format!(
243                "SLANeXt: structure_probs shape atteso [1,T,V], trovato {sp_shape:?}"
244            )));
245        }
246
247        let t_len  = sp_shape[1] as usize;
248        let v_len  = sp_shape[2] as usize;
249        let lp_cols = lp_shape.get(2).copied().unwrap_or(4) as usize;
250        // ── Decode ─────────────────────────────────────────────────────────
251        // Token che aprono una cella → hanno bbox associata in loc_preds
252        // Token che marcano un'apertura di cella: include i tag <td standard +
253        // i token attributo colspan/rowspan usati da SLANet_plus come marcatori
254        // di cella (le coord bbox in loc_preds sono allineate a questi step).
255        let structural = |t: &str| matches!(t,
256            "<thead>" | "</thead>" | "<tbody>" | "</tbody>" | "<tr>" | "</tr>" | "</td>" | ">"
257        );
258        let td_opener = |t: &str| !structural(t) && !t.is_empty() && t != "<eos>";
259
260        let mut html = String::with_capacity(256);
261        let mut cell_boxes: Vec<TableCellBox> = Vec::new();
262        let mut scores:     Vec<f32>          = Vec::new();
263
264        for step in 0..t_len {
265            // argmax su vocabolario V
266            let base = step * v_len;
267            let (char_idx, prob) = argmax_slice(&sp_data[base..base + v_len.min(sp_data.len().saturating_sub(base))]);
268
269            if step > 0 && char_idx == self.end_idx { break; }
270            if char_idx == 0 { continue; } // <pad>
271
272            let token = self.token_dict.get(char_idx)
273                .map(|s| s.as_str())
274                .unwrap_or("");
275            if token.is_empty() || token == "<eos>" { break; }
276
277            html.push_str(token);
278            scores.push(prob);
279
280            if td_opener(token) && lp_cols >= 4 {
281                let lp_base = step * lp_cols;
282                let needed = if lp_cols >= 6 { lp_base + 5 } else { lp_base + 3 };
283                if needed < lp_data.len() {
284                    let pad_sz = self.input_size as f32;
285                    // Formato 4 colonne: [x1,y1,x2,y2].
286                    // Formato 8 colonne (quad): [x_tl,y_tl,x_tr,y_tr,x_br,y_br,x_bl,y_bl].
287                    // Per ottenere il bbox axis-aligned dal quad: x1=col0, y1=col1, x2=col4, y2=col5.
288                    let (xi2, yi2) = if lp_cols >= 6 { (lp_base + 4, lp_base + 5) }
289                                     else             { (lp_base + 2, lp_base + 3) };
290                    let x1 = decode_coord(lp_data[lp_base],     pad_sz, ratio_w, orig_w);
291                    let y1 = decode_coord(lp_data[lp_base + 1], pad_sz, ratio_h, orig_h);
292                    let x2 = decode_coord(lp_data[xi2],         pad_sz, ratio_w, orig_w);
293                    let y2 = decode_coord(lp_data[yi2],         pad_sz, ratio_h, orig_h);
294                    if x2 > x1 && y2 > y1 {
295                        cell_boxes.push(TableCellBox { x1, y1, x2, y2 });
296                    }
297                }
298            }
299        }
300
301        let score = if scores.is_empty() { 0.0 } else {
302            scores.iter().sum::<f32>() / scores.len() as f32
303        };
304
305        Ok(TableStructure { html_tokens: html, score, cell_boxes })
306    }
307
308    /// Numero di token nel dizionario caricato (inclusi pad/eos).
309    pub fn vocab_size(&self) -> usize { self.token_dict.len() }
310}
311
312// ─── Token dict helpers ───────────────────────────────────────────────────────
313
314fn build_token_dict(dict_path: Option<&Path>) -> Result<Vec<String>, OcrError> {
315    if let Some(p) = dict_path {
316        let content = std::fs::read_to_string(p)?;
317        // index 0 = <pad>, poi le righe del file, infine <eos>
318        let mut dict = vec!["<pad>".to_string()];
319        for line in content.lines() {
320            let t = line.trim_end_matches('\r').to_string();
321            if !t.is_empty() {
322                dict.push(t);
323            }
324        }
325        // Aggiungi <eos> se non già presente
326        if !dict.iter().any(|t| t == "<eos>") {
327            dict.push("<eos>".to_string());
328        }
329        Ok(dict)
330    } else {
331        // Dizionario inglese embedded
332        let mut dict = vec!["<pad>".to_string()];
333        dict.extend(EN_DICT_TOKENS.iter().map(|s| s.to_string()));
334        dict.push("<eos>".to_string());
335        Ok(dict)
336    }
337}
338
339fn find_end_idx(dict: &[String]) -> usize {
340    // EOS preferito: </tbody> — segnala fine del contenuto tabella
341    dict.iter().position(|t| t == "</tbody>")
342        .or_else(|| dict.iter().position(|t| t == "<eos>"))
343        .unwrap_or(dict.len().saturating_sub(1))
344}
345
346// ─── Preprocessing ────────────────────────────────────────────────────────────
347
348/// `ResizeTableImage(max_len=512)` + `PaddingTableImage(512×512)` + ImageNet normalize.
349///
350/// Ritorna `(blob, ratio_w, ratio_h)` dove i ratio servono per
351/// denormalizzare le bbox di `loc_preds`.
352fn preprocess_slanext(
353    image: &image::RgbImage,
354    target: u32,
355) -> (Array4<f32>, f32, f32) {
356    let (orig_w, orig_h) = (image.width(), image.height());
357    // Scale il lato maggiore a `target` mantenendo aspect ratio
358    let scale = (target as f32 / orig_w as f32).min(target as f32 / orig_h as f32);
359    let new_w = ((orig_w as f32 * scale).round() as u32).max(1);
360    let new_h = ((orig_h as f32 * scale).round() as u32).max(1);
361    let ratio_w = new_w as f32 / orig_w as f32;
362    let ratio_h = new_h as f32 / orig_h as f32;
363
364    let resized = image::imageops::resize(image, new_w, new_h, image::imageops::FilterType::Triangle);
365    let mean = [0.485f32, 0.456, 0.406];
366    let std  = [0.229f32, 0.224, 0.225];
367    let t = target as usize;
368    // Canvas vuoto (pad = valore zero normalizzato ≈ media di background)
369    let mut blob: Array4<f32> = Array::zeros((1, 3, t, t));
370    for y in 0..new_h as usize {
371        for x in 0..new_w as usize {
372            let p = resized.get_pixel(x as u32, y as u32);
373            for c in 0..3usize {
374                blob[[0, c, y, x]] = (p[c] as f32 / 255.0 - mean[c]) / std[c];
375            }
376        }
377    }
378    (blob, ratio_w, ratio_h)
379}
380
381/// Denormalizza una coordinata da `loc_preds` (relativa al canvas 512×512)
382/// alle coordinate originali dell'immagine.
383///
384/// Formula (da `_bbox_decode` in PaddleOCR):
385///   `coord_orig = coord_norm * pad_size / ratio`
386#[inline]
387fn decode_coord(norm: f32, pad_size: f32, ratio: f32, orig_max: f32) -> f32 {
388    (norm * pad_size / ratio).clamp(0.0, orig_max)
389}
390
391// ─── Utility ──────────────────────────────────────────────────────────────────
392
393fn argmax_slice(s: &[f32]) -> (usize, f32) {
394    s.iter().copied().enumerate()
395        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
396        .unwrap_or((0, 0.0))
397}