docpipe

docpipe is the domain-independent 70% of every scanned-document extraction pipeline: ingest, page-quality measurement, adaptive preprocessing, pluggable OCR/VLM reading backends, schema-driven extraction with provenance, calibrated confidence fusion, and an evaluation harness.

Design in one paragraph

A scanned page is a fact that survived a noisy channel: printed, photocopied, faxed, scanned at whatever DPI the clerk's flatbed defaulted to, JPEG'd, and wrapped in a PDF. Extraction is channel inversion. Three things follow. Preprocessing is equalisation, so it must be a function of the page's measured degradation rather than a fixed sequence. Confidence is a property of the channel, not of how fluent the model sounds, so it must fuse independent evidence. Provenance is recoverable at read time and unrecoverable afterwards, so it is threaded through every layer from the start.

Layers (each usable standalone)

Layer 5EvalSuite — datasets, metrics, pipeline A/B, regressions
Layer 4extract — schema in, typed result with evidence out
Layer 3read — pluggable, cost-aware OCR / VLM backends
Layer 2preprocess — composable Page -> Page ops
Layer 1Document / Page / TextSpan / BBox
Layer 0ingest — PDF, TIFF, image, email attachment

Cross-cutting: caching, cost accounting, retries, tracing, provenance.

Namespaces

One file does not have to mean one flat heap of names. Groups of free functions that share a subject are gathered onto a namespace class as staticmethods, and only the class is exported:

Capsoptional-dependency probing, the OpenCV kill switch
Utilhashing, clamping, string distance, JSON coercion
Imageraster primitives (arrays in, arrays out)
Qualitypage-quality measures and estimators
Ingestbytes of unknown provenance to a Document
Opsthe preprocessing ops, as Op factories
Policiesmeasured page to the ops it needs
Pricingtoken accounting and the optional price table
Textscript detection, normalisation, value parsing
Confidence signal fusion and calibration metrics
Validators domain-independent validator factories

Every one of those staticmethods is also a module attribute under its bare name — dp.to_gray and dp.Image.to_gray are the same object. The flat names are not decoration: they are how the staticmethods call one another, and they keep every existing call site working. Image.to_gray is the documented path; to_gray is the one that already works. Classes that carry state or are meant for subclassing (Page, BaseBackend, EvalSuite) and the layer entry points (read, preprocess, extract, process) stay where they are.

Quickstart

import docpipe as dp

doc = dp.Ingest.ingest("claim_47812.pdf")
doc = dp.preprocess(doc, policy=dp.Policies.default_policy)
doc = dp.read(doc, router=dp.default_router)

print(doc.text()[:500])
print(doc.pages[0].quality)

With a schema:

from pydantic import BaseModel

class Bill(BaseModel):
    hospital_name: str
    total: float

res = dp.extract(doc, schema=Bill, client=dp.AnthropicClient())
res.fields["total"].value        # 48250.0
res.fields["total"].confidence   # 0.94  (fused, calibratable)
res.fields["total"].evidence     # [BBox(page=3, ...)]

Dependencies

The core (IR, normalisation, confidence, extraction plumbing, eval harness) is pure standard library. Everything heavier is optional and imported lazily:

numpyall raster work
opencv-pythonfast/high-quality image ops (NumPy fallbacks too)
pymupdfPDF ingest and native text layers
pillowimage and multi-page TIFF ingest
pytesseractTesseract backend (or the tesseract binary)
paddleocrPaddleOCR backend
rapidocr-onnxruntimeRapidOCR backend (PP-OCR weights, ONNX runtime)
easyocrEasyOCR backend
python-doctrdocTR backend
surya-ocrSurya backend (90+ scripts)
anthropic / openaivision-language backends and extraction clients
pydanticschema-driven extraction (v1 and v2 supported)

Call capabilities to see what is actually importable right now.

Compatibility

Python 3.8+. No X | Y annotations, no match, no walrus-in-comprehension cleverness — this file is meant to be dropped into old codebases.

Annotations are written inline and are never evaluated at runtime, because from __future__ import annotations (PEP 563) is in force below. That is what lets a 3.8-compatible file use modern annotation syntax and forward references without quoting them. Aliases such as ImageArray and Source name the conventions that the optional-dependency types cannot express — see the type-alias block after the imports.

License: MIT.

github.com/utkarsh5026/Docpipe 179 entries

1. Optional dependencies

Rule: nothing heavy is imported at module import time. Every optional import goes through _try_import, which caches both successes and failures, so a missing dependency costs one failed import for the life of the process and produces an actionable error message at the point of use — not a traceback from three frames deep inside a backend.

DocpipeError

class public API source
DocpipeError(Exception)

Base class for every error this library raises deliberately.

MissingDependency

class public API source
MissingDependency(DocpipeError, ImportError)

An optional dependency is required for the requested operation.

Methods

MissingDependency.__init__

method source
__init__(self, module: str, purpose: str = "", extra: str = "") -> None

Build the error, including the pip install line that fixes it.

module
the import name that failed
purpose
what it was needed for, e.g. "PDF handling"
extra
the pip name, when it differs from the import name

IngestError

class public API source
IngestError(DocpipeError)

A source document could not be opened, decoded or repaired.

BackendError

class public API source
BackendError(DocpipeError)

A reading backend failed.

ExtractionError

class public API source
ExtractionError(DocpipeError)

Structured extraction failed (bad model output, bad schema, ...).

ConfigError

class public API source
ConfigError(DocpipeError)

A pipeline was configured with values that cannot work.

BudgetExceeded

class public API source
BudgetExceeded(DocpipeError)

A cost budget was exhausted before the document finished.

_PIP_NAMES

