Skip to main content

qsm_core/unwrap/
laplacian.rs

1//! Laplacian-based phase unwrapping
2//!
3//! Uses the Laplacian operator to unwrap phase without path dependence.
4//! The wrapped phase Laplacian equals the true Laplacian, so we can
5//! recover the true phase by solving a Poisson equation.
6//!
7//! Reference:
8//! Schofield, M.A., Zhu, Y. (2003). "Fast phase unwrapping algorithm for
9//! interferometric applications." Optics Letters, 28(14):1194-1196.
10//! https://doi.org/10.1364/OL.28.001194
11//!
12//! Reference implementation: https://github.com/kamesy/QSM.jl
13
14use std::f64::consts::PI;
15use num_complex::Complex64;
16use crate::fft::{fft3d, ifft3d};
17
18/// Wrap angle to [-π, π]
19#[inline]
20fn wrap(x: f64) -> f64 {
21    let mut y = x % (2.0 * PI);
22    if y > PI {
23        y -= 2.0 * PI;
24    } else if y < -PI {
25        y += 2.0 * PI;
26    }
27    y
28}
29
30/// Compute wrapped Laplacian of phase with periodic boundary conditions
31///
32/// Uses second-order central finite differences on wrapped phase differences.
33fn wrapped_laplacian_periodic(
34    phase: &[f64],
35    nx: usize, ny: usize, nz: usize,
36    vsx: f64, vsy: f64, vsz: f64,
37) -> Vec<f64> {
38    let n_total = nx * ny * nz;
39    let mut d2u = vec![0.0; n_total];
40
41    let dx2 = 1.0 / (vsx * vsx);
42    let dy2 = 1.0 / (vsy * vsy);
43    let dz2 = 1.0 / (vsz * vsz);
44
45    // Fortran order: index = i + j*nx + k*nx*ny
46    for k in 0..nz {
47        let km1 = if k == 0 { nz - 1 } else { k - 1 };
48        let kp1 = if k + 1 >= nz { 0 } else { k + 1 };
49
50        for j in 0..ny {
51            let jm1 = if j == 0 { ny - 1 } else { j - 1 };
52            let jp1 = if j + 1 >= ny { 0 } else { j + 1 };
53
54            for i in 0..nx {
55                let im1 = if i == 0 { nx - 1 } else { i - 1 };
56                let ip1 = if i + 1 >= nx { 0 } else { i + 1 };
57
58                let idx = i + j * nx + k * nx * ny;
59                let u_ijk = phase[idx];
60
61                // Indices for neighbors
62                let idx_im1 = im1 + j * nx + k * nx * ny;
63                let idx_ip1 = ip1 + j * nx + k * nx * ny;
64                let idx_jm1 = i + jm1 * nx + k * nx * ny;
65                let idx_jp1 = i + jp1 * nx + k * nx * ny;
66                let idx_km1 = i + j * nx + km1 * nx * ny;
67                let idx_kp1 = i + j * nx + kp1 * nx * ny;
68
69                // Wrapped Laplacian using central differences
70                // Δu = (u[i+1] - 2*u[i] + u[i-1]) / dx²
71                // But we wrap the differences: wrap(u[i+1] - u[i]) - wrap(u[i] - u[i-1])
72                let lap_x = (wrap(phase[idx_ip1] - u_ijk) - wrap(u_ijk - phase[idx_im1])) * dx2;
73                let lap_y = (wrap(phase[idx_jp1] - u_ijk) - wrap(u_ijk - phase[idx_jm1])) * dy2;
74                let lap_z = (wrap(phase[idx_kp1] - u_ijk) - wrap(u_ijk - phase[idx_km1])) * dz2;
75
76                d2u[idx] = lap_x + lap_y + lap_z;
77            }
78        }
79    }
80
81    d2u
82}
83
84/// Solve Poisson equation using FFT (periodic boundary conditions)
85///
86/// Solves: ∇²u = f
87/// In Fourier domain: λ * û = f̂, where λ are eigenvalues of discrete Laplacian
88fn solve_poisson_fft(
89    f: &[f64],
90    nx: usize, ny: usize, nz: usize,
91    vsx: f64, vsy: f64, vsz: f64,
92) -> Vec<f64> {
93    let n_total = nx * ny * nz;
94
95    // FFT of RHS
96    let mut f_complex: Vec<Complex64> = f.iter()
97        .map(|&x| Complex64::new(x, 0.0))
98        .collect();
99    fft3d(&mut f_complex, nx, ny, nz);
100
101    let idx2 = 1.0 / (vsx * vsx);
102    let idy2 = 1.0 / (vsy * vsy);
103    let idz2 = 1.0 / (vsz * vsz);
104
105    // Divide by eigenvalues of discrete Laplacian
106    // λ[i,j,k] = 2*(cos(2πi/nx)-1)/dx² + 2*(cos(2πj/ny)-1)/dy² + 2*(cos(2πk/nz)-1)/dz²
107    for k in 0..nz {
108        let fk = if k <= nz / 2 { k as f64 / nz as f64 } else { (k as f64 - nz as f64) / nz as f64 };
109        let lam_z = 2.0 * ((2.0 * PI * fk).cos() - 1.0) * idz2;
110
111        for j in 0..ny {
112            let fj = if j <= ny / 2 { j as f64 / ny as f64 } else { (j as f64 - ny as f64) / ny as f64 };
113            let lam_y = 2.0 * ((2.0 * PI * fj).cos() - 1.0) * idy2;
114
115            for i in 0..nx {
116                let fi = if i <= nx / 2 { i as f64 / nx as f64 } else { (i as f64 - nx as f64) / nx as f64 };
117                let lam_x = 2.0 * ((2.0 * PI * fi).cos() - 1.0) * idx2;
118
119                let lam = lam_x + lam_y + lam_z;
120                let idx = i + j * nx + k * nx * ny;
121
122                if lam.abs() > 1e-20 {
123                    f_complex[idx] /= lam;
124                } else {
125                    // DC component - set to zero (solution is unique up to a constant)
126                    f_complex[idx] = Complex64::new(0.0, 0.0);
127                }
128            }
129        }
130    }
131
132    // IFFT to get solution
133    ifft3d(&mut f_complex, nx, ny, nz);
134
135    // Extract real part
136    f_complex.iter().map(|c| c.re).collect()
137}
138
139/// Laplacian phase unwrapping
140///
141/// Uses FFT-based Poisson solver with periodic boundary conditions.
142/// Fast and robust but may have issues at mask boundaries.
143///
144/// # Arguments
145/// * `phase` - Wrapped phase (nx * ny * nz)
146/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
147/// * `nx`, `ny`, `nz` - Array dimensions
148/// * `vsx`, `vsy`, `vsz` - Voxel sizes in mm
149///
150/// # Returns
151/// Unwrapped phase
152pub fn laplacian_unwrap(
153    phase: &[f64],
154    mask: &[u8],
155    nx: usize, ny: usize, nz: usize,
156    vsx: f64, vsy: f64, vsz: f64,
157) -> Vec<f64> {
158    let n_total = nx * ny * nz;
159
160    // Step 1: Compute wrapped Laplacian
161    let d2u = wrapped_laplacian_periodic(phase, nx, ny, nz, vsx, vsy, vsz);
162
163    // Step 2: Apply mask to wrapped Laplacian (Dirichlet-like BCs on mask boundary)
164    let d2u_masked: Vec<f64> = d2u.iter()
165        .enumerate()
166        .map(|(i, &val)| if mask[i] != 0 { val } else { 0.0 })
167        .collect();
168
169    // Step 3: Solve Poisson equation
170    let unwrapped = solve_poisson_fft(&d2u_masked, nx, ny, nz, vsx, vsy, vsz);
171
172    // Step 4: Apply mask to result
173    let mut result = vec![0.0; n_total];
174    for i in 0..n_total {
175        if mask[i] != 0 {
176            result[i] = unwrapped[i];
177        }
178    }
179
180    result
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_wrap() {
189        assert!((wrap(0.0) - 0.0).abs() < 1e-10);
190        assert!((wrap(PI) - PI).abs() < 1e-10);
191        assert!((wrap(-PI) - (-PI)).abs() < 1e-10);
192        assert!((wrap(2.0 * PI) - 0.0).abs() < 1e-10);
193        assert!((wrap(3.0 * PI) - PI).abs() < 1e-10);
194        assert!((wrap(-3.0 * PI) - (-PI)).abs() < 1e-10);
195    }
196
197    #[test]
198    fn test_laplacian_unwrap_constant() {
199        // Constant phase should stay constant (up to arbitrary offset)
200        let n = 8;
201        let phase = vec![1.0; n * n * n];
202        let mask = vec![1u8; n * n * n];
203
204        let unwrapped = laplacian_unwrap(&phase, &mask, n, n, n, 1.0, 1.0, 1.0);
205
206        // Check that result is approximately constant
207        let mean: f64 = unwrapped.iter().sum::<f64>() / (n * n * n) as f64;
208        for &val in unwrapped.iter() {
209            assert!((val - mean).abs() < 1e-6, "Constant phase should unwrap to constant");
210        }
211    }
212
213    #[test]
214    fn test_laplacian_unwrap_smooth() {
215        // Smooth phase should remain smooth after unwrapping
216        let n = 16;
217        let mut phase = vec![0.0; n * n * n];
218        let mask = vec![1u8; n * n * n];
219
220        // Create smooth sinusoidal phase (no wraps needed)
221        for k in 0..n {
222            for j in 0..n {
223                for i in 0..n {
224                    let idx = i + j * n + k * n * n;
225                    // Small amplitude so no wrapping occurs
226                    phase[idx] = 0.5 * (2.0 * PI * i as f64 / n as f64).sin();
227                }
228            }
229        }
230
231        let unwrapped = laplacian_unwrap(&phase, &mask, n, n, n, 1.0, 1.0, 1.0);
232
233        // Check that result is finite and similar to input
234        // (since input has no wraps, output should be close)
235        for (i, (&orig, &unwr)) in phase.iter().zip(unwrapped.iter()).enumerate() {
236            assert!(unwr.is_finite(), "Unwrapped should be finite at {}", i);
237            // Allow some deviation due to boundary conditions
238            assert!((orig - unwr).abs() < 1.0,
239                "Unwrapped should be close to original for smooth phase");
240        }
241    }
242
243    #[test]
244    fn test_laplacian_unwrap_finite() {
245        let n = 8;
246        let phase: Vec<f64> = (0..n*n*n).map(|i| wrap((i as f64) * 0.1)).collect();
247        let mask = vec![1u8; n * n * n];
248
249        let unwrapped = laplacian_unwrap(&phase, &mask, n, n, n, 1.0, 1.0, 1.0);
250
251        for (i, &val) in unwrapped.iter().enumerate() {
252            assert!(val.is_finite(), "Unwrapped phase should be finite at index {}", i);
253        }
254    }
255}