Skip to main content

qsm_core/bet/
rs2net.rs

1//! RS2-Net rodent brain extraction (`onnx` feature).
2//!
3//! RS2-Net is a Swin-UNETR trained within nnU-Net v2 on 1,142 rat and mouse MRIs from 89 centres
4//! (3–17.2 T). It masks rodent brains, where BET's human-scale surface model and HD-BET (trained on
5//! humans) both fail. This module ports the nnU-Net pipeline RS2-Net ships around the exported
6//! network (`rs2-net.onnx`, see [`crate::models`]), sharing HD-BET's sliding-window and
7//! resampling helpers ([`super::hdbet`]):
8//!
9//! 1. transpose `(z, y, x)` → `(y, z, x)` (the plans' `transpose_forward = [1, 0, 2]`);
10//! 2. crop to the bounding box of non-zero voxels, z-score normalise over the crop;
11//! 3. resample linearly to 0.25 × 0.2 × 0.16 mm (nnU-Net's "separate z" rule for anisotropic
12//!    voxels: linear in-plane, nearest-neighbour through-plane);
13//! 4. pad to at least one patch, then Gaussian-weighted sliding-window inference with 50 %
14//!    overlap (optionally with 8-fold mirroring test-time augmentation);
15//! 5. resample the logits back the same way, threshold the sigmoid at 0.5 (logit > 0), un-crop
16//!    and transpose back.
17//!
18//! One deliberate deviation: RS2-Net's export resamples the logits back with the *untransposed*
19//! original spacing (upstream nnU-Net v2 transposes it), so on thick-slice data it picks the wrong
20//! axis for nearest-neighbour. This port uses the transposed spacing, as nnU-Net intends; on an
21//! in-vivo mouse GRE (0.17 × 0.20 × 0.8 mm) the two masks agree at Dice 0.986. Isotropic data is
22//! unaffected.
23//!
24//! Swin's window attention uses a relative position bias only, so the network runs at any patch
25//! that is a multiple of 32 per axis — but the exported graph is traced at a fixed one,
26//! [`RS2_NET_PATCH`]: 128 × 96 × 128 instead of RS2-Net's 128 × 128 × 160, which peaks at
27//! ≈4.5 GB and does not fit wasm32's address space (this one peaks at ≈2.7 GB). On the in-vivo
28//! mouse GRE the two patches agree at Dice 0.987; larger volumes are tiled.
29//!
30//! The input should be a magnitude image (the first echo of a multi-echo GRE works well), in the
31//! scanner's native orientation. RS2-Net applies no post-processing.
32//!
33//! # Reference pipeline
34//!
35//! Rodent (rat, mouse) brains only, at preclinical resolution (the plans resample to
36//! 0.25 × 0.2 × 0.16 mm); for human data use BET, HD-BET or MindGrab. No affine is used. The
37//! fixed 128 × 96 × 128 patch peaks at ≈2.7 GB, so the network fits a 32-bit WASM heap; larger
38//! volumes are tiled with it.
39//!
40//! Reference:
41//! Lin, Y., Ding, Y., Chang, S., Ge, X., Sui, X., Jiang, Y. (2024). "RS2-Net: An end-to-end deep
42//! learning framework for rodent skull stripping in multi-center brain MRI." NeuroImage,
43//! 298:120769. https://doi.org/10.1016/j.neuroimage.2024.120769
44//!
45//! Reference implementation: https://github.com/VitoLin21/Rodent-Skull-Stripping (GPL-3.0).
46
47// The pre/post-processing is plain Rust; only inference needs `onnx`.
48#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
49
50use super::hdbet::{nonzero_bbox, resample_nnunet};
51#[cfg(feature = "onnx")]
52use super::hdbet::predict_logits;
53use crate::grid::Grid;
54#[cfg(feature = "onnx")]
55use crate::models::onnx::{OnnxError, OnnxModel};
56
57/// nnU-Net target spacing (mm), in the transposed `(y, z, x)` order.
58const TARGET_SPACING: [f64; 3] = [0.25, 0.2, 0.16];
59
60/// Patch the exported graph was traced at, in the transposed `(y, z, x)` order.
61pub const RS2_NET_PATCH: [usize; 3] = [128, 96, 128];
62
63/// RS2-Net inference parameters.
64#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
65#[derive(Clone, Debug, PartialEq)]
66pub struct Rs2NetParams {
67    /// Sliding-window step as a fraction of the patch (default 0.5, nnU-Net's).
68    pub tile_step: f64,
69    /// 8-fold mirroring test-time augmentation (default off: 8× slower, and on an in-vivo mouse
70    /// GRE the mask agrees with the mirrored one at Dice 0.987).
71    pub mirror_tta: bool,
72}
73
74impl Default for Rs2NetParams {
75    fn default() -> Self {
76        Self { tile_step: 0.5, mirror_tta: false }
77    }
78}
79
80/// Rodent brain mask via RS2-Net.
81///
82/// * `magnitude` — magnitude image, column-major `(nx, ny, nz)`.
83/// * `model_onnx` — bytes of the exported `rs2-net.onnx` (see [`crate::models`]).
84/// * `progress(done, total)` — called after each network evaluation.
85///
86/// Returns a binary mask (0/1) on the input grid.
87#[cfg(feature = "onnx")]
88pub fn rs2_net(
89    magnitude: &[f64],
90    grid: &Grid,
91    model_onnx: &[u8],
92    params: &Rs2NetParams,
93    progress: impl FnMut(usize, usize),
94) -> Result<Vec<u8>, OnnxError> {
95    let (nx, ny, nz) = grid.dims;
96    assert_eq!(magnitude.len(), nx * ny * nz, "magnitude length must match grid");
97    if !(params.tile_step > 0.0 && params.tile_step <= 1.0) {
98        return Err(OnnxError::Shape(format!("tile_step {} must be in (0, 1]", params.tile_step)));
99    }
100    let Some(pre) = preprocess(magnitude, grid) else {
101        return Ok(vec![0; magnitude.len()]);
102    };
103    let model = OnnxModel::load(model_onnx)?;
104    let logits = predict_logits(
105        &pre.data, pre.dims, &model, RS2_NET_PATCH, 1, params.tile_step, params.mirror_tta, progress,
106    )?;
107    Ok(postprocess(&logits, &pre))
108}
109
110/// nnU-Net-preprocessed volume plus what is needed to map predictions back.
111struct Preprocessed {
112    /// Cropped, normalised, resampled volume, C-order `(y, z, x)`.
113    data: Vec<f32>,
114    dims: [usize; 3],
115    /// Crop box `[start, end)` per axis in the transposed `(y, z, x)` volume.
116    bbox: [(usize, usize); 3],
117    /// Transposed volume shape `(y, z, x)`.
118    full_dims: [usize; 3],
119    /// Transposed spacing `(y, z, x)`.
120    spacing: [f64; 3],
121}
122
123/// Column-major `(nx, ny, nz)` — i.e. C-order `(z, y, x)` — to C-order `(y, z, x)`.
124fn transpose_forward(data: &[f64], (nx, ny, nz): (usize, usize, usize)) -> Vec<f64> {
125    let mut out = vec![0.0; data.len()];
126    for z in 0..nz {
127        for y in 0..ny {
128            let (src, dst) = ((z * ny + y) * nx, (y * nz + z) * nx);
129            out[dst..dst + nx].copy_from_slice(&data[src..src + nx]);
130        }
131    }
132    out
133}
134
135/// Transpose, crop to non-zero, z-score, resample. `None` if the image is entirely zero.
136fn preprocess(magnitude: &[f64], grid: &Grid) -> Option<Preprocessed> {
137    let (nx, ny, nz) = grid.dims;
138    let (vx, vy, vz) = grid.voxel_size;
139    let full_dims = [ny, nz, nx];
140    let spacing = [vy, vz, vx];
141    let data = transpose_forward(magnitude, grid.dims);
142
143    let bbox = nonzero_bbox(&data, full_dims)?;
144    let cdims: [usize; 3] = std::array::from_fn(|a| bbox[a].1 - bbox[a].0);
145    let mut img = Vec::with_capacity(cdims.iter().product());
146    for i in bbox[0].0..bbox[0].1 {
147        for j in bbox[1].0..bbox[1].1 {
148            let row = (i * full_dims[1] + j) * full_dims[2];
149            img.extend_from_slice(&data[row + bbox[2].0..row + bbox[2].1]);
150        }
151    }
152
153    // ZScoreNormalization without a mask (RS2-Net's plans: use_mask_for_norm = false).
154    let n = img.len() as f64;
155    let mean = img.iter().sum::<f64>() / n;
156    let std = (img.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n).sqrt();
157    let s = std.max(1e-8);
158    img.iter_mut().for_each(|v| *v = (*v - mean) / s);
159
160    // compute_new_shape: round(spacing / target * shape), Python round (half to even).
161    let rdims: [usize; 3] =
162        std::array::from_fn(|a| (spacing[a] / TARGET_SPACING[a] * cdims[a] as f64).round_ties_even() as usize);
163    // RS2-Net's plans: linear (order 1), nearest through-plane.
164    let data = resample_nnunet(&img, cdims, rdims, spacing, TARGET_SPACING, 1)
165        .into_iter()
166        .map(|v| v as f32)
167        .collect();
168    Some(Preprocessed { data, dims: rdims, bbox, full_dims, spacing })
169}
170
171/// Resample logits back to the cropped grid, threshold, un-crop, transpose back. Returns the
172/// column-major `(nx, ny, nz)` mask.
173fn postprocess(logits: &[f32], pre: &Preprocessed) -> Vec<u8> {
174    let bbox = pre.bbox;
175    let cdims: [usize; 3] = std::array::from_fn(|a| bbox[a].1 - bbox[a].0);
176    let ch: Vec<f64> = logits.iter().map(|&v| v as f64).collect();
177    let back = resample_nnunet(&ch, pre.dims, cdims, TARGET_SPACING, pre.spacing, 1);
178    let [ny, nz, nx] = pre.full_dims;
179    let mut mask = vec![0u8; ny * nz * nx];
180    let mut idx = 0;
181    for y in bbox[0].0..bbox[0].1 {
182        for z in bbox[1].0..bbox[1].1 {
183            for x in bbox[2].0..bbox[2].1 {
184                // sigmoid(logit) > 0.5, written into the column-major (z, y, x) layout
185                mask[(z * ny + y) * nx + x] = (back[idx] > 0.0) as u8;
186                idx += 1;
187            }
188        }
189    }
190    mask
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn transpose_swaps_z_and_y() {
199        let (nx, ny, nz) = (2, 3, 4);
200        let v: Vec<f64> = (0..24).map(|i| i as f64).collect();
201        let t = transpose_forward(&v, (nx, ny, nz));
202        // column-major voxel (x=1, y=2, z=3) lands at C-order (y=2, z=3, x=1) of [ny, nz, nx]
203        assert_eq!(t[(2 * nz + 3) * nx + 1], v[(3 * ny + 2) * nx + 1]);
204    }
205
206    #[test]
207    fn patch_is_swin_compatible() {
208        // Swin-UNETR downsamples five times: every patch axis must be a multiple of 32.
209        assert!(RS2_NET_PATCH.iter().all(|&p| p > 0 && p % 32 == 0));
210    }
211
212    #[test]
213    fn empty_image_gives_no_preprocessing() {
214        assert!(preprocess(&[0.0; 8], &Grid::new(2, 2, 2, 0.1, 0.1, 0.1)).is_none());
215    }
216
217    #[test]
218    fn postprocess_inverts_preprocess_geometry() {
219        // A bright box in a thick-slice volume: thresholding the preprocessed (z-scored,
220        // resampled) intensities at the box/background midpoint, as if they were logits, must
221        // give the box back exactly — i.e. crop, transpose and both resamplings round-trip.
222        let (nx, ny, nz) = (40, 30, 8);
223        let mut v = vec![0.001; nx * ny * nz];
224        for z in 2..6 {
225            for y in 8..22 {
226                for x in 10..30 {
227                    v[(z * ny + y) * nx + x] = 1.0;
228                }
229            }
230        }
231        let pre = preprocess(&v, &Grid::new(nx, ny, nz, 0.2, 0.2, 0.8)).unwrap();
232        // 0.2 x 0.8 x 0.2 mm (transposed) is anisotropic past 3x: separate-z resampling
233        assert_eq!(super::super::hdbet::separate_z_axis(pre.spacing, TARGET_SPACING), Some(1));
234        let (lo, hi) = pre.data.iter().fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v)));
235        let logits: Vec<f32> = pre.data.iter().map(|v| v - (lo + hi) / 2.0).collect();
236        let mask = postprocess(&logits, &pre);
237        let want: Vec<u8> = v.iter().map(|&x| (x > 0.5) as u8).collect();
238        assert_eq!(mask, want);
239    }
240
241    // ---- parity against RS2-Net's own pipeline: see tests/models_onnx.rs (rs2net_*) ----
242
243    /// Minimal little-endian f32 C-order `.npy` reader.
244    fn read_npy_f32(path: &str) -> (Vec<usize>, Vec<f32>) {
245        let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
246        assert_eq!(&b[..6], b"\x93NUMPY");
247        let (hlen, off) = if b[6] == 1 {
248            (u16::from_le_bytes([b[8], b[9]]) as usize, 10)
249        } else {
250            (u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize, 12)
251        };
252        let header = std::str::from_utf8(&b[off..off + hlen]).unwrap();
253        assert!(header.contains("'<f4'") && header.contains("'fortran_order': False"), "{header}");
254        let shape_str = header.split("'shape': (").nth(1).unwrap().split(')').next().unwrap();
255        let shape: Vec<usize> = shape_str.split(',').filter_map(|s| s.trim().parse().ok()).collect();
256        let data = b[off + hlen..].as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect();
257        (shape, data)
258    }
259
260    /// `RS2_REF_DIR` holds `mag.nii` plus `ref_preprocessed.npy`, `ref_logits.npy` and
261    /// `ref_mask.nii.gz` written by `scripts/onnx-export/ref_rs2net.py` on it.
262    fn load_ref() -> Option<(String, Vec<f64>, Grid)> {
263        let dir = std::env::var("RS2_REF_DIR").ok()?;
264        let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/mag.nii"))).unwrap();
265        let (nx, ny, nz) = nii.dims;
266        let (vx, vy, vz) = nii.voxel_size;
267        Some((dir, nii.data, Grid::new(nx, ny, nz, vx, vy, vz)))
268    }
269
270    /// Transpose + crop + z-score + resample vs RS2-Net's `DefaultPreprocessor.run_case`.
271    #[test]
272    #[ignore]
273    fn preprocessing_matches_rs2net() {
274        let Some((dir, mag, grid)) = load_ref() else { return eprintln!("RS2_REF_DIR not set; skipping") };
275        let pre = preprocess(&mag, &grid).unwrap();
276        let (shape, want) = read_npy_f32(&format!("{dir}/ref_preprocessed.npy"));
277        assert_eq!(pre.dims.to_vec(), shape);
278        let err = pre.data.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
279        eprintln!("dims {:?}, bbox {:?}, max |d| {err:e}", pre.dims, pre.bbox);
280        assert!(err < 1e-3, "preprocessed max |d| = {err}");
281    }
282
283    /// Resample-back + threshold + un-crop applied to RS2-Net's own logits reproduces its mask
284    /// (exported with the spacing transposed; see the module docs).
285    #[test]
286    #[ignore]
287    fn postprocessing_matches_rs2net() {
288        let Some((dir, mag, grid)) = load_ref() else { return eprintln!("RS2_REF_DIR not set; skipping") };
289        let pre = preprocess(&mag, &grid).unwrap();
290        let (shape, logits) = read_npy_f32(&format!("{dir}/ref_logits.npy"));
291        // RS2-Net broadcasts its one output channel into two identical ones; take the first.
292        let n: usize = shape[1..].iter().product();
293        let mask = postprocess(&logits[..n], &pre);
294        let want = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/ref_mask.nii.gz"))).unwrap();
295        let diff = mask.iter().zip(&want.data).filter(|(&m, &w)| (m != 0) != (w > 0.5)).count();
296        eprintln!("{diff} voxels differ from RS2-Net's mask");
297        assert_eq!(diff, 0, "{diff} differing voxels");
298    }
299}