Skip to main content

monocr_onnx/
monocr.rs

1//! Main OCR implementation
2//!
3//! This module contains the core OCR functionality including the MonOcr struct,
4//! the builder pattern for configuration, and the prediction/inference logic.
5
6use anyhow::{Context, Result};
7use image::{imageops::FilterType, GrayImage};
8use ndarray::Array4;
9use ort::session::{builder::GraphOptimizationLevel, Session};
10use std::borrow::Cow;
11use std::fmt;
12use std::path::{Path, PathBuf};
13
14use crate::model_manager::ModelManager;
15use crate::segmenter::{tile_line, LineSegment, LineSegmenter, DEFAULT_DENSITY_THRESHOLD_RATIO};
16use crate::utils::calculate_accuracy;
17use crate::OcrResult;
18
19/// Default embedded charset
20///
21/// This constant includes the default character set for Mon OCR, embedded from
22/// the charset.txt file at compile time. It contains all supported characters
23/// that the model can recognize, in the order the classifier emits them.
24const DEFAULT_CHARSET: &str = include_str!("charset.txt");
25
26/// Input height this binding preprocesses for.
27///
28/// The charset, the input height and the classifier width are one contract. If
29/// they drift apart the model still runs and still returns text — it is just
30/// the wrong text, with no error anywhere. So this is declared here, checked
31/// against the graph in [`MonOcr::new`], and a disagreement refuses to load.
32pub const EXPECTED_INPUT_HEIGHT: u32 = 160;
33
34/// Padded canvas width fed to the model.
35///
36/// This is a *fallback*, not the binding's free choice. v3.5 was exported with
37/// `dynamic_axes={"input": {0: "batch"}}` and nothing else, so axis 3 is the
38/// literal integer 1024 and the graph runs at that width alone. The comment
39/// here used to read "the model's width axis is dynamic; this is the binding's
40/// choice, not a model constraint" — true of v2 (`[1, 1, 128, width]`), false
41/// since the move to `d3d9d5e`, and it is the stated reason `check_contract`
42/// below validates height but not width.
43pub const DEFAULT_INPUT_WIDTH: u32 = 1024;
44
45/// Fraction of each side sampled for the polarity probe: a patch one tenth of
46/// the width by one tenth of the height, at each of the four corners.
47///
48/// The model is trained on dark text on a light background, and this binding
49/// never checked which it was given.
50///
51/// Measured 2026-08-27 over 300 labelled crops from mon_OCR's
52/// `data/real/digits/val`, same graph, only the polarity of the input changed:
53///
54/// ```text
55/// upright, with this probe    CER 0.0000   300/300 exact
56/// inverted, with this probe   CER 0.0000   300/300 exact
57/// upright, without it         CER 0.0036   296/300
58/// inverted, without it        CER 0.0342   288/300   <- 9.5x worse
59/// ```
60///
61/// Degradation rather than the total failure it might sound like, and cheap to
62/// close. Those crops are Myanmar digits on composited backgrounds, so the
63/// effect on full Mon text lines is unmeasured.
64///
65/// A COPY of the same probe in `go/pkg/predictor/onnx.go`,
66/// `python/monocr_onnx/predictor.py` and `js/src/monocr.js`, not a shared
67/// module: these bindings ship independently. Step 4 of mon_OCR's
68/// `to_normalized_grayscale`, background levelling, is not ported here and is
69/// what the 0.0036 upright row above costs.
70const POLARITY_CORNER_FRACTION: u32 = 10;
71
72/// Smallest corner patch, in pixels, on each axis. A tenth of a 20px crop is
73/// 2px, and a 2x2 sample is a coin toss rather than a measurement.
74const POLARITY_CORNER_FLOOR: u32 = 3;
75
76/// Corner median at or above this is a light background; below it the image is
77/// light-text-on-dark and needs inverting.
78const DARK_BACKGROUND_MEDIAN: u8 = 128;
79
80/// Whether the four corner patches say this image is light-text-on-dark.
81///
82/// Corner-median rather than a global mean: document corners are almost always
83/// background, so their median survives a dense, text-heavy page where a global
84/// mean is dragged toward the ink. A page 64% covered in ink has a mean below 128
85/// and must NOT be inverted — `a_dense_page_is_not_mistaken_for_dark_mode` is
86/// what pins that.
87fn background_is_dark(image: &GrayImage) -> bool {
88    let (width, height) = image.dimensions();
89    if width == 0 || height == 0 {
90        return false;
91    }
92
93    // The floor can exceed the image on a tiny crop, so clamp to the image.
94    // Without the clamp the patch reads past the edge; with an empty patch there
95    // is no median at all, and "no opinion" would silently mean "not dark",
96    // which is a wrong answer rather than a crash.
97    let ch = (height / POLARITY_CORNER_FRACTION)
98        .max(POLARITY_CORNER_FLOOR)
99        .min(height);
100    let cw = (width / POLARITY_CORNER_FRACTION)
101        .max(POLARITY_CORNER_FLOOR)
102        .min(width);
103
104    let mut samples = Vec::with_capacity((4 * ch * cw) as usize);
105    for (ox, oy) in [
106        (0, 0),
107        (width - cw, 0),
108        (0, height - ch),
109        (width - cw, height - ch),
110    ] {
111        for y in 0..ch {
112            for x in 0..cw {
113                samples.push(image.get_pixel(ox + x, oy + y)[0]);
114            }
115        }
116    }
117    samples.sort_unstable();
118
119    // Four patches of equal size, so the sample count is always a multiple of
120    // four. The odd-length half of a general median that the Go and Python
121    // copies carry cannot be reached from here, so it is not written.
122    let n = samples.len();
123    let median = (samples[n / 2 - 1] as f64 + samples[n / 2] as f64) / 2.0;
124    median < DARK_BACKGROUND_MEDIAN as f64
125}
126
127/// Return `image` as dark-text-on-light, inverting it when the background is
128/// dark.
129///
130/// An already-correct image is returned borrowed and untouched, which is what
131/// makes this safe to run on every input and idempotent: once the corners are
132/// light a second call is a no-op. Both call sites rely on that — the page path
133/// runs it before segmentation and `MonOcr::preprocess` runs it again per
134/// crop, and the second call must not undo the first.
135pub fn normalize_polarity(image: &GrayImage) -> Cow<'_, GrayImage> {
136    if !background_is_dark(image) {
137        return Cow::Borrowed(image);
138    }
139    let mut inverted = image.clone();
140    for pixel in inverted.pixels_mut() {
141        pixel[0] = 255 - pixel[0];
142    }
143    Cow::Owned(inverted)
144}
145
146/// Load a page as grayscale with its polarity corrected, ready for the
147/// segmenter.
148///
149/// Only the polarity probe runs here. Everything the model needs — the resize,
150/// the pad, the normalisation — belongs to [`MonOcr::preprocess`], per crop.
151/// This mirrors `js/src/monocr.js`'s `normalizePageForSegmentation` and
152/// `go/monocr.go`'s `predictImage`.
153fn page_for_segmentation(image_path: &Path) -> Result<GrayImage> {
154    let page = image::open(image_path)
155        .with_context(|| format!("cannot open {}", image_path.display()))?
156        .to_luma8();
157    Ok(normalize_polarity(&page).into_owned())
158}
159
160/// Find the lines of a page: correct polarity, then segment.
161///
162/// A free function taking the segmenter rather than a method on [`MonOcr`], so
163/// the ORDER of these two steps can be tested without a loaded ONNX session.
164/// When the ordering lived inline in `predict_page` it was reachable only
165/// through the model, and a mutation that dropped the probe survived the whole
166/// suite — which is how three sibling bindings shipped the bug this function
167/// exists to prevent.
168fn segment_page(segmenter: &LineSegmenter, image_path: &Path) -> Result<Vec<LineSegment>> {
169    let page = page_for_segmentation(image_path)?;
170    segmenter.segment_image(&page)
171}
172
173/// A model artifact that disagrees with the charset or the input geometry this
174/// binding was built for.
175///
176/// Returned instead of running, because running would produce confident
177/// nonsense rather than an error.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ModelContractError(pub String);
180
181impl fmt::Display for ModelContractError {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        write!(f, "model contract violation: {}", self.0)
184    }
185}
186
187impl std::error::Error for ModelContractError {}
188
189/// Strip line terminators, and nothing else.
190///
191/// The charset's first character really is U+0020 — a space is one of the
192/// classes the model emits. A bare `.trim()` eats it, which drops the charset
193/// from 276 characters to 275 and shifts every index in the decode by one, so
194/// every character comes back as its neighbour.
195pub fn normalize_charset(charset: &str) -> &str {
196    charset
197        .trim_start_matches(['\n', '\r'])
198        .trim_end_matches(['\n', '\r'])
199}
200
201/// Read `shape[axis]` when it is a fixed positive size.
202///
203/// ONNX reports dynamic axes as -1; those return `None` because there is
204/// nothing to compare them against.
205fn static_dim(shape: &[i64], axis: usize) -> Option<usize> {
206    match shape.get(axis) {
207        Some(&d) if d > 0 => Some(d as usize),
208        _ => None,
209    }
210}
211
212/// Compare the charset and geometry this binding holds against what the ONNX
213/// graph actually declares.
214///
215/// `model_classes` and `model_height` are `None` when the graph leaves that axis
216/// dynamic, in which case there is nothing to compare and the check passes —
217/// decoding re-derives the class count from the real output tensor and fails
218/// there instead.
219fn check_contract(
220    charset_len: usize,
221    model_classes: Option<usize>,
222    model_height: Option<usize>,
223    source: &str,
224) -> Result<(), ModelContractError> {
225    if charset_len == 0 {
226        return Err(ModelContractError(
227            "no charset available; cannot decode model output".to_string(),
228        ));
229    }
230    if let Some(classes) = model_classes {
231        let expected = charset_len + 1;
232        if classes != expected {
233            return Err(ModelContractError(format!(
234                "charset/model mismatch.\n  \
235                 charset: {charset_len} characters -> expects {expected} classes \
236                 ({charset_len} + CTC blank)\n  \
237                 model ({source}): {classes} classes\n\
238                 Every index above the first divergence would decode to the wrong character."
239            )));
240        }
241    }
242    if let Some(height) = model_height {
243        if height != EXPECTED_INPUT_HEIGHT as usize {
244            return Err(ModelContractError(format!(
245                "input height mismatch: this binding preprocesses to height {EXPECTED_INPUT_HEIGHT} \
246                 but {source} expects {height}"
247            )));
248        }
249    }
250    Ok(())
251}
252
253/// Validate the segmenter's gap threshold ratio.
254///
255/// Rejected at build time rather than at segmentation time, so a bad value
256/// surfaces where the caller set it. Free-standing so it can be tested without a
257/// model or a session.
258fn check_density_ratio(ratio: f32) -> Result<f32> {
259    if !ratio.is_finite() || ratio <= 0.0 {
260        anyhow::bail!(
261            "density_threshold_ratio must be finite and greater than 0, got {ratio}; \
262             at or below 0 every row clears the gap threshold and the page comes back \
263             as a single band"
264        );
265    }
266    Ok(ratio)
267}
268
269/// Builder for configuring and creating MonOcr instances
270///
271/// The builder pattern allows flexible configuration of OCR settings before
272/// creating an instance. All settings have sensible defaults.
273///
274/// # Configuration Options
275///
276/// - `model_path`: Custom path to the ONNX model file (default: download from HuggingFace)
277/// - `charset`: Custom character set for OCR (default: built-in Mon charset)
278/// - `min_line_height`: Minimum height for line segmentation (default: 10 pixels)
279/// - `smooth_window`: Window size for smoothing projection profile (default: 3)
280/// - `density_threshold_ratio`: Gap threshold as a fraction of mean row density
281///   (default: 0.05)
282///
283/// # Example
284///
285/// ```no_run
286/// use monocr_onnx::MonOcr;
287///
288/// #[tokio::main]
289/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
290///     let mut ocr = MonOcr::builder()
291///         .min_line_height(15)
292///         .smooth_window(5)
293///         .build()
294///         .await?;
295///
296///     let text = ocr.read_image("document.png").await?;
297///     println!("{text}");
298///     Ok(())
299/// }
300/// ```
301pub struct MonOcrBuilder {
302    /// Optional custom path to ONNX model file
303    model_path: Option<PathBuf>,
304    /// Optional custom charset string
305    charset: Option<String>,
306    /// Minimum line height for segmentation (in pixels)
307    min_line_height: u32,
308    /// Smoothing window size for projection profile
309    smooth_window: u32,
310    /// Gap threshold as a fraction of mean row density
311    density_threshold_ratio: f32,
312    /// Whether a line wider than the window is tiled or squeezed into it.
313    tile_wide_lines: bool,
314}
315
316impl Default for MonOcrBuilder {
317    /// Create a MonOcrBuilder with default settings
318    ///
319    /// Default values:
320    /// - model_path: None (will download from HuggingFace)
321    /// - charset: None (uses the charset published with the pinned model,
322    ///   falling back to the built-in Mon charset)
323    /// - min_line_height: 10 pixels
324    /// - smooth_window: 3
325    /// - density_threshold_ratio: 0.05
326    fn default() -> Self {
327        Self {
328            model_path: None,
329            charset: None,
330            min_line_height: 10,
331            smooth_window: 3,
332            density_threshold_ratio: DEFAULT_DENSITY_THRESHOLD_RATIO,
333            tile_wide_lines: true,
334        }
335    }
336}
337
338impl MonOcrBuilder {
339    /// Create a new builder with default settings
340    ///
341    /// This is equivalent to calling `MonOcrBuilder::default()`.
342    ///
343    /// # Returns
344    ///
345    /// A new `MonOcrBuilder` instance with default configuration
346    ///
347    /// # Example
348    ///
349    /// ```
350    /// use monocr_onnx::MonOcrBuilder;
351    ///
352    /// let builder = MonOcrBuilder::new();
353    /// ```
354    pub fn new() -> Self {
355        Self::default()
356    }
357
358    /// Set the path to the ONNX model file
359    ///
360    /// By default, the model is downloaded from HuggingFace if not found in cache.
361    /// Use this method to specify a custom model file location.
362    ///
363    /// # Arguments
364    ///
365    /// * `path` - Path to the ONNX model file
366    ///
367    /// # Returns
368    ///
369    /// The builder with the model path set
370    ///
371    /// # Example
372    ///
373    /// ```no_run
374    /// use monocr_onnx::MonOcr;
375    ///
376    /// #[tokio::main]
377    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
378    ///     let ocr = MonOcr::builder()
379    ///         .model_path("./models/monocr.onnx")
380    ///         .build()
381    ///         .await?;
382    ///     Ok(())
383    /// }
384    /// ```
385    pub fn model_path(mut self, path: impl AsRef<Path>) -> Self {
386        self.model_path = Some(path.as_ref().to_path_buf());
387        self
388    }
389
390    /// Set the charset string directly
391    ///
392    /// The charset defines all characters that the OCR model can recognize.
393    /// It should be a string containing all valid characters in order.
394    ///
395    /// # Arguments
396    ///
397    /// * `charset` - A string containing the character set
398    ///
399    /// # Returns
400    ///
401    /// The builder with the charset set
402    ///
403    /// # Note
404    ///
405    /// The charset must match the one used during model training.
406    /// The default charset is built-in and suitable for Mon text.
407    pub fn charset(mut self, charset: impl Into<String>) -> Self {
408        self.charset = Some(charset.into());
409        self
410    }
411
412    /// Set the minimum line height for segmentation
413    ///
414    /// During line segmentation, any detected region shorter than this value
415    /// will be ignored. This helps filter out noise and small artifacts.
416    ///
417    /// # Arguments
418    ///
419    /// * `height` - Minimum line height in pixels (default: 10)
420    ///
421    /// # Returns
422    ///
423    /// The builder with the minimum line height set
424    ///
425    /// # Recommendation
426    ///
427    /// Increase this value for noisy documents or decrease for documents
428    /// with small font sizes.
429    pub fn min_line_height(mut self, height: u32) -> Self {
430        self.min_line_height = height;
431        self
432    }
433
434    /// Set the smoothing window for projection profile
435    ///
436    /// The smoothing window is used when computing the horizontal projection
437    /// profile for line detection. A larger window produces smoother results
438    /// but may merge close lines.
439    ///
440    /// # Arguments
441    ///
442    /// * `window` - Window size for smoothing (default: 3, use 1 for no smoothing)
443    ///
444    /// # Returns
445    ///
446    /// The builder with the smooth window set
447    pub fn smooth_window(mut self, window: u32) -> Self {
448        self.smooth_window = window;
449        self
450    }
451
452    /// Set the gap threshold for line segmentation
453    ///
454    /// A row counts as a gap between lines when its ink density falls below
455    /// `ratio` times the mean density of the page's non-empty rows. Lower it to
456    /// split lines that are being merged; raise it to stop faint texture between
457    /// lines from cutting one line in two.
458    ///
459    /// # Arguments
460    ///
461    /// * `ratio` - Fraction of mean row density, greater than 0 (default: 0.05)
462    ///
463    /// # Why this is exposed
464    ///
465    /// The right value is a property of the input class, not a constant waiting
466    /// to be settled. `mon_OCR/docs/LIMITATIONS.md:304-334` measured the
467    /// ordering reversing between a book page and a photographed poster: a
468    /// six-line slide returned 3 lines at the low ratio and all 6 at 0.50, and
469    /// the response to the ratio is explicitly non-monotone. So a caller that
470    /// knows what it is reading can do better than any single default, and every
471    /// port of this pipeline picked a different number.
472    ///
473    /// # Errors
474    ///
475    /// [`build`](Self::build) fails if `ratio` is not finite or not positive. At
476    /// 0 every row clears the threshold and the page comes back as one band,
477    /// which is a wrong result rather than a degraded one.
478    /// Squeeze wide lines into the window instead of tiling them.
479    ///
480    /// Tiling is the default and should stay the default. This exists so the two
481    /// strategies can be measured against each other on the same pipeline, which
482    /// `mon_OCR/docs/ROADMAP.md` item 4.5.6 requires before either is trusted,
483    /// and which was impossible while the squeeze arm was unreachable.
484    ///
485    /// The measurement in `mon_OCR/eval/tiling-ab-2026-08-22.md` found the answer
486    /// is width-dependent: squeezing is mildly better up to 3 tiles and 3.7x to
487    /// 24x worse from 4 tiles up, where it drives CER above 0.9. Tiling is the
488    /// safe default because its downside is bounded and squeezing's is not.
489    pub fn tile_wide_lines(mut self, tile: bool) -> Self {
490        self.tile_wide_lines = tile;
491        self
492    }
493
494    pub fn density_threshold_ratio(mut self, ratio: f32) -> Self {
495        self.density_threshold_ratio = ratio;
496        self
497    }
498
499    /// Build the MonOcr instance
500    ///
501    /// This method initializes the ONNX runtime session and prepares the OCR
502    /// engine for use. It may download the model if not cached.
503    ///
504    /// # Returns
505    ///
506    /// * `Ok(MonOcr)` - Ready-to-use OCR instance
507    /// * `Err(anyhow::Error)` - If model loading fails
508    ///
509    /// # Async
510    ///
511    /// This function is async because model initialization may involve
512    /// downloading the model file from the network.
513    pub async fn build(self) -> Result<MonOcr> {
514        MonOcr::new(
515            self.model_path,
516            self.charset,
517            self.min_line_height,
518            self.smooth_window,
519            check_density_ratio(self.density_threshold_ratio)?,
520            self.tile_wide_lines,
521        )
522        .await
523    }
524}
525
526/// Main OCR engine for text recognition
527///
528/// This struct encapsulates the OCR pipeline including:
529/// - ONNX runtime session for model inference
530/// - Character set for decoding predictions
531/// - Line segmenter for page layout analysis
532/// - Image preprocessing utilities
533///
534/// # Usage
535///
536/// Typically, you would create a `MonOcr` instance using the builder:
537///
538/// ```no_run
539/// use monocr_onnx::MonOcr;
540///
541/// #[tokio::main]
542/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
543///     let mut ocr = MonOcr::builder().build().await?;
544///     let text = ocr.read_image("document.png").await?;
545///     println!("Recognized: {}", text);
546///     Ok(())
547/// }
548/// ```
549///
550/// The instance must be mutable because internal state is modified during
551/// inference (e.g., the ONNX session).
552pub struct MonOcr {
553    /// ONNX runtime session for model inference
554    session: Session,
555    /// Character set for decoding model output
556    charset: Vec<char>,
557    /// Line segmenter for page layout analysis
558    segmenter: LineSegmenter,
559    /// Target height for model input, taken from the model graph once the
560    /// contract check has confirmed it matches [`EXPECTED_INPUT_HEIGHT`]
561    target_height: u32,
562    /// Target width for model input. Unlike `target_height` this is NOT read
563    /// from the graph — see `DEFAULT_INPUT_WIDTH` for why that is a gap and not
564    /// a decision.
565    target_width: u32,
566    /// False squeezes wide lines instead of tiling. Measurement only; see
567    /// `MonOcrBuilder::tile_wide_lines`.
568    tile_wide_lines: bool,
569}
570
571/// Result from line prediction
572///
573/// This struct contains the recognized text and its bounding box location
574/// for a single line in the image.
575#[derive(Debug, Clone)]
576pub struct LineResult {
577    /// The recognized text for this line
578    pub text: String,
579    /// The bounding box of this text line in the original image
580    pub bbox: BBox,
581}
582
583/// Bounding box for a line or text region
584///
585/// Represents a rectangular region in the image with pixel coordinates.
586#[derive(Debug, Clone, Copy)]
587pub struct BBox {
588    /// X coordinate of the top-left corner
589    pub x: u32,
590    /// Y coordinate of the top-left corner
591    pub y: u32,
592    /// Width of the bounding box
593    pub w: u32,
594    /// Height of the bounding box
595    pub h: u32,
596}
597
598/// Smallest box containing both inputs.
599///
600/// Used to report one bbox for a line that was read as several tiles, so the
601/// geometry still describes the line the text came from.
602fn union_bbox(a: BBox, b: BBox) -> BBox {
603    let x = a.x.min(b.x);
604    let y = a.y.min(b.y);
605    let right = (a.x + a.w).max(b.x + b.w);
606    let bottom = (a.y + a.h).max(b.y + b.h);
607    BBox {
608        x,
609        y,
610        w: right - x,
611        h: bottom - y,
612    }
613}
614
615/// Join one page's line texts the way [`MonOcr::read_image`] does.
616///
617/// Distinct lines are separated by a newline. Tiles of the same line are already
618/// concatenated inside their [`LineResult`], so no separator appears mid-line.
619pub fn page_text(lines: &[LineResult]) -> String {
620    lines
621        .iter()
622        .map(|l| l.text.as_str())
623        .collect::<Vec<_>>()
624        .join("\n")
625}
626
627impl MonOcr {
628    /// Create a builder for configuring MonOcr
629    ///
630    /// This is the entry point for creating a customized OCR instance.
631    /// Use the builder methods to configure options, then call `build()`.
632    ///
633    /// # Returns
634    ///
635    /// A new `MonOcrBuilder` instance
636    ///
637    /// # Example
638    ///
639    /// ```no_run
640    /// use monocr_onnx::MonOcr;
641    ///
642    /// #[tokio::main]
643    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
644    ///     let mut ocr = MonOcr::builder()
645    ///         .min_line_height(15)
646    ///         .build()
647    ///         .await?;
648    ///     Ok(())
649    /// }
650    /// ```
651    pub fn builder() -> MonOcrBuilder {
652        MonOcrBuilder::new()
653    }
654
655    /// Internal constructor (not part of public API)
656    ///
657    /// This method is called by the builder's `build()` method.
658    /// It initializes the ONNX session, loads the charset, and creates
659    /// the line segmenter.
660    ///
661    /// # Arguments
662    ///
663    /// * `model_path` - Optional custom path to ONNX model
664    /// * `charset` - Optional custom charset string
665    /// * `min_line_height` - Minimum line height for segmentation
666    /// * `smooth_window` - Smoothing window size
667    /// * `density_threshold_ratio` - Gap threshold as a fraction of mean row
668    ///   density, already validated by the builder
669    async fn new(
670        model_path: Option<PathBuf>,
671        charset: Option<String>,
672        min_line_height: u32,
673        smooth_window: u32,
674        density_threshold_ratio: f32,
675        tile_wide_lines: bool,
676    ) -> Result<Self> {
677        // Get or download model. When the model comes from the manager, its
678        // charset comes from the same pinned revision, so the two agree by
679        // construction; the embedded copy is the offline fallback.
680        let (model_path, published_charset) = match model_path {
681            Some(path) => (path, None),
682            None => {
683                // `ModelManager` uses `reqwest::blocking`, which builds its own
684                // runtime and drops it when the request finishes. Doing that on
685                // an async worker thread panics outright:
686                //
687                //   Cannot drop a runtime in a context where blocking is not
688                //   allowed. This happens when a runtime is dropped from within
689                //   an asynchronous context.
690                //
691                // Every entry point here is `async`, so the only safe place for
692                // it is the blocking pool. This fires only on a cache miss,
693                // which is why it stayed latent: once the model is cached the
694                // download path is never taken and the panic never appears.
695                tokio::task::spawn_blocking(|| {
696                    let manager = ModelManager::new();
697                    let path = manager.get_model_path()?;
698                    let published = manager.get_charset().ok();
699                    Ok::<_, anyhow::Error>((path, published))
700                })
701                .await
702                .context("the model download task did not finish")??
703            }
704        };
705
706        // Get charset
707        let charset_str = charset
708            .or(published_charset)
709            .unwrap_or_else(|| DEFAULT_CHARSET.to_string());
710        let charset: Vec<char> = normalize_charset(&charset_str).chars().collect();
711
712        // Create ONNX session
713        let session = Session::builder()?
714            .with_optimization_level(GraphOptimizationLevel::Level3)?
715            .commit_from_file(&model_path)?;
716
717        // Read the real graph rather than assuming the input height or the
718        // class count. Both have changed under this SDK before.
719        let source = model_path.display().to_string();
720        let in_shape = session
721            .inputs()
722            .first()
723            .and_then(|i| i.dtype().tensor_shape())
724            .ok_or_else(|| ModelContractError(format!("{source} has no tensor input")))?
725            .to_vec();
726        let out_shape = session
727            .outputs()
728            .first()
729            .and_then(|o| o.dtype().tensor_shape())
730            .ok_or_else(|| ModelContractError(format!("{source} has no tensor output")))?
731            .to_vec();
732
733        if in_shape.len() != 4 {
734            return Err(ModelContractError(format!(
735                "expected a 4-D [batch, channel, height, width] input, {source} declares {in_shape:?}"
736            ))
737            .into());
738        }
739        if out_shape.len() != 3 {
740            return Err(ModelContractError(format!(
741                "expected a 3-D [batch, sequence, classes] output, {source} declares {out_shape:?}"
742            ))
743            .into());
744        }
745
746        let model_height = static_dim(&in_shape, 2);
747        let model_classes = static_dim(&out_shape, 2);
748        check_contract(charset.len(), model_classes, model_height, &source)?;
749
750        let segmenter = LineSegmenter::with_density_ratio(
751            min_line_height,
752            smooth_window,
753            density_threshold_ratio,
754        );
755
756        Ok(Self {
757            session,
758            charset,
759            segmenter,
760            target_height: model_height
761                .map(|h| h as u32)
762                .unwrap_or(EXPECTED_INPUT_HEIGHT),
763            target_width: DEFAULT_INPUT_WIDTH,
764            tile_wide_lines,
765        })
766    }
767
768    /// Read text from a single image
769    ///
770    /// This method performs OCR on a single image file. The image is automatically
771    /// segmented into lines, and each line is recognized using the ONNX model.
772    ///
773    /// # Arguments
774    ///
775    /// * `image_path` - Path to the image file (PNG, JPG, BMP, etc.)
776    ///
777    /// # Returns
778    ///
779    /// * `Ok(String)` - Recognized text with lines separated by newlines
780    /// * `Err(anyhow::Error)` - If the image cannot be read or OCR fails
781    ///
782    /// # Example
783    ///
784    /// ```no_run
785    /// use monocr_onnx::MonOcr;
786    ///
787    /// #[tokio::main]
788    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
789    ///     let mut ocr = MonOcr::builder().build().await?;
790    ///     let text = ocr.read_image("document.png").await?;
791    ///     println!("Recognized text:\n{}", text);
792    ///     Ok(())
793    /// }
794    /// ```
795    pub async fn read_image(&mut self, image_path: impl AsRef<Path>) -> Result<String> {
796        let results = self.predict_page(image_path).await?;
797        Ok(page_text(&results))
798    }
799
800    /// Read text from multiple images
801    ///
802    /// This method processes multiple images in sequence, returning a vector of
803    /// recognized texts. Each image is segmented into lines and processed individually.
804    ///
805    /// # Arguments
806    ///
807    /// * `image_paths` - A slice of paths to image files
808    ///
809    /// # Returns
810    ///
811    /// * `Ok(Vec<String>)` - Vector of recognized texts, one per image
812    /// * `Err(anyhow::Error)` - If any image cannot be processed
813    ///
814    /// # Example
815    ///
816    /// ```no_run
817    /// use monocr_onnx::MonOcr;
818    ///
819    /// #[tokio::main]
820    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
821    ///     let mut ocr = MonOcr::builder().build().await?;
822    ///     let paths = vec!["page1.png", "page2.png", "page3.png"];
823    ///     let results = ocr.read_images(&paths).await?;
824    ///     for (i, text) in results.iter().enumerate() {
825    ///         println!("Page {}: {}", i + 1, text);
826    ///     }
827    ///     Ok(())
828    /// }
829    /// ```
830    pub async fn read_images(&mut self, image_paths: &[impl AsRef<Path>]) -> Result<Vec<String>> {
831        let mut results = Vec::new();
832        for path in image_paths {
833            let text = self.read_image(path).await?;
834            results.push(text);
835        }
836        Ok(results)
837    }
838
839    /// Read text from a PDF file
840    ///
841    /// This method converts a PDF document to images using pdftoppm and performs
842    /// OCR on each page. Each page is processed as a separate image.
843    ///
844    /// # Arguments
845    ///
846    /// * `pdf_path` - Path to the PDF file
847    ///
848    /// # Returns
849    ///
850    /// * `Ok(Vec<String>)` - Vector of recognized texts, one per page
851    /// * `Err(anyhow::Error)` - If PDF conversion fails or OCR fails
852    ///
853    /// # Requirements
854    ///
855    /// Requires `pdftoppm` from poppler-utils to be installed:
856    /// - Ubuntu/Debian: `sudo apt-get install poppler-utils`
857    /// - macOS: `brew install poppler`
858    pub async fn read_pdf(&mut self, pdf_path: impl AsRef<Path>) -> Result<Vec<String>> {
859        let pages = self.predict_pdf(pdf_path).await?;
860        Ok(pages.iter().map(|lines| page_text(lines)).collect())
861    }
862
863    /// Predict text and geometry from a PDF file, page by page
864    ///
865    /// Same conversion as [`read_pdf`](Self::read_pdf), but keeps the per-line
866    /// bounding boxes. Coordinates are in pixels of the 300 DPI render of the
867    /// page, not PDF points.
868    ///
869    /// # Returns
870    ///
871    /// * `Ok(Vec<Vec<LineResult>>)` - One vector of line results per page
872    /// * `Err(anyhow::Error)` - If PDF conversion fails or OCR fails
873    pub async fn predict_pdf(
874        &mut self,
875        pdf_path: impl AsRef<Path>,
876    ) -> Result<Vec<Vec<LineResult>>> {
877        use std::process::Stdio;
878        use tokio::process::Command;
879
880        let pdf_path = pdf_path.as_ref();
881
882        // Check for pdftoppm
883        let check = Command::new("which").arg("pdftoppm").output().await;
884
885        if check.is_err() || !check.as_ref().map(|o| o.status.success()).unwrap_or(false) {
886            anyhow::bail!("pdftoppm not found: please install poppler-utils");
887        }
888        if check.as_ref().map(|o| o.stdout.is_empty()).unwrap_or(true) {
889            anyhow::bail!("pdftoppm not found: please install poppler-utils");
890        }
891
892        // Create temp directory
893        let temp_dir = tempfile::tempdir()?;
894        let output_prefix = temp_dir.path().join("page");
895
896        // Convert PDF to images
897        let output = Command::new("pdftoppm")
898            .args(["-png", "-r", "300"])
899            .arg(pdf_path)
900            .arg(&output_prefix)
901            .stdout(Stdio::null())
902            .stderr(Stdio::null())
903            .status()
904            .await?;
905
906        if !output.success() {
907            anyhow::bail!("Failed to convert PDF to images");
908        }
909
910        // Read generated images
911        let mut entries: Vec<_> = std::fs::read_dir(temp_dir.path())?
912            .filter_map(|e| e.ok())
913            .filter(|e| {
914                e.path()
915                    .extension()
916                    .map(|ext| ext == "png")
917                    .unwrap_or(false)
918            })
919            .collect();
920
921        // Sort by page number
922        entries.sort_by(|a, b| {
923            let name_a = a.file_name();
924            let name_b = b.file_name();
925            let num_a: u32 = name_a
926                .to_string_lossy()
927                .split('-')
928                .next_back()
929                .and_then(|s| s.trim_end_matches(".png").parse().ok())
930                .unwrap_or(0);
931            let num_b: u32 = name_b
932                .to_string_lossy()
933                .split('-')
934                .next_back()
935                .and_then(|s| s.trim_end_matches(".png").parse().ok())
936                .unwrap_or(0);
937            num_a.cmp(&num_b)
938        });
939
940        if entries.is_empty() {
941            anyhow::bail!("No images generated from PDF");
942        }
943
944        // Process each page
945        let mut pages = Vec::new();
946        for entry in entries {
947            let lines = self.predict_page(entry.path()).await?;
948            pages.push(lines);
949        }
950
951        Ok(pages)
952    }
953
954    /// Read image with accuracy measurement
955    ///
956    /// This method performs OCR on an image and calculates accuracy by comparing
957    /// the recognized text against ground truth using Levenshtein distance.
958    ///
959    /// # Arguments
960    ///
961    /// * `image_path` - Path to the image file
962    /// * `ground_truth` - The expected/ground truth text to compare against
963    ///
964    /// # Returns
965    ///
966    /// * `Ok(OcrResult)` - Contains recognized text and accuracy percentage
967    /// * `Err(anyhow::Error)` - If OCR fails
968    ///
969    /// # Accuracy Calculation
970    ///
971    /// Accuracy = (1 - CER) * 100, where CER is Character Error Rate
972    /// calculated as Levenshtein distance / max(len(predicted), len(ground_truth))
973    pub async fn read_image_with_accuracy(
974        &mut self,
975        image_path: impl AsRef<Path>,
976        ground_truth: &str,
977    ) -> Result<OcrResult> {
978        let text = self.read_image(image_path).await?;
979        let accuracy = calculate_accuracy(&text, ground_truth);
980        Ok(OcrResult { text, accuracy })
981    }
982
983    /// Predict text from a single line image
984    ///
985    /// This is an internal method that runs the ONNX model on a single
986    /// pre-segmented line image. It performs preprocessing, inference,
987    /// and CTC decoding.
988    ///
989    /// # Arguments
990    ///
991    /// * `image` - Pre-processed grayscale image of a single text line
992    ///
993    /// # Returns
994    ///
995    /// * `Ok(String)` - Recognized text for this line
996    /// * `Err(anyhow::Error)` - If inference fails
997    async fn predict_line(&mut self, image: &GrayImage) -> Result<String> {
998        let input_tensor = self.preprocess(image)?;
999
1000        // Run inference
1001        let input = ort::value::Tensor::from_array(input_tensor)?;
1002        let outputs = self.session.run(ort::inputs![input])?;
1003
1004        // Get output tensor
1005        let output = outputs[0].downcast_ref::<ort::value::DynTensorValueType>()?;
1006        let (shape, data) = output.try_extract_tensor::<f32>()?;
1007        let output_shape: Vec<usize> = shape.iter().cloned().map(|x| x as usize).collect();
1008        let output_data: Vec<f32> = data.to_vec();
1009        drop(outputs);
1010
1011        // Decode
1012        self.decode_owned(&output_data, &output_shape)
1013    }
1014
1015    /// Predict text from a full page image
1016    ///
1017    /// This method segments the image into lines and recognizes each line
1018    /// using the ONNX model. Returns results with text and bounding boxes.
1019    ///
1020    /// # Arguments
1021    ///
1022    /// * `image_path` - Path to the full page image
1023    ///
1024    /// # Returns
1025    ///
1026    /// * `Ok(Vec<LineResult>)` - Vector of line results with text and bounding boxes
1027    /// * `Err(anyhow::Error)` - If segmentation or OCR fails
1028    ///
1029    /// # Process
1030    ///
1031    /// 1. Segment the page into individual text lines using horizontal projection
1032    /// 2. For each line:
1033    ///    - Tile it at whitespace columns if it is too wide for the model window
1034    ///    - Preprocess each tile for the model
1035    ///    - Run inference with the ONNX model
1036    ///    - Decode CTC output to text
1037    /// 3. Return one result per line, with the text of its tiles concatenated
1038    ///
1039    /// # Wide lines
1040    ///
1041    /// A line wider than the model window is tiled by
1042    /// [`crate::segmenter::tile_line`], not squeezed.
1043    ///
1044    /// Measured on **this** binding, 2026-08-22, over 201 rendered Mon lines by
1045    /// `examples/tiling_ab.rs`. The answer depends on how wide the line is:
1046    ///
1047    /// ```text
1048    /// tiles   squeezed   tiled    winner
1049    ///     2     0.0444  0.0635    squeezing, 0.7x
1050    ///     3     0.0317  0.0294    parity, 1.1x
1051    ///     4     0.1509  0.0364    tiling, 4.1x
1052    ///     6     0.8382  0.0229    tiling, 36.5x
1053    ///     8     0.9090  0.0387    tiling, 23.5x
1054    /// ```
1055    ///
1056    /// So tiling is not a uniform win: it is a **safety net**. Up to 3 tiles the
1057    /// two are level, and from 4 up squeezing degrades without bound while tiling
1058    /// stays flat. Tiling is the default because that asymmetry is the whole
1059    /// argument — the downside is a fraction of a point on already-low rates, and
1060    /// the upside is not losing the line.
1061    ///
1062    /// Char-level CER here; `mon_OCR/eval/tiling-ab-2026-08-22.md` scores the same
1063    /// images by grapheme cluster and finds the same crossover. That report also
1064    /// records that these numbers do **not** reproduce the older
1065    /// squeezed-0.1434-against-tiled-0.0795 figures quoted elsewhere, whose
1066    /// harness was never committed.
1067    ///
1068    /// The measurement is one held-out font at one size, on rendered lines rather
1069    /// than photographed pages. If the pinned model moves, re-run the example
1070    /// rather than assuming any of this still holds.
1071    ///
1072    /// The tiles of one line are joined with no separator, and their union is
1073    /// reported as that line's bbox. Joining them with a newline is what
1074    /// produced "Mon E-boo" and "k library" as two readings of a single line.
1075    pub async fn predict_page(&mut self, image_path: impl AsRef<Path>) -> Result<Vec<LineResult>> {
1076        let image_path = image_path.as_ref();
1077
1078        // Polarity BEFORE segmentation, and this ordering is the point. The
1079        // segmenter treats dark as ink (`segmenter.rs`'s `< 128`), so handed a
1080        // light-on-dark page it segments the BACKGROUND and returns the gaps
1081        // between lines. Inverting each crop inside `preprocess` afterwards
1082        // cannot recover a line that was never found.
1083        //
1084        // The three sibling bindings all fixed this after an audit caught the
1085        // probe sitting in `preprocess` alone — `go/monocr.go` `predictImage`,
1086        // `js/src/monocr.js` `predictPage`, `python/monocr_onnx/predictor.py`
1087        // `predict_page`. This binding had the probe in neither place.
1088        //
1089        // The probe is idempotent, so the per-crop call in `preprocess_line` still
1090        // covers `predict_single_line` without fighting this one.
1091        //
1092        // The ordering itself is tested through `segment_page`, not through here:
1093        // this method needs a loaded session, so a mutation to THIS LINE survives
1094        // the suite. Keep the delegation a single call so the untested surface
1095        // stays one line wide.
1096        let lines = segment_page(&self.segmenter, image_path)?;
1097
1098        let mut results = Vec::new();
1099        for line in lines {
1100            let origin = BBox {
1101                x: line.bbox.x,
1102                y: line.bbox.y,
1103                w: line.bbox.w,
1104                h: line.bbox.h,
1105            };
1106            results.push(self.read_line_crop(&line.img, origin).await?);
1107        }
1108
1109        Ok(results)
1110    }
1111
1112    /// Recognise an image that is already a single cropped line
1113    ///
1114    /// Skips segmentation entirely. Use when the caller knows the input is one
1115    /// line — segmenting a line fragments it, because the projection profile has
1116    /// no gap to find and any faint row inside the glyphs becomes one. The crop
1117    /// is still tiled if it is wider than the model window, so a long line is
1118    /// not squeezed.
1119    ///
1120    /// Deciding when an input is a single line belongs to the caller; the
1121    /// library does not guess.
1122    ///
1123    /// # Returns
1124    ///
1125    /// * `Ok(LineResult)` - The text, with a bbox covering the whole source image
1126    /// * `Err(anyhow::Error)` - If the image cannot be read or inference fails
1127    pub async fn predict_single_line(
1128        &mut self,
1129        image_path: impl AsRef<Path>,
1130    ) -> Result<LineResult> {
1131        let image_path = image_path.as_ref();
1132        let crop = image::open(image_path)
1133            .with_context(|| format!("cannot open {}", image_path.display()))?
1134            .to_luma8();
1135
1136        let (w, h) = crop.dimensions();
1137        if w == 0 || h == 0 {
1138            anyhow::bail!(
1139                "{} is {w}x{h}: there is nothing to read",
1140                image_path.display()
1141            );
1142        }
1143
1144        self.read_line_crop(&crop, BBox { x: 0, y: 0, w, h }).await
1145    }
1146
1147    /// Read one line crop: tile it if it is too wide, recognise the tiles left
1148    /// to right, and report their union in source coordinates.
1149    ///
1150    /// `origin` is where the crop sits in the source image, so a caller working
1151    /// on a whole page passes the segment's box and a caller working on an
1152    /// already-cropped line passes the image's own box.
1153    ///
1154    /// The tiles' texts are concatenated with no separator. A newline here is
1155    /// what produced "Mon E-boo" and "k library" as two readings of one line.
1156    async fn read_line_crop(&mut self, crop: &GrayImage, origin: BBox) -> Result<LineResult> {
1157        // One tile means the squeeze path in `preprocess` handles the whole crop,
1158        // which is exactly the arm being compared against.
1159        let tiles = if self.tile_wide_lines {
1160            tile_line(crop, self.target_height, self.target_width)
1161        } else {
1162            vec![crop.clone()]
1163        };
1164
1165        let mut text = String::new();
1166        let mut bbox: Option<BBox> = None;
1167        // Tiles partition the crop left to right, so the running offset is what
1168        // maps a tile back to source coordinates.
1169        let mut x_offset = 0u32;
1170
1171        for tile in &tiles {
1172            let (tile_w, tile_h) = tile.dimensions();
1173            text.push_str(&self.predict_line(tile).await?);
1174
1175            let tile_bbox = BBox {
1176                x: origin.x + x_offset,
1177                y: origin.y,
1178                w: tile_w,
1179                h: tile_h,
1180            };
1181            bbox = Some(match bbox {
1182                Some(current) => union_bbox(current, tile_bbox),
1183                None => tile_bbox,
1184            });
1185            x_offset += tile_w;
1186        }
1187
1188        Ok(LineResult {
1189            text,
1190            // Derived from the tiles rather than copied from `origin`, so if
1191            // tiling ever stops covering the crop the reported geometry follows
1192            // the text instead of overstating it. An empty tile list cannot
1193            // happen — tile_line always returns at least the crop — but `origin`
1194            // is the honest fallback.
1195            bbox: bbox.unwrap_or(origin),
1196        })
1197    }
1198
1199    /// Preprocess image for model input
1200    ///
1201    /// This method transforms a grayscale image into the tensor format
1202    /// expected by the ONNX model.
1203    ///
1204    /// # Processing Steps
1205    ///
1206    /// 1. **Scaling**: Scale the image to fit the model's input height (160) by
1207    ///    [`DEFAULT_INPUT_WIDTH`] (1024)
1208    ///    while maintaining aspect ratio
1209    /// 2. **Resizing**: Resize using Triangle filter for quality
1210    /// 3. **Normalization**: Convert pixel values from [0, 255] to [-1, 1]
1211    /// 4. **Padding**: Pad with white (1.0) if width is less than target
1212    ///
1213    /// Polarity is corrected first, because the model is trained on dark text on
1214    /// light and the normalisation in step 3 is a straight rescale that carries
1215    /// an inverted crop through unchanged. See [`normalize_polarity`] for the
1216    /// measured cost of skipping it.
1217    ///
1218    /// # Arguments
1219    ///
1220    /// * `image` - Source grayscale image
1221    ///
1222    /// # Returns
1223    ///
1224    /// * `Ok(Array4<f32>)` - 4D tensor with shape [1, 1, target_height, target_width]
1225    /// * `Err(anyhow::Error)` - If preprocessing fails
1226    ///
1227    /// The body lives in [`preprocess_line`], which is where the tests reach it;
1228    /// this wrapper needs a loaded session and so is not itself covered. Keep it
1229    /// a single delegating call for that reason.
1230    fn preprocess(&self, image: &GrayImage) -> Result<Array4<f32>> {
1231        Ok(preprocess_line(
1232            image,
1233            self.target_height,
1234            self.target_width,
1235        ))
1236    }
1237
1238    /// CTC Greedy Decoding
1239    ///
1240    /// Converts the model output tensor to text using CTC (Connectionist
1241    /// Temporal Classification) greedy decoding.
1242    ///
1243    /// # CTC Decoding Process
1244    ///
1245    /// 1. For each timestep, find the class with the highest score
1246    /// 2. Skip the blank class (index 0)
1247    /// 3. Skip repeated characters - only keep the first of consecutive same chars
1248    /// 4. Map class index `n` to `charset[n - 1]`
1249    ///
1250    /// # Arguments
1251    ///
1252    /// * `data` - Flattened output data in row-major order
1253    /// * `shape` - Tensor shape [batch, sequence_length, num_classes]
1254    ///
1255    /// # Contract
1256    ///
1257    /// The stride comes from the output tensor's own shape, never from the
1258    /// charset. A charset that disagrees with the tensor is refused here rather
1259    /// than silently decoding every index to its neighbour.
1260    fn decode_owned(&self, data: &[f32], shape: &[usize]) -> Result<String> {
1261        decode_ctc(&self.charset, data, shape)
1262    }
1263}
1264
1265/// Turn one line crop into the model's input tensor.
1266///
1267/// A free function taking the geometry rather than a method, for the same reason
1268/// as [`segment_page`]: the polarity step below is otherwise reachable only
1269/// through a loaded ONNX session, and a mutation that deleted it survived the
1270/// whole suite.
1271fn preprocess_line(image: &GrayImage, target_height: u32, target_width: u32) -> Array4<f32> {
1272    // Per crop, which is what the single-line path needs: `predict_single_line`
1273    // never reaches the page-level probe in `segment_page`. On a page the probe
1274    // has already run and this call is a no-op, because it is idempotent.
1275    let normalized = normalize_polarity(image);
1276    let image = normalized.as_ref();
1277    let (width, height) = image.dimensions();
1278
1279    // Calculate new width maintaining aspect ratio
1280    let scale = target_height as f32 / height as f32;
1281    let new_width = (width as f32 * scale).round() as u32;
1282    let new_width = new_width.min(target_width);
1283
1284    // Resize image
1285    let resized = image::imageops::resize(image, new_width, target_height, FilterType::Triangle);
1286
1287    // Create tensor and normalize
1288    let mut tensor = Array4::<f32>::zeros((1, 1, target_height as usize, target_width as usize));
1289
1290    for y in 0..target_height {
1291        for x in 0..target_width {
1292            let value = if x < new_width {
1293                let pixel = resized.get_pixel(x, y);
1294                (pixel[0] as f32 / 127.5) - 1.0 // Normalize to [-1, 1]
1295            } else {
1296                1.0 // White padding
1297            };
1298            tensor[[0, 0, y as usize, x as usize]] = value;
1299        }
1300    }
1301
1302    tensor
1303}
1304
1305/// CTC greedy decode of a flat logits buffer.
1306///
1307/// Free-standing so it can be exercised without an ONNX session.
1308fn decode_ctc(charset: &[char], data: &[f32], shape: &[usize]) -> Result<String> {
1309    if shape.len() != 3 {
1310        return Err(ModelContractError(format!(
1311            "expected a 3-D [batch, sequence, classes] output tensor, got shape {shape:?}"
1312        ))
1313        .into());
1314    }
1315    let sequence_length = shape[1];
1316    let num_classes = shape[2];
1317    if sequence_length == 0 || num_classes == 0 {
1318        return Err(ModelContractError(format!(
1319            "output tensor has an empty axis: shape {shape:?}"
1320        ))
1321        .into());
1322    }
1323
1324    let expected = charset.len() + 1;
1325    if num_classes != expected {
1326        return Err(ModelContractError(format!(
1327            "charset/model mismatch at decode time: charset has {} characters -> \
1328             expects {expected} classes, tensor has {num_classes}",
1329            charset.len()
1330        ))
1331        .into());
1332    }
1333    if data.len() < sequence_length * num_classes {
1334        return Err(ModelContractError(format!(
1335            "output tensor holds {} values, shape {shape:?} needs {}",
1336            data.len(),
1337            sequence_length * num_classes
1338        ))
1339        .into());
1340    }
1341
1342    let mut decoded = String::new();
1343    let mut prev_idx: i32 = -1;
1344
1345    for t in 0..sequence_length {
1346        let mut max_val = f32::NEG_INFINITY;
1347        let mut max_idx = 0;
1348
1349        let base = t * num_classes;
1350        for c in 0..num_classes {
1351            let val = data[base + c];
1352            if val > max_val {
1353                max_val = val;
1354                max_idx = c;
1355            }
1356        }
1357
1358        // Index 0 is the CTC blank; 1..=N map onto charset[0..N-1].
1359        if max_idx != 0 && max_idx as i32 != prev_idx {
1360            decoded.push(charset[max_idx - 1]);
1361        }
1362        prev_idx = max_idx as i32;
1363    }
1364
1365    Ok(decoded)
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371
1372    use image::Luma;
1373
1374    /// A page of glyph blobs on a light background, and its exact inverse.
1375    ///
1376    /// Blobs rather than a flat fill because the polarity probe reads the
1377    /// corners: a page has to have real margins for the corner median to mean
1378    /// anything, and `ink_fraction` lets a test say how much of the page is
1379    /// covered without touching those margins.
1380    fn drawn_page(width: u32, height: u32, band_h: u32, glyph_w: u32, pitch: u32) -> GrayImage {
1381        let mut img = GrayImage::from_pixel(width, height, Luma([255u8]));
1382        let margin = height / 10 + 4;
1383        let mut y = margin;
1384        while y + band_h < height - margin {
1385            for yy in y..y + band_h {
1386                let mut x = margin;
1387                while x + glyph_w < width - margin {
1388                    for i in 0..glyph_w {
1389                        img.put_pixel(x + i, yy, Luma([0u8]));
1390                    }
1391                    x += pitch;
1392                }
1393            }
1394            y += band_h * 2;
1395        }
1396        img
1397    }
1398
1399    fn inverted(img: &GrayImage) -> GrayImage {
1400        let mut out = img.clone();
1401        for pixel in out.pixels_mut() {
1402            pixel[0] = 255 - pixel[0];
1403        }
1404        out
1405    }
1406
1407    fn ink_fraction(img: &GrayImage) -> f64 {
1408        let dark = img.pixels().filter(|p| p[0] < 128).count();
1409        dark as f64 / (img.width() * img.height()) as f64
1410    }
1411
1412    /// The unconditional-safety property, the same one
1413    /// `segmenter::tests::a_page_with_no_rules_is_untouched_to_the_pixel` asserts
1414    /// for rule suppression: every input gets the probe, so on a correct page it
1415    /// must do nothing at all rather than nearly nothing.
1416    #[test]
1417    fn a_light_page_is_not_inverted() {
1418        let page = drawn_page(400, 300, 20, 10, 18);
1419        let out = normalize_polarity(&page);
1420        assert!(
1421            matches!(out, Cow::Borrowed(_)),
1422            "a light page must be handed back borrowed, not copied"
1423        );
1424        assert_eq!(out.as_raw(), page.as_raw(), "a light page was modified");
1425    }
1426
1427    #[test]
1428    fn a_dark_page_is_inverted() {
1429        let page = drawn_page(400, 300, 20, 10, 18);
1430        let dark = inverted(&page);
1431        let out = normalize_polarity(&dark);
1432        assert!(
1433            matches!(out, Cow::Owned(_)),
1434            "a light-on-dark page must be inverted"
1435        );
1436        assert_eq!(
1437            out.as_raw(),
1438            page.as_raw(),
1439            "inverting a dark page must reproduce the light original exactly"
1440        );
1441    }
1442
1443    /// Corner-median rather than a global mean, and this is the case that
1444    /// separates them. A page more than half covered in ink has a global mean
1445    /// below 128 and would be inverted by a mean-based probe — turning correct
1446    /// input into garbage on precisely the dense pages OCR is for.
1447    #[test]
1448    fn a_dense_page_is_not_mistaken_for_dark_mode() {
1449        // Solid ink inside the margins, so the corners are the only light part of
1450        // the page. 64% coverage is the figure the Go copy of this probe records
1451        // as the case a mean-based test gets wrong.
1452        let mut page = GrayImage::from_pixel(400, 300, Luma([255u8]));
1453        let (margin_x, margin_y) = (34, 34);
1454        for y in margin_y..300 - margin_y {
1455            for x in margin_x..400 - margin_x {
1456                page.put_pixel(x, y, Luma([0u8]));
1457            }
1458        }
1459        let covered = ink_fraction(&page);
1460        assert!(
1461            covered > 0.5,
1462            "the fixture must be more than half ink for this to test anything, \
1463             got {covered:.3}"
1464        );
1465        assert!(
1466            matches!(normalize_polarity(&page), Cow::Borrowed(_)),
1467            "a dense but correctly-polarised page was inverted"
1468        );
1469    }
1470
1471    /// Both call sites depend on this. `predict_page` corrects the page and
1472    /// `preprocess` corrects each crop of it, so a probe that flipped on every
1473    /// call would undo itself and feed the model inverted tiles.
1474    #[test]
1475    fn polarity_is_idempotent() {
1476        let dark = inverted(&drawn_page(400, 300, 20, 10, 18));
1477        let once = normalize_polarity(&dark).into_owned();
1478        let twice = normalize_polarity(&once).into_owned();
1479        assert_eq!(
1480            once.as_raw(),
1481            twice.as_raw(),
1482            "a second pass changed the image; the two call sites would fight"
1483        );
1484    }
1485
1486    /// A 1x1 crop makes the corner patches overlap and the floor exceed the
1487    /// image. Reading past the edge is what would panic.
1488    #[test]
1489    fn a_tiny_crop_does_not_panic() {
1490        for (w, h) in [(1u32, 1u32), (1, 40), (40, 1), (5, 5), (2, 7)] {
1491            let dark = GrayImage::from_pixel(w, h, Luma([10u8]));
1492            assert!(
1493                matches!(normalize_polarity(&dark), Cow::Owned(_)),
1494                "{w}x{h}: a solid dark crop must be inverted"
1495            );
1496            let light = GrayImage::from_pixel(w, h, Luma([240u8]));
1497            assert!(
1498                matches!(normalize_polarity(&light), Cow::Borrowed(_)),
1499                "{w}x{h}: a solid light crop must be left alone"
1500            );
1501        }
1502    }
1503
1504    /// The gap this closes, end to end and without the model.
1505    ///
1506    /// `predict_page` used to hand the raw path to the segmenter, and the
1507    /// segmenter treats dark as ink. On a light-on-dark page that means the
1508    /// BACKGROUND is what gets segmented and the returned bands are the gaps
1509    /// BETWEEN the lines — 3 bands where the same page upright gives 4, each one
1510    /// landing on white space. Correcting polarity per crop afterwards cannot
1511    /// recover a line that was never found.
1512    ///
1513    /// This drives `segment_page`, which is the whole of what `predict_page` does
1514    /// before the model, so the assertion covers the ORDER of the two steps and
1515    /// not just the probe in isolation.
1516    #[test]
1517    fn a_dark_mode_page_segments_into_the_same_lines_as_its_upright_twin() {
1518        let page = drawn_page(400, 300, 20, 10, 18);
1519        let dir = tempfile::tempdir().unwrap();
1520        let light_path = dir.path().join("light.png");
1521        let dark_path = dir.path().join("dark.png");
1522        page.save(&light_path).unwrap();
1523        inverted(&page).save(&dark_path).unwrap();
1524
1525        let seg = LineSegmenter::new(10, 3);
1526        let light = seg.segment(&light_path).unwrap();
1527        assert!(
1528            light.len() > 1,
1529            "the upright control found {} line(s); the comparison below needs a \
1530             page that actually segments",
1531            light.len()
1532        );
1533
1534        // Uncorrected, for the record: this is what `predict_page` used to do.
1535        let uncorrected = seg.segment(&dark_path).unwrap();
1536        assert_ne!(
1537            uncorrected.len(),
1538            light.len(),
1539            "the dark page segmented correctly without the probe, so this test \
1540             cannot show the probe is needed — pick a harder fixture"
1541        );
1542
1543        let corrected = segment_page(&seg, &dark_path).unwrap();
1544        assert_eq!(
1545            corrected.len(),
1546            light.len(),
1547            "a dark-mode page gave {} line(s) against {} for the same page \
1548             upright; polarity is not being corrected before segmentation",
1549            corrected.len(),
1550            light.len()
1551        );
1552        for (c, l) in corrected.iter().zip(light.iter()) {
1553            assert_eq!(
1554                (c.bbox.y, c.bbox.h),
1555                (l.bbox.y, l.bbox.h),
1556                "corrected bands must land on the same rows as the upright page"
1557            );
1558        }
1559    }
1560
1561    /// The per-crop half of the same gap. `predict_single_line` never reaches
1562    /// `segment_page`, so without the probe inside `preprocess_line` a dark-mode
1563    /// crop goes to the model inverted — measured at 9.5x the error rate on the
1564    /// 300-crop set quoted above.
1565    ///
1566    /// Comparing tensors rather than text keeps this off the model: an inverted
1567    /// crop and its upright twin must arrive at the graph as the same input.
1568    #[test]
1569    fn a_dark_crop_preprocesses_to_the_same_tensor_as_its_upright_twin() {
1570        let crop = drawn_page(300, 40, 20, 10, 18);
1571        let upright = preprocess_line(&crop, EXPECTED_INPUT_HEIGHT, DEFAULT_INPUT_WIDTH);
1572        let dark = preprocess_line(&inverted(&crop), EXPECTED_INPUT_HEIGHT, DEFAULT_INPUT_WIDTH);
1573
1574        // Guard the guard: a tensor of nothing but white padding would make the
1575        // comparison below hold for the wrong reason.
1576        assert!(
1577            upright.iter().any(|v| *v < 0.0),
1578            "the upright control has no ink in it, so equality proves nothing"
1579        );
1580        assert_eq!(
1581            upright, dark,
1582            "a light-on-dark crop reached the model as a different tensor from \
1583             the same crop upright; the per-crop polarity probe is missing"
1584        );
1585    }
1586
1587    /// The pinned model: input [1, 1, 160, 1024], output [1, sequence, 277].
1588    ///
1589    /// These two must move together. They were 316 and 276 for one commit —
1590    /// mutually inconsistent, since check_contract requires
1591    /// classes == charset_len + 1 — because the migration to v3.5 was verified
1592    /// with `cargo check`, which compiles tests without running them.
1593    const PINNED_CLASSES: usize = 277;
1594    const PINNED_CHAR_LEN: usize = 276;
1595
1596    fn charset_of_len(n: usize) -> Vec<char> {
1597        // Leading U+0020, as the real charset has.
1598        std::iter::once(' ')
1599            .chain(std::iter::repeat_n('x', n - 1))
1600            .collect()
1601    }
1602
1603    #[test]
1604    fn contract_accepts_the_pinned_model() {
1605        check_contract(
1606            PINNED_CHAR_LEN,
1607            Some(PINNED_CLASSES),
1608            Some(EXPECTED_INPUT_HEIGHT as usize),
1609            "model.onnx",
1610        )
1611        .expect("the pinned pair should pass");
1612    }
1613
1614    /// The bundled charset used to be 225 characters against a 316-class model.
1615    #[test]
1616    fn contract_rejects_charset_mismatch() {
1617        let err = check_contract(
1618            225,
1619            Some(PINNED_CLASSES),
1620            Some(EXPECTED_INPUT_HEIGHT as usize),
1621            "model.onnx",
1622        )
1623        .expect_err("225 characters vs 277 classes must be refused");
1624        assert!(err.0.contains("226") && err.0.contains("277"), "{err}");
1625    }
1626
1627    /// `.trim()` eating the leading space is a one-character mismatch, and one
1628    /// character is enough to shift the whole decode.
1629    #[test]
1630    fn contract_rejects_off_by_one_charset() {
1631        check_contract(
1632            PINNED_CHAR_LEN - 1,
1633            Some(PINNED_CLASSES),
1634            Some(EXPECTED_INPUT_HEIGHT as usize),
1635            "model.onnx",
1636        )
1637        .expect_err("275 characters vs 277 classes must be refused");
1638    }
1639
1640    /// This binding hard-coded `target_height: 64` while the pinned model's
1641    /// input is a static 160.
1642    ///
1643    /// Passes PINNED_CLASSES so the class check is satisfied and the height
1644    /// branch is the one actually exercised. With the stale 316 it errored on
1645    /// class count and never reached the height comparison it names.
1646    #[test]
1647    fn contract_rejects_height_mismatch() {
1648        check_contract(
1649            PINNED_CHAR_LEN,
1650            Some(PINNED_CLASSES),
1651            Some(64),
1652            "stale.onnx",
1653        )
1654        .expect_err("a 64-pixel input must be refused");
1655    }
1656
1657    #[test]
1658    fn contract_rejects_empty_charset() {
1659        check_contract(
1660            0,
1661            Some(PINNED_CLASSES),
1662            Some(EXPECTED_INPUT_HEIGHT as usize),
1663            "model.onnx",
1664        )
1665        .expect_err("an empty charset must be refused");
1666    }
1667
1668    /// A dynamic axis reports as -1; there is nothing to compare at load time,
1669    /// so the load passes and decoding re-checks the real output tensor.
1670    #[test]
1671    fn contract_skips_dynamic_axes() {
1672        check_contract(PINNED_CHAR_LEN, None, None, "dynamic.onnx")
1673            .expect("dynamic axes should defer the check");
1674    }
1675
1676    #[test]
1677    fn static_dim_reads_only_fixed_axes() {
1678        let shape = [1i64, 1, 128, -1];
1679        assert_eq!(static_dim(&shape, 2), Some(128));
1680        assert_eq!(static_dim(&shape, 3), None, "dynamic axis");
1681        assert_eq!(static_dim(&shape, 9), None, "out of range");
1682    }
1683
1684    fn synthetic_logits(seq_len: usize, num_classes: usize) -> Vec<f32> {
1685        (0..seq_len * num_classes)
1686            .map(|i| (i as f32 * 0.37).sin())
1687            .collect()
1688    }
1689
1690    /// The decode stride must come from the output tensor, never from the
1691    /// charset. A charset that disagrees is refused outright rather than
1692    /// reinterpreting the whole buffer.
1693    #[test]
1694    fn decode_stride_comes_from_the_tensor() {
1695        let seq_len = 128;
1696        let data = synthetic_logits(seq_len, PINNED_CLASSES);
1697        let shape = [1, seq_len, PINNED_CLASSES];
1698
1699        let text = decode_ctc(&charset_of_len(PINNED_CHAR_LEN), &data, &shape)
1700            .expect("the matching charset should decode");
1701        assert!(!text.is_empty());
1702
1703        // The `.trim()` victim: one character short.
1704        decode_ctc(&charset_of_len(PINNED_CHAR_LEN - 1), &data, &shape)
1705            .expect_err("a 275-character charset against a 277-class tensor must be refused");
1706
1707        // The old bundled charset.
1708        decode_ctc(&charset_of_len(225), &data, &shape)
1709            .expect_err("a 225-character charset against a 277-class tensor must be refused");
1710    }
1711
1712    #[test]
1713    fn decode_rejects_unexpected_shapes() {
1714        let charset = charset_of_len(PINNED_CHAR_LEN);
1715        let data = synthetic_logits(8, PINNED_CLASSES);
1716
1717        decode_ctc(&charset, &data, &[8, PINNED_CLASSES]).expect_err("2-D output");
1718        decode_ctc(&charset, &data, &[1, 0, PINNED_CLASSES]).expect_err("empty sequence axis");
1719        decode_ctc(&charset, &data, &[1, 16, PINNED_CLASSES])
1720            .expect_err("shape larger than the buffer");
1721    }
1722
1723    /// A tiled line must report the box the text actually came from: the tiles
1724    /// are adjacent and full height, so their union is the line.
1725    #[test]
1726    fn union_of_adjacent_tiles_is_the_line() {
1727        let line = BBox {
1728            x: 100,
1729            y: 40,
1730            w: 900,
1731            h: 60,
1732        };
1733        let widths = [254u32, 255, 255, 136];
1734
1735        let mut x = line.x;
1736        let mut acc: Option<BBox> = None;
1737        for w in widths {
1738            let tile = BBox {
1739                x,
1740                y: line.y,
1741                w,
1742                h: line.h,
1743            };
1744            acc = Some(match acc {
1745                Some(current) => union_bbox(current, tile),
1746                None => tile,
1747            });
1748            x += w;
1749        }
1750
1751        let got = acc.expect("at least one tile");
1752        assert_eq!(
1753            (got.x, got.y, got.w, got.h),
1754            (line.x, line.y, line.w, line.h)
1755        );
1756    }
1757
1758    #[test]
1759    fn union_covers_boxes_in_any_order() {
1760        let a = BBox {
1761            x: 10,
1762            y: 5,
1763            w: 4,
1764            h: 2,
1765        };
1766        let b = BBox {
1767            x: 2,
1768            y: 9,
1769            w: 3,
1770            h: 6,
1771        };
1772        let u = union_bbox(a, b);
1773        assert_eq!((u.x, u.y, u.w, u.h), (2, 5, 12, 10));
1774        let flipped = union_bbox(b, a);
1775        assert_eq!(
1776            (flipped.x, flipped.y, flipped.w, flipped.h),
1777            (u.x, u.y, u.w, u.h)
1778        );
1779    }
1780
1781    /// Exposing the knob must not move the default: every existing caller
1782    /// segments exactly as before.
1783    #[test]
1784    fn density_ratio_default_is_unchanged() {
1785        assert_eq!(DEFAULT_DENSITY_THRESHOLD_RATIO, 0.05);
1786        assert_eq!(
1787            MonOcrBuilder::default().density_threshold_ratio,
1788            DEFAULT_DENSITY_THRESHOLD_RATIO
1789        );
1790        assert_eq!(
1791            MonOcr::builder()
1792                .density_threshold_ratio(0.3)
1793                .density_threshold_ratio,
1794            0.3
1795        );
1796    }
1797
1798    /// A ratio of 0 makes every row clear the gap threshold, so the page comes
1799    /// back as one band. That is a wrong result, not a degraded one.
1800    #[test]
1801    fn density_ratio_rejects_useless_values() {
1802        for bad in [0.0, -0.05, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1803            check_density_ratio(bad).expect_err(&format!("{bad} must be refused"));
1804        }
1805        for good in [DEFAULT_DENSITY_THRESHOLD_RATIO, 0.12, 0.5, 1.0] {
1806            assert_eq!(check_density_ratio(good).expect("valid ratio"), good);
1807        }
1808    }
1809
1810    /// CTC: index 0 is blank, repeats collapse, index n maps to charset[n - 1].
1811    #[test]
1812    fn decode_ctc_semantics() {
1813        let charset: Vec<char> = "abc".chars().collect();
1814        let num_classes = charset.len() + 1;
1815
1816        // Timesteps: a, a, blank, a, b, c
1817        let argmax = [1usize, 1, 0, 1, 2, 3];
1818        let mut data = vec![0.0f32; argmax.len() * num_classes];
1819        for (t, &want) in argmax.iter().enumerate() {
1820            data[t * num_classes + want] = 1.0;
1821        }
1822
1823        let got = decode_ctc(&charset, &data, &[1, argmax.len(), num_classes]).unwrap();
1824        assert_eq!(got, "aabc");
1825    }
1826}