Skip to main content

oar_ocr/indra/
mod.rs

1//! Indra OCR: Datalab's CPU-first page reader on ONNX Runtime.
2//!
3//! [Indra OCR](https://huggingface.co/datalab-to/indra-ocr) is a set of small
4//! ONNX models that turn a page image into lines with boxes, layout blocks
5//! in reading order, and Markdown: a page orientation classifier, a line
6//! detector, a script classifier that routes each line to the recognizer
7//! of its script group (Latin, Cyrillic and Greek; Chinese, Japanese and
8//! Korean; Arabic and Hebrew; Indic, Southeast Asian and other scripts),
9//! n-gram language models for lines a recognizer is unsure of, a
10//! reading-order model with layout heads, and an optional math reader that
11//! writes lines with math as inline LaTeX.
12//!
13//! [`IndraOcr`] loads the models from a local copy of the Hugging Face
14//! repository: `det.onnx`, `rec.onnx`, `order.onnx` and `meta.json`, with
15//! `orient.onnx`, `script.onnx`, the group recognizers `rec_<group>.onnx`
16//! and the language models `lm.npz` and `lm_<group>.npz` used when present.
17//! It reproduces the reference pipeline's OCR mode (`indra-ocr` 1.1.2;
18//! the earlier v3.1 model set loads too) with two fixes: token positions
19//! of a tall-window recognizer map back through the crop it read, and a
20//! table built from the table heads never spans over a neighbouring cell. Reading a PDF's own text layer is
21//! not supported: render PDF pages to images first.
22//!
23//! The model weights use a modified OpenRAIL-M license; see the model
24//! repository before using them commercially.
25//!
26//! ```no_run
27//! use oar_ocr::indra::IndraOcrBuilder;
28//! use oar_ocr::utils::load_image;
29//!
30//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
31//! let ocr = IndraOcrBuilder::new("models/datalab-to/indra-ocr").build()?;
32//! let page = ocr.predict(vec![load_image("page.png")?])?.remove(0);
33//! println!("{}", page.markdown);
34//! for line in &page.lines {
35//!     println!("{:?} {}", line.bbox, line.text);
36//! }
37//! # Ok(())
38//! # }
39//! ```
40
41mod 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
63/// A median line height (detector input pixels) under this triggers a
64/// second detection pass at a larger scale.
65const ADAPTIVE_MIN_H: f32 = 9.0;
66const ADAPTIVE_TARGET_H: f32 = 12.0;
67const ADAPTIVE_MAX_SIDE: usize = 2048;
68/// Cut a detected line at a gap this many line heights wide (between CTC
69/// character centers).
70const SPLIT_GAP: f64 = 3.0;
71/// Cut a table line into cells at a gap this many line heights wide.
72const CELL_GAP: f64 = 1.8;
73/// `orient.onnx` input side: the page thumbnail, padded square.
74const ORIENT_SIDE: usize = 768;
75/// A quarter or half turn is applied only above this probability.
76const ORIENT_MIN: f32 = 0.95;
77/// The `orient.onnx` class (of five) of a page set in vertical columns.
78const VERTICAL: u32 = 4;
79/// The skew search and the orientation model read the page shrunk to at
80/// most this many pixels a side.
81const PREVIEW_SIDE: f64 = 1600.0;
82/// A recognized line under this mean CTC peak probability gets no text.
83const MIN_CONF: f64 = 0.5;
84/// Degrees of tilt searched by the deskew (quarter turns cover the rest).
85const DESKEW_MAX: f64 = 45.0;
86/// Smaller tilts are left alone.
87const DESKEW_MIN: f64 = 0.3;
88/// Lines per recognizer batch.
89const REC_BATCH: usize = 64;
90/// A line goes to one group's recognizer when the script classifier is
91/// surer than this; otherwise the main recognizer reads it as well and the
92/// more confident reading wins.
93const ROUTE_SURE: f32 = 0.95;
94/// Script groups in `script.onnx`'s class order unless `meta.json` names
95/// them (`script_groups`). `base` is `rec.onnx`.
96const GROUPS: [&str; 4] = ["base", "cjk", "rtl", "other"];
97/// Language-model defaults (`meta.json` `lm_params` overrides them per
98/// group): lines under the gate confidence are read by beam search.
99const LM_GATE: f64 = 0.9;
100const LM_ALPHA: f64 = 0.5;
101const LM_BETA: f64 = 1.5;
102const LM_BEAM: usize = 8;
103/// A class under this probability at a step is not tried by the beam search.
104const LM_CAND_FLOOR: f64 = 3e-3;
105
106/// Builder for [`IndraOcr`].
107#[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    /// Starts a builder for the models in `model_dir`, a local copy of the
118    /// `datalab-to/indra-ocr` Hugging Face repository.
119    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    /// Reads lines that hold math as text with inline LaTeX (off by default;
130    /// slower on math pages). Needs `mathcls.onnx`, `math_enc.onnx`,
131    /// `math_dec_init.onnx`, `math_dec_step.onnx` and `math_meta.json`.
132    pub fn math(mut self, enable: bool) -> Self {
133        self.math = enable;
134        self
135    }
136
137    /// Keeps page headers and footers in the Markdown (left out by default;
138    /// they are always listed in [`IndraPage::blocks`]).
139    pub fn keep_furniture(mut self, enable: bool) -> Self {
140        self.keep_furniture = enable;
141        self
142    }
143
144    /// Builds tables from the order model's row, column and cell heads
145    /// instead of from line geometry (off by default, as in the reference).
146    /// Table lines are first split into cells and the order model runs
147    /// again; a table whose cell head joins side-by-side lines falls back
148    /// to geometry. Needs an `order.onnx` with the `tab` output; without
149    /// it, tables come from geometry.
150    pub fn table_heads(mut self, enable: bool) -> Self {
151        self.table_heads = enable;
152        self
153    }
154
155    /// Sets the ONNX Runtime session configuration (execution providers,
156    /// threads) applied to every model.
157    pub fn ort_session(mut self, config: OrtSessionConfig) -> Self {
158        self.ort_session = Some(config);
159        self
160    }
161
162    /// Loads the models.
163    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        // A recognizer, with its language model when the folder has one and
185        // the recognizer exports the beam search's inputs.
186        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        // A script group is read by its own recognizer when the folder has
219        // it; otherwise by rec.onnx.
220        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/// A CTC line recognizer and its vocabulary.
274#[derive(Debug)]
275struct Recognizer {
276    model: OrtInfer,
277    vocab: Vec<String>,
278    /// The vocabulary reads Hangul as jamo, composed back into syllables.
279    jamo: bool,
280    lm: Option<Lm>,
281}
282
283/// A recognizer's language model and when to use it.
284#[derive(Debug)]
285struct Lm {
286    model: lm::NgramLm,
287    /// Lines under this confidence are read by beam search.
288    gate: f64,
289    weights: lm::Weights,
290}
291
292/// The Indra OCR pipeline. Build it with [`IndraOcrBuilder`].
293#[derive(Debug)]
294pub struct IndraOcr {
295    det: OrtInfer,
296    /// `rec.onnx`: Latin, Cyrillic and Greek, and every line no group
297    /// recognizer reads.
298    base: Recognizer,
299    order: OrtInfer,
300    orient: Option<OrtInfer>,
301    /// The script groups' recognizers.
302    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    /// `rec.onnx` reads this many line bands: the line and the page above
309    /// and below it.
310    window: usize,
311    keep_furniture: bool,
312    table_heads: bool,
313}
314
315/// One recognized line.
316#[derive(Debug, Clone, Serialize)]
317#[non_exhaustive]
318pub struct IndraLine {
319    /// `[x0, y0, x1, y1]` in pixels of the upright page.
320    pub bbox: [f32; 4],
321    /// The line's text; empty when the recognizer was not confident.
322    pub text: String,
323    /// Mean CTC peak probability (1.0 for a math reading).
324    pub confidence: f32,
325}
326
327/// One layout block.
328#[derive(Debug, Clone, Serialize)]
329#[non_exhaustive]
330pub struct IndraBlock {
331    /// The block class: `Text`, `Section-Header`, `Page-Header`,
332    /// `Page-Footer`, `List-Group`, `Table`, `Caption`, `Footnote`,
333    /// `Table-Of-Contents` or `Form`.
334    pub label: String,
335    /// `[x0, y0, x1, y1]` in pixels of the upright page: the union of the
336    /// block's lines, or on a straightened page that union's rectangle
337    /// mapped back across the skew (as the reference does), which can be
338    /// slightly larger than the union of the mapped line boxes.
339    pub bbox: [f32; 4],
340    /// The block's lines, newline-joined.
341    pub text: String,
342    /// The table HTML of a table block.
343    #[serde(skip)]
344    html: Option<String>,
345}
346
347/// One page read by [`IndraOcr`].
348#[derive(Debug, Clone, Serialize)]
349#[non_exhaustive]
350pub struct IndraPage {
351    /// Position of the page in the [`IndraOcr::predict`] batch.
352    pub index: usize,
353    /// Width of the upright page the boxes are in.
354    pub width: u32,
355    /// Height of the upright page the boxes are in.
356    pub height: u32,
357    /// Degrees counter-clockwise the input was turned (0, 90, 180 or 270);
358    /// the page was turned upright before reading.
359    pub rotation: u32,
360    /// Degrees of tilt straightened before reading; boxes are mapped back
361    /// to the page before the straightening.
362    pub skew: f32,
363    /// The page is set in vertical columns (Chinese, Japanese): it was read
364    /// turned a quarter turn, each column a line, and its boxes are on the
365    /// page as it is ([`rotation`](Self::rotation) stays 0).
366    pub vertical: bool,
367    /// Lines in reading order.
368    pub lines: Vec<IndraLine>,
369    /// Layout blocks in reading order (empty for a page with fewer than two
370    /// lines).
371    pub blocks: Vec<IndraBlock>,
372    /// Headings, paragraphs, lists, and tables as HTML. Page headers and
373    /// footers are left out unless [`IndraOcrBuilder::keep_furniture`].
374    pub markdown: String,
375}
376
377impl IndraPage {
378    /// Exports this page in the shared page JSON format
379    /// (`docs/page-format.md`) with `pipeline` `"indra"`.
380    ///
381    /// Block labels map onto the canonical vocabulary (`Section-Header` →
382    /// `paragraph_title`, `Page-Header` → `header`, `Page-Footer` →
383    /// `footer`, `List-Group` → `list`, `Caption` →
384    /// `figure_table_chart_title`, `Table-Of-Contents` → `content`), with
385    /// the original label in `label`; `Form` passes through. Table blocks
386    /// carry their HTML. Boxes are in the upright page, whose size is
387    /// `page.width` and `page.height`, and `page.angle` is
388    /// [`rotation`](Self::rotation) when the page was turned.
389    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                    // Boxes are rounded to 0.1 px; widen without float noise.
410                    "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    /// Reads pages; one [`IndraPage`] per image, in input order. An empty
441    /// image is an [`OCRError::InvalidInput`].
442    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        // The orientation model reads the page straightened by its row
468        // angle (a tilted page could read as turned).
469        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        // Quarter turns clockwise to read the page in: a vertical page
476        // turns a quarter counter-clockwise, so its columns become lines,
477        // the first (right) column on top.
478        let turns = if vertical { 3 } else { k % 4 };
479        let gray = cv::rot90_cw(&gray, turns);
480        // A half turn keeps the rows, so the angle stands; after a quarter
481        // turn the rows were columns, so it is measured again.
482        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            // Back on the page as it is: (x, y) -> (W - y, x).
509            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    /// Quarter turns (counter-clockwise) the page content is turned by, or
531    /// [`VERTICAL`] for a page set in vertical columns.
532    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    /// Line boxes `[x0, y0, x1, y1, score]` in page pixels.
576    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    /// Text, confidence and tokens per line; each token's x is its CTC peak
601    /// mapped back through the crop. Each line goes to the recognizer of
602    /// its script group (every line of a vertical page to the CJK one); a
603    /// line the script classifier is unsure of is also read by `rec.onnx`,
604    /// and the more confident reading wins. A line under `MIN_CONF` comes
605    /// back empty.
606    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        // Light-on-dark is decided once per line box, for every crop.
615        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        // rec.onnx may read a tall window: the line and the page around it.
628        let tall = (self.window > 1).then(|| crop_all(self.window));
629        let n = crops.len();
630        // (recognizer, the lines it reads, whether its tokens are characters)
631        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            // A vertical page's lines are CJK columns, characters on their
636            // side; the CJK recognizer reads those.
637            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                    // Timesteps map back through the crop the recognizer
713                    // read (the reference scales by the one-band crop even
714                    // for a tall window, which stretches token positions).
715                    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                    // An id past the vocabulary means meta.json does not
738                    // belong to this recognizer.
739                    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                    // Only a confident reading with math replaces the line,
797                    // as one token that is never split.
798                    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    /// Per line crop, the script group whose recognizer reads it (`None`:
814    /// `rec.onnx`), and whether the script classifier is sure of it. An
815    /// older `script.onnx` has one output: the chance of Chinese characters.
816    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    /// Lines split at wide gaps, tidied, reading order, layout blocks and
865    /// Markdown. A right-to-left page reads its lines right to left, and
866    /// its reading order and Markdown work on the page mirrored left to
867    /// right, so the first column is the right one.
868    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            // The order model saw no vertical pages: turned, their columns
936            // of short lines look like one table read row by row. Blocks and
937            // order come from XY-cut instead, without grid classes.
938            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            // The table heads learned one line per cell, but the detector
957            // can join cells: split table lines into cells, then run the
958            // order model again.
959            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        // Inside tables, cells split at smaller gaps.
1009        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
1065/// The order model's reading of a page's lines.
1066struct Layout {
1067    order: Vec<usize>,
1068    /// Block class logits, `[n, classes]`.
1069    cls: Vec<f32>,
1070    /// Same-block logits, `[n, n]`.
1071    same: Vec<f32>,
1072    /// Same row, column and cell logits, `[3, n, n]`, with the table heads.
1073    tab: Option<Vec<f32>>,
1074}
1075
1076impl IndraOcr {
1077    /// Runs the order model on line boxes (two or more) and decodes the
1078    /// reading order: blocks from the same-block head, lines inside a block
1079    /// by geometry, blocks by the order head.
1080    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
1122/// The first index of the largest value.
1123fn 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
1133/// numpy's mean of float32 values: a pairwise sum and a division in
1134/// float32.
1135fn 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
1162/// The page shrunk once to at most `PREVIEW_SIDE` pixels a side (area
1163/// averaging): the skew search and the orientation model read it instead
1164/// of the full scan.
1165fn 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
1178/// The ink map (255 = ink) of the page shrunk to `side` pixels a side.
1179fn 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
1193/// How sharply the horizontal ink profile changes row to row with the page
1194/// turned by `a` degrees: highest when the text rows are level. Integer row
1195/// sums keep the score exact.
1196fn 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
1206/// Degrees (counter-clockwise) that straighten the text rows of a page:
1207/// the angle whose horizontal ink profile changes most sharply row to row.
1208/// Searched over ±`DESKEW_MAX` in 2 degree steps on a 400 px thumbnail;
1209/// the two best angles and 0 are then refined in 0.5 and 0.1 degree steps
1210/// on an 800 px thumbnail, where small text rows are still resolved.
1211fn estimate_skew(gray: &GrayImage) -> f64 {
1212    let fine = ink_thumb(gray, 800.0);
1213    // A one-row thumbnail has no row profile, and a blank or flat page has
1214    // no rows to straighten.
1215    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    // The 0.5 degree ranges of nearby starts overlap: each angle is scored
1234    // once.
1235    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
1261/// The first index of the largest value.
1262fn 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
1272/// `numpy.arange`: values `start + i * delta` with `delta` taken from the
1273/// first two values, so the angles (and the deskew threshold test on them)
1274/// match the reference to the last bit.
1275fn 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
1281/// A box of the page `rotate_u8(.., degrees)` made, mapped back to the page
1282/// before it: the bounding box of the four mapped corners, clipped to it.
1283fn 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
1296/// Loads one model of the folder.
1297fn 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
1303/// A non-empty JSON array of non-empty strings (a vocabulary).
1304fn 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
1325/// Runs a model on named inputs and reads its outputs while the session is
1326/// held.
1327fn 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    // Keep the run options (CUDA arena shrinkage) that `OrtInfer` applies.
1334    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
1342/// Maps an input tensor that ONNX Runtime rejects to an error.
1343fn 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    /// numpy's arange makes the third fine angle around 0 read
1352    /// -0.30000000000000004, which passes the deskew threshold; `-0.5 + 0.2`
1353    /// would not.
1354    #[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}