docling_pdf/tableformer.rs
1//! TableFormer: table-structure recovery via docling-ibm-models, exported to
2//! ONNX by `scripts/install/export_tableformer.py`. The image encoder + tag-transformer
3//! encoder run once to a memory tensor; the decoder is then stepped
4//! autoregressively to emit an OTSL structure-token sequence (the same model
5//! docling runs). See docs/PDF_CONFORMANCE.md.
6
7use crate::pdfium_backend::TextCell;
8// The ONNX-free half (preprocessing, structure corrections, bbox bookkeeping,
9// span merge, OTSL→grid) lives in tf_core so the browser build (#157 stage 3)
10// runs the same logic; this file owns the three `ort` sessions and the
11// owned-value KV-cache fast path.
12use crate::tf_core::{
13 argmax, build_table_cells, correct, merge_spans, preprocess_input, BboxBook, TableCell, END,
14 MAX_STEPS, START, UCEL,
15};
16use image::RgbImage;
17use ort::session::Session;
18use ort::value::{DynValue, Tensor};
19
20const SIDE: usize = crate::tf_core::SIDE as usize;
21const EMBED_DIM: usize = crate::tf_core::EMBED_DIM;
22/// Decoder geometry, fixed by the exported TableModel04_rs graph: the cached
23/// decoder threads a `[N_LAYERS, past, 1, EMBED_DIM]` per-layer state cache.
24const N_LAYERS: usize = 6;
25
26/// Resolve the encoder / decoder / bbox files exactly as [`TableFormer::load`]
27/// will (shared with `model_inventory`, so diagnostics can never drift from
28/// what actually loads). Explicit `DOCLING_TABLEFORMER_*` overrides win; the
29/// decoder otherwise picks by preference — INT8 variants first unless
30/// `DOCLING_RS_FP32` opts out, and within a precision the true-KV-cache
31/// export (`decoder_kv*`, one token per step, O(past) step cost) ranks ahead
32/// of the legacy layer-output-cache graph it matches byte-for-byte (91/91
33/// snapshot corpus exact with either; the KV graph re-measured ~13–17% faster
34/// warm, so speed wins the default and the legacy file stays as the smaller
35/// fallback). `decoder_kv` ranks ABOVE `decoder_int8`: the #97 hoisted fp32
36/// KV graph is faster than the quantized legacy graph on every machine
37/// measured, and it is byte-exact (its own int8 variant is not produced — see
38/// quantize_models.py).
39pub fn resolved_paths() -> (String, String, String) {
40 let enc = std::env::var("DOCLING_TABLEFORMER_ENCODER")
41 .unwrap_or_else(|_| crate::resolve_asset("models/tableformer/encoder.onnx"));
42 let dec = std::env::var("DOCLING_TABLEFORMER_DECODER").unwrap_or_else(|_| {
43 let candidates: &[&str] = if crate::prefer_fp32() {
44 &[
45 "models/tableformer/decoder_kv.onnx",
46 "models/tableformer/decoder.onnx",
47 ]
48 } else {
49 &[
50 "models/tableformer/decoder_kv_int8.onnx",
51 "models/tableformer/decoder_kv.onnx",
52 "models/tableformer/decoder_int8.onnx",
53 "models/tableformer/decoder.onnx",
54 ]
55 };
56 candidates
57 .iter()
58 .map(|p| crate::resolve_asset(p))
59 .find(|p| std::path::Path::new(p).exists())
60 .unwrap_or_else(|| "models/tableformer/decoder.onnx".to_string())
61 });
62 let bbx = std::env::var("DOCLING_TABLEFORMER_BBOX")
63 .unwrap_or_else(|_| crate::resolve_asset("models/tableformer/bbox.onnx"));
64 (enc, dec, bbx)
65}
66
67pub struct TableFormer {
68 encoder: Session,
69 decoder: Session,
70 bbox: Session,
71 /// Which decoder graph flavour is loaded, detected from the session's
72 /// input names (so an explicit `DOCLING_TABLEFORMER_DECODER` override
73 /// works with any of them).
74 style: DecoderStyle,
75}
76
77/// The three decoder-graph generations the loop supports.
78#[derive(Clone, Copy, PartialEq, Eq)]
79enum DecoderStyle {
80 /// `decoder.onnx`: layer-output cache; feeds the full `tags` prefix and a
81 /// single `cache` every step.
82 Legacy,
83 /// The pre-#97 `decoder_kv.onnx`: one tag per step, `cache_k`/`cache_v`,
84 /// with the stacked `cross_k`/`cross_v` re-split inside every step.
85 KvStacked,
86 /// The #97 `decoder_kv.onnx`: one tag per step, and the constant cross
87 /// tensors arrive as 2×`N_LAYERS` per-layer inputs (`cross_kt_i` already
88 /// transposed for q·Kᵀ, `cross_v_i`), computed once per table by the
89 /// encoder — the step graph does no work proportional to their size.
90 KvHoisted,
91}
92
93/// KV-cache geometry fixed by the `decoder_kv.onnx` export
94/// (`[N_LAYERS, 1, KV_HEADS, past, KV_HEAD_DIM]`, `KV_HEADS × KV_HEAD_DIM = EMBED_DIM`).
95const KV_HEADS: usize = 8;
96const KV_HEAD_DIM: usize = 64;
97
98/// The autoregressive decode state: `a` is the legacy layer-output cache, or
99/// `cache_k` for the KV graph; `b` is `cache_v` (KV graph only). `None` = first
100/// step (the zero-`past` empties are allocated per table by [`TableFormer::empty_cache`]).
101#[derive(Default)]
102struct DecodeCache {
103 a: Option<DynValue>,
104 b: Option<DynValue>,
105}
106
107/// Zero-`past` first-step cache tensors: `(cache, None)` for the legacy graph,
108/// `(cache_k, Some(cache_v))` for the KV graph.
109type EmptyCache = (Tensor<f32>, Option<Tensor<f32>>);
110
111/// Encoder outputs that drive the cached decode loop: the per-layer cross-attention
112/// K/V (projected from the image memory once, constant across decode steps) and
113/// `enc_out` for the bbox decoder. Kept as owned `ort` values so each decode step
114/// (and the bbox run) borrows them directly — no per-step extract/copy/re-wrap.
115struct EncodeOut {
116 ck: DynValue,
117 cv: DynValue,
118 eo: DynValue,
119 /// `KvHoisted` only: per-layer `[cross_kt_0..N, cross_v_0..N]`, index-aligned
120 /// with the decoder's input names, borrowed by every decode step.
121 per_layer: Vec<(String, DynValue)>,
122}
123
124impl TableFormer {
125 /// Load the exported encoder/decoder/bbox ONNX graphs (env overrides, else
126 /// `models/tableformer/{encoder,decoder,bbox}.onnx`). Returns `None` if any is
127 /// absent, so the pipeline falls back to geometric reconstruction.
128 pub fn load() -> Option<Self> {
129 Self::load_with(crate::intra_threads())
130 }
131
132 /// Like [`load`](Self::load) but with an explicit intra-op thread count, so a
133 /// parallel page-worker pool can run each table model on fewer threads (the
134 /// throughput comes from running pages concurrently, not from one fat model).
135 ///
136 /// See [`resolved_paths`] for the encoder/decoder/bbox file selection.
137 pub fn load_with(intra: usize) -> Option<Self> {
138 // (resolution shared with the model inventory — see resolved_paths)
139 let (enc, dec, bbx) = resolved_paths();
140 if crate::timing::enabled() {
141 eprintln!("docling-pdf: tableformer decoder: {dec}");
142 }
143 if [&enc, &dec, &bbx]
144 .iter()
145 .any(|p| !std::path::Path::new(p).exists())
146 {
147 // The geometric fallback is a supported, intentional configuration
148 // (docling has no ML table-structure equivalent baked in either), so
149 // this stays a single quiet stderr note rather than an error — but it
150 // fires every process (not per-worker) so a CWD-relative default that
151 // silently misses its files (a very easy mistake for anything not run
152 // from the repo root, e.g. an embedding app) is at least visible once.
153 warn_missing_once(&enc, &dec, &bbx);
154 return None;
155 }
156 // The decoder's KV-cache grows by one entry every autoregressive step, so
157 // its input shapes differ on every `run()` call. ONNX Runtime's memory
158 // pattern optimizer assumes stable shapes to plan buffer reuse; disabling
159 // it for this session avoids repeatedly re-validating/re-touching that
160 // plan (and the external-weights file) on each step.
161 let build = |path: &str, mem_pattern: bool| -> Result<Session, String> {
162 let builder = Session::builder()
163 .map_err(|e| e.to_string())?
164 .with_intra_threads(intra)
165 .map_err(|e| e.to_string())?
166 .with_memory_pattern(mem_pattern)
167 .map_err(|e| e.to_string())?;
168 crate::ep::apply(builder)?
169 .commit_from_file(path)
170 .map_err(|e| format!("tableformer load {path}: {e}"))
171 };
172 match (build(&enc, true), build(&dec, false), build(&bbx, true)) {
173 (Ok(encoder), Ok(decoder), Ok(bbox)) => {
174 let has = |n: &str| decoder.inputs().iter().any(|i| i.name() == n);
175 let style = if has("cross_kt_0") {
176 DecoderStyle::KvHoisted
177 } else if has("cache_k") {
178 DecoderStyle::KvStacked
179 } else {
180 DecoderStyle::Legacy
181 };
182 if style == DecoderStyle::KvHoisted
183 && !encoder.outputs().iter().any(|o| o.name() == "cross_kt_0")
184 {
185 eprintln!(
186 "docling-pdf: tableformer decoder needs per-layer cross tensors \
187 (cross_kt_*) the encoder doesn't emit — re-download or re-export \
188 the model set (scripts/install/export_tableformer.py); \
189 falling back to geometric tables"
190 );
191 return None;
192 }
193 Some(Self {
194 encoder,
195 decoder,
196 bbox,
197 style,
198 })
199 }
200 _ => None,
201 }
202 }
203
204 /// Run the image encoder and capture what the cached decoder loop needs: each
205 /// decoder layer's cross-attention K/V (projected from the image memory once,
206 /// shape `[N_LAYERS,1,H,S,head_dim]`) and `enc_out` for the bbox decoder.
207 fn encode(&mut self, img: &RgbImage) -> Result<EncodeOut, String> {
208 let input = preprocess(img)?;
209 let mut enc_out = self
210 .encoder
211 .run(ort::inputs!["image" => input])
212 .map_err(|e| format!("tableformer: encode: {e}"))?;
213 let mut per_layer = Vec::new();
214 if self.style == DecoderStyle::KvHoisted {
215 for prefix in ["cross_kt_", "cross_v_"] {
216 for i in 0.. {
217 let name = format!("{prefix}{i}");
218 match enc_out.remove(&name) {
219 Some(v) => per_layer.push((name, v)),
220 None => break,
221 }
222 }
223 }
224 if per_layer.is_empty() {
225 return Err("tableformer: encoder emitted no cross_kt_* outputs".into());
226 }
227 }
228 let mut grab = |name: &str| -> Result<DynValue, String> {
229 enc_out
230 .remove(name)
231 .ok_or_else(|| format!("tableformer: encoder output {name} missing"))
232 };
233 Ok(EncodeOut {
234 ck: grab("cross_k")?,
235 cv: grab("cross_v")?,
236 eo: grab("enc_out")?,
237 per_layer,
238 })
239 }
240
241 /// One doubly-cached decode step: feed the current `tags`, the constant cross
242 /// K/V, and the growing self-attention `cache`; return the raw argmax tag and
243 /// the last token's hidden state, advancing the cache. The cache stays an owned
244 /// `ort` value — the previous step's `out_cache` output is fed back directly,
245 /// never extracted or copied (it grows every step, so per-step copies were
246 /// O(steps²) float traffic). `empty_cache` is the zero-`past` value used on the
247 /// first step (ort's array constructors reject a 0-length dim, so it is
248 /// allocated through the session allocator by the caller).
249 fn decode_step(
250 &mut self,
251 tags: &[i64],
252 enc: &EncodeOut,
253 cache: &mut DecodeCache,
254 empty: &EmptyCache,
255 ) -> Result<(i64, Vec<f32>), String> {
256 crate::timing::timed("tf.decode_step", || {
257 self.decode_step_inner(tags, enc, cache, empty)
258 })
259 }
260
261 fn decode_step_inner(
262 &mut self,
263 tags: &[i64],
264 enc: &EncodeOut,
265 cache: &mut DecodeCache,
266 empty: &EmptyCache,
267 ) -> Result<(i64, Vec<f32>), String> {
268 let mut dout = match self.style {
269 DecoderStyle::KvHoisted => {
270 // #97 graph: one tag; the constant per-layer cross tensors are
271 // borrowed views — the step pays nothing proportional to them.
272 let last = *tags.last().expect("decode starts from <start>");
273 let tag_t = Tensor::from_array(([1usize, 1usize], vec![last]))
274 .map_err(|e| format!("tableformer: tag: {e}"))?;
275 let mut inputs: Vec<(
276 std::borrow::Cow<'_, str>,
277 ort::session::SessionInputValue<'_>,
278 )> = Vec::with_capacity(3 + enc.per_layer.len());
279 inputs.push(("tag".into(), tag_t.into()));
280 match (cache.a.as_ref(), cache.b.as_ref()) {
281 (Some(k), Some(v)) => {
282 inputs.push(("cache_k".into(), k.into()));
283 inputs.push(("cache_v".into(), v.into()));
284 }
285 _ => {
286 inputs.push(("cache_k".into(), (&empty.0).into()));
287 inputs.push((
288 "cache_v".into(),
289 empty
290 .1
291 .as_ref()
292 .expect("kv empty cache has both halves")
293 .into(),
294 ));
295 }
296 }
297 for (name, v) in &enc.per_layer {
298 inputs.push((name.as_str().into(), v.into()));
299 }
300 self.decoder.run(inputs)
301 }
302 DecoderStyle::KvStacked => {
303 // Pre-#97 KV graph: feed only the newly emitted tag; the projected
304 // K/V for the whole prefix live in cache_k/cache_v and are fed
305 // back as-is.
306 let last = *tags.last().expect("decode starts from <start>");
307 let tag_t = Tensor::from_array(([1usize, 1usize], vec![last]))
308 .map_err(|e| format!("tableformer: tag: {e}"))?;
309 match (cache.a.as_ref(), cache.b.as_ref()) {
310 (Some(k), Some(v)) => self.decoder.run(ort::inputs![
311 "tag" => tag_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
312 "cache_k" => k, "cache_v" => v]),
313 _ => self.decoder.run(ort::inputs![
314 "tag" => tag_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
315 "cache_k" => &empty.0,
316 "cache_v" => empty.1.as_ref().expect("kv empty cache has both halves")]),
317 }
318 }
319 DecoderStyle::Legacy => {
320 let tags_t = Tensor::from_array(([tags.len(), 1usize], tags.to_vec()))
321 .map_err(|e| format!("tableformer: tags: {e}"))?;
322 match cache.a.as_ref() {
323 None => self.decoder.run(ort::inputs![
324 "tags" => tags_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
325 "cache" => &empty.0]),
326 Some(c) => self.decoder.run(ort::inputs![
327 "tags" => tags_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
328 "cache" => c]),
329 }
330 }
331 }
332 .map_err(|e| format!("tableformer: decode: {e}"))?;
333 let (_, logits) = dout["logits"]
334 .try_extract_tensor::<f32>()
335 .map_err(|e| format!("tableformer: logits: {e}"))?;
336 let raw = argmax(logits) as i64;
337 let (_, hidden) = dout["hidden"]
338 .try_extract_tensor::<f32>()
339 .map_err(|e| format!("tableformer: hidden: {e}"))?;
340 let hidden = hidden.to_vec();
341 if self.style != DecoderStyle::Legacy {
342 cache.a = Some(
343 dout.remove("out_cache_k")
344 .ok_or_else(|| "tableformer: out_cache_k missing".to_string())?,
345 );
346 cache.b = Some(
347 dout.remove("out_cache_v")
348 .ok_or_else(|| "tableformer: out_cache_v missing".to_string())?,
349 );
350 } else {
351 cache.a = Some(
352 dout.remove("out_cache")
353 .ok_or_else(|| "tableformer: decoder output out_cache missing".to_string())?,
354 );
355 }
356 Ok((raw, hidden))
357 }
358
359 /// The zero-`past` first-step cache(s), allocated through the session
360 /// allocator (ort's array constructors reject a 0-length dim; the C API does
361 /// allow it).
362 fn empty_cache(&self) -> Result<EmptyCache, String> {
363 let alloc = self.decoder.allocator();
364 if self.style != DecoderStyle::Legacy {
365 let mk = || {
366 Tensor::<f32>::new(alloc, [N_LAYERS, 1, KV_HEADS, 0usize, KV_HEAD_DIM])
367 .map_err(|e| format!("tableformer: empty kv cache: {e}"))
368 };
369 Ok((mk()?, Some(mk()?)))
370 } else {
371 let c = Tensor::<f32>::new(alloc, [N_LAYERS, 0usize, 1, EMBED_DIM])
372 .map_err(|e| format!("tableformer: empty cache: {e}"))?;
373 Ok((c, None))
374 }
375 }
376
377 /// Predict the OTSL structure-token sequence for a table-region image.
378 pub fn predict_otsl(&mut self, img: &RgbImage) -> Result<Vec<i64>, String> {
379 let enc = self.encode(img)?;
380 // Structure corrections live in tf_core::correct (shared with the wasm
381 // path); docling's line_num is never incremented, so xcel→lcel fires on
382 // every row.
383 let mut tags: Vec<i64> = vec![START];
384 let mut out: Vec<i64> = Vec::new();
385 let mut prev_ucel = false;
386 let mut cache = DecodeCache::default();
387 let empty = self.empty_cache()?;
388 while out.len() < MAX_STEPS {
389 let (raw, _hidden) = self.decode_step(&tags, &enc, &mut cache, &empty)?;
390 let tag = correct(raw, prev_ucel);
391 if tag == END {
392 break;
393 }
394 out.push(tag);
395 tags.push(tag);
396 prev_ucel = tag == UCEL;
397 }
398 Ok(out)
399 }
400
401 /// Full structure prediction: OTSL grid cells with per-cell boxes (in the 448
402 /// image, normalized cxcywh). Collects per-cell decoder hidden states using
403 /// docling's exact bbox bookkeeping (skip-after-row-break, first-lcel of a
404 /// horizontal span), runs the bbox decoder, merges span boxes, then lays the
405 /// cells onto the OTSL grid with row/col spans.
406 pub fn predict_table_structure(&mut self, img: &RgbImage) -> Result<Vec<TableCell>, String> {
407 let enc = self.encode(img)?;
408
409 // The autoregressive loop's bbox bookkeeping lives in tf_core::BboxBook
410 // (shared with the wasm path); this loop only steps the decoder.
411 let mut book = BboxBook::new();
412 let mut cache = DecodeCache::default();
413 let empty = self.empty_cache()?;
414 while book.otsl.len() < MAX_STEPS {
415 let (raw, hidden) = self.decode_step(&book.tags, &enc, &mut cache, &empty)?;
416 if !book.step(raw, &hidden) {
417 break;
418 }
419 }
420 if book.n == 0 {
421 return Ok(Vec::new());
422 }
423 let tag_h = Tensor::from_array(([book.n, EMBED_DIM], std::mem::take(&mut book.hiddens)))
424 .map_err(|e| format!("tableformer: tag_h: {e}"))?;
425 let bout = self
426 .bbox
427 .run(ort::inputs!["enc_out" => &enc.eo, "tag_h" => tag_h])
428 .map_err(|e| format!("tableformer: bbox: {e}"))?;
429 let (_, raw) = bout["boxes"]
430 .try_extract_tensor::<f32>()
431 .map_err(|e| format!("tableformer: boxes: {e}"))?;
432 let boxes: Vec<[f32; 4]> = raw
433 .chunks_exact(4)
434 .map(|c| [c[0], c[1], c[2], c[3]])
435 .collect();
436 // Per-cell class logits [n, 3] → argmax (docling's `outputs_class`).
437 let (_, craw) = bout["classes"]
438 .try_extract_tensor::<f32>()
439 .map_err(|e| format!("tableformer: classes: {e}"))?;
440 let classes: Vec<i64> = craw.chunks_exact(3).map(|c| argmax(c) as i64).collect();
441 let (merged, merged_classes) = merge_spans(&boxes, &classes, &book.merge);
442 Ok(build_table_cells(&book.otsl, &merged, &merged_classes))
443 }
444
445 /// Predict a table region's Markdown grid: crop the region (docling's
446 /// page→1024px box-average then bbox crop), run the structure model, then
447 /// match the page's word cells into the predicted cells with docling's
448 /// matching post-processor ([`crate::tf_match`]) and expand spans into a
449 /// dense `rows × cols` grid. `region` is `(l, t, r, b)` in page points
450 /// (top-left). Returns `None` if no structure is predicted.
451 pub fn predict_table_rows(
452 &mut self,
453 page_image: &RgbImage,
454 region: [f32; 4],
455 words: &[TextCell],
456 ) -> Option<Vec<Vec<String>>> {
457 // page → 1024px height (cv2.INTER_AREA), then crop the table bbox.
458 // docling's coordinate chain, rounding included: the cluster bbox is
459 // rounded to integer page points *first* (`round(cluster.bbox.l) *
460 // scale`, banker's rounding), scaled by 2 (its table-structure page
461 // scale), then by `1024 / <2x page-image height>`, and the crop indices
462 // round again. Rounding after scaling instead shifts some crops by a
463 // pixel — enough to change TableFormer's cell boxes on tall tables
464 // (redp5110's TOC).
465 let sf = 1024.0 / page_image.height() as f32;
466 let pw = (page_image.width() as f32 * sf) as u32;
467 let page1024 = crate::timing::timed("tableformer.inter_area", || {
468 crate::resample::inter_area(page_image, pw, 1024)
469 });
470 let k = 2.0 * 1024.0 / page_image.height() as f64;
471 let px = |v: f32| (v as f64).round_ties_even() * k;
472 let x = (px(region[0]).round_ties_even()).max(0.0) as u32;
473 let y = (px(region[1]).round_ties_even()).max(0.0) as u32;
474 let x2 = (px(region[2]).round_ties_even() as u32).min(page1024.width());
475 let y2 = (px(region[3]).round_ties_even() as u32).min(page1024.height());
476 if x2 <= x || y2 <= y {
477 return None;
478 }
479 let crop = image::imageops::crop_imm(&page1024, x, y, x2 - x, y2 - y).to_image();
480 let cells = crate::timing::timed("tableformer.structure", || {
481 self.predict_table_structure(&crop)
482 })
483 .ok()?;
484 if cells.is_empty() {
485 return None;
486 }
487 // The ort-free tail (word matching + grid assembly) is shared with the
488 // browser path in tf_core.
489 crate::tf_core::table_rows(&cells, region, words)
490 }
491}
492
493/// Note once per process that TableFormer's ONNX graphs weren't found, so tables
494/// fall back to geometric reconstruction. The default paths are relative
495/// (`models/tableformer/*.onnx`), which only resolves when the process's current
496/// directory happens to be the repo root — a very easy miss for anything else
497/// (an embedding app, a binding invoked from a different working directory, …),
498/// and previously failed with no signal at all.
499fn warn_missing_once(enc: &str, dec: &str, bbx: &str) {
500 static WARNED: std::sync::Once = std::sync::Once::new();
501 WARNED.call_once(|| {
502 eprintln!(
503 "docling.rs: TableFormer models not found (checked {enc}, {dec}, {bbx}); \
504 tables will use geometric reconstruction instead of ML table-structure \
505 recognition. Set DOCLING_TABLEFORMER_ENCODER / DOCLING_TABLEFORMER_DECODER \
506 / DOCLING_TABLEFORMER_BBOX to enable it (see README.md)."
507 );
508 });
509}
510
511/// docling's preprocessing: bilinear (cv2.INTER_LINEAR) resize the crop to 448²,
512/// normalize `(x/255 − mean)/std`, laid out as (C, W, H) — docling transposes
513/// (2,1,0), so width is the major spatial axis. The page→1024px box-average
514/// (cv2.INTER_AREA) is the caller's job.
515fn preprocess(img: &RgbImage) -> Result<Tensor<f32>, String> {
516 Tensor::from_array(([1usize, 3, SIDE, SIDE], preprocess_input(img)))
517 .map_err(|e| format!("tableformer: input: {e}"))
518}