Paper/

Netra OCR 1

A 19M-parameter model for reading printed Khmer and English text

One imaginary flowering shrub, painted in ink on paper

Generated with Nonflowers by Lingdong Huang · MIT

Parameters
19M
Training images
1.3M
License
MIT

Khmer is hard for machines to read. The script stacks consonants on top of one another, places vowels above, below and on both sides of the consonant they belong to, and runs words together without spaces. General-purpose OCR systems treat Khmer as an afterthought, and large vision-language models, which can read many scripts, are expensive to run and, as we found, unreliable on Khmer.

Netra OCR 1 is a 19M-parameter model that reads one line of printed Khmer or English text and returns the characters on it. We trained it from scratch on 1.3 million text-line images, every one of them synthetic, and tested it on real scans and phone photos.

In brief:

  • Accuracy. It has the lowest character error rate of the five systems we tested, on all three test sets. On the public KHOB benchmark it gets 0.96% of characters wrong, against 5.12% for Tesseract.
  • Hard photos. On phone photos of legal documents, the hardest set, its error rate is 5.31%. Every other system gets more than 40% of characters wrong.
  • Size. It is small enough for a laptop. In our app it reads a page in about 1.1 seconds on a desktop CPU, with no GPU.

The code and weights are open source under the MIT licence.

Architecture#

Chunking#

Text lines vary enormously in length, from a two-character ID field to a 200-character sentence. Squeezing every line into one fixed-size image would distort the long ones beyond recognition. Instead, we resize each line to a height of 48 pixels, keeping its proportions, and cut it into chunks 100 pixels wide. Neighbouring chunks share 16 pixels, so no character is ever cut in two without also appearing whole in the next chunk.

A Khmer text line, តើអ្នកសុខសប្បាយទេ? (“How are you?”), resized to 48 pixels high and 268 wide, is cut into three chunks 100 pixels wide. Each chunk shares 16 pixels with its neighbour. Every chunk is encoded on its own into a short sequence of tokens, and the three sequences are joined end to end into one sequence for the whole line, overlaps included.

  1. 1Resize to 48 px high, keeping proportions

  2. 2Cut into 100 px chunks; neighbours share 16 px

  3. 3Encode every chunk on its own, in parallel

  4. 4Join the sequences end to end, overlaps and all

Figure 1 — តើអ្នកសុខសប្បាយទេ? (“How are you?”), to scale. It is resized to 48 px high, cut into 100 px chunks that overlap by 16 px, and each chunk is encoded on its own. The chunks' token sequences are then joined end to end, overlaps included. Yellow marks the overlapping strips, which the model sees twice.

This chunk-and-merge design comes from earlier work on Khmer OCR by Buoy et al. (2023). It is efficient: every chunk goes through the same image encoder, in parallel. But it has two weaknesses, and our two changes address them.

Diagram of Netra OCR 1: an input text line is cut into overlapping chunks; each chunk goes through the Squeeze-and-Excitation network to feature maps, patch and position embeddings, and a Transformer encoder to give vision tokens; a merger joins them, a bidirectional LSTM runs over the merged tokens, and a Transformer decoder predicts the characters
Figure 2 — Netra OCR 1 from text line to text. Each chunk goes through the Squeeze-and-Excitation network, patch and position embeddings and a Transformer encoder, giving one sequence of vision tokens per chunk. The merger joins the sequences, the bidirectional LSTM passes context across the chunk seams, and the Transformer decoder predicts the text one character or cluster at a time.

Column-wise Squeeze-and-Excitation#

Each chunk is first read by our image encoder, a Squeeze-and-Excitation network (SENet). A plain convolutional network, such as the VGG encoder of Buoy et al., treats every feature channel as equally important everywhere in the image. On a noisy scan, that means background texture and character strokes compete on equal terms.

A Squeeze-and-Excitation (SE) block lets the network learn which channels to trust. The standard block averages each channel over the whole image, so a single weighting applies everywhere. In a line of text, horizontal position is the order of the characters, and that average throws it away.

Our block averages only over the height. For a feature map xx with CC channels, height HH and width WW, each column ww gets its own descriptor and its own weights:

