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