Skip to main content

qsm_core/inversion/
msmv.rs

1//! mSMV (maximum spherical mean value) filtering, the field step of MEDI-mSMV
2//!
3//! mSMV removes residual background field near the brain boundary, the main source of shadow
4//! artifacts, without eroding the mask (Roberts et al. 2024). It is **not a background field
5//! removal step on its own**: the authors run it inside MEDI, as `MEDI_L1(..., 'msmv', radius)`,
6//! on the local field from a conventional background removal (PDF, V-SHARP or LBV in the
7//! paper), and the inversion then fits the SMV-filtered field with the SMV-filtered dipole
8//! kernel `(δ − S_r1) d` (the paper's Eq. 12, "MEDI-mSMV"). Its output is a high-passed field,
9//! `(δ − S_r1)` applied to the local field plus a boundary correction, so inverting it with the
10//! plain dipole kernel `d`, as a background removal output would be, is the wrong forward
11//! model. In QSM.rs it is therefore an option of [`medi`](fn@super::medi):
12//! [`MediParams::msmv`](super::MediParams::msmv). This module holds the filter itself, a port of
13//! `msmv.m` and `kernel_lim.m`, for MEDI and for parity checks.
14//!
15//! # The authors' method, as their code runs it
16//! Reference code: agr78/mSMV (commit 42a1f18), `prerelease/MEDI_toolbox-mSMV100125.zip`, the
17//! newest MEDI toolbox with mSMV: `functions/msmv.m`, `kernel_lim.m`, `MaskErode.m`, `SMV.m`,
18//! `sphere_kernel.m`, `MEDI_L1.m` and `parse_QSM_input.m`. `MEDI_L1(..., 'msmv', r)` (the
19//! `'msmv'` keyword takes the radius, like `'smv'`, and turns SMV on):
20//!
21//! 1. reads `R2s` from the input file and **errors if it is missing**;
22//! 2. does **not** erode the mask (`smv_shrink_mask` is forced off) and calls
23//!    `RDF = msmv(RDF, Mask, R2s, voxel_size, r, B0_mag, prefilter, maxk, vessel_radius)`;
24//! 3. inverts with `D = (1 − SphereK).*D` and the SMV-propagated noise
25//!    `tempn = sqrt(SMV(tempn.^2) + tempn.^2)`, exactly as MEDI-SMV does, over the full mask.
26//!
27//! `msmv.m` itself, in MEDI's units (the field in radians over the echo spacing `delta_TE`):
28//!
29//! - `SphereK = single(sphere_kernel(..., radius))`, the partial-volume sphere of MEDI;
30//!   `Mne = SMV(Mask, SphereK) > 0.999`, `Me = Mask − Mne` (the boundary band of width ~radius).
31//! - Prefilter (default on): `RDF_s = Mask.*(RDF − SMV(RDF, SphereK))` (the paper's Eq. 4). With
32//!   `prefilter = 0` the input is used as it is (not masked); the README says this is
33//!   "typically used with SHARP, RESHARP, VSHARP", i.e. background removals that already
34//!   applied an SMV. The paper ran it with the prefilter on after PDF, V-SHARP and LBV.
35//! - Threshold `t = kernel_lim(RDF, ...)` on the **unfiltered** input: starting from a rendered
36//!   sphere of half the smallest voxel, the radius grows in 1e-3 mm steps and `t` is the first
37//!   `max|Mask.*(RDF − SMV(RDF, K))|` that reaches `tmin = 0.01·B0_mag/3` rad. At half a voxel
38//!   the rendered sphere is the centre voxel alone (the residual is 0); the residual then grows
39//!   in steps as sub-voxel samples of the neighbours enter the sphere, so `t` is either just
40//!   above `tmin` (smooth fields) or the first nonzero step, which strong local fields put well
41//!   above it (0.138 rad against `tmin` = 0.023 rad on the 7 T qsm-forward phantom after PDF):
42//!   the code's form of the paper's `max(t_min, lim_{r'→0} ...)`.
43//! - Vessel mask: `Mask_ev = Mask − MaskErode(Mask, radius + 1)`,
44//!   `Mv = imbinarize(fibermetric(Mask_ev.*R2s, 1:vessel_radius, 'ObjectPolarity', 'bright'), 0)`
45//!   with `vessel_radius = max(15, 8·max(voxel_size))` by default. `fibermetric` takes the
46//!   thicknesses in voxels; the threshold 0 keeps every voxel with any bright-tubular response at
47//!   any of the 15+ scales (the Frangi sign condition `λ2, λ3 < 0`), not only veins: 70 % of the
48//!   band on the qsm-forward phantom (in MATLAB as here), so the correction acts on the rest.
49//! - `Mb = imbinarize(|Me.*RDF_s|, t) & ~Mv`; while `sum(Mb)/sum(Mask) > 1e-6`: recompute `Mb`
50//!   from the current field, `RDF_s = Mask.*(RDF_s − SMV(Mb.*RDF_s, r2))` with the minimum
51//!   radius `r2 = min(voxel_size)/2 + 0.05` mm, stop after `maxk − 1` passes (default `maxk` 5,
52//!   so at most 4 passes). The loop test uses the `Mb` of the previous pass.
53//!
54//! # Where the paper and the code differ (the code is followed)
55//! - **Threshold.** The paper (Eq. 7) defines `t = max(t_min, lim_{r'→0} max_M |b_L0 − S_r' b_L0|)`
56//!   on the prefiltered field with `t_min = 0.3 Hz` at 3 T, "scaled accordingly for other field
57//!   strengths". `kernel_lim.m` uses the unfiltered input field and `tmin = 0.01·B0/3` **radians
58//!   over `delta_TE`**, which is 0.3 Hz only for `delta_TE` ≈ 5.3 ms (0.49 Hz at the paper's
59//!   in-vivo 3.25 ms). The field strength comes from `B0_mag`, which `MEDI_L1` reads from the
60//!   input file and otherwise leaves at `msmv.m`'s default of 3 T. QSM.rs passes the scan's
61//!   field strength, as the paper's scaling asks; at 3 T this is the code's value.
62//! - **Iterations.** The paper's `i_max = 5`; the code's `maxk = 5` gives at most 4 passes.
63//! - **Vessel mask.** The paper says "a Frangi filter ... followed by thresholding" without the
64//!   scales or threshold; the code's are above.
65//!
66//! # Ported here
67//! [`msmv`] follows `msmv.m` line for line, with MEDI's rendered `sphere_kernel` for every SMV
68//! (the mSMV release's version, which takes the real part of the kernel's FFT), `kernel_lim.m`
69//! as written, `MaskErode.m`, and MATLAB's `fibermetric` (R2023b, default
70//! `StructureSensitivity` 0.01 for double input, ported in
71//! [`fibermetric`](mod@crate::utils::fibermetric)). R2* is a required input, as it is for
72//! `MEDI_L1`'s mSMV path; MEDI's pipeline (and the authors' SEPIA patch) computes it with ARLO
73//! from the echo magnitudes, which is what [`medi`](fn@super::medi) does
74//! ([`arlo_unclipped`](crate::utils::arlo_unclipped): `arlo.m` exactly, negative values kept,
75//! since the vessel filter sees them).
76//!
77//! Differences from MATLAB, none of which changes the method:
78//! - Arithmetic is in double. `msmv.m` stores `SphereK` as single, which makes `SMV(RDF, SphereK)`
79//!   and, with the prefilter on, the whole filtered field single precision; here the kernel is
80//!   rounded to single as MATLAB stores it but the field stays double. Voxels within single
81//!   rounding of the threshold `t` can be classified differently.
82//! - SMVs with small kernels (`kernel_lim`'s growing sphere and the minimum-radius `r2` kernel)
83//!   are applied as a direct circular convolution with the same kernel instead of by FFT (same
84//!   operator, equal to rounding; `kernel_lim` runs dozens to hundreds of them).
85//! - `fibermetric` is evaluated only on the boundary band `Me`, the only place `Mv` is used
86//!   (`Mb` is zero outside `Me`), so [`MsmvOutput::vessel_mask`] is false outside it.
87//! - `kernel_lim.m` loops forever if the residual never reaches `tmin` (a field below `tmin/2`
88//!   everywhere); here the threshold is then `tmin`, which flags no voxel (see [`kernel_lim`]).
89//!
90//! # Parity with the authors' MATLAB
91//! Against `msmv.m` and `MEDI_L1(..., 'msmv', 5)` (R2023b, the prerelease above):
92//! - Small phantom (`tests/msmv_medi_parity.rs`, 32×36×28, anisotropic voxels): `t` identical,
93//!   `Me` and the vessel mask identical (5615 of 6272 band voxels), the filtered field to 5e-8
94//!   rad (relative 1e-7; single rounding) with the prefilter and to 1e-16 without; one MEDI-mSMV
95//!   Gauss-Newton step to 3e-7 ppm (relative 6e-7).
96//! - qsm-forward phantom after MATLAB's `PDF.m` (164×206×206 once padded, 7 T): `t` 0.138 rad
97//!   equal to 1e-13 relative, `Me` (321,821 voxels) and `Mv` (225,330) identical, the field to
98//!   1.5e-7 relative with the prefilter and 3e-16 without. MEDI-mSMV with MEDI's defaults agrees
99//!   to 4e-3 relative L2 (1e-2 ppm max) and one step solved to `cg_tol` 1e-4 to 2.4e-4 (4e-4 ppm
100//!   max), as close as MEDI-SMV gets in the same setup (1.9e-4): MATLAB's MEDI runs in single
101//!   precision, and at the default `cg_tol` 0.01 CG can stop one iteration apart, which moves χ
102//!   by ~1e-3 (see the parity test's docs).
103//!
104//! # Measured
105//! QSM.rs MEDI with its defaults (λ 1000, MERIT, MEDI+0), after QSM.rs PDF, V-SHARP and LBV,
106//! comparing MEDI without SMV (the paper's Eq. 14), MEDI-SMV (Eq. 13, mask eroded by 5 mm) and
107//! MEDI-mSMV (Eq. 12). "GM variance" is the paper's shadow score, the χ variance in grey matter
108//! (×1e-4 ppm²; lower means less shadow, down to the true variation).
109//!
110//! qsm-forward phantom (7 T, TE 4/12/20/28 ms, 1 mm; ground-truth GM variance 1.61), XSIM and
111//! NRMSE over the brain mask (MEDI-SMV is zero on the third of the mask it erodes):
112//!
113//! | field | BFR | MEDI XSIM / NRMSE / GM var | MEDI-SMV | MEDI-mSMV |
114//! |---|---|---|---|---|
115//! | noise-free | PDF | 0.597 / 65 % / 3.64 | 0.563 / 63 % / 2.37 | 0.561 / 65 % / 2.92 |
116//! | noise-free | V-SHARP | 0.548 / 63 % / 2.88 | 0.510 / 66 % / 2.46 | 0.547 / 63 % / 2.42 |
117//! | noise-free | LBV | 0.612 / 59 % / 3.32 | 0.529 / 65 % / 2.40 | 0.606 / 59 % / 2.46 |
118//! | SNR 50 | PDF | 0.305 / 103 % / 9.20 | 0.352 / 76 % / 3.55 | 0.305 / 90 % / 5.58 |
119//! | SNR 50 | V-SHARP | 0.321 / 80 % / 3.87 | 0.335 / 77 % / 3.12 | 0.333 / 78 % / 3.07 |
120//! | SNR 50 | LBV | 0.296 / 98 % / 9.84 | 0.336 / 77 % / 3.48 | 0.358 / 78 % / 3.52 |
121//!
122//! In vivo, four 3 T multi-echo scans (EMTP travelling heads in QSM-CI: Philips Achieva and
123//! Ingenia, Siemens Skyra and Prisma), GM variance in the SynthSeg cortex, mean of the four
124//! (MEDI-SMV covers only 55–67 % of the cortex; MEDI and MEDI-mSMV 93–100 %):
125//!
126//! | BFR | MEDI | MEDI-SMV | MEDI-mSMV |
127//! |---|---|---|---|
128//! | PDF | 15.1 | 6.0 | 7.3 |
129//! | V-SHARP | 7.0 | 5.5 | 4.5 |
130//! | LBV | 17.0 | 5.8 | 6.2 |
131//!
132//! MEDI-mSMV lowered the GM variance against plain MEDI for every background removal and every
133//! scan (PDF by half, LBV to a third, V-SHARP by a third, the paper's direction and size), to
134//! about MEDI-SMV's level without its erosion, and lowered the χ spread in the 5 mm edge band.
135//! On the phantom it did not raise XSIM in general: noise-free it was level with MEDI after
136//! V-SHARP and LBV and lower after PDF (0.561 vs 0.597); at SNR 50 it was level after PDF and
137//! higher after V-SHARP (0.333 vs 0.321) and LBV (0.358 vs 0.296), with lower NRMSE. It helps
138//! most where the local field carries residual background at the edge (PDF, LBV, noisy data)
139//! and little where V-SHARP's erosion already removed it.
140//!
141//! **Prefilter off** (`msmv.m`'s `prefilter = 0`) breaks the forward model in `MEDI_L1`: the
142//! field is then the background removal's local field, unfiltered, but `MEDI_L1` still fits it
143//! with `(δ − S) d`. On the phantom that gave NRMSE 460 % (PDF), 280 % (V-SHARP) and 740 % (LBV),
144//! and the authors' MATLAB gives the same (481 % for PDF + `MEDI_L1(..., 'msmv', 5)` with
145//! `prefilter = 0`). It is only consistent for a field that is already high-passed by the same
146//! SMV; V-SHARP's deconvolved output is not. The option is kept because the authors' code has
147//! it; keep the default.
148//!
149//! # Use with other inversions
150//! Only MEDI is offered. The authors made and tested MEDI-mSMV only (with PDF, V-SHARP and LBV
151//! before it). An inversion whose forward model is an explicit dipole convolution could in
152//! principle fit the mSMV field with `(δ − S_r1) d` instead of `d`, but none was made or
153//! evaluated by the authors, and the result would be a new method, so QSM.rs does not offer one.
154//!
155//! Reference:
156//! Roberts, A.G., Romano, D.J., Şişman, M., Dimov, A.V., Nguyen, T.D., Kovanlikaya, I.,
157//! Gauthier, S.A., Wang, Y., Spincemaille, P. (2024). "Maximum spherical mean value filtering for
158//! whole-brain QSM." Magnetic Resonance in Medicine, 91(4):1586-1597.
159//! https://doi.org/10.1002/mrm.29963
160//!
161//! Reference implementation: https://github.com/agr78/mSMV (`prerelease/MEDI_toolbox-mSMV100125.zip`)
162
163#[cfg(feature = "parallel")]
164use rayon::prelude::*;
165
166use super::medi::{sphere_kernel_real, sphere_kernel_single, sphere_kernel_sparse};
167use crate::fft::apply_real_kernel;
168use crate::utils::fibermetric::{fibermetric, FiberPolarity, FIBERMETRIC_DEFAULT_C};
169use crate::Grid;
170
171/// mSMV options (`msmv.m`'s `prefilter`, `maxk` and `vessel_radius`, with its defaults). The
172/// prefilter radius is MEDI's SMV radius and the field strength the scan's (see
173/// [`MediParams::msmv`](super::MediParams::msmv)).
174#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
175#[derive(Clone, Debug, PartialEq)]
176pub struct MsmvParams {
177    /// SMV-filter the field first, `Mask.*(RDF − SMV(RDF))` (default on, as in `msmv.m` and the
178    /// paper). The authors' README suggests turning it off after background removals that
179    /// already applied an SMV ("typically used with SHARP, RESHARP, VSHARP"); the paper kept it
180    /// on after PDF, V-SHARP and LBV. In MEDI-mSMV, off is only consistent for a field already
181    /// high-passed by the same 5 mm SMV: MEDI still fits it with the SMV-filtered dipole kernel,
182    /// and after PDF, V-SHARP or LBV that diverges (NRMSE 280–740 % on the phantom, in the
183    /// authors' MATLAB too; see the module docs).
184    pub prefilter: bool,
185    /// `maxk`: the boundary correction makes at most `maxk − 1` passes (default 5)
186    pub maxk: usize,
187    /// Largest `fibermetric` thickness in voxels; `None` is `msmv.m`'s default
188    /// `max(15, 8·max(voxel_size))`. The thicknesses are `1:vessel_radius`.
189    pub vessel_radius: Option<f64>,
190}
191
192impl Default for MsmvParams {
193    fn default() -> Self {
194        Self { prefilter: true, maxk: 5, vessel_radius: None }
195    }
196}
197
198/// [`msmv`]'s output with the intermediate results, for parity checks and evaluation
199#[derive(Clone, Debug)]
200pub struct MsmvOutput {
201    /// The filtered field (`msmv.m`'s return value), in the input's unit (radians)
202    pub field: Vec<f64>,
203    /// `t` from `kernel_lim.m`, radians
204    pub threshold: f64,
205    /// `Mv` on the boundary band `Me` (false elsewhere, see the module docs)
206    pub vessel_mask: Vec<bool>,
207    /// The boundary band `Me = Mask − (SMV(Mask) > 0.999)`, as `Me != 0`
208    pub boundary: Vec<bool>,
209    /// Boundary-correction passes made
210    pub passes: usize,
211}
212
213/// `msmv.m`: mSMV-filter a local field (see the module docs).
214///
215/// # Arguments
216/// * `rdf` - The local field in **radians over the echo spacing**, as MEDI's `RDF` (`nx·ny·nz`)
217/// * `mask` - The brain mask the field is defined on (1 = brain), not eroded
218/// * `r2s` - R2* in Hz (`arlo.m`), for the vessel mask
219/// * `grid` - Dimensions and voxel sizes (mm); each axis needs at least 3 voxels
220/// * `radius` - Prefilter / boundary radius in mm (MEDI's SMV radius, default 5)
221/// * `b0` - `B0_mag` in tesla: sets `tmin = 0.01·b0/3` rad
222/// * `params` - [`MsmvParams`]
223pub fn msmv(
224    rdf: &[f64],
225    mask: &[u8],
226    r2s: &[f64],
227    grid: &Grid,
228    radius: f64,
229    b0: f64,
230    params: &MsmvParams,
231) -> MsmvOutput {
232    let (nx, ny, nz) = grid.dims;
233    let n = grid.n_total();
234    assert_eq!(rdf.len(), n, "mSMV: field length does not match the grid");
235    assert_eq!(mask.len(), n, "mSMV: mask length does not match the grid");
236    assert_eq!(r2s.len(), n, "mSMV: R2* length does not match the grid");
237    assert!(radius.is_finite() && radius > 0.0, "mSMV: radius must be finite and > 0 mm");
238    assert!(b0.is_finite() && b0 > 0.0, "mSMV: field strength must be finite and > 0 T");
239    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
240    let maskf: Vec<f64> = mask.iter().map(|&m| f64::from(m)).collect();
241
242    // r2 = min(voxel_size(:))/2+0.05; SphereK = single(sphere_kernel(matrix_size,voxel_size,radius))
243    let r2 = vs.iter().cloned().fold(f64::INFINITY, f64::min) / 2.0 + 0.05;
244    let sphere_k = sphere_kernel_single(grid, radius);
245
246    // Mne = SMV(Mask,SphereK) > 0.999; Me = Mask-Mne
247    let smv_mask = apply_real_kernel(&maskf, &sphere_k, nx, ny, nz);
248    let me: Vec<f64> = (0..n).map(|i| maskf[i] - f64::from(u8::from(smv_mask[i] > 0.999))).collect();
249
250    // RDF_s = Mask.*(RDF-SMV(RDF,SphereK)), or RDF itself without the prefilter
251    let mut rdf_s: Vec<f64> = if params.prefilter {
252        let s = apply_real_kernel(rdf, &sphere_k, nx, ny, nz);
253        (0..n).map(|i| maskf[i] * (rdf[i] - s[i])).collect()
254    } else {
255        rdf.to_vec()
256    };
257
258    // t = kernel_lim(RDF,voxel_size,matrix_size,Mask,B0_mag)
259    let t = kernel_lim(rdf, &maskf, grid, b0);
260
261    // Mask_ev = Mask-MaskErode(Mask,matrix_size,voxel_size,radius+1)
262    let erode_k = sphere_kernel_real(grid, radius + 1.0);
263    let smv_ev = apply_real_kernel(&maskf, &erode_k, nx, ny, nz);
264    let mask_ev: Vec<f64> = (0..n).map(|i| maskf[i] - f64::from(u8::from(smv_ev[i] > 0.999))).collect();
265
266    // Mv = imbinarize(fibermetric(Mask_ev.*R2s, 1:vessel_radius, 'ObjectPolarity','bright'), 0),
267    // needed only where Me is nonzero
268    let vessel_radius = params
269        .vessel_radius
270        .unwrap_or_else(|| (8.0 * vs.iter().cloned().fold(0.0, f64::max)).max(15.0));
271    let thickness: Vec<usize> = (1..=vessel_radius.floor() as usize).collect();
272    let boundary: Vec<bool> = me.iter().map(|&v| v != 0.0).collect();
273    let ev_r2s: Vec<f64> = (0..n).map(|i| mask_ev[i] * r2s[i]).collect();
274    let vessel_mask: Vec<bool> = if thickness.is_empty() {
275        vec![false; n]
276    } else {
277        fibermetric(&ev_r2s, grid.dims, &thickness, FIBERMETRIC_DEFAULT_C, FiberPolarity::Bright, Some(&boundary))
278            .into_iter()
279            .map(|v| v > 0.0)
280            .collect()
281    };
282
283    // Mb = imbinarize(abs(Me.*RDF_s0),t); Mb = Mb & ~Mv
284    let flagged = |f: &[f64]| -> Vec<bool> { (0..n).map(|i| (me[i] * f[i]).abs() > t && !vessel_mask[i]).collect() };
285    let mut mb = flagged(&rdf_s);
286    let mask_sum: f64 = maskf.iter().sum();
287    let small = SparseKernel::new(grid, r2);
288    let mut passes = 0usize;
289    let mut k = 1usize;
290    while mb.iter().filter(|&&b| b).count() as f64 / mask_sum > 1e-6 {
291        mb = flagged(&rdf_s);
292        // RDF_s = Mask.*(RDF_s-SMV(Mb.*RDF_s,matrix_size,voxel_size,r2))
293        let masked: Vec<f64> = (0..n).map(|i| if mb[i] { rdf_s[i] } else { 0.0 }).collect();
294        let s = small.apply(&masked, grid);
295        for i in 0..n {
296            rdf_s[i] = maskf[i] * (rdf_s[i] - s[i]);
297        }
298        passes += 1;
299        k += 1;
300        if k + 1 > params.maxk {
301            break;
302        }
303    }
304
305    MsmvOutput { field: rdf_s, threshold: t, vessel_mask, boundary, passes }
306}
307
308/// `kernel_lim.m`: the shadow threshold `t` in radians (see the module docs).
309///
310/// `mask` is the brain mask as 0/1. Where `kernel_lim.m` would loop forever (the residual can
311/// never reach `tmin`) this returns `tmin`, which flags no voxel, as any value `kernel_lim.m`
312/// could return would not; and the search stops once the sphere is larger than the volume.
313pub fn kernel_lim(rdf: &[f64], mask: &[f64], grid: &Grid, b0: f64) -> f64 {
314    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
315    let tmin = 0.01 * b0 / 3.0;
316    let mut k = 0.5 * vs.iter().cloned().fold(f64::INFINITY, f64::min);
317    let mut t = 0.001;
318    let eta = 1e-3;
319    let extent = {
320        let (nx, ny, nz) = grid.dims;
321        ((nx as f64 * vs[0]).powi(2) + (ny as f64 * vs[1]).powi(2) + (nz as f64 * vs[2]).powi(2)).sqrt()
322    };
323    // |RDF − SMV(RDF)| <= 2 max|RDF| (the sphere averages with positive weights), so below
324    // tmin/2 kernel_lim.m never returns. No voxel can exceed tmin then, so tmin itself is a
325    // threshold that flags nothing, as any value kernel_lim.m could have returned would.
326    let max_abs = rdf.iter().fold(0.0f64, |m, v| m.max(v.abs()));
327    if t < tmin && 2.0 * max_abs < tmin {
328        return tmin;
329    }
330    while t < tmin {
331        // RDF_smv = Mask.*(RDF-SMV(RDF,matrix_size,voxel_size,K)); t = max(abs(RDF_smv(:)))
332        let s = SparseKernel::new(grid, k).apply(rdf, grid);
333        t = (0..rdf.len()).map(|i| (mask[i] * (rdf[i] - s[i])).abs()).fold(0.0, f64::max);
334        k += eta;
335        if k > extent {
336            break;
337        }
338    }
339    t
340}
341
342/// `SMV(x, matrix_size, voxel_size, r)` for a small `r`: MEDI's rendered sphere applied as a
343/// circular convolution with `real(fftn(...))`'s kernel, i.e. the sphere symmetrised
344/// (`(S(o) + S(−o))/2`), which is the sphere itself on even grids. Large spheres go through the
345/// FFT.
346enum SparseKernel {
347    Direct(Vec<([isize; 3], f64)>),
348    Fft(Vec<f64>),
349}
350
351impl SparseKernel {
352    fn new(grid: &Grid, radius: f64) -> Self {
353        let sparse = sphere_kernel_sparse(grid, radius);
354        if sparse.len() > 512 {
355            return SparseKernel::Fft(sphere_kernel_real(grid, radius));
356        }
357        let (nx, ny, nz) = grid.dims;
358        let signed = |i: usize, n: usize| if 2 * i > n { i as isize - n as isize } else { i as isize };
359        let mut taps: Vec<([isize; 3], f64)> = Vec::with_capacity(2 * sparse.len());
360        for (p, v) in sparse {
361            let (i, j, k) = (p % nx, (p / nx) % ny, p / (nx * ny));
362            let o = [signed(i, nx), signed(j, ny), signed(k, nz)];
363            taps.push((o, v / 2.0));
364            taps.push(([-o[0], -o[1], -o[2]], v / 2.0));
365        }
366        SparseKernel::Direct(taps)
367    }
368
369    fn apply(&self, x: &[f64], grid: &Grid) -> Vec<f64> {
370        let (nx, ny, nz) = grid.dims;
371        match self {
372            SparseKernel::Fft(k) => apply_real_kernel(x, k, nx, ny, nz),
373            SparseKernel::Direct(taps) => {
374                let wrap = |a: usize, o: isize, n: usize| (a as isize + o).rem_euclid(n as isize) as usize;
375                let mut out = vec![0.0f64; x.len()];
376                let fill = |(p, v): (usize, &mut f64)| {
377                    let (i, j, k) = (p % nx, (p / nx) % ny, p / (nx * ny));
378                    let mut acc = 0.0;
379                    for &(o, w) in taps {
380                        acc += w * x[wrap(i, o[0], nx) + nx * (wrap(j, o[1], ny) + ny * wrap(k, o[2], nz))];
381                    }
382                    *v = acc;
383                };
384                #[cfg(feature = "parallel")]
385                out.par_iter_mut().enumerate().with_min_len(1 << 12).for_each(fill);
386                #[cfg(not(feature = "parallel"))]
387                out.iter_mut().enumerate().for_each(fill);
388                out
389            }
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    /// The direct convolution equals `real(ifftn(fftn(x).*K))` with the FFT kernel.
399    #[test]
400    fn direct_smv_matches_fft() {
401        for dims in [(12usize, 10usize, 8usize), (11, 9, 7)] {
402            let grid = Grid::new(dims.0, dims.1, dims.2, 1.0, 0.9, 1.3);
403            let n = grid.n_total();
404            let x: Vec<f64> = (0..n).map(|i| ((i * 37 % 101) as f64 - 50.0) * 0.01).collect();
405            for r in [0.5, 0.55, 0.7, 1.2] {
406                let direct = SparseKernel::new(&grid, r).apply(&x, &grid);
407                let fft = apply_real_kernel(&x, &sphere_kernel_real(&grid, r), dims.0, dims.1, dims.2);
408                let err = direct.iter().zip(&fft).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
409                assert!(err < 1e-13, "dims {dims:?} r {r}: {err}");
410            }
411        }
412    }
413
414    /// At half a voxel the rendered sphere is the centre voxel alone, so kernel_lim's first
415    /// residual is 0 and the threshold is the first residual at or above `tmin`.
416    #[test]
417    fn kernel_lim_reaches_tmin() {
418        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
419        let n = grid.n_total();
420        let rdf: Vec<f64> = (0..n).map(|i| ((i % 16) as f64 * 0.7).sin() * 0.5).collect();
421        let mask = vec![1.0; n];
422        let t = kernel_lim(&rdf, &mask, &grid, 3.0);
423        assert!((0.01..0.02).contains(&t), "t = {t}");
424        let t7 = kernel_lim(&rdf, &mask, &grid, 7.0);
425        assert!(t7 >= 0.01 * 7.0 / 3.0, "t7 = {t7}");
426    }
427
428    /// A field that can never reach tmin ends the search instead of looping.
429    #[test]
430    fn kernel_lim_terminates_on_small_field() {
431        let grid = Grid::new(8, 8, 8, 2.0, 2.0, 2.0);
432        let n = grid.n_total();
433        assert_eq!(kernel_lim(&vec![0.0; n], &vec![1.0; n], &grid, 3.0), 0.01);
434        assert_eq!(kernel_lim(&vec![1e-3; n], &vec![1.0; n], &grid, 3.0), 0.01);
435    }
436
437    fn ball(nn: usize, r: f64) -> (Grid, Vec<u8>) {
438        let grid = Grid::new(nn, nn, nn, 1.0, 1.0, 1.0);
439        let c = nn as f64 / 2.0;
440        let mut mask = vec![0u8; grid.n_total()];
441        for k in 0..nn {
442            for j in 0..nn {
443                for i in 0..nn {
444                    let d = ((i as f64 - c).powi(2) + (j as f64 - c).powi(2) + (k as f64 - c).powi(2)).sqrt();
445                    mask[i + nn * (j + nn * k)] = u8::from(d < r);
446                }
447            }
448        }
449        (grid, mask)
450    }
451
452    /// With the prefilter off and a field below the threshold everywhere, nothing is flagged and
453    /// the field comes back unchanged (and unmasked, as in `msmv.m`).
454    #[test]
455    fn no_prefilter_below_threshold_is_identity() {
456        let (grid, mask) = ball(24, 9.0);
457        let n = grid.n_total();
458        let rdf: Vec<f64> = (0..n).map(|i| 1e-4 * ((i % 24) as f64 * 0.3).sin()).collect();
459        let r2s = vec![20.0; n];
460        let p = MsmvParams { prefilter: false, ..MsmvParams::default() };
461        let out = msmv(&rdf, &mask, &r2s, &grid, 4.0, 3.0, &p);
462        assert_eq!(out.passes, 0);
463        assert_eq!(out.field, rdf);
464    }
465
466    /// A large field on the boundary band is reduced, the mask is not eroded, and every voxel
467    /// the correction touches is in the band.
468    #[test]
469    fn corrects_the_boundary_band() {
470        let (grid, mask) = ball(32, 12.0);
471        let n = grid.n_total();
472        let c = 16.0;
473        // A harmonic-like residual growing towards the edge: x^2 - y^2 in radians
474        let rdf: Vec<f64> = (0..n)
475            .map(|p| {
476                let (i, j) = ((p % 32) as f64 - c, ((p / 32) % 32) as f64 - c);
477                if mask[p] != 0 { 2e-3 * (i * i - j * j) } else { 0.0 }
478            })
479            .collect();
480        let r2s = vec![0.0; n];
481        let out = msmv(&rdf, &mask, &r2s, &grid, 4.0, 3.0, &MsmvParams::default());
482        assert!(out.passes >= 1 && out.passes <= 4, "passes {}", out.passes);
483        assert!(out.vessel_mask.iter().all(|&v| !v), "flat R2* has no vessels");
484        let band_max = |f: &[f64]| (0..n).filter(|&i| out.boundary[i]).map(|i| f[i].abs()).fold(0.0, f64::max);
485        // the prefiltered field before correction
486        let maskf: Vec<f64> = mask.iter().map(|&m| f64::from(m)).collect();
487        let s = apply_real_kernel(&rdf, &sphere_kernel_single(&grid, 4.0), 32, 32, 32);
488        let pre: Vec<f64> = (0..n).map(|i| maskf[i] * (rdf[i] - s[i])).collect();
489        assert!(band_max(&out.field) < band_max(&pre), "{} vs {}", band_max(&out.field), band_max(&pre));
490        assert!((0..n).all(|i| mask[i] != 0 || out.field[i] == 0.0));
491    }
492}