zc,w=1H∑h=1Hxc,h,wz_{c,w} = \frac{1}{H} \sum_{h=1}^{H} x_{c,h,w} s⋅,w=σ(W2 δ(W1 z⋅,w)),x~c,h,w=sc,w xc,h,ws_{\cdot,w} = \sigma\big(W_2\,\delta(W_1\, z_{\cdot,w})\big), \qquad \tilde{x}_{c,h,w} = s_{c,w}\, x_{c,h,w}

Here W1W_1 narrows the CC channels to C/16C/16, δ\delta is a ReLU, W2W_2 widens them back to CC, and the sigmoid σ\sigma turns the result into weights between 0 and 1. The same W1W_1 and W2W_2 serve every column, so the block adds very few parameters, but each column is scaled by its own weights. The network can suppress a stain over one character without dimming the character beside it.

Standard SE

One weighting for the whole chunk

Column-wise SE

One weighting for every column

Standard Squeeze-and-Excitation averages each channel over the whole feature map, turns that one descriptor into one set of channel weights, and scales every column by the same weights. Column-wise Squeeze-and-Excitation averages each channel over the height only, giving a descriptor per column, and scales each column by its own weights, so one column can be dimmed without dimming its neighbours.

Figure 3 — Standard and column-wise SE on the same feature map. Both squeeze, excite and scale; the standard block averages over height and width and applies one weighting to the whole chunk, while ours averages over height only and weights every column separately. The weights shown are illustrative.

We add the block after the last three of the network's five convolutional stages, where the features are abstract enough for reweighting to help.

Bidirectional LSTM#

Each chunk is encoded on its own, so a token knows only what is inside its chunk. A character that falls in an overlap is described twice, once by each chunk, and each sees only part of it. If the decoder read these tokens as they are, it could drop that character or write it twice.

So after the chunks' tokens are joined into one sequence, a single bidirectional LSTM reads the whole line, once left to right and once right to left. At every token, the outputs of the two passes are joined into one vector. Each token then carries what comes before it and what comes after it, across every seam, before the decoder starts.

The line តើអ្នកសុខសប្បាយទេ?, cut into three overlapping chunks. Each chunk is encoded on its own into three tokens, so no token knows anything beyond its chunk. The nine tokens are joined into one sequence with two seams and read by a bidirectional LSTM. Each token feeds two cells: one in a forward row, which passes its state left to right, and one in a backward row, which passes it right to left, both straight across the seams. At every position the two cells' states are concatenated into one output, and the transformer decoder reads the outputs and writes តើអ្នកសុខសប្បាយទេ?.

  1. 1Encode each chunk on its own

  2. 2Read the joined tokens both ways

  3. 3Decode the outputs

Figure 4 — The BiLSTM, on the line from Figure 1. (1) Each chunk is encoded on its own, so its tokens know nothing beyond the seams (dashed). (2) The tokens are joined and fed to two rows of LSTM cells: the forward row passes its state left to right, the backward row right to left, both straight across the seams. Each output joins the two cells at its position. (3) The decoder reads the outputs. Orange marks the BiLSTM; yellow marks tokens from the overlapping strips, as in Figure 1. Three tokens per chunk is schematic.

Blockwise decoding#

The decoder writes the text one token at a time, and each token needs a full pass through it. An optional second decoder uses blockwise parallel decoding: a small proposal head, trained on top of the frozen model, guesses the next 4 tokens at once. The decoder then checks all the guesses in one parallel pass. The guesses that match its own greedy choice are kept, along with its own token at the first mismatch, and the rest are discarded.

Every kept token is one that greedy decoding would also have written, so the output is exactly the same, in fewer passes. On KHOB it keeps 2.7 tokens per pass on average (Table 2).

