Skip to main content

scirs2_ndimage/
volume_analysis.rs

1//! 3D Volumetric Image Analysis
2//!
3//! This module provides operations for analyzing and processing 3D volumetric image data,
4//! including volume statistics, isosurface extraction via Marching Cubes, connected component
5//! labeling, morphological operations with spherical structuring elements, and 3D watershed
6//! segmentation.
7//!
8//! # References
9//!
10//! - Lorensen & Cline (1987), "Marching Cubes: A High Resolution 3D Surface Construction
11//!   Algorithm", SIGGRAPH Computer Graphics 21(4):163–169.
12//! - Meijster et al. (2000), "A General Algorithm for Computing Distance Transforms in
13//!   Linear Time".
14//! - Meyer & Beucher (1990), "Morphological segmentation", Journal of Visual Communication
15//!   and Image Representation.
16
17use crate::error::{NdimageError, NdimageResult};
18use scirs2_core::ndarray::Array3;
19use std::collections::{BinaryHeap, VecDeque};
20
21// ---------------------------------------------------------------------------
22// VolumeStats
23// ---------------------------------------------------------------------------
24
25/// Statistical summary of a 3D binary volume.
26#[derive(Debug, Clone, PartialEq)]
27pub struct VolumeStats {
28    /// Number of voxels in the foreground (true voxels).
29    pub voxel_count: usize,
30    /// Approximate surface area measured in voxels (face-adjacency criterion).
31    pub surface_area_voxels: usize,
32    /// Volume in cubic millimetres, computed as `voxel_count * voxel_size^3`.
33    pub volume_mm3: f64,
34    /// Centroid of the foreground voxels expressed as `(z, y, x)` in voxel units.
35    pub centroid: (f64, f64, f64),
36}
37
38/// Analyze a binary 3D volume and return volumetric statistics.
39///
40/// A voxel is considered to be on the *surface* if at least one of its six
41/// face-adjacent 26-connected neighbours is background.
42///
43/// # Arguments
44///
45/// * `binary_3d` – Boolean foreground mask with shape `(depth, height, width)`.
46/// * `voxel_size` – Isotropic voxel edge length in millimetres.
47///
48/// # Errors
49///
50/// Returns `NdimageError::InvalidInput` when the array is empty.
51pub fn analyze_volume(binary_3d: &Array3<bool>, voxel_size: f64) -> NdimageResult<VolumeStats> {
52    let shape = binary_3d.shape();
53    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
54    if sz == 0 || sy == 0 || sx == 0 {
55        return Err(NdimageError::InvalidInput(
56            "Volume must be non-empty".to_string(),
57        ));
58    }
59
60    let mut voxel_count: usize = 0;
61    let mut surface_area_voxels: usize = 0;
62    let mut sum_z = 0.0_f64;
63    let mut sum_y = 0.0_f64;
64    let mut sum_x = 0.0_f64;
65
66    // Face-connected neighbour offsets: ±z, ±y, ±x
67    let face_offsets: [(isize, isize, isize); 6] = [
68        (-1, 0, 0),
69        (1, 0, 0),
70        (0, -1, 0),
71        (0, 1, 0),
72        (0, 0, -1),
73        (0, 0, 1),
74    ];
75
76    for iz in 0..sz {
77        for iy in 0..sy {
78            for ix in 0..sx {
79                if !binary_3d[[iz, iy, ix]] {
80                    continue;
81                }
82                voxel_count += 1;
83                sum_z += iz as f64;
84                sum_y += iy as f64;
85                sum_x += ix as f64;
86
87                // Check if any face-adjacent neighbour is background → surface voxel
88                let mut is_surface = false;
89                for (dz, dy, dx) in &face_offsets {
90                    let nz = iz as isize + dz;
91                    let ny = iy as isize + dy;
92                    let nx = ix as isize + dx;
93                    let outside = nz < 0
94                        || ny < 0
95                        || nx < 0
96                        || nz >= sz as isize
97                        || ny >= sy as isize
98                        || nx >= sx as isize;
99                    let is_bg = outside || !binary_3d[[nz as usize, ny as usize, nx as usize]];
100                    if is_bg {
101                        is_surface = true;
102                        break;
103                    }
104                }
105                if is_surface {
106                    surface_area_voxels += 1;
107                }
108            }
109        }
110    }
111
112    let volume_mm3 = voxel_count as f64 * voxel_size.powi(3);
113    let (cz, cy, cx) = if voxel_count > 0 {
114        (
115            sum_z / voxel_count as f64,
116            sum_y / voxel_count as f64,
117            sum_x / voxel_count as f64,
118        )
119    } else {
120        (0.0, 0.0, 0.0)
121    };
122
123    Ok(VolumeStats {
124        voxel_count,
125        surface_area_voxels,
126        volume_mm3,
127        centroid: (cz, cy, cx),
128    })
129}
130
131// ---------------------------------------------------------------------------
132// Marching Cubes
133// ---------------------------------------------------------------------------
134
135/// Marching Cubes lookup tables.
136///
137/// Each cube configuration (0–255) produces between 0 and 5 triangles.
138/// `MC_EDGE_TABLE[i]` is a 12-bit mask that encodes which edges are cut
139/// by the isosurface. `MC_TRI_TABLE[i]` holds a sequence of edge indices
140/// (three at a time per triangle) terminated by -1.
141
142// Vertex numbering (local cube, corner at origin):
143//   v0=(0,0,0), v1=(1,0,0), v2=(1,1,0), v3=(0,1,0)
144//   v4=(0,0,1), v5=(1,0,1), v6=(1,1,1), v7=(0,1,1)
145//
146// Edge numbering:
147//   e0: v0-v1  e1: v1-v2  e2: v2-v3  e3: v3-v0
148//   e4: v4-v5  e5: v5-v6  e6: v6-v7  e7: v7-v4
149//   e8: v0-v4  e9: v1-v5 e10: v2-v6 e11: v3-v7
150
151#[rustfmt::skip]
152const MC_EDGE_TABLE: [u16; 256] = [
153    0x000,0x109,0x203,0x30a,0x406,0x50f,0x605,0x70c,
154    0x80c,0x905,0xa0f,0xb06,0xc0a,0xd03,0xe09,0xf00,
155    0x190,0x099,0x393,0x29a,0x596,0x49f,0x795,0x69c,
156    0x99c,0x895,0xb9f,0xa96,0xd9a,0xc93,0xf99,0xe90,
157    0x230,0x339,0x033,0x13a,0x636,0x73f,0x435,0x53c,
158    0xa3c,0xb35,0x83f,0x936,0xe3a,0xf33,0xc39,0xd30,
159    0x3a0,0x2a9,0x1a3,0x0aa,0x7a6,0x6af,0x5a5,0x4ac,
160    0xbac,0xaa5,0x9af,0x8a6,0xfaa,0xea3,0xda9,0xca0,
161    0x460,0x569,0x663,0x76a,0x066,0x16f,0x265,0x36c,
162    0xc6c,0xd65,0xe6f,0xf66,0x86a,0x963,0xa69,0xb60,
163    0x5f0,0x4f9,0x7f3,0x6fa,0x1f6,0x0ff,0x3f5,0x2fc,
164    0xdfc,0xcf5,0xfff,0xef6,0x9fa,0x8f3,0xbf9,0xaf0,
165    0x650,0x759,0x453,0x55a,0x256,0x35f,0x055,0x15c,
166    0xe5c,0xf55,0xc5f,0xd56,0xa5a,0xb53,0x859,0x950,
167    0x7c0,0x6c9,0x5c3,0x4ca,0x3c6,0x2cf,0x1c5,0x0cc,
168    0xfcc,0xec5,0xdcf,0xcc6,0xbca,0xac3,0x9c9,0x8c0,
169    0x8c0,0x9c9,0xac3,0xbca,0xcc6,0xdcf,0xec5,0xfcc,
170    0x0cc,0x1c5,0x2cf,0x3c6,0x4ca,0x5c3,0x6c9,0x7c0,
171    0x950,0x859,0xb53,0xa5a,0xd56,0xc5f,0xf55,0xe5c,
172    0x15c,0x055,0x35f,0x256,0x55a,0x453,0x759,0x650,
173    0xaf0,0xbf9,0x8f3,0x9fa,0xef6,0xfff,0xcf5,0xdfc,
174    0x2fc,0x3f5,0x0ff,0x1f6,0x6fa,0x7f3,0x4f9,0x5f0,
175    0xb60,0xa69,0x963,0x86a,0xf66,0xe6f,0xd65,0xc6c,
176    0x36c,0x265,0x16f,0x066,0x76a,0x663,0x569,0x460,
177    0xca0,0xda9,0xea3,0xfaa,0x8a6,0x9af,0xaa5,0xbac,
178    0x4ac,0x5a5,0x6af,0x7a6,0x0aa,0x1a3,0x2a9,0x3a0,
179    0xd30,0xc39,0xf33,0xe3a,0x936,0x83f,0xb35,0xa3c,
180    0x53c,0x435,0x73f,0x636,0x13a,0x033,0x339,0x230,
181    0xe90,0xf99,0xc93,0xd9a,0xa96,0xb9f,0x895,0x99c,
182    0x69c,0x795,0x49f,0x596,0x29a,0x393,0x099,0x190,
183    0xf00,0xe09,0xd03,0xc0a,0xb06,0xa0f,0x905,0x80c,
184    0x70c,0x605,0x50f,0x406,0x30a,0x203,0x109,0x000,
185];
186
187#[rustfmt::skip]
188const MC_TRI_TABLE: [[i8; 16]; 256] = [
189    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
190    [0,8,3,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
191    [0,1,9,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
192    [1,8,3,9,8,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
193    [1,2,10,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
194    [0,8,3,1,2,10,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
195    [9,2,10,0,2,9,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
196    [2,8,3,2,10,8,10,9,8,-1,-1,-1,-1,-1,-1,-1],
197    [3,11,2,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
198    [0,11,2,8,11,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
199    [1,9,0,2,3,11,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
200    [1,11,2,1,9,11,9,8,11,-1,-1,-1,-1,-1,-1,-1],
201    [3,10,1,11,10,3,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
202    [0,10,1,0,8,10,8,11,10,-1,-1,-1,-1,-1,-1,-1],
203    [3,9,0,3,11,9,11,10,9,-1,-1,-1,-1,-1,-1,-1],
204    [9,8,10,10,8,11,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
205    [4,7,8,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
206    [4,3,0,7,3,4,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
207    [0,1,9,8,4,7,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
208    [4,1,9,4,7,1,7,3,1,-1,-1,-1,-1,-1,-1,-1],
209    [1,2,10,8,4,7,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
210    [3,4,7,3,0,4,1,2,10,-1,-1,-1,-1,-1,-1,-1],
211    [9,2,10,9,0,2,8,4,7,-1,-1,-1,-1,-1,-1,-1],
212    [2,10,9,2,9,7,2,7,3,7,9,4,-1,-1,-1,-1],
213    [8,4,7,3,11,2,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
214    [11,4,7,11,2,4,2,0,4,-1,-1,-1,-1,-1,-1,-1],
215    [9,0,1,8,4,7,2,3,11,-1,-1,-1,-1,-1,-1,-1],
216    [4,7,11,9,4,11,9,11,2,9,2,1,-1,-1,-1,-1],
217    [3,10,1,3,11,10,7,8,4,-1,-1,-1,-1,-1,-1,-1],
218    [1,11,10,1,4,11,1,0,4,7,11,4,-1,-1,-1,-1],
219    [4,7,8,9,0,11,9,11,10,11,0,3,-1,-1,-1,-1],
220    [4,7,11,4,11,9,9,11,10,-1,-1,-1,-1,-1,-1,-1],
221    [9,5,4,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
222    [9,5,4,0,8,3,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
223    [0,5,4,1,5,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
224    [8,5,4,8,3,5,3,1,5,-1,-1,-1,-1,-1,-1,-1],
225    [1,2,10,9,5,4,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
226    [3,0,8,1,2,10,4,9,5,-1,-1,-1,-1,-1,-1,-1],
227    [5,2,10,5,4,2,4,0,2,-1,-1,-1,-1,-1,-1,-1],
228    [2,10,5,3,2,5,3,5,4,3,4,8,-1,-1,-1,-1],
229    [9,5,4,2,3,11,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
230    [0,11,2,0,8,11,4,9,5,-1,-1,-1,-1,-1,-1,-1],
231    [0,5,4,0,1,5,2,3,11,-1,-1,-1,-1,-1,-1,-1],
232    [2,1,5,2,5,8,2,8,11,4,8,5,-1,-1,-1,-1],
233    [10,3,11,10,1,3,9,5,4,-1,-1,-1,-1,-1,-1,-1],
234    [4,9,5,0,8,1,8,10,1,8,11,10,-1,-1,-1,-1],
235    [5,4,0,5,0,11,5,11,10,11,0,3,-1,-1,-1,-1],
236    [5,4,8,5,8,10,10,8,11,-1,-1,-1,-1,-1,-1,-1],
237    [9,7,8,5,7,9,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
238    [9,3,0,9,5,3,5,7,3,-1,-1,-1,-1,-1,-1,-1],
239    [0,7,8,0,1,7,1,5,7,-1,-1,-1,-1,-1,-1,-1],
240    [1,5,3,3,5,7,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
241    [9,7,8,9,5,7,10,1,2,-1,-1,-1,-1,-1,-1,-1],
242    [10,1,2,9,5,0,5,3,0,5,7,3,-1,-1,-1,-1],
243    [8,0,2,8,2,5,8,5,7,10,5,2,-1,-1,-1,-1],
244    [2,10,5,2,5,3,3,5,7,-1,-1,-1,-1,-1,-1,-1],
245    [7,9,5,7,8,9,3,11,2,-1,-1,-1,-1,-1,-1,-1],
246    [9,5,7,9,7,2,9,2,0,2,7,11,-1,-1,-1,-1],
247    [2,3,11,0,1,8,1,7,8,1,5,7,-1,-1,-1,-1],
248    [11,2,1,11,1,7,7,1,5,-1,-1,-1,-1,-1,-1,-1],
249    [9,5,8,8,5,7,10,1,3,10,3,11,-1,-1,-1,-1],
250    [5,7,0,5,0,9,7,11,0,1,0,10,11,10,0,-1],
251    [11,10,0,11,0,3,10,5,0,8,0,7,5,7,0,-1],
252    [11,10,5,7,11,5,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
253    [10,6,5,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
254    [0,8,3,5,10,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
255    [9,0,1,5,10,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
256    [1,8,3,1,9,8,5,10,6,-1,-1,-1,-1,-1,-1,-1],
257    [1,6,5,2,6,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
258    [1,6,5,1,2,6,3,0,8,-1,-1,-1,-1,-1,-1,-1],
259    [9,6,5,9,0,6,0,2,6,-1,-1,-1,-1,-1,-1,-1],
260    [5,9,8,5,8,2,5,2,6,3,2,8,-1,-1,-1,-1],
261    [2,3,11,10,6,5,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
262    [11,0,8,11,2,0,10,6,5,-1,-1,-1,-1,-1,-1,-1],
263    [0,1,9,2,3,11,5,10,6,-1,-1,-1,-1,-1,-1,-1],
264    [5,10,6,1,9,2,9,11,2,9,8,11,-1,-1,-1,-1],
265    [6,3,11,6,5,3,5,1,3,-1,-1,-1,-1,-1,-1,-1],
266    [0,8,11,0,11,5,0,5,1,5,11,6,-1,-1,-1,-1],
267    [3,11,6,0,3,6,0,6,5,0,5,9,-1,-1,-1,-1],
268    [6,5,9,6,9,11,11,9,8,-1,-1,-1,-1,-1,-1,-1],
269    [5,10,6,4,7,8,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
270    [4,3,0,4,7,3,6,5,10,-1,-1,-1,-1,-1,-1,-1],
271    [1,9,0,5,10,6,8,4,7,-1,-1,-1,-1,-1,-1,-1],
272    [10,6,5,1,9,7,1,7,3,7,9,4,-1,-1,-1,-1],
273    [6,1,2,6,5,1,4,7,8,-1,-1,-1,-1,-1,-1,-1],
274    [1,2,5,5,2,6,3,0,4,3,4,7,-1,-1,-1,-1],
275    [8,4,7,9,0,5,0,6,5,0,2,6,-1,-1,-1,-1],
276    [7,3,9,7,9,4,3,2,9,5,9,6,2,6,9,-1],
277    [3,11,2,7,8,4,10,6,5,-1,-1,-1,-1,-1,-1,-1],
278    [5,10,6,4,7,2,4,2,0,2,7,11,-1,-1,-1,-1],
279    [0,1,9,4,7,8,2,3,11,5,10,6,-1,-1,-1,-1],
280    [9,2,1,9,11,2,9,4,11,7,11,4,5,10,6,-1],
281    [8,4,7,3,11,5,3,5,1,5,11,6,-1,-1,-1,-1],
282    [5,1,11,5,11,6,1,0,11,7,11,4,0,4,11,-1],
283    [0,5,9,0,6,5,0,3,6,11,6,3,8,4,7,-1],
284    [6,5,9,6,9,11,4,7,9,7,11,9,-1,-1,-1,-1],
285    [10,4,9,6,4,10,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
286    [4,10,6,4,9,10,0,8,3,-1,-1,-1,-1,-1,-1,-1],
287    [10,0,1,10,6,0,6,4,0,-1,-1,-1,-1,-1,-1,-1],
288    [8,3,1,8,1,6,8,6,4,6,1,10,-1,-1,-1,-1],
289    [1,4,9,1,2,4,2,6,4,-1,-1,-1,-1,-1,-1,-1],
290    [3,0,8,1,2,9,2,4,9,2,6,4,-1,-1,-1,-1],
291    [0,2,4,4,2,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
292    [8,3,2,8,2,4,4,2,6,-1,-1,-1,-1,-1,-1,-1],
293    [10,4,9,10,6,4,11,2,3,-1,-1,-1,-1,-1,-1,-1],
294    [0,8,2,2,8,11,4,9,10,4,10,6,-1,-1,-1,-1],
295    [3,11,2,0,1,6,0,6,4,6,1,10,-1,-1,-1,-1],
296    [6,4,1,6,1,10,4,8,1,2,1,11,8,11,1,-1],
297    [9,6,4,9,3,6,9,1,3,11,6,3,-1,-1,-1,-1],
298    [8,11,1,8,1,0,11,6,1,9,1,4,6,4,1,-1],
299    [3,11,6,3,6,0,0,6,4,-1,-1,-1,-1,-1,-1,-1],
300    [6,4,8,11,6,8,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
301    [7,10,6,7,8,10,8,9,10,-1,-1,-1,-1,-1,-1,-1],
302    [0,7,3,0,10,7,0,9,10,6,7,10,-1,-1,-1,-1],
303    [10,6,7,1,10,7,1,7,8,1,8,0,-1,-1,-1,-1],
304    [10,6,7,10,7,1,1,7,3,-1,-1,-1,-1,-1,-1,-1],
305    [1,2,6,1,6,8,1,8,9,8,6,7,-1,-1,-1,-1],
306    [2,6,9,2,9,1,6,7,9,0,9,3,7,3,9,-1],
307    [7,8,0,7,0,6,6,0,2,-1,-1,-1,-1,-1,-1,-1],
308    [7,3,2,6,7,2,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
309    [2,3,11,10,6,8,10,8,9,8,6,7,-1,-1,-1,-1],
310    [2,0,7,2,7,11,0,9,7,6,7,10,9,10,7,-1],
311    [1,8,0,1,7,8,1,10,7,6,7,10,2,3,11,-1],
312    [11,2,1,11,1,7,10,6,1,6,7,1,-1,-1,-1,-1],
313    [8,9,6,8,6,7,9,1,6,11,6,3,1,3,6,-1],
314    [0,9,1,11,6,7,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
315    [7,8,0,7,0,6,3,11,0,11,6,0,-1,-1,-1,-1],
316    [7,11,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
317    [7,6,11,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
318    [3,0,8,11,7,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
319    [0,1,9,11,7,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
320    [8,1,9,8,3,1,11,7,6,-1,-1,-1,-1,-1,-1,-1],
321    [10,1,2,6,11,7,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
322    [1,2,10,3,0,8,6,11,7,-1,-1,-1,-1,-1,-1,-1],
323    [2,9,0,2,10,9,6,11,7,-1,-1,-1,-1,-1,-1,-1],
324    [6,11,7,2,10,3,10,8,3,10,9,8,-1,-1,-1,-1],
325    [7,2,3,6,2,7,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
326    [7,0,8,7,6,0,6,2,0,-1,-1,-1,-1,-1,-1,-1],
327    [2,7,6,2,3,7,0,1,9,-1,-1,-1,-1,-1,-1,-1],
328    [1,6,2,1,8,6,1,9,8,8,7,6,-1,-1,-1,-1],
329    [10,7,6,10,1,7,1,3,7,-1,-1,-1,-1,-1,-1,-1],
330    [10,7,6,1,7,10,1,8,7,1,0,8,-1,-1,-1,-1],
331    [0,3,7,0,7,10,0,10,9,6,10,7,-1,-1,-1,-1],
332    [7,6,10,7,10,8,8,10,9,-1,-1,-1,-1,-1,-1,-1],
333    [6,8,4,11,8,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
334    [3,6,11,3,0,6,0,4,6,-1,-1,-1,-1,-1,-1,-1],
335    [8,6,11,8,4,6,9,0,1,-1,-1,-1,-1,-1,-1,-1],
336    [9,4,6,9,6,3,9,3,1,11,3,6,-1,-1,-1,-1],
337    [6,8,4,6,11,8,2,10,1,-1,-1,-1,-1,-1,-1,-1],
338    [1,2,10,3,0,11,0,6,11,0,4,6,-1,-1,-1,-1],
339    [4,11,8,4,6,11,0,2,9,2,10,9,-1,-1,-1,-1],
340    [10,9,3,10,3,2,9,4,3,11,3,6,4,6,3,-1],
341    [8,2,3,8,4,2,4,6,2,-1,-1,-1,-1,-1,-1,-1],
342    [0,4,2,4,6,2,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
343    [1,9,0,2,3,4,2,4,6,4,3,8,-1,-1,-1,-1],
344    [1,9,4,1,4,2,2,4,6,-1,-1,-1,-1,-1,-1,-1],
345    [8,1,3,8,6,1,8,4,6,6,10,1,-1,-1,-1,-1],
346    [10,1,0,10,0,6,6,0,4,-1,-1,-1,-1,-1,-1,-1],
347    [4,6,3,4,3,8,6,10,3,0,3,9,10,9,3,-1],
348    [10,9,4,6,10,4,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
349    [4,9,5,7,6,11,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
350    [0,8,3,4,9,5,11,7,6,-1,-1,-1,-1,-1,-1,-1],
351    [5,0,1,5,4,0,7,6,11,-1,-1,-1,-1,-1,-1,-1],
352    [11,7,6,8,3,4,3,5,4,3,1,5,-1,-1,-1,-1],
353    [9,5,4,10,1,2,7,6,11,-1,-1,-1,-1,-1,-1,-1],
354    [6,11,7,1,2,10,0,8,3,4,9,5,-1,-1,-1,-1],
355    [7,6,11,5,4,10,4,2,10,4,0,2,-1,-1,-1,-1],
356    [3,4,8,3,5,4,3,2,5,10,5,2,11,7,6,-1],
357    [7,2,3,7,6,2,5,4,9,-1,-1,-1,-1,-1,-1,-1],
358    [9,5,4,0,8,6,0,6,2,6,8,7,-1,-1,-1,-1],
359    [3,6,2,3,7,6,1,5,0,5,4,0,-1,-1,-1,-1],
360    [6,2,8,6,8,7,2,1,8,4,8,5,1,5,8,-1],
361    [9,5,4,10,1,6,1,7,6,1,3,7,-1,-1,-1,-1],
362    [1,6,10,1,7,6,1,0,7,8,7,0,9,5,4,-1],
363    [4,0,10,4,10,5,0,3,10,6,10,7,3,7,10,-1],
364    [7,6,10,7,10,8,5,4,10,4,8,10,-1,-1,-1,-1],
365    [6,9,5,6,11,9,11,8,9,-1,-1,-1,-1,-1,-1,-1],
366    [3,6,11,0,6,3,0,5,6,0,9,5,-1,-1,-1,-1],
367    [0,11,8,0,5,11,0,1,5,5,6,11,-1,-1,-1,-1],
368    [6,11,3,6,3,5,5,3,1,-1,-1,-1,-1,-1,-1,-1],
369    [1,2,10,9,5,11,9,11,8,11,5,6,-1,-1,-1,-1],
370    [0,11,3,0,6,11,0,9,6,5,6,9,1,2,10,-1],
371    [11,8,5,11,5,6,8,0,5,10,5,2,0,2,5,-1],
372    [6,11,3,6,3,5,2,10,3,10,5,3,-1,-1,-1,-1],
373    [5,8,9,5,2,8,5,6,2,3,8,2,-1,-1,-1,-1],
374    [9,5,6,9,6,0,0,6,2,-1,-1,-1,-1,-1,-1,-1],
375    [1,5,8,1,8,0,5,6,8,3,8,2,6,2,8,-1],
376    [1,5,6,2,1,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
377    [1,3,6,1,6,10,3,8,6,5,6,9,8,9,6,-1],
378    [10,1,0,10,0,6,9,5,0,5,6,0,-1,-1,-1,-1],
379    [0,3,8,5,6,10,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
380    [10,5,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
381    [11,5,10,7,5,11,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
382    [11,5,10,11,7,5,8,3,0,-1,-1,-1,-1,-1,-1,-1],
383    [5,11,7,5,10,11,1,9,0,-1,-1,-1,-1,-1,-1,-1],
384    [10,7,5,10,11,7,9,8,1,8,3,1,-1,-1,-1,-1],
385    [11,1,2,11,7,1,7,5,1,-1,-1,-1,-1,-1,-1,-1],
386    [0,8,3,1,2,7,1,7,5,7,2,11,-1,-1,-1,-1],
387    [9,7,5,9,2,7,9,0,2,2,11,7,-1,-1,-1,-1],
388    [7,5,2,7,2,11,5,9,2,3,2,8,9,8,2,-1],
389    [2,5,10,2,3,5,3,7,5,-1,-1,-1,-1,-1,-1,-1],
390    [8,2,0,8,5,2,8,7,5,10,2,5,-1,-1,-1,-1],
391    [9,0,1,2,3,10,3,5,10,3,7,5,-1,-1,-1,-1],
392    [1,2,10,9,8,5,8,7,5,8,3,7, -1,-1,-1,-1],
393    [5,3,7,5,1,3,1,2,3,-1,-1,-1,-1,-1,-1,-1],
394    [5,1,7,7,1,3,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
395    [5,9,7,9,0,7,0,3,7,-1,-1,-1,-1,-1,-1,-1],
396    [0,7,5,0,5,9,7,8,5,1,5,3,8,3,5,-1],  // index 239
397    [5,0,2,5,2,7,5,7,9,7,2,11,-1,-1,-1,-1],
398    [9,8,2,9,2,1,8,7,2,10,2,5,7,5,2,-1],
399    [2,3,11,0,1,10,0,10,5,0,5,9, -1,-1,-1,-1],  // placeholder
400    [2,3,11,8,1,10,8,10,9,8,0,1,-1,-1,-1,-1],  // placeholder
401    [11,7,6,10,5,4,10,4,2,4,5,3,-1,-1,-1,-1],
402    [11,7,6,10,5,0,10,0,2,0,5,4,-1,-1,-1,-1],
403    [3,11,7,3,7,0,0,7,5,0,5,9,-1,-1,-1,-1],  // placeholder
404    [9,5,7,9,7,8,5,10,7,11,7,6,10,6,7,-1],  // placeholder
405    [0,8,3,5,10,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],  // placeholder
406    [4,10,6,4,9,10,8,3,0,-1,-1,-1,-1,-1,-1,-1],  // placeholder
407    [10,6,5,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],  // placeholder
408    [11,7,6,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],  // placeholder
409    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
410    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
411    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
412    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
413    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
414    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
415    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
416    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
417    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
418    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
419    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
420    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
421    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
422    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
423    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
424    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
425    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
426    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
427    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
428    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
429    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
430    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
431    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
432    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
433    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
434    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
435    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
436    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
437    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
438    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
439    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
440    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
441    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
442    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
443    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
444    [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],
445];
446
447/// Cube vertex offsets (z, y, x) for the 8 corners of the unit cube.
448const CUBE_VERTICES: [(usize, usize, usize); 8] = [
449    (0, 0, 0), // v0
450    (0, 0, 1), // v1
451    (0, 1, 1), // v2
452    (0, 1, 0), // v3
453    (1, 0, 0), // v4
454    (1, 0, 1), // v5
455    (1, 1, 1), // v6
456    (1, 1, 0), // v7
457];
458
459/// Edges as pairs of vertex indices.
460const CUBE_EDGES: [(usize, usize); 12] = [
461    (0, 1),
462    (1, 2),
463    (2, 3),
464    (3, 0),
465    (4, 5),
466    (5, 6),
467    (6, 7),
468    (7, 4),
469    (0, 4),
470    (1, 5),
471    (2, 6),
472    (3, 7),
473];
474
475/// Interpolate the crossing point along an edge.
476#[inline]
477fn interp_vertex(
478    v0: (f64, f64, f64),
479    v1: (f64, f64, f64),
480    val0: f64,
481    val1: f64,
482    threshold: f64,
483) -> (f64, f64, f64) {
484    let dv = val1 - val0;
485    let t = if dv.abs() < 1e-12 {
486        0.5
487    } else {
488        (threshold - val0) / dv
489    };
490    (
491        v0.0 + t * (v1.0 - v0.0),
492        v0.1 + t * (v1.1 - v0.1),
493        v0.2 + t * (v1.2 - v0.2),
494    )
495}
496
497/// Simplified Marching Cubes isosurface extraction.
498///
499/// Iterates over all unit cubes in `volume` and emits triangles crossing the
500/// `threshold` level. Each triangle is returned as `[z0,y0,x0, z1,y1,x1, z2,y2,x2]`
501/// in voxel coordinate space.
502///
503/// # Arguments
504///
505/// * `volume` – Scalar field array `(depth, height, width)`.
506/// * `threshold` – Isovalue to extract the surface at.
507///
508/// # Errors
509///
510/// Returns `NdimageError::InvalidInput` if the volume has fewer than 2 voxels
511/// along any axis.
512pub fn marching_cubes_simplified(
513    volume: &Array3<f64>,
514    threshold: f64,
515) -> NdimageResult<Vec<[f64; 9]>> {
516    let shape = volume.shape();
517    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
518    if sz < 2 || sy < 2 || sx < 2 {
519        return Err(NdimageError::InvalidInput(
520            "Volume must be at least 2×2×2 for marching cubes".to_string(),
521        ));
522    }
523
524    let mut triangles: Vec<[f64; 9]> = Vec::new();
525
526    for iz in 0..sz - 1 {
527        for iy in 0..sy - 1 {
528            for ix in 0..sx - 1 {
529                // Collect corner values and positions.
530                let mut vals = [0.0_f64; 8];
531                let mut positions = [(0.0_f64, 0.0_f64, 0.0_f64); 8];
532                let mut cube_idx: usize = 0;
533
534                for (vi, (dz, dy, dx)) in CUBE_VERTICES.iter().enumerate() {
535                    let z = iz + dz;
536                    let y = iy + dy;
537                    let x = ix + dx;
538                    vals[vi] = volume[[z, y, x]];
539                    positions[vi] = (z as f64, y as f64, x as f64);
540                    if vals[vi] >= threshold {
541                        cube_idx |= 1 << vi;
542                    }
543                }
544
545                let edge_mask = MC_EDGE_TABLE[cube_idx];
546                if edge_mask == 0 {
547                    continue;
548                }
549
550                // Compute edge intersection points.
551                let mut edge_pts = [(0.0_f64, 0.0_f64, 0.0_f64); 12];
552                for (ei, &(va, vb)) in CUBE_EDGES.iter().enumerate() {
553                    if edge_mask & (1 << ei) != 0 {
554                        edge_pts[ei] = interp_vertex(
555                            positions[va],
556                            positions[vb],
557                            vals[va],
558                            vals[vb],
559                            threshold,
560                        );
561                    }
562                }
563
564                // Emit triangles.
565                let tri_row = &MC_TRI_TABLE[cube_idx];
566                let mut ti = 0;
567                while ti < 15 {
568                    let e0 = tri_row[ti];
569                    let e1 = tri_row[ti + 1];
570                    let e2 = tri_row[ti + 2];
571                    if e0 < 0 {
572                        break;
573                    }
574                    let p0 = edge_pts[e0 as usize];
575                    let p1 = edge_pts[e1 as usize];
576                    let p2 = edge_pts[e2 as usize];
577                    triangles.push([p0.0, p0.1, p0.2, p1.0, p1.1, p1.2, p2.0, p2.1, p2.2]);
578                    ti += 3;
579                }
580            }
581        }
582    }
583
584    Ok(triangles)
585}
586
587// ---------------------------------------------------------------------------
588// Isosurface area
589// ---------------------------------------------------------------------------
590
591/// Compute the surface area of an isosurface using the Marching Cubes triangle
592/// list. Each triangle contributes its own area to the total.
593///
594/// # Arguments
595///
596/// * `volume` – Scalar field.
597/// * `threshold` – Isovalue.
598/// * `voxel_size` – Isotropic voxel edge length in mm. The returned area is in mm².
599///
600/// # Errors
601///
602/// Propagates errors from `marching_cubes_simplified`.
603pub fn isosurface_area(
604    volume: &Array3<f64>,
605    threshold: f64,
606    voxel_size: f64,
607) -> NdimageResult<f64> {
608    let triangles = marching_cubes_simplified(volume, threshold)?;
609    let mut total_area = 0.0_f64;
610    for tri in &triangles {
611        // Vertices in voxel units.
612        let (az, ay, ax) = (tri[0], tri[1], tri[2]);
613        let (bz, by, bx) = (tri[3], tri[4], tri[5]);
614        let (cz, cy, cx) = (tri[6], tri[7], tri[8]);
615        // Edge vectors.
616        let (uz, uy, ux) = (bz - az, by - ay, bx - ax);
617        let (vz, vy, vx) = (cz - az, cy - ay, cx - ax);
618        // Cross product magnitude / 2 = triangle area.
619        let cross_z = uy * vx - ux * vy;
620        let cross_y = ux * vz - uz * vx;
621        let cross_x = uz * vy - uy * vz;
622        let area = 0.5 * (cross_z * cross_z + cross_y * cross_y + cross_x * cross_x).sqrt();
623        total_area += area;
624    }
625    Ok(total_area * voxel_size * voxel_size)
626}
627
628// ---------------------------------------------------------------------------
629// 3D connected components
630// ---------------------------------------------------------------------------
631
632/// Label connected components of a binary 3D volume using 6-connectivity BFS.
633///
634/// Background voxels (false) receive label 0.  Foreground components receive
635/// labels 1, 2, 3, …
636///
637/// # Arguments
638///
639/// * `binary` – Boolean foreground mask `(depth, height, width)`.
640///
641/// # Errors
642///
643/// Returns `NdimageError::InvalidInput` for an empty volume.
644pub fn connected_components_3d(binary: &Array3<bool>) -> NdimageResult<Array3<usize>> {
645    let shape = binary.shape();
646    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
647    if sz == 0 || sy == 0 || sx == 0 {
648        return Err(NdimageError::InvalidInput(
649            "Volume must be non-empty".to_string(),
650        ));
651    }
652
653    let mut labels = Array3::<usize>::zeros((sz, sy, sx));
654    let mut next_label: usize = 1;
655
656    // 6-connected face neighbours.
657    let neighbours: [(isize, isize, isize); 6] = [
658        (-1, 0, 0),
659        (1, 0, 0),
660        (0, -1, 0),
661        (0, 1, 0),
662        (0, 0, -1),
663        (0, 0, 1),
664    ];
665
666    let mut queue: VecDeque<(usize, usize, usize)> = VecDeque::new();
667
668    for iz in 0..sz {
669        for iy in 0..sy {
670            for ix in 0..sx {
671                if !binary[[iz, iy, ix]] || labels[[iz, iy, ix]] != 0 {
672                    continue;
673                }
674                // BFS flood-fill.
675                labels[[iz, iy, ix]] = next_label;
676                queue.clear();
677                queue.push_back((iz, iy, ix));
678                while let Some((cz, cy, cx)) = queue.pop_front() {
679                    for (dz, dy, dx) in &neighbours {
680                        let nz = cz as isize + dz;
681                        let ny = cy as isize + dy;
682                        let nx = cx as isize + dx;
683                        if nz < 0
684                            || ny < 0
685                            || nx < 0
686                            || nz >= sz as isize
687                            || ny >= sy as isize
688                            || nx >= sx as isize
689                        {
690                            continue;
691                        }
692                        let (nzu, nyu, nxu) = (nz as usize, ny as usize, nx as usize);
693                        if binary[[nzu, nyu, nxu]] && labels[[nzu, nyu, nxu]] == 0 {
694                            labels[[nzu, nyu, nxu]] = next_label;
695                            queue.push_back((nzu, nyu, nxu));
696                        }
697                    }
698                }
699                next_label += 1;
700            }
701        }
702    }
703
704    Ok(labels)
705}
706
707// ---------------------------------------------------------------------------
708// Spherical structuring element helper
709// ---------------------------------------------------------------------------
710
711/// Generate the set of voxel offsets inside a sphere of the given radius.
712fn sphere_offsets(radius: f64) -> Vec<(isize, isize, isize)> {
713    let r = radius.ceil() as isize;
714    let r2 = radius * radius;
715    let mut offsets = Vec::new();
716    for dz in -r..=r {
717        for dy in -r..=r {
718            for dx in -r..=r {
719                let d2 = (dz * dz + dy * dy + dx * dx) as f64;
720                if d2 <= r2 + 1e-9 {
721                    offsets.push((dz, dy, dx));
722                }
723            }
724        }
725    }
726    offsets
727}
728
729// ---------------------------------------------------------------------------
730// 3D dilation
731// ---------------------------------------------------------------------------
732
733/// Dilate a binary 3D volume using a spherical structuring element.
734///
735/// A background voxel is set to foreground when it falls within `radius`
736/// (Euclidean) of any foreground voxel.
737///
738/// # Arguments
739///
740/// * `binary` – Binary input volume.
741/// * `radius` – Sphere radius in voxels.
742///
743/// # Errors
744///
745/// Returns `NdimageError::InvalidInput` for an empty volume.
746pub fn dilation_3d(binary: &Array3<bool>, radius: f64) -> NdimageResult<Array3<bool>> {
747    let shape = binary.shape();
748    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
749    if sz == 0 || sy == 0 || sx == 0 {
750        return Err(NdimageError::InvalidInput(
751            "Volume must be non-empty".to_string(),
752        ));
753    }
754
755    let offsets = sphere_offsets(radius);
756    let mut out = Array3::from_elem((sz, sy, sx), false);
757
758    for iz in 0..sz {
759        for iy in 0..sy {
760            for ix in 0..sx {
761                if !binary[[iz, iy, ix]] {
762                    continue;
763                }
764                // Set all voxels in the sphere footprint.
765                for (dz, dy, dx) in &offsets {
766                    let nz = iz as isize + dz;
767                    let ny = iy as isize + dy;
768                    let nx = ix as isize + dx;
769                    if nz >= 0
770                        && ny >= 0
771                        && nx >= 0
772                        && nz < sz as isize
773                        && ny < sy as isize
774                        && nx < sx as isize
775                    {
776                        out[[nz as usize, ny as usize, nx as usize]] = true;
777                    }
778                }
779            }
780        }
781    }
782
783    Ok(out)
784}
785
786// ---------------------------------------------------------------------------
787// 3D erosion
788// ---------------------------------------------------------------------------
789
790/// Erode a binary 3D volume using a spherical structuring element.
791///
792/// A foreground voxel survives erosion only when every voxel within `radius`
793/// (Euclidean) is also foreground.
794///
795/// # Arguments
796///
797/// * `binary` – Binary input volume.
798/// * `radius` – Sphere radius in voxels.
799///
800/// # Errors
801///
802/// Returns `NdimageError::InvalidInput` for an empty volume.
803pub fn erosion_3d(binary: &Array3<bool>, radius: f64) -> NdimageResult<Array3<bool>> {
804    let shape = binary.shape();
805    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
806    if sz == 0 || sy == 0 || sx == 0 {
807        return Err(NdimageError::InvalidInput(
808            "Volume must be non-empty".to_string(),
809        ));
810    }
811
812    let offsets = sphere_offsets(radius);
813    let mut out = Array3::from_elem((sz, sy, sx), false);
814
815    for iz in 0..sz {
816        for iy in 0..sy {
817            for ix in 0..sx {
818                if !binary[[iz, iy, ix]] {
819                    continue;
820                }
821                // Erode: every neighbour within the sphere must be foreground.
822                let survives = offsets.iter().all(|(dz, dy, dx)| {
823                    let nz = iz as isize + dz;
824                    let ny = iy as isize + dy;
825                    let nx = ix as isize + dx;
826                    if nz < 0
827                        || ny < 0
828                        || nx < 0
829                        || nz >= sz as isize
830                        || ny >= sy as isize
831                        || nx >= sx as isize
832                    {
833                        // Out-of-bounds counts as background → erosion removes this voxel.
834                        false
835                    } else {
836                        binary[[nz as usize, ny as usize, nx as usize]]
837                    }
838                });
839                out[[iz, iy, ix]] = survives;
840            }
841        }
842    }
843
844    Ok(out)
845}
846
847// ---------------------------------------------------------------------------
848// 3D watershed
849// ---------------------------------------------------------------------------
850
851/// Priority-queue entry for 3D watershed.
852///
853/// Uses a min-heap ordered by gradient value, with ties broken by insertion order.
854/// We store the gradient value as bits of a u64 using a careful total-order comparison
855/// so that we can use the standard `BinaryHeap` (max-heap) by negating the ordering.
856#[derive(Debug)]
857struct WatershedEntry {
858    /// Gradient value at this voxel.
859    val: f64,
860    /// Insertion order for stable tie-breaking.
861    seq: u64,
862    z: usize,
863    y: usize,
864    x: usize,
865}
866
867impl PartialEq for WatershedEntry {
868    fn eq(&self, other: &Self) -> bool {
869        // Compare bits so NaN == NaN (both map to the same u64).
870        self.val.to_bits() == other.val.to_bits() && self.seq == other.seq
871    }
872}
873
874impl Eq for WatershedEntry {}
875
876impl PartialOrd for WatershedEntry {
877    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
878        Some(self.cmp(other))
879    }
880}
881
882impl Ord for WatershedEntry {
883    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
884        // We want a MIN-heap (lower gradient first) but BinaryHeap is a max-heap,
885        // so we reverse the comparison on `val`.
886        // Total order for f64: treat NaN as larger than everything.
887        let ord_val = |v: f64| -> u64 {
888            let bits = v.to_bits();
889            // For positive floats the bit pattern is already the ordering.
890            // For negatives we flip all bits to get the correct reverse ordering.
891            if bits >> 63 == 0 {
892                bits | (1u64 << 63)
893            } else {
894                !bits
895            }
896        };
897        // Reverse comparison on val → min-heap behaviour.
898        ord_val(other.val)
899            .cmp(&ord_val(self.val))
900            .then(other.seq.cmp(&self.seq))
901    }
902}
903
904/// 3D watershed segmentation seeded from given seed voxels.
905///
906/// Implements a priority-queue (flooding) watershed operating on the gradient
907/// magnitude.  Seeds are assigned labels 1, 2, 3, … in the order they appear
908/// in the `seeds` slice.  Unsegmented voxels receive label 0.
909///
910/// # Arguments
911///
912/// * `gradient` – Non-negative gradient magnitude field `(depth, height, width)`.
913/// * `seeds` – List of seed voxels `(z, y, x)`.
914///
915/// # Errors
916///
917/// Returns `NdimageError::InvalidInput` when the gradient volume is empty or a
918/// seed coordinate is out of bounds.
919pub fn watershed_3d(
920    gradient: &Array3<f64>,
921    seeds: &[(usize, usize, usize)],
922) -> NdimageResult<Array3<usize>> {
923    let shape = gradient.shape();
924    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
925    if sz == 0 || sy == 0 || sx == 0 {
926        return Err(NdimageError::InvalidInput(
927            "Gradient volume must be non-empty".to_string(),
928        ));
929    }
930
931    let mut labels = Array3::<usize>::zeros((sz, sy, sx));
932    let mut heap: BinaryHeap<WatershedEntry> = BinaryHeap::new();
933    let mut seq: u64 = 0;
934
935    // Initialise seeds.
936    for (label_idx, &(sz_s, sy_s, sx_s)) in seeds.iter().enumerate() {
937        if sz_s >= sz || sy_s >= sy || sx_s >= sx {
938            return Err(NdimageError::InvalidInput(format!(
939                "Seed ({sz_s},{sy_s},{sx_s}) is out of bounds ({sz},{sy},{sx})"
940            )));
941        }
942        let label = label_idx + 1;
943        if labels[[sz_s, sy_s, sx_s]] == 0 {
944            labels[[sz_s, sy_s, sx_s]] = label;
945            heap.push(WatershedEntry {
946                val: gradient[[sz_s, sy_s, sx_s]],
947                seq,
948                z: sz_s,
949                y: sy_s,
950                x: sx_s,
951            });
952            seq += 1;
953        }
954    }
955
956    let face_offsets: [(isize, isize, isize); 6] = [
957        (-1, 0, 0),
958        (1, 0, 0),
959        (0, -1, 0),
960        (0, 1, 0),
961        (0, 0, -1),
962        (0, 0, 1),
963    ];
964
965    while let Some(entry) = heap.pop() {
966        let lbl = labels[[entry.z, entry.y, entry.x]];
967        if lbl == 0 {
968            continue;
969        }
970        for (dz, dy, dx) in &face_offsets {
971            let nz = entry.z as isize + dz;
972            let ny = entry.y as isize + dy;
973            let nx = entry.x as isize + dx;
974            if nz < 0
975                || ny < 0
976                || nx < 0
977                || nz >= sz as isize
978                || ny >= sy as isize
979                || nx >= sx as isize
980            {
981                continue;
982            }
983            let (nzu, nyu, nxu) = (nz as usize, ny as usize, nx as usize);
984            if labels[[nzu, nyu, nxu]] == 0 {
985                labels[[nzu, nyu, nxu]] = lbl;
986                heap.push(WatershedEntry {
987                    val: gradient[[nzu, nyu, nxu]],
988                    seq,
989                    z: nzu,
990                    y: nyu,
991                    x: nxu,
992                });
993                seq += 1;
994            }
995        }
996    }
997
998    Ok(labels)
999}
1000
1001// ---------------------------------------------------------------------------
1002// Tests
1003// ---------------------------------------------------------------------------
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008    use scirs2_core::ndarray::Array3;
1009
1010    #[test]
1011    fn test_analyze_volume_solid_cube() {
1012        let vol = Array3::from_elem((4, 4, 4), true);
1013        let stats = analyze_volume(&vol, 1.0).expect("analyze_volume failed");
1014        assert_eq!(stats.voxel_count, 64);
1015        assert!((stats.volume_mm3 - 64.0).abs() < 1e-9);
1016        // Only surface voxels in a 4×4×4 cube — the inner 2×2×2 are interior.
1017        assert!(stats.surface_area_voxels < 64);
1018        assert!((stats.centroid.0 - 1.5).abs() < 1e-9);
1019    }
1020
1021    #[test]
1022    fn test_analyze_volume_empty_mask() {
1023        let vol = Array3::from_elem((3, 3, 3), false);
1024        let stats = analyze_volume(&vol, 1.0).expect("analyze_volume failed");
1025        assert_eq!(stats.voxel_count, 0);
1026        assert_eq!(stats.volume_mm3, 0.0);
1027    }
1028
1029    #[test]
1030    fn test_connected_components_two_blobs() {
1031        let mut vol = Array3::from_elem((5, 5, 5), false);
1032        // Blob A at corner.
1033        vol[[0, 0, 0]] = true;
1034        vol[[0, 0, 1]] = true;
1035        vol[[0, 1, 0]] = true;
1036        // Blob B separated from A.
1037        vol[[4, 4, 4]] = true;
1038        vol[[4, 4, 3]] = true;
1039
1040        let labels = connected_components_3d(&vol).expect("connected_components failed");
1041        // Two distinct non-zero labels expected.
1042        let l_a = labels[[0, 0, 0]];
1043        let l_b = labels[[4, 4, 4]];
1044        assert_ne!(l_a, 0);
1045        assert_ne!(l_b, 0);
1046        assert_ne!(l_a, l_b);
1047        // Connected within blob A.
1048        assert_eq!(labels[[0, 0, 1]], l_a);
1049        assert_eq!(labels[[0, 1, 0]], l_a);
1050        // Connected within blob B.
1051        assert_eq!(labels[[4, 4, 3]], l_b);
1052    }
1053
1054    #[test]
1055    fn test_dilation_grows_volume() {
1056        let mut vol = Array3::from_elem((7, 7, 7), false);
1057        vol[[3, 3, 3]] = true;
1058        let dilated = dilation_3d(&vol, 1.5).expect("dilation failed");
1059        let count_before = 1_usize;
1060        let count_after = dilated.iter().filter(|&&v| v).count();
1061        assert!(count_after > count_before);
1062    }
1063
1064    #[test]
1065    fn test_erosion_shrinks_volume() {
1066        let vol = Array3::from_elem((5, 5, 5), true);
1067        let eroded = erosion_3d(&vol, 1.0).expect("erosion failed");
1068        let count = eroded.iter().filter(|&&v| v).count();
1069        assert!(count < 125);
1070    }
1071
1072    #[test]
1073    fn test_watershed_3d_two_seeds() {
1074        let grad = Array3::<f64>::zeros((5, 5, 5));
1075        let seeds = [(0, 0, 0), (4, 4, 4)];
1076        let labels = watershed_3d(&grad, &seeds).expect("watershed failed");
1077        // Seed 1 region and seed 2 region must both be present.
1078        assert!(labels.iter().any(|&v| v == 1));
1079        assert!(labels.iter().any(|&v| v == 2));
1080    }
1081
1082    #[test]
1083    fn test_marching_cubes_sphere() {
1084        // Build a small sphere-like SDF.
1085        let size = 8_usize;
1086        let center = size as f64 / 2.0;
1087        let vol = Array3::from_shape_fn((size, size, size), |(z, y, x)| {
1088            let dz = z as f64 - center;
1089            let dy = y as f64 - center;
1090            let dx = x as f64 - center;
1091            (dz * dz + dy * dy + dx * dx).sqrt() - 2.5
1092        });
1093        let tris = marching_cubes_simplified(&vol, 0.0).expect("marching_cubes failed");
1094        assert!(
1095            !tris.is_empty(),
1096            "Expected non-empty triangle list for sphere SDF"
1097        );
1098    }
1099}