Skip to content

Commit 1fde7a3

Browse files
artizclaude
andcommitted
feat(tableformer): predict full table structure (cells + boxes + spans)
predict_table_structure runs the full model: encode → autoregressive decode collecting per-cell decoder hidden states (docling's exact tag_H_buf / bboxes_to_merge bookkeeping — skip-after-row-break, first-lcel of a horizontal span) → bbox decoder → span-box merge (mergebboxes) → OTSL→grid with row/col spans read off the grid. Verified on 2305v1-pg9: 41 cells, structure matches docling exactly (# enc-layers rowspan 2, TEDs colspan 3) and the per-cell boxes match docling's to ~2e-3 (cxcywh). Remaining: word-cell IoC matching → cell text → Table node + pipeline wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff613b7 commit 1fde7a3

2 files changed

Lines changed: 272 additions & 51 deletions

File tree

crates/fleischwolf-pdf/examples/tf_otsl.rs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,28 @@ fn main() {
4646
let y2 = (r.b * k).round() as u32;
4747
let (w, h) = (x2 - x, y2 - y);
4848
let crop = imageops::crop_imm(&page1024, x, y, w, h).to_image();
49-
let otsl = tf.predict_otsl(&crop).expect("predict");
50-
let rows = otsl.iter().filter(|&&t| t == 9).count();
51-
let cols = otsl.iter().take_while(|&&t| t != 9).count();
49+
let cells = tf.predict_table_structure(&crop).expect("predict");
5250
println!(
53-
"page {} table {}x{}px -> {} tokens, {} rows x {} cols",
51+
"page {} table {}x{}px -> {} cells",
5452
pi + 1,
5553
w,
5654
h,
57-
otsl.len(),
58-
rows,
59-
cols
60-
);
61-
println!(
62-
" {}",
63-
otsl.iter().map(|&t| name(t)).collect::<Vec<_>>().join(" ")
55+
cells.len()
6456
);
57+
for c in &cells {
58+
println!(
59+
" r{} c{} {}x{} {} | cxcywh {:.4} {:.4} {:.4} {:.4}",
60+
c.row,
61+
c.col,
62+
c.colspan,
63+
c.rowspan,
64+
name(c.tag),
65+
c.cx,
66+
c.cy,
67+
c.w,
68+
c.h
69+
);
70+
}
6571
}
6672
}
6773
}

crates/fleischwolf-pdf/src/tableformer.rs

Lines changed: 255 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,42 @@ pub const CHED: i64 = 10; // column header
3030
pub const RHED: i64 = 11; // row header
3131
pub const SROW: i64 = 12; // section row
3232

33+
/// A predicted table cell: an OTSL grid position (with spans) + its box in the
34+
/// 448 image normalized cxcywh, and the OTSL tag.
35+
#[derive(Debug, Clone)]
36+
pub struct TableCell {
37+
pub row: usize,
38+
pub col: usize,
39+
pub colspan: usize,
40+
pub rowspan: usize,
41+
pub tag: i64,
42+
pub cx: f32,
43+
pub cy: f32,
44+
pub w: f32,
45+
pub h: f32,
46+
}
47+
3348
pub struct TableFormer {
3449
encoder: Session,
3550
decoder: Session,
51+
bbox: Session,
3652
}
3753

