DocpipeError(Exception)
Base class for every error this library raises deliberately.
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.
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.
| Layer 5 | EvalSuite — datasets, metrics, pipeline A/B, regressions |
| Layer 4 | extract — schema in, typed result with evidence out |
| Layer 3 | read — pluggable, cost-aware OCR / VLM backends |
| Layer 2 | preprocess — composable Page -> Page ops |
| Layer 1 | Document / Page / TextSpan / BBox |
| Layer 0 | ingest — PDF, TIFF, image, email attachment |
Cross-cutting: caching, cost accounting, retries, tracing, provenance.
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:
Caps | optional-dependency probing, the OpenCV kill switch |
Util | hashing, clamping, string distance, JSON coercion |
Image | raster primitives (arrays in, arrays out) |
Quality | page-quality measures and estimators |
Ingest | bytes of unknown provenance to a Document |
Ops | the preprocessing ops, as Op factories |
Policies | measured page to the ops it needs |
Pricing | token accounting and the optional price table |
Text | script 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.
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, ...)]
The core (IR, normalisation, confidence, extraction plumbing, eval harness) is pure standard library. Everything heavier is optional and imported lazily:
numpy | all raster work |
opencv-python | fast/high-quality image ops (NumPy fallbacks too) |
pymupdf | PDF ingest and native text layers |
pillow | image and multi-page TIFF ingest |
pytesseract | Tesseract backend (or the tesseract binary) |
paddleocr | PaddleOCR backend |
rapidocr-onnxruntime | RapidOCR backend (PP-OCR weights, ONNX runtime) |
easyocr | EasyOCR backend |
python-doctr | docTR backend |
surya-ocr | Surya backend (90+ scripts) |
anthropic / openai | vision-language backends and extraction clients |
pydantic | schema-driven extraction (v1 and v2 supported) |
Call capabilities to see what is actually importable right now.
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.
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(Exception)
Base class for every error this library raises deliberately.
MissingDependency(DocpipeError, ImportError)
An optional dependency is required for the requested operation.
__init__(self, module: str, purpose: str = "", extra: str = "") -> None
Build the error, including the pip install line that fixes it.
modulepurposeextraIngestError(DocpipeError)
A source document could not be opened, decoded or repaired.
BackendError(DocpipeError)
A reading backend failed.
ExtractionError(DocpipeError)
Structured extraction failed (bad model output, bad schema, ...).
ConfigError(DocpipeError)
A pipeline was configured with values that cannot work.
BudgetExceeded(DocpipeError)
A cost budget was exhausted before the document finished.
_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(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.
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.
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"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"Tesseract input conversion". It goes into the error message, so make it name the feature the user was reaching for.returnsraises MissingDependencyhave(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"cv2" or "pytesseract"returnsFalse also covers a module that is installed but broken — a native library failing to load counts as unavailable, which is the useful answer.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.
enabledFalse forces the pure-NumPy path even when OpenCV is installed. Results differ slightly between the two backends — the fallbacks are equivalent, not bit-identical.returnswithout_opencv() -> _OpenCVDisabled
with docpipe.without_opencv(): ... — force pure-NumPy image ops.
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.
_np() -> Module
NumPy, or MissingDependency. Used by every raster code path.
_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 = 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() -> Optional[Module]
OpenCV if available and enabled, else None.
Image ops call this and branch: cv = _cv2(); if cv is not None: ....
Context manager forcing NumPy fallbacks inside a block.
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.
stable_hash(*parts: Any) -> str
A short, stable, cross-process content hash.
hash() is salted per process and useless for caching; this is not.
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.
arrreprreturnsclamp(value: float, lo: float, hi: float) -> float
Clamp value into [lo, hi].
valuelovalue is below ithivalue is above itreturnslo <= hi; inverted bounds return lo.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.
valuesq0.0..100.0, clamped. 50 is the median, 95 the usual tail metric.returns0.0 for an empty sequencelevenshtein(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.
abmax_distancereturnsmax_distance + 1 when the bound was hit. The sentinel means "further than you asked about", not an exact distance.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.
abreturns1.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.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.
fnattempts1base_delaymax_delayjitter0.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,) is broad because provider SDKs raise their own hierarchies.give_up_onretry_on, so a bad API key fails once, not three times.on_retryon_retry(attempt, exc, delay) before each sleep — the hook for counting billed-but-failed attempts.sleepreturnsfn returned on its first successraises ConfigErrorattempts is less than 1raises Exceptionto_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.
objindentNone produces compact one-line JSONsort_keysreturns_safe_div(a: float, b: float, default: float = 0.0) -> float
a / b, or default when b is zero.
Wall-clock stopwatch. with Timer() as t: ... then t.ms.
chunked(seq: Sequence[T], size: int) -> Iterator[List[T]]
Yield seq in lists of at most size.
seqsizereturnsraises ConfigErrorsize is less than 1_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() -> 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(obj: Any) -> Any
Best-effort conversion of nested docpipe/py objects to JSON primitives.
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:
PageKind(str, enum.Enum)
How the text on a page is stored — which decides how to read it.
DIGITAL_NATIVE = 'digital_native'SCANNED = 'scanned'HYBRID = 'hybrid'BLANK = 'blank'Verdict(str, enum.Enum)
Coarse readability judgement produced by measure_quality.
RegionKind(str, enum.Enum)
Layout region types worth routing on.
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.
page: intx0: floaty0: floatx1: floaty1: floatwidth(self) -> float
Width in points (never negative — see __post_init__).
height(self) -> float
Height in points (never negative — see __post_init__).
area(self) -> float
Area in square points.
center(self) -> Tuple[float, float]
(x, y) of the centre, in points.
union(self, other: BBox) -> BBox
Smallest box containing both. Requires the same page.
otherreturnsraises ValueErrorintersection(self, other: BBox) -> Optional[BBox]
Overlap rectangle, or None when they do not overlap.
otherreturnsNone 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.iou(self, other: BBox) -> float
Intersection over union in [0, 1].
otherreturns0.0..1.0. 0.0 for boxes on different pages or with no overlap; 1.0 for identical boxes.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.
othertolerance0.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.returnsother lies within this box, on the same pageoverlaps(self, other: BBox) -> bool
True when the two boxes share any area on the same page.
otherreturnsintersection.expand(self, margin: float) -> BBox
This box grown by margin points on every side (negative shrinks).
marginreturnsclipped when the result must stay on the sheet.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.
factorreturnstranslated(self, dx: float, dy: float) -> BBox
This box shifted by (dx, dy) points.
dxdyreturnsclipped(self, width: float, height: float) -> BBox
This box confined to a width x height page.
widthheightreturnsto_pixels(self, dpi: float) -> Tuple[int, int, int, int]
(x0, y0, x1, y1) in integer pixels at dpi.
dpipage.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 pixelsfrom_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.
pagex0y0x1y1dpipage.raster_dpireturnsfrom_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.
pagexywhreturnsfrom_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.
pagepoints[(x, y), ...] in points — any number of them, though detectors emit fourreturnswhole_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.
pagewidth_ptheight_ptreturnsto_dict(self) -> Dict[str, float]
JSON-ready dict; coordinates are rounded to 3 decimals (~0.001 pt).
from_dict(cls, d: Mapping[str, Any]) -> BBox
Inverse of to_dict.
dpage, x0, y0, x1, y1returnsraises KeyErrormerge_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.
boxesreturnsA 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".
text: strbbox: BBoxsource: str = 'unknown'confidence: Optional[float] = Nonescript: Optional[str] = Noneline: Optional[int] = Noneblock: Optional[int] = Nonemeta: Dict[str, Any] = field(default_factory=dict)page(self) -> int
Index of the page this span sits on.
is_empty(self) -> bool
True when the span carries no non-whitespace text.
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.
from_dict(cls, d: Mapping[str, Any]) -> TextSpan
Inverse of to_dict.
dto_dictreturnsraises KeyErrortext or bbox is missingMeasured 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.
blur: float = 1.0skew_deg: float = 0.0contrast: float = 1.0ink_coverage: float = 0.0effective_dpi: int = 0raster_dpi: int = 0noise: float = 0.0illumination: float = 1.0verdict: Verdict = Verdict.CLEANextra: Dict[str, float] = field(default_factory=dict)is_readable(self) -> bool
True unless the page was judged UNREADABLE.
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.
to_dict(self) -> Dict[str, Any]
JSON-ready dict, including the derived score.
from_dict(cls, d: Mapping[str, Any]) -> PageQuality
Inverse of to_dict (the derived score key is ignored).
dto_dictreturnsscore is recomputed from the components.A detected region of a page: table, stamp, signature, handwriting...
kind: RegionKindbbox: BBoxconfidence: float = 1.0meta: Dict[str, Any] = field(default_factory=dict)to_dict(self) -> Dict[str, Any]
JSON-ready dict.
from_dict(cls, d: Mapping[str, Any]) -> LayoutRegion
Inverse of to_dict.
dto_dictreturnsraises KeyErrorbbox is missingraises ValueErrorkind is not a RegionKind valueRegion inventory for a page, with the predicates routing cares about.
regions: List[LayoutRegion] = field(default_factory=list)of_kind(self, kind: RegionKind) -> List[LayoutRegion]
Every region of exactly kind, in detection order.
kindRegionKind to filter on, e.g. RegionKind.TABLE or RegionKind.SIGNATURE. Exact, not hierarchical — asking for TABLE never returns table cells.returnsis_tabular(self) -> bool
True when at least one table region was detected.
has_handwriting(self) -> bool
True when at least one handwriting region was detected.
has_stamps(self) -> bool
True when at least one stamp region was detected.
to_dict(self) -> Dict[str, Any]
JSON-ready dict.
from_dict(cls, d: Mapping[str, Any]) -> Layout
Inverse of to_dict.
dto_dictreturnsregions key yields an empty layout rather than an errorOne 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".
op: strparams: Dict[str, Any] = field(default_factory=dict)ms: float = 0.0note: str = ''to_dict(self) -> Dict[str, Any]
JSON-ready dict, omitting empty params and notes.
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 = 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.
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.
index: intwidth_pt: float = 612.0height_pt: float = 792.0kind: PageKind = PageKind.SCANNEDrotation: int = 0quality: 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)raster(self, dpi: Optional[int] = None) -> ImageArray
Return the page image as a uint8 NumPy array, rendering on demand.
Semantics that matter:
dpiNone 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.returnsuint8 NumPy array, greyscale or RGBraises DocpipeErrorhas_raster.has_raster(self) -> bool
True when a raster is materialised or can be produced without I/O errors.
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.
imguint8 NumPy arraydpiimg 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_providerFalse except when installing a raster equivalent to what the provider would return; keeping it lets a later DPI change discard your image.returnsself, so calls chainset_raster_provider(self, provider: RasterProvider,
dpi: Optional[int] = None) -> Page
Attach a lazy raster source, e.g. a PDF page renderer.
providerprovider(dpi) and must return an ImageArraydpirelease_raster(self) -> Page
Drop the materialised raster. Only safe while a provider remains.
raster_dpi(self) -> int
Resolution of the current raster, falling back to DEFAULT_DPI.
pixel_size(self) -> Tuple[int, int]
(width, height) of the current raster in pixels, (0, 0) if none.
text(self, separator: str = "\n") -> str
Reading-order text. Spans are sorted by line, then by x.
separatorreturnslines(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_toleranceNone 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.returnsspans_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.
bboxmin_overlap0.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.returnschar_count(self) -> int
Total non-whitespace characters across every span on the page.
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.
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.
opparamsmsnotehistory_summary(self) -> str
The op history as one a -> b -> c line.
bbox(self) -> BBox
A box covering the whole page, in points.
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_spansTextSpan 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.returnsto_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_spansreturnsfrom_dict.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.
dto_dictreturnsraises KeyErrorindex is missing; every other key has a defaultAn ordered collection of pages plus provenance about where it came from.
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)__len__(self) -> int
Number of pages.
__iter__(self) -> Iterator[Page]
Iterate over pages in order.
__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.
ireturnsraises IndexErrorpage(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.
indexindex attributereturnsraises KeyErrortext(self, separator: str = "\n\n") -> str
Reading-order text of every non-empty page, joined by separator.
separatorformat_document_text instead when the text is headed for a prompt, since that one adds page markers.returnsspans(self) -> List[TextSpan]
Every span in the document, page by page, in order.
char_count(self) -> int
Total non-whitespace characters in the document.
kinds(self) -> Dict[str, int]
Count of pages per PageKind, e.g. {"scanned": 12}.
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.
messagereturnsself, so calls chaincopy(self) -> Document
Deep-enough copy: pages are copied, rasters are shared.
release_rasters(self) -> Document
Free every materialised raster that can be re-rendered on demand.
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.
predicateCallable[[Page], bool], e.g. lambda p: not p.is_blank or lambda p: p.quality.score > 0.5returnsto_dict(self, include_spans: bool = True) -> Dict[str, Any]
JSON-ready dict; include_spans=False drops the text.
include_spansFalse yields a compact structural summary — geometry, quality, history — which is what you want for a manifest or a diff between two runs.returnsfrom_dict(cls, d: Mapping[str, Any]) -> Document
Inverse of to_dict. Rasters are not restored.
dto_dictreturnshas_raster of False.save_json(self, path: str, include_spans: bool = True) -> str
Write to_dict to path as UTF-8 JSON. Returns path.
pathinclude_spansto_dictreturnspath, so it can be used inlineload_json(cls, path: str) -> Document
Read a document back from a file written by save_json.
pathsave_jsonreturnsraises OSErrorraises ValueErrorMoney and tokens spent. Adds like a number so it can be accumulated.
currency: str = 'USD'amount: float = 0.0input_tokens: int = 0output_tokens: int = 0calls: int = 0wasted_calls: int = 0__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.
otherreturnsCost with amounts, tokens, calls and wasted calls summed. NotImplemented for a non-Cost, so Python falls back to the reflected operand.raises ConfigErrorzero(cls, currency: str = "USD") -> Cost
An empty cost in currency — the identity for addition.
currencyreturnstotal_tokens(self) -> int
Input plus output tokens.
to_dict(self) -> Dict[str, Any]
JSON-ready dict; the amount keeps 6 decimals (sub-cent per page).
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(img: ImageArray) -> bool
True for a single-channel (H, W) array.
imgndim (which reports False)returnsis_color(img: ImageArray) -> bool
True for a multi-channel (H, W, C>=3) array.
imgndim (which reports False)returns(H, W, 1) array is not colour by this test.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.
imgreturns(height, width), ignoring any channel dimension_integral(arr: ImageArray) -> FloatArray
Summed-area table with a zero row/column, shape (H+1, W+1).
_rect_box_sum(arr: ImageArray, kh: int, kw: int) -> Tuple[FloatArray, FloatArray]
(window_sums, window_counts) over a kh x kw rectangle, clamped.
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.
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.
imguint8 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..255returnsuint8 array of the same shapeto_gray(img: ImageArray) -> GrayImage
Grayscale view of img using ITU-R 601 luma weights.
imgreturnsuint8 arrayto_rgb(img: ImageArray) -> ImageArray
3-channel RGB view of img.
imgreturns(H, W, 3) uint8 arraywindow_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.
graywindowreturns(mean, std), both float arrays the same shape as graybox_blur(img: ImageArray, radius: int) -> ImageArray
Mean filter of radius radius (integral-image based in the fallback).
imgradius2 * radius + 1 square. Below 1 the image is returned unchanged.returnsuint8 arraygaussian_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.
imgsigmareturnsuint8 arraymedian_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.
imgksize3 removes single speckles; 5 handles heavier fax noise and starts to round off the corners of glyphs.returnsuint8 arrayresize_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).
imgsize(width, height) in pixels — note this is the opposite order to image_shape. Takes precedence over scale.scalesize 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.returnsuint8 arrayrotate_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.
imgangle_deg1e-4 return the image unchanged.border_value0..255. None uses the image's median, which is what keeps a white page white and a dark scan dark.expandTrue for deskewing — False keeps the original dimensions and cuts the corners off, which loses letterheads.returnsuint8 array, larger than the input when expand is setrow_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.
grayangle_degreturnsotsu_threshold(gray: GrayImage) -> int
Otsu's global threshold via between-class variance maximisation.
grayreturns0..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.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.
graythreshold0..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.returnsTrue where there is inkgradient_magnitude(gray: GrayImage) -> FloatArray
Sobel gradient magnitude as float64. Used by the sharpness metric.
grayreturnsmorph_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.
mask0/1 arraykhkw(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.returnsestimate_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.
grayradius3. 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.returnsuint8 array, the same shape as the input. Divide the page by it to flatten lighting, which is what normalize_illumination does.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.
imgreturnsraises MissingDependencyencode_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.
imgquality1..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.returnsraises MissingDependencydecode_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.
datareturnsuint8 array, greyscale (H, W) or RGB (H, W, 3)raises IngestErrorraises MissingDependencysave_image(img: ImageArray, path: str) -> str
Write an array to disk, choosing the encoder from the extension.
imgpath.jpg/.jpeg encode as JPEG; every other extension, including none, encodes as PNG.returnspath, so it can be used inlineraises MissingDependencyraises OSError_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 = 0.55
Below this illumination score a global threshold is unsafe — see `ink_mask`.
_percentile_np(arr: ImageArray, q: float) -> float
q-th percentile of an array, via NumPy when it is loaded.
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.
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.
blur_floor: float = 0.35blur_unreadable: float = 0.08contrast_floor: float = 0.3contrast_unreadable: float = 0.1min_dpi: int = 300dpi_unreadable: int = 120skew_correct_deg: float = 0.4skew_max_deg: float = 15.0noise_ceiling: float = 0.35illumination_floor: float = 0.55ink_blank: float = 0.002ink_saturated: float = 0.6DEFAULT_THRESHOLDS = QualityThresholds()
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.
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.
grayreturns0.0..1.0; 1.0 is crisp. Compare against QualityThresholds.blur_floor (degraded) and blur_unreadable.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.
grayreturns0.0..1.0. A near-blank page reports high contrast rather than zero, since there is no ink whose separation could be poor.measure_ink_coverage(gray: GrayImage) -> float
Fraction of pixels that are ink, using an illumination-aware mask.
grayreturns0.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.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.
grayreturns0.0..1.0; 0.0 is clean. Above QualityThresholds.noise_ceiling the page is treated as degraded and denoise is reached for.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.
grayreturns0.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.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:
projectionhoughminarearectauto's median.automin_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.
graymax_angle15 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_step1.0 is ample; smaller only costs time, since the fine pass refines whatever the coarse pass found.fine_step0.1 is below what deskewing can act on anyway — deskew declines under 0.4 degrees.min_improvement0.15 keeps a page that is genuinely straight from being rotated by noise.returns0.0 when the page is straight, nearly blank, or ink-saturatedestimate_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.
grayreturns0.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.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.
graymin_pitch8 is below body text at any usable resolution; raising it rejects the harmonics that dense text can produce.max_pitch200 covers double-spaced large print at 600 DPI.returns0.0 when the page has too little ink or no convincing periodicityestimate_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.
grayfallbackraster_dpi so callers always get a usable number.returns30..1200. Compare it against page.raster_dpi: a large gap means the page was upsampled somewhere and carries less signal than its size suggests.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.
qPageQualitythresholdsNone uses the defaults in DEFAULT_THRESHOLDSreturnsVerdict. 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.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.
imgraster_dpieffective_dpi. 0 means unknown.thresholdsNone uses the defaults in DEFAULT_THRESHOLDSskew_methodestimate_skew — "auto", "projection", "hough", or "minarearect"max_skewreturnsPageQuality carrying blur, skew, contrast, ink coverage, noise, illumination, effective DPI and an overall verdictmeasure_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.
pagepage.quality is replaced and the measurement recorded in page.historydpiNone uses whatever the page already hasthresholdsNone uses the defaults in DEFAULT_THRESHOLDSkwargsmeasure_quality — skew_method, max_skewreturnsmeasure_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.
docdpiNone uses each page's ownthresholdsNone uses the defaults in DEFAULT_THRESHOLDSmax_workers0 runs serially.kwargsmeasure_qualityreturnspreprocess does this for you — call it directly to inspect quality without changing pixels, which is what docpipe quality does._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 = 3.2
Body text strokes run ~3.2px wide at 300 DPI.
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 = [(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 = 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.
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.
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.
datafilenamereturns"pdf", "png", "jpeg", "tiff", "bmp", "gif", "webp", "eml", or "unknown". A .msg file reports "eml", since it takes the same path.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_countimage_area_ratio0.0..1.0. Above 0.35 alongside real text means HYBRID — a scanned insert on a typed form.has_imagesmin_charsMIN_NATIVE_CHARS. Scanned pages routinely carry a handful of stray characters from a header stamp, so the floor cannot be 1.ink_hint0.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.returnsPageKind — DIGITAL_NATIVE, HYBRID, SCANNED, or BLANK. This drives routing, as decided by default_router.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.
sourcebytes, or an open binary filepasswordmax_pages0 means all of them. Applied after page_range.page_rangerange(10, 20) or [0, 5, 9]. None takes every page.render_dpi300 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_textdefault_router route a digital page to the exact, free "pymupdf" read.min_native_charsclassify_page_kind.returnsDocument whose pages carry native spans where they exist and a lazy raster provider throughoutraises IngestErroringest_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.
sourcebytes, or an open binary filedpiNone 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_indexreturnsDocument with the raster already attachedraises IngestErroringest_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.
sourcebytes, or an open binary filedpiNone reads the TIFF tag and falls back to DEFAULT_IMAGE_DPI. Fax TIFFs commonly declare 204x98, which is anisotropic and worth overriding.returnsDocument with one page per frameraises IngestErroringest_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.
sourcebytes, or an open binary file holding an RFC 822 messagedpiNone detects per attachmentinclude_bodymax_attachmentsreturnsDocument 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.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.
imguint8.indexbbox.page refers back todpiDEFAULT_IMAGE_DPI. Get this right: every DPI-relative threshold downstream is computed from it.returnsPage with the raster attached and its point-space geometry derived from the array's shape and dpidocument_from_images(images: Sequence[ImageArray], dpi: int = DEFAULT_IMAGE_DPI,
source_uri: str = "<arrays>") -> Document
Public helper: build a Document from in-memory arrays.
imagesdpipage_from_imagesource_urireturnsDocument with one page per array, indexed from 0ingest(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"scan.pdf"), raw bytes, or an open binary file object. A directory is not accepted here; walk one with ingest_dir instead.filenamesource is bytes or a stream and only to break a tie the magic bytes could not. Also becomes the document's source_uri.dpiNone reads the file's metadata and falls back to DEFAULT_IMAGE_DPI. Ignored for PDFs, which use render_dpi.passwordmax_pages0 means no limitpage_rangerange(10, 20). PDFs only.render_dpi300 is the floor for reliable OCR; 400 helps on small print.min_native_charsclassify_page_kind.returnsDocument whose pages are geometry-complete but not yet rasterised — raster() is lazy by contract, not as an optimisation.raises IngestErrorBytes off a queue, with the name as the only hint:
doc = ingest(payload, filename="claim-4471.pdf")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.
directorypatternre.search, e.g. "[.]pdf$" or "^claim-". None attempts every file, relying on content sniffing.recursiveFalse reads only the top level.kwargsingest for each file — render_dpi, max_pages, password, ...returnsDocument 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.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.
docssource_urireturnsDocument whose pages are numbered from 0, with warnings unioned and duplicates dropped_read_source(source: Source) -> Tuple[bytes, str]
Normalise a path / bytes / file-like into (data, uri).
_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(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(data: bytes) -> Optional[int]
Read declared DPI from image metadata, if it looks plausible.
_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(page: Page, new_index: int) -> Page
Renumber a page and every bbox on it. Needed when merging documents.
_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.
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(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).
OP_FACTORIES = {}
Registry of op factories by name, so a serialised policy can be rebuilt.
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.
__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.
namefnfn(img, page, **params) -> Optional[ImageArray]; returning None means "declined, leave the raster alone"paramsfn at every application, e.g. {"max_angle": 10.0}. Copied, so the caller's dict cannot change the op afterwards.geometricneeds_raster__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.
pagereturnsPage. 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.then(self, other: Op) -> Op
a.then(b) — a composite that runs this op, then other.
otherreturnsCompositeOp of the two, equivalent to compose(self, other) and chainable furtherto_dict(self) -> Dict[str, Any]
JSON-ready dict; feed it back to op_from_dict.
_sync_page_geometry(page: Page) -> None
Recompute point-space page size after a geometry-changing op.
_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(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.
namepage.history. Must be unique — registering an existing name replaces it, which is how you override a built-in, deliberately or by accident.geometricTrue 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_rasterTrue (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.returnsOp factorycompose(*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(Op)
An ordered sequence of ops applied as one.
__init__(self, ops: Sequence[Op]) -> None
Wrap an ordered sequence of ops as a single op.
ops.ops so they stay reachable for inspection and serialisation.__call__(self, page: Page) -> Page
Run every op in order, threading the page through.
pagereturnshistory entry per op rather than a single entry for the compositeto_dict(self) -> Dict[str, Any]
JSON-ready dict containing each member op.
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.returnsOp, or a CompositeOp for a "compose" entryraises ConfigErrorOP_FACTORIES; the message lists what is registered, since the usual cause is a custom op whose module has not been imported yetapply_ops(page: Page, ops: Sequence[Op]) -> Page
Apply ops in order, returning a new page.
pageopsOps factories. An empty sequence returns the page unchanged.returnshistory entry per opThe 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.
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.
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_threshold0.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.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_pct0.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_pct0.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.returnsNone when the two percentiles are within one grey level of each other — a blank or already-saturated page, where stretching would only amplify noise.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.
value0.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.returnsNone when value is 1.0 within toleranceclahe(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.
clip2.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.grid8 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.returnsNone, since the caller has already decided the page needs itnormalize_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.
radiusNone 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.strength0.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.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.
radius21 suits 300 DPI body text; raise towards 41 at 600 DPI or for large print.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.
factor2.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".returnsNone when factor is 1.0 within toleranceensure_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_dpi300 is where Tesseract and PaddleOCR are trained; 400 measurably helps on small print and Indic scripts.max_dpimin_dpi when honouring it would produce an unreasonably large raster. 600 is already past the point of accuracy returns.interpolationrescale; "auto" resolves to cubic here, since this op only ever enlarges.returnsNone when the page is already at or above min_dpi, or when the cap leaves no room to scaleresize_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_px2000 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.returnsNone when the page is already within max_pxdeskew(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_anglemin_angle0.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"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.anglemin_angle and max_angle.returnsNone when the measured skew falls outside [min_angle, max_angle] — the page is left untouchedrotate(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.
degrees90, 180, 270 are the useful values; -90 is accepted and means the same as 270. 0 returns None.returnsNone when the rotation is a whole number of full turnsauto_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_osdpytesseract 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_confidence0.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.returnsNone when the page is already upright, or when the page has too little ink to judgecrop_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_pt6.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_keep0.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.returnsNone when the page is blank, when the crop would be a no-op, or when min_keep refuses itremove_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_frac0.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_threshold0..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.returnsNone when no edge rows or columns are dark enough to trimpad(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_px16 is enough for Tesseract at 300 DPI; scale it with your DPI if you render higher. 0 or negative returns None.value0..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.returnsNone when margin_px is not positiveperspective_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_ratio0.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_frac0.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.returnsNone when OpenCV is missing, or when no quad passes both guards — an unwarped page beats a sheared onedenoise(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"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 ConfigErrorstrength or method is not one of the above — a typo here silently disabling denoising would be worsedespeckle(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_px6 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.returnsNone when the page has no ink, or nothing is small enough to removeunsharp(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.
amount1.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.radius2.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.threshold0..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.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"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.ksize3 is a one-pixel nudge; 5 and 7 act fast, and anything larger tends to merge adjacent glyphs into blobs.iterations3 are gentler and more controllable than one pass of 5.raises ConfigErroroperation is not one of the six listed (OpenCV path only)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.
otsuadaptiveoffset. 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.niblackT = m + k * s (k negative). Sauvola's predecessor; keeps more faint text, at the cost of needing the min_std guard below.wolfnickT = m + k * sqrt(var + m^2). Tuned for very low-contrast scans — the faded thermal-printer case.bradleywindow 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.
graymethod"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.window3. 31 suits 300 DPI body text; use 51-61 at 600 DPI. Ignored by "otsu", which is global.kNone 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"adaptive", where 10 is a mild bias towards keeping ink.min_std"niblack" falls back to the global Otsu threshold. Guards Niblack, and only Niblack, against turning blank paper black.returns0 and 255raises ConfigErrormethod is not one of the seven listedbinarize(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"window31 at 300 DPIkNone takes each method's published defaultoffset"adaptive" onlymin_std"niblack" onlyraises ConfigErrormethod is not one of the seven listedremove_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_ratio0.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.thickness2 suits 300 DPI; 3-4 for heavier print or higher DPI.keep_textTrue unless you are removing rules from a page with no text on them.returnsNone when the page has no ink, or no run is long enough to count as a ruleraises ConfigErrordirection is not one of the three listedremove_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_min0..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_min0..255. 40 excludes near-black pixels whose hue is meaningless noise — without it, dark toner gets classified by whatever hue the sensor guessed.coverage_max0.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"].returnsNone on greyscale input, when nothing is coloured, or when coverage_max aborts the removalsplit_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.
pageaxis"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_frac0.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_parts3 reflects what fits on a sheet; a result of eight parts means the detector found text columns, not documents.min_part_frac0.0..1.0. At 0.2 a cut near the edge — a margin note, a punch-hole strip — cannot become its own document.returnssplit_from and split_part in page.meta and spans remapped into its own coordinate space; or [page] when no confident split was foundraises ConfigErroraxis is not one of the three listedsplit_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.
dockwargssplit_multi_bill_page — axis, min_gap_frac, max_parts, min_part_fracreturnsDocument whose pages are numbered from 0; pages that did not split carry through unchanged_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(img: ImageArray, min_confidence: float = 1.0) -> Optional[int]
Ask Tesseract which way is up. Returns clockwise degrees, or None.
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.
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.
pagemeasure_page has already filled in. On an unmeasured page every reading is zero and this returns an empty list.thresholdsNone 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.returnsocr_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.
pagethresholdsdefault_policy; None uses DEFAULT_THRESHOLDSbinarizationbinarize — "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.returnsvlm_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.
pagethresholdsdefault_policy; None uses DEFAULT_THRESHOLDS. Note the skew branch here compares against twice skew_correct_deg.max_sideresize_max_side. 2000 is comfortably above every major provider's internal downscale; 1500 cuts cost with no measurable accuracy loss on ordinary print.returnsno_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.
pagePolicyFnreturnsfixed_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())
opsreturnsPolicyFn ignoring page quality entirelypreprocess(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.
docpolicydefault_policy; also ocr_policy, or else vlm_policy, or any Callable[[Page], List[Op]]. Ignored when ops is given, and None means apply nothing.opsops=[deskew(), binarize()] — not for production, where a fixed sequence destroys signal on the pages that did not need it.dpiNone trusts page.raster_dpi.max_workers0 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.measureFalse every reading stays zero, and so default_policy sees a perfect page and picks nothing. Set it False only alongside an explicit ops list.thresholdsNone uses DEFAULT_THRESHOLDS.in_placedoc 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.returnsDocument. 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)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.
What a backend returns: spans, what they cost, and how long they took.
spans: List[TextSpan] = field(default_factory=list)cost: Cost = field(default_factory=Cost)latency_ms: float = 0.0backend: str = ''warnings: List[str] = field(default_factory=list)raw: Optional[Any] = field(default=None, repr=False)TextBackend(Protocol)
Structural type for anything that can read text off a page.
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.
pagereturnsFalse when this backend needs a raster the page lacks, when its dependencies are missing, or when the page is otherwise outside what it handlesis_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.
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.
pagereturnsCost; Cost.zero() for local engines. Money is 0.00 until set_pricing is called, though token counts are always real.read(self, page: Page) -> ReadResult
Read page and return its spans, cost and timing.
pagereturnsReadResult 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.Convenience base implementing the boring parts of the protocol.
needs_raster = Truepreferred_dpi = DEFAULT_DPI__init__(self, name: Optional[str] = None, **options: Any) -> None
Configure the backend.
nameoptionssupports(self, page: Page) -> bool
Whether this backend can read page at all.
pagereturnsTrue 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.is_available(self) -> bool
Whether this backend's dependencies are importable right now.
estimate_cost(self, page: Page) -> Cost
Predicted cost of reading page. Free by default (local engines).
pagereturnsCost.zero(). Vision backends override this; see the per-token estimate in estimate_cost.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.
pagereturnsReadResult from _read, with latency_ms, backend, and each span's source and script filled in_read(self, page: Page) -> ReadResult
_image_for(self, page: Page) -> ImageArray
The raster this backend should see, at its preferred DPI when free.
_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.
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.
__init__(self) -> None
Start empty. Factories are registered, never instances.
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"))
namebackend="myocr" and by any RuleRouter rulefactoryreplaceraises ConfigErrorname is taken and replace is Falseget(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"tesseract" or "anthropic"returnsraises ConfigErrorname; the message lists what is__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.
namereturns__getitem__(self, name: str) -> Any
registry["tesseract"] — alias for get.
namereturnsraises ConfigErrornamenames(self) -> List[str]
Every registered name, sorted.
available(self) -> List[str]
Names whose dependencies are importable, without constructing them.
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 = BackendRegistry()
The process-wide backend registry. docpipe.registry.register("mine", ...)
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.
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.
pagereturnsTrue only when the page is DIGITAL_NATIVE or HYBRID and already carries spans — see classify_page_kind_read(self, page: Page) -> ReadResult
Return the page's existing native spans, re-tagged as this backend's.
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).
__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+-joined, e.g. "eng", "eng+hin", "eng+deu". Each needs its traineddata installed; naming a missing one fails at read time, not here.psm3 (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.oem3 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"-c tessedit_char_whitelist=0123456789.," to read an amounts-only column.min_confidence0.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.nameoptionsBaseBackendis_available(self) -> bool
True when either pytesseract or the tesseract binary is present.
_config_string(self) -> str
The --psm/--oem/extra flags as one command-line string.
_read(self, page: Page) -> ReadResult
Read via pytesseract when importable, else via the binary.
_read_pytesseract(self, page: Page, img: ImageArray, pt: Any) -> ReadResult
Read through pytesseract's word-level TSV output.
_read_cli(self, page: Page, img: ImageArray) -> ReadResult
Shell out to tesseract, parsing TSV. Used when pytesseract is absent.
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.
__init__(self, lang: str = "en", use_angle_cls: bool = True,
name: Optional[str] = None, **options: Any) -> None
Configure the engine.
lang"en", "ch", "devanagari". One per engine instance — register the backend twice under different names to read two scripts.use_angle_clsnameoptionsBaseBackendis_available(self) -> bool
True when paddleocr is importable.
_get_engine(self) -> Any
Construct the engine once, under the lock. Loads model weights.
_read(self, page: Page) -> ReadResult
Run PP-OCR and convert its quads into spans.
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.
__init__(self, name: Optional[str] = None, **options: Any) -> None
Configure the engine.
nameoptionsRapidOCR at construction, e.g. det_model_path=... to point at your own ONNX weightsis_available(self) -> bool
True when either RapidOCR distribution is importable.
_get_engine(self) -> Any
Construct the engine once, under the lock. Loads ONNX weights.
_read(self, page: Page) -> ReadResult
Run RapidOCR and convert its quads into spans.
EasyOCRBackend(BaseBackend)
EasyOCR. Broad script coverage and easy setup; slower than PP-OCR.
__init__(self, languages: Sequence[str] = ("en",), gpu: bool = False,
name: Optional[str] = None, **options: Any) -> None
Configure the engine.
languages("en",) or ("en", "hi"). EasyOCR restricts which codes may be combined — Latin scripts mix freely, but most non-Latin scripts pair only with English.gpuFalse is the safe default; on CPU this engine is several times slower than PP-OCR.nameoptionsBaseBackendis_available(self) -> bool
True when easyocr is importable.
_get_engine(self) -> Any
Construct the reader once, under the lock. Loads model weights.
_read(self, page: Page) -> ReadResult
Run EasyOCR and convert its quads into spans.
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.
__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"db_resnet50" is the accurate default; "db_mobilenet_v3_large" is markedly faster and a little worse on small print.reco_arch"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.pretrainedFalse is only useful when loading your own fine-tuned weights — an untrained model reads nothing.nameoptionsBaseBackendis_available(self) -> bool
True when doctr is importable.
_get_engine(self) -> Any
Build the OCR predictor once, under the lock. Loads model weights.
_read(self, page: Page) -> ReadResult
Run docTR and scale its relative geometry back into pixels.
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.
__init__(self, languages: Sequence[str] = ("en",),
name: Optional[str] = None, **options: Any) -> None
Configure the engine.
languages("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.nameoptionsBaseBackendis_available(self) -> bool
True when surya is importable.
_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.
_build_with_manager(predictor_cls: Any) -> Any
Construct a predictor via the older SuryaInferenceManager API.
_build_with_foundation(predictor_cls: Any) -> Any
Construct a predictor via the newer FoundationPredictor API.
_read(self, page: Page) -> ReadResult
Run Surya and convert its text lines into spans.
PRICING = {}
model id -> (input USD per million tokens, output USD per million tokens)
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.
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)
modelCost.model; a mismatched string leaves the model unpricedinput_per_mtok3.0output_per_mtok15.0, typically several times the input rateprice_tokens(model: str, input_tokens: int, output_tokens: int) -> Cost
Convert token counts to a Cost using the registered pricing.
modelPRICINGinput_tokensoutput_tokensreturnsCost 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.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.
imgdivisor750 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.returnsBudgetRouter does, and then reconciles against the real usage once the call returns.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(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.
max_side_px = 2000__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"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.promptDEFAULT_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_tokens8192 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.temperature0.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.nameoptionsBaseBackend_image_for(self, page: Page) -> ImageArray
The page raster, downscaled to max_side_px so cost stays bounded.
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.
pagereturnsCost 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._spans_from_text(self, page: Page, text: str) -> List[TextSpan]
Turn a transcription into one span per line, with honest geometry.
_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).
_read(self, page: Page) -> ReadResult
Encode the page as JPEG, transcribe it, and split it into line spans.
AnthropicVisionBackend(VisionBackend)
Read a page with a Claude vision model.
__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-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_keyANTHROPIC_API_KEY environment variable when Noneclientapi_key is unused and is_available is True without the SDK check.optionsVisionBackend — prompt, max_tokens, temperature, nameis_available(self) -> bool
True with an injected client, or with the SDK plus an API key.
_get_client(self) -> Any
Construct the SDK client once, under the lock.
_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(VisionBackend)
Read a page with an OpenAI vision model.
__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"gpt-4o". Also the pricing key.api_keyOPENAI_API_KEY environment variable when Noneclientapi_key is unuseddetail"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.optionsVisionBackend — prompt, max_tokens, temperature, nameis_available(self) -> bool
True with an injected client, or with the SDK plus an API key.
_get_client(self) -> Any
Construct the SDK client once, under the lock.
_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(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.
tile_px = 768tokens_per_tile = 258untiled_max_px = 384__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-2.5-flash" (fast and cheap, ample for printed pages) or "gemini-2.5-pro" for harder reads. Also the pricing key.api_keyGOOGLE_API_KEY, then GEMINI_API_KEYclientgenai.Client, e.g. one pointed at Vertex AIthinking_budgetNone to omit the setting altogether, which is required for models that do not support thinking at all.optionssafety_settings or top_p need no subclassis_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.
_get_client(self) -> Any
Construct the SDK client once, under the lock.
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.
pagereturnsCost, or Cost.zero() for a page with no raster. Reads 0.00 until the model is priced._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.
_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.
_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(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.
__init__(self, inner: Any, cache: Optional["DiskCache"] = None,
namespace: str = "") -> None
Wrap inner with a content-addressed read cache.
innerneeds_raster is inheritedcacheNone builds a DiskCache in the standard location. Point it at a per-branch directory to keep experiments from sharing results.namespace"v2-prompt" after changing the OCR prompt, since the prompt is not part of the raster hash.is_available(self) -> bool
Delegates to the wrapped backend.
supports(self, page: Page) -> bool
Delegates to the wrapped backend.
pagereturnsestimate_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.
pagereturnsCost.zero() when the key is already cached, else the wrapped backend's estimate_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.
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.
pagereturnsReadResult. 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(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.
__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.
innerattempts3 absorbs the usual transient provider failure; beyond 5 you are queueing on an outage rather than retrying a blip.base_delay0.5 gives 0.5s, 1s, 2s. Raise it when the provider rate-limits by window rather than by concurrency.retry_on(Exception,) is deliberately broad, since provider SDKs raise their own hierarchies; narrow it if you would rather fail fast on unrecognised errors.give_up_onretry_on, so a bad API key fails once, not three timesis_available(self) -> bool
Delegates to the wrapped backend.
supports(self, page: Page) -> bool
Delegates to the wrapped backend.
pagereturnsestimate_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.
pagereturnsread(self, page: Page) -> ReadResult
Read with retries, recording any billed-but-failed attempts.
pagereturnsReadResult, 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 themraises Exceptioninner raised, once every attempt is spent or the error type is in give_up_onEnsembleBackend(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.
__init__(self, primary: TextBackend, secondary: TextBackend,
name: Optional[str] = None) -> None
Pair two backends for cross-checked reading.
primarysecondaryname"ensemble:a+b" registry nameis_available(self) -> bool
True only when both backends are available.
supports(self, page: Page) -> bool
True only when both backends support the page.
pagereturnsFalse if either backend declines — there is no agreement signal to be had from one readestimate_cost(self, page: Page) -> Cost
The sum of both backends' estimates — an ensemble is not free.
pagereturnsRuleRouter can send only low-quality pages here and leave clean ones on a single cheap read.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.
pagereturnsReadResult 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_valuedefault_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:
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.
pagereturns"pymupdf", "paddle" or "vlm:claude"; or None for a blank page, and also when no backend at all is installedA 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.
__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")
fallbackNone 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.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.
predicateCallable[[Page], bool]; one that raises is logged and skipped rather than failing the documentbackend_namereturnsself, so calls chain — router.add(a, "x").add(b, "y")__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.
pagereturnsfallback, which may be None to skip the pageWrap 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.
__init__(self, inner: RouterFn, budget: float, cheap_backend: str = "tesseract",
currency: str = "USD") -> None
Wrap inner with a spending ceiling.
innerdefault_router, or a RuleRouterbudgetcurrency. Meaningless until set_pricing is called — an unpriced model estimates 0.00 and never triggers a downgrade.cheap_backend"tesseract" needs no model download and no network; if it is not registered the page is skipped instead.currencyrecord(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.
costCost of a completed readremaining(self) -> float
Budget left, never negative.
__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.
pagereturnscheap_backend; None when the inner router skipped the page or the cheap backend is not registered_register_default_backends() -> None
Register the built-in backends as lazy factories.
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.
pagehistory gains a "read" entry either way, including when no backend would take it.backend"pymupdf", "tesseract", "paddle", "vlm:claude") or an instance of TextBackend. Overrides router when both given.routerbackend is None. Defaults to default_router; None with no backend reads nothing.replace_spanspage.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.returnsReadResult. 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(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.
docdefault_router then sees a perfect page everywhere and routes accordingly.backendTextBackend. Overrides router.routerbackend is set. Defaults to default_router; see also RuleRouter and the ceiling-aware BudgetRouter.max_workers0 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_placedoc rather than working on a copybudgetBudgetRouter, which downgrades to stay under; this one stops. Requires set_pricing to mean anything.on_pageon_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.returnsDocument. 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 BudgetExceededbudgetA page whose read raises is logged, recorded as a document warning, and skipped; the other pages still get read.
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 = [('Latn', 65, 591), ('Grek', 880, 1023), ('Cyrl', 1024, 1279), ('Arab', 1536, 1791), ('D...
Unicode block starts for the scripts these documents actually contain.
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.
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.
textminimum1 reports whatever dominates, which is right for a single span; raise it to ignore a stray character in a long string.returns"Latn", "Deva", "Arab", "Beng", "Taml", "Hani" and the rest — or None for empty text or text that is all digits and punctuationnormalize_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.
textreturnsnormalize_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.
textform"NFKC" (default), "NFC", "NFD", or "NFKD". The compatibility forms (NFKC/NFKD) are what fold fi to fi and full-width to ASCII, which is what OCR output needs. Use "NFC" when the exact original characters must survive.returnsnormalize_whitespace(text: str, collapse_newlines: bool = False) -> str
Collapse runs of whitespace, preserving line structure by default.
textcollapse_newlinesFalse 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.returnsnormalize_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.
textNone is treated as emptyreturnsfix_ocr_confusions.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".
textexpect"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.returnsparse_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 signReturns a Decimal — never a float, because money and binary floating point should not meet. Returns None when no number is present.
textNone 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_negativeDR marker. False returns the magnitude, for a field that cannot meaningfully be negative.returnsDecimal, or None when the text holds no numberdetect_currency(text: str) -> Optional[str]
ISO currency code implied by text, or None.
text₹, $, €) or a code (INR, USD)returnsNone 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.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.
textNone returns Nonedayfirst03/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_yearmax_yearreturnsdate, or None when no date is present or every candidate falls outside the year rangeparse_bool(text: Optional[str]) -> Optional[bool]
Parse a checkbox-ish value from a form.
textNone returns None; so does anything unrecognised, which keeps "the box was unreadable" distinct from "the box was unticked".returnsTrue for "yes", "y", "true", "1", "x", "✓", "✔", "checked"; False for "no", "n", "false", "0", "", "-", "unchecked"; None otherwise. Matching is case-insensitive.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.
spansdigitsunicode_formNone to skip it. "NFKC" is the default and the right choice for OCR output.returnsscript filled in where it was missing. Geometry, confidence and meta carry through untouched.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.
docin_placedoc rather than working on a copykwargsnormalize_spans — digits, unicode_formreturns_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 = {'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 = '\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 = 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(value: int) -> int
Expand a two-digit year. Documents in scope are recent, not Victorian.
_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.
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.
__init__(self, directory: Optional[str] = None, enabled: bool = True,
max_entries: int = 0) -> None
Open (and create) the cache directory.
directory$DOCPIPE_CACHE_DIR or a temp directoryenabledmax_entriesAn 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.
_path(self, key: str) -> str
Filesystem path holding key.
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.
keystable_hashreturnsNone 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.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.
keyvalue__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.
keyreturnsclear(self) -> None
Drop every entry, in memory and on disk.
One timed step in a pipeline run.
name: strstart: floatend: float = 0.0attributes: Dict[str, Any] = field(default_factory=dict)children: List['TraceSpan'] = field(default_factory=list)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.
__init__(self, enabled: bool = True) -> None
Start a trace. enabled=False makes every span a no-op.
enabledFalse makes span return null context managers, so instrumentation can stay in the code with no measurable cost when tracing is offspan(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"read" or "extract"attributespages=12 or backend="paddle". They survive into to_dict, so this is where to put whatever would make a slow trace explicable afterwards.returnsTraceSpan, so the block can add attributes it only learns partway throughfinish(self) -> TraceSpan
Close the root span and return it.
to_dict(self) -> Dict[str, Any]
The finished trace tree as a JSON-ready dict.
A context manager that does nothing — used when tracing is off.
Accumulate spend, optionally enforcing a hard ceiling.
__init__(self, budget: Optional[float] = None, currency: str = "USD") -> None
Start at zero.
budgetBudgetExceededcurrencyadd(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.
costCost to accumulatebackendreturnsraises BudgetExceededself.total afterwards is the true figure.to_dict(self) -> Dict[str, Any]
JSON-ready dict: total, per-backend breakdown, and the budget.
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 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.
logit(p: float, eps: float = 1e-6) -> float
Log-odds of p, clamped away from the infinities.
p0.0..1.0eps0.0 and 1.0 map to large finite values rather than infinities. 1e-6 bounds the result to about +/-13.8.returnslog(p / (1 - p))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.
zreturns0.0..1.0fuse_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.
signals0.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.weightsNone uses the defaults in DEFAULT_CONFIDENCE_WEIGHTS. Only the ratios matter, since the pool renormalises. Zero or negative drops a signal entirely.prior0.5 is the honest "no evidence either way"; lower it if your documents are hard enough that silence should read as doubt.returns0.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.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.
qualityPageQualitybboxNone uses the page-wide measurement, which is the right fallback but a blunt one.pagebbox belongs to, needed to crop the raster. Both this and bbox must be given for localisation to happen.returns0.0..1.0, ready to pass to fuse_confidence as its page_quality signalalign_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.
abiou_threshold0.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.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.
abiou_thresholdalign_spansmin_geometric_fraction0.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.returns0.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.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.
abnumeric_tolerance0.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.returns1.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.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.
valueexpected_type"number", "integer", "amount", "date", "boolean", "string", and their aliases. Matching is by name, so an unrecognised type returns None rather than a guess.returns1.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.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.
scores0.0..1.0labelsbins10 is conventional. Watch the count per bin — a bin holding three samples says nothing, however tempting its accuracy looks.returnsbin_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 ConfigErrorscores and labels differ in lengthexpected_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.
scores0.0..1.0labelsbins10 is conventional; more bins resolve finer structure but need far more samples per bin to mean anything.returns0.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.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.
scores0.0..1.0labelsreturns0.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 = {'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 = 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(text: str) -> str
Aggressive normalisation for agreement comparison only.
Base for post-hoc probability calibration.
fit(self, scores: Sequence[float], labels: Sequence[bool]) -> Calibrator
Fit on scores and their correctness labels. Returns self.
scores[0, 1]labelspredict(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.
score0.0..1.0returns0.0..1.0raises NotImplementedErrorpredict_many(self, scores: Sequence[float]) -> List[float]
Calibrate a sequence of scores.
scoresreturnsto_dict(self) -> Dict[str, Any]
JSON-ready dict, tagged with kind for from_dict.
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.
dto_dict, tagged with kind: "platt", "isotonic", or "identity"returnsraises ConfigErrorkind is missing or unrecognisedIdentityCalibrator(Calibrator)
Pass scores through unchanged. The honest default before fitting.
fit(self, scores: Sequence[float], labels: Sequence[bool]) -> IdentityCalibrator
Nothing to fit. Returns self.
scoreslabelsreturnsself, unchanged. The point of this class is to be a drop-in that documents "no calibration was applied" instead of leaving calibrator=None ambiguous.predict(self, score: float) -> float
The score itself, clamped to [0, 1].
scorereturnsto_dict(self) -> Dict[str, Any]
JSON-ready dict.
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.
__init__(self, a: float = 1.0, b: float = 0.0) -> None
Start from a, b — the defaults are the identity mapping.
a1.0 with b=0.0 is the identity, so an unfitted calibrator changes nothing.bfit(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.
scores0.0..1.0labelsiterations400 converges on the few-hundred-sample sets this is meant for; raising it is cheap but rarely changes the fit.learning_rate0.25 is stable for this loss — larger values oscillate, and the fit silently ends up worse rather than failing.returnsself, fittedpredict(self, score: float) -> float
sigmoid(a * logit(score) + b).
score0.0..1.0returnsscore, so calibration never reorders two fields — it only changes what the numbers mean.to_dict(self) -> Dict[str, Any]
JSON-ready dict, including the sample count it was fitted on.
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.
dto_dict, with a, b and nreturnsIsotonicCalibrator(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.
__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.
thresholdsvaluesthresholds. Both empty gives an unfitted calibrator that passes scores through.fit(self, scores: Sequence[float], labels: Sequence[bool],
min_samples: int = 50) -> IsotonicCalibrator
Fit a monotone step function by pool-adjacent-violators.
scores0.0..1.0labelsmin_samples50 is already optimistic for a free-form step function; a few hundred is where it starts beating Platt scaling.returnsself, fittedraises ConfigErrormin_samples samples were supplied. The message points at PlattCalibrator, whose two parameters survive a small set.predict(self, score: float) -> float
The fitted step at score; the raw score when unfitted.
score0.0..1.0returnsto_dict(self) -> Dict[str, Any]
JSON-ready dict holding the full step function.
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.
dto_dict, with thresholds, values and nreturnsthresholds it is unfitted and passes scores throughA 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.
One extractable field: its name, declared type, and whether it is required.
name: strtype_name: str = 'string'description: str = ''required: bool = Trueitem_type: Optional[str] = Nonepath: str = ''to_dict(self) -> Dict[str, Any]
JSON-ready dict.
_json_type_for(annotation: Any) -> Tuple[str, bool, Optional[str]]
(json_type, required, item_type) for a Python annotation.
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.
__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.
schemabuild can instantiate it. None means "dict only", and building then returns a plain dict.json_schemafieldsFieldSpec list used for coercion and per-field confidencenamefrom_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{field: type} dict such as {"invoice_no": str, "total": float, "items": list}, the quickest way to try something;SchemaAdapter, returned unchanged so callers can pass either without checking.returnsjson_schema and fieldsraises ConfigError_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.
_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.
_from_dict_spec(cls, spec: Dict[str, Any]) -> SchemaAdapter
{"total": "number", "patient": {"type": "string", "description": ...}}
field(self, name: str) -> Optional[FieldSpec]
The spec for name, or None when the schema has no such field.
namereturnsFieldSpec, 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.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.
datareturnsraises ExtractionErrorextract catches this and records it as a warning, so the per-field results survive even when the object cannot be built.coerce(self, name: str, value: Any) -> Any
Convert a raw JSON value to the declared type, tolerantly.
namevaluereturnsNone when it could not be coerced — which confidence fusion then scores as a format mismatchcoerce_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.
valueNone passes straight through, since "the field is absent" is different from "the field would not parse".type_name"string", "number", "integer", "boolean", "date", "array", or "object". Unknown names are treated as "string".item_type"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.returnsNone when coercion failedWhat 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") # -> NoneDEFAULT_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(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.
docinclude_page_markers—- 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_chars0 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.returnsbuild_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.
adapterjson_schema is embedded verbatimdocument_textformat_document_textcontext"Indian private hospital bill, INR". Omitted from the prompt entirely when empty.extra_instructionsreturnschunk_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.
docmax_chars60000 fits current context windows with room for the schema and the reply. 0 or less disables chunking and returns the document whole.overlap_pages1 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.returnsDocument 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(Protocol)
Anything that can turn a prompt into text and report what it cost.
complete(self, prompt: str, system: Optional[str] = None,
images: Optional[Sequence[bytes]] = None) -> Tuple[str, Cost]
Complete prompt and report what it cost.
promptsystemimagesreturns(text, cost)Shared plumbing for model clients.
__init__(self, model: str = "", max_tokens: int = 4096,
temperature: float = 0.0, **options: Any) -> None
Configure the model call.
modelmax_tokenstemperatureoptionsis_available(self) -> bool
Whether this client can be used right now. True by default.
complete(self, prompt: str, system: Optional[str] = None,
images: Optional[Sequence[bytes]] = None) -> Tuple[str, Cost]
Complete prompt. Subclasses implement this.
promptsystemimagesreturns(text, cost)raises NotImplementedErrorEchoClient(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.
__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'{"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.costCost to report per call, for exercising budget and pricing logic. Defaults to one call at zero money.recordself.prompts, so a test can assert on what the pipeline actually asked forcomplete(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).
promptself.prompts when record is set, and passed to response when that is a callablesystemimagesreturns(text, cost) with the configured cost. Deterministic by construction, which is what makes accuracy assertions in the test suite exact rather than plausible.AnthropicClient(BaseLLMClient)
Claude, via the anthropic SDK. Text and/or page images.
__init__(self, model: str = "claude-sonnet-5",
api_key: Optional[str] = None, client: Any = None,
**options: Any) -> None
Configure the Claude client.
model"claude-sonnet-5". Also the pricing key — see set_pricing.api_keyANTHROPIC_API_KEY environment variable when Noneclientis_available(self) -> bool
True with an injected client, or with the SDK plus an API key.
_get_client(self) -> Any
Construct the SDK client once, under the lock.
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.
promptsystemimagesreturns(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(BaseLLMClient)
GPT models via the openai SDK.
__init__(self, model: str = "gpt-4o", api_key: Optional[str] = None,
client: Any = None, **options: Any) -> None
Configure the OpenAI client.
model"gpt-4o". Also the pricing key.api_keyOPENAI_API_KEY environment variable when NoneclientoptionsBaseLLMClient — max_tokens, temperatureis_available(self) -> bool
True with an injected client, or with the SDK plus an API key.
_get_client(self) -> Any
Construct the SDK client once, under the lock.
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.
promptsystemimagesreturns(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(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.
textNone and empty both raise, since there is a real difference between "the model said nothing" and "the model said something unparseable".returnsdict, sometimes a list when the model wrapped its answer in oneraises ExtractionErrorextract catches this per chunk and records a warning, so one unparseable chunk does not lose the others._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(text: str) -> Optional[str]
Slice out the first balanced {...} or [...], ignoring braces in strings.
_repair_json(text: str) -> str
Fix the small syntax errors models actually make.
Where a value was found, and how well it matched.
bbox: BBoxscore: floattext: str = ''source: str = ''to_dict(self) -> Dict[str, Any]
JSON-ready dict.
_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(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(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.
docvalue48250 finds "Rs 48,250.00" and "2024-04-12" finds "12-04-2024". None returns no evidence.min_score0.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_window8 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.pagesNone 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.returnsEvidence 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(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(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".
docevidencelocate_valuereturnsNone when no supporting span reported one. Vision backends report nothing here, which is why cross-read agreement carries the weight for them.A business-rule failure, optionally attributed to specific fields.
message: strfields: List[str] = field(default_factory=list)severity: str = 'error'to_dict(self) -> Dict[str, Any]
JSON-ready dict.
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.
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.
modelvalidatorsNone (passed), a string (an error), a ValidationIssue, or a list of those.returns"warning" naming the exception.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"line_items" or "charges"amount_field"amount"total_field"total" or "net_payable"toleranceDecimal. 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_tolerance0.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.returnsValidator closure. It passes quietly when either field is missing, since "this document has no line items" is not an arithmetic failure.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"admission_date"later_field"discharge_date"allow_equalTrue is right for same-day admission and discharge; set False when the two genuinely cannot coincide.returnsValidator closure, passing quietly when either date is missing. Both values must be comparable — coerce them to dates first via a date-typed schema field.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_namepatternre.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.messageNone generates one naming the field and the patternreturnsValidator closure, passing quietly when the field is absent — absence is a completeness question, not a format onerequired_fields(*names: str) -> Validator
Validator factory: these fields must be present and non-empty.
_get_attr(obj: Any, name: str) -> Any
Attribute or key access, whichever the object supports.
FieldResult(Generic[T])
One extracted field, wrapped in everything needed to judge it.
name: strvalue: Optional[T] = Noneconfidence: float = 0.0evidence: List[BBox] = field(default_factory=list)method: str = ''signals: Dict[str, Optional[float]] = field(default_factory=dict)raw: Any = Nonewarnings: List[str] = field(default_factory=list)Extraction(Generic[T])
The result of extracting a schema from a document.
model: Optional[T] = Nonefields: Dict[str, FieldResult] = field(default_factory=dict)document: Optional[Document] = Nonecost: 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__getitem__(self, name: str) -> FieldResult
result["total"] — the FieldResult. Raises on unknown names.
namereturnsFieldResult, carrying value, confidence and evidence togetherraises KeyErrorvalue when a missing field should be a default rather than an error.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.
namedefaultNonereturnsconfidence(self, name: str) -> float
Confidence for name, or 0.0 when the field is absent.
namereturns0.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.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.
low_confidence(self, threshold: float = 0.7) -> List[FieldResult]
Fields a human should look at. The point of the whole exercise.
threshold0.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.returnsFieldResult objects, least confident first, so a review queue is already in priority orderis_valid(self) -> bool
True when no validator reported an error (warnings are allowed).
to_dict(self) -> Dict[str, Any]
JSON-ready dict of the whole result, fields and all.
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.
chunksreturnsoverlap_pages does not double every line item it carried.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.
docread. An unread document yields empty prompt text and no evidence to locate values against.schema{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"Indian private hospital bill, INR". The cheapest accuracy in the library — it is what disambiguates lakh grouping and local date order.clientLLMClient — one of AnthropicClient, or OpenAIClient, or EchoClient in tests. Required.validatorssystemDEFAULT_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_instructionscontext.max_chars60000 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_pages1 is usually enough; 0 disables it and is only right when pages are known to be independent.include_imagesweightsNone uses the defaults in DEFAULT_CONFIDENCE_WEIGHTS. The signals and what they mean are set out in fuse_confidence.calibrator0.9 does not mean 90% right. Fit a PlattCalibrator on your own labelled set to change that.locate_evidencemin_evidence_score0.0..1.0, passed to locate_value.retries2 means up to three tries. Configuration errors and missing dependencies are never retried.returnsExtraction carrying the built model, and one per-field FieldResult with its confidence and evidence, plus the accumulated cost, validation issues and warningsraises ConfigErrorclient was givenConfidence 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(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.
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.
namelabelr"total\s*amount"value_patterndirectionright, below or same_linetype_namename: strlabel: strvalue_pattern: str = '(.+)'direction: str = 'right'type_name: str = 'string'max_distance_pt: float = 260.0occurrence: int = 0extract_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.
docrulesFieldRulereturnsFieldResult 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(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(line_spans: Sequence[TextSpan], offset: int) -> float
Approximate the x coordinate of a character offset within a joined line.
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.
One labelled document: the file, the truth, and how it is grouped.
case_id: strpath: strtruth: Dict[str, Any] = field(default_factory=dict)tags: List[str] = field(default_factory=list)meta: Dict[str, Any] = field(default_factory=dict)to_dict(self) -> Dict[str, Any]
JSON-ready dict.
How one field of one document scored under one pipeline.
case_id: strpipeline: strfield: strexpected: Any = Nonepredicted: Any = Noneexact: bool = Falsefuzzy: float = 0.0within_tolerance: bool = Falseconfidence: float = 0.0has_evidence: bool = Falsepage_verdict: str = 'unknown'type_name: str = 'string'Everything one pipeline produced for one case.
case_id: strpipeline: stroutcomes: List[FieldOutcome] = field(default_factory=list)cost: Cost = field(default_factory=Cost)latency_ms: float = 0.0error: str = ''page_verdict: str = 'unknown'to_dict(self) -> Dict[str, Any]
JSON-ready dict of one case's outcomes, cost and timing.
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.
expectedNone values count as a correct match — the pipeline agreeing that a field is absent is right, not a miss.predictedtype_name"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_tolerance0.005 allows half a percent. Applied to the larger magnitude of the pair.fuzzy_threshold0.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 = ('field_exact', 'field_fuzzy', 'numeric_tolerance', 'field_recall', 'evidence_coverage',...
Metrics run knows how to compute.
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")__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.
casesnamefrom_dir defaults it to the folderfield_typesfrom_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.
directorytruth_suffixnamereturnsraises ConfigErroradd(self, case: EvalCase) -> EvalSuite
Append a case. Returns self, so cases chain.
casereturnsself, so calls chainfilter(self, predicate: Callable[[EvalCase], bool]) -> EvalSuite
A new suite holding only the cases satisfying predicate.
predicateCallable[[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__len__(self) -> int
Number of labelled cases.
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.
pipelinesExtraction. A configured Pipeline is already such a callable. Pass two to A/B them against identical cases::report = suite.run({"baseline": current, "candidate": tuned})
metricsKNOWN_METRICS to compute; None computes all of them.max_workers0 runs serially. Cases are independent, so this scales well — but it multiplies your concurrent API calls by the same factor.numeric_tolerancecompare_values. 0.005 is half a percent.on_caseon_case(pipeline_name, case_id) for progress reporting.returnsEvalReport holding every per-field outcome, not just the aggregates — which is what lets the CI gate of regression_vs name what broke.raises ConfigErrorKNOWN_METRICS_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(value: Any) -> str
Guess a field's type from its ground-truth value.
Scored results for one or more pipelines over one suite.
suite: str = ''metrics: List[str] = field(default_factory=list)created_at: str = ''case_count: int = 0results: Dict[str, List[CaseResult]] = field(default_factory=dict)LOWER_IS_BETTER = frozenset(['confidence_calibration', 'brier', 'cost_per_doc', 'latency_p50', 'latency_p95', 'error_rate'])RELATIVE_METRICS = frozenset(['cost_per_doc', 'latency_p50', 'latency_p95'])[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.pipelines(self) -> List[str]
Names of the pipelines in this report, sorted.
outcomes(self, pipeline: str) -> List[FieldOutcome]
Every field outcome for pipeline, across all cases.
pipelinereturnsFieldOutcome 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.compute(self, pipeline: str) -> Dict[str, float]
All requested metrics for one pipeline.
pipelinereturnsKNOWN_METRICS the run requested. cost_per_doc reads 0.0 until set_pricing is called, though token counts behind it are real.by_field(self, pipeline: Optional[str] = None) -> Dict[str, Dict[str, Any]]
Per-field accuracy — which fields degraded, not just the average.
pipelineNone takes the only one, which is unambiguous for a single-pipeline reportThe 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.
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?
pipelineNone takes the only one, which is unambiguous for a single-pipeline reportA 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.
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.
pipelineNone takes the only one, which is unambiguous for a single-pipeline reportbins10 is conventionalreturnsreliability_curve — one dict per bin with count, mean_confidence, accuracy and gapfit_calibrator(self, pipeline: Optional[str] = None,
kind: str = "platt") -> Calibrator
Fit a calibrator on this report, ready to pass to extract.
pipelineNone takes the only one, which is unambiguous for a single-pipeline reportkind"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.returnsCalibrator. Serialise it with to_dict and pass it to extract or Pipeline to make the confidence numbers mean what they say.raises ConfigErrorThis closes the loop: measure, calibrate, ship the calibrator, and the confidence numbers a reviewer sees start meaning what they say.
errors(self, pipeline: Optional[str] = None) -> List[Tuple[str, str]]
(case_id, error) for every case the pipeline failed on.
pipelineNone takes the only one, which is unambiguous for a single-pipeline reportreturns(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.worst_cases(self, pipeline: Optional[str] = None,
limit: int = 10) -> List[Tuple[str, float]]
Cases with the lowest field accuracy — where to look first.
pipelineNone takes the only one, which is unambiguous for a single-pipeline reportlimitreturns(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.compare(self, baseline_pipeline: str, candidate_pipeline: str) -> Dict[str, Any]
Metric deltas and per-field regressions between two pipelines.
baseline_pipelinecandidate_pipelinereturnsregression_vs.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.
baselinesave, or else an EvalReport already in memorypipelineNone takes the first. The baseline is matched by the same name, falling back to its own first pipeline.tolerance0.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.metricsNone compares every metric present in either report.returnsregressions list. An empty regressions list is the pass condition — which is what docpipe eval --baseline exits non-zero on.raises ConfigErrorsummary(self) -> str
A human-readable table, for a terminal or a CI log.
to_dict(self) -> Dict[str, Any]
JSON-ready dict: the computed metrics plus every raw outcome.
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.
pathreturnspath, so it can be used inlineload(cls, path: str) -> EvalReport
Read a report back from a file written by save.
pathsavereturnsraises OSErrorraises ValueError_mean(values: Sequence[float]) -> float
Arithmetic mean, or 0.0 for an empty sequence.
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.
policy: Optional[PolicyFn] = default_policyocr_policy, vlm_policy, or your own Callable[[Page], List[Op]]. None skips preprocessing — the baseline to measure against.router: Optional[RouterFn] = default_routerRuleRouter, BudgetRouter, or your own Callable[[Page], Optional[str]]. None sends every page to backend.backend: Optional[Union[str, TextBackend]] = None"pymupdf", "tesseract", "paddle", "anthropic") or a TextBackend. Takes precedence over router.schema: Optional[SchemaLike] = None{field: type} dict. None means text only: no model call, no cost.context: str = ''"Indian private hospital bill, INR".client: Optional[LLMClient] = NoneLLMClient that does the extracting. Required when schema is set.validators: List[Validator] = field(default_factory=list)calibrator: Optional[Calibrator] = NonePlattCalibrator.render_dpi: int = DEFAULT_DPImax_pages: int = 00 means no limit.max_workers: int = 00 runs serially, which is what you want while debugging.split_pages: bool = Falsesplit_multi_bill_page.normalize: bool = Truebudget: Optional[float] = NoneBudgetExceeded. None means unbounded. Meaningless until set_pricing is called — unpriced models cost 0.00.name: str = 'pipeline'ingest(self, source: Source, **kwargs: Any) -> Document
Ingest source using this pipeline's DPI and page limit.
sourcebytes, or open binary file — anything ingest acceptskwargsingest (password, kind_hint, ...); render_dpi and max_pages come from this pipelinereturnsDocument whose rasters are still lazy — nothing has been rendered yetrun_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.
docreturnspage.spans populated, page.history recording every op applied, and text normalised if normalize is setrun(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.
sourcebytes, or open binary file — anything ingest acceptskwargsingest, e.g. password="secret" for an encrypted PDFreturnsExtraction; when schema is None its model is None and only document and cost are filled inraises IngestErrorsource could not be read as a documentraises BudgetExceededbudget and reading exceeded it__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].
sourcerunkwargsrunreturnsExtractionto_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(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"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{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"Indian private hospital bill, amounts in INR". Cheap and effective — it is what tells the model that 1,20,000 is one-lakh-twenty.clientLLMClient doing the extracting — one of AnthropicClient, OpenAIClient, or the deterministic EchoClient in tests. Required whenever schema is given.policydefault_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.routerdefault_router; see also RuleRouter and BudgetRouter. None sends every page to backend.backend"pymupdf", "tesseract", "paddle", "anthropic") or an instance of TextBackend. Overrides router when both given.validatorsValidators holds the ready-made ones.kwargsPipeline — render_dpi, max_pages, max_workers, split_pages, normalize, budget, calibrator, name.returnsExtraction 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 ConfigErrorschema was given without a clientraises IngestErrorsource could not be read as a documentraises BudgetExceededbudget was set and reading would exceed itText 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)python -m docpipe caps_cli_caps(args: Any) -> int
docpipe caps — report importable integrations and backends.
_cli_info(args: Any) -> int
docpipe info — ingest a document and describe what arrived.
_cli_quality(args: Any) -> int
docpipe quality — measure per-page degradation.
_cli_preprocess(args: Any) -> int
docpipe preprocess — apply a policy, optionally writing PNGs.
_cli_read(args: Any) -> int
docpipe read — preprocess and OCR, printing text or JSON.
_cli_extract(args: Any) -> int
docpipe extract — run a schema end to end. Exit 2 if invalid.
_cli_eval(args: Any) -> int
docpipe eval — score a dataset, optionally gating on a baseline.
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.
argvNone reads argv, which is what the console entry point does; pass a list to drive the CLI from a test.returns0 on success, 1 on error, and 1 from eval --baseline when a metric regressed, which is what makes it usable as a CI gate