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}