use image::RgbImage;
const PRECISION_BITS: u32 = 32 - 8 - 2;
fn bicubic_filter(x: f64) -> f64 {
const A: f64 = -0.5;
let x = x.abs();
if x < 1.0 {
((A + 2.0) * x - (A + 3.0)) * x * x + 1.0
} else if x < 2.0 {
(((x - 5.0) * x + 8.0) * x - 4.0) * A
} else {
0.0
}
}
fn sinc_filter(x: f64) -> f64 {
if x == 0.0 {
return 1.0;
}
let x = x * std::f64::consts::PI;
x.sin() / x
}
fn lanczos_filter(x: f64) -> f64 {
if (-3.0..3.0).contains(&x) {
sinc_filter(x) * sinc_filter(x / 3.0)
} else {
0.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PilFilter {
Bicubic,
Lanczos,
}
impl PilFilter {
fn support(self) -> f64 {
match self {
PilFilter::Bicubic => 2.0,
PilFilter::Lanczos => 3.0,
}
}
fn eval(self, x: f64) -> f64 {
match self {
PilFilter::Bicubic => bicubic_filter(x),
PilFilter::Lanczos => lanczos_filter(x),
}
}
}
struct PassCoeffs {
ksize: usize,
bounds: Vec<(usize, usize)>,
kk: Vec<i32>,
}
fn precompute_coeffs(in_size: u32, out_size: u32, filter: PilFilter) -> PassCoeffs {
let scale = f64::from(in_size) / f64::from(out_size);
let filterscale = if scale < 1.0 { 1.0 } else { scale };
let support = filter.support() * filterscale;
let ksize = (support.ceil() as usize) * 2 + 1;
let ss = 1.0 / filterscale;
let mut bounds = Vec::with_capacity(out_size as usize);
let mut prekk = vec![0.0f64; out_size as usize * ksize];
for xx in 0..out_size as usize {
let center = (xx as f64 + 0.5) * scale;
let xmin = ((center - support + 0.5) as i64).max(0);
let xmax = ((center + support + 0.5) as i64).min(i64::from(in_size));
let taps = usize::try_from((xmax - xmin).max(0))
.expect("pil_resample: window tap count fits usize");
let xmin = usize::try_from(xmin).expect("pil_resample: window start fits usize");
let k = &mut prekk[xx * ksize..(xx + 1) * ksize];
let mut ww = 0.0f64;
for (x, slot) in k.iter_mut().enumerate().take(taps) {
let w = filter.eval(((x + xmin) as f64 - center + 0.5) * ss);
*slot = w;
ww += w;
}
if ww != 0.0 {
for slot in k.iter_mut().take(taps) {
*slot /= ww;
}
}
bounds.push((xmin, taps));
}
let fixed_one = f64::from(1i32 << PRECISION_BITS);
let kk = prekk
.iter()
.map(|&w| {
if w < 0.0 {
(-0.5 + w * fixed_one) as i32
} else {
(0.5 + w * fixed_one) as i32
}
})
.collect();
PassCoeffs { ksize, bounds, kk }
}
fn clip8(v: i32) -> u8 {
if v >= 1 << PRECISION_BITS << 8 {
255
} else if v <= 0 {
0
} else {
(v >> PRECISION_BITS) as u8
}
}
fn resample_horizontal(src: &RgbImage, out_w: u32, filter: PilFilter) -> RgbImage {
let coeffs = precompute_coeffs(src.width(), out_w, filter);
let mut out = RgbImage::new(out_w, src.height());
for yy in 0..src.height() {
for xx in 0..out_w {
let (xmin, taps) = coeffs.bounds[xx as usize];
let k = &coeffs.kk[xx as usize * coeffs.ksize..];
let mut ss = [1i32 << (PRECISION_BITS - 1); 3];
for (x, &w) in k.iter().enumerate().take(taps) {
let p = src.get_pixel((xmin + x) as u32, yy).0;
for c in 0..3 {
ss[c] += i32::from(p[c]) * w;
}
}
out.put_pixel(
xx,
yy,
image::Rgb([clip8(ss[0]), clip8(ss[1]), clip8(ss[2])]),
);
}
}
out
}
fn resample_vertical(src: &RgbImage, out_h: u32, filter: PilFilter) -> RgbImage {
let coeffs = precompute_coeffs(src.height(), out_h, filter);
let mut out = RgbImage::new(src.width(), out_h);
for yy in 0..out_h {
let (ymin, taps) = coeffs.bounds[yy as usize];
let k = &coeffs.kk[yy as usize * coeffs.ksize..];
for xx in 0..src.width() {
let mut ss = [1i32 << (PRECISION_BITS - 1); 3];
for (y, &w) in k.iter().enumerate().take(taps) {
let p = src.get_pixel(xx, (ymin + y) as u32).0;
for c in 0..3 {
ss[c] += i32::from(p[c]) * w;
}
}
out.put_pixel(
xx,
yy,
image::Rgb([clip8(ss[0]), clip8(ss[1]), clip8(ss[2])]),
);
}
}
out
}
#[must_use]
pub fn resize_bicubic(src: &RgbImage, out_w: u32, out_h: u32) -> RgbImage {
resize_with(src, out_w, out_h, PilFilter::Bicubic)
}
#[must_use]
pub fn resize_lanczos(src: &RgbImage, out_w: u32, out_h: u32) -> RgbImage {
resize_with(src, out_w, out_h, PilFilter::Lanczos)
}
fn resize_with(src: &RgbImage, out_w: u32, out_h: u32, filter: PilFilter) -> RgbImage {
assert!(
out_w > 0 && out_h > 0,
"pil_resample::resize ({filter:?}): zero output dimension {out_w}x{out_h}"
);
let need_horizontal = out_w != src.width();
let need_vertical = out_h != src.height();
match (need_horizontal, need_vertical) {
(false, false) => src.clone(),
(true, false) => resample_horizontal(src, out_w, filter),
(false, true) => resample_vertical(src, out_h, filter),
(true, true) => resample_vertical(&resample_horizontal(src, out_w, filter), out_h, filter),
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Golden {
name: &'static str,
src_size: (u32, u32),
dst_size: (u32, u32),
src: &'static [u8],
expected: &'static [u8],
}
const SRC_4X4_TO_2X2: [u8; 48] = [
31, 48, 152, 201, 113, 39, 41, 31, 63, 112, 250, 222, 62, 244, 162, 134, 48, 248, 90, 11, 69, 195, 75, 134, 192, 146, 79, 19, 253, 111, 48, 156, 234, 82, 21, 18, 204, 231, 236, 36, 233, 82, 240, 64, 236, 221, 216, 35,
];
const PIL_4X4_TO_2X2: [u8; 12] = [99, 108, 135, 108, 88, 124, 113, 203, 152, 134, 116, 124];
const SRC_5X3_TO_7X2: [u8; 45] = [
127, 17, 136, 243, 179, 60, 214, 68, 55, 219, 34, 195, 82, 195, 208, 110, 94, 198, 69, 124, 206, 8, 228, 152, 49, 228, 213, 13, 42, 223, 5, 214, 111, 33, 94, 240, 214, 255, 215, 121, 62, 187, 218, 47, 99,
];
const PIL_5X3_TO_7X2: [u8; 42] = [
122, 32, 164, 161, 110, 134, 175, 161, 97, 134, 123, 86, 157, 109, 172, 111, 122, 217, 43, 143, 219, 41, 179, 139, 34, 124, 197, 63, 137, 232, 134, 252, 197, 99, 169, 199, 110, 81, 173, 147, 34, 140,
];
const SRC_3X5_TO_2X8: [u8; 45] = [
220, 250, 235, 172, 14, 122, 254, 41, 31, 116, 56, 100, 234, 174, 252, 239, 239, 202, 0, 77, 179, 56, 191, 60, 167, 125, 49, 181, 113, 86, 23, 144, 213, 27, 84, 119, 237, 243, 10, 128, 188, 167, 251, 190, 187,
];
const PIL_3X5_TO_2X8: [u8; 48] = [
203, 171, 202, 222, 8, 46, 190, 137, 182, 236, 104, 133, 150, 94, 153, 239, 224, 224, 44, 110, 141, 169, 182, 94, 32, 120, 138, 85, 132, 64, 120, 123, 136, 18, 105, 152, 173, 186, 91, 125, 153, 180, 199, 231, 59, 217, 193, 188,
];
const SRC_4X4_TO_6X6: [u8; 48] = [
165, 238, 191, 208, 37, 197, 187, 220, 18, 177, 201, 214, 32, 237, 131, 229, 43, 161, 112, 35, 222, 33, 184, 169, 179, 237, 184, 109, 167, 154, 148, 166, 80, 143, 137, 226, 79, 55, 209, 238, 137, 73, 42, 52, 68, 8, 250, 246,
];
const PIL_4X4_TO_6X6: [u8; 108] = [
171, 251, 195, 190, 132, 208, 207, 49, 174, 195, 208, 16, 188, 228, 105, 186, 200, 231, 85, 251, 158, 160, 136, 173, 224, 31, 174, 163, 108, 126, 120, 168, 155, 100, 201, 194, 32, 251, 132, 130, 155, 143, 213, 44, 168, 132, 33, 213, 68, 111, 198, 35, 185, 171, 169, 254, 177, 141, 203, 171, 118, 149, 150, 145, 155, 100, 144, 145, 159, 135, 135, 226, 133, 140, 206, 156, 152, 161, 163, 153, 98, 104, 114, 60, 81, 154, 155, 77, 195, 250, 61, 37, 220, 166, 92, 143, 231, 127, 57, 64, 44, 55, 3, 152, 163, 0, 255, 255,
];
const SRC_8X5_TO_3X5: [u8; 120] = [
146, 142, 132, 113, 159, 47, 3, 202, 9, 245, 255, 139, 40, 88, 135, 100, 174, 228, 110, 254, 171, 82, 191, 217, 182, 148, 105, 138, 253, 65, 176, 239, 82, 123, 104, 150, 14, 195, 134, 179, 207, 178, 23, 231, 145, 57, 171, 149, 178, 89, 215, 18, 172, 227, 103, 172, 233, 185, 209, 3, 56, 233, 136, 11, 164, 20, 115, 252, 109, 50, 126, 80, 163, 0, 52, 164, 114, 228, 35, 250, 6, 55, 177, 15, 239, 219, 225, 93, 127, 23, 249, 180, 101, 90, 40, 250, 244, 126, 127, 233, 9, 11, 168, 49, 222, 232, 66, 77, 174, 216, 86, 11, 131, 41, 206, 83, 4, 82, 167, 113,
];
const PIL_8X5_TO_3X5: [u8; 45] = [
105, 172, 64, 111, 179, 128, 93, 204, 203, 164, 209, 83, 104, 177, 138, 68, 206, 156, 102, 146, 218, 97, 208, 88, 62, 190, 78, 123, 114, 104, 121, 203, 83, 166, 122, 141, 226, 53, 105, 166, 120, 95, 113, 132, 47,
];
const SRC_5X8_TO_5X3: [u8; 120] = [
112, 88, 248, 229, 125, 49, 8, 215, 212, 158, 94, 169, 8, 30, 36, 2, 217, 8, 218, 161, 133, 204, 137, 181, 33, 212, 155, 150, 15, 224, 223, 182, 180, 80, 182, 32, 176, 144, 235, 88, 62, 188, 133, 89, 79, 2, 233, 5, 80, 37, 112, 52, 92, 182, 108, 140, 251, 123, 52, 81, 125, 43, 152, 58, 234, 252, 129, 36, 10, 32, 179, 39, 176, 48, 194, 129, 45, 21, 22, 101, 54, 198, 225, 159, 79, 198, 4, 38, 243, 59, 5, 71, 21, 1, 228, 192, 167, 119, 19, 221, 143, 145, 101, 203, 84, 179, 116, 204, 125, 155, 75, 203, 109, 234, 210, 136, 224, 39, 53, 101,
];
const PIL_5X8_TO_5X3: [u8; 45] = [
90, 175, 127, 185, 147, 77, 126, 162, 210, 91, 131, 180, 100, 36, 121, 97, 132, 76, 58, 139, 139, 126, 103, 125, 70, 152, 125, 133, 93, 118, 97, 70, 85, 46, 177, 127, 187, 138, 116, 174, 159, 124, 68, 162, 90,
];
fn img(w: u32, h: u32, bytes: &[u8]) -> RgbImage {
RgbImage::from_raw(w, h, bytes.to_vec()).expect("golden byte count matches dims")
}
#[test]
fn matches_pillow_12_1_1_goldens() {
let goldens = [
Golden {
name: "4x4 -> 2x2 (symmetric downscale)",
src_size: (4, 4),
dst_size: (2, 2),
src: &SRC_4X4_TO_2X2,
expected: &PIL_4X4_TO_2X2,
},
Golden {
name: "5x3 -> 7x2 (up-x, down-y)",
src_size: (5, 3),
dst_size: (7, 2),
src: &SRC_5X3_TO_7X2,
expected: &PIL_5X3_TO_7X2,
},
Golden {
name: "3x5 -> 2x8 (down-x, up-y)",
src_size: (3, 5),
dst_size: (2, 8),
src: &SRC_3X5_TO_2X8,
expected: &PIL_3X5_TO_2X8,
},
Golden {
name: "4x4 -> 6x6 (upscale, overshoot clips)",
src_size: (4, 4),
dst_size: (6, 6),
src: &SRC_4X4_TO_6X6,
expected: &PIL_4X4_TO_6X6,
},
Golden {
name: "8x5 -> 3x5 (horizontal-only)",
src_size: (8, 5),
dst_size: (3, 5),
src: &SRC_8X5_TO_3X5,
expected: &PIL_8X5_TO_3X5,
},
Golden {
name: "5x8 -> 5x3 (vertical-only)",
src_size: (5, 8),
dst_size: (5, 3),
src: &SRC_5X8_TO_5X3,
expected: &PIL_5X8_TO_5X3,
},
];
for g in &goldens {
let out = resize_bicubic(
&img(g.src_size.0, g.src_size.1, g.src),
g.dst_size.0,
g.dst_size.1,
);
assert_eq!(
out.dimensions(),
g.dst_size,
"{}: wrong output size",
g.name
);
assert_eq!(
out.as_raw().as_slice(),
g.expected,
"{}: diverged from Pillow 12.1.1",
g.name
);
}
}
#[test]
fn identity_size_is_copy() {
let src = img(4, 4, &SRC_4X4_TO_2X2);
let out = resize_bicubic(&src, 4, 4);
assert_eq!(out.as_raw(), src.as_raw());
}
const LZ_SRC_9X7_TO_4X3: [u8; 189] = [
31, 48, 152, 201, 113, 39, 41, 31, 63, 112, 250, 222, 62, 244, 162, 134, 48, 248, 90, 11,
69, 195, 75, 134, 192, 146, 79, 19, 253, 111, 48, 156, 234, 82, 21, 18, 204, 231, 236, 36,
233, 82, 240, 64, 236, 221, 216, 35, 127, 17, 136, 243, 179, 60, 214, 68, 55, 219, 34, 195,
82, 195, 208, 110, 94, 198, 69, 124, 206, 8, 228, 152, 49, 228, 213, 13, 42, 223, 5, 214,
111, 33, 94, 240, 214, 255, 215, 121, 62, 187, 218, 47, 99, 18, 198, 196, 220, 250, 235,
172, 14, 122, 254, 41, 31, 116, 56, 100, 234, 174, 252, 239, 239, 202, 0, 77, 179, 56, 191,
60, 167, 125, 49, 181, 113, 86, 23, 144, 213, 27, 84, 119, 237, 243, 10, 128, 188, 167,
251, 190, 187, 185, 87, 181, 165, 238, 191, 208, 37, 197, 187, 220, 18, 177, 201, 214, 32,
237, 131, 229, 43, 161, 112, 35, 222, 33, 184, 169, 179, 237, 184, 109, 167, 154, 148, 166,
80, 143, 137, 226, 79, 55, 209, 238, 137, 73, 42, 52, 68,
];
const LZ_PIL_9X7_TO_4X3: [u8; 36] = [
92, 98, 112, 103, 165, 166, 120, 139, 172, 154, 102, 106, 171, 148, 203, 115, 120, 153,
120, 164, 160, 115, 106, 112, 151, 167, 201, 151, 165, 142, 153, 141, 141, 131, 131, 121,
];
const LZ_SRC_4X3_TO_7X9: [u8; 36] = [
146, 142, 132, 113, 159, 47, 3, 202, 9, 245, 255, 139, 40, 88, 135, 100, 174, 228, 110,
254, 171, 82, 191, 217, 182, 148, 105, 138, 253, 65, 176, 239, 82, 123, 104, 150,
];
const LZ_PIL_4X3_TO_7X9: [u8; 189] = [
172, 154, 142, 168, 153, 100, 130, 159, 21, 4, 166, 0, 3, 199, 0, 181, 244, 80, 255, 255,
141, 146, 142, 140, 148, 144, 112, 123, 157, 56, 19, 175, 0, 15, 207, 17, 166, 243, 99,
255, 255, 155, 103, 121, 136, 115, 128, 133, 112, 152, 114, 42, 189, 67, 34, 221, 64, 142,
242, 131, 205, 243, 179, 53, 96, 130, 75, 110, 157, 99, 151, 185, 76, 210, 152, 65, 239,
126, 110, 236, 171, 137, 221, 208, 33, 81, 122, 57, 106, 167, 95, 165, 225, 118, 235, 206,
108, 252, 170, 90, 215, 198, 78, 181, 224, 74, 92, 115, 84, 127, 148, 107, 195, 191, 146,
251, 181, 145, 250, 161, 98, 184, 188, 67, 137, 209, 138, 116, 112, 129, 156, 117, 125,
225, 123, 156, 255, 114, 165, 238, 122, 122, 158, 158, 91, 107, 180, 188, 135, 110, 165,
178, 93, 139, 245, 68, 158, 255, 58, 175, 228, 88, 142, 141, 132, 116, 89, 157, 219, 147,
109, 187, 192, 78, 148, 255, 35, 160, 255, 24, 182, 222, 68, 154, 130, 117, 130, 77, 143,
];
const LZ_SRC_8X5_TO_5X5: [u8; 120] = [
14, 195, 134, 179, 207, 178, 23, 231, 145, 57, 171, 149, 178, 89, 215, 18, 172, 227, 103,
172, 233, 185, 209, 3, 56, 233, 136, 11, 164, 20, 115, 252, 109, 50, 126, 80, 163, 0, 52,
164, 114, 228, 35, 250, 6, 55, 177, 15, 239, 219, 225, 93, 127, 23, 249, 180, 101, 90, 40,
250, 244, 126, 127, 233, 9, 11, 168, 49, 222, 232, 66, 77, 174, 216, 86, 11, 131, 41, 206,
83, 4, 82, 167, 113, 112, 88, 248, 229, 125, 49, 8, 215, 212, 158, 94, 169, 8, 30, 36, 2,
217, 8, 218, 161, 133, 204, 137, 181, 33, 212, 155, 150, 15, 224, 223, 182, 180, 80, 182,
32,
];
const LZ_PIL_8X5_TO_5X5: [u8; 75] = [
89, 199, 154, 69, 225, 151, 106, 128, 175, 67, 151, 249, 151, 199, 91, 36, 203, 86, 66,
221, 75, 120, 54, 82, 138, 127, 144, 36, 221, 4, 181, 187, 134, 164, 134, 100, 180, 85,
188, 225, 41, 78, 199, 56, 145, 103, 182, 70, 124, 108, 14, 121, 120, 176, 148, 145, 131,
89, 150, 186, 0, 108, 15, 177, 180, 108, 122, 162, 176, 146, 91, 210, 148, 185, 89,
];
#[test]
fn lanczos_matches_pillow_goldens() {
let goldens = [
Golden {
name: "lanczos 9x7 -> 4x3 (downscale)",
src_size: (9, 7),
dst_size: (4, 3),
src: &LZ_SRC_9X7_TO_4X3,
expected: &LZ_PIL_9X7_TO_4X3,
},
Golden {
name: "lanczos 4x3 -> 7x9 (upscale)",
src_size: (4, 3),
dst_size: (7, 9),
src: &LZ_SRC_4X3_TO_7X9,
expected: &LZ_PIL_4X3_TO_7X9,
},
Golden {
name: "lanczos 8x5 -> 5x5 (squash)",
src_size: (8, 5),
dst_size: (5, 5),
src: &LZ_SRC_8X5_TO_5X5,
expected: &LZ_PIL_8X5_TO_5X5,
},
];
for g in &goldens {
let out = resize_lanczos(
&img(g.src_size.0, g.src_size.1, g.src),
g.dst_size.0,
g.dst_size.1,
);
assert_eq!(
out.dimensions(),
g.dst_size,
"{}: wrong output size",
g.name
);
assert_eq!(
out.as_raw().as_slice(),
g.expected,
"{}: diverged from Pillow LANCZOS",
g.name
);
}
}
#[test]
fn solid_color_is_preserved_exactly() {
let src = RgbImage::from_pixel(9, 5, image::Rgb([3, 200, 77]));
for (w, h) in [(4, 7), (2, 2), (17, 3), (9, 8)] {
let out = resize_bicubic(&src, w, h);
assert!(
out.pixels().all(|p| p.0 == [3, 200, 77]),
"solid color drifted at {w}x{h}"
);
}
}
#[test]
fn one_pixel_upscale_replicates() {
let src = RgbImage::from_pixel(1, 1, image::Rgb([42, 0, 255]));
let out = resize_bicubic(&src, 3, 3);
assert_eq!(out.dimensions(), (3, 3));
assert!(out.pixels().all(|p| p.0 == [42, 0, 255]));
}
#[test]
fn zero_output_dimension_panics() {
let panic = std::panic::catch_unwind(|| {
let src = RgbImage::from_pixel(2, 2, image::Rgb([1, 2, 3]));
resize_bicubic(&src, 0, 2)
})
.expect_err("zero width must panic");
let msg = panic
.downcast_ref::<String>()
.cloned()
.or_else(|| panic.downcast_ref::<&str>().map(|s| (*s).to_owned()))
.unwrap_or_default();
assert!(
msg.contains("zero output dimension"),
"unexpected panic: {msg}"
);
}
}