Skip to main content

MonOcr

Struct MonOcr 

Source
pub struct MonOcr { /* private fields */ }
Expand description

Main OCR engine for text recognition

This struct encapsulates the OCR pipeline including:

  • ONNX runtime session for model inference
  • Character set for decoding predictions
  • Line segmenter for page layout analysis
  • Image preprocessing utilities

§Usage

Typically, you would create a MonOcr instance using the builder:

use monocr_onnx::MonOcr;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut ocr = MonOcr::builder().build().await?;
    let text = ocr.read_image("document.png").await?;
    println!("Recognized: {}", text);
    Ok(())
}

The instance must be mutable because internal state is modified during inference (e.g., the ONNX session).

Implementations§

Source§

impl MonOcr

Source

pub fn builder() -> MonOcrBuilder

Create a builder for configuring MonOcr

This is the entry point for creating a customized OCR instance. Use the builder methods to configure options, then call build().

§Returns

A new MonOcrBuilder instance

§Example
use monocr_onnx::MonOcr;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut ocr = MonOcr::builder()
        .min_line_height(15)
        .build()
        .await?;
    Ok(())
}
Examples found in repository?
examples/tiling_ab.rs (line 101)
59async fn main() -> Result<()> {
60    let dir: PathBuf = std::env::args()
61        .nth(1)
62        .context("usage: tiling_ab <dir with line_*.png and labels.txt>")?
63        .into();
64
65    let labels_path = dir.join("labels.txt");
66    let labels_raw = std::fs::read_to_string(&labels_path)
67        .with_context(|| format!("cannot read {}", labels_path.display()))?;
68
69    let mut labels: Vec<(PathBuf, String)> = Vec::new();
70    for line in labels_raw.lines() {
71        let Some((name, text)) = line.split_once('\t') else {
72            continue;
73        };
74        labels.push((dir.join(name), text.to_string()));
75    }
76    if labels.is_empty() {
77        anyhow::bail!(
78            "{} contained no tab-separated entries",
79            labels_path.display()
80        );
81    }
82    eprintln!("{} labelled lines", labels.len());
83
84    // Null baseline, asserted before any real number is produced. If the metric
85    // arithmetic is wrong every rate below is wrong in the same direction, and
86    // nothing else in this example would reveal it.
87    assert_eq!(
88        char_cer("", "abc"),
89        1.0,
90        "empty prediction must score exactly 1.0"
91    );
92    assert_eq!(
93        char_cer("abc", "abc"),
94        0.0,
95        "identity must score exactly 0.0"
96    );
97    eprintln!("null baseline ok");
98
99    // Two sessions rather than rebuilding one per arm: the flag is fixed at build
100    // time, and reloading the graph 240 times would dominate the runtime.
101    let mut tiled = MonOcr::builder().tile_wide_lines(true).build().await?;
102    let mut squeezed = MonOcr::builder().tile_wide_lines(false).build().await?;
103
104    let mut rows: Vec<Row> = Vec::new();
105    for (i, (path, truth)) in labels.iter().enumerate() {
106        let t = tiled.predict_single_line(path).await?;
107        let s = squeezed.predict_single_line(path).await?;
108
109        // Recovered from the geometry: a tiled read reports the union of its
110        // tiles, so width over the window width is the tile count.
111        let img = image::open(path)?.to_luma8();
112        let (w, h) = img.dimensions();
113        let scaled = (w as f64 * (160.0 / h as f64)) as u32;
114        let tiles = ((scaled as f64) / 1024.0).ceil().max(1.0) as usize;
115
116        rows.push(Row {
117            tiles,
118            cer_tiled: char_cer(&t.text, truth),
119            cer_squeezed: char_cer(&s.text, truth),
120        });
121
122        if (i + 1) % 25 == 0 {
123            eprintln!("  {}/{}", i + 1, labels.len());
124        }
125    }
126
127    let mean =
128        |f: fn(&Row) -> f64, rs: &[Row]| -> f64 { rs.iter().map(f).sum::<f64>() / rs.len() as f64 };
129    let m_sq = mean(|r| r.cer_squeezed, &rows);
130    let m_ti = mean(|r| r.cer_tiled, &rows);
131
132    println!("\nn                {}", rows.len());
133    println!("squeezed CER     {m_sq:.4}");
134    println!("tiled CER        {m_ti:.4}");
135    println!("ratio sq/tiled   {:.2}x", m_sq / m_ti);
136    println!(
137        "tiled better on  {}/{}",
138        rows.iter().filter(|r| r.cer_tiled < r.cer_squeezed).count(),
139        rows.len()
140    );
141
142    // The band table is the finding; the aggregate depends entirely on the width
143    // mix of whatever sample was handed in.
144    let mut bands: BTreeMap<usize, Vec<&Row>> = BTreeMap::new();
145    for r in &rows {
146        bands.entry(r.tiles).or_default().push(r);
147    }
148    println!("\n tiles     n   squeezed     tiled    ratio");
149    for (band, sub) in &bands {
150        if sub.len() < 3 {
151            continue;
152        }
153        let s_m = sub.iter().map(|r| r.cer_squeezed).sum::<f64>() / sub.len() as f64;
154        let t_m = sub.iter().map(|r| r.cer_tiled).sum::<f64>() / sub.len() as f64;
155        println!(
156            " {band:>5}  {:>4}   {s_m:>8.4}  {t_m:>8.4}  {:>6.1}x",
157            sub.len(),
158            s_m / t_m
159        );
160    }
161    Ok(())
162}
Source

