Skip to main content

qsm_core/utils/
r2star.rs

1//! R2* and T2* mapping from multi-echo magnitude data
2//!
3//! Provides R2* estimation using the ARLO (Auto-Regression on Linear Operations)
4//! algorithm for equi-spaced echo times, with a log-linear fallback for edge cases.
5//! T2* maps can be computed as the reciprocal of R2* via [`t2star_from_r2star`].
6//!
7//! Reference:
8//! Pei, M., et al. (2015). "Algorithm for fast monoexponential fitting based on
9//! Auto-Regression on Linear Operations (ARLO) of data."
10//! Magnetic Resonance in Medicine, 73(2):843-850.
11
12/// Check if echo times are approximately equi-spaced (suitable for ARLO).
13///
14/// Requires at least 3 echo times with spacing deviations below `tolerance`.
15///
16/// # Arguments
17/// * `echo_times` - Echo times in seconds
18/// * `tolerance` - Maximum allowed deviation from uniform spacing (seconds, e.g. 0.1e-3)
19pub fn use_arlo(echo_times: &[f64], tolerance: f64) -> bool {
20    if echo_times.len() < 3 {
21        return false;
22    }
23
24    let mut te_sorted: Vec<f64> = echo_times.to_vec();
25    te_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
26
27    let diffs: Vec<f64> = te_sorted.windows(2).map(|w| w[1] - w[0]).collect();
28    let mean_diff: f64 = diffs.iter().sum::<f64>() / diffs.len() as f64;
29    let max_dev = diffs.iter().map(|&d| (d - mean_diff).abs()).fold(0.0_f64, f64::max);
30
31    max_dev <= tolerance
32}
33
34/// R2* mapping using ARLO for equi-spaced echo times (≥3 echoes).
35///
36/// For each masked voxel, uses the integral-based ARLO estimator (Pei et al.
37/// 2015) — a least-squares fit exploiting `s_n - s_{n+2} = R2*·∫S dt` with a
38/// Simpson-rule integral — to estimate the mono-exponential decay rate. Falls
39/// back to log-linear fitting where the estimate is unreliable.
40///
41/// # Arguments
42/// * `magnitude` - Multi-echo magnitude data, flattened as `[voxel0_echo0, voxel0_echo1, ..., voxel1_echo0, ...]`
43///   i.e. shape `(n_voxels, n_echoes)` in row-major order, where `n_voxels = nx*ny*nz`
44/// * `mask` - Binary brain mask `[nx*ny*nz]` (1 = process, 0 = skip)
45/// * `echo_times` - Echo times in seconds `[n_echoes]` (must be equi-spaced)
46/// * `grid` - Volume grid (dimensions and voxel sizes)
47///
48/// # Returns
49/// `(r2star_map, s0_map)` - R2* in Hz and S0 (proton density), both `[nx*ny*nz]`
50///
51/// # Panics
52/// Panics if `echo_times.len() < 3` or echo times are not equi-spaced.
53pub fn r2star_arlo(
54    magnitude: &[f64],
55    mask: &[u8],
56    echo_times: &[f64],
57    grid: &crate::Grid,
58) -> (Vec<f64>, Vec<f64>) {
59    let n_echoes = echo_times.len();
60    assert!(n_echoes >= 3, "ARLO requires at least 3 echo times");
61    let n_voxels = grid.n_total();
62    assert_eq!(magnitude.len(), n_voxels * n_echoes,
63        "magnitude length must be n_voxels * n_echoes");
64    assert_eq!(mask.len(), n_voxels, "mask length must be n_voxels");
65
66    // Sort echo times and get the uniform spacing
67    let mut te_sorted: Vec<f64> = echo_times.to_vec();
68    let sort_indices: Vec<usize> = {
69        let mut idx: Vec<usize> = (0..n_echoes).collect();
70        idx.sort_by(|&a, &b| echo_times[a].partial_cmp(&echo_times[b]).unwrap());
71        idx
72    };
73    te_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
74    let delta_te = te_sorted[1] - te_sorted[0];
75
76    let mut r2star_map = vec![0.0_f64; n_voxels];
77    let mut s0_map = vec![0.0_f64; n_voxels];
78
79    for v in 0..n_voxels {
80        if mask[v] == 0 {
81            continue;
82        }
83
84        // Extract signal for this voxel, sorted by echo time
85        let signal: Vec<f64> = sort_indices.iter()
86            .map(|&ei| magnitude[v * n_echoes + ei])
87            .collect();
88
89        // Skip if signal is too small
90        let sig_max = signal.iter().cloned().fold(0.0_f64, f64::max);
91        if sig_max < 1e-10 {
92            continue;
93        }
94
95        // ARLO (Pei et al. 2015): integral-based auto-regression on linear
96        // operations. From S'(t) = -R2*·S(t), integrating over [t_n, t_{n+2}]:
97        //   s_n - s_{n+2} = R2* · ∫_{t_n}^{t_{n+2}} S dt
98        // Estimate the integral with Simpson's rule (y_n) and the drop (x_n),
99        // then R2* = Σ x_n·y_n / Σ y_n² (least-squares fit of x = R2*·y).
100        // This has no ad-hoc ratio selection, so it does not bias on noisy data.
101        let mut num = 0.0_f64;
102        let mut den = 0.0_f64;
103        for i in 0..(n_echoes - 2) {
104            let integ = (delta_te / 3.0) * (signal[i] + 4.0 * signal[i + 1] + signal[i + 2]);
105            let diff = signal[i] - signal[i + 2];
106            num += diff * integ;
107            den += integ * integ;
108        }
109
110        if den > 1e-30 {
111            let r2star_val = num / den;
112            if (0.0..=500.0).contains(&r2star_val) {
113                r2star_map[v] = r2star_val;
114                s0_map[v] = signal[0] * (r2star_val * te_sorted[0]).exp();
115                continue;
116            }
117        }
118
119        // Fallback: log-linear fit
120        let (r2, s0) = log_linear_fit(&signal, &te_sorted);
121        r2star_map[v] = r2.max(0.0);
122        s0_map[v] = s0.max(0.0);
123    }
124
125    (r2star_map, s0_map)
126}
127
128/// Convert an R2* map (Hz) to a T2* map (seconds).
129///
130/// T2* = 1 / R2* for voxels where R2* > 0; zero otherwise.
131pub fn t2star_from_r2star(r2star: &[f64]) -> Vec<f64> {
132    r2star.iter().map(|&r| if r > 0.0 { 1.0 / r } else { 0.0 }).collect()
133}
134
135/// Log-linear R2* fit: log(S) = log(S0) - R2* * TE
136/// Solves via normal equations.
137fn log_linear_fit(signal: &[f64], echo_times: &[f64]) -> (f64, f64) {
138    let n = signal.len();
139    let mut sum_t = 0.0;
140    let mut sum_tt = 0.0;
141    let mut sum_y = 0.0;
142    let mut sum_ty = 0.0;
143    let mut count = 0;
144
145    for i in 0..n {
146        if signal[i] > 1e-10 {
147            let y = signal[i].ln();
148            let t = echo_times[i];
149            sum_t += t;
150            sum_tt += t * t;
151            sum_y += y;
152            sum_ty += t * y;
153            count += 1;
154        }
155    }
156
157    if count < 2 {
158        return (0.0, 0.0);
159    }
160
161    let n_f = count as f64;
162    let denom = n_f * sum_tt - sum_t * sum_t;
163    if denom.abs() < 1e-15 {
164        return (0.0, 0.0);
165    }
166
167    let slope = (n_f * sum_ty - sum_t * sum_y) / denom;
168    let intercept = (sum_y - slope * sum_t) / n_f;
169
170    let r2star = -slope;  // S = S0 * exp(-R2* * TE) → log(S) = log(S0) - R2* * TE
171    let s0 = intercept.exp();
172
173    (r2star, s0)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::Grid;
180
181    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
182        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
183    }
184
185    #[test]
186    fn test_use_arlo_equispaced() {
187        let te = vec![0.005, 0.010, 0.015, 0.020];
188        assert!(use_arlo(&te, 0.1e-3));
189    }
190
191    #[test]
192    fn test_use_arlo_not_equispaced() {
193        let te = vec![0.005, 0.008, 0.015];
194        assert!(!use_arlo(&te, 0.1e-3));
195    }
196
197    #[test]
198    fn test_use_arlo_too_few() {
199        let te = vec![0.005, 0.010];
200        assert!(!use_arlo(&te, 0.1e-3));
201    }
202
203    #[test]
204    fn test_r2star_arlo_synthetic() {
205        // Synthetic mono-exponential decay: S(TE) = S0 * exp(-R2* * TE)
206        let r2star_true: f64 = 30.0; // Hz
207        let s0_true: f64 = 1000.0;
208        let te = vec![0.005, 0.010, 0.015, 0.020, 0.025];
209        let n_echoes = te.len();
210
211        // Single voxel in a 1x1x1 volume
212        let mask = vec![1u8];
213        let magnitude: Vec<f64> = te.iter()
214            .map(|&t| s0_true * (-r2star_true * t).exp())
215            .collect();
216
217        let (r2star_map, s0_map) = r2star_arlo(&magnitude, &mask, &te, &grid(1, 1, 1));
218
219        let r2star_err = (r2star_map[0] - r2star_true).abs() / r2star_true;
220        let s0_err = (s0_map[0] - s0_true).abs() / s0_true;
221
222        assert!(r2star_err < 0.01, "R2* error {:.4}% should be < 1%", r2star_err * 100.0);
223        assert!(s0_err < 0.01, "S0 error {:.4}% should be < 1%", s0_err * 100.0);
224    }
225
226    #[test]
227    fn test_t2star_from_r2star() {
228        let r2star = vec![50.0, 0.0, 100.0, 25.0];
229        let t2star = t2star_from_r2star(&r2star);
230        assert!((t2star[0] - 0.02).abs() < 1e-10);
231        assert_eq!(t2star[1], 0.0);
232        assert!((t2star[2] - 0.01).abs() < 1e-10);
233        assert!((t2star[3] - 0.04).abs() < 1e-10);
234    }
235
236    #[test]
237    fn test_r2star_arlo_masked_voxel() {
238        let te = vec![0.005, 0.010, 0.015];
239        let mask = vec![0u8; 1]; // masked out
240        let magnitude = vec![100.0, 80.0, 60.0];
241
242        let (r2star_map, s0_map) = r2star_arlo(&magnitude, &mask, &te, &grid(1, 1, 1));
243        assert_eq!(r2star_map[0], 0.0);
244        assert_eq!(s0_map[0], 0.0);
245    }
246
247    #[test]
248    fn test_r2star_arlo_multiple_voxels() {
249        let te = vec![0.005, 0.010, 0.015, 0.020];
250        let n_echoes = te.len();
251        let nx = 2; let ny = 2; let nz = 1;
252        let n_voxels = nx * ny * nz;
253
254        let r2star_values = vec![20.0, 40.0, 60.0, 80.0];
255        let s0: f64 = 500.0;
256
257        let mask = vec![1u8; n_voxels];
258        let mut magnitude = vec![0.0_f64; n_voxels * n_echoes];
259        for v in 0..n_voxels {
260            for (e, &t) in te.iter().enumerate() {
261                magnitude[v * n_echoes + e] = s0 * f64::exp(-r2star_values[v] * t);
262            }
263        }
264
265        let (r2star_map, _s0_map) = r2star_arlo(&magnitude, &mask, &te, &grid(nx, ny, nz));
266
267        for v in 0..n_voxels {
268            let err = (r2star_map[v] - r2star_values[v]).abs() / r2star_values[v];
269            assert!(err < 0.01, "Voxel {} R2* error {:.4}% should be < 1%", v, err * 100.0);
270        }
271    }
272}