Skip to main content

qsm_core/inversion/
medi.rs

1//! MEDI (Morphology Enabled Dipole Inversion), a port of `MEDI_L1.m` from the MEDI toolbox
2//! (release 2024.11.26)
3//!
4//! Solves
5//!
6//! min_χ ||wG ∇χ||₁ + 2λ ||m (exp(i Dχ) − exp(i f))||²₂
7//!
8//! by Gauss-Newton, with the L1 term handled by lagged diffusivity and each Gauss-Newton step
9//! solved by conjugate gradients. `f` is the local field in radians, `m` the data weight
10//! (`dataterm_mask.m`) and `wG` the binary edge mask from the magnitude (`gradient_mask.m`): 0 at
11//! the strongest magnitude edges, 1 elsewhere, per gradient direction.
12//!
13//! The port follows `MEDI_L1.m` and its helpers line for line: `fgrad.m`/`bdiv.m` (forward
14//! differences, zero at the last voxel, and their adjoint), the per-direction weight
15//! `1/sqrt(|wG ∇χ|² + 1e-6)`, `cgsolve.m`, `sphere_kernel.m` (the partial-volume sphere) for the
16//! SMV step, and the MERIT reweighting. Defaults are those of `MEDI_L1.m` and
17//! `parse_QSM_input.m`: λ = 1000, 10 Gauss-Newton iterations, 100 CG iterations, tolerances 0.1
18//! and 0.01, `percentage` 0.9, SNR data weighting, SMV (radius 5 mm, shrinking the mask), MERIT
19//! and the CSF zero reference (MEDI+0, `lambda_CSF` 100) on. Not ported: the preconditioner
20//! `P`, the linear formulation and the `Lreg` term (all off unless requested).
21//!
22//! # MEDI+0: CSF zero reference
23//! MEDI's pipeline (README.m) computes R2* from the echo magnitudes with `arlo.m`
24//! ([`arlo`](crate::utils::r2star::arlo)), the ventricular CSF mask from it with
25//! `extract_CSF.m` ([`extract_csf`]), and passes the mask to `MEDI_L1`, which then adds
26//! `reg_CSF.m`'s term `lambda_CSF/2 · ||M_CSF (χ − mean_CSF(χ))||²` to the cost and subtracts
27//! the CSF mean from the result. `medi` does the same from the echo magnitudes and echo times
28//! it requires ([`MediParams::csf`], default on, as `parse_QSM_input` sets `lambda_CSF` = 100).
29//! Differences from MATLAB, which only matter where MATLAB fails or gives no answer:
30//! - Fewer than three echoes: `arlo.m` gives no R2* for one echo (so MEDI runs without the CSF
31//!   term) and 0 everywhere for two (which would make the whole central brain "CSF"). `medi`
32//!   runs without the CSF term in both cases.
33//! - A CSF mask with no voxels makes `MEDI_L1` return NaN (the mean over no voxels); `medi`
34//!   runs without the CSF term instead.
35//! - `extract_CSF.m` errors when the central sphere holds fewer than three low-R2* regions;
36//!   [`extract_csf`] uses those it finds.
37//! - `MEDI_L1` subtracts the CSF mean from every voxel, so the result is `−mean` outside the
38//!   mask; `medi` keeps the result zero outside the mask, like the other inversions. Inside the
39//!   mask the values are the same.
40//!
41//! # Units
42//! Like the other inversions, `medi` takes the local field in ppm and returns ppm. MEDI works on
43//! the field in radians accumulated over the echo spacing (`delta_TE`, `TE(2) − TE(1)` for
44//! multi-echo data and `TE` for one echo, as MEDI's DICOM readers set it) at the centre frequency
45//! `CF = γ·B0`. The field is converted with `2π·CF·delta_TE·1e-6` first and the result converted
46//! back at the end, as `MEDI_L1.m` does. The result depends on this scale (the data term is
47//! nonlinear and λ is fixed), so `b0` and `echo_times` are scan parameters that the pipeline
48//! fills from metadata, and [`MediParams::te`] picks the echo time (by default `delta_TE`).
49//!
50//! # Magnitude and data weight
51//! `MEDI_L1.m` weights the data by `1/N_std`, the noise standard deviation of the field
52//! (normalised to mean 1 in the mask), and builds the edge mask from `iMag`. MEDI's pipeline
53//! (README.m) takes both from the complex echoes: `iMag = sqrt(sum(abs(iField).^2,4))` and
54//! `N_std` from `Fit_ppm_complex.m`, which depends only on the echo magnitudes
55//! ([`fit_ppm_complex_noise`](crate::utils::fit_ppm_complex_noise)). `medi` requires the echo
56//! magnitudes ([`EchoMagnitudes`]) and their echo times ([`MediParams::echo_times`]) and does
57//! the same, so it always weights the data as
58//! `MEDI_L1` does in MEDI's pipeline; one echo is fitted as `Fit_ppm_complex.m` fits one echo.
59//! `Fit_ppm_complex.m` assumes evenly spaced echoes.
60//!
61//! # Zero padding
62//! `zeropad` pads the volume with zeros at the end of each axis before the inversion and crops
63//! the result back. That is what `MEDI_L1`'s `'zeropad'` option is meant to do, but in MEDI
64//! 2024.11.26 the option fails: `parse_QSM_input` pads the volumes but leaves `matrix_size` and
65//! the masks (`Mask`, `Mask_CSF`) at the unpadded size, so `MEDI_L1` stops on mismatched
66//! arrays. The QSM-CI `medi-cornell` container pads the inputs itself before calling `MEDI_L1`
67//! instead (QSMxT/QSM-CI#309).
68//!
69//! # Input: the local field
70//! `medi` takes the local field from a background removal, with or without SMV or mSMV. The
71//! MEDI toolbox's pipeline (`README.m`) runs `RDF = PDF(iFreq, N_std, Mask, ...)` and then
72//! `MEDI_L1('lambda',1000,'lambda_CSF',100,'merit','smv',5)` on `RDF`. Its naming convention
73//! calls `RDF` the "Relative Difference Field, aka local field", and `MEDI_L1` reads `iFreq`
74//! (the total field) without using it. The mSMV paper (Roberts et al. 2024, Eqs. 4 and 13)
75//! defines MEDI-SMV on the local field after PDF, `b_L0 = b̂_L − S·M·b̂_L` on the eroded mask,
76//! and SEPIA's MEDI wrapper passes MEDI its local field. MEDI's SMV step only removes the
77//! residual background that a background removal leaves near the edge. The pipeline therefore
78//! routes MEDI through a background removal
79//! ([`InversionConfig::input`](crate::pipeline::InversionConfig::input)), PDF in the authors'
80//! pipeline ([`reference_bg_removal`](crate::pipeline::InversionConfig::reference_bg_removal)).
81//!
82//! Until #287, QSM.rs gave MEDI-SMV the total field and the brain mask instead. The two routes
83//! were compared with MEDI's defaults (λ 1000, MERIT, MEDI+0) and QSM.rs's PDF. Data: the
84//! qsm-forward 7 T head phantom (TE 4/12/20/28 ms), noise-free from its true total field and at
85//! SNR 50 through QSM.rs's field mapping, scored over the brain mask; and four 3 T EMTP
86//! travelling-head scans in QSM-CI (Philips Achieva, Siemens Skyra, Siemens Prisma, Philips
87//! Ingenia), scored by the χ variance in the SynthSeg cortex (the mSMV paper's shadow score):
88//!
89//! | input → MEDI | phantom, noise-free: XSIM / NRMSE / corr | phantom, SNR 50 | in vivo cortex χ variance (×1e-4 ppm²), 4 scans |
90//! |---|---|---|---|
91//! | total field → MEDI-SMV (before #287) | 0.563 / 62.6 % / 0.780 | 0.352 / 76.2 % / 0.657 | 4.76 / 8.99 / 4.12 / 5.71 |
92//! | PDF → MEDI-SMV (authors) | 0.563 / 62.6 % / 0.780 | 0.352 / 76.2 % / 0.658 | 4.86 / 9.09 / 4.14 / 5.76 |
93//! | total field → MEDI-SMV, no shrinking | 0.233 / 140.6 % / 0.458 | 0.079 / 315.0 % / 0.207 | 850 / 383 / 875 / 983 |
94//! | PDF → MEDI-SMV, no shrinking | 0.558 / 65.1 % / 0.760 | 0.292 / 94.0 % / 0.511 | 6.9 / 12.7 / 9.1 / 11.4 |
95//!
96//! With the default mask shrinking the two routes give nearly the same χ: the SMV high-pass
97//! removes any harmonic field inside the eroded mask, and the total field differs from PDF's
98//! local field by a field that is harmonic there. The maps differ by 0.5 ppb RMS on the phantom
99//! (relative L2 1.3 %) and 1.5–4.9 ppb in vivo (4–14 %). Without shrinking, the edge band keeps
100//! the background field on the total-field route, and the result is unusable. The authors'
101//! route costs a PDF run, 10–80 s on these volumes.
102//!
103//! # SMV options
104//! What `MEDI_L1.m` does with each combination of `'smv'`/`'nosmv'`, `smv_shrink_mask` and
105//! `'msmv'` (`SphereK` is `sphere_kernel.m`'s kernel, `SMV(x) = ifftn(SphereK·fftn(x))`):
106//!
107//! | options | mask | field `RDF` | kernel `D` | noise `tempn` |
108//! |---|---|---|---|---|
109//! | `'nosmv'` | unchanged | unchanged | `D` | `N_std` |
110//! | `'smv'`, shrink on (default) | `SMV(Mask) > 0.999` | `Mask·(RDF − SMV(RDF))` | `(1 − SphereK)·D` | `sqrt(SMV(tempn²) + tempn²)` |
111//! | `'smv'`, shrink off, release 2024.11.26 | unchanged | `Mask·(RDF − SMV(RDF))` | `(1 − SphereK)·D` | `sqrt(SMV(tempn²) + tempn²)` |
112//! | `'smv'`, shrink off, mSMV prerelease | unchanged | **unchanged** | `(1 − SphereK)·D` | `sqrt(SMV(tempn²) + tempn²)` |
113//! | `'msmv'` (prerelease only; forces shrink off) | unchanged | `msmv(RDF, Mask, R2s, ...)` | `(1 − SphereK)·D` | `sqrt(SMV(tempn²) + tempn²)` |
114//!
115//! `medi` follows the 2024.11.26 release for the first three rows ([`MediParams::smv`],
116//! [`MediParams::smv_shrink_mask`]) and the prerelease for `'msmv'` ([`MediParams::msmv`]),
117//! which only the prerelease has. The prerelease (`MEDI_toolbox-mSMV100125.zip`) moved the
118//! field high-pass inside `if opts.smv_shrink_mask && ~opts.msmv`, so with SMV on and shrinking
119//! off it fits the unfiltered field with the SMV-filtered kernel. Its comments and the mSMV
120//! paper do not describe this case: the paper's MEDI-SMV (Eq. 13) is the shrinking one. The
121//! two releases agree in every other row and, apart from the mSMV code, in the rest of
122//! `MEDI_L1.m`. In both, `'msmv'`/`'smv'` set `smv = 1`. In the prerelease, `'nosmv'` given
123//! after `'msmv'` turns SMV off and with it mSMV, a combination `medi` does not have:
124//! [`MediParams::msmv`] implies SMV.
125//!
126//! Checked against both toolboxes in MATLAB R2023b (`tests/medi_smv_parity.rs`, a 32×36×28
127//! phantom, no CSF term). The two `MEDI_L1.m` versions give identical χ with `'nosmv'` and with
128//! `'smv'` and shrinking. With shrinking off they differ by 101 % relative L2 after one step
129//! and 176 % with all defaults. `medi` against the release, χ over the mask:
130//!
131//! | options | one Gauss-Newton step: max abs diff (ppm), rel. L2 | all defaults (10 steps) |
132//! |---|---|---|
133//! | `'nosmv'` | 2.5e-7, 1.1e-6 | 1.4e-3, 1.9e-3 |
134//! | `'smv'`, shrink on | 1.1e-8, 2.1e-6 | 2.9e-8, 5.8e-7 |
135//! | `'smv'`, shrink off | 1.7e-7, 5.7e-7 | 2.0e-4, 2.1e-4 |
136//!
137//! After one step the differences are single (MATLAB) against double (here) rounding. Over
138//! ten steps they also include CG stopping one iteration apart at `cg_tol` 0.01.
139//!
140//! `tests/medi_cornell_parity.rs` checks the same against the verified QSM-CI `medi-cornell`
141//! container (MEDI 2024.11.26, QSMxT/QSM-CI#309; regenerated in Actions by
142//! `tests/data/medi_cornell_small.py`), measured 2026-10-30: one step 2.7e-7 / 1.3e-8 / 1.5e-7
143//! ppm max abs diff for the three rows above (the container writes χ as float32); and MEDI_L1's
144//! defaults end to end (SMV with shrinking, MERIT, 10 steps, MEDI+0 with the CSF mask from
145//! `arlo` and [`extract_csf`] on the phantom with three CSF-like blobs) 2.9e-8 ppm, relative L2
146//! 6.0e-7, every CG solve stopping where MATLAB's did. On the way, `fit_ppm_complex_noise`
147//! (N_std), `arlo` (R2*) and [`extract_csf`] are bit-identical to the container's.
148//!
149//! # MEDI-mSMV
150//! [`MediParams::msmv`] is `MEDI_L1(..., 'msmv', radius)` from the mSMV authors' MEDI toolbox
151//! (agr78/mSMV, `prerelease/MEDI_toolbox-mSMV100125.zip`; Roberts et al. 2024, Eq. 12): SMV is
152//! on with radius [`MediParams::smv_radius`], but instead of eroding the mask and high-passing
153//! the field, `MEDI_L1` passes the local field and the full mask to `msmv.m`
154//! ([`msmv`](super::msmv::msmv)), then inverts the result with the SMV-filtered dipole kernel
155//! `(1 − SphereK)·D` and the SMV-propagated noise, as MEDI-SMV does. The mask stays whole, so the
156//! result reaches the brain edge. `msmv.m` needs R2* for its vessel mask, and `MEDI_L1` errors
157//! without it; `medi` computes it from the echo magnitudes with `arlo.m`
158//! ([`arlo_unclipped`](crate::utils::arlo_unclipped)), as MEDI's pipeline and the authors' SEPIA
159//! patch do, so MEDI-mSMV needs at least three echoes (and panics with fewer; the pipeline
160//! returns an error). `msmv.m`'s field strength `B0_mag` is [`MediParams::b0`]. MEDI-mSMV takes
161//! the local field from a background removal (the paper: PDF, V-SHARP and LBV), not the total
162//! field. See [`msmv`](mod@super::msmv) for what the filter does and how it compares with the
163//! authors' MATLAB.
164//!
165//! **Deliberate deviation from `MEDI_L1.m`:** with SMV on, `sphere_kernel.m` builds its grid as
166//! `-N/2:N/2-1`, which along an axis with an odd number of voxels centres the sphere half a voxel
167//! off the grid. The SMV operator is then not symmetric, and `MEDI_L1` diverges (in MATLAB as
168//! here: NRMSE above 10000% on a 164×205×205 phantom). So when SMV is on, `medi` pads every axis
169//! that is still odd after `zeropad` by one more voxel and crops the result back. On grids that
170//! are even after `zeropad`, and with SMV off, nothing changes and the result is `MEDI_L1`'s.
171//!
172//! # Reference pipeline
173//! What MEDI's own pipeline script (`README.m` of the 2024.11.26 release; the release has no
174//! `MEDI_full_process.m`) runs around `MEDI_L1`. Inputs, units and the CSF term are above.
175//! - Field map: `Fit_ppm_complex(iField)` on the complex echoes (evenly spaced; README.m:87),
176//!   which also gives `N_std`; `Fit_ppm_complex_bipolar` for bipolar readouts (README.m:98).
177//!   Unwrapping: `unwrapPhase(iMag, iFreq_raw, matrix_size)`, MEDI's region-growing unwrapper
178//!   (README.m:93; graph-cut and Laplacian unwrapping are commented alternatives).
179//! - Brain mask: FSL BET via the bundled `bet2` MEX, `BET(iMag, matrix_size, voxel_size)` on the
180//!   root-sum-of-squares magnitude (README.m:118), with BET's defaults (fractional threshold 0.5,
181//!   gradient threshold 0; `functions/bet2/bet2.cpp:136`). `genMask.m` (magnitude threshold) is a
182//!   commented alternative.
183//! - Background removal: PDF, `PDF(iFreq, N_std, Mask, matrix_size, voxel_size, B0_dir)` with
184//!   `PDF.m`'s defaults (README.m:124). LBV with `tol` 0.005 is the commented alternative
185//!   (README.m:129). `MEDI_L1` then inverts this local field (`RDF`), so with SMV on, the SMV
186//!   high-pass is applied on top of the PDF local field, not to the total field.
187//! - Inversion: `MEDI_L1('lambda',1000,'lambda_CSF',100,'merit','smv',5)` (README.m:144), which
188//!   equals the 2024 defaults (`parse_QSM_input.m:10-20`: MERIT and SMV on, radius 5 mm). The
189//!   2020 release's `parse_QSM_input.m` defaulted to MERIT and SMV off.
190//! - ppm conversion: `delta_TE` is `TE(2) − TE(1)`, or `TE` for one echo (`Read_DICOM.m:166-168`),
191//!   and `CF` is read from the DICOM header; QSM.rs uses `CF = γ·B0`.
192//! - Field strength: nothing in `MEDI_L1`, `PDF`, `extract_CSF` or `arlo` depends on it beyond
193//!   `CF`; λ = 1000 and `extract_CSF`'s 5 Hz R2* threshold are the same at 3 T and 7 T.
194//!
195//! Reference:
196//! Liu, T., Liu, J., de Rochefort, L., Spincemaille, P., Khalidov, I., Ledoux, J.R.,
197//! Wang, Y. (2011). "Morphology enabled dipole inversion (MEDI) from a single-angle
198//! acquisition: comparison with COSMOS in human brain imaging."
199//! Magnetic Resonance in Medicine, 66(3):777-783. https://doi.org/10.1002/mrm.22816
200//!
201//! Liu, J., Liu, T., de Rochefort, L., Ledoux, J., Khalidov, I., Chen, W., Tsiouris, A.J.,
202//! Wisnieff, C., Spincemaille, P., Prince, M.R., Wang, Y. (2012).
203//! "Morphology enabled dipole inversion for quantitative susceptibility mapping using
204//! structural consistency between the magnitude image and the susceptibility map."
205//! NeuroImage, 59(3):2560-2568.
206//!
207//! Reference implementation: MEDI toolbox release 2024.11.26 (Cornell MRI Research Lab,
208//! `functions/MEDI_L1.m`)
209//!
210//! The double-precision helpers below are shared with [`tfi`](super::tfi), which is `MEDI_L1.m`
211//! run with a preconditioner, and with the χ-separation ports.
212//!
213//! # Pipeline rules
214//!
215//! The facts above that a configuration can depart from are published as rules in
216//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `medi.mask-bet`, `medi.field-complex-fit`, `medi.te-spacing`, `medi-msmv.three-echoes`, `medi-msmv.bg-removal`, `medi-msmv.prefilter`, `medi.bg-pdf`.
217
218use num_complex::Complex64;
219#[cfg(feature = "parallel")]
220use rayon::prelude::*;
221use crate::fft::{Box3, Fft3dWorkspace, FftOpts};
222use crate::kernels::dipole::dipole_kernel;
223use crate::{EchoTime, Grid};
224
225/// MEDI parameters (`MEDI_L1.m` / `parse_QSM_input.m` defaults)
226#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
227#[derive(Clone, Debug)]
228pub struct MediParams {
229    /// Weight of the data-fidelity term (`'lambda'`): larger fits the data more closely
230    pub lambda: f64,
231    /// MERIT: reweight the noise by the data residual after each iteration (`'merit'`)
232    pub merit: bool,
233    /// MEDI-SMV (`'smv'`; `false` is `'nosmv'`): high-pass the local field with the spherical
234    /// mean value kernel, `RDF − SMV(RDF)`, and fit it with the SMV-filtered dipole kernel
235    /// `(1 − SphereK)·D` and the SMV-propagated noise `sqrt(SMV(N²) + N²)`. This removes the
236    /// residual background a background removal leaves near the edge. It is applied to the
237    /// local field, not the total field (see the module docs). On by default, as in
238    /// `MEDI_L1.m`.
239    pub smv: bool,
240    /// SMV radius in mm
241    pub smv_radius: f64,
242    /// With SMV, erode the mask to where the SMV of the mask exceeds 0.999 (`smv_shrink_mask`,
243    /// default on). With it off, the field is still high-passed, the kernel filtered and the
244    /// noise propagated, on the full mask, as the 2024.11.26 `MEDI_L1.m` does. The mSMV
245    /// prerelease's `MEDI_L1.m` skips the field high-pass in this case and keeps the rest; `medi`
246    /// follows the release (table in the module docs). The edge band within the SMV radius then
247    /// holds `RDF − SMV(RDF)` of a sphere that reaches outside the mask. Ignored without SMV,
248    /// and with [`msmv`](Self::msmv), which keeps the mask, as `MEDI_L1` does.
249    pub smv_shrink_mask: bool,
250    /// MEDI-mSMV (`MEDI_L1(..., 'msmv', smv_radius)`): replace the SMV step's mask erosion and
251    /// field high-pass with [`msmv`](super::msmv::msmv) on the full mask, keeping the
252    /// SMV-filtered dipole kernel and noise. Takes the local field from a background removal
253    /// (the authors used PDF, V-SHARP and LBV) on its mask; implies SMV (radius
254    /// [`smv_radius`](Self::smv_radius)). Needs at least three echoes: the vessel mask uses R2*
255    /// from `arlo.m` on the echo magnitudes, as MEDI's pipeline computes it. `None` (default) is
256    /// plain MEDI or MEDI-SMV. See the module docs.
257    pub msmv: Option<super::msmv::MsmvParams>,
258    /// Data weighting: 0 uniform (1 everywhere), 1 SNR (`mask / n_std`, mean 1 in the mask)
259    pub data_weighting: i32,
260    /// Edge fraction for the gradient mask (`'percentage'`): the threshold is set so that the
261    /// number of gradient components (all three directions) above it is this fraction of the
262    /// number of voxels in the mask
263    pub percentage: f64,
264    /// CG relative residual tolerance
265    pub cg_tol: f64,
266    /// Maximum CG iterations per Gauss-Newton step
267    pub cg_max_iter: usize,
268    /// Maximum Gauss-Newton iterations
269    pub max_iter: usize,
270    /// Stop when ||dχ|| / ||χ|| falls below this
271    pub tol: f64,
272    /// Field strength in Tesla (scan parameter, for the radian conversion)
273    pub b0: f64,
274    /// Echo times in seconds, one per echo magnitude (scan parameter). The radian conversion
275    /// picks one with [`te`](Self::te); the CSF mask uses R2* from all of them (`arlo.m`).
276    pub echo_times: Vec<f64>,
277    /// Which echo time the radian conversion uses. Default [`EchoTime::Spacing`], MEDI's
278    /// `delta_TE`: `MEDI_L1.m` works on the field in radians over `delta_TE`
279    /// (`x/(2*pi*delta_TE*CF)*1e6`), which its readers set to `TE(2) − TE(1)`, or `TE` for one
280    /// echo (`Read_Siemens_DICOM.m` lines 176-178).
281    pub te: EchoTime,
282    /// MEDI+0: zero-reference to the ventricular CSF (`extract_CSF.m` on `arlo.m` R2*, and
283    /// `MEDI_L1`'s `lambda_CSF` term). On by default, as in MEDI's pipeline. Needs at least
284    /// three echoes; with fewer, MEDI runs without it (see the module docs).
285    pub csf: bool,
286    /// Weight of the CSF term (`'lambda_CSF'`)
287    pub lambda_csf: f64,
288    /// `extract_CSF.m`'s `thresh_R2s`: voxels below this R2* (Hz) are CSF candidates
289    pub csf_r2s_threshold: f64,
290    /// `extract_CSF.m`'s `flag_erode`: erode the mask by a 10 mm SMV before finding the CSF
291    pub csf_erode: bool,
292    /// Voxels of zeros appended along x, y and z before the inversion (`'zeropad'`). With SMV,
293    /// axes that are still odd get one more (see the module docs).
294    pub zeropad: (usize, usize, usize),
295}
296
297/// The magnitude of each echo, `n_echoes` volumes stacked with the echo slowest. MEDI's edge
298/// mask uses their root sum of squares and its data weight the `Fit_ppm_complex.m` noise.
299#[derive(Clone, Copy, Debug)]
300pub struct EchoMagnitudes<'a> {
301    /// `n_echoes` magnitude volumes, echo slowest
302    pub data: &'a [f64],
303    /// Number of echoes (evenly spaced)
304    pub n_echoes: usize,
305}
306
307impl Default for MediParams {
308    fn default() -> Self {
309        Self {
310            lambda: 1000.0,
311            merit: true,
312            smv: true,
313            smv_radius: 5.0,
314            smv_shrink_mask: true,
315            msmv: None,
316            data_weighting: 1,
317            percentage: 0.9,
318            cg_tol: 0.01,
319            cg_max_iter: 100,
320            max_iter: 10,
321            tol: 0.1,
322            b0: 3.0,
323            echo_times: vec![0.005],
324            te: EchoTime::Spacing,
325            csf: true,
326            lambda_csf: 100.0,
327            csf_r2s_threshold: 5.0,
328            csf_erode: true,
329            zeropad: (0, 0, 0),
330        }
331    }
332}
333
334/// MEDI dipole inversion (`MEDI_L1.m`)
335///
336/// # Arguments
337/// * `local_field` - Local field in ppm (nx * ny * nz)
338/// * `mask` - Binary mask (nx * ny * nz), 1 = brain
339/// * `magnitude` - The echo magnitudes (see module docs)
340/// * `grid` - Volume grid (dimensions and voxel sizes)
341/// * `bdir` - B0 field direction
342/// * `params` - MEDI parameters, including field strength and echo spacing
343/// * `progress` - Progress callback `(current_step, total_steps)` over CG iterations
344///
345/// # Returns
346/// Susceptibility in ppm, zero outside the mask (the SMV-eroded mask when `smv` is on without
347/// `msmv`)
348pub fn medi(
349    local_field: &[f64],
350    mask: &[u8],
351    magnitude: EchoMagnitudes,
352    grid: &Grid,
353    bdir: (f64, f64, f64),
354    params: &MediParams,
355    progress: impl FnMut(usize, usize),
356) -> Vec<f64> {
357    let n = grid.n_total();
358    // iMag = sqrt(sum(abs(iField).^2,4)); [~, N_std] = Fit_ppm_complex(iField)
359    let EchoMagnitudes { data: m, n_echoes } = magnitude;
360    assert!(n_echoes > 0 && m.len() == n * n_echoes, "magnitude is not n_echoes volumes");
361    let imag: Vec<f64> = (0..n)
362        .map(|i| (0..n_echoes).map(|e| m[i + n * e].powi(2)).sum::<f64>().sqrt())
363        .collect();
364    let n_std = crate::utils::fit_ppm_complex_noise(m, n_echoes);
365    let te = &params.echo_times;
366    assert_eq!(te.len(), n_echoes, "echo_times does not match the echo magnitudes");
367    // MEDI-mSMV's vessel mask needs R2s (MEDI_L1 errors without it); MEDI's pipeline and the
368    // authors' SEPIA patch compute it as arlo(TE, abs(iField)), negative values and all.
369    let r2s_msmv: Option<Vec<f64>> = params.msmv.as_ref().map(|_| {
370        crate::utils::r2star::arlo_unclipped(m, te, n)
371            .unwrap_or_else(|e| panic!("MEDI-mSMV needs R2* from three or more echoes: {e}"))
372    });
373    // R2s = arlo(TE, abs(iField)); Mask_CSF = extract_CSF(R2s, Mask, voxel_size)
374    let csf: Option<Vec<bool>> = if params.csf && n_echoes >= 3 {
375        let r2s = crate::utils::r2star::arlo(m, te, n).expect("three or more increasing echo times");
376        let c = extract_csf(&r2s, mask, grid, params.csf_r2s_threshold, params.csf_erode);
377        c.iter().any(|&v| v != 0).then(|| c.iter().map(|&v| v != 0).collect())
378    } else {
379        None
380    };
381
382    let (nx, ny, nz) = grid.dims;
383    let (zx, zy, zz) = params.zeropad;
384    // Deviation (see module docs): with SMV, pad axes that are still odd by one voxel.
385    let smv_on = params.smv || params.msmv.is_some();
386    let even = |n: usize, z: usize| z + usize::from(smv_on && (n + z) % 2 == 1);
387    let (px, py, pz) = (even(nx, zx), even(ny, zy), even(nz, zz));
388    if (px, py, pz) == (0, 0, 0) {
389        return medi_l1(
390            local_field, mask, &imag, &n_std, csf.as_deref(), r2s_msmv.as_deref(), grid, bdir, params, progress,
391        );
392    }
393    // padarray(..., pad, 'post'), then x = x(1:matrix_size0(1), ...)
394    let (mx, my, mz) = (nx + px, ny + py, nz + pz);
395    let pad = |v: &[f64]| -> Vec<f64> {
396        let mut out = vec![0.0; mx * my * mz];
397        for k in 0..nz {
398            for j in 0..ny {
399                let (src, dst) = (nx * (j + ny * k), mx * (j + my * k));
400                out[dst..dst + nx].copy_from_slice(&v[src..src + nx]);
401            }
402        }
403        out
404    };
405    let mask_f: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
406    let mask_p: Vec<u8> = pad(&mask_f).iter().map(|&v| v as u8).collect();
407    let pgrid = Grid::new(mx, my, mz, grid.vsx(), grid.vsy(), grid.vsz());
408    let csf_p: Option<Vec<bool>> = csf.map(|c| {
409        let f: Vec<f64> = c.iter().map(|&b| b as u8 as f64).collect();
410        pad(&f).iter().map(|&v| v != 0.0).collect()
411    });
412    let r2s_p = r2s_msmv.as_deref().map(pad);
413    let chi = medi_l1(
414        &pad(local_field), &mask_p, &pad(&imag), &pad(&n_std), csf_p.as_deref(), r2s_p.as_deref(),
415        &pgrid, bdir, params, progress,
416    );
417    let mut out = vec![0.0; n];
418    for k in 0..nz {
419        for j in 0..ny {
420            let (dst, src) = (nx * (j + ny * k), mx * (j + my * k));
421            out[dst..dst + nx].copy_from_slice(&chi[src..src + nx]);
422        }
423    }
424    out
425}
426
427/// `MEDI_L1.m` on an `iMag`, an `N_std` and, for MEDI+0, a `Mask_CSF`
428#[allow(clippy::too_many_arguments)]
429fn medi_l1(
430    local_field: &[f64],
431    mask: &[u8],
432    magnitude: &[f64],
433    n_std: &[f64],
434    mask_csf: Option<&[bool]>,
435    r2s: Option<&[f64]>,
436    grid: &Grid,
437    bdir: (f64, f64, f64),
438    params: &MediParams,
439    mut progress: impl FnMut(usize, usize),
440) -> Vec<f64> {
441    let n = grid.n_total();
442    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
443    let mut fft = Fft3dWorkspace::new(grid.nx(), grid.ny(), grid.nz());
444    let mut buf = vec![Complex64::new(0.0, 0.0); n];
445
446    // ppm -> radians over delta_TE (by default); MEDI_L1 converts back with
447    // x/(2*pi*delta_TE*CF)*1e6.
448    let cf = crate::constants::GAMMA_BAR_HZ_PER_T * params.b0;
449    let delta_te = params.te.seconds(&params.echo_times, "MEDI");
450    let ppm_to_rad = 2.0 * std::f64::consts::PI * delta_te * cf * 1e-6;
451    let mut rdf = vec![0.0f64; n];
452    par_fill(&mut rdf, |i| local_field[i] * ppm_to_rad);
453
454    // N_std = N_std.*Mask; tempn = N_std
455    let mut mask: Vec<bool> = mask.iter().map(|&m| m != 0).collect();
456    let mut n_std: Vec<f64> = (0..n)
457        .map(|i| if mask[i] { n_std[i] } else { 0.0 })
458        .collect();
459    let mut tempn = n_std.clone();
460
461    // D = dipole_kernel(...); complex because the SMV step multiplies it by SphereK.
462    let mut d: Vec<Complex64> = dipole_kernel(grid, bdir).iter().map(|&v| Complex64::new(v, 0.0)).collect();
463
464    let sphere_k = if params.smv || params.msmv.is_some() {
465        let k = sphere_kernel(grid, params.smv_radius, &mut fft);
466        if let Some(mp) = &params.msmv {
467            // if opts.msmv: RDF = msmv(RDF, Mask, opts.R2s, voxel_size, radius, B0_mag, ...),
468            // on the full mask (smv_shrink_mask is off). B0_mag: the scan's field strength
469            // (see the msmv module docs).
470            let r2s = r2s.expect("MEDI-mSMV needs R2*");
471            let mask_u8: Vec<u8> = mask.iter().map(|&b| u8::from(b)).collect();
472            rdf = super::msmv::msmv(&rdf, &mask_u8, r2s, grid, params.smv_radius, params.b0, mp).field;
473        } else {
474            // if opts.smv_shrink_mask, Mask = SMV(Mask, SphereK) > 0.999
475            if params.smv_shrink_mask {
476                let m: Vec<f64> = mask.iter().map(|&b| b as u8 as f64).collect();
477                smv(&m, &k, &mut fft, &mut buf);
478                par_fill(&mut mask, |i| buf[i].re > 0.999);
479            }
480            // RDF = RDF - SMV(RDF, SphereK); RDF = RDF.*Mask, with or without shrinking, as
481            // in the 2024.11.26 release. The mSMV prerelease does this only when shrinking
482            // (see the SMV options table in the module docs).
483            smv(&rdf, &k, &mut fft, &mut buf);
484            par_update(&mut rdf, |i, v| *v = if mask[i] { *v - buf[i].re } else { 0.0 });
485        }
486        // D = (1-SphereK).*D
487        par_update(&mut d, |i, v| *v *= Complex64::new(1.0, 0.0) - k[i]);
488        // tempn = sqrt(SMV(tempn.^2, SphereK) + tempn.^2)
489        smv_noise(&mut tempn, &k, &mut fft, &mut buf);
490        Some(k)
491    } else {
492        None
493    };
494
495    let mut m = vec![0.0f64; n];
496    dataterm_mask(params.data_weighting, &tempn, &mask, &mut m);
497    let mut b0 = vec![Complex64::new(0.0, 0.0); n];
498    par_fill(&mut b0, |i| m[i] * Complex64::new(0.0, rdf[i]).exp());
499    let wg = gradient_mask(magnitude, &mask, grid, params.percentage);
500    // The data weight m, and with it w and conj(w).*w, is zero outside the mask, so the
501    // Dconv(dx) in A(dx) is only needed, and the Dconv after the weighting only has input,
502    // inside the mask's bounding box.
503    let mbox = if params.data_weighting == 0 {
504        [0..grid.nx(), 0..grid.ny(), 0..grid.nz()]
505    } else {
506        bounding_box(&mask, grid.dims)
507    };
508
509    let lambda2 = 2.0 * params.lambda;
510    // reg_CSF.m: opHop(x) = lambda_CSF * LT(LT(x)), LT(x) = Mask_CSF.*(x - mean(x(Mask_CSF)))
511    let csf_idx: Vec<usize> = mask_csf.map_or(Vec::new(), |c| (0..n).filter(|&i| c[i]).collect());
512    let reg_csf = |x: &[f64], acc: &mut [f64]| {
513        if csf_idx.is_empty() {
514            return;
515        }
516        let len = csf_idx.len() as f64;
517        let mean = csf_idx.iter().map(|&i| x[i]).sum::<f64>() / len;
518        let t1: Vec<f64> = csf_idx.iter().map(|&i| x[i] - mean).collect();
519        let mean = t1.iter().sum::<f64>() / len;
520        for (q, &i) in csf_idx.iter().enumerate() {
521            acc[i] += params.lambda_csf * (t1[q] - mean);
522        }
523    };
524    let e = 1e-6;
525    let dims = grid.dims;
526    let mut x = vec![0.0f64; n];
527    // wG.*Vr.*wG per direction, Vr = 1./sqrt(abs(wG.*grad(real(x))).^2 + e)
528    let mut wvw = [vec![0.0f64; n], vec![0.0f64; n], vec![0.0f64; n]];
529    let mut g = [vec![0.0f64; n], vec![0.0f64; n], vec![0.0f64; n]];
530    let mut w = vec![Complex64::new(0.0, 0.0); n];
531    let mut w2 = vec![0.0f64; n];
532    let mut b = vec![0.0f64; n];
533    let mut t = vec![0.0f64; n];
534    let mut rv = vec![0.0f64; n];
535    let mut cg = CgBuffers::new(n);
536    let mask_idx: Vec<usize> = (0..n).filter(|&i| mask[i]).collect();
537    let total = params.max_iter * params.cg_max_iter;
538
539    let mut res_norm_ratio = f64::INFINITY;
540    let mut iter = 0;
541    while res_norm_ratio > params.tol && iter < params.max_iter {
542        iter += 1;
543        // Vr = 1./sqrt(abs(wG.*grad(real(x))).^2 + e), per direction; kept as wG.*Vr.*wG
544        fgrad(&x, dims, vs, &mut g);
545        for c in 0..3 {
546            let (wgc, gc) = (&wg[c], &g[c]);
547            par_fill(&mut wvw[c], |i| {
548                let u = wgc[i] * gc[i];
549                let vr = 1.0 / (u * u + e).sqrt();
550                wgc[i] * vr * wgc[i]
551            });
552        }
553        // w = W(m, x) = m.*exp(1i*Dconv(x))
554        dconv(&x, &d, &mut fft, &mut buf, &mut t);
555        par_fill(&mut w, |i| m[i] * Complex64::new(0.0, t[i]).exp());
556        par_fill(&mut w2, |i| w[i].norm_sqr());
557
558        // b = reg(x) + 2*lambda*Dconv(real(conj(w).*conj(1i).*(w-b0)))
559        reg(&x, &wvw, dims, vs, &mut b);
560        reg_csf(&x, &mut b);
561        par_fill(&mut rv, |i| (w[i].conj() * Complex64::new(0.0, -1.0) * (w[i] - b0[i])).re);
562        dconv(&rv, &d, &mut fft, &mut buf, &mut t);
563        par_update(&mut b, |i, v| *v = -(*v + lambda2 * t[i]));
564
565        // A = @(dx) reg(dx) + 2*lambda*Dconv(conj(w).*w.*Dconv(dx))
566        let base = (iter - 1) * params.cg_max_iter;
567        cgsolve(
568            |p: &[f64], out: &mut [f64]| {
569                reg(p, &wvw, dims, vs, out);
570                reg_csf(p, out);
571                dconv_weighted(p, &d, &w2, &mbox, &mut fft, &mut buf);
572                par_update(out, |i, v| *v += lambda2 * buf[i].re);
573            },
574            &b, params.cg_tol, params.cg_max_iter, &mut cg,
575            |k| progress(base + k, total),
576        );
577        let dx = &cg.x;
578
579        // res_norm_ratio = norm(dx)/norm(x); x = x + dx
580        let ndx = dx.iter().map(|v| v * v).sum::<f64>().sqrt();
581        let nrm_x = x.iter().map(|v| v * v).sum::<f64>().sqrt();
582        res_norm_ratio = ndx / nrm_x;
583        par_update(&mut x, |i, v| *v += dx[i]);
584
585        if params.merit {
586            // wres = m.*exp(1i*real(ifftn(D.*fftn(x)))) - b0, used inside the mask only
587            dconv(&x, &d, &mut fft, &mut buf, &mut t);
588            let wres: Vec<Complex64> = mask_idx
589                .iter()
590                .map(|&i| m[i] * Complex64::new(0.0, t[i]).exp() - b0[i])
591                .collect();
592            let mean = wres.iter().copied().sum::<Complex64>() / mask_idx.len() as f64;
593            // factor = std(abs(a))*6, MATLAB's std (normalised by N-1)
594            let a: Vec<f64> = wres.iter().map(|&v| (v - mean).norm()).collect();
595            let am = a.iter().sum::<f64>() / a.len() as f64;
596            let sd = (a.iter().map(|v| (v - am).powi(2)).sum::<f64>() / (a.len() as f64 - 1.0)).sqrt();
597            let factor = sd * 6.0;
598            for (q, &i) in mask_idx.iter().enumerate() {
599                let r = (a[q] / factor).max(1.0);
600                n_std[i] *= r * r;
601            }
602            tempn.copy_from_slice(&n_std);
603            if let Some(k) = &sphere_k {
604                smv_noise(&mut tempn, k, &mut fft, &mut buf);
605            }
606            dataterm_mask(params.data_weighting, &tempn, &mask, &mut m);
607            par_fill(&mut b0, |i| m[i] * Complex64::new(0.0, rdf[i]).exp());
608        }
609    }
610    progress(total, total);
611
612    // x = x/(2*pi*delta_TE*CF)*1e6.*Mask
613    par_update(&mut x, |i, v| *v = if mask[i] { *v / ppm_to_rad } else { 0.0 });
614    // if flag_CSF, x = x - mean(x(Mask_CSF)); kept zero outside the mask (see the module docs)
615    if !csf_idx.is_empty() {
616        let mean = csf_idx.iter().map(|&i| x[i]).sum::<f64>() / csf_idx.len() as f64;
617        par_update(&mut x, |i, v| {
618            if mask[i] {
619                *v -= mean;
620            }
621        });
622    }
623    x
624}
625
626/// `out[i] = f(i)` for every `i` (over threads with the `parallel` feature)
627pub(crate) fn par_fill<T: Send>(out: &mut [T], f: impl Fn(usize) -> T + Sync + Send) {
628    #[cfg(feature = "parallel")]
629    out.par_iter_mut().with_min_len(1 << 14).enumerate().for_each(|(i, v)| *v = f(i));
630    #[cfg(not(feature = "parallel"))]
631    out.iter_mut().enumerate().for_each(|(i, v)| *v = f(i));
632}
633
634/// `f(i, &mut out[i])` for every `i` (over threads with the `parallel` feature)
635pub(crate) fn par_update<T: Send>(out: &mut [T], f: impl Fn(usize, &mut T) + Sync + Send) {
636    #[cfg(feature = "parallel")]
637    out.par_iter_mut().with_min_len(1 << 14).enumerate().for_each(|(i, v)| f(i, v));
638    #[cfg(not(feature = "parallel"))]
639    out.iter_mut().enumerate().for_each(|(i, v)| f(i, v));
640}
641
642/// The smallest box holding every `true` voxel (the whole grid if there is none)
643pub(super) fn bounding_box(mask: &[bool], (nx, ny, nz): (usize, usize, usize)) -> Box3 {
644    let (mut lo, mut hi) = ([usize::MAX; 3], [0usize; 3]);
645    for (p, _) in mask.iter().enumerate().filter(|(_, &m)| m) {
646        let c = [p % nx, (p / nx) % ny, p / (nx * ny)];
647        for a in 0..3 {
648            lo[a] = lo[a].min(c[a]);
649            hi[a] = hi[a].max(c[a] + 1);
650        }
651    }
652    if lo[0] == usize::MAX {
653        return [0..nx, 0..ny, 0..nz];
654    }
655    [lo[0]..hi[0], lo[1]..hi[1], lo[2]..hi[2]]
656}
657
658/// Loads a real array into the transform's rows as `complex(x, 0)`
659fn load_real(x: &[f64]) -> impl Fn(usize, &mut [Complex64]) + Sync + '_ {
660    move |l, row| {
661        for (c, &v) in row.iter_mut().zip(&x[l..]) {
662            *c = Complex64::new(v, 0.0);
663        }
664    }
665}
666
667/// Multiplies the transform's rows by `k` as they are loaded (`K.*buf`, fused into the first
668/// pass of the next transform)
669fn mul_kernel(k: &[Complex64]) -> impl Fn(usize, &mut [Complex64]) + Sync + '_ {
670    move |l, row| {
671        for (c, &kv) in row.iter_mut().zip(&k[l..]) {
672            *c *= kv;
673        }
674    }
675}
676
677/// `Dconv`: `out = real(ifftn(D.*fftn(x)))`
678pub(super) fn dconv(x: &[f64], d: &[Complex64], fft: &mut Fft3dWorkspace, buf: &mut [Complex64], out: &mut [f64]) {
679    let load = load_real(x);
680    fft.fft3d_with(buf, &FftOpts { load: Some(&load), ..FftOpts::default() });
681    fft.ifft3d_with(buf, &FftOpts { load: Some(&mul_kernel(d)), ..FftOpts::default() });
682    par_fill(out, |i| buf[i].re);
683}
684
685/// `Dconv(w2.*Dconv(p))`, left in the real part of `buf`. `w2` is zero outside `mbox`, so the
686/// inner `Dconv` is computed only on `mbox` and the outer one reads input only there; the
687/// values are those of the two plain `Dconv`s (see [`Fft3dWorkspace::fft3d_with`]).
688pub(super) fn dconv_weighted(
689    p: &[f64],
690    d: &[Complex64],
691    w2: &[f64],
692    mbox: &Box3,
693    fft: &mut Fft3dWorkspace,
694    buf: &mut [Complex64],
695) {
696    let load = load_real(p);
697    fft.fft3d_with(buf, &FftOpts { load: Some(&load), ..FftOpts::default() });
698    // t = real(ifftn(D.*...)).*w2 on the box (the imaginary part is dropped on the way back in)
699    let mul_d = mul_kernel(d);
700    fft.ifft3d_with(buf, &FftOpts { needed: Some(mbox), post: Some(w2), load: Some(&mul_d), ..FftOpts::default() });
701    let drop_imag = |_: usize, row: &mut [Complex64]| {
702        for c in row.iter_mut() {
703            c.im = 0.0;
704        }
705    };
706    fft.fft3d_with(buf, &FftOpts { support: Some(mbox), load: Some(&drop_imag), ..FftOpts::default() });
707    fft.ifft3d_with(buf, &FftOpts { load: Some(&mul_d), ..FftOpts::default() });
708}
709
710/// `fgrad.m`: forward differences, zero at the last voxel along each axis
711pub(crate) fn fgrad(x: &[f64], dims: (usize, usize, usize), vs: [f64; 3], g: &mut [Vec<f64>; 3]) {
712    let (nx, ny, nz) = dims;
713    let nxy = nx * ny;
714    let [g0, g1, g2] = g;
715    let plane = |k: usize, ((a, b), c): ((&mut [f64], &mut [f64]), &mut [f64])| {
716        for j in 0..ny {
717            for i in 0..nx {
718                let q = i + nx * j;
719                let p = q + nxy * k;
720                a[q] = if i + 1 < nx { (x[p + 1] - x[p]) / vs[0] } else { 0.0 };
721                b[q] = if j + 1 < ny { (x[p + nx] - x[p]) / vs[1] } else { 0.0 };
722                c[q] = if k + 1 < nz { (x[p + nxy] - x[p]) / vs[2] } else { 0.0 };
723            }
724        }
725    };
726    #[cfg(feature = "parallel")]
727    g0.par_chunks_mut(nxy)
728        .zip(g1.par_chunks_mut(nxy))
729        .zip(g2.par_chunks_mut(nxy))
730        .enumerate()
731        .for_each(|(k, s)| plane(k, s));
732    #[cfg(not(feature = "parallel"))]
733    g0.chunks_mut(nxy)
734        .zip(g1.chunks_mut(nxy))
735        .zip(g2.chunks_mut(nxy))
736        .enumerate()
737        .for_each(|(k, s)| plane(k, s));
738}
739
740/// `bdiv.m`: the adjoint of `fgrad` (minus the backward-difference divergence). [`reg`] computes
741/// the same with the gradients fused in; this is its reference in the tests.
742#[cfg(test)]
743fn bdiv(g: &[Vec<f64>; 3], dims: (usize, usize, usize), vs: [f64; 3], out: &mut [f64]) {
744    let (nx, ny, nz) = dims;
745    let nxy = nx * ny;
746    // Dx = [Gx(1:end-1); 0] - [0; Gx(1:end-1)]; div = -(Dx/vsx + Dy/vsy + Dz/vsz)
747    let term = |gc: &[f64], p: usize, i: usize, len: usize, stride: usize| -> f64 {
748        let cur = if i + 1 < len { gc[p] } else { 0.0 };
749        let prev = if i > 0 { gc[p - stride] } else { 0.0 };
750        cur - prev
751    };
752    for k in 0..nz {
753        for j in 0..ny {
754            for i in 0..nx {
755                let p = i + nx * j + nxy * k;
756                out[p] = -(term(&g[0], p, i, nx, 1) / vs[0]
757                    + term(&g[1], p, j, ny, nx) / vs[1]
758                    + term(&g[2], p, k, nz, nxy) / vs[2]);
759            }
760        }
761    }
762}
763
764/// `reg(dx) = div(wG.*(Vr.*(wG.*grad(real(dx)))))` into `out`, with `wvw = wG.*Vr.*wG`: `bdiv`
765/// of the weighted `fgrad`, a z plane at a time so the gradients never leave the cache. Each
766/// value is computed with the same operations as `fgrad` followed by `bdiv`.
767pub(crate) fn reg(x: &[f64], wvw: &[Vec<f64>; 3], dims: (usize, usize, usize), vs: [f64; 3], out: &mut [f64]) {
768    let (nx, ny, nz) = dims;
769    let nxy = nx * ny;
770    // Weighted forward differences of plane k: x and y into gx, gy (when given), z into gz.
771    let grad = |k: usize, gx: Option<(&mut [f64], &mut [f64])>, gz: &mut [f64]| {
772        let base = nxy * k;
773        for q in 0..nxy {
774            let p = base + q;
775            gz[q] = (if k + 1 < nz { (x[p + nxy] - x[p]) / vs[2] } else { 0.0 }) * wvw[2][p];
776        }
777        if let Some((gx, gy)) = gx {
778            for j in 0..ny {
779                for i in 0..nx {
780                    let q = i + nx * j;
781                    let p = base + q;
782                    gx[q] = (if i + 1 < nx { (x[p + 1] - x[p]) / vs[0] } else { 0.0 }) * wvw[0][p];
783                    gy[q] = (if j + 1 < ny { (x[p + nx] - x[p]) / vs[1] } else { 0.0 }) * wvw[1][p];
784                }
785            }
786        }
787    };
788    // Dx = [Gx(1:end-1); 0] - [0; Gx(1:end-1)]; div = -(Dx/vsx + Dy/vsy + Dz/vsz)
789    let planes = |k0: usize, o: &mut [f64], bufs: &mut [Vec<f64>; 4]| {
790        let [gx, gy, gz, gz_prev] = bufs;
791        if k0 > 0 {
792            grad(k0 - 1, None, gz_prev);
793        }
794        for (dk, op) in o.chunks_mut(nxy).enumerate() {
795            let k = k0 + dk;
796            grad(k, Some((gx, gy)), gz);
797            for j in 0..ny {
798                for i in 0..nx {
799                    let q = i + nx * j;
800                    let tx = (if i + 1 < nx { gx[q] } else { 0.0 }) - (if i > 0 { gx[q - 1] } else { 0.0 });
801                    let ty = (if j + 1 < ny { gy[q] } else { 0.0 }) - (if j > 0 { gy[q - nx] } else { 0.0 });
802                    let tz = (if k + 1 < nz { gz[q] } else { 0.0 }) - (if k > 0 { gz_prev[q] } else { 0.0 });
803                    op[q] = -(tx / vs[0] + ty / vs[1] + tz / vs[2]);
804                }
805            }
806            std::mem::swap(gz, gz_prev);
807        }
808    };
809    let new_bufs = || [vec![0.0f64; nxy], vec![0.0f64; nxy], vec![0.0f64; nxy], vec![0.0f64; nxy]];
810    // A few planes per task: each task recomputes one plane of z differences.
811    const PLANES: usize = 4;
812    #[cfg(feature = "parallel")]
813    out.par_chunks_mut(nxy * PLANES)
814        .enumerate()
815        .for_each_init(new_bufs, |bufs, (c, o)| planes(c * PLANES, o, bufs));
816    #[cfg(not(feature = "parallel"))]
817    {
818        let mut bufs = new_bufs();
819        out.chunks_mut(nxy * PLANES).enumerate().for_each(|(c, o)| planes(c * PLANES, o, &mut bufs));
820    }
821}
822
823/// `cgsolve.m`'s vectors, reused across Gauss-Newton iterations
824pub(crate) struct CgBuffers {
825    pub(crate) x: Vec<f64>,
826    r: Vec<f64>,
827    d: Vec<f64>,
828    q: Vec<f64>,
829}
830
831impl CgBuffers {
832    pub(crate) fn new(n: usize) -> Self {
833        Self { x: vec![0.0; n], r: vec![0.0; n], d: vec![0.0; n], q: vec![0.0; n] }
834    }
835}
836
837/// `cgsolve.m` from a zero start; the last iterate is left in `cg.x`. `progress` receives the
838/// iteration count. The dot products are summed in index order, as before.
839pub(crate) fn cgsolve(
840    mut a: impl FnMut(&[f64], &mut [f64]),
841    b: &[f64],
842    tol: f64,
843    max_iter: usize,
844    cg: &mut CgBuffers,
845    mut progress: impl FnMut(usize),
846) {
847    let dot = |u: &[f64], v: &[f64]| u.iter().zip(v).map(|(a, b)| a * b).sum::<f64>();
848    let CgBuffers { x, r, d, q } = cg;
849    x.fill(0.0);
850    r.copy_from_slice(b);
851    d.copy_from_slice(b);
852    let mut delta = dot(r, r);
853    let delta0 = dot(b, b);
854    let mut numiter = 0;
855    while numiter < max_iter && delta > tol * tol * delta0 {
856        a(d, q);
857        let alpha = delta / dot(d, q);
858        if (numiter + 1) % 50 == 0 {
859            par_update(x, |i, v| *v += alpha * d[i]);
860            // r = b - A*x, recomputed every 50 iterations
861            a(x, q);
862            par_fill(r, |i| b[i] - q[i]);
863        } else {
864            #[cfg(feature = "parallel")]
865            x.par_iter_mut().zip(r.par_iter_mut()).with_min_len(1 << 14).enumerate().for_each(|(i, (xv, rv))| {
866                *xv += alpha * d[i];
867                *rv -= alpha * q[i];
868            });
869            #[cfg(not(feature = "parallel"))]
870            x.iter_mut().zip(r.iter_mut()).enumerate().for_each(|(i, (xv, rv))| {
871                *xv += alpha * d[i];
872                *rv -= alpha * q[i];
873            });
874        }
875        let delta_old = delta;
876        delta = dot(r, r);
877        let beta = delta / delta_old;
878        let rr = &*r;
879        par_update(d, |i, v| *v = rr[i] + beta * *v);
880        numiter += 1;
881        progress(numiter);
882    }
883}
884
885/// `dataterm_mask.m` into `w`: 0 gives 1 everywhere; 1 gives `Mask./N_std` (0 where that is
886/// not finite) normalised to mean 1 over the mask.
887pub(crate) fn dataterm_mask(mode: i32, n_std: &[f64], mask: &[bool], w: &mut [f64]) {
888    if mode == 0 {
889        w.fill(1.0);
890        return;
891    }
892    par_fill(w, |i| {
893        let (s, m) = (n_std[i], mask[i]);
894        let v = (m as u8 as f64) / s;
895        if m && v.is_finite() { v } else { 0.0 }
896    });
897    let count = mask.iter().filter(|&&m| m).count() as f64;
898    let mean = w.iter().zip(mask).filter(|(_, &m)| m).map(|(v, _)| v).sum::<f64>() / count;
899    par_update(w, |_, v| *v /= mean);
900}
901
902/// `gradient_mask.m` (binary mode): 1 where `|∇(iMag.*Mask)|` is at or below a threshold, per
903/// direction, over the whole volume. The threshold starts at 1% of `max(iMag)` and is scaled by
904/// 1.05 or 0.95 until the number of gradient components above it, over the number of voxels in
905/// the mask, crosses `percentage`.
906pub(crate) fn gradient_mask(mag: &[f64], mask: &[bool], grid: &Grid, percentage: f64) -> [Vec<f64>; 3] {
907    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
908    let n = mag.len();
909    let masked: Vec<f64> = mag.iter().zip(mask).map(|(&v, &m)| if m { v } else { 0.0 }).collect();
910    let mut g = [vec![0.0f64; n], vec![0.0f64; n], vec![0.0f64; n]];
911    fgrad(&masked, grid.dims, vs, &mut g);
912    for c in g.iter_mut() {
913        par_update(c, |_, v| *v = v.abs());
914    }
915    // Counting `wG > level` by binary search over the sorted values gives the same counts as
916    // MATLAB's sum(wG(:) > level).
917    let mut sorted: Vec<f64> = g.iter().flat_map(|c| c.iter().copied()).collect();
918    let cmp = |a: &f64, b: &f64| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal);
919    #[cfg(feature = "parallel")]
920    sorted.par_sort_unstable_by(cmp);
921    #[cfg(not(feature = "parallel"))]
922    sorted.sort_unstable_by(cmp);
923    let above = |level: f64| sorted.len() - sorted.partition_point(|&v| v <= level);
924    let denominator = mask.iter().filter(|&&m| m).count() as f64;
925    let mut level = 0.01 * mag.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
926    let mut numerator = above(level) as f64;
927    if numerator / denominator > percentage {
928        while numerator / denominator > percentage {
929            level *= 1.05;
930            numerator = above(level) as f64;
931        }
932    } else {
933        // MATLAB loops forever if the fraction cannot reach `percentage` (e.g. a constant
934        // magnitude, whose only edges are at the mask boundary). Stop once the level is below
935        // the smallest nonzero gradient, where the count can no longer change; with no nonzero
936        // gradient at all, at once (0.95·level stalls at the smallest subnormal, never 0).
937        let min_positive = sorted.iter().copied().find(|&v| v > 0.0).unwrap_or(f64::INFINITY);
938        while numerator / denominator < percentage && level >= min_positive && level > 0.0 {
939            level *= 0.95;
940            numerator = above(level) as f64;
941        }
942    }
943    for c in g.iter_mut() {
944        par_update(c, |_, v| *v = if *v <= level { 1.0 } else { 0.0 });
945    }
946    g
947}
948
949/// `sphere_kernel.m`'s sphere in image space: a sphere of `radius` mm with partial-volume edge
950/// voxels (each split into 20³ sub-voxels), on MATLAB's `-N/2:N/2-1` grid, `fftshift`ed
951/// (circshift by `floor(N/2)`) and normalised to sum 1. Returned sparsely as `(flat index,
952/// value)` for the nonzero voxels, in column-major order. Only voxels within one voxel of the
953/// sphere are visited, so small radii (mSMV's `kernel_lim.m` and its minimum-radius kernel) are
954/// cheap; the values are those of the full-grid loop.
955pub(crate) fn sphere_kernel_sparse(grid: &Grid, radius: f64) -> Vec<(usize, f64)> {
956    let (nx, ny, nz) = grid.dims;
957    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
958    let dims = [nx, ny, nz];
959    let r2 = radius * radius;
960    // MATLAB's -N/2 : N/2-1 grid, then fftshift (circshift by floor(N/2)).
961    let coord = |i: usize, a: usize| (i as f64 - dims[a] as f64 / 2.0) * vs[a];
962    // Voxels whose centre is further than radius + one voxel from the origin are outside.
963    let range = |a: usize| {
964        let reach = (radius / vs[a]).ceil() + 2.0;
965        let c = dims[a] as f64 / 2.0;
966        let lo = (c - reach).floor().max(0.0) as usize;
967        let hi = ((c + reach).ceil() as usize).min(dims[a] - 1);
968        lo..=hi
969    };
970    let split = 10usize;
971    let sub: Vec<f64> = (0..2 * split).map(|s| (s as f64 - split as f64 + 0.5) / (2 * split) as f64).collect();
972    let mut out = Vec::new();
973    for k in range(2) {
974        let z = coord(k, 2);
975        for j in range(1) {
976            let y = coord(j, 1);
977            for i in range(0) {
978                let x = coord(i, 0);
979                let outside = (x.abs() - 0.5 * vs[0]).max(0.0).powi(2)
980                    + (y.abs() - 0.5 * vs[1]).max(0.0).powi(2)
981                    + (z.abs() - 0.5 * vs[2]).max(0.0).powi(2)
982                    > r2;
983                let inside = (x.abs() + 0.5 * vs[0]).powi(2)
984                    + (y.abs() + 0.5 * vs[1]).powi(2)
985                    + (z.abs() + 0.5 * vs[2]).powi(2)
986                    <= r2;
987                let v = if inside {
988                    1.0
989                } else if outside {
990                    0.0
991                } else {
992                    let mut occupied = 0usize;
993                    for &sz in &sub {
994                        for &sy in &sub {
995                            for &sx in &sub {
996                                let (px, py, pz) = (x + sx * vs[0], y + sy * vs[1], z + sz * vs[2]);
997                                if px * px + py * py + pz * pz <= r2 {
998                                    occupied += 1;
999                                }
1000                            }
1001                        }
1002                    }
1003                    occupied as f64 / sub.len().pow(3) as f64
1004                };
1005                if v != 0.0 {
1006                    let (si, sj, sk) = ((i + nx / 2) % nx, (j + ny / 2) % ny, (k + nz / 2) % nz);
1007                    out.push((si + nx * (sj + ny * sk), v));
1008                }
1009            }
1010        }
1011    }
1012    let total: f64 = out.iter().map(|&(_, v)| v).sum();
1013    for e in out.iter_mut() {
1014        e.1 /= total;
1015    }
1016    out
1017}
1018
1019/// `sphere_kernel(matrix_size, voxel_size, radius)` with the mSMV release's `sphere_kernel.m`
1020/// (`real(fftn(fftshift(Sphere)))`): the real part of the FFT of [`sphere_kernel_sparse`], in
1021/// double, as `SMV.m` builds it from a radius (`SMV(x, matrix_size, voxel_size, r)`).
1022pub(crate) fn sphere_kernel_real(grid: &Grid, radius: f64) -> Vec<f64> {
1023    let mut full = vec![0.0f64; grid.n_total()];
1024    for (p, v) in sphere_kernel_sparse(grid, radius) {
1025        full[p] = v;
1026    }
1027    crate::fft::fft_real_kernel(&full, grid.nx(), grid.ny(), grid.nz())
1028}
1029
1030/// [`sphere_kernel_real`] rounded to single precision: `single(sphere_kernel(...))`, as
1031/// `msmv.m` (and `MEDI_L1.m`) store the prefilter kernel `SphereK`.
1032pub(crate) fn sphere_kernel_single(grid: &Grid, radius: f64) -> Vec<f64> {
1033    sphere_kernel_real(grid, radius).into_iter().map(|v| v as f32 as f64).collect()
1034}
1035
1036/// `sphere_kernel.m` as `MEDI_L1.m` and `extract_CSF.m` use it here: `fftn(fftshift(Sphere))`
1037/// in double, complex (the MEDI toolbox's `sphere_kernel.m` before the mSMV release added
1038/// `real(...)`; the two differ only on odd grids, which `medi` pads away)
1039fn sphere_kernel(grid: &Grid, radius: f64, fft: &mut Fft3dWorkspace) -> Vec<Complex64> {
1040    let mut k = vec![Complex64::new(0.0, 0.0); grid.n_total()];
1041    for (p, v) in sphere_kernel_sparse(grid, radius) {
1042        k[p] = Complex64::new(v, 0.0);
1043    }
1044    fft.fft3d(&mut k);
1045    k
1046}
1047
1048/// `SMV.m` with a precomputed kernel: `ifftn(fftn(x).*K)`, left in `buf`
1049fn smv(x: &[f64], k: &[Complex64], fft: &mut Fft3dWorkspace, buf: &mut [Complex64]) {
1050    let load = load_real(x);
1051    fft.fft3d_with(buf, &FftOpts { load: Some(&load), ..FftOpts::default() });
1052    fft.ifft3d_with(buf, &FftOpts { load: Some(&mul_kernel(k)), ..FftOpts::default() });
1053}
1054
1055/// The SMV noise propagation in `MEDI_L1.m`, in place: `tempn = sqrt(SMV(tempn.^2, SphereK) + tempn.^2)`
1056fn smv_noise(tempn: &mut [f64], k: &[Complex64], fft: &mut Fft3dWorkspace, buf: &mut [Complex64]) {
1057    let t = &*tempn;
1058    let load = |l: usize, row: &mut [Complex64]| {
1059        for (c, &v) in row.iter_mut().zip(&t[l..]) {
1060            *c = Complex64::new(v * v, 0.0);
1061        }
1062    };
1063    fft.fft3d_with(buf, &FftOpts { load: Some(&load), ..FftOpts::default() });
1064    fft.ifft3d_with(buf, &FftOpts { load: Some(&mul_kernel(k)), ..FftOpts::default() });
1065    par_update(tempn, |i, v| *v = (buf[i].re + *v * *v).sqrt());
1066}
1067
1068/// `extract_CSF.m` (MEDI toolbox 2024.11.26): the ventricular CSF mask for MEDI+0.
1069///
1070/// Low-R2* voxels (`r2s < r2s_threshold`) form 6-connected regions twice: inside a 30 mm sphere
1071/// at the mask's centroid, and inside the mask (eroded first, when `erode`, to where the 10 mm
1072/// `SMV.m` of the mask exceeds 0.999). The CSF mask is every region of the second kind that
1073/// touches one of the three largest regions of the first kind. `extract_CSF.m` errors when
1074/// there are fewer than three central regions; this uses those there are.
1075///
1076/// # Arguments
1077/// * `r2s` - R2* in Hz (`arlo.m`, [`arlo`](crate::utils::r2star::arlo))
1078/// * `mask` - Brain mask, 1 = brain
1079/// * `grid` - Volume grid (dimensions and voxel sizes in mm)
1080/// * `r2s_threshold` - `thresh_R2s` (default 5 Hz)
1081/// * `erode` - `flag_erode` (default on)
1082///
1083/// # Returns
1084/// The CSF mask, 1 = CSF
1085pub fn extract_csf(r2s: &[f64], mask: &[u8], grid: &Grid, r2s_threshold: f64, erode: bool) -> Vec<u8> {
1086    let (nx, ny, nz) = grid.dims;
1087    let n = grid.n_total();
1088    assert!(r2s.len() == n && mask.len() == n, "r2s and mask must match the grid");
1089    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
1090    let pos = |p: usize| {
1091        let (i, j, k) = (p % nx, (p / nx) % ny, p / (nx * ny));
1092        [(i + 1) as f64 * vs[0], (j + 1) as f64 * vs[1], (k + 1) as f64 * vs[2]]
1093    };
1094    // Center region (sphere): radius 30 mm about the centroid of the mask
1095    let inside: Vec<usize> = (0..n).filter(|&p| mask[p] > 0).collect();
1096    let mut cen = [0.0f64; 3];
1097    for &p in &inside {
1098        let x = pos(p);
1099        for a in 0..3 {
1100            cen[a] += x[a];
1101        }
1102    }
1103    let count = inside.len() as f64;
1104    let cen = cen.map(|c| c / count);
1105    let mask_cen: Vec<bool> = (0..n)
1106        .map(|p| {
1107            let x = pos(p);
1108            ((x[0] - cen[0]).powi(2) + (x[1] - cen[1]).powi(2) + (x[2] - cen[2]).powi(2)).sqrt() <= 30.0
1109        })
1110        .collect();
1111    // if flag_erode, Mask = SMV(Mask, matrix_size, voxel_size, 10) > 0.999
1112    let mask: Vec<bool> = if erode {
1113        let mut fft = Fft3dWorkspace::new(nx, ny, nz);
1114        let k = sphere_kernel(grid, 10.0, &mut fft);
1115        let m: Vec<f64> = mask.iter().map(|&v| v as f64).collect();
1116        let mut buf = vec![Complex64::new(0.0, 0.0); n];
1117        smv(&m, &k, &mut fft, &mut buf);
1118        buf.iter().map(|v| v.re > 0.999).collect()
1119    } else {
1120        mask.iter().map(|&v| v != 0).collect()
1121    };
1122    let low: Vec<bool> = r2s.iter().map(|&r| r < r2s_threshold).collect();
1123
1124    // ROIs_region_cen: the three largest 6-connected regions of (R2s < thresh) in the sphere
1125    let raw1: Vec<bool> = (0..n).map(|p| low[p] && mask_cen[p]).collect();
1126    let (lab1, sizes1) = label6(&raw1, grid.dims);
1127    let mut order: Vec<usize> = (0..sizes1.len()).collect();
1128    order.sort_by(|&a, &b| sizes1[b].cmp(&sizes1[a])); // stable, as MATLAB's sort
1129    let mut central = vec![false; sizes1.len()];
1130    for &c in order.iter().take(3) {
1131        central[c] = true;
1132    }
1133    // ROIs_region: 6-connected regions of (R2s < thresh) in the (eroded) mask; keep those that
1134    // overlap a central region
1135    let raw2: Vec<bool> = (0..n).map(|p| low[p] && mask[p]).collect();
1136    let (lab2, sizes2) = label6(&raw2, grid.dims);
1137    let mut keep = vec![false; sizes2.len()];
1138    for p in 0..n {
1139        if let (Some(a), Some(b)) = (lab1[p], lab2[p]) {
1140            if central[a] {
1141                keep[b] = true;
1142            }
1143        }
1144    }
1145    // Mask_ROI_CSF(Mask == 0) = 0
1146    (0..n).map(|p| u8::from(mask[p] && lab2[p].is_some_and(|b| keep[b]))).collect()
1147}
1148
1149/// 6-connected components (`bwconncomp(·, 6)`), numbered in the order of their first voxel in
1150/// memory order (x fastest, as MATLAB's column-major order). Returns each voxel's component and
1151/// the component sizes.
1152fn label6(fg: &[bool], dims: (usize, usize, usize)) -> (Vec<Option<usize>>, Vec<usize>) {
1153    let (nx, ny, nz) = dims;
1154    let mut label = vec![None; fg.len()];
1155    let mut sizes = Vec::new();
1156    let mut stack = Vec::new();
1157    for start in 0..fg.len() {
1158        if !fg[start] || label[start].is_some() {
1159            continue;
1160        }
1161        let c = sizes.len();
1162        let mut size = 0;
1163        label[start] = Some(c);
1164        stack.push(start);
1165        while let Some(p) = stack.pop() {
1166            size += 1;
1167            let (i, j, k) = (p % nx, (p / nx) % ny, p / (nx * ny));
1168            let mut visit = |q: usize| {
1169                if fg[q] && label[q].is_none() {
1170                    label[q] = Some(c);
1171                    stack.push(q);
1172                }
1173            };
1174            if i > 0 { visit(p - 1); }
1175            if i + 1 < nx { visit(p + 1); }
1176            if j > 0 { visit(p - nx); }
1177            if j + 1 < ny { visit(p + nx); }
1178            if k > 0 { visit(p - nx * ny); }
1179            if k + 1 < nz { visit(p + nx * ny); }
1180        }
1181        sizes.push(size);
1182    }
1183    (label, sizes)
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::*;
1189
1190    /// A constant magnitude has no gradient anywhere: the level search must stop (MATLAB's loops
1191    /// forever) and nothing is an edge.
1192    #[test]
1193    fn gradient_mask_of_a_constant_magnitude_terminates_with_no_edges() {
1194        let grid = Grid::new(6, 6, 6, 1.0, 1.0, 1.0);
1195        let g = gradient_mask(&[1.0; 216], &[true; 216], &grid, 0.9);
1196        assert!(g.iter().all(|c| c.iter().all(|&v| v == 1.0)));
1197    }
1198
1199    fn small_params() -> MediParams {
1200        MediParams { cg_max_iter: 20, max_iter: 3, smv: false, merit: false, ..MediParams::default() }
1201    }
1202
1203    /// A sphere of susceptibility in a cube, its field in ppm, a magnitude with an edge at the
1204    /// sphere, and a spherical mask.
1205    fn phantom(n: usize) -> (Grid, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>) {
1206        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1207        let c = n as f64 / 2.0;
1208        let r2 = |i: usize| {
1209            let (x, y, z) = ((i % n) as f64 - c, ((i / n) % n) as f64 - c, (i / (n * n)) as f64 - c);
1210            x * x + y * y + z * z
1211        };
1212        let chi: Vec<f64> = (0..n * n * n).map(|i| if r2(i) <= 9.0 { 0.1 } else { 0.0 }).collect();
1213        let d = dipole_kernel(&grid, (0.0, 0.0, 1.0));
1214        let dc: Vec<Complex64> = d.iter().map(|&v| Complex64::new(v, 0.0)).collect();
1215        let mut fft = Fft3dWorkspace::new(n, n, n);
1216        let mut buf = vec![Complex64::new(0.0, 0.0); n * n * n];
1217        let mut field = vec![0.0; n * n * n];
1218        dconv(&chi, &dc, &mut fft, &mut buf, &mut field);
1219        let mag: Vec<f64> = (0..n * n * n).map(|i| if r2(i) <= 9.0 { 0.5 } else { 1.0 }).collect();
1220        let mask: Vec<u8> = (0..n * n * n).map(|i| (r2(i) <= 49.0) as u8).collect();
1221        (grid, chi, field, mag, mask)
1222    }
1223
1224    #[test]
1225    fn defaults_are_medi_l1s() {
1226        let p = MediParams::default();
1227        assert_eq!((p.lambda, p.percentage, p.cg_tol, p.tol), (1000.0, 0.9, 0.01, 0.1));
1228        assert_eq!((p.cg_max_iter, p.max_iter, p.data_weighting), (100, 10, 1));
1229        assert!(p.smv && p.merit && p.smv_shrink_mask);
1230        assert_eq!(p.smv_radius, 5.0);
1231        // MEDI+0 as in MEDI's README: lambda_CSF 100, extract_CSF's defaults
1232        assert!(p.csf && p.csf_erode);
1233        assert_eq!((p.lambda_csf, p.csf_r2s_threshold), (100.0, 5.0));
1234    }
1235
1236    #[test]
1237    fn bdiv_is_the_adjoint_of_fgrad() {
1238        let grid = Grid::new(5, 4, 3, 1.0, 0.8, 1.5);
1239        let n = grid.n_total();
1240        let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
1241        let x: Vec<f64> = (0..n).map(|i| ((i * 37) % 11) as f64 - 5.0).collect();
1242        let g: [Vec<f64>; 3] = [0, 1, 2].map(|c| (0..n).map(|i| ((i * 13 + c * 7) % 9) as f64 - 4.0).collect());
1243        let mut gx = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
1244        fgrad(&x, grid.dims, vs, &mut gx);
1245        let lhs: f64 = (0..3).map(|c| gx[c].iter().zip(&g[c]).map(|(a, b)| a * b).sum::<f64>()).sum();
1246        let mut div = vec![0.0; n];
1247        bdiv(&g, grid.dims, vs, &mut div);
1248        let rhs: f64 = x.iter().zip(&div).map(|(a, b)| a * b).sum::<f64>();
1249        assert!((lhs - rhs).abs() < 1e-9, "<fgrad x, g> = {lhs}, <x, bdiv g> = {rhs}");
1250    }
1251
1252    #[test]
1253    fn dconv_weighted_equals_two_plain_dconvs() {
1254        // The pruned Dconv(w2.*Dconv(p)) of A(dx) gives the values of the two plain Dconvs.
1255        let grid = Grid::new(12, 10, 8, 1.0, 1.0, 1.2);
1256        let n = grid.n_total();
1257        let (nx, ny) = (12, 10);
1258        let d: Vec<Complex64> = dipole_kernel(&grid, (0.1, 0.2, 0.97))
1259            .iter()
1260            .enumerate()
1261            .map(|(i, &v)| Complex64::new(v, 0.01 * ((i % 7) as f64 - 3.0)))
1262            .collect();
1263        let p: Vec<f64> = (0..n).map(|i| ((i as f64 * 12.9898).sin() * 43758.5453).fract()).collect();
1264        let mask: Vec<bool> = (0..n)
1265            .map(|i| (2..9).contains(&(i % nx)) && (3..8).contains(&((i / nx) % ny)) && (1..6).contains(&(i / (nx * ny))))
1266            .collect();
1267        let w2: Vec<f64> = (0..n).map(|i| if mask[i] { 1.0 + (i % 5) as f64 } else { 0.0 }).collect();
1268        let mut fft = Fft3dWorkspace::new(12, 10, 8);
1269        let mut buf = vec![Complex64::new(0.0, 0.0); n];
1270        let (mut t, mut f) = (vec![0.0; n], vec![0.0; n]);
1271        dconv(&p, &d, &mut fft, &mut buf, &mut t);
1272        let t: Vec<f64> = (0..n).map(|i| t[i] * w2[i]).collect();
1273        dconv(&t, &d, &mut fft, &mut buf, &mut f);
1274        let mbox = bounding_box(&mask, grid.dims);
1275        assert_eq!(mbox, [2..9, 3..8, 1..6]);
1276        dconv_weighted(&p, &d, &w2, &mbox, &mut fft, &mut buf);
1277        for i in 0..n {
1278            assert_eq!(buf[i].re, f[i], "voxel {i}");
1279        }
1280    }
1281
1282    #[test]
1283    fn reg_equals_bdiv_of_weighted_fgrad() {
1284        // 9 z planes: tasks of 4 planes, so plane boundaries inside and between tasks
1285        let grid = Grid::new(7, 6, 9, 1.0, 0.8, 1.5);
1286        let n = grid.n_total();
1287        let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
1288        let x: Vec<f64> = (0..n).map(|i| ((i as f64 * 12.9898).sin() * 43758.5453).fract()).collect();
1289        let wvw: [Vec<f64>; 3] = [0, 1, 2].map(|c| (0..n).map(|i| ((i * 13 + c * 7) % 5) as f64 * 0.3).collect());
1290        let mut g = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
1291        fgrad(&x, grid.dims, vs, &mut g);
1292        for c in 0..3 {
1293            for i in 0..n {
1294                g[c][i] *= wvw[c][i];
1295            }
1296        }
1297        let (mut want, mut got) = (vec![0.0; n], vec![0.0; n]);
1298        bdiv(&g, grid.dims, vs, &mut want);
1299        reg(&x, &wvw, grid.dims, vs, &mut got);
1300        assert_eq!(want, got);
1301    }
1302
1303    #[test]
1304    fn fgrad_is_zero_at_the_last_voxel() {
1305        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
1306        let x: Vec<f64> = (0..64).map(|i| (i * i) as f64).collect();
1307        let mut g = [vec![1.0; 64], vec![1.0; 64], vec![1.0; 64]];
1308        fgrad(&x, grid.dims, [1.0; 3], &mut g);
1309        for j in 0..4 {
1310            for k in 0..4 {
1311                assert_eq!(g[0][3 + 4 * (j + 4 * k)], 0.0);
1312            }
1313        }
1314        assert_eq!(g[0][0], x[1] - x[0]);
1315    }
1316
1317    #[test]
1318    fn gradient_mask_marks_about_percentage_of_mask_voxels_as_edges() {
1319        let n = 16;
1320        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1321        let mag: Vec<f64> = (0..n * n * n).map(|i| ((i as f64 * 12.9898).sin() * 43758.5453).fract().abs()).collect();
1322        let mask = vec![true; n * n * n];
1323        let wg = gradient_mask(&mag, &mask, &grid, 0.9);
1324        let edges: usize = wg.iter().map(|c| c.iter().filter(|&&v| v == 0.0).count()).sum();
1325        let frac = edges as f64 / (n * n * n) as f64;
1326        assert!((frac - 0.9).abs() < 0.02, "edge count / mask voxels = {frac}");
1327    }
1328
1329    #[test]
1330    fn sphere_kernel_is_normalised_and_real() {
1331        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
1332        let mut fft = Fft3dWorkspace::new(16, 16, 16);
1333        let k = sphere_kernel(&grid, 3.0, &mut fft);
1334        assert!((k[0].re - 1.0).abs() < 1e-12, "DC = sum of the sphere = {}", k[0].re);
1335        assert!(k.iter().all(|v| v.im.abs() < 1e-12));
1336    }
1337
1338    #[test]
1339    fn zero_field_gives_zero() {
1340        let (grid, _, field, mag, mask) = phantom(16);
1341        let zero = vec![0.0; field.len()];
1342        let chi = medi_l1(&zero, &mask, &mag, &vec![1.0; mag.len()], None, None, &grid, (0.0, 0.0, 1.0), &small_params(), |_, _| {});
1343        assert!(chi.iter().all(|&v| v == 0.0));
1344    }
1345
1346    #[test]
1347    fn recovers_a_sphere_in_ppm() {
1348        let (grid, chi_true, field, mag, mask) = phantom(24);
1349        let chi = medi_l1(&field, &mask, &mag, &vec![1.0; mag.len()], None, None, &grid, (0.0, 0.0, 1.0), &small_params(), |_, _| {});
1350        let c = 12 + 24 * (12 + 24 * 12);
1351        assert!((chi[c] - chi_true[c]).abs() < 0.03, "centre: {} vs {}", chi[c], chi_true[c]);
1352        for (i, &m) in mask.iter().enumerate() {
1353            if m == 0 {
1354                assert_eq!(chi[i], 0.0);
1355            }
1356        }
1357    }
1358
1359    #[test]
1360    fn uniform_noise_map_equals_none() {
1361        let (grid, _, field, mag, mask) = phantom(16);
1362        let p = small_params();
1363        let a = medi_l1(&field, &mask, &mag, &vec![1.0; mag.len()], None, None, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1364        let b = medi_l1(&field, &mask, &mag, &vec![3.0; field.len()], None, None, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1365        let err = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max);
1366        assert!(err < 1e-9, "max |diff| {err}");
1367    }
1368
1369    #[test]
1370    fn smv_and_merit_run() {
1371        let (grid, _, field, mag, mask) = phantom(16);
1372        let p = MediParams { smv: true, smv_radius: 2.0, merit: true, ..small_params() };
1373        let mut calls = 0;
1374        let chi = medi_l1(&field, &mask, &mag, &vec![1.0; mag.len()], None, None, &grid, (0.0, 0.0, 1.0), &p, |_, _| calls += 1);
1375        assert!(chi.iter().all(|v| v.is_finite()));
1376        assert!(chi.iter().any(|&v| v != 0.0));
1377        assert!(calls > 0);
1378    }
1379
1380    #[test]
1381    fn zeropad_pads_at_the_end_and_crops() {
1382        let (grid, _, field, mag, mask) = phantom(15);
1383        let p = MediParams { zeropad: (1, 0, 1), ..small_params() };
1384        let chi = medi(&field, &mask, EchoMagnitudes { data: &mag, n_echoes: 1 }, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1385        // the same inversion on a volume padded by hand
1386        let (n, m) = (15usize, 16usize);
1387        let pad = |v: &[f64], nyp: usize| {
1388            let mut out = vec![0.0; m * nyp * m];
1389            for k in 0..n { for j in 0..n { for i in 0..n {
1390                out[i + m * (j + nyp * k)] = v[i + n * (j + n * k)];
1391            }}}
1392            out
1393        };
1394        let mask_f: Vec<f64> = mask.iter().map(|&v| v as f64).collect();
1395        let mask_p: Vec<u8> = pad(&mask_f, n).iter().map(|&v| v as u8).collect();
1396        let pgrid = Grid::new(m, n, m, 1.0, 1.0, 1.0);
1397        let padded = medi_l1(&pad(&field, n), &mask_p, &pad(&mag, n), &pad(&crate::utils::fit_ppm_complex_noise(&mag, 1), n), None, None, &pgrid, (0.0, 0.0, 1.0), &small_params(), |_, _| {});
1398        for k in 0..n { for j in 0..n { for i in 0..n {
1399            assert_eq!(chi[i + n * (j + n * k)], padded[i + m * (j + n * k)]);
1400        }}}
1401    }
1402
1403    #[test]
1404    fn echo_magnitudes_give_rss_and_the_fit_noise() {
1405        let (grid, _, field, mag, mask) = phantom(16);
1406        let n = mag.len();
1407        let echoes: Vec<f64> = mag.iter().copied().chain(mag.iter().map(|v| 0.5 * v)).collect();
1408        let p = MediParams { echo_times: vec![0.005, 0.010], ..small_params() };
1409        let a = medi(&field, &mask, EchoMagnitudes { data: &echoes, n_echoes: 2 }, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1410        let rss: Vec<f64> = (0..n).map(|i| (echoes[i].powi(2) + echoes[i + n].powi(2)).sqrt()).collect();
1411        let n_std = crate::utils::fit_ppm_complex_noise(&echoes, 2);
1412        let b = medi_l1(&field, &mask, &rss, &n_std, None, None, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1413        assert_eq!(a, b);
1414    }
1415
1416    #[test]
1417    fn smv_pads_odd_axes_to_even() {
1418        let (grid, _, field, mag, mask) = phantom(15);
1419        let p = MediParams { smv: true, smv_radius: 2.0, merit: false, ..small_params() };
1420        let auto = medi(&field, &mask, EchoMagnitudes { data: &mag, n_echoes: 1 }, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1421        let explicit = MediParams { zeropad: (1, 1, 1), ..p.clone() };
1422        let manual = medi(&field, &mask, EchoMagnitudes { data: &mag, n_echoes: 1 }, &grid, (0.0, 0.0, 1.0), &explicit, |_, _| {});
1423        assert_eq!(auto, manual);
1424        // an explicit pad that leaves an axis odd gets one more voxel
1425        let odd = MediParams { zeropad: (2, 0, 0), ..p.clone() };
1426        let more = MediParams { zeropad: (3, 1, 1), ..p };
1427        let a = medi(&field, &mask, EchoMagnitudes { data: &mag, n_echoes: 1 }, &grid, (0.0, 0.0, 1.0), &odd, |_, _| {});
1428        let b = medi(&field, &mask, EchoMagnitudes { data: &mag, n_echoes: 1 }, &grid, (0.0, 0.0, 1.0), &more, |_, _| {});
1429        assert_eq!(a, b);
1430    }
1431
1432    /// A 100³ head-sized phantom: a 48 mm-radius brain at R2* 20 Hz with low-R2* regions.
1433    fn csf_phantom() -> (Grid, Vec<f64>, Vec<u8>, impl Fn(usize) -> [f64; 3]) {
1434        let n = 100;
1435        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1436        let at = move |p: usize| [(p % n) as f64 - 50.0, ((p / n) % n) as f64 - 50.0, (p / (n * n)) as f64 - 50.0];
1437        let mask: Vec<u8> = (0..n * n * n).map(|p| u8::from(dist(at(p), [0.0; 3]) <= 48.0)).collect();
1438        let r2s: Vec<f64> = (0..n * n * n)
1439            .map(|p| {
1440                let x = at(p);
1441                if is_ventricle(x) {
1442                    2.0
1443                // low R2* inside the eroded mask but 35 mm from the centre
1444                } else if dist(x, [0.0, 35.0, 0.0]) <= 2.0 {
1445                    1.0
1446                // a rim the 10 mm erosion removes, and the background
1447                } else if dist(x, [0.0; 3]) > 44.0 {
1448                    0.0
1449                } else {
1450                    20.0
1451                }
1452            })
1453            .collect();
1454        (grid, r2s, mask, at)
1455    }
1456
1457    fn dist(x: [f64; 3], c: [f64; 3]) -> f64 {
1458        ((x[0] - c[0]).powi(2) + (x[1] - c[1]).powi(2) + (x[2] - c[2]).powi(2)).sqrt()
1459    }
1460
1461    /// Two central "ventricles"
1462    fn is_ventricle(x: [f64; 3]) -> bool {
1463        dist(x, [-5.0, 0.0, 0.0]) <= 3.0 || dist(x, [5.0, 0.0, 0.0]) <= 2.0
1464    }
1465
1466    #[test]
1467    fn extract_csf_keeps_central_low_r2s_regions_in_the_eroded_mask() {
1468        let (grid, r2s, mask, at) = csf_phantom();
1469        let csf = extract_csf(&r2s, &mask, &grid, 5.0, true);
1470        for (p, &c) in csf.iter().enumerate() {
1471            assert_eq!(c == 1, is_ventricle(at(p)), "voxel {:?}", at(p));
1472        }
1473        // Without the erosion the rim is a candidate region, but it does not touch the 30 mm
1474        // central sphere, so the result is the same.
1475        assert_eq!(extract_csf(&r2s, &mask, &grid, 5.0, false), csf);
1476        // a threshold below the ventricles' R2* finds nothing there
1477        assert!(extract_csf(&r2s, &mask, &grid, 1.5, true).iter().all(|&v| v == 0));
1478    }
1479
1480    #[test]
1481    fn extract_csf_takes_the_three_largest_central_regions() {
1482        let n = 24;
1483        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1484        let mask = vec![1u8; n * n * n];
1485        // four separate low-R2* single-row regions of 4, 3, 2 and 1 voxels near the centre
1486        let mut r2s = vec![20.0; n * n * n];
1487        for (len, j) in [(4usize, 6usize), (3, 10), (2, 14), (1, 18)] {
1488            for i in 0..len {
1489                r2s[(10 + i) + n * (j + n * 12)] = 0.0;
1490            }
1491        }
1492        let csf = extract_csf(&r2s, &mask, &grid, 5.0, false);
1493        assert_eq!(csf.iter().filter(|&&v| v == 1).count(), 4 + 3 + 2);
1494        assert_eq!(csf[10 + n * (18 + n * 12)], 0);
1495    }
1496
1497    #[test]
1498    fn csf_term_zero_references_the_result() {
1499        let (grid, _, field, mag, mask) = phantom(16);
1500        let n = mag.len();
1501        let csf: Vec<bool> = (0..n).map(|i| mask[i] == 1 && i % 7 == 0).collect();
1502        let p = small_params();
1503        let off = medi_l1(&field, &mask, &mag, &vec![1.0; n], None, None, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1504        let on = medi_l1(&field, &mask, &mag, &vec![1.0; n], Some(&csf), None, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1505        let mean = |x: &[f64]| (0..n).filter(|&i| csf[i]).map(|i| x[i]).sum::<f64>() / csf.iter().filter(|&&c| c).count() as f64;
1506        assert!(mean(&on).abs() < 1e-12, "CSF mean {}", mean(&on));
1507        assert!(mean(&off).abs() > 1e-6);
1508        for i in 0..n {
1509            if mask[i] == 0 {
1510                assert_eq!(on[i], 0.0);
1511            }
1512        }
1513        // with lambda_CSF = 0 the result is the plain result shifted by its CSF mean
1514        let p0 = MediParams { lambda_csf: 0.0, ..p };
1515        let shifted = medi_l1(&field, &mask, &mag, &vec![1.0; n], Some(&csf), None, &grid, (0.0, 0.0, 1.0), &p0, |_, _| {});
1516        let m = mean(&off);
1517        for i in 0..n {
1518            let want = if mask[i] == 1 { off[i] - m } else { 0.0 };
1519            assert!((shifted[i] - want).abs() < 1e-12);
1520        }
1521        // and the CSF term changes the solution itself
1522        let d = (0..n).map(|i| (on[i] - shifted[i]).abs()).fold(0.0, f64::max);
1523        assert!(d > 1e-6, "lambda_CSF had no effect");
1524    }
1525
1526    #[test]
1527    fn fewer_than_three_echoes_run_without_the_csf_term() {
1528        let (grid, _, field, mag, mask) = phantom(16);
1529        let echoes: Vec<f64> = mag.iter().copied().chain(mag.iter().map(|v| 0.5 * v)).collect();
1530        let p = MediParams { echo_times: vec![0.005, 0.010], ..small_params() };
1531        let on = medi(&field, &mask, EchoMagnitudes { data: &echoes, n_echoes: 2 }, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1532        let off = medi(&field, &mask, EchoMagnitudes { data: &echoes, n_echoes: 2 }, &grid, (0.0, 0.0, 1.0), &MediParams { csf: false, ..p }, |_, _| {});
1533        assert_eq!(on, off);
1534    }
1535}