pub async fn read_image( &mut self, image_path: impl AsRef<Path>, ) -> Result<String>

Read text from a single image

This method performs OCR on a single image file. The image is automatically segmented into lines, and each line is recognized using the ONNX model.

§Arguments
  • image_path - Path to the image file (PNG, JPG, BMP, etc.)
§Returns
  • Ok(String) - Recognized text with lines separated by newlines
  • Err(anyhow::Error) - If the image cannot be read or OCR fails
§Example
use monocr_onnx::MonOcr;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut ocr = MonOcr::builder().build().await?;
    let text = ocr.read_image("document.png").await?;
    println!("Recognized text:\n{}", text);
    Ok(())
}
Source

pub async fn read_images( &mut self, image_paths: &[impl AsRef<Path>], ) -> Result<Vec<String>>

Read text from multiple images

This method processes multiple images in sequence, returning a vector of recognized texts. Each image is segmented into lines and processed individually.

§Arguments
  • image_paths - A slice of paths to image files
§Returns
  • Ok(Vec<String>) - Vector of recognized texts, one per image
  • Err(anyhow::Error) - If any image cannot be processed
§Example
use monocr_onnx::MonOcr;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut ocr = MonOcr::builder().build().await?;
    let paths = vec!["page1.png", "page2.png", "page3.png"];
    let results = ocr.read_images(&paths).await?;
    for (i, text) in results.iter().enumerate() {
        println!("Page {}: {}", i + 1, text);
    }
    Ok(())
}
Source

pub async fn read_pdf( &mut self, pdf_path: impl AsRef<Path>, ) -> Result<Vec<String>>

Read text from a PDF file

This method converts a PDF document to images using pdftoppm and performs OCR on each page. Each page is processed as a separate image.

§Arguments
  • pdf_path - Path to the PDF file
§Returns
  • Ok(Vec<String>) - Vector of recognized texts, one per page
  • Err(anyhow::Error) - If PDF conversion fails or OCR fails
§Requirements

Requires pdftoppm from poppler-utils to be installed:

  • Ubuntu/Debian: sudo apt-get install poppler-utils
  • macOS: brew install poppler
Source

pub async fn predict_pdf( &mut self, pdf_path: impl AsRef<Path>, ) -> Result<Vec<Vec<LineResult>>>

Predict text and geometry from a PDF file, page by page

Same conversion as read_pdf, but keeps the per-line bounding boxes. Coordinates are in pixels of the 300 DPI render of the page, not PDF points.

§Returns
  • Ok(Vec<Vec<LineResult>>) - One vector of line results per page
  • Err(anyhow::Error) - If PDF conversion fails or OCR fails
Source

pub async fn read_image_with_accuracy( &mut self, image_path: impl AsRef<Path>, ground_truth: &str, ) -> Result<OcrResult>

