qsm_core/pipeline/per_echo.rs
1//! Per-echo QSM: a susceptibility map reconstructed from each echo on its own.
2//!
3//! The usual pipeline combines the echoes into one field ([`super::run_field_mapping`]) and
4//! inverts that. A per-echo reconstruction ([`QsmPipelineConfig::qsm_per_echo`](super::config::QsmPipelineConfig::qsm_per_echo)) instead runs
5//! every stage once per echo:
6//!
7//! 1. **Field**, [`run_field_mapping_per_echo`]: echo `e`'s phase unwrapped and divided by its own
8//! echo time. The echo combination, phase-offset removal and bipolar correction combine or
9//! compare echoes, so they do not run.
10//! 2. **Background removal, inversion and referencing**: the host calls
11//! [`super::run_bg_removal`], [`super::run_dipole_inversion`] and [`super::apply_reference`]
12//! on that echo's field with [`ScanMetadata::echo`](super::config::ScanMetadata::echo)`(e)`, so a method that converts at an
13//! echo time (STAR-QSM's `te`, MEDI's, …) uses that echo's.
14//! 3. **Outputs**: the per-echo χ maps, and their voxel-wise mean across echoes
15//! ([`mean_across_echoes`]) as the single QSM. [`voxel_major`] lays the per-echo maps out as
16//! [`SeparationInputs::qsm_per_echo`](super::SeparationInputs::qsm_per_echo) takes them.
17//!
18//! # Why, and the reference
19//!
20//! DECOMPOSE's signal model synthesises each echo's phase from that echo's χ(t): "Note that QSM
21//! of each echo, χ(t), is used instead of the average QSM across all echoes" (Chen et al. 2021,
22//! NeuroImage 242:118477, §2.2), and "it is necessary to use TE-dependent QSM as the input"
23//! (§4.1). Their pipeline (§3.6, and `DECOMPOSE_analysis.m`) is the one above: Laplacian
24//! unwrapping of each echo's raw phase, V-SHARP, and STAR-QSM "to compute the susceptibility
25//! maps for each echo". The pipeline rule `decompose.qsm-per-echo` recommends this mode when
26//! DECOMPOSE is chosen.
27//!
28//! # The mean across echoes
29//!
30//! [`mean_across_echoes`] is the plain voxel-wise arithmetic mean with equal weights: the
31//! "average QSM across all echoes" Chen et al. contrast χ(t) with (§2.2), which names no
32//! weighting. Referencing commutes with it: the mean of maps each referenced to their mean over
33//! one mask is referenced to its mean over that mask.
34
35pub use super::field_mapping::run_field_mapping_per_echo;
36
37/// The voxel-wise mean of `maps` (one map per echo, each `n_voxels` long), with equal weights.
38/// See the [module docs](self#the-mean-across-echoes). Empty when `maps` is; panics if the maps'
39/// lengths differ.
40pub fn mean_across_echoes<M: AsRef<[f64]>>(maps: &[M]) -> Vec<f64> {
41 let Some(first) = maps.first() else { return Vec::new() };
42 let n = first.as_ref().len();
43 let mut out = vec![0.0; n];
44 for m in maps {
45 let m = m.as_ref();
46 assert_eq!(m.len(), n, "per-echo maps differ in length");
47 for (o, &v) in out.iter_mut().zip(m) {
48 *o += v;
49 }
50 }
51 let k = maps.len() as f64;
52 out.iter_mut().for_each(|o| *o /= k);
53 out
54}
55
56/// `maps` (one per echo, each `n_voxels` long) interleaved voxel-major, `(n_voxels, n_echoes)`
57/// with the echo fastest, as [`SeparationInputs::qsm_per_echo`](super::SeparationInputs::qsm_per_echo)
58/// and the multi-echo magnitude take them. Panics if the maps' lengths differ.
59pub fn voxel_major<M: AsRef<[f64]>>(maps: &[M]) -> Vec<f64> {
60 let ne = maps.len();
61 let n = maps.first().map_or(0, |m| m.as_ref().len());
62 let mut out = vec![0.0; n * ne];
63 for (e, m) in maps.iter().enumerate() {
64 let m = m.as_ref();
65 assert_eq!(m.len(), n, "per-echo maps differ in length");
66 for (v, &x) in m.iter().enumerate() {
67 out[v * ne + e] = x;
68 }
69 }
70 out
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use crate::pipeline::config::ScanMetadata;
77 use crate::pipeline::config::{FieldMappingConfig, UnwrappingAlgorithm};
78 use crate::pipeline::run_field_mapping;
79
80 #[test]
81 fn mean_and_layout() {
82 let maps = [vec![1.0, 2.0, 3.0], vec![3.0, 4.0, 5.0]];
83 assert_eq!(mean_across_echoes(&maps), vec![2.0, 3.0, 4.0]);
84 assert_eq!(voxel_major(&maps), vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0]);
85 assert!(mean_across_echoes::<Vec<f64>>(&[]).is_empty());
86 }
87
88 #[test]
89 fn echo_metadata_keeps_one_echo() {
90 let m = ScanMetadata {
91 dims: (2, 2, 2), voxel_size: (1.0, 1.0, 1.0), echo_times: vec![0.004, 0.008, 0.012],
92 field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0), slice_geometry: None, affine: None,
93 };
94 let e = m.echo(1);
95 assert_eq!(e.echo_times, vec![0.008]);
96 assert_eq!((e.dims, e.field_strength), (m.dims, m.field_strength));
97 }
98
99 /// Each echo's field is that echo run alone through [`run_field_mapping`], whatever the echo
100 /// combination is set to (here a robust fit, which one echo could not run).
101 #[test]
102 fn each_echo_is_a_single_echo_field() {
103 let (nx, ny, nz) = (8, 8, 8);
104 let n = nx * ny * nz;
105 let tes = [0.005, 0.010, 0.015];
106 let metadata = ScanMetadata {
107 dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0), echo_times: tes.to_vec(),
108 field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0), slice_geometry: None, affine: None,
109 };
110 let freq: Vec<f64> = (0..n).map(|i| 20.0 * ((i % nx) as f64 - 3.5)).collect(); // rad/s
111 let phases: Vec<Vec<f64>> = tes.iter().map(|&t| freq.iter().map(|f| f * t).collect()).collect();
112 let refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
113 let mask = vec![1u8; n];
114 for alg in [UnwrappingAlgorithm::Romeo, UnwrappingAlgorithm::Laplacian, UnwrappingAlgorithm::Prelude] {
115 let single = FieldMappingConfig { unwrapping_algorithm: alg, ..Default::default() };
116 let config = FieldMappingConfig {
117 b0_estimation: crate::pipeline::config::B0EstimationMethod::RobustFit(Default::default()),
118 phase_offset_removal: true,
119 ..single.clone()
120 };
121 let per_echo = run_field_mapping_per_echo(&refs, None, &mask, &metadata, &config, &mut |_, _| {}).unwrap();
122 assert_eq!(per_echo.len(), tes.len());
123 for (e, r) in per_echo.iter().enumerate() {
124 let alone = run_field_mapping(&refs[e..=e], None, &mask, &metadata.echo(e), &single, &mut |_, _| {}).unwrap();
125 assert_eq!(r.b0_field_ppm, alone.b0_field_ppm, "{alg:?} echo {e}");
126 }
127 }
128 }
129}