One step of blockwise parallel decoding: the head guesses 4 tokens, the decoder's own choice matches the first two and differs at the third, so the two matches plus the decoder's token are kept
Figure 5 — One step of blockwise decoding, reading ព្រះរាជាណាចក្រកម្ពុជា (“Kingdom of Cambodia”) one character cluster per token. (1) The proposal head guesses the next 4 tokens. (2) In one parallel pass, the decoder gives its own greedy choice under each guess; it agrees with the first two and differs at the third (ក against ក្រ). (3) The two matching guesses and the decoder's own ក្រ are kept: 3 tokens from 2 passes, where greedy decoding needs 3.
InputOne text line, resized to 48 px high
Chunks100 px wide, 16 px overlap
Image encoderSqueeze-and-Excitation network: 7 convolutions in 5 stages, column-wise SE after stages 3–5
Chunk encoderTransformer, 2 layers, 8 heads, 384 dimensions
ContextBidirectional LSTM, 1 layer
DecoderTransformer, 2 layers, 8 heads
Vocabulary2,101 tokens: Khmer clusters and characters, Latin characters
Blockwise head4 tokens proposed per step
Parameters19.2M

Tokenization#

Khmer and English need different treatment at the output. English is written one letter at a time, so we predict it character by character. Khmer letters combine into clusters: a consonant with its stacked subscripts and vowel signs, read as one unit, such as ក្រ. Predicting a whole cluster at once is often easier than getting each of its parts right, in the right order.

Our vocabulary therefore mixes common Khmer clusters with single characters, which cover anything the clusters don't. On a 35,000-line Khmer corpus, this makes the token sequences 42.65% shorter than one token per character, and every line can be rebuilt exactly from its tokens.

Data#

Training data#

Annotated scans of Khmer text are scarce, so we trained only on synthetic images. We generated 200,000 of our own, set in 11 fonts, and added two open datasets for 1.3 million images in total.

DatasetImagesHow it was made
Our document text100,000Rendered with Pillow from a Khmer text corpus, then eroded, noised, thickened or thinned, and warped in perspective
Our scene text100,000Made with SynthTIGER over photographs from the Stanford Background Dataset, then rotated, blurred and noised
KhmerSynthetic1M1,000,000External, by SoyVitou
khmer-hanuman-100k100,000External, by seanghay

Evaluation data#

To find out whether training on synthetic images carries over to real ones, we tested on two sets of real images and one synthetic set built to probe a specific weakness.

DatasetTypeLinesWhat it tests
KHOBReal325The public Khmer OCR benchmark by EKYC Solutions, cropped into lines. Clean backgrounds, but compression artefacts and smaller text than we trained on
Legal DocumentReal220Phone photos of birth certificates, diplomas and ID cards, with uneven lighting, wear and perspective distortion. We annotated these ourselves
Printed WordsSynthetic500Short single words in 10 fonts, the first 500 of the set's 5,000 lines, to check that a model trained mostly on long lines can still read short ones
Sample text-line images from each of the training and evaluation datasets
Figure 6 — Example lines from each training and test set.

Training#

We trained for 100 epochs with Adam and a cross-entropy loss. Each epoch draws a fresh random sample of 50,000 images from the training set and augments them, so the model rarely sees the same image the same way twice.

The learning rate is held at 10⁻⁴ for the first 15 epochs, to converge quickly. It then cycles between 10⁻⁴ and 10⁻⁵ for 15 epochs, and between 10⁻⁵ and 10⁻⁶ for the rest, to settle into a minimum without stalling.

The released model, with the cluster vocabulary, was warm-started from an earlier version that predicted single characters only. Its vision layers and the token embeddings the two vocabularies share were carried over, and it was then trained on the full 1.3 million lines.

Results#

We compared Netra OCR 1 with Tesseract 5, the most widely used open-source OCR engine, and with Surya, a modern open-source OCR toolkit. We also compared it with two vision-language models, Qwen2.5-VL (3B) and DeepSeek-OCR (3B). Neither documents Khmer support, and without adaptation both score above 100% CER on KHOB, writing more wrong characters than the line has. So we gave each a short LoRA fine-tune: 300 steps on 6,000 lines from our training set.

KHOB0.96 (best in this row)5.12 (second best in this row)14.5289.2133.23
Legal Document5.31 (best in this row)41.78 (second best in this row)51.1684.6344.22
Printed Words2.95 (best in this row)20.14 (second best in this row)28.4598.6269.79
Table 1 — Character error rate (%) on single text-line crops from each test set. Lower is better. The best score in each row is bold and the second best is in italics. Netra OCR 1 uses beam search of width 3, and every system's output is normalised the same way before scoring. Qwen2.5-VL and DeepSeek-OCR had a 300-step LoRA fine-tune on 6,000 of our training lines.

