qsm_core/separation/chisepnet.rs
1//! χ-sepnet deep-learning χ-separation (`onnx` feature).
2//!
3//! Two entry points. [`chisepnet_toolbox`] is the authors' route, the χ-separation toolbox
4//! v1.1.3's `chi_sepnet_general` (see *The authors' route* below), and the pipeline's default
5//! ([`ChiSepNetRoute::Toolbox`]). [`chisepnet`] is the network alone on the acquired grid with the
6//! caller's QSM ([`ChiSepNetRoute::Native`]), described first.
7//!
8//! χ-sepnet (SNU-LIST) is a 3D U-Net that maps a 3-channel patch — [QSM (χ_total,
9//! ppm), local field (ppm), R2′/Dr] each z-scored by training statistics — to
10//! paramagnetic (χ+) and diamagnetic (χ−) source magnitudes (z-scored). The network
11//! is a fixed **192×192×128** patch; we run it as an overlapping sliding window over
12//! the whole volume and average the overlaps, then de-normalize.
13//!
14//! Recipe (mirrors the SNU-LIST / QSM-CI `recon.py`): z-score each channel, zero
15//! outside the mask, end-pad each dim up to the patch size, tile with a 0.75 stride
16//! plus a final flush patch, average overlaps, de-normalize, crop, and mask. `Dr`
17//! (114 Hz/ppm, the network's COSMOS-referenced relaxivity) scales R2′ into the
18//! network's ppm-equivalent input channel. Negative de-normalised magnitudes are set to 0, as the
19//! authors' `test.py` does ("zero-truncation"). We return χ− as a signed (≤ 0) value to
20//! match the crate's separation convention `(chi_pos ≥ 0, chi_neg ≤ 0, chi_total)`.
21//!
22//! **Array orientation.** The network takes no B0 direction, so whatever orientation the caller
23//! hands it is the one it reconstructs in. Kim et al. (Hum Brain Mapp 2025,
24//! doi:10.1002/hbm.70136) say what that should be, and the two halves are not equally strict.
25//! The training scans were acquired so that "the imaging slab was axial so that the z-axis was
26//! oriented along the B0 field", and the patches were then augmented by rotating "only … within
27//! the plane perpendicular to B0 (degree: −90° to +90°) to avoid complication from B0 orientation
28//! dependent R2* in white matter". So the slice axis carries a real requirement, while the
29//! in-plane order is deliberately covered by augmentation across the full quarter turn.
30//!
31//! The complete order is (A-P, L-R, I-S). The in-plane half of that is not in the paper: it comes
32//! from the brain mask shipped in `Data/test_file.mat` — a dummy file, but the mask is real, and
33//! axis 1 is the mirror-symmetry axis in all six COSMOS orientations (Dice 0.87-0.97 against
34//! 0.78-0.88) while axis 0 is the longest — corroborated by the authors' own axis naming in
35//! `train_data_patch.py`. The left/right *sign* stays undetermined, and the numbers below say it
36//! does not matter. Nothing in the reference code enforces any of this: neither the toolbox's
37//! `Chisep_script.m`, nor the authors' `test.py`, nor the QSM-CI `recon.py` ever permutes or flips
38//! an axis, and neither does this function — it is handed a [`Grid`] (dims and voxel sizes), not
39//! an affine, so the caller owns the orientation.
40//!
41//! That turns out to cost very little. Measured on the QSM Reconstruction Challenge 2.0 head
42//! phantom (QSM-CI's χ-separation dataset, which builds χ+/χ− on that head model): correlation
43//! against the ground-truth source maps, one 192³ pass, the volume relabelled and the result
44//! relabelled back.
45//!
46//! | array orientation | χ+ r | χ− r |
47//! |---|---|---|
48//! | RAS — what QSMxT feeds today | 0.9480 | 0.8072 |
49//! | all 8 in-plane relabellings (slice axis still along B0) | 0.9477–0.9483 | 0.8053–0.8078 |
50//! | slice axis moved to A-P (coronal-like) | 0.9471 | 0.7914 |
51//! | slice axis moved to L-R (sagittal-like) | 0.9485 | 0.7855 |
52//!
53//! which is what the ±90° in-plane augmentation predicts: the in-plane order is immaterial, and
54//! even putting the slice axis across B0 costs only about 0.02 of χ− correlation and nothing of
55//! χ+, less than the training recipe alone would lead you to expect. Two of the
56//! three input channels determine χ± algebraically and carry no orientation at all
57//! (χ+ + χ− = χ_total, χ+ − χ− ≈ R2′/Dr), which is most of why. The outputs are not *identical*
58//! across orientations — two in-plane orientations of the same in-vivo volume differ by ~12%
59//! NRMSE, against a ~3% floor from a pure 16-voxel translation — but neither is nearer the truth,
60//! so there is no orientation to prefer. Running on an oblique acquisition is a different
61//! question, and still wants [`resample_to_axial`](crate::geometry::resample_to_axial): a
62//! relabelling can only fix axes that are already cardinal.
63//!
64//! **Patch size.** The published graph declares that patch as a fixed input shape, but it is
65//! fully convolutional (Conv/Relu/MaxPool/ConvTranspose/Concat only), so the hosted
66//! `chi-sepnet.onnx` has its spatial axes re-declared as dynamic — bit-identical at the
67//! authors' patch, and able to run smaller ones. [`ChiSepNetParams::patch`] defaults to the
68//! authors' 192×192×128; a 32-bit host (WASM) cannot afford it — one 64-channel activation at
69//! that size is 1.2 GB — and uses [`WASM_PATCH`] instead. Patch dimensions must be multiples
70//! of 16 (four pooling levels) or the skip-connection concatenations misalign.
71//!
72//! Weights are not bundled; the caller passes the exported `chi-sepnet.onnx` bytes.
73//!
74//! # Parity
75//!
76//! Measured against QSM-CI's `chisepnet` entry (the SNU-LIST toolbox's `240904_xsepnet.onnx` and
77//! its normalisation `.mat`, run by QSM-CI's `recon.py`) on a 36×32×28 in-vivo box of
78//! `prisma-bridge-run1`, at the authors' 192×192×128 patch (QSM.rs#330):
79//!
80//! - The hosted `chi-sepnet.onnx` (spatial axes re-declared dynamic) gives exactly the toolbox
81//! graph's outputs on the same patch: max |Δ| 0.
82//! - The normalisation constants above are the `.mat`'s values.
83//! - The only difference is zero-truncation. This function sets negative de-normalised
84//! magnitudes to 0, as the authors' `test.py` does. QSM-CI's `recon.py` keeps them: on this box
85//! 2 487 of 19 996 χ+ voxels and 5 χ− voxels are negative. Against the entry as it runs, that is
86//! 1.73 % NRMSE for χ+ and 0.045 % for χ−. Against the entry's output truncated the same way it
87//! is 0.0000 % (max |Δ| 1.4e-8 ppm). QSMxT/QSM-CI#333 adds the truncation to the entry.
88//! - The SNU MATLAB toolbox truncates too. Its `chi_sepnet_general.p` → `chi_sepnet.p` (v1.1.3)
89//! was run as a black box on Bunya, MATLAB R2023b, 2026-10-30. The ONNX import was replaced by a
90//! one-layer network with constant z-scored outputs. χ+ = −2 and χ− = −3 came back as 0 and 0.
91//! χ+ = +2 came back as 2·0.03526 + 0.02591 = 0.0964. χ− = +0.5 came back as 0.0407, with
92//! χ_total = χ+ − |χ−|. So negative de-normalised magnitudes are set to 0, and positive ones pass
93//! through unchanged.
94//! - The same run shows that the toolbox's χ-sepnet path works differently from this function.
95//! It takes the local field in Hz and R2′ (not a QSM). It makes the QSM channel itself with its
96//! own `240904_QSMnet.onnx`, after rotating to B0 (`CalculateRotMat`) and resampling to 1 mm
97//! (`interp3`): [`chisepnet_toolbox`], *The authors' route* below.
98//!
99//! # Reference pipeline
100//!
101//! Kim et al. 2025 (arXiv:2409.14030, §2.1.1 and §2.2):
102//!
103//! - **Training data:** 3 T (Siemens Tim Trio), 1 × 1 × 1 mm, 6 echoes, TE 7.70–32.85 ms
104//! (ΔTE ≈ 5 ms), six head orientations, with 64³ training patches.
105//! - **Field chain:** magnitude by sum of squares, phase by MCPC-3D. Mask by FSL BET on the
106//! combined magnitude. Each echo unwrapped with ROMEO, background removal by V-SHARP with "a
107//! spherical mean value size of 20" per echo, then weighted echo averaging (Wu 2012) into the
108//! local field. The toolbox driver instead runs V-SHARP `smvsize = 12` after echo averaging
109//! (`Chisep_script.m:113-139`); the two disagree (open question).
110//! - **QSM channel:** QSMnetME, a QSMnet trained on multi-echo data, applied to the local field
111//! (§2.2.2): [`crate::inversion::qsmnetme`], which [`chisepnet_toolbox`] runs.
112//! - **R2' channel:** R2* by ARLO; R2 by StimFit dictionary matching of a 2D multi-echo SE with
113//! stimulated-echo correction (QSM.rs: `r2_epg`); R2' = R2* − R2 with negatives set to 0. With
114//! GRE only, R2PRIMEnet ([`crate::relaxometry`]) predicts R2'.
115//! - **Resolution:** trained at 1 mm isotropic. The toolbox either interpolates the inputs to
116//! 1 mm and returns 1 mm maps, or runs a resolution-generalisation pipeline for QSMnetME
117//! (`Chisep_script.m:188-195`, `chi_sepnet_general` vs `..._wResolGen`). The README says the
118//! networks handle resolutions above 0.6 mm. [`chisepnet_toolbox`] runs the first (the
119//! script's `resgen = false`; despite the script's comment it returns its maps on the acquired
120//! grid); [`chisepnet`] runs on the native grid with neither. The resolution-generalisation
121//! branch (`chi_sepnet_general_new_wResolGen`) is not ported.
122//! - **Field strength:** 3 T only. At 7 T the authors keep this 3 T network and feed it the 7 T
123//! local field, a QSMnetME QSM and a *3 T-equivalent* R2' from R2PRIMEnet7T (Kim J. et al.,
124//! arXiv:2410.12239, §2.2.1). They name the choice of D_r at 7 T as unresolved (§4). Feeding a
125//! 7 T measured R2' here is off-distribution.
126//! - **Orientation:** "For non-axial input data, the orientation was rotated such that the B0
127//! orientation is along the z-direction" (§2.1.2), as covered above.
128//!
129//! # Agreement with the original
130//!
131//! `tests/models_onnx/separation.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T,
132//! across the brain's edge) against its original's verified QSM-CI entry, SNU-LIST's
133//! `240904_xsepnet.onnx` in QSM-CI's sliding window (`chisepnet`) (QSMxT/QSM-CI#316; the fixture is
134//! regenerated in Actions by `tests/data/dl_separation_qsmci_small.py`, from the entry with the
135//! authors' zero-truncation, QSMxT/QSM-CI#333): at the authors' 192×192×128 patch, **χ+ NRMSE
136//! 5.8e-5 %**, χ− 3.4e-5 % (max |Δ| 2.4e-7 ppm; measured 2026-11-02), float32 rounding. The 1.7 % /
137//! 0.045 % first measured was the entry's missing truncation (above; astewartau/QSM.rs#330). The
138//! patch matters for parity: a volume smaller than the patch is zero-padded up to it, and the
139//! network's activations in that padding differ from the convolutions' own zero padding, so a
140//! smaller [`ChiSepNetParams::patch`] changes the result near the volume's far edges.
141//!
142//! # The authors' route
143//!
144//! [`chisepnet_toolbox`] is `chi_sepnet_general` of the toolbox v1.1.3, which `Chisep_script.m`
145//! calls with `resgen = false` (l. 194). The P-code was probed as a black box in licensed MATLAB
146//! R2023b (QSM.rs#377, 2026-11-03; method in [`crate::utils::chisep_frame`]): the inputs go into
147//! the training frame (B0 along the third axis, 1 mm, centred, padded to multiples of 16), QSMnetME
148//! makes the QSM channel there ([`crate::inversion::qsmnetme`]), R2′ is the measured map or, for
149//! R2*, R2PRIMEnet's output, χ-sepnet runs once over the whole frame, and χ± come back onto the
150//! acquired grid. The steps and what was measured for each are listed on [`chisepnet_toolbox`].
151//! Findings that settle earlier open points:
152//!
153//! - **Output grid.** The toolbox returns its maps on the acquired grid (`interp3 'spline'` at
154//! `R⁻¹·x`), not at 1 mm as the script's comment says, so no resampling of our own is involved.
155//! - **R2* is not clipped** before R2PRIMEnet, and R2PRIMEnet's output reaches χ-sepnet unclipped
156//! (the toolbox clips its de-normalised copy, which it does not use).
157//! - **Whole volume, no tiling**: the toolbox replaces each network's input layer to fit the frame.
158//! [`ChiSepNetParams::frame_patch`] tiles instead, for 32-bit hosts (not the authors').
159//! - **B0 direction**: the toolbox does not normalise it; a non-unit vector gives it a slightly
160//! non-orthogonal rotation. QSM.rs normalises first (identical for unit vectors). For B0 = −ẑ the
161//! toolbox's rotation is `NaN`; QSM.rs takes the half turn about the first axis.
162//! - **7 T**: the authors feed χ-sepnet a 3 T-equivalent R2′ from R2PRIMEnet7T (toolbox v1.2's
163//! `R2PNET_7T.onnx`, only through SNU's form, not in v1.1.3 and not available to us). So the
164//! R2* route refuses fields outside 3 T ± [`B0_TOLERANCE_T`](crate::relaxometry::B0_TOLERANCE_T),
165//! unless [`ChiSepNetParams::ignore_field_mismatch`]; a measured R2′ is taken at any field.
166//! - **Not part of the route**: `Chisep_script.m` Tukey-windows the complex multi-echo data in
167//! k-space before everything (`tukey_fac` 0.4 for Siemens and GE, 0 for Philips, l. 69-73), so its
168//! mask, R2* fit and phase see windowed data. That happens upstream of the field and R2* maps
169//! this function takes, so it is the caller's: [`crate::utils::tukey_window`] ports it, and the
170//! rules `chi-sepnet.tukey-siemens-ge` and `chi-sepnet.tukey-philips` suggest the authors' value
171//! by manufacturer on this route, as the authors' setting (`Kind::Reference`: shown, never
172//! preselected).
173//!
174//! **Agreement with the toolbox.** On the probe volumes (64×60×48 at 0.8×0.9×1.1 mm with B0 along
175//! (0.1, 0.2, 0.97)/|·| and R2*, and at 1.1×0.7×0.9 mm with B0 along (−0.15, 0.05, 0.95)/|·| and R2′;
176//! MATLAB R2023b with the networks computed by ONNX Runtime), [`chisepnet_toolbox`] matches the
177//! toolbox's χ+ to 5.0e-5 % / 5.2e-5 % NRMSE and χ− to 5.0e-5 % / 5.1e-5 % (max |Δ| 1.8e-7 ppm):
178//! float32 rounding, tract against ONNX Runtime. QSM-CI's Python reproduction
179//! (`algorithms/chisepnet/chisep_route.py`) matches the toolbox to 1.4e-5 to 2.1e-5 %.
180//! `tests/models_onnx/snu_route.rs` checks [`chisepnet_toolbox`] (R2′ and R2*) and
181//! [`crate::inversion::qsmnetme`] against that reproduction on the in-vivo box, as acquired and
182//! with a tilted, anisotropic geometry (`tests/data/dl_snu_route_small.py`).
183//!
184//! # Pipeline rules
185//!
186//! The facts above that a configuration can depart from are published as rules in
187//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `chi-sep.mask-bet`, `chi-sep.unwrap-romeo`, `chi-sep.echo-combination`, `chi-sep.echo-weights`, `chi-sep.bg-vsharp`, `chi-sepnet.min-resolution`, `chi-sepnet.tukey-siemens-ge`, `chi-sepnet.tukey-philips`.
188
189// The parameters are plain Rust, so configs can carry them; only inference needs `onnx`.
190#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
191
192#[cfg(feature = "onnx")]
193use crate::grid::Grid;
194#[cfg(feature = "onnx")]
195use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
196#[cfg(feature = "onnx")]
197use crate::utils::sliding::starts;
198
199/// The authors' patch: `(D, H, W)` = `(x, y, z)`.
200pub const AUTHORS_PATCH: (usize, usize, usize) = (192, 192, 128);
201
202/// A patch that fits a 32-bit (WASM) heap. Peak memory, measured natively (single-threaded) on a
203/// 184×256×128 volume: 1.7 GB at this patch, 4.5 GB at [`AUTHORS_PATCH`]. On the QSM-CI
204/// χ-separation phantom it agrees with the authors' patch to r ≥ 0.996 on both maps on
205/// [`ChiSepNetRoute::Native`]; as [`ChiSepNetParams::frame_patch`] on the default
206/// [`ChiSepNetRoute::Toolbox`] (QSMnetME and χ-sepnet both windowed) it agrees with the
207/// whole-frame passes to r = 0.990 (χ+) / 0.983 (χ−) (`tests/chisep_xsepnet.rs`).
208pub const WASM_PATCH: (usize, usize, usize) = (128, 128, 64);
209
210/// Four pooling levels, so every patch dimension must be a multiple of this. Public (and built
211/// without `onnx`) so a host can check a patch, or state the rule, without restating the number.
212pub const POOL_MULTIPLE: usize = 16;
213
214/// Which route χ-sepnet's inputs take ([`ChiSepNetParams::route`]).
215#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
216#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
217pub enum ChiSepNetRoute {
218 /// The authors' route, `chi_sepnet_general` of the χ-separation toolbox v1.1.3
219 /// ([`chisepnet_toolbox`]): the inputs resampled into the training frame (B0 along the third
220 /// axis, 1 mm), the QSM channel made by QSMnetME there, R2′ from R2PRIMEnet when only R2* is
221 /// given, the whole volume in one pass, and the maps resampled back. The default.
222 #[default]
223 Toolbox,
224 /// The network alone on the acquired grid ([`chisepnet`]): the caller's QSM and R2′, a
225 /// sliding window of [`ChiSepNetParams::patch`], no resampling. QSM-CI's first χ-sepnet entry
226 /// ran it this way (with the ground-truth χ as the QSM).
227 Native,
228}
229
230impl ChiSepNetRoute {
231 /// Stable id: `"toolbox"` or `"native"` (the pipeline rules' `separation.chi_sepnet.route`).
232 pub fn id(self) -> &'static str {
233 match self {
234 ChiSepNetRoute::Toolbox => "toolbox",
235 ChiSepNetRoute::Native => "native",
236 }
237 }
238}
239
240/// Inference parameters for [`chisepnet`] and [`chisepnet_toolbox`].
241#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243pub struct ChiSepNetParams {
244 /// The input route; see [`ChiSepNetRoute`]. Defaults to the authors' ([`ChiSepNetRoute::Toolbox`]).
245 pub route: ChiSepNetRoute,
246 /// [`ChiSepNetRoute::Native`]'s sliding-window patch `(D, H, W)`; each dimension a multiple of
247 /// 16. Defaults to [`AUTHORS_PATCH`]; WASM hosts pass [`WASM_PATCH`].
248 pub patch: (usize, usize, usize),
249 /// [`ChiSepNetRoute::Toolbox`]: `None` (default) runs each network on the whole training-frame
250 /// volume in one pass, as the toolbox does; `Some(patch)` runs a sliding window of that patch
251 /// instead, for hosts that cannot hold the whole volume's activations (not the authors').
252 pub frame_patch: Option<(usize, usize, usize)>,
253 /// [`ChiSepNetRoute::Toolbox`] given R2*: run R2PRIMEnet even when the field strength is
254 /// outside [`B0_TOLERANCE_T`](crate::relaxometry::B0_TOLERANCE_T) of its 3 T training (see
255 /// [`R2PrimeNetParams::ignore_field_mismatch`](crate::relaxometry::R2PrimeNetParams)). Off
256 /// by default.
257 pub ignore_field_mismatch: bool,
258}
259
260impl Default for ChiSepNetParams {
261 fn default() -> Self {
262 Self { route: ChiSepNetRoute::Toolbox, patch: AUTHORS_PATCH, frame_patch: None, ignore_field_mismatch: false }
263 }
264}
265
266impl ChiSepNetParams {
267 /// Settings for a 32-bit (WASM) heap: the defaults with [`WASM_PATCH`] for both routes.
268 pub fn low_memory() -> Self {
269 Self { patch: WASM_PATCH, frame_patch: Some(WASM_PATCH), ..Self::default() }
270 }
271
272 /// Check the patches: every dimension a positive multiple of 16 (the network's four pooling
273 /// levels). [`chisepnet`] panics on what this refuses.
274 pub fn validate(&self) -> Result<(), String> {
275 let check = |name: &str, (d, h, w): (usize, usize, usize)| -> Result<(), String> {
276 for (axis, v) in [("D", d), ("H", h), ("W", w)] {
277 if v == 0 || v % POOL_MULTIPLE != 0 {
278 return Err(format!(
279 "χ-sepnet {name} {axis} = {v} must be a positive multiple of {POOL_MULTIPLE} \
280 (four pooling levels)"
281 ));
282 }
283 }
284 Ok(())
285 };
286 check("patch", self.patch)?;
287 if let Some(p) = self.frame_patch {
288 check("frame patch", p)?;
289 }
290 Ok(())
291 }
292}
293
294/// Training z-score constants for χ-sepnet (`xsepnet_train_patch_norm_factor…mat`):
295/// each field is `(mean, std)`. Inputs are normalized `(x-mean)/std`; outputs
296/// de-normalized `y*std + mean`. `dr` scales R2′ (Hz) into the ppm-equivalent
297/// channel `(r2prime/dr - mean)/std`.
298#[derive(Clone, Copy, Debug)]
299pub struct ChiSepNetNorm {
300 pub qsm: (f64, f64),
301 pub field: (f64, f64),
302 pub r2prime: (f64, f64),
303 pub chi_pos: (f64, f64),
304 pub chi_neg: (f64, f64),
305 pub dr: f64,
306}
307
308impl Default for ChiSepNetNorm {
309 /// Constants from `xsepnet_train_patch_norm_factor_inplane_largedegree_romeo_arlo.mat`.
310 fn default() -> Self {
311 Self {
312 qsm: (-0.0013402086915448308, 0.031504031270742416),
313 field: (-0.0026045702397823334, 0.010849659331142902),
314 r2prime: (0.05141879618167877, 0.06977531313896179),
315 chi_pos: (0.025912819430232048, 0.03526417911052704),
316 chi_neg: (0.026700690388679504, 0.027936099097132683),
317 dr: 114.0,
318 }
319 }
320}
321
322/// Run χ-sepnet χ-separation.
323///
324/// * `local_field_ppm`, `qsm` (χ_total, ppm), `r2prime` (Hz) — column-major `(nx,ny,nz)`.
325/// * `mask` — binary brain mask (same layout).
326/// * `model_onnx` — bytes of the exported `chi-sepnet.onnx` (192×192×128, 3→2 chan).
327/// * `norm` — training normalization constants.
328/// * `params` — patch size; see [`ChiSepNetParams`].
329///
330/// Returns `(chi_pos ≥ 0, chi_neg ≤ 0, chi_total = chi_pos + chi_neg)` in ppm,
331/// masked, in the same layout.
332///
333/// # Panics
334/// If a patch dimension is not a multiple of 16 (the network's four pooling levels).
335#[cfg(feature = "onnx")]
336#[allow(clippy::too_many_arguments)]
337pub fn chisepnet(
338 local_field_ppm: &[f64],
339 qsm: &[f64],
340 r2prime: &[f64],
341 mask: &[u8],
342 grid: &Grid,
343 model_onnx: &[u8],
344 norm: &ChiSepNetNorm,
345 params: &ChiSepNetParams,
346) -> Result<super::ChiSepMaps, OnnxError> {
347 let (nx, ny, nz) = grid.dims;
348 if let Err(e) = params.validate() {
349 panic!("{e}");
350 }
351 let (pd, ph, pw) = params.patch;
352 let n = nx * ny * nz;
353 for (name, v) in [("field", local_field_ppm), ("qsm", qsm), ("r2prime", r2prime)] {
354 assert_eq!(v.len(), n, "{name} length must match grid");
355 }
356 assert_eq!(mask.len(), n, "mask length must match grid");
357
358 // End-pad each dim up to at least the patch size (col-major padded volume).
359 let (px, py, pz) = (nx.max(pd), ny.max(ph), nz.max(pw));
360 let mut ch = [vec![0.0f32; px * py * pz], vec![0.0f32; px * py * pz], vec![0.0f32; px * py * pz]];
361 for z in 0..nz {
362 for y in 0..ny {
363 for x in 0..nx {
364 let i = x + nx * (y + ny * z);
365 if mask[i] == 0 {
366 continue;
367 }
368 let p = x + px * (y + py * z);
369 ch[0][p] = ((qsm[i] - norm.qsm.0) / norm.qsm.1) as f32;
370 ch[1][p] = ((local_field_ppm[i] - norm.field.0) / norm.field.1) as f32;
371 ch[2][p] = ((r2prime[i] / norm.dr - norm.r2prime.0) / norm.r2prime.1) as f32;
372 }
373 }
374 }
375
376 let model = OnnxModel::load(model_onnx)?;
377 let plane = pd * ph * pw;
378 let mut acc0 = vec![0.0f64; px * py * pz];
379 let mut acc1 = vec![0.0f64; px * py * pz];
380 let mut wsum = vec![0.0f64; px * py * pz];
381 for &x0 in &starts(px, pd) {
382 for &y0 in &starts(py, ph) {
383 for &z0 in &starts(pz, pw) {
384 // Build the [1,3,pd,ph,pw] NCDHW patch (D=x, H=y, W=z).
385 let mut buf = vec![0.0f32; 3 * plane];
386 for i in 0..pd {
387 for j in 0..ph {
388 for k in 0..pw {
389 let p = (x0 + i) + px * ((y0 + j) + py * (z0 + k));
390 let o = (i * ph + j) * pw + k;
391 buf[o] = ch[0][p];
392 buf[plane + o] = ch[1][p];
393 buf[2 * plane + o] = ch[2][p];
394 }
395 }
396 }
397 let out = model.run_single(&Tensor::new(vec![1, 3, pd, ph, pw], buf))?;
398 if out.data.len() < 2 * plane {
399 return Err(OnnxError::Run("expected 2-channel output".into()));
400 }
401 for i in 0..pd {
402 for j in 0..ph {
403 for k in 0..pw {
404 let p = (x0 + i) + px * ((y0 + j) + py * (z0 + k));
405 let o = (i * ph + j) * pw + k;
406 acc0[p] += out.data[o] as f64;
407 acc1[p] += out.data[plane + o] as f64;
408 wsum[p] += 1.0;
409 }
410 }
411 }
412 }
413 }
414 }
415
416 // Average overlaps, de-normalize, crop to (nx,ny,nz), mask. χ− → signed (≤ 0).
417 let mut chi_pos = vec![0.0f64; n];
418 let mut chi_neg = vec![0.0f64; n];
419 let mut chi_total = vec![0.0f64; n];
420 for z in 0..nz {
421 for y in 0..ny {
422 for x in 0..nx {
423 let i = x + nx * (y + ny * z);
424 if mask[i] == 0 {
425 continue;
426 }
427 let p = x + px * (y + py * z);
428 let w = wsum[p].max(1.0);
429 let pos = (acc0[p] / w) * norm.chi_pos.1 + norm.chi_pos.0;
430 let neg = (acc1[p] / w) * norm.chi_neg.1 + norm.chi_neg.0;
431 // test.py: pred_x_pos[pred_x_pos < 0] = 0; pred_x_neg[pred_x_neg < 0] = 0
432 (chi_pos[i], chi_neg[i], chi_total[i]) = super::sources_from_magnitudes(pos, neg);
433 }
434 }
435 }
436 Ok((chi_pos, chi_neg, chi_total))
437}
438
439/// χ-sepnet's R2 input for [`chisepnet_toolbox`].
440#[derive(Clone, Copy, Debug)]
441pub enum ChiSepNetR2<'a> {
442 /// A measured R2′ (Hz): χ-sepnet-R2′.
443 R2Prime(&'a [f64]),
444 /// R2* (Hz): χ-sepnet-R2*, with R2′ predicted by R2PRIMEnet inside the route.
445 R2Star(&'a [f64]),
446}
447
448/// The weights [`chisepnet_toolbox`] runs: χ-sepnet, QSMnetME, and R2PRIMEnet for
449/// [`ChiSepNetR2::R2Star`].
450#[derive(Clone, Copy, Debug)]
451pub struct ChiSepNetToolboxModels<'a> {
452 pub chisepnet: &'a [u8],
453 pub qsmnetme: &'a [u8],
454 pub r2primenet: Option<&'a [u8]>,
455}
456
457/// χ-sepnet by the authors' route: the χ-separation toolbox v1.1.3's `chi_sepnet_general`
458/// (`Chisep_script.m`:177-194), recovered by black-box probing in licensed MATLAB R2023b
459/// (QSM.rs#377; method in [`crate::utils::chisep_frame`]). Unlike [`chisepnet`] it takes no QSM:
460///
461/// 1. the local field, the R2 map and the mask go into the training frame
462/// ([`TrainingFrame`](crate::utils::chisep_frame::TrainingFrame): B0 along the third axis,
463/// 1 mm, `interp3 'spline'` / `'linear'` > 0.5);
464/// 2. the QSM channel is QSMnetME's ([`crate::inversion::qsmnetme`], whose field channel is
465/// χ-sepnet's field channel too);
466/// 3. the R2′ channel is `(R2′/Dr − mean)/std` for a measured R2′, or, given R2*, R2PRIMEnet's
467/// raw z-scored output for `(R2*/Dr − r2star_mean)/r2star_std`. Neither is clipped: the
468/// toolbox clips its de-normalised R2′ (`max(·, 0)`) but feeds χ-sepnet the unclipped channel,
469/// and does not clip negative R2* before R2PRIMEnet (measured: the channels matched the
470/// unclipped forms to float32 rounding, 2 040 and 4 691 negative frame voxels). These are
471/// intermediates the route never returns, so QSM.rs's rule against negative R2′ maps
472/// (applied by [`r2primenet`](crate::relaxometry::r2primenet)) does not reach them;
473/// 4. every channel is 0 outside the frame mask; χ-sepnet runs on the whole frame;
474/// `χ± = max(std·y + mean, 0)` inside the frame mask;
475/// 5. each map is resampled back onto the acquired grid (`interp3 'spline'`), set to 0 where
476/// negative (the spline overshoots) and outside the mask. `χ_total = χ+ − |χ−|`.
477///
478/// `local_field_ppm` is in ppm (the toolbox takes Hz and divides by `CF·1e-6`; the same).
479/// `b0_tesla` is only checked for [`ChiSepNetR2::R2Star`]: R2PRIMEnet is trained at 3 T and the
480/// v1.1.3 toolbox has no 7 T network (v1.2's `R2PNET_7T.onnx` is not available to us), so other
481/// field strengths are refused unless [`ChiSepNetParams::ignore_field_mismatch`] is set.
482///
483/// Returns `(χ+ ≥ 0, χ− ≤ 0, χ_total)` in ppm on the acquired grid.
484#[cfg(feature = "onnx")]
485#[allow(clippy::too_many_arguments)]
486pub fn chisepnet_toolbox(
487 local_field_ppm: &[f64],
488 r2: ChiSepNetR2,
489 mask: &[u8],
490 grid: &Grid,
491 b0_dir: (f64, f64, f64),
492 b0_tesla: f64,
493 models: &ChiSepNetToolboxModels,
494 norm: &ChiSepNetNorm,
495 params: &ChiSepNetParams,
496) -> Result<super::ChiSepMaps, OnnxError> {
497 use crate::relaxometry::{R2PrimeNetNorm, B0_TOLERANCE_T, TRAINED_B0_T};
498 use crate::utils::chisep_frame::{run_net, TrainingFrame};
499 let n = grid.n_total();
500 assert_eq!(local_field_ppm.len(), n, "field length must match grid");
501 assert_eq!(mask.len(), n, "mask length must match grid");
502 params.validate().map_err(OnnxError::Domain)?;
503 let r2_map = match r2 {
504 ChiSepNetR2::R2Prime(v) | ChiSepNetR2::R2Star(v) => v,
505 };
506 assert_eq!(r2_map.len(), n, "R2 map length must match grid");
507 if matches!(r2, ChiSepNetR2::R2Star(_)) {
508 if !params.ignore_field_mismatch && (b0_tesla - TRAINED_B0_T).abs() > B0_TOLERANCE_T {
509 return Err(OnnxError::Domain(format!(
510 "χ-sepnet-R2* predicts R2' with R2PRIMEnet, whose weights are trained at {TRAINED_B0_T} T, \
511 and this R2* map is from {b0_tesla} T: outside the training distribution. Give a \
512 measured R2' (χ-sepnet-R2'), or set ChiSepNetParams::ignore_field_mismatch."
513 )));
514 }
515 if models.r2primenet.is_none() {
516 return Err(OnnxError::Domain("χ-sepnet-R2* needs the R2PRIMEnet weights".into()));
517 }
518 }
519
520 let frame = TrainingFrame::new(grid.dims, grid.voxel_size, b0_dir);
521 let dims = frame.dims();
522 let fmask = frame.mask_to_frame(mask);
523 let field = frame.to_frame(local_field_ppm);
524 let r2f = frame.to_frame(r2_map);
525
526 // QSM channel: QSMnetME (its z-scored field input is χ-sepnet's field channel).
527 let qnorm = crate::inversion::QsmnetmeNorm { field: norm.field, chi: norm.qsm };
528 let qsmnetme = OnnxModel::load(models.qsmnetme)?;
529 let (qsm, field_ch) = crate::inversion::qsmnetme::qsmnetme_in_frame(&field, &fmask, dims, &qsmnetme, &qnorm, params.frame_patch)?;
530 drop(qsmnetme);
531 let masked = |v: f64, m: u8| if m != 0 { v as f32 } else { 0.0 };
532 let qsm_ch: Vec<f32> = qsm.iter().zip(&fmask).map(|(&q, &m)| masked((q - norm.qsm.0) / norm.qsm.1, m)).collect();
533 drop(qsm);
534
535 // R2′ channel.
536 let r2p_ch: Vec<f32> = match r2 {
537 ChiSepNetR2::R2Prime(_) => r2f
538 .iter()
539 .zip(&fmask)
540 .map(|(&v, &m)| masked((v / norm.dr - norm.r2prime.0) / norm.r2prime.1, m))
541 .collect(),
542 ChiSepNetR2::R2Star(_) => {
543 let rn = R2PrimeNetNorm::default();
544 let x: Vec<f32> = r2f.iter().zip(&fmask).map(|(&v, &m)| masked((v / norm.dr - rn.r2star.0) / rn.r2star.1, m)).collect();
545 let net = OnnxModel::load(models.r2primenet.expect("checked above"))?;
546 let y = run_net(&net, std::slice::from_ref(&x), dims, 1, params.frame_patch)?;
547 y[0].iter().zip(&fmask).map(|(&v, &m)| if m != 0 { v } else { 0.0 }).collect()
548 }
549 };
550 drop(r2f);
551 drop(field);
552
553 let net = OnnxModel::load(models.chisepnet)?;
554 let y = run_net(&net, &[qsm_ch, field_ch, r2p_ch], dims, 2, params.frame_patch)?;
555 drop(net);
556 let source = |c: usize, (mean, std): (f64, f64)| -> Vec<f64> {
557 y[c].iter().zip(&fmask).map(|(&v, &m)| if m != 0 { (v as f64 * std + mean).max(0.0) } else { 0.0 }).collect()
558 };
559 let pos_f = source(0, norm.chi_pos);
560 let neg_f = source(1, norm.chi_neg);
561 let pos = frame.to_native(&pos_f);
562 let neg = frame.to_native(&neg_f);
563 let mut chi_pos = vec![0.0; n];
564 let mut chi_neg = vec![0.0; n];
565 let mut chi_total = vec![0.0; n];
566 for i in 0..n {
567 if mask[i] != 0 {
568 (chi_pos[i], chi_neg[i], chi_total[i]) = super::sources_from_magnitudes(pos[i], neg[i]);
569 }
570 }
571 Ok((chi_pos, chi_neg, chi_total))
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 /// Both shipped patches are legal; anything off the 16-grid, or zero, is refused.
579 #[test]
580 fn patch_validation() {
581 assert_eq!(ChiSepNetParams::default().patch, AUTHORS_PATCH);
582 assert_eq!(ChiSepNetParams::low_memory().patch, WASM_PATCH);
583 assert!(ChiSepNetParams::default().validate().is_ok());
584 assert!(ChiSepNetParams::low_memory().validate().is_ok());
585 for bad in [(0, 128, 64), (128, 120, 64), (128, 128, 8)] {
586 let e = ChiSepNetParams { patch: bad, ..Default::default() }.validate().unwrap_err();
587 assert!(e.contains("multiple of 16"), "{e}");
588 }
589 }
590}