psikosen/t-rsi-ocr

🤗 Hugging Face sourceimage-to-textmit1M params1 MBsafetensorsHF checksums availableupdated today
No torrent yet

T-RSI Fast Reflex OCR

A 1.58-bit ternary BitNet text-line recognizer in 461 KB, with a dependency-free C engine

[!WARNING] Experimental research release. Every number below was measured on one Apple Silicon machine against ground truth this project generated. It has not been independently replicated. Treat the figures as reproducible-by-you, not as verified.

Every weight is -1, 0 or +1, packed four to a byte. The model reads a 32-pixel-tall text strip and emits characters through CTC, decoded with a prefix beam search over a 27 KB character language model.

It ties or beats Tesseract 5 on character accuracy in every scenario measured, reads a line 8.8x faster in 5x less memory, and still returns fewer perfect lines. Full numbers and the reasons are below, including where it loses.

That speed figure is ARM-specific. It was measured on an Apple M4 Max, where the C engine uses NEON and the ARMv8.2 dot-product instruction. Those carry the engine: without any SIMD it runs at 144 ms/line, which is Tesseract's speed. See Hardware dependence before quoting a number.

The exact-line gap used to be 25.7 points and is now 3.6 to 6.6 depending on the sample. What closed it is multi-stretch fusion, described below: decode the same line at two stretches, align the frames, average the log-probabilities, and decode once. It replaced the hypothesis voting an earlier version of this card recommended, and is both more accurate and about 6x faster than it.


Measured results

Reproduce with python3 benchmark_vs_tesseract.py. Ground truth needs no hand labelling: a digital PDF already contains its own exact text, so rasterizing a page and cropping each line at the bbox the PDF reports gives pixels whose correct answer is known. Training and test documents are split by file and deduplicated by content fingerprint.

Character accuracy

scenario T-RSI Tesseract 5
fonts, clean 99.50% 98.09% +1.41
fonts, degraded 99.27% 96.97% +2.30
documents, clean 98.18% 97.86% +0.32
documents, degraded 0.5 97.83% 97.46% +0.37
documents, degraded 1.0 95.57% 93.57% +2.00

On the larger 3,000-line corpus in the deep benchmark below, clean documents come out a tie rather than a win (97.72% against 97.62%). Two samples, two answers, both reported.

Exact-line match — where it loses

scenario T-RSI Tesseract 5
fonts, clean 93.7% 75.7%
fonts, degraded 92.3% 70.3%
documents, clean 84.3% 87.9%

Tesseract's errors cluster onto a few hard lines and leave the rest clean. This model's scatter — single characters across many otherwise-good lines — and one stray character ruins a line as thoroughly as ten. That is the documented weakness of CTC, which treats every timestep as conditionally independent given the image and so cannot notice that the letters it just emitted do not form a word.

Multi-stretch fusion closes most of that gap, and is the default. Decode the same line at more than one horizontal stretch, align the frames with DTW, average the LOG-PROBABILITIES, and decode once. The stretches fail on different characters, so averaging lets them cancel.

Combining the numbers rather than the decoded strings is the point. An earlier version of this card recommended voting on decoded strings (ROVER); measured head to head on the same lines, aligning and averaging the log-probabilities beats it by 1.8 points of exact-line match, because a decoded string has already thrown away how sure the decoder was. Soft decoding beating hard is a Shannon-era result, and confusion-network combination beats ROVER in speech for the same reason.

C engine, 2,000 held-out lines, latency on an M4 Max:

decoding char accuracy WER exact line ms/line
single stretch 97.16% 0.0841 71.70% 9.2
2 stretches, fused (default) 97.39% 0.0739 76.25% 17.5
5 stretches, fused 97.53% 0.0710 79.00% 41.2
4 strings + ROVER vote 97.46% 0.0733 78.05%
Tesseract 5 97.51% 0.0503 84.60% 124

Fusion costs one extra forward pass, no extra bytes, and no retraining. Alignment has to be DTW: assuming a stretch scales time uniformly and resampling linearly gives up 1.5 points, because the model does not fire on a uniformly scaled schedule.

