qsm_core/inversion/tfi.rs
1//! TFI (preconditioned Total Field Inversion)
2//!
3//! Single-step QSM: inverts the total field, before background removal, over the whole field of
4//! view. Background sources outside the mask are part of the solution, made cheap to reach by a
5//! preconditioner `P`, and the susceptibility is `χ = P·y`:
6//!
7//! min_y ||wG ∇(P y)||₁ + 2λ ||m (D P y − f)||²₂
8//!
9//! # What follows which source
10//!
11//! Cornell's own TFI script (`TFI_L1.m`) is not public. The 2024.11.26 MEDI toolbox has the TFI
12//! solver inside `MEDI_L1.m`: when `RDF.mat` holds a preconditioner `P` (`parse_QSM_input.m`),
13//! `MEDI_L1` solves for `y` with `χ = P·y`. This port follows that code line for line:
14//!
15//! - **2024 `MEDI_L1.m` and its helpers** (`fgrad.m`, `bdiv.m`, `gradient_mask.m`,
16//! `dataterm_mask.m`, `cgsolve.m`, `dipole_kernel.m`): the `'linear'` data term (and the
17//! toolbox's default exponential one, as an option), the data weight `m` from `N_std`, the edge mask `wG` and then `wG.*Mask`
18//! (no regularisation outside the mask), the lagged-diffusivity weight
19//! `1/sqrt(|wG ∇(P y)|² + 1e-6·P²)` (epsilon scaled by `P²`), the operator
20//! `PH(reg(P dx)) + 2λ·PH(Dconv(|w|² Dconv(P dx)))`, the right-hand side, the Gauss-Newton
21//! stop `||dy||/||y||`, MERIT, the output `P y` in ppm masked by `Mask`, and the defaults of
22//! `parse_QSM_input.m` (λ = 1000, MERIT on, 10 Gauss-Newton and 100 CG iterations, tolerances
23//! 0.1 and 0.01, `percentage` 0.9).
24//! - **Liu et al. 2017**, for what the toolbox does not settle for TFI: `P = 1` inside the mask
25//! and `P_B = 30` outside (eq. 3; 30 is the paper's in-vivo value); the input is the total
26//! field over the whole field of view, so there is no SMV step (an SMV high-pass would remove
27//! the background TFI is meant to fit); and the data term is the linear one of eq. 5, which is
28//! `MEDI_L1`'s `'linear'` option rather than its exponential default. (With the exponential
29//! term, `MEDI_L1` itself fails on in-vivo total fields in radians: the field wraps inside
30//! `exp(i·)`. Set `linear: false` to run the toolbox default.)
31//!
32//! Not ported: the R2*-based preconditioner (eq. 4), zero padding and the CSF zero reference.
33//!
34//! # Parity
35//! Against TFI as the verified QSM-CI `tfi-cornell` container runs it (MEDI 2024.11.26 `MEDI_L1.m`
36//! with a preconditioner, identical to licensed MATLAB R2023b), astewartau/QSM.rs#407. Two
37//! departures are deliberate, each with the original's behaviour as an option:
38//! - **Dipole kernel on odd grids** ([`TfiKernel`]). `dipole_kernel.m` builds its k grid as
39//! `-N/2:N/2-1`, half a frequency step off the DFT grid on an odd axis, so it is not the dipole
40//! kernel there. On QSM-CI's simulated head (164×205×205) that alone made the port's χ 0.77× the
41//! original's (42 % NRMSE); with [`TfiKernel::MediOddGrid`] 2.5 %. The DFT-grid kernel fits the
42//! true χ better (demeaned, in the mask: NRMSE 62.1 % against 71.4 %, r 0.785 against 0.718), so it
43//! stays the default. On even grids the two are the same.
44//! - **Precision** ([`TfiPrecision`]). `MEDI_L1.m` runs in single precision (`tempn =
45//! single(N_std)` makes the data weight single, and MATLAB's mixed arithmetic carries that into
46//! everything). On the fixture's small odd phantom (`tests/tfi_cornell_parity.rs`) the port with
47//! both options is 2.9e-6 relative after one Gauss-Newton step and 4.7e-4 with all defaults; in
48//! double precision 4.7e-3. The container itself differs by 3.0e-4 there on another CPU.
49//!
50//! Beyond that the original is not reproducible at full size: its 100-iteration CG solves at
51//! `cg_tol` 0.01 in single precision amplify rounding. In the same container, 1e-7 relative noise
52//! on the simulated head's total field moves its χ by 1.6 % NRMSE, and the container on another
53//! CPU (QSM-CI's runner against this VM) differs from itself by 2.8 % on the KKI travelling head.
54//! On the reference's own inputs the port with both options is 3.6 % (sim) and 1.26 % (KKI) from
55//! it; with the DFT-grid kernel in double precision, 42 % and 1.25 %.
56//!
57//! # Reference pipeline
58//! From Liu et al. 2017 (Methods, in-vivo experiments); not stated above:
59//! - Brain mask (the `M` where `P = 1`): BET. Field map: nonlinear (complex) field estimation
60//! followed by graph-cut phase unwrapping. No background removal or high-pass before TFI.
61//! - In vivo the paper uses the R2*-based preconditioner of eq. 4, `P = 1` in `M ∩ {R2* < 30 s⁻¹}`
62//! and `P_B` elsewhere, with `P_B` = 30 (10 for the phantom). Only eq. 3 is ported here.
63//! - Data: 3 T, 6 echoes, TE₁ 3.5 ms, ΔTE 3.6 ms, 1×1×3 mm³, monopolar readout.
64//! - The paper's solver settings differ from `MEDI_L1`'s defaults, which this port uses: λ = 1e-3
65//! on the L1 term (not the data term), Gauss-Newton stop `||dy||/||y|| < 0.01`, up to 300 CG
66//! iterations. CSF is the reference for reported values.
67//!
68//! # Units
69//! Like the other inversions, `tfi` takes the field in ppm and returns ppm. `MEDI_L1` works on the
70//! field in radians accumulated over the echo spacing (`delta_TE`) at `CF = γ·B0`; the field is
71//! converted with `2π·CF·delta_TE·1e-6` first and back at the end, as `MEDI_L1.m` does. The
72//! result depends on this scale, so `b0` and `echo_times` are scan parameters
73//! that the pipeline fills from metadata, and [`TfiParams::te`] picks the echo time the
74//! conversion uses (by default the reference's choice; see there).
75//!
76//! # Magnitude and data weight
77//! As for [`medi`](super::medi), the echo magnitudes ([`EchoMagnitudes`]) are required: the edge
78//! mask uses their root sum of squares (`iMag = sqrt(sum(abs(iField).^2,4))`) and the data weight
79//! `m = Mask./N_std` (mean 1 in the mask) uses `Fit_ppm_complex.m`'s noise map
80//! ([`fit_ppm_complex_noise`](crate::utils::fit_ppm_complex_noise)), as MEDI's pipeline makes
81//! them, so TFI always weights the data as the reference does.
82//!
83//! Reference:
84//! Liu, Z., Kee, Y., Zhou, D., Wang, Y., Spincemaille, P. (2017). "Preconditioned total field
85//! inversion (TFI) method for quantitative susceptibility mapping." Magnetic Resonance in
86//! Medicine, 78(1):303-315. https://doi.org/10.1002/mrm.26331
87//!
88//! Reference implementation: MEDI toolbox 2024.11.26 (Weill Cornell), `functions/MEDI_L1.m`.
89//!
90//! # Pipeline rules
91//!
92//! The facts above that a configuration can depart from are published as rules in
93//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `tfi.mask-bet`, `tfi.te-spacing`.
94
95use num_complex::Complex64;
96use crate::fft::Fft3dWorkspace;
97use crate::kernels::dipole::{dipole_kernel, dipole_kernel_medi};
98use crate::{EchoTime, Grid};
99
100use super::medi::{
101 bounding_box, cgsolve, dataterm_mask, dconv, dconv_weighted, fgrad, gradient_mask, par_fill,
102 par_update, reg, CgBuffers, EchoMagnitudes,
103};
104
105/// Which dipole kernel [`tfi`] uses: they differ only on grids with an odd number of voxels
106/// along some axis.
107#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
109pub enum TfiKernel {
110 /// `1/3 − (k·B0)²/|k|²` sampled on the DFT frequency grid ([`dipole_kernel`]), zero at DC,
111 /// on every grid. The default.
112 #[default]
113 Dft,
114 /// `dipole_kernel.m` exactly as MATLAB builds it ([`dipole_kernel_medi`]): on an odd axis its
115 /// `-N/2:N/2-1` grid samples half a frequency step off the DFT grid, with no zero at DC. The
116 /// parity option for the MEDI toolbox's TFI on odd grids (astewartau/QSM.rs#407).
117 MediOddGrid,
118}
119
120/// Arithmetic of [`tfi`]'s Gauss-Newton iterations.
121#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
122#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
123pub enum TfiPrecision {
124 /// Double precision throughout. The default.
125 #[default]
126 Double,
127 /// MATLAB's single precision, as `MEDI_L1.m` ends up running: it makes the data weight from
128 /// `tempn = single(N_std)`, and MATLAB's mixed arithmetic then makes the weight, the data,
129 /// every operator output, every CG vector and the iterate single (an array op with one
130 /// single operand is single). Here: every array is f32, with f32 FFTs, and the double
131 /// operands (`P`, the edge mask, `ε`, after MERIT the reweighted data weight) are rounded to
132 /// single where they meet a single one; inner products are accumulated in double and
133 /// rounded. The parity option for the MEDI toolbox's TFI (astewartau/QSM.rs#407).
134 Single,
135}
136
137/// TFI parameters (2024 `MEDI_L1.m` / `parse_QSM_input.m` defaults; `P_B` and the linear data
138/// term from Liu et al. 2017)
139#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
140#[derive(Clone, Debug)]
141pub struct TfiParams {
142 /// Weight of the data-fidelity term (`'lambda'`): larger fits the data more closely
143 pub lambda: f64,
144 /// Preconditioner outside the mask, `P_B` (1 inside); Liu et al. 2017 use 30 in vivo
145 pub precond: f64,
146 /// MERIT: reweight the noise by the data residual after each iteration (`'merit'`)
147 pub merit: bool,
148 /// Linear data term `||m (D P y − f)||²` (`'linear'`, Liu et al. 2017 eq. 5); `false` gives
149 /// `MEDI_L1`'s default exponential term `||m (exp(i D P y) − exp(i f))||²`
150 pub linear: bool,
151 /// Data weighting: 0 uniform (1 everywhere), 1 SNR (`mask / n_std`, mean 1 in the mask)
152 pub data_weighting: i32,
153 /// Edge fraction for the gradient mask (`'percentage'`)
154 pub percentage: f64,
155 /// CG relative residual tolerance
156 pub cg_tol: f64,
157 /// Maximum CG iterations per Gauss-Newton step
158 pub cg_max_iter: usize,
159 /// Maximum Gauss-Newton iterations
160 pub max_iter: usize,
161 /// Stop when ||dy|| / ||y|| falls below this
162 pub tol: f64,
163 /// Field strength in Tesla (scan parameter, for the radian conversion)
164 pub b0: f64,
165 /// Echo times in seconds, in acquisition order (scan parameter, for the radian
166 /// conversion).
167 pub echo_times: Vec<f64>,
168 /// Which echo time the radian conversion uses. Default [`EchoTime::Spacing`]: the MEDI
169 /// toolbox's `MEDI_L1.m` works on the field in radians over `delta_TE`
170 /// (`x/(2*pi*delta_TE*CF)*1e6`), which its readers set to `TE(2) - TE(1)`, or TE for one echo
171 /// (`Read_Siemens_DICOM.m` lines 176-178).
172 pub te: EchoTime,
173 /// Dipole kernel (default [`TfiKernel::Dft`]). `MEDI_L1.m` calls `dipole_kernel.m`, whose
174 /// k-space grid is half a step off on axes with an odd number of voxels; there it is not the
175 /// dipole field. On QSM-CI's simulated head (164×205×205) that scales χ by ~1.3 against the
176 /// DFT-grid kernel and fits the true χ worse (in the mask, demeaned: NRMSE 71.4 % against
177 /// 62.1 %, r 0.718 against 0.785), so the DFT-grid kernel is the default and
178 /// [`TfiKernel::MediOddGrid`] the parity option. On even grids they are the same.
179 pub kernel: TfiKernel,
180 /// Arithmetic (default [`TfiPrecision::Double`]). `MEDI_L1.m` runs in single precision, by
181 /// accident of `tempn = single(N_std)` (see [`TfiPrecision::Single`]); the CG solves at
182 /// `cg_tol` 0.01 then stop at different points from double precision, which moves χ by about
183 /// 1 % (KKI travelling head) from the same inputs. Double precision solves the same equations
184 /// more accurately, so it is the default and single precision the parity option.
185 pub precision: TfiPrecision,
186}
187
188impl Default for TfiParams {
189 fn default() -> Self {
190 Self {
191 lambda: 1000.0,
192 precond: 30.0,
193 merit: true,
194 linear: true,
195 data_weighting: 1,
196 percentage: 0.9,
197 cg_tol: 0.01,
198 cg_max_iter: 100,
199 max_iter: 10,
200 tol: 0.1,
201 b0: 3.0,
202 echo_times: vec![0.005],
203 te: EchoTime::Spacing,
204 kernel: TfiKernel::Dft,
205 precision: TfiPrecision::Double,
206 }
207 }
208}
209
210/// Preconditioned total field inversion (2024 `MEDI_L1.m` with a preconditioner)
211///
212/// # Arguments
213/// * `total_field` - Total field in ppm, before background removal (nx * ny * nz)
214/// * `mask` - Binary mask (nx * ny * nz), 1 = brain: `P = 1` and the data are fitted here
215/// * `magnitude` - Echo magnitudes, or a combined magnitude (see module docs)
216/// * `grid` - Volume grid (dimensions and voxel sizes)
217/// * `bdir` - B0 field direction
218/// * `params` - TFI parameters, including field strength and echo spacing
219/// * `progress` - Progress callback `(current_step, total_steps)` over CG iterations
220///
221/// # Returns
222/// Susceptibility `P·y` in ppm, zero outside the mask
223pub fn tfi(
224 total_field: &[f64],
225 mask: &[u8],
226 magnitude: EchoMagnitudes,
227 grid: &Grid,
228 bdir: (f64, f64, f64),
229 params: &TfiParams,
230 progress: impl FnMut(usize, usize),
231) -> Vec<f64> {
232 let n = grid.n_total();
233 // iMag = sqrt(sum(abs(iField).^2,4)); [~, N_std] = Fit_ppm_complex(iField)
234 let EchoMagnitudes { data: m, n_echoes } = magnitude;
235 assert!(n_echoes > 0 && m.len() == n * n_echoes, "magnitude is not n_echoes volumes");
236 let imag: Vec<f64> = (0..n)
237 .map(|i| (0..n_echoes).map(|e| m[i + n * e].powi(2)).sum::<f64>().sqrt())
238 .collect();
239 let n_std = crate::utils::fit_ppm_complex_noise(m, n_echoes);
240 tfi_l1(total_field, mask, &imag, &n_std, grid, bdir, params, progress)
241}
242
243/// `MEDI_L1.m` with the preconditioner, on an `iMag` and an `N_std`
244#[allow(clippy::too_many_arguments)]
245fn tfi_l1(
246 total_field: &[f64],
247 mask: &[u8],
248 magnitude: &[f64],
249 n_std: &[f64],
250 grid: &Grid,
251 bdir: (f64, f64, f64),
252 params: &TfiParams,
253 mut progress: impl FnMut(usize, usize),
254) -> Vec<f64> {
255 let n = grid.n_total();
256 let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
257 let dims = grid.dims;
258 let mut fft = Fft3dWorkspace::new(grid.nx(), grid.ny(), grid.nz());
259 let mut buf = vec![Complex64::new(0.0, 0.0); n];
260
261 // ppm -> radians over delta_TE; MEDI_L1 converts back with x/(2*pi*delta_TE*CF)*1e6.
262 let cf = crate::constants::GAMMA_BAR_HZ_PER_T * params.b0;
263 let ppm_to_rad = 2.0 * std::f64::consts::PI * params.te.seconds(¶ms.echo_times, "TFI") * cf * 1e-6;
264 let mut rdf = vec![0.0f64; n];
265 par_fill(&mut rdf, |i| total_field[i] * ppm_to_rad);
266
267 let mask: Vec<bool> = mask.iter().map(|&m| m != 0).collect();
268 // P = 1*Mask + P_B*(1-Mask) (Liu et al. 2017, eq. 3). P is real, so PH = P.
269 let mut p = vec![0.0f64; n];
270 par_fill(&mut p, |i| if mask[i] { 1.0 } else { params.precond });
271
272 // N_std = N_std.*Mask; tempn = N_std
273 let mut n_std: Vec<f64> = (0..n).map(|i| if mask[i] { n_std[i] } else { 0.0 }).collect();
274
275 // D = dipole_kernel(matrix_size, voxel_size, B0_dir): see TfiKernel
276 let dk = match params.kernel {
277 TfiKernel::Dft => dipole_kernel(grid, bdir),
278 TfiKernel::MediOddGrid => {
279 let n = (bdir.0 * bdir.0 + bdir.1 * bdir.1 + bdir.2 * bdir.2).sqrt();
280 dipole_kernel_medi(grid, (bdir.0 / n, bdir.1 / n, bdir.2 / n))
281 }
282 };
283 let mut m = vec![0.0f64; n];
284 dataterm_mask(params.data_weighting, &n_std, &mask, &mut m);
285 // opts.wG = gradient_mask(...); with a preconditioner, opts.wG = wG.*Mask
286 let mut wg = gradient_mask(magnitude, &mask, grid, params.percentage);
287 for c in wg.iter_mut() {
288 par_update(c, |i, v| {
289 if !mask[i] {
290 *v = 0.0;
291 }
292 });
293 }
294 if params.precision == TfiPrecision::Single {
295 return tfi_single(&rdf, &mask, &p, n_std, &dk, &wg, grid, ppm_to_rad, params, progress);
296 }
297 // m, and with it w2, is zero outside the mask (SNR weighting), so the Dconv(P dx) in A(dx)
298 // is only needed, and the Dconv after the weighting only has input, inside its bounding box.
299 let d: Vec<Complex64> = dk.iter().map(|&v| Complex64::new(v, 0.0)).collect();
300 let mbox = if params.data_weighting == 0 {
301 [0..grid.nx(), 0..grid.ny(), 0..grid.nz()]
302 } else {
303 bounding_box(&mask, dims)
304 };
305
306 let linear = params.linear;
307 // B = m.*exp(1i*RDF), or m.*RDF with 'linear'
308 let data = |m: &[f64], b0: &mut [Complex64]| {
309 par_fill(b0, |i| {
310 if linear { Complex64::new(m[i] * rdf[i], 0.0) } else { m[i] * Complex64::new(0.0, rdf[i]).exp() }
311 });
312 };
313 // W = m.*exp(1i*Dconv(x)), or m.*Dconv(x) with 'linear'
314 let model = |m: f64, dpx: f64| -> Complex64 {
315 if linear { Complex64::new(m * dpx, 0.0) } else { m * Complex64::new(0.0, dpx).exp() }
316 };
317 let mut b0 = vec![Complex64::new(0.0, 0.0); n];
318 data(&m, &mut b0);
319
320 // e = 0.000001, scaled with P: e*PH(P(ones(matrix_size)))
321 let e: Vec<f64> = p.iter().map(|&v| 1e-6 * v * v).collect();
322 let lambda2 = 2.0 * params.lambda;
323
324 let mut x = vec![0.0f64; n];
325 let mut px = vec![0.0f64; n];
326 let mut pq = vec![0.0f64; n];
327 // wG.*Vr.*wG per direction
328 let mut wvw = [vec![0.0f64; n], vec![0.0f64; n], vec![0.0f64; n]];
329 let mut g = [vec![0.0f64; n], vec![0.0f64; n], vec![0.0f64; n]];
330 let mut w = vec![Complex64::new(0.0, 0.0); n];
331 let mut w2 = vec![0.0f64; n];
332 let mut b = vec![0.0f64; n];
333 let mut t = vec![0.0f64; n];
334 let mut rv = vec![0.0f64; n];
335 let mut cg = CgBuffers::new(n);
336 let mask_idx: Vec<usize> = (0..n).filter(|&i| mask[i]).collect();
337 let total = params.max_iter * params.cg_max_iter;
338 let mut res_norm_ratio = f64::INFINITY;
339 let mut iter = 0;
340 while res_norm_ratio > params.tol && iter < params.max_iter {
341 iter += 1;
342 // w = W(opts.m, P(x))
343 par_fill(&mut px, |i| p[i] * x[i]);
344 dconv(&px, &d, &mut fft, &mut buf, &mut t);
345 par_fill(&mut w, |i| model(m[i], t[i]));
346 // reg_MEDI(opts, P(x), PHP): Vr = 1./sqrt(abs(wG.*grad(real(P(x)))).^2 + e)
347 fgrad(&px, dims, vs, &mut g);
348 for c in 0..3 {
349 let (wgc, gc) = (&wg[c], &g[c]);
350 par_fill(&mut wvw[c], |i| {
351 let u = wgc[i] * gc[i];
352 let vr = 1.0 / (u * u + e[i]).sqrt();
353 wgc[i] * vr * wgc[i]
354 });
355 }
356 // fidelity weight: conj(opts.m).*opts.m (linear) or conj(w).*w
357 if linear {
358 par_fill(&mut w2, |i| m[i] * m[i]);
359 } else {
360 par_fill(&mut w2, |i| w[i].norm_sqr());
361 }
362
363 // b = PH(reg(P(x))) + 2*lambda*PH(Dconv(real(conj(w).*conj(1i).*(w-b0))))
364 // or, with 'linear', b = PH(reg(P(x))) + 2*lambda*PH(Dconv(real(conj(opts.m).*(w-b0))))
365 par_fill(&mut rv, |i| {
366 if linear {
367 m[i] * (w[i] - b0[i]).re
368 } else {
369 (w[i].conj() * Complex64::new(0.0, -1.0) * (w[i] - b0[i])).re
370 }
371 });
372 dconv(&rv, &d, &mut fft, &mut buf, &mut t);
373 reg(&px, &wvw, dims, vs, &mut b);
374 par_update(&mut b, |i, v| *v = -(p[i] * *v + lambda2 * p[i] * t[i]));
375
376 // A = @(dx) PH(reg(P(dx))) + 2*lambda*PH(Dconv(w2.*Dconv(P(dx)))); dx = cgsolve(A, -b)
377 let base = (iter - 1) * params.cg_max_iter;
378 cgsolve(
379 |q: &[f64], out: &mut [f64]| {
380 par_fill(&mut pq, |i| p[i] * q[i]);
381 reg(&pq, &wvw, dims, vs, out);
382 dconv_weighted(&pq, &d, &w2, &mbox, &mut fft, &mut buf);
383 par_update(out, |i, v| *v = p[i] * *v + lambda2 * p[i] * buf[i].re);
384 },
385 &b, params.cg_tol, params.cg_max_iter, &mut cg,
386 |k| progress(base + k, total),
387 );
388 let dx = &cg.x;
389
390 // res_norm_ratio = norm(dx)/norm(x); x = x + dx
391 let ndx = dx.iter().map(|v| v * v).sum::<f64>().sqrt();
392 let nrm_x = x.iter().map(|v| v * v).sum::<f64>().sqrt();
393 res_norm_ratio = ndx / nrm_x;
394 par_update(&mut x, |i, v| *v += dx[i]);
395
396 if params.merit {
397 // wres = W(opts.m, P(x)) - b0, used inside the mask only
398 par_fill(&mut px, |i| p[i] * x[i]);
399 dconv(&px, &d, &mut fft, &mut buf, &mut t);
400 let wres: Vec<Complex64> = mask_idx.iter().map(|&i| model(m[i], t[i]) - b0[i]).collect();
401 let mean = wres.iter().copied().sum::<Complex64>() / mask_idx.len() as f64;
402 // factor = std(abs(a))*6, MATLAB's std (normalised by N-1)
403 let a: Vec<f64> = wres.iter().map(|&v| (v - mean).norm()).collect();
404 let am = a.iter().sum::<f64>() / a.len() as f64;
405 let sd = (a.iter().map(|v| (v - am).powi(2)).sum::<f64>() / (a.len() as f64 - 1.0)).sqrt();
406 let factor = sd * 6.0;
407 for (q, &i) in mask_idx.iter().enumerate() {
408 let r = (a[q] / factor).max(1.0);
409 n_std[i] *= r * r;
410 }
411 dataterm_mask(params.data_weighting, &n_std, &mask, &mut m);
412 data(&m, &mut b0);
413 }
414 }
415 progress(total, total);
416
417 // x = P(x)/(2*pi*delta_TE*CF)*1e6.*Mask
418 par_update(&mut x, |i, v| *v = if mask[i] { p[i] * *v / ppm_to_rad } else { 0.0 });
419 x
420}
421
422/// [`tfi_l1`] in MATLAB's single precision ([`TfiPrecision::Single`]): `MEDI_L1.m` line for line
423/// with MATLAB's types. `tempn = single(N_std)` makes the data weight `m`, and with it the data
424/// `b0 = m.*RDF`, single; from there every product with a single operand is single, so the
425/// operator, the right-hand side, `cgsolve.m`'s vectors and scalars, `dx` and the iterate `x`
426/// (`x + dx`) are single. Double operands (`P`, the edge mask, `ε`, the kernel, and after MERIT
427/// the data weight and data, which `dataterm_mask` remakes from `double(N_std)`) are rounded to
428/// single where they meet a single one. FFTs are single precision.
429#[allow(clippy::too_many_arguments)]
430fn tfi_single(
431 rdf: &[f64],
432 mask: &[bool],
433 p: &[f64],
434 mut n_std: Vec<f64>,
435 dk: &[f64],
436 wg: &[Vec<f64>; 3],
437 grid: &Grid,
438 ppm_to_rad: f64,
439 params: &TfiParams,
440 mut progress: impl FnMut(usize, usize),
441) -> Vec<f64> {
442 use num_complex::Complex32;
443 let n = grid.n_total();
444 let (nx, ny, nz) = grid.dims;
445 let vs = [grid.vsx() as f32, grid.vsy() as f32, grid.vsz() as f32];
446 let mut fft = crate::fft::Fft3dWorkspaceF32::new(nx, ny, nz);
447 let mut buf = vec![Complex32::new(0.0, 0.0); n];
448 let d: Vec<f32> = dk.iter().map(|&v| v as f32).collect();
449 let p32: Vec<f32> = p.iter().map(|&v| v as f32).collect();
450 let wg32: Vec<Vec<f32>> = wg.iter().map(|c| c.iter().map(|&v| v as f32).collect()).collect();
451 // e = 0.000001*PH(P(ones)) (reg_MEDI.m), double, rounded where it meets single
452 let e: Vec<f32> = p.iter().map(|&v| (1e-6 * v * v) as f32).collect();
453 let lambda2 = (2.0 * params.lambda) as f32;
454 let linear = params.linear;
455
456 // Dconv = @(dx) real(ifftn(D.*fftn(dx))), single
457 let mut dconv = |x: &[f32], out: &mut [f32]| {
458 for (c, &v) in buf.iter_mut().zip(x) {
459 *c = Complex32::new(v, 0.0);
460 }
461 fft.fft3d(&mut buf);
462 for (c, &dv) in buf.iter_mut().zip(&d) {
463 *c *= dv;
464 }
465 fft.ifft3d(&mut buf);
466 for (o, c) in out.iter_mut().zip(&buf) {
467 *o = c.re;
468 }
469 };
470 let nxy = nx * ny;
471 // fgrad.m, single
472 let fgrad = |x: &[f32], g: &mut [Vec<f32>; 3]| {
473 for k in 0..nz {
474 for j in 0..ny {
475 for i in 0..nx {
476 let q = i + nx * j + nxy * k;
477 g[0][q] = if i + 1 < nx { (x[q + 1] - x[q]) / vs[0] } else { 0.0 };
478 g[1][q] = if j + 1 < ny { (x[q + nx] - x[q]) / vs[1] } else { 0.0 };
479 g[2][q] = if k + 1 < nz { (x[q + nxy] - x[q]) / vs[2] } else { 0.0 };
480 }
481 }
482 }
483 };
484 // bdiv.m, single
485 let bdiv = |g: &[Vec<f32>; 3], out: &mut [f32]| {
486 for k in 0..nz {
487 for j in 0..ny {
488 for i in 0..nx {
489 let q = i + nx * j + nxy * k;
490 let tx = (if i + 1 < nx { g[0][q] } else { 0.0 }) - (if i > 0 { g[0][q - 1] } else { 0.0 });
491 let ty = (if j + 1 < ny { g[1][q] } else { 0.0 }) - (if j > 0 { g[1][q - nx] } else { 0.0 });
492 let tz = (if k + 1 < nz { g[2][q] } else { 0.0 }) - (if k > 0 { g[2][q - nxy] } else { 0.0 });
493 out[q] = -(tx / vs[0] + ty / vs[1] + tz / vs[2]);
494 }
495 }
496 }
497 };
498 // inner products: accumulated in double, returned single
499 let dot = |a: &[f32], b: &[f32]| -> f32 { a.iter().zip(b).map(|(&x, &y)| x as f64 * y as f64).sum::<f64>() as f32 };
500
501 // m = dataterm_mask(mode, single(N_std), Mask): single
502 let dataterm_single = |n_std: &[f64]| -> Vec<f32> {
503 if params.data_weighting == 0 {
504 return vec![1.0; n];
505 }
506 let mut w: Vec<f32> = (0..n)
507 .map(|i| {
508 let v = (mask[i] as u8 as f32) / (n_std[i] as f32);
509 if mask[i] && v.is_finite() { v } else { 0.0 }
510 })
511 .collect();
512 let count = mask.iter().filter(|&&m| m).count() as f64;
513 let mean = (w.iter().zip(mask).filter(|(_, &m)| m).map(|(&v, _)| v as f64).sum::<f64>() / count) as f32;
514 w.iter_mut().for_each(|v| *v /= mean);
515 w
516 };
517 let mut m: Vec<f32> = dataterm_single(&n_std);
518 // b0 = B(m, RDF): m.*RDF (linear) or m.*exp(1i*RDF), single
519 let data = |m: &[f32]| -> Vec<Complex32> {
520 (0..n)
521 .map(|i| {
522 if linear { Complex32::new(m[i] * rdf[i] as f32, 0.0) } else { m[i] * Complex32::new(0.0, rdf[i] as f32).exp() }
523 })
524 .collect()
525 };
526 let mut b0 = data(&m);
527
528 let mut x = vec![0.0f32; n];
529 let mut px = vec![0.0f32; n];
530 let mut t = vec![0.0f32; n];
531 let mut t2 = vec![0.0f32; n];
532 let mut g = [vec![0.0f32; n], vec![0.0f32; n], vec![0.0f32; n]];
533 let mut wvw = [vec![0.0f32; n], vec![0.0f32; n], vec![0.0f32; n]];
534 let mut w = vec![Complex32::new(0.0, 0.0); n];
535 let mut w2 = vec![0.0f32; n];
536 let mut b = vec![0.0f32; n];
537 let mut rv = vec![0.0f32; n];
538 let (mut cx, mut cr, mut cd, mut cq) = (vec![0.0f32; n], vec![0.0f32; n], vec![0.0f32; n], vec![0.0f32; n]);
539 let mut pq = vec![0.0f32; n];
540 let mut regout = vec![0.0f32; n];
541 let model = |m: f32, dpx: f32| -> Complex32 {
542 if linear { Complex32::new(m * dpx, 0.0) } else { m * Complex32::new(0.0, dpx).exp() }
543 };
544 let mask_idx: Vec<usize> = (0..n).filter(|&i| mask[i]).collect();
545 let total = params.max_iter * params.cg_max_iter;
546 let mut res_norm_ratio = f32::INFINITY;
547 let mut iter = 0;
548 while res_norm_ratio > params.tol as f32 && iter < params.max_iter {
549 iter += 1;
550 for i in 0..n {
551 px[i] = p32[i] * x[i];
552 }
553 dconv(&px, &mut t);
554 for i in 0..n {
555 w[i] = model(m[i], t[i]);
556 }
557 // reg_MEDI: Vr = 1./sqrt(abs(wG.*grad(real(P(x)))).^2 + e)
558 fgrad(&px, &mut g);
559 for c in 0..3 {
560 for i in 0..n {
561 let u = wg32[c][i] * g[c][i];
562 let vr = 1.0 / (u * u + e[i]).sqrt();
563 wvw[c][i] = wg32[c][i] * vr * wg32[c][i];
564 }
565 }
566 if linear {
567 for i in 0..n {
568 w2[i] = m[i] * m[i];
569 }
570 } else {
571 for i in 0..n {
572 w2[i] = w[i].norm_sqr();
573 }
574 }
575 // reg(dx) = PH(div(wG.*Vr.*wG.*grad(real(P(dx)))))
576 let regop = |q: &[f32], out: &mut [f32], pq: &mut [f32], g: &mut [Vec<f32>; 3]| {
577 for i in 0..n {
578 pq[i] = p32[i] * q[i];
579 }
580 fgrad(pq, g);
581 for c in 0..3 {
582 for i in 0..n {
583 g[c][i] *= wvw[c][i];
584 }
585 }
586 bdiv(g, out);
587 for i in 0..n {
588 out[i] *= p32[i];
589 }
590 };
591 // b = reg(x) + 2*lambda*PH(Dconv(real(conj(m).*(w-b0)))) (or the exponential term's)
592 for i in 0..n {
593 rv[i] = if linear {
594 m[i] * (w[i] - b0[i]).re
595 } else {
596 (w[i].conj() * Complex32::new(0.0, -1.0) * (w[i] - b0[i])).re
597 };
598 }
599 dconv(&rv, &mut t);
600 regop(&x, &mut b, &mut pq, &mut g);
601 for i in 0..n {
602 b[i] = -(b[i] + lambda2 * (p32[i] * t[i]));
603 }
604 // A = @(dx) reg(dx) + 2*lambda*PH(Dconv(w2.*Dconv(P(dx)))); dx = cgsolve(A, -b)
605 let mut apply = |q: &[f32], out: &mut [f32]| {
606 regop(q, &mut regout, &mut pq, &mut g);
607 for i in 0..n {
608 pq[i] = p32[i] * q[i];
609 }
610 dconv(&pq, &mut t);
611 for i in 0..n {
612 t[i] *= w2[i];
613 }
614 dconv(&t, &mut t2);
615 for i in 0..n {
616 out[i] = regout[i] + lambda2 * (p32[i] * t2[i]);
617 }
618 };
619 // cgsolve.m, single
620 let base = (iter - 1) * params.cg_max_iter;
621 let tol = params.cg_tol as f32;
622 cx.fill(0.0);
623 cr.copy_from_slice(&b);
624 cd.copy_from_slice(&b);
625 let mut delta = dot(&cr, &cr);
626 let delta0 = dot(&b, &b);
627 let mut numiter = 0;
628 while numiter < params.cg_max_iter && delta > tol * tol * delta0 {
629 apply(&cd, &mut cq);
630 let alpha = delta / dot(&cd, &cq);
631 for i in 0..n {
632 cx[i] += alpha * cd[i];
633 }
634 if (numiter + 1) % 50 == 0 {
635 apply(&cx, &mut cq);
636 for i in 0..n {
637 cr[i] = b[i] - cq[i];
638 }
639 } else {
640 for i in 0..n {
641 cr[i] -= alpha * cq[i];
642 }
643 }
644 let delta_old = delta;
645 delta = dot(&cr, &cr);
646 let beta = delta / delta_old;
647 for i in 0..n {
648 cd[i] = cr[i] + beta * cd[i];
649 }
650 numiter += 1;
651 progress(base + numiter, total);
652 }
653 // res_norm_ratio = norm(dx)/norm(x); x = x + dx
654 res_norm_ratio = dot(&cx, &cx).sqrt() / dot(&x, &x).sqrt();
655 for i in 0..n {
656 x[i] += cx[i];
657 }
658
659 if params.merit {
660 for i in 0..n {
661 px[i] = p32[i] * x[i];
662 }
663 dconv(&px, &mut t);
664 // wres = W(m, P(x)) - b0, single; mean and std over the mask in single
665 let wres: Vec<Complex32> = mask_idx.iter().map(|&i| model(m[i], t[i]) - b0[i]).collect();
666 let cnt = mask_idx.len() as f64;
667 let mean = {
668 let s = wres.iter().fold(num_complex::Complex64::new(0.0, 0.0), |a, v| a + num_complex::Complex64::new(v.re as f64, v.im as f64));
669 Complex32::new((s.re / cnt) as f32, (s.im / cnt) as f32)
670 };
671 let a: Vec<f32> = wres.iter().map(|&v| (v - mean).norm()).collect();
672 let am = (a.iter().map(|&v| v as f64).sum::<f64>() / cnt) as f32;
673 let sd = ((a.iter().map(|&v| ((v - am) as f64).powi(2)).sum::<f64>() / (cnt - 1.0)).sqrt()) as f32;
674 let factor = sd * 6.0;
675 // N_std(Mask==1) = N_std(Mask==1).*wres(Mask==1).^2: single product, stored double
676 for (q, &i) in mask_idx.iter().enumerate() {
677 let r = (a[q] / factor).max(1.0);
678 n_std[i] = ((n_std[i] as f32) * (r * r)) as f64;
679 }
680 // tempn = double(N_std); m = dataterm_mask(...) in double, b0 = B(m, RDF) in double,
681 // both rounded to single where they meet the single iterate
682 let mut md = vec![0.0f64; n];
683 dataterm_mask(params.data_weighting, &n_std, mask, &mut md);
684 m = md.iter().map(|&v| v as f32).collect();
685 b0 = (0..n)
686 .map(|i| {
687 if linear {
688 Complex32::new((md[i] * rdf[i]) as f32, 0.0)
689 } else {
690 let z = md[i] * num_complex::Complex64::new(0.0, rdf[i]).exp();
691 Complex32::new(z.re as f32, z.im as f32)
692 }
693 })
694 .collect();
695 }
696 }
697 progress(total, total);
698 let scale = ppm_to_rad as f32;
699 (0..n).map(|i| if mask[i] { (p32[i] * x[i] / scale) as f64 } else { 0.0 }).collect()
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 fn small() -> TfiParams {
707 TfiParams { cg_max_iter: 10, max_iter: 3, ..TfiParams::default() }
708 }
709
710 fn cube_mask(n: usize, lo: usize, hi: usize) -> Vec<u8> {
711 (0..n * n * n)
712 .map(|i| {
713 let (x, y, z) = (i % n, (i / n) % n, i / (n * n));
714 u8::from((lo..hi).contains(&x) && (lo..hi).contains(&y) && (lo..hi).contains(&z))
715 })
716 .collect()
717 }
718
719 #[test]
720 fn zero_field_gives_zero() {
721 let n = 8;
722 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
723 let mask = cube_mask(n, 2, 6);
724 let mag: Vec<f64> = (0..n * n * n).map(|i| 1.0 + (i % 5) as f64).collect();
725 let chi = tfi_l1(&vec![0.0; n * n * n], &mask, &mag, &vec![1.0; mag.len()], &grid, (0.0, 0.0, 1.0), &small(), |_, _| {});
726 assert!(chi.iter().all(|v| v.abs() < 1e-12));
727 }
728
729 #[test]
730 fn output_is_masked_and_finite() {
731 let n = 8;
732 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
733 let mask = cube_mask(n, 2, 6);
734 let field: Vec<f64> = (0..n * n * n).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
735 let mag: Vec<f64> = (0..n * n * n).map(|i| 1.0 + (i % 5) as f64).collect();
736 for linear in [false, true] {
737 let p = TfiParams { linear, ..small() };
738 let chi = tfi_l1(&field, &mask, &mag, &vec![1.0; mag.len()], &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
739 assert!(chi.iter().any(|&v| v != 0.0));
740 for i in 0..chi.len() {
741 assert!(chi[i].is_finite());
742 if mask[i] == 0 {
743 assert_eq!(chi[i], 0.0);
744 }
745 }
746 }
747 }
748
749 /// The field scale matters (λ is fixed and, with the exponential term, the data term is
750 /// nonlinear), so `b0` and `te` change the result.
751 #[test]
752 fn units_follow_b0_and_te() {
753 let n = 8;
754 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
755 let mask = cube_mask(n, 2, 6);
756 let field: Vec<f64> = (0..n * n * n).map(|i| 0.05 * ((i % 7) as f64 - 3.0)).collect();
757 let mag: Vec<f64> = (0..n * n * n).map(|i| 1.0 + (i % 5) as f64).collect();
758 let a = tfi_l1(&field, &mask, &mag, &vec![1.0; mag.len()], &grid, (0.0, 0.0, 1.0), &small(), |_, _| {});
759 let p7 = TfiParams { b0: 7.0, ..small() };
760 let b = tfi_l1(&field, &mask, &mag, &vec![1.0; mag.len()], &grid, (0.0, 0.0, 1.0), &p7, |_, _| {});
761 assert_ne!(a, b);
762 }
763
764 /// Echo magnitudes give the RSS edge mask and the Fit_ppm_complex noise map.
765 #[test]
766 fn echo_magnitudes_give_rss_and_the_fit_noise() {
767 let n = 8;
768 let nt = n * n * n;
769 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
770 let mask = cube_mask(n, 2, 6);
771 let field: Vec<f64> = (0..nt).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
772 let mag: Vec<f64> = (0..nt).map(|i| 1.0 + (i % 5) as f64).collect();
773 let echoes: Vec<f64> = mag.iter().copied().chain(mag.iter().map(|v| 0.5 * v + (v % 2.0))).collect();
774 let p = small();
775 let a = tfi(&field, &mask, EchoMagnitudes { data: &echoes, n_echoes: 2 }, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
776 let rss: Vec<f64> = (0..nt).map(|i| (echoes[i].powi(2) + echoes[i + nt].powi(2)).sqrt()).collect();
777 let n_std = crate::utils::fit_ppm_complex_noise(&echoes, 2);
778 let b = tfi_l1(&field, &mask, &rss, &n_std, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
779 assert_eq!(a, b);
780 }
781}