Read image with accuracy measurement

This method performs OCR on an image and calculates accuracy by comparing the recognized text against ground truth using Levenshtein distance.

§Arguments
  • image_path - Path to the image file
  • ground_truth - The expected/ground truth text to compare against
§Returns
  • Ok(OcrResult) - Contains recognized text and accuracy percentage
  • Err(anyhow::Error) - If OCR fails
§Accuracy Calculation

Accuracy = (1 - CER) * 100, where CER is Character Error Rate calculated as Levenshtein distance / max(len(predicted), len(ground_truth))

Source

pub async fn predict_page( &mut self, image_path: impl AsRef<Path>, ) -> Result<Vec<LineResult>>

Predict text from a full page image

This method segments the image into lines and recognizes each line using the ONNX model. Returns results with text and bounding boxes.

§Arguments
  • image_path - Path to the full page image
§Returns
  • Ok(Vec<LineResult>) - Vector of line results with text and bounding boxes
  • Err(anyhow::Error) - If segmentation or OCR fails
§Process
  1. Segment the page into individual text lines using horizontal projection
  2. For each line:
    • Tile it at whitespace columns if it is too wide for the model window
    • Preprocess each tile for the model
    • Run inference with the ONNX model
    • Decode CTC output to text
  3. Return one result per line, with the text of its tiles concatenated
§Wide lines

A line wider than the model window is tiled by crate::segmenter::tile_line, not squeezed.

Measured on this binding, 2026-08-22, over 201 rendered Mon lines by examples/tiling_ab.rs. The answer depends on how wide the line is:

tiles   squeezed   tiled    winner
    2     0.0444  0.0635    squeezing, 0.7x
    3     0.0317  0.0294    parity, 1.1x
    4     0.1509  0.0364    tiling, 4.1x
    6     0.8382  0.0229    tiling, 36.5x
    8     0.9090  0.0387    tiling, 23.5x

So tiling is not a uniform win: it is a safety net. Up to 3 tiles the two are level, and from 4 up squeezing degrades without bound while tiling stays flat. Tiling is the default because that asymmetry is the whole argument — the downside is a fraction of a point on already-low rates, and the upside is not losing the line.

Char-level CER here; mon_OCR/eval/tiling-ab-2026-08-22.md scores the same images by grapheme cluster and finds the same crossover. That report also records that these numbers do not reproduce the older squeezed-0.1434-against-tiled-0.0795 figures quoted elsewhere, whose harness was never committed.

The measurement is one held-out font at one size, on rendered lines rather than photographed pages. If the pinned model moves, re-run the example rather than assuming any of this still holds.

The tiles of one line are joined with no separator, and their union is reported as that line’s bbox. Joining them with a newline is what produced “Mon E-boo” and “k library” as two readings of a single line.

Source

pub async fn predict_single_line( &mut self, image_path: impl AsRef<Path>, ) -> Result<LineResult>

Recognise an image that is already a single cropped line

Skips segmentation entirely. Use when the caller knows the input is one line — segmenting a line fragments it, because the projection profile has no gap to find and any faint row inside the glyphs becomes one. The crop is still tiled if it is wider than the model window, so a long line is not squeezed.

Deciding when an input is a single line belongs to the caller; the library does not guess.

§Returns
  • Ok(LineResult) - The text, with a bbox covering the whole source image
  • Err(anyhow::Error) - If the image cannot be read or inference fails
