Skip to main content

qsm_core/pipeline/
qsmart.rs

1//! QSMART two-stage reconstruction
2//!
3//! Two-stage SDF + iLSQR pipeline with vasculature detection, after Yaghmaie et al. (2021)
4//! and their MATLAB toolbox (<https://github.com/wtsyeda/QSMART>), which inverts each stage
5//! with STI Suite's `QSM_iLSQR`.
6//! Stage 1: whole-ROI reconstruction. Stage 2: tissue-only reconstruction
7//! with vasculature excluded. Final offset adjustment combines both.
8//!
9//! # Reference pipeline
10//!
11//! What the authors' toolbox (`QSMART_toolbox_v1.0`, wtsyeda/QSMART; "toolbox" below) and paper
12//! (Yaghmaie et al. 2021, NeuroImage 231:117701) run before and around the two stages:
13//!
14//! - **Brain mask: FSL BET**, not HD-BET. Paper §3.1: "a brain mask … is constructed using FSL's
15//!   Brain Extraction Tool". Toolbox `brainmask.m` l. 7–33: root-sum-of-squares over coils of
16//!   the **first echo** magnitude, ANTs `N4BiasFieldCorrection` (defaults), `bet2 … -f 0.2 -m`;
17//!   then voxels whose BET-extracted magnitude is below `mag_threshold` are removed (100 in
18//!   `Demo_QSMART.m`; the authors' October 2020 human demo uses the adaptive 2nd percentile),
19//!   `bwmorph3(mask, 'majority')`, and `imclose` with `strel('sphere', 2)` (`sph_radius1`,
20//!   voxels). No erosion: avoiding it is the point of SDF (paper §1, §5). Rodent masks were
21//!   made by non-linear registration to the Waxholm atlases plus manual correction (paper §3.4).
22//! - **Coil combination**: POEM (`coil_comb.m`, `'smooth3'`), before anything QSM.rs takes.
23//! - **Unwrapping**: Laplacian (`Demo_QSMART.m` l. 32), Hongfu Sun's `lapunwrap.m` per echo on
24//!   the unmasked phase (`unwrap_phase.m` l. 5–10):
25//!   [`LaplacianSolver::Mirror`](crate::unwrap::LaplacianSolver::Mirror) with
26//!   [`FieldMappingConfig::laplacian_unmasked`]. These are the field mapping's own settings, not
27//!   QSMART's: the global default stays ROMEO, and the rules below suggest QSMART's choices.
28//! - **Total field**: `echofit.m`, magnitude-weighted zero-intercept fit of phase against TE,
29//!   divided by `γ·B0/1e6` with γ = 2.675e8 rad/s/T and B0 = `qsm_params.field` (7 in the demo);
30//!   the reliability map `R_0` from the box-blurred residual (`fit_threshold` 40 in
31//!   `Demo_QSMART.m` with `adaptive_threshold` 0; the 99th percentile in the October 2020 demo
32//!   and the authors' 7T run): [`B0EstimationMethod::QsmartEchofit`].
33//! - **Vessel magnitude**: mean over echoes of the coil-combined magnitude, then N4
34//!   (`vasculature_mask.m` l. 8–14): [`qsmart_vessel_magnitude`](crate::utils::qsmart_vessel_magnitude).
35//! - **Inversion**: STI Suite **v2.2**'s `QSM_iLSQR` (paper §3.4, "number of iterations = 50"),
36//!   called on `mask·R_0` (stage 1) and `mask·vessels·R_0` (stage 2); see the comment in
37//!   [`run_qsmart`] for the call.
38//! - **Data and field strength**: tuned on 7T Siemens human 3D unipolar multi-echo GRE, 0.6 mm
39//!   isotropic, TE 4.80/8.35/11.90/15.45 ms (paper §3.3; README: "parameter settings have been
40//!   optimized for multiecho gradient echo data from Siemens 7T"). Also run on 9.4T ex vivo
41//!   mouse (125 µm) and 4.7T rat (150 µm) with other settings: Frangi scales 0.01–0.5 in steps
42//!   of 0.1, c = 50, σ_C = σ_V = 5 (paper §3.4). No 3T settings are published. The radii of
43//!   the morphology (mask closing 2, bottom-hat 8, indent closing 8) are in voxels.
44//! - **SDF widths, code vs paper**: the paper gives σ_C = 10, σ_V = 2, σ_I = 10, σ = 10, C_s = 500
45//!   (§3.4). The toolbox passes `s1.sdf_sigma1` (10) for both widths in stage 1, so stage 1's σ
46//!   is √(10² + 10²) ≈ 14.1, and stage 2 uses σ1 = 8, σ2 = 2 (σ = √68 ≈ 8.2)
47//!   (`QSMART_SDF.m` l. 12–13, 20–21; `sdf_curvature.m` l. 8). QSM.rs follows the toolbox.
48//!
49//! # Agreement with the original
50//!
51//! `tests/qsmart_qsmci_parity.rs` (measured 2026-11-02) runs each stage on the inputs the verified
52//! QSM-CI `qsmart-matlab` v4 container (QSMxT/QSM-CI#296, #355, bit-identical to licensed MATLAB)
53//! gave its own, on a 36×36×28 vessel phantom at 3 T; the fixture is regenerated in Actions by
54//! `tests/data/qsmart_qsmci_small.py`. Against the toolbox's intermediates: the vessel mask is
55//! identical; the total field (Laplacian unwrapping and the echo fit) differs by a constant 4.3e-3
56//! ppm, the Laplacian's free constant, and by NRMSE 0.024 % without it; SDF stage 1 by 3.8e-5 %
57//! and stage 2 by 1.6e-5 % (float32 rounding of the fixture; the 10.9 % / 2.1 % before
58//! astewartau/QSM.rs#370 were the curvature step, #320); iLSQR stage 1 by 1.0e-4 % and stage 2 by
59//! 1.0e-3 %, STI's single-precision floor (0.15 % / 0.094 % before astewartau/QSM.rs#396, the
60//! dipole kernel's rounding on the magic angle; see [`ilsqr`](fn@crate::inversion::ilsqr)); and
61//! the offset adjustment is identical (2e-7 %, float32 rounding of the fixture). End to end from
62//! the container's total field and `R_0`, [`run_qsmart`] is **1.0e-3 %** from the toolbox's χ
63//! (14.7 % before #370, 0.094 % before #396). These iLSQR numbers apply the original's γ
64//! mismatch: QSMART turns ppm into rad/s with its γ = 2.675e8 rad/s/T, and STI's `QSM_iLSQR`
65//! turns rad/s back with 2π·42.575 MHz/T, so the original's χ is 2.2e-5 (relative) below the χ
66//! its field implies. QSM.rs uses one γ throughout and does not reproduce that (2.5e-3 %
67//! without it). On QSM-CI's full-size data, the
68//! `qsmart` parity pair (QSMxT/QSM-CI#355, total field and `R_0` fed, `qsmart-reference` offset
69//! grid on the odd-sized sim) is 0.359 % on the sim and 0.779 % on the KKI travelling head.
70//!
71//! # Pipeline rules
72//!
73//! The facts above that a configuration can depart from are published as rules in
74//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `qsmart.mask-bet`, `qsmart.unwrap-laplacian`, `qsmart.laplacian-solver`, `qsmart.unwrap-unmasked`, `qsmart.field-echofit`, `qsmart.inner-inversion`, `qsmart.ilsqr-iterations`, `qsmart.ilsqr-pad`.
75
76use super::config::*;
77use super::observer::{Observer, VolumeRef};
78
79/// Names of the intermediates [`run_qsmart`] reports through [`Observer::output`], in the order
80/// they are produced. Each is reported once per run.
81pub mod outputs {
82    /// Vessels found by vasculature detection ([`VolumeRef::Mask`](super::VolumeRef::Mask),
83    /// 1 = vessel, always inside the brain mask). Stage 2 runs on the brain mask minus these.
84    pub const VESSEL_MASK: &str = "vessel_mask";
85    /// Stage-1 local field: SDF over the whole brain mask, vessels included (ppm).
86    pub const LOCAL_FIELD_STAGE1: &str = "local_field_stage1";
87    /// Stage-1 susceptibility: inner inversion of the stage-1 local field over the brain mask
88    /// (ppm, unreferenced).
89    pub const CHI_STAGE1: &str = "chi_stage1";
90    /// Stage-2 local field: vessel-aware SDF on the tissue-only field (ppm).
91    pub const LOCAL_FIELD_STAGE2: &str = "local_field_stage2";
92    /// Stage-2 susceptibility: inner inversion of the stage-2 local field over tissue only
93    /// (ppm, unreferenced, zero in vessels).
94    pub const CHI_STAGE2: &str = "chi_stage2";
95}
96
97/// Run QSMART two-stage QSM reconstruction.
98///
99/// # Arguments
100/// * `field_ppm` - Total field in ppm (after field mapping)
101/// * `mask` - Binary brain mask
102/// * `reliability` - QSMART's reliability map `R_0` (1 = reliable), from its echo fit
103///   ([`qsmart_echofit`](crate::utils::qsmart_echofit)). Both stages run on `mask` × `R_0`, as
104///   `QSMART.m` does; vasculature detection uses `mask` alone. `None` means every voxel is
105///   reliable.
106/// * `magnitude` - Combined magnitude (for vasculature detection and, when the inner
107///   inversion is MEDI, edge weighting; uniform if None)
108/// * `metadata` - Scan metadata
109/// * `inversion_config` - Inversion configuration. QSMART-specific settings (SDF,
110///   vasculature, iLSQR iteration cap and padding) come from `inversion_config.qsmart`; the
111///   inner dipole inversion algorithm is selected by `inversion_config.qsmart.inversion` and
112///   tuned via the matching per-algorithm field on this config. With the default iLSQR, the
113///   inversion is STI Suite's [`ilsqr`](fn@crate::inversion::ilsqr) with QSMART's arguments
114///   (`qsmart.ilsqr_max_iter`, `qsmart.ilsqr_pad_mm`; the artefact tolerance comes from
115///   `inversion_config.ilsqr`).
116/// * `reference` - QSM referencing method
117/// * `observer` - Receives progress (step of 6) and the intermediates named in [`outputs`] as
118///   they are produced, as borrowed slices. A plain `FnMut(usize, usize)` progress closure works
119///   here too and ignores the intermediates.
120///
121/// # Returns
122/// Susceptibility map in ppm (referenced): the stage-1 and stage-2 χ combined by offset
123/// adjustment, zero outside the brain mask.
124pub fn run_qsmart(
125    field_ppm: &[f64],
126    mask: &[u8],
127    reliability: Option<&[u8]>,
128    magnitude: Option<&[f64]>,
129    metadata: &ScanMetadata,
130    inversion_config: &InversionConfig,
131    reference: QsmReference,
132    observer: &mut dyn Observer,
133) -> Result<Vec<f64>, PipelineError> {
134    // Refused up front rather than part-way through the two inversions below.
135    metadata.require_contiguous_slices()?;
136    let (nx, ny, nz) = metadata.dims;
137    let bdir = metadata.b0_direction;
138    let n_voxels = nx * ny * nz;
139    let grid = metadata.grid();
140    let qsmart_params = &inversion_config.qsmart;
141
142    // QSMART runs a standard dipole inversion per stage; the two pipeline-level
143    // algorithms can't be nested here.
144    if matches!(
145        qsmart_params.inversion,
146        InversionAlgorithm::Qsmart | InversionAlgorithm::Tgv
147    ) {
148        return Err(PipelineError::InvalidConfig(
149            "QSMART inversion cannot be Qsmart or Tgv".into(),
150        ));
151    }
152
153    // Inner inversion config: same per-algorithm params as the caller, with the
154    // QSMART-selected algorithm.
155    let mut inner_config = inversion_config.clone();
156    inner_config.algorithm = qsmart_params.inversion;
157    // QSMART's two stages are its own recipe: HEIDI's cone fill is not part of it.
158    inner_config.apply_heidi = false;
159    // The original QSMART (QSMART_toolbox_v1.0/QSMART.m) calls STI Suite's QSM_iLSQR for both
160    // stages as `QSM_iLSQR(lfs, mask, 'H', z_prjs, 'voxelsize', res, 'niter', 50, 'TE', 1000,
161    // 'B0', B0)`: no `'padsize'` (STI's default, 6 mm) and both LSQR solves capped at 50
162    // iterations. TE/B0 only convert units (the field here is already in ppm).
163    let ilsqr_params = crate::inversion::IlsqrParams {
164        max_iter: qsmart_params.ilsqr_max_iter,
165        pad_mm: qsmart_params.ilsqr_pad_mm,
166        ..inversion_config.ilsqr.clone()
167    };
168    if matches!(inner_config.algorithm, InversionAlgorithm::Ilsqr) {
169        ilsqr_params.validate().map_err(|e| PipelineError::InvalidConfig(format!("QSMART: {e}")))?;
170    }
171    let invert = |lfs: &[f64], m: &[u8]| -> Result<Vec<f64>, PipelineError> {
172        if matches!(inner_config.algorithm, InversionAlgorithm::Ilsqr) {
173            let (chi, _, _, _) = crate::inversion::ilsqr(lfs, m, &grid, bdir, &ilsqr_params, |_, _| {});
174            Ok(chi)
175        } else {
176            super::inversion::run_dipole_inversion(lfs, m, metadata, &inner_config, magnitude, &mut |_, _| {})
177        }
178    };
179
180    // Step 1: Vasculature detection (vasc_mask: 1 = tissue, 0 = vessel)
181    observer.progress(1, 6);
182    let uniform_mag = vec![1.0f64; n_voxels];
183    let mag = magnitude.unwrap_or(&uniform_mag);
184    let vasc_params = crate::utils::VasculatureParams {
185        sphere_radius: qsmart_params.vasc_sphere_radius,
186        frangi_scale_range: qsmart_params.frangi_scale_range,
187        frangi_scale_ratio: qsmart_params.frangi_scale_ratio,
188        frangi_c: qsmart_params.frangi_c,
189    };
190    let vasc_mask = crate::utils::generate_vasculature_mask(
191        mag, mask, &grid, &vasc_params, |_, _| {},
192    )
193    .map_err(|e| PipelineError::InvalidConfig(format!("QSMART: {e}")))?;
194    {
195        let vessel_mask: Vec<u8> = vasc_mask.iter().map(|&v| u8::from(v < 0.5)).collect();
196        observer.output(outputs::VESSEL_MASK, VolumeRef::Mask(&vessel_mask));
197    }
198
199    // QSMART.m's `mask.*R_0`: the region both stages reconstruct.
200    if reliability.is_some_and(|r| r.len() != n_voxels) {
201        return Err(PipelineError::InvalidConfig("QSMART: reliability map does not match the grid".into()));
202    }
203    let weighted_mask: Vec<f64> = (0..n_voxels)
204        .map(|i| if mask[i] != 0 && reliability.is_none_or(|r| r[i] != 0) { 1.0 } else { 0.0 })
205        .collect();
206    let ones_vasc = vec![1.0f64; n_voxels];
207
208    // Step 2: SDF stage 1 — background removal over the whole ROI (vessels included).
209    observer.progress(2, 6);
210    let sdf_params1 = crate::bgremove::SdfParams {
211        sigma1: qsmart_params.sdf_sigma1_stage1,
212        sigma2: qsmart_params.sdf_sigma2_stage1,
213        lower_lim: qsmart_params.sdf_lower_lim,
214        curv_constant: qsmart_params.sdf_curv_constant,
215        use_curvature: true,
216        vessel_proximity: false,
217    };
218    let lfs1 = crate::bgremove::sdf::sdf(
219        field_ppm, &weighted_mask, &ones_vasc, &grid, &sdf_params1, |_, _| {},
220    );
221    observer.output(outputs::LOCAL_FIELD_STAGE1, VolumeRef::Real(&lfs1));
222
223    // Step 3: dipole inversion stage 1 over the whole ROI.
224    observer.progress(3, 6);
225    let mask_stage1: Vec<u8> = weighted_mask.iter().map(|&v| if v > 0.1 { 1 } else { 0 }).collect();
226    let chi1 = invert(&lfs1, &mask_stage1)?;
227    observer.output(outputs::CHI_STAGE1, VolumeRef::Real(&chi1));
228
229    // Step 4: SDF stage 2 — tissue-only, vessel-aware (mask-zeroed field input).
230    observer.progress(4, 6);
231    let sdf_params2 = crate::bgremove::SdfParams {
232        sigma1: qsmart_params.sdf_sigma1_stage2,
233        sigma2: qsmart_params.sdf_sigma2_stage2,
234        lower_lim: qsmart_params.sdf_lower_lim,
235        curv_constant: qsmart_params.sdf_curv_constant,
236        use_curvature: true,
237        vessel_proximity: true,
238    };
239    let field_weighted: Vec<f64> = field_ppm.iter()
240        .zip(weighted_mask.iter())
241        .map(|(&f, &m)| f * m)
242        .collect();
243    let lfs2 = crate::bgremove::sdf::sdf(
244        &field_weighted, &weighted_mask, &vasc_mask, &grid, &sdf_params2, |_, _| {},
245    );
246    observer.output(outputs::LOCAL_FIELD_STAGE2, VolumeRef::Real(&lfs2));
247
248    // Step 5: dipole inversion stage 2 over tissue only (in-mask AND not vessel).
249    observer.progress(5, 6);
250    let mask_stage2: Vec<u8> = weighted_mask.iter()
251        .zip(vasc_mask.iter())
252        .map(|(&m, &v)| if m > 0.1 && v > 0.5 { 1 } else { 0 })
253        .collect();
254    let chi2 = invert(&lfs2, &mask_stage2)?;
255    observer.output(outputs::CHI_STAGE2, VolumeRef::Real(&chi2));
256
257    // Step 6: offset adjustment (combine stages) and reference.
258    // removed_voxels = vessel regions (in mask but excluded from stage 2).
259    // Everything is in ppm here, so adjust_offset's lfs rescale is the identity (ppm = 1.0).
260    observer.progress(6, 6);
261    let removed_voxels: Vec<f64> = weighted_mask.iter()
262        .zip(vasc_mask.iter())
263        .map(|(&m, &v)| m - v)
264        .collect();
265    let mut chi = crate::utils::adjust_offset(
266        &removed_voxels, &lfs1, &chi1, &chi2, &grid, bdir, 1.0, qsmart_params.adjust_offset_grid,
267    );
268    // QSMART susceptibility is only defined inside the brain mask.
269    for (c, &m) in chi.iter_mut().zip(mask.iter()) {
270        if m == 0 {
271            *c = 0.0;
272        }
273    }
274
275    Ok(super::referencing::apply_reference(&chi, mask, reference))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_run_qsmart_basic() {
284        let (nx, ny, nz) = (8, 8, 8);
285        let n = nx * ny * nz;
286        let field = vec![0.01; n];
287        let mask = vec![1u8; n];
288        let meta = ScanMetadata {
289            dims: (nx, ny, nz),
290            voxel_size: (1.0, 1.0, 1.0),
291            echo_times: vec![0.005],
292            field_strength: 3.0,
293            b0_direction: (0.0, 0.0, 1.0),
294            slice_geometry: None,
295            affine: None,
296        };
297        let config = InversionConfig {
298            algorithm: InversionAlgorithm::Qsmart,
299            qsmart: crate::utils::QsmartParams::for_field_strength(3.0),
300            ..Default::default()
301        };
302
303        let result = run_qsmart(
304            &field, &mask, None, None, &meta, &config, QsmReference::Mean,
305            &mut (),
306        );
307        assert!(result.is_ok());
308        let chi = result.unwrap();
309        assert_eq!(chi.len(), n);
310        for &v in &chi {
311            assert!(v.is_finite(), "QSMART output must be finite");
312        }
313    }
314
315    #[test]
316    fn test_run_qsmart_swappable_inversion() {
317        // QSMART with a non-default inner inversion (TKD) should run end to end.
318        let (nx, ny, nz) = (8, 8, 8);
319        let n = nx * ny * nz;
320        let field = vec![0.01; n];
321        let mask = vec![1u8; n];
322        let meta = ScanMetadata {
323            dims: (nx, ny, nz),
324            voxel_size: (1.0, 1.0, 1.0),
325            echo_times: vec![0.005],
326            field_strength: 3.0,
327            b0_direction: (0.0, 0.0, 1.0),
328            slice_geometry: None,
329            affine: None,
330        };
331        let mut qsmart = crate::utils::QsmartParams::for_field_strength(3.0);
332        qsmart.inversion = InversionAlgorithm::Tkd;
333        let config = InversionConfig {
334            algorithm: InversionAlgorithm::Qsmart,
335            qsmart,
336            ..Default::default()
337        };
338
339        let chi = run_qsmart(
340            &field, &mask, None, None, &meta, &config, QsmReference::Mean,
341            &mut (),
342        )
343        .unwrap();
344        assert_eq!(chi.len(), n);
345        for &v in &chi {
346            assert!(v.is_finite(), "QSMART output must be finite");
347        }
348    }
349
350    #[test]
351    fn test_run_qsmart_reports_intermediates() {
352        #[derive(Default)]
353        struct Recorder {
354            steps: Vec<(usize, usize)>,
355            outputs: Vec<(&'static str, Vec<f64>)>,
356        }
357        impl Observer for Recorder {
358            fn progress(&mut self, done: usize, total: usize) {
359                self.steps.push((done, total));
360            }
361            fn output(&mut self, name: &'static str, volume: VolumeRef<'_>) {
362                let data = match volume {
363                    VolumeRef::Real(v) => v.to_vec(),
364                    VolumeRef::Mask(m) => m.iter().map(|&b| b as f64).collect(),
365                };
366                self.outputs.push((name, data));
367            }
368        }
369
370        let (nx, ny, nz) = (12, 12, 12);
371        let n = nx * ny * nz;
372        let field: Vec<f64> = (0..n).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
373        let magnitude: Vec<f64> = (0..n).map(|i| if i % 11 == 0 { 0.2 } else { 1.0 }).collect();
374        let mask = vec![1u8; n];
375        let meta = ScanMetadata {
376            dims: (nx, ny, nz),
377            voxel_size: (1.0, 1.0, 1.0),
378            echo_times: vec![0.005],
379            field_strength: 3.0,
380            b0_direction: (0.0, 0.0, 1.0),
381            slice_geometry: None,
382            affine: None,
383        };
384        let mut qsmart = crate::utils::QsmartParams::for_field_strength(3.0);
385        qsmart.inversion = InversionAlgorithm::Tkd;
386        let config = InversionConfig { algorithm: InversionAlgorithm::Qsmart, qsmart, ..Default::default() };
387
388        let mut rec = Recorder::default();
389        let chi = run_qsmart(
390            &field, &mask, None, Some(&magnitude), &meta, &config, QsmReference::None, &mut rec,
391        )
392        .unwrap();
393        // Reporting intermediates does not change the result.
394        let plain = run_qsmart(
395            &field, &mask, None, Some(&magnitude), &meta, &config, QsmReference::None, &mut (),
396        )
397        .unwrap();
398        assert_eq!(chi, plain);
399
400        assert_eq!(rec.steps, (1..=6).map(|i| (i, 6)).collect::<Vec<_>>());
401        let names: Vec<_> = rec.outputs.iter().map(|(name, _)| *name).collect();
402        assert_eq!(
403            names,
404            [
405                outputs::VESSEL_MASK,
406                outputs::LOCAL_FIELD_STAGE1,
407                outputs::CHI_STAGE1,
408                outputs::LOCAL_FIELD_STAGE2,
409                outputs::CHI_STAGE2,
410            ]
411        );
412        for (name, data) in &rec.outputs {
413            assert_eq!(data.len(), n, "{name}");
414            assert!(data.iter().all(|v| v.is_finite()), "{name}");
415        }
416        // Stage 2 inverts tissue only, so its χ is zero in vessels.
417        let vessels = &rec.outputs[0].1;
418        let chi2 = &rec.outputs[4].1;
419        for (&v, &c) in vessels.iter().zip(chi2) {
420            assert!(v == 0.0 || v == 1.0);
421            if v == 1.0 {
422                assert_eq!(c, 0.0);
423            }
424        }
425
426        // A progress closure is an observer too.
427        let mut steps = 0;
428        run_qsmart(
429            &field, &mask, None, Some(&magnitude), &meta, &config, QsmReference::None,
430            &mut |_: usize, _: usize| steps += 1,
431        )
432        .unwrap();
433        assert_eq!(steps, 6);
434    }
435
436    #[test]
437    fn unreliable_voxels_are_left_out_of_both_stages() {
438        // R_0 = 0 takes a voxel out of both stages' region (QSMART.m's `mask.*R_0`), as an
439        // unmasked voxel would be, but not out of the vessel search or the brain mask.
440        #[derive(Default)]
441        struct Chis(Vec<(&'static str, Vec<f64>)>);
442        impl Observer for Chis {
443            fn progress(&mut self, _: usize, _: usize) {}
444            fn output(&mut self, name: &'static str, volume: VolumeRef<'_>) {
445                if let VolumeRef::Real(v) = volume { self.0.push((name, v.to_vec())); }
446            }
447        }
448        let (nx, ny, nz) = (12, 12, 12);
449        let n = nx * ny * nz;
450        let field: Vec<f64> = (0..n).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
451        let mask = vec![1u8; n];
452        let reliability: Vec<u8> = (0..n).map(|i| u8::from(i % 5 != 0)).collect();
453        let meta = ScanMetadata {
454            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0), echo_times: vec![0.005],
455            field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0), slice_geometry: None, affine: None,
456        };
457        let mut qsmart = crate::utils::QsmartParams::for_field_strength(3.0);
458        qsmart.inversion = InversionAlgorithm::Tkd;
459        let config = InversionConfig { algorithm: InversionAlgorithm::Qsmart, qsmart, ..Default::default() };
460        let mut rec = Chis::default();
461        run_qsmart(&field, &mask, Some(&reliability), None, &meta, &config, QsmReference::None, &mut rec).unwrap();
462        for (name, v) in &rec.0 {
463            for i in (0..n).filter(|&i| reliability[i] == 0) {
464                assert_eq!(v[i], 0.0, "{name} at unreliable voxel {i}");
465            }
466        }
467        let short = vec![1u8; n - 1];
468        assert!(run_qsmart(&field, &mask, Some(&short), None, &meta, &config, QsmReference::None, &mut ()).is_err());
469    }
470
471    #[test]
472    fn test_run_qsmart_rejects_nested_pipeline_inversion() {
473        let (nx, ny, nz) = (4, 4, 4);
474        let n = nx * ny * nz;
475        let meta = ScanMetadata {
476            dims: (nx, ny, nz),
477            voxel_size: (1.0, 1.0, 1.0),
478            echo_times: vec![0.005],
479            field_strength: 3.0,
480            b0_direction: (0.0, 0.0, 1.0),
481            slice_geometry: None,
482            affine: None,
483        };
484        let mut qsmart = crate::utils::QsmartParams::for_field_strength(3.0);
485        qsmart.inversion = InversionAlgorithm::Qsmart;
486        let config = InversionConfig {
487            algorithm: InversionAlgorithm::Qsmart,
488            qsmart,
489            ..Default::default()
490        };
491
492        let result = run_qsmart(
493            &vec![0.0; n], &vec![1u8; n], None, None, &meta, &config,
494            QsmReference::Mean, &mut (),
495        );
496        assert!(result.is_err());
497    }
498}