Python API
Resolve a backend by name rather than importing one directly — the registry
reports a missing optional dependency as an actionable error instead of
ModuleNotFoundError.
from pathlib import Path
from tetrak_ocr.registry import get_backend
ocr = get_backend("tesseract-auto")
text = ocr(Path("scan.jpg"))
Registry
tetrak_ocr.registry
Resolve a backend by name.
Every backend exposes the same callable — ocr_image(path) -> str — so the
rest of the package can treat them interchangeably. This module is where a
name becomes one of those callables, and where a missing optional dependency
turns into an error that says which extra to install.
Importing a backend module is always safe: each one records whether its heavy
dependency imported successfully in _IMPORT_OK rather than failing at
import time. That matters because :func:available needs to inspect every
backend, including the ones this machine cannot run.
get_backend(name: 'str') -> 'Callable[..., str]'
Return the ocr_image callable for name.
Raises :class:UnknownBackendError for a name that does not exist, and
:class:MissingBackendError when the backend exists but its extra is not
installed.
is_available(name: 'str') -> 'bool'
True when name can actually run on this machine.
available() -> 'list[str]'
Every backend whose dependencies are installed here.
Used by the CLI and the evaluation harness to skip backends the current environment cannot run, instead of failing the whole run.
supported_extensions(name: 'str') -> 'set[str]'
File extensions name accepts, from the backend’s own declaration.
Accuracy metrics
tetrak_ocr.accuracy
Text similarity metrics for OCR evaluation.
Two metrics are provided:
character_similarity Uses Python’s difflib.SequenceMatcher to compare the two texts character-by-character. Returns a ratio from 0.0 (nothing in common) to 1.0 (identical after normalisation). Good for detecting small transcription errors and OCR noise.
word_recall Computes what fraction of the words in the expected text were also found in the actual OCR output. This is a recall-oriented metric: it rewards capturing all the content, and is tolerant of reordering or extra words introduced by the OCR engine.
Both functions normalise their inputs first (lowercase, collapsed whitespace) so that trivial formatting differences do not affect scores.
normalise(text: str) -> str
Lowercase and collapse all whitespace to a single space.
character_similarity(actual: str, expected: str) -> float
Return character-level similarity as a ratio 0.0–1.0.
Uses difflib.SequenceMatcher, which finds the longest common subsequences between the two strings.
Args: actual: The text produced by OCR. expected: The reference (ground-truth) text.
Returns: A float in [0.0, 1.0]. 1.0 means the texts are identical after normalisation.
word_recall(actual: str, expected: str) -> float
Return the fraction of expected words present in the OCR output.
This measures recall: how much of the expected content did we capture? It is tolerant of the OCR engine producing extra words or changing word order, which is common with complex layouts.
Args: actual: The text produced by OCR. expected: The reference (ground-truth) text.
Returns: A float in [0.0, 1.0]. 1.0 means every expected word was found.
Quality scoring
tetrak_ocr.qa_score
Reference-free OCR quality metrics.
Two metrics are combined into a single score used to rank competing transcripts produced by different OCR backends, and to decide whether a document should be routed to the triage queue.
dictionary_coverage Fraction of word tokens that are recognised English words, using pyspellchecker (pure Python, ~100 KB, no model downloads). Effective at catching non-word OCR noise (“tbe”, “h0use”, “rece1ved”).
perplexity_score GPT-2 language model perplexity via the Hugging Face transformers library. Lower values mean more coherent English prose. The GPT-2 model (~500 MB) is downloaded on first use and cached at ~/.cache/huggingface/. The model and tokeniser are kept as module-level singletons so the cost is paid once per process.
combined_score dict_coverage × (1 / log1p(perplexity)). Higher is better.
LowQualityError Raised by ocr_auto_local when every backend’s combined_score falls below MIN_QUALITY_THRESHOLD. Carries the per-backend scores and raw transcripts so tetrak_ocr.batch can write a useful triage manifest.
LowQualityError(message: str, scores: dict[str, float], transcripts: dict[str, str]) -> None
Raised when all OCR backends produce output below MIN_QUALITY_THRESHOLD.
Attributes: scores: {backend_name: combined_score} for every backend tried. transcripts: {backend_name: text} for every backend tried.
is_available() -> bool
True when every package the scoring path imports is installed.
dictionary_coverage(text: str) -> float
Return the fraction of word tokens that are recognised English words.
Tokens shorter than 2 characters are excluded (they are too short to be meaningful spell-check targets and common as OCR noise).
Args: text: Raw text string.
Returns: A float in [0.0, 1.0]. Returns 0.0 for empty or token-free input.
perplexity_score(text: str) -> float
Return the GPT-2 perplexity of the text.
Lower values indicate more coherent, English-like prose. Typical ranges:
- Clean prose: 20–80
- Moderate errors: 100–500
- Heavy OCR noise: 500+
Text is truncated to 1024 tokens (GPT-2 context limit). Very short texts (fewer than 5 tokens) return a fixed high value (1000.0) since perplexity is unreliable on such short sequences.
Args: text: Raw text string.
Returns: A positive float. Lower is better.
combined_score(text: str) -> float
Return a single quality score combining dictionary coverage and perplexity.
Score = dict_coverage × (1 / log1p(perplexity))
Higher is better. log1p tames the unbounded perplexity scale. Returns 0.0 if text is empty.
Args: text: Raw text string.
Returns: A non-negative float. Higher means better quality.
Image and frame handling
tetrak_ocr.imaging
Frame handling for multi-page raster images.
TIFF is the one raster format in SUPPORTED_EXTENSIONS that can hold more than one page, and archival TIFF frequently does — a scanned pamphlet or register often arrives as a single file with one frame per leaf.
Pillow opens such a file at frame 0 and says nothing about the rest. Every backend here loads images through Pillow, directly or indirectly, so “TIFF support” meant reading page one and silently discarding the others. That is the same failure the Claude backend already guards against with its max_tokens check: a truncated transcript is worse than a failed one, because it looks like a result and quietly corrupts everything downstream of it.
So the rule in this package is that a multi-frame image is either read in full
or refused by name. Tesseract reads it in full, page by page, exactly as it
already does for PDFs. The backends that cannot yet do so raise
:class:MultiPageNotSupportedError, which names the file, the page count and a
backend that will read it — rather than returning page one as though that were
the document.
frame_count(path: 'Path') -> 'int'
Number of pages in a raster image; 1 for ordinary single-page files.
Pages, not IFDs. Not every extra frame in a TIFF is another leaf of the
document – see :func:_is_page.
Non-raster inputs (PDFs) and anything Pillow cannot open report 1: this is a question about TIFF paging, and callers handle those cases by other routes. It never raises, so it is safe to call as a guard.
iter_frames(path: 'Path') -> 'Iterator[Image.Image]'
Yield every page of path as an independent image.
Reduced-resolution renditions and transparency masks are skipped; see
:func:_is_page.
The frame’s mode is preserved, not normalised. A bilevel TIFF yields
mode “1”, a palette one yields “P”. That is deliberate: converting to RGB
first was measured and changes no OCR output, so it would be a conversion
that costs memory and buys nothing. preprocess greyscales whatever it is
given. See TestFrameModeIsNotAProblem for the measurement.
Each frame is copied before being yielded. Pillow’s frames are views onto
one open file that seek mutates in place, so a caller that collected them
without copying would end up holding several references to the last frame.
reject_multi_page(path: 'Path', backend: 'str', *, use_instead: 'str' = 'tesseract') -> 'None'
Raise if path holds more than one frame.
For backends that read only frame 0. Called before any work is done, so the caller learns the file cannot be read properly rather than receiving a plausible-looking transcript of its first page.
Auto-local routing
tetrak_ocr.auto_local
auto-local OCR backend: fan out over the local engines and keep the best.
Runs every eligible local backend, scores each transcript with qa_score.combined_score() (dictionary coverage x inverse log-perplexity), and returns the highest-ranked one. It exists because no single local engine wins across document types, so choosing per file beats choosing per batch.
The winning transcript passes a quality gate: if it falls below
qa_score.MIN_QUALITY_THRESHOLD, LowQualityError is raised so the caller can
route the document to the triage queue rather than silently writing a bad
result. The same gate is available for any single backend via
tetrak-ocr batch --quality-gate; this one additionally carries the
per-backend score table, which is what makes the triage manifest worth
reading.
Backend eligibility:
GPU available + Marker installed: Images: Marker, EasyOCR, Paddle, Tesseract-auto (each if installed) PDFs: Marker, Tesseract-auto
No GPU: Images: EasyOCR, Paddle, Tesseract-auto (each if installed) PDFs: Tesseract-auto
Neither EasyOCR nor Paddle can read PDFs, so both are excluded for those.
Fan-out runs its backends sequentially, by design. Running them in parallel looks like free speed and is not: these engines are individually heavy on CPU and RAM, and several hold module-level model singletons. Starting two or three at once on one machine risks memory pressure and contention that would make runtimes less predictable, not more – which matters most on exactly the large batch jobs where the time would otherwise be worth saving.
There was briefly a second strategy here, auto-local-fast, which ran only the
top-ranked eligible backend. The benchmark retired it: in every installed
configuration it was at best equal to running tesseract-auto directly, and on
a GPU machine it resolved to Marker and cost twenty-four times as much for less
accuracy. If a single cheap engine is what you want, name it – and add
--quality-gate if you want the triage queue with it.
Public API: has_gpu() -> bool — True if CUDA, ROCm, or Apple MPS is detected ocr_image(path) -> str — OCR a file; raises LowQualityError if poor SUPPORTED_EXTENSIONS — set of supported file extensions
Model weights are downloaded on first use and cached locally. Singleton patterns (same as EasyOCR, PaddleOCR) ensure each model loads only once.
has_gpu() -> bool
Return True if a GPU is available for neural-network acceleration.
Detects NVIDIA CUDA, AMD ROCm, and Apple Metal Performance Shaders (MPS). Returns False if torch is not importable or no supported GPU is found.
ocr_image(path: 'Path | str') -> str
OCR a file by running every eligible local backend and keeping the best.
Args: path: Path to an image or PDF file.
Fan-out exists because no single local backend wins across document types,
so choosing per file beats choosing per batch – see
tetrak-ocr evaluate --all for the measurement behind that.
The winning transcript passes a quality gate, so a file that no backend reads acceptably reaches the triage queue instead of being written out.
Returns: Extracted text as a string.
Raises: LowQualityError: If the winning combined score falls below MIN_QUALITY_THRESHOLD. The exception carries .scores and .transcripts dicts for triage queue reporting.
Batch pipeline
tetrak_ocr.batch
Batch OCR processor.
Scans the scans/ directory for supported image and PDF files, runs OCR on
each one, writes the extracted text to a Markdown file in processed/, and
moves the original file into processed/ alongside it.
After processing, each file pair looks like: processed/ my-postcard.jpg ← original moved here my-postcard.md ← extracted text
Usage (run from the repository root): tetrak-ocr batch tetrak-ocr batch –backend auto-local tetrak-ocr batch –backend claude tetrak-ocr batch –backend tesseract –contrast 3.0
build_ocr_fn(backend: str, contrast: float | None = None, psm: int | None = None, auto: bool = False) -> collections.abc.Callable[[pathlib.Path], str]
Return the OCR callable for the chosen backend.
Backend resolution lives in :mod:tetrak_ocr.registry — this used to
duplicate it as an if/elif chain of imports, which meant a new backend had
to be registered in two places and a missing optional dependency surfaced
as a bare ImportError.
Only Tesseract takes tuning arguments; the rest ignore them, and passing them is reported rather than silently dropped.
supported_extensions(backend: str) -> set[str]
File extensions backend accepts.
auto-local routes PDFs to Tesseract, so it reports Tesseract’s full set — the registry reads this from each backend’s own SUPPORTED_EXTENSIONS.
process_file(image_path: pathlib.Path, ocr_fn: collections.abc.Callable[[pathlib.Path], str], triaged: list[str] | None = None, quality_gate: bool = False, backend: str | None = None) -> None
OCR a single file, write Markdown output, and move it to processed/.
A transcript that scores below MIN_QUALITY_THRESHOLD goes to the triage queue instead of processed/, so a bad read is triaged rather than silently written out. There are two routes to that, because there are two things that know a transcript is poor:
auto-localscores every backend it ran in order to pick a winner, so it raises LowQualityError itself and hands over the whole score table. That table is what makes the triage manifest worth reading.- Any other backend produces one transcript and no opinion about it. When quality_gate is set, this function scores that transcript and applies the same threshold.
The gate is opt-in for single backends rather than always on: it changes
which files reach processed/, and turning it on silently would start
diverting output for anyone already running --backend tesseract.
Args: image_path: Path to the file inside scans/. ocr_fn: The OCR callable to use. triaged: Optional list to append filenames routed to triage. quality_gate: Score this backend’s transcript and gate on it. backend: Backend name, used to label the triage manifest.
main(args=None) -> int
Parse arguments and process all supported files found in scans/.
Returns a process exit code – 0 on success, 1 if any file failed or
scans/ is missing – so that callers can propagate a partial failure
instead of reporting success. Every failure this function decides on is
returned rather than raised, which is what lets cli.main honour its own
-> int contract.
The one exception is not ours: argparse raises SystemExit on an
unparseable argument list before this function regains control. Callers
driving it programmatically with untrusted arguments should expect that.
Errors
tetrak_ocr.errors
Exceptions raised by the package.
These exist mainly so that a missing optional backend fails as a library
should — with a catchable exception naming the extra to install — rather than
by printing to stderr and calling sys.exit(1), which is what the original
scripts did. That was reasonable when each file was run directly; it is not,
when a caller imports one backend and wants to fall back to another.
OcrPipelineError(…)
Base class for every error this package raises.
MissingBackendError(backend: 'str', extra: 'str', packages: 'str') -> 'None'
A backend was requested but its optional dependency is not installed.
The message names the extra to install, because “No module named ‘paddleocr’” tells a user what broke but not what to do about it.
UnknownBackendError(name: 'str', known: 'list[str]') -> 'None'
A backend name was requested that does not exist.
MultiPageNotSupportedError(path: 'Path', backend: 'str', pages: 'int', use_instead: 'str') -> 'None'
A multi-frame image was given to a backend that reads only frame 0.
Raised instead of transcribing page one and returning it as though it were the whole document. Archival TIFF is often multi-page, and a partial transcript that looks complete is the failure mode this package treats as worst: it passes quality scoring, enters the archive, and misleads every reader after that.