Skip to main content

scirs2_ndimage/
morphology3d.rs

1//! 3D Morphological Operations
2//!
3//! This module provides three-dimensional morphological operations for volumetric
4//! image data, including binary and grayscale erosion/dilation, opening/closing,
5//! connected component labeling, object property measurement, skeletonization,
6//! and Euclidean distance transforms.
7//!
8//! # References
9//!
10//! - Gonzalez & Woods, "Digital Image Processing", 3rd ed.
11//! - Meijster et al. (2000), "A General Algorithm for Computing Distance Transforms in Linear Time"
12//! - Lee et al. (1994), "Building Skeleton Models via 3-D Medial Surface Axis Thinning"
13
14use crate::error::{NdimageError, NdimageResult};
15use scirs2_core::ndarray::ArrayView3;
16use scirs2_core::ndarray::{s, Array3};
17
18// ---------------------------------------------------------------------------
19// Structuring element
20// ---------------------------------------------------------------------------
21
22/// 3D structuring element shapes for morphological operations.
23#[derive(Debug, Clone)]
24pub enum StructuringElement3D {
25    /// Cubic structuring element: side = `2 * radius + 1`.
26    Cube(usize),
27    /// Spherical structuring element with given radius.
28    Sphere(f64),
29    /// 6-connectivity cross (face-connected neighbors only: ±x, ±y, ±z plus center).
30    Cross,
31    /// 26-connectivity: full 3×3×3 neighborhood (all 26 neighbors plus center).
32    Cross26,
33    /// Arbitrary boolean array.
34    Custom(Array3<bool>),
35}
36
37impl StructuringElement3D {
38    /// Convert the structuring element to a dense boolean `Array3`.
39    pub fn to_array(&self) -> Array3<bool> {
40        match self {
41            StructuringElement3D::Cube(radius) => {
42                let side = 2 * radius + 1;
43                Array3::from_elem((side, side, side), true)
44            }
45            StructuringElement3D::Sphere(radius) => {
46                let r = radius.ceil() as usize;
47                let side = 2 * r + 1;
48                let cr = r as f64;
49                Array3::from_shape_fn((side, side, side), |(z, y, x)| {
50                    let dz = z as f64 - cr;
51                    let dy = y as f64 - cr;
52                    let dx = x as f64 - cr;
53                    dz * dz + dy * dy + dx * dx <= radius * radius + 1e-9
54                })
55            }
56            StructuringElement3D::Cross => {
57                let mut arr = Array3::from_elem((3, 3, 3), false);
58                // center
59                arr[[1, 1, 1]] = true;
60                // ±z
61                arr[[0, 1, 1]] = true;
62                arr[[2, 1, 1]] = true;
63                // ±y
64                arr[[1, 0, 1]] = true;
65                arr[[1, 2, 1]] = true;
66                // ±x
67                arr[[1, 1, 0]] = true;
68                arr[[1, 1, 2]] = true;
69                arr
70            }
71            StructuringElement3D::Cross26 => Array3::from_elem((3, 3, 3), true),
72            StructuringElement3D::Custom(arr) => arr.clone(),
73        }
74    }
75
76    /// Return the center voxel index `(z, y, x)` of this structuring element.
77    pub fn center(&self) -> (usize, usize, usize) {
78        let arr = self.to_array();
79        let shape = arr.shape();
80        (shape[0] / 2, shape[1] / 2, shape[2] / 2)
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Internal helpers
86// ---------------------------------------------------------------------------
87
88/// Apply one pass of binary erosion using a dense SE mask.
89fn erode_pass(src: &Array3<bool>, se: &Array3<bool>) -> Array3<bool> {
90    let (sz, sy, sx) = (src.shape()[0], src.shape()[1], src.shape()[2]);
91    let (ez, ey, ex) = (se.shape()[0], se.shape()[1], se.shape()[2]);
92    let (cz, cy, cx) = (ez / 2, ey / 2, ex / 2);
93
94    let mut out = Array3::from_elem((sz, sy, sx), false);
95    for iz in 0..sz {
96        for iy in 0..sy {
97            for ix in 0..sx {
98                let mut fits = true;
99                'se_loop: for dz in 0..ez {
100                    for dy in 0..ey {
101                        for dx in 0..ex {
102                            if !se[[dz, dy, dx]] {
103                                continue;
104                            }
105                            let nz = iz as isize + dz as isize - cz as isize;
106                            let ny = iy as isize + dy as isize - cy as isize;
107                            let nx = ix as isize + dx as isize - cx as isize;
108                            if nz < 0
109                                || ny < 0
110                                || nx < 0
111                                || nz >= sz as isize
112                                || ny >= sy as isize
113                                || nx >= sx as isize
114                            {
115                                fits = false;
116                                break 'se_loop;
117                            }
118                            if !src[[nz as usize, ny as usize, nx as usize]] {
119                                fits = false;
120                                break 'se_loop;
121                            }
122                        }
123                    }
124                }
125                out[[iz, iy, ix]] = fits;
126            }
127        }
128    }
129    out
130}
131
132/// Apply one pass of binary dilation using a dense SE mask.
133fn dilate_pass(src: &Array3<bool>, se: &Array3<bool>) -> Array3<bool> {
134    let (sz, sy, sx) = (src.shape()[0], src.shape()[1], src.shape()[2]);
135    let (ez, ey, ex) = (se.shape()[0], se.shape()[1], se.shape()[2]);
136    let (cz, cy, cx) = (ez / 2, ey / 2, ex / 2);
137
138    let mut out = Array3::from_elem((sz, sy, sx), false);
139    for iz in 0..sz {
140        for iy in 0..sy {
141            for ix in 0..sx {
142                if !src[[iz, iy, ix]] {
143                    continue;
144                }
145                for dz in 0..ez {
146                    for dy in 0..ey {
147                        for dx in 0..ex {
148                            if !se[[dz, dy, dx]] {
149                                continue;
150                            }
151                            let nz = iz as isize + dz as isize - cz as isize;
152                            let ny = iy as isize + dy as isize - cy as isize;
153                            let nx = ix as isize + dx as isize - cx as isize;
154                            if nz >= 0
155                                && ny >= 0
156                                && nx >= 0
157                                && nz < sz as isize
158                                && ny < sy as isize
159                                && nx < sx as isize
160                            {
161                                out[[nz as usize, ny as usize, nx as usize]] = true;
162                            }
163                        }
164                    }
165                }
166            }
167        }
168    }
169    out
170}
171
172// ---------------------------------------------------------------------------
173// Public binary morphology API
174// ---------------------------------------------------------------------------
175
176/// 3D binary erosion.
177///
178/// Applies erosion `iterations` times.  The image is shrunken by removing
179/// foreground voxels that are not fully covered by the structuring element.
180///
181/// # Arguments
182///
183/// * `image` - Input binary volumetric image (view).
184/// * `structuring_element` - Shape of the morphological probe.
185/// * `iterations` - Number of erosion iterations (≥1).
186///
187/// # Errors
188///
189/// Returns `NdimageError::InvalidInput` if `iterations == 0`.
190pub fn binary_erosion3d(
191    image: ArrayView3<bool>,
192    structuring_element: &StructuringElement3D,
193    iterations: usize,
194) -> NdimageResult<Array3<bool>> {
195    if iterations == 0 {
196        return Err(NdimageError::InvalidInput(
197            "iterations must be at least 1".to_string(),
198        ));
199    }
200    let se = structuring_element.to_array();
201    let mut current = image.to_owned();
202    for _ in 0..iterations {
203        current = erode_pass(&current, &se);
204    }
205    Ok(current)
206}
207
208/// 3D binary dilation.
209///
210/// Applies dilation `iterations` times.  The image is grown by adding background
211/// voxels that are touched by the structuring element placed at any foreground voxel.
212///
213/// # Arguments
214///
215/// * `image` - Input binary volumetric image (view).
216/// * `structuring_element` - Shape of the morphological probe.
217/// * `iterations` - Number of dilation iterations (≥1).
218///
219/// # Errors
220///
221/// Returns `NdimageError::InvalidInput` if `iterations == 0`.
222pub fn binary_dilation3d(
223    image: ArrayView3<bool>,
224    structuring_element: &StructuringElement3D,
225    iterations: usize,
226) -> NdimageResult<Array3<bool>> {
227    if iterations == 0 {
228        return Err(NdimageError::InvalidInput(
229            "iterations must be at least 1".to_string(),
230        ));
231    }
232    let se = structuring_element.to_array();
233    let mut current = image.to_owned();
234    for _ in 0..iterations {
235        current = dilate_pass(&current, &se);
236    }
237    Ok(current)
238}
239
240/// 3D binary opening (erosion followed by dilation).
241///
242/// Removes small bright features and smooths boundaries while roughly preserving
243/// the shape and size of larger foreground objects.
244pub fn binary_opening3d(
245    image: ArrayView3<bool>,
246    structuring_element: &StructuringElement3D,
247) -> NdimageResult<Array3<bool>> {
248    let se = structuring_element.to_array();
249    let eroded = erode_pass(&image.to_owned(), &se);
250    Ok(dilate_pass(&eroded, &se))
251}
252
253/// 3D binary closing (dilation followed by erosion).
254///
255/// Fills small holes and gaps in the foreground while roughly preserving shape.
256pub fn binary_closing3d(
257    image: ArrayView3<bool>,
258    structuring_element: &StructuringElement3D,
259) -> NdimageResult<Array3<bool>> {
260    let se = structuring_element.to_array();
261    let dilated = dilate_pass(&image.to_owned(), &se);
262    Ok(erode_pass(&dilated, &se))
263}
264
265// ---------------------------------------------------------------------------
266// Grayscale morphology
267// ---------------------------------------------------------------------------
268
269/// Apply one pass of grayscale erosion (minimum filter with SE).
270fn grey_erode_pass(src: &Array3<f64>, se: &Array3<bool>) -> Array3<f64> {
271    let (sz, sy, sx) = (src.shape()[0], src.shape()[1], src.shape()[2]);
272    let (ez, ey, ex) = (se.shape()[0], se.shape()[1], se.shape()[2]);
273    let (cz, cy, cx) = (ez / 2, ey / 2, ex / 2);
274
275    let mut out = Array3::<f64>::from_elem((sz, sy, sx), f64::INFINITY);
276    for iz in 0..sz {
277        for iy in 0..sy {
278            for ix in 0..sx {
279                let mut min_val = f64::INFINITY;
280                for dz in 0..ez {
281                    for dy in 0..ey {
282                        for dx in 0..ex {
283                            if !se[[dz, dy, dx]] {
284                                continue;
285                            }
286                            let nz = iz as isize + dz as isize - cz as isize;
287                            let ny = iy as isize + dy as isize - cy as isize;
288                            let nx = ix as isize + dx as isize - cx as isize;
289                            if nz >= 0
290                                && ny >= 0
291                                && nx >= 0
292                                && nz < sz as isize
293                                && ny < sy as isize
294                                && nx < sx as isize
295                            {
296                                let v = src[[nz as usize, ny as usize, nx as usize]];
297                                if v < min_val {
298                                    min_val = v;
299                                }
300                            }
301                        }
302                    }
303                }
304                out[[iz, iy, ix]] = if min_val.is_infinite() {
305                    src[[iz, iy, ix]]
306                } else {
307                    min_val
308                };
309            }
310        }
311    }
312    out
313}
314
315/// Apply one pass of grayscale dilation (maximum filter with SE).
316fn grey_dilate_pass(src: &Array3<f64>, se: &Array3<bool>) -> Array3<f64> {
317    let (sz, sy, sx) = (src.shape()[0], src.shape()[1], src.shape()[2]);
318    let (ez, ey, ex) = (se.shape()[0], se.shape()[1], se.shape()[2]);
319    let (cz, cy, cx) = (ez / 2, ey / 2, ex / 2);
320
321    let mut out = Array3::<f64>::from_elem((sz, sy, sx), f64::NEG_INFINITY);
322    for iz in 0..sz {
323        for iy in 0..sy {
324            for ix in 0..sx {
325                let mut max_val = f64::NEG_INFINITY;
326                for dz in 0..ez {
327                    for dy in 0..ey {
328                        for dx in 0..ex {
329                            if !se[[dz, dy, dx]] {
330                                continue;
331                            }
332                            let nz = iz as isize + dz as isize - cz as isize;
333                            let ny = iy as isize + dy as isize - cy as isize;
334                            let nx = ix as isize + dx as isize - cx as isize;
335                            if nz >= 0
336                                && ny >= 0
337                                && nx >= 0
338                                && nz < sz as isize
339                                && ny < sy as isize
340                                && nx < sx as isize
341                            {
342                                let v = src[[nz as usize, ny as usize, nx as usize]];
343                                if v > max_val {
344                                    max_val = v;
345                                }
346                            }
347                        }
348                    }
349                }
350                out[[iz, iy, ix]] = if max_val.is_infinite() {
351                    src[[iz, iy, ix]]
352                } else {
353                    max_val
354                };
355            }
356        }
357    }
358    out
359}
360
361/// 3D grayscale erosion (min filter).
362///
363/// Each output voxel receives the minimum value within the structuring element
364/// neighbourhood centered at that voxel.  Out-of-bounds positions are ignored.
365pub fn grey_erosion3d(
366    image: ArrayView3<f64>,
367    structuring_element: &StructuringElement3D,
368) -> NdimageResult<Array3<f64>> {
369    let se = structuring_element.to_array();
370    Ok(grey_erode_pass(&image.to_owned(), &se))
371}
372
373/// 3D grayscale dilation (max filter).
374///
375/// Each output voxel receives the maximum value within the structuring element
376/// neighbourhood centered at that voxel.  Out-of-bounds positions are ignored.
377pub fn grey_dilation3d(
378    image: ArrayView3<f64>,
379    structuring_element: &StructuringElement3D,
380) -> NdimageResult<Array3<f64>> {
381    let se = structuring_element.to_array();
382    Ok(grey_dilate_pass(&image.to_owned(), &se))
383}
384
385// ---------------------------------------------------------------------------
386// Connected-component labeling (union-find)
387// ---------------------------------------------------------------------------
388
389/// Union-Find data structure with path compression and union by rank.
390struct UnionFind {
391    parent: Vec<usize>,
392    rank: Vec<usize>,
393}
394
395impl UnionFind {
396    fn new(n: usize) -> Self {
397        UnionFind {
398            parent: (0..n).collect(),
399            rank: vec![0; n],
400        }
401    }
402
403    fn find(&mut self, mut x: usize) -> usize {
404        while self.parent[x] != x {
405            // Path halving
406            self.parent[x] = self.parent[self.parent[x]];
407            x = self.parent[x];
408        }
409        x
410    }
411
412    fn union(&mut self, a: usize, b: usize) {
413        let ra = self.find(a);
414        let rb = self.find(b);
415        if ra == rb {
416            return;
417        }
418        if self.rank[ra] < self.rank[rb] {
419            self.parent[ra] = rb;
420        } else if self.rank[ra] > self.rank[rb] {
421            self.parent[rb] = ra;
422        } else {
423            self.parent[rb] = ra;
424            self.rank[ra] += 1;
425        }
426    }
427}
428
429/// Generate neighbor offsets for a given connectivity (6, 18, or 26).
430fn neighbor_offsets(connectivity: usize) -> Vec<(isize, isize, isize)> {
431    match connectivity {
432        6 => vec![(-1, 0, 0), (0, -1, 0), (0, 0, -1)],
433        18 => {
434            let mut offsets = Vec::new();
435            for dz in -1isize..=1 {
436                for dy in -1isize..=1 {
437                    for dx in -1isize..=1 {
438                        let nonzero = (dz != 0) as usize + (dy != 0) as usize + (dx != 0) as usize;
439                        if nonzero > 0 && nonzero <= 2 {
440                            // face- or edge-connected (not corner)
441                            if dz < 0 || (dz == 0 && dy < 0) || (dz == 0 && dy == 0 && dx < 0) {
442                                offsets.push((dz, dy, dx));
443                            }
444                        }
445                    }
446                }
447            }
448            offsets
449        }
450        _ => {
451            // 26-connectivity: all previously scanned neighbors
452            let mut offsets = Vec::new();
453            for dz in -1isize..=1 {
454                for dy in -1isize..=1 {
455                    for dx in -1isize..=1 {
456                        if dz == 0 && dy == 0 && dx == 0 {
457                            continue;
458                        }
459                        if dz < 0 || (dz == 0 && dy < 0) || (dz == 0 && dy == 0 && dx < 0) {
460                            offsets.push((dz, dy, dx));
461                        }
462                    }
463                }
464            }
465            offsets
466        }
467    }
468}
469
470/// 3D connected component labeling using union-find.
471///
472/// Labels connected foreground (`true`) voxels with distinct integer labels
473/// starting from 1.  Background voxels receive label 0.
474///
475/// # Arguments
476///
477/// * `binary_image` - Input binary volumetric image.
478/// * `connectivity` - One of 6 (face), 18 (face+edge), or 26 (full neighborhood).
479///   Any value other than 6 or 18 is treated as 26.
480///
481/// # Returns
482///
483/// A tuple `(label_image, n_components)`.
484pub fn label3d(
485    binary_image: ArrayView3<bool>,
486    connectivity: usize,
487) -> NdimageResult<(Array3<i32>, usize)> {
488    let shape = binary_image.shape();
489    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
490    let n_voxels = sz * sy * sx;
491
492    // Flat label array: 0 = background, provisional labels 1..
493    let mut labels = vec![0usize; n_voxels];
494    let mut uf = UnionFind::new(n_voxels + 1); // index 0 = background sentinel
495    let mut next_label = 1usize;
496
497    let offsets = neighbor_offsets(connectivity);
498
499    let flat = |z: usize, y: usize, x: usize| z * sy * sx + y * sx + x;
500
501    // First pass: assign provisional labels and record unions
502    for iz in 0..sz {
503        for iy in 0..sy {
504            for ix in 0..sx {
505                if !binary_image[[iz, iy, ix]] {
506                    continue;
507                }
508                let mut neighbor_labels: Vec<usize> = Vec::new();
509                for &(dz, dy, dx) in &offsets {
510                    let nz = iz as isize + dz;
511                    let ny = iy as isize + dy;
512                    let nx = ix as isize + dx;
513                    if nz < 0
514                        || ny < 0
515                        || nx < 0
516                        || nz >= sz as isize
517                        || ny >= sy as isize
518                        || nx >= sx as isize
519                    {
520                        continue;
521                    }
522                    let nb = flat(nz as usize, ny as usize, nx as usize);
523                    if labels[nb] > 0 {
524                        neighbor_labels.push(labels[nb]);
525                    }
526                }
527
528                let idx = flat(iz, iy, ix);
529                if neighbor_labels.is_empty() {
530                    labels[idx] = next_label;
531                    next_label += 1;
532                } else {
533                    let min_lbl = neighbor_labels.iter().copied().min().unwrap_or(next_label);
534                    labels[idx] = min_lbl;
535                    for &nl in &neighbor_labels {
536                        uf.union(min_lbl, nl);
537                    }
538                }
539            }
540        }
541    }
542
543    // Second pass: relabel via union-find roots to contiguous integers
544    let mut root_to_final = vec![0usize; next_label];
545    let mut n_components = 0usize;
546    let mut out = Array3::<i32>::zeros((sz, sy, sx));
547
548    for iz in 0..sz {
549        for iy in 0..sy {
550            for ix in 0..sx {
551                let idx = flat(iz, iy, ix);
552                if labels[idx] == 0 {
553                    continue;
554                }
555                let root = uf.find(labels[idx]);
556                if root_to_final[root] == 0 {
557                    n_components += 1;
558                    root_to_final[root] = n_components;
559                }
560                out[[iz, iy, ix]] = root_to_final[root] as i32;
561            }
562        }
563    }
564
565    Ok((out, n_components))
566}
567
568// ---------------------------------------------------------------------------
569// Object properties
570// ---------------------------------------------------------------------------
571
572/// Properties of a single labeled 3D object.
573#[derive(Debug, Clone)]
574pub struct Object3DProps {
575    /// Label index (1-based).
576    pub label: i32,
577    /// Volume in voxels.
578    pub volume: usize,
579    /// Centroid `(z, y, x)` coordinates.
580    pub centroid: (f64, f64, f64),
581    /// Bounding box `((z_min, y_min, x_min), (z_max, y_max, x_max))`.
582    pub bounding_box: ((usize, usize, usize), (usize, usize, usize)),
583    /// Diameter of a sphere with the same volume.
584    pub equivalent_diameter: f64,
585    /// Approximate surface area (number of voxels adjacent to background).
586    pub surface_area_approx: f64,
587}
588
589/// Compute properties for each labeled object in a 3D label image.
590///
591/// Objects with label values 1 through `n_objects` are analyzed.  Label 0
592/// (background) is ignored.
593///
594/// # Arguments
595///
596/// * `label_image` - Integer label image (output of [`label3d`]).
597/// * `n_objects` - Number of distinct objects (second return value of [`label3d`]).
598pub fn object_properties3d(label_image: &Array3<i32>, n_objects: usize) -> Vec<Object3DProps> {
599    let shape = label_image.shape();
600    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
601
602    // Accumulators
603    let mut volumes = vec![0usize; n_objects + 1];
604    let mut sum_z = vec![0.0f64; n_objects + 1];
605    let mut sum_y = vec![0.0f64; n_objects + 1];
606    let mut sum_x = vec![0.0f64; n_objects + 1];
607    let mut min_z = vec![usize::MAX; n_objects + 1];
608    let mut min_y = vec![usize::MAX; n_objects + 1];
609    let mut min_x = vec![usize::MAX; n_objects + 1];
610    let mut max_z = vec![0usize; n_objects + 1];
611    let mut max_y = vec![0usize; n_objects + 1];
612    let mut max_x = vec![0usize; n_objects + 1];
613    let mut surface = vec![0usize; n_objects + 1];
614
615    // 6-connectivity neighbors for surface detection
616    let face_neighbors: [(isize, isize, isize); 6] = [
617        (-1, 0, 0),
618        (1, 0, 0),
619        (0, -1, 0),
620        (0, 1, 0),
621        (0, 0, -1),
622        (0, 0, 1),
623    ];
624
625    for iz in 0..sz {
626        for iy in 0..sy {
627            for ix in 0..sx {
628                let lbl = label_image[[iz, iy, ix]] as usize;
629                if lbl == 0 || lbl > n_objects {
630                    continue;
631                }
632                volumes[lbl] += 1;
633                sum_z[lbl] += iz as f64;
634                sum_y[lbl] += iy as f64;
635                sum_x[lbl] += ix as f64;
636                if iz < min_z[lbl] {
637                    min_z[lbl] = iz;
638                }
639                if iy < min_y[lbl] {
640                    min_y[lbl] = iy;
641                }
642                if ix < min_x[lbl] {
643                    min_x[lbl] = ix;
644                }
645                if iz > max_z[lbl] {
646                    max_z[lbl] = iz;
647                }
648                if iy > max_y[lbl] {
649                    max_y[lbl] = iy;
650                }
651                if ix > max_x[lbl] {
652                    max_x[lbl] = ix;
653                }
654
655                // Count surface voxels
656                let mut on_surface = false;
657                for &(dz, dy, dx) in &face_neighbors {
658                    let nz = iz as isize + dz;
659                    let ny = iy as isize + dy;
660                    let nx = ix as isize + dx;
661                    if nz < 0
662                        || ny < 0
663                        || nx < 0
664                        || nz >= sz as isize
665                        || ny >= sy as isize
666                        || nx >= sx as isize
667                    {
668                        on_surface = true;
669                        break;
670                    }
671                    if label_image[[nz as usize, ny as usize, nx as usize]] != lbl as i32 {
672                        on_surface = true;
673                        break;
674                    }
675                }
676                if on_surface {
677                    surface[lbl] += 1;
678                }
679            }
680        }
681    }
682
683    let pi = std::f64::consts::PI;
684
685    (1..=n_objects)
686        .map(|lbl| {
687            let vol = volumes[lbl];
688            let centroid = if vol > 0 {
689                (
690                    sum_z[lbl] / vol as f64,
691                    sum_y[lbl] / vol as f64,
692                    sum_x[lbl] / vol as f64,
693                )
694            } else {
695                (0.0, 0.0, 0.0)
696            };
697            // diameter of sphere with same volume: V = (4/3)*pi*r^3 => d = 2*(3V/4pi)^(1/3)
698            let equiv_diam = if vol > 0 {
699                2.0 * (3.0 * vol as f64 / (4.0 * pi)).powf(1.0 / 3.0)
700            } else {
701                0.0
702            };
703            let bb_min = (
704                if min_z[lbl] == usize::MAX {
705                    0
706                } else {
707                    min_z[lbl]
708                },
709                if min_y[lbl] == usize::MAX {
710                    0
711                } else {
712                    min_y[lbl]
713                },
714                if min_x[lbl] == usize::MAX {
715                    0
716                } else {
717                    min_x[lbl]
718                },
719            );
720            Object3DProps {
721                label: lbl as i32,
722                volume: vol,
723                centroid,
724                bounding_box: (bb_min, (max_z[lbl], max_y[lbl], max_x[lbl])),
725                equivalent_diameter: equiv_diam,
726                surface_area_approx: surface[lbl] as f64,
727            }
728        })
729        .collect()
730}
731
732// ---------------------------------------------------------------------------
733// Euclidean Distance Transform 3D
734// ---------------------------------------------------------------------------
735
736/// 3D Euclidean distance transform.
737///
738/// For every foreground (`true`) voxel, computes the exact Euclidean distance
739/// to the nearest background (`false`) voxel.  Background voxels receive 0.
740///
741/// Uses the separable Meijster/Felzenszwalb algorithm extended to three
742/// dimensions.
743///
744/// # Arguments
745///
746/// * `binary_image` - Input binary volumetric image.
747pub fn distance_transform_edt3d(binary_image: ArrayView3<bool>) -> NdimageResult<Array3<f64>> {
748    let shape = binary_image.shape();
749    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
750    if sz == 0 || sy == 0 || sx == 0 {
751        return Ok(Array3::zeros((sz, sy, sx)));
752    }
753
754    let inf_sq = (sz * sz + sy * sy + sx * sx + 1) as f64;
755
756    // --- Phase 1: along X axis ---
757    // g[z][y][x] = squared distance to nearest background in row (z, y, *)
758    let mut g = Array3::<f64>::from_elem((sz, sy, sx), inf_sq);
759
760    for iz in 0..sz {
761        for iy in 0..sy {
762            // Forward scan
763            let mut prev = if !binary_image[[iz, iy, 0]] {
764                0.0
765            } else {
766                inf_sq
767            };
768            g[[iz, iy, 0]] = prev;
769            for ix in 1..sx {
770                if !binary_image[[iz, iy, ix]] {
771                    prev = 0.0;
772                } else if prev < inf_sq {
773                    prev += 1.0;
774                }
775                g[[iz, iy, ix]] = prev * prev;
776            }
777            // Backward scan
778            let mut prev_back = if !binary_image[[iz, iy, sx - 1]] {
779                0.0
780            } else {
781                inf_sq
782            };
783            for ix in (0..sx).rev() {
784                if !binary_image[[iz, iy, ix]] {
785                    prev_back = 0.0;
786                } else if prev_back < inf_sq {
787                    prev_back += 1.0;
788                }
789                let bval = prev_back * prev_back;
790                if bval < g[[iz, iy, ix]] {
791                    g[[iz, iy, ix]] = bval;
792                }
793            }
794        }
795    }
796
797    // --- Phase 2: along Y axis ---
798    // h[z][y][x] = min over y' of (g[z][y'][x] + (y - y')^2)
799    let mut h = Array3::<f64>::from_elem((sz, sy, sx), inf_sq);
800
801    for iz in 0..sz {
802        for ix in 0..sx {
803            // Felzenszwalb parabola lower envelope
804            let mut s = vec![0.0f64; sy]; // separation points
805            let mut t = vec![0usize; sy]; // parabola indices
806            let f = |q: usize| g[[iz, q, ix]] + (q as f64).powi(2);
807
808            let mut k = 0usize;
809            t[0] = 0;
810            s[0] = f64::NEG_INFINITY;
811
812            for q in 1..sy {
813                // intersection of parabolas at t[k] and q
814                loop {
815                    let sq =
816                        ((f(q) - f(t[k])) / (2.0 * q as f64 - 2.0 * t[k] as f64) + 0.5).floor();
817                    if k == 0 || sq > s[k] {
818                        k += 1;
819                        s[k] = sq;
820                        t[k] = q;
821                        break;
822                    }
823                    if k > 0 {
824                        k -= 1;
825                    } else {
826                        break;
827                    }
828                }
829            }
830
831            let mut j = k;
832            for q in (0..sy).rev() {
833                while j > 0 && s[j] > q as f64 {
834                    j -= 1;
835                }
836                h[[iz, q, ix]] = f(t[j]) + (q as f64 - t[j] as f64).powi(2) - (t[j] as f64).powi(2);
837            }
838        }
839    }
840
841    // --- Phase 3: along Z axis ---
842    let mut out = Array3::<f64>::zeros((sz, sy, sx));
843
844    for iy in 0..sy {
845        for ix in 0..sx {
846            let f = |q: usize| h[[q, iy, ix]] + (q as f64).powi(2);
847            let mut s = vec![0.0f64; sz];
848            let mut t = vec![0usize; sz];
849            let mut k = 0usize;
850            t[0] = 0;
851            s[0] = f64::NEG_INFINITY;
852
853            for q in 1..sz {
854                loop {
855                    let sq =
856                        ((f(q) - f(t[k])) / (2.0 * q as f64 - 2.0 * t[k] as f64) + 0.5).floor();
857                    if k == 0 || sq > s[k] {
858                        k += 1;
859                        s[k] = sq;
860                        t[k] = q;
861                        break;
862                    }
863                    if k > 0 {
864                        k -= 1;
865                    } else {
866                        break;
867                    }
868                }
869            }
870
871            let mut j = k;
872            for q in (0..sz).rev() {
873                while j > 0 && s[j] > q as f64 {
874                    j -= 1;
875                }
876                let dist_sq = f(t[j]) + (q as f64 - t[j] as f64).powi(2) - (t[j] as f64).powi(2);
877                out[[q, iy, ix]] = dist_sq.max(0.0).sqrt();
878            }
879        }
880    }
881
882    Ok(out)
883}
884
885// ---------------------------------------------------------------------------
886// Skeletonization (3D thinning)
887// ---------------------------------------------------------------------------
888
889/// 3D binary skeletonization via iterative thinning.
890///
891/// Iteratively removes border voxels that are topologically simple (i.e.,
892/// their removal does not change the topology of the object) until no further
893/// voxels can be removed.  The result is a one-voxel-wide medial skeleton.
894///
895/// This implementation uses a simplified heuristic based on 26-connectivity
896/// thinning criteria adapted from Lee et al. (1994).
897///
898/// # Arguments
899///
900/// * `binary_image` - Input binary volumetric image.
901///
902/// # Errors
903///
904/// Returns `NdimageError::InvalidInput` if the image is empty.
905pub fn skeletonize3d(binary_image: ArrayView3<bool>) -> NdimageResult<Array3<bool>> {
906    let shape = binary_image.shape();
907    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
908    if sz == 0 || sy == 0 || sx == 0 {
909        return Err(NdimageError::InvalidInput(
910            "image must be non-empty".to_string(),
911        ));
912    }
913
914    let mut current = binary_image.to_owned();
915
916    // Six sub-iteration directions (thinning along each face direction)
917    let directions: [(isize, isize, isize); 6] = [
918        (-1, 0, 0),
919        (1, 0, 0),
920        (0, -1, 0),
921        (0, 1, 0),
922        (0, 0, -1),
923        (0, 0, 1),
924    ];
925
926    let mut changed = true;
927    while changed {
928        changed = false;
929        for &(dz, dy, dx) in &directions {
930            // Collect candidates: foreground voxels with an exposed face in direction (dz,dy,dx)
931            let mut candidates = Vec::new();
932            for iz in 0..sz {
933                for iy in 0..sy {
934                    for ix in 0..sx {
935                        if !current[[iz, iy, ix]] {
936                            continue;
937                        }
938                        let nz = iz as isize + dz;
939                        let ny = iy as isize + dy;
940                        let nx = ix as isize + dx;
941                        // The neighbor in the thinning direction must be background
942                        let is_border = if nz < 0
943                            || ny < 0
944                            || nx < 0
945                            || nz >= sz as isize
946                            || ny >= sy as isize
947                            || nx >= sx as isize
948                        {
949                            true
950                        } else {
951                            !current[[nz as usize, ny as usize, nx as usize]]
952                        };
953                        if is_border && is_simple_point(&current, iz, iy, ix) {
954                            candidates.push((iz, iy, ix));
955                        }
956                    }
957                }
958            }
959            for (iz, iy, ix) in candidates {
960                current[[iz, iy, ix]] = false;
961                changed = true;
962            }
963        }
964    }
965
966    Ok(current)
967}
968
969/// Determine whether removing voxel (iz, iy, ix) leaves the local topology unchanged.
970///
971/// A voxel is "simple" if its 26-neighborhood has exactly one connected component
972/// of foreground voxels and exactly one connected component of background voxels
973/// when the voxel itself is excluded.
974fn is_simple_point(vol: &Array3<bool>, iz: usize, iy: usize, ix: usize) -> bool {
975    let shape = vol.shape();
976    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
977
978    // Extract the 3×3×3 neighborhood, excluding the center voxel
979    let mut nbhood = [[[false; 3]; 3]; 3];
980    for dz in 0..3usize {
981        for dy in 0..3usize {
982            for dx in 0..3usize {
983                if dz == 1 && dy == 1 && dx == 1 {
984                    continue;
985                }
986                let nz = iz as isize + dz as isize - 1;
987                let ny = iy as isize + dy as isize - 1;
988                let nx = ix as isize + dx as isize - 1;
989                if nz >= 0
990                    && ny >= 0
991                    && nx >= 0
992                    && nz < sz as isize
993                    && ny < sy as isize
994                    && nx < sx as isize
995                {
996                    nbhood[dz][dy][dx] = vol[[nz as usize, ny as usize, nx as usize]];
997                }
998            }
999        }
1000    }
1001
1002    // Count 26-connected components of foreground in neighborhood
1003    let fg_components = count_components_26(&nbhood, true);
1004    if fg_components != 1 {
1005        return false;
1006    }
1007
1008    // Count 6-connected components of background in neighborhood (treating center as bg)
1009    let mut bg_nbhood = [[[true; 3]; 3]; 3];
1010    for dz in 0..3usize {
1011        for dy in 0..3usize {
1012            for dx in 0..3usize {
1013                if dz == 1 && dy == 1 && dx == 1 {
1014                    // center: set to background (we're removing it)
1015                    bg_nbhood[dz][dy][dx] = true;
1016                } else {
1017                    // background if the neighbor is background
1018                    bg_nbhood[dz][dy][dx] = !nbhood[dz][dy][dx];
1019                }
1020            }
1021        }
1022    }
1023    let bg_components = count_components_6(&bg_nbhood, true);
1024    bg_components == 1
1025}
1026
1027/// Count 26-connected components of `target` value in a 3×3×3 neighborhood.
1028fn count_components_26(nbhood: &[[[bool; 3]; 3]; 3], target: bool) -> usize {
1029    let mut visited = [[[false; 3]; 3]; 3];
1030    let mut count = 0;
1031    for sz in 0..3usize {
1032        for sy in 0..3usize {
1033            for sx in 0..3usize {
1034                if nbhood[sz][sy][sx] == target && !visited[sz][sy][sx] {
1035                    // BFS in 26-connectivity
1036                    let mut queue = std::collections::VecDeque::new();
1037                    queue.push_back((sz, sy, sx));
1038                    visited[sz][sy][sx] = true;
1039                    while let Some((cz, cy, cx)) = queue.pop_front() {
1040                        for dz in 0..3usize {
1041                            for dy in 0..3usize {
1042                                for dx in 0..3usize {
1043                                    if dz == 1 && dy == 1 && dx == 1 {
1044                                        continue;
1045                                    }
1046                                    let nz = cz as isize + dz as isize - 1;
1047                                    let ny = cy as isize + dy as isize - 1;
1048                                    let nx = cx as isize + dx as isize - 1;
1049                                    if nz >= 0 && ny >= 0 && nx >= 0 && nz < 3 && ny < 3 && nx < 3 {
1050                                        let (nzu, nyu, nxu) =
1051                                            (nz as usize, ny as usize, nx as usize);
1052                                        if nbhood[nzu][nyu][nxu] == target
1053                                            && !visited[nzu][nyu][nxu]
1054                                        {
1055                                            visited[nzu][nyu][nxu] = true;
1056                                            queue.push_back((nzu, nyu, nxu));
1057                                        }
1058                                    }
1059                                }
1060                            }
1061                        }
1062                    }
1063                    count += 1;
1064                }
1065            }
1066        }
1067    }
1068    count
1069}
1070
1071/// Count 6-connected components of `target` value in a 3×3×3 neighborhood.
1072fn count_components_6(nbhood: &[[[bool; 3]; 3]; 3], target: bool) -> usize {
1073    let offsets: [(isize, isize, isize); 6] = [
1074        (-1, 0, 0),
1075        (1, 0, 0),
1076        (0, -1, 0),
1077        (0, 1, 0),
1078        (0, 0, -1),
1079        (0, 0, 1),
1080    ];
1081    let mut visited = [[[false; 3]; 3]; 3];
1082    let mut count = 0;
1083    for sz in 0..3usize {
1084        for sy in 0..3usize {
1085            for sx in 0..3usize {
1086                if nbhood[sz][sy][sx] == target && !visited[sz][sy][sx] {
1087                    let mut queue = std::collections::VecDeque::new();
1088                    queue.push_back((sz, sy, sx));
1089                    visited[sz][sy][sx] = true;
1090                    while let Some((cz, cy, cx)) = queue.pop_front() {
1091                        for &(dz, dy, dx) in &offsets {
1092                            let nz = cz as isize + dz;
1093                            let ny = cy as isize + dy;
1094                            let nx = cx as isize + dx;
1095                            if nz >= 0 && ny >= 0 && nx >= 0 && nz < 3 && ny < 3 && nx < 3 {
1096                                let (nzu, nyu, nxu) = (nz as usize, ny as usize, nx as usize);
1097                                if nbhood[nzu][nyu][nxu] == target && !visited[nzu][nyu][nxu] {
1098                                    visited[nzu][nyu][nxu] = true;
1099                                    queue.push_back((nzu, nyu, nxu));
1100                                }
1101                            }
1102                        }
1103                    }
1104                    count += 1;
1105                }
1106            }
1107        }
1108    }
1109    count
1110}
1111
1112// ---------------------------------------------------------------------------
1113// Tests
1114// ---------------------------------------------------------------------------
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119    use scirs2_core::ndarray::Array3;
1120
1121    // Helper: small 5×5×5 sphere in center
1122    fn sphere_5() -> Array3<bool> {
1123        Array3::from_shape_fn((5, 5, 5), |(z, y, x)| {
1124            let dz = z as f64 - 2.0;
1125            let dy = y as f64 - 2.0;
1126            let dx = x as f64 - 2.0;
1127            dz * dz + dy * dy + dx * dx <= 4.0 + 1e-9
1128        })
1129    }
1130
1131    // Helper: solid 4×4×4 cube
1132    fn solid_cube() -> Array3<bool> {
1133        Array3::from_elem((4, 4, 4), true)
1134    }
1135
1136    // Helper: two disjoint 2×2×2 blobs
1137    fn two_blobs() -> Array3<bool> {
1138        let mut v = Array3::from_elem((5, 5, 5), false);
1139        for z in 0..2usize {
1140            for y in 0..2usize {
1141                for x in 0..2usize {
1142                    v[[z, y, x]] = true;
1143                    v[[z + 3, y + 3, x + 3]] = true;
1144                }
1145            }
1146        }
1147        v
1148    }
1149
1150    // ----- StructuringElement3D -----
1151
1152    #[test]
1153    fn test_se_cube_shape() {
1154        let se = StructuringElement3D::Cube(1);
1155        let arr = se.to_array();
1156        assert_eq!(arr.shape(), &[3, 3, 3]);
1157        assert!(arr.iter().all(|&v| v));
1158    }
1159
1160    #[test]
1161    fn test_se_sphere_center() {
1162        let se = StructuringElement3D::Sphere(2.0);
1163        let arr = se.to_array();
1164        let (cz, cy, cx) = se.center();
1165        assert!(arr[[cz, cy, cx]]);
1166    }
1167
1168    #[test]
1169    fn test_se_cross_6_connectivity() {
1170        let se = StructuringElement3D::Cross;
1171        let arr = se.to_array();
1172        assert!(arr[[1, 1, 1]]); // center
1173        assert!(arr[[0, 1, 1]]); // -z
1174        assert!(arr[[2, 1, 1]]); // +z
1175        assert!(!arr[[0, 0, 0]]); // corner must be false
1176                                  // Exactly 7 true values (6 neighbors + center)
1177        let count = arr.iter().filter(|&&v| v).count();
1178        assert_eq!(count, 7);
1179    }
1180
1181    #[test]
1182    fn test_se_cross26_full() {
1183        let se = StructuringElement3D::Cross26;
1184        let arr = se.to_array();
1185        assert_eq!(arr.iter().filter(|&&v| v).count(), 27);
1186    }
1187
1188    // ----- Binary erosion / dilation -----
1189
1190    #[test]
1191    fn test_binary_erosion_shrinks() {
1192        let img = sphere_5();
1193        let se = StructuringElement3D::Cross;
1194        let eroded = binary_erosion3d(img.view(), &se, 1).expect("erosion failed");
1195        let before: usize = img.iter().filter(|&&v| v).count();
1196        let after: usize = eroded.iter().filter(|&&v| v).count();
1197        assert!(after <= before, "erosion should not grow the object");
1198    }
1199
1200    #[test]
1201    fn test_binary_dilation_grows() {
1202        let img = sphere_5();
1203        let se = StructuringElement3D::Cross;
1204        let dilated = binary_dilation3d(img.view(), &se, 1).expect("dilation failed");
1205        let before: usize = img.iter().filter(|&&v| v).count();
1206        let after: usize = dilated.iter().filter(|&&v| v).count();
1207        assert!(after >= before, "dilation should not shrink the object");
1208    }
1209
1210    #[test]
1211    fn test_binary_erosion_zero_iterations_error() {
1212        let img = sphere_5();
1213        let se = StructuringElement3D::Cross;
1214        let result = binary_erosion3d(img.view(), &se, 0);
1215        assert!(result.is_err());
1216    }
1217
1218    #[test]
1219    fn test_binary_opening_smaller_than_original() {
1220        let img = sphere_5();
1221        let se = StructuringElement3D::Cube(1);
1222        let opened = binary_opening3d(img.view(), &se).expect("opening failed");
1223        let before: usize = img.iter().filter(|&&v| v).count();
1224        let after: usize = opened.iter().filter(|&&v| v).count();
1225        assert!(after <= before);
1226    }
1227
1228    #[test]
1229    fn test_binary_closing_larger_than_original() {
1230        // Closing fills gaps → result ≥ original in count
1231        let mut img = Array3::from_elem((7, 7, 7), false);
1232        img[[3, 3, 3]] = true;
1233        img[[3, 3, 5]] = true;
1234        let se = StructuringElement3D::Cube(1);
1235        let closed = binary_closing3d(img.view(), &se).expect("closing failed");
1236        let before: usize = img.iter().filter(|&&v| v).count();
1237        let after: usize = closed.iter().filter(|&&v| v).count();
1238        assert!(after >= before);
1239    }
1240
1241    #[test]
1242    fn test_binary_erosion_all_background_stays_background() {
1243        let img = Array3::from_elem((5, 5, 5), false);
1244        let se = StructuringElement3D::Cross26;
1245        let eroded = binary_erosion3d(img.view(), &se, 1).expect("erosion failed");
1246        assert!(eroded.iter().all(|&v| !v));
1247    }
1248
1249    // ----- Grayscale morphology -----
1250
1251    #[test]
1252    fn test_grey_erosion_reduces_values() {
1253        let img = Array3::from_shape_fn((5, 5, 5), |(z, y, x)| (z + y + x) as f64);
1254        let se = StructuringElement3D::Cross;
1255        let eroded = grey_erosion3d(img.view(), &se).expect("grey erosion failed");
1256        // Every voxel should be ≤ original
1257        for ((z, y, x), &v) in eroded.indexed_iter() {
1258            assert!(v <= img[[z, y, x]] + 1e-10);
1259        }
1260    }
1261
1262    #[test]
1263    fn test_grey_dilation_increases_values() {
1264        let img = Array3::from_shape_fn((5, 5, 5), |(z, y, x)| (z + y + x) as f64);
1265        let se = StructuringElement3D::Cross;
1266        let dilated = grey_dilation3d(img.view(), &se).expect("grey dilation failed");
1267        for ((z, y, x), &v) in dilated.indexed_iter() {
1268            assert!(v >= img[[z, y, x]] - 1e-10);
1269        }
1270    }
1271
1272    // ----- Connected component labeling -----
1273
1274    #[test]
1275    fn test_label3d_single_blob() {
1276        let img = sphere_5();
1277        let (labels, n) = label3d(img.view(), 26).expect("labeling failed");
1278        assert_eq!(n, 1);
1279        // All labeled voxels should have label 1
1280        for ((z, y, x), &lbl) in labels.indexed_iter() {
1281            if img[[z, y, x]] {
1282                assert_eq!(lbl, 1);
1283            } else {
1284                assert_eq!(lbl, 0);
1285            }
1286        }
1287    }
1288
1289    #[test]
1290    fn test_label3d_two_blobs() {
1291        let img = two_blobs();
1292        let (_, n) = label3d(img.view(), 26).expect("labeling failed");
1293        assert_eq!(n, 2, "expected exactly 2 components");
1294    }
1295
1296    #[test]
1297    fn test_label3d_background_only() {
1298        let img = Array3::from_elem((4, 4, 4), false);
1299        let (_, n) = label3d(img.view(), 6).expect("labeling failed");
1300        assert_eq!(n, 0);
1301    }
1302
1303    #[test]
1304    fn test_label3d_full_volume() {
1305        let img = Array3::from_elem((3, 3, 3), true);
1306        let (_, n) = label3d(img.view(), 6).expect("labeling failed");
1307        assert_eq!(n, 1);
1308    }
1309
1310    // ----- Object properties -----
1311
1312    #[test]
1313    fn test_object_properties_volume() {
1314        let img = solid_cube();
1315        let (labels, n) = label3d(img.view(), 26).expect("labeling failed");
1316        let props = object_properties3d(&labels, n);
1317        assert_eq!(props.len(), 1);
1318        assert_eq!(props[0].volume, 64); // 4^3
1319    }
1320
1321    #[test]
1322    fn test_object_properties_centroid() {
1323        let img = solid_cube();
1324        let (labels, n) = label3d(img.view(), 26).expect("labeling failed");
1325        let props = object_properties3d(&labels, n);
1326        let c = props[0].centroid;
1327        assert!((c.0 - 1.5).abs() < 1e-6);
1328        assert!((c.1 - 1.5).abs() < 1e-6);
1329        assert!((c.2 - 1.5).abs() < 1e-6);
1330    }
1331
1332    #[test]
1333    fn test_object_properties_equiv_diameter() {
1334        let img = solid_cube();
1335        let (labels, n) = label3d(img.view(), 26).expect("labeling failed");
1336        let props = object_properties3d(&labels, n);
1337        // Diameter must be positive
1338        assert!(props[0].equivalent_diameter > 0.0);
1339    }
1340
1341    // ----- Distance transform -----
1342
1343    #[test]
1344    fn test_edt3d_background_zero() {
1345        let img = Array3::from_elem((5, 5, 5), false);
1346        let dist = distance_transform_edt3d(img.view()).expect("EDT failed");
1347        assert!(dist.iter().all(|&v| v == 0.0));
1348    }
1349
1350    #[test]
1351    fn test_edt3d_center_voxel() {
1352        // Single foreground voxel in center of 5x5x5 volume surrounded by background border
1353        // The center voxel [2,2,2] is surrounded; nearest bg is at distance 1 (face neighbor)
1354        // Wait -- we need a non-trivial layout.
1355        // All-foreground interior; background = border => center [2,2,2] has dist=2
1356        let mut img = Array3::from_elem((5, 5, 5), true);
1357        // Set border to false
1358        for z in 0..5usize {
1359            for y in 0..5usize {
1360                for x in 0..5usize {
1361                    if z == 0 || z == 4 || y == 0 || y == 4 || x == 0 || x == 4 {
1362                        img[[z, y, x]] = false;
1363                    }
1364                }
1365            }
1366        }
1367        let dist = distance_transform_edt3d(img.view()).expect("EDT failed");
1368        // Center voxel [2,2,2] should have distance 2 (2 steps to border)
1369        assert!(
1370            (dist[[2, 2, 2]] - 2.0).abs() < 0.5,
1371            "Expected ~2.0, got {}",
1372            dist[[2, 2, 2]]
1373        );
1374    }
1375
1376    #[test]
1377    fn test_edt3d_single_fg_voxel() {
1378        let mut img = Array3::from_elem((3, 3, 3), false);
1379        img[[1, 1, 1]] = true;
1380        let dist = distance_transform_edt3d(img.view()).expect("EDT failed");
1381        // Background voxels have distance 0
1382        assert_eq!(dist[[0, 0, 0]], 0.0);
1383        // The single foreground voxel has distance = 1 (closest bg is face neighbor)
1384        assert!((dist[[1, 1, 1]] - 1.0).abs() < 0.5);
1385    }
1386
1387    // ----- Skeletonization -----
1388
1389    #[test]
1390    fn test_skeletonize3d_preserves_nonempty() {
1391        let img = sphere_5();
1392        let skel = skeletonize3d(img.view()).expect("skeletonize failed");
1393        // Skeleton must be a subset of the original
1394        for ((z, y, x), &v) in skel.indexed_iter() {
1395            if v {
1396                assert!(img[[z, y, x]], "skeleton contains voxel not in original");
1397            }
1398        }
1399    }
1400
1401    #[test]
1402    fn test_skeletonize3d_empty_error() {
1403        let img: Array3<bool> = Array3::from_elem((0, 0, 0), false);
1404        let result = skeletonize3d(img.view());
1405        assert!(result.is_err());
1406    }
1407
1408    #[test]
1409    fn test_skeletonize3d_single_voxel() {
1410        let mut img = Array3::from_elem((3, 3, 3), false);
1411        img[[1, 1, 1]] = true;
1412        let skel = skeletonize3d(img.view()).expect("skeletonize failed");
1413        // A single foreground voxel with all background neighbors is NOT a simple point
1414        // (removing it would disconnect foreground) → should remain
1415        assert!(skel[[1, 1, 1]]);
1416    }
1417}