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. pad to at least one patch, then Gaussian-weighted sliding-window inference with 50 %
13//!    overlap (optionally with 8-fold mirroring test-time augmentation);
14//! 5. resample the logits back (linear; nearest through-plane for anisotropic voxels), take the
15//!    argmax and un-crop.
16//!
17//! nnU-Net works on SimpleITK arrays in `(z, y, x)` order, which is exactly the memory layout of
18//! the crate's column-major `(nx, ny, nz)` volumes — no transposition is needed.
19//!
20//! The input should be a magnitude image (for multi-echo GRE, the root-sum-of-squares over
21//! echoes works well) in roughly standard radiological orientation (axial slices along z), as
22//! HD-BET was trained on MNI-aligned data. HD-BET applies no post-processing; combine with
23//! [`crate::utils::fill_holes`] / erosion refinements as needed.
24//!
25//! Reference:
26//! Isensee, F., Schell, M., Pflueger, I., et al. (2019). "Automated brain extraction of
27//! multisequence MRI using artificial neural networks." Human Brain Mapping, 40(17):4952-4964.
28//! https://doi.org/10.1002/hbm.24750
29//!
30//! Reference implementation: https://github.com/MIC-DKFZ/HD-BET (Apache-2.0; weights CC-BY-NC-4.0).
31
32// The pre/post-processing is plain Rust; only inference needs `onnx`.
33#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
34
35use crate::grid::Grid;
36#[cfg(feature = "onnx")]
37use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
38use crate::utils::resample::{resize, resize_axis};
39
40/// HD-BET inference parameters.
41#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
42#[derive(Clone, Debug, PartialEq)]
43pub struct HdBetParams {
44    /// Sliding-window patch size `(px, py, pz)` in voxels at 1 mm (default `(192, 192, 96)`, the
45    /// size HD-BET was trained with). Must be multiples of `(32, 32, 16)`. Smaller patches bound
46    /// memory (peak ≈1.9 GB for `(128, 128, 64)` vs ≈4.5 GB native, on a 164×205×205 volume) at a
47    /// small accuracy cost; below ~`(128, 128, 64)` tiles that lie wholly inside the brain start
48    /// to be labelled background.
49    pub patch: (usize, usize, usize),
50    /// Sliding-window step as a fraction of the patch (default 0.5, nnU-Net's).
51    pub tile_step: f64,
52    /// 8-fold mirroring test-time augmentation (default off, as in QSM-CI; 8× slower for a
53    /// marginal gain).
54    pub mirror_tta: bool,
55}
56
57impl Default for HdBetParams {
58    fn default() -> Self {
59        Self { patch: (192, 192, 96), tile_step: 0.5, mirror_tta: false }
60    }
61}
62
63impl HdBetParams {
64    /// Memory-bounded setting for constrained hosts (e.g. 32-bit WASM): `(128, 128, 64)` patches,
65    /// peak ≈1.9 GB on a 164×205×205 volume. Agreed with the native patch at Dice ≈0.99 in
66    /// validation (and with nnU-Net run at the same patch size to 2 voxels).
67    pub fn low_memory() -> Self {
68        Self { patch: (128, 128, 64), ..Self::default() }
69    }
70}
71
72/// nnU-Net target spacing for HD-BET (mm).
73const TARGET_SPACING: [f64; 3] = [1.0, 1.0, 1.0];
74/// Required divisibility of the patch in nnU-Net `(z, y, x)` order (5 down-samplings, the last
75/// one in-plane only).
76const PATCH_DIVISOR: [usize; 3] = [16, 32, 32];
77
78/// Brain mask via HD-BET.
79///
80/// * `magnitude` — magnitude image, column-major `(nx, ny, nz)`.
81/// * `model_onnx` — bytes of the exported `hd-bet.onnx` (see [`crate::models`]).
82/// * `progress(done, total)` — called after each network evaluation.
83///
84/// Returns a binary mask (0/1) on the input grid.
85#[cfg(feature = "onnx")]
86pub fn hd_bet(
87    magnitude: &[f64],
88    grid: &Grid,
89    model_onnx: &[u8],
90    params: &HdBetParams,
91    progress: impl FnMut(usize, usize),
92) -> Result<Vec<u8>, OnnxError> {
93    let (nx, ny, nz) = grid.dims;
94    assert_eq!(magnitude.len(), nx * ny * nz, "magnitude length must match grid");
95    let patch = [params.patch.2, params.patch.1, params.patch.0];
96    if patch.iter().zip(PATCH_DIVISOR).any(|(&p, d)| p == 0 || p % d != 0) {
97        return Err(OnnxError::Shape(format!(
98            "HD-BET patch {:?} must be a non-zero multiple of (32, 32, 16)",
99            params.patch
100        )));
101    }
102    if !(params.tile_step > 0.0 && params.tile_step <= 1.0) {
103        return Err(OnnxError::Shape(format!("tile_step {} must be in (0, 1]", params.tile_step)));
104    }
105    let Some(pre) = preprocess(magnitude, grid) else {
106        return Ok(vec![0; magnitude.len()]);
107    };
108    let model = OnnxModel::load(model_onnx)?;
109    let logits = predict_logits(&pre.data, pre.dims, &model, patch, params, progress)?;
110    Ok(postprocess(&logits, &pre))
111}
112
113/// nnU-Net-preprocessed volume plus what is needed to map predictions back.
114struct Preprocessed {
115    /// Cropped, normalised, resampled volume, C-order `(z, y, x)`.
116    data: Vec<f32>,
117    dims: [usize; 3],
118    /// Crop box `[start, end)` per axis in the original `(z, y, x)` volume.
119    bbox: [(usize, usize); 3],
120    /// Original volume shape `(z, y, x)`.
121    full_dims: [usize; 3],
122    /// Original spacing `(z, y, x)`.
123    spacing: [f64; 3],
124}
125
126/// Crop to non-zero, z-score, resample to 1 mm. `None` if the image is entirely zero.
127fn preprocess(magnitude: &[f64], grid: &Grid) -> Option<Preprocessed> {
128    let (nx, ny, nz) = grid.dims;
129    let (vx, vy, vz) = grid.voxel_size;
130    let full_dims = [nz, ny, nx];
131    let spacing = [vz, vy, vx];
132
133    let bbox = nonzero_bbox(magnitude, full_dims)?;
134    let cdims = [bbox[0].1 - bbox[0].0, bbox[1].1 - bbox[1].0, bbox[2].1 - bbox[2].0];
135    let mut img = Vec::with_capacity(cdims.iter().product());
136    for z in bbox[0].0..bbox[0].1 {
137        for y in bbox[1].0..bbox[1].1 {
138            let row = (z * ny + y) * nx;
139            img.extend_from_slice(&magnitude[row + bbox[2].0..row + bbox[2].1]);
140        }
141    }
142
143    // ZScoreNormalization without a mask (HD-BET's plans: use_mask_for_norm = false).
144    let n = img.len() as f64;
145    let mean = img.iter().sum::<f64>() / n;
146    let std = (img.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n).sqrt();
147    let s = std.max(1e-8);
148    img.iter_mut().for_each(|v| *v = (*v - mean) / s);
149
150    // compute_new_shape: round(spacing / target * shape), Python round (half to even).
151    let rdims: [usize; 3] =
152        std::array::from_fn(|a| (spacing[a] / TARGET_SPACING[a] * cdims[a] as f64).round_ties_even() as usize);
153    let data = resample_nnunet(&img, cdims, rdims, spacing, TARGET_SPACING, 3)
154        .into_iter()
155        .map(|v| v as f32)
156        .collect();
157    Some(Preprocessed { data, dims: rdims, bbox, full_dims, spacing })
158}
159
160/// Bounding box `[start, end)` of the non-zero voxels of a C-order `(z, y, x)` volume.
161fn nonzero_bbox(data: &[f64], dims: [usize; 3]) -> Option<[(usize, usize); 3]> {
162    let mut lo = [usize::MAX; 3];
163    let mut hi = [0usize; 3];
164    let mut any = false;
165    for z in 0..dims[0] {
166        for y in 0..dims[1] {
167            let row = (z * dims[1] + y) * dims[2];
168            for x in 0..dims[2] {
169                if data[row + x] != 0.0 {
170                    any = true;
171                    for (a, c) in [z, y, x].into_iter().enumerate() {
172                        lo[a] = lo[a].min(c);
173                        hi[a] = hi[a].max(c + 1);
174                    }
175                }
176            }
177        }
178    }
179    any.then(|| std::array::from_fn(|a| (lo[a], hi[a])))
180}
181
182/// nnU-Net `resample_data_or_seg_to_shape` for one channel (non-segmentation, `order_z = 0`,
183/// `force_separate_z = None`): a plain 3D spline resize, unless the voxels are anisotropic
184/// (max/min spacing > 3), in which case each slice is resized in-plane with `order` and the
185/// low-resolution axis with nearest-neighbour.
186fn resample_nnunet(
187    data: &[f64],
188    dims: [usize; 3],
189    new_dims: [usize; 3],
190    current_spacing: [f64; 3],
191    new_spacing: [f64; 3],
192    order: usize,
193) -> Vec<f64> {
194    if dims == new_dims {
195        return data.to_vec();
196    }
197    let Some(axis) = separate_z_axis(current_spacing, new_spacing) else {
198        return resize(data, dims, new_dims, order);
199    };
200    let inplane: Vec<usize> = (0..3).filter(|&a| a != axis).collect();
201    let mut out = data.to_vec();
202    let mut cur = dims;
203    if inplane.iter().any(|&a| dims[a] != new_dims[a]) {
204        // skimage.resize on each 2D slice: both in-plane axes (a same-length axis still goes
205        // through the spline for order 3, as in scipy), then clip each slice to its own range.
206        for &a in &inplane {
207            if order == 3 || cur[a] != new_dims[a] {
208                (out, cur) = resize_axis(&out, cur, a, new_dims[a], order);
209            }
210        }
211        if order > 1 {
212            let range = slice_ranges(data, dims, axis);
213            for_each_slice(&mut out, cur, axis, |k, v| *v = v.clamp(range[k].0, range[k].1));
214        }
215    }
216    if cur[axis] != new_dims[axis] {
217        (out, _) = resize_axis(&out, cur, axis, new_dims[axis], 0);
218    }
219    out
220}
221
222/// nnU-Net `determine_do_sep_z_and_axis(force_separate_z=None, ...)`: the low-resolution axis if
223/// either spacing is anisotropic by more than 3×, and that axis is unique.
224fn separate_z_axis(current: [f64; 3], new: [f64; 3]) -> Option<usize> {
225    let aniso = |s: [f64; 3]| {
226        let (mn, mx) = s.iter().fold((f64::INFINITY, 0.0f64), |(a, b), &v| (a.min(v), b.max(v)));
227        mx / mn > 3.0
228    };
229    let lowres = |s: [f64; 3]| {
230        let mx = s.iter().copied().fold(0.0f64, f64::max);
231        let axes: Vec<usize> = (0..3).filter(|&a| mx / s[a] == 1.0).collect();
232        (axes.len() == 1).then(|| axes[0])
233    };
234    if aniso(current) {
235        lowres(current)
236    } else if aniso(new) {
237        lowres(new)
238    } else {
239        None
240    }
241}
242
243/// Per-slice `(min, max)` along `axis` of a C-order volume.
244fn slice_ranges(data: &[f64], dims: [usize; 3], axis: usize) -> Vec<(f64, f64)> {
245    let mut r = vec![(f64::INFINITY, f64::NEG_INFINITY); dims[axis]];
246    let mut idx = 0;
247    for i in 0..dims[0] {
248        for j in 0..dims[1] {
249            for k in 0..dims[2] {
250                let s = [i, j, k][axis];
251                let v = data[idx];
252                r[s] = (r[s].0.min(v), r[s].1.max(v));
253                idx += 1;
254            }
255        }
256    }
257    r
258}
259
260/// Apply `f(slice_index_along_axis, &mut value)` to every voxel of a C-order volume.
261fn for_each_slice(data: &mut [f64], dims: [usize; 3], axis: usize, mut f: impl FnMut(usize, &mut f64)) {
262    let mut idx = 0;
263    for i in 0..dims[0] {
264        for j in 0..dims[1] {
265            for k in 0..dims[2] {
266                f([i, j, k][axis], &mut data[idx]);
267                idx += 1;
268            }
269        }
270    }
271}
272
273/// nnU-Net `compute_steps_for_sliding_window` for one axis.
274fn window_steps(size: usize, tile: usize, step: f64) -> Vec<usize> {
275    let n = ((size - tile) as f64 / (tile as f64 * step)).ceil() as usize + 1;
276    if n == 1 {
277        return vec![0];
278    }
279    let actual = (size - tile) as f64 / (n - 1) as f64;
280    (0..n).map(|i| (actual * i as f64).round_ties_even() as usize).collect()
281}
282
283/// nnU-Net `compute_gaussian(tile, sigma_scale=1/8, value_scaling_factor=10)`: a Gaussian
284/// centred on the patch (σ = tile/8 per axis), peak 10.
285fn gaussian_importance(tile: [usize; 3]) -> Vec<f32> {
286    let w: [Vec<f64>; 3] = std::array::from_fn(|a| {
287        let (c, s) = ((tile[a] / 2) as f64, tile[a] as f64 / 8.0);
288        (0..tile[a]).map(|i| (-0.5 * ((i as f64 - c) / s).powi(2)).exp()).collect()
289    });
290    let mut g = Vec::with_capacity(tile.iter().product());
291    for &a in &w[0] {
292        for &b in &w[1] {
293            for &c in &w[2] {
294                g.push((10.0 * a * b * c) as f32);
295            }
296        }
297    }
298    g
299}
300
301/// Gaussian-weighted sliding-window logits (2 channels, C-order `(z, y, x)`, shape `dims`).
302#[cfg(feature = "onnx")]
303fn predict_logits(
304    data: &[f32],
305    dims: [usize; 3],
306    model: &OnnxModel,
307    patch: [usize; 3],
308    params: &HdBetParams,
309    mut progress: impl FnMut(usize, usize),
310) -> Result<Vec<f32>, OnnxError> {
311    // Pad (centred, extra voxel on the high side) to at least one patch, with zeros (= the mean
312    // after normalisation), as pad_nd_image does.
313    let pdims: [usize; 3] = std::array::from_fn(|a| dims[a].max(patch[a]));
314    let lo: [usize; 3] = std::array::from_fn(|a| (pdims[a] - dims[a]) / 2);
315    let np: usize = pdims.iter().product();
316    let mut padded = vec![0.0f32; np];
317    for z in 0..dims[0] {
318        for y in 0..dims[1] {
319            let src = (z * dims[1] + y) * dims[2];
320            let dst = ((z + lo[0]) * pdims[1] + y + lo[1]) * pdims[2] + lo[2];
321            padded[dst..dst + dims[2]].copy_from_slice(&data[src..src + dims[2]]);
322        }
323    }
324
325    let steps: [Vec<usize>; 3] = std::array::from_fn(|a| window_steps(pdims[a], patch[a], params.tile_step));
326    let gauss = gaussian_importance(patch);
327    let pp: usize = patch.iter().product();
328    let shape = [1, 1, patch[0], patch[1], patch[2]];
329    let plan = model.plan_for(&[&shape])?;
330    let flips: &[[bool; 3]] = if params.mirror_tta {
331        &[[false, false, false], [true, false, false], [false, true, false], [false, false, true],
332          [true, true, false], [true, false, true], [false, true, true], [true, true, true]]
333    } else {
334        &[[false, false, false]]
335    };
336    let total = steps.iter().map(Vec::len).product::<usize>() * flips.len();
337    let mut done = 0;
338
339    let mut acc = vec![0.0f32; 2 * np];
340    let mut weight = vec![0.0f32; np];
341    let mut input = vec![0.0f32; pp];
342    let mut pred = vec![0.0f32; 2 * pp];
343    for &s0 in &steps[0] {
344        for &s1 in &steps[1] {
345            for &s2 in &steps[2] {
346                for_each_patch_row(pdims, patch, [s0, s1, s2], |src, dst| {
347                    input[dst..dst + patch[2]].copy_from_slice(&padded[src..src + patch[2]]);
348                });
349                pred.iter_mut().for_each(|v| *v = 0.0);
350                for flip in flips {
351                    let x = flip3(&input, patch, *flip);
352                    let out = plan.run_single(&Tensor::new(shape.to_vec(), x))?;
353                    if out.shape != [1, 2, patch[0], patch[1], patch[2]] {
354                        return Err(OnnxError::Run(format!("unexpected HD-BET output shape {:?}", out.shape)));
355                    }
356                    for c in 0..2 {
357                        let back = flip3(&out.data[c * pp..(c + 1) * pp], patch, *flip);
358                        pred[c * pp..(c + 1) * pp].iter_mut().zip(back).for_each(|(p, v)| *p += v);
359                    }
360                    done += 1;
361                    progress(done, total);
362                }
363                let inv = 1.0 / flips.len() as f32;
364                for_each_patch_row(pdims, patch, [s0, s1, s2], |dst, src| {
365                    for k in 0..patch[2] {
366                        let g = gauss[src + k];
367                        weight[dst + k] += g;
368                        for c in 0..2 {
369                            acc[c * np + dst + k] += pred[c * pp + src + k] * inv * g;
370                        }
371                    }
372                });
373            }
374        }
375    }
376
377    // Normalise and crop the padding back off.
378    let n: usize = dims.iter().product();
379    let mut logits = vec![0.0f32; 2 * n];
380    for c in 0..2 {
381        for z in 0..dims[0] {
382            for y in 0..dims[1] {
383                let src = ((z + lo[0]) * pdims[1] + y + lo[1]) * pdims[2] + lo[2];
384                let dst = (z * dims[1] + y) * dims[2];
385                for x in 0..dims[2] {
386                    logits[c * n + dst + x] = acc[c * np + src + x] / weight[src + x];
387                }
388            }
389        }
390    }
391    Ok(logits)
392}
393
394/// Visit the rows of a patch at `origin` inside a volume: `f(volume_row_start, patch_row_start)`.
395fn for_each_patch_row(vdims: [usize; 3], patch: [usize; 3], origin: [usize; 3], mut f: impl FnMut(usize, usize)) {
396    for z in 0..patch[0] {
397        for y in 0..patch[1] {
398            let v = ((origin[0] + z) * vdims[1] + origin[1] + y) * vdims[2] + origin[2];
399            f(v, (z * patch[1] + y) * patch[2]);
400        }
401    }
402}
403
404/// Mirror a C-order patch along the flagged axes (an involution).
405fn flip3(src: &[f32], dims: [usize; 3], flip: [bool; 3]) -> Vec<f32> {
406    if flip == [false; 3] {
407        return src.to_vec();
408    }
409    let mut out = vec![0.0f32; src.len()];
410    let m = |i: usize, a: usize| if flip[a] { dims[a] - 1 - i } else { i };
411    for z in 0..dims[0] {
412        for y in 0..dims[1] {
413            for x in 0..dims[2] {
414                out[(m(z, 0) * dims[1] + m(y, 1)) * dims[2] + m(x, 2)] = src[(z * dims[1] + y) * dims[2] + x];
415            }
416        }
417    }
418    out
419}
420
421/// Resample logits back to the cropped grid, argmax, un-crop. Returns the column-major mask.
422fn postprocess(logits: &[f32], pre: &Preprocessed) -> Vec<u8> {
423    let n = pre.data.len();
424    let bbox = pre.bbox;
425    let cdims = [bbox[0].1 - bbox[0].0, bbox[1].1 - bbox[1].0, bbox[2].1 - bbox[2].0];
426    let back = |c: usize| -> Vec<f64> {
427        let ch: Vec<f64> = logits[c * n..(c + 1) * n].iter().map(|&v| v as f64).collect();
428        resample_nnunet(&ch, pre.dims, cdims, TARGET_SPACING, pre.spacing, 1)
429    };
430    let (bg, fg) = (back(0), back(1));
431    let fd = pre.full_dims;
432    let mut mask = vec![0u8; fd.iter().product()];
433    let mut i = 0;
434    for z in bbox[0].0..bbox[0].1 {
435        for y in bbox[1].0..bbox[1].1 {
436            let row = (z * fd[1] + y) * fd[2];
437            for x in bbox[2].0..bbox[2].1 {
438                // argmax over (background, brain); a tie goes to background like torch.argmax
439                mask[row + x] = (fg[i] > bg[i]) as u8;
440                i += 1;
441            }
442        }
443    }
444    mask
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn window_steps_match_nnunet() {
453        // nnU-Net docstring example: image 110, patch 64, step 0.5 -> 0, 23, 46
454        assert_eq!(window_steps(110, 64, 0.5), vec![0, 23, 46]);
455        assert_eq!(window_steps(205, 96, 0.5), vec![0, 36, 73, 109]);
456        assert_eq!(window_steps(192, 192, 0.5), vec![0]);
457        assert_eq!(window_steps(205, 192, 1.0), vec![0, 13]);
458    }
459
460    #[test]
461    fn gaussian_peaks_at_centre_with_value_10() {
462        let g = gaussian_importance([16, 32, 32]);
463        let centre = (8 * 32 + 16) * 32 + 16;
464        assert!((g[centre] - 10.0).abs() < 1e-6);
465        assert!(g.iter().all(|&v| v > 0.0 && v <= 10.0));
466    }
467
468    #[test]
469    fn separate_z_rule() {
470        assert_eq!(separate_z_axis([4.0, 0.9, 0.9], [1.0; 3]), Some(0));
471        assert_eq!(separate_z_axis([1.0; 3], [0.9, 0.9, 4.0]), Some(2));
472        assert_eq!(separate_z_axis([1.2, 0.9, 0.9], [1.0; 3]), None);
473        // two equally coarse axes -> no separate-z (nnU-Net: len(axis) == 2)
474        assert_eq!(separate_z_axis([4.0, 4.0, 1.0], [1.0; 3]), None);
475    }
476
477    #[test]
478    fn flip_is_an_involution() {
479        let d = [2, 3, 4];
480        let v: Vec<f32> = (0..24).map(|i| i as f32).collect();
481        let f = flip3(&v, d, [true, false, true]);
482        assert_ne!(f, v);
483        assert_eq!(flip3(&f, d, [true, false, true]), v);
484        // voxel (0,0,0) of the flipped patch is source voxel (z=1, y=0, x=3)
485        assert_eq!(f[0], v[(3 * 4) + 3]);
486    }
487
488    #[test]
489    fn bbox_of_nonzero() {
490        let mut d = vec![0.0; 3 * 4 * 5];
491        let at = |z: usize, y: usize, x: usize| (z * 4 + y) * 5 + x;
492        d[at(1, 2, 3)] = 1.0;
493        d[at(2, 1, 1)] = -2.0;
494        assert_eq!(nonzero_bbox(&d, [3, 4, 5]), Some([(1, 3), (1, 3), (1, 4)]));
495        assert_eq!(nonzero_bbox(&[0.0; 8], [2, 2, 2]), None);
496    }
497
498    // ---- parity against nnU-Net intermediates (ref_hdbet.py in qsm-ci scripts/onnx-export) ----
499
500    /// Minimal little-endian f32 C-order `.npy` reader.
501    fn read_npy_f32(path: &str) -> (Vec<usize>, Vec<f32>) {
502        let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
503        assert_eq!(&b[..6], b"\x93NUMPY");
504        let (hlen, off) = if b[6] == 1 { (u16::from_le_bytes([b[8], b[9]]) as usize, 10) } else {
505            (u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize, 12)
506        };
507        let header = std::str::from_utf8(&b[off..off + hlen]).unwrap();
508        assert!(header.contains("'<f4'") && header.contains("'fortran_order': False"), "{header}");
509        let shape_str = header.split("'shape': (").nth(1).unwrap().split(')').next().unwrap();
510        let shape: Vec<usize> = shape_str.split(',').filter_map(|s| s.trim().parse().ok()).collect();
511        let data = b[off + hlen..].chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect();
512        (shape, data)
513    }
514
515    fn ref_dir() -> Option<String> {
516        std::env::var("HDBET_REF_DIR").ok()
517    }
518
519    fn load_case(dir: &str, case: &str) -> (Vec<f64>, Grid) {
520        let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/{case}_mag.nii.gz"))).unwrap();
521        let (nx, ny, nz) = nii.dims;
522        let (vx, vy, vz) = nii.voxel_size;
523        (nii.data, Grid::new(nx, ny, nz, vx, vy, vz))
524    }
525
526    /// Crop + z-score + resample vs nnU-Net's `run_case_npy` for the three reference cases.
527    #[test]
528    #[ignore]
529    fn preprocessing_matches_nnunet() {
530        let Some(dir) = ref_dir() else { return eprintln!("HDBET_REF_DIR not set; skipping") };
531        for case in ["A", "B", "C"] {
532            let (mag, grid) = load_case(&dir, case);
533            let pre = preprocess(&mag, &grid).unwrap();
534            let (shape, want) = read_npy_f32(&format!("{dir}/{case}_preprocessed.npy"));
535            assert_eq!(pre.dims.to_vec(), shape, "case {case}: shape");
536            let err = pre.data.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
537            eprintln!("case {case}: dims {:?}, bbox {:?}, max |d| {err:e}", pre.dims, pre.bbox);
538            assert!(err < 1e-3, "case {case}: preprocessed max |d| = {err}");
539        }
540    }
541
542    /// Resample-back + argmax + un-crop applied to nnU-Net's own logits reproduces the CLI mask.
543    #[test]
544    #[ignore]
545    fn postprocessing_matches_nnunet() {
546        let Some(dir) = ref_dir() else { return eprintln!("HDBET_REF_DIR not set; skipping") };
547        for case in ["A", "B", "C"] {
548            let (mag, grid) = load_case(&dir, case);
549            let pre = preprocess(&mag, &grid).unwrap();
550            let (_, logits) = read_npy_f32(&format!("{dir}/{case}_logits.npy"));
551            let mask = postprocess(&logits, &pre);
552            let cli = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/{case}_mask_cli.nii.gz"))).unwrap();
553            let diff = mask.iter().zip(&cli.data).filter(|(&m, &c)| (m != 0) != (c > 0.5)).count();
554            eprintln!("case {case}: {diff} voxels differ from the CLI mask");
555            assert!(diff * 100_000 < mask.len(), "case {case}: {diff} differing voxels");
556        }
557    }
558}