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}