3854
impl TableFormer {
39-
/// Load the exported encoder/decoder ONNX graphs (env overrides, else
40-
/// `models/tableformer/{encoder,decoder}.onnx`). Returns `None` if either is
55+
/// Load the exported encoder/decoder/bbox ONNX graphs (env overrides, else
56+
/// `models/tableformer/{encoder,decoder,bbox}.onnx`). Returns `None` if any is
4157
/// absent, so the pipeline falls back to geometric reconstruction.
4258
pub fn load() -> Option<Self> {
4359
let enc = std::env::var("DOCLING_TABLEFORMER_ENCODER")
4460
.unwrap_or_else(|_| "models/tableformer/encoder.onnx".to_string());
4561
let dec = std::env::var("DOCLING_TABLEFORMER_DECODER")
4662
.unwrap_or_else(|_| "models/tableformer/decoder.onnx".to_string());
47-
if !std::path::Path::new(&enc).exists() || !std::path::Path::new(&dec).exists() {
63+
let bbx = std::env::var("DOCLING_TABLEFORMER_BBOX")
64+
.unwrap_or_else(|_| "models/tableformer/bbox.onnx".to_string());
65+
if [&enc, &dec, &bbx]
66+
.iter()
67+
.any(|p| !std::path::Path::new(p).exists())
68+
{
4869
return None;
4970
}
5071
let build = |path: &str| -> Result<Session, String> {
@@ -55,49 +76,19 @@ impl TableFormer {
5576
.commit_from_file(path)
5677
.map_err(|e| format!("tableformer load {path}: {e}"))
5778
};
58-
match (build(&enc), build(&dec)) {
59-
(Ok(encoder), Ok(decoder)) => Some(Self { encoder, decoder }),
79+
match (build(&enc), build(&dec), build(&bbx)) {
80+
(Ok(encoder), Ok(decoder), Ok(bbox)) => Some(Self {
81+
encoder,
82+
decoder,
83+
bbox,
84+
}),
6085
_ => None,
6186
}
6287
}
6388

6489
/// Predict the OTSL structure-token sequence for a table-region image.
6590
pub fn predict_otsl(&mut self, img: &RgbImage) -> Result<Vec<i64>, String> {
66-
// Preprocess exactly as docling: bilinear (cv2.INTER_LINEAR) resize the
67-
// crop to 448², normalize (x/255 − mean)/std, laid out as (C, W, H) —
68-
// docling transposes (2,1,0), so width is the major spatial axis (not
69-
// C,H,W). The page→1024px box-average (cv2.INTER_AREA) is the caller's.
70-
let n = (SIDE * SIDE) as usize;
71-
let side = SIDE as usize;
72-
let (sw, sh) = (img.width() as i32, img.height() as i32);
73-
let sxr = sw as f32 / SIDE as f32;
74-
let syr = sh as f32 / SIDE as f32;
75-
let mut data = vec![0f32; 3 * n];
76-
for h in 0..side {
77-
let fy = (h as f32 + 0.5) * syr - 0.5;
78-
let wy = fy - fy.floor();
79-
let y0c = (fy.floor() as i32).clamp(0, sh - 1) as u32;
80-
let y1c = (fy.floor() as i32 + 1).clamp(0, sh - 1) as u32;
81-
for w in 0..side {
82-
let fx = (w as f32 + 0.5) * sxr - 0.5;
83-
let wx = fx - fx.floor();
84-
let x0c = (fx.floor() as i32).clamp(0, sw - 1) as u32;
85-
let x1c = (fx.floor() as i32 + 1).clamp(0, sw - 1) as u32;
86-
let p00 = img.get_pixel(x0c, y0c);
87-
let p01 = img.get_pixel(x1c, y0c);
88-
let p10 = img.get_pixel(x0c, y1c);
89-
let p11 = img.get_pixel(x1c, y1c);
90-
let idx = w * side + h; // (C, W, H): c*n + w*H + h
91-
for c in 0..3 {
92-
let top = p00[c] as f32 * (1.0 - wx) + p01[c] as f32 * wx;
93-
let bot = p10[c] as f32 * (1.0 - wx) + p11[c] as f32 * wx;
94-
let v = top * (1.0 - wy) + bot * wy;
95-
data[c * n + idx] = (v / 255.0 - MEAN[c]) / STD[c];
96-
}
97-
}
98-
}
99-
let input = Tensor::from_array(([1usize, 3, SIDE as usize, SIDE as usize], data))
100-
.map_err(|e| format!("tableformer: input: {e}"))?;
91+
let input = preprocess(img)?;
10192
let enc_out = self
10293
.encoder
10394
.run(ort::inputs!["image" => input])
@@ -144,6 +135,230 @@ impl TableFormer {
144135
}
145136
Ok(out)
146137
}
138+
139+
/// Full structure prediction: OTSL grid cells with per-cell boxes (in the 448
140+
/// image, normalized cxcywh). Collects per-cell decoder hidden states using
141+
/// docling's exact bbox bookkeeping (skip-after-row-break, first-lcel of a
142+
/// horizontal span), runs the bbox decoder, merges span boxes, then lays the
143+
/// cells onto the OTSL grid with row/col spans.
144+
pub fn predict_table_structure(&mut self, img: &RgbImage) -> Result<Vec<TableCell>, String> {
145+
let input = preprocess(img)?;
146+
let enc_out = self
147+
.encoder
148+
.run(ort::inputs!["image" => input])
149+
.map_err(|e| format!("tableformer: encode: {e}"))?;
150+
let (mshape, mem) = enc_out["memory"]
151+
.try_extract_tensor::<f32>()
152+
.map_err(|e| format!("tableformer: memory: {e}"))?;
153+
let mshape: Vec<usize> = mshape.iter().map(|&x| x as usize).collect();
154+
let mem: Vec<f32> = mem.to_vec();
155+
let (eshape, eo) = enc_out["enc_out"]
156+
.try_extract_tensor::<f32>()
157+
.map_err(|e| format!("tableformer: enc_out: {e}"))?;
158+
let eshape: Vec<usize> = eshape.iter().map(|&x| x as usize).collect();
159+
let eo: Vec<f32> = eo.to_vec();
160+
161+
let mut tags: Vec<i64> = vec![START];
162+
let mut otsl: Vec<i64> = Vec::new();
163+
let mut hiddens: Vec<f32> = Vec::new(); // flattened [n, 512]
164+
let mut n = 0usize;
165+
let mut prev_ucel = false;
166+
let mut skip = true; // first tag after <start> is skipped
167+
let mut first_lcel = true;
168+
let mut bbox_ind = 0usize;
169+
let mut cur_bbox_ind = 0usize;
170+
let mut merge: std::collections::HashMap<usize, i64> = std::collections::HashMap::new();
171+
while otsl.len() < MAX_STEPS {
172+
let tags_t = Tensor::from_array(([tags.len(), 1usize], tags.clone()))
173+
.map_err(|e| format!("tableformer: tags: {e}"))?;
174+
let mem_t = Tensor::from_array((mshape.clone(), mem.clone()))
175+
.map_err(|e| format!("tableformer: mem: {e}"))?;
176+
let dout = self
177+
.decoder
178+
.run(ort::inputs!["tags" => tags_t, "memory" => mem_t])
179+
.map_err(|e| format!("tableformer: decode: {e}"))?;
180+
let (_, logits) = dout["logits"]
181+
.try_extract_tensor::<f32>()
182+
.map_err(|e| format!("tableformer: logits: {e}"))?;
183+
let mut tag = argmax(logits) as i64;
184+
if tag == XCEL {
185+
tag = LCEL;
186+
}
187+
if prev_ucel && tag == LCEL {
188+
tag = FCEL;
189+
}
190+
if tag == END {
191+
break;
192+
}
193+
let (_, hidden) = dout["hidden"]
194+
.try_extract_tensor::<f32>()
195+
.map_err(|e| format!("tableformer: hidden: {e}"))?;
196+
// docling's tag_H_buf / bboxes_to_merge bookkeeping.
197+
if !skip && matches!(tag, FCEL | ECEL | CHED | RHED | SROW | NL | UCEL) {
198+
hiddens.extend_from_slice(hidden);
199+
n += 1;
200+
if !first_lcel {
201+
merge.insert(cur_bbox_ind, bbox_ind as i64);
202+
}
203+
bbox_ind += 1;
204+
}
205+
if tag != LCEL {
206+
first_lcel = true;
207+
} else if first_lcel {
208+
hiddens.extend_from_slice(hidden);
209+
n += 1;
210+
first_lcel = false;
211+
cur_bbox_ind = bbox_ind;
212+
merge.insert(cur_bbox_ind, -1);
213+
bbox_ind += 1;
214+
}
215+
skip = matches!(tag, NL | UCEL | XCEL);
216+
prev_ucel = tag == UCEL;
217+
otsl.push(tag);
218+
tags.push(tag);
219+
}
220+
if n == 0 {
221+
return Ok(Vec::new());
222+
}
223+
let tag_h = Tensor::from_array(([n, 512usize], hiddens))
224+
.map_err(|e| format!("tableformer: tag_h: {e}"))?;
225+
let eo_t = Tensor::from_array((eshape, eo)).map_err(|e| format!("tableformer: eo: {e}"))?;
226+
let bout = self
227+
.bbox
228+
.run(ort::inputs!["enc_out" => eo_t, "tag_h" => tag_h])
229+
.map_err(|e| format!("tableformer: bbox: {e}"))?;
230+
let (_, raw) = bout["boxes"]
231+
.try_extract_tensor::<f32>()
232+
.map_err(|e| format!("tableformer: boxes: {e}"))?;
233+
let boxes: Vec<[f32; 4]> = raw
234+
.chunks_exact(4)
235+
.map(|c| [c[0], c[1], c[2], c[3]])
236+
.collect();
237+
let merged = merge_spans(&boxes, &merge);
238+
Ok(build_table_cells(&otsl, &merged))
239+
}
240+
}
241+
242+
/// docling's preprocessing: bilinear (cv2.INTER_LINEAR) resize the crop to 448²,
243+
/// normalize `(x/255 − mean)/std`, laid out as (C, W, H) — docling transposes
244+
/// (2,1,0), so width is the major spatial axis. The page→1024px box-average
245+
/// (cv2.INTER_AREA) is the caller's job.
246+
fn preprocess(img: &RgbImage) -> Result<Tensor<f32>, String> {
247+
let nn = (SIDE * SIDE) as usize;
248+
let side = SIDE as usize;
249+
let (sw, sh) = (img.width() as i32, img.height() as i32);
250+
let sxr = sw as f32 / SIDE as f32;
251+
let syr = sh as f32 / SIDE as f32;
252+
let mut data = vec![0f32; 3 * nn];
253+
for h in 0..side {
254+
let fy = (h as f32 + 0.5) * syr - 0.5;
255+
let wy = fy - fy.floor();
256+
let y0c = (fy.floor() as i32).clamp(0, sh - 1) as u32;
257+
let y1c = (fy.floor() as i32 + 1).clamp(0, sh - 1) as u32;
258+
for w in 0..side {
259+
let fx = (w as f32 + 0.5) * sxr - 0.5;
260+
let wx = fx - fx.floor();
261+
let x0c = (fx.floor() as i32).clamp(0, sw - 1) as u32;
262+
let x1c = (fx.floor() as i32 + 1).clamp(0, sw - 1) as u32;
263+
let p00 = img.get_pixel(x0c, y0c);
264+
let p01 = img.get_pixel(x1c, y0c);
265+
let p10 = img.get_pixel(x0c, y1c);
266+
let p11 = img.get_pixel(x1c, y1c);
267+
let idx = w * side + h; // (C, W, H): c*n + w*H + h
268+
for c in 0..3 {
269+
let top = p00[c] as f32 * (1.0 - wx) + p01[c] as f32 * wx;
270+
let bot = p10[c] as f32 * (1.0 - wx) + p11[c] as f32 * wx;
271+
let v = top * (1.0 - wy) + bot * wy;
272+
data[c * nn + idx] = (v / 255.0 - MEAN[c]) / STD[c];
273+
}
274+
}
275+
}
276+
Tensor::from_array(([1usize, 3, side, side], data))
277+
.map_err(|e| format!("tableformer: input: {e}"))
278+
}
279+
280+
/// docling's `mergebboxes` (cxcywh): the union box of a horizontal span's first
281+
/// and last cell.
282+
fn mergebboxes(b1: [f32; 4], b2: [f32; 4]) -> [f32; 4] {
283+
let new_w = (b2[0] + b2[2] / 2.0) - (b1[0] - b1[2] / 2.0);
284+
let new_h = (b2[1] + b2[3] / 2.0) - (b1[1] - b1[3] / 2.0);
285+
let new_left = b1[0] - b1[2] / 2.0;
286+
let new_top = (b2[1] - b2[3] / 2.0).min(b1[1] - b1[3] / 2.0);
287+
[new_left + new_w / 2.0, new_top + new_h / 2.0, new_w, new_h]
288+
}
289+
290+
/// Apply docling's span merges: each merge key combines its box with the partner
291+
/// (`-1` → the last box); partners are dropped.
292+
fn merge_spans(boxes: &[[f32; 4]], merge: &std::collections::HashMap<usize, i64>) -> Vec<[f32; 4]> {
293+
let skip: std::collections::HashSet<usize> = merge
294+
.values()
295+
.filter(|&&v| v >= 0)
296+
.map(|&v| v as usize)
297+
.collect();
298+
let mut out = Vec::new();
299+
for (i, &b) in boxes.iter().enumerate() {
300+
if let Some(&j) = merge.get(&i) {
301+
let partner = if j < 0 { boxes.len() - 1 } else { j as usize };
302+
out.push(mergebboxes(b, boxes[partner.min(boxes.len() - 1)]));
303+
} else if !skip.contains(&i) {
304+
out.push(b);
305+
}
306+
}
307+
out
308+
}
309+
310+
const CELL_TAGS: [i64; 6] = [FCEL, ECEL, XCEL, CHED, RHED, SROW];
311+
312+
/// Lay the OTSL tag stream onto a grid (docling's `_build_table_cells`, OTSL
313+
/// mode): cell tags create cells at (row, col); `lcel`/`ucel`/`xcel` are spans
314+
/// (counted toward the column index but not cells). Colspan/rowspan are read off
315+
/// the grid (consecutive `lcel`/`ucel` to the right/below). `boxes` are indexed
316+
/// by cell order and aligned with the cells.
317+
fn build_table_cells(otsl: &[i64], boxes: &[[f32; 4]]) -> Vec<TableCell> {
318+
// 2D grid of tags (rows split on NL) for span lookups.
319+
let mut grid: Vec<Vec<i64>> = vec![Vec::new()];
320+
for &t in otsl {
321+
if t == NL {
322+
grid.push(Vec::new());
323+
} else {
324+
grid.last_mut().unwrap().push(t);
325+
}
326+
}
327+
let mut cells = Vec::new();
328+
let mut cell_id = 0usize;
329+
for (r, row) in grid.iter().enumerate() {
330+
for (c, &tag) in row.iter().enumerate() {
331+
if !CELL_TAGS.contains(&tag) {
332+
continue;
333+
}
334+
let mut colspan = 1;
335+
while c + colspan < row.len() && matches!(row[c + colspan], LCEL | XCEL) {
336+
colspan += 1;
337+
}
338+
let mut rowspan = 1;
339+
while r + rowspan < grid.len()
340+
&& grid[r + rowspan]
341+
.get(c)
342+
.is_some_and(|&t| matches!(t, UCEL | XCEL))
343+
{
344+
rowspan += 1;
345+
}
346+
let b = boxes.get(cell_id).copied().unwrap_or([0.0; 4]);
347+
cells.push(TableCell {
348+
row: r,
349+
col: c,
350+
colspan,
351+
rowspan,
352+
tag,
353+
cx: b[0],
354+
cy: b[1],
355+
w: b[2],
356+
h: b[3],
357+
});
358+
cell_id += 1;
359+
}
360+
}
361+
cells
147362
}
148363

149364
fn argmax(v: &[f32]) -> usize {

0 commit comments

Comments
 (0)