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}