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}