vello_common/filter/gaussian_blur.rs
1// Copyright 2026 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! The gaussian blur filter.
5
6use alloc::vec::Vec;
7
8use crate::filter_effects::EdgeMode;
9use crate::kurbo::Affine;
10use crate::util::extract_scales;
11use core::f32::consts::E;
12#[cfg(not(feature = "std"))]
13use peniko::kurbo::common::FloatFuncs as _;
14
15/// Scale a blur's standard deviation uniformly based on the transformation.
16///
17/// Extracts the scale factors from the transformation matrix using SVD and
18/// averages them to get a uniform scale factor for the blur radius.
19///
20/// # Arguments
21/// * `std_deviation` - The blur standard deviation in user space
22/// * `transform` - The transformation matrix to extract scale from
23///
24/// # Returns
25/// The scaled standard deviation in device space
26pub(crate) fn transform_blur_params(std_deviation: f32, transform: &Affine) -> f32 {
27 let (scale_x, scale_y) = extract_scales(transform);
28 let uniform_scale = (scale_x + scale_y) / 2.0;
29 // TODO: Support separate std_deviation for x and y axes (std_deviation_x, std_deviation_y)
30 // to properly handle non-uniform scaling. This would eliminate the need for uniform_scale
31 // and allow blur to scale independently along each axis.
32 std_deviation * uniform_scale
33}
34
35/// Maximum size of the Gaussian kernel (must be odd and equal to or smaller than [`u8::MAX`]).
36///
37/// The multi-scale decimation algorithm guarantees that kernel size never exceeds this value.
38/// Decimation stops when remaining variance ≤ 4.0 (σ ≤ 2.0), which produces kernels of size
39/// at most 13 (radius = ceil(3σ) = 6, size = 1 + 2×6 = 13).
40// Keep in sync with MAX_KERNEL_SIZE in vello_sparse_shaders/shaders/filter.wesl
41pub const MAX_KERNEL_SIZE: usize = 13;
42
43#[cfg(test)]
44const _: () = const {
45 if MAX_KERNEL_SIZE.is_multiple_of(2) {
46 panic!("`MAX_KERNEL_SIZE` must be odd");
47 }
48 if MAX_KERNEL_SIZE > u8::MAX as usize {
49 panic!("`MAX_KERNEL_SIZE` must be less than or equal to `u8::MAX`");
50 }
51};
52
53/// A gaussian blur.
54#[derive(Debug)]
55pub struct GaussianBlur {
56 /// The standard deviation.
57 pub std_deviation: f32,
58 /// Number of 2× decimation levels to use (0 means no decimation, direct convolution).
59 pub n_decimations: usize,
60 /// Pre-computed Gaussian kernel weights for the reduced blur.
61 /// Only the first `kernel_size` elements are valid.
62 pub kernel: [f32; MAX_KERNEL_SIZE],
63 /// Actual length of the kernel (rest is padding up to `MAX_KERNEL_SIZE`).
64 pub kernel_size: u8,
65 /// Edge mode for handling out-of-bounds sampling.
66 pub edge_mode: EdgeMode,
67}
68
69impl GaussianBlur {
70 /// Create a new Gaussian blur filter with the specified standard deviation.
71 ///
72 /// This precomputes the decimation plan, kernel, and radius for optimal performance.
73 pub fn new(std_deviation: f32, edge_mode: EdgeMode) -> Self {
74 let (n_decimations, kernel, kernel_size) = plan_decimated_blur(std_deviation);
75
76 Self {
77 std_deviation,
78 edge_mode,
79 n_decimations,
80 kernel,
81 kernel_size,
82 }
83 }
84}
85
86/// Compute the blur execution plan based on standard deviation.
87///
88/// Returns (`n_decimations`, `kernel`, `kernel_size`):
89/// - `n_decimations`: Number of 2× downsampling steps to perform (per axis)
90/// - `kernel`: Pre-computed Gaussian kernel weights (fixed-size array)
91/// - `kernel_size`: Actual length of the kernel (rest is zero-padded)
92pub fn plan_decimated_blur(std_deviation: f32) -> (usize, [f32; MAX_KERNEL_SIZE], u8) {
93 if std_deviation <= 0.0 {
94 // Invalid standard deviation, return identity kernel (no blur)
95 let mut kernel = [0.0; MAX_KERNEL_SIZE];
96 kernel[0] = 1.0;
97 return (0, kernel, 1);
98 }
99
100 // Compute decimation plan using variance analysis.
101 // Variance (σ²) has the additive property: applying two blurs sequentially
102 // adds their variances together. We use this to decompose the blur.
103 //
104 // Mathematical Foundation: From probability theory, convolving two Gaussians
105 // G(σ₁) ⊗ G(σ₂) = G(√(σ₁² + σ₂²)). This means variance is additive: σ²_total = σ²_1 + σ²_2.
106 // Rearranging: σ²_2 = σ²_total - σ²_1, allowing us to decompose the target blur.
107 let variance = std_deviation * std_deviation;
108 let mut n_decimations = 0;
109 let mut remaining_variance = variance;
110
111 // Each decimation level blurs the image *twice* over the full round trip, and both passes
112 // must be subtracted from the budget so the final result matches the target σ:
113 // 1. The downscale applies a [1,3,3,1]/8 binomial filter (variance 0.75 in the current grid).
114 // 2. The matching upscale reconstruction adds 0.75 variance too: each output samples
115 // neighbouring decimated pixels 0.5 and 1.5 original-grid pixels away from its centre,
116 // so 0.75*(0.5²) + 0.25*(1.5²) = 0.75.
117 // So a level removes 0.75 + 0.75 = 1.5 of variance (in current-grid units) before the 2×
118 // downsampling rescales the remaining variance by 0.25 (= 1/2²) into the next grid.
119 while remaining_variance > 4.0 {
120 remaining_variance = (remaining_variance - 1.5) * 0.25;
121 n_decimations += 1;
122 }
123 // Compute the reduced standard deviation to apply at the decimated resolution
124 let remaining_sigma = remaining_variance.sqrt();
125 // Compute Gaussian kernel for the reduced blur
126 let (kernel, kernel_size) = compute_gaussian_kernel(remaining_sigma);
127
128 (n_decimations, kernel, kernel_size)
129}
130
131/// Compute 1D Gaussian kernel weights for separable convolution.
132///
133/// Returns (`kernel_weights`, `kernel_size`) where `kernel_size = 2×radius + 1`.
134/// The kernel is stored in a fixed-size array to avoid heap allocation.
135/// Uses the standard Gaussian formula: G(x) = exp(-x² / (2σ²)), normalized to sum to 1.
136pub fn compute_gaussian_kernel(std_deviation: f32) -> ([f32; MAX_KERNEL_SIZE], u8) {
137 // Use radius = 3σ to capture 99.7% of the Gaussian distribution.
138 // Beyond ±3σ, the Gaussian values are negligible (<0.3%).
139 let radius = (3.0 * std_deviation).ceil() as usize;
140 let kernel_size = (1 + radius * 2).min(MAX_KERNEL_SIZE) as u8;
141
142 let mut kernel = [0.0; MAX_KERNEL_SIZE];
143 // Compute Gaussian weights using the formula: G(x) = exp(-x² / (2σ²))
144 // This creates a symmetric bell curve centered at the middle of the kernel.
145 let gaussian_denominator = 2.0 * std_deviation * std_deviation;
146 let mut sum = 0.0;
147 let kernel_center = (kernel_size / 2) as f32;
148 for (i, weight) in kernel.iter_mut().enumerate().take(usize::from(kernel_size)) {
149 // Compute distance from center (0 at center, increases outward)
150 let x = (i as f32) - kernel_center;
151 // Apply Gaussian formula: weight decreases exponentially with squared distance
152 *weight = E.powf(-x * x / gaussian_denominator);
153 sum += *weight;
154 }
155
156 // Normalize weights to sum to 1.0, ensuring the blur doesn't change overall brightness.
157 // Without normalization, blurring a uniform gray area could make it brighter/darker.
158 let scale = 1.0 / sum;
159 for weight in kernel.iter_mut().take(usize::from(kernel_size)) {
160 *weight *= scale;
161 }
162
163 (kernel, kernel_size)
164}
165
166/// Tracks dimensions through a chain of downscale/upscale operations.
167#[derive(Debug, Default)]
168pub struct DecimationSizer {
169 width: u16,
170 height: u16,
171 dim_stack: Vec<(u16, u16)>,
172}
173
174impl DecimationSizer {
175 /// Create a new sizer with the given initial dimensions.
176 #[inline]
177 pub fn new(width: u16, height: u16) -> Self {
178 Self {
179 width,
180 height,
181 dim_stack: Vec::new(),
182 }
183 }
184
185 /// Reset the sizer so it can be reused.
186 #[inline]
187 pub fn reset(&mut self, width: u16, height: u16) {
188 self.width = width;
189 self.height = height;
190 self.dim_stack.clear();
191 }
192
193 /// Returns the current logical dimensions.
194 #[inline]
195 pub fn current(&self) -> (u16, u16) {
196 (self.width, self.height)
197 }
198
199 /// Apply a new downscale operation.
200 #[inline]
201 pub fn downscale(&mut self) -> (u16, u16) {
202 self.dim_stack.push((self.width, self.height));
203 self.width = self.width.div_ceil(2);
204 self.height = self.height.div_ceil(2);
205 (self.width, self.height)
206 }
207
208 /// Apply a new upscale operation.
209 #[inline]
210 pub fn upscale(&mut self) -> (u16, u16) {
211 let (target_w, target_h) = self.dim_stack.pop().unwrap();
212 // Clamp because upscale can exceed target on odd dimensions (e.g., 5→3→6 > 5)
213 self.width = (self.width * 2).min(target_w);
214 self.height = (self.height * 2).min(target_h);
215 (self.width, self.height)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use crate::filter::gaussian_blur::{
222 DecimationSizer, compute_gaussian_kernel, plan_decimated_blur,
223 };
224
225 /// Test Gaussian kernel computation for small σ.
226 #[test]
227 fn test_gaussian_kernel_small_sigma() {
228 let (kernel, size) = compute_gaussian_kernel(1.0);
229 // For σ=1.0, radius = ceil(3.0) = 3, size = 2*3+1 = 7
230 assert_eq!(size, 7);
231
232 // Kernel should be symmetric
233 for i in 0..size / 2 {
234 assert!((kernel[usize::from(i)] - kernel[usize::from(size - 1 - i)]).abs() < 1e-6);
235 }
236
237 // Kernel should sum to 1.0 (normalized)
238 let sum: f32 = kernel.iter().take(usize::from(size)).sum();
239 assert!((sum - 1.0).abs() < 1e-6);
240
241 // Center should be the largest weight
242 let center_idx = size / 2;
243 for i in 0..size {
244 if i != center_idx {
245 assert!(kernel[usize::from(center_idx)] >= kernel[usize::from(i)]);
246 }
247 }
248 }
249
250 /// Test Gaussian kernel computation for very small σ (near-zero).
251 #[test]
252 fn test_gaussian_kernel_very_small_sigma() {
253 let (kernel, size) = compute_gaussian_kernel(0.1);
254 // For σ=0.1, radius = ceil(0.3) = 1, size = 3
255 assert_eq!(size, 3);
256 // Should sum to 1.0
257 let sum: f32 = kernel.iter().take(usize::from(size)).sum();
258 assert!((sum - 1.0).abs() < 1e-6);
259 // Center weight should be dominant for very small σ
260 assert!(kernel[1] > 0.9); // Center is highly weighted
261 }
262
263 /// Test Gaussian kernel for fractional σ.
264 #[test]
265 fn test_gaussian_kernel_fractional_sigma() {
266 let (kernel, size) = compute_gaussian_kernel(0.5);
267 // For σ=0.5, radius = ceil(1.5) = 2, size = 5
268 assert_eq!(size, 5);
269
270 // Should still sum to 1.0
271 let sum: f32 = kernel.iter().take(usize::from(size)).sum();
272 assert!((sum - 1.0).abs() < 1e-6);
273 }
274
275 /// Test decimation plan for small blur (no decimation).
276 #[test]
277 fn test_plan_no_decimation() {
278 let (n_decimations, _kernel, _size) = plan_decimated_blur(1.0);
279 // σ=1.0 → variance=1.0, should not decimate
280 assert_eq!(n_decimations, 0);
281 }
282
283 /// Test decimation plan for medium blur (some decimation).
284 #[test]
285 fn test_plan_with_decimation() {
286 let (n_decimations, _kernel, _size) = plan_decimated_blur(5.0);
287 // σ=5.0 → variance=25.0, should decimate
288 assert_eq!(n_decimations, 2);
289 }
290
291 /// Test decimation plan at boundary (σ=2.0).
292 #[test]
293 fn test_plan_decimation_boundary() {
294 let (n_decimations, _kernel, _size) = plan_decimated_blur(2.0);
295 // σ=2.0 → variance=4.0, right at the boundary
296 assert_eq!(n_decimations, 0);
297 }
298
299 /// Test decimation plan for negative σ (invalid, should return identity).
300 #[test]
301 fn test_plan_negative_sigma() {
302 let (n_decimations, kernel, size) = plan_decimated_blur(-1.0);
303 assert_eq!(n_decimations, 0);
304 assert_eq!(size, 1);
305 assert!((kernel[0] - 1.0).abs() < 1e-6);
306 }
307
308 #[test]
309 fn test_decimation_sizer_even() {
310 let mut sizer = DecimationSizer::new(8, 8);
311 assert_eq!(sizer.current(), (8, 8));
312
313 assert_eq!(sizer.downscale(), (4, 4));
314 assert_eq!(sizer.downscale(), (2, 2));
315
316 assert_eq!(sizer.upscale(), (4, 4));
317 assert_eq!(sizer.upscale(), (8, 8));
318 }
319
320 #[test]
321 fn test_decimation_sizer_odd() {
322 let mut sizer = DecimationSizer::new(5, 7);
323 assert_eq!(sizer.downscale(), (3, 4));
324 assert_eq!(sizer.downscale(), (2, 2));
325
326 // Upscale clamps to the pre-downscale target
327 assert_eq!(sizer.upscale(), (3, 4));
328 assert_eq!(sizer.upscale(), (5, 7));
329 }
330
331 #[test]
332 fn test_decimation_sizer_single_level() {
333 let mut sizer = DecimationSizer::new(100, 50);
334 assert_eq!(sizer.downscale(), (50, 25));
335 assert_eq!(sizer.upscale(), (100, 50));
336 }
337}