Initial commit
This commit is contained in:
@@ -0,0 +1,834 @@
|
||||
//! 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::{MedicalIoError, Result};
|
||||
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 {
|
||||
#[default]
|
||||
Unknown,
|
||||
Meters,
|
||||
Millimeters,
|
||||
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 {
|
||||
#[default]
|
||||
Unknown,
|
||||
Seconds,
|
||||
Milliseconds,
|
||||
Microseconds,
|
||||
Hertz,
|
||||
Ppm,
|
||||
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,
|
||||
pub intent_p2: f64,
|
||||
pub intent_p3: f64,
|
||||
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,
|
||||
pub quatern_c: f64,
|
||||
pub quatern_d: f64,
|
||||
pub qoffset_x: f64,
|
||||
pub qoffset_y: f64,
|
||||
pub qoffset_z: f64,
|
||||
|
||||
/// Affine matrix rows for sform
|
||||
pub srow_x: [f64; 4],
|
||||
pub srow_y: [f64; 4],
|
||||
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) -> Result<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(MedicalIoError::InvalidHeaderSize(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) -> Result<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(MedicalIoError::UnsupportedDataType(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(MedicalIoError::InvalidNiftiMagic(magic[..4].to_vec()));
|
||||
}
|
||||
|
||||
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) -> Result<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(MedicalIoError::InvalidNiftiMagic(magic.to_vec()));
|
||||
}
|
||||
|
||||
// datatype at 12
|
||||
let datatype_code = read_i16(12);
|
||||
let datatype = NiftiDataType::from_code(datatype_code)
|
||||
.ok_or(MedicalIoError::UnsupportedDataType(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,42 @@
|
||||
//! 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_io::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, "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::{read_nifti, read_nifti_header};
|
||||
pub use transform::{
|
||||
Affine4, 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_inr, write_nifti};
|
||||
@@ -0,0 +1,273 @@
|
||||
//! NIfTI file reader supporting .nii and .nii.gz formats.
|
||||
|
||||
use crate::error::{MedicalIoError, Result};
|
||||
use crate::nifti::header::{NiftiDataType, NiftiHeader};
|
||||
use crate::volume::Volume;
|
||||
use byteorder::{ByteOrder, LittleEndian};
|
||||
use flate2::read::GzDecoder;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
/// Read a NIfTI file from disk.
|
||||
///
|
||||
/// Supports both .nii and .nii.gz files. The data is returned as a `Volume`
|
||||
/// with the voxel data converted to f64.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Path to the NIfTI file
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Volume` containing the image data and metadata
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use rtx_medical_io::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) -> Result<Volume> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if !path.exists() {
|
||||
return Err(MedicalIoError::FileNotFound(path.display().to_string()));
|
||||
}
|
||||
|
||||
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(MedicalIoError::InvalidExtension(
|
||||
path.extension()
|
||||
.map(|e| e.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a gzip-compressed NIfTI file (.nii.gz)
|
||||
fn read_nifti_gz<P: AsRef<Path>>(path: P) -> Result<Volume> {
|
||||
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| MedicalIoError::Decompression(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) -> Result<Volume> {
|
||||
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) -> Result<Volume> {
|
||||
// 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
|
||||
};
|
||||
|
||||
// Create volume
|
||||
let (x, y, z) = header.shape();
|
||||
let (dx, dy, dz) = header.spacing();
|
||||
let (ox, oy, oz) = header.origin();
|
||||
|
||||
Ok(Volume::new(
|
||||
data,
|
||||
[x, y, z],
|
||||
[dx, dy, dz],
|
||||
[ox, oy, oz],
|
||||
header.affine(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert raw bytes to f64 based on NIfTI data type
|
||||
fn convert_to_f64(raw: &[u8], header: &NiftiHeader) -> Result<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(MedicalIoError::UnsupportedDataType(header.datatype as i16));
|
||||
}
|
||||
}
|
||||
|
||||
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) -> Result<NiftiHeader> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if !path.exists() {
|
||||
return Err(MedicalIoError::FileNotFound(path.display().to_string()));
|
||||
}
|
||||
|
||||
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| {
|
||||
MedicalIoError::Decompression(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 {
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! 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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! NIfTI file writer supporting .nii and .nii.gz formats.
|
||||
|
||||
use crate::error::{MedicalIoError, Result};
|
||||
use crate::nifti::header::{NiftiDataType, NiftiHeader, SpatialUnits, TransformCode};
|
||||
use crate::volume::Volume;
|
||||
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 a Volume to a NIfTI file.
|
||||
///
|
||||
/// The output format is determined by the file extension:
|
||||
/// - `.nii` - uncompressed NIfTI-1
|
||||
/// - `.nii.gz` - gzip-compressed NIfTI-1
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `volume` - The volume to write
|
||||
/// * `path` - Output file path
|
||||
/// * `datatype` - Output data type (default: Float64)
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use rtx_medical_io::nifti::{write_nifti, NiftiDataType};
|
||||
/// use rtx_medical_io::volume::Volume;
|
||||
///
|
||||
/// let volume = Volume::zeros([64, 64, 64]);
|
||||
/// write_nifti(&volume, "output.nii.gz", NiftiDataType::Float32)?;
|
||||
/// ```
|
||||
pub fn write_nifti<P: AsRef<Path>>(
|
||||
volume: &Volume,
|
||||
path: P,
|
||||
datatype: NiftiDataType,
|
||||
) -> Result<()> {
|
||||
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(volume, datatype);
|
||||
|
||||
// Convert data to target type
|
||||
let raw_data = convert_from_f64(volume.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]) -> Result<()> {
|
||||
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| {
|
||||
MedicalIoError::Compression(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],
|
||||
) -> Result<()> {
|
||||
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 a Volume
|
||||
fn build_header(volume: &Volume, datatype: NiftiDataType) -> NiftiHeader {
|
||||
let [x, y, z] = volume.shape();
|
||||
let [dx, dy, dz] = volume.spacing();
|
||||
let affine = volume.affine();
|
||||
|
||||
NiftiHeader {
|
||||
sizeof_hdr: 348,
|
||||
datatype,
|
||||
bitpix: (datatype.bytes_per_voxel() * 8) as i16,
|
||||
dim: [3, x as i64, y as i64, z as i64, 1, 1, 1, 1],
|
||||
intent_p1: 0.0,
|
||||
intent_p2: 0.0,
|
||||
intent_p3: 0.0,
|
||||
intent_code: 0,
|
||||
pixdim: [1.0, dx, dy, dz, 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-io".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) -> Result<()> {
|
||||
// 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) -> Result<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(MedicalIoError::UnsupportedDataType(datatype as i16));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Write a Volume to an INR file (INRIMAGE-4 format).
|
||||
///
|
||||
/// This format is used internally by CGAL for mesh generation.
|
||||
/// It's a simple headerless format with a text header followed by raw binary data.
|
||||
pub fn write_inr<P: AsRef<Path>>(volume: &Volume, path: P) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
let [xdim, ydim, zdim] = volume.shape();
|
||||
let [vx, vy, vz] = volume.spacing();
|
||||
|
||||
// Determine data type and bit length
|
||||
let (btype, bitlen) = ("float", 64);
|
||||
|
||||
// Build header
|
||||
let mut header = format!(
|
||||
"#INRIMAGE-4#{{\n\
|
||||
XDIM={}\n\
|
||||
YDIM={}\n\
|
||||
ZDIM={}\n\
|
||||
VDIM=1\n\
|
||||
TYPE={}\n\
|
||||
PIXSIZE={} bits\n\
|
||||
CPU=decm\n\
|
||||
VX={:.6}\n\
|
||||
VY={:.6}\n\
|
||||
VZ={:.6}\n",
|
||||
xdim, ydim, zdim, btype, bitlen, vx, vy, vz
|
||||
);
|
||||
|
||||
// Pad header to 256 - 4 bytes, then add closing tag
|
||||
let target_len = 256 - 4;
|
||||
while header.len() < target_len {
|
||||
header.push('\n');
|
||||
}
|
||||
header.push_str("##}\n");
|
||||
|
||||
// Write header
|
||||
writer.write_all(header.as_bytes())?;
|
||||
|
||||
// Write data in Fortran order (column-major)
|
||||
let data = volume.data();
|
||||
for &val in data {
|
||||
writer.write_f64::<LittleEndian>(val)?;
|
||||
}
|
||||
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_header() {
|
||||
let volume = Volume::zeros([64, 64, 32]);
|
||||
let header = build_header(&volume, 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user