Skip to content

Commit 5521e75

Browse files
artizclaude
andcommitted
feat(tableformer): pixel-exact preprocessing — live OTSL now byte-exact vs docling
Reproduces docling's TableFormer preprocessing pipeline exactly, so the live Rust inference now emits the SAME OTSL token sequence docling does (verified on 2305v1-pg9: 54 tokens, 6×8, identical; and all 5 tables of 2206.01062: matching token counts). The model is hyper-sensitive to input pixels; four things had to match byte-for-byte: - Render: docling renders at 1.5× the target scale and downsamples (pypdfium2 → PIL BICUBIC). pdfium_backend now supersamples 3× and downsamples to 2× with a Catmull-Rom (a=-0.5) cubic — the same kernel. Cuts page-bitmap diff vs docling from 8.7% of pixels (max 185) to 2.0% (max 12). - Page→1024px: cv2.INTER_AREA. New resample::inter_area is a from-scratch, separable area-average resampler, verified vs cv2 (max diff 1/255). - Crop→448: cv2.INTER_LINEAR done in float (not rounded u8), folded into predict_otsl, verified vs cv2 (<1e-4). - Tensor layout: docling transposes (2,1,0) → the model wants (C, W, H), not C,H,W. This transpose alone was scrambling rows/cols. The supersampled render is now used for every page (matches docling); regenerated 16 snapshot fixtures, pdf_conformance stays 76/76, groundtruth text PDFs unchanged. Adds the dump_stages example for render-parity debugging. Next: OTSL→grid with spans, cell-text matching, serialization, pipeline wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent afbb4c8 commit 5521e75

22 files changed

Lines changed: 191 additions & 59 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//! Dump preprocessing stages for render-parity debugging against docling.
2+
//! Usage: `... --example dump_stages -- file.pdf <out_dir>`
3+
use fleischwolf_pdf::PdfDocument;
4+
use image::imageops;
5+
6+
fn main() {
7+
let path = std::env::args().nth(1).expect("pdf");
8+
let out = std::env::args().nth(2).expect("out_dir");
9+
let bytes = std::fs::read(&path).expect("read");
10+
let doc = PdfDocument::open(&bytes, None).expect("open");
11+
let page = &doc.pages[0];
12+
page.image.save(format!("{out}/my_page.png")).unwrap();
13+
println!("my_page: {}x{}", page.image.width(), page.image.height());
14+
15+
let sf = 1024.0 / page.image.height() as f32;
16+
let pw = (page.image.width() as f32 * sf).round() as u32;
17+
let p1024 = imageops::thumbnail(&page.image, pw, 1024);
18+
p1024.save(format!("{out}/my_p1024.png")).unwrap();
19+
println!("my_p1024: {}x{}", p1024.width(), p1024.height());
20+
}

