Skip to main content

qsm_core/utils/morphology/
signal_erosion.rs

1//! Signal-gated mask erosion.
2//!
3//! Near the sinuses and skull base, T2* dropout leaves brain-mask voxels with no usable signal.
4//! The field estimate there is unreliable and leaks background-field artefact into the χ map. A
5//! global erosion suppresses it only by peeling healthy cortex just as hard. This erosion instead
6//! strips mask **boundary** voxels whose (coil-debiased) magnitude is low, iteratively, so it eats
7//! inward through dropout territory and stops as soon as it reaches real signal. Dark *interior*
8//! structures (veins, iron-rich deep grey matter, haemorrhage) are never touched because they are
9//! not reachable from the boundary through low-signal voxels within the depth cap.
10//!
11//! Works on any binary mask (BET, thresholding, a CNN mask, …) — it is a mask refinement, not
12//! part of any one brain-extraction method.
13//!
14//! Port of `_signal_erode` from the QSM-CI `hd-bet-qsmci` submission (`extract.py`), used for the
15//! QSM-CI harmonization track at `erode_global=1, erode_threshold=0.80, erode_depth_cap=5`
16//! (the [`SignalErosionParams::default`]). Distances and smoothing scales are in **voxels**, as in
17//! the reference implementation.
18
19use crate::utils::morphology::{erode_mask, fill_holes};
20use crate::Grid;
21#[cfg(feature = "parallel")]
22use rayon::prelude::*;
23
24/// Signal-gated erosion parameters. The default is the QSM-CI harmonization setting.
25#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
26#[derive(Clone, Debug, PartialEq)]
27pub struct SignalErosionParams {
28    /// Signal gate as a fraction of the in-mask median of the coil-debiased magnitude
29    /// (default 0.80). Above ~0.85 it starts over-carving.
30    pub threshold: f64,
31    /// Never remove a voxel more than this many voxels below the original mask surface
32    /// (default 5). Bounds the erosion by construction: without it, the peel follows low-signal
33    /// sulcal and interhemispheric CSF deep into healthy cortex. `0` disables the cap.
34    pub depth_cap: usize,
35    /// Plain 6-connected erosions applied before the signal gate (default 1). Trims the bright
36    /// one-voxel skull/CSF sliver a mask often carries, which no signal gate can catch.
37    pub global_erosions: usize,
38    /// Gaussian scale in voxels of the receive-coil bias estimate divided out of the magnitude
39    /// (default 12).
40    pub bias_sigma: f64,
41    /// Keep every connected component with at least this many voxels, not just the largest
42    /// (default 1000): a capped peel can sever small real pieces off thin structures.
43    pub min_component: usize,
44}
45
46impl Default for SignalErosionParams {
47    fn default() -> Self {
48        Self { threshold: 0.80, depth_cap: 5, global_erosions: 1, bias_sigma: 12.0, min_component: 1000 }
49    }
50}
51
52/// Erode `mask` only where the signal is low (see the module docs).
53///
54/// `magnitude` should be a combined magnitude (e.g. the root-sum-of-squares over echoes) on the
55/// same grid. Returns the refined mask (0/1).
56pub fn signal_gated_erosion(
57    mask: &[u8],
58    magnitude: &[f64],
59    grid: &Grid,
60    params: &SignalErosionParams,
61) -> Vec<u8> {
62    let (nx, ny, nz) = grid.dims;
63    let n = nx * ny * nz;
64    assert_eq!(mask.len(), n, "mask length does not match grid");
65    assert_eq!(magnitude.len(), n, "magnitude length does not match grid");
66    let inside: Vec<bool> = mask.iter().map(|&m| m != 0).collect();
67    if !inside.iter().any(|&b| b) {
68        return mask.to_vec();
69    }
70
71    // Divide out the receive-coil sensitivity profile: the magnitude over its own mask-normalised
72    // smoothed self, so the estimate is not dragged down by the zeros outside the brain.
73    let sig = gaussian_filter(magnitude, grid.dims, 1.0);
74    let masked: Vec<f64> = sig.iter().zip(&inside).map(|(&s, &m)| if m { s } else { 0.0 }).collect();
75    let num = gaussian_filter(&masked, grid.dims, params.bias_sigma);
76    let mask_f: Vec<f64> = inside.iter().map(|&m| m as u8 as f64).collect();
77    let den = gaussian_filter(&mask_f, grid.dims, params.bias_sigma);
78    let rel: Vec<f64> = (0..n).map(|i| sig[i] / (num[i] / den[i].max(1e-6)).max(1e-6)).collect();
79
80    let mut in_mask: Vec<f64> = (0..n).filter(|&i| inside[i]).map(|i| rel[i]).collect();
81    let gate = params.threshold * median(&mut in_mask);
82
83    // Depth below the ORIGINAL surface, on a padded mask so a brain touching the FOV face
84    // (common inferiorly — exactly where this matters) is measured as surface, not interior.
85    let depth = (params.depth_cap > 0).then(|| distance_transform_padded(&inside, grid.dims));
86    let mut current = if params.global_erosions > 0 {
87        erode_mask(mask, grid, params.global_erosions)
88    } else {
89        mask.iter().map(|&m| (m != 0) as u8).collect()
90    };
91    let candidates: Vec<usize> = (0..n)
92        .filter(|&i| current[i] != 0 && rel[i] < gate)
93        .filter(|&i| depth.as_ref().is_none_or(|d| d[i] <= params.depth_cap as f64))
94        .collect();
95
96    // Synchronous peel: each pass removes every low-signal candidate that is on the boundary of
97    // the current mask (6-connected, volume edge counts as outside), exactly like
98    // `eroded & ~binary_erosion(eroded) & low` per pass in the reference.
99    let passes = (params.depth_cap + 2).max(40);
100    let nxy = nx * ny;
101    for _ in 0..passes {
102        let drop: Vec<usize> = candidates
103            .iter()
104            .copied()
105            .filter(|&i| current[i] != 0)
106            .filter(|&i| {
107                let (x, y, z) = (i % nx, (i / nx) % ny, i / nxy);
108                x == 0 || x == nx - 1 || y == 0 || y == ny - 1 || z == 0 || z == nz - 1
109                    || current[i - 1] == 0 || current[i + 1] == 0
110                    || current[i - nx] == 0 || current[i + nx] == 0
111                    || current[i - nxy] == 0 || current[i + nxy] == 0
112            })
113            .collect();
114        if drop.is_empty() {
115            break;
116        }
117        for i in drop {
118            current[i] = 0;
119        }
120    }
121
122    // Never leave an enclosed cavity, then keep every component of at least `min_component`
123    // voxels (or the largest, if none is that big).
124    let filled = fill_holes(&current, grid, n);
125    keep_large_components(&filled, grid.dims, params.min_component)
126}
127
128/// Median with numpy semantics (mean of the two middle values for an even count).
129fn median(v: &mut [f64]) -> f64 {
130    let len = v.len();
131    let mid = len / 2;
132    let (_, &mut hi, _) = v.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
133    if len % 2 == 1 {
134        hi
135    } else {
136        let lo = v[..mid].iter().copied().fold(f64::NEG_INFINITY, f64::max);
137        0.5 * (lo + hi)
138    }
139}
140
141/// `scipy.ndimage` `reflect` boundary (half-sample symmetric: `d c b a | a b c d | d c b a`),
142/// valid for any offset, including kernels longer than the axis.
143#[inline]
144fn reflect(i: isize, n: usize) -> usize {
145    let n = n as isize;
146    let m = i.rem_euclid(2 * n);
147    (if m >= n { 2 * n - 1 - m } else { m }) as usize
148}
149
150/// Separable Gaussian filter matching `scipy.ndimage.gaussian_filter(x, sigma)` (voxel units,
151/// `truncate=4.0`, `mode='reflect'`) on a column-major `(nx, ny, nz)` volume.
152pub(crate) fn gaussian_filter(data: &[f64], dims: (usize, usize, usize), sigma: f64) -> Vec<f64> {
153    gaussian_filter_anisotropic(data, dims, [sigma; 3])
154}
155
156/// 1-D Gaussian weights for `scipy.ndimage.gaussian_filter1d(sigma, truncate=4.0)`.
157fn gaussian_weights(sigma: f64) -> Vec<f64> {
158    let radius = (4.0 * sigma + 0.5) as isize;
159    let mut w: Vec<f64> =
160        (-radius..=radius).map(|x| (-0.5 * (x * x) as f64 / (sigma * sigma)).exp()).collect();
161    let s: f64 = w.iter().sum();
162    w.iter_mut().for_each(|v| *v /= s);
163    w
164}
165
166/// [`gaussian_filter`] with a separate standard deviation per axis; an axis whose sigma is zero
167/// is left unfiltered, as `scipy.ndimage.gaussian_filter` does.
168pub(crate) fn gaussian_filter_anisotropic(
169    data: &[f64],
170    dims: (usize, usize, usize),
171    sigma: [f64; 3],
172) -> Vec<f64> {
173    if sigma.iter().all(|&s| s <= 0.0) {
174        return data.to_vec();
175    }
176    let (nx, ny, nz) = dims;
177    let nxy = nx * ny;
178
179    // x: along contiguous rows.
180    let a = if sigma[0] > 0.0 {
181        let w = gaussian_weights(sigma[0]);
182        let radius = (w.len() / 2) as isize;
183        let mut a = vec![0.0; data.len()];
184        crate::maybe_par_chunks_mut!(a, nx).enumerate().for_each(|(r, row)| {
185            let src = &data[r * nx..(r + 1) * nx];
186            for (x, out) in row.iter_mut().enumerate() {
187                *out = w
188                    .iter()
189                    .enumerate()
190                    .map(|(t, &wt)| wt * src[reflect(x as isize + t as isize - radius, nx)])
191                    .sum();
192            }
193        });
194        a
195    } else {
196        data.to_vec()
197    };
198    // y: within each z-slice, weighted sum of whole rows.
199    let b = if sigma[1] > 0.0 {
200        let w = gaussian_weights(sigma[1]);
201        let radius = (w.len() / 2) as isize;
202        let mut b = vec![0.0; data.len()];
203        crate::maybe_par_chunks_mut!(b, nxy).enumerate().for_each(|(z, slab)| {
204            let src = &a[z * nxy..(z + 1) * nxy];
205            for y in 0..ny {
206                let out = &mut slab[y * nx..(y + 1) * nx];
207                for (t, &wt) in w.iter().enumerate() {
208                    let sy = reflect(y as isize + t as isize - radius, ny);
209                    for (o, &v) in out.iter_mut().zip(&src[sy * nx..(sy + 1) * nx]) {
210                        *o += wt * v;
211                    }
212                }
213            }
214        });
215        b
216    } else {
217        a
218    };
219    // z: each output slice is a weighted sum of whole input slices.
220    if sigma[2] <= 0.0 {
221        return b;
222    }
223    let w = gaussian_weights(sigma[2]);
224    let radius = (w.len() / 2) as isize;
225    let mut c = vec![0.0; data.len()];
226    crate::maybe_par_chunks_mut!(c, nxy).enumerate().for_each(|(z, slab)| {
227        for (t, &wt) in w.iter().enumerate() {
228            let sz = reflect(z as isize + t as isize - radius, nz);
229            for (o, &v) in slab.iter_mut().zip(&b[sz * nxy..(sz + 1) * nxy]) {
230                *o += wt * v;
231            }
232        }
233    });
234    c
235}
236
237/// Exact Euclidean distance (voxel units) from each `inside` voxel to the nearest outside voxel,
238/// with the volume padded by one outside voxel on every face — i.e.
239/// `distance_transform_edt(np.pad(mask, 1))[1:-1, 1:-1, 1:-1]`. Outside voxels get 0.
240pub(crate) fn distance_transform_padded(inside: &[bool], dims: (usize, usize, usize)) -> Vec<f64> {
241    let (nx, ny, nz) = dims;
242    let (px, py, pz) = (nx + 2, ny + 2, nz + 2);
243    const FAR: f64 = 1e20;
244    let mut f = vec![0.0; px * py * pz];
245    for z in 0..nz {
246        for y in 0..ny {
247            for x in 0..nx {
248                if inside[x + nx * (y + ny * z)] {
249                    f[(x + 1) + px * ((y + 1) + py * (z + 1))] = FAR;
250                }
251            }
252        }
253    }
254    // Felzenszwalb–Huttenlocher squared EDT, one axis at a time.
255    let (mut line, mut out) = (Vec::new(), Vec::new());
256    let mut pass = |f: &mut [f64], base: usize, stride: usize, len: usize| {
257        line.clear();
258        line.extend((0..len).map(|i| f[base + i * stride]));
259        edt_1d(&line, &mut out);
260        for (i, &v) in out.iter().enumerate() {
261            f[base + i * stride] = v;
262        }
263    };
264    for z in 0..pz {
265        for y in 0..py {
266            pass(&mut f, px * (y + py * z), 1, px);
267        }
268    }
269    for z in 0..pz {
270        for x in 0..px {
271            pass(&mut f, x + px * py * z, px, py);
272        }
273    }
274    for y in 0..py {
275        for x in 0..px {
276            pass(&mut f, x + px * y, px * py, pz);
277        }
278    }
279    let mut d = vec![0.0; nx * ny * nz];
280    for z in 0..nz {
281        for y in 0..ny {
282            for x in 0..nx {
283                let i = x + nx * (y + ny * z);
284                if inside[i] {
285                    d[i] = f[(x + 1) + px * ((y + 1) + py * (z + 1))].sqrt();
286                }
287            }
288        }
289    }
290    d
291}
292
293/// 1D squared distance transform of sampled function `f` (Felzenszwalb & Huttenlocher 2012).
294fn edt_1d(f: &[f64], d: &mut Vec<f64>) {
295    let n = f.len();
296    let mut v = vec![0usize; n];
297    let mut z = vec![0.0f64; n + 1];
298    let mut k = 0usize;
299    z[0] = f64::NEG_INFINITY;
300    z[1] = f64::INFINITY;
301    let inter = |q: usize, p: usize| {
302        ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64)
303    };
304    for q in 1..n {
305        let mut s = inter(q, v[k]);
306        while s <= z[k] {
307            k -= 1;
308            s = inter(q, v[k]);
309        }
310        k += 1;
311        v[k] = q;
312        z[k] = s;
313        z[k + 1] = f64::INFINITY;
314    }
315    d.clear();
316    d.resize(n, 0.0);
317    k = 0;
318    for (q, dq) in d.iter_mut().enumerate() {
319        while z[k + 1] < q as f64 {
320            k += 1;
321        }
322        let dx = q as f64 - v[k] as f64;
323        *dq = dx * dx + f[v[k]];
324    }
325}
326
327/// Keep the 6-connected foreground components of at least `min(min_size, largest)` voxels.
328fn keep_large_components(mask: &[u8], dims: (usize, usize, usize), min_size: usize) -> Vec<u8> {
329    let (label, sizes) = crate::utils::connected::label_components(
330        mask,
331        dims,
332        crate::utils::connected::Connectivity::Face,
333    );
334    let largest = sizes.iter().copied().max().unwrap_or(0);
335    let keep = min_size.min(largest);
336    label.iter().map(|&l| (l != 0 && sizes[l as usize] >= keep) as u8).collect()
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn grid(n: (usize, usize, usize)) -> Grid {
344        Grid::new(n.0, n.1, n.2, 1.0, 1.0, 1.0)
345    }
346
347    fn idx(x: usize, y: usize, z: usize, n: (usize, usize, usize)) -> usize {
348        x + n.0 * (y + n.1 * z)
349    }
350
351    #[test]
352    fn edt_matches_brute_force() {
353        let dims = (9, 7, 6);
354        // Irregular blob that touches the x=0 face (so the padding matters).
355        let inside: Vec<bool> = (0..dims.0 * dims.1 * dims.2)
356            .map(|i| {
357                let (x, y, z) = (i % dims.0, (i / dims.0) % dims.1, i / (dims.0 * dims.1));
358                (x as f64 - 2.0).powi(2) / 9.0 + (y as f64 - 3.0).powi(2) / 6.0 + (z as f64 - 2.5).powi(2) / 5.0 < 1.6
359                    && (x + y + z) % 7 != 3
360            })
361            .collect();
362        let d = distance_transform_padded(&inside, dims);
363        let (px, py, pz) = (dims.0 as isize + 2, dims.1 as isize + 2, dims.2 as isize + 2);
364        let is_in = |x: isize, y: isize, z: isize| {
365            x >= 1 && y >= 1 && z >= 1 && x <= dims.0 as isize && y <= dims.1 as isize && z <= dims.2 as isize
366                && inside[idx((x - 1) as usize, (y - 1) as usize, (z - 1) as usize, dims)]
367        };
368        for z in 0..dims.2 {
369            for y in 0..dims.1 {
370                for x in 0..dims.0 {
371                    let i = idx(x, y, z, dims);
372                    let mut best = f64::INFINITY;
373                    if inside[i] {
374                        for qz in 0..pz {
375                            for qy in 0..py {
376                                for qx in 0..px {
377                                    if !is_in(qx, qy, qz) {
378                                        let dd = ((qx - x as isize - 1).pow(2) + (qy - y as isize - 1).pow(2)
379                                            + (qz - z as isize - 1).pow(2)) as f64;
380                                        best = best.min(dd);
381                                    }
382                                }
383                            }
384                        }
385                        assert!((d[i] - best.sqrt()).abs() < 1e-9, "voxel {:?}: {} vs {}", (x, y, z), d[i], best.sqrt());
386                    } else {
387                        assert_eq!(d[i], 0.0);
388                    }
389                }
390            }
391        }
392    }
393
394    #[test]
395    fn gaussian_matches_direct_reflect_convolution() {
396        let dims = (7, 5, 4);
397        let data: Vec<f64> = (0..140).map(|i| ((i * 37) % 11) as f64 - 3.0).collect();
398        // sigma 1.5 → radius 6, longer than every axis, exercising repeated reflection.
399        let sigma = 1.5;
400        let got = gaussian_filter(&data, dims, sigma);
401        let r = (4.0 * sigma + 0.5) as isize;
402        let w: Vec<f64> = (-r..=r).map(|t| (-0.5 * (t * t) as f64 / (sigma * sigma)).exp()).collect();
403        let s: f64 = w.iter().sum();
404        for z in 0..dims.2 {
405            for y in 0..dims.1 {
406                for x in 0..dims.0 {
407                    let mut acc = 0.0;
408                    for (a, wa) in w.iter().enumerate() {
409                        for (b, wb) in w.iter().enumerate() {
410                            for (c, wc) in w.iter().enumerate() {
411                                let sx = reflect(x as isize + a as isize - r, dims.0);
412                                let sy = reflect(y as isize + b as isize - r, dims.1);
413                                let sz = reflect(z as isize + c as isize - r, dims.2);
414                                acc += wa * wb * wc * data[idx(sx, sy, sz, dims)];
415                            }
416                        }
417                    }
418                    let want = acc / (s * s * s);
419                    assert!((got[idx(x, y, z, dims)] - want).abs() < 1e-10);
420                }
421            }
422        }
423    }
424
425    #[test]
426    fn reflect_is_half_sample_symmetric() {
427        let n = 4;
428        let got: Vec<usize> = (-5..9).map(|i| reflect(i, n)).collect();
429        assert_eq!(got, vec![3, 3, 2, 1, 0, 0, 1, 2, 3, 3, 2, 1, 0, 0]);
430    }
431
432    #[test]
433    fn median_matches_numpy() {
434        assert_eq!(median(&mut [3.0, 1.0, 2.0]), 2.0);
435        assert_eq!(median(&mut [4.0, 1.0, 3.0, 2.0]), 2.5);
436    }
437
438    /// Ball with uniform signal except (a) a dark wedge reaching in from the -x surface (dropout)
439    /// and (b) a dark enclosed core (a vein / iron-rich nucleus). The gate should peel the wedge,
440    /// only down to the depth cap, and leave the dark core and the healthy surface alone.
441    #[test]
442    fn peels_boundary_dropout_but_not_interior_dark_structures() {
443        let dims = (40, 40, 40);
444        let g = grid(dims);
445        let c = 19.5;
446        let n = dims.0 * dims.1 * dims.2;
447        let mut mask = vec![0u8; n];
448        // Bright outside too (scalp), so a healthy edge isn't dimmed by partial-volume smoothing.
449        let mut mag = vec![100.0; n];
450        for z in 0..dims.2 {
451            for y in 0..dims.1 {
452                for x in 0..dims.0 {
453                    let (dx, dy, dz) = (x as f64 - c, y as f64 - c, z as f64 - c);
454                    let r = (dx * dx + dy * dy + dz * dz).sqrt();
455                    let i = idx(x, y, z, dims);
456                    if r < 16.0 {
457                        mask[i] = 1;
458                        if dx < -6.0 && dy.abs() < 5.0 && dz.abs() < 5.0 {
459                            mag[i] = 2.0; // dropout wedge from the -x surface, ~10 voxels deep
460                        }
461                        if r < 3.0 {
462                            mag[i] = 2.0; // dark interior structure
463                        }
464                    }
465                }
466            }
467        }
468        let params = SignalErosionParams { global_erosions: 0, min_component: 10, ..Default::default() };
469        let out = signal_gated_erosion(&mask, &mag, &g, &params);
470        let depth = distance_transform_padded(&mask.iter().map(|&m| m != 0).collect::<Vec<_>>(), dims);
471
472        // Interior dark core untouched.
473        assert_eq!(out[idx(20, 20, 20, dims)], 1);
474        // Healthy +x surface untouched.
475        assert_eq!(out[idx(35, 20, 20, dims)], 1);
476        // Dropout removed at the surface…
477        assert_eq!(out[idx(5, 20, 20, dims)], 0);
478        // …but never deeper than the cap.
479        for i in 0..n {
480            if mask[i] == 1 && out[i] == 0 {
481                assert!(depth[i] <= params.depth_cap as f64 + 1e-9, "removed voxel at depth {}", depth[i]);
482            }
483        }
484        // No enclosed cavities.
485        assert_eq!(fill_holes(&out, &g, n), out);
486    }
487
488    #[test]
489    fn uniform_signal_only_applies_global_erosion() {
490        let dims = (20, 20, 20);
491        let g = grid(dims);
492        let mask: Vec<u8> = (0..8000)
493            .map(|i| {
494                let (x, y, z) = (i % 20, (i / 20) % 20, i / 400);
495                (3..17).contains(&x) && (3..17).contains(&y) && (3..17).contains(&z)
496            } as u8)
497            .collect();
498        let mag = vec![50.0; 8000];
499        let params = SignalErosionParams { min_component: 1, ..Default::default() };
500        let out = signal_gated_erosion(&mask, &mag, &g, &params);
501        assert_eq!(out, erode_mask(&mask, &g, 1));
502    }
503}