By content type (real documents)

content T-RSI Tesseract 5
prose 99.53% 99.23%
symbol-heavy 97.41% 96.71%
numeric / amounts 99.09% 98.71%
alphanumeric IDs 83.71% 84.67%
ALL CAPS headers 99.60% 100.00%

Alphanumeric identifiers are the weakest content for both engines. Random codes carry no language structure, so no language model can rescue a misread glyph.


Deep benchmark vs Tesseract 5

3,000 held-out lines from 8 unseen documents, three degradation levels. Run it yourself with python3 deep_benchmark.py.

metric T-RSI (C) Tesseract 5
Character accuracy, clean 97.72% 97.62% tie
Character accuracy, degraded 95.33% 93.83% win
Word error rate 0.0658 0.0545 loss
Exact-line match 78.9% 85.5% loss
Peak RAM 7.1 MB 35.3 MB win 5.0x
Cold start 21 ms 96 ms win 4.5x
Throughput (ARM, NEON+SDOT) 16.9 ms/line 147.8 ms/line win 8.8x
Install size 461 KB 48.9 MB win 109x

5 wins, 2 losses, 1 tie.

Peak RAM rose from 6.1 MB when fusion became the default -- it holds a second set of frame log-probabilities. Throughput improved anyway, from 25.1 ms/line, because the hot paths were rewritten at the same time (see the C engine section).

Per document -- genuinely mixed

4 wins, 2 losses and 2 ties on character accuracy across the 8 documents. One file swings it either way; this is not a clean sweep.

Content type

content n T-RSI Tesseract
prose 1967 98.44% 98.28%
symbol-heavy 784 97.29% 96.67%
alphanumeric IDs 134 92.16% 92.84%
numeric / amounts 69 95.30% 98.63%
ALL CAPS headers 46 94.14% 98.37%

Numbers and capitals are the weak spots, by 3.3 and 4.2 points. If your documents are mostly figures, this is the wrong tool.

Line length

length n frames/char T-RSI Tesseract
< 30 chars 561 1.39 92.40% 93.63%
30-60 767 1.25 97.88% 97.54%
60-90 506 1.25 99.49% 99.03%
90-120 1158 1.25 99.43% 98.99%

It wins on long lines and loses on short ones -- and short lines have the best frame budget, so this is not the deletion problem that stretching solves.

This is the biggest unexplained loss in the model, and it survived a dedicated hunt. Tried and measured on short lines, none of which helped: turning the character LM off (it costs 2.7 points, not gains), priming the LM with a leading space, raising or lowering the insertion bonus, a wider beam, and padding the line with white margin so the first glyph is not at frame zero. The only thing that helps is more stretch, which fusion already does per line. Short lines are headers, captions and table cells set in larger type; why that hurts is not known.


C engine

csrc/trsi_ocr.c -- one file, no dependencies beyond libm. No BLAS, no image library; input is binary PGM parsed in twenty lines.

cd csrc && make
./trsi_ocr ../model_v4.trsi line.pgm          # fuses 2 stretches, the default
./trsi_ocr --fuse=full ../model_v4.trsi line.pgm    # 5 stretches, more accurate
./trsi_ocr --fuse=single ../model_v4.trsi line.pgm  # one decode, fastest
./trsi_ocr --self-check                       # verify the SIMD paths

It is a port of engine.py, not a generic BitNet runtime. bitnet.cpp (arXiv:2410.16144) provides ternary matmul kernels for LLMs but has no patch tokenizer, no CTC, no beam search and no character LM, which is most of what this model is.

