Word-level alignment of OCR output to ground-truth (GT) text when the OCR reading order is wrong. Zoning errors include interleaved columns, displaced blocks and swapped paragraphs. The repository also holds the CPU-only evaluation scripts behind the tables of the paper (see Citation).
Contents:
- Aligners (pure Python, depend only on
Levenshtein):LocationAwareAligner: the 2019 location-aware heuristic of Fonseca Cacho & Taghva (step1_eval.py)ReanchoringAligner: the 2019 heuristic plus n-gram re-anchoring after failure streaks (step1_eval.py)SimpleRETAS("RETAS-lite"): unique-word anchors plus local search (step1_eval.py)MultiRegionAligner(multiregion v1): unique word/n-gram anchors, then greedy assignment of OCR chunks to GT windows, then local edit-distance fill (multiregion_align.py; frozen defaults inFROZEN_PARAMS.md)MultiRegionAlignerV2(multiregion v2): v1 plus faster chunk assignment, a long-document fallback and an anchor-scarcity fallback (multiregion_v2.py; frozen defaults inFROZEN_PARAMS_V2.md)
- Synthetic zoning generator with exact word-level gold
(
step1_eval.generate_zoning_pair), plus metrics: precision, recall, F1, cascade length and recovery@N (step1_eval.score_alignment). - Evaluation scripts for each paper table (see Reproduce the paper).
ocr_alignment_prototype.py is the original 2019-era prototype, kept for
provenance. Its built-in demo has measurement bugs that step1_eval.py
corrects (see that file's docstring), so do not use it for evaluation.
Python 3.9+.
git clone https://github.com/sniperjf/zone-robust-ocr-align cd zone-robust-ocr-align python -m venv .venv && . .venv/bin/activate pip install -r requirements.txt # Levenshtein pip install pytest # optional, for tests
Run all scripts from the repository root. They import each other as flat modules.
Only some evaluation scripts need these. None is needed for the aligners, the
synthetic experiments or the tests. Each script that uses Java RETAS accepts
--skip-java. The scripts that use genalog only as a baseline column accept
--skip-genalog.
| Tool | Used by | How to obtain | How the scripts find it |
|---|---|---|---|
Tesseract 5 with nld, deu, eng language data (5.5.3 was used) |
real-OCR scripts (Tables 4, 5, 8) | distro package, conda-forge tesseract, or https://github.com/tesseract-ocr/tesseract; traineddata from https://github.com/tesseract-ocr/tessdata |
--tesseract, $TESSERACT, or tesseract on PATH. Language data: --tessdata or $TESSDATA_PREFIX |
| Java RETAS (Java 17 was used) | Java RETAS baseline column | git clone https://github.com/Early-Modern-OCR/RETAS && cd RETAS && javac *.java (commit 0193ea6 was used, with its stock config.txt: level=WORD, alignmentFormat=LINES). RETAS has its own GPL licence; it is not distributed here |
java: --java, $JAVA_HOME/bin/java, or java on PATH. RETAS: --retas-dir or $RETAS_HOME |
| Microsoft genalog (MIT) | genalog baseline column; reference mapping for TREC-5 and real-OCR agreement | git clone https://github.com/microsoft/genalog (commit 806a782 was used), pip install biopython, then export PYTHONPATH=/path/to/genalog:$PYTHONPATH. Only genalog.text is used, and it needs only biopython |
normal import genalog |
from step1_eval import tokenize from multiregion_v2 import MultiRegionAlignerV2 gt = tokenize("Left column text first. Then the right column follows here.") ocr = tokenize("Left column Then the right text first. column follows here.") res = MultiRegionAlignerV2().align(ocr, gt) # frozen v2 defaults res["mapping"] # list: OCR token i -> GT token index, or -1 if unaligned res["success_rate"] # fraction of OCR tokens given any GT partner (not accuracy)
All aligners share the align(ocr_tokens, gt_tokens) -> dict interface and
return mapping. To score against a known gold mapping:
from step1_eval import BASE_PARAGRAPHS, generate_zoning_pair, score_alignment from multiregion_align import MultiRegionAligner pair = generate_zoning_pair(BASE_PARAGRAPHS, error_rate=0.06, zoning="column_interleave", severity="medium", seed=0) res = MultiRegionAligner().align(pair.ocr, pair.gt) print(score_alignment(res["mapping"], pair.gold, pair.break_positions)["f1"])
python smoke_mr.py # runs every pure-Python aligner on synthetic zoning pairs python -m pytest # checks frozen defaults, generator gold, aligner sanity
All scripts run on CPU. Each script in the table writes a JSON file and a short
markdown summary to --results-dir (default results/, git-ignored). Every
script takes --help. Data download and layout are described in
docs/DATA.md. Defaults match the paper protocol unless a flag
is shown.
| Paper table | Script and command | Needs |
|---|---|---|
| Table 1: synthetic zoning F1 | python step3c_ablation.py |
genalog (or --skip-genalog) |
| Table 2: cascade length / recovery@10 | same run as Table 1 (step3c_ablation.json) |
as above |
| Table 3: IMPACT reading-order permutation (GT vs GT) | python step3d_impact_eval.py --max-pages 40 --seed 0Java RETAS row: add --with-java (see note 1) |
IMPACT XML, genalog |
| Table 4: IMPACT real Tesseract vs geometry-derived gold | python step6_impact_geogold.py --max-pages 200 --seed 42 --validate-n 20 --max-tokens 600 [--ids-file IDS]v2 row: step8_v2_eval.py (Table 8 run) |
IMPACT XML + TIFs, Tesseract, genalog, Java |
| Table 5: CC0 word-box gold | python step9_wordgold.py |
word-gold corpora, Tesseract, genalog, Java |
| Table 6: stress tests | python step4_stress.py |
Java (or --skip-java) |
| Table 7: long documents | python step7_longdoc.py (v1, RETAS-lite, Java, profile)v2 column: python step8_v2_eval.py --skip-impact --skip-trec (read output section longdoc_nonrep; boilerplate runs too) |
step7: genalog up to 5k tokens (or --skip-genalog), Java (or --skip-java). v2 command: no external data |
| Table 8: v1 vs v2 held-out comparison | python step8_v2_eval.py [--ids-file IDS] |
IMPACT XML + TIFs, Tesseract, TREC-5 originals, Java |
| Table 9: TREC-5 Confusion Track agreement F1 / matched rate | python step4_trec_all.py --n-docs 30 --seed 0 --max-tokens 600 |
TREC-5, genalog, Java |
| Prose: TREC synthetic noise + column interleave (v1 / RETAS-lite / 2019 / Java) | python step4_trec_synth.py |
TREC-5 originals, Java (or --skip-java) |
Suggested order for the real-OCR tables: download the IMPACT XML, then
download_impact_tifs.py --n 200 --seed 42, which writes the sampled ids to
data/impact_kb/page_ids.txt. Then run step6_impact_geogold.py, followed by
step8_v2_eval.py, passing both --ids-file data/impact_kb/page_ids.txt.
step6 caches Tesseract TSV under results/tess_tsv/, and step8 reuses it.
To run only the synthetic sections of step8_v2_eval.py, use
--skip-impact --skip-trec.
Other scripts:
step1_eval.py: the full synthetic sweep (noise × severity × zoning) for the 2019, re-anchoring and RETAS-lite aligners. It also holds the shared aligners, generator and metrics. Its output directory is--out(defaultresults/), not--results-dir.step5_realocr.py: supplementary real-OCR run on IMPACT, scored only as agreement with genalog (no box gold). Table 4 usesstep6instead.step8_v2_dev.py: the development sweep that chose the v2 defaults inFROZEN_PARAMS_V2.md(dev seeds 100–129). Its optional IMPACT sanity section needs the held-out test-200 page list as--ids-file, used only to exclude those pages; without--ids-filethat section is skipped.download_impact_tifs.py: fetches selected IMPACT TIFs by HTTP range request (seedocs/DATA.md).
Notes:
- Table 3, Java RETAS row. In the paper this row came from a separate run
on the same 40 pages (shuffle seed 0, the same selection as
step3d_impact_eval.py). It scored Java RETAS throughcommon_eval.run_alignerwith the samescore_alignmentmetric.--with-javarepeats that procedure insidestep3d_impact_eval.py. The published row was not produced by this exact script. - Page lists are not shipped. The IMPACT scripts accept
--ids-file. If you omit it, they draw the protocol sample (200 pages, seed 42) from your XML directory. If every XML page has a TIF in the KB zip, this equals the download script's sample, but it has not been checked against the paper's list. The paper's test-200 page list will be added if rights permit. Seedocs/DATA.md. - Results that involve Tesseract depend on the Tesseract version and language data. Runtimes depend on hardware.
- The scripts that build the paper's LaTeX tables and figures are not included. They only reformat the JSON outputs listed above.
- Synthetic tables (1, 2, 6, 7). The gold is exact, but the zoning permutations and character noise are generated, so they are simpler than real layouts.
- TREC-5 (Table 9) has no word-level alignment gold. Its numbers are agreement F1 with genalog's mapping (genalog scores 1.0 by construction) plus a matched rate (the share of OCR tokens given any partner). They do not measure correctness against human word-level gold.
- IMPACT geometry gold (Tables 4, 8) is approximate. KB IMPACT PAGE XML
has region boxes only, so word boxes are estimated by a proportional split
and matched to Tesseract boxes.
step6reports an estimated gold error and a high-confidence subset. - Word-box gold (Table 5) uses human word boxes but covers only 14 pages of 16th–17th century print. It is still a box match with Tesseract.
- The v1 and v2 hyperparameters were frozen before the held-out evaluations
(
FROZEN_PARAMS*.md). The scripts instantiate the aligners with those defaults.
Code: Apache License 2.0. See LICENSE and NOTICE.
Copyright 2026 Jorge Ramón Fonseca Cacho.
Datasets and external tools keep their own licences and terms (see
docs/DATA.md and the table above). None of them is redistributed here.
If you use this code, please cite:
@unpublished{fonsecacacho2026wordalign, author = {Jorge Ramón {Fonseca Cacho}}, title = {Word-Level Alignment of OCR and Ground Truth under Zoning and Reading-Order Errors}, note = {Manuscript under review}, year = {2026} }