Skip to main content

qsm_core/bgremove/
pdf.rs

1//! Projection onto Dipole Fields (PDF) background field removal, a port of `PDF.m` from the MEDI
2//! toolbox (release 2024.11.26)
3//!
4//! PDF fits the field inside the mask with the field of dipoles outside it, by weighted least
5//! squares,
6//!
7//! min_x || W (f − D M_bg x) ||²₂  over the mask,  W = 1 / N_std,
8//!
9//! and returns what the fit does not explain, `(f − D M_bg x)` in the mask, as the local field.
10//! `D` is the dipole kernel, `M_bg` restricts the sources to outside the mask, and `N_std` is the
11//! noise standard deviation of the field.
12//!
13//! The port follows `PDF.m` and its helpers line for line:
14//!
15//! - the volume is cropped to the bounding box of the mask and zero padded after it to
16//!   `floor((extent − 1 + n_pad) / 2) · 2` voxels per axis (`n_pad` = 40), so the background
17//!   sources live in the box plus the padding, and the result is put back into the full volume;
18//! - `W = 1/N_std`, set to 0 where `N_std` is 0, and to 0 outside the mask;
19//! - the normal equations `M_bg D W² D M_bg x = M_bg D W² f` (`dipole_term.m`) are solved by
20//!   conjugate gradients (`cgsolve.m`, starting from 0, at most `n_cg` = 30 iterations,
21//!   returning the last iterate; more iterations are a tuning option, see
22//!   [`PdfParams::n_cg`]), stopping when the relative residual falls below
23//!   `tol · ||D W outside the mask|| / ||M_bg D W² f||` with `tol` = 0.1;
24//! - the dipole kernel is `dipole_kernel.m`'s k-space kernel when B0 is along an axis
25//!   (`norm(B0_dir, 1) < 1.01`). For an oblique B0 it is the kernel named by `space`, by default
26//!   `dipole_kernel.m`'s `'imagespace'` kernel, the FFT of the sampled dipole field
27//!   `(3 (r·B0)² − |r|²) / (4π |r|⁵)` (0 at r = 0).
28//!
29//! # Units and the noise map
30//! Like the other background removals, `pdf` takes the total field in ppm and returns the local
31//! field in ppm. `PDF.m` works in whatever unit the field is in (MEDI's pipeline passes radians
32//! over the echo spacing), and the weighting is relative, but the absolute scale of `N_std`
33//! still matters: the CG tolerance is `tol · ||D W|| / ||D W² f||`, which scales with
34//! `N_std / f`. So `n_std` must be in the same unit as the field, here ppm. MEDI's pipeline
35//! (README.m) takes `N_std` from `Fit_ppm_complex.m`
36//! ([`fit_ppm_complex_noise`](crate::utils::fit_ppm_complex_noise), radians over the echo
37//! spacing); divide it by `2π · γ · B0 · ΔTE · 1e-6` for ppm, as the pipeline does.
38//!
39//! # Deviation from `PDF.m`
40//! With `n_pad = 0`, an axis with an odd number of voxels is not padded to an even size, and
41//! `dipole_kernel.m` then builds its grid as `-N/2:N/2-1`, half a voxel off the sample points.
42//! `pdf` uses the kernel centred on the voxel grid instead. With `n_pad > 0` (the default)
43//! every axis is even and the kernels are `dipole_kernel.m`'s.
44//!
45//! # Parity
46//! Against `PDF.m` as the verified QSM-CI `pdf-cornell` container runs it (MEDI 2024.11.26,
47//! identical to licensed MATLAB R2023b), at `PDF.m`'s defaults, local field NRMSE: KKI travelling
48//! head (oblique B0, `imagespace`) 2e-5 %; with the reference's own `ifreq` and `N_std`, QSM-CI's
49//! simulated head (B0 along z, so the k-space kernel) 0.007 % and KKI with `space = kspace`
50//! 0.026 %. At the 30-iteration cap the k-space path is rounding-sensitive (a 1-ulp change of the
51//! input moves `PDF.m` itself by up to 0.03 %), so the CG's inner products are summed with
52//! compensation (see `dot`); with a plain sum the port was 0.61 % / 0.74 % off
53//! (astewartau/QSM.rs#405). Fixture: `tests/pdf_cornell_parity.rs`.
54//!
55//! # Reference pipeline
56//! MEDI's pipeline script (`README.m` of the 2024.11.26 release, line 124) runs
57//! `PDF(iFreq, N_std, Mask, matrix_size, voxel_size, B0_dir)` with `PDF.m`'s defaults (`tol`
58//! 0.1, `n_CG` 30, `'imagespace'`, `n_pad` 40) on the field from `Fit_ppm_complex` unwrapped by
59//! `unwrapPhase`, inside the BET mask of the root-sum-of-squares magnitude (README.m:118, BET
60//! defaults), and passes the local field to `MEDI_L1`. The mSMV authors (same group) run
61//! `PDF(..., tol = 0, n_CG = 100)` after ROMEO unwrapping (github.com/agr78/mSMV, README.md
62//! and `data/healthy_subjects/proc.m:15`). Nothing in `PDF.m` depends on the field strength.
63//!
64//! Reference:
65//! Liu, T., Khalidov, I., de Rochefort, L., Spincemaille, P., Liu, J., Tsiouris, A.J.,
66//! Wang, Y. (2011). "A novel background field removal method for MRI using projection
67//! onto dipole fields." NMR in Biomedicine, 24(9):1129-1136. https://doi.org/10.1002/nbm.1670
68//!
69//! de Rochefort, L., Liu, T., Kressler, B., Liu, J., Spincemaille, P., Lebon, V., Wu, J.,
70//! Wang, Y. (2010). "Quantitative susceptibility map reconstruction from MR phase data using
71//! bayesian regularization: validation and application to brain imaging." Magnetic Resonance
72//! in Medicine, 63(1):194-206.
73//!
74//! Reference implementation: MEDI toolbox release 2024.11.26 (Cornell MRI Research Lab,
75//! `functions/PDF.m`, `dipole_term.m`, `cgsolve.m`, `dipole_kernel.m`)
76//!
77//! # Pipeline rules
78//!
79//! The facts above that a configuration can depart from are published as rules in
80//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `pdf.mask-bet`, `pdf.field-complex-fit`, and the measured suggestion `pdf.cg-iterations`
81//! (see [`PdfParams::n_cg`]).
82
83use num_complex::Complex64;
84use crate::error::{check_len, InputError};
85use crate::fft::Fft3dWorkspace;
86use crate::kernels::dipole::dipole_kernel;
87use crate::Grid;
88
89/// Which dipole kernel `pdf` uses when B0 is oblique (`PDF.m`'s `space` argument). With B0
90/// along an axis the k-space kernel is used whatever this says, as in `PDF.m`.
91#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
93pub enum DipoleKernelSpace {
94    /// The FFT of the dipole field sampled on the voxel grid (`'imagespace'`)
95    #[default]
96    ImageSpace,
97    /// The analytic k-space kernel `1/3 − (k·B0)²/|k|²` (`'kspace'`)
98    KSpace,
99}
100
101/// PDF parameters (`PDF.m` defaults)
102#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
103#[derive(Clone, Debug)]
104pub struct PdfParams {
105    /// Tolerance (`tol`): CG stops when its relative residual falls below
106    /// `tol · ||D W outside the mask|| / ||M_bg D W² f||`
107    pub tol: f64,
108    /// Maximum CG iterations (`n_CG`), by default `PDF.m`'s 30 (its default argument, which
109    /// MEDI's `README.m:124` pipeline uses). Decided 2026-10-30: the default follows the
110    /// reference, and the iteration count is tuning. Until then QSM.rs defaulted to 300.
111    ///
112    /// More iterations measured better, so 300 is suggested by the rule `pdf.cg-iterations`
113    /// (Recommended, measured; [`crate::pipeline::rules::RULES`]). At 30 the solve can stop far
114    /// from convergence:
115    /// - on a Philips 3 T travelling-head scan the local field kept a large residual background
116    ///   (correlation with V-SHARP 0.54 at 30 iterations, 0.83 at 300), while a GE scan was
117    ///   unaffected (0.85 at 30, 0.84 at 300); neither scan reached `tol` within 300;
118    /// - on QSM-CI's simulated head (QSM Challenge 2.0, 7 T), MEDI's own `PDF.m` at its
119    ///   defaults (the `pdf-cornell` container, identical to licensed MATLAB) scored about
120    ///   100 % NRMSE against the true local field, and QSM.rs's PDF at 300 iterations 44 %
121    ///   (QSMxT/QSM-CI#322).
122    ///
123    /// 300 costs about 30 s more per 1 mm whole-brain scan. The mSMV authors run 100 (with
124    /// `tol` 0; see the module docs, Reference pipeline).
125    pub n_cg: usize,
126    /// Kernel for an oblique B0 (`space`)
127    pub space: DipoleKernelSpace,
128    /// Zero padding around the mask's bounding box (`n_pad`), in voxels per axis; 0 runs on the
129    /// full volume. 1 is refused, as `PDF.m` cannot run with it on odd extents.
130    pub n_pad: usize,
131}
132
133impl Default for PdfParams {
134    fn default() -> Self {
135        Self { tol: 0.1, n_cg: 30, space: DipoleKernelSpace::ImageSpace, n_pad: 40 }
136    }
137}
138
139/// PDF background field removal (`PDF.m`)
140///
141/// # Arguments
142/// * `field` - Unwrapped total field in ppm (nx * ny * nz)
143/// * `n_std` - Noise standard deviation of the field, in ppm (see module docs); 0 = no weight
144/// * `mask` - Binary mask (nx * ny * nz), 1 = brain
145/// * `grid` - Volume dimensions and voxel sizes
146/// * `bdir` - B0 field direction (normalised here)
147/// * `params` - PDF parameters
148/// * `progress` - Progress callback `(cg_iteration, n_cg)`
149///
150/// # Returns
151/// Local field in ppm, zero outside the mask
152pub fn pdf(
153    field: &[f64],
154    n_std: &[f64],
155    mask: &[u8],
156    grid: &Grid,
157    bdir: (f64, f64, f64),
158    params: &PdfParams,
159    mut progress: impl FnMut(usize, usize),
160) -> Result<Vec<f64>, InputError> {
161    let (nx, ny, nz) = grid.dims;
162    let n = grid.n_total();
163    check_len("field", field.len(), n)?;
164    check_len("n_std", n_std.len(), n)?;
165    check_len("mask", mask.len(), n)?;
166    if params.n_pad == 1 {
167        return Err(InputError::new("PDF n_pad must be 0 or at least 2"));
168    }
169    let bnorm = (bdir.0 * bdir.0 + bdir.1 * bdir.1 + bdir.2 * bdir.2).sqrt();
170    if !(bnorm.is_finite() && bnorm > 0.0) {
171        return Err(InputError::new(format!("PDF B0 direction {bdir:?} has no length")));
172    }
173    let b = (bdir.0 / bnorm, bdir.1 / bnorm, bdir.2 / bnorm);
174
175    // d1first:d1last etc.: the bounding box of the mask
176    let (mut lo, mut hi) = ([usize::MAX; 3], [0usize; 3]);
177    for k in 0..nz {
178        for j in 0..ny {
179            for i in 0..nx {
180                if mask[i + nx * (j + ny * k)] != 0 {
181                    for (a, v) in [i, j, k].into_iter().enumerate() {
182                        lo[a] = lo[a].min(v);
183                        hi[a] = hi[a].max(v);
184                    }
185                }
186            }
187        }
188    }
189    if lo[0] == usize::MAX {
190        return Err(InputError::new("PDF mask is empty"));
191    }
192
193    // Crop to the box and pad after it to floor((last - first + n_pad)/2)*2, or keep the
194    // whole volume when n_pad = 0.
195    let (origin, ext, dims) = if params.n_pad > 0 {
196        let ext = [hi[0] - lo[0] + 1, hi[1] - lo[1] + 1, hi[2] - lo[2] + 1];
197        let m = ext.map(|e| (e - 1 + params.n_pad) / 2 * 2);
198        (lo, ext, m)
199    } else {
200        ([0; 3], [nx, ny, nz], [nx, ny, nz])
201    };
202    let [mx, my, mz] = dims;
203    let m_total = mx * my * mz;
204    let crop = |v: &[f64]| -> Vec<f64> {
205        let mut out = vec![0.0; m_total];
206        for k in 0..ext[2] {
207            for j in 0..ext[1] {
208                let src = origin[0] + nx * (origin[1] + j + ny * (origin[2] + k));
209                let dst = mx * (j + my * k);
210                out[dst..dst + ext[0]].copy_from_slice(&v[src..src + ext[0]]);
211            }
212        }
213        out
214    };
215    let f = crop(field);
216    let noise = crop(n_std);
217    let mask_f: Vec<f64> = mask.iter().map(|&m| f64::from(u8::from(m != 0))).collect();
218    let inside: Vec<bool> = crop(&mask_f).iter().map(|&m| m > 0.0).collect();
219
220    // W = 1./N_std; W(isinf(W)) = 0; W = W.*(Mask>0); W_var = W.^2
221    let w: Vec<f64> = (0..m_total)
222        .map(|i| {
223            let v = 1.0 / noise[i];
224            if !inside[i] || v.is_infinite() { 0.0 } else { v }
225        })
226        .collect();
227    let w_var: Vec<f64> = w.iter().map(|&v| v * v).collect();
228
229    let pgrid = Grid::new(mx, my, mz, grid.vsx(), grid.vsy(), grid.vsz());
230    let oblique = b.0.abs() + b.1.abs() + b.2.abs() >= 1.01;
231    let d: Vec<Complex64> = if oblique && params.space == DipoleKernelSpace::ImageSpace {
232        image_space_dipole_kernel(&pgrid, b)
233    } else {
234        dipole_kernel(&pgrid, b).into_iter().map(|v| Complex64::new(v, 0.0)).collect()
235    };
236
237    let mut fft = Fft3dWorkspace::new(mx, my, mz);
238    let mut buf = vec![Complex64::new(0.0, 0.0); m_total];
239    // real(ifftn(D.*fftn(x)))
240    let mut conv = |x: &[f64], out: &mut [f64]| {
241        for (c, &v) in buf.iter_mut().zip(x) {
242            *c = Complex64::new(v, 0.0);
243        }
244        fft.fft3d(&mut buf);
245        for (c, dk) in buf.iter_mut().zip(&d) {
246            *c *= dk;
247        }
248        fft.ifft3d(&mut buf);
249        for (o, c) in out.iter_mut().zip(&buf) {
250            *o = c.re;
251        }
252    };
253
254    // b = real(ifftn(D.*fftn(W_var.*iFreq))) outside the mask. Vectors over the background
255    // voxels are kept as full volumes that are zero inside the mask.
256    let mut tmp: Vec<f64> = (0..m_total).map(|i| w_var[i] * f[i]).collect();
257    let mut rhs = vec![0.0; m_total];
258    conv(&tmp, &mut rhs);
259    zero_inside(&mut rhs, &inside);
260
261    // E_noise_level = real(ifftn(D.*fftn(W_std))); cg_tol = tol*norm(E outside)/norm(b)
262    let mut e = vec![0.0; m_total];
263    conv(&w, &mut e);
264    zero_inside(&mut e, &inside);
265    let cg_tol = params.tol * dot(&e, &e).sqrt() / dot(&rhs, &rhs).sqrt();
266
267    // dipole_term: Ax = real(ifftn(D.*fftn(W_var.*real(ifftn(D.*fftn(x)))))) outside the mask
268    let mut tmp2 = vec![0.0; m_total];
269    let mut apply = |x: &[f64], out: &mut [f64]| {
270        conv(x, &mut tmp);
271        for i in 0..m_total {
272            tmp[i] *= w_var[i];
273        }
274        conv(&tmp, &mut tmp2);
275        out.copy_from_slice(&tmp2);
276        zero_inside(out, &inside);
277    };
278    let x = cgsolve(&mut apply, &rhs, cg_tol, params.n_cg, &mut progress);
279
280    // p_dipole = real(ifftn(D.*fftn(xx))); p_final = (iFreq - p_dipole).*Mask
281    let mut p_dipole = vec![0.0; m_total];
282    conv(&x, &mut p_dipole);
283    let mut local = vec![0.0; n];
284    for k in 0..ext[2] {
285        for j in 0..ext[1] {
286            for i in 0..ext[0] {
287                let src = i + mx * (j + my * k);
288                if inside[src] {
289                    let dst = origin[0] + i + nx * (origin[1] + j + ny * (origin[2] + k));
290                    local[dst] = f[src] - p_dipole[src];
291                }
292            }
293        }
294    }
295    Ok(local)
296}
297
298fn zero_inside(v: &mut [f64], inside: &[bool]) {
299    for (x, &m) in v.iter_mut().zip(inside) {
300        if m {
301            *x = 0.0;
302        }
303    }
304}
305
306/// Inner product with Neumaier-compensated summation, so the sum is accurate to about one
307/// rounding whatever the length (a plain left-to-right sum over the millions of background
308/// voxels carries ~1e-13 relative error).
309///
310/// `cgsolve.m` on `PDF.m`'s normal equations is very sensitive to rounding where the k-space
311/// kernel is used (B0 along an axis, or `space = kspace`): by the 30th iteration it amplifies a
312/// 1e-13 difference in `α` at the first iteration to ~10 % in `α`, concentrated in a few voxels
313/// (QSM-CI's simulated head: one voxel near (89, 95, 46)). MATLAB's MKL `ddot` and numpy's BLAS
314/// sum in blocks and stay within ~1e-16 of the exact sum; a plain sum did not, and was what made
315/// the port differ from `PDF.m` by 0.61 % (sim) and 0.74 % (KKI, `kspace`) at the 30-iteration
316/// cap (astewartau/QSM.rs#405). With this sum: 0.007 % and 0.026 %, inside `PDF.m`'s own
317/// rounding spread (a numpy `PDF.m` is 0.003 % / 0.03 % from MATLAB, and moves 0.03 % on KKI for
318/// a 1-ulp change of its input).
319fn dot(a: &[f64], b: &[f64]) -> f64 {
320    let (mut s, mut c) = (0.0f64, 0.0f64);
321    for (x, y) in a.iter().zip(b) {
322        let p = x * y;
323        let t = s + p;
324        c += if s.abs() >= p.abs() { (s - t) + p } else { (p - t) + s };
325        s = t;
326    }
327    s + c
328}
329
330/// `cgsolve.m` (Romberg; as modified for MEDI) from x0 = 0: conjugate gradients on `A x = b`,
331/// stopping after `maxiter` iterations or when `||r|| / ||b|| <= tol`, with the residual
332/// recomputed from `x` every 50 iterations. Returns the last iterate, as `cgsolve.m` does.
333fn cgsolve(
334    a: &mut impl FnMut(&[f64], &mut [f64]),
335    b: &[f64],
336    tol: f64,
337    maxiter: usize,
338    progress: &mut impl FnMut(usize, usize),
339) -> Vec<f64> {
340    let n = b.len();
341    let mut x = vec![0.0; n];
342    let mut r = b.to_vec();
343    let mut d = r.clone();
344    let mut q = vec![0.0; n];
345    let mut delta = dot(&r, &r);
346    let delta0 = dot(b, b);
347    let mut numiter = 0;
348    while numiter < maxiter && delta > tol * tol * delta0 {
349        a(&d, &mut q);
350        let alpha = delta / dot(&d, &q);
351        for i in 0..n {
352            x[i] += alpha * d[i];
353        }
354        if (numiter + 1) % 50 == 0 {
355            a(&x, &mut q);
356            for i in 0..n {
357                r[i] = b[i] - q[i];
358            }
359        } else {
360            for i in 0..n {
361                r[i] -= alpha * q[i];
362            }
363        }
364        let deltaold = delta;
365        delta = dot(&r, &r);
366        let beta = delta / deltaold;
367        for i in 0..n {
368            d[i] = r[i] + beta * d[i];
369        }
370        numiter += 1;
371        progress(numiter, maxiter);
372    }
373    x
374}
375
376/// `dipole_kernel.m`'s `'imagespace'` kernel: the dipole field
377/// `(3 (r·B0)² − |r|²) / (4π |r|⁵)` sampled at the voxel positions r (mm), 0 at r = 0, with the
378/// origin at index 0 (`fftshift` of the `-N/2:N/2-1` grid), Fourier transformed.
379fn image_space_dipole_kernel(grid: &Grid, b: (f64, f64, f64)) -> Vec<Complex64> {
380    let (nx, ny, nz) = grid.dims;
381    // Position of index j on the wrapped grid: j for j < ceil(n/2), else j - n. For even n this
382    // is fftshift(-n/2:n/2-1); for odd n it is centred on the voxels (see module docs).
383    let coord = |j: usize, n: usize, vs: f64| -> f64 {
384        let c = if j < n.div_ceil(2) { j as f64 } else { j as f64 - n as f64 };
385        c * vs
386    };
387    let mut d = vec![Complex64::new(0.0, 0.0); nx * ny * nz];
388    for k in 0..nz {
389        let z = coord(k, nz, grid.vsz());
390        for j in 0..ny {
391            let y = coord(j, ny, grid.vsy());
392            for i in 0..nx {
393                let x = coord(i, nx, grid.vsx());
394                let r2 = x * x + y * y + z * z;
395                if r2 > 0.0 {
396                    let rb = x * b.0 + y * b.1 + z * b.2;
397                    let v = (3.0 * rb * rb - r2) / (4.0 * std::f64::consts::PI * r2.powf(2.5));
398                    d[i + nx * (j + ny * k)] = Complex64::new(v, 0.0);
399                }
400            }
401        }
402    }
403    Fft3dWorkspace::new(nx, ny, nz).fft3d(&mut d);
404    d
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn sphere(n: usize, c: (f64, f64, f64), r: f64) -> Vec<u8> {
412        let mut m = vec![0u8; n * n * n];
413        for k in 0..n {
414            for j in 0..n {
415                for i in 0..n {
416                    let (dx, dy, dz) = (i as f64 - c.0, j as f64 - c.1, k as f64 - c.2);
417                    m[i + n * (j + n * k)] = u8::from(dx * dx + dy * dy + dz * dz <= r * r);
418                }
419            }
420        }
421        m
422    }
423
424    /// Field of a susceptibility distribution on an `n`³ grid (k-space kernel, B0 along z).
425    fn forward(chi: &[f64], n: usize) -> Vec<f64> {
426        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
427        let d = dipole_kernel(&grid, (0.0, 0.0, 1.0));
428        let mut c: Vec<Complex64> = chi.iter().map(|&v| Complex64::new(v, 0.0)).collect();
429        let mut fft = Fft3dWorkspace::new(n, n, n);
430        fft.fft3d(&mut c);
431        for (v, dk) in c.iter_mut().zip(&d) {
432            *v *= dk;
433        }
434        fft.ifft3d(&mut c);
435        c.iter().map(|v| v.re).collect()
436    }
437
438    fn params() -> PdfParams {
439        PdfParams { n_pad: 8, ..PdfParams::default() }
440    }
441
442    fn maxdiff(a: &[f64], b: &[f64]) -> f64 {
443        a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max)
444    }
445
446    #[test]
447    fn zero_field_gives_zero() {
448        let n = 12;
449        let mask = sphere(n, (6.0, 6.0, 6.0), 3.0);
450        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
451        let out = pdf(&vec![0.0; n * n * n], &vec![1.0; n * n * n], &mask, &grid, (0.0, 0.0, 1.0), &params(), |_, _| {}).unwrap();
452        assert!(out.iter().all(|&v| v == 0.0));
453    }
454
455    #[test]
456    fn removes_a_background_source_and_zeroes_outside() {
457        // A source outside the mask: its field inside the mask is background, and PDF should
458        // remove most of it.
459        let n = 24;
460        let mask = sphere(n, (12.0, 12.0, 12.0), 5.0);
461        let mut chi = vec![0.0; n * n * n];
462        for k in 2..5 {
463            for j in 10..14 {
464                for i in 10..14 {
465                    chi[i + n * (j + n * k)] = 1.0;
466                }
467            }
468        }
469        let field = forward(&chi, n);
470        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
471        let p = PdfParams { tol: 1e-6, n_cg: 300, ..params() };
472        let out = pdf(&field, &vec![1.0; n * n * n], &mask, &grid, (0.0, 0.0, 1.0), &p, |_, _| {}).unwrap();
473        let (mut before, mut after) = (0.0, 0.0);
474        for i in 0..n * n * n {
475            if mask[i] != 0 {
476                before += field[i] * field[i];
477                after += out[i] * out[i];
478            } else {
479                assert_eq!(out[i], 0.0);
480            }
481        }
482        assert!(after < 0.05 * before, "background energy {before} -> {after}");
483    }
484
485    #[test]
486    fn unweighted_voxels_do_not_affect_the_fit() {
487        // N_std = 0 gives W = 0 (1/0 = inf -> 0), so the field there does not enter the fit:
488        // changing it changes the output at that voxel only.
489        let n = 16;
490        let mask = sphere(n, (8.0, 8.0, 8.0), 4.0);
491        let field: Vec<f64> = (0..n * n * n).map(|i| ((i * 7919) % 101) as f64 * 1e-3).collect();
492        let mut noise = vec![1.0; n * n * n];
493        let c = 8 + n * (8 + n * 8);
494        noise[c] = 0.0;
495        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
496        let run = |f: &[f64]| pdf(f, &noise, &mask, &grid, (0.0, 0.0, 1.0), &params(), |_, _| {}).unwrap();
497        let a = run(&field);
498        let mut field2 = field.clone();
499        field2[c] += 5.0;
500        let b = run(&field2);
501        for i in 0..n * n * n {
502            let expect = if i == c { 5.0 } else { 0.0 };
503            assert!((b[i] - a[i] - expect).abs() < 1e-9, "voxel {i}");
504        }
505    }
506
507    #[test]
508    fn noise_scale_sets_the_stopping_point_only() {
509        // The weights are relative, but the CG tolerance scales with N_std / field. With the
510        // tolerance out of play (all n_cg iterations run) the result does not depend on the
511        // scale of N_std.
512        let n = 16;
513        let mask = sphere(n, (8.0, 8.0, 8.0), 4.0);
514        let field: Vec<f64> = (0..n * n * n).map(|i| ((i * 31) % 17) as f64 * 1e-2).collect();
515        let noise: Vec<f64> = (0..n * n * n).map(|i| 1.0 + (i % 5) as f64 * 0.1).collect();
516        let noise3: Vec<f64> = noise.iter().map(|v| v * 3.0).collect();
517        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
518        let p = PdfParams { tol: 0.0, n_cg: 10, ..params() };
519        let a = pdf(&field, &noise, &mask, &grid, (0.0, 0.0, 1.0), &p, |_, _| {}).unwrap();
520        let b = pdf(&field, &noise3, &mask, &grid, (0.0, 0.0, 1.0), &p, |_, _| {}).unwrap();
521        assert!(maxdiff(&a, &b) < 1e-9);
522        // With the default tolerance, a larger N_std stops CG earlier.
523        let (mut ia, mut ib) = (0, 0);
524        pdf(&field, &noise, &mask, &grid, (0.0, 0.0, 1.0), &params(), |i, _| ia = i).unwrap();
525        pdf(&field, &noise3, &mask, &grid, (0.0, 0.0, 1.0), &params(), |i, _| ib = i).unwrap();
526        assert!(ib <= ia, "{ib} > {ia}");
527    }
528
529    #[test]
530    fn result_follows_the_mask_not_the_volume() {
531        // PDF crops to the mask's bounding box, so moving the volume around the mask gives the
532        // same local field, moved.
533        let n = 16;
534        let mask = sphere(n, (7.0, 7.0, 7.0), 3.0);
535        let field: Vec<f64> = (0..n * n * n).map(|i| ((i * 13) % 23) as f64 * 1e-2).collect();
536        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
537        let a = pdf(&field, &vec![1.0; n * n * n], &mask, &grid, (0.0, 0.0, 1.0), &params(), |_, _| {}).unwrap();
538        let sh = |v: &[f64]| -> Vec<f64> {
539            let mut o = vec![0.0; n * n * n];
540            for k in 0..n - 2 {
541                for j in 0..n - 1 {
542                    for i in 0..n - 3 {
543                        o[(i + 3) + n * ((j + 1) + n * (k + 2))] = v[i + n * (j + n * k)];
544                    }
545                }
546            }
547            o
548        };
549        let mask_s: Vec<u8> = sh(&mask.iter().map(|&m| m as f64).collect::<Vec<_>>()).iter().map(|&v| v as u8).collect();
550        let b = pdf(&sh(&field), &vec![1.0; n * n * n], &mask_s, &grid, (0.0, 0.0, 1.0), &params(), |_, _| {}).unwrap();
551        assert!(maxdiff(&sh(&a), &b) < 1e-12);
552    }
553
554    #[test]
555    fn oblique_b0_uses_the_chosen_kernel_axial_does_not() {
556        let n = 16;
557        let mask = sphere(n, (8.0, 8.0, 8.0), 4.0);
558        let field: Vec<f64> = (0..n * n * n).map(|i| ((i * 31) % 17) as f64 * 1e-2).collect();
559        // low noise, so CG runs (with N_std ~ the field, the tolerance stops it at once)
560        let noise = vec![1e-4; n * n * n];
561        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
562        let run = |b, space| {
563            let p = PdfParams { space, ..params() };
564            pdf(&field, &noise, &mask, &grid, b, &p, |_, _| {}).unwrap()
565        };
566        let axial = (0.0, 0.0, 1.0);
567        assert_eq!(maxdiff(&run(axial, DipoleKernelSpace::ImageSpace), &run(axial, DipoleKernelSpace::KSpace)), 0.0);
568        let tilted = (0.1, 0.0, 1.0);
569        assert!(maxdiff(&run(tilted, DipoleKernelSpace::ImageSpace), &run(tilted, DipoleKernelSpace::KSpace)) > 1e-6);
570    }
571
572    #[test]
573    fn image_space_kernel_is_the_fft_of_the_sampled_dipole() {
574        // fftn(fftshift(d)) on a 4x4x4 grid of 1 mm voxels, B0 along z: D at k = 0 is the sum
575        // of the sampled field over the grid -2:1 per axis.
576        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
577        let d = image_space_dipole_kernel(&grid, (0.0, 0.0, 1.0));
578        let mut sum = 0.0;
579        for z in -2..2 {
580            for y in -2..2 {
581                for x in -2i32..2 {
582                    let r2 = f64::from(x * x + y * y + z * z);
583                    if r2 > 0.0 {
584                        sum += (3.0 * f64::from(z * z) - r2) / (4.0 * std::f64::consts::PI * r2.powf(2.5));
585                    }
586                }
587            }
588        }
589        assert!((d[0].re - sum).abs() < 1e-12);
590    }
591
592    #[test]
593    fn cgsolve_solves_a_small_spd_system() {
594        let m = [[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]];
595        let mut a = |x: &[f64], out: &mut [f64]| {
596            for r in 0..3 {
597                out[r] = (0..3).map(|c| m[r][c] * x[c]).sum();
598            }
599        };
600        let b = [1.0, 2.0, 3.0];
601        let x = cgsolve(&mut a, &b, 1e-12, 10, &mut |_, _| {});
602        let mut ax = [0.0; 3];
603        a(&x, &mut ax);
604        for r in 0..3 {
605            assert!((ax[r] - b[r]).abs() < 1e-10);
606        }
607    }
608
609    #[test]
610    fn refuses_bad_input() {
611        let n = 8;
612        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
613        let f = vec![0.0; n * n * n];
614        let mask = sphere(n, (4.0, 4.0, 4.0), 2.0);
615        let one = vec![1.0; n * n * n];
616        let z = (0.0, 0.0, 1.0);
617        assert!(pdf(&f, &one[1..], &mask, &grid, z, &params(), |_, _| {}).is_err());
618        assert!(pdf(&f, &one, &vec![0u8; n * n * n], &grid, z, &params(), |_, _| {}).is_err());
619        assert!(pdf(&f, &one, &mask, &grid, z, &PdfParams { n_pad: 1, ..params() }, |_, _| {}).is_err());
620        assert!(pdf(&f, &one, &mask, &grid, (0.0, 0.0, 0.0), &params(), |_, _| {}).is_err());
621    }
622}