Skip to main content

qsm_core/utils/
r2primenet.rs

1//! R2PRIMEnet: learned R2* → R2′ conversion (inference needs the `onnx` feature).
2//!
3//! When no spin-echo acquisition provides a measured R2, [`r2prime`](super::epg::r2prime)
4//! (R2′ = R2* − R2) has nothing to subtract. R2PRIMEnet — the R2*→R2′ conversion network of
5//! the χ-sepnet pipeline (SNU-LIST) — fills that gap for the **GRE-only** condition: a 3D
6//! U-Net trained on paired R2*/R2′ data predicts the reversible component from the
7//! GRE-derived R2* alone, so the R2′-consuming χ-separation methods
8//! ([`chisepnet`](crate::separation::chisepnet), [`susep_net`](crate::separation::susep_net),
9//! [`hc_chisep`](crate::separation::hc_chisep), [`wavesep`](crate::separation::wavesep),
10//! [`chi_sep_medi`](crate::separation::chi_sep_medi),
11//! [`chi_sep_ilsqr`](crate::separation::chi_sep_ilsqr)) can run on a plain multi-echo GRE.
12//!
13//! The authors run the network on **192×192×128** single-channel patches as an overlapping
14//! sliding window over the whole volume, averaging the overlaps. Recipe (mirrors the
15//! authors' inference and the QSM-CI `r2primenet` submission): scale R2* by `Dr` into the
16//! network's ppm-equivalent channel, z-score by the training statistics, zero outside the
17//! mask, end-pad each dim up to the patch size, tile with a 0.75 stride plus a final flush
18//! patch, average overlaps, de-normalize, scale back by `Dr`, clip negatives to zero (the
19//! paper's convention), crop, and mask.
20//!
21//! `Dr` (114 Hz/ppm) is the network family's COSMOS-referenced relaxivity — the same constant
22//! [`ChiSepNetNorm`](crate::separation::ChiSepNetNorm) uses. The network was trained at that
23//! value, so changing it feeds off-distribution inputs.
24//!
25//! **Field strength.** The published weights are trained at **3 T** ([`TRAINED_B0_T`]) and the
26//! network takes no field strength as an input. R2* scales with B0, so 7 T R2* is far outside the
27//! training distribution — and what comes back is an ordinary-looking R2' map in Hz, not a NaN or
28//! an out-of-range value, so nothing downstream can tell it apart from a good one. [`r2primenet`]
29//! therefore **refuses** outside [`B0_TOLERANCE_T`] of 3 T rather than warning: a warning this
30//! crate cannot emit (it links no logger) would be a doc comment, and a wrong R2' propagates into
31//! every R2'-consuming χ-separation method. A caller that means it can set
32//! [`R2PrimeNetParams::ignore_field_mismatch`] and treat the result as uncalibrated.
33//!
34//! The SNU-LIST group also publishes a **7 T** variant that maps 7 T R2* to *3 T-equivalent* R2',
35//! which is what χ-sepnet expects downstream. It needs its own weights in the model registry and
36//! its own validation, so it is not here yet; the guard refuses 7 T input rather than pretending.
37//! That variant (Kim J. et al., arXiv:2410.12239, §2.2.1) was trained on 7 T R2* registered to
38//! 3 T R2' from the same subjects. The same paper's linear alternative, 3 T R2' ≈ 0.190 × 7 T R2*
39//! (§2.2.3, ROI regression, Fig. S2), was evaluated only as a comparison arm.
40//!
41//! **Training R2.** The R2' labels are R2* (ARLO) minus an R2 from StimFit dictionary matching
42//! of a 2D multi-echo SE with stimulated-echo correction, with negatives set to 0 (Kim et al.
43//! 2025, arXiv:2409.14030, §2.1.1). In QSM.rs the measured-R2 counterpart is
44//! [`r2_epg`](super::epg::r2_epg) followed by [`r2prime`](super::epg::r2prime).
45//!
46//! **R2\* fitting.** The training pipeline fitted R2* with ARLO, so
47//! [`r2primenet_from_magnitude`] uses [`arlo`](super::r2star::arlo). Supplying
48//! an R2* map fitted some other way is allowed but moves the input off-distribution.
49//!
50//! **Array orientation.** R2PRIMEnet shares χ-sepnet's training set and so its orientation
51//! convention: volumes ordered (A-P, L-R, I-S), slice axis along B0. As with
52//! [`chisepnet`](crate::separation::chisepnet), nothing enforces it — this function sees a
53//! [`Grid`](crate::grid::Grid), not an affine — and it matters even less here, because the
54//! mapping it learned is close to pointwise in R2*: on a 1 mm in-vivo volume every cardinal
55//! relabelling tried (in-plane quarter turn, L-R mirror, slice axis moved to A-P or to L-R)
56//! reproduced the untouched run at r ≥ 0.994 / NRMSE ≤ 6.1%, and agreement with the measured
57//! R2′ = R2* − R2 moved only from 0.892 to 0.888. Note that
58//! R2PRIMEnet is **not** covered by the pipeline's axial-only machinery
59//! ([`OrientationSupport`](crate::pipeline::config::OrientationSupport) hangs off the stage
60//! algorithm enums, and R2′ generation is an input-preparation step with no enum of its own), so
61//! a caller that resamples an oblique acquisition on account of the separation method it chose
62//! may still be running *this* network on the acquired grid.
63//!
64//! **Patch size.** The published graph declares a fixed 192×192×128 input, but it is fully
65//! convolutional (Conv/Relu/MaxPool/ConvTranspose/Concat only), so the hosted `r2primenet.onnx`
66//! has its spatial axes re-declared as dynamic — bit-identical at the authors' patch, and able
67//! to run smaller ones. [`R2PrimeNetParams::patch`] defaults to the authors' 192×192×128 and
68//! should stay there natively; a 32-bit host (WASM) cannot afford it — one 64-channel
69//! activation at that size is 1.2 GB — and runs 128×128×64 instead, which on the reference
70//! volume agrees with the authors' patch at corr 0.998 / NRMSE 2.9%. Patch dimensions must be
71//! multiples of 16 (four pooling levels) or the skip-connection concatenations misalign.
72//!
73//! Weights are not bundled; the caller passes the exported `r2primenet.onnx` bytes (see
74//! [`crate::models`]).
75//!
76//! Reference:
77//! Kim, M., Ji, S., Lee, J., et al. (2025). "χ-sepnet: Deep neural network for magnetic
78//! susceptibility source separation." Human Brain Mapping, 46(4):e70136 — R2PRIMEnet is its
79//! R2*→R2′ conversion network.
80//!
81//! # Agreement with the original
82//!
83//! `tests/models_onnx/separation.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T,
84//! across the brain's edge) against its original's verified QSM-CI entry, SNU-LIST's
85//! `240531_R2PRIMEnet.onnx` in QSM-CI's sliding window (`r2primenet`) (QSMxT/QSM-CI#316; the
86//! fixture is regenerated in Actions by `tests/data/dl_separation_qsmci_small.py`): given the same
87//! R2*, [`r2primenet`] matches it to NRMSE 2.9e-5 %, max |Δ| 4.5e-5 s⁻¹ (measured 2026-10-30):
88//! float32 rounding.
89//!
90//! The entry fits R2* itself with a stand-in for ARLO (log-linear least squares weighted by
91//! magnitude²), so the test gives [`r2primenet`] that fit's R2*. [`r2primenet_from_magnitude`] fits
92//! R2* with ARLO, as the toolbox's training data did (its normalisation file is
93//! `..._romeo_arlo.mat`); on the box the two R2* fits differ by 60 % NRMSE (r 0.89), and the R2′
94//! that follows by 112 %. That is the entry's R2* fit, not the network.
95
96// The parameters and limits are plain Rust and always built, so hosts can carry the settings
97// and check a patch without the networks; only inference needs `onnx`.
98#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
99
100#[cfg(feature = "onnx")]
101use crate::grid::Grid;
102#[cfg(feature = "onnx")]
103use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
104#[cfg(feature = "onnx")]
105use super::sliding::starts;
106
107/// The authors' patch: `(D, H, W)` = `(x, y, z)`.
108pub const AUTHORS_PATCH: (usize, usize, usize) = (192, 192, 128);
109
110/// A patch that fits a 32-bit (WASM) heap; see the module docs for what it costs.
111pub const WASM_PATCH: (usize, usize, usize) = (128, 128, 64);
112
113/// Four pooling levels, so every patch dimension must be a multiple of this. Built without `onnx`,
114/// like the other limits here, so a host can check a patch without the networks.
115pub const POOL_MULTIPLE: usize = 16;
116
117/// Field strength the published R2PRIMEnet weights were trained at, in Tesla (Kim 2025).
118pub const TRAINED_B0_T: f64 = 3.0;
119
120/// How far from [`TRAINED_B0_T`] an acquisition may sit and still be accepted, in Tesla.
121///
122/// Wide enough for everything sold as "3 T" — 2.89 T (Siemens) through 3.0 T — and narrow enough
123/// that 1.5 T and 7 T are refused.
124pub const B0_TOLERANCE_T: f64 = 0.3;
125
126/// Training z-score constants for R2PRIMEnet
127/// (`xsepnet_train_patch_norm_factor_inplane_largedegree_romeo_arlo.mat`): each field is
128/// `(mean, std)`, in `Dr`-scaled (ppm-equivalent) units. The input is normalized
129/// `(r2star/dr - mean)/std`; the output de-normalized `(y*std + mean)*dr`.
130#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
131#[derive(Clone, Copy, Debug)]
132pub struct R2PrimeNetNorm {
133    /// `(mean, std)` of the `Dr`-scaled R2* input channel.
134    pub r2star: (f64, f64),
135    /// `(mean, std)` of the `Dr`-scaled R2′ output channel.
136    pub r2prime: (f64, f64),
137    /// Relaxivity (Hz/ppm) scaling R2*/R2′ into the network's ppm-equivalent units.
138    pub dr: f64,
139}
140
141/// Inference parameters for [`r2primenet`].
142#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
143#[derive(Clone, Copy, Debug)]
144pub struct R2PrimeNetParams {
145    /// Sliding-window patch `(D, H, W)`; each dimension a multiple of 16. Defaults to
146    /// [`AUTHORS_PATCH`]; WASM hosts pass [`WASM_PATCH`].
147    pub patch: (usize, usize, usize),
148    /// Run even when the `b0_tesla` passed to [`r2primenet`] is outside [`B0_TOLERANCE_T`] of
149    /// [`TRAINED_B0_T`]. Off by default; the result is then uncalibrated, and a caller that
150    /// sets it owes its own users that caveat.
151    pub ignore_field_mismatch: bool,
152}
153
154impl Default for R2PrimeNetParams {
155    fn default() -> Self {
156        Self { patch: AUTHORS_PATCH, ignore_field_mismatch: false }
157    }
158}
159
160impl R2PrimeNetNorm {
161    /// De-normalise one network output to R2′ in Hz, set to 0 where negative (see
162    /// [`r2primenet`]).
163    pub(crate) fn r2prime_hz(&self, z: f64) -> f64 {
164        ((z * self.r2prime.1 + self.r2prime.0) * self.dr).max(0.0)
165    }
166}
167
168impl Default for R2PrimeNetNorm {
169    /// Constants from `xsepnet_train_patch_norm_factor_inplane_largedegree_romeo_arlo.mat`
170    /// — the same normalization file χ-sepnet uses.
171    fn default() -> Self {
172        Self {
173            r2star: (0.15865366160869598, 0.07501979172229767),
174            r2prime: (0.05141879618167877, 0.06977531313896179),
175            dr: 114.0,
176        }
177    }
178}
179
180/// Run R2PRIMEnet: predict R2′ (Hz) from an R2* map (Hz).
181///
182/// # Arguments
183/// * `r2star` — R2* map in **Hz**, column-major `(nx,ny,nz)`.
184/// * `mask` — binary brain mask (same layout).
185/// * `grid` — volume dimensions and voxel sizes.
186/// * `b0_tesla` — field strength the R2* map was acquired at, in **Tesla**. An argument rather
187///   than a [`R2PrimeNetParams`] field precisely because it must not have a default: a 3 T that
188///   nobody chose is indistinguishable from a 3 T that nobody checked, which is the whole of the
189///   failure this guards. See the module docs.
190/// * `model_onnx` — bytes of the exported `r2primenet.onnx` (1→1 chan, dynamic spatial axes).
191/// * `norm` — training normalization constants.
192/// * `params` — patch size and the field-strength override; see [`R2PrimeNetParams`].
193/// * `progress` — progress callback `(patches_done, patches_total)`.
194///
195/// # Returns
196/// R2′ in **Hz**, clipped at zero and restricted to `mask`, in the same layout.
197///
198/// **Deviation:** the authors' `test.py` (χ-sepnet-R2*) passes R2PRIMEnet's output on to
199/// χ-sepnet without truncating it. Here negative R2′ is set to 0, since a negative reversible
200/// decay rate has no physical meaning; every non-negative value is the network's unchanged.
201/// QSM.rs clamps R2′ the same way wherever it makes it (this and
202/// [`r2prime`](crate::relaxometry::r2prime)), so every consumer, χ-sepnet included, gets the
203/// clamped map. The effect on χ-sepnet is negligible: on two travelling-head scans (Philips
204/// and GE, 3 T) 0.3–0.4% of brain voxels were clamped (all above −1 Hz), χ+ and χ− changed by at
205/// most 1.5 ppb (median < 0.001 ppb), and mean χ+/χ− in deep grey matter by under 0.001 ppb,
206/// about 1e-5 of its value.
207///
208/// # Errors
209/// [`OnnxError::Domain`] when `b0_tesla` is more than [`B0_TOLERANCE_T`] from [`TRAINED_B0_T`]
210/// and [`R2PrimeNetParams::ignore_field_mismatch`] is not set — checked before anything is
211/// allocated or loaded.
212///
213/// # Panics
214/// If a patch dimension is not a multiple of 16 (the network's four pooling levels).
215#[cfg(feature = "onnx")]
216pub fn r2primenet(
217    r2star: &[f64],
218    mask: &[u8],
219    grid: &Grid,
220    b0_tesla: f64,
221    model_onnx: &[u8],
222    norm: &R2PrimeNetNorm,
223    params: &R2PrimeNetParams,
224    mut progress: impl FnMut(usize, usize),
225) -> Result<Vec<f64>, OnnxError> {
226    if !params.ignore_field_mismatch && (b0_tesla - TRAINED_B0_T).abs() > B0_TOLERANCE_T {
227        return Err(OnnxError::Domain(format!(
228            "R2PRIMEnet's weights are trained at {TRAINED_B0_T} T and this R2* map is from \
229             {b0_tesla} T. R2* scales with field strength, so the input is outside the training \
230             distribution and the predicted R2' would be a well-formed wrong answer rather than a \
231             visibly bad one. Measure R2' from a spin-echo acquisition (R2' = R2* - R2), or set \
232             R2PrimeNetParams::ignore_field_mismatch to run anyway and treat the R2' as \
233             uncalibrated."
234        )));
235    }
236    let (nx, ny, nz) = grid.dims;
237    let n = nx * ny * nz;
238    assert_eq!(r2star.len(), n, "r2star length must match grid");
239    assert_eq!(mask.len(), n, "mask length must match grid");
240    let (pd, ph, pw) = params.patch;
241    for (axis, d) in [("D", pd), ("H", ph), ("W", pw)] {
242        assert!(
243            d > 0 && d % POOL_MULTIPLE == 0,
244            "patch {axis} = {d} must be a positive multiple of {POOL_MULTIPLE} (four pooling levels)"
245        );
246    }
247
248    // End-pad each dim up to at least the patch size (col-major padded volume).
249    let (px, py, pz) = (nx.max(pd), ny.max(ph), nz.max(pw));
250    let mut vol = vec![0.0f32; px * py * pz];
251    for z in 0..nz {
252        for y in 0..ny {
253            for x in 0..nx {
254                let i = x + nx * (y + ny * z);
255                if mask[i] == 0 {
256                    continue;
257                }
258                let p = x + px * (y + py * z);
259                vol[p] = ((r2star[i] / norm.dr - norm.r2star.0) / norm.r2star.1) as f32;
260            }
261        }
262    }
263
264    let model = OnnxModel::load(model_onnx)?;
265    let plane = pd * ph * pw;
266    let mut acc = vec![0.0f64; px * py * pz];
267    let mut wsum = vec![0.0f64; px * py * pz];
268
269    let (sx, sy, sz) = (starts(px, pd), starts(py, ph), starts(pz, pw));
270    let total = sx.len() * sy.len() * sz.len();
271    let mut done = 0usize;
272    progress(0, total);
273    for &x0 in &sx {
274        for &y0 in &sy {
275            for &z0 in &sz {
276                // Build the [1,1,pd,ph,pw] NCDHW patch (D=x, H=y, W=z).
277                let mut buf = vec![0.0f32; plane];
278                for i in 0..pd {
279                    for j in 0..ph {
280                        for k in 0..pw {
281                            let p = (x0 + i) + px * ((y0 + j) + py * (z0 + k));
282                            buf[(i * ph + j) * pw + k] = vol[p];
283                        }
284                    }
285                }
286                let out = model.run_single(&Tensor::new(vec![1, 1, pd, ph, pw], buf))?;
287                if out.data.len() < plane {
288                    return Err(OnnxError::Run("expected 1-channel output".into()));
289                }
290                for i in 0..pd {
291                    for j in 0..ph {
292                        for k in 0..pw {
293                            let p = (x0 + i) + px * ((y0 + j) + py * (z0 + k));
294                            acc[p] += out.data[(i * ph + j) * pw + k] as f64;
295                            wsum[p] += 1.0;
296                        }
297                    }
298                }
299                done += 1;
300                progress(done, total);
301            }
302        }
303    }
304
305    // Average overlaps, de-normalize to Hz, clip negatives, crop to (nx,ny,nz), mask.
306    let mut r2prime = vec![0.0f64; n];
307    for z in 0..nz {
308        for y in 0..ny {
309            for x in 0..nx {
310                let i = x + nx * (y + ny * z);
311                if mask[i] == 0 {
312                    continue;
313                }
314                let p = x + px * (y + py * z);
315                let w = wsum[p].max(1.0);
316                r2prime[i] = norm.r2prime_hz(acc[p] / w);
317            }
318        }
319    }
320    Ok(r2prime)
321}
322
323/// Convenience: fit R2* from multi-echo magnitude with ARLO, then run [`r2primenet`].
324///
325/// `magnitude` is voxel-major `(n_voxels, n_echoes)` and `echo_times` are in **seconds**
326/// (ARLO requires ≥3 increasing echo times; other echo times are [`OnnxError::Domain`]); R2* is 0 outside `mask`. Returns `(r2prime_hz, r2star_hz)` so the caller
327/// can keep the intermediate R2* map.
328#[cfg(feature = "onnx")]
329pub fn r2primenet_from_magnitude(
330    magnitude: &[f64],
331    echo_times: &[f64],
332    mask: &[u8],
333    grid: &Grid,
334    b0_tesla: f64,
335    model_onnx: &[u8],
336    norm: &R2PrimeNetNorm,
337    params: &R2PrimeNetParams,
338    progress: impl FnMut(usize, usize),
339) -> Result<(Vec<f64>, Vec<f64>), OnnxError> {
340    // Echo times ARLO cannot use are refused like any other input the network cannot take.
341    let (n, ne) = (grid.n_total(), echo_times.len());
342    if magnitude.len() != n * ne {
343        return Err(OnnxError::Domain(format!(
344            "R2* fit: magnitude has {} values, expected {}", magnitude.len(), n * ne
345        )));
346    }
347    // arlo takes the echoes stacked echo-slowest
348    let echo_slowest: Vec<f64> = (0..ne).flat_map(|e| (0..n).map(move |v| magnitude[v * ne + e])).collect();
349    let mut r2star = super::r2star::arlo(&echo_slowest, echo_times, n)
350        .map_err(|e| OnnxError::Domain(format!("R2* fit: {e}")))?;
351    for (r, &m) in r2star.iter_mut().zip(mask) {
352        if m == 0 {
353            *r = 0.0;
354        }
355    }
356    let r2prime = r2primenet(&r2star, mask, grid, b0_tesla, model_onnx, norm, params, progress)?;
357    Ok((r2prime, r2star))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    /// Both shipped patches are legal for a four-pool-level network.
365    #[test]
366    fn patches_are_pool_aligned() {
367        for (d, h, w) in [AUTHORS_PATCH, WASM_PATCH] {
368            for v in [d, h, w] {
369                assert_eq!(v % POOL_MULTIPLE, 0, "{v} must be a multiple of {POOL_MULTIPLE}");
370            }
371        }
372        assert_eq!(R2PrimeNetParams::default().patch, AUTHORS_PATCH);
373    }
374
375    /// Negative de-normalised R2′ is 0; non-negative R2′ is the plain de-normalisation.
376    #[test]
377    fn r2prime_is_never_negative() {
378        let norm = R2PrimeNetNorm::default();
379        let z0 = -norm.r2prime.0 / norm.r2prime.1; // de-normalises to exactly 0 Hz
380        assert_eq!(norm.r2prime_hz(z0 - 1.0), 0.0);
381        let z = z0 + 0.7;
382        let want = (z * norm.r2prime.1 + norm.r2prime.0) * norm.dr;
383        assert!(want > 0.0);
384        assert_eq!(norm.r2prime_hz(z), want);
385    }
386
387    /// The normalization round-trips: de-normalizing a normalized R2* with the *input*
388    /// constants must return the original, which pins the Dr scaling on both sides.
389    #[test]
390    fn norm_round_trips() {
391        let norm = R2PrimeNetNorm::default();
392        for hz in [0.0, 12.5, 40.0, 137.0] {
393            let z = (hz / norm.dr - norm.r2star.0) / norm.r2star.1;
394            let back = (z * norm.r2star.1 + norm.r2star.0) * norm.dr;
395            assert!((back - hz).abs() < 1e-9, "{hz} -> {z} -> {back}");
396        }
397    }
398}