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}