by Martin Monperrus

In my perfect-OCR quest, I wrote that OCR of base64 is a nightmare because it suffers from all possible pitfalls (1/l, O/0, upper-case/lower-case such as x/X). That is true of GOCR, of Tesseract, and of the latest vision models of OpenAI and DeepSeek.

TLDR: with Go Mono, RapidOCR reads random base64 at 0.26% character error rate, 94% of those errors being a single confusion, O read as 0. The error rate does not change between 12 pt and 5 pt. With the per-line CRC32 repair from my original post, that gives 16.7 kB of base64 per A4 page, 100% recovered — 4x my previous best of 4.2 kB.

Setup

RapidOCR 3.4.2 (PP-OCRv6 det_small + rec_small, ONNXRuntime, CPU only, pip install rapidocr onnxruntime). It is free, local, and runs a page in 30-60 seconds on 8 cores.

Pages are generated as follows: A4, ReportLab, 9 mm margins, lines filled edge-to-edge with uniform random base64 (A-Za-z0-9+/=), rasterized at 400 DPI grayscale (3308 x 4678 px).

One warning before any number: the default pipeline downscales anything longer than 2000 px before running detection, so whole rows vanish (CER 15-43% instead of 0.3%). The fix is two parameters: Global.max_side_len=6000 and Det.limit_side_len=2560.

Step 0: choose the font

The font is not a detail, it is the main knob. Eleven monospaced faces, 9 pt, 400 DPI, three random pages each — same seeds, same geometry, only the font changes:

font CER chars/page dominant confusions
Go Mono 0.196% 7,300 O->0 39, c->C 4
Ubuntu Mono 0.312% 7,811 O->0 73
FreeMono 1.009% 7,300 l->1 212
Liberation Mono 1.197% 7,300 O->0 251, W->w 10
Nimbus Mono PS 1.234% 7,300 l->1 259
Inconsolata 1.537% 8,760 l->1 371, O->0 33
Hack 1.626% 7,300 O->0 356
DejaVu Sans Mono 1.639% 7,300 O->0 355
Cousine 2.615% 7,300 l->1 299, O->0 265
OCR-A 5.534% 6,132 O->0 278, l->1 265, 0->O 172
OCR-B 5.905% 6,059 0->O 212, y->Y 164, l->L 157

Eight times between the best and the median font, thirty times between the best and the worst. Three remarks:

Go Mono wins because of one glyph: its zero carries a full diagonal slash, while DejaVu marks its zero with a small dot and FreeMono, Inconsolata and Nimbus leave l and 1 nearly identical. At 9 pt and 400 DPI a dot is two pixels — the recognizer drops it — whereas a slash crosses the whole counter and survives. Measured effect: Go Mono loses 15% of its uppercase Os, DejaVu loses 98%.

Everything below uses Go Mono, 400 DPI.

Density is free

size chars/page errors CER wall clock
12 pt 4,050 6 0.148% 36 s
11 pt 4,838 8 0.165% 34 s
10 pt 5,850 13 0.222% 30 s
9 pt 7,300 19 0.260% 36 s
8 pt 9,266 23 0.248% 43 s
7 pt 12,126 35 0.289% 44 s
6 pt 16,459 50 0.304% 48 s
5 pt 23,711 69 0.291% 76 s

This is the surprise. The error rate is flat from 12 pt down to 5 pt. A 5 pt page carries 23,711 characters — 5.9x the 12 pt page — and is read just as accurately. Line and column alignment is perfect everywhere: zero lost rows, zero deletions over the entire sweep, including the 131-row 5 pt page with 181 characters per line. Only 4 lines out of 1,139 contain a spurious inserted character.

For comparison, on the very same pages, gpt-5.4 goes from 3.1% at 12 pt to 35.4% at 5 pt, and GOCR loses baseline tracking below 7 pt.

Error mode

Over 138,606 characters (3 seeds at 12 and 6 pt, plus the full size sweep):

truth read count share of errors
O 0 337 94.4%
c C 18 5.0%
l o L 0 2 0.6%

337 of the 2,218 uppercase Os — 15.2% — come back as 0, and 18 of the 2,174 lowercase cs go up to C. That is the whole story: 357 errors, two classes. The 1/l confusion that ruins base64 for every other engine and most other fonts: zero occurrences in Go Mono.

It is not a resolution problem, it is a language prior: PP-OCR is trained on natural text, where an O inside an alphanumeric run is a zero, and where case is decided by context that random data does not have.

Repairing base64 with CRC32

My original post proposed per-line CRC32 (written in digits, protected by a Damm digit) to repair base64 OCR, and reached 4.2 kB per A4 page with Inconsolata 11 pt. The bottleneck there is the size of the repair search: with all pitfalls live, a line has a huge number of plausible corrections.

With RapidOCR and Go Mono the search collapses to two questions: which of the 0s I read were really O, and which of the Cs were really c. So I ran the repair for real on the OCR output: enumerate corrections in increasing Hamming distance from what the engine returned, accept the first one whose CRC32 matches.

size lines repaired CRC collisions unrecovered CRC evals/line (median / p95 / max) payload
12 pt 162 162 0 0 1 / 3 / 10 2.59 kB
11 pt 59 59 0 0 1 / 4 / 22 3.14 kB
10 pt 65 65 0 0 1 / 5 / 22 3.85 kB
9 pt 219 219 0 0 1 / 11 / 50 4.87 kB
8 pt 82 82 0 0 1 / 8 / 83 6.27 kB
7 pt 93 93 0 0 1 / 10 / 36 8.32 kB
6 pt 325 325 0 0 1 / 27 / 232 11.45 kB
5 pt 130 130 0 0 1 / 49 / 359 16.70 kB

1,135 lines out of 1,135 recovered exactly, at every size, with zero CRC collisions. The median line is already correct and costs one CRC32 evaluation; the worst line in the whole corpus needed 359, about 50 microseconds. Payload is net of the checksum: 11 characters per line (10 digits of CRC32 plus one Damm digit), which costs 15% of the line at 12 pt and only 6% at 5 pt.

The 4 lines with an inserted character are excluded: this repair is substitution-only. A production decoder would also try deleting each duplicated confusable glyph, which the CRC validates for free.

Where this lands

base64 on A4, all numbers measured on random data:

pipeline payload/A4 accuracy
Schilke & Rauber, microfilm, 24 pt 1.8 kB
blog: base64 + CRC32/Damm, GOCR, Inconsolata 11 pt 4.2 kB 100% after repair
gpt-5.4 vision, raw base64, DejaVu 3.1% CER at 12 pt, 35.4% at 5 pt
RapidOCR, raw base64, Go Mono 5 pt 17.8 kB 99.7% (0.29% CER)
RapidOCR + CRC32 repair, Go Mono 5 pt 16.7 kB 100%

4x my previous best, and the gain is entirely density: at 11 pt the two pipelines are equivalent (3.1 kB here)

See also