Skip to content

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 Page before 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 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.

Each method appends a step to the preprocess phase. Cardinality is 0..N; relative order is preserved.

MethodWhat 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

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() 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.

Bilateral filter. Conservative defaults; raise strength only for phone-camera scans of textured paper.

.denoise(strength=1)

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)

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.

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()).

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.


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:

  1. Transform a region’s crop in place (binarise this cell only, sharpen all data cells, add 12 px of padding to header rows).
  2. 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_override set (transforms — the Worker reads region.crop_override in preference to cropping page at region.bbox).
  • Existing regions with role changed (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.

Crop transforms (one region → one region with crop_override)

Section titled “Crop transforms (one region → one region with crop_override)”
OpWhat 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.

OpWhat 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.

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.

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.

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).

Three recurring patterns:

  1. Mixed-quality cells in one form. Header rows are typeset; data cells are stamped or handwritten. Binarise only the data cells.
  2. 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.
  3. Tight bboxes from an aggressive splitter. .pad(px=12) around each crop recovers character tops and descenders without re-splitting.
  • 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.