Skip to content

Commit 7ce4039

Browse files
artizclaude
andcommitted
#157 stage 3 (phase 3b/3c): browser TableFormer via ort-web interop
Wire the ONNX table-structure model into the browser pipeline. New wasm module tableformer.rs delegates the three graphs (encoder/decoder/bbox) to a stateful JS TfSession and drives the autoregressive loop in Rust on tf_core: preprocess_input → encode → step* (BboxBook bookkeeping, argmax) → bbox → merge_spans → build_table_cells → table_rows. The heavy tensors — the encoder's constant cross K/V and enc_out, and the growing decoder KV-cache — stay JS-side in the session, so each step marshals only the last tag (one int) in and logits+hidden (525 floats) out, avoiding O(steps^2) copies across the boundary (the decoder_kv graph: 6 layers, 8 KV heads, head_dim 64, cross length 784). ScannedConverter gains addPageTf (the lite add_page stays geometric): table regions run predict_table_rows, others stay None, then scanned::assemble_page_with_tables (new — the general assemble the native pipeline uses) lays them out. TableFormer is opt-in so the 225 MB encoder isn't fetched unless a table profile is chosen. Native ml unchanged; wasm target + clippy clean. 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 482a578 commit 7ce4039

4 files changed

Lines changed: 226 additions & 6 deletions

File tree

