Skip to content

Commit 4d2199f

Browse files
artizclaude
andcommitted
#157 stage 3 (phase 4): TableFormer demo wiring
Add the browser TableFormer profile to the scan demo. A "TableFormer tables" checkbox opts in; when checked, pipeline.js lazily loads the three graphs same-origin from ./models/tableformer/ (encoder.onnx self-contained ~225 MB; decoder_kv.onnx + bbox.onnx with their .onnx.data sidecars via ort-web's externalData option) and builds JsTfSession — the stateful session the wasm TfSession interop expects: encode stashes the constant cross K/V + enc_out and resets the KV-cache, step runs one decoder round (int64 tag, empty first-step cache [6,1,8,0,64]) and returns logits+hidden, bbox runs the box/class head. Table regions then go through ScannedConverter.addPageTf; the lite geometric path stays the default so the big models aren't fetched unless asked. Models load lazily on the first table conversion with a progress line. 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 7ce4039 commit 4d2199f

3 files changed

Lines changed: 95 additions & 9 deletions

File tree

crates/docling-wasm/www/pipeline.js

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,60 @@ const REC_MODELS = {
3838
},
3939
};
4040

41+
// TableFormer graphs, loaded same-origin from ./models/tableformer/ (the same
42+
// files download_dependencies.sh fetches). The encoder is self-contained; the
43+
// decoder/bbox use ONNX external data, so their .onnx.data sidecar rides along
44+
// via ort-web's externalData option (path = the location stored in the .onnx).
45+
const TF_DIR = "./models/tableformer/";
46+
47+
/// The stateful session the wasm TfSession interop expects (see
48+
/// src/tableformer.rs): `encode` runs the image encoder once and stashes the
49+
/// constant cross-attention K/V + enc_out and resets the KV-cache; `step` runs
50+
/// one decoder step feeding the stored cross + growing cache; `bbox` runs the
51+
/// bbox decoder. The heavy tensors stay here — only tags and logits/hidden
52+
/// cross the wasm boundary. Geometry: 6 layers, 8 KV heads, head_dim 64.
53+
class JsTfSession {
54+
constructor(enc, dec, bbox) {
55+
this.enc = enc;
56+
this.dec = dec;
57+
this.bboxSess = bbox;
58+
this.cross = null;
59+
this.encOut = null;
60+
this.cacheK = null;
61+
this.cacheV = null;
62+
}
63+
async encode(image) {
64+
const out = await this.enc.run({ image: new ort.Tensor("float32", image, [1, 3, 448, 448]) });
65+
this.cross = {};
66+
for (let i = 0; i < 6; i++) {
67+
this.cross["cross_kt_" + i] = out["cross_kt_" + i];
68+
this.cross["cross_v_" + i] = out["cross_v_" + i];
69+
}
70+
this.encOut = out["enc_out"];
71+
// Empty first-step KV-cache: [N_LAYERS, 1, KV_HEADS, past=0, head_dim].
72+
this.cacheK = new ort.Tensor("float32", new Float32Array(0), [6, 1, 8, 0, 64]);
73+
this.cacheV = new ort.Tensor("float32", new Float32Array(0), [6, 1, 8, 0, 64]);
74+
}
75+
async step(tag) {
76+
const out = await this.dec.run({
77+
tag: new ort.Tensor("int64", BigInt64Array.from([BigInt(tag)]), [1, 1]),
78+
cache_k: this.cacheK,
79+
cache_v: this.cacheV,
80+
...this.cross,
81+
});
82+
this.cacheK = out["out_cache_k"];
83+
this.cacheV = out["out_cache_v"];
84+
return { logits: out["logits"].data, hidden: out["hidden"].data };
85+
}
86+
async bbox(tagH, n) {
87+
const out = await this.bboxSess.run({
88+
enc_out: this.encOut,
89+
tag_h: new ort.Tensor("float32", tagH, [n, 512]),
90+
});
91+
return { boxes: out["boxes"].data, classes: out["classes"].data };
92+
}
93+
}
94+
4195
export function createOcr({ onStatus }) {
4296
const status = (msg, spinning = true) => onStatus && onStatus(msg, spinning);
4397

@@ -141,15 +195,42 @@ export function createOcr({ onStatus }) {
141195
}
142196
}
143197

