Tetrak OCR
GitHub ↗
Reference

Auto-local routing

One strategy over the whole set of local engines, for when you would rather not choose a backend per document.

auto-local runs every viable local engine on a file, scores each transcript with a reference-free quality score, and keeps the best. It is the best local option on this corpus (0.53/0.74), and the most expensive, since it runs several engines per file.

The decision table below is implemented in tetrak_ocr.auto_local and asserted, case by case, in tests/test_auto_local.py. Every eligible candidate runs; the order settles ties.

Decision logic for tetrak_ocr.auto_local, tetrak_ocr.qa_score, and the triage queue in tetrak_ocr.batch.

Yes — and Marker installed

No

Yes

No — image

Yes

No — image

No — passes quality gate

Yes — LowQualityError

runs via

Tesseract-auto detail (tesseract.py — analyse_image)

Yes

No

< 30 — very flat

30 – 54 — flat

55 – 69 — average

≥ 70 — high contrast

< 15% — sparse text on open paper

15 – 39% — document with layout

≥ 40% — image-dominated frame

PDF?

Extract pages via pdf2image
Defaults: contrast 2.0 · PSM 3
auto-analysis skipped for PDFs

Convert to greyscale

StdDev of pixel intensities
(tonal range proxy)

Contrast × 3.5

Contrast × 3.0

Contrast × 2.0

Contrast × 1.5

Dark pixel %
pixels with luminance < 128

PSM 12
sparse text + orientation detection

PSM 3
auto page segmentation

PSM 6
single uniform block

Tesseract OCR

Triage queue (batch.py — _send_to_triage)

Write triage/‹stem›.md
per-backend score table
+ first 500 chars of each transcript

Move original file to triage/‹filename›

Score each transcript (qa_score.py)

dict_coverage
fraction of word tokens found in an English
dictionary — catches non-word OCR noise
(pyspellchecker, ~100 KB, no model load)

combined_score = dict_coverage × 1 / log1p(perplexity)

perplexity
GPT-2 coherence score — lower means more
natural English; catches real-word substitutions
that spell-checking misses (transformers, ~500 MB)

effective_score = combined_score × (0.5 + 0.5 × words / max_words)

Word-count factor prevents a conservative backend
that emits few clean words from beating a more
complete one on sparse-text images

word_count per transcript
max_words = max across all candidates

Fan-out — run every candidate, collect transcripts

Marker

EasyOCR

PaddleOCR

Tesseract-auto

Input file

has_gpu()
CUDA · ROCm · MPS

PDF?

PDF?

Marker · Tesseract-auto

Marker · EasyOCR¹ · PaddleOCR¹ · Tesseract-auto

Tesseract-auto

EasyOCR¹ · PaddleOCR¹ · Tesseract-auto

Select winner
= candidate with highest effective_score

winner's raw
combined_score
< 0.10?

Return best transcript

triage/
awaiting manual review
or escalation to Claude

¹ EasyOCR and PaddleOCR are excluded from PDF candidates — neither reads PDFs.

Candidates are ordered by measured result, because fast mode takes the first and fan-out uses the order to break ties. EasyOCR sits above PaddleOCR: it is ahead on every image where either engine reads anything at all. See results for the scores.


Notes

GPU detection (has_gpu())

Uses PyTorch, which is already installed as a transitive dependency:

This verifies the GPU is usable by the ML stack, not just visible to the OS.

Fan-out vs routing

The old auto-local picked one backend per file type before processing began. The new version runs every eligible backend and scores each transcript. The cost is roughly proportional to the number of backends × per-image time, which is acceptable given the priority of output quality over build speed.

Effective score: quality × relative word count

Pure combined_score was insufficient on sparse-text images — a conservative backend emitting 8 perfectly clean words can outscore a backend that recovers 23 slightly noisier words, because dict_coverage is trivially 1.00 and perplexity is low for a short well-formed sentence. The 0.5 + 0.5 × words/max_words multiplier ensures coverage is weighted alongside quality. The floor of 0.5 means even a zero-word backend retains half weight (edge case), and max_words is relative so the formula is scale-invariant across different document sizes.

Quality gate threshold

MIN_QUALITY_THRESHOLD = 0.10 in qa_score.py. The gate uses the winner’s raw combined_score, not the effective score — the triage decision should reflect output quality, not quality × length.

Triage queue

Triage is passive by design: nothing escalates automatically. A file lands there with a manifest, and a human decides whether to send it to the Claude backend, re-scan it, or transcribe it by hand. Auto-escalation would quietly turn a local, free, offline pipeline into one that makes paid API calls on material the user may not want leaving the machine.

Tesseract auto-tuning

PDFs bypass per-image analysis — pdf2image renders each page before any analysis step, so fixed defaults (contrast 2.0, PSM 3) are used. For images the bands are those in the diagram above, taken from analyse_image in backends/tesseract.py, which is the authority. Note that dark_pct measures how much of the frame is not paper, not text density — a night-scene postcard reads 56% dark because of the photograph. That turns out to be the useful signal anyway: a frame dominated by imagery has little page structure for layout analysis to work with, however the darkness got there.