Skip to content

Commit 39d58b2

Browse files
artizclaude
andcommitted
feat(tableformer): export the bbox decoder (full model now exported)
Extends the export with the third graph, completing the model: the encoder now also emits the raw enc_out [1,28,28,256], and bbox.onnx batches docling's BBoxDecoder.inference over cells — enc_out + per-cell tag hidden states [N,512] → boxes [N,4] (cxcywh, sigmoid) + classes. All three graphs verified against PyTorch (boxes max diff 2e-7). This gives the per-cell geometry needed to match PDF text cells onto the OTSL grid for cell content. The Rust OTSL decode is unaffected (still byte-exact: 54 tokens on 2305v1-pg9). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11703a2 commit 39d58b2

1 file changed

Lines changed: 51 additions & 9 deletions

File tree

scripts/export_tableformer.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,35 @@
5555

5656
class Encode(nn.Module):
5757
def forward(self, img):
58-
eo = m._encoder(img)
59-
eo = tt._input_filter(eo.permute(0, 3, 1, 2)).permute(0, 2, 3, 1)
58+
eo_raw = m._encoder(img) # [1,28,28,512] — also feeds the bbox decoder
59+
eo = tt._input_filter(eo_raw.permute(0, 3, 1, 2)).permute(0, 2, 3, 1)
6060
ei = eo.reshape(1, -1, eo.size(-1)).permute(1, 0, 2)
6161
pos = ei.shape[0]
62-
return tt._encoder(ei, mask=torch.zeros((nh, pos, pos), dtype=torch.bool))
62+
mem = tt._encoder(ei, mask=torch.zeros((nh, pos, pos), dtype=torch.bool))
63+
return mem, eo_raw
64+
65+
66+
class BBoxDecode(nn.Module):
67+
# docling's BBoxDecoder.inference, batched over cells: each cell's tag hidden
68+
# state attends over the (bbox-decoder-filtered) encoder output to a box.
69+
def forward(self, enc_out, tag_h): # enc_out [1,28,28,512], tag_h [N,512]
70+
bd = m._bbox_decoder
71+
e = bd._input_filter(enc_out.permute(0, 3, 1, 2)).permute(0, 2, 3, 1)
72+
e = e.reshape(1, -1, e.size(3)) # [1, 784, 512]
73+
n = tag_h.shape[0]
74+
h = bd._init_h(e.mean(dim=1)).expand(n, -1) # [N, dim] (same for all cells)
75+
a = bd._attention
76+
att = a._full_att(
77+
a._relu(
78+
a._encoder_att(e)
79+
+ a._tag_decoder_att(tag_h).unsqueeze(1)
80+
+ a._language_att(h).unsqueeze(1)
81+
)
82+
).squeeze(2)
83+
alpha = a._softmax(att) # [N, 784]
84+
awe = (e * alpha.unsqueeze(2)).sum(dim=1) # [N, 512]
85+
h = (bd._sigmoid(bd._f_beta(h)) * awe) * h
86+
return bd._bbox_embed(h).sigmoid(), bd._class_embed(h)
6387

6488

6589
class Decode(nn.Module):
@@ -88,39 +112,57 @@ def check(name, a, b):
88112
return d
89113

90114

115+
from torch.export import Dim # noqa: E402
116+
91117
img = torch.randn(1, 3, 448, 448)
92118
with torch.no_grad():
93-
mem = Encode()(img)
119+
mem, enc_out = Encode()(img)
94120
torch.onnx.export(
95121
Encode(), (img,), f"{OUT}/encoder.onnx",
96-
input_names=["image"], output_names=["memory"], opset_version=17, dynamo=False,
122+
input_names=["image"], output_names=["memory", "enc_out"],
123+
opset_version=17, dynamo=False,
97124
)
98125
tags = torch.full((4, 1), start, dtype=torch.long)
99126
with torch.no_grad():
100127
logits, hidden = Decode()(tags, mem)
101128
# The dynamo exporter is needed here: the legacy tracer bakes the sequence length
102129
# into nn.MultiheadAttention's reshape, so a 1-token first step fails. dynamo keeps
103130
# the `seq` axis symbolic.
104-
from torch.export import Dim # noqa: E402
105-
106131
seq = Dim("seq", min=1, max=1024)
107132
torch.onnx.export(
108133
Decode(), (tags, mem), f"{OUT}/decoder.onnx",
109134
input_names=["tags", "memory"], output_names=["logits", "hidden"],
110135
dynamo=True, dynamic_shapes=({0: seq}, {}),
111136
)
137+
# bbox decoder: N cell hiddens → N boxes (+ classes). N is dynamic.
138+
tag_h = torch.randn(5, 512)
139+
with torch.no_grad():
140+
boxes, classes = BBoxDecode()(enc_out, tag_h)
141+
ncells = Dim("ncells", min=1, max=1024)
142+
torch.onnx.export(
143+
BBoxDecode(), (enc_out, tag_h), f"{OUT}/bbox.onnx",
144+
input_names=["enc_out", "tag_h"], output_names=["boxes", "classes"],
145+
dynamo=True, dynamic_shapes=({}, {0: ncells}),
146+
)
112147

113148
import onnxruntime as ort # noqa: E402
114149

115150
print("encoder.onnx:")
116-
eo = ort.InferenceSession(f"{OUT}/encoder.onnx").run(None, {"image": img.numpy()})[0]
117-
check("memory", eo, mem.numpy())
151+
eres = ort.InferenceSession(f"{OUT}/encoder.onnx").run(None, {"image": img.numpy()})
152+
check("memory", eres[0], mem.numpy())
153+
check("enc_out", eres[1], enc_out.numpy())
118154
print("decoder.onnx:")
119155
do = ort.InferenceSession(f"{OUT}/decoder.onnx").run(
120156
None, {"tags": tags.numpy(), "memory": mem.numpy()}
121157
)
122158
check("logits", do[0], logits.numpy())
123159
check("hidden", do[1], hidden.numpy())
160+
print("bbox.onnx:")
161+
bo = ort.InferenceSession(f"{OUT}/bbox.onnx").run(
162+
None, {"enc_out": enc_out.numpy(), "tag_h": tag_h.numpy()}
163+
)
164+
check("boxes", bo[0], boxes.numpy())
165+
check("classes", bo[1], classes.numpy())
124166

125167
# word map → tokens file for the Rust decode loop
126168
json.dump(

0 commit comments

Comments
 (0)