qsm_core/separation/chi_sep_medi.rs
1//! χ-separation (MEDI): the SNU χ-separation toolbox's `chi_sep_MEDI`
2//!
3//! [`chi_sep_medi`] implements **`chi_sep_MEDI` of the χ-separation toolbox v1.1.3** (SNU-LIST,
4//! Jongho Lee's lab; `functions/corefunc/chi_sep_MEDI.p` and `chi_sep_MEDI_func.p`), as its
5//! script `Chisep_script.m` calls it (l. 153-161):
6//! `chi_sep_MEDI(mag, local_field_hz, r2prime, N_std, mask, params, option_data)` with
7//! `params.lambda = 1`, `params.lambda_CSF = 1`, `option_data.qsm`, `option_data.mask_CSF` and
8//! `option_data.N_std`. The toolbox ships as protected P-code; with the authors' permission the
9//! algorithm was recovered by black-box probing in licensed MATLAB R2023b: the profiler (which
10//! lists the bodies of the P-code's anonymous functions verbatim), logging wrappers on the
11//! functions it calls that save the caller's whole workspace (`evalin('caller', 'save ...')`),
12//! and replaying the operators captured in those workspaces (their function handles stay
13//! callable after `load`). A MATLAB re-implementation built from that reproduces
14//! `chi_sep_MEDI` to 3·10⁻¹⁶ relative on six phantoms (see *Agreement with the toolbox*).
15//!
16//! Earlier QSM.rs versions ported Shin et al. 2021's Supplementary Methods instead, with their
17//! own weights, λ and no QSM, CSF mask or noise map; the toolbox's algorithm below differs in
18//! every step after the forward model.
19//!
20//! # The algorithm
21//!
22//! The unknowns are `x+ = χ+ ≥ 0` and `x− = |χ−| ≥ 0` in ppm. With `D` the dipole convolution in
23//! Hz (`(CF/10⁶)·F⁻¹ D F`) and `D_r,±` the relaxometric constants (Hz/ppm):
24//!
25//! 1. **Defaults** (`chi_sep_MEDI`): `B0 = CF/42.58·10⁶`; `D_r,pos = D_r,neg = 137·(B0/2.9)`
26//! ([`toolbox_default_dr`]); `pad_size` 5, `tol_norm_ratio` 0.03, `max_iter` 30,
27//! `cg_max_iter` 100, `cg_tol` 0.05 (see [`ChiSepMediParams`]); and, not parameters, at least
28//! [`CHI_SEP_MEDI_MIN_ITER`] = 6 iterations, `ε` = 10⁻⁵ and `wei_mag` = 1/(2π).
29//! 2. **Edge masks** on the input grid: `wG = gradient_mask(mag, mask, 0.9)` (MEDI's
30//! `gradient_mask.m`: `|∇(mag·mask)|` thresholded so that 90 % of the mask's voxel count of
31//! gradient components lie above the level; 1 = not an edge). The R2′ edge mask `wG_r2p` is
32//! the same magnitude mask.
33//! 3. **Padding**: every volume is zero-padded by `pad_size` voxels at both ends of the **third
34//! axis only**; `wG`, `wG_r2p` are padded with 1. Everything below runs on that grid. Exactly
35//! zero R2′ voxels (including the padding) become `eps` (2.2·10⁻¹⁶).
36//! 4. **Weights and targets**: `m = dataterm_mask(N_std, mask)` (MEDI's: `mask/N_std`, 0 where not
37//! finite, divided by its mean over the mask); R2′ residual weight `wei_mag·m`, field residual
38//! weight `m`. Targets: `R2′·mask` where `R2′ > 0` (0 elsewhere) and the local field in Hz.
39//! 5. **Dipole kernel**: MEDI's `dipole_kernel(N, vs, B0_dir, 'kspace')`: the k-grid
40//! `(−N/2 : N/2−1)/(N·vs)` per axis, `D = 1/3 − (k·B0)²/|k|²` (0 at `k = 0`), `fftshift`ed. On
41//! an axis with an odd number of voxels that grid steps through half-integers and misses
42//! `k = 0`, so the kernel is sampled half a frequency bin off and the convolution has an
43//! imaginary part; it is kept as the toolbox keeps it (complex arithmetic throughout, real part
44//! taken where the toolbox takes it).
45//! 6. **Initial guess** from the QSM `χ` (`option_data.qsm`): the voxelwise solution of
46//! `D_r,pos·x+ + D_r,neg·x− = R2′`, `x+ − x− = χ`, each clamped at 0:
47//! `x+ = max((R2′ + D_r,neg·χ)/(D_r,pos + D_r,neg), 0)`, `x− = max((R2′ − D_r,pos·χ)/(…), 0)`
48//! (with the raw R2′, negative values included). The iterate is that, masked. A copy with the
49//! CSF voxels set to 0 is what the first iteration's L1 weights see (a quirk of the toolbox:
50//! the iterate itself keeps the CSF values).
51//! 7. **Gauss-Newton** while (`res > tol` or `iter < 6`) and `iter < max_iter`:
52//! - L1 weights `V_s = 1/√(|wG·∇x_s|² + ε)` for `s = +, −`, and `V_t = 1/√(|wG·∇(x+ − x−)|² + ε)`
53//! (∇: MEDI's `fgrad`, forward differences scaled by the voxel size, 0 at the last voxel).
54//! - Gradients `b_s` of
55//! `‖wei_mag·m·(D_r,pos·x+ + D_r,neg·x− − R2′)‖² + ‖m·(D x+ − D x− − f)‖²
56//! + λ Σ_s ‖wG V_s^½ ∇x_s‖² + 2λ ‖wG V_t^½ ∇(x+ − x−)‖² + λ_CSF Σ_s ‖LT(|x_s|)‖²`
57//! (as the toolbox evaluates them: `2·D_rᵀ M²·residual`, `∇ᵀ(wG V wG ∇·)` with `∇ᵀ` the exact
58//! adjoint of `fgrad`), where `LT(v) = M_CSF·(v − mean(v over M_CSF))`.
59//! - One CG solve **per source** (`cgsolve.m`, MEDI's: zero start, last iterate, residual
60//! recomputed every 50 iterations): `A_s dx_s = −b_s`, where `A_s` is the same expression's
61//! linearisation in `x_s` with the other source's step set to zero, **CSF term included**
62//! (`λ_CSF·LT(LT(|dx|))`, nonlinear through the `abs`; the toolbox rebinds its `reg_p`/`reg_n`
63//! to include it before building the CG operators).
64//! - `x_s ← max(x_s + dx_s·mask, 0)`; `res = ‖Δx‖/‖x‖` over both sources inside the mask
65//! eroded by a sphere (`imerode(mask, strel('sphere', erode_size))`, voxels outside the
66//! array counted as inside), with `Δx` the projected step. `erode_size =
67//! min(round(min(effective_mask_size)/10), 5)` voxels, `effective_mask_size` being the
68//! number of slices along each axis that hold any mask voxel (5 on a head); with
69//! `erode_size` 0 the whole padded grid counts. The iterate with the smallest `res` so far is
70//! kept.
71//! 8. **Output**: that iterate, cropped back to the input grid: `x_para = x+`, `x_dia = x−` (≥ 0),
72//! `x_tot = x+ − x−`. QSM.rs returns `(χ+, χ− = −x−, χ+ + χ−)`.
73//!
74//! # Units
75//!
76//! The toolbox takes the local field in Hz; [`chi_sep_medi`] takes it in ppm and multiplies by
77//! `cf/10⁶` (so the result depends on [`ChiSepMediParams::cf`] only through `D_r`'s default and the
78//! relative weight of the two residuals). R2′ is in Hz, the QSM and the output in ppm.
79//!
80//! # Agreement with the toolbox
81//!
82//! Compared with `chi_sep_MEDI` itself (toolbox v1.1.3 in licensed MATLAB R2023b; QSM.rs in
83//! double precision, as the toolbox runs):
84//!
85//! - **Phantoms** (`tests/chi_sep_medi_snu_parity.rs`, fixture from
86//! `tests/data/chi_sep_medi_snu_small.m`): 40×36×28 at 3 T with a CSF mask; 33×29×21 at 7 T,
87//! 0.8×0.9×1.5 mm, oblique B0, with a CSF mask; negative R2′, `D_r` 120/150, λ = 2, no CSF
88//! mask. Same Gauss-Newton iteration counts (7, 6, 6), χ+ and |χ−| to relative L2
89//! ≤ 1.1·10⁻¹⁵, max |Δ| ≤ 8·10⁻¹⁷ ppm.
90//! - **The verified QSM-CI `chi-sep-medi` container** (v4, identical to licensed MATLAB;
91//! QSMxT/QSM-CI#351): `option_data.qsm` = χ_total, `N_std` = `Fit_ppm_complex`'s `dp1` from the
92//! echoes' magnitudes, `mask_CSF = []`, `CF` = 42.5774 MHz/T·B0. QSM.rs's `N_std`
93//! ([`crate::utils::fit_ppm_complex_noise`]) equals the container's bit for bit in every case.
94//! - a 24×22×18 phantom whose voxels decay at different rates (`tests/chi_sep_medi_qsmci_parity.rs`,
95//! regenerated in Actions by `tests/data/chi_sep_medi_qsmci_small.py`): χ+ and |χ−| to relative
96//! L2 2.6·10⁻⁸, the container's float32 output;
97//! - in vivo (the EMTP travelling-head scan of QSM-CI's fidelity check, 3 T, 184×256×128,
98//! 1.39 M mask voxels; its local field, R2′ and χ): 6 iterations each, the same `res` at every
99//! iteration (to the 4 digits the toolbox prints); χ+ and |χ−| to relative L2 2.5·10⁻⁸, max
100//! |Δ| 3·10⁻⁸ / 9·10⁻⁸ ppm, the float32 output (measured 2026-11-01, `examples/chi_sep_file.rs`);
101//! - QSM-CI's χ-separation phantom (`chisep-mc`, 7 T, 164×205×205, same inputs): 6 iterations
102//! each, `res` equal at the first; χ+ relative L2 1.7·10⁻³ (max |Δ| 8.3·10⁻³ ppm), |χ−|
103//! 9.3·10⁻³ (7.9·10⁻³ ppm), r ≥ 0.9999. That residual is rounding, amplified: on this phantom
104//! every inner CG solve runs 42-50 iterations (5-10 in vivo), and the result moves as much under
105//! changes at the last bit. The toolbox itself, run with 4 instead of 8 threads, moves by
106//! 3.7·10⁻³ / 1.13·10⁻² (more than QSM.rs differs from it), and QSM.rs moves by 2.1·10⁻³ /
107//! 4.5·10⁻³ when `CF` changes by one ulp. QSM-CI's `chisep-medi` / `chisep-medi-qsmrs` parity
108//! pair checks this phantom.
109//!
110//! Before QSM.rs#382 these cases ran with `N_std = 1/|mag|` (in vivo relative L2 8·10⁻¹³ against
111//! licensed MATLAB with that input), and the convergence test eroded the mask by 3 voxels
112//! instead of the toolbox's `erode_size` (5 on these scans; see step 7), which changed `res` by
113//! about 5 % but neither the iteration counts nor the results here.
114
115//! # Performance
116//!
117//! Each CG iteration costs four 3-D FFTs (two dipole convolutions, complex) and two fused
118//! gradient-adjoint passes; a Gauss-Newton iteration is two CG solves. In vivo (184×256×138
119//! padded, 6 iterations) [`chi_sep_medi`] takes 106-133 s on 6 threads with a 2.7 GB peak (the
120//! toolbox: 286 s on 8); on `chisep-mc` (164×205×215, CG solves of ~45 iterations) 282-292 s
121//! (the toolbox: 931 s). Memory is about 45 `f64` per padded voxel.
122//!
123//! # Deviations
124//!
125//! - **An all-zero CSF mask means no CSF term.** The toolbox applies the CSF term whenever
126//! `option_data.mask_CSF` is not empty; with a mask that has no voxels, the mean over it is
127//! `NaN`, `NaN·λ_CSF` is `NaN` even for `λ_CSF = 0`, and every step is lost (the solver returns its
128//! initial guess). Its own GUI passes `[]` when it has no CSF mask, which turns the term off.
129//! The CSF mask is a required input here, as in `Chisep_script.m`; passing an all-zero mask
130//! reproduces the GUI's no-CSF behaviour (`mask_CSF = []`).
131//! - **No wall-clock limit.** The toolbox also stops after `params.time_limit` (default 1800 s) of
132//! iterations. That makes its output depend on the machine; it is not reproduced.
133//! - Options of `chi_sep_MEDI` that `Chisep_script.m` does not use are not exposed:
134//! `option_data.wG`/`wG_r2p` (precomputed edge masks), `mask_FastRelax`/`mask_SlowRelax`
135//! (voxels whose R2′ residual weight drops to 0.1; both default to empty), and the TKD
136//! initialisation it uses without `option_data.qsm` (`params.init_option`).
137//!
138//! # Reference pipeline
139//!
140//! What `Chisep_script.m` (toolbox v1.1.3) runs before `chi_sep_MEDI`, with its line numbers:
141//!
142//! - Tukey k-space windowing of the complex multi-echo data, factor 0.4 for Siemens/GE and 0 for
143//! Philips (l. 70-73); `mag` = root-sum-of-squares over echoes (l. 75).
144//! - `N_std` from `Preprocessing4Phase(mag, phase)` (l. 76), which is MEDI's `Fit_ppm_complex`
145//! noise map (probed: equal to 3·10⁻¹⁸; QSM.rs [`crate::utils::fit_ppm_complex_noise`]).
146//! - Mask: MEDI's `BET` on `mag` (l. 82); R2* by ARLO with more than three equally spaced echoes,
147//! NNLS otherwise (l. 87-93); `mask_CSF = extract_CSF(r2star, mask, voxel_size)` (MEDI's
148//! `extract_CSF.m`, l. 96; QSM.rs [`crate::inversion::extract_csf`]).
149//! - ROMEO with phase-offset correction and weighted echo averaging, `W = TE·exp(−TE/40 ms)`
150//! (l. 99-123); V-SHARP `smvsize` 12 (l. 136-139); the local field in Hz (l. 139).
151//! - QSM: STI Suite `QSM_iLSQR(..., 'padsize', [12 12 12])` (l. 145), passed as `option_data.qsm`
152//! (l. 158).
153//! - R2′ is the user's (the script's input; its README offers a pseudo-R2 map otherwise).
154//!
155//! The toolbox's GUI (`WrapperChisepMEDI.p`, probed with a stand-in app object) differs: it
156//! passes `mask_CSF = []` (no CSF term), the RSS of the Tukey-windowed magnitude, `λ = λ_CSF = 1`,
157//! and `option_data.qsm` only when its "QSM initialisation" option is on. Its `N_std` is the
158//! script's: `Fit_ppm_complex`'s `dp1` from the multi-echo magnitude, zero outside the mask
159//! (QSM.rs#382; probed in licensed R2023b on phantoms whose voxels decay at different rates, where
160//! the GUI's and `Preprocessing4Phase`'s `N_std` both equal `dp1` to 1.1·10⁻¹⁵). An earlier reading
161//! of the GUI's as `c/|mag|` came from a phantom with uniform decay, where `dp1` is proportional to
162//! `1/RSS`; with per-voxel decay `dp1·RSS` varies (0.72-0.87 there).
163//!
164//! Reference: Shin, H.-G., Lee, J., Yun, Y. H., Yoo, S. H., Jang, J., Oh, S.-H., Nam, Y., Jung,
165//! S., Kim, S., Fukunaga, M., Kim, W., Choi, H. J., Lee, J. (2021). "χ-separation: Magnetic
166//! susceptibility source separation toward iron and myelin mapping in the brain." NeuroImage,
167//! 240:118371. <https://doi.org/10.1016/j.neuroimage.2021.118371>
168//!
169//! # Pipeline rules
170//!
171//! The facts above that a configuration can depart from are published as rules in
172//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source):
173//! `chi-sep.mask-bet`, `chi-sep.unwrap-romeo`, `chi-sep.echo-combination`, `chi-sep.echo-weights`,
174//! `chi-sep.bg-vsharp`, `chi-sep-medi.qsm-ilsqr`, and by manufacturer `chi-sep.tukey-siemens-ge`
175//! and `chi-sep.tukey-philips` (`Chisep_script.m` Tukey-windows the echoes before this method as
176//! before χ-sepnet; [`crate::utils::tukey_window`]). All are the authors' setting
177//! (`Kind::Reference`).
178
179use crate::error::{check_len, InputError};
180use crate::fft::Fft3dWorkspace;
181use crate::inversion::medi::{cgsolve, dataterm_mask, fgrad, gradient_mask, par_fill, par_update, reg, CgBuffers};
182use crate::Grid;
183use num_complex::Complex64;
184
185use super::ChiSepMaps;
186
187/// The gyromagnetic ratio the toolbox's `chi_sep_MEDI` and `chi_sep_iLSQR` use to get the field
188/// strength from `params.CF` (their `gyro_`), in Hz/T. It differs from
189/// [`crate::constants::GAMMA_BAR_HZ_PER_T`] (42.576 MHz/T); kept so the field-dependent defaults
190/// match the toolbox.
191pub const TOOLBOX_GYRO_HZ_PER_T: f64 = 42.58e6;
192
193/// The field strength, in tesla, at which the toolbox's default relaxometric constant is
194/// [`super::DR_SHIN_2021`] (137 Hz/ppm): `chi_sep_MEDI` and `chi_sep_iLSQR` default to
195/// `D_r = 137·(B0/2.9)` for both sources, `B0 = CF/42.58e6`, when `params.Dr_pos`/`Dr_neg` are not
196/// given (probed: 141.7241379310345 at `CF` = 127.74 MHz, bit for bit). Shin et al. 2021 measured
197/// 137 on a nominal 3 T (2.89 T) scanner; the toolbox scales it linearly with the field.
198pub const TOOLBOX_DR_REFERENCE_TESLA: f64 = 2.9;
199
200/// The toolbox's default `D_r` in Hz/ppm at central frequency `cf` (Hz): `137·((cf/42.58e6)/2.9)`,
201/// in that order of operations.
202pub fn toolbox_default_dr(cf: f64) -> f64 {
203 super::DR_SHIN_2021 * ((cf / TOOLBOX_GYRO_HZ_PER_T) / TOOLBOX_DR_REFERENCE_TESLA)
204}
205
206/// Gauss-Newton iterations `chi_sep_MEDI` always runs (its `min_iter`, not a parameter), even
207/// once the relative change is below [`ChiSepMediParams::tol`].
208pub const CHI_SEP_MEDI_MIN_ITER: usize = 6;
209
210/// `ε` in the L1 weights `1/sqrt(|wG·∇χ|² + ε)` (`chi_sep_MEDI_func`'s `eps__`).
211const EPS_L1: f64 = 1e-5;
212/// Weight of the R2′ residual against the field residual, both in Hz (`wei_mag` = 1/(2π)).
213const WEI_MAG: f64 = 1.0 / (2.0 * std::f64::consts::PI);
214/// MEDI `gradient_mask`'s `percentage` as the toolbox passes it: the number of gradient components
215/// marked as edges, as a fraction of the mask's voxel count.
216const EDGE_PERCENTAGE: f64 = 0.9;
217/// The largest radius, in voxels, of `chi_sep_MEDI`'s convergence-mask erosion.
218const ERODE_MAX: i64 = 5;
219
220/// `chi_sep_MEDI`'s convergence mask (`Mask_erode`): the mask eroded by a sphere of radius
221/// `erode_size = min(round(min(effective_mask_size)/10), 5)` voxels, where `effective_mask_size`
222/// counts, along each axis, the slices that hold any mask voxel (probed in licensed R2023b: two
223/// 10³ cubes far apart give [20 20 20]; voxel sizes and `pad_size` play no part; a head gives 5).
224/// A radius of 0 (an axis with fewer than five such slices) makes it the whole padded grid, as in
225/// the toolbox. `round` is MATLAB's, halves away from zero (15 slices give 2, 25 give 3), as
226/// Rust's.
227fn convergence_mask(mask: &[bool], dims: (usize, usize, usize)) -> Vec<bool> {
228 let (nx, ny, nz) = dims;
229 let mut any = [vec![false; nx], vec![false; ny], vec![false; nz]];
230 for (p, _) in mask.iter().enumerate().filter(|(_, &m)| m) {
231 any[0][p % nx] = true;
232 any[1][(p / nx) % ny] = true;
233 any[2][p / (nx * ny)] = true;
234 }
235 let extent = any.iter().map(|a| a.iter().filter(|&&v| v).count()).min().unwrap_or(0);
236 let r = ((extent as f64 / 10.0).round() as i64).min(ERODE_MAX);
237 if r == 0 {
238 vec![true; mask.len()]
239 } else {
240 erode_sphere(mask, dims, r)
241 }
242}
243
244/// Parameters for [`chi_sep_medi`]. The defaults are the toolbox's (see the module docs).
245#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
246#[derive(Clone, Debug)]
247pub struct ChiSepMediParams {
248 /// Central frequency in Hz (`params.CF`; default 123.2 MHz). The field strength the
249 /// defaults of [`Self::dr_pos`] and [`Self::dr_neg`] scale with is `cf / 42.58e6`.
250 pub cf: f64,
251 /// Weight of the edge-masked L1 terms (`params.lambda`; default 1, as `Chisep_script.m` and
252 /// the toolbox GUI set it).
253 pub lambda: f64,
254 /// Weight of the CSF term (`params.lambda_CSF`; default 1, as `Chisep_script.m` and the GUI
255 /// set it). Used only where [`ChiSepMediInputs::mask_csf`] has voxels.
256 pub lambda_csf: f64,
257 /// Paramagnetic relaxometric constant in Hz/ppm (`params.Dr_pos`). `None` (the default):
258 /// the toolbox's `137·(B0/2.9)` ([`toolbox_default_dr`]).
259 pub dr_pos: Option<f64>,
260 /// Diamagnetic relaxometric constant in Hz/ppm (`params.Dr_neg`). `None` (the default): the
261 /// same as [`Self::dr_pos`]'s default.
262 pub dr_neg: Option<f64>,
263 /// Zero padding, in voxels, added at both ends of the third axis only (`params.pad_size`;
264 /// default 5).
265 pub pad: usize,
266 /// Stop once the relative change of `[χ+ |χ−|]` in the eroded mask falls to this, after at
267 /// least [`CHI_SEP_MEDI_MIN_ITER`] iterations (`params.tol_norm_ratio`; default 0.03).
268 pub tol: f64,
269 /// Gauss-Newton iterations at most (`params.max_iter`; default 30).
270 pub max_iter: usize,
271 /// Conjugate-gradient relative tolerance (`params.cg_tol`; default 0.05).
272 pub cg_tol: f64,
273 /// Conjugate-gradient iterations at most, per source and Gauss-Newton step
274 /// (`params.cg_max_iter`; default 100).
275 pub cg_max_iter: usize,
276}
277
278impl Default for ChiSepMediParams {
279 fn default() -> Self {
280 Self {
281 cf: 123.2e6,
282 lambda: 1.0,
283 lambda_csf: 1.0,
284 dr_pos: None,
285 dr_neg: None,
286 pad: 5,
287 tol: 0.03,
288 max_iter: 30,
289 cg_tol: 0.05,
290 cg_max_iter: 100,
291 }
292 }
293}
294
295impl ChiSepMediParams {
296 /// The relaxometric constants used, `(D_r,pos, D_r,neg)` in Hz/ppm: the explicit values, or
297 /// the toolbox's [`toolbox_default_dr`].
298 pub fn dr(&self) -> (f64, f64) {
299 let default = toolbox_default_dr(self.cf);
300 (self.dr_pos.unwrap_or(default), self.dr_neg.unwrap_or(default))
301 }
302
303 /// Checks the parameters [`chi_sep_medi`] checks.
304 pub fn validate(&self) -> Result<(), InputError> {
305 let pos = |name: &str, v: f64| {
306 if v.is_finite() && v > 0.0 {
307 Ok(())
308 } else {
309 Err(InputError::param(name, v, "finite and > 0"))
310 }
311 };
312 pos("χ-separation MEDI cf", self.cf)?;
313 let (dp, dn) = self.dr();
314 pos("χ-separation MEDI dr_pos", dp)?;
315 pos("χ-separation MEDI dr_neg", dn)?;
316 for (name, v) in [("χ-separation MEDI lambda", self.lambda), ("χ-separation MEDI lambda_csf", self.lambda_csf)] {
317 if !(v.is_finite() && v >= 0.0) {
318 return Err(InputError::param(name, v, "finite and ≥ 0"));
319 }
320 }
321 for (name, v) in [("χ-separation MEDI tol", self.tol), ("χ-separation MEDI cg_tol", self.cg_tol)] {
322 if !(v.is_finite() && v >= 0.0) {
323 return Err(InputError::param(name, v, "finite and ≥ 0"));
324 }
325 }
326 if self.max_iter == 0 {
327 return Err(InputError::param("χ-separation MEDI max_iter", 0, "≥ 1"));
328 }
329 Ok(())
330 }
331}
332
333/// The volumes [`chi_sep_medi`] takes, as `chi_sep_MEDI(mag, local_field_hz, r2prime, N_std,
334/// mask, params, option_data)` takes them. All are on the grid, column-major.
335#[derive(Clone, Copy)]
336pub struct ChiSepMediInputs<'a> {
337 /// Magnitude (`mag`; `Chisep_script.m` passes the root-sum-of-squares over echoes). Only its
338 /// edges are used (MEDI's `gradient_mask`).
339 pub magnitude: &'a [f64],
340 /// Local field in ppm (the toolbox takes it in Hz; converted with [`ChiSepMediParams::cf`]).
341 pub local_field: &'a [f64],
342 /// R2′ in Hz (`r2prime`).
343 pub r2prime: &'a [f64],
344 /// Noise standard deviation of the field (`N_std`); the data weights are `mask/N_std`
345 /// normalised to mean 1, so only its relative values matter. The toolbox (script and GUI)
346 /// passes MEDI `Fit_ppm_complex`'s `dp1` from the echoes' magnitudes,
347 /// [`crate::utils::fit_ppm_complex_noise`].
348 pub n_std: &'a [f64],
349 /// Conventional QSM in ppm (`option_data.qsm`), the initial guess.
350 pub qsm: &'a [f64],
351 /// Brain mask (`mask`), nonzero inside.
352 pub mask: &'a [u8],
353 /// CSF mask (`option_data.mask_CSF`), nonzero inside; required, as `Chisep_script.m` passes
354 /// one. All zero: no CSF term, which reproduces the toolbox GUI (`mask_CSF = []`).
355 pub mask_csf: &'a [u8],
356}
357
358/// The arrays of one run, on the padded grid.
359struct Problem {
360 dims: (usize, usize, usize),
361 vs: [f64; 3],
362 /// R2′ data weight `wei_mag·m` and field data weight `m` (`M_cat`'s two halves)
363 w_mag: Vec<f64>,
364 w_phs: Vec<f64>,
365 /// R2′`.*mask` where R2′ > 0, and the local field in Hz (`target_`'s two halves)
366 t_mag: Vec<f64>,
367 t_phs: Vec<f64>,
368 /// Dipole kernel, MEDI's `dipole_kernel(..., 'kspace')`
369 d: Vec<f64>,
370 /// MEDI edge mask of the magnitude, 1 = not an edge (`wG`; `wG_r2p` is the same)
371 wg: [Vec<f64>; 3],
372 csf: Vec<usize>,
373 drp: f64,
374 drn: f64,
375 cf_mhz: f64,
376 lambda: f64,
377 lambda_csf: f64,
378}
379
380/// χ-separation by the SNU χ-separation toolbox's `chi_sep_MEDI` (see the module docs).
381///
382/// Takes the volumes of [`ChiSepMediInputs`] on `grid` and the B0 direction `bdir` (used as
383/// given, as the toolbox uses `params.b0_dir`; pass a unit vector), and returns `(χ+, χ−,
384/// χ_total)` in ppm, χ+ ≥ 0, χ− ≤ 0, zero outside the mask. `progress` receives
385/// `(iteration, max_iter)` after each Gauss-Newton step.
386///
387/// # Errors
388/// An [`InputError`] for parameters [`ChiSepMediParams::validate`] rejects and for a volume
389/// whose length is not the grid's.
390pub fn chi_sep_medi(
391 inputs: &ChiSepMediInputs,
392 grid: &Grid,
393 bdir: (f64, f64, f64),
394 params: &ChiSepMediParams,
395 mut progress: impl FnMut(usize, usize),
396) -> Result<ChiSepMaps, InputError> {
397 params.validate()?;
398 let n0 = grid.n_total();
399 for (name, len) in [
400 ("χ-separation MEDI magnitude", inputs.magnitude.len()),
401 ("χ-separation MEDI local_field", inputs.local_field.len()),
402 ("χ-separation MEDI r2prime", inputs.r2prime.len()),
403 ("χ-separation MEDI n_std", inputs.n_std.len()),
404 ("χ-separation MEDI qsm", inputs.qsm.len()),
405 ("χ-separation MEDI mask", inputs.mask.len()),
406 ("χ-separation MEDI mask_csf", inputs.mask_csf.len()),
407 ] {
408 check_len(name, len, n0)?;
409 }
410 let (nx, ny, nz0) = grid.dims;
411 let pz = params.pad;
412 let nz = nz0 + 2 * pz;
413 let dims = (nx, ny, nz);
414 let n = nx * ny * nz;
415 let nxy = nx * ny;
416 let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
417 // padarray(a, [0 0 pad_size], fill, 'both')
418 let pad = |a: &dyn Fn(usize) -> f64, fill: f64| -> Vec<f64> {
419 let mut out = vec![fill; n];
420 out[pz * nxy..(pz + nz0) * nxy].iter_mut().enumerate().for_each(|(i, v)| *v = a(i));
421 out
422 };
423 let mask0: Vec<bool> = inputs.mask.iter().map(|&m| m != 0).collect();
424 // wG = gradient_mask(iMag, Mask, 0.9) on the input grid; wG_r2p is the same. Padded with 1.
425 let wg0 = gradient_mask(inputs.magnitude, &mask0, grid, EDGE_PERCENTAGE);
426 let wg = [0, 1, 2].map(|c| pad(&|i| wg0[c][i], 1.0));
427 let maskf = pad(&|i| f64::from(u8::from(mask0[i])), 0.0);
428 let mask: Vec<bool> = maskf.iter().map(|&m| m != 0.0).collect();
429 let (drp, drn) = params.dr();
430 let cf_mhz = params.cf / 1e6;
431 let t_phs = pad(&|i| inputs.local_field[i] * cf_mhz, 0.0);
432 // r2prime(r2prime == 0) = eps, after padding
433 let mut r2p = pad(&|i| inputs.r2prime[i], 0.0);
434 par_update(&mut r2p, |_, v| {
435 if *v == 0.0 {
436 *v = f64::EPSILON;
437 }
438 });
439 let n_std = pad(&|i| inputs.n_std[i], 0.0);
440 let qsm = pad(&|i| inputs.qsm[i], 0.0);
441 let csf_mask: Vec<bool> = pad(&|i| f64::from(u8::from(inputs.mask_csf[i] != 0)), 0.0).iter().map(|&v| v != 0.0).collect();
442 let csf: Vec<usize> = (0..n).filter(|&i| csf_mask[i]).collect();
443
444 // m = dataterm_mask(1, N_std, Mask); M_cat = [wei_mag.*m.*wei_r2p, m]
445 let mut m = vec![0.0; n];
446 dataterm_mask(1, &n_std, &mask, &mut m);
447 let mut w_mag = vec![0.0; n];
448 par_fill(&mut w_mag, |i| WEI_MAG * m[i]);
449 // target: R2' where it is positive (the initial guess below takes it as given)
450 let mut t_mag = vec![0.0; n];
451 par_fill(&mut t_mag, |i| if r2p[i] > 0.0 { r2p[i] * maskf[i] } else { 0.0 });
452 let d = toolbox_dipole_kernel(dims, vs, bdir);
453
454 // Initial guess from the voxelwise system Dr_pos·x+ + Dr_neg·x− = R2', x+ − x− = χ.
455 let init = |sign: f64, dr_other: f64| -> Vec<f64> {
456 let mut v = vec![0.0; n];
457 par_fill(&mut v, |i| ((r2p[i] + sign * dr_other * qsm[i]) / (drp + drn)).max(0.0));
458 v
459 };
460 let xp0 = init(1.0, drn);
461 let xn0 = init(-1.0, drp);
462 // The weights of the first iteration see the guess with the CSF zeroed; the iterate does not.
463 let mut x_pos = xp0.clone();
464 let mut x_neg = xn0.clone();
465 for &i in &csf {
466 x_pos[i] = 0.0;
467 x_neg[i] = 0.0;
468 }
469 let mut it_pos = xp0;
470 let mut it_neg = xn0;
471 par_update(&mut it_pos, |i, v| *v *= maskf[i]);
472 par_update(&mut it_neg, |i, v| *v *= maskf[i]);
473 let eroded = convergence_mask(&mask, dims);
474
475 let pb = Problem {
476 dims, vs, w_mag, w_phs: m, t_mag, t_phs, d, wg, csf,
477 drp, drn, cf_mhz, lambda: params.lambda, lambda_csf: params.lambda_csf,
478 };
479 let mut ws = Workspace::new(dims);
480
481 let mut iter = 0;
482 let mut res = f64::INFINITY;
483 let mut res_min = f64::INFINITY;
484 let (mut best_pos, mut best_neg) = (x_pos.clone(), x_neg.clone());
485 let mut cg = CgBuffers::new(n);
486 let mut bp = vec![0.0; n];
487 let mut bn = vec![0.0; n];
488 while (res > params.tol || iter < CHI_SEP_MEDI_MIN_ITER) && iter < params.max_iter {
489 iter += 1;
490 // Vr = 1./sqrt(abs(wG.*grad(x)).^2 + eps), for each source and for their difference
491 let vr = |ws: &mut Workspace, x: &[f64]| -> [Vec<f64>; 3] {
492 fgrad(x, dims, vs, &mut ws.g);
493 let mut out = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
494 for c in 0..3 {
495 let (g, w) = (&ws.g[c], &pb.wg[c]);
496 par_fill(&mut out[c], |i| {
497 let a = w[i] * g[i];
498 // wG.*(Vr.*(wG.*.)) with wG ∈ {0, 1}
499 w[i] / (a * a + EPS_L1).sqrt()
500 });
501 }
502 out
503 };
504 let mut diff = vec![0.0; n];
505 par_fill(&mut diff, |i| x_pos[i] - x_neg[i]);
506 let w_pos = vr(&mut ws, &x_pos);
507 let w_neg = vr(&mut ws, &x_neg);
508 let w_tot = vr(&mut ws, &diff);
509 // bp, bn: the gradients, at (x_pos, x_neg)
510 pb.gradients(&mut ws, &x_pos, &x_neg, &w_pos, &w_neg, &w_tot, &mut bp, &mut bn);
511 par_update(&mut bp, |_, v| *v = -*v);
512 par_update(&mut bn, |_, v| *v = -*v);
513 cgsolve(|dx, out| pb.apply(&mut ws, dx, true, &w_pos, &w_tot, out), &bp, params.cg_tol, params.cg_max_iter, &mut cg, |_| {});
514 let dxp = cg.x.clone();
515 cgsolve(|dx, out| pb.apply(&mut ws, dx, false, &w_neg, &w_tot, out), &bn, params.cg_tol, params.cg_max_iter, &mut cg, |_| {});
516 let dxn = &cg.x;
517 // x = max(x + dx.*Mask, 0); res = norm(dx_real.*Me)/norm(x.*Me) over both sources
518 let mut num = 0.0;
519 let mut den = 0.0;
520 for (it, x, dx) in [(&mut it_pos, &mut x_pos, &dxp[..]), (&mut it_neg, &mut x_neg, &dxn[..])] {
521 for i in 0..n {
522 let new = (it[i] + dx[i] * maskf[i]).max(0.0);
523 if eroded[i] {
524 let dr = new - it[i];
525 num += dr * dr;
526 den += new * new;
527 }
528 it[i] = new;
529 x[i] = new;
530 }
531 }
532 res = num.sqrt() / den.sqrt();
533 if res < res_min {
534 res_min = res;
535 best_pos.copy_from_slice(&x_pos);
536 best_neg.copy_from_slice(&x_neg);
537 }
538 progress(iter, params.max_iter);
539 }
540
541 // Crop the padding; χ− is returned signed.
542 let crop = |a: &[f64], sign: f64| -> Vec<f64> {
543 (0..n0).map(|i| if mask0[i] { sign * a[pz * nxy + i] } else { 0.0 }).collect()
544 };
545 let chi_pos = crop(&best_pos, 1.0);
546 let chi_neg = crop(&best_neg, -1.0);
547 let chi_total = chi_pos.iter().zip(&chi_neg).map(|(p, q)| p + q).collect();
548 Ok((chi_pos, chi_neg, chi_total))
549}
550
551/// Buffers reused across operator applications.
552struct Workspace {
553 fft: Fft3dWorkspace,
554 a: Vec<Complex64>,
555 b: Vec<Complex64>,
556 g: [Vec<f64>; 3],
557 r: Vec<f64>,
558 s: Vec<f64>,
559}
560
561impl Workspace {
562 fn new((nx, ny, nz): (usize, usize, usize)) -> Self {
563 let n = nx * ny * nz;
564 let z = Complex64::new(0.0, 0.0);
565 Self {
566 fft: Fft3dWorkspace::new(nx, ny, nz),
567 a: vec![z; n],
568 b: vec![z; n],
569 g: [vec![0.0; n], vec![0.0; n], vec![0.0; n]],
570 r: vec![0.0; n],
571 s: vec![0.0; n],
572 }
573 }
574}
575
576impl Problem {
577 /// `Dconv_p` in place: `buf ← (CF/1e6)·ifftn(D.*fftn(buf))`
578 fn dconv(&self, fft: &mut Fft3dWorkspace, buf: &mut [Complex64]) {
579 fft.fft3d(buf);
580 par_update(buf, |i, v| *v *= self.d[i]);
581 fft.ifft3d(buf);
582 let s = self.cf_mhz;
583 par_update(buf, |_, v| *v *= s);
584 }
585
586 /// `λ_CSF·LT(LT(|x|))` added to `out`, with `LT(v) = Mask_CSF.*(v − mean(v(Mask_CSF)))`
587 fn add_csf(&self, x: &[f64], out: &mut [f64]) {
588 if self.csf.is_empty() {
589 return;
590 }
591 let k = self.csf.len() as f64;
592 let m1 = self.csf.iter().map(|&i| x[i].abs()).sum::<f64>() / k;
593 let lt: Vec<f64> = self.csf.iter().map(|&i| x[i].abs() - m1).collect();
594 let m2 = lt.iter().sum::<f64>() / k;
595 for (&i, &v) in self.csf.iter().zip(<) {
596 out[i] += self.lambda_csf * (v - m2);
597 }
598 }
599
600 /// The regulariser of one source: `λ·∇ᵀ(W_single ∇x) + 2λ·∇ᵀ(W_tot ∇(x − y)) + CSF(x)` with
601 /// `x − y` given as `diff`, added to `out`.
602 #[allow(clippy::too_many_arguments)]
603 fn add_reg(&self, ws: &mut Workspace, x: &[f64], diff: &[f64], w_single: &[Vec<f64>; 3], w_tot: &[Vec<f64>; 3], out: &mut [f64]) {
604 let lam = self.lambda;
605 reg(x, w_single, self.dims, self.vs, &mut ws.r);
606 reg(diff, w_tot, self.dims, self.vs, &mut ws.s);
607 let (r, s) = (&ws.r, &ws.s);
608 par_update(out, |i, v| *v += lam * r[i] + 2.0 * (lam * s[i]));
609 self.add_csf(x, out);
610 }
611
612 /// The CG operator of one source (`Ap` or `An`): data term plus regulariser at `dx` with the
613 /// other source's step zero.
614 fn apply(&self, ws: &mut Workspace, dx: &[f64], pos: bool, w_single: &[Vec<f64>; 3], w_tot: &[Vec<f64>; 3], out: &mut [f64]) {
615 let dr = if pos { self.drp } else { self.drn };
616 // phase: Dconv_p(m.*(m.*Dconv_p(dx)))
617 par_fill(&mut ws.a, |i| Complex64::new(dx[i], 0.0));
618 self.dconv(&mut ws.fft, &mut ws.a);
619 let w = &self.w_phs;
620 par_update(&mut ws.a, |i, v| *v = (*v * w[i]) * w[i]);
621 self.dconv(&mut ws.fft, &mut ws.a);
622 let (a, wm) = (&ws.a, &self.w_mag);
623 par_fill(out, |i| 2.0 * (dr * (wm[i] * (wm[i] * (dr * dx[i])))) + 2.0 * a[i].re);
624 // reg_p(dx, zero_mat) / reg_n(zero_mat, dx): the difference term sees dx − 0
625 self.add_reg(ws, dx, dx, w_single, w_tot, out);
626 }
627
628 /// `bp = fidelity_derivative_p + reg_p` and `bn` likewise, at `(xp, xn)`.
629 #[allow(clippy::too_many_arguments)]
630 fn gradients(
631 &self,
632 ws: &mut Workspace,
633 xp: &[f64],
634 xn: &[f64],
635 w_pos: &[Vec<f64>; 3],
636 w_neg: &[Vec<f64>; 3],
637 w_tot: &[Vec<f64>; 3],
638 bp: &mut [f64],
639 bn: &mut [f64],
640 ) {
641 let n = xp.len();
642 // field residual: Dconv_p(xp) + (−Dconv_p(xn)) − f, weighted twice
643 par_fill(&mut ws.a, |i| Complex64::new(xp[i], 0.0));
644 self.dconv(&mut ws.fft, &mut ws.a);
645 par_fill(&mut ws.b, |i| Complex64::new(xn[i], 0.0));
646 self.dconv(&mut ws.fft, &mut ws.b);
647 let (b, t, w) = (&ws.b, &self.t_phs, &self.w_phs);
648 par_update(&mut ws.a, |i, v| *v = ((*v + (-b[i])) - t[i]) * w[i] * w[i]);
649 self.dconv(&mut ws.fft, &mut ws.a);
650 // R2' residual: Dr_pos·xp + Dr_neg·xn − r2p.*mask, weighted twice
651 let mut rm = vec![0.0; n];
652 let (wm, tm) = (&self.w_mag, &self.t_mag);
653 par_fill(&mut rm, |i| wm[i] * (wm[i] * ((self.drp * xp[i] + self.drn * xn[i]) - tm[i])));
654 let a = &ws.a;
655 par_fill(bp, |i| 2.0 * (self.drp * rm[i]) + 2.0 * a[i].re);
656 par_fill(bn, |i| 2.0 * (self.drn * rm[i]) - 2.0 * a[i].re);
657 let mut diff = vec![0.0; n];
658 par_fill(&mut diff, |i| xp[i] - xn[i]);
659 self.add_reg(ws, xp, &diff, w_pos, w_tot, bp);
660 par_fill(&mut diff, |i| xn[i] - xp[i]);
661 self.add_reg(ws, xn, &diff, w_neg, w_tot, bn);
662 }
663}
664
665/// MEDI's `dipole_kernel(matrix_size, voxel_size, B0_dir, 'kspace')`, as the toolbox builds it:
666/// the k-grid `(-N/2 : N/2-1)/(N·vs)` per axis (half-integer steps on an odd axis), `D = 1/3 −
667/// (k·B0)²/|k|²`, `NaN` (k = 0) set to 0, then `fftshift`ed (circular shift by `floor(N/2)`).
668pub(super) fn toolbox_dipole_kernel((nx, ny, nz): (usize, usize, usize), vs: [f64; 3], b0: (f64, f64, f64)) -> Vec<f64> {
669 let coord = |i: usize, n: usize, v: f64| (-(n as f64) / 2.0 + i as f64) / (n as f64 * v);
670 let mut d = vec![0.0; nx * ny * nz];
671 for k in 0..nz {
672 let z = coord(k, nz, vs[2]);
673 let kk = (k + nz / 2) % nz;
674 for j in 0..ny {
675 let y = coord(j, ny, vs[1]);
676 let jj = (j + ny / 2) % ny;
677 for i in 0..nx {
678 let x = coord(i, nx, vs[0]);
679 let ii = (i + nx / 2) % nx;
680 let s = x * b0.0 + y * b0.1 + z * b0.2;
681 let den = x * x + y * y + z * z;
682 let v = 1.0 / 3.0 - (s * s) / den;
683 d[ii + nx * (jj + ny * kk)] = if v.is_nan() { 0.0 } else { v };
684 }
685 }
686 }
687 d
688}
689
690/// `imerode(mask, strel('sphere', r))`: a voxel stays if every voxel of the ball of radius `r`
691/// voxels around it is in the mask, counting voxels outside the array as in the mask (MATLAB's
692/// border rule for erosion).
693fn erode_sphere(mask: &[bool], (nx, ny, nz): (usize, usize, usize), r: i64) -> Vec<bool> {
694 let mut offs = Vec::new();
695 for dz in -r..=r {
696 for dy in -r..=r {
697 for dx in -r..=r {
698 if dx * dx + dy * dy + dz * dz <= r * r {
699 offs.push((dx, dy, dz));
700 }
701 }
702 }
703 }
704 let mut out = vec![false; mask.len()];
705 par_fill(&mut out, |p| {
706 if !mask[p] {
707 return false;
708 }
709 let (i, j, k) = ((p % nx) as i64, ((p / nx) % ny) as i64, (p / (nx * ny)) as i64);
710 offs.iter().all(|&(dx, dy, dz)| {
711 let (a, b, c) = (i + dx, j + dy, k + dz);
712 if a < 0 || b < 0 || c < 0 || a >= nx as i64 || b >= ny as i64 || c >= nz as i64 {
713 return true;
714 }
715 mask[a as usize + nx * (b as usize + ny * c as usize)]
716 })
717 });
718 out
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 /// A small separable problem: a χ+ block and a χ− block in an ellipsoid, with R2′ and field
726 /// consistent with them (the field from QSM.rs's own kernel; only shapes matter here).
727 /// (field ppm, R2′ Hz, χ ppm, magnitude, N_std, mask)
728 type Problem6 = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>);
729
730 fn problem(n: (usize, usize, usize)) -> Problem6 {
731 let (nx, ny, nz) = n;
732 let nt = nx * ny * nz;
733 let mut chi = vec![0.0; nt];
734 let mut mask = vec![0u8; nt];
735 let mut r2p = vec![0.0; nt];
736 for k in 0..nz {
737 for j in 0..ny {
738 for i in 0..nx {
739 let l = i + nx * (j + ny * k);
740 let (x, y, z) = (i as f64 - nx as f64 / 2.0, j as f64 - ny as f64 / 2.0, k as f64 - nz as f64 / 2.0);
741 if (x / (0.4 * nx as f64)).powi(2) + (y / (0.4 * ny as f64)).powi(2) + (z / (0.4 * nz as f64)).powi(2) <= 1.0 {
742 mask[l] = 1;
743 let (p, q) = (if x > 1.0 { 0.05 } else { 0.0 }, if x < -1.0 { 0.03 } else { 0.0 });
744 chi[l] = p - q;
745 r2p[l] = 137.0 * (p + q) + 2.0;
746 }
747 }
748 }
749 }
750 let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
751 let d = crate::kernels::dipole::dipole_kernel(&grid, (0.0, 0.0, 1.0));
752 let mut buf: Vec<Complex64> = chi.iter().map(|&v| Complex64::new(v, 0.0)).collect();
753 crate::fft::fft3d(&mut buf, nx, ny, nz);
754 buf.iter_mut().zip(&d).for_each(|(b, &dv)| *b *= dv);
755 crate::fft::ifft3d(&mut buf, nx, ny, nz);
756 let field: Vec<f64> = buf.iter().zip(&mask).map(|(b, &m)| b.re * m as f64).collect();
757 let mag: Vec<f64> = (0..nt).map(|l| 100.0 + (l % 7) as f64).collect();
758 let n_std: Vec<f64> = mask.iter().map(|&m| 0.01 * m as f64).collect();
759 (field, r2p, chi, mag, n_std, mask)
760 }
761
762 fn run(
763 n: (usize, usize, usize),
764 csf: bool,
765 params: &ChiSepMediParams,
766 ) -> (Result<ChiSepMaps, InputError>, usize, Vec<u8>) {
767 let (field, r2p, chi, mag, n_std, mask) = problem(n);
768 let mut mask_csf = vec![0u8; mask.len()];
769 if csf {
770 let c = n.0 / 2 + n.0 * (n.1 / 2 + n.1 * (n.2 / 2));
771 mask_csf[c] = 1;
772 mask_csf[c + 1] = 1;
773 }
774 let inputs = ChiSepMediInputs {
775 magnitude: &mag, local_field: &field, r2prime: &r2p, n_std: &n_std, qsm: &chi, mask: &mask, mask_csf: &mask_csf,
776 };
777 let grid = Grid::new(n.0, n.1, n.2, 1.0, 1.0, 1.0);
778 let mut iters = 0;
779 let r = chi_sep_medi(&inputs, &grid, (0.0, 0.0, 1.0), params, |i, _| iters = i);
780 (r, iters, mask)
781 }
782
783 #[test]
784 fn defaults_are_the_toolbox_values() {
785 let p = ChiSepMediParams::default();
786 assert_eq!((p.lambda, p.lambda_csf, p.pad, p.tol, p.max_iter, p.cg_tol, p.cg_max_iter), (1.0, 1.0, 5, 0.03, 30, 0.05, 100));
787 assert_eq!((p.dr_pos, p.dr_neg), (None, None));
788 let (dp, dn) = ChiSepMediParams { cf: 127.74e6, ..p.clone() }.dr();
789 assert_eq!((dp, dn), (141.7241379310345, 141.7241379310345));
790 let (dp, dn) = ChiSepMediParams { dr_pos: Some(120.0), dr_neg: Some(150.0), ..p }.dr();
791 assert_eq!((dp, dn), (120.0, 150.0));
792 }
793
794 #[test]
795 fn param_errors_name_the_parameter() {
796 let bad = [
797 ChiSepMediParams { cf: 0.0, ..Default::default() },
798 ChiSepMediParams { dr_pos: Some(-1.0), ..Default::default() },
799 ChiSepMediParams { dr_neg: Some(f64::NAN), ..Default::default() },
800 ChiSepMediParams { lambda: -1.0, ..Default::default() },
801 ChiSepMediParams { lambda_csf: f64::INFINITY, ..Default::default() },
802 ChiSepMediParams { tol: -0.1, ..Default::default() },
803 ChiSepMediParams { cg_tol: f64::NAN, ..Default::default() },
804 ChiSepMediParams { max_iter: 0, ..Default::default() },
805 ];
806 let names = ["cf", "dr_pos", "dr_neg", "lambda", "lambda_csf", "tol", "cg_tol", "max_iter"];
807 for (p, name) in bad.iter().zip(names) {
808 let e = p.validate().unwrap_err().to_string();
809 assert!(e.starts_with(&format!("χ-separation MEDI {name} = ")), "{e}");
810 }
811 assert!(ChiSepMediParams::default().validate().is_ok());
812 }
813
814 #[test]
815 fn wrong_buffer_length_is_an_input_error() {
816 let n = (8, 8, 6);
817 let nt = n.0 * n.1 * n.2;
818 let v = vec![0.0; nt];
819 let short = vec![0.0; nt - 1];
820 let m = vec![1u8; nt];
821 let grid = Grid::new(n.0, n.1, n.2, 1.0, 1.0, 1.0);
822 let inputs = ChiSepMediInputs { magnitude: &v, local_field: &v, r2prime: &short, n_std: &v, qsm: &v, mask: &m, mask_csf: &m };
823 let e = chi_sep_medi(&inputs, &grid, (0.0, 0.0, 1.0), &ChiSepMediParams::default(), |_, _| {}).unwrap_err();
824 assert_eq!(e.to_string(), format!("χ-separation MEDI r2prime has {} values, expected {nt}", nt - 1));
825 }
826
827 #[test]
828 fn sources_have_their_signs_and_runs_at_least_six_iterations() {
829 let (r, iters, mask) = run((16, 14, 10), true, &ChiSepMediParams::default());
830 let (p, q, t) = r.unwrap();
831 assert!(iters >= CHI_SEP_MEDI_MIN_ITER, "{iters}");
832 for l in 0..p.len() {
833 assert!(p[l] >= 0.0 && q[l] <= 0.0 && t[l] == p[l] + q[l]);
834 if mask[l] == 0 {
835 assert!(p[l] == 0.0 && q[l] == 0.0);
836 }
837 }
838 assert!(p.iter().any(|&v| v > 0.01) && q.iter().any(|&v| v < -0.01));
839 // max_iter below the minimum caps it
840 let (_, iters, _) = run((16, 14, 10), true, &ChiSepMediParams { max_iter: 2, ..Default::default() });
841 assert_eq!(iters, 2);
842 }
843
844 /// The toolbox turns every value NaN with a CSF mask that has no voxels; here it means no CSF
845 /// term, and the result is the same as with a mask that never touches a voxel's update.
846 /// `nnz(Mask_erode)` as `chi_sep_MEDI` made it (licensed R2023b, read from its workspace) for
847 /// box masks on a 64×60×56 grid (66 slices after its padding): the erosion radius follows the
848 /// smallest number of mask slices along an axis, rounded to tens.
849 #[test]
850 fn convergence_mask_matches_the_toolbox() {
851 let dims = (64, 60, 66);
852 let boxes = |boxes: &[([usize; 3], [usize; 3])]| -> Vec<bool> {
853 let mut m = vec![false; dims.0 * dims.1 * dims.2];
854 for &(o, n) in boxes {
855 for k in o[2]..o[2] + n[2] {
856 for j in o[1]..o[1] + n[1] {
857 for i in o[0]..o[0] + n[0] {
858 m[i + dims.0 * (j + dims.1 * (k + 5))] = true;
859 }
860 }
861 }
862 }
863 m
864 };
865 let centred = |n: [usize; 3]| boxes(&[([(64 - n[0]) / 2, (60 - n[1]) / 2, (56 - n[2]) / 2], n)]);
866 let count = |m: &[bool]| convergence_mask(m, dims).iter().filter(|&&v| v).count();
867 for (n, want) in [([10, 10, 10], 512), ([16, 16, 16], 1728), ([24, 24, 24], 8000), ([30, 30, 30], 13824),
868 ([48, 48, 48], 54872), ([40, 20, 10], 5472), ([15, 30, 30], 7436), ([25, 30, 30], 10944),
869 ([4, 30, 30], 64 * 60 * 66), ([56, 56, 50], 84640)] {
870 assert_eq!(count(¢red(n)), want, "box {n:?}");
871 }
872 // Two 10³ cubes far apart count 20 slices per axis: radius 2.
873 assert_eq!(count(&boxes(&[([4, 4, 4], [10; 3]), ([44, 39, 39], [10; 3])])), 432);
874 }
875
876 #[test]
877 fn empty_csf_mask_means_no_csf_term() {
878 let (r, _, _) = run((16, 14, 10), false, &ChiSepMediParams::default());
879 let (p, q, _) = r.unwrap();
880 assert!(p.iter().chain(&q).all(|v| v.is_finite()));
881 let (r0, _, _) = run((16, 14, 10), false, &ChiSepMediParams { lambda_csf: 0.0, ..Default::default() });
882 assert_eq!(r0.unwrap().0, p);
883 }
884
885 /// On an even grid MEDI's k-space kernel is the usual unshifted kernel; on an odd axis its
886 /// grid misses k = 0 by half a bin.
887 #[test]
888 fn toolbox_kernel_is_the_fft_kernel_on_even_grids_and_half_a_bin_off_on_odd() {
889 let vs = [0.8, 1.0, 1.5];
890 let h = (0.1, -0.2, 0.97);
891 let d = toolbox_dipole_kernel((8, 6, 4), vs, h);
892 let grid = Grid::new(8, 6, 4, vs[0], vs[1], vs[2]);
893 let norm = (h.0 * h.0 + h.1 * h.1 + h.2 * h.2).sqrt();
894 let r = crate::kernels::dipole::dipole_kernel(&grid, (h.0 / norm, h.1 / norm, h.2 / norm));
895 let rn = toolbox_dipole_kernel((8, 6, 4), vs, (h.0 / norm, h.1 / norm, h.2 / norm));
896 assert!(rn.iter().zip(&r).all(|(a, b)| (a - b).abs() < 1e-12));
897 assert_eq!(d[0], 0.0);
898 let odd = toolbox_dipole_kernel((5, 6, 4), [1.0; 3], (0.0, 0.0, 1.0));
899 // no exact zero frequency on the odd axis: no entry is the k = 0 fill value except by
900 // coincidence, and index 0 holds k_x = -0.5 + 2 = ... the fftshift of -2.5..1.5
901 assert!(odd[0] != 0.0);
902 }
903
904 #[test]
905 fn erosion_counts_voxels_outside_the_array_as_inside() {
906 let dims = (9, 9, 9);
907 let full = vec![true; 729];
908 assert!(erode_sphere(&full, dims, 3).iter().all(|&v| v));
909 let mut holed = full.clone();
910 holed[4 + 9 * (4 + 9 * 4)] = false;
911 let e = erode_sphere(&holed, dims, 3);
912 // every voxel within distance 3 of the hole is removed, the rest stay
913 for k in 0..9i64 {
914 for j in 0..9i64 {
915 for i in 0..9i64 {
916 let d2 = (i - 4).pow(2) + (j - 4).pow(2) + (k - 4).pow(2);
917 assert_eq!(e[(i + 9 * (j + 9 * k)) as usize], d2 > 9, "({i},{j},{k})");
918 }
919 }
920 }
921 }
922}