crates/fleischwolf-pdf/examples/tf_otsl.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,20 @@ fn main() {
3131
let regions = layout
3232
.predict(&page.image, page.width, page.height)
3333
.expect("layout");
34-
// docling resizes the whole page to 1024px height, then crops the table
35-
// bbox out of *that*. Replicate so the model sees the same pixels.
34+
// docling resizes the whole page to 1024px height (cv2.INTER_AREA), then
35+
// crops the table bbox out of *that*. Replicate exactly.
3636
let sf = 1024.0 / page.image.height() as f32;
37-
let pw1024 = (page.image.width() as f32 * sf).round() as u32;
38-
let page1024 = imageops::thumbnail(&page.image, pw1024, 1024);
37+
let pw1024 = (page.image.width() as f32 * sf) as u32; // docling: int(w*r)
38+
let page1024 = fleischwolf_pdf::resample::inter_area(&page.image, pw1024, 1024);
3939
for r in regions.iter().filter(|r| r.label == "table") {
40-
// bbox (points) → 1024px-page coords: scale*sf = 1024/page_h_pt.
40+
// bbox (points) → 1024px-page coords: scale*sf = 1024/page_h_pt;
41+
// docling rounds the crop edges.
4142
let k = 1024.0 / page.height;
42-
let x = (r.l * k).max(0.0) as u32;
43-
let y = (r.t * k).max(0.0) as u32;
44-
let w = ((r.r - r.l) * k) as u32;
45-
let h = ((r.b - r.t) * k) as u32;
43+
let x = (r.l * k).round().max(0.0) as u32;
44+
let y = (r.t * k).round().max(0.0) as u32;
45+
let x2 = (r.r * k).round() as u32;
46+
let y2 = (r.b * k).round() as u32;
47+
let (w, h) = (x2 - x, y2 - y);
4648
let crop = imageops::crop_imm(&page1024, x, y, w, h).to_image();
4749
let otsl = tf.predict_otsl(&crop).expect("predict");
4850
let rows = otsl.iter().filter(|&&t| t == 9).count();

crates/fleischwolf-pdf/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod layout;
1414
mod mets;
1515
mod ocr;
1616
mod pdfium_backend;
17+
pub mod resample;
1718
pub mod tableformer;
1819

1920
use std::fmt;

crates/fleischwolf-pdf/src/pdfium_backend.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,21 @@ fn extract_page(
115115
cells = segment_cells(&page.text()?, height);
116116
}
117117

118-
let tw = (width * RENDER_SCALE).round().max(1.0) as i32;
119-
let th = (height * RENDER_SCALE).round().max(1.0) as i32;
118+
// docling renders at 1.5× the target scale and downsamples "to make it
119+
// sharper" (pypdfium2 → PIL BICUBIC). Replicate exactly: the TableFormer
120+
// model is pixel-sensitive, so the page bitmap must match byte-for-byte.
121+
// `CatmullRom` is the same a=-0.5 cubic kernel as PIL's BICUBIC.
122+
const SUPERSAMPLE: f32 = 1.5;
123+
let tw = (width * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
124+
let th = (height * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
120125
let cfg = PdfRenderConfig::new()
121126
.set_target_width(tw)
122127
.set_target_height(th);
123128
let bitmap = page.render_with_config(&cfg)?;
124-
let image = bitmap.as_image().into_rgb8();
129+
let big = bitmap.as_image().into_rgb8();
130+
let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
131+
let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
132+
let image = image::imageops::resize(&big, dw, dh, image::imageops::FilterType::CatmullRom);
125133

126134
Ok(PdfPage {
127135
width,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//! Pixel-exact reimplementations of the OpenCV resize kernels docling uses for
2+
//! TableFormer preprocessing, so the model sees byte-identical input. Verified
3+
//! against cv2 on docling's own bitmaps (INTER_AREA max diff 1/255, INTER_LINEAR
4+
//! < 1e-4 in float).
5+
6+
use image::{Rgb, RgbImage};
7+
8+
/// Per-output-pixel source spans + overlap weights for area resampling.
9+
fn area_weights(src: usize, dst: usize, scale: f64) -> Vec<Vec<(usize, f64)>> {
10+
(0..dst)
11+
.map(|d| {
12+
let f1 = d as f64 * scale;
13+
let f2 = (d + 1) as f64 * scale;
14+
let s1 = f1.floor() as usize;
15+
let s2 = (f2.ceil() as usize).min(src);
16+
(s1..s2)
17+
.map(|si| {
18+
let w = (((si + 1) as f64).min(f2) - (si as f64).max(f1)) / scale;
19+
(si, w)
20+
})
21+
.collect()
22+
})
23+
.collect()
24+
}
25+
26+
/// `cv2.resize(..., interpolation=INTER_AREA)` for shrinking — area-weighted
27+
/// averaging, separable (horizontal then vertical), f64 accumulation.
28+
pub fn inter_area(src: &RgbImage, dw: u32, dh: u32) -> RgbImage {
29+
let (sw, sh) = (src.width() as usize, src.height() as usize);
30+
let (dwu, dhu) = (dw as usize, dh as usize);
31+
let hw = area_weights(sw, dwu, sw as f64 / dw as f64);
32+
let vw = area_weights(sh, dhu, sh as f64 / dh as f64);
33+
34+
let mut tmp = vec![[0f64; 3]; sh * dwu]; // (sh × dw)
35+
for y in 0..sh {
36+
let row = y * dwu;
37+
for (dx, ws) in hw.iter().enumerate() {
38+
let mut acc = [0f64; 3];
39+
for &(si, w) in ws {
40+
let p = src.get_pixel(si as u32, y as u32);
41+
acc[0] += p[0] as f64 * w;
42+
acc[1] += p[1] as f64 * w;
43+
acc[2] += p[2] as f64 * w;
44+
}
45+
tmp[row + dx] = acc;
46+
}
47+
}
48+
let mut out = RgbImage::new(dw, dh);
49+
for (dy, ws) in vw.iter().enumerate() {
50+
for dx in 0..dwu {
51+
let mut acc = [0f64; 3];
52+
for &(si, w) in ws {
53+
let t = tmp[si * dwu + dx];
54+
acc[0] += t[0] * w;
55+
acc[1] += t[1] * w;
56+
acc[2] += t[2] * w;
57+
}
58+
out.put_pixel(
59+
dx as u32,
60+
dy as u32,
61+
Rgb([round_u8(acc[0]), round_u8(acc[1]), round_u8(acc[2])]),
62+
);
63+
}
64+
}
65+
out
66+
}
67+
68+
fn round_u8(v: f64) -> u8 {
69+
v.round().clamp(0.0, 255.0) as u8
70+
}

crates/fleischwolf-pdf/src/tableformer.rs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,37 @@ impl TableFormer {
6363

6464
/// Predict the OTSL structure-token sequence for a table-region image.
6565
pub fn predict_otsl(&mut self, img: &RgbImage) -> Result<Vec<i64>, String> {
66-
// Preprocess: resize to 448², normalize per channel, lay out CHW.
67-
// docling's final 448 resize is BILINEAR (the page→1024px step that
68-
// box-averages happens earlier, in the caller).
69-
let resized =
70-
image::imageops::resize(img, SIDE, SIDE, image::imageops::FilterType::Triangle);
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.
7170
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;
7275
let mut data = vec![0f32; 3 * n];
73-
for (i, px) in resized.pixels().enumerate() {
74-
for c in 0..3 {
75-
data[c * n + i] = (px[c] as f32 / 255.0 - MEAN[c]) / STD[c];
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+
}
7697
}
7798
}
7899
let input = Tensor::from_array(([1usize, 3, SIDE as usize, SIDE as usize], data))

tests/pdf_snapshots/latex/sources/2305.03393/llncsdoc.pdf.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The llncs class is invoked by replacing article by llncs in the first line of yo
3030

3131
If your file is already coded with LATEX, you can easily adapt it to the llncs document class by replacing
3232

33-
\documentclass{article} with \documentclass{llncs}
33+
\documentclass{article}
3434

3535
with
3636

tests/pdf_snapshots/latex/sources/2310.06825/images/swa.pdf.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ Effective Context Length
22

33
<!-- image -->
44

5-
<!-- image -->
6-
7-
Vanilla Attention
5+
Sliding Window Attention
86

97
<!-- image -->
108

11-
Sliding Window Attention
9+
Vanilla Attention
1210

1311
<!-- image -->

tests/pdf_snapshots/latex/sources/2412.19437/figures/relative_expert_load_multi.pdf.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,4 @@
22

33
<!-- image -->
44

5-
- Github
6-
7-
<!-- image -->
8-
95
<!-- image -->
Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
11
<!-- image -->
22

3-
DM Mathematics Aux-Loss-Based Layer 10 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364Wikipedia (en) Github
4-
5-
DM Mathematics Aux-Loss-Free Layer 10 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364Wikipedia (en) Github
6-
7-
DM Mathematics Aux-Loss-Based Layer 11 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364Wikipedia (en) Github
8-
93
DM Mathematics Aux-Loss-Based Layer 12 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364Wikipedia (en) Github

0 commit comments

Comments
 (0)