Skip to main content

qsm_core/utils/
mask.rs

1//! Mask utilities
2//!
3//! Provides functions for creating and applying masks on 3D volumes, and for finding where a
4//! scanner wrote no phase ([`scanner_masked_phase`]). Morphology (erosion,
5//! dilation, closing, hole filling) is in [`crate::utils::morphology`].
6
7use crate::Grid;
8
9/// Create a binary sphere mask on a 3D volume
10///
11/// Generates a mask where voxels within the specified radius of the center
12/// are set to 1, and all others are 0. Uses Fortran (column-major) ordering
13/// to match NIfTI convention: index = x + y*nx + z*nx*ny.
14///
15/// # Arguments
16/// * `grid` - Volume grid (dimensions and voxel sizes)
17/// * `center_x`, `center_y`, `center_z` - Sphere center in voxel coordinates
18/// * `radius` - Sphere radius in voxels
19///
20/// # Returns
21/// Flattened binary mask of length nx*ny*nz
22pub fn create_sphere_mask(
23    grid: &Grid,
24    center_x: f64, center_y: f64, center_z: f64,
25    radius: f64,
26) -> Vec<u8> {
27    let (nx, ny, nz) = grid.dims;
28    let mut mask = vec![0u8; nx * ny * nz];
29    let r2 = radius * radius;
30
31    for k in 0..nz {
32        for j in 0..ny {
33            for i in 0..nx {
34                let dx = i as f64 - center_x;
35                let dy = j as f64 - center_y;
36                let dz = k as f64 - center_z;
37                if dx * dx + dy * dy + dz * dz <= r2 {
38                    mask[i + j * nx + k * nx * ny] = 1;
39                }
40            }
41        }
42    }
43
44    mask
45}
46
47/// Zero out elements where mask is 0.
48#[inline]
49pub fn apply_mask_zero(data: &mut [f64], mask: &[u8]) {
50    for i in 0..data.len() {
51        if mask[i] == 0 {
52            data[i] = 0.0;
53        }
54    }
55}
56
57/// Smallest share of the volume a single exact phase value must cover to count as a scanner's
58/// fill value in [`scanner_masked_phase`]: 5 %.
59///
60/// Measured phase is quantised (12 bits on most scanners, 4,096 levels), so no single value
61/// covers more than a fraction of a percent of a real image, smooth background included; the
62/// fill values found so far cover 56–67 % of the field of view (Philips, Q0748 audit, 2026-11).
63pub const SCANNER_FILL_MIN_FRACTION: f64 = 0.05;
64
65/// Where a scanner wrote a fill value instead of a measured phase: [`scanner_masked_phase`].
66#[derive(Clone, Debug, PartialEq)]
67pub struct ScannerMaskedPhase {
68    /// 1 where the phase holds data, 0 where it is the scanner's fill value.
69    pub has_data: Vec<u8>,
70    /// The fill value, if one was found.
71    pub fill_value: Option<f64>,
72    /// Number of voxels without data.
73    pub n_masked: usize,
74}
75
76/// Find the voxels where the scanner wrote no phase: a constant fill value, the same in every
77/// echo, covering a large part of the volume.
78///
79/// Some scanners mask the phase they export. Philips writes the lowest phase value (−π once
80/// scaled) outside its own signal mask, in every echo, with magnitude 0 or noise; on the Philips
81/// series of the Q0748 audit (2026-11) that was 56–67 % of the field of view. A phase that is flat
82/// in space and constant over echoes looks like the most reliable phase there is: ROMEO's phase
83/// quality (and ROMEO.jl's `voxelquality`) rates it far above the brain, so a phase-quality mask
84/// takes the background in (27–50 % of the default mask's voxels on those series), and MCPC-3D-S
85/// and the unwrapper treat it as signal.
86///
87/// A voxel has no data when its phase is **bit-identical in every echo** and equal to the
88/// volume's most frequent exact value, provided that value covers at least
89/// [`SCANNER_FILL_MIN_FRACTION`] of the volume. With one echo only the second condition applies.
90/// Both conditions are needed: with two echoes of a 12-bit phase, about 1 in 4,096 real voxels
91/// repeats its value by chance, and a mode alone could be a genuine (if implausible) phase.
92/// Measured phase never meets both, so this is not a threshold on the data, only the detection
93/// of a value the scanner wrote; when no value qualifies, every voxel has data.
94///
95/// `phases` holds each echo's phase, in any units (the comparison is exact).
96pub fn scanner_masked_phase(phases: &[&[f64]]) -> ScannerMaskedPhase {
97    let n = phases.first().map_or(0, |p| p.len());
98    let all_data = || ScannerMaskedPhase { has_data: vec![1; n], fill_value: None, n_masked: 0 };
99    if n == 0 || phases.iter().any(|p| p.len() != n) {
100        return all_data();
101    }
102    let same_in_every_echo = |i: usize| {
103        let v = phases[0][i].to_bits();
104        phases[1..].iter().all(|p| p[i].to_bits() == v)
105    };
106    // The most frequent exact value among the voxels that repeat it in every echo.
107    let mut counts: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
108    for i in 0..n {
109        if phases[0][i].is_finite() && same_in_every_echo(i) {
110            *counts.entry(phases[0][i].to_bits()).or_insert(0) += 1;
111        }
112    }
113    // Ties go to the smaller value, so the result does not depend on the hash order.
114    let Some((bits, count)) = counts.into_iter().max_by(|a, b| {
115        a.1.cmp(&b.1).then_with(|| f64::from_bits(b.0).total_cmp(&f64::from_bits(a.0)))
116    }) else {
117        return all_data();
118    };
119    if (count as f64) < SCANNER_FILL_MIN_FRACTION * n as f64 {
120        return all_data();
121    }
122    let has_data: Vec<u8> = (0..n)
123        .map(|i| u8::from(!(phases[0][i].to_bits() == bits && same_in_every_echo(i))))
124        .collect();
125    ScannerMaskedPhase { has_data, fill_value: Some(f64::from_bits(bits)), n_masked: count }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
133        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
134    }
135
136    fn noisy(n: usize, seed: u64) -> Vec<f64> {
137        // a 12-bit quantised "measured" phase
138        (0..n).map(|i| {
139            let h = (i as u64).wrapping_mul(2654435761).wrapping_add(seed.wrapping_mul(40503)) % 4096;
140            -std::f64::consts::PI + h as f64 * (2.0 * std::f64::consts::PI / 4096.0)
141        }).collect()
142    }
143
144    #[test]
145    fn scanner_fill_value_is_found_in_every_echo() {
146        let n = 4000;
147        let fill = -std::f64::consts::PI;
148        let mut echoes: Vec<Vec<f64>> = (0..3).map(|e| noisy(n, e + 1)).collect();
149        for e in echoes.iter_mut() {
150            for v in e[..1500].iter_mut() { *v = fill; }
151        }
152        // a voxel that holds the fill value in two echoes only is measured phase
153        echoes[2][1499] = 0.25;
154        let refs: Vec<&[f64]> = echoes.iter().map(|e| e.as_slice()).collect();
155        let r = scanner_masked_phase(&refs);
156        assert_eq!(r.fill_value, Some(fill));
157        assert_eq!(r.n_masked, 1499);
158        assert!(r.has_data[..1499].iter().all(|&h| h == 0));
159        assert!(r.has_data[1499..].iter().all(|&h| h == 1));
160    }
161
162    #[test]
163    fn single_echo_uses_the_dominant_value_and_noise_has_none() {
164        let n = 4000;
165        let mut one = noisy(n, 7);
166        let r = scanner_masked_phase(&[&one]);
167        assert_eq!((r.fill_value, r.n_masked), (None, 0));
168        assert!(r.has_data.iter().all(|&h| h == 1));
169        // below the minimum share: not a fill value
170        for v in one[..150].iter_mut() { *v = 0.0; }
171        assert_eq!(scanner_masked_phase(&[&one]).n_masked, 0);
172        for v in one[..400].iter_mut() { *v = 0.0; }
173        let r = scanner_masked_phase(&[&one]);
174        assert_eq!((r.fill_value, r.n_masked), (Some(0.0), 400 + one[400..].iter().filter(|&&v| v == 0.0).count()));
175        // two echoes of independent noise repeat values by chance, but no repeated value dominates
176        let (a, b) = (noisy(n, 3), noisy(n, 4));
177        assert_eq!(scanner_masked_phase(&[&a, &b]).n_masked, 0);
178    }
179
180    #[test]
181    fn test_sphere_mask_basic() {
182        let mask = create_sphere_mask(&grid(10, 10, 10), 5.0, 5.0, 5.0, 3.0);
183        assert_eq!(mask.len(), 1000);
184
185        // Center voxel should be inside
186        assert_eq!(mask[5 + 5 * 10 + 5 * 100], 1);
187
188        // Corner should be outside
189        assert_eq!(mask[0], 0);
190
191        // Count should be reasonable for a sphere of radius 3
192        let count: usize = mask.iter().map(|&m| m as usize).sum();
193        assert!(count > 50 && count < 200, "Sphere voxel count {} seems wrong", count);
194    }
195
196    #[test]
197    fn test_sphere_mask_non_cubic() {
198        let mask = create_sphere_mask(&grid(20, 10, 5), 10.0, 5.0, 2.5, 2.0);
199        assert_eq!(mask.len(), 1000);
200
201        // Center should be inside
202        assert_eq!(mask[10 + 5 * 20 + 2 * 20 * 10], 1);
203    }
204
205    #[test]
206    fn test_sphere_mask_zero_radius() {
207        let mask = create_sphere_mask(&grid(5, 5, 5), 2.0, 2.0, 2.0, 0.0);
208        // Only the exact center voxel (distance 0 <= 0)
209        let count: usize = mask.iter().map(|&m| m as usize).sum();
210        assert_eq!(count, 1);
211    }
212
213    #[test]
214    fn test_apply_mask_zero() {
215        let mask = vec![1, 0, 1, 0, 1];
216        let mut data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
217        apply_mask_zero(&mut data, &mask);
218        assert_eq!(data, vec![1.0, 0.0, 3.0, 0.0, 5.0]);
219    }
220}