Skip to main content

qsm_core/bgremove/
sharp.rs

1//! SHARP background field removal
2//!
3//! Sophisticated Harmonic Artifact Reduction for Phase data (Schweser et al. 2011). A harmonic
4//! function equals its spherical mean, so the high-pass `f − S∗f` (S a normalised sphere) has no
5//! harmonic (background) part. SHARP takes it inside the mask eroded by the sphere, then undoes
6//! the high-pass by deconvolving `1 − Ŝ`, with its small values truncated (TSVD).
7//!
8//! # Which implementation
9//!
10//! The SHARP authors (Schweser et al. 2011) published no code. This module follows Tian Liu's
11//! `SHARP.m` (Cornell, the MEDI group; 2010, last modified 2011-02-01) as SEPIA distributes and
12//! runs it: kschan0214/sepia `db939cc` (v1.3.0.1), `misc/background_removal/SHARP/SHARP.m`,
13//! called by `wrapper/WrapperBFR/Wrapper_BFR_SHARP.m`, which puts the MEDI toolbox on the path for
14//! `SMV.m`, `SMV_kernel.m` and `sphere_kernel.m`. Those are MEDI 2024.11.26's (byte-identical to
15//! the copies in AMP-PE and other MEDI redistributions; they were last changed in 2013, so they are
16//! also what SEPIA's README's MEDI of Jan 2020 has). The defaults are SEPIA's (below). Decided
17//! 2026-10-28. QSM.jl's port, which this module followed before, is kept as
18//! [`SharpVariant::QsmJl`] for the QSM.jl parity pair.
19//!
20//! `SHARP.m`, step by step, as SEPIA runs it (`BackgroundRemovalMacro.m` with the settings SEPIA's
21//! GUI exports for SHARP):
22//!
23//! 0. SEPIA zero-pads every odd axis by one slice at its end (`zeropad_odd_dimension(…, 'pre')`,
24//!    `padarray(…, 'post')`) and crops the result back afterwards. Without it MEDI's sphere, built
25//!    on the grid `-N/2 : N/2-1` and `fftshift`ed, sits half a voxel off the origin on an odd
26//!    axis, which shifts the output.
27//! 1. `S` = `sphere_kernel(matrix_size, voxel_size, radius)`: a sphere of `radius` **mm**
28//!    (coordinates are scaled by the voxel size), voxels wholly inside are 1, voxels the surface
29//!    cuts get their volume fraction from 20³ sub-voxel samples, normalised to sum 1,
30//!    `fftn(fftshift(Sphere))`.
31//! 2. Erode: `Mask = SMV(Mask, matrix_size, voxel_size, radius) > 0.999`, i.e. keep the voxels
32//!    whose sphere lies in the mask to 0.1 % of its weight. The convolution is periodic on the
33//!    (padded) field of view.
34//! 3. `kernel = SMV_kernel(…) = 1 − S`; `iFreq = ifftn(fftn(iFreq).*kernel)`, times `Mask`.
35//! 4. `kRDF = fftn(·)./kernel`; `kRDF(abs(kernel) <= threshold) = 0`; NaN and Inf set to 0.
36//! 5. `RDF = ifftn(kRDF).*Mask`. Every FFT is on the whole (padded) field of view: no cropping
37//!    and no further padding.
38//!
39//! `SHARP.m` returns only the local field; this port also returns step 2's eroded mask (SEPIA's
40//! own next step takes `imfill(RDF ≠ 0, 'holes')` as the mask for the dipole inversion).
41//! `SHARP.m` does not take `real()`: MATLAB's `ifftn` returns a real array because its input is
42//! exactly conjugate-symmetric (checked in licensed R2023b: `isreal(RDF)` on even, odd and
43//! padded grids), so the real part taken here is the same thing.
44//!
45//! # Defaults
46//!
47//! `radius` 4 mm and `threshold` 0.03: SEPIA's GUI (`gui_func/panel/BFR_method/
48//! sepia_handle_panel_bkgRemoval_SHARP.m`, `defaultRadius = 4; defaultthreshold = 0.03`), which is
49//! what SEPIA's exported config and pipeline pass to `SHARP.m`. Confirmed by running SEPIA
50//! headless in licensed MATLAB R2023b: the GUI, with SHARP selected, exports
51//! `algorParam.bfr.radius = 4; algorParam.bfr.threshold = 0.03` (refine 'None', no extra erosion),
52//! and with those `BackgroundRemovalMacro` calls `SHARP(…, 4, 0.03)` and its output is identical
53//! to `SHARP.m` called directly on the padded data. Two traps in SEPIA:
54//!
55//! - The GUI labels the radius "SMV radius (voxel)" and the wrapper prints `Radius(voxel)`, but
56//!   the number is passed unchanged to `sphere_kernel`, which compares `(X·voxel_size)²` with
57//!   `radius²`: it is in **mm**. QSM.rs's `radius` is in mm too, so SEPIA's 4 is 4 mm here, on any
58//!   voxel size.
59//! - `Wrapper_BFR_SHARP.m`'s fallbacks for missing fields are `radius = 0.1` and
60//!   `threshold = 30` (swapped, and unusable: no voxel survives a 0.1 mm sphere's erosion). They
61//!   apply only when the config lacks the fields, which the GUI never writes.
62//!
63//! `SHARP.m`'s own defaults, used by nobody here, are `threshold = 0` (no truncation, only the
64//! NaN/Inf guard) and `radius = round(6/max(voxel_size))·max(voxel_size)` (6 mm at 1 mm).
65//!
66//! Other implementations, for the record:
67//!
68//! | implementation | radius | TSVD threshold |
69//! |---|---|---|
70//! | SEPIA + Tian Liu's `SHARP.m` (followed) | 4 mm (GUI) | 0.03 (GUI) |
71//! | QSM.jl `sharp` (kamesy/QSM.jl fde4b92; [`SharpVariant::QsmJl`]) | `18·min(vsz)` mm | 0.05 |
72//! | QSM.m `sharp.m` (kamesy/QSM.m f22dc40, `src/bgremove/sharp.m:13-15,33-34`) | 9 mm | 0.05 |
73//! | Hongfu Sun's `sharp.m` (sunhongfu/QSM c75b138, `background_field_removal/sharp.m`) | 4 mm if omitted, 5 mm in its header's example; his pipelines pass 2–5 mm, mostly 3 | none (required); header example 0.05; pipelines 0.05–0.1, mostly 0.1 |
74//!
75//! QSM.jl's 18 mm is not used: SHARP erodes the mask by its radius, so an 18 mm sphere removes
76//! most of the cortex.
77//!
78//! # Tian Liu's `SHARP.m` and Hongfu Sun's `sharp.m`
79//!
80//! | | Liu `SHARP.m` (+ MEDI) | Sun `sharp.m` |
81//! |---|---|---|
82//! | radius units | mm | mm, rounded to whole voxels per axis: `r_i = max(1, round(r/vox_i))` |
83//! | kernel | sphere of radius r mm, partial-volume edge voxels (20³ sub-samples), normalised | binary ellipsoid `Σ x_i²/r_i² ≤ 1` in voxels, normalised; no partial volume |
84//! | erosion | `S∗mask > 0.999`, periodic (FFT) convolution | `convn(mask, ker, 'same') > 1 − 1/|ball|` (the whole ball fits), linear convolution, zero outside the volume |
85//! | TSVD | zero where `|1 − Ŝ| ≤ thr`; NaN/Inf zeroed | zero where `|1 − Ŝ| < thr` |
86//! | FFT domain | the whole volume, odd axes padded to even by SEPIA | the whole volume, no padding (the kernel is placed at the corner and the result `circshift`ed back, so the ball stays centred on odd axes) |
87//! | output | `ifftn(·).*Mask` (real) | `real(ifftn(·)).*mask_ero`, plus the eroded mask |
88//! | defaults | `thr` 0, `r` 6 mm rounded to the coarsest voxel; SEPIA passes 4 mm, 0.03 | `r` 4 mm; `thr` none |
89//!
90//! Neither is clearly better reasoned. Liu's kernel is the more careful discretisation: a true
91//! sphere in mm on anisotropic voxels, with partial-volume weights, so the kernel varies smoothly
92//! with the radius. Sun's rounds the radius per axis, so on anisotropic or non-integer radii the
93//! "sphere" is an ellipsoid of whole voxels, and the radius changes the kernel only in steps.
94//! Sun's erosion is the stricter and the better defined (the ball must fit; no wrap-around at the
95//! field of view's edge), where Liu's 0.999 is an arbitrary tolerance and its periodic convolution
96//! can let a mask touching the volume's edge see the opposite side. Liu's code needs SEPIA's
97//! padding to be right on odd axes; Sun's is right on any size. The TSVD rules differ only in
98//! `<` against `≤`. These are judgement calls, not errors, on either side.
99//!
100//! # Agreement with `SHARP.m`
101//!
102//! `tests/sharp_sepia_parity.rs` checks this port against SEPIA's output in licensed MATLAB R2023b
103//! (`tests/data/sharp_sepia_small.m`, the generator, run in SEPIA's `BackgroundRemovalMacro`) on
104//! small phantoms: at SEPIA's defaults on a 1 mm grid, at SEPIA's defaults on an anisotropic grid
105//! with odd dimensions, and at 2.5 mm / 0.1 on another anisotropic grid; the eroded masks are
106//! identical and the fields agree to float rounding.
107//!
108//! On QSM-CI's fidelity cases, against the licensed-MATLAB run of the QSM-CI `sharp-sepia`
109//! container's `recon.m` (QSMxT/QSM-CI `algorithms/sharp-sepia`, the same SEPIA path; the
110//! container is bit-identical to it), both in float64, at SEPIA's defaults, with identical eroded
111//! masks: the in-silico head (QSM Challenge 2.0 simulation, 1 mm, 164×205×205, so two odd axes
112//! padded; ground-truth total field) NRMSE 2.8e-11 %, max |Δ| 1.3e-14 ppm; the in-vivo
113//! travelling head (Siemens Prisma, 1 mm, 184×256×128, ROMEO total field) NRMSE 1.2e-11 %, max
114//! |Δ| 1.4e-14 ppm.
115//!
116//! # [`SharpVariant::QsmJl`]: QSM.jl's `sharp`
117//!
118//! A port of QSM.jl's `sharp` (kamesy/QSM.jl fde4b92, `src/bgremove/sharp.jl`), kept for the
119//! QSM.jl parity pair ([`SharpParams::qsmjl`] gives its defaults). Line by line:
120//!
121//! 1. Crop the field and the mask to the mask's bounding box (`crop_indices`) and zero-pad them
122//!    to `fastfftsize(box, (p, p, p); rfft = true)` with `p = ⌈r / min(vsz)⌉`
123//!    ([`crate::utils::padding::qsmjl_fast_fft_size`]); every FFT below is
124//!    periodic on that padded box.
125//! 2. `S` = the FFT of the normalised ball of voxel centres within `r` mm (`_smv_kernel!`).
126//! 3. Erode the mask to the voxels where `S ∗ mask > 1 − √eps` (eps of `Float64`), i.e. where
127//!    the whole ball fits in the mask.
128//! 4. High-pass `(1 − S)·F(f)`, back to image space, multiply by the eroded mask.
129//! 5. Deconvolve: `F⁻¹[F(·)/(1 − S)]` with the frequencies where `|1 − S| < thr` set to 0
130//!    (truncated SVD), and multiply by the eroded mask again.
131//!
132//! QSM.jl uses real-to-complex FFTs, this port complex ones; otherwise every step is QSM.jl's. On
133//! the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, ROMEO total
134//! field and brain mask), QSM.jl's `sharp` in the QSM-CI `julia-qsm` image (`sha256:b3c048ac…`)
135//! and this variant with the same `r` and `thr` agree to float rounding (both outputs saved as
136//! float32), with identical eroded masks: at `r` 6 mm NRMSE 6.6e-10 %, max |Δ| 1.2e-10 ppm; at
137//! QSM.jl's default 18 mm NRMSE 7.8e-12 %, max |Δ| 9.1e-13 ppm. `tests/sharp_qsmjl_parity.rs`
138//! checks it against QSM.jl's output on small phantoms. V-SHARP's filtering
139//! ([`super::vsharp()`]) shares this variant's box and edge test.
140//!
141//! Reference:
142//! Schweser, F., Deistung, A., Lehr, B.W., Reichenbach, J.R. (2011).
143//! "Quantitative imaging of intrinsic magnetic tissue properties using MRI signal phase."
144//! NeuroImage, 54(4):2789-2807. <https://doi.org/10.1016/j.neuroimage.2010.10.070>
145//!
146//! Reference implementation: <https://github.com/kschan0214/sepia> (`SHARP.m`, db939cc) with the
147//! MEDI toolbox (<http://pre.weill.cornell.edu/mri/pages/qsm.html>, 2024.11.26)
148
149use num_complex::Complex64;
150use crate::Grid;
151use crate::fft::{fft3d, ifft3d, fft_real_kernel, Fft3dWorkspace};
152use crate::kernels::smv::{smv_kernel, erode_mask_smv};
153use crate::utils::padding::qsmjl_fast_fft_size;
154
155/// QSM.jl's threshold for "the whole ball fits in the mask": `S ∗ mask > 1 − √eps(Float64)`
156/// (`δ` in `src/bgremove/sharp.jl` `_sharp!`, fde4b92). Shared by [`SharpVariant::QsmJl`] and
157/// V-SHARP.
158pub(crate) const QSMJL_SMV_DELTA: f64 = 1.0 - 1.490_116_119_384_765_6e-8; // 1 - sqrt(f64::EPSILON)
159
160/// `SHARP.m`'s erosion threshold: `Mask = SMV(Mask, …, radius) > 0.999`.
161const SEPIA_SMV_DELTA: f64 = 0.999;
162
163/// Which implementation of SHARP to reproduce. See the [module docs](self).
164#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
165#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
166pub enum SharpVariant {
167    /// Tian Liu's `SHARP.m` with MEDI's kernels, as SEPIA runs it (odd axes padded to even):
168    /// the reference.
169    #[default]
170    Sepia,
171    /// QSM.jl's `sharp` (fde4b92): its own ball, `1 − √eps` edge test, `<` TSVD rule, and the
172    /// mask's bounding box padded to a fast FFT size. For the QSM.jl parity pair.
173    QsmJl,
174}
175
176/// SHARP parameters. The defaults are SEPIA's (see the [module docs](self#defaults)).
177#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
178#[derive(Clone, Debug)]
179pub struct SharpParams {
180    /// Deconvolution (TSVD) threshold (default 0.03, SEPIA's GUI). [`SharpVariant::Sepia`] sets
181    /// the frequencies where `|1 − Ŝ| ≤ threshold` to 0 (`SHARP.m`), [`SharpVariant::QsmJl`]
182    /// those where `|1 − Ŝ| < threshold`.
183    pub threshold: f64,
184    /// Sphere radius in mm (default 4, SEPIA's GUI, whose "voxel" label is wrong: `SHARP.m`
185    /// takes mm). Before 2026-10-28 this followed QSM.jl (`18 · min(voxel size)`), and before
186    /// that defaulted to 6 mm, which had no reference.
187    pub radius: f64,
188    /// The implementation to reproduce (default [`SharpVariant::Sepia`]).
189    pub variant: SharpVariant,
190}
191
192impl Default for SharpParams {
193    fn default() -> Self {
194        Self {
195            threshold: 0.03,
196            radius: 4.0,
197            variant: SharpVariant::Sepia,
198        }
199    }
200}
201
202impl SharpParams {
203    /// QSM.jl `sharp`'s default kernel radius as a multiple of the smallest voxel dimension.
204    pub const QSMJL_RADIUS_VOXELS: f64 = 18.0;
205
206    /// QSM.jl `sharp` at its own defaults on `grid`: [`SharpVariant::QsmJl`], `r` =
207    /// `18 · min(voxel size)` mm, `thr` 0.05.
208    pub fn qsmjl(grid: &Grid) -> Self {
209        let (a, b, c) = grid.voxel_size;
210        Self {
211            threshold: 0.05,
212            radius: Self::QSMJL_RADIUS_VOXELS * a.min(b).min(c),
213            variant: SharpVariant::QsmJl,
214        }
215    }
216}
217
218/// SHARP background field removal (see the [module docs](self))
219///
220/// # Arguments
221/// * `field` - Unwrapped total field (nx * ny * nz)
222/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
223/// * `grid` - Volume dimensions and voxel sizes
224/// * `params` - SHARP parameters (threshold, radius in mm, variant)
225///
226/// # Returns
227/// (local_field, eroded_mask)
228pub fn sharp(
229    field: &[f64],
230    mask: &[u8],
231    grid: &Grid,
232    params: &SharpParams,
233) -> (Vec<f64>, Vec<u8>) {
234    match params.variant {
235        SharpVariant::Sepia => sharp_sepia(field, mask, grid, params.threshold, params.radius),
236        SharpVariant::QsmJl => sharp_core(field, mask, grid, params.threshold, params.radius),
237    }
238}
239
240/// Tian Liu's `SHARP.m` as SEPIA runs it (steps 0-5 of the module docs).
241fn sharp_sepia(
242    field: &[f64],
243    mask: &[u8],
244    grid: &Grid,
245    threshold: f64,
246    radius: f64,
247) -> (Vec<f64>, Vec<u8>) {
248    let (nx, ny, nz) = grid.dims;
249    // 0. SEPIA's zeropad_odd_dimension(…, 'pre'): one zero slice at the end of each odd axis.
250    let (px, py, pz) = (nx + nx % 2, ny + ny % 2, nz + nz % 2);
251    let pgrid = Grid::new(px, py, pz, grid.vsx(), grid.vsy(), grid.vsz());
252    let n = px * py * pz;
253    let at = |i: usize, j: usize, k: usize| i + px * (j + py * k);
254    let pad = |x: &dyn Fn(usize) -> f64| {
255        let mut out = vec![Complex64::new(0.0, 0.0); n];
256        for k in 0..nz {
257            for j in 0..ny {
258                for i in 0..nx {
259                    out[at(i, j, k)] = Complex64::new(x(i + nx * (j + ny * k)), 0.0);
260                }
261            }
262        }
263        out
264    };
265    let mut fft = Fft3dWorkspace::new(px, py, pz);
266
267    // 1. S = sphere_kernel(matrix_size, voxel_size, radius) = fftn(fftshift(Sphere))
268    let mut s = vec![Complex64::new(0.0, 0.0); n];
269    for (p, v) in crate::inversion::medi::sphere_kernel_sparse(&pgrid, radius) {
270        s[p] = Complex64::new(v, 0.0);
271    }
272    fft.fft3d(&mut s);
273
274    // 2. Mask = SMV(Mask, matrix_size, voxel_size, radius) > 0.999 (MATLAB's > compares real parts)
275    let mut m = pad(&|l| mask[l] as f64);
276    fft.fft3d(&mut m);
277    for (x, k) in m.iter_mut().zip(&s) {
278        *x *= k;
279    }
280    fft.ifft3d(&mut m);
281    let eroded: Vec<bool> = m.iter().map(|x| x.re > SEPIA_SMV_DELTA).collect();
282    drop(m);
283
284    // 3. kernel = 1 - S; iFreq = ifftn(fftn(iFreq).*kernel).*Mask
285    let mut f = pad(&|l| field[l]);
286    fft.fft3d(&mut f);
287    for (x, k) in f.iter_mut().zip(&s) {
288        *x *= Complex64::new(1.0, 0.0) - k;
289    }
290    fft.ifft3d(&mut f);
291    for (x, &e) in f.iter_mut().zip(&eroded) {
292        if !e {
293            *x = Complex64::new(0.0, 0.0);
294        }
295    }
296
297    // 4. kRDF = fftn(.)./kernel; kRDF(abs(kernel) <= threshold) = 0; NaN and Inf -> 0
298    fft.fft3d(&mut f);
299    for (x, k) in f.iter_mut().zip(&s) {
300        let kernel = Complex64::new(1.0, 0.0) - k;
301        let q = *x / kernel;
302        *x = if kernel.norm() <= threshold || !q.is_finite() { Complex64::new(0.0, 0.0) } else { q };
303    }
304
305    // 5. RDF = ifftn(kRDF).*Mask (real: see the module docs), cropped back
306    fft.ifft3d(&mut f);
307    let mut local = vec![0.0; nx * ny * nz];
308    let mut out_mask = vec![0u8; nx * ny * nz];
309    for k in 0..nz {
310        for j in 0..ny {
311            for i in 0..nx {
312                let p = at(i, j, k);
313                if eroded[p] {
314                    let l = i + nx * (j + ny * k);
315                    local[l] = f[p].re;
316                    out_mask[l] = 1;
317                }
318            }
319        }
320    }
321    (local, out_mask)
322}
323
324/// The box QSM.jl filters in (`_sharp!`, `src/bgremove/sharp.jl`, fde4b92): the mask's bounding
325/// box (`crop_indices(mask)`), zero-padded to `fastfftsize(box, (p, p, p); rfft = true)` with
326/// `p = ⌈r_max / min(vsz)⌉` (`padfastfft`). QSM.jl centres the box in the padding; here it sits
327/// at the origin, which is the same for periodic convolution, masking and the inverse filter.
328pub(crate) struct SmvBox {
329    lo: [usize; 3],
330    n: [usize; 3],
331    /// Padded box dimensions.
332    pub np: (usize, usize, usize),
333    dims: (usize, usize, usize),
334}
335
336impl SmvBox {
337    /// The box for `mask` on `grid` and the largest kernel radius `r_max` (mm); `None` for an
338    /// empty mask.
339    pub fn new(mask: &[u8], grid: &Grid, r_max: f64) -> Option<Self> {
340        let (nx, ny, nz) = grid.dims;
341        let mut lo = [usize::MAX; 3];
342        let mut hi = [0usize; 3];
343        for k in 0..nz {
344            for j in 0..ny {
345                for i in 0..nx {
346                    if mask[i + j * nx + k * nx * ny] != 0 {
347                        for (d, v) in [i, j, k].into_iter().enumerate() {
348                            lo[d] = lo[d].min(v);
349                            hi[d] = hi[d].max(v);
350                        }
351                    }
352                }
353            }
354        }
355        if lo[0] == usize::MAX {
356            return None;
357        }
358        let n = [0, 1, 2].map(|d| hi[d] - lo[d] + 1);
359        let (a, b, c) = grid.voxel_size;
360        // QSM.jl: pad = Int(cld(r, minimum(vsz)))
361        let p = (r_max / a.min(b).min(c)).ceil() as i64;
362        let [px, py, pz] = qsmjl_fast_fft_size(n, [p; 3]);
363        Some(Self { lo, n, np: (px, py, pz), dims: grid.dims })
364    }
365
366    /// The padded box's grid (same voxel size as `grid`).
367    pub fn grid(&self, grid: &Grid) -> Grid {
368        Grid::new(self.np.0, self.np.1, self.np.2, grid.vsx(), grid.vsy(), grid.vsz())
369    }
370
371    /// `x` cropped to the box and zero-padded.
372    pub fn crop<T: Copy + Default>(&self, x: &[T]) -> Vec<T> {
373        let (nx, ny, _) = self.dims;
374        let (px, py, pz) = self.np;
375        let mut out = vec![T::default(); px * py * pz];
376        for k in 0..self.n[2] {
377            for j in 0..self.n[1] {
378                let src = self.lo[0] + (j + self.lo[1]) * nx + (k + self.lo[2]) * nx * ny;
379                let dst = j * px + k * px * py;
380                out[dst..dst + self.n[0]].copy_from_slice(&x[src..src + self.n[0]]);
381            }
382        }
383        out
384    }
385
386    /// The box of the padded `xp` put back on the full grid, zero outside it.
387    pub fn uncrop<T: Copy + Default>(&self, xp: &[T]) -> Vec<T> {
388        let (nx, ny, nz) = self.dims;
389        let (px, py, _) = self.np;
390        let mut out = vec![T::default(); nx * ny * nz];
391        for k in 0..self.n[2] {
392            for j in 0..self.n[1] {
393                let dst = self.lo[0] + (j + self.lo[1]) * nx + (k + self.lo[2]) * nx * ny;
394                let src = j * px + k * px * py;
395                out[dst..dst + self.n[0]].copy_from_slice(&xp[src..src + self.n[0]]);
396            }
397        }
398        out
399    }
400}
401
402/// QSM.jl's SHARP ([`SharpVariant::QsmJl`]) with an explicit kernel radius (mm).
403///
404/// Internal entry point shared with V-SHARP; the public [`sharp`] wrapper
405/// dispatches to it for [`SharpVariant::QsmJl`].
406pub(crate) fn sharp_core(
407    field: &[f64],
408    mask: &[u8],
409    grid: &Grid,
410    threshold: f64,
411    radius: f64,
412) -> (Vec<f64>, Vec<u8>) {
413    let Some(bx) = SmvBox::new(mask, grid, radius) else {
414        return (vec![0.0; field.len()], vec![0; mask.len()]);
415    };
416    let (local, eroded) = sharp_padded(&bx.crop(field), &bx.crop(mask), &bx.grid(grid), threshold, radius);
417    (bx.uncrop(&local), bx.uncrop(&eroded))
418}
419
420/// SHARP on QSM.jl's padded box (steps 2-5 of the module docs).
421fn sharp_padded(
422    field: &[f64],
423    mask: &[u8],
424    grid: &Grid,
425    threshold: f64,
426    radius: f64,
427) -> (Vec<f64>, Vec<u8>) {
428    let (nx, ny, nz) = grid.dims;
429    let n_total = nx * ny * nz;
430
431    // Generate SMV kernel and FFT it
432    let s_kernel = smv_kernel(grid, radius);
433    let s_fft = fft_real_kernel(&s_kernel, nx, ny, nz);
434
435    // Erode mask via SMV convolution
436    let eroded_mask = erode_mask_smv(mask, &s_fft, grid, QSMJL_SMV_DELTA);
437
438    // FFT of field
439    let mut field_complex: Vec<Complex64> = field.iter()
440        .map(|&x| Complex64::new(x, 0.0))
441        .collect();
442    fft3d(&mut field_complex, nx, ny, nz);
443
444    // High-pass filter: multiply by (1-S)
445    for i in 0..n_total {
446        field_complex[i] *= 1.0 - s_fft[i];
447    }
448
449    // IFFT
450    ifft3d(&mut field_complex, nx, ny, nz);
451
452    // Apply eroded mask
453    for i in 0..n_total {
454        if eroded_mask[i] == 0 {
455            field_complex[i] = Complex64::new(0.0, 0.0);
456        }
457    }
458
459    // FFT again for deconvolution
460    fft3d(&mut field_complex, nx, ny, nz);
461
462    // Deconvolution: divide by (1-S) with threshold
463    for i in 0..n_total {
464        let one_minus_s = 1.0 - s_fft[i];
465        if one_minus_s.abs() < threshold {
466            field_complex[i] = Complex64::new(0.0, 0.0);
467        } else {
468            field_complex[i] /= one_minus_s;
469        }
470    }
471
472    // Final IFFT
473    ifft3d(&mut field_complex, nx, ny, nz);
474
475    // Apply eroded mask and extract real part
476    let local_field: Vec<f64> = field_complex.iter()
477        .enumerate()
478        .map(|(i, c)| if eroded_mask[i] == 1 { c.re } else { 0.0 })
479        .collect();
480
481    (local_field, eroded_mask)
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn params(variant: SharpVariant) -> SharpParams {
489        SharpParams { threshold: 0.05, radius: 2.0, variant }
490    }
491
492    #[test]
493    fn test_sharp_zero_field() {
494        // Zero field should give zero local field
495        let n = 16;
496        let field = vec![0.0; n * n * n];
497        let mask = vec![1u8; n * n * n];
498        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
499
500        for variant in [SharpVariant::Sepia, SharpVariant::QsmJl] {
501            let (local, _) = sharp(&field, &mask, &grid, &params(variant));
502            for &val in local.iter() {
503                assert!(val.abs() < 1e-8, "{variant:?}: zero field should give zero local field, got {val}");
504            }
505        }
506    }
507
508    #[test]
509    fn test_sharp_finite() {
510        // Result should be finite (no NaN or Inf), on an odd grid too (SEPIA pads it)
511        let (nx, ny, nz) = (16, 15, 13);
512        let field: Vec<f64> = (0..nx * ny * nz).map(|i| (i as f64) * 0.01).collect();
513        let mut mask = vec![0u8; nx * ny * nz];
514        for k in 2..nz - 2 {
515            for j in 2..ny - 2 {
516                for i in 2..nx - 2 {
517                    mask[i + nx * (j + ny * k)] = 1;
518                }
519            }
520        }
521        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
522
523        for variant in [SharpVariant::Sepia, SharpVariant::QsmJl] {
524            let (local, eroded) = sharp(&field, &mask, &grid, &params(variant));
525            assert_eq!(local.len(), field.len());
526            for (i, &val) in local.iter().enumerate() {
527                assert!(val.is_finite(), "{variant:?}: local field should be finite at index {i}");
528                assert!(eroded[i] == 1 || val == 0.0, "{variant:?}: field outside the eroded mask at {i}");
529            }
530            let eroded_count: usize = eroded.iter().map(|&m| m as usize).sum();
531            assert!(eroded_count > 0, "{variant:?}: eroded mask should have some voxels");
532            assert!((0..mask.len()).all(|i| eroded[i] <= mask[i]), "{variant:?}: eroded mask outside the mask");
533        }
534    }
535
536    #[test]
537    fn test_sharp_defaults_are_sepias() {
538        let p = SharpParams::default();
539        assert_eq!((p.radius, p.threshold, p.variant), (4.0, 0.03, SharpVariant::Sepia));
540        let q = SharpParams::qsmjl(&Grid::new(8, 8, 8, 0.8, 1.0, 1.2));
541        assert_eq!((q.threshold, q.variant), (0.05, SharpVariant::QsmJl));
542        assert!((q.radius - 14.4).abs() < 1e-12);
543    }
544}