Skip to content

Commit 050cf3c

Browse files
artizclaude
andcommitted
feat(tableformer): export TableFormer to ONNX (encoder + autoregressive decoder)
First milestone of the TableFormer port: scripts/export_tableformer.py loads docling's TableModel04_rs (accurate) from the docling-ibm-models safetensors and exports two ONNX graphs, both verified against PyTorch (max abs diff < 1.5e-5): encoder.onnx : image[1,3,448,448] -> memory[784,1,512] decoder.onnx : tags[seq,1] + memory -> logits[1,13], hidden[1,512] Key findings that de-risk the rest of the port: - The OTSL structure vocabulary is just 13 tokens (wordmap.json). - The decoder runs with cache=None (re-embeds the full prefix each step), so it exports cleanly with a dynamic seq axis and is a plain Rust loop — no KV-cache. - Needed `requires_grad_(False)` on the params for the decoder to trace. The critical feasibility gate (ONNX-exporting an autoregressive transformer) is passed. Remaining work is the Rust side — decode loop, OTSL→grid serialization (port otsl.py), and cell-text matching — scoped in PDF_CONFORMANCE.md. The ONNX weights are gitignored (exported/downloaded like the layout/OCR models). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b3b9d69 commit 050cf3c

2 files changed

Lines changed: 137 additions & 18 deletions

File tree

PDF_CONFORMANCE.md

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -120,24 +120,37 @@ rows/columns); docling runs **TableFormer**, an autoregressive transformer that
120120
predicts the table structure as an OTSL/HTML tag sequence plus per-cell bounding
121121
boxes, which recovers spanning headers and merged cells we cannot.
122122

123-
**Scope of a port** (large — own PR, likely staged over several):
124-
125-
1. **Weights.** TableFormer ships in `docling-ibm-models` (`TableModel04_rs`,
126-
"accurate"/"fast" variants). Export the encoder + the two decoders to ONNX
127-
from the published checkpoint; confirm the license permits redistribution of
128-
a converted model.
129-
2. **Inference loop.** Unlike the layout/OCR models (single `Session::run`),
130-
TableFormer is **autoregressive**: encode the table-crop image once, then step
131-
the structure decoder to emit OTSL tokens until `<end>`, feeding each token
132-
back in. The cell-bbox decoder runs per predicted cell. This is a real
133-
decoding loop in `fleischwolf-pdf`, not a one-shot call — budget for KV-cache
134-
handling and a token vocabulary/OTSL grammar.
135-
3. **Cell content.** Map predicted cell bounding boxes back onto the PDF text
136-
cells (we have these) to fill cell text — the same matching docling does for
137-
"PDF" tables (it does not OCR programmatic tables).
138-
4. **Serialization.** Convert the predicted OTSL grid (with row/col spans) to the
139-
`Table` node; the Markdown table serializer already exists but assumes a plain
140-
grid, so spans need representing.
123+
**Status — ONNX export DONE ✅.** `scripts/export_tableformer.py` loads
124+
`TableModel04_rs` (`accurate`, resnet18 + 6-layer encoder + 6-layer decoder) from
125+
the `docling-ibm-models` safetensors and exports two graphs, **both verified
126+
against PyTorch** (max abs diff < 1.5e-5):
127+
128+
- `encoder.onnx``image[1,3,448,448] → memory[784,1,512]`
129+
- `decoder.onnx``tags[seq,1] + memory → logits[1,13], hidden[1,512]`
130+
131+
The OTSL vocabulary is only **13 tokens** (`wordmap.json`). The decoder runs with
132+
`cache=None` (re-embeds the full token prefix each step), so it exports cleanly
133+
with a dynamic `seq` axis and is driven as a simple loop from Rust — no KV-cache
134+
machinery needed. The ONNX/weights are gitignored (downloaded/exported, like the
135+
layout/OCR models).
136+
137+
**Remaining (the Rust inference, staged):**
138+
139+
1. **Decode loop** in `fleischwolf-pdf`: crop+resize the table region to 448²,
140+
normalise, `encoder.onnx` once, then loop `decoder.onnx``argmax` the logits,
141+
apply docling's two structure-correction rules (first-line `xcel→lcel`,
142+
`ucel`-then-`lcel → fcel`), append, stop at `<end>`. Yields the OTSL tag run.
143+
2. **Bbox decoder** (`bbox.onnx`, not yet exported): per-cell hidden → box, for
144+
matching. Interim: skip it and match by grid geometry.
145+
3. **OTSL → grid.** `docling_ibm_models/tableformer/otsl.py` is the reference
146+
(`fcel/ched/rhed/srow/ecel/lcel/ucel/xcel/nl`); port it to a `Table` with
147+
row/col spans (the Markdown serializer needs span support added).
148+
4. **Cell content.** Map the PDF text cells we already extract onto the grid
149+
cells (docling does not OCR programmatic tables).
150+
151+
A cheaper interim improvement (not docling-exact, but closes some diff): better
152+
geometric reconstruction — detect header rows, merge obvious spanning cells, and
153+
handle the multi-line header cells that currently shatter into many columns.
141154

