qsm_core/inversion/star.rs
1//! STAR-QSM: streaking artifact reduction for QSM
2//!
3//! [`star_qsm`] implements **STI Suite 3.0's `QSM_star`** (Wei et al. 2015), as called with
4//! `QSM_star(phase, mask, 'TE', TE, 'B0', B0, 'H', H, 'padsize', pad, 'voxelsize', vs)`
5//! with `pad` = [`StarParams::pad`]. Its default, [`STAR_GUI_PAD`] = [8, 8, 20] voxels, is
6//! what STI Suite's `QSM_GUI` passes (see [`StarParams::pad`]); `QSM_star` called without
7//! `'padsize'` pads [`QSM_STAR_DEFAULT_PAD`] = [0, 0, 0].
8//! STI Suite ships as protected P-code, so the algorithm was recovered by black-box probing
9//! (call tracing through logging wrappers around the functions it calls, capturing the arrays
10//! it hands them, then a MATLAB re-implementation that reproduces `QSM_star` to within 4e-16
11//! relative on phantoms and an in-vivo field):
12//!
13//! 1. Zero-pad the phase `φ` (radians at the echo time) and the mask `M` by `pad[a]` voxels on
14//! both sides of axis `a`. STI's dipole kernel errors on odd extents, so `QSM_star` does
15//! too; here an odd padded extent grows by one more voxel at the high end instead.
16//! Everything below runs on that grid.
17//! 2. Dipole kernel `D = 1/3 − (k·H)²/|k|²` (`D = 0` at `k = 0`) with `k` in cycles/mm
18//! (computed as STI does, to the bit: see `sti_kernel`), and
19//! the k-space mask `K = |D| ≥ 0.065`. STI builds both on a centred k-grid and transforms
20//! with centred FFTs (`fftshift ∘ fftn ∘ fftshift`, unitary); on an even grid every operator
21//! below is the same as the plain FFT convolution used here.
22//! 3. **Initial guess**: `z = M·F⁻¹(D⁺ F(Mφ))` with `D⁺ = 1/D` on `K` and `±1/0.065` off it
23//! (the sign of `D`, `+` where `D = 0`); then twice `z ← M·F⁻¹(K·F z)`; then
24//! `x₀ = z / max|z|`. All of it stays complex.
25//! 4. **Solve** `min ½‖A x − Mφ‖² + τ‖x‖₁` (`A x = F⁻¹(D F(M x))`, `τ = 10⁻⁶`, complex `x`)
26//! from `x₀` with SpaRSA 2.0 (Wright, Nowak & Figueiredo) at the options STI passes:
27//! `'AT'` = `A` itself (not its adjoint `M F⁻¹ D F`), stopping criterion 0 (stop once fewer
28//! than 1 % of the components change between zero and non-zero, after at least 5 and at
29//! most 1000 iterations), and SpaRSA's defaults otherwise: Barzilai–Borwein step
30//! `α = ‖A Δx‖²/‖Δx‖²` clamped to [10⁻³⁰, 10³⁰], `α = 1` at the start, no monotone or
31//! safeguard test (every step is accepted), no continuation, no debiasing, and SpaRSA's
32//! complex soft threshold `x ↦ max(|x| − τ/α, 0)/(max(|x| − τ/α, 0) + τ/α) · x`. STI also
33//! passes a TV operator as `'PHI'`; SpaRSA ignores a `'PHI'` given without `'PSI'`.
34//! If `τ ≥ max|A φ|` SpaRSA returns zero.
35//! 5. `χ = 1.1 · Re(x) / (2π·γ·B0·TE)` with γ = 42.575 MHz/T (STI's `ScalingFactor`), cropped
36//! back to the input grid and masked. The factor 1.1 is a constant in STI's code.
37//!
38//! SpaRSA takes 5–12 iterations on the phantoms below and 5 on the in-vivo field.
39//!
40//! # Units
41//!
42//! Like the other inversions, [`star_qsm`] takes the field in ppm and returns χ in ppm, but
43//! unlike them the result depends on the field's scale: `x₀` is normalised to a peak of one
44//! (in radians), `τ` is fixed and the solve stops after a handful of iterations, so the same
45//! field in ppm gives a different χ at another `TE` or `B0`. [`StarParams::te`] and
46//! [`StarParams::b0`] are therefore part of the method: the field is converted to the phase
47//! `φ = 2π·42.575·B0·TE · field` it would have at the echo time `te` picks from
48//! [`StarParams::echo_times`], which is what STI is given. By default that is the sum of the
49//! echo times: STI Suite's `QSM_GUI` gives `QSM_star` the V-SHARP of the echoes' summed
50//! unwrapped phases with `'TE'` their summed echo times (see [`StarParams::te`]). With
51//! that conversion `star_qsm(field)` is `QSM_star(φ)` exactly (to rounding), 1.1 included.
52//!
53//! # Agreement with STI Suite
54//!
55//! On six 48×56×40 phantoms (3 T and 7 T, TE 5–20 ms, axial and oblique B0, voxels
56//! 0.8×0.9×1.5 mm, `pad` 6, 8 and 12 on every axis) and an in-vivo 7 T local field (164×206×206), χ agrees
57//! with STI Suite's output to rounding: relative L2 difference ≤ 1.1e-14, max |Δχ| ≤ 2.6e-15
58//! ppm, with the same iteration counts. Through STI Suite's GUI (`QSM_GUI`: load, "3D GRE
59//! data", "star QSM", with `QSM_star`'s inputs and output captured) on 64×64×48 at 1×1×1.5 mm,
60//! 80×72×40 at 0.8×0.9×2 mm and 64×64×64 at 1 mm, the default [`STAR_GUI_PAD`] reproduces the
61//! GUI's χ to rounding as well. `tests/star_sti_parity.rs` checks three of the phantoms and the
62//! GUI padding on the anisotropic one (licensed MATLAB R2023b, `tests/data/star_sti_small.m`).
63//! It also checks three 32×36×28 phantoms (3 T axial, anisotropic voxels, oblique 7 T with `pad`
64//! 8) against the verified QSM-CI `star-sti` container (QSMxT/QSM-CI#304), which writes χ as
65//! float32: QSM.rs is within half a float32 ulp of every voxel (relative L2 2.4–3.0e-8, the
66//! float32 rounding; measured 2026-10-30). That fixture is regenerated in Actions by
67//! `tests/data/star_sti_qsmci_small.py`.
68//!
69//! # Performance
70//!
71//! Each SpaRSA iteration costs four 3-D FFTs on the padded grid (`A` twice); the forward
72//! transforms skip the zero rows outside the mask's bounding box and multiply by `D` in their
73//! last pass. The initial guess and setup cost ten more. Memory is three complex and one real
74//! array on the padded grid. On the in-vivo field above (188×230×230 padded, 5 iterations)
75//! it takes 1.2–1.7 s on 16 cores; iLSQR takes 9 s on the same field and cores.
76//!
77//! # Reference pipeline
78//!
79//! What STI Suite 3.0's `QSM_GUI` runs around `QSM_star`, probed headless in MATLAB R2023b
80//! with logging wrappers (3-echo synthetic head, TE 4/10/16 ms, 1×1×1.5 mm). The "3D GRE data"
81//! + "star QSM" buttons and the "Single button for QSM" path call the same functions:
82//!
83//! - **Chain**: `MRPhaseUnwrap` on the raw, unmasked phase of every echo (default `padsize`,
84//! 12 voxels); the unwrapped echoes **summed** (V-SHARP's input is bitwise `sum(φ, 4)`);
85//! `V_SHARP(Σφ, mask, 'smvsize', 12)` (no deconvolution; see [`crate::bgremove::vsharp`](mod@crate::bgremove::vsharp));
86//! then `QSM_star` on V-SHARP's eroded mask.
87//! - **TE**: `QSM_star` gets `'TE'` = the **sum** of the echo times (the GUI's "sum_TE" field:
88//! 30 ms for 4 + 10 + 16; the GUI's "Frequency" output is `TissuePhase/(2π·ΣTE)`), so the
89//! phase it inverts is the summed phase. STAR's result depends on the phase scale (see
90//! *Units*), so this matters; [`StarParams::te`] defaults to [`EchoTime::Sum`] for this.
91//! - **Padding**: `[8, 8, 20]` from the GUI ([`STAR_GUI_PAD`]). Called **without** `'padsize'`,
92//! `QSM_star` pads **0**: its output is bitwise equal to `'padsize', [0 0 0]`, and differs from
93//! `[12 12 12]` by 8–15 % relative, on three grids (64×64×48 at 1×1×1.5 mm and at 1 mm,
94//! 54×60×40 at 0.8×0.9×2 mm). `[12, 12, 12]` ([`STI_TEMPLATES_STAR_PAD`]) is the default that
95//! `STI_Templates.m` lists, not what the function does.
96//! - **Masking**: the GUI thresholds the magnitude (`Support_Functions/CreateMask.m`: rescale,
97//! threshold, largest connected component, fill holes; then the P-coded
98//! `Getmasking`/`PolishMask`), not BET.
99//! - **Data**: the v3.0 manual's test data are 3T multi-echo human brain GRE (0.9×0.9×2 mm,
100//! 3 echoes), single-echo human and mouse brain, and 2D GRE-EPI.
101//!
102//! Reference:
103//! Wei, H., Dibb, R., Zhou, Y., Sun, Y., Xu, J., Wang, N., Liu, C. (2015). "Streaking artifact
104//! reduction for quantitative susceptibility mapping of sources with large dynamic range."
105//! NMR in Biomedicine, 28(10):1294-1303. <https://doi.org/10.1002/nbm.3383>
106//!
107//! Wright, S.J., Nowak, R.D., Figueiredo, M.A.T. (2009). "Sparse reconstruction by separable
108//! approximation." IEEE Trans. Signal Process. 57(7):2479-2493.
109//! <https://doi.org/10.1109/TSP.2009.2016892>
110//!
111//! # Pipeline rules
112//!
113//! The facts above that a configuration can depart from are published as rules in
114//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `sti.unwrap-laplacian`, `sti.laplacian-solver`, `sti.echo-combination`, `sti.echo-weights`, `sti.bg-vsharp`, `sti.vsharp-no-deconvolve`, `sti.vsharp-radius`, `sti.vsharp-grid`, `star.padding`, `star.te-sum`.
115
116use num_complex::Complex64;
117#[cfg(feature = "parallel")]
118use rayon::prelude::*;
119use crate::fft::{Box3, Fft3dWorkspace, FftOpts};
120use crate::{EchoTime, Grid};
121
122/// Parameters for [`star_qsm`]. The defaults are STI Suite's: the GUI's padding, and
123/// `QSM_star`'s defaults otherwise.
124#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
125#[derive(Clone, Debug)]
126pub struct StarParams {
127 /// Zero padding per side in voxels, per axis (x, y, z in voxel axes): STI's `'padsize'`.
128 ///
129 /// Default [`STAR_GUI_PAD`] = `[8, 8, 20]`: what STI Suite 3.0's `QSM_GUI` passes to
130 /// `QSM_star`, so the default gives what STI Suite's GUI users get. It is a constant in the
131 /// GUI, not derived from the data. Probed in MATLAB R2023b by driving `QSM_GUI` headless
132 /// (load, "3D GRE data", "star QSM", and the "Single button for QSM" path) with a logging
133 /// wrapper around `QSM_star`: `'padsize'` was `[8 8 20]` on all 14 grid / voxel-size
134 /// combinations tried (32 to 128 voxels per axis, axes permuted; 0.5 to 3 mm voxels,
135 /// isotropic and anisotropic) and with other mask sizes and positions, field strengths,
136 /// B0 directions and V-SHARP radii. The GUI has no padding control. The 20 is on the third
137 /// voxel axis whatever the B0 direction. (The GUI's phase unwrapping fails on odd matrix
138 /// sizes, so it never gives `QSM_star` an odd grid; see step 1 of the module docs for odd
139 /// grids here.)
140 ///
141 /// [`QSM_STAR_DEFAULT_PAD`] (`[0, 0, 0]`) is what `QSM_star` does when called without
142 /// `'padsize'`. `STI_Templates.m` lists `[12 12 12]` as the default
143 /// ([`STI_TEMPLATES_STAR_PAD`]), but that is not what the function does. Any other padding
144 /// can be given per axis.
145 ///
146 /// **B0 direction (a deliberate deviation from the GUI).** The GUI takes `'H'` from its
147 /// `H_Vector` field, which loading the data does not fill from the scan (the same probe:
148 /// the loader returned an oblique direction and `QSM_star` still got `[0 0 1]`), so GUI
149 /// users get `[0 0 1]` unless they type a direction in. [`star_qsm`] instead takes the
150 /// direction as its `bdir` argument, and the pipeline passes the scan's B0 direction (from
151 /// the affine, as for every other inversion), which is correct for oblique acquisitions.
152 /// For the GUI's result, pass `(0, 0, 1)` explicitly; for axial scans the two agree.
153 pub pad: [usize; 3],
154 /// Echo times in seconds, in acquisition order (default `[0.04]`, STI's 40 ms). The
155 /// pipeline sets them from the scan metadata.
156 pub echo_times: Vec<f64>,
157 /// Which echo time the field is converted to phase at (see the module docs, *Units*).
158 /// Default [`EchoTime::Sum`], as STI Suite 3.0's multi-echo pipeline (`QSM_GUI`) gives
159 /// `QSM_star` its phase. Probed in MATLAB R2023b with logging wrappers, on 3 synthetic
160 /// echoes: loading the data sets its `sum_TE` field (`TE_value`) to `1000·sum(TEs)`;
161 /// "3D GRE data" (`VSHARPCallBackFcn`) runs `V_SHARP` on the sum of the echoes'
162 /// Laplacian-unwrapped phases (`MRPhaseUnwrap`), bit for bit, and divides that tissue
163 /// phase by `2π·ΣTE` for its frequency map; "star QSM" (`starQSMCallBack`) calls
164 /// `QSM_star(TissuePhase, NewMask, 'TE', sum_TE, ...)`. So the phase is the field over
165 /// `ΣTE`. (`STI_Templates.m` line 107's `TE=40` is that field's default.)
166 pub te: EchoTime,
167 /// Field strength in tesla (default 3). The pipeline sets it from the scan metadata.
168 pub b0: f64,
169}
170
171impl Default for StarParams {
172 fn default() -> Self {
173 Self { pad: STAR_GUI_PAD, echo_times: vec![0.04], te: EchoTime::Sum, b0: 3.0 }
174 }
175}
176
177impl StarParams {
178 /// Checks the parameters: the echo time `te` picks must be finite and > 0, and so must
179 /// `b0`.
180 pub fn validate(&self) -> Result<(), String> {
181 self.te.resolve(&self.echo_times).map_err(|e| format!("STAR-QSM: {e}"))?;
182 if !(self.b0.is_finite() && self.b0 > 0.0) {
183 return Err(format!("STAR-QSM field strength must be finite and > 0 T, got {}", self.b0));
184 }
185 Ok(())
186 }
187
188 /// Radians of phase per ppm of field at the echo time `te` picks and B0, with STI's
189 /// γ = 42.575 MHz/T.
190 ///
191 /// # Panics
192 /// If `te` cannot be resolved from `echo_times` ([`validate`](Self::validate) checks).
193 pub fn rad_per_ppm(&self) -> f64 {
194 let te = self.te.seconds(&self.echo_times, "STAR-QSM");
195 2.0 * std::f64::consts::PI * STI_GAMMA_MHZ_PER_T * self.b0 * te
196 }
197}
198
199/// Zero padding STI Suite 3.0's `QSM_GUI` gives `QSM_star`, in voxels per side on each axis,
200/// and the default of [`StarParams::pad`].
201pub const STAR_GUI_PAD: [usize; 3] = [8, 8, 20];
202
203/// Zero padding `QSM_star` uses when called without `'padsize'`, in voxels per side: none.
204/// Probed in MATLAB R2023b on STI Suite 3.0: `QSM_star` without `'padsize'` is bitwise equal to
205/// `'padsize', [0 0 0]` and differs from `[12 12 12]` by 8-15 % relative, on 64×64×48 at
206/// 1×1×1.5 mm and at 1 mm and on 54×60×40 at 0.8×0.9×2 mm.
207pub const QSM_STAR_DEFAULT_PAD: [usize; 3] = [0, 0, 0];
208
209/// Zero padding `STI_Templates.m` lists as `QSM_star`'s default (`padsize = [12 12 12]`), in
210/// voxels per side. Not what the function does without `'padsize'` ([`QSM_STAR_DEFAULT_PAD`]).
211pub const STI_TEMPLATES_STAR_PAD: [usize; 3] = [12, 12, 12];
212
213/// γ/2π in MHz/T as STI Suite's `ScalingFactor` uses it: the reference implementation's value,
214/// not `crate::constants`. Keep it.
215const STI_GAMMA_MHZ_PER_T: f64 = 42.575;
216/// The constant STI multiplies the solution by.
217const STI_CHI_FACTOR: f64 = 1.1;
218/// SpaRSA's L1 weight `τ` as STI passes it.
219const TAU: f64 = 1e-6;
220/// `|D|` below which the initial guess clamps `1/D`, and outside which `K` keeps k-space.
221const KSPACE_THRESHOLD: f64 = 0.065;
222/// SpaRSA's `MaxiterA` as STI passes it.
223const MAX_ITER: usize = 1000;
224/// SpaRSA's default `MiniterA`.
225const MIN_ITER: usize = 5;
226/// SpaRSA's default `ToleranceA` (stopping criterion 0: fraction of components changing).
227const TOL_A: f64 = 0.01;
228/// SpaRSA's default `AlphaMin` / `AlphaMax`.
229const ALPHA_MIN: f64 = 1e-30;
230const ALPHA_MAX: f64 = 1e30;
231
232/// Chunk length of the reductions: fixed, so every sum is independent of the thread count.
233const CHUNK: usize = 1 << 16;
234
235/// STAR-QSM as in STI Suite 3.0 (`QSM_star`).
236///
237/// See the module docs for the algorithm. `field` is the local field in ppm, `bdir` the B0
238/// direction in voxel axes (normalised here). `progress` is called after every SpaRSA
239/// iteration with `(iteration, 1000)`.
240///
241/// # Returns
242/// χ in ppm on the input grid, zero outside the mask.
243///
244/// # Panics
245/// If `params` fails [`StarParams::validate`] (the pipeline checks this first and returns
246/// `InvalidConfig`), or `field` / `mask` do not match `grid`.
247pub fn star_qsm(
248 field: &[f64],
249 mask: &[u8],
250 grid: &Grid,
251 bdir: (f64, f64, f64),
252 params: &StarParams,
253 progress: impl FnMut(usize, usize),
254) -> Vec<f64> {
255 if let Err(e) = params.validate() {
256 panic!("{e}");
257 }
258 let (nx, ny, nz) = grid.dims;
259 let n_in = nx * ny * nz;
260 assert_eq!(field.len(), n_in, "field does not match the grid");
261 assert_eq!(mask.len(), n_in, "mask does not match the grid");
262 let mut chi = vec![0.0; n_in];
263 let p = params.pad;
264 let n = padded_dims([nx, ny, nz], p);
265 let nt = n[0] * n[1] * n[2];
266 let src_dst = |i: usize, j: usize, k: usize| (i + j * nx + k * nx * ny, (i + p[0]) + (j + p[1]) * n[0] + (k + p[2]) * n[0] * n[1]);
267
268 // 1. padded masked phase, mask, and the mask's bounding box on the padded grid
269 let rad = params.rad_per_ppm();
270 let mut y = vec![0.0; nt];
271 let mut inside = vec![false; nt];
272 let (mut lo, mut hi) = ([usize::MAX; 3], [0usize; 3]);
273 for k in 0..nz {
274 for j in 0..ny {
275 for i in 0..nx {
276 let (s, d) = src_dst(i, j, k);
277 if mask[s] != 0 {
278 y[d] = field[s] * rad;
279 inside[d] = true;
280 for (a, c) in [i + p[0], j + p[1], k + p[2]].into_iter().enumerate() {
281 lo[a] = lo[a].min(c);
282 hi[a] = hi[a].max(c);
283 }
284 }
285 }
286 }
287 }
288 if lo[0] == usize::MAX {
289 return chi;
290 }
291 let support: Box3 = [0, 1, 2].map(|a| lo[a]..hi[a] + 1);
292
293 // 2. dipole kernel on the padded grid
294 let vs = grid.voxel_size;
295 let dk = sti_kernel(n, [vs.0, vs.1, vs.2], bdir);
296 let mut ws = Fft3dWorkspace::new(n[0], n[1], n[2]);
297
298 // 3. initial guess
299 let Some(mut x) = initial_guess(&y, &inside, &dk, &support, &mut ws) else {
300 return chi;
301 };
302
303 // 4. SpaRSA with A = F⁻¹ D F M (also as Aᵀ)
304 let mut apply_a = |v: &mut [Complex64]| {
305 ws.fft3d_with(v, &FftOpts { support: Some(&support), post: Some(&dk), ..FftOpts::default() });
306 ws.ifft3d(v);
307 };
308 sparsa(&mut x, &y, &inside, TAU, MAX_ITER, &mut apply_a, progress);
309
310 // 5. scale, crop, mask
311 let scale = STI_CHI_FACTOR * (1.0 / rad);
312 for k in 0..nz {
313 for j in 0..ny {
314 for i in 0..nx {
315 let (s, d) = src_dst(i, j, k);
316 if mask[s] != 0 {
317 chi[s] = scale * x[d].re;
318 }
319 }
320 }
321 }
322 chi
323}
324
325/// STI's dipole kernel (`calcD2Matrix`) in unshifted order: `D = 1/3 − (k·H)²/|k|²`, 0 at
326/// `k = 0`, with `k_d = f/(n_d·vs_d)` for the signed frequency index `f` (−n/2 at Nyquist) and
327/// `H` normalised.
328///
329/// It is [`dipole_kernel`](crate::kernels::dipole::dipole_kernel)'s formula, but evaluated the
330/// way STI does, bit for bit: `k` by division rather than by multiplying with `1/(n·vs)`. The
331/// two differ in the last bit of `k` on anisotropic grids, which flips the sign of `D` at
332/// k-space points exactly on the magic-angle cone, and the initial guess's `±1/0.065` with it
333/// (a 3e-6 relative change in χ on the 0.8×0.9×1.5 mm phantom).
334fn sti_kernel(n: [usize; 3], vs: [f64; 3], bdir: (f64, f64, f64)) -> Vec<f64> {
335 let hn = (bdir.0 * bdir.0 + bdir.1 * bdir.1 + bdir.2 * bdir.2).sqrt();
336 let h = [bdir.0 / hn, bdir.1 / hn, bdir.2 / hn];
337 let k: Vec<Vec<f64>> = (0..3)
338 .map(|a| {
339 (0..n[a])
340 .map(|i| {
341 let f = if i < n[a] / 2 { i as f64 } else { i as f64 - n[a] as f64 };
342 f / (n[a] as f64 * vs[a])
343 })
344 .collect()
345 })
346 .collect();
347 let mut d = vec![0.0; n[0] * n[1] * n[2]];
348 crate::maybe_par_chunks_mut!(d, n[0] * n[1]).enumerate().for_each(|(kz, plane)| {
349 let z = k[2][kz];
350 for (l, v) in plane.iter_mut().enumerate() {
351 let (x, y) = (k[0][l % n[0]], k[1][l / n[0]]);
352 let k2 = x * x + y * y + z * z;
353 if k2 != 0.0 {
354 let kh = x * h[0] + y * h[1] + z * h[2];
355 *v = 1.0 / 3.0 - kh * kh / k2;
356 }
357 }
358 });
359 d
360}
361
362/// Padded grid: `pad[a]` voxels on both sides of axis `a`, plus one at the high end where
363/// that is odd.
364fn padded_dims(dims: [usize; 3], pad: [usize; 3]) -> [usize; 3] {
365 [0, 1, 2].map(|a| dims[a] + 2 * pad[a] + (dims[a] % 2))
366}
367
368/// Step 3: `x₀ = z / max|z|` with `z = M F⁻¹(K F(M F⁻¹(K F(M F⁻¹(D⁺ F y))))))`. `y` is zero
369/// outside `inside`, which lies in `support`. `None` if `z` is zero.
370fn initial_guess(
371 y: &[f64],
372 inside: &[bool],
373 dk: &[f64],
374 support: &Box3,
375 ws: &mut Fft3dWorkspace,
376) -> Option<Vec<Complex64>> {
377 let mut z: Vec<Complex64> = crate::maybe_par_iter!(y).map(|&v| Complex64::new(v, 0.0)).collect();
378 let opts = FftOpts { support: Some(support), ..FftOpts::default() };
379 ws.fft3d_with(&mut z, &opts);
380 crate::maybe_par_iter_mut!(z).zip(crate::maybe_par_iter!(dk)).for_each(|(v, &d)| *v *= dinv(d));
381 ws.ifft3d(&mut z);
382 mask_inplace(&mut z, inside);
383 for _ in 0..2 {
384 ws.fft3d_with(&mut z, &opts);
385 crate::maybe_par_iter_mut!(z).zip(crate::maybe_par_iter!(dk)).for_each(|(v, &d)| {
386 if d.abs() < KSPACE_THRESHOLD {
387 *v = Complex64::new(0.0, 0.0);
388 }
389 });
390 ws.ifft3d(&mut z);
391 mask_inplace(&mut z, inside);
392 }
393 let peak = z.iter().map(|v| v.norm()).fold(0.0, f64::max);
394 if peak == 0.0 {
395 return None;
396 }
397 crate::maybe_par_iter_mut!(z).for_each(|v| *v /= peak);
398 Some(z)
399}
400
401/// The initial guess's inverse kernel: `1/D` where `|D| ≥ 0.065`, else `±1/0.065` by the sign
402/// of `D` (`+` at `D = 0`).
403fn dinv(d: f64) -> f64 {
404 if d.abs() >= KSPACE_THRESHOLD {
405 1.0 / d
406 } else if d < 0.0 {
407 -1.0 / KSPACE_THRESHOLD
408 } else {
409 1.0 / KSPACE_THRESHOLD
410 }
411}
412
413fn mask_inplace(z: &mut [Complex64], inside: &[bool]) {
414 crate::maybe_par_iter_mut!(z).zip(crate::maybe_par_iter!(inside)).for_each(|(v, &m)| {
415 if !m {
416 *v = Complex64::new(0.0, 0.0);
417 }
418 });
419}
420
421/// SpaRSA's complex soft threshold (`soft.m`): `max(|v| − t, 0)/(max(|v| − t, 0) + t) · v`.
422#[inline]
423fn soft(v: Complex64, t: f64) -> Complex64 {
424 if t == 0.0 {
425 return v;
426 }
427 let s = (v.norm() - t).max(0.0);
428 v * (s / (s + t))
429}
430
431#[inline]
432fn nsq(z: Complex64) -> f64 {
433 z.re * z.re + z.im * z.im
434}
435
436/// SpaRSA 2.0 at STI's options (module docs, step 4) for `A x = op(M x)`, `Aᵀ` = `A`, with
437/// `y` zero outside `inside`. `x` holds the initial guess on entry and the solution on return.
438/// `apply_core` applies `op` in place. Returns the number of iterations (0 if `τ ≥ max|A y|`,
439/// when `x` is set to zero as SpaRSA does).
440fn sparsa(
441 x: &mut [Complex64],
442 y: &[f64],
443 inside: &[bool],
444 tau: f64,
445 max_iter: usize,
446 apply_core: &mut dyn FnMut(&mut [Complex64]),
447 mut progress: impl FnMut(usize, usize),
448) -> usize {
449 let nt = x.len();
450 let zero = Complex64::new(0.0, 0.0);
451 // resid and a work buffer (Aᵀ resid, then the masked step, then A·step)
452 let mut r: Vec<Complex64> = crate::maybe_par_iter!(y).map(|&v| Complex64::new(v, 0.0)).collect();
453 let mut g = vec![zero; nt];
454
455 // SpaRSA returns zero when τ ≥ max|Aᵀy| (y is already masked)
456 apply_core(&mut r);
457 let max_tau = r.iter().map(|v| v.norm()).fold(0.0, f64::max);
458 if tau >= max_tau {
459 x.fill(zero);
460 return 0;
461 }
462
463 // resid = A x − y; then g = M·resid, the input of Aᵀ resid
464 crate::maybe_par_iter_mut!(r).zip(crate::maybe_par_iter!(x)).zip(crate::maybe_par_iter!(inside))
465 .for_each(|((rv, &xv), &m)| *rv = if m { xv } else { zero });
466 apply_core(&mut r);
467 crate::maybe_par_iter_mut!(r).zip(crate::maybe_par_iter_mut!(g)).zip(crate::maybe_par_iter!(y)).zip(crate::maybe_par_iter!(inside))
468 .for_each(|(((rv, gv), &yv), &m)| {
469 *rv -= yv;
470 *gv = if m { *rv } else { zero };
471 });
472
473 let mut nz: Vec<bool> = crate::maybe_par_iter!(x).map(|v| *v != zero).collect();
474 let mut alpha = 1.0;
475 let mut iter = 1usize;
476 let mut keep_going = true;
477 while keep_going {
478 apply_core(&mut g); // gradq = Aᵀ resid
479
480 // x ← soft(x − gradq/α, τ/α); g ← M·Δx; ‖Δx‖² and the active-set change
481 let (inv_alpha, t) = (1.0 / alpha, tau / alpha);
482 let parts: Vec<(f64, usize, usize)> = crate::maybe_par_chunks_mut!(x, CHUNK)
483 .zip(crate::maybe_par_chunks_mut!(g, CHUNK))
484 .zip(crate::maybe_par_chunks_mut!(nz, CHUNK))
485 .zip(crate::maybe_par_chunks!(inside, CHUNK))
486 .map(|(((xc, gc), nc), mc)| {
487 let (mut dd, mut changed, mut nnz) = (0.0, 0usize, 0usize);
488 for (((xv, gv), nv), &m) in xc.iter_mut().zip(gc.iter_mut()).zip(nc.iter_mut()).zip(mc) {
489 let xn = soft(*xv - *gv * inv_alpha, t);
490 let dx = xn - *xv;
491 *xv = xn;
492 dd += nsq(dx);
493 *gv = if m { dx } else { zero };
494 let now = xn != zero;
495 changed += (now != *nv) as usize;
496 nnz += now as usize;
497 *nv = now;
498 }
499 (dd, changed, nnz)
500 })
501 .collect();
502 let dd: f64 = parts.iter().map(|p| p.0).sum();
503 let changed: usize = parts.iter().map(|p| p.1).sum();
504 let nnz: usize = parts.iter().map(|p| p.2).sum();
505
506 apply_core(&mut g); // A Δx
507
508 // resid += A Δx, ‖A Δx‖², then g = M·resid for the next Aᵀ resid
509 let dgd: f64 = crate::maybe_par_chunks_mut!(r, CHUNK)
510 .zip(crate::maybe_par_chunks_mut!(g, CHUNK))
511 .zip(crate::maybe_par_chunks!(inside, CHUNK))
512 .map(|((rc, gc), mc)| {
513 let mut s = 0.0;
514 for ((rv, gv), &m) in rc.iter_mut().zip(gc.iter_mut()).zip(mc) {
515 s += nsq(*gv);
516 *rv += *gv;
517 *gv = if m { *rv } else { zero };
518 }
519 s
520 })
521 .collect::<Vec<f64>>()
522 .iter()
523 .sum();
524
525 // min(αmax, max(αmin, ·)) as MATLAB evaluates it, where max(αmin, NaN) is αmin
526 let bb = dgd / (f64::MIN_POSITIVE + dd);
527 alpha = if bb.is_nan() { ALPHA_MIN } else { bb.clamp(ALPHA_MIN, ALPHA_MAX) };
528 iter += 1;
529 if nnz >= 1 {
530 keep_going = changed as f64 / nnz as f64 > TOL_A;
531 }
532 if iter <= MIN_ITER {
533 keep_going = true;
534 } else if iter > max_iter {
535 keep_going = false;
536 }
537 progress(iter - 1, max_iter);
538 }
539 iter - 1
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::kernels::dipole::dipole_kernel;
546 use std::f64::consts::PI;
547
548 /// fftshift index map on one axis (even n: its own inverse).
549 fn shift(i: usize, n: usize) -> usize {
550 (i + n / 2) % n
551 }
552
553 /// STI's kernel: `D = 1/3 − (k·H)²/|k|²` on the centred grid `k_d = (−n/2 … n/2−1)/(n·vs)`.
554 fn centred_kernel(n: [usize; 3], vs: [f64; 3], h: [f64; 3]) -> Vec<f64> {
555 let mut d = vec![0.0; n[0] * n[1] * n[2]];
556 for k in 0..n[2] {
557 for j in 0..n[1] {
558 for i in 0..n[0] {
559 let kv = [i, j, k].iter().enumerate()
560 .map(|(a, &c)| (c as f64 - (n[a] / 2) as f64) / (n[a] as f64 * vs[a]))
561 .collect::<Vec<_>>();
562 let k2: f64 = kv.iter().map(|v| v * v).sum();
563 let kh: f64 = kv.iter().zip(h).map(|(a, b)| a * b).sum();
564 d[i + j * n[0] + k * n[0] * n[1]] = if k2 == 0.0 { 0.0 } else { 1.0 / 3.0 - kh * kh / k2 };
565 }
566 }
567 }
568 d
569 }
570
571 /// STI's centred unitary transform, `fftshift(fftn(fftshift(x)))/sqrt(N)` (or the inverse),
572 /// as a direct sum.
573 fn fftnc_naive(x: &[Complex64], n: [usize; 3], inverse: bool) -> Vec<Complex64> {
574 let nt = x.len();
575 let sgn = if inverse { 1.0 } else { -1.0 };
576 let mut out = vec![Complex64::new(0.0, 0.0); nt];
577 for (o, ov) in out.iter_mut().enumerate() {
578 let oc = [o % n[0], (o / n[0]) % n[1], o / (n[0] * n[1])];
579 // output index o of the shifted spectrum is frequency index shift(oc)
580 let f = [0, 1, 2].map(|a| shift(oc[a], n[a]));
581 let mut s = Complex64::new(0.0, 0.0);
582 for (l, &xv) in x.iter().enumerate() {
583 let lc = [l % n[0], (l / n[0]) % n[1], l / (n[0] * n[1])];
584 let t = [0, 1, 2].map(|a| shift(lc[a], n[a]));
585 let ph: f64 = (0..3).map(|a| (f[a] * t[a]) as f64 / n[a] as f64).sum();
586 s += xv * Complex64::from_polar(1.0, sgn * 2.0 * PI * ph);
587 }
588 *ov = s / (nt as f64).sqrt();
589 }
590 out
591 }
592
593 #[test]
594 fn kernel_is_stis_centred_kernel_shifted() {
595 // even extents, anisotropic voxels, oblique H: the Nyquist rows (k = −n/2) carry the
596 // sign of the cross terms, so they must sit at −1/(2·vs) as on STI's grid
597 let (n, vs) = ([6, 8, 4], [0.8, 0.9, 1.5]);
598 let h = [0.3, -0.4, 0.5f64.sqrt() * 1.2];
599 let hn = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt();
600 let c = centred_kernel(n, vs, h.map(|v| v / hn));
601 let d = sti_kernel(n, vs, (h[0], h[1], h[2]));
602 let g = dipole_kernel(&Grid::new(n[0], n[1], n[2], vs[0], vs[1], vs[2]), (h[0], h[1], h[2]));
603 for k in 0..n[2] {
604 for j in 0..n[1] {
605 for i in 0..n[0] {
606 let l = i + j * n[0] + k * n[0] * n[1];
607 let b = c[shift(i, n[0]) + shift(j, n[1]) * n[0] + shift(k, n[2]) * n[0] * n[1]];
608 assert_eq!(d[l], b, "({i},{j},{k})");
609 assert!((g[l] - b).abs() < 1e-15, "the library kernel is the same up to rounding");
610 }
611 }
612 }
613 }
614
615 #[test]
616 fn padding_is_symmetric_and_even() {
617 assert_eq!(padded_dims([48, 56, 40], [12; 3]), [72, 80, 64]);
618 assert_eq!(padded_dims([47, 56, 39], [6; 3]), [60, 68, 52]);
619 assert_eq!(padded_dims([1, 2, 3], [0; 3]), [2, 2, 4]);
620 assert_eq!(padded_dims([64, 64, 48], STAR_GUI_PAD), [80, 80, 88]);
621 }
622
623 #[test]
624 fn inverse_kernel_and_soft_threshold() {
625 assert_eq!(dinv(0.5), 2.0);
626 assert_eq!(dinv(-0.065), -1.0 / 0.065);
627 assert_eq!(dinv(0.01), 1.0 / 0.065);
628 assert_eq!(dinv(-0.01), -1.0 / 0.065);
629 assert_eq!(dinv(0.0), 1.0 / 0.065);
630 let v = Complex64::new(3.0, 4.0);
631 assert_eq!(soft(v, 0.0), v);
632 assert_eq!(soft(v, 5.0), Complex64::new(0.0, 0.0));
633 assert_eq!(soft(v, 7.0), Complex64::new(0.0, 0.0));
634 let s = soft(v, 1.0); // |v| = 5 → 4, same phase
635 assert!((s - Complex64::new(2.4, 3.2)).norm() < 1e-15);
636 }
637
638 /// The initial guess as the spec writes it (centred transforms by direct sums, STI's
639 /// centred kernel) against [`initial_guess`] (plain FFTs on the unshifted grid).
640 #[test]
641 fn initial_guess_matches_stis_centred_formulation() {
642 let (n, vs) = ([4, 6, 4], [1.0, 0.9, 1.2]);
643 let h = [0.0, 0.6, 0.8];
644 let nt = n[0] * n[1] * n[2];
645 let idx = |i: usize, j: usize, k: usize| i + j * n[0] + k * n[0] * n[1];
646 let mut inside = vec![false; nt];
647 let mut y = vec![0.0; nt];
648 for k in 1..3 {
649 for j in 1..5 {
650 for i in 1..3 {
651 inside[idx(i, j, k)] = true;
652 y[idx(i, j, k)] = ((i * 7 + j * 3 + k * 5) % 11) as f64 * 0.1 - 0.4;
653 }
654 }
655 }
656 // spec: z = ifftnc(fftnc(y) .* Dinv) .* M, twice z = ifftnc(K .* fftnc(z)) .* M
657 let c = centred_kernel(n, vs, h);
658 let yc: Vec<Complex64> = y.iter().map(|&v| Complex64::new(v, 0.0)).collect();
659 let mut z: Vec<Complex64> = fftnc_naive(&yc, n, false).iter().zip(&c).map(|(v, &d)| v * dinv(d)).collect();
660 z = fftnc_naive(&z, n, true);
661 mask_inplace(&mut z, &inside);
662 for _ in 0..2 {
663 let f: Vec<Complex64> = fftnc_naive(&z, n, false).iter().zip(&c)
664 .map(|(v, &d)| if d.abs() >= KSPACE_THRESHOLD { *v } else { Complex64::new(0.0, 0.0) }).collect();
665 z = fftnc_naive(&f, n, true);
666 mask_inplace(&mut z, &inside);
667 }
668 let peak = z.iter().map(|v| v.norm()).fold(0.0, f64::max);
669 let want: Vec<Complex64> = z.iter().map(|v| v / peak).collect();
670
671 let dk = sti_kernel(n, vs, (h[0], h[1], h[2]));
672 let support: Box3 = [1..3, 1..5, 1..3];
673 let mut ws = Fft3dWorkspace::new(n[0], n[1], n[2]);
674 let got = initial_guess(&y, &inside, &dk, &support, &mut ws).unwrap();
675 let peak_got = got.iter().map(|v| v.norm()).fold(0.0, f64::max);
676 assert!((peak_got - 1.0).abs() < 1e-15);
677 assert!(got.iter().any(|v| v.im.abs() > 1e-6), "the initial guess stays complex");
678 for l in 0..nt {
679 assert!((got[l] - want[l]).norm() < 1e-12, "{l}: {} vs {}", got[l], want[l]);
680 if !inside[l] {
681 assert_eq!(got[l], Complex64::new(0.0, 0.0));
682 }
683 }
684 // a zero field has no initial guess
685 assert!(initial_guess(&vec![0.0; nt], &inside, &dk, &support, &mut ws).is_none());
686 }
687
688 /// SpaRSA.m transcribed line by line (unfused, one array per MATLAB variable) at STI's
689 /// options, for `A v = op(M v)` and `Aᵀ = A`.
690 fn sparsa_reference(
691 y: &[f64],
692 inside: &[bool],
693 op: &dyn Fn(&[Complex64]) -> Vec<Complex64>,
694 tau: f64,
695 max_iter: usize,
696 x0: &[Complex64],
697 ) -> (Vec<Complex64>, usize) {
698 let zero = Complex64::new(0.0, 0.0);
699 let a = |v: &[Complex64]| op(&v.iter().zip(inside).map(|(z, &m)| if m { *z } else { zero }).collect::<Vec<_>>());
700 let yc: Vec<Complex64> = y.iter().map(|&v| Complex64::new(v, 0.0)).collect();
701 let max_tau = a(&yc).iter().map(|v| v.norm()).fold(0.0, f64::max);
702 if tau >= max_tau {
703 return (vec![zero; y.len()], 0);
704 }
705 let mut x = x0.to_vec();
706 let mut nz_x: Vec<bool> = x.iter().map(|v| *v != zero).collect();
707 let mut resid: Vec<Complex64> = a(&x).iter().zip(&yc).map(|(p, q)| p - q).collect();
708 let (mut alpha, mut iter, mut keep_going) = (1.0f64, 1usize, true);
709 while keep_going {
710 let gradq = a(&resid);
711 let prev_x = x.clone();
712 let prev_resid = resid.clone();
713 x = prev_x.iter().zip(&gradq).map(|(p, g)| soft(p - g * (1.0 / alpha), tau / alpha)).collect();
714 let dx: Vec<Complex64> = x.iter().zip(&prev_x).map(|(p, q)| p - q).collect();
715 let adx = a(&dx);
716 resid = prev_resid.iter().zip(&adx).map(|(p, q)| p + q).collect();
717 let dd: f64 = dx.iter().map(|v| nsq(*v)).sum();
718 let dgd: f64 = adx.iter().map(|v| nsq(*v)).sum();
719 alpha = ALPHA_MAX.min(ALPHA_MIN.max(dgd / (f64::MIN_POSITIVE + dd)));
720 iter += 1;
721 let nz_x_prev = std::mem::replace(&mut nz_x, x.iter().map(|v| v.norm() != 0.0).collect());
722 let num_nz_x = nz_x.iter().filter(|&&b| b).count();
723 let num_changes_active = nz_x.iter().zip(&nz_x_prev).filter(|(p, q)| p != q).count();
724 if num_nz_x >= 1 {
725 keep_going = num_changes_active as f64 / num_nz_x as f64 > TOL_A;
726 }
727 if iter <= MIN_ITER {
728 keep_going = true;
729 } else if iter > max_iter {
730 keep_going = false;
731 }
732 }
733 (x, iter - 1)
734 }
735
736 /// Circular convolution along the vector (a non-diagonal, non-symmetric `op`).
737 fn conv(v: &[Complex64]) -> Vec<Complex64> {
738 let h = [1.0, 0.45, -0.3, 0.1];
739 let n = v.len();
740 (0..n).map(|i| h.iter().enumerate().map(|(k, &c)| v[(i + n - k) % n] * c).sum()).collect()
741 }
742
743 fn small_problem(n: usize) -> (Vec<f64>, Vec<bool>, Vec<Complex64>) {
744 let y: Vec<f64> = (0..n).map(|i| if i % 5 == 3 { 0.0 } else { ((i * 37 + 11) % 17) as f64 / 17.0 - 0.45 }).collect();
745 let inside: Vec<bool> = (0..n).map(|i| i % 7 != 6).collect();
746 let x0: Vec<Complex64> = (0..n).map(|i| if inside[i] { Complex64::new(((i * 13) % 7) as f64 * 0.1 - 0.3, ((i * 5) % 3) as f64 * 0.05) } else { Complex64::new(0.0, 0.0) }).collect();
747 (y, inside, x0)
748 }
749
750 /// The fused solver takes the same steps as the transcription, including its stopping
751 /// rule (active-set change ≤ 1 % after at least 5 iterations) and the cap.
752 #[test]
753 fn sparsa_matches_a_transcription_of_sparsa_m() {
754 let mut lens = Vec::new();
755 for (n, tau, cap) in [(40, 1e-3, 1000), (40, 3e-2, 1000), (64, 1e-6, 1000), (64, 2e-2, 1000), (64, 2e-2, 6)] {
756 let (y, inside, x0) = small_problem(n);
757 let (want, want_it) = sparsa_reference(&y, &inside, &conv, tau, cap, &x0);
758 let mut x = x0.clone();
759 let mut seen = Vec::new();
760 let it = sparsa(&mut x, &y, &inside, tau, cap, &mut |v: &mut [Complex64]| { let c = conv(v); v.copy_from_slice(&c); }, |i, m| seen.push((i, m)));
761 assert_eq!(it, want_it, "n {n} tau {tau}");
762 assert_eq!(seen, (1..=it).map(|i| (i, cap)).collect::<Vec<_>>());
763 for l in 0..n {
764 assert!((x[l] - want[l]).norm() <= 1e-14 * (1.0 + want[l].norm()), "n {n} tau {tau} [{l}]: {} vs {}", x[l], want[l]);
765 }
766 lens.push(it);
767 }
768 assert!(lens.iter().all(|&i| i >= MIN_ITER));
769 assert_eq!(lens[4], 6, "the cap ends the run");
770 assert!(lens[3] > 6, "the capped problem runs longer uncapped: {lens:?}");
771 }
772
773 /// SpaRSA applies `MiniterA` before `MaxiterA`, so a cap below 5 still runs 5 iterations.
774 #[test]
775 fn sparsa_minimum_overrides_a_lower_cap() {
776 let y = vec![0.5, 0.0, 1.0];
777 let inside = vec![true; 3];
778 let mut x = vec![Complex64::new(0.3, 0.0); 3];
779 assert_eq!(sparsa(&mut x, &y, &inside, 1e-3, 3, &mut |_| {}, |_, _| {}), MIN_ITER);
780 }
781
782 #[test]
783 fn sparsa_returns_zero_when_tau_dominates() {
784 let y = vec![1e-7, 0.0];
785 let inside = vec![true, true];
786 let mut x = vec![Complex64::new(1.0, 0.0); 2];
787 assert_eq!(sparsa(&mut x, &y, &inside, 1e-6, 1000, &mut |_| {}, |_, _| {}), 0);
788 assert!(x.iter().all(|v| *v == Complex64::new(0.0, 0.0)));
789 }
790
791 #[test]
792 fn empty_mask_zero_field_and_validation() {
793 let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
794 let p = StarParams::default();
795 assert_eq!(star_qsm(&[1.0; 64], &[0; 64], &grid, (0.0, 0.0, 1.0), &p, |_, _| {}), vec![0.0; 64]);
796 assert_eq!(star_qsm(&[0.0; 64], &[1; 64], &grid, (0.0, 0.0, 1.0), &p, |_, _| {}), vec![0.0; 64]);
797 assert!(p.validate().is_ok());
798 for bad in [
799 StarParams { te: EchoTime::Seconds(0.0), ..StarParams::default() },
800 StarParams { echo_times: vec![], ..StarParams::default() },
801 StarParams { b0: f64::NAN, ..StarParams::default() },
802 ] {
803 assert!(bad.validate().is_err());
804 }
805 let p = StarParams { echo_times: vec![0.02], te: EchoTime::First, b0: 3.0, pad: [0; 3] };
806 assert!((p.rad_per_ppm() - 2.0 * PI * 42.575 * 0.06).abs() < 1e-12);
807 // STI Suite's default: the phase summed over the echoes, at ΣTE.
808 let p = StarParams { echo_times: vec![0.004, 0.008, 0.012], ..StarParams::default() };
809 assert!((p.rad_per_ppm() - 2.0 * PI * 42.575 * 3.0 * 0.024).abs() < 1e-12);
810 }
811
812 /// A sphere's field on an odd grid: the padded grid grows to even, and χ comes out with
813 /// the sphere's sign and magnitude, zero outside the mask.
814 #[test]
815 fn recovers_a_sphere_on_an_odd_grid() {
816 let (nx, ny, nz) = (15, 16, 13);
817 let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
818 let nt = nx * ny * nz;
819 let mut chi0 = vec![0.0; nt];
820 let mut mask = vec![0u8; nt];
821 let c = [7.0, 7.5, 6.0];
822 for k in 0..nz {
823 for j in 0..ny {
824 for i in 0..nx {
825 let r2 = (i as f64 - c[0]).powi(2) + (j as f64 - c[1]).powi(2) + (k as f64 - c[2]).powi(2);
826 let l = i + j * nx + k * nx * ny;
827 if r2 < 2.5f64.powi(2) { chi0[l] = 0.1; }
828 if r2 < 5.5f64.powi(2) { mask[l] = 1; }
829 }
830 }
831 }
832 // field of chi0 (circular convolution on an even grid big enough not to wrap)
833 let (mx, my, mz) = (32, 32, 32);
834 let big = Grid::new(mx, my, mz, 1.0, 1.0, 1.0);
835 let dk = dipole_kernel(&big, (0.0, 0.0, 1.0));
836 let mut buf = vec![Complex64::new(0.0, 0.0); mx * my * mz];
837 for k in 0..nz { for j in 0..ny { for i in 0..nx { buf[i + j * mx + k * mx * my].re = chi0[i + j * nx + k * nx * ny]; } } }
838 let mut ws = Fft3dWorkspace::new(mx, my, mz);
839 ws.fft3d(&mut buf);
840 buf.iter_mut().zip(&dk).for_each(|(v, &d)| *v *= d);
841 ws.ifft3d(&mut buf);
842 let field: Vec<f64> = (0..nt).map(|l| {
843 let (i, j, k) = (l % nx, (l / nx) % ny, l / (nx * ny));
844 if mask[l] != 0 { buf[i + j * mx + k * mx * my].re } else { 0.0 }
845 }).collect();
846
847 let mut iters = 0;
848 let chi = star_qsm(&field, &mask, &grid, (0.0, 0.0, 1.0), &StarParams { pad: [6; 3], ..StarParams::default() }, |i, _| iters = i);
849 assert!(iters >= MIN_ITER);
850 let centre = chi[7 + 7 * nx + 6 * nx * ny];
851 assert!(centre > 0.05 && centre < 0.2, "centre χ {centre}");
852 for l in 0..nt {
853 if mask[l] == 0 { assert_eq!(chi[l], 0.0); }
854 }
855 }
856}