On every set, Netra OCR 1 has the lowest error rate. The gap is widest on the legal documents: phone photos with glare, creases and skew, the kind of image people actually need to read. Every other system gets more than 40% of characters wrong on them.

The vision-language models did worst, despite being about 150 times larger. We don't think these numbers show how well such models could read Khmer. Their fine-tune was short, and a full training run or more real data might change the picture considerably. What the numbers do show is that size alone doesn't solve the problem.

CER (%) ↓1.171.17
Exact match (%)84.984.9
ms per line ↓17.6011.35 (best in this row)
Lines per second56.888.1 (best in this row)
Tokens per step12.7 (best in this row)
Table 2 — Greedy and blockwise decoding on KHOB (325 lines), with the same model in PyTorch. Higher is better for lines per second and tokens per step, lower for the rest. Where the two differ, the better score is bold. Both decoders are greedy, so the CER here is above the 0.96% that beam search gives in Table 1.

Blockwise decoding gives the same text 1.55 times faster. Every accuracy figure is identical, as it must be. The decoder runs about 2.7 times fewer passes, but the encoder costs the same, so the end-to-end gain is smaller. In the package's ONNX models, which cache the decoder's keys and values, each pass is already cheap, and the two decoders run at the same speed: about 13 ms per line.

A phone photo of a Khmer government notice, and the text the app read from it
Figure 7 — A phone photo of a Khmer government notice (left) and the text our app read from it (right). Most lines read cleanly; the handwritten reference number comes back as noise, and two lines gain a stray “?”.
A scanned embassy notice in Khmer and English, and the text the app read from it
Figure 8 — A scanned embassy notice in Khmer and English (left) and the text our app read from it (right).

Ablation#

We tested the two changes in an earlier experiment, before we had the full 1.3M-image training set. All three models below were trained on the same 200,000 synthetic images. The baselines are the chunk-and-merge model of Buoy et al., with either a VGG or a ResNet image encoder. Ours swaps that encoder for the Squeeze-and-Excitation network and adds the BiLSTM after the merger.

KHOB5.07 (second best in this row)5.854.79 (best in this row)
Legal Document10.27 (second best in this row)11.579.13 (best in this row)
Printed Words3.612.80 (best in this row)3.44 (second best in this row)
Table 3 — Character error rate (%) in the earlier experiment, with all three models trained on the same 200,000 synthetic images. The highlighted column is ours: the Squeeze-and-Excitation encoder plus the BiLSTM. Lower is better. The best score in each row is bold and the second best is in italics.

The changes helped most where we expected. On long lines from KHOB, and on the noisy legal documents, the modified model made fewer errors than both baselines. On short isolated words, which rarely span more than one chunk and so have no seams to smooth, the ResNet baseline stayed slightly ahead.

Table of four difficult text-line images, their correct transcriptions, and each model's output with errors marked in red
Figure 9 — Failure cases from the earlier experiment, with errors in red. Every model misreads the very small text in the first line. On the last, ours repeats two digits of a number that Tesseract reads correctly.

Limitations#

  • Small test sets. The two real-world sets hold 325 and 220 lines. That is enough to show large gaps, such as the one on legal documents, but not to separate systems that score within a point or two of each other.
  • One test set is private. The legal documents belong to real people, so we can't release them, and that result can't be independently checked.
  • Short words are weaker. Training lines are mostly mid-to-long, so isolated words have about three times the error of full lines: 2.95% on Printed Words against 0.96% on KHOB.
  • Khmer first. The model is trained mostly on Khmer, so English text can come back with small errors.
  • Synthetic training data. The model has never seen a real scan during training. Unusual fonts and layouts, and very low-resolution images, can fall outside what it can read.
  • It reads lines, not pages. The recognizer expects a single cropped line of text, and doesn't read handwriting, seals or logos. On a full page, accuracy also depends on the detector that finds the lines and the layout model that orders them, and the scores above don't measure those steps.
  • Our baselines, our setup. We ran every comparison ourselves. Tesseract and Surya scores depend on their settings, and the vision-language models had a 300-step fine-tune, not a full training run: they show what a quick adaptation gives, not their ceiling.
  • Training data licence. KhmerSynthetic1M, part of the training set, is licensed for research and academic use only. Check its terms before using the weights commercially.

