Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -0,0 +1,872 @@
//! NIfTI header parsing for NIfTI-1 and NIfTI-2 formats.
//!
//! NIfTI-1 header is 348 bytes, NIfTI-2 header is 540 bytes.
//! Both formats store 3D/4D volumetric data with spatial metadata.
use crate::error::{MedicalError, MedicalResult};
use byteorder::{ByteOrder, LittleEndian, ReadBytesExt};
use std::io::{Read, Seek, SeekFrom};
/// NIfTI data type codes
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i16)]
pub enum NiftiDataType {
/// Unknown data type
Unknown = 0,
/// Binary (1 bit per voxel)
Binary = 1,
/// Unsigned 8-bit integer
UInt8 = 2,
/// Signed 16-bit integer
Int16 = 4,
/// Signed 32-bit integer
Int32 = 8,
/// 32-bit floating point
Float32 = 16,
/// 64-bit complex (2x float32)
Complex64 = 32,
/// 64-bit floating point
Float64 = 64,
/// RGB (3x uint8)
Rgb24 = 128,
/// Signed 8-bit integer
Int8 = 256,
/// Unsigned 16-bit integer
UInt16 = 512,
/// Unsigned 32-bit integer
UInt32 = 768,
/// Signed 64-bit integer
Int64 = 1024,
/// Unsigned 64-bit integer
UInt64 = 1280,
/// 128-bit floating point
Float128 = 1536,
/// 128-bit complex (2x float64)
Complex128 = 1792,
/// 256-bit complex (2x float128)
Complex256 = 2048,
/// RGBA (4x uint8)
Rgba32 = 2304,
}
impl NiftiDataType {
/// Create from raw code
pub fn from_code(code: i16) -> Option<Self> {
match code {
0 => Some(Self::Unknown),
1 => Some(Self::Binary),
2 => Some(Self::UInt8),
4 => Some(Self::Int16),
8 => Some(Self::Int32),
16 => Some(Self::Float32),
32 => Some(Self::Complex64),
64 => Some(Self::Float64),
128 => Some(Self::Rgb24),
256 => Some(Self::Int8),
512 => Some(Self::UInt16),
768 => Some(Self::UInt32),
1024 => Some(Self::Int64),
1280 => Some(Self::UInt64),
1536 => Some(Self::Float128),
1792 => Some(Self::Complex128),
2048 => Some(Self::Complex256),
2304 => Some(Self::Rgba32),
_ => None,
}
}
/// Get the number of bytes per voxel
pub fn bytes_per_voxel(&self) -> usize {
match self {
Self::Unknown => 0,
Self::Binary => 1, // Stored as bytes, 1 bit per voxel logically
Self::UInt8 | Self::Int8 => 1,
Self::Int16 | Self::UInt16 => 2,
Self::Int32 | Self::UInt32 | Self::Float32 => 4,
Self::Float64 | Self::Int64 | Self::UInt64 | Self::Complex64 => 8,
Self::Rgb24 => 3,
Self::Rgba32 => 4,
Self::Float128 | Self::Complex128 => 16,
Self::Complex256 => 32,
}
}
/// Get a human-readable name
pub fn name(&self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Binary => "binary",
Self::UInt8 => "uint8",
Self::Int8 => "int8",
Self::Int16 => "int16",
Self::UInt16 => "uint16",
Self::Int32 => "int32",
Self::UInt32 => "uint32",
Self::Int64 => "int64",
Self::UInt64 => "uint64",
Self::Float32 => "float32",
Self::Float64 => "float64",
Self::Float128 => "float128",
Self::Complex64 => "complex64",
Self::Complex128 => "complex128",
Self::Complex256 => "complex256",
Self::Rgb24 => "rgb24",
Self::Rgba32 => "rgba32",
}
}
}
/// Transform code for sform/qform
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(i16)]
pub enum TransformCode {
/// Unknown coordinate system
#[default]
Unknown = 0,
/// Scanner-based anatomical coordinates
ScannerAnat = 1,
/// Coordinates aligned to another file
AlignedAnat = 2,
/// Talairach space
Talairach = 3,
/// MNI-152 space
Mni152 = 4,
/// Template-other space
TemplateOther = 5,
}
impl TransformCode {
/// Create from raw code
pub fn from_code(code: i16) -> Self {
match code {
1 => Self::ScannerAnat,
2 => Self::AlignedAnat,
3 => Self::Talairach,
4 => Self::Mni152,
5 => Self::TemplateOther,
_ => Self::Unknown,
}
}
}
/// Units for spatial dimensions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpatialUnits {
/// Unknown units
#[default]
Unknown,
/// Meters
Meters,
/// Millimeters
Millimeters,
/// Micrometers
Micrometers,
}
impl SpatialUnits {
/// Create from xyzt_units field (lower 3 bits)
pub fn from_code(code: u8) -> Self {
match code & 0x07 {
1 => Self::Meters,
2 => Self::Millimeters,
3 => Self::Micrometers,
_ => Self::Unknown,
}
}
/// Get conversion factor to millimeters
pub fn to_mm_factor(&self) -> f64 {
match self {
Self::Unknown => 1.0,
Self::Meters => 1000.0,
Self::Millimeters => 1.0,
Self::Micrometers => 0.001,
}
}
}
/// Units for temporal dimension
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TemporalUnits {
/// Unknown units
#[default]
Unknown,
/// Seconds
Seconds,
/// Milliseconds
Milliseconds,
/// Microseconds
Microseconds,
/// Hertz
Hertz,
/// Parts per million
Ppm,
/// Radians per second
Rads,
}
impl TemporalUnits {
/// Create from xyzt_units field (bits 3-5)
pub fn from_code(code: u8) -> Self {
match (code >> 3) & 0x07 {
1 => Self::Seconds,
2 => Self::Milliseconds,
3 => Self::Microseconds,
4 => Self::Hertz,
5 => Self::Ppm,
6 => Self::Rads,
_ => Self::Unknown,
}
}
}
/// NIfTI header (unified for NIfTI-1 and NIfTI-2)
#[derive(Debug, Clone)]
pub struct NiftiHeader {
/// Header size (348 for NIfTI-1, 540 for NIfTI-2)
pub sizeof_hdr: i32,
/// Data type
pub datatype: NiftiDataType,
/// Bits per voxel
pub bitpix: i16,
/// Dimensions: [ndim, dim1, dim2, dim3, dim4, dim5, dim6, dim7]
pub dim: [i64; 8],
/// Intent parameters (for statistical data)
pub intent_p1: f64,
/// Intent parameter 2
pub intent_p2: f64,
/// Intent parameter 3
pub intent_p3: f64,
/// Intent code
pub intent_code: i16,
/// Voxel dimensions (spacing): [_, pixdim1, pixdim2, pixdim3, ...]
pub pixdim: [f64; 8],
/// Offset to voxel data in file
pub vox_offset: i64,
/// Data scaling: slope
pub scl_slope: f64,
/// Data scaling: intercept
pub scl_inter: f64,
/// Slice timing order code
pub slice_code: u8,
/// Units for xyzt dimensions
pub xyzt_units: u8,
/// Maximum value in data (informational)
pub cal_max: f64,
/// Minimum value in data (informational)
pub cal_min: f64,
/// Slice duration
pub slice_duration: f64,
/// Time axis shift
pub toffset: f64,
/// First slice index
pub slice_start: i64,
/// Last slice index
pub slice_end: i64,
/// Description string (max 80 chars)
pub descrip: String,
/// Auxiliary filename
pub aux_file: String,
/// QForm transform code
pub qform_code: TransformCode,
/// SForm transform code
pub sform_code: TransformCode,
/// Quaternion parameters for qform
pub quatern_b: f64,
/// Quaternion c component
pub quatern_c: f64,
/// Quaternion d component
pub quatern_d: f64,
/// Quaternion offset x
pub qoffset_x: f64,
/// Quaternion offset y
pub qoffset_y: f64,
/// Quaternion offset z
pub qoffset_z: f64,
/// Affine matrix rows for sform
pub srow_x: [f64; 4],
/// Affine matrix row y
pub srow_y: [f64; 4],
/// Affine matrix row z
pub srow_z: [f64; 4],
/// Intent name
pub intent_name: String,
/// Magic bytes (determines NIfTI-1 vs NIfTI-2)
pub magic: [u8; 8],
/// Whether this is NIfTI-2 format
pub is_nifti2: bool,
/// Byte order (true = little endian)
pub little_endian: bool,
}
impl Default for NiftiHeader {
fn default() -> Self {
Self {
sizeof_hdr: 348,
datatype: NiftiDataType::Float32,
bitpix: 32,
dim: [3, 1, 1, 1, 1, 1, 1, 1],
intent_p1: 0.0,
intent_p2: 0.0,
intent_p3: 0.0,
intent_code: 0,
pixdim: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
vox_offset: 352,
scl_slope: 1.0,
scl_inter: 0.0,
slice_code: 0,
xyzt_units: 2, // mm
cal_max: 0.0,
cal_min: 0.0,
slice_duration: 0.0,
toffset: 0.0,
slice_start: 0,
slice_end: 0,
descrip: String::new(),
aux_file: String::new(),
qform_code: TransformCode::Unknown,
sform_code: TransformCode::Unknown,
quatern_b: 0.0,
quatern_c: 0.0,
quatern_d: 0.0,
qoffset_x: 0.0,
qoffset_y: 0.0,
qoffset_z: 0.0,
srow_x: [1.0, 0.0, 0.0, 0.0],
srow_y: [0.0, 1.0, 0.0, 0.0],
srow_z: [0.0, 0.0, 1.0, 0.0],
intent_name: String::new(),
magic: *b"n+1\0\0\0\0\0",
is_nifti2: false,
little_endian: true,
}
}
}
impl NiftiHeader {
/// Get spatial dimensions (x, y, z)
pub fn shape(&self) -> (usize, usize, usize) {
(
self.dim[1] as usize,
self.dim[2] as usize,
self.dim[3] as usize,
)
}
/// Get voxel spacing (dx, dy, dz) in mm
pub fn spacing(&self) -> (f64, f64, f64) {
let factor = SpatialUnits::from_code(self.xyzt_units).to_mm_factor();
(
self.pixdim[1].abs() * factor,
self.pixdim[2].abs() * factor,
self.pixdim[3].abs() * factor,
)
}
/// Get the number of dimensions
pub fn ndim(&self) -> usize {
self.dim[0] as usize
}
/// Get total number of voxels
pub fn num_voxels(&self) -> usize {
let ndim = self.ndim();
let mut total = 1usize;
for i in 1..=ndim {
total *= self.dim[i] as usize;
}
total
}
/// Get total data size in bytes
pub fn data_size(&self) -> usize {
self.num_voxels() * self.datatype.bytes_per_voxel()
}
/// Get the 4x4 affine transformation matrix.
/// Prefers sform if available, falls back to qform, then identity.
pub fn affine(&self) -> [[f64; 4]; 4] {
if self.sform_code != TransformCode::Unknown {
// Use sform
[self.srow_x, self.srow_y, self.srow_z, [0.0, 0.0, 0.0, 1.0]]
} else if self.qform_code != TransformCode::Unknown {
// Use qform (quaternion to rotation matrix)
self.qform_to_affine()
} else {
// Identity with spacing
let (dx, dy, dz) = self.spacing();
[
[dx, 0.0, 0.0, 0.0],
[0.0, dy, 0.0, 0.0],
[0.0, 0.0, dz, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
}
}
/// Convert quaternion (qform) to affine matrix
fn qform_to_affine(&self) -> [[f64; 4]; 4] {
let b = self.quatern_b;
let c = self.quatern_c;
let d = self.quatern_d;
// Compute a (quaternion w component)
let a = (1.0 - b * b - c * c - d * d).max(0.0).sqrt();
// Rotation matrix from quaternion
let r11 = a * a + b * b - c * c - d * d;
let r12 = 2.0 * (b * c - a * d);
let r13 = 2.0 * (b * d + a * c);
let r21 = 2.0 * (b * c + a * d);
let r22 = a * a + c * c - b * b - d * d;
let r23 = 2.0 * (c * d - a * b);
let r31 = 2.0 * (b * d - a * c);
let r32 = 2.0 * (c * d + a * b);
let r33 = a * a + d * d - b * b - c * c;
// Apply scaling (pixdim)
let (dx, dy, dz) = self.spacing();
// Handle qfac (sign of pixdim[0] determines handedness)
let qfac = if self.pixdim[0] < 0.0 { -1.0 } else { 1.0 };
[
[r11 * dx, r12 * dy, r13 * dz * qfac, self.qoffset_x],
[r21 * dx, r22 * dy, r23 * dz * qfac, self.qoffset_y],
[r31 * dx, r32 * dy, r33 * dz * qfac, self.qoffset_z],
[0.0, 0.0, 0.0, 1.0],
]
}
/// Get origin (translation from affine)
pub fn origin(&self) -> (f64, f64, f64) {
let affine = self.affine();
(affine[0][3], affine[1][3], affine[2][3])
}
/// Read NIfTI header from a reader
pub fn read<R: Read + Seek>(reader: &mut R) -> MedicalResult<Self> {
// Read first 4 bytes to determine header size
let sizeof_hdr = reader.read_i32::<LittleEndian>()?;
// Check if we need to swap bytes
let (sizeof_hdr, little_endian) = if sizeof_hdr == 348 || sizeof_hdr == 540 {
(sizeof_hdr, true)
} else {
let swapped = sizeof_hdr.swap_bytes();
if swapped == 348 || swapped == 540 {
(swapped, false)
} else {
return Err(MedicalError::Nifti(format!(
"Invalid NIfTI header size: expected 348 or 540, got {}",
sizeof_hdr
)));
}
};
// Seek back to start
reader.seek(SeekFrom::Start(0))?;
if sizeof_hdr == 348 {
Self::read_nifti1(reader, little_endian)
} else {
Self::read_nifti2(reader, little_endian)
}
}
/// Read NIfTI-1 header (348 bytes)
fn read_nifti1<R: Read>(reader: &mut R, little_endian: bool) -> MedicalResult<Self> {
let mut buf = [0u8; 348];
reader.read_exact(&mut buf)?;
let read_i16 = |offset: usize| -> i16 {
if little_endian {
LittleEndian::read_i16(&buf[offset..])
} else {
byteorder::BigEndian::read_i16(&buf[offset..])
}
};
let read_i32 = |offset: usize| -> i32 {
if little_endian {
LittleEndian::read_i32(&buf[offset..])
} else {
byteorder::BigEndian::read_i32(&buf[offset..])
}
};
let read_f32 = |offset: usize| -> f32 {
if little_endian {
LittleEndian::read_f32(&buf[offset..])
} else {
byteorder::BigEndian::read_f32(&buf[offset..])
}
};
let sizeof_hdr = read_i32(0);
// dim array at offset 40 (8 x i16)
let mut dim = [0i64; 8];
for i in 0..8 {
dim[i] = read_i16(40 + i * 2) as i64;
}
// intent_p1, p2, p3 at offsets 56, 60, 64
let intent_p1 = read_f32(56) as f64;
let intent_p2 = read_f32(60) as f64;
let intent_p3 = read_f32(64) as f64;
// intent_code at 68
let intent_code = read_i16(68);
// datatype at 70
let datatype_code = read_i16(70);
let datatype = NiftiDataType::from_code(datatype_code).ok_or_else(|| {
MedicalError::Nifti(format!(
"Unsupported NIfTI data type code: {}",
datatype_code
))
})?;
// bitpix at 72
let bitpix = read_i16(72);
// slice_start at 74
let slice_start = read_i16(74) as i64;
// pixdim at 76 (8 x f32)
let mut pixdim = [0.0f64; 8];
for i in 0..8 {
pixdim[i] = read_f32(76 + i * 4) as f64;
}
// vox_offset at 108
let vox_offset = read_f32(108) as i64;
// scl_slope at 112, scl_inter at 116
let scl_slope = read_f32(112) as f64;
let scl_inter = read_f32(116) as f64;
// slice_end at 120
let slice_end = read_i16(120) as i64;
// slice_code at 122
let slice_code = buf[122];
// xyzt_units at 123
let xyzt_units = buf[123];
// cal_max at 124, cal_min at 128
let cal_max = read_f32(124) as f64;
let cal_min = read_f32(128) as f64;
// slice_duration at 132
let slice_duration = read_f32(132) as f64;
// toffset at 136
let toffset = read_f32(136) as f64;
// descrip at 148 (80 bytes)
let descrip = String::from_utf8_lossy(&buf[148..228])
.trim_end_matches('\0')
.to_string();
// aux_file at 228 (24 bytes)
let aux_file = String::from_utf8_lossy(&buf[228..252])
.trim_end_matches('\0')
.to_string();
// qform_code at 252, sform_code at 254
let qform_code = TransformCode::from_code(read_i16(252));
let sform_code = TransformCode::from_code(read_i16(254));
// Quaternion at 256-279
let quatern_b = read_f32(256) as f64;
let quatern_c = read_f32(260) as f64;
let quatern_d = read_f32(264) as f64;
let qoffset_x = read_f32(268) as f64;
let qoffset_y = read_f32(272) as f64;
let qoffset_z = read_f32(276) as f64;
// Sform rows at 280-327
let mut srow_x = [0.0f64; 4];
let mut srow_y = [0.0f64; 4];
let mut srow_z = [0.0f64; 4];
for i in 0..4 {
srow_x[i] = read_f32(280 + i * 4) as f64;
srow_y[i] = read_f32(296 + i * 4) as f64;
srow_z[i] = read_f32(312 + i * 4) as f64;
}
// intent_name at 328 (16 bytes)
let intent_name = String::from_utf8_lossy(&buf[328..344])
.trim_end_matches('\0')
.to_string();
// magic at 344 (4 bytes for NIfTI-1)
let mut magic = [0u8; 8];
magic[..4].copy_from_slice(&buf[344..348]);
// Validate magic
if &magic[..3] != b"n+1" && &magic[..3] != b"ni1" {
return Err(MedicalError::Nifti(format!(
"Invalid NIfTI magic bytes: expected 'n+1' or 'ni1', got {:?}",
&magic[..4]
)));
}
Ok(Self {
sizeof_hdr,
datatype,
bitpix,
dim,
intent_p1,
intent_p2,
intent_p3,
intent_code,
pixdim,
vox_offset,
scl_slope,
scl_inter,
slice_code,
xyzt_units,
cal_max,
cal_min,
slice_duration,
toffset,
slice_start,
slice_end,
descrip,
aux_file,
qform_code,
sform_code,
quatern_b,
quatern_c,
quatern_d,
qoffset_x,
qoffset_y,
qoffset_z,
srow_x,
srow_y,
srow_z,
intent_name,
magic,
is_nifti2: false,
little_endian,
})
}
/// Read NIfTI-2 header (540 bytes)
fn read_nifti2<R: Read>(reader: &mut R, little_endian: bool) -> MedicalResult<Self> {
let mut buf = [0u8; 540];
reader.read_exact(&mut buf)?;
let read_i16 = |offset: usize| -> i16 {
if little_endian {
LittleEndian::read_i16(&buf[offset..])
} else {
byteorder::BigEndian::read_i16(&buf[offset..])
}
};
let read_i32 = |offset: usize| -> i32 {
if little_endian {
LittleEndian::read_i32(&buf[offset..])
} else {
byteorder::BigEndian::read_i32(&buf[offset..])
}
};
let read_i64 = |offset: usize| -> i64 {
if little_endian {
LittleEndian::read_i64(&buf[offset..])
} else {
byteorder::BigEndian::read_i64(&buf[offset..])
}
};
let read_f64 = |offset: usize| -> f64 {
if little_endian {
LittleEndian::read_f64(&buf[offset..])
} else {
byteorder::BigEndian::read_f64(&buf[offset..])
}
};
let sizeof_hdr = read_i32(0);
// magic at 4 (8 bytes for NIfTI-2)
let mut magic = [0u8; 8];
magic.copy_from_slice(&buf[4..12]);
// Validate magic
if &magic[..3] != b"n+2" && &magic[..3] != b"ni2" {
return Err(MedicalError::Nifti(format!(
"Invalid NIfTI-2 magic bytes: expected 'n+2' or 'ni2', got {:?}",
&magic
)));
}
// datatype at 12
let datatype_code = read_i16(12);
let datatype = NiftiDataType::from_code(datatype_code).ok_or_else(|| {
MedicalError::Nifti(format!(
"Unsupported NIfTI data type code: {}",
datatype_code
))
})?;
// bitpix at 14
let bitpix = read_i16(14);
// dim at 16 (8 x i64)
let mut dim = [0i64; 8];
for i in 0..8 {
dim[i] = read_i64(16 + i * 8);
}
// intent_p1, p2, p3 at 80, 88, 96
let intent_p1 = read_f64(80);
let intent_p2 = read_f64(88);
let intent_p3 = read_f64(96);
// pixdim at 104 (8 x f64)
let mut pixdim = [0.0f64; 8];
for i in 0..8 {
pixdim[i] = read_f64(104 + i * 8);
}
// vox_offset at 168
let vox_offset = read_i64(168);
// scl_slope at 176, scl_inter at 184
let scl_slope = read_f64(176);
let scl_inter = read_f64(184);
// cal_max at 192, cal_min at 200
let cal_max = read_f64(192);
let cal_min = read_f64(200);
// slice_duration at 208
let slice_duration = read_f64(208);
// toffset at 216
let toffset = read_f64(216);
// slice_start at 224, slice_end at 232
let slice_start = read_i64(224);
let slice_end = read_i64(232);
// descrip at 240 (80 bytes)
let descrip = String::from_utf8_lossy(&buf[240..320])
.trim_end_matches('\0')
.to_string();
// aux_file at 320 (24 bytes)
let aux_file = String::from_utf8_lossy(&buf[320..344])
.trim_end_matches('\0')
.to_string();
// qform_code at 344, sform_code at 348
let qform_code = TransformCode::from_code(read_i32(344) as i16);
let sform_code = TransformCode::from_code(read_i32(348) as i16);
// Quaternion at 352-399
let quatern_b = read_f64(352);
let quatern_c = read_f64(360);
let quatern_d = read_f64(368);
let qoffset_x = read_f64(376);
let qoffset_y = read_f64(384);
let qoffset_z = read_f64(392);
// Sform rows at 400-495
let mut srow_x = [0.0f64; 4];
let mut srow_y = [0.0f64; 4];
let mut srow_z = [0.0f64; 4];
for i in 0..4 {
srow_x[i] = read_f64(400 + i * 8);
srow_y[i] = read_f64(432 + i * 8);
srow_z[i] = read_f64(464 + i * 8);
}
// slice_code at 496
let slice_code = buf[496];
// xyzt_units at 497
let xyzt_units = buf[497];
// intent_code at 500
let intent_code = read_i32(500) as i16;
// intent_name at 504 (16 bytes)
let intent_name = String::from_utf8_lossy(&buf[504..520])
.trim_end_matches('\0')
.to_string();
Ok(Self {
sizeof_hdr,
datatype,
bitpix,
dim,
intent_p1,
intent_p2,
intent_p3,
intent_code,
pixdim,
vox_offset,
scl_slope,
scl_inter,
slice_code,
xyzt_units,
cal_max,
cal_min,
slice_duration,
toffset,
slice_start,
slice_end,
descrip,
aux_file,
qform_code,
sform_code,
quatern_b,
quatern_c,
quatern_d,
qoffset_x,
qoffset_y,
qoffset_z,
srow_x,
srow_y,
srow_z,
intent_name,
magic,
is_nifti2: true,
little_endian,
})
}
}
@@ -0,0 +1,49 @@
//! NIfTI (Neuroimaging Informatics Technology Initiative) file format support.
//!
//! This module provides readers and writers for NIfTI-1 and NIfTI-2 formats,
//! which are standard file formats for storing neuroimaging data.
//!
//! # Supported formats
//!
//! - `.nii` - Uncompressed NIfTI-1 single file
//! - `.nii.gz` - Gzip-compressed NIfTI-1 single file
//! - NIfTI-2 format (read support)
//!
//! # Example
//!
//! ```ignore
//! use rtx_medical_core::nifti::{read_nifti, write_nifti, NiftiDataType};
//!
//! // Read a NIfTI file
//! let volume = read_nifti("brain.nii.gz")?;
//! println!("Shape: {:?}", volume.shape());
//! println!("Spacing: {:?}", volume.spacing());
//! println!("Origin: {:?}", volume.origin());
//!
//! // Process the volume...
//!
//! // Write back to a new file
//! write_nifti(
//! &volume.data,
//! volume.shape(),
//! volume.spacing(),
//! &volume.affine(),
//! "output.nii.gz",
//! NiftiDataType::Float32
//! )?;
//! ```
pub mod header;
pub mod reader;
pub mod transform;
pub mod writer;
// Re-exports for convenience
pub use header::{NiftiDataType, NiftiHeader, SpatialUnits, TemporalUnits, TransformCode};
pub use reader::{NiftiVolume, read_nifti, read_nifti_header};
pub use transform::{
Affine4, approx_equal, compose, from_flat, from_matrix4, from_spacing_origin, get_origin,
get_rotation, get_spacing, identity, inverse, scaling, to_flat, to_matrix4, transform_point,
transform_vector, translation, voxel_to_world, world_to_voxel,
};
pub use writer::write_nifti;
@@ -0,0 +1,317 @@
//! NIfTI file reader supporting .nii and .nii.gz formats.
use crate::error::{MedicalError, MedicalResult};
use crate::nifti::header::{NiftiDataType, NiftiHeader};
use byteorder::{ByteOrder, LittleEndian};
use flate2::read::GzDecoder;
use std::fs::File;
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
use std::path::Path;
/// NIfTI volume data structure returned by the reader.
///
/// Contains raw voxel data as f64 and complete header metadata.
#[derive(Debug, Clone)]
pub struct NiftiVolume {
/// Voxel data in row-major order (z varies slowest, x varies fastest)
pub data: Vec<f64>,
/// NIfTI header with all metadata
pub header: NiftiHeader,
}
impl NiftiVolume {
/// Get the volume shape (x, y, z)
pub fn shape(&self) -> (usize, usize, usize) {
self.header.shape()
}
/// Get voxel spacing in mm (dx, dy, dz)
pub fn spacing(&self) -> (f64, f64, f64) {
self.header.spacing()
}
/// Get origin in world coordinates (ox, oy, oz)
pub fn origin(&self) -> (f64, f64, f64) {
self.header.origin()
}
/// Get the 4x4 affine transformation matrix
pub fn affine(&self) -> [[f64; 4]; 4] {
self.header.affine()
}
}
/// Read a NIfTI file from disk.
///
/// Supports both .nii and .nii.gz files. The data is returned as a `NiftiVolume`
/// with the voxel data converted to f64.
///
/// # Arguments
/// * `path` - Path to the NIfTI file
///
/// # Returns
/// A `NiftiVolume` containing the image data and metadata
///
/// # Example
/// ```ignore
/// use rtx_medical_core::nifti::read_nifti;
///
/// let volume = read_nifti("brain.nii.gz")?;
/// println!("Shape: {:?}", volume.shape());
/// println!("Spacing: {:?}", volume.spacing());
/// ```
pub fn read_nifti<P: AsRef<Path>>(path: P) -> MedicalResult<NiftiVolume> {
let path = path.as_ref();
if !path.exists() {
return Err(MedicalError::Nifti(format!(
"File not found: {}",
path.display()
)));
}
let path_str = path.to_string_lossy().to_lowercase();
let is_gzipped = path_str.ends_with(".nii.gz") || path_str.ends_with(".gz");
if is_gzipped {
read_nifti_gz(path)
} else if path_str.ends_with(".nii") {
read_nifti_uncompressed(path)
} else {
Err(MedicalError::InvalidFormat(format!(
"Invalid file extension: expected .nii or .nii.gz, got {:?}",
path.extension()
)))
}
}
/// Read a gzip-compressed NIfTI file (.nii.gz)
fn read_nifti_gz<P: AsRef<Path>>(path: P) -> MedicalResult<NiftiVolume> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut decoder = GzDecoder::new(reader);
// Read entire decompressed content into memory
let mut data = Vec::new();
decoder
.read_to_end(&mut data)
.map_err(|e| MedicalError::Nifti(format!("Failed to decompress gzip: {}", e)))?;
// Parse from memory buffer
let mut cursor = Cursor::new(data);
read_nifti_from_reader(&mut cursor)
}
/// Read an uncompressed NIfTI file (.nii)
fn read_nifti_uncompressed<P: AsRef<Path>>(path: P) -> MedicalResult<NiftiVolume> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
read_nifti_from_reader(&mut reader)
}
/// Read NIfTI from any reader that implements Read + Seek
fn read_nifti_from_reader<R: Read + Seek>(reader: &mut R) -> MedicalResult<NiftiVolume> {
// Read header
let header = NiftiHeader::read(reader)?;
// Seek to voxel data
reader.seek(SeekFrom::Start(header.vox_offset as u64))?;
// Read voxel data
let data_size = header.data_size();
let mut raw_data = vec![0u8; data_size];
reader.read_exact(&mut raw_data)?;
// Convert to f64 based on data type
let data = convert_to_f64(&raw_data, &header)?;
// Apply scaling if needed
let data = if header.scl_slope != 0.0 && (header.scl_slope != 1.0 || header.scl_inter != 0.0) {
data.iter()
.map(|&v| v * header.scl_slope + header.scl_inter)
.collect()
} else {
data
};
Ok(NiftiVolume { data, header })
}
/// Convert raw bytes to f64 based on NIfTI data type
fn convert_to_f64(raw: &[u8], header: &NiftiHeader) -> MedicalResult<Vec<f64>> {
let num_voxels = header.num_voxels();
let mut result = Vec::with_capacity(num_voxels);
let little_endian = header.little_endian;
match header.datatype {
NiftiDataType::UInt8 => {
for &byte in raw.iter().take(num_voxels) {
result.push(byte as f64);
}
}
NiftiDataType::Int8 => {
for &byte in raw.iter().take(num_voxels) {
result.push((byte as i8) as f64);
}
}
NiftiDataType::Int16 => {
for chunk in raw.chunks_exact(2).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_i16(chunk)
} else {
byteorder::BigEndian::read_i16(chunk)
};
result.push(val as f64);
}
}
NiftiDataType::UInt16 => {
for chunk in raw.chunks_exact(2).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_u16(chunk)
} else {
byteorder::BigEndian::read_u16(chunk)
};
result.push(val as f64);
}
}
NiftiDataType::Int32 => {
for chunk in raw.chunks_exact(4).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_i32(chunk)
} else {
byteorder::BigEndian::read_i32(chunk)
};
result.push(val as f64);
}
}
NiftiDataType::UInt32 => {
for chunk in raw.chunks_exact(4).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_u32(chunk)
} else {
byteorder::BigEndian::read_u32(chunk)
};
result.push(val as f64);
}
}
NiftiDataType::Float32 => {
for chunk in raw.chunks_exact(4).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_f32(chunk)
} else {
byteorder::BigEndian::read_f32(chunk)
};
result.push(val as f64);
}
}
NiftiDataType::Float64 => {
for chunk in raw.chunks_exact(8).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_f64(chunk)
} else {
byteorder::BigEndian::read_f64(chunk)
};
result.push(val);
}
}
NiftiDataType::Int64 => {
for chunk in raw.chunks_exact(8).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_i64(chunk)
} else {
byteorder::BigEndian::read_i64(chunk)
};
result.push(val as f64);
}
}
NiftiDataType::UInt64 => {
for chunk in raw.chunks_exact(8).take(num_voxels) {
let val = if little_endian {
LittleEndian::read_u64(chunk)
} else {
byteorder::BigEndian::read_u64(chunk)
};
result.push(val as f64);
}
}
_ => {
return Err(MedicalError::Nifti(format!(
"Unsupported NIfTI data type: {}",
header.datatype.name()
)));
}
}
Ok(result)
}
/// Read only the NIfTI header without loading voxel data.
///
/// Useful for quickly checking image metadata without loading the full volume.
pub fn read_nifti_header<P: AsRef<Path>>(path: P) -> MedicalResult<NiftiHeader> {
let path = path.as_ref();
if !path.exists() {
return Err(MedicalError::Nifti(format!(
"File not found: {}",
path.display()
)));
}
let path_str = path.to_string_lossy().to_lowercase();
let is_gzipped = path_str.ends_with(".nii.gz") || path_str.ends_with(".gz");
if is_gzipped {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut decoder = GzDecoder::new(reader);
// Only read enough for the header (540 bytes covers both NIfTI-1 and NIfTI-2)
let mut data = vec![0u8; 540];
decoder
.read_exact(&mut data)
.map_err(|e| MedicalError::Nifti(format!("Failed to decompress header: {}", e)))?;
let mut cursor = Cursor::new(data);
NiftiHeader::read(&mut cursor)
} else {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
NiftiHeader::read(&mut reader)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_gzip() {
assert!("test.nii.gz".to_lowercase().ends_with(".nii.gz"));
assert!("TEST.NII.GZ".to_lowercase().ends_with(".nii.gz"));
assert!(!"test.nii".to_lowercase().ends_with(".nii.gz"));
}
#[test]
fn test_nifti_volume_shape() {
let header = NiftiHeader {
dim: [3, 64, 64, 32, 1, 1, 1, 1],
..Default::default()
};
let volume = NiftiVolume {
data: vec![0.0; 64 * 64 * 32],
header,
};
assert_eq!(volume.shape(), (64, 64, 32));
}
}
@@ -0,0 +1,382 @@
//! Affine transformation utilities for medical imaging.
//!
//! Provides functions for working with 4x4 affine transformation matrices
//! used in NIfTI files to map voxel coordinates to world coordinates.
use nalgebra::{Matrix4, Point3, Vector3};
/// A 4x4 affine transformation matrix.
pub type Affine4 = [[f64; 4]; 4];
/// Convert array-based affine to nalgebra Matrix4
pub fn to_matrix4(affine: &Affine4) -> Matrix4<f64> {
Matrix4::from_row_slice(&[
affine[0][0],
affine[0][1],
affine[0][2],
affine[0][3],
affine[1][0],
affine[1][1],
affine[1][2],
affine[1][3],
affine[2][0],
affine[2][1],
affine[2][2],
affine[2][3],
affine[3][0],
affine[3][1],
affine[3][2],
affine[3][3],
])
}
/// Convert nalgebra Matrix4 to array-based affine
pub fn from_matrix4(mat: &Matrix4<f64>) -> Affine4 {
[
[mat[(0, 0)], mat[(0, 1)], mat[(0, 2)], mat[(0, 3)]],
[mat[(1, 0)], mat[(1, 1)], mat[(1, 2)], mat[(1, 3)]],
[mat[(2, 0)], mat[(2, 1)], mat[(2, 2)], mat[(2, 3)]],
[mat[(3, 0)], mat[(3, 1)], mat[(3, 2)], mat[(3, 3)]],
]
}
/// Create an identity affine transformation
pub fn identity() -> Affine4 {
[
[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],
]
}
/// Create a scaling affine transformation
pub fn scaling(sx: f64, sy: f64, sz: f64) -> Affine4 {
[
[sx, 0.0, 0.0, 0.0],
[0.0, sy, 0.0, 0.0],
[0.0, 0.0, sz, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
}
/// Create a translation affine transformation
pub fn translation(tx: f64, ty: f64, tz: f64) -> Affine4 {
[
[1.0, 0.0, 0.0, tx],
[0.0, 1.0, 0.0, ty],
[0.0, 0.0, 1.0, tz],
[0.0, 0.0, 0.0, 1.0],
]
}
/// Create an affine from spacing and origin
pub fn from_spacing_origin(spacing: [f64; 3], origin: [f64; 3]) -> Affine4 {
[
[spacing[0], 0.0, 0.0, origin[0]],
[0.0, spacing[1], 0.0, origin[1]],
[0.0, 0.0, spacing[2], origin[2]],
[0.0, 0.0, 0.0, 1.0],
]
}
/// Compose two affine transformations: result = a * b
pub fn compose(a: &Affine4, b: &Affine4) -> Affine4 {
let ma = to_matrix4(a);
let mb = to_matrix4(b);
from_matrix4(&(ma * mb))
}
/// Invert an affine transformation
pub fn inverse(affine: &Affine4) -> Option<Affine4> {
let mat = to_matrix4(affine);
mat.try_inverse().map(|inv| from_matrix4(&inv))
}
/// Transform a 3D point using an affine matrix
pub fn transform_point(affine: &Affine4, point: [f64; 3]) -> [f64; 3] {
let mat = to_matrix4(affine);
let p = Point3::new(point[0], point[1], point[2]);
let transformed = mat.transform_point(&p);
[transformed.x, transformed.y, transformed.z]
}
/// Transform a 3D vector using an affine matrix (ignores translation)
pub fn transform_vector(affine: &Affine4, vector: [f64; 3]) -> [f64; 3] {
let mat = to_matrix4(affine);
let v = Vector3::new(vector[0], vector[1], vector[2]);
// Extract rotation/scaling part (upper-left 3x3)
let rotated = mat.fixed_view::<3, 3>(0, 0) * v;
[rotated.x, rotated.y, rotated.z]
}
/// Convert voxel indices to world coordinates
pub fn voxel_to_world(affine: &Affine4, voxel: [f64; 3]) -> [f64; 3] {
transform_point(affine, voxel)
}
/// Convert world coordinates to voxel indices
pub fn world_to_voxel(affine: &Affine4, world: [f64; 3]) -> Option<[f64; 3]> {
inverse(affine).map(|inv| transform_point(&inv, world))
}
/// Extract the origin (translation component) from an affine
pub fn get_origin(affine: &Affine4) -> [f64; 3] {
[affine[0][3], affine[1][3], affine[2][3]]
}
/// Extract the voxel spacing from an affine (assuming no shear)
pub fn get_spacing(affine: &Affine4) -> [f64; 3] {
[
(affine[0][0].powi(2) + affine[1][0].powi(2) + affine[2][0].powi(2)).sqrt(),
(affine[0][1].powi(2) + affine[1][1].powi(2) + affine[2][1].powi(2)).sqrt(),
(affine[0][2].powi(2) + affine[1][2].powi(2) + affine[2][2].powi(2)).sqrt(),
]
}
/// Extract the rotation matrix from an affine (normalized)
pub fn get_rotation(affine: &Affine4) -> [[f64; 3]; 3] {
let spacing = get_spacing(affine);
[
[
affine[0][0] / spacing[0],
affine[0][1] / spacing[1],
affine[0][2] / spacing[2],
],
[
affine[1][0] / spacing[0],
affine[1][1] / spacing[1],
affine[1][2] / spacing[2],
],
[
affine[2][0] / spacing[0],
affine[2][1] / spacing[1],
affine[2][2] / spacing[2],
],
]
}
/// Check if two affines are approximately equal
pub fn approx_equal(a: &Affine4, b: &Affine4, epsilon: f64) -> bool {
for i in 0..4 {
for j in 0..4 {
if (a[i][j] - b[i][j]).abs() > epsilon {
return false;
}
}
}
true
}
/// Convert affine to a flat row-major array (16 elements)
pub fn to_flat(affine: &Affine4) -> [f64; 16] {
[
affine[0][0],
affine[0][1],
affine[0][2],
affine[0][3],
affine[1][0],
affine[1][1],
affine[1][2],
affine[1][3],
affine[2][0],
affine[2][1],
affine[2][2],
affine[2][3],
affine[3][0],
affine[3][1],
affine[3][2],
affine[3][3],
]
}
/// Create affine from a flat row-major array
pub fn from_flat(flat: &[f64; 16]) -> Affine4 {
[
[flat[0], flat[1], flat[2], flat[3]],
[flat[4], flat[5], flat[6], flat[7]],
[flat[8], flat[9], flat[10], flat[11]],
[flat[12], flat[13], flat[14], flat[15]],
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity() {
let id = identity();
let point = [1.0, 2.0, 3.0];
let result = transform_point(&id, point);
assert_eq!(result, point);
}
#[test]
fn test_translation() {
let trans = translation(10.0, 20.0, 30.0);
let point = [1.0, 2.0, 3.0];
let result = transform_point(&trans, point);
assert_eq!(result, [11.0, 22.0, 33.0]);
}
#[test]
fn test_scaling() {
let scale = scaling(2.0, 3.0, 4.0);
let point = [1.0, 1.0, 1.0];
let result = transform_point(&scale, point);
assert_eq!(result, [2.0, 3.0, 4.0]);
}
#[test]
fn test_compose() {
let scale = scaling(2.0, 2.0, 2.0);
let trans = translation(1.0, 1.0, 1.0);
let composed = compose(&trans, &scale); // First scale, then translate
let point = [1.0, 1.0, 1.0];
let result = transform_point(&composed, point);
assert_eq!(result, [3.0, 3.0, 3.0]); // 1*2 + 1 = 3
}
#[test]
fn test_inverse() {
let trans = translation(10.0, 20.0, 30.0);
let inv = inverse(&trans).unwrap();
let composed = compose(&trans, &inv);
assert!(approx_equal(&composed, &identity(), 1e-10));
}
#[test]
fn test_get_spacing() {
let affine = scaling(1.5, 2.0, 0.5);
let spacing = get_spacing(&affine);
assert!((spacing[0] - 1.5).abs() < 1e-10);
assert!((spacing[1] - 2.0).abs() < 1e-10);
assert!((spacing[2] - 0.5).abs() < 1e-10);
}
#[test]
fn test_voxel_to_world() {
let affine = from_spacing_origin([2.0, 2.0, 2.0], [10.0, 20.0, 30.0]);
let world = voxel_to_world(&affine, [5.0, 5.0, 5.0]);
assert_eq!(world, [20.0, 30.0, 40.0]); // 5*2 + 10 = 20, etc.
}
#[test]
fn test_world_to_voxel() {
let affine = from_spacing_origin([2.0, 2.0, 2.0], [10.0, 20.0, 30.0]);
let voxel = world_to_voxel(&affine, [20.0, 30.0, 40.0]).unwrap();
assert!((voxel[0] - 5.0).abs() < 1e-10);
assert!((voxel[1] - 5.0).abs() < 1e-10);
assert!((voxel[2] - 5.0).abs() < 1e-10);
}
#[test]
fn test_get_origin() {
let affine = from_spacing_origin([1.0, 1.0, 1.0], [5.0, 10.0, 15.0]);
let origin = get_origin(&affine);
assert_eq!(origin, [5.0, 10.0, 15.0]);
}
#[test]
fn test_from_spacing_origin() {
let spacing = [2.0, 3.0, 4.0];
let origin = [10.0, 20.0, 30.0];
let affine = from_spacing_origin(spacing, origin);
assert_eq!(affine[0][0], 2.0);
assert_eq!(affine[1][1], 3.0);
assert_eq!(affine[2][2], 4.0);
assert_eq!(affine[0][3], 10.0);
assert_eq!(affine[1][3], 20.0);
assert_eq!(affine[2][3], 30.0);
assert_eq!(affine[3][3], 1.0);
}
#[test]
fn test_transform_vector() {
let scale = scaling(2.0, 3.0, 4.0);
let vector = [1.0, 1.0, 1.0];
let result = transform_vector(&scale, vector);
assert_eq!(result, [2.0, 3.0, 4.0]);
}
#[test]
fn test_transform_vector_ignores_translation() {
let trans = translation(10.0, 20.0, 30.0);
let vector = [1.0, 2.0, 3.0];
let result = transform_vector(&trans, vector);
assert_eq!(result, vector); // Translation should not affect vectors
}
#[test]
fn test_approx_equal_identical() {
let a = identity();
let b = identity();
assert!(approx_equal(&a, &b, 1e-10));
}
#[test]
fn test_approx_equal_different() {
let a = identity();
let mut b = identity();
b[0][0] = 2.0;
assert!(!approx_equal(&a, &b, 1e-10));
}
#[test]
fn test_approx_equal_within_epsilon() {
let a = identity();
let mut b = identity();
b[0][0] = 1.0 + 1e-11;
assert!(approx_equal(&a, &b, 1e-10));
}
#[test]
fn test_to_flat() {
let affine = identity();
let flat = to_flat(&affine);
assert_eq!(flat[0], 1.0);
assert_eq!(flat[5], 1.0);
assert_eq!(flat[10], 1.0);
assert_eq!(flat[15], 1.0);
assert_eq!(flat[1], 0.0);
}
#[test]
fn test_from_flat() {
let flat = [
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,
];
let affine = from_flat(&flat);
assert!(approx_equal(&affine, &identity(), 1e-10));
}
#[test]
fn test_flat_round_trip() {
let original = from_spacing_origin([2.0, 3.0, 4.0], [10.0, 20.0, 30.0]);
let flat = to_flat(&original);
let restored = from_flat(&flat);
assert!(approx_equal(&original, &restored, 1e-10));
}
#[test]
fn test_get_rotation() {
let affine = scaling(2.0, 3.0, 4.0);
let rotation = get_rotation(&affine);
// For pure scaling, rotation should be identity
assert!((rotation[0][0] - 1.0).abs() < 1e-10);
assert!((rotation[1][1] - 1.0).abs() < 1e-10);
assert!((rotation[2][2] - 1.0).abs() < 1e-10);
assert!((rotation[0][1]).abs() < 1e-10);
}
#[test]
fn test_matrix4_conversion_round_trip() {
let original = from_spacing_origin([1.5, 2.0, 2.5], [5.0, 10.0, 15.0]);
let matrix = to_matrix4(&original);
let restored = from_matrix4(&matrix);
assert!(approx_equal(&original, &restored, 1e-10));
}
}
@@ -0,0 +1,441 @@
//! NIfTI file writer supporting .nii and .nii.gz formats.
use crate::error::{MedicalError, MedicalResult};
use crate::nifti::header::{NiftiDataType, NiftiHeader, SpatialUnits, TransformCode};
use byteorder::{LittleEndian, WriteBytesExt};
use flate2::Compression;
use flate2::write::GzEncoder;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
/// Write voxel data to a NIfTI file.
///
/// The output format is determined by the file extension:
/// - `.nii` - uncompressed NIfTI-1
/// - `.nii.gz` - gzip-compressed NIfTI-1
///
/// # Arguments
/// * `data` - Voxel data in row-major order (z varies slowest, x varies fastest)
/// * `shape` - Volume dimensions (x, y, z)
/// * `spacing` - Voxel spacing in mm (dx, dy, dz)
/// * `affine` - 4x4 affine transformation matrix
/// * `path` - Output file path
/// * `datatype` - Output data type (e.g., Float32, Int16)
///
/// # Example
/// ```ignore
/// use rtx_medical_core::nifti::{write_nifti, NiftiDataType};
///
/// let data = vec![0.0; 64 * 64 * 64];
/// let shape = (64, 64, 64);
/// let spacing = (1.0, 1.0, 1.0);
/// let affine = [[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]];
/// write_nifti(&data, shape, spacing, &affine, "output.nii.gz", NiftiDataType::Float32)?;
/// ```
pub fn write_nifti<P: AsRef<Path>>(
data: &[f64],
shape: (usize, usize, usize),
spacing: (f64, f64, f64),
affine: &[[f64; 4]; 4],
path: P,
datatype: NiftiDataType,
) -> MedicalResult<()> {
let path = path.as_ref();
let path_str = path.to_string_lossy().to_lowercase();
let is_gzipped = path_str.ends_with(".nii.gz") || path_str.ends_with(".gz");
// Build header
let header = build_header(shape, spacing, affine, datatype);
// Validate data length
let expected_len = shape.0 * shape.1 * shape.2;
if data.len() != expected_len {
return Err(MedicalError::DimensionMismatch {
expected: format!("{} voxels", expected_len),
got: format!("{} voxels", data.len()),
});
}
// Convert data to target type
let raw_data = convert_from_f64(data, datatype)?;
if is_gzipped {
write_nifti_gz(path, &header, &raw_data)
} else {
write_nifti_uncompressed(path, &header, &raw_data)
}
}
/// Write a gzip-compressed NIfTI file
fn write_nifti_gz<P: AsRef<Path>>(path: P, header: &NiftiHeader, data: &[u8]) -> MedicalResult<()> {
let file = File::create(path)?;
let writer = BufWriter::new(file);
let mut encoder = GzEncoder::new(writer, Compression::default());
write_header(&mut encoder, header)?;
encoder.write_all(data)?;
encoder
.finish()
.map_err(|e| MedicalError::Nifti(format!("Failed to finish gzip compression: {}", e)))?;
Ok(())
}
/// Write an uncompressed NIfTI file
fn write_nifti_uncompressed<P: AsRef<Path>>(
path: P,
header: &NiftiHeader,
data: &[u8],
) -> MedicalResult<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
write_header(&mut writer, header)?;
writer.write_all(data)?;
writer.flush()?;
Ok(())
}
/// Build a NIfTI-1 header from volume metadata
fn build_header(
shape: (usize, usize, usize),
spacing: (f64, f64, f64),
affine: &[[f64; 4]; 4],
datatype: NiftiDataType,
) -> NiftiHeader {
NiftiHeader {
sizeof_hdr: 348,
datatype,
bitpix: (datatype.bytes_per_voxel() * 8) as i16,
dim: [
3,
shape.0 as i64,
shape.1 as i64,
shape.2 as i64,
1,
1,
1,
1,
],
intent_p1: 0.0,
intent_p2: 0.0,
intent_p3: 0.0,
intent_code: 0,
pixdim: [1.0, spacing.0, spacing.1, spacing.2, 1.0, 1.0, 1.0, 1.0],
vox_offset: 352,
scl_slope: 1.0,
scl_inter: 0.0,
slice_code: 0,
xyzt_units: SpatialUnits::Millimeters as u8,
cal_max: 0.0,
cal_min: 0.0,
slice_duration: 0.0,
toffset: 0.0,
slice_start: 0,
slice_end: 0,
descrip: "Created by rtx-medical-core".to_string(),
aux_file: String::new(),
qform_code: TransformCode::Unknown,
sform_code: TransformCode::ScannerAnat,
quatern_b: 0.0,
quatern_c: 0.0,
quatern_d: 0.0,
qoffset_x: 0.0,
qoffset_y: 0.0,
qoffset_z: 0.0,
srow_x: affine[0],
srow_y: affine[1],
srow_z: affine[2],
intent_name: String::new(),
magic: *b"n+1\0\0\0\0\0",
is_nifti2: false,
little_endian: true,
}
}
/// Write NIfTI-1 header (348 bytes + 4 bytes padding = 352 bytes)
fn write_header<W: Write>(writer: &mut W, header: &NiftiHeader) -> MedicalResult<()> {
// sizeof_hdr (0-3)
writer.write_i32::<LittleEndian>(header.sizeof_hdr)?;
// data_type (unused, 4-13) - 10 bytes
writer.write_all(&[0u8; 10])?;
// db_name (unused, 14-31) - 18 bytes
writer.write_all(&[0u8; 18])?;
// extents (unused, 32-35)
writer.write_i32::<LittleEndian>(0)?;
// session_error (unused, 36-37)
writer.write_i16::<LittleEndian>(0)?;
// regular (unused, 38)
writer.write_all(&[0u8; 1])?;
// dim_info (unused, 39)
writer.write_all(&[0u8; 1])?;
// dim (40-55) - 8 x i16
for i in 0..8 {
writer.write_i16::<LittleEndian>(header.dim[i] as i16)?;
}
// intent_p1 (56-59)
writer.write_f32::<LittleEndian>(header.intent_p1 as f32)?;
// intent_p2 (60-63)
writer.write_f32::<LittleEndian>(header.intent_p2 as f32)?;
// intent_p3 (64-67)
writer.write_f32::<LittleEndian>(header.intent_p3 as f32)?;
// intent_code (68-69)
writer.write_i16::<LittleEndian>(header.intent_code)?;
// datatype (70-71)
writer.write_i16::<LittleEndian>(header.datatype as i16)?;
// bitpix (72-73)
writer.write_i16::<LittleEndian>(header.bitpix)?;
// slice_start (74-75)
writer.write_i16::<LittleEndian>(header.slice_start as i16)?;
// pixdim (76-107) - 8 x f32
for i in 0..8 {
writer.write_f32::<LittleEndian>(header.pixdim[i] as f32)?;
}
// vox_offset (108-111)
writer.write_f32::<LittleEndian>(header.vox_offset as f32)?;
// scl_slope (112-115)
writer.write_f32::<LittleEndian>(header.scl_slope as f32)?;
// scl_inter (116-119)
writer.write_f32::<LittleEndian>(header.scl_inter as f32)?;
// slice_end (120-121)
writer.write_i16::<LittleEndian>(header.slice_end as i16)?;
// slice_code (122)
writer.write_all(&[header.slice_code])?;
// xyzt_units (123)
writer.write_all(&[header.xyzt_units])?;
// cal_max (124-127)
writer.write_f32::<LittleEndian>(header.cal_max as f32)?;
// cal_min (128-131)
writer.write_f32::<LittleEndian>(header.cal_min as f32)?;
// slice_duration (132-135)
writer.write_f32::<LittleEndian>(header.slice_duration as f32)?;
// toffset (136-139)
writer.write_f32::<LittleEndian>(header.toffset as f32)?;
// glmax (unused, 140-143)
writer.write_i32::<LittleEndian>(0)?;
// glmin (unused, 144-147)
writer.write_i32::<LittleEndian>(0)?;
// descrip (148-227) - 80 bytes
let descrip_bytes = header.descrip.as_bytes();
let mut descrip_buf = [0u8; 80];
let len = descrip_bytes.len().min(80);
descrip_buf[..len].copy_from_slice(&descrip_bytes[..len]);
writer.write_all(&descrip_buf)?;
// aux_file (228-251) - 24 bytes
let aux_bytes = header.aux_file.as_bytes();
let mut aux_buf = [0u8; 24];
let len = aux_bytes.len().min(24);
aux_buf[..len].copy_from_slice(&aux_bytes[..len]);
writer.write_all(&aux_buf)?;
// qform_code (252-253)
writer.write_i16::<LittleEndian>(header.qform_code as i16)?;
// sform_code (254-255)
writer.write_i16::<LittleEndian>(header.sform_code as i16)?;
// quatern_b (256-259)
writer.write_f32::<LittleEndian>(header.quatern_b as f32)?;
// quatern_c (260-263)
writer.write_f32::<LittleEndian>(header.quatern_c as f32)?;
// quatern_d (264-267)
writer.write_f32::<LittleEndian>(header.quatern_d as f32)?;
// qoffset_x (268-271)
writer.write_f32::<LittleEndian>(header.qoffset_x as f32)?;
// qoffset_y (272-275)
writer.write_f32::<LittleEndian>(header.qoffset_y as f32)?;
// qoffset_z (276-279)
writer.write_f32::<LittleEndian>(header.qoffset_z as f32)?;
// srow_x (280-295) - 4 x f32
for val in &header.srow_x {
writer.write_f32::<LittleEndian>(*val as f32)?;
}
// srow_y (296-311) - 4 x f32
for val in &header.srow_y {
writer.write_f32::<LittleEndian>(*val as f32)?;
}
// srow_z (312-327) - 4 x f32
for val in &header.srow_z {
writer.write_f32::<LittleEndian>(*val as f32)?;
}
// intent_name (328-343) - 16 bytes
let intent_bytes = header.intent_name.as_bytes();
let mut intent_buf = [0u8; 16];
let len = intent_bytes.len().min(16);
intent_buf[..len].copy_from_slice(&intent_bytes[..len]);
writer.write_all(&intent_buf)?;
// magic (344-347) - 4 bytes for NIfTI-1
writer.write_all(&header.magic[..4])?;
// Padding to vox_offset (348-351) - 4 bytes
writer.write_all(&[0u8; 4])?;
Ok(())
}
/// Convert f64 data to raw bytes for the target data type
fn convert_from_f64(data: &[f64], datatype: NiftiDataType) -> MedicalResult<Vec<u8>> {
let mut result = Vec::with_capacity(data.len() * datatype.bytes_per_voxel());
match datatype {
NiftiDataType::UInt8 => {
for &val in data {
result.push(val.clamp(0.0, 255.0) as u8);
}
}
NiftiDataType::Int8 => {
for &val in data {
result.push(val.clamp(-128.0, 127.0) as i8 as u8);
}
}
NiftiDataType::Int16 => {
for &val in data {
let v = val.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
result.write_i16::<LittleEndian>(v)?;
}
}
NiftiDataType::UInt16 => {
for &val in data {
let v = val.clamp(0.0, u16::MAX as f64) as u16;
result.write_u16::<LittleEndian>(v)?;
}
}
NiftiDataType::Int32 => {
for &val in data {
let v = val.clamp(i32::MIN as f64, i32::MAX as f64) as i32;
result.write_i32::<LittleEndian>(v)?;
}
}
NiftiDataType::UInt32 => {
for &val in data {
let v = val.clamp(0.0, u32::MAX as f64) as u32;
result.write_u32::<LittleEndian>(v)?;
}
}
NiftiDataType::Float32 => {
for &val in data {
result.write_f32::<LittleEndian>(val as f32)?;
}
}
NiftiDataType::Float64 => {
for &val in data {
result.write_f64::<LittleEndian>(val)?;
}
}
NiftiDataType::Int64 => {
for &val in data {
let v = val.clamp(i64::MIN as f64, i64::MAX as f64) as i64;
result.write_i64::<LittleEndian>(v)?;
}
}
NiftiDataType::UInt64 => {
for &val in data {
let v = val.clamp(0.0, u64::MAX as f64) as u64;
result.write_u64::<LittleEndian>(v)?;
}
}
_ => {
return Err(MedicalError::Nifti(format!(
"Unsupported NIfTI data type for writing: {}",
datatype.name()
)));
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_header() {
let shape = (64, 64, 32);
let spacing = (1.0, 1.0, 2.0);
let affine = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 2.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let header = build_header(shape, spacing, &affine, NiftiDataType::Float32);
assert_eq!(header.sizeof_hdr, 348);
assert_eq!(header.dim[1], 64);
assert_eq!(header.dim[2], 64);
assert_eq!(header.dim[3], 32);
assert_eq!(header.datatype, NiftiDataType::Float32);
assert_eq!(header.bitpix, 32);
}
#[test]
fn test_convert_uint8() {
let data = vec![0.0, 127.5, 255.0, 300.0, -10.0];
let result = convert_from_f64(&data, NiftiDataType::UInt8).unwrap();
// 127.5 truncates to 127 (not rounded)
assert_eq!(result, vec![0, 127, 255, 255, 0]);
}
#[test]
fn test_convert_float32() {
let data = vec![1.5, 2.7, 3.9];
let result = convert_from_f64(&data, NiftiDataType::Float32).unwrap();
assert_eq!(result.len(), 12); // 3 values * 4 bytes each
}
}