MonOCR — line-level OCR for Mon (mnw)
A CRNN that reads one line of Mon text and returns a string. Mon is classified by
UNESCO as a vulnerable language, and this is the model, the charset and the export
artifacts around it.
In production. This revision ships on web
(
ocr.mondevhub.com), Android and iOS.
Held-out CER 0.0100 on 150 unseen lines in a typeface the model never
trained on. That number, its confidence interval and the four things it does not
cover are in
Performance. Read them before quoting it.
What changed at v3.5, and what it breaks
This revision replaces the v2 network. It is not a drop-in. Four parts of the
contract moved:
| v2 (revision a51be11) | v3.5 (this revision) |
|---|
| Input height | 128 | 160 |
| Input width | dynamic — any width accepted | static 1024 |
| Batch axis | fixed at 1 | dynamic |
| Output classes | 316 | 277 |
| Charset | 315 characters | 276 characters |
| Parameters | 6,575,868 | 11,553,437 |
The width axis is the one that catches integrations. v2's graph accepted a crop
of any width; v3.5 accepts 1024 and nothing else. nn.MultiheadAttention fixes
the sequence length when the graph is traced, so a declaration of anything wider
would be a promise the artifact cannot keep. Resize to 1024 before calling.
If you are pinned to a51be11 you are unaffected — that revision still serves v2
and always will. Move deliberately.
Files
| Path | What |
|---|
onnx/monocr.onnx | ONNX FP32, opset 17, 46,247,040 bytes |
onnx/monocr.json | sidecar: charset, geometry, class count, normalization contract |
onnx/charset.txt | the 276 characters, index 1..276; first character is U+0020 |
coreml/monocr.mlpackage | Core ML, FP32 |
coreml/monocr.mlpackage.json | its sidecar |
charset.txt, monocr.json | copies at the root, for consumers that expect them there |
Only the deployment artifacts are published. There is no PyTorch checkpoint
here. ONNX and Core ML are what the SDKs and the apps load, and they are what the
export gates check; a training checkpoint would add weight that nothing in the
toolchain reads.
Load the charset from the same revision as the weights. They are one contract:
a charset from another revision decodes every index to the wrong character and
raises nothing. Strip only \n and \r — the first class is a space, and a bare
.strip() removes it and shifts every index by one.
Input contract
- Grayscale, 1 channel, float32, shape
[batch, 1, 160, 1024]
- Aspect-preserving resize to height 160, then pad the width to 1024
- Normalize
pixel / 127.5 - 1.0
- Pad with white, which is
+1.0 after normalization, not 0.0
- Greedy CTC decode; index 0 is the blank
Feeding raw uint8 in [0, 255] produces confident garbage with no error.
Architecture
MobileNetV3-Large + squeeze-excitation neck → band pooling → 2×BiLSTM(512) →
bottleneck self-attention (256-dim, 4 heads) → Linear(1024 → 277) → CTC.
11,553,437 parameters, all trainable.
Performance
Held-out evaluation
Measured 2026-08-16 on the test split, which was held out from training and never
scored before this run. Under the project's evaluation ledger a given
(data, weights) pair may only be spent once, and this is that spend.
| CER | CI95 | sequence accuracy |
|---|
| Overall | 0.0100 | [0.0056, 0.0147] | 85.33% |
| Mon (n=99) | 0.0113 | [0.0050, 0.0186] | 85.86% |
| Burmese (n=32) | 0.0027 | [0.0000, 0.0064] | 93.75% |
| English (n=14) | 0.0103 | [0.0009, 0.0226] | 71.43% |
| Mon + English (n=5) | 0.0144 | [0.0000, 0.0292] | 60.00% |
n = 150 lines. Macro-averaged CER 0.0047. Expected calibration error 0.0315.
Greedy decoding.
Three baselines ran alongside it, and they are the reason the number is
believable:
| Baseline | CER | What it rules out |
|---|
| empty prediction | 1.0000 | The metric itself. Anything other than exactly 1.0 means the scorer is broken, and the suite refuses to report |
| most-common grapheme | 1.0832 | That the charset's prior alone explains the result |
| 1-nearest-neighbour pixel retrieval over 3,000 training images | 1.3675 | Memorisation. Retrieving the visually closest training image scores worse than predicting nothing, so the test images are not near-duplicates of training ones |
Train/serve skew: none measured. The PyTorch checkpoint and the published
ONNX graph both scored 0.0100 (cer_delta 0.0) and produced
exactly the same string on 150 of 150 lines. The number above therefore
describes the artifact in this repository, not just the checkpoint it came from.
Per typeface: Pyidaungsu-Regular 0.0076 (n=56), Pyidaungsu-Numbers 0.0108
(n=51), Pyidaungsu-Bold 0.0124 (n=43).
Latency p50 134.8 ms/line, CPU, batched — a per-batch timing divided by batch
size, so it is a mean and not a tail.
Four things this number does not cover
- n = 150. The interval is [0.0056, 0.0147]; treat the width as real.
- One typeface. All 150 lines are Pyidaungsu, held out from training. A
second held-out design,
yunghkio, is not represented: this split was
generated before that design was set aside.
- Unseen text, not an unseen renderer. The same generator and the same
augmentation pipeline produced training and test images. A defect in the
renderer is learned, validated and tested against identically. This is the
single largest caveat on the card and only real photographed lines close it.
- Disjointness is argued, not directly verified. The training-side labels
for this model were produced on a machine whose state was not fully retained.
The partition function is byte-unchanged since generation and none of the 150
test labels appears in the local training or validation sets, but that is an
argument from the stability of a hash rather than a direct comparison.
The selection metric, for contrast
val_cer 0.0210 at epoch 23 / step 93,645 — a periodic check over 4,096
lines during training, in a single typeface, on data that also drove checkpoint
selection. It is not an evaluation and it is not comparable to the 0.0100
above: different split, different typeface, different sample size, and one of
them chose the weights being scored.
Against v2, on identical images
The one comparison that is available and fair. Both generations ship an ONNX
export, so the same rendered lines go through both graphs with the same
preprocessing and the same greedy decode. Text is restricted to the 273
characters both charsets can emit, so neither model is charged for a character
it has no class for. 600 lines per arm, rendered 2026-08-15.
| Rendered in | n | v2 CER | v3.5 CER | error reduced |
|---|
| the 32 trained designs | 600 | 0.1470 | 0.0396 | 73% |
namkhon, held out from v3.5 | 600 | 0.0521 | 0.0188 | 64% |
pyidaungsu, yunghkio, held out from v3.5 | 600 | 0.0342 | 0.0051 | 85% |
The bottom two rows are held out from v3.5 and from nothing for v2, which
predates font-disjoint splits, so the comparison is tilted against v3.5 and it
wins anyway. This is a preview, not an evaluation: one rendering pipeline
reading its own output, synthetic for both models. The held-out number above is
the evaluation; this table only says v3.5 reads rendered Mon better than v2 did.
Reading a page
Wide lines need care. Measured 2026-08-15 over 240 rendered lines wide enough to
need the choice, median 3 model windows each: squeezing a whole line into the
1024px canvas scored 0.1434, cutting it into canvas-width tiles at whitespace
columns and joining scored 0.0795. Tile wide lines for this model. The
opposite is true of v2, where squeezing wins — the direction is a property of the
network, so re-measure if you swap models.
Exports, and how they were checked
Both exports are gated against the PyTorch model on a seeded uniform [-1, 1]
input, and the gate fails the build:
- ONNX — logits within
rtol=atol=1e-3.
- Core ML — logits within
1e-3 and an identical CTC-decoded string.
The Core ML gate runs on CPU. The Neural Engine computes in fp16 and its compiler
may reassociate, so what ships to a device is not what was verified. No device
latency number exists for this model, on any platform.
Limitations
- Real photographs. Every training sample is synthetic, rendered by one
generator. A camera photo of physical text is out of domain and fails
confidently — measured on a whiteboard photo, the model returned fluent Mon at
confidence 0.83 for text that appears nowhere on the page. Confidence is not
a usable filter for this.
- Page layout. The input is a cropped line. There is no detector and no
reading-order model.
- Handwriting, except Myanmar digits.
- Beam search. Greedy only in the exported graph.
- Latency. No per-line tail measured, and no on-device number at all.
- Typeface coverage. 35 distinct designs across 79 usable font files bound
every generalisation claim above.
Training data
Rendered from a Mon text corpus. The corpus licensing is not fully settled:
one source has no established licence, and CC BY-SA attribution for another
cannot be reconstructed because per-article URLs were stripped at import. The MIT
licence on this repository covers the model weights and the code that produced
them; it does not resolve the provenance of the text they were rendered from.
Citation
1@misc{monocr,
2 title = {MonOCR: line-level OCR for the Mon language},
3 author = {Zin Min},
4 year = {2026},
5 url = {https://huggingface.co/janakhpon/monocr}
6}