Two measured notes from building it:

  • The first version ran at 364 ms/line because it unpacked 2-bit codes inside the inner loop. Unpacking once at load costs 1.25 MB resident and made it 5.7x faster. At 6 MB, memory was no longer the constraint.
  • NEON SIMD took it from 63.7 to 23.7 ms/line, bit-identical on all 300 test lines at the time.
  • Profiling the fused engine put 97% of the time in two functions: the ternary matmul (50.7%) and attention (46.5%). The DTW alignment that fusion is named after was 0.6% -- fusion was never slow because of the fusion, only because it runs the transformer twice.
  • Rewriting those two took it from 27.5 to 9.2 ms/line unfused and 52.0 to 17.5 ms fused, so the fused path now costs less than the unfused one used to. The matmul moved from float FMA to SDOT (int8 dot product, 16 multiply-accumulates per instruction) -- the old code widened ternary weights to float, doing multiplication on a format that exists to avoid it. Attention gained SIMD, a loop interchange that turned a stride-192 read of v into 32 contiguous floats, and a 4-wide polynomial exp in place of ~1.3 million libm expf calls per line.
  • That last change gives up bit-identity by design, so it is bounded instead: ./trsi_ocr --self-check asserts the fast exp within 1e-5 relative of libm (measured 4.97e-6) and monotone, and the SDOT product within 1e-5 of a scalar reference (1.02e-6). End to end, exact-line match on 2,000 held-out lines moved by at most 0.30 points. Scalar fallbacks are kept for CPUs without the instructions.
  • Measured and rejected: -O3, -funroll-loops and -march=native are all within noise of plain -O2 here (17.4-17.9 ms/line). Threading was not attempted -- a line recognizer is normally called on many lines, so the parallelism belongs in batching one level up.

model_v4.trsi (410 KB) is a flat binary holding the packed weights, the character LM and the decode parameters, in the exact order the C reads them -- no JSON parser, no name lookup.

Hardware dependence

Every throughput figure on this card is from one machine, an Apple M4 Max, and the speed is mostly the SIMD, not the C. The ternary matmul and attention are 97% of the runtime, so how well they vectorise decides everything.

Three build paths, same source, measured on that machine by disabling the higher ones. Fused two-stretch decode, 100 held-out lines, median of three runs:

build ms/line selected on
NEON + SDOT 16.6 ARMv8.2+ with dot product: Apple Silicon, Graviton3, recent Cortex-A
NEON, no SDOT 23.1 any ARMv8: Cortex-A53/A72, Raspberry Pi 3/4
no hand SIMD, compiler still vectorises 20.7 -DTRSI_NO_SIMD
no SIMD at all 144.1 -DTRSI_NO_SIMD -fno-vectorize -fno-slp-vectorize

Two things worth reading off that table.

The bottom row is the honest floor. With nothing vectorised the engine runs at 144 ms/line, which is Tesseract's 148 — the 8.8x advantage is an argument about SIMD, not about being small. The memory and install-size wins are unaffected.

Hand-written intrinsics are worth less than they look (16.6 against 20.7), because clang at -O2 on arm64 already vectorises the plain loops. The intrinsics buy the SDOT path, which the compiler will not choose on its own.

x86: it works, and it is leaving the main optimisation on the table.

There is no x86 machine in this evaluation, so there is no native x86 timing on this card and none should be inferred. What could be established on ARM:

  • An -arch x86_64 build compiles, passes --self-check, and decodes 100 real lines with one line differing from the ARM build -- the same float-noise level that separates any two paths here. It is functionally equivalent.
  • clang auto-vectorises the ternary matmul on x86, but as floats: mulps at the x86-64 baseline, vmulps with -mavx2. It emits no integer dot-product instruction. So x86 runs the arithmetic this engine used before the SDOT rewrite -- widening ternary weights to float to multiply them.
  • x86's direct equivalent to SDOT is vpdpbusd (AVX-VNNI on Alder Lake and later, AVX512-VNNI on Ice Lake and Zen 4). The compiler here accepts it, so the instruction is reachable; it is simply not implemented in this source.

That is the concrete gap, and it is not written blind on purpose: a VNNI binary does not execute under Rosetta on this machine, so an x86 integer path could not be checked for correctness here, never mind timed. It needs real x86 hardware.

Rosetta timings are not reported. Running the x86 binary under translation measures the translator, not an x86 CPU.

The paths agree on text, not on bits. Across 100 lines the non-vectorised build produced identical output to NEON+SDOT, and the auto-vectorised one differed on a single line -- float reassociation, the same order of noise that separates the C and Python engines. --self-check prints which path a binary was compiled with.


