{"slug": "running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition", "title": "Running OCR entirely in the browser meant decoupling detection from recognition", "summary": "StabRise has released @stabrise/scaledp, a document-AI pipeline that runs OCR entirely in the browser via onnxruntime-web, eliminating the need for server-side processing. The new PaddleRecognizer stage decouples text detection from recognition, allowing models like DBNet or YOLO to feed boxes into PaddleOCR's recognizer, which previously only worked with its own detector. The update addresses limitations in the original PP-OCR integration, enabling flexible pipeline configurations for sensitive documents such as IDs and medical forms.", "body_md": "*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.*\n\n**TL;DR:** [ @stabrise/scaledp](https://github.com/StabRise/scaledp-ts) is a document-AI pipeline — PDF rendering, OCR, NER — that runs entirely client-side on\n\n`onnxruntime-web`\n\n, 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`\n\n, a second stage with the contract `[image, boxes] -> Document`\n\n, 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.`@stabrise/scaledp`\n\nis a TypeScript port of [ScaleDP](https://github.com/StabRise/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, ...]`\n\nin, `[image, text, boxes, ...]`\n\nout. 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.\n\nFor text recognition we use [ppu-paddle-ocr](https://www.npmjs.com/package/ppu-paddle-ocr), a WASM/ONNX port of PP-OCR. Our `PaddleTextRecognizer`\n\nstage 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()`\n\ncall 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.\n\nThat's a real limitation, because the library also ships `DbnetOnnxDetector`\n\n(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`\n\n, even if PaddleOCR would read your script better.\n\n`PaddleRecognizer`\n\nis the missing half. Same contract as `TesseractRecognizer`\n\n:\n\n``` php\n[imageColumn, boxColumn] -> Document\n```\n\nso any stage that writes a `DetectorOutput`\n\ncolumn — DBNet, YOLO, PaddleOCR's own detector — can feed it.\n\n``` js\nimport { Pipeline, configure } from '@stabrise/scaledp'\nimport { PdfToImage } from '@stabrise/scaledp/pdf'\nimport { DbnetOnnxDetector, PaddleRecognizer } from '@stabrise/scaledp/ocr'\n\nconfigure({ cache: 'indexeddb', pdf: { workerSrc: '/pdf.worker.min.mjs' } })\n\nconst pipeline = new Pipeline([\n  new PdfToImage({ resolution: 300 }),\n  new DbnetOnnxDetector({ inputCol: 'image', outputCol: 'boxes' }),\n  new PaddleRecognizer({ inputCols: ['image', 'boxes'], preset: 'v6-small' }),\n])\n\nconst rows = await pipeline.transform(file)\nconsole.log(rows[0].text.text)\n```\n\nDetection and recognition are now two stages you can mix independently: swap `DbnetOnnxDetector`\n\nfor 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.\n\nRun in the [builder](https://scaledp-ts.stabrise.com/demo), that is DBNet's boxes with PaddleOCR's text in them:\n\nWorth 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.\n\nThree 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.\n\n`ppu`\n\n'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`\n\nstraightens 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.\n\nThe natural implementation is \"for each box, call `run()`\n\n.\" That's also the slow one: `ppu`\n\nonly 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.\n\n`PaddleRecognizer`\n\nstacks the straightened crops onto a single sheet canvas first, then reads them together in one `run()`\n\n. 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.\n\n``` js\n// Simplified shape of what happens internally\nconst crops = boxes.map((box) => straightenAndCrop(image, box))\nconst sheet = stackOntoSheet(crops)          // one canvas, N slots\nconst results = await recognizer.run(sheet)  // one inference call\nconst text = matchBySlotOffset(results, crops)  // not by array position\n```\n\n`onnxruntime-web`\n\n's WebGPU backend rewrites convolutions into an internal `com.ms.internal.nhwc`\n\nop 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`\n\nalready retries on WASM internally when this happens; we exposed the same behavior in our own `createSession()`\n\nas an opt-in `fallbackToWasm`\n\nflag. 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.\n\n``` js\nimport { createSession } from '@stabrise/scaledp/ocr'\n\nconst session = await createSession(modelBuffer, {\n  executionProviders: ['webgpu'],\n  fallbackToWasm: true, // PP-OCR recognition specifically needs this\n})\n```\n\nBuilding `PaddleRecognizer`\n\nmeant 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`\n\ntoo. 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.\n\nThe 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/`\n\n(missing a slash) became `https://stabrise.com/scaledp/`\n\n— and it's cheaper too: one inference per line instead of one per word.\n\nIt 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.\n\nWe didn't build a generic \"pluggable detector/recognizer interface\" with a registry and adapters. `PaddleRecognizer`\n\nand `TesseractRecognizer`\n\njust happen to share the same input contract — `[image, boxes] -> Document`\n\n— 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.\n\nThat 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`\n\nrecords a failure in the output `Document`\n\n's `exception`\n\nfield and returns a well-formed empty document for that row; you opt into throwing with `propagateError: true`\n\nif you'd rather fail fast during development.\n\n`recBatchSize`\n\nexists for exactly this, and defaults conservatively.`fallbackToWasm`\n\ntrades silent correctness for silent slowness.\n\n```\nnpm install @stabrise/scaledp onnxruntime-web ppu-paddle-ocr pdfjs-dist\njs\nimport { Pipeline, configure } from '@stabrise/scaledp'\nimport { PdfToImage } from '@stabrise/scaledp/pdf'\nimport { DbnetOnnxDetector, PaddleRecognizer } from '@stabrise/scaledp/ocr'\n\nconfigure({ cache: 'indexeddb', pdf: { workerSrc: '/pdf.worker.min.mjs' } })\n\nconst rows = await new Pipeline([\n  new PdfToImage({ resolution: 300 }),\n  new DbnetOnnxDetector(),\n  new PaddleRecognizer({ preset: 'v6-small' }),\n]).transform(file)\n```\n\nThe live demo (drop a PDF or image, pick a detector and recognizer, run it) is at [scaledp-ts.stabrise.com/demo](https://scaledp-ts.stabrise.com/demo); docs at [scaledp-ts.stabrise.com/docs](https://scaledp-ts.stabrise.com/docs).", "url": "https://wpnews.pro/news/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition", "canonical_source": "https://dev.to/mykola_melnyk_ml/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition-37ak", "published_at": "2026-09-04 08:17:21+00:00", "updated_at": "2026-09-04 08:23:50.771521+00:00", "lang": "en", "topics": ["developer-tools", "computer-vision", "machine-learning", "ai-products"], "entities": ["StabRise", "PaddleOCR", "DBNet", "YOLO", "onnxruntime-web", "Tesseract", "ScaleDP", "PaddleRecognizer"], "alternates": {"html": "https://wpnews.pro/news/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition", "markdown": "https://wpnews.pro/news/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition.md", "text": "https://wpnews.pro/news/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition.txt", "jsonld": "https://wpnews.pro/news/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition.jsonld"}}