Skip to main content

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(&params.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 &params.denoise {
394        Some(d) => {
395            let to_phase = -GAMMA * params.te.seconds(&params.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, &params);
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, &params);
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, &params);
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, &params, |_, _| {});
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, &params, |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, &params, |_, _| {});
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), &params, |i, total| {
1111            assert!(i <= total);
1112            assert_eq!(total, cone_fill_iterations(&params));
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(&params));
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            &params,
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), &params, |_, _| {});
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            &params,
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, &params, |_, _| {});
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}