Examples found in repository?
examples/tiling_ab.rs (line 106)
59async fn main() -> Result<()> {
60    let dir: PathBuf = std::env::args()
61        .nth(1)
62        .context("usage: tiling_ab <dir with line_*.png and labels.txt>")?
63        .into();
64
65    let labels_path = dir.join("labels.txt");
66    let labels_raw = std::fs::read_to_string(&labels_path)
67        .with_context(|| format!("cannot read {}", labels_path.display()))?;
68
69    let mut labels: Vec<(PathBuf, String)> = Vec::new();
70    for line in labels_raw.lines() {
71        let Some((name, text)) = line.split_once('\t') else {
72            continue;
73        };
74        labels.push((dir.join(name), text.to_string()));
75    }
76    if labels.is_empty() {
77        anyhow::bail!(
78            "{} contained no tab-separated entries",
79            labels_path.display()
80        );
81    }
82    eprintln!("{} labelled lines", labels.len());
83
84    // Null baseline, asserted before any real number is produced. If the metric
85    // arithmetic is wrong every rate below is wrong in the same direction, and
86    // nothing else in this example would reveal it.
87    assert_eq!(
88        char_cer("", "abc"),
89        1.0,
90        "empty prediction must score exactly 1.0"
91    );
92    assert_eq!(
93        char_cer("abc", "abc"),
94        0.0,
95        "identity must score exactly 0.0"
96    );
97    eprintln!("null baseline ok");
98
99    // Two sessions rather than rebuilding one per arm: the flag is fixed at build
100    // time, and reloading the graph 240 times would dominate the runtime.
101    let mut tiled = MonOcr::builder().tile_wide_lines(true).build().await?;
102    let mut squeezed = MonOcr::builder().tile_wide_lines(false).build().await?;
103
104    let mut rows: Vec<Row> = Vec::new();
105    for (i, (path, truth)) in labels.iter().enumerate() {
106        let t = tiled.predict_single_line(path).await?;
107        let s = squeezed.predict_single_line(path).await?;
108
109        // Recovered from the geometry: a tiled read reports the union of its
110        // tiles, so width over the window width is the tile count.
111        let img = image::open(path)?.to_luma8();
112        let (w, h) = img.dimensions();
113        let scaled = (w as f64 * (160.0 / h as f64)) as u32;
114        let tiles = ((scaled as f64) / 1024.0).ceil().max(1.0) as usize;
115
116        rows.push(Row {
117            tiles,
118            cer_tiled: char_cer(&t.text, truth),
119            cer_squeezed: char_cer(&s.text, truth),
120        });
121
122        if (i + 1) % 25 == 0 {
123            eprintln!("  {}/{}", i + 1, labels.len());
124        }
125    }
126
127    let mean =
128        |f: fn(&Row) -> f64, rs: &[Row]| -> f64 { rs.iter().map(f).sum::<f64>() / rs.len() as f64 };
129    let m_sq = mean(|r| r.cer_squeezed, &rows);
130    let m_ti = mean(|r| r.cer_tiled, &rows);
131
132    println!("\nn                {}", rows.len());
133    println!("squeezed CER     {m_sq:.4}");
134    println!("tiled CER        {m_ti:.4}");
135    println!("ratio sq/tiled   {:.2}x", m_sq / m_ti);
136    println!(
137        "tiled better on  {}/{}",
138        rows.iter().filter(|r| r.cer_tiled < r.cer_squeezed).count(),
139        rows.len()
140    );
141
142    // The band table is the finding; the aggregate depends entirely on the width
143    // mix of whatever sample was handed in.
144    let mut bands: BTreeMap<usize, Vec<&Row>> = BTreeMap::new();
145    for r in &rows {
146        bands.entry(r.tiles).or_default().push(r);
147    }
148    println!("\n tiles     n   squeezed     tiled    ratio");
149    for (band, sub) in &bands {
150        if sub.len() < 3 {
151            continue;
152        }
153        let s_m = sub.iter().map(|r| r.cer_squeezed).sum::<f64>() / sub.len() as f64;
154        let t_m = sub.iter().map(|r| r.cer_tiled).sum::<f64>() / sub.len() as f64;
155        println!(
156            " {band:>5}  {:>4}   {s_m:>8.4}  {t_m:>8.4}  {:>6.1}x",
157            sub.len(),
158            s_m / t_m
159        );
160    }
161    Ok(())
162}

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> Pointable for T

Source§

const ALIGN: usize

The alignment of pointer.
Source§

type Init = T

The type for initializers.
Source§

unsafe fn init(init: <T as Pointable>::Init) -> usize

Initializes a with the given initializer. Read more
Source§

unsafe fn deref<'a>(ptr: usize) -> &'a T

Dereferences the given pointer. Read more
Source§

unsafe fn deref_mut<'a>(ptr: usize) -> &'a mut T

Mutably dereferences the given pointer. Read more
Source§

unsafe fn drop(ptr: usize)

Drops the object pointed to by the given pointer. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more