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}