Skip to main content

qsm_core/bet/
hdbet.rs

1//! HD-BET deep-learning brain extraction (`onnx` feature).
2//!
3//! HD-BET v2 is an nnU-Net v2 3D U-Net (`PlainConvUNet`, 1 mm isotropic, 96×192×192 patches)
4//! trained on 11,751 multi-sequence clinical MRIs; it segments the brain from a single magnitude
5//! image. This module is a faithful port of nnU-Net's inference pipeline around the exported
6//! network (`hd-bet.onnx`, see [`crate::models`]):
7//!
8//! 1. crop to the bounding box of non-zero voxels;
9//! 2. z-score normalise over the cropped image;
10//! 3. resample to 1 mm with cubic splines (nnU-Net's "separate z" rule for anisotropic voxels:
11//!    cubic in-plane, nearest-neighbour through-plane);
12//! 4. run the network on the whole resampled volume, zero-padded to a size the network accepts
13//!    ([`HdBetInference::FullVolume`], the default, as HD-BET v1 did), or — as nnU-Net and
14//!    HD-BET v2 do — pad to at least one patch and run Gaussian-weighted sliding-window
15//!    inference with 50 % overlap ([`HdBetInference::SlidingWindow`]); either optionally with
16//!    8-fold mirroring test-time augmentation;
17//! 5. resample the logits back (linear; nearest through-plane for anisotropic voxels), take the
18//!    argmax and un-crop.
19//!
20//! nnU-Net works on SimpleITK arrays in `(z, y, x)` order, which is exactly the memory layout of
21//! the crate's column-major `(nx, ny, nz)` volumes — no transposition is needed.
22//!
23//! The input should be a magnitude image (for multi-echo GRE, the root-sum-of-squares over
24//! echoes works well) in roughly standard radiological orientation (axial slices along z), as
25//! HD-BET was trained on MNI-aligned data. HD-BET applies no post-processing; combine with
26//! [`crate::utils::morphology::fill_holes`] / erosion refinements as needed.
27//!
28//! # Reference pipeline
29//!
30//! From the HD-BET repository (MIC-DKFZ/HD-BET 678e44d, v2):
31//!
32//! - **Contrast.** Trained on pre- and post-contrast T1-w, T2-w and FLAIR; "other sequences will
33//!   most likely work as well" (`readme.md:25-28,79-80`). No GRE was in training, and QSM.rs has
34//!   no Dice against a reference brain mask on GRE recorded; its validation is agreement with the
35//!   `hd-bet` CLI (Dice ≥ 0.99998, `docs/DL_ONNX_SCOPING.md`).
36//! - **Orientation.** "Input images must match the orientation of standard MNI152 template! Use
37//!   fslreorient2std upfront" (`readme.md:80-82`). Neither HD-BET nor this port reorients: the
38//!   array is used in its stored voxel order, and `hd_bet` takes no affine. A host must pass a
39//!   volume already in that orientation.
40//! - **Resolution.** Any; nnU-Net resamples to 1 mm (step 3 above), so the network's memory
41//!   follows the field of view in mm ([`hd_bet_memory_bytes`]).
42//! - **Inference.** The v2 CLI uses nnU-Net's sliding window (`tile_step_size=0.5`, Gaussian)
43//!   with mirroring test-time augmentation on unless `--disable_tta`
44//!   (`HD_BET/hd_bet_prediction.py:28-35`, `HD_BET/entry_point.py:30,48`); the readme
45//!   recommends `--disable_tta` on CPU (`readme.md:100-114`). [`HdBetParams::default`] is
46//!   whole-volume without TTA.
47//! - **WASM.** On a 32-bit WASM heap (4 GiB) the whole-volume default does not fit a typical head
48//!   scan; [`HdBetParams::low_memory`] does (1.6 GB on a 184×256×128 mm volume).
49//!   [`hd_bet_memory_bytes`] with [`crate::models::Heap::Wasm32`] gives the peak for a grid and
50//!   setting.
51//!
52//! Reference:
53//! Isensee, F., Schell, M., Pflueger, I., et al. (2019). "Automated brain extraction of
54//! multisequence MRI using artificial neural networks." Human Brain Mapping, 40(17):4952-4964.
55//! https://doi.org/10.1002/hbm.24750
56//!
57//! Reference implementation: https://github.com/MIC-DKFZ/HD-BET (Apache-2.0; weights CC-BY-NC-4.0).
58//!
59//! # Agreement with the original
60//!
61//! `tests/models_onnx/separation.rs` runs it on the 2 mm root-sum-of-squares magnitude of QSM-CI's
62//! 128×160×96 case (`tests/data/dl_head_small.bin`; a whole head, which HD-BET needs) against its
63//! original's verified QSM-CI entry, HD-BET 2.0.1 (`hd-bet-qsmci`) (QSMxT/QSM-CI#316; the fixture
64//! is regenerated in Actions by `tests/data/dl_hdbet_qsmci_small.py`): with
65//! [`HdBetInference::SLIDING_WINDOW`] and no mirroring, as the original's `hd-bet --disable_tta`
66//! runs, the masks are identical (Dice 1.0, 187,055 voxels; measured 2026-10-30).
67//!
68//! # Pipeline rules
69//!
70//! The facts above that a configuration can depart from are published as rules in
71//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `hd-bet.wasm-heap`.
72
73// The pre/post-processing is plain Rust; only inference needs `onnx`.
74#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
75
76use crate::grid::Grid;
77#[cfg(feature = "onnx")]
78use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
79use crate::utils::resample::{resize, resize_axis};
80
81/// How the network is applied to the preprocessed (cropped, 1 mm) volume.
82#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub enum HdBetInference {
85    /// One network pass over the whole volume, zero-padded (centred) to the next multiple of
86    /// `(32, 32, 16)`. Fastest, and every voxel sees the whole head; peak memory grows with the
87    /// field of view in mm (natively 5.7 GB for 184×256×128 mm, 9.3 GB for 240×240×160 mm; see
88    /// [`hd_bet_memory_bytes`]).
89    FullVolume,
90    /// Gaussian-weighted sliding window, as nnU-Net (and the HD-BET v2 CLI) runs it.
91    SlidingWindow {
92        /// Patch size `(px, py, pz)` in voxels at 1 mm; HD-BET v2 was trained with
93        /// `(192, 192, 96)`. Must be multiples of `(32, 32, 16)`. Peak memory follows the patch
94        /// (natively 3.4 GB at `(192, 192, 96)`, 1.3 GB at `(128, 128, 64)`; see
95        /// [`hd_bet_memory_bytes`]); below ~`(128, 128, 64)` tiles that lie wholly inside the
96        /// brain start to be labelled background.
97        patch: (usize, usize, usize),
98        /// Step between windows as a fraction of the patch (nnU-Net: 0.5).
99        tile_step: f64,
100    },
101}
102
103impl HdBetInference {
104    /// nnU-Net's sliding window: HD-BET v2's training patch `(192, 192, 96)`, 50 % overlap.
105    pub const SLIDING_WINDOW: Self = Self::SlidingWindow { patch: (192, 192, 96), tile_step: 0.5 };
106    /// Memory-bounded sliding window: `(128, 128, 64)` patches, 50 % overlap.
107    pub const LOW_MEMORY: Self = Self::SlidingWindow { patch: (128, 128, 64), tile_step: 0.5 };
108}
109
110/// HD-BET inference parameters.
111#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
112#[derive(Clone, Debug, PartialEq)]
113pub struct HdBetParams {
114    /// Whole-volume (default) or sliding-window inference.
115    pub inference: HdBetInference,
116    /// 8-fold mirroring test-time augmentation (default off, as in QSM-CI; 8× slower for a
117    /// marginal gain).
118    pub mirror_tta: bool,
119}
120
121impl Default for HdBetParams {
122    fn default() -> Self {
123        Self { inference: HdBetInference::FullVolume, mirror_tta: false }
124    }
125}
126
127impl HdBetParams {
128    /// Memory-bounded setting for constrained hosts (e.g. 32-bit WASM): `(128, 128, 64)`
129    /// sliding-window patches, peak 1.6 GB in a WASM heap on a 184×256×128 mm volume. Agreed
130    /// with the native patch at Dice ≈0.99 in validation (and with nnU-Net run at the same patch
131    /// size to 2 voxels).
132    pub fn low_memory() -> Self {
133        Self { inference: HdBetInference::LOW_MEMORY, ..Self::default() }
134    }
135}
136
137/// nnU-Net target spacing for HD-BET (mm).
138const TARGET_SPACING: [f64; 3] = [1.0, 1.0, 1.0];
139/// Required divisibility of the patch in nnU-Net `(z, y, x)` order (5 down-samplings, the last
140/// one in-plane only).
141const PATCH_DIVISOR: [usize; 3] = [16, 32, 32];
142
143/// Brain mask via HD-BET.
144///
145/// * `magnitude` — magnitude image, column-major `(nx, ny, nz)`.
146/// * `model_onnx` — bytes of the exported `hd-bet.onnx` (see [`crate::models`]).
147/// * `progress(done, total)` — called after each network evaluation.
148///
149/// Returns a binary mask (0/1) on the input grid.
150#[cfg(feature = "onnx")]
151pub fn hd_bet(
152    magnitude: &[f64],
153    grid: &Grid,
154    model_onnx: &[u8],
155    params: &HdBetParams,
156    progress: impl FnMut(usize, usize),
157) -> Result<Vec<u8>, OnnxError> {
158    let (nx, ny, nz) = grid.dims;
159    assert_eq!(magnitude.len(), nx * ny * nz, "magnitude length must match grid");
160    if let HdBetInference::SlidingWindow { patch, tile_step } = params.inference {
161        let p = [patch.2, patch.1, patch.0];
162        if p.iter().zip(PATCH_DIVISOR).any(|(&p, d)| p == 0 || p % d != 0) {
163            return Err(OnnxError::Shape(format!(
164                "HD-BET patch {patch:?} must be a non-zero multiple of (32, 32, 16)"
165            )));
166        }
167        if !(tile_step > 0.0 && tile_step <= 1.0) {
168            return Err(OnnxError::Shape(format!("tile_step {tile_step} must be in (0, 1]")));
169        }
170    }
171    let Some(pre) = preprocess(magnitude, grid) else {
172        return Ok(vec![0; magnitude.len()]);
173    };
174    let (patch, tile_step) = match params.inference {
175        HdBetInference::FullVolume => (full_volume_patch(pre.dims), 1.0),
176        HdBetInference::SlidingWindow { patch, tile_step } => ([patch.2, patch.1, patch.0], tile_step),
177    };
178    let model = OnnxModel::load(model_onnx)?;
179    let logits = predict_logits(&pre.data, pre.dims, &model, patch, 2, tile_step, params.mirror_tta, progress)?;
180    Ok(postprocess(&logits, &pre))
181}
182
183/// nnU-Net-preprocessed volume plus what is needed to map predictions back.
184struct Preprocessed {
185    /// Cropped, normalised, resampled volume, C-order `(z, y, x)`.
186    data: Vec<f32>,
187    dims: [usize; 3],
188    /// Crop box `[start, end)` per axis in the original `(z, y, x)` volume.
189    bbox: [(usize, usize); 3],
190    /// Original volume shape `(z, y, x)`.
191    full_dims: [usize; 3],
192    /// Original spacing `(z, y, x)`.
193    spacing: [f64; 3],
194}
195
196/// Crop to non-zero, z-score, resample to 1 mm. `None` if the image is entirely zero.
197fn preprocess(magnitude: &[f64], grid: &Grid) -> Option<Preprocessed> {
198    let (nx, ny, nz) = grid.dims;
199    let (vx, vy, vz) = grid.voxel_size;
200    let full_dims = [nz, ny, nx];
201    let spacing = [vz, vy, vx];
202
203    let bbox = nonzero_bbox(magnitude, full_dims)?;
204    let cdims = [bbox[0].1 - bbox[0].0, bbox[1].1 - bbox[1].0, bbox[2].1 - bbox[2].0];
205    let mut img = Vec::with_capacity(cdims.iter().product());
206    for z in bbox[0].0..bbox[0].1 {
207        for y in bbox[1].0..bbox[1].1 {
208            let row = (z * ny + y) * nx;
209            img.extend_from_slice(&magnitude[row + bbox[2].0..row + bbox[2].1]);
210        }
211    }
212
213    // ZScoreNormalization without a mask (HD-BET's plans: use_mask_for_norm = false).
214    let n = img.len() as f64;
215    let mean = img.iter().sum::<f64>() / n;
216    let std = (img.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n).sqrt();
217    let s = std.max(1e-8);
218    img.iter_mut().for_each(|v| *v = (*v - mean) / s);
219
220    let rdims = resampled_dims(cdims, spacing);
221    let data = resample_nnunet(&img, cdims, rdims, spacing, TARGET_SPACING, 3)
222        .into_iter()
223        .map(|v| v as f32)
224        .collect();
225    Some(Preprocessed { data, dims: rdims, bbox, full_dims, spacing })
226}
227
228/// nnU-Net `compute_new_shape`: the `(z, y, x)` dims of a `dims` volume with voxel `spacing` at
229/// 1 mm, `round(spacing / target · dims)` with Python's rounding (half to even).
230fn resampled_dims(dims: [usize; 3], spacing: [f64; 3]) -> [usize; 3] {
231    std::array::from_fn(|a| (spacing[a] / TARGET_SPACING[a] * dims[a] as f64).round_ties_even() as usize)
232}
233
234/// The spatial shape `(z, y, x)` of the tensor HD-BET's network sees on a scan on `grid`: with
235/// [`HdBetInference::FullVolume`], the field of view resampled to 1 mm and padded to multiples of
236/// 16×32×32; with a sliding window, its patch.
237///
238/// HD-BET crops to the bounding box of the non-zero voxels before resampling. This takes that box
239/// to be the whole field of view, as it is for any scan with noise in the background, so on an
240/// image whose background was zeroed it is an upper bound.
241pub fn hd_bet_network_input(grid: &Grid, params: &HdBetParams) -> [usize; 3] {
242    match params.inference {
243        HdBetInference::FullVolume => {
244            let (nx, ny, nz) = grid.dims;
245            let (vx, vy, vz) = grid.voxel_size;
246            full_volume_patch(resampled_dims([nz, ny, nx], [vz, vy, vx]))
247        }
248        HdBetInference::SlidingWindow { patch, .. } => [patch.2, patch.1, patch.0],
249    }
250}
251
252/// Peak heap of [`hd_bet`] on a scan on `grid`, in bytes, natively or in a `wasm32` heap.
253///
254/// The network at [`hd_bet_network_input`] ([`crate::models::ModelSpec::peak_bytes`]) plus what
255/// the step allocates around it: the 1 mm volume, the padded volume with its two-channel logit
256/// and weight accumulators, and the window's buffers (Gaussian, input, prediction, the copy handed
257/// to the network). The cropping and resampling before and after the network are counted too,
258/// but never set the peak at the resolutions HD-BET is used at. The magnitude passed in and the
259/// weight file's bytes are the caller's and are not included. Like [`hd_bet_network_input`], it
260/// takes the non-zero bounding box to be the whole field of view.
261///
262/// Within 0.5–1.5 % above the measured native peak of the whole step, whole-volume and sliding
263/// window, at 1 mm, 1.5 mm, 2 mm, 0.6 mm and 0.8×0.8×3 mm (see the [`crate::models`] docs,
264/// "Memory").
265pub fn hd_bet_memory_bytes(grid: &Grid, params: &HdBetParams, heap: crate::models::Heap) -> u64 {
266    let spec = crate::models::find_model("hd-bet").expect("hd-bet is in the registry");
267    let (nx, ny, nz) = grid.dims;
268    let (vx, vy, vz) = grid.voxel_size;
269    let rdims = resampled_dims([nz, ny, nx], [vz, vy, vx]);
270    let patch = hd_bet_network_input(grid, params);
271    let prod = |d: [usize; 3]| d.iter().map(|&v| v as u64).product::<u64>();
272    // The scan, the 1 mm volume, the padded volume, the patch.
273    let c = prod([nz, ny, nx]);
274    let r = prod(rdims);
275    let np = prod(std::array::from_fn(|a| rdims[a].max(patch[a])));
276    let pp = prod(patch);
277    let big = c.max(r);
278    // Cropping and z-scoring in f64, then the spline resize (its copy, and two axis passes alive).
279    let pre = 16 * c + 16 * big + 4 * r;
280    // The network on the 1 mm volume (f32): the volume; the padded volume, logits (2 channels)
281    // and weights on the padded grid; the Gaussian, input, prediction (2) and the network's copy
282    // of the input on the patch, and with mirroring the flipped prediction.
283    let flips = if params.mirror_tta { 4 * pp } else { 0 };
284    let net = spec.peak_bytes(patch, heap) + 4 * r + 16 * np + 20 * pp + flips;
285    // Logits back to the scan: the 1 mm volume and logits, one channel in f64 and its resize,
286    // both channels on the scan in f64, and the mask.
287    let post = spec.resident_bytes() + 20 * r + 16 * big + 16 * c + c;
288    pre.max(net).max(post)
289}
290
291/// Bounding box `[start, end)` of the non-zero voxels of a C-order `(z, y, x)` volume.
292pub(super) fn nonzero_bbox(data: &[f64], dims: [usize; 3]) -> Option<[(usize, usize); 3]> {
293    let mut lo = [usize::MAX; 3];
294    let mut hi = [0usize; 3];
295    let mut any = false;
296    for z in 0..dims[0] {
297        for y in 0..dims[1] {
298            let row = (z * dims[1] + y) * dims[2];
299            for x in 0..dims[2] {
300                if data[row + x] != 0.0 {
301                    any = true;
302                    for (a, c) in [z, y, x].into_iter().enumerate() {
303                        lo[a] = lo[a].min(c);
304                        hi[a] = hi[a].max(c + 1);
305                    }
306                }
307            }
308        }
309    }
310    any.then(|| std::array::from_fn(|a| (lo[a], hi[a])))
311}
312
313/// nnU-Net `resample_data_or_seg_to_shape` for one channel (non-segmentation, `order_z = 0`,
314/// `force_separate_z = None`): a plain 3D spline resize, unless the voxels are anisotropic
315/// (max/min spacing > 3), in which case each slice is resized in-plane with `order` and the
316/// low-resolution axis with nearest-neighbour.
317pub(super) fn resample_nnunet(
318    data: &[f64],
319    dims: [usize; 3],
320    new_dims: [usize; 3],
321    current_spacing: [f64; 3],
322    new_spacing: [f64; 3],
323    order: usize,
324) -> Vec<f64> {
325    if dims == new_dims {
326        return data.to_vec();
327    }
328    let Some(axis) = separate_z_axis(current_spacing, new_spacing) else {
329        return resize(data, dims, new_dims, order);
330    };
331    let inplane: Vec<usize> = (0..3).filter(|&a| a != axis).collect();
332    let mut out = data.to_vec();
333    let mut cur = dims;
334    if inplane.iter().any(|&a| dims[a] != new_dims[a]) {
335        // skimage.resize on each 2D slice: both in-plane axes (a same-length axis still goes
336        // through the spline for order 3, as in scipy), then clip each slice to its own range.
337        for &a in &inplane {
338            if order == 3 || cur[a] != new_dims[a] {
339                (out, cur) = resize_axis(&out, cur, a, new_dims[a], order);
340            }
341        }
342        if order > 1 {
343            let range = slice_ranges(data, dims, axis);
344            for_each_slice(&mut out, cur, axis, |k, v| *v = v.clamp(range[k].0, range[k].1));
345        }
346    }
347    if cur[axis] != new_dims[axis] {
348        (out, _) = resize_axis(&out, cur, axis, new_dims[axis], 0);
349    }
350    out
351}
352
353/// nnU-Net `determine_do_sep_z_and_axis(force_separate_z=None, ...)`: the low-resolution axis if
354/// either spacing is anisotropic by more than 3×, and that axis is unique.
355pub(super) fn separate_z_axis(current: [f64; 3], new: [f64; 3]) -> Option<usize> {
356    let aniso = |s: [f64; 3]| {
357        let (mn, mx) = s.iter().fold((f64::INFINITY, 0.0f64), |(a, b), &v| (a.min(v), b.max(v)));
358        mx / mn > 3.0
359    };
360    let lowres = |s: [f64; 3]| {
361        let mx = s.iter().copied().fold(0.0f64, f64::max);
362        let axes: Vec<usize> = (0..3).filter(|&a| mx / s[a] == 1.0).collect();
363        (axes.len() == 1).then(|| axes[0])
364    };
365    if aniso(current) {
366        lowres(current)
367    } else if aniso(new) {
368        lowres(new)
369    } else {
370        None
371    }
372}
373
374/// Per-slice `(min, max)` along `axis` of a C-order volume.
375fn slice_ranges(data: &[f64], dims: [usize; 3], axis: usize) -> Vec<(f64, f64)> {
376    let mut r = vec![(f64::INFINITY, f64::NEG_INFINITY); dims[axis]];
377    let mut idx = 0;
378    for i in 0..dims[0] {
379        for j in 0..dims[1] {
380            for k in 0..dims[2] {
381                let s = [i, j, k][axis];
382                let v = data[idx];
383                r[s] = (r[s].0.min(v), r[s].1.max(v));
384                idx += 1;
385            }
386        }
387    }
388    r
389}
390
391/// Apply `f(slice_index_along_axis, &mut value)` to every voxel of a C-order volume.
392fn for_each_slice(data: &mut [f64], dims: [usize; 3], axis: usize, mut f: impl FnMut(usize, &mut f64)) {
393    let mut idx = 0;
394    for i in 0..dims[0] {
395        for j in 0..dims[1] {
396            for k in 0..dims[2] {
397                f([i, j, k][axis], &mut data[idx]);
398                idx += 1;
399            }
400        }
401    }
402}
403
404/// The single "patch" covering a whole `(z, y, x)` volume: each axis rounded up to the network's
405/// divisor. [`predict_logits`] centres the volume in it and runs one window.
406pub(super) fn full_volume_patch(dims: [usize; 3]) -> [usize; 3] {
407    std::array::from_fn(|a| dims[a].div_ceil(PATCH_DIVISOR[a]).max(1) * PATCH_DIVISOR[a])
408}
409
410/// nnU-Net `compute_steps_for_sliding_window` for one axis.
411pub(crate) fn window_steps(size: usize, tile: usize, step: f64) -> Vec<usize> {
412    let n = ((size - tile) as f64 / (tile as f64 * step)).ceil() as usize + 1;
413    if n == 1 {
414        return vec![0];
415    }
416    let actual = (size - tile) as f64 / (n - 1) as f64;
417    (0..n).map(|i| (actual * i as f64).round_ties_even() as usize).collect()
418}
419
420/// nnU-Net `compute_gaussian(tile, sigma_scale=1/8, value_scaling_factor=10)`: a Gaussian
421/// centred on the patch (σ = tile/8 per axis), peak 10.
422pub(crate) fn gaussian_importance(tile: [usize; 3]) -> Vec<f32> {
423    let w: [Vec<f64>; 3] = std::array::from_fn(|a| {
424        let (c, s) = ((tile[a] / 2) as f64, tile[a] as f64 / 8.0);
425        (0..tile[a]).map(|i| (-0.5 * ((i as f64 - c) / s).powi(2)).exp()).collect()
426    });
427    let mut g = Vec::with_capacity(tile.iter().product());
428    for &a in &w[0] {
429        for &b in &w[1] {
430            for &c in &w[2] {
431                g.push((10.0 * a * b * c) as f32);
432            }
433        }
434    }
435    g
436}
437
438/// Gaussian-weighted sliding-window logits of an nnU-Net-style network with `channels` outputs,
439/// channel-major C-order (shape `[channels, dims...]`). `patch` must be a size the network
440/// accepts; `tile_step` is the stride as a fraction of the patch; `mirror_tta` averages over the
441/// 8 axis mirrorings. Shared with [`super::rs2net`].
442#[cfg(feature = "onnx")]
443#[allow(clippy::too_many_arguments)]
444pub(super) fn predict_logits(
445    data: &[f32],
446    dims: [usize; 3],
447    model: &OnnxModel,
448    patch: [usize; 3],
449    channels: usize,
450    tile_step: f64,
451    mirror_tta: bool,
452    mut progress: impl FnMut(usize, usize),
453) -> Result<Vec<f32>, OnnxError> {
454    // Pad (centred, extra voxel on the high side) to at least one patch, with zeros (= the mean
455    // after normalisation), as pad_nd_image does.
456    let pdims: [usize; 3] = std::array::from_fn(|a| dims[a].max(patch[a]));
457    let lo: [usize; 3] = std::array::from_fn(|a| (pdims[a] - dims[a]) / 2);
458    let np: usize = pdims.iter().product();
459    let mut padded = vec![0.0f32; np];
460    for z in 0..dims[0] {
461        for y in 0..dims[1] {
462            let src = (z * dims[1] + y) * dims[2];
463            let dst = ((z + lo[0]) * pdims[1] + y + lo[1]) * pdims[2] + lo[2];
464            padded[dst..dst + dims[2]].copy_from_slice(&data[src..src + dims[2]]);
465        }
466    }
467
468    let steps: [Vec<usize>; 3] = std::array::from_fn(|a| window_steps(pdims[a], patch[a], tile_step));
469    let windows = steps.iter().map(Vec::len).product::<usize>();
470    // One window covers everything: weight it uniformly (a Gaussian would cancel out anyway,
471    // apart from rounding where it is ~1e-10 at the corners).
472    let gauss = if windows == 1 { vec![1.0f32; patch.iter().product()] } else { gaussian_importance(patch) };
473    let pp: usize = patch.iter().product();
474    let shape = [1, 1, patch[0], patch[1], patch[2]];
475    let plan = model.plan_for(&[&shape])?;
476    let flips: &[[bool; 3]] = if mirror_tta {
477        &[[false, false, false], [true, false, false], [false, true, false], [false, false, true],
478          [true, true, false], [true, false, true], [false, true, true], [true, true, true]]
479    } else {
480        &[[false, false, false]]
481    };
482    let total = windows * flips.len();
483    let mut done = 0;
484
485    let mut acc = vec![0.0f32; channels * np];
486    let mut weight = vec![0.0f32; np];
487    let mut input = vec![0.0f32; pp];
488    let mut pred = vec![0.0f32; channels * pp];
489    for &s0 in &steps[0] {
490        for &s1 in &steps[1] {
491            for &s2 in &steps[2] {
492                for_each_patch_row(pdims, patch, [s0, s1, s2], |src, dst| {
493                    input[dst..dst + patch[2]].copy_from_slice(&padded[src..src + patch[2]]);
494                });
495                pred.iter_mut().for_each(|v| *v = 0.0);
496                for flip in flips {
497                    let x = flip3(&input, patch, *flip);
498                    let out = plan.run_single(&Tensor::new(shape.to_vec(), x))?;
499                    if out.shape != [1, channels, patch[0], patch[1], patch[2]] {
500                        return Err(OnnxError::Run(format!("unexpected network output shape {:?}", out.shape)));
501                    }
502                    for c in 0..channels {
503                        let back = flip3(&out.data[c * pp..(c + 1) * pp], patch, *flip);
504                        pred[c * pp..(c + 1) * pp].iter_mut().zip(back).for_each(|(p, v)| *p += v);
505                    }
506                    done += 1;
507                    progress(done, total);
508                }
509                let inv = 1.0 / flips.len() as f32;
510                for_each_patch_row(pdims, patch, [s0, s1, s2], |dst, src| {
511                    for k in 0..patch[2] {
512                        let g = gauss[src + k];
513                        weight[dst + k] += g;
514                        for c in 0..channels {
515                            acc[c * np + dst + k] += pred[c * pp + src + k] * inv * g;
516                        }
517                    }
518                });
519            }
520        }
521    }
522
523    // Normalise and crop the padding back off.
524    let n: usize = dims.iter().product();
525    let mut logits = vec![0.0f32; channels * n];
526    for c in 0..channels {
527        for z in 0..dims[0] {
528            for y in 0..dims[1] {
529                let src = ((z + lo[0]) * pdims[1] + y + lo[1]) * pdims[2] + lo[2];
530                let dst = (z * dims[1] + y) * dims[2];
531                for x in 0..dims[2] {
532                    logits[c * n + dst + x] = acc[c * np + src + x] / weight[src + x];
533                }
534            }
535        }
536    }
537    Ok(logits)
538}
539
540/// Visit the rows of a patch at `origin` inside a volume: `f(volume_row_start, patch_row_start)`.
541pub(super) fn for_each_patch_row(vdims: [usize; 3], patch: [usize; 3], origin: [usize; 3], mut f: impl FnMut(usize, usize)) {
542    for z in 0..patch[0] {
543        for y in 0..patch[1] {
544            let v = ((origin[0] + z) * vdims[1] + origin[1] + y) * vdims[2] + origin[2];
545            f(v, (z * patch[1] + y) * patch[2]);
546        }
547    }
548}
549
550/// Mirror a C-order patch along the flagged axes (an involution).
551pub(super) fn flip3(src: &[f32], dims: [usize; 3], flip: [bool; 3]) -> Vec<f32> {
552    if flip == [false; 3] {
553        return src.to_vec();
554    }
555    let mut out = vec![0.0f32; src.len()];
556    let m = |i: usize, a: usize| if flip[a] { dims[a] - 1 - i } else { i };
557    for z in 0..dims[0] {
558        for y in 0..dims[1] {
559            for x in 0..dims[2] {
560                out[(m(z, 0) * dims[1] + m(y, 1)) * dims[2] + m(x, 2)] = src[(z * dims[1] + y) * dims[2] + x];
561            }
562        }
563    }
564    out
565}
566
567/// Resample logits back to the cropped grid, argmax, un-crop. Returns the column-major mask.
568fn postprocess(logits: &[f32], pre: &Preprocessed) -> Vec<u8> {
569    let n = pre.data.len();
570    let bbox = pre.bbox;
571    let cdims = [bbox[0].1 - bbox[0].0, bbox[1].1 - bbox[1].0, bbox[2].1 - bbox[2].0];
572    let back = |c: usize| -> Vec<f64> {
573        let ch: Vec<f64> = logits[c * n..(c + 1) * n].iter().map(|&v| v as f64).collect();
574        resample_nnunet(&ch, pre.dims, cdims, TARGET_SPACING, pre.spacing, 1)
575    };
576    let (bg, fg) = (back(0), back(1));
577    let fd = pre.full_dims;
578    let mut mask = vec![0u8; fd.iter().product()];
579    let mut i = 0;
580    for z in bbox[0].0..bbox[0].1 {
581        for y in bbox[1].0..bbox[1].1 {
582            let row = (z * fd[1] + y) * fd[2];
583            for x in bbox[2].0..bbox[2].1 {
584                // argmax over (background, brain); a tie goes to background like torch.argmax
585                mask[row + x] = (fg[i] > bg[i]) as u8;
586                i += 1;
587            }
588        }
589    }
590    mask
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn window_steps_match_nnunet() {
599        // nnU-Net docstring example: image 110, patch 64, step 0.5 -> 0, 23, 46
600        assert_eq!(window_steps(110, 64, 0.5), vec![0, 23, 46]);
601        assert_eq!(window_steps(205, 96, 0.5), vec![0, 36, 73, 109]);
602        assert_eq!(window_steps(192, 192, 0.5), vec![0]);
603        assert_eq!(window_steps(205, 192, 1.0), vec![0, 13]);
604    }
605
606    #[test]
607    fn full_volume_patch_rounds_up_to_the_divisor() {
608        // (z, y, x): z to a multiple of 16, y and x to multiples of 32
609        assert_eq!(full_volume_patch([150, 240, 240]), [160, 256, 256]);
610        assert_eq!(full_volume_patch([160, 256, 224]), [160, 256, 224]);
611        assert_eq!(full_volume_patch([1, 1, 1]), [16, 32, 32]);
612        // ... so the sliding window over it is a single window
613        let p = full_volume_patch([150, 240, 241]);
614        assert!([150, 240, 241].iter().zip(p).all(|(&d, p)| window_steps(p.max(d), p, 1.0) == vec![0]));
615    }
616
617    #[test]
618    fn default_is_full_volume() {
619        assert_eq!(HdBetParams::default().inference, HdBetInference::FullVolume);
620        assert!(!HdBetParams::default().mirror_tta);
621        assert_eq!(HdBetParams::low_memory().inference, HdBetInference::LOW_MEMORY);
622    }
623
624    #[test]
625    fn gaussian_peaks_at_centre_with_value_10() {
626        let g = gaussian_importance([16, 32, 32]);
627        let centre = (8 * 32 + 16) * 32 + 16;
628        assert!((g[centre] - 10.0).abs() < 1e-6);
629        assert!(g.iter().all(|&v| v > 0.0 && v <= 10.0));
630    }
631
632    #[test]
633    fn separate_z_rule() {
634        assert_eq!(separate_z_axis([4.0, 0.9, 0.9], [1.0; 3]), Some(0));
635        assert_eq!(separate_z_axis([1.0; 3], [0.9, 0.9, 4.0]), Some(2));
636        assert_eq!(separate_z_axis([1.2, 0.9, 0.9], [1.0; 3]), None);
637        // two equally coarse axes -> no separate-z (nnU-Net: len(axis) == 2)
638        assert_eq!(separate_z_axis([4.0, 4.0, 1.0], [1.0; 3]), None);
639    }
640
641    /// The whole step's peak against [`hd_bet_memory_bytes`]: native peaks of `hd_bet` on a
642    /// synthetic head filling the field of view (`examples/inference_memory.rs hdbet`, counting
643    /// allocator, single-threaded). The estimate must cover each and sit within 5 % of it.
644    #[test]
645    fn memory_estimate_covers_measured_peaks() {
646        use crate::models::Heap;
647        let window = HdBetParams { inference: HdBetInference::SLIDING_WINDOW, ..HdBetParams::default() };
648        let cases = [
649            ((96, 96, 64), (2.0, 2.0, 2.0), HdBetParams::default(), 4_326_663_752u64),
650            ((184, 256, 128), (1.0, 1.0, 1.0), HdBetParams::default(), 5_675_605_064),
651            ((256, 288, 48), (0.8, 0.8, 3.0), HdBetParams::default(), 7_352_436_552),
652            ((240, 240, 160), (1.0, 1.0, 1.0), HdBetParams::default(), 9_257_042_005),
653            ((160, 192, 96), (1.5, 1.5, 1.5), HdBetParams::default(), 9_375_081_688),
654            ((184, 256, 128), (1.0, 1.0, 1.0), window.clone(), 3_371_351_512),
655            ((320, 320, 240), (0.6, 0.6, 0.6), window, 3_352_739_280),
656            ((184, 256, 128), (1.0, 1.0, 1.0), HdBetParams::low_memory(), 1_272_213_986),
657            ((256, 288, 48), (0.8, 0.8, 3.0), HdBetParams::low_memory(), 1_287_419_762),
658            ((320, 320, 240), (0.6, 0.6, 0.6), HdBetParams::low_memory(), 1_257_796_066),
659        ];
660        for (dims, voxel, params, peak) in cases {
661            let grid = Grid::new(dims.0, dims.1, dims.2, voxel.0, voxel.1, voxel.2);
662            let est = hd_bet_memory_bytes(&grid, &params, Heap::Native);
663            let rel = (est as f64 - peak as f64) / peak as f64;
664            assert!((0.0..=0.05).contains(&rel), "{dims:?} at {voxel:?}, {:?}: estimate {est} vs measured {peak}", params.inference);
665            assert!(hd_bet_memory_bytes(&grid, &params, Heap::Wasm32) > est, "{dims:?}: the WASM heap needs more");
666        }
667    }
668
669    #[test]
670    fn network_input_is_the_field_of_view_at_1mm() {
671        // 0.8×0.8×3 mm GRE, 256×288×48: 205×230×144 mm, padded to multiples of 32×32×16 (z, y, x).
672        let grid = Grid::new(256, 288, 48, 0.8, 0.8, 3.0);
673        assert_eq!(hd_bet_network_input(&grid, &HdBetParams::default()), [144, 256, 224]);
674        assert_eq!(hd_bet_network_input(&grid, &HdBetParams::low_memory()), [64, 128, 128]);
675    }
676
677    #[test]
678    fn flip_is_an_involution() {
679        let d = [2, 3, 4];
680        let v: Vec<f32> = (0..24).map(|i| i as f32).collect();
681        let f = flip3(&v, d, [true, false, true]);
682        assert_ne!(f, v);
683        assert_eq!(flip3(&f, d, [true, false, true]), v);
684        // voxel (0,0,0) of the flipped patch is source voxel (z=1, y=0, x=3)
685        assert_eq!(f[0], v[(3 * 4) + 3]);
686    }
687
688    #[test]
689    fn bbox_of_nonzero() {
690        let mut d = vec![0.0; 3 * 4 * 5];
691        let at = |z: usize, y: usize, x: usize| (z * 4 + y) * 5 + x;
692        d[at(1, 2, 3)] = 1.0;
693        d[at(2, 1, 1)] = -2.0;
694        assert_eq!(nonzero_bbox(&d, [3, 4, 5]), Some([(1, 3), (1, 3), (1, 4)]));
695        assert_eq!(nonzero_bbox(&[0.0; 8], [2, 2, 2]), None);
696    }
697
698    // ---- parity against nnU-Net intermediates (ref_hdbet.py in qsm-ci scripts/onnx-export) ----
699
700    /// Minimal little-endian f32 C-order `.npy` reader.
701    fn read_npy_f32(path: &str) -> (Vec<usize>, Vec<f32>) {
702        let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
703        assert_eq!(&b[..6], b"\x93NUMPY");
704        let (hlen, off) = if b[6] == 1 { (u16::from_le_bytes([b[8], b[9]]) as usize, 10) } else {
705            (u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize, 12)
706        };
707        let header = std::str::from_utf8(&b[off..off + hlen]).unwrap();
708        assert!(header.contains("'<f4'") && header.contains("'fortran_order': False"), "{header}");
709        let shape_str = header.split("'shape': (").nth(1).unwrap().split(')').next().unwrap();
710        let shape: Vec<usize> = shape_str.split(',').filter_map(|s| s.trim().parse().ok()).collect();
711        let data = b[off + hlen..].as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect();
712        (shape, data)
713    }
714
715    fn ref_dir() -> Option<String> {
716        std::env::var("HDBET_REF_DIR").ok()
717    }
718
719    fn load_case(dir: &str, case: &str) -> (Vec<f64>, Grid) {
720        let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/{case}_mag.nii.gz"))).unwrap();
721        let (nx, ny, nz) = nii.dims;
722        let (vx, vy, vz) = nii.voxel_size;
723        (nii.data, Grid::new(nx, ny, nz, vx, vy, vz))
724    }
725
726    /// Crop + z-score + resample vs nnU-Net's `run_case_npy` for the three reference cases.
727    #[test]
728    #[ignore]
729    fn preprocessing_matches_nnunet() {
730        let Some(dir) = ref_dir() else { return eprintln!("HDBET_REF_DIR not set; skipping") };
731        for case in ["A", "B", "C"] {
732            let (mag, grid) = load_case(&dir, case);
733            let pre = preprocess(&mag, &grid).unwrap();
734            let (shape, want) = read_npy_f32(&format!("{dir}/{case}_preprocessed.npy"));
735            assert_eq!(pre.dims.to_vec(), shape, "case {case}: shape");
736            let err = pre.data.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
737            eprintln!("case {case}: dims {:?}, bbox {:?}, max |d| {err:e}", pre.dims, pre.bbox);
738            assert!(err < 1e-3, "case {case}: preprocessed max |d| = {err}");
739        }
740    }
741
742    /// Resample-back + argmax + un-crop applied to nnU-Net's own logits reproduces the CLI mask.
743    #[test]
744    #[ignore]
745    fn postprocessing_matches_nnunet() {
746        let Some(dir) = ref_dir() else { return eprintln!("HDBET_REF_DIR not set; skipping") };
747        for case in ["A", "B", "C"] {
748            let (mag, grid) = load_case(&dir, case);
749            let pre = preprocess(&mag, &grid).unwrap();
750            let (_, logits) = read_npy_f32(&format!("{dir}/{case}_logits.npy"));
751            let mask = postprocess(&logits, &pre);
752            let cli = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/{case}_mask_cli.nii.gz"))).unwrap();
753            let diff = mask.iter().zip(&cli.data).filter(|(&m, &c)| (m != 0) != (c > 0.5)).count();
754            eprintln!("case {case}: {diff} voxels differ from the CLI mask");
755            assert!(diff * 100_000 < mask.len(), "case {case}: {diff} differing voxels");
756        }
757    }
758}