Skip to main content

qsm_core/bgremove/
iharperella.rs

1//! HARPERELLA and iHARPERELLA — integrated phase unwrapping and background field removal
2//!
3//! Both algorithms simultaneously unwrap phase and remove background field by
4//! estimating the phase Laplacian outside the brain. The background phase is
5//! harmonic inside the brain (nabla^2 phi_bg = 0), so the wrapped Laplacian inside
6//! the brain contains only tissue sources.
7//!
8//! **HARPERELLA** estimates the exterior Laplacian by making the SMV of the total
9//! Laplacian uniform across the FOV (Eq [3] in the paper):
10//!   min ||S(nabla^2 phi_E) + S(nabla^2 phi_brain) - delta||_2
11//!
12//! **iHARPERELLA** instead directly minimizes the weighted resulting phase
13//! (Eq [3] in the ISMRM abstract):
14//!   min ||W * inv_lap(nabla^2 phi_brain + nabla^2 phi_out)||_2
15//! providing more robust low-frequency background suppression.
16//!
17//! References:
18//! - HARPERELLA: Li, W., et al. (2014). "Integrated Laplacian-based phase
19//!   unwrapping and background phase removal for quantitative susceptibility
20//!   mapping." NMR in Biomedicine, 27(2):219-227. doi:10.1002/nbm.3056
21//! - iHARPERELLA: Li, W., Wu, B., Liu, C. (2015). "iHARPERELLA: an improved
22//!   method for integrated 3D phase unwrapping and background phase removal."
23//!   Proc. ISMRM 23, p.3313.
24
25use num_complex::Complex64;
26use crate::Grid;
27use crate::fft::{fft3d, ifft3d};
28use crate::kernels::smv::smv_kernel;
29use crate::unwrap::laplacian::wrapped_laplacian_periodic;
30
31/// HARPERELLA / iHARPERELLA algorithm parameters
32#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
33#[derive(Clone, Debug)]
34pub struct HarperellaParams {
35    /// SMV kernel radius in mm (paper uses 10mm for in vivo)
36    pub radius: f64,
37    /// Maximum CG iterations for exterior Laplacian estimation
38    pub max_iter: usize,
39    /// CG convergence tolerance
40    pub tol: f64,
41}
42
43/// Type alias for backwards compatibility
44pub type IharperellaParams = HarperellaParams;
45
46impl Default for HarperellaParams {
47    fn default() -> Self {
48        Self {
49            radius: 10.0,
50            max_iter: 40,
51            tol: 1e-6,
52        }
53    }
54}
55
56// =============================================================================
57// HARPERELLA (Li et al., NMR Biomed 2014)
58// Exterior estimation via SMV uniformity in Laplacian domain
59// =============================================================================
60
61/// HARPERELLA — integrated phase unwrapping and background removal
62///
63/// Estimates exterior Laplacian by enforcing uniform SMV across the FOV.
64///
65/// # Arguments
66/// * `phase` - Wrapped phase in radians (nx * ny * nz)
67/// * `mask` - Binary brain mask (nx * ny * nz), 1 = inside
68/// * `grid` - Volume dimensions and voxel sizes
69/// * `params` - Algorithm parameters
70/// * `progress` - Progress callback (iteration, max_iter)
71///
72/// # Returns
73/// (tissue_phase, mask) — unwrapped background-free tissue phase and brain mask
74pub fn harperella(
75    phase: &[f64], mask: &[u8],
76    grid: &Grid,
77    params: &HarperellaParams,
78    mut progress: impl FnMut(usize, usize),
79) -> (Vec<f64>, Vec<u8>) {
80    let (nx, ny, nz) = grid.dims;
81    let (vsx, vsy, vsz) = grid.voxel_size;
82    let n_total = nx * ny * nz;
83    let (lap_brain, interior_mask, exterior_mask) =
84        prepare_laplacian(phase, mask, nx, ny, nz, vsx, vsy, vsz);
85
86    // SMV kernel
87    let s_fft = compute_smv_fft(nx, ny, nz, vsx, vsy, vsz, params.radius);
88
89    // Compute delta = mean of S(nabla^2 phi) over trustable interior I (Eq [4])
90    let smv_lap = apply_smv(&lap_brain, &s_fft, nx, ny, nz);
91    let mut delta_sum = 0.0;
92    let mut delta_count = 0usize;
93    for i in 0..n_total {
94        if interior_mask[i] == 1 {
95            delta_sum += smv_lap[i];
96            delta_count += 1;
97        }
98    }
99    let delta_val = if delta_count > 0 { delta_sum / delta_count as f64 } else { 0.0 };
100
101    // Solve: min ||S(nabla^2 phi_E) + S(nabla^2 phi_brain) - delta||_2  (Eq [3])
102    // Normal equations: ext * S(S(ext * x)) = ext * S(delta - S(lap_brain))
103    let rhs: Vec<f64> = (0..n_total).map(|i| delta_val - smv_lap[i]).collect();
104    let smv_rhs = apply_smv(&rhs, &s_fft, nx, ny, nz);
105    let atb: Vec<f64> = (0..n_total).map(|i| exterior_mask[i] * smv_rhs[i]).collect();
106
107    let lap_ext = cg_solve_masked(
108        &atb, &exterior_mask, params.max_iter, params.tol, &mut progress,
109        |x| {
110            let masked: Vec<f64> = (0..n_total).map(|i| exterior_mask[i] * x[i]).collect();
111            let sx = apply_smv(&masked, &s_fft, nx, ny, nz);
112            let ssx = apply_smv(&sx, &s_fft, nx, ny, nz);
113            (0..n_total).map(|i| exterior_mask[i] * ssx[i]).collect()
114        },
115    );
116
117    // Combine and inverse Laplacian
118    let mut lap_fov: Vec<f64> = lap_brain;
119    for i in 0..n_total { lap_fov[i] += exterior_mask[i] * lap_ext[i]; }
120    let tissue_phase = solve_poisson(&lap_fov, nx, ny, nz, vsx, vsy, vsz);
121
122    let result: Vec<f64> = tissue_phase.iter().enumerate()
123        .map(|(i, &v)| if mask[i] != 0 { v } else { 0.0 }).collect();
124    (result, mask.to_vec())
125}
126
127// =============================================================================
128// iHARPERELLA (Li et al., ISMRM 2015 #3313)
129// Exterior estimation via direct phase minimization
130// =============================================================================
131
132/// iHARPERELLA — improved integrated phase unwrapping and background removal
133///
134/// More robust low-frequency background suppression than HARPERELLA.
135/// Estimates exterior Laplacian by directly minimizing the weighted resulting
136/// phase rather than enforcing SMV uniformity.
137///
138/// # Arguments
139/// * `phase` - Wrapped phase in radians (nx * ny * nz)
140/// * `mask` - Binary brain mask (nx * ny * nz), 1 = inside
141/// * `grid` - Volume dimensions and voxel sizes
142/// * `params` - Algorithm parameters (radius is unused in iHARPERELLA)
143/// * `progress` - Progress callback (iteration, max_iter)
144///
145/// # Returns
146/// (tissue_phase, mask)
147pub fn iharperella(
148    phase: &[f64], mask: &[u8],
149    grid: &Grid,
150    params: &HarperellaParams,
151    progress: impl FnMut(usize, usize),
152) -> (Vec<f64>, Vec<u8>) {
153    let w_brain: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
154    iharperella_with_weights(phase, mask, grid,
155                             params.max_iter, params.tol, &w_brain, progress)
156}
157
158/// iHARPERELLA with custom W_Brain weighting
159///
160/// Allows specifying an arbitrary weighting function for the phase minimization.
161/// The weighting should be non-negative and defined over the full volume (nx*ny*nz).
162///
163/// # Arguments
164/// * `phase` - Wrapped phase in radians (nx * ny * nz)
165/// * `mask` - Binary brain mask (nx * ny * nz), 1 = inside
166/// * `grid` - Volume dimensions and voxel sizes
167/// * `max_iter` - Maximum CG iterations
168/// * `tol` - CG convergence tolerance
169/// * `w_brain` - Weighting function for phase minimization
170/// * `progress` - Progress callback (iteration, max_iter)
171///
172/// # Returns
173/// (tissue_phase, mask)
174pub fn iharperella_with_weights(
175    phase: &[f64], mask: &[u8],
176    grid: &Grid,
177    max_iter: usize, tol: f64,
178    w_brain: &[f64],
179    mut progress: impl FnMut(usize, usize),
180) -> (Vec<f64>, Vec<u8>) {
181    let (nx, ny, nz) = grid.dims;
182    let (vsx, vsy, vsz) = grid.voxel_size;
183    let n_total = nx * ny * nz;
184    let (lap_brain, _interior_mask, exterior_mask) =
185        prepare_laplacian(phase, mask, nx, ny, nz, vsx, vsy, vsz);
186
187    // Pre-compute Laplacian eigenvalues for inverse Laplacian
188    let lap_eig = compute_laplacian_eigenvalues(nx, ny, nz, vsx, vsy, vsz);
189
190    // Solve: min ||W * inv_lap(nabla^2 phi_brain + nabla^2 phi_out * (1-M))||_2  (Eq [3])
191    //
192    // A(x) = W * inv_lap(x * (1-M))
193    // A'(y) = (1-M) * inv_lap(W * y)    [inv_lap is self-adjoint]
194    // b = -W * inv_lap(nabla^2 phi_brain)
195    //
196    // Normal equations: A'A(x) = A'(-b)
197    // (1-M) * inv_lap(W^2 * inv_lap((1-M) * x)) = (1-M) * inv_lap(W^2 * inv_lap(nabla^2 phi_brain))
198
199    // Compute -A'c = -(1-M) * inv_lap(W^2 * inv_lap(nabla^2 phi_brain))
200    // The objective is min ||Ax + c||_2 where c = W * inv_lap(nabla^2 phi_brain * M)
201    // Normal equations: A'Ax = -A'c
202    let phase_from_brain = apply_inv_lap(&lap_brain, &lap_eig, nx, ny, nz);
203    let w2_phase: Vec<f64> = (0..n_total).map(|i| w_brain[i] * w_brain[i] * phase_from_brain[i]).collect();
204    let atb_raw = apply_inv_lap(&w2_phase, &lap_eig, nx, ny, nz);
205    let atb: Vec<f64> = (0..n_total).map(|i| -exterior_mask[i] * atb_raw[i]).collect();
206
207    let lap_ext = cg_solve_masked(
208        &atb, &exterior_mask, max_iter, tol, &mut progress,
209        |x| {
210            // A'A(x) = (1-M) * inv_lap(W^2 * inv_lap((1-M) * x))
211            let masked: Vec<f64> = (0..n_total).map(|i| exterior_mask[i] * x[i]).collect();
212            let inv1 = apply_inv_lap(&masked, &lap_eig, nx, ny, nz);
213            let w2_inv1: Vec<f64> = (0..n_total).map(|i| w_brain[i] * w_brain[i] * inv1[i]).collect();
214            let inv2 = apply_inv_lap(&w2_inv1, &lap_eig, nx, ny, nz);
215            (0..n_total).map(|i| exterior_mask[i] * inv2[i]).collect()
216        },
217    );
218
219    // Combine and inverse Laplacian
220    let mut lap_fov = lap_brain;
221    for i in 0..n_total { lap_fov[i] += exterior_mask[i] * lap_ext[i]; }
222    let tissue_phase = solve_poisson(&lap_fov, nx, ny, nz, vsx, vsy, vsz);
223
224    let result: Vec<f64> = tissue_phase.iter().enumerate()
225        .map(|(i, &v)| if mask[i] != 0 { v } else { 0.0 }).collect();
226    (result, mask.to_vec())
227}
228
229// =============================================================================
230// Shared infrastructure
231// =============================================================================
232
233/// Compute wrapped Laplacian, erode mask, prepare interior/exterior masks
234fn prepare_laplacian(
235    phase: &[f64], mask: &[u8],
236    nx: usize, ny: usize, nz: usize,
237    vsx: f64, vsy: f64, vsz: f64,
238) -> (Vec<f64>, Vec<u8>, Vec<f64>) {
239    let n_total = nx * ny * nz;
240
241    // Wrapped Laplacian (Eq [1])
242    let lap = wrapped_laplacian_periodic(phase, nx, ny, nz, vsx, vsy, vsz);
243
244    // Erode mask by ~3 voxels
245    let erode_radius = 3.0 * vsx.min(vsy).min(vsz);
246    let erode_fft = compute_smv_fft(nx, ny, nz, vsx, vsy, vsz, erode_radius);
247
248    let mask_f64: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
249    let mut mask_conv: Vec<Complex64> = mask_f64.iter()
250        .map(|&x| Complex64::new(x, 0.0)).collect();
251    fft3d(&mut mask_conv, nx, ny, nz);
252    for i in 0..n_total { mask_conv[i] *= erode_fft[i]; }
253    ifft3d(&mut mask_conv, nx, ny, nz);
254
255    let delta_thresh = 1.0 - 1e-7_f64.sqrt();
256    let interior_mask: Vec<u8> = mask_conv.iter()
257        .map(|c| if c.re > delta_thresh { 1 } else { 0 }).collect();
258    let exterior_mask: Vec<f64> = mask.iter()
259        .map(|&m| if m == 0 { 1.0 } else { 0.0 }).collect();
260
261    // Keep Laplacian only at trustable interior voxels
262    let mut lap_brain = vec![0.0; n_total];
263    for i in 0..n_total {
264        if interior_mask[i] == 1 { lap_brain[i] = lap[i]; }
265    }
266
267    (lap_brain, interior_mask, exterior_mask)
268}
269
270/// Compute FFT of SMV kernel (returns real part, since kernel is symmetric)
271fn compute_smv_fft(
272    nx: usize, ny: usize, nz: usize,
273    vsx: f64, vsy: f64, vsz: f64, radius: f64,
274) -> Vec<f64> {
275    let grid = Grid::new(nx, ny, nz, vsx, vsy, vsz);
276    let s_kernel = smv_kernel(&grid, radius);
277    let mut c: Vec<Complex64> = s_kernel.iter()
278        .map(|&x| Complex64::new(x, 0.0)).collect();
279    fft3d(&mut c, nx, ny, nz);
280    c.iter().map(|v| v.re).collect()
281}
282
283/// Apply SMV filter in k-space: S(x) = ifft(s_fft * fft(x))
284fn apply_smv(x: &[f64], s_fft: &[f64], nx: usize, ny: usize, nz: usize) -> Vec<f64> {
285    let n_total = nx * ny * nz;
286    let mut c: Vec<Complex64> = x.iter()
287        .map(|&v| Complex64::new(v, 0.0)).collect();
288    fft3d(&mut c, nx, ny, nz);
289    for i in 0..n_total { c[i] *= s_fft[i]; }
290    ifft3d(&mut c, nx, ny, nz);
291    c.iter().map(|v| v.re).collect()
292}
293
294/// Compute discrete Laplacian eigenvalues for inverse Laplacian
295fn compute_laplacian_eigenvalues(
296    nx: usize, ny: usize, nz: usize,
297    vsx: f64, vsy: f64, vsz: f64,
298) -> Vec<f64> {
299    use std::f64::consts::PI;
300    let n_total = nx * ny * nz;
301    let idx2 = 1.0 / (vsx * vsx);
302    let idy2 = 1.0 / (vsy * vsy);
303    let idz2 = 1.0 / (vsz * vsz);
304    let mut eig = vec![0.0; n_total];
305
306    for k in 0..nz {
307        let fk = if k <= nz / 2 { k as f64 / nz as f64 } else { (k as f64 - nz as f64) / nz as f64 };
308        let lz = 2.0 * ((2.0 * PI * fk).cos() - 1.0) * idz2;
309        for j in 0..ny {
310            let fj = if j <= ny / 2 { j as f64 / ny as f64 } else { (j as f64 - ny as f64) / ny as f64 };
311            let ly = 2.0 * ((2.0 * PI * fj).cos() - 1.0) * idy2;
312            for i in 0..nx {
313                let fi = if i <= nx / 2 { i as f64 / nx as f64 } else { (i as f64 - nx as f64) / nx as f64 };
314                let lx = 2.0 * ((2.0 * PI * fi).cos() - 1.0) * idx2;
315                eig[i + j * nx + k * nx * ny] = lx + ly + lz;
316            }
317        }
318    }
319    eig
320}
321
322/// Apply inverse Laplacian using pre-computed eigenvalues
323fn apply_inv_lap(f: &[f64], eig: &[f64], nx: usize, ny: usize, nz: usize) -> Vec<f64> {
324    let n_total = nx * ny * nz;
325    let mut c: Vec<Complex64> = f.iter()
326        .map(|&x| Complex64::new(x, 0.0)).collect();
327    fft3d(&mut c, nx, ny, nz);
328    for i in 0..n_total {
329        if eig[i].abs() > 1e-20 {
330            c[i] /= eig[i];
331        } else {
332            c[i] = Complex64::new(0.0, 0.0);
333        }
334    }
335    ifft3d(&mut c, nx, ny, nz);
336    c.iter().map(|v| v.re).collect()
337}
338
339/// Solve Poisson equation via FFT: nabla^2 u = f -> u
340fn solve_poisson(
341    f: &[f64], nx: usize, ny: usize, nz: usize,
342    vsx: f64, vsy: f64, vsz: f64,
343) -> Vec<f64> {
344    let eig = compute_laplacian_eigenvalues(nx, ny, nz, vsx, vsy, vsz);
345    apply_inv_lap(f, &eig, nx, ny, nz)
346}
347
348/// Generic CG solver for masked normal equations
349fn cg_solve_masked<F, Op>(
350    b: &[f64],
351    _mask: &[f64],
352    max_iter: usize,
353    tol: f64,
354    callback: &mut F,
355    apply_ata: Op,
356) -> Vec<f64>
357where
358    F: FnMut(usize, usize),
359    Op: Fn(&[f64]) -> Vec<f64>,
360{
361    let n = b.len();
362    let mut x = vec![0.0; n];
363    let mut r = b.to_vec();
364    let mut p = r.clone();
365    let mut rsold: f64 = r.iter().map(|&v| v * v).sum();
366    let b_norm: f64 = b.iter().map(|&v| v * v).sum::<f64>().sqrt();
367
368    if b_norm < 1e-20 { return x; }
369
370    for iter in 0..max_iter {
371        callback(iter + 1, max_iter);
372
373        let ap = apply_ata(&p);
374        let pap: f64 = p.iter().zip(ap.iter()).map(|(&pi, &api)| pi * api).sum();
375
376        if pap.abs() < 1e-20 { break; }
377
378        let alpha = rsold / pap;
379        for i in 0..n {
380            x[i] += alpha * p[i];
381            r[i] -= alpha * ap[i];
382        }
383
384        let rsnew: f64 = r.iter().map(|&v| v * v).sum();
385        if rsnew.sqrt() < tol * b_norm { break; }
386
387        let beta = rsnew / rsold;
388        for i in 0..n { p[i] = r[i] + beta * p[i]; }
389        rsold = rsnew;
390    }
391    x
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::unwrap::laplacian::wrap;
398
399    #[test]
400    fn test_harperella_zero_phase() {
401        let n = 16;
402        let phase = vec![0.0; n * n * n];
403        let mask = vec![1u8; n * n * n];
404        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
405        let params = HarperellaParams { radius: 2.0, max_iter: 10, tol: 1e-6 };
406        let (tissue, _) = harperella(&phase, &mask, &grid, &params, |_, _| {});
407        for &val in tissue.iter() {
408            assert!(val.abs() < 1e-8, "Zero phase should give zero tissue phase, got {}", val);
409        }
410    }
411
412    #[test]
413    fn test_harperella_finite() {
414        let n = 16;
415        let phase: Vec<f64> = (0..n*n*n).map(|i| wrap((i as f64) * 0.1)).collect();
416        let mask = vec![1u8; n * n * n];
417        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
418        let params = HarperellaParams { radius: 2.0, max_iter: 10, tol: 1e-6 };
419        let (tissue, _) = harperella(&phase, &mask, &grid, &params, |_, _| {});
420        for (i, &val) in tissue.iter().enumerate() {
421            assert!(val.is_finite(), "Tissue phase should be finite at index {}", i);
422        }
423    }
424
425    #[test]
426    fn test_iharperella_zero_phase() {
427        let n = 16;
428        let phase = vec![0.0; n * n * n];
429        let mask = vec![1u8; n * n * n];
430        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
431        let params = HarperellaParams { radius: 2.0, max_iter: 10, tol: 1e-6 };
432        let (tissue, _) = iharperella(&phase, &mask, &grid, &params, |_, _| {});
433        for &val in tissue.iter() {
434            assert!(val.abs() < 1e-8, "Zero phase should give zero tissue phase, got {}", val);
435        }
436    }
437
438    #[test]
439    fn test_iharperella_finite() {
440        let n = 16;
441        let phase: Vec<f64> = (0..n*n*n).map(|i| wrap((i as f64) * 0.1)).collect();
442        let mask = vec![1u8; n * n * n];
443        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
444        let params = HarperellaParams { radius: 2.0, max_iter: 10, tol: 1e-6 };
445        let (tissue, _) = iharperella(&phase, &mask, &grid, &params, |_, _| {});
446        for (i, &val) in tissue.iter().enumerate() {
447            assert!(val.is_finite(), "Tissue phase should be finite at index {}", i);
448        }
449    }
450}