My perfect-OCR quest failed for encoding beyond 32 characters.
This post explores one can OCR base58 with no errors.
TLDR: The result is OcrMono, a FreeMono fork in which GOCR can read 1,051,200 random base58 characters with zero errors — 6.6 kB of payload per A4 page.
Goal
We want perfect OCR of base58.
OCR software: we will use GOCR because it’s open source and already a strong baseline.
We will read a A4 page full of base58 at 400 DPI. We aim at testing a million characters, and maintain zero errors.
Step 1: measure what actually breaks
Methodology: A4, ReportLab, 9 mm margins, lines filled edge-to-edge with uniform random base58, rasterized at 400 DPI grayscale (3308 x 4678 px), PGM for GOCR. Baseline font: FreeMono, the blog’s own favourite, at 9 pt (100 chars x 73 rows = 7,300 chars/page).
| font | size | chars | substitutions | CER |
|---|---|---|---|---|
| FreeMono | 12 pt | 36,450 | 0 | 0.0000% |
| FreeMono | 9 pt | 65,700 | 43 | 0.0654% |
Two campaigns went into this. First, the 9 pt / 12 pt runs from the table above: 65,700 and 36,450 characters, giving the 43 substitutions at 9 pt their exact confusion breakdown. Second, a sweep of every other printable FreeMono size (6, 7, 8, 10, 11 pt, one seed-42 page each) to check the edges of the usable range — pushing total base58 coverage past 150,000 characters and turning up more failure modes:
| size | chars | truth | read | count | proportion |
|---|---|---|---|---|---|
| 9 pt | 65,700 | o | O | 36 | 36/43 |
| 9 pt | 65,700 | o | 0 | 5 | 5/43 |
| 9 pt | 65,700 | U | u | 1 | 1/43 |
| 9 pt | 65,700 | C | c | 1 | 1/43 |
| 6 pt | 16,459 | n | _ | 284 | 284/306 |
| 6 pt | 16,459 | o | 0 | 21 | 21/306 |
| 6 pt | 16,459 | g | 9 | 1 | 1/306 |
| 8 pt | 9,266 | A | h | 2 | 2/2 |
6 pt and 11 pt are excluded from the rest of this post (see Appendix). 7 pt, 10 pt and 12 pt have zero substitutions and don’t appear above.
95% of all errors are one glyph: lowercase o wandering
into the O/0 neighborhood. The explanation is
pure geometry: in FreeMono, o spans y = -16..431
units/em while O spans -16..576 — the two shapes differ by
only 145 units of vertical extent, i.e. 7 pixels at 9 pt and 400 DPI,
and GOCR’s template matcher folds them together.
Step 2: edit the font
Next, we edit the FreeMono font definition with FontForge. After experimentation, we decide to only change the lowercase side, touch capitals never, and never change an advance width. (Enlarging capitals introduces new failure modes)
- Advance widths stay at 600/1000 em for every glyph, so the printed grid is pixel-identical to FreeMono’s line and column tracking.
- Lowercase letters colliding with their uppercase are uniformly scaled down to 74-78% around a center of gravity of 300/1000 em. Their tops drop to ~310-336/1000 while the cap line stays at 563-576: a gap of 230/1000 em, i.e. 11-12 pixels at 9 pt and 400 DPI — unmissable for a template matcher.
0is condensed to 78% width only: 292 vsO’s 498 units wide, a narrow oval against a round one, height unchanged.8and9shrink to 94% to break the8->ßand9->gligature matches.jwidens to 110% (advance unchanged) so its dotted ascender cannot fold ontoJ.
Final OcrMono geometry, measured from the shipped TTF:
| glyph | FreeMono | OcrMono |
|---|---|---|
o |
y -16..431 | y -12..336 |
O |
y -16..576 | y -16..576 (untouched) |
0 |
w 374 | w 292, y -15..618 |
s z v x k |
y 0..431 | y 0..317-328 |
p y w g |
y -186..431 | y -138..309-319 |
S Z V X K P Y W |
y 0..563-576 | unchanged |
Results: 1,051,200 characters, zero errors
144 independent random A4 pages, OcrMono 9 pt, 400 DPI, GOCR 0.52:
| metric | value |
|---|---|
| pages | 144 |
| characters | 1,051,200 |
| substitutions | 0 |
| insertions/deletions | 0 |
| lost lines | 0 |
| CER | 0.0000000 |
The confusion matrix over the whole run is empty.
Not one o/O/0, S/s, Z/z,
V/v, X/x, P/p, Y/y,
W/w, K/k, U/u, C/c,
j/J, 8/ß or 9/g substitution
occurred in more than a million random characters — nearly a megabyte of
transcription without a single flipped bit.
Versus the blog’s reference results on A4:
| pipeline | payload/A4 | accuracy |
|---|---|---|
| blog: base16, GOCR, Inconsolata 12 pt, 400 DPI | ~2.5 kB | 100% |
| blog: base64 + CRC32/Damm repair, Inconsolata 11 pt | ~4.2 kB | 100% after repair |
| OcrMono: base58, GOCR, 9 pt, 400 DPI | 6.6 kB | 100% raw, no repair |
OcrMono can be downloaded at https://github.com/monperrus/OcrMono.
Practical Use
Practical uses of perfect base58 OCR: paper backup of cold-wallet keys and seeds, offline key transport, data-on-paper archiving — now at 6.6 kB/A4 with no error-correction overhead.
Repro
# 1. build the font (FreeMono -> /tmp/ocrbench/OcrMono.ttf)
python3 make_ocrmono.py
# 2. one page: generate, rasterize, OCR, compare
python3 gen.py --alphabet base58 --font /tmp/ocrbench/OcrMono.ttf \
--size 9 --cols 0 --seed 5000 --pdf p.pdf --txt p.txt
pdftoppm -r 400 -gray -png p.pdf p
convert p-1.png p.pgm # gocr reads PGM, not PNG
gocr -f UTF8 -i p.pgm -o out.txt
python3 cmp.py p.txt out.txt # strips gocr's spurious spaces
# 3. the million-character validation
python3 run_gocr_batch.py 132 9 # 132 pages x 7,300 chars, running CER
Appendix
n->_ and W->_ are baseline-tracking
“underscore storms”, not shape confusions — which is why 6 pt and 11 pt
are excluded from the rest of this post.