Skip to main content

qsm_core/
io.rs

1//! NIfTI file I/O for WASM
2//!
3//! Provides functions to load and save NIfTI files from/to byte arrays,
4//! suitable for use in WebAssembly where filesystem access is not available.
5
6use std::io::Cursor;
7use nifti::{NiftiObject, InMemNiftiObject, NiftiHeader};
8use nifti::volume::ndarray::IntoNdArray;
9use flate2::read::GzDecoder;
10use ndarray::Array;
11
12/// NIfTI data loaded from bytes
13pub struct NiftiData {
14    /// Volume data as f64
15    pub data: Vec<f64>,
16    /// Dimensions (nx, ny, nz) - only 3D supported for now
17    pub dims: (usize, usize, usize),
18    /// Voxel sizes in mm
19    pub voxel_size: (f64, f64, f64),
20    /// Affine transformation matrix (4x4, row-major)
21    pub affine: [f64; 16],
22    /// Data scaling slope
23    pub scl_slope: f64,
24    /// Data scaling intercept
25    pub scl_inter: f64,
26}
27
28/// Check if bytes are gzip compressed
29fn is_gzip(bytes: &[u8]) -> bool {
30    bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
31}
32
33/// Get header info for diagnostics
34fn 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    // NIfTI-1 header size should be at offset 0, stored as i32
40    let sizeof_hdr = i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
41
42    // Magic bytes at offset 344 for NIfTI-1
43    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    // Data type at offset 70 (dim[0..8] at 40, then datatype at 70)
50    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
59/// Load a NIfTI file from bytes
60///
61/// Supports both .nii and .nii.gz files (gzip is auto-detected)
62pub 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                // Try to get header info from decompressed data
69                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    // Get dimensions (only support 3D for now)
88    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    // Get voxel sizes
95    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    // Get scaling
101    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    // Get affine matrix
105    let affine = get_affine(header);
106
107    // Convert volume to ndarray
108    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    // Get the actual shape from the ndarray
113    let shape = array.shape();
114
115    // Verify shape is at least 3D
116    if shape.len() < 3 {
117        return Err(format!("Expected at least 3D array, got {}D", shape.len()));
118    }
119
120    // Use the actual array shape for dimensions (nifti-rs may reorder)
121    let (dim0, dim1, dim2) = (shape[0], shape[1], shape[2]);
122    let expected_size = dim0 * dim1 * dim2;
123
124    // Extract data in Fortran order (x varies fastest) to match NIfTI convention
125    // index = x + y*nx + z*nx*ny
126    let mut data = Vec::with_capacity(expected_size);
127
128    // Handle potentially 4D arrays (take first volume)
129    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        // 4D array - take first timepoint
139        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    // Return dimensions matching the actual array shape order
149    // This ensures data indexing is consistent with reported dimensions
150    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
160/// Load a 4D NIfTI file from bytes (for multi-echo data)
161pub fn load_nifti_4d(bytes: &[u8]) -> Result<(Vec<f64>, (usize, usize, usize, usize), (f64, f64, f64), [f64; 16]), String> {
162    let obj: InMemNiftiObject = if is_gzip(bytes) {
163        let cursor = Cursor::new(bytes);
164        let decoder = GzDecoder::new(cursor);
165        InMemNiftiObject::from_reader(decoder)
166            .map_err(|e| format!("Failed to read gzipped NIfTI: {}", e))?
167    } else {
168        let cursor = Cursor::new(bytes);
169        InMemNiftiObject::from_reader(cursor)
170            .map_err(|e| format!("Failed to read NIfTI: {}", e))?
171    };
172
173    let header = obj.header();
174    let dim = header.dim;
175    let _ndim = dim[0] as usize;
176
177    let pixdim = header.pixdim;
178    let vsx = pixdim[1] as f64;
179    let vsy = pixdim[2] as f64;
180    let vsz = pixdim[3] as f64;
181
182    let affine = get_affine(header);
183
184    // Convert volume to ndarray
185    let volume = obj.into_volume();
186    let array: Array<f64, _> = volume.into_ndarray()
187        .map_err(|e| format!("Failed to convert to ndarray: {}", e))?;
188
189    let shape = array.shape();
190
191    // Use actual array shape for dimensions
192    let (dim0, dim1, dim2) = (shape[0], shape[1], shape[2]);
193    let dim3 = if shape.len() >= 4 { shape[3] } else { 1 };
194
195    // Extract data in Fortran order (x varies fastest) to match NIfTI convention
196    // For 4D: index = x + y*nx + z*nx*ny + t*nx*ny*nz
197    let mut data = Vec::with_capacity(dim0 * dim1 * dim2 * dim3);
198
199    if shape.len() == 3 {
200        // 3D array
201        for k in 0..dim2 {
202            for j in 0..dim1 {
203                for i in 0..dim0 {
204                    data.push(array[[i, j, k]]);
205                }
206            }
207        }
208    } else if shape.len() >= 4 {
209        // 4D array - each volume in Fortran order
210        for t in 0..dim3 {
211            for k in 0..dim2 {
212                for j in 0..dim1 {
213                    for i in 0..dim0 {
214                        data.push(array[[i, j, k, t]]);
215                    }
216                }
217            }
218        }
219    }
220
221    // Return dimensions matching actual array shape
222    Ok((data, (dim0, dim1, dim2, dim3), (vsx, vsy, vsz), affine))
223}
224
225/// Get affine transformation matrix from header
226fn get_affine(header: &NiftiHeader) -> [f64; 16] {
227    // Prefer sform if available (sform_code > 0)
228    if header.sform_code > 0 {
229        let s = &header.srow_x;
230        let t = &header.srow_y;
231        let u = &header.srow_z;
232        [
233            s[0] as f64, s[1] as f64, s[2] as f64, s[3] as f64,
234            t[0] as f64, t[1] as f64, t[2] as f64, t[3] as f64,
235            u[0] as f64, u[1] as f64, u[2] as f64, u[3] as f64,
236            0.0, 0.0, 0.0, 1.0,
237        ]
238    } else {
239        // Fall back to identity with voxel scaling
240        let vsx = header.pixdim[1] as f64;
241        let vsy = header.pixdim[2] as f64;
242        let vsz = header.pixdim[3] as f64;
243        [
244            vsx, 0.0, 0.0, 0.0,
245            0.0, vsy, 0.0, 0.0,
246            0.0, 0.0, vsz, 0.0,
247            0.0, 0.0, 0.0, 1.0,
248        ]
249    }
250}
251
252/// Save data as NIfTI bytes
253///
254/// Writes an uncompressed .nii file
255pub fn save_nifti(
256    data: &[f64],
257    dims: (usize, usize, usize),
258    voxel_size: (f64, f64, f64),
259    affine: &[f64; 16],
260) -> Result<Vec<u8>, String> {
261    use std::io::Write;
262
263    let (nx, ny, nz) = dims;
264    let (vsx, vsy, vsz) = voxel_size;
265
266    // Create NIfTI-1 header (348 bytes)
267    let mut header = [0u8; 348];
268
269    // sizeof_hdr = 348
270    header[0..4].copy_from_slice(&348i32.to_le_bytes());
271
272    // dim[0..7]
273    let dim: [i16; 8] = [3, nx as i16, ny as i16, nz as i16, 1, 1, 1, 1];
274    for (i, &d) in dim.iter().enumerate() {
275        let offset = 40 + i * 2;
276        header[offset..offset + 2].copy_from_slice(&d.to_le_bytes());
277    }
278
279    // datatype = 16 (FLOAT32)
280    header[70..72].copy_from_slice(&16i16.to_le_bytes());
281
282    // bitpix = 32
283    header[72..74].copy_from_slice(&32i16.to_le_bytes());
284
285    // pixdim[0..7]
286    let pixdim: [f32; 8] = [1.0, vsx as f32, vsy as f32, vsz as f32, 1.0, 1.0, 1.0, 1.0];
287    for (i, &p) in pixdim.iter().enumerate() {
288        let offset = 76 + i * 4;
289        header[offset..offset + 4].copy_from_slice(&p.to_le_bytes());
290    }
291
292    // vox_offset = 352 (header + 4 bytes extension)
293    header[108..112].copy_from_slice(&352.0f32.to_le_bytes());
294
295    // scl_slope = 1.0
296    header[112..116].copy_from_slice(&1.0f32.to_le_bytes());
297
298    // scl_inter = 0.0
299    header[116..120].copy_from_slice(&0.0f32.to_le_bytes());
300
301    // sform_code = 1 (scanner anat)
302    header[254..256].copy_from_slice(&1i16.to_le_bytes());
303
304    // srow_x, srow_y, srow_z
305    for i in 0..4 {
306        let offset = 280 + i * 4;
307        header[offset..offset + 4].copy_from_slice(&(affine[i] as f32).to_le_bytes());
308    }
309    for i in 0..4 {
310        let offset = 296 + i * 4;
311        header[offset..offset + 4].copy_from_slice(&(affine[4 + i] as f32).to_le_bytes());
312    }
313    for i in 0..4 {
314        let offset = 312 + i * 4;
315        header[offset..offset + 4].copy_from_slice(&(affine[8 + i] as f32).to_le_bytes());
316    }
317
318    // magic = "n+1\0" for NIfTI-1 single file
319    header[344..348].copy_from_slice(b"n+1\0");
320
321    // Build output buffer
322    let mut buffer = Vec::with_capacity(352 + data.len() * 4);
323
324    // Write header
325    buffer.write_all(&header).map_err(|e| format!("Write header failed: {}", e))?;
326
327    // Write extension (4 bytes, all zeros = no extension)
328    buffer.write_all(&[0u8; 4]).map_err(|e| format!("Write extension failed: {}", e))?;
329
330    // Write data as float32
331    for &val in data {
332        buffer.write_all(&(val as f32).to_le_bytes())
333            .map_err(|e| format!("Write data failed: {}", e))?;
334    }
335
336    Ok(buffer)
337}
338
339/// Save data as gzipped NIfTI bytes (.nii.gz)
340pub fn save_nifti_gz(
341    data: &[f64],
342    dims: (usize, usize, usize),
343    voxel_size: (f64, f64, f64),
344    affine: &[f64; 16],
345) -> Result<Vec<u8>, String> {
346    use flate2::write::GzEncoder;
347    use flate2::Compression;
348    use std::io::Write;
349
350    // First create uncompressed NIfTI
351    let uncompressed = save_nifti(data, dims, voxel_size, affine)?;
352
353    // Compress with gzip
354    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
355    encoder.write_all(&uncompressed)
356        .map_err(|e| format!("Gzip compression failed: {}", e))?;
357
358    encoder.finish()
359        .map_err(|e| format!("Gzip finish failed: {}", e))
360}
361
362/// Read only the dimensions from a NIfTI file header without loading volume data.
363///
364/// Returns (nx, ny, nz). Much faster and uses negligible memory compared to
365/// `read_nifti_file` since it only reads and parses the 348-byte header.
366pub fn read_nifti_dims(path: &std::path::Path) -> Result<(usize, usize, usize), String> {
367    use std::io::Read;
368
369    let mut file = std::fs::File::open(path)
370        .map_err(|e| format!("Failed to open '{}': {}", path.display(), e))?;
371
372    // Read enough bytes to detect gzip and parse header
373    let mut header_bytes = [0u8; 348];
374
375    let path_str = path.to_string_lossy();
376    if path_str.ends_with(".gz") {
377        // Decompress just the header
378        let mut decoder = GzDecoder::new(file);
379        decoder.read_exact(&mut header_bytes)
380            .map_err(|e| format!("Failed to read gzipped NIfTI header '{}': {}", path.display(), e))?;
381    } else {
382        file.read_exact(&mut header_bytes)
383            .map_err(|e| format!("Failed to read NIfTI header '{}': {}", path.display(), e))?;
384    }
385
386    // Determine endianness from sizeof_hdr (bytes 0-3, should be 348)
387    let sizeof_hdr_le = i32::from_le_bytes([header_bytes[0], header_bytes[1], header_bytes[2], header_bytes[3]]);
388    let is_le = sizeof_hdr_le == 348;
389
390    if !is_le {
391        let sizeof_hdr_be = i32::from_be_bytes([header_bytes[0], header_bytes[1], header_bytes[2], header_bytes[3]]);
392        if sizeof_hdr_be != 348 {
393            return Err(format!(
394                "Invalid NIfTI header in '{}': sizeof_hdr={} (LE) / {} (BE), expected 348",
395                path.display(), sizeof_hdr_le, sizeof_hdr_be
396            ));
397        }
398    }
399
400    // dim[0..7] at offset 40, each i16
401    let read_i16 = |offset: usize| -> i16 {
402        if is_le {
403            i16::from_le_bytes([header_bytes[offset], header_bytes[offset + 1]])
404        } else {
405            i16::from_be_bytes([header_bytes[offset], header_bytes[offset + 1]])
406        }
407    };
408
409    let ndim = read_i16(40);
410    if ndim < 3 {
411        return Err(format!("Expected at least 3D volume in '{}', got {}D", path.display(), ndim));
412    }
413
414    let nx = read_i16(42) as usize;
415    let ny = read_i16(44) as usize;
416    let nz = read_i16(46) as usize;
417
418    Ok((nx, ny, nz))
419}
420
421/// Read a NIfTI file from a filesystem path
422///
423/// Supports both .nii and .nii.gz files.
424pub fn read_nifti_file(path: &std::path::Path) -> Result<NiftiData, String> {
425    let bytes = std::fs::read(path)
426        .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
427    load_nifti(&bytes)
428}
429
430/// Save NIfTI data to a file
431///
432/// If the path ends with .nii.gz, the file is gzip compressed.
433/// Otherwise it is saved as uncompressed .nii.
434pub fn save_nifti_to_file(
435    path: &std::path::Path,
436    data: &[f64],
437    dims: (usize, usize, usize),
438    voxel_size: (f64, f64, f64),
439    affine: &[f64; 16],
440) -> Result<(), String> {
441    let path_str = path.to_string_lossy();
442    let bytes = if path_str.ends_with(".nii.gz") {
443        save_nifti_gz(data, dims, voxel_size, affine)?
444    } else {
445        save_nifti(data, dims, voxel_size, affine)?
446    };
447
448    std::fs::write(path, &bytes)
449        .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn test_affine_identity() {
458        let mut header = NiftiHeader::default();
459        header.pixdim[1] = 1.0;
460        header.pixdim[2] = 2.0;
461        header.pixdim[3] = 3.0;
462        header.sform_code = 0;
463
464        let affine = get_affine(&header);
465        assert_eq!(affine[0], 1.0);
466        assert_eq!(affine[5], 2.0);
467        assert_eq!(affine[10], 3.0);
468    }
469
470    #[test]
471    fn test_gzip_detection() {
472        assert!(is_gzip(&[0x1f, 0x8b, 0x00]));
473        assert!(!is_gzip(&[0x00, 0x00, 0x00]));
474        assert!(!is_gzip(&[0x1f])); // Too short
475    }
476
477    #[test]
478    fn test_save_nifti_header() {
479        let data = vec![0.0; 8]; // 2x2x2
480        let dims = (2, 2, 2);
481        let voxel_size = (1.0, 1.0, 1.0);
482        let affine = [
483            1.0, 0.0, 0.0, 0.0,
484            0.0, 1.0, 0.0, 0.0,
485            0.0, 0.0, 1.0, 0.0,
486            0.0, 0.0, 0.0, 1.0,
487        ];
488
489        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
490
491        // Check header size + extension + data
492        assert_eq!(bytes.len(), 352 + 8 * 4); // 348 header + 4 ext + 8 floats
493
494        // Check magic
495        assert_eq!(&bytes[344..348], b"n+1\0");
496
497        // Check sizeof_hdr
498        let sizeof_hdr = i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
499        assert_eq!(sizeof_hdr, 348);
500    }
501
502    #[test]
503    fn test_save_and_read_nifti_roundtrip() {
504        let dims = (4, 4, 4);
505        let n = dims.0 * dims.1 * dims.2;
506        let voxel_size = (1.0, 2.0, 3.0);
507        let affine = [
508            1.0, 0.0, 0.0, 10.0,
509            0.0, 2.0, 0.0, 20.0,
510            0.0, 0.0, 3.0, 30.0,
511            0.0, 0.0, 0.0, 1.0,
512        ];
513
514        // Create test data with known values
515        let data: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
516
517        // Save to temp file
518        let tmp_dir = std::env::temp_dir();
519        let tmp_path = tmp_dir.join("test_nifti_roundtrip.nii");
520
521        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
522
523        // Read back
524        let loaded = read_nifti_file(&tmp_path).unwrap();
525
526        // Verify dimensions
527        assert_eq!(loaded.dims, dims, "Dimensions should match");
528
529        // Verify voxel sizes
530        assert!((loaded.voxel_size.0 - voxel_size.0).abs() < 1e-5, "Voxel size X mismatch");
531        assert!((loaded.voxel_size.1 - voxel_size.1).abs() < 1e-5, "Voxel size Y mismatch");
532        assert!((loaded.voxel_size.2 - voxel_size.2).abs() < 1e-5, "Voxel size Z mismatch");
533
534        // Verify data values (saved as f32, so some precision loss expected)
535        assert_eq!(loaded.data.len(), n, "Data length should match");
536        for i in 0..n {
537            assert!(
538                (loaded.data[i] - data[i]).abs() < 0.01,
539                "Data mismatch at index {}: expected {}, got {}",
540                i, data[i], loaded.data[i]
541            );
542        }
543
544        // Cleanup
545        std::fs::remove_file(&tmp_path).ok();
546    }
547
548    #[test]
549    fn test_save_and_read_nifti_f32() {
550        let dims = (4, 4, 4);
551        let n = dims.0 * dims.1 * dims.2;
552        let voxel_size = (1.5, 1.5, 1.5);
553        let affine = [
554            1.5, 0.0, 0.0, 0.0,
555            0.0, 1.5, 0.0, 0.0,
556            0.0, 0.0, 1.5, 0.0,
557            0.0, 0.0, 0.0, 1.0,
558        ];
559
560        // Create small f32-precision data
561        let data: Vec<f64> = (0..n).map(|i| (i as f32 * 0.1) as f64).collect();
562
563        let tmp_dir = std::env::temp_dir();
564        let tmp_path = tmp_dir.join("test_nifti_f32.nii");
565
566        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
567        let loaded = read_nifti_file(&tmp_path).unwrap();
568
569        assert_eq!(loaded.dims, dims);
570        assert_eq!(loaded.data.len(), n);
571
572        // f32 precision: data is saved as f32, so roundtrip should be exact for f32 values
573        for i in 0..n {
574            assert!(
575                (loaded.data[i] - data[i]).abs() < 1e-5,
576                "f32 data mismatch at index {}: expected {}, got {}",
577                i, data[i], loaded.data[i]
578            );
579        }
580
581        std::fs::remove_file(&tmp_path).ok();
582    }
583
584    #[test]
585    fn test_save_nifti_gzip() {
586        let dims = (4, 4, 4);
587        let n = dims.0 * dims.1 * dims.2;
588        let voxel_size = (1.0, 1.0, 1.0);
589        let affine = [
590            1.0, 0.0, 0.0, 0.0,
591            0.0, 1.0, 0.0, 0.0,
592            0.0, 0.0, 1.0, 0.0,
593            0.0, 0.0, 0.0, 1.0,
594        ];
595
596        let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
597
598        let tmp_dir = std::env::temp_dir();
599        let tmp_path = tmp_dir.join("test_nifti_gz.nii.gz");
600
601        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
602
603        // Verify the file is actually gzip compressed
604        let bytes = std::fs::read(&tmp_path).unwrap();
605        assert!(is_gzip(&bytes), "File should be gzip compressed");
606
607        // Read it back
608        let loaded = read_nifti_file(&tmp_path).unwrap();
609        assert_eq!(loaded.dims, dims);
610        assert_eq!(loaded.data.len(), n);
611
612        for i in 0..n {
613            assert!(
614                (loaded.data[i] - data[i]).abs() < 0.01,
615                "Gzip roundtrip mismatch at index {}: expected {}, got {}",
616                i, data[i], loaded.data[i]
617            );
618        }
619
620        std::fs::remove_file(&tmp_path).ok();
621    }
622
623    #[test]
624    fn test_load_nifti_invalid_bytes() {
625        // Invalid bytes should return an error
626        let result = load_nifti(&[0u8; 10]);
627        assert!(result.is_err(), "Loading invalid bytes should error");
628    }
629
630    #[test]
631    fn test_load_nifti_invalid_gzip() {
632        // Bytes that look like gzip but are corrupt
633        let result = load_nifti(&[0x1f, 0x8b, 0x00, 0x00, 0x00]);
634        assert!(result.is_err(), "Loading invalid gzip should error");
635    }
636
637    #[test]
638    fn test_get_header_info_small_file() {
639        let info = get_header_info(&[0u8; 10]);
640        assert!(info.contains("too small"), "Should report file too small");
641    }
642
643    #[test]
644    fn test_get_header_info_normal() {
645        // Create a 348-byte mock header
646        let mut bytes = vec![0u8; 348];
647        // sizeof_hdr at offset 0
648        bytes[0..4].copy_from_slice(&348i32.to_le_bytes());
649        // magic at offset 344
650        bytes[344..348].copy_from_slice(b"n+1\0");
651        // datatype at offset 70
652        bytes[70..72].copy_from_slice(&16i16.to_le_bytes());
653
654        let info = get_header_info(&bytes);
655        assert!(info.contains("sizeof_hdr=348"), "Should contain sizeof_hdr");
656        assert!(info.contains("datatype=16"), "Should contain datatype");
657    }
658
659    #[test]
660    fn test_affine_sform() {
661        // Test with sform_code > 0
662        let mut header = NiftiHeader::default();
663        header.sform_code = 1;
664        header.srow_x = [1.0, 0.0, 0.0, 10.0];
665        header.srow_y = [0.0, 2.0, 0.0, 20.0];
666        header.srow_z = [0.0, 0.0, 3.0, 30.0];
667
668        let affine = get_affine(&header);
669        assert_eq!(affine[0], 1.0);
670        assert_eq!(affine[3], 10.0);
671        assert_eq!(affine[5], 2.0);
672        assert_eq!(affine[7], 20.0);
673        assert_eq!(affine[10], 3.0);
674        assert_eq!(affine[11], 30.0);
675        assert_eq!(affine[15], 1.0);
676    }
677
678    #[test]
679    fn test_save_nifti_header_details() {
680        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; // 2x2x2
681        let dims = (2, 2, 2);
682        let voxel_size = (1.5, 2.5, 3.5);
683        let affine = [
684            1.5, 0.0, 0.0, 5.0,
685            0.0, 2.5, 0.0, 10.0,
686            0.0, 0.0, 3.5, 15.0,
687            0.0, 0.0, 0.0, 1.0,
688        ];
689
690        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
691
692        // Verify datatype = 16 (FLOAT32)
693        let datatype = i16::from_le_bytes([bytes[70], bytes[71]]);
694        assert_eq!(datatype, 16);
695
696        // Verify bitpix = 32
697        let bitpix = i16::from_le_bytes([bytes[72], bytes[73]]);
698        assert_eq!(bitpix, 32);
699
700        // Verify dim[0] = 3
701        let ndim = i16::from_le_bytes([bytes[40], bytes[41]]);
702        assert_eq!(ndim, 3);
703
704        // Verify dim[1] = 2
705        let nx = i16::from_le_bytes([bytes[42], bytes[43]]);
706        assert_eq!(nx, 2);
707
708        // Verify vox_offset = 352
709        let vox_offset = f32::from_le_bytes([bytes[108], bytes[109], bytes[110], bytes[111]]);
710        assert_eq!(vox_offset, 352.0);
711
712        // Verify scl_slope = 1.0
713        let scl_slope = f32::from_le_bytes([bytes[112], bytes[113], bytes[114], bytes[115]]);
714        assert_eq!(scl_slope, 1.0);
715
716        // Verify sform_code = 1
717        let sform_code = i16::from_le_bytes([bytes[254], bytes[255]]);
718        assert_eq!(sform_code, 1);
719
720        // Verify pixdim[1] matches voxel_size
721        let pixdim1 = f32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]);
722        assert!((pixdim1 - 1.5).abs() < 1e-6);
723    }
724
725    #[test]
726    fn test_save_nifti_data_values() {
727        let data = vec![1.0f64, 2.0, -3.0, 4.5, 0.0, 100.0, -0.5, 999.0]; // 2x2x2
728        let dims = (2, 2, 2);
729        let voxel_size = (1.0, 1.0, 1.0);
730        let affine = [
731            1.0, 0.0, 0.0, 0.0,
732            0.0, 1.0, 0.0, 0.0,
733            0.0, 0.0, 1.0, 0.0,
734            0.0, 0.0, 0.0, 1.0,
735        ];
736
737        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
738
739        // Data starts at offset 352
740        for i in 0..8 {
741            let offset = 352 + i * 4;
742            let val = f32::from_le_bytes([
743                bytes[offset], bytes[offset + 1],
744                bytes[offset + 2], bytes[offset + 3],
745            ]);
746            assert!(
747                (val as f64 - data[i]).abs() < 0.01,
748                "Data value {} mismatch: saved {}, expected {}",
749                i, val, data[i]
750            );
751        }
752    }
753
754    #[test]
755    fn test_save_nifti_gz_bytes() {
756        let data = vec![0.0; 8]; // 2x2x2
757        let dims = (2, 2, 2);
758        let voxel_size = (1.0, 1.0, 1.0);
759        let affine = [
760            1.0, 0.0, 0.0, 0.0,
761            0.0, 1.0, 0.0, 0.0,
762            0.0, 0.0, 1.0, 0.0,
763            0.0, 0.0, 0.0, 1.0,
764        ];
765
766        let bytes = save_nifti_gz(&data, dims, voxel_size, &affine).unwrap();
767        assert!(is_gzip(&bytes), "save_nifti_gz should produce gzip bytes");
768
769        // Should be able to load it back
770        let loaded = load_nifti(&bytes).unwrap();
771        assert_eq!(loaded.dims, dims);
772    }
773
774    #[test]
775    fn test_read_nonexistent_file() {
776        let result = read_nifti_file(std::path::Path::new("/tmp/nonexistent_file_12345.nii"));
777        assert!(result.is_err(), "Reading nonexistent file should error");
778        match result {
779            Err(err) => {
780                assert!(err.contains("Failed to read file"), "Error should mention file reading: {}", err);
781            }
782            Ok(_) => panic!("Should have returned an error"),
783        }
784    }
785
786    #[test]
787    fn test_save_nifti_large_volume() {
788        // Test with 8x8x8 volume (512 elements)
789        let dims = (8, 8, 8);
790        let n = dims.0 * dims.1 * dims.2;
791        let voxel_size = (0.5, 0.5, 0.5);
792        let affine = [
793            0.5, 0.0, 0.0, -2.0,
794            0.0, 0.5, 0.0, -2.0,
795            0.0, 0.0, 0.5, -2.0,
796            0.0, 0.0, 0.0, 1.0,
797        ];
798
799        let data: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
800
801        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
802        assert_eq!(bytes.len(), 352 + n * 4);
803
804        // Load it back
805        let loaded = load_nifti(&bytes).unwrap();
806        assert_eq!(loaded.dims, dims);
807        assert_eq!(loaded.data.len(), n);
808
809        for i in 0..n {
810            assert!(
811                (loaded.data[i] - data[i]).abs() < 0.01,
812                "Roundtrip mismatch at {}: expected {}, got {}",
813                i, data[i], loaded.data[i]
814            );
815        }
816    }
817
818    #[test]
819    fn test_nifti_roundtrip_affine() {
820        // Verify affine is preserved through save/load
821        let dims = (4, 4, 4);
822        let n = dims.0 * dims.1 * dims.2;
823        let voxel_size = (1.0, 2.0, 3.0);
824        let affine = [
825            1.0, 0.1, 0.2, 10.0,
826            0.3, 2.0, 0.4, 20.0,
827            0.5, 0.6, 3.0, 30.0,
828            0.0, 0.0, 0.0, 1.0,
829        ];
830
831        let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
832
833        let tmp_dir = std::env::temp_dir();
834        let tmp_path = tmp_dir.join("test_nifti_affine_rt.nii");
835
836        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
837        let loaded = read_nifti_file(&tmp_path).unwrap();
838
839        // Affine values are stored as f32, so expect f32-level precision
840        for i in 0..16 {
841            assert!(
842                (loaded.affine[i] - affine[i]).abs() < 0.01,
843                "Affine[{}] mismatch: expected {}, got {}",
844                i, affine[i], loaded.affine[i]
845            );
846        }
847
848        std::fs::remove_file(&tmp_path).ok();
849    }
850
851    #[test]
852    fn test_nifti_scl_slope_intercept() {
853        // Test that scl_slope and scl_inter are reported correctly
854        let dims = (4, 4, 4);
855        let n = dims.0 * dims.1 * dims.2;
856        let voxel_size = (1.0, 1.0, 1.0);
857        let affine = [
858            1.0, 0.0, 0.0, 0.0,
859            0.0, 1.0, 0.0, 0.0,
860            0.0, 0.0, 1.0, 0.0,
861            0.0, 0.0, 0.0, 1.0,
862        ];
863
864        let data = vec![1.0; n];
865        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
866        let loaded = load_nifti(&bytes).unwrap();
867
868        // Our save sets scl_slope = 1.0 and scl_inter = 0.0
869        assert!((loaded.scl_slope - 1.0).abs() < 1e-5);
870        assert!((loaded.scl_inter - 0.0).abs() < 1e-5);
871    }
872}