Skip to main content

docling_pdf/
resample.rs

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
6use image::{Rgb, RgbImage};
7
8/// Per-output-pixel source spans + overlap weights for area resampling.
9fn 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.
28pub 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
68fn round_u8(v: f64) -> u8 {
69    v.round().clamp(0.0, 255.0) as u8
70}
71
72// ---------------------------------------------------------------------------
73// Pixel-exact reimplementation of Pillow's `Image.resize` for 8-bit RGB —
74// the kernels docling's layout input passes through (`get_page_image`'s
75// default-BICUBIC downsample, then the RT-DETR processor's BILINEAR stretch
76// to 640×640). Ported from Pillow `src/libImaging/Resample.c`: per-axis
77// coefficient tables quantized to fixed point (`PRECISION_BITS`), a
78// horizontal pass then a vertical pass, each rounding through uint8 — that
79// intermediate rounding is why a float resampler can never match Pillow
80// byte-for-byte.
81
82/// Pillow's `PRECISION_BITS` (32 − 8 − 2).
83const PIL_PRECISION_BITS: i32 = 22;
84
85/// Pillow filter kernels.
86#[derive(Clone, Copy)]
87pub enum PilFilter {
88    /// `Image.Resampling.BILINEAR` — triangle, support 1.
89    Bilinear,
90    /// `Image.Resampling.BICUBIC` — Catmull-Rom-style cubic, a = −0.5,
91    /// support 2 (Pillow's — and PIL `resize`'s **default** — kernel).
92    Bicubic,
93}
94
95impl PilFilter {
96    fn support(self) -> f64 {
97        match self {
98            Self::Bilinear => 1.0,
99            Self::Bicubic => 2.0,
100        }
101    }
102
103    fn eval(self, x: f64) -> f64 {
104        match self {
105            Self::Bilinear => {
106                let x = x.abs();
107                if x < 1.0 {
108                    1.0 - x
109                } else {
110                    0.0
111                }
112            }
113            Self::Bicubic => {
114                const A: f64 = -0.5;
115                let x = x.abs();
116                if x < 1.0 {
117                    ((A + 2.0) * x - (A + 3.0)) * x * x + 1.0
118                } else if x < 2.0 {
119                    (((x - 5.0) * x + 8.0) * x - 4.0) * A
120                } else {
121                    0.0
122                }
123            }
124        }
125    }
126}
127
128/// Pillow `precompute_coeffs` + `normalize_coeffs_8bpc`: for each output
129/// index, the first source index and the fixed-point kernel weights.
130fn pil_coeffs(in_size: usize, out_size: usize, filter: PilFilter) -> Vec<(usize, Vec<i32>)> {
131    let scale = in_size as f64 / out_size as f64;
132    let filterscale = scale.max(1.0);
133    let support = filter.support() * filterscale;
134    let ss = 1.0 / filterscale;
135    (0..out_size)
136        .map(|xx| {
137            let center = (xx as f64 + 0.5) * scale;
138            let xmin = ((center - support + 0.5) as i64).max(0) as usize;
139            let xmax = (((center + support + 0.5) as i64).min(in_size as i64) as usize) - xmin;
140            let mut k: Vec<f64> = (0..xmax)
141                .map(|x| filter.eval(((x + xmin) as f64 - center + 0.5) * ss))
142                .collect();
143            let ww: f64 = k.iter().sum();
144            if ww != 0.0 {
145                for w in &mut k {
146                    *w /= ww;
147                }
148            }
149            // Pillow's 8-bit quantization: round half away from zero via
150            // `(int)(±0.5 + w · 2^PRECISION_BITS)` (C truncation toward zero).
151            let quant: Vec<i32> = k
152                .iter()
153                .map(|&w| {
154                    let s = w * f64::from(1i32 << PIL_PRECISION_BITS);
155                    if s < 0.0 {
156                        (s - 0.5) as i32
157                    } else {
158                        (s + 0.5) as i32
159                    }
160                })
161                .collect();
162            (xmin, quant)
163        })
164        .collect()
165}
166
167/// Pillow `clip8`: shift out the fixed point and clamp (negative sums —
168/// possible with the bicubic kernel's negative lobes — clip to 0).
169fn pil_clip8(v: i32) -> u8 {
170    (v >> PIL_PRECISION_BITS).clamp(0, 255) as u8
171}
172
173/// `PIL.Image.resize((dw, dh), resample=filter)` for RGB, byte-exact:
174/// horizontal pass then vertical pass, uint8 in between, i32 accumulators
175/// seeded with the rounding bias (Pillow `ImagingResampleHorizontal_8bpc`).
176pub fn pil_resize(src: &RgbImage, dw: u32, dh: u32, filter: PilFilter) -> RgbImage {
177    let (sw, sh) = (src.width() as usize, src.height() as usize);
178    let (dwu, dhu) = (dw as usize, dh as usize);
179    let bias = 1i32 << (PIL_PRECISION_BITS - 1);
180
181    // Horizontal pass (skipped when the width is unchanged, like Pillow).
182    let hpass: RgbImage = if dwu != sw {
183        let coeffs = pil_coeffs(sw, dwu, filter);
184        let mut out = RgbImage::new(dw, sh as u32);
185        for y in 0..sh {
186            for (xx, (xmin, k)) in coeffs.iter().enumerate() {
187                let mut acc = [bias; 3];
188                for (x, &w) in k.iter().enumerate() {
189                    let p = src.get_pixel((xmin + x) as u32, y as u32);
190                    acc[0] += i32::from(p[0]) * w;
191                    acc[1] += i32::from(p[1]) * w;
192                    acc[2] += i32::from(p[2]) * w;
193                }
194                out.put_pixel(
195                    xx as u32,
196                    y as u32,
197                    Rgb([pil_clip8(acc[0]), pil_clip8(acc[1]), pil_clip8(acc[2])]),
198                );
199            }
200        }
201        out
202    } else {
203        src.clone()
204    };
205
206    // Vertical pass.
207    if dhu == sh {
208        return hpass;
209    }
210    let coeffs = pil_coeffs(sh, dhu, filter);
211    let mut out = RgbImage::new(dw, dh);
212    for (yy, (ymin, k)) in coeffs.iter().enumerate() {
213        for x in 0..dwu {
214            let mut acc = [bias; 3];
215            for (y, &w) in k.iter().enumerate() {
216                let p = hpass.get_pixel(x as u32, (ymin + y) as u32);
217                acc[0] += i32::from(p[0]) * w;
218                acc[1] += i32::from(p[1]) * w;
219                acc[2] += i32::from(p[2]) * w;
220            }
221            out.put_pixel(
222                x as u32,
223                yy as u32,
224                Rgb([pil_clip8(acc[0]), pil_clip8(acc[1]), pil_clip8(acc[2])]),
225            );
226        }
227    }
228    out
229}
230
231#[cfg(test)]
232mod pil_tests {
233    use super::*;
234
235    /// Deterministic test image — the same LCG generates the Python-side
236    /// reference (see the hash constants' provenance below).
237    fn lcg_image(w: u32, h: u32) -> RgbImage {
238        let mut state = 0x2545f491u64;
239        let mut next = || {
240            state = state
241                .wrapping_mul(6364136223846793005)
242                .wrapping_add(1442695040888963407);
243            (state >> 33) as u8
244        };
245        let mut img = RgbImage::new(w, h);
246        for y in 0..h {
247            for x in 0..w {
248                img.put_pixel(x, y, Rgb([next(), next(), next()]));
249            }
250        }
251        img
252    }
253
254    fn fnv1a(bytes: &[u8]) -> u64 {
255        let mut h = 0xcbf29ce484222325u64;
256        for &b in bytes {
257            h ^= u64::from(b);
258            h = h.wrapping_mul(0x100000001b3);
259        }
260        h
261    }
262
263    /// Byte-exactness against Pillow 12.3 (`Image.resize`), reference hashes
264    /// generated with the identical LCG image:
265    /// down+up, both kernels, odd sizes to exercise the coefficient edges.
266    #[test]
267    fn matches_pillow_reference_hashes() {
268        let img = lcg_image(61, 47);
269        for (dw, dh, filter, want) in [
270            (40u32, 30u32, PilFilter::Bilinear, PIL_HASH_BILINEAR_DOWN),
271            (97, 83, PilFilter::Bilinear, PIL_HASH_BILINEAR_UP),
272            (40, 30, PilFilter::Bicubic, PIL_HASH_BICUBIC_DOWN),
273            (97, 83, PilFilter::Bicubic, PIL_HASH_BICUBIC_UP),
274            (640, 640, PilFilter::Bilinear, PIL_HASH_BILINEAR_640),
275        ] {
276            let out = pil_resize(&img, dw, dh, filter);
277            assert_eq!(
278                fnv1a(out.as_raw()),
279                want,
280                "PIL mismatch at {dw}x{dh} {:?}",
281                match filter {
282                    PilFilter::Bilinear => "bilinear",
283                    PilFilter::Bicubic => "bicubic",
284                }
285            );
286        }
287    }
288
289    // Generated by scripts/conformance/gen_pil_resample_ref.py (Pillow 12.3.0).
290    const PIL_HASH_BILINEAR_DOWN: u64 = 0x2ac8262283746b4c;
291    const PIL_HASH_BILINEAR_UP: u64 = 0x031c9b4dae3ce142;
292    const PIL_HASH_BICUBIC_DOWN: u64 = 0xb450da21946e06c3;
293    const PIL_HASH_BICUBIC_UP: u64 = 0xc3134a9cff63718d;
294    const PIL_HASH_BILINEAR_640: u64 = 0x967d65f732845b9f;
295}