Skip to main content

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