const source
_PIP_NAMES = {'cv2': 'opencv-python', 'fitz': 'pymupdf', 'PIL': 'pillow', 'pytesseract': 'pytesseract...

pip names for modules whose import name differs from their distribution name.

_try_import

func source
_try_import(name: str) -> Optional[Module]

Import name, returning None if it is unavailable.

Results (including failures) are cached, so a missing optional dependency is only paid for once.

Caps

class public API source

Optional-dependency probing and the OpenCV kill switch.

Grouped so that a caller can ask one object what is importable right now. require is the only sanctioned way to reach an optional import: it raises MissingDependency with a pip hint at the point of the call, rather than letting an ImportError surface three frames deep inside a backend.

Methods

Caps.require

staticmethod source
require(name: str, purpose: str = "") -> Module

Import name or raise MissingDependency with a pip hint.

The gate every optional import goes through, so a missing dependency fails where the user called it rather than three frames deep inside a backend.

name
the import name, e.g. "cv2", "fitz", "PIL.Image". Note that this is the import name, not the pip name — the mapping between them is what produces a correct install hint.
purpose
what it was needed for, e.g. "Tesseract input conversion". It goes into the error message, so make it name the feature the user was reaching for.
returns
the imported module
raises MissingDependency
not importable; the message carries the pip install command

Caps.have

staticmethod source
have(name: str) -> bool

True when optional module name can be imported.

The non-raising counterpart to require, for choosing a code path rather than demanding one. Results are cached, so probing in a hot loop is cheap.

name
the import name, e.g. "cv2" or "pytesseract"
returns
whether the import succeeds. False also covers a module that is installed but broken — a native library failing to load counts as unavailable, which is the useful answer.

Caps.set_opencv_enabled

staticmethod source
set_opencv_enabled(enabled: bool) -> bool

Enable/disable OpenCV acceleration globally. Returns the previous value.

Process-wide and not thread-safe, so set it during start-up or in a test fixture. For a scoped change prefer with Caps.without_opencv():. The DOCPIPE_DISABLE_OPENCV=1 environment variable does the same at import time, which is what CI uses to exercise the NumPy fallbacks.

enabled
False forces the pure-NumPy path even when OpenCV is installed. Results differ slightly between the two backends — the fallbacks are equivalent, not bit-identical.
returns
the previous setting, so a caller can restore it

Caps.without_opencv

staticmethod source
without_opencv() -> _OpenCVDisabled

with docpipe.without_opencv(): ... — force pure-NumPy image ops.

Caps.capabilities

staticmethod source
capabilities() -> Dict[str, bool]

Report which optional integrations are usable in this interpreter.

Cheap to call and safe at import time; useful in a health check.

_fitz

func source
_fitz(required: bool = False) -> Optional[Module]

PyMuPDF under either of its import names.

The project renamed fitz to pymupdf and the old name now emits a deprecation warning on import, so try the new one first and fall back.

_USE_OPENCV

const source
_USE_OPENCV = os.environ.get('DOCPIPE_DISABLE_OPENCV', '').strip().lower() not in ('1', 'true', 'yes',...

Global switch. Set to False to force the pure-NumPy fallbacks even when OpenCV is installed — used by the test suite to exercise both paths, and occasionally useful when a wheel's OpenCV misbehaves in a container. DOCPIPE_DISABLE_OPENCV=1 in the environment sets it at import time.

_cv2

func source
_cv2() -> Optional[Module]

OpenCV if available and enabled, else None.

Image ops call this and branch: cv = _cv2(); if cv is not None: ....

_OpenCVDisabled

class source

Context manager forcing NumPy fallbacks inside a block.

2. Small utilities

Util

class public API source

Small, dependency-free helpers used across every layer.

Nothing here knows about documents. They live together because they are the handful of primitives — stable hashing, clamping, string distance, JSON coercion — that the rest of the file assumes exist.

Methods

Util.stable_hash

staticmethod source
stable_hash(*parts: Any) -> str

A short, stable, cross-process content hash.

hash() is salted per process and useless for caching; this is not.

Util.array_hash

staticmethod source
array_hash(arr: ImageArray) -> str

Content hash of a NumPy array, for caching backend reads.

Hashes the pixels, shape and dtype rather than a filename, which is what makes CachingBackend correct across preprocessing changes: alter an op and the cache misses, as it should.

arr
the array to hash; a non-array falls back to hashing its repr
returns
a stable hex digest, identical across processes and runs

Util.clamp

staticmethod source
clamp(value: float, lo: float, hi: float) -> float

Clamp value into [lo, hi].

value
the number to bound
lo
lower bound, returned when value is below it
hi
upper bound, returned when value is above it
returns
the bounded value. No check that lo <= hi; inverted bounds return lo.

Util.percentile

staticmethod source
percentile(values: Sequence[float], q: float) -> float

Linear-interpolated percentile without pulling in NumPy.

q is in [0, 100]. Returns 0.0 for an empty sequence, which is the convention the metrics code below relies on.

values
the sample, in any order; it is sorted internally
q
the percentile in 0.0..100.0, clamped. 50 is the median, 95 the usual tail metric.
returns
the interpolated value, or 0.0 for an empty sequence

Util.levenshtein

staticmethod source
levenshtein(a: str, b: str, max_distance: Optional[int] = None) -> int

Edit distance with an optional early-exit bound.

Iterative two-row DP: O(len(a) * len(b)) time, O(min) space. When max_distance is given and every cell in a row exceeds it, we bail out early and return max_distance + 1.

a
the first string
b
the second string
max_distance
give up once the distance provably exceeds this. Turns a "are these nearly equal?" test into an early exit rather than a full matrix — worth passing when scanning many candidates and only close ones matter.
returns
the edit distance, or max_distance + 1 when the bound was hit. The sentinel means "further than you asked about", not an exact distance.

Util.similarity

staticmethod source
similarity(a: str, b: str) -> float

Normalised similarity in [0, 1]: 1 - edit_distance / max_len.

The comparison behind evidence matching and cross-read agreement. Normalising by the longer string keeps a one-character error in a long value from scoring as badly as one in a short value.

a
the first string
b
the second string
returns
1.0 for identical strings, 0.0 when either is empty (unless both are), graded in between. Normalise the inputs first if case or punctuation should not count.

Util.retry_call

staticmethod source
retry_call(fn: Callable[[], T], attempts: int = 3, base_delay: float = 0.5,
               max_delay: float = 8.0, jitter: float = 0.25,
               retry_on: Tuple[Type[BaseException], ...] = (Exception,),
               give_up_on: Tuple[Type[BaseException], ...] = (),
               on_retry: Optional[Callable[[int, BaseException, float], None]] = None,
               sleep: Callable[[float], None] = time.sleep) -> T

Call fn with exponential backoff and full jitter.

give_up_on wins over retry_on so that non-retryable failures (bad API key, malformed request) fail immediately instead of being hammered three times and billed three times. on_retry(attempt, exc, delay) is the hook the cost tracker uses to record failed-but-charged attempts.

fn
the zero-argument callable to attempt
attempts
total tries including the first; must be at least 1
base_delay
seconds before the first retry, doubling each time
max_delay
ceiling on the backoff, before jitter
jitter
random fraction of the delay added on top, in 0.0..1.0. 0.25 is enough to break up the thundering herd when many pages fail against the same provider at once.
retry_on
exception types worth retrying. (Exception,) is broad because provider SDKs raise their own hierarchies.
give_up_on
exception types that never become successes. These win over retry_on, so a bad API key fails once, not three times.
on_retry
called as on_retry(attempt, exc, delay) before each sleep — the hook for counting billed-but-failed attempts.
sleep
the sleep function, injectable so tests do not wait
returns
whatever fn returned on its first success
raises ConfigError
attempts is less than 1
raises Exception
the last failure, once every attempt is spent

Util.to_json

staticmethod source
to_json(obj: Any, indent: Optional[int] = 2, sort_keys: bool = False) -> str

Serialise any docpipe object graph to JSON.

Handles the types json will not: dataclasses, enums, Decimal, dates and NumPy scalars, by way of each object's to_dict.

obj
any docpipe object, or a container of them
indent
spaces per level; None produces compact one-line JSON
sort_keys
sort object keys, which makes output diffable — worth setting for anything committed as a baseline
returns
the JSON text, with non-ASCII characters left as themselves so Devanagari and CJK stay readable in a report

_safe_div

func source
_safe_div(a: float, b: float, default: float = 0.0) -> float

a / b, or default when b is zero.

Timer

class public API source

Wall-clock stopwatch. with Timer() as t: ... then t.ms.

Methods

Timer.__init__

method source
__init__(self) -> None

Start at zero; the clock only runs between __enter__ and __exit__.

Timer.ms

property source
ms(self) -> float

Elapsed milliseconds — so far, if the block has not exited yet.

chunked

func source
chunked(seq: Sequence[T], size: int) -> Iterator[List[T]]

Yield seq in lists of at most size.

seq
the sequence to split
size
maximum items per chunk; the final chunk may be shorter
returns
an iterator of lists
raises ConfigError
size is less than 1

_map_maybe_parallel

func source
_map_maybe_parallel(fn: Callable[[Any], Any], items: Sequence[Any],
                        max_workers: int = 0, label: str = "work") -> List[Any]

map that goes through a thread pool when max_workers > 1.

Threads (not processes) because the expensive work is either native code that releases the GIL (OpenCV, ONNX runtime) or network I/O (VLM calls), and because a Page holding a lazily-rendered raster is not picklable.

_utcnow

func source
_utcnow() -> str

Current UTC time as a second-resolution ISO-8601 string.

Reads the clock as an aware UTC datetime and then drops the offset, because datetime.utcnow() is deprecated from 3.12 on. The Z suffix is appended by hand, so the output is unchanged: 2026-08-12T09:15:00Z.

_as_jsonable

func source
_as_jsonable(obj: Any) -> Any

Best-effort conversion of nested docpipe/py objects to JSON primitives.

3. Layer 1 — the intermediate representation

This is the actual product. Backends are swappable because they all speak TextSpan; ops compose because they are Page -> Page; two whole pipelines can be A/B'd because they share input and output types. Everything else in this file is replaceable around these five types.

Coordinate convention, stated once and obeyed everywhere:

  • BBox lives in PDF points (1/72 inch), top-left origin, y growing down, with page rotation already applied.
  • Raster arrays are uint8 NumPy, either (H, W) grayscale or (H, W, 3) RGB.
  • pixels = points * dpi / 72. Use BBox.to_pixels()/from_pixels().

PageKind

class public API source
PageKind(str, enum.Enum)

How the text on a page is stored — which decides how to read it.

Attributes

DIGITAL_NATIVE = 'digital_native'
trustworthy embedded text layer
SCANNED = 'scanned'
raster only, OCR required
HYBRID = 'hybrid'
native text plus scanned inserts
BLANK = 'blank'
nothing worth reading

Verdict

class public API source
Verdict(str, enum.Enum)

Coarse readability judgement produced by measure_quality.

RegionKind

class public API source
RegionKind(str, enum.Enum)

Layout region types worth routing on.

BBox

classdataclass public API source

An axis-aligned rectangle on a specific page, in canonical page points.

Frozen because bboxes get shared liberally between spans, fields and eval records; an accidental in-place mutation would corrupt provenance silently.

Fields

page: int
x0: float
y0: float
x1: float
y1: float

Methods

BBox.width

property source
width(self) -> float

Width in points (never negative — see __post_init__).

BBox.height

property source
height(self) -> float

Height in points (never negative — see __post_init__).

BBox.area

property source
area(self) -> float

Area in square points.

BBox.center

property source
center(self) -> Tuple[float, float]

(x, y) of the centre, in points.

BBox.union

method source
union(self, other: BBox) -> BBox

Smallest box containing both. Requires the same page.

other
a box on the same page
returns
the smallest box enclosing both
raises ValueError
the boxes are on different pages. A union across pages has no geometric meaning, and silently returning one would put a reviewer highlight in the wrong place.

BBox.intersection

method source
intersection(self, other: BBox) -> Optional[BBox]

Overlap rectangle, or None when they do not overlap.

other
any box
returns
the overlapping rectangle, or None when the boxes are on different pages or merely touch. Edge contact is not overlap: two adjacent table cells intersect in zero area and return None.

BBox.iou

method source
iou(self, other: BBox) -> float

Intersection over union in [0, 1].

other
any box
returns
overlap area divided by combined area, in 0.0..1.0. 0.0 for boxes on different pages or with no overlap; 1.0 for identical boxes.

BBox.contains

method source
contains(self, other: BBox, tolerance: float = 0.0) -> bool

True when other lies inside this box on the same page.

tolerance slackens every edge outward, which matters because OCR boxes and layout boxes come from different estimators and rarely nest exactly.

other
the box that might be inside this one
tolerance
slack in points applied outward on every edge. 0.0 demands exact containment; 2.0 (about a character's width at 12pt) is realistic when testing OCR word boxes against a detected table region.
returns
whether other lies within this box, on the same page

BBox.overlaps

method source
overlaps(self, other: BBox) -> bool

True when the two boxes share any area on the same page.

other
any box
returns
whether the two share positive area. Touching edges do not count — see intersection.

BBox.expand

method source
expand(self, margin: float) -> BBox

This box grown by margin points on every side (negative shrinks).

margin
points to add to every side; negative shrinks. Useful for padding a highlight so it does not clip the glyphs it marks.
returns
a new box. Not clipped to the page, so follow it up with clipped when the result must stay on the sheet.

BBox.scaled

method source
scaled(self, factor: float) -> BBox

This box with every coordinate multiplied by factor.

Scales position as well as size, since it multiplies the coordinates rather than resizing about the centre.

factor
multiplier applied to all four coordinates
returns
a new box on the same page

BBox.translated

method source
translated(self, dx: float, dy: float) -> BBox

This box shifted by (dx, dy) points.

dx
horizontal shift in points; positive moves right
dy
vertical shift in points; positive moves down, since the origin is the top-left corner
returns
a new box on the same page

BBox.clipped

method source
clipped(self, width: float, height: float) -> BBox

This box confined to a width x height page.

width
page width in points (595 for A4, 612 for US Letter)
height
page height in points (842 for A4, 792 for US Letter)
returns
a new box with every coordinate clamped into the page. A box entirely outside collapses to zero area rather than raising.

BBox.to_pixels

method source
to_pixels(self, dpi: float) -> Tuple[int, int, int, int]

(x0, y0, x1, y1) in integer pixels at dpi.

dpi
the resolution to express the box at. Use page.raster_dpi to index into that page's current raster — passing a different DPI gives coordinates for an image that does not exist.
returns
(x0, y0, x1, y1) as rounded integer pixels

BBox.from_pixels

classmethod source
from_pixels(cls, page: int, x0: float, y0: float, x1: float, y1: float,
                dpi: float) -> BBox

Build a point-space BBox from pixel coordinates measured at dpi.

The conversion every backend needs: engines report pixels, the IR stores points, and storing pixels would silently invalidate every box the moment a resolution-changing op ran.

page
zero-based page index the box belongs to
x0
left edge in pixels
y0
top edge in pixels
x1
right edge in pixels
y1
bottom edge in pixels
dpi
resolution the pixel coordinates were measured at, normally page.raster_dpi
returns
the box in points (1/72 inch), which stays valid across rescaling

BBox.from_xywh

classmethod source
from_xywh(cls, page: int, x: float, y: float, w: float, h: float) -> BBox

Build from a top-left corner plus a width and height.

For engines reporting (x, y, w, h) rather than two corners — Tesseract's TSV output among them.

page
zero-based page index
x
left edge, in points
y
top edge, in points
w
width in points
h
height in points
returns
the equivalent corner-based box

BBox.from_quad

classmethod source
from_quad(cls, page: int, points: Sequence[Sequence[float]]) -> BBox

Axis-aligned hull of a polygon — what most detectors actually emit.

PaddleOCR, RapidOCR and EasyOCR all detect quadrilaterals so that rotated text is found correctly. The IR stores axis-aligned boxes, so the hull is taken here; backends that need the original keep it in span.meta.

page
zero-based page index
points
the polygon's vertices as [(x, y), ...] in points — any number of them, though detectors emit four
returns
the smallest axis-aligned box containing every vertex

BBox.whole_page

classmethod source
whole_page(cls, page: int, width_pt: float, height_pt: float) -> BBox

A box covering the entire page — the default evidence region.

What a vision backend attaches to its spans, since it reports no coordinates. Such spans are marked approximate_bbox so nothing mistakes a page-sized box for real geometry.

page
zero-based page index
width_pt
page width in points
height_pt
page height in points
returns
a box spanning the whole page

BBox.to_dict

method source
to_dict(self) -> Dict[str, float]

JSON-ready dict; coordinates are rounded to 3 decimals (~0.001 pt).

BBox.from_dict

classmethod source
from_dict(cls, d: Mapping[str, Any]) -> BBox

Inverse of to_dict.

d
a mapping with page, x0, y0, x1, y1
returns
the reconstructed box
raises KeyError
a required key is missing

merge_bboxes

func public API source
merge_bboxes(boxes: Sequence[BBox]) -> List[BBox]

Union boxes per page. Evidence spanning pages stays as one box per page.

Per page rather than one box overall, because a value found on pages 2 and 7 is two pieces of evidence — collapsing them would produce a box spanning both and pointing at neither.

boxes
any boxes, in any order, from any pages
returns
one merged box per page that appeared, ordered by page index. Empty input gives an empty list.

TextSpan

classdataclass public API source

A run of text with a location, a producer and (maybe) a confidence.

confidence is deliberately Optional: several backends report nothing, and inventing 1.0 for them would poison confidence fusion downstream. None means "no evidence", not "certain".

Fields

text: str
bbox: BBox
source: str = 'unknown'
confidence: Optional[float] = None
script: Optional[str] = None
line: Optional[int] = None
block: Optional[int] = None
meta: Dict[str, Any] = field(default_factory=dict)

Methods

TextSpan.page

property source
page(self) -> int

Index of the page this span sits on.

TextSpan.is_empty

property source
is_empty(self) -> bool

True when the span carries no non-whitespace text.

TextSpan.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

Optional fields are omitted when unset rather than serialised as null: a document's span list is the bulk of its JSON, and absent-means-unknown round-trips through from_dict unchanged.

TextSpan.from_dict

classmethod source
from_dict(cls, d: Mapping[str, Any]) -> TextSpan

Inverse of to_dict.

d
a mapping as produced by to_dict
returns
the reconstructed span
raises KeyError
text or bbox is missing

PageQuality

classdataclass public API source

Measured channel degradation for one page.

Every number here is a measurement, not a guess, and the preprocessing policy and confidence prior are both functions of these values. See measure_quality for how each is computed.

Fields

blur: float = 1.0
0 = smeared, 1 = crisp (normalised VoL)
skew_deg: float = 0.0
positive = content rotated clockwise
contrast: float = 1.0
0 = flat grey, 1 = full range
ink_coverage: float = 0.0
fraction of dark pixels
effective_dpi: int = 0
estimated content resolution
raster_dpi: int = 0
dpi the raster was rendered at
noise: float = 0.0
0 = clean, 1 = heavy speckle
illumination: float = 1.0
1 = even lighting, 0 = severe gradient
verdict: Verdict = Verdict.CLEAN
extra: Dict[str, float] = field(default_factory=dict)

Methods

PageQuality.is_readable

property source
is_readable(self) -> bool

True unless the page was judged UNREADABLE.

PageQuality.score

property source
score(self) -> float

Single scalar in [0, 1] summarising page health.

Used as the prior term in confidence fusion. Weights are deliberately blunt — they exist to be recalibrated against a labelled set (Layer 5), not to be believed as-is.

PageQuality.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict, including the derived score.

PageQuality.from_dict

classmethod source
from_dict(cls, d: Mapping[str, Any]) -> PageQuality

Inverse of to_dict (the derived score key is ignored).

d
a mapping as produced by to_dict
returns
the reconstructed quality. Every key has a default that means "undegraded", so a partial dict loads as a clean page rather than a spuriously bad one. score is recomputed from the components.

LayoutRegion

classdataclass public API source

A detected region of a page: table, stamp, signature, handwriting...

Fields

kind: RegionKind
bbox: BBox
confidence: float = 1.0
meta: Dict[str, Any] = field(default_factory=dict)

Methods

LayoutRegion.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

LayoutRegion.from_dict

classmethod source
from_dict(cls, d: Mapping[str, Any]) -> LayoutRegion

Inverse of to_dict.

d
a mapping as produced by to_dict
returns
the reconstructed region
raises KeyError
bbox is missing
raises ValueError
kind is not a RegionKind value

Layout

classdataclass public API source

Region inventory for a page, with the predicates routing cares about.

Fields

regions: List[LayoutRegion] = field(default_factory=list)

Methods

Layout.of_kind

method source
of_kind(self, kind: RegionKind) -> List[LayoutRegion]

Every region of exactly kind, in detection order.

kind
the RegionKind to filter on, e.g. RegionKind.TABLE or RegionKind.SIGNATURE. Exact, not hierarchical — asking for TABLE never returns table cells.
returns
the matching regions, in the order the detector found them

Layout.is_tabular

property source
is_tabular(self) -> bool

True when at least one table region was detected.

Layout.has_handwriting

property source
has_handwriting(self) -> bool

True when at least one handwriting region was detected.

Layout.has_stamps

property source
has_stamps(self) -> bool

True when at least one stamp region was detected.

Layout.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

Layout.from_dict

classmethod source
from_dict(cls, d: Mapping[str, Any]) -> Layout

Inverse of to_dict.

d
a mapping as produced by to_dict
returns
the reconstructed layout; a missing regions key yields an empty layout rather than an error

OpRecord

classdataclass public API source

One entry in a page's processing history.

This is what makes an experiment reproducible and lets the eval harness attribute an accuracy delta to a specific op rather than to "the new build".

Fields

op: str
params: Dict[str, Any] = field(default_factory=dict)
ms: float = 0.0
note: str = ''

Methods

OpRecord.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict, omitting empty params and notes.

DEFAULT_DPI

const public API source
DEFAULT_DPI = 300

Default rendering resolution. 300 DPI is the point where Tesseract's character models stop degrading on 8-10pt body text; below ~200 accuracy falls off a cliff, above ~400 you pay for pixels that carry no extra signal.

DEFAULT_IMAGE_DPI

const public API source
DEFAULT_IMAGE_DPI = 200

Assumed resolution for a bare image file with no DPI metadata. A phone photo of an A4 page is typically 150-250 effective DPI.

Page

classdataclass public API source

One page: its geometry, its measured quality, its text, its history.

The raster is lazy by contract, not as an optimisation. A 300-page legal bundle rendered eagerly at 400 DPI is tens of gigabytes of RAM, so laziness has to live in the type — it cannot be retrofitted by a caller.

Fields

index: int
width_pt: float = 612.0
height_pt: float = 792.0
kind: PageKind = PageKind.SCANNED
rotation: int = 0
quality: PageQuality = field(default_factory=PageQuality)
spans: List[TextSpan] = field(default_factory=list)
layout: Layout = field(default_factory=Layout)
history: List[OpRecord] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict)
_raster: Optional[Any] = field(default=None, repr=False, compare=False)
_raster_dpi: int = field(default=0, repr=False, compare=False)
_raster_provider: Optional[RasterProvider] = field(default=None, repr=False, compare=False)

Methods

Page.raster

method source
raster(self, dpi: Optional[int] = None) -> ImageArray

Return the page image as a uint8 NumPy array, rendering on demand.

Semantics that matter:

  • With a provider still attached (nothing has been preprocessed yet), a request at a different DPI re-renders from source — always better than upsampling.
  • Once an op has run, the provider is dropped and the processed raster is authoritative; a differing DPI request rescales it instead, so an op chain is never silently discarded.
dpi
the resolution wanted. None keeps the page's current DPI, falling back to DEFAULT_DPI. Ask for what you need rather than rescaling afterwards — a re-render from the PDF beats an upsample every time, and this method knows which is possible.
returns
the page image as a uint8 NumPy array, greyscale or RGB
raises DocpipeError
the page has neither a raster nor a provider, e.g. an email-body page, which carries text but no pixels. Guard with has_raster.

Page.has_raster

method source
has_raster(self) -> bool

True when a raster is materialised or can be produced without I/O errors.

Page.set_raster

method source
set_raster(self, img: ImageArray, dpi: Optional[int] = None,
               keep_provider: bool = False) -> Page

Install img as this page's raster. Ops use this.

Dropping the provider is the default and is deliberate: after a deskew, re-rendering from the PDF would silently undo the op.

img
the new raster, a uint8 NumPy array
dpi
the resolution img represents. None keeps the page's current DPI — correct for an op that preserved size, wrong for one that rescaled, which must pass the new value.
keep_provider
retain the lazy source. Leave this False except when installing a raster equivalent to what the provider would return; keeping it lets a later DPI change discard your image.
returns
self, so calls chain

Page.set_raster_provider

method source
set_raster_provider(self, provider: RasterProvider,
                        dpi: Optional[int] = None) -> Page

Attach a lazy raster source, e.g. a PDF page renderer.

provider
called as provider(dpi) and must return an ImageArray
dpi
the resolution to assume until one is requested explicitly

Page.release_raster

method source
release_raster(self) -> Page

Drop the materialised raster. Only safe while a provider remains.

Page.raster_dpi

property source
raster_dpi(self) -> int

Resolution of the current raster, falling back to DEFAULT_DPI.

Page.pixel_size

property source
pixel_size(self) -> Tuple[int, int]

(width, height) of the current raster in pixels, (0, 0) if none.

Page.text

method source
text(self, separator: str = "\n") -> str

Reading-order text. Spans are sorted by line, then by x.

separator
placed between lines; the default single newline preserves the page's line structure, which is what amount-and-label parsing depends on
returns
the page's text. Empty for a page that has not been read.

Page.lines

method source
lines(self, y_tolerance: Optional[float] = None) -> List[str]

Group spans into visual lines and return them top-to-bottom.

y_tolerance defaults to 60% of the median span height, which tracks font size instead of assuming one.

y_tolerance
vertical distance in points within which two spans count as the same line. None derives it from the page's own median span height, which is right across font sizes. Pass a value only for pages that defeat that — a table whose rows are closer than its text is tall.
returns
one string per visual line, top to bottom, words joined by single spaces. Empty lines are dropped.

Page.spans_in

method source
spans_in(self, bbox: BBox, min_overlap: float = 0.5) -> List[TextSpan]

Spans whose area overlaps bbox by at least min_overlap.

The fraction is of the span's area, not the box's, so a small word inside a large region counts as fully contained.

bbox
the region to search, in points
min_overlap
least fraction of each span's area that must fall inside, in 0.0..1.0. 0.5 requires the majority of the word; 0.3 is more forgiving and is what evidence matching uses, since OCR boxes and layout boxes come from different estimators.
returns
the qualifying spans, in page order

Page.char_count

property source
char_count(self) -> int

Total non-whitespace characters across every span on the page.

Page.is_blank

property source
is_blank(self) -> bool

True when the page is worth skipping entirely.

Either it was classified blank at ingest, or it has no text and essentially no ink — both checks are needed, because an unread scan of a full page also has a character count of zero.

Page.record

method source
record(self, op: str, params: Optional[Dict[str, Any]] = None, ms: float = 0.0,
           note: str = "") -> Page

Append an entry to this page's processing history. Returns self.

op
the op's registered name
params
the arguments it ran with, for reproducibility
ms
wall-clock duration
note
anything worth knowing, e.g. why an op declined to run

Page.history_summary

method source
history_summary(self) -> str

The op history as one a -> b -> c line.

Page.bbox

method source
bbox(self) -> BBox

A box covering the whole page, in points.

Page.copy

method source
copy(self, deep_spans: bool = True) -> Page

Shallow-copy the page, sharing the raster but not the span list.

Ops use this so that a pipeline never mutates the caller's document — A/B'ing two pipelines against one ingested document has to be safe.

The raster is shared, not copied: it is large, and ops replace it rather than writing into it. Everything else — spans, layout, history, meta — is copied, so the two pages diverge cleanly.

deep_spans
copy each TextSpan rather than sharing the objects. True is right whenever the copy will be read or normalised, since span mutation would otherwise reach back into the original.
returns
the new page

Page.to_dict

method source
to_dict(self, include_spans: bool = True) -> Dict[str, Any]

JSON-ready dict.

include_spans=False keeps the structure and the measurements but drops the text, which is what the eval harness stores for a fixture.

include_spans
keep the per-span text and geometry
returns
a JSON-serialisable dict. The raster is never included — see from_dict.

Page.from_dict

classmethod source
from_dict(cls, d: Mapping[str, Any]) -> Page

Inverse of to_dict. The raster is not restored.

Pixels are deliberately not serialised — a JSON document would be enormous — so a restored page has spans, geometry and history but has_raster is False. Re-ingest the source to read it again.

d
a mapping as produced by to_dict
returns
the reconstructed page
raises KeyError
index is missing; every other key has a default

Document

classdataclass public API source

An ordered collection of pages plus provenance about where it came from.

Fields

source_uri: str = ''
pages: List[Page] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)

Methods

Document.__len__

method source
__len__(self) -> int

Number of pages.

Document.__iter__

method source
__iter__(self) -> Iterator[Page]

Iterate over pages in order.

Document.__getitem__

method source
__getitem__(self, i: int) -> Page

Page by list position (see page for lookup by page index).

Position and page index diverge the moment pages are filtered or split, which is why both accessors exist.

i
list position, negative indexing allowed
returns
the page at that position
raises IndexError
out of range

Document.page

method source
page(self, index: int) -> Page

Page by its index attribute (not its list position).

The accessor to use when resolving provenance: every bbox.page is a page index, not a position, and the two differ after select.

index
the page's own index attribute
returns
the matching page
raises KeyError
no page carries that index

Document.text

method source
text(self, separator: str = "\n\n") -> str

Reading-order text of every non-empty page, joined by separator.

separator
placed between pages. The default blank line reads naturally; use format_document_text instead when the text is headed for a prompt, since that one adds page markers.
returns
the document's text. Empty for a document that has been ingested but not read.

Document.spans

method source
spans(self) -> List[TextSpan]

Every span in the document, page by page, in order.

Document.char_count

property source
char_count(self) -> int

Total non-whitespace characters in the document.

Document.kinds

method source
kinds(self) -> Dict[str, int]

Count of pages per PageKind, e.g. {"scanned": 12}.

Document.warn

method source
warn(self, message: str) -> Document

Record a de-duplicated warning and log it. Returns self.

Warnings survive on the Document rather than being raised because a bad page is not a bad document: the caller usually wants the other 40 pages.

message
what went wrong. Duplicates are dropped, so a warning raised once per page on a 300-page bundle appears once.
returns
self, so calls chain

Document.copy

method source
copy(self) -> Document

Deep-enough copy: pages are copied, rasters are shared.

Document.release_rasters

method source
release_rasters(self) -> Document

Free every materialised raster that can be re-rendered on demand.

Document.select

method source
select(self, predicate: Callable[[Page], bool]) -> Document

A new Document containing only pages satisfying predicate.

Page index attributes are preserved, not renumbered, so provenance still resolves through page. Use merge_documents when you do want contiguous renumbering.

predicate
Callable[[Page], bool], e.g. lambda p: not p.is_blank or lambda p: p.quality.score > 0.5
returns
a new document sharing the surviving page objects

Document.to_dict

method source
to_dict(self, include_spans: bool = True) -> Dict[str, Any]

JSON-ready dict; include_spans=False drops the text.

include_spans
keep per-page spans. False yields a compact structural summary — geometry, quality, history — which is what you want for a manifest or a diff between two runs.
returns
a JSON-serialisable dict. Rasters are never included.

Document.from_dict

classmethod source
from_dict(cls, d: Mapping[str, Any]) -> Document

Inverse of to_dict. Rasters are not restored.

d
a mapping as produced by to_dict
returns
the reconstructed document, on which every page reports a has_raster of False.

Document.save_json

method source
save_json(self, path: str, include_spans: bool = True) -> str

Write to_dict to path as UTF-8 JSON. Returns path.

path
destination file; overwritten if it exists
include_spans
keep per-page spans, as to_dict
returns
path, so it can be used inline

Document.load_json

classmethod source
load_json(cls, path: str) -> Document

Read a document back from a file written by save_json.

path
a file written by save_json
returns
the reconstructed document, without rasters
raises OSError
the file could not be read
raises ValueError
the file is not valid JSON

Cost

classdataclass public API source

Money and tokens spent. Adds like a number so it can be accumulated.

Fields

currency: str = 'USD'
amount: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
calls: int = 0
wasted_calls: int = 0
attempts that failed after the provider billed us

Methods

Cost.__add__

method source
__add__(self, other: Cost) -> Cost

Sum two costs.

Mixing currencies raises ConfigError — unless one side is zero, which is what makes sum(costs) (starting from 0) work.

other
the cost to add
returns
a new Cost with amounts, tokens, calls and wasted calls summed. NotImplemented for a non-Cost, so Python falls back to the reflected operand.
raises ConfigError
both sides are non-zero and in different currencies. No conversion is attempted — a guessed exchange rate is exactly the kind of confidently wrong number this library refuses to produce.

Cost.zero

classmethod source
zero(cls, currency: str = "USD") -> Cost

An empty cost in currency — the identity for addition.

currency
the currency label; no conversion is ever performed, so it must match the units of your registered prices
returns
a zero cost, safe to use as an accumulator seed

Cost.total_tokens

property source
total_tokens(self) -> int

Input plus output tokens.

Cost.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict; the amount keeps 6 decimals (sub-cent per page).

4. Image primitives

Every raster helper here has an OpenCV path and, where it is honestly feasible, a pure-NumPy fallback. That is not gold-plating: OpenCV wheels are awkward in some locked-down deployment targets, and a library that hard-fails without it cannot be used at all there. The fallbacks are slower and occasionally slightly different numerically; they are not toys, and the test suite runs the ops both ways.

Array convention: uint8, (H, W) grayscale or (H, W, 3) RGB. RGB, not BGR — the OpenCV boundary is crossed inside these helpers, never outside them.

is_gray

func source
is_gray(img: ImageArray) -> bool

True for a single-channel (H, W) array.

img
an array, or anything without ndim (which reports False)
returns
whether the array is two-dimensional

is_color

func source
is_color(img: ImageArray) -> bool

True for a multi-channel (H, W, C>=3) array.

img
an array, or anything without ndim (which reports False)
returns
whether the array is three-dimensional with at least 3 channels. A single-channel (H, W, 1) array is not colour by this test.

image_shape

func source
image_shape(img: ImageArray) -> Tuple[int, int]

(height, width) in pixels.

Note the order: NumPy's, not the (width, height) that resize_image takes. Mixing the two silently transposes pages.

img
a 2D or 3D array
returns
(height, width), ignoring any channel dimension

_integral

func source
_integral(arr: ImageArray) -> FloatArray

Summed-area table with a zero row/column, shape (H+1, W+1).

_rect_box_sum

func source
_rect_box_sum(arr: ImageArray, kh: int, kw: int) -> Tuple[FloatArray, FloatArray]

(window_sums, window_counts) over a kh x kw rectangle, clamped.

Image

class public API source

Raster primitives, each with an OpenCV path and a NumPy fallback.

Array convention: uint8, (H, W) greyscale or (H, W, 3) RGB — RGB, not BGR. The OpenCV boundary is crossed inside these helpers and never outside them, which is what makes set_opencv_enabled a single switch rather than a hunt.

These take arrays and return arrays; they know nothing about a Page. Pixel work that corrects a measured degradation is an op instead — see Ops.

Methods

Image.ensure_uint8

staticmethod source
ensure_uint8(img: ImageArray) -> ImageArray

Coerce any numeric array to uint8, scaling float [0,1] images by 255.

The guard at the top of every image primitive, so ops need not care whether an upstream step handed back floats or booleans.

img
any numeric array — uint8 passes straight through, floats peaking at or below 1.0 are scaled by 255, booleans map to 0/255, and anything else is clipped into 0..255
returns
a uint8 array of the same shape

Image.to_gray

staticmethod source
to_gray(img: ImageArray) -> GrayImage

Grayscale view of img using ITU-R 601 luma weights.

img
a greyscale or colour array. Greyscale input is returned unchanged, so this is safe to call unconditionally; an alpha channel is dropped.
returns
a single-channel uint8 array

Image.to_rgb

staticmethod source
to_rgb(img: ImageArray) -> ImageArray

3-channel RGB view of img.

img
a greyscale or colour array. Greyscale is replicated across three channels; a 4-channel image loses its alpha.
returns
an (H, W, 3) uint8 array

Image.window_stats

staticmethod source
window_stats(gray: GrayImage, window: int) -> Tuple[FloatArray, FloatArray]

Local (mean, std) over a window x window neighbourhood.

Uses summed-area tables, so cost is independent of window size — which is what makes Sauvola-family binarisation practical at 300 DPI. Windows are clipped at the borders and the divisor tracks the clipped area, so edge pixels are not biased toward black the way zero-padding would make them.

gray
a single-channel array
window
neighbourhood side in pixels. Cost does not grow with it, so pick it by what the page needs — a little taller than one text line for binarisation.
returns
(mean, std), both float arrays the same shape as gray

Image.box_blur

staticmethod source
box_blur(img: ImageArray, radius: int) -> ImageArray

Mean filter of radius radius (integral-image based in the fallback).

img
greyscale or colour; colour is filtered per channel
radius
half-width in pixels, so the kernel is 2 * radius + 1 square. Below 1 the image is returned unchanged.
returns
the blurred uint8 array

Image.gaussian_blur

staticmethod source
gaussian_blur(img: ImageArray, sigma: float) -> ImageArray

Gaussian blur. The fallback is three variance-matched box blurs.

The fallback still loses a little tail mass, because each pass rounds back to uint8, so its effective sigma runs slightly under the request at large radii. Measured against measure_blur the two paths agree to within about 0.02 over sigma 0.8-4.0, which is what the tests assert.

img
greyscale or colour
sigma
standard deviation in pixels. Zero or negative returns the image unchanged. The kernel radius is derived from it, so cost grows with sigma on the OpenCV path and stays flat on the fallback.
returns
the blurred uint8 array

Image.median_blur

staticmethod source
median_blur(img: ImageArray, ksize: int = 3) -> ImageArray

Median filter — the right tool for salt-and-pepper scanner speckle.

Unlike a mean filter it removes isolated specks without softening the stroke edges around them, which is why denoising uses it rather than a blur.

img
greyscale or colour; colour is filtered per channel
ksize
kernel side in pixels, forced odd. 3 removes single speckles; 5 handles heavier fax noise and starts to round off the corners of glyphs.
returns
the filtered uint8 array

Image.resize_image

staticmethod source
resize_image(img: ImageArray, size: Optional[Tuple[int, int]] = None,
                 scale: Optional[float] = None, interpolation: str = "auto") -> ImageArray

Resize to size=(width, height) or by scale.

interpolation="auto" picks cubic when upscaling (preserves stroke edges that OCR character models rely on) and area when downscaling (avoids the aliasing that makes fine print look like noise).

img
greyscale or colour
size
target (width, height) in pixels — note this is the opposite order to image_shape. Takes precedence over scale.
scale
uniform multiplier, used when size is None. A scale of 1.0 returns the image unchanged.
interpolation
"auto" (cubic up, area down), or one of "nearest", "linear", "cubic", "area". Use "nearest" on an already-binarised page, where interpolation would reintroduce grey.
returns
the resized uint8 array

Image.rotate_image

staticmethod source
rotate_image(img: ImageArray, angle_deg: float, border_value: Optional[int] = None,
                 expand: bool = True) -> ImageArray

Rotate counter-clockwise by angle_deg about the image centre.

expand=True grows the canvas so no content is clipped — deskewing a page and losing the top-right corner of the letterhead is a real failure mode of the naive implementation. border_value defaults to the image's median, which keeps a white page white and a dark scan dark rather than stamping black wedges that later confuse ink-coverage measurement.

img
greyscale or colour
angle_deg
counter-clockwise rotation in degrees. Angles under 1e-4 return the image unchanged.
border_value
fill level for exposed corners, 0..255. None uses the image's median, which is what keeps a white page white and a dark scan dark.
expand
grow the canvas so nothing is clipped. Leave it True for deskewing — False keeps the original dimensions and cuts the corners off, which loses letterheads.
returns
the rotated uint8 array, larger than the input when expand is set

Image.row_projection_at_angle

staticmethod source
row_projection_at_angle(gray: GrayImage, angle_deg: float) -> FloatArray

Horizontal projection profile as if the image were rotated by angle_deg.

Note carefully which axis is sheared. Shearing rows horizontally — the obvious reading of "shear to simulate rotation" — leaves every row sum unchanged, so a skew search built on it measures nothing but border artefacts. The columns must be shifted vertically instead, so that content from a tilted text line lands in the same output row.

For angles under ~15 degrees a shear and a rotation give indistinguishable profiles, and the shear costs one integer fancy-index instead of a full resampling pass.

gray
a single-channel array
angle_deg
the tilt to simulate, in degrees. Accurate for small angles only — beyond about 15 degrees a shear stops approximating a rotation, which is why the skew search is bounded there.
returns
the per-row ink sums as a float array, whose variance peaks at the angle that makes text lines horizontal

Image.otsu_threshold

staticmethod source
otsu_threshold(gray: GrayImage) -> int

Otsu's global threshold via between-class variance maximisation.

gray
a greyscale or colour array; colour is converted first
returns
the threshold as an integer in 0..255. Global, so it is exact on an evenly-lit page and badly wrong under a shadow gradient — which is what ink_mask detects and works around.

Image.ink_mask

staticmethod source
ink_mask(gray: GrayImage, threshold: Optional[int] = None,
             adaptive: Any = "auto") -> GrayImage

Boolean mask of "ink" pixels (dark on light).

Otsu is the right default: it is cheap, global, and exact on an evenly-lit page. It is also catastrophically wrong on an unevenly-lit one — under a shadow gradient the darker paper falls below the global threshold, so a third of the page is classified as ink. Everything built on this mask then fails together: ink coverage reads 40%, the skew search locks onto the shadow boundary instead of the text lines, and stroke width becomes meaningless. Shadowed phone photographs are a primary input here, so the mask checks for that case and switches to a local (Sauvola) threshold, which is immune to it.

gray
a greyscale or colour array
threshold
force a fixed grey level, 0..255; pixels at or below it are ink. None chooses a threshold automatically, which is almost always what you want.
adaptive
"auto" (default) uses a local Sauvola threshold only when the page looks unevenly lit; True always does; False always uses global Otsu. Ignored when threshold is given.
returns
a boolean mask, True where there is ink

Image.gradient_magnitude

staticmethod source
gradient_magnitude(gray: GrayImage) -> FloatArray

Sobel gradient magnitude as float64. Used by the sharpness metric.

gray
a greyscale or colour array; colour is converted first
returns
a float array of edge strengths, the same shape as the input. Unnormalised, so compare it against itself rather than an absolute number.

Image.morph_binary

staticmethod source
morph_binary(mask: GrayImage, kh: int, kw: int, operation: str = "erode") -> GrayImage

Binary morphology with a rectangular structuring element.

Implemented with summed-area tables so that a 1x120 kernel (what line removal needs) costs the same as a 3x3 one.

mask
a boolean or 0/1 array
kh
kernel height in pixels
kw
kernel width in pixels. Asymmetry is the point: (1, 120) finds horizontal rules and ignores everything else.
operation
"erode", "dilate", "open" (erode then dilate, removing specks) or "close" (dilate then erode, filling gaps). Named from the mask's point of view, so "dilate" grows the True region.
returns
the transformed boolean mask

Image.estimate_background

staticmethod source
estimate_background(gray: GrayImage, radius: Optional[int] = None) -> GrayImage

Estimate the page background (paper + lighting) with the text erased.

The kernel has to be larger than the tallest glyph, or the middle of a text line survives as background and the correction punches a hole through it. Sized at 1/12 of the shorter side and clamped: big enough for body text at any sane DPI, small enough to still follow a shadow gradient.

gray
a greyscale or colour array
radius
kernel size in pixels, forced odd, minimum 3. None derives one from the page size, which is right unless the text is unusually large — a kernel narrower than a glyph is tall leaves text in the background estimate.
returns
the estimated background as a uint8 array, the same shape as the input. Divide the page by it to flatten lighting, which is what normalize_illumination does.

Image.encode_png

staticmethod source
encode_png(img: ImageArray) -> bytes

Encode an array as PNG bytes — what vision backends actually send.

Lossless, so prefer it for archival and for debugging output. For a model call use encode_jpeg, which is several times smaller at a quality the page cannot tell apart.

img
greyscale or colour
returns
the PNG bytes
raises MissingDependency
neither OpenCV nor Pillow is importable

Image.encode_jpeg

staticmethod source
encode_jpeg(img: ImageArray, quality: int = 88) -> bytes

Encode as JPEG. Preferred for VLM calls: a 300 DPI page as PNG is several megabytes, and the artefacts JPEG adds at q>=85 are well below what the scanner already introduced.

img
greyscale or colour; greyscale is expanded to RGB
quality
JPEG quality in 1..100. 88 is the default and sits above the point where compression artefacts become visible to a recogniser; below 75 ringing around glyph edges starts to cost accuracy.
returns
the JPEG bytes
raises MissingDependency
neither OpenCV nor Pillow is importable

Image.decode_image

staticmethod source
decode_image(data: bytes) -> ImageArray

Decode image bytes to a uint8 RGB/gray array.

Raises IngestError — not a Pillow or OpenCV exception — when the bytes are not a decodable image. Callers batch-processing a directory or walking email attachments need one exception type to catch, or a single corrupt scan takes down the run.

data
the encoded bytes — PNG, JPEG, BMP, GIF or WebP. Format is detected from content, so no filename is needed.
returns
a uint8 array, greyscale (H, W) or RGB (H, W, 3)
raises IngestError
the bytes are empty or not a decodable image
raises MissingDependency
neither OpenCV nor Pillow is importable

Image.save_image

staticmethod source
save_image(img: ImageArray, path: str) -> str

Write an array to disk, choosing the encoder from the extension.

img
greyscale or colour
path
destination. .jpg/.jpeg encode as JPEG; every other extension, including none, encodes as PNG.
returns
path, so it can be used inline
raises MissingDependency
neither OpenCV nor Pillow is importable
raises OSError
the file could not be written

_box_widths_for_gaussian

func source
_box_widths_for_gaussian(sigma: float, passes: int = 3) -> List[int]

Odd box-filter widths whose repeated application matches sigma.

Three successive box filters converge on a Gaussian by the central limit theorem, but only if their widths are chosen so the variances add up to the target. A box of width w has variance (w^2 - 1) / 12; picking the radius by eye (say 1.5 * sigma) overshoots by about a factor of two, which quietly makes every fallback blur far heavier than the OpenCV path it is standing in for.

This is the standard mixed-width construction: use the largest odd width that undershoots for m of the passes and the next odd width up for the rest, choosing m so the total variance lands on 12 * sigma^2.

_UNEVEN_ILLUMINATION

const source
_UNEVEN_ILLUMINATION = 0.55

Below this illumination score a global threshold is unsafe — see `ink_mask`.

_percentile_np

func source
_percentile_np(arr: ImageArray, q: float) -> float

q-th percentile of an array, via NumPy when it is loaded.

5. Page quality measurement

Preprocessing is channel equalisation, so it must be driven by measurement. Every metric below is normalised into [0, 1] (except skew, in degrees) so that thresholds are portable across scanners and DPIs, and so the same numbers can feed both the preprocessing policy and the confidence prior.

QualityThresholds

classdataclass public API source

Tunable decision boundaries for quality verdicts and policy.

These are starting points from scanned Indian hospital bills and court filings, not universal constants. Recalibrate them against a labelled set (Layer 5) per document class; that is exactly the kind of finding that should ship in a version bump instead of living in one person's notebook.

Fields

blur_floor: float = 0.35
below this a page is visibly soft
blur_unreadable: float = 0.08
contrast_floor: float = 0.3
contrast_unreadable: float = 0.1
min_dpi: int = 300
dpi_unreadable: int = 120
skew_correct_deg: float = 0.4
worth correcting above this
skew_max_deg: float = 15.0
beyond this it is a rotation, not skew
noise_ceiling: float = 0.35
illumination_floor: float = 0.55
ink_blank: float = 0.002
below this the page carries no marks
ink_saturated: float = 0.6
above this it is inverted or all-black

DEFAULT_THRESHOLDS

const public API source
DEFAULT_THRESHOLDS = QualityThresholds()

Quality

class public API source

Page quality measurement — the evidence preprocessing acts on.

Every measure is deliberately cheap and scale-aware, because the point is to decide what to do to a page, not to score it for its own sake. A measure that cost as much as the OCR it is protecting would be worse than no measure at all.

Methods

Quality.measure_blur

staticmethod source
measure_blur(gray: GrayImage) -> float

Sharpness in [0, 1], from edge-transition width.

Variance of the Laplacian is the textbook answer and it is the wrong one here. It rises with noise, so a grainy scan scores sharper than a clean one, and it falls on sparse pages, so a crisp page with little ink scores blurry. Both are everyday cases in scanned bills.

Two independent sub-scores are computed instead, and the **worse of the two wins**, because each covers the other's blind spot:

Concentration — the fraction of edge pixels carrying strong gradient. A sharp edge puts its whole intensity step into one or two pixels; blurring spreads it over many pixels of medium gradient. Being a ratio it is immune to contrast and to noise, and it correctly catches a low-resolution page that was upsampled. It saturates under extreme blur, where the gradient field becomes smooth and self-similar.

Steepness — peak gradient divided by the ink-to-paper step, i.e. the reciprocal of the edge's transition width in pixels. It degrades cleanly all the way into heavy blur, but it is fooled by noise (which supplies spurious steep gradients) and by upsampling.

The result is contrast-invariant by construction: a faded page and a dark one score the same, which is correct, because fading is measure_contrast's job to report, not this one's.

Roughly: crisp print ~0.9, Gaussian sigma=1 ~0.6, sigma=2 ~0.45, sigma=4 ~0.3, sigma=8 ~0.05, a 120 DPI scan upsampled to 300 ~0.18.

gray
the page as a greyscale array; colour input is converted
returns
sharpness in 0.0..1.0; 1.0 is crisp. Compare against QualityThresholds.blur_floor (degraded) and blur_unreadable.

Quality.measure_contrast

staticmethod source
measure_contrast(gray: GrayImage) -> float

Contrast in [0, 1] — specifically, ink-to-paper separation.

A generic 5th-95th percentile spread is wrong for documents: a perfectly crisp page that is 97% white paper has both percentiles sitting at 255 and reports zero contrast. What actually matters to a recogniser is how far the ink sits from the paper, so we split on Otsu and measure the gap between the two populations, using inner percentiles on each side so that antialiased edge pixels do not flatter the result.

Falls back to the percentile spread when the page has too little (or too much) ink for the split to mean anything.

gray
the page as a greyscale array; colour input is converted
returns
ink-to-paper separation in 0.0..1.0. A near-blank page reports high contrast rather than zero, since there is no ink whose separation could be poor.

Quality.measure_ink_coverage

staticmethod source
measure_ink_coverage(gray: GrayImage) -> float

Fraction of pixels that are ink, using an illumination-aware mask.

gray
the page as a greyscale array; colour input is converted
returns
inked fraction in 0.0..1.0. Body text lands near 0.05, dense Devanagari near 0.25; above 0.4 suggests a negative scan — see invert_if_dark.

Quality.measure_noise

staticmethod source
measure_noise(gray: GrayImage) -> float

Speckle estimate in [0, 1].

The high-frequency residual (image minus its median-filtered self) contains both text edges and noise. Text edges are sparse and large; sensor noise is dense and small, so the median absolute residual tracks noise while being largely blind to text.

gray
the page as a greyscale array; colour input is converted
returns
speckle in 0.0..1.0; 0.0 is clean. Above QualityThresholds.noise_ceiling the page is treated as degraded and denoise is reached for.

Quality.measure_illumination

staticmethod source
measure_illumination(gray: GrayImage) -> float

Lighting evenness in [0, 1]; 1 is flat, 0 is a hard shadow gradient.

The page background is estimated by heavy downsampling (which averages text away) and its spread measured. This is what catches phone photos with a shadow across one half — the exact case where global binarisation destroys a third of the page.

gray
the page as a greyscale array; colour input is converted
returns
evenness in 0.0..1.0; 1.0 is perfectly flat lighting and 0.0 a hard shadow gradient. Below the QualityThresholds.illumination_floor cut-off, the op normalize_illumination is applied.

Quality.estimate_skew

staticmethod source
estimate_skew(gray: GrayImage, max_angle: float = 15.0, method: str = "auto",
                  coarse_step: float = 1.0, fine_step: float = 0.1,
                  min_improvement: float = 0.15) -> float

Estimate the page's skew in degrees (positive = content tilted clockwise).

method:

projection
Coarse-to-fine search maximising the variance of the horizontal projection profile. Text lines align into sharp peaks only at the correct angle. Robust on sparse pages, needs no OpenCV, and is the default because it degrades gracefully on forms and tables.
hough
Probabilistic Hough transform over Canny edges, taking the median angle of near-horizontal segments. Excellent when ruled lines exist.
minarearect
Minimum-area rectangle of all ink pixels. Fast, but fooled by a single stray margin mark, so it is only used inside auto's median.
auto
Median of whichever of the above are available — a cheap consensus that avoids each method's individual failure mode.

min_improvement is how much better than the straight-page hypothesis a candidate angle must score before it is believed; see by_projection.

Returns 0.0 when the page has too little ink to judge, or when no angle is convincingly better than leaving the page alone.

gray
the page as a greyscale array; colour input is converted
max_angle
widest tilt to search, in degrees either way. Beyond 15 a page is not skewed but rotated — a different problem, and searching that far mostly finds table rules.
method
"auto", "projection", "hough", or "minarearect", each described above. "auto" takes the median of whichever are available.
coarse_step
degrees per step in the first projection pass. 1.0 is ample; smaller only costs time, since the fine pass refines whatever the coarse pass found.
fine_step
degrees per step in the refinement pass. 0.1 is below what deskewing can act on anyway — deskew declines under 0.4 degrees.
min_improvement
how much better than the straight-page hypothesis a candidate must score to be believed, as a relative margin. 0.15 keeps a page that is genuinely straight from being rotated by noise.
returns
the skew in degrees, positive meaning content tilted clockwise; 0.0 when the page is straight, nearly blank, or ink-saturated

Quality.estimate_stroke_width

staticmethod source
estimate_stroke_width(gray: GrayImage) -> float

Mean ink stroke thickness in pixels, from the area-to-perimeter ratio.

For an elongated shape, 2 * area / perimeter is its width. Cheap and exact on clean print — but it inflates under blur, because a soft edge puts the Otsu threshold outside the true stroke and the halo is counted as ink. Callers must not use it as a resolution estimate on a soft page; see estimate_line_pitch.

gray
the page as a greyscale array; colour input is converted
returns
mean stroke thickness in pixels, or 0.0 when the page has too little ink. Roughly 3 px for body text at 300 DPI — but it inflates under blur, so prefer line pitch for resolution.

Quality.estimate_line_pitch

staticmethod source
estimate_line_pitch(gray: GrayImage, min_pitch: int = 8, max_pitch: int = 200) -> float

Dominant text-line spacing in pixels, via autocorrelation, or 0.0.

Blur, fading and threshold choice all move stroke width around; none of them move the distance between one baseline and the next. That makes line pitch the sturdiest resolution cue a page offers, and it is what estimate_effective_dpi prefers.

Returns 0.0 when the projection has no convincing periodicity — a title page, a photograph, or a form with irregular row heights.

gray
the page as a greyscale array
min_pitch
shortest spacing considered, in pixels. 8 is below body text at any usable resolution; raising it rejects the harmonics that dense text can produce.
max_pitch
longest spacing considered, in pixels. 200 covers double-spaced large print at 600 DPI.
returns
the dominant line spacing in pixels, or 0.0 when the page has too little ink or no convincing periodicity

Quality.estimate_effective_dpi

staticmethod source
estimate_effective_dpi(gray: GrayImage, fallback: int = 0) -> int

Estimate the content resolution of a page image.

A page can be rendered at 600 DPI and still carry only 120 DPI of signal, because it was scanned once at 120 and upsampled by three tools since. Rendering DPI is therefore not resolution, and this is the number that should drive ensure_dpi and the confidence prior.

Two independent cues are used. Line pitch is preferred: it survives blur, fading and threshold choice, all of which distort stroke width. Stroke width is the fallback for pages with no regular text lines, and the two are averaged when they roughly agree, which tightens the estimate on ordinary prose.

Returns fallback when the page carries too little ink to judge.

gray
the page as a greyscale array
fallback
returned when neither cue yields an estimate. Pass the page's nominal raster_dpi so callers always get a usable number.
returns
estimated content DPI, clamped to 30..1200. Compare it against page.raster_dpi: a large gap means the page was upsampled somewhere and carries less signal than its size suggests.

Quality.classify_quality

staticmethod source
classify_quality(q: PageQuality,
                     thresholds: Optional[QualityThresholds] = None) -> Verdict

Turn measurements into clean / degraded / unreadable.

unreadable is a routing signal, not a discard: those pages get the vision model and a confidence penalty rather than being dropped, because a human reviewer would rather see a low-confidence guess than a blank.

q
the measured PageQuality
thresholds
cut-offs to compare against. None uses the defaults in DEFAULT_THRESHOLDS
returns
a Verdict. A near-blank page reports CLEAN rather than UNREADABLE — there is nothing on it to misread, and calling it unreadable would route empty pages to the expensive backend.

Quality.measure_quality

staticmethod source
measure_quality(img: ImageArray, raster_dpi: int = 0,
                    thresholds: Optional[QualityThresholds] = None,
                    skew_method: str = "auto", max_skew: float = 15.0) -> PageQuality

Measure every channel-degradation signal for one page image.

The measurement half of adaptive preprocessing: policies read the result and reach only for the ops a page's actual degradation warrants.

img
the page raster, greyscale or colour
raster_dpi
the resolution the image was rendered at, recorded on the result and used as the fallback for effective_dpi. 0 means unknown.
thresholds
cut-offs for the verdict. None uses the defaults in DEFAULT_THRESHOLDS
skew_method
passed to estimate_skew"auto", "projection", "hough", or "minarearect"
max_skew
widest skew to search, in degrees
returns
a PageQuality carrying blur, skew, contrast, ink coverage, noise, illumination, effective DPI and an overall verdict

Quality.measure_page

staticmethod source
measure_page(page: Page, dpi: Optional[int] = None,
                 thresholds: Optional[QualityThresholds] = None, **kwargs: Any) -> Page

Measure page in place and return it. No-op for pages without rasters.

page
the page to measure; page.quality is replaced and the measurement recorded in page.history
dpi
render the raster at this DPI for measuring; None uses whatever the page already has
thresholds
cut-offs for the verdict. None uses the defaults in DEFAULT_THRESHOLDS
kwargs
forwarded to measure_qualityskew_method, max_skew
returns
the same page, mutated. A page with no raster is returned untouched, its quality left at defaults.

Quality.measure_document

staticmethod source
measure_document(doc: Document, dpi: Optional[int] = None,
                     thresholds: Optional[QualityThresholds] = None, max_workers: int = 0,
                     **kwargs: Any) -> Document

Measure every page's quality. Returns the same Document, mutated.

doc
the document to measure, page by page
dpi
render DPI for measuring; None uses each page's own
thresholds
cut-offs for the verdict. None uses the defaults in DEFAULT_THRESHOLDS
max_workers
threads to measure with. 0 runs serially.
kwargs
forwarded to measure_quality
returns
the same document, mutated. preprocess does this for you — call it directly to inspect quality without changing pixels, which is what docpipe quality does.

_INCHES_PER_TEXT_LINE

const source
_INCHES_PER_TEXT_LINE = 1.0 / 6.0

Body text is typically set with ~1/6 inch of leading (12pt on 10pt type), which is what converts a measured line pitch into a resolution.

_STROKE_PX_AT_300

const source
_STROKE_PX_AT_300 = 3.2

Body text strokes run ~3.2px wide at 300 DPI.

6. Layer 0 — ingest and normalisation

Ingest's job is to produce a Document whose pages know their geometry, know whether they carry a trustworthy text layer, and can render themselves on demand. It does not rasterise anything: a 300-page bundle must survive ingest in a few megabytes of RAM.

_MAGIC

const source
_MAGIC = [(b'%PDF-', 'pdf'), (b'\x89PNG\r\n\x1a\n', 'png'), (b'\xff\xd8\xff', 'jpeg'), (b'II*\x00...

Magic-byte signatures. Extensions lie — especially on email attachments, where a JPEG named scan.pdf is a weekly occurrence.

MIN_NATIVE_CHARS

const public API source
MIN_NATIVE_CHARS = 48

A page needs at least this many extracted characters before we will believe its text layer. Scanned pages routinely carry a handful of characters from a header stamp or a producer watermark; trusting those and skipping OCR is the single most damaging false positive in this whole library.

Ingest

class public API source

Layer 0 — bytes of unknown provenance to a Document.

Ingest never rasterises eagerly. Pages carry a lazy raster() provider instead, because a 300-page bundle at 400 DPI rendered up front is tens of gigabytes for a job that may only need page 2.

Methods

Ingest.sniff_format

staticmethod source
sniff_format(data: bytes, filename: str = "") -> str

Identify a document format from its bytes, falling back to its name.

Content first, extension last, because in practice the extension lies: scanners emit .pdf files that are really TIFFs, and mail gateways strip extensions entirely.

data
the leading bytes of the file; the first 2 KB are enough
filename
used only for its extension, and only when the magic bytes were inconclusive
returns
one of "pdf", "png", "jpeg", "tiff", "bmp", "gif", "webp", "eml", or "unknown". A .msg file reports "eml", since it takes the same path.

Ingest.classify_page_kind

staticmethod source
classify_page_kind(char_count: int, image_area_ratio: float, has_images: bool,
                       min_chars: int = MIN_NATIVE_CHARS,
                       ink_hint: Optional[float] = None) -> PageKind

Decide how a page stores its text, from cheap PDF-level evidence.

image_area_ratio is the fraction of the page covered by embedded raster images. The interesting case is HYBRID: a native-text page with a scanned insert pasted in (a photographed bill stapled into a typed claim form). Those pages need both paths, and treating them as native silently loses the insert — which is usually where the numbers are.

char_count
characters the PDF's text layer reports for this page
image_area_ratio
fraction of the page covered by embedded raster images, in 0.0..1.0. Above 0.35 alongside real text means HYBRID — a scanned insert on a typed form.
has_images
whether the page embeds any raster image at all
min_chars
characters below which the text layer is not trusted. Defaults to MIN_NATIVE_CHARS. Scanned pages routinely carry a handful of stray characters from a header stamp, so the floor cannot be 1.
ink_hint
measured ink coverage in 0.0..1.0, when known. Lets a page with no text and no embedded image still be called SCANNED rather than BLANK, which is the case for a page whose content is one large drawing.
returns
the PageKindDIGITAL_NATIVE, HYBRID, SCANNED, or BLANK. This drives routing, as decided by default_router.

Ingest.ingest_pdf

staticmethod source
ingest_pdf(source: Source, password: Optional[str] = None, max_pages: int = 0,
               page_range: Optional[Sequence[int]] = None, render_dpi: int = DEFAULT_DPI,
               extract_text: bool = True,
               min_native_chars: int = MIN_NATIVE_CHARS) -> Document

Open a PDF, read any native text layer, and attach lazy page renderers.

Handles the three things that break naive PDF ingest in production: encrypted-but-openable files (empty owner password), files with leading junk before %PDF-, and structurally damaged files that PyMuPDF can repair on a second pass.

source
path, path-like, raw bytes, or an open binary file
password
for an encrypted PDF. Files encrypted with an empty owner password — the common case for bank and hospital statements — open without this.
max_pages
stop after this many pages; 0 means all of them. Applied after page_range.
page_range
zero-based page indices to ingest, e.g. range(10, 20) or [0, 5, 9]. None takes every page.
render_dpi
resolution for the lazy page renderers. 300 is the floor for reliable OCR, 400 helps on small print. Nothing is rendered until a page's raster is actually asked for — a 300-page bundle at 400 DPI would be tens of gigabytes eagerly.
extract_text
read the embedded text layer. Leave it on: it is nearly free, and it is what lets default_router route a digital page to the exact, free "pymupdf" read.
min_native_chars
per-page character floor for trusting that text layer; passed to classify_page_kind.
returns
a Document whose pages carry native spans where they exist and a lazy raster provider throughout
raises IngestError
the file could not be opened even after a repair pass, or the password was wrong

Ingest.ingest_image

staticmethod source
ingest_image(source: Source, dpi: Optional[int] = None,
                 page_index: int = 0) -> Document

Ingest a single-page image (PNG/JPEG/BMP/WebP) as a one-page Document.

source
path, path-like, raw bytes, or an open binary file
dpi
the image's true resolution. None reads it from the file's metadata and falls back to DEFAULT_IMAGE_DPI. Worth passing explicitly for phone photos, whose EXIF DPI is meaningless — every DPI-relative threshold downstream depends on this number.
page_index
the index this page takes in the document, for assembling a multi-page document from separate image files
returns
a one-page Document with the raster already attached
raises IngestError
the bytes could not be decoded as an image

Ingest.ingest_tiff

staticmethod source
ingest_tiff(source: Source, dpi: Optional[int] = None) -> Document

Ingest a (possibly multi-page) TIFF. Fax archives are full of these.

Needs Pillow for multi-page files; without it, only the first frame is read, via ingest_image.

source
path, path-like, raw bytes, or an open binary file
dpi
true resolution; None reads the TIFF tag and falls back to DEFAULT_IMAGE_DPI. Fax TIFFs commonly declare 204x98, which is anisotropic and worth overriding.
returns
a Document with one page per frame
raises IngestError
the file could not be opened as a TIFF

Ingest.ingest_email

staticmethod source
ingest_email(source: Source, dpi: Optional[int] = None, include_body: bool = True,
                 max_attachments: int = 50) -> Document

Ingest an .eml message: every readable attachment becomes pages.

Inbound claim registration lives on this path. Attachments arrive as native PDFs, scans, and images pasted into the body, all in one message, and each part has to be routed on its actual bytes rather than its filename. Pages carry meta['attachment'] so a downstream reviewer can be told which attachment a field came from.

source
path, path-like, raw bytes, or an open binary file holding an RFC 822 message
dpi
passed to each image attachment's ingest; None detects per attachment
include_body
turn the message body into a leading text page. Useful — claim references and policy numbers are often typed in the covering mail rather than attached. That page carries no raster.
max_attachments
ceiling on attachments processed, guarding against a mail bomb. Attachments past the limit are noted in the document's warnings rather than silently dropped.
returns
a Document whose pages span every readable attachment, in message order, each tagged with meta["attachment"]. An attachment that cannot be read is recorded as a warning and skipped, so one corrupt part does not cost the message.

Ingest.page_from_image

staticmethod source
page_from_image(img: ImageArray, index: int = 0, dpi: int = DEFAULT_IMAGE_DPI) -> Page

Public helper: build a Page from an in-memory array.

The entry point for pages that never came from a file — a frame grabbed from a scanner SDK, a crop produced upstream, a synthetic test page.

img
a NumPy array, greyscale or RGB. Converted to uint8.
index
the page's index within its document, which every span's bbox.page refers back to
dpi
the array's true resolution. Defaults to the constant DEFAULT_IMAGE_DPI. Get this right: every DPI-relative threshold downstream is computed from it.
returns
a Page with the raster attached and its point-space geometry derived from the array's shape and dpi

Ingest.document_from_images

staticmethod source
document_from_images(images: Sequence[ImageArray], dpi: int = DEFAULT_IMAGE_DPI,
                         source_uri: str = "<arrays>") -> Document

Public helper: build a Document from in-memory arrays.

images
the page rasters, in order
dpi
resolution assumed for every array, as described in page_from_image
source_uri
label recorded on the document and used in reports
returns
a Document with one page per array, indexed from 0

Ingest.ingest

staticmethod source
ingest(source: Source, filename: str = "", dpi: Optional[int] = None,
           password: Optional[str] = None, max_pages: int = 0,
           page_range: Optional[Sequence[int]] = None, render_dpi: int = DEFAULT_DPI,
           min_native_chars: int = MIN_NATIVE_CHARS) -> Document

Ingest anything: PDF, image, multi-page TIFF, .eml, path or bytes.

Format is decided by content, not by extension. Returns a Document whose pages are geometry-complete but not rasterised.

source
a path or path-like ("scan.pdf"), raw bytes, or an open binary file object. A directory is not accepted here; walk one with ingest_dir instead.
filename
name hint, used only when source is bytes or a stream and only to break a tie the magic bytes could not. Also becomes the document's source_uri.
dpi
assumed resolution for bare images. None reads the file's metadata and falls back to DEFAULT_IMAGE_DPI. Ignored for PDFs, which use render_dpi.
password
for an encrypted PDF; ignored for other formats
max_pages
stop after this many pages; 0 means no limit
page_range
explicit zero-based page indices to keep, e.g. range(10, 20). PDFs only.
render_dpi
resolution for PDF page rendering. 300 is the floor for reliable OCR; 400 helps on small print.
min_native_chars
per-page character floor for trusting a PDF text layer; see classify_page_kind.
returns
a Document whose pages are geometry-complete but not yet rasterised — raster() is lazy by contract, not as an optimisation.
raises IngestError
the input was empty, the format was unrecognised, or the file could not be opened

Bytes off a queue, with the name as the only hint:

doc = ingest(payload, filename="claim-4471.pdf")

Ingest.ingest_dir

staticmethod source
ingest_dir(directory: str, pattern: Optional[str] = None, recursive: bool = True,
               **kwargs: Any) -> List[Document]

Ingest every readable document in a directory tree.

Unreadable files are reported as warnings on an empty Document rather than aborting the batch — a 5000-file inbox with three corrupt scans should still process 4997 of them.

directory
the root to walk. Dotfiles are skipped; files are taken in sorted order per directory, so the result is stable.
pattern
a regular expression matched against each filename with re.search, e.g. "[.]pdf$" or "^claim-". None attempts every file, relying on content sniffing.
recursive
descend into subdirectories. False reads only the top level.
kwargs
forwarded to ingest for each file — render_dpi, max_pages, password, ...
returns
one Document per file attempted, in walk order. A file that failed yields an empty document carrying the reason in its warnings, so the count always matches the files seen.

Ingest.merge_documents

staticmethod source
merge_documents(docs: Sequence[Document], source_uri: str = "<merged>") -> Document

Concatenate documents into one, renumbering pages and their bboxes.

Renumbering is the point: every span's bbox.page refers to a page index, so concatenating without reindexing would leave provenance pointing at the wrong pages.

docs
the documents to concatenate, in order
source_uri
label for the merged document
returns
one Document whose pages are numbered from 0, with warnings unioned and duplicates dropped

_read_source

func source
_read_source(source: Source) -> Tuple[bytes, str]

Normalise a path / bytes / file-like into (data, uri).

_pdf_raster_provider

func source
_pdf_raster_provider(fdoc: Any, pno: int) -> RasterProvider

Closure that renders page pno at a requested DPI, on demand.

Holds a reference to the open PyMuPDF document; that keeps the (in-memory) file alive exactly as long as some page might still need to render.

_ingest_pdf_pdfium

func source
_ingest_pdf_pdfium(source: Source, max_pages: int = 0,
                       page_range: Optional[Sequence[int]] = None,
                       render_dpi: int = DEFAULT_DPI) -> Document

Render-only PDF ingest via pypdfium2, when PyMuPDF is unavailable.

No text-layer extraction, so every page is treated as SCANNED. That is a correctness-preserving degradation — it costs OCR money on native pages, it does not produce wrong text.

_image_dpi_hint

func source
_image_dpi_hint(data: bytes) -> Optional[int]

Read declared DPI from image metadata, if it looks plausible.

_page_from_array

func source
_page_from_array(img: ImageArray, index: int, dpi: int, uri: str = "") -> Page

Wrap a decoded raster as a Page with correct point-space geometry.

_reindex_page

func source
_reindex_page(page: Page, new_index: int) -> Page

Renumber a page and every bbox on it. Needed when merging documents.

_text_only_page

func source
_text_only_page(text: str, index: int, source: str) -> Page

A synthetic page holding plain text (an email body) with no raster.

Laid out as monospaced lines so that bboxes are at least ordinally meaningful — a reviewer can be shown "line 12", which beats no provenance.

7. Layer 2 — preprocessing as composable ops

Ops are Page -> Page. They never mutate their input, they record themselves in the output page's history, and they are built by small factories so that a whole policy is serialisable data rather than code.

The policy is adaptive by default because preprocessing is channel equalisation: a fixed sequence over-processes clean pages (destroying signal that was never damaged) and under-processes bad ones. Note in particular what default_policy does not do — unconditional binarisation. It reliably helps classical OCR and reliably hurts vision-language models, which use greyscale gradient to disambiguate faint strokes. That trade-off is encoded once here, with an eval set behind it, instead of three times.

OpFactory

class public API source
OpFactory(Protocol)

What register_op returns: a callable that builds an Op.

It carries the two attributes the decorator attaches, which are part of the contract rather than implementation detail — fn is how one op reuses another's raster function without paying to build an Op first (see auto_orient calling rotate.fn).

Methods

OpFactory.__call__

method source
__call__(self, *args: Any, **kwargs: Any) -> Op

Build an Op from positional/keyword op parameters.

OP_FACTORIES

const public API source
OP_FACTORIES = {}

Registry of op factories by name, so a serialised policy can be rebuilt.

Op

class public API source

A named, parameterised, serialisable Page -> Page transform.

The underlying function has signature fn(img, page, **params) -> img and may additionally mutate page (to update DPI or geometry). It must not mutate img in place — ops share rasters with their input page until the moment of replacement.

Methods

Op.__init__

method source
__init__(self, name: str, fn: OpFn, params: Optional[Dict[str, Any]] = None,
             geometric: bool = False, needs_raster: bool = True) -> None

Bind a raster function and its parameters into a reusable op.

name
the registered name, used for history and serialisation
fn
fn(img, page, **params) -> Optional[ImageArray]; returning None means "declined, leave the raster alone"
params
keyword arguments bound now and passed to fn at every application, e.g. {"max_angle": 10.0}. Copied, so the caller's dict cannot change the op afterwards.
geometric
True when the op changes the page's pixel dimensions, so that point-space geometry is recomputed afterwards
needs_raster
when True the op is skipped (and says so in the history) on a page that has no raster

Op.__call__

method source
__call__(self, page: Page) -> Page

Run the op, returning a new page. The input page is never mutated.

Timing and parameters land in the returned page's history whether or not the op actually changed anything, so a pipeline is reconstructable from its output alone.

page
the page to transform; it is copied, never mutated
returns
a new Page. Identical in pixels to the input when the op declined (returned None) or was skipped for want of a raster — but its history records the attempt either way.

Op.then

method source
then(self, other: Op) -> Op

a.then(b) — a composite that runs this op, then other.

other
the op to run second
returns
a CompositeOp of the two, equivalent to compose(self, other) and chainable further

Op.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict; feed it back to op_from_dict.

_sync_page_geometry

func source
_sync_page_geometry(page: Page) -> None

Recompute point-space page size after a geometry-changing op.

_map_span_points

func source
_map_span_points(page: Page,
                     mapper: Callable[[float, float], Tuple[float, float]]) -> None

Apply a point-space coordinate map to every span and region on a page.

Geometric ops use this so that a native text layer survives deskewing instead of being silently invalidated — provenance is unrecoverable once dropped, which is the whole reason it is threaded through the IR.

register_op

func public API source
register_op(name: str, geometric: bool = False,
                needs_raster: bool = True
                ) -> Callable[[OpFn], OpFactory]

Decorator turning fn(img, page, **params) into an Op factory.

The resulting factory keeps the decorated function's signature and defaults, so deskew(max_angle=10) reads naturally and help(deskew) shows the real documentation.

This is the extension point: an op registered from your own module works everywhere a built-in does, including op_from_dict round-trips, with no edit to this file.

name
registry key, and the label recorded in page.history. Must be unique — registering an existing name replaces it, which is how you override a built-in, deliberately or by accident.
geometric
True when the op moves pixels, so span and region coordinates must be remapped with it. Getting this wrong is the classic silent provenance bug: the raster rotates, the boxes do not, and every bbox points at the wrong place. See deskew for the mapping pattern.
needs_raster
True (default) skips the op, recording why, on a page with no raster. Set False only for ops that work on spans or metadata alone and so never touch pixels.
returns
a decorator that registers the raster function and returns its Op factory

compose

func public API source
compose(*ops: Optional[Op]) -> CompositeOp

Chain ops into one. None entries are dropped, so conditional policies can be written without branching noise.

Returns the CompositeOp rather than a bare Op, so that the .ops it was built from stay reachable for inspection and serialisation.

CompositeOp

class public API source
CompositeOp(Op)

An ordered sequence of ops applied as one.

Methods

CompositeOp.__init__

method source
__init__(self, ops: Sequence[Op]) -> None

Wrap an ordered sequence of ops as a single op.

ops
the member ops, applied in this order. Kept on .ops so they stay reachable for inspection and serialisation.

CompositeOp.__call__

method source
__call__(self, page: Page) -> Page

Run every op in order, threading the page through.

page
the starting page; it is not mutated
returns
the page after every member op, carrying one history entry per op rather than a single entry for the composite

CompositeOp.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict containing each member op.

op_from_dict

func public API source
op_from_dict(d: Mapping[str, Any]) -> Op

Rebuild an op (or a composite) from its serialised form.

The inverse of to_dict, which is what lets a policy be recorded in an eval report and replayed later against a new build.

d
{"op": name, "params": {...}}, or {"op": "compose", "ops": [...]} for a composite. Rebuilt recursively.
returns
an Op, or a CompositeOp for a "compose" entry
raises ConfigError
the name is not in OP_FACTORIES; the message lists what is registered, since the usual cause is a custom op whose module has not been imported yet

apply_ops

func public API source
apply_ops(page: Page, ops: Sequence[Op]) -> Page

Apply ops in order, returning a new page.

page
the starting page; it is not mutated
ops
ops to run in sequence, as built by the Ops factories. An empty sequence returns the page unchanged.
returns
the page after every op, with one history entry per op

Ops

class public API source

The preprocessing ops, as Op factories.

Calling one builds an op; applying it returns a new page. Each corrects a measured degradation and returns None when there is nothing to correct, so a clean page passes through untouched — a fixed sequence applied to every page destroys signal that was never damaged.

Registering a new op does not require editing this class: register_op adds to OP_FACTORIES from anywhere.

**Reading these signatures.** Each is shown as fn(img, page, **knobs), which is how the raster function is written — but register_op strips the first two, so what you call is the knobs alone. img and page are

supplied by the machinery when the op runs:

op = deskew(min_angle=0.5)   # build: only the knobs
page = op(page)              # apply: img and page threaded in for you

Every :param: documented below is therefore a keyword you may pass at build time. Ops apply lazily in this sense: nothing is rasterised until the op runs, and an op that returns None leaves the page's pixels untouched while still recording the decision in page.history.

Methods

Ops.to_grayscale

staticmethod source
@register_op('to_grayscale')
to_grayscale(img: ImageArray, page: Page) -> Optional[ImageArray]

Collapse to a single channel.

Do this before classical OCR (which discards colour anyway) but not before colour-based stamp removal, which needs hue.

Ops.invert_if_dark

staticmethod source
@register_op('invert_if_dark')
invert_if_dark(img: ImageArray, page: Page,
                   ink_threshold: float = 0.55) -> Optional[ImageArray]

Invert white-on-black pages (negative scans, some fax modes).

Every downstream measurement assumes dark ink on light paper; a negative page reports 90% ink coverage and gets classified unreadable, when in fact it just needs one subtraction.

ink_threshold
inked fraction above which the page is judged to be a negative, in 0.0..1.0. The default 0.55 sits well above any real document — dense Devanagari body text reaches about 0.25, a solid-black form header about 0.4 — so this fires on true negatives and not on merely heavy pages. Lower it towards 0.45 only if you have genuinely ink-saturated scans; below that you will start inverting healthy pages.

Ops.autocontrast

staticmethod source
@register_op('autocontrast')
autocontrast(img: ImageArray, page: Page, low_pct: float = 1.0,
                 high_pct: float = 99.0) -> Optional[ImageArray]

Stretch the [low_pct, high_pct] intensity range to full scale.

Percentile-based so that a punch hole or a black scan border does not eat the entire dynamic range, which is what naive min/max stretching does.

low_pct
percentile mapped to black, in 0.0..100.0. 1.0 discards the darkest 1% of pixels — enough to absorb punch holes, staple shadows and a thin scan border. 0.0 is exact min/max stretching and is what you want only on synthetic images.
high_pct
percentile mapped to white, in 0.0..100.0. 99.0 pairs with the default low_pct; widen to 0.5/99.5 for a gentler stretch on pages that are already close to full range.
returns
None when the two percentiles are within one grey level of each other — a blank or already-saturated page, where stretching would only amplify noise.

Ops.gamma

staticmethod source
@register_op('gamma')
gamma(img: ImageArray, page: Page, value: float = 1.0) -> Optional[ImageArray]

Apply gamma correction. value < 1 brightens, > 1 darkens.

Non-linear, unlike autocontrast: it moves the midtones while leaving true black and true white fixed, which is what recovers faint pencil or carbon-copy strokes without blowing out the paper.

value
gamma exponent, practically 0.4..2.5. 0.6 lifts faint grey strokes on an underexposed phone photo; 1.4 deepens washed-out thermal-printer output; 1.0 is identity and returns None so a no-op costs nothing.
returns
None when value is 1.0 within tolerance

Ops.clahe

staticmethod source
@register_op('clahe')
clahe(img: ImageArray, page: Page, clip: float = 2.0,
          grid: int = 8) -> Optional[ImageArray]

Contrast-limited adaptive histogram equalisation.

The right tool for a page that is faded in one corner and fine elsewhere — a global stretch cannot fix that by construction. Falls back to autocontrast without OpenCV.

clip
contrast ceiling per tile. 2.0 is the usual document setting; 1.0 is nearly a no-op, and above 4.0 the noise in blank paper is amplified into visible grain that OCR reads as specks.
grid
tile count along each axis, so 8 means an 8x8 grid. Fewer, larger tiles (4) approach a global stretch; more, smaller tiles (16) track tighter lighting gradients but start equalising within a glyph, which thins strokes.
returns
the equalised greyscale page; never None, since the caller has already decided the page needs it

Ops.normalize_illumination

staticmethod source
@register_op('normalize_illumination')
normalize_illumination(img: ImageArray, page: Page, radius: Optional[int] = None,
                           strength: float = 1.0) -> Optional[ImageArray]

Flatten uneven lighting by dividing out the estimated background.

This is the fix for phone photos and book scans with a shadow gradient. Doing it before binarisation is what lets a global threshold work at all; skipping it is why so many pipelines lose the shadowed third of a page.

radius
background-estimation window in pixels; None derives one from the page size, which is right unless your text is unusually large. The window must be comfortably wider than a glyph — set it near a character width and the estimator treats the text itself as background and erases it.
strength
how far to move towards the flattened result, in 0.0..1.0. 1.0 applies it fully, whereas the gentler vlm policy uses 0.7 — vision models read the greyscale gradient, and a fully flattened page discards the very cue that distinguishes a faint stroke from paper. See the gentler vlm_policy.

Ops.remove_shadow

staticmethod source
@register_op('remove_shadow')
remove_shadow(img: ImageArray, page: Page, radius: int = 21) -> Optional[ImageArray]

Remove cast shadows via dilate-then-median background subtraction.

The classic recipe: dilating with a mid-sized kernel removes text, the median then removes residual structure, and the difference is the page without its shadow.

Overlaps with normalize_illumination; prefer that one for a smooth lighting gradient and this one for a hard-edged cast shadow, such as the photographer's hand or the spine shadow of an open book.

radius
dilation and median kernel in pixels, forced odd. It must exceed the thickest stroke on the page or the text survives dilation and is subtracted away with the shadow. 21 suits 300 DPI body text; raise towards 41 at 600 DPI or for large print.

Ops.rescale

staticmethod source
@register_op('rescale', geometric=True)
rescale(img: ImageArray, page: Page, factor: float = 1.0,
            interpolation: str = "auto") -> Optional[ImageArray]

Scale the raster by factor, keeping physical page size constant.

Point-space coordinates are unaffected because DPI scales with the pixels; that invariant is what keeps every previously-extracted bbox valid.

factor
linear scale. 2.0 doubles each side and quadruples the pixel count; 0.5 halves it. 1.0 returns None.
interpolation
"auto" picks by direction — cubic when enlarging, area when shrinking, which is the pairing that avoids both softness and aliasing. Override with "nearest" (preserves hard edges on already-binarised pages), "linear", "cubic", or "area".
returns
None when factor is 1.0 within tolerance

Ops.ensure_dpi

staticmethod source
@register_op('ensure_dpi', geometric=True)
ensure_dpi(img: ImageArray, page: Page, min_dpi: int = 300, max_dpi: int = 600,
               interpolation: str = "auto") -> Optional[ImageArray]

Upsample until the page reaches min_dpi, capped at max_dpi.

Upsampling adds no information — but Tesseract's and PaddleOCR's character models are trained around 300 DPI glyph sizes and measurably lose accuracy on smaller input, so the resample buys real recall even though it buys no new signal. The cap exists because beyond ~400 DPI you pay quadratically in pixels for nothing.

Measures against page.quality.effective_dpi — the resolution the text actually carries — not the nominal raster DPI, so a 600 DPI upscan of a 150 DPI fax is correctly seen as still needing help.

min_dpi
floor to reach. 300 is where Tesseract and PaddleOCR are trained; 400 measurably helps on small print and Indic scripts.
max_dpi
ceiling that overrides min_dpi when honouring it would produce an unreasonably large raster. 600 is already past the point of accuracy returns.
interpolation
as rescale; "auto" resolves to cubic here, since this op only ever enlarges.
returns
None when the page is already at or above min_dpi, or when the cap leaves no room to scale

Ops.resize_max_side

staticmethod source
@register_op('resize_max_side', geometric=True)
resize_max_side(img: ImageArray, page: Page, max_px: int = 2000,
                    interpolation: str = "area") -> Optional[ImageArray]

Cap the longest side. Vision-model cost is ~linear in pixels, and most providers downscale above ~1500px anyway — paying to upload pixels the provider will discard is pure waste.

The counterpart to ensure_dpi: that one raises resolution for classical OCR, this one caps it for a VLM. Running both is normal — they clamp opposite ends.

max_px
ceiling for the longer side, in pixels. 2000 is what the vlm policy uses, comfortably above every major provider's internal downscale; 1500 matches it more tightly and costs less; below ~1000 small print stops being legible to the model.
interpolation
"area" by default, which is the correct filter for downscaling — it averages the discarded pixels instead of point sampling, so thin strokes fade rather than disappear.
returns
None when the page is already within max_px

Ops.deskew

staticmethod source
@register_op('deskew', geometric=True)
deskew(img: ImageArray, page: Page, max_angle: float = 15.0, min_angle: float = 0.4,
           method: str = "auto", angle: Optional[float] = None) -> Optional[ImageArray]

Rotate the page so its text lines are horizontal.

Rotation is skipped below min_angle because resampling always costs a little sharpness, and correcting 0.2 degrees costs more than it recovers. Above max_angle the page is not skewed, it is rotated — a different problem, handled by auto_orient.

Existing span coordinates are rotated with the image rather than discarded.

max_angle
refuse to correct beyond this many degrees. A larger reading almost always means the detector locked onto a table rule or a page edge rather than the text baselines, and acting on it would wreck a page that was fine.
min_angle
skip below this many degrees, since resampling costs sharpness that 0.2 degrees of skew does not. 0.4 is the default; drop to 0.2 only if downstream is a classical OCR engine, which is far more skew-sensitive than a VLM.
method
skew estimator — "auto" (projection profile, then Hough if OpenCV is present), "projection", or "hough". Projection is more robust on dense text; Hough does better on sparse forms with strong rules.
angle
bypass detection and rotate by exactly this many degrees. Use when you already know the skew, e.g. from a calibration target or a previous page of the same batch. Still subject to min_angle and max_angle.
returns
None when the measured skew falls outside [min_angle, max_angle] — the page is left untouched

Ops.rotate

staticmethod source
@register_op('rotate', geometric=True)
rotate(img: ImageArray, page: Page, degrees: int = 0) -> Optional[ImageArray]

Rotate by an exact multiple of 90 degrees (lossless).

Quarter-turns resample nothing, so unlike deskew this costs no sharpness and needs no threshold. Span coordinates turn with the page.

degrees
clockwise rotation, rounded to the nearest multiple of 90 and taken modulo 360. 90, 180, 270 are the useful values; -90 is accepted and means the same as 270. 0 returns None.
returns
None when the rotation is a whole number of full turns

Ops.auto_orient

staticmethod source
@register_op('auto_orient', geometric=True)
auto_orient(img: ImageArray, page: Page, use_osd: bool = True,
                min_confidence: float = 1.0) -> Optional[ImageArray]

Detect and correct 90/180/270-degree page rotation.

Tesseract's orientation-and-script-detection is used when available because it is the only cheap method that can tell upside-down text from right-way-up text. Without it we fall back to a projection-profile test, which reliably distinguishes portrait from landscape text but **cannot** detect a 180-degree flip — that limitation is reported in the page history rather than hidden.

use_osd
allow the Tesseract OSD path when the binary or pytesseract is importable. Set False to force the projection fallback — worth doing when OSD is present but unreliable, which it is on sparse forms and on scripts it was not trained for.
min_confidence
least OSD confidence to act on. Tesseract reports roughly 0.5..5.0 here, and its low-confidence guesses on sparse pages are close to coin flips; 1.0 rejects those. Raise to 2.0 if you would rather leave a page unrotated than risk a wrong quarter-turn.
returns
None when the page is already upright, or when the page has too little ink to judge

Ops.crop_to_content

staticmethod source
@register_op('crop_to_content', geometric=True)
crop_to_content(img: ImageArray, page: Page, margin_pt: float = 6.0,
                    min_keep: float = 0.25) -> Optional[ImageArray]

Crop away empty margins, keeping margin_pt of whitespace.

Guarded by min_keep: if the detected content occupies less than that fraction of the page, the detection is more likely to have locked onto a speck of dust than onto the content, and the crop is refused.

margin_pt
whitespace to keep around the content, in typographic points (1/72 inch), so it means the same thing at any DPI. 6.0 is roughly a line of leading. Do not drop to 0 — OCR layout analysis degrades when glyphs touch the edge, which is precisely what pad exists to prevent.
min_keep
least fraction of the original area the crop may keep, in 0.0..1.0. At 0.25 a crop discarding more than three quarters of the page is refused and the reason recorded in page.meta["crop_refused"]. Lower it only for documents that genuinely are a receipt on a big scan bed.
returns
None when the page is blank, when the crop would be a no-op, or when min_keep refuses it

Ops.remove_border

staticmethod source
@register_op('remove_border', geometric=True)
remove_border(img: ImageArray, page: Page, max_frac: float = 0.08,
                  dark_threshold: int = 90) -> Optional[ImageArray]

Trim the black frame a flatbed leaves when the lid is open.

Those frames wreck contrast measurement and ink coverage, and Tesseract frequently reads them as a column of punctuation.

Trims only inward from the four edges, so unlike crop_to_content it cannot cut into the page when content reaches the margin.

max_frac
most of each dimension the trim may eat, in 0.0..1.0. 0.08 allows an 8% frame, which covers the usual lid-open border while making it impossible to consume a dark but legitimate header.
dark_threshold
mean grey level, 0..255, below which a row or column counts as frame. 90 clears true scanner black (near 0) with margin to spare; raise towards 120 for a grey plastic lid, but not so far that a dense text row qualifies.
returns
None when no edge rows or columns are dark enough to trim

Ops.pad

staticmethod source
@register_op('pad', geometric=True)
pad(img: ImageArray, page: Page, margin_px: int = 16,
        value: int = 255) -> Optional[ImageArray]

Add a quiet margin. Tesseract's layout analysis degrades noticeably when glyphs touch the image edge.

The usual last step of an OCR policy, after cropping and binarisation have both had the chance to leave text flush against the border.

margin_px
border width in pixels, applied to all four sides. 16 is enough for Tesseract at 300 DPI; scale it with your DPI if you render higher. 0 or negative returns None.
value
fill level, 0..255. 255 (white) is right for a normal page; use 0 after invert_if_dark has left you with light ink on dark paper, so the margin matches the paper rather than framing it.
returns
None when margin_px is not positive

Ops.perspective_correct

staticmethod source
@register_op('perspective_correct', geometric=True)
perspective_correct(img: ImageArray, page: Page, min_area_ratio: float = 0.35,
                        epsilon_frac: float = 0.02) -> Optional[ImageArray]

Flatten a photographed page by warping its detected quadrilateral.

Only fires when a convincing four-sided contour covering most of the frame is found, because warping on a bad quad is far worse than not warping: it shears the text irrecoverably. Requires OpenCV.

min_area_ratio
least fraction of the frame the detected quad must cover, in 0.0..1.0. 0.35 rejects the small quadrilaterals that table borders and photo frames produce. Raise towards 0.6 when every photo is known to be page-filling; lowering it is how you get a page warped to the shape of a table.
epsilon_frac
contour simplification tolerance as a fraction of the perimeter. 0.02 is the standard value that collapses a slightly wavy page outline to four corners; too small and the contour keeps more than four vertices and is rejected, too large and unrelated shapes collapse into plausible-looking quads.
returns
None when OpenCV is missing, or when no quad passes both guards — an unwarped page beats a sheared one

Ops.denoise

staticmethod source
@register_op('denoise')
denoise(img: ImageArray, page: Page, strength: str = "light",
            method: str = "auto") -> Optional[ImageArray]

Suppress scanner noise.

method="auto" picks bilateral filtering, which smooths flat paper while preserving stroke edges — ordinary Gaussian blur removes noise and glyph detail in equal measure, which is why "denoise then OCR" so often scores worse than doing nothing.

strength
one of "light", "medium", "aggressive". "light" is the only safe default: each step up also erodes diacritics, Devanagari matras and thin CJK strokes, so "aggressive" is for genuinely filthy fax output and nothing else.
method
"auto" (bilateral with OpenCV, median without), "bilateral", "nlmeans", "median", or "gaussian". "nlmeans" is the highest quality and by far the slowest; "gaussian" is the one to avoid, since it blurs strokes and noise alike. "bilateral" and "nlmeans" fall through to a non-OpenCV path when OpenCV is missing.
raises ConfigError
strength or method is not one of the above — a typo here silently disabling denoising would be worse

Ops.despeckle

staticmethod source
@register_op('despeckle')
despeckle(img: ImageArray, page: Page, min_area_px: int = 6) -> Optional[ImageArray]

Remove ink blobs smaller than min_area_px.

Dust, fax speckle and JPEG mosquito noise become spurious punctuation otherwise — which then poisons amount parsing (1.234 vs 1,234).

min_area_px
connected ink components smaller than this many pixels are painted back to the local paper colour. 6 is tuned for 300 DPI, where a full stop covers roughly 12-20 pixels — so the default leaves real punctuation alone. Scale it with the square of your DPI (about 24 at 600 DPI); leave it low if the script carries small marks, since Devanagari matras and Arabic dots are legitimately tiny.
returns
None when the page has no ink, or nothing is small enough to remove

Ops.unsharp

staticmethod source
@register_op('unsharp')
unsharp(img: ImageArray, page: Page, amount: float = 1.0, radius: float = 2.0,
            threshold: int = 0) -> Optional[ImageArray]

Unsharp masking: img + amount * (img - blur(img)).

The one operation that genuinely recovers readability on soft scans. threshold suppresses sharpening of low-contrast areas so that paper grain is not amplified along with the strokes.

amount
how much of the high-frequency difference to add back. 1.0 is a normal correction, and 1.2 is what the default policy applies to a page measured as blurred. Past about 2.0 strokes gain white halos that OCR segments as extra characters — oversharpening reads worse than the soft original.
radius
Gaussian radius in pixels defining "detail". 2.0 suits 300 DPI body text; raise it for large print, lower it towards 1.0 for dense small type, where a wide radius sharpens the gaps between glyphs rather than the glyphs.
threshold
least absolute difference, 0..255, that gets sharpened at all. 0 sharpens everything including paper grain; 5-10 leaves flat paper alone and is worth setting on any noisy scan.

Ops.morphology

staticmethod source
@register_op('morphology')
morphology(img: ImageArray, page: Page, operation: str = "open", ksize: int = 3,
               iterations: int = 1) -> Optional[ImageArray]

Grayscale morphology. close fills broken strokes in faded thermal print; open thins bleed-through from the reverse side.

Named from the point of view of the ink, not the pixel values. Because ink is dark, an OCR-sense "dilate the text" is a greyscale erosion, and this op flips the operation for you — so "dilate" thickens strokes, as the name suggests.

operation
one of "open", "close", "erode", "dilate", "tophat", "blackhat". Use "close" for broken strokes, "open" for speckle and show-through, "dilate" to thicken hairline print before OCR, and "tophat" to pull small bright detail off an uneven background. Without OpenCV only the first four are meaningful, as the fallback runs on a binary ink mask.
ksize
structuring element side in pixels, forced odd. 3 is a one-pixel nudge; 5 and 7 act fast, and anything larger tends to merge adjacent glyphs into blobs.
iterations
how many times to apply it. Two passes of 3 are gentler and more controllable than one pass of 5.
raises ConfigError
operation is not one of the six listed (OpenCV path only)

Ops.binarize_array

staticmethod source
binarize_array(gray: GrayImage, method: str = "sauvola", window: int = 31,
                   k: Optional[float] = None, offset: float = 10,
                   min_std: float = 8.0) -> GrayImage

Binarise a grayscale array to a 0/255 image.

otsu
One global threshold from between-class variance. Fast and excellent on evenly-lit print; catastrophic under a shadow gradient.
adaptive
Local mean minus offset. Cheap, robust, slightly noisy.
sauvola (default)
T = m * (1 + k * (s / R - 1)). Designed for degraded documents: it lowers the threshold where local contrast is low, so faint strokes survive without dragging the background in with them.
niblack
T = m + k * s (k negative). Sauvola's predecessor; keeps more faint text, at the cost of needing the min_std guard below.
wolf
Niblack normalised by the global minimum and maximum local deviation. Better than Sauvola when contrast varies wildly across the page.
nick
T = m + k * sqrt(var + m^2). Tuned for very low-contrast scans — the faded thermal-printer case.
bradley
Integral-image mean with a percentage offset. Very fast, and the most forgiving of a badly chosen window.

window should be a little larger than one text line's height. Too small and glyph interiors get their own threshold (hollow letters); too large and it degenerates to Otsu.

min_std guards Niblack, and only Niblack. In a uniform patch of paper the local deviation is ~0, so Niblack's threshold collapses onto the local mean and half the blank page comes out black — its notorious failure mode. Where the local deviation is below min_std there is no local evidence to use, so the global Otsu decision is taken instead. The other local methods need no such guard: Sauvola's (s/R - 1) term, Wolf's normalisation and NICK's sqrt(var + m^2) all drive the threshold well below the mean when deviation vanishes.

gray
the page as a greyscale array; colour input is converted
method
one of "otsu", "adaptive", "sauvola", "niblack", "wolf", "nick", "bradley", each described above. "sauvola" is the default because it degrades most gracefully across the widest range of real scans.
window
local window side in pixels, forced odd, minimum 3. 31 suits 300 DPI body text; use 51-61 at 600 DPI. Ignored by "otsu", which is global.
k
method-specific sensitivity; None selects the published default for the chosen method — 0.2 for Sauvola, -0.2 for Niblack, 0.5 for Wolf, -0.14 for NICK, 0.15 for Bradley. Raising Sauvola's k keeps less faint ink; lowering it keeps more background. Ignored by "otsu" and "adaptive".
offset
grey levels subtracted from the local mean. Used only by "adaptive", where 10 is a mild bias towards keeping ink.
min_std
local deviation below which "niblack" falls back to the global Otsu threshold. Guards Niblack, and only Niblack, against turning blank paper black.
returns
an array of the same shape holding only 0 and 255
raises ConfigError
method is not one of the seven listed

Ops.binarize

staticmethod source
@register_op('binarize')
binarize(img: ImageArray, page: Page, method: str = "sauvola", window: int = 31,
             k: Optional[float] = None, offset: int = 10,
             min_std: float = 8.0) -> Optional[ImageArray]

Binarise the page.

Deliberately not part of default_policy. Binarisation reliably lifts classical OCR accuracy and reliably degrades vision-language model accuracy, because VLMs use greyscale gradient to disambiguate faint strokes that a threshold has already destroyed. Apply it in an OCR-bound branch only — see ocr_policy versus vlm_policy.

The op wrapper around binarize_array; every parameter has the same meaning and defaults there, where each method is described in full.

method
"sauvola" (default), "otsu", "adaptive", "niblack", "wolf", "nick", or "bradley"
window
local window side in pixels, forced odd; 31 at 300 DPI
k
method sensitivity; None takes each method's published default
offset
grey levels below the local mean, "adaptive" only
min_std
flat-region guard for "niblack" only
raises ConfigError
method is not one of the seven listed

Ops.remove_lines

staticmethod source
@register_op('remove_lines')
remove_lines(img: ImageArray, page: Page, direction: str = "both",
                 min_len_ratio: float = 0.4, thickness: int = 2,
                 keep_text: bool = True) -> Optional[ImageArray]

Erase long ruled lines (table borders, form rules, underlines).

Ruled lines merge with glyphs during OCR line segmentation and turn |1,234| into 11,2341. Removing them costs nothing on prose pages and buys a lot on the ruled tables that dominate hospital bills. keep_text restores pixels where a line crossed a glyph, so struck-through or underlined text is not punched full of holes.

direction
"both", "horizontal", or "vertical". Use "horizontal" alone on statements ruled only between rows, which avoids mistaking a tall bracket or a devanagari shirorekha stem for a vertical rule.
min_len_ratio
least run length for a line to count, as a fraction of the page dimension, in 0.0..1.0. 0.4 means a horizontal rule must span 40% of the width. Lower it towards 0.25 for narrow-column tables; raising it protects underlines from removal.
thickness
dilation in pixels applied to the detected run, so that the anti-aliased edges of a rule are erased along with its core. 2 suits 300 DPI; 3-4 for heavier print or higher DPI.
keep_text
restore pixels that also look like glyph, so a rule crossing a digit does not punch a hole in it. Leave this True unless you are removing rules from a page with no text on them.
returns
None when the page has no ink, or no run is long enough to count as a rule
raises ConfigError
direction is not one of the three listed

Ops.remove_stamps

staticmethod source
@register_op('remove_stamps')
remove_stamps(img: ImageArray, page: Page, saturation_min: int = 70,
                  value_min: int = 40, coverage_max: float = 0.25) -> Optional[ImageArray]

Suppress coloured stamps, seals and signatures, keeping black print.

Purple "PAID" stamps and blue ink signatures land on top of the numbers that matter, and OCR reads the overlap as garbage. Detection is by saturation: black toner is unsaturated by definition, so anything strongly coloured is an overlay. coverage_max aborts the removal on genuinely colourful documents (a colour brochure) where the premise does not hold. Requires a colour raster; on grayscale input this is a no-op.

Order matters: run this before to_grayscale, which throws away the hue this op detects with.

saturation_min
least HSV saturation, 0..255, for a pixel to count as coloured. 70 clears black and grey toner, which sit near 0, while catching blue and purple ink. Lower it towards 50 for faded stamps, at the risk of eating colour-tinted paper.
value_min
least HSV value (brightness), 0..255. 40 excludes near-black pixels whose hue is meaningless noise — without it, dark toner gets classified by whatever hue the sensor guessed.
coverage_max
abort if more than this fraction of the page is coloured, in 0.0..1.0. At 0.25 a colour brochure or a coloured-paper form is left alone, because there the premise "colour means overlay" is simply false. The reason lands in page.meta["remove_stamps"].
returns
None on greyscale input, when nothing is coloured, or when coverage_max aborts the removal

Ops.split_multi_bill_page

staticmethod source
split_multi_bill_page(page: Page, axis: str = "auto", min_gap_frac: float = 0.06,
                          max_parts: int = 3, min_part_frac: float = 0.2) -> List[Page]

Split a page carrying several documents into one page per document.

Two bills photocopied side by side onto one A4 is routine, and every downstream assumption (one header, one total) breaks on it. Splitting is by wide whitespace gutters in the ink projection profile.

Returns [page] unchanged when no confident split is found — a false split is much more damaging than a missed one, so the thresholds are deliberately conservative.

Not an Op: ops map one page to one page, and this changes the page count. Reach for it through Pipeline(split_pages=True), or apply it document-wide with split_document.

page
a page with a raster; a page without one is returned as-is
axis
"auto", "vertical", or "horizontal". "auto" prefers whichever axis yields more gutters, which handles both side-by-side and stacked layouts. Name the axis when you know it — forcing "vertical" on side-by-side bills stops a wide gap between table sections being read as a horizontal split.
min_gap_frac
least whitespace-gutter width for a split, as a fraction of the page dimension, in 0.0..1.0. 0.06 is about half an inch on A4. Raise it if inter-column spacing is being read as a document boundary.
max_parts
refuse the split entirely if it would produce more than this many pages. 3 reflects what fits on a sheet; a result of eight parts means the detector found text columns, not documents.
min_part_frac
least fraction of the page each part must occupy, in 0.0..1.0. At 0.2 a cut near the edge — a margin note, a punch-hole strip — cannot become its own document.
returns
one page per detected document, each with split_from and split_part in page.meta and spans remapped into its own coordinate space; or [page] when no confident split was found
raises ConfigError
axis is not one of the three listed

Ops.split_document

staticmethod source
split_document(doc: Document, **kwargs: Any) -> Document

Apply split_multi_bill_page across a document, renumbering pages.

Renumbering matters: every span's bbox.page and every provenance reference is keyed by page index, so the new pages are reindexed contiguously rather than inheriting the index they were split from.

doc
the document to split; it is not mutated
kwargs
forwarded per page to split_multi_bill_pageaxis, min_gap_frac, max_parts, min_part_frac
returns
a new Document whose pages are numbered from 0; pages that did not split carry through unchanged

_autocontrast_array

func source
_autocontrast_array(gray: GrayImage, low_pct: float = 1.0,
                        high_pct: float = 99.0) -> GrayImage

Stretch gray so low_pct..high_pct spans the full 0-255 range.

Percentiles rather than min/max, so one dust speck and one blown-out highlight cannot define the range for the whole page.

_osd_rotation

func source
_osd_rotation(img: ImageArray, min_confidence: float = 1.0) -> Optional[int]

Ask Tesseract which way is up. Returns clockwise degrees, or None.

Policies

class public API source

Policies — functions from a measured page to the ops it needs.

A policy is the adaptive half of preprocessing: it reads PageQuality and reaches only for the ops that page warrants. Note what default_policy deliberately omits — unconditional binarisation, which reliably helps classical OCR and reliably hurts vision-language models that use greyscale gradient.

Methods

Policies.default_policy

staticmethod source
default_policy(page: Page, thresholds: Optional[QualityThresholds] = None) -> List[Op]

Correct the distortions this page actually has, and nothing else.

Reads only from page.quality, so it is pure, testable, and cheap to audit. Every branch here corresponds to a measured degradation; there is no unconditional step, because a clean 400 DPI native render needs nothing done to it and every op applied to it can only remove signal.

Reader-agnostic by design, which is why it omits binarisation — that helps classical OCR and hurts VLMs, so it belongs in ocr_policy, not here.

page
a page whose quality measure_page has already filled in. On an unmeasured page every reading is zero and this returns an empty list.
thresholds
the cut-offs each branch compares against; None uses DEFAULT_THRESHOLDS. Pass a modified QualityThresholds to retune the whole policy without rewriting it — e.g. dataclasses.replace( DEFAULT_THRESHOLDS, min_dpi=400) to upsample more eagerly.
returns
the ops this page warrants, in application order; empty for a page that needs nothing

Policies.ocr_policy

staticmethod source
ocr_policy(page: Page, thresholds: Optional[QualityThresholds] = None,
               binarization: str = "sauvola") -> List[Op]

Policy for classical OCR engines (Tesseract, Paddle, docTR).

Everything default_policy does, plus the steps that only make sense when a threshold-based recogniser is downstream: line removal, ruled-form cleanup, and binarisation.

Line removal is conditional on the page looking tabular, but greyscale conversion, binarisation, despeckling and padding are unconditional — every classical engine wants them, so there is no measurement to gate on.

page
a page whose quality has been measured
thresholds
as default_policy; None uses DEFAULT_THRESHOLDS
binarization
method passed to binarize"sauvola" (default), "otsu", "adaptive", "niblack", "wolf", "nick", or "bradley". Try "otsu" on evenly-lit laser print, where it is faster and marginally cleaner; stay on "sauvola" for anything photographed or faded.
returns
the ops for this page, ending with the binarise-despeckle-pad sequence that classical OCR expects

Policies.vlm_policy

staticmethod source
vlm_policy(page: Page, thresholds: Optional[QualityThresholds] = None,
               max_side: int = 2000) -> List[Op]

Policy for vision-language models.

Gentler on purpose. No binarisation (it destroys the greyscale gradient the model reads faint strokes from), no aggressive denoising (it removes diacritics and thin Devanagari strokes), and a hard pixel cap because cost scales with image size while accuracy stops improving well before the provider's limit.

Its skew tolerance is also twice default_policy's, since vision models read moderately tilted text without help and the resampling would cost more sharpness than the tilt costs accuracy.

page
a page whose quality has been measured
thresholds
as default_policy; None uses DEFAULT_THRESHOLDS. Note the skew branch here compares against twice skew_correct_deg.
max_side
pixel cap for the longer side, handed to resize_max_side. 2000 is comfortably above every major provider's internal downscale; 1500 cuts cost with no measurable accuracy loss on ordinary print.
returns
a short op list — often empty for a clean page, which is the intended behaviour

Policies.no_policy

staticmethod source
no_policy(page: Page) -> List[Op]

Apply nothing. Useful as an eval baseline.

Distinct from policy=None in intent only — both skip preprocessing. Use this one when you want a named policy in the eval report, so the baseline row reads no_policy rather than None.

page
ignored; the signature exists to satisfy PolicyFn
returns
always an empty list

Policies.fixed_policy

staticmethod source
fixed_policy(*ops: Op) -> PolicyFn

Turn a fixed op list into a policy, for A/B against adaptive ones.

The honest way to test the library's central claim. Pit a fixed sequence against default_policy in an EvalSuite and see

whether adapting to measured quality actually wins on your documents:

fixed = fixed_policy(deskew(), binarize(), despeckle())
ops
the ops to apply to every page, in order. A fresh copy of the list is returned per page, so callers cannot mutate it.
returns
a PolicyFn ignoring page quality entirely

preprocess

func public API source
preprocess(doc: Document, policy: Optional[PolicyFn] = default_policy,
               ops: Optional[Sequence[Op]] = None, dpi: Optional[int] = None,
               max_workers: int = 0, measure: bool = True,
               thresholds: Optional[QualityThresholds] = None,
               in_place: bool = False) -> Document

Measure and correct every page in a document.

Layer 2 in one call: measure each page, ask the policy what that page needs, apply it, then re-measure so downstream routing and the confidence prior see the page as it will actually be read.

doc
an ingested document. Rasters are pulled lazily per page, so a 300-page bundle is not materialised all at once.
policy
called per page to choose ops. Defaults to default_policy; also ocr_policy, or else vlm_policy, or any Callable[[Page], List[Op]]. Ignored when ops is given, and None means apply nothing.
ops
a fixed op list applied to every page, bypassing the policy entirely. For debugging and ablations — ops=[deskew(), binarize()] — not for production, where a fixed sequence destroys signal on the pages that did not need it.
dpi
override the DPI assumed when measuring, for images whose metadata lies or is missing. None trusts page.raster_dpi.
max_workers
threads for per-page work. 0 runs serially, which is what you want while debugging, since op errors then surface in order. The ops are IO- and NumPy-bound, so threads do help despite the GIL.
measure
measure quality before choosing ops. Required for any adaptive policy — with False every reading stays zero, and so default_policy sees a perfect page and picks nothing. Set it False only alongside an explicit ops list.
thresholds
cut-offs for measurement and policy; None uses DEFAULT_THRESHOLDS.
in_place
mutate doc rather than working on a copy. The copy is the safe default; True saves memory on large bundles, at the cost of being unable to compare against the original.
returns
the processed Document. Pages carrying no raster — an email body, a native-text page that never rendered — pass through untouched, and every op applied is recorded in page.history.

Measure the no-preprocessing baseline, then the adaptive one:

base = preprocess(doc, policy=None)
tuned = preprocess(doc, policy=Policies.ocr_policy, max_workers=4)

8. Layer 3 — pluggable reading backends

Every backend consumes a Page and produces TextSpans. That is the whole contract, and it is what makes them interchangeable: a router can pick one per page, an ensemble can run two and compare, and the eval harness can A/B entire pipelines because the types line up.

Backends are registered by name, so a consuming project can add its own without a pull request to this file — if anyone ever has to fork the library to get their engine in, that is a defect here, not there.

ReadResult

classdataclass public API source

What a backend returns: spans, what they cost, and how long they took.

Fields

spans: List[TextSpan] = field(default_factory=list)
cost: Cost = field(default_factory=Cost)
latency_ms: float = 0.0
backend: str = ''
warnings: List[str] = field(default_factory=list)
raw: Optional[Any] = field(default=None, repr=False)

Methods

ReadResult.text

property source
text(self) -> str

All non-empty spans joined by spaces, in the order returned.

ReadResult.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict. raw is deliberately excluded (unbounded size).

TextBackend

class public API source
TextBackend(Protocol)

Structural type for anything that can read text off a page.

Methods

TextBackend.supports

method source
supports(self, page: Page) -> bool

Whether this backend can read page at all.

Asked per page, so the answer may legitimately differ across a document: a text-layer backend supports the digital pages of a mixed PDF and not the scanned ones.

page
the page in question
returns
False when this backend needs a raster the page lacks, when its dependencies are missing, or when the page is otherwise outside what it handles

TextBackend.is_available

method source
is_available(self) -> bool

Whether this backend's dependencies are importable right now.

Part of the protocol, not just of BaseBackend: the routers call it to skip a backend whose engine is not installed, so a custom backend that omits it breaks routing rather than merely failing to read.

TextBackend.estimate_cost

method source
estimate_cost(self, page: Page) -> Cost

Predicted cost of reading page, before doing it.

Consulted by BudgetRouter to decide whether a page can afford this backend, so it must not read the page or call the provider.

page
the page that would be read
returns
a Cost; Cost.zero() for local engines. Money is 0.00 until set_pricing is called, though token counts are always real.

TextBackend.read

method source
read(self, page: Page) -> ReadResult

Read page and return its spans, cost and timing.

page
the page to read; implementations must not mutate it
returns
a ReadResult whose spans carry text, geometry and per-span confidence. A page the backend could not read yields an empty span list and a warning, not an exception — one bad page must not cost the document.

BaseBackend

class public API source

Convenience base implementing the boring parts of the protocol.

Attributes

needs_raster = True
Whether this backend needs a rasterised page (as opposed to a text layer).
preferred_dpi = DEFAULT_DPI
Preferred input DPI. The reader upsamples toward this when cheap.

Methods

BaseBackend.__init__

method source
__init__(self, name: Optional[str] = None, **options: Any) -> None

Configure the backend.

name
overrides the class-level registry name, so the same engine can be registered twice with different options
options
passed through to the underlying engine at construction

BaseBackend.supports

method source
supports(self, page: Page) -> bool

Whether this backend can read page at all.

page
the page in question
returns
True unless the page lacks a raster this backend needs, or is_available is False. Override to add engine-specific limits, such as a script or a page-size ceiling.

BaseBackend.is_available

method source
is_available(self) -> bool

Whether this backend's dependencies are importable right now.

BaseBackend.estimate_cost

method source
estimate_cost(self, page: Page) -> Cost

Predicted cost of reading page. Free by default (local engines).

page
the page that would be read
returns
Cost.zero(). Vision backends override this; see the per-token estimate in estimate_cost.

BaseBackend.read

method source
read(self, page: Page) -> ReadResult

Read page, timing the call and tagging spans with this backend.

Do not override this; implement _read instead. This wrapper is what fills in latency, stamps span.source with the backend name, and detects the script of each span — three things every backend would otherwise have to remember.

page
the page to read
returns
the ReadResult from _read, with latency_ms, backend, and each span's source and script filled in

BaseBackend._read

method source
_read(self, page: Page) -> ReadResult

Do the actual reading. Subclasses implement this, not read.

Timing, span sourcing and script detection are handled by read, so an implementation only has to produce spans.

BaseBackend._image_for

method source
_image_for(self, page: Page) -> ImageArray

The raster this backend should see, at its preferred DPI when free.

BaseBackend._bbox

method source
_bbox(self, page: Page, x0: float, y0: float, x1: float, y1: float,
          dpi: Optional[float] = None) -> BBox

Pixel coordinates from an engine -> canonical page points.

BackendRegistry

class public API source

Name -> backend, with lazy construction so imports stay cheap.

Registering a factory rather than an instance matters: constructing a PaddleOCR or Surya backend loads hundreds of megabytes of weights, and a document that never routes to it should never pay that.

Methods

BackendRegistry.__init__

method source
__init__(self) -> None

Start empty. Factories are registered, never instances.

BackendRegistry.register

method source
register(self, name: str, factory: Union[Callable[[], Any], Any],
             replace: bool = False) -> None

Register a backend under name.

Prefer a callable: instantiating PaddleOCR or Surya loads hundreds of megabytes of weights, and a document that never routes there must not pay for it. Passing an instance is supported for cheap backends and

test doubles, and constructs eagerly by definition:

registry.register("myocr", lambda: MyOCRBackend(lang="hi"))
name
the routing key, as used by backend="myocr" and by any RuleRouter rule
factory
a zero-argument callable returning a backend, or a backend instance (wrapped in a callable for you)
replace
allow overwriting an existing registration. Off by default so a name collision is an error rather than a backend that silently stops being used; any cached instance is dropped.
raises ConfigError
name is taken and replace is False

BackendRegistry.get

method source
get(self, name: str) -> Any

Instantiate (once) and return the backend registered as name.

The instance is cached under the lock, so the weights load once even if several pages race for the same backend.

name
a registered name, e.g. "tesseract" or "anthropic"
returns
the shared backend instance
raises ConfigError
nothing is registered under name; the message lists what is

BackendRegistry.__contains__

method source
__contains__(self, name: str) -> bool

"tesseract" in registry — true if registered, available or not.

Registered is not the same as usable: use available to ask whether the engine's dependencies are actually importable.

name
the name to look for
returns
whether a factory exists under that name

BackendRegistry.__getitem__

method source
__getitem__(self, name: str) -> Any

registry["tesseract"] — alias for get.

name
a registered name
returns
the shared backend instance
raises ConfigError
nothing is registered under name

BackendRegistry.names

method source
names(self) -> List[str]

Every registered name, sorted.

BackendRegistry.available

method source
available(self) -> List[str]

Names whose dependencies are importable, without constructing them.

BackendRegistry.clear_instances

method source
clear_instances(self) -> None

Drop cached instances, keeping the factories.

Next use reconstructs them — which is how a test swaps a backend's configuration, and how a long-lived process releases model weights.

registry

const public API source
registry = BackendRegistry()

The process-wide backend registry. docpipe.registry.register("mine", ...)

PyMuPDFTextLayer

class public API source
PyMuPDFTextLayer(BaseBackend)

Return the PDF's own text layer.

Exact by construction and free. For a native-text page this is strictly better than any recogniser: a model reading a picture of text it could have read directly can only introduce errors, and will occasionally hallucinate a plausible number where the text layer has the real one.

Methods

PyMuPDFTextLayer.supports

method source
supports(self, page: Page) -> bool

True only for a page that already carries a trustworthy text layer.

Stricter than supports on purpose: a scanned page in a PDF has a text layer of empty strings, and reading that would return nothing while looking like a success.

page
the page in question
returns
True only when the page is DIGITAL_NATIVE or HYBRID and already carries spans — see classify_page_kind

PyMuPDFTextLayer._read

method source
_read(self, page: Page) -> ReadResult

Return the page's existing native spans, re-tagged as this backend's.

TesseractOCR

class public API source
TesseractOCR(BaseBackend)

Tesseract 5 via pytesseract, or the tesseract binary directly.

Still the strongest accuracy-per-millisecond option for clean, well-framed machine print, and the only one of these engines that runs anywhere without a model download. It degrades sharply on degraded scans, handwriting and dense tables — which is precisely what the router is for.

Word-level confidences are reported, which matters: they are the backend_conf term in confidence fusion, and Tesseract's are reasonably well behaved (unlike a language model's self-report).

Methods

TesseractOCR.__init__

method source
__init__(self, lang: str = "eng", psm: int = 3, oem: int = 3, config: str = "",
             min_confidence: float = 0.0, name: Optional[str] = None,
             **options: Any) -> None

Configure the engine.

lang
Tesseract language code(s), +-joined, e.g. "eng", "eng+hin", "eng+deu". Each needs its traineddata installed; naming a missing one fails at read time, not here.
psm
page segmentation mode. 3 (fully automatic) is right for a whole page; 6 ("a single uniform block") is markedly better on a cropped table cell or a form field; 7 reads one line, 11 finds sparse text in any order.
oem
OCR engine mode. 3 picks the best available, 1 forces the LSTM engine, 0 the legacy one. Leave it at 3 unless you are reproducing an old result.
config
extra flags appended to the command line, e.g. "-c tessedit_char_whitelist=0123456789.," to read an amounts-only column.
min_confidence
drop words below this confidence, in 0.0..1.0. 0.0 keeps everything, which is the right default — confidence is a fusion input, and dropping words here hides evidence from locate_value rather than improving accuracy.
name
overrides the registry name, so the same engine can be registered twice with different options
options
forwarded to BaseBackend

TesseractOCR.is_available

method source
is_available(self) -> bool

True when either pytesseract or the tesseract binary is present.

TesseractOCR._config_string

method source
_config_string(self) -> str

The --psm/--oem/extra flags as one command-line string.

TesseractOCR._read

method source
_read(self, page: Page) -> ReadResult

Read via pytesseract when importable, else via the binary.

TesseractOCR._read_pytesseract

method source
_read_pytesseract(self, page: Page, img: ImageArray, pt: Any) -> ReadResult

Read through pytesseract's word-level TSV output.

TesseractOCR._read_cli

method source
_read_cli(self, page: Page, img: ImageArray) -> ReadResult

Shell out to tesseract, parsing TSV. Used when pytesseract is absent.

PaddleOCRBackend

class public API source
PaddleOCRBackend(BaseBackend)

PaddleOCR (PP-OCR).

Strong on ruled tables, dense layouts and CJK, and considerably more robust than Tesseract on moderately degraded scans. Detection is quadrilateral, so rotated text is found correctly; we keep the axis-aligned hull for the IR and stash the original quad in span.meta, because a reviewer highlight box wants the hull but a dewarper wants the quad.

Methods

PaddleOCRBackend.__init__

method source
__init__(self, lang: str = "en", use_angle_cls: bool = True,
             name: Optional[str] = None, **options: Any) -> None

Configure the engine.

lang
PaddleOCR language code, e.g. "en", "ch", "devanagari". One per engine instance — register the backend twice under different names to read two scripts.
use_angle_cls
run the text-direction classifier, needed for pages with rotated or vertical text. Costs a little latency per page and is worth keeping on for anything photographed.
name
overrides the registry name, so the same engine can be registered twice with different options
options
forwarded to BaseBackend

PaddleOCRBackend.is_available

method source
is_available(self) -> bool

True when paddleocr is importable.

PaddleOCRBackend._get_engine

method source
_get_engine(self) -> Any

Construct the engine once, under the lock. Loads model weights.

PaddleOCRBackend._read

method source
_read(self, page: Page) -> ReadResult

Run PP-OCR and convert its quads into spans.

RapidOCRBackend

class public API source
RapidOCRBackend(BaseBackend)

RapidOCR — the PP-OCR models repacked for ONNX Runtime.

Practically the same accuracy as PaddleOCR with a fraction of the install weight and no PaddlePaddle dependency, which makes it the pragmatic default for on-premise deployments where documents cannot leave the estate.

Methods

RapidOCRBackend.__init__

method source
__init__(self, name: Optional[str] = None, **options: Any) -> None

Configure the engine.

name
overrides the registry name, so the same engine can be registered twice with different options
options
passed straight to RapidOCR at construction, e.g. det_model_path=... to point at your own ONNX weights

RapidOCRBackend.is_available

method source
is_available(self) -> bool

True when either RapidOCR distribution is importable.

RapidOCRBackend._get_engine

method source
_get_engine(self) -> Any

Construct the engine once, under the lock. Loads ONNX weights.

RapidOCRBackend._read

method source
_read(self, page: Page) -> ReadResult

Run RapidOCR and convert its quads into spans.

EasyOCRBackend

class public API source
EasyOCRBackend(BaseBackend)

EasyOCR. Broad script coverage and easy setup; slower than PP-OCR.

Methods

EasyOCRBackend.__init__

method source
__init__(self, languages: Sequence[str] = ("en",), gpu: bool = False,
             name: Optional[str] = None, **options: Any) -> None

Configure the engine.

languages
EasyOCR language codes loaded together, e.g. ("en",) or ("en", "hi"). EasyOCR restricts which codes may be combined — Latin scripts mix freely, but most non-Latin scripts pair only with English.
gpu
use CUDA if EasyOCR finds it. False is the safe default; on CPU this engine is several times slower than PP-OCR.
name
overrides the registry name, so the same engine can be registered twice with different options
options
forwarded to BaseBackend

EasyOCRBackend.is_available

method source
is_available(self) -> bool

True when easyocr is importable.

EasyOCRBackend._get_engine

method source
_get_engine(self) -> Any

Construct the reader once, under the lock. Loads model weights.

EasyOCRBackend._read

method source
_read(self, page: Page) -> ReadResult

Run EasyOCR and convert its quads into spans.

DocTRBackend

class public API source
DocTRBackend(BaseBackend)

docTR (Mindee). Two-stage detection + recognition, both swappable.

Worth having when you want to tune the accuracy/latency trade-off yourself rather than accept an engine's fixed choice. Geometry comes back relative to the page, so it is scaled here rather than left for callers to get wrong.

Methods

DocTRBackend.__init__

method source
__init__(self, det_arch: str = "db_resnet50",
             reco_arch: str = "crnn_vgg16_bn", pretrained: bool = True,
             name: Optional[str] = None, **options: Any) -> None

Configure the two-stage predictor.

det_arch
text-detection architecture. "db_resnet50" is the accurate default; "db_mobilenet_v3_large" is markedly faster and a little worse on small print.
reco_arch
recognition architecture. "crnn_vgg16_bn" is the balanced default; "crnn_mobilenet_v3_small" trades accuracy for speed, and "master" or "parseq" cost more for better results on irregular text.
pretrained
download pretrained weights rather than start cold. False is only useful when loading your own fine-tuned weights — an untrained model reads nothing.
name
overrides the registry name, so the same engine can be registered twice with different options
options
forwarded to BaseBackend

DocTRBackend.is_available

method source
is_available(self) -> bool

True when doctr is importable.

DocTRBackend._get_engine

method source
_get_engine(self) -> Any

Build the OCR predictor once, under the lock. Loads model weights.

DocTRBackend._read

method source
_read(self, page: Page) -> ReadResult

Run docTR and scale its relative geometry back into pixels.

SuryaBackend

class public API source
SuryaBackend(BaseBackend)

Surya — detection, recognition and layout across 90+ scripts.

The best open option when a page mixes scripts, which is the normal case for Indian documents (a Devanagari letterhead over a Latin table of amounts). Heavy: it wants a GPU to be quick.

Surya's API has moved several times; the loader below tries the current shape first and falls back through the two previous ones rather than pinning consumers to one release.

Methods

SuryaBackend.__init__

method source
__init__(self, languages: Sequence[str] = ("en",),
             name: Optional[str] = None, **options: Any) -> None

Configure the engine.

languages
script/language hints passed to the recogniser, e.g. ("en",) or ("en", "hi"). Surya detects script itself, so these are hints rather than a restriction — which is why it handles a Devanagari letterhead over a Latin amounts table without being told the page is mixed.
name
overrides the registry name, so the same engine can be registered twice with different options
options
forwarded to BaseBackend

SuryaBackend.is_available

method source
is_available(self) -> bool

True when surya is importable.

SuryaBackend._get_predictors

method source
_get_predictors(self) -> Tuple[Any, Any]

Build (detection, recognition) predictors once, under the lock.

Surya's constructor signature has changed across releases, so several known variants are tried before giving up with a clear error.

SuryaBackend._build_with_manager

staticmethod source
_build_with_manager(predictor_cls: Any) -> Any

Construct a predictor via the older SuryaInferenceManager API.

SuryaBackend._build_with_foundation

staticmethod source
_build_with_foundation(predictor_cls: Any) -> Any

Construct a predictor via the newer FoundationPredictor API.

SuryaBackend._read

method source
_read(self, page: Page) -> ReadResult

Run Surya and convert its text lines into spans.

PRICING

const public API source
PRICING = {}

model id -> (input USD per million tokens, output USD per million tokens)

Pricing

class public API source

Token accounting and the optional price table.

Token counts are always tracked. Money reads 0.00 and warns until a consumer calls set_pricing, because PRICING ships empty on purpose: a stale hard-coded rate silently produces confidently wrong numbers, which is worse than an obvious zero.

Methods

Pricing.set_pricing

staticmethod source
set_pricing(model: str, input_per_mtok: float, output_per_mtok: float) -> None

Register token prices (USD per million tokens) for model.

Until a model is priced, Cost reports token counts with a zero amount and warns once, so a missing price is visible rather than silently reported as free.

Register what your account actually pays, not the list price — committed spend and enterprise agreements both move it, and the point of this table

is that the number in the report is one you can defend:

Pricing.set_pricing("claude-sonnet-4-5", 3.0, 15.0)
model
the model id exactly as the backend reports it in Cost.model; a mismatched string leaves the model unpriced
input_per_mtok
USD per million input tokens, e.g. 3.0
output_per_mtok
USD per million output tokens, e.g. 15.0, typically several times the input rate

Pricing.price_tokens

staticmethod source
price_tokens(model: str, input_tokens: int, output_tokens: int) -> Cost

Convert token counts to a Cost using the registered pricing.

model
the model id to look up in PRICING
input_tokens
prompt tokens consumed
output_tokens
completion tokens produced
returns
a Cost for one call. When the model is unpriced the token counts are still exact and amount is 0.0, with a warning logged once per model — never an exception, since an unpriced model must not stop a document being read.

Pricing.estimate_image_tokens

staticmethod source
estimate_image_tokens(img: ImageArray, divisor: float = 750.0) -> int

Approximate the token cost of an image for a vision model.

Providers bill images roughly by area; width * height / 750 is the documented approximation for Claude and is close enough for OpenAI's high-detail mode to be useful for budgeting. It is an estimate, and the reconciled figure from the response's usage block always wins.

img
the raster that would be sent
divisor
pixels per token. 750 is Anthropic's documented approximation and is close enough for OpenAI high-detail mode to be useful for budgeting. Lower it to estimate more conservatively.
returns
an estimated token count, rounded up. Use it for pre-flight budgeting only — BudgetRouter does, and then reconciles against the real usage once the call returns.

DEFAULT_OCR_PROMPT

const public API source
DEFAULT_OCR_PROMPT = "Transcribe every character of text visible in this document image, exactly as printed, ...

The instruction given to vision models when transcribing a page. Written to suppress the two failure modes that matter: summarising instead of transcribing, and inventing plausible-looking values for illegible regions.

VisionBackend

class public API source
VisionBackend(BaseBackend)

Base for vision-language model readers.

A VLM is the right tool for degraded scans, handwriting, and multi-script pages — the cases where classical OCR's character models have nothing to work with. It is the wrong tool for a native text layer, and an expensive tool for a clean printed page, which is exactly what routing is for.

The provenance a VLM returns is weak: it reads the whole page and gives back a wall of text with no coordinates. Rather than fabricate bboxes, one span per line is emitted with a page-level box, and the line index is recorded. locate_value can then recover tighter boxes by matching the value against a second, geometry-bearing read — which is also the cross-read agreement signal. Fabricated coordinates would be worse than none, because a reviewer would be pointed at the wrong part of the page.

Attributes

max_side_px = 2000
Vision models see no benefit above this; the extra pixels are pure cost.

Methods

VisionBackend.__init__

method source
__init__(self, model: str = "", prompt: str = DEFAULT_OCR_PROMPT,
             max_tokens: int = 8192, temperature: float = 0.0,
             name: Optional[str] = None, **options: Any) -> None

Configure the model call.

model
provider model id, e.g. "claude-sonnet-4-5" or "gpt-4o". Used as the pricing key as well as in the request, so it must match what you passed to set_pricing.
prompt
transcription instructions. The default value is DEFAULT_OCR_PROMPT, tuned to suppress the summarising and reordering a general prompt invites. Override it to add domain vocabulary, never to ask for interpretation — that belongs in extract.
max_tokens
ceiling on the transcription length. 8192 covers a dense A4 page; raise it for large-format or multi-column pages, where a truncated read silently loses the bottom of the page.
temperature
0.0, because transcription is recall, not creativity. Raising it produces plausible text that is not on the page, which is the one failure mode confidence cannot catch.
name
overrides the registry name, so the same model can be registered twice with different prompts
options
forwarded to BaseBackend

VisionBackend._image_for

method source
_image_for(self, page: Page) -> ImageArray

The page raster, downscaled to max_side_px so cost stays bounded.

VisionBackend.estimate_cost

method source
estimate_cost(self, page: Page) -> Cost

Price the call before making it, from image tokens plus prompt.

Approximate by construction — it is what BudgetRouter decides on, so it errs toward the honest side rather than the flattering one.

page
the page that would be read
returns
estimated Cost from image tokens plus prompt length, with output guessed from the page's character count. Cost.zero() for a page with no raster. Reads 0.00 until the model is priced.

VisionBackend._spans_from_text

method source
_spans_from_text(self, page: Page, text: str) -> List[TextSpan]

Turn a transcription into one span per line, with honest geometry.

VisionBackend._call_model

method source
_call_model(self, image_bytes: bytes, media_type: str,
                prompt: str) -> Tuple[str, Cost]

Send one image and prompt to the provider.

Subclasses implement this; returns (transcription, cost).

VisionBackend._read

method source
_read(self, page: Page) -> ReadResult

Encode the page as JPEG, transcribe it, and split it into line spans.

AnthropicVisionBackend

class public API source
AnthropicVisionBackend(VisionBackend)

Read a page with a Claude vision model.

Methods

AnthropicVisionBackend.__init__

method source
__init__(self, model: str = "claude-sonnet-5", api_key: Optional[str] = None,
             client: Any = None, **options: Any) -> None

Configure the Claude vision call.

model
Claude model id, e.g. "claude-sonnet-5" (the default, the best accuracy-per-rupee for transcription) or "claude-opus-5" for the hardest handwriting. Also the pricing key — see set_pricing.
api_key
falls back to the ANTHROPIC_API_KEY environment variable when None
client
a pre-built SDK client, e.g. one with custom transport, a proxy, or a Bedrock/Vertex wrapper. When given, api_key is unused and is_available is True without the SDK check.
options
forwarded to VisionBackendprompt, max_tokens, temperature, name

AnthropicVisionBackend.is_available

method source
is_available(self) -> bool

True with an injected client, or with the SDK plus an API key.

AnthropicVisionBackend._get_client

method source
_get_client(self) -> Any

Construct the SDK client once, under the lock.

AnthropicVisionBackend._call_model

method source
_call_model(self, image_bytes: bytes, media_type: str,
                prompt: str) -> Tuple[str, Cost]

One Messages API call; returns the text and its billed cost.

OpenAIVisionBackend

class public API source
OpenAIVisionBackend(VisionBackend)

Read a page with an OpenAI vision model.

Methods

OpenAIVisionBackend.__init__

method source
__init__(self, model: str = "gpt-4o", api_key: Optional[str] = None,
             client: Any = None, detail: str = "high",
             **options: Any) -> None

Configure the OpenAI vision call.

model
OpenAI model id, e.g. "gpt-4o". Also the pricing key.
api_key
falls back to the OPENAI_API_KEY environment variable when None
client
a pre-built SDK client; when given, api_key is unused
detail
image fidelity hint — "high" tiles the image and reads small print, "low" sends a single low-resolution pass for far fewer tokens, and "auto" lets the provider choose. Keep "high" for documents; "low" loses most printed detail.
options
forwarded to VisionBackendprompt, max_tokens, temperature, name

OpenAIVisionBackend.is_available

method source
is_available(self) -> bool

True with an injected client, or with the SDK plus an API key.

OpenAIVisionBackend._get_client

method source
_get_client(self) -> Any

Construct the SDK client once, under the lock.

OpenAIVisionBackend._call_model

method source
_call_model(self, image_bytes: bytes, media_type: str,
                prompt: str) -> Tuple[str, Cost]

One chat-completions call; returns the text and its billed cost.

GeminiVisionBackend

class public API source
GeminiVisionBackend(VisionBackend)

Read a page with a Google Gemini vision model.

Uses the unified google-genai SDK (from google import genai), which reaches both the Gemini Developer API and Vertex AI. The legacy google-generativeai package is a different, incompatible API and is not supported; for Vertex, build the client yourself and pass it as client, since Vertex authenticates by project and location rather than by API key.

Gemini bills images by tile rather than by area, which is why estimate_cost is overridden — see there.

Attributes

tile_px = 768
Gemini crops a large image into tiles of this size, billing each one.
tokens_per_tile = 258
Tokens per tile, and the flat cost of an image small enough not to be tiled.
untiled_max_px = 384
An image with both sides under this is sent whole, at one tile's price.

Methods

GeminiVisionBackend.__init__

method source
__init__(self, model: str = "gemini-2.5-flash", api_key: Optional[str] = None,
             client: Any = None, thinking_budget: Optional[int] = 0,
             **options: Any) -> None

Configure the Gemini vision call.

model
Gemini model id, e.g. "gemini-2.5-flash" (fast and cheap, ample for printed pages) or "gemini-2.5-pro" for harder reads. Also the pricing key.
api_key
falls back to GOOGLE_API_KEY, then GEMINI_API_KEY
client
a pre-built genai.Client, e.g. one pointed at Vertex AI
thinking_budget
reasoning tokens allowed before the answer. Defaults to 0, which disables thinking: transcription is recall, not reasoning, and thinking tokens bill at the output rate for output the page never needed. Pass None to omit the setting altogether, which is required for models that do not support thinking at all.
options
merged into the generation config, so safety_settings or top_p need no subclass

GeminiVisionBackend.is_available

method source
is_available(self) -> bool

True with an injected client, or with the SDK plus an API key.

Probes google.genai rather than google: the latter is a namespace package that a dozen unrelated Google libraries populate, so its importability would say nothing about whether this SDK is installed.

GeminiVisionBackend._get_client

method source
_get_client(self) -> Any

Construct the SDK client once, under the lock.

GeminiVisionBackend.estimate_cost

method source
estimate_cost(self, page: Page) -> Cost

Price the call before making it, using Gemini's tiling rule.

The base class's area/750 approximation is Claude's rule and is wrong here by enough to matter to BudgetRouter: Gemini charges a flat tokens_per_tile for a small image and the same again for every tile_px tile of a large one, so cost is a step function of size, not a line. Still an estimate — the response's usage block always wins.

page
the page that would be read
returns
estimated Cost, or Cost.zero() for a page with no raster. Reads 0.00 until the model is priced.

GeminiVisionBackend._config

method source
_config(self) -> Dict[str, Any]

The generation config for one transcription call.

self.options is applied last, so a consumer can override anything here — including the thinking budget — without subclassing.

GeminiVisionBackend._text_of

staticmethod source
_text_of(response: Any) -> str

The transcription, or BackendError explaining its absence.

Gemini reports a refused or truncated generation as an empty candidate with a reason attached, not as an exception, so response.text is simply None. Reading that as "this page had no text" would put a silently blank page into the document, which is the one outcome nobody catches in review. Raising instead is safe at document scale: read records the failure against the page and keeps going, so one refusal costs one page rather than the whole bundle.

An empty response with no reason given is passed through as empty — a genuinely blank page is a legitimate answer, and inventing a failure for it would be as wrong in the other direction.

GeminiVisionBackend._call_model

method source
_call_model(self, image_bytes: bytes, media_type: str,
                prompt: str) -> Tuple[str, Cost]

One generate_content call; returns the text and its billed cost.

CachingBackend

class public API source
CachingBackend(BaseBackend)

Memoise reads by raster content hash.

Keyed on the pixels, not the file: after a preprocessing change the cache correctly misses, and two identical pages in different documents correctly hit. This is what makes re-running an eval suite over a hundred documents cheap enough to do on every commit.

Methods

CachingBackend.__init__

method source
__init__(self, inner: Any, cache: Optional["DiskCache"] = None,
             namespace: str = "") -> None

Wrap inner with a content-addressed read cache.

inner
the backend to memoise; its needs_raster is inherited
cache
where reads are stored; None builds a DiskCache in the standard location. Point it at a per-branch directory to keep experiments from sharing results.
namespace
mixed into every key, to separate unrelated runs that would otherwise collide — e.g. "v2-prompt" after changing the OCR prompt, since the prompt is not part of the raster hash.

CachingBackend.is_available

method source
is_available(self) -> bool

Delegates to the wrapped backend.

CachingBackend.supports

method source
supports(self, page: Page) -> bool

Delegates to the wrapped backend.

page
the page in question
returns
whatever the wrapped backend answers — caching never widens what can be read

CachingBackend.estimate_cost

method source
estimate_cost(self, page: Page) -> Cost

Zero on a cache hit, otherwise the wrapped backend's estimate.

This is what lets BudgetRouter send a repeated page to an expensive backend it could not otherwise afford.

page
the page that would be read
returns
Cost.zero() when the key is already cached, else the wrapped backend's estimate

CachingBackend._key

method source
_key(self, page: Page) -> str

Cache key: the raster's pixels, or the span text when there is no raster.

Hashing the content rather than the filename is what makes the cache correct across preprocessing changes.

CachingBackend.read

method source
read(self, page: Page) -> ReadResult

Return the cached read if there is one, else read and store it.

Hits and misses accumulate on self.hits and self.misses, which is how you check the cache is actually working rather than quietly missing on every page.

page
the page to read
returns
a ReadResult. On a hit, cost is zero and latency_ms is 0.0 — the cached run's figures would misreport this run. Spans and warnings are restored; raw is not, being unbounded in size.

RetryingBackend

class public API source
RetryingBackend(BaseBackend)

Retry a flaky backend with exponential backoff and honest accounting.

The subtlety this exists for: a provider that times out after generating tokens has already billed for them. Retrying is right, but reporting the retry as free is not — so failed attempts are counted in Cost.wasted_calls and surfaced, which is how a pipeline that quietly doubled its bill gets noticed.

Methods

RetryingBackend.__init__

method source
__init__(self, inner: TextBackend, attempts: int = 3,
             base_delay: float = 0.5,
             retry_on: Tuple[type, ...] = (Exception,),
             give_up_on: Tuple[type, ...] = (MissingDependency,
                                             ConfigError)) -> None

Wrap inner with bounded retries.

inner
the backend to wrap
attempts
total tries, including the first. 3 absorbs the usual transient provider failure; beyond 5 you are queueing on an outage rather than retrying a blip.
base_delay
seconds before the first retry, doubling thereafter — 0.5 gives 0.5s, 1s, 2s. Raise it when the provider rate-limits by window rather than by concurrency.
retry_on
exception types worth retrying. (Exception,) is deliberately broad, since provider SDKs raise their own hierarchies; narrow it if you would rather fail fast on unrecognised errors.
give_up_on
exception types that never become successes — these win over retry_on, so a bad API key fails once, not three times

RetryingBackend.is_available

method source
is_available(self) -> bool

Delegates to the wrapped backend.

RetryingBackend.supports

method source
supports(self, page: Page) -> bool

Delegates to the wrapped backend.

page
the page in question
returns
whatever the wrapped backend answers

RetryingBackend.estimate_cost

method source
estimate_cost(self, page: Page) -> Cost

Delegates to the wrapped backend (the estimate excludes retries).

Deliberately optimistic: budgeting for the worst case would reserve attempts times the money for calls that usually succeed first time. Retries that do happen are charged to Cost.wasted_calls after the fact.

page
the page that would be read
returns
the wrapped backend's estimate for a single attempt

RetryingBackend.read

method source
read(self, page: Page) -> ReadResult

Read with retries, recording any billed-but-failed attempts.

page
the page to read
returns
the successful ReadResult, with cost.wasted_calls incremented per failed attempt and a warning naming the first few failures — a provider that times out after generating tokens has already billed for them
raises Exception
whatever inner raised, once every attempt is spent or the error type is in give_up_on

EnsembleBackend

class public API source
EnsembleBackend(BaseBackend)

Read a page with two backends and keep both reads for cross-checking.

Cross-read agreement is by a distance the strongest confidence signal available: two independent methods that produce the same string for the same region are very unlikely to have failed identically. It is also the most annoying signal to implement, and therefore the one least likely to be built correctly three separate times under three separate deadlines — which is the argument for it living here.

The primary backend's spans are returned (they carry the better geometry); the secondary's are attached to the result for cross_read_agreement.

Methods

EnsembleBackend.__init__

method source
__init__(self, primary: TextBackend, secondary: TextBackend,
             name: Optional[str] = None) -> None

Pair two backends for cross-checked reading.

primary
its spans are returned, so this should be the backend with the better geometry — a classical OCR engine rather than a VLM, which reports no coordinates at all.
secondary
read for agreement only; its spans are attached to the result rather than returned. Pick something that fails differently — pairing two Tesseract configurations agrees on its own mistakes and tells you nothing.
name
overrides the generated "ensemble:a+b" registry name

EnsembleBackend.is_available

method source
is_available(self) -> bool

True only when both backends are available.

EnsembleBackend.supports

method source
supports(self, page: Page) -> bool

True only when both backends support the page.

page
the page in question
returns
False if either backend declines — there is no agreement signal to be had from one read

EnsembleBackend.estimate_cost

method source
estimate_cost(self, page: Page) -> Cost

The sum of both backends' estimates — an ensemble is not free.

page
the page that would be read
returns
both estimates added together. Worth reserving for the pages that need it: RuleRouter can send only low-quality pages here and leave clean ones on a single cheap read.

EnsembleBackend.read

method source
read(self, page: Page) -> ReadResult

Read with both backends and attach their agreement to every span.

The agreement score lands in span.meta["cross_read_agreement"], where confidence fusion picks it up.

page
the page to read; both backends see the same one
returns
a ReadResult carrying the primary's spans, the summed cost, both sets of warnings, and both raw reads under raw["primary"] and raw["secondary"] — which is how geometry is recovered for a VLM read by locate_value

default_router

func public API source
default_router(page: Page) -> Optional[str]

Choose a backend name for page.

The cost delta between "send every page to a vision model" and "send only the pages that need one" is large. The accuracy delta on native-text pages runs the other way: a text layer read directly is exact, while a model reading a picture of it can hallucinate. So both considerations point the same way and this function is worth writing down once, carefully.

Order of preference:

  1. a trustworthy embedded text layer — free and exact, never pay for it;
  2. a clean page, especially a ruled/tabular one — a local OCR engine;
  3. anything degraded, handwritten or multi-script — a vision model;
  4. whatever local engine is installed, if no vision model is configured.

Returns None for blank pages, which the reader skips.

Only ever names a backend that available reports as installed, so the same code path works on a machine with nothing but Tesseract and on one with every engine present.

page
a page whose quality and layout have been measured. Without measurement every reading is zero, so the "degraded" test never fires and clean-page routing is used throughout.
returns
a registered backend name such as "pymupdf", "paddle" or "vlm:claude"; or None for a blank page, and also when no backend at all is installed

RuleRouter

class public API source

A router built from (predicate, backend_name) rules, first match wins.

Rules are data, so a project can express "handwritten annexures go to the vision model, everything else to Paddle" without subclassing anything.

Methods

RuleRouter.__init__

method source
__init__(self, rules: Optional[Sequence[Tuple[Callable[[Page], bool], str]]] = None,
             fallback: Optional[str] = None) -> None

Build a router from ordered rules.

rules
(predicate, backend_name) pairs, evaluated in order with the first match winning. The predicate takes a Page, so any measured property is fair game::

RuleRouter([(lambda p: p.layout.has_handwriting, "vlm:claude"), (lambda p: p.layout.is_tabular, "paddle")], fallback="tesseract")

fallback
name returned when no rule matches. None means the page is skipped entirely — usually you want a cheap local engine here instead, or pass fallback=default_router(page)-style logic as a final catch-all rule.

RuleRouter.add

method source
add(self, predicate: Callable[[Page], bool], backend_name: str) -> RuleRouter

Append a rule. Returns self, so rules chain.

Appends, so it is always lower priority than the rules already present.

predicate
Callable[[Page], bool]; one that raises is logged and skipped rather than failing the document
backend_name
registered name to route to when it matches
returns
self, so calls chain — router.add(a, "x").add(b, "y")

RuleRouter.__call__

method source
__call__(self, page: Page) -> Optional[str]

The first matching rule's backend, else the fallback.

A predicate that raises is logged and skipped rather than failing the document: a broken rule should not cost you the other 200 pages.

page
the page to route
returns
the first matching rule's backend name, else fallback, which may be None to skip the page

BudgetRouter

class public API source

Wrap a router and downgrade to a cheaper backend once a budget is spent.

Long bundles are where cost surprises happen: page 3 of a 300-page court filing looks like every other page, and nothing in a per-page decision knows that 297 more are coming. This makes the ceiling explicit.

Methods

BudgetRouter.__init__

method source
__init__(self, inner: RouterFn, budget: float, cheap_backend: str = "tesseract",
             currency: str = "USD") -> None

Wrap inner with a spending ceiling.

inner
the router whose choice is second-guessed — typically the built-in default_router, or a RuleRouter
budget
total spend allowed across the whole document, in currency. Meaningless until set_pricing is called — an unpriced model estimates 0.00 and never triggers a downgrade.
cheap_backend
registered name used once the budget would be exceeded. "tesseract" needs no model download and no network; if it is not registered the page is skipped instead.
currency
label for messages and reports only; no conversion is performed, so it must match the units of your registered prices

BudgetRouter.record

method source
record(self, cost: Cost) -> None

Add an actual cost to the running total. Called by the reader.

Actual, not estimated: the running total tracks what the provider really billed, so an estimate that was optimistic still shows up here.

cost
the Cost of a completed read

BudgetRouter.remaining

method source
remaining(self) -> float

Budget left, never negative.

BudgetRouter.__call__

method source
__call__(self, page: Page) -> Optional[str]

The inner router's choice, downgraded when it would break the budget.

Returns None if even the cheap backend is unavailable — skipping a page is preferable to silently blowing through a ceiling.

Downgrades are counted on self.downgrades and logged, so a run that quietly fell back to cheap OCR halfway through is visible afterwards rather than showing up as an unexplained accuracy drop.

page
the page to route
returns
the inner router's choice when it fits the remaining budget, otherwise cheap_backend; None when the inner router skipped the page or the cheap backend is not registered

_register_default_backends

func source
_register_default_backends() -> None

Register the built-in backends as lazy factories.

read_page

func public API source
read_page(page: Page, backend: Optional[Union[str, TextBackend]] = None,
              router: Optional[RouterFn] = default_router,
              replace_spans: bool = True) -> ReadResult

Read one page with an explicit backend, or one chosen by router.

The single-page entry point, useful for debugging a page that came out wrong without running the whole document.

page
the page to read. Its history gains a "read" entry either way, including when no backend would take it.
backend
force a specific reader, as a registered name ("pymupdf", "tesseract", "paddle", "vlm:claude") or an instance of TextBackend. Overrides router when both given.
router
chooses the backend when backend is None. Defaults to default_router; None with no backend reads nothing.
replace_spans
overwrite page.spans with the result. Set False to read without disturbing the page — which is how you compare two backends on the same page, or keep a native text layer while reading it again with OCR.
returns
a ReadResult. When no backend was selected or the chosen one declined the page, the result is empty with an explanatory warning rather than an exception — one unreadable page must not cost the document.

Compare two engines on a page that read badly:

a = read_page(page, backend="tesseract", replace_spans=False)
b = read_page(page, backend="paddle", replace_spans=False)
print(a.text(), "|", b.text())

read

func public API source
read(doc: Document, backend: Optional[Union[str, TextBackend]] = None,
         router: Optional[RouterFn] = default_router, max_workers: int = 0,
         in_place: bool = False, budget: Optional[float] = None,
         on_page: Optional[Callable[[Page, ReadResult], None]] = None) -> Document

Read every page of doc, choosing a backend per page.

doc
a document, normally already preprocessed. Reading an unmeasured document works, but default_router then sees a perfect page everywhere and routes accordingly.
backend
force one reader for all pages, as a registered name or an instance of TextBackend. Overrides router.
router
per-page backend chooser, ignored when backend is set. Defaults to default_router; see also RuleRouter and the ceiling-aware BudgetRouter.
max_workers
threads for concurrent page reads. 0 runs serially. Safe to raise — backends hold their own locks — and the win is large for network-bound vision backends. Local engines that already use every core benefit far less.
in_place
mutate doc rather than working on a copy
budget
hard ceiling in USD across the document, checked after each page. Distinct from BudgetRouter, which downgrades to stay under; this one stops. Requires set_pricing to mean anything.
on_page
called as on_page(page, result) after each page, for progress reporting. Runs outside the internal lock, so it may be slow, but it is called from worker threads when max_workers is set.
returns
the read Document. Costs, warnings and the per-page backend choices accumulate onto meta["read_cost"] and meta["read_backends"] — the latter being how you check the router did what you expected.
raises BudgetExceeded
the running total passed budget

A page whose read raises is logged, recorded as a document warning, and skipped; the other pages still get read.

9. Normalisation — scripts, digits, dates, amounts

Domain-independent, but not culture-independent: date order, digit grouping and numeral systems are properties of the document's origin, not of what it means. These live here so that "1,23,456.78 is 123456.78, not 1.23" is decided once.

_SCRIPT_RANGES

const source
_SCRIPT_RANGES = [('Latn', 65, 591), ('Grek', 880, 1023), ('Cyrl', 1024, 1279), ('Arab', 1536, 1791), ('D...

Unicode block starts for the scripts these documents actually contain.

Text

class public API source

Script detection, text normalisation and value parsing.

These run after reading and before extraction, on the theory that an OCR engine's mistakes are systematic: full-width digits, Devanagari numerals, O for 0 in a numeric field. Parsing is locale-aware by necessity — 1,23,456.78 and 1.234,56 are both real.

Methods

Text.detect_script

staticmethod source
detect_script(text: str, minimum: int = 1) -> Optional[str]

Dominant ISO 15924 script code of text, or None.

Per-span rather than per-document, because a hospital letterhead in Devanagari over a table of Latin numerals is one page with two scripts, and routing a recogniser at the page level would get one of them wrong.

text
the text to inspect; digits and whitespace are ignored, as they carry no script information
minimum
least character count for a script to be reported. 1 reports whatever dominates, which is right for a single span; raise it to ignore a stray character in a long string.
returns
an ISO 15924 code — "Latn", "Deva", "Arab", "Beng", "Taml", "Hani" and the rest — or None for empty text or text that is all digits and punctuation

Text.normalize_digits

staticmethod source
normalize_digits(text: str) -> str

Convert every Unicode decimal digit to ASCII 0-9.

A Devanagari ४८२५० and an ASCII 48250 are the same amount, and every validator downstream should only ever have to handle one of them.

text
any text; non-digit characters pass through untouched
returns
the text with every Unicode decimal digit — Devanagari, Arabic-Indic, Bengali, full-width and the rest — mapped to ASCII

Text.normalize_unicode

staticmethod source
normalize_unicode(text: str,
                      form: Literal["NFC", "NFD", "NFKC", "NFKD"] = "NFKC") -> str

Normalise Unicode form, collapsing ligatures and compatibility variants.

The four forms are spelled out in the annotation because they are the only values unicodedata accepts; anything else is a ValueError from the standard library rather than a docpipe error.

text
the text to normalise
form
"NFKC" (default), "NFC", "NFD", or "NFKD". The compatibility forms (NFKC/NFKD) are what fold to fi and full-width to ASCII, which is what OCR output needs. Use "NFC" when the exact original characters must survive.
returns
the normalised text

Text.normalize_whitespace

staticmethod source
normalize_whitespace(text: str, collapse_newlines: bool = False) -> str

Collapse runs of whitespace, preserving line structure by default.

text
the text to tidy
collapse_newlines
also fold line breaks into spaces. Leave it False for page text, where line structure is what label-and-value parsing depends on; set it True for a single field value that OCR split across lines.
returns
the text with whitespace runs collapsed, leading and trailing whitespace stripped, and runs of three or more blank lines reduced to one

Text.normalize_text

staticmethod source
normalize_text(text: str) -> str

The standard cleanup: Unicode form, ASCII digits, tidy whitespace.

Applies normalize_unicode, then normalize_digits, then normalize_whitespace, in that order — digit mapping has to follow the compatibility fold, or full-width digits are missed.

text
the text to clean; None is treated as empty
returns
the normalised text. Safe for free text: nothing here guesses at characters, unlike fix_ocr_confusions.

Text.fix_ocr_confusions

staticmethod source
fix_ocr_confusions(text: str, expect: str = "numeric") -> str

Repair classic OCR character confusions for a known-type field.

Never apply this to free text. expect="numeric" on a field declared as an amount is safe and recovers a lot of otherwise-lost values; the same substitution on a patient's name turns "Sonia" into "50nia".

text
the field value to repair
expect
"numeric" maps letters onto digits (O to 0, l and I to 1, S to 5, B to 8); "alpha" maps the other way for a known-alphabetic field. Any other value leaves the text untouched rather than guessing.
returns
the repaired text

Text.parse_amount

staticmethod source
parse_amount(text: Optional[str], decimal_separator: str = "auto",
                 allow_negative: bool = True) -> Optional[decimal.Decimal]

Parse a monetary amount, handling Indian and Western digit grouping.

Indian grouping (1,23,456.78 — two digits per group above the hundreds) breaks any parser that assumes groups of three, and it is the default on every document this library was written for. Grouping is therefore inferred rather than assumed:

  • 1,234.56 -> 1234.56 (comma groups, dot decimal)
  • 1,23,456.78 -> 123456.78 (Indian grouping)
  • 1.234,56 -> 1234.56 (European convention, detected by position)
  • (1,234) -> -1234 (accounting negative)
  • 1,234 CR / 1,234 DR -> credit/debit sign

Returns a Decimal — never a float, because money and binary floating point should not meet. Returns None when no number is present.

text
the text to parse, currency symbols and labels included. None returns None.
decimal_separator
"auto" (default) infers the convention from separator positions, which is what handles Indian grouping. Force it with "dot" or "comma" when a batch is known to be one convention and short values like "1,50" are genuinely ambiguous.
allow_negative
honour accounting negatives — a leading minus, parentheses, and a DR marker. False returns the magnitude, for a field that cannot meaningfully be negative.
returns
the amount as a Decimal, or None when the text holds no number

Text.detect_currency

staticmethod source
detect_currency(text: str) -> Optional[str]

ISO currency code implied by text, or None.

text
text that may carry a symbol (, $, ) or a code (INR, USD)
returns
the ISO 4217 code, or None when nothing identifies one. Ambiguous symbols resolve to the most likely code for these document classes, so treat the answer as a hint — pass the currency in context when you know it.

Text.parse_date

staticmethod source
parse_date(text: Optional[str], dayfirst: bool = True, min_year: int = 1900,
               max_year: int = 2100) -> Optional[_dt.date]

Parse a date from noisy OCR text.

dayfirst=True by default because DD-MM-YYYY is the convention in the document classes this library targets, and an ambiguous 03/04/2024 silently read as March 4th is the kind of error that reaches a claims reviewer looking entirely plausible. Unambiguous inputs (a day above 12, a named month, an ISO date) always win over the flag.

Returns None rather than guessing when the text holds no date.

text
the text to parse; None returns None
dayfirst
how to read a genuinely ambiguous 03/04/2024. True (default) reads it as 3 April. Only consulted when both numbers are 12 or below — a day above 12, a named month, or an ISO date decides for itself.
min_year
reject dates before this year. Guards against an OCR misread producing a plausible but absurd date.
max_year
reject dates after this year
returns
a date, or None when no date is present or every candidate falls outside the year range

Text.parse_bool

staticmethod source
parse_bool(text: Optional[str]) -> Optional[bool]

Parse a checkbox-ish value from a form.

text
the cell's text. None returns None; so does anything unrecognised, which keeps "the box was unreadable" distinct from "the box was unticked".
returns
True for "yes", "y", "true", "1", "x", "✓", "✔", "checked"; False for "no", "n", "false", "0", "", "-", "unchecked"; None otherwise. Matching is case-insensitive.

Text.normalize_spans

staticmethod source
normalize_spans(spans: Sequence[TextSpan], digits: bool = True,
                    unicode_form: Optional[Literal["NFC", "NFD", "NFKC", "NFKD"]]
                    = "NFKC") -> List[TextSpan]

Return normalised copies of spans, tagging each with its script.

Copies rather than mutating, so the original read stays available for comparison — which matters when a normalisation is suspected of losing something.

spans
the spans to normalise
digits
map every Unicode digit to ASCII
unicode_form
Unicode normalisation form, or None to skip it. "NFKC" is the default and the right choice for OCR output.
returns
new spans with normalised text and script filled in where it was missing. Geometry, confidence and meta carry through untouched.

Text.normalize_document

staticmethod source
normalize_document(doc: Document, in_place: bool = False, **kwargs: Any) -> Document

Normalise every span in a document.

Run automatically at the end of run_document unless normalize=False.

doc
the document to normalise
in_place
mutate doc rather than working on a copy
kwargs
forwarded to normalize_spansdigits, unicode_form
returns
the normalised document

_DIGIT_BLOCKS

const source
_DIGIT_BLOCKS = [1632, 1776, 2406, 2534, 2662, 2790, 2918, 3046, 3174, 3302, 3430, 3664, 65296]

Non-ASCII decimal digits seen on Indian and Arabic documents.

_NUMERIC_CONFUSIONS

const source
_NUMERIC_CONFUSIONS = {'O': '0', 'o': '0', 'Q': '0', 'D': '0', 'l': '1', 'I': '1', 'i': '1', '|': '1', '!': '1...

Character confusions that classical OCR makes constantly. Applied only within a field whose expected type is known, never globally — "SO" is a word and "S0" is not, but only the caller knows which was expected.

_GROUP_SPACES

const source
_GROUP_SPACES = '\u2009\u202f\xa0'

Typographic group separators that are unambiguously inside a number. A plain ASCII space is deliberately excluded: "12 500" is as likely to be two numbers in a table row as one grouped amount, and gluing them together silently invents a value.

_AMOUNT_RE

const source
_AMOUNT_RE = re.compile("[-+]?\\d[\\d.,'%s]*\\d|[-+]?\\d" % _GROUP_SPACES)

Matches a number with any mixture of grouping and decimal separators, so that "1.234,56" is captured whole rather than truncated at the first dot.

_expand_year

func source
_expand_year(value: int) -> int

Expand a two-digit year. Documents in scope are recent, not Victorian.

_DATE_SHAPED_RE

const source
_DATE_SHAPED_RE = re.compile('\\d{1,4}[-/.]\\d{1,2}[-/.]\\d{1,4}|\\d{1,2}[\\s-]*[A-Za-z]{3,9}[\\s,-]*\\d{2...

Text that looks like a date: two separators, or a named month.

10. Cross-cutting — caching, tracing, budgets

DiskCache

class public API source

A tiny content-addressed JSON cache on the filesystem.

Deliberately not sqlite: it must be safe under concurrent readers and writers across processes, and "write a temp file then rename" gives that for free on every POSIX filesystem, with no locking and no schema to migrate.

Methods

DiskCache.__init__

method source
__init__(self, directory: Optional[str] = None, enabled: bool = True,
             max_entries: int = 0) -> None

Open (and create) the cache directory.

directory
defaults to $DOCPIPE_CACHE_DIR or a temp directory
enabled
False makes every read a miss and every write a no-op
max_entries
reserved; entries are not evicted yet

An unusable directory is not fatal — the cache degrades to memory-only and logs a warning, because a read-only filesystem should not stop a run.

DiskCache._path

method source
_path(self, key: str) -> str

Filesystem path holding key.

DiskCache.get

method source
get(self, key: str) -> Optional[Any]

The cached value, or None when absent, disabled or unreadable.

A corrupt or truncated entry counts as a miss: a cache is an optimisation, and it must never be able to fail a run.

key
the cache key, normally a content hash produced by stable_hash
returns
the stored value, or None when the key is absent, the cache is disabled, or the entry could not be read. All four cases look the same to a caller by design — there is nothing useful to do differently about a corrupt entry.

DiskCache.set

method source
set(self, key: str, value: Any) -> None

Store value under key.

Written to a temp file and renamed, which is atomic on POSIX — so a concurrent reader sees either the old entry or the new one, never half.

key
the cache key
value
any JSON-serialisable value. A value that will not serialise is logged and dropped rather than raised: failing a read because its result would not cache is the wrong trade.

DiskCache.__contains__

method source
__contains__(self, key: str) -> bool

key in cache — true when a value is stored for it.

Implemented as a full get, so it costs a read. When you intend to fetch the value anyway, call get once and test for None.

key
the cache key
returns
whether a readable value is stored

DiskCache.clear

method source
clear(self) -> None

Drop every entry, in memory and on disk.

TraceSpan

classdataclass public API source

One timed step in a pipeline run.

Fields

name: str
start: float
end: float = 0.0
attributes: Dict[str, Any] = field(default_factory=dict)
children: List[&#x27;TraceSpan'] = field(default_factory=list)

Methods

TraceSpan.ms

property source
ms(self) -> float

Duration in milliseconds — so far, if the span is still open.

TraceSpan.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict, including nested children.

Tracer

class public API source

Nested timing spans, so "why did this document take 40 seconds" has an answer.

Deliberately minimal and dependency-free. Emit to OpenTelemetry from the consuming service if you have it; this exists so the library is debuggable without one.

Methods

Tracer.__init__

method source
__init__(self, enabled: bool = True) -> None

Start a trace. enabled=False makes every span a no-op.

enabled
False makes span return null context managers, so instrumentation can stay in the code with no measurable cost when tracing is off

Tracer.span

method source
span(self, name: str, **attributes: Any) -> Any

with tracer.span("read", pages=12): ... — open a nested span.

Returns a no-op context manager when tracing is disabled, so callers never need to branch.

name
the span's label, e.g. "read" or "extract"
attributes
arbitrary key-value pairs recorded on the span, e.g. pages=12 or backend="paddle". They survive into to_dict, so this is where to put whatever would make a slow trace explicable afterwards.
returns
a context manager yielding the TraceSpan, so the block can add attributes it only learns partway through

Tracer.finish

method source
finish(self) -> TraceSpan

Close the root span and return it.

Tracer.to_dict

method source
to_dict(self) -> Dict[str, Any]

The finished trace tree as a JSON-ready dict.

_NullContext

class source

A context manager that does nothing — used when tracing is off.

CostTracker

class public API source

Accumulate spend, optionally enforcing a hard ceiling.

Methods

CostTracker.__init__

method source
__init__(self, budget: Optional[float] = None, currency: str = "USD") -> None

Start at zero.

budget
hard ceiling; exceeding it raises BudgetExceeded
currency
the currency every added cost must be in

CostTracker.add

method source
add(self, cost: Cost, backend: str = "") -> Cost

Add cost to the total and return the new total.

Raises BudgetExceeded once the budget is passed. The cost is recorded before the check, so the reported total is what was actually spent rather than the last figure under the ceiling.

cost
the Cost to accumulate
backend
attributes the spend, for the per-backend breakdown. Empty means "unattributed", which still counts towards the total.
returns
the new running total
raises BudgetExceeded
the total passed the budget. The spend is already recorded when this raises, so self.total afterwards is the true figure.

CostTracker.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict: total, per-backend breakdown, and the budget.

11. Confidence — fusion and calibration

A model's self-reported confidence is not a probability of correctness. It correlates with fluency. A clean, confidently-formatted hallucination scores high; a correct reading of a smudged digit scores low. Shipping that number to a claims reviewer labelled "confidence" is worse than shipping no number, because it will be trusted.

A defensible score fuses signals chosen to be as uncorrelated as possible, and is then calibrated against a labelled set. Confidence without a calibration set is decoration — which is why Layer 5 exists.

Confidence

class public API source

Confidence fusion and the metrics that check it.

Fusion combines independent signals — page quality, backend confidence, cross-read agreement, logprob, validation, format match — in log-odds space. A model's self-reported confidence is one input among several and never the answer: it measures fluency, not correctness. Calibrators live alongside as classes.

Methods

Confidence.logit

staticmethod source
logit(p: float, eps: float = 1e-6) -> float

Log-odds of p, clamped away from the infinities.

p
a probability in 0.0..1.0
eps
clamp distance from each end, so 0.0 and 1.0 map to large finite values rather than infinities. 1e-6 bounds the result to about +/-13.8.
returns
log(p / (1 - p))

Confidence.sigmoid

staticmethod source
sigmoid(z: float) -> float

Inverse of logit, numerically safe at both tails.

Branches on the sign so neither tail overflows exp, which the naive form does for z beyond about -700.

z
a log-odds value, any real number
returns
the probability in 0.0..1.0

Confidence.fuse_confidence

staticmethod source
fuse_confidence(signals: Mapping[str, Optional[float]],
                    weights: Optional[Mapping[str, float]] = None,
                    prior: float = 0.5) -> float

Fuse independent evidence into one calibrated-shaped score in [0, 1].

Fusion happens in log-odds space (a log-linear opinion pool), not as a weighted average of probabilities. That matters: averaging lets one confident-but-wrong signal of 0.99 drag a field up, whereas in log-odds a single strong disagreement pulls the result down hard, which is the behaviour you want when a validator says the line items do not sum to the total.

Signals set to None are absent, not zero, and are dropped with their weight renormalised away. Encoding "the backend reported no confidence" as 0.0 would quietly punish every engine that declines to guess.

Each signal is clamped to [0.02, 0.98] before its log-odds are taken, so no individual signal can dominate the pool outright — see _SIGNAL_CLAMP.

Returns prior when no signal is available.

signals
named signal values in 0.0..1.0, or None for "not measured". The names the default weights recognise are page_quality, backend_conf, cross_read, logprob, validation and format_match. An unrecognised name is ignored, so adding a signal means adding a weight for it too.
weights
relative influence per signal. None uses the defaults in DEFAULT_CONFIDENCE_WEIGHTS. Only the ratios matter, since the pool renormalises. Zero or negative drops a signal entirely.
prior
returned when every signal is absent. 0.5 is the honest "no evidence either way"; lower it if your documents are hard enough that silence should read as doubt.
returns
the fused score in 0.0..1.0, rounded to 4 places. Uncalibrated — it ranks reliably, but is not a probability until a Calibrator fitted on your own labelled data is applied.

Confidence.page_quality_prior

staticmethod source
page_quality_prior(quality: PageQuality, bbox: Optional[BBox] = None,
                       page: Optional[Page] = None) -> float

Prior probability of a correct read, from measured page degradation.

When a bbox and its page are supplied the measurement is localised to the evidence region: a page can be pristine at the top and destroyed at the bottom, and a total read from the destroyed part should not inherit the page's average health.

quality
the page's measured PageQuality
bbox
the evidence region to localise to. None uses the page-wide measurement, which is the right fallback but a blunt one.
page
the page bbox belongs to, needed to crop the raster. Both this and bbox must be given for localisation to happen.
returns
the prior in 0.0..1.0, ready to pass to fuse_confidence as its page_quality signal

Confidence.align_spans

staticmethod source
align_spans(a: Sequence[TextSpan], b: Sequence[TextSpan],
                iou_threshold: float = 0.3
                ) -> List[Tuple[Optional[TextSpan], Optional[TextSpan]]]

Pair up spans from two reads of the same page by geometric overlap.

Greedy best-IoU matching. Unmatched spans are returned paired with None so that a backend which simply missed a field is penalised, not silently ignored.

a
spans from the first read, normally the one with better geometry
b
spans from the second read
iou_threshold
least intersection-over-union for two spans to pair, in 0.0..1.0. 0.3 is deliberately loose, because two engines word-segment differently and demanding tight overlap would report disagreement where there is only different tokenisation.
returns
(a_span, b_span) pairs. An unmatched span from a appears as (span, None) and one from b as (None, span), so both directions of miss are visible.

Confidence.cross_read_agreement

staticmethod source
cross_read_agreement(a: Sequence[TextSpan], b: Sequence[TextSpan],
                         iou_threshold: float = 0.3,
                         min_geometric_fraction: float = 0.2) -> float

Agreement in [0, 1] between two independent reads of one page.

Prefers geometric matching, which is precise. When too few spans match geometrically — the normal case when one of the reads came from a vision model, whose bboxes are approximate by construction — it falls back to comparing the two transcriptions as token sequences, which is coarser but still discriminating.

Returns 0.0 if either read is empty: one method finding nothing where the other found text is maximal disagreement, not a missing measurement.

a
spans from the first read
b
spans from the second read
iou_threshold
geometric pairing threshold, with the meaning it has in align_spans
min_geometric_fraction
fraction of spans that must pair geometrically before the geometric score is trusted, in 0.0..1.0. Below it the comparison falls back to token sequences. 0.2 is low on purpose: a vision read has approximate boxes, so requiring more would discard the signal exactly when it is most valuable.
returns
agreement in 0.0..1.0. Feed it in as the cross_read signal of fuse_confidence — it is the strongest single signal available, because two methods that fail identically are rare.

Confidence.value_agreement

staticmethod source
value_agreement(a: Any, b: Any, numeric_tolerance: float = 0.0) -> float

Agreement between two extracted values rather than two transcriptions.

Numbers compare numerically (48250 and 48,250.00 agree), dates compare as dates, and everything else compares as normalised strings.

a
the first value, of any type
b
the second value
numeric_tolerance
relative slack for numbers, so 0.001 allows a tenth of a percent. 0.0 demands exact equality, which is right for amounts — two engines reading the same digits should agree exactly, and a near miss is a misread, not a rounding difference.
returns
1.0 or 0.0 for numbers, booleans and dates, which either match or do not; a graded string similarity otherwise. 0.0 when either value is None.

Confidence.format_match_score

staticmethod source
format_match_score(value: Any, expected_type: str) -> Optional[float]

Does value look like a well-formed instance of expected_type?

A weak signal on its own, but a genuinely independent one: it catches the field-shifted-by-one-row failure that every other signal misses, because a date sitting in an amount field is fluent, high-confidence and wrong.

value
the extracted value to check
expected_type
the declared type — "number", "integer", "amount", "date", "boolean", "string", and their aliases. Matching is by name, so an unrecognised type returns None rather than a guess.
returns
1.0 when the value is well formed for the type, 0.0 when it is not, or None when the type carries no format expectation — a free-text field cannot be malformed. None is dropped by the fusion pool rather than scored as failure.

Confidence.reliability_curve

staticmethod source
reliability_curve(scores: Sequence[float], labels: Sequence[bool],
                      bins: int = 10) -> List[Dict[str, float]]

Bin predictions and report empirical accuracy per bin.

This is the plot that answers "is a 0.9 right 90% of the time?". Until it is drawn, a confidence number is an assertion, not a measurement.

scores
predicted confidences in 0.0..1.0
labels
whether each prediction was actually correct
bins
equal-width bins over the confidence range. 10 is conventional. Watch the count per bin — a bin holding three samples says nothing, however tempting its accuracy looks.
returns
one dict per bin with bin_low, bin_high, count, mean_confidence, accuracy and gap. A positive gap means underconfidence, negative means overconfidence, which is the usual direction. Empty bins are included with zero counts so the list always has bins entries.
raises ConfigError
scores and labels differ in length

Confidence.expected_calibration_error

staticmethod source
expected_calibration_error(scores: Sequence[float], labels: Sequence[bool],
                               bins: int = 10) -> float

Expected calibration error: the bin-count-weighted mean |accuracy - confidence|.

The single number to track per release. Under 0.05 is a defensible target for a shipped extraction pipeline.

scores
predicted confidences in 0.0..1.0
labels
whether each prediction was actually correct
bins
histogram bins over the confidence range. 10 is conventional; more bins resolve finer structure but need far more samples per bin to mean anything.
returns
the error in 0.0..1.0; 0.0 is perfect calibration, and also what an empty input returns. Pair it with the sharpness-aware Brier score, which a constant predictor cannot game; that lives at brier_score.

Confidence.brier_score

staticmethod source
brier_score(scores: Sequence[float], labels: Sequence[bool]) -> float

Mean squared error of the probabilities. Rewards sharpness and calibration, unlike ECE, which a constant predictor can game.

scores
predicted confidences in 0.0..1.0
labels
whether each prediction was actually correct
returns
the mean squared error in 0.0..1.0, lower being better. A model that always predicts the base rate scores a perfect ECE and a poor Brier, which is exactly the difference worth watching.

DEFAULT_CONFIDENCE_WEIGHTS

const public API source
DEFAULT_CONFIDENCE_WEIGHTS = {'cross_read_agree': 0.25, 'validation': 0.25, 'text_support': 0.2, 'backend_conf': 0.15...

Default fusion weights. These are priors, chosen by how much independent evidence each signal carries, and they are meant to be replaced by weights fitted against a labelled set for your document class. Ship the fitted ones.

_SIGNAL_CLAMP

const source
_SIGNAL_CLAMP = 0.02

No single signal may contribute more than logit(0.98) (~3.9) to the pool. Without this, a binary signal reporting exactly 1.0 lands at logit(1 - 1e-6) — nearly 14 log-odds — and one "the format is valid" check outvotes every genuine measurement on the page.

_compare_key

func source
_compare_key(text: str) -> str

Aggressive normalisation for agreement comparison only.

Calibrator

class public API source

Base for post-hoc probability calibration.

Methods

Calibrator.fit

method source
fit(self, scores: Sequence[float], labels: Sequence[bool]) -> Calibrator

Fit on scores and their correctness labels. Returns self.

scores
raw fused confidences in [0, 1]
labels
True where that field was actually correct

Calibrator.predict

method source
predict(self, score: float) -> float

Map one raw score onto a calibrated probability.

Implementations must be monotone: calibration changes what a number means, never the ranking between two fields.

score
a raw fused confidence in 0.0..1.0
returns
the calibrated probability in 0.0..1.0
raises NotImplementedError
on the base class

Calibrator.predict_many

method source
predict_many(self, scores: Sequence[float]) -> List[float]

Calibrate a sequence of scores.

scores
raw fused confidences
returns
the calibrated probabilities, in the same order

Calibrator.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict, tagged with kind for from_dict.

Calibrator.from_dict

staticmethod source
from_dict(d: Mapping[str, Any]) -> Calibrator

Rebuild whichever calibrator d describes.

The dispatcher that makes a fitted calibrator round-trip through an eval report — which is how a calibration fitted last month gets applied to this month's run.

d
a mapping from any calibrator's to_dict, tagged with kind: "platt", "isotonic", or "identity"
returns
the reconstructed calibrator
raises ConfigError
kind is missing or unrecognised

IdentityCalibrator

class public API source
IdentityCalibrator(Calibrator)

Pass scores through unchanged. The honest default before fitting.

Methods

IdentityCalibrator.fit

method source
fit(self, scores: Sequence[float], labels: Sequence[bool]) -> IdentityCalibrator

Nothing to fit. Returns self.

scores
ignored
labels
ignored
returns
self, unchanged. The point of this class is to be a drop-in that documents "no calibration was applied" instead of leaving calibrator=None ambiguous.

IdentityCalibrator.predict

method source
predict(self, score: float) -> float

The score itself, clamped to [0, 1].

score
a fused confidence
returns
the same value, clamped

IdentityCalibrator.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

PlattCalibrator

class public API source
PlattCalibrator(Calibrator)

Platt scaling: fit sigmoid(a * logit(s) + b) by log-loss.

Two parameters, so it works from a few hundred labelled fields — which is realistic for a first calibration set. Fitted on the logit of the raw score rather than the score itself, so a perfectly calibrated input is recovered as the identity (a=1, b=0) instead of being squashed.

Methods

PlattCalibrator.__init__

method source
__init__(self, a: float = 1.0, b: float = 0.0) -> None

Start from a, b — the defaults are the identity mapping.

a
slope applied to the logit of the raw score. 1.0 with b=0.0 is the identity, so an unfitted calibrator changes nothing.
b
intercept in logit space; negative shifts scores down, which is what fitting produces when the raw scores are overconfident.

PlattCalibrator.fit

method source
fit(self, scores: Sequence[float], labels: Sequence[bool], iterations: int = 400,
        learning_rate: float = 0.25) -> PlattCalibrator

Fit a and b by gradient descent on the log-loss.

Targets are smoothed by Platt's correction so a perfectly separable set cannot drive the weights to infinity.

scores
raw fused confidences in 0.0..1.0
labels
whether each prediction was actually correct
iterations
full-batch gradient steps. 400 converges on the few-hundred-sample sets this is meant for; raising it is cheap but rarely changes the fit.
learning_rate
step size. 0.25 is stable for this loss — larger values oscillate, and the fit silently ends up worse rather than failing.
returns
self, fitted

PlattCalibrator.predict

method source
predict(self, score: float) -> float

sigmoid(a * logit(score) + b).

score
a fused confidence in 0.0..1.0
returns
the calibrated probability, rounded to 4 places. Monotone in score, so calibration never reorders two fields — it only changes what the numbers mean.

PlattCalibrator.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict, including the sample count it was fitted on.

PlattCalibrator.from_dict

staticmethod source
from_dict(d: Mapping[str, Any]) -> PlattCalibrator

Rebuild from to_dict output.

A staticmethod, not a classmethod, so that it matches from_dict — the base declares the dispatcher every calibrator must answer to, and a classmethod would take an extra positional parameter it does not have.

d
a mapping from to_dict, with a, b and n
returns
the reconstructed calibrator; missing keys fall back to the identity mapping rather than raising

IsotonicCalibrator

class public API source
IsotonicCalibrator(Calibrator)

Isotonic regression via pool-adjacent-violators.

Non-parametric, so it fixes arbitrary miscalibration shapes — including the common one where a pipeline is well calibrated in the middle and wildly overconfident at the top. Needs more labelled data than Platt scaling and will overfit a small set, so fit requires a minimum sample size rather than silently producing a step function.

Methods

IsotonicCalibrator.__init__

method source
__init__(self, thresholds: Optional[Sequence[float]] = None,
             values: Optional[Sequence[float]] = None) -> None

Start from an optional fitted step function.

Normally built by fit or from_dict rather than directly.

thresholds
ascending score breakpoints of the step function
values
the calibrated probability for each breakpoint, one per entry in thresholds. Both empty gives an unfitted calibrator that passes scores through.

IsotonicCalibrator.fit

method source
fit(self, scores: Sequence[float], labels: Sequence[bool],
        min_samples: int = 50) -> IsotonicCalibrator

Fit a monotone step function by pool-adjacent-violators.

scores
predicted confidences in 0.0..1.0
labels
whether each prediction was actually correct
min_samples
refuse to fit below this many samples rather than overfit. 50 is already optimistic for a free-form step function; a few hundred is where it starts beating Platt scaling.
returns
self, fitted
raises ConfigError
fewer than min_samples samples were supplied. The message points at PlattCalibrator, whose two parameters survive a small set.

IsotonicCalibrator.predict

method source
predict(self, score: float) -> float

The fitted step at score; the raw score when unfitted.

score
a raw fused confidence in 0.0..1.0
returns
the calibrated probability, rounded to 4 places. Piecewise constant, so nearby raw scores can map to the same output — the price of not assuming a parametric shape.

IsotonicCalibrator.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict holding the full step function.

IsotonicCalibrator.from_dict

staticmethod source
from_dict(d: Mapping[str, Any]) -> IsotonicCalibrator

Rebuild from to_dict output.

A staticmethod for the same reason as from_dict: it has to match the dispatcher declared on Calibrator.

d
a mapping from to_dict, with thresholds, values and n
returns
the reconstructed calibrator; without thresholds it is unfitted and passes scores through

12. Layer 4 — schema-driven extraction with confidence and provenance

A domain project declares a schema and its business rules. Everything from here to the typed result — prompt assembly, chunking, lenient parsing, coercion, provenance recovery, confidence fusion — is substrate.

FieldSpec

classdataclass public API source

One extractable field: its name, declared type, and whether it is required.

Fields

name: str
type_name: str = 'string'
description: str = ''
required: bool = True
item_type: Optional[str] = None
path: str = ''

Methods

FieldSpec.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

_json_type_for

func source
_json_type_for(annotation: Any) -> Tuple[str, bool, Optional[str]]

(json_type, required, item_type) for a Python annotation.

SchemaAdapter

class public API source

Bridge between a project's schema and this library's extraction machinery.

Pydantic v1, Pydantic v2, dataclasses and plain dict specifications are all accepted, and all reduce to JSON Schema plus a flat field list. Supporting four shapes is not indulgence: the whole point is that a consuming project should not have to adopt this library's idea of a model in order to use its substrate.

Methods

SchemaAdapter.__init__

method source
__init__(self, schema: SchemaLike, json_schema: Dict[str, Any],
             fields: List[FieldSpec], name: str = "Extraction") -> None

Hold a schema alongside its normalised form.

Built by from_schema rather than directly — it is the classmethod that knows how to reduce each supported schema shape to these arguments.

schema
the project's original schema object, retained so that build can instantiate it. None means "dict only", and building then returns a plain dict.
json_schema
the JSON Schema sent to the model in the prompt
fields
the flat FieldSpec list used for coercion and per-field confidence
name
label for the schema, used in prompts and reports

SchemaAdapter.from_schema

classmethod source
from_schema(cls, schema: SchemaLike) -> SchemaAdapter

Adapt any supported schema shape. The one entry point.

Accepts a pydantic v1 or v2 model, a dataclass, a {field: type} dict, or an existing adapter (returned unchanged). Anything else raises ConfigError naming the shapes that do work.

schema
one of —
  • a pydantic model class (v1 or v2), which carries descriptions and required-ness and so gives the model the most to work with;
  • a dataclass, whose annotations become the field types;
  • a {field: type} dict such as {"invoice_no": str, "total": float, "items": list}, the quickest way to try something;
  • an existing SchemaAdapter, returned unchanged so callers can pass either without checking.
returns
an adapter exposing json_schema and fields
raises ConfigError
the object is none of the above

SchemaAdapter._from_json_schema

classmethod source
_from_json_schema(cls, schema: SchemaLike, json_schema: Dict[str, Any],
                      name: str) -> SchemaAdapter

Adapt a model that can already emit JSON Schema (pydantic v1/v2).

anyOf/oneOf branches — how pydantic renders Optional[X] — are collapsed to their first non-null type, and date formats are lifted into a date type so coercion knows to parse them.

SchemaAdapter._from_dataclass

classmethod source
_from_dataclass(cls, schema: SchemaLike) -> SchemaAdapter

Adapt a dataclass, deriving JSON Schema from its annotations.

A field with a default (or a default factory) is treated as optional even when its annotation is not Optional, matching what the author meant.

SchemaAdapter._from_dict_spec

classmethod source
_from_dict_spec(cls, spec: Dict[str, Any]) -> SchemaAdapter

{"total": "number", "patient": {"type": "string", "description": ...}}

SchemaAdapter.field

method source
field(self, name: str) -> Optional[FieldSpec]

The spec for name, or None when the schema has no such field.

name
the field name to look up
returns
its FieldSpec, or None. None is normal, not an error: a model may return a field the schema never asked for, and that value is coerced as a string rather than dropped.

SchemaAdapter.build

method source
build(self, data: Mapping[str, Any]) -> Any

Instantiate the project's schema from raw extracted data.

Falls back to returning the coerced dict when the schema cannot be instantiated — a validation failure must not lose the extraction, it must be reported alongside it, because a reviewer can still use eleven correct fields out of twelve.

data
already-coerced field values
returns
an instance of the project's schema — a pydantic model, a dataclass instance, or a plain dict when the adapter was built from a dict spec. Dataclass construction drops keys the dataclass does not declare.
raises ExtractionError
the schema rejected the data. extract catches this and records it as a warning, so the per-field results survive even when the object cannot be built.

SchemaAdapter.coerce

method source
coerce(self, name: str, value: Any) -> Any

Convert a raw JSON value to the declared type, tolerantly.

name
the field the value belongs to; an unknown one is coerced as a string rather than rejected
value
the raw value as the model returned it
returns
the coerced value, or None when it could not be coerced — which confidence fusion then scores as a format mismatch

coerce_value

func public API source
coerce_value(value: Any, type_name: str, item_type: Optional[str] = None) -> Any

Coerce a model's JSON output into the declared type.

Tolerant on purpose: a model asked for a number will sometimes answer "Rs. 48,250.00", and rejecting that loses a correct extraction over a formatting preference. Returns None when the value cannot be coerced, which the confidence machinery then scores as a format mismatch.

value
the raw JSON value. None passes straight through, since "the field is absent" is different from "the field would not parse".
type_name
the JSON Schema type to coerce to — "string", "number", "integer", "boolean", "date", "array", or "object". Unknown names are treated as "string".
item_type
element type for "array", applied to each entry. A non-list value under "array" is wrapped in a one-element list, since a model asked for a list of one item routinely returns the item.
returns
the coerced value, or None when coercion failed

What tolerance buys:

coerce_value("Rs. 48,250.00", "number")   # -> 48250.0
coerce_value("1,20,000", "number")        # -> 120000.0  (lakh grouping)
coerce_value("15/03/2024", "date")        # -> "2024-03-15"
coerce_value("yes", "boolean")            # -> True
coerce_value("not a number", "number")    # -> None

DEFAULT_EXTRACTION_SYSTEM

const public API source
DEFAULT_EXTRACTION_SYSTEM = 'You extract structured data from documents that have been scanned and read by OCR, so t...

The system prompt for extraction. Every line here exists to suppress an observed failure: inventing values for absent fields, silently normalising numbers, translating Indian date order, and answering in prose.

format_document_text

func public API source
format_document_text(doc: Document, include_page_markers: bool = True,
                         max_chars: int = 0) -> str

Render a document's text for a prompt, with page markers.

Page markers are not decoration: they are what lets an extracted value be traced back to a page when the model is asked to cite one, and what keeps a 40-page bundle's fields from being attributed to page 1.

doc
a document that has been read. Pages with no text are skipped entirely rather than emitting an empty marker.
include_page_markers
prefix each page with —- page N —-, numbered from 1 to match what a human sees. Turn it off only when feeding a consumer that cannot tolerate the markers.
max_chars
truncate at this many characters; 0 means no limit. Truncation prefers a whole-page boundary, falling back to a partial page only when the remaining room is large enough to be worth using.
returns
the rendered text, pages separated by blank lines

build_extraction_prompt

func public API source
build_extraction_prompt(adapter: SchemaAdapter, document_text: str, context: str = "",
                            extra_instructions: str = "") -> str

Assemble the user prompt: schema, domain context, then document text.

The order is deliberate. The schema comes first so the model knows what it is looking for before it reads, and the document goes last inside a fence so that instructions embedded in a scanned page read as data rather than as something to obey.

adapter
the schema, whose json_schema is embedded verbatim
document_text
the page text, normally produced by format_document_text
context
domain hints, e.g. "Indian private hospital bill, INR". Omitted from the prompt entirely when empty.
extra_instructions
run-specific notes, appended after the context and also omitted when empty
returns
the complete user prompt, ending with the instruction to reply with JSON only

chunk_pages

func public API source
chunk_pages(doc: Document, max_chars: int = 60000,
                overlap_pages: int = 1) -> List[Document]

Split a document into page-aligned chunks that fit a context window.

Splitting on page boundaries rather than character counts keeps tables and totals intact, and the one-page overlap catches the field that straddles a page break — which is exactly where discharge dates and totals live.

doc
the document to split
max_chars
character budget per chunk. 60000 fits current context windows with room for the schema and the reply. 0 or less disables chunking and returns the document whole.
overlap_pages
pages repeated at the start of each subsequent chunk. 1 catches the straddling field; 0 disables the overlap and is right only when pages are genuinely independent. Overlap costs tokens at every boundary, which is why it is one page and not three.
returns
one or more Document chunks sharing the original's source_uri and meta. Always at least one, so callers need no empty-case branch. A single page longer than max_chars is never split, since splitting mid-table is worse than one oversized call.

LLMClient

class public API source
LLMClient(Protocol)

Anything that can turn a prompt into text and report what it cost.

Methods

LLMClient.complete

method source
complete(self, prompt: str, system: Optional[str] = None,
             images: Optional[Sequence[bytes]] = None) -> Tuple[str, Cost]

Complete prompt and report what it cost.

prompt
the fully assembled user prompt
system
system instructions, when the provider supports them
images
JPEG page images to send alongside the prompt
returns
(text, cost)

BaseLLMClient

class public API source

Shared plumbing for model clients.

Methods

BaseLLMClient.__init__

method source
__init__(self, model: str = "", max_tokens: int = 4096,
             temperature: float = 0.0, **options: Any) -> None

Configure the model call.

model
provider model id — also the pricing key
max_tokens
ceiling on the response
temperature
0.0 — extraction is not a creative task
options
passed through to the underlying SDK

BaseLLMClient.is_available

method source
is_available(self) -> bool

Whether this client can be used right now. True by default.

BaseLLMClient.complete

method source
complete(self, prompt: str, system: Optional[str] = None,
             images: Optional[Sequence[bytes]] = None) -> Tuple[str, Cost]

Complete prompt. Subclasses implement this.

prompt
the fully assembled user prompt
system
system instructions, when the provider supports them
images
JPEG page images to send alongside the prompt
returns
(text, cost)
raises NotImplementedError
always — this is the extension point

EchoClient

class public API source
EchoClient(BaseLLMClient)

A deterministic test double.

Returns a canned response, or one produced by a callable given the prompt. Having this in the library rather than in a test file is deliberate: consuming projects need to test their schemas and validators without hitting a paid API, and every one of them would otherwise write it again.

Methods

EchoClient.__init__

method source
__init__(self, response: Union[str, Mapping[str, Any], Callable[[str], Any]] = "{}",
             cost: Optional[Cost] = None, record: bool = True, **options: Any) -> None

Configure the double.

response
what to return — a JSON string ('{"total": 100}'), a mapping ({"total": 100}, encoded for you), or a callable fn(prompt) -> str | mapping, which is how you make the double answer differently per chunk or assert on what it was asked.
cost
the Cost to report per call, for exercising budget and pricing logic. Defaults to one call at zero money.
record
keep every prompt in self.prompts, so a test can assert on what the pipeline actually asked for

EchoClient.complete

method source
complete(self, prompt: str, system: Optional[str] = None,
             images: Optional[Sequence[bytes]] = None) -> Tuple[str, Cost]

Return the canned response (JSON-encoded if it is not a string).

prompt
recorded on self.prompts when record is set, and passed to response when that is a callable
system
accepted and ignored — the double does not model system instructions
images
accepted and ignored
returns
(text, cost) with the configured cost. Deterministic by construction, which is what makes accuracy assertions in the test suite exact rather than plausible.

AnthropicClient

class public API source
AnthropicClient(BaseLLMClient)

Claude, via the anthropic SDK. Text and/or page images.

Methods

AnthropicClient.__init__

method source
__init__(self, model: str = "claude-sonnet-5",
             api_key: Optional[str] = None, client: Any = None,
             **options: Any) -> None

Configure the Claude client.

model
Claude model id, e.g. "claude-sonnet-5". Also the pricing key — see set_pricing.
api_key
falls back to the ANTHROPIC_API_KEY environment variable when None
client
a pre-built SDK client, injected instead of constructed

AnthropicClient.is_available

method source
is_available(self) -> bool

True with an injected client, or with the SDK plus an API key.

AnthropicClient._get_client

method source
_get_client(self) -> Any

Construct the SDK client once, under the lock.

AnthropicClient.complete

method source
complete(self, prompt: str, system: Optional[str] = None,
             images: Optional[Sequence[bytes]] = None) -> Tuple[str, Cost]

One Messages API call, with any page images attached first.

prompt
the fully assembled user prompt
system
system instructions, when the provider supports them
images
JPEG page images sent alongside the prompt, for pages OCR handled badly. Ignored by providers without vision.
returns
(text, cost) — the raw completion, and what it billed as a Cost. The text goes through parse_json_lenient, so a fenced code block or a prose preamble is tolerated.

OpenAIClient

class public API source
OpenAIClient(BaseLLMClient)

GPT models via the openai SDK.

Methods

OpenAIClient.__init__

method source
__init__(self, model: str = "gpt-4o", api_key: Optional[str] = None,
             client: Any = None, **options: Any) -> None

Configure the OpenAI client.

model
OpenAI model id, e.g. "gpt-4o". Also the pricing key.
api_key
falls back to the OPENAI_API_KEY environment variable when None
client
a pre-built SDK client, injected instead of constructed — for a proxy, custom transport, or a test double
options
forwarded to BaseLLMClientmax_tokens, temperature

OpenAIClient.is_available

method source
is_available(self) -> bool

True with an injected client, or with the SDK plus an API key.

OpenAIClient._get_client

method source
_get_client(self) -> Any

Construct the SDK client once, under the lock.

OpenAIClient.complete

method source
complete(self, prompt: str, system: Optional[str] = None,
             images: Optional[Sequence[bytes]] = None) -> Tuple[str, Cost]

One chat-completions call, with any page images attached.

prompt
the fully assembled user prompt
system
system instructions, when the provider supports them
images
JPEG page images sent alongside the prompt, for pages OCR handled badly
returns
(text, cost) — the raw completion, and what it billed as a Cost. The text goes through parse_json_lenient, so a fenced code block or a prose preamble is tolerated.

parse_json_lenient

func public API source
parse_json_lenient(text: Optional[str]) -> Any

Recover a JSON object from a model's reply.

Models wrap JSON in fences, prefix it with "Here is the extracted data:", leave trailing commas, and occasionally emit NaN. Failing the whole extraction over any of that would be throwing away a correct answer on a presentation detail, so this walks through progressively more forgiving strategies and raises only when nothing parses.

text
the model's raw reply. None and empty both raise, since there is a real difference between "the model said nothing" and "the model said something unparseable".
returns
the decoded object — usually a dict, sometimes a list when the model wrapped its answer in one
raises ExtractionError
no strategy recovered valid JSON. extract catches this per chunk and records a warning, so one unparseable chunk does not lose the others.

_drop_non_finite

func source
_drop_non_finite(value: Any) -> Any

Replace NaN and +/-Infinity with None, recursively.

Python's json accepts these as float literals, so they survive parsing and then poison everything downstream: Decimal(nan) is a valid object, nan != nan breaks every comparison, and the value serialises back out as invalid JSON. A missing value is the honest reading of NaN anyway.

_first_balanced_json

func source
_first_balanced_json(text: str) -> Optional[str]

Slice out the first balanced {...} or [...], ignoring braces in strings.

_repair_json

func source
_repair_json(text: str) -> str

Fix the small syntax errors models actually make.

Evidence

classdataclass public API source

Where a value was found, and how well it matched.

Fields

bbox: BBox
score: float
text: str = ''
source: str = ''

Methods

Evidence.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

_DATE_RENDERINGS

const source
_DATE_RENDERINGS = ('%d-%m-%Y', '%Y-%m-%d', '%m-%d-%Y', '%d-%b-%Y', '%b-%d-%Y', '%d-%B-%Y', '%d-%m-%y')

Renderings a date may plausibly have on the page. After _compare_key strips separators these collapse to a handful of distinct keys.

_comparison_keys

func source
_comparison_keys(value: Any) -> List[str]

Every form value might plausibly take on the page.

A value's extracted representation legitimately differs from its printed one for exactly two types. Numbers are printed with currency symbols and grouping (Rs 1,18,250.00) but extracted as 118250.00. Dates are printed 12-04-2024 but — because the extraction prompt asks for it — come back as 2024-04-12. Without this, the pipeline instructs the model to normalise and then penalises it for having done so, scoring every correctly-read date as though it were a hallucination.

locate_value

func public API source
locate_value(doc: Document, value: Any, min_score: float = 0.72,
                 max_span_window: int = 8,
                 pages: Optional[Sequence[int]] = None) -> List[Evidence]

Find where on which page a value appears, returning bounding boxes.

Every extracted fact came from a specific rectangle on a specific page. If that link is dropped during extraction it cannot be rebuilt later, and with it goes the ability to show a reviewer or an auditor why we believe something — so it is recovered here even when the reading backend gave only page-level geometry.

Matching is fuzzy and window-based: a value is compared against every run of up to max_span_window consecutive spans on a line, because "1,23,456.78" may arrive as three separate word spans. Numeric values are additionally matched on their digits alone, so "Rs 48,250.00" is found for 48250.

Returns the best matches sorted by score, one per page at most.

doc
a document that has been read, so its pages carry spans
value
the extracted value to locate. Numbers and dates are matched in every form they might be printed in, so 48250 finds "Rs 48,250.00" and "2024-04-12" finds "12-04-2024". None returns no evidence.
min_score
least similarity for a match to count, in 0.0..1.0. 0.72 tolerates the usual OCR damage while rejecting coincidence. Raise it towards 0.85 when values are long and distinctive, lower it only if you would rather have approximate evidence than none.
max_span_window
how many consecutive spans on a line may be joined to form a candidate. 8 covers "Rs", "1,23,456.78" split across word spans, and a label sharing the line. Raising it costs time quadratically and invites spurious matches.
pages
restrict the search to these page indices; None searches every page. Worth passing when the model reported which page it read a value from — it is faster and avoids matching a repeated header.
returns
the best Evidence per page scoring at least min_score, sorted best first. Empty when nothing matched, which is itself a signal — confidence fusion scores an unlocatable value lower.

_group_spans_into_lines

func source
_group_spans_into_lines(page: Page,
                            y_tolerance: Optional[float] = None) -> List[List[TextSpan]]

Spans grouped into visual lines, left to right, top to bottom.

Backend-reported line ids are used when available, but keyed on (block, line) rather than line alone. PyMuPDF — and every other engine with a block/line/word hierarchy — restarts its line counter inside each block, so grouping on the line index by itself silently welds the first line of every block on the page into one enormous line. That corrupts reading order and defeats provenance lookup, which searches within lines.

backend_confidence_for

func public API source
backend_confidence_for(doc: Document, evidence: Sequence[Evidence]) -> Optional[float]

Mean backend-reported confidence over the spans supporting a value.

Returns None when no supporting span carries a confidence, which keeps "the engine declined to say" distinct from "the engine said zero".

doc
the document the evidence points into
evidence
spans of evidence, as returned by locate_value
returns
the mean confidence over overlapping spans, rounded to 4 places; or None when no supporting span reported one. Vision backends report nothing here, which is why cross-read agreement carries the weight for them.

ValidationIssue

classdataclass public API source

A business-rule failure, optionally attributed to specific fields.

Fields

message: str
fields: List[str] = field(default_factory=list)
severity: str = 'error'
"error" or "warning"

Methods

ValidationIssue.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

Validators

class public API source

Domain-independent validators, as factories.

Each returns a Validator closure, so a schema declares its checks as data. Only structural invariants that hold for any document type belong here — arithmetic consistency, date ordering, a format match. Anything that knows what a field means belongs in the consuming project.

Methods

Validators.run_validators

staticmethod source
run_validators(model: Any, validators: Sequence[Validator]) -> List[ValidationIssue]

Run business rules, collecting issues.

A validator may return None, a string, a ValidationIssue, or a list of those. A validator that raises is itself reported as an issue rather than aborting extraction: a buggy rule must not cost you the document.

model
the built schema object to validate
validators
the rules to run. Each takes the model and returns None (passed), a string (an error), a ValidationIssue, or a list of those.
returns
every issue raised, in validator order. A validator that raised contributes one issue of severity "warning" naming the exception.

Validators.line_items_sum_to_total

staticmethod source
line_items_sum_to_total(items_field: str = "line_items", amount_field: str = "amount",
                            total_field: str = "total",
                            tolerance: decimal.Decimal = decimal.Decimal("0.02"),
                            relative_tolerance: float = 0.0) -> Validator

Validator factory: line items must add up to the stated total.

The single most valuable check on a bill, because it is independent of the reading: an OCR error in any one amount breaks the sum, which is why this feeds confidence fusion rather than just producing a warning.

items_field
attribute or key holding the line items, e.g. "line_items" or "charges"
amount_field
the per-item amount attribute, e.g. "amount"
total_field
the stated document total, e.g. "total" or "net_payable"
tolerance
absolute slack, as a Decimal. 0.02 absorbs two paise of rounding. Decimal rather than float on purpose — money compared in binary floating point produces failures nobody can reproduce.
relative_tolerance
extra slack proportional to the total, so 0.005 allows half a percent. Use it when the document rounds its own total, and keep it at 0.0 otherwise: a percentage tolerance on a large bill hides real errors.
returns
a Validator closure. It passes quietly when either field is missing, since "this document has no line items" is not an arithmetic failure.

Validators.date_order

staticmethod source
date_order(earlier_field: str, later_field: str, allow_equal: bool = True) -> Validator

Validator factory: one date must not precede another.

Catches the digit-transposition errors OCR makes on dates, which are otherwise invisible — 2024 misread as 2074 is still a valid date.

earlier_field
the field that should come first, e.g. "admission_date"
later_field
the field that should come second, e.g. "discharge_date"
allow_equal
treat equal dates as valid. True is right for same-day admission and discharge; set False when the two genuinely cannot coincide.
returns
a Validator closure, passing quietly when either date is missing. Both values must be comparable — coerce them to dates first via a date-typed schema field.

Validators.field_matches

staticmethod source
field_matches(field_name: str, pattern: str,
                  message: Optional[str] = None) -> Validator

Validator factory: a field must match a regular expression.

The cheapest way to catch a value the model invented in the right shape but the wrong place — a GSTIN that is 14 characters, an invoice number missing its prefix.

field_name
the field to check
pattern
a regular expression, applied with re.search so it need not match the whole value. Anchor it with ^...$ when it should, e.g. "^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9]Z[A-Z0-9]$" for a GSTIN.
message
the issue text on failure; None generates one naming the field and the pattern
returns
a Validator closure, passing quietly when the field is absent — absence is a completeness question, not a format one

Validators.required_fields

staticmethod source
required_fields(*names: str) -> Validator

Validator factory: these fields must be present and non-empty.

_get_attr

func source
_get_attr(obj: Any, name: str) -> Any

Attribute or key access, whichever the object supports.

FieldResult

classdataclass public API source
FieldResult(Generic[T])

One extracted field, wrapped in everything needed to judge it.

Fields

name: str
value: Optional[T] = None
confidence: float = 0.0
evidence: List[BBox] = field(default_factory=list)
method: str = ''
signals: Dict[str, Optional[float]] = field(default_factory=dict)
raw: Any = None
warnings: List[str] = field(default_factory=list)

Methods

FieldResult.pages

property source
pages(self) -> List[int]

Page indices this value has evidence on, ascending.

FieldResult.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict, including the individual confidence signals.

Extraction

classdataclass public API source
Extraction(Generic[T])

The result of extracting a schema from a document.

Fields

model: Optional[T] = None
fields: Dict[str, FieldResult] = field(default_factory=dict)
document: Optional[Document] = None
cost: Cost = field(default_factory=Cost)
issues: List[ValidationIssue] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
raw_response: str = ''
latency_ms: float = 0.0

Methods

Extraction.__getitem__

method source
__getitem__(self, name: str) -> FieldResult

result["total"] — the FieldResult. Raises on unknown names.

name
a schema field name
returns
its FieldResult, carrying value, confidence and evidence together
raises KeyError
no such field. Use value when a missing field should be a default rather than an error.

Extraction.value

method source
value(self, name: str, default: Any = None) -> Any

The extracted value for name, or default if missing or null.

Deliberately conflates "absent" and "null": both mean the document did not state it. Use name in result.fields to tell them apart.

name
a schema field name
default
returned when the field is absent or its value is None
returns
the coerced value, already converted to the declared type

Extraction.confidence

method source
confidence(self, name: str) -> float

Confidence for name, or 0.0 when the field is absent.

name
a schema field name
returns
the fused confidence in 0.0..1.0, or 0.0 for an unknown field. Uncalibrated unless a calibrator was fitted on your own labelled data — it ranks reliably, but it is not a probability.

Extraction.mean_confidence

property source
mean_confidence(self) -> float

Mean confidence over the fields that actually got a value.

Null fields are excluded rather than scored zero: "not present on this document" is a legitimate answer, not a low-confidence one.

Extraction.low_confidence

method source
low_confidence(self, threshold: float = 0.7) -> List[FieldResult]

Fields a human should look at. The point of the whole exercise.

threshold
confidence below which a field is queued for review. 0.7 is a starting point, not a recommendation — pick it from your own reliability curve, where regression_vs and reliability_curve tell you what a given score actually buys on your documents.
returns
the qualifying FieldResult objects, least confident first, so a review queue is already in priority order

Extraction.is_valid

property source
is_valid(self) -> bool

True when no validator reported an error (warnings are allowed).

Extraction.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict of the whole result, fields and all.

merge_extracted_data

func public API source
merge_extracted_data(chunks: Sequence[Mapping[str, Any]]) -> Dict[str, Any]

Merge per-chunk extractions into one.

First non-null wins for scalars (earlier pages hold headers and identifiers); lists are concatenated with duplicates dropped, because line items are spread across pages and each chunk sees only its own.

chunks
the parsed object from each chunk, in document order. Order matters: "first non-null wins" makes the earliest chunk authoritative for scalars, which is why chunks must not be reordered or run out-of-sequence.
returns
one merged dict. Duplicate list entries are dropped by comparing their JSON encoding, so the page repeated by overlap_pages does not double every line item it carried.

extract

func public API source
extract(doc: Document, schema: SchemaLike, context: str = "",
            client: Optional[LLMClient] = None,
            validators: Optional[Sequence[Validator]] = None,
            system: str = DEFAULT_EXTRACTION_SYSTEM, extra_instructions: str = "",
            max_chars: int = 60000, overlap_pages: int = 1, include_images: bool = False,
            weights: Optional[Mapping[str, float]] = None,
            calibrator: Optional[Calibrator] = None, locate_evidence: bool = True,
            min_evidence_score: float = 0.72, retries: int = 2) -> Extraction

Extract schema from doc, with per-field confidence and provenance.

This is the whole domain-side integration surface. A project supplies its schema, its validators and a context string; everything else — prompting, chunking, parsing, coercion, evidence recovery, confidence fusion — is library.

doc
a document that has already been read, so its pages carry spans — see read. An unread document yields empty prompt text and no evidence to locate values against.
schema
a pydantic model, a dataclass, or a {field: type} dict such as {"invoice_no": str, "total": float}. A pydantic model gives the model the most to work with, since field descriptions reach the prompt. See from_schema.
context
domain hints pasted into the prompt, e.g. "Indian private hospital bill, INR". The cheapest accuracy in the library — it is what disambiguates lakh grouping and local date order.
client
an LLMClient — one of AnthropicClient, or OpenAIClient, or EchoClient in tests. Required.
validators
business rules run over the built object. Each outcome feeds the confidence of the fields it names, so a failing sum lowers confidence on the amounts rather than only logging a complaint.
system
system prompt; defaults to DEFAULT_EXTRACTION_SYSTEM. Replace it only if you know what you are giving up — it is what keeps the model from inferring values that are not on the page.
extra_instructions
appended to the user prompt, after the schema and the context. For per-run notes ("this batch has two bills per page"), not for domain vocabulary, which belongs in context.
max_chars
characters of document text per model call. 60000 is a safe fit for current context windows with room for the schema. A document above it is split into page-aligned chunks and the results merged; a warning records that this happened.
overlap_pages
pages repeated at each chunk boundary, so a field straddling a page break is not lost. 1 is usually enough; 0 disables it and is only right when pages are known to be independent.
include_images
also send up to 8 page rasters alongside the text. Worth it for pages OCR handled badly, and it costs vision tokens on every call — so it is off by default rather than "on just in case".
weights
override the confidence fusion weights; None uses the defaults in DEFAULT_CONFIDENCE_WEIGHTS. The signals and what they mean are set out in fuse_confidence.
calibrator
maps fused scores onto calibrated probabilities. Without one, confidence ranks correctly but is not a probability — 0.9 does not mean 90% right. Fit a PlattCalibrator on your own labelled set to change that.
locate_evidence
search the document for each value to recover its bounding box. Leave it on: provenance is unrecoverable afterwards, and it is also a confidence signal. Turning it off saves time on very large documents at the cost of both.
min_evidence_score
least match score for evidence to count, in 0.0..1.0, passed to locate_value.
retries
extra attempts on transport or parse failure, so 2 means up to three tries. Configuration errors and missing dependencies are never retried.
returns
an Extraction carrying the built model, and one per-field FieldResult with its confidence and evidence, plus the accumulated cost, validation issues and warnings
raises ConfigError
no client was given

Confidence is fused from independent signals and is uncalibrated unless a calibrator fitted on your own labelled set is supplied. See fuse_confidence and EvalSuite.

_build_field_result

func source
_build_field_result(spec: FieldSpec, value: Any, raw: Any, doc: Document,
                        client_name: str, locate: bool, min_evidence_score: float,
                        failed: bool, any_error: bool,
                        weights: Optional[Mapping[str, float]],
                        calibrator: Optional[Calibrator]) -> FieldResult

Assemble one field's value, evidence, signals and fused confidence.

FieldRule

classdataclass public API source

A label-anchored extraction rule.

Deterministic, free, auditable, and often more accurate than a model on fixed-layout forms. It also makes an excellent second opinion: running a rule set alongside a model gives a genuine cross-read agreement signal at no marginal cost.

name
the field this rule fills
label
regex matching the printed label, e.g. r"total\s*amount"
value_pattern
regex for the value; the first group wins if present
direction
where to look relative to the label — right, below or same_line
type_name
declared type, used for coercion and format scoring

Fields

name: str
label: str
value_pattern: str = '(.+)'
direction: str = 'right'
type_name: str = 'string'
max_distance_pt: float = 260.0
occurrence: int = 0

Methods

FieldRule.compiled_label

method source
compiled_label(self) -> Pattern

The label pattern, compiled case-insensitively.

FieldRule.compiled_value

method source
compiled_value(self) -> Pattern

The value pattern, compiled case-insensitively.

extract_with_rules

func public API source
extract_with_rules(doc: Document, rules: Sequence[FieldRule]) -> Dict[str, FieldResult]

Extract fields by anchoring on printed labels. No model, no network.

The deterministic alternative to extract, for fields that sit beside a fixed printed label. Free, instant and exactly repeatable — but it only works where the layout is stable, so the usual arrangement is rules for the reliable fields and a model for the rest.

doc
a document that has been read, so its pages carry spans
rules
the label-anchored rules to apply; see FieldRule
returns
a FieldResult per rule, keyed by rule.name. Every rule produces an entry: one that matched nothing reports that in its warnings and carries a None value, so a caller can tell "absent" from "never looked for".

_apply_rule

func source
_apply_rule(doc: Document, rule: FieldRule) -> FieldResult

Apply one label-anchored rule across the document.

Scans lines for the label, then reads the value from beside or below it, honouring rule.occurrence when a label repeats (a per-page "Total" on a multi-page bill). Always returns a FieldResult — a rule that matched nothing reports that in warnings rather than raising.

_approx_x_for_offset

func source
_approx_x_for_offset(line_spans: Sequence[TextSpan], offset: int) -> float

Approximate the x coordinate of a character offset within a joined line.

13. Layer 5 — the evaluation harness

If only one component of this library is built, it should be this one.

Without a harness, "did that change help?" is answered by eyeballing a handful of documents. That is not a measurement. Every accuracy claim is anecdote, regressions from a model version changing underneath you are invisible, and confidence cannot be calibrated at all.

It also makes the labelled datasets themselves shared, versioned assets — and ground-truth labelling is the single most expensive input to this kind of work. Doing it once per document class and reusing it across projects and across model migrations is a large and permanent saving.

EvalCase

classdataclass public API source

One labelled document: the file, the truth, and how it is grouped.

Fields

case_id: str
path: str
truth: Dict[str, Any] = field(default_factory=dict)
tags: List[str] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict)

Methods

EvalCase.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict.

FieldOutcome

classdataclass public API source

How one field of one document scored under one pipeline.

Fields

case_id: str
pipeline: str
field: str
expected: Any = None
predicted: Any = None
exact: bool = False
fuzzy: float = 0.0
within_tolerance: bool = False
confidence: float = 0.0
has_evidence: bool = False
page_verdict: str = 'unknown'
type_name: str = 'string'

Methods

FieldOutcome.correct

property source
correct(self) -> bool

Exact match, or numerically/date equal within tolerance.

FieldOutcome.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict, including the derived correct flag.

CaseResult

classdataclass public API source

Everything one pipeline produced for one case.

Fields

case_id: str
pipeline: str
outcomes: List[FieldOutcome] = field(default_factory=list)
cost: Cost = field(default_factory=Cost)
latency_ms: float = 0.0
error: str = ''
page_verdict: str = 'unknown'

Methods

CaseResult.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict of one case's outcomes, cost and timing.

compare_values

func public API source
compare_values(expected: Any, predicted: Any, type_name: str = "string",
                   numeric_tolerance: float = 0.005,
                   fuzzy_threshold: float = 0.92) -> Tuple[bool, float, bool]

Score a prediction against ground truth.

Returns (exact, fuzzy_similarity, within_tolerance).

Three numbers rather than one because they answer different questions. Exact match is what a downstream system needs. Fuzzy similarity shows whether a miss was a single transposed digit or complete garbage — the difference between a tuning problem and a broken pipeline. Tolerance matching is what a human reviewer would call correct.

expected
the ground-truth value. Two None values count as a correct match — the pipeline agreeing that a field is absent is right, not a miss.
predicted
what the pipeline produced
type_name
how to compare — "number", "integer", "amount", "date", "array", "object", or "string". Drives the comparison, so a truth value typed as a string is compared textually even when it looks numeric; that is what EvalSuite.field_types overrides.
numeric_tolerance
relative slack for numbers, so 0.005 allows half a percent. Applied to the larger magnitude of the pair.
fuzzy_threshold
similarity at or above which a string counts as within tolerance, in 0.0..1.0. 0.9 treats a single wrong character in a long value as close enough for a human.
returns
(exact, fuzzy_similarity, within_tolerance)

KNOWN_METRICS

const public API source
KNOWN_METRICS = ('field_exact', 'field_fuzzy', 'numeric_tolerance', 'field_recall', 'evidence_coverage',...

Metrics run knows how to compute.

EvalSuite

class public API source

A labelled dataset plus the machinery to run pipelines against it.

suite = dp.EvalSuite.from_dir("datasets/hospital_bills_v3")
report = suite.run({"baseline": pipeline_a, "candidate": pipeline_b})
print(report.summary())
report.regression_vs("reports/release-2026-08.json")

Methods

EvalSuite.__init__

method source
__init__(self, cases: Optional[Sequence[EvalCase]] = None, name: str = "",
             field_types: Optional[Mapping[str, str]] = None) -> None

Build a suite from cases already in memory.

cases
the labelled documents
name
shown in reports; from_dir defaults it to the folder
field_types
declared type per field name, overriding the type inferred from each ground-truth value — needed when a truth value is a string that should nonetheless be compared as a number or a date

EvalSuite.from_dir

classmethod source
from_dir(cls, directory: str, truth_suffix: str = ".json",
             name: str = "") -> EvalSuite

Load a dataset laid out as <stem>.pdf beside <stem>.json.

The JSON may be the truth mapping directly, or {"truth": {...}, "tags": [...]} when a case needs grouping metadata.

directory
folder holding the documents and their truth files
truth_suffix
extension identifying truth files. Everything with this suffix becomes a case; the document is whichever sibling shares the stem, found by extension.
name
label shown in reports; defaults to the folder's basename
returns
the loaded suite
raises ConfigError
the directory does not exist. A truth file with no matching document is recorded on the case rather than raising, so one stray file does not block the suite.

EvalSuite.add

method source
add(self, case: EvalCase) -> EvalSuite

Append a case. Returns self, so cases chain.

case
the labelled case to add
returns
self, so calls chain

EvalSuite.filter

method source
filter(self, predicate: Callable[[EvalCase], bool]) -> EvalSuite

A new suite holding only the cases satisfying predicate.

predicate
Callable[[EvalCase], bool], e.g. lambda c: "handwritten" in c.tags to measure a hard subset separately — an aggregate number hides exactly the regressions worth catching.
returns
a new suite sharing the surviving cases, keeping the original's name and field types

EvalSuite.__len__

method source
__len__(self) -> int

Number of labelled cases.

EvalSuite.run

method source
run(self, pipelines: Mapping[str, PipelineFn],
        metrics: Optional[Sequence[str]] = None, max_workers: int = 0,
        numeric_tolerance: float = 0.005,
        on_case: Optional[Callable[[str, str], None]] = None) -> EvalReport

Run each pipeline over every case and score the results.

A pipeline that raises on one document is recorded as an error for that case and the run continues — a suite that aborts on the first bad scan measures nothing.

pipelines
name to callable, each taking a document path and returning an Extraction. A configured Pipeline is already such a callable. Pass two to A/B them against identical cases::

report = suite.run({"baseline": current, "candidate": tuned})

metrics
which of KNOWN_METRICS to compute; None computes all of them.
max_workers
threads across cases. 0 runs serially. Cases are independent, so this scales well — but it multiplies your concurrent API calls by the same factor.
numeric_tolerance
relative slack for numeric comparison, passed to compare_values. 0.005 is half a percent.
on_case
called as on_case(pipeline_name, case_id) for progress reporting.
returns
an EvalReport holding every per-field outcome, not just the aggregates — which is what lets the CI gate of regression_vs name what broke.
raises ConfigError
a metric name is not in KNOWN_METRICS

EvalSuite._run_case

method source
_run_case(self, case: EvalCase, pipeline: PipelineFn, pipeline_name: str,
              numeric_tolerance: float) -> CaseResult

Run one pipeline over one case and score every truth field.

A pipeline that raises is captured into CaseResult.error with its latency intact, so the run continues and the failure still shows up in error_rate rather than vanishing.

_infer_type_name

func source
_infer_type_name(value: Any) -> str

Guess a field's type from its ground-truth value.

EvalReport

classdataclass public API source

Scored results for one or more pipelines over one suite.

Fields

suite: str = ''
metrics: List[str] = field(default_factory=list)
created_at: str = ''
case_count: int = 0
results: Dict[str, List[CaseResult]] = field(default_factory=dict)

Attributes

LOWER_IS_BETTER = frozenset(['confidence_calibration', 'brier', 'cost_per_doc', 'latency_p50', 'latency_p95', 'error_rate'])
Metrics that are lower-is-better. A rise in these is a regression.
RELATIVE_METRICS = frozenset(['cost_per_doc', 'latency_p50', 'latency_p95'])
Metrics that are unbounded (milliseconds, currency) rather than rates in [0, 1]. For these tolerance is read as a fraction, because an absolute tolerance that is sensible for an accuracy rate is meaningless for a latency — 0.05 ms of scheduler noise would fail every build.

Methods

EvalReport.pipelines

method source
pipelines(self) -> List[str]

Names of the pipelines in this report, sorted.

EvalReport.outcomes

method source
outcomes(self, pipeline: str) -> List[FieldOutcome]

Every field outcome for pipeline, across all cases.

pipeline
the pipeline name to pull results for
returns
every FieldOutcome from every case, flattened. Empty for an unknown name rather than raising, so a caller iterating over pipeline names cannot trip on one that produced nothing.

EvalReport.compute

method source
compute(self, pipeline: str) -> Dict[str, float]

All requested metrics for one pipeline.

pipeline
the pipeline name to score
returns
metric name to value, covering whichever entries of KNOWN_METRICS the run requested. cost_per_doc reads 0.0 until set_pricing is called, though token counts behind it are real.

EvalReport.by_field

method source
by_field(self, pipeline: Optional[str] = None) -> Dict[str, Dict[str, Any]]

Per-field accuracy — which fields degraded, not just the average.

pipeline
which pipeline's results to use; None takes the only one, which is unambiguous for a single-pipeline report

The aggregate hides everything that matters. A pipeline can gain two points overall while losing eight on the total amount, which is the only field anyone downstream actually cares about.

EvalReport.by_page_quality

method source
by_page_quality(self, pipeline: Optional[str] = None) -> Dict[str, Dict[str, Any]]

Accuracy split by page condition — did we only improve on clean pages?

pipeline
which pipeline's results to use; None takes the only one, which is unambiguous for a single-pipeline report

A change that lifts the average by improving pages that were already fine, while leaving the degraded ones untouched, has not solved the problem it was meant to solve. This is the split that catches that.

EvalReport.confidence_curve

method source
confidence_curve(self, pipeline: Optional[str] = None,
                     bins: int = 10) -> List[Dict[str, float]]

Is a 0.9 right 90% of the time? This is the answer.

pipeline
which pipeline's results to use; None takes the only one, which is unambiguous for a single-pipeline report
bins
equal-width confidence bins; 10 is conventional
returns
the reliability curve as computed by reliability_curve — one dict per bin with count, mean_confidence, accuracy and gap

EvalReport.fit_calibrator

method source
fit_calibrator(self, pipeline: Optional[str] = None,
                   kind: str = "platt") -> Calibrator

Fit a calibrator on this report, ready to pass to extract.

pipeline
which pipeline's results to use; None takes the only one, which is unambiguous for a single-pipeline report
kind
"platt" (default) or "isotonic". Platt fits two parameters and works from a few hundred labelled fields; isotonic assumes no shape but needs a few thousand not to overfit.
returns
a fitted Calibrator. Serialise it with to_dict and pass it to extract or Pipeline to make the confidence numbers mean what they say.
raises ConfigError
too few samples for the chosen kind

This closes the loop: measure, calibrate, ship the calibrator, and the confidence numbers a reviewer sees start meaning what they say.

EvalReport.errors

method source
errors(self, pipeline: Optional[str] = None) -> List[Tuple[str, str]]

(case_id, error) for every case the pipeline failed on.

pipeline
which pipeline's results to use; None takes the only one, which is unambiguous for a single-pipeline report
returns
(case_id, error_message) pairs. Empty is what you want; a non-empty list means those cases scored zero for reasons that have nothing to do with extraction accuracy.

EvalReport.worst_cases

method source
worst_cases(self, pipeline: Optional[str] = None,
                limit: int = 10) -> List[Tuple[str, float]]

Cases with the lowest field accuracy — where to look first.

pipeline
which pipeline's results to use; None takes the only one, which is unambiguous for a single-pipeline report
limit
how many to return, worst first
returns
(case_id, accuracy) pairs. Read these before tuning anything — an aggregate that moved usually moved because of a handful of documents with a shared cause.

EvalReport.compare

method source
compare(self, baseline_pipeline: str, candidate_pipeline: str) -> Dict[str, Any]

Metric deltas and per-field regressions between two pipelines.

baseline_pipeline
the name to measure against
candidate_pipeline
the name being evaluated
returns
a dict of metric deltas plus the per-field accuracy changes, signed so that positive means the candidate improved. Both names must be present in this report — comparing across two separate runs is regression_vs.

EvalReport.regression_vs

method source
regression_vs(self, baseline: Union[str, EvalReport],
                  pipeline: Optional[str] = None, tolerance: float = 0.0,
                  metrics: Optional[Sequence[str]] = None) -> Dict[str, Any]

Compare against a saved report — the CI gate.

baseline is a path to a report saved by save, or a report object. A metric that moved by less than tolerance is treated as noise rather than a regression.

tolerance is read as an absolute delta for rate metrics and as a fraction for the unbounded ones (see RELATIVE_METRICS), so a single number like 0.02 means "two accuracy points, or two percent slower" — both of which are what a person means by that number.

Timing metrics vary between runs on shared CI hardware regardless of the code. Pass metrics=["field_exact", "numeric_tolerance"] to gate on accuracy alone when that matters more than a stable wall clock.

baseline
a path to a report saved by save, or else an EvalReport already in memory
pipeline
which pipeline in this report to compare; None takes the first. The baseline is matched by the same name, falling back to its own first pipeline.
tolerance
movement treated as noise. Absolute for rate metrics, fractional for the unbounded ones, so 0.02 means "two accuracy points, or two percent slower". 0.0 gates on any movement at all, which is only realistic for a deterministic suite.
metrics
restrict the gate to these metric names; None compares every metric present in either report.
returns
a dict with the per-metric before/after/delta and a regressions list. An empty regressions list is the pass condition — which is what docpipe eval --baseline exits non-zero on.
raises ConfigError
the baseline report contains no pipelines

EvalReport.summary

method source
summary(self) -> str

A human-readable table, for a terminal or a CI log.

EvalReport.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready dict: the computed metrics plus every raw outcome.

EvalReport.save

method source
save(self, path: str) -> str

Write the report to path, creating parent directories.

Returns path. This is the file regression_vs reads as a baseline, so it is worth committing alongside a release.

path
destination file; parent directories are created, and an existing file is overwritten
returns
path, so it can be used inline

EvalReport.load

classmethod source
load(cls, path: str) -> EvalReport

Read a report back from a file written by save.

path
a file written by save
returns
the reconstructed report, with every per-field outcome intact so the comparison can name individual fields
raises OSError
the file could not be read
raises ValueError
the file is not valid JSON

_mean

func source
_mean(values: Sequence[float]) -> float

Arithmetic mean, or 0.0 for an empty sequence.

14. The whole pipeline, as one configurable object

Pipeline

classdataclass public API source

Ingest -> preprocess -> read -> extract, as data.

Serialisable, so a pipeline configuration can be recorded in an eval report and reproduced exactly. Every stage is optional: drop schema to get a text-extraction pipeline, drop policy to measure the no-preprocessing baseline.

Fields

policy: Optional[PolicyFn] = default_policy
Chooses preprocessing ops per page from its measured quality. Also ocr_policy, vlm_policy, or your own Callable[[Page], List[Op]]. None skips preprocessing — the baseline to measure against.
router: Optional[RouterFn] = default_router
Picks a backend per page. Also RuleRouter, BudgetRouter, or your own Callable[[Page], Optional[str]]. None sends every page to backend.
backend: Optional[Union[str, TextBackend]] = None
Force one reader for all pages: a registered name ("pymupdf", "tesseract", "paddle", "anthropic") or a TextBackend. Takes precedence over router.
schema: Optional[SchemaLike] = None
What to extract — pydantic model, dataclass, or {field: type} dict. None means text only: no model call, no cost.
context: str = ''
Domain hints for the prompt, e.g. "Indian private hospital bill, INR".
client: Optional[LLMClient] = None
The LLMClient that does the extracting. Required when schema is set.
validators: List[Validator] = field(default_factory=list)
Business rules run over the extracted object; each outcome feeds the confidence of the fields it touched.
calibrator: Optional[Calibrator] = None
Maps fused confidence onto calibrated probabilities. Without one, scores rank correctly but are not probabilities — see PlattCalibrator.
render_dpi: int = DEFAULT_DPI
Render resolution for PDF pages. 300 is the floor for reliable OCR; 400 helps on small print; above 600 costs memory without accuracy.
max_pages: int = 0
Stop after this many pages. 0 means no limit.
max_workers: int = 0
Thread count for per-page preprocessing and reading. 0 runs serially, which is what you want while debugging.
split_pages: bool = False
Split pages that hold two physical documents before reading — see split_multi_bill_page.
normalize: bool = True
Apply script, digit, date and amount normalisation to spans after reading.
budget: Optional[float] = None
Currency ceiling for the read stage; exceeding it raises BudgetExceeded. None means unbounded. Meaningless until set_pricing is called — unpriced models cost 0.00.
name: str = 'pipeline'
Label carried into eval reports so runs can be told apart.

Methods

Pipeline.ingest

method source
ingest(self, source: Source, **kwargs: Any) -> Document

Ingest source using this pipeline's DPI and page limit.

source
path, path-like, directory, raw bytes, or open binary file — anything ingest accepts
kwargs
forwarded to ingest (password, kind_hint, ...); render_dpi and max_pages come from this pipeline
returns
a Document whose rasters are still lazy — nothing has been rendered yet

Pipeline.run_document

method source
run_document(self, doc: Document) -> Document

Preprocess, read and normalise an already-ingested document.

The middle of run, exposed separately for when you ingested the document yourself — to filter its pages, attach metadata, or feed pages assembled from somewhere other than a file.

doc
an ingested document; it is read in place and also returned
returns
the same document, with page.spans populated, page.history recording every op applied, and text normalised if normalize is set

Pipeline.run

method source
run(self, source: Source, **kwargs: Any) -> Extraction

Run end to end and return an Extraction.

With no schema configured the result still carries the document, so a text-only pipeline is just this with the extraction step empty.

source
path, path-like, directory, raw bytes, or open binary file — anything ingest accepts
kwargs
forwarded to ingest, e.g. password="secret" for an encrypted PDF
returns
an Extraction; when schema is None its model is None and only document and cost are filled in
raises IngestError
source could not be read as a document
raises BudgetExceeded
this pipeline has a budget and reading exceeded it

Pipeline.__call__

method source
__call__(self, source: Source, **kwargs: Any) -> Extraction

pipeline(source) — alias for run, so it is a PipelineFn.

Being callable is what lets a configured pipeline be handed straight to run, which expects a Callable[[str], Extraction].

source
as run
kwargs
as run
returns
an Extraction

Pipeline.to_dict

method source
to_dict(self) -> Dict[str, Any]

JSON-ready description of how this pipeline is configured.

Callables are recorded by name, not serialised: the point is to make a report say which policy and router produced it.

process

func public API source
process(source: Source, schema: Optional[SchemaLike] = None, context: str = "",
            client: Optional[LLMClient] = None, policy: Optional[PolicyFn] = default_policy,
            router: Optional[RouterFn] = default_router,
            backend: Optional[Union[str, TextBackend]] = None,
            validators: Optional[Sequence[Validator]] = None,
            **kwargs: Any) -> Extraction

One-call convenience wrapper around Pipeline — ingest to extraction.

Equivalent to building a Pipeline with these arguments and calling it once. Use this for a single document; build a Pipeline when you want to reuse one configuration across many documents, record it in an eval report, or run it through EvalSuite.

source
what to read. A path or path-like ("scan.pdf", Path("bill.png")), a directory of pages, raw bytes of a PDF or image, or an open binary file object. Format is sniffed from content, not from the extension.
schema
what to pull out — a pydantic model, a dataclass, or a plain {field: type} dict such as {"invoice_no": str, "total": float}. Leave it None for a text-only run: the read Document still comes back on Extraction.document, with no model call made.
context
domain hints pasted into the prompt, e.g. "Indian private hospital bill, amounts in INR". Cheap and effective — it is what tells the model that 1,20,000 is one-lakh-twenty.
client
the LLMClient doing the extracting — one of AnthropicClient, OpenAIClient, or the deterministic EchoClient in tests. Required whenever schema is given.
policy
chooses preprocessing ops per page from its measured quality. Defaults to default_policy. Pass ocr_policy or else vlm_policy to bias for one reader, a callable of your own for full control, or None to skip preprocessing entirely — the honest baseline when measuring whether preprocessing earns its keep.
router
picks a backend per page, e.g. native text layer for digital pages and a VLM for photographed ones. Defaults to the rule-based default_router; see also RuleRouter and BudgetRouter. None sends every page to backend.
backend
force one reader for every page, as a registered name ("pymupdf", "tesseract", "paddle", "anthropic") or an instance of TextBackend. Overrides router when both given.
validators
business rules run over the extracted object; each outcome feeds the confidence of the fields it touched. The namespace Validators holds the ready-made ones.
kwargs
forwarded to Pipelinerender_dpi, max_pages, max_workers, split_pages, normalize, budget, calibrator, name.
returns
an Extraction carrying the parsed object, per-field confidence and provenance, the read document, the accumulated Cost, and any warnings. A failure that costs one page rather than the whole document arrives as a warning, not an exception.
raises ConfigError
a schema was given without a client
raises IngestError
source could not be read as a document
raises BudgetExceeded
a budget was set and reading would exceed it

Text only, no model call:

doc = docpipe.process("scan.pdf").document
print(doc.text())

A schema, with domain context and a validator:

result = docpipe.process(
    "bill.pdf",
    schema={"patient": str, "total": float, "bill_date": str},
    context="Indian private hospital bill, INR",
    client=docpipe.AnthropicClient(),
    validators=[docpipe.Validators.line_items_sum_to_total],
)
print(result.value("total"), result.confidence("total"))
for field in result.low_confidence(threshold=0.7):
    print("review by hand:", field.name, field.confidence)

Cheap OCR, no VLM, capped at the first 20 pages:

result = docpipe.process("bundle.pdf", backend="tesseract", router=None,
                         policy=docpipe.Policies.ocr_policy, max_pages=20)

15. Command line interface

python -m docpipe caps
python -m docpipe info scan.pdf python -m docpipe quality scan.pdf python -m docpipe preprocess scan.pdf --out ./debug python -m docpipe read scan.pdf --backend tesseract python -m docpipe extract bill.pdf --schema schema.json --client anthropic python -m docpipe eval datasets/bills --report reports/run.json

_cli_caps

func source
_cli_caps(args: Any) -> int

docpipe caps — report importable integrations and backends.

_cli_info

func source
_cli_info(args: Any) -> int

docpipe info — ingest a document and describe what arrived.

_cli_quality

func source
_cli_quality(args: Any) -> int

docpipe quality — measure per-page degradation.

_cli_preprocess

func source
_cli_preprocess(args: Any) -> int

docpipe preprocess — apply a policy, optionally writing PNGs.

_cli_read

func source
_cli_read(args: Any) -> int

docpipe read — preprocess and OCR, printing text or JSON.

_cli_extract

func source
_cli_extract(args: Any) -> int

docpipe extract — run a schema end to end. Exit 2 if invalid.

_cli_eval

func source
_cli_eval(args: Any) -> int

docpipe eval — score a dataset, optionally gating on a baseline.

main

func public API source
main(argv: Optional[Sequence[str]] = None) -> int

Entry point for python -m docpipe.

Subcommands are caps, info, quality, preprocess, read, extract and eval. Run docpipe <command> --help for each one's flags.

argv
the argument list, excluding the program name. None reads argv, which is what the console entry point does; pass a list to drive the CLI from a test.
returns
the process exit code — 0 on success, 1 on error, and 1 from eval --baseline when a metric regressed, which is what makes it usable as a CI gate