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:
- The two fonts designed for OCR are the two worst.
OCR-A and OCR-B were drawn in the 1960s for template matchers reading
one font; a neural recognizer trained on the world’s text finds their
letterforms exotic, and OCR-B even triggers full-width CJK look-alikes
(
p,T,¥). - Inconsolata, the font of my original post, is a poor choice here:
its
lgoes to1in 1.5% of characters. It compensates by fitting 120 columns. - Each font has exactly one killer pair, either
O/0orl/1, never both — except Cousine and the OCR fonts, which manage to have both.
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)