Preprocessors
Preprocessors
Section titled “Preprocessors”A preprocess step manipulates pixels before they reach the Worker.
scriva ships two flavours, distinguished by what they see:
- Page-level preprocess steps — transform a whole
Pagebefore splitting. Rotate, crop, deskew, denoise, normalise, orient. - Region refine ops — transform crops or split regions after the split step, before recognition. Per-cell binarise, slice a tall region into rows, pad, sharpen.
Both attach to the Orchestrator chain. Page-level calls hang directly off the Orchestrator; region refine ops hang off the split step:
.deskew() / .crop() / .denoise() ──► .split.<method>() ──► .refine.<op>() ──► .classify() ──► .recognize(...)The two phases sit either side of .split.*() because they work on
different inputs (raw pixels vs identified crops).
Page-level preprocess steps
Section titled “Page-level preprocess steps”Page-level steps return a new Page — preprocess steps are pure,
never in-place. The original page is preserved on
ctx.original_page so downstream steps can refer back if they need
to.
Built-in page preprocess steps
Section titled “Built-in page preprocess steps”Each method appends a step to the preprocess phase. Cardinality is
0..N; relative order is preserved.
| Method | What it does |
|---|---|
.orient(...) | Coarse rotation (0/90/180/270°) via Tesseract OSD |
.rotate(degrees=...) | Fixed rotation |
.deskew(...) | Sub-degree skew correction via projection-profile alignment |
.denoise(...) | Bilateral-filter noise removal |
.normalise(...) | Contrast stretch and optional DPI resample |
.crop(...) | Fixed-rectangle or auto-margin crop |
.dewarp(...) | Document-perspective correction (photo of a curled page) |
.whiteboard_clean(...) | Strip marker hue, normalise lighting (Office-Lens-style) |
.binarise(...) | Otsu / adaptive binarisation for low-quality scans |
.invert() | Negative — for white-on-dark documents |
.orient(...)
Section titled “.orient(...)”Phone-camera and document-scanner outputs often arrive 90°, 180°, or
270° off. .orient() uses Tesseract OSD (Orientation and Script
Detection) to estimate the angle and then applies an exact-pixel
PIL.Image.transpose — no interpolation blur, no JPEG recompression.
recipe = ( Orchestrator() .orient( backend="tesseract", # or a custom OrientationEstimator on_failure="skip", # "skip" | "raise" save_format="png", # "png" preserves quality; "keep" reuses input format ))If Tesseract is not on $PATH, the step logs once and leaves the page
unchanged. Set on_failure="raise" to make missing OSD a build-time
EngineError instead.
Custom backends implement the OrientationEstimator protocol:
class OrientationEstimator(Protocol): async def estimate(self, page: Page) -> int: ... # returns 0, 90, 180, or 270.deskew(...)
Section titled “.deskew(...)”.deskew() handles small skew angles introduced by sheet-feed
scanners. Pure OpenCV / NumPy — no model dependency. Running it on an
already-straight scan costs less than 30 ms on a 2400 px page, but a
safety threshold keeps the no-op cheap:
.deskew( max_correction_deg=5.0, # never rotate by more than this threshold_deg=0.3, # below this, do nothing)Run .orient() first when both are present — a 90°-rotated scan has
no meaningful “skew” to measure.
.denoise(...)
Section titled “.denoise(...)”Bilateral filter. Conservative defaults; raise strength only for
phone-camera scans of textured paper.
.denoise(strength=1).normalise(...)
Section titled “.normalise(...)”Stretches contrast to [0, 255] and can resample to a target DPI.
Most useful in front of splitters trained at a known resolution.
.normalise(target_dpi=None, contrast=True).crop(...)
Section titled “.crop(...)”Applies a fixed-rectangle or auto-margin crop in page-pixel coordinates before any other step runs. Useful when an upstream UI already let the user select the region of interest.
.crop(bbox=(x, y, w, h)) # fixed.crop(margins="auto") # detect document margins and trim.dewarp(), .whiteboard_clean(...), .binarise(...), .invert()
Section titled “.dewarp(), .whiteboard_clean(...), .binarise(...), .invert()”.dewarp() # photo of a curled page → flat.whiteboard_clean(saturation_drop=0.8, # office-lens-style contrast_boost=1.4).binarise(method="otsu") # or "adaptive", "sauvola".invert() # white-on-dark → dark-on-white.binarise() is the cheapest accuracy lever for low-DPI grayscale
scans — many VLMs and all classical engines score noticeably higher on
two-tone input.
Composing page-level steps
Section titled “Composing page-level steps”from scriva import Orchestrator
recipe = ( Orchestrator() .orient() # rotate first — everything else assumes upright .deskew() .denoise() .split.grid() .recognize(worker))Put .orient() before .deskew(), and put any geometry-changing
step (.crop(), .orient()) before any colour-space one
(.normalise(), .denoise()).
Writing your own page preprocess step
Section titled “Writing your own page preprocess step”Subclass for stateful steps:
from scriva import Step, Context
class Grayscale(Step): phase = "preprocess" name = "grayscale"
async def run(self, ctx: Context) -> Context: ctx.page = ctx.page.with_image(ctx.page.image.convert("L")) return ctx…or decorate a function for stateless ones:
from scriva import step
@step(phase="preprocess", name="grayscale")async def grayscale(ctx): ctx.page = ctx.page.with_image(ctx.page.image.convert("L")) return ctx
recipe = recipe.then(grayscale)Page is immutable; use page.with_image(...) or
page.replace(**kwargs) to derive a new one — same pattern as
Recognition.
Region refine
Section titled “Region refine”A region refine op operates on the layout after split — so it can see what the splitter produced and apply different treatment per region. Two things it can do:
- Transform a region’s crop in place (binarise this cell only, sharpen all data cells, add 12 px of padding to header rows).
- Slice a region into multiple sub-regions (split a tall paragraph into N rows, split a misdetected merged cell into halves).
Both attach to the split step:
.split.grid().refine.pad(top=4, bottom=4).split.grid().refine.binarise(method="otsu").split.grid().refine.slice_overflow(direction="vertical")Each .refine.<op>() call returns a new Orchestrator with the op
appended to the refine chain. Multiple ops compose:
.split.grid() .refine.pad(px=8) .refine.binarise(method="otsu") .refine.sharpen(strength=0.6) .refine.slice_overflow(max_height_px=1500)The returned Layout may have:
- New regions appended (slicers — one in, N out).
- Existing regions with
crop_overrideset (transforms — the Worker readsregion.crop_overridein preference to croppingpageatregion.bbox). - Existing regions with
rolechanged (e.g. demoted to"blank"after a binarisation reveals the cell is empty).
Region.crop_override is a PageCrop — the same shape page.crop(...)
returns. Transforms compose naturally: a region that has been
sharpened, then padded, then binarised carries the final crop with all
three steps baked in.
Built-in refine ops
Section titled “Built-in refine ops”Crop transforms (one region → one region with crop_override)
Section titled “Crop transforms (one region → one region with crop_override)”| Op | What it does |
|---|---|
.pad(px=8) | Add uniform padding around each region’s crop |
.pad(top=, right=, bottom=, left=) | Asymmetric padding |
.binarise(method="otsu") | Per-cell binarisation (separately tuned per cell) |
.sharpen(strength=1.0) | Unsharp mask |
.contrast(factor=1.4) | Local contrast boost |
.scale(factor=2.0) | Lanczos upscale — useful for tiny cells in dense forms |
.equalise() | Histogram equalisation |
.grayscale() | Drop chroma; cheaper for some VLM providers |
.invert() | Per-cell inversion (white-on-dark cells in a mixed page) |
.dewarp() | Region-local perspective correction |
.glare_remove(strength=1.0) | Remove specular highlights in photo crops |
.whiteboard_clean(...) | Region-local marker / lighting normalisation |
.deskew_region(...) | Per-region micro-deskew (for cells rotated independently) |
.mask(predicate) | Black out pixels for which predicate(x, y) is True |
.pad() is the workhorse — many VLMs lose accuracy when a crop is
tight to the glyph baseline. The default 4 px on the Worker is a
fallback; when you want different padding per role (more for headers,
less for dense data cells), use .refine.pad(...) here instead.
Slicers (one region → N regions)
Section titled “Slicers (one region → N regions)”| Op | What it does |
|---|---|
.slice_horizontal(rows=N, overlap_px=0) | Split into N equal-height bands |
.slice_vertical(cols=N, overlap_px=0) | Split into N equal-width bands |
.slice_grid(rows=R, cols=C) | R × C grid of sub-regions |
.slice_on_separator(direction=..., min_gap_px=...) | Split where a whitespace gap ≥ min_gap_px is found |
.slice_overflow(max_height_px=..., direction=...) | Slice only regions exceeding max_height_px; leave the rest alone |
.slice_by(callable) | Caller-supplied splitter: Region → list[Region] |
Slicers preserve region.id as the parent and assign each child a
fresh ID with parent_region_id pointing back. Recognitions merge
back into the parent on result.merge_slices() — by default, the
exporter does this automatically.
Filtering: where=
Section titled “Filtering: where=”Every refine op accepts a where= callable that filters which regions
it touches. Regions for which it returns False are passed through
untouched:
.split.grid() .refine.binarise(method="otsu", where=lambda r: r.role == "data") .refine.pad(px=12, where=lambda r: r.role == "header") .refine.slice_horizontal(rows=2, where=lambda r: r.bbox.h > 1200) .refine.slice_on_separator(direction="horizontal", min_gap_px=20, where=lambda r: r.role == "data")where= is the single composition primitive — chains of “different
treatment per role” stay readable.
A full chain
Section titled “A full chain”from scriva import Orchestrator, Worker
recipe = ( Orchestrator() .orient() .deskew() .split.grid() .refine.pad(px=8, where=lambda r: r.role == "data") .refine.binarise(method="otsu", where=lambda r: r.role == "data") .refine.sharpen(strength=0.6) .refine.slice_overflow(max_height_px=1500) .classify(blank=True, merged=True) .recognize(Worker.openai("gpt-4o")))Order within the refine chain is the order you wrote.
Writing your own region refine op
Section titled “Writing your own region refine op”Subclass:
from scriva import Step, Context
class StripHeaderBars(Step): phase = "split.refine" name = "strip-header-bars"
async def run(self, ctx: Context) -> Context: for region in ctx.layout.regions: if region.role == "header": crop = ctx.page.crop(region.bbox, padding=0) region.crop_override = crop.with_image(_strip_top_bar(crop.image)) return ctx…or decorate a function:
from scriva import step
@step(phase="split.refine", name="strip-header-bars", where=lambda r: r.role == "header")async def strip_header_bars(ctx, region): crop = ctx.page.crop(region.bbox, padding=0) return region.with_crop_override(crop.with_image(_strip_top_bar(crop.image)))The decorated form gets called per-region — the framework iterates and
respects where=. The subclass form gets the whole layout in one
call, which is what you want when the transform needs cross-region
context (e.g. global statistics, neighbour-aware slicing).
When per-cell refining pays off
Section titled “When per-cell refining pays off”Three recurring patterns:
- Mixed-quality cells in one form. Header rows are typeset; data cells are stamped or handwritten. Binarise only the data cells.
- Tall paragraphs that overflow a VLM’s effective resolution.
.slice_overflow(max_height_px=1500)splits them into bands; the Worker sees clean, in-scale text on each. - Tight bboxes from an aggressive splitter.
.pad(px=12)around each crop recovers character tops and descenders without re-splitting.
When not to use a region refine op
Section titled “When not to use a region refine op”- If the transform needs to see the whole page in raw form, use a page-level preprocess step — refine ops get a cropped view by design.
- If the transform is about output text (whitespace, dictionary correction, splitting on detected ruled lines), use a reconstruct step. Reconstruct runs after the Worker; refine runs before it.
- If the split depends on what the Worker said (e.g. “split this cell
because the recognised text contains
│”), use.reconstruct.rule_splitter()— it ships exactly that.
What to read next
Section titled “What to read next”- Orchestrator › High-accuracy patterns — Region refine is one of three accuracy levers; the others are human-in-the-loop and cross-checking.
- Detectors — region refine ops run after split; if the splitter itself needs work, start there.
- Postprocessors — the symmetric phase on the output side.