1use std::io::Cursor;
7use nifti::{NiftiObject, InMemNiftiObject, NiftiHeader};
8use nifti::volume::ndarray::IntoNdArray;
9use flate2::read::GzDecoder;
10use ndarray::Array;
11
12pub struct NiftiData {
14 pub data: Vec<f64>,
16 pub dims: (usize, usize, usize),
18 pub voxel_size: (f64, f64, f64),
20 pub affine: [f64; 16],
22 pub scl_slope: f64,
24 pub scl_inter: f64,
26}
27
28fn is_gzip(bytes: &[u8]) -> bool {
30 bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
31}
32
33fn get_header_info(bytes: &[u8]) -> String {
35 if bytes.len() < 348 {
36 return format!("File too small ({} bytes, need at least 348)", bytes.len());
37 }
38
39 let sizeof_hdr = i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
41
42 let magic = if bytes.len() >= 348 {
44 String::from_utf8_lossy(&bytes[344..348]).to_string()
45 } else {
46 "N/A".to_string()
47 };
48
49 let datatype = if bytes.len() >= 72 {
51 i16::from_le_bytes([bytes[70], bytes[71]])
52 } else {
53 -1
54 };
55
56 format!("sizeof_hdr={}, magic='{}', datatype={}", sizeof_hdr, magic, datatype)
57}
58
59pub fn load_nifti(bytes: &[u8]) -> Result<NiftiData, String> {
63 let obj: InMemNiftiObject = if is_gzip(bytes) {
64 let cursor = Cursor::new(bytes);
65 let decoder = GzDecoder::new(cursor);
66 InMemNiftiObject::from_reader(decoder)
67 .map_err(|e| {
68 let mut decoder2 = GzDecoder::new(Cursor::new(bytes));
70 let mut decompressed = Vec::new();
71 let info = if std::io::Read::read_to_end(&mut decoder2, &mut decompressed).is_ok() {
72 get_header_info(&decompressed)
73 } else {
74 "Could not decompress".to_string()
75 };
76 format!("Failed to read gzipped NIfTI: {} ({})", e, info)
77 })?
78 } else {
79 let info = get_header_info(bytes);
80 let cursor = Cursor::new(bytes);
81 InMemNiftiObject::from_reader(cursor)
82 .map_err(|e| format!("Failed to read NIfTI: {} ({})", e, info))?
83 };
84
85 let header = obj.header();
86
87 let dim = header.dim;
89 let ndim = dim[0] as usize;
90 if ndim < 3 {
91 return Err(format!("Expected at least 3D volume, got {}D", ndim));
92 }
93
94 let pixdim = header.pixdim;
96 let vsx = pixdim[1] as f64;
97 let vsy = pixdim[2] as f64;
98 let vsz = pixdim[3] as f64;
99
100 let scl_slope = if header.scl_slope == 0.0 { 1.0 } else { header.scl_slope as f64 };
102 let scl_inter = header.scl_inter as f64;
103
104 let affine = get_affine(header);
106
107 let volume = obj.into_volume();
109 let array: Array<f64, _> = volume.into_ndarray()
110 .map_err(|e| format!("Failed to convert to ndarray: {}", e))?;
111
112 let shape = array.shape();
114
115 if shape.len() < 3 {
117 return Err(format!("Expected at least 3D array, got {}D", shape.len()));
118 }
119
120 let (dim0, dim1, dim2) = (shape[0], shape[1], shape[2]);
122 let expected_size = dim0 * dim1 * dim2;
123
124 let mut data = Vec::with_capacity(expected_size);
127
128 if shape.len() == 3 {
130 for k in 0..dim2 {
131 for j in 0..dim1 {
132 for i in 0..dim0 {
133 data.push(array[[i, j, k]]);
134 }
135 }
136 }
137 } else if shape.len() >= 4 {
138 for k in 0..dim2 {
140 for j in 0..dim1 {
141 for i in 0..dim0 {
142 data.push(array[[i, j, k, 0]]);
143 }
144 }
145 }
146 }
147
148 Ok(NiftiData {
151 data,
152 dims: (dim0, dim1, dim2),
153 voxel_size: (vsx, vsy, vsz),
154 affine,
155 scl_slope,
156 scl_inter,
157 })
158}
159
160pub type Nifti4d = (Vec<f64>, (usize, usize, usize, usize), (f64, f64, f64), [f64; 16]);
162
163pub fn load_nifti_4d(bytes: &[u8]) -> Result<Nifti4d, String> {
165 let obj: InMemNiftiObject = if is_gzip(bytes) {
166 let cursor = Cursor::new(bytes);
167 let decoder = GzDecoder::new(cursor);
168 InMemNiftiObject::from_reader(decoder)
169 .map_err(|e| format!("Failed to read gzipped NIfTI: {}", e))?
170 } else {
171 let cursor = Cursor::new(bytes);
172 InMemNiftiObject::from_reader(cursor)
173 .map_err(|e| format!("Failed to read NIfTI: {}", e))?
174 };
175
176 let header = obj.header();
177 let dim = header.dim;
178 let _ndim = dim[0] as usize;
179
180 let pixdim = header.pixdim;
181 let vsx = pixdim[1] as f64;
182 let vsy = pixdim[2] as f64;
183 let vsz = pixdim[3] as f64;
184
185 let affine = get_affine(header);
186
187 let volume = obj.into_volume();
189 let array: Array<f64, _> = volume.into_ndarray()
190 .map_err(|e| format!("Failed to convert to ndarray: {}", e))?;
191
192 let shape = array.shape();
193
194 let (dim0, dim1, dim2) = (shape[0], shape[1], shape[2]);
196 let dim3 = if shape.len() >= 4 { shape[3] } else { 1 };
197
198 let mut data = Vec::with_capacity(dim0 * dim1 * dim2 * dim3);
201
202 if shape.len() == 3 {
203 for k in 0..dim2 {
205 for j in 0..dim1 {
206 for i in 0..dim0 {
207 data.push(array[[i, j, k]]);
208 }
209 }
210 }
211 } else if shape.len() >= 4 {
212 for t in 0..dim3 {
214 for k in 0..dim2 {
215 for j in 0..dim1 {
216 for i in 0..dim0 {
217 data.push(array[[i, j, k, t]]);
218 }
219 }
220 }
221 }
222 }
223
224 Ok((data, (dim0, dim1, dim2, dim3), (vsx, vsy, vsz), affine))
226}
227
228fn get_affine(header: &NiftiHeader) -> [f64; 16] {
230 if header.sform_code > 0 {
236 let s = &header.srow_x;
237 let t = &header.srow_y;
238 let u = &header.srow_z;
239 [
240 s[0] as f64, s[1] as f64, s[2] as f64, s[3] as f64,
241 t[0] as f64, t[1] as f64, t[2] as f64, t[3] as f64,
242 u[0] as f64, u[1] as f64, u[2] as f64, u[3] as f64,
243 0.0, 0.0, 0.0, 1.0,
244 ]
245 } else if header.qform_code > 0 {
246 let q = QForm {
247 quatern: [header.quatern_b as f64, header.quatern_c as f64, header.quatern_d as f64],
248 offset: [header.quatern_x as f64, header.quatern_y as f64, header.quatern_z as f64],
249 qfac: if header.pixdim[0] < 0.0 { -1.0 } else { 1.0 },
251 };
252 let pixdim = (
253 header.pixdim[1] as f64,
254 header.pixdim[2] as f64,
255 header.pixdim[3] as f64,
256 );
257 affine_from_quatern(&q, pixdim)
258 } else {
259 let vsx = header.pixdim[1] as f64;
261 let vsy = header.pixdim[2] as f64;
262 let vsz = header.pixdim[3] as f64;
263 [
264 vsx, 0.0, 0.0, 0.0,
265 0.0, vsy, 0.0, 0.0,
266 0.0, 0.0, vsz, 0.0,
267 0.0, 0.0, 0.0, 1.0,
268 ]
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq)]
278struct QForm {
279 quatern: [f64; 3],
281 offset: [f64; 3],
283 qfac: f64,
290}
291
292fn quatern_from_affine(affine: &[f64; 16]) -> Option<QForm> {
306 let mut r = [
307 [affine[0], affine[1], affine[2]],
308 [affine[4], affine[5], affine[6]],
309 [affine[8], affine[9], affine[10]],
310 ];
311 let offset = [affine[3], affine[7], affine[11]];
312
313 for c in 0..3 {
316 let n = (r[0][c] * r[0][c] + r[1][c] * r[1][c] + r[2][c] * r[2][c]).sqrt();
317 if n < 1e-12 {
318 return None;
319 }
320 for row in r.iter_mut() {
321 row[c] /= n;
322 }
323 }
324
325 let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
326 - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
327 + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
328 let qfac = if det < 0.0 { -1.0 } else { 1.0 };
329 if qfac < 0.0 {
330 for row in r.iter_mut() {
333 row[2] = -row[2];
334 }
335 }
336
337 let trace = r[0][0] + r[1][1] + r[2][2] + 1.0;
341 let (b, c, d) = if trace > 0.5 {
342 let a = 0.5 * trace.sqrt();
343 (
344 0.25 * (r[2][1] - r[1][2]) / a,
345 0.25 * (r[0][2] - r[2][0]) / a,
346 0.25 * (r[1][0] - r[0][1]) / a,
347 )
348 } else {
349 let xd = 1.0 + r[0][0] - (r[1][1] + r[2][2]);
351 let yd = 1.0 + r[1][1] - (r[0][0] + r[2][2]);
352 let zd = 1.0 + r[2][2] - (r[0][0] + r[1][1]);
353 let (a, b, c, d) = if xd > 1.0 {
354 let b = 0.5 * xd.sqrt();
355 (0.25 * (r[2][1] - r[1][2]) / b, b, 0.25 * (r[0][1] + r[1][0]) / b,
356 0.25 * (r[0][2] + r[2][0]) / b)
357 } else if yd > 1.0 {
358 let c = 0.5 * yd.sqrt();
359 (0.25 * (r[0][2] - r[2][0]) / c, 0.25 * (r[0][1] + r[1][0]) / c, c,
360 0.25 * (r[1][2] + r[2][1]) / c)
361 } else {
362 let d = 0.5 * zd.sqrt();
363 (0.25 * (r[1][0] - r[0][1]) / d, 0.25 * (r[0][2] + r[2][0]) / d,
364 0.25 * (r[1][2] + r[2][1]) / d, d)
365 };
366 if a < 0.0 { (-b, -c, -d) } else { (b, c, d) }
367 };
368
369 Some(QForm { quatern: [b, c, d], offset, qfac })
370}
371
372fn affine_from_quatern(q: &QForm, pixdim: (f64, f64, f64)) -> [f64; 16] {
375 let [mut b, mut c, mut d] = q.quatern;
376 let mut a = 1.0 - (b * b + c * c + d * d);
377 if a < 1e-7 {
378 let n = (b * b + c * c + d * d).sqrt();
381 b /= n;
382 c /= n;
383 d /= n;
384 a = 0.0;
385 } else {
386 a = a.sqrt();
387 }
388
389 let xd = if pixdim.0 > 0.0 { pixdim.0 } else { 1.0 };
390 let yd = if pixdim.1 > 0.0 { pixdim.1 } else { 1.0 };
391 let zd = if pixdim.2 > 0.0 { pixdim.2 } else { 1.0 };
392 let zd = if q.qfac < 0.0 { -zd } else { zd };
393
394 [
395 (a * a + b * b - c * c - d * d) * xd, 2.0 * (b * c - a * d) * yd, 2.0 * (b * d + a * c) * zd, q.offset[0],
396 2.0 * (b * c + a * d) * xd, (a * a + c * c - b * b - d * d) * yd, 2.0 * (c * d - a * b) * zd, q.offset[1],
397 2.0 * (b * d - a * c) * xd, 2.0 * (c * d + a * b) * yd, (a * a + d * d - c * c - b * b) * zd, q.offset[2],
398 0.0, 0.0, 0.0, 1.0,
399 ]
400}
401
402fn qform_for(affine: &[f64; 16], pixdim: (f64, f64, f64)) -> Option<QForm> {
418 let q = quatern_from_affine(affine)?;
419 let rebuilt = affine_from_quatern(&q, pixdim);
420
421 let scale = (0..3)
426 .map(|c| (rebuilt[c] * rebuilt[c] + rebuilt[4 + c] * rebuilt[4 + c] + rebuilt[8 + c] * rebuilt[8 + c]).sqrt())
427 .fold(0.0f64, f64::max);
428 let tol = 1e-4 * scale.max(1e-6);
429 for row in 0..3 {
430 for col in 0..3 {
431 if (rebuilt[4 * row + col] - affine[4 * row + col]).abs() > tol {
432 return None;
433 }
434 }
435 }
436 Some(q)
437}
438
439pub const NIFTI_XYZT_UNITS: u8 = 2 | 8;
447
448fn encode_nifti(
453 volumes: &[&[f64]],
454 dims: (usize, usize, usize),
455 voxel_size: (f64, f64, f64),
456 affine: &[f64; 16],
457 four_d: bool,
458) -> Result<Vec<u8>, String> {
459 use std::io::Write;
460
461 let (nx, ny, nz) = dims;
462 let (vsx, vsy, vsz) = voxel_size;
463 let nt = volumes.len();
464 if nt == 0 {
465 return Err("cannot write a NIfTI with no volumes".to_string());
466 }
467 debug_assert!(four_d || nt == 1, "a 3D file holds exactly one volume");
468
469 let expected = nx * ny * nz;
474 for (t, v) in volumes.iter().enumerate() {
475 if v.len() != expected {
476 let which = if !four_d { String::new() } else { format!(" (volume {t})") };
477 return Err(format!(
478 "cannot write a {}x{}x{} NIfTI ({} voxels) from {} values{}",
479 nx, ny, nz, expected, v.len(), which,
480 ));
481 }
482 }
483 for (name, n) in [("nx", nx), ("ny", ny), ("nz", nz), ("volumes", nt)] {
485 if n > i16::MAX as usize {
486 return Err(format!("{name} = {n} exceeds NIfTI-1's limit of {}", i16::MAX));
487 }
488 }
489
490 let mut header = [0u8; 348];
492
493 header[0..4].copy_from_slice(&348i32.to_le_bytes());
495
496 let ndim: i16 = if four_d { 4 } else { 3 };
498 let dim: [i16; 8] = [ndim, nx as i16, ny as i16, nz as i16, nt as i16, 1, 1, 1];
499 for (i, &d) in dim.iter().enumerate() {
500 let offset = 40 + i * 2;
501 header[offset..offset + 2].copy_from_slice(&d.to_le_bytes());
502 }
503
504 header[70..72].copy_from_slice(&16i16.to_le_bytes());
506
507 header[72..74].copy_from_slice(&32i16.to_le_bytes());
509
510 let qform = qform_for(affine, voxel_size);
513
514 let qfac = qform.map_or(1.0, |q| q.qfac);
517 let pixdim: [f32; 8] = [qfac as f32, vsx as f32, vsy as f32, vsz as f32, 1.0, 1.0, 1.0, 1.0];
518 for (i, &p) in pixdim.iter().enumerate() {
519 let offset = 76 + i * 4;
520 header[offset..offset + 4].copy_from_slice(&p.to_le_bytes());
521 }
522
523 header[108..112].copy_from_slice(&352.0f32.to_le_bytes());
525
526 header[112..116].copy_from_slice(&1.0f32.to_le_bytes());
528
529 header[116..120].copy_from_slice(&0.0f32.to_le_bytes());
531
532 header[123] = NIFTI_XYZT_UNITS;
534
535 if let Some(q) = qform {
539 header[252..254].copy_from_slice(&1i16.to_le_bytes());
540 for (i, &v) in q.quatern.iter().chain(q.offset.iter()).enumerate() {
541 let offset = 256 + i * 4;
542 header[offset..offset + 4].copy_from_slice(&(v as f32).to_le_bytes());
543 }
544 }
545 header[254..256].copy_from_slice(&1i16.to_le_bytes());
546
547 for i in 0..4 {
549 let offset = 280 + i * 4;
550 header[offset..offset + 4].copy_from_slice(&(affine[i] as f32).to_le_bytes());
551 }
552 for i in 0..4 {
553 let offset = 296 + i * 4;
554 header[offset..offset + 4].copy_from_slice(&(affine[4 + i] as f32).to_le_bytes());
555 }
556 for i in 0..4 {
557 let offset = 312 + i * 4;
558 header[offset..offset + 4].copy_from_slice(&(affine[8 + i] as f32).to_le_bytes());
559 }
560
561 header[344..348].copy_from_slice(b"n+1\0");
563
564 let mut buffer = Vec::with_capacity(352 + nt * expected * 4);
566
567 buffer.write_all(&header).map_err(|e| format!("Write header failed: {}", e))?;
569
570 buffer.write_all(&[0u8; 4]).map_err(|e| format!("Write extension failed: {}", e))?;
572
573 for v in volumes {
575 for &val in v.iter() {
576 buffer.write_all(&(val as f32).to_le_bytes())
577 .map_err(|e| format!("Write data failed: {}", e))?;
578 }
579 }
580
581 Ok(buffer)
582}
583
584fn gzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
585 use flate2::write::GzEncoder;
586 use flate2::Compression;
587 use std::io::Write;
588
589 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
590 encoder.write_all(bytes)
591 .map_err(|e| format!("Gzip compression failed: {}", e))?;
592 encoder.finish()
593 .map_err(|e| format!("Gzip finish failed: {}", e))
594}
595
596pub fn save_nifti(
600 data: &[f64],
601 dims: (usize, usize, usize),
602 voxel_size: (f64, f64, f64),
603 affine: &[f64; 16],
604) -> Result<Vec<u8>, String> {
605 encode_nifti(&[data], dims, voxel_size, affine, false)
606}
607
608pub fn save_nifti_gz(
610 data: &[f64],
611 dims: (usize, usize, usize),
612 voxel_size: (f64, f64, f64),
613 affine: &[f64; 16],
614) -> Result<Vec<u8>, String> {
615 gzip(&save_nifti(data, dims, voxel_size, affine)?)
616}
617
618pub fn save_nifti_4d(
626 volumes: &[&[f64]],
627 dims: (usize, usize, usize),
628 voxel_size: (f64, f64, f64),
629 affine: &[f64; 16],
630) -> Result<Vec<u8>, String> {
631 encode_nifti(volumes, dims, voxel_size, affine, true)
632}
633
634pub fn save_nifti_4d_gz(
636 volumes: &[&[f64]],
637 dims: (usize, usize, usize),
638 voxel_size: (f64, f64, f64),
639 affine: &[f64; 16],
640) -> Result<Vec<u8>, String> {
641 gzip(&save_nifti_4d(volumes, dims, voxel_size, affine)?)
642}
643
644pub fn read_nifti_dims(path: &std::path::Path) -> Result<(usize, usize, usize), String> {
649 use std::io::Read;
650
651 let mut file = std::fs::File::open(path)
652 .map_err(|e| format!("Failed to open '{}': {}", path.display(), e))?;
653
654 let mut header_bytes = [0u8; 348];
656
657 let path_str = path.to_string_lossy();
658 if path_str.ends_with(".gz") {
659 let mut decoder = GzDecoder::new(file);
661 decoder.read_exact(&mut header_bytes)
662 .map_err(|e| format!("Failed to read gzipped NIfTI header '{}': {}", path.display(), e))?;
663 } else {
664 file.read_exact(&mut header_bytes)
665 .map_err(|e| format!("Failed to read NIfTI header '{}': {}", path.display(), e))?;
666 }
667
668 let sizeof_hdr_le = i32::from_le_bytes([header_bytes[0], header_bytes[1], header_bytes[2], header_bytes[3]]);
670 let is_le = sizeof_hdr_le == 348;
671
672 if !is_le {
673 let sizeof_hdr_be = i32::from_be_bytes([header_bytes[0], header_bytes[1], header_bytes[2], header_bytes[3]]);
674 if sizeof_hdr_be != 348 {
675 return Err(format!(
676 "Invalid NIfTI header in '{}': sizeof_hdr={} (LE) / {} (BE), expected 348",
677 path.display(), sizeof_hdr_le, sizeof_hdr_be
678 ));
679 }
680 }
681
682 let read_i16 = |offset: usize| -> i16 {
684 if is_le {
685 i16::from_le_bytes([header_bytes[offset], header_bytes[offset + 1]])
686 } else {
687 i16::from_be_bytes([header_bytes[offset], header_bytes[offset + 1]])
688 }
689 };
690
691 let ndim = read_i16(40);
692 if ndim < 3 {
693 return Err(format!("Expected at least 3D volume in '{}', got {}D", path.display(), ndim));
694 }
695
696 let nx = read_i16(42) as usize;
697 let ny = read_i16(44) as usize;
698 let nz = read_i16(46) as usize;
699
700 Ok((nx, ny, nz))
701}
702
703pub fn load_itk_geometry(bytes: &[u8]) -> Result<crate::utils::ItkGeometry, String> {
708 let header = if is_gzip(bytes) {
709 NiftiHeader::from_reader(GzDecoder::new(Cursor::new(bytes)))
710 } else {
711 NiftiHeader::from_reader(Cursor::new(bytes))
712 }
713 .map_err(|e| format!("Failed to read NIfTI header: {e} ({})", get_header_info(bytes)))?;
714 crate::utils::ItkGeometry::from_nifti_header(&header).map_err(|e| e.to_string())
715}
716
717pub fn read_itk_geometry_file(path: &std::path::Path) -> Result<crate::utils::ItkGeometry, String> {
719 let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
720 load_itk_geometry(&bytes)
721}
722
723pub fn read_nifti_file(path: &std::path::Path) -> Result<NiftiData, String> {
727 let bytes = std::fs::read(path)
728 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
729 load_nifti(&bytes)
730}
731
732pub fn save_nifti_to_file(
737 path: &std::path::Path,
738 data: &[f64],
739 dims: (usize, usize, usize),
740 voxel_size: (f64, f64, f64),
741 affine: &[f64; 16],
742) -> Result<(), String> {
743 let path_str = path.to_string_lossy();
744 let bytes = if path_str.ends_with(".nii.gz") {
745 save_nifti_gz(data, dims, voxel_size, affine)
746 } else {
747 save_nifti(data, dims, voxel_size, affine)
748 }
749 .map_err(|e| format!("{}: {}", path.display(), e))?;
750
751 std::fs::write(path, &bytes)
752 .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))
753}
754
755pub fn save_nifti_4d_to_file(
760 path: &std::path::Path,
761 volumes: &[&[f64]],
762 dims: (usize, usize, usize),
763 voxel_size: (f64, f64, f64),
764 affine: &[f64; 16],
765) -> Result<(), String> {
766 let path_str = path.to_string_lossy();
767 let bytes = if path_str.ends_with(".nii.gz") {
768 save_nifti_4d_gz(volumes, dims, voxel_size, affine)
769 } else {
770 save_nifti_4d(volumes, dims, voxel_size, affine)
771 }
772 .map_err(|e| format!("{}: {}", path.display(), e))?;
773
774 std::fs::write(path, &bytes)
775 .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 #[test]
783 fn test_affine_identity() {
784 let mut header = NiftiHeader::default();
785 header.pixdim[1] = 1.0;
786 header.pixdim[2] = 2.0;
787 header.pixdim[3] = 3.0;
788 header.sform_code = 0;
789
790 let affine = get_affine(&header);
791 assert_eq!(affine[0], 1.0);
792 assert_eq!(affine[5], 2.0);
793 assert_eq!(affine[10], 3.0);
794 }
795
796 #[test]
797 fn test_gzip_detection() {
798 assert!(is_gzip(&[0x1f, 0x8b, 0x00]));
799 assert!(!is_gzip(&[0x00, 0x00, 0x00]));
800 assert!(!is_gzip(&[0x1f])); }
802
803 #[test]
807 fn test_save_nifti_rejects_a_mismatched_payload() {
808 let dims = (2, 2, 2);
809 let voxel_size = (1.0, 1.0, 1.0);
810 let affine = [
811 1.0, 0.0, 0.0, 0.0,
812 0.0, 1.0, 0.0, 0.0,
813 0.0, 0.0, 1.0, 0.0,
814 0.0, 0.0, 0.0, 1.0,
815 ];
816
817 let short = save_nifti(&[0.0; 6], dims, voxel_size, &affine).unwrap_err();
818 assert!(short.contains("2x2x2"), "{}", short);
819 assert!(short.contains("8 voxels"), "{}", short);
820 assert!(short.contains("6 values"), "{}", short);
821
822 assert!(save_nifti(&[0.0; 9], dims, voxel_size, &affine).is_err());
823 assert!(save_nifti_gz(&[0.0; 6], dims, voxel_size, &affine).is_err());
825 }
826
827 #[test]
828 fn test_save_nifti_to_file_names_the_file_it_refused() {
829 let dir = std::env::temp_dir().join("qsm-core-mismatch-test");
830 std::fs::create_dir_all(&dir).unwrap();
831 let affine = [
832 1.0, 0.0, 0.0, 0.0,
833 0.0, 1.0, 0.0, 0.0,
834 0.0, 0.0, 1.0, 0.0,
835 0.0, 0.0, 0.0, 1.0,
836 ];
837 for name in ["bad.nii", "bad.nii.gz"] {
838 let path = dir.join(name);
839 let err = save_nifti_to_file(&path, &[0.0; 6], (2, 2, 2), (1.0, 1.0, 1.0), &affine)
840 .unwrap_err();
841 assert!(err.contains(name), "{}", err);
842 assert!(!path.exists(), "nothing may be written for a mismatch");
843 }
844 let _ = std::fs::remove_dir_all(&dir);
845 }
846
847 #[test]
848 fn test_save_nifti_header() {
849 let data = vec![0.0; 8]; let dims = (2, 2, 2);
851 let voxel_size = (1.0, 1.0, 1.0);
852 let affine = [
853 1.0, 0.0, 0.0, 0.0,
854 0.0, 1.0, 0.0, 0.0,
855 0.0, 0.0, 1.0, 0.0,
856 0.0, 0.0, 0.0, 1.0,
857 ];
858
859 let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
860
861 assert_eq!(bytes.len(), 352 + 8 * 4); assert_eq!(&bytes[344..348], b"n+1\0");
866
867 let sizeof_hdr = i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
869 assert_eq!(sizeof_hdr, 348);
870 }
871
872 #[test]
873 fn test_save_and_read_nifti_roundtrip() {
874 let dims = (4, 4, 4);
875 let n = dims.0 * dims.1 * dims.2;
876 let voxel_size = (1.0, 2.0, 3.0);
877 let affine = [
878 1.0, 0.0, 0.0, 10.0,
879 0.0, 2.0, 0.0, 20.0,
880 0.0, 0.0, 3.0, 30.0,
881 0.0, 0.0, 0.0, 1.0,
882 ];
883
884 let data: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
886
887 let tmp_dir = std::env::temp_dir();
889 let tmp_path = tmp_dir.join("test_nifti_roundtrip.nii");
890
891 save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
892
893 let loaded = read_nifti_file(&tmp_path).unwrap();
895
896 assert_eq!(loaded.dims, dims, "Dimensions should match");
898
899 assert!((loaded.voxel_size.0 - voxel_size.0).abs() < 1e-5, "Voxel size X mismatch");
901 assert!((loaded.voxel_size.1 - voxel_size.1).abs() < 1e-5, "Voxel size Y mismatch");
902 assert!((loaded.voxel_size.2 - voxel_size.2).abs() < 1e-5, "Voxel size Z mismatch");
903
904 assert_eq!(loaded.data.len(), n, "Data length should match");
906 for i in 0..n {
907 assert!(
908 (loaded.data[i] - data[i]).abs() < 0.01,
909 "Data mismatch at index {}: expected {}, got {}",
910 i, data[i], loaded.data[i]
911 );
912 }
913
914 std::fs::remove_file(&tmp_path).ok();
916 }
917
918 #[test]
919 fn test_save_and_read_nifti_f32() {
920 let dims = (4, 4, 4);
921 let n = dims.0 * dims.1 * dims.2;
922 let voxel_size = (1.5, 1.5, 1.5);
923 let affine = [
924 1.5, 0.0, 0.0, 0.0,
925 0.0, 1.5, 0.0, 0.0,
926 0.0, 0.0, 1.5, 0.0,
927 0.0, 0.0, 0.0, 1.0,
928 ];
929
930 let data: Vec<f64> = (0..n).map(|i| (i as f32 * 0.1) as f64).collect();
932
933 let tmp_dir = std::env::temp_dir();
934 let tmp_path = tmp_dir.join("test_nifti_f32.nii");
935
936 save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
937 let loaded = read_nifti_file(&tmp_path).unwrap();
938
939 assert_eq!(loaded.dims, dims);
940 assert_eq!(loaded.data.len(), n);
941
942 for i in 0..n {
944 assert!(
945 (loaded.data[i] - data[i]).abs() < 1e-5,
946 "f32 data mismatch at index {}: expected {}, got {}",
947 i, data[i], loaded.data[i]
948 );
949 }
950
951 std::fs::remove_file(&tmp_path).ok();
952 }
953
954 #[test]
955 fn test_save_nifti_gzip() {
956 let dims = (4, 4, 4);
957 let n = dims.0 * dims.1 * dims.2;
958 let voxel_size = (1.0, 1.0, 1.0);
959 let affine = [
960 1.0, 0.0, 0.0, 0.0,
961 0.0, 1.0, 0.0, 0.0,
962 0.0, 0.0, 1.0, 0.0,
963 0.0, 0.0, 0.0, 1.0,
964 ];
965
966 let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
967
968 let tmp_dir = std::env::temp_dir();
969 let tmp_path = tmp_dir.join("test_nifti_gz.nii.gz");
970
971 save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
972
973 let bytes = std::fs::read(&tmp_path).unwrap();
975 assert!(is_gzip(&bytes), "File should be gzip compressed");
976
977 let loaded = read_nifti_file(&tmp_path).unwrap();
979 assert_eq!(loaded.dims, dims);
980 assert_eq!(loaded.data.len(), n);
981
982 for i in 0..n {
983 assert!(
984 (loaded.data[i] - data[i]).abs() < 0.01,
985 "Gzip roundtrip mismatch at index {}: expected {}, got {}",
986 i, data[i], loaded.data[i]
987 );
988 }
989
990 std::fs::remove_file(&tmp_path).ok();
991 }
992
993 #[test]
994 fn test_load_nifti_invalid_bytes() {
995 let result = load_nifti(&[0u8; 10]);
997 assert!(result.is_err(), "Loading invalid bytes should error");
998 }
999
1000 #[test]
1001 fn test_load_nifti_invalid_gzip() {
1002 let result = load_nifti(&[0x1f, 0x8b, 0x00, 0x00, 0x00]);
1004 assert!(result.is_err(), "Loading invalid gzip should error");
1005 }
1006
1007 #[test]
1008 fn test_get_header_info_small_file() {
1009 let info = get_header_info(&[0u8; 10]);
1010 assert!(info.contains("too small"), "Should report file too small");
1011 }
1012
1013 #[test]
1014 fn test_get_header_info_normal() {
1015 let mut bytes = vec![0u8; 348];
1017 bytes[0..4].copy_from_slice(&348i32.to_le_bytes());
1019 bytes[344..348].copy_from_slice(b"n+1\0");
1021 bytes[70..72].copy_from_slice(&16i16.to_le_bytes());
1023
1024 let info = get_header_info(&bytes);
1025 assert!(info.contains("sizeof_hdr=348"), "Should contain sizeof_hdr");
1026 assert!(info.contains("datatype=16"), "Should contain datatype");
1027 }
1028
1029 #[test]
1030 fn test_affine_sform() {
1031 let header = NiftiHeader {
1033 sform_code: 1,
1034 srow_x: [1.0, 0.0, 0.0, 10.0],
1035 srow_y: [0.0, 2.0, 0.0, 20.0],
1036 srow_z: [0.0, 0.0, 3.0, 30.0],
1037 ..Default::default()
1038 };
1039
1040 let affine = get_affine(&header);
1041 assert_eq!(affine[0], 1.0);
1042 assert_eq!(affine[3], 10.0);
1043 assert_eq!(affine[5], 2.0);
1044 assert_eq!(affine[7], 20.0);
1045 assert_eq!(affine[10], 3.0);
1046 assert_eq!(affine[11], 30.0);
1047 assert_eq!(affine[15], 1.0);
1048 }
1049
1050 const ISSUE_240_AFFINE: [f64; 16] = [
1054 0.0, 0.0, 0.600000, -88.492340,
1055 0.520448, 0.300481, 0.0, -151.538284,
1056 0.300481, -0.520448, 0.0, 53.045219,
1057 0.0, 0.0, 0.0, 1.0,
1058 ];
1059 const ISSUE_240_VOXEL: (f64, f64, f64) = (0.600962, 0.600962, 0.6);
1060
1061 fn read_qform(bytes: &[u8]) -> (i16, [f64; 16]) {
1066 let f32_at = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as f64;
1067 let code = i16::from_le_bytes([bytes[252], bytes[253]]);
1068 let q = QForm {
1069 quatern: [f32_at(256), f32_at(260), f32_at(264)],
1070 offset: [f32_at(268), f32_at(272), f32_at(276)],
1071 qfac: if f32_at(76) < 0.0 { -1.0 } else { 1.0 },
1072 };
1073 (code, affine_from_quatern(&q, (f32_at(80), f32_at(84), f32_at(88))))
1074 }
1075
1076 fn read_sform(bytes: &[u8]) -> (i16, [f64; 16]) {
1078 let f32_at = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as f64;
1079 let mut a = [0.0f64; 16];
1080 for row in 0..3 {
1081 for col in 0..4 {
1082 a[4 * row + col] = f32_at(280 + 16 * row + 4 * col);
1083 }
1084 }
1085 a[15] = 1.0;
1086 (i16::from_le_bytes([bytes[254], bytes[255]]), a)
1087 }
1088
1089 fn max_abs_diff(a: &[f64; 16], b: &[f64; 16]) -> f64 {
1090 a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max)
1091 }
1092
1093 #[test]
1098 fn qform_agrees_with_sform_on_an_oblique_affine() {
1099 let bytes = save_nifti(&[0.0; 24], (2, 3, 4), ISSUE_240_VOXEL, &ISSUE_240_AFFINE).unwrap();
1100 let (qcode, qto) = read_qform(&bytes);
1101 let (scode, sto) = read_sform(&bytes);
1102 assert_eq!((qcode, scode), (1, 1), "both transforms should be advertised");
1103 assert!(max_abs_diff(&qto, &sto) < 1e-4, "qto_xyz {qto:?} vs sto_xyz {sto:?}");
1105 assert!(max_abs_diff(&sto, &ISSUE_240_AFFINE) < 1e-4, "sform drifted: {sto:?}");
1106 }
1107
1108 #[test]
1113 fn a_left_handed_affine_is_written_with_qfac_minus_one() {
1114 for (label, voxel, affine) in [
1115 ("issue 240", ISSUE_240_VOXEL, ISSUE_240_AFFINE),
1116 ("LAS", (0.8, 0.8, 3.0), [
1117 -0.8, 0.0, 0.0, 60.0,
1118 0.0, 0.8, 0.0, -70.0,
1119 0.0, 0.0, 3.0, -25.0,
1120 0.0, 0.0, 0.0, 1.0,
1121 ]),
1122 ] {
1123 let bytes = save_nifti(&[0.0; 24], (2, 3, 4), voxel, &affine).unwrap();
1124 let qfac = f32::from_le_bytes(bytes[76..80].try_into().unwrap());
1125 assert_eq!(qfac, -1.0, "{label}: pixdim[0] should flag the reflection");
1126 let (code, qto) = read_qform(&bytes);
1127 assert_eq!(code, 1, "{label}");
1128 assert!(max_abs_diff(&qto, &affine) < 1e-4, "{label}: {qto:?}");
1129
1130 let f32_at = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as f64;
1133 let ignored = QForm {
1134 quatern: [f32_at(256), f32_at(260), f32_at(264)],
1135 offset: [f32_at(268), f32_at(272), f32_at(276)],
1136 qfac: 1.0,
1137 };
1138 let wrong = affine_from_quatern(&ignored, (f32_at(80), f32_at(84), f32_at(88)));
1139 assert!(max_abs_diff(&wrong, &affine) > 0.1, "{label}: qfac changed nothing");
1140 }
1141 }
1142
1143 #[test]
1146 fn pixdim_zero_is_never_written_as_qfac() {
1147 for affine in [
1148 ISSUE_240_AFFINE,
1149 [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1150 [1.0, 0.3, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1152 ] {
1153 let vs = voxel_sizes(&affine);
1154 let bytes = save_nifti(&[0.0; 24], (2, 3, 4), vs, &affine).unwrap();
1155 let qfac = f32::from_le_bytes(bytes[76..80].try_into().unwrap());
1156 assert!(qfac == 1.0 || qfac == -1.0, "pixdim[0] = {qfac}");
1157 }
1158 }
1159
1160 fn voxel_sizes(affine: &[f64; 16]) -> (f64, f64, f64) {
1161 let n = |c: usize| {
1162 (affine[c] * affine[c] + affine[4 + c] * affine[4 + c] + affine[8 + c] * affine[8 + c])
1163 .sqrt()
1164 };
1165 (n(0), n(1), n(2))
1166 }
1167 type AffineCase = (&'static str, (f64, f64, f64), [f64; 16]);
1169
1170
1171 #[test]
1175 fn an_affine_no_quaternion_describes_gets_no_qform() {
1176 let cases: [AffineCase; 3] = [
1177 ("sheared", (1.0, 1.0, 1.0), [
1178 1.0, 0.3, 0.0, 0.0,
1179 0.0, 1.0, 0.0, 0.0,
1180 0.0, 0.0, 1.0, 0.0,
1181 0.0, 0.0, 0.0, 1.0,
1182 ]),
1183 ("pixdim disagrees with the affine", (1.0, 1.0, 1.0), [
1186 1.0, 0.0, 0.0, 0.0,
1187 0.0, 1.0, 0.0, 0.0,
1188 0.0, 0.0, 2.0, 0.0,
1189 0.0, 0.0, 0.0, 1.0,
1190 ]),
1191 ("singular", (1.0, 1.0, 1.0), [
1192 1.0, 0.0, 0.0, 0.0,
1193 0.0, 1.0, 0.0, 0.0,
1194 0.0, 0.0, 0.0, 0.0,
1195 0.0, 0.0, 0.0, 1.0,
1196 ]),
1197 ];
1198 for (label, voxel, affine) in cases {
1199 let bytes = save_nifti(&[0.0; 24], (2, 3, 4), voxel, &affine).unwrap();
1200 assert_eq!(read_qform(&bytes).0, 0, "{label}: should advertise no qform");
1201 assert_eq!(read_sform(&bytes).0, 1, "{label}: the sform still carries the geometry");
1202 assert!(bytes[256..280].iter().all(|&b| b == 0), "{label}");
1204 }
1205 }
1206
1207 #[test]
1210 fn qform_and_sform_round_trip_to_the_same_affine() {
1211 let affines: [AffineCase; 4] = [
1212 ("axial anisotropic", (1.0, 1.0, 2.0), [
1213 1.0, 0.0, 0.0, -10.0,
1214 0.0, 1.0, 0.0, -20.0,
1215 0.0, 0.0, 2.0, -30.0,
1216 0.0, 0.0, 0.0, 1.0,
1217 ]),
1218 ("oblique (issue 240)", ISSUE_240_VOXEL, ISSUE_240_AFFINE),
1219 ("left-handed", (0.8, 0.8, 3.0), [
1220 -0.8, 0.0, 0.0, 60.0,
1221 0.0, 0.8, 0.0, -70.0,
1222 0.0, 0.0, 3.0, -25.0,
1223 0.0, 0.0, 0.0, 1.0,
1224 ]),
1225 ("sagittal anisotropic", (0.8, 0.8, 3.0), [
1227 0.0, 0.0, 3.0, -90.0,
1228 0.8, 0.0, 0.0, -110.0,
1229 0.0, 0.8, 0.0, -70.0,
1230 0.0, 0.0, 0.0, 1.0,
1231 ]),
1232 ];
1233 for (label, voxel, affine) in affines {
1234 let bytes = save_nifti(&[0.0; 24], (2, 3, 4), voxel, &affine).unwrap();
1235 let (qcode, qto) = read_qform(&bytes);
1236 let (scode, sto) = read_sform(&bytes);
1237 assert_eq!((qcode, scode), (1, 1), "{label}");
1238 assert!(max_abs_diff(&qto, &sto) < 1e-4, "{label}: qto {qto:?} sto {sto:?}");
1239
1240 let loaded = load_nifti(&bytes).unwrap();
1242 assert!(max_abs_diff(&loaded.affine, &affine) < 1e-4, "{label}: {:?}", loaded.affine);
1243 }
1244 }
1245
1246 #[test]
1250 fn get_affine_reads_the_qform_when_there_is_no_sform() {
1251 let affine = [
1253 0.0, 0.0, 3.0, -90.0,
1254 0.8, 0.0, 0.0, -110.0,
1255 0.0, 0.8, 0.0, 70.0,
1256 0.0, 0.0, 0.0, 1.0,
1257 ];
1258 let voxel = (0.8, 0.8, 3.0);
1259 let q = qform_for(&affine, voxel).expect("a cardinal permutation has a quaternion");
1260
1261 let mut header = NiftiHeader {
1262 sform_code: 0,
1263 qform_code: 1,
1264 quatern_b: q.quatern[0] as f32,
1265 quatern_c: q.quatern[1] as f32,
1266 quatern_d: q.quatern[2] as f32,
1267 quatern_x: q.offset[0] as f32,
1268 quatern_y: q.offset[1] as f32,
1269 quatern_z: q.offset[2] as f32,
1270 pixdim: [q.qfac as f32, 0.8, 0.8, 3.0, 1.0, 1.0, 1.0, 1.0],
1271 ..Default::default()
1272 };
1273
1274 let got = get_affine(&header);
1275 assert!(max_abs_diff(&got, &affine) < 1e-4, "{got:?}");
1276 header.qform_code = 0;
1279 let fallback = get_affine(&header);
1280 assert!((fallback[0] - 0.8).abs() < 1e-6, "fallback should be the bare voxel scaling");
1281 assert!(max_abs_diff(&fallback, &affine) > 1.0);
1282 }
1283
1284 #[test]
1285 fn test_save_nifti_header_details() {
1286 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; let dims = (2, 2, 2);
1288 let voxel_size = (1.5, 2.5, 3.5);
1289 let affine = [
1290 1.5, 0.0, 0.0, 5.0,
1291 0.0, 2.5, 0.0, 10.0,
1292 0.0, 0.0, 3.5, 15.0,
1293 0.0, 0.0, 0.0, 1.0,
1294 ];
1295
1296 let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1297
1298 let datatype = i16::from_le_bytes([bytes[70], bytes[71]]);
1300 assert_eq!(datatype, 16);
1301
1302 let bitpix = i16::from_le_bytes([bytes[72], bytes[73]]);
1304 assert_eq!(bitpix, 32);
1305
1306 let ndim = i16::from_le_bytes([bytes[40], bytes[41]]);
1308 assert_eq!(ndim, 3);
1309
1310 let nx = i16::from_le_bytes([bytes[42], bytes[43]]);
1312 assert_eq!(nx, 2);
1313
1314 let vox_offset = f32::from_le_bytes([bytes[108], bytes[109], bytes[110], bytes[111]]);
1316 assert_eq!(vox_offset, 352.0);
1317
1318 let scl_slope = f32::from_le_bytes([bytes[112], bytes[113], bytes[114], bytes[115]]);
1320 assert_eq!(scl_slope, 1.0);
1321
1322 let sform_code = i16::from_le_bytes([bytes[254], bytes[255]]);
1324 assert_eq!(sform_code, 1);
1325
1326 let pixdim1 = f32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]);
1328 assert!((pixdim1 - 1.5).abs() < 1e-6);
1329 }
1330
1331 #[test]
1332 fn test_save_nifti_data_values() {
1333 let data = vec![1.0f64, 2.0, -3.0, 4.5, 0.0, 100.0, -0.5, 999.0]; let dims = (2, 2, 2);
1335 let voxel_size = (1.0, 1.0, 1.0);
1336 let affine = [
1337 1.0, 0.0, 0.0, 0.0,
1338 0.0, 1.0, 0.0, 0.0,
1339 0.0, 0.0, 1.0, 0.0,
1340 0.0, 0.0, 0.0, 1.0,
1341 ];
1342
1343 let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1344
1345 for i in 0..8 {
1347 let offset = 352 + i * 4;
1348 let val = f32::from_le_bytes([
1349 bytes[offset], bytes[offset + 1],
1350 bytes[offset + 2], bytes[offset + 3],
1351 ]);
1352 assert!(
1353 (val as f64 - data[i]).abs() < 0.01,
1354 "Data value {} mismatch: saved {}, expected {}",
1355 i, val, data[i]
1356 );
1357 }
1358 }
1359
1360 #[test]
1361 fn test_save_nifti_gz_bytes() {
1362 let data = vec![0.0; 8]; let dims = (2, 2, 2);
1364 let voxel_size = (1.0, 1.0, 1.0);
1365 let affine = [
1366 1.0, 0.0, 0.0, 0.0,
1367 0.0, 1.0, 0.0, 0.0,
1368 0.0, 0.0, 1.0, 0.0,
1369 0.0, 0.0, 0.0, 1.0,
1370 ];
1371
1372 let bytes = save_nifti_gz(&data, dims, voxel_size, &affine).unwrap();
1373 assert!(is_gzip(&bytes), "save_nifti_gz should produce gzip bytes");
1374
1375 let loaded = load_nifti(&bytes).unwrap();
1377 assert_eq!(loaded.dims, dims);
1378 }
1379
1380 #[test]
1381 fn test_read_nonexistent_file() {
1382 let result = read_nifti_file(std::path::Path::new("/tmp/nonexistent_file_12345.nii"));
1383 assert!(result.is_err(), "Reading nonexistent file should error");
1384 match result {
1385 Err(err) => {
1386 assert!(err.contains("Failed to read file"), "Error should mention file reading: {}", err);
1387 }
1388 Ok(_) => panic!("Should have returned an error"),
1389 }
1390 }
1391
1392 #[test]
1393 fn test_save_nifti_large_volume() {
1394 let dims = (8, 8, 8);
1396 let n = dims.0 * dims.1 * dims.2;
1397 let voxel_size = (0.5, 0.5, 0.5);
1398 let affine = [
1399 0.5, 0.0, 0.0, -2.0,
1400 0.0, 0.5, 0.0, -2.0,
1401 0.0, 0.0, 0.5, -2.0,
1402 0.0, 0.0, 0.0, 1.0,
1403 ];
1404
1405 let data: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
1406
1407 let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1408 assert_eq!(bytes.len(), 352 + n * 4);
1409
1410 let loaded = load_nifti(&bytes).unwrap();
1412 assert_eq!(loaded.dims, dims);
1413 assert_eq!(loaded.data.len(), n);
1414
1415 for i in 0..n {
1416 assert!(
1417 (loaded.data[i] - data[i]).abs() < 0.01,
1418 "Roundtrip mismatch at {}: expected {}, got {}",
1419 i, data[i], loaded.data[i]
1420 );
1421 }
1422 }
1423
1424 #[test]
1425 fn test_nifti_roundtrip_affine() {
1426 let dims = (4, 4, 4);
1428 let n = dims.0 * dims.1 * dims.2;
1429 let voxel_size = (1.0, 2.0, 3.0);
1430 let affine = [
1431 1.0, 0.1, 0.2, 10.0,
1432 0.3, 2.0, 0.4, 20.0,
1433 0.5, 0.6, 3.0, 30.0,
1434 0.0, 0.0, 0.0, 1.0,
1435 ];
1436
1437 let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
1438
1439 let tmp_dir = std::env::temp_dir();
1440 let tmp_path = tmp_dir.join("test_nifti_affine_rt.nii");
1441
1442 save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
1443 let loaded = read_nifti_file(&tmp_path).unwrap();
1444
1445 for i in 0..16 {
1447 assert!(
1448 (loaded.affine[i] - affine[i]).abs() < 0.01,
1449 "Affine[{}] mismatch: expected {}, got {}",
1450 i, affine[i], loaded.affine[i]
1451 );
1452 }
1453
1454 std::fs::remove_file(&tmp_path).ok();
1455 }
1456
1457 #[test]
1458 fn test_nifti_scl_slope_intercept() {
1459 let dims = (4, 4, 4);
1461 let n = dims.0 * dims.1 * dims.2;
1462 let voxel_size = (1.0, 1.0, 1.0);
1463 let affine = [
1464 1.0, 0.0, 0.0, 0.0,
1465 0.0, 1.0, 0.0, 0.0,
1466 0.0, 0.0, 1.0, 0.0,
1467 0.0, 0.0, 0.0, 1.0,
1468 ];
1469
1470 let data = vec![1.0; n];
1471 let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1472 let loaded = load_nifti(&bytes).unwrap();
1473
1474 assert!((loaded.scl_slope - 1.0).abs() < 1e-5);
1476 assert!((loaded.scl_inter - 0.0).abs() < 1e-5);
1477 }
1478
1479 #[test]
1482 fn written_headers_carry_mm_and_sec_units() {
1483 let id = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
1484 let v = [0.0; 8];
1485 let three = save_nifti(&v, (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap();
1486 let four = save_nifti_4d(&[&v, &v], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap();
1487 for bytes in [&three, &four] {
1488 assert_eq!(bytes[123], 10, "xyzt_units should be mm | sec");
1489 let hdr = InMemNiftiObject::from_reader(Cursor::new(bytes.as_slice())).unwrap();
1490 assert_eq!(hdr.header().xyzt_units, 10);
1491 }
1492 }
1493
1494 #[test]
1497 fn save_nifti_4d_round_trips() {
1498 let dims = (3, 4, 5);
1499 let n = dims.0 * dims.1 * dims.2;
1500 let nt = 6;
1501 let vols: Vec<Vec<f64>> =
1502 (0..nt).map(|t| (0..n).map(|i| (t * 1000 + i) as f64 * 0.25 - 7.0).collect()).collect();
1503 let refs: Vec<&[f64]> = vols.iter().map(|v| v.as_slice()).collect();
1504
1505 for (label, voxel, affine) in [
1506 ("oblique left-handed", ISSUE_240_VOXEL, ISSUE_240_AFFINE),
1507 ("axial anisotropic", (1.0, 1.0, 2.0), [
1508 1.0, 0.0, 0.0, -10.0,
1509 0.0, 1.0, 0.0, -20.0,
1510 0.0, 0.0, 2.0, -30.0,
1511 0.0, 0.0, 0.0, 1.0,
1512 ]),
1513 ] {
1514 let plain = save_nifti_4d(&refs, dims, voxel, &affine).unwrap();
1515 let gz = save_nifti_4d_gz(&refs, dims, voxel, &affine).unwrap();
1516 assert!(is_gzip(&gz));
1517 for bytes in [&plain, &gz] {
1518 let (data, d4, vs, aff) = load_nifti_4d(bytes).unwrap();
1519 assert_eq!(d4, (3, 4, 5, nt), "{label}");
1520 assert!((vs.0 - voxel.0).abs() < 1e-5 && (vs.1 - voxel.1).abs() < 1e-5
1521 && (vs.2 - voxel.2).abs() < 1e-5, "{label}: {vs:?}");
1522 assert!(max_abs_diff(&aff, &affine) < 1e-4, "{label}: {aff:?}");
1523 let expected: Vec<f64> = vols.concat();
1524 assert_eq!(data, expected, "{label}: data (exact in f32)");
1525 }
1526
1527 let three = save_nifti(&vols[0], dims, voxel, &affine).unwrap();
1529 assert_eq!(i16::from_le_bytes([plain[40], plain[41]]), 4);
1530 assert_eq!(i16::from_le_bytes([plain[48], plain[49]]), nt as i16);
1531 assert_eq!(f32::from_le_bytes(plain[92..96].try_into().unwrap()), 1.0);
1532 for (a, b) in [(0, 40), (50, 348)] {
1533 assert_eq!(plain[a..b], three[a..b], "{label}: header bytes {a}..{b}");
1534 }
1535 assert_eq!(&plain[352..352 + n * 4], &three[352..], "{label}: first volume");
1536 }
1537
1538 let dir = std::env::temp_dir().join("qsm-core-4d-test");
1539 std::fs::create_dir_all(&dir).unwrap();
1540 for name in ["four.nii", "four.nii.gz"] {
1541 let path = dir.join(name);
1542 save_nifti_4d_to_file(&path, &refs, dims, ISSUE_240_VOXEL, &ISSUE_240_AFFINE).unwrap();
1543 let bytes = std::fs::read(&path).unwrap();
1544 assert_eq!(is_gzip(&bytes), name.ends_with(".gz"), "{name}");
1545 let (data, d4, _, _) = load_nifti_4d(&bytes).unwrap();
1546 assert_eq!(d4, (3, 4, 5, nt), "{name}");
1547 assert_eq!(data.len(), n * nt, "{name}");
1548 }
1549 let _ = std::fs::remove_dir_all(&dir);
1550 }
1551
1552 #[test]
1553 fn save_nifti_4d_writes_one_volume_as_4d_and_rejects_bad_input() {
1554 let id = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
1555 let v = [1.0; 8];
1556 let one = save_nifti_4d(&[&v], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap();
1557 assert_eq!(i16::from_le_bytes([one[40], one[41]]), 4);
1558 assert_eq!(i16::from_le_bytes([one[48], one[49]]), 1);
1559
1560 assert!(save_nifti_4d(&[], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap_err().contains("no volumes"));
1561 let err = save_nifti_4d(&[&v, &v[..7]], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap_err();
1562 assert!(err.contains("volume 1") && err.contains("7 values"), "{err}");
1563 }
1564}