Build your own pipeline¶
The batch subcommand is one arrangement of the package’s parts. When your
material needs a different one (a different folder layout, a fallback order
of your own, transcripts written somewhere other than beside the source, or a
score kept rather than acted on), the same parts assemble from Python in a
few dozen lines. This tutorial builds that script. Using the Python
API describes each piece in more detail.
The script will:
pick the best backend this machine has, falling back down a list;
walk a folder, taking only the files that backend can read;
transcribe each one, page by page where a searchable PDF needs it;
score the transcript where the score means something;
write Markdown, plain text and a searchable PDF for each file;
keep a run log, and report what scored poorly at the end.
The script¶
#!/usr/bin/env python3
"""Transcribe every scan in SOURCE into DESTINATION, with a fallback across backends."""
from __future__ import annotations
import sys
from pathlib import Path
from tetrak_ocr import MissingBackendError, outputs, qa_score, telemetry
from tetrak_ocr.batch import transcribe_per_page
from tetrak_ocr.imaging import page_count
from tetrak_ocr.registry import get_backend, supported_extensions
# Best first. Vision ships with macOS and is the strongest local engine
# measured; EasyOCR is the fallback elsewhere; Tesseract is always present.
PREFERRED = ("vision", "easyocr", "tesseract-auto")
FORMATS = ("markdown", "text", "pdf")
def choose_backend():
"""The first preferred backend whose extra is installed here."""
for name in PREFERRED:
try:
return name, get_backend(name)
except MissingBackendError:
continue
raise SystemExit(f"none of {', '.join(PREFERRED)} is installed")
def transcribe(path: Path, ocr) -> tuple[str, list[str] | None]:
"""The transcript, and the per-page transcripts when there is more than one page."""
if page_count(path) > 1:
pages = transcribe_per_page(path, ocr)
return "\n\n".join(t.strip() for t in pages if t.strip()), pages
return ocr(path), None
def main(source: Path, destination: Path) -> int:
name, ocr = choose_backend()
readable = supported_extensions(name)
files = sorted(p for p in source.iterdir() if p.suffix.lower() in readable)
destination.mkdir(parents=True, exist_ok=True)
telemetry.start_run(destination / "telemetry.jsonl", backend=name, files=len(files))
print(f"{name}: {len(files)} file(s)")
poor: list[str] = []
for path in files:
info = telemetry.describe_file(path)
with telemetry.timed("file_finish", backend=name, **info):
text, pages = transcribe(path, ocr)
# The score reads English only, so apply it only where it can
# say something, and record it rather than acting on it.
if qa_score.is_available() and qa_score.scores_english_text(text):
score = qa_score.combined_score(text)
telemetry.record("scored", score=round(score, 4), **info)
if score < qa_score.MIN_QUALITY_THRESHOLD:
poor.append(path.name)
for fmt in FORMATS:
outputs.write(fmt, path, text, destination, page_transcripts=pages)
print(f" {path.name}")
telemetry.finish_run(files=len(files), poor=len(poor))
if poor:
print(f"\nscored below {qa_score.MIN_QUALITY_THRESHOLD}: {', '.join(poor)}")
return 0
if __name__ == "__main__":
raise SystemExit(main(Path(sys.argv[1]), Path(sys.argv[2])))
python transcribe.py ~/archive/scans out/
What each part is doing¶
Choosing a backend. get_backend raises MissingBackendError when the
extra is not installed, so a preference list is a loop with a continue.
The error’s message is the pip install line, which is worth printing if
you decide to stop instead of falling back.
Taking only readable files. supported_extensions(name) is the
backend’s own declaration. Filtering on it is what stops a PDF reaching
Vision, which cannot read one; the batch subcommand does the same, and
--dry-run is how it shows you the result.
Page by page when it matters. A searchable PDF needs one transcript per
page and a backend returns one per document, so transcribe_per_page splits
the document into single pages and transcribes each. It costs roughly twice a
whole-document pass, which is why the script only takes that route when
page_count says there is more than one page. It also means a multi-page
TIFF reaches Vision one frame at a time, where the whole file would have been
refused.
Scoring without acting. combined_score is the reference-free quality
score auto-local ranks with. Here it is kept in the run log and reported
at the end, so the decision about what to do with a poor transcript is left
to a person. scores_english_text guards it: on an Armenian page the score
would be 0.0 whatever the quality.
Writing formats. outputs.write names the file after the source’s stem
and the format’s extension, and writes <stem>.searchable.pdf when the
source is itself a PDF. page_transcripts is ignored by the text formats
and required by the PDF one for a multi-page document; passing it to all of
them is fine, because every writer tolerates options meant for another.
The run log. start_run opens the JSON Lines file and finish_run
closes it; between them, timed wraps each file with an event carrying the
elapsed seconds, success or not, and record adds anything else worth
keeping. describe_file gives every event the same file fields. With
TETRAK_NO_TELEMETRY=1 in the environment every one of these is a no-op.
Variations¶
Let
auto-localchoose, and catch its refusal. Replace the preference list withget_backend("auto-local")and wrap the call inexcept LowQualityError as exc:exc.scoresandexc.transcriptshold every engine’s result, which is what thebatchsubcommand writes into a triage manifest.Tune Tesseract.
tetrak_ocr.batch.build_ocr_fn("tesseract-auto", contrast=3.0)returns a one-argument callable with the options applied, so it drops into the script whereocris.Name the PDF’s font. For a script the built-in font cannot encode, pass
pdf_font="/path/to/font.ttf"throughoutputs.write; without a font that can encode the transcript the PDF is refused withPdfFontError.Re-use the pipeline’s own file handling.
tetrak_ocr.batch.process_filedoes all of the above for one file, with the triage manifest and the move intoworkspace/processed/; it is the function thebatchsubcommand calls per file.