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 pipeline
31//!
32//! Dimov et al., Tomography 2022 (doi:10.3390/tomography8030127, Methods): ex-vivo brain
33//! tissue at 3 T (GE), 0.5 mm isotropic, 8 echoes (TE1 = ΔTE = 5.9 ms). The field was a nonlinear
34//! fit of the multi-echo complex data followed by spatial unwrapping, background removal was
35//! PDF, χ_total was MEDI, and R2* was fitted with ARLO. The full method is a joint minimisation
36//! with magnitude- and R2*-derived edge masks at λ1 = 1500, initialised from the voxelwise solve
37//! implemented here. 𝓇 = 274 Hz/ppm was calibrated at 3 T (against a theoretical 321 Hz/ppm).
38//! The paper gives no value at any other field: the `B0/3` scaling above is QSM.rs's (and the
39//! QSM-CI submission's) assumption from static-dephasing theory, not the authors' (open
40//! question).
41//!
42//! Reference:
43//! Dimov, A.V., et al. (2022). "Magnetic susceptibility source separation solely
44//! from gradient echo data: histological validation." Tomography, 8(3):1544-1551
45//! (and J. Neuroimaging 2022, https://doi.org/10.1111/jon.13014). R2*
46//! susceptibility model: Yablonskiy & Haacke, Magn. Reson. Med. 1994.
47//!
48//! # Pipeline rules
49//!
50//! The facts above that a configuration can depart from are published as rules in
51//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `r2star-qsm.field-complex-fit`, `r2star-qsm.bg-pdf`, `r2star-qsm.qsm-medi`.
52
53/// Parameters for [`r2star_qsm`] / [`r2star_qsm_from_magnitude`].
54#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
55#[derive(Clone, Debug)]
56pub struct R2starQsmParams {
57    /// Main field strength in Tesla (the constant is scaled by `B0/3`).
58    pub b0: f64,
59    /// Relaxometric constant at 3 T in Hz/ppm (Dimov et al.: 274).
60    pub r_const_3t: f64,
61}
62
63impl Default for R2starQsmParams {
64    fn default() -> Self {
65        Self { b0: 3.0, r_const_3t: 274.0 }
66    }
67}
68
69/// Closed-form R2*-QSM separation from a QSM and an R2* map.
70///
71/// # Arguments
72/// * `chi_total` — Conventional QSM χ_total in **ppm** (`n` voxels).
73/// * `r2star` — R2* map in **Hz** (`n` voxels), e.g. from [`crate::r2star::arlo`].
74/// * `mask` — Binary brain mask (`n` voxels, 1 = inside).
75/// * `params` — See [`R2starQsmParams`].
76///
77/// # Returns
78/// `(chi_pos, chi_neg, chi_total)` in ppm, restricted to `mask` — matching the
79/// [`chi_sep_ilsqr`](super::chi_sep_ilsqr)/[`chi_sep_medi`](super::chi_sep_medi)
80/// convention: `chi_pos` ≥ 0 (paramagnetic), `chi_neg` ≤ 0 (diamagnetic, signed),
81/// and `chi_total = chi_pos + chi_neg`.
82pub fn r2star_qsm(
83    chi_total: &[f64],
84    r2star: &[f64],
85    mask: &[u8],
86    params: &R2starQsmParams,
87) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
88    let n = chi_total.len();
89    assert_eq!(r2star.len(), n, "r2star length must match chi_total");
90    assert_eq!(mask.len(), n, "mask length must match chi_total");
91
92    // Field-scaled relaxometric constant (Hz/ppm).
93    let r = params.r_const_3t * (params.b0 / 3.0);
94
95    let mut chi_pos = vec![0.0_f64; n];
96    let mut chi_neg = vec![0.0_f64; n];
97    let mut chi_out = vec![0.0_f64; n];
98    for i in 0..n {
99        if mask[i] == 0 {
100            continue;
101        }
102        let chi = chi_total[i];
103        let s = r2star[i] / r; // R2*/𝓇 = |χ+| + |χ−|
104        let pos = ((chi + s) / 2.0).max(0.0); // χ+
105        let dia_mag = ((s - chi) / 2.0).max(0.0); // |χ−|
106        chi_pos[i] = pos;
107        chi_neg[i] = -dia_mag; // signed χ− ≤ 0
108        chi_out[i] = pos - dia_mag; // χ_total = χ+ + χ−
109    }
110    (chi_pos, chi_neg, chi_out)
111}
112
113/// R2*-QSM separation fitting R2* from multi-echo magnitude, then separating.
114///
115/// Fits R2* by magnitude-weighted log-linear least squares (weight ∝ magnitude²),
116/// matching the QSM-CI reference implementation, then calls [`r2star_qsm`].
117///
118/// # Arguments
119/// * `magnitude` — Multi-echo magnitude, flattened as `(n_voxels, n_echoes)` in
120///   row-major order (echo fastest per voxel) — the same layout as
121///   [`crate::r2star::arlo`].
122/// * `echo_times` — Echo times in **seconds** (`n_echoes`).
123/// * `chi_total` — Conventional QSM χ_total in **ppm** (`n_voxels`).
124/// * `mask` — Binary brain mask (`n_voxels`).
125/// * `params` — See [`R2starQsmParams`].
126///
127/// # Returns
128/// `(chi_pos, chi_neg, chi_total)` as in [`r2star_qsm`].
129pub fn r2star_qsm_from_magnitude(
130    magnitude: &[f64],
131    echo_times: &[f64],
132    chi_total: &[f64],
133    mask: &[u8],
134    params: &R2starQsmParams,
135) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
136    let n = chi_total.len();
137    let ne = echo_times.len();
138    assert_eq!(magnitude.len(), n * ne, "magnitude must be n_voxels * n_echoes");
139    let r2star = fit_r2star_weighted_loglinear(magnitude, echo_times, mask, n);
140    r2star_qsm(chi_total, &r2star, mask, params)
141}
142
143/// R2* (Hz) by magnitude-weighted log-linear least squares over echoes.
144///
145/// Weight ∝ magnitude² (SNR) so noisy late echoes don't dominate — the QSM-CI
146/// reference's robust stand-in for ARLO. `R2* = −slope of log(magnitude) vs TE`,
147/// clamped to ≥ 0.
148fn fit_r2star_weighted_loglinear(
149    magnitude: &[f64],
150    echo_times: &[f64],
151    mask: &[u8],
152    n_voxels: usize,
153) -> Vec<f64> {
154    let ne = echo_times.len();
155    let mut r2s = vec![0.0_f64; n_voxels];
156    for v in 0..n_voxels {
157        if mask[v] == 0 {
158            continue;
159        }
160        // Weighted means over echoes.
161        let mut sw = 0.0;
162        let mut swt = 0.0;
163        let mut swl = 0.0;
164        for e in 0..ne {
165            let m = magnitude[v * ne + e].max(1e-9);
166            let w = m * m;
167            sw += w;
168            swt += w * echo_times[e];
169            swl += w * m.ln();
170        }
171        if sw <= 0.0 {
172            continue;
173        }
174        let tbar = swt / sw;
175        let lbar = swl / sw;
176        // Weighted covariance / variance.
177        let mut cov = 0.0;
178        let mut var = 0.0;
179        for e in 0..ne {
180            let m = magnitude[v * ne + e].max(1e-9);
181            let w = m * m;
182            let dt = echo_times[e] - tbar;
183            cov += w * dt * (m.ln() - lbar);
184            var += w * dt * dt;
185        }
186        if var > 0.0 {
187            r2s[v] = (-cov / var).max(0.0);
188        }
189    }
190    r2s
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn closed_form_recovers_sources() {
199        // Build χ_total and R2* from known χ+ / |χ−| via the forward model, then
200        // check the closed form inverts them exactly.
201        let params = R2starQsmParams { b0: 3.0, r_const_3t: 274.0 };
202        let r = params.r_const_3t; // B0 = 3 -> r = 274
203        let chi_pos = [0.10, 0.05, 0.00, 0.20];
204        let chi_neg_mag = [0.02, 0.10, 0.15, 0.00]; // |χ−|
205        let n = chi_pos.len();
206        let chi_total: Vec<f64> = (0..n).map(|i| chi_pos[i] - chi_neg_mag[i]).collect();
207        let r2star: Vec<f64> = (0..n).map(|i| r * (chi_pos[i] + chi_neg_mag[i])).collect();
208        let mask = vec![1u8; n];
209
210        let (para, neg, total) = r2star_qsm(&chi_total, &r2star, &mask, &params);
211        for i in 0..n {
212            assert!((para[i] - chi_pos[i]).abs() < 1e-9, "χ+ voxel {i}");
213            assert!((-neg[i] - chi_neg_mag[i]).abs() < 1e-9, "|χ−| voxel {i}");
214            assert!((total[i] - chi_total[i]).abs() < 1e-9, "χ_total voxel {i}");
215            assert!(neg[i] <= 0.0, "χ− must be ≤ 0 at voxel {i}");
216        }
217    }
218
219    #[test]
220    fn field_scaling_and_masking() {
221        // At B0 = 6, r doubles, so the same R2* yields half the source magnitude.
222        let p3 = R2starQsmParams { b0: 3.0, r_const_3t: 274.0 };
223        let p6 = R2starQsmParams { b0: 6.0, r_const_3t: 274.0 };
224        let chi_total = vec![0.0, 0.0];
225        let r2star = vec![274.0, 274.0];
226        let mask = vec![1u8, 0u8];
227
228        let (para3, neg3, _t3) = r2star_qsm(&chi_total, &r2star, &mask, &p3);
229        let (para6, _neg6, _t6) = r2star_qsm(&chi_total, &r2star, &mask, &p6);
230        // voxel 0: χ_total=0, R2*/r = 1 at 3T -> χ+ = |χ−| = 0.5 (χ− = −0.5)
231        assert!((para3[0] - 0.5).abs() < 1e-9);
232        assert!((neg3[0] + 0.5).abs() < 1e-9);
233        // at 6T r = 548 -> R2*/r = 0.5 -> χ+ = 0.25
234        assert!((para6[0] - 0.25).abs() < 1e-9);
235        // masked-out voxel stays zero
236        assert_eq!(para3[1], 0.0);
237        assert_eq!(neg3[1], 0.0);
238    }
239
240    #[test]
241    fn r2star_fit_from_magnitude() {
242        // Mono-exponential decay with known R2*, single voxel.
243        let r2star_true = 40.0_f64;
244        let s0 = 1000.0_f64;
245        let te = vec![0.004, 0.012, 0.020, 0.028];
246        let mag: Vec<f64> = te.iter().map(|&t| s0 * (-r2star_true * t).exp()).collect();
247        let mask = vec![1u8];
248        let r2s = fit_r2star_weighted_loglinear(&mag, &te, &mask, 1);
249        assert!((r2s[0] - r2star_true).abs() / r2star_true < 1e-6, "R2* fit {}", r2s[0]);
250    }
251}