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)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
| Parameter | Type | Default | Meaning |
|---|---|---|---|
inputCols | string[] (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. |
presetCol | string | '' | 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. |
scaleFactor | number 0.1–8 | 1 | Resize the page by this factor before cropping. |
padding | number | 5 | Grow each box before cropping. ScaleDP hardcodes 5. |
endPadding | number 0–2 | 0.3 | Extend 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. |
dropNested | boolean | true | Skip a region lying mostly inside a larger one, such as a loose stroke of a handwritten capital, since the larger region already reads it. |
scoreThreshold | number 0–1 | 0.5 | Drop regions below this confidence. |
keepFormatting | boolean | false | Rebuild the original layout with spaces and blank lines. |
lineTolerance | number | 0 | Line-grouping tolerance in pixels; 0 derives it from character height. |
detectLineOrientation | boolean | false | Classify 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. |
onlyRotated | boolean | false | Read 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. |