DEV Community

Mykola Melnyk
Mykola Melnyk

Posted on

Running OCR entirely in the browser meant decoupling detection from recognition

Running OCR in the browser meant decoupling detection from recognition

Written 2026-09-04. Code examples target @stabrise/scaledp@0.1.1 and onnxruntime-web@1.20.x. This post assumes basic familiarity with OCR (text detection vs. text recognition) and TypeScript; it does not assume you know onnxruntime-web.

TL;DR: @stabrise/scaledp is a document-AI pipeline — PDF rendering, OCR, NER — that runs entirely client-side on onnxruntime-web, no server involved. The recognizer that ships with it, PaddleOCR, detects and reads text in one pass, which meant no other detector could ever feed it a box. We built PaddleRecognizer, a second stage with the contract [image, boxes] -> Document, so DBNet or a YOLO signature detector can hand PaddleOCR's recognition model boxes it never found itself. Along the way we hit a WebGPU kernel gap, a batching constraint in the underlying library, and a word-splitting bug that was quietly dropping characters.

The problem: an OCR stage that couldn't be composed

@stabrise/scaledp is a TypeScript port of ScaleDP, a Python/Spark document-processing library. The pipeline model is the same in both: a list of stages, each stage a pure function over a row, [image, text, boxes, ...] in, [image, text, boxes, ...] out. The point of the TypeScript version is that it never leaves the browser — no upload, which matters a lot once you're running OCR over things like IDs, contracts, or medical forms.

For text recognition we use ppu-paddle-ocr, a WASM/ONNX port of PP-OCR. Our PaddleTextRecognizer stage wraps it directly: hand it a page image, get back text and boxes. Convenient, but it hides a detail that matters once you have more than one detector — PP-OCR's run() call does detection and recognition together. There was no way to say "read this specific set of boxes"; the boxes always came from PP-OCR's own detector.

That's a real limitation, because the library also ships DbnetOnnxDetector (the same DBNet ONNX model ScaleDP uses server-side) and a YOLO-based signature/face detector. If your pipeline picks DBNet for detection — maybe because it's faster, maybe because you're comparing detectors, maybe because you need YOLO to find signature regions specifically — there was no path from those boxes into PP-OCR's recognizer. You'd fall back to Tesseract, which does support recognize-only via TesseractRecognizer, even if PaddleOCR would read your script better.

The fix: split detection out of the recognizer

PaddleRecognizer is the missing half. Same contract as TesseractRecognizer:

[imageColumn, boxColumn] -> Document
Enter fullscreen mode Exit fullscreen mode

so any stage that writes a DetectorOutput column — DBNet, YOLO, PaddleOCR's own detector — can feed it.

PaddleTextRecognizer's single detect-and-recognize call versus the two-stage PaddleRecognizer pipeline

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

configure({ cache: 'indexeddb', pdf: { workerSrc: '/pdf.worker.min.mjs' } })

const pipeline = new Pipeline([
  new PdfToImage({ resolution: 300 }),
  new DbnetOnnxDetector({ inputCol: 'image', outputCol: 'boxes' }),
  new PaddleRecognizer({ inputCols: ['image', 'boxes'], preset: 'v6-small' }),
])

const rows = await pipeline.transform(file)
console.log(rows[0].text.text)
Enter fullscreen mode Exit fullscreen mode

Detection and recognition are now two stages you can mix independently: swap DbnetOnnxDetector for the YOLO signature detector to recognize only signature regions, or run DBNet once and try several recognizer presets against the same boxes without re-detecting.

Run in the builder, that is DBNet's boxes with PaddleOCR's text in them:

A scanned page with cyan word boxes found by DBNet and read by PaddleRecognizer, including a false positive around a face

Worth noticing the box around the man's face: DBNet found structure there and PaddleOCR dutifully tried to read it. Detection errors do not disappear because you changed which model reads them — they just become somebody else's boxes.

Three things made this harder than "call the recognizer per box," and each is the kind of detail that only shows up once you've profiled or diffed real output.

1. Straighten before you crop

ppu's own cropping is axis-aligned. Hand it a rotated box and it crops that box's bounding rectangle, not the box itself — fine for horizontal text, wrong for anything at an angle (a rotated scan, a stamped/rotated field). PaddleRecognizer straightens each box with a perspective warp before cropping, so a 15° rotated line is read upright instead of read with its neighbors bleeding into the crop.

2. Batch by canvas, not by call

The natural implementation is "for each box, call run()." That's also the slow one: ppu only batches crops within a single run() call, and it cuts them from one canvas. Calling it per box costs one inference and one main-thread yield per line — on a page with 80 lines, that's 80 round trips.

PaddleRecognizer stacks the straightened crops onto a single sheet canvas first, then reads them together in one run(). Results come back sorted into reading order rather than array order, so we match them back to boxes by the slot each crop occupied on the sheet, not by index.

Calling run() per box costs one inference per line; stacking the crops onto one sheet costs one inference per page

// Simplified shape of what happens internally
const crops = boxes.map((box) => straightenAndCrop(image, box))
const sheet = stackOntoSheet(crops)          // one canvas, N slots
const results = await recognizer.run(sheet)  // one inference call
const text = matchBySlotOffset(results, crops)  // not by array position
Enter fullscreen mode Exit fullscreen mode