Size, memory and speed

T-RSI (C) T-RSI (Python) Tesseract 5
Packed ternary weights 312.0 KB 312.0 KB
Float32 tensors (LayerNorm, biases, scales) 27.4 KB 27.4 KB
Character 3-gram LM 27.4 KB 27.4 KB
Engine 50 KB binary 26.1 KB code
Install total 461 KB 393 KB + numpy 48.9 MB
Peak process RAM 7.1 MB 55.6 MB 35.3 MB
Cold start to first result 21 ms 96 ms
Steady-state per line 16.9 ms 55 ms 148 ms

109x smaller on disk, 8.8x faster per line, 4.5x faster to cold start, and 5x less memory — in the C engine, on ARM with NEON and SDOT. Size and memory hold everywhere; the speed does not. Without SIMD the same code runs at 144 ms/line, level with Tesseract — see Hardware dependence.

In Python it uses more memory than Tesseract, 55.6 MB against 35.3 MB. The model is a small part of that: numpy and pillow cost 32.1 MB before a single weight loads. An earlier version of this card said a C port "would remove that, but no such port has been measured, so it is not a claim." It is now measured, and it is the row above.

Position encoding is fixed sinusoidal, computed at load and stored as zero bytes. A learned embedding at 1024×192 float32 would cost 768 KB — more than twice all the ternary weights combined.


Architecture

