Skip to main content

ppocr_rs/
ocr_lite.rs

1use std::collections::HashMap;
2
3use image::ImageBuffer;
4use ort::session::builder::SessionBuilder;
5
6use crate::{
7    angle_net::AngleNet,
8    base_net::BaseNet,
9    crnn_net::CrnnNet,
10    db_net::DbNet,
11    layout::{LayoutAnalyzer, LayoutBox},
12    ocr_error::OcrError,
13    ocr_result::{OcrResult, Point, TextBlock, WordBox},
14    ocr_utils::OcrUtils,
15    scale_param::ScaleParam,
16    table_classifier::{DocOrientation, DocOrientationClassifier},
17};
18
19/// Opzioni di runtime per la pipeline OCR.
20///
21/// # Mappa degli stage opzionali PP-OCRv6 / PP-StructureV3
22///
23/// | Flag                    | Stato        | Modello / API                                   | Default |
24/// |-------------------------|:------------:|-------------------------------------------------|:-------:|
25/// | `return_word_box`       | ✅           | CTC timestep tracking (in-process)              | false   |
26/// | `lang`                  | ✅ (routing) | PP-OCRv6 CH+Latin unico                         | None    |
27/// | `use_doc_orientation`   | ✅           | [`DocOrientationClassifier`] (0/90/180/270°)    | true    |
28/// | `use_doc_unwarping`     | ❌ reserved  | TextImageUnwarping (UVDoc)                      | false   |
29/// | `use_seal`              | ❌ reserved  | SealTextDet + SealTextRec                       | false   |
30/// | `use_formula`           | ❌ reserved  | PP-FormulaNet-plus-L (LaTeX)                    | false   |
31/// | `use_chart`             | ❌ n/a       | ChartRecognition (non disponibile come ONNX)    | false   |
32///
33/// ## Moduli standalone
34///
35/// Layout e cell-detection si invocano separatamente:
36/// - **Layout**: [`LayoutAnalyzer`] + `detect_with_layout`
37/// - **Tipo tabella**: [`TableTypeClassifier`] standalone
38/// - **Struttura tabella**: [`TableStructureRecognizer`] standalone
39///
40/// I flag `❌ reserved` / `❌ n/a` sono **sempre ignorati**: il campo
41/// esiste per stabilire l'API surface senza breaking changes.
42///
43/// [`DocOrientationClassifier`]: crate::table_classifier::DocOrientationClassifier
44/// [`TableTypeClassifier`]: crate::table_classifier::TableTypeClassifier
45/// [`TableStructureRecognizer`]: crate::table_structure::TableStructureRecognizer
46#[derive(Debug, Clone)]
47pub struct OcrOptions {
48    // ── Output arricchito ────────────────────────────────────────────────────
49
50    /// Popola `TextBlock.words` con bbox per-parola via CTC timestep
51    /// tracking. Default `false` (costo negligibile ma disabilitato per
52    /// back-compat con callers che non usano i word bbox).
53    ///
54    /// **Limitazione**: le linee verticali (crop_h ≥ crop_w × 3/2)
55    /// restituiscono `words = []`; il line-level `box_points` è sempre
56    /// valido.
57    pub return_word_box: bool,
58
59    // ── Routing lingua ────────────────────────────────────────────────────────
60
61    /// ISO 639-1 (`"it"`, `"en"`, `"fr"`, `"de"`, `"es"`, `"pt"`) o
62    /// codice 3-lettere. **Oggi ignorato**: PP-OCRv6 CH+Latin unico copre
63    /// tutte le 6 lingue EU. Riservato per routing a modelli per-lingua
64    /// futuri (Tesseract / fine-tuned rec).
65    pub lang: Option<String>,
66
67    // ── Stage documento (PP-OCRv6 opzionali) ─────────────────────────────────
68
69    /// Corregge automaticamente la rotazione della pagina (0°/90°/180°/270°)
70    /// prima dell'OCR. Richiede che il classificatore sia caricato via
71    /// [`OcrLite::set_doc_orientation_model`]; senza modello il flag è ignorato
72    /// silenziosamente. Default `true`.
73    pub use_doc_orientation: bool,
74
75    /// **⚠ riservato.** Correzione prospettica pre-det via UVDoc.
76    /// Modello disponibile (`PpStructureModel::DocUnwarp`) ma non integrato
77    /// nella pipeline detect.
78    pub use_doc_unwarping: bool,
79
80    // ── Stage struttura (PP-StructureV3 opzionali) ────────────────────────────
81
82    /// **⚠ riservato.** Riconoscimento timbri circolari (PII nei rogiti notarili).
83    /// Modello non ancora scaricato/integrato.
84    pub use_seal: bool,
85
86    /// **⚠ riservato.** Riconoscimento formule LaTeX via PP-FormulaNet-plus-L.
87    /// Modello disponibile (`PpStructureModel::FormulaRec`) ma non integrato;
88    /// irrilevante per PII su documenti legali/medici.
89    pub use_formula: bool,
90
91    /// **⚠ n/a.** ChartRecognition non è disponibile come ONNX su HuggingFace.
92    /// Vedi `tools/convert/` per convertire il modello Paddle.
93    pub use_chart: bool,
94}
95
96impl Default for OcrOptions {
97    fn default() -> Self {
98        Self {
99            return_word_box:     false,
100            lang:                None,
101            use_doc_orientation: true,  // abilitato di default: corregge scansioni ruotate
102            use_doc_unwarping:   false,
103            use_seal:            false,
104            use_formula:         false,
105            use_chart:           false,
106        }
107    }
108}
109
110#[derive(Debug)]
111pub struct OcrLite {
112    db_net: DbNet,
113    angle_net: AngleNet,
114    crnn_net: CrnnNet,
115    doc_orientation_clf: Option<DocOrientationClassifier>,
116}
117
118impl Default for OcrLite {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl OcrLite {
125    pub fn new() -> Self {
126        Self {
127            db_net: DbNet::new(),
128            angle_net: AngleNet::new(),
129            crnn_net: CrnnNet::new(),
130            doc_orientation_clf: None,
131        }
132    }
133
134    /// Carica il classificatore orientamento pagina (PP-LCNet_x1_0_doc_ori_onnx).
135    /// Necessario per `OcrOptions { use_doc_orientation: true }`.
136    pub fn set_doc_orientation_model(&mut self, clf: DocOrientationClassifier) {
137        self.doc_orientation_clf = Some(clf);
138    }
139
140    pub fn init_models(
141        &mut self,
142        det_path: &str,
143        cls_path: &str,
144        rec_path: &str,
145        num_thread: usize,
146    ) -> Result<(), OcrError> {
147        self.db_net.init_model(det_path, num_thread, None)?;
148        self.angle_net.init_model(cls_path, num_thread, None)?;
149        self.crnn_net.init_model(rec_path, num_thread, None)?;
150        Ok(())
151    }
152
153    pub fn init_models_with_dict(
154        &mut self,
155        det_path: &str,
156        cls_path: &str,
157        rec_path: &str,
158        dict_path: &str,
159        num_thread: usize,
160    ) -> Result<(), OcrError> {
161        self.db_net.init_model(det_path, num_thread, None)?;
162        self.angle_net.init_model(cls_path, num_thread, None)?;
163        self.crnn_net
164            .init_model_dict_file(rec_path, num_thread, None, dict_path)?;
165        Ok(())
166    }
167
168    /// Variante senza angle-net: init solo det + rec, salta il cls model.
169    /// Usare con `do_angle: false` (se `do_angle=true` l'inferenza fallisce
170    /// con `SessionNotInitialized`). Utile nei test dove il cls ONNX non è
171    /// disponibile in cache.
172    pub fn init_models_no_angle(
173        &mut self,
174        det_path: &str,
175        rec_path: &str,
176        dict_path: &str,
177        num_thread: usize,
178    ) -> Result<(), OcrError> {
179        self.db_net.init_model(det_path, num_thread, None)?;
180        self.crnn_net
181            .init_model_dict_file(rec_path, num_thread, None, dict_path)?;
182        Ok(())
183    }
184
185    /// Variante di `init_models_with_dict` che accetta un `builder_fn`
186    /// custom da applicare a tutti e 3 i `Session::builder()` (det+cls+rec).
187    /// Usato dai consumer (Edge) per registrare execution provider
188    /// hardware-accelerated (QNN-HTP / DirectML / CoreML / CUDA / XNNPACK)
189    /// invece del default CPU-only del path standard.
190    pub fn init_models_with_dict_and_builder(
191        &mut self,
192        det_path: &str,
193        cls_path: &str,
194        rec_path: &str,
195        dict_path: &str,
196        num_thread: usize,
197        builder_fn: Option<fn(ort::session::builder::SessionBuilder) -> Result<ort::session::builder::SessionBuilder, ort::Error>>,
198    ) -> Result<(), OcrError> {
199        self.db_net.init_model(det_path, num_thread, builder_fn)?;
200        self.angle_net.init_model(cls_path, num_thread, builder_fn)?;
201        self.crnn_net
202            .init_model_dict_file(rec_path, num_thread, builder_fn, dict_path)?;
203        Ok(())
204    }
205
206    pub fn init_models_custom(
207        &mut self,
208        det_path: &str,
209        cls_path: &str,
210        rec_path: &str,
211        builder_fn: fn(SessionBuilder) -> Result<SessionBuilder, ort::Error>,
212    ) -> Result<(), OcrError> {
213        self.db_net.init_model(det_path, 0, Some(builder_fn))?;
214        self.angle_net.init_model(cls_path, 0, Some(builder_fn))?;
215        self.crnn_net.init_model(rec_path, 0, Some(builder_fn))?;
216        Ok(())
217    }
218
219    pub fn init_models_custom_with_dict(
220        &mut self,
221        det_path: &str,
222        cls_path: &str,
223        rec_path: &str,
224        dict_path: &str,
225        builder_fn: fn(SessionBuilder) -> Result<SessionBuilder, ort::Error>,
226    ) -> Result<(), OcrError> {
227        self.db_net.init_model(det_path, 0, Some(builder_fn))?;
228        self.angle_net.init_model(cls_path, 0, Some(builder_fn))?;
229        self.crnn_net
230            .init_model_dict_file(rec_path, 0, Some(builder_fn), dict_path)?;
231        Ok(())
232    }
233
234    pub fn init_models_from_memory(
235        &mut self,
236        det_bytes: &[u8],
237        cls_bytes: &[u8],
238        rec_bytes: &[u8],
239        num_thread: usize,
240    ) -> Result<(), OcrError> {
241        self.db_net
242            .init_model_from_memory(det_bytes, num_thread, None)?;
243        self.angle_net
244            .init_model_from_memory(cls_bytes, num_thread, None)?;
245        self.crnn_net
246            .init_model_from_memory(rec_bytes, num_thread, None)?;
247        Ok(())
248    }
249
250    pub fn init_models_from_memory_custom(
251        &mut self,
252        det_bytes: &[u8],
253        cls_bytes: &[u8],
254        rec_bytes: &[u8],
255        builder_fn: fn(SessionBuilder) -> Result<SessionBuilder, ort::Error>,
256    ) -> Result<(), OcrError> {
257        self.db_net
258            .init_model_from_memory(det_bytes, 0, Some(builder_fn))?;
259        self.angle_net
260            .init_model_from_memory(cls_bytes, 0, Some(builder_fn))?;
261        self.crnn_net
262            .init_model_from_memory(rec_bytes, 0, Some(builder_fn))?;
263        Ok(())
264    }
265
266    fn detect_base(
267        &mut self,
268        img_src: &image::RgbImage,
269        padding: u32,
270        max_side_len: u32,
271        box_score_thresh: f32,
272        box_thresh: f32,
273        un_clip_ratio: f32,
274        do_angle: bool,
275        most_angle: bool,
276        angle_rollback: bool,
277        angle_rollback_threshold: f32,
278    ) -> Result<OcrResult, OcrError> {
279        let origin_max_side = img_src.width().max(img_src.height());
280        let mut resize;
281        if max_side_len == 0 || max_side_len > origin_max_side {
282            resize = origin_max_side;
283        } else {
284            resize = max_side_len;
285        }
286        resize += 2 * padding;
287
288        let padding_src = OcrUtils::make_padding(img_src, padding)?;
289
290        let scale = ScaleParam::get_scale_param(&padding_src, resize);
291
292        self.detect_once(
293            &padding_src,
294            &scale,
295            padding,
296            box_score_thresh,
297            box_thresh,
298            un_clip_ratio,
299            do_angle,
300            most_angle,
301            angle_rollback,
302            angle_rollback_threshold,
303            OcrOptions::default(),
304        )
305    }
306
307    /// 检测图片
308    ///
309    /// # Arguments
310    ///
311    /// - `&self` (`undefined`) - Describe this parameter.
312    /// - `img_src` (`&image`) - 图片
313    /// - `padding` (`u32`) - 变换图片时添加边框的宽度(提高检测效果)
314    /// - `max_side_len` (`u32`) - 变换图片后图片宽和高保留的最大边长(超出该尺寸的图片将缩小)
315    /// - `box_score_thresh` (`f32`) - 检测存在文本的区域的分值阈值
316    /// - `do_angle` (`bool`) - 是否进行角度检测
317    /// ```
318    pub fn detect(
319        &mut self,
320        img_src: &image::RgbImage,
321        padding: u32,
322        max_side_len: u32,
323        box_score_thresh: f32,
324        box_thresh: f32,
325        un_clip_ratio: f32,
326        do_angle: bool,
327        most_angle: bool,
328    ) -> Result<OcrResult, OcrError> {
329        self.detect_base(
330            img_src,
331            padding,
332            max_side_len,
333            box_score_thresh,
334            box_thresh,
335            un_clip_ratio,
336            do_angle,
337            most_angle,
338            false,
339            0.0,
340        )
341    }
342
343    /// 支持角度回滚的检测图片
344    /// 在 do_angle 为 true 时生效,如果图片经过了角度纠正,但识别效果过差,则取消角度纠正
345    ///
346    /// # Arguments
347    ///
348    /// - `&self` (`undefined`) - Describe this parameter.
349    /// - `img_src` (`&image`) - 图片
350    /// - `padding` (`u32`) - 变换图片时添加的边框的宽度(提高检测效果)
351    /// - `max_side_len` (`u32`) - 变换图片后图片宽和高保留的最大边长(超出该尺寸的图片将缩小)
352    /// - `box_score_thresh` (`f32`) - 检测存在文本的区域的分值阈值
353    /// - `do_angle` (`bool`) - 是否进行角度检测
354    /// - `angle_rollback_threshold` (`f32`) - 角度回滚的阈值,如果识别到的文字得分低于该值(或等于 NaN),则取消角度回滚
355    /// ```
356    pub fn detect_angle_rollback(
357        &mut self,
358        img_src: &image::RgbImage,
359        padding: u32,
360        max_side_len: u32,
361        box_score_thresh: f32,
362        box_thresh: f32,
363        un_clip_ratio: f32,
364        do_angle: bool,
365        most_angle: bool,
366        angle_rollback_threshold: f32,
367    ) -> Result<OcrResult, OcrError> {
368        self.detect_base(
369            img_src,
370            padding,
371            max_side_len,
372            box_score_thresh,
373            box_thresh,
374            un_clip_ratio,
375            do_angle,
376            most_angle,
377            true,
378            angle_rollback_threshold,
379        )
380    }
381
382    pub fn detect_from_path(
383        &mut self,
384        img_path: &str,
385        padding: u32,
386        max_side_len: u32,
387        box_score_thresh: f32,
388        box_thresh: f32,
389        un_clip_ratio: f32,
390        do_angle: bool,
391        most_angle: bool,
392    ) -> Result<OcrResult, OcrError> {
393        let img_src = image::open(img_path)?.to_rgb8();
394
395        self.detect(
396            &img_src,
397            padding,
398            max_side_len,
399            box_score_thresh,
400            box_thresh,
401            un_clip_ratio,
402            do_angle,
403            most_angle,
404        )
405    }
406
407    fn detect_once(
408        &mut self,
409        img_src: &image::RgbImage,
410        scale: &ScaleParam,
411        padding: u32,
412        box_score_thresh: f32,
413        box_thresh: f32,
414        un_clip_ratio: f32,
415        do_angle: bool,
416        most_angle: bool,
417        angle_rollback: bool,
418        angle_rollback_threshold: f32,
419        options: OcrOptions,
420    ) -> Result<OcrResult, OcrError> {
421        let text_boxes = self.db_net.get_text_boxes(
422            img_src,
423            scale,
424            box_score_thresh,
425            box_thresh,
426            un_clip_ratio,
427        )?;
428
429        let part_images = OcrUtils::get_part_images(img_src, &text_boxes);
430
431        let angles = self
432            .angle_net
433            .get_angles(&part_images, do_angle, most_angle)?;
434
435        let mut rotated_images: Vec<image::RgbImage> = Vec::with_capacity(part_images.len());
436
437        // 角度纠正回滚
438        let mut angle_rollback_records =
439            HashMap::<usize, ImageBuffer<image::Rgb<u8>, Vec<u8>>>::new();
440
441        for (index, (angle, mut part_image)) in
442            angles.iter().zip(part_images.into_iter()).enumerate()
443        {
444            if angle.index == 1 {
445                if angle_rollback {
446                    // 保留原始副本
447                    angle_rollback_records.insert(index, part_image.clone());
448                }
449
450                OcrUtils::mat_rotate_clock_wise_180(&mut part_image);
451            }
452            rotated_images.push(part_image);
453        }
454
455        // CRNN: ritorna anche le info necessarie per l'inverse-warp quando
456        // `options.return_word_box=true`. Quando `false`, le ignoriamo
457        // (`words` resta `Vec::new()` nel `TextBlock`).
458        let lines_meta = self.crnn_net.get_text_lines_with_word_ranges(
459            &rotated_images,
460            &angle_rollback_records,
461            angle_rollback_threshold,
462        )?;
463
464        let mut text_blocks = Vec::with_capacity(lines_meta.len());
465        for (i, (text_line, word_ranges, crop_size, target_w, t_steps)) in lines_meta.into_iter().enumerate() {
466            // Polygon nello spazio dell'immagine ORIGINALE (post -padding).
467            let box_points: Vec<Point> = text_boxes[i].points.iter().map(|p| Point {
468                x: ((p.x as f32) - padding as f32) as u32,
469                y: ((p.y as f32) - padding as f32) as u32,
470            }).collect();
471
472            let words: Vec<WordBox> = if options.return_word_box {
473                build_word_boxes(
474                    &word_ranges,
475                    &text_boxes[i].points, // padded space
476                    crop_size,
477                    target_w,
478                    t_steps,
479                    angles[i].index == 1,
480                    padding,
481                )
482            } else {
483                Vec::new()
484            };
485
486            text_blocks.push(TextBlock {
487                box_points,
488                box_score:   text_boxes[i].score,
489                angle_index: angles[i].index,
490                angle_score: angles[i].score,
491                text:        text_line.text,
492                text_score:  text_line.text_score,
493                words,
494            });
495        }
496
497        Ok(OcrResult { text_blocks, page_angle: 0 })
498    }
499
500    /// Pipeline layout-aware completa: cls (per-line) → layout → ppocr
501    /// (det+cls+rec) → associazione text-line ↔ layout-box → orphan
502    /// recovery (nearest-neighbor) → sort per reading-order.
503    ///
504    /// Vincolo utente: le **text-line di OCR sono fonte di verità per il
505    /// testo** (non si filtrano per layout, evitando i buchi quando il
506    /// layout omette regioni). Il layout serve per ordering + classifica
507    /// semantica + associazione spaziale.
508    ///
509    /// **Ordering**:
510    /// 1. Primary: `LayoutBox.reading_order` (-1 va in fondo).
511    /// 2. Secondary: y-position del centroide del text-block dentro lo
512    ///    stesso box (top-to-bottom).
513    /// 3. Tertiary: x-position del centroide (left-to-right) — utile per
514    ///    box che contengono testo affiancato.
515    ///
516    /// **Orphan recovery**: text-line con centroide fuori da TUTTI i
517    /// layout-box → assigned al box più vicino (centroid distance).
518    /// Se non ci sono layout-box → tutto torna come orphan ordinato per
519    /// y/x (fallback graceful per pagine senza layout detection).
520    pub fn detect_with_layout(
521        &mut self,
522        image: &image::RgbImage,
523        layout: &mut LayoutAnalyzer,
524        padding: u32,
525        max_side_len: u32,
526        box_score_thresh: f32,
527        box_thresh: f32,
528        un_clip_ratio: f32,
529        do_angle: bool,
530        most_angle: bool,
531        options: OcrOptions,
532    ) -> Result<LayoutAwareResult, OcrError> {
533        // ── Step 1: layout analysis (pre-OCR) ───────────────────────────
534        let layout_boxes = layout.analyze(image)?;
535
536        // ── Step 2: full OCR (det + cls + rec, con word-box se richiesto)
537        let ocr = self.detect_with_options(
538            image,
539            padding,
540            max_side_len,
541            box_score_thresh,
542            box_thresh,
543            un_clip_ratio,
544            do_angle,
545            most_angle,
546            options,
547        )?;
548
549        // ── Step 3: associate ogni text-block a un layout-box ───────────
550        // Containment via centroid → fallback nearest-neighbor (orphan
551        // recovery: line OCR fuori da tutti i layout-box).
552        let mut associated: Vec<TextBlockWithLayout> = ocr.text_blocks
553            .into_iter()
554            .map(|tb| {
555                let (cx, cy) = OcrUtils::polygon_centroid(&tb.box_points);
556                let contained = layout_boxes.iter().position(|lb| lb.contains(cx, cy));
557                let (idx, dist) = match contained {
558                    Some(i) => (Some(i), 0.0),
559                    None    => nearest_layout_box(&layout_boxes, cx, cy),
560                };
561                TextBlockWithLayout {
562                    block: tb,
563                    layout_index: idx,
564                    distance: dist,
565                    centroid_x: cx,
566                    centroid_y: cy,
567                }
568            })
569            .collect();
570
571        // ── Step 4: sort by reading_order primary, y secondary, x tertiary
572        associated.sort_by(|a, b| {
573            let ra = a.layout_index
574                .map(|i| layout_boxes[i].reading_order)
575                .filter(|&r| r >= 0)
576                .unwrap_or(i32::MAX);
577            let rb = b.layout_index
578                .map(|i| layout_boxes[i].reading_order)
579                .filter(|&r| r >= 0)
580                .unwrap_or(i32::MAX);
581            ra.cmp(&rb)
582                .then_with(|| a.centroid_y.cmp(&b.centroid_y))
583                .then_with(|| a.centroid_x.cmp(&b.centroid_x))
584        });
585
586        Ok(LayoutAwareResult {
587            layout_boxes,
588            blocks: associated,
589        })
590    }
591
592    fn rotate_to_upright(img: image::RgbImage, orient: DocOrientation) -> image::RgbImage {
593        match orient {
594            DocOrientation::Deg0   => img,
595            DocOrientation::Deg90  => image::imageops::rotate270(&img),
596            DocOrientation::Deg180 => image::imageops::rotate180(&img),
597            DocOrientation::Deg270 => image::imageops::rotate90(&img),
598        }
599    }
600
601    /// Variante "ricca" di [`Self::detect`] con [`OcrOptions`].
602    ///
603    /// I flag `use_seal`, `use_formula`, `use_chart`, `use_doc_orientation`
604    /// e `use_doc_unwarping` sono **riservati e non ancora implementati**:
605    /// se impostati a `true` viene emesso un warning su `eprintln!` e
606    /// l'esecuzione prosegue senza lo stage corrispondente. Questo garantisce
607    /// che i caller esistenti non si rompano quando gli stage verranno
608    /// aggiunti in futuro.
609    pub fn detect_with_options(
610        &mut self,
611        img_src: &image::RgbImage,
612        padding: u32,
613        max_side_len: u32,
614        box_score_thresh: f32,
615        box_thresh: f32,
616        un_clip_ratio: f32,
617        do_angle: bool,
618        most_angle: bool,
619        options: OcrOptions,
620    ) -> Result<OcrResult, OcrError> {
621        // ── Orientamento pagina ─────────────────────────────────────────────
622        if options.use_doc_orientation {
623            match &self.doc_orientation_clf {
624                None => {} // modello non caricato: salta silenziosamente
625                Some(clf) => {
626                    let (orient, _conf) = clf.classify(img_src)?;
627                    if orient != DocOrientation::Deg0 {
628                        let rotated = Self::rotate_to_upright(img_src.clone(), orient);
629                        let mut opts2 = options.clone();
630                        opts2.use_doc_orientation = false; // evita doppia rotazione nel ricorso
631                        let mut result = self.detect_with_options(
632                            &rotated, padding, max_side_len,
633                            box_score_thresh, box_thresh, un_clip_ratio,
634                            do_angle, most_angle, opts2,
635                        )?;
636                        result.page_angle = orient.degrees();
637                        return Ok(result);
638                    }
639                }
640            }
641        }
642
643        // Warn su flag riservati non ancora implementati.
644        if options.use_doc_unwarping {
645            eprintln!("[ppocr-rs] WARN: use_doc_unwarping non implementato (TextImageUnwarping)");
646        }
647        if options.use_seal {
648            eprintln!("[ppocr-rs] WARN: use_seal non implementato (SealTextDet + SealTextRec)");
649        }
650        if options.use_formula {
651            eprintln!("[ppocr-rs] WARN: use_formula non implementato (PP-FormulaNet-L)");
652        }
653        if options.use_chart {
654            eprintln!("[ppocr-rs] WARN: use_chart non implementato (ChartRecognition)");
655        }
656
657        let origin_max_side = img_src.width().max(img_src.height());
658        let mut resize;
659        if max_side_len == 0 || max_side_len > origin_max_side {
660            resize = origin_max_side;
661        } else {
662            resize = max_side_len;
663        }
664        resize += 2 * padding;
665
666        let padding_src = OcrUtils::make_padding(img_src, padding)?;
667        let scale = ScaleParam::get_scale_param(&padding_src, resize);
668
669        self.detect_once(
670            &padding_src,
671            &scale,
672            padding,
673            box_score_thresh,
674            box_thresh,
675            un_clip_ratio,
676            do_angle,
677            most_angle,
678            false,
679            0.0,
680            options,
681        )
682    }
683}
684
685/// Output di [`OcrLite::detect_with_layout`]. Contiene sia i layout-box
686/// (sorted by reading_order) sia i text-block OCR (associati ai layout-box,
687/// sorted per reading-order then y-position).
688///
689/// **Vincolo testuale**: NON si perde mai un text-block. Se il layout
690/// omette regioni, i text-block in quelle regioni finiscono come orphan
691/// recovery sul box più vicino (`distance > 0`). Il consumer può
692/// filtrare per `distance == 0.0` se vuole solo containment esatto.
693#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
694pub struct LayoutAwareResult {
695    /// Layout-box rilevati da PP-DocLayoutV3, ordinati per
696    /// `reading_order` ascending (-1 in fondo). Vec vuoto se il modello
697    /// non rileva regioni → tutto va in orphan path.
698    pub layout_boxes: Vec<LayoutBox>,
699    /// Text-block OCR (det+rec+cls) con annotazione layout. Ordinati per
700    /// reading-order primario, y-position secondario, x-position
701    /// terziario. **Fonte di verità del testo riconosciuto**.
702    pub blocks: Vec<TextBlockWithLayout>,
703}
704
705/// Text-block OCR con metadata di associazione layout. Output di
706/// [`LayoutAwareResult::blocks`].
707#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
708pub struct TextBlockWithLayout {
709    pub block: TextBlock,
710    /// Index in `LayoutAwareResult.layout_boxes` del box associato
711    /// (containment via centroid, fallback nearest-neighbor). `None`
712    /// solo se non ci sono layout-box (lista vuota).
713    pub layout_index: Option<usize>,
714    /// `0.0` se il centroide del block è dentro il layout-box assegnato.
715    /// `> 0.0` se siamo arrivati al box via orphan-recovery (centroid
716    /// distance pixel-space). `INFINITY` se non c'erano layout-box.
717    pub distance:  f32,
718    /// Centroide del polygon block (cache di `polygon_centroid`, usato
719    /// per il sort secondario y/x).
720    pub centroid_x: u32,
721    pub centroid_y: u32,
722}
723
724/// Trova il layout-box più vicino al punto `(cx, cy)` via distanza dal
725/// centroide del box. Ritorna `(Some(idx), dist)` o `(None, INF)` se
726/// `boxes` è vuoto.
727fn nearest_layout_box(boxes: &[LayoutBox], cx: u32, cy: u32) -> (Option<usize>, f32) {
728    if boxes.is_empty() { return (None, f32::INFINITY); }
729    let mut best_idx = 0usize;
730    let mut best_dist = f32::INFINITY;
731    for (i, lb) in boxes.iter().enumerate() {
732        let d = lb.distance_to(cx, cy);
733        if d < best_dist {
734            best_dist = d;
735            best_idx = i;
736        }
737    }
738    (Some(best_idx), best_dist)
739}
740
741/// Mappa i `WordRange` CTC dal cropped-line space allo spazio dell'immagine
742/// originale. Percorso:
743///
744/// 1. `(start_ts, end_ts)` → `x_crnn_input` via `target_w / T` (≈ 8 px/ts).
745/// 2. `x_crnn_input` → `x_crop` via il rapporto `crop_w / resized_w` (con
746///    clamp a `crop_w` per timestep dentro la zona di padding).
747/// 3. Se `was_180_rotated` (angle.index==1), flippa `x_crop` orizzontalmente
748///    (la rotazione 180° è stata applicata DOPO il warp e PRIMA del CRNN).
749/// 4. 4-corner quad nel crop space → invocazione [`OcrUtils::inverse_warp_quad`]
750///    con il polygon (in PADDED image space) → quad in PADDED image space.
751/// 5. De-padding: sottrai `(padding, padding)` da ogni Point.
752///
753/// **Skip rule**: se la crop fu ruotata 90° dentro `get_rotate_crop_image`
754/// (caso testo verticale, `crop_h >= crop_w * 3/2`), non possiamo derivare
755/// word-box meaningful — ritorniamo `Vec::new()` per quella linea.
756fn build_word_boxes(
757    word_ranges: &[crate::crnn_net::WordRange],
758    polygon_padded: &[Point], // 4 corner nello spazio padded
759    crop_size:      (u32, u32),
760    target_w:       usize,
761    t_steps:        usize,
762    was_180_rotated: bool,
763    padding: u32,
764) -> Vec<WordBox> {
765    if word_ranges.is_empty() || polygon_padded.len() != 4 || t_steps == 0 || target_w == 0 {
766        return Vec::new();
767    }
768    let (crop_w, crop_h) = crop_size;
769    if crop_h == 0 || crop_w == 0 { return Vec::new(); }
770
771    // Rilevazione "crop ruotato 90°": dopo `get_rotate_crop_image`, se la
772    // line era verticale, l'immagine arriva al CRNN già con dimensioni
773    // swappate. Heuristic: il crop normale di una line di testo è ~3-30×
774    // più largo che alto. Se crop_h >= crop_w (cioè il crop è quadrato o
775    // più alto che largo), molto probabilmente è stato ruotato 90° → no
776    // word-box. Documento orizzontale tipico avrà sempre `crop_w > crop_h`.
777    if crop_h >= crop_w {
778        return Vec::new();
779    }
780
781    // resized_w è la larghezza che il CRNN ha visto PRIMA del padding right.
782    // Da get_text_line_with_wh_ratio:
783    //   scale = 48 / crop_h
784    //   resized_w = crop_w * scale = crop_w * 48 / crop_h
785    let dst_h = crate::crnn_net::CRNN_DST_HEIGHT as f32;
786    let resized_w = ((crop_w as f32) * dst_h / (crop_h as f32)).ceil() as u32;
787    let resized_w = resized_w.min(target_w as u32).max(1);
788
789    // x per timestep nello spazio CRNN-input (post-padding).
790    let x_per_ts = (target_w as f32) / (t_steps as f32).max(1.0);
791
792    // Polygon (4 punti) in PADDED space — array fisso per inverse_warp_quad.
793    let poly: [Point; 4] = [
794        polygon_padded[0], polygon_padded[1], polygon_padded[2], polygon_padded[3],
795    ];
796
797    let mut out = Vec::with_capacity(word_ranges.len());
798    for w in word_ranges {
799        // Step 1+2: timestep → crop x.
800        let x_crnn_start = (w.start_ts as f32)       * x_per_ts;
801        let x_crnn_end   = ((w.end_ts + 1) as f32)   * x_per_ts;
802        // Clamp dentro resized_w (oltre = padding zone).
803        let x_crnn_start = x_crnn_start.min(resized_w as f32);
804        let x_crnn_end   = x_crnn_end  .min(resized_w as f32);
805        // CRNN-input → crop space.
806        let ratio = (crop_w as f32) / (resized_w as f32);
807        let mut x_crop_start = x_crnn_start * ratio;
808        let mut x_crop_end   = x_crnn_end   * ratio;
809        if x_crop_end <= x_crop_start { continue; }
810
811        // Step 3: 180° rotation → flip orizzontale nel crop space.
812        if was_180_rotated {
813            let new_start = (crop_w as f32) - x_crop_end;
814            let new_end   = (crop_w as f32) - x_crop_start;
815            x_crop_start = new_start.max(0.0);
816            x_crop_end   = new_end.max(0.0);
817        }
818
819        // Step 4: 4-corner quad nel crop space (full height).
820        let quad = [
821            (x_crop_start, 0.0),
822            (x_crop_end,   0.0),
823            (x_crop_end,   crop_h as f32),
824            (x_crop_start, crop_h as f32),
825        ];
826
827        // Step 5: inverse-warp → padded image space → de-padding.
828        if let Some(image_pts_padded) = OcrUtils::inverse_warp_quad(&poly, crop_size, &quad) {
829            let image_pts: Vec<Point> = image_pts_padded.iter().map(|p| Point {
830                x: p.x.saturating_sub(padding),
831                y: p.y.saturating_sub(padding),
832            }).collect();
833            out.push(WordBox {
834                text:       w.text.clone(),
835                box_points: image_pts,
836                score:      w.score,
837            });
838        }
839    }
840    out
841}