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, ¶ms, |_, _| {});
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}