On-device OCR for the Mon language (mnw), running on ONNX Runtime, with bindings for Python, JavaScript (Node.js), Go and Rust. Images never leave the machine.
Mon is classified as vulnerable in UNESCO's Atlas of the World's Languages in Danger. This project digitises the Mon script so that later work — system integrations, corpora, language models — has something to build on.
- 0.5.0 is the version in this tree, for all four bindings. The four share one version number, one model and one charset; see the changelog.
- It reads printed Mon. Handwriting is out of scope.
- No accuracy figure is claimed here. The model card has the held-out result and what it does not cover.
- The bindings do not yet return the same text for the same page. See Limitations.
Important
Upgrade the JavaScript package. Every npm release before 0.4.0 returned
noise rather than text: the preprocessing step read a three-channel buffer as
if it were one channel, so it sampled the wrong bytes. On a typeset page
monocr@0.3.2 returned 168 characters of garbage where 0.4.0 returns 1,178 of
Mon. The Python, Go and Rust bindings were never affected.
pip install "monocr-onnx>=0.4.1" # Python; or: uv add "monocr-onnx>=0.4.1"
npm install "monocr@>=0.4.1" # Node.js
go get github.com/MonDevHub/monocr-onnx/go # Go
cargo add monocr # RustKeep the floors. 0.1.x pairs a 225-character charset with a 277-class graph and
returns wrong characters, not merely worse ones, and every npm release before
0.4.0 returns noise. pip and npm take the newest release above the floor, and
go get and cargo add the newest release. npm then saves a caret range, which
on 0.x stops below the next minor, so moving from 0.4.x to 0.5.x takes another
install.
The Rust crate is monocr, but the library it exposes is monocr_onnx:
use monocr_onnx::MonOcr.
The first run downloads the model (46.2 MB) and caches it. PDFs need poppler's
pdftoppm on the PATH in every binding; images need nothing extra.
from monocr_onnx import MonOCR
# Downloads the pinned model on first run and caches it by revision.
engine = MonOCR()
# Page-level: segments into lines, then reads each one.
text = engine.predict("scanned_document.jpg")
print(text)
# Line-level: skips segmentation, for a crop you have already cut.
line_text = engine.predict_line("single_line_crop.png")Each binding's README has its own quick start; the Python, JavaScript and Go
ones also cover a CLI. Runnable examples for Python, JavaScript and Go are in
examples/.
All four run on the CPU, and are built for Linux, Windows and Apple-silicon
macOS. Intel Macs are narrower: onnxruntime-node ships no Intel-macOS binary,
the Rust crate's build has no prebuilt ONNX Runtime to link there, and neither
Microsoft's 1.24.1 release nor Homebrew has a prebuilt Intel-macOS library for
Go. Python still installs there, on onnxruntime 1.23.2 (the last release with an
Intel-macOS wheel) and Python 3.11 to 3.13. CI runs on Linux only.
| Binding | Directory | Package | Requires | In this tree |
|---|---|---|---|---|
| Python | python/ |
PyPI: monocr-onnx | Python 3.11+ | 0.5.0 |
| JavaScript | js/ |
npm: monocr | Node.js 20.9+ | 0.5.0 |
| Go | go/ |
pkg.go.dev | Go 1.23+, ONNX Runtime 1.18.0+ shared library | v0.5.0 |
| Rust | rust/ |
crates.io: monocr | Rust 1.88+ | 0.5.0 |
Go is the only binding that always needs ONNX Runtime installed separately; it
loads the shared library at run time. go/README.md covers installing it.
The Python and JavaScript packages bring the runtime with them, and the Rust
crate links a prebuilt one at build time on the targets ort publishes one for.
"In this tree" is the version the source here declares, not what a registry serves. A tag and a publish are different events: if a registry's page shows an older number than a pushed tag, that release has not landed.
Image (File/Buffer)
ModelManager → fetch + cache monocr.onnx at the pinned revision
LineSegmenter → horizontal projection profile → line boxes
MonOCR.predict → crop + scale + normalize to [-1.0, 1.0]
→ ONNX Runtime session → greedy CTC decode → String
The names are the Python and JavaScript classes; JavaScript's page method is
predictPage. Rust spells the engine
MonOcr; Go uses model.Manager, segmenter.LineSegmenter and
predictor.Predictor.
| Attribute | Specification |
|---|---|
| Architecture | MobileNetV3-Large + SE + 2×BiLSTM-512 + attention + CTC |
| Precision | FP32 (ONNX) |
| Parameters | 11.55M |
| Input | 160 × 1024 (H × W), both static |
| Charset | 276 characters, 277 classes |
| Asset size | 46.2 MB |
The model is pinned to revision d3d9d5e of
janakhpon/monocr, not to main, and
cached under ~/.monocr/models/<revision>/. Each binding ships the matching
charset and refuses to decode if the two disagree: CTC reserves index 0 for the
blank, so a model over N characters must emit N + 1 classes. Without that check
a mismatch returns well-formed Mon text that is wrong, with no error and no
lookup miss. Decoding refuses a broken output the same way: logits holding a NaN
or an infinity raise ModelOutputError (Go: *predictor.OutputError), and
logits that are not [1, sequence, N + 1] raise ModelContractError (Go:
*predictor.ContractError), rather than decoding into a blank or wrong line.
A file the binding opens itself, and in JavaScript an encoded Buffer, is turned
the way it is displayed, from its EXIF Orientation tag. An image you pass in
already decoded (a PIL Image, a sharp instance, an image.Image) is not
re-oriented. Transparency is composited onto white before the conversion to grey
for every input, opened by the binding or decoded by you, so a transparent
background is not read as black; the one exception is a NumPy array handed
straight to Python's LineSegmenter.segment, which is converted as given. Go and
Rust read the tag only from JPEG and from a PNG eXIf chunk;
Python and JavaScript also read it from the other formats their imaging libraries
decode, such as TIFF and WebP.
docs/CROSS_BINDING_PARITY.md
has the remaining differences.
The bindings disagree on page output. Measured 2026-10-01 on the page path —
read_image / predict, the call the quick starts use — over the seven images
in data/images/: Python, JavaScript and Go return identical
text on 1 of 7. Pairwise, Python matches JavaScript on 1, Python matches Go
on 1, and JavaScript matches Go on 3. Rust matches Python on 3. The widest gap is
000028.jpg, where Python and Rust return 154 characters and JavaScript and Go
return 55 and 61.
The cause is two things compounding: four different image-resampling kernels, two of them the wrong family, and line segmenters that are not configured alike. Python binarises adaptively where the other three use a flat threshold at 128; Python sets its gap threshold at 0.02 of the profile's maximum with a smoothing window of 5, where the other three use 0.05 of the non-zero mean and a window of 3; and Python and Rust cut a line too wide for the model into tiles where JavaScript and Go squeeze it.
That is agreement, not accuracy. These images have no ground truth, and four implementations reading the same wrong thing would agree perfectly.
docs/CROSS_BINDING_PARITY.md reports 5 of 7.
It measured the line path on pre-cropped files against the previous model, which
skips segmentation entirely, and its header marks the figures stale. Only the
page figure above describes what the documented API does.
- Model card — ONNX and Core ML exports, the held-out evaluation and its caveats.
- MonDevHub/monocr — the apps built on this
model:
apps/web(SvelteKit),apps/android(Jetpack Compose) andapps/ios(SwiftUI). The Android and iOS apps build from source and are not in an app store yet. The apps cap a file you open for recognition at 50 MB; this SDK has no such limit. - Issues — bugs and
questions.
RELEASING.mddescribes how a release is cut.