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, ¶ms);
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}