Skip to content

Commit dc4e264

Browse files
artizclaude
andcommitted
wasm demo: load models from device files (no download)
Add a multi-file picker so the models can be selected from the device instead of fetched: each File is read to an ArrayBuffer, handed to the pipeline (transferred to the worker), and used by name before any network fetch. This avoids the 380 MB TableFormer download on a phone entirely and — since reading a File is a single allocation — sidesteps the double- buffering peak fetchProgress hits on the 225 MB encoder (the likely cause of the earlier load failure). Layout and TableFormer both resolve provided-file → local ./models/ → Hugging Face, in that order. 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 af1ef6c commit dc4e264

3 files changed

Lines changed: 91 additions & 49 deletions

File tree

crates/docling-wasm/www/pipeline.js

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,11 @@ ort.env.wasm.numThreads = self.crossOriginIsolated
1616
: 1;
1717
export const THREADS = ort.env.wasm.numThreads;
1818

19-
// Layout model candidates, tried in order. Local ./models/ first (populated by
20-
// download_dependencies.sh for local dev); then a CORS-enabled Hugging Face
21-
// mirror so the page works when served cross-origin — e.g. the hosted phone
22-
// demo on raw.githack — since GitHub Release assets carry no CORS header.
19+
// Model bases, tried in order after any user-provided file: local ./models/
20+
// first (download_dependencies.sh for local dev), then a CORS-enabled Hugging
21+
// Face mirror so the page works cross-origin (the hosted phone demo on
22+
// raw.githack) — GitHub Release assets carry no CORS header.
2323
const MODEL_BASE = "https://huggingface.co/pivozavrus/docling-rs-models/resolve/main/";
24-
const LAYOUT_PATHS = [
25-
"./models/layout_heron_int8.onnx",
26-
MODEL_BASE + "layout_heron_int8.onnx",
27-
"./models/layout_heron.onnx",
28-
];
2924

