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}