StagesRecognise

PaddleRecognizer

Reads exactly the regions a detector found with a PaddleOCR preset. The stage that connects any detector to PP-OCR recognition.

Import
import { PaddleRecognizer } from '@stabrise/scaledp/ocr'
Group
Recognise
Reads
image, boxes
Writes
document
Needs
ppu-paddle-ocr

PaddleTextRecognizer detects and recognises in a single pass, so boxes from a separate detector never reach it. This stage does the opposite: it reads exactly the boxes it is handed, cropping and straightening each one — the PaddleOCR counterpart to TesseractRecognizer.

import { Pipeline } from '@stabrise/scaledp'
import { PdfToImage } from '@stabrise/scaledp/pdf'
import { DbnetOnnxDetector, PaddleRecognizer } from '@stabrise/scaledp/ocr'

const rows = await new Pipeline([
    new PdfToImage(),
    new DbnetOnnxDetector({ outputCol: 'boxes' }),
    new PaddleRecognizer({ inputCols: ['image', 'boxes'] }),
]).transform(file)
Open in builder

Document.type is 'paddle-recognizer'. A missing box column produces a helpful OcrError in exception rather than an empty result.

Only the recognition model is downloaded

A preset pairs a detection model, a recognition model and a character dictionary. This stage never detects, so it fetches only the last two. The cache keys are shared with PaddleTextDetector and PaddleTextRecognizer, so a pipeline that also uses one of those pays for each file once.

The preset is still what picks the script: the same list, and the same choice as everywhere else in the library.

boxLevel decides how fine the output boxes are

'word' is the default, matching TesseractRecognizer, so swapping the engine does not change how finely the page comes back.

Getting there takes more work than it does with Tesseract, which reports word rects of its own. PaddleOCR reads a crop into one undivided string with no geometry inside it, so the words have to be recovered — but the line is read first, whole, and only then split.

The order is load-bearing. Cutting the crop first and reading each word on its own gives markedly worse text: PP-OCR's recogniser is a CTC model trained on full text lines, and a three-character crop stretched to its fixed input height is nothing like what it saw in training. It also throws away the line's context, which is where the model's accuracy comes from. Reading whole and splitting afterwards is both better and cheaper — one inference per line instead of one per word.

Splitting reconciles two independent readings of the same line: the words the model reported, and the ink gaps a vertical projection of the crop finds — any blank column run at least wordGapRatio of the crop's height wide. The text says what the words are; the spans say where. When the counts agree, which is the ordinary case, they zip together and every box is measured geometry.

When they disagree, the ink decides how many boxes there are and the text decides what is in them. No box is ever invented:

  • More spans than words — the ink gapped where the model read no space. Adjacent spans merge, smallest gap first, until the counts meet.
  • More words than spans — the model read a space the ink does not show. The extra words are joined back onto the spans they fall on, so one block of ink gives one box carrying all of its words.

The second case is not an edge case: on a signature or a scribble the model routinely emits a scatter of single letters for one continuous stroke. Cutting a span up to match would hand back a row of boxes with identical widths and heights — the character count drawn as a rectangle rather than anything measured, which reads as per-symbol noise. Joining keeps every box a span the projection actually found.

Spans are mapped back through the crop's own transform, rotation included, so a word inside a skewed line comes back skewed the same way. Every word carries its line's score, because that is the only score computed — the model scored the line, not its parts.

'region' keeps one box per region the detector found, carrying everything read inside it. The recognition is identical either way, so this changes only how the result is cut up; it is the right choice for a script that does not separate words with spaces — Chinese, Japanese and Thai have no gaps to cut on, and word mode will return one box per line there anyway.

spaceRecovery is off, and word splitting does not need it. The spaces between words come from ppu's own decode, which places them from the geometry of the recognised characters — their spacing against the median inter-character gap. spaceRecovery is a separate, cruder pass that inserts a space wherever the space class scores above 0.001, a threshold nearly every timestep clears on small or noisy text. Turned on there, a line comes back as s e n s i t i v e. It is only for recovering a genuinely collapsed word gap on clean, large text.

