Skip to main content

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}