Skip to main content

qsm_core/bgremove/
ismv.rs

1//! Iterative Spherical Mean Value (iSMV) background field removal
2//!
3//! A harmonic (background) field equals its own spherical mean. iSMV holds the field fixed on a
4//! thin band at the edge of the mask and, inside the mask eroded by the sphere, repeatedly
5//! replaces the field by its spherical mean (SMV), `F ← SMV(F)·M + (mask − M)·field`. That
6//! converges to the harmonic field matching the edge band, i.e. the background; the local field
7//! is `(field − F)·M`. No deconvolution follows, so the sphere can be small and the mask is
8//! eroded less than by SHARP.
9//!
10//! # Which implementation (decided 2026-10-29, astewartau/QSM.rs#322)
11//!
12//! The default, [`IsmvVariant::Wen2014`], is **the authors' paper and code together**:
13//!
14//! - **Code:** the iSMV authors' lab (Cornell, Yi Wang's group) ships `iSMV.m` with its kernel
15//!   `tdcsmv.m` in github.com/agr78/mSMV (42a1f18, `code/dependencies/MEDI_functions/`). This
16//!   module ports its loop, kernel and erosion line by line (see *`iSMV.m`, step by step*). Nothing
17//!   in that repository (or in MEDI 2024.11.26, which ships no iSMV) calls it, so the code fixes
18//!   no radius, and its hard-coded tolerance, 0.001, under-converges (below).
19//! - **Paper:** Wen, Zhou, Liu, Spincemaille, Wang 2014 (*Methods*, PMC4026360) stop "when the
20//!   relative change between successive iterates falls below 5 × 10⁻⁵" and compare radii of 1
21//!   and 6 voxels, recommending neither ("much less dependent on the choice of radius"). So the
22//!   defaults are the paper's: `tol` 5 × 10⁻⁵ on `iSMV.m`'s relative change, and a radius of
23//!   **6 voxels**. The paper's other radius, 1 voxel, degenerates with `tdcsmv.m`: its in-plane
24//!   loop runs over `-(n−1):(n−1)` with `n = ceil(r/vs₁) = 1`, so the "sphere" is a 3-voxel
25//!   column along the third axis.
26//! - **6 voxels in mm** is `6 × the smallest voxel dimension` ([`IsmvParams::radius_mm`]): the
27//!   paper's in-vivo voxels were 0.94 × 0.94 × 2 mm, and `tdcsmv.m` counts the sphere's
28//!   in-plane extent in units of `voxel_size(1)`, the in-plane (smallest) voxel size. On
29//!   isotropic voxels the two readings agree. `iSMV.m` itself takes the radius in mm.
30//! - **No iteration cap** by default ([`IsmvParams::max_iter`] `None`), as in `iSMV.m` (the paper
31//!   reports none). The iteration is a contraction on the eroded mask (the SMV of every voxel of
32//!   `M` reaches the fixed edge band or the zero outside the mask), so it always terminates for
33//!   `tol > 0`; a cap is available.
34//!
35//! QSM.jl's `ismv`, which this module followed from 2026-10-28 (the designated implementation of
36//! Kames's SMV methods when the authors have no code), is kept as [`IsmvVariant::QsmJl`] for the
37//! QSM.jl parity pair ([`IsmvParams::qsmjl`]); see *QSM.jl's `ismv`*.
38//!
39//! ## Why the paper's tolerance (measured, 2026-10-28)
40//!
41//! The stopping tolerance matters more than the radius. At `tol` 1e-3 (`iSMV.m` as shipped, or
42//! QSM.jl's default) the iteration stops after 9–15 iterations and leaves background in the
43//! field, which shrinks deep-grey-matter contrast about threefold in vivo. CI phantom
44//! (qsm-forward, 1 mm; local field scored over the brain mask as CI scores it, and over the
45//! 941,844 voxels every setting keeps; RTS χ over those voxels) and the QSM-CI travelling heads
46//! (10 acquisitions of 4 subjects, ROMEO field, SynthSeg regions; |slope − 1| of each scan's
47//! regional χ against the subject's mean across scanners):
48//!
49//! | setting | phantom field, CI scoring: NRMSE % / r | common voxels: NRMSE % / r | RTS χ: NRMSE % / r | in vivo median \|slope−1\| | pallidum χ (ppb) |
50//! |---|---|---|---|---|---|
51//! | QSM.jl: 2 × max voxel, 1e-3 of the first residual, 500 | 60.9 / 0.800 | 53.3 / 0.853 | 68.4 / 0.740 | 0.111 | 41 |
52//! | `iSMV.m` as shipped, 2 voxels, 0.001 | 63.0 / 0.778 | 53.7 / 0.847 | 68.9 / 0.731 | 0.106 | 44 |
53//! | `iSMV.m` as shipped, 6 voxels, 0.001 | 57.9 / 0.817 | 36.2 / 0.934 | 59.9 / 0.806 | 0.055 | 97 |
54//! | **`iSMV.m`, 6 voxels, 5e-5 (Wen 2014; the default)** | 53.0 / 0.848 | **25.2 / 0.968** | 53.6 / 0.848 | 0.036 | 129 |
55//! | QSM.m `ismv.m`: 3 mm, √eps of the first residual, 500 | 48.1 / 0.878 | 27.4 / 0.963 | 51.2 / 0.862 | 0.038 | 134 |
56//! | QSM.rs before 2026-10-28: 5 mm, 1e-6, 50 (no reference) | 51.4 / 0.858 | 26.2 / 0.966 | 54.2 / 0.845 | 0.024 | 124 |
57//!
58//! The default took 94 iterations on the phantom. Its cost is coverage: a 6-voxel sphere keeps
59//! fewer edge voxels (in vivo, on average 1.06 M brain voxels, against 1.25 M at 3 mm and 1.33 M
60//! at 2 voxels).
61//!
62//! ## `iSMV.m`, step by step
63//!
64//! 0. **Odd sizes (QSM.rs's fix).** Every odd axis is zero-padded by one slice at its end and the
65//!    result cropped back, as SEPIA does before its FFT-based background removal
66//!    (`zeropad_odd_dimension(…, 'pre')`, kschan0214/sepia db939cc; see [`super::sharp`](mod@super::sharp)).
67//!    `tdcsmv.m` places the kernel at `f(end/2+1+k, …)`, which is not an integer index on an
68//!    odd axis, so `iSMV.m` errors there; padding is the unequivocal fix, and on even sizes it is
69//!    a no-op.
70//! 1. **Kernel** `a = tdcsmv(matrix_size, voxel_size, radius)`: a sphere of `radius` mm with
71//!    partial-volume weights from sub-voxel samples ([`tdcsmv`]), normalised to sum 1,
72//!    `fftn(fftshift(SMV))`.
73//! 2. **Erosion** `M = ifftn(fftn(Mask).*a) > .999` (MATLAB's `>` compares real parts), periodic
74//!    on the (padded) array. `BV = (Mask − M).*iFreq`.
75//! 3. **Loop** from `F1 = iFreq` (the whole input array, also outside the mask, as `iSMV.m`):
76//!    `F2 = ifftn(fftn(F1).*a).*M + BV`; `d = norm(F1(:)−F2(:))/norm(F1(:))`; `F1 = F2`; while
77//!    `d ≥ tol` (`iSMV.m`: `while (d>=.001)`; here [`IsmvParams::tol`], default 5e-5). The
78//!    iterates are kept complex, as MATLAB's are (their imaginary part is rounding, ~1e-17).
79//!    A NaN `d` (an all-zero input) stops the loop, as in MATLAB.
80//! 4. **Output** `L = (iFreq − F2).*M` (its real part; `iSMV.m` returns the complex array) and
81//!    `M`. The local field is zero outside the eroded mask `M`, not only outside the input mask.
82//!
83//! `tdcsmv.m` has two more places where it cannot run as written; both changes leave every
84//! input it handles unchanged:
85//!
86//! - It uses `voxel_size(1)` for the second axis too (sub-voxel grid, offsets and extent), so on
87//!   voxels with `vs₁ ≠ vs₂` its sphere has the wrong size along that axis. Here the second axis
88//!   uses `voxel_size(2)`; with `vs₁ = vs₂` (every case `tdcsmv.m` is right for) nothing
89//!   changes.
90//! - It picks the sub-voxel factor `w` as the smallest integer with `vs₁·w` integral in single
91//!   precision (`while (int16(g*w)~=single(g*w))`), unless `vs₁ = vs₃` (then 10), then doubles
92//!   it. For a voxel size with no such `w` (a header value like 0.93750006) that loop never ends
93//!   in MATLAB (`int16` saturates at 32767). Here the search stops at
94//!   [`TDCSMV_MAX_SUBVOXEL`] and falls back to `w = 10`, `tdcsmv.m`'s own isotropic choice.
95//!
96//! A sphere that does not fit the array (MATLAB would grow `f` and then fail on the size
97//! mismatch) wraps around it periodically.
98//!
99//! # QSM.jl's `ismv` ([`IsmvVariant::QsmJl`])
100//!
101//! A port of QSM.jl's `ismv` (kamesy/QSM.jl fde4b92, `src/bgremove/ismv.jl`), with its defaults
102//! from [`IsmvParams::qsmjl`]: radius `r = 2·max(vsz)` mm, stop when the residual `‖fᵢ − fᵢ₋₁‖`
103//! falls to `tol = 1e-3` of the first one, at most `maxit = 500` iterations. Its ball is the
104//! voxel centres within `r` (no partial volume), its erosion `S ∗ mask > 1 − √eps(Float64)`, and
105//! it returns `mask·(field − f)` over the input mask with the eroded mask.
106//!
107//! QSM.jl crops to the mask's bounding box and zero-pads to a fast FFT size before filtering;
108//! this port filters on the whole array, periodically. iSMV only applies the SMV kernel
109//! forwards, at voxels whose ball lies inside the mask, so the two are the same unless the mask
110//! reaches two opposite faces of the array (then a ball can wrap around here and count as
111//! fitting). QSM.jl's edge test is `S ∗ mask > 1 − √eps(Float64)`, this port's `> 1 − 1e-10`:
112//! the same for any ball of fewer than 6·10⁷ voxels.
113//!
114//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, ROMEO
115//! total field and brain mask), QSM.jl's `ismv` in the QSM-CI `julia-qsm` image
116//! (`sha256:b3c048ac…`, QSM.jl fde4b92) and this port agree to float rounding (both outputs
117//! saved as float32), with identical masks: at QSM.jl's defaults NRMSE 1.4e-9 %, max |Δ|
118//! 2.3e-10 ppm; at radius 5 mm, `tol` 1e-6, 50 iterations NRMSE 3.1e-9 %, max |Δ| 4.7e-10 ppm.
119//!
120//! # Other implementations, for the record
121//!
122//! | implementation | radius | stopping rule | cap |
123//! |---|---|---|---|
124//! | Wen et al. 2014 + lab `iSMV.m` (the default, [`IsmvVariant::Wen2014`]) | 6 voxels (paper; also 1) | relative change < 5e-5 (paper); `iSMV.m`: < 0.001 | none |
125//! | QSM.jl `ismv` (fde4b92; [`IsmvVariant::QsmJl`]) | `2·max(vsz)` mm | residual < 1e-3 of the first | 500 |
126//! | QSM.m `ismv.m` (kamesy/QSM.m f22dc40, `src/bgremove/ismv.m:12-17,37-39`) | 3 mm | residual < √eps of the first | 500 |
127//!
128//! SEPIA does not offer iSMV.
129//!
130//! # Pipeline rules
131//!
132//! Choosing [`IsmvVariant::QsmJl`] keeps the tolerance and iteration cap that are set, which by
133//! default are the paper's (5e-5, no cap). QSM.jl's own are published as `Reference` rules in
134//! [`crate::pipeline::rules::RULES`], so a host can offer them with the variant:
135//! `ismv-qsmjl.tolerance` (1e-3) and `ismv-qsmjl.max-iterations` (500), QSM.jl fde4b92
136//! `src/bgremove/ismv.jl:39-40` (`tol = 1e-3`, `maxit = 500`; [`IsmvParams::qsmjl`]). Its radius needs
137//! no rule: `radius` `None` is already the variant's own (`2 · max(voxel size)`).
138//!
139//! # Tests
140//!
141//! `tests/ismv_wen2014_parity.rs` checks the default against the lab's `iSMV.m`/`tdcsmv.m` run
142//! in GNU Octave (`tests/data/ismv_wen2014_small.m`): at the paper's defaults on 1 mm and
143//! anisotropic voxels, `iSMV.m` as shipped (tol 0.001), and an odd-sized array padded as above.
144//! `tests/ismv_qsmjl_parity.rs` checks [`IsmvVariant::QsmJl`] against QSM.jl's output.
145//!
146//! Reference:
147//! Wen, Y., Zhou, D., Liu, T., Spincemaille, P., Wang, Y. (2014).
148//! "An iterative spherical mean value method for background field removal in MRI."
149//! Magnetic Resonance in Medicine, 72(4):1065-1071. <https://doi.org/10.1002/mrm.24998>
150//!
151//! Reference implementation: <https://github.com/agr78/mSMV> (`iSMV.m`, `tdcsmv.m`, 42a1f18);
152//! QSM.jl's: <https://github.com/kamesy/QSM.jl>
153
154use num_complex::Complex64;
155use crate::Grid;
156use crate::error::InputError;
157use crate::fft::{fft3d, ifft3d, fft_real_kernel, Fft3dWorkspace};
158use crate::kernels::smv::{smv_kernel, erode_mask_smv};
159use crate::utils::vec_norm;
160
161/// Largest sub-voxel factor `tdcsmv.m`'s search for an integral `voxel_size(1)·w` tries before
162/// falling back to 10 (MATLAB's loop would not end). See the [module docs](self#ismvm-step-by-step).
163pub const TDCSMV_MAX_SUBVOXEL: u32 = 64;
164
165/// `iSMV.m`'s erosion threshold: `M = ifftn(fftn(Mask).*a) > .999`.
166const LAB_ERODE_THRESHOLD: f64 = 0.999;
167
168/// Which implementation of iSMV to reproduce. See the [module docs](self).
169#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
170#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
171pub enum IsmvVariant {
172    /// The authors' paper and code: the lab's `iSMV.m` + `tdcsmv.m` (agr78/mSMV 42a1f18) with
173    /// Wen et al. 2014's stopping rule, odd axes padded to even. The default.
174    #[default]
175    Wen2014,
176    /// QSM.jl's `ismv` (fde4b92): its ball, `1 − √eps` erosion and residual-based stopping
177    /// rule. For the QSM.jl parity pair.
178    QsmJl,
179}
180
181impl IsmvVariant {
182    /// Stable id: `"wen2014"` or `"qsmjl"` (the pipeline rules' `bg_removal.ismv.variant`).
183    pub fn id(self) -> &'static str {
184        match self {
185            IsmvVariant::Wen2014 => "wen2014",
186            IsmvVariant::QsmJl => "qsmjl",
187        }
188    }
189}
190
191/// iSMV parameters. The defaults are Wen et al. 2014's with the lab's `iSMV.m` (see the
192/// [module docs](self)); [`IsmvParams::qsmjl`] gives QSM.jl's.
193#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
194#[derive(Clone, Debug)]
195pub struct IsmvParams {
196    /// The implementation to reproduce (default [`IsmvVariant::Wen2014`]).
197    pub variant: IsmvVariant,
198    /// Stopping tolerance; finite and > 0. [`IsmvVariant::Wen2014`]: stop when the relative
199    /// change between successive iterates, `‖F₁ − F₂‖/‖F₁‖`, falls below it (default 5e-5,
200    /// Wen et al. 2014; `iSMV.m` hard-codes 0.001). [`IsmvVariant::QsmJl`]: stop when the
201    /// residual falls to this fraction of the first one (QSM.jl's `tol`, 1e-3).
202    pub tol: f64,
203    /// Iteration cap; `None` (the default) is none, as in `iSMV.m`. QSM.jl's `maxit` is 500.
204    pub max_iter: Option<usize>,
205    /// Sphere radius in mm, finite and > 0; `None` (the default) is the variant's default:
206    /// 6 voxels for [`IsmvVariant::Wen2014`] (Wen et al. 2014), `2 · max(voxel size)` for
207    /// [`IsmvVariant::QsmJl`]. See [`IsmvParams::radius_mm`]. Before 2026-10-29 the default was
208    /// QSM.jl's, and before 2026-10-28 5 mm with `tol` 1e-6 and 50 iterations, which had no
209    /// reference.
210    pub radius: Option<f64>,
211}
212
213impl Default for IsmvParams {
214    fn default() -> Self {
215        Self { variant: IsmvVariant::Wen2014, tol: Self::WEN2014_TOL, max_iter: None, radius: None }
216    }
217}
218
219impl IsmvParams {
220    /// Wen et al. 2014's stopping tolerance on the relative change between iterates.
221    pub const WEN2014_TOL: f64 = 5e-5;
222    /// Wen et al. 2014's radius in voxels (of the smallest voxel dimension).
223    pub const WEN2014_RADIUS_VOXELS: f64 = 6.0;
224    /// QSM.jl `ismv`'s default kernel radius as a multiple of the largest voxel dimension.
225    pub const QSMJL_RADIUS_VOXELS: f64 = 2.0;
226
227    /// QSM.jl `ismv` at its own defaults: [`IsmvVariant::QsmJl`], `tol` 1e-3, `maxit` 500,
228    /// `r = 2 · max(voxel size)`.
229    pub fn qsmjl() -> Self {
230        Self { variant: IsmvVariant::QsmJl, tol: 1e-3, max_iter: Some(500), radius: None }
231    }
232
233    /// The sphere radius in mm on `grid`: [`radius`](Self::radius), or the variant's default
234    /// (`6 · min(voxel size)` for [`IsmvVariant::Wen2014`], `2 · max(voxel size)` for
235    /// [`IsmvVariant::QsmJl`]).
236    pub fn radius_mm(&self, grid: &Grid) -> f64 {
237        let (a, b, c) = grid.voxel_size;
238        self.radius.unwrap_or(match self.variant {
239            IsmvVariant::Wen2014 => Self::WEN2014_RADIUS_VOXELS * a.min(b).min(c),
240            IsmvVariant::QsmJl => Self::QSMJL_RADIUS_VOXELS * a.max(b).max(c),
241        })
242    }
243
244    /// Checks what iSMV cannot run with: `tol` and the radius must be finite and > 0 (at
245    /// `tol` 0 and no cap the iteration would not end).
246    pub fn validate(&self) -> Result<(), InputError> {
247        if !(self.tol.is_finite() && self.tol > 0.0) {
248            return Err(InputError::param("iSMV tol", self.tol, "finite and > 0"));
249        }
250        if let Some(r) = self.radius {
251            if !(r.is_finite() && r > 0.0) {
252                return Err(InputError::param("iSMV radius", r, "finite and > 0 mm"));
253            }
254        }
255        Ok(())
256    }
257}
258
259/// iSMV background field removal (see the [module docs](self))
260///
261/// # Arguments
262/// * `field` - Total field (nx * ny * nz)
263/// * `mask` - Binary mask (nx * ny * nz), 1 = brain, 0 = background
264/// * `grid` - Volume dimensions and voxel sizes
265/// * `params` - iSMV parameters
266/// * `progress` - Progress callback (iteration, expected iterations; with no cap the second is
267///   an estimate from the convergence rate so far)
268///
269/// # Returns
270/// (local field, eroded mask). [`IsmvVariant::Wen2014`]'s local field is zero outside the
271/// eroded mask (`iSMV.m`), [`IsmvVariant::QsmJl`]'s outside the input mask (QSM.jl).
272pub fn ismv(
273    field: &[f64],
274    mask: &[u8],
275    grid: &Grid,
276    params: &IsmvParams,
277    progress: impl FnMut(usize, usize),
278) -> Result<(Vec<f64>, Vec<u8>), InputError> {
279    params.validate()?;
280    let n = grid.dims.0 * grid.dims.1 * grid.dims.2;
281    if field.len() != n || mask.len() != n {
282        return Err(InputError::new(format!(
283            "iSMV: field ({}) and mask ({}) must have {n} voxels ({:?})", field.len(), mask.len(), grid.dims,
284        )));
285    }
286    let radius = params.radius_mm(grid);
287    Ok(match params.variant {
288        IsmvVariant::Wen2014 => ismv_wen2014(field, mask, grid, radius, params.tol, params.max_iter, progress),
289        IsmvVariant::QsmJl => ismv_qsmjl(field, mask, grid, radius, params.tol, params.max_iter, progress),
290    })
291}
292
293/// `tdcsmv.m` (agr78/mSMV 42a1f18): the SMV sphere of `radius` mm on a `dims` array, centred
294/// at the FFT origin, normalised to sum 1 (image space; `tdcsmv.m` returns its `fftn`).
295///
296/// Each voxel within the sphere's reach gets the fraction of its `w`-per-edge sub-voxel samples
297/// inside the sphere. Two changes, unchanged on every input `tdcsmv.m` runs on (see the
298/// [module docs](self#ismvm-step-by-step)): the second axis uses `voxel_size.1` (`tdcsmv.m`
299/// uses `voxel_size(1)` for it), and the search for `w` stops at [`TDCSMV_MAX_SUBVOXEL`].
300/// Placement wraps around the array (`tdcsmv.m` places at `end/2+1+offset` and `fftshift`s,
301/// which is the same on even sizes).
302pub fn tdcsmv(dims: (usize, usize, usize), voxel_size: (f64, f64, f64), radius: f64) -> Vec<f64> {
303    let (nx, ny, nz) = dims;
304    let (v1, v2, v3) = voxel_size;
305    // if (voxel_size(1)==voxel_size(3)) w = 10; else smallest w with int16(g*w) == single(g*w)
306    let mut w: u32 = if v1 == v3 {
307        10
308    } else {
309        let int16 = |x: f64| x.round().clamp(-32768.0, 32767.0);
310        (1..=TDCSMV_MAX_SUBVOXEL)
311            .find(|&w| { let gw = v1 * w as f64; int16(gw) == (gw as f32) as f64 })
312            .unwrap_or(10)
313    };
314    w *= 2;
315    let wf = w as f64;
316    // vs = voxel_size*w; R = radius*w; dx = vs(1); dz = vs(3)
317    let (dx, dy, dz) = (v1 * wf, v2 * wf, v3 * wf);
318    let r2 = (radius * wf) * (radius * wf);
319    // n = ceil(radius/voxel_size(1)) (per in-plane axis here); zmax = floor((radius+vs(3)/2)/vs(3))
320    let n1 = (radius / v1).ceil() as i64;
321    let n2 = (radius / v2).ceil() as i64;
322    let zmax = ((radius + v3 / 2.0) / v3).floor() as i64;
323    // meshgrid(-d/2+d1 : ps : d/2-d2) with ps = 1, d1 = d2 = 0.5 (MATLAB colon semantics)
324    let colon = |d: f64| -> Vec<f64> {
325        let (a, b) = (-d / 2.0 + 0.5, d / 2.0 - 0.5);
326        if b < a {
327            return Vec::new();
328        }
329        let tol = 3.0 * f64::EPSILON * a.abs().max(b.abs());
330        let count = ((b - a) + tol).floor() as usize + 1;
331        (0..count).map(|i| a + i as f64).collect()
332    };
333    let (px, py, pz) = (colon(dx), colon(dy), colon(dz));
334    let wrap = |o: i64, m: usize| o.rem_euclid(m as i64) as usize;
335    let mut f = vec![0.0f64; nx * ny * nz];
336    let norm = wf * wf * wf; // (w/ps)^3
337    for zof in -zmax..=zmax {
338        let zz: Vec<f64> = pz.iter().map(|&z| (z + dz * zof as f64).powi(2)).collect();
339        for j in (-n2 + 1)..=(n2 - 1) {
340            let yy: Vec<f64> = py.iter().map(|&y| (y + dy * j as f64).powi(2)).collect();
341            for k in (-n1 + 1)..=(n1 - 1) {
342                let xx: Vec<f64> = px.iter().map(|&x| (x + dx * k as f64).powi(2)).collect();
343                // sphere = ((x+dx*k).^2 + (y+dx*j).^2 + (z+dz*zof).^2) <= R^2, summed
344                let mut count = 0usize;
345                for &z2 in &zz {
346                    for &y2 in &yy {
347                        count += xx.iter().filter(|&&x2| x2 + y2 + z2 <= r2).count();
348                    }
349                }
350                // f(end/2+1+k, end/2+1+j, end/2+zof+1) = sum(sphere(:))/((w/ps)^3)
351                f[wrap(k, nx) + nx * (wrap(j, ny) + ny * wrap(zof, nz))] += count as f64 / norm;
352            }
353        }
354    }
355    let s: f64 = f.iter().sum();
356    f.iter_mut().for_each(|v| *v /= s);
357    f
358}
359
360/// The lab's `iSMV.m` with Wen et al. 2014's stopping rule (steps 0–4 of the module docs).
361fn ismv_wen2014(
362    field: &[f64],
363    mask: &[u8],
364    grid: &Grid,
365    radius: f64,
366    tol: f64,
367    max_iter: Option<usize>,
368    mut progress: impl FnMut(usize, usize),
369) -> (Vec<f64>, Vec<u8>) {
370    let (nx, ny, nz) = grid.dims;
371    // 0. SEPIA's zeropad_odd_dimension(…, 'pre'): one zero slice at the end of each odd axis.
372    let (px, py, pz) = (nx + nx % 2, ny + ny % 2, nz + nz % 2);
373    let n = px * py * pz;
374    let at = |i: usize, j: usize, k: usize| i + px * (j + py * k);
375    let pad = |x: &dyn Fn(usize) -> f64| {
376        let mut out = vec![0.0; n];
377        for k in 0..nz {
378            for j in 0..ny {
379                for i in 0..nx {
380                    out[at(i, j, k)] = x(i + nx * (j + ny * k));
381                }
382            }
383        }
384        out
385    };
386    let ifreq = pad(&|l| field[l]);
387    let m0 = pad(&|l| (mask[l] != 0) as u8 as f64);
388    let mut fft = Fft3dWorkspace::new(px, py, pz);
389    let to_c = |x: &[f64]| -> Vec<Complex64> { x.iter().map(|&v| Complex64::new(v, 0.0)).collect() };
390
391    // 1. a = tdcsmv(matrix_size, voxel_size, radius)
392    let mut a = to_c(&tdcsmv((px, py, pz), grid.voxel_size, radius));
393    fft.fft3d(&mut a);
394
395    // 2. M = ifftn(fftn(Mask).*a) > .999; BV = (Mask - M).*iFreq
396    let mut c = to_c(&m0);
397    fft.fft3d(&mut c);
398    c.iter_mut().zip(&a).for_each(|(x, k)| *x *= k);
399    fft.ifft3d(&mut c);
400    let m: Vec<f64> = c.iter().map(|x| (x.re > LAB_ERODE_THRESHOLD) as u8 as f64).collect();
401    let bv: Vec<f64> = (0..n).map(|l| (m0[l] - m[l]) * ifreq[l]).collect();
402
403    // 3. F1 = iFreq; while d >= tol: F2 = ifftn(fftn(F1).*a).*M + BV; d = ‖F1-F2‖/‖F1‖; F1 = F2
404    let mut f1 = to_c(&ifreq);
405    let mut f2 = vec![Complex64::new(0.0, 0.0); n];
406    let mut iter = 0usize;
407    let mut d_prev = f64::NAN;
408    loop {
409        f2.copy_from_slice(&f1);
410        fft.fft3d(&mut f2);
411        f2.iter_mut().zip(&a).for_each(|(x, k)| *x *= k);
412        fft.ifft3d(&mut f2);
413        let (mut num, mut den) = (0.0, 0.0);
414        for l in 0..n {
415            f2[l] = f2[l] * m[l] + bv[l];
416            num += (f1[l] - f2[l]).norm_sqr();
417            den += f1[l].norm_sqr();
418        }
419        let d = num.sqrt() / den.sqrt();
420        iter += 1;
421        std::mem::swap(&mut f1, &mut f2); // F1 = F2 (f1 now holds the newest iterate)
422        let capped = max_iter.is_some_and(|c| iter >= c);
423        // `while (d>=tol)`: a NaN d ends the loop, as in MATLAB.
424        if d < tol || d.is_nan() || capped {
425            progress(iter, iter);
426            break;
427        }
428        progress(iter, max_iter.unwrap_or_else(|| expected_iterations(iter, d, d_prev, tol)));
429        d_prev = d;
430    }
431
432    // 4. L = (F0 - F2).*M, cropped back
433    let mut local = vec![0.0; nx * ny * nz];
434    let mut eroded = vec![0u8; nx * ny * nz];
435    for k in 0..nz {
436        for j in 0..ny {
437            for i in 0..nx {
438                let (l, p) = (i + nx * (j + ny * k), at(i, j, k));
439                local[l] = ((Complex64::new(ifreq[p], 0.0) - f1[p]) * m[p]).re;
440                eroded[l] = m[p] as u8;
441            }
442        }
443    }
444    (local, eroded)
445}
446
447/// Iterations an uncapped geometric convergence from `d_prev` to `d` needs to reach `tol`, for
448/// the progress callback (at least one more than `iter`).
449fn expected_iterations(iter: usize, d: f64, d_prev: f64, tol: f64) -> usize {
450    let rate = d / d_prev;
451    if !(rate.is_finite() && rate > 0.0 && rate < 1.0) {
452        return iter + 1;
453    }
454    let more = ((tol / d).ln() / rate.ln()).ceil();
455    iter + if more.is_finite() && more >= 1.0 { (more as usize).min(1_000_000) } else { 1 }
456}
457
458/// QSM.jl's `ismv` with an explicit radius (mm); see the [module docs](self).
459fn ismv_qsmjl(
460    field: &[f64],
461    mask: &[u8],
462    grid: &Grid,
463    radius: f64,
464    tol: f64,
465    max_iter: Option<usize>,
466    mut progress: impl FnMut(usize, usize),
467) -> (Vec<f64>, Vec<u8>) {
468    let (nx, ny, nz) = grid.dims;
469    let n_total = nx * ny * nz;
470    let max_iter = max_iter.unwrap_or(usize::MAX);
471
472    // Generate SMV kernel
473    let smv = smv_kernel(grid, radius);
474    let smv_fft_real = fft_real_kernel(&smv, nx, ny, nz);
475
476    // Complex FFT needed for iteration loop
477    let mut smv_fft: Vec<Complex64> = smv.iter().map(|&x| Complex64::new(x, 0.0)).collect();
478    fft3d(&mut smv_fft, nx, ny, nz);
479
480    let m0: Vec<f64> = mask.iter().map(|&m| if m != 0 { 1.0 } else { 0.0 }).collect();
481
482    // Erode mask using SMV
483    let eroded_mask: Vec<f64> =
484        erode_mask_smv(mask, &smv_fft_real, grid, 1.0 - 1e-10).iter().map(|&m| m as f64).collect();
485
486    // Boundary correction: bc = (mask - eroded) * field
487    let bc: Vec<f64> = (0..n_total).map(|i| (m0[i] - eroded_mask[i]) * field[i]).collect();
488
489    let mut f: Vec<f64> = field.to_vec();
490    // f0 = eroded_mask * field (for residual calculation)
491    let mut f0: Vec<f64> = field.iter().zip(&eroded_mask).map(|(&fi, &m)| fi * m).collect();
492
493    let mut nr = vec_norm(&f0);
494    let eps = tol * nr;
495
496    let mut iter = 0;
497    while iter < max_iter {
498        progress(iter + 1, if max_iter == usize::MAX { iter + 2 } else { max_iter });
499        if nr <= eps {
500            progress(iter + 1, iter + 1);
501            break;
502        }
503        // f = eroded_mask * SMV(f) + bc
504        let mut f_complex: Vec<Complex64> = f.iter().map(|&x| Complex64::new(x, 0.0)).collect();
505        fft3d(&mut f_complex, nx, ny, nz);
506        f_complex.iter_mut().zip(&smv_fft).for_each(|(x, k)| *x *= k);
507        ifft3d(&mut f_complex, nx, ny, nz);
508        for i in 0..n_total {
509            f[i] = eroded_mask[i] * f_complex[i].re + bc[i];
510        }
511        // residual ||f0 - f||
512        let mut residual_sq = 0.0;
513        for i in 0..n_total {
514            let diff = f0[i] - f[i];
515            residual_sq += diff * diff;
516            f0[i] = f[i];
517        }
518        nr = residual_sq.sqrt();
519        iter += 1;
520    }
521
522    // local field: mask * (field - f)
523    let local_field: Vec<f64> =
524        (0..n_total).map(|i| if mask[i] != 0 { field[i] - f[i] } else { 0.0 }).collect();
525    let eroded_mask_u8: Vec<u8> = eroded_mask.iter().map(|&m| (m > 0.5) as u8).collect();
526    (local_field, eroded_mask_u8)
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn ball_mask(n: usize, r: f64) -> Vec<u8> {
534        let c = (n as f64 - 1.0) / 2.0;
535        let mut m = vec![0u8; n * n * n];
536        for k in 0..n {
537            for j in 0..n {
538                for i in 0..n {
539                    let d2 = (i as f64 - c).powi(2) + (j as f64 - c).powi(2) + (k as f64 - c).powi(2);
540                    m[i + n * (j + n * k)] = (d2 <= r * r) as u8;
541                }
542            }
543        }
544        m
545    }
546
547    fn both() -> [IsmvParams; 2] {
548        [IsmvParams::default(), IsmvParams::qsmjl()]
549    }
550
551    #[test]
552    fn defaults_are_the_papers_and_qsmjls() {
553        let p = IsmvParams::default();
554        assert_eq!(p.variant, IsmvVariant::Wen2014);
555        assert_eq!(p.tol, 5e-5);
556        assert_eq!(p.max_iter, None);
557        let grid = Grid::new(8, 8, 8, 0.9, 0.9, 2.0);
558        assert!((p.radius_mm(&grid) - 5.4).abs() < 1e-12);
559        let q = IsmvParams::qsmjl();
560        assert_eq!((q.variant, q.tol, q.max_iter), (IsmvVariant::QsmJl, 1e-3, Some(500)));
561        assert_eq!(q.radius_mm(&grid), 4.0);
562        assert_eq!(IsmvParams { radius: Some(3.0), ..q }.radius_mm(&grid), 3.0);
563    }
564
565    #[test]
566    fn validate_refuses_unusable_tol_and_radius() {
567        for tol in [0.0, -1.0, f64::NAN, f64::INFINITY] {
568            assert!(IsmvParams { tol, ..Default::default() }.validate().is_err(), "tol {tol}");
569        }
570        for r in [0.0, -2.0, f64::NAN] {
571            assert!(IsmvParams { radius: Some(r), ..Default::default() }.validate().is_err(), "radius {r}");
572        }
573        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
574        assert!(ismv(&[0.0; 64], &[1; 64], &grid, &IsmvParams { tol: 0.0, ..Default::default() }, |_, _| {}).is_err());
575        assert!(ismv(&[0.0; 63], &[1; 64], &grid, &IsmvParams::default(), |_, _| {}).is_err());
576    }
577
578    #[test]
579    fn tdcsmv_is_normalised_symmetric_and_degenerates_at_one_voxel() {
580        let k = tdcsmv((16, 16, 16), (1.0, 1.0, 1.0), 3.0);
581        assert!((k.iter().sum::<f64>() - 1.0).abs() < 1e-12);
582        let at = |i: i64, j: i64, l: i64| k[(i.rem_euclid(16) + 16 * (j.rem_euclid(16) + 16 * l.rem_euclid(16))) as usize];
583        for (i, j, l) in [(1, 0, 0), (2, 1, 0), (1, 1, 2)] {
584            assert!((at(i, j, l) - at(-i, -j, -l)).abs() < 1e-15);
585            assert!((at(i, j, l) - at(j, i, l)).abs() < 1e-15);
586        }
587        // tdcsmv.m's in-plane extent is ±(ceil(r/vs)-1) voxels, its z extent floor(r/vs + 1/2)
588        assert_eq!(at(3, 0, 0), 0.0);
589        assert!(at(0, 0, 3) > 0.0);
590        // r = 1 voxel: a 3-voxel column along the third axis (the paper's other radius)
591        let k1 = tdcsmv((8, 8, 8), (1.0, 1.0, 1.0), 1.0);
592        let nonzero: Vec<usize> = (0..512).filter(|&l| k1[l] != 0.0).collect();
593        assert_eq!(nonzero, vec![0, 64, 7 * 64]);
594    }
595
596    #[test]
597    fn tdcsmv_second_axis_uses_its_own_voxel_size() {
598        // vs₂ = 2·vs₁: the sphere reaches half as many voxels along the second axis
599        let k = tdcsmv((16, 16, 16), (1.0, 2.0, 1.0), 4.0);
600        let at = |i: i64, j: i64| k[(i.rem_euclid(16) + 16 * j.rem_euclid(16)) as usize];
601        assert!(at(3, 0) > 0.0 && at(0, 1) > 0.0);
602        assert_eq!(at(0, 2), 0.0);
603        // a voxel size with no integral w (single precision) still builds a kernel
604        let k = tdcsmv((16, 16, 16), (0.937_500_06, 0.937_500_06, 2.0), 4.0);
605        assert!((k.iter().sum::<f64>() - 1.0).abs() < 1e-12);
606    }
607
608    #[test]
609    fn harmonic_field_is_removed() {
610        // A linear field is harmonic: iSMV's local field vanishes on the eroded mask.
611        let n = 24;
612        let field: Vec<f64> = (0..n * n * n).map(|l| 0.01 * (l / (n * n)) as f64 + 0.002 * (l % n) as f64).collect();
613        let mask = ball_mask(n, 10.0);
614        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
615        for p in [IsmvParams { radius: Some(3.0), ..Default::default() }, IsmvParams::qsmjl()] {
616            let p = IsmvParams { tol: 1e-9, max_iter: Some(5000), ..p };
617            let (local, eroded) = ismv(&field, &mask, &grid, &p, |_, _| {}).unwrap();
618            let kept: usize = eroded.iter().map(|&m| m as usize).sum();
619            assert!(kept > 0 && kept < mask.iter().map(|&m| m as usize).sum::<usize>(), "{:?}", p.variant);
620            // `iSMV.m`'s erosion keeps voxels whose sphere has up to 0.1 % of its weight outside
621            // the mask, where the iterate is 0, so its fixed point is off by up to 1e-3 × the
622            // field; QSM.jl's ball fits exactly.
623            let bound = match p.variant { IsmvVariant::Wen2014 => 1e-3 * 0.25, IsmvVariant::QsmJl => 1e-6 };
624            let worst = (0..local.len()).filter(|&l| eroded[l] != 0).map(|l| local[l].abs()).fold(0.0, f64::max);
625            assert!(worst < bound, "{:?}: max |local| {worst}", p.variant);
626            for l in 0..local.len() {
627                assert!(eroded[l] == 0 || mask[l] != 0, "eroded mask within the mask");
628                if mask[l] == 0 {
629                    assert_eq!(local[l], 0.0);
630                }
631            }
632        }
633    }
634
635    #[test]
636    fn wen2014_is_zero_outside_the_eroded_mask_and_handles_odd_sizes() {
637        let n = 21; // odd: padded to 22 internally
638        let field: Vec<f64> = (0..n * n * n).map(|l| ((l * 7919) % 101) as f64 * 1e-3).collect();
639        let mask = ball_mask(n, 9.0);
640        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
641        let (local, eroded) = ismv(&field, &mask, &grid, &IsmvParams { radius: Some(3.0), ..Default::default() }, |_, _| {}).unwrap();
642        assert_eq!((local.len(), eroded.len()), (n * n * n, n * n * n));
643        assert!(eroded.iter().any(|&m| m != 0));
644        for l in 0..local.len() {
645            assert!(local[l].is_finite());
646            if eroded[l] == 0 {
647                assert_eq!(local[l], 0.0);
648            }
649        }
650    }
651
652    #[test]
653    fn zero_field_and_progress() {
654        let n = 12;
655        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
656        let mask = ball_mask(n, 5.0);
657        for p in both() {
658            let p = IsmvParams { radius: Some(2.0), ..p };
659            let mut calls = Vec::new();
660            let (local, _) = ismv(&vec![0.0; n * n * n], &mask, &grid, &p, |i, m| calls.push((i, m))).unwrap();
661            assert!(local.iter().all(|&v| v == 0.0), "{:?}", p.variant);
662            assert!(!calls.is_empty());
663            let (i, m) = *calls.last().unwrap();
664            assert_eq!(i, m, "{:?}: the last progress call is complete", p.variant);
665        }
666    }
667
668    #[test]
669    fn iteration_cap_is_honoured() {
670        let n = 16;
671        let field: Vec<f64> = (0..n * n * n).map(|l| ((l * 31) % 17) as f64 * 1e-3).collect();
672        let mask = ball_mask(n, 7.0);
673        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
674        for p in both() {
675            let p = IsmvParams { radius: Some(2.0), tol: 1e-14, max_iter: Some(3), ..p };
676            let mut last = 0;
677            ismv(&field, &mask, &grid, &p, |i, _| last = i).unwrap();
678            assert!(last <= 4, "{:?}: {last}", p.variant);
679        }
680    }
681
682    #[test]
683    fn larger_radius_erodes_more() {
684        let n = 20;
685        let field = vec![0.1; n * n * n];
686        let mask = ball_mask(n, 8.0);
687        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
688        for p in both() {
689            let count = |r: f64| {
690                let (_, e) = ismv(&field, &mask, &grid, &IsmvParams { radius: Some(r), ..p.clone() }, |_, _| {}).unwrap();
691                e.iter().map(|&m| m as usize).sum::<usize>()
692            };
693            assert!(count(4.0) < count(2.0), "{:?}", p.variant);
694        }
695    }
696
697    #[test]
698    fn expected_iterations_extrapolates_geometric_convergence() {
699        assert_eq!(expected_iterations(3, 1e-3, f64::NAN, 1e-5), 4);
700        assert_eq!(expected_iterations(3, 1e-3, 1e-2, 1e-5), 5);
701        assert_eq!(expected_iterations(3, 1e-3, 1e-3, 1e-5), 4);
702    }
703}