Skip to main content

spatialrust_vision/
filter.rs

1//! Linear CPU image filters with explicit border and kernel contracts.
2
3use pulp::Arch;
4use rayon::prelude::*;
5use spatialrust_image::{Image, ImageView};
6
7use crate::border::{fetch, map_index};
8use crate::dispatch::{
9    bounded_workers, items_per_worker, should_parallelize, LARGE_PARALLEL_COMPONENTS,
10};
11use crate::{BorderMode, PixelComponent, VisionError, VisionResult};
12
13/// Validated two-dimensional correlation kernel.
14#[derive(Clone, Debug, PartialEq)]
15pub struct Kernel2D {
16    width: usize,
17    height: usize,
18    anchor_x: usize,
19    anchor_y: usize,
20    coefficients: Vec<f64>,
21}
22
23impl Kernel2D {
24    /// Creates a kernel with its anchor at `(width / 2, height / 2)`.
25    pub fn try_new(width: usize, height: usize, coefficients: Vec<f64>) -> VisionResult<Self> {
26        Self::try_new_with_anchor(width, height, width / 2, height / 2, coefficients)
27    }
28
29    /// Creates a kernel with an explicit in-kernel anchor.
30    pub fn try_new_with_anchor(
31        width: usize,
32        height: usize,
33        anchor_x: usize,
34        anchor_y: usize,
35        coefficients: Vec<f64>,
36    ) -> VisionResult<Self> {
37        if width == 0 || height == 0 {
38            return Err(VisionError::InvalidParameter("kernel dimensions must be non-zero".into()));
39        }
40        let expected = width
41            .checked_mul(height)
42            .ok_or_else(|| VisionError::InvalidParameter("kernel dimensions overflow".into()))?;
43        if coefficients.len() != expected {
44            return Err(VisionError::ShapeMismatch(format!(
45                "kernel needs {expected} coefficients, found {}",
46                coefficients.len()
47            )));
48        }
49        if anchor_x >= width || anchor_y >= height {
50            return Err(VisionError::InvalidParameter(format!(
51                "kernel anchor ({anchor_x}, {anchor_y}) is outside {width}x{height}"
52            )));
53        }
54        if coefficients.iter().any(|value| !value.is_finite()) {
55            return Err(VisionError::InvalidParameter("kernel coefficients must be finite".into()));
56        }
57        Ok(Self { width, height, anchor_x, anchor_y, coefficients })
58    }
59
60    /// Kernel width.
61    #[must_use]
62    pub const fn width(&self) -> usize {
63        self.width
64    }
65
66    /// Kernel height.
67    #[must_use]
68    pub const fn height(&self) -> usize {
69        self.height
70    }
71
72    /// Kernel anchor `(x, y)`.
73    #[must_use]
74    pub const fn anchor(&self) -> (usize, usize) {
75        (self.anchor_x, self.anchor_y)
76    }
77
78    /// Row-major coefficients.
79    #[must_use]
80    pub fn coefficients(&self) -> &[f64] {
81        &self.coefficients
82    }
83
84    /// Returns a kernel reversed in both axes for true convolution.
85    #[must_use]
86    pub fn reversed(&self) -> Self {
87        let mut coefficients = self.coefficients.clone();
88        coefficients.reverse();
89        Self {
90            width: self.width,
91            height: self.height,
92            anchor_x: self.width - 1 - self.anchor_x,
93            anchor_y: self.height - 1 - self.anchor_y,
94            coefficients,
95        }
96    }
97}
98
99/// Validated one-dimensional correlation kernel.
100#[derive(Clone, Debug, PartialEq)]
101pub struct Kernel1D {
102    anchor: usize,
103    coefficients: Vec<f64>,
104}
105
106impl Kernel1D {
107    /// Creates a kernel anchored at `coefficients.len() / 2`.
108    pub fn try_new(coefficients: Vec<f64>) -> VisionResult<Self> {
109        let anchor = coefficients.len() / 2;
110        Self::try_new_with_anchor(coefficients, anchor)
111    }
112
113    /// Creates a one-dimensional kernel with an explicit anchor.
114    pub fn try_new_with_anchor(coefficients: Vec<f64>, anchor: usize) -> VisionResult<Self> {
115        if coefficients.is_empty() {
116            return Err(VisionError::InvalidParameter("kernel must not be empty".into()));
117        }
118        if anchor >= coefficients.len() {
119            return Err(VisionError::InvalidParameter(format!(
120                "kernel anchor {anchor} is outside length {}",
121                coefficients.len()
122            )));
123        }
124        if coefficients.iter().any(|value| !value.is_finite()) {
125            return Err(VisionError::InvalidParameter("kernel coefficients must be finite".into()));
126        }
127        Ok(Self { anchor, coefficients })
128    }
129
130    /// Kernel length.
131    #[must_use]
132    pub fn len(&self) -> usize {
133        self.coefficients.len()
134    }
135
136    /// Returns whether the kernel is empty. Valid kernels always return false.
137    #[must_use]
138    pub fn is_empty(&self) -> bool {
139        self.coefficients.is_empty()
140    }
141
142    /// Anchor index.
143    #[must_use]
144    pub const fn anchor(&self) -> usize {
145        self.anchor
146    }
147
148    /// Kernel coefficients.
149    #[must_use]
150    pub fn coefficients(&self) -> &[f64] {
151        &self.coefficients
152    }
153}
154
155/// Applies OpenCV-style correlation and converts the result back to the input dtype.
156pub fn filter2d<T: PixelComponent, const CHANNELS: usize>(
157    input: ImageView<'_, T, CHANNELS>,
158    kernel: &Kernel2D,
159    delta: f64,
160    border: BorderMode<T, CHANNELS>,
161) -> VisionResult<Image<T, CHANNELS>> {
162    validate_delta(delta)?;
163    let accumulators = correlate(input, kernel, delta, border);
164    let output = accumulators.into_iter().map(T::from_f64).collect();
165    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
166}
167
168/// Applies OpenCV-style correlation and preserves signed/fractional results as `f32`.
169pub fn filter2d_f32<T: PixelComponent, const CHANNELS: usize>(
170    input: ImageView<'_, T, CHANNELS>,
171    kernel: &Kernel2D,
172    delta: f64,
173    border: BorderMode<T, CHANNELS>,
174) -> VisionResult<Image<f32, CHANNELS>> {
175    validate_delta(delta)?;
176    let output =
177        correlate(input, kernel, delta, border).into_iter().map(|value| value as f32).collect();
178    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
179}
180
181/// Applies true convolution by reversing the supplied kernel around its anchor.
182pub fn convolve2d<T: PixelComponent, const CHANNELS: usize>(
183    input: ImageView<'_, T, CHANNELS>,
184    kernel: &Kernel2D,
185    delta: f64,
186    border: BorderMode<T, CHANNELS>,
187) -> VisionResult<Image<T, CHANNELS>> {
188    filter2d(input, &kernel.reversed(), delta, border)
189}
190
191/// Applies horizontal and vertical kernels using an `f64` intermediate buffer.
192pub fn separable_filter<T: PixelComponent, const CHANNELS: usize>(
193    input: ImageView<'_, T, CHANNELS>,
194    kernel_x: &Kernel1D,
195    kernel_y: &Kernel1D,
196    delta: f64,
197    border: BorderMode<T, CHANNELS>,
198) -> VisionResult<Image<T, CHANNELS>> {
199    let output = separable_accumulators(input, kernel_x, kernel_y, delta, border)?
200        .into_iter()
201        .map(T::from_f64)
202        .collect();
203    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
204}
205
206/// Applies separable kernels and preserves signed/fractional output as `f32`.
207pub fn separable_filter_f32<T: PixelComponent, const CHANNELS: usize>(
208    input: ImageView<'_, T, CHANNELS>,
209    kernel_x: &Kernel1D,
210    kernel_y: &Kernel1D,
211    delta: f64,
212    border: BorderMode<T, CHANNELS>,
213) -> VisionResult<Image<f32, CHANNELS>> {
214    let output = separable_accumulators(input, kernel_x, kernel_y, delta, border)?
215        .into_iter()
216        .map(|value| value as f32)
217        .collect();
218    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
219}
220
221/// Applies a normalized rectangular box filter.
222pub fn box_blur<T: PixelComponent, const CHANNELS: usize>(
223    input: ImageView<'_, T, CHANNELS>,
224    kernel_width: usize,
225    kernel_height: usize,
226    border: BorderMode<T, CHANNELS>,
227) -> VisionResult<Image<T, CHANNELS>> {
228    if kernel_width == 0 || kernel_height == 0 {
229        return Err(VisionError::InvalidParameter("box kernel dimensions must be non-zero".into()));
230    }
231    let x = Kernel1D::try_new(vec![1.0 / kernel_width as f64; kernel_width])?;
232    let y = Kernel1D::try_new(vec![1.0 / kernel_height as f64; kernel_height])?;
233    separable_filter(input, &x, &y, 0.0, border)
234}
235
236/// Applies a separable Gaussian blur with explicit odd sizes and standard deviations.
237pub fn gaussian_blur<T: PixelComponent, const CHANNELS: usize>(
238    input: ImageView<'_, T, CHANNELS>,
239    kernel_width: usize,
240    kernel_height: usize,
241    sigma_x: f64,
242    sigma_y: f64,
243    border: BorderMode<T, CHANNELS>,
244) -> VisionResult<Image<T, CHANNELS>> {
245    let x = gaussian_kernel(kernel_width, sigma_x)?;
246    let y = gaussian_kernel(kernel_height, sigma_y)?;
247    separable_filter(input, &x, &y, 0.0, border)
248}
249
250/// Reusable scratch storage and fixed-point kernel cache for [`gaussian_blur_u8_into`].
251///
252/// The workspace makes the intermediate allocation and kernel construction explicit. A
253/// workspace may be reused across calls, but must not be shared by concurrent operations.
254#[derive(Clone, Debug, Default)]
255pub struct GaussianBlurU8Workspace {
256    horizontal: Vec<u16>,
257    high_precision_horizontal: Vec<u32>,
258    kernel_x: Vec<u16>,
259    kernel_y: Vec<u16>,
260    kernel_x_key: Option<(usize, u64, u8)>,
261    kernel_y_key: Option<(usize, u64, u8)>,
262}
263
264impl GaussianBlurU8Workspace {
265    /// Creates an empty workspace that grows on first use.
266    #[must_use]
267    pub const fn new() -> Self {
268        Self {
269            horizontal: Vec::new(),
270            high_precision_horizontal: Vec::new(),
271            kernel_x: Vec::new(),
272            kernel_y: Vec::new(),
273            kernel_x_key: None,
274            kernel_y_key: None,
275        }
276    }
277
278    /// Current intermediate-buffer capacity in scalar channel elements.
279    #[must_use]
280    pub fn capacity(&self) -> usize {
281        self.horizontal.capacity().max(self.high_precision_horizontal.capacity())
282    }
283
284    /// Returns bytes reserved by the intermediate buffers and cached kernels.
285    #[must_use]
286    pub fn allocated_bytes(&self) -> usize {
287        self.horizontal.capacity() * std::mem::size_of::<u16>()
288            + self.high_precision_horizontal.capacity() * std::mem::size_of::<u32>()
289            + (self.kernel_x.capacity() + self.kernel_y.capacity()) * std::mem::size_of::<u16>()
290    }
291}
292
293/// Applies a specialized 3×3, 5×5, or 7×7 Gaussian blur to interleaved `u8` input.
294///
295/// The 3×3 and 5×5 paths use normalized Q8 kernels and a `u16` horizontal intermediate. Calls
296/// with a 7×7 axis use Q15 kernels, a `u32` horizontal intermediate, and a rounded `u64` final
297/// accumulation. OpenCV comparison is gated to a maximum difference of two `u8` levels.
298pub fn gaussian_blur_u8<const CHANNELS: usize>(
299    input: ImageView<'_, u8, CHANNELS>,
300    kernel_width: usize,
301    kernel_height: usize,
302    sigma_x: f64,
303    sigma_y: f64,
304    border: BorderMode<u8, CHANNELS>,
305) -> VisionResult<Image<u8, CHANNELS>> {
306    validate_specialized_gaussian_size(kernel_width)?;
307    validate_specialized_gaussian_size(kernel_height)?;
308    validate_gaussian_sigma(sigma_x)?;
309    validate_gaussian_sigma(sigma_y)?;
310    let len = gaussian_output_len::<CHANNELS>(input.width(), input.height())?;
311    let mut output = vec![0; len];
312    let mut workspace = GaussianBlurU8Workspace::new();
313    gaussian_blur_u8_into(
314        input,
315        kernel_width,
316        kernel_height,
317        sigma_x,
318        sigma_y,
319        border,
320        &mut output,
321        &mut workspace,
322    )?;
323    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
324}
325
326/// Applies the specialized `u8` Gaussian blur into caller-owned packed output and workspace.
327#[allow(clippy::too_many_arguments)]
328pub fn gaussian_blur_u8_into<const CHANNELS: usize>(
329    input: ImageView<'_, u8, CHANNELS>,
330    kernel_width: usize,
331    kernel_height: usize,
332    sigma_x: f64,
333    sigma_y: f64,
334    border: BorderMode<u8, CHANNELS>,
335    output: &mut [u8],
336    workspace: &mut GaussianBlurU8Workspace,
337) -> VisionResult<()> {
338    let len = gaussian_output_len::<CHANNELS>(input.width(), input.height())?;
339    if output.len() != len {
340        return Err(VisionError::ShapeMismatch(format!(
341            "Gaussian output needs {len} elements, found {}",
342            output.len()
343        )));
344    }
345    validate_specialized_gaussian_size(kernel_width)?;
346    validate_specialized_gaussian_size(kernel_height)?;
347    validate_gaussian_sigma(sigma_x)?;
348    validate_gaussian_sigma(sigma_y)?;
349    if len == 0 {
350        return Ok(());
351    }
352    if kernel_width == 7 || kernel_height == 7 {
353        return gaussian_blur_u8_high_precision_into(
354            input,
355            kernel_width,
356            kernel_height,
357            sigma_x,
358            sigma_y,
359            border,
360            output,
361            workspace,
362        );
363    }
364
365    prepare_fixed_gaussian_kernel(
366        kernel_width,
367        sigma_x,
368        &mut workspace.kernel_x_key,
369        &mut workspace.kernel_x,
370    );
371    prepare_fixed_gaussian_kernel(
372        kernel_height,
373        sigma_y,
374        &mut workspace.kernel_y_key,
375        &mut workspace.kernel_y,
376    );
377    workspace.horizontal.resize(len, 0);
378    let row_len = input.width() * CHANNELS;
379    let horizontal = &mut workspace.horizontal;
380    let kernel_x = workspace.kernel_x.as_slice();
381    let arch = Arch::new();
382    if should_parallelize(len, input.height(), LARGE_PARALLEL_COMPONENTS) {
383        gaussian_blur_u8_parallel_bands(
384            arch,
385            input,
386            kernel_x,
387            workspace.kernel_y.as_slice(),
388            border,
389            output,
390            horizontal,
391        );
392        return Ok(());
393    }
394    arch.dispatch(|| {
395        for (y, row) in horizontal.chunks_mut(row_len).enumerate() {
396            gaussian_horizontal_row(input, y, kernel_x, border, row);
397        }
398    });
399
400    let kernel_y = workspace.kernel_y.as_slice();
401    arch.dispatch(|| {
402        for (y, row) in output.chunks_mut(row_len).enumerate() {
403            gaussian_vertical_row(
404                horizontal,
405                input.width(),
406                input.height(),
407                y,
408                kernel_y,
409                border,
410                row,
411            );
412        }
413    });
414    Ok(())
415}
416
417#[allow(clippy::too_many_arguments)]
418fn gaussian_blur_u8_parallel_bands<const CHANNELS: usize>(
419    arch: Arch,
420    input: ImageView<'_, u8, CHANNELS>,
421    kernel_x: &[u16],
422    kernel_y: &[u16],
423    border: BorderMode<u8, CHANNELS>,
424    output: &mut [u8],
425    horizontal: &mut Vec<u16>,
426) {
427    let width = input.width();
428    let height = input.height();
429    let row_len = width * CHANNELS;
430    let workers = bounded_workers(
431        horizontal.len(),
432        height,
433        LARGE_PARALLEL_COMPONENTS,
434        rayon::current_num_threads(),
435    );
436    let rows_per_worker = items_per_worker(height, workers);
437    let radius_y = kernel_y.len() / 2;
438    let scratch_rows = rows_per_worker + radius_y * 2;
439    let scratch_len = scratch_rows * row_len;
440    horizontal.resize(workers * scratch_len, 0);
441    horizontal
442        .par_chunks_mut(scratch_len)
443        .zip(output.par_chunks_mut(rows_per_worker * row_len))
444        .enumerate()
445        .for_each(|(band, (scratch, output_rows))| {
446            let start_y = band * rows_per_worker;
447            let output_row_count = output_rows.len() / row_len;
448            let used_scratch_rows = output_row_count + radius_y * 2;
449            arch.dispatch(|| {
450                for (local_y, row) in
451                    scratch[..used_scratch_rows * row_len].chunks_mut(row_len).enumerate()
452                {
453                    let source_y = start_y as isize + local_y as isize - radius_y as isize;
454                    if let Some(mapped_y) = map_index(source_y, height, border) {
455                        gaussian_horizontal_row(input, mapped_y, kernel_x, border, row);
456                    } else {
457                        let constant = match border {
458                            BorderMode::Constant(pixel) => pixel,
459                            _ => unreachable!("only constant borders can map outside the image"),
460                        };
461                        for pixel in row.chunks_exact_mut(CHANNELS) {
462                            for channel in 0..CHANNELS {
463                                pixel[channel] = u16::from(constant[channel]) * 256;
464                            }
465                        }
466                    }
467                }
468                for (local_y, row) in output_rows.chunks_mut(row_len).enumerate() {
469                    gaussian_vertical_row(
470                        scratch,
471                        width,
472                        used_scratch_rows,
473                        local_y + radius_y,
474                        kernel_y,
475                        BorderMode::<u8, CHANNELS>::Replicate,
476                        row,
477                    );
478                }
479            });
480        });
481}
482
483fn gaussian_output_len<const CHANNELS: usize>(width: usize, height: usize) -> VisionResult<usize> {
484    width
485        .checked_mul(height)
486        .and_then(|pixels| pixels.checked_mul(CHANNELS))
487        .ok_or_else(|| VisionError::InvalidDimensions("Gaussian output size overflows".into()))
488}
489
490fn validate_specialized_gaussian_size(size: usize) -> VisionResult<()> {
491    if !matches!(size, 3 | 5 | 7) {
492        return Err(VisionError::InvalidParameter(
493            "specialized Gaussian kernel size must be 3, 5, or 7".into(),
494        ));
495    }
496    Ok(())
497}
498
499fn validate_gaussian_sigma(sigma: f64) -> VisionResult<()> {
500    if !sigma.is_finite() || sigma <= 0.0 {
501        return Err(VisionError::InvalidParameter(
502            "Gaussian sigma must be finite and positive".into(),
503        ));
504    }
505    Ok(())
506}
507
508const HIGH_PRECISION_GAUSSIAN_BITS: u8 = 15;
509const HIGH_PRECISION_GAUSSIAN_SCALE: u32 = 1 << HIGH_PRECISION_GAUSSIAN_BITS;
510
511#[allow(clippy::too_many_arguments)]
512fn gaussian_blur_u8_high_precision_into<const CHANNELS: usize>(
513    input: ImageView<'_, u8, CHANNELS>,
514    kernel_width: usize,
515    kernel_height: usize,
516    sigma_x: f64,
517    sigma_y: f64,
518    border: BorderMode<u8, CHANNELS>,
519    output: &mut [u8],
520    workspace: &mut GaussianBlurU8Workspace,
521) -> VisionResult<()> {
522    prepare_high_precision_gaussian_kernel(
523        kernel_width,
524        sigma_x,
525        &mut workspace.kernel_x_key,
526        &mut workspace.kernel_x,
527    );
528    prepare_high_precision_gaussian_kernel(
529        kernel_height,
530        sigma_y,
531        &mut workspace.kernel_y_key,
532        &mut workspace.kernel_y,
533    );
534
535    let row_len = input.width() * CHANNELS;
536    let height = input.height();
537    workspace.high_precision_horizontal.resize(output.len(), 0);
538    let horizontal = &mut workspace.high_precision_horizontal;
539    let kernel_x = workspace.kernel_x.as_slice();
540    let workers = bounded_workers(
541        output.len(),
542        height,
543        LARGE_PARALLEL_COMPONENTS,
544        rayon::current_num_threads(),
545    );
546    if workers > 1 {
547        let rows_per_worker = items_per_worker(height, workers);
548        horizontal.par_chunks_mut(rows_per_worker * row_len).enumerate().for_each(
549            |(chunk, rows)| {
550                let start_y = chunk * rows_per_worker;
551                for (offset, row) in rows.chunks_mut(row_len).enumerate() {
552                    gaussian_horizontal_high_precision_row(
553                        input,
554                        start_y + offset,
555                        kernel_x,
556                        border,
557                        row,
558                    );
559                }
560            },
561        );
562    } else {
563        for (y, row) in horizontal.chunks_mut(row_len).enumerate() {
564            gaussian_horizontal_high_precision_row(input, y, kernel_x, border, row);
565        }
566    }
567
568    let kernel_y = workspace.kernel_y.as_slice();
569    if workers > 1 {
570        let rows_per_worker = items_per_worker(height, workers);
571        output.par_chunks_mut(rows_per_worker * row_len).enumerate().for_each(|(chunk, rows)| {
572            let start_y = chunk * rows_per_worker;
573            for (offset, row) in rows.chunks_mut(row_len).enumerate() {
574                gaussian_vertical_high_precision_row(
575                    horizontal,
576                    input.width(),
577                    height,
578                    start_y + offset,
579                    kernel_y,
580                    border,
581                    row,
582                );
583            }
584        });
585    } else {
586        for (y, row) in output.chunks_mut(row_len).enumerate() {
587            gaussian_vertical_high_precision_row(
588                horizontal,
589                input.width(),
590                height,
591                y,
592                kernel_y,
593                border,
594                row,
595            );
596        }
597    }
598    Ok(())
599}
600
601fn prepare_high_precision_gaussian_kernel(
602    size: usize,
603    sigma: f64,
604    cached_key: &mut Option<(usize, u64, u8)>,
605    cached_kernel: &mut Vec<u16>,
606) {
607    let key = (size, sigma.to_bits(), HIGH_PRECISION_GAUSSIAN_BITS);
608    if *cached_key == Some(key) {
609        return;
610    }
611    let center = (size / 2) as f64;
612    let denominator = 2.0 * sigma * sigma;
613    let mut fixed = (0..size)
614        .map(|index| {
615            let offset = index as f64 - center;
616            (-(offset * offset) / denominator).exp()
617        })
618        .collect::<Vec<_>>();
619    let sum = fixed.iter().sum::<f64>();
620    let mut fixed = fixed
621        .drain(..)
622        .map(|weight| (weight * f64::from(HIGH_PRECISION_GAUSSIAN_SCALE) / sum).round() as i32)
623        .collect::<Vec<_>>();
624    let adjustment = HIGH_PRECISION_GAUSSIAN_SCALE as i32 - fixed.iter().sum::<i32>();
625    fixed[size / 2] += adjustment;
626    cached_kernel.clear();
627    cached_kernel.extend(fixed.into_iter().map(|weight| weight as u16));
628    *cached_key = Some(key);
629}
630
631fn gaussian_horizontal_high_precision_row<const CHANNELS: usize>(
632    input: ImageView<'_, u8, CHANNELS>,
633    y: usize,
634    kernel: &[u16],
635    border: BorderMode<u8, CHANNELS>,
636    output: &mut [u32],
637) {
638    let width = input.width();
639    let radius = kernel.len() / 2;
640    let source = input.row(y).expect("Gaussian source row in bounds");
641    let constant = match border {
642        BorderMode::Constant(pixel) => pixel,
643        _ => [0; CHANNELS],
644    };
645    for x in 0..width {
646        let interior = x >= radius && x + radius < width;
647        for channel in 0..CHANNELS {
648            let mut sum = 0_u32;
649            for (tap, &weight) in kernel.iter().enumerate() {
650                let value = if interior {
651                    source[(x + tap - radius) * CHANNELS + channel]
652                } else {
653                    let source_x = x as isize + tap as isize - radius as isize;
654                    map_index(source_x, width, border)
655                        .map_or(constant[channel], |mapped| source[mapped * CHANNELS + channel])
656                };
657                sum += u32::from(value) * u32::from(weight);
658            }
659            output[x * CHANNELS + channel] = sum;
660        }
661    }
662}
663
664fn gaussian_vertical_high_precision_row<const CHANNELS: usize>(
665    horizontal: &[u32],
666    width: usize,
667    height: usize,
668    y: usize,
669    kernel: &[u16],
670    border: BorderMode<u8, CHANNELS>,
671    output: &mut [u8],
672) {
673    let row_len = width * CHANNELS;
674    let radius = kernel.len() / 2;
675    let interior = y >= radius && y + radius < height;
676    let constant = match border {
677        BorderMode::Constant(pixel) => {
678            pixel.map(|value| u32::from(value) * HIGH_PRECISION_GAUSSIAN_SCALE)
679        }
680        _ => [0; CHANNELS],
681    };
682    let round = 1_u64 << (u32::from(HIGH_PRECISION_GAUSSIAN_BITS) * 2 - 1);
683    let shift = u32::from(HIGH_PRECISION_GAUSSIAN_BITS) * 2;
684    for scalar_x in 0..row_len {
685        let channel = scalar_x % CHANNELS;
686        let mut sum = 0_u64;
687        for (tap, &weight) in kernel.iter().enumerate() {
688            let value = if interior {
689                horizontal[(y + tap - radius) * row_len + scalar_x]
690            } else {
691                let source_y = y as isize + tap as isize - radius as isize;
692                map_index(source_y, height, border)
693                    .map_or(constant[channel], |mapped| horizontal[mapped * row_len + scalar_x])
694            };
695            sum += u64::from(value) * u64::from(weight);
696        }
697        output[scalar_x] = ((sum + round) >> shift).min(255) as u8;
698    }
699}
700
701fn prepare_fixed_gaussian_kernel(
702    size: usize,
703    sigma: f64,
704    cached_key: &mut Option<(usize, u64, u8)>,
705    cached_kernel: &mut Vec<u16>,
706) {
707    let key = (size, sigma.to_bits(), 8);
708    if *cached_key == Some(key) {
709        return;
710    }
711    let center = (size / 2) as f64;
712    let denominator = 2.0 * sigma * sigma;
713    let mut weights = (0..size)
714        .map(|index| {
715            let offset = index as f64 - center;
716            (-(offset * offset) / denominator).exp()
717        })
718        .collect::<Vec<_>>();
719    let sum = weights.iter().sum::<f64>();
720    let mut fixed =
721        weights.drain(..).map(|weight| (weight * 256.0 / sum).round() as i32).collect::<Vec<_>>();
722    let adjustment = 256 - fixed.iter().sum::<i32>();
723    fixed[size / 2] += adjustment;
724    cached_kernel.clear();
725    cached_kernel.extend(fixed.into_iter().map(|weight| weight as u16));
726    *cached_key = Some(key);
727}
728
729#[inline(always)]
730fn gaussian_horizontal_row<const CHANNELS: usize>(
731    input: ImageView<'_, u8, CHANNELS>,
732    y: usize,
733    kernel: &[u16],
734    border: BorderMode<u8, CHANNELS>,
735    output: &mut [u16],
736) {
737    let width = input.width();
738    let radius = kernel.len() / 2;
739    let source = input.row(y).expect("Gaussian source row in bounds");
740    let constant = match border {
741        BorderMode::Constant(pixel) => pixel,
742        _ => [0; CHANNELS],
743    };
744    if width <= radius * 2 {
745        for x in 0..width {
746            gaussian_horizontal_border_pixel(source, width, x, kernel, border, constant, output);
747        }
748        return;
749    }
750    for x in 0..radius {
751        gaussian_horizontal_border_pixel(source, width, x, kernel, border, constant, output);
752    }
753    for x in width - radius..width {
754        gaussian_horizontal_border_pixel(source, width, x, kernel, border, constant, output);
755    }
756
757    let start = radius * CHANNELS;
758    let end = (width - radius) * CHANNELS;
759    match kernel {
760        [outer, center, _] => {
761            let (outer, center) = (u32::from(*outer), u32::from(*center));
762            for index in start..end {
763                output[index] = (u32::from(source[index]) * center
764                    + (u32::from(source[index - CHANNELS]) + u32::from(source[index + CHANNELS]))
765                        * outer) as u16;
766            }
767        }
768        [outer, inner, center, _, _] => {
769            let (outer, inner, center) = (u32::from(*outer), u32::from(*inner), u32::from(*center));
770            for index in start..end {
771                output[index] = (u32::from(source[index]) * center
772                    + (u32::from(source[index - CHANNELS]) + u32::from(source[index + CHANNELS]))
773                        * inner
774                    + (u32::from(source[index - 2 * CHANNELS])
775                        + u32::from(source[index + 2 * CHANNELS]))
776                        * outer) as u16;
777            }
778        }
779        [outer, middle, inner, center, _, _, _] => {
780            let (outer, middle, inner, center) =
781                (u32::from(*outer), u32::from(*middle), u32::from(*inner), u32::from(*center));
782            for index in start..end {
783                output[index] = (u32::from(source[index]) * center
784                    + (u32::from(source[index - CHANNELS]) + u32::from(source[index + CHANNELS]))
785                        * inner
786                    + (u32::from(source[index - 2 * CHANNELS])
787                        + u32::from(source[index + 2 * CHANNELS]))
788                        * middle
789                    + (u32::from(source[index - 3 * CHANNELS])
790                        + u32::from(source[index + 3 * CHANNELS]))
791                        * outer) as u16;
792            }
793        }
794        _ => unreachable!("specialized Gaussian kernel is validated"),
795    }
796}
797
798fn gaussian_horizontal_border_pixel<const CHANNELS: usize>(
799    source: &[u8],
800    width: usize,
801    x: usize,
802    kernel: &[u16],
803    border: BorderMode<u8, CHANNELS>,
804    constant: [u8; CHANNELS],
805    output: &mut [u16],
806) {
807    let radius = kernel.len() / 2;
808    for channel in 0..CHANNELS {
809        let mut sum = 0_u32;
810        for (tap, &weight) in kernel.iter().enumerate() {
811            let source_x = x as isize + tap as isize - radius as isize;
812            let value = map_index(source_x, width, border)
813                .map_or(constant[channel], |mapped| source[mapped * CHANNELS + channel]);
814            sum += u32::from(value) * u32::from(weight);
815        }
816        output[x * CHANNELS + channel] = sum as u16;
817    }
818}
819
820#[inline(always)]
821fn gaussian_round_u8(sum: u32) -> u8 {
822    ((sum + 32_768) >> 16).min(255) as u8
823}
824
825fn gaussian_vertical_border_row<const CHANNELS: usize>(
826    horizontal: &[u16],
827    width: usize,
828    height: usize,
829    y: usize,
830    kernel: &[u16],
831    border: BorderMode<u8, CHANNELS>,
832    output: &mut [u8],
833) {
834    let row_len = width * CHANNELS;
835    let radius = kernel.len() / 2;
836    let constant = match border {
837        BorderMode::Constant(pixel) => pixel.map(|value| u32::from(value) * 256),
838        _ => [0; CHANNELS],
839    };
840    for scalar_x in 0..row_len {
841        let channel = scalar_x % CHANNELS;
842        let mut sum = 0_u32;
843        for (tap, &weight) in kernel.iter().enumerate() {
844            let source_y = y as isize + tap as isize - radius as isize;
845            let value = map_index(source_y, height, border).map_or(constant[channel], |mapped| {
846                u32::from(horizontal[mapped * row_len + scalar_x])
847            });
848            sum += value * u32::from(weight);
849        }
850        output[scalar_x] = gaussian_round_u8(sum);
851    }
852}
853
854#[allow(clippy::too_many_arguments)]
855#[inline(always)]
856fn gaussian_vertical_row<const CHANNELS: usize>(
857    horizontal: &[u16],
858    width: usize,
859    height: usize,
860    y: usize,
861    kernel: &[u16],
862    border: BorderMode<u8, CHANNELS>,
863    output: &mut [u8],
864) {
865    let row_len = width * CHANNELS;
866    let radius = kernel.len() / 2;
867    if y < radius || y + radius >= height {
868        gaussian_vertical_border_row(horizontal, width, height, y, kernel, border, output);
869        return;
870    }
871    match kernel {
872        [outer, center, _] => {
873            let (outer, center) = (u32::from(*outer), u32::from(*center));
874            let above = (y - 1) * row_len;
875            let current = y * row_len;
876            let below = (y + 1) * row_len;
877            for index in 0..row_len {
878                output[index] = gaussian_round_u8(
879                    u32::from(horizontal[current + index]) * center
880                        + (u32::from(horizontal[above + index])
881                            + u32::from(horizontal[below + index]))
882                            * outer,
883                );
884            }
885        }
886        [outer, inner, center, _, _] => {
887            let (outer, inner, center) = (u32::from(*outer), u32::from(*inner), u32::from(*center));
888            let row0 = (y - 2) * row_len;
889            let row1 = (y - 1) * row_len;
890            let row2 = y * row_len;
891            let row3 = (y + 1) * row_len;
892            let row4 = (y + 2) * row_len;
893            for index in 0..row_len {
894                output[index] = gaussian_round_u8(
895                    u32::from(horizontal[row2 + index]) * center
896                        + (u32::from(horizontal[row1 + index])
897                            + u32::from(horizontal[row3 + index]))
898                            * inner
899                        + (u32::from(horizontal[row0 + index])
900                            + u32::from(horizontal[row4 + index]))
901                            * outer,
902                );
903            }
904        }
905        [outer, middle, inner, center, _, _, _] => {
906            let (outer, middle, inner, center) =
907                (u32::from(*outer), u32::from(*middle), u32::from(*inner), u32::from(*center));
908            let row0 = (y - 3) * row_len;
909            let row1 = (y - 2) * row_len;
910            let row2 = (y - 1) * row_len;
911            let row3 = y * row_len;
912            let row4 = (y + 1) * row_len;
913            let row5 = (y + 2) * row_len;
914            let row6 = (y + 3) * row_len;
915            for index in 0..row_len {
916                output[index] = gaussian_round_u8(
917                    u32::from(horizontal[row3 + index]) * center
918                        + (u32::from(horizontal[row2 + index])
919                            + u32::from(horizontal[row4 + index]))
920                            * inner
921                        + (u32::from(horizontal[row1 + index])
922                            + u32::from(horizontal[row5 + index]))
923                            * middle
924                        + (u32::from(horizontal[row0 + index])
925                            + u32::from(horizontal[row6 + index]))
926                            * outer,
927                );
928            }
929        }
930        _ => unreachable!("specialized Gaussian kernel is validated"),
931    }
932}
933
934fn gaussian_kernel(size: usize, sigma: f64) -> VisionResult<Kernel1D> {
935    if size == 0 || size % 2 == 0 {
936        return Err(VisionError::InvalidParameter(
937            "Gaussian kernel size must be positive and odd".into(),
938        ));
939    }
940    if !sigma.is_finite() || sigma <= 0.0 {
941        return Err(VisionError::InvalidParameter(
942            "Gaussian sigma must be finite and positive".into(),
943        ));
944    }
945    let center = (size / 2) as f64;
946    let denominator = 2.0 * sigma * sigma;
947    let mut coefficients = (0..size)
948        .map(|index| {
949            let offset = index as f64 - center;
950            (-(offset * offset) / denominator).exp()
951        })
952        .collect::<Vec<_>>();
953    let sum = coefficients.iter().sum::<f64>();
954    for value in &mut coefficients {
955        *value /= sum;
956    }
957    Kernel1D::try_new(coefficients)
958}
959
960fn validate_delta(delta: f64) -> VisionResult<()> {
961    if !delta.is_finite() {
962        return Err(VisionError::InvalidParameter("filter delta must be finite".into()));
963    }
964    Ok(())
965}
966
967fn correlate<T: PixelComponent, const CHANNELS: usize>(
968    input: ImageView<'_, T, CHANNELS>,
969    kernel: &Kernel2D,
970    delta: f64,
971    border: BorderMode<T, CHANNELS>,
972) -> Vec<f64> {
973    let mut output = Vec::with_capacity(input.width() * input.height() * CHANNELS);
974    for y in 0..input.height() {
975        for x in 0..input.width() {
976            let mut sums = [delta; CHANNELS];
977            for ky in 0..kernel.height {
978                for kx in 0..kernel.width {
979                    let pixel = fetch(
980                        input,
981                        x as isize + kx as isize - kernel.anchor_x as isize,
982                        y as isize + ky as isize - kernel.anchor_y as isize,
983                        border,
984                    );
985                    let weight = kernel.coefficients[ky * kernel.width + kx];
986                    for channel in 0..CHANNELS {
987                        sums[channel] += pixel[channel].to_f64() * weight;
988                    }
989                }
990            }
991            output.extend_from_slice(&sums);
992        }
993    }
994    output
995}
996
997fn separable_accumulators<T: PixelComponent, const CHANNELS: usize>(
998    input: ImageView<'_, T, CHANNELS>,
999    kernel_x: &Kernel1D,
1000    kernel_y: &Kernel1D,
1001    delta: f64,
1002    border: BorderMode<T, CHANNELS>,
1003) -> VisionResult<Vec<f64>> {
1004    validate_delta(delta)?;
1005    let mut horizontal = vec![0.0; input.width() * input.height() * CHANNELS];
1006    for y in 0..input.height() {
1007        for x in 0..input.width() {
1008            for (kx, &weight) in kernel_x.coefficients.iter().enumerate() {
1009                let pixel = fetch(
1010                    input,
1011                    x as isize + kx as isize - kernel_x.anchor as isize,
1012                    y as isize,
1013                    border,
1014                );
1015                for channel in 0..CHANNELS {
1016                    horizontal[(y * input.width() + x) * CHANNELS + channel] +=
1017                        pixel[channel].to_f64() * weight;
1018                }
1019            }
1020        }
1021    }
1022
1023    let constant_horizontal = match border {
1024        BorderMode::Constant(pixel) => {
1025            let sum_x = kernel_x.coefficients.iter().sum::<f64>();
1026            std::array::from_fn(|channel| pixel[channel].to_f64() * sum_x)
1027        }
1028        _ => [0.0; CHANNELS],
1029    };
1030    let mut output = Vec::with_capacity(horizontal.len());
1031    for y in 0..input.height() {
1032        for x in 0..input.width() {
1033            let mut sums = [delta; CHANNELS];
1034            for (ky, &weight) in kernel_y.coefficients.iter().enumerate() {
1035                let source_y = y as isize + ky as isize - kernel_y.anchor as isize;
1036                if let Some(mapped_y) = map_index(source_y, input.height(), border) {
1037                    let offset = (mapped_y * input.width() + x) * CHANNELS;
1038                    for channel in 0..CHANNELS {
1039                        sums[channel] += horizontal[offset + channel] * weight;
1040                    }
1041                } else {
1042                    for channel in 0..CHANNELS {
1043                        sums[channel] += constant_horizontal[channel] * weight;
1044                    }
1045                }
1046            }
1047            output.extend_from_slice(&sums);
1048        }
1049    }
1050    Ok(output)
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::{
1056        box_blur, convolve2d, filter2d, gaussian_blur, gaussian_blur_u8, gaussian_blur_u8_into,
1057        separable_filter, GaussianBlurU8Workspace, Kernel1D, Kernel2D,
1058    };
1059    use crate::BorderMode;
1060    use proptest::prelude::*;
1061    use spatialrust_image::{Image, ImageRegion, ImageView};
1062
1063    #[test]
1064    fn filter2d_is_correlation_and_convolution_reverses() {
1065        let image = Image::<u8, 1>::try_new(3, 1, vec![1, 2, 4]).unwrap();
1066        let kernel = Kernel2D::try_new_with_anchor(2, 1, 0, 0, vec![1.0, 10.0]).unwrap();
1067        let correlation = filter2d(image.view(), &kernel, 0.0, BorderMode::Replicate).unwrap();
1068        let convolution = convolve2d(image.view(), &kernel, 0.0, BorderMode::Replicate).unwrap();
1069        assert_eq!(correlation.as_slice(), &[21, 42, 44]);
1070        assert_eq!(convolution.as_slice(), &[11, 12, 24]);
1071    }
1072
1073    #[test]
1074    fn separable_matches_outer_product_with_constant_border() {
1075        let image = Image::<u8, 1>::try_new(2, 2, vec![1, 2, 3, 4]).unwrap();
1076        let x = Kernel1D::try_new(vec![0.25, 0.5, 0.25]).unwrap();
1077        let y = Kernel1D::try_new(vec![0.25, 0.5, 0.25]).unwrap();
1078        let kernel = Kernel2D::try_new(
1079            3,
1080            3,
1081            vec![0.0625, 0.125, 0.0625, 0.125, 0.25, 0.125, 0.0625, 0.125, 0.0625],
1082        )
1083        .unwrap();
1084        let expected = filter2d(image.view(), &kernel, 0.0, BorderMode::Constant([9])).unwrap();
1085        let actual =
1086            separable_filter(image.view(), &x, &y, 0.0, BorderMode::Constant([9])).unwrap();
1087        assert_eq!(actual, expected);
1088    }
1089
1090    #[test]
1091    fn roi_and_packed_filter_results_match() {
1092        let parent = Image::<u8, 1>::try_new(5, 3, (0..15).collect()).unwrap();
1093        let roi = parent.view().subview(ImageRegion::new(1, 1, 3, 2)).unwrap();
1094        let packed = Image::<u8, 1>::try_new(3, 2, vec![6, 7, 8, 11, 12, 13]).unwrap();
1095        let kernel = Kernel2D::try_new(3, 1, vec![0.25, 0.5, 0.25]).unwrap();
1096        assert_eq!(
1097            filter2d(roi, &kernel, 0.0, BorderMode::Reflect101).unwrap(),
1098            filter2d(packed.view(), &kernel, 0.0, BorderMode::Reflect101).unwrap()
1099        );
1100    }
1101
1102    #[test]
1103    fn normalized_blurs_preserve_constant_images() {
1104        let image = Image::<f32, 3>::from_pixel(4, 3, [2.0, 4.0, 8.0]).unwrap();
1105        let box_output = box_blur(image.view(), 4, 2, BorderMode::Replicate).unwrap();
1106        let gaussian = gaussian_blur(image.view(), 5, 3, 1.2, 0.8, BorderMode::Reflect101).unwrap();
1107        assert_eq!(box_output, image);
1108        for (actual, expected) in gaussian.as_slice().iter().zip(image.as_slice()) {
1109            assert!((actual - expected).abs() < 1e-5);
1110        }
1111    }
1112
1113    #[test]
1114    fn specialized_gaussian_matches_generic_for_strided_rgb_input() {
1115        let width = 17;
1116        let height = 11;
1117        let stride = width * 3 + 7;
1118        let mut storage = vec![199_u8; stride * height];
1119        for y in 0..height {
1120            for x in 0..width {
1121                for channel in 0..3 {
1122                    storage[y * stride + x * 3 + channel] =
1123                        ((x * 31 + y * 17 + channel * 73) & 255) as u8;
1124                }
1125            }
1126        }
1127        let view = spatialrust_image::ImageView::new(width, height, stride, &storage).unwrap();
1128        for &(size, sigma) in &[(3, 0.8), (5, 1.2), (7, 2.0)] {
1129            for border in [
1130                BorderMode::Replicate,
1131                BorderMode::Reflect,
1132                BorderMode::Reflect101,
1133                BorderMode::Wrap,
1134                BorderMode::Constant([11, 23, 47]),
1135            ] {
1136                let expected = gaussian_blur(view, size, size, sigma, sigma, border).unwrap();
1137                let actual = gaussian_blur_u8(view, size, size, sigma, sigma, border).unwrap();
1138                assert!(expected
1139                    .as_slice()
1140                    .iter()
1141                    .zip(actual.as_slice())
1142                    .all(|(&left, &right)| left.abs_diff(right) <= 1));
1143            }
1144        }
1145    }
1146
1147    #[test]
1148    fn specialized_gaussian_reuses_workspace_and_validates_output() {
1149        let image =
1150            Image::<u8, 3>::try_new(8, 6, (0..144).map(|value| value as u8).collect()).unwrap();
1151        let mut output = vec![0; 8 * 6 * 3];
1152        let mut workspace = GaussianBlurU8Workspace::new();
1153        gaussian_blur_u8_into(
1154            image.view(),
1155            5,
1156            5,
1157            1.2,
1158            1.2,
1159            BorderMode::Reflect101,
1160            &mut output,
1161            &mut workspace,
1162        )
1163        .unwrap();
1164        let capacity = workspace.capacity();
1165        let allocated_bytes = workspace.allocated_bytes();
1166        assert!(allocated_bytes > 0);
1167        gaussian_blur_u8_into(
1168            image.view(),
1169            5,
1170            5,
1171            1.2,
1172            1.2,
1173            BorderMode::Reflect101,
1174            &mut output,
1175            &mut workspace,
1176        )
1177        .unwrap();
1178        assert_eq!(workspace.capacity(), capacity);
1179        assert_eq!(workspace.allocated_bytes(), allocated_bytes);
1180        assert!(gaussian_blur_u8_into(
1181            image.view(),
1182            5,
1183            5,
1184            1.2,
1185            1.2,
1186            BorderMode::Reflect101,
1187            &mut output[..10],
1188            &mut workspace,
1189        )
1190        .is_err());
1191        assert!(gaussian_blur_u8(image.view(), 9, 5, 1.2, 1.2, BorderMode::Reflect101,).is_err());
1192    }
1193
1194    #[test]
1195    fn specialized_gaussian_parallel_bands_match_generic_across_seams() {
1196        let width = 600;
1197        let height = 600;
1198        let stride = width * 3 + 7;
1199        let mut storage = vec![199_u8; stride * height];
1200        for y in 0..height {
1201            for x in 0..width {
1202                for channel in 0..3 {
1203                    storage[y * stride + x * 3 + channel] =
1204                        ((x * 31 + y * 17 + channel * 73) & 255) as u8;
1205                }
1206            }
1207        }
1208        let view = ImageView::<u8, 3>::new(width, height, stride, &storage).unwrap();
1209        let expected = gaussian_blur(view, 5, 5, 1.2, 1.2, BorderMode::Reflect101).unwrap();
1210        let actual = gaussian_blur_u8(view, 5, 5, 1.2, 1.2, BorderMode::Reflect101).unwrap();
1211        assert!(expected
1212            .as_slice()
1213            .iter()
1214            .zip(actual.as_slice())
1215            .all(|(&left, &right)| left.abs_diff(right) <= 1));
1216
1217        let constant = Image::<u8, 3>::from_pixel(width, height, [11, 23, 47]).unwrap();
1218        let constant_blur =
1219            gaussian_blur_u8(constant.view(), 5, 5, 1.2, 1.2, BorderMode::Constant([11, 23, 47]))
1220                .unwrap();
1221        assert_eq!(constant_blur, constant);
1222    }
1223
1224    proptest! {
1225        #![proptest_config(ProptestConfig::with_cases(300))]
1226
1227        #[test]
1228        fn high_precision_7x7_matches_generic_with_strided_input(
1229            width in 1_usize..33,
1230            height in 1_usize..33,
1231            padding in 0_usize..8,
1232            sigma_x in 0.3_f64..4.0,
1233            sigma_y in 0.3_f64..4.0,
1234            seed in any::<u64>(),
1235        ) {
1236            let stride = width * 3 + padding;
1237            let mut storage = vec![173_u8; stride * height];
1238            let mut state = seed;
1239            for y in 0..height {
1240                for value in &mut storage[y * stride..y * stride + width * 3] {
1241                    state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1242                    *value = (state >> 32) as u8;
1243                }
1244            }
1245            let input = ImageView::<u8, 3>::new(width, height, stride, &storage).unwrap();
1246            let expected = gaussian_blur(
1247                input,
1248                7,
1249                7,
1250                sigma_x,
1251                sigma_y,
1252                BorderMode::Reflect101,
1253            )
1254            .unwrap();
1255            let mut output = vec![0_u8; width * height * 3];
1256            let mut workspace = GaussianBlurU8Workspace::new();
1257            gaussian_blur_u8_into(
1258                input,
1259                7,
1260                7,
1261                sigma_x,
1262                sigma_y,
1263                BorderMode::Reflect101,
1264                &mut output,
1265                &mut workspace,
1266            )
1267            .unwrap();
1268            prop_assert!(expected
1269                .as_slice()
1270                .iter()
1271                .zip(&output)
1272                .all(|(&left, &right)| left.abs_diff(right) <= 1));
1273            let capacity = workspace.capacity();
1274            gaussian_blur_u8_into(
1275                input,
1276                7,
1277                7,
1278                sigma_x,
1279                sigma_y,
1280                BorderMode::Reflect101,
1281                &mut output,
1282                &mut workspace,
1283            )
1284            .unwrap();
1285            prop_assert_eq!(workspace.capacity(), capacity);
1286        }
1287    }
1288
1289    #[test]
1290    fn invalid_kernels_are_rejected() {
1291        assert!(Kernel2D::try_new(0, 1, Vec::new()).is_err());
1292        assert!(Kernel2D::try_new(2, 2, vec![1.0; 3]).is_err());
1293        assert!(Kernel1D::try_new(vec![f64::NAN]).is_err());
1294    }
1295}