1#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
49
50use super::hdbet::{nonzero_bbox, resample_nnunet};
51#[cfg(feature = "onnx")]
52use super::hdbet::predict_logits;
53use crate::grid::Grid;
54#[cfg(feature = "onnx")]
55use crate::models::onnx::{OnnxError, OnnxModel};
56
57const TARGET_SPACING: [f64; 3] = [0.25, 0.2, 0.16];
59
60pub const RS2_NET_PATCH: [usize; 3] = [128, 96, 128];
62
63#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
65#[derive(Clone, Debug, PartialEq)]
66pub struct Rs2NetParams {
67 pub tile_step: f64,
69 pub mirror_tta: bool,
72}
73
74impl Default for Rs2NetParams {
75 fn default() -> Self {
76 Self { tile_step: 0.5, mirror_tta: false }
77 }
78}
79
80#[cfg(feature = "onnx")]
88pub fn rs2_net(
89 magnitude: &[f64],
90 grid: &Grid,
91 model_onnx: &[u8],
92 params: &Rs2NetParams,
93 progress: impl FnMut(usize, usize),
94) -> Result<Vec<u8>, OnnxError> {
95 let (nx, ny, nz) = grid.dims;
96 assert_eq!(magnitude.len(), nx * ny * nz, "magnitude length must match grid");
97 if !(params.tile_step > 0.0 && params.tile_step <= 1.0) {
98 return Err(OnnxError::Shape(format!("tile_step {} must be in (0, 1]", params.tile_step)));
99 }
100 let Some(pre) = preprocess(magnitude, grid) else {
101 return Ok(vec![0; magnitude.len()]);
102 };
103 let model = OnnxModel::load(model_onnx)?;
104 let logits = predict_logits(
105 &pre.data, pre.dims, &model, RS2_NET_PATCH, 1, params.tile_step, params.mirror_tta, progress,
106 )?;
107 Ok(postprocess(&logits, &pre))
108}
109
110struct Preprocessed {
112 data: Vec<f32>,
114 dims: [usize; 3],
115 bbox: [(usize, usize); 3],
117 full_dims: [usize; 3],
119 spacing: [f64; 3],
121}
122
123fn transpose_forward(data: &[f64], (nx, ny, nz): (usize, usize, usize)) -> Vec<f64> {
125 let mut out = vec![0.0; data.len()];
126 for z in 0..nz {
127 for y in 0..ny {
128 let (src, dst) = ((z * ny + y) * nx, (y * nz + z) * nx);
129 out[dst..dst + nx].copy_from_slice(&data[src..src + nx]);
130 }
131 }
132 out
133}
134
135fn preprocess(magnitude: &[f64], grid: &Grid) -> Option<Preprocessed> {
137 let (nx, ny, nz) = grid.dims;
138 let (vx, vy, vz) = grid.voxel_size;
139 let full_dims = [ny, nz, nx];
140 let spacing = [vy, vz, vx];
141 let data = transpose_forward(magnitude, grid.dims);
142
143 let bbox = nonzero_bbox(&data, full_dims)?;
144 let cdims: [usize; 3] = std::array::from_fn(|a| bbox[a].1 - bbox[a].0);
145 let mut img = Vec::with_capacity(cdims.iter().product());
146 for i in bbox[0].0..bbox[0].1 {
147 for j in bbox[1].0..bbox[1].1 {
148 let row = (i * full_dims[1] + j) * full_dims[2];
149 img.extend_from_slice(&data[row + bbox[2].0..row + bbox[2].1]);
150 }
151 }
152
153 let n = img.len() as f64;
155 let mean = img.iter().sum::<f64>() / n;
156 let std = (img.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n).sqrt();
157 let s = std.max(1e-8);
158 img.iter_mut().for_each(|v| *v = (*v - mean) / s);
159
160 let rdims: [usize; 3] =
162 std::array::from_fn(|a| (spacing[a] / TARGET_SPACING[a] * cdims[a] as f64).round_ties_even() as usize);
163 let data = resample_nnunet(&img, cdims, rdims, spacing, TARGET_SPACING, 1)
165 .into_iter()
166 .map(|v| v as f32)
167 .collect();
168 Some(Preprocessed { data, dims: rdims, bbox, full_dims, spacing })
169}
170
171fn postprocess(logits: &[f32], pre: &Preprocessed) -> Vec<u8> {
174 let bbox = pre.bbox;
175 let cdims: [usize; 3] = std::array::from_fn(|a| bbox[a].1 - bbox[a].0);
176 let ch: Vec<f64> = logits.iter().map(|&v| v as f64).collect();
177 let back = resample_nnunet(&ch, pre.dims, cdims, TARGET_SPACING, pre.spacing, 1);
178 let [ny, nz, nx] = pre.full_dims;
179 let mut mask = vec![0u8; ny * nz * nx];
180 let mut idx = 0;
181 for y in bbox[0].0..bbox[0].1 {
182 for z in bbox[1].0..bbox[1].1 {
183 for x in bbox[2].0..bbox[2].1 {
184 mask[(z * ny + y) * nx + x] = (back[idx] > 0.0) as u8;
186 idx += 1;
187 }
188 }
189 }
190 mask
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn transpose_swaps_z_and_y() {
199 let (nx, ny, nz) = (2, 3, 4);
200 let v: Vec<f64> = (0..24).map(|i| i as f64).collect();
201 let t = transpose_forward(&v, (nx, ny, nz));
202 assert_eq!(t[(2 * nz + 3) * nx + 1], v[(3 * ny + 2) * nx + 1]);
204 }
205
206 #[test]
207 fn patch_is_swin_compatible() {
208 assert!(RS2_NET_PATCH.iter().all(|&p| p > 0 && p % 32 == 0));
210 }
211
212 #[test]
213 fn empty_image_gives_no_preprocessing() {
214 assert!(preprocess(&[0.0; 8], &Grid::new(2, 2, 2, 0.1, 0.1, 0.1)).is_none());
215 }
216
217 #[test]
218 fn postprocess_inverts_preprocess_geometry() {
219 let (nx, ny, nz) = (40, 30, 8);
223 let mut v = vec![0.001; nx * ny * nz];
224 for z in 2..6 {
225 for y in 8..22 {
226 for x in 10..30 {
227 v[(z * ny + y) * nx + x] = 1.0;
228 }
229 }
230 }
231 let pre = preprocess(&v, &Grid::new(nx, ny, nz, 0.2, 0.2, 0.8)).unwrap();
232 assert_eq!(super::super::hdbet::separate_z_axis(pre.spacing, TARGET_SPACING), Some(1));
234 let (lo, hi) = pre.data.iter().fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v)));
235 let logits: Vec<f32> = pre.data.iter().map(|v| v - (lo + hi) / 2.0).collect();
236 let mask = postprocess(&logits, &pre);
237 let want: Vec<u8> = v.iter().map(|&x| (x > 0.5) as u8).collect();
238 assert_eq!(mask, want);
239 }
240
241 fn read_npy_f32(path: &str) -> (Vec<usize>, Vec<f32>) {
245 let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
246 assert_eq!(&b[..6], b"\x93NUMPY");
247 let (hlen, off) = if b[6] == 1 {
248 (u16::from_le_bytes([b[8], b[9]]) as usize, 10)
249 } else {
250 (u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize, 12)
251 };
252 let header = std::str::from_utf8(&b[off..off + hlen]).unwrap();
253 assert!(header.contains("'<f4'") && header.contains("'fortran_order': False"), "{header}");
254 let shape_str = header.split("'shape': (").nth(1).unwrap().split(')').next().unwrap();
255 let shape: Vec<usize> = shape_str.split(',').filter_map(|s| s.trim().parse().ok()).collect();
256 let data = b[off + hlen..].as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect();
257 (shape, data)
258 }
259
260 fn load_ref() -> Option<(String, Vec<f64>, Grid)> {
263 let dir = std::env::var("RS2_REF_DIR").ok()?;
264 let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/mag.nii"))).unwrap();
265 let (nx, ny, nz) = nii.dims;
266 let (vx, vy, vz) = nii.voxel_size;
267 Some((dir, nii.data, Grid::new(nx, ny, nz, vx, vy, vz)))
268 }
269
270 #[test]
272 #[ignore]
273 fn preprocessing_matches_rs2net() {
274 let Some((dir, mag, grid)) = load_ref() else { return eprintln!("RS2_REF_DIR not set; skipping") };
275 let pre = preprocess(&mag, &grid).unwrap();
276 let (shape, want) = read_npy_f32(&format!("{dir}/ref_preprocessed.npy"));
277 assert_eq!(pre.dims.to_vec(), shape);
278 let err = pre.data.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
279 eprintln!("dims {:?}, bbox {:?}, max |d| {err:e}", pre.dims, pre.bbox);
280 assert!(err < 1e-3, "preprocessed max |d| = {err}");
281 }
282
283 #[test]
286 #[ignore]
287 fn postprocessing_matches_rs2net() {
288 let Some((dir, mag, grid)) = load_ref() else { return eprintln!("RS2_REF_DIR not set; skipping") };
289 let pre = preprocess(&mag, &grid).unwrap();
290 let (shape, logits) = read_npy_f32(&format!("{dir}/ref_logits.npy"));
291 let n: usize = shape[1..].iter().product();
293 let mask = postprocess(&logits[..n], &pre);
294 let want = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/ref_mask.nii.gz"))).unwrap();
295 let diff = mask.iter().zip(&want.data).filter(|(&m, &w)| (m != 0) != (w > 0.5)).count();
296 eprintln!("{diff} voxels differ from RS2-Net's mask");
297 assert_eq!(diff, 0, "{diff} differing voxels");
298 }
299}