Skip to main content

qsm_core/utils/
morphology.rs

1//! Binary mask morphology on 3D volumes (Fortran order, `x + y*nx + z*nx*ny`).
2//!
3//! - [`erode_mask`] / [`dilate_mask`] — iterated 6-connected erosion and dilation
4//! - [`morphological_close`] — closing with a spherical structuring element
5//! - [`fill_holes`] — fill enclosed background components up to a size limit
6//! - [`signal_gated_erosion`] — erode only low-signal boundary voxels ([`signal_erosion`])
7
8use crate::Grid;
9use crate::utils::bias_correction::{check_volume, idx3d};
10
11pub mod signal_erosion;
12pub use signal_erosion::{signal_gated_erosion, SignalErosionParams};
13
14/// Erode a binary mask by removing boundary voxels (6-connectivity).
15///
16/// Each iteration removes voxels that have any 6-connected neighbor equal to 0
17/// or that sit on the volume boundary.
18pub fn erode_mask(mask: &[u8], grid: &Grid, iterations: usize) -> Vec<u8> {
19    let (nx, ny, nz) = grid.dims;
20    let mut current = mask.to_vec();
21    for _ in 0..iterations {
22        let mut eroded = current.clone();
23        for z in 0..nz {
24            for y in 0..ny {
25                for x in 0..nx {
26                    let idx = x + y * nx + z * nx * ny;
27                    if current[idx] == 0 {
28                        continue;
29                    }
30                    if x == 0
31                        || x == nx - 1
32                        || y == 0
33                        || y == ny - 1
34                        || z == 0
35                        || z == nz - 1
36                        || current[idx - 1] == 0
37                        || current[idx + 1] == 0
38                        || current[idx - nx] == 0
39                        || current[idx + nx] == 0
40                        || current[idx - nx * ny] == 0
41                        || current[idx + nx * ny] == 0
42                    {
43                        eroded[idx] = 0;
44                    }
45                }
46            }
47        }
48        current = eroded;
49    }
50    current
51}
52
53/// Dilate a binary mask by expanding into neighboring voxels (6-connectivity).
54///
55/// Each iteration adds voxels that have any 6-connected neighbor equal to 1.
56pub fn dilate_mask(mask: &[u8], grid: &Grid, iterations: usize) -> Vec<u8> {
57    let (nx, ny, nz) = grid.dims;
58    let mut current = mask.to_vec();
59    for _ in 0..iterations {
60        let mut dilated = current.clone();
61        for z in 0..nz {
62            for y in 0..ny {
63                for x in 0..nx {
64                    let idx = x + y * nx + z * nx * ny;
65                    if current[idx] == 1 {
66                        continue;
67                    }
68                    let has_neighbor = (x > 0 && current[idx - 1] == 1)
69                        || (x < nx - 1 && current[idx + 1] == 1)
70                        || (y > 0 && current[idx - nx] == 1)
71                        || (y < ny - 1 && current[idx + nx] == 1)
72                        || (z > 0 && current[idx - nx * ny] == 1)
73                        || (z < nz - 1 && current[idx + nx * ny] == 1);
74                    if has_neighbor {
75                        dilated[idx] = 1;
76                    }
77                }
78            }
79        }
80        current = dilated;
81    }
82    current
83}
84
85/// Erode a binary mask with a spherical structuring element of `radius` voxels. Voxels whose
86/// ball crosses the volume edge are removed (unlike MATLAB's `imerode`: see
87/// [`imerode_sphere`]).
88pub(crate) fn erode_ball(mask: &[u8], nx: usize, ny: usize, nz: usize, radius: i32) -> Vec<u8> {
89    erode_ball_padded(mask, nx, ny, nz, radius, false)
90}
91
92/// MATLAB's `imerode(mask, strel('sphere', radius))`: `imerode` pads the volume with its
93/// maximum, so the edge doesn't erode a mask that touches it.
94pub(crate) fn imerode_sphere(mask: &[u8], nx: usize, ny: usize, nz: usize, radius: i32) -> Vec<u8> {
95    erode_ball_padded(mask, nx, ny, nz, radius, true)
96}
97
98fn erode_ball_padded(mask: &[u8], nx: usize, ny: usize, nz: usize, radius: i32, edge_inside: bool) -> Vec<u8> {
99    let n_total = nx * ny * nz;
100    let mut eroded = vec![0u8; n_total];
101
102    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
103
104    for k in 0..nz {
105        for j in 0..ny {
106            for i in 0..nx {
107                if mask[idx(i, j, k)] == 0 {
108                    continue;
109                }
110
111                let mut all_inside = true;
112
113                'outer: for dz in -radius..=radius {
114                    for dy in -radius..=radius {
115                        for dx in -radius..=radius {
116                            let dist2 = dx * dx + dy * dy + dz * dz;
117                            if dist2 > radius * radius {
118                                continue;
119                            }
120
121                            let ni = i as i32 + dx;
122                            let nj = j as i32 + dy;
123                            let nk = k as i32 + dz;
124
125                            if ni < 0 || ni >= nx as i32 ||
126                               nj < 0 || nj >= ny as i32 ||
127                               nk < 0 || nk >= nz as i32 {
128                                if edge_inside {
129                                    continue;
130                                }
131                                all_inside = false;
132                                break 'outer;
133                            }
134
135                            if mask[idx(ni as usize, nj as usize, nk as usize)] == 0 {
136                                all_inside = false;
137                                break 'outer;
138                            }
139                        }
140                    }
141                }
142
143                if all_inside {
144                    eroded[idx(i, j, k)] = 1;
145                }
146            }
147        }
148    }
149
150    eroded
151}
152
153/// Dilate a binary mask with a spherical structuring element of `radius` voxels
154/// (MATLAB `imdilate(mask, strel('sphere', radius))`).
155pub(crate) fn dilate_ball(mask: &[u8], nx: usize, ny: usize, nz: usize, radius: i32) -> Vec<u8> {
156    let n_total = nx * ny * nz;
157    let mut dilated = vec![0u8; n_total];
158
159    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
160
161    for k in 0..nz {
162        for j in 0..ny {
163            for i in 0..nx {
164                if mask[idx(i, j, k)] != 0 {
165                    // Set all neighbors within radius
166                    for dz in -radius..=radius {
167                        for dy in -radius..=radius {
168                            for dx in -radius..=radius {
169                                let dist2 = dx * dx + dy * dy + dz * dz;
170                                if dist2 > radius * radius {
171                                    continue;
172                                }
173
174                                let ni = i as i32 + dx;
175                                let nj = j as i32 + dy;
176                                let nk = k as i32 + dz;
177
178                                if ni >= 0 && ni < nx as i32 &&
179                                   nj >= 0 && nj < ny as i32 &&
180                                   nk >= 0 && nk < nz as i32 {
181                                    dilated[idx(ni as usize, nj as usize, nk as usize)] = 1;
182                                }
183                            }
184                        }
185                    }
186                }
187            }
188        }
189    }
190
191    dilated
192}
193
194/// Morphological closing (dilation followed by erosion) with a spherical structuring element
195/// of `radius` voxels.
196pub fn morphological_close(mask: &[u8], grid: &Grid, radius: i32) -> Vec<u8> {
197    let (nx, ny, nz) = grid.dims;
198    let dilated = dilate_ball(mask, nx, ny, nz, radius);
199    erode_ball(&dilated, nx, ny, nz, radius)
200}
201
202/// Find connected component using flood fill (6-connectivity in 3D)
203fn flood_fill_component(
204    mask: &[u8],
205    visited: &mut [bool],
206    start: usize,
207    nx: usize, ny: usize, nz: usize,
208) -> Vec<usize> {
209    let mut component = Vec::new();
210    let mut stack = vec![start];
211
212    while let Some(idx) = stack.pop() {
213        if visited[idx] || mask[idx] != 0 {
214            continue;
215        }
216
217        visited[idx] = true;
218        component.push(idx);
219
220        // Get 3D coordinates
221        let k = idx / (nx * ny);
222        let rem = idx % (nx * ny);
223        let j = rem / nx;
224        let i = rem % nx;
225
226        // 6-connectivity neighbors
227        if i > 0 {
228            let n = idx3d(i - 1, j, k, nx, ny);
229            if !visited[n] && mask[n] == 0 { stack.push(n); }
230        }
231        if i + 1 < nx {
232            let n = idx3d(i + 1, j, k, nx, ny);
233            if !visited[n] && mask[n] == 0 { stack.push(n); }
234        }
235        if j > 0 {
236            let n = idx3d(i, j - 1, k, nx, ny);
237            if !visited[n] && mask[n] == 0 { stack.push(n); }
238        }
239        if j + 1 < ny {
240            let n = idx3d(i, j + 1, k, nx, ny);
241            if !visited[n] && mask[n] == 0 { stack.push(n); }
242        }
243        if k > 0 {
244            let n = idx3d(i, j, k - 1, nx, ny);
245            if !visited[n] && mask[n] == 0 { stack.push(n); }
246        }
247        if k + 1 < nz {
248            let n = idx3d(i, j, k + 1, nx, ny);
249            if !visited[n] && mask[n] == 0 { stack.push(n); }
250        }
251    }
252
253    component
254}
255
256/// Fill holes in a binary mask
257///
258/// Matches MriResearchTools.jl's fill_holes function (771eadf; to the voxel against it in the
259/// verified QSM-CI `julia-qsm` image, `tests/mritools_parity.rs`).
260/// Fills connected components of zeros (holes) up to max_hole_size.
261/// Uses 6-connectivity for 3D.
262pub fn fill_holes(mask: &[u8], grid: &Grid, max_hole_size: usize) -> Vec<u8> {
263    let (nx, ny, nz) = grid.dims;
264    let n_total = check_volume("fill_holes: mask", mask.len(), grid);
265    let mut result = mask.to_vec();
266    let mut visited = vec![false; n_total];
267
268    // Find all connected components of zeros (potential holes)
269    for idx in 0..n_total {
270        if mask[idx] == 0 && !visited[idx] {
271            let component = flood_fill_component(mask, &mut visited, idx, nx, ny, nz);
272
273            // Check if this component touches the boundary
274            let mut touches_boundary = false;
275            for &cidx in &component {
276                let k = cidx / (nx * ny);
277                let rem = cidx % (nx * ny);
278                let j = rem / nx;
279                let i = rem % nx;
280
281                if i == 0 || i == nx - 1 || j == 0 || j == ny - 1 || k == 0 || k == nz - 1 {
282                    touches_boundary = true;
283                    break;
284                }
285            }
286
287            // Fill if it's a hole (doesn't touch boundary) and small enough
288            if !touches_boundary && component.len() <= max_hole_size {
289                for cidx in component {
290                    result[cidx] = 1;
291                }
292            }
293        }
294    }
295
296    result
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
304        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
305    }
306
307    /// Create a solid sphere mask centered in an n x n x n volume.
308    fn make_sphere_mask(n: usize, radius: f64) -> Vec<u8> {
309        let center = n as f64 / 2.0;
310        let mut mask = vec![0u8; n * n * n];
311        for k in 0..n {
312            for j in 0..n {
313                for i in 0..n {
314                    let dx = i as f64 - center;
315                    let dy = j as f64 - center;
316                    let dz = k as f64 - center;
317                    if (dx * dx + dy * dy + dz * dz).sqrt() < radius {
318                        mask[i + j * n + k * n * n] = 1;
319                    }
320                }
321            }
322        }
323        mask
324    }
325
326    #[test]
327    fn test_erode_mask_cube() {
328        // 3x3x3 all-ones mask: erosion should remove everything (all on boundary)
329        let mask = vec![1u8; 27];
330        let result = erode_mask(&mask, &grid(3, 3, 3), 1);
331        assert_eq!(result.iter().filter(|&&v| v == 1).count(), 1); // only center
332    }
333
334    #[test]
335    fn test_dilate_mask_single_voxel() {
336        // Single voxel in center of 5x5x5: dilation should add 6 neighbors
337        let mut mask = vec![0u8; 125];
338        mask[2 + 2 * 5 + 2 * 25] = 1; // center
339        let result = dilate_mask(&mask, &grid(5, 5, 5), 1);
340        assert_eq!(result.iter().filter(|&&v| v == 1).count(), 7); // center + 6 neighbors
341    }
342
343    #[test]
344    fn test_erode_ball() {
345        // 5x5x5 solid cube
346        let mask = vec![1u8; 125];
347        let eroded = erode_ball(&mask, 5, 5, 5, 1);
348
349        // Center 3x3x3 should remain
350        let count: usize = eroded.iter().map(|&v| v as usize).sum();
351        assert!(count > 0);
352        assert!(count < 125);
353    }
354
355    #[test]
356    fn test_dilate_ball() {
357        // Single center voxel in 5x5x5
358        let mut mask = vec![0u8; 125];
359        mask[62] = 1; // Center
360
361        let dilated = dilate_ball(&mask, 5, 5, 5, 1);
362
363        // Should expand to 6-connectivity
364        let count: usize = dilated.iter().map(|&v| v as usize).sum();
365        assert!(count >= 7); // At least 7 voxels (center + 6 neighbors)
366    }
367
368    // =====================================================================
369    // Tests for erode_ball
370    // =====================================================================
371
372    #[test]
373    fn test_erode_ball_sphere() {
374        let n = 10;
375        let mask = make_sphere_mask(n, 4.0);
376        let eroded = erode_ball(&mask, n, n, n, 1);
377
378        let orig_count: usize = mask.iter().map(|&v| v as usize).sum();
379        let eroded_count: usize = eroded.iter().map(|&v| v as usize).sum();
380        assert!(
381            eroded_count < orig_count,
382            "Eroded sphere should be smaller: {} < {}",
383            eroded_count,
384            orig_count
385        );
386        assert!(eroded_count > 0, "Eroded sphere should not be empty");
387
388        // Center should still be in eroded mask
389        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
390        assert_eq!(eroded[center], 1, "Center should survive erosion");
391    }
392
393    #[test]
394    fn test_erode_ball_single_voxel() {
395        // A single voxel should be eroded away
396        let mut mask = vec![0u8; 125];
397        mask[62] = 1; // center of 5x5x5
398        let eroded = erode_ball(&mask, 5, 5, 5, 1);
399        let count: usize = eroded.iter().map(|&v| v as usize).sum();
400        assert_eq!(count, 0, "Single voxel should be fully eroded");
401    }
402
403    // =====================================================================
404    // Tests for dilate_ball
405    // =====================================================================
406
407    #[test]
408    fn test_dilate_ball_sphere() {
409        let n = 10;
410        let mask = make_sphere_mask(n, 3.0);
411        let dilated = dilate_ball(&mask, n, n, n, 1);
412
413        let orig_count: usize = mask.iter().map(|&v| v as usize).sum();
414        let dilated_count: usize = dilated.iter().map(|&v| v as usize).sum();
415        assert!(
416            dilated_count > orig_count,
417            "Dilated sphere should be larger: {} > {}",
418            dilated_count,
419            orig_count
420        );
421    }
422
423    #[test]
424    fn test_dilate_ball_radius_2() {
425        let mut mask = vec![0u8; 125];
426        mask[62] = 1; // center of 5x5x5
427        let dilated = dilate_ball(&mask, 5, 5, 5, 2);
428        let count: usize = dilated.iter().map(|&v| v as usize).sum();
429        // Should be more than radius=1 dilation
430        assert!(count > 7, "Radius-2 dilation should produce more than 7 voxels, got {}", count);
431    }
432
433    // =====================================================================
434    // Tests for morphological_close
435    // =====================================================================
436
437    #[test]
438    fn test_morphological_close_fills_small_gaps() {
439        let n = 10;
440        let mut mask = make_sphere_mask(n, 4.0);
441        // Remove a surface voxel to create a small gap
442        let eroded = erode_ball(&mask, n, n, n, 1);
443        let surface: Vec<usize> = (0..mask.len()).filter(|&i| mask[i] != 0 && eroded[i] == 0).collect();
444        if !surface.is_empty() {
445            mask[surface[0]] = 0;
446        }
447
448        let closed = morphological_close(&mask, &grid(n, n, n), 1);
449        let orig_count: usize = mask.iter().map(|&v| v as usize).sum();
450        let closed_count: usize = closed.iter().map(|&v| v as usize).sum();
451        // Closing should recover the gap or at least not shrink significantly
452        assert!(
453            closed_count >= orig_count,
454            "Closing should not reduce mask size: {} vs {}",
455            closed_count,
456            orig_count
457        );
458    }
459
460    #[test]
461    fn test_morphological_close_empty() {
462        let mask = vec![0u8; 27];
463        let closed = morphological_close(&mask, &grid(3, 3, 3), 1);
464        let count: usize = closed.iter().map(|&v| v as usize).sum();
465        assert_eq!(count, 0, "Closing empty mask should stay empty");
466    }
467
468    // =====================================================================
469    // Tests for fill_holes
470    // =====================================================================
471
472    #[test]
473    fn test_fill_holes_basic() {
474        // 3x3x3 cube with a hole in the center
475        let mut mask = vec![1u8; 27];
476        mask[13] = 0; // center voxel
477
478        let filled = fill_holes(&mask, &grid(3, 3, 3), 5);
479        assert_eq!(filled[13], 1, "Center hole should be filled");
480    }
481
482    // =====================================================================
483    // Tests for fill_holes (more complex cases)
484    // =====================================================================
485
486    #[test]
487    fn test_fill_holes_boundary_hole_not_filled() {
488        // A hole touching the boundary should NOT be filled
489        let mut mask = vec![1u8; 125]; // 5x5x5
490        mask[0] = 0; // corner voxel - touches boundary
491        let filled = fill_holes(&mask, &grid(5, 5, 5), 100);
492        assert_eq!(filled[0], 0, "Boundary hole should not be filled");
493    }
494
495    #[test]
496    fn test_fill_holes_large_hole_not_filled() {
497        // A hole larger than max_hole_size should NOT be filled
498        let n = 7;
499        let n_total = n * n * n;
500        let mut mask = vec![1u8; n_total];
501        // Create a large internal hole (3x3x3 = 27 voxels)
502        for k in 2..5 {
503            for j in 2..5 {
504                for i in 2..5 {
505                    mask[i + j * n + k * n * n] = 0;
506                }
507            }
508        }
509        let filled = fill_holes(&mask, &grid(n, n, n), 5); // max_hole_size=5, hole is 27
510        // Hole should NOT be filled because it's too large
511        let center = 3 + 3 * n + 3 * n * n;
512        assert_eq!(filled[center], 0, "Large hole should not be filled");
513    }
514
515    #[test]
516    #[should_panic(expected = "fill_holes: mask has 216 voxels but the grid is 8x8x8 (512 voxels)")]
517    fn fill_holes_refuses_mask_not_matching_grid() {
518        fill_holes(&vec![1u8; 6 * 6 * 6], &grid(8, 8, 8), 10);
519    }
520
521    // =====================================================================
522    // Tests for flood_fill_component
523    // =====================================================================
524
525    #[test]
526    fn test_flood_fill_component_single() {
527        let mask = vec![1u8; 27]; // 3x3x3 all filled => no zeros
528        let mut visited = vec![false; 27];
529        // Start at a filled voxel -> should find nothing (mask[idx] != 0)
530        let component = flood_fill_component(&mask, &mut visited, 0, 3, 3, 3);
531        assert!(component.is_empty(), "All filled mask should have no zero component");
532    }
533
534    #[test]
535    fn test_flood_fill_component_connected_zeros() {
536        let n = 5;
537        let n_total = n * n * n;
538        let mut mask = vec![1u8; n_total];
539        // Create a connected line of zeros along x at j=2, k=2
540        for i in 1..4 {
541            mask[i + 2 * n + 2 * n * n] = 0;
542        }
543        let mut visited = vec![false; n_total];
544        let start = 1 + 2 * n + 2 * n * n;
545        let component = flood_fill_component(&mask, &mut visited, start, n, n, n);
546        assert_eq!(component.len(), 3, "Should find 3 connected zero voxels");
547    }
548}