Skip to main content

monocr_onnx/
lib.rs

1//! MonOcr - Mon language OCR library using ONNX models
2//!
3//! This library provides OCR (Optical Character Recognition) functionality for Mon text
4//! using deep learning models. It supports reading text from images and PDFs, with optional
5//! accuracy measurement against ground truth text.
6//!
7//! Mon (`mnw`) is a Mon-Khmer language of Myanmar and Thailand, written in a
8//! Myanmar-script orthography. It is unrelated to Mongolian.
9//!
10//! # The model
11//!
12//! Weights are downloaded from [janakhpon/monocr](https://huggingface.co/janakhpon/monocr),
13//! pinned to revision [`model_manager::MODEL_REVISION`]. That artifact takes a
14//! `[1, 1, 160, 1024]` input and emits `[1, sequence, 277]` logits: 276
15//! characters plus the CTC blank. The width is static: v3.5 accepts 1024 and
16//! nothing else, where v2 accepted any width.
17//!
18//! The charset, the input height and the classifier width are one contract. If
19//! they drift apart the model still runs and still returns text — it is just the
20//! wrong text, with no error anywhere. So the graph is read on load and a
21//! disagreement yields [`ModelContractError`] instead of a result.
22//!
23//! # Quick Start
24//!
25//! ```no_run
26//! use monocr_onnx::read_image;
27//!
28//! #[tokio::main]
29//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
30//!     let text = read_image("path/to/image.png").await?;
31//!     println!("Recognized text: {}", text);
32//!     Ok(())
33//! }
34//! ```
35//!
36//! # Features
37//!
38//! - Read text from single images (PNG, JPG, etc.)
39//! - Read text from multiple images in batch
40//! - Read text from PDF files (requires poppler-utils)
41//! - Measure OCR accuracy against ground truth
42//! - Customizable model paths and character sets
43//! - Line segmentation for full page OCR
44//! - Lines too wide for the 1024px model window are tiled at whitespace columns
45//!   rather than squeezed into it; see [`MonOcr::predict_page`] for the measured
46//!   reason
47
48use anyhow::Result;
49use std::path::Path;
50
51pub mod model_manager;
52mod monocr;
53pub mod segmenter;
54mod utils;
55
56pub use model_manager::ModelManager;
57pub use monocr::{
58    normalize_charset, normalize_polarity, page_text, BBox, LineResult, ModelContractError, MonOcr,
59    MonOcrBuilder, DEFAULT_INPUT_WIDTH, EXPECTED_INPUT_HEIGHT,
60};
61pub use segmenter::{
62    cut_column, tile_line, CUT_INK_THRESHOLD, CUT_SEARCH_FRACTION, DEFAULT_DENSITY_THRESHOLD_RATIO,
63};
64pub use utils::calculate_accuracy;
65
66/// Read text from a single image file
67///
68/// This function initializes a new MonOcr instance with default settings and performs
69/// OCR on the given image. The image is automatically segmented into lines, and each
70/// line is recognized using the ONNX model.
71///
72/// # Arguments
73///
74/// * `image_path` - Path to the image file (PNG, JPG, BMP, etc.)
75///
76/// # Returns
77///
78/// Returns a `Result<String>` containing the recognized text, with lines separated by newlines.
79///
80/// # Example
81///
82/// ```no_run
83/// use monocr_onnx::read_image;
84///
85/// #[tokio::main]
86/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
87///     let text = read_image("document.png").await?;
88///     println!("Recognized: {}", text);
89///     Ok(())
90/// }
91/// ```
92pub async fn read_image(image_path: impl AsRef<Path>) -> Result<String> {
93    let mut ocr = MonOcr::builder().build().await?;
94    ocr.read_image(image_path).await
95}
96
97/// Read text from multiple image files
98///
99/// This function processes multiple images in sequence, returning a vector of recognized texts.
100/// Each image is segmented into lines and processed individually.
101///
102/// # Arguments
103///
104/// * `image_paths` - A slice of paths to image files
105///
106/// # Returns
107///
108/// Returns a `Result<Vec<String>>` where each element contains the recognized text
109/// from the corresponding image. Lines within each text are separated by newlines.
110///
111/// # Example
112///
113/// ```no_run
114/// use monocr_onnx::read_images;
115///
116/// #[tokio::main]
117/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
118///     let paths = vec!["page1.png", "page2.png", "page3.png"];
119///     let results = read_images(&paths).await?;
120///     for (i, text) in results.iter().enumerate() {
121///         println!("Page {}: {}", i + 1, text);
122///     }
123///     Ok(())
124/// }
125/// ```
126pub async fn read_images(image_paths: &[impl AsRef<Path>]) -> Result<Vec<String>> {
127    let mut ocr = MonOcr::builder().build().await?;
128    ocr.read_images(image_paths).await
129}
130
131/// Read text from a PDF file
132///
133/// This function converts a PDF document to images (using pdftoppm from poppler-utils)
134/// and performs OCR on each page. Each page is treated as a separate image.
135///
136/// # Arguments
137///
138/// * `pdf_path` - Path to the PDF file
139///
140/// # Returns
141///
142/// Returns a `Result<Vec<String>>` where each element contains the recognized text
143/// from the corresponding page.
144///
145/// # Requirements
146///
147/// This function requires `pdftoppm` from the poppler-utils package to be installed:
148/// - Ubuntu/Debian: `sudo apt-get install poppler-utils`
149/// - macOS: `brew install poppler`
150/// - Fedora/RHEL: `sudo dnf install poppler-utils`
151///
152/// # Example
153///
154/// ```no_run
155/// use monocr_onnx::read_pdf;
156///
157/// #[tokio::main]
158/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
159///     let pages = read_pdf("document.pdf").await?;
160///     for (i, text) in pages.iter().enumerate() {
161///         println!("=== Page {} ===\n{}", i + 1, text);
162///     }
163///     Ok(())
164/// }
165/// ```
166pub async fn read_pdf(pdf_path: impl AsRef<Path>) -> Result<Vec<String>> {
167    let mut ocr = MonOcr::builder().build().await?;
168    ocr.read_pdf(pdf_path).await
169}
170
171/// Read text from an image with accuracy measurement
172///
173/// This function performs OCR on an image and calculates the accuracy by comparing
174/// the recognized text against the ground truth using Levenshtein distance.
175///
176/// # Arguments
177///
178/// * `image_path` - Path to the image file
179/// * `ground_truth` - The expected/ground truth text to compare against
180///
181/// # Returns
182///
183/// Returns a `Result<OcrResult>` containing:
184/// - `text`: The recognized text from the image
185/// - `accuracy`: A percentage (0-100) representing how close the recognized text is
186///   to the ground truth
187///
188/// # Accuracy Calculation
189///
190/// Accuracy is calculated as: `(1 - CER) * 100` where CER is the Character Error Rate
191/// (Levenshtein distance divided by the maximum length of the two strings).
192/// This gives a percentage score where 100% means perfect recognition.
193///
194/// # Example
195///
196/// ```no_run
197/// use monocr_onnx::read_image_with_accuracy;
198///
199/// #[tokio::main]
200/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
201///     let result = read_image_with_accuracy("image.png", "ဘာသာမန်").await?;
202///     println!("Recognized: {}", result.text);
203///     println!("Accuracy: {:.2}%", result.accuracy);
204///     Ok(())
205/// }
206/// ```
207pub async fn read_image_with_accuracy(
208    image_path: impl AsRef<Path>,
209    ground_truth: &str,
210) -> Result<OcrResult> {
211    let mut ocr = MonOcr::builder().build().await?;
212    ocr.read_image_with_accuracy(image_path, ground_truth).await
213}
214
215/// OCR result containing recognized text and accuracy measurement
216///
217/// This struct is returned by [`read_image_with_accuracy`] and contains both
218/// the recognized text and the accuracy score when compared against ground truth.
219///
220/// # Fields
221///
222/// * `text` - The recognized text from the OCR process
223/// * `accuracy` - A percentage value (0-100) indicating how closely the recognized
224///   text matches the ground truth
225///
226/// # Example
227///
228/// ```no_run
229/// use monocr_onnx::read_image_with_accuracy;
230///
231/// #[tokio::main]
232/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
233///     let result = read_image_with_accuracy("test.png", "Hello World").await?;
234///     if result.accuracy >= 90.0 {
235///         println!("Good recognition: {}", result.text);
236///     } else {
237///         println!("Poor recognition: {} ({}% accuracy)", result.text, result.accuracy);
238///     }
239///     Ok(())
240/// }
241/// ```
242#[derive(Debug, Clone)]
243pub struct OcrResult {
244    /// The recognized text from the image
245    pub text: String,
246    /// Accuracy percentage (0-100) based on Levenshtein distance
247    pub accuracy: f64,
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// The pinned model (`model_manager::MODEL_REVISION`) emits 277 classes:
255    /// 276 characters plus the CTC blank at index 0.
256    const PINNED_CHARSET_LEN: usize = 276;
257
258    const EMBEDDED_CHARSET: &str = include_str!("charset.txt");
259
260    #[test]
261    fn embedded_charset_matches_the_pinned_model() {
262        let n = normalize_charset(EMBEDDED_CHARSET).chars().count();
263        assert_eq!(
264            n,
265            PINNED_CHARSET_LEN,
266            "bundled charset has {n} characters, the pinned model expects {PINNED_CHARSET_LEN} \
267             ({} classes minus the CTC blank)",
268            PINNED_CHARSET_LEN + 1
269        );
270    }
271
272    /// The charset's first character is U+0020. A bare `.trim()` eats it,
273    /// dropping 276 to 275 and shifting every index in the decode by one — the
274    /// model still runs and still returns text, just the wrong text.
275    #[test]
276    fn embedded_charset_keeps_its_leading_space() {
277        let charset = normalize_charset(EMBEDDED_CHARSET);
278        assert_eq!(
279            charset.chars().next(),
280            Some(' '),
281            "charset must start with U+0020"
282        );
283        assert_eq!(
284            charset.trim().chars().count(),
285            PINNED_CHARSET_LEN - 1,
286            "expected .trim() to drop exactly the leading space"
287        );
288    }
289
290    #[test]
291    fn normalize_charset_trims_only_line_terminators() {
292        assert_eq!(normalize_charset(" abc"), " abc");
293        assert_eq!(normalize_charset(" abc\n"), " abc");
294        assert_eq!(normalize_charset(" abc\r\n"), " abc");
295        assert_eq!(normalize_charset("\n abc\n"), " abc");
296        // A trailing space is a class too.
297        assert_eq!(normalize_charset(" abc "), " abc ");
298    }
299
300    #[tokio::test]
301    #[ignore = "requires network access to download model from HuggingFace"]
302    async fn test_builder() {
303        let builder = MonOcr::builder();
304        assert!(builder.build().await.is_ok());
305    }
306}