There is no strategy parameter, unlike PaddleTextRecognizer. PaddleOCR's 'per-line' and 'cross-line' strategies merge boxes together before reading them; the contract here is one result per crop, which only 'per-box' can honour.

Why the crops are stacked

PaddleOCR reads crops out of the one canvas it is handed per call. So the crops are stacked onto a sheet canvas and read in one call, rather than one call (and one main-thread yield) per line. The blank width beside a narrow crop is never sampled, because each crop is re-cut from its own slot.

Word level costs nothing extra here: lines are read whole and split afterwards, so a word box never needs a crop or an inference of its own.

One line per inference

recBatchSize is how many lines share one inference, and it defaults to 1. A batch pads every line to the widest in it, and ppu pads by repeating the line's last column, which on a document is white paper. PP-OCR's models were trained on PaddleOCR's zero padding (mid-grey once normalised), and they misread the white: on the sample scans a batch of 6 garbled 8 of 22 and 10 of 16 lines, inventing characters or dropping the line. Batching is not faster on WASM either, since the padding is computed like any other pixel. Leave it at 1; the measurements are on Benchmarks.

The recognition model runs on WebGPU as well as WASM: the library fetches the .onnx twin of ppu's .ort models, which WebGPU can load. See Execution providers.

Straightening

The crop is a perspective warp onto the box's own axes, which is what makes a skewed line readable at all — PaddleOCR's own cropping is axis-aligned, so handing it a rotated box would read that box's envelope instead.

padding defaults to 5, which is what ScaleDP hardcodes. scaleFactor resizes the page before cropping and scales the box to index into it — useful for small text. The boxes reported back are the originals, in the space they arrived in.

detectLineOrientation defaults off here

TesseractRecognizer defaults it to true; this stage defaults it to false. PaddleOCR already turns a crop that is markedly taller than it is wide, so only the 180° case is missing — and catching it costs a separate ~9 MB model that an ordinary page never needs.

Turn it on for scans that come in upside down. It uses the same model as LineOrientationDetector, so putting that stage in front as well would run it twice.

Letting the page choose the model

presetCol takes the recognition model from a column, per page, instead of the fixed preset — put a TesseractScriptDetector in front and the model follows the script it found. preset stays as the fallback for pages it could not classify. See PaddleTextRecognizer for the full shape.

Parameters

ParameterTypeDefaultMeaning
inputColsstring[] (2)['image', 'boxes']The page image, then the detector output whose regions to read.
preset'v6-small' | 'v6-medium' | 'v6-tiny' | 'v5-latin-mobile' | … (14 total)'v6-small'Language/script pairing. Only the recognition half is downloaded here.
presetColstring''Take the model from a script-detection column, per page, instead of the fixed preset above. Empty pins it to the preset. Falls back to the preset whenever the column has no usable answer.
scaleFactornumber 0.1–81Resize the page by this factor before cropping.
paddingnumber5Grow each box before cropping. ScaleDP hardcodes 5.
endPaddingnumber 0–20.3Extend each region along its line by this fraction of its height, stopping at the next region -- recovers a handwritten capital or flourish the detector left off. 0 reads regions as detected.
dropNestedbooleantrueSkip a region lying mostly inside a larger one, such as a loose stroke of a handwritten capital, since the larger region already reads it.
scoreThresholdnumber 0–10.5Drop regions below this confidence.
keepFormattingbooleanfalseRebuild the original layout with spaces and blank lines.
lineTolerancenumber0Line-grouping tolerance in pixels; 0 derives it from character height.
detectLineOrientationbooleanfalseClassify each crop 0°/180° and turn the inverted ones. Off by default: PaddleOCR turns tall crops itself, and only the 180° case needs this extra model.
onlyRotatedbooleanfalseRead only rotated or inverted boxes. On, an ordinary page returns nothing.
boxLevel'word' | 'region''word'The line is read whole either way; word splits the reading on its own spaces, matched to the crop’s ink gaps. Pick “region” for a script that does not separate words with spaces.

On this page