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::RgbImage;
7
8/// Per-output-pixel source spans + overlap weights for area resampling:
9/// `(first source index, weights)`, the taps being the contiguous run of
10/// source pixels the output pixel covers, in increasing index order.
11fn area_weights(src: usize, dst: usize, scale: f64) -> Vec<(usize, Vec<f64>)> {
12    (0..dst)
13        .map(|d| {
14            let f1 = d as f64 * scale;
15            let f2 = (d + 1) as f64 * scale;
16            let s1 = f1.floor() as usize;
17            let s2 = (f2.ceil() as usize).min(src);
18            let ws = (s1..s2)
19                .map(|si| (((si + 1) as f64).min(f2) - (si as f64).max(f1)) / scale)
20                .collect();
21            (s1, ws)
22        })
23        .collect()
24}
25
26/// `cv2.resize(..., interpolation=INTER_AREA)` for shrinking — area-weighted
27/// averaging, separable (horizontal then vertical), f64 accumulation.
28///
29/// The per-pixel addition order is the naive form's — horizontal taps in
30/// increasing source column, then vertical taps in increasing source row — so
31/// the f64 sums, and the rounded bytes, are bit-identical to it (asserted by
32/// `area_tests`). Within that contract the work is arranged for the cache:
33/// a horizontally-shrunk source row is computed on demand as the vertical
34/// pass reaches it and kept only while an output row still needs it (a
35/// source row feeds at most two output rows, so a ring of a few `f64` rows
36/// replaces the 30 MB `sh × dw` intermediate a full first pass wrote and
37/// re-read), the horizontal taps run over the contiguous byte span they
38/// cover (no per-tap indexing), and the vertical pass is a flat `f64` axpy
39/// the compiler vectorizes. ~3× faster than the two-pass form on a page
40/// render (43 → 18 ms, 1224×1584 → 791×1024, release, one thread).
41pub fn inter_area(src: &RgbImage, dw: u32, dh: u32) -> RgbImage {
42    let (sw, sh) = (src.width() as usize, src.height() as usize);
43    let (dwu, dhu) = (dw as usize, dh as usize);
44    let hw = area_weights(sw, dwu, sw as f64 / dw as f64);
45    let vw = area_weights(sh, dhu, sh as f64 / dh as f64);
46    let raw = src.as_raw();
47    let stride = dwu * 3;
48
49    // Horizontal shrink of one source row into `dst` (dw × 3 f64).
50    let shrink_row = |sy: usize, dst: &mut [f64]| {
51        let src_row = &raw[sy * sw * 3..(sy + 1) * sw * 3];
52        for ((s1, ws), acc) in hw.iter().zip(dst.chunks_exact_mut(3)) {
53            let taps = &src_row[s1 * 3..(s1 + ws.len()) * 3];
54            let mut a = [0f64; 3];
55            for (p, &w) in taps.chunks_exact(3).zip(ws) {
56                a[0] += f64::from(p[0]) * w;
57                a[1] += f64::from(p[1]) * w;
58                a[2] += f64::from(p[2]) * w;
59            }
60            acc.copy_from_slice(&a);
61        }
62    };
63
64    // Ring of shrunk source rows keyed by source row index. Output rows walk
65    // the source monotonically, so a row older than the current window's
66    // first tap is never needed again and its buffer is recycled.
67    let mut ring: Vec<(usize, Vec<f64>)> = Vec::new();
68    let mut spare: Vec<Vec<f64>> = Vec::new();
69    let mut out = vec![0u8; stride * dhu];
70    let mut acc = vec![0f64; stride];
71    for ((s1, ws), out_row) in vw.iter().zip(out.chunks_exact_mut(stride)) {
72        let mut i = 0;
73        while i < ring.len() {
74            if ring[i].0 < *s1 {
75                spare.push(ring.swap_remove(i).1);
76            } else {
77                i += 1;
78            }
79        }
80        acc.fill(0.0);
81        for (k, &w) in ws.iter().enumerate() {
82            let sy = s1 + k;
83            let row = match ring.iter().position(|(y, _)| *y == sy) {
84                Some(j) => &ring[j].1,
85                None => {
86                    let mut buf = spare.pop().unwrap_or_else(|| vec![0f64; stride]);
87                    shrink_row(sy, &mut buf);
88                    ring.push((sy, buf));
89                    &ring[ring.len() - 1].1
90                }
91            };
92            for (a, t) in acc.iter_mut().zip(row) {
93                *a += t * w;
94            }
95        }
96        for (o, &a) in out_row.iter_mut().zip(&acc) {
97            *o = round_u8(a);
98        }
99    }
100    RgbImage::from_raw(dw, dh, out).expect("inter_area buffer sized dw×dh×3")
101}
102
103fn round_u8(v: f64) -> u8 {
104    v.round().clamp(0.0, 255.0) as u8
105}
106
107// ---------------------------------------------------------------------------
108// Pixel-exact reimplementation of Pillow's `Image.resize` for 8-bit RGB —
109// the kernels docling's layout input passes through (`get_page_image`'s
110// default-BICUBIC downsample, then the RT-DETR processor's BILINEAR stretch
111// to 640×640). Ported from Pillow `src/libImaging/Resample.c`: per-axis
112// coefficient tables quantized to fixed point (`PRECISION_BITS`), a
113// horizontal pass then a vertical pass, each rounding through uint8 — that
114// intermediate rounding is why a float resampler can never match Pillow
115// byte-for-byte.
116
117/// Pillow's `PRECISION_BITS` (32 − 8 − 2).
118const PIL_PRECISION_BITS: i32 = 22;
119
120/// Pillow filter kernels.
121#[derive(Clone, Copy)]
122pub enum PilFilter {
123    /// `Image.Resampling.BILINEAR` — triangle, support 1.
124    Bilinear,
125    /// `Image.Resampling.BICUBIC` — Catmull-Rom-style cubic, a = −0.5,
126    /// support 2 (Pillow's — and PIL `resize`'s **default** — kernel).
127    Bicubic,
128}
129
130impl PilFilter {
131    fn support(self) -> f64 {
132        match self {
133            Self::Bilinear => 1.0,
134            Self::Bicubic => 2.0,
135        }
136    }
137
138    fn eval(self, x: f64) -> f64 {
139        match self {
140            Self::Bilinear => {
141                let x = x.abs();
142                if x < 1.0 {
143                    1.0 - x
144                } else {
145                    0.0
146                }
147            }
148            Self::Bicubic => {
149                const A: f64 = -0.5;
150                let x = x.abs();
151                if x < 1.0 {
152                    ((A + 2.0) * x - (A + 3.0)) * x * x + 1.0
153                } else if x < 2.0 {
154                    (((x - 5.0) * x + 8.0) * x - 4.0) * A
155                } else {
156                    0.0
157                }
158            }
159        }
160    }
161}
162
163/// Pillow `precompute_coeffs` + `normalize_coeffs_8bpc`: for each output
164/// index, the first source index and the fixed-point kernel weights.
165fn pil_coeffs(in_size: usize, out_size: usize, filter: PilFilter) -> Vec<(usize, Vec<i32>)> {
166    let scale = in_size as f64 / out_size as f64;
167    let filterscale = scale.max(1.0);
168    let support = filter.support() * filterscale;
169    let ss = 1.0 / filterscale;
170    (0..out_size)
171        .map(|xx| {
172            let center = (xx as f64 + 0.5) * scale;
173            let xmin = ((center - support + 0.5) as i64).max(0) as usize;
174            let xmax = (((center + support + 0.5) as i64).min(in_size as i64) as usize) - xmin;
175            let mut k: Vec<f64> = (0..xmax)
176                .map(|x| filter.eval(((x + xmin) as f64 - center + 0.5) * ss))
177                .collect();
178            let ww: f64 = k.iter().sum();
179            if ww != 0.0 {
180                for w in &mut k {
181                    *w /= ww;
182                }
183            }
184            // Pillow's 8-bit quantization: round half away from zero via
185            // `(int)(±0.5 + w · 2^PRECISION_BITS)` (C truncation toward zero).
186            let quant: Vec<i32> = k
187                .iter()
188                .map(|&w| {
189                    let s = w * f64::from(1i32 << PIL_PRECISION_BITS);
190                    if s < 0.0 {
191                        (s - 0.5) as i32
192                    } else {
193                        (s + 0.5) as i32
194                    }
195                })
196                .collect();
197            (xmin, quant)
198        })
199        .collect()
200}
201
202/// Pillow `clip8`: shift out the fixed point and clamp (negative sums —
203/// possible with the bicubic kernel's negative lobes — clip to 0).
204fn pil_clip8(v: i32) -> u8 {
205    (v >> PIL_PRECISION_BITS).clamp(0, 255) as u8
206}
207
208/// `PIL.Image.resize((dw, dh), resample=filter)` for RGB, byte-exact:
209/// horizontal pass then vertical pass, uint8 in between, i32 accumulators
210/// seeded with the rounding bias (Pillow `ImagingResampleHorizontal_8bpc`).
211pub fn pil_resize(src: &RgbImage, dw: u32, dh: u32, filter: PilFilter) -> RgbImage {
212    let (sw, sh) = (src.width() as usize, src.height() as usize);
213    let (dwu, dhu) = (dw as usize, dh as usize);
214    let bias = 1i32 << (PIL_PRECISION_BITS - 1);
215    // Both passes work on the raw byte rows rather than through
216    // `get_pixel`/`put_pixel`: the per-pixel accessors bounds-check and
217    // re-index for every tap, and the vertical pass walked *columns*, so a
218    // 4-tap bicubic over a 900×1200 page render cost ~30 ms per page on the
219    // pipeline's single render thread — slower than the SIMD 3×→2× downscale
220    // of a larger image. The arithmetic is unchanged and purely integer
221    // (i32 accumulators, no rounding until `pil_clip8`), so any evaluation
222    // order gives the same bytes; the Pillow reference hashes below hold.
223
224    // Horizontal pass (skipped when the width is unchanged, like Pillow).
225    let hpass: RgbImage = if dwu != sw {
226        let coeffs = pil_coeffs(sw, dwu, filter);
227        let src_raw = src.as_raw();
228        let (sstride, dstride) = (sw * 3, dwu * 3);
229        let mut out = vec![0u8; dstride * sh];
230        for (row, orow) in src_raw
231            .chunks_exact(sstride)
232            .zip(out.chunks_exact_mut(dstride))
233        {
234            for ((xmin, k), o) in coeffs.iter().zip(orow.chunks_exact_mut(3)) {
235                let mut acc = [bias; 3];
236                let taps = &row[xmin * 3..(xmin + k.len()) * 3];
237                for (px, &w) in taps.chunks_exact(3).zip(k) {
238                    acc[0] += i32::from(px[0]) * w;
239                    acc[1] += i32::from(px[1]) * w;
240                    acc[2] += i32::from(px[2]) * w;
241                }
242                o[0] = pil_clip8(acc[0]);
243                o[1] = pil_clip8(acc[1]);
244                o[2] = pil_clip8(acc[2]);
245            }
246        }
247        RgbImage::from_raw(dw, sh as u32, out).expect("hpass buffer sized dw×sh×3")
248    } else {
249        src.clone()
250    };
251
252    // Vertical pass: one i32 accumulator row, each source row added in as a
253    // whole (an axpy the compiler vectorizes), then clipped out.
254    if dhu == sh {
255        return hpass;
256    }
257    let coeffs = pil_coeffs(sh, dhu, filter);
258    let hraw = hpass.as_raw();
259    let stride = dwu * 3;
260    let mut out = vec![0u8; stride * dhu];
261    let mut acc = vec![0i32; stride];
262    for ((ymin, k), orow) in coeffs.iter().zip(out.chunks_exact_mut(stride)) {
263        acc.fill(bias);
264        for (y, &w) in k.iter().enumerate() {
265            let row = &hraw[(ymin + y) * stride..(ymin + y + 1) * stride];
266            for (a, &p) in acc.iter_mut().zip(row) {
267                *a += i32::from(p) * w;
268            }
269        }
270        for (o, &a) in orow.iter_mut().zip(&acc) {
271            *o = pil_clip8(a);
272        }
273    }
274    RgbImage::from_raw(dw, dh, out).expect("vpass buffer sized dw×dh×3")
275}
276
277#[cfg(test)]
278mod pil_tests {
279    use super::*;
280    use image::Rgb;
281
282    /// Deterministic test image — the same LCG generates the Python-side
283    /// reference (see the hash constants' provenance below).
284    fn lcg_image(w: u32, h: u32) -> RgbImage {
285        let mut state = 0x2545f491u64;
286        let mut next = || {
287            state = state
288                .wrapping_mul(6364136223846793005)
289                .wrapping_add(1442695040888963407);
290            (state >> 33) as u8
291        };
292        let mut img = RgbImage::new(w, h);
293        for y in 0..h {
294            for x in 0..w {
295                img.put_pixel(x, y, Rgb([next(), next(), next()]));
296            }
297        }
298        img
299    }
300
301    fn fnv1a(bytes: &[u8]) -> u64 {
302        let mut h = 0xcbf29ce484222325u64;
303        for &b in bytes {
304            h ^= u64::from(b);
305            h = h.wrapping_mul(0x100000001b3);
306        }
307        h
308    }
309
310    /// Byte-exactness against Pillow 12.3 (`Image.resize`), reference hashes
311    /// generated with the identical LCG image:
312    /// down+up, both kernels, odd sizes to exercise the coefficient edges.
313    #[test]
314    fn matches_pillow_reference_hashes() {
315        let img = lcg_image(61, 47);
316        for (dw, dh, filter, want) in [
317            (40u32, 30u32, PilFilter::Bilinear, PIL_HASH_BILINEAR_DOWN),
318            (97, 83, PilFilter::Bilinear, PIL_HASH_BILINEAR_UP),
319            (40, 30, PilFilter::Bicubic, PIL_HASH_BICUBIC_DOWN),
320            (97, 83, PilFilter::Bicubic, PIL_HASH_BICUBIC_UP),
321            (640, 640, PilFilter::Bilinear, PIL_HASH_BILINEAR_640),
322        ] {
323            let out = pil_resize(&img, dw, dh, filter);
324            assert_eq!(
325                fnv1a(out.as_raw()),
326                want,
327                "PIL mismatch at {dw}x{dh} {:?}",
328                match filter {
329                    PilFilter::Bilinear => "bilinear",
330                    PilFilter::Bicubic => "bicubic",
331                }
332            );
333        }
334    }
335
336    // Generated by scripts/conformance/gen_pil_resample_ref.py (Pillow 12.3.0).
337    const PIL_HASH_BILINEAR_DOWN: u64 = 0x2ac8262283746b4c;
338    const PIL_HASH_BILINEAR_UP: u64 = 0x031c9b4dae3ce142;
339    const PIL_HASH_BICUBIC_DOWN: u64 = 0xb450da21946e06c3;
340    const PIL_HASH_BICUBIC_UP: u64 = 0xc3134a9cff63718d;
341    const PIL_HASH_BILINEAR_640: u64 = 0x967d65f732845b9f;
342}
343
344#[cfg(test)]
345mod area_tests {
346    use super::*;
347
348    fn lcg_image(w: u32, h: u32, seed: u64) -> RgbImage {
349        let mut state = seed;
350        let mut next = || {
351            state = state
352                .wrapping_mul(6364136223846793005)
353                .wrapping_add(1442695040888963407);
354            (state >> 33) as u8
355        };
356        let mut raw = vec![0u8; (w * h * 3) as usize];
357        for b in &mut raw {
358            *b = next();
359        }
360        RgbImage::from_raw(w, h, raw).unwrap()
361    }
362
363    /// Reference: the naive per-output-pixel form, taps in increasing source
364    /// index, horizontal then vertical — the addition order the fast path
365    /// must reproduce for bit-identical bytes.
366    fn inter_area_naive(src: &RgbImage, dw: u32, dh: u32) -> RgbImage {
367        let (sw, sh) = (src.width() as usize, src.height() as usize);
368        let hw = area_weights(sw, dw as usize, sw as f64 / dw as f64);
369        let vw = area_weights(sh, dh as usize, sh as f64 / dh as f64);
370        let mut out = RgbImage::new(dw, dh);
371        for (dy, vws) in vw.iter().enumerate() {
372            for (dx, hws) in hw.iter().enumerate() {
373                let mut acc = [0f64; 3];
374                for (ky, &wy) in vws.1.iter().enumerate() {
375                    let sy = vws.0 + ky;
376                    let mut t = [0f64; 3];
377                    for (kx, &wx) in hws.1.iter().enumerate() {
378                        let sx = hws.0 + kx;
379                        let p = src.get_pixel(sx as u32, sy as u32).0;
380                        for c in 0..3 {
381                            t[c] += p[c] as f64 * wx;
382                        }
383                    }
384                    for c in 0..3 {
385                        acc[c] += t[c] * wy;
386                    }
387                }
388                out.put_pixel(
389                    dx as u32,
390                    dy as u32,
391                    image::Rgb([round_u8(acc[0]), round_u8(acc[1]), round_u8(acc[2])]),
392                );
393            }
394        }
395        out
396    }
397
398    #[test]
399    fn inter_area_matches_naive_order() {
400        for (i, (sw, sh, dw, dh)) in [
401            (1224u32, 1584u32, 791u32, 1024u32),
402            (1190, 1684, 723, 1024),
403            (1584, 1224, 1325, 1024),
404            (61, 47, 40, 30),
405            (100, 100, 100, 50),
406            (37, 91, 36, 90),
407        ]
408        .into_iter()
409        .enumerate()
410        {
411            let img = lcg_image(sw, sh, 0x9e3779b97f4a7c15 ^ i as u64);
412            assert_eq!(
413                inter_area(&img, dw, dh).as_raw(),
414                inter_area_naive(&img, dw, dh).as_raw(),
415                "{sw}x{sh} -> {dw}x{dh}"
416            );
417        }
418    }
419
420    #[test]
421    #[ignore = "timing only: cargo test --release -p docling-pdf --lib area_tests::bench -- --ignored --nocapture"]
422    fn bench_inter_area() {
423        let img = lcg_image(1224, 1584, 7);
424        let _ = inter_area(&img, 791, 1024);
425        let t = std::time::Instant::now();
426        let n = 20;
427        for _ in 0..n {
428            std::hint::black_box(inter_area(&img, 791, 1024));
429        }
430        eprintln!(
431            "inter_area 1224x1584 -> 791x1024: {:.1} ms",
432            t.elapsed().as_secs_f64() * 1e3 / n as f64
433        );
434    }
435}