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}