Skip to main content

bids_nifti/
lib.rs

1#![deny(unsafe_code)]
2//! NIfTI-1 / NIfTI-2 reader for BIDS datasets.
3//!
4//! Reads headers to extract dimensions, voxel sizes, TR, and volume count.
5//! Also loads voxel data as `f64` arrays with automatic type conversion and
6//! `scl_slope`/`scl_inter` scaling.
7//!
8//! Handles `.nii`, `.nii.gz`, `.dtseries.nii`, and legacy `.hdr`/`.img` pairs.
9//!
10//! # Performance
11//!
12//! - Bulk reads entire data region in one I/O call
13//! - Type-specific decode paths (no branch in inner loop)
14//! - Pre-computed scaling: `value = raw * slope + inter`
15//! - For `.nii.gz`: streams through gzip decompressor
16//!
17//! # Example
18//!
19//! ```no_run
20//! use bids_nifti::{NiftiHeader, NiftiImage};
21//!
22//! // Header only (fast)
23//! let hdr = NiftiHeader::from_file("sub-01_bold.nii.gz".as_ref()).unwrap();
24//! println!("{}D, {} vols, TR={:?}s", hdr.ndim, hdr.n_vols(), hdr.tr());
25//!
26//! // Full image load
27//! let img = NiftiImage::from_file("sub-01_bold.nii.gz".as_ref()).unwrap();
28//! println!("shape: {:?}, {} voxels", img.shape(), img.data.len());
29//! let val = img.get_voxel(&[32, 32, 16, 0]);
30//! ```
31
32pub mod cifti;
33pub mod gifti;
34#[allow(unsafe_code)]
35pub mod mmap;
36pub mod qmri;
37
38pub use cifti::{BrainModel, CiftiHeader, read_cifti_header};
39pub use gifti::{GiftiImage, read_gifti_header};
40pub use qmri::QmriMetadata;
41
42use std::io::{BufReader, Read, Seek, SeekFrom};
43use std::path::Path;
44
45// ─── NIfTI datatype codes ──────────────────────────────────────────────────────
46
47/// NIfTI datatype codes (from nifti1.h).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[repr(i16)]
50pub enum DataType {
51    UInt8 = 2,
52    Int16 = 4,
53    Int32 = 8,
54    Float32 = 16,
55    Float64 = 64,
56    Int8 = 256,
57    UInt16 = 512,
58    UInt32 = 768,
59    Int64 = 1024,
60    UInt64 = 1280,
61    Unknown = -1,
62}
63
64impl DataType {
65    fn from_code(code: i16) -> Self {
66        match code {
67            2 => Self::UInt8,
68            4 => Self::Int16,
69            8 => Self::Int32,
70            16 => Self::Float32,
71            64 => Self::Float64,
72            256 => Self::Int8,
73            512 => Self::UInt16,
74            768 => Self::UInt32,
75            1024 => Self::Int64,
76            1280 => Self::UInt64,
77            _ => Self::Unknown,
78        }
79    }
80
81    fn bytes_per_voxel(self) -> usize {
82        match self {
83            Self::UInt8 | Self::Int8 => 1,
84            Self::Int16 | Self::UInt16 => 2,
85            Self::Int32 | Self::UInt32 | Self::Float32 => 4,
86            Self::Float64 | Self::Int64 | Self::UInt64 => 8,
87            Self::Unknown => 0,
88        }
89    }
90}
91
92// ─── NiftiHeader ───────────────────────────────────────────────────────────────
93
94#[derive(Debug, Clone)]
95pub struct NiftiHeader {
96    /// Number of dimensions (1-7).
97    pub ndim: usize,
98    /// Size of each dimension: `dim[0]`=ndim, `dim[1..8]`.
99    pub dim: [i64; 8],
100    /// Voxel sizes / TR: `pixdim[1..3]` = voxel mm, `pixdim[4]` = TR for 4D.
101    pub pixdim: [f64; 8],
102    /// Data type code.
103    pub datatype: i16,
104    /// Bits per voxel.
105    pub bitpix: i16,
106    /// NIfTI version (1 or 2).
107    pub version: u8,
108    /// Byte offset to the start of voxel data.
109    pub vox_offset: u64,
110    /// Slope for intensity scaling: scaled = raw * scl_slope + scl_inter.
111    /// If 0.0, no scaling is applied.
112    pub scl_slope: f64,
113    /// Intercept for intensity scaling.
114    pub scl_inter: f64,
115    /// sform affine matrix (4×4, row-major). Maps voxel (i,j,k) → (x,y,z) in mm.
116    /// Set from sform if sform_code > 0, else from qform.
117    pub affine: [[f64; 4]; 4],
118    /// sform code (0=unknown, 1=scanner, 2=aligned, 3=Talairach, 4=MNI).
119    pub sform_code: i16,
120    /// qform code.
121    pub qform_code: i16,
122}
123
124impl NiftiHeader {
125    /// Read header from a NIfTI file (.nii, .nii.gz, .hdr).
126    pub fn from_file(path: &Path) -> Result<Self, NiftiError> {
127        let name = path.to_string_lossy();
128        if name.ends_with(".gz") {
129            let file = std::fs::File::open(path)?;
130            let mut gz = flate2::read::GzDecoder::new(file);
131            Self::parse_reader(&mut gz)
132        } else {
133            let mut file = std::fs::File::open(path)?;
134            Self::parse_reader(&mut file)
135        }
136    }
137
138    fn parse_reader<R: Read>(reader: &mut R) -> Result<Self, NiftiError> {
139        let mut buf4 = [0u8; 4];
140        reader.read_exact(&mut buf4)?;
141        let sizeof_hdr = i32::from_le_bytes(buf4);
142
143        if sizeof_hdr == 348 {
144            Self::parse_nifti1(reader)
145        } else if sizeof_hdr == 540 {
146            Self::parse_nifti2(reader)
147        } else {
148            let sizeof_be = i32::from_be_bytes(buf4);
149            if sizeof_be == 348 || sizeof_be == 540 {
150                Err(NiftiError::BigEndian)
151            } else {
152                Err(NiftiError::InvalidHeader(sizeof_hdr))
153            }
154        }
155    }
156
157    fn parse_nifti1<R: Read>(reader: &mut R) -> Result<Self, NiftiError> {
158        let mut buf = [0u8; 344];
159        reader.read_exact(&mut buf)?;
160
161        let f32_at = |off: usize| -> f64 {
162            f32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) as f64
163        };
164        let i16_at = |off: usize| -> i16 { i16::from_le_bytes([buf[off], buf[off + 1]]) };
165
166        let mut dim = [0i64; 8];
167        for (i, d) in dim.iter_mut().enumerate() {
168            *d = i16_at(36 + i * 2) as i64;
169        }
170
171        let datatype = i16_at(66);
172        let bitpix = i16_at(68);
173
174        let mut pixdim = [0.0f64; 8];
175        for (i, p) in pixdim.iter_mut().enumerate() {
176            *p = f32_at(72 + i * 4);
177        }
178
179        let vox_offset = f32_at(104) as u64;
180        let scl_slope = f32_at(108);
181        let scl_inter = f32_at(112);
182
183        // qform_code: byte 252 → offset 248; sform_code: byte 254 → offset 250
184        let qform_code = i16_at(248);
185        let sform_code = i16_at(250);
186
187        // sform affine: bytes 280-327 → offset 276-323 (3 rows × 4 cols of f32)
188        let mut affine = [[0.0f64; 4]; 4];
189        if sform_code > 0 {
190            for (row, arow) in affine[..3].iter_mut().enumerate() {
191                for (col, acol) in arow.iter_mut().enumerate() {
192                    *acol = f32_at(276 + (row * 4 + col) * 4);
193                }
194            }
195            affine[3][3] = 1.0;
196        } else if qform_code > 0 {
197            // Build affine from quaternion (qoffset + quatern params)
198            let qb = f32_at(256);
199            let qc = f32_at(260);
200            let qd = f32_at(264);
201            let qx = f32_at(268);
202            let qy = f32_at(272);
203            let qz = f32_at(276); // Note: overlaps with sform — only used when sform_code==0
204            // Actually qoffset_x/y/z are at different offsets for NIfTI-1:
205            // qoffset_x=268, qoffset_y=272, qoffset_z=276
206            let qa = (1.0 - qb * qb - qc * qc - qd * qd).max(0.0).sqrt();
207            let (i, j, k) = (pixdim[1], pixdim[2], pixdim[3]);
208            let qfac = if pixdim[0] < 0.0 { -1.0 } else { 1.0 };
209
210            affine[0][0] = (qa * qa + qb * qb - qc * qc - qd * qd) * i;
211            affine[0][1] = 2.0 * (qb * qc - qa * qd) * j;
212            affine[0][2] = 2.0 * (qb * qd + qa * qc) * k * qfac;
213            affine[0][3] = qx;
214            affine[1][0] = 2.0 * (qb * qc + qa * qd) * i;
215            affine[1][1] = (qa * qa + qc * qc - qb * qb - qd * qd) * j;
216            affine[1][2] = 2.0 * (qc * qd - qa * qb) * k * qfac;
217            affine[1][3] = qy;
218            affine[2][0] = 2.0 * (qb * qd - qa * qc) * i;
219            affine[2][1] = 2.0 * (qc * qd + qa * qb) * j;
220            affine[2][2] = (qa * qa + qd * qd - qb * qb - qc * qc) * k * qfac;
221            affine[2][3] = qz;
222            affine[3][3] = 1.0;
223        } else {
224            // Method 1: use pixdim as diagonal
225            affine[0][0] = pixdim[1];
226            affine[1][1] = pixdim[2];
227            affine[2][2] = pixdim[3];
228            affine[3][3] = 1.0;
229        }
230
231        let ndim = dim[0].clamp(0, 7) as usize;
232
233        Ok(Self {
234            ndim,
235            dim,
236            pixdim,
237            datatype,
238            bitpix,
239            version: 1,
240            vox_offset,
241            scl_slope,
242            scl_inter,
243            affine,
244            sform_code,
245            qform_code,
246        })
247    }
248
249    fn parse_nifti2<R: Read>(reader: &mut R) -> Result<Self, NiftiError> {
250        let mut buf = [0u8; 536];
251        reader.read_exact(&mut buf)?;
252
253        let f64_at =
254            |off: usize| -> f64 { f64::from_le_bytes(buf[off..off + 8].try_into().unwrap()) };
255        let i16_at = |off: usize| -> i16 { i16::from_le_bytes([buf[off], buf[off + 1]]) };
256        let i64_at =
257            |off: usize| -> i64 { i64::from_le_bytes(buf[off..off + 8].try_into().unwrap()) };
258
259        let datatype = i16_at(8);
260        let bitpix = i16_at(10);
261
262        let mut dim = [0i64; 8];
263        for (i, d) in dim.iter_mut().enumerate() {
264            *d = i64_at(12 + i * 8);
265        }
266
267        let mut pixdim = [0.0f64; 8];
268        for (i, p) in pixdim.iter_mut().enumerate() {
269            *p = f64_at(100 + i * 8);
270        }
271
272        let vox_offset = i64_at(164) as u64;
273        let scl_slope = f64_at(172);
274        let scl_inter = f64_at(180);
275
276        // NIfTI-2: qform_code at 344, sform_code at 346
277        let qform_code = i16_at(340);
278        let sform_code = i16_at(342);
279
280        // sform: 3×4 f64 at bytes 400-495 → offset 396-491
281        let mut affine = [[0.0f64; 4]; 4];
282        if sform_code > 0 {
283            for (row, arow) in affine[..3].iter_mut().enumerate() {
284                for (col, acol) in arow.iter_mut().enumerate() {
285                    *acol = f64_at(396 + (row * 4 + col) * 8);
286                }
287            }
288            affine[3][3] = 1.0;
289        } else {
290            affine[0][0] = pixdim[1];
291            affine[1][1] = pixdim[2];
292            affine[2][2] = pixdim[3];
293            affine[3][3] = 1.0;
294        }
295
296        let ndim = dim[0].clamp(0, 7) as usize;
297
298        Ok(Self {
299            ndim,
300            dim,
301            pixdim,
302            datatype,
303            bitpix,
304            version: 2,
305            vox_offset,
306            scl_slope,
307            scl_inter,
308            affine,
309            sform_code,
310            qform_code,
311        })
312    }
313
314    /// Number of volumes (4th dimension). Returns 1 for 3D images.
315    pub fn n_vols(&self) -> usize {
316        if self.ndim >= 4 {
317            self.dim[4].max(1) as usize
318        } else {
319            1
320        }
321    }
322
323    /// Voxel dimensions in mm: (x, y, z).
324    pub fn voxel_size(&self) -> (f64, f64, f64) {
325        (self.pixdim[1], self.pixdim[2], self.pixdim[3])
326    }
327
328    /// Matrix size: (nx, ny, nz).
329    pub fn matrix_size(&self) -> (usize, usize, usize) {
330        (
331            self.dim[1].max(0) as usize,
332            self.dim[2].max(0) as usize,
333            self.dim[3].max(0) as usize,
334        )
335    }
336
337    /// Repetition time in seconds (`pixdim[4]` for 4D time series).
338    pub fn tr(&self) -> Option<f64> {
339        if self.ndim >= 4 && self.pixdim[4] > 0.0 {
340            Some(self.pixdim[4])
341        } else {
342            None
343        }
344    }
345
346    /// Total number of voxels across all dimensions.
347    pub fn n_voxels(&self) -> usize {
348        let mut n = 1usize;
349        for i in 1..=self.ndim {
350            n = n.saturating_mul(self.dim[i].max(0) as usize);
351        }
352        n
353    }
354
355    /// Shape as a Vec (dims 1..ndim).
356    pub fn shape(&self) -> Vec<usize> {
357        (1..=self.ndim)
358            .map(|i| self.dim[i].max(0) as usize)
359            .collect()
360    }
361
362    /// Whether slope/intercept scaling should be applied.
363    pub fn has_scaling(&self) -> bool {
364        self.scl_slope != 0.0 && (self.scl_slope != 1.0 || self.scl_inter != 0.0)
365    }
366
367    /// Parsed datatype enum.
368    pub fn data_type(&self) -> DataType {
369        DataType::from_code(self.datatype)
370    }
371
372    /// Get the 4×4 affine matrix mapping voxel indices to mm coordinates.
373    /// Equivalent to nibabel's `img.affine`.
374    pub fn affine(&self) -> &[[f64; 4]; 4] {
375        &self.affine
376    }
377
378    /// Transform voxel indices [i, j, k] to world coordinates [x, y, z] in mm.
379    pub fn voxel_to_world(&self, ijk: [f64; 3]) -> [f64; 3] {
380        let a = &self.affine;
381        [
382            a[0][0] * ijk[0] + a[0][1] * ijk[1] + a[0][2] * ijk[2] + a[0][3],
383            a[1][0] * ijk[0] + a[1][1] * ijk[1] + a[1][2] * ijk[2] + a[1][3],
384            a[2][0] * ijk[0] + a[2][1] * ijk[1] + a[2][2] * ijk[2] + a[2][3],
385        ]
386    }
387}
388
389// ─── NiftiImage ────────────────────────────────────────────────────────────────
390
391/// A loaded NIfTI image with header and voxel data.
392///
393/// All voxel data is stored as `f64` regardless of the on-disk type. If the
394/// header has non-trivial `scl_slope`/`scl_inter`, the scaling is applied
395/// automatically: `value = raw * slope + inter`.
396#[derive(Clone)]
397pub struct NiftiImage {
398    /// Parsed header.
399    pub header: NiftiHeader,
400    /// Voxel data as a flat f64 array in row-major order.
401    /// For a 4D image (x, y, z, t), the index is: x + nx*(y + ny*(z + nz*t)).
402    pub data: Vec<f64>,
403}
404
405impl std::fmt::Debug for NiftiImage {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        f.debug_struct("NiftiImage")
408            .field("shape", &self.header.shape())
409            .field("datatype", &self.header.data_type())
410            .field("n_voxels", &self.data.len())
411            .field("tr", &self.header.tr())
412            .finish()
413    }
414}
415
416impl std::fmt::Display for NiftiImage {
417    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418        let shape = self.header.shape();
419        let shape_str: Vec<String> = shape.iter().map(std::string::ToString::to_string).collect();
420        write!(f, "NiftiImage({})", shape_str.join("×"))
421    }
422}
423
424impl NiftiImage {
425    /// Load a complete NIfTI image (header + data) from a file.
426    ///
427    /// Supports `.nii`, `.nii.gz`, and `.hdr`/`.img` pairs.
428    pub fn from_file(path: &Path) -> Result<Self, NiftiError> {
429        let name = path.to_string_lossy();
430        if name.ends_with(".gz") {
431            Self::load_gz(path)
432        } else if name.ends_with(".hdr") {
433            // .hdr/.img pair
434            let img_path = path.with_extension("img");
435            Self::load_hdr_img(path, &img_path)
436        } else {
437            Self::load_nii(path)
438        }
439    }
440
441    /// Load a single volume from a 4D image (0-indexed).
442    pub fn from_file_volume(path: &Path, volume: usize) -> Result<Self, NiftiError> {
443        let name = path.to_string_lossy();
444        if name.ends_with(".gz") {
445            // For gzip, we have to decompress sequentially, so load all then slice
446            let mut img = Self::load_gz(path)?;
447            img.extract_volume_in_place(volume)?;
448            Ok(img)
449        } else {
450            Self::load_nii_volume(path, volume)
451        }
452    }
453
454    fn load_nii(path: &Path) -> Result<Self, NiftiError> {
455        let file = std::fs::File::open(path)?;
456        let mut reader = BufReader::with_capacity(256 * 1024, file);
457        let header = NiftiHeader::parse_reader(&mut reader)?;
458        let data = read_voxel_data_seekable(&mut reader, &header)?;
459        Ok(Self { header, data })
460    }
461
462    fn load_nii_volume(path: &Path, volume: usize) -> Result<Self, NiftiError> {
463        let file = std::fs::File::open(path)?;
464        let mut reader = BufReader::with_capacity(256 * 1024, file);
465        let header = NiftiHeader::parse_reader(&mut reader)?;
466        let data = read_single_volume_seekable(&mut reader, &header, volume)?;
467        let mut vol_header = header.clone();
468        // Adjust header to 3D
469        if vol_header.ndim >= 4 {
470            vol_header.dim[4] = 1;
471        }
472        Ok(Self {
473            header: vol_header,
474            data,
475        })
476    }
477
478    fn load_gz(path: &Path) -> Result<Self, NiftiError> {
479        let file = std::fs::File::open(path)?;
480        let mut gz = flate2::read::GzDecoder::new(BufReader::with_capacity(256 * 1024, file));
481        let header = NiftiHeader::parse_reader(&mut gz)?;
482        let data = read_voxel_data_stream(&mut gz, &header)?;
483        Ok(Self { header, data })
484    }
485
486    fn load_hdr_img(hdr_path: &Path, img_path: &Path) -> Result<Self, NiftiError> {
487        // Parse header from .hdr
488        let mut hdr_file = std::fs::File::open(hdr_path)?;
489        let header = NiftiHeader::parse_reader(&mut hdr_file)?;
490        // Read data from .img
491        let file = std::fs::File::open(img_path)?;
492        let mut reader = BufReader::with_capacity(256 * 1024, file);
493        let data = read_voxel_data_stream(&mut reader, &header)?;
494        Ok(Self { header, data })
495    }
496
497    fn extract_volume_in_place(&mut self, volume: usize) -> Result<(), NiftiError> {
498        if self.header.ndim < 4 {
499            return if volume == 0 {
500                Ok(())
501            } else {
502                Err(NiftiError::VolumeOutOfRange {
503                    requested: volume,
504                    available: 1,
505                })
506            };
507        }
508        let n_vols = self.header.n_vols();
509        if volume >= n_vols {
510            return Err(NiftiError::VolumeOutOfRange {
511                requested: volume,
512                available: n_vols,
513            });
514        }
515        let vol_size = self.header.n_voxels() / n_vols;
516        let start = volume * vol_size;
517        let end = start + vol_size;
518        self.data = self.data[start..end].to_vec();
519        self.header.dim[4] = 1;
520        Ok(())
521    }
522
523    /// Save image data as a safetensors file.
524    ///
525    /// Tensor name "data" with shape matching the NIfTI dimensions.
526    /// Metadata includes affine, voxel size, and TR.
527    #[cfg(feature = "safetensors")]
528    pub fn save_safetensors(&self, path: &std::path::Path) -> std::io::Result<()> {
529        use safetensors::tensor::{Dtype, TensorView};
530        use std::collections::HashMap;
531
532        let shape = self.header.shape();
533        let flat_bytes: &[u8] = bytemuck::cast_slice(&self.data);
534
535        let tensor = TensorView::new(Dtype::F64, shape.clone(), flat_bytes)
536            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
537
538        let mut tensors = HashMap::new();
539        tensors.insert("data".to_string(), tensor);
540
541        let mut metadata = HashMap::new();
542        metadata.insert("shape".to_string(), format!("{:?}", shape));
543        metadata.insert(
544            "voxel_size".to_string(),
545            format!("{:?}", self.header.voxel_size()),
546        );
547        if let Some(tr) = self.header.tr() {
548            metadata.insert("tr".to_string(), tr.to_string());
549        }
550        // Flatten affine to string
551        let aff: Vec<f64> = self
552            .header
553            .affine
554            .iter()
555            .flat_map(|r| r.iter().copied())
556            .collect();
557        metadata.insert("affine".to_string(), format!("{:?}", aff));
558
559        let bytes = safetensors::tensor::serialize(&tensors, &Some(metadata))
560            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
561        std::fs::write(path, bytes)
562    }
563
564    /// Shape of the image as a Vec.
565    pub fn shape(&self) -> Vec<usize> {
566        self.header.shape()
567    }
568
569    /// Convert to an ndarray ArrayD<f64> with the image's native shape.
570    #[cfg(feature = "ndarray")]
571    pub fn to_ndarray(&self) -> ndarray::ArrayD<f64> {
572        let shape: Vec<usize> = self.header.shape();
573        ndarray::ArrayD::from_shape_vec(ndarray::IxDyn(&shape), self.data.clone())
574            .unwrap_or_else(|_| ndarray::ArrayD::zeros(ndarray::IxDyn(&shape)))
575    }
576
577    /// Get a voxel value by multi-dimensional index [x, y, z] or [x, y, z, t].
578    pub fn get_voxel(&self, idx: &[usize]) -> Option<f64> {
579        let linear = self.linear_index(idx)?;
580        self.data.get(linear).copied()
581    }
582
583    /// Get a 3D slice at time point t from a 4D image. Returns None for 3D images
584    /// if t > 0.
585    pub fn get_volume(&self, t: usize) -> Option<Vec<f64>> {
586        let (nx, ny, nz) = self.header.matrix_size();
587        let vol_size = nx * ny * nz;
588        let n_vols = self.header.n_vols();
589        if t >= n_vols {
590            return None;
591        }
592        let start = t * vol_size;
593        let end = start + vol_size;
594        if end <= self.data.len() {
595            Some(self.data[start..end].to_vec())
596        } else {
597            None
598        }
599    }
600
601    /// Extract a time series for a single voxel [x, y, z] across all volumes.
602    pub fn get_timeseries(&self, x: usize, y: usize, z: usize) -> Option<Vec<f64>> {
603        let (nx, ny, nz) = self.header.matrix_size();
604        if x >= nx || y >= ny || z >= nz {
605            return None;
606        }
607        let vol_size = nx * ny * nz;
608        let n_vols = self.header.n_vols();
609        let voxel_offset = x + nx * (y + ny * z);
610        let mut ts = Vec::with_capacity(n_vols);
611        for t in 0..n_vols {
612            ts.push(self.data[t * vol_size + voxel_offset]);
613        }
614        Some(ts)
615    }
616
617    /// Compute the mean across all voxels (useful for QC).
618    pub fn mean(&self) -> f64 {
619        if self.data.is_empty() {
620            return 0.0;
621        }
622        self.data.iter().sum::<f64>() / self.data.len() as f64
623    }
624
625    /// Compute the mean image across time (for 4D data, returns 3D mean).
626    pub fn temporal_mean(&self) -> Vec<f64> {
627        let (nx, ny, nz) = self.header.matrix_size();
628        let vol_size = nx * ny * nz;
629        let n_vols = self.header.n_vols();
630        if n_vols <= 1 {
631            return self.data.clone();
632        }
633        let mut mean = vec![0.0f64; vol_size];
634        for t in 0..n_vols {
635            let start = t * vol_size;
636            for (m, &d) in mean
637                .iter_mut()
638                .zip(self.data[start..start + vol_size].iter())
639            {
640                *m += d;
641            }
642        }
643        let scale = 1.0 / n_vols as f64;
644        for v in &mut mean {
645            *v *= scale;
646        }
647        mean
648    }
649
650    fn linear_index(&self, idx: &[usize]) -> Option<usize> {
651        if idx.len() > self.header.ndim {
652            return None;
653        }
654        let mut linear = 0;
655        let mut stride = 1;
656        for (i, &ix) in idx.iter().enumerate() {
657            let dim_size = self.header.dim[i + 1] as usize;
658            if ix >= dim_size {
659                return None;
660            }
661            linear += ix * stride;
662            stride *= dim_size;
663        }
664        if linear < self.data.len() {
665            Some(linear)
666        } else {
667            None
668        }
669    }
670}
671
672// ─── Data reading ──────────────────────────────────────────────────────────────
673
674/// Read voxel data from a seekable reader (uncompressed .nii).
675fn read_voxel_data_seekable<R: Read + Seek>(
676    reader: &mut R,
677    header: &NiftiHeader,
678) -> Result<Vec<f64>, NiftiError> {
679    let hdr_size = if header.version == 1 { 348u64 } else { 540 };
680    let offset = if header.vox_offset > hdr_size {
681        header.vox_offset
682    } else {
683        hdr_size
684    };
685    reader.seek(SeekFrom::Start(offset))?;
686    decode_voxels(reader, header)
687}
688
689/// Read a single volume from a seekable reader.
690fn read_single_volume_seekable<R: Read + Seek>(
691    reader: &mut R,
692    header: &NiftiHeader,
693    volume: usize,
694) -> Result<Vec<f64>, NiftiError> {
695    let n_vols = header.n_vols();
696    if volume >= n_vols {
697        return Err(NiftiError::VolumeOutOfRange {
698            requested: volume,
699            available: n_vols,
700        });
701    }
702    let dt = DataType::from_code(header.datatype);
703    let bpv = dt.bytes_per_voxel();
704    if bpv == 0 {
705        return Err(NiftiError::UnsupportedDataType(header.datatype));
706    }
707
708    let vol_voxels = header.n_voxels() / n_vols;
709    let vol_bytes = vol_voxels * bpv;
710
711    let hdr_size = if header.version == 1 { 348u64 } else { 540 };
712    let offset = if header.vox_offset > hdr_size {
713        header.vox_offset
714    } else {
715        hdr_size
716    };
717    reader.seek(SeekFrom::Start(offset + volume as u64 * vol_bytes as u64))?;
718
719    let mut raw = vec![0u8; vol_bytes];
720    reader.read_exact(&mut raw)?;
721    decode_raw_to_f64(&raw, dt, header.scl_slope, header.scl_inter)
722}
723
724/// Read voxel data from a streaming reader (gzip, .img pair).
725fn read_voxel_data_stream<R: Read>(
726    reader: &mut R,
727    header: &NiftiHeader,
728) -> Result<Vec<f64>, NiftiError> {
729    // Skip to vox_offset (for .nii.gz the header was already consumed by parse_reader,
730    // so we need to skip: vox_offset - header_size bytes)
731    let hdr_size = if header.version == 1 { 348u64 } else { 540 };
732    let offset = if header.vox_offset > hdr_size {
733        header.vox_offset
734    } else {
735        hdr_size
736    };
737    let skip = offset - hdr_size;
738    if skip > 0 {
739        let mut discard = vec![0u8; skip as usize];
740        reader.read_exact(&mut discard)?;
741    }
742    decode_voxels(reader, header)
743}
744
745/// Decode all voxels from current reader position.
746fn decode_voxels<R: Read>(reader: &mut R, header: &NiftiHeader) -> Result<Vec<f64>, NiftiError> {
747    let dt = DataType::from_code(header.datatype);
748    let bpv = dt.bytes_per_voxel();
749    if bpv == 0 {
750        return Err(NiftiError::UnsupportedDataType(header.datatype));
751    }
752
753    let n_voxels = header.n_voxels();
754    let total_bytes = n_voxels * bpv;
755    let mut raw = vec![0u8; total_bytes];
756    reader.read_exact(&mut raw)?;
757
758    decode_raw_to_f64(&raw, dt, header.scl_slope, header.scl_inter)
759}
760
761/// Convert raw bytes to f64 with optional scaling. Type-specific decode paths
762/// with no branch in the inner loop.
763#[inline(never)]
764pub(crate) fn decode_raw_to_f64(
765    raw: &[u8],
766    dt: DataType,
767    scl_slope: f64,
768    scl_inter: f64,
769) -> Result<Vec<f64>, NiftiError> {
770    let bpv = dt.bytes_per_voxel();
771    let n = raw.len() / bpv;
772
773    let mut data = vec![0.0f64; n];
774
775    let apply_scaling = scl_slope != 0.0 && (scl_slope != 1.0 || scl_inter != 0.0);
776    let slope = if scl_slope == 0.0 { 1.0 } else { scl_slope };
777    let inter = scl_inter;
778
779    // Helper: read a LE value from a byte slice at a known-good offset.
780    // Using explicit byte indexing avoids try_into().unwrap().
781    macro_rules! le_read {
782        (i16, $src:expr, $off:expr) => {
783            i16::from_le_bytes([$src[$off], $src[$off + 1]])
784        };
785        (u16, $src:expr, $off:expr) => {
786            u16::from_le_bytes([$src[$off], $src[$off + 1]])
787        };
788        (i32, $src:expr, $off:expr) => {
789            i32::from_le_bytes([$src[$off], $src[$off + 1], $src[$off + 2], $src[$off + 3]])
790        };
791        (u32, $src:expr, $off:expr) => {
792            u32::from_le_bytes([$src[$off], $src[$off + 1], $src[$off + 2], $src[$off + 3]])
793        };
794        (f32, $src:expr, $off:expr) => {
795            f32::from_le_bytes([$src[$off], $src[$off + 1], $src[$off + 2], $src[$off + 3]])
796        };
797        (f64, $src:expr, $off:expr) => {
798            f64::from_le_bytes([
799                $src[$off],
800                $src[$off + 1],
801                $src[$off + 2],
802                $src[$off + 3],
803                $src[$off + 4],
804                $src[$off + 5],
805                $src[$off + 6],
806                $src[$off + 7],
807            ])
808        };
809        (i64, $src:expr, $off:expr) => {
810            i64::from_le_bytes([
811                $src[$off],
812                $src[$off + 1],
813                $src[$off + 2],
814                $src[$off + 3],
815                $src[$off + 4],
816                $src[$off + 5],
817                $src[$off + 6],
818                $src[$off + 7],
819            ])
820        };
821        (u64, $src:expr, $off:expr) => {
822            u64::from_le_bytes([
823                $src[$off],
824                $src[$off + 1],
825                $src[$off + 2],
826                $src[$off + 3],
827                $src[$off + 4],
828                $src[$off + 5],
829                $src[$off + 6],
830                $src[$off + 7],
831            ])
832        };
833    }
834
835    match dt {
836        DataType::UInt8 => {
837            for (dst, &src) in data.iter_mut().zip(raw.iter()) {
838                *dst = src as f64;
839            }
840        }
841        DataType::Int8 => {
842            for (dst, &src) in data.iter_mut().zip(raw.iter()) {
843                *dst = (src as i8) as f64;
844            }
845        }
846        DataType::Int16 => {
847            for (i, dst) in data.iter_mut().enumerate() {
848                *dst = le_read!(i16, raw, i * 2) as f64;
849            }
850        }
851        DataType::UInt16 => {
852            for (i, dst) in data.iter_mut().enumerate() {
853                *dst = le_read!(u16, raw, i * 2) as f64;
854            }
855        }
856        DataType::Int32 => {
857            for (i, dst) in data.iter_mut().enumerate() {
858                *dst = le_read!(i32, raw, i * 4) as f64;
859            }
860        }
861        DataType::UInt32 => {
862            for (i, dst) in data.iter_mut().enumerate() {
863                *dst = le_read!(u32, raw, i * 4) as f64;
864            }
865        }
866        DataType::Float32 => {
867            for (i, dst) in data.iter_mut().enumerate() {
868                *dst = le_read!(f32, raw, i * 4) as f64;
869            }
870        }
871        DataType::Float64 => {
872            for (i, dst) in data.iter_mut().enumerate() {
873                *dst = le_read!(f64, raw, i * 8);
874            }
875        }
876        DataType::Int64 => {
877            for (i, dst) in data.iter_mut().enumerate() {
878                *dst = le_read!(i64, raw, i * 8) as f64;
879            }
880        }
881        DataType::UInt64 => {
882            for (i, dst) in data.iter_mut().enumerate() {
883                *dst = le_read!(u64, raw, i * 8) as f64;
884            }
885        }
886        DataType::Unknown => {
887            return Err(NiftiError::UnsupportedDataType(dt as i16));
888        }
889    }
890
891    if apply_scaling {
892        for v in &mut data {
893            *v = *v * slope + inter;
894        }
895    }
896
897    Ok(data)
898}
899
900// ─── Error ─────────────────────────────────────────────────────────────────────
901
902#[derive(Debug, thiserror::Error)]
903pub enum NiftiError {
904    #[error("IO error: {0}")]
905    Io(#[from] std::io::Error),
906    #[error("Invalid NIfTI header size: {0}")]
907    InvalidHeader(i32),
908    #[error("Big-endian NIfTI not supported (swap bytes externally)")]
909    BigEndian,
910    #[error("Unsupported NIfTI datatype code: {0}")]
911    UnsupportedDataType(i16),
912    #[error("Volume {requested} out of range (available: {available})")]
913    VolumeOutOfRange { requested: usize, available: usize },
914}
915
916// ─── Tests ─────────────────────────────────────────────────────────────────────
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use std::io::Write;
922
923    /// Build a NIfTI-1 .nii file in memory with given dimensions and float32 data.
924    fn build_nifti1(dims: &[i16; 8], pixdims: &[f32; 8], data: &[f32]) -> Vec<u8> {
925        let mut header = vec![0u8; 348];
926        header[0..4].copy_from_slice(&348i32.to_le_bytes());
927        for (i, &d) in dims.iter().enumerate() {
928            let off = 40 + i * 2;
929            header[off..off + 2].copy_from_slice(&d.to_le_bytes());
930        }
931        // datatype = FLOAT32 (16)
932        header[70..72].copy_from_slice(&16i16.to_le_bytes());
933        // bitpix = 32
934        header[72..74].copy_from_slice(&32i16.to_le_bytes());
935        for (i, &p) in pixdims.iter().enumerate() {
936            let off = 76 + i * 4;
937            header[off..off + 4].copy_from_slice(&p.to_le_bytes());
938        }
939        // vox_offset = 352 (348 header + 4 extension bytes)
940        header[108..112].copy_from_slice(&352.0f32.to_le_bytes());
941        // scl_slope = 1.0
942        header[112..116].copy_from_slice(&1.0f32.to_le_bytes());
943        // scl_inter = 0.0
944        header[116..120].copy_from_slice(&0.0f32.to_le_bytes());
945        // magic
946        header[344..348].copy_from_slice(b"n+1\0");
947        // 4 extension bytes (required by NIfTI-1 .nii)
948        header.extend_from_slice(&[0u8; 4]);
949        // data
950        for &v in data {
951            header.extend_from_slice(&v.to_le_bytes());
952        }
953        header
954    }
955
956    fn write_nifti1_file(path: &Path, dims: &[i16; 8], pixdims: &[f32; 8], data: &[f32]) {
957        let bytes = build_nifti1(dims, pixdims, data);
958        std::fs::write(path, &bytes).unwrap();
959    }
960
961    fn write_nifti1_gz(path: &Path, dims: &[i16; 8], pixdims: &[f32; 8], data: &[f32]) {
962        let bytes = build_nifti1(dims, pixdims, data);
963        let file = std::fs::File::create(path).unwrap();
964        let mut gz = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
965        gz.write_all(&bytes).unwrap();
966        gz.finish().unwrap();
967    }
968
969    #[test]
970    fn test_header_nifti1() {
971        let dir = std::env::temp_dir().join("bids_nifti_hdr1");
972        std::fs::create_dir_all(&dir).unwrap();
973        let path = dir.join("test.nii");
974        let dims: [i16; 8] = [4, 64, 64, 32, 100, 1, 1, 1];
975        let pixdims: [f32; 8] = [1.0, 3.0, 3.0, 3.5, 2.0, 0.0, 0.0, 0.0];
976        let data: Vec<f32> = vec![0.0; 64 * 64 * 32 * 100];
977        write_nifti1_file(&path, &dims, &pixdims, &data);
978
979        let hdr = NiftiHeader::from_file(&path).unwrap();
980        assert_eq!(hdr.version, 1);
981        assert_eq!(hdr.ndim, 4);
982        assert_eq!(hdr.n_vols(), 100);
983        assert_eq!(hdr.matrix_size(), (64, 64, 32));
984        assert!((hdr.tr().unwrap() - 2.0).abs() < 0.01);
985        assert_eq!(hdr.n_voxels(), 64 * 64 * 32 * 100);
986        assert_eq!(hdr.shape(), vec![64, 64, 32, 100]);
987
988        std::fs::remove_dir_all(&dir).unwrap();
989    }
990
991    #[test]
992    fn test_load_3d_image() {
993        let dir = std::env::temp_dir().join("bids_nifti_3d");
994        std::fs::create_dir_all(&dir).unwrap();
995        let path = dir.join("anat.nii");
996        let dims: [i16; 8] = [3, 4, 4, 3, 1, 1, 1, 1];
997        let pixdims: [f32; 8] = [1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];
998        // 4×4×3 = 48 voxels, fill with index values
999        let data: Vec<f32> = (0..48).map(|i| i as f32).collect();
1000        write_nifti1_file(&path, &dims, &pixdims, &data);
1001
1002        let img = NiftiImage::from_file(&path).unwrap();
1003        assert_eq!(img.shape(), vec![4, 4, 3]);
1004        assert_eq!(img.data.len(), 48);
1005        // Voxel at [0,0,0] = 0.0
1006        assert_eq!(img.get_voxel(&[0, 0, 0]), Some(0.0));
1007        // Voxel at [1,0,0] = 1.0 (x varies fastest)
1008        assert_eq!(img.get_voxel(&[1, 0, 0]), Some(1.0));
1009        // Voxel at [0,1,0] = 4.0 (stride = nx = 4)
1010        assert_eq!(img.get_voxel(&[0, 1, 0]), Some(4.0));
1011        // Voxel at [0,0,1] = 16.0 (stride = nx*ny = 16)
1012        assert_eq!(img.get_voxel(&[0, 0, 1]), Some(16.0));
1013        // Out of bounds
1014        assert_eq!(img.get_voxel(&[4, 0, 0]), None);
1015
1016        assert!((img.mean() - 23.5).abs() < 0.01);
1017
1018        std::fs::remove_dir_all(&dir).unwrap();
1019    }
1020
1021    #[test]
1022    fn test_load_4d_image() {
1023        let dir = std::env::temp_dir().join("bids_nifti_4d");
1024        std::fs::create_dir_all(&dir).unwrap();
1025        let path = dir.join("bold.nii");
1026        let nx = 4;
1027        let ny = 4;
1028        let nz = 3;
1029        let nt = 10;
1030        let dims: [i16; 8] = [4, nx as i16, ny as i16, nz as i16, nt as i16, 1, 1, 1];
1031        let pixdims: [f32; 8] = [1.0, 2.0, 2.0, 2.0, 1.5, 0.0, 0.0, 0.0];
1032        let vol_size = nx * ny * nz;
1033        let data: Vec<f32> = (0..(vol_size * nt)).map(|i| i as f32).collect();
1034        write_nifti1_file(&path, &dims, &pixdims, &data);
1035
1036        let img = NiftiImage::from_file(&path).unwrap();
1037        assert_eq!(img.shape(), vec![4, 4, 3, 10]);
1038        assert_eq!(img.data.len(), vol_size * nt);
1039        assert_eq!(img.header.n_vols(), 10);
1040
1041        // Get volume 0
1042        let vol0 = img.get_volume(0).unwrap();
1043        assert_eq!(vol0.len(), vol_size);
1044        assert_eq!(vol0[0], 0.0);
1045        assert_eq!(vol0[vol_size - 1], (vol_size - 1) as f64);
1046
1047        // Get volume 1
1048        let vol1 = img.get_volume(1).unwrap();
1049        assert_eq!(vol1[0], vol_size as f64);
1050
1051        // Timeseries for voxel [0,0,0]
1052        let ts = img.get_timeseries(0, 0, 0).unwrap();
1053        assert_eq!(ts.len(), nt);
1054        for t in 0..nt {
1055            assert_eq!(ts[t], (t * vol_size) as f64);
1056        }
1057
1058        // Temporal mean
1059        let tmean = img.temporal_mean();
1060        assert_eq!(tmean.len(), vol_size);
1061        // Mean of voxel [0,0,0] across time: mean of 0, 48, 96, ..., 432
1062        let expected_mean = (0..nt).map(|t| (t * vol_size) as f64).sum::<f64>() / nt as f64;
1063        assert!((tmean[0] - expected_mean).abs() < 0.01);
1064
1065        std::fs::remove_dir_all(&dir).unwrap();
1066    }
1067
1068    #[test]
1069    fn test_load_single_volume() {
1070        let dir = std::env::temp_dir().join("bids_nifti_vol");
1071        std::fs::create_dir_all(&dir).unwrap();
1072        let path = dir.join("bold.nii");
1073        let nx = 4;
1074        let ny = 4;
1075        let nz = 3;
1076        let nt = 5;
1077        let dims: [i16; 8] = [4, nx as i16, ny as i16, nz as i16, nt as i16, 1, 1, 1];
1078        let pixdims: [f32; 8] = [1.0, 2.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0];
1079        let vol_size = nx * ny * nz;
1080        let data: Vec<f32> = (0..(vol_size * nt)).map(|i| i as f32).collect();
1081        write_nifti1_file(&path, &dims, &pixdims, &data);
1082
1083        let img = NiftiImage::from_file_volume(&path, 2).unwrap();
1084        assert_eq!(img.data.len(), vol_size);
1085        assert_eq!(img.data[0], (2 * vol_size) as f64);
1086
1087        // Out of range
1088        assert!(NiftiImage::from_file_volume(&path, 5).is_err());
1089
1090        std::fs::remove_dir_all(&dir).unwrap();
1091    }
1092
1093    #[test]
1094    fn test_load_gzip() {
1095        let dir = std::env::temp_dir().join("bids_nifti_gz");
1096        std::fs::create_dir_all(&dir).unwrap();
1097        let path = dir.join("test.nii.gz");
1098        let dims: [i16; 8] = [3, 8, 8, 4, 1, 1, 1, 1];
1099        let pixdims: [f32; 8] = [1.0, 1.5, 1.5, 1.5, 0.0, 0.0, 0.0, 0.0];
1100        let data: Vec<f32> = (0..256).map(|i| i as f32 * 0.5).collect();
1101        write_nifti1_gz(&path, &dims, &pixdims, &data);
1102
1103        let img = NiftiImage::from_file(&path).unwrap();
1104        assert_eq!(img.shape(), vec![8, 8, 4]);
1105        assert_eq!(img.data.len(), 256);
1106        assert!((img.data[0] - 0.0).abs() < 0.01);
1107        assert!((img.data[1] - 0.5).abs() < 0.01);
1108        assert!((img.data[255] - 127.5).abs() < 0.01);
1109
1110        std::fs::remove_dir_all(&dir).unwrap();
1111    }
1112
1113    #[test]
1114    fn test_scaling() {
1115        let dir = std::env::temp_dir().join("bids_nifti_scl");
1116        std::fs::create_dir_all(&dir).unwrap();
1117        let path = dir.join("scaled.nii");
1118        let dims: [i16; 8] = [3, 2, 2, 2, 1, 1, 1, 1];
1119        let pixdims: [f32; 8] = [1.0; 8];
1120        let raw_data: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1121
1122        // Build with custom slope/inter
1123        let mut bytes = build_nifti1(&dims, &pixdims, &raw_data);
1124        // scl_slope at byte 112, scl_inter at 116
1125        bytes[112..116].copy_from_slice(&2.0f32.to_le_bytes()); // slope = 2
1126        bytes[116..120].copy_from_slice(&10.0f32.to_le_bytes()); // inter = 10
1127        std::fs::write(&path, &bytes).unwrap();
1128
1129        let img = NiftiImage::from_file(&path).unwrap();
1130        assert!(img.header.has_scaling());
1131        // value = raw * 2 + 10
1132        assert!((img.data[0] - 12.0).abs() < 0.01); // 1*2+10
1133        assert!((img.data[7] - 26.0).abs() < 0.01); // 8*2+10
1134
1135        std::fs::remove_dir_all(&dir).unwrap();
1136    }
1137
1138    #[test]
1139    fn test_int16_datatype() {
1140        let dir = std::env::temp_dir().join("bids_nifti_i16");
1141        std::fs::create_dir_all(&dir).unwrap();
1142        let path = dir.join("int16.nii");
1143
1144        let dims: [i16; 8] = [3, 4, 4, 2, 1, 1, 1, 1];
1145        let n_voxels = 32usize;
1146
1147        let mut header = vec![0u8; 348];
1148        header[0..4].copy_from_slice(&348i32.to_le_bytes());
1149        for (i, &d) in dims.iter().enumerate() {
1150            let off = 40 + i * 2;
1151            header[off..off + 2].copy_from_slice(&d.to_le_bytes());
1152        }
1153        // datatype = INT16 (4)
1154        header[70..72].copy_from_slice(&4i16.to_le_bytes());
1155        header[72..74].copy_from_slice(&16i16.to_le_bytes());
1156        let pixdims = [1.0f32; 8];
1157        for (i, &p) in pixdims.iter().enumerate() {
1158            let off = 76 + i * 4;
1159            header[off..off + 4].copy_from_slice(&p.to_le_bytes());
1160        }
1161        header[108..112].copy_from_slice(&352.0f32.to_le_bytes());
1162        header[112..116].copy_from_slice(&0.0f32.to_le_bytes()); // slope=0 → no scaling
1163        header[344..348].copy_from_slice(b"n+1\0");
1164        header.extend_from_slice(&[0u8; 4]);
1165
1166        // Write int16 data
1167        for i in 0..n_voxels as i16 {
1168            header.extend_from_slice(&(i * 100).to_le_bytes());
1169        }
1170        std::fs::write(&path, &header).unwrap();
1171
1172        let img = NiftiImage::from_file(&path).unwrap();
1173        assert_eq!(img.data.len(), n_voxels);
1174        assert_eq!(img.data[0], 0.0);
1175        assert_eq!(img.data[1], 100.0);
1176        assert_eq!(img.data[31], 3100.0);
1177
1178        std::fs::remove_dir_all(&dir).unwrap();
1179    }
1180
1181    #[test]
1182    fn test_large_4d_performance() {
1183        let dir = std::env::temp_dir().join("bids_nifti_perf");
1184        std::fs::create_dir_all(&dir).unwrap();
1185        let path = dir.join("bold.nii");
1186
1187        // 64×64×32×100 float32 = ~50 MB
1188        let nx = 64;
1189        let ny = 64;
1190        let nz = 32;
1191        let nt = 100;
1192        let dims: [i16; 8] = [4, nx, ny, nz, nt, 1, 1, 1];
1193        let pixdims: [f32; 8] = [1.0, 3.0, 3.0, 3.5, 2.0, 0.0, 0.0, 0.0];
1194        let n = (nx as usize) * (ny as usize) * (nz as usize) * (nt as usize);
1195        // Generate data in bulk
1196        let data: Vec<f32> = (0..n).map(|i| (i % 1000) as f32 * 0.1).collect();
1197        write_nifti1_file(&path, &dims, &pixdims, &data);
1198
1199        let file_mb = std::fs::metadata(&path).unwrap().len() as f64 / 1e6;
1200
1201        // Warm up
1202        let _ = NiftiImage::from_file(&path).unwrap();
1203
1204        let t = std::time::Instant::now();
1205        let img = NiftiImage::from_file(&path).unwrap();
1206        let ms = t.elapsed().as_secs_f64() * 1000.0;
1207
1208        assert_eq!(img.data.len(), n);
1209        assert_eq!(img.header.n_vols(), nt as usize);
1210
1211        eprintln!(
1212            "  NIfTI 64×64×32×100 float32 ({:.0}MB): {:.1}ms ({:.0} MB/s)",
1213            file_mb,
1214            ms,
1215            file_mb / (ms / 1000.0)
1216        );
1217
1218        // Should be well under 500ms for 50MB uncompressed
1219        assert!(ms < 500.0, "NIfTI read took {:.0}ms, expected <500ms", ms);
1220
1221        std::fs::remove_dir_all(&dir).unwrap();
1222    }
1223
1224    #[test]
1225    fn test_header_nifti2_synthetic() {
1226        let mut header = vec![0u8; 540];
1227        header[0..4].copy_from_slice(&540i32.to_le_bytes());
1228        let dims: [i64; 8] = [4, 96, 96, 48, 200, 1, 1, 1];
1229        for (i, &d) in dims.iter().enumerate() {
1230            let off = 16 + i * 8;
1231            header[off..off + 8].copy_from_slice(&d.to_le_bytes());
1232        }
1233        let pixdims: [f64; 8] = [1.0, 2.0, 2.0, 2.0, 1.5, 0.0, 0.0, 0.0];
1234        for (i, &p) in pixdims.iter().enumerate() {
1235            let off = 104 + i * 8;
1236            header[off..off + 8].copy_from_slice(&p.to_le_bytes());
1237        }
1238
1239        let mut cursor = std::io::Cursor::new(header);
1240        let hdr = NiftiHeader::parse_reader(&mut cursor).unwrap();
1241
1242        assert_eq!(hdr.version, 2);
1243        assert_eq!(hdr.n_vols(), 200);
1244        assert_eq!(hdr.matrix_size(), (96, 96, 48));
1245        assert!((hdr.tr().unwrap() - 1.5).abs() < 0.01);
1246    }
1247
1248    #[test]
1249    fn test_sform_affine() {
1250        let dir = std::env::temp_dir().join("bids_nifti_affine");
1251        std::fs::create_dir_all(&dir).unwrap();
1252        let path = dir.join("affine.nii");
1253
1254        let dims: [i16; 8] = [3, 4, 4, 3, 1, 1, 1, 1];
1255        let pixdims: [f32; 8] = [1.0, 2.0, 2.0, 3.0, 0.0, 0.0, 0.0, 0.0];
1256        let data: Vec<f32> = vec![0.0; 48];
1257        let mut bytes = build_nifti1(&dims, &pixdims, &data);
1258
1259        // Set sform_code = 1 (scanner) at byte 254 (offset 254 in full header)
1260        bytes[254..256].copy_from_slice(&1i16.to_le_bytes());
1261        // sform affine at bytes 280-327: set diagonal = [2, 2, 3] + offset = [10, 20, 30]
1262        // Row 0: [2, 0, 0, 10]
1263        let sform_vals: [f32; 12] = [
1264            2.0, 0.0, 0.0, 10.0, 0.0, 2.0, 0.0, 20.0, 0.0, 0.0, 3.0, 30.0,
1265        ];
1266        for (i, &v) in sform_vals.iter().enumerate() {
1267            let off = 280 + i * 4;
1268            bytes[off..off + 4].copy_from_slice(&v.to_le_bytes());
1269        }
1270        std::fs::write(&path, &bytes).unwrap();
1271
1272        let hdr = NiftiHeader::from_file(&path).unwrap();
1273        assert_eq!(hdr.sform_code, 1);
1274        assert!((hdr.affine[0][0] - 2.0).abs() < 1e-6);
1275        assert!((hdr.affine[1][1] - 2.0).abs() < 1e-6);
1276        assert!((hdr.affine[2][2] - 3.0).abs() < 1e-6);
1277        assert!((hdr.affine[0][3] - 10.0).abs() < 1e-6);
1278        assert!((hdr.affine[1][3] - 20.0).abs() < 1e-6);
1279        assert!((hdr.affine[2][3] - 30.0).abs() < 1e-6);
1280        assert!((hdr.affine[3][3] - 1.0).abs() < 1e-6);
1281
1282        // voxel_to_world: voxel [1, 2, 3] → [2*1+10, 2*2+20, 3*3+30] = [12, 24, 39]
1283        let world = hdr.voxel_to_world([1.0, 2.0, 3.0]);
1284        assert!((world[0] - 12.0).abs() < 1e-6);
1285        assert!((world[1] - 24.0).abs() < 1e-6);
1286        assert!((world[2] - 39.0).abs() < 1e-6);
1287
1288        std::fs::remove_dir_all(&dir).unwrap();
1289    }
1290}