Skip to main content

qsm_core/bgremove/
msmv.rs

1//! Maximum Spherical Mean Value (mSMV) background field removal
2//!
3//! mSMV removes the residual harmonic background field near the brain boundary —
4//! the dominant source of QSM shadow artifacts — using the maximum-value
5//! corollary of Green's theorem, and does so **without eroding the brain mask**.
6//!
7//! ## Two ways to use it (the `prefilter` flag)
8//! In the original work mSMV is a **refinement applied after a primary background
9//! removal**, and that is its intended use:
10//!
11//! ```ignore
12//! let (local, m) = bgremove::vsharp(&total, &mask, &grid, &VsharpParams::default(), |_,_| {});
13//! let (refined, _) = bgremove::msmv(&local, &m, &grid, &MsmvParams::refine(), |_,_| {});
14//! ```
15//!
16//! It composes with any harmonic BFR that leaves a boundary shadow —
17//! [`sharp`](super::sharp), [`resharp`](super::resharp), [`vsharp`](super::vsharp),
18//! [`pdf`](super::pdf), [`lbv`](super::lbv). (It is redundant after
19//! [`ismv`](super::ismv), which is already iterative SMV.) In this mode set
20//! [`MsmvParams::prefilter`] `= false` (via [`MsmvParams::refine`]) so mSMV does
21//! **only** the boundary correction on the already-local field.
22//!
23//! With `prefilter = true` (the default, matching the QSM-CI submission) mSMV is a
24//! self-contained total→local BFR: it first does its own SMV primary removal, then
25//! the boundary correction. Run standalone it is only as strong as one non-
26//! deconvolved SMV pass, so prefer the refinement mode above for real pipelines.
27//!
28//! The two internal steps:
29//!   1. **SMV primary removal** (only if `prefilter`): `RDF_s = mask·(RDF − SMV(RDF))`
30//!      (radius `radius` mm). When `prefilter` is false the input field is taken as
31//!      the local field directly.
32//!   2. **Boundary shadow correction**: iteratively detect boundary voxels whose
33//!      residual field exceeds an adaptive threshold `t` and strip it with a
34//!      minimum-radius SMV.
35//!
36//! ## Units
37//! mSMV's shadow-detection threshold is defined in **radians** and capped at
38//! `0.01·B0/3` rad. The library carries fields in **ppm**, so this port converts
39//! ppm→rad with `rad = ppm · 2π · γ · B0 · TE` (γ in MHz/T), runs the filter,
40//! then converts back. `TE` only sets the operating point of the radian
41//! threshold; the filter is otherwise a (thresholded) linear high-pass.
42//!
43//! The optional vessel-protection step (which needs an R2* map) is omitted, per
44//! the upstream behaviour when R2* is unavailable.
45//!
46//! Reference:
47//! Roberts, A.G., et al. (2024). "Maximum spherical mean value (mSMV) filtering
48//! for whole-brain quantitative susceptibility mapping." Magnetic Resonance in
49//! Medicine, 91(4):1586-1597. https://doi.org/10.1002/mrm.29963
50//!
51//! Reference implementation: https://github.com/agr78/mSMV (`msmv.m`, MEDI SMV helpers)
52
53use crate::fft::{apply_real_kernel, fft_real_kernel};
54use crate::kernels::smv::smv_kernel;
55use crate::Grid;
56
57/// Proton gyromagnetic ratio in MHz/T (matches the QSM-CI `recon.m` constant).
58const GYRO_MHZ_T: f64 = 42.5774;
59
60/// Parameters for [`msmv`].
61#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
62#[derive(Clone, Debug)]
63pub struct MsmvParams {
64    /// SMV prefilter kernel radius in mm (paper default 5).
65    pub radius: f64,
66    /// Maximum number of residual-field removal iterations (paper default 5).
67    pub maxk: usize,
68    /// Main field strength in Tesla — sets the radian shadow-threshold cap `0.01·B0/3`.
69    pub b0: f64,
70    /// Echo time in seconds for the ppm↔radian conversion (MEDI-representative default).
71    pub te: f64,
72    /// Whether to run mSMV's own SMV primary removal before the boundary
73    /// correction. `true` (default) = self-contained total→local BFR; `false` =
74    /// refine an already-local field from a primary BFR (see [`MsmvParams::refine`]).
75    pub prefilter: bool,
76}
77
78impl Default for MsmvParams {
79    fn default() -> Self {
80        Self { radius: 5.0, maxk: 5, b0: 3.0, te: 0.008, prefilter: true }
81    }
82}
83
84impl MsmvParams {
85    /// Parameters for using mSMV as a **boundary-shadow refinement** after a
86    /// primary background-field removal (SHARP / RESHARP / V-SHARP / PDF / LBV):
87    /// `prefilter = false`, all other fields at their defaults.
88    pub fn refine() -> Self {
89        Self { prefilter: false, ..Self::default() }
90    }
91}
92
93/// mSMV background field removal (SMV primary removal + boundary shadow correction).
94///
95/// # Arguments
96/// * `field` — Field in **ppm** (`nx·ny·nz`, column-major): the unwrapped total
97///   field when `params.prefilter` is `true`, or an already-local field (from a
98///   primary BFR) when `false`.
99/// * `mask` — Binary brain mask (`nx·ny·nz`, 1 = inside).
100/// * `grid` — Volume dimensions and voxel sizes.
101/// * `params` — See [`MsmvParams`].
102/// * `progress` — Progress callback `(iteration, max_iterations)` over the
103///   boundary-correction loop.
104///
105/// # Returns
106/// `(local_field_ppm, mask)` — the filtered local field in ppm restricted to
107/// `mask`, and the mask unchanged (mSMV preserves the brain edge; it does not
108/// erode). Same `(field, eroded_mask)` return shape as the other BFR methods.
109pub fn msmv(
110    field: &[f64],
111    mask: &[u8],
112    grid: &Grid,
113    params: &MsmvParams,
114    mut progress: impl FnMut(usize, usize),
115) -> (Vec<f64>, Vec<u8>) {
116    let (nx, ny, nz) = grid.dims;
117    let n = nx * ny * nz;
118    assert_eq!(field.len(), n, "field length must match grid");
119    assert_eq!(mask.len(), n, "mask length must match grid");
120
121    // ppm -> radians: rad = ppm · 2π · γ(MHz/T) · B0 · TE  (the 1e6/1e-6 cancel).
122    let ppm2rad = std::f64::consts::TAU * GYRO_MHZ_T * params.b0 * params.te;
123    let rdf: Vec<f64> = field.iter().map(|&v| v * ppm2rad).collect();
124
125    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
126
127    // Prefilter SMV kernel (radius mm) and its real-valued FFT.
128    let sphere_k = smv_kernel(grid, params.radius);
129    let sphere_fft = fft_real_kernel(&sphere_k, nx, ny, nz);
130
131    // Boundary shell Me = mask − erode(mask): voxels whose full sphere neighbourhood
132    // is not entirely inside the mask (SMV(mask) ≤ 0.999).
133    let smv_mask = apply_real_kernel(&maskf, &sphere_fft, nx, ny, nz);
134    let me: Vec<f64> = (0..n)
135        .map(|i| {
136            let mne = if smv_mask[i] > 0.999 { 1.0 } else { 0.0 };
137            maskf[i] - mne
138        })
139        .collect();
140
141    // Step 1 — SMV primary removal: RDF_s = mask·(RDF − SMV(RDF)). Skipped in
142    // refinement mode (`prefilter = false`), where the input is already a local
143    // field from a primary BFR and only the boundary correction is wanted.
144    let mut rdf_s: Vec<f64> = if params.prefilter {
145        let smv_rdf = apply_real_kernel(&rdf, &sphere_fft, nx, ny, nz);
146        (0..n).map(|i| maskf[i] * (rdf[i] - smv_rdf[i])).collect()
147    } else {
148        (0..n).map(|i| maskf[i] * rdf[i]).collect()
149    };
150    let rdf_s0 = rdf_s.clone();
151
152    // Shadow-detection threshold t (radians). Upstream `kernel_lim.m` grows an
153    // SMV kernel from ~0 until the max masked SMV-residual reaches the cap
154    // `0.01·B0/3` and returns that value — i.e. t converges to the cap from
155    // below. With the binary (non-rendered) SMV kernel used library-wide the
156    // sub-voxel sweep is a no-op between voxel-radius thresholds and can never
157    // exceed the cap, so we use the limit value directly.
158    let t = 0.01 * params.b0 / 3.0;
159
160    // Minimum-radius SMV kernel for the residual-removal step.
161    let min_vox = grid.vsx().min(grid.vsy()).min(grid.vsz());
162    let r2_radius = min_vox / 2.0 + 0.05;
163    let small_k = smv_kernel(grid, r2_radius);
164    let small_fft = fft_real_kernel(&small_k, nx, ny, nz);
165
166    // Step 2 — iterative boundary shadow correction.
167    // Initial boundary background estimate (|Me·RDF_s0| > t) sets the loop guard.
168    let mut mb_count = (0..n).filter(|&i| (me[i] * rdf_s0[i]).abs() > t).count();
169    let mask_count = mask.iter().filter(|&&m| m != 0).count().max(1);
170
171    let mut k = 1usize;
172    while (mb_count as f64) / (mask_count as f64) > 1e-6 {
173        progress(k, params.maxk);
174        // Boundary voxels whose residual field still exceeds the threshold.
175        let mb: Vec<f64> = (0..n)
176            .map(|i| if (me[i] * rdf_s[i]).abs() > t { 1.0 } else { 0.0 })
177            .collect();
178        mb_count = mb.iter().filter(|&&v| v > 0.0).count();
179
180        // Strip the detected residual field with the minimum-radius SMV.
181        let masked: Vec<f64> = (0..n).map(|i| mb[i] * rdf_s[i]).collect();
182        let smv_masked = apply_real_kernel(&masked, &small_fft, nx, ny, nz);
183        for i in 0..n {
184            rdf_s[i] = maskf[i] * (rdf_s[i] - smv_masked[i]);
185        }
186
187        k += 1;
188        if k > params.maxk.saturating_sub(1).max(1) {
189            break;
190        }
191    }
192
193    // radians -> ppm, restricted to the mask.
194    let local_field: Vec<f64> = (0..n)
195        .map(|i| if mask[i] != 0 { rdf_s[i] / ppm2rad } else { 0.0 })
196        .collect();
197
198    (local_field, mask.to_vec())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn grid(n: usize) -> Grid {
206        Grid::new(n, n, n, 1.0, 1.0, 1.0)
207    }
208
209    /// A harmonic field (satisfying Laplace's equation) is annihilated by SMV, so
210    /// mSMV should drive a purely-harmonic interior field toward zero.
211    #[test]
212    fn removes_harmonic_field() {
213        let nn = 48;
214        let g = grid(nn);
215        let n = nn * nn * nn;
216        // Harmonic background: f = x^2 - y^2 (∇²f = 0). Use centered coordinates.
217        let mut field = vec![0.0f64; n];
218        let mut mask = vec![0u8; n];
219        let c = nn as f64 / 2.0;
220        let r_mask = 16.0;
221        for k in 0..nn {
222            for j in 0..nn {
223                for i in 0..nn {
224                    let x = i as f64 - c;
225                    let y = j as f64 - c;
226                    let z = k as f64 - c;
227                    let idx = i + j * nn + k * nn * nn;
228                    // scale down to a ppm-like magnitude
229                    field[idx] = 1e-3 * (x * x - y * y);
230                    if (x * x + y * y + z * z).sqrt() < r_mask {
231                        mask[idx] = 1;
232                    }
233                }
234            }
235        }
236        let params = MsmvParams { radius: 4.0, maxk: 5, b0: 3.0, te: 0.008, prefilter: true };
237        let (local, out_mask) = msmv(&field, &mask, &g, &params, |_, _| {});
238
239        // Mask is preserved (mSMV does not erode).
240        assert_eq!(out_mask, mask);
241
242        // Interior (eroded) energy of the harmonic field should be strongly reduced.
243        let inside: Vec<usize> = (0..n).filter(|&i| mask[i] == 1).collect();
244        let in_rms = |a: &[f64]| {
245            (inside.iter().map(|&i| a[i] * a[i]).sum::<f64>() / inside.len() as f64).sqrt()
246        };
247        let before = in_rms(&field);
248        let after = in_rms(&local);
249        assert!(
250            after < 0.5 * before,
251            "harmonic field should be reduced: before={before:.3e} after={after:.3e}"
252        );
253    }
254
255    #[test]
256    fn preserves_shapes_and_mask() {
257        let g = grid(16);
258        let n = 16 * 16 * 16;
259        let field: Vec<f64> = (0..n).map(|i| ((i % 7) as f64 - 3.0) * 1e-3).collect();
260        let mut mask = vec![0u8; n];
261        for (i, m) in mask.iter_mut().enumerate() {
262            // a solid central-ish block
263            let x = i % 16;
264            let y = (i / 16) % 16;
265            let z = i / (16 * 16);
266            if (4..12).contains(&x) && (4..12).contains(&y) && (4..12).contains(&z) {
267                *m = 1;
268            }
269        }
270        let (local, out_mask) = msmv(&field, &mask, &g, &MsmvParams::default(), |_, _| {});
271        assert_eq!(local.len(), n);
272        assert_eq!(out_mask, mask);
273        // Output is zero outside the mask.
274        assert!((0..n).all(|i| mask[i] != 0 || local[i] == 0.0));
275    }
276
277    /// In refinement mode (`prefilter = false`) mSMV does NOT run its own SMV
278    /// removal: on an already-clean local field (all voxels below the shadow
279    /// threshold) it returns the masked input unchanged — whereas the standalone
280    /// mode SMV-filters it and changes the interior.
281    #[test]
282    fn refine_mode_skips_primary_smv() {
283        let nn = 24;
284        let g = grid(nn);
285        let n = nn * nn * nn;
286        // Small, smooth "already-local" field (well below the radian threshold).
287        let mut field = vec![0.0f64; n];
288        let mut mask = vec![0u8; n];
289        let c = nn as f64 / 2.0;
290        for k in 0..nn {
291            for j in 0..nn {
292                for i in 0..nn {
293                    let idx = i + j * nn + k * nn * nn;
294                    let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
295                    field[idx] = 1e-4 * ((x * 0.3).sin() + (y * 0.2).cos());
296                    if (x * x + y * y + z * z).sqrt() < 8.0 {
297                        mask[idx] = 1;
298                    }
299                }
300            }
301        }
302        let p = MsmvParams { b0: 3.0, te: 0.008, ..MsmvParams::default() };
303
304        // Refinement mode: no primary SMV, field below threshold → unchanged in mask.
305        let (refined, _) = msmv(&field, &mask, &g, &MsmvParams { prefilter: false, ..p.clone() }, |_, _| {});
306        for i in 0..n {
307            let expect = if mask[i] != 0 { field[i] } else { 0.0 };
308            assert!((refined[i] - expect).abs() < 1e-12, "refine mode changed voxel {i}");
309        }
310
311        // Standalone mode: primary SMV removal actually alters the interior.
312        let (standalone, _) = msmv(&field, &mask, &g, &MsmvParams { prefilter: true, ..p }, |_, _| {});
313        let changed: f64 = (0..n)
314            .filter(|&i| mask[i] != 0)
315            .map(|i| (standalone[i] - field[i]).abs())
316            .fold(0.0, f64::max);
317        assert!(changed > 1e-9, "standalone mode should SMV-filter the field");
318    }
319}