1#![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#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
42#[derive(Clone, Debug, PartialEq)]
43pub struct HdBetParams {
44 pub patch: (usize, usize, usize),
50 pub tile_step: f64,
52 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 pub fn low_memory() -> Self {
68 Self { patch: (128, 128, 64), ..Self::default() }
69 }
70}
71
72const TARGET_SPACING: [f64; 3] = [1.0, 1.0, 1.0];
74const PATCH_DIVISOR: [usize; 3] = [16, 32, 32];
77
78#[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
113struct Preprocessed {
115 data: Vec<f32>,
117 dims: [usize; 3],
118 bbox: [(usize, usize); 3],
120 full_dims: [usize; 3],
122 spacing: [f64; 3],
124}
125
126fn 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 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 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
160fn 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
182fn 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 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
222fn 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
243fn 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
260fn 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
273fn 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
283fn 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#[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 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 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
394fn 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
404fn 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
421fn 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 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 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 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 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 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 #[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 #[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}