198+
// TableFormer sessions, loaded lazily on first use (the encoder alone is
199+
// ~225 MB, so this only downloads when the table profile is chosen).
200+
let tf = null;
201+
async function ensureTf() {
202+
if (tf) return tf;
203+
const load = async (name, external) => {
204+
const model = await fetchProgress(TF_DIR + name + ".onnx", `tableformer ${name}`);
205+
const opts = { executionProviders: ["wasm"], logSeverityLevel: 3 };
206+
if (external) {
207+
const data = await fetchProgress(TF_DIR + name + ".onnx.data", `tableformer ${name} data`);
208+
opts.externalData = [{ path: name + ".onnx.data", data: new Uint8Array(data) }];
209+
}
210+
return ort.InferenceSession.create(model, opts);
211+
};
212+
const enc = await load("encoder", false);
213+
const dec = await load("decoder_kv", true);
214+
const bbox = await load("bbox", true);
215+
status("starting tableformer sessions …", true);
216+
tf = new JsTfSession(enc, dec, bbox);
217+
return tf;
218+
}
219+
144220
// Multi-page document lifecycle: startDoc → addPage* → finishDoc. Pages come
145221
// in as RGBA (already rasterized on the main thread), one document at a time.
146222
let cur = null;
147-
async function startDoc(lang) {
223+
async function startDoc(lang, useTf) {
148224
const { dict, rec } = await recFor(lang);
149-
cur = { conv: new ScannedConverter(dict), rec };
225+
const tfSess = useTf ? await ensureTf() : null;
226+
cur = { conv: new ScannedConverter(dict), rec, tf: tfSess };
150227
}
151228
async function addPage(rgba, w, h, scale) {
152-
await cur.conv.add_page(rgba, w, h, scale, layout, cur.rec);
229+
if (cur.tf) {
230+
await cur.conv.addPageTf(rgba, w, h, scale, layout, cur.rec, cur.tf);
231+
} else {
232+
await cur.conv.add_page(rgba, w, h, scale, layout, cur.rec);
233+
}
153234
}
154235
function finishDoc(name) {
155236
const md = cur.conv.finish(name, "md");

crates/docling-wasm/www/scan.html

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ <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/.">
88+
<input type="checkbox" id="tf" disabled> TableFormer tables
89+
<span style="color:#888">(+~380 MB models, PDFs)</span>
90+
</label>
8791
<span id="meta"></span>
8892
</p>
8993

@@ -118,6 +122,7 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
118122
const meta = document.getElementById("meta");
119123
const out = document.getElementById("out");
120124
const lang = document.getElementById("lang");
125+
const tf = document.getElementById("tf");
121126
const drop = document.getElementById("drop");
122127
const pick = document.getElementById("pick");
123128

@@ -128,12 +133,12 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
128133
}
129134
function busy(msg) {
130135
document.body.classList.add("busy");
131-
pick.disabled = lang.disabled = true;
136+
pick.disabled = lang.disabled = tf.disabled = true;
132137
status(msg, true);
133138
}
134139
function ready(msg) {
135140
document.body.classList.remove("busy");
136-
pick.disabled = lang.disabled = false;
141+
pick.disabled = lang.disabled = tf.disabled = false;
137142
status(msg, false);
138143
}
139144
const onStatus = (msg, spinning) => {
@@ -172,7 +177,7 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
172177
worker,
173178
boot: (l) => rpc("boot", { lang: l }),
174179
rec: (l) => rpc("rec", { lang: l }),
175-
docStart: (l) => rpc("doc-start", { lang: l }),
180+
docStart: (l, useTf) => rpc("doc-start", { lang: l, useTf }),
176181
addPage: (rgba, w, h, scale) => rpc("doc-page", { rgba, w, h, scale }, [rgba]),
177182
docFinish: (name) => rpc("doc-finish", { name }).then((r) => r.md),
178183
convertImage: (bytes, name, l) => rpc("convert-image", { bytes, name, lang: l }, [bytes]).then((r) => r.md),
@@ -190,7 +195,7 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
190195
return { kind, threads: THREADS, main: true };
191196
},
192197
rec: async (l) => { await ocr.recFor(l); await ocr.warmup(l); return {}; },
193-
docStart: (l) => ocr.startDoc(l),
198+
docStart: (l, useTf) => ocr.startDoc(l, useTf),
194199
addPage: (rgba, w, h, scale) => ocr.addPage(new Uint8Array(rgba), w, h, scale),
195200
docFinish: (name) => Promise.resolve(ocr.finishDoc(name)),
196201
convertImage: (bytes, name, l) => ocr.convertImage(bytes, name, l),
@@ -205,7 +210,7 @@ <h1>docling.rs → wasm: scanned PDF / image, full lite profile</h1>
205210
async function handlePdf(file) {
206211
const buf = await file.arrayBuffer();
207212
const pdf = await pdfjs.getDocument({ data: buf, ...PDFJS_ASSETS }).promise;
208-
await drv.docStart(lang.value);
213+
await drv.docStart(lang.value, tf.checked);
209214
const canvas = document.createElement("canvas");
210215
const ctx = canvas.getContext("2d", { willReadFrequently: true });
211216
async function render(p) {

crates/docling-wasm/www/worker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async function handle(m) {
3232
await ocr.warmup(m.lang);
3333
return {};
3434
case "doc-start":
35-
await ocr.startDoc(m.lang);
35+
await ocr.startDoc(m.lang, m.useTf);
3636
return {};
3737
case "doc-page":
3838
await ocr.addPage(new Uint8Array(m.rgba), m.w, m.h, m.scale);

0 commit comments

Comments
 (0)