3. WebGPU can't run this graph

onnxruntime-web's WebGPU backend rewrites convolutions into an internal com.ms.internal.nhwc op set. PP-OCR's recognition graph hits a kernel that isn't implemented for that rewrite, and session creation fails outright — not a slowdown, a hard error. ppu-paddle-ocr already retries on WASM internally when this happens; we exposed the same behavior in our own createSession() as an opt-in fallbackToWasm flag. It's off by default: if you asked for WebGPU and it's genuinely misconfigured for some other reason, we want that to fail loudly, not silently degrade to WASM and leave you wondering why inference is slower than expected.

import { createSession } from '@stabrise/scaledp/ocr'

const session = await createSession(modelBuffer, {
  executionProviders: ['webgpu'],
  fallbackToWasm: true, // PP-OCR recognition specifically needs this
})
Enter fullscreen mode Exit fullscreen mode

A word-splitting bug we found while wiring this up

Building PaddleRecognizer meant looking hard at how word-level boxes get derived from a line the model read as a whole — and that surfaced a pre-existing bug in PaddleTextRecognizer too. The old code cut each line at its ink gaps first and recognized every word crop independently. PP-OCR's recognizer is a CTC model trained on full lines; feeding it a three-character crop stretched to a fixed input height is nothing like its training distribution, and it also throws away the sentence-level context the model's accuracy depends on.

The fix reverses the order: read the line whole, then split the words out of the result, reconciling the model's own word boundaries against a vertical-projection ink-gap scan. Equal counts zip together directly. Where they disagree, the ink decides how many boxes exist and the text decides what's in them — more ink-spans than words merges adjacent spans smallest-gap-first, more words than spans joins the extras back onto the span they overlap. On a real page this measurably fixed dropped characters — https:/stabrise.com/scaledp/ (missing a slash) became https://stabrise.com/scaledp/ — and it's cheaper too: one inference per line instead of one per word.

It also fixed something subtler: on a signature, the old per-word recognition emitted a scatter of single letters for one continuous stroke, and cutting the line up to match produced a row of boxes with identical width and height — the character count rendered as geometry, not anything measured from the pixels. Reading whole-line-first, that signature went from 15 boxes (10 of them these fake uniform ones) down to accurate stroke-level geometry.

Why this shape, not a bigger abstraction

We didn't build a generic "pluggable detector/recognizer interface" with a registry and adapters. PaddleRecognizer and TesseractRecognizer just happen to share the same input contract — [image, boxes] -> Document — because that's the natural shape of "read boxes someone else found," not because we designed an interface for it. Every stage in the pipeline is engine-specific and takes exactly the parameters that engine needs; composability comes from stages agreeing on column names and schemas, the same way Python ScaleDP's Spark stages do. Adding a third recognizer later means writing a third stage with that same two-column contract, not implementing an interface.

That also matters for a rule this library holds hard: stages never throw by default. A batch job over forty PDF pages can't lose the other thirty-nine because page twelve's crop was degenerate. PaddleRecognizer records a failure in the output Document's exception field and returns a well-formed empty document for that row; you opt into throwing with propagateError: true if you'd rather fail fast during development.

Trade-offs

  • Sheet-batching costs memory, not just time. Stacking every crop on a page onto one canvas means the whole page's worth of crops lives in memory at once. For most pages that's trivial; for a page with hundreds of tiny regions (a densely annotated form, say) you may want to chunk boxes into multiple sheets — recBatchSize exists for exactly this, and defaults conservatively.
  • fallbackToWasm trades silent correctness for silent slowness. We chose to make WebGPU failures loud by default specifically so a genuinely broken provider doesn't quietly become "OCR is fine but 3x slower than it should be" with no error to grep for. Turn the flag on once you've confirmed WebGPU works for your other stages and just isn't implemented for this one op set.
  • Straightening every box costs a warp per crop. For an unrotated page (the common case) this is wasted work; we don't currently skip it for axis-aligned boxes, because detecting "close enough to axis-aligned" reliably is its own source of edge cases. If your pipeline is always horizontal-only and profiling shows this mattering, that's a reasonable place to special-case.

Try it

npm install @stabrise/scaledp onnxruntime-web ppu-paddle-ocr pdfjs-dist
Enter fullscreen mode Exit fullscreen mode
import { Pipeline, configure } from '@stabrise/scaledp'
import { PdfToImage } from '@stabrise/scaledp/pdf'
import { DbnetOnnxDetector, PaddleRecognizer } from '@stabrise/scaledp/ocr'

configure({ cache: 'indexeddb', pdf: { workerSrc: '/pdf.worker.min.mjs' } })

const rows = await new Pipeline([
  new PdfToImage({ resolution: 300 }),
  new DbnetOnnxDetector(),
  new PaddleRecognizer({ preset: 'v6-small' }),
]).transform(file)
Enter fullscreen mode Exit fullscreen mode

The live demo (drop a PDF or image, pick a detector and recognizer, run it) is at scaledp-ts.stabrise.com/demo; docs at scaledp-ts.stabrise.com/docs.

Further reading

Top comments (0)