Skip to main content

qsm_core/utils/
r2star.rs

1//! R2* and T2* mapping from multi-echo magnitude data
2//!
3//! [`arlo`] is a port of `arlo.m` from the MEDI toolbox (release 2024.11.26, Cornell MRI
4//! Research Lab), the ARLO estimator of Pei et al. (2015) as its authors' group implements it.
5//! T2* maps are the reciprocal of R2* ([`t2star_from_r2star`]).
6//!
7//! # Reference pipeline
8//! MEDI's pipeline script (`README.m:133` of the 2024.11.26 release) runs `arlo(TE, abs(iField))`
9//! on the magnitudes of all echoes, unmasked, and uses the result only for the ventricular CSF
10//! mask (`extract_CSF(R2s, Mask, voxel_size)`, R2* < 5 Hz within 30 mm of the mask centroid,
11//! after a 10 mm SMV erosion; `extract_CSF.m:8-37`) and, in the 2024 release, for the
12//! source-separation initialisation. `arlo.m` returns no map for one echo (`arlo.m:22-26`).
13//! Nothing in it depends on the field strength.
14//!
15//! Reference:
16//! Pei, M., Nguyen, T.D., Thimmappa, N.D., Salustri, C., Dong, F., Cooper, M.A., Li, J.,
17//! Prince, M.R., Wang, Y. (2015). "Algorithm for fast monoexponential fitting based on
18//! Auto-Regression on Linear Operations (ARLO) of data."
19//! Magnetic Resonance in Medicine, 73(2):843-850. https://doi.org/10.1002/mrm.25137
20
21use crate::error::{check_len, InputError};
22
23/// Why [`arlo`] cannot use these echo times, or `None` if it can: it needs at least three
24/// finite, strictly increasing echo times. A host can call this to reject a run before any work
25/// starts.
26pub fn arlo_echo_times_problem(echo_times: &[f64]) -> Option<String> {
27    if echo_times.len() < 3 {
28        Some(format!("ARLO needs at least 3 echoes, got {}", echo_times.len()))
29    } else if echo_times.iter().any(|t| !t.is_finite()) {
30        Some(format!("ARLO needs finite echo times, got {echo_times:?}"))
31    } else if echo_times.windows(2).any(|w| w[1] <= w[0]) {
32        Some(format!("ARLO needs strictly increasing echo times, got {echo_times:?} s"))
33    } else {
34        None
35    }
36}
37
38/// R2* by ARLO: `arlo.m` (MEDI toolbox 2024.11.26), line for line.
39///
40/// Over each consecutive echo triplet `j, j+1, j+2`, `y` is `arlo.m`'s integral of the signal
41/// (Simpson's rule for even spacing, its general form otherwise) and `x = s_j − s_{j+2}`; then
42/// `R2* = (Σ y·x + Σ β·x²) / (Σ β·y·x + Σ y²)`. Echo spacing may be uneven. Every voxel is
43/// fitted, with no upper clip and no fallback, and NaN or infinite results are 0, as in
44/// `arlo.m`.
45///
46/// **Deviations from `arlo.m`:**
47/// - Negative estimates (noise-only or rising-signal voxels) are set to 0: a negative decay rate
48///   has no physical meaning, and QSM.rs does not output negative relaxation rates. Every
49///   non-negative estimate is `arlo.m`'s value unchanged. (MEDI's `extract_CSF` thresholds R2*
50///   at 5 Hz, so its CSF mask is the same either way.)
51/// - `arlo.m` returns `[]` for one echo and 0 everywhere for two (no triplet); this returns an
52///   error for fewer than three echoes, and for echo times that are not finite and strictly
53///   increasing (where `arlo.m` divides by zero or fits the wrong order).
54///
55/// # Arguments
56/// * `magnitude` - The echo magnitudes, `echo_times.len()` volumes of `n_voxels` stacked with
57///   the echo slowest (the order of a 4D NIfTI)
58/// * `echo_times` - Echo times in seconds
59/// * `n_voxels` - Voxels per echo
60///
61/// # Returns
62/// R2* in Hz per voxel
63///
64/// # Errors
65/// See [`arlo_echo_times_problem`]; also a `magnitude` whose length is not
66/// `n_voxels * echo_times.len()`.
67pub fn arlo(magnitude: &[f64], echo_times: &[f64], n_voxels: usize) -> Result<Vec<f64>, InputError> {
68    let mut r2 = arlo_unclipped(magnitude, echo_times, n_voxels)?;
69    // (deviation) no negative R2*
70    for v in r2.iter_mut() {
71        *v = v.max(0.0);
72    }
73    Ok(r2)
74}
75
76/// `arlo.m` exactly, negative estimates included (NaN and infinite results are 0, as in
77/// `arlo.m`). [`arlo`] clips the negatives; this is for ports whose result depends on them, such
78/// as mSMV's vessel mask (`fibermetric` on the R2* map, see [`msmv`](mod@crate::inversion::msmv)).
79/// Same arguments and errors as [`arlo`].
80pub fn arlo_unclipped(magnitude: &[f64], echo_times: &[f64], n_voxels: usize) -> Result<Vec<f64>, InputError> {
81    if let Some(problem) = arlo_echo_times_problem(echo_times) {
82        return Err(InputError(problem));
83    }
84    let nte = echo_times.len();
85    check_len("magnitude (n_echoes * n_voxels)", magnitude.len(), n_voxels * nte)?;
86    let te = echo_times;
87    let mut yy = vec![0.0f64; n_voxels];
88    let mut yx = vec![0.0f64; n_voxels];
89    let mut beta_yx = vec![0.0f64; n_voxels];
90    let mut beta_xx = vec![0.0f64; n_voxels];
91    for j in 0..nte - 2 {
92        let alpha = (te[j + 2] - te[j]) * (te[j + 2] - te[j]) / 2.0 / (te[j + 1] - te[j]);
93        let tmp = (2.0 * te[j + 2] * te[j + 2] - te[j] * te[j + 2] - te[j] * te[j]
94            + 3.0 * te[j] * te[j + 1] - 3.0 * te[j + 1] * te[j + 2]) / 6.0;
95        let beta = tmp / (te[j + 2] - te[j + 1]);
96        let gamma = tmp / (te[j + 1] - te[j]);
97        let (c0, c1, c2) = (te[j + 2] - te[j] - alpha + gamma, alpha - beta - gamma, beta);
98        let (s0, s1, s2) = (
99            &magnitude[j * n_voxels..(j + 1) * n_voxels],
100            &magnitude[(j + 1) * n_voxels..(j + 2) * n_voxels],
101            &magnitude[(j + 2) * n_voxels..(j + 3) * n_voxels],
102        );
103        for v in 0..n_voxels {
104            let y1 = s0[v] * c0 + s1[v] * c1 + s2[v] * c2;
105            let x1 = s0[v] - s2[v];
106            yy[v] += y1 * y1;
107            yx[v] += y1 * x1;
108            beta_yx[v] += beta * y1 * x1;
109            beta_xx[v] += beta * x1 * x1;
110        }
111    }
112    Ok((0..n_voxels)
113        .map(|v| {
114            // r2(isnan(r2)) = 0; r2(isinf(r2)) = 0
115            let r2 = (yx[v] + beta_xx[v]) / (beta_yx[v] + yy[v]);
116            if r2.is_finite() { r2 } else { 0.0 }
117        })
118        .collect())
119}
120
121/// Convert an R2* map (Hz) to a T2* map (seconds).
122///
123/// T2* = 1 / R2* for voxels where R2* > 0; zero otherwise. R2* = 0 (no measurable decay, or no
124/// fit) has no finite T2*; 0 marks it as "no value" rather than a cap such as 1 s, so a T2*
125/// map is never larger than the decay it can measure.
126pub fn t2star_from_r2star(r2star: &[f64]) -> Vec<f64> {
127    r2star.iter().map(|&r| if r > 0.0 { 1.0 / r } else { 0.0 }).collect()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn arlo_matches_the_closed_form() {
136        // Noise-free monoexponential decay at even and uneven spacing: ARLO is close to exact.
137        for te in [vec![0.005, 0.011, 0.017, 0.023, 0.029], vec![0.004, 0.009, 0.016, 0.030]] {
138            let r2: [f64; 3] = [5.0, 30.0, 80.0];
139            let n = r2.len();
140            let mag: Vec<f64> = te.iter().flat_map(|&t| r2.iter().map(move |&r| 100.0 * (-r * t).exp())).collect();
141            let est = arlo(&mag, &te, n).unwrap();
142            for v in 0..n {
143                assert!((est[v] - r2[v]).abs() / r2[v] < 0.02, "{te:?}: {} vs {}", est[v], r2[v]);
144            }
145        }
146        // even spacing: arlo_even.m's closed form
147        let (esp, s) = (0.006, [90.0, 70.0, 61.0, 40.0]);
148        let te: Vec<f64> = (0..4).map(|i| 0.005 + esp * i as f64).collect();
149        let (mut xy, mut xx, mut yy) = (0.0, 0.0, 0.0);
150        for j in 0..2 {
151            let y = esp / 3.0 * (s[j] + 4.0 * s[j + 1] + s[j + 2]);
152            let x = s[j] - s[j + 2];
153            xy += x * y;
154            xx += x * x;
155            yy += y * y;
156        }
157        let want = (esp / 3.0 * xx + xy) / (yy + esp / 3.0 * xy);
158        assert!((arlo(&s, &te, 1).unwrap()[0] - want).abs() < 1e-9 * want);
159        // zero signal: 0/0 -> 0
160        assert_eq!(arlo(&[0.0; 3], &[0.005, 0.01, 0.015], 1).unwrap(), vec![0.0]);
161        // a rising signal gives a negative arlo.m estimate; clamped to 0
162        assert_eq!(arlo(&[10.0, 20.0, 40.0], &[0.005, 0.01, 0.015], 1).unwrap(), vec![0.0]);
163    }
164
165    #[test]
166    fn arlo_layout_is_echo_slowest() {
167        let te: [f64; 4] = [0.005, 0.010, 0.015, 0.020];
168        let r2: [f64; 4] = [20.0, 40.0, 60.0, 80.0];
169        let mag: Vec<f64> = te.iter().flat_map(|&t| r2.iter().map(move |&r| 500.0 * (-r * t).exp())).collect();
170        let est = arlo(&mag, &te, 4).unwrap();
171        for v in 0..4 {
172            assert!((est[v] - r2[v]).abs() / r2[v] < 0.01, "voxel {v}: {}", est[v]);
173        }
174    }
175
176    #[test]
177    fn arlo_refuses_echo_times_it_cannot_use() {
178        let run = |te: &[f64]| {
179            let mag: Vec<f64> = te.iter().map(|t| (-30.0 * t).exp()).collect();
180            arlo(&mag, te, 1)
181        };
182        assert!(run(&[0.005, 0.010, 0.015, 0.020]).is_ok());
183        assert!(run(&[0.004, 0.008, 0.012, 0.030]).is_ok(), "uneven spacing is arlo.m's general form");
184        for (te, why) in [
185            (&[0.005][..], "at least 3"),
186            (&[0.005, 0.010][..], "at least 3"),
187            (&[0.005, 0.005, 0.005][..], "strictly increasing"),
188            (&[0.015, 0.010, 0.005][..], "strictly increasing"),
189            (&[0.005, f64::NAN, 0.015][..], "finite"),
190        ] {
191            let err = run(te).unwrap_err().to_string();
192            assert!(err.contains(why), "{te:?}: {err}");
193            assert!(arlo_echo_times_problem(te).is_some());
194        }
195        assert!(arlo(&[1.0; 2], &[0.005, 0.010, 0.015], 1).is_err());
196    }
197
198    #[test]
199    fn test_t2star_from_r2star() {
200        let r2star = vec![50.0, 0.0, 100.0, 25.0, -3.0];
201        let t2star = t2star_from_r2star(&r2star);
202        assert!((t2star[0] - 0.02).abs() < 1e-10);
203        assert_eq!(t2star[1], 0.0);
204        assert!((t2star[2] - 0.01).abs() < 1e-10);
205        assert!((t2star[3] - 0.04).abs() < 1e-10);
206        assert_eq!(t2star[4], 0.0);
207    }
208}