Skip to main content

vello_cpu/filter/
gaussian_blur.rs

1// Copyright 2025 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Gaussian blur filter implementation using multi-scale separable convolution.
5//!
6//! This implementation uses a multi-scale approach for efficient blurring:
7//! - **Small blurs** (σ ≤ 2): Direct separable convolution at full resolution
8//! - **Large blurs** (σ > 2): Iterative downsample → blur → upsample pyramid
9//!
10//! The algorithm automatically determines the optimal number of decimation levels
11//! using variance analysis. Each 2× decimation applies a \[1,3,3,1\]/8 binomial filter
12//! (adding variance = 3.0), then downsamples, reducing the remaining blur work needed.
13//! This exploits the variance additivity property: `σ²_total = σ²_downsample + σ²_blur`.
14//!
15//! **Variance Addition Reference**: The convolution of two Gaussians with variances σ₁²
16//! and σ₂² produces a Gaussian with variance σ₁² + σ₂². This fundamental property comes
17//! from probability theory and applies to Gaussian convolution in image processing. See:
18//! - Torralba & Freeman, "Foundations of Computer Vision" (MIT Press), Section 2.2:
19//!   <https://visionbook.mit.edu/blurring_2.html#properties-of-the-continuous-gaussian>
20
21use super::FilterEffect;
22use crate::filter::context::ScratchBuffer;
23use vello_common::filter::gaussian_blur::{DecimationSizer, GaussianBlur};
24use vello_common::filter_effects::EdgeMode;
25use vello_common::peniko::color::PremulRgba8;
26#[cfg(not(feature = "std"))]
27use vello_common::peniko::kurbo::common::FloatFuncs as _;
28use vello_common::pixmap::Pixmap;
29
30impl FilterEffect for GaussianBlur {
31    fn execute_lowp(&self, pixmap: &mut Pixmap, filter_scratch: &mut ScratchBuffer) {
32        // No blur if std_deviation is zero or negative
33        if self.std_deviation <= 0.0 {
34            return;
35        }
36
37        let scratch = filter_scratch.get_scratch_buffer(pixmap.width(), pixmap.height());
38        apply_blur(
39            pixmap,
40            scratch,
41            self.n_decimations,
42            &self.kernel[..usize::from(self.kernel_size)],
43            self.edge_mode,
44        );
45    }
46
47    fn execute_highp(&self, pixmap: &mut Pixmap, filter_scratch: &mut ScratchBuffer) {
48        // TODO: Currently only lowp is implemented and used for highp as well.
49        // This needs to be updated to use proper high-precision arithmetic.
50        Self::execute_lowp(self, pixmap, filter_scratch);
51    }
52}
53
54/// Apply Gaussian blur using multi-scale decimation and upsampling.
55///
56/// Uses a precomputed decimation plan and kernel for optimal performance.
57/// Operates in-place using a single pixmap buffer with logical dimension tracking
58/// to minimize memory allocations. For `n_decimations=0`, applies direct convolution.
59///
60/// The `scratch` buffer is used for separable convolution and must be at least as
61/// large as the source pixmap.
62pub(crate) fn apply_blur(
63    pixmap: &mut Pixmap,
64    scratch: &mut Pixmap,
65    n_decimations: usize,
66    kernel: &[f32],
67    edge_mode: EdgeMode,
68) {
69    let radius = (kernel.len() / 2) as u8;
70    let width = pixmap.width();
71    let height = pixmap.height();
72
73    // Small blur: apply direct convolution at full resolution
74    if n_decimations == 0 {
75        convolve(pixmap, scratch, width, height, kernel, radius, edge_mode);
76        return;
77    }
78
79    // Track logical dimensions through decimation (physical buffer stays the same size)
80    let mut sizer = DecimationSizer::new(width, height);
81
82    // Downsample n times (each step reduces resolution by 2×)
83    for _ in 0..n_decimations {
84        let (w, h) = sizer.current();
85        downscale(pixmap, w, h, edge_mode);
86        sizer.downscale();
87    }
88
89    // Apply the reduced blur at the coarsest resolution
90    let (w, h) = sizer.current();
91    convolve(pixmap, scratch, w, h, kernel, radius, edge_mode);
92
93    // Upsample back to original resolution (each step doubles resolution by 2×)
94    for _ in 0..n_decimations {
95        let (w, h) = sizer.current();
96        upscale(pixmap, w, h, edge_mode);
97        sizer.upscale();
98    }
99
100    debug_assert_eq!(
101        sizer.current(),
102        (width, height),
103        "Final dimensions should match original"
104    );
105}
106
107/// Apply separable Gaussian convolution with logical dimensions.
108///
109/// Performs horizontal blur followed by vertical blur. Works with a logical view
110/// of the pixmap, using only the top-left region defined by width × height.
111/// The `temp` buffer is provided by the caller to avoid allocations.
112pub(crate) fn convolve(
113    src: &mut Pixmap,
114    scratch: &mut Pixmap,
115    width: u16,
116    height: u16,
117    kernel: &[f32],
118    radius: u8,
119    edge_mode: EdgeMode,
120) {
121    convolve_x(src, scratch, width, height, kernel, radius, edge_mode);
122    convolve_y(scratch, src, width, height, kernel, radius, edge_mode);
123}
124
125/// Apply horizontal blur pass (1D convolution along x-axis).
126///
127/// For each output pixel, computes a weighted sum of horizontally neighboring pixels
128/// using the Gaussian kernel. Handles edge cases according to the specified edge mode.
129/// Writes results to a destination buffer to avoid overwriting source data.
130pub(crate) fn convolve_x(
131    src: &Pixmap,
132    dst: &mut Pixmap,
133    src_width: u16,
134    src_height: u16,
135    kernel: &[f32],
136    radius: u8,
137    edge_mode: EdgeMode,
138) {
139    for y in 0..src_height {
140        for x in 0..src_width {
141            let mut rgba = [0.0_f32; 4];
142
143            // Sum contributions from all kernel positions: output = Σ(weight[j] × pixel[x+j-radius])
144            for (j, &k) in kernel.iter().enumerate() {
145                #[expect(
146                    clippy::cast_possible_wrap,
147                    reason = "This cast never wraps because `kernel.len()` is never greater than `u8::MAX` due to the restriction on `MAX_KERNEL_SIZE`"
148                )]
149                let j = j as i32;
150                let src_x = x as i32 + j - radius as i32;
151                let p = sample_x(src, src_x, y, src_width, edge_mode);
152
153                rgba[0] += p.r as f32 * k;
154                rgba[1] += p.g as f32 * k;
155                rgba[2] += p.b as f32 * k;
156                rgba[3] += p.a as f32 * k;
157            }
158
159            // Convert back to u8 with rounding
160            dst.set_pixel(
161                x,
162                y,
163                PremulRgba8 {
164                    r: rgba[0].round() as u8,
165                    g: rgba[1].round() as u8,
166                    b: rgba[2].round() as u8,
167                    a: rgba[3].round() as u8,
168                },
169            );
170        }
171    }
172}
173
174/// Apply vertical blur pass (1D convolution along y-axis).
175///
176/// For each output pixel, computes a weighted sum of vertically neighboring pixels
177/// using the Gaussian kernel. Handles edge cases according to the specified edge mode.
178/// Writes results to a destination buffer to avoid overwriting source data.
179pub(crate) fn convolve_y(
180    src: &Pixmap,
181    dst: &mut Pixmap,
182    src_width: u16,
183    src_height: u16,
184    kernel: &[f32],
185    radius: u8,
186    edge_mode: EdgeMode,
187) {
188    for y in 0..src_height {
189        for x in 0..src_width {
190            let mut rgba = [0.0_f32; 4];
191
192            // Sum contributions from all kernel positions: output = Σ(weight[j] × pixel[y+j-radius])
193            for (j, &k) in kernel.iter().enumerate() {
194                #[expect(
195                    clippy::cast_possible_wrap,
196                    reason = "This cast never wraps because `kernel.len()` is never greater than `u8::MAX` due to the restriction on `MAX_KERNEL_SIZE`"
197                )]
198                let j = j as i32;
199                let src_y = y as i32 + j - radius as i32;
200                let p = sample_y(src, x, src_y, src_height, edge_mode);
201
202                rgba[0] += p.r as f32 * k;
203                rgba[1] += p.g as f32 * k;
204                rgba[2] += p.b as f32 * k;
205                rgba[3] += p.a as f32 * k;
206            }
207
208            // Convert back to u8 with rounding
209            dst.set_pixel(
210                x,
211                y,
212                PremulRgba8 {
213                    r: rgba[0].round() as u8,
214                    g: rgba[1].round() as u8,
215                    b: rgba[2].round() as u8,
216                    a: rgba[3].round() as u8,
217                },
218            );
219        }
220    }
221}
222
223/// Downsample image by 2x using separable \[1,3,3,1\]/8 binomial filter.
224///
225/// Performs horizontal and vertical decimation in sequence. Returns the new
226/// logical dimensions (ceil(width/2), ceil(height/2)).
227pub(crate) fn downscale(
228    src: &mut Pixmap,
229    src_width: u16,
230    src_height: u16,
231    edge_mode: EdgeMode,
232) -> (u16, u16) {
233    let dst_width = src_width.div_ceil(2);
234    let dst_height = src_height.div_ceil(2);
235    downscale_x(src, src_width, src_height, dst_width, edge_mode);
236    // We can pass `dst_width` instead of `src_width` here, since we already decimated
237    // horizontally.
238    downscale_y(src, dst_width, src_height, dst_height, edge_mode);
239    (dst_width, dst_height)
240}
241
242/// Horizontal decimation pass using \[1,3,3,1\]/8 filter.
243///
244/// Reduces width by 2x while applying a binomial blur kernel. The \[1,3,3,1\] weights
245/// approximate a Gaussian and contribute variance=0.75 before the 2x downsampling.
246fn downscale_x(
247    src: &mut Pixmap,
248    src_width: u16,
249    src_height: u16,
250    dst_width: u16,
251    edge_mode: EdgeMode,
252) {
253    for y in 0..src_height {
254        // Sliding window: maintains 2 previous pixels (x0, x1) to form 4-tap filter
255        // with current pixels (x2, x3). Start with sample at -1 for implicit left padding.
256        let mut p0 = sample_x(src, -1, y, src_width, edge_mode);
257        let mut p1 = sample_x(src, 0, y, src_width, edge_mode);
258
259        for x in 0..dst_width {
260            // Sample 4 horizontally adjacent pixels for [1,3,3,1] kernel
261            // Pattern: [x*2-1, x*2, x*2+1, x*2+2]
262            let src_x = (x * 2) as i32;
263            let p2 = sample_x(src, src_x + 1, y, src_width, edge_mode);
264            let p3 = sample_x(src, src_x + 2, y, src_width, edge_mode);
265
266            // Apply [1,3,3,1]/8 weights → output = (p0 + 3×p1 + 3×p2 + p3) / 8
267            src.set_pixel(x, y, decimate_weighted(p0, p1, p2, p3));
268
269            // Advance window: previous p2,p3 become next p0,p1
270            p0 = p2;
271            p1 = p3;
272        }
273    }
274}
275
276/// Vertical decimation pass using \[1,3,3,1\]/8 filter.
277///
278/// Reduces logical height by 2x while applying a binomial blur kernel.
279/// Operates in-place by writing to the beginning of the same buffer.
280fn downscale_y(
281    src: &mut Pixmap,
282    src_width: u16,
283    src_height: u16,
284    dst_height: u16,
285    edge_mode: EdgeMode,
286) {
287    for x in 0..src_width {
288        // Sliding window: maintains 2 previous pixels (y0, y1) to form 4-tap filter
289        // with current pixels (y2, y3). Start with sample at -1 for implicit top padding.
290        let mut p0 = sample_y(src, x, -1, src_height, edge_mode);
291        let mut p1 = sample_y(src, x, 0, src_height, edge_mode);
292
293        for y in 0..dst_height {
294            // Sample 4 vertically adjacent pixels for [1,3,3,1] kernel
295            // Pattern: [y*2-1, y*2, y*2+1, y*2+2]
296            let src_y = (y * 2) as i32;
297            let p2 = sample_y(src, x, src_y + 1, src_height, edge_mode);
298            let p3 = sample_y(src, x, src_y + 2, src_height, edge_mode);
299
300            // Apply [1,3,3,1]/8 weights → output = (p0 + 3×p1 + 3×p2 + p3) / 8
301            src.set_pixel(x, y, decimate_weighted(p0, p1, p2, p3));
302
303            // Advance window: previous p2,p3 become next p0,p1
304            p0 = p2;
305            p1 = p3;
306        }
307    }
308}
309
310/// Upsample a pixmap by 2x using linear interpolation with [0.75, 0.25] weights.
311///
312/// Uses separable passes: horizontal doubling followed by vertical doubling.
313///
314/// ## Phase Alignment Theory
315///
316/// The downsampling \[1,3,3,1\]/8 filter creates a half-pixel offset. Its center of mass
317/// is at position `2k + 0.5`, not `2k`. This means each downsampled pixel at discrete
318/// position `k` represents a sample at continuous position `2k + 0.5`.
319///
320/// When upsampling, we reconstruct pixels at positions `2k` and `2k+1` from downsampled
321/// pixels whose centers are at `..., 2k-1.5, 2k+0.5, 2k+2.5, ...`
322///
323/// **Interpolation weights** are derived from linear interpolation based on distances:
324/// - Position `2k`:   distance 0.5 from center at `2k+0.5`, distance 1.5 from center at `2k-1.5`
325///   → weights: 0.75×pixel\[k\] + 0.25×pixel\[k-1\]
326/// - Position `2k+1`: distance 0.5 from center at `2k+0.5`, distance 1.5 from center at `2k+2.5`
327///   → weights: 0.75×pixel\[k\] + 0.25×pixel\[k+1\]
328pub(crate) fn upscale(
329    src: &mut Pixmap,
330    src_width: u16,
331    src_height: u16,
332    edge_mode: EdgeMode,
333) -> (u16, u16) {
334    let dst_width = src_width * 2;
335    let dst_height = src_height * 2;
336    upscale_x(src, src_width, src_height, edge_mode);
337    upscale_y(src, dst_width, src_height, edge_mode);
338    (dst_width, dst_height)
339}
340
341/// Horizontal upsampling pass using [0.75, 0.25] interpolation with logical dimensions.
342///
343/// Doubles the logical width using phase-aligned interpolation. Each input pixel
344/// generates two output pixels with different weights based on their distance from
345/// the downsampled pixel's center position.
346/// Operates in-place by processing backwards to avoid overwriting source data.
347fn upscale_x(src: &mut Pixmap, src_width: u16, src_height: u16, edge_mode: EdgeMode) {
348    // Process backwards (right to left) to avoid overwriting source data
349    for y in 0..src_height {
350        // Maintain sliding window of three pixels: prev, current, next
351        // This allows us to compute both output pixels that depend on current pixel x
352        let mut p0 = sample_x(src, src_width as i32, y, src_width, edge_mode);
353        let mut p1 = sample_x(src, src_width as i32 - 1, y, src_width, edge_mode);
354
355        for x in (0..src_width).rev() {
356            let src_x = x as i32;
357            let p2 = sample_x(src, src_x - 1, y, src_width, edge_mode);
358
359            // Generate two output pixels per input with phase-aligned interpolation:
360            // output[2x]   = 0.25×p2 + 0.75×p1  (position 2x   is 0.5 from center at 2x+0.5)
361            // output[2x+1] = 0.75×p1 + 0.25×p0  (position 2x+1 is 0.5 from center at 2x+0.5)
362            let dst_x = x * 2;
363            src.set_pixel(dst_x, y, interpolate_25_75(p2, p1));
364            src.set_pixel(dst_x + 1, y, interpolate_75_25(p1, p0));
365
366            // Advance sliding window for next iteration
367            p0 = p1;
368            p1 = p2;
369        }
370    }
371}
372
373/// Vertical upsampling pass using [0.75, 0.25] interpolation with logical dimensions.
374///
375/// Doubles the logical height using phase-aligned interpolation. Each input pixel
376/// generates two output pixels with different weights based on their distance from
377/// the downsampled pixel's center position.
378/// Operates in-place by processing backwards to avoid overwriting source data.
379fn upscale_y(src: &mut Pixmap, src_width: u16, src_height: u16, edge_mode: EdgeMode) {
380    // Process backwards (bottom to top) to avoid overwriting source data
381    for x in 0..src_width {
382        // Maintain sliding window of three pixels: prev, current, next
383        // This allows us to compute both output pixels that depend on current pixel y
384        let mut p0 = sample_y(src, x, src_height as i32, src_height, edge_mode);
385        let mut p1 = sample_y(src, x, src_height as i32 - 1, src_height, edge_mode);
386
387        for y in (0..src_height).rev() {
388            let src_y = y as i32;
389            let p2 = sample_y(src, x, src_y - 1, src_height, edge_mode);
390
391            // Generate two output rows per input with phase-aligned interpolation:
392            // output[2y]   = 0.25×p2 + 0.75×p1  (position 2y   is 0.5 from center at 2y+0.5)
393            // output[2y+1] = 0.75×p1 + 0.25×p0  (position 2y+1 is 0.5 from center at 2y+0.5)
394            let dst_y = y * 2;
395            src.set_pixel(x, dst_y, interpolate_25_75(p2, p1));
396            src.set_pixel(x, dst_y + 1, interpolate_75_25(p1, p0));
397
398            // Advance sliding window for next iteration
399            p0 = p1;
400            p1 = p2;
401        }
402    }
403}
404
405/// Transparent black pixel constant.
406const TRANSPARENT_BLACK: PremulRgba8 = PremulRgba8 {
407    r: 0,
408    g: 0,
409    b: 0,
410    a: 0,
411};
412
413/// Sample a pixel with edge mode handling for horizontal sampling.
414#[inline(always)]
415fn sample_x(src: &Pixmap, x: i32, y: u16, width: u16, edge_mode: EdgeMode) -> PremulRgba8 {
416    sample(x, width, edge_mode, |src_x| src.sample(src_x, y))
417}
418
419/// Sample a pixel with edge mode handling for vertical sampling.
420#[inline(always)]
421fn sample_y(src: &Pixmap, x: u16, y: i32, height: u16, edge_mode: EdgeMode) -> PremulRgba8 {
422    sample(y, height, edge_mode, |src_y| src.sample(x, src_y))
423}
424
425/// Sample a pixel with edge mode handling (generic implementation).
426///
427/// Handles both horizontal and vertical sampling based on the provided closure.
428/// The `sample_fn` closure receives the clamped/extended coordinate and returns the pixel.
429/// For `EdgeMode::None`, returns transparent black if the coordinate is out of bounds.
430#[inline(always)]
431fn sample<F>(coord: i32, size: u16, edge_mode: EdgeMode, sample_fn: F) -> PremulRgba8
432where
433    F: FnOnce(u16) -> PremulRgba8,
434{
435    // For EdgeMode::None, return transparent black if out of bounds
436    if edge_mode == EdgeMode::None && (coord < 0 || coord >= size as i32) {
437        return TRANSPARENT_BLACK;
438    }
439    let extended_coord = extend(coord, size, edge_mode);
440    sample_fn(extended_coord)
441}
442
443/// Extend a coordinate beyond image boundaries according to the edge mode.
444///
445/// Transforms out-of-bounds coordinates for sampling: clamped, wrapped, or mirrored
446/// depending on the mode. For `EdgeMode::None`, the coordinate is guaranteed to be
447/// in-bounds (already checked by caller, which returns transparent black for out-of-bounds).
448#[inline(always)]
449fn extend(coord: i32, size: u16, edge_mode: EdgeMode) -> u16 {
450    match edge_mode {
451        EdgeMode::Duplicate => {
452            // Clamp to image bounds: pixels outside use nearest edge pixel
453            coord.clamp(0, size as i32 - 1) as u16
454        }
455        EdgeMode::None => {
456            // Coordinate is already validated as in-bounds by caller
457            coord as u16
458        }
459        EdgeMode::Wrap => {
460            // Wrap around using modulo: image tiles infinitely
461            let mut c = coord % size as i32;
462            if c < 0 {
463                c += size as i32;
464            }
465            c as u16
466        }
467        EdgeMode::Mirror => {
468            // Mirror at boundaries: image reflects across edges
469            let period = size as i32 * 2;
470            let mut c = coord % period;
471            if c < 0 {
472                c += period;
473            }
474            if c >= size as i32 {
475                c = period - c - 1;
476            }
477            c as u16
478        }
479    }
480}
481
482/// Blend 4 RGBA pixels using \[1,3,3,1\]/8 binomial weights.
483///
484/// Computes `(p0 + 3×p1 + 3×p2 + p3) / 8` using efficient integer arithmetic (right shift).
485/// This binomial pattern approximates a Gaussian and contributes variance=0.75 to the blur.
486/// Adds 4 before the shift to implement round-to-nearest instead of floor division.
487#[inline(always)]
488fn decimate_weighted(
489    p0: PremulRgba8,
490    p1: PremulRgba8,
491    p2: PremulRgba8,
492    p3: PremulRgba8,
493) -> PremulRgba8 {
494    let r = ((p0.r as u32 + p1.r as u32 * 3 + p2.r as u32 * 3 + p3.r as u32 + 4) >> 3) as u8;
495    let g = ((p0.g as u32 + p1.g as u32 * 3 + p2.g as u32 * 3 + p3.g as u32 + 4) >> 3) as u8;
496    let b = ((p0.b as u32 + p1.b as u32 * 3 + p2.b as u32 * 3 + p3.b as u32 + 4) >> 3) as u8;
497    let a = ((p0.a as u32 + p1.a as u32 * 3 + p2.a as u32 * 3 + p3.a as u32 + 4) >> 3) as u8;
498    PremulRgba8 { r, g, b, a }
499}
500
501/// Blend 2 RGBA pixels using [0.25, 0.75] weights (right-weighted interpolation).
502///
503/// Used for upsampling to generate the second of two output pixels.
504/// Favors the right/next pixel (p1) with 75% weight.
505/// Adds 2 before the shift to implement round-to-nearest instead of floor division.
506#[inline(always)]
507fn interpolate_25_75(p0: PremulRgba8, p1: PremulRgba8) -> PremulRgba8 {
508    let r = ((p0.r as u32 + p1.r as u32 * 3 + 2) >> 2) as u8;
509    let g = ((p0.g as u32 + p1.g as u32 * 3 + 2) >> 2) as u8;
510    let b = ((p0.b as u32 + p1.b as u32 * 3 + 2) >> 2) as u8;
511    let a = ((p0.a as u32 + p1.a as u32 * 3 + 2) >> 2) as u8;
512    PremulRgba8 { r, g, b, a }
513}
514
515/// Blend 2 RGBA pixels using [0.75, 0.25] weights.
516///
517/// Computes `0.75×p0 + 0.25×p1` using efficient integer arithmetic.
518/// Used during upsampling for positions closer to the first pixel.
519/// Adds 2 before the shift to implement round-to-nearest instead of floor division.
520#[inline(always)]
521fn interpolate_75_25(p0: PremulRgba8, p1: PremulRgba8) -> PremulRgba8 {
522    let r = ((p0.r as u32 * 3 + p1.r as u32 + 2) >> 2) as u8;
523    let g = ((p0.g as u32 * 3 + p1.g as u32 + 2) >> 2) as u8;
524    let b = ((p0.b as u32 * 3 + p1.b as u32 + 2) >> 2) as u8;
525    let a = ((p0.a as u32 * 3 + p1.a as u32 + 2) >> 2) as u8;
526    PremulRgba8 { r, g, b, a }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use vello_common::filter::gaussian_blur::{
533        MAX_KERNEL_SIZE, compute_gaussian_kernel, plan_decimated_blur,
534    };
535
536    /// Test edge extension with Duplicate mode.
537    #[test]
538    fn test_extend_duplicate() {
539        let size = 10;
540
541        // In-bounds coordinate
542        assert_eq!(extend(5, size, EdgeMode::Duplicate), 5);
543
544        // Below bounds: clamp to 0
545        assert_eq!(extend(-1, size, EdgeMode::Duplicate), 0);
546        assert_eq!(extend(-10, size, EdgeMode::Duplicate), 0);
547
548        // Above bounds: clamp to size-1
549        assert_eq!(extend(10, size, EdgeMode::Duplicate), 9);
550        assert_eq!(extend(20, size, EdgeMode::Duplicate), 9);
551    }
552
553    /// Test edge extension with Wrap mode.
554    #[test]
555    fn test_extend_wrap() {
556        let size = 10;
557
558        // In-bounds: identity
559        assert_eq!(extend(5, size, EdgeMode::Wrap), 5);
560
561        // Above bounds: wrap around
562        assert_eq!(extend(10, size, EdgeMode::Wrap), 0);
563        assert_eq!(extend(11, size, EdgeMode::Wrap), 1);
564        assert_eq!(extend(25, size, EdgeMode::Wrap), 5);
565
566        // Below bounds: wrap from end
567        assert_eq!(extend(-1, size, EdgeMode::Wrap), 9);
568        assert_eq!(extend(-2, size, EdgeMode::Wrap), 8);
569    }
570
571    /// Test edge extension with Mirror mode.
572    #[test]
573    fn test_extend_mirror() {
574        let size = 10;
575
576        // In-bounds: identity
577        assert_eq!(extend(5, size, EdgeMode::Mirror), 5);
578
579        // Just past boundary: mirror back
580        // For coord=10: period=20, c=10, c>=10 so c = 20-10-1 = 9
581        assert_eq!(extend(10, size, EdgeMode::Mirror), 9);
582        // For coord=11: period=20, c=11, c>=10 so c = 20-11-1 = 8
583        assert_eq!(extend(11, size, EdgeMode::Mirror), 8);
584
585        // Full reflection cycle (period = 2*size = 20)
586        // For coord=19: c=19, c>=10 so c = 20-19-1 = 0
587        assert_eq!(extend(19, size, EdgeMode::Mirror), 0);
588        // For coord=20: c=20%20=0, c<10 so c=0
589        assert_eq!(extend(20, size, EdgeMode::Mirror), 0);
590
591        // Negative coordinates
592        // For coord=-1: c=-1%20=-1, c<0 so c+=20 → c=19, c>=10 so c=20-19-1=0
593        assert_eq!(extend(-1, size, EdgeMode::Mirror), 0);
594        // For coord=-2: c=-2%20=-2, c<0 so c+=20 → c=18, c>=10 so c=20-18-1=1
595        assert_eq!(extend(-2, size, EdgeMode::Mirror), 1);
596    }
597
598    /// Test edge extension with None mode (coordinate should pass through).
599    #[test]
600    fn test_extend_none() {
601        let size = 10;
602        // None mode just passes the coordinate as u16
603        assert_eq!(extend(5, size, EdgeMode::None), 5);
604        assert_eq!(extend(9, size, EdgeMode::None), 9);
605        assert_eq!(extend(10, size, EdgeMode::None), 10);
606        assert_eq!(extend(11, size, EdgeMode::None), 11);
607    }
608
609    /// Test [1,3,3,1]/8 decimation weights.
610    #[test]
611    fn test_decimate_weighted() {
612        let p0 = PremulRgba8 {
613            r: 0,
614            g: 0,
615            b: 0,
616            a: 0,
617        };
618        let p1 = PremulRgba8 {
619            r: 8,
620            g: 8,
621            b: 8,
622            a: 8,
623        };
624        let p2 = PremulRgba8 {
625            r: 8,
626            g: 8,
627            b: 8,
628            a: 8,
629        };
630        let p3 = PremulRgba8 {
631            r: 0,
632            g: 0,
633            b: 0,
634            a: 0,
635        };
636
637        // (0 + 3*8 + 3*8 + 0 + 4) / 8 = (48 + 4) / 8 = 52 / 8 = 6.5 → 6 (rounds down)
638        let result = decimate_weighted(p0, p1, p2, p3);
639        assert_eq!(result.r, 6);
640        assert_eq!(result.g, 6);
641        assert_eq!(result.b, 6);
642        assert_eq!(result.a, 6);
643    }
644
645    /// Test [1,3,3,1]/8 with all same values (should be identity).
646    #[test]
647    fn test_decimate_weighted_uniform() {
648        let p = PremulRgba8 {
649            r: 100,
650            g: 150,
651            b: 200,
652            a: 255,
653        };
654        let result = decimate_weighted(p, p, p, p);
655        // All same: (100 + 300 + 300 + 100 + 4) / 8 = 804/8 = 100.5 → 100 (rounds down)
656        assert_eq!(result.r, 100);
657        assert_eq!(result.g, 150);
658        assert_eq!(result.b, 200);
659        assert_eq!(result.a, 255);
660    }
661
662    /// Test [0.25, 0.75] interpolation.
663    #[test]
664    fn test_interpolate_25_75() {
665        let p0 = PremulRgba8 {
666            r: 0,
667            g: 0,
668            b: 0,
669            a: 0,
670        };
671        let p1 = PremulRgba8 {
672            r: 100,
673            g: 100,
674            b: 100,
675            a: 100,
676        };
677
678        // (0 + 300 + 2) / 4 = 302 / 4 = 75.5 → 75 (rounds down)
679        let result = interpolate_25_75(p0, p1);
680        assert_eq!(result.r, 75);
681        assert_eq!(result.g, 75);
682        assert_eq!(result.b, 75);
683        assert_eq!(result.a, 75);
684    }
685
686    /// Test [0.75, 0.25] interpolation.
687    #[test]
688    fn test_interpolate_75_25() {
689        let p0 = PremulRgba8 {
690            r: 100,
691            g: 100,
692            b: 100,
693            a: 100,
694        };
695        let p1 = PremulRgba8 {
696            r: 0,
697            g: 0,
698            b: 0,
699            a: 0,
700        };
701
702        // (300 + 0 + 2) / 4 = 302 / 4 = 75.5 → 75 (rounds down)
703        let result = interpolate_75_25(p0, p1);
704        assert_eq!(result.r, 75);
705        assert_eq!(result.g, 75);
706        assert_eq!(result.b, 75);
707        assert_eq!(result.a, 75);
708    }
709
710    /// Test interpolation symmetry.
711    #[test]
712    fn test_interpolation_symmetry() {
713        let p0 = PremulRgba8 {
714            r: 50,
715            g: 100,
716            b: 150,
717            a: 200,
718        };
719        let p1 = PremulRgba8 {
720            r: 200,
721            g: 150,
722            b: 100,
723            a: 50,
724        };
725
726        let r1 = interpolate_25_75(p0, p1);
727        let r2 = interpolate_75_25(p1, p0);
728
729        // Should be symmetric
730        assert!((r1.r as i32 - r2.r as i32).abs() <= 0);
731        assert!((r1.g as i32 - r2.g as i32).abs() <= 0);
732        assert!((r1.b as i32 - r2.b as i32).abs() <= 0);
733        assert!((r1.a as i32 - r2.a as i32).abs() <= 0);
734    }
735
736    /// Test that very small image sizes don't panic.
737    #[test]
738    fn test_small_image_sizes() {
739        let mut pixmap = Pixmap::new(1, 1);
740        let (n_decimations, kernel, kernel_size) = plan_decimated_blur(2.0);
741
742        // Should not panic
743        let result = std::panic::catch_unwind(move || {
744            let mut scratch = Pixmap::new(1, 1);
745            apply_blur(
746                &mut pixmap,
747                &mut scratch,
748                n_decimations,
749                &kernel[..usize::from(kernel_size)],
750                EdgeMode::None,
751            );
752        });
753
754        assert!(result.is_ok());
755    }
756
757    /// Test downscale with odd dimensions.
758    #[test]
759    fn test_downscale_odd_dimensions() {
760        let mut pixmap = Pixmap::new(5, 5);
761        // Fill with white
762        for y in 0..5 {
763            for x in 0..5 {
764                pixmap.set_pixel(
765                    x,
766                    y,
767                    PremulRgba8 {
768                        r: 255,
769                        g: 255,
770                        b: 255,
771                        a: 255,
772                    },
773                );
774            }
775        }
776
777        let (new_width, new_height) = downscale(&mut pixmap, 5, 5, EdgeMode::Duplicate);
778        // 5 / 2 = 2.5 → ceil = 3
779        assert_eq!(new_width, 3);
780        assert_eq!(new_height, 3);
781    }
782
783    /// Test upscale dimensions.
784    #[test]
785    fn test_upscale_dimensions() {
786        let mut pixmap = Pixmap::new(6, 6);
787        let (new_width, new_height) = upscale(&mut pixmap, 3, 3, EdgeMode::Duplicate);
788        // 3 * 2 = 6
789        assert_eq!(new_width, 6);
790        assert_eq!(new_height, 6);
791    }
792
793    /// Test that horizontal convolution preserves uniform colors.
794    #[test]
795    fn test_convolve_x_uniform() {
796        let mut src = Pixmap::new(5, 3);
797        let mut dst = Pixmap::new(5, 3);
798        // Fill with uniform gray
799        src.data_mut().fill(PremulRgba8 {
800            r: 128,
801            g: 128,
802            b: 128,
803            a: 255,
804        });
805
806        let (kernel, kernel_size) = compute_gaussian_kernel(1.0);
807        convolve_x(
808            &src,
809            &mut dst,
810            5,
811            3,
812            &kernel[..usize::from(kernel_size)],
813            kernel_size / 2,
814            EdgeMode::Duplicate,
815        );
816
817        // Uniform input should produce uniform output
818        for y in 0..3 {
819            for x in 0..5 {
820                let pixel = dst.sample(x, y);
821                assert_eq!(
822                    pixel,
823                    PremulRgba8 {
824                        r: 128,
825                        g: 128,
826                        b: 128,
827                        a: 255,
828                    }
829                );
830            }
831        }
832    }
833
834    /// Test that vertical convolution preserves uniform colors.
835    #[test]
836    fn test_convolve_y_uniform() {
837        let mut src = Pixmap::new(3, 5);
838        let mut dst = Pixmap::new(3, 5);
839        // Fill with uniform gray
840        src.data_mut().fill(PremulRgba8 {
841            r: 128,
842            g: 128,
843            b: 128,
844            a: 255,
845        });
846
847        let (kernel, kernel_size) = compute_gaussian_kernel(1.0);
848        convolve_y(
849            &src,
850            &mut dst,
851            3,
852            5,
853            &kernel[..usize::from(kernel_size)],
854            kernel_size / 2,
855            EdgeMode::Duplicate,
856        );
857
858        // Uniform input should produce uniform output
859        for y in 0..5 {
860            for x in 0..3 {
861                let pixel = dst.sample(x, y);
862                assert_eq!(
863                    pixel,
864                    PremulRgba8 {
865                        r: 128,
866                        g: 128,
867                        b: 128,
868                        a: 255,
869                    }
870                );
871            }
872        }
873    }
874
875    /// Test that convolution with identity kernel is a no-op.
876    #[test]
877    fn test_convolve_identity_kernel() {
878        let mut src = Pixmap::new(3, 3);
879        let mut dst = Pixmap::new(3, 3);
880        src.set_pixel(
881            1,
882            1,
883            PremulRgba8 {
884                r: 255,
885                g: 100,
886                b: 50,
887                a: 200,
888            },
889        );
890
891        let kernel = [1.0]; // Identity kernel
892        convolve_x(&src, &mut dst, 3, 3, &kernel, 0, EdgeMode::None);
893
894        // Should be unchanged
895        assert_eq!(dst.sample(1, 1), src.sample(1, 1));
896    }
897
898    /// Test that large sigma values are clamped to `MAX_KERNEL_SIZE`.
899    #[test]
900    fn test_large_sigma_clamped_to_max() {
901        let (kernel, kernel_size) = compute_gaussian_kernel(100.0);
902        // For σ=100, radius = ceil(300) = 300, size would be 601
903        // But it should be clamped to MAX_KERNEL_SIZE
904        assert_eq!(usize::from(kernel_size), MAX_KERNEL_SIZE);
905
906        // The clamped kernel should still sum to 1.0 (normalized)
907        let sum: f32 = kernel.iter().take(usize::from(kernel_size)).sum();
908        assert!((sum - 1.0).abs() < 1e-6);
909    }
910
911    /// Test that decimation prevents kernel from exceeding `MAX_KERNEL_SIZE`.
912    #[test]
913    fn test_decimation_reduces_kernel_size() {
914        let (n_decimations, _kernel, kernel_size) = plan_decimated_blur(100.0);
915        assert_eq!(kernel_size, 11);
916        assert_eq!(n_decimations, 6, "Large sigma should trigger decimation");
917    }
918
919    /// Test sampling behavior at exact boundaries for each edge mode.
920    #[test]
921    fn test_sample_x_at_boundaries() {
922        let mut pixmap = Pixmap::new(3, 1);
923        pixmap.set_pixel(
924            0,
925            0,
926            PremulRgba8 {
927                r: 10,
928                g: 0,
929                b: 0,
930                a: 255,
931            },
932        );
933        pixmap.set_pixel(
934            1,
935            0,
936            PremulRgba8 {
937                r: 20,
938                g: 0,
939                b: 0,
940                a: 255,
941            },
942        );
943        pixmap.set_pixel(
944            2,
945            0,
946            PremulRgba8 {
947                r: 30,
948                g: 0,
949                b: 0,
950                a: 255,
951            },
952        );
953
954        // Test left boundary with Duplicate
955        let mut p = sample_x(&pixmap, -1, 0, 3, EdgeMode::Duplicate);
956        assert_eq!(p.r, 10); // Should clamp to first pixel
957
958        // Test right boundary with Duplicate
959        p = sample_x(&pixmap, 3, 0, 3, EdgeMode::Duplicate);
960        assert_eq!(p.r, 30); // Should clamp to last pixel
961
962        // Test with None mode (should return transparent black)
963        p = sample_x(&pixmap, -1, 0, 3, EdgeMode::None);
964        assert_eq!(p.a, 0);
965    }
966
967    /// Test sampling behavior at exact boundaries for vertical sampling.
968    #[test]
969    fn test_sample_y_at_boundaries() {
970        let mut pixmap = Pixmap::new(1, 3);
971        pixmap.set_pixel(
972            0,
973            0,
974            PremulRgba8 {
975                r: 10,
976                g: 0,
977                b: 0,
978                a: 255,
979            },
980        );
981        pixmap.set_pixel(
982            0,
983            1,
984            PremulRgba8 {
985                r: 20,
986                g: 0,
987                b: 0,
988                a: 255,
989            },
990        );
991        pixmap.set_pixel(
992            0,
993            2,
994            PremulRgba8 {
995                r: 30,
996                g: 0,
997                b: 0,
998                a: 255,
999            },
1000        );
1001
1002        // Test top boundary with Duplicate
1003        let mut p = sample_y(&pixmap, 0, -1, 3, EdgeMode::Duplicate);
1004        assert_eq!(p.r, 10); // Should clamp to first pixel
1005
1006        // Test bottom boundary with Duplicate
1007        p = sample_y(&pixmap, 0, 3, 3, EdgeMode::Duplicate);
1008        assert_eq!(p.r, 30); // Should clamp to last pixel
1009
1010        // Test with None mode (should return transparent black)
1011        p = sample_y(&pixmap, 0, -1, 3, EdgeMode::None);
1012        assert_eq!(p.a, 0);
1013    }
1014
1015    /// Test that kernel normalization is precise for various sigma values.
1016    #[test]
1017    fn test_kernel_normalization_precision() {
1018        for sigma in [0.1, 0.5, 1.0, 2.0, 5.0, 10.0] {
1019            let (kernel, kernel_size) = compute_gaussian_kernel(sigma);
1020            let sum: f32 = kernel.iter().take(usize::from(kernel_size)).sum();
1021            assert!(
1022                (sum - 1.0).abs() < 1e-6,
1023                "Kernel for σ={} not normalized: sum={}",
1024                sigma,
1025                sum
1026            );
1027        }
1028    }
1029
1030    /// Test that downscale → upscale preserves dimensions.
1031    #[test]
1032    fn test_downscale_upscale_roundtrip() {
1033        let mut pixmap = Pixmap::new(8, 8);
1034        // Fill with a pattern
1035        pixmap.data_mut().fill(PremulRgba8 {
1036            r: 128,
1037            g: 128,
1038            b: 128,
1039            a: 255,
1040        });
1041
1042        let (w1, h1) = downscale(&mut pixmap, 8, 8, EdgeMode::Duplicate);
1043        assert_eq!(w1, 4);
1044        assert_eq!(h1, 4);
1045
1046        let (w2, h2) = upscale(&mut pixmap, w1, h1, EdgeMode::Duplicate);
1047        assert_eq!(w2, 8);
1048        assert_eq!(h2, 8);
1049    }
1050
1051    fn pixmap_from_red(width: u16, height: u16, values: &[&[u8]]) -> Pixmap {
1052        let mut pixmap = Pixmap::new(width, height);
1053        for (y, row) in values.iter().enumerate() {
1054            for (x, &r) in row.iter().enumerate() {
1055                pixmap.set_pixel(
1056                    x as u16,
1057                    y as u16,
1058                    PremulRgba8 {
1059                        r,
1060                        g: 0,
1061                        b: 0,
1062                        a: 255,
1063                    },
1064                );
1065            }
1066        }
1067        pixmap
1068    }
1069
1070    fn assert_red_values<const W: usize, const H: usize>(pixmap: &Pixmap, expected: [[u8; W]; H]) {
1071        for (y, row) in expected.iter().enumerate() {
1072            for (x, &want) in row.iter().enumerate() {
1073                let got = pixmap.sample(x as u16, y as u16).r;
1074
1075                assert_eq!(
1076                    got, want,
1077                    "red mismatch at ({x}, {y}): got {got}, expected {want}"
1078                );
1079            }
1080        }
1081    }
1082
1083    #[test]
1084    fn test_downscale_x_non_uniform() {
1085        //   (0,0)=0    (1,0)=40   (2,0)=80   (3,0)=120
1086        //   (0,1)=20   (1,1)=60   (2,1)=100  (3,1)=140
1087        let mut pixmap = pixmap_from_red(4, 2, &[&[0, 40, 80, 120], &[20, 60, 100, 140]]);
1088
1089        let dst_width = 4_u16.div_ceil(2);
1090        downscale_x(&mut pixmap, 4, 2, dst_width, EdgeMode::Duplicate);
1091
1092        // Row 0: p[-1]=0,p[0]=0,p[1]=40,p[2]=80    → (0+0*3+40*3+80+4)>>3       = 25
1093        //        p[1]=40,p[2]=80,p[3]=120,p[4]=120  → (40+80*3+120*3+120+4)>>3   = 95
1094        // Row 1: p[-1]=20,p[0]=20,p[1]=60,p[2]=100  → (20+20*3+60*3+100+4)>>3    = 45
1095        //        p[1]=60,p[2]=100,p[3]=140,p[4]=140  → (60+100*3+140*3+140+4)>>3   = 115
1096        assert_red_values(&pixmap, [[25, 95], [45, 115]]);
1097    }
1098
1099    #[test]
1100    fn test_downscale_y_non_uniform() {
1101        // Use a 2x4 image (output of a prior downscale_x).
1102        //   (0,0)=25   (1,0)=95
1103        //   (0,1)=45   (1,1)=115
1104        //   (0,2)=35   (1,2)=105
1105        //   (0,3)=55   (1,3)=125
1106        let mut pixmap = pixmap_from_red(2, 4, &[&[25, 95], &[45, 115], &[35, 105], &[55, 125]]);
1107
1108        let dst_height = 4_u16.div_ceil(2);
1109        downscale_y(&mut pixmap, 2, 4, dst_height, EdgeMode::Duplicate);
1110
1111        // Col 0: p[-1]=25,p[0]=25,p[1]=45,p[2]=35 → (25+25*3+45*3+35+4)>>3   = 34
1112        //        p[1]=45,p[2]=35,p[3]=55,p[4]=55   → (45+35*3+55*3+55+4)>>3  = 46
1113        // Col 1: p[-1]=95,p[0]=95,p[1]=115,p[2]=105 → (95+95*3+115*3+105+4)>>3 = 104
1114        //        p[1]=115,p[2]=105,p[3]=125,p[4]=125 → (115+105*3+125*3+125+4)>>3 = 116
1115        assert_red_values(&pixmap, [[34, 104], [46, 116]]);
1116    }
1117
1118    #[test]
1119    fn test_upscale_x_non_uniform() {
1120        let mut pixmap = pixmap_from_red(4, 2, &[&[34, 104], &[46, 116]]);
1121
1122        upscale_x(&mut pixmap, 2, 2, EdgeMode::Duplicate);
1123
1124        // Row 0 [34, 104]:
1125        //   x=0: interp25_75(34,34)=34,  interp75_25(34,104)=(34*3+104+2)>>2 = 52
1126        //   x=1: interp25_75(34,104)=(34+104*3+2)>>2=87, interp75_25(104,104)=104
1127        // Row 1 [46, 116]:
1128        //   x=0: 46, interp75_25(46,116)=(46*3+116+2)>>2=64
1129        //   x=1: interp25_75(46,116)=(46+116*3+2)>>2=99, 116
1130        assert_red_values(&pixmap, [[34, 52, 87, 104], [46, 64, 99, 116]]);
1131    }
1132
1133    #[test]
1134    fn test_upscale_y_non_uniform() {
1135        let mut pixmap = pixmap_from_red(4, 4, &[&[34, 52, 87, 104], &[46, 64, 99, 116]]);
1136
1137        upscale_y(&mut pixmap, 4, 2, EdgeMode::Duplicate);
1138
1139        #[rustfmt::skip]
1140        assert_red_values(&pixmap, [
1141            [ 34,  52,  87, 104],
1142            [ 37,  55,  90, 107],
1143            [ 43,  61,  96, 113],
1144            [ 46,  64,  99, 116],
1145        ]);
1146    }
1147}