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), ¶ms(), |_, _| {}).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), ¶ms(), |_, _| {}).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), ¶ms(), |i, _| ia = i).unwrap();
525 pdf(&field, &noise3, &mask, &grid, (0.0, 0.0, 1.0), ¶ms(), |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), ¶ms(), |_, _| {}).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), ¶ms(), |_, _| {}).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, ¶ms(), |_, _| {}).is_err());
618 assert!(pdf(&f, &one, &vec![0u8; n * n * n], &grid, z, ¶ms(), |_, _| {}).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), ¶ms(), |_, _| {}).is_err());
621 }
622}