Skip to main content

scirs2_ndimage/
convolution3d.rs

1//! 3D Convolution and Volumetric Filtering
2//!
3//! This module provides 3D convolution and cross-correlation, separable Gaussian
4//! filtering, box filtering, non-separable median filtering, and Laplacian-of-Gaussian
5//! (LoG) blob detection for volumetric (`Array3<f64>`) data.
6//!
7//! # Border / Padding Modes
8//!
9//! Three padding strategies are available via [`Padding3D`]:
10//!
11//! | Variant     | Behaviour at the boundary                        |
12//! |-------------|--------------------------------------------------|
13//! | `Zero`      | Out-of-bounds indices map to 0.0                 |
14//! | `Reflect`   | Mirror reflection around the edge voxel          |
15//! | `Replicate` | Clamp-to-edge (nearest-neighbour extrapolation)  |
16//!
17//! # Separable Filter Optimization
18//!
19//! Filters that are separable (Gaussian, uniform/box) are implemented as three
20//! successive 1D convolutions along z, y, and x.  This reduces the per-voxel
21//! work from O(k³) to O(3k) where k is the 1D kernel half-width.
22
23use crate::error::{NdimageError, NdimageResult};
24use scirs2_core::ndarray::{Array1, Array3};
25
26// ─────────────────────────────────────────────────────────────────────────────
27// Padding type
28// ─────────────────────────────────────────────────────────────────────────────
29
30/// Padding strategy for 3D convolution / filtering operations.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Padding3D {
33    /// Zero-pad: out-of-bounds voxels are treated as 0.0.
34    Zero,
35    /// Reflect: mirror the volume around the boundary.
36    ///
37    /// For an axis of length N the index `i` outside `[0, N-1]` is mapped to
38    /// the reflected position using the rule `reflect(i, N)`.
39    Reflect,
40    /// Replicate (clamp-to-edge): clamp the index to `[0, N-1]`.
41    Replicate,
42}
43
44// ─────────────────────────────────────────────────────────────────────────────
45// Index resolution helpers
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// Reflect an out-of-range index `i` into `[0, n-1]`.
49///
50/// Uses the "edge-inclusive" formula:  ...3 2 1 0 1 2 3...
51#[inline]
52fn reflect_index(i: isize, n: usize) -> Option<usize> {
53    if n == 0 {
54        return None;
55    }
56    let n = n as isize;
57    // Fold the index into the periodic double-length domain.
58    let period = 2 * n - 2;
59    if period <= 0 {
60        return Some(0); // single-element axis
61    }
62    // Reduce to [0, period)
63    let mut r = i % period;
64    if r < 0 {
65        r += period;
66    }
67    // Unfold second half
68    if r >= n {
69        r = period - r;
70    }
71    Some(r as usize)
72}
73
74/// Resolve a raw (possibly out-of-range) index according to the padding mode.
75///
76/// Returns `None` when the mode is `Zero` and the index is out of bounds.
77#[inline]
78fn resolve_index(i: isize, n: usize, padding: Padding3D) -> Option<usize> {
79    if i >= 0 && (i as usize) < n {
80        return Some(i as usize);
81    }
82    match padding {
83        Padding3D::Zero => None,
84        Padding3D::Replicate => {
85            if n == 0 {
86                None
87            } else {
88                Some(i.clamp(0, n as isize - 1) as usize)
89            }
90        }
91        Padding3D::Reflect => reflect_index(i, n),
92    }
93}
94
95/// Read a voxel from `volume`, applying `padding` for out-of-bounds coordinates.
96#[inline]
97fn get_padded(volume: &Array3<f64>, z: isize, y: isize, x: isize, padding: Padding3D) -> f64 {
98    let shape = volume.shape();
99    let oz = resolve_index(z, shape[0], padding);
100    let oy = resolve_index(y, shape[1], padding);
101    let ox = resolve_index(x, shape[2], padding);
102    match (oz, oy, ox) {
103        (Some(rz), Some(ry), Some(rx)) => volume[[rz, ry, rx]],
104        _ => 0.0, // Zero mode, or zero-volume axis
105    }
106}
107
108// ─────────────────────────────────────────────────────────────────────────────
109// Full 3D convolution / cross-correlation
110// ─────────────────────────────────────────────────────────────────────────────
111
112/// 3D discrete convolution.
113///
114/// Convolves `volume` with `kernel` using the chosen `padding` strategy.
115/// The kernel is flipped (mirrored) along all three axes before sliding, which
116/// is the definition of mathematical convolution.
117///
118/// # Arguments
119///
120/// * `volume`  - Input 3D array.
121/// * `kernel`  - Convolution kernel (any odd-or-even size is accepted).
122/// * `padding` - Border handling strategy.
123///
124/// # Returns
125///
126/// An `Array3<f64>` with the same shape as `volume`.
127///
128/// # Errors
129///
130/// Returns [`NdimageError::InvalidInput`] if the kernel has zero extent on any axis.
131///
132/// # Example
133///
134/// ```
135/// use scirs2_ndimage::convolution3d::{convolve3d, Padding3D};
136/// use scirs2_core::ndarray::Array3;
137///
138/// let vol = Array3::<f64>::ones((8, 8, 8));
139/// let kernel = Array3::<f64>::ones((3, 3, 3));
140/// let out = convolve3d(&vol, &kernel, Padding3D::Zero).unwrap();
141/// assert_eq!(out.shape(), [8, 8, 8]);
142/// ```
143pub fn convolve3d(
144    volume: &Array3<f64>,
145    kernel: &Array3<f64>,
146    padding: Padding3D,
147) -> NdimageResult<Array3<f64>> {
148    let ks = kernel.shape();
149    if ks[0] == 0 || ks[1] == 0 || ks[2] == 0 {
150        return Err(NdimageError::InvalidInput(
151            "kernel must have non-zero extent on all axes".to_string(),
152        ));
153    }
154    let vs = volume.shape();
155    let (sz, sy, sx) = (vs[0], vs[1], vs[2]);
156    let (kz, ky, kx) = (ks[0], ks[1], ks[2]);
157    let hz = (kz / 2) as isize;
158    let hy = (ky / 2) as isize;
159    let hx = (kx / 2) as isize;
160
161    let mut out = Array3::zeros((sz, sy, sx));
162
163    for iz in 0..sz {
164        for iy in 0..sy {
165            for ix in 0..sx {
166                let mut acc = 0.0f64;
167                for dkz in 0..kz {
168                    for dky in 0..ky {
169                        for dkx in 0..kx {
170                            // Flipped kernel indices (convolution vs correlation)
171                            let fkz = kz - 1 - dkz;
172                            let fky = ky - 1 - dky;
173                            let fkx = kx - 1 - dkx;
174                            let kval = kernel[[fkz, fky, fkx]];
175                            let vz = iz as isize + dkz as isize - hz;
176                            let vy = iy as isize + dky as isize - hy;
177                            let vx = ix as isize + dkx as isize - hx;
178                            acc += kval * get_padded(volume, vz, vy, vx, padding);
179                        }
180                    }
181                }
182                out[[iz, iy, ix]] = acc;
183            }
184        }
185    }
186    Ok(out)
187}
188
189/// 3D cross-correlation.
190///
191/// Slides `kernel` over `volume` **without** flipping it (unlike [`convolve3d`]).
192/// This is the operation commonly called "convolution" in machine-learning
193/// frameworks.
194///
195/// # Arguments
196///
197/// * `volume`  - Input 3D array.
198/// * `kernel`  - Correlation template.
199/// * `padding` - Border handling strategy.
200///
201/// # Returns
202///
203/// An `Array3<f64>` with the same shape as `volume`.
204///
205/// # Errors
206///
207/// Returns [`NdimageError::InvalidInput`] if the kernel has zero extent on any axis.
208///
209/// # Example
210///
211/// ```
212/// use scirs2_ndimage::convolution3d::{correlate3d, Padding3D};
213/// use scirs2_core::ndarray::Array3;
214///
215/// let vol = Array3::<f64>::ones((6, 6, 6));
216/// let kernel = Array3::<f64>::ones((3, 3, 3));
217/// let out = correlate3d(&vol, &kernel, Padding3D::Replicate).unwrap();
218/// assert_eq!(out.shape(), [6, 6, 6]);
219/// ```
220pub fn correlate3d(
221    volume: &Array3<f64>,
222    kernel: &Array3<f64>,
223    padding: Padding3D,
224) -> NdimageResult<Array3<f64>> {
225    let ks = kernel.shape();
226    if ks[0] == 0 || ks[1] == 0 || ks[2] == 0 {
227        return Err(NdimageError::InvalidInput(
228            "kernel must have non-zero extent on all axes".to_string(),
229        ));
230    }
231    let vs = volume.shape();
232    let (sz, sy, sx) = (vs[0], vs[1], vs[2]);
233    let (kz, ky, kx) = (ks[0], ks[1], ks[2]);
234    let hz = (kz / 2) as isize;
235    let hy = (ky / 2) as isize;
236    let hx = (kx / 2) as isize;
237
238    let mut out = Array3::zeros((sz, sy, sx));
239
240    for iz in 0..sz {
241        for iy in 0..sy {
242            for ix in 0..sx {
243                let mut acc = 0.0f64;
244                for dkz in 0..kz {
245                    for dky in 0..ky {
246                        for dkx in 0..kx {
247                            let kval = kernel[[dkz, dky, dkx]];
248                            let vz = iz as isize + dkz as isize - hz;
249                            let vy = iy as isize + dky as isize - hy;
250                            let vx = ix as isize + dkx as isize - hx;
251                            acc += kval * get_padded(volume, vz, vy, vx, padding);
252                        }
253                    }
254                }
255                out[[iz, iy, ix]] = acc;
256            }
257        }
258    }
259    Ok(out)
260}
261
262// ─────────────────────────────────────────────────────────────────────────────
263// Separable 1D kernel helpers
264// ─────────────────────────────────────────────────────────────────────────────
265
266/// Apply a 1D kernel along the Z axis with the given padding mode.
267fn convolve1d_z(src: &Array3<f64>, kernel: &Array1<f64>, padding: Padding3D) -> Array3<f64> {
268    let (sz, sy, sx) = (src.shape()[0], src.shape()[1], src.shape()[2]);
269    let klen = kernel.len();
270    let half = (klen / 2) as isize;
271    let mut out = Array3::zeros((sz, sy, sx));
272    for iz in 0..sz {
273        for iy in 0..sy {
274            for ix in 0..sx {
275                let mut acc = 0.0f64;
276                for (ki, &kv) in kernel.iter().enumerate() {
277                    let nz = iz as isize + ki as isize - half;
278                    acc += kv * get_padded(src, nz, iy as isize, ix as isize, padding);
279                }
280                out[[iz, iy, ix]] = acc;
281            }
282        }
283    }
284    out
285}
286
287/// Apply a 1D kernel along the Y axis with the given padding mode.
288fn convolve1d_y(src: &Array3<f64>, kernel: &Array1<f64>, padding: Padding3D) -> Array3<f64> {
289    let (sz, sy, sx) = (src.shape()[0], src.shape()[1], src.shape()[2]);
290    let klen = kernel.len();
291    let half = (klen / 2) as isize;
292    let mut out = Array3::zeros((sz, sy, sx));
293    for iz in 0..sz {
294        for iy in 0..sy {
295            for ix in 0..sx {
296                let mut acc = 0.0f64;
297                for (ki, &kv) in kernel.iter().enumerate() {
298                    let ny = iy as isize + ki as isize - half;
299                    acc += kv * get_padded(src, iz as isize, ny, ix as isize, padding);
300                }
301                out[[iz, iy, ix]] = acc;
302            }
303        }
304    }
305    out
306}
307
308/// Apply a 1D kernel along the X axis with the given padding mode.
309fn convolve1d_x(src: &Array3<f64>, kernel: &Array1<f64>, padding: Padding3D) -> Array3<f64> {
310    let (sz, sy, sx) = (src.shape()[0], src.shape()[1], src.shape()[2]);
311    let klen = kernel.len();
312    let half = (klen / 2) as isize;
313    let mut out = Array3::zeros((sz, sy, sx));
314    for iz in 0..sz {
315        for iy in 0..sy {
316            for ix in 0..sx {
317                let mut acc = 0.0f64;
318                for (ki, &kv) in kernel.iter().enumerate() {
319                    let nx = ix as isize + ki as isize - half;
320                    acc += kv * get_padded(src, iz as isize, iy as isize, nx, padding);
321                }
322                out[[iz, iy, ix]] = acc;
323            }
324        }
325    }
326    out
327}
328
329// ─────────────────────────────────────────────────────────────────────────────
330// Gaussian filter
331// ─────────────────────────────────────────────────────────────────────────────
332
333/// Build a normalised 1D Gaussian kernel.
334///
335/// The kernel spans `2 * ceil(truncate * sigma) + 1` elements.
336fn gaussian_kernel1d(sigma: f64, truncate: f64) -> Array1<f64> {
337    let radius = (truncate * sigma).ceil() as usize;
338    let size = 2 * radius + 1;
339    let mut k = Array1::zeros(size);
340    let two_s2 = 2.0 * sigma * sigma;
341    let mut sum = 0.0f64;
342    for i in 0..size {
343        let x = i as f64 - radius as f64;
344        let v = (-x * x / two_s2).exp();
345        k[i] = v;
346        sum += v;
347    }
348    if sum != 0.0 {
349        k.mapv_inplace(|v| v / sum);
350    }
351    k
352}
353
354/// 3D separable Gaussian filter with per-axis sigma values.
355///
356/// The filter is applied as three independent 1D convolutions along z, y, and x.
357/// This is mathematically equivalent to a full 3D Gaussian convolution but runs
358/// in O(N · k) rather than O(N · k³) time.
359///
360/// # Arguments
361///
362/// * `volume`  - Input volumetric array.
363/// * `sigma`   - Standard deviations `[sz, sy, sx]` (all must be > 0).
364/// * `padding` - Border handling.  Defaults to `Replicate` if not specified;
365///               this function always requires an explicit choice.
366///
367/// # Errors
368///
369/// Returns [`NdimageError::InvalidInput`] if any sigma component is ≤ 0.
370///
371/// # Example
372///
373/// ```
374/// use scirs2_ndimage::convolution3d::{gaussian_filter3d, Padding3D};
375/// use scirs2_core::ndarray::Array3;
376///
377/// let vol = Array3::<f64>::ones((10, 10, 10));
378/// let out = gaussian_filter3d(&vol, [1.0, 1.0, 1.0], Padding3D::Reflect).unwrap();
379/// assert_eq!(out.shape(), [10, 10, 10]);
380/// // Interior of a uniform volume is unchanged by Gaussian filtering
381/// assert!((out[[5, 5, 5]] - 1.0).abs() < 1e-10);
382/// ```
383pub fn gaussian_filter3d(
384    volume: &Array3<f64>,
385    sigma: [f64; 3],
386    padding: Padding3D,
387) -> NdimageResult<Array3<f64>> {
388    for (axis, &s) in sigma.iter().enumerate() {
389        if s <= 0.0 {
390            return Err(NdimageError::InvalidInput(format!(
391                "sigma[{}] must be positive, got {}",
392                axis, s
393            )));
394        }
395    }
396    const TRUNCATE: f64 = 4.0;
397    let kz = gaussian_kernel1d(sigma[0], TRUNCATE);
398    let ky = gaussian_kernel1d(sigma[1], TRUNCATE);
399    let kx = gaussian_kernel1d(sigma[2], TRUNCATE);
400
401    let tmp1 = convolve1d_z(volume, &kz, padding);
402    let tmp2 = convolve1d_y(&tmp1, &ky, padding);
403    Ok(convolve1d_x(&tmp2, &kx, padding))
404}
405
406// ─────────────────────────────────────────────────────────────────────────────
407// Uniform (box) filter
408// ─────────────────────────────────────────────────────────────────────────────
409
410/// 3D separable uniform (box) filter with per-axis window sizes.
411///
412/// Each voxel in the output is the mean of the rectangular neighbourhood of
413/// shape `size[0] × size[1] × size[2]`.  The filter is applied as three
414/// successive 1D averaging kernels for efficiency.
415///
416/// # Arguments
417///
418/// * `volume`  - Input volumetric array.
419/// * `size`    - Window sizes `[sz, sy, sx]`.  Each component must be ≥ 1.
420/// * `padding` - Border handling strategy.
421///
422/// # Errors
423///
424/// Returns [`NdimageError::InvalidInput`] if any size component is 0.
425///
426/// # Example
427///
428/// ```
429/// use scirs2_ndimage::convolution3d::{uniform_filter3d, Padding3D};
430/// use scirs2_core::ndarray::Array3;
431///
432/// let vol = Array3::<f64>::ones((8, 8, 8));
433/// let out = uniform_filter3d(&vol, [3, 3, 3], Padding3D::Replicate).unwrap();
434/// assert_eq!(out.shape(), [8, 8, 8]);
435/// ```
436pub fn uniform_filter3d(
437    volume: &Array3<f64>,
438    size: [usize; 3],
439    padding: Padding3D,
440) -> NdimageResult<Array3<f64>> {
441    for (axis, &s) in size.iter().enumerate() {
442        if s == 0 {
443            return Err(NdimageError::InvalidInput(format!(
444                "size[{}] must be at least 1",
445                axis
446            )));
447        }
448    }
449    // Build normalised box kernels for each axis.
450    let make_box = |s: usize| -> Array1<f64> { Array1::from_elem(s, 1.0 / s as f64) };
451    let kz = make_box(size[0]);
452    let ky = make_box(size[1]);
453    let kx = make_box(size[2]);
454
455    let tmp1 = convolve1d_z(volume, &kz, padding);
456    let tmp2 = convolve1d_y(&tmp1, &ky, padding);
457    Ok(convolve1d_x(&tmp2, &kx, padding))
458}
459
460// ─────────────────────────────────────────────────────────────────────────────
461// Median filter
462// ─────────────────────────────────────────────────────────────────────────────
463
464/// 3D median filter with a rectangular window.
465///
466/// Replaces each voxel with the median value in the neighbourhood of shape
467/// `size[0] × size[1] × size[2]`.
468///
469/// # Arguments
470///
471/// * `volume`  - Input volumetric array.
472/// * `size`    - Window sizes `[sz, sy, sx]`.  Each component must be ≥ 1.
473/// * `padding` - Border handling strategy.
474///
475/// # Errors
476///
477/// Returns [`NdimageError::InvalidInput`] if any size component is 0.
478///
479/// # Example
480///
481/// ```
482/// use scirs2_ndimage::convolution3d::{median_filter3d, Padding3D};
483/// use scirs2_core::ndarray::Array3;
484///
485/// let vol = Array3::<f64>::ones((6, 6, 6));
486/// let out = median_filter3d(&vol, [3, 3, 3], Padding3D::Replicate).unwrap();
487/// assert_eq!(out.shape(), [6, 6, 6]);
488/// ```
489pub fn median_filter3d(
490    volume: &Array3<f64>,
491    size: [usize; 3],
492    padding: Padding3D,
493) -> NdimageResult<Array3<f64>> {
494    for (axis, &s) in size.iter().enumerate() {
495        if s == 0 {
496            return Err(NdimageError::InvalidInput(format!(
497                "size[{}] must be at least 1",
498                axis
499            )));
500        }
501    }
502    let vs = volume.shape();
503    let (sz, sy, sx) = (vs[0], vs[1], vs[2]);
504    let hz = (size[0] / 2) as isize;
505    let hy = (size[1] / 2) as isize;
506    let hx = (size[2] / 2) as isize;
507    let window_vol = size[0] * size[1] * size[2];
508
509    let mut out = Array3::zeros((sz, sy, sx));
510    for iz in 0..sz {
511        for iy in 0..sy {
512            for ix in 0..sx {
513                let mut window: Vec<f64> = Vec::with_capacity(window_vol);
514                for dz in -hz..=hz {
515                    for dy in -hy..=hy {
516                        for dx in -hx..=hx {
517                            window.push(get_padded(
518                                volume,
519                                iz as isize + dz,
520                                iy as isize + dy,
521                                ix as isize + dx,
522                                padding,
523                            ));
524                        }
525                    }
526                }
527                window.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
528                out[[iz, iy, ix]] = window[window.len() / 2];
529            }
530        }
531    }
532    Ok(out)
533}
534
535// ─────────────────────────────────────────────────────────────────────────────
536// Laplacian of Gaussian (LoG)
537// ─────────────────────────────────────────────────────────────────────────────
538
539/// 3D Laplacian of Gaussian (LoG) filter for blob detection.
540///
541/// The LoG is computed as the Laplacian of a Gaussian-blurred volume:
542///
543/// ```text
544/// LoG(f) = ∇²(G_σ * f)
545/// ```
546///
547/// Implementation uses the approximation:
548///
549/// ```text
550/// LoG ≈ G_σ * L(f)
551/// ```
552///
553/// where `L` is the 6-neighbour discrete Laplacian.  This is faster than
554/// building a full 3D LoG kernel and numerically equivalent for smooth signals.
555///
556/// Blobs appear as local minima (dark blobs on bright background) or local
557/// maxima (bright blobs on dark background) in the output.
558///
559/// # Arguments
560///
561/// * `volume`  - Input volumetric array.
562/// * `sigma`   - Gaussian sigma `[sz, sy, sx]` (all must be > 0).
563///               Larger sigma detects larger blobs.
564/// * `padding` - Border handling strategy.
565///
566/// # Errors
567///
568/// Returns [`NdimageError::InvalidInput`] if any sigma component is ≤ 0.
569///
570/// # Example
571///
572/// ```
573/// use scirs2_ndimage::convolution3d::{laplacian_of_gaussian3d, Padding3D};
574/// use scirs2_core::ndarray::Array3;
575///
576/// let mut vol = Array3::<f64>::zeros((12, 12, 12));
577/// vol[[6, 6, 6]] = 1.0;
578/// let log = laplacian_of_gaussian3d(&vol, [1.5, 1.5, 1.5], Padding3D::Zero).unwrap();
579/// assert_eq!(log.shape(), [12, 12, 12]);
580/// ```
581pub fn laplacian_of_gaussian3d(
582    volume: &Array3<f64>,
583    sigma: [f64; 3],
584    padding: Padding3D,
585) -> NdimageResult<Array3<f64>> {
586    // 1. Gaussian-smooth the volume.
587    let smoothed = gaussian_filter3d(volume, sigma, padding)?;
588    // 2. Apply discrete 3D Laplacian (6-neighbour stencil).
589    laplacian3d_internal(&smoothed, padding)
590}
591
592/// 3D discrete Laplacian using the 6-neighbour stencil.
593///
594/// The stencil is:  L[i,j,k] = f[i+1,j,k] + f[i-1,j,k]
595///                            + f[i,j+1,k] + f[i,j-1,k]
596///                            + f[i,j,k+1] + f[i,j,k-1]
597///                            - 6 · f[i,j,k]
598fn laplacian3d_internal(volume: &Array3<f64>, padding: Padding3D) -> NdimageResult<Array3<f64>> {
599    let vs = volume.shape();
600    let (sz, sy, sx) = (vs[0], vs[1], vs[2]);
601    let mut out = Array3::zeros((sz, sy, sx));
602    for iz in 0..sz {
603        for iy in 0..sy {
604            for ix in 0..sx {
605                let z = iz as isize;
606                let y = iy as isize;
607                let x = ix as isize;
608                let center = volume[[iz, iy, ix]];
609                let lp = get_padded(volume, z - 1, y, x, padding)
610                    + get_padded(volume, z + 1, y, x, padding)
611                    + get_padded(volume, z, y - 1, x, padding)
612                    + get_padded(volume, z, y + 1, x, padding)
613                    + get_padded(volume, z, y, x - 1, padding)
614                    + get_padded(volume, z, y, x + 1, padding)
615                    - 6.0 * center;
616                out[[iz, iy, ix]] = lp;
617            }
618        }
619    }
620    Ok(out)
621}
622
623// ─────────────────────────────────────────────────────────────────────────────
624// Tests
625// ─────────────────────────────────────────────────────────────────────────────
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use scirs2_core::ndarray::Array3;
631
632    fn make_impulse(sz: usize, sy: usize, sx: usize) -> Array3<f64> {
633        let mut v = Array3::zeros((sz, sy, sx));
634        v[[sz / 2, sy / 2, sx / 2]] = 1.0;
635        v
636    }
637
638    #[test]
639    fn test_convolve3d_identity_kernel() {
640        let vol = Array3::<f64>::from_shape_fn((4, 5, 6), |(z, y, x)| (z * 30 + y * 6 + x) as f64);
641        let mut identity = Array3::zeros((3, 3, 3));
642        identity[[1, 1, 1]] = 1.0;
643        // Convolution with a delta kernel (flipped, but delta is symmetric)
644        let out = convolve3d(&vol, &identity, Padding3D::Zero).expect("convolve3d failed");
645        // Interior should match the original
646        for iz in 1..3usize {
647            for iy in 1..4usize {
648                for ix in 1..5usize {
649                    assert!(
650                        (out[[iz, iy, ix]] - vol[[iz, iy, ix]]).abs() < 1e-12,
651                        "mismatch at [{}, {}, {}]: got {}, expected {}",
652                        iz,
653                        iy,
654                        ix,
655                        out[[iz, iy, ix]],
656                        vol[[iz, iy, ix]]
657                    );
658                }
659            }
660        }
661    }
662
663    #[test]
664    fn test_correlate3d_vs_convolve3d_symmetric() {
665        // For a symmetric kernel, correlate == convolve
666        let vol = Array3::<f64>::ones((6, 6, 6));
667        let kernel = Array3::from_shape_fn((3, 3, 3), |(z, y, x)| {
668            // symmetric kernel: distance-weighted
669            let dz = z as f64 - 1.0;
670            let dy = y as f64 - 1.0;
671            let dx = x as f64 - 1.0;
672            1.0 / (1.0 + dz * dz + dy * dy + dx * dx)
673        });
674        let c1 = convolve3d(&vol, &kernel, Padding3D::Replicate).expect("convolve3d");
675        let c2 = correlate3d(&vol, &kernel, Padding3D::Replicate).expect("correlate3d");
676        for iz in 0..6 {
677            for iy in 0..6 {
678                for ix in 0..6 {
679                    assert!(
680                        (c1[[iz, iy, ix]] - c2[[iz, iy, ix]]).abs() < 1e-12,
681                        "asymmetry at [{},{},{}]",
682                        iz,
683                        iy,
684                        ix
685                    );
686                }
687            }
688        }
689    }
690
691    #[test]
692    fn test_gaussian_filter3d_uniform_volume() {
693        let vol = Array3::<f64>::ones((10, 10, 10));
694        let out = gaussian_filter3d(&vol, [1.0, 1.0, 1.0], Padding3D::Reflect)
695            .expect("gaussian_filter3d");
696        // Interior of uniform volume should be preserved exactly.
697        for iz in 3..7 {
698            for iy in 3..7 {
699                for ix in 3..7 {
700                    assert!(
701                        (out[[iz, iy, ix]] - 1.0).abs() < 1e-10,
702                        "interior mismatch at [{},{},{}]: {}",
703                        iz,
704                        iy,
705                        ix,
706                        out[[iz, iy, ix]]
707                    );
708                }
709            }
710        }
711    }
712
713    #[test]
714    fn test_gaussian_filter3d_invalid_sigma() {
715        let vol = Array3::<f64>::ones((4, 4, 4));
716        assert!(gaussian_filter3d(&vol, [0.0, 1.0, 1.0], Padding3D::Zero).is_err());
717        assert!(gaussian_filter3d(&vol, [1.0, -1.0, 1.0], Padding3D::Zero).is_err());
718    }
719
720    #[test]
721    fn test_uniform_filter3d_mean() {
722        let vol = Array3::<f64>::ones((8, 8, 8));
723        let out =
724            uniform_filter3d(&vol, [3, 3, 3], Padding3D::Replicate).expect("uniform_filter3d");
725        // Mean of a uniform volume is the same value.
726        for iz in 1..7 {
727            for iy in 1..7 {
728                for ix in 1..7 {
729                    assert!((out[[iz, iy, ix]] - 1.0).abs() < 1e-12);
730                }
731            }
732        }
733    }
734
735    #[test]
736    fn test_median_filter3d_constant() {
737        let vol = Array3::<f64>::from_elem((5, 5, 5), 7.0);
738        let out = median_filter3d(&vol, [3, 3, 3], Padding3D::Replicate).expect("median_filter3d");
739        for &v in out.iter() {
740            assert!((v - 7.0).abs() < 1e-12);
741        }
742    }
743
744    #[test]
745    fn test_median_filter3d_invalid_size() {
746        let vol = Array3::<f64>::ones((4, 4, 4));
747        assert!(median_filter3d(&vol, [0, 3, 3], Padding3D::Zero).is_err());
748    }
749
750    #[test]
751    fn test_log3d_impulse() {
752        // LoG of an impulse should resemble the Mexican-hat shape.
753        let vol = make_impulse(12, 12, 12);
754        let log = laplacian_of_gaussian3d(&vol, [1.0, 1.0, 1.0], Padding3D::Zero)
755            .expect("laplacian_of_gaussian3d");
756        // The center should be a local extremum (negative for bright-on-dark with LoG convention)
757        let center = log[[6, 6, 6]];
758        let neighbour = log[[7, 6, 6]];
759        // Center should be more negative than a neighbour
760        assert!(
761            center < neighbour,
762            "center={}, neighbour={}",
763            center,
764            neighbour
765        );
766    }
767
768    #[test]
769    fn test_padding_modes_shape() {
770        let vol = Array3::<f64>::ones((4, 5, 6));
771        let k = Array3::<f64>::ones((3, 3, 3));
772        for &mode in &[Padding3D::Zero, Padding3D::Reflect, Padding3D::Replicate] {
773            let out = convolve3d(&vol, &k, mode).expect("convolve3d");
774            assert_eq!(out.shape(), [4, 5, 6]);
775        }
776    }
777
778    #[test]
779    fn test_reflect_index_symmetry() {
780        // reflect_index should map -1 -> 1, n -> n-2 for n > 1
781        assert_eq!(reflect_index(-1, 5), Some(1));
782        assert_eq!(reflect_index(5, 5), Some(3));
783        assert_eq!(reflect_index(0, 5), Some(0));
784        assert_eq!(reflect_index(4, 5), Some(4));
785    }
786}