Skip to main content

qsm_core/bgremove/
iharperella.rs

1//! HARPERELLA and iHARPERELLA: integrated phase unwrapping and background field removal
2//!
3//! Both methods remove the background field through the Laplacian. The background field is
4//! harmonic inside the brain, so the Laplacian there holds only tissue sources. They estimate
5//! the Laplacian outside the brain, add it to the brain's, and invert the Laplacian over the
6//! whole field of view.
7//!
8//! iHARPERELLA is a port of STI Suite 3.0's `iHARPERELLA` (its protected P-code was recovered by
9//! black-box probing: logging the calls it makes to its helper functions and to MATLAB's
10//! `lsqr`, and probing the `lsqr` operator it builds). HARPERELLA is not in STI Suite 3.0; it
11//! follows the paper, and uses STI's building blocks for what the paper leaves open (see
12//! [`harperella`]).
13//!
14//! ## Building blocks (STI Suite)
15//!
16//! - **Laplacian**: spectral, `L(x) = F⁻¹[k² F(x)]` with `k` in cycles/mm (STI's `fftnc` grid),
17//!   which is `−∇²x / 4π²`. Its inverse divides by `k²` and sets the DC term to zero. The scale
18//!   cancels between the two, so the result is in the input's units.
19//! - **Boundary rim**: brain voxels with more than one voxel outside the brain among the 146
20//!   neighbours of STI's `ball3D(3)` (the offsets with `1 ≤ |d|² ≤ 10` voxels²), i.e.
21//!   `SMVFiltering(mask, 3) < 0.99`. Within the rim, the 20 % of Laplacian values largest in
22//!   magnitude are set to zero (the paper's "large Laplacian values near the boundary of the
23//!   brain ... were considered inaccurate and set to zero"). Outside the brain the Laplacian is
24//!   zero.
25//! - **Solver**: MATLAB's `lsqr` (no preconditioner, zero initial guess) at tolerance `tol`
26//!   for at most `max_iter` iterations; STI passes 0.001 and `niter` (default 40).
27//! - **Padding**: `pad` zero voxels on each side (STI's `padsize`, default 0).
28//!
29//! Past about ten iterations LSQR on these operators amplifies rounding: two implementations
30//! whose residuals agree to 1e-14 for the first ten iterations differ by ~1e-3 by the twelfth.
31//! So results agree with STI Suite to rounding for a few iterations and to a few percent at 40.
32//!
33//! ## Input
34//!
35//! - [`HarperellaInput::FieldPpm`]: an unwrapped total field in ppm, e.g. a multi-echo field
36//!   map. This is what STI's `iHARPERELLA` does with its input: it takes the spectral
37//!   Laplacian of it as it is (it does not unwrap).
38//! - [`HarperellaInput::WrappedPhase`]: one wrapped echo in rad, as in the HARPERELLA paper:
39//!   the Laplacian is `cos φ L(sin φ) − sin φ L(cos φ)` (the paper's Eq. 1) with the spectral
40//!   `L`, and the result is divided by
41//!   [`rad_per_ppm`](crate::inversion::rad_per_ppm)`(te_s, b0_t)`.
42//!
43//! Either way the output is the local field in **ppm**, inside the mask.
44//!
45//! ## Reference pipeline
46//!
47//! - **Input**: STI's help text and `STI_Templates.m` call it on the "raw phase", but probed
48//!   in MATLAB R2023b, STI 3.0's `iHARPERELLA` does not unwrap: on a synthetic head whose
49//!   phase wraps in 59 % of the brain, its output from the wrapped phase correlates −0.03 with
50//!   the true local phase, from the unwrapped phase 0.98. Hence [`HarperellaInput::FieldPpm`].
51//! - **Defaults**: `niter` 40; `padsize` 0 (probed: bitwise equal to `[0 0 0]`, while
52//!   `STI_Templates.m` lists `[12 12 12]`). [`IharperellaParams::default`] matches the function.
53//! - **Chain**: STI Suite 2.01's manual has the GUI combine the Laplacians of all echoes
54//!   ("Combine Laplacian") and then run iHARPERELLA, as the alternative to "Unwrapping" +
55//!   V-SHARP, before iLSQR; the mask is the GUI's magnitude threshold of the 3rd or 4th echo.
56//!   STI Suite 3.0's GUI has no iHARPERELLA button (only the function).
57//!
58//! References:
59//! - HARPERELLA: Li, W., Avram, A. V., Wu, B., Xiao, X., Liu, C. (2014). "Integrated
60//!   Laplacian-based phase unwrapping and background phase removal for quantitative
61//!   susceptibility mapping." NMR in Biomedicine, 27(2):219-227. doi:10.1002/nbm.3056
62//! - iHARPERELLA: Li, W., Wu, B., Liu, C. (2015). "iHARPERELLA: an improved method for
63//!   integrated 3D phase unwrapping and background phase removal." Proc. ISMRM 23, p.3313.
64//!   Code: STI Suite 3.0, `Core_Functions_P/iHARPERELLA.p`.
65
66use num_complex::Complex64;
67use crate::Grid;
68use crate::error::{check_len, InputError};
69use crate::fft::{fft3d, ifft3d};
70use crate::inversion::ilsqr::lsqr_matlab;
71use crate::kernels::smv::smv_kernel;
72use crate::utils::padding::{pad3d, unpad3d};
73
74/// What HARPERELLA / iHARPERELLA start from. See the module docs.
75#[derive(Clone, Copy, Debug)]
76pub enum HarperellaInput<'a> {
77    /// Unwrapped total field in ppm (e.g. a multi-echo field map). Spectral Laplacian.
78    FieldPpm(&'a [f64]),
79    /// One wrapped echo in rad. Laplacian by the paper's Eq. 1; the result is converted to
80    /// ppm with [`rad_per_ppm`](crate::inversion::rad_per_ppm)`(te_s, b0_t)`.
81    WrappedPhase {
82        /// Wrapped phase in rad.
83        phase: &'a [f64],
84        /// Echo time of `phase` in seconds.
85        te_s: f64,
86        /// Field strength in Tesla.
87        b0_t: f64,
88    },
89}
90
91impl HarperellaInput<'_> {
92    /// Check the input against the grid and mask; return the data and, for phase, the
93    /// rad-per-ppm factor the result is divided by.
94    fn data(&self, mask: &[u8], grid: &Grid) -> Result<(&[f64], Option<f64>), InputError> {
95        let n = grid.n_total();
96        check_len("mask", mask.len(), n)?;
97        match *self {
98            HarperellaInput::FieldPpm(field) => {
99                check_len("field", field.len(), n)?;
100                Ok((field, None))
101            }
102            HarperellaInput::WrappedPhase { phase, te_s, b0_t } => {
103                check_len("phase", phase.len(), n)?;
104                if !(te_s.is_finite() && te_s > 0.0 && b0_t.is_finite() && b0_t > 0.0) {
105                    return Err(InputError(format!(
106                        "wrapped-phase input needs a positive echo time and field strength, \
107                         got TE = {te_s} s, B0 = {b0_t} T"
108                    )));
109                }
110                Ok((phase, Some(crate::inversion::rad_per_ppm(te_s, b0_t))))
111            }
112        }
113    }
114}
115
116/// HARPERELLA parameters.
117#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
118#[derive(Clone, Debug, PartialEq)]
119pub struct HarperellaParams {
120    /// SMV kernel radius in mm (the paper uses 10 mm in vivo)
121    pub radius: f64,
122    /// Maximum LSQR iterations for the exterior Laplacian (STI's `niter`, default 40)
123    pub max_iter: usize,
124    /// LSQR tolerance (MATLAB `lsqr`'s `tol`; 0.001 as in STI's iHARPERELLA)
125    pub tol: f64,
126    /// Zero padding in voxels on each side (STI's `padsize`, default 0)
127    pub pad: [usize; 3],
128}
129
130impl Default for HarperellaParams {
131    fn default() -> Self {
132        Self { radius: 10.0, max_iter: 40, tol: 1e-3, pad: [0, 0, 0] }
133    }
134}
135
136/// iHARPERELLA parameters (STI Suite's `iHARPERELLA` options). It has no SMV kernel.
137#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
138#[derive(Clone, Debug, PartialEq)]
139pub struct IharperellaParams {
140    /// Maximum LSQR iterations (STI's `niter`, default 40)
141    pub max_iter: usize,
142    /// LSQR tolerance (STI passes 0.001 to MATLAB's `lsqr`)
143    pub tol: f64,
144    /// Zero padding in voxels on each side (STI's `padsize`, default 0)
145    pub pad: [usize; 3],
146}
147
148impl Default for IharperellaParams {
149    fn default() -> Self {
150        Self { max_iter: 40, tol: 1e-3, pad: [0, 0, 0] }
151    }
152}
153
154/// The padded working volume: data, mask, Laplacian (rim cleaned), and the padded grid.
155struct Work {
156    dims: (usize, usize, usize),
157    vs: (f64, f64, f64),
158    mask: Vec<u8>,
159    k2: Vec<f64>,
160    lap: Vec<f64>,
161    rim: Vec<u8>,
162}
163
164impl Work {
165    fn new(data: &[f64], mask: &[u8], grid: &Grid, pad: [usize; 3], wrapped: bool) -> Self {
166        let (nx, ny, nz) = grid.dims;
167        let dims = (nx + 2 * pad[0], ny + 2 * pad[1], nz + 2 * pad[2]);
168        let vs = grid.voxel_size;
169        let pd = pad_centred(data, grid.dims, dims, pad);
170        let pm = pad_centred(mask, grid.dims, dims, pad);
171        let k2 = k2_kernel(dims, vs);
172        let mut lap = if wrapped {
173            let (sin, cos): (Vec<f64>, Vec<f64>) = pd.iter().map(|p| p.sin_cos()).unzip();
174            let ls = apply_kernel(&sin, &k2, dims);
175            let lc = apply_kernel(&cos, &k2, dims);
176            (0..pd.len()).map(|i| cos[i] * ls[i] - sin[i] * lc[i]).collect()
177        } else {
178            apply_kernel(&pd, &k2, dims)
179        };
180        for (l, &m) in lap.iter_mut().zip(&pm) {
181            if m == 0 { *l = 0.0; }
182        }
183        let rim = sti_rim(&pm, dims);
184        drop_large_rim_values(&mut lap, &rim);
185        Self { dims, vs, mask: pm, k2, lap, rim }
186    }
187
188    fn n(&self) -> usize { self.mask.len() }
189
190    /// Invert the Laplacian, crop the padding, keep the brain, convert to ppm.
191    fn finish(&self, lap_fov: &[f64], mask: &[u8], grid: &Grid, pad: [usize; 3], rad_per_ppm: Option<f64>) -> Vec<f64> {
192        let tissue = apply_inv_kernel(lap_fov, &self.k2, self.dims);
193        let tissue = unpad_centred(&tissue, self.dims, grid.dims, pad);
194        tissue.iter().zip(mask)
195            .map(|(&v, &m)| match (m != 0, rad_per_ppm) {
196                (false, _) => 0.0,
197                (true, None) => v,
198                (true, Some(c)) => v / c,
199            })
200            .collect()
201    }
202}
203
204// =============================================================================
205// HARPERELLA (Li et al., NMR Biomed 2014)
206// =============================================================================
207
208/// HARPERELLA: the exterior Laplacian makes the SMV of the total Laplacian uniform.
209///
210/// Solves the paper's Eq. 3, `min ‖S(∇²φ_E) + S(∇²φ_{I+O}) − δ‖₂` over the field of view, for
211/// the Laplacian `∇²φ_E` outside the brain, where `S` is the spherical mean of `radius` mm and
212/// `δ` (Eq. 4) is the mean of `S(∇²φ)` over the trustable interior I. The paper splits the
213/// tissue into I and the boundary region O, "the set of tissue voxels next to the boundaries
214/// that are within a distance of the radius of the spherical mean value filter", so I is the
215/// mask eroded by `radius`. The local field is the inverse Laplacian of the brain's Laplacian
216/// plus the exterior one (Eqs. 5-6).
217///
218/// STI Suite 3.0 does not include HARPERELLA, so where the paper is silent this uses what STI's
219/// iHARPERELLA does (module docs): the spectral Laplacian and its inverse (the paper's Eq. 6
220/// is the same inverse), the boundary rim and its 20 % of large values set to zero (the paper:
221/// "large Laplacian values near the boundary of the brain (<3 voxels from the boundary)"),
222/// MATLAB `lsqr` at tolerance 0.001 (the paper: "the preconditioned LSQR method", preconditioner
223/// unstated), and no padding by default (the paper pads, size unstated).
224///
225/// # Arguments
226/// * `input` - Wrapped phase (rad) or unwrapped total field (ppm); see [`HarperellaInput`]
227/// * `mask` - Binary brain mask (nx * ny * nz), 1 = inside
228/// * `grid` - Volume dimensions and voxel sizes
229/// * `params` - Algorithm parameters
230/// * `progress` - Progress callback (iteration, max_iter)
231///
232/// # Returns
233/// `(local_field_ppm, mask)`: the background-free local field in ppm, and the brain mask.
234///
235/// # Errors
236/// A buffer whose length does not match `grid`, or a `WrappedPhase` echo time or field
237/// strength that is not positive.
238pub fn harperella(
239    input: HarperellaInput,
240    mask: &[u8],
241    grid: &Grid,
242    params: &HarperellaParams,
243    mut progress: impl FnMut(usize, usize),
244) -> Result<(Vec<f64>, Vec<u8>), InputError> {
245    let (data, rad_per_ppm) = input.data(mask, grid)?;
246    let wrapped = rad_per_ppm.is_some();
247    let w = Work::new(data, mask, grid, params.pad, wrapped);
248    let (nx, ny, nz) = w.dims;
249    let (vsx, vsy, vsz) = w.vs;
250    let n = w.n();
251
252    let s_fft = compute_smv_fft(nx, ny, nz, vsx, vsy, vsz, params.radius);
253    let smv_lap = apply_smv(&w.lap, &s_fft, nx, ny, nz);
254
255    // delta (Eq. 4): mean of S(Laplacian) over the interior I, the mask eroded by the radius.
256    let interior = erode(&w.mask, nx, ny, nz, vsx, vsy, vsz, params.radius);
257    let (sum, count) = smv_lap.iter().zip(&interior)
258        .filter(|(_, &i)| i == 1)
259        .fold((0.0, 0usize), |(s, c), (&v, _)| (s + v, c + 1));
260    let delta = if count > 0 { sum / count as f64 } else { 0.0 };
261
262    // Eq. 3 as least squares: A x = S(E x), b = delta - S(Laplacian); Aᵀ y = E S(y).
263    let ext: Vec<f64> = w.mask.iter().map(|&m| if m == 0 { 1.0 } else { 0.0 }).collect();
264    let b: Vec<Complex64> = smv_lap.iter().map(|&v| Complex64::new(delta - v, 0.0)).collect();
265    let mut it = 0usize;
266    let max_iter = params.max_iter;
267    let (x, _) = lsqr_matlab(
268        |v| {
269            it += 1;
270            progress(it.min(max_iter), max_iter);
271            let ev: Vec<f64> = (0..n).map(|i| ext[i] * v[i].re).collect();
272            to_complex(apply_smv(&ev, &s_fft, nx, ny, nz))
273        },
274        |u| {
275            let re: Vec<f64> = u.iter().map(|c| c.re).collect();
276            let su = apply_smv(&re, &s_fft, nx, ny, nz);
277            (0..n).map(|i| Complex64::new(ext[i] * su[i], 0.0)).collect()
278        },
279        &b,
280        params.tol,
281        max_iter,
282    );
283
284    let lap_fov: Vec<f64> = (0..n).map(|i| w.lap[i] + ext[i] * x[i].re).collect();
285    Ok((w.finish(&lap_fov, mask, grid, params.pad, rad_per_ppm), mask.to_vec()))
286}
287
288// =============================================================================
289// iHARPERELLA (Li et al., ISMRM 2015 #3313; STI Suite 3.0 iHARPERELLA)
290// =============================================================================
291
292/// iHARPERELLA: the exterior Laplacian minimises the weighted resulting field in the brain.
293///
294/// STI Suite 3.0's `iHARPERELLA`: with `L` the brain's Laplacian (module docs), `E` the
295/// exterior and `W` = 1 in the brain, 2 in the boundary rim and 0 outside, it solves
296/// `min_x ‖W L⁻¹(E x) − W L⁻¹(L)‖₂` with MATLAB `lsqr` and returns `L⁻¹(L − x)` in the mask:
297/// the field from the brain's Laplacian minus the field the exterior sources `x` explain.
298///
299/// # Arguments
300/// * `input` - Unwrapped total field (ppm) or wrapped phase (rad); see [`HarperellaInput`]
301/// * `mask` - Binary brain mask (nx * ny * nz), 1 = inside
302/// * `grid` - Volume dimensions and voxel sizes
303/// * `params` - Algorithm parameters
304/// * `progress` - Progress callback (iteration, max_iter)
305///
306/// # Returns
307/// `(local_field_ppm, mask)`
308///
309/// # Errors
310/// As [`harperella`].
311pub fn iharperella(
312    input: HarperellaInput,
313    mask: &[u8],
314    grid: &Grid,
315    params: &IharperellaParams,
316    mut progress: impl FnMut(usize, usize),
317) -> Result<(Vec<f64>, Vec<u8>), InputError> {
318    let (data, rad_per_ppm) = input.data(mask, grid)?;
319    let w = Work::new(data, mask, grid, params.pad, rad_per_ppm.is_some());
320    let dims = w.dims;
321    let n = w.n();
322    let ext: Vec<f64> = w.mask.iter().map(|&m| if m == 0 { 1.0 } else { 0.0 }).collect();
323    let wt: Vec<f64> = (0..n).map(|i| w.mask[i] as f64 + w.rim[i] as f64).collect();
324
325    let inv_lap = apply_inv_kernel(&w.lap, &w.k2, dims);
326    let b: Vec<Complex64> = (0..n).map(|i| Complex64::new(wt[i] * inv_lap[i], 0.0)).collect();
327    let mut it = 0usize;
328    let max_iter = params.max_iter;
329    let (x, _) = lsqr_matlab(
330        |v| {
331            it += 1;
332            progress(it.min(max_iter), max_iter);
333            let ev: Vec<f64> = (0..n).map(|i| ext[i] * v[i].re).collect();
334            let f = apply_inv_kernel(&ev, &w.k2, dims);
335            (0..n).map(|i| Complex64::new(wt[i] * f[i], 0.0)).collect()
336        },
337        |u| {
338            let wu: Vec<f64> = (0..n).map(|i| wt[i] * u[i].re).collect();
339            let f = apply_inv_kernel(&wu, &w.k2, dims);
340            (0..n).map(|i| Complex64::new(ext[i] * f[i], 0.0)).collect()
341        },
342        &b,
343        params.tol,
344        max_iter,
345    );
346
347    let lap_fov: Vec<f64> = (0..n).map(|i| w.lap[i] - x[i].re).collect();
348    Ok((w.finish(&lap_fov, mask, grid, params.pad, rad_per_ppm), mask.to_vec()))
349}
350
351// =============================================================================
352// Shared infrastructure
353// =============================================================================
354
355fn to_complex(v: Vec<f64>) -> Vec<Complex64> {
356    v.into_iter().map(|x| Complex64::new(x, 0.0)).collect()
357}
358
359/// Pad with `pad` zero voxels on each side (STI's `padarray(x, padsize)`).
360fn pad_centred<T: Copy + Default>(
361    x: &[T], dims: (usize, usize, usize), new: (usize, usize, usize), pad: [usize; 3],
362) -> Vec<T> {
363    if pad == [0, 0, 0] { return x.to_vec(); }
364    let corner = pad3d(x, dims, new);
365    shift(&corner, new, [pad[0] as isize, pad[1] as isize, pad[2] as isize])
366}
367
368/// Inverse of [`pad_centred`].
369fn unpad_centred(x: &[f64], padded: (usize, usize, usize), dims: (usize, usize, usize), pad: [usize; 3]) -> Vec<f64> {
370    if pad == [0, 0, 0] { return x.to_vec(); }
371    let back = shift(x, padded, [-(pad[0] as isize), -(pad[1] as isize), -(pad[2] as isize)]);
372    unpad3d(&back, padded, dims)
373}
374
375/// Circular shift by `s` voxels.
376fn shift<T: Copy + Default>(x: &[T], dims: (usize, usize, usize), s: [isize; 3]) -> Vec<T> {
377    let (nx, ny, nz) = dims;
378    let mut out = vec![T::default(); x.len()];
379    let w = |i: usize, s: isize, n: usize| ((i as isize + s).rem_euclid(n as isize)) as usize;
380    for k in 0..nz {
381        let kk = w(k, s[2], nz);
382        for j in 0..ny {
383            let jj = w(j, s[1], ny);
384            for i in 0..nx {
385                out[w(i, s[0], nx) + jj * nx + kk * nx * ny] = x[i + j * nx + k * nx * ny];
386            }
387        }
388    }
389    out
390}
391
392/// STI's spectral Laplacian kernel `k² = kx² + ky² + kz²`, `k` in cycles/mm, in FFT order.
393fn k2_kernel(dims: (usize, usize, usize), vs: (f64, f64, f64)) -> Vec<f64> {
394    let (nx, ny, nz) = dims;
395    let f = |i: usize, n: usize, v: f64| {
396        let k = if i <= n / 2 { i as f64 } else { i as f64 - n as f64 };
397        let k = k / (n as f64 * v);
398        k * k
399    };
400    let mut out = vec![0.0; nx * ny * nz];
401    for k in 0..nz {
402        let kz = f(k, nz, vs.2);
403        for j in 0..ny {
404            let ky = f(j, ny, vs.1);
405            for i in 0..nx {
406                out[i + j * nx + k * nx * ny] = f(i, nx, vs.0) + ky + kz;
407            }
408        }
409    }
410    out
411}
412
413/// `F⁻¹[k F(x)]`, real part.
414fn apply_kernel(x: &[f64], k: &[f64], dims: (usize, usize, usize)) -> Vec<f64> {
415    let (nx, ny, nz) = dims;
416    let mut c: Vec<Complex64> = x.iter().map(|&v| Complex64::new(v, 0.0)).collect();
417    fft3d(&mut c, nx, ny, nz);
418    for (ci, &ki) in c.iter_mut().zip(k) { *ci *= ki; }
419    ifft3d(&mut c, nx, ny, nz);
420    c.iter().map(|v| v.re).collect()
421}
422
423/// `F⁻¹[F(x) / k]` with the `k = 0` term set to zero, real part.
424fn apply_inv_kernel(x: &[f64], k: &[f64], dims: (usize, usize, usize)) -> Vec<f64> {
425    let (nx, ny, nz) = dims;
426    let mut c: Vec<Complex64> = x.iter().map(|&v| Complex64::new(v, 0.0)).collect();
427    fft3d(&mut c, nx, ny, nz);
428    for (ci, &ki) in c.iter_mut().zip(k) {
429        *ci = if ki > 0.0 { *ci / ki } else { Complex64::new(0.0, 0.0) };
430    }
431    ifft3d(&mut c, nx, ny, nz);
432    c.iter().map(|v| v.re).collect()
433}
434
435/// STI's boundary rim: mask voxels where `SMVFiltering(mask, 3)`, the mean of the mask over
436/// the 146 neighbours of `ball3D(3)` (offsets with `1 ≤ |d|² ≤ 10` voxels², periodic), is
437/// below 0.99, i.e. more than one of those neighbours is outside the mask.
438fn sti_rim(mask: &[u8], dims: (usize, usize, usize)) -> Vec<u8> {
439    let (nx, ny, nz) = dims;
440    let mut offs = Vec::new();
441    for dz in -3i64..=3 {
442        for dy in -3i64..=3 {
443            for dx in -3i64..=3 {
444                let r2 = dx * dx + dy * dy + dz * dz;
445                if (1..=10).contains(&r2) { offs.push((dx, dy, dz)); }
446            }
447        }
448    }
449    debug_assert_eq!(offs.len(), 146);
450    let total = offs.len() as f64;
451    let w = |i: usize, d: i64, n: usize| (i as i64 + d).rem_euclid(n as i64) as usize;
452    let mut rim = vec![0u8; mask.len()];
453    for k in 0..nz {
454        for j in 0..ny {
455            for i in 0..nx {
456                let idx = i + j * nx + k * nx * ny;
457                if mask[idx] == 0 { continue; }
458                let inside = offs.iter()
459                    .filter(|&&(dx, dy, dz)| mask[w(i, dx, nx) + w(j, dy, ny) * nx + w(k, dz, nz) * nx * ny] != 0)
460                    .count();
461                rim[idx] = u8::from((inside as f64) / total < 0.99);
462            }
463        }
464    }
465    rim
466}
467
468/// Set the 20 % of rim Laplacian values largest in magnitude to zero: keep `|L| ≤` the sorted
469/// rim magnitude at 1-based index `round(0.8 N)`.
470fn drop_large_rim_values(lap: &mut [f64], rim: &[u8]) {
471    let mut mags: Vec<f64> = lap.iter().zip(rim).filter(|(_, &r)| r != 0).map(|(l, _)| l.abs()).collect();
472    if mags.is_empty() { return; }
473    let k = ((0.8 * mags.len() as f64).round() as usize).clamp(1, mags.len()) - 1;
474    let thr = *mags.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1;
475    for (l, &r) in lap.iter_mut().zip(rim) {
476        if r != 0 && l.abs() > thr { *l = 0.0; }
477    }
478}
479
480/// Erode `mask` by a sphere of `radius` mm: keep the voxels whose whole sphere lies in the mask.
481///
482/// The radius is widened by one part in 10^6 so that a voxel exactly `radius` away counts as
483/// inside the sphere whatever the rounding of the voxel size: a header voxel size of
484/// 0.99999994 mm must not drop the voxels at exactly `radius` from the sphere.
485#[allow(clippy::too_many_arguments)]
486fn erode(
487    mask: &[u8],
488    nx: usize, ny: usize, nz: usize,
489    vsx: f64, vsy: f64, vsz: f64,
490    radius: f64,
491) -> Vec<u8> {
492    let erode_fft = compute_smv_fft(nx, ny, nz, vsx, vsy, vsz, radius * (1.0 + 1e-6));
493    let mut mask_conv: Vec<Complex64> = mask.iter()
494        .map(|&m| Complex64::new(m as f64, 0.0)).collect();
495    fft3d(&mut mask_conv, nx, ny, nz);
496    for (c, &e) in mask_conv.iter_mut().zip(&erode_fft) { *c *= e; }
497    ifft3d(&mut mask_conv, nx, ny, nz);
498    let thresh = 1.0 - 1e-7_f64.sqrt();
499    mask_conv.iter().map(|c| u8::from(c.re > thresh)).collect()
500}
501
502/// Compute FFT of SMV kernel (returns real part, since kernel is symmetric)
503fn compute_smv_fft(
504    nx: usize, ny: usize, nz: usize,
505    vsx: f64, vsy: f64, vsz: f64, radius: f64,
506) -> Vec<f64> {
507    let grid = Grid::new(nx, ny, nz, vsx, vsy, vsz);
508    let s_kernel = smv_kernel(&grid, radius);
509    let mut c: Vec<Complex64> = s_kernel.iter()
510        .map(|&x| Complex64::new(x, 0.0)).collect();
511    fft3d(&mut c, nx, ny, nz);
512    c.iter().map(|v| v.re).collect()
513}
514
515/// Apply SMV filter in k-space: S(x) = ifft(s_fft * fft(x))
516fn apply_smv(x: &[f64], s_fft: &[f64], nx: usize, ny: usize, nz: usize) -> Vec<f64> {
517    apply_kernel(x, s_fft, (nx, ny, nz))
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::unwrap::laplacian::wrap;
524
525    const N: usize = 16;
526
527    fn grid() -> Grid {
528        Grid::new(N, N, N, 1.0, 1.0, 1.0)
529    }
530
531    /// A sphere of radius 6 voxels in the middle of the FOV.
532    fn sphere_mask() -> Vec<u8> {
533        let c = (N as f64 - 1.0) / 2.0;
534        (0..N * N * N)
535            .map(|idx| {
536                let (i, j, k) = (idx % N, (idx / N) % N, idx / (N * N));
537                let r2 = (i as f64 - c).powi(2) + (j as f64 - c).powi(2) + (k as f64 - c).powi(2);
538                u8::from(r2 <= 36.0)
539            })
540            .collect()
541    }
542
543    /// A smooth field in ppm: a Gaussian blob (1.5 ppm peak) inside the mask on a smooth
544    /// periodic background (0.3 ppm).
545    fn field_ppm(scale: f64) -> Vec<f64> {
546        let c = (N as f64 - 1.0) / 2.0;
547        (0..N * N * N)
548            .map(|idx| {
549                let (x, y, z) = ((idx % N) as f64 - c, ((idx / N) % N) as f64 - c, (idx / (N * N)) as f64 - c);
550                let blob = 1.5 * (-(x * x + y * y + z * z) / 16.0).exp();
551                let bg = 0.3 * (2.0 * std::f64::consts::PI * x / N as f64).sin();
552                scale * (blob + bg)
553            })
554            .collect()
555    }
556
557    fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 {
558        a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max)
559    }
560
561    fn max_abs(a: &[f64]) -> f64 {
562        a.iter().fold(0.0f64, |m, v| m.max(v.abs()))
563    }
564
565    fn run_h(input: HarperellaInput, mask: &[u8]) -> Vec<f64> {
566        let p = HarperellaParams { radius: 3.0, max_iter: 30, ..Default::default() };
567        harperella(input, mask, &grid(), &p, |_, _| {}).unwrap().0
568    }
569
570    fn run_ih(input: HarperellaInput, mask: &[u8]) -> Vec<f64> {
571        let p = IharperellaParams { max_iter: 30, ..Default::default() };
572        iharperella(input, mask, &grid(), &p, |_, _| {}).unwrap().0
573    }
574
575    #[test]
576    fn zero_field_gives_zero() {
577        let zero = vec![0.0; N * N * N];
578        let mask = sphere_mask();
579        for out in [run_h(HarperellaInput::FieldPpm(&zero), &mask), run_ih(HarperellaInput::FieldPpm(&zero), &mask)] {
580            assert!(max_abs(&out) < 1e-12);
581        }
582    }
583
584    #[test]
585    fn wrapped_phase_is_finite() {
586        let phase: Vec<f64> = (0..N * N * N).map(|i| wrap((i as f64) * 0.1)).collect();
587        let mask = vec![1u8; N * N * N];
588        let input = HarperellaInput::WrappedPhase { phase: &phase, te_s: 0.02, b0_t: 3.0 };
589        assert!(run_h(input, &mask).iter().all(|v| v.is_finite()));
590        assert!(run_ih(input, &mask).iter().all(|v| v.is_finite()));
591    }
592
593    /// The spectral Laplacian and its inverse undo each other up to the mean.
594    #[test]
595    fn spectral_laplacian_inverts() {
596        let dims = (N, N, 12);
597        let k2 = k2_kernel(dims, (1.0, 0.9, 1.5));
598        let x: Vec<f64> = (0..N * N * 12).map(|i| ((i * 37 % 101) as f64).sin()).collect();
599        let mean = x.iter().sum::<f64>() / x.len() as f64;
600        let back = apply_inv_kernel(&apply_kernel(&x, &k2, dims), &k2, dims);
601        let centred: Vec<f64> = x.iter().map(|v| v - mean).collect();
602        assert!(max_abs_diff(&back, &centred) < 1e-10);
603        // k² is -∇²/4π²: on a plane wave of 2 cycles over 16 mm along x, k² = (2/16)².
604        let wave: Vec<f64> = (0..N * N * 12).map(|i| (2.0 * std::f64::consts::PI * 2.0 * (i % N) as f64 / N as f64).cos()).collect();
605        let lw = apply_kernel(&wave, &k2, dims);
606        let expect: Vec<f64> = wave.iter().map(|v| v * (2.0f64 / 16.0).powi(2)).collect();
607        assert!(max_abs_diff(&lw, &expect) < 1e-12);
608    }
609
610    /// STI's rim: a voxel with one ball neighbour outside the mask is not rim, with two it is.
611    #[test]
612    fn rim_needs_two_outside_neighbours() {
613        let dims = (N, N, N);
614        let mut mask = vec![1u8; N * N * N];
615        let at = |i: usize, j: usize, k: usize| i + j * N + k * N * N;
616        mask[at(3, 8, 8)] = 0;
617        let rim = sti_rim(&mask, dims);
618        assert_eq!(rim.iter().filter(|&&r| r != 0).count(), 0, "one hole: 145/146 > 0.99");
619        mask[at(4, 8, 8)] = 0;
620        let rim = sti_rim(&mask, dims);
621        // voxels with both holes in their ball: offsets to both within |d|² <= 10
622        assert!(rim[at(6, 8, 8)] == 1 && rim[at(1, 8, 8)] == 1 && rim[at(8, 8, 8)] == 0);
623    }
624
625    /// The rim keeps round(0.8 N) values and zeroes the rest, the largest in magnitude.
626    #[test]
627    fn rim_drops_the_largest_fifth() {
628        for n in [1usize, 4, 5, 9, 13] {
629            let mut lap: Vec<f64> = (0..n + 3).map(|i| if i % 2 == 0 { i as f64 } else { -(i as f64) }).collect();
630            let mut rim = vec![1u8; n];
631            rim.extend([0u8; 3]);
632            let outside_before: Vec<f64> = lap[n..].to_vec();
633            drop_large_rim_values(&mut lap, &rim);
634            let kept = lap[..n].iter().filter(|&&v| v != 0.0).count() + usize::from(n > 0); // value 0 at i = 0
635            assert_eq!(kept, ((0.8 * n as f64).round() as usize).max(1), "n = {n}");
636            assert_eq!(&lap[n..], &outside_before[..], "outside the rim is untouched");
637        }
638    }
639
640    #[test]
641    fn padding_round_trips() {
642        let dims = (5, 4, 3);
643        let x: Vec<f64> = (0..60).map(|i| i as f64).collect();
644        let pad = [2, 1, 3];
645        let new = (9, 6, 9);
646        let p = pad_centred(&x, dims, new, pad);
647        assert_eq!(p[2 + new.0 + 3 * new.0 * new.1], x[0]);
648        assert_eq!(unpad_centred(&p, new, dims, pad), x);
649    }
650
651    /// Wrapped phase goes through the paper's Eq. 1, which agrees with the field's Laplacian
652    /// where the field is smooth: the two local fields agree closely.
653    #[test]
654    fn wrapped_phase_agrees_with_field_ppm() {
655        let field = field_ppm(1.0);
656        let mask = sphere_mask();
657        let te = 0.007;
658        let b0 = 3.0;
659        let c = crate::inversion::rad_per_ppm(te, b0);
660        let phase: Vec<f64> = field.iter().map(|f| wrap(f * c)).collect();
661        assert!(max_abs(&field) * c > 2.0 * std::f64::consts::PI, "test field does not wrap");
662        let input = HarperellaInput::WrappedPhase { phase: &phase, te_s: te, b0_t: b0 };
663        for (from_phase, from_field, name) in [
664            (run_ih(input, &mask), run_ih(HarperellaInput::FieldPpm(&field), &mask), "iHARPERELLA"),
665            (run_h(input, &mask), run_h(HarperellaInput::FieldPpm(&field), &mask), "HARPERELLA"),
666        ] {
667            let d = max_abs_diff(&from_phase, &from_field) / max_abs(&from_field);
668            assert!(d < 0.1, "{name}: phase vs field differ by {d:e}");
669        }
670    }
671
672    /// LSQR's stopping rule is relative, so scaling the field scales the result. (A
673    /// power-of-two factor scales every floating-point operation exactly.)
674    #[test]
675    fn field_input_is_scale_equivariant() {
676        let mask = sphere_mask();
677        let (f1, f2) = (field_ppm(1.0), field_ppm(16.0));
678        for (run, name) in [(run_h as fn(HarperellaInput, &[u8]) -> Vec<f64>, "HARPERELLA"), (run_ih, "iHARPERELLA")] {
679            let a: Vec<f64> = run(HarperellaInput::FieldPpm(&f1), &mask).iter().map(|v| v * 16.0).collect();
680            let b = run(HarperellaInput::FieldPpm(&f2), &mask);
681            let d = max_abs_diff(&a, &b) / max_abs(&b);
682            assert!(d < 1e-12, "{name}: not scale-equivariant ({d:e})");
683        }
684    }
685
686    /// Padding enlarges the working volume and changes the result, but the output stays on
687    /// the input grid, zero outside the mask.
688    #[test]
689    fn padding_is_applied() {
690        let mask = sphere_mask();
691        let field = field_ppm(1.0);
692        let p0 = IharperellaParams { max_iter: 10, ..Default::default() };
693        let p4 = IharperellaParams { pad: [4, 4, 4], ..p0.clone() };
694        let a = iharperella(HarperellaInput::FieldPpm(&field), &mask, &grid(), &p0, |_, _| {}).unwrap().0;
695        let b = iharperella(HarperellaInput::FieldPpm(&field), &mask, &grid(), &p4, |_, _| {}).unwrap().0;
696        assert_eq!(b.len(), N * N * N);
697        assert!(max_abs_diff(&a, &b) > 1e-6);
698        assert!(b.iter().zip(&mask).all(|(&v, &m)| m != 0 || v == 0.0));
699    }
700
701    /// The erosions do not depend on how the voxel size was rounded: a header voxel size of
702    /// 0.99999994 mm erodes like 1 mm.
703    #[test]
704    fn erosion_ignores_voxel_size_rounding() {
705        let mask = sphere_mask();
706        for radius in [2.0, 3.0] {
707            let exact = erode(&mask, N, N, N, 1.0, 1.0, 1.0, radius);
708            let rounded = erode(&mask, N, N, N, 1.0, 1.0, 0.999_999_94, radius * 0.999_999_94);
709            assert!(exact.contains(&1), "radius {radius}: nothing left");
710            assert_eq!(exact, rounded, "radius {radius}");
711        }
712    }
713
714    /// HARPERELLA's trustable interior I, where delta is averaged, is the mask eroded by the
715    /// SMV radius. A radius wider than the brain leaves I empty; delta is then 0 and the result
716    /// is still finite.
717    #[test]
718    fn empty_interior_gives_zero_delta() {
719        let mask = sphere_mask();
720        assert!(erode(&mask, N, N, N, 1.0, 1.0, 1.0, 6.5).iter().all(|&v| v == 0));
721        assert!(erode(&mask, N, N, N, 1.0, 1.0, 1.0, 3.0).contains(&1));
722        let field = field_ppm(1.0);
723        let p = HarperellaParams { radius: 6.5, max_iter: 30, ..Default::default() };
724        let out = harperella(HarperellaInput::FieldPpm(&field), &mask, &grid(), &p, |_, _| {}).unwrap().0;
725        assert!(out.iter().all(|v| v.is_finite()) && max_abs(&out) > 1e-3);
726    }
727
728    #[test]
729    fn bad_input_is_an_error_not_a_panic() {
730        let n = N * N * N;
731        let (field, mask) = (vec![0.0; n], vec![1u8; n]);
732        let (g, hp, ip) = (grid(), HarperellaParams::default(), IharperellaParams::default());
733        let short = vec![0.0; n - 1];
734        assert!(harperella(HarperellaInput::FieldPpm(&short), &mask, &g, &hp, |_, _| {}).is_err());
735        assert!(iharperella(HarperellaInput::FieldPpm(&field), &mask[1..], &g, &ip, |_, _| {}).is_err());
736        for (te_s, b0_t) in [(0.0, 3.0), (0.02, 0.0), (f64::NAN, 3.0), (-0.01, 3.0)] {
737            let input = HarperellaInput::WrappedPhase { phase: &field, te_s, b0_t };
738            let err = harperella(input, &mask, &g, &hp, |_, _| {}).unwrap_err();
739            assert!(err.to_string().contains("positive echo time"), "{err}");
740            assert!(iharperella(input, &mask, &g, &ip, |_, _| {}).is_err());
741        }
742    }
743}