Skip to main content

qsm_core/unwrap/
laplacian.rs

1//! Laplacian-based phase unwrapping
2//!
3//! The Laplacian of the wrapped phase equals the Laplacian of the true phase wherever
4//! neighbouring samples differ by less than π, so the true phase can be recovered by
5//! solving a Poisson equation. Path-independent and fast, unlike region-growing methods.
6//!
7//! [`laplacian_unwrap`] unwraps only; the harmonic (background) component survives and the
8//! result is a total field. It has two solvers, named as in QSM.jl's `unwrap_laplacian`:
9//!
10//! | [`LaplacianSolver`] | ∇²φ estimate | Poisson solve | Reference |
11//! |---|---|---|---|
12//! | [`Dct`](LaplacianSolver::Dct) (default) | finite differences of wrapped neighbour differences | DCT, Neumann boundary on the array | Ghiglia & Romero (1994); QSM.jl `:dct` |
13//! | [`Fft { pad }`](LaplacianSolver::Fft) | `cos φ·∇²(sin φ) − sin φ·∇²(cos φ)`, spectral ∇² (`|k|²`) | FFT, periodic, on the volume zero-padded by `pad` | Schofield & Zhu (2003); STI Suite 3.0 `MRPhaseUnwrap` |
14//!
15//! `Dct` is unweighted least-squares unwrapping: the Poisson equation for the wrapped phase
16//! differences, solved under a Neumann condition with a DCT (Ghiglia & Romero 1994). It
17//! reproduces QSM.jl's `:dct` solver exactly (QSM.jl v0.5.4 on byte-identical input: r =
18//! 1.000000, rms difference 0.0). `tests/laplacian_qsmjl_parity.rs` keeps this checked against
19//! QSM.jl fde4b92 in the verified QSM-CI `julia-qsm` image (QSMxT/QSM-CI#299) on two wrapped
20//! phantoms, 1 mm and anisotropic: max |Δ| 8.9e-15 rad (measured 2026-10-30).
21//!
22//! Through UK Biobank's pipeline in the verified QSM-CI `ukb-qsm` container (QSMxT/QSM-CI#305),
23//! `Fft` with `pad` 64 per echo, combined with
24//! [`B0WeightType::AssumedDecay`](crate::utils::B0WeightType::AssumedDecay), gives the
25//! pipeline's combined phase to 2.5e-14 rad (`tests/ukb_qsm_parity.rs`, two echoes at
26//! 1×1×1.5 mm, measured 2026-10-30).
27//!
28//! `Fft` is the literal Schofield & Zhu method: the Laplacian of the true phase from the sin/cos
29//! identity with spectral derivatives, then an FFT Poisson solve. It reproduces STI Suite 3.0's
30//! `MRPhaseUnwrap(phase, 'voxelsize', vs, 'padsize', pad)`, which is this algorithm, to
31//! ≈1e-14 rad inside the mask (pad 64; in-vivo 3 T data and synthetic volumes at several voxel
32//! sizes). Steps: zero-pad by `pad` per side (an odd padded dimension gets one more zero plane at
33//! its end); `K = |k|²` on the padded FFT grid, `k_d = m / (M_d·h_d)`, no 4π² factor;
34//! `L = cos φ·F⁻¹[K·F(sin φ)] − sin φ·F⁻¹[K·F(cos φ)]`; `φ_u = F⁻¹[F(L)/K]` with DC → 0; crop.
35//!
36//! The two give nearly the same local field after background removal (simulated head: NRMSE
37//! 79.5 % for `Fft` against 81.3 % for `Dct`; on a 150-subject in-vivo test-retest cohort the
38//! same iron-region ICC, 0.924 against 0.925). They differ in the total field: zero padding puts
39//! a step wherever the phase at the array edge is not zero, which `Fft` turns into a harmonic
40//! error that `Dct` does not have. Hence `Dct` is the default.
41//!
42//! Multi-echo field maps are built by [`crate::pipeline::run_field_mapping`], whose
43//! `laplacian_solver` setting selects the solver: each echo is masked (unless
44//! [`laplacian_unmasked`](crate::pipeline::FieldMappingConfig::laplacian_unmasked)), unwrapped
45//! and combined with the configured [`B0WeightType`](crate::utils::B0WeightType). UK Biobank's field map is
46//! `Fft { pad: [64; 3] }` with [`B0WeightType::AssumedDecay`](crate::utils::B0WeightType::AssumedDecay)
47//! at 40 ms.
48//!
49//! [`UnwrapMethod::Laplacian`](super::UnwrapMethod::Laplacian) selects [`laplacian_unwrap`] with
50//! [`LaplacianSolver::Dct`], since the pipeline removes background as a later stage.
51//!
52//! # Reference pipeline (STI Suite)
53//!
54//! Probed in MATLAB R2023b by driving STI Suite 3.0's `QSM_GUI` headless with logging wrappers
55//! (3-echo synthetic head, TE 4/10/16 ms, 1×1×1.5 mm):
56//!
57//! - `MRPhaseUnwrap`'s default `padsize` is 12 voxels (bitwise equal to an explicit
58//!   `[12 12 12]`; [`LaplacianSolver::FFT_DEFAULT_PAD`]), as `STI_Templates.m` says.
59//! - The GUI ("3D GRE data" and "Single button for QSM") calls it once on all echoes,
60//!   `MRPhaseUnwrap(φ, 'voxelsize', vs)` with the **raw, unmasked** wrapped phase (the input
61//!   is bitwise the loaded phase, nonzero outside the mask), so the GUI does not do the
62//!   `mask · φ` that [`run_field_mapping`](crate::pipeline::run_field_mapping) does by default and
63//!   UK Biobank does ([`laplacian_unmasked`](crate::pipeline::FieldMappingConfig::laplacian_unmasked)
64//!   is the GUI's choice; rule `sti.unwrap-unmasked`). It then **sums** the unwrapped echoes (no per-echo offset removal, no weighting) and
65//!   gives `Σφ` to V-SHARP, with `TE` = `ΣTE` for the inversion: in `φ/TE` form, the
66//!   [`B0WeightType::TEs`](crate::utils::B0WeightType::TEs) weighting (`Σφₑ/ΣTEₑ`).
67//! - STI Suite 2.01's GUI also offered "Combine Laplacian" (the Laplacians of all echoes,
68//!   combined) followed by iHARPERELLA (see [`crate::bgremove::iharperella`](mod@crate::bgremove::iharperella)).
69//! - QSMART does not use `MRPhaseUnwrap` but Hongfu Sun's `lapunwrap.m` (mirror extension):
70//!   [`LaplacianSolver::Mirror`], per echo on the unmasked phase (`unwrap_phase.m` l. 5–10;
71//!   [`laplacian_unmasked`](crate::pipeline::FieldMappingConfig::laplacian_unmasked)).
72//!
73//! # Reference pipeline (other than STI Suite)
74//!
75//! - **QSM.jl** (kamesy/QSM.jl, local copy fde4b92): `unwrap_laplacian`'s default solver is
76//!   `:mgpcg`, a Dirichlet condition on the ROI (`uphas[!mask] = 0`) that "combines phase
77//!   unwrapping and harmonic background field removing"; `:dct` and `:fft` are the
78//!   array-boundary options (`src/unwrap/laplacian.jl:6,14-19,28-32`). `:mgpcg` is not ported;
79//!   the default here is `:dct`, which unwraps only. The README's pipeline masks with FSL BET on
80//!   the last echo (`-f 0.5`), erodes the mask by 5 voxels, unwraps every echo with
81//!   `unwrap_laplacian(phas, mask1, vsz)` (so `:mgpcg`), scales echo `e` by `1/(B0·γ·TEₑ)` and
82//!   runs V-SHARP (`README.md:34-49`).
83//! - **MriResearchTools.jl / CLEAR-SWI** (korbinian90, 771eadf / 0949bc5): `laplacianunwrap` is
84//!   the Schofield & Zhu sin/cos method with DCT transforms (Neumann, unpadded, no mask, no voxel
85//!   size; `src/laplacianunwrapping.jl:1-20`), so it is neither of the solvers above exactly.
86//!   CLEAR-SWI's default unwrapping is `laplacian` (`ext/ClearswiApp/argparse.jl:55-57`),
87//!   applied per echo to the unmasked phase, and it also offers `laplacianslice`, the same per
88//!   2D slice (`src/phase_processing.jl:88-116`); it does no MCPC-3D-S before combining the
89//!   echoes with `calculateB0_unwrapped` (`:phase_snr`).
90//! - Nothing in the method assumes a field strength or B0 direction; `Dct` and `Fft` take the
91//!   voxel size into the Laplacian, so isotropic voxels are not required.
92//!
93//! # References
94//!
95//! Ghiglia, D.C., Romero, L.A. (1994). "Robust two-dimensional weighted and unweighted phase
96//! unwrapping that uses fast transforms and iterative methods." Journal of the Optical Society
97//! of America A, 11(1):107-117. <https://doi.org/10.1364/JOSAA.11.000107>
98//!
99//! Schofield, M.A., Zhu, Y. (2003). "Fast phase unwrapping algorithm for interferometric
100//! applications." Optics Letters, 28(14):1194-1196. <https://doi.org/10.1364/OL.28.001194>
101//!
102//! Li, W., Wu, B., Liu, C. (2011). "Quantitative susceptibility mapping of human brain reflects
103//! spatial variation in tissue composition." NeuroImage, 55(4):1645-1656 (STI Suite's use of
104//! the Schofield & Zhu method). <https://doi.org/10.1016/j.neuroimage.2010.11.088>
105//!
106//! Reference implementation: <https://github.com/kamesy/QSM.jl> (`unwrap_laplacian`).
107
108use std::f64::consts::PI;
109use num_complex::Complex64;
110#[cfg(test)]
111use crate::fft::{fft3d, ifft3d};
112use crate::fft::{fftfreq, Fft3dWorkspace};
113use crate::Grid;
114
115/// Wrap angle to [-π, π]
116#[inline]
117pub(crate) fn wrap(x: f64) -> f64 {
118    let mut y = x % (2.0 * PI);
119    if y > PI {
120        y -= 2.0 * PI;
121    } else if y < -PI {
122        y += 2.0 * PI;
123    }
124    y
125}
126
127/// Compute wrapped Laplacian of phase with periodic boundary conditions
128///
129/// Uses second-order central finite differences on wrapped phase differences.
130#[cfg(test)]
131pub(crate) fn wrapped_laplacian_periodic(
132    phase: &[f64],
133    nx: usize, ny: usize, nz: usize,
134    vsx: f64, vsy: f64, vsz: f64,
135) -> Vec<f64> {
136    periodic_laplacian_of_differences(phase, nx, ny, nz, vsx, vsy, vsz, wrap)
137}
138
139/// Second-order central Laplacian with periodic boundaries, where each neighbour difference
140/// is passed through `wrap` first (the 2π wrap for phase, the identity for a field).
141#[cfg(test)]
142#[allow(clippy::too_many_arguments)]
143#[inline(always)]
144fn periodic_laplacian_of_differences(
145    phase: &[f64],
146    nx: usize, ny: usize, nz: usize,
147    vsx: f64, vsy: f64, vsz: f64,
148    wrap: impl Fn(f64) -> f64,
149) -> Vec<f64> {
150    let n_total = nx * ny * nz;
151    let mut d2u = vec![0.0; n_total];
152
153    let dx2 = 1.0 / (vsx * vsx);
154    let dy2 = 1.0 / (vsy * vsy);
155    let dz2 = 1.0 / (vsz * vsz);
156
157    for k in 0..nz {
158        let km1 = if k == 0 { nz - 1 } else { k - 1 };
159        let kp1 = if k + 1 >= nz { 0 } else { k + 1 };
160
161        for j in 0..ny {
162            let jm1 = if j == 0 { ny - 1 } else { j - 1 };
163            let jp1 = if j + 1 >= ny { 0 } else { j + 1 };
164
165            for i in 0..nx {
166                let im1 = if i == 0 { nx - 1 } else { i - 1 };
167                let ip1 = if i + 1 >= nx { 0 } else { i + 1 };
168
169                let idx = i + j * nx + k * nx * ny;
170                let u_ijk = phase[idx];
171
172                let idx_im1 = im1 + j * nx + k * nx * ny;
173                let idx_ip1 = ip1 + j * nx + k * nx * ny;
174                let idx_jm1 = i + jm1 * nx + k * nx * ny;
175                let idx_jp1 = i + jp1 * nx + k * nx * ny;
176                let idx_km1 = i + j * nx + km1 * nx * ny;
177                let idx_kp1 = i + j * nx + kp1 * nx * ny;
178
179                let lap_x = (wrap(phase[idx_ip1] - u_ijk) - wrap(u_ijk - phase[idx_im1])) * dx2;
180                let lap_y = (wrap(phase[idx_jp1] - u_ijk) - wrap(u_ijk - phase[idx_jm1])) * dy2;
181                let lap_z = (wrap(phase[idx_kp1] - u_ijk) - wrap(u_ijk - phase[idx_km1])) * dz2;
182
183                d2u[idx] = lap_x + lap_y + lap_z;
184            }
185        }
186    }
187
188    d2u
189}
190
191/// Solve Poisson equation using FFT (periodic boundary conditions).
192///
193/// Only the test-only even-extension oracle uses this now; the library solves under
194/// Neumann via [`solve_poisson_dct`].
195#[cfg(test)]
196pub(crate) fn solve_poisson_fft(
197    f: &[f64],
198    nx: usize, ny: usize, nz: usize,
199    vsx: f64, vsy: f64, vsz: f64,
200) -> Vec<f64> {
201    let mut f_complex: Vec<Complex64> = f.iter()
202        .map(|&x| Complex64::new(x, 0.0))
203        .collect();
204    fft3d(&mut f_complex, nx, ny, nz);
205
206    let idx2 = 1.0 / (vsx * vsx);
207    let idy2 = 1.0 / (vsy * vsy);
208    let idz2 = 1.0 / (vsz * vsz);
209
210    for k in 0..nz {
211        let fk = if k <= nz / 2 { k as f64 / nz as f64 } else { (k as f64 - nz as f64) / nz as f64 };
212        let lam_z = 2.0 * ((2.0 * PI * fk).cos() - 1.0) * idz2;
213
214        for j in 0..ny {
215            let fj = if j <= ny / 2 { j as f64 / ny as f64 } else { (j as f64 - ny as f64) / ny as f64 };
216            let lam_y = 2.0 * ((2.0 * PI * fj).cos() - 1.0) * idy2;
217
218            for i in 0..nx {
219                let fi = if i <= nx / 2 { i as f64 / nx as f64 } else { (i as f64 - nx as f64) / nx as f64 };
220                let lam_x = 2.0 * ((2.0 * PI * fi).cos() - 1.0) * idx2;
221
222                let lam = lam_x + lam_y + lam_z;
223                let idx = i + j * nx + k * nx * ny;
224
225                if lam.abs() > 1e-20 {
226                    f_complex[idx] /= lam;
227                } else {
228                    f_complex[idx] = Complex64::new(0.0, 0.0);
229                }
230            }
231        }
232    }
233
234    ifft3d(&mut f_complex, nx, ny, nz);
235    f_complex.iter().map(|c| c.re).collect()
236}
237
238/// Wrapped Laplacian under a Neumann boundary: at each array face the missing neighbour is
239/// the sample itself, so the wrapped difference across the face is zero. This is exactly
240/// the half-sample even extension the DCT-II assumes, so pairing it with
241/// [`solve_poisson_dct`] reproduces the even-extended periodic solve without building the
242/// 2x-per-axis extension.
243pub(crate) fn wrapped_laplacian_neumann(
244    phase: &[f64],
245    nx: usize, ny: usize, nz: usize,
246    vsx: f64, vsy: f64, vsz: f64,
247) -> Vec<f64> {
248    let n_total = nx * ny * nz;
249    let mut d2u = vec![0.0; n_total];
250    let (dx2, dy2, dz2) = (1.0 / (vsx * vsx), 1.0 / (vsy * vsy), 1.0 / (vsz * vsz));
251
252    for k in 0..nz {
253        let km1 = k.saturating_sub(1);
254        let kp1 = if k + 1 >= nz { k } else { k + 1 };
255        for j in 0..ny {
256            let jm1 = j.saturating_sub(1);
257            let jp1 = if j + 1 >= ny { j } else { j + 1 };
258            for i in 0..nx {
259                let im1 = i.saturating_sub(1);
260                let ip1 = if i + 1 >= nx { i } else { i + 1 };
261                let idx = i + j * nx + k * nx * ny;
262                let u = phase[idx];
263                let lap_x = (wrap(phase[ip1 + j * nx + k * nx * ny] - u) - wrap(u - phase[im1 + j * nx + k * nx * ny])) * dx2;
264                let lap_y = (wrap(phase[i + jp1 * nx + k * nx * ny] - u) - wrap(u - phase[i + jm1 * nx + k * nx * ny])) * dy2;
265                let lap_z = (wrap(phase[i + j * nx + kp1 * nx * ny] - u) - wrap(u - phase[i + j * nx + km1 * nx * ny])) * dz2;
266                d2u[idx] = lap_x + lap_y + lap_z;
267            }
268        }
269    }
270    d2u
271}
272
273/// Solve ∇²u = f under a Neumann boundary condition on the array, in place, via DCT-II.
274///
275/// The DCT-II of a length-N signal is the FFT of its even extension restricted to the
276/// original samples, so this is the padded periodic solve with the extension never
277/// materialised: working memory is the volume itself plus one axis-length buffer,
278/// against 8x the volume in complex doubles for the explicit extension.
279///
280/// Eigenvalues of the second difference under this basis are `2(cos(πk/N) − 1)/h²`. The DC
281/// mode is set to zero, as in the periodic solve — the result is defined up to a constant.
282pub(crate) fn solve_poisson_dct(
283    f: &[f64],
284    nx: usize, ny: usize, nz: usize,
285    vsx: f64, vsy: f64, vsz: f64,
286) -> Vec<f64> {
287    use rustdct::DctPlanner;
288    let nxy = nx * ny;
289    let mut data = f.to_vec();
290    let mut planner = DctPlanner::<f64>::new();
291
292    // Forward: DCT-II along each axis.
293    let dct2_x = planner.plan_dct2(nx);
294    let dct2_y = planner.plan_dct2(ny);
295    let dct2_z = planner.plan_dct2(nz);
296    {
297        let mut scratch = vec![0.0; dct2_x.get_scratch_len()];
298        for row in data.chunks_mut(nx) {
299            dct2_x.process_dct2_with_scratch(row, &mut scratch);
300        }
301    }
302    {
303        let mut buf = vec![0.0; ny];
304        let mut scratch = vec![0.0; dct2_y.get_scratch_len()];
305        for k in 0..nz {
306            for i in 0..nx {
307                for j in 0..ny { buf[j] = data[i + j * nx + k * nxy]; }
308                dct2_y.process_dct2_with_scratch(&mut buf, &mut scratch);
309                for j in 0..ny { data[i + j * nx + k * nxy] = buf[j]; }
310            }
311        }
312    }
313    {
314        let mut buf = vec![0.0; nz];
315        let mut scratch = vec![0.0; dct2_z.get_scratch_len()];
316        for j in 0..ny {
317            for i in 0..nx {
318                for k in 0..nz { buf[k] = data[i + j * nx + k * nxy]; }
319                dct2_z.process_dct2_with_scratch(&mut buf, &mut scratch);
320                for k in 0..nz { data[i + j * nx + k * nxy] = buf[k]; }
321            }
322        }
323    }
324
325    // Divide by the Laplacian eigenvalue of each DCT-II mode.
326    let (ix2, iy2, iz2) = (1.0 / (vsx * vsx), 1.0 / (vsy * vsy), 1.0 / (vsz * vsz));
327    let lam_x: Vec<f64> = (0..nx).map(|i| 2.0 * ((PI * i as f64 / nx as f64).cos() - 1.0) * ix2).collect();
328    let lam_y: Vec<f64> = (0..ny).map(|j| 2.0 * ((PI * j as f64 / ny as f64).cos() - 1.0) * iy2).collect();
329    let lam_z: Vec<f64> = (0..nz).map(|k| 2.0 * ((PI * k as f64 / nz as f64).cos() - 1.0) * iz2).collect();
330    for (k, &lz) in lam_z.iter().enumerate() {
331        for (j, &ly) in lam_y.iter().enumerate() {
332            let row = j * nx + k * nxy;
333            for (i, &lx) in lam_x.iter().enumerate() {
334                let lam = lx + ly + lz;
335                let idx = row + i;
336                data[idx] = if lam.abs() > 1e-20 { data[idx] / lam } else { 0.0 };
337            }
338        }
339    }
340
341    // Inverse: DCT-III along each axis. Unnormalised DCT-III∘DCT-II scales by N/2 per axis.
342    let dct3_x = planner.plan_dct3(nx);
343    let dct3_y = planner.plan_dct3(ny);
344    let dct3_z = planner.plan_dct3(nz);
345    {
346        let mut buf = vec![0.0; nz];
347        let mut scratch = vec![0.0; dct3_z.get_scratch_len()];
348        for j in 0..ny {
349            for i in 0..nx {
350                for k in 0..nz { buf[k] = data[i + j * nx + k * nxy]; }
351                dct3_z.process_dct3_with_scratch(&mut buf, &mut scratch);
352                for k in 0..nz { data[i + j * nx + k * nxy] = buf[k]; }
353            }
354        }
355    }
356    {
357        let mut buf = vec![0.0; ny];
358        let mut scratch = vec![0.0; dct3_y.get_scratch_len()];
359        for k in 0..nz {
360            for i in 0..nx {
361                for j in 0..ny { buf[j] = data[i + j * nx + k * nxy]; }
362                dct3_y.process_dct3_with_scratch(&mut buf, &mut scratch);
363                for j in 0..ny { data[i + j * nx + k * nxy] = buf[j]; }
364            }
365        }
366    }
367    {
368        let mut scratch = vec![0.0; dct3_x.get_scratch_len()];
369        for row in data.chunks_mut(nx) {
370            dct3_x.process_dct3_with_scratch(row, &mut scratch);
371        }
372    }
373    let norm = 8.0 / (nx as f64 * ny as f64 * nz as f64);
374    for v in data.iter_mut() { *v *= norm; }
375    data
376}
377
378/// The original even-extension implementation of [`laplacian_unwrap`] (`Dct`), kept only as the
379/// oracle for the DCT solve: the DCT-II is the FFT of the even extension, so the two must
380/// agree to rounding. Not compiled into the library.
381#[cfg(test)]
382fn laplacian_unwrap_even_extended_reference(
383    phase: &[f64],
384    mask: &[u8],
385    grid: &Grid,
386) -> Vec<f64> {
387    let (nx, ny, nz) = grid.dims;
388    let (vsx, vsy, vsz) = grid.voxel_size;
389    let n_total = nx * ny * nz;
390
391    // Even extension: continuous across the seam, so the periodic FFT solve realises a
392    // Neumann condition on the original array instead of wrapping a discontinuity.
393    let (px, py, pz) = (2 * nx, 2 * ny, 2 * nz);
394    let mut ext = vec![0.0f64; px * py * pz];
395    for k in 0..pz {
396        let sk = if k < nz { k } else { 2 * nz - 1 - k };
397        for j in 0..py {
398            let sj = if j < ny { j } else { 2 * ny - 1 - j };
399            for i in 0..px {
400                let si = if i < nx { i } else { 2 * nx - 1 - i };
401                ext[i + j * px + k * px * py] = phase[si + sj * nx + sk * nx * ny];
402            }
403        }
404    }
405
406    // Laplacian is taken on the extended array, so the wrapped differences never straddle
407    // the original boundary — doing it before the extension reintroduces the seam.
408    let d2u = wrapped_laplacian_periodic(&ext, px, py, pz, vsx, vsy, vsz);
409    let solved = solve_poisson_fft(&d2u, px, py, pz, vsx, vsy, vsz);
410
411    let mut result = vec![0.0; n_total];
412    for k in 0..nz {
413        for j in 0..ny {
414            for i in 0..nx {
415                let dst = i + j * nx + k * nx * ny;
416                if mask[dst] != 0 {
417                    result[dst] = solved[i + j * px + k * px * py];
418                }
419            }
420        }
421    }
422    result
423}
424
425/// Poisson solver for [`laplacian_unwrap`] (names as in QSM.jl's `unwrap_laplacian`).
426#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
427#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
428pub enum LaplacianSolver {
429    /// Unweighted least squares (Ghiglia & Romero 1994): finite differences of wrapped
430    /// neighbour differences, Poisson solve under a Neumann condition via DCT. Matches QSM.jl
431    /// `:dct`. The default.
432    #[default]
433    Dct,
434    /// Schofield & Zhu (2003): ∇²φ from the sin/cos identity with spectral derivatives, FFT
435    /// Poisson solve on the volume zero-padded by `pad` voxels per side. Reproduces STI Suite
436    /// 3.0's `MRPhaseUnwrap` with `padsize = pad` (its default is 12; UK Biobank uses 64).
437    Fft {
438        /// Zero-padding added to both sides of each axis, in voxels.
439        pad: [usize; 3],
440    },
441    /// Schofield & Zhu (2003) as Hongfu Sun's `lapunwrap.m` does it (QSMART's Laplacian
442    /// unwrapping): ∇²φ from the sin/cos identity with spectral derivatives and an FFT Poisson
443    /// solve, on the volume mirrored to twice its size along each axis (so no seam at the edge)
444    /// rather than zero-padded. DC → 0 (`lapunwrap.m` divides it by `eps`, a constant that
445    /// depends only on rounding).
446    Mirror,
447}
448
449impl LaplacianSolver {
450    /// [`LaplacianSolver::Fft`] with STI Suite's default padding, 12 voxels per side.
451    pub const FFT_DEFAULT_PAD: LaplacianSolver = LaplacianSolver::Fft { pad: [12, 12, 12] };
452}
453
454/// Laplacian phase unwrapping, **without** background field removal.
455///
456/// Solves the Poisson equation over the whole array with the chosen [`LaplacianSolver`] (see
457/// the [module docs](self) for both). Nothing is masked out of the input, so field sources
458/// anywhere in the FOV are retained and the harmonic (background) component survives: the
459/// result is an unwrapped **total** field, suitable for a subsequent background-removal stage.
460/// (With [`LaplacianSolver::Fft`] the total field carries a harmonic error from the zero
461/// padding wherever the phase at the array edge is not zero; the local field is unaffected.)
462///
463/// Because the whole array participates, this is sensitive to phase quality *outside* the
464/// ROI. STI Suite-based pipelines zero the phase outside the brain first (`mask · φ`), which
465/// [`run_field_mapping`](crate::pipeline::run_field_mapping) does on its multi-echo Laplacian
466/// path; otherwise, where the phase outside the object is
467/// noise, or wraps faster than one radian per voxel, prefer ROMEO
468/// ([`super::romeo::unwrap_romeo`]).
469///
470/// # Arguments
471/// * `phase` - Wrapped phase (nx * ny * nz)
472/// * `mask` - Binary mask (nx * ny * nz); applied to the *output* only
473/// * `grid` - Volume grid (dimensions and voxel sizes)
474/// * `solver` - [`LaplacianSolver::Dct`] (default) or [`LaplacianSolver::Fft`]
475///
476/// # Returns
477/// Unwrapped phase, zero outside `mask`.
478///
479/// # References
480/// Ghiglia & Romero (1994) for `Dct`; Schofield & Zhu (2003) for `Fft`. See the module docs.
481pub fn laplacian_unwrap(
482    phase: &[f64],
483    mask: &[u8],
484    grid: &Grid,
485    solver: LaplacianSolver,
486) -> Vec<f64> {
487    let (nx, ny, nz) = grid.dims;
488    let (vsx, vsy, vsz) = grid.voxel_size;
489    let u = match solver {
490        LaplacianSolver::Dct => {
491            let d2u = wrapped_laplacian_neumann(phase, nx, ny, nz, vsx, vsy, vsz);
492            solve_poisson_dct(&d2u, nx, ny, nz, vsx, vsy, vsz)
493        }
494        LaplacianSolver::Fft { pad } => laplacian_unwrap_fft_padded(phase, grid, pad),
495        LaplacianSolver::Mirror => laplacian_unwrap_fft_mirrored(phase, grid),
496    };
497    u.iter().zip(mask).map(|(&v, &m)| if m != 0 { v } else { 0.0 }).collect()
498}
499
500/// Schofield & Zhu sin/cos unwrapping on the zero-padded volume (STI Suite's `MRPhaseUnwrap`).
501/// Returns the whole volume, unmasked.
502fn laplacian_unwrap_fft_padded(phase: &[f64], grid: &Grid, pad: [usize; 3]) -> Vec<f64> {
503    let (nx, ny, nz) = grid.dims;
504    let (vsx, vsy, vsz) = grid.voxel_size;
505    assert_eq!(phase.len(), nx * ny * nz, "phase length does not match grid");
506    let [px, py, pz] = pad;
507    let even = |n: usize| n + (n % 2);
508    let (mx, my, mz) = (even(nx + 2 * px), even(ny + 2 * py), even(nz + 2 * pz));
509    let mxy = mx * my;
510
511    let k2 = |n: usize, h: f64| -> Vec<f64> { fftfreq(n, h).into_iter().map(|f| f * f).collect() };
512    let (kx, ky, kz) = (k2(mx, vsx), k2(my, vsy), k2(mz, vsz));
513    // Padded phase as (sin φ, cos φ); outside the input φ = 0.
514    let sincos = |i: usize, j: usize, k: usize| -> (f64, f64) {
515        match (i.checked_sub(px), j.checked_sub(py), k.checked_sub(pz)) {
516            (Some(i), Some(j), Some(k)) if i < nx && j < ny && k < nz => phase[i + j * nx + k * nx * ny].sin_cos(),
517            _ => (0.0, 1.0),
518        }
519    };
520
521    let mut ws = Fft3dWorkspace::new(mx, my, mz);
522    let mut buf = vec![Complex64::new(0.0, 0.0); mxy * mz];
523
524    // K is real and even in k, so F⁻¹[K·F(x)] is real for real x: transform sin φ + i·cos φ
525    // once and read F⁻¹[K·F(sin φ)] and F⁻¹[K·F(cos φ)] off the real and imaginary parts.
526    for k in 0..mz {
527        for j in 0..my {
528            for i in 0..mx {
529                let (s, c) = sincos(i, j, k);
530                buf[i + j * mx + k * mxy] = Complex64::new(s, c);
531            }
532        }
533    }
534    ws.fft3d(&mut buf);
535    apply_k2(&mut buf, &kx, &ky, &kz, false);
536    ws.ifft3d(&mut buf);
537
538    // L = cos φ · F⁻¹[K F sin φ] − sin φ · F⁻¹[K F cos φ]  (= −∇²φ / 4π²)
539    for k in 0..mz {
540        for j in 0..my {
541            for i in 0..mx {
542                let idx = i + j * mx + k * mxy;
543                let (s, c) = sincos(i, j, k);
544                let v = buf[idx];
545                buf[idx] = Complex64::new(c * v.re - s * v.im, 0.0);
546            }
547        }
548    }
549
550    // Poisson inverse: φ = F⁻¹[F(L) / K], DC → 0.
551    ws.fft3d(&mut buf);
552    apply_k2(&mut buf, &kx, &ky, &kz, true);
553    ws.ifft3d(&mut buf);
554
555    let mut out = vec![0.0; nx * ny * nz];
556    for k in 0..nz {
557        for j in 0..ny {
558            for i in 0..nx {
559                out[i + j * nx + k * nx * ny] = buf[(i + px) + (j + py) * mx + (k + pz) * mxy].re;
560            }
561        }
562    }
563    out
564}
565
566/// Schofield & Zhu sin/cos unwrapping on the volume mirrored to `2n` along each axis
567/// (`lapunwrap.m`). Returns the whole volume, unmasked.
568fn laplacian_unwrap_fft_mirrored(phase: &[f64], grid: &Grid) -> Vec<f64> {
569    let (nx, ny, nz) = grid.dims;
570    let (vsx, vsy, vsz) = grid.voxel_size;
571    assert_eq!(phase.len(), nx * ny * nz, "phase length does not match grid");
572    let (mx, my, mz) = (2 * nx, 2 * ny, 2 * nz);
573    let mxy = mx * my;
574    let k2 = |n: usize, h: f64| -> Vec<f64> { fftfreq(n, h).into_iter().map(|f| f * f).collect() };
575    let (kx, ky, kz) = (k2(mx, vsx), k2(my, vsy), k2(mz, vsz));
576    let fold = |i: usize, n: usize| if i < n { i } else { 2 * n - 1 - i };
577    let sincos = |i: usize, j: usize, k: usize| phase[fold(i, nx) + fold(j, ny) * nx + fold(k, nz) * nx * ny].sin_cos();
578
579    let mut ws = Fft3dWorkspace::new(mx, my, mz);
580    let mut buf = vec![Complex64::new(0.0, 0.0); mxy * mz];
581    for k in 0..mz {
582        for j in 0..my {
583            for i in 0..mx {
584                let (s, c) = sincos(i, j, k);
585                buf[i + j * mx + k * mxy] = Complex64::new(s, c);
586            }
587        }
588    }
589    ws.fft3d(&mut buf);
590    apply_k2(&mut buf, &kx, &ky, &kz, false);
591    ws.ifft3d(&mut buf);
592    for k in 0..mz {
593        for j in 0..my {
594            for i in 0..mx {
595                let idx = i + j * mx + k * mxy;
596                let (s, c) = sincos(i, j, k);
597                let v = buf[idx];
598                buf[idx] = Complex64::new(c * v.re - s * v.im, 0.0);
599            }
600        }
601    }
602    ws.fft3d(&mut buf);
603    apply_k2(&mut buf, &kx, &ky, &kz, true);
604    ws.ifft3d(&mut buf);
605
606    let mut out = vec![0.0; nx * ny * nz];
607    for k in 0..nz {
608        for j in 0..ny {
609            for i in 0..nx {
610                out[i + j * nx + k * nx * ny] = buf[i + j * mx + k * mxy].re;
611            }
612        }
613    }
614    out
615}
616
617/// Multiply (or, with `invert`, divide) a spectrum by `K = kx² + ky² + kz²`; DC → 0 on divide.
618fn apply_k2(buf: &mut [Complex64], kx: &[f64], ky: &[f64], kz: &[f64], invert: bool) {
619    let (mx, my) = (kx.len(), ky.len());
620    let plane = |(k, slab): (usize, &mut [Complex64])| {
621        for j in 0..my {
622            let kyz = ky[j] + kz[k];
623            for (v, &kxi) in slab[j * mx..(j + 1) * mx].iter_mut().zip(kx) {
624                let kk = kxi + kyz;
625                if invert {
626                    *v = if kk == 0.0 { Complex64::new(0.0, 0.0) } else { *v / kk };
627                } else {
628                    *v *= kk;
629                }
630            }
631        }
632    };
633    #[cfg(feature = "parallel")]
634    {
635        use rayon::prelude::*;
636        buf.par_chunks_mut(mx * my).enumerate().for_each(plane);
637    }
638    #[cfg(not(feature = "parallel"))]
639    buf.chunks_mut(mx * my).enumerate().for_each(plane);
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn mirror_matches_lapunwrap() {
648        // Hongfu Sun's lapunwrap.m (as QSMART calls it) in MATLAB R2023b on the same wrapped
649        // phase, both made zero-mean (lapunwrap's DC is a rounding-dependent constant).
650        let (nx, ny, nz) = (8, 6, 5);
651        let grid = Grid::new(nx, ny, nz, 1.0, 1.5, 2.0);
652        let phase: Vec<f64> = (0..nx * ny * nz)
653            .map(|i| {
654                let (a, b, c) = ((i % nx) as f64, ((i / nx) % ny) as f64, (i / (nx * ny)) as f64);
655                let t = 0.9 * a * a / 4.0 + 1.7 * b - 0.6 * c * c + 0.3 * (i as f64).sin();
656                t.sin().atan2(t.cos())
657            })
658            .collect();
659        let mut u = laplacian_unwrap(&phase, &vec![1; phase.len()], &grid, LaplacianSolver::Mirror);
660        let mean = u.iter().sum::<f64>() / u.len() as f64;
661        u.iter_mut().for_each(|v| *v -= mean);
662        let close = |a: f64, b: f64| assert!((a - b).abs() <= 1e-10 * b.abs().max(1.0), "{a} vs {b}");
663        close(u.iter().map(|v| v.abs()).sum(), 809.9262085647304);
664        close(u.iter().map(|v| v * v).sum(), 4077.856347779972);
665        close(u[0], -5.778520101297622);
666        close(u[7], 2.016668604865187);
667        close(u[100], -4.332690703087126);
668    }
669
670    fn grid(n: usize) -> Grid {
671        Grid::new(n, n, n, 1.0, 1.0, 1.0)
672    }
673
674    #[test]
675    fn test_wrap() {
676        assert!((wrap(0.0) - 0.0).abs() < 1e-10);
677        assert!((wrap(PI) - PI).abs() < 1e-10);
678        assert!((wrap(-PI) - (-PI)).abs() < 1e-10);
679        assert!((wrap(2.0 * PI) - 0.0).abs() < 1e-10);
680        assert!((wrap(3.0 * PI) - PI).abs() < 1e-10);
681        assert!((wrap(-3.0 * PI) - (-PI)).abs() < 1e-10);
682    }
683
684    /// Pearson r and the slope of `want` regressed on `obs`, inside `mask`.
685    fn corr_slope(obs: &[f64], want: &[f64], mask: &[u8]) -> (f64, f64) {
686        let (mut sa, mut sb, mut n) = (0.0, 0.0, 0usize);
687        for i in 0..obs.len() {
688            if mask[i] != 0 { sa += obs[i]; sb += want[i]; n += 1; }
689        }
690        let nf = n as f64;
691        let (ma, mb) = (sa / nf, sb / nf);
692        let (mut cov, mut va, mut vb) = (0.0, 0.0, 0.0);
693        for i in 0..obs.len() {
694            if mask[i] == 0 { continue; }
695            let (da, db) = (obs[i] - ma, want[i] - mb);
696            cov += da * db;
697            va += da * da;
698            vb += db * db;
699        }
700        (cov / (va.sqrt() * vb.sqrt()), cov / va)
701    }
702    /// `(wrapped, truth, ramp, blob, mask)`.
703    type RampPlusBlob = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>);
704
705
706    /// A field split into a harmonic part (a linear ramp, ∇² = 0) and a non-harmonic part
707    /// (a Gaussian blob), wrapped hard enough that unwrapping is doing real work.
708    /// Returns (wrapped, truth, ramp, blob, mask).
709    fn ramp_plus_blob(n: usize) -> RampPlusBlob {
710        let c = n as f64 / 2.0;
711        let total = n * n * n;
712        let (mut truth, mut ramp, mut blob) = (vec![0.0; total], vec![0.0; total], vec![0.0; total]);
713        let mut mask = vec![0u8; total];
714        for k in 0..n {
715            for j in 0..n {
716                for i in 0..n {
717                    let idx = i + j * n + k * n * n;
718                    let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
719                    let r = (x * x + y * y + z * z).sqrt();
720                    ramp[idx] = 0.35 * x + 0.20 * y + 0.15 * z;
721                    blob[idx] = 12.0 * (-(r * r) / (2.0 * 8.0f64.powi(2))).exp();
722                    truth[idx] = ramp[idx] + blob[idx];
723                    if r < 22.0 { mask[idx] = 1; }
724                }
725            }
726        }
727        let wrapped: Vec<f64> = truth.iter().map(|&v| wrap(v)).collect();
728        (wrapped, truth, ramp, blob, mask)
729    }
730
731    #[test]
732    fn laplacian_unwrap_recovers_the_whole_field_including_background() {
733        let n = 64;
734        let (wrapped, truth, _, _, mask) = ramp_plus_blob(n);
735
736        let out = laplacian_unwrap(&wrapped, &mask, &grid(n), LaplacianSolver::Dct);
737        let (r, slope) = corr_slope(&out, &truth, &mask);
738
739        assert!(r > 0.99, "should recover the total field, got r = {r}");
740        assert!((slope - 1.0).abs() < 0.05, "expected unit slope, got {slope}");
741    }
742
743    #[test]
744    fn dct_solve_matches_the_even_extended_solve() {
745        // The DCT-II is the FFT of the even extension, so these are the same computation
746        // with and without materialising the extension. Agreement should be to rounding.
747        let n = 64;
748        let (wrapped, _, _, _, mask) = ramp_plus_blob(n);
749        let padded = laplacian_unwrap_even_extended_reference(&wrapped, &mask, &grid(n));
750        let dct = laplacian_unwrap(&wrapped, &mask, &grid(n), LaplacianSolver::Dct);
751        let scale = padded.iter().fold(0.0f64, |m, &v| m.max(v.abs()));
752        let max_diff = padded.iter().zip(&dct).fold(0.0f64, |m, (&a, &b)| m.max((a - b).abs()));
753        assert!(max_diff < 1e-9 * scale, "DCT and padded solves differ: max |diff| = {max_diff} (scale {scale})");
754    }
755
756    // ---- LaplacianSolver::Fft (Schofield & Zhu; STI Suite's MRPhaseUnwrap) and multi-echo ----
757
758    /// `Fft` unwrap of the whole volume (all-ones mask, so nothing is zeroed).
759    fn fft(p: &[f64], grid: &Grid, pad: [usize; 3]) -> Vec<f64> {
760        laplacian_unwrap(p, &vec![1u8; p.len()], grid, LaplacianSolver::Fft { pad })
761    }
762
763    #[test]
764    fn dct_is_the_default_solver() {
765        assert_eq!(LaplacianSolver::default(), LaplacianSolver::Dct);
766    }
767
768    /// The 8×8×6 test volume used for the STI golden values (0-based indices here,
769    /// 1-based `i+1` etc. in the MATLAB that produced them).
770    fn sti_golden_input() -> (Vec<f64>, Grid) {
771        let (nx, ny, nz) = (8, 8, 6);
772        let mut p = vec![0.0; nx * ny * nz];
773        for k in 0..nz {
774            for j in 0..ny {
775                for i in 0..nx {
776                    let (a, b, c) = ((i + 1) as f64, (j + 1) as f64, (k + 1) as f64);
777                    let t = 2.1 * (0.7 * a).sin() + 1.3 * (0.4 * b * c).cos() + 0.9 * a - 0.6 * c + 0.05 * a * b;
778                    p[i + j * nx + k * nx * ny] = t.sin().atan2(t.cos());
779                }
780            }
781        }
782        (p, Grid::new(nx, ny, nz, 1.0, 1.0, 2.0))
783    }
784
785    #[test]
786    fn fft_matches_sti_golden_values() {
787        // STI Suite 3.0 MRPhaseUnwrap(phi, 'voxelsize', [1 1 2], 'padsize', [2 3 1]) in STI Suite 3.0
788        // (R2023b, the values in STI_GOLDEN).
789        let (p, grid) = sti_golden_input();
790        let u = fft(&p, &grid, [2, 3, 1]);
791        let sum: f64 = u.iter().sum();
792        let sumsq: f64 = u.iter().map(|v| v * v).sum();
793        assert!((sum - STI_GOLDEN.0).abs() < 1e-9, "sum {sum} vs STI {}", STI_GOLDEN.0);
794        assert!((sumsq - STI_GOLDEN.1).abs() < 1e-9, "sum sq {sumsq} vs STI {}", STI_GOLDEN.1);
795        for &(idx, want) in STI_GOLDEN.2 {
796            assert!((u[idx] - want).abs() < 1e-12, "voxel {idx}: {} vs STI {want}", u[idx]);
797        }
798    }
799
800    /// (sum, sum of squares, [(0-based linear index, value)]) of STI's output on golden_input().
801    #[allow(clippy::type_complexity, clippy::excessive_precision)]
802    const STI_GOLDEN: (f64, f64, &[(usize, f64)]) = (
803        -47.110605048249184,
804        278.05516319999214,
805        &[
806            (0, 0.035944170373255008), (1, 0.54603271528608766), (8, -0.12289609617968449),
807            (64, 0.17121148831819896), (99, -0.58598953742943161), (199, 0.35334342671042662),
808            (332, -0.18693470845945748), (383, 1.1196905106581743),
809        ],
810    );
811
812    #[test]
813    fn fft_invariant_to_two_pi_jumps() {
814        // Only sin φ and cos φ enter, so adding 2π anywhere changes nothing.
815        let (p, grid) = sti_golden_input();
816        let jumped: Vec<f64> = p.iter().enumerate()
817            .map(|(i, &v)| v + 2.0 * PI * ((i * 7919) % 5) as f64 - 4.0 * PI)
818            .collect();
819        let a = fft(&p, &grid, [12; 3]);
820        let b = fft(&jumped, &grid, [12; 3]);
821        let d = a.iter().zip(&b).fold(0.0f64, |m, (x, y)| m.max((x - y).abs()));
822        assert!(d < 1e-12, "max |diff| {d}");
823    }
824
825    /// Smooth blob, wrapped many times, inside a box of zeros.
826    fn wrapped_blob_vs(n: (usize, usize, usize), vs: (f64, f64, f64)) -> (Vec<f64>, Vec<f64>, Grid) {
827        let grid = Grid::new(n.0, n.1, n.2, vs.0, vs.1, vs.2);
828        let c = (n.0 as f64 / 2.0 * vs.0, n.1 as f64 / 2.0 * vs.1, n.2 as f64 / 2.0 * vs.2);
829        let mut truth = vec![0.0; grid.n_total()];
830        for k in 0..n.2 {
831            for j in 0..n.1 {
832                for i in 0..n.0 {
833                    let (x, y, z) = (i as f64 * vs.0 - c.0, j as f64 * vs.1 - c.1, k as f64 * vs.2 - c.2);
834                    truth[i + j * n.0 + k * n.0 * n.1] = 20.0 * (-(x * x + y * y + z * z) / (2.0 * 9.0f64.powi(2))).exp();
835                }
836            }
837        }
838        let wrapped = truth.iter().map(|&v| wrap(v)).collect();
839        (wrapped, truth, grid)
840    }
841
842    fn max_dev_after_mean(a: &[f64], b: &[f64]) -> f64 {
843        let n = a.len() as f64;
844        let off = a.iter().zip(b).map(|(x, y)| x - y).sum::<f64>() / n;
845        a.iter().zip(b).fold(0.0f64, |m, (x, y)| m.max((x - y - off).abs()))
846    }
847
848    #[test]
849    fn fft_unwraps_a_smooth_field() {
850        // Peak 20 rad (≈ 3 wraps), decays to ~0 at the edge: the periodic, zero-padded solve
851        // should return it up to a constant. Steepest change ≈ 1.35 rad/mm, so at most ~1.6 rad
852        // per voxel here; the sin/cos identity degrades well before π per voxel (at 2 mm,
853        // 2.7 rad/voxel, the error is ~0.2 rad).
854        for vs in [(1.0, 1.0, 1.0), (0.8, 0.8, 1.2), (1.2, 0.9, 1.0)] {
855            let (wrapped, truth, grid) = wrapped_blob_vs((64, 64, 48), vs);
856            let u = fft(&wrapped, &grid, [12; 3]);
857            let d = max_dev_after_mean(&u, &truth);
858            assert!(d < 0.05, "voxel size {vs:?}: max deviation {d} rad");
859        }
860    }
861
862    #[test]
863    fn fft_odd_dimensions_keep_the_input_size() {
864        let (wrapped, truth, grid) = wrapped_blob_vs((61, 63, 47), (1.0, 1.0, 1.0));
865        for pad in [[0, 0, 0], [3, 4, 5]] {
866            let u = fft(&wrapped, &grid, pad);
867            assert_eq!(u.len(), grid.n_total());
868            assert!(max_dev_after_mean(&u, &truth) < 0.05);
869        }
870    }
871
872    #[test]
873    fn fft_dc_of_the_padded_box_is_zero() {
874        // The Poisson solve zeroes the mean over the padded box, not over the input.
875        let (wrapped, _, grid) = wrapped_blob_vs((32, 32, 24), (1.0, 1.0, 1.0));
876        let u = fft(&wrapped, &grid, [0, 0, 0]);
877        let mean = u.iter().sum::<f64>() / u.len() as f64;
878        assert!(mean.abs() < 1e-12, "mean {mean}");
879    }
880
881    #[test]
882    fn fft_zero_padding_costs_total_field_fidelity() {
883        // A harmonic ramp that is far from zero at the array faces: the zero padding puts a
884        // step there, and the periodic solve turns it into a harmonic error inside. The Neumann
885        // solve has no such step. This is the documented reason `Fft` is not the
886        // default; if it starts passing the other way, update the module docs.
887        let n = 48;
888        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
889        let c = n as f64 / 2.0;
890        let mut truth = vec![0.0; n * n * n];
891        for k in 0..n { for j in 0..n { for i in 0..n {
892            let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
893            truth[i + j * n + k * n * n] = 0.35 * x + 0.2 * y + 0.15 * z
894                + 12.0 * (-(x * x + y * y + z * z) / 128.0).exp();
895        }}}
896        let wrapped: Vec<f64> = truth.iter().map(|&v| wrap(v)).collect();
897        let mask = vec![1u8; truth.len()];
898        let r = |u: &[f64]| {
899            let m = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
900            let (mu, mt) = (m(u), m(&truth));
901            let (mut cov, mut vu, mut vt) = (0.0, 0.0, 0.0);
902            for (a, b) in u.iter().zip(&truth) { cov += (a - mu) * (b - mt); vu += (a - mu).powi(2); vt += (b - mt).powi(2); }
903            cov / (vu * vt).sqrt()
904        };
905        let sti = fft(&wrapped, &grid, [12; 3]);
906        let neumann = laplacian_unwrap(&wrapped, &mask, &grid, LaplacianSolver::Dct);
907        assert!(r(&neumann) > 0.99, "Neumann r = {}", r(&neumann));
908        assert!(r(&sti) < 0.9, "STI r = {}", r(&sti));
909    }
910}