Skip to content

Commit 312de4c

Browse files
artizclaude
andcommitted
wasm scan demo: run the pipeline in a Web Worker (responsive UI)
Even with multi-threaded ORT, the main thread still froze during a conversion: pdf.js rasterizes on it, and the Rust pre/post-processing inside add_page (line segmentation, CTC decode, reading-order assembly) runs synchronously on the calling thread. Move the whole pipeline off the main thread so the page stays interactive. Factor the DOM-free pipeline (model loading, rasterization, OCR/layout) into pipeline.js — parameterized only by an onStatus reporter and a makeCanvas factory — and drive it from worker.js, which rasterizes into an OffscreenCanvas and posts status/result back. scan.html becomes a thin UI: it spawns the worker, relays progress to the status line, and transfers the file bytes in. If module workers or OffscreenCanvas are unavailable it falls back to running pipeline.js on the main thread (functionally identical, just blocking); a worker-side convert failure retries once on the main thread so a result is always produced. Also folds in an ORT warm-up (one blank layout+rec inference at boot) so the first real page no longer pays the lazy kernel/thread init. Refs #157 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent d707941 commit 312de4c

3 files changed

Lines changed: 341 additions & 184 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Shared browser scanned-document pipeline (stage 2 of #157), DOM-free so it
2+
// runs identically on the main thread (fallback) or inside a Web Worker
3+
// (worker.js — the default, keeps the UI responsive). It owns every import
4+
// (ORT Web, pdf.js, the wasm glue) and delegates only two host concerns:
5+
// onStatus(msg, spinning) — progress reporting (DOM span vs postMessage)
6+
// makeCanvas() — an HTMLCanvasElement or an OffscreenCanvas
7+
// Same code as the native pipeline behind the wasm boundary; drift can only
8+
// come from the ORT kernels.
9+
10+
import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.mjs";
11+
import * as pdfjs from "https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.mjs";
12+
import init, { ScannedConverter, convert_scanned_image } from "./pkg/docling_wasm.js";
13+
14+
pdfjs.GlobalWorkerOptions.workerSrc =
15+
"https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.mjs";
16+
17+
// Multi-threaded wasm when cross-origin isolated (coi.js); else one thread.
18+
// SIMD is auto-selected by the ORT bundle when the browser supports it.
19+
ort.env.wasm.numThreads = self.crossOriginIsolated
20+
? Math.min(navigator.hardwareConcurrency || 4, 8)
21+
: 1;
22+
export const THREADS = ort.env.wasm.numThreads;
23+
24+
// Same-origin candidates (release assets carry no CORS header — see scan.html's
25+
// setup note); int8 preferred, fp32 fallback.
26+
const LAYOUT_PATHS = ["./models/layout_heron_int8.onnx", "./models/layout_heron.onnx"];
27+
const SCALE = 2.0; // px per PDF point — the native pipeline's RENDER_SCALE
28+
29+
const REC_MODELS = {
30+
en: {
31+
model: "https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv3/en_PP-OCRv3_rec_infer.onnx",
32+
dict: "https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/en_dict.txt",
33+
},
34+
cyrillic: {
35+
// PP-OCRv5: markedly better Cyrillic accuracy than the v3 export (spaces
36+
// and case survive); its dictionary only exists inside the repo's
37+
// inference.yml, so a flattened copy ships next to this page.
38+
model: "https://huggingface.co/PaddlePaddle/cyrillic_PP-OCRv5_mobile_rec_onnx/resolve/main/inference.onnx",
39+
dict: "cyrillic_v5_dict.txt",
40+
},
41+
ch: {
42+
model: "https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv3/ch_PP-OCRv3_rec_infer.onnx",
43+
dict: "https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/ppocr_keys_v1.txt",
44+
},
45+
};
46+
47+
export function createPipeline({ onStatus, makeCanvas }) {
48+
const status = (msg, spinning = true) => onStatus && onStatus(msg, spinning);
49+
50+
// fetch() with a live "x / y MB" progress line.
51+
async function fetchProgress(url, label) {
52+
const resp = await fetch(url, { cache: "force-cache" });
53+
if (!resp.ok) throw new Error(`${label}: HTTP ${resp.status}`);
54+
const total = Number(resp.headers.get("Content-Length")) || 0;
55+
if (!resp.body) return resp.arrayBuffer();
56+
const reader = resp.body.getReader();
57+
const chunks = [];
58+
let got = 0;
59+
for (;;) {
60+
const { done, value } = await reader.read();
61+
if (done) break;
62+
chunks.push(value);
63+
got += value.length;
64+
const mb = (got / 1048576).toFixed(1);
65+
status(total ? `${label}${mb} / ${(total / 1048576).toFixed(1)} MB` : `${label}${mb} MB`, true);
66+
}
67+
const buf = new Uint8Array(got);
68+
let off = 0;
69+
for (const c of chunks) { buf.set(c, off); off += c.length; }
70+
return buf.buffer;
71+
}
72+
73+
const recCache = {};
74+
async function recFor(lang) {
75+
if (!recCache[lang]) {
76+
const [model, dict] = await Promise.all([
77+
fetchProgress(REC_MODELS[lang].model, `${lang} recognition model`),
78+
fetch(REC_MODELS[lang].dict, { cache: "force-cache" }).then((r) => r.text()),
79+
]);
80+
status(`starting ${lang} recognition session …`, true);
81+
const session = await ort.InferenceSession.create(model, {
82+
executionProviders: ["wasm"],
83+
logSeverityLevel: 3,
84+
});
85+
recCache[lang] = {
86+
dict,
87+
rec: {
88+
run: async (n, h, w, data) => {
89+
const results = await session.run({
90+
[session.inputNames[0]]: new ort.Tensor("float32", data, [n, 3, h, w]),
91+
});
92+
const t = results[session.outputNames[0]];
93+
return { data: t.data, dims: Array.from(t.dims) };
94+
},
95+
},
96+
};
97+
}
98+
return recCache[lang];
99+
}
100+
101+
// Interop wrapper docling_wasm expects (see src/scanned.rs).
102+
let layout = null;
103+
let layoutKind = null;
104+
async function loadLayout() {
105+
for (const path of LAYOUT_PATHS) {
106+
try {
107+
const buf = await fetchProgress(path, "layout model (first load only)");
108+
status("starting layout session …", true);
109+
const session = await ort.InferenceSession.create(buf, {
110+
executionProviders: ["wasm"],
111+
logSeverityLevel: 3,
112+
});
113+
layoutKind = path.includes("int8") ? "int8" : "fp32";
114+
layout = {
115+
run: async (data) => {
116+
const results = await session.run({
117+
pixel_values: new ort.Tensor("float32", data, [1, 3, 640, 640]),
118+
});
119+
const t = (n) => ({ data: results[n].data, dims: Array.from(results[n].dims) });
120+
return { logits: t("logits"), boxes: t("pred_boxes") };
121+
},
122+
};
123+
return layoutKind;
124+
} catch (e) {
125+
// 404 / decode failure → try the next candidate.
126+
}
127+
}
128+
return null;
129+
}
130+
131+
// Bring wasm + the layout model up. Returns "int8" | "fp32" | null.
132+
async function boot() {
133+
status("loading wasm module …", true);
134+
await init();
135+
return loadLayout();
136+
}
137+
138+
// Run one blank inference through layout + rec so ORT's lazy kernel/thread
139+
// init happens now, not inside the first real document.
140+
async function warmup(lang) {
141+
try {
142+
await layout.run(new Float32Array(3 * 640 * 640));
143+
const { rec } = await recFor(lang);
144+
await rec.run(1, 48, 320, new Float32Array(3 * 48 * 320));
145+
} catch (e) {
146+
// warm-up is best-effort; a failure just means the first page pays it.
147+
}
148+
}
149+
150+
async function handlePdf(bytes, name, dict, rec) {
151+
const pdf = await pdfjs.getDocument({ data: bytes }).promise;
152+
const conv = new ScannedConverter(dict);
153+
// Rasterize page p+1 while converting page p; getImageData hands back an
154+
// independent buffer, so one canvas ping-pongs safely across pages.
155+
const canvas = makeCanvas();
156+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
157+
async function render(p) {
158+
const page = await pdf.getPage(p);
159+
const viewport = page.getViewport({ scale: SCALE });
160+
canvas.width = viewport.width;
161+
canvas.height = viewport.height;
162+
await page.render({ canvasContext: ctx, viewport }).promise;
163+
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
164+
return { rgba: new Uint8Array(img.data.buffer), w: canvas.width, h: canvas.height };
165+
}
166+
167+
const t0 = performance.now();
168+
let elapsed = 0;
169+
let next = render(1);
170+
for (let p = 1; p <= pdf.numPages; p++) {
171+
const done = p - 1;
172+
const avg = done ? ` — ${(elapsed / done / 1000).toFixed(1)}s/page avg` : "";
173+
status(`${name}: page ${p}/${pdf.numPages}${avg} …`, true);
174+
const cur = await next;
175+
if (p < pdf.numPages) next = render(p + 1);
176+
const tp = performance.now();
177+
await conv.add_page(cur.rgba, cur.w, cur.h, SCALE, layout, rec);
178+
const dt = performance.now() - tp;
179+
elapsed += dt;
180+
console.log(`${name}: page ${p}/${pdf.numPages}${(dt / 1000).toFixed(1)}s convert`);
181+
}
182+
const wall = (performance.now() - t0) / 1000;
183+
const avg = pdf.numPages ? wall / pdf.numPages : 0;
184+
console.log(`${name}: ${pdf.numPages} pages in ${wall.toFixed(1)}s wall (${avg.toFixed(1)}s/page)`);
185+
return conv.finish(name, "md");
186+
}
187+
188+
async function convert(bytes, name, lang) {
189+
const { dict, rec } = await recFor(lang);
190+
const u8 = new Uint8Array(bytes);
191+
if (name.toLowerCase().endsWith(".pdf")) return handlePdf(u8, name, dict, rec);
192+
return convert_scanned_image(u8, name, dict, layout, rec, "md");
193+
}
194+
195+
return { boot, warmup, recFor, convert, get layoutKind() { return layoutKind; } };
196+
}

0 commit comments

Comments
 (0)