142155
A cheaper interim improvement (not docling-exact, but closes some diff): better
143156
geometric reconstruction — detect header rows, merge obvious spanning cells, and

scripts/export_tableformer.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
"""Export docling's TableFormer (TableModel04_rs) to ONNX for the Rust pipeline.
3+
4+
TableFormer is autoregressive: an image encoder + a tag-transformer encoder run
5+
once to produce a memory tensor, then a decoder step is looped to emit OTSL
6+
structure tokens, and a bbox decoder turns the per-cell hidden states into boxes.
7+
We export three graphs and drive the loop from Rust:
8+
9+
encoder.onnx : image[1,3,448,448] -> memory[784,1,512]
10+
decoder.onnx : tags[seq,1] + memory -> logits[1,V], hidden[1,512]
11+
bbox.onnx : memory + cell_hidden[ncells,512] -> classes, coords (optional)
12+
13+
Run inside the docling venv:
14+
.venv-compare/bin/python scripts/export_tableformer.py <accurate-artifacts-dir> [out_dir]
15+
"""
16+
import json
17+
import os
18+
import sys
19+
import warnings
20+
21+
import torch
22+
import torch.nn as nn
23+
24+
warnings.filterwarnings("ignore")
25+
26+
ART = sys.argv[1]
27+
OUT = sys.argv[2] if len(sys.argv) > 2 else "models/tableformer"
28+
os.makedirs(OUT, exist_ok=True)
29+
30+
cfg = json.load(open(f"{ART}/tm_config.json"))
31+
cfg["model"]["save_dir"] = ART
32+
cfg["predict"]["profiling"] = False
33+
34+
from docling_ibm_models.tableformer.data_management.tf_predictor import TFPredictor # noqa: E402
35+
36+
pred = TFPredictor(cfg, device="cpu")
37+
m = pred._model
38+
m.eval()
39+
torch.set_grad_enabled(False)
40+
for p in m.parameters():
41+
p.requires_grad_(False)
42+
tt = m._tag_transformer
43+
nh = tt._n_heads
44+
word_map = pred._init_data["word_map"]["word_map_tag"]
45+
start = word_map["<start>"]
46+
47+
48+
class Encode(nn.Module):
49+
def forward(self, img):
50+
eo = m._encoder(img)
51+
eo = tt._input_filter(eo.permute(0, 3, 1, 2)).permute(0, 2, 3, 1)
52+
ei = eo.reshape(1, -1, eo.size(-1)).permute(1, 0, 2)
53+
pos = ei.shape[0]
54+
return tt._encoder(ei, mask=torch.zeros((nh, pos, pos), dtype=torch.bool))
55+
56+
57+
class Decode(nn.Module):
58+
def forward(self, tags, memory):
59+
emb = tt._positional_encoding(tt._embedding(tags))
60+
decoded, _ = tt._decoder(emb, memory, None, memory_key_padding_mask=None)
61+
last = decoded[-1]
62+
return tt._fc(last), last
63+
64+
65+
def check(name, a, b):
66+
import numpy as np
67+
68+
d = float(np.abs(a - b).max())
69+
print(f" {name}: shape {tuple(a.shape)} | max|onnx-torch| = {d:.2e}")
70+
return d
71+
72+
73+
img = torch.randn(1, 3, 448, 448)
74+
with torch.no_grad():
75+
mem = Encode()(img)
76+
torch.onnx.export(
77+
Encode(), (img,), f"{OUT}/encoder.onnx",
78+
input_names=["image"], output_names=["memory"], opset_version=17, dynamo=False,
79+
)
80+
tags = torch.full((3, 1), start, dtype=torch.long)
81+
with torch.no_grad():
82+
logits, hidden = Decode()(tags, mem)
83+
torch.onnx.export(
84+
Decode(), (tags, mem), f"{OUT}/decoder.onnx",
85+
input_names=["tags", "memory"], output_names=["logits", "hidden"],
86+
dynamic_axes={"tags": {0: "seq"}}, opset_version=17, dynamo=False,
87+
)
88+
89+
import onnxruntime as ort # noqa: E402
90+
91+
print("encoder.onnx:")
92+
eo = ort.InferenceSession(f"{OUT}/encoder.onnx").run(None, {"image": img.numpy()})[0]
93+
check("memory", eo, mem.numpy())
94+
print("decoder.onnx:")
95+
do = ort.InferenceSession(f"{OUT}/decoder.onnx").run(
96+
None, {"tags": tags.numpy(), "memory": mem.numpy()}
97+
)
98+
check("logits", do[0], logits.numpy())
99+
check("hidden", do[1], hidden.numpy())
100+
101+
# word map → tokens file for the Rust decode loop
102+
json.dump(
103+
{"word_map_tag": word_map, "start": start, "end": word_map["<end>"]},
104+
open(f"{OUT}/wordmap.json", "w"),
105+
)
106+
print("wrote wordmap.json; OTSL vocab size:", len(word_map))

0 commit comments

Comments
 (0)