crates/docling-pdf/src/scanned.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,20 @@ pub fn refine_regions(
3636
/// Assemble one refined page — geometric tables (no TableFormer), no
3737
/// enrichments — into its nodes and hyperlink pairs.
3838
pub fn assemble_page(page: &PdfPage, regions: Vec<Region>) -> AssembledPage {
39-
let table_rows = vec![None; regions.len()];
39+
assemble_page_with_tables(page, regions, vec![None; 0].into_iter().collect())
40+
}
41+
42+
/// Like [`assemble_page`] but with pre-computed table rows per region:
43+
/// `table_rows[i] = Some(rows)` for a table region whose structure the browser
44+
/// TableFormer path (#157 stage 3) resolved, `None` for the geometric fallback.
45+
/// An empty `table_rows` means "all geometric" (what [`assemble_page`] passes).
46+
/// Same assembly as the native pipeline.
47+
pub fn assemble_page_with_tables(
48+
page: &PdfPage,
49+
regions: Vec<Region>,
50+
mut table_rows: Vec<Option<Vec<Vec<String>>>>,
51+
) -> AssembledPage {
52+
table_rows.resize(regions.len(), None);
4053
let enrich = vec![None; regions.len()];
4154
crate::assemble::assemble_page(page, regions, &table_rows, &enrich)
4255
}

crates/docling-wasm/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ mod ocr;
2424
#[cfg(feature = "ocr")]
2525
mod scanned;
2626
#[cfg(feature = "ocr")]
27+
mod tableformer;
28+
#[cfg(feature = "ocr")]
2729
pub use ocr::ocr_image;
2830
#[cfg(feature = "ocr")]
2931
pub use scanned::{convert_scanned_image, ScannedConverter};

crates/docling-wasm/src/scanned.rs

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ use docling_pdf::ocr_prep::{
2626
REC_HEIGHT,
2727
};
2828
use docling_pdf::pdfium_backend::{PdfPage, TextCell};
29-
use docling_pdf::scanned::{assemble_page, finish_document, refine_regions};
29+
use docling_pdf::scanned::{assemble_page_with_tables, finish_document, refine_regions};
3030
use image::RgbImage;
3131
use wasm_bindgen::prelude::*;
3232

3333
use crate::ocr::{tensor_parts, RecSession};
34+
use crate::tableformer::TfSession;
3435

3536
#[wasm_bindgen]
3637
extern "C" {
@@ -65,9 +66,9 @@ impl ScannedConverter {
6566
}
6667
}
6768

68-
/// Convert one page: `rgba` is the rendered bitmap (canvas ImageData),
69-
/// `scale` its pixels-per-PDF-point (2.0 for pdf.js `{scale: 2}`; 1.0
70-
/// for a standalone image).
69+
/// Convert one page (lite profile — geometric tables): `rgba` is the
70+
/// rendered bitmap (canvas ImageData), `scale` its pixels-per-PDF-point
71+
/// (2.0 for pdf.js `{scale: 2}`; 1.0 for a standalone image).
7172
pub async fn add_page(
7273
&mut self,
7374
rgba: &[u8],
@@ -76,6 +77,43 @@ impl ScannedConverter {
7677
scale: f32,
7778
layout: &LayoutSession,
7879
rec: &RecSession,
80+
) -> Result<(), JsError> {
81+
self.add_page_impl(rgba, px_w, px_h, scale, layout, rec, None)
82+
.await
83+
}
84+
85+
/// Convert one page with TableFormer (#157 stage 3): table regions get the
86+
/// ONNX table-structure model + docling's cell matcher instead of the
87+
/// geometric reconstruction. `tf` is the JS-side session over the encoder /
88+
/// decoder / bbox graphs.
89+
#[wasm_bindgen(js_name = addPageTf)]
90+
#[allow(clippy::too_many_arguments)] // wasm-bindgen entry: page bitmap + 3 sessions
91+
pub async fn add_page_tf(
92+
&mut self,
93+
rgba: &[u8],
94+
px_w: u32,
95+
px_h: u32,
96+
scale: f32,
97+
layout: &LayoutSession,
98+
rec: &RecSession,
99+
tf: &TfSession,
100+
) -> Result<(), JsError> {
101+
self.add_page_impl(rgba, px_w, px_h, scale, layout, rec, Some(tf))
102+
.await
103+
}
104+
}
105+
106+
impl ScannedConverter {
107+
#[allow(clippy::too_many_arguments)]
108+
async fn add_page_impl(
109+
&mut self,
110+
rgba: &[u8],
111+
px_w: u32,
112+
px_h: u32,
113+
scale: f32,
114+
layout: &LayoutSession,
115+
rec: &RecSession,
116+
tf: Option<&TfSession>,
79117
) -> Result<(), JsError> {
80118
if rgba.len() != (px_w as usize) * (px_h as usize) * 4 {
81119
return Err(JsError::new("rgba buffer size does not match dimensions"));
@@ -142,8 +180,34 @@ impl ScannedConverter {
142180
cells.push(TextCell { text, l, t, r, b });
143181
}
144182

183+
// TableFormer (opt-in): resolve each table region's structure through
184+
// the ONNX graphs + shared matcher; other regions stay `None` (geometric
185+
// fallback). The lite path passes no session → all geometric.
186+
let table_rows = if let Some(tf) = tf {
187+
let mut rows = Vec::with_capacity(regions.len());
188+
for r in &regions {
189+
if r.label == "table" {
190+
rows.push(
191+
crate::tableformer::predict_table_rows(
192+
tf,
193+
&img,
194+
[r.l, r.t, r.r, r.b],
195+
&cells,
196+
)
197+
.await,
198+
);
199+
} else {
200+
rows.push(None);
201+
}
202+
}
203+
rows
204+
} else {
205+
Vec::new() // assemble_page_with_tables resizes to all-None
206+
};
207+
145208
let page = PdfPage::from_cells(page_w, page_h, scale, cells);
146-
self.pages.push(assemble_page(&page, regions));
209+
self.pages
210+
.push(assemble_page_with_tables(&page, regions, table_rows));
147211
Ok(())
148212
}
149213

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//! Browser TableFormer — stage 3 of #157. The autoregressive structure loop
2+
//! and every ONNX-free step (448 preprocessing, tag corrections, bbox
3+
//! bookkeeping, span merge, OTSL→grid, cell matching) run in Rust via
4+
//! `docling_pdf::tf_core` — the *same* code the native pipeline uses — and only
5+
//! the three ONNX graphs (encoder / decoder / bbox) are delegated to ONNX
6+
//! Runtime Web through the [`TfSession`] interop object.
7+
//!
8+
//! The heavy tensors never cross the wasm boundary: [`TfSession`] holds the
9+
//! encoder's constant cross-attention K/V and `enc_out`, and the growing
10+
//! decoder KV-cache, entirely on the JS side. Each decode step sends only the
11+
//! last tag (one int) and gets back `logits` + `hidden` (525 floats). See
12+
//! `www/scan.html` for the JS wiring (the `decoder_kv` graph: N_LAYERS=6,
13+
//! KV_HEADS=8, head_dim=64, cross length 784).
14+
15+
use docling_pdf::pdfium_backend::TextCell;
16+
use docling_pdf::tf_core::{
17+
argmax, build_table_cells, merge_spans, preprocess_input, table_rows, BboxBook, TableCell,
18+
MAX_STEPS,
19+
};
20+
use image::RgbImage;
21+
use wasm_bindgen::prelude::*;
22+
23+
#[wasm_bindgen]
24+
extern "C" {
25+
/// The JS-side TableFormer session: a stateful wrapper around the three
26+
/// `ort.InferenceSession`s. `encode` runs the image encoder and stashes the
27+
/// constant cross tensors + `enc_out` and resets the KV-cache; `step` runs
28+
/// one decoder step (feeding the stored cross + growing cache) and returns
29+
/// `{ logits: Float32Array, hidden: Float32Array }`; `bbox` runs the bbox
30+
/// decoder over the collected per-cell hidden states and returns
31+
/// `{ boxes: Float32Array, classes: Float32Array }`.
32+
pub type TfSession;
33+
34+
#[wasm_bindgen(method, catch)]
35+
async fn encode(this: &TfSession, image: js_sys::Float32Array) -> Result<JsValue, JsValue>;
36+
37+
#[wasm_bindgen(method, catch)]
38+
async fn step(this: &TfSession, tag: i32) -> Result<JsValue, JsValue>;
39+
40+
#[wasm_bindgen(method, catch)]
41+
async fn bbox(
42+
this: &TfSession,
43+
tag_h: js_sys::Float32Array,
44+
n: u32,
45+
) -> Result<JsValue, JsValue>;
46+
}
47+
48+
/// Pull a named `Float32Array` out of a JS result object.
49+
fn get_f32(obj: &JsValue, key: &str) -> Result<Vec<f32>, JsError> {
50+
let v = js_sys::Reflect::get(obj, &JsValue::from_str(key))
51+
.map_err(|_| JsError::new(&format!("tf result has no `{key}`")))?;
52+
let arr: js_sys::Float32Array = v
53+
.dyn_into()
54+
.map_err(|_| JsError::new(&format!("tf `{key}` is not a Float32Array")))?;
55+
Ok(arr.to_vec())
56+
}
57+
58+
/// Run the full structure model on a `SIDE×SIDE`-croppable region image: encode
59+
/// once, step the decoder to the OTSL sequence (`tf_core::BboxBook` drives the
60+
/// exact bbox bookkeeping), run the bbox decoder, then merge spans and lay the
61+
/// cells onto the grid — all shared with native.
62+
async fn predict_structure(
63+
session: &TfSession,
64+
crop: &RgbImage,
65+
) -> Result<Vec<TableCell>, JsError> {
66+
let input = preprocess_input(crop);
67+
session
68+
.encode(js_sys::Float32Array::from(input.as_slice()))
69+
.await
70+
.map_err(|e| JsError::new(&format!("tf encode: {e:?}")))?;
71+
72+
let mut book = BboxBook::new();
73+
while book.otsl.len() < MAX_STEPS {
74+
let last = *book.tags.last().expect("decode starts from <start>");
75+
let out = session
76+
.step(last as i32)
77+
.await
78+
.map_err(|e| JsError::new(&format!("tf decode step: {e:?}")))?;
79+
let logits = get_f32(&out, "logits")?;
80+
let hidden = get_f32(&out, "hidden")?;
81+
let raw = argmax(&logits) as i64;
82+
if !book.step(raw, &hidden) {
83+
break;
84+
}
85+
}
86+
if book.n == 0 {
87+
return Ok(Vec::new());
88+
}
89+
90+
let out = session
91+
.bbox(
92+
js_sys::Float32Array::from(book.hiddens.as_slice()),
93+
book.n as u32,
94+
)
95+
.await
96+
.map_err(|e| JsError::new(&format!("tf bbox: {e:?}")))?;
97+
let boxes: Vec<[f32; 4]> = get_f32(&out, "boxes")?
98+
.chunks_exact(4)
99+
.map(|c| [c[0], c[1], c[2], c[3]])
100+
.collect();
101+
let classes: Vec<i64> = get_f32(&out, "classes")?
102+
.chunks_exact(3)
103+
.map(|c| argmax(c) as i64)
104+
.collect();
105+
let (merged, merged_classes) = merge_spans(&boxes, &classes, &book.merge);
106+
Ok(build_table_cells(&book.otsl, &merged, &merged_classes))
107+
}
108+
109+
/// Predict a table region's grid, the browser counterpart of the native
110+
/// `TableFormer::predict_table_rows`: docling's page→1024px box-average + crop
111+
/// (`docling_pdf::resample`), the ONNX structure model, then the shared word
112+
/// matcher (`tf_core::table_rows`). `page_image` is the rendered page (2 px per
113+
/// point, like the native pipeline); `region` is `(l, t, r, b)` in page points.
114+
/// `None` when no structure is predicted.
115+
pub(crate) async fn predict_table_rows(
116+
session: &TfSession,
117+
page_image: &RgbImage,
118+
region: [f32; 4],
119+
words: &[TextCell],
120+
) -> Option<Vec<Vec<String>>> {
121+
// page → 1024px height (cv2.INTER_AREA), then crop the table bbox — docling's
122+
// coordinate chain with the same rounding the native path reproduces.
123+
let sf = 1024.0 / page_image.height() as f32;
124+
let pw = (page_image.width() as f32 * sf) as u32;
125+
let page1024 = docling_pdf::resample::inter_area(page_image, pw, 1024);
126+
let k = 2.0 * 1024.0 / page_image.height() as f64;
127+
let px = |v: f32| (v as f64).round_ties_even() * k;
128+
let x = (px(region[0]).round_ties_even()).max(0.0) as u32;
129+
let y = (px(region[1]).round_ties_even()).max(0.0) as u32;
130+
let x2 = (px(region[2]).round_ties_even() as u32).min(page1024.width());
131+
let y2 = (px(region[3]).round_ties_even() as u32).min(page1024.height());
132+
if x2 <= x || y2 <= y {
133+
return None;
134+
}
135+
let crop = image::imageops::crop_imm(&page1024, x, y, x2 - x, y2 - y).to_image();
136+
let cells = predict_structure(session, &crop).await.ok()?;
137+
if cells.is_empty() {
138+
return None;
139+
}
140+
table_rows(&cells, region, words)
141+
}

0 commit comments

Comments
 (0)