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}