1#![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#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub enum HdBetInference {
85 FullVolume,
90 SlidingWindow {
92 patch: (usize, usize, usize),
98 tile_step: f64,
100 },
101}
102
103impl HdBetInference {
104 pub const SLIDING_WINDOW: Self = Self::SlidingWindow { patch: (192, 192, 96), tile_step: 0.5 };
106 pub const LOW_MEMORY: Self = Self::SlidingWindow { patch: (128, 128, 64), tile_step: 0.5 };
108}
109
110#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
112#[derive(Clone, Debug, PartialEq)]
113pub struct HdBetParams {
114 pub inference: HdBetInference,
116 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 pub fn low_memory() -> Self {
133 Self { inference: HdBetInference::LOW_MEMORY, ..Self::default() }
134 }
135}
136
137const TARGET_SPACING: [f64; 3] = [1.0, 1.0, 1.0];
139const PATCH_DIVISOR: [usize; 3] = [16, 32, 32];
142
143#[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
183struct Preprocessed {
185 data: Vec<f32>,
187 dims: [usize; 3],
188 bbox: [(usize, usize); 3],
190 full_dims: [usize; 3],
192 spacing: [f64; 3],
194}
195
196fn 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 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
228fn 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
234pub 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
252pub 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 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 let pre = 16 * c + 16 * big + 4 * r;
280 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 let post = spec.resident_bytes() + 20 * r + 16 * big + 16 * c + c;
288 pre.max(net).max(post)
289}
290
291pub(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
313pub(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 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
353pub(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
374fn 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
391fn 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
404pub(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
410pub(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
420pub(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#[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 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 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 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
540pub(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
550pub(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
567fn 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 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 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 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 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 assert_eq!(separate_z_axis([4.0, 4.0, 1.0], [1.0; 3]), None);
639 }
640
641 #[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, ¶ms, 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, ¶ms, 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 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 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 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 #[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 #[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}