1mod cv;
42mod detect;
43mod layout;
44mod lm;
45mod math;
46mod order;
47mod recognize;
48mod rtl;
49
50use image::{GrayImage, RgbImage};
51use oar_ocr_core::core::OCRError;
52use oar_ocr_core::core::config::{ModelInferenceConfig, OrtSessionConfig};
53use oar_ocr_core::core::inference::OrtInfer;
54use ort::session::{SessionInputValue, SessionOutputs};
55use ort::value::Tensor;
56use rayon::prelude::*;
57use recognize::{Crop, LineText, Token};
58use serde::Serialize;
59use std::borrow::Cow;
60use std::path::{Path, PathBuf};
61use unicode_normalization::UnicodeNormalization;
62
63const ADAPTIVE_MIN_H: f32 = 9.0;
66const ADAPTIVE_TARGET_H: f32 = 12.0;
67const ADAPTIVE_MAX_SIDE: usize = 2048;
68const SPLIT_GAP: f64 = 3.0;
71const CELL_GAP: f64 = 1.8;
73const ORIENT_SIDE: usize = 768;
75const ORIENT_MIN: f32 = 0.95;
77const VERTICAL: u32 = 4;
79const PREVIEW_SIDE: f64 = 1600.0;
82const MIN_CONF: f64 = 0.5;
84const DESKEW_MAX: f64 = 45.0;
86const DESKEW_MIN: f64 = 0.3;
88const REC_BATCH: usize = 64;
90const ROUTE_SURE: f32 = 0.95;
94const GROUPS: [&str; 4] = ["base", "cjk", "rtl", "other"];
97const LM_GATE: f64 = 0.9;
100const LM_ALPHA: f64 = 0.5;
101const LM_BETA: f64 = 1.5;
102const LM_BEAM: usize = 8;
103const LM_CAND_FLOOR: f64 = 3e-3;
105
106#[derive(Debug, Clone)]
108pub struct IndraOcrBuilder {
109 model_dir: PathBuf,
110 math: bool,
111 keep_furniture: bool,
112 table_heads: bool,
113 ort_session: Option<OrtSessionConfig>,
114}
115
116impl IndraOcrBuilder {
117 pub fn new(model_dir: impl Into<PathBuf>) -> Self {
120 Self {
121 model_dir: model_dir.into(),
122 math: false,
123 keep_furniture: false,
124 table_heads: false,
125 ort_session: None,
126 }
127 }
128
129 pub fn math(mut self, enable: bool) -> Self {
133 self.math = enable;
134 self
135 }
136
137 pub fn keep_furniture(mut self, enable: bool) -> Self {
140 self.keep_furniture = enable;
141 self
142 }
143
144 pub fn table_heads(mut self, enable: bool) -> Self {
151 self.table_heads = enable;
152 self
153 }
154
155 pub fn ort_session(mut self, config: OrtSessionConfig) -> Self {
158 self.ort_session = Some(config);
159 self
160 }
161
162 pub fn build(self) -> Result<IndraOcr, OCRError> {
164 let dir = self.model_dir.as_path();
165 let mut common = ModelInferenceConfig::new();
166 common.ort_session = self.ort_session;
167 let meta = read_json(&dir.join("meta.json"))?;
168 let vocab = |key: &str| -> Result<Vec<String>, OCRError> {
169 let inner: serde_json::Value = serde_json::from_str(meta[key].as_str().unwrap_or(""))
170 .map_err(|e| {
171 OCRError::config_error(format!("meta.json: {key} is not a vocabulary: {e}"))
172 })?;
173 string_list(&inner["chars"], &format!("meta.json: {key}.chars"))
174 };
175 let size = |key: &str, default: Option<usize>| {
176 match meta.get(key) {
177 None => default,
178 Some(v) => v.as_u64().filter(|&v| v > 0).map(|v| v as usize),
179 }
180 .ok_or_else(|| {
181 OCRError::config_error(format!("meta.json: {key} must be a positive integer"))
182 })
183 };
184 let recognizer = |name: &str, file: &str, lm_file: &str, vocab: Vec<String>| {
187 let model = model(dir, file, &common)?;
188 let lm_path = dir.join(lm_file);
189 let outputs: Vec<String> = model.output_shapes().into_iter().map(|o| o.0).collect();
190 let lm = if lm_path.exists()
191 && ["blank", "topv", "topi"]
192 .iter()
193 .all(|o| outputs.iter().any(|n| n == o))
194 {
195 let params = &meta["lm_params"][name];
196 let param = |key: &str, default: f64| params[key].as_f64().unwrap_or(default);
197 Some(Lm {
198 model: lm::NgramLm::load(&lm_path)?,
199 gate: param("gate", LM_GATE),
200 weights: lm::Weights {
201 alpha: param("alpha", LM_ALPHA),
202 beta: param("beta", LM_BETA),
203 beam: LM_BEAM,
204 cand_floor: LM_CAND_FLOOR,
205 },
206 })
207 } else {
208 None
209 };
210 let jamo = vocab.iter().any(|c| c == "\u{1100}");
211 Ok::<_, OCRError>(Recognizer {
212 model,
213 vocab,
214 jamo,
215 lm,
216 })
217 };
218 let mut groups = Vec::new();
221 if let Some(map) = meta.as_object() {
222 for name in map.keys().filter_map(|k| k.strip_prefix("vocab_")) {
223 let file = format!("rec_{name}.onnx");
224 if dir.join(&file).exists() {
225 let rec = recognizer(
226 name,
227 &file,
228 &format!("lm_{name}.npz"),
229 vocab(&format!("vocab_{name}"))?,
230 )?;
231 groups.push((name.to_string(), rec));
232 }
233 }
234 }
235 let script = if !groups.is_empty() && dir.join("script.onnx").exists() {
236 Some(model(dir, "script.onnx", &common)?)
237 } else {
238 None
239 };
240 let script_groups = match &meta["script_groups"] {
241 serde_json::Value::Null => GROUPS.map(str::to_string).to_vec(),
242 v => string_list(v, "meta.json: script_groups")?,
243 };
244 let optional = |name: &str| -> Result<Option<OrtInfer>, OCRError> {
245 if dir.join(name).exists() {
246 model(dir, name, &common).map(Some)
247 } else {
248 Ok(None)
249 }
250 };
251 Ok(IndraOcr {
252 det: model(dir, "det.onnx", &common)?,
253 base: recognizer("base", "rec.onnx", "lm.npz", vocab("vocab")?)?,
254 order: model(dir, "order.onnx", &common)?,
255 orient: optional("orient.onnx")?,
256 groups,
257 script,
258 script_groups,
259 math: if self.math {
260 Some(math::MathReader::load(dir, &common)?)
261 } else {
262 None
263 },
264 long_side: size("det_long_side", None)?,
265 rec_height: size("rec_height", None)?,
266 window: size("rec_window", Some(1))?,
267 keep_furniture: self.keep_furniture,
268 table_heads: self.table_heads,
269 })
270 }
271}
272
273#[derive(Debug)]
275struct Recognizer {
276 model: OrtInfer,
277 vocab: Vec<String>,
278 jamo: bool,
280 lm: Option<Lm>,
281}
282
283#[derive(Debug)]
285struct Lm {
286 model: lm::NgramLm,
287 gate: f64,
289 weights: lm::Weights,
290}
291
292#[derive(Debug)]
294pub struct IndraOcr {
295 det: OrtInfer,
296 base: Recognizer,
299 order: OrtInfer,
300 orient: Option<OrtInfer>,
301 groups: Vec<(String, Recognizer)>,
303 script: Option<OrtInfer>,
304 script_groups: Vec<String>,
305 math: Option<math::MathReader>,
306 long_side: usize,
307 rec_height: usize,
308 window: usize,
311 keep_furniture: bool,
312 table_heads: bool,
313}
314
315#[derive(Debug, Clone, Serialize)]
317#[non_exhaustive]
318pub struct IndraLine {
319 pub bbox: [f32; 4],
321 pub text: String,
323 pub confidence: f32,
325}
326
327#[derive(Debug, Clone, Serialize)]
329#[non_exhaustive]
330pub struct IndraBlock {
331 pub label: String,
335 pub bbox: [f32; 4],
340 pub text: String,
342 #[serde(skip)]
344 html: Option<String>,
345}
346
347#[derive(Debug, Clone, Serialize)]
349#[non_exhaustive]
350pub struct IndraPage {
351 pub index: usize,
353 pub width: u32,
355 pub height: u32,
357 pub rotation: u32,
360 pub skew: f32,
363 pub vertical: bool,
367 pub lines: Vec<IndraLine>,
369 pub blocks: Vec<IndraBlock>,
372 pub markdown: String,
375}
376
377impl IndraPage {
378 pub fn to_json(&self) -> serde_json::Value {
390 let blocks: Vec<_> = self
391 .blocks
392 .iter()
393 .enumerate()
394 .map(|(i, block)| {
395 let canonical = match block.label.as_str() {
396 "Text" => "text",
397 "Section-Header" => "paragraph_title",
398 "Page-Header" => "header",
399 "Page-Footer" => "footer",
400 "List-Group" => "list",
401 "Table" => "table",
402 "Caption" => "figure_table_chart_title",
403 "Footnote" => "footnote",
404 "Table-Of-Contents" => "content",
405 other => other,
406 };
407 let mut value = serde_json::json!({
408 "type": canonical,
409 "bbox": block.bbox.map(|v| (v as f64 * 10.0).round() / 10.0),
411 "order": i + 1,
412 "confidence": null,
413 "angle": null,
414 "content": block.html.as_deref().unwrap_or(&block.text),
415 });
416 if !block.label.eq_ignore_ascii_case(canonical) {
417 value["label"] = serde_json::Value::String(block.label.clone());
418 }
419 value
420 })
421 .collect();
422 serde_json::json!({
423 "version": 1,
424 "pipeline": "indra",
425 "page": {
426 "index": self.index,
427 "width": self.width,
428 "height": self.height,
429 "angle": (self.rotation != 0).then_some(self.rotation),
430 },
431 "blocks": blocks,
432 "markdown": self.markdown,
433 "raw_output": null,
434 "diagnostics": [],
435 })
436 }
437}
438
439impl IndraOcr {
440 pub fn predict(&self, images: Vec<RgbImage>) -> Result<Vec<IndraPage>, OCRError> {
443 if let Some(i) = images
444 .iter()
445 .position(|im| im.width() == 0 || im.height() == 0)
446 {
447 return Err(OCRError::invalid_input(format!(
448 "image {i} has no pixels ({}x{})",
449 images[i].width(),
450 images[i].height()
451 )));
452 }
453 images
454 .iter()
455 .enumerate()
456 .map(|(index, image)| {
457 let mut page = self.read_page(cv::rgb_to_gray(image))?;
458 page.index = index;
459 Ok(page)
460 })
461 .collect()
462 }
463
464 fn read_page(&self, gray: GrayImage) -> Result<IndraPage, OCRError> {
465 let small = preview(&gray);
466 let a = estimate_skew(&small);
467 let k = if a.abs() >= DESKEW_MIN {
470 self.orientation(&cv::rotate_u8(&small, a))?
471 } else {
472 self.orientation(&small)?
473 };
474 let vertical = k == VERTICAL;
475 let turns = if vertical { 3 } else { k % 4 };
479 let gray = cv::rot90_cw(&gray, turns);
480 let mut skew = if turns % 2 == 1 {
483 estimate_skew(&cv::rot90_cw(&small, turns))
484 } else {
485 a
486 };
487 let gray = if skew.abs() >= DESKEW_MIN {
488 cv::rotate_u8(&gray, skew)
489 } else {
490 skew = 0.0;
491 gray
492 };
493 let boxes = self.detect(&gray)?;
494 let lines = self.recognize(&gray, &boxes, vertical)?;
495 let mut page = self.assemble(gray.dimensions(), boxes, lines, vertical)?;
496 page.rotation = if vertical { 0 } else { 90 * turns };
497 page.skew = ((skew * 100.0).round_ties_even() / 100.0) as f32;
498 if skew != 0.0 {
499 let wh = gray.dimensions();
500 for line in &mut page.lines {
501 line.bbox = unrotate_box(line.bbox, skew, wh);
502 }
503 for block in &mut page.blocks {
504 block.bbox = unrotate_box(block.bbox, skew, wh);
505 }
506 }
507 if vertical {
508 let w = gray.height() as f64;
510 let back = |b: [f32; 4]| {
511 [
512 (w - b[3] as f64) as f32,
513 b[0],
514 (w - b[1] as f64) as f32,
515 b[2],
516 ]
517 };
518 for line in &mut page.lines {
519 line.bbox = back(line.bbox);
520 }
521 for block in &mut page.blocks {
522 block.bbox = back(block.bbox);
523 }
524 (page.width, page.height) = (gray.height(), gray.width());
525 page.vertical = true;
526 }
527 Ok(page)
528 }
529
530 fn orientation(&self, gray: &GrayImage) -> Result<u32, OCRError> {
533 let Some(orient) = &self.orient else {
534 return Ok(0);
535 };
536 let (w, h) = (gray.width() as f64, gray.height() as f64);
537 let s = ORIENT_SIDE as f64 / w.max(h);
538 let (sw, sh) = (
539 ((w * s).round_ties_even() as u32).max(1),
540 ((h * s).round_ties_even() as u32).max(1),
541 );
542 let small = cv::resize(gray, sw, sh, cv::Interp::Area);
543 let mut x = vec![0f32; ORIENT_SIDE * ORIENT_SIDE];
544 for (y, row) in small.as_raw().chunks_exact(sw as usize).enumerate() {
545 for (o, &v) in x[y * ORIENT_SIDE..].iter_mut().zip(row) {
546 *o = 1.0 - v as f32 / 255.0;
547 }
548 }
549 let p = run(
550 orient,
551 ort::inputs!["x" => Tensor::from_array(([1, 1, ORIENT_SIDE, ORIENT_SIDE], x)).map_err(input_error)?],
552 |o| Ok(o["p"].try_extract_tensor::<f32>()?.1.to_vec()),
553 )?;
554 let k = argmax(&p);
555 Ok(if p[k] > ORIENT_MIN { k as u32 } else { 0 })
556 }
557
558 fn detect_at(
559 &self,
560 gray: &GrayImage,
561 long_side: usize,
562 ) -> Result<(Vec<[f32; 5]>, f64), OCRError> {
563 let (x, h, w, scale) = detect::page_to_input(gray, long_side);
564 let boxes = run(
565 &self.det,
566 ort::inputs!["x" => Tensor::from_array(([1, 1, h, w], x)).map_err(input_error)?],
567 |o| {
568 let (shape, out) = o["out"].try_extract_tensor::<f32>()?;
569 Ok(detect::decode(out, shape[2] as usize, shape[3] as usize))
570 },
571 )?;
572 Ok((boxes, scale))
573 }
574
575 fn detect(&self, gray: &GrayImage) -> Result<Vec<[f32; 5]>, OCRError> {
577 let (mut boxes, mut scale) = self.detect_at(gray, self.long_side)?;
578 if boxes.len() >= 8 {
579 let mut heights: Vec<f32> = boxes.iter().map(|b| b[3] - b[1]).collect();
580 let med_h = order::median_f32(&mut heights);
581 if med_h < ADAPTIVE_MIN_H {
582 let target = (self.long_side as f32 * ADAPTIVE_TARGET_H / med_h.max(1.0)) as f64;
583 let ls = (target.min(ADAPTIVE_MAX_SIDE as f64) as usize) / 32 * 32;
584 if ls > self.long_side {
585 (boxes, scale) = self.detect_at(gray, ls)?;
586 }
587 }
588 }
589 let (w, h) = (gray.width() as f32, gray.height() as f32);
590 let s = scale as f32;
591 for b in &mut boxes {
592 b[0] = (b[0] / s).clamp(0.0, w);
593 b[1] = (b[1] / s).clamp(0.0, h);
594 b[2] = (b[2] / s).clamp(0.0, w);
595 b[3] = (b[3] / s).clamp(0.0, h);
596 }
597 Ok(boxes)
598 }
599
600 fn recognize(
607 &self,
608 gray: &GrayImage,
609 boxes: &[[f32; 5]],
610 vertical: bool,
611 ) -> Result<Vec<LineText>, OCRError> {
612 let height = self.rec_height;
613 let b4 = |b: &[f32; 5]| [b[0], b[1], b[2], b[3]];
614 let dark: Vec<bool> = boxes
616 .par_iter()
617 .map(|b| recognize::is_dark_line(gray, b4(b)))
618 .collect();
619 let crop_all = |window: usize| -> Vec<Crop> {
620 boxes
621 .par_iter()
622 .zip(&dark)
623 .map(|(b, &d)| recognize::crop_line(gray, b4(b), height, window, d))
624 .collect()
625 };
626 let crops = crop_all(1);
627 let tall = (self.window > 1).then(|| crop_all(self.window));
629 let n = crops.len();
630 let mut reads: Vec<(&Recognizer, Vec<usize>, bool)> =
632 vec![(&self.base, (0..n).collect(), false)];
633 let cjk = self.groups.iter().find(|(name, _)| name == "cjk");
634 if vertical && let Some((_, rec)) = cjk {
635 reads = vec![(rec, (0..n).collect(), true)];
638 } else if let Some(script) = &self.script
639 && n > 0
640 {
641 let (route, sure) = self.line_groups(script, &crops)?;
642 reads = vec![(
643 &self.base,
644 (0..n).filter(|&j| route[j].is_none() || !sure[j]).collect(),
645 false,
646 )];
647 for (g, (_, rec)) in self.groups.iter().enumerate() {
648 reads.push((rec, (0..n).filter(|&j| route[j] == Some(g)).collect(), true));
649 }
650 }
651 let mut out = vec![LineText::default(); n];
652 let mut best_conf = vec![-1.0; n];
653 for (rec, sel, char_tokens) in reads {
654 let base = std::ptr::eq(rec, &self.base);
655 let (src, rows) = match &tall {
656 Some(tall) if base => (tall, height * self.window),
657 _ => (&crops, height),
658 };
659 let widths: Vec<usize> = sel.iter().map(|&j| crops[j].width).collect();
660 for part in recognize::width_batches(&widths, REC_BATCH, 1.3, 64 * 640) {
661 let idx: Vec<usize> = part.iter().map(|&i| sel[i]).collect();
662 let batch: Vec<&Crop> = idx.iter().map(|&j| &src[j]).collect();
663 let (data, wm, crop_widths) = recognize::pad_batch(&batch, rows);
664 let b = batch.len();
665 let read = run(
666 &rec.model,
667 ort::inputs![
668 "x" => Tensor::from_array(([b, 1, rows, wm], data)).map_err(input_error)?,
669 "widths" => Tensor::from_array(([b], crop_widths)).map_err(input_error)?,
670 ],
671 |o| {
672 let (shape, ids) = o["ids"].try_extract_tensor::<i64>()?;
673 let (_, conf) = o["conf"].try_extract_tensor::<f32>()?;
674 let (_, lengths) = o["lengths"].try_extract_tensor::<i64>()?;
675 let lm = if rec.lm.is_some() {
676 let (_, blank) = o["blank"].try_extract_tensor::<f32>()?;
677 let (top_shape, topv) = o["topv"].try_extract_tensor::<f32>()?;
678 let (_, topi) = o["topi"].try_extract_tensor::<i64>()?;
679 Some((
680 blank.to_vec(),
681 topv.to_vec(),
682 topi.to_vec(),
683 top_shape[2] as usize,
684 ))
685 } else {
686 None
687 };
688 Ok((
689 ids.to_vec(),
690 conf.to_vec(),
691 lengths.to_vec(),
692 shape[1] as usize,
693 lm,
694 ))
695 },
696 )?;
697 let (ids, conf, lengths, t_len, lm_inputs) = read;
698 let stride = wm as f64 / t_len.max(1) as f64;
699 for (k, &j) in idx.iter().enumerate() {
700 let len = (lengths[k].max(0) as usize).min(t_len);
701 let row = k * t_len;
702 let seq = &ids[row..row + len];
703 let pm = &conf[row..row + len];
704 let mut keep: Vec<usize> = (0..len)
705 .filter(|&t| seq[t] != 0 && (t == 0 || seq[t] != seq[t - 1]))
706 .collect();
707 let [x0, y0, x1, _] =
708 [boxes[j][0], boxes[j][1], boxes[j][2], boxes[j][3]].map(f64::from);
709 let h = (boxes[j][3] as f64 - y0).max(1.0);
710 let ix0 = (x0 - recognize::PAD_X * h).floor();
711 let cw = (x1 + recognize::PAD_X * h).ceil() - ix0;
712 let f = cw / src[j].width as f64;
716 let line_conf = if keep.is_empty() {
717 0.0
718 } else {
719 mean_f32(&keep.iter().map(|&t| pm[t]).collect::<Vec<_>>())
720 };
721 let mut line_ids: Vec<i64> = keep.iter().map(|&t| seq[t]).collect();
722 if let (Some(lm), Some((blank, topv, topi, top_k))) = (&rec.lm, &lm_inputs)
723 && (MIN_CONF..lm.gate).contains(&line_conf)
724 {
725 let steps = lm::Steps {
726 blank: &blank[row..row + len],
727 topv: &topv[row * top_k..(row + len) * top_k],
728 topi: &topi[row * top_k..(row + len) * top_k],
729 k: *top_k,
730 };
731 (line_ids, keep) = lm::beam_search(&steps, &lm.model, lm.weights);
732 }
733 if line_conf <= best_conf[j] {
734 continue;
735 }
736 best_conf[j] = line_conf;
737 let tokens: Vec<Token> = line_ids
740 .iter()
741 .zip(&keep)
742 .map(|(&id, &t)| {
743 let text = usize::try_from(id - 1)
744 .ok()
745 .and_then(|i| rec.vocab.get(i))
746 .ok_or_else(|| {
747 OCRError::config_error(format!(
748 "{}: token id {id} is outside its {}-entry vocabulary",
749 rec.model.model_name(),
750 rec.vocab.len()
751 ))
752 })?;
753 Ok(Token {
754 text: text.clone(),
755 x: ix0 + (t as f64 + 0.5) * stride * f,
756 })
757 })
758 .collect::<Result<_, OCRError>>()?;
759 out[j] = if line_conf >= MIN_CONF {
760 let text = if char_tokens {
761 let text: String = tokens.iter().map(|t| t.text.as_str()).collect();
762 if rec.jamo { text.nfc().collect() } else { text }
763 } else {
764 recognize::join_tokens(
765 &tokens.iter().map(|t| t.text.as_str()).collect::<Vec<_>>(),
766 )
767 };
768 LineText {
769 text,
770 conf: line_conf,
771 tokens,
772 char_tokens,
773 }
774 } else {
775 LineText {
776 conf: line_conf,
777 char_tokens,
778 ..LineText::default()
779 }
780 };
781 }
782 }
783 }
784 if let Some(math) = &self.math
785 && !crops.is_empty()
786 {
787 let flagged: Vec<usize> = math
788 .flags(&crops, height)?
789 .iter()
790 .enumerate()
791 .filter_map(|(j, &f)| f.then_some(j))
792 .collect();
793 if !flagged.is_empty() {
794 let mboxes: Vec<[f32; 4]> = flagged.iter().map(|&j| b4(&boxes[j])).collect();
795 for (&j, (text, conf)) in flagged.iter().zip(math.read(gray, &mboxes)?) {
796 if text.contains('$') && conf >= math::MATH_MIN_CONF {
799 let x = (boxes[j][0] + boxes[j][2]) as f64 / 2.0;
800 out[j] = LineText {
801 text: text.clone(),
802 conf: 1.0,
803 tokens: vec![Token { text, x }],
804 char_tokens: true,
805 };
806 }
807 }
808 }
809 }
810 Ok(out)
811 }
812
813 fn line_groups(
817 &self,
818 script: &OrtInfer,
819 crops: &[Crop],
820 ) -> Result<(Vec<Option<usize>>, Vec<bool>), OCRError> {
821 let height = self.rec_height;
822 let mut route = vec![None; crops.len()];
823 let mut sure = vec![true; crops.len()];
824 for idx in recognize::line_batches(crops) {
825 let batch: Vec<&Crop> = idx.iter().map(|&j| &crops[j]).collect();
826 let (data, wm, widths) = recognize::pad_batch(&batch, height);
827 let n = batch.len();
828 let (p, classes) = run(
829 script,
830 ort::inputs![
831 "x" => Tensor::from_array(([n, 1, height, wm], data)).map_err(input_error)?,
832 "widths" => Tensor::from_array(([n], widths)).map_err(input_error)?,
833 ],
834 |o| {
835 let (shape, p) = o["p"].try_extract_tensor::<f32>()?;
836 Ok((p.to_vec(), shape.get(1).map(|&c| c as usize)))
837 },
838 )?;
839 for (k, &j) in idx.iter().enumerate() {
840 let (name, pk) = match classes {
841 None => {
842 let pc = p[k];
843 (if pc > 0.5 { "cjk" } else { "base" }, pc.max(1.0 - pc))
844 }
845 Some(c) => {
846 let row = &p[k * c..(k + 1) * c];
847 let g = argmax(row);
848 let name = self.script_groups.get(g).ok_or_else(|| {
849 OCRError::config_error(format!(
850 "script.onnx has {c} classes; meta.json script_groups names {}",
851 self.script_groups.len()
852 ))
853 })?;
854 (name.as_str(), row[g])
855 }
856 };
857 route[j] = self.groups.iter().position(|(g, _)| g == name);
858 sure[j] = pk > ROUTE_SURE;
859 }
860 }
861 Ok((route, sure))
862 }
863
864 fn assemble(
869 &self,
870 (pw, ph): (u32, u32),
871 boxes: Vec<[f32; 5]>,
872 lines: Vec<LineText>,
873 vertical: bool,
874 ) -> Result<IndraPage, OCRError> {
875 let boxes: Vec<[f32; 4]> = boxes.iter().map(|b| [b[0], b[1], b[2], b[3]]).collect();
876 let (letters, n_rtl) = lines
877 .iter()
878 .flat_map(|l| l.text.chars())
879 .filter(|&c| rtl::is_letter(c))
880 .fold((0, 0), |(n, r), c| (n + 1, r + rtl::is_rtl(c) as usize));
881 let page_rtl = n_rtl > letters - n_rtl;
882 let width = pw as f32;
883 let geo = |b: [f32; 4]| {
884 if page_rtl {
885 [width - b[2], b[1], width - b[0], b[3]]
886 } else {
887 b
888 }
889 };
890 let cuts = recognize::gutter_cuts(&boxes, &lines);
891 let (mut nb, mut nl) = (Vec::new(), Vec::new());
892 for ((b, line), at) in boxes.iter().zip(&lines).zip(&cuts) {
893 for (bx, text, tokens) in recognize::split_line(*b, line, SPLIT_GAP, at, page_rtl) {
894 nb.push(bx);
895 nl.push(LineText {
896 text,
897 conf: line.conf,
898 tokens,
899 char_tokens: line.char_tokens,
900 });
901 }
902 }
903 let space = self.base.vocab.iter().any(|c| c == " ");
904 let (mut boxes, mut lines) = recognize::tidy_lines(nb, nl, page_rtl, space);
905 let mut texts: Vec<String> = lines.iter().map(|l| l.text.clone()).collect();
906 let round1 = |v: f32| ((v as f64 * 10.0).round_ties_even() / 10.0) as f32;
907 let mut page = IndraPage {
908 index: 0,
909 width: pw,
910 height: ph,
911 rotation: 0,
912 skew: 0.0,
913 vertical: false,
914 lines: Vec::new(),
915 blocks: Vec::new(),
916 markdown: String::new(),
917 };
918 if boxes.len() < 2 {
919 page.lines = boxes
920 .iter()
921 .zip(&lines)
922 .map(|(b, l)| IndraLine {
923 bbox: b.map(round1),
924 text: l.text.clone(),
925 confidence: ((l.conf * 1e4).round_ties_even() / 1e4) as f32,
926 })
927 .collect();
928 page.markdown = texts.join("\n");
929 return Ok(page);
930 }
931 let mut geo_boxes: Vec<[f32; 4]> = boxes.iter().map(|&b| geo(b)).collect();
932 let mut layout = self.layout(&geo_boxes, &texts, (pw, ph))?;
933 let mut n = boxes.len();
934 if vertical {
935 let blocks = order::xycut_blocks(&boxes);
939 layout.same = vec![-1.0; n * n];
940 for b in &blocks {
941 for &i in b {
942 for &j in b {
943 layout.same[i * n + j] = 1.0;
944 }
945 }
946 }
947 let nc = order::BLOCK_CLASSES.len();
948 for row in layout.cls.chunks_exact_mut(nc) {
949 for c in order::GRID_CLASSES {
950 row[c] = -1e4;
951 }
952 }
953 layout.order = blocks.concat();
954 layout.tab = None;
955 } else if layout.tab.is_some() {
956 let nc = order::BLOCK_CLASSES.len();
960 let is_table: Vec<bool> = layout
961 .cls
962 .chunks_exact(nc)
963 .map(|row| argmax(row) == layout::TABLE)
964 .collect();
965 if is_table.contains(&true) {
966 let (mut nb, mut nl) = (Vec::new(), Vec::new());
967 for ((b, line), table) in boxes.iter().zip(&lines).zip(is_table) {
968 if !table {
969 nb.push(*b);
970 nl.push(line.clone());
971 continue;
972 }
973 for (bx, text, tokens) in
974 recognize::split_line(*b, line, CELL_GAP, &[], page_rtl)
975 {
976 nb.push(bx);
977 nl.push(LineText {
978 text,
979 conf: line.conf,
980 tokens,
981 char_tokens: line.char_tokens,
982 });
983 }
984 }
985 if nb.len() != n {
986 (boxes, lines) = (nb, nl);
987 texts = lines.iter().map(|l| l.text.clone()).collect();
988 geo_boxes = boxes.iter().map(|&b| geo(b)).collect();
989 layout = self.layout(&geo_boxes, &texts, (pw, ph))?;
990 n = boxes.len();
991 }
992 }
993 }
994 let Layout {
995 order: reading,
996 cls,
997 same,
998 tab,
999 } = layout;
1000 page.lines = reading
1001 .iter()
1002 .map(|&i| IndraLine {
1003 bbox: boxes[i].map(round1),
1004 text: lines[i].text.clone(),
1005 confidence: ((lines[i].conf * 1e4).round_ties_even() / 1e4) as f32,
1006 })
1007 .collect();
1008 let cells: Vec<Vec<([f32; 4], String)>> = boxes
1010 .iter()
1011 .zip(&lines)
1012 .map(|(b, l)| {
1013 recognize::split_line(*b, l, CELL_GAP, &[], page_rtl)
1014 .into_iter()
1015 .map(|(bx, text, _)| (geo(bx), text))
1016 .collect()
1017 })
1018 .collect();
1019 let blocks = layout::page_blocks(
1020 &geo_boxes,
1021 &texts,
1022 &reading,
1023 &layout::Heads {
1024 cls: &cls,
1025 same: order::Square { n, data: &same },
1026 tab: tab.as_deref().map(|data| layout::TableHeads { n, data }),
1027 },
1028 self.keep_furniture,
1029 &cells,
1030 );
1031 page.markdown = layout::page_markdown(&blocks);
1032 let round = |v: f32| (v as f64 * 10.0).round_ties_even() / 10.0;
1033 page.blocks = blocks
1034 .into_iter()
1035 .map(|block| {
1036 let geo_boxes = &geo_boxes;
1037 let bx = |k: usize| block.lines.iter().map(move |&i| geo_boxes[i][k]);
1038 let mut bbox = [
1039 round(bx(0).fold(f32::INFINITY, f32::min)),
1040 round(bx(1).fold(f32::INFINITY, f32::min)),
1041 round(bx(2).fold(f32::NEG_INFINITY, f32::max)),
1042 round(bx(3).fold(f32::NEG_INFINITY, f32::max)),
1043 ];
1044 if page_rtl {
1045 let w = pw as f64;
1046 (bbox[0], bbox[2]) = (w - bbox[2], w - bbox[0]);
1047 }
1048 IndraBlock {
1049 label: layout::label(block.class).to_string(),
1050 bbox: bbox.map(|v| round1(v as f32)),
1051 text: block
1052 .lines
1053 .iter()
1054 .map(|&i| texts[i].as_str())
1055 .collect::<Vec<_>>()
1056 .join("\n"),
1057 html: block.html,
1058 }
1059 })
1060 .collect();
1061 Ok(page)
1062 }
1063}
1064
1065struct Layout {
1067 order: Vec<usize>,
1068 cls: Vec<f32>,
1070 same: Vec<f32>,
1072 tab: Option<Vec<f32>>,
1074}
1075
1076impl IndraOcr {
1077 fn layout(
1081 &self,
1082 boxes: &[[f32; 4]],
1083 texts: &[String],
1084 wh: (u32, u32),
1085 ) -> Result<Layout, OCRError> {
1086 let n = boxes.len();
1087 let feats = order::line_features(boxes, wh, texts);
1088 let (a, cls, same, tab) = run(
1089 &self.order,
1090 ort::inputs![
1091 "feats" => Tensor::from_array(([1, n, order::N_FEATS], feats)).map_err(input_error)?,
1092 "mask" => Tensor::from_array(([1, n], vec![true; n])).map_err(input_error)?,
1093 ],
1094 |o| {
1095 let (_, a) = o["a"].try_extract_tensor::<f32>()?;
1096 let (_, cls) = o["cls"].try_extract_tensor::<f32>()?;
1097 let (_, same) = o["same"].try_extract_tensor::<f32>()?;
1098 let tab = match o.get("tab") {
1099 Some(tab) if self.table_heads => {
1100 Some(tab.try_extract_tensor::<f32>()?.1.to_vec())
1101 }
1102 _ => None,
1103 };
1104 Ok((a.to_vec(), cls.to_vec(), same.to_vec(), tab))
1105 },
1106 )?;
1107 let order = order::decode_blocks_order(
1108 &order::Square { n, data: &a },
1109 &order::Square { n, data: &same },
1110 boxes,
1111 &cls,
1112 );
1113 Ok(Layout {
1114 order,
1115 cls,
1116 same,
1117 tab,
1118 })
1119 }
1120}
1121
1122fn argmax(values: &[f32]) -> usize {
1124 let mut k = 0;
1125 for (i, v) in values.iter().enumerate() {
1126 if *v > values[k] {
1127 k = i;
1128 }
1129 }
1130 k
1131}
1132
1133fn mean_f32(values: &[f32]) -> f64 {
1136 fn pairwise(a: &[f32]) -> f32 {
1137 let n = a.len();
1138 if n < 8 {
1139 a.iter().fold(0.0, |s, &v| s + v)
1140 } else if n <= 128 {
1141 let mut r: [f32; 8] = a[..8].try_into().expect("8 values");
1142 let full = n - n % 8;
1143 for chunk in a[8..full].as_chunks::<8>().0 {
1144 for (r, v) in r.iter_mut().zip(chunk) {
1145 *r += v;
1146 }
1147 }
1148 let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
1149 for v in &a[full..] {
1150 res += v;
1151 }
1152 res
1153 } else {
1154 let half = n / 2;
1155 let half = half - half % 8;
1156 pairwise(&a[..half]) + pairwise(&a[half..])
1157 }
1158 }
1159 (pairwise(values) / values.len() as f32) as f64
1160}
1161
1162fn preview(gray: &GrayImage) -> Cow<'_, GrayImage> {
1166 let (w, h) = (gray.width() as f64, gray.height() as f64);
1167 let s = PREVIEW_SIDE / w.max(h);
1168 if s >= 1.0 {
1169 return Cow::Borrowed(gray);
1170 }
1171 let (sw, sh) = (
1172 ((w * s).round_ties_even() as u32).max(1),
1173 ((h * s).round_ties_even() as u32).max(1),
1174 );
1175 Cow::Owned(cv::resize(gray, sw, sh, cv::Interp::Area))
1176}
1177
1178fn ink_thumb(gray: &GrayImage, side: f64) -> GrayImage {
1180 let (w, h) = (gray.width() as f64, gray.height() as f64);
1181 let s = side / w.max(h);
1182 let (sw, sh) = (
1183 ((w * s).round_ties_even() as u32).max(1),
1184 ((h * s).round_ties_even() as u32).max(1),
1185 );
1186 let mut small = cv::resize(gray, sw, sh, cv::Interp::Area);
1187 for v in small.iter_mut() {
1188 *v = 255 - *v;
1189 }
1190 small
1191}
1192
1193fn row_sharpness(ink: &GrayImage, a: f64) -> f64 {
1197 let r = cv::rotate_u8_zero(ink, a);
1198 let rows: Vec<i64> = r
1199 .as_raw()
1200 .chunks_exact(ink.width() as usize)
1201 .map(|row| row.iter().map(|&v| v as i64).sum())
1202 .collect();
1203 rows.windows(2).map(|p| (p[1] - p[0]).pow(2)).sum::<i64>() as f64
1204}
1205
1206fn estimate_skew(gray: &GrayImage) -> f64 {
1212 let fine = ink_thumb(gray, 800.0);
1213 let raw = fine.as_raw();
1216 let mean = raw.iter().map(|&v| v as u64).sum::<u64>() as f64 / raw.len() as f64;
1217 if fine.height() < 2 || mean < 0.003 * 255.0 || raw.iter().all(|&v| v == raw[0]) {
1218 return 0.0;
1219 }
1220 let coarse = ink_thumb(gray, 400.0);
1221 let grid = arange(-DESKEW_MAX, DESKEW_MAX + 1e-6, 2.0);
1222 let scores: Vec<f64> = grid
1223 .par_iter()
1224 .map(|&a| row_sharpness(&coarse, a))
1225 .collect();
1226 let mut best: Vec<usize> = (0..grid.len()).collect();
1227 best.sort_by(|&i, &j| scores[i].total_cmp(&scores[j]));
1228 let starts = [0.0, grid[best[best.len() - 2]], grid[best[best.len() - 1]]];
1229 let near: Vec<f64> = starts
1230 .iter()
1231 .flat_map(|&a0| arange(a0 - 2.0, a0 + 2.0 + 1e-6, 0.5))
1232 .collect();
1233 let mut angles: Vec<f64> = Vec::new();
1236 for &a in &near {
1237 if !angles.contains(&a) {
1238 angles.push(a);
1239 }
1240 }
1241 let fine_scores: Vec<f64> = angles
1242 .par_iter()
1243 .map(|&a| row_sharpness(&fine, a))
1244 .collect();
1245 let score = |a: f64| fine_scores[angles.iter().position(|&b| b == a).expect("scored")];
1246 let first_best = |angles: &[f64], score: &dyn Fn(f64) -> f64| {
1247 let mut best = angles[0];
1248 for &a in angles {
1249 if score(a) > score(best) {
1250 best = a;
1251 }
1252 }
1253 best
1254 };
1255 let a1 = first_best(&near, &score);
1256 let last = arange(a1 - 0.5, a1 + 0.5 + 1e-6, 0.1);
1257 let last_scores: Vec<f64> = last.par_iter().map(|&a| row_sharpness(&fine, a)).collect();
1258 last[argmax_f64(&last_scores)]
1259}
1260
1261fn argmax_f64(values: &[f64]) -> usize {
1263 let mut k = 0;
1264 for (i, v) in values.iter().enumerate() {
1265 if *v > values[k] {
1266 k = i;
1267 }
1268 }
1269 k
1270}
1271
1272fn arange(start: f64, stop: f64, step: f64) -> Vec<f64> {
1276 let n = ((stop - start) / step).ceil().max(0.0) as usize;
1277 let delta = (start + step) - start;
1278 (0..n).map(|i| start + i as f64 * delta).collect()
1279}
1280
1281fn unrotate_box(b: [f32; 4], degrees: f64, (w, h): (u32, u32)) -> [f32; 4] {
1284 let (w, h) = (w as f64, h as f64);
1285 let m = cv::rotation_matrix(w / 2.0, h / 2.0, -degrees);
1286 let [x0, y0, x1, y1] = b.map(f64::from);
1287 let corners = [(x0, y0), (x1, y0), (x0, y1), (x1, y1)];
1288 let xs = corners.map(|(x, y)| m[0] * x + m[1] * y + m[2]);
1289 let ys = corners.map(|(x, y)| m[3] * x + m[4] * y + m[5]);
1290 let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min);
1291 let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1292 let r = |v: f64, hi: f64| ((v.clamp(0.0, hi) * 10.0).round_ties_even() / 10.0) as f32;
1293 [r(min(xs), w), r(min(ys), h), r(max(xs), w), r(max(ys), h)]
1294}
1295
1296fn model(dir: &Path, name: &str, common: &ModelInferenceConfig) -> Result<OrtInfer, OCRError> {
1298 let mut common = common.clone();
1299 common.model_name = Some(format!("indra {name}"));
1300 OrtInfer::from_config(&common, dir.join(name), None)
1301}
1302
1303fn string_list(value: &serde_json::Value, what: &str) -> Result<Vec<String>, OCRError> {
1305 let list: Option<Vec<String>> = value.as_array().filter(|a| !a.is_empty()).and_then(|a| {
1306 a.iter()
1307 .map(|v| v.as_str().filter(|s| !s.is_empty()).map(str::to_string))
1308 .collect()
1309 });
1310 list.ok_or_else(|| {
1311 OCRError::config_error(format!(
1312 "{what} must be a non-empty list of non-empty strings"
1313 ))
1314 })
1315}
1316
1317fn read_json(path: &Path) -> Result<serde_json::Value, OCRError> {
1318 let text = std::fs::read_to_string(path).map_err(|e| {
1319 OCRError::model_load_error(path, "cannot read", Some("check the model folder"), Some(e))
1320 })?;
1321 serde_json::from_str(&text)
1322 .map_err(|e| OCRError::config_error(format!("{}: {e}", path.display())))
1323}
1324
1325fn run<R>(
1328 model: &OrtInfer,
1329 inputs: Vec<(Cow<'_, str>, SessionInputValue<'_>)>,
1330 read: impl FnOnce(&SessionOutputs<'_>) -> ort::Result<R>,
1331) -> Result<R, OCRError> {
1332 let mut session = model.get_session(0)?;
1333 let outputs = match model.run_options() {
1335 Some(options) => session.run_with_options(inputs, options),
1336 None => session.run(inputs),
1337 }
1338 .map_err(|e| OCRError::inference_error(model.model_name(), "forward pass", e))?;
1339 read(&outputs).map_err(|e| OCRError::inference_error(model.model_name(), "reading outputs", e))
1340}
1341
1342fn input_error(e: ort::Error) -> OCRError {
1344 OCRError::invalid_input(format!("Indra OCR model input: {e}"))
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350
1351 #[test]
1355 fn deskew_angles_follow_numpy_arange() {
1356 let angles = arange(-0.5, 0.5 + 1e-6, 0.1);
1357 assert_eq!(angles.len(), 11);
1358 assert!(angles[2].abs() >= DESKEW_MIN);
1359 }
1360}