Skip to main content

qsm_core/pipeline/
config.rs

1//! Pipeline configuration types
2//!
3//! Defines the configuration structs and enums for the QSM pipeline runner.
4//! These are pure algorithm config types (no serde). Consumers that need
5//! serialization (e.g. qsmxt-config) provide their own serde wrappers
6//! and convert to these types.
7
8use crate::bgremove::{
9    IsmvParams, LbvParams, PdfParams, ResharpParams, SharpParams, SdfParams, VsharpParams,
10    HarperellaParams, IharperellaParams,
11};
12use crate::inversion::{
13    IlsqrParams, MediParams, NltvParams, RtsParams, TgvParams, TikhonovParams, TkdParams, TvParams,
14    NdiParams, FansiParams, L1QsmParams, WhQsmParams, HdQsmParams, TfiParams, AmpPeParams,
15    LsqrQsmParams, HeidiParams, StarParams,
16};
17use crate::separation::{
18    ChiSepIlsqrParams, ChiSepMediParams, ChiSepNetParams, DecomposeParams, HcChisepParams,
19    R2starQsmParams, SusepNetParams, WaveSepParams,
20};
21use crate::unwrap::romeo::RomeoParams;
22use crate::unwrap::PreludeParams;
23use crate::unwrap::LaplacianSolver;
24use crate::utils::multi_echo::{B0WeightType, EchoQuality, FitPpmComplexParams, LinearFitParams, RobustFitParams};
25use crate::utils::QsmartParams;
26
27
28// =========================================================================
29// Selection enums
30// =========================================================================
31
32/// Phase unwrapping algorithm
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub enum UnwrappingAlgorithm {
35    Romeo,
36    Laplacian,
37    /// PRELUDE ([`crate::unwrap::unwrap_prelude`], with
38    /// [`FieldMappingConfig::prelude_params`]): each echo unwrapped spatially on its own, as
39    /// FSL's `prelude` unwraps one volume, then whole turns aligned between echoes
40    /// ([`crate::unwrap::correct_multi_echo_wraps`]). The magnitude is not used: FSL's `prelude`
41    /// uses it only to make a mask when none is given, and here the mask is always given.
42    Prelude,
43    /// MEDI's region growing ([`crate::unwrap::unwrap_medi_region_growing`], MEDI toolbox
44    /// `unwrapPhase` / `mexUnwrap.c`), bit-identical to the original. As in MEDI it takes no
45    /// mask: it grows over the whole array from the middle of the field of view, through voxels
46    /// above 0.5 % of the largest magnitude, and the brain mask is applied only after. It needs
47    /// the magnitude (a run without one is [`PipelineError::InvalidInput`]),
48    /// as its quality map:
49    /// - with [`B0EstimationMethod::ComplexFit`], MEDI's `iMag`, the root-sum-of-squares of the
50    ///   echo magnitudes, unwrapping the fit per echo spacing: MEDI's field map
51    ///   (`README.m:80-95`);
52    /// - on the multi-echo paths, each echo is unwrapped on its own with that same `iMag`, then
53    ///   whole turns are aligned between echoes
54    ///   ([`crate::unwrap::correct_multi_echo_wraps`]), as for PRELUDE. MEDI never unwraps
55    ///   echoes one by one, so this use is QSM.rs's own; `iMag` is used because it is what MEDI
56    ///   orders its growth by;
57    /// - for a single echo, and per echo
58    ///   ([`super::per_echo::run_field_mapping_per_echo`]), that
59    ///   echo's magnitude (its `iMag`).
60    MediRegionGrowing,
61}
62
63/// Whether an algorithm is valid when B0 does not lie along the voxel `+z` axis.
64///
65/// The dipole relationship is direction-dependent, and the FFT that implements it lives in the
66/// voxel grid, so an oblique acquisition has to be handled deliberately. There are three cases,
67/// and the difference matters because getting it wrong is silent — the reconstruction completes
68/// and the numbers are simply wrong.
69///
70/// Every algorithm enum reports its case through `orientation_support()`. Hosts should check
71/// [`OrientationSupport::requires_axial`] before running an oblique dataset. See
72/// [`crate::geometry`] for the B0 direction and for resampling to a cardinal grid,
73/// [`crate::registration`] for recovering the rigid transform between volumes (which is how a
74/// multi-orientation set gets onto one grid, and where its per-orientation B0 directions come
75/// from, since the object rotates and B0 does not), and [`crate::motion`] for the same within
76/// one acquisition, which reports one B0 direction per volume.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum OrientationSupport {
79    /// The B0 direction is an explicit parameter — the dipole kernel is built from it — so the
80    /// method reconstructs correctly on the grid the data was acquired on, whatever its
81    /// orientation. See [`crate::geometry::b0_direction_from_affine`].
82    Arbitrary,
83    /// The method never uses B0. The SMV-family background removals rest on the spherical mean
84    /// value property of harmonic fields, which is rotation-invariant.
85    NotApplicable,
86    /// Assumes B0 along `+z` and offers no way to say otherwise. Deep-learning methods learned
87    /// the dipole relationship from axially-acquired training data, so there is no direction to
88    /// rotate; oblique data must be resampled to a cardinal grid first
89    /// ([`crate::geometry::resample_complex_to_axial`]).
90    ///
91    /// Unrolled networks with a physics data-consistency term (LPCNN, MoDL-QSM, NeXtQSM) build
92    /// that term from the true direction, so they are partly corrected — but their learned prior
93    /// is still axial, so they belong here.
94    AxialOnly,
95}
96
97impl OrientationSupport {
98    /// Whether oblique data must be resampled before this method can be trusted.
99    pub fn requires_axial(&self) -> bool {
100        matches!(self, OrientationSupport::AxialOnly)
101    }
102}
103
104/// What a dipole inversion reconstructs from, and so which earlier stages a host has to run.
105///
106/// Reported by [`InversionConfig::input`], the one place this is decided. A host should route
107/// from it rather than keep its own lists of methods that skip background removal.
108///
109/// The pipeline's own background-removal methods ([`BgRemovalAlgorithm`]) all take the total
110/// field in ppm and produce a local field, and so does iQFM ([`run_iqfm`](super::run_iqfm)) from
111/// wrapped phase. Each of them is therefore only useful in front of a
112/// [`InversionInput::LocalField`] inversion: with the others it would have no effect.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum InversionInput {
115    /// The background-removed local field in ppm: field mapping, then background removal,
116    /// then [`run_dipole_inversion`](super::run_dipole_inversion) with the eroded mask the
117    /// background removal returned.
118    LocalField,
119    /// The total field in ppm, straight from field mapping with **no** background removal,
120    /// and the brain mask. The method removes or models the background itself.
121    TotalField,
122    /// The per-echo wrapped phase (and magnitude): no field mapping and no background removal.
123    WrappedPhase,
124}
125
126impl InversionInput {
127    /// Whether a background-removal stage (or iQFM) belongs in front of the inversion.
128    pub fn needs_bg_removal(&self) -> bool {
129        matches!(self, InversionInput::LocalField)
130    }
131
132    /// Whether the echoes have to be field-mapped (unwrapped and combined) first.
133    pub fn needs_field_mapping(&self) -> bool {
134        !matches!(self, InversionInput::WrappedPhase)
135    }
136}
137
138/// Background field removal algorithm
139#[derive(Clone, Copy, Debug, PartialEq)]
140pub enum BgRemovalAlgorithm {
141    Vsharp,
142    Pdf,
143    Lbv,
144    Ismv,
145    Sharp,
146    Resharp,
147    Harperella,
148    Iharperella,
149    /// BFRnet deep-learning background removal (requires the `onnx` feature and
150    /// the `bfrnet` model weights; see [`crate::models`]).
151    Bfrnet,
152}
153
154/// Dipole inversion algorithm
155#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
156#[derive(Clone, Copy, Debug, PartialEq)]
157pub enum InversionAlgorithm {
158    Tkd,
159    Tsvd,
160    Tikhonov,
161    Tv,
162    Rts,
163    Nltv,
164    Medi,
165    /// Preconditioned Total Field Inversion (single-step, total field).
166    Tfi,
167    Ilsqr,
168    /// STAR-QSM (STI Suite 3.0's `QSM_star`): k-space initial guess refined by SpaRSA.
169    Star,
170    Tgv,
171    Qsmart,
172    /// Nonlinear Dipole Inversion (FANSI ndi.m).
173    Ndi,
174    /// FANSI nonlinear Total Variation (nlTV).
175    Fansi,
176    /// FANSI nonlinear Total Generalized Variation (nlTGV).
177    FansiTgv,
178    /// L1 data-fidelity QSM (FANSI nlL1TV / PI-QSM).
179    L1qsm,
180    /// Weak-Harmonic QSM (FANSI WH_nlTV).
181    Whqsm,
182    /// Hybrid two-stage L1→L2 QSM (HD-QSM).
183    Hdqsm,
184    /// Approximate Message Passing with built-in Parameter Estimation (AMP-PE).
185    AmpPe,
186    /// Minimally regularised LSQR with residual-field and global-offset unknowns
187    /// (Schweser 2010).
188    Lsqr,
189    /// xQSM deep-learning dipole inversion (requires the `onnx` feature and the
190    /// `xqsm` model weights; see [`crate::models`]).
191    Xqsm,
192    /// QSMnet deep-learning dipole inversion (requires the `onnx` feature and the
193    /// `qsmnet` model weights; see [`crate::models`]).
194    Qsmnet,
195    /// QSMnet+ deep-learning dipole inversion (susceptibility-scaling augmented;
196    /// requires the `onnx` feature and the `qsmnet-plus` weights).
197    QsmnetPlus,
198    /// QSMnetME deep-learning dipole inversion, the χ-separation toolbox's QSM network, by the
199    /// toolbox's route (rotation to B0 along z and 1 mm resampling inside; requires the `onnx`
200    /// feature and the `qsmnetme` weights; see [`crate::inversion::qsmnetme`]).
201    Qsmnetme,
202    /// AutoQSM single-step reconstruction (requires the `onnx` feature and the
203    /// `autoqsm` weights). NOTE: takes the **total** field — it does its own
204    /// background removal, so the `local_field_ppm` argument should be the total field.
205    Autoqsm,
206    /// QSMGAN deep-learning dipole inversion (local field → χ; `onnx` + `qsmgan` weights).
207    Qsmgan,
208    /// IR2QSM unrolled deep-learning dipole inversion (local field → χ; `onnx` + `ir2qsm` weights).
209    Ir2qsm,
210    /// LPCNN learned-proximal deep-learning dipole inversion (local field → χ; `onnx` + `lpcnn` weights).
211    Lpcnn,
212    /// MoDL-QSM model-based deep-learning dipole inversion (local field → χ33/STI component;
213    /// `onnx` + `modl-qsm` weights).
214    ModlQsm,
215    /// NeXtQSM single-step reconstruction (requires `onnx` + the two `nextqsm` weight files).
216    /// NOTE: takes the **total** field — it does its own background removal.
217    Nextqsm,
218    /// iQSM single-step reconstruction from wrapped **phase** (end-to-end: joint unwrap +
219    /// background removal + inversion). Not a dipole-inversion-stage option — use
220    /// [`super::run_iqsm`]. Rejected by `run_dipole_inversion`.
221    Iqsm,
222    /// iQSM+ single-step reconstruction from wrapped **phase** with orientation-adaptive
223    /// feature editing (uses the B0 direction). End-to-end; use [`super::run_iqsm_plus`].
224    IqsmPlus,
225}
226
227/// How [`run_field_mapping`](super::run_field_mapping) combines the unwrapped echoes into one
228/// B0 map. Each method carries exactly the settings it reads, so a weighting cannot be set on a
229/// fit that ignores it.
230///
231/// Only multi-echo data is combined. A single echo is `φ/TE` whatever is chosen here, except
232/// that [`Self::RobustFit`] refuses a series too short for it (see [`RobustFitParams::min_echoes`])
233/// and [`Self::ComplexFit`] needs the echo's magnitude (it is also `φ/TE` there, as in MEDI).
234#[derive(Clone, Debug, PartialEq)]
235pub enum B0EstimationMethod {
236    /// Weighted mean of the per-echo frequencies, `B0 = Σ wₑ (φₑ/TEₑ) / Σ wₑ`, with `wₑ` from
237    /// `weighting` (default [`B0WeightType::PhaseSNR`]). The default method.
238    WeightedAvg {
239        /// Per-echo weight; [`B0WeightType::AssumedDecay`] needs a finite, positive T2*.
240        weighting: B0WeightType,
241    },
242    /// Magnitude-weighted least-squares fit of phase against TE
243    /// ([`multi_echo_linear_fit`](crate::utils::multi_echo_linear_fit)). The weight is always the
244    /// magnitude; there is no `B0WeightType` here.
245    LinearFit(LinearFitParams),
246    /// Motion-robust echo combination
247    /// ([`multi_echo_robust_fit`](crate::utils::multi_echo_robust_fit)): the same
248    /// magnitude-weighted fit as [`Self::LinearFit`], iteratively reweighted so that an echo which
249    /// departs from a straight line in TE at a voxel stops pulling the slope there. The weight is
250    /// magnitude × robust weight; there is no `B0WeightType`, because the robust scale is measured
251    /// on magnitude-standardised residuals and another weighting would break that.
252    ///
253    /// Needs at least [`RobustFitParams::min_echoes`] echoes (4 with `estimate_offset`, else 3);
254    /// fewer is [`PipelineError::InvalidConfig`] rather than a silent plain fit. The per-echo
255    /// report is returned in [`FieldMappingResult::robust_fit`].
256    RobustFit(RobustFitParams),
257    /// MEDI's field map: the complex fit of `Fit_ppm_complex.m`
258    /// ([`fit_ppm_complex`](crate::utils::fit_ppm_complex)) to the wrapped echoes, then spatial
259    /// unwrapping of the fitted phase per echo spacing with `unwrapping_algorithm`, then division
260    /// by the echo spacing. MEDI uses its region-growing `unwrapPhase`
261    /// ([`UnwrappingAlgorithm::MediRegionGrowing`]); the rule `complex-fit.unwrap-region-growing`
262    /// offers it as the authors' setting, never preselected, and ROMEO (given MEDI's `iMag`, the
263    /// root-sum-of-squares of the echo magnitudes) stays the default.
264    ///
265    /// This replaces the per-echo path rather than combining unwrapped echoes: the fit has its own
266    /// phase offset (`p0`), so `phase_offset_removal` does not apply and is not run;
267    /// `bipolar_correction` is refused (MEDI's `Fit_ppm_complex_bipolar` is not ported). Needs the
268    /// magnitude of every echo and evenly spaced echoes. Also returns the field's noise map
269    /// ([`FieldMappingResult::field_noise_ppm`], MEDI's `N_std`).
270    ComplexFit(FitPpmComplexParams),
271    /// QSMART's echo fit (`echofit.m`, [`qsmart_echofit`](crate::utils::qsmart_echofit)): the
272    /// magnitude-weighted fit of phase against TE through the origin, over every voxel of the
273    /// unwrapped echoes, with no per-echo constant removed after Laplacian unwrapping (with ROMEO,
274    /// whole turns between echoes are aligned, as for the weighted mean). Also returns QSMART's
275    /// reliability map `R_0` ([`FieldMappingResult::reliability`]): voxels whose fit residual,
276    /// box-filtered over ~1 mm, reaches `fit_threshold` (by default the 99th percentile of the
277    /// blurred residual, as QSMART's authors run it; see
278    /// [`QsmartFitThreshold`](crate::utils::QsmartFitThreshold)). The field is converted to ppm
279    /// with QSMART's γ = 2.675e8 rad/s/T.
280    ///
281    /// The echoes come from the configured unwrapping, phase-offset removal and bipolar
282    /// correction. QSMART unwraps each echo with Laplacian unwrapping on the raw phase
283    /// (`unwrap_phase.m`: [`LaplacianSolver::Mirror`],
284    /// [`FieldMappingConfig::laplacian_unmasked`]) and does neither of the others; with those
285    /// settings this is QSMART's field map, bit for bit. The rules `qsmart.unwrap-laplacian`,
286    /// `qsmart.laplacian-solver` and `qsmart.unwrap-unmasked` suggest them for a QSMART run.
287    QsmartEchofit {
288        /// Residual threshold for `R_0`
289        fit_threshold: crate::utils::QsmartFitThreshold,
290    },
291}
292
293impl Default for B0EstimationMethod {
294    fn default() -> Self {
295        B0EstimationMethod::WeightedAvg { weighting: B0WeightType::PhaseSNR }
296    }
297}
298
299/// QSM referencing method
300#[derive(Clone, Copy, Debug, PartialEq)]
301pub enum QsmReference {
302    Mean,
303    None,
304}
305
306// =========================================================================
307// Masking types
308// =========================================================================
309
310/// Input data source for mask generation
311#[derive(Clone, Copy, Debug, PartialEq)]
312pub enum MaskingInput {
313    MagnitudeFirst,
314    Magnitude,
315    MagnitudeLast,
316    PhaseQuality,
317}
318
319/// Threshold method for mask generation
320#[derive(Clone, Copy, Debug, PartialEq)]
321pub enum MaskThresholdMethod {
322    Otsu,
323    Fixed,
324    Percentile,
325}
326
327/// A single mask operation (generator or refinement)
328#[derive(Clone, Debug, PartialEq)]
329pub enum MaskOp {
330    Threshold {
331        method: MaskThresholdMethod,
332        value: Option<f64>,
333    },
334    /// BET brain extraction from the **magnitude** (a generator), with the full
335    /// [`BetParams`](crate::bet::BetParams) — fractional intensity, smoothness, gradient
336    /// threshold, iterations, subdivisions and voxel scale. `BetParams::default()` is FSL's
337    /// defaults on the acquisition's own geometry.
338    Bet(crate::bet::BetParams),
339    Erode {
340        iterations: usize,
341    },
342    Dilate {
343        iterations: usize,
344    },
345    Close {
346        radius: usize,
347    },
348    FillHoles {
349        max_size: usize,
350    },
351    GaussianSmooth {
352        sigma_mm: f64,
353    },
354    /// Signal-gated erosion: peel only low-signal boundary voxels (skull-base / sinus dropout)
355    /// down to a depth cap. See [`crate::utils::morphology::signal_gated_erosion`].
356    ///
357    /// Gates on the **magnitude** image, never on the section's input: it divides out a
358    /// receive-coil bias estimate and compares against the in-mask median, which only means
359    /// anything for magnitude. Errors if no magnitude is supplied, so a phase-quality mask input
360    /// can still be refined with it as long as the magnitude is available.
361    SignalErode(crate::utils::morphology::SignalErosionParams),
362    /// HD-BET deep-learning brain extraction from the **magnitude** (a generator, like `Bet`),
363    /// whatever the section's input is. Requires the `onnx` feature and the `hd-bet` model
364    /// weights; see [`crate::models`].
365    HdBet(crate::bet::HdBetParams),
366    /// RS2-Net deep-learning **rodent** brain extraction from the **magnitude** (a generator,
367    /// like `HdBet`). Requires the `onnx` feature and the `rs2-net` model weights; see
368    /// [`crate::models`].
369    Rs2Net(crate::bet::Rs2NetParams),
370    /// MindGrab deep-learning brain extraction from the **magnitude** (a generator, like
371    /// `HdBet`), for any contrast. Needs [`ScanMetadata::affine`], the `onnx` feature and the
372    /// `mindgrab` model weights; see [`crate::models`].
373    MindGrab(crate::bet::MindGrabParams),
374}
375
376impl MaskOp {
377    /// Registry id of the deep-learning model this op runs, if any.
378    pub fn dl_model_id(&self) -> Option<&'static str> {
379        match self {
380            Self::HdBet(_) => Some("hd-bet"),
381            Self::Rs2Net(_) => Some("rs2-net"),
382            Self::MindGrab(_) => Some("mindgrab"),
383            Self::Threshold { .. } | Self::Bet(_) | Self::Erode { .. } | Self::Dilate { .. }
384            | Self::Close { .. } | Self::FillHoles { .. } | Self::GaussianSmooth { .. }
385            | Self::SignalErode(_) => None,
386        }
387    }
388}
389
390/// A mask section: input source + generator + refinements
391#[derive(Clone, Debug, PartialEq)]
392pub struct MaskSection {
393    pub input: MaskingInput,
394    pub generator: MaskOp,
395    pub refinements: Vec<MaskOp>,
396}
397
398impl MaskSection {
399    /// Get all operations (generator + refinements) in order
400    pub fn all_ops(&self) -> Vec<MaskOp> {
401        let mut ops = vec![self.generator.clone()];
402        ops.extend(self.refinements.iter().cloned());
403        ops
404    }
405}
406
407// =========================================================================
408// Per-stage config structs
409// =========================================================================
410
411/// Masking configuration
412#[derive(Clone, Debug)]
413pub struct MaskingConfig {
414    pub inhomogeneity_correction: bool,
415    pub homogeneity_sigma_mm: f64,
416    pub homogeneity_nbox: usize,
417    pub sections: Vec<MaskSection>,
418}
419
420impl Default for MaskingConfig {
421    fn default() -> Self {
422        Self {
423            inhomogeneity_correction: true,
424            homogeneity_sigma_mm: 7.0,
425            homogeneity_nbox: 15,
426            sections: vec![MaskSection {
427                input: MaskingInput::PhaseQuality,
428                generator: MaskOp::Threshold {
429                    method: MaskThresholdMethod::Otsu,
430                    value: None,
431                },
432                refinements: vec![
433                    MaskOp::Dilate { iterations: 1 },
434                    MaskOp::FillHoles { max_size: 0 },
435                    MaskOp::Erode { iterations: 1 },
436                ],
437            }],
438        }
439    }
440}
441
442/// Field mapping configuration
443#[derive(Clone, Debug)]
444pub struct FieldMappingConfig {
445    pub unwrapping_algorithm: UnwrappingAlgorithm,
446    pub phase_offset_removal: bool,
447    /// Gaussian smoothing sigma of the MCPC-3D-S phase offsets, in **mm** per axis (default
448    /// [7, 7, 7]); divided by the voxel size to give the smoothing in voxels
449    /// ([`Self::phase_offset_sigma_voxels`]). The default is the ROMEO command line's (what
450    /// users run, `romeo -B`): `--phase-offset-smoothing-sigma-mm`, "default: [7,7,7]", divided
451    /// by the NIfTI voxel size (ROMEO.jl 08776d4, `ext/RomeoApp/argparse.jl` l. 79-84 and
452    /// 217-224, `caller.jl` l. 137-138). The same physical width at every resolution.
453    /// MriResearchTools' `mcpc3ds` function itself defaults to `sigma = [10, 10, 5]` in voxels
454    /// (`src/mcpc3ds.jl` l. 39), which QSM.rs used before; that is 10/10/5 mm at 1 mm voxels but
455    /// a different width at any other resolution.
456    ///
457    /// Measured on 1 mm data (ROMEO field, default V-SHARP, RTS), where the old default is
458    /// 10/10/5 mm: χ moves by at most 0.003 XSIM, on the qsm-forward phantom XSIM 0.254 → 0.256,
459    /// NRMSE 85.9 → 85.7 %, and on an in-vivo 3T Philips scan, agreement with the UK Biobank
460    /// pipeline's χ, XSIM 0.714 → 0.714, NRMSE 69.7 → 69.4 %.
461    pub phase_offset_sigma_mm: [f64; 3],
462    pub bipolar_correction: bool,
463    /// How the unwrapped echoes are combined, with that method's own settings (default
464    /// [`B0EstimationMethod::WeightedAvg`] with [`B0WeightType::PhaseSNR`]).
465    pub b0_estimation: B0EstimationMethod,
466    /// Poisson solver used when `unwrapping_algorithm` is [`UnwrappingAlgorithm::Laplacian`]
467    /// (default [`LaplacianSolver::Dct`]). With [`LaplacianSolver::Fft`] `{ pad: [64; 3] }` and
468    /// `b0_estimation` [`B0EstimationMethod::WeightedAvg`] with
469    /// [`B0WeightType::AssumedDecay`] at 40 ms, a multi-echo run gives UK Biobank's field map
470    /// (see [`run_field_mapping`](super::run_field_mapping)).
471    pub laplacian_solver: LaplacianSolver,
472    /// Laplacian unwrapping on the raw phase over the whole volume, instead of on the phase
473    /// zeroed outside the brain mask (default `false`). Zeroing first keeps the noise outside the
474    /// brain out of the global Poisson solve, and is what UK Biobank (`MRPhaseUnwrap(mask .*
475    /// phase)`) does. QSM.jl's `unwrap_laplacian` takes the raw phase: its `:dct` solver (this
476    /// crate's [`LaplacianSolver::Dct`]) ignores the mask, so it is `laplacian_unmasked: true`
477    /// here, while its default `:mgpcg` (not ported) multiplies the Laplacian by the mask and
478    /// solves with a Dirichlet boundary on it, which also removes the background (see
479    /// [`crate::unwrap::laplacian`]). STI Suite 3.0's `QSM_GUI` and QSMART unwrap the raw phase
480    /// (rules `sti.unwrap-unmasked`, `qsmart.unwrap-unmasked`; see [`crate::unwrap::laplacian`]).
481    /// With [`B0EstimationMethod::QsmartEchofit`] the unwrapped echoes are also kept over the
482    /// whole volume, as QSMART fits them; the other combinations use the voxels inside the mask.
483    /// Only with Laplacian unwrapping.
484    pub laplacian_unmasked: bool,
485    /// ROMEO's settings (default [`RomeoParams::default`], ROMEO.jl's API defaults).
486    /// [`RomeoParams::romeo_cli`] gives the `romeo` command line's (`romeo3` weights with a
487    /// magnitude, no temporal re-unwrapping), which with the rest of this default config is the
488    /// CLI's B0 pipeline. Which of the two QSMxT should default to is being measured (QSM.rs#319).
489    pub romeo_params: RomeoParams,
490    /// PRELUDE's settings, used when `unwrapping_algorithm` is [`UnwrappingAlgorithm::Prelude`]
491    /// (default [`PreludeParams::default`]: FSL `prelude`'s defaults, 8 phase partitions with
492    /// regions labelled slice by slice, and the median wrap count removed per echo as FSL's
493    /// output has it).
494    pub prelude_params: PreludeParams,
495}
496
497/// The ROMEO command line's default phase-offset smoothing sigma, in mm (the default of
498/// [`FieldMappingConfig::phase_offset_sigma_mm`]).
499pub const ROMEO_CLI_PHASE_OFFSET_SIGMA_MM: [f64; 3] = [7.0, 7.0, 7.0];
500
501impl FieldMappingConfig {
502    /// [`phase_offset_sigma_mm`](Self::phase_offset_sigma_mm) in voxels of `voxel_size`
503    /// (mm), as the ROMEO command line converts it.
504    pub fn phase_offset_sigma_voxels(&self, voxel_size: (f64, f64, f64)) -> [f64; 3] {
505        let s = self.phase_offset_sigma_mm;
506        [s[0] / voxel_size.0, s[1] / voxel_size.1, s[2] / voxel_size.2]
507    }
508}
509
510impl Default for FieldMappingConfig {
511    fn default() -> Self {
512        Self {
513            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
514            phase_offset_removal: true,
515            phase_offset_sigma_mm: ROMEO_CLI_PHASE_OFFSET_SIGMA_MM,
516            bipolar_correction: false,
517            b0_estimation: B0EstimationMethod::default(),
518            laplacian_solver: LaplacianSolver::Dct,
519            laplacian_unmasked: false,
520            romeo_params: RomeoParams::default(),
521            prelude_params: PreludeParams::default(),
522        }
523    }
524}
525
526/// Background removal configuration
527#[derive(Clone, Debug)]
528pub struct BgRemovalConfig {
529    pub algorithm: BgRemovalAlgorithm,
530    pub vsharp: VsharpParams,
531    pub pdf: PdfParams,
532    pub lbv: LbvParams,
533    pub ismv: IsmvParams,
534    pub sharp: SharpParams,
535    pub resharp: ResharpParams,
536    pub harperella: HarperellaParams,
537    pub iharperella: IharperellaParams,
538    pub sdf: SdfParams,
539    /// Overlap-tiling for BFRnet, as `(core, halo)` in voxels, as [`InversionConfig::tile`] is
540    /// for the inversion networks. `None` (default) runs the whole volume, which on a whole-head
541    /// scan does not fit a 32-bit (WASM) heap; `Some` runs it patch by patch, an approximation
542    /// with bounded memory ([`bfrnet_tiled`](crate::bgremove::bfrnet_tiled)). Ignored by the
543    /// classical methods.
544    pub tile: Option<(usize, usize)>,
545}
546
547impl Default for BgRemovalConfig {
548    fn default() -> Self {
549        Self {
550            algorithm: BgRemovalAlgorithm::Vsharp,
551            vsharp: VsharpParams::default(),
552            pdf: PdfParams::default(),
553            lbv: LbvParams::default(),
554            ismv: IsmvParams::default(),
555            sharp: SharpParams::default(),
556            resharp: ResharpParams::default(),
557            harperella: HarperellaParams::default(),
558            iharperella: IharperellaParams::default(),
559            sdf: SdfParams::default(),
560            tile: None,
561        }
562    }
563}
564
565/// Dipole inversion configuration
566#[derive(Clone, Debug)]
567pub struct InversionConfig {
568    pub algorithm: InversionAlgorithm,
569    pub tkd: TkdParams,
570    pub tsvd: TkdParams,
571    pub tikhonov: TikhonovParams,
572    pub tv: TvParams,
573    pub rts: RtsParams,
574    pub nltv: NltvParams,
575    pub medi: MediParams,
576    pub tfi: TfiParams,
577    pub ilsqr: IlsqrParams,
578    /// STAR-QSM (`te` and `b0` are overridden from scan metadata by the dispatcher).
579    pub star: StarParams,
580    pub tgv: TgvParams,
581    pub qsmart: QsmartParams,
582    pub ndi: NdiParams,
583    /// Shared by the `Fansi` (nlTV) and `FansiTgv` (nlTGV) algorithms; the
584    /// dispatcher sets `is_tgv` from the selected algorithm.
585    pub fansi: FansiParams,
586    pub l1qsm: L1QsmParams,
587    pub whqsm: WhQsmParams,
588    pub hdqsm: HdQsmParams,
589    /// AMP-PE (`b0` is overridden from scan metadata by the dispatcher).
590    pub amp_pe: AmpPeParams,
591    /// Minimally regularised LSQR (`b0` is overridden from scan metadata by the
592    /// dispatcher). Also HEIDI's reference seed.
593    pub lsqr: LsqrQsmParams,
594    /// HEIDI (Schweser 2012) as streaking reduction after the inversion: its cone fill
595    /// ([`crate::inversion::heidi_cone_fill`]) keeps the well-conditioned k-space of the method's
596    /// χ and refills the dipole cone under a field-derived weighted-TV prior. With
597    /// [`InversionAlgorithm::Lsqr`] this is HEIDI as published, run as the authors' pipeline runs
598    /// it ([`crate::inversion::heidi()`]: the unmasked LSQR seed on LSQR's grid). Other seeds:
599    /// see the HEIDI module docs (*Any seed*), the `heidi.*` rules and [`heidi_seed_problem`](super::heidi_seed_problem).
600    pub apply_heidi: bool,
601    /// HEIDI's parameters, used with [`Self::apply_heidi`].
602    pub heidi: HeidiParams,
603    /// Overlap-tiling for the deep-learning inversions, as `(core, halo)` in voxels. `None`
604    /// (default) runs the net whole-volume; `Some` runs it patch-by-patch (bounded memory) via the
605    /// `*_tiled` variants — an approximation, mainly for memory-constrained targets (e.g. WASM).
606    /// Stored as a plain tuple so this config type stays available without the `onnx` feature;
607    /// the dispatcher converts it to a [`crate::inversion::TileConfig`]. Ignored by the
608    /// natively-patch-based nets (autoqsm/qsmgan) and by non-DL algorithms.
609    pub tile: Option<(usize, usize)>,
610}
611
612impl InversionConfig {
613    /// What the selected inversion reconstructs from, for data with `n_echoes` echoes. See
614    /// [`InversionInput`].
615    ///
616    /// It is a property of the config rather than of [`InversionAlgorithm`] alone, because
617    /// **TGV** depends on more than its name:
618    ///
619    /// - **TGV** ([`run_tgv`](super::run_tgv)) reconstructs multi-echo data from the
620    ///   field-mapped total field ([`TgvInput::FieldPpm`](crate::inversion::TgvInput::FieldPpm),
621    ///   expressed as phase at the first echo time) and a single echo from its wrapped phase
622    ///   ([`TgvInput::WrappedPhase`](crate::inversion::TgvInput::WrappedPhase)). Either way it
623    ///   does its own background removal.
624    ///
625    /// The other total-field methods are TFI (a total-field method by definition: it inverts
626    /// the field over the whole FOV under a preconditioner), QSMART
627    /// ([`run_qsmart`](super::run_qsmart), whose two SDF stages are its background removal),
628    /// AutoQSM and NeXtQSM (networks with their own background removal). iQSM and iQSM+
629    /// ([`run_iqsm`](super::run_iqsm), [`run_iqsm_plus`](super::run_iqsm_plus)) take wrapped
630    /// phase. Every other method, including WH-QSM (which models only a *residual* harmonic
631    /// field) and LSQR (a residual-field term), takes the local field.
632    ///
633    /// **MEDI** takes the local field with every option: plain MEDI, MEDI-SMV
634    /// ([`MediParams::smv`]) and MEDI-mSMV ([`MediParams::msmv`]). That is how the MEDI authors
635    /// run it. MEDI toolbox `README.m` runs `RDF = PDF(iFreq, N_std, Mask, ...)` and then
636    /// `MEDI_L1('lambda',1000,'lambda_CSF',100,'merit','smv',5)` on `RDF`, which its naming
637    /// convention defines as the local field. `MEDI_L1` reads `iFreq` but never uses it. The
638    /// mSMV paper (Roberts et al. 2024, Eqs. 4 and 13) defines MEDI-SMV as the SMV high-pass of
639    /// the *local* field after PDF, and SEPIA passes MEDI its local field. The SMV step removes
640    /// the residual background that a background removal leaves near the brain edge. It is not
641    /// a background removal itself. On the total field, MEDI-SMV is a different pipeline. With
642    /// the default mask shrinking it gives nearly the same χ, but without shrinking it fails
643    /// (see the MEDI module docs). The authors' pipeline uses PDF for the background removal
644    /// ([`reference_bg_removal`](Self::reference_bg_removal)).
645    pub fn input(&self, n_echoes: usize) -> InversionInput {
646        use InversionAlgorithm as A;
647        match self.algorithm {
648            A::Tgv if n_echoes > 1 => InversionInput::TotalField,
649            A::Tgv | A::Iqsm | A::IqsmPlus => InversionInput::WrappedPhase,
650            A::Tfi | A::Qsmart | A::Autoqsm | A::Nextqsm => InversionInput::TotalField,
651            A::Medi
652            | A::Tkd
653            | A::Tsvd
654            | A::Tikhonov
655            | A::Tv
656            | A::Rts
657            | A::Nltv
658            | A::Ilsqr
659            | A::Star
660            | A::Ndi
661            | A::Fansi
662            | A::FansiTgv
663            | A::L1qsm
664            | A::Whqsm
665            | A::Hdqsm
666            | A::AmpPe
667            | A::Lsqr
668            | A::Xqsm
669            | A::Qsmnet
670            | A::QsmnetPlus
671            | A::Qsmnetme
672            | A::Qsmgan
673            | A::Ir2qsm
674            | A::Lpcnn
675            | A::ModlQsm => InversionInput::LocalField,
676        }
677    }
678
679    /// Whether HEIDI's cone fill runs on the method's result after the inversion, rather than
680    /// inside it: [`Self::apply_heidi`] with any seed but LSQR, whose reference recipe
681    /// ([`crate::inversion::heidi()`]) [`run_dipole_inversion`](super::run_dipole_inversion) runs as
682    /// one step. [`run_dipole_inversion`](super::run_dipole_inversion) applies it either way. A
683    /// host that combines several inversions (two-pass) instead runs them with `apply_heidi` off
684    /// and fills the combined map once with [`run_heidi_cone_fill`](super::run_heidi_cone_fill),
685    /// whatever the seed (an LSQR map is then filled as the method's masked result, as
686    /// `Wrapper_QSM_HEIDI4all` fills any seed).
687    pub fn cone_fill_after(&self) -> bool {
688        self.apply_heidi && self.algorithm != InversionAlgorithm::Lsqr
689    }
690
691    /// The background removal the selected method's authors run in front of it, for a
692    /// [`InversionInput::LocalField`] method whose authors' pipeline QSM.rs ports. Hosts can use
693    /// it as the default background removal for that method.
694    ///
695    /// - MEDI (with or without SMV or mSMV): [`BgRemovalAlgorithm::Pdf`]. The MEDI toolbox's
696    ///   `README.m` runs `PDF.m` (ported as [`BgRemovalAlgorithm::Pdf`], with the same
697    ///   `Fit_ppm_complex` noise weighting) and gives `MEDI_L1` its result. The mSMV paper
698    ///   (Roberts et al. 2024) also runs MEDI-SMV and MEDI-mSMV after PDF.
699    ///
700    /// `None` when no such pipeline is recorded for the method. It is then the host's choice.
701    pub fn reference_bg_removal(&self) -> Option<BgRemovalAlgorithm> {
702        match self.algorithm {
703            InversionAlgorithm::Medi => Some(BgRemovalAlgorithm::Pdf),
704            _ => None,
705        }
706    }
707}
708
709impl Default for InversionConfig {
710    fn default() -> Self {
711        Self {
712            algorithm: InversionAlgorithm::Rts,
713            tkd: TkdParams::default(),
714            tsvd: TkdParams::default(),
715            tikhonov: TikhonovParams::default(),
716            tv: TvParams::default(),
717            rts: RtsParams::default(),
718            nltv: NltvParams::default(),
719            medi: MediParams::default(),
720            tfi: TfiParams::default(),
721            ilsqr: IlsqrParams::default(),
722            star: StarParams::default(),
723            tgv: TgvParams::default(),
724            qsmart: QsmartParams::default(),
725            ndi: NdiParams::default(),
726            fansi: FansiParams::default(),
727            l1qsm: L1QsmParams::default(),
728            whqsm: WhQsmParams::default(),
729            hdqsm: HdQsmParams::default(),
730            amp_pe: AmpPeParams::default(),
731            lsqr: LsqrQsmParams::default(),
732            apply_heidi: false,
733            heidi: HeidiParams::default(),
734            tile: None,
735        }
736    }
737}
738
739/// Susceptibility source-separation algorithm (χ+ / χ−).
740#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
741#[derive(Clone, Copy, Debug, PartialEq)]
742pub enum SeparationAlgorithm {
743    /// Shin 2021 projected-CG, QSM-initialized (local field + R2' + magnitude + QSM).
744    ChiSepIlsqr,
745    /// MEDI-based Gauss-Newton (local field + R2' + magnitude).
746    ChiSepMedi,
747    /// Closed-form from a QSM + R2* (Dimov 2022; R2* fit from magnitude if absent).
748    R2starQsm,
749    /// Wavelet-L1 proximal-gradient from a QSM + R2' (Fang 2023).
750    WaveSep,
751    /// Signal-domain per-voxel fit from a QSM + multi-echo magnitude (Chen 2021).
752    Decompose,
753    /// Hollow-cylinder fit from a QSM + R2' + multi-echo magnitude (Wharton & Bowtell).
754    HcChisep,
755    /// SUSEP-Net deep-learning separation from QSM + R2' + local field (requires
756    /// the `onnx` feature and the `susep-net` weights; see [`crate::models`]).
757    SusepNet,
758    /// χ-sepnet (SNU-LIST) deep-learning separation from local field + QSM + R2'
759    /// (requires the `onnx` feature and the `chi-sepnet` weights; see [`crate::models`]).
760    ChiSepNet,
761}
762
763impl BgRemovalAlgorithm {
764    /// How this method behaves when B0 is not along voxel `+z`.
765    ///
766    /// Only PDF projects onto dipole fields and therefore needs the direction; every other
767    /// method here is harmonic/SMV-based and is direction-independent. BFRnet is a network, but
768    /// background removal has no dipole orientation to get wrong in the way inversion does — it
769    /// separates harmonic from non-harmonic content, so it is treated as direction-independent.
770    pub fn orientation_support(&self) -> OrientationSupport {
771        match self {
772            BgRemovalAlgorithm::Pdf => OrientationSupport::Arbitrary,
773            BgRemovalAlgorithm::Vsharp
774            | BgRemovalAlgorithm::Lbv
775            | BgRemovalAlgorithm::Ismv
776            | BgRemovalAlgorithm::Sharp
777            | BgRemovalAlgorithm::Resharp
778            | BgRemovalAlgorithm::Harperella
779            | BgRemovalAlgorithm::Iharperella
780            | BgRemovalAlgorithm::Bfrnet => OrientationSupport::NotApplicable,
781        }
782    }
783}
784
785impl InversionAlgorithm {
786    /// How this method behaves when B0 is not along voxel `+z`.
787    ///
788    /// Every classical inversion builds its dipole kernel from the supplied direction, directly
789    /// or through the shared ADMM/FANSI spectral setup, so all of them handle oblique data on
790    /// the acquired grid. The deep-learning methods take no direction at all.
791    pub fn orientation_support(&self) -> OrientationSupport {
792        match self {
793            InversionAlgorithm::Tkd
794            | InversionAlgorithm::Tsvd
795            | InversionAlgorithm::Tikhonov
796            | InversionAlgorithm::Tv
797            | InversionAlgorithm::Rts
798            | InversionAlgorithm::Nltv
799            | InversionAlgorithm::Medi
800            | InversionAlgorithm::Tfi
801            | InversionAlgorithm::Ilsqr
802            | InversionAlgorithm::Star
803            | InversionAlgorithm::Tgv
804            | InversionAlgorithm::Qsmart
805            | InversionAlgorithm::Ndi
806            | InversionAlgorithm::Fansi
807            | InversionAlgorithm::FansiTgv
808            | InversionAlgorithm::L1qsm
809            | InversionAlgorithm::Whqsm
810            | InversionAlgorithm::Hdqsm
811            | InversionAlgorithm::AmpPe
812            | InversionAlgorithm::Lsqr
813            // QSMnetME's route rotates the field into its training frame (B0 along z) itself.
814            | InversionAlgorithm::Qsmnetme => OrientationSupport::Arbitrary,
815            InversionAlgorithm::Xqsm
816            | InversionAlgorithm::Qsmnet
817            | InversionAlgorithm::QsmnetPlus
818            | InversionAlgorithm::Autoqsm
819            | InversionAlgorithm::Qsmgan
820            | InversionAlgorithm::Ir2qsm
821            | InversionAlgorithm::Lpcnn
822            | InversionAlgorithm::ModlQsm
823            | InversionAlgorithm::Nextqsm
824            | InversionAlgorithm::Iqsm
825            | InversionAlgorithm::IqsmPlus => OrientationSupport::AxialOnly,
826        }
827    }
828}
829
830impl SeparationAlgorithm {
831    /// How this method behaves when B0 is not along voxel `+z`.
832    ///
833    /// The model-based separations carry the direction through their field terms. The rest
834    /// either consume an already-reconstructed χ map and R2\* (so orientation was settled
835    /// upstream) or are networks trained on axial data.
836    pub fn orientation_support(&self) -> OrientationSupport {
837        match self {
838            SeparationAlgorithm::ChiSepIlsqr | SeparationAlgorithm::ChiSepMedi => {
839                OrientationSupport::Arbitrary
840            }
841            SeparationAlgorithm::R2starQsm
842            | SeparationAlgorithm::WaveSep
843            | SeparationAlgorithm::Decompose
844            | SeparationAlgorithm::HcChisep => OrientationSupport::NotApplicable,
845            // χ-sepnet's default route (ChiSepNetRoute::Toolbox) rotates its inputs into the
846            // training frame itself, as the authors' toolbox does; the Native route expects
847            // B0 along z, which is the caller's to arrange.
848            SeparationAlgorithm::ChiSepNet => OrientationSupport::Arbitrary,
849            SeparationAlgorithm::SusepNet => OrientationSupport::AxialOnly,
850        }
851    }
852}
853
854// ─── Deep-learning model-registry mapping ───
855//
856// These map each stage enum's deep-learning variants to their [`crate::models`]
857// registry id. Exhaustive matches: adding a variant forces a decision here, and the
858// `registry_models_are_pipeline_wired` test asserts every registry model of a pipeline
859// stage is claimed by some variant — so a model added to the registry but not wired
860// into an enum fails the build's tests (registry↔pipeline drift guard).
861
862impl BgRemovalAlgorithm {
863    /// Model-registry id for deep-learning variants, else `None` (classical methods).
864    pub fn dl_model_id(self) -> Option<&'static str> {
865        match self {
866            Self::Bfrnet => Some("bfrnet"),
867            Self::Vsharp | Self::Pdf | Self::Lbv | Self::Ismv | Self::Sharp
868            | Self::Resharp | Self::Harperella | Self::Iharperella => None,
869        }
870    }
871    /// Every variant, for exhaustiveness in tests/tools.
872    pub const VARIANTS: &'static [Self] = &[
873        Self::Vsharp, Self::Pdf, Self::Lbv, Self::Ismv, Self::Sharp,
874        Self::Resharp, Self::Harperella, Self::Iharperella, Self::Bfrnet,
875    ];
876}
877
878impl InversionAlgorithm {
879    /// Model-registry id for deep-learning variants, else `None` (classical methods).
880    pub fn dl_model_id(self) -> Option<&'static str> {
881        match self {
882            Self::Xqsm => Some("xqsm"),
883            Self::Qsmnet => Some("qsmnet"),
884            Self::QsmnetPlus => Some("qsmnet-plus"),
885            Self::Qsmnetme => Some("qsmnetme"),
886            Self::Autoqsm => Some("autoqsm"),
887            Self::Qsmgan => Some("qsmgan"),
888            Self::Ir2qsm => Some("ir2qsm"),
889            Self::Lpcnn => Some("lpcnn"),
890            Self::ModlQsm => Some("modl-qsm"),
891            Self::Nextqsm => Some("nextqsm"),
892            Self::Iqsm => Some("iqsm"),
893            Self::IqsmPlus => Some("iqsm-plus"),
894            Self::Tkd | Self::Tsvd | Self::Tikhonov | Self::Tv | Self::Rts | Self::Nltv
895            | Self::Medi | Self::Tfi | Self::Ilsqr | Self::Star | Self::Tgv | Self::Qsmart | Self::Ndi
896            | Self::Fansi | Self::FansiTgv | Self::L1qsm | Self::Whqsm | Self::Hdqsm
897            | Self::AmpPe | Self::Lsqr => None,
898        }
899    }
900    pub const VARIANTS: &'static [Self] = &[
901        Self::Tkd, Self::Tsvd, Self::Tikhonov, Self::Tv, Self::Rts, Self::Nltv, Self::Medi,
902        Self::Tfi, Self::Ilsqr, Self::Star, Self::Tgv, Self::Qsmart, Self::Ndi, Self::Fansi, Self::FansiTgv,
903        Self::L1qsm, Self::Whqsm, Self::Hdqsm, Self::AmpPe, Self::Lsqr,
904        Self::Xqsm, Self::Qsmnet,
905        Self::QsmnetPlus, Self::Qsmnetme, Self::Autoqsm, Self::Qsmgan, Self::Ir2qsm, Self::Lpcnn, Self::ModlQsm,
906        Self::Nextqsm, Self::Iqsm, Self::IqsmPlus,
907    ];
908}
909
910impl SeparationAlgorithm {
911    /// Model-registry id for deep-learning variants, else `None` (classical methods).
912    pub fn dl_model_id(self) -> Option<&'static str> {
913        match self {
914            Self::SusepNet => Some("susep-net"),
915            Self::ChiSepNet => Some("chi-sepnet"),
916            Self::ChiSepIlsqr | Self::ChiSepMedi | Self::R2starQsm | Self::WaveSep
917            | Self::Decompose | Self::HcChisep => None,
918        }
919    }
920    pub const VARIANTS: &'static [Self] = &[
921        Self::ChiSepIlsqr, Self::ChiSepMedi, Self::R2starQsm, Self::WaveSep,
922        Self::Decompose, Self::HcChisep, Self::SusepNet, Self::ChiSepNet,
923    ];
924}
925
926// ─── Stable choice ids ───
927//
928// Each selection enum names its variants by a stable kebab-case id, used by the pipeline rules
929// ([`super::rules`]) to name a choice in data and JSON. They are the names QSMxT's config uses for
930// the same choices, so a host's mapping is the identity wherever the two agree.
931
932impl UnwrappingAlgorithm {
933    /// Stable id (`"romeo"`, `"laplacian"`, `"prelude"`, `"medi-region-growing"`).
934    pub fn id(self) -> &'static str {
935        match self {
936            Self::Romeo => "romeo",
937            Self::Laplacian => "laplacian",
938            Self::Prelude => "prelude",
939            Self::MediRegionGrowing => "medi-region-growing",
940        }
941    }
942}
943
944impl B0EstimationMethod {
945    /// Stable id of the method (its settings are not part of it).
946    pub fn id(&self) -> &'static str {
947        match self {
948            Self::WeightedAvg { .. } => "weighted-avg",
949            Self::LinearFit(_) => "linear-fit",
950            Self::RobustFit(_) => "robust-fit",
951            Self::ComplexFit(_) => "complex-fit",
952            Self::QsmartEchofit { .. } => "qsmart-echofit",
953        }
954    }
955}
956
957impl MaskOp {
958    /// Stable id of the operation (its parameters are not part of it).
959    pub fn id(&self) -> &'static str {
960        match self {
961            Self::Threshold { .. } => "threshold",
962            Self::Bet(_) => "bet",
963            Self::Erode { .. } => "erode",
964            Self::Dilate { .. } => "dilate",
965            Self::Close { .. } => "close",
966            Self::FillHoles { .. } => "fill-holes",
967            Self::GaussianSmooth { .. } => "gaussian-smooth",
968            Self::SignalErode(_) => "signal-erode",
969            Self::HdBet(_) => "hd-bet",
970            Self::Rs2Net(_) => "rs2-net",
971            Self::MindGrab(_) => "mindgrab",
972        }
973    }
974}
975
976impl BgRemovalAlgorithm {
977    /// Stable id (`"vsharp"`, `"pdf"`, …).
978    pub fn id(self) -> &'static str {
979        match self {
980            Self::Vsharp => "vsharp",
981            Self::Pdf => "pdf",
982            Self::Lbv => "lbv",
983            Self::Ismv => "ismv",
984            Self::Sharp => "sharp",
985            Self::Resharp => "resharp",
986            Self::Harperella => "harperella",
987            Self::Iharperella => "iharperella",
988            Self::Bfrnet => "bfrnet",
989        }
990    }
991}
992
993impl InversionAlgorithm {
994    /// Stable id (`"rts"`, `"medi"`, `"fansi-tgv"`, …).
995    pub fn id(self) -> &'static str {
996        match self {
997            Self::Tkd => "tkd",
998            Self::Tsvd => "tsvd",
999            Self::Tikhonov => "tikhonov",
1000            Self::Tv => "tv",
1001            Self::Rts => "rts",
1002            Self::Nltv => "nltv",
1003            Self::Medi => "medi",
1004            Self::Tfi => "tfi",
1005            Self::Ilsqr => "ilsqr",
1006            Self::Star => "star",
1007            Self::Tgv => "tgv",
1008            Self::Qsmart => "qsmart",
1009            Self::Ndi => "ndi",
1010            Self::Fansi => "fansi",
1011            Self::FansiTgv => "fansi-tgv",
1012            Self::L1qsm => "l1qsm",
1013            Self::Whqsm => "whqsm",
1014            Self::Hdqsm => "hdqsm",
1015            Self::AmpPe => "amp-pe",
1016            Self::Lsqr => "lsqr",
1017            Self::Xqsm => "xqsm",
1018            Self::Qsmnet => "qsmnet",
1019            Self::QsmnetPlus => "qsmnet-plus",
1020            Self::Qsmnetme => "qsmnetme",
1021            Self::Autoqsm => "autoqsm",
1022            Self::Qsmgan => "qsmgan",
1023            Self::Ir2qsm => "ir2qsm",
1024            Self::Lpcnn => "lpcnn",
1025            Self::ModlQsm => "modl-qsm",
1026            Self::Nextqsm => "nextqsm",
1027            Self::Iqsm => "iqsm",
1028            Self::IqsmPlus => "iqsm-plus",
1029        }
1030    }
1031}
1032
1033impl SeparationAlgorithm {
1034    /// Stable id (`"chi-sep-ilsqr"`, `"decompose"`, …).
1035    pub fn id(self) -> &'static str {
1036        match self {
1037            Self::ChiSepIlsqr => "chi-sep-ilsqr",
1038            Self::ChiSepMedi => "chi-sep-medi",
1039            Self::R2starQsm => "r2star-qsm",
1040            Self::WaveSep => "wavesep",
1041            Self::Decompose => "decompose",
1042            Self::HcChisep => "hc-chisep",
1043            Self::SusepNet => "susep-net",
1044            Self::ChiSepNet => "chi-sepnet",
1045        }
1046    }
1047}
1048
1049/// Configuration for the χ-separation stage.
1050#[derive(Clone, Debug)]
1051pub struct SeparationConfig {
1052    pub algorithm: SeparationAlgorithm,
1053    /// `cf` on the chi-sep params is overridden from scan metadata by the dispatcher.
1054    pub chi_sep_ilsqr: ChiSepIlsqrParams,
1055    pub chi_sep_medi: ChiSepMediParams,
1056    /// `b0` overridden from scan metadata by the dispatcher.
1057    pub r2star_qsm: R2starQsmParams,
1058    pub wavesep: WaveSepParams,
1059    /// `b0` overridden from scan metadata by the dispatcher.
1060    pub decompose: DecomposeParams,
1061    /// `b0` overridden from scan metadata; `se_echo_times` supplies the SE echoes.
1062    pub hc_chisep: HcChisepParams,
1063    /// χ-sepnet sliding-window patch (default: the authors' 192×192×128).
1064    pub chi_sepnet: ChiSepNetParams,
1065    /// SUSEP-Net whole volume (default) or sliding-window patch.
1066    pub susep_net: SusepNetParams,
1067}
1068
1069impl Default for SeparationConfig {
1070    fn default() -> Self {
1071        Self {
1072            algorithm: SeparationAlgorithm::ChiSepIlsqr,
1073            chi_sep_ilsqr: ChiSepIlsqrParams::default(),
1074            chi_sep_medi: ChiSepMediParams::default(),
1075            r2star_qsm: R2starQsmParams::default(),
1076            wavesep: WaveSepParams::default(),
1077            decompose: DecomposeParams::default(),
1078            hc_chisep: HcChisepParams::default(),
1079            chi_sepnet: ChiSepNetParams::default(),
1080            susep_net: SusepNetParams::default(),
1081        }
1082    }
1083}
1084
1085// =========================================================================
1086// Top-level pipeline config
1087// =========================================================================
1088
1089/// Complete QSM pipeline configuration.
1090///
1091/// Contains all per-stage configs. Consumers call individual stage functions
1092/// (e.g. `run_field_mapping`, `run_bg_removal`) passing the relevant section.
1093/// Masking config is used when the consumer needs to generate a mask.
1094#[derive(Clone, Debug)]
1095pub struct QsmPipelineConfig {
1096    pub masking: MaskingConfig,
1097    pub field_mapping: FieldMappingConfig,
1098    pub bg_removal: BgRemovalConfig,
1099    pub inversion: InversionConfig,
1100    pub reference: QsmReference,
1101    /// Reconstruct a QSM for each echo instead of one from the echo-combined field (see
1102    /// [`super::per_echo`]): each echo's phase is unwrapped and divided by its own echo time
1103    /// ([`super::per_echo::run_field_mapping_per_echo`]), and background removal, inversion and
1104    /// referencing run on that echo's field with its own echo time ([`ScanMetadata::echo`]). The
1105    /// echo combination ([`FieldMappingConfig::b0_estimation`]), phase-offset removal and bipolar
1106    /// correction then do not run. Default `false`: one QSM from the combined field.
1107    pub qsm_per_echo: bool,
1108    /// Taper ratio of the SNU χ-separation toolbox's k-space Tukey window
1109    /// ([`crate::utils::tukey_window`]), which the host applies to every echo's magnitude and
1110    /// phase on the acquired grid before anything else reads them
1111    /// ([`tukey_window_mag_phase`](crate::utils::tukey_window::tukey_window_mag_phase)). The
1112    /// toolbox uses 0.4 for Siemens and GE and 0 for Philips, before all three of its χ-separation
1113    /// methods (the authors' setting: rules `chi-sep.tukey-*` and `chi-sepnet.tukey-*`). Default
1114    /// 0: no windowing.
1115    pub tukey_window: f64,
1116}
1117
1118impl Default for QsmPipelineConfig {
1119    fn default() -> Self {
1120        Self {
1121            masking: MaskingConfig::default(),
1122            field_mapping: FieldMappingConfig::default(),
1123            bg_removal: BgRemovalConfig::default(),
1124            inversion: InversionConfig::default(),
1125            reference: QsmReference::Mean,
1126            qsm_per_echo: false,
1127            tukey_window: 0.0,
1128        }
1129    }
1130}
1131
1132// =========================================================================
1133// Scan metadata and stage result types
1134// =========================================================================
1135
1136/// Metadata about the scan
1137#[derive(Clone, Debug)]
1138pub struct ScanMetadata {
1139    /// Volume dimensions (nx, ny, nz)
1140    pub dims: (usize, usize, usize),
1141    /// Voxel size in mm (vsx, vsy, vsz)
1142    pub voxel_size: (f64, f64, f64),
1143    /// Echo times in seconds
1144    pub echo_times: Vec<f64>,
1145    /// Main field strength in Tesla
1146    pub field_strength: f64,
1147    /// B0 direction as unit vector in voxel coordinates
1148    pub b0_direction: (f64, f64, f64),
1149    /// Slice thickness and slice axis, when the acquisition recorded them.
1150    ///
1151    /// `voxel_size` along the slice axis is the slice-to-slice *pitch*; this supplies the
1152    /// thickness, and the two together say whether the sampled volume has holes in it. A 2D
1153    /// multi-slice acquisition often leaves a gap, and a gapped volume cannot be reconstructed
1154    /// at all — the FFT dipole and spherical-mean kernels are defined on a uniform grid, so on
1155    /// one with holes they compute a different convolution and produce a map that is wrong
1156    /// while still looking like a susceptibility map.
1157    ///
1158    /// `None` means the acquisition did not say. BIDS only *recommends* `SliceThickness`, so
1159    /// this is common, and a gap then cannot be detected by this crate or by anything else
1160    /// reading the NIfTI alone. The stages run anyway rather than refusing the vast majority of
1161    /// data that has no gap — so a host that can read the field should set it, and a host that
1162    /// cannot should tell its user that this check could not run. See
1163    /// [`Grid::require_contiguous_slices_if_known`](crate::Grid::require_contiguous_slices_if_known).
1164    pub slice_geometry: Option<crate::grid::SliceGeometry>,
1165    /// The image's voxel→world affine (row-major 4×4, as in a NIfTI sform/qform), when the host
1166    /// has one. Only the steps that need the anatomical orientation read it — currently
1167    /// [`MaskOp::MindGrab`], which errors without it; everything else works in voxel space.
1168    pub affine: Option<[f64; 16]>,
1169}
1170
1171impl ScanMetadata {
1172    /// Get a Grid from this metadata's dimensions and voxel sizes.
1173    #[inline]
1174    pub fn grid(&self) -> crate::Grid {
1175        crate::Grid {
1176            dims: self.dims,
1177            voxel_size: self.voxel_size,
1178        }
1179    }
1180
1181    /// This scan's metadata for echo `e` alone (`echo_times = [echo_times[e]]`), for the stages
1182    /// of a per-echo reconstruction ([`QsmPipelineConfig::qsm_per_echo`]): each echo's field,
1183    /// local field and χ are computed at that echo's time. Panics if `e` is out of range.
1184    pub fn echo(&self, e: usize) -> ScanMetadata {
1185        ScanMetadata { echo_times: vec![self.echo_times[e]], ..self.clone() }
1186    }
1187
1188    /// Refuse an acquisition whose slices are not contiguous.
1189    ///
1190    /// Every stage that convolves with a kernel defined on the voxel grid calls this first —
1191    /// background removal, dipole inversion, TGV, chi-separation. They call it unconditionally
1192    /// rather than per algorithm, because every one of those is three-dimensional: a complete
1193    /// background removal needs a 3D stage to have the mean value property at all, and the
1194    /// dipole kernel is inherently 3D, so there is no variant for which a gap is harmless.
1195    ///
1196    /// Phase unwrapping deliberately does **not** call this. A gap degrades 3D unwrapping too,
1197    /// but the remedy there is to unwrap in plane ([`crate::unwrap::unwrap_slicewise`]) rather
1198    /// than to refuse, and refusing would block the one path that handles the case.
1199    ///
1200    /// Returns [`PipelineError::InvalidInput`] carrying the measured thickness, pitch and gap.
1201    pub fn require_contiguous_slices(&self) -> Result<(), PipelineError> {
1202        self.grid()
1203            .require_contiguous_slices_if_known(self.slice_geometry)
1204            .map_err(|e| PipelineError::InvalidInput(e.to_string()))
1205    }
1206}
1207
1208/// Results from field mapping stage
1209pub struct FieldMappingResult {
1210    /// B0 field map in ppm; 0 outside [`valid_mask`](Self::valid_mask) (inside `mask`).
1211    pub b0_field_ppm: Vec<f64>,
1212    /// Where the field is defined: the brain mask passed in, less the voxels where MCPC-3D-S's
1213    /// phase offset is undefined ([`PhaseOffsetRemoval::valid`](crate::utils::PhaseOffsetRemoval::valid))
1214    /// when phase-offset removal ran; the brain mask itself on every other path. The reference
1215    /// (MriResearchTools' `mcpc3ds` → ROMEO.jl → `calculateB0_unwrapped`) leaves the field NaN
1216    /// there, so later stages (background removal, inversion, referencing) must work in this mask,
1217    /// not in the brain mask.
1218    pub valid_mask: Vec<u8>,
1219    /// Phase offset map (if phase offset removal was used); 0 where it is undefined.
1220    pub phase_offset: Option<Vec<f64>>,
1221    /// What the robust fit found, when [`B0EstimationMethod::RobustFit`] combined the echoes;
1222    /// `None` for every other method.
1223    pub robust_fit: Option<RobustFitReport>,
1224    /// QSMART's reliability map `R_0` (1 = reliable), when [`B0EstimationMethod::QsmartEchofit`]
1225    /// combined the echoes; `None` for every other method. Pass it to
1226    /// [`run_qsmart`](crate::pipeline::run_qsmart).
1227    pub reliability: Option<Vec<u8>>,
1228    /// The blurred echo-fit residual [`reliability`](Self::reliability) was cut at, when
1229    /// [`B0EstimationMethod::QsmartEchofit`] combined the echoes: the percentile's value for
1230    /// [`QsmartFitThreshold::Percentile`](crate::utils::QsmartFitThreshold::Percentile), the
1231    /// value itself for a fixed threshold (`echofit.m` prints it as "Setting adaptive echo-fit
1232    /// threshold at %2.2f"). `None` for every other method.
1233    pub qsmart_fit_threshold: Option<f64>,
1234    /// Standard deviation of `b0_field_ppm` per voxel, in ppm, for the methods that estimate it:
1235    /// [`B0EstimationMethod::ComplexFit`] (`Fit_ppm_complex.m`'s `dp1`, MEDI's `N_std`, which
1236    /// MEDI passes to PDF and MEDI_L1). `None` for every other method.
1237    pub field_noise_ppm: Option<Vec<f64>>,
1238    /// MEDI's complex fit itself, when [`B0EstimationMethod::ComplexFit`] made the field map:
1239    /// [`fit_ppm_complex`](crate::utils::fit_ppm_complex)'s result, before spatial unwrapping.
1240    /// Its [`field`](crate::utils::FitPpmComplexResult::field) is MEDI's `iFreq_raw` (phase per
1241    /// echo spacing, radians, wrapped) and its [`noise`](crate::utils::FitPpmComplexResult::noise)
1242    /// MEDI's `N_std` (radians per echo spacing), so a caller that wants them need not run the fit
1243    /// again. `None` for every other method.
1244    pub complex_fit: Option<crate::utils::FitPpmComplexResult>,
1245}
1246
1247/// Per-echo evidence from [`B0EstimationMethod::RobustFit`]: which echoes the fit stopped
1248/// trusting, and where. Diagnostics only; the field map has already been computed with these
1249/// weights and nothing here drops an echo.
1250#[derive(Clone, Debug)]
1251pub struct RobustFitReport {
1252    /// Per-echo summary, including [`EchoQuality::flagged`] (the echoes that are out of line
1253    /// with the rest) and [`EchoQuality::voxels_examined`] (check it before trusting a clean
1254    /// report).
1255    pub quality: EchoQuality,
1256    /// Final robust weight per echo per voxel, on `[0, 1]`, before the magnitude is multiplied
1257    /// in: `robust_weights[echo][voxel]`, same voxel order as the input. `1.0` where the loss
1258    /// never fired, including
1259    /// everywhere outside the mask, which is never examined.
1260    pub robust_weights: Vec<Vec<f64>>,
1261}
1262
1263/// Results from background removal stage
1264pub struct BgRemovalResult {
1265    /// Local field in ppm
1266    pub local_field_ppm: Vec<f64>,
1267    /// Eroded mask
1268    pub eroded_mask: Vec<u8>,
1269}
1270
1271/// Pipeline error type
1272#[derive(Debug)]
1273pub enum PipelineError {
1274    /// Invalid configuration
1275    InvalidConfig(String),
1276    /// Invalid input data
1277    InvalidInput(String),
1278    /// Algorithm failure
1279    AlgorithmError(String),
1280    /// Dimension mismatch
1281    DimensionMismatch { expected: usize, got: usize },
1282}
1283
1284impl std::fmt::Display for PipelineError {
1285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1286        match self {
1287            Self::InvalidConfig(msg) => write!(f, "invalid config: {}", msg),
1288            Self::InvalidInput(msg) => write!(f, "invalid input: {}", msg),
1289            Self::AlgorithmError(msg) => write!(f, "algorithm error: {}", msg),
1290            Self::DimensionMismatch { expected, got } => {
1291                write!(f, "dimension mismatch: expected {}, got {}", expected, got)
1292            }
1293        }
1294    }
1295}
1296
1297impl std::error::Error for PipelineError {}
1298
1299impl From<crate::error::InputError> for PipelineError {
1300    fn from(e: crate::error::InputError) -> Self {
1301        Self::InvalidInput(e.0)
1302    }
1303}
1304
1305#[cfg(test)]
1306mod orientation_tests {
1307    use super::*;
1308
1309    /// Every classical inversion builds its kernel from the supplied direction, so it must claim
1310    /// Arbitrary. If a new one is added without wiring bdir through, this is where it shows up.
1311    #[test]
1312    fn classical_inversions_take_a_direction() {
1313        for a in [
1314            InversionAlgorithm::Tkd, InversionAlgorithm::Tsvd, InversionAlgorithm::Tikhonov,
1315            InversionAlgorithm::Tv, InversionAlgorithm::Rts, InversionAlgorithm::Nltv,
1316            InversionAlgorithm::Medi, InversionAlgorithm::Tfi, InversionAlgorithm::Ilsqr,
1317            InversionAlgorithm::Star, InversionAlgorithm::Tgv, InversionAlgorithm::Qsmart, InversionAlgorithm::Ndi,
1318            InversionAlgorithm::Fansi, InversionAlgorithm::FansiTgv, InversionAlgorithm::L1qsm,
1319            InversionAlgorithm::Whqsm, InversionAlgorithm::Hdqsm, InversionAlgorithm::AmpPe,
1320            InversionAlgorithm::Lsqr, InversionAlgorithm::Qsmnetme,
1321        ] {
1322            assert_eq!(a.orientation_support(), OrientationSupport::Arbitrary, "{a:?}");
1323            assert!(!a.orientation_support().requires_axial(), "{a:?}");
1324        }
1325    }
1326
1327    /// Networks have no direction input, so oblique data must be resampled for them.
1328    #[test]
1329    fn learned_inversions_need_axial_data() {
1330        for a in [
1331            InversionAlgorithm::Xqsm, InversionAlgorithm::Qsmnet, InversionAlgorithm::QsmnetPlus,
1332            InversionAlgorithm::Autoqsm, InversionAlgorithm::Qsmgan, InversionAlgorithm::Ir2qsm,
1333            InversionAlgorithm::Lpcnn, InversionAlgorithm::ModlQsm, InversionAlgorithm::Nextqsm,
1334            InversionAlgorithm::Iqsm, InversionAlgorithm::IqsmPlus,
1335        ] {
1336            assert!(a.orientation_support().requires_axial(), "{a:?}");
1337        }
1338    }
1339
1340    /// SMV-family background removal is harmonic and rotation-invariant; only PDF cares.
1341    #[test]
1342    fn background_removal_is_direction_independent_except_pdf() {
1343        assert_eq!(BgRemovalAlgorithm::Pdf.orientation_support(), OrientationSupport::Arbitrary);
1344        for a in [
1345            BgRemovalAlgorithm::Vsharp, BgRemovalAlgorithm::Sharp, BgRemovalAlgorithm::Resharp,
1346            BgRemovalAlgorithm::Ismv, BgRemovalAlgorithm::Lbv, BgRemovalAlgorithm::Harperella,
1347            BgRemovalAlgorithm::Iharperella, BgRemovalAlgorithm::Bfrnet,
1348        ] {
1349            assert_eq!(a.orientation_support(), OrientationSupport::NotApplicable, "{a:?}");
1350            assert!(!a.orientation_support().requires_axial(), "{a:?}");
1351        }
1352    }
1353
1354    /// No background removal or separation method should ever force a resample on its own.
1355    #[test]
1356    fn only_learned_methods_force_a_resample() {
1357        for a in [SeparationAlgorithm::ChiSepIlsqr, SeparationAlgorithm::ChiSepMedi, SeparationAlgorithm::ChiSepNet] {
1358            assert_eq!(a.orientation_support(), OrientationSupport::Arbitrary, "{a:?}");
1359        }
1360        assert!(SeparationAlgorithm::SusepNet.orientation_support().requires_axial());
1361        for a in [
1362            SeparationAlgorithm::R2starQsm, SeparationAlgorithm::WaveSep,
1363            SeparationAlgorithm::Decompose, SeparationAlgorithm::HcChisep,
1364        ] {
1365            assert!(!a.orientation_support().requires_axial(), "{a:?}");
1366        }
1367    }
1368}
1369
1370#[cfg(test)]
1371mod inversion_input_tests {
1372    use super::*;
1373
1374    fn input(algorithm: InversionAlgorithm, smv: bool, n_echoes: usize) -> InversionInput {
1375        let mut c = InversionConfig { algorithm, ..Default::default() };
1376        c.medi.smv = smv;
1377        c.input(n_echoes)
1378    }
1379
1380    /// Every variant, spelled out, so a new algorithm cannot be added without deciding its input.
1381    #[test]
1382    fn every_inversion_declares_its_input() {
1383        use InversionAlgorithm as A;
1384        use InversionInput::*;
1385        let expected: &[(A, InversionInput)] = &[
1386            (A::Tkd, LocalField), (A::Tsvd, LocalField), (A::Tikhonov, LocalField),
1387            (A::Tv, LocalField), (A::Rts, LocalField), (A::Nltv, LocalField),
1388            (A::Tfi, TotalField), (A::Ilsqr, LocalField), (A::Star, LocalField), (A::Qsmart, TotalField),
1389            (A::Ndi, LocalField), (A::Fansi, LocalField), (A::FansiTgv, LocalField),
1390            (A::L1qsm, LocalField), (A::Whqsm, LocalField), (A::Hdqsm, LocalField),
1391            (A::AmpPe, LocalField), (A::Lsqr, LocalField),
1392            (A::Xqsm, LocalField), (A::Qsmnet, LocalField), (A::QsmnetPlus, LocalField), (A::Qsmnetme, LocalField),
1393            (A::Autoqsm, TotalField), (A::Qsmgan, LocalField), (A::Ir2qsm, LocalField),
1394            (A::Lpcnn, LocalField), (A::ModlQsm, LocalField), (A::Nextqsm, TotalField),
1395            (A::Iqsm, WrappedPhase), (A::IqsmPlus, WrappedPhase), (A::Medi, LocalField),
1396        ];
1397        // TGV depends on more than the name; checked below.
1398        let special = [A::Tgv];
1399        for v in A::VARIANTS {
1400            if special.contains(v) {
1401                continue;
1402            }
1403            let (_, want) = expected.iter().find(|(a, _)| a == v)
1404                .unwrap_or_else(|| panic!("{v:?} is missing from this test"));
1405            for n_echoes in [1, 4] {
1406                for smv in [false, true] {
1407                    assert_eq!(input(*v, smv, n_echoes), *want, "{v:?}, {n_echoes} echoes, smv {smv}");
1408                }
1409            }
1410        }
1411        assert_eq!(expected.len() + special.len(), A::VARIANTS.len());
1412    }
1413
1414    /// MEDI takes a background removal's local field with every SMV option, as the MEDI
1415    /// toolbox's README.m pipeline and the mSMV paper run it (#287).
1416    #[test]
1417    fn medi_takes_the_local_field_with_every_smv_option() {
1418        for smv in [true, false] {
1419            for shrink in [true, false] {
1420                for msmv in [None, Some(crate::inversion::MsmvParams::default())] {
1421                    let mut c = InversionConfig { algorithm: InversionAlgorithm::Medi, ..Default::default() };
1422                    c.medi.smv = smv;
1423                    c.medi.smv_shrink_mask = shrink;
1424                    c.medi.msmv = msmv;
1425                    for n in [1, 4] {
1426                        assert_eq!(c.input(n), InversionInput::LocalField);
1427                    }
1428                }
1429            }
1430        }
1431    }
1432
1433    /// The MEDI toolbox's pipeline runs PDF before MEDI_L1.
1434    #[test]
1435    fn medi_reference_bg_removal_is_pdf() {
1436        let c = InversionConfig { algorithm: InversionAlgorithm::Medi, ..Default::default() };
1437        assert_eq!(c.reference_bg_removal(), Some(BgRemovalAlgorithm::Pdf));
1438        let c = InversionConfig { algorithm: InversionAlgorithm::Rts, ..Default::default() };
1439        assert_eq!(c.reference_bg_removal(), None);
1440    }
1441
1442    #[test]
1443    fn tgv_takes_the_field_for_multi_echo_and_phase_for_one_echo() {
1444        assert_eq!(input(InversionAlgorithm::Tgv, true, 1), InversionInput::WrappedPhase);
1445        for n in [2, 4] {
1446            assert_eq!(input(InversionAlgorithm::Tgv, true, n), InversionInput::TotalField);
1447        }
1448    }
1449
1450    #[test]
1451    fn routing_predicates() {
1452        assert!(InversionInput::LocalField.needs_bg_removal());
1453        assert!(!InversionInput::TotalField.needs_bg_removal());
1454        assert!(!InversionInput::WrappedPhase.needs_bg_removal());
1455        assert!(InversionInput::LocalField.needs_field_mapping());
1456        assert!(InversionInput::TotalField.needs_field_mapping());
1457        assert!(!InversionInput::WrappedPhase.needs_field_mapping());
1458    }
1459}
1460
1461#[cfg(test)]
1462mod tests {
1463    use super::*;
1464    use crate::models::{all_models, ModelStage};
1465
1466    /// Registry↔pipeline drift guard: every deep-learning model whose `stage` maps to a
1467    /// pipeline stage enum must be claimed by some enum variant's `dl_model_id()`. A model
1468    /// added to the registry but not wired into an enum (so no pipeline consumer could
1469    /// select it) fails here. `PhaseToField` (iQFM) is intentionally exempt — it's exposed
1470    /// as the standalone `run_iqfm` field-preparation building block, not a stage enum.
1471    #[test]
1472    fn registry_models_are_pipeline_wired() {
1473        let inv: Vec<&str> = InversionAlgorithm::VARIANTS.iter().filter_map(|v| v.dl_model_id()).collect();
1474        let bfr: Vec<&str> = BgRemovalAlgorithm::VARIANTS.iter().filter_map(|v| v.dl_model_id()).collect();
1475        let sep: Vec<&str> = SeparationAlgorithm::VARIANTS.iter().filter_map(|v| v.dl_model_id()).collect();
1476        for m in all_models() {
1477            let (wired, enum_name) = match m.stage {
1478                ModelStage::BackgroundRemoval => (bfr.contains(&m.id), "BgRemovalAlgorithm"),
1479                ModelStage::DipoleInversion | ModelStage::SingleStep => (inv.contains(&m.id), "InversionAlgorithm"),
1480                ModelStage::ChiSeparation => (sep.contains(&m.id), "SeparationAlgorithm"),
1481                ModelStage::BrainExtraction => {
1482                    let ops = [
1483                        MaskOp::HdBet(Default::default()),
1484                        MaskOp::Rs2Net(Default::default()),
1485                        MaskOp::MindGrab(Default::default()),
1486                    ];
1487                    (ops.iter().any(|op| op.dl_model_id() == Some(m.id)), "MaskOp")
1488                }
1489                // iQFM: standalone run_iqfm, no stage enum.
1490                ModelStage::PhaseToField => continue,
1491                // SynthSeg: standalone segment::synthseg, an analysis step rather than a
1492                // reconstruction stage. Remove this arm if a segmentation stage is added.
1493                ModelStage::Segmentation => continue,
1494                // R2PRIMEnet: standalone relaxometry::r2primenet, an input-preparation step
1495                // that feeds the R2'-consuming separation methods rather than a stage of its own.
1496                ModelStage::R2PrimeGeneration => continue,
1497            };
1498            assert!(wired, "registry model '{}' (stage {:?}) has no {} variant — wire it or add a dl_model_id mapping", m.id, m.stage, enum_name);
1499        }
1500    }
1501
1502    #[test]
1503    fn test_default_config() {
1504        let config = QsmPipelineConfig::default();
1505        assert_eq!(config.field_mapping.unwrapping_algorithm, UnwrappingAlgorithm::Romeo);
1506        assert_eq!(config.bg_removal.algorithm, BgRemovalAlgorithm::Vsharp);
1507        assert_eq!(config.inversion.algorithm, InversionAlgorithm::Rts);
1508        assert_eq!(config.reference, QsmReference::Mean);
1509        assert!(config.field_mapping.phase_offset_removal);
1510        assert!(!config.field_mapping.bipolar_correction);
1511    }
1512
1513    #[test]
1514    fn test_default_masking_config() {
1515        let config = MaskingConfig::default();
1516        assert_eq!(config.sections.len(), 1);
1517        assert_eq!(config.sections[0].input, MaskingInput::PhaseQuality);
1518        assert_eq!(config.sections[0].refinements.len(), 3);
1519    }
1520
1521    #[test]
1522    fn test_mask_section_all_ops() {
1523        let section = MaskSection {
1524            input: MaskingInput::Magnitude,
1525            generator: MaskOp::Threshold { method: MaskThresholdMethod::Otsu, value: None },
1526            refinements: vec![MaskOp::Erode { iterations: 1 }, MaskOp::Dilate { iterations: 2 }],
1527        };
1528        let ops = section.all_ops();
1529        assert_eq!(ops.len(), 3);
1530        assert!(matches!(ops[0], MaskOp::Threshold { .. }));
1531        assert!(matches!(ops[1], MaskOp::Erode { iterations: 1 }));
1532        assert!(matches!(ops[2], MaskOp::Dilate { iterations: 2 }));
1533    }
1534}
1535
1536#[cfg(test)]
1537mod slice_gap_tests {
1538    use super::*;
1539
1540    // What gates the slice-gap wiring, recorded so the sweep can be reconstructed. Each of
1541    // these was applied and confirmed to fail at least one assertion in this module,
1542    // `slice_gap_coverage`, or the stage tests: the check removed from each of background
1543    // removal, dipole inversion, chi-separation and `run_nextqsm`; the helper swallowing the
1544    // refusal and returning `Ok`; the refusal downgraded to `InvalidConfig`; the tolerance
1545    // widened to half the pitch; a non-positive thickness accepted; `None` refused instead of
1546    // allowed; the `Option`-aware check ignoring the declared geometry; `gap()` sign-flipped;
1547    // `spacing()` reading the wrong axis; `SliceGeometry::contiguous` ignoring its axis; and
1548    // the pinned stage count made stale.
1549    //
1550    // Worth knowing: removing the *dispatcher's* check does not fail the NeXtQSM route test,
1551    // because that route is defended twice. `slice_gap_coverage` is what pins both call sites.
1552    use crate::grid::SliceGeometry;
1553
1554    fn meta(thickness: Option<f64>) -> ScanMetadata {
1555        ScanMetadata {
1556            dims: (8, 8, 4),
1557            voxel_size: (1.0, 1.0, 3.0),
1558            echo_times: vec![0.005],
1559            field_strength: 3.0,
1560            b0_direction: (0.0, 0.0, 1.0),
1561            slice_geometry: thickness.map(|t| SliceGeometry { thickness: t, axis: 2 }),
1562            affine: None,
1563        }
1564    }
1565
1566    #[test]
1567    fn a_declared_gap_is_refused_with_the_numbers_in_the_message() {
1568        let err = meta(Some(2.0)).require_contiguous_slices().unwrap_err();
1569        let PipelineError::InvalidInput(msg) = &err else {
1570            panic!("a gap should be invalid *input*, not a config or algorithm error: {err:?}");
1571        };
1572        // the host has to be able to tell its user what was wrong with the data
1573        assert!(msg.contains("2.0000"), "no thickness in the message: {msg}");
1574        assert!(msg.contains("3.0000"), "no pitch in the message: {msg}");
1575        assert!(msg.contains("1.0000"), "no gap in the message: {msg}");
1576        assert!(msg.contains("not contiguous"), "no explanation in the message: {msg}");
1577    }
1578
1579    #[test]
1580    fn a_declared_contiguous_acquisition_passes() {
1581        assert!(meta(Some(3.0)).require_contiguous_slices().is_ok());
1582    }
1583
1584    #[test]
1585    fn an_undeclared_acquisition_passes_because_nothing_can_be_concluded() {
1586        // Not a silent skip: the thickness is genuinely absent from the input, and refusing
1587        // every acquisition that omitted a recommended BIDS field would block almost all data.
1588        assert!(meta(None).require_contiguous_slices().is_ok());
1589    }
1590
1591    #[test]
1592    fn the_slice_axis_is_honoured() {
1593        let mut m = meta(None);
1594        // 1 mm slices stacked along x on a 1 x 1 x 3 mm grid: contiguous
1595        m.slice_geometry = Some(SliceGeometry { thickness: 1.0, axis: 0 });
1596        assert!(m.require_contiguous_slices().is_ok());
1597        m.slice_geometry = Some(SliceGeometry { thickness: 0.5, axis: 0 });
1598        assert!(m.require_contiguous_slices().is_err());
1599    }
1600}
1601
1602/// Every pipeline stage that convolves on the voxel grid has to refuse a gapped volume, and a
1603/// stage added later must not be able to skip that by omission. These tests read the stage
1604/// sources and require each `pub fn` taking a `&ScanMetadata` to either call
1605/// [`ScanMetadata::require_contiguous_slices`] or appear on an allow-list with a reason —
1606/// the same spirit as the orientation tests above, which enumerate every algorithm variant so a
1607/// new one cannot be added without declaring what it supports.
1608#[cfg(test)]
1609mod slice_gap_coverage {
1610    /// Stages that legitimately do not check, and why.
1611    const EXEMPT: &[(&str, &str)] = &[
1612        // Unwrapping is degraded by a gap, not invalidated by it, and the remedy is
1613        // unwrap::unwrap_slicewise rather than refusal. Refusing here would block the one path
1614        // that handles the case.
1615        ("run_field_mapping", "unwrapping: the remedy is slice-wise, not refusal"),
1616        ("run_field_mapping_per_echo", "unwrapping, as run_field_mapping"),
1617        // Masking is thresholding and morphology on the voxel grid. It reads no kernel and
1618        // produces no field, so a gap changes nothing about whether its output is meaningful —
1619        // and the stages that consume the mask refuse the gap themselves.
1620        ("run_masking", "no kernel, no field: a gap is irrelevant"),
1621        ("build_mask_section", "no kernel, no field: a gap is irrelevant"),
1622        ("resolve_masking_input", "no kernel, no field: a gap is irrelevant"),
1623        ("section_magnitude", "no kernel, no field: a gap is irrelevant"),
1624        ("apply_mask_ops", "no kernel, no field: a gap is irrelevant"),
1625        // Registration and resampling are interpolation on the sampled grid, not a convolution
1626        // with a kernel that assumes one. A gap makes the through-plane interpolation cross
1627        // tissue that was never measured, which costs accuracy bounded by how smooth the field
1628        // is — it does not turn the operation into a different operation. The output stays on
1629        // the reference echo's grid, so `slice_geometry` still describes it, and the stages that
1630        // genuinely cannot cope refuse before any kernel runs.
1631        ("run_motion_correction", "interpolation, not a kernel: a gap degrades rather than invalidates"),
1632    ];
1633
1634    const SOURCES: &[(&str, &str)] = &[
1635        ("bg_removal.rs", include_str!("bg_removal.rs")),
1636        ("inversion.rs", include_str!("inversion.rs")),
1637        ("separation.rs", include_str!("separation.rs")),
1638        ("qsmart.rs", include_str!("qsmart.rs")),
1639        ("field_mapping.rs", include_str!("field_mapping.rs")),
1640        ("masking.rs", include_str!("masking.rs")),
1641        ("motion.rs", include_str!("motion.rs")),
1642        ("two_pass.rs", include_str!("two_pass.rs")),
1643        ("referencing.rs", include_str!("referencing.rs")),
1644        ("phase_utils.rs", include_str!("phase_utils.rs")),
1645    ];
1646
1647    /// Every `pub fn` in a stage source, with its signature and body, excluding test modules.
1648    fn public_stage_fns(src: &str) -> Vec<(String, String)> {
1649        // stop at the test module so test helpers taking metadata are not scanned
1650        let code = match src.find("\nmod tests {") {
1651            Some(i) => &src[..i],
1652            None => src,
1653        };
1654        let mut out = Vec::new();
1655        let mut rest = code;
1656        while let Some(i) = rest.find("\npub fn ") {
1657            let after = &rest[i + "\npub fn ".len()..];
1658            let name: String = after.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
1659            // the body runs to the next top-level `pub fn`, which is enough to see the call
1660            let body_end = after.find("\npub fn ").unwrap_or(after.len());
1661            out.push((name, after[..body_end].to_string()));
1662            rest = &rest[i + 1..];
1663        }
1664        out
1665    }
1666
1667    #[test]
1668    fn every_stage_taking_scan_metadata_refuses_a_gapped_volume() {
1669        let mut checked = 0usize;
1670        for (file, src) in SOURCES {
1671            for (name, body) in public_stage_fns(src) {
1672                // The cfg(not(feature = "onnx")) stubs name it `_metadata` and return an error
1673                // without touching the data, so they have nothing to check. Match on the
1674                // leading delimiter: "_metadata: &ScanMetadata" contains the unprefixed string.
1675                let takes_metadata = body.contains(" metadata: &ScanMetadata")
1676                    || body.contains("(metadata: &ScanMetadata");
1677                let returns_result = body.contains("PipelineError");
1678                if !takes_metadata || !returns_result {
1679                    continue;
1680                }
1681                if let Some((_, why)) = EXEMPT.iter().find(|(n, _)| *n == name) {
1682                    assert!(
1683                        !body.contains("require_contiguous_slices()"),
1684                        "{file}: {name} is on the slice-gap exemption list ({why}) but calls \
1685                         require_contiguous_slices anyway - remove it from EXEMPT"
1686                    );
1687                    continue;
1688                }
1689                assert!(
1690                    body.contains("metadata.require_contiguous_slices()?"),
1691                    "{file}: {name} takes a &ScanMetadata and returns a PipelineError but never \
1692                     calls metadata.require_contiguous_slices(). Either call it, or add it to \
1693                     EXEMPT with a reason a gap cannot invalidate it."
1694                );
1695                checked += 1;
1696            }
1697        }
1698        // if the scan stops finding stages the test has quietly stopped testing anything
1699        // Background removal, dipole inversion, HEIDI's cone fill, the four single-step DL
1700        // reconstructions (NeXtQSM, iQSM, iQSM+, iQFM), TGV, chi-separation and QSMART: ten stages
1701        // convolve on the grid and must refuse a gap. If this count drops, the source scan has
1702        // stopped seeing stages rather than the stages having stopped needing the check.
1703        assert_eq!(
1704            checked, 10,
1705            "expected 10 checking stages, scanned {checked}; either a stage was added without \
1706             the check and without an exemption, or the source scan has broken"
1707        );
1708    }
1709
1710    #[test]
1711    fn the_exemption_list_names_stages_that_exist() {
1712        for (name, _) in EXEMPT {
1713            assert!(
1714                SOURCES.iter().any(|(_, src)| src.contains(&format!("pub fn {name}("))),
1715                "EXEMPT names {name}, which no longer exists - stale exemption"
1716            );
1717        }
1718    }
1719}