Layers 4
Hidden size 192
Attention heads 6 (head dim 32)
FFN intermediate 384
Patch grid 32 × 8, one token per 8-pixel column
Ternary weights 1,277,952
Vocabulary 256 slots, 95 printable ASCII
Weight quantization ternary {-1, 0, +1}, STE, per-tensor `mean
Activation quantization 8-bit absmax, dequantized
Decoder CTC prefix beam search, width 4
Decode-time fusion 2 stretches (1.2x, 1.3x), DTW-aligned log-probabilities

Inference is engine.py, NumPy only, no framework. Weights are ternary, so an integer SIMD or microcontroller port needs only additions and subtractions. On a CPU the same arithmetic runs through BLAS, which is not literally multiplication-free — the property belongs to the weights, not to this runtime.

engine.py is checked against the PyTorch training module on shared weights and must agree to float32 precision. That test exists because the two once silently computed different functions.


Quickstart

from PIL import Image
from engine import FastReflexOCREngine

engine = FastReflexOCREngine.from_pretrained(
    "psikosen/t-rsi-ocr",
    lm_name="char_lm_3gram.npz",   # omit for greedy CTC
    beam_width=4,
)

text, latency_us, ram_kb = engine.recognize(Image.open("line.png"))
print(f"{text!r}  ({latency_us:.0f} us)")

Two-stretch fusion is on by default. To trade accuracy against latency:

import fusion

engine.fuse = fusion.FULL     # 5 stretches: +2.75 exact-line points, 2.4x slower
engine.fuse = None            # single decode: 2x faster, -4.55 points

When a line must come back verbatim, ask for candidates instead of one answer. The beam already ranks its hypotheses, so the list is free, and the correct string is in the top 8 for 41% of the lines the top answer gets wrong:

from beam_decoder import prefix_beam_search

candidates = prefix_beam_search(
    logits, engine.blank_id, {i: t for i, t in engine.id_to_token.items() if len(t) == 1},
    lm=engine.lm, beam_width=8, n_best=3,
)
list size exact-line match
top-1 73.3%
top-3 82.0%
top-8 84.3%

That is not a better recognizer, it is a different contract — useful when a downstream parser knows the shape it expects.


Two things that matter more than the model

1. Most documents need no OCR at all. DOCX files and digital PDFs already contain their exact text. Reading it takes 4.8 ms per page against Tesseract's 2,038 ms for the same page — 425× faster and exact rather than 97% accurate. Rasterizing a text layer and guessing it back loses table structure and turns 1999.00 into 999.00. Check for a text layer before reaching for any recognizer.

2. This is a line recognizer, not a page reader. It needs pre-cropped lines. Tesseract does layout analysis and recognition in one pass; this does not, and no line detector ships with it. A per-line speed comparison assumes the crops already exist.


Training

python3 train_v2.py --epochs 70 --pool 50000 --real 25000 \
    --layers 4 --hidden 192 --heads 6 --guide-weight 0.5 --out model_v4

49,022 synthetic lines across 111 screened system fonts, mixed with 19,816 real lines cropped from PDFs. Both matter: a synthetic-only model scored 81% on held-out fonts and 16% on real documents — it had learned the renderer.

Augmentation covers blur, sensor speckle, non-uniform lighting, ±5° skew, exposure drift, and horizontal scale jitter (1.0–1.4×). That last one is what makes input_stretch work at inference: CTC cannot emit more characters than it has frames, real lines average 1.29 frames per character, and stretching to 1.69 is worth +14.9 points of exact-line match. Applied to a model trained without the jitter, the same trick is a net loss.

Training uses an attention decoder as an auxiliary teacher (GTC, arXiv:2002.01276), discarded at export — it costs zero bytes and zero milliseconds at inference.


Limitations

  • Line recognizer only. No page layout, no line segmentation, no table structure.
  • 95 printable ASCII characters. No accents, no CJK, no mathematical notation, no typographic quotes.
  • Exact-line match still trails Tesseract, 78.9% against 85.5% on the 3,000-line corpus with fusion on. It was 74.6%. Ask for an n-best list if a line must come back verbatim.
  • Short lines are the weakest bucket and the least explained. Under 30 characters: 92.40% against Tesseract's 93.63%. They have the best CTC frame budget in the corpus and still the worst accuracy, so the usual explanation does not apply. Six fixes were measured and none helped.
  • The Python engine uses more RAM than Tesseract (55.6 MB against 35.3 MB), because of numpy and pillow rather than the model. The C engine uses 7.1 MB.
  • Alphanumeric identifiers are weak (83.7%), as they are for every engine measured.
  • Fusion runs the model twice. All headline accuracy assumes it is on. --fuse=single is roughly 2x faster and gives up 4.55 exact-line points.
  • The speed advantage is SIMD, and it is measured on one ARM machine. With no vectorisation the engine runs at 144 ms/line against Tesseract's 148 — a tie, not an 8.8x win.
  • x86 runs correctly but without the main optimisation. The build is verified equivalent; clang vectorises it as floats and emits no integer dot product, so x86 gets the pre-SDOT arithmetic. A vpdpbusd path is the obvious fix and is not implemented. No native x86 timing exists for this engine.
  • Evaluated on one machine, an Apple M4 Max. Independent replication welcome, and especially welcome on non-ARM hardware.

What was tried and did not work

Recorded so it is not rebuilt from the same reasoning.

approach result
x86 AVX-VNNI matmul path not attempted: a VNNI binary will not execute under Rosetta, so it could not be verified on the available hardware. Named as headroom rather than guessed at
Patch width 4 (more CTC frames) 92.2% against patch-8's 97.8%; each token sees too little context
Dictionary word repair built three times; detection works (greedy-vs-beam disagreement is an 11.6× error signal), correction does not
Larger LM corpus saturated at 4,933 of 9,025 contexts
Caching ternary weights as float32 1.13× faster end to end, +4 MB RAM
Per-head attention no memory saved; CPython does not return freed memory to the OS
Per-line stretch from frames-per-character the obvious adaptive rule. +0.25 exact-line points against an +11.60 oracle — prediction fails, and fusion gets the same gain by not choosing
Grammar-constrained decoding 41% of edits are deletions, which no grammar can reach, and 86% of the reachable substitutions stay inside their own token class
Turning the LM off on short lines a 59-line pilot said +5.08; 2,000 lines said −2.67. The pilot was noise
White margin around short lines 8px costs 1.3 points, 16px collapses exact-line match to 1.07% — the model reads the margin as a space
String voting (ROVER) instead of fusion 78.05% against fusion's 79.00%, and slower
-O3 / -march=native / -funroll-loops all within noise of -O2 on this CPU

Team C · experimental research release · v3 (fused decode, SIMD C engine)