3025
const REC_MODELS = {
3126
en: {
@@ -104,6 +99,29 @@ class JsTfSession {
10499
export function createOcr({ onStatus }) {
105100
const status = (msg, spinning = true) => onStatus && onStatus(msg, spinning);
106101

102+
// Model files the user picked from the device (basename → ArrayBuffer),
103+
// used instead of any network fetch — see scan.html's file picker. Reading a
104+
// File to an ArrayBuffer is a single allocation, so it also sidesteps the
105+
// double-buffering peak fetchProgress hits on the 225 MB encoder.
106+
let provided = {};
107+
function setProvidedModels(map) {
108+
provided = map || {};
109+
}
110+
// Resolve a model by name: a user-provided file wins, else the first base
111+
// that serves it. Returns { buf, base } (base is null for a provided file).
112+
async function fetchModel(name, bases, label) {
113+
if (provided[name]) return { buf: provided[name], base: null };
114+
let lastErr = null;
115+
for (const b of bases) {
116+
try {
117+
return { buf: await fetchProgress(b + name, label), base: b };
118+
} catch (e) {
119+
lastErr = e;
120+
}
121+
}
122+
throw new Error(`${name} failed: ${(lastErr && lastErr.message) || lastErr}`);
123+
}
124+
107125
// fetch() with a live "x / y MB" progress line.
108126
async function fetchProgress(url, label) {
109127
const resp = await fetch(url, { cache: "force-cache" });
@@ -159,28 +177,32 @@ export function createOcr({ onStatus }) {
159177
let layout = null;
160178
let layoutKind = null;
161179
async function loadLayout() {
162-
for (const path of LAYOUT_PATHS) {
180+
for (const [name, kind] of [
181+
["layout_heron_int8.onnx", "int8"],
182+
["layout_heron.onnx", "fp32"],
183+
]) {
184+
let buf;
163185
try {
164-
const buf = await fetchProgress(path, "layout model (first load only)");
165-
status("starting layout session …", true);
166-
const session = await ort.InferenceSession.create(buf, {
167-
executionProviders: ["wasm"],
168-
logSeverityLevel: 3,
169-
});
170-
layoutKind = path.includes("int8") ? "int8" : "fp32";
171-
layout = {
172-
run: async (data) => {
173-
const results = await session.run({
174-
pixel_values: new ort.Tensor("float32", data, [1, 3, 640, 640]),
175-
});
176-
const t = (n) => ({ data: results[n].data, dims: Array.from(results[n].dims) });
177-
return { logits: t("logits"), boxes: t("pred_boxes") };
178-
},
179-
};
180-
return layoutKind;
186+
({ buf } = await fetchModel(name, ["./models/", MODEL_BASE], "layout model (first load only)"));
181187
} catch (e) {
182-
// 404 / decode failure → try the next candidate.
188+
continue; // not provided and not fetchable → try the next candidate
183189
}
190+
status("starting layout session …", true);
191+
const session = await ort.InferenceSession.create(buf, {
192+
executionProviders: ["wasm"],
193+
logSeverityLevel: 3,
194+
});
195+
layoutKind = kind;
196+
layout = {
197+
run: async (data) => {
198+
const results = await session.run({
199+
pixel_values: new ort.Tensor("float32", data, [1, 3, 640, 640]),
200+
});
201+
const t = (n) => ({ data: results[n].data, dims: Array.from(results[n].dims) });
202+
return { logits: t("logits"), boxes: t("pred_boxes") };
203+
},
204+
};
205+
return layoutKind;
184206
}
185207
return null;
186208
}
@@ -210,24 +232,16 @@ export function createOcr({ onStatus }) {
210232
async function ensureTf() {
211233
if (tf) return tf;
212234
const load = async (name, external) => {
213-
// Find the first base (local, then HF) that serves the .onnx; take its
214-
// external data from the same base.
215-
let base = null;
216-
let model = null;
217-
let lastErr = null;
218-
for (const b of TF_DIRS) {
219-
try {
220-
model = await fetchProgress(b + name + ".onnx", `tableformer ${name}`);
221-
base = b;
222-
break;
223-
} catch (e) {
224-
lastErr = e; // keep the real cause (HTTP status, out-of-memory, …)
225-
}
226-
}
227-
if (!model) throw new Error(`tableformer ${name}.onnx failed: ${(lastErr && lastErr.message) || lastErr}`);
235+
// A provided file wins; else the first base (local, then HF). External
236+
// data comes from a provided file or the same base the .onnx came from.
237+
const { buf: model, base } = await fetchModel(name + ".onnx", TF_DIRS, `tableformer ${name}`);
228238
const opts = { executionProviders: ["wasm"], logSeverityLevel: 3 };
229239
if (external) {
230-
const data = await fetchProgress(base + name + ".onnx.data", `tableformer ${name} data`);
240+
const { buf: data } = await fetchModel(
241+
name + ".onnx.data",
242+
base ? [base] : TF_DIRS,
243+
`tableformer ${name} data`,
244+
);
231245
opts.externalData = [{ path: name + ".onnx.data", data: new Uint8Array(data) }];
232246
}
233247
return ort.InferenceSession.create(model, opts);
@@ -268,7 +282,7 @@ export function createOcr({ onStatus }) {
268282
}
269283

270284
return {
271-
boot, warmup, recFor, startDoc, addPage, finishDoc, convertImage,
285+
boot, warmup, recFor, startDoc, addPage, finishDoc, convertImage, setProvidedModels,
272286
get layoutKind() { return layoutKind; },
273287
};
274288
}

crates/docling-wasm/www/scan.html

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,17 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
8484
<option value="cyrillic">Cyrillic + Latin (cyrillic_PP-OCRv5)</option>
8585
<option value="ch">Chinese + Latin (ch_PP-OCRv3)</option>
8686
</select>
87-
<label style="margin-left:1rem" title="Stage 3: real table structure via the TableFormer ONNX graphs, instead of the geometric reconstruction. Fetches ~380 MB of models (encoder/decoder/bbox) on first use, same-origin from ./models/tableformer/.">
87+
<label style="margin-left:1rem" title="Stage 3: real table structure via the TableFormer ONNX graphs, instead of the geometric reconstruction.">
8888
<input type="checkbox" id="tf" disabled> TableFormer tables
89-
<span style="color:#888">(+~380 MB models, PDFs)</span>
9089
</label>
9190
<span id="meta"></span>
9291
</p>
92+
<p style="font-size:0.85rem; color:#888">
93+
Models from device (optional — skips the download):
94+
<input type="file" id="models" multiple accept=".onnx,.data,.txt" disabled>
95+
<br>pick <code>layout_heron_int8.onnx</code> and, for TableFormer, <code>encoder.onnx</code>,
96+
<code>decoder_kv.onnx</code>(+<code>.data</code>), <code>bbox.onnx</code>(+<code>.data</code>).
97+
</p>
9398

9499
<div id="diag" style="font: 0.8rem ui-monospace, monospace; color: #888; white-space: pre-wrap; margin: 0.3rem 0;"></div>
95100
<div id="out"></div>
@@ -125,6 +130,7 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
125130
const diagEl = document.getElementById("diag");
126131
const lang = document.getElementById("lang");
127132
const tf = document.getElementById("tf");
133+
const modelsInput = document.getElementById("models");
128134
const drop = document.getElementById("drop");
129135
const pick = document.getElementById("pick");
130136

@@ -143,12 +149,12 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
143149
}
144150
function busy(msg) {
145151
document.body.classList.add("busy");
146-
pick.disabled = lang.disabled = tf.disabled = true;
152+
pick.disabled = lang.disabled = tf.disabled = modelsInput.disabled = true;
147153
status(msg, true);
148154
}
149155
function ready(msg) {
150156
document.body.classList.remove("busy");
151-
pick.disabled = lang.disabled = tf.disabled = false;
157+
pick.disabled = lang.disabled = tf.disabled = modelsInput.disabled = false;
152158
status(msg, false);
153159
}
154160
const onStatus = (msg, spinning) => {
@@ -192,6 +198,7 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
192198
addPage: (rgba, w, h, scale) => rpc("doc-page", { rgba, w, h, scale }, [rgba]),
193199
docFinish: (name) => rpc("doc-finish", { name }).then((r) => r.md),
194200
convertImage: (bytes, name, l) => rpc("convert-image", { bytes, name, lang: l }, [bytes]).then((r) => r.md),
201+
setModels: (models) => rpc("set-models", { models }, Object.values(models)),
195202
};
196203
}
197204
async function mainDriver() {
@@ -210,6 +217,7 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
210217
addPage: (rgba, w, h, scale) => ocr.addPage(new Uint8Array(rgba), w, h, scale),
211218
docFinish: (name) => Promise.resolve(ocr.finishDoc(name)),
212219
convertImage: (bytes, name, l) => ocr.convertImage(bytes, name, l),
220+
setModels: (models) => ocr.setProvidedModels(models),
213221
};
214222
}
215223

@@ -280,6 +288,23 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
280288
}
281289
});
282290

291+
// Pick model files from the device: read each to an ArrayBuffer and hand
292+
// them to the pipeline (transferred to the worker), so layout/TableFormer
293+
// load from local files instead of fetching — no 380 MB download, and a
294+
// single allocation per file (no fetch double-buffering).
295+
modelsInput.addEventListener("change", async () => {
296+
if (!drv || !modelsInput.files.length) return;
297+
busy(`reading ${modelsInput.files.length} model file(s) …`);
298+
try {
299+
const map = {};
300+
for (const f of modelsInput.files) map[f.name] = await f.arrayBuffer();
301+
await drv.setModels(map);
302+
ready(`models ready: ${Object.keys(map).join(", ")} — drop a scanned PDF or image`);
303+
} catch (e) {
304+
ready(`model file read failed: ${(e && e.message) || e}`);
305+
}
306+
});
307+
283308
drop.addEventListener("click", () => pick.click());
284309
pick.addEventListener("change", () => pick.files[0] && handle(pick.files[0]));
285310
for (const ev of ["dragover", "dragleave", "drop"]) {

crates/docling-wasm/www/worker.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const ocr = createOcr({
2424

2525
async function handle(m) {
2626
switch (m.type) {
27+
case "set-models":
28+
ocr.setProvidedModels(m.models);
29+
return {};
2730
case "boot": {
2831
const kind = await ocr.boot();
2932
if (!kind) return { noLayout: true };

0 commit comments

Comments
 (0)