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//! This module provides **two algorithms that are not interchangeable**, because the
8//! boundary condition used to solve the Poisson equation decides whether the harmonic
9//! (background) component of the field survives:
10//!
11//! | Function | Boundary condition | Category |
12//! |---|---|---|
13//! | [`laplacian_unwrap`] | Neumann, on the array | Phase unwrapping |
14//! | [`laplacian_unwrap_bfr`] (deprecated) | ∇² masked to the ROI | Phase unwrapping **+ background field removal** |
15//!
16//! [`laplacian_unwrap_bfr`] zeroes ∇²φ outside the mask, which discards every field source
17//! outside the ROI. Background fields are harmonic inside the ROI, and ∇²(harmonic) = 0
18//! carries no information about them, so they cannot be recovered afterwards — the
19//! function returns a partially background-removed field, not a total field. That is the
20//! same combination HARPERELLA and iHARPERELLA perform, and is why it is categorised
21//! with them rather than with ROMEO.
22//!
23//! Pair [`laplacian_unwrap_bfr`] with a separate background-removal stage only deliberately:
24//! doing so removes background twice, by an amount that is not controlled.
25//!
26//! The background removal works by zeroing ∇² outside the mask — which deletes the exterior
27//! sources that generate the background, since a field produced outside the ROI is harmonic
28//! inside it — and then solving under a homogeneous Dirichlet condition on the ROI. On the
29//! project's test data it reaches r = 0.887 against the ground-truth local field, against
30//! r = 0.909 for [`crate::bgremove::lbv`] on the same field and 0.879 for V-SHARP.
31//!
32//! [`UnwrapMethod::Laplacian`](super::UnwrapMethod::Laplacian) selects [`laplacian_unwrap`],
33//! since the pipeline removes background as a later stage; reach for
34//! [`laplacian_unwrap_bfr`] when you want the two together.
35//!
36//! # References
37//!
38//! Laplacian unwrapping:
39//! Schofield, M.A., Zhu, Y. (2003). "Fast phase unwrapping algorithm for
40//! interferometric applications." Optics Letters, 28(14):1194-1196.
41//! <https://doi.org/10.1364/OL.28.001194>
42//!
43//! The background-removal half of [`laplacian_unwrap_bfr`] (solving the Laplacian as a
44//! boundary value problem on the ROI):
45//! Zhou, D., Liu, T., Spincemaille, P., Wang, Y. (2014). "Background field removal by
46//! solving the Laplacian boundary value problem." NMR in Biomedicine, 27(3):312-319.
47//! <https://doi.org/10.1002/nbm.3064>
48//!
49//! Reference implementation: <https://github.com/kamesy/QSM.jl> — its `unwrap_laplacian`
50//! exposes the same split through its `solver` keyword (`:dct`/`:fft` impose the boundary
51//! condition on the array and unwrap only; `:mgpcg` imposes it on the ROI and also removes
52//! the harmonic background).
53//!
54//! Both functions here were cross-checked against it by running QSM.jl v0.5.4 on
55//! byte-identical input (a wrapped harmonic ramp plus a non-harmonic blob, 64³):
56//! [`laplacian_unwrap`] reproduces `:dct` exactly (r = 1.000000, rms difference 0.0), and
57//! [`laplacian_unwrap_bfr`] matches `:mgpcg` to r = 0.999983 — the residual being
58//! Gauss-Seidel against their multigrid-preconditioned CG on the same equation. Both
59//! implementations return the harmonic component as zero and the non-harmonic component
60//! at r > 0.9999.
61
62use std::f64::consts::PI;
63#[cfg(test)]
64use num_complex::Complex64;
65#[cfg(test)]
66use crate::fft::{fft3d, ifft3d};
67use crate::Grid;
68
69/// Wrap angle to [-π, π]
70#[inline]
71pub(crate) fn wrap(x: f64) -> f64 {
72    let mut y = x % (2.0 * PI);
73    if y > PI {
74        y -= 2.0 * PI;
75    } else if y < -PI {
76        y += 2.0 * PI;
77    }
78    y
79}
80
81/// Compute wrapped Laplacian of phase with periodic boundary conditions
82///
83/// Uses second-order central finite differences on wrapped phase differences.
84pub(crate) fn wrapped_laplacian_periodic(
85    phase: &[f64],
86    nx: usize, ny: usize, nz: usize,
87    vsx: f64, vsy: f64, vsz: f64,
88) -> Vec<f64> {
89    let n_total = nx * ny * nz;
90    let mut d2u = vec![0.0; n_total];
91
92    let dx2 = 1.0 / (vsx * vsx);
93    let dy2 = 1.0 / (vsy * vsy);
94    let dz2 = 1.0 / (vsz * vsz);
95
96    for k in 0..nz {
97        let km1 = if k == 0 { nz - 1 } else { k - 1 };
98        let kp1 = if k + 1 >= nz { 0 } else { k + 1 };
99
100        for j in 0..ny {
101            let jm1 = if j == 0 { ny - 1 } else { j - 1 };
102            let jp1 = if j + 1 >= ny { 0 } else { j + 1 };
103
104            for i in 0..nx {
105                let im1 = if i == 0 { nx - 1 } else { i - 1 };
106                let ip1 = if i + 1 >= nx { 0 } else { i + 1 };
107
108                let idx = i + j * nx + k * nx * ny;
109                let u_ijk = phase[idx];
110
111                let idx_im1 = im1 + j * nx + k * nx * ny;
112                let idx_ip1 = ip1 + j * nx + k * nx * ny;
113                let idx_jm1 = i + jm1 * nx + k * nx * ny;
114                let idx_jp1 = i + jp1 * nx + k * nx * ny;
115                let idx_km1 = i + j * nx + km1 * nx * ny;
116                let idx_kp1 = i + j * nx + kp1 * nx * ny;
117
118                let lap_x = (wrap(phase[idx_ip1] - u_ijk) - wrap(u_ijk - phase[idx_im1])) * dx2;
119                let lap_y = (wrap(phase[idx_jp1] - u_ijk) - wrap(u_ijk - phase[idx_jm1])) * dy2;
120                let lap_z = (wrap(phase[idx_kp1] - u_ijk) - wrap(u_ijk - phase[idx_km1])) * dz2;
121
122                d2u[idx] = lap_x + lap_y + lap_z;
123            }
124        }
125    }
126
127    d2u
128}
129
130/// Solve Poisson equation using FFT (periodic boundary conditions).
131///
132/// Only the test-only even-extension oracle uses this now; the library solves under
133/// Neumann via [`solve_poisson_dct`].
134#[cfg(test)]
135pub(crate) fn solve_poisson_fft(
136    f: &[f64],
137    nx: usize, ny: usize, nz: usize,
138    vsx: f64, vsy: f64, vsz: f64,
139) -> Vec<f64> {
140    let mut f_complex: Vec<Complex64> = f.iter()
141        .map(|&x| Complex64::new(x, 0.0))
142        .collect();
143    fft3d(&mut f_complex, nx, ny, nz);
144
145    let idx2 = 1.0 / (vsx * vsx);
146    let idy2 = 1.0 / (vsy * vsy);
147    let idz2 = 1.0 / (vsz * vsz);
148
149    for k in 0..nz {
150        let fk = if k <= nz / 2 { k as f64 / nz as f64 } else { (k as f64 - nz as f64) / nz as f64 };
151        let lam_z = 2.0 * ((2.0 * PI * fk).cos() - 1.0) * idz2;
152
153        for j in 0..ny {
154            let fj = if j <= ny / 2 { j as f64 / ny as f64 } else { (j as f64 - ny as f64) / ny as f64 };
155            let lam_y = 2.0 * ((2.0 * PI * fj).cos() - 1.0) * idy2;
156
157            for i in 0..nx {
158                let fi = if i <= nx / 2 { i as f64 / nx as f64 } else { (i as f64 - nx as f64) / nx as f64 };
159                let lam_x = 2.0 * ((2.0 * PI * fi).cos() - 1.0) * idx2;
160
161                let lam = lam_x + lam_y + lam_z;
162                let idx = i + j * nx + k * nx * ny;
163
164                if lam.abs() > 1e-20 {
165                    f_complex[idx] /= lam;
166                } else {
167                    f_complex[idx] = Complex64::new(0.0, 0.0);
168                }
169            }
170        }
171    }
172
173    ifft3d(&mut f_complex, nx, ny, nz);
174    f_complex.iter().map(|c| c.re).collect()
175}
176
177/// Laplacian phase unwrapping **combined with background field removal**.
178///
179/// Solves the Poisson equation with the Laplacian zeroed outside `mask`. That discards the
180/// field sources outside the ROI, and a field generated outside the ROI is harmonic inside
181/// it — so the background component is removed along with the wraps.
182///
183/// **The result is not a total field.** It is unwrapped *and* partially background-removed,
184/// by an amount that depends on the mask and the field geometry. Following this with a
185/// separate background-removal stage (V-SHARP, PDF, …) removes background twice.
186///
187/// Use [`laplacian_unwrap`] to unwrap without removing background.
188///
189/// Because ∇²(harmonic) = 0, the discarded component leaves no trace in the input to the
190/// Poisson solve and cannot be restored afterwards.
191///
192/// # Intended input
193/// One wrapped phase volume. On the project's test data it then matches
194/// [`crate::bgremove::lbv`] on the same field (r = 0.887 against 0.909, identical residual
195/// smooth content). It takes *wrapped phase*, so in a multi-echo pipeline the only way to
196/// apply it is to each echo before combining; that usage is not what the algorithm
197/// describes, has not been validated, and leaves visibly more background than either
198/// unwrapping then a field-map background removal or this function on a single volume.
199/// For multi-echo data use [`laplacian_unwrap`] or ROMEO, combine, then a background
200/// removal from [`crate::bgremove`].
201///
202/// # Echo time
203/// Accuracy falls off with the amount of phase to unwrap. On the 7 T test data, against
204/// the ground-truth local field: r = 0.84 at TE = 4 ms, 0.70 at 8 ms, 0.50 at 12 ms —
205/// where unwrapping then [`crate::bgremove::lbv`] gives 0.86, 0.84, 0.69 and
206/// [`crate::bgremove::vsharp`] holds near 0.82 throughout. Prefer the earliest echo.
207///
208/// # Arguments
209/// * `phase` - Wrapped phase (nx * ny * nz)
210/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
211/// * `grid` - Volume grid (dimensions and voxel sizes)
212///
213/// # Returns
214/// Unwrapped, partially background-removed phase, zero outside `mask`.
215///
216/// # References
217/// Schofield & Zhu (2003) for the unwrapping; Zhou et al. (2014) for the
218/// boundary-value formulation of the background removal. See the module docs.
219/// Solve ∇²u = f inside `mask` with u = 0 outside it (homogeneous Dirichlet on the ROI),
220/// by Gauss-Seidel with successive over-relaxation.
221///
222/// Masking the source term is only half of the ROI formulation: the solution has to be
223/// constrained at the ROI boundary too, or it picks up an arbitrary harmonic component.
224/// Solving the masked source over the whole volume with a periodic FFT does not constrain
225/// it, and that component is large — which is what this replaces.
226///
227/// Mirrors the solver in [`crate::bgremove::lbv`], which solves the homogeneous case
228/// (`f = 0`) with boundary values taken from the field.
229fn solve_poisson_dirichlet_roi(
230    f: &[f64],
231    mask: &[u8],
232    grid: &Grid,
233    tol: f64,
234    max_iter: usize,
235) -> Vec<f64> {
236    let (nx, ny, nz) = grid.dims;
237    let (vsx, vsy, vsz) = grid.voxel_size;
238    let (dx2, dy2, dz2) = (1.0 / (vsx * vsx), 1.0 / (vsy * vsy), 1.0 / (vsz * vsz));
239    let diag = -2.0 * (dx2 + dy2 + dz2);
240    let omega = 1.5;
241
242    let mut u = vec![0.0f64; nx * ny * nz];
243
244    // Relative criterion, so convergence does not depend on the units of `f`.
245    let scale = f.iter().map(|v| v.abs()).fold(0.0f64, f64::max).max(1e-30);
246    let scaled_tol = tol * scale / diag.abs();
247
248    for _ in 0..max_iter {
249        let mut max_change = 0.0f64;
250        for k in 1..nz - 1 {
251            for j in 1..ny - 1 {
252                for i in 1..nx - 1 {
253                    let idx = i + j * nx + k * nx * ny;
254                    if mask[idx] == 0 {
255                        continue; // stays 0: this is the Dirichlet condition
256                    }
257                    let sum = dx2 * (u[idx - 1] + u[idx + 1])
258                            + dy2 * (u[idx - nx] + u[idx + nx])
259                            + dz2 * (u[idx - nx * ny] + u[idx + nx * ny]);
260                    // ∇²u = sum + diag*u = f  =>  u = (f - sum) / diag
261                    let target = (f[idx] - sum) / diag;
262                    let old = u[idx];
263                    let next = old + omega * (target - old);
264                    max_change = max_change.max((next - old).abs());
265                    u[idx] = next;
266                }
267            }
268        }
269        if max_change < scaled_tol {
270            break;
271        }
272    }
273    u
274}
275
276#[deprecated(
277    since = "0.35.0",
278    note = "unwrap with `laplacian_unwrap` and then remove background with a `bgremove` \
279            method (`lbv` reproduces this; `vsharp` is more robust at long TE). That route \
280            is more accurate at every echo time measured and composes with any background \
281            removal. Kept for parity with QSM.jl's `unwrap_laplacian(solver = :mgpcg)`."
282)]
283pub fn laplacian_unwrap_bfr(
284    phase: &[f64],
285    mask: &[u8],
286    grid: &Grid,
287) -> Vec<f64> {
288    let (nx, ny, nz) = grid.dims;
289    let (vsx, vsy, vsz) = grid.voxel_size;
290    let n_total = nx * ny * nz;
291
292    let d2u = wrapped_laplacian_periodic(phase, nx, ny, nz, vsx, vsy, vsz);
293
294    let d2u_masked: Vec<f64> = d2u.iter()
295        .enumerate()
296        .map(|(i, &val)| if mask[i] != 0 { val } else { 0.0 })
297        .collect();
298
299    // Dirichlet on the ROI, matching QSM.jl's `:mgpcg` path. Masking the source and then
300    // solving over the whole volume with a periodic FFT leaves the harmonic component
301    // unconstrained, which showed up as a large spurious background field.
302    let max_iter = (3 * nx.max(ny).max(nz)).min(500);
303    let unwrapped = solve_poisson_dirichlet_roi(&d2u_masked, mask, grid, 1e-6, max_iter);
304
305    let mut result = vec![0.0; n_total];
306    for i in 0..n_total {
307        if mask[i] != 0 {
308            result[i] = unwrapped[i];
309        }
310    }
311
312    result
313}
314
315/// Wrapped Laplacian under a Neumann boundary: at each array face the missing neighbour is
316/// the sample itself, so the wrapped difference across the face is zero. This is exactly
317/// the half-sample even extension the DCT-II assumes, so pairing it with
318/// [`solve_poisson_dct`] reproduces the even-extended periodic solve without building the
319/// 2x-per-axis extension.
320pub(crate) fn wrapped_laplacian_neumann(
321    phase: &[f64],
322    nx: usize, ny: usize, nz: usize,
323    vsx: f64, vsy: f64, vsz: f64,
324) -> Vec<f64> {
325    let n_total = nx * ny * nz;
326    let mut d2u = vec![0.0; n_total];
327    let (dx2, dy2, dz2) = (1.0 / (vsx * vsx), 1.0 / (vsy * vsy), 1.0 / (vsz * vsz));
328
329    for k in 0..nz {
330        let km1 = k.saturating_sub(1);
331        let kp1 = if k + 1 >= nz { k } else { k + 1 };
332        for j in 0..ny {
333            let jm1 = j.saturating_sub(1);
334            let jp1 = if j + 1 >= ny { j } else { j + 1 };
335            for i in 0..nx {
336                let im1 = i.saturating_sub(1);
337                let ip1 = if i + 1 >= nx { i } else { i + 1 };
338                let idx = i + j * nx + k * nx * ny;
339                let u = phase[idx];
340                let lap_x = (wrap(phase[ip1 + j * nx + k * nx * ny] - u) - wrap(u - phase[im1 + j * nx + k * nx * ny])) * dx2;
341                let lap_y = (wrap(phase[i + jp1 * nx + k * nx * ny] - u) - wrap(u - phase[i + jm1 * nx + k * nx * ny])) * dy2;
342                let lap_z = (wrap(phase[i + j * nx + kp1 * nx * ny] - u) - wrap(u - phase[i + j * nx + km1 * nx * ny])) * dz2;
343                d2u[idx] = lap_x + lap_y + lap_z;
344            }
345        }
346    }
347    d2u
348}
349
350/// Solve ∇²u = f under a Neumann boundary condition on the array, in place, via DCT-II.
351///
352/// The DCT-II of a length-N signal is the FFT of its even extension restricted to the
353/// original samples, so this is the padded periodic solve with the extension never
354/// materialised: working memory is the volume itself plus one axis-length buffer,
355/// against 8x the volume in complex doubles for the explicit extension.
356///
357/// Eigenvalues of the second difference under this basis are `2(cos(πk/N) − 1)/h²`. The DC
358/// mode is set to zero, as in the periodic solve — the result is defined up to a constant.
359pub(crate) fn solve_poisson_dct(
360    f: &[f64],
361    nx: usize, ny: usize, nz: usize,
362    vsx: f64, vsy: f64, vsz: f64,
363) -> Vec<f64> {
364    use rustdct::DctPlanner;
365    let nxy = nx * ny;
366    let mut data = f.to_vec();
367    let mut planner = DctPlanner::<f64>::new();
368
369    // Forward: DCT-II along each axis.
370    let dct2_x = planner.plan_dct2(nx);
371    let dct2_y = planner.plan_dct2(ny);
372    let dct2_z = planner.plan_dct2(nz);
373    {
374        let mut scratch = vec![0.0; dct2_x.get_scratch_len()];
375        for row in data.chunks_mut(nx) {
376            dct2_x.process_dct2_with_scratch(row, &mut scratch);
377        }
378    }
379    {
380        let mut buf = vec![0.0; ny];
381        let mut scratch = vec![0.0; dct2_y.get_scratch_len()];
382        for k in 0..nz {
383            for i in 0..nx {
384                for j in 0..ny { buf[j] = data[i + j * nx + k * nxy]; }
385                dct2_y.process_dct2_with_scratch(&mut buf, &mut scratch);
386                for j in 0..ny { data[i + j * nx + k * nxy] = buf[j]; }
387            }
388        }
389    }
390    {
391        let mut buf = vec![0.0; nz];
392        let mut scratch = vec![0.0; dct2_z.get_scratch_len()];
393        for j in 0..ny {
394            for i in 0..nx {
395                for k in 0..nz { buf[k] = data[i + j * nx + k * nxy]; }
396                dct2_z.process_dct2_with_scratch(&mut buf, &mut scratch);
397                for k in 0..nz { data[i + j * nx + k * nxy] = buf[k]; }
398            }
399        }
400    }
401
402    // Divide by the Laplacian eigenvalue of each DCT-II mode.
403    let (ix2, iy2, iz2) = (1.0 / (vsx * vsx), 1.0 / (vsy * vsy), 1.0 / (vsz * vsz));
404    let lam_x: Vec<f64> = (0..nx).map(|i| 2.0 * ((PI * i as f64 / nx as f64).cos() - 1.0) * ix2).collect();
405    let lam_y: Vec<f64> = (0..ny).map(|j| 2.0 * ((PI * j as f64 / ny as f64).cos() - 1.0) * iy2).collect();
406    let lam_z: Vec<f64> = (0..nz).map(|k| 2.0 * ((PI * k as f64 / nz as f64).cos() - 1.0) * iz2).collect();
407    for (k, &lz) in lam_z.iter().enumerate() {
408        for (j, &ly) in lam_y.iter().enumerate() {
409            let row = j * nx + k * nxy;
410            for (i, &lx) in lam_x.iter().enumerate() {
411                let lam = lx + ly + lz;
412                let idx = row + i;
413                data[idx] = if lam.abs() > 1e-20 { data[idx] / lam } else { 0.0 };
414            }
415        }
416    }
417
418    // Inverse: DCT-III along each axis. Unnormalised DCT-III∘DCT-II scales by N/2 per axis.
419    let dct3_x = planner.plan_dct3(nx);
420    let dct3_y = planner.plan_dct3(ny);
421    let dct3_z = planner.plan_dct3(nz);
422    {
423        let mut buf = vec![0.0; nz];
424        let mut scratch = vec![0.0; dct3_z.get_scratch_len()];
425        for j in 0..ny {
426            for i in 0..nx {
427                for k in 0..nz { buf[k] = data[i + j * nx + k * nxy]; }
428                dct3_z.process_dct3_with_scratch(&mut buf, &mut scratch);
429                for k in 0..nz { data[i + j * nx + k * nxy] = buf[k]; }
430            }
431        }
432    }
433    {
434        let mut buf = vec![0.0; ny];
435        let mut scratch = vec![0.0; dct3_y.get_scratch_len()];
436        for k in 0..nz {
437            for i in 0..nx {
438                for j in 0..ny { buf[j] = data[i + j * nx + k * nxy]; }
439                dct3_y.process_dct3_with_scratch(&mut buf, &mut scratch);
440                for j in 0..ny { data[i + j * nx + k * nxy] = buf[j]; }
441            }
442        }
443    }
444    {
445        let mut scratch = vec![0.0; dct3_x.get_scratch_len()];
446        for row in data.chunks_mut(nx) {
447            dct3_x.process_dct3_with_scratch(row, &mut scratch);
448        }
449    }
450    let norm = 8.0 / (nx as f64 * ny as f64 * nz as f64);
451    for v in data.iter_mut() { *v *= norm; }
452    data
453}
454
455/// The original even-extension implementation of [`laplacian_unwrap`], kept only as the
456/// oracle for the DCT solve: the DCT-II is the FFT of the even extension, so the two must
457/// agree to rounding. Not compiled into the library.
458#[cfg(test)]
459fn laplacian_unwrap_even_extended_reference(
460    phase: &[f64],
461    mask: &[u8],
462    grid: &Grid,
463) -> Vec<f64> {
464    let (nx, ny, nz) = grid.dims;
465    let (vsx, vsy, vsz) = grid.voxel_size;
466    let n_total = nx * ny * nz;
467
468    // Even extension: continuous across the seam, so the periodic FFT solve realises a
469    // Neumann condition on the original array instead of wrapping a discontinuity.
470    let (px, py, pz) = (2 * nx, 2 * ny, 2 * nz);
471    let mut ext = vec![0.0f64; px * py * pz];
472    for k in 0..pz {
473        let sk = if k < nz { k } else { 2 * nz - 1 - k };
474        for j in 0..py {
475            let sj = if j < ny { j } else { 2 * ny - 1 - j };
476            for i in 0..px {
477                let si = if i < nx { i } else { 2 * nx - 1 - i };
478                ext[i + j * px + k * px * py] = phase[si + sj * nx + sk * nx * ny];
479            }
480        }
481    }
482
483    // Laplacian is taken on the extended array, so the wrapped differences never straddle
484    // the original boundary — doing it before the extension reintroduces the seam.
485    let d2u = wrapped_laplacian_periodic(&ext, px, py, pz, vsx, vsy, vsz);
486    let solved = solve_poisson_fft(&d2u, px, py, pz, vsx, vsy, vsz);
487
488    let mut result = vec![0.0; n_total];
489    for k in 0..nz {
490        for j in 0..ny {
491            for i in 0..nx {
492                let dst = i + j * nx + k * nx * ny;
493                if mask[dst] != 0 {
494                    result[dst] = solved[i + j * px + k * px * py];
495                }
496            }
497        }
498    }
499    result
500}
501
502/// Laplacian phase unwrapping, **without** background field removal.
503///
504/// Solves the Poisson equation over the whole array under a Neumann (zero normal
505/// derivative) boundary condition, via a DCT-II in place. Nothing is masked out, so field
506/// sources anywhere in the FOV are retained and the harmonic (background) component
507/// survives: the result is an unwrapped **total** field, suitable for a subsequent
508/// background-removal stage.
509///
510/// Use [`laplacian_unwrap_bfr`] if you want unwrapping and background removal together.
511///
512/// Because the whole array participates, this is sensitive to phase quality *outside* the
513/// ROI in a way [`laplacian_unwrap_bfr`] is not. Where the phase outside the object is noise,
514/// or wraps faster than one radian per voxel, prefer ROMEO
515/// ([`super::romeo::unwrap_romeo`]) or the masked variant.
516///
517/// # Arguments
518/// * `phase` - Wrapped phase (nx * ny * nz)
519/// * `mask` - Binary mask (nx * ny * nz); applied to the *output* only
520/// * `grid` - Volume grid (dimensions and voxel sizes)
521///
522/// # Returns
523/// Unwrapped phase, zero outside `mask`.
524///
525/// # References
526/// Schofield, M.A., Zhu, Y. (2003). "Fast phase unwrapping algorithm for interferometric
527/// applications." Optics Letters, 28(14):1194-1196.
528/// <https://doi.org/10.1364/OL.28.001194>
529pub fn laplacian_unwrap(
530    phase: &[f64],
531    mask: &[u8],
532    grid: &Grid,
533) -> Vec<f64> {
534    let (nx, ny, nz) = grid.dims;
535    let (vsx, vsy, vsz) = grid.voxel_size;
536    let d2u = wrapped_laplacian_neumann(phase, nx, ny, nz, vsx, vsy, vsz);
537    let u = solve_poisson_dct(&d2u, nx, ny, nz, vsx, vsy, vsz);
538    u.iter().zip(mask).map(|(&v, &m)| if m != 0 { v } else { 0.0 }).collect()
539}
540
541#[cfg(test)]
542#[allow(deprecated)]
543mod tests {
544    use super::*;
545
546    fn grid(n: usize) -> Grid {
547        Grid::new(n, n, n, 1.0, 1.0, 1.0)
548    }
549
550    #[test]
551    fn test_wrap() {
552        assert!((wrap(0.0) - 0.0).abs() < 1e-10);
553        assert!((wrap(PI) - PI).abs() < 1e-10);
554        assert!((wrap(-PI) - (-PI)).abs() < 1e-10);
555        assert!((wrap(2.0 * PI) - 0.0).abs() < 1e-10);
556        assert!((wrap(3.0 * PI) - PI).abs() < 1e-10);
557        assert!((wrap(-3.0 * PI) - (-PI)).abs() < 1e-10);
558    }
559
560    #[test]
561    fn test_laplacian_unwrap_bfr_constant() {
562        let n = 8;
563        let phase = vec![1.0; n * n * n];
564        let mask = vec![1u8; n * n * n];
565
566        let unwrapped = laplacian_unwrap_bfr(&phase, &mask, &grid(n));
567
568        let mean: f64 = unwrapped.iter().sum::<f64>() / (n * n * n) as f64;
569        for &val in unwrapped.iter() {
570            assert!((val - mean).abs() < 1e-6, "Constant phase should unwrap to constant");
571        }
572    }
573
574    #[test]
575    fn test_laplacian_unwrap_bfr_smooth() {
576        let n = 16;
577        let mut phase = vec![0.0; n * n * n];
578        let mask = vec![1u8; n * n * n];
579
580        for k in 0..n {
581            for j in 0..n {
582                for i in 0..n {
583                    let idx = i + j * n + k * n * n;
584                    phase[idx] = 0.5 * (2.0 * PI * i as f64 / n as f64).sin();
585                }
586            }
587        }
588
589        let unwrapped = laplacian_unwrap_bfr(&phase, &mask, &grid(n));
590
591        // This function solves under a Dirichlet condition on the ROI, so it does *not*
592        // round-trip its input — the harmonic part that would be needed to match at the
593        // boundary is exactly what it removes. What must hold is that the output is finite,
594        // stays bounded by the input, and is zero where the ROI is not.
595        //
596        // The original assertion here was `< 1.0` against a signal of amplitude 0.5, which
597        // passed for an all-zero output. `laplacian_unwrap_bfr_removes_the_harmonic_component`
598        // is the test that pins what this function actually does.
599        assert!(unwrapped.iter().all(|v| v.is_finite()), "output must be finite");
600        let peak = unwrapped.iter().fold(0.0f64, |m, &v| m.max(v.abs()));
601        assert!(peak <= 0.5 + 1e-6, "output should not exceed the input amplitude, got {peak}");
602    }
603
604    /// Pearson r and the slope of `want` regressed on `obs`, inside `mask`.
605    fn corr_slope(obs: &[f64], want: &[f64], mask: &[u8]) -> (f64, f64) {
606        let (mut sa, mut sb, mut n) = (0.0, 0.0, 0usize);
607        for i in 0..obs.len() {
608            if mask[i] != 0 { sa += obs[i]; sb += want[i]; n += 1; }
609        }
610        let nf = n as f64;
611        let (ma, mb) = (sa / nf, sb / nf);
612        let (mut cov, mut va, mut vb) = (0.0, 0.0, 0.0);
613        for i in 0..obs.len() {
614            if mask[i] == 0 { continue; }
615            let (da, db) = (obs[i] - ma, want[i] - mb);
616            cov += da * db;
617            va += da * da;
618            vb += db * db;
619        }
620        (cov / (va.sqrt() * vb.sqrt()), cov / va)
621    }
622
623    /// A field split into a harmonic part (a linear ramp, ∇² = 0) and a non-harmonic part
624    /// (a Gaussian blob), wrapped hard enough that unwrapping is doing real work.
625    /// Returns (wrapped, truth, ramp, blob, mask).
626    fn ramp_plus_blob(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>) {
627        let c = n as f64 / 2.0;
628        let total = n * n * n;
629        let (mut truth, mut ramp, mut blob) = (vec![0.0; total], vec![0.0; total], vec![0.0; total]);
630        let mut mask = vec![0u8; total];
631        for k in 0..n {
632            for j in 0..n {
633                for i in 0..n {
634                    let idx = i + j * n + k * n * n;
635                    let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
636                    let r = (x * x + y * y + z * z).sqrt();
637                    ramp[idx] = 0.35 * x + 0.20 * y + 0.15 * z;
638                    blob[idx] = 12.0 * (-(r * r) / (2.0 * 8.0f64.powi(2))).exp();
639                    truth[idx] = ramp[idx] + blob[idx];
640                    if r < 22.0 { mask[idx] = 1; }
641                }
642            }
643        }
644        let wrapped: Vec<f64> = truth.iter().map(|&v| wrap(v)).collect();
645        (wrapped, truth, ramp, blob, mask)
646    }
647
648    #[test]
649    fn laplacian_unwrap_recovers_the_whole_field_including_background() {
650        let n = 64;
651        let (wrapped, truth, _, _, mask) = ramp_plus_blob(n);
652
653        let out = laplacian_unwrap(&wrapped, &mask, &grid(n));
654        let (r, slope) = corr_slope(&out, &truth, &mask);
655
656        assert!(r > 0.99, "should recover the total field, got r = {r}");
657        assert!((slope - 1.0).abs() < 0.05, "expected unit slope, got {slope}");
658    }
659
660    #[test]
661    fn laplacian_unwrap_bfr_removes_the_harmonic_component() {
662        // Pins the documented contract: laplacian_unwrap_bfr is unwrapping + background
663        // removal. It reproduces the non-harmonic field faithfully and returns the
664        // harmonic (background) component as zero, because ∇²(harmonic) = 0 leaves no
665        // trace in the input to the Poisson solve.
666        //
667        // If the harmonic assertion starts failing, the limitation was lifted — update
668        // the module docs and the README categorisation along with it.
669        let n = 64;
670        let (wrapped, _, ramp, blob, mask) = ramp_plus_blob(n);
671
672        let out = laplacian_unwrap_bfr(&wrapped, &mask, &grid(n));
673
674        let (r_blob, slope_blob) = corr_slope(&out, &blob, &mask);
675        assert!(r_blob > 0.99, "non-harmonic part should survive, got r = {r_blob}");
676        assert!((slope_blob - 1.0).abs() < 0.05, "expected unit slope, got {slope_blob}");
677
678        let (r_ramp, _) = corr_slope(&out, &ramp, &mask);
679        assert!(r_ramp.abs() < 0.05, "harmonic part should be discarded, got r = {r_ramp}");
680    }
681
682    #[test]
683    fn dct_solve_matches_the_even_extended_solve() {
684        // The DCT-II is the FFT of the even extension, so these are the same computation
685        // with and without materialising the extension. Agreement should be to rounding.
686        let n = 64;
687        let (wrapped, _, _, _, mask) = ramp_plus_blob(n);
688        let padded = laplacian_unwrap_even_extended_reference(&wrapped, &mask, &grid(n));
689        let dct = laplacian_unwrap(&wrapped, &mask, &grid(n));
690        let scale = padded.iter().fold(0.0f64, |m, &v| m.max(v.abs()));
691        let max_diff = padded.iter().zip(&dct).fold(0.0f64, |m, (&a, &b)| m.max((a - b).abs()));
692        assert!(max_diff < 1e-9 * scale, "DCT and padded solves differ: max |diff| = {max_diff} (scale {scale})");
693    }
694
695    #[test]
696    fn the_two_variants_disagree_by_the_background_field() {
697        // The difference between them is the harmonic component, which is what makes
698        // them different algorithms rather than two settings of one.
699        let n = 64;
700        let (wrapped, _, ramp, _, mask) = ramp_plus_blob(n);
701
702        let pure = laplacian_unwrap(&wrapped, &mask, &grid(n));
703        let combined = laplacian_unwrap_bfr(&wrapped, &mask, &grid(n));
704        let diff: Vec<f64> = pure.iter().zip(&combined).map(|(a, b)| a - b).collect();
705
706        let (r, _) = corr_slope(&diff, &ramp, &mask);
707        assert!(r > 0.95, "their difference should be the harmonic field, got r = {r}");
708    }
709
710    #[test]
711    fn test_laplacian_unwrap_bfr_finite() {
712        let n = 8;
713        let phase: Vec<f64> = (0..n*n*n).map(|i| wrap((i as f64) * 0.1)).collect();
714        let mask = vec![1u8; n * n * n];
715
716        let unwrapped = laplacian_unwrap_bfr(&phase, &mask, &grid(n));
717
718        for (i, &val) in unwrapped.iter().enumerate() {
719            assert!(val.is_finite(), "Unwrapped phase should be finite at index {}", i);
720        }
721    }
722}