Skip to main content

qsm_core/separation/
r2star_qsm.rs

1//! R2*-QSM: susceptibility source separation from gradient-echo data alone.
2//!
3//! Dimov et al. separate paramagnetic (χ+, iron) and diamagnetic (χ−, myelin)
4//! susceptibility from GRE data alone — R2* from the multi-echo magnitude plus a
5//! conventional QSM (χ_total) from the phase — with **no** separate R2/R2'
6//! measurement. Per voxel the model is
7//!
8//! ```text
9//!   R2*      = 𝓇·(|χ+| + |χ−|)      (a single relaxometric constant for both sources)
10//!   χ_total  = χ+ + χ−               (χ+ ≥ 0, χ− ≤ 0)
11//! ```
12//!
13//! which inverts in closed form to
14//!
15//! ```text
16//!   χ+   = (χ_total + R2*/𝓇) / 2
17//!   |χ−| = (R2*/𝓇 − χ_total) / 2
18//! ```
19//!
20//! with the physical constraints `χ+ ≥ 0`, `|χ−| ≥ 0` enforced by clipping.
21//!
22//! **Relaxometric constant.** Dimov calibrated `𝓇 = 274 Hz/ppm` at 3 T (a single
23//! value for both sources). R2* susceptibility-induced decay scales linearly with
24//! B0 while χ does not, so at the acquisition field `𝓇 = 274·(B0/3)` Hz/ppm.
25//!
26//! This is the paper's voxel-level solve; the full method adds a field
27//! data-consistency term (auto-satisfied when χ_total is supplied) and an
28//! edge-masked L1 regularisation, both omitted here.
29//!
30//! Reference:
31//! Dimov, A.V., et al. (2022). "Magnetic susceptibility source separation solely
32//! from gradient echo data: histological validation." Tomography, 8(3):1544-1551
33//! (and J. Neuroimaging 2022, https://doi.org/10.1111/jon.13014). R2*
34//! susceptibility model: Yablonskiy & Haacke, Magn. Reson. Med. 1994.
35
36/// Parameters for [`r2star_qsm`] / [`r2star_qsm_from_magnitude`].
37#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
38#[derive(Clone, Debug)]
39pub struct R2starQsmParams {
40    /// Main field strength in Tesla (the constant is scaled by `B0/3`).
41    pub b0: f64,
42    /// Relaxometric constant at 3 T in Hz/ppm (Dimov et al.: 274).
43    pub r_const_3t: f64,
44}
45
46impl Default for R2starQsmParams {
47    fn default() -> Self {
48        Self { b0: 3.0, r_const_3t: 274.0 }
49    }
50}
51
52/// Closed-form R2*-QSM separation from a QSM and an R2* map.
53///
54/// # Arguments
55/// * `chi_total` — Conventional QSM χ_total in **ppm** (`n` voxels).
56/// * `r2star` — R2* map in **Hz** (`n` voxels), e.g. from [`crate::r2star::r2star_arlo`].
57/// * `mask` — Binary brain mask (`n` voxels, 1 = inside).
58/// * `params` — See [`R2starQsmParams`].
59///
60/// # Returns
61/// `(chi_pos, chi_neg, chi_total)` in ppm, restricted to `mask` — matching the
62/// [`chi_sep_ilsqr`](super::chi_sep_ilsqr)/[`chi_sep_medi`](super::chi_sep_medi)
63/// convention: `chi_pos` ≥ 0 (paramagnetic), `chi_neg` ≤ 0 (diamagnetic, signed),
64/// and `chi_total = chi_pos + chi_neg`.
65pub fn r2star_qsm(
66    chi_total: &[f64],
67    r2star: &[f64],
68    mask: &[u8],
69    params: &R2starQsmParams,
70) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
71    let n = chi_total.len();
72    assert_eq!(r2star.len(), n, "r2star length must match chi_total");
73    assert_eq!(mask.len(), n, "mask length must match chi_total");
74
75    // Field-scaled relaxometric constant (Hz/ppm).
76    let r = params.r_const_3t * (params.b0 / 3.0);
77
78    let mut chi_pos = vec![0.0_f64; n];
79    let mut chi_neg = vec![0.0_f64; n];
80    let mut chi_out = vec![0.0_f64; n];
81    for i in 0..n {
82        if mask[i] == 0 {
83            continue;
84        }
85        let chi = chi_total[i];
86        let s = r2star[i] / r; // R2*/𝓇 = |χ+| + |χ−|
87        let pos = ((chi + s) / 2.0).max(0.0); // χ+
88        let dia_mag = ((s - chi) / 2.0).max(0.0); // |χ−|
89        chi_pos[i] = pos;
90        chi_neg[i] = -dia_mag; // signed χ− ≤ 0
91        chi_out[i] = pos - dia_mag; // χ_total = χ+ + χ−
92    }
93    (chi_pos, chi_neg, chi_out)
94}
95
96/// R2*-QSM separation fitting R2* from multi-echo magnitude, then separating.
97///
98/// Fits R2* by magnitude-weighted log-linear least squares (weight ∝ magnitude²),
99/// matching the QSM-CI reference implementation, then calls [`r2star_qsm`].
100///
101/// # Arguments
102/// * `magnitude` — Multi-echo magnitude, flattened as `(n_voxels, n_echoes)` in
103///   row-major order (echo fastest per voxel) — the same layout as
104///   [`crate::r2star::r2star_arlo`].
105/// * `echo_times` — Echo times in **seconds** (`n_echoes`).
106/// * `chi_total` — Conventional QSM χ_total in **ppm** (`n_voxels`).
107/// * `mask` — Binary brain mask (`n_voxels`).
108/// * `params` — See [`R2starQsmParams`].
109///
110/// # Returns
111/// `(chi_pos, chi_neg, chi_total)` as in [`r2star_qsm`].
112pub fn r2star_qsm_from_magnitude(
113    magnitude: &[f64],
114    echo_times: &[f64],
115    chi_total: &[f64],
116    mask: &[u8],
117    params: &R2starQsmParams,
118) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
119    let n = chi_total.len();
120    let ne = echo_times.len();
121    assert_eq!(magnitude.len(), n * ne, "magnitude must be n_voxels * n_echoes");
122    let r2star = fit_r2star_weighted_loglinear(magnitude, echo_times, mask, n);
123    r2star_qsm(chi_total, &r2star, mask, params)
124}
125
126/// R2* (Hz) by magnitude-weighted log-linear least squares over echoes.
127///
128/// Weight ∝ magnitude² (SNR) so noisy late echoes don't dominate — the QSM-CI
129/// reference's robust stand-in for ARLO. `R2* = −slope of log(magnitude) vs TE`,
130/// clamped to ≥ 0.
131fn fit_r2star_weighted_loglinear(
132    magnitude: &[f64],
133    echo_times: &[f64],
134    mask: &[u8],
135    n_voxels: usize,
136) -> Vec<f64> {
137    let ne = echo_times.len();
138    let mut r2s = vec![0.0_f64; n_voxels];
139    for v in 0..n_voxels {
140        if mask[v] == 0 {
141            continue;
142        }
143        // Weighted means over echoes.
144        let mut sw = 0.0;
145        let mut swt = 0.0;
146        let mut swl = 0.0;
147        for e in 0..ne {
148            let m = magnitude[v * ne + e].max(1e-9);
149            let w = m * m;
150            sw += w;
151            swt += w * echo_times[e];
152            swl += w * m.ln();
153        }
154        if sw <= 0.0 {
155            continue;
156        }
157        let tbar = swt / sw;
158        let lbar = swl / sw;
159        // Weighted covariance / variance.
160        let mut cov = 0.0;
161        let mut var = 0.0;
162        for e in 0..ne {
163            let m = magnitude[v * ne + e].max(1e-9);
164            let w = m * m;
165            let dt = echo_times[e] - tbar;
166            cov += w * dt * (m.ln() - lbar);
167            var += w * dt * dt;
168        }
169        if var > 0.0 {
170            r2s[v] = (-cov / var).max(0.0);
171        }
172    }
173    r2s
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn closed_form_recovers_sources() {
182        // Build χ_total and R2* from known χ+ / |χ−| via the forward model, then
183        // check the closed form inverts them exactly.
184        let params = R2starQsmParams { b0: 3.0, r_const_3t: 274.0 };
185        let r = params.r_const_3t; // B0 = 3 -> r = 274
186        let chi_pos = vec![0.10, 0.05, 0.00, 0.20];
187        let chi_neg_mag = vec![0.02, 0.10, 0.15, 0.00]; // |χ−|
188        let n = chi_pos.len();
189        let chi_total: Vec<f64> = (0..n).map(|i| chi_pos[i] - chi_neg_mag[i]).collect();
190        let r2star: Vec<f64> = (0..n).map(|i| r * (chi_pos[i] + chi_neg_mag[i])).collect();
191        let mask = vec![1u8; n];
192
193        let (para, neg, total) = r2star_qsm(&chi_total, &r2star, &mask, &params);
194        for i in 0..n {
195            assert!((para[i] - chi_pos[i]).abs() < 1e-9, "χ+ voxel {i}");
196            assert!((-neg[i] - chi_neg_mag[i]).abs() < 1e-9, "|χ−| voxel {i}");
197            assert!((total[i] - chi_total[i]).abs() < 1e-9, "χ_total voxel {i}");
198            assert!(neg[i] <= 0.0, "χ− must be ≤ 0 at voxel {i}");
199        }
200    }
201
202    #[test]
203    fn field_scaling_and_masking() {
204        // At B0 = 6, r doubles, so the same R2* yields half the source magnitude.
205        let p3 = R2starQsmParams { b0: 3.0, r_const_3t: 274.0 };
206        let p6 = R2starQsmParams { b0: 6.0, r_const_3t: 274.0 };
207        let chi_total = vec![0.0, 0.0];
208        let r2star = vec![274.0, 274.0];
209        let mask = vec![1u8, 0u8];
210
211        let (para3, neg3, _t3) = r2star_qsm(&chi_total, &r2star, &mask, &p3);
212        let (para6, _neg6, _t6) = r2star_qsm(&chi_total, &r2star, &mask, &p6);
213        // voxel 0: χ_total=0, R2*/r = 1 at 3T -> χ+ = |χ−| = 0.5 (χ− = −0.5)
214        assert!((para3[0] - 0.5).abs() < 1e-9);
215        assert!((neg3[0] + 0.5).abs() < 1e-9);
216        // at 6T r = 548 -> R2*/r = 0.5 -> χ+ = 0.25
217        assert!((para6[0] - 0.25).abs() < 1e-9);
218        // masked-out voxel stays zero
219        assert_eq!(para3[1], 0.0);
220        assert_eq!(neg3[1], 0.0);
221    }
222
223    #[test]
224    fn r2star_fit_from_magnitude() {
225        // Mono-exponential decay with known R2*, single voxel.
226        let r2star_true = 40.0_f64;
227        let s0 = 1000.0_f64;
228        let te = vec![0.004, 0.012, 0.020, 0.028];
229        let mag: Vec<f64> = te.iter().map(|&t| s0 * (-r2star_true * t).exp()).collect();
230        let mask = vec![1u8];
231        let r2s = fit_r2star_weighted_loglinear(&mag, &te, &mask, 1);
232        assert!((r2s[0] - r2star_true).abs() / r2star_true < 1e-6, "R2* fit {}", r2s[0]);
233    }
234}