1#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
90
91#[cfg(feature = "onnx")]
92use crate::grid::Grid;
93#[cfg(feature = "onnx")]
94use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
95#[cfg(feature = "onnx")]
96use crate::utils::connected::{largest_component, Connectivity};
97
98pub const CONFORM_DIM: usize = 256;
100
101const CONFORM_F_HIGH: f64 = 0.98;
104
105#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
107#[derive(Clone, Debug, PartialEq, Default)]
108pub struct MindGrabParams {
109 pub flip_tta: bool,
112}
113
114#[cfg(feature = "onnx")]
123pub fn mindgrab(
124 image: &[f64],
125 grid: &Grid,
126 affine: &[f64; 16],
127 model_onnx: &[u8],
128 params: &MindGrabParams,
129 mut progress: impl FnMut(usize, usize),
130) -> Result<Vec<u8>, OnnxError> {
131 let (nx, ny, nz) = grid.dims;
132 assert_eq!(image.len(), nx * ny * nz, "image length must match grid");
133 let conformed = Conformed::new(image, grid.dims, affine)
134 .ok_or_else(|| OnnxError::Shape("MindGrab needs a non-singular affine".into()))?;
135 let input = normalize(&conformed.data);
136
137 let model = OnnxModel::load_edited(model_onnx, pool_instance_norm_means)?;
138 let n = CONFORM_DIM;
139 let shape = vec![1, 1, n, n, n];
140 let passes = if params.flip_tta { 2 } else { 1 };
141 let mut logits = model.run_single(&Tensor::new(shape.clone(), input.clone()))?.data;
142 progress(1, passes);
143 if params.flip_tta {
144 let flipped = model.run_single(&Tensor::new(shape, flip_axis0(&input, 1)))?.data;
145 logits.iter_mut().zip(flip_axis0(&flipped, 2)).for_each(|(l, f)| *l += f);
146 progress(2, passes);
147 }
148
149 let mask = argmax_mask(&logits);
150 let mask = largest_component(&mask, (n, n, n), Connectivity::Full);
151 Ok(conformed.reslice_back(&mask))
152}
153
154struct Conformed {
161 data: Vec<u8>,
163 dims: (usize, usize, usize),
165 in_affine: M44,
167 out_affine: M44,
169}
170
171impl Conformed {
172 fn new(image: &[f64], dims: (usize, usize, usize), affine: &[f64; 16]) -> Option<Self> {
175 let in_affine: M44 = std::array::from_fn(|r| std::array::from_fn(|c| affine[4 * r + c] as f32));
176 let out_affine = conform_affine(dims, &in_affine);
177 let to_input = vox2vox_inverse(&in_affine, &out_affine)?;
179 let scaled = scale_to_u8_range(image);
180 let data = reslice_linear(&scaled, dims, &to_input);
181 Some(Self { data, dims, in_affine, out_affine })
182 }
183
184 fn reslice_back(&self, mask: &[u8]) -> Vec<u8> {
187 let (nx, ny, nz) = self.dims;
188 let n = CONFORM_DIM as i64;
189 let Some(m) = vox2vox_inverse(&self.out_affine, &self.in_affine) else {
191 return vec![0; nx * ny * nz];
192 };
193 let mut out = vec![0u8; nx * ny * nz];
194 for z in 0..nz {
195 for y in 0..ny {
196 let yz = row_yz(&m, y, z);
197 for x in 0..nx {
198 let c: [i64; 3] = std::array::from_fn(|r| (x as f32 * m[r][0] + yz[r]).round() as i64);
199 if c.iter().all(|&v| (0..n).contains(&v)) {
200 out[x + nx * (y + ny * z)] = mask[(c[0] + n * (c[1] + n * c[2])) as usize];
201 }
202 }
203 }
204 }
205 out
206 }
207}
208
209type M44 = [[f32; 4]; 4];
211
212fn mul44(a: &M44, b: &M44) -> M44 {
214 std::array::from_fn(|i| {
215 std::array::from_fn(|j| {
216 let mut c = 0.0f32;
217 for k in 0..4 {
218 c += a[i][k] * b[k][j];
219 }
220 c
221 })
222 })
223}
224
225fn inv44(r: &M44) -> Option<M44> {
228 let g = |i: usize, j: usize| r[i][j] as f64;
229 let (r11, r12, r13, r21, r22, r23, r31, r32, r33) =
230 (g(0, 0), g(0, 1), g(0, 2), g(1, 0), g(1, 1), g(1, 2), g(2, 0), g(2, 1), g(2, 2));
231 let (v1, v2, v3) = (g(0, 3), g(1, 3), g(2, 3));
232 let det = r11 * r22 * r33 - r11 * r32 * r23 - r21 * r12 * r33 + r21 * r32 * r13 + r31 * r12 * r23
233 - r31 * r22 * r13;
234 if det == 0.0 {
235 return None;
236 }
237 let d = 1.0 / det;
238 let f = |v: f64| (d * v) as f32;
239 Some([
240 [
241 f(r22 * r33 - r32 * r23),
242 f(-r12 * r33 + r32 * r13),
243 f(r12 * r23 - r22 * r13),
244 f(-r12 * r23 * v3 + r12 * v2 * r33 + r22 * r13 * v3 - r22 * v1 * r33 - r32 * r13 * v2 + r32 * v1 * r23),
245 ],
246 [
247 f(-r21 * r33 + r31 * r23),
248 f(r11 * r33 - r31 * r13),
249 f(-r11 * r23 + r21 * r13),
250 f(r11 * r23 * v3 - r11 * v2 * r33 - r21 * r13 * v3 + r21 * v1 * r33 + r31 * r13 * v2 - r31 * v1 * r23),
251 ],
252 [
253 f(r21 * r32 - r31 * r22),
254 f(-r11 * r32 + r31 * r12),
255 f(r11 * r22 - r21 * r12),
256 f(-r11 * r22 * v3 + r11 * r32 * v2 + r21 * r12 * v3 - r21 * r32 * v1 - r31 * r12 * v2 + r31 * r22 * v1),
257 ],
258 [0.0, 0.0, 0.0, 1.0],
259 ])
260}
261
262fn mul_vec(m: &M44, v: [f32; 3]) -> [f32; 4] {
264 let v = [v[0], v[1], v[2], 1.0];
265 std::array::from_fn(|i| {
266 let mut o = 0.0f32;
267 for j in 0..4 {
268 o += m[i][j] * v[j];
269 }
270 o
271 })
272}
273
274fn vox2vox_inverse(src: &M44, dst: &M44) -> Option<M44> {
277 inv44(&mul44(&inv44(dst)?, src))
278}
279
280fn row_yz(m: &M44, y: usize, z: usize) -> [f32; 3] {
282 std::array::from_fn(|r| y as f32 * m[r][1] + z as f32 * m[r][2] + m[r][3])
283}
284
285fn conform_affine(dims: (usize, usize, usize), affine: &M44) -> M44 {
289 let half = [dims.0 as f32 / 2.0, dims.1 as f32 / 2.0, dims.2 as f32 / 2.0];
290 let centre = mul_vec(affine, half);
291 let mut out: M44 = [
293 [-1.0, 0.0, 0.0, 0.0],
294 [0.0, 0.0, 1.0, 0.0],
295 [0.0, -1.0, 0.0, 0.0],
296 [0.0, 0.0, 0.0, 1.0],
297 ];
298 let n = CONFORM_DIM as f32;
299 let vol_centre = mul_vec(&out, [n, n, n]).map(|v| v * 0.5);
300 for r in 0..3 {
301 out[r][3] = centre[r] - vol_centre[r];
302 }
303 out
304}
305
306fn scale_to_u8_range(image: &[f64]) -> Vec<f32> {
313 let img: Vec<f32> = image.iter().map(|&v| v as f32).collect();
314 let n = img.len();
315 let (mut src_min, src_max) = img.iter().fold((img[0], img[0]), |(lo, hi), &v| (lo.min(v), hi.max(v)));
316 let nz = img.iter().filter(|v| v.abs() >= 1e-15).count();
317 let nltz = img.iter().filter(|&&v| v < 0.0).count();
318 if src_min < 0.0 && 100.0 * (nltz as f64 / n as f64) < 2.0 {
319 src_min = 0.0;
320 }
321
322 const BINS: usize = 1000;
323 let bin_size = (src_max - src_min) / BINS as f32;
324 let mut cs = vec![0usize; BINS];
325 for &v in &img {
326 let b = ((v - src_min) / bin_size) as i64;
328 cs[b.clamp(0, BINS as i64 - 1) as usize] += 1;
329 }
330 for i in 1..BINS {
331 cs[i] += cs[i - 1];
332 }
333 let nth = n - ((1.0 - CONFORM_F_HIGH as f32) * nz as f32) as usize;
335 let mut idx = 0;
336 while idx < BINS - 1 && cs[idx + 1] < nth {
337 idx += 1;
338 }
339 let robust_max = idx as f32 * bin_size + src_min;
340 let scale = if src_min != robust_max { 255.0 / (robust_max - src_min) } else { 1.0 };
341 img.iter().map(|&v| (scale * (v - src_min)).clamp(0.0, 255.0)).collect()
342}
343
344fn reslice_linear(img: &[f32], (nx, ny, nz): (usize, usize, usize), m: &M44) -> Vec<u8> {
349 let n = CONFORM_DIM;
350 let fill = img.iter().copied().fold(f32::INFINITY, f32::min);
351 let mut out = vec![fill; n * n * n];
352 let (sy, sz) = (nx, nx * ny);
353 for z in 0..n {
354 for y in 0..n {
355 let yz = row_yz(m, y, z);
356 for x in 0..n {
357 let [ix, iy, iz]: [f32; 3] = std::array::from_fn(|r| x as f32 * m[r][0] + yz[r]);
358 let (fx, fy, fz) = (ix.floor(), iy.floor(), iz.floor());
359 if fx < 0.0 || fy < 0.0 || fz < 0.0 {
360 continue;
361 }
362 let (fx, fy, fz) = (fx as usize, fy as usize, fz as usize);
363 if fx + 1 >= nx || fy + 1 >= ny || fz + 1 >= nz {
364 continue;
365 }
366 let (dx, dy, dz) = (ix - fx as f32, iy - fy as f32, iz - fz as f32);
367 let (wx0, wy0, wz0) = (1.0 - dx, 1.0 - dy, 1.0 - dz);
368 let i000 = fx + fy * sy + fz * sz;
369 let mut v = 0.0f32;
371 v += img[i000] * wx0 * wy0 * wz0;
372 v += img[i000 + sz] * wx0 * wy0 * dz;
373 v += img[i000 + sy] * wx0 * dy * wz0;
374 v += img[i000 + sy + sz] * wx0 * dy * dz;
375 v += img[i000 + 1] * dx * wy0 * wz0;
376 v += img[i000 + 1 + sz] * dx * wy0 * dz;
377 v += img[i000 + 1 + sy] * dx * dy * wz0;
378 v += img[i000 + 1 + sy + sz] * dx * dy * dz;
379 out[x + n * (y + n * z)] = v;
380 }
381 }
382 }
383 out.iter().map(|&v| v.round().clamp(0.0, 255.0) as u8).collect()
385}
386
387fn normalize(data: &[u8]) -> Vec<f32> {
391 let mut hist = [0usize; 256];
392 data.iter().for_each(|&v| hist[v as usize] += 1);
393 let at = |pos: usize| {
395 let mut cum = 0;
396 for (v, &c) in hist.iter().enumerate() {
397 cum += c;
398 if cum > pos {
399 return v as f64;
400 }
401 }
402 255.0
403 };
404 let quantile = |q: f64| {
405 let p = q * (data.len() - 1) as f64;
406 let (lo, frac) = (p.floor() as usize, p - p.floor());
407 let a = at(lo);
408 if frac == 0.0 { a } else { a + (at(lo + 1) - a) * frac }
409 };
410 let (qlow, qhigh) = (quantile(0.02) as f32, quantile(0.98) as f32);
413 let denom = qhigh - qlow + 1e-3f32;
414 let lut: Vec<f32> = (0..256).map(|v| ((v as f32 - qlow) / denom).clamp(0.0, 1.0)).collect();
415
416 let n = CONFORM_DIM;
417 let mut out = vec![0.0f32; n * n * n];
418 for z in 0..n {
419 for y in 0..n {
420 for x in 0..n {
421 out[(x * n + y) * n + z] = lut[data[x + n * (y + n * z)] as usize];
422 }
423 }
424 }
425 out
426}
427
428const NORM_POOL: usize = 16;
431
432#[cfg(feature = "onnx")]
439fn pool_instance_norm_means(proto: &mut tract_onnx::pb::ModelProto) -> Result<(), OnnxError> {
440 use tract_onnx::pb::{attribute_proto::AttributeType, AttributeProto, NodeProto};
441 let graph = proto.graph.as_mut().ok_or_else(|| OnnxError::Load("MindGrab: ONNX model has no graph".into()))?;
442 let ints = |name: &str| AttributeProto {
443 name: name.into(),
444 r#type: AttributeType::Ints as i32,
445 ints: vec![NORM_POOL as i64; 3],
446 ..Default::default()
447 };
448 let mut rewritten = 0;
449 let mut nodes = Vec::with_capacity(graph.node.len() + 64);
450 for node in std::mem::take(&mut graph.node) {
451 let spatial_mean = node.op_type == "ReduceMean"
452 && node.input.len() == 1
453 && node.attribute.iter().any(|a| a.name == "axes" && a.ints == [2, 3, 4])
454 && node.attribute.iter().all(|a| a.name != "keepdims" || a.i == 1);
455 if !spatial_mean {
456 nodes.push(node);
457 continue;
458 }
459 let pooled = format!("{}/window_means", node.output[0]);
460 nodes.push(NodeProto {
461 input: node.input.clone(),
462 output: vec![pooled.clone()],
463 name: format!("{}/window_means", node.name),
464 op_type: "AveragePool".into(),
465 attribute: vec![ints("kernel_shape"), ints("strides")],
466 ..Default::default()
467 });
468 nodes.push(NodeProto { input: vec![pooled], ..node });
469 rewritten += 1;
470 }
471 graph.node = nodes;
472 if rewritten == 0 {
473 return Err(OnnxError::Load("MindGrab: no instance-norm ReduceMean over axes [2, 3, 4] to restate".into()));
474 }
475 Ok(())
476}
477
478fn argmax_mask(logits: &[f32]) -> Vec<u8> {
481 let n = CONFORM_DIM;
482 let n3 = n * n * n;
483 assert_eq!(logits.len(), 2 * n3, "MindGrab must output two 256³ channels");
484 let (bg, fg) = logits.split_at(n3);
485 let mut mask = vec![0u8; n3];
486 for x in 0..n {
487 for y in 0..n {
488 for z in 0..n {
489 let i = (x * n + y) * n + z;
490 mask[x + n * (y + n * z)] = (fg[i] > bg[i]) as u8;
491 }
492 }
493 }
494 mask
495}
496
497fn flip_axis0(data: &[f32], channels: usize) -> Vec<f32> {
500 let n = CONFORM_DIM;
501 let plane = n * n;
502 let mut out = vec![0.0f32; data.len()];
503 for c in 0..channels {
504 let base = c * n * plane;
505 for x in 0..n {
506 let (src, dst) = (base + x * plane, base + (n - 1 - x) * plane);
507 out[dst..dst + plane].copy_from_slice(&data[src..src + plane]);
508 }
509 }
510 out
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 #[rustfmt::skip]
518 const RAS_1MM: [f64; 16] = [
519 1.0, 0.0, 0.0, -10.0,
520 0.0, 1.0, 0.0, -20.0,
521 0.0, 0.0, 1.0, -30.0,
522 0.0, 0.0, 0.0, 1.0,
523 ];
524
525 #[test]
526 fn conform_affine_is_lia_centred_on_the_input() {
527 let aff: M44 = std::array::from_fn(|r| std::array::from_fn(|c| RAS_1MM[4 * r + c] as f32));
528 let a = conform_affine((40, 60, 80), &aff);
529 assert_eq!(mul_vec(&a, [128.0, 128.0, 128.0])[..3], [10.0, 10.0, 10.0]);
531 assert_eq!([a[0][0], a[1][0], a[2][0]], [-1.0, 0.0, 0.0]);
533 assert_eq!([a[0][1], a[1][1], a[2][1]], [0.0, 0.0, -1.0]);
534 assert_eq!([a[0][2], a[1][2], a[2][2]], [0.0, 1.0, 0.0]);
535 }
536
537 #[test]
538 fn mask_round_trips_through_the_conformed_grid() {
539 let dims = (40, 60, 80);
541 let n = dims.0 * dims.1 * dims.2;
542 let mut img = vec![0.0; n];
543 for z in 20..60 {
544 for y in 15..45 {
545 for x in 10..30 {
546 img[x + dims.0 * (y + dims.1 * z)] = 100.0;
547 }
548 }
549 }
550 let c = Conformed::new(&img, dims, &RAS_1MM).unwrap();
551 let mask: Vec<u8> = c.data.iter().map(|&v| (v > 127) as u8).collect();
552 let back = c.reslice_back(&mask);
553 let want: Vec<u8> = img.iter().map(|&v| (v > 50.0) as u8).collect();
554 assert_eq!(back, want);
555 }
556
557 #[test]
558 fn intensity_scaling_maps_robust_range_to_u8() {
559 let img: Vec<f64> = (0..10_000).map(|i| if i < 9_800 { (i % 98 + 1) as f64 } else { 1000.0 }).collect();
561 let s = scale_to_u8_range(&img);
562 assert_eq!(s.iter().cloned().fold(f32::MIN, f32::max), 255.0);
563 assert!(s[..9_800].iter().all(|&v| v < 255.0));
564 assert_eq!(s.iter().cloned().fold(f32::MAX, f32::min), 0.0);
565 }
566
567 #[test]
568 fn normalize_matches_numpy_quantiles() {
569 let n = CONFORM_DIM;
571 let data: Vec<u8> = (0..n * n * n).map(|i| (i % 256) as u8).collect();
572 let out = normalize(&data);
573 let total = (n * n * n) as f64;
575 let per = total / 256.0;
576 let q = |q: f64| {
577 let p = q * (total - 1.0);
578 let lo = (p.floor() / per).floor();
579 let hi = ((p.floor() + 1.0) / per).floor();
580 lo + (hi - lo) * (p - p.floor())
581 };
582 let (ql, qh) = (q(0.02), q(0.98));
583 let (ql, qh) = (ql as f32, qh as f32);
585 let want = |v: f64| ((v as f32 - ql) / (qh - ql + 1e-3f32)).clamp(0.0, 1.0);
586 assert_eq!(out[200 * n * n], want(200.0));
588 assert_eq!(out[0], 0.0);
589 assert_eq!(out[255 * n * n], 1.0);
590 }
591
592 #[test]
593 fn flip_is_an_involution() {
594 let n = CONFORM_DIM;
595 let v: Vec<f32> = (0..n * n * n).map(|i| (i % 977) as f32).collect();
596 assert_eq!(flip_axis0(&flip_axis0(&v, 1), 1), v);
597 assert_eq!(flip_axis0(&v, 1)[0], v[(n - 1) * n * n]);
598 }
599
600 #[cfg(feature = "onnx")]
603 fn spatial_mean_graph() -> tract_onnx::pb::ModelProto {
604 use tract_onnx::pb::*;
605 let attr = |name: &str, ints: Vec<i64>, i: i64, t: attribute_proto::AttributeType| AttributeProto {
606 name: name.into(),
607 r#type: t as i32,
608 ints,
609 i,
610 ..Default::default()
611 };
612 let value = |name: &str, rank: usize| ValueInfoProto {
614 name: name.into(),
615 r#type: Some(TypeProto {
616 value: Some(type_proto::Value::TensorType(type_proto::Tensor {
617 elem_type: tensor_proto::DataType::Float as i32,
618 shape: Some(TensorShapeProto {
619 dim: (0..rank)
620 .map(|d| tensor_shape_proto::Dimension {
621 value: Some(tensor_shape_proto::dimension::Value::DimParam(format!("d{d}"))),
622 ..Default::default()
623 })
624 .collect(),
625 }),
626 })),
627 ..Default::default()
628 }),
629 ..Default::default()
630 };
631 ModelProto {
632 ir_version: 8,
633 opset_import: vec![OperatorSetIdProto { domain: String::new(), version: 17 }],
634 graph: Some(GraphProto {
635 name: "spatial_mean".into(),
636 node: vec![NodeProto {
637 input: vec!["x".into()],
638 output: vec!["m".into()],
639 name: "mean".into(),
640 op_type: "ReduceMean".into(),
641 attribute: vec![
642 attr("axes", vec![2, 3, 4], 0, attribute_proto::AttributeType::Ints),
643 attr("keepdims", vec![], 1, attribute_proto::AttributeType::Int),
644 ],
645 ..Default::default()
646 }],
647 input: vec![value("x", 5)],
648 output: vec![value("m", 5)],
649 ..Default::default()
650 }),
651 ..Default::default()
652 }
653 }
654
655 #[test]
660 #[cfg(feature = "onnx")]
661 fn restated_instance_norm_mean_is_accurate() {
662 let n = CONFORM_DIM;
663 let x: Vec<f32> = (0..n * n * n)
664 .map(|i| {
665 let (a, b, c) = (i % n, (i / n) % n, i / (n * n));
666 let inside = [a, b, c].iter().all(|&v| (40..216).contains(&v));
667 if inside { 0.1 + 0.05 * ((i * 7919 % 1009) as f32 / 1009.0) } else { 0.003 }
668 })
669 .collect();
670 let exact = x.iter().map(|&v| v as f64).sum::<f64>() / x.len() as f64;
671 let mean_of = |proto: &tract_onnx::pb::ModelProto| -> f64 {
672 let model = OnnxModel::from_proto(proto).unwrap();
673 model.run_single(&Tensor::new(vec![1, 1, n, n, n], x.clone())).unwrap().data[0] as f64
674 };
675 let plain = mean_of(&spatial_mean_graph());
676 let mut proto = spatial_mean_graph();
677 pool_instance_norm_means(&mut proto).unwrap();
678 assert_eq!(proto.graph.as_ref().unwrap().node.len(), 2, "one AveragePool in front of the mean");
679 let pooled = mean_of(&proto);
680 let (e_plain, e_pooled) = ((plain - exact).abs() / exact, (pooled - exact).abs() / exact);
681 eprintln!("relative error of the mean: tract ReduceMean {e_plain:.2e}, restated {e_pooled:.2e}");
682 assert!(e_pooled < 1e-5, "restated mean off by {e_pooled:.2e}");
684 assert!(e_plain > 1e-3, "tract's ReduceMean is now accurate ({e_plain:.2e}): restatement unneeded?");
686 }
687
688 #[test]
690 #[cfg(feature = "onnx")]
691 fn restatement_refuses_a_graph_without_spatial_means() {
692 let mut proto = spatial_mean_graph();
693 proto.graph.as_mut().unwrap().node[0].op_type = "ReduceMax".into();
694 assert!(pool_instance_norm_means(&mut proto).is_err());
695 }
696
697 fn read_npy_f32(path: &str) -> (Vec<usize>, Vec<f32>) {
701 let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
702 assert_eq!(&b[..6], b"\x93NUMPY");
703 let (hlen, off) = if b[6] == 1 {
704 (u16::from_le_bytes([b[8], b[9]]) as usize, 10)
705 } else {
706 (u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize, 12)
707 };
708 let header = std::str::from_utf8(&b[off..off + hlen]).unwrap();
709 assert!(header.contains("'<f4'") && header.contains("'fortran_order': False"), "{header}");
710 let shape_str = header.split("'shape': (").nth(1).unwrap().split(')').next().unwrap();
711 let shape: Vec<usize> = shape_str.split(',').filter_map(|s| s.trim().parse().ok()).collect();
712 let data = b[off + hlen..].as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect();
713 (shape, data)
714 }
715
716 type Ref = (String, crate::io::NiftiData);
718
719 fn load_ref() -> Option<Ref> {
721 let dir = std::env::var("MINDGRAB_REF_DIR").ok()?;
722 let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/mag.nii"))).unwrap();
723 Some((dir, nii))
724 }
725
726 fn read_conformed(dir: &str, name: &str) -> Vec<u8> {
727 let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/{name}"))).unwrap();
728 assert_eq!(nii.dims, (CONFORM_DIM, CONFORM_DIM, CONFORM_DIM));
729 nii.data.iter().map(|&v| v as u8).collect()
730 }
731
732 #[test]
734 #[ignore]
735 fn conform_matches_niimath() {
736 let Some((dir, mag)) = load_ref() else { return eprintln!("MINDGRAB_REF_DIR not set; skipping") };
737 let c = Conformed::new(&mag.data, mag.dims, &mag.affine).unwrap();
738 let want = read_conformed(&dir, "conformed.nii");
739 let diff: Vec<i32> = c.data.iter().zip(&want).map(|(&a, &b)| a as i32 - b as i32).collect();
740 let n_diff = diff.iter().filter(|&&d| d != 0).count();
741 let max = diff.iter().map(|d| d.abs()).max().unwrap();
742 eprintln!("{n_diff} conformed voxels differ, max |d| {max}");
743 assert!(max <= 1 && n_diff < 100, "{n_diff} voxels differ, max |d| = {max}");
745 }
746
747 #[test]
749 #[ignore]
750 fn normalization_matches_brainchop() {
751 let Some((dir, ..)) = load_ref() else { return eprintln!("MINDGRAB_REF_DIR not set; skipping") };
752 let got = normalize(&read_conformed(&dir, "conformed.nii"));
753 let (shape, want) = read_npy_f32(&format!("{dir}/ref_input.npy"));
754 assert_eq!(shape, vec![1, 1, CONFORM_DIM, CONFORM_DIM, CONFORM_DIM]);
755 let err = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
756 assert!(err < 1e-6, "max |d| = {err}");
757 }
758
759 #[test]
762 #[ignore]
763 #[cfg(feature = "onnx")]
764 fn postprocessing_matches_brainchop() {
765 let Some((dir, mag)) = load_ref() else { return eprintln!("MINDGRAB_REF_DIR not set; skipping") };
766 let (_, logits) = read_npy_f32(&format!("{dir}/ref_logits.npy"));
767 let n = CONFORM_DIM;
768 let mask = largest_component(&argmax_mask(&logits), (n, n, n), Connectivity::Full);
769 let want_c = read_conformed(&dir, "ref_mask_conformed.nii");
770 let diff_c = mask.iter().zip(&want_c).filter(|(&m, &w)| (m != 0) != (w != 0)).count();
771 eprintln!("{diff_c} conformed voxels differ after the largest component");
772
773 let c = Conformed::new(&mag.data, mag.dims, &mag.affine).unwrap();
775 let back = c.reslice_back(&want_c.iter().map(|&w| (w != 0) as u8).collect::<Vec<_>>());
776 let want = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/ref_mask.nii.gz"))).unwrap();
777 let diff = back.iter().zip(&want.data).filter(|(&m, &w)| (m != 0) != (w > 0.5)).count();
778 eprintln!("{diff} native voxels differ from niimath -reslice_nn");
779 assert_eq!(diff_c, 0, "largest component: {diff_c} differing voxels");
780 assert_eq!(diff, 0, "reslice: {diff} differing voxels");
781 }
782}