Tetrak OCR
GitHub ↗
Reference

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:

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:

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.