Skip to main content

qsm_core/inversion/
tgv.rs

1//! TGV-QSM: Total Generalized Variation for Quantitative Susceptibility Mapping
2//!
3//! Single-step QSM: background removal and dipole inversion (and, for a wrapped echo, phase
4//! unwrapping) in one primal-dual iteration with TGV regularisation.
5//!
6//! References:
7//! Langkammer, C., Bredies, K., Poser, B.A., et al. (2015).
8//! "Fast quantitative susceptibility mapping using 3D EPI and total generalized variation."
9//! NeuroImage, 111:622-630. https://doi.org/10.1016/j.neuroimage.2015.02.041
10//!
11//! Chatnuntawech, I., McDaniel, P., et al. (2017).
12//! "Single-step quantitative susceptibility mapping with variational penalties."
13//! NMR in Biomedicine, 30(4):e3570. https://doi.org/10.1002/nbm.3570
14//!
15//! Reference implementation: https://github.com/korbinian90/QuantitativeSusceptibilityMappingTGV.jl
16//! (`qsm_tgv`). The iteration, step sizes, dipole stencil, mask erosions, Laplacian mean
17//! correction and the defaults (α from `get_default_alpha(2)`, iterations from
18//! `get_default_iterations`, `erosions = 3`, `step_size = 3`) are that package's.
19//!
20//! # Units
21//!
22//! The reference solves
23//!
24//! ```text
25//!   min_{χ,w,ψ}  α₁‖∇χ − w‖₁ + α₀‖ε(w)‖₁ + ½‖ψ‖²   s.t.  ∇²φ = (⅓∇² − ∂²_z)χ + ∇²ψ
26//! ```
27//!
28//! on the phase Laplacian `∇²φ` in rad/mm², with α in those units, and divides χ by
29//! `c = 2π·γ·B0·TE` ([`rad_per_ppm`]) at the end. QSM.rs does the same: the input (a field in
30//! ppm, or one wrapped echo in rad) becomes phase in rad at the given TE and B0 as the first
31//! step, and χ is divided by `c` as the last. α are therefore in the reference's rad units and
32//! mean what they mean there, at the scan's actual TE and B0. What the caller has decides how
33//! `∇²φ` is formed (see [`TgvInput`]):
34//!
35//! - [`TgvInput::FieldPpm`]: an unwrapped field in ppm (for example a multi-echo field map),
36//!   scaled to phase at the echo time [`TgvParams::te`] picks. `∇²φ` is the ordinary discrete
37//!   Laplacian: the reference's `get_laplace_phase_romeo` with the unwrapping already done.
38//! - [`TgvInput::WrappedPhase`]: one wrapped echo in rad, the original single-step TGV-QSM.
39//!   `∇²φ` is the wrap-insensitive sin/cos (DEL) Laplacian, the reference's default
40//!   `get_laplace_phase_del`.
41//!
42//! The output is χ in ppm.
43//!
44//! # Agreement with the reference
45//!
46//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, ROMEO
47//! total field, brain mask touching the first z plane) against the package in the QSM-CI
48//! `julia-qsm` image (`sha256:b3c048ac…`, 6c0886a, 4 threads), both at the package's defaults
49//! (1655 iterations) and with both [`TgvParams::reference_divergence`] and
50//! [`TgvParams::reference_edge_erosion`], over the voxels both return: NRMSE 4.0e-4 % for a wrapped echo (the field as phase
51//! at TE1, the package's `get_laplace_phase_del`), and 3.7e-4 % for [`TgvInput::FieldPpm`]
52//! against the package with its own discrete Laplacian kernel on the unwrapped phase. Both are
53//! float32 rounding over 1655 iterations. `tests/tgv_tgvjl_parity.rs` checks both inputs,
54//! anisotropic voxels and oblique B0 against the package's output on small phantoms (relative
55//! L2 ≤ 3e-6), with all three reference options on (the comparison covers the package's whole
56//! output box). The defaults differ; see below.
57//!
58//! Against the package's `get_laplace_phase_romeo`, which ROMEO-unwraps the given phase again
59//! before its discrete Laplacian, the in-vivo field differs by NRMSE 12 %: on that scan the
60//! re-unwrap adds ±2π at 2 voxels inside the data-term mask, which spreads through the
61//! inversion. [`TgvInput::FieldPpm`] takes the Laplacian of the field as given.
62//!
63//! # Where QSM.rs differs from the reference
64//!
65//! Two details of the reference code are fixed by default, and one of its outputs is trimmed.
66//! Each is an independent option that restores the reference's behaviour (QSM.rs's reference
67//! rule: unequivocal fixes are the default, and the original's behaviour stays available for
68//! parity):
69//! - **Divergence in the `w` update** ([`TgvParams::reference_divergence`]). The reference scales
70//!   each row of `q` by that row's own resolution for all three of its differences
71//!   (`update_w_kernel!`; the same in the original Python `tgv_deconv_update_w`). Chambolle–Pock
72//!   converges to the problem the paper states only when the operator in the primal update is
73//!   the exact adjoint of the one in the dual update. On anisotropic voxels the reference's is not
74//!   the adjoint of its symmetric gradient; a unit test checks the identity for the default and
75//!   its failure for the reference's. By default the `w` update uses the exact adjoint. This is a
76//!   pure correctness fix: it affects only anisotropic voxels, and on isotropic voxels the two
77//!   are the same operator.
78//! - **Erosion at the array's edge** ([`TgvParams::reference_edge_erosion`]). The reference skips
79//!   neighbours outside the array (`erode_mask`, `src/tgv.jl:218-222`; the original Python's
80//!   `erode_mask` treats them as inside), so a mask that reaches an array face is not eroded
81//!   from it. There the Laplacian and gradient stencils have no valid neighbour (the reference
82//!   replicates the edge value or drops the term), so the data term at those voxels is not the
83//!   operator it is elsewhere. By default a neighbour outside the array counts as outside the
84//!   mask, which removes those voxels. This affects only masks that reach an array face, and
85//!   costs coverage at those faces.
86//! - **Output support** ([`TgvParams::reference_output_box`]). χ is returned inside the eroded
87//!   mask (`erosions` erosions of `mask`) and is zero elsewhere. The reference returns its whole
88//!   working box (the eroded mask's bounding box plus one voxel, `reduce_to_mask_box`), whose χ
89//!   outside that mask is the stencils' spill-over where the data term does not act: a rim of
90//!   up to two voxels around the eroded mask (no data there; e.g. 44,530 voxels of the in-vivo
91//!   brain mask). Inside the eroded mask the two are the same. By default QSM.rs trims that rim,
92//!   since no data constrain it; the option returns it.
93//!   - **What it changes in a comparison with the package.** Inside an uneroded brain mask the
94//!     rim is part of the comparison. QSM-CI's `tgv` pair (QSMxT/QSM-CI `tgv-tgvjl` vs
95//!     `tgv-qsmrs`, compared inside the dataset's mask, QSM.rs main 7f8271e, run 41019310531)
96//!     measured NRMSE 3.84 % (sim, 41,907 rim voxels) and 6.74 % (KKI travelling head, 46,101):
97//!     all of it the rim. Over the voxels both return, the same outputs differ by 3.8e-4 %
98//!     (sim) and 4.9e-3 % (KKI): float32 rounding over 1655 iterations (#369).
99//!
100//! Measured against the package and, on the QSM-CI phantom, against the true χ. The total field
101//! is the input; the defaults are QSM.rs's, and "both" turns both options on. NRMSE against the
102//! package is over the voxels both return. NRMSE and corr against the truth are over the voxels
103//! every setting returns.
104//!
105//! | case | mask reaches | default | divergence only | edge erosion only | both |
106//! |---|---|---|---|---|---|
107//! | in vivo, 1 mm (travelling head, brain mask) | z = 0 | 11.0 % | 11.0 % | 3.7e-4 % | 3.7e-4 % |
108//! | in vivo, 1 × 1 × 2 mm (same, z pairs averaged) | z = 0 | 14.6 % | 14.4 % | 2.3 % | 2.0e-4 % |
109//! | phantom, 1 mm (CI brain mask) | no face | 3.8e-4 % | 3.8e-4 % | 3.8e-4 % | 3.8e-4 % |
110//! | phantom slab, 48 slices at 1 mm | both z faces | 99.8 % | 99.8 % | 4.4e-4 % | 4.4e-4 % |
111//! | phantom slab, 48 slices at 1 × 1 × 2 mm | both z faces | 97.9 % | 97.9 % | 1.7 % | 1.1e-4 % |
112//!
113//! Against the true χ:
114//!
115//! | case | default | divergence only | edge erosion only | both (= the package) |
116//! |---|---|---|---|---|
117//! | phantom, 1 mm | 59.4 % / 0.807 | same | same | same |
118//! | phantom slab, 1 mm | **68.5 % / 0.730** | 68.5 % / 0.730 | 1334 % / 0.034 | 1334 % / 0.034 |
119//! | phantom slab, 1 × 1 × 2 mm | **65.6 % / 0.757** | 65.5 % / 0.757 | 349 % / 0.082 | 347 % / 0.082 |
120//!
121//! - **Coverage.** The edge erosion's cost is the voxels it removes at the faces: about 1,010 of
122//!   the in-vivo scan's 1.5 million (one face), and 70,418 of 617,917 on the 1 mm phantom slab
123//!   (two faces, 11 %). On a head mask (χ ≠ 0) of the phantom reaching three faces, it removes
124//!   308,014 voxels.
125//! - **Why it is still the default.** When the mask is cut by a face, the reference's unmasked
126//!   stencils there spread through the whole slab: χ against the truth goes from NRMSE 69 % to
127//!   1334 %.
128//! - **Divergence.** It changes χ only on anisotropic voxels: by 1.1 % (relative L2) on a
129//!   0.9 × 1.0 × 1.3 mm phantom. On the 1 × 1 × 2 mm in-vivo scan with the reference's erosion,
130//!   the package's divergence accounts for the 2.3 % that separates "edge erosion only" from the
131//!   package. Against the truth it does not help either way: on the 1 × 1 × 2 mm slab the
132//!   package's divergence scores 0.1 points lower NRMSE (65.5 % vs 65.6 %, corr equal). The
133//!   default stays the exact adjoint because that is the operator the TGV problem needs, not
134//!   for a measured gain.
135//!
136//! The phantom cases use QSM-CI's in-silico phantom (QSM Challenge 2.0 head model, 164 × 205 × 205
137//! at 1 mm, 7 T) with its true total field. They were run in the QSM.rs Actions on the CI's data
138//! (QSM.rs#324). The slabs are 48 slices centred on the brain, cut from the field, the mask and
139//! the truth; the 1 × 1 × 2 mm slab averages pairs of 96 slices.
140//!
141//! Optimizations:
142//! - Bounding box reduction: only process the region containing the mask
143//! - Pre-allocated buffers: all temporary arrays allocated once outside the loop
144//!
145//! # Reference pipeline
146//! QuantitativeSusceptibilityMappingTGV.jl, commit 6c0886a:
147//! - Input: one echo's phase in rad, its `TE` and the field strength, plus a brain mask
148//!   (README "Example to run TGV"; `qsm_tgv(phase, mask, res; TE, fieldstrength, ...)`,
149//!   `src/tgv.jl` L10). No unwrapping or background removal beforehand: the Laplacian of the
150//!   phase does both (default `get_laplace_phase_del`; `get_laplace_phase_romeo` is the option
151//!   for an unwrapped phase). χ is scaled by `1/(2π·TE·B0·42.5781)` at the end (L224-228).
152//! - Mask: `erosions = 3` erosions of the given mask with a diamond (6-neighbour) element, then
153//!   one more for the data-term mask `mask0` (L14-20, L218-222). The README's "Masking for QSM"
154//!   points to phase-quality masking (ROMEO voxel quality, `robustmask(romeovoxelquality(...))`)
155//!   combined with a brain mask.
156//! - Resolution and orientation: no resampling; anisotropic voxels and oblique `B0_dir` are
157//!   handled directly (README intro; the 27-point oblique stencil, L160). The default iteration
158//!   count scales with the voxel volume (`get_default_iterations`, L108-115), and the README says
159//!   the defaults "should lead to good results independently of the acquired resolution".
160//!   `dedimensionalize = false` by default.
161//! - Defaults: `regularization = 2` (α₀ = 0.002, α₁ = 0.003), `step_size = 3`,
162//!   `correct_laplacian = true`. The settings that reproduce the original Python version are
163//!   `step_size = 1`, `iterations = 1000`, `alpha = [0.003, 0.001]`, `erosions = 5`,
164//!   `laplacian = get_laplace_phase3`, `correct_laplacian = false` (README).
165//! - Langkammer et al. 2015 (abstract): 3D EPI at 3T and 7T, 1 mm isotropic at 3T.
166
167#[cfg(feature = "parallel")]
168use rayon::prelude::*;
169
170/// One erosion with the 6-neighbour (`strel_diamond`) element. A neighbour outside the array
171/// counts as outside the mask, so a mask that reaches the array's edge is eroded from it (the
172/// default: the Laplacian and gradient stencils have no neighbour there). With `skip_outside`,
173/// neighbours outside the array are skipped instead, as the reference's `erode_mask` does
174/// (`src/tgv.jl:218-222`, 6c0886a, `checkbounds`), so such a mask is not eroded from the edge
175/// ([`TgvParams::reference_edge_erosion`]).
176fn erode_mask(mask: &[u8], nx: usize, ny: usize, nz: usize, skip_outside: bool) -> Vec<u8> {
177    let edge = skip_outside;
178    let nxy = nx * ny;
179    let mut out = vec![0u8; mask.len()];
180    for k in 0..nz {
181        for j in 0..ny {
182            for i in 0..nx {
183                let l = i + j * nx + k * nxy;
184                out[l] = u8::from(
185                    mask[l] != 0
186                        && (if i == 0 { edge } else { mask[l - 1] != 0 })
187                        && (if i + 1 == nx { edge } else { mask[l + 1] != 0 })
188                        && (if j == 0 { edge } else { mask[l - nx] != 0 })
189                        && (if j + 1 == ny { edge } else { mask[l + nx] != 0 })
190                        && (if k == 0 { edge } else { mask[l - nxy] != 0 })
191                        && (if k + 1 == nz { edge } else { mask[l + nxy] != 0 }),
192                );
193            }
194        }
195    }
196    out
197}
198
199/// The divergence of the symmetric tensor `q` in the `w` update, as the reference computes it
200/// (`update_w_kernel!`, `src/tgv_helper.jl:88-105`, 6c0886a; the same in the original Python
201/// `tgv_deconv_update_w`, `qsm_tgv_cython_helper.pyx`): row `d` of the tensor (`(qxx, qxy, qxz)`
202/// for `w_x`, ...) takes its backward differences along x, y and z all scaled by `1/res_d`, the
203/// row's own resolution, not by the resolution of the axis each difference is along. On
204/// isotropic voxels that is the ordinary divergence; on anisotropic voxels it is not the adjoint
205/// of the symmetric gradient used in the `q` update. Used only with
206/// [`TgvParams::reference_divergence`]; the default is the adjoint
207/// ([`crate::utils::gradient::symdiv_inplace_f32`]). See the module docs.
208#[allow(clippy::too_many_arguments)] // the six tensor components, as in symdiv_inplace_f32
209fn symdiv_rows_f32(
210    divx: &mut [f32], divy: &mut [f32], divz: &mut [f32],
211    sxx: &[f32], sxy: &[f32], sxz: &[f32],
212    syy: &[f32], syz: &[f32], szz: &[f32],
213    grid: &crate::Grid,
214) {
215    let (nx, ny, _) = grid.dims;
216    let (r1, r2, r3) = (1.0 / grid.vsx() as f32, 1.0 / grid.vsy() as f32, 1.0 / grid.vsz() as f32);
217    let nxy = nx * ny;
218    // Backward differences with zero flux before the first plane (`div_local`).
219    let d = |a: &[f32], l: usize, has_prev: bool, step: usize| a[l] - if has_prev { a[l - step] } else { 0.0 };
220    maybe_par_chunks_mut!(divx, nxy)
221        .zip(maybe_par_chunks_mut!(divy, nxy))
222        .zip(maybe_par_chunks_mut!(divz, nxy))
223        .enumerate()
224        .for_each(|(k, ((dxs, dys), dzs))| {
225            for j in 0..ny {
226                for i in 0..nx {
227                    let local = i + j * nx;
228                    let l = local + k * nxy;
229                    let (bi, bj, bk) = (i > 0, j > 0, k > 0);
230                    dxs[local] = (d(sxx, l, bi, 1) + d(sxy, l, bj, nx) + d(sxz, l, bk, nxy)) * r1;
231                    dys[local] = (d(sxy, l, bi, 1) + d(syy, l, bj, nx) + d(syz, l, bk, nxy)) * r2;
232                    dzs[local] = (d(sxz, l, bi, 1) + d(syz, l, bj, nx) + d(szz, l, bk, nxy)) * r3;
233                }
234            }
235        });
236}
237
238/// γ/2π in MHz/T, as used by TGV-QSM (Julia and Python reference implementations): the
239/// reference implementation's value, not `crate::constants`. Keep it.
240pub const TGV_GAMMA_MHZ_PER_T: f64 = 42.5781;
241
242/// Phase (rad) per ppm of field at echo time `te_s` (s) and field strength `b0_t` (T):
243/// `2π·γ·B0·TE` with γ/2π = [`TGV_GAMMA_MHZ_PER_T`].
244pub fn rad_per_ppm(te_s: f64, b0_t: f64) -> f64 {
245    2.0 * std::f64::consts::PI * TGV_GAMMA_MHZ_PER_T * b0_t * te_s
246}
247
248/// What TGV-QSM reconstructs from. Both carry the echo time and field strength that turn
249/// the input into phase in rad, the units the reference (and α) work in.
250#[derive(Clone, Copy, Debug)]
251pub enum TgvInput<'a> {
252    /// Unwrapped total (or local) field in ppm, e.g. a multi-echo field map. It is scaled to
253    /// phase at the echo time [`TgvParams::te`] picks from `echo_times`, and its Laplacian is
254    /// the ordinary discrete Laplacian.
255    FieldPpm {
256        /// Field in ppm.
257        field: &'a [f64],
258        /// The scan's echo times in seconds, in acquisition order.
259        echo_times: &'a [f64],
260        /// Field strength in Tesla.
261        b0_t: f64,
262    },
263    /// One wrapped echo in rad (the original single-step TGV-QSM). Its Laplacian is the
264    /// sin/cos (DEL) identity.
265    WrappedPhase {
266        /// Wrapped phase in rad.
267        phase: &'a [f64],
268        /// Echo time of `phase` in seconds.
269        te_s: f64,
270        /// Field strength in Tesla.
271        b0_t: f64,
272    },
273}
274
275impl TgvInput<'_> {
276    fn data(&self) -> &[f64] {
277        match self {
278            TgvInput::FieldPpm { field, .. } => field,
279            TgvInput::WrappedPhase { phase, .. } => phase,
280        }
281    }
282
283    /// The echo time the input is phase at (for a field, the one `te` picks) and B0.
284    fn te_b0(&self, te: crate::EchoTime) -> Result<(f64, f64), String> {
285        match *self {
286            TgvInput::FieldPpm { echo_times, b0_t, .. } => Ok((te.resolve(echo_times)?, b0_t)),
287            TgvInput::WrappedPhase { te_s, b0_t, .. } => Ok((te_s, b0_t)),
288        }
289    }
290}
291
292/// TGV parameters. `alpha0`/`alpha1` are in the reference's rad units (see the module docs).
293#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
294#[derive(Clone, Debug)]
295pub struct TgvParams {
296    /// First-order TGV weight (gradient term), rad units
297    pub alpha1: f32,
298    /// Second-order TGV weight (symmetric gradient term), rad units
299    pub alpha0: f32,
300    /// Number of primal-dual iterations; `None` uses [`get_default_iterations`] for the
301    /// voxel size and `step_size`, as the reference does.
302    pub iterations: Option<usize>,
303    /// Number of mask erosions
304    pub erosions: usize,
305    /// Primal step size multiplier (larger = faster but less stable; 1 is the publication)
306    pub step_size: f32,
307    /// Which echo time a field ([`TgvInput::FieldPpm`]) is converted to phase at; a wrapped
308    /// echo is at its own. Default [`EchoTime::First`](crate::EchoTime::First):
309    /// QuantitativeSusceptibilityMappingTGV.jl takes the phase of one echo and its `TE`
310    /// (`qsm_tgv(phase, mask, res; TE, ...)`, README, af7650b) and has no rule for a field
311    /// combined over echoes, so the first echo time, the default where the reference does not
312    /// say.
313    pub te: crate::EchoTime,
314    /// Use the reference's divergence in the `w` update (default `false`: the exact adjoint of
315    /// the symmetric gradient). The reference scales each row of `q` by that row's own
316    /// resolution; on anisotropic voxels that is not the adjoint, so Chambolle–Pock does not
317    /// solve the stated TGV problem. No effect on isotropic voxels. See the module docs,
318    /// *Where QSM.rs differs from the reference*.
319    pub reference_divergence: bool,
320    /// Erode the mask as the reference does (default `false`: erode it from the array's edge
321    /// too). The reference skips neighbours outside the array, so a mask that reaches an array
322    /// face is not eroded from it, and keeps voxels whose Laplacian and gradient stencils have
323    /// no neighbour there. No effect unless the mask reaches an array face.
324    ///
325    /// With this, [`reference_divergence`](Self::reference_divergence) and
326    /// [`reference_output_box`](Self::reference_output_box), QSM.rs matches the package to
327    /// float32 rounding (`tests/tgv_tgvjl_parity.rs`).
328    pub reference_edge_erosion: bool,
329    /// Return χ over the reference's whole working box (default `false`: inside the eroded
330    /// mask only, zero elsewhere). The reference returns every voxel of the eroded mask's
331    /// bounding box plus one voxel (`reduce_to_mask_box`, `src/tgv.jl` L21, L86-88), so a rim
332    /// of up to two voxels just outside the eroded mask carries the stencils' spill-over, where
333    /// the data term does not act. A comparison with the package inside an uneroded mask
334    /// counts that rim. See the module docs, *Where QSM.rs differs from the reference*.
335    pub reference_output_box: bool,
336}
337
338impl Default for TgvParams {
339    fn default() -> Self {
340        // QuantitativeSusceptibilityMappingTGV.jl defaults: regularization = 2,
341        // erosions = 3, step_size = 3, iterations from the voxel size.
342        let (alpha0, alpha1) = get_default_alpha(2.0);
343        Self {
344            alpha1,
345            alpha0,
346            iterations: None,
347            erosions: 3,
348            step_size: 3.0,
349            te: crate::EchoTime::First,
350            reference_divergence: false,
351            reference_edge_erosion: false,
352            reference_output_box: false,
353        }
354    }
355}
356
357/// Default `(alpha0, alpha1)` for a regularization strength (the reference's
358/// `get_default_alpha`): `max([0.001, 0.001] + [0.001, 0.002]·(regularization − 1), 0)`.
359/// 2 is the default, giving α₀ = 0.002, α₁ = 0.003.
360pub fn get_default_alpha(regularization: f32) -> (f32, f32) {
361    let r = regularization - 1.0;
362    ((0.001 + 0.001 * r).max(0.0), (0.001 + 0.002 * r).max(0.0))
363}
364
365/// Get default number of iterations based on voxel size and step size.
366///
367/// Matches the Julia reference: `max(1000, 3200 / prod(res)^0.42) / step_size^0.6`
368pub fn get_default_iterations(res: (f32, f32, f32), step_size: f32) -> usize {
369    let prod_res = res.0 * res.1 * res.2;
370    let it = (1000.0_f32).max(3200.0 / prod_res.powf(0.42)) / step_size.powf(0.6);
371    it.round() as usize
372}
373
374/// Bounding box for mask region
375#[derive(Clone, Debug)]
376struct BoundingBox {
377    i_min: usize,
378    i_max: usize,
379    j_min: usize,
380    j_max: usize,
381    k_min: usize,
382    k_max: usize,
383}
384
385impl BoundingBox {
386    /// Find minimal bounding box containing the mask with padding
387    fn from_mask(mask: &[u8], nx: usize, ny: usize, nz: usize, padding: usize) -> Self {
388        let mut i_min = nx;
389        let mut i_max = 0;
390        let mut j_min = ny;
391        let mut j_max = 0;
392        let mut k_min = nz;
393        let mut k_max = 0;
394
395        for k in 0..nz {
396            for j in 0..ny {
397                for i in 0..nx {
398                    if mask[i + j * nx + k * nx * ny] != 0 {
399                        i_min = i_min.min(i);
400                        i_max = i_max.max(i);
401                        j_min = j_min.min(j);
402                        j_max = j_max.max(j);
403                        k_min = k_min.min(k);
404                        k_max = k_max.max(k);
405                    }
406                }
407            }
408        }
409
410        // Add padding and clamp to bounds
411        let i_min = i_min.saturating_sub(padding);
412        let j_min = j_min.saturating_sub(padding);
413        let k_min = k_min.saturating_sub(padding);
414        let i_max = (i_max + padding + 1).min(nx);
415        let j_max = (j_max + padding + 1).min(ny);
416        let k_max = (k_max + padding + 1).min(nz);
417
418        Self { i_min, i_max, j_min, j_max, k_min, k_max }
419    }
420
421    fn dims(&self) -> (usize, usize, usize) {
422        (self.i_max - self.i_min, self.j_max - self.j_min, self.k_max - self.k_min)
423    }
424
425    fn total(&self) -> usize {
426        let (bx, by, bz) = self.dims();
427        bx * by * bz
428    }
429}
430
431/// Extract sub-volume from full volume
432fn extract_subvolume<T: Copy + Default>(
433    full: &[T],
434    bbox: &BoundingBox,
435    nx: usize, ny: usize, _nz: usize,
436) -> Vec<T> {
437    let (bx, by, bz) = bbox.dims();
438    let mut sub = vec![T::default(); bx * by * bz];
439
440    for k in 0..bz {
441        for j in 0..by {
442            for i in 0..bx {
443                let full_idx = (bbox.i_min + i) + (bbox.j_min + j) * nx + (bbox.k_min + k) * nx * ny;
444                let sub_idx = i + j * bx + k * bx * by;
445                sub[sub_idx] = full[full_idx];
446            }
447        }
448    }
449    sub
450}
451
452/// Insert sub-volume back into full volume
453fn insert_subvolume<T: Copy>(
454    full: &mut [T],
455    sub: &[T],
456    bbox: &BoundingBox,
457    nx: usize, ny: usize, _nz: usize,
458) {
459    let (bx, by, bz) = bbox.dims();
460
461    for k in 0..bz {
462        for j in 0..by {
463            for i in 0..bx {
464                let full_idx = (bbox.i_min + i) + (bbox.j_min + j) * nx + (bbox.k_min + k) * nx * ny;
465                let sub_idx = i + j * bx + k * bx * by;
466                full[full_idx] = sub[sub_idx];
467            }
468        }
469    }
470}
471
472/// Compute dipole stencil (27-point spatial kernel)
473pub fn compute_dipole_stencil(
474    res: (f32, f32, f32),
475    b0_dir: (f32, f32, f32),
476) -> [[[f32; 3]; 3]; 3] {
477    let (dx, dy, dz) = res;
478    let (bx, by, bz) = b0_dir;
479
480    // Normalize B0 direction
481    let b_norm = (bx * bx + by * by + bz * bz).sqrt();
482    let bx = bx / b_norm;
483    let by = by / b_norm;
484    let bz = bz / b_norm;
485
486    let mut stencil = [[[0.0f32; 3]; 3]; 3];
487
488    let hx2 = 1.0 / (dx * dx);
489    let hy2 = 1.0 / (dy * dy);
490    let hz2 = 1.0 / (dz * dz);
491    let factor = 1.0 / 3.0;
492
493    // Center (i=1, j=1, k=1)
494    stencil[1][1][1] = -2.0 * (hx2 + hy2 + hz2) * factor
495                     + 2.0 * (bx * bx * hx2 + by * by * hy2 + bz * bz * hz2);
496
497    // X neighbors
498    stencil[0][1][1] = hx2 * factor - bx * bx * hx2;
499    stencil[2][1][1] = hx2 * factor - bx * bx * hx2;
500
501    // Y neighbors
502    stencil[1][0][1] = hy2 * factor - by * by * hy2;
503    stencil[1][2][1] = hy2 * factor - by * by * hy2;
504
505    // Z neighbors
506    stencil[1][1][0] = hz2 * factor - bz * bz * hz2;
507    stencil[1][1][2] = hz2 * factor - bz * bz * hz2;
508
509    // Cross terms for oblique B0
510    let hxy = 1.0 / (dx * dy);
511    let hxz = 1.0 / (dx * dz);
512    let hyz = 1.0 / (dy * dz);
513
514    let xy_factor = -bx * by * hxy;
515    stencil[0][0][1] = xy_factor;
516    stencil[2][2][1] = xy_factor;
517    stencil[0][2][1] = -xy_factor;
518    stencil[2][0][1] = -xy_factor;
519
520    let xz_factor = -bx * bz * hxz;
521    stencil[0][1][0] = xz_factor;
522    stencil[2][1][2] = xz_factor;
523    stencil[0][1][2] = -xz_factor;
524    stencil[2][1][0] = -xz_factor;
525
526    let yz_factor = -by * bz * hyz;
527    stencil[1][0][0] = yz_factor;
528    stencil[1][2][2] = yz_factor;
529    stencil[1][0][2] = -yz_factor;
530    stencil[1][2][0] = -yz_factor;
531
532    stencil
533}
534
535/// Compute SVD-fitted oblique 27-point dipole stencil via DST-based Poisson solves.
536///
537/// Matches the Julia reference implementation from QuantitativeSusceptibilityMappingTGV.jl.
538/// For each unique centrosymmetric pair of the 26 off-center stencil positions, a Poisson
539/// equation is solved using DST-I to obtain Green's function values. These are then fitted
540/// to the analytical dipole field via least squares, and resolution-weighted to produce
541/// the final stencil.
542///
543/// Note: The delta function for offsets (di,dj,dk) and (-di,-dj,-dk) are identical
544/// (both place +1 at mid±I and -2 at mid), so we only compute 13 unique Poisson
545/// solutions and solve a full-rank 13×13 system. This avoids the rank deficiency
546/// that would occur with all 26 positions.
547pub fn compute_oblique_stencil(
548    res: (f32, f32, f32),
549    b0_dir: (f32, f32, f32),
550) -> [[[f32; 3]; 3]; 3] {
551    let n: usize = 64;
552    let singularity_cutout = 4.0_f64;
553    let mid = n / 2; // 32 (0-indexed center)
554    let n2 = n * n;
555    let n3 = n * n * n;
556
557    let (dx, dy, dz) = (res.0 as f64, res.1 as f64, res.2 as f64);
558
559    // Normalize B0 direction
560    let b_norm = ((b0_dir.0 * b0_dir.0 + b0_dir.1 * b0_dir.1 + b0_dir.2 * b0_dir.2) as f64).sqrt();
561    let bdir = (b0_dir.0 as f64 / b_norm, b0_dir.1 as f64 / b_norm, b0_dir.2 as f64 / b_norm);
562
563    // Compute dipole field on 64³ grid
564    let mut d = vec![f64::NAN; n3];
565    let mut d_mask = vec![false; n3];
566
567    for k in 0..n {
568        for j in 0..n {
569            for i in 0..n {
570                let x = i as f64 - mid as f64;
571                let y = j as f64 - mid as f64;
572                let z = k as f64 - mid as f64;
573                let r = (x * x + y * y + z * z).sqrt();
574
575                let idx = i + j * n + k * n2;
576                if r < singularity_cutout {
577                    // d[idx] remains NAN, d_mask[idx] remains false
578                } else {
579                    let xz = (bdir.0 * x + bdir.1 * y + bdir.2 * z) / r;
580                    let kappa = (3.0 * xz * xz - 1.0) / (4.0 * std::f64::consts::PI * r * r * r);
581                    d[idx] = kappa;
582                    d_mask[idx] = true;
583                }
584            }
585        }
586    }
587
588    // DST-I eigenvalue components: coord2[k] = 2 * sin(π*(k+1) / (2*(N+1)))
589    let coord2_sq: Vec<f64> = (0..n)
590        .map(|k| {
591            let v = 2.0 * (std::f64::consts::PI * (k as f64 + 1.0) / (2.0 * (n as f64 + 1.0))).sin();
592            v * v
593        })
594        .collect();
595
596    // 3D eigenvalue grid: coord2_grid[i,j,k] = coord2[i]² + coord2[j]² + coord2[k]²
597    let mut coord2_grid = vec![0.0_f64; n3];
598    for k in 0..n {
599        for j in 0..n {
600            for i in 0..n {
601                coord2_grid[i + j * n + k * n2] = coord2_sq[i] + coord2_sq[j] + coord2_sq[k];
602            }
603        }
604    }
605
606    let dst = rustdct::DctPlanner::<f64>::new().plan_dst1(n);
607    // Inverse DST-I scale: our DST computes Σ x[n]*sin(...) (no factor of 2),
608    // so the inverse is (2/(N+1))^3 for 3D (unlike FFTW which uses 1/(2*(N+1))^3).
609    let idst_scale = (2.0 / (n as f64 + 1.0)).powi(3);
610
611    // Enumerate all 26 stencil positions (excluding center), column-major order
612    let mut stencil_positions: Vec<(i32, i32, i32)> = Vec::with_capacity(26);
613    for dk in -1..=1_i32 {
614        for dj in -1..=1_i32 {
615            for di in -1..=1_i32 {
616                if di == 0 && dj == 0 && dk == 0 {
617                    continue;
618                }
619                stencil_positions.push((di, dj, dk));
620            }
621        }
622    }
623
624    // Collect valid dipole point indices
625    let valid_indices: Vec<usize> = d_mask
626        .iter()
627        .enumerate()
628        .filter(|(_, &m)| m)
629        .map(|(idx, _)| idx)
630        .collect();
631
632    // Exploit centrosymmetry: delta for (di,dj,dk) is identical to (-di,-dj,-dk),
633    // so only compute 13 unique Poisson solutions (one per centrosymmetric pair).
634    // Pair p corresponds to stencil_positions[p] and stencil_positions[25-p].
635    let num_pairs = 13;
636    let sin_table = dst_sin_table(n);
637    let pairs: Vec<usize> = (0..num_pairs).collect();
638    let a_rows: Vec<Vec<f64>> = maybe_par_iter!(pairs).map(|&p| {
639        let (di, dj, dk) = stencil_positions[p];
640
641        // Delta function: 1 at mid+I and at mid-I, -2 at mid. Its forward 3D DST-I is the
642        // weighted sum of the separable transforms of its three points, written down directly.
643        let at = |o: i32| (mid as i32 + o) as usize;
644        let points = [
645            ((at(di), at(dj), at(dk)), 1.0),
646            ((at(-di), at(-dj), at(-dk)), 1.0),
647            ((mid, mid, mid), -2.0),
648        ];
649        let mut fdelta = vec![0.0_f64; n3];
650        for &((a, b, c), w) in &points {
651            for k in 0..n {
652                let sk = w * sin_table[c][k];
653                for j in 0..n {
654                    let sjk = sk * sin_table[b][j];
655                    let base = j * n + k * n2;
656                    for (i, v) in fdelta[base..base + n].iter_mut().enumerate() {
657                        *v += sjk * sin_table[a][i];
658                    }
659                }
660            }
661        }
662
663        // Poisson solve: divide by -coord2_grid
664        for idx in 0..n3 {
665            fdelta[idx] /= -coord2_grid[idx];
666        }
667
668        // Inverse 3D DST-I (= forward DST-I * scale)
669        let vdelta = dst3d(&fdelta, n, dst.as_ref());
670
671        // Extract values at valid dipole points, applying inverse scale
672        valid_indices.iter().map(|&idx| vdelta[idx] * idst_scale).collect()
673    }).collect();
674
675    // Extract dipole values at valid points
676    let d_valid: Vec<f64> = valid_indices.iter().map(|&idx| d[idx]).collect();
677
678    // Solve least squares: B^T * y ≈ d_valid via normal equations (13 unknowns, full rank)
679    // G = B * B^T (13×13), h = B * d_valid (13×1)
680    let mut g = vec![vec![0.0_f64; num_pairs]; num_pairs];
681    for i in 0..num_pairs {
682        for j in 0..=i {
683            let dot: f64 = a_rows[i]
684                .iter()
685                .zip(a_rows[j].iter())
686                .map(|(&a, &b)| a * b)
687                .sum();
688            g[i][j] = dot;
689            g[j][i] = dot;
690        }
691    }
692
693    let mut h = vec![0.0_f64; num_pairs];
694    for i in 0..num_pairs {
695        h[i] = a_rows[i]
696            .iter()
697            .zip(d_valid.iter())
698            .map(|(&a, &b)| a * b)
699            .sum();
700    }
701
702    // Solve G * y = h via eigendecomposition with thresholding (handles rank deficiency
703    // from centrosymmetry and other spatial symmetries like x-y equivalence)
704    let y = solve_symmetric_pseudoinverse(&g, &h);
705
706    // Assemble stencil: assign each pair's coefficient to both positions
707    // y[p] = x[p] + x[25-p] = 2*x[p] (by symmetry), so stencil[I] = 2*x[I] = y[p]
708    let mut result = [[[0.0f32; 3]; 3]; 3];
709    for p in 0..num_pairs {
710        let coeff = y[p] as f32;
711
712        // First member of pair
713        let (di, dj, dk) = stencil_positions[p];
714        result[(di + 1) as usize][(dj + 1) as usize][(dk + 1) as usize] = coeff;
715
716        // Second member (centrosymmetric partner)
717        let (di2, dj2, dk2) = stencil_positions[25 - p];
718        result[(di2 + 1) as usize][(dj2 + 1) as usize][(dk2 + 1) as usize] = coeff;
719    }
720
721    // Apply resolution weights: w = (i²/dx² + j²/dy² + k²/dz²) / (i² + j² + k²)
722    for dk in -1..=1_i32 {
723        for dj in -1..=1_i32 {
724            for di in -1..=1_i32 {
725                if di == 0 && dj == 0 && dk == 0 {
726                    continue;
727                }
728                let si = (di + 1) as usize;
729                let sj = (dj + 1) as usize;
730                let sk = (dk + 1) as usize;
731
732                let i2 = (di * di) as f64;
733                let j2 = (dj * dj) as f64;
734                let k2 = (dk * dk) as f64;
735                let weight = (i2 / (dx * dx) + j2 / (dy * dy) + k2 / (dz * dz)) / (i2 + j2 + k2);
736                result[si][sj][sk] *= weight as f32;
737            }
738        }
739    }
740
741    // Center = -sum(all others)
742    let mut total = 0.0f32;
743    for dk in 0..3 {
744        for dj in 0..3 {
745            for di in 0..3 {
746                if !(di == 1 && dj == 1 && dk == 1) {
747                    total += result[di][dj][dk];
748                }
749            }
750        }
751    }
752    result[1][1][1] = -total;
753
754    result
755}
756
757/// 3D separable DST-I, unnormalised: X[k] = Σ x[j]·sin(π·(j+1)·(k+1)/(N+1)) along each axis.
758fn dst3d(input: &[f64], n: usize, dst: &dyn rustdct::Dst1<f64>) -> Vec<f64> {
759    let n2 = n * n;
760    let mut data = input.to_vec();
761    let mut scratch = vec![0.0_f64; dst.get_scratch_len()];
762    let mut line = vec![0.0_f64; n];
763    // rustdct's FFT-based DST-I takes the zero padding of its FFT input from the scratch
764    // buffer, so the scratch is cleared before every line.
765    let mut dst1 = |buf: &mut [f64]| {
766        scratch.fill(0.0);
767        dst.process_dst1_with_scratch(buf, &mut scratch);
768    };
769    for row in data.chunks_mut(n) {
770        dst1(row);
771    }
772    // y lines start at i + k·n² with stride n; z lines start at i + j·n with stride n².
773    for (stride, outer_step) in [(n, n2), (n2, n)] {
774        for a in 0..n {
775            for i in 0..n {
776                let base = i + a * outer_step;
777                for t in 0..n {
778                    line[t] = data[base + t * stride];
779                }
780                dst1(&mut line);
781                for t in 0..n {
782                    data[base + t * stride] = line[t];
783                }
784            }
785        }
786    }
787    data
788}
789
790/// Build sin table for DST-I of given length.
791/// sin_table[n][k] = sin(π*(n+1)*(k+1)/(N+1))
792fn dst_sin_table(n: usize) -> Vec<Vec<f64>> {
793    let scale = std::f64::consts::PI / (n as f64 + 1.0);
794    let mut table = vec![vec![0.0_f64; n]; n];
795    for j in 0..n {
796        for k in 0..n {
797            table[j][k] = ((j as f64 + 1.0) * (k as f64 + 1.0) * scale).sin();
798        }
799    }
800    table
801}
802
803/// 3D separable DST-I by the direct O(N⁴) sum, the reference [`dst3d`] is checked against.
804#[cfg(test)]
805fn dst3d_direct(input: &[f64], n: usize, sin_table: &[Vec<f64>]) -> Vec<f64> {
806    let n2 = n * n;
807    let mut data = input.to_vec();
808    let mut buf_in = vec![0.0_f64; n];
809    let mut buf_out = vec![0.0_f64; n];
810
811    // Transform along x (contiguous dimension)
812    for k in 0..n {
813        for j in 0..n {
814            let base = j * n + k * n2;
815            buf_in.copy_from_slice(&data[base..base + n]);
816            dst1(&buf_in, sin_table, &mut buf_out);
817            data[base..base + n].copy_from_slice(&buf_out);
818        }
819    }
820
821    // Transform along y (stride = n)
822    for k in 0..n {
823        for i in 0..n {
824            for j in 0..n {
825                buf_in[j] = data[i + j * n + k * n2];
826            }
827            dst1(&buf_in, sin_table, &mut buf_out);
828            for j in 0..n {
829                data[i + j * n + k * n2] = buf_out[j];
830            }
831        }
832    }
833
834    // Transform along z (stride = n*n)
835    for j in 0..n {
836        for i in 0..n {
837            for k in 0..n {
838                buf_in[k] = data[i + j * n + k * n2];
839            }
840            dst1(&buf_in, sin_table, &mut buf_out);
841            for k in 0..n {
842                data[i + j * n + k * n2] = buf_out[k];
843            }
844        }
845    }
846
847    data
848}
849
850#[cfg(test)]
851/// 1D DST-I: X[k] = Σ x[n] * sin(π*(n+1)*(k+1)/(N+1))
852fn dst1(input: &[f64], sin_table: &[Vec<f64>], output: &mut [f64]) {
853    let n = input.len();
854    for k in 0..n {
855        let mut sum = 0.0_f64;
856        for j in 0..n {
857            sum += input[j] * sin_table[j][k];
858        }
859        output[k] = sum;
860    }
861}
862
863/// Solve a symmetric positive semi-definite system via eigendecomposition with thresholding.
864///
865/// Computes the pseudo-inverse solution: x = V * diag(1/λ, thresholded) * V^T * h
866/// where G = V Λ V^T is the eigendecomposition. Small eigenvalues (< threshold * max_eigenvalue)
867/// are zeroed, matching Julia's SVD-based approach for handling rank-deficient systems.
868fn solve_symmetric_pseudoinverse(g: &[Vec<f64>], h: &[f64]) -> Vec<f64> {
869    let n = h.len();
870
871    // Copy g for eigendecomposition (Jacobi method modifies in place)
872    let mut a: Vec<Vec<f64>> = g.to_vec();
873    let mut v = vec![vec![0.0_f64; n]; n];
874    for i in 0..n {
875        v[i][i] = 1.0;
876    }
877
878    // Jacobi eigendecomposition for symmetric matrices
879    let max_sweeps = 100;
880    let tol = 1e-15;
881
882    for _ in 0..max_sweeps {
883        // Find largest off-diagonal element
884        let mut max_off = 0.0_f64;
885        for i in 0..n {
886            for j in (i + 1)..n {
887                max_off = max_off.max(a[i][j].abs());
888            }
889        }
890        if max_off < tol {
891            break;
892        }
893
894        // Sweep all off-diagonal pairs
895        for p in 0..n {
896            for q in (p + 1)..n {
897                if a[p][q].abs() < tol {
898                    continue;
899                }
900
901                // Compute Givens rotation angle to zero a[p][q]
902                let app = a[p][p];
903                let aqq = a[q][q];
904                let apq = a[p][q];
905                let tau = (aqq - app) / (2.0 * apq);
906                let t = if tau >= 0.0 {
907                    1.0 / (tau + (1.0 + tau * tau).sqrt())
908                } else {
909                    -1.0 / (-tau + (1.0 + tau * tau).sqrt())
910                };
911                let c = 1.0 / (1.0 + t * t).sqrt();
912                let s = t * c;
913
914                // Update matrix A' = G^T A G (only rows/cols p, q change)
915                // First update off-diagonal rows
916                for i in 0..n {
917                    if i == p || i == q {
918                        continue;
919                    }
920                    let aip = a[i][p];
921                    let aiq = a[i][q];
922                    a[i][p] = c * aip - s * aiq;
923                    a[p][i] = a[i][p];
924                    a[i][q] = s * aip + c * aiq;
925                    a[q][i] = a[i][q];
926                }
927
928                // Update 2×2 block
929                a[p][p] = c * c * app - 2.0 * c * s * apq + s * s * aqq;
930                a[q][q] = s * s * app + 2.0 * c * s * apq + c * c * aqq;
931                a[p][q] = 0.0;
932                a[q][p] = 0.0;
933
934                // Accumulate eigenvectors: V' = V * G
935                for i in 0..n {
936                    let vip = v[i][p];
937                    let viq = v[i][q];
938                    v[i][p] = c * vip - s * viq;
939                    v[i][q] = s * vip + c * viq;
940                }
941            }
942        }
943    }
944
945    // Eigenvalues are on diagonal
946    let eigenvalues: Vec<f64> = (0..n).map(|i| a[i][i]).collect();
947    let max_eigen = eigenvalues.iter().cloned().fold(0.0_f64, |a, b| a.max(b.abs()));
948    let threshold = 1e-10 * max_eigen;
949
950    // Solve: x = V * diag(1/λ_thresholded) * V^T * h
951    let mut vt_h = vec![0.0_f64; n];
952    for i in 0..n {
953        for j in 0..n {
954            vt_h[i] += v[j][i] * h[j];
955        }
956    }
957
958    for i in 0..n {
959        if eigenvalues[i].abs() > threshold {
960            vt_h[i] /= eigenvalues[i];
961        } else {
962            vt_h[i] = 0.0;
963        }
964    }
965
966    let mut x = vec![0.0_f64; n];
967    for i in 0..n {
968        for j in 0..n {
969            x[i] += v[i][j] * vt_h[j];
970        }
971    }
972
973    x
974}
975
976/// Apply dipole stencil to a 3D volume
977/// Uses Neumann BC at boundaries (matching Julia's wave_local)
978///
979/// With `mask`, the output is zero outside it; without, it is computed at every voxel (the
980/// reference's `wave_local`, zero only on the box boundary).
981fn apply_stencil(
982    output: &mut [f32],
983    input: &[f32],
984    stencil: &[[[f32; 3]; 3]; 3],
985    mask: Option<&[u8]>,
986    nx: usize, ny: usize, nz: usize,
987) {
988    let nxy = nx * ny;
989    // One rayon task per z-slab (disjoint output chunk); reads input by global index.
990    maybe_par_chunks_mut!(output, nxy).enumerate().for_each(|(k, out_slab)| {
991        for j in 0..ny {
992            for i in 0..nx {
993                let local = i + j * nx;
994                let idx = local + k * nxy;
995
996                if mask.is_some_and(|m| m[idx] == 0) {
997                    out_slab[local] = 0.0;
998                    continue;
999                }
1000
1001                // Julia's wave_local only computes if not at boundary
1002                // If at boundary, result is 0
1003                if i == 0 || j == 0 || k == 0 || i + 1 >= nx || j + 1 >= ny || k + 1 >= nz {
1004                    out_slab[local] = 0.0;
1005                    continue;
1006                }
1007
1008                let mut sum = 0.0f32;
1009
1010                for dk in 0..3i32 {
1011                    for dj in 0..3i32 {
1012                        for di in 0..3i32 {
1013                            let ni = (i as i32 + di - 1) as usize;
1014                            let nj = (j as i32 + dj - 1) as usize;
1015                            let nk = (k as i32 + dk - 1) as usize;
1016
1017                            let nidx = ni + nj * nx + nk * nxy;
1018                            sum += stencil[di as usize][dj as usize][dk as usize] * input[nidx];
1019                        }
1020                    }
1021                }
1022
1023                out_slab[local] = sum;
1024            }
1025        }
1026    });
1027}
1028
1029/// Laplacian of an unwrapped field (any units), zero outside `mask`.
1030///
1031/// Ordinary 7-point discrete Laplacian with replicated boundaries. This is what
1032/// [`TgvInput::FieldPpm`] uses: unlike [`compute_phase_laplacian`] it is linear, so it is
1033/// exact for fields whose neighbour differences are not small compared with 1.
1034pub fn compute_field_laplacian(
1035    field: &[f32],
1036    mask: &[u8],
1037    nx: usize, ny: usize, nz: usize,
1038    vsx: f32, vsy: f32, vsz: f32,
1039) -> Vec<f32> {
1040    let mut lap = compute_laplacian(field, nx, ny, nz, vsx, vsy, vsz);
1041    for (v, &m) in lap.iter_mut().zip(mask.iter()) {
1042        if m == 0 {
1043            *v = 0.0;
1044        }
1045    }
1046    lap
1047}
1048
1049/// Compute Laplacian of wrapped phase (rad) using the DEL method.
1050///
1051/// `cos φ·∇²sin φ − sin φ·∇²cos φ`, which on the discrete grid is
1052/// `Σ_neighbours sin(φ_n − φ)/h²`: insensitive to 2π wraps, and equal to the ordinary
1053/// Laplacian only while neighbour differences are small. Only meaningful for phase in rad.
1054pub fn compute_phase_laplacian(
1055    phase: &[f32],
1056    mask: &[u8],
1057    nx: usize, ny: usize, nz: usize,
1058    vsx: f32, vsy: f32, vsz: f32,
1059) -> Vec<f32> {
1060    let n_total = nx * ny * nz;
1061
1062    let sin_phase: Vec<f32> = phase.iter().map(|&p| p.sin()).collect();
1063    let cos_phase: Vec<f32> = phase.iter().map(|&p| p.cos()).collect();
1064
1065    let lap_sin = compute_laplacian(&sin_phase, nx, ny, nz, vsx, vsy, vsz);
1066    let lap_cos = compute_laplacian(&cos_phase, nx, ny, nz, vsx, vsy, vsz);
1067
1068    let mut laplacian = vec![0.0f32; n_total];
1069    for i in 0..n_total {
1070        if mask[i] != 0 {
1071            laplacian[i] = lap_sin[i] * cos_phase[i] - lap_cos[i] * sin_phase[i];
1072        }
1073    }
1074
1075    laplacian
1076}
1077
1078/// Compute discrete Laplacian of a 3D array
1079fn compute_laplacian(
1080    input: &[f32],
1081    nx: usize, ny: usize, nz: usize,
1082    vsx: f32, vsy: f32, vsz: f32,
1083) -> Vec<f32> {
1084    let n_total = nx * ny * nz;
1085    let mut output = vec![0.0f32; n_total];
1086
1087    let hx2 = 1.0 / (vsx * vsx);
1088    let hy2 = 1.0 / (vsy * vsy);
1089    let hz2 = 1.0 / (vsz * vsz);
1090    let center = -2.0 * (hx2 + hy2 + hz2);
1091
1092    for k in 0..nz {
1093        let km1 = if k == 0 { 0 } else { k - 1 };
1094        let kp1 = if k + 1 >= nz { nz - 1 } else { k + 1 };
1095
1096        for j in 0..ny {
1097            let jm1 = if j == 0 { 0 } else { j - 1 };
1098            let jp1 = if j + 1 >= ny { ny - 1 } else { j + 1 };
1099
1100            for i in 0..nx {
1101                let im1 = if i == 0 { 0 } else { i - 1 };
1102                let ip1 = if i + 1 >= nx { nx - 1 } else { i + 1 };
1103
1104                let idx = i + j * nx + k * nx * ny;
1105
1106                output[idx] = center * input[idx]
1107                    + hx2 * (input[im1 + j * nx + k * nx * ny] + input[ip1 + j * nx + k * nx * ny])
1108                    + hy2 * (input[i + jm1 * nx + k * nx * ny] + input[i + jp1 * nx + k * nx * ny])
1109                    + hz2 * (input[i + j * nx + km1 * nx * ny] + input[i + j * nx + kp1 * nx * ny]);
1110            }
1111        }
1112    }
1113
1114    output
1115}
1116
1117/// Apply the Laplacian (Neumann boundary). With `mask`, the output is zero outside it;
1118/// without, it is computed at every voxel.
1119fn apply_laplacian_inplace(
1120    output: &mut [f32],
1121    input: &[f32],
1122    mask: Option<&[u8]>,
1123    nx: usize, ny: usize, nz: usize,
1124    vsx: f32, vsy: f32, vsz: f32,
1125) {
1126    let hx2 = 1.0 / (vsx * vsx);
1127    let hy2 = 1.0 / (vsy * vsy);
1128    let hz2 = 1.0 / (vsz * vsz);
1129    let nxy = nx * ny;
1130
1131    maybe_par_chunks_mut!(output, nxy).enumerate().for_each(|(k, out_slab)| {
1132        let k_offset = k * nxy;
1133
1134        for j in 0..ny {
1135            let j_offset = j * nx;
1136
1137            for i in 0..nx {
1138                let local = i + j_offset;
1139                let idx = local + k_offset;
1140
1141                if mask.is_some_and(|m| m[idx] == 0) {
1142                    out_slab[local] = 0.0;
1143                    continue;
1144                }
1145
1146                let a0 = input[idx];
1147
1148                // Neumann BC: use center value at boundary (matching Julia)
1149                let a_xm = if i > 0 { input[(i - 1) + j_offset + k_offset] } else { a0 };
1150                let a_xp = if i + 1 < nx { input[(i + 1) + j_offset + k_offset] } else { a0 };
1151                let a_ym = if j > 0 { input[i + (j - 1) * nx + k_offset] } else { a0 };
1152                let a_yp = if j + 1 < ny { input[i + (j + 1) * nx + k_offset] } else { a0 };
1153                let a_zm = if k > 0 { input[i + j_offset + (k - 1) * nxy] } else { a0 };
1154                let a_zp = if k + 1 < nz { input[i + j_offset + (k + 1) * nxy] } else { a0 };
1155
1156                // Laplacian: sum of second derivatives
1157                out_slab[local] = hx2 * (a_xm - 2.0 * a0 + a_xp)
1158                            + hy2 * (a_ym - 2.0 * a0 + a_yp)
1159                            + hz2 * (a_zm - 2.0 * a0 + a_zp);
1160            }
1161        }
1162    });
1163}
1164
1165
1166/// Compute gradient norm squared
1167#[inline]
1168fn grad_norm_sq(res: (f32, f32, f32)) -> f32 {
1169    let (dx, dy, dz) = res;
1170    4.0 * (1.0 / (dx * dx) + 1.0 / (dy * dy) + 1.0 / (dz * dz))
1171}
1172
1173/// Compute the squared spectral norm of the TGV operator matrix via power iteration
1174///
1175/// The operator matrix M is:
1176/// [0,   g,  1 ]
1177/// [0,   0,  g ]
1178/// [g², w,  0 ]
1179///
1180/// We compute the largest eigenvalue of M^T * M using power iteration.
1181fn compute_operator_norm_sqr(g: f32, g2: f32, w: f32) -> f32 {
1182    // M^T * M = [g⁴,   g²w,    0    ]
1183    //           [g²w,  g²+w²,  g    ]
1184    //           [0,    g,      g²+1 ]
1185    let g4 = g2 * g2;
1186    let g2w = g2 * w;
1187    let g2_w2 = g2 + w * w;
1188    let g2_1 = g2 + 1.0;
1189
1190    // Power iteration to find largest eigenvalue
1191    let mut v = [1.0f32, 1.0, 1.0];
1192
1193    for _ in 0..20 {
1194        // Matrix-vector multiply: y = (M^T * M) * v
1195        let y0 = g4 * v[0] + g2w * v[1];
1196        let y1 = g2w * v[0] + g2_w2 * v[1] + g * v[2];
1197        let y2 = g * v[1] + g2_1 * v[2];
1198
1199        // Compute norm
1200        let norm = (y0 * y0 + y1 * y1 + y2 * y2).sqrt();
1201        if norm < 1e-10 {
1202            break;
1203        }
1204
1205        // Normalize
1206        v[0] = y0 / norm;
1207        v[1] = y1 / norm;
1208        v[2] = y2 / norm;
1209    }
1210
1211    // Rayleigh quotient: eigenvalue = v^T * (M^T * M) * v
1212    let y0 = g4 * v[0] + g2w * v[1];
1213    let y1 = g2w * v[0] + g2_w2 * v[1] + g * v[2];
1214    let y2 = g * v[1] + g2_1 * v[2];
1215
1216    v[0] * y0 + v[1] * y1 + v[2] * y2
1217}
1218
1219/// L2 norm of 3-component vector
1220#[inline]
1221fn norm3(x: f32, y: f32, z: f32) -> f32 {
1222    (x * x + y * y + z * z).sqrt()
1223}
1224
1225/// Frobenius norm of symmetric 3x3 tensor (6 components)
1226#[inline]
1227fn frobenius_norm(sxx: f32, sxy: f32, sxz: f32, syy: f32, syz: f32, szz: f32) -> f32 {
1228    (sxx * sxx + syy * syy + szz * szz + 2.0 * (sxy * sxy + sxz * sxz + syz * syz)).sqrt()
1229}
1230
1231/// L-infinity projection for 3-component vector
1232#[inline]
1233fn project_linf3(px: &mut f32, py: &mut f32, pz: &mut f32, threshold: f32) {
1234    let norm = norm3(*px, *py, *pz);
1235    if norm > threshold {
1236        let scale = threshold / norm;
1237        *px *= scale;
1238        *py *= scale;
1239        *pz *= scale;
1240    }
1241}
1242
1243/// L-infinity projection for 6-component symmetric tensor
1244#[inline]
1245fn project_linf6(
1246    qxx: &mut f32, qxy: &mut f32, qxz: &mut f32,
1247    qyy: &mut f32, qyz: &mut f32, qzz: &mut f32,
1248    threshold: f32,
1249) {
1250    let norm = frobenius_norm(*qxx, *qxy, *qxz, *qyy, *qyz, *qzz);
1251    if norm > threshold {
1252        let scale = threshold / norm;
1253        *qxx *= scale;
1254        *qxy *= scale;
1255        *qxz *= scale;
1256        *qyy *= scale;
1257        *qyz *= scale;
1258        *qzz *= scale;
1259    }
1260}
1261
1262/// Pre-allocated workspace for TGV iteration
1263struct TgvWorkspace {
1264    // Primal variables
1265    chi: Vec<f32>,
1266    chi_: Vec<f32>,
1267    phi: Vec<f32>,
1268    phi_: Vec<f32>,
1269    wx: Vec<f32>,
1270    wy: Vec<f32>,
1271    wz: Vec<f32>,
1272    wx_: Vec<f32>,
1273    wy_: Vec<f32>,
1274    wz_: Vec<f32>,
1275
1276    // Dual variables
1277    eta: Vec<f32>,
1278    px: Vec<f32>,
1279    py: Vec<f32>,
1280    pz: Vec<f32>,
1281    qxx: Vec<f32>,
1282    qxy: Vec<f32>,
1283    qxz: Vec<f32>,
1284    qyy: Vec<f32>,
1285    qyz: Vec<f32>,
1286    qzz: Vec<f32>,
1287
1288    // Temporary buffers
1289    temp1: Vec<f32>,
1290    temp2: Vec<f32>,
1291    gx: Vec<f32>,
1292    gy: Vec<f32>,
1293    gz: Vec<f32>,
1294
1295    // Symmetric gradient buffers (reused)
1296    sxx: Vec<f32>,
1297    sxy: Vec<f32>,
1298    sxz: Vec<f32>,
1299    syy: Vec<f32>,
1300    syz: Vec<f32>,
1301    szz: Vec<f32>,
1302
1303    // Divergence buffers (reused)
1304    divqx: Vec<f32>,
1305    divqy: Vec<f32>,
1306    divqz: Vec<f32>,
1307}
1308
1309impl TgvWorkspace {
1310    fn new(n: usize) -> Self {
1311        Self {
1312            chi: vec![0.0; n],
1313            chi_: vec![0.0; n],
1314            phi: vec![0.0; n],
1315            phi_: vec![0.0; n],
1316            wx: vec![0.0; n],
1317            wy: vec![0.0; n],
1318            wz: vec![0.0; n],
1319            wx_: vec![0.0; n],
1320            wy_: vec![0.0; n],
1321            wz_: vec![0.0; n],
1322            eta: vec![0.0; n],
1323            px: vec![0.0; n],
1324            py: vec![0.0; n],
1325            pz: vec![0.0; n],
1326            qxx: vec![0.0; n],
1327            qxy: vec![0.0; n],
1328            qxz: vec![0.0; n],
1329            qyy: vec![0.0; n],
1330            qyz: vec![0.0; n],
1331            qzz: vec![0.0; n],
1332            temp1: vec![0.0; n],
1333            temp2: vec![0.0; n],
1334            gx: vec![0.0; n],
1335            gy: vec![0.0; n],
1336            gz: vec![0.0; n],
1337            sxx: vec![0.0; n],
1338            sxy: vec![0.0; n],
1339            sxz: vec![0.0; n],
1340            syy: vec![0.0; n],
1341            syz: vec![0.0; n],
1342            szz: vec![0.0; n],
1343            divqx: vec![0.0; n],
1344            divqy: vec![0.0; n],
1345            divqz: vec![0.0; n],
1346        }
1347    }
1348}
1349
1350/// Main TGV-QSM reconstruction
1351///
1352/// # Arguments
1353/// * `input` - Field in ppm or one wrapped echo in rad (see [`TgvInput`])
1354/// * `mask` - Binary mask
1355/// * `grid` - Volume grid (dimensions and voxel sizes)
1356/// * `params` - TGV parameters (α in the reference's rad units)
1357/// * `b0_dir` - B0 field direction
1358/// * `progress` - Progress callback `(iteration, total_iterations)`
1359///
1360/// Returns χ in ppm.
1361///
1362/// # Errors
1363/// If the input or mask length does not match `grid`, if the echo time (`te_s`, or the one
1364/// [`TgvParams::te`] picks from `echo_times`) or `b0_t` is not positive and finite, or if the
1365/// mask is empty after `params.erosions` erosions.
1366///
1367/// The reconstruction runs in single precision internally; inputs and
1368/// outputs use `f64` to match the rest of the crate's API.
1369pub fn tgv_qsm(
1370    input: TgvInput,
1371    mask: &[u8],
1372    grid: &crate::Grid,
1373    params: &TgvParams,
1374    b0_dir: (f64, f64, f64),
1375    mut progress: impl FnMut(usize, usize),
1376) -> Result<Vec<f64>, crate::error::InputError> {
1377    use crate::error::{check_len, InputError};
1378    let n_total = grid.n_total();
1379    check_len("TGV input", input.data().len(), n_total)?;
1380    check_len("TGV mask", mask.len(), n_total)?;
1381    let (te_s, b0_t) = input.te_b0(params.te).map_err(|e| InputError(format!("TGV: {e}")))?;
1382    if !(te_s.is_finite() && te_s > 0.0 && b0_t.is_finite() && b0_t > 0.0) {
1383        return Err(InputError(format!(
1384            "TGV needs te_s > 0 and b0_t > 0, got TE = {te_s} s, B0 = {b0_t} T"
1385        )));
1386    }
1387    // rad per ppm at this TE and B0: the input becomes phase in rad (the reference's units)
1388    // first, and χ is divided by it last (the reference's `scale`).
1389    let c = rad_per_ppm(te_s, b0_t);
1390
1391    // The algorithm runs in single precision; convert the f64 boundary inputs.
1392    let data_f32: Vec<f32> = match input {
1393        TgvInput::FieldPpm { field, .. } => field.iter().map(|&v| (v * c) as f32).collect(),
1394        TgvInput::WrappedPhase { phase, .. } => phase.iter().map(|&v| v as f32).collect(),
1395    };
1396    let b0_dir = (b0_dir.0 as f32, b0_dir.1 as f32, b0_dir.2 as f32);
1397
1398    let (nx, ny, nz) = grid.dims;
1399    let vsx = grid.vsx() as f32;
1400    let vsy = grid.vsy() as f32;
1401    let vsz = grid.vsz() as f32;
1402    let res = (vsx, vsy, vsz);
1403    let iterations = params.iterations.unwrap_or_else(|| get_default_iterations(res, params.step_size));
1404
1405    // Erode the mask (from the array's edge too, unless `reference_edge_erosion`)
1406    let mut mask_eroded = mask.to_vec();
1407    for _ in 0..params.erosions {
1408        mask_eroded = erode_mask(&mask_eroded, nx, ny, nz, params.reference_edge_erosion);
1409    }
1410    // The bounding box below has no extent for an empty mask (its size underflowed).
1411    if !mask_eroded.iter().any(|&m| m != 0) {
1412        return Err(if mask.iter().any(|&m| m != 0) {
1413            InputError::param(
1414                "TGV erosions",
1415                params.erosions,
1416                "small enough to leave part of the mask (this mask erodes away)",
1417            )
1418        } else {
1419            InputError::new("TGV mask is empty")
1420        });
1421    }
1422
1423    // Create mask0 (one more erosion for internal computations)
1424    let mask0 = erode_mask(&mask_eroded, nx, ny, nz, params.reference_edge_erosion);
1425
1426    // Bounding box of the eroded mask with one voxel of padding (the reference's
1427    // `mask_box_indices`)
1428    let bbox = BoundingBox::from_mask(&mask_eroded, nx, ny, nz, 1);
1429    let (bx, by, bz) = bbox.dims();
1430    let b_total = bbox.total();
1431    let sub_grid = crate::Grid::new(bx, by, bz, vsx as f64, vsy as f64, vsz as f64);
1432
1433    // Extract sub-volumes for the bounding box region
1434    let data_sub = extract_subvolume(&data_f32, &bbox, nx, ny, nz);
1435    let mask0_sub = extract_subvolume(&mask0, &bbox, nx, ny, nz);
1436    let mask_eroded_sub = extract_subvolume(&mask_eroded, &bbox, nx, ny, nz);
1437
1438    // Laplacian of the phase in rad/mm² on the sub-volume
1439    let mut laplace_phi0 = match input {
1440        TgvInput::FieldPpm { .. } => {
1441            compute_field_laplacian(&data_sub, &mask0_sub, bx, by, bz, vsx, vsy, vsz)
1442        }
1443        TgvInput::WrappedPhase { .. } => {
1444            compute_phase_laplacian(&data_sub, &mask0_sub, bx, by, bz, vsx, vsy, vsz)
1445        }
1446    };
1447
1448    // Subtract mean within mask
1449    let (sum, count): (f32, usize) = laplace_phi0.iter().zip(mask0_sub.iter())
1450        .filter(|(_, &m)| m != 0)
1451        .fold((0.0, 0), |(s, c), (&v, _)| (s + v, c + 1));
1452    if count > 0 {
1453        let mean = sum / count as f32;
1454        for (v, &m) in laplace_phi0.iter_mut().zip(mask0_sub.iter()) {
1455            if m != 0 {
1456                *v -= mean;
1457            }
1458        }
1459    }
1460
1461    // Compute SVD-fitted oblique dipole stencil (matching Julia reference)
1462    let stencil = compute_oblique_stencil(res, b0_dir);
1463
1464    // Compute step sizes for convergence (matching Julia implementation)
1465    let grad_norm_squared = grad_norm_sq(res);
1466    let grad_norm = grad_norm_squared.sqrt();
1467    let wave_norm: f32 = stencil.iter().flatten().flatten().map(|x| x.abs()).sum();
1468    let norm_sqr = compute_operator_norm_sqr(grad_norm, grad_norm_squared, wave_norm);
1469
1470    let tau = 1.0 / norm_sqr.sqrt();
1471    let sigma = tau;
1472
1473    // step_size is applied selectively in the updates:
1474    // - eta, phi: use base sigma/tau
1475    // - p, q, chi, w: use sigma * step_size / tau * step_size
1476    let sigma_step = sigma * params.step_size;
1477    let tau_step = tau * params.step_size;
1478
1479    // Projection thresholds are alpha values (NOT 1/alpha!)
1480    // Julia: projects p to ||p|| <= alpha1, q to ||q|| <= alpha0
1481    let alpha = (params.alpha0, params.alpha1);
1482
1483    // Pre-allocate all workspace buffers
1484    let mut ws = TgvWorkspace::new(b_total);
1485
1486    // Main iteration loop (a fixed number of iterations, as in the reference)
1487    for iter in 0..iterations {
1488        progress(iter, iterations);
1489
1490        // === DUAL UPDATE ===
1491
1492        // 1. Update eta (data term dual)
1493        apply_laplacian_inplace(&mut ws.temp1, &ws.phi_, Some(&mask0_sub), bx, by, bz, vsx, vsy, vsz);
1494        apply_stencil(&mut ws.temp2, &ws.chi_, &stencil, Some(&mask0_sub), bx, by, bz);
1495
1496        for i in 0..b_total {
1497            if mask0_sub[i] != 0 {
1498                ws.eta[i] += sigma * (-ws.temp1[i] + ws.temp2[i] - laplace_phi0[i]);
1499            }
1500        }
1501
1502        // 2. Update p (gradient dual)
1503        // Julia: p += mask0 * sigma * grad(chi) - mask * sigma * w
1504        // Compute unmasked gradient first
1505        crate::utils::gradient::fgrad_inplace_f32(
1506            &mut ws.gx, &mut ws.gy, &mut ws.gz, &ws.chi_, &sub_grid
1507        );
1508
1509        for i in 0..b_total {
1510            let in_mask0 = mask0_sub[i] != 0;
1511            let in_mask = mask_eroded_sub[i] != 0;
1512
1513            if in_mask0 || in_mask {
1514                // gradient term scaled by mask0, w term scaled by mask
1515                let sigmaw0 = if in_mask0 { sigma_step } else { 0.0 };
1516                let sigmaw = if in_mask { sigma_step } else { 0.0 };
1517
1518                ws.px[i] += sigmaw0 * ws.gx[i] - sigmaw * ws.wx_[i];
1519                ws.py[i] += sigmaw0 * ws.gy[i] - sigmaw * ws.wy_[i];
1520                ws.pz[i] += sigmaw0 * ws.gz[i] - sigmaw * ws.wz_[i];
1521
1522                project_linf3(&mut ws.px[i], &mut ws.py[i], &mut ws.pz[i], alpha.1);
1523            }
1524        }
1525
1526        // 3. Update q (symmetric gradient dual)
1527        crate::utils::gradient::symgrad_inplace_f32(
1528            &mut ws.sxx, &mut ws.sxy, &mut ws.sxz, &mut ws.syy, &mut ws.syz, &mut ws.szz,
1529            &ws.wx_, &ws.wy_, &ws.wz_, &sub_grid
1530        );
1531
1532        for i in 0..b_total {
1533            if mask0_sub[i] != 0 {
1534                ws.qxx[i] += sigma_step * ws.sxx[i];
1535                ws.qxy[i] += sigma_step * ws.sxy[i];
1536                ws.qxz[i] += sigma_step * ws.sxz[i];
1537                ws.qyy[i] += sigma_step * ws.syy[i];
1538                ws.qyz[i] += sigma_step * ws.syz[i];
1539                ws.qzz[i] += sigma_step * ws.szz[i];
1540
1541                project_linf6(
1542                    &mut ws.qxx[i], &mut ws.qxy[i], &mut ws.qxz[i],
1543                    &mut ws.qyy[i], &mut ws.qyz[i], &mut ws.qzz[i],
1544                    alpha.0
1545                );
1546            }
1547        }
1548
1549        // === VARIABLE SWAP ===
1550        std::mem::swap(&mut ws.phi, &mut ws.phi_);
1551        std::mem::swap(&mut ws.chi, &mut ws.chi_);
1552        std::mem::swap(&mut ws.wx, &mut ws.wx_);
1553        std::mem::swap(&mut ws.wy, &mut ws.wy_);
1554        std::mem::swap(&mut ws.wz, &mut ws.wz_);
1555
1556        // === PRIMAL UPDATE ===
1557
1558        // 1. Update phi
1559        for i in 0..b_total {
1560            ws.temp1[i] = if mask0_sub[i] != 0 { ws.eta[i] } else { 0.0 };
1561        }
1562        // Julia: phi = (phi + tau*laplace(mask0*eta)) / (1 + mask*tau), at every voxel
1563        apply_laplacian_inplace(&mut ws.temp2, &ws.temp1, None, bx, by, bz, vsx, vsy, vsz);
1564
1565        for i in 0..b_total {
1566            let denom = 1.0 + if mask_eroded_sub[i] != 0 { tau } else { 0.0 };
1567            ws.phi[i] = (ws.phi_[i] + tau * ws.temp2[i]) / denom;
1568        }
1569
1570        // 2. Update chi
1571        crate::utils::gradient::bdiv_masked_inplace_f32(
1572            &mut ws.temp1, &ws.px, &ws.py, &ws.pz, &mask0_sub, &sub_grid
1573        );
1574
1575        for i in 0..b_total {
1576            ws.gx[i] = if mask0_sub[i] != 0 { ws.eta[i] } else { 0.0 };
1577        }
1578        // Julia: chi = chi + tau*(div(mask0*p) - wave(mask0*eta)), at every voxel
1579        apply_stencil(&mut ws.temp2, &ws.gx, &stencil, None, bx, by, bz);
1580
1581        for i in 0..b_total {
1582            ws.chi[i] = ws.chi_[i] + tau_step * (ws.temp1[i] - ws.temp2[i]);
1583        }
1584
1585        // 3. Update w
1586        for i in 0..b_total {
1587            let m = if mask0_sub[i] != 0 { 1.0 } else { 0.0 };
1588            ws.sxx[i] = ws.qxx[i] * m;
1589            ws.sxy[i] = ws.qxy[i] * m;
1590            ws.sxz[i] = ws.qxz[i] * m;
1591            ws.syy[i] = ws.qyy[i] * m;
1592            ws.syz[i] = ws.qyz[i] * m;
1593            ws.szz[i] = ws.qzz[i] * m;
1594        }
1595
1596        // The exact adjoint of the symmetric gradient in the q update (Chambolle–Pock needs
1597        // it); the reference's row-scaled version with `reference_divergence`.
1598        if params.reference_divergence {
1599            symdiv_rows_f32(
1600                &mut ws.divqx, &mut ws.divqy, &mut ws.divqz,
1601                &ws.sxx, &ws.sxy, &ws.sxz, &ws.syy, &ws.syz, &ws.szz,
1602                &sub_grid
1603            );
1604        } else {
1605            crate::utils::gradient::symdiv_inplace_f32(
1606                &mut ws.divqx, &mut ws.divqy, &mut ws.divqz,
1607                &ws.sxx, &ws.sxy, &ws.sxz, &ws.syy, &ws.syz, &ws.szz,
1608                &sub_grid
1609            );
1610        }
1611
1612        // Julia: w_dest = w; if mask: w_dest += tau*(p + div(mask0*q))
1613        for i in 0..b_total {
1614            ws.wx[i] = ws.wx_[i];
1615            ws.wy[i] = ws.wy_[i];
1616            ws.wz[i] = ws.wz_[i];
1617            if mask_eroded_sub[i] != 0 {
1618                ws.wx[i] += tau_step * (ws.px[i] + ws.divqx[i]);
1619                ws.wy[i] += tau_step * (ws.py[i] + ws.divqy[i]);
1620                ws.wz[i] += tau_step * (ws.pz[i] + ws.divqz[i]);
1621            }
1622        }
1623
1624        // === EXTRAGRADIENT UPDATE ===
1625        for i in 0..b_total {
1626            ws.phi_[i] = 2.0 * ws.phi[i] - ws.phi_[i];
1627            ws.chi_[i] = 2.0 * ws.chi[i] - ws.chi_[i];
1628            ws.wx_[i] = 2.0 * ws.wx[i] - ws.wx_[i];
1629            ws.wy_[i] = 2.0 * ws.wy[i] - ws.wy_[i];
1630            ws.wz_[i] = 2.0 * ws.wz[i] - ws.wz_[i];
1631        }
1632    }
1633
1634    progress(iterations, iterations);
1635
1636    // Full-size result inside the eroded mask, or over the whole box with
1637    // `reference_output_box`; chi is in rad units, divide by c for ppm
1638    let mut result = vec![0.0f32; n_total];
1639    if !params.reference_output_box {
1640        for (v, &m) in ws.chi.iter_mut().zip(mask_eroded_sub.iter()) {
1641            if m == 0 {
1642                *v = 0.0;
1643            }
1644        }
1645    }
1646
1647    // Insert back into full volume
1648    insert_subvolume(&mut result, &ws.chi, &bbox, nx, ny, nz);
1649
1650    Ok(result.iter().map(|&v| v as f64 / c).collect())
1651}
1652
1653#[cfg(test)]
1654mod tests {
1655    use super::*;
1656
1657    #[test]
1658    fn test_dipole_stencil() {
1659        let stencil = compute_dipole_stencil((1.0, 1.0, 1.0), (0.0, 0.0, 1.0));
1660
1661        let mut sum = 0.0f32;
1662        for k in 0..3 {
1663            for j in 0..3 {
1664                for i in 0..3 {
1665                    sum += stencil[i][j][k];
1666                }
1667            }
1668        }
1669        assert!(sum.abs() < 1e-6, "Stencil sum should be ~0, got {}", sum);
1670    }
1671
1672    #[test]
1673    fn test_phase_laplacian() {
1674        let nx = 4;
1675        let ny = 4;
1676        let nz = 4;
1677        let n = nx * ny * nz;
1678
1679        let phase = vec![1.0f32; n];
1680        let mask = vec![1u8; n];
1681
1682        let lap = compute_phase_laplacian(&phase, &mask, nx, ny, nz, 1.0, 1.0, 1.0);
1683
1684        let max_val = lap.iter().map(|x| x.abs()).fold(0.0f32, f32::max);
1685        assert!(max_val < 1e-5, "Laplacian of constant should be ~0, got max {}", max_val);
1686    }
1687
1688    fn adjoint_gap(rows: bool) -> f64 {
1689        // <E w, q> (off-diagonals counted twice, as the q projection's norm counts them) against
1690        // -<w, div q>, on anisotropic voxels, with q zero on the box faces (as mask0 keeps it).
1691        let (nx, ny, nz) = (9, 8, 7);
1692        let n = nx * ny * nz;
1693        let grid = crate::Grid::new(nx, ny, nz, 0.9, 1.0, 1.4);
1694        let val = |s: usize, l: usize| ((l * 2654435761 + s * 40503) % 1000) as f32 / 500.0 - 1.0;
1695        let w: Vec<Vec<f32>> = (0..3).map(|s| (0..n).map(|l| val(s, l)).collect()).collect();
1696        let face = |l: usize| {
1697            let (i, j, k) = (l % nx, (l / nx) % ny, l / (nx * ny));
1698            i == 0 || j == 0 || k == 0 || i + 1 == nx || j + 1 == ny || k + 1 == nz
1699        };
1700        let q: Vec<Vec<f32>> = (0..6).map(|s| (0..n).map(|l| if face(l) { 0.0 } else { val(s + 3, l) }).collect()).collect();
1701        let mut e: Vec<Vec<f32>> = vec![vec![0.0; n]; 6];
1702        let [exx, exy, exz, eyy, eyz, ezz] = &mut e[..] else { unreachable!() };
1703        crate::utils::gradient::symgrad_inplace_f32(exx, exy, exz, eyy, eyz, ezz, &w[0], &w[1], &w[2], &grid);
1704        let mut d: Vec<Vec<f32>> = vec![vec![0.0; n]; 3];
1705        let [dx, dy, dz] = &mut d[..] else { unreachable!() };
1706        if rows {
1707            symdiv_rows_f32(dx, dy, dz, &q[0], &q[1], &q[2], &q[3], &q[4], &q[5], &grid);
1708        } else {
1709            crate::utils::gradient::symdiv_inplace_f32(dx, dy, dz, &q[0], &q[1], &q[2], &q[3], &q[4], &q[5], &grid);
1710        }
1711        let weight = [1.0, 2.0, 2.0, 1.0, 2.0, 1.0];
1712        let lhs: f64 = (0..6).map(|s| (0..n).map(|l| weight[s] * e[s][l] as f64 * q[s][l] as f64).sum::<f64>()).sum();
1713        let rhs: f64 = -(0..3).map(|s| (0..n).map(|l| w[s][l] as f64 * d[s][l] as f64).sum::<f64>()).sum::<f64>();
1714        (lhs - rhs).abs() / lhs.abs().max(rhs.abs())
1715    }
1716
1717    #[test]
1718    fn default_w_update_divergence_is_the_adjoint_and_the_references_is_not() {
1719        assert!(adjoint_gap(false) < 1e-5, "default: relative gap {}", adjoint_gap(false));
1720        assert!(adjoint_gap(true) > 1e-2, "reference: relative gap {}", adjoint_gap(true));
1721    }
1722
1723    #[test]
1724    fn test_erode_mask() {
1725        let nx = 5;
1726        let ny = 5;
1727        let nz = 5;
1728
1729        // Default: outside the array counts as outside the mask, so a full mask erodes from
1730        // the edge; the reference (skip_outside) does not erode it.
1731        let mask = vec![1u8; nx * ny * nz];
1732        let eroded = erode_mask(&mask, nx, ny, nz, false);
1733        assert_eq!(eroded.iter().filter(|&&m| m == 1).count(), 27);
1734        assert!(erode_mask(&mask, nx, ny, nz, true).iter().all(|&m| m == 1));
1735
1736        // A hole erodes its 6 neighbours, not the diagonals.
1737        let mut mask = mask;
1738        let center = 2 + 2 * nx + 2 * nx * ny;
1739        mask[center] = 0;
1740        let eroded = erode_mask(&mask, nx, ny, nz, true);
1741        assert_eq!(eroded.iter().filter(|&&m| m == 0).count(), 7);
1742        assert_eq!(eroded[center + 1], 0);
1743        assert_eq!(eroded[center + 1 + nx], 1);
1744    }
1745
1746    #[test]
1747    fn test_default_alpha() {
1748        // The reference's get_default_alpha: [0.001, 0.001] + [0.001, 0.002]·(r − 1), ≥ 0.
1749        let close = |a: (f32, f32), b: (f32, f32)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
1750        assert!(close(get_default_alpha(1.0), (0.001, 0.001)));
1751        assert!(close(get_default_alpha(2.0), (0.002, 0.003)));
1752        assert!(close(get_default_alpha(3.0), (0.003, 0.005)));
1753        assert!(close(get_default_alpha(4.0), (0.004, 0.007)));
1754        assert!(close(get_default_alpha(0.0), (0.0, 0.0)));
1755        let d = TgvParams::default();
1756        assert!(close((d.alpha0, d.alpha1), (0.002, 0.003)));
1757        assert_eq!((d.iterations, d.erosions, d.step_size), (None, 3, 3.0));
1758        assert!((rad_per_ppm(0.004, 7.0) - 7.4907).abs() < 1e-3);
1759    }
1760
1761    #[test]
1762    fn test_bounding_box() {
1763        let nx = 10;
1764        let ny = 10;
1765        let nz = 10;
1766        let mut mask = vec![0u8; nx * ny * nz];
1767
1768        // Set a small region in the center
1769        for k in 3..7 {
1770            for j in 3..7 {
1771                for i in 3..7 {
1772                    mask[i + j * nx + k * nx * ny] = 1;
1773                }
1774            }
1775        }
1776
1777        let bbox = BoundingBox::from_mask(&mask, nx, ny, nz, 1);
1778
1779        // Should be 3-1=2 to 6+1+1=8 (with padding 1)
1780        assert_eq!(bbox.i_min, 2);
1781        assert_eq!(bbox.i_max, 8);
1782        assert_eq!(bbox.j_min, 2);
1783        assert_eq!(bbox.j_max, 8);
1784    }
1785
1786    #[test]
1787    fn test_tgv_qsm_small() {
1788        let n = 12;
1789        let n_total = n * n * n;
1790        let center = 6.0_f32;
1791        let radius = 4.0_f32;
1792
1793        // Build a sphere mask
1794        let mut mask = vec![0u8; n_total];
1795        for k in 0..n {
1796            for j in 0..n {
1797                for i in 0..n {
1798                    let dx = i as f32 - center;
1799                    let dy = j as f32 - center;
1800                    let dz = k as f32 - center;
1801                    if (dx * dx + dy * dy + dz * dz).sqrt() < radius {
1802                        mask[i + j * n + k * n * n] = 1;
1803                    }
1804                }
1805            }
1806        }
1807
1808        // Fill phase with a linear ramp (z direction) masked
1809        let mut phase = vec![0.0f64; n_total];
1810        for k in 0..n {
1811            for j in 0..n {
1812                for i in 0..n {
1813                    let idx = i + j * n + k * n * n;
1814                    if mask[idx] != 0 {
1815                        phase[idx] = 0.1 * k as f64;
1816                    }
1817                }
1818            }
1819        }
1820
1821        let params = TgvParams {
1822            iterations: Some(10),
1823            erosions: 1,
1824            ..TgvParams::default()
1825        };
1826
1827        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
1828        let input = TgvInput::WrappedPhase { phase: &phase, te_s: 0.02, b0_t: 3.0 };
1829        let result = tgv_qsm(input, &mask, &grid, &params, (0.0, 0.0, 1.0), |_, _| {}).unwrap();
1830
1831        assert_eq!(result.len(), n_total);
1832
1833        // All values should be finite
1834        for &v in &result {
1835            assert!(v.is_finite(), "Result contains non-finite value: {}", v);
1836        }
1837
1838        // There should be at least some non-zero values within the (eroded) mask
1839        let has_nonzero = result.iter().any(|&v| v.abs() > 1e-20);
1840        assert!(has_nonzero, "Result is entirely zero; expected non-zero values within mask");
1841    }
1842
1843    #[test]
1844    fn test_oblique_stencil() {
1845        let stencil = compute_oblique_stencil((1.0, 1.0, 1.0), (0.0, 0.0, 1.0));
1846
1847        // Sum of all elements should be ~0 (Laplacian-like operator)
1848        let mut sum = 0.0f32;
1849        for k in 0..3 {
1850            for j in 0..3 {
1851                for i in 0..3 {
1852                    sum += stencil[i][j][k];
1853                }
1854            }
1855        }
1856        assert!(
1857            sum.abs() < 1e-4,
1858            "Oblique stencil sum should be ~0, got {}",
1859            sum
1860        );
1861
1862        // Center element should be approximately -sum(others), so verify it matches
1863        let mut off_sum = 0.0f32;
1864        for k in 0..3 {
1865            for j in 0..3 {
1866                for i in 0..3 {
1867                    if !(i == 1 && j == 1 && k == 1) {
1868                        off_sum += stencil[i][j][k];
1869                    }
1870                }
1871            }
1872        }
1873        assert!(
1874            (stencil[1][1][1] + off_sum).abs() < 1e-6,
1875            "Center should be -sum(others): center={}, off_sum={}",
1876            stencil[1][1][1], off_sum
1877        );
1878    }
1879
1880    #[test]
1881    fn test_oblique_stencil_aniso() {
1882        let stencil = compute_oblique_stencil((1.0, 1.0, 2.0), (0.2, 0.3, 0.9));
1883
1884        // Sum of all elements should be ~0
1885        let mut sum = 0.0f32;
1886        for k in 0..3 {
1887            for j in 0..3 {
1888                for i in 0..3 {
1889                    sum += stencil[i][j][k];
1890                }
1891            }
1892        }
1893        assert!(
1894            sum.abs() < 1e-4,
1895            "Anisotropic oblique stencil sum should be ~0, got {}",
1896            sum
1897        );
1898    }
1899
1900    #[test]
1901    fn test_get_default_iterations() {
1902        // With isotropic 1mm voxels and step_size=1.0
1903        let it = get_default_iterations((1.0, 1.0, 1.0), 1.0);
1904        assert!(it >= 1000, "Iterations should be >= 1000 for 1mm iso, got {}", it);
1905
1906        // With larger voxels the count should decrease (prod_res is larger)
1907        let it_large = get_default_iterations((2.0, 2.0, 2.0), 1.0);
1908        assert!(it_large >= 1000, "Iterations should still be >= 1000 for 2mm iso");
1909
1910        // With very small voxels the count should be larger than 1mm iso
1911        let it_small = get_default_iterations((0.5, 0.5, 0.5), 1.0);
1912        assert!(it_small > it, "Smaller voxels should need more iterations: {} vs {}", it_small, it);
1913
1914        // Higher step_size should reduce iterations
1915        let it_fast = get_default_iterations((1.0, 1.0, 1.0), 3.0);
1916        assert!(it_fast < it, "Higher step_size should give fewer iterations: {} vs {}", it_fast, it);
1917    }
1918
1919    #[test]
1920    fn test_tgv_zero_input() {
1921        // Zero field => chi should be ~0, and progress reports every iteration.
1922        let n = 12;
1923        let n_total = n * n * n;
1924        let center = 6.0_f32;
1925        let radius = 4.0_f32;
1926
1927        let mut mask = vec![0u8; n_total];
1928        for k in 0..n {
1929            for j in 0..n {
1930                for i in 0..n {
1931                    let dx = i as f32 - center;
1932                    let dy = j as f32 - center;
1933                    let dz = k as f32 - center;
1934                    if (dx * dx + dy * dy + dz * dz).sqrt() < radius {
1935                        mask[i + j * n + k * n * n] = 1;
1936                    }
1937                }
1938            }
1939        }
1940
1941        let phase = vec![0.0f64; n_total];
1942
1943        let params = TgvParams {
1944            iterations: Some(100),
1945            erosions: 1,
1946            ..TgvParams::default()
1947        };
1948
1949        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
1950        let progress_iters = std::cell::RefCell::new(Vec::new());
1951        let result = tgv_qsm(
1952            TgvInput::FieldPpm { field: &phase, echo_times: &[0.02], b0_t: 3.0 }, &mask, &grid, &params, (0.0, 0.0, 1.0),
1953            |iter, _total| { progress_iters.borrow_mut().push(iter); }
1954        ).unwrap();
1955
1956        assert_eq!(result.len(), n_total);
1957
1958        // With zero phase, all output values should be very close to zero
1959        let max_abs = result.iter().map(|v| v.abs()).fold(0.0f64, f64::max);
1960        assert!(
1961            max_abs < 1e-3,
1962            "Zero-phase TGV result should be ~0, got max abs {}",
1963            max_abs
1964        );
1965
1966        // A fixed number of iterations, as in the reference: no early stop
1967        let iters = progress_iters.borrow();
1968        assert_eq!(iters.len(), 101);
1969        assert_eq!(*iters.last().unwrap(), 100);
1970    }
1971
1972    /// The default returns χ inside the eroded mask only; `reference_output_box` returns the
1973    /// same χ there plus the reference's rim (nonzero spill-over just outside it).
1974    #[test]
1975    fn output_box_adds_the_rim_and_keeps_the_eroded_mask() {
1976        let n = 20;
1977        let mask = sphere_mask(n, 8.0);
1978        let field = test_field(n, 0.01);
1979        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
1980        let run = |box_out: bool| {
1981            let p = TgvParams { iterations: Some(50), erosions: 1, reference_output_box: box_out, ..Default::default() };
1982            tgv_qsm(TgvInput::FieldPpm { field: &field, echo_times: &[0.02], b0_t: 3.0 }, &mask, &grid, &p, (0.0, 0.0, 1.0), |_, _| {})
1983                .unwrap()
1984        };
1985        let (trimmed, boxed) = (run(false), run(true));
1986        let eroded = erode_mask(&mask, n, n, n, false);
1987        let mut rim = 0;
1988        for l in 0..n * n * n {
1989            if eroded[l] != 0 {
1990                assert_eq!(trimmed[l], boxed[l]);
1991            } else {
1992                assert_eq!(trimmed[l], 0.0);
1993                rim += (boxed[l] != 0.0) as usize;
1994            }
1995        }
1996        assert!(rim > 0, "no χ outside the eroded mask with reference_output_box");
1997    }
1998
1999    fn sphere_mask(n: usize, radius: f64) -> Vec<u8> {
2000        let c = (n as f64 - 1.0) / 2.0;
2001        let mut mask = vec![0u8; n * n * n];
2002        for k in 0..n {
2003            for j in 0..n {
2004                for i in 0..n {
2005                    let (dx, dy, dz) = (i as f64 - c, j as f64 - c, k as f64 - c);
2006                    if (dx * dx + dy * dy + dz * dz).sqrt() < radius {
2007                        mask[i + j * n + k * n * n] = 1;
2008                    }
2009                }
2010            }
2011        }
2012        mask
2013    }
2014
2015    /// A smooth, non-harmonic field in ppm: a Gaussian bump plus a quadratic.
2016    fn test_field(n: usize, scale: f64) -> Vec<f64> {
2017        let c = (n as f64 - 1.0) / 2.0;
2018        let mut f = vec![0.0; n * n * n];
2019        for k in 0..n {
2020            for j in 0..n {
2021                for i in 0..n {
2022                    let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
2023                    let r2 = x * x + y * y + z * z;
2024                    f[i + j * n + k * n * n] = scale * ((-r2 / 8.0).exp() + 0.002 * (x * x - z * z));
2025                }
2026            }
2027        }
2028        f
2029    }
2030
2031    /// FieldPpm uses the plain Laplacian (the reference's `get_laplace_phase_romeo` on an
2032    /// already unwrapped phase). On a field whose rad-equivalent exceeds π and whose neighbour
2033    /// differences are not small, DEL would be wrong, while the plain Laplacian is exact.
2034    #[test]
2035    fn field_ppm_uses_plain_laplacian_where_del_would_be_wrong() {
2036        let n = 16;
2037        let mask = vec![1u8; n * n * n];
2038        // Quadratic field f = a·x² ppm, ∇²f = 2a exactly with the 3-point stencil.
2039        let a = 0.05f32;
2040        let c = (n as f32 - 1.0) / 2.0;
2041        let field: Vec<f32> = (0..n * n * n)
2042            .map(|idx| { let x = (idx % n) as f32 - c; a * x * x })
2043            .collect();
2044        let rad_per = rad_per_ppm(0.020, 3.0) as f32; // ≈ 16.05
2045        let phase: Vec<f32> = field.iter().map(|&v| v * rad_per).collect();
2046        let max_rad = phase.iter().cloned().fold(0.0f32, f32::max);
2047        assert!(max_rad > std::f32::consts::PI, "test needs rad-equivalent > π, max {max_rad}");
2048
2049        let plain = compute_field_laplacian(&field, &mask, n, n, n, 1.0, 1.0, 1.0);
2050        let del = compute_phase_laplacian(&phase, &mask, n, n, n, 1.0, 1.0, 1.0);
2051
2052        // Interior voxels, away from the replicated boundary
2053        let mut max_err_plain = 0.0f32;
2054        let mut max_err_del = 0.0f32;
2055        for k in 1..n - 1 {
2056            for j in 1..n - 1 {
2057                for i in 1..n - 1 {
2058                    let idx = i + j * n + k * n * n;
2059                    max_err_plain = max_err_plain.max((plain[idx] - 2.0 * a).abs());
2060                    max_err_del = max_err_del.max((del[idx] / rad_per - 2.0 * a).abs());
2061                }
2062            }
2063        }
2064        assert!(max_err_plain < 1e-4, "plain Laplacian error {max_err_plain}");
2065        // DEL sees sin of the neighbour differences, which reach several rad here.
2066        assert!(max_err_del > 0.5 * 2.0 * a, "DEL error unexpectedly small: {max_err_del}");
2067    }
2068
2069    /// α are in the reference's rad units at the given TE: the same α at twice the TE
2070    /// regularises like half the α at the original TE, as in the reference, where the phase
2071    /// (and so the data term) doubles with TE.
2072    #[test]
2073    fn field_ppm_alpha_is_in_rad_units_at_te() {
2074        let n = 20;
2075        let mask = sphere_mask(n, 8.5);
2076        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
2077        let field = test_field(n, 0.1);
2078        let params = TgvParams { iterations: Some(300), erosions: 1, ..TgvParams::default() };
2079        let at = |te: f64, p: &TgvParams| {
2080            tgv_qsm(TgvInput::FieldPpm { field: &field, echo_times: &[te], b0_t: 3.0 }, &mask, &grid, p, (0.0, 0.0, 1.0), |_, _| {}).unwrap()
2081        };
2082        let chi_2te = at(0.02, &params);
2083        let half = TgvParams { alpha0: params.alpha0 / 2.0, alpha1: params.alpha1 / 2.0, ..params.clone() };
2084        let chi_half = at(0.01, &half);
2085        let chi_te = at(0.01, &params);
2086
2087        let norm = chi_2te.iter().map(|v| v * v).sum::<f64>().sqrt();
2088        let rel = |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::<f64>().sqrt() / norm;
2089        assert!(norm > 0.0);
2090        assert!(rel(&chi_2te, &chi_half) < 1e-5, "relative difference {}", rel(&chi_2te, &chi_half));
2091        // and the TE does matter for a fixed α
2092        assert!(rel(&chi_2te, &chi_te) > 1e-3, "TE had no effect: {}", rel(&chi_2te, &chi_te));
2093    }
2094
2095    /// WrappedPhase of a gently varying phase equals FieldPpm of the same field at the same
2096    /// TE (DEL ≈ plain Laplacian when neighbour differences are small).
2097    #[test]
2098    fn wrapped_phase_matches_field_ppm() {
2099        let n = 20;
2100        let mask = sphere_mask(n, 8.5);
2101        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
2102        let field = test_field(n, 0.02);
2103        let params = TgvParams { iterations: Some(300), erosions: 1, ..TgvParams::default() };
2104        for te in [0.005, 0.020] {
2105            let chi_f = tgv_qsm(TgvInput::FieldPpm { field: &field, echo_times: &[te], b0_t: 3.0 }, &mask, &grid, &params, (0.0, 0.0, 1.0), |_, _| {}).unwrap();
2106            let norm = chi_f.iter().map(|v| v * v).sum::<f64>().sqrt();
2107            let c = rad_per_ppm(te, 3.0);
2108            let wrap = |x: f64| (x + std::f64::consts::PI).rem_euclid(2.0 * std::f64::consts::PI) - std::f64::consts::PI;
2109            let phase: Vec<f64> = field.iter().map(|v| wrap(v * c)).collect();
2110            let input = TgvInput::WrappedPhase { phase: &phase, te_s: te, b0_t: 3.0 };
2111            let chi_p = tgv_qsm(input, &mask, &grid, &params, (0.0, 0.0, 1.0), |_, _| {}).unwrap();
2112            let diff = chi_f.iter().zip(&chi_p).map(|(a, b)| (a - b).powi(2)).sum::<f64>().sqrt();
2113            assert!(diff / norm < 0.02, "TE {te}: relative difference {}", diff / norm);
2114        }
2115    }
2116
2117    /// The fast DST-I is the direct sum, to rounding.
2118    #[test]
2119    fn dst3d_matches_the_direct_sum() {
2120        let n = 9;
2121        let x: Vec<f64> = (0..n * n * n).map(|i| ((i * 37 % 101) as f64 / 50.0) - 1.0).collect();
2122        let fast = dst3d(&x, n, rustdct::DctPlanner::<f64>::new().plan_dst1(n).as_ref());
2123        let direct = dst3d_direct(&x, n, &dst_sin_table(n));
2124        let err = fast.iter().zip(&direct).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
2125        assert!(err < 1e-9, "max difference {err}");
2126    }
2127
2128    /// Least-squares slope of `got` against `truth` over the voxels where `keep` holds:
2129    /// 1 when χ comes out in the units of the phantom, offset-invariant.
2130    fn slope(got: &[f64], truth: &[f64], keep: impl Fn(usize) -> bool) -> f64 {
2131        let idx: Vec<usize> = (0..truth.len()).filter(|&i| keep(i)).collect();
2132        let k = idx.len() as f64;
2133        let mt = idx.iter().map(|&i| truth[i]).sum::<f64>() / k;
2134        let mg = idx.iter().map(|&i| got[i]).sum::<f64>() / k;
2135        let cov: f64 = idx.iter().map(|&i| (truth[i] - mt) * (got[i] - mg)).sum();
2136        let var: f64 = idx.iter().map(|&i| (truth[i] - mt).powi(2)).sum();
2137        cov / var
2138    }
2139
2140    /// TGV returns χ in ppm on the scale of the field it was given: three spheres of known χ,
2141    /// forward-simulated through the dipole kernel, come back with a slope near 1 against the
2142    /// truth, whether TGV gets the field in ppm or one wrapped echo with its TE and B0 (the
2143    /// default α shrink these small spheres to about 0.66). A field in ppm read as rad at TE1
2144    /// (QSM.rs#170) gave a slope of about 0.2 here.
2145    #[test]
2146    fn chi_comes_back_on_the_phantom_scale() {
2147        let n = 24;
2148        let c = n as f64 / 2.0;
2149        let at = |i: usize| ((i % n) as f64 + 0.5, ((i / n) % n) as f64 + 0.5, (i / (n * n)) as f64 + 0.5);
2150        let blobs = [((c - 4.0, c, c), 3.0, 0.15), ((c + 4.0, c + 2.0, c), 2.5, -0.10), ((c, c - 4.0, c + 3.0), 2.0, 0.20)];
2151        let chi: Vec<f64> = (0..n * n * n).map(|i| {
2152            let p = at(i);
2153            blobs.iter().rev()
2154                .find(|&&((x, y, z), r, _)| ((p.0 - x).powi(2) + (p.1 - y).powi(2) + (p.2 - z).powi(2)).sqrt() < r)
2155                .map_or(0.0, |b| b.2)
2156        }).collect();
2157        let r = |i: usize| { let p = at(i); ((p.0 - c).powi(2) + (p.1 - c).powi(2) + (p.2 - c).powi(2)).sqrt() };
2158        let mask: Vec<u8> = (0..n * n * n).map(|i| (r(i) < c - 1.0) as u8).collect();
2159        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
2160        let kernel = crate::kernels::dipole::dipole_kernel(&grid, (0.0, 0.0, 1.0));
2161        let field = crate::fft::apply_real_kernel(&chi, &kernel, n, n, n);
2162        // Scored well inside the mask, clear of the eroded rim TGV leaves at zero.
2163        let scored = |i: usize| r(i) < c - 5.0;
2164
2165        let params = TgvParams::default();
2166        let (te, b0) = (0.010, 3.0);
2167        let ppm = tgv_qsm(TgvInput::FieldPpm { field: &field, echo_times: &[te], b0_t: b0 }, &mask, &grid, &params, (0.0, 0.0, 1.0), |_, _| {}).unwrap();
2168        let s_ppm = slope(&ppm, &chi, scored);
2169        assert!((0.5..1.5).contains(&s_ppm), "FieldPpm slope {s_ppm:.3} against the phantom");
2170
2171        let wrap = |x: f64| (x + std::f64::consts::PI).rem_euclid(2.0 * std::f64::consts::PI) - std::f64::consts::PI;
2172        let phase: Vec<f64> = field.iter().map(|v| wrap(v * rad_per_ppm(te, b0))).collect();
2173        let input = TgvInput::WrappedPhase { phase: &phase, te_s: te, b0_t: b0 };
2174        let rad = tgv_qsm(input, &mask, &grid, &params, (0.0, 0.0, 1.0), |_, _| {}).unwrap();
2175        let s_rad = slope(&rad, &chi, scored);
2176        eprintln!("phantom χ slope vs truth: FieldPpm {s_ppm:.3}, WrappedPhase {s_rad:.3}");
2177        assert!((0.5..1.5).contains(&s_rad), "WrappedPhase slope {s_rad:.3} against the phantom");
2178    }
2179
2180    #[test]
2181    fn rejects_zero_te() {
2182        let n = 8;
2183        let mask = vec![1u8; n * n * n];
2184        let phase = vec![0.0; n * n * n];
2185        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
2186        let input = TgvInput::WrappedPhase { phase: &phase, te_s: 0.0, b0_t: 3.0 };
2187        let err = tgv_qsm(input, &mask, &grid, &TgvParams::default(), (0.0, 0.0, 1.0), |_, _| {})
2188            .unwrap_err();
2189        assert!(err.to_string().contains("te_s > 0"), "{err}");
2190        let input = TgvInput::FieldPpm { field: &phase, echo_times: &[0.0], b0_t: 3.0 };
2191        assert!(tgv_qsm(input, &mask, &grid, &TgvParams::default(), (0.0, 0.0, 1.0), |_, _| {}).is_err());
2192        // and a mask of the wrong size is an error, not a panic
2193        let input = TgvInput::FieldPpm { field: &phase, echo_times: &[0.02], b0_t: 3.0 };
2194        assert!(tgv_qsm(input, &mask[1..], &grid, &TgvParams::default(), (0.0, 0.0, 1.0), |_, _| {}).is_err());
2195    }
2196
2197    /// #199: erosions that empty the mask underflowed the bounding-box size; an empty mask, or
2198    /// one eroded away, is an error now.
2199    #[test]
2200    fn mask_eroded_away_is_an_error() {
2201        let n = 8;
2202        let grid = crate::Grid::new(n, n, n, 1.0, 1.0, 1.0);
2203        let field = vec![0.0; n * n * n];
2204        let mut mask = vec![0u8; n * n * n];
2205        mask[3 + 3 * n + 3 * n * n] = 1; // one voxel: gone after a single erosion
2206        let p = TgvParams { erosions: 1, iterations: Some(1), ..Default::default() };
2207        let input = TgvInput::FieldPpm { field: &field, echo_times: &[0.02], b0_t: 3.0 };
2208        let err = tgv_qsm(input, &mask, &grid, &p, (0.0, 0.0, 1.0), |_, _| {}).unwrap_err();
2209        assert!(err.0.contains("TGV erosions = 1"), "{err}");
2210        let input = TgvInput::FieldPpm { field: &field, echo_times: &[0.02], b0_t: 3.0 };
2211        let err = tgv_qsm(input, &[0u8; 512], &grid, &p, (0.0, 0.0, 1.0), |_, _| {}).unwrap_err();
2212        assert!(err.0.contains("TGV mask is empty"), "{err}");
2213    }
2214}