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.
¹ 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:
torch.cuda.is_available()— covers NVIDIA CUDA and AMD ROCm (ROCm exposes the CUDA API)torch.backends.mps.is_available()— covers Apple Silicon (Metal Performance Shaders)
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.