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, computes consecutive signal ratios to estimate the
37/// mono-exponential decay rate. Falls back to log-linear fitting for voxels
38/// with unreliable ratio estimates.
39///
40/// # Arguments
41/// * `magnitude` - Multi-echo magnitude data, flattened as `[voxel0_echo0, voxel0_echo1, ..., voxel1_echo0, ...]`
42///   i.e. shape `(n_voxels, n_echoes)` in row-major order, where `n_voxels = nx*ny*nz`
43/// * `mask` - Binary brain mask `[nx*ny*nz]` (1 = process, 0 = skip)
44/// * `echo_times` - Echo times in seconds `[n_echoes]` (must be equi-spaced)
45/// * `nx`, `ny`, `nz` - Volume dimensions
46///
47/// # Returns
48/// `(r2star_map, s0_map)` - R2* in Hz and S0 (proton density), both `[nx*ny*nz]`
49///
50/// # Panics
51/// Panics if `echo_times.len() < 3` or echo times are not equi-spaced.
52#[allow(clippy::too_many_arguments)]
53pub fn r2star_arlo(
54    magnitude: &[f64],
55    mask: &[u8],
56    echo_times: &[f64],
57    nx: usize, ny: usize, nz: usize,
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 = nx * ny * nz;
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: compute consecutive signal ratios
96        let mut alphas = Vec::new();
97        for i in 0..(n_echoes - 1) {
98            if signal[i] > 1e-10 {
99                let alpha = signal[i + 1] / signal[i];
100                if alpha > 0.0 && alpha <= 1.0 {
101                    alphas.push(alpha);
102                }
103            }
104        }
105
106        if alphas.len() >= 2 {
107            let alpha_mean: f64 = alphas.iter().sum::<f64>() / alphas.len() as f64;
108            let r2star_val = -alpha_mean.ln() / delta_te;
109
110            if r2star_val >= 0.0 && r2star_val <= 500.0 {
111                r2star_map[v] = r2star_val;
112                s0_map[v] = signal[0] * (r2star_val * te_sorted[0]).exp();
113                continue;
114            }
115        }
116
117        // Fallback: log-linear fit
118        let (r2, s0) = log_linear_fit(&signal, &te_sorted);
119        r2star_map[v] = r2.max(0.0);
120        s0_map[v] = s0.max(0.0);
121    }
122
123    (r2star_map, s0_map)
124}
125
126/// Convert an R2* map (Hz) to a T2* map (seconds).
127///
128/// T2* = 1 / R2* for voxels where R2* > 0; zero otherwise.
129pub fn t2star_from_r2star(r2star: &[f64]) -> Vec<f64> {
130    r2star.iter().map(|&r| if r > 0.0 { 1.0 / r } else { 0.0 }).collect()
131}
132
133/// Log-linear R2* fit: log(S) = log(S0) - R2* * TE
134/// Solves via normal equations.
135fn log_linear_fit(signal: &[f64], echo_times: &[f64]) -> (f64, f64) {
136    let n = signal.len();
137    let mut sum_t = 0.0;
138    let mut sum_tt = 0.0;
139    let mut sum_y = 0.0;
140    let mut sum_ty = 0.0;
141    let mut count = 0;
142
143    for i in 0..n {
144        if signal[i] > 1e-10 {
145            let y = signal[i].ln();
146            let t = echo_times[i];
147            sum_t += t;
148            sum_tt += t * t;
149            sum_y += y;
150            sum_ty += t * y;
151            count += 1;
152        }
153    }
154
155    if count < 2 {
156        return (0.0, 0.0);
157    }
158
159    let n_f = count as f64;
160    let denom = n_f * sum_tt - sum_t * sum_t;
161    if denom.abs() < 1e-15 {
162        return (0.0, 0.0);
163    }
164
165    let slope = (n_f * sum_ty - sum_t * sum_y) / denom;
166    let intercept = (sum_y - slope * sum_t) / n_f;
167
168    let r2star = -slope;  // S = S0 * exp(-R2* * TE) → log(S) = log(S0) - R2* * TE
169    let s0 = intercept.exp();
170
171    (r2star, s0)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_use_arlo_equispaced() {
180        let te = vec![0.005, 0.010, 0.015, 0.020];
181        assert!(use_arlo(&te, 0.1e-3));
182    }
183
184    #[test]
185    fn test_use_arlo_not_equispaced() {
186        let te = vec![0.005, 0.008, 0.015];
187        assert!(!use_arlo(&te, 0.1e-3));
188    }
189
190    #[test]
191    fn test_use_arlo_too_few() {
192        let te = vec![0.005, 0.010];
193        assert!(!use_arlo(&te, 0.1e-3));
194    }
195
196    #[test]
197    fn test_r2star_arlo_synthetic() {
198        // Synthetic mono-exponential decay: S(TE) = S0 * exp(-R2* * TE)
199        let r2star_true: f64 = 30.0; // Hz
200        let s0_true: f64 = 1000.0;
201        let te = vec![0.005, 0.010, 0.015, 0.020, 0.025];
202        let n_echoes = te.len();
203
204        // Single voxel in a 1x1x1 volume
205        let mask = vec![1u8];
206        let magnitude: Vec<f64> = te.iter()
207            .map(|&t| s0_true * (-r2star_true * t).exp())
208            .collect();
209
210        let (r2star_map, s0_map) = r2star_arlo(&magnitude, &mask, &te, 1, 1, 1);
211
212        let r2star_err = (r2star_map[0] - r2star_true).abs() / r2star_true;
213        let s0_err = (s0_map[0] - s0_true).abs() / s0_true;
214
215        assert!(r2star_err < 0.01, "R2* error {:.4}% should be < 1%", r2star_err * 100.0);
216        assert!(s0_err < 0.01, "S0 error {:.4}% should be < 1%", s0_err * 100.0);
217    }
218
219    #[test]
220    fn test_t2star_from_r2star() {
221        let r2star = vec![50.0, 0.0, 100.0, 25.0];
222        let t2star = t2star_from_r2star(&r2star);
223        assert!((t2star[0] - 0.02).abs() < 1e-10);
224        assert_eq!(t2star[1], 0.0);
225        assert!((t2star[2] - 0.01).abs() < 1e-10);
226        assert!((t2star[3] - 0.04).abs() < 1e-10);
227    }
228
229    #[test]
230    fn test_r2star_arlo_masked_voxel() {
231        let te = vec![0.005, 0.010, 0.015];
232        let mask = vec![0u8; 1]; // masked out
233        let magnitude = vec![100.0, 80.0, 60.0];
234
235        let (r2star_map, s0_map) = r2star_arlo(&magnitude, &mask, &te, 1, 1, 1);
236        assert_eq!(r2star_map[0], 0.0);
237        assert_eq!(s0_map[0], 0.0);
238    }
239
240    #[test]
241    fn test_r2star_arlo_multiple_voxels() {
242        let te = vec![0.005, 0.010, 0.015, 0.020];
243        let n_echoes = te.len();
244        let nx = 2; let ny = 2; let nz = 1;
245        let n_voxels = nx * ny * nz;
246
247        let r2star_values = vec![20.0, 40.0, 60.0, 80.0];
248        let s0: f64 = 500.0;
249
250        let mask = vec![1u8; n_voxels];
251        let mut magnitude = vec![0.0_f64; n_voxels * n_echoes];
252        for v in 0..n_voxels {
253            for (e, &t) in te.iter().enumerate() {
254                magnitude[v * n_echoes + e] = s0 * f64::exp(-r2star_values[v] * t);
255            }
256        }
257
258        let (r2star_map, _s0_map) = r2star_arlo(&magnitude, &mask, &te, nx, ny, nz);
259
260        for v in 0..n_voxels {
261            let err = (r2star_map[v] - r2star_values[v]).abs() / r2star_values[v];
262            assert!(err < 0.01, "Voxel {} R2* error {:.4}% should be < 1%", v, err * 100.0);
263        }
264    }
265}