qsm_core/inversion/heidi.rs
1//! HEIDI — Homogeneity Enabled Incremental Dipole Inversion
2//!
3//! HEIDI splits k-space at the dipole cone and treats the two halves differently:
4//!
5//! * **Well-conditioned coefficients** (`|D(k)| > threshold`) are taken straight
6//! from an already-computed χ map — normally the minimally regularised LSQR
7//! solution of [`super::lsqr_qsm`] — and held as *exact* constraints.
8//! * **Ill-conditioned coefficients** inside the cone, where direct division
9//! amplifies noise without bound, are recovered by minimising a weighted total
10//! variation subject to those constraints.
11//!
12//! ```text
13//! minimise Σ_v ‖ (M_x ∂_x χ, M_y ∂_y χ, M_z ∂_z χ) ‖ subject to (F χ)|cone = (F χ_init)|cone
14//! ```
15//!
16//! The "homogeneity" of the name is the weighting `M`. Each direction gets its own
17//! 0/1 mask, built by thresholding the gradient of the *field* map: where the field
18//! is locally flat the susceptibility is expected to be flat too, and TV is applied;
19//! across an anatomical edge the mask opens and the gradient goes unpenalised. A
20//! Laplacian test additionally opens the mask wherever the field curves sharply.
21//! Voxels that are an edge in every direction get the weight
22//! [`HeidiParams::gradient_mask_floor`], 0 in the shipped code (see that field).
23//!
24//! The constrained minimisation is solved with NESTA (Nesterov's smoothing with
25//! continuation): the TV term is smoothed by a parameter μ that is annealed down
26//! over several outer steps, each of which runs a fixed number of accelerated
27//! gradient iterations. Because the data constraint is an orthogonal projection
28//! (`A·Aᵀ = I` for a restricted FFT), the projection step is exact and costs one
29//! FFT round trip.
30//!
31//! Ported from the authors' code, qsm_heidi's `inversion_heidi.m` and
32//! `computesusceptibility_heidi.m` (<https://gitlab.com/R01NS114227/qsm_heidi>), run after
33//! its `inversion_lsqr.m` ([`super::lsqr_qsm`]):
34//!
35//! * the seed is the LSQR solution on LSQR's grid (the mask's bounding box with a 40-voxel
36//! anti-aliasing frame, odd extents), unmasked, and HEIDI runs on that same grid;
37//! * the cone is `|D| > 0.1` (the driver's `PostProcCone.threshold`) with `D` the
38//! spatial-domain unit dipole response ([`super::lsqr_qsm::spatial_dipole_kernel`]);
39//! * NESTA runs with the driver's tolerance, machine epsilon, so every continuation step uses
40//! all of its inner iterations (8 × 500);
41//! * the homogeneity masks are built from the phase denoised by ITK 3.20's gradient
42//! anisotropic diffusion (the executable `diffusionimagefilter.m` runs, 5 iterations, time
43//! step 0.05, conductance 1), in the phase's units. That executable works in `float` and sums
44//! the mean squared gradient that sets its conductance scale in `float`, which underestimates
45//! it (by about 1 % on a whole brain); QSM.rs computes it in double by default, and
46//! [`AnisotropicDiffusionParams::reference_itk`] reproduces the executable bit for bit. The
47//! two give the same masks but for the voxels at the gradient thresholds (about 0.07 % on an
48//! in-vivo brain, QSM-CI parity case `s005001-cbic-prisma-bridge-run1`).
49//!
50//! Units: the field is in ppm and χ comes back in ppm. The gradient and Laplacian
51//! thresholds below are therefore in ppm/mm and ppm/voxel², independent of echo
52//! time and field strength — upstream divides the phase by TE[ms]·B₀[T] before
53//! thresholding, which is the same quantity. The cone fill is not scale-free: NESTA's
54//! smoothing ends at an absolute μ (machine epsilon) and upstream's χ is in units of the
55//! phase (`ppm · γ·TE·B₀·1e-6`, `γ` in rad/s/T), so the seed is converted to those units
56//! before NESTA and back after, with [`HeidiParams::b0`] and [`HeidiParams::echo_times`] (scan
57//! parameters the pipeline fills from metadata) at the echo time [`HeidiParams::te`] picks.
58//!
59//! Reference:
60//! Schweser, F., Sommer, K., Deistung, A., Reichenbach, J.R. (2012).
61//! "Quantitative susceptibility mapping for investigating subtle susceptibility
62//! variations in the human brain." NeuroImage, 62(3):2083-2100.
63//! https://doi.org/10.1016/j.neuroimage.2012.05.067
64//!
65//! NESTA:
66//! Becker, S., Bobin, J., Candès, E.J. (2011). "NESTA: A fast and accurate
67//! first-order method for sparse recovery." SIAM Journal on Imaging Sciences,
68//! 4(1):1-39. https://doi.org/10.1137/090756855
69//!
70//! # Any seed: HEIDI as streaking reduction
71//!
72//! The cone fill does not depend on where the seed came from. The authors'
73//! `computesusceptibility_heidi(suscInterim, …)` (qsm_heidi 4592631) takes the initial χ map
74//! from outside and only refills its cone (the changelog calls the step "PostProcCone"), and
75//! SEPIA's HEIDI add-on (Salman, Schweser and Chan, 2025) offers it after any dipole inversion
76//! as "Streaking reduction by HEIDI": `Wrapper_QSM_HEIDI4all(localField, chiRaw, …)`, called from
77//! `wrapper/QSMMacro.m` on the method's result. [`heidi_cone_fill`] is that step:
78//!
79//! * **Grid:** as `Wrapper_QSM_HEIDI4all`, the field, mask and seed are cropped to the mask's
80//! bounding box with the 40-voxel anti-aliasing frame (the grid [`heidi`] runs on), and χ is
81//! masked at the end (`chi .* mask`). The seed is the method's (masked) result as it is.
82//! * **Units:** the qsm_heidi pipeline's, phase at the chosen echo time, for every seed, so
83//! [`heidi`] and [`heidi_cone_fill`] fill the cone the same way. `Wrapper_QSM_HEIDI4all` instead
84//! passes the field and the seed in Hz/2π, a factor `(2π)²·TE` (≈ 0.8 at TE = 20 ms) from
85//! those; the factor moves the gradient thresholds and NESTA's absolute final μ only.
86//! * **Seed suitability.** The paper takes the well-conditioned region from a minimally
87//! regularised inversion and only the cone from the TV prior. A seed whose region outside the
88//! cone is close to data-consistent fits that: LSQR (the reference, which [`heidi`] runs), and
89//! TKD, TSVD, small-λ Tikhonov, NDI and RTS (RTS already splits k-space the same way). A
90//! regularised seed (MEDI, TV and the FANSI family, NLTV, iLSQR, STAR, AMP-PE, the
91//! deep-learning methods) keeps its regularisation outside the cone, as only the cone is
92//! replaced. The pipeline publishes this as the `heidi.seed-*` rules
93//! ([`crate::pipeline::rules::RULES`]).
94//! * **Truncated seeds.** TKD and TSVD zero or clip `|D| < threshold`; with that threshold above
95//! the cone threshold, truncated coefficients would be kept as if they were data. Upstream
96//! stops with an error there ("DipoleFilter parameter must not be larger that Cone threshold",
97//! `computesusceptibility_heidi.m` L922-923), and so does the pipeline
98//! ([`crate::pipeline::heidi_seed_problem`]).
99//! * **Not offered:** methods without a local field (the total-field and phase methods: the
100//! homogeneity weights come from the local field), χ-separation and multi-orientation
101//! reconstructions (upstream refuses more than one field: "You cannot use HEIDI with COSMOS").
102//!
103//! # Reference pipeline
104//! Same inputs as [`super::lsqr_qsm`](mod@super::lsqr_qsm) (qsm_heidi commit 4592631): a SHARP-corrected local field
105//! (`makefile` L14-20, `background_correction_sharp.mat`), the magnitude, the LSQR result, and
106//! the mask `mask_tissue .* mask_phase .* phsInput.mask` (`inversion_heidi.m`). The echo time for
107//! the phase units is the last one (`phase2susceptibilityfactor.m` L70-71), as the pipeline sets.
108//!
109//! # Pipeline rules
110//!
111//! The facts above that a configuration can depart from are published as rules in
112//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `lsqr.bg-sharp`, `heidi.te-last`.
113
114use num_complex::Complex64;
115
116use crate::fft::Fft3dWorkspace;
117use crate::kernels::laplacian::laplacian_kernel;
118use crate::utils::anisotropic_diffusion::{
119 gradient_anisotropic_diffusion, AnisotropicDiffusionParams,
120};
121use crate::utils::apply_mask_zero;
122use crate::{EchoTime, Grid};
123
124use super::lsqr_qsm::{crop_grid, crop_pad, lsqr_qsm_padded, spatial_dipole_kernel, uncrop, LsqrQsmParams};
125#[cfg(feature = "parallel")]
126use rayon::prelude::*;
127
128/// Gyromagnetic ratio of the proton, in rad/s/T: the reference implementation's value
129/// (`getconstants.m`), not `crate::constants`. Keep it.
130const GAMMA: f64 = 2.67522128e8;
131
132/// Upstream expresses its mask thresholds per unit of `phase / (TE[ms] · B₀[T])`.
133/// One ppm of field is `γ · 1e-3 · 1e-6` of that, so dividing an upstream threshold
134/// by this constant converts it to ppm.
135const UPSTREAM_PPM_SCALE: f64 = GAMMA * 1e-9;
136
137/// Upstream `DEFAULT_GRADIENTMASK_THRESHOLD`, converted to ppm/mm.
138pub const DEFAULT_GRADIENT_THRESHOLD_PPM: f64 = 0.00105 / UPSTREAM_PPM_SCALE;
139
140/// Upstream `DEFAULT_GRADIENTMASK_LAPLACIANTRHESHOLD`, converted to ppm/voxel².
141pub const DEFAULT_LAPLACIAN_THRESHOLD_PPM: f64 = 0.0159 / UPSTREAM_PPM_SCALE;
142
143/// Parameters for [`heidi`].
144#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
145#[derive(Clone, Debug)]
146pub struct HeidiParams {
147 /// `|D(k)|` above which a coefficient counts as well-conditioned and is taken
148 /// from `chi_init` unchanged.
149 pub cone_threshold: f64,
150 /// Field-gradient threshold in ppm/mm. Below it, a direction is considered
151 /// homogeneous and its TV weight is switched on.
152 pub gradient_threshold: f64,
153 /// Open the homogeneity masks where the field's Laplacian is large.
154 pub apply_laplacian_correction: bool,
155 /// Field-Laplacian threshold in ppm per voxel² (the stencil is in voxel units,
156 /// matching upstream).
157 pub laplacian_threshold: f64,
158 /// Anisotropic-diffusion smoothing applied to the field (in upstream's phase units) before
159 /// the gradients are taken. `None` skips it. Its `reference_itk` reproduces the ITK
160 /// executable upstream runs (see the module docs).
161 pub denoise: Option<AnisotropicDiffusionParams>,
162 /// TV weight kept at voxels that read as an edge in *every* direction.
163 ///
164 /// The default, 0, is what the shipped code does: `computesusceptibility_heidi` sets
165 /// these voxels to `gradientMaskReplaceValue` (0.1) and then passes the masks through
166 /// `fourierbasedresize(...) > 0.5`, which rounds the 0.1 away. (Its changelog, v10.6,
167 /// says the masks "must contain values of 0.1"; set 0.1 to follow that instead.)
168 pub gradient_mask_floor: f64,
169 /// NESTA continuation steps on μ.
170 ///
171 /// This is the dominant quality knob, and more is not better. μ always lands
172 /// on `mu_min`, so the count controls how many levels of TV minimisation are
173 /// actually performed — past the first couple of levels μ is already far
174 /// below the field's gradient scale and the term is effectively pure TV, so
175 /// extra levels just keep flattening. On the CI phantom, at otherwise-default
176 /// settings: 4 steps → r=0.8984, 6 → r=0.9017, 8 (upstream's
177 /// `DEFAULT_TV_MAXINTITER`, kept here) → r=0.8757. Worth sweeping on your own
178 /// data before trusting the default.
179 pub continuation_steps: usize,
180 /// Accelerated-gradient iterations per continuation step.
181 pub inner_iterations: usize,
182 /// Final (smallest) smoothing parameter μ.
183 pub mu_min: f64,
184 /// Relative objective change that ends an inner loop early.
185 ///
186 /// The driver `inversion_heidi.m` sets `PostProcCone.tol = eps`, which never
187 /// triggers: every continuation step runs all `inner_iterations`.
188 /// (`computesusceptibility_heidi`'s own default, used when the driver sets none, is
189 /// `DEFAULT_TV_TOLVAR` = 1e-3.)
190 pub tol: f64,
191 /// Field strength in tesla (scan parameter, for the conversion to upstream's units)
192 pub b0: f64,
193 /// Echo times in seconds, in acquisition order (scan parameter, for the conversion to
194 /// upstream's units).
195 pub echo_times: Vec<f64>,
196 /// Which echo time the field stands for the phase of, in the conversion to upstream's units. Default [`EchoTime::Last`]: qsm_heidi's
197 /// `phase2susceptibilityfactor.m` uses the last echo time unless an echo is named
198 /// (`heidi/phase2susceptibilityfactor.m` lines 70-71, qsm_heidi 4592631), and
199 /// `inversion_heidi.m` names none (line 58).
200 pub te: EchoTime,
201}
202
203impl Default for HeidiParams {
204 /// `inversion_heidi.m`'s settings.
205 fn default() -> Self {
206 Self {
207 // Upstream's `computesusceptibility_heidi` defaults to 0.14; the
208 // shipped driver `inversion_heidi.m` overrides it to 0.1.
209 cone_threshold: 0.1,
210 gradient_threshold: DEFAULT_GRADIENT_THRESHOLD_PPM,
211 apply_laplacian_correction: true,
212 laplacian_threshold: DEFAULT_LAPLACIAN_THRESHOLD_PPM,
213 denoise: Some(AnisotropicDiffusionParams {
214 iterations: 5,
215 time_step: 0.05,
216 conductance: 1.0,
217 reference_itk: false,
218 }),
219 gradient_mask_floor: 0.0,
220 continuation_steps: 8,
221 inner_iterations: 500,
222 mu_min: f64::EPSILON,
223 tol: f64::EPSILON,
224 b0: 3.0,
225 echo_times: vec![0.02],
226 te: EchoTime::Last,
227 }
228 }
229}
230
231/// The three per-direction homogeneity weights, as used by the TV term.
232pub struct HomogeneityMasks {
233 pub x: Vec<f64>,
234 pub y: Vec<f64>,
235 pub z: Vec<f64>,
236}
237
238/// HEIDI dipole inversion: LSQR ([`super::lsqr_qsm`]) followed by HEIDI's cone fill, as
239/// qsm_heidi's `inversion_lsqr.m` then `inversion_heidi.m`.
240///
241/// # Arguments
242/// * `local_field` - Local field in ppm (`nx * ny * nz`, Fortran order)
243/// * `mask` - Binary brain mask
244/// * `magnitude` - Optional magnitude image, the LSQR seed's data weight
245/// * `grid` - Volume grid
246/// * `bdir` - B0 direction
247/// * `lsqr` - Parameters of the LSQR seed (its `mask_output` is ignored: the seed stays
248/// unmasked, as upstream's `susceptibilityRaw`)
249/// * `params` - HEIDI parameters
250/// * `progress` - Progress callback `(iteration, total_iterations)`, over both stages
251///
252/// # Returns
253/// Susceptibility map in ppm, masked.
254#[allow(clippy::too_many_arguments)] // the dipole-inversion inputs plus the seed's parameters
255pub fn heidi(
256 local_field: &[f64],
257 mask: &[u8],
258 magnitude: Option<&[f64]>,
259 grid: &Grid,
260 bdir: (f64, f64, f64),
261 lsqr: &LsqrQsmParams,
262 params: &HeidiParams,
263 mut progress: impl FnMut(usize, usize),
264) -> Vec<f64> {
265 let lsqr_iters = lsqr.max_iter;
266 let total = lsqr_iters + cone_fill_iterations(params);
267 let seed = lsqr_qsm_padded(local_field, mask, magnitude, grid, bdir, lsqr, |i, _| progress(i, total));
268 let Some((pgrid, crop, chi_seed)) = seed else {
269 return vec![0.0; grid.n_total()];
270 };
271 let field = crop_pad(local_field, grid.dims, &crop);
272 let pmask = crop_pad(mask, grid.dims, &crop);
273 let chi = heidi_on_grid(&field, &pmask, &chi_seed, &pgrid, bdir, params, |i, _| progress(lsqr_iters + i, total));
274 let mut chi = uncrop(&chi, grid.dims, &crop);
275 apply_mask_zero(&mut chi, mask);
276 chi
277}
278
279/// HEIDI's cone fill on any seed χ map: streaking reduction after a dipole inversion, as the
280/// authors' `computesusceptibility_heidi(suscInterim, …)` and SEPIA's `Wrapper_QSM_HEIDI4all`.
281///
282/// The well-conditioned coefficients (`|D(k)| > cone_threshold`) of `chi_seed` are kept as exact
283/// constraints and the dipole cone is refilled under HEIDI's homogeneity-weighted TV, exactly as
284/// [`heidi`] does to its LSQR seed. See the module docs, *Any seed*, for which seeds suit it.
285///
286/// The grid is upstream's: the field, the mask and the seed are cropped to the mask's bounding
287/// box and given LSQR's 40-voxel anti-aliasing frame (odd extents), as `Wrapper_QSM_HEIDI4all`
288/// does with `zealouscrop(mask)` and `prepareforconvolution(…, 40)` on every input, the seed
289/// included. A seed is whatever its method returned, normally masked; the frame around it is 0.
290///
291/// # Arguments
292/// * `local_field` - Local field in ppm (`nx * ny * nz`, Fortran order), the one the seed was
293/// inverted from: HEIDI's homogeneity weights come from it
294/// * `mask` - Binary brain mask
295/// * `chi_seed` - The seed χ map in ppm
296/// * `grid` - Volume grid
297/// * `bdir` - B0 direction
298/// * `params` - HEIDI parameters ([`HeidiParams::b0`] and [`HeidiParams::echo_times`] are the
299/// scan's)
300/// * `progress` - Progress callback `(iteration, total_iterations)`
301///
302/// # Returns
303/// Susceptibility map in ppm, masked (as `Wrapper_QSM_HEIDI4all`'s `chi .* mask`).
304pub fn heidi_cone_fill(
305 local_field: &[f64],
306 mask: &[u8],
307 chi_seed: &[f64],
308 grid: &Grid,
309 bdir: (f64, f64, f64),
310 params: &HeidiParams,
311 progress: impl FnMut(usize, usize),
312) -> Vec<f64> {
313 let n_total = grid.n_total();
314 assert_eq!(local_field.len(), n_total, "field length must match grid");
315 assert_eq!(mask.len(), n_total, "mask length must match grid");
316 assert_eq!(chi_seed.len(), n_total, "seed length must match grid");
317 let Some(crop) = crop_grid(mask, grid.dims) else {
318 return vec![0.0; n_total];
319 };
320 let (vsx, vsy, vsz) = grid.voxel_size;
321 let pgrid = Grid::new(crop[0].2, crop[1].2, crop[2].2, vsx, vsy, vsz);
322 let field = crop_pad(local_field, grid.dims, &crop);
323 let pmask = crop_pad(mask, grid.dims, &crop);
324 let seed = crop_pad(chi_seed, grid.dims, &crop);
325 let chi = heidi_on_grid(&field, &pmask, &seed, &pgrid, bdir, params, progress);
326 let mut chi = uncrop(&chi, grid.dims, &crop);
327 apply_mask_zero(&mut chi, mask);
328 chi
329}
330
331/// Progress steps of the cone fill: [`heidi_cone_fill`], and [`heidi`]'s second stage.
332pub fn cone_fill_iterations(params: &HeidiParams) -> usize {
333 params.continuation_steps.max(1) * params.inner_iterations
334}
335
336/// HEIDI's cone fill on a grid with odd extents (upstream's), from the seed `chi_init`
337/// (unmasked). Returns χ unmasked.
338fn heidi_on_grid(
339 local_field: &[f64],
340 mask: &[u8],
341 chi_init: &[f64],
342 grid: &Grid,
343 bdir: (f64, f64, f64),
344 params: &HeidiParams,
345 progress: impl FnMut(usize, usize),
346) -> Vec<f64> {
347 let n_total = grid.n_total();
348 assert_eq!(local_field.len(), n_total, "field length must match grid");
349 assert_eq!(mask.len(), n_total, "mask length must match grid");
350 assert_eq!(chi_init.len(), n_total, "chi_init length must match grid");
351
352 let masks = homogeneity_masks(local_field, mask, grid, params);
353 let cone = cone_mask(grid, bdir, params.cone_threshold);
354 // ppm -> upstream's phase units for χ (see the module docs, *Units*), and back.
355 let to_phase = GAMMA * params.te.seconds(¶ms.echo_times, "HEIDI") * params.b0 * 1e-6;
356 let seed: Vec<f64> = chi_init.iter().map(|v| v * to_phase).collect();
357 let mut chi = nesta_cone_fill(&seed, &cone, &masks, grid, params, progress);
358 for v in &mut chi {
359 *v /= to_phase;
360 }
361 chi
362}
363
364/// `|D(k)| > threshold`: the k-space region the dipole model conditions well, with `D` the
365/// spatial-domain unit dipole response, as upstream (`generateunitdipoleresponse`).
366fn cone_mask(grid: &Grid, bdir: (f64, f64, f64), threshold: f64) -> Vec<bool> {
367 spatial_dipole_kernel(grid, bdir)
368 .into_iter()
369 .map(|d| d.abs() > threshold)
370 .collect()
371}
372
373/// Build the three per-direction homogeneity weights from the field map.
374///
375/// Public so callers can inspect or reuse the weighting; [`heidi`] calls it itself.
376pub fn homogeneity_masks(
377 local_field: &[f64],
378 mask: &[u8],
379 grid: &Grid,
380 params: &HeidiParams,
381) -> HomogeneityMasks {
382 let (nx, ny, nz) = grid.dims;
383 let n_total = grid.n_total();
384
385 // Upstream masks the field before denoising, so the diffusion sees a clean
386 // zero outside the brain rather than background junk.
387 let mut field = local_field.to_vec();
388 apply_mask_zero(&mut field, mask);
389
390 // Upstream denoises the phase (`diffusionimagefilter` on `phase .* mask`), so the
391 // diffusion runs in its units; it is scale-free but for rounding, which ITK's float
392 // arithmetic (`reference_itk`) makes visible.
393 let smoothed = match ¶ms.denoise {
394 Some(d) => {
395 let to_phase = -GAMMA * params.te.seconds(¶ms.echo_times, "HEIDI") * params.b0 * 1e-6;
396 let phase: Vec<f64> = field.iter().map(|v| v * to_phase).collect();
397 gradient_anisotropic_diffusion(&phase, grid, d).into_iter().map(|v| v / to_phase).collect()
398 }
399 None => field,
400 };
401
402 // A direction is "homogeneous" where the field gradient is small.
403 let mut mx = threshold_gradient(&smoothed, grid, 0, params.gradient_threshold);
404 let mut my = threshold_gradient(&smoothed, grid, 1, params.gradient_threshold);
405 let mut mz = threshold_gradient(&smoothed, grid, 2, params.gradient_threshold);
406
407 if params.apply_laplacian_correction {
408 // Sharp field curvature means an unresolved source: drop the TV weight
409 // there in every direction. The stencil is in voxel units, as upstream's.
410 let unit_grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
411 let lap_kernel = laplacian_kernel(&unit_grid, false);
412
413 let mut ws = Fft3dWorkspace::new(nx, ny, nz);
414 let mut kernel_ft: Vec<Complex64> =
415 lap_kernel.iter().map(|&v| Complex64::new(v, 0.0)).collect();
416 ws.fft3d(&mut kernel_ft);
417
418 let mut buf: Vec<Complex64> =
419 smoothed.iter().map(|&v| Complex64::new(v, 0.0)).collect();
420 ws.fft3d(&mut buf);
421 for (b, k) in buf.iter_mut().zip(&kernel_ft) {
422 *b *= *k;
423 }
424 ws.ifft3d(&mut buf);
425
426 for i in 0..n_total {
427 if buf[i].re.abs() > params.laplacian_threshold {
428 mx[i] = 0.0;
429 my[i] = 0.0;
430 mz[i] = 0.0;
431 }
432 }
433 }
434
435 // Restrict to the brain, then give all-edge voxels a residual weight so they
436 // are not left completely unconstrained.
437 for i in 0..n_total {
438 if mask[i] == 0 {
439 mx[i] = 0.0;
440 my[i] = 0.0;
441 mz[i] = 0.0;
442 } else if mx[i] == 0.0 && my[i] == 0.0 && mz[i] == 0.0 {
443 mx[i] = params.gradient_mask_floor;
444 my[i] = params.gradient_mask_floor;
445 mz[i] = params.gradient_mask_floor;
446 }
447 }
448
449 HomogeneityMasks { x: mx, y: my, z: mz }
450}
451
452/// 1.0 where `|∂_axis field| < threshold`, else 0.0.
453fn threshold_gradient(field: &[f64], grid: &Grid, axis: usize, threshold: f64) -> Vec<f64> {
454 let g = discrete_gradient(field, grid, axis, false);
455 g.into_iter()
456 .map(|v| if v.abs() < threshold { 1.0 } else { 0.0 })
457 .collect()
458}
459
460/// Backward difference along `axis`, scaled by voxel size, zero on the leading face.
461///
462/// `transpose` gives the operator upstream pairs with it: a forward difference,
463/// zero on the *trailing* face. The two agree with a true adjoint pair except on
464/// the two boundary faces, which carry no TV weight (the brain mask is zero there),
465/// so the discrepancy never reaches the solution.
466fn discrete_gradient_into(x: &[f64], out: &mut [f64], grid: &Grid, axis: usize, transpose: bool) {
467 let (nx, ny, nz) = grid.dims;
468 let (vsx, vsy, vsz) = grid.voxel_size;
469 let (n_axis, voxel_size) = match axis {
470 0 => (nx, vsx),
471 1 => (ny, vsy),
472 _ => (nz, vsz),
473 };
474
475 out.fill(0.0);
476 if n_axis < 2 {
477 return;
478 }
479 let inv = 1.0 / voxel_size;
480 let slab = nx * ny;
481
482 // Split the volume into independent pieces: one row per chunk along x, one
483 // z-slab along y and z. Each chunk writes only its own output but may read
484 // its neighbour's input, which is fine — `x` is shared immutably.
485 let chunk = if axis == 0 { nx } else { slab };
486
487 crate::maybe_par_chunks_mut!(out, chunk)
488 .enumerate()
489 .for_each(|(c, o)| {
490 let base = c * chunk;
491 match axis {
492 0 => {
493 if transpose {
494 for p in 0..nx - 1 {
495 o[p] = (x[base + p] - x[base + p + 1]) * inv;
496 }
497 } else {
498 for p in 1..nx {
499 o[p] = (x[base + p] - x[base + p - 1]) * inv;
500 }
501 }
502 }
503 1 => {
504 if transpose {
505 for j in 0..ny - 1 {
506 for i in 0..nx {
507 let q = i + j * nx;
508 o[q] = (x[base + q] - x[base + q + nx]) * inv;
509 }
510 }
511 } else {
512 for j in 1..ny {
513 for i in 0..nx {
514 let q = i + j * nx;
515 o[q] = (x[base + q] - x[base + q - nx]) * inv;
516 }
517 }
518 }
519 }
520 _ => {
521 // `c` is the slice index along z.
522 if transpose {
523 if c + 1 < nz {
524 for p in 0..slab {
525 o[p] = (x[base + p] - x[base + p + slab]) * inv;
526 }
527 }
528 } else if c >= 1 {
529 for p in 0..slab {
530 o[p] = (x[base + p] - x[base + p - slab]) * inv;
531 }
532 }
533 }
534 }
535 });
536}
537
538/// Allocating wrapper around [`discrete_gradient_into`].
539fn discrete_gradient(x: &[f64], grid: &Grid, axis: usize, transpose: bool) -> Vec<f64> {
540 let mut out = vec![0.0; x.len()];
541 discrete_gradient_into(x, &mut out, grid, axis, transpose);
542 out
543}
544
545/// Scratch buffers for the NESTA loop, so the inner iterations allocate nothing.
546struct TvWorkspace {
547 /// Per-direction weighted gradients, reused to carry `M·u` into the adjoint pass.
548 d1: Vec<f64>,
549 d2: Vec<f64>,
550 d3: Vec<f64>,
551 /// One `(semi_norm, ‖u‖²)` partial per z-slab, summed after the parallel pass.
552 partials: Vec<(f64, f64)>,
553}
554
555impl TvWorkspace {
556 fn new(grid: &Grid) -> Self {
557 let n = grid.n_total();
558 Self {
559 d1: vec![0.0; n],
560 d2: vec![0.0; n],
561 d3: vec![0.0; n],
562 partials: vec![(0.0, 0.0); grid.nz()],
563 }
564 }
565}
566
567/// Smoothed weighted-TV objective and its gradient.
568///
569/// Returns `f_μ(x)` and writes `∇f_μ(x)` into `df`. Two passes over the volume,
570/// each parallel over z-slabs: the first builds the masked gradients and the
571/// smoothing weight `w`, the second applies the adjoint differences.
572fn tv_objective(
573 x: &[f64],
574 mu: f64,
575 masks: &HomogeneityMasks,
576 grid: &Grid,
577 ws: &mut TvWorkspace,
578 df: &mut [f64],
579) -> f64 {
580 let (nx, ny, nz) = grid.dims;
581 let (vsx, vsy, vsz) = grid.voxel_size;
582 let slab = nx * ny;
583 let (ix, iy, iz) = (1.0 / vsx, 1.0 / vsy, 1.0 / vsz);
584
585 // ── Pass 1: masked gradients, Nesterov smoothing, objective partials ──
586 //
587 // `w = max(μ, ‖∇x‖)` replaces the TV norm with a Huber-like function that is
588 // quadratic below μ and linear above it. The buffers come out holding
589 // `M·u = M²·Dx / w`, which is exactly what the adjoint pass needs.
590 crate::maybe_par_chunks_mut!(ws.d1, slab)
591 .zip(crate::maybe_par_chunks_mut!(ws.d2, slab))
592 .zip(crate::maybe_par_chunks_mut!(ws.d3, slab))
593 .zip(crate::maybe_par_chunks_mut!(ws.partials, 1))
594 .enumerate()
595 .for_each(|(k, (((d1s, d2s), d3s), acc))| {
596 let base = k * slab;
597 let (mut semi, mut nsq) = (0.0, 0.0);
598
599 for j in 0..ny {
600 for i in 0..nx {
601 let q = i + j * nx;
602 let idx = base + q;
603
604 // Backward differences, zero on the leading face of each axis.
605 let gx = if i >= 1 { (x[idx] - x[idx - 1]) * ix } else { 0.0 };
606 let gy = if j >= 1 { (x[idx] - x[idx - nx]) * iy } else { 0.0 };
607 let gz = if k >= 1 { (x[idx] - x[idx - slab]) * iz } else { 0.0 };
608
609 let (a, b, c) = (
610 masks.x[idx] * gx,
611 masks.y[idx] * gy,
612 masks.z[idx] * gz,
613 );
614 let mag_sq = a * a + b * b + c * c;
615 let w = mag_sq.sqrt().max(mu);
616 semi += mag_sq / w;
617 nsq += mag_sq / (w * w);
618
619 let inv_w = 1.0 / w;
620 d1s[q] = masks.x[idx] * a * inv_w;
621 d2s[q] = masks.y[idx] * b * inv_w;
622 d3s[q] = masks.z[idx] * c * inv_w;
623 }
624 }
625
626 acc[0] = (semi, nsq);
627 });
628
629 let semi_norm: f64 = ws.partials.iter().map(|p| p.0).sum();
630 let norm_u_sq: f64 = ws.partials.iter().map(|p| p.1).sum();
631
632 // ── Pass 2: ∇f = Dᵀ(M u), summed over the three directions ──
633 let (d1, d2, d3) = (&ws.d1, &ws.d2, &ws.d3);
634 crate::maybe_par_chunks_mut!(df, slab)
635 .enumerate()
636 .for_each(|(k, out)| {
637 let base = k * slab;
638 for j in 0..ny {
639 for i in 0..nx {
640 let q = i + j * nx;
641 let idx = base + q;
642
643 // Forward differences, zero on the trailing face of each axis.
644 let mut v = 0.0;
645 if i + 1 < nx {
646 v += (d1[idx] - d1[idx + 1]) * ix;
647 }
648 if j + 1 < ny {
649 v += (d2[idx] - d2[idx + nx]) * iy;
650 }
651 if k + 1 < nz {
652 v += (d3[idx] - d3[idx + slab]) * iz;
653 }
654 out[q] = v;
655 }
656 }
657 });
658
659 semi_norm - 0.5 * mu * norm_u_sq
660}
661
662/// NESTA with continuation: fill the dipole cone under a weighted-TV prior while
663/// holding the well-conditioned coefficients of `chi_init` fixed.
664fn nesta_cone_fill(
665 chi_init: &[f64],
666 cone: &[bool],
667 masks: &HomogeneityMasks,
668 grid: &Grid,
669 params: &HeidiParams,
670 mut progress: impl FnMut(usize, usize),
671) -> Vec<f64> {
672 let (nx, ny, nz) = grid.dims;
673 let n = grid.n_total();
674
675 let mut fft = Fft3dWorkspace::new(nx, ny, nz);
676 let mut cbuf: Vec<Complex64> = vec![Complex64::new(0.0, 0.0); n];
677
678 // P(x) = F⁻¹( cone · F x ): projection onto the well-conditioned subspace.
679 // A = restrict∘F and Aᵀ = F⁻¹∘zero-fill satisfy A·Aᵀ = I, so P is idempotent
680 // and the NESTA data step below is an exact projection.
681 let project = |x: &[f64], out: &mut [f64], fft: &mut Fft3dWorkspace, cbuf: &mut Vec<Complex64>| {
682 for (c, &v) in cbuf.iter_mut().zip(x) {
683 *c = Complex64::new(v, 0.0);
684 }
685 fft.fft3d(cbuf);
686 for (c, &keep) in cbuf.iter_mut().zip(cone) {
687 if !keep {
688 *c = Complex64::new(0.0, 0.0);
689 }
690 }
691 fft.ifft3d(cbuf);
692 for (o, c) in out.iter_mut().zip(cbuf.iter()) {
693 *o = c.re;
694 }
695 };
696
697 // The data term: the cone content of chi_init, which every iterate must match.
698 let mut p_b = vec![0.0; n];
699 project(chi_init, &mut p_b, &mut fft, &mut cbuf);
700
701 // TV gradients use relative voxel sizes, as upstream normalises them.
702 let (vsx, vsy, vsz) = grid.voxel_size;
703 let vmax = vsx.max(vsy).max(vsz);
704 let tv_grid = Grid::new(nx, ny, nz, vsx / vmax, vsy / vmax, vsz / vmax);
705
706 // Lipschitz constant of the smoothed TV gradient: ‖D‖² / μ, with ‖D‖² summed
707 // over the three difference operators (2/v per direction).
708 let lip_geometry = 4.0 / (vsx / vmax).powi(2)
709 + 4.0 / (vsy / vmax).powi(2)
710 + 4.0 / (vsz / vmax).powi(2);
711
712 let mu0 = 0.9 * p_b.iter().fold(0.0f64, |acc, v| acc.max(v.abs()));
713 if mu0 <= 0.0 {
714 // Nothing in the cone — no data to honour, and TV alone gives zero.
715 return vec![0.0; n];
716 }
717
718 let steps = params.continuation_steps.max(1);
719 let gamma = (params.mu_min / mu0).powf(1.0 / steps as f64);
720
721 let mut ws = TvWorkspace::new(&tv_grid);
722
723 let mut xplug = p_b.clone();
724 let mut df = vec![0.0; n];
725 let mut wk = vec![0.0; n];
726 let mut cp = vec![0.0; n];
727 let mut proj = vec![0.0; n];
728 let mut yk = vec![0.0; n];
729 let mut zk = vec![0.0; n];
730
731 let total_iters = steps * params.inner_iterations;
732 let mut done = 0usize;
733 let mut mu = mu0;
734
735 for _ in 0..steps {
736 mu *= gamma;
737 let lmu = lip_geometry / mu;
738 let inv_lmu = 1.0 / lmu;
739
740 let mut xk = xplug.clone();
741 wk.fill(0.0);
742 // `fmean` is a short history of objective values; the stopping test
743 // compares the current objective against their mean.
744 let mut fmean: Vec<f64> = vec![f64::MIN_POSITIVE / 10.0];
745
746 for k in 0..params.inner_iterations {
747 let fx = tv_objective(&xk, mu, masks, &tv_grid, &mut ws, &mut df);
748
749 // Upstream aborts the whole run on divergence. Rewinding to this
750 // step's starting point instead keeps the previous continuation
751 // level's answer, which is a usable map rather than an error.
752 if !fx.is_finite() {
753 xk.copy_from_slice(&xplug);
754 break;
755 }
756
757 // yk: gradient step from xk, projected back onto the constraint set.
758 for i in 0..n {
759 cp[i] = xk[i] - inv_lmu * df[i];
760 }
761 project(&cp, &mut proj, &mut fft, &mut cbuf);
762 for i in 0..n {
763 yk[i] = p_b[i] + cp[i] - proj[i];
764 }
765
766 let fbar = fmean.iter().sum::<f64>() / fmean.len() as f64;
767 let qp = (fx - fbar).abs() / fbar;
768
769 done += 1;
770 progress(done, total_iters);
771
772 // Upstream breaks before the momentum update, keeping the current
773 // iterate rather than the freshly projected `yk`.
774 if qp <= params.tol {
775 break;
776 }
777
778 fmean.insert(0, fx);
779 fmean.truncate(10);
780
781 // zk: step from the anchor point along the accumulated weighted
782 // gradient — the Nesterov momentum term.
783 let apk = 0.5 * (k as f64 + 1.0);
784 let tauk = 2.0 / (k as f64 + 3.0);
785 for i in 0..n {
786 wk[i] += apk * df[i];
787 cp[i] = xplug[i] - inv_lmu * wk[i];
788 }
789 project(&cp, &mut proj, &mut fft, &mut cbuf);
790 for i in 0..n {
791 zk[i] = p_b[i] + cp[i] - proj[i];
792 xk[i] = tauk * zk[i] + (1.0 - tauk) * yk[i];
793 }
794 }
795
796 xplug = xk;
797 }
798
799 xplug
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 fn test_grid(n: usize) -> Grid {
807 Grid::new(n, n, n, 1.0, 1.0, 1.0)
808 }
809
810 #[test]
811 fn discrete_gradient_matches_upstream_edges() {
812 let g = Grid::new(4, 1, 1, 1.0, 1.0, 1.0);
813 let x = vec![1.0, 3.0, 6.0, 10.0];
814
815 let fwd = discrete_gradient(&x, &g, 0, false);
816 assert_eq!(fwd, vec![0.0, 2.0, 3.0, 4.0]);
817
818 let tr = discrete_gradient(&x, &g, 0, true);
819 assert_eq!(tr, vec![-2.0, -3.0, -4.0, 0.0]);
820 }
821
822 #[test]
823 fn discrete_gradient_scales_by_voxel_size() {
824 let g = Grid::new(3, 1, 1, 2.0, 1.0, 1.0);
825 let x = vec![0.0, 4.0, 10.0];
826 let fwd = discrete_gradient(&x, &g, 0, false);
827 assert_eq!(fwd, vec![0.0, 2.0, 3.0]);
828 }
829
830 #[test]
831 fn thresholds_convert_from_upstream_units() {
832 // A 0.00105 threshold in phase/(TE[ms]·B0[T]) is ~3.93e-3 ppm/mm.
833 assert!((DEFAULT_GRADIENT_THRESHOLD_PPM - 3.9249e-3).abs() < 1e-6);
834 assert!((DEFAULT_LAPLACIAN_THRESHOLD_PPM - 0.059434).abs() < 1e-5);
835 }
836
837 #[test]
838 fn homogeneity_masks_open_at_edges() {
839 let n = 12;
840 let g = test_grid(n);
841 let n_total = g.n_total();
842
843 // Flat left half, steep ramp on the right.
844 let mut field = vec![0.0; n_total];
845 for k in 0..n {
846 for j in 0..n {
847 for i in 0..n {
848 field[i + j * n + k * n * n] =
849 if i < n / 2 { 0.0 } else { 0.05 * (i - n / 2) as f64 };
850 }
851 }
852 }
853 let mask = vec![1u8; n_total];
854 let params = HeidiParams {
855 denoise: None,
856 apply_laplacian_correction: false,
857 ..Default::default()
858 };
859 let m = homogeneity_masks(&field, &mask, &g, ¶ms);
860
861 // Deep in the flat region the x mask is on.
862 let flat = 2 + (n / 2) * n + (n / 2) * n * n;
863 assert_eq!(m.x[flat], 1.0);
864 // On the ramp the gradient is 0.05 ppm/mm, far above the 3.9e-3 threshold.
865 let ramp = (n / 2 + 2) + (n / 2) * n + (n / 2) * n * n;
866 assert_eq!(m.x[ramp], 0.0);
867 // y and z are flat everywhere, so the all-edge floor never triggers.
868 assert_eq!(m.y[ramp], 1.0);
869 }
870
871 #[test]
872 fn all_edge_voxels_get_the_floor() {
873 let n = 8;
874 let g = test_grid(n);
875 let n_total = g.n_total();
876
877 // A field that ramps steeply in every direction.
878 let mut field = vec![0.0; n_total];
879 for k in 0..n {
880 for j in 0..n {
881 for i in 0..n {
882 field[i + j * n + k * n * n] = 0.1 * (i + j + k) as f64;
883 }
884 }
885 }
886 let mask = vec![1u8; n_total];
887 let params = HeidiParams {
888 denoise: None,
889 apply_laplacian_correction: false,
890 gradient_mask_floor: 0.1,
891 ..Default::default()
892 };
893 let m = homogeneity_masks(&field, &mask, &g, ¶ms);
894
895 let inner = 4 + 4 * n + 4 * n * n;
896 assert_eq!(m.x[inner], 0.1);
897 assert_eq!(m.y[inner], 0.1);
898 assert_eq!(m.z[inner], 0.1);
899 }
900
901 #[test]
902 fn masks_are_zero_outside_the_brain() {
903 let n = 8;
904 let g = test_grid(n);
905 let n_total = g.n_total();
906 let field = vec![0.0; n_total];
907 let mut mask = vec![0u8; n_total];
908 mask[4 + 4 * n + 4 * n * n] = 1;
909
910 let params = HeidiParams { denoise: None, ..Default::default() };
911 let m = homogeneity_masks(&field, &mask, &g, ¶ms);
912 for (i, &inside) in mask.iter().enumerate() {
913 if inside == 0 {
914 assert_eq!(m.x[i], 0.0);
915 assert_eq!(m.y[i], 0.0);
916 assert_eq!(m.z[i], 0.0);
917 }
918 }
919 }
920
921 /// The defining property: coefficients outside the cone are free, but the
922 /// well-conditioned ones must come back unchanged.
923 #[test]
924 fn cone_coefficients_are_preserved() {
925 let n = 17;
926 let g = test_grid(n);
927 let n_total = g.n_total();
928 let bdir = (0.0, 0.0, 1.0);
929
930 let mut chi_init = vec![0.0; n_total];
931 let c = n as f64 / 2.0;
932 for k in 0..n {
933 for j in 0..n {
934 for i in 0..n {
935 let r = ((i as f64 - c).powi(2)
936 + (j as f64 - c).powi(2)
937 + (k as f64 - c).powi(2))
938 .sqrt();
939 chi_init[i + j * n + k * n * n] = if r < 4.0 { 0.1 } else { 0.0 };
940 }
941 }
942 }
943
944 let field = vec![0.0; n_total];
945 let mask = vec![1u8; n_total];
946 let params = HeidiParams {
947 denoise: None,
948 continuation_steps: 3,
949 inner_iterations: 20,
950 ..Default::default()
951 };
952 let chi = heidi_on_grid(&field, &mask, &chi_init, &g, bdir, ¶ms, |_, _| {});
953
954 // Compare the two maps' k-space inside the cone.
955 let cone = cone_mask(&g, bdir, params.cone_threshold);
956 let mut ws = Fft3dWorkspace::new(n, n, n);
957 let mut a: Vec<Complex64> = chi_init.iter().map(|&v| Complex64::new(v, 0.0)).collect();
958 let mut b: Vec<Complex64> = chi.iter().map(|&v| Complex64::new(v, 0.0)).collect();
959 ws.fft3d(&mut a);
960 ws.fft3d(&mut b);
961
962 let mut worst: f64 = 0.0;
963 let mut scale: f64 = 0.0;
964 for i in 0..n_total {
965 if cone[i] {
966 worst = worst.max((a[i] - b[i]).norm());
967 scale = scale.max(a[i].norm());
968 }
969 }
970 assert!(
971 worst < 1e-6 * scale.max(1.0),
972 "cone coefficients drifted by {worst} (scale {scale})"
973 );
974 }
975
976 /// The fused two-pass TV kernel must agree with the straightforward
977 /// composition of `discrete_gradient` it replaced.
978 #[test]
979 fn fused_tv_matches_the_naive_composition() {
980 let (nx, ny, nz) = (9usize, 7usize, 8usize);
981 let g = Grid::new(nx, ny, nz, 1.0, 1.3, 0.8);
982 let n = g.n_total();
983
984 let x: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.37).sin()).collect();
985 let masks = HomogeneityMasks {
986 x: (0..n).map(|i| if i % 5 == 0 { 0.1 } else { 1.0 }).collect(),
987 y: (0..n).map(|i| if i % 3 == 0 { 0.0 } else { 1.0 }).collect(),
988 z: (0..n).map(|i| if i % 7 == 0 { 0.1 } else { 1.0 }).collect(),
989 };
990 let mu = 0.05;
991
992 let mut ws = TvWorkspace::new(&g);
993 let mut df = vec![0.0; n];
994 let fx = tv_objective(&x, mu, &masks, &g, &mut ws, &mut df);
995
996 // Reference: build each direction separately, then apply the adjoints.
997 let mut d1 = discrete_gradient(&x, &g, 0, false);
998 let mut d2 = discrete_gradient(&x, &g, 1, false);
999 let mut d3 = discrete_gradient(&x, &g, 2, false);
1000 for i in 0..n {
1001 d1[i] *= masks.x[i];
1002 d2[i] *= masks.y[i];
1003 d3[i] *= masks.z[i];
1004 }
1005 let mut semi = 0.0;
1006 let mut nsq = 0.0;
1007 for i in 0..n {
1008 let mag_sq = d1[i] * d1[i] + d2[i] * d2[i] + d3[i] * d3[i];
1009 let w = mag_sq.sqrt().max(mu);
1010 semi += mag_sq / w;
1011 nsq += mag_sq / (w * w);
1012 d1[i] = masks.x[i] * d1[i] / w;
1013 d2[i] = masks.y[i] * d2[i] / w;
1014 d3[i] = masks.z[i] * d3[i] / w;
1015 }
1016 let ref_fx = semi - 0.5 * mu * nsq;
1017
1018 let gx = discrete_gradient(&d1, &g, 0, true);
1019 let gy = discrete_gradient(&d2, &g, 1, true);
1020 let gz = discrete_gradient(&d3, &g, 2, true);
1021
1022 assert!((fx - ref_fx).abs() < 1e-12, "objective {fx} vs {ref_fx}");
1023 for i in 0..n {
1024 let expect = gx[i] + gy[i] + gz[i];
1025 assert!(
1026 (df[i] - expect).abs() < 1e-12,
1027 "gradient mismatch at {i}: {} vs {expect}",
1028 df[i]
1029 );
1030 }
1031 }
1032
1033 /// End to end on an even, prime-factored grid: LSQR's cropped odd grid is used inside and
1034 /// the result comes back at the caller's dimensions, masked.
1035 #[test]
1036 fn heidi_runs_on_upstream_grid_and_returns_the_input_grid() {
1037 let (nx, ny, nz) = (12usize, 11usize, 13usize);
1038 let g = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1039 let n_total = g.n_total();
1040 let field: Vec<f64> = (0..n_total).map(|i| ((i as f64) * 0.31).sin() * 0.05).collect();
1041 let mut mask = vec![0u8; n_total];
1042 for k in 3..10 {
1043 for j in 2..9 {
1044 for i in 3..9 {
1045 mask[i + nx * (j + ny * k)] = 1;
1046 }
1047 }
1048 }
1049 let lsqr = LsqrQsmParams { max_iter: 10, ..Default::default() };
1050 let params = HeidiParams { denoise: None, continuation_steps: 2, inner_iterations: 10, ..Default::default() };
1051 let mut last = 0;
1052 let chi = heidi(&field, &mask, None, &g, (0.0, 0.0, 1.0), &lsqr, ¶ms, |i, total| {
1053 assert!(i <= total);
1054 last = i;
1055 });
1056 assert_eq!(chi.len(), n_total);
1057 assert!(chi.iter().all(|v| v.is_finite()));
1058 assert!(chi.iter().zip(&mask).all(|(v, &m)| m != 0 || *v == 0.0), "not masked");
1059 assert!(chi.iter().any(|v| v.abs() > 0.0), "empty map");
1060 assert!(last > 10, "progress did not cover the HEIDI stage");
1061 // empty mask
1062 let z = heidi(&field, &vec![0u8; n_total], None, &g, (0.0, 0.0, 1.0), &lsqr, ¶ms, |_, _| {});
1063 assert!(z.iter().all(|v| *v == 0.0));
1064 }
1065
1066 /// The cone fill runs on χ in upstream's phase units (ppm · γ·TE·B0·1e-6): NESTA's final
1067 /// μ is absolute, so the scale changes the answer.
1068 #[test]
1069 fn cone_fill_runs_in_phase_units() {
1070 let n = 11;
1071 let g = test_grid(n);
1072 let nt = g.n_total();
1073 let seed: Vec<f64> = (0..nt).map(|i| ((i as f64) * 0.13).sin() * 0.05).collect();
1074 let field: Vec<f64> = (0..nt).map(|i| ((i as f64) * 0.07).cos() * 0.01).collect();
1075 let mask = vec![1u8; nt];
1076 let p = HeidiParams { denoise: None, continuation_steps: 3, inner_iterations: 30, echo_times: vec![0.005, 0.025], b0: 7.0, ..Default::default() };
1077 let chi = heidi_on_grid(&field, &mask, &seed, &g, (0.0, 0.0, 1.0), &p, |_, _| {});
1078 let s = GAMMA * 0.025 * p.b0 * 1e-6; // the last echo time, HEIDI's default
1079 let masks = homogeneity_masks(&field, &mask, &g, &p);
1080 let cone = cone_mask(&g, (0.0, 0.0, 1.0), p.cone_threshold);
1081 let scaled: Vec<f64> = seed.iter().map(|v| v * s).collect();
1082 let manual = nesta_cone_fill(&scaled, &cone, &masks, &g, &p, |_, _| {});
1083 for (a, b) in chi.iter().zip(&manual) {
1084 assert!((a - b / s).abs() < 1e-12, "{a} vs {}", b / s);
1085 }
1086 let unscaled = nesta_cone_fill(&seed, &cone, &masks, &g, &p, |_, _| {});
1087 assert!(chi.iter().zip(&unscaled).any(|(a, b)| (a - b).abs() > 1e-9), "scale had no effect");
1088 }
1089
1090 /// The cone fill on an outside seed runs on upstream's grid like `heidi`'s, returns the
1091 /// input grid masked, and agrees with `heidi_on_grid` on that grid (the seed cropped and
1092 /// framed as `Wrapper_QSM_HEIDI4all` frames it).
1093 #[test]
1094 fn cone_fill_takes_any_seed_on_upstreams_grid() {
1095 let (nx, ny, nz) = (12usize, 11usize, 13usize);
1096 let g = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1097 let n_total = g.n_total();
1098 let field: Vec<f64> = (0..n_total).map(|i| ((i as f64) * 0.31).sin() * 0.05).collect();
1099 let mut mask = vec![0u8; n_total];
1100 for k in 3..10 {
1101 for j in 2..9 {
1102 for i in 3..9 {
1103 mask[i + nx * (j + ny * k)] = 1;
1104 }
1105 }
1106 }
1107 let seed: Vec<f64> = field.iter().zip(&mask).map(|(f, &m)| if m != 0 { 0.4 * f } else { 0.0 }).collect();
1108 let params = HeidiParams { denoise: None, continuation_steps: 2, inner_iterations: 10, ..Default::default() };
1109 let mut last = 0;
1110 let chi = heidi_cone_fill(&field, &mask, &seed, &g, (0.0, 0.0, 1.0), ¶ms, |i, total| {
1111 assert!(i <= total);
1112 assert_eq!(total, cone_fill_iterations(¶ms));
1113 last = i;
1114 });
1115 assert_eq!(chi.len(), n_total);
1116 assert!(chi.iter().all(|v| v.is_finite()));
1117 assert!(chi.iter().zip(&mask).all(|(v, &m)| m != 0 || *v == 0.0), "not masked");
1118 assert!(chi.iter().any(|v| v.abs() > 0.0), "empty map");
1119 assert_eq!(last, cone_fill_iterations(¶ms));
1120
1121 let crop = crop_grid(&mask, g.dims).unwrap();
1122 let pgrid = Grid::new(crop[0].2, crop[1].2, crop[2].2, 1.0, 1.0, 1.0);
1123 let on_grid = heidi_on_grid(
1124 &crop_pad(&field, g.dims, &crop),
1125 &crop_pad(&mask, g.dims, &crop),
1126 &crop_pad(&seed, g.dims, &crop),
1127 &pgrid,
1128 (0.0, 0.0, 1.0),
1129 ¶ms,
1130 |_, _| {},
1131 );
1132 let mut expected = uncrop(&on_grid, g.dims, &crop);
1133 apply_mask_zero(&mut expected, &mask);
1134 assert_eq!(chi, expected);
1135
1136 let z = heidi_cone_fill(&field, &vec![0u8; n_total], &seed, &g, (0.0, 0.0, 1.0), ¶ms, |_, _| {});
1137 assert!(z.iter().all(|v| *v == 0.0));
1138 }
1139
1140 #[test]
1141 fn zero_init_gives_zero_output() {
1142 let n = 9;
1143 let g = test_grid(n);
1144 let n_total = g.n_total();
1145 let params = HeidiParams {
1146 denoise: None,
1147 continuation_steps: 2,
1148 inner_iterations: 5,
1149 ..Default::default()
1150 };
1151 let chi = heidi_on_grid(
1152 &vec![0.0; n_total],
1153 &vec![1u8; n_total],
1154 &vec![0.0; n_total],
1155 &g,
1156 (0.0, 0.0, 1.0),
1157 ¶ms,
1158 |_, _| {},
1159 );
1160 assert!(chi.iter().all(|v| v.abs() < 1e-12));
1161 }
1162
1163 /// The cone is where the TV prior actually does work: HEIDI should reduce
1164 /// total variation relative to just low-passing the initial map.
1165 #[test]
1166 fn tv_is_reduced_against_the_bare_cone_projection() {
1167 let n = 17;
1168 let g = test_grid(n);
1169 let n_total = g.n_total();
1170 let bdir = (0.0, 0.0, 1.0);
1171
1172 let mut chi_init = vec![0.0; n_total];
1173 let c = n as f64 / 2.0;
1174 for k in 0..n {
1175 for j in 0..n {
1176 for i in 0..n {
1177 let r = ((i as f64 - c).powi(2)
1178 + (j as f64 - c).powi(2)
1179 + (k as f64 - c).powi(2))
1180 .sqrt();
1181 chi_init[i + j * n + k * n * n] = if r < 5.0 { 0.1 } else { 0.0 };
1182 }
1183 }
1184 }
1185
1186 let field = vec![0.0; n_total];
1187 let mask = vec![1u8; n_total];
1188 let params = HeidiParams {
1189 denoise: None,
1190 continuation_steps: 4,
1191 inner_iterations: 60,
1192 ..Default::default()
1193 };
1194 let chi = heidi_on_grid(&field, &mask, &chi_init, &g, bdir, ¶ms, |_, _| {});
1195
1196 // Bare projection = what you get with no prior at all.
1197 let cone = cone_mask(&g, bdir, params.cone_threshold);
1198 let mut ws = Fft3dWorkspace::new(n, n, n);
1199 let mut buf: Vec<Complex64> =
1200 chi_init.iter().map(|&v| Complex64::new(v, 0.0)).collect();
1201 ws.fft3d(&mut buf);
1202 for (b, &keep) in buf.iter_mut().zip(&cone) {
1203 if !keep {
1204 *b = Complex64::new(0.0, 0.0);
1205 }
1206 }
1207 ws.ifft3d(&mut buf);
1208 let projected: Vec<f64> = buf.iter().map(|c| c.re).collect();
1209
1210 let tv = |x: &[f64]| -> f64 {
1211 let dx = discrete_gradient(x, &g, 0, false);
1212 let dy = discrete_gradient(x, &g, 1, false);
1213 let dz = discrete_gradient(x, &g, 2, false);
1214 (0..n_total)
1215 .map(|i| (dx[i] * dx[i] + dy[i] * dy[i] + dz[i] * dz[i]).sqrt())
1216 .sum()
1217 };
1218
1219 let tv_heidi = tv(&chi);
1220 let tv_proj = tv(&projected);
1221 assert!(
1222 tv_heidi < tv_proj,
1223 "HEIDI TV {tv_heidi} is not below the bare projection's {tv_proj}"
1224 );
1225 }
1226}