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/// A loaded 4D volume: `(data, (nx, ny, nz, nt), (vsx, vsy, vsz), affine)`.
161pub type Nifti4d = (Vec<f64>, (usize, usize, usize, usize), (f64, f64, f64), [f64; 16]);
162
163/// Load a 4D NIfTI file from bytes (for multi-echo data)
164pub fn load_nifti_4d(bytes: &[u8]) -> Result<Nifti4d, String> {
165    let obj: InMemNiftiObject = if is_gzip(bytes) {
166        let cursor = Cursor::new(bytes);
167        let decoder = GzDecoder::new(cursor);
168        InMemNiftiObject::from_reader(decoder)
169            .map_err(|e| format!("Failed to read gzipped NIfTI: {}", e))?
170    } else {
171        let cursor = Cursor::new(bytes);
172        InMemNiftiObject::from_reader(cursor)
173            .map_err(|e| format!("Failed to read NIfTI: {}", e))?
174    };
175
176    let header = obj.header();
177    let dim = header.dim;
178    let _ndim = dim[0] as usize;
179
180    let pixdim = header.pixdim;
181    let vsx = pixdim[1] as f64;
182    let vsy = pixdim[2] as f64;
183    let vsz = pixdim[3] as f64;
184
185    let affine = get_affine(header);
186
187    // Convert volume to ndarray
188    let volume = obj.into_volume();
189    let array: Array<f64, _> = volume.into_ndarray()
190        .map_err(|e| format!("Failed to convert to ndarray: {}", e))?;
191
192    let shape = array.shape();
193
194    // Use actual array shape for dimensions
195    let (dim0, dim1, dim2) = (shape[0], shape[1], shape[2]);
196    let dim3 = if shape.len() >= 4 { shape[3] } else { 1 };
197
198    // Extract data in Fortran order (x varies fastest) to match NIfTI convention
199    // For 4D: index = x + y*nx + z*nx*ny + t*nx*ny*nz
200    let mut data = Vec::with_capacity(dim0 * dim1 * dim2 * dim3);
201
202    if shape.len() == 3 {
203        // 3D array
204        for k in 0..dim2 {
205            for j in 0..dim1 {
206                for i in 0..dim0 {
207                    data.push(array[[i, j, k]]);
208                }
209            }
210        }
211    } else if shape.len() >= 4 {
212        // 4D array - each volume in Fortran order
213        for t in 0..dim3 {
214            for k in 0..dim2 {
215                for j in 0..dim1 {
216                    for i in 0..dim0 {
217                        data.push(array[[i, j, k, t]]);
218                    }
219                }
220            }
221        }
222    }
223
224    // Return dimensions matching actual array shape
225    Ok((data, (dim0, dim1, dim2, dim3), (vsx, vsy, vsz), affine))
226}
227
228/// Get affine transformation matrix from header
229fn get_affine(header: &NiftiHeader) -> [f64; 16] {
230    // The precedence is the spec's, and nibabel's and nifticlib's: sform, then qform, then the
231    // bare voxel scaling. Files this crate writes carry both and they agree by construction (see
232    // [`qform_for`]), but plenty of converters write only a qform, and falling straight through to
233    // the voxel scaling there discards the orientation — a sagittal acquisition would come back
234    // looking axial.
235    if header.sform_code > 0 {
236        let s = &header.srow_x;
237        let t = &header.srow_y;
238        let u = &header.srow_z;
239        [
240            s[0] as f64, s[1] as f64, s[2] as f64, s[3] as f64,
241            t[0] as f64, t[1] as f64, t[2] as f64, t[3] as f64,
242            u[0] as f64, u[1] as f64, u[2] as f64, u[3] as f64,
243            0.0, 0.0, 0.0, 1.0,
244        ]
245    } else if header.qform_code > 0 {
246        let q = QForm {
247            quatern: [header.quatern_b as f64, header.quatern_c as f64, header.quatern_d as f64],
248            offset: [header.quatern_x as f64, header.quatern_y as f64, header.quatern_z as f64],
249            // pixdim[0] is +-1; the spec says to read anything that is not -1 as +1.
250            qfac: if header.pixdim[0] < 0.0 { -1.0 } else { 1.0 },
251        };
252        let pixdim = (
253            header.pixdim[1] as f64,
254            header.pixdim[2] as f64,
255            header.pixdim[3] as f64,
256        );
257        affine_from_quatern(&q, pixdim)
258    } else {
259        // Fall back to identity with voxel scaling
260        let vsx = header.pixdim[1] as f64;
261        let vsy = header.pixdim[2] as f64;
262        let vsz = header.pixdim[3] as f64;
263        [
264            vsx, 0.0, 0.0, 0.0,
265            0.0, vsy, 0.0, 0.0,
266            0.0, 0.0, vsz, 0.0,
267            0.0, 0.0, 0.0, 1.0,
268        ]
269    }
270}
271
272/// NIfTI-1's quaternion ("qform") description of a voxel→world transform — method 2 in the spec.
273///
274/// A reader rebuilds the matrix from the quaternion, the voxel sizes in `pixdim[1..=3]`, and
275/// `qfac`. The quaternion's scalar part is not stored: it is implied by the other three
276/// components, which is why only `b`, `c`, `d` appear here.
277#[derive(Debug, Clone, Copy, PartialEq)]
278struct QForm {
279    /// `quatern_b`, `quatern_c`, `quatern_d`.
280    quatern: [f64; 3],
281    /// `qoffset_x`, `qoffset_y`, `qoffset_z` — the affine's translation, verbatim.
282    offset: [f64; 3],
283    /// `pixdim[0]`: `-1` for a left-handed (negative determinant) transform, `+1` otherwise.
284    ///
285    /// A quaternion only ever describes a rotation, so a mirrored acquisition cannot be expressed
286    /// by one. This is how the spec carries the reflection separately: the reader negates the
287    /// third column after rotating. `0` is not a legal value, and the spec says to read it as
288    /// `+1`.
289    qfac: f64,
290}
291
292/// Decompose a row-major voxel→world affine into its quaternion form, following the NIfTI-1
293/// spec's `nifti_mat44_to_quatern`.
294///
295/// Returns `None` for an affine with a zero-length column, which describes no orientation at all.
296///
297/// The spec's reference implementation runs a polar decomposition at this point, to pick the
298/// nearest orthogonal matrix when the caller's columns are not perpendicular. This deliberately
299/// does not. A sheared affine has no quaternion, and approximating one is exactly how a qform
300/// comes to disagree with its sform — the thing [`qform_for`] exists to prevent. Shear is caught
301/// there instead, by checking the reconstruction, and the file then goes out with no qform rather
302/// than a wrong one. For an orthogonal affine — every affine a scanner produces, and every affine
303/// this crate builds — the polar step is the identity, so this agrees with the reference to
304/// round-off.
305fn quatern_from_affine(affine: &[f64; 16]) -> Option<QForm> {
306    let mut r = [
307        [affine[0], affine[1], affine[2]],
308        [affine[4], affine[5], affine[6]],
309        [affine[8], affine[9], affine[10]],
310    ];
311    let offset = [affine[3], affine[7], affine[11]];
312
313    // The column norms are the voxel sizes; dividing them out leaves the rotation. They are
314    // carried by `pixdim`, not by the quaternion.
315    for c in 0..3 {
316        let n = (r[0][c] * r[0][c] + r[1][c] * r[1][c] + r[2][c] * r[2][c]).sqrt();
317        if n < 1e-12 {
318            return None;
319        }
320        for row in r.iter_mut() {
321            row[c] /= n;
322        }
323    }
324
325    let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
326        - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
327        + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
328    let qfac = if det < 0.0 { -1.0 } else { 1.0 };
329    if qfac < 0.0 {
330        // Flip the third column so what is left is a proper rotation; `qfac` tells the reader to
331        // put the reflection back.
332        for row in r.iter_mut() {
333            row[2] = -row[2];
334        }
335    }
336
337    // Trace formula, branching on whichever component is largest so the division stays well
338    // conditioned. `a` is the scalar part — computed only to normalise the sign convention
339    // (the spec keeps `a >= 0`), then discarded, since readers recover it from b, c, d.
340    let trace = r[0][0] + r[1][1] + r[2][2] + 1.0;
341    let (b, c, d) = if trace > 0.5 {
342        let a = 0.5 * trace.sqrt();
343        (
344            0.25 * (r[2][1] - r[1][2]) / a,
345            0.25 * (r[0][2] - r[2][0]) / a,
346            0.25 * (r[1][0] - r[0][1]) / a,
347        )
348    } else {
349        // 4b², 4c², 4d². For a rotation these sum to `4 - trace` ≥ 3.5, so at least one exceeds 1.
350        let xd = 1.0 + r[0][0] - (r[1][1] + r[2][2]);
351        let yd = 1.0 + r[1][1] - (r[0][0] + r[2][2]);
352        let zd = 1.0 + r[2][2] - (r[0][0] + r[1][1]);
353        let (a, b, c, d) = if xd > 1.0 {
354            let b = 0.5 * xd.sqrt();
355            (0.25 * (r[2][1] - r[1][2]) / b, b, 0.25 * (r[0][1] + r[1][0]) / b,
356             0.25 * (r[0][2] + r[2][0]) / b)
357        } else if yd > 1.0 {
358            let c = 0.5 * yd.sqrt();
359            (0.25 * (r[0][2] - r[2][0]) / c, 0.25 * (r[0][1] + r[1][0]) / c, c,
360             0.25 * (r[1][2] + r[2][1]) / c)
361        } else {
362            let d = 0.5 * zd.sqrt();
363            (0.25 * (r[1][0] - r[0][1]) / d, 0.25 * (r[0][2] + r[2][0]) / d,
364             0.25 * (r[1][2] + r[2][1]) / d, d)
365        };
366        if a < 0.0 { (-b, -c, -d) } else { (b, c, d) }
367    };
368
369    Some(QForm { quatern: [b, c, d], offset, qfac })
370}
371
372/// Rebuild the voxel→world affine a reader derives from a qform — the spec's
373/// `nifti_quatern_to_mat44`. `pixdim` is `pixdim[1..=3]` as it appears in the header.
374fn affine_from_quatern(q: &QForm, pixdim: (f64, f64, f64)) -> [f64; 16] {
375    let [mut b, mut c, mut d] = q.quatern;
376    let mut a = 1.0 - (b * b + c * c + d * d);
377    if a < 1e-7 {
378        // A half-turn, where the scalar part vanishes. Renormalise the vector part rather than
379        // taking the square root of a small negative number.
380        let n = (b * b + c * c + d * d).sqrt();
381        b /= n;
382        c /= n;
383        d /= n;
384        a = 0.0;
385    } else {
386        a = a.sqrt();
387    }
388
389    let xd = if pixdim.0 > 0.0 { pixdim.0 } else { 1.0 };
390    let yd = if pixdim.1 > 0.0 { pixdim.1 } else { 1.0 };
391    let zd = if pixdim.2 > 0.0 { pixdim.2 } else { 1.0 };
392    let zd = if q.qfac < 0.0 { -zd } else { zd };
393
394    [
395        (a * a + b * b - c * c - d * d) * xd, 2.0 * (b * c - a * d) * yd, 2.0 * (b * d + a * c) * zd, q.offset[0],
396        2.0 * (b * c + a * d) * xd, (a * a + c * c - b * b - d * d) * yd, 2.0 * (c * d - a * b) * zd, q.offset[1],
397        2.0 * (b * d - a * c) * xd, 2.0 * (c * d + a * b) * yd, (a * a + d * d - c * c - b * b) * zd, q.offset[2],
398        0.0, 0.0, 0.0, 1.0,
399    ]
400}
401
402/// The qform to write alongside an sform, or `None` when no quaternion reproduces the affine.
403///
404/// Every file this crate writes carries an sform, and for a long time that was all it carried:
405/// `qform_code` and the quaternion fields were left at zero. That is spec-legal — a reader is
406/// meant to fall back to method 1, the bare `pixdim` scaling — but it is not what readers
407/// actually show. `fslhd` and friends print a `qto_xyz` derived from the all-zero quaternion
408/// regardless, so every output looked as though it had been silently reoriented to a canonical
409/// axial frame with no translation, disagreeing with the sform beside it (QSMxT#240). Writing a
410/// real qform is what those tools want, so that is what we do.
411///
412/// `pixdim` has to be the voxel size *as written to the header*, because method 2 scales the
413/// quaternion's rotation by it. An affine whose column norms disagree with `pixdim` therefore
414/// cannot be expressed as a qform even when its rotation is exact, and that is one of two ways
415/// the attempt can fail; shear is the other. Both are caught the same way — rebuild the affine a
416/// reader would derive and compare — so a qform is only ever advertised when it is the sform.
417fn qform_for(affine: &[f64; 16], pixdim: (f64, f64, f64)) -> Option<QForm> {
418    let q = quatern_from_affine(affine)?;
419    let rebuilt = affine_from_quatern(&q, pixdim);
420
421    // Tolerance scales with the voxel size, the unit the 3×3 is in. Comfortably above the ~1e-7
422    // relative noise an affine picks up from being stored as f32 in the file it was read from,
423    // and far below any shear worth preserving. The translation is copied into the qoffset
424    // verbatim, so it cannot disagree and is not checked.
425    let scale = (0..3)
426        .map(|c| (rebuilt[c] * rebuilt[c] + rebuilt[4 + c] * rebuilt[4 + c] + rebuilt[8 + c] * rebuilt[8 + c]).sqrt())
427        .fold(0.0f64, f64::max);
428    let tol = 1e-4 * scale.max(1e-6);
429    for row in 0..3 {
430        for col in 0..3 {
431            if (rebuilt[4 * row + col] - affine[4 * row + col]).abs() > tol {
432                return None;
433            }
434        }
435    }
436    Some(q)
437}
438
439/// `xyzt_units` for every file this crate writes: `NIFTI_UNITS_MM | NIFTI_UNITS_SEC`.
440///
441/// The writers take no input header to carry units over from, and everything in the crate works
442/// in millimetres, so the spatial unit is always mm. The time bits say seconds; for a 4D file
443/// whose fourth axis is not time (echoes, tensor components) that is still the conventional
444/// value, and `pixdim[4]` is written as 1. Leaving the field at 0 ("unknown") fails the BIDS
445/// validator's `NIFTI_UNIT` check and leaves readers guessing what the voxel sizes mean.
446pub const NIFTI_XYZT_UNITS: u8 = 2 | 8;
447
448/// Encode `volumes` (each `nx*ny*nz` values, x fastest) as an uncompressed NIfTI-1 file, 3D
449/// (`four_d = false`, exactly one volume) or 4D (`dim[4] = volumes.len()`). Both writers share this, so they cannot disagree
450/// on the header: float32 data, unit scaling, sform plus a matching qform where one exists, and
451/// [`NIFTI_XYZT_UNITS`].
452fn encode_nifti(
453    volumes: &[&[f64]],
454    dims: (usize, usize, usize),
455    voxel_size: (f64, f64, f64),
456    affine: &[f64; 16],
457    four_d: bool,
458) -> Result<Vec<u8>, String> {
459    use std::io::Write;
460
461    let (nx, ny, nz) = dims;
462    let (vsx, vsy, vsz) = voxel_size;
463    let nt = volumes.len();
464    if nt == 0 {
465        return Err("cannot write a NIfTI with no volumes".to_string());
466    }
467    debug_assert!(four_d || nt == 1, "a 3D file holds exactly one volume");
468
469    // The header states a matrix size and every reader trusts it, so writing a buffer that does
470    // not match produces a file that opens fine and then fails — or shows nothing — the moment
471    // something reads the voxels (QSMxT#211). A wrong length is always a caller bug; the only
472    // question is whether it surfaces here or as a damaged file someone opens later.
473    let expected = nx * ny * nz;
474    for (t, v) in volumes.iter().enumerate() {
475        if v.len() != expected {
476            let which = if !four_d { String::new() } else { format!(" (volume {t})") };
477            return Err(format!(
478                "cannot write a {}x{}x{} NIfTI ({} voxels) from {} values{}",
479                nx, ny, nz, expected, v.len(), which,
480            ));
481        }
482    }
483    // `dim` is i16 in NIfTI-1; a larger extent would wrap silently.
484    for (name, n) in [("nx", nx), ("ny", ny), ("nz", nz), ("volumes", nt)] {
485        if n > i16::MAX as usize {
486            return Err(format!("{name} = {n} exceeds NIfTI-1's limit of {}", i16::MAX));
487        }
488    }
489
490    // Create NIfTI-1 header (348 bytes)
491    let mut header = [0u8; 348];
492
493    // sizeof_hdr = 348
494    header[0..4].copy_from_slice(&348i32.to_le_bytes());
495
496    // dim[0..7]
497    let ndim: i16 = if four_d { 4 } else { 3 };
498    let dim: [i16; 8] = [ndim, nx as i16, ny as i16, nz as i16, nt as i16, 1, 1, 1];
499    for (i, &d) in dim.iter().enumerate() {
500        let offset = 40 + i * 2;
501        header[offset..offset + 2].copy_from_slice(&d.to_le_bytes());
502    }
503
504    // datatype = 16 (FLOAT32)
505    header[70..72].copy_from_slice(&16i16.to_le_bytes());
506
507    // bitpix = 32
508    header[72..74].copy_from_slice(&32i16.to_le_bytes());
509
510    // The quaternion has to be settled before `pixdim` is written, because `pixdim[0]` carries
511    // its handedness: 0, which a zeroed buffer would leave there, is not a legal value.
512    let qform = qform_for(affine, voxel_size);
513
514    // pixdim[0..7]. pixdim[0] is qfac — meaningless without a qform, where +1 is the spec's
515    // reading of anything that is not -1.
516    let qfac = qform.map_or(1.0, |q| q.qfac);
517    let pixdim: [f32; 8] = [qfac as f32, vsx as f32, vsy as f32, vsz as f32, 1.0, 1.0, 1.0, 1.0];
518    for (i, &p) in pixdim.iter().enumerate() {
519        let offset = 76 + i * 4;
520        header[offset..offset + 4].copy_from_slice(&p.to_le_bytes());
521    }
522
523    // vox_offset = 352 (header + 4 bytes extension)
524    header[108..112].copy_from_slice(&352.0f32.to_le_bytes());
525
526    // scl_slope = 1.0
527    header[112..116].copy_from_slice(&1.0f32.to_le_bytes());
528
529    // scl_inter = 0.0
530    header[116..120].copy_from_slice(&0.0f32.to_le_bytes());
531
532    // xyzt_units (a single byte at offset 123)
533    header[123] = NIFTI_XYZT_UNITS;
534
535    // qform_code / sform_code = 1 (scanner anat). Both describe the same transform, so they get
536    // the same code; a qform we could not make agree with the sform is left absent (code 0),
537    // which sends readers to the sform rather than to a transform that contradicts it.
538    if let Some(q) = qform {
539        header[252..254].copy_from_slice(&1i16.to_le_bytes());
540        for (i, &v) in q.quatern.iter().chain(q.offset.iter()).enumerate() {
541            let offset = 256 + i * 4;
542            header[offset..offset + 4].copy_from_slice(&(v as f32).to_le_bytes());
543        }
544    }
545    header[254..256].copy_from_slice(&1i16.to_le_bytes());
546
547    // srow_x, srow_y, srow_z
548    for i in 0..4 {
549        let offset = 280 + i * 4;
550        header[offset..offset + 4].copy_from_slice(&(affine[i] as f32).to_le_bytes());
551    }
552    for i in 0..4 {
553        let offset = 296 + i * 4;
554        header[offset..offset + 4].copy_from_slice(&(affine[4 + i] as f32).to_le_bytes());
555    }
556    for i in 0..4 {
557        let offset = 312 + i * 4;
558        header[offset..offset + 4].copy_from_slice(&(affine[8 + i] as f32).to_le_bytes());
559    }
560
561    // magic = "n+1\0" for NIfTI-1 single file
562    header[344..348].copy_from_slice(b"n+1\0");
563
564    // Build output buffer
565    let mut buffer = Vec::with_capacity(352 + nt * expected * 4);
566
567    // Write header
568    buffer.write_all(&header).map_err(|e| format!("Write header failed: {}", e))?;
569
570    // Write extension (4 bytes, all zeros = no extension)
571    buffer.write_all(&[0u8; 4]).map_err(|e| format!("Write extension failed: {}", e))?;
572
573    // Write data as float32, volume after volume
574    for v in volumes {
575        for &val in v.iter() {
576            buffer.write_all(&(val as f32).to_le_bytes())
577                .map_err(|e| format!("Write data failed: {}", e))?;
578        }
579    }
580
581    Ok(buffer)
582}
583
584fn gzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
585    use flate2::write::GzEncoder;
586    use flate2::Compression;
587    use std::io::Write;
588
589    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
590    encoder.write_all(bytes)
591        .map_err(|e| format!("Gzip compression failed: {}", e))?;
592    encoder.finish()
593        .map_err(|e| format!("Gzip finish failed: {}", e))
594}
595
596/// Save data as NIfTI bytes
597///
598/// Writes an uncompressed .nii file
599pub fn save_nifti(
600    data: &[f64],
601    dims: (usize, usize, usize),
602    voxel_size: (f64, f64, f64),
603    affine: &[f64; 16],
604) -> Result<Vec<u8>, String> {
605    encode_nifti(&[data], dims, voxel_size, affine, false)
606}
607
608/// Save data as gzipped NIfTI bytes (.nii.gz)
609pub fn save_nifti_gz(
610    data: &[f64],
611    dims: (usize, usize, usize),
612    voxel_size: (f64, f64, f64),
613    affine: &[f64; 16],
614) -> Result<Vec<u8>, String> {
615    gzip(&save_nifti(data, dims, voxel_size, affine)?)
616}
617
618/// Save several 3D volumes on one grid as a single uncompressed 4D NIfTI file.
619///
620/// Each of `volumes` holds `nx*ny*nz` values in Fortran order (x fastest) and becomes one entry
621/// along the fourth axis, in order. The header is the 3D writer's — same datatype, scaling,
622/// sform/qform and units — with `dim[0] = 4`, `dim[4] = volumes.len()` and `pixdim[4] = 1`, even
623/// for a single volume. Reading the result back with [`load_nifti_4d`] gives the volumes
624/// concatenated in the same order.
625pub fn save_nifti_4d(
626    volumes: &[&[f64]],
627    dims: (usize, usize, usize),
628    voxel_size: (f64, f64, f64),
629    affine: &[f64; 16],
630) -> Result<Vec<u8>, String> {
631    encode_nifti(volumes, dims, voxel_size, affine, true)
632}
633
634/// [`save_nifti_4d`], gzipped (.nii.gz).
635pub fn save_nifti_4d_gz(
636    volumes: &[&[f64]],
637    dims: (usize, usize, usize),
638    voxel_size: (f64, f64, f64),
639    affine: &[f64; 16],
640) -> Result<Vec<u8>, String> {
641    gzip(&save_nifti_4d(volumes, dims, voxel_size, affine)?)
642}
643
644/// Read only the dimensions from a NIfTI file header without loading volume data.
645///
646/// Returns (nx, ny, nz). Much faster and uses negligible memory compared to
647/// `read_nifti_file` since it only reads and parses the 348-byte header.
648pub fn read_nifti_dims(path: &std::path::Path) -> Result<(usize, usize, usize), String> {
649    use std::io::Read;
650
651    let mut file = std::fs::File::open(path)
652        .map_err(|e| format!("Failed to open '{}': {}", path.display(), e))?;
653
654    // Read enough bytes to detect gzip and parse header
655    let mut header_bytes = [0u8; 348];
656
657    let path_str = path.to_string_lossy();
658    if path_str.ends_with(".gz") {
659        // Decompress just the header
660        let mut decoder = GzDecoder::new(file);
661        decoder.read_exact(&mut header_bytes)
662            .map_err(|e| format!("Failed to read gzipped NIfTI header '{}': {}", path.display(), e))?;
663    } else {
664        file.read_exact(&mut header_bytes)
665            .map_err(|e| format!("Failed to read NIfTI header '{}': {}", path.display(), e))?;
666    }
667
668    // Determine endianness from sizeof_hdr (bytes 0-3, should be 348)
669    let sizeof_hdr_le = i32::from_le_bytes([header_bytes[0], header_bytes[1], header_bytes[2], header_bytes[3]]);
670    let is_le = sizeof_hdr_le == 348;
671
672    if !is_le {
673        let sizeof_hdr_be = i32::from_be_bytes([header_bytes[0], header_bytes[1], header_bytes[2], header_bytes[3]]);
674        if sizeof_hdr_be != 348 {
675            return Err(format!(
676                "Invalid NIfTI header in '{}': sizeof_hdr={} (LE) / {} (BE), expected 348",
677                path.display(), sizeof_hdr_le, sizeof_hdr_be
678            ));
679        }
680    }
681
682    // dim[0..7] at offset 40, each i16
683    let read_i16 = |offset: usize| -> i16 {
684        if is_le {
685            i16::from_le_bytes([header_bytes[offset], header_bytes[offset + 1]])
686        } else {
687            i16::from_be_bytes([header_bytes[offset], header_bytes[offset + 1]])
688        }
689    };
690
691    let ndim = read_i16(40);
692    if ndim < 3 {
693        return Err(format!("Expected at least 3D volume in '{}', got {}D", path.display(), ndim));
694    }
695
696    let nx = read_i16(42) as usize;
697    let ny = read_i16(44) as usize;
698    let nz = read_i16(46) as usize;
699
700    Ok((nx, ny, nz))
701}
702
703/// The image geometry ITK (ITK 5.3, as in ANTs 2.4.3) reads from a NIfTI file's header
704/// ([`ItkGeometry::from_nifti_header`](crate::utils::ItkGeometry::from_nifti_header)), for ports
705/// whose output depends on it ([`n4_bias_correction`](crate::utils::n4_bias_correction)).
706/// Supports .nii and .nii.gz bytes.
707pub fn load_itk_geometry(bytes: &[u8]) -> Result<crate::utils::ItkGeometry, String> {
708    let header = if is_gzip(bytes) {
709        NiftiHeader::from_reader(GzDecoder::new(Cursor::new(bytes)))
710    } else {
711        NiftiHeader::from_reader(Cursor::new(bytes))
712    }
713    .map_err(|e| format!("Failed to read NIfTI header: {e} ({})", get_header_info(bytes)))?;
714    crate::utils::ItkGeometry::from_nifti_header(&header).map_err(|e| e.to_string())
715}
716
717/// [`load_itk_geometry`] of a file.
718pub fn read_itk_geometry_file(path: &std::path::Path) -> Result<crate::utils::ItkGeometry, String> {
719    let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
720    load_itk_geometry(&bytes)
721}
722
723/// Read a NIfTI file from a filesystem path
724///
725/// Supports both .nii and .nii.gz files.
726pub fn read_nifti_file(path: &std::path::Path) -> Result<NiftiData, String> {
727    let bytes = std::fs::read(path)
728        .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
729    load_nifti(&bytes)
730}
731
732/// Save NIfTI data to a file
733///
734/// If the path ends with .nii.gz, the file is gzip compressed.
735/// Otherwise it is saved as uncompressed .nii.
736pub fn save_nifti_to_file(
737    path: &std::path::Path,
738    data: &[f64],
739    dims: (usize, usize, usize),
740    voxel_size: (f64, f64, f64),
741    affine: &[f64; 16],
742) -> Result<(), String> {
743    let path_str = path.to_string_lossy();
744    let bytes = if path_str.ends_with(".nii.gz") {
745        save_nifti_gz(data, dims, voxel_size, affine)
746    } else {
747        save_nifti(data, dims, voxel_size, affine)
748    }
749    .map_err(|e| format!("{}: {}", path.display(), e))?;
750
751    std::fs::write(path, &bytes)
752        .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))
753}
754
755/// Save several 3D volumes as one 4D NIfTI file (see [`save_nifti_4d`]).
756///
757/// If the path ends with .nii.gz, the file is gzip compressed.
758/// Otherwise it is saved as uncompressed .nii.
759pub fn save_nifti_4d_to_file(
760    path: &std::path::Path,
761    volumes: &[&[f64]],
762    dims: (usize, usize, usize),
763    voxel_size: (f64, f64, f64),
764    affine: &[f64; 16],
765) -> Result<(), String> {
766    let path_str = path.to_string_lossy();
767    let bytes = if path_str.ends_with(".nii.gz") {
768        save_nifti_4d_gz(volumes, dims, voxel_size, affine)
769    } else {
770        save_nifti_4d(volumes, dims, voxel_size, affine)
771    }
772    .map_err(|e| format!("{}: {}", path.display(), e))?;
773
774    std::fs::write(path, &bytes)
775        .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn test_affine_identity() {
784        let mut header = NiftiHeader::default();
785        header.pixdim[1] = 1.0;
786        header.pixdim[2] = 2.0;
787        header.pixdim[3] = 3.0;
788        header.sform_code = 0;
789
790        let affine = get_affine(&header);
791        assert_eq!(affine[0], 1.0);
792        assert_eq!(affine[5], 2.0);
793        assert_eq!(affine[10], 3.0);
794    }
795
796    #[test]
797    fn test_gzip_detection() {
798        assert!(is_gzip(&[0x1f, 0x8b, 0x00]));
799        assert!(!is_gzip(&[0x00, 0x00, 0x00]));
800        assert!(!is_gzip(&[0x1f])); // Too short
801    }
802
803    /// A buffer that does not match the dimensions must never reach a file: the header would
804    /// promise voxels the file does not hold, and readers only discover that on `get_fdata()`
805    /// (QSMxT#211).
806    #[test]
807    fn test_save_nifti_rejects_a_mismatched_payload() {
808        let dims = (2, 2, 2);
809        let voxel_size = (1.0, 1.0, 1.0);
810        let affine = [
811            1.0, 0.0, 0.0, 0.0,
812            0.0, 1.0, 0.0, 0.0,
813            0.0, 0.0, 1.0, 0.0,
814            0.0, 0.0, 0.0, 1.0,
815        ];
816
817        let short = save_nifti(&[0.0; 6], dims, voxel_size, &affine).unwrap_err();
818        assert!(short.contains("2x2x2"), "{}", short);
819        assert!(short.contains("8 voxels"), "{}", short);
820        assert!(short.contains("6 values"), "{}", short);
821
822        assert!(save_nifti(&[0.0; 9], dims, voxel_size, &affine).is_err());
823        // Both the gzip and the file writers route through it.
824        assert!(save_nifti_gz(&[0.0; 6], dims, voxel_size, &affine).is_err());
825    }
826
827    #[test]
828    fn test_save_nifti_to_file_names_the_file_it_refused() {
829        let dir = std::env::temp_dir().join("qsm-core-mismatch-test");
830        std::fs::create_dir_all(&dir).unwrap();
831        let affine = [
832            1.0, 0.0, 0.0, 0.0,
833            0.0, 1.0, 0.0, 0.0,
834            0.0, 0.0, 1.0, 0.0,
835            0.0, 0.0, 0.0, 1.0,
836        ];
837        for name in ["bad.nii", "bad.nii.gz"] {
838            let path = dir.join(name);
839            let err = save_nifti_to_file(&path, &[0.0; 6], (2, 2, 2), (1.0, 1.0, 1.0), &affine)
840                .unwrap_err();
841            assert!(err.contains(name), "{}", err);
842            assert!(!path.exists(), "nothing may be written for a mismatch");
843        }
844        let _ = std::fs::remove_dir_all(&dir);
845    }
846
847    #[test]
848    fn test_save_nifti_header() {
849        let data = vec![0.0; 8]; // 2x2x2
850        let dims = (2, 2, 2);
851        let voxel_size = (1.0, 1.0, 1.0);
852        let affine = [
853            1.0, 0.0, 0.0, 0.0,
854            0.0, 1.0, 0.0, 0.0,
855            0.0, 0.0, 1.0, 0.0,
856            0.0, 0.0, 0.0, 1.0,
857        ];
858
859        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
860
861        // Check header size + extension + data
862        assert_eq!(bytes.len(), 352 + 8 * 4); // 348 header + 4 ext + 8 floats
863
864        // Check magic
865        assert_eq!(&bytes[344..348], b"n+1\0");
866
867        // Check sizeof_hdr
868        let sizeof_hdr = i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
869        assert_eq!(sizeof_hdr, 348);
870    }
871
872    #[test]
873    fn test_save_and_read_nifti_roundtrip() {
874        let dims = (4, 4, 4);
875        let n = dims.0 * dims.1 * dims.2;
876        let voxel_size = (1.0, 2.0, 3.0);
877        let affine = [
878            1.0, 0.0, 0.0, 10.0,
879            0.0, 2.0, 0.0, 20.0,
880            0.0, 0.0, 3.0, 30.0,
881            0.0, 0.0, 0.0, 1.0,
882        ];
883
884        // Create test data with known values
885        let data: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
886
887        // Save to temp file
888        let tmp_dir = std::env::temp_dir();
889        let tmp_path = tmp_dir.join("test_nifti_roundtrip.nii");
890
891        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
892
893        // Read back
894        let loaded = read_nifti_file(&tmp_path).unwrap();
895
896        // Verify dimensions
897        assert_eq!(loaded.dims, dims, "Dimensions should match");
898
899        // Verify voxel sizes
900        assert!((loaded.voxel_size.0 - voxel_size.0).abs() < 1e-5, "Voxel size X mismatch");
901        assert!((loaded.voxel_size.1 - voxel_size.1).abs() < 1e-5, "Voxel size Y mismatch");
902        assert!((loaded.voxel_size.2 - voxel_size.2).abs() < 1e-5, "Voxel size Z mismatch");
903
904        // Verify data values (saved as f32, so some precision loss expected)
905        assert_eq!(loaded.data.len(), n, "Data length should match");
906        for i in 0..n {
907            assert!(
908                (loaded.data[i] - data[i]).abs() < 0.01,
909                "Data mismatch at index {}: expected {}, got {}",
910                i, data[i], loaded.data[i]
911            );
912        }
913
914        // Cleanup
915        std::fs::remove_file(&tmp_path).ok();
916    }
917
918    #[test]
919    fn test_save_and_read_nifti_f32() {
920        let dims = (4, 4, 4);
921        let n = dims.0 * dims.1 * dims.2;
922        let voxel_size = (1.5, 1.5, 1.5);
923        let affine = [
924            1.5, 0.0, 0.0, 0.0,
925            0.0, 1.5, 0.0, 0.0,
926            0.0, 0.0, 1.5, 0.0,
927            0.0, 0.0, 0.0, 1.0,
928        ];
929
930        // Create small f32-precision data
931        let data: Vec<f64> = (0..n).map(|i| (i as f32 * 0.1) as f64).collect();
932
933        let tmp_dir = std::env::temp_dir();
934        let tmp_path = tmp_dir.join("test_nifti_f32.nii");
935
936        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
937        let loaded = read_nifti_file(&tmp_path).unwrap();
938
939        assert_eq!(loaded.dims, dims);
940        assert_eq!(loaded.data.len(), n);
941
942        // f32 precision: data is saved as f32, so roundtrip should be exact for f32 values
943        for i in 0..n {
944            assert!(
945                (loaded.data[i] - data[i]).abs() < 1e-5,
946                "f32 data mismatch at index {}: expected {}, got {}",
947                i, data[i], loaded.data[i]
948            );
949        }
950
951        std::fs::remove_file(&tmp_path).ok();
952    }
953
954    #[test]
955    fn test_save_nifti_gzip() {
956        let dims = (4, 4, 4);
957        let n = dims.0 * dims.1 * dims.2;
958        let voxel_size = (1.0, 1.0, 1.0);
959        let affine = [
960            1.0, 0.0, 0.0, 0.0,
961            0.0, 1.0, 0.0, 0.0,
962            0.0, 0.0, 1.0, 0.0,
963            0.0, 0.0, 0.0, 1.0,
964        ];
965
966        let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
967
968        let tmp_dir = std::env::temp_dir();
969        let tmp_path = tmp_dir.join("test_nifti_gz.nii.gz");
970
971        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
972
973        // Verify the file is actually gzip compressed
974        let bytes = std::fs::read(&tmp_path).unwrap();
975        assert!(is_gzip(&bytes), "File should be gzip compressed");
976
977        // Read it back
978        let loaded = read_nifti_file(&tmp_path).unwrap();
979        assert_eq!(loaded.dims, dims);
980        assert_eq!(loaded.data.len(), n);
981
982        for i in 0..n {
983            assert!(
984                (loaded.data[i] - data[i]).abs() < 0.01,
985                "Gzip roundtrip mismatch at index {}: expected {}, got {}",
986                i, data[i], loaded.data[i]
987            );
988        }
989
990        std::fs::remove_file(&tmp_path).ok();
991    }
992
993    #[test]
994    fn test_load_nifti_invalid_bytes() {
995        // Invalid bytes should return an error
996        let result = load_nifti(&[0u8; 10]);
997        assert!(result.is_err(), "Loading invalid bytes should error");
998    }
999
1000    #[test]
1001    fn test_load_nifti_invalid_gzip() {
1002        // Bytes that look like gzip but are corrupt
1003        let result = load_nifti(&[0x1f, 0x8b, 0x00, 0x00, 0x00]);
1004        assert!(result.is_err(), "Loading invalid gzip should error");
1005    }
1006
1007    #[test]
1008    fn test_get_header_info_small_file() {
1009        let info = get_header_info(&[0u8; 10]);
1010        assert!(info.contains("too small"), "Should report file too small");
1011    }
1012
1013    #[test]
1014    fn test_get_header_info_normal() {
1015        // Create a 348-byte mock header
1016        let mut bytes = vec![0u8; 348];
1017        // sizeof_hdr at offset 0
1018        bytes[0..4].copy_from_slice(&348i32.to_le_bytes());
1019        // magic at offset 344
1020        bytes[344..348].copy_from_slice(b"n+1\0");
1021        // datatype at offset 70
1022        bytes[70..72].copy_from_slice(&16i16.to_le_bytes());
1023
1024        let info = get_header_info(&bytes);
1025        assert!(info.contains("sizeof_hdr=348"), "Should contain sizeof_hdr");
1026        assert!(info.contains("datatype=16"), "Should contain datatype");
1027    }
1028
1029    #[test]
1030    fn test_affine_sform() {
1031        // Test with sform_code > 0
1032        let header = NiftiHeader {
1033            sform_code: 1,
1034            srow_x: [1.0, 0.0, 0.0, 10.0],
1035            srow_y: [0.0, 2.0, 0.0, 20.0],
1036            srow_z: [0.0, 0.0, 3.0, 30.0],
1037            ..Default::default()
1038        };
1039
1040        let affine = get_affine(&header);
1041        assert_eq!(affine[0], 1.0);
1042        assert_eq!(affine[3], 10.0);
1043        assert_eq!(affine[5], 2.0);
1044        assert_eq!(affine[7], 20.0);
1045        assert_eq!(affine[10], 3.0);
1046        assert_eq!(affine[11], 30.0);
1047        assert_eq!(affine[15], 1.0);
1048    }
1049
1050    /// Niklas's own affine from QSMxT/QSMxT#240, as `fslhd` printed its `sto_xyz`. An oblique
1051    /// sagittal acquisition — and, as it happens, a left-handed one, so it only round-trips if
1052    /// `qfac` is handled.
1053    const ISSUE_240_AFFINE: [f64; 16] = [
1054        0.0, 0.0, 0.600000, -88.492340,
1055        0.520448, 0.300481, 0.0, -151.538284,
1056        0.300481, -0.520448, 0.0, 53.045219,
1057        0.0, 0.0, 0.0, 1.0,
1058    ];
1059    const ISSUE_240_VOXEL: (f64, f64, f64) = (0.600962, 0.600962, 0.6);
1060
1061    /// The qform a reader finds in written header bytes: `(qform_code, qto_xyz)`, with the
1062    /// transform rebuilt the way the spec says to, from the stored quaternion, `pixdim` and
1063    /// `qfac`. Deliberately goes through the bytes rather than through [`qform_for`], so a header
1064    /// written to the wrong offsets fails the test instead of passing it.
1065    fn read_qform(bytes: &[u8]) -> (i16, [f64; 16]) {
1066        let f32_at = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as f64;
1067        let code = i16::from_le_bytes([bytes[252], bytes[253]]);
1068        let q = QForm {
1069            quatern: [f32_at(256), f32_at(260), f32_at(264)],
1070            offset: [f32_at(268), f32_at(272), f32_at(276)],
1071            qfac: if f32_at(76) < 0.0 { -1.0 } else { 1.0 },
1072        };
1073        (code, affine_from_quatern(&q, (f32_at(80), f32_at(84), f32_at(88))))
1074    }
1075
1076    /// The sform a reader finds in written header bytes.
1077    fn read_sform(bytes: &[u8]) -> (i16, [f64; 16]) {
1078        let f32_at = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as f64;
1079        let mut a = [0.0f64; 16];
1080        for row in 0..3 {
1081            for col in 0..4 {
1082                a[4 * row + col] = f32_at(280 + 16 * row + 4 * col);
1083            }
1084        }
1085        a[15] = 1.0;
1086        (i16::from_le_bytes([bytes[254], bytes[255]]), a)
1087    }
1088
1089    fn max_abs_diff(a: &[f64; 16], b: &[f64; 16]) -> f64 {
1090        a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max)
1091    }
1092
1093    /// QSMxT/QSMxT#240: the writer set `sform_code` and the `srow_*` rows and left `qform_code`
1094    /// and the quaternion at zero, so `fslhd` showed every output with a `qto_xyz` of
1095    /// `diag(pixdim)` and no translation — a canonical axial frame that contradicted the sform
1096    /// beside it. Both transforms now describe the same geometry.
1097    #[test]
1098    fn qform_agrees_with_sform_on_an_oblique_affine() {
1099        let bytes = save_nifti(&[0.0; 24], (2, 3, 4), ISSUE_240_VOXEL, &ISSUE_240_AFFINE).unwrap();
1100        let (qcode, qto) = read_qform(&bytes);
1101        let (scode, sto) = read_sform(&bytes);
1102        assert_eq!((qcode, scode), (1, 1), "both transforms should be advertised");
1103        // f32 storage of a ~150 mm translation is the limiting term.
1104        assert!(max_abs_diff(&qto, &sto) < 1e-4, "qto_xyz {qto:?} vs sto_xyz {sto:?}");
1105        assert!(max_abs_diff(&sto, &ISSUE_240_AFFINE) < 1e-4, "sform drifted: {sto:?}");
1106    }
1107
1108    /// A rotation matrix with a negative determinant is a reflection, which no quaternion
1109    /// describes; `qfac = -1` is how NIfTI carries it. The affine in #240 is one, so getting this
1110    /// wrong would emit a mirrored transform — left and right swapped — rather than a visibly
1111    /// broken one.
1112    #[test]
1113    fn a_left_handed_affine_is_written_with_qfac_minus_one() {
1114        for (label, voxel, affine) in [
1115            ("issue 240", ISSUE_240_VOXEL, ISSUE_240_AFFINE),
1116            ("LAS", (0.8, 0.8, 3.0), [
1117                -0.8, 0.0, 0.0, 60.0,
1118                0.0, 0.8, 0.0, -70.0,
1119                0.0, 0.0, 3.0, -25.0,
1120                0.0, 0.0, 0.0, 1.0,
1121            ]),
1122        ] {
1123            let bytes = save_nifti(&[0.0; 24], (2, 3, 4), voxel, &affine).unwrap();
1124            let qfac = f32::from_le_bytes(bytes[76..80].try_into().unwrap());
1125            assert_eq!(qfac, -1.0, "{label}: pixdim[0] should flag the reflection");
1126            let (code, qto) = read_qform(&bytes);
1127            assert_eq!(code, 1, "{label}");
1128            assert!(max_abs_diff(&qto, &affine) < 1e-4, "{label}: {qto:?}");
1129
1130            // And the flag is load-bearing: read as right-handed, the same quaternion mirrors the
1131            // third column. Without it the file would look fine and be wrong.
1132            let f32_at = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as f64;
1133            let ignored = QForm {
1134                quatern: [f32_at(256), f32_at(260), f32_at(264)],
1135                offset: [f32_at(268), f32_at(272), f32_at(276)],
1136                qfac: 1.0,
1137            };
1138            let wrong = affine_from_quatern(&ignored, (f32_at(80), f32_at(84), f32_at(88)));
1139            assert!(max_abs_diff(&wrong, &affine) > 0.1, "{label}: qfac changed nothing");
1140        }
1141    }
1142
1143    /// `pixdim[0] = 0` is not a legal qfac, and a zeroed header buffer leaves it there. The
1144    /// writer always put 1.0, which was legal but only correct by luck.
1145    #[test]
1146    fn pixdim_zero_is_never_written_as_qfac() {
1147        for affine in [
1148            ISSUE_240_AFFINE,
1149            [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1150            // Sheared, so no qform is written and qfac falls back to +1.
1151            [1.0, 0.3, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1152        ] {
1153            let vs = voxel_sizes(&affine);
1154            let bytes = save_nifti(&[0.0; 24], (2, 3, 4), vs, &affine).unwrap();
1155            let qfac = f32::from_le_bytes(bytes[76..80].try_into().unwrap());
1156            assert!(qfac == 1.0 || qfac == -1.0, "pixdim[0] = {qfac}");
1157        }
1158    }
1159
1160    fn voxel_sizes(affine: &[f64; 16]) -> (f64, f64, f64) {
1161        let n = |c: usize| {
1162            (affine[c] * affine[c] + affine[4 + c] * affine[4 + c] + affine[8 + c] * affine[8 + c])
1163                .sqrt()
1164        };
1165        (n(0), n(1), n(2))
1166    }
1167    /// A labelled geometry case: `(description, voxel size, 4x4 affine)`.
1168    type AffineCase = (&'static str, (f64, f64, f64), [f64; 16]);
1169
1170
1171    /// A qform cannot express shear, and a qform that silently disagrees with the sform is the
1172    /// bug this whole thing is about. So an affine no quaternion reproduces is written with
1173    /// `qform_code = 0`, which is spec-legal and sends readers to the sform.
1174    #[test]
1175    fn an_affine_no_quaternion_describes_gets_no_qform() {
1176        let cases: [AffineCase; 3] = [
1177            ("sheared", (1.0, 1.0, 1.0), [
1178                1.0, 0.3, 0.0, 0.0,
1179                0.0, 1.0, 0.0, 0.0,
1180                0.0, 0.0, 1.0, 0.0,
1181                0.0, 0.0, 0.0, 1.0,
1182            ]),
1183            // pixdim is what scales the qform's rotation, so a voxel size that contradicts the
1184            // affine's column norms makes the two transforms differ even with an exact rotation.
1185            ("pixdim disagrees with the affine", (1.0, 1.0, 1.0), [
1186                1.0, 0.0, 0.0, 0.0,
1187                0.0, 1.0, 0.0, 0.0,
1188                0.0, 0.0, 2.0, 0.0,
1189                0.0, 0.0, 0.0, 1.0,
1190            ]),
1191            ("singular", (1.0, 1.0, 1.0), [
1192                1.0, 0.0, 0.0, 0.0,
1193                0.0, 1.0, 0.0, 0.0,
1194                0.0, 0.0, 0.0, 0.0,
1195                0.0, 0.0, 0.0, 1.0,
1196            ]),
1197        ];
1198        for (label, voxel, affine) in cases {
1199            let bytes = save_nifti(&[0.0; 24], (2, 3, 4), voxel, &affine).unwrap();
1200            assert_eq!(read_qform(&bytes).0, 0, "{label}: should advertise no qform");
1201            assert_eq!(read_sform(&bytes).0, 1, "{label}: the sform still carries the geometry");
1202            // The quaternion fields stay zero, so nothing is left for a reader to misinterpret.
1203            assert!(bytes[256..280].iter().all(|&b| b == 0), "{label}");
1204        }
1205    }
1206
1207    /// Both transforms survive a write/read cycle, and `get_affine` returns the same geometry
1208    /// whichever one a reader is left with.
1209    #[test]
1210    fn qform_and_sform_round_trip_to_the_same_affine() {
1211        let affines: [AffineCase; 4] = [
1212            ("axial anisotropic", (1.0, 1.0, 2.0), [
1213                1.0, 0.0, 0.0, -10.0,
1214                0.0, 1.0, 0.0, -20.0,
1215                0.0, 0.0, 2.0, -30.0,
1216                0.0, 0.0, 0.0, 1.0,
1217            ]),
1218            ("oblique (issue 240)", ISSUE_240_VOXEL, ISSUE_240_AFFINE),
1219            ("left-handed", (0.8, 0.8, 3.0), [
1220                -0.8, 0.0, 0.0, 60.0,
1221                0.0, 0.8, 0.0, -70.0,
1222                0.0, 0.0, 3.0, -25.0,
1223                0.0, 0.0, 0.0, 1.0,
1224            ]),
1225            // Sagittal: a cardinal permutation, where the quaternion is a quarter turn.
1226            ("sagittal anisotropic", (0.8, 0.8, 3.0), [
1227                0.0, 0.0, 3.0, -90.0,
1228                0.8, 0.0, 0.0, -110.0,
1229                0.0, 0.8, 0.0, -70.0,
1230                0.0, 0.0, 0.0, 1.0,
1231            ]),
1232        ];
1233        for (label, voxel, affine) in affines {
1234            let bytes = save_nifti(&[0.0; 24], (2, 3, 4), voxel, &affine).unwrap();
1235            let (qcode, qto) = read_qform(&bytes);
1236            let (scode, sto) = read_sform(&bytes);
1237            assert_eq!((qcode, scode), (1, 1), "{label}");
1238            assert!(max_abs_diff(&qto, &sto) < 1e-4, "{label}: qto {qto:?} sto {sto:?}");
1239
1240            // And through the loader, which is what the rest of the crate sees.
1241            let loaded = load_nifti(&bytes).unwrap();
1242            assert!(max_abs_diff(&loaded.affine, &affine) < 1e-4, "{label}: {:?}", loaded.affine);
1243        }
1244    }
1245
1246    /// Plenty of converters write a qform and no sform. Falling straight through to the bare
1247    /// `pixdim` scaling there throws the orientation away: a sagittal acquisition comes back
1248    /// looking axial, which is the same failure #240 reported, with the roles reversed.
1249    #[test]
1250    fn get_affine_reads_the_qform_when_there_is_no_sform() {
1251        // Sagittal 0.8 x 0.8 x 3 mm: voxel axis 0 runs A-P, axis 1 S-I, axis 2 L-R.
1252        let affine = [
1253            0.0, 0.0, 3.0, -90.0,
1254            0.8, 0.0, 0.0, -110.0,
1255            0.0, 0.8, 0.0, 70.0,
1256            0.0, 0.0, 0.0, 1.0,
1257        ];
1258        let voxel = (0.8, 0.8, 3.0);
1259        let q = qform_for(&affine, voxel).expect("a cardinal permutation has a quaternion");
1260
1261        let mut header = NiftiHeader {
1262            sform_code: 0,
1263            qform_code: 1,
1264            quatern_b: q.quatern[0] as f32,
1265            quatern_c: q.quatern[1] as f32,
1266            quatern_d: q.quatern[2] as f32,
1267            quatern_x: q.offset[0] as f32,
1268            quatern_y: q.offset[1] as f32,
1269            quatern_z: q.offset[2] as f32,
1270            pixdim: [q.qfac as f32, 0.8, 0.8, 3.0, 1.0, 1.0, 1.0, 1.0],
1271            ..Default::default()
1272        };
1273
1274        let got = get_affine(&header);
1275        assert!(max_abs_diff(&got, &affine) < 1e-4, "{got:?}");
1276        // The old behaviour, for contrast: diag(pixdim), which puts the 3 mm slice pitch on L-R
1277        // and calls a sagittal stack axial.
1278        header.qform_code = 0;
1279        let fallback = get_affine(&header);
1280        assert!((fallback[0] - 0.8).abs() < 1e-6, "fallback should be the bare voxel scaling");
1281        assert!(max_abs_diff(&fallback, &affine) > 1.0);
1282    }
1283
1284    #[test]
1285    fn test_save_nifti_header_details() {
1286        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; // 2x2x2
1287        let dims = (2, 2, 2);
1288        let voxel_size = (1.5, 2.5, 3.5);
1289        let affine = [
1290            1.5, 0.0, 0.0, 5.0,
1291            0.0, 2.5, 0.0, 10.0,
1292            0.0, 0.0, 3.5, 15.0,
1293            0.0, 0.0, 0.0, 1.0,
1294        ];
1295
1296        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1297
1298        // Verify datatype = 16 (FLOAT32)
1299        let datatype = i16::from_le_bytes([bytes[70], bytes[71]]);
1300        assert_eq!(datatype, 16);
1301
1302        // Verify bitpix = 32
1303        let bitpix = i16::from_le_bytes([bytes[72], bytes[73]]);
1304        assert_eq!(bitpix, 32);
1305
1306        // Verify dim[0] = 3
1307        let ndim = i16::from_le_bytes([bytes[40], bytes[41]]);
1308        assert_eq!(ndim, 3);
1309
1310        // Verify dim[1] = 2
1311        let nx = i16::from_le_bytes([bytes[42], bytes[43]]);
1312        assert_eq!(nx, 2);
1313
1314        // Verify vox_offset = 352
1315        let vox_offset = f32::from_le_bytes([bytes[108], bytes[109], bytes[110], bytes[111]]);
1316        assert_eq!(vox_offset, 352.0);
1317
1318        // Verify scl_slope = 1.0
1319        let scl_slope = f32::from_le_bytes([bytes[112], bytes[113], bytes[114], bytes[115]]);
1320        assert_eq!(scl_slope, 1.0);
1321
1322        // Verify sform_code = 1
1323        let sform_code = i16::from_le_bytes([bytes[254], bytes[255]]);
1324        assert_eq!(sform_code, 1);
1325
1326        // Verify pixdim[1] matches voxel_size
1327        let pixdim1 = f32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]);
1328        assert!((pixdim1 - 1.5).abs() < 1e-6);
1329    }
1330
1331    #[test]
1332    fn test_save_nifti_data_values() {
1333        let data = vec![1.0f64, 2.0, -3.0, 4.5, 0.0, 100.0, -0.5, 999.0]; // 2x2x2
1334        let dims = (2, 2, 2);
1335        let voxel_size = (1.0, 1.0, 1.0);
1336        let affine = [
1337            1.0, 0.0, 0.0, 0.0,
1338            0.0, 1.0, 0.0, 0.0,
1339            0.0, 0.0, 1.0, 0.0,
1340            0.0, 0.0, 0.0, 1.0,
1341        ];
1342
1343        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1344
1345        // Data starts at offset 352
1346        for i in 0..8 {
1347            let offset = 352 + i * 4;
1348            let val = f32::from_le_bytes([
1349                bytes[offset], bytes[offset + 1],
1350                bytes[offset + 2], bytes[offset + 3],
1351            ]);
1352            assert!(
1353                (val as f64 - data[i]).abs() < 0.01,
1354                "Data value {} mismatch: saved {}, expected {}",
1355                i, val, data[i]
1356            );
1357        }
1358    }
1359
1360    #[test]
1361    fn test_save_nifti_gz_bytes() {
1362        let data = vec![0.0; 8]; // 2x2x2
1363        let dims = (2, 2, 2);
1364        let voxel_size = (1.0, 1.0, 1.0);
1365        let affine = [
1366            1.0, 0.0, 0.0, 0.0,
1367            0.0, 1.0, 0.0, 0.0,
1368            0.0, 0.0, 1.0, 0.0,
1369            0.0, 0.0, 0.0, 1.0,
1370        ];
1371
1372        let bytes = save_nifti_gz(&data, dims, voxel_size, &affine).unwrap();
1373        assert!(is_gzip(&bytes), "save_nifti_gz should produce gzip bytes");
1374
1375        // Should be able to load it back
1376        let loaded = load_nifti(&bytes).unwrap();
1377        assert_eq!(loaded.dims, dims);
1378    }
1379
1380    #[test]
1381    fn test_read_nonexistent_file() {
1382        let result = read_nifti_file(std::path::Path::new("/tmp/nonexistent_file_12345.nii"));
1383        assert!(result.is_err(), "Reading nonexistent file should error");
1384        match result {
1385            Err(err) => {
1386                assert!(err.contains("Failed to read file"), "Error should mention file reading: {}", err);
1387            }
1388            Ok(_) => panic!("Should have returned an error"),
1389        }
1390    }
1391
1392    #[test]
1393    fn test_save_nifti_large_volume() {
1394        // Test with 8x8x8 volume (512 elements)
1395        let dims = (8, 8, 8);
1396        let n = dims.0 * dims.1 * dims.2;
1397        let voxel_size = (0.5, 0.5, 0.5);
1398        let affine = [
1399            0.5, 0.0, 0.0, -2.0,
1400            0.0, 0.5, 0.0, -2.0,
1401            0.0, 0.0, 0.5, -2.0,
1402            0.0, 0.0, 0.0, 1.0,
1403        ];
1404
1405        let data: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
1406
1407        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1408        assert_eq!(bytes.len(), 352 + n * 4);
1409
1410        // Load it back
1411        let loaded = load_nifti(&bytes).unwrap();
1412        assert_eq!(loaded.dims, dims);
1413        assert_eq!(loaded.data.len(), n);
1414
1415        for i in 0..n {
1416            assert!(
1417                (loaded.data[i] - data[i]).abs() < 0.01,
1418                "Roundtrip mismatch at {}: expected {}, got {}",
1419                i, data[i], loaded.data[i]
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn test_nifti_roundtrip_affine() {
1426        // Verify affine is preserved through save/load
1427        let dims = (4, 4, 4);
1428        let n = dims.0 * dims.1 * dims.2;
1429        let voxel_size = (1.0, 2.0, 3.0);
1430        let affine = [
1431            1.0, 0.1, 0.2, 10.0,
1432            0.3, 2.0, 0.4, 20.0,
1433            0.5, 0.6, 3.0, 30.0,
1434            0.0, 0.0, 0.0, 1.0,
1435        ];
1436
1437        let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
1438
1439        let tmp_dir = std::env::temp_dir();
1440        let tmp_path = tmp_dir.join("test_nifti_affine_rt.nii");
1441
1442        save_nifti_to_file(&tmp_path, &data, dims, voxel_size, &affine).unwrap();
1443        let loaded = read_nifti_file(&tmp_path).unwrap();
1444
1445        // Affine values are stored as f32, so expect f32-level precision
1446        for i in 0..16 {
1447            assert!(
1448                (loaded.affine[i] - affine[i]).abs() < 0.01,
1449                "Affine[{}] mismatch: expected {}, got {}",
1450                i, affine[i], loaded.affine[i]
1451            );
1452        }
1453
1454        std::fs::remove_file(&tmp_path).ok();
1455    }
1456
1457    #[test]
1458    fn test_nifti_scl_slope_intercept() {
1459        // Test that scl_slope and scl_inter are reported correctly
1460        let dims = (4, 4, 4);
1461        let n = dims.0 * dims.1 * dims.2;
1462        let voxel_size = (1.0, 1.0, 1.0);
1463        let affine = [
1464            1.0, 0.0, 0.0, 0.0,
1465            0.0, 1.0, 0.0, 0.0,
1466            0.0, 0.0, 1.0, 0.0,
1467            0.0, 0.0, 0.0, 1.0,
1468        ];
1469
1470        let data = vec![1.0; n];
1471        let bytes = save_nifti(&data, dims, voxel_size, &affine).unwrap();
1472        let loaded = load_nifti(&bytes).unwrap();
1473
1474        // Our save sets scl_slope = 1.0 and scl_inter = 0.0
1475        assert!((loaded.scl_slope - 1.0).abs() < 1e-5);
1476        assert!((loaded.scl_inter - 0.0).abs() < 1e-5);
1477    }
1478
1479    /// #183: every written header says its voxel sizes are in mm (and time in seconds), so the
1480    /// BIDS validator's `NIFTI_UNIT` check passes and readers need not guess.
1481    #[test]
1482    fn written_headers_carry_mm_and_sec_units() {
1483        let id = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
1484        let v = [0.0; 8];
1485        let three = save_nifti(&v, (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap();
1486        let four = save_nifti_4d(&[&v, &v], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap();
1487        for bytes in [&three, &four] {
1488            assert_eq!(bytes[123], 10, "xyzt_units should be mm | sec");
1489            let hdr = InMemNiftiObject::from_reader(Cursor::new(bytes.as_slice())).unwrap();
1490            assert_eq!(hdr.header().xyzt_units, 10);
1491        }
1492    }
1493
1494    /// #182: N volumes written as one 4D file read back with the same data, dims, pixdim and
1495    /// affine — through gzip and the file writer too.
1496    #[test]
1497    fn save_nifti_4d_round_trips() {
1498        let dims = (3, 4, 5);
1499        let n = dims.0 * dims.1 * dims.2;
1500        let nt = 6;
1501        let vols: Vec<Vec<f64>> =
1502            (0..nt).map(|t| (0..n).map(|i| (t * 1000 + i) as f64 * 0.25 - 7.0).collect()).collect();
1503        let refs: Vec<&[f64]> = vols.iter().map(|v| v.as_slice()).collect();
1504
1505        for (label, voxel, affine) in [
1506            ("oblique left-handed", ISSUE_240_VOXEL, ISSUE_240_AFFINE),
1507            ("axial anisotropic", (1.0, 1.0, 2.0), [
1508                1.0, 0.0, 0.0, -10.0,
1509                0.0, 1.0, 0.0, -20.0,
1510                0.0, 0.0, 2.0, -30.0,
1511                0.0, 0.0, 0.0, 1.0,
1512            ]),
1513        ] {
1514            let plain = save_nifti_4d(&refs, dims, voxel, &affine).unwrap();
1515            let gz = save_nifti_4d_gz(&refs, dims, voxel, &affine).unwrap();
1516            assert!(is_gzip(&gz));
1517            for bytes in [&plain, &gz] {
1518                let (data, d4, vs, aff) = load_nifti_4d(bytes).unwrap();
1519                assert_eq!(d4, (3, 4, 5, nt), "{label}");
1520                assert!((vs.0 - voxel.0).abs() < 1e-5 && (vs.1 - voxel.1).abs() < 1e-5
1521                    && (vs.2 - voxel.2).abs() < 1e-5, "{label}: {vs:?}");
1522                assert!(max_abs_diff(&aff, &affine) < 1e-4, "{label}: {aff:?}");
1523                let expected: Vec<f64> = vols.concat();
1524                assert_eq!(data, expected, "{label}: data (exact in f32)");
1525            }
1526
1527            // The header is the 3D writer's, apart from the fourth axis.
1528            let three = save_nifti(&vols[0], dims, voxel, &affine).unwrap();
1529            assert_eq!(i16::from_le_bytes([plain[40], plain[41]]), 4);
1530            assert_eq!(i16::from_le_bytes([plain[48], plain[49]]), nt as i16);
1531            assert_eq!(f32::from_le_bytes(plain[92..96].try_into().unwrap()), 1.0);
1532            for (a, b) in [(0, 40), (50, 348)] {
1533                assert_eq!(plain[a..b], three[a..b], "{label}: header bytes {a}..{b}");
1534            }
1535            assert_eq!(&plain[352..352 + n * 4], &three[352..], "{label}: first volume");
1536        }
1537
1538        let dir = std::env::temp_dir().join("qsm-core-4d-test");
1539        std::fs::create_dir_all(&dir).unwrap();
1540        for name in ["four.nii", "four.nii.gz"] {
1541            let path = dir.join(name);
1542            save_nifti_4d_to_file(&path, &refs, dims, ISSUE_240_VOXEL, &ISSUE_240_AFFINE).unwrap();
1543            let bytes = std::fs::read(&path).unwrap();
1544            assert_eq!(is_gzip(&bytes), name.ends_with(".gz"), "{name}");
1545            let (data, d4, _, _) = load_nifti_4d(&bytes).unwrap();
1546            assert_eq!(d4, (3, 4, 5, nt), "{name}");
1547            assert_eq!(data.len(), n * nt, "{name}");
1548        }
1549        let _ = std::fs::remove_dir_all(&dir);
1550    }
1551
1552    #[test]
1553    fn save_nifti_4d_writes_one_volume_as_4d_and_rejects_bad_input() {
1554        let id = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
1555        let v = [1.0; 8];
1556        let one = save_nifti_4d(&[&v], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap();
1557        assert_eq!(i16::from_le_bytes([one[40], one[41]]), 4);
1558        assert_eq!(i16::from_le_bytes([one[48], one[49]]), 1);
1559
1560        assert!(save_nifti_4d(&[], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap_err().contains("no volumes"));
1561        let err = save_nifti_4d(&[&v, &v[..7]], (2, 2, 2), (1.0, 1.0, 1.0), &id).unwrap_err();
1562        assert!(err.contains("volume 1") && err.contains("7 values"), "{err}");
1563    }
1564}