Skip to main content

qsm_core/bgremove/
vsharp.rs

1//! V-SHARP background field removal
2//!
3//! Variable kernel SHARP uses multiple SMV kernel radii
4//! to preserve more brain tissue at edges while still
5//! removing background fields.
6//!
7//! Each voxel takes the SMV high-pass `φ − S_r∗φ` of the largest radius `r`
8//! whose ball fits inside the mask. By default that high-passed field is returned
9//! as is, which is what the authors' code, STI Suite's `V_SHARP`, does. With
10//! [`VsharpParams::deconvolve`] on it is then deconvolved by the largest kernel,
11//! `1/(1 − Ŝ)` with TSVD at `threshold` (as QSM.jl does). The deconvolution restores low
12//! spatial frequencies; on real data those are dominated by residual background and
13//! field-map error, so leaving it off gives a more reproducible local field at the cost
14//! of attenuating genuinely low-frequency tissue contrast.
15//!
16//! ## Defaults
17//!
18//! The defaults are STI Suite 3.0's `V_SHARP` (Liu lab, the authors of the V-SHARP papers),
19//! which is P-code and was probed black-box (ukb-qsmxt `docs/08_vsharp_sti.md`): crop to the
20//! mask's bounding box + 12 voxels, linear resampling to a **1 mm** grid, balls `|x| ≤ r + 1/6`
21//! (centre excluded) for r = 1…`smvsize` mm, the largest fitting ball's background per voxel,
22//! **no deconvolution**, the background interpolated back; `smvsize` defaults to 12 mm. Here
23//! that is `deconvolve: false`, an [`Isotropic`](VsharpGrid::Isotropic) 1 mm grid (same 12-voxel
24//! crop margin, [`CROP_MARGIN`]), `max_radius` 12 and STI's radius schedule, 12 → 2 mm in 1 mm
25//! steps ([`VsharpParams::radius_step`]), and STI's ball, [`VsharpBall::StiSuite`] (`|x| ≤ r + 1/6`
26//! without its centre voxel, a ball fitting only where all its voxels are in the mask), and STI's
27//! crop: its `MaskBoundingBox` makes the bounding box even along every axis before the margin
28//! (`sti_even_box`).
29//!
30//! **Measured** (QSM-CI parity, astewartau/QSM.rs#329): on the KKI travelling head (256×256×128,
31//! 1 mm, STI Suite `V_SHARP` in the verified `vsharp-sti` v2 container, the same total field and
32//! mask), the STI ball gives the same eroded mask and NRMSE 2e-6 relative (STI's float32 output),
33//! where the earlier ball (radius `r`, centre included, [`VsharpBall::QsmJl`]) gave 4.4 %.
34//! `tests/vsharp_sti_parity.rs` holds a small case (40×44×36, `smvsize` 12 and 6, from the same
35//! container, regenerated in Actions by `tests/data/vsharp_sti_small.py`): the same eroded mask
36//! and max |Δ| < 5e-8 on a field of up to 0.15, STI's single-precision filtering.
37//! The papers and the code disagree on the deconvolution:
38//! Li et al. 2011 (NeuroImage 55:1645, *Image Analysis* and *Results*) and Wu et al. 2012
39//! (*Background phase removal*: "a deconvolution is carried out to restore the local phase",
40//! with the kernel of the largest diameter used) both deconvolve the SMV-filtered phase,
41//! without giving the kernel's truncation; STI Suite's `V_SHARP` does not (its impulse
42//! response is exactly `δ − S₁₂`). The code is followed. On 150 UK Biobank subjects × 2
43//! visits (ukb-qsmxt `docs/08_vsharp_sti.md`, PR #160) deconvolving lowered deep-grey-matter
44//! test-retest ICC (0.922 → 0.915) and removed the detectable longitudinal change (SRM
45//! putamen + caudate 0.161 → −0.019); on the noise-free CI phantom it is closer to the true
46//! local field, since there the low frequencies are tissue. Measured with the pipeline's ROMEO
47//! field and RTS against the previous defaults (deconvolution, native grid, radii 12 → 1 mm),
48//! on the voxels both keep: qsm-forward phantom χ XSIM 0.261 → 0.256, NRMSE 84.4 → 85.7 %,
49//! r 0.546 → 0.521; in-vivo 3T Philips scan, agreement with the UK Biobank pipeline's χ
50//! (STI Suite V-SHARP + iLSQR) XSIM 0.641 → 0.714, NRMSE 75.2 → 69.4 %, r 0.781 → 0.790. On
51//! these 1 mm isotropic inputs the 1 mm grid only adds the crop.
52//!
53//! **Anisotropic voxels** (astewartau/QSM.rs#329, measured 2026-10-30): on UK Biobank's
54//! 1×1×1.5 mm (a 32×32×20 phantom through `UKBiobank_QSM_core.m` in the verified `ukb-qsm` v2
55//! container, identical to licensed MATLAB R2023b on it) the eroded mask was STI's but the local
56//! field 4.1 % NRMSE from it. The cause was the crop, not the resampling: STI's
57//! `MaskBoundingBox` turns the mask's 11 slices into 12, and that slice's phase (outside the mask)
58//! reaches the 1 mm working grid through the z interpolation. Spied in licensed MATLAB, STI's
59//! resampling is exactly this module's (`interp3` linear, native samples at `(1:N)·vs` mm, working
60//! samples at `1:ceil(N·vs)` mm, the mask `> 0.5` both ways, the background interpolated back in
61//! float32), and its working-grid background is the phase outside the eroded mask and the
62//! largest fitting ball's `S_r∗φ` inside it. With the even box that phantom agrees to 1.2e-6. A
63//! second, smaller cause showed on non-integer resamplings (1.2×0.8×1.5 mm): STI interpolates the
64//! mask in single precision, and exact ties at 0.5 fall outside its `> 0.5` test where f64 put
65//! some just inside (`trilinear_mask_f32`; 0.96 % and 13 output voxels before). With both,
66//! `tests/vsharp_sti_aniso_parity.rs` (four phantoms from 1×1×1.5 to 0.7×0.7×3 mm, the verified
67//! `vsharp-sti` container) agrees to 7e-7–1.1e-6 NRMSE (STI's float32) with the same masks; before,
68//! 6e-4 to 2 % and up to 22 mask voxels apart.
69//!
70//! ## QSM.jl's `vsharp`
71//!
72//! The filtering (the per-voxel choice of radius and the deconvolution, and with
73//! [`VsharpBall::QsmJl`] the balls and the edge test) is a port of QSM.jl's `vsharp` (kamesy/QSM.jl fde4b92, `_sharp!` for a radius
74//! vector in `src/bgremove/sharp.jl`), including its working box: the mask's bounding box
75//! zero-padded to QSM.jl's fast FFT size for the largest radius, so the deconvolution is periodic
76//! on that box (as in [`super::sharp`]), and its edge test `S_r ∗ mask > 1 − √eps(Float64)`.
77//! QSM.jl's own defaults are reachable with
78//!
79//! ```
80//! use qsm_core::bgremove::{VsharpBall, VsharpGrid, VsharpParams};
81//! let vsz = (1.0_f64, 1.0_f64, 1.0_f64);
82//! let (lo, hi) = (vsz.0.min(vsz.1).min(vsz.2), vsz.0.max(vsz.1).max(vsz.2));
83//! let qsmjl = VsharpParams {
84//!     max_radius: 18.0 * lo,   // QSM.jl: r = 18*minimum(vsz):-2*maximum(vsz):2*maximum(vsz)
85//!     min_radius: 2.0 * hi,
86//!     radius_step: 2.0 * hi,
87//!     threshold: 0.05,         // QSM.jl's thr (also this module's default)
88//!     deconvolve: true,        // QSM.jl always deconvolves
89//!     ball: VsharpBall::QsmJl,  // QSM.jl's ball and edge test
90//!     grid: VsharpGrid::Native,
91//! };
92//! # let _ = qsmjl;
93//! ```
94//!
95//! The deconvolution threshold default, 0.05, is QSM.jl's `vsharp` `thr` and QSM.m's
96//! `vsharp.m` `thr` (kamesy/QSM.m f22dc40, `src/bgremove/vsharp.m:15,32`): STI Suite's
97//! `V_SHARP` (the original authors' code, whose other defaults this module keeps) does not
98//! deconvolve, so the threshold has no STI value. QSM.rs defaulted to 0.2 from PR #72
99//! (2026-09-07), a QSM.rs tuning: on the QSM Challenge 2.0 phantom (ROMEO field, V-SHARP,
100//! several dipole inversions) 0.2 gave a lower NRMSE on every inversion tested, and was
101//! identical to 0.05 on a noise-free field, because the higher threshold leaves out more of the
102//! low-frequency band where field-mapping error is amplified. It remains available as an
103//! explicit `threshold`.
104//!
105//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, ROMEO
106//! total field and brain mask) these settings and QSM.jl's `vsharp` in the QSM-CI `julia-qsm`
107//! image (`sha256:b3c048ac…`) agree to float rounding: NRMSE 2.6e-10 %, max |Δ| 5.8e-11 ppm,
108//! identical masks (both outputs saved as float32); with radii 12 → 1 mm in 1 mm steps, NRMSE
109//! 2.1e-10 %. Before the working box and edge test were QSM.jl's (the deconvolution was
110//! periodic on the whole field of view, and the edge test was `> 1 − √1e-7`), the 12 → 1 mm
111//! comparison gave NRMSE 15.5 %. `tests/sharp_qsmjl_parity.rs` checks the port against
112//! QSM.jl's output on small phantoms (max |Δ| < 1e-15 ppm).
113//!
114//! The working box depends on the voxel size at the float rounding level: its padding is
115//! `⌈r_max / min(vsz)⌉`, and NIfTI headers often store 1 mm as 0.99999994 (one float32 ulp
116//! below), for which a 12 mm radius pads 13 voxels, not 12. On the QSM-CI KKI travelling head
117//! (Philips 3 T, 256×256×128, header z spacing 0.99999994, `params.json` 1.0) that alone gave
118//! NRMSE 1.19 % between QSM.rs (header voxel size) and QSM.jl (`params.json`'s) with radii
119//! 12 → 1 mm (QSMxT/QSM-CI parity run 41029942914); given the same voxel size the outputs are
120//! identical in float32. The port keeps QSM.jl's rule (no rounding of the voxel size), so the
121//! two agree whenever they are given the same voxel size; `tests/sharp_qsmjl_parity.rs` has a
122//! case at that spacing (0.75 % from QSM.jl if 1.0 is used instead).
123//!
124//! The old edge test `1 − √1e-7` also let a ball count as fitting when it missed a few of its
125//! voxels (up to 2 of the 7,153 voxels of a 12 mm ball at 1 mm), so near the rim some voxels took a larger
126//! radius than fits. The QSM.jl test fixes that for every setting, including the STI Suite
127//! defaults: on the travelling-head scan the default output moved by NRMSE 1.8 % (same
128//! output mask), all of it from the edge test (the working box alone changes it by 2e-7 %).
129//!
130//! ## Working grid
131//!
132//! The SMV balls live on a voxel grid. On strongly anisotropic voxels (e.g. 0.8 × 0.8 × 3 mm)
133//! the native grid makes them crude in the coarse direction: a 1–2 mm ball is a single-slice
134//! disk. [`VsharpParams::grid`] can instead estimate the background on an isotropic working
135//! grid, at the finest input voxel size ([`VsharpGrid::IsotropicFinest`]) or at a fixed spacing
136//! ([`VsharpGrid::Isotropic`]):
137//!
138//! 1. crop the field and mask to the mask's bounding box, made even along each axis as STI's
139//!    `MaskBoundingBox` does (`sti_even_box`), with a zero margin of
140//!    [`CROP_MARGIN`] native voxels per side (this also fixes where the working grid sits
141//!    relative to the anatomy, so it lands the same way on repeat scans);
142//! 2. resample both trilinearly onto the isotropic grid (mask: interpolated value > 0.5), axes
143//!    whose voxel size already equals the spacing are left as they are;
144//! 3. run V-SHARP there (same balls, radii, edge test and deconvolution as on the native grid);
145//! 4. interpolate the **background** (field − local field on the working grid) back to the native
146//!    grid trilinearly, and return local = native field − background, on the voxels where the
147//!    back-interpolated working-grid support is > 0.5.
148//!
149//! The local field thus keeps native resolution; only the smooth background estimate is made on
150//! the working grid. The default is [`VsharpGrid::Isotropic`] at 1 mm (STI Suite's grid; see *Defaults*). [`VsharpGrid::IsotropicFinest`]
151//! makes the balls spherical without coarsening any axis, at the cost of a working grid that
152//! is larger by the anisotropy ratio. A fixed [`VsharpGrid::Isotropic`] spacing gives the same
153//! working grid whatever the acquisition resolution, but coarsens axes finer than it,
154//! including the mask edge the background is estimated from.
155//!
156//! ## Reference pipeline
157//!
158//! STI Suite 3.0's `V_SHARP(φ, mask, 'voxelsize', vs, 'smvsize', R)` (Liu lab, the authors of
159//! the V-SHARP papers) is P-code; black-box probing (ukb-qsmxt `docs/08_vsharp_sti.md`, scripts
160//! in `vsharp_probe/`) found: crop to the mask's bounding box, zero-pad 12 voxels per side,
161//! resample linearly to a **1 mm** grid, ball `|x| ≤ r + 1/6` without its centre for
162//! r = 1…R mm, each voxel takes the background `S_r∗φ` of the largest ball that fits,
163//! **no deconvolution**, background interpolated back and subtracted from the native field.
164//! Voxels where only r = 1 fits are dropped, so the radii in effect are 2…R mm and the output
165//! mask is the voxels a 2 mm (+1/6) ball fits around, back-interpolated `> 0.5`; this module's
166//! mask is the union down to [`VsharpParams::min_radius`], which by default is the same 2 mm
167//! (see *Defaults*), and the ball is STI's ([`VsharpBall::StiSuite`]).
168//! `smvsize` defaults to 12 (probed: bitwise equal to an explicit 12), which is also what STI
169//! Suite's GUI passes (its "Radius" field) and what QSMART's paper used for its V-SHARP
170//! comparison (Yaghmaie et al. 2021, §3.4).
171//!
172//! In STI Suite's GUI (probed headless; see [`crate::inversion::star`]), V-SHARP's input is the
173//! **sum** of the echoes' unwrapped phases from `MRPhaseUnwrap` on the raw, unmasked phase, its
174//! mask is the GUI's magnitude-threshold mask (not BET), and its eroded mask and output go to
175//! `QSM_star` or `QSM_iLSQR` with `TE` = the sum of the echo times.
176//!
177//! Reference:
178//! Wu, B., Li, W., Guidon, A., Liu, C. (2012).
179//! "Whole brain susceptibility mapping using compressed sensing."
180//! Magnetic Resonance in Medicine, 67(1):137-147. https://doi.org/10.1002/mrm.23000
181//!
182//! Implementation follows QSM.jl (https://github.com/kamesy/QSM.jl; see *QSM.jl's `vsharp`*);
183//! defaults follow STI Suite 3.0's `V_SHARP` (see *Defaults*), except the deconvolution
184//! threshold, which only QSM.jl has.
185//!
186//! # Pipeline rules
187//!
188//! The facts above that a configuration can depart from are published as rules in
189//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `sti.unwrap-laplacian`, `sti.laplacian-solver`, `sti.echo-combination`, `sti.echo-weights`, `sti.bg-vsharp`, `sti.vsharp-no-deconvolve`, `sti.vsharp-radius`, `sti.vsharp-grid`, `sti.vsharp-min-radius`, `sti.vsharp-radius-step`.
190
191use num_complex::Complex64;
192use crate::error::InputError;
193use crate::Grid;
194use crate::fft::{fft3d, ifft3d};
195use crate::kernels::smv::{smv_kernel, sti_smv_kernel, erode_mask_smv};
196use super::sharp::{SmvBox, QSMJL_SMV_DELTA};
197
198/// Zero margin (native voxels per side) around the mask's bounding box when V-SHARP runs on an
199/// isotropic working grid. See the [module docs](self).
200pub const CROP_MARGIN: usize = 12;
201
202/// Smallest radius in effect in STI Suite's `V_SHARP`, in mm (it drops voxels only r = 1 fits):
203/// the default of [`VsharpParams::min_radius`].
204pub const STI_MIN_RADIUS_MM: f64 = 2.0;
205
206/// Step between STI Suite `V_SHARP`'s radii, in mm: the default of [`VsharpParams::radius_step`].
207pub const STI_RADIUS_STEP_MM: f64 = 1.0;
208
209/// Working-grid spacing of STI Suite's `V_SHARP`, in mm: the default of [`VsharpParams::grid`].
210pub const STI_GRID_MM: f64 = 1.0;
211
212/// The SMV ball V-SHARP filters with. See the [module docs](self#defaults).
213#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
214#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
215pub enum VsharpBall {
216    /// STI Suite's `V_SHARP` ball ([`sti_smv_kernel`]): the voxels within `r + 1/6` mm,
217    /// centre voxel excluded; a ball fits where every one of its voxels is in the mask. The
218    /// default.
219    #[default]
220    StiSuite,
221    /// QSM.jl's ball ([`smv_kernel`]): the voxels within `r`, centre included; a ball fits
222    /// where `S∗mask > 1 − √1e-7`.
223    QsmJl,
224}
225
226/// Grid V-SHARP estimates the background field on. See the [module docs](self#working-grid).
227#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
228#[derive(Clone, Copy, Debug, Default, PartialEq)]
229pub enum VsharpGrid {
230    /// The input voxel grid (no resampling). The enum's `Default`; [`VsharpParams`] defaults
231    /// to [`Isotropic`](Self::Isotropic) at [`STI_GRID_MM`].
232    #[default]
233    Native,
234    /// An isotropic grid at the smallest input voxel dimension (e.g. 0.3 mm for
235    /// 0.3 × 0.3 × 1 mm): no axis is coarsened. Isotropic input runs on the native grid.
236    IsotropicFinest,
237    /// An isotropic grid of `spacing_mm`, anchored at the mask's bounding box. The background
238    /// is estimated there and interpolated back; the local field keeps native resolution.
239    Isotropic {
240        /// Working-grid voxel size in mm; must be finite and > 0.
241        spacing_mm: f64,
242    },
243}
244
245impl VsharpGrid {
246    /// Voxels count as isotropic for [`IsotropicFinest`](Self::IsotropicFinest) when the
247    /// largest voxel dimension exceeds the smallest by at most this fraction (1 %), so header
248    /// rounding does not trigger a resample.
249    pub const ANISOTROPY_TOLERANCE: f64 = 0.01;
250
251    /// This choice resolved for `voxel_size`: [`IsotropicFinest`](Self::IsotropicFinest)
252    /// becomes [`Isotropic`](Self::Isotropic) at the smallest voxel dimension, or
253    /// [`Native`](Self::Native) when the voxels are isotropic. The others are returned as is.
254    pub fn resolve(self, voxel_size: (f64, f64, f64)) -> Self {
255        match self {
256            VsharpGrid::IsotropicFinest => {
257                let (a, b, c) = voxel_size;
258                let (lo, hi) = (a.min(b).min(c), a.max(b).max(c));
259                if hi > lo * (1.0 + Self::ANISOTROPY_TOLERANCE) {
260                    VsharpGrid::Isotropic { spacing_mm: lo }
261                } else {
262                    VsharpGrid::Native
263                }
264            }
265            g => g,
266        }
267    }
268}
269
270/// V-SHARP algorithm parameters
271#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
272#[derive(Clone, Debug)]
273pub struct VsharpParams {
274    /// Deconvolution (TSVD) threshold: k-space frequencies where |1 - S| < threshold are dropped
275    /// rather than divided through (default: 0.05, QSM.jl `vsharp`'s and QSM.m `vsharp.m`'s
276    /// `thr`; STI Suite does not deconvolve). Only used when [`deconvolve`](Self::deconvolve) is
277    /// true. Higher values regularise more; 0.2 was QSM.rs's tuned default before (see the
278    /// module docs, *QSM.jl's `vsharp`*).
279    pub threshold: f64,
280    /// Maximum (starting) SMV kernel radius in mm (default: 12.0, STI Suite's `V_SHARP`
281    /// `smvsize` default, also what its `QSM_GUI` and the χ-separation toolbox pass)
282    pub max_radius: f64,
283    /// Smallest SMV kernel radius in mm (default: 2.0, STI Suite's schedule; see
284    /// [`radius_step`](Self::radius_step)). Voxels where no ball of at least this radius fits
285    /// are dropped from the output mask.
286    pub min_radius: f64,
287    /// Step between successive radii in mm (default: 1.0). The radii are `max_radius`,
288    /// `max_radius − radius_step`, … down to `min_radius`, largest first; each voxel takes the
289    /// largest that fits.
290    ///
291    /// The default schedule, 12, 11, …, 2 mm, is STI Suite's `V_SHARP`: it computes balls for
292    /// r = 1 … `smvsize` mm in 1 mm steps (ascending, larger radii overwriting, so each voxel
293    /// ends with the largest ball that fits) and then drops the voxels where only r = 1 fits,
294    /// so the radii in effect are 2 … 12 mm (black-box probe, ukb-qsmxt
295    /// `docs/08_vsharp_sti.md` step 4). Before this parameter existed `min_radius` was also
296    /// the step, which cannot express that schedule; the previous default, 12 → 1 mm in 1 mm
297    /// steps, is `min_radius: 1.0, radius_step: 1.0`. The ball shape is [`ball`](Self::ball).
298    ///
299    /// Measured against 12 → 1 mm (no deconvolution, 1 mm grid, ROMEO field, then RTS): the
300    /// output mask loses ~5 % of its voxels, all at the rim. On the voxels both keep, χ on the
301    /// qsm-forward phantom is unchanged (XSIM 0.257 → 0.256, NRMSE 85.8 → 85.7 %); on an
302    /// in-vivo 3T Philips scan its agreement with the UK Biobank pipeline's χ (STI Suite
303    /// V-SHARP + iLSQR) improves slightly, XSIM 0.704 → 0.714, NRMSE 70.4 → 69.4 %.
304    pub radius_step: f64,
305    /// Deconvolve the SMV high-passed field by the largest kernel, `1/(1 − Ŝ)` with TSVD at
306    /// [`threshold`](Self::threshold). Default false: return the high-passed field
307    /// `φ − S_r∗φ` itself, each voxel with the largest radius whose ball fits in the mask, as
308    /// the authors' code (STI Suite's `V_SHARP`) does. `true` is the papers' description
309    /// (Li 2011, Wu 2012) as QSM.jl implements it. The deconvolution restores low spatial
310    /// frequencies that `1 − Ŝ` attenuated; on real data those are dominated by residual
311    /// background and field-map error, so `false` is the more reproducible choice there, while
312    /// `true` recovers more low-frequency tissue contrast on a clean field (see the module
313    /// docs, *Defaults*).
314    pub deconvolve: bool,
315    /// The SMV ball (default [`VsharpBall::StiSuite`], STI Suite's `V_SHARP`). See the
316    /// [module docs](self#defaults).
317    pub ball: VsharpBall,
318    /// Grid the background is estimated on (default: [`VsharpGrid::Isotropic`] at 1 mm, as
319    /// STI Suite's `V_SHARP` resamples to 1 mm before filtering). See
320    /// [`VsharpParams::working_grid`] for the choice resolved on a given input.
321    /// [`VsharpGrid::Native`] avoids the interpolation and does not coarsen sub-millimetre
322    /// acquisitions' background estimate (on 1 mm isotropic data the two agree up to the crop).
323    pub grid: VsharpGrid,
324}
325
326impl VsharpParams {
327    /// Checks the parameters V-SHARP cannot run with: `min_radius` and `radius_step` must be
328    /// finite and > 0 — at a step of 0 the radius list never ends — and an
329    /// [`Isotropic`](VsharpGrid::Isotropic) working grid needs a spacing that is finite and > 0.
330    pub fn validate(&self) -> Result<(), InputError> {
331        if !(self.min_radius.is_finite() && self.min_radius > 0.0) {
332            return Err(InputError::param("V-SHARP min_radius", self.min_radius, "finite and > 0 mm"));
333        }
334        if !(self.radius_step.is_finite() && self.radius_step > 0.0) {
335            return Err(InputError::param("V-SHARP radius_step", self.radius_step, "finite and > 0 mm"));
336        }
337        if let VsharpGrid::Isotropic { spacing_mm } = self.grid {
338            if !(spacing_mm.is_finite() && spacing_mm > 0.0) {
339                return Err(InputError::param(
340                    "V-SHARP isotropic working grid spacing_mm", spacing_mm, "finite and > 0 mm",
341                ));
342            }
343        }
344        Ok(())
345    }
346
347    /// The working grid V-SHARP will use on `grid`: [`Self::grid`] resolved by
348    /// [`VsharpGrid::resolve`]; never [`VsharpGrid::IsotropicFinest`].
349    pub fn working_grid(&self, grid: &Grid) -> VsharpGrid {
350        self.grid.resolve(grid.voxel_size)
351    }
352}
353
354impl Default for VsharpParams {
355    fn default() -> Self {
356        Self {
357            // QSM.jl's `thr` (see the field docs); unused unless `deconvolve`.
358            threshold: 0.05,
359            max_radius: 12.0,
360            min_radius: STI_MIN_RADIUS_MM,
361            radius_step: STI_RADIUS_STEP_MM,
362            deconvolve: false,
363            ball: VsharpBall::StiSuite,
364            grid: VsharpGrid::Isotropic { spacing_mm: STI_GRID_MM },
365        }
366    }
367}
368
369/// V-SHARP background field removal
370///
371/// Uses multiple SMV kernel radii, starting from largest and decreasing.
372/// At each voxel, uses the largest radius whose ball fits inside the mask,
373/// then (if `params.deconvolve`) deconvolves by the largest kernel.
374///
375/// # Arguments
376/// * `field` - Unwrapped total field (nx * ny * nz)
377/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
378/// * `grid` - Volume dimensions and voxel sizes
379/// * `params` - V-SHARP parameters (min/max radius in mm, deconvolution on/off and its
380///   threshold, working grid)
381/// * `progress` - Progress callback (radius_index, total_radii)
382///
383/// # Returns
384/// (local_field, eroded_mask)
385///
386/// # Errors
387/// If [`VsharpParams::validate`] rejects `params`.
388pub fn vsharp(
389    field: &[f64],
390    mask: &[u8],
391    grid: &Grid,
392    params: &VsharpParams,
393    progress: impl FnMut(usize, usize),
394) -> Result<(Vec<f64>, Vec<u8>), InputError> {
395    params.validate()?;
396    let radii = vsharp_radii(params);
397    let deconvolve = params.deconvolve.then_some(params.threshold);
398    Ok(match params.working_grid(grid) {
399        VsharpGrid::Native => vsharp_with_radii(field, mask, grid, &radii, deconvolve, params.ball, progress),
400        VsharpGrid::Isotropic { spacing_mm } => {
401            vsharp_isotropic(field, mask, grid, spacing_mm, &radii, deconvolve, params.ball, progress)
402        }
403        VsharpGrid::IsotropicFinest => unreachable!("working_grid resolves IsotropicFinest"),
404    })
405}
406
407/// V-SHARP with the background estimated on an isotropic grid of spacing `h` mm anchored at the
408/// mask's bounding box (see the [module docs](self#working-grid)).
409fn vsharp_isotropic(
410    field: &[f64],
411    mask: &[u8],
412    grid: &Grid,
413    h: f64,
414    radii: &[f64],
415    deconvolve: Option<f64>,
416    ball: VsharpBall,
417    progress: impl FnMut(usize, usize),
418) -> (Vec<f64>, Vec<u8>) {
419    let (nx, ny, nz) = grid.dims;
420    let vs = [grid.voxel_size.0, grid.voxel_size.1, grid.voxel_size.2];
421    let mut local_out = vec![0.0; nx * ny * nz];
422    let mut mask_out = vec![0u8; nx * ny * nz];
423    let Some((lo, hi)) = bounding_box(mask, grid.dims) else { return (local_out, mask_out) };
424    let (lo, hi) = sti_even_box(lo, hi, [nx, ny, nz]);
425
426    // 1. crop to the bounding box with a zero margin
427    let pad = CROP_MARGIN;
428    let np = [0, 1, 2].map(|d| hi[d] - lo[d] + 1 + 2 * pad);
429    let mut p = vec![0.0; np[0] * np[1] * np[2]];
430    let mut m = vec![0.0; p.len()];
431    for k in lo[2]..=hi[2] {
432        for j in lo[1]..=hi[1] {
433            for i in lo[0]..=hi[0] {
434                let src = i + j * nx + k * nx * ny;
435                let dst = (i - lo[0] + pad) + (j - lo[1] + pad) * np[0] + (k - lo[2] + pad) * np[0] * np[1];
436                p[dst] = field[src];
437                m[dst] = if mask[src] != 0 { 1.0 } else { 0.0 };
438            }
439        }
440    }
441
442    // 2. resample onto the working grid W: sample q (1-based) of axis d sits at 1-based native
443    // index q·h/vs[d]; an axis already at spacing h stays on its native lattice. W is zero-filled
444    // to even dimensions.
445    let ni = [0, 1, 2].map(|d| if vs[d] == h { np[d] } else { (np[d] as f64 * vs[d] / h).ceil() as usize });
446    let fwd = [0, 1, 2].map(|d| {
447        (1..=ni[d]).map(|q| if vs[d] == h { q as f64 } else { q as f64 * h / vs[d] }).collect::<Vec<f64>>()
448    });
449    let pi = trilinear(&p, np, &fwd);
450    // STI interpolates the mask as single (`interp3` on a single array), and the `> 0.5` test
451    // below meets exact ties at 0.5 on non-integer resamplings, so this follows its arithmetic
452    let mi = trilinear_mask_f32(&m, np, &fwd);
453    let ne = ni.map(|n| n + n % 2);
454    let mut pe = vec![0.0; ne[0] * ne[1] * ne[2]];
455    let mut me = vec![0u8; pe.len()];
456    for k in 0..ni[2] {
457        for j in 0..ni[1] {
458            for i in 0..ni[0] {
459                let s = i + j * ni[0] + k * ni[0] * ni[1];
460                let d = i + j * ne[0] + k * ne[0] * ne[1];
461                pe[d] = pi[s];
462                me[d] = u8::from(mi[s] > 0.5f32);
463            }
464        }
465    }
466    drop((pi, mi));
467
468    // 3. V-SHARP on W
469    let w_grid = Grid::new(ne[0], ne[1], ne[2], h, h, h);
470    let (local_w, support_w) = vsharp_with_radii(&pe, &me, &w_grid, radii, deconvolve, ball, progress);
471
472    // 4. background (field − local) and support back to the native grid
473    let mut bg_c = vec![0.0; ni[0] * ni[1] * ni[2]];
474    let mut nm_c = vec![0.0; bg_c.len()];
475    for k in 0..ni[2] {
476        for j in 0..ni[1] {
477            for i in 0..ni[0] {
478                let d = i + j * ni[0] + k * ni[0] * ni[1];
479                let s = i + j * ne[0] + k * ne[0] * ne[1];
480                bg_c[d] = pe[s] - local_w[s];
481                nm_c[d] = f64::from(support_w[s]);
482            }
483        }
484    }
485    let back = [0, 1, 2].map(|d| {
486        (1..=np[d]).map(|a| if vs[d] == h { a as f64 } else { a as f64 * vs[d] / h }).collect::<Vec<f64>>()
487    });
488    let bg_n = trilinear(&bg_c, ni, &back);
489    let nm_n = trilinear_mask_f32(&nm_c, ni, &back);
490    for k in lo[2]..=hi[2] {
491        for j in lo[1]..=hi[1] {
492            for i in lo[0]..=hi[0] {
493                let s = (i - lo[0] + pad) + (j - lo[1] + pad) * np[0] + (k - lo[2] + pad) * np[0] * np[1];
494                if nm_n[s] > 0.5f32 {
495                    let d = i + j * nx + k * nx * ny;
496                    local_out[d] = p[s] - bg_n[s];
497                    mask_out[d] = 1;
498                }
499            }
500        }
501    }
502    (local_out, mask_out)
503}
504
505/// STI Suite's `MaskBoundingBox(mask, voxelsize)` (V_SHARP's crop): the mask's bounding box made
506/// even along every axis. An odd extent grows by one voxel at its high end, or at its low end
507/// when the high end is the array's last voxel; an odd axis the mask fills entirely loses its
508/// last voxel. The voxel size plays no part. Probed black-box in licensed MATLAB R2023b on
509/// 17,952 boxes (lengths 8–40, eleven voxel sizes 0.5–3 mm, every position), all reproduced.
510///
511/// This was the anisotropic-voxel gap (astewartau/QSM.rs#329): the extra slice holds phase from
512/// outside the mask, and on a non-1 mm axis the resampling carries it onto the working grid
513/// next to the mask edge. See the [module docs](self#defaults).
514fn sti_even_box(mut lo: [usize; 3], mut hi: [usize; 3], n: [usize; 3]) -> ([usize; 3], [usize; 3]) {
515    for d in 0..3 {
516        if (hi[d] - lo[d] + 1) % 2 == 1 {
517            if hi[d] + 1 < n[d] {
518                hi[d] += 1;
519            } else if lo[d] > 0 {
520                lo[d] -= 1;
521            } else if hi[d] > lo[d] {
522                hi[d] -= 1;
523            }
524        }
525    }
526    (lo, hi)
527}
528
529/// Inclusive bounding box of the nonzero mask voxels, or `None` for an empty mask.
530fn bounding_box(mask: &[u8], dims: (usize, usize, usize)) -> Option<([usize; 3], [usize; 3])> {
531    let (nx, ny, nz) = dims;
532    let mut lo = [usize::MAX; 3];
533    let mut hi = [0usize; 3];
534    let mut any = false;
535    for k in 0..nz {
536        for j in 0..ny {
537            for i in 0..nx {
538                if mask[i + j * nx + k * nx * ny] != 0 {
539                    any = true;
540                    for (d, v) in [i, j, k].into_iter().enumerate() {
541                        lo[d] = lo[d].min(v);
542                        hi[d] = hi[d].max(v);
543                    }
544                }
545            }
546        }
547    }
548    any.then_some((lo, hi))
549}
550
551/// Separable trilinear interpolation (MATLAB `interp3(..., 'linear')`): `coords[d]` are 1-based
552/// fractional source indices along axis `d`, one output sample per entry, output in the same
553/// Fortran order. Samples outside `[1, n]` on any axis are 0.
554fn trilinear(src: &[f64], n: [usize; 3], coords: &[Vec<f64>; 3]) -> Vec<f64> {
555    let w = |d: usize| -> Vec<Option<(usize, f64)>> {
556        coords[d]
557            .iter()
558            .map(|&u| {
559                if !(1.0..=n[d] as f64).contains(&u) {
560                    return None;
561                }
562                let t = u - 1.0;
563                let i0 = (t.floor() as usize).min(n[d].saturating_sub(2));
564                Some((i0, t - i0 as f64))
565            })
566            .collect()
567    };
568    let (wx, wy, wz) = (w(0), w(1), w(2));
569    let (ox, oy) = (wx.len(), wy.len());
570    let at = |i: usize, j: usize, k: usize| -> f64 {
571        if i < n[0] && j < n[1] && k < n[2] { src[i + j * n[0] + k * n[0] * n[1]] } else { 0.0 }
572    };
573    let mut out = vec![0.0; ox * oy * wz.len()];
574    for (c, wzk) in wz.iter().enumerate() {
575        let Some((k0, fz)) = *wzk else { continue };
576        for (b, wyj) in wy.iter().enumerate() {
577            let Some((j0, fy)) = *wyj else { continue };
578            for (a, wxi) in wx.iter().enumerate() {
579                let Some((i0, fx)) = *wxi else { continue };
580                let mut v = 0.0;
581                for (dk, gz) in [(0, 1.0 - fz), (1, fz)] {
582                    if gz == 0.0 { continue; }
583                    for (dj, gy) in [(0, 1.0 - fy), (1, fy)] {
584                        if gy == 0.0 { continue; }
585                        for (di, gx) in [(0, 1.0 - fx), (1, fx)] {
586                            if gx == 0.0 { continue; }
587                            v += gx * gy * gz * at(i0 + di, j0 + dj, k0 + dk);
588                        }
589                    }
590                }
591                out[a + b * ox + c * ox * oy] = v;
592            }
593        }
594    }
595    out
596}
597
598/// [`trilinear`] of a 0/1 mask as MATLAB's `interp3(..., 'linear')` computes it on a **single**
599/// array, which is how STI Suite's `V_SHARP` resamples its mask both ways: each fraction rounded
600/// to f32, then `(1 − f)·v₀ + f·v₁` in f32, along axis 1 first (`interp3`'s X, the second array
601/// dimension), then axis 0, then axis 2.
602///
603/// The masks are thresholded at `> 0.5`, and on non-integer resamplings exact ties at 0.5 occur;
604/// f64 puts some of them at 0.5 + 1e-15 (in), STI's single at 0.5 or 0.49999997 (out). On a
605/// 27×31×24 phantom at 1.2×0.8×1.5 mm (STI Suite `V_SHARP` in licensed MATLAB R2023b, spied,
606/// 2026-10-30) this reproduces every one of STI's thresholded working-mask voxels (the values to
607/// 1 ulp at 39 of 116,280); f64 put 19 voxels on the other side and moved the local field by 0.96 %.
608fn trilinear_mask_f32(src: &[f64], n: [usize; 3], coords: &[Vec<f64>; 3]) -> Vec<f32> {
609    let w = |d: usize| -> Vec<Option<(usize, f32)>> {
610        coords[d]
611            .iter()
612            .map(|&u| {
613                if !(1.0..=n[d] as f64).contains(&u) {
614                    return None;
615                }
616                let t = u - 1.0;
617                let i0 = (t.floor() as usize).min(n[d].saturating_sub(2));
618                Some((i0, (t - i0 as f64) as f32))
619            })
620            .collect()
621    };
622    let (wx, wy, wz) = (w(0), w(1), w(2));
623    let (ox, oy) = (wx.len(), wy.len());
624    let at = |i: usize, j: usize, k: usize| -> f32 {
625        if i < n[0] && j < n[1] && k < n[2] { src[i + j * n[0] + k * n[0] * n[1]] as f32 } else { 0.0 }
626    };
627    let lerp = |v0: f32, v1: f32, f: f32| (1.0 - f) * v0 + f * v1;
628    let mut out = vec![0.0f32; ox * oy * wz.len()];
629    for (c, wzk) in wz.iter().enumerate() {
630        let Some((k0, fz)) = *wzk else { continue };
631        for (b, wyj) in wy.iter().enumerate() {
632            let Some((j0, fy)) = *wyj else { continue };
633            for (a, wxi) in wx.iter().enumerate() {
634                let Some((i0, fx)) = *wxi else { continue };
635                // along axis 1, then 0, then 2
636                let plane = |k: usize| {
637                    let r0 = lerp(at(i0, j0, k), at(i0, j0 + 1, k), fy);
638                    let r1 = lerp(at(i0 + 1, j0, k), at(i0 + 1, j0 + 1, k), fy);
639                    lerp(r0, r1, fx)
640                };
641                out[a + b * ox + c * ox * oy] = lerp(plane(k0), plane(k0 + 1), fz);
642            }
643        }
644    }
645    out
646}
647
648/// Build the descending list of SMV kernel radii (mm) from the params.
649///
650/// Starts at `max_radius` (mm) and steps down by `radius_step` (mm) while the radius is at
651/// least `min_radius` (with a tolerance of 1e-9 mm for the floating-point steps).
652fn vsharp_radii(params: &VsharpParams) -> Vec<f64> {
653    let mut radii = Vec::new();
654    let step = params.radius_step;
655    let mut k = 0usize;
656    loop {
657        let r = params.max_radius - k as f64 * step;
658        if r < params.min_radius - 1e-9 {
659            break;
660        }
661        radii.push(r);
662        k += 1;
663    }
664    if radii.is_empty() {
665        radii.push(params.max_radius);
666    }
667    radii
668}
669
670/// V-SHARP with an explicit list of SMV kernel radii (mm).
671///
672/// Internal entry point; the public [`vsharp`] wrapper derives the radii
673/// from [`VsharpParams`]. `deconvolve` is the TSVD threshold of the final
674/// deconvolution, or `None` to return the SMV high-passed field undeconvolved. `ball` picks
675/// the SMV kernel and its edge test.
676///
677/// Like QSM.jl's `_sharp!` for a radius vector (`src/bgremove/sharp.jl`, fde4b92), it works in
678/// the mask's bounding box zero-padded for the largest radius ([`SmvBox`]), so the
679/// deconvolution is periodic on that box as in QSM.jl.
680#[allow(clippy::too_many_arguments)]
681pub(crate) fn vsharp_with_radii(
682    field: &[f64],
683    mask: &[u8],
684    grid: &Grid,
685    radii: &[f64],
686    deconvolve: Option<f64>,
687    ball: VsharpBall,
688    mut progress: impl FnMut(usize, usize),
689) -> (Vec<f64>, Vec<u8>) {
690    let (nx, ny, nz) = grid.dims;
691
692    if radii.is_empty() {
693        return (vec![0.0; nx * ny * nz], mask.to_vec());
694    }
695
696    // If only one radius and deconvolving with SHARP's ball, this is regular SHARP
697    if let (1, Some(threshold), VsharpBall::QsmJl) = (radii.len(), deconvolve, ball) {
698        progress(1, 1);
699        return crate::bgremove::sharp::sharp_core(
700            field, mask, grid, threshold, radii[0]
701        );
702    }
703
704    let r_max = radii.iter().cloned().fold(f64::MIN, f64::max);
705    let Some(bx) = SmvBox::new(mask, grid, r_max) else {
706        return (vec![0.0; nx * ny * nz], vec![0; nx * ny * nz]);
707    };
708    let (local, support) = vsharp_padded(&bx.crop(field), &bx.crop(mask), &bx.grid(grid), radii, deconvolve, ball, progress);
709    (bx.uncrop(&local), bx.uncrop(&support))
710}
711
712/// V-SHARP on QSM.jl's padded box.
713fn vsharp_padded(
714    field: &[f64],
715    mask: &[u8],
716    grid: &Grid,
717    radii: &[f64],
718    deconvolve: Option<f64>,
719    ball: VsharpBall,
720    mut progress: impl FnMut(usize, usize),
721) -> (Vec<f64>, Vec<u8>) {
722    let (nx, ny, nz) = grid.dims;
723    let n_total = nx * ny * nz;
724    let n_radii = radii.len();
725
726    // Sort radii from largest to smallest
727    let mut sorted_radii = radii.to_vec();
728    sorted_radii.sort_by(|a, b| b.partial_cmp(a).unwrap());
729
730    // FFT of field
731    let mut field_complex: Vec<Complex64> = field.iter()
732        .map(|&x| Complex64::new(x, 0.0))
733        .collect();
734    fft3d(&mut field_complex, nx, ny, nz);
735    let field_fft = field_complex.clone();
736
737    // Track which voxels have been processed and final mask
738    let mut processed = vec![false; n_total];
739    let mut local_field = vec![0.0; n_total];
740    let mut final_mask = vec![0u8; n_total];
741
742    // A ball fits where S∗mask reaches 1. STI's test admits no missing voxel (its eroded mask
743    // is reproduced exactly with a tolerance far below one voxel of a 2 mm ball, 1/32).
744    let delta = match ball {
745        VsharpBall::StiSuite => 1.0 - 1e-9,
746        VsharpBall::QsmJl => QSMJL_SMV_DELTA,
747    };
748    let mut inverse_kernel: Option<Vec<f64>> = None;
749
750    for (idx, &radius) in sorted_radii.iter().enumerate() {
751        // Report progress
752        progress(idx + 1, n_radii);
753
754        // Generate SMV kernel
755        let s_kernel = match ball {
756            VsharpBall::StiSuite => sti_smv_kernel(grid, radius),
757            VsharpBall::QsmJl => smv_kernel(grid, radius),
758        };
759
760        // FFT of SMV kernel
761        let mut s_complex: Vec<Complex64> = s_kernel.iter()
762            .map(|&x| Complex64::new(x, 0.0))
763            .collect();
764        fft3d(&mut s_complex, nx, ny, nz);
765        let s_fft: Vec<f64> = s_complex.iter().map(|c| c.re).collect();
766
767        // Store inverse of first (largest) kernel
768        if let (0, Some(threshold)) = (idx, deconvolve) {
769            inverse_kernel = Some(s_fft.iter().map(|&s| {
770                let one_minus_s = 1.0 - s;
771                if one_minus_s.abs() < threshold {
772                    0.0
773                } else {
774                    1.0 / one_minus_s
775                }
776            }).collect());
777        }
778
779        // Erode mask for this radius
780        let eroded = erode_mask_smv(mask, &s_fft, grid, delta);
781        let current_mask: Vec<bool> = eroded.iter().map(|&m| m == 1).collect();
782
783        // Apply high-pass filter
784        let mut filtered = field_fft.clone();
785        for i in 0..n_total {
786            filtered[i] *= 1.0 - s_fft[i];
787        }
788
789        ifft3d(&mut filtered, nx, ny, nz);
790
791        for i in 0..n_total {
792            if current_mask[i] && !processed[i] {
793                local_field[i] = filtered[i].re;
794                processed[i] = true;
795                final_mask[i] = 1;
796            }
797        }
798    }
799
800    // Deconvolution (only built when `deconvolve` is set)
801    if let Some(inv_kernel) = inverse_kernel {
802        let mut local_complex: Vec<Complex64> = local_field.iter()
803            .map(|&x| Complex64::new(x, 0.0))
804            .collect();
805
806        fft3d(&mut local_complex, nx, ny, nz);
807
808        for i in 0..n_total {
809            local_complex[i] *= inv_kernel[i];
810        }
811
812        ifft3d(&mut local_complex, nx, ny, nz);
813
814        for i in 0..n_total {
815            local_field[i] = if final_mask[i] == 1 { local_complex[i].re } else { 0.0 };
816        }
817    }
818
819    (local_field, final_mask)
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825
826    /// STI Suite's `MaskBoundingBox(mask, [vs 1 1])` in licensed MATLAB R2023b (2026-10-30) on a
827    /// mask spanning `lo..=hi` (1-based) of an axis of length `n`: (n, vs, lo, hi, STI lo, STI hi).
828    #[test]
829    fn sti_even_box_matches_mask_bounding_box() {
830        let probed = [
831            (9, 1.5, 1, 9, 1, 8), (9, 1.0, 1, 8, 1, 8), (9, 1.0, 2, 9, 2, 9), (9, 1.0, 9, 9, 8, 9),
832            (9, 0.8, 3, 7, 3, 8), (20, 2.5, 5, 10, 5, 10), (20, 1.2, 5, 11, 5, 12), (21, 0.7, 1, 21, 1, 20),
833            (40, 3.0, 40, 40, 39, 40), (8, 1.0, 1, 1, 1, 2),
834        ];
835        for (n, _vs, lo, hi, slo, shi) in probed {
836            let (l, h) = sti_even_box([lo - 1, 0, 0], [hi - 1, 1, 1], [n, 4, 4]);
837            assert_eq!((l[0] + 1, h[0] + 1), (slo, shi), "n {n}, {lo}..={hi}");
838            assert_eq!((l[1], h[1]), (0, 1), "an even extent is kept");
839        }
840    }
841
842    #[test]
843    fn test_vsharp_zero_field() {
844        let n = 8;
845        let field = vec![0.0; n * n * n];
846        let mask = vec![1u8; n * n * n];
847        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
848
849        let radii = vec![4.0, 3.0, 2.0];
850        let (local, _) = vsharp_with_radii(&field, &mask, &grid, &radii, Some(0.05), VsharpBall::QsmJl, |_, _| {});
851
852        for &val in local.iter() {
853            assert!(val.abs() < 1e-10);
854        }
855    }
856
857    #[test]
858    fn test_vsharp_preserves_more_than_sharp() {
859        let n = 16;
860        let field = vec![0.0; n * n * n];
861        let mask = vec![1u8; n * n * n];
862        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
863
864        // V-SHARP with multiple radii
865        let radii = vec![5.0, 4.0, 3.0, 2.0];
866        let (_, vsharp_mask) = vsharp_with_radii(&field, &mask, &grid, &radii, Some(0.05), VsharpBall::QsmJl, |_, _| {});
867
868        // SHARP with single large radius
869        let (_, sharp_mask) = crate::bgremove::sharp::sharp_core(
870            &field, &mask, &grid, 0.05, 5.0
871        );
872
873        let vsharp_count: usize = vsharp_mask.iter().map(|&m| m as usize).sum();
874        let sharp_count: usize = sharp_mask.iter().map(|&m| m as usize).sum();
875
876        // V-SHARP should preserve at least as many voxels as SHARP
877        assert!(vsharp_count >= sharp_count,
878            "V-SHARP {} should preserve at least as many as SHARP {}",
879            vsharp_count, sharp_count);
880    }
881
882    #[test]
883    fn test_vsharp_nonuniform_voxels() {
884        // Balls that fit in the mask on every axis (the filtering box is zero-padded, as in
885        // QSM.jl, so a ball no longer fits by wrapping around an all-ones mask).
886        let n = 16;
887        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
888        let mask = vec![1u8; n * n * n];
889        let grid = Grid::new(n, n, n, 0.5, 1.0, 2.0);
890
891        // Anisotropic voxel sizes
892        let radii = vec![4.0, 3.0, 2.0];
893        let (local, final_mask) = vsharp_with_radii(
894            &field, &mask, &grid, &radii, Some(0.05), VsharpBall::QsmJl, |_, _| {}
895        );
896
897        // All values should be finite
898        for (i, &val) in local.iter().enumerate() {
899            assert!(val.is_finite(), "V-SHARP nonuniform voxels: finite at index {}", i);
900        }
901
902        // Final mask should have some voxels
903        let mask_count: usize = final_mask.iter().map(|&m| m as usize).sum();
904        assert!(mask_count > 0, "V-SHARP nonuniform: final mask should have some voxels");
905    }
906
907    #[test]
908    fn test_vsharp_single_radius() {
909        let n = 8;
910        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
911        let mask = vec![1u8; n * n * n];
912        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
913
914        // Single radius should delegate to SHARP
915        let radii = vec![3.0];
916        let (local, final_mask) = vsharp_with_radii(
917            &field, &mask, &grid, &radii, Some(0.05), VsharpBall::QsmJl, |_, _| {}
918        );
919
920        // All values should be finite
921        for (i, &val) in local.iter().enumerate() {
922            assert!(val.is_finite(), "V-SHARP single radius: finite at index {}", i);
923        }
924
925        // Result should match SHARP with same radius
926        let (sharp_local, sharp_mask) = crate::bgremove::sharp::sharp_core(
927            &field, &mask, &grid, 0.05, 3.0
928        );
929
930        for i in 0..n*n*n {
931            assert!(
932                (local[i] - sharp_local[i]).abs() < 1e-10,
933                "Single-radius V-SHARP should match SHARP at index {}", i
934            );
935        }
936
937        assert_eq!(final_mask, sharp_mask, "Single-radius V-SHARP mask should match SHARP mask");
938    }
939
940    #[test]
941    fn test_vsharp_empty_radii() {
942        // Empty radii should return zeros and the original mask
943        let n = 8;
944        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
945        let mask = vec![1u8; n * n * n];
946        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
947
948        let (local, returned_mask) = vsharp_with_radii(
949            &field, &mask, &grid, &[], Some(0.05), VsharpBall::QsmJl, |_, _| {}
950        );
951
952        for &val in &local {
953            assert_eq!(val, 0.0, "Empty radii should return zero local field");
954        }
955        assert_eq!(returned_mask, mask, "Empty radii should return original mask");
956    }
957
958    #[test]
959    fn test_vsharp_larger_volume() {
960        // 16x16x16 volume with spherical mask
961        let n = 16;
962        let mut field = vec![0.0; n * n * n];
963        // Linear background field in z
964        for z in 0..n {
965            for y in 0..n {
966                for x in 0..n {
967                    field[x + y * n + z * n * n] = (z as f64) * 0.1;
968                }
969            }
970        }
971
972        // Spherical mask
973        let mut mask = vec![0u8; n * n * n];
974        let center = n / 2;
975        let radius = n / 3;
976        for z in 0..n {
977            for y in 0..n {
978                for x in 0..n {
979                    let dx = (x as i32) - (center as i32);
980                    let dy = (y as i32) - (center as i32);
981                    let dz = (z as i32) - (center as i32);
982                    if dx * dx + dy * dy + dz * dz <= (radius * radius) as i32 {
983                        mask[x + y * n + z * n * n] = 1;
984                    }
985                }
986            }
987        }
988
989        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
990        let radii = vec![6.0, 4.0, 3.0, 2.0];
991        let (local, final_mask) = vsharp_with_radii(
992            &field, &mask, &grid, &radii, Some(0.05), VsharpBall::QsmJl, |_, _| {}
993        );
994
995        assert_eq!(local.len(), n * n * n);
996        for &val in &local {
997            assert!(val.is_finite(), "V-SHARP larger volume values should be finite");
998        }
999
1000        let mask_count: usize = final_mask.iter().map(|&m| m as usize).sum();
1001        assert!(mask_count > 0, "V-SHARP larger volume should have voxels in final mask");
1002
1003        // Voxels outside the final mask should be zero
1004        for i in 0..n * n * n {
1005            if final_mask[i] == 0 {
1006                assert_eq!(local[i], 0.0, "Outside final mask should be zero");
1007            }
1008        }
1009    }
1010
1011    #[test]
1012    fn test_vsharp_with_progress() {
1013        let n = 8;
1014        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
1015        let mask = vec![1u8; n * n * n];
1016        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1017
1018        let radii = vec![4.0, 3.0, 2.0];
1019        let mut progress_calls = Vec::new();
1020        let (local, _) = vsharp_with_radii(
1021            &field, &mask, &grid, &radii, Some(0.05), VsharpBall::QsmJl,
1022            |idx, total| { progress_calls.push((idx, total)); }
1023        );
1024
1025        assert_eq!(local.len(), n * n * n);
1026        assert!(!progress_calls.is_empty(), "Progress should be called");
1027        for &val in &local {
1028            assert!(val.is_finite());
1029        }
1030    }
1031
1032    #[test]
1033    fn test_vsharp_with_progress_single_radius() {
1034        let n = 8;
1035        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
1036        let mask = vec![1u8; n * n * n];
1037        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1038
1039        let radii = vec![3.0];
1040        let mut progress_calls = Vec::new();
1041        let (local, _) = vsharp_with_radii(
1042            &field, &mask, &grid, &radii, Some(0.05), VsharpBall::QsmJl,
1043            |idx, total| { progress_calls.push((idx, total)); }
1044        );
1045
1046        assert_eq!(local.len(), n * n * n);
1047        assert!(!progress_calls.is_empty(), "Progress should be called for single radius");
1048        for &val in &local {
1049            assert!(val.is_finite());
1050        }
1051    }
1052
1053    #[test]
1054    fn test_vsharp_with_progress_empty_radii() {
1055        let n = 8;
1056        let field = vec![0.0; n * n * n];
1057        let mask = vec![1u8; n * n * n];
1058        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1059
1060        let mut progress_calls = Vec::new();
1061        let (local, returned_mask) = vsharp_with_radii(
1062            &field, &mask, &grid, &[], Some(0.05), VsharpBall::QsmJl,
1063            |idx, total| { progress_calls.push((idx, total)); }
1064        );
1065
1066        for &val in &local {
1067            assert_eq!(val, 0.0);
1068        }
1069        assert_eq!(returned_mask, mask);
1070    }
1071
1072    #[test]
1073    fn test_vsharp_unsorted_radii() {
1074        // Radii given in arbitrary order - should be sorted internally
1075        let n = 8;
1076        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
1077        let mask = vec![1u8; n * n * n];
1078        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1079
1080        let radii_sorted = vec![4.0, 3.0, 2.0];
1081        let radii_unsorted = vec![2.0, 4.0, 3.0];
1082
1083        let (local_sorted, mask_sorted) = vsharp_with_radii(
1084            &field, &mask, &grid, &radii_sorted, Some(0.05), VsharpBall::QsmJl, |_, _| {}
1085        );
1086        let (local_unsorted, mask_unsorted) = vsharp_with_radii(
1087            &field, &mask, &grid, &radii_unsorted, Some(0.05), VsharpBall::QsmJl, |_, _| {}
1088        );
1089
1090        // Results should be the same regardless of input order
1091        assert_eq!(mask_sorted, mask_unsorted, "Sorted and unsorted radii should give same mask");
1092        for i in 0..n * n * n {
1093            assert!(
1094                (local_sorted[i] - local_unsorted[i]).abs() < 1e-10,
1095                "Results should match at index {}",
1096                i
1097            );
1098        }
1099    }
1100
1101    /// Spherical mask of radius `rad` voxels centred in an `n`³ volume, and a smooth
1102    /// non-harmonic field over it.
1103    fn sphere_case(n: usize, rad: f64) -> (Vec<f64>, Vec<u8>) {
1104        let c = n as f64 / 2.0;
1105        let mut field = vec![0.0; n * n * n];
1106        let mut mask = vec![0u8; n * n * n];
1107        for z in 0..n {
1108            for y in 0..n {
1109                for x in 0..n {
1110                    let (dx, dy, dz) = (x as f64 - c, y as f64 - c, z as f64 - c);
1111                    let i = x + y * n + z * n * n;
1112                    field[i] = 0.01 * dz + 0.002 * (dx * dx - dy * dy) + (0.3 * dx).sin() * 0.05;
1113                    mask[i] = (dx * dx + dy * dy + dz * dz <= rad * rad) as u8;
1114                }
1115            }
1116        }
1117        (field, mask)
1118    }
1119
1120    #[test]
1121    fn test_vsharp_defaults_are_sti_suites() {
1122        // STI Suite's V_SHARP: no deconvolution, 1 mm working grid, smvsize 12.
1123        let p = VsharpParams::default();
1124        assert!(!p.deconvolve);
1125        assert_eq!(p.grid, VsharpGrid::Isotropic { spacing_mm: STI_GRID_MM });
1126        assert_eq!(p.max_radius, 12.0);
1127        assert_eq!((p.min_radius, p.radius_step), (2.0, 1.0));
1128    }
1129
1130    #[test]
1131    fn test_vsharp_no_deconvolve_is_smv_highpass() {
1132        // Without deconvolution each voxel is φ − S_r∗φ for the largest radius r whose
1133        // ball fits the mask; check against that computed directly, radius by radius.
1134        let n = 20;
1135        let (field, mask) = sphere_case(n, 8.0);
1136        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1137        let radii = [4.0, 3.0, 2.0];
1138        let (local, out_mask) = vsharp_with_radii(&field, &mask, &grid, &radii, None, VsharpBall::QsmJl, |_, _| {});
1139
1140        let delta = 1.0 - 1e-7_f64.sqrt();
1141        let mut expected = vec![0.0; n * n * n];
1142        let mut expected_mask = vec![0u8; n * n * n];
1143        for &r in &radii {
1144            let mut s: Vec<Complex64> = smv_kernel(&grid, r).iter().map(|&x| Complex64::new(x, 0.0)).collect();
1145            fft3d(&mut s, n, n, n);
1146            let s_fft: Vec<f64> = s.iter().map(|c| c.re).collect();
1147            let fits = erode_mask_smv(&mask, &s_fft, &grid, delta);
1148            let mut sf: Vec<Complex64> = field.iter().map(|&x| Complex64::new(x, 0.0)).collect();
1149            fft3d(&mut sf, n, n, n);
1150            for i in 0..sf.len() { sf[i] *= s_fft[i]; }
1151            ifft3d(&mut sf, n, n, n);
1152            for i in 0..n * n * n {
1153                if fits[i] == 1 && expected_mask[i] == 0 {
1154                    expected[i] = field[i] - sf[i].re;
1155                    expected_mask[i] = 1;
1156                }
1157            }
1158        }
1159        assert_eq!(out_mask, expected_mask);
1160        assert!(out_mask.contains(&1));
1161        for i in 0..n * n * n {
1162            assert!((local[i] - expected[i]).abs() < 1e-12, "voxel {i}: {} vs {}", local[i], expected[i]);
1163        }
1164    }
1165
1166    #[test]
1167    fn test_vsharp_deconvolve_flag() {
1168        let n = 20;
1169        let (field, mask) = sphere_case(n, 8.0);
1170        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1171        let base = VsharpParams {
1172            max_radius: 4.0, min_radius: 1.0, deconvolve: true, grid: VsharpGrid::Native, ..Default::default()
1173        };
1174        let (dec, dec_mask) = vsharp(&field, &mask, &grid, &base, |_, _| {}).unwrap();
1175        let off = VsharpParams { deconvolve: false, ..base.clone() };
1176        let (hp, hp_mask) = vsharp(&field, &mask, &grid, &off, |_, _| {}).unwrap();
1177        // Same voxels either way; the deconvolution changes the values.
1178        assert_eq!(dec_mask, hp_mask);
1179        assert!(dec.iter().zip(&hp).any(|(a, b)| (a - b).abs() > 1e-6));
1180        // The threshold only applies to the deconvolution.
1181        let (hp2, _) = vsharp(&field, &mask, &grid, &VsharpParams { threshold: 0.9, ..off }, |_, _| {}).unwrap();
1182        assert_eq!(hp, hp2);
1183        for i in 0..n * n * n {
1184            if hp_mask[i] == 0 { assert_eq!(hp[i], 0.0); }
1185        }
1186    }
1187
1188    #[test]
1189    fn test_vsharp_no_deconvolve_single_radius() {
1190        // One radius without deconvolution is the plain SMV high-pass, not SHARP.
1191        let n = 20;
1192        let (field, mask) = sphere_case(n, 8.0);
1193        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1194        let (hp, hp_mask) = vsharp_with_radii(&field, &mask, &grid, &[3.0], None, VsharpBall::QsmJl, |_, _| {});
1195        let (sh, sh_mask) = crate::bgremove::sharp::sharp_core(&field, &mask, &grid, 0.05, 3.0);
1196        assert_eq!(hp_mask, sh_mask);
1197        assert!(hp.iter().zip(&sh).any(|(a, b)| (a - b).abs() > 1e-6));
1198    }
1199
1200    #[test]
1201    fn test_grid_resolve() {
1202        let finest = VsharpGrid::IsotropicFinest;
1203        assert_eq!(finest.resolve((1.0, 1.0, 1.0)), VsharpGrid::Native);
1204        assert_eq!(finest.resolve((0.5, 0.5, 0.5)), VsharpGrid::Native);
1205        // header rounding is not anisotropy
1206        assert_eq!(finest.resolve((1.0, 1.0, 1.005)), VsharpGrid::Native);
1207        assert_eq!(finest.resolve((0.8, 0.8, 3.0)), VsharpGrid::Isotropic { spacing_mm: 0.8 });
1208        assert_eq!(finest.resolve((3.0, 0.8, 0.8)), VsharpGrid::Isotropic { spacing_mm: 0.8 });
1209        assert_eq!(finest.resolve((0.3, 0.3, 0.5)), VsharpGrid::Isotropic { spacing_mm: 0.3 });
1210        let iso1 = VsharpGrid::Isotropic { spacing_mm: 1.0 };
1211        assert_eq!(iso1.resolve((0.3, 0.3, 0.5)), iso1);
1212        assert_eq!(VsharpGrid::Native.resolve((0.8, 0.8, 3.0)), VsharpGrid::Native);
1213        // the default is STI's 1 mm grid, whatever the voxel size
1214        assert_eq!(VsharpParams::default().working_grid(&Grid::new(4, 4, 4, 0.8, 0.8, 3.0)), iso1);
1215        assert_eq!(VsharpParams::default().working_grid(&Grid::new(4, 4, 4, 1.0, 1.0, 1.0)), iso1);
1216    }
1217
1218    /// Ellipsoidal mask on a `dims` grid of voxel size `vs` (semi-axes in mm) and a smooth
1219    /// non-harmonic field with a background-like linear and quadratic part.
1220    fn ellipsoid_case(dims: (usize, usize, usize), vs: (f64, f64, f64), semi: [f64; 3]) -> (Vec<f64>, Vec<u8>) {
1221        let (nx, ny, nz) = dims;
1222        let c = [nx as f64 / 2.0, ny as f64 / 2.0, nz as f64 / 2.0];
1223        let mut field = vec![0.0; nx * ny * nz];
1224        let mut mask = vec![0u8; nx * ny * nz];
1225        for z in 0..nz {
1226            for y in 0..ny {
1227                for x in 0..nx {
1228                    let (dx, dy, dz) = ((x as f64 - c[0]) * vs.0, (y as f64 - c[1]) * vs.1, (z as f64 - c[2]) * vs.2);
1229                    let i = x + y * nx + z * nx * ny;
1230                    field[i] = 0.01 * dz + 0.002 * (dx * dx - dy * dy) + 0.05 * (0.3 * dx).sin() * (0.2 * dz).cos();
1231                    let e = (dx / semi[0]).powi(2) + (dy / semi[1]).powi(2) + (dz / semi[2]).powi(2);
1232                    mask[i] = (e <= 1.0) as u8;
1233                }
1234            }
1235        }
1236        (field, mask)
1237    }
1238
1239    #[test]
1240    fn test_native_grid_is_unchanged_v_sharp() {
1241        // `grid: Native` and `IsotropicFinest` on isotropic voxels are the plain native-grid
1242        // V-SHARP.
1243        let n = 20;
1244        let (field, mask) = sphere_case(n, 8.0);
1245        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1246        for deconvolve in [true, false] {
1247            let base = VsharpParams { max_radius: 4.0, deconvolve, grid: VsharpGrid::Native, ..Default::default() };
1248            let radii = vsharp_radii(&base);
1249            let expected = vsharp_with_radii(&field, &mask, &grid, &radii, deconvolve.then_some(base.threshold), base.ball, |_, _| {});
1250            assert_eq!(vsharp(&field, &mask, &grid, &base, |_, _| {}).unwrap(), expected);
1251            let finest = VsharpParams { grid: VsharpGrid::IsotropicFinest, ..base.clone() };
1252            assert_eq!(vsharp(&field, &mask, &grid, &finest, |_, _| {}).unwrap(), expected);
1253        }
1254        // anisotropic voxels, native grid
1255        let ag = Grid::new(24, 24, 10, 0.8, 0.8, 3.0);
1256        let (af, am) = ellipsoid_case(ag.dims, ag.voxel_size, [8.0, 8.0, 12.0]);
1257        let p = VsharpParams { max_radius: 4.0, deconvolve: true, grid: VsharpGrid::Native, ..Default::default() };
1258        let expected = vsharp_with_radii(&af, &am, &ag, &vsharp_radii(&p), Some(p.threshold), p.ball, |_, _| {});
1259        assert_eq!(vsharp(&af, &am, &ag, &p, |_, _| {}).unwrap(), expected);
1260    }
1261
1262    #[test]
1263    fn test_isotropic_working_grids() {
1264        let grid = Grid::new(30, 30, 12, 0.8, 0.8, 3.0);
1265        let (field, mask) = ellipsoid_case(grid.dims, grid.voxel_size, [10.0, 10.0, 14.0]);
1266        let native = VsharpParams { max_radius: 4.0, deconvolve: false, grid: VsharpGrid::Native, ..Default::default() };
1267        let (ln, mn) = vsharp(&field, &mask, &grid, &native, |_, _| {}).unwrap();
1268        let finest = VsharpParams { grid: VsharpGrid::IsotropicFinest, ..native.clone() };
1269        assert_eq!(finest.working_grid(&grid), VsharpGrid::Isotropic { spacing_mm: 0.8 });
1270        let at_finest = VsharpParams { grid: VsharpGrid::Isotropic { spacing_mm: 0.8 }, ..native.clone() };
1271        let iso1 = VsharpParams { grid: VsharpGrid::Isotropic { spacing_mm: 1.0 }, ..native.clone() };
1272        let (lf, mf) = vsharp(&field, &mask, &grid, &finest, |_, _| {}).unwrap();
1273        assert_eq!((lf.clone(), mf.clone()), vsharp(&field, &mask, &grid, &at_finest, |_, _| {}).unwrap());
1274        let (l1, m1) = vsharp(&field, &mask, &grid, &iso1, |_, _| {}).unwrap();
1275        for (l, m) in [(&lf, &mf), (&l1, &m1)] {
1276            assert!(m.contains(&1));
1277            assert!(*m != mn || l.iter().zip(&ln).any(|(a, b)| (a - b).abs() > 1e-6));
1278            for i in 0..l.len() {
1279                if m[i] == 0 { assert_eq!(l[i], 0.0); } else { assert!(mask[i] == 1 && l[i].is_finite()); }
1280            }
1281        }
1282    }
1283
1284    #[test]
1285    fn test_isotropic_grid_empty_mask_and_bad_spacing() {
1286        let grid = Grid::new(8, 8, 4, 0.8, 0.8, 3.0);
1287        let iso1 = VsharpParams { grid: VsharpGrid::Isotropic { spacing_mm: 1.0 }, ..Default::default() };
1288        let (l, m) = vsharp(&vec![1.0; 256], &[0u8; 256], &grid, &iso1, |_, _| {}).unwrap();
1289        assert!(l.iter().all(|&v| v == 0.0) && m.iter().all(|&v| v == 0));
1290        let bad = VsharpParams { grid: VsharpGrid::Isotropic { spacing_mm: 0.0 }, ..Default::default() };
1291        let err = vsharp(&vec![1.0; 256], &[1u8; 256], &grid, &bad, |_, _| {}).unwrap_err();
1292        assert!(err.0.contains("spacing_mm = 0") && err.0.contains("> 0 mm"), "{err}");
1293    }
1294
1295    /// #199: `min_radius` used to be the step between radii too, so at 0 (or below, or NaN) the
1296    /// radius list never ended and V-SHARP hung. It is an error now, as is such a `radius_step`.
1297    #[test]
1298    fn min_radius_that_is_not_positive_is_an_error() {
1299        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
1300        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1301            let p = VsharpParams { min_radius: bad, ..Default::default() };
1302            let err = vsharp(&[0.0; 64], &[1u8; 64], &grid, &p, |_, _| {}).unwrap_err();
1303            assert!(err.0.contains("V-SHARP min_radius") && err.0.contains("> 0 mm"), "{err}");
1304        }
1305    }
1306
1307    #[test]
1308    fn radius_step_that_is_not_positive_is_an_error() {
1309        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
1310        for bad in [0.0, -1.0, f64::NAN] {
1311            let p = VsharpParams { radius_step: bad, ..Default::default() };
1312            let err = vsharp(&[0.0; 64], &[1u8; 64], &grid, &p, |_, _| {}).unwrap_err();
1313            assert!(err.0.contains("V-SHARP radius_step"), "{err}");
1314        }
1315    }
1316
1317    #[test]
1318    fn radius_schedules() {
1319        // STI Suite's V_SHARP: 12 → 2 mm in 1 mm steps (r = 1 is dropped)
1320        let sti: Vec<f64> = (2..=12).rev().map(|r| r as f64).collect();
1321        assert_eq!(vsharp_radii(&VsharpParams::default()), sti);
1322        // the previous default, 12 → 1 mm
1323        let old = VsharpParams { min_radius: 1.0, radius_step: 1.0, ..Default::default() };
1324        assert_eq!(vsharp_radii(&old), (1..=12).rev().map(|r| r as f64).collect::<Vec<_>>());
1325        // a step independent of the smallest radius
1326        let p = VsharpParams { max_radius: 10.0, min_radius: 3.0, radius_step: 2.5, ..Default::default() };
1327        assert_eq!(vsharp_radii(&p), vec![10.0, 7.5, 5.0]);
1328        let p = VsharpParams { max_radius: 1.0, min_radius: 2.0, ..Default::default() };
1329        assert_eq!(vsharp_radii(&p), vec![1.0]);
1330    }
1331
1332    /// The QSM.rs V-SHARP ablation prototype's isotropic-grid path (`exp/vsharp-ablation`,
1333    /// `vsharp.rs::ablation::run`, spec `d?g1b0r0p1e0`: 1 mm grid, main ball, main radii,
1334    /// crop to the mask bbox + 12 voxels, main edge test), kept here verbatim as the reference
1335    /// that was scored on UK Biobank (ukb-qsmxt docs/18).
1336    fn prototype_iso(field: &[f64], mask: &[u8], grid: &Grid, p: &VsharpParams) -> (Vec<f64>, Vec<u8>) {
1337        fn kernel_fft(n: [usize; 3], vs: [f64; 3], r: f64) -> (Vec<Complex64>, f64) {
1338            let mut k = vec![Complex64::new(0.0, 0.0); n[0] * n[1] * n[2]];
1339            let lim = r * r;
1340            let h = [0, 1, 2].map(|d| ((r / vs[d]).floor() as i64).min(n[d] as i64 / 2));
1341            let wrap = |d: i64, nd: usize| d.rem_euclid(nd as i64) as usize;
1342            let mut count = 0.0;
1343            for dz in -h[2]..=h[2] { for dy in -h[1]..=h[1] { for dx in -h[0]..=h[0] {
1344                let (x, y, z) = (dx as f64 * vs[0], dy as f64 * vs[1], dz as f64 * vs[2]);
1345                if x * x + y * y + z * z > lim { continue; }
1346                let idx = wrap(dx, n[0]) + wrap(dy, n[1]) * n[0] + wrap(dz, n[2]) * n[0] * n[1];
1347                if k[idx].re == 0.0 { k[idx] = Complex64::new(1.0, 0.0); count += 1.0; }
1348            }}}
1349            fft3d(&mut k, n[0], n[1], n[2]);
1350            (k, count)
1351        }
1352        let to_complex = |v: &[f64]| -> Vec<Complex64> { v.iter().map(|&x| Complex64::new(x, 0.0)).collect() };
1353        let (nx, ny, nz) = grid.dims;
1354        let vs = [grid.voxel_size.0, grid.voxel_size.1, grid.voxel_size.2];
1355        let mut local_out = vec![0.0; nx * ny * nz];
1356        let mut mask_out = vec![0u8; nx * ny * nz];
1357        let Some((lo, hi)) = bounding_box(mask, grid.dims) else { return (local_out, mask_out) };
1358        let (lo, hi) = sti_even_box(lo, hi, [nx, ny, nz]);
1359        let pad = 12;
1360        let np = [0, 1, 2].map(|d| hi[d] - lo[d] + 1 + 2 * pad);
1361        let mut pp = vec![0.0; np[0] * np[1] * np[2]];
1362        let mut m = vec![0.0; pp.len()];
1363        for k in lo[2]..=hi[2] { for j in lo[1]..=hi[1] { for i in lo[0]..=hi[0] {
1364            let src = i + j * nx + k * nx * ny;
1365            let dst = (i - lo[0] + pad) + (j - lo[1] + pad) * np[0] + (k - lo[2] + pad) * np[0] * np[1];
1366            pp[dst] = field[src];
1367            m[dst] = if mask[src] != 0 { 1.0 } else { 0.0 };
1368        }}}
1369        let h = 1.0;
1370        let ni = [0, 1, 2].map(|d| if vs[d] == h { np[d] } else { (np[d] as f64 * vs[d] / h).ceil() as usize });
1371        let fwd = [0, 1, 2].map(|d| (1..=ni[d]).map(|q| if vs[d] == h { q as f64 } else { q as f64 * h / vs[d] }).collect::<Vec<f64>>());
1372        let pi = trilinear(&pp, np, &fwd);
1373        let mi = trilinear_mask_f32(&m, np, &fwd);
1374        let ne = ni.map(|n| n + n % 2);
1375        let mut pe = vec![0.0; ne[0] * ne[1] * ne[2]];
1376        let mut me = vec![0.0; pe.len()];
1377        for k in 0..ni[2] { for j in 0..ni[1] { for i in 0..ni[0] {
1378            let s = i + j * ni[0] + k * ni[0] * ni[1];
1379            let d = i + j * ne[0] + k * ne[0] * ne[1];
1380            pe[d] = pi[s];
1381            me[d] = if mi[s] > 0.5f32 { 1.0 } else { 0.0 };
1382        }}}
1383        let nw = ne[0] * ne[1] * ne[2];
1384        let mut radii = vsharp_radii(p);
1385        radii.sort_by(|a, b| b.partial_cmp(a).unwrap());
1386        let mut p_fft = to_complex(&pe);
1387        fft3d(&mut p_fft, ne[0], ne[1], ne[2]);
1388        let mut m_fft = to_complex(&me);
1389        fft3d(&mut m_fft, ne[0], ne[1], ne[2]);
1390        let mut bg = pe.clone();
1391        let mut nm = vec![0.0; nw];
1392        let mut buf = vec![Complex64::new(0.0, 0.0); nw];
1393        let mut inv: Option<Vec<f64>> = None;
1394        let delta = QSMJL_SMV_DELTA;
1395        for &r in &radii {
1396            let (mut k_fft, count) = kernel_fft(ne, [h; 3], r);
1397            if count == 0.0 { continue; }
1398            for i in 0..nw { buf[i] = m_fft[i] * k_fft[i]; }
1399            ifft3d(&mut buf, ne[0], ne[1], ne[2]);
1400            let fits: Vec<bool> = (0..nw).map(|i| me[i] > 0.0 && buf[i].re / count > delta).collect();
1401            for v in k_fft.iter_mut() { *v /= count; }
1402            if inv.is_none() {
1403                inv = Some(k_fft.iter().map(|s| { let oms = 1.0 - s.re; if oms.abs() < p.threshold { 0.0 } else { 1.0 / oms } }).collect());
1404            }
1405            for i in 0..nw { buf[i] = p_fft[i] * k_fft[i]; }
1406            ifft3d(&mut buf, ne[0], ne[1], ne[2]);
1407            for i in 0..nw { if fits[i] && nm[i] == 0.0 { bg[i] = buf[i].re; nm[i] = 1.0; } }
1408        }
1409        let mut l: Vec<Complex64> = (0..nw).map(|i| Complex64::new((pe[i] - bg[i]) * nm[i], 0.0)).collect();
1410        if p.deconvolve && inv.is_some() {
1411            // QSM.jl deconvolves periodically on the mask's bounding box padded for the largest
1412            // radius (its `padfastfft`), not on the whole working grid.
1413            let w_grid = Grid::new(ne[0], ne[1], ne[2], h, h, h);
1414            let me_u8: Vec<u8> = me.iter().map(|&v| v as u8).collect();
1415            let bx = SmvBox::new(&me_u8, &w_grid, radii[0]).unwrap();
1416            let (bx_n, by_n, bz_n) = bx.np;
1417            let (k_fft, count) = kernel_fft([bx_n, by_n, bz_n], [h; 3], radii[0]);
1418            let inv_b: Vec<f64> = k_fft.iter().map(|s| { let oms = 1.0 - s.re / count; if oms.abs() < p.threshold { 0.0 } else { 1.0 / oms } }).collect();
1419            let lr: Vec<f64> = l.iter().map(|c| c.re).collect();
1420            let mut lb = to_complex(&bx.crop(&lr));
1421            fft3d(&mut lb, bx_n, by_n, bz_n);
1422            for (v, iv) in lb.iter_mut().zip(&inv_b) { *v *= iv; }
1423            ifft3d(&mut lb, bx_n, by_n, bz_n);
1424            let lb: Vec<f64> = lb.iter().map(|c| c.re).collect();
1425            let back = bx.uncrop(&lb);
1426            for i in 0..nw { l[i] = Complex64::new(back[i] * nm[i], 0.0); }
1427        }
1428        let mut bg_c = vec![0.0; ni[0] * ni[1] * ni[2]];
1429        let mut nm_c = vec![0.0; bg_c.len()];
1430        for k in 0..ni[2] { for j in 0..ni[1] { for i in 0..ni[0] {
1431            let d = i + j * ni[0] + k * ni[0] * ni[1];
1432            let s = i + j * ne[0] + k * ne[0] * ne[1];
1433            bg_c[d] = if p.deconvolve { pe[s] - l[s].re } else { bg[s] };
1434            nm_c[d] = nm[s];
1435        }}}
1436        let back = [0, 1, 2].map(|d| (1..=np[d]).map(|a| if vs[d] == h { a as f64 } else { a as f64 * vs[d] / h }).collect::<Vec<f64>>());
1437        let bg_n = trilinear(&bg_c, ni, &back);
1438        let nm_n = trilinear_mask_f32(&nm_c, ni, &back);
1439        for k in lo[2]..=hi[2] { for j in lo[1]..=hi[1] { for i in lo[0]..=hi[0] {
1440            let s = (i - lo[0] + pad) + (j - lo[1] + pad) * np[0] + (k - lo[2] + pad) * np[0] * np[1];
1441            if nm_n[s] > 0.5f32 {
1442                let d = i + j * nx + k * nx * ny;
1443                local_out[d] = pp[s] - bg_n[s];
1444                mask_out[d] = 1;
1445            }
1446        }}}
1447        (local_out, mask_out)
1448    }
1449
1450    #[test]
1451    fn test_isotropic_grid_reproduces_the_prototype() {
1452        // UK Biobank-like voxels (0.8 × 0.8 × 3 mm); the mask touches neither the FOV edge nor
1453        // itself across the wrap.
1454        let grid = Grid::new(40, 36, 14, 0.8, 0.8, 3.0);
1455        let (field, mask) = ellipsoid_case(grid.dims, grid.voxel_size, [13.0, 11.0, 17.0]);
1456        for deconvolve in [false, true] {
1457            let p = VsharpParams {
1458                max_radius: 6.0,
1459                deconvolve,
1460                // The prototype builds QSM.jl's ball; the grid mechanics do not depend on it.
1461                ball: VsharpBall::QsmJl,
1462                grid: VsharpGrid::Isotropic { spacing_mm: 1.0 },
1463                ..Default::default()
1464            };
1465            let (want, want_mask) = prototype_iso(&field, &mask, &grid, &p);
1466            let (got, got_mask) = vsharp(&field, &mask, &grid, &p, |_, _| {}).unwrap();
1467            assert_eq!(got_mask, want_mask, "support (deconvolve {deconvolve})");
1468            assert!(got_mask.contains(&1));
1469            let scale = want.iter().map(|v| v.abs()).fold(0.0, f64::max);
1470            let worst = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
1471            assert!(worst <= 1e-12 * scale.max(1.0), "deconvolve {deconvolve}: max |Δ| {worst} (scale {scale})");
1472        }
1473    }
1474}