Usage#

The easiest way to use the model is Netra OCR, an app that runs on your own computer. It reads whole PDFs and photos, finds the headings, paragraphs and tables, lets you correct the result, and exports to Word, HTML, Markdown, JSON or plain text. Your files never leave your machine. To try the recognizer on a single line without installing anything, use the playground.

For developers, the recognizer is a Python package. It runs on ONNX Runtime, without PyTorch, and downloads its models from Hugging Face on first use:

pip install "netra-ocr[cpu]"    # or "netra-ocr[gpu]" for an NVIDIA GPU

It reads line crops from any source, or whole pages with the line detector:

from netra_ocr.recognition import recognize
from netra_ocr.ocr_engine import KhmerOCRPipeline

text = recognize("line_crop.png")

pipeline = KhmerOCRPipeline(detector="yolo", decoder="blockwise")
page = pipeline.process_image(image_path="document.png", output_path="document.docx")

To fine-tune the model on your own lines, the repository has one script for each step. A dataset is a folder of line images with a text file for each:

git clone https://github.com/netra-ai-lab/Netra-OCR && cd Netra-OCR
bash training/setup.sh
bash training/check_data.sh my_lines/
bash training/finetune.sh my_lines/ --name my-model
bash training/evaluate.sh published training/runs/my-model/best.pth --test khob my_test_lines/
bash training/export.sh training/runs/my-model/best.pth

Future work#

Each of these follows from a limitation above:

  • Real training data. Adding annotated scans and photos to the synthetic set, to close the gap between what the model trains on and what it reads.
  • Short lines. More single words and short fields in training, to bring their error down towards that of full lines.
  • Wider coverage. Handwriting and decorative fonts, which the current training data does not cover.
  • Page-level evaluation. Measuring the line detector and layout model together with the recognizer, so the scores reflect reading a whole page rather than a cropped line.

References#

  1. An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale
    Alexey Dosovitskiy, Lucas Beyer, Alexander Kolesnikov, et al.
    ICLR 2021.
    arXiv:2010.11929

  2. TrOCR: Transformer-based Optical Character Recognition with Pre-trained Models
    Minghao Li, Tengchao Lv, Lei Cui, Yijuan Lu, Dinei Florencio, Cha Zhang, Zhoujun Li, Furu Wei.
    AAAI 2023.
    arXiv:2109.10282

  3. Toward a Low-Resource Non-Latin-Complete Baseline: An Exploration of Khmer Optical Character Recognition
    R. Buoy, M. Iwamura, S. Srun and K. Kise.
    IEEE Access, vol. 11, pp. 128044-128060, 2023.
    DOI: 10.1109/ACCESS.2023.3332361

  4. Balraj98. (2018). Stanford background dataset [Data set]. Kaggle.

  5. EKYC Solutions. (2022). Khmer OCR benchmark dataset (KHOB) [Data set]. GitHub.

  6. Em, H., Valy, D., Gosselin, B., & Kong, P. (2024). Khmer text recognition dataset [Data set]. Kaggle.

  7. Squeeze-and-Excitation Networks
    Jie Hu, Li Shen, and Gang Sun.
    CVPR 2018.
    arXiv:1709.01507

  8. Bidirectional Recurrent Neural Networks
    Mike Schuster and Kuldip K. Paliwal.
    IEEE Transactions on Signal Processing, 1997.
    DOI: 10.1109/78.650093

  9. Blockwise Parallel Decoding for Deep Autoregressive Models
    Mitchell Stern, Noam Shazeer, and Jakob Uszkoreit.
    NeurIPS 2018.
    arXiv:1811.03115

Cite this paper

@misc{netralab2026netraocr,
  title  = {{Netra OCR 1: A 19M-parameter model for reading printed Khmer and English text}},
  author = {{Netra Lab}},
  year   = {2026},
  url    = {https://netra-lab.example/research/netra-ocr}
}

More research