2. 医学影像数据格式与读取
在 nnU-Net 的工程链路里,数据格式与读取不是“顺手做一下”的前置步骤,而是决定后续预处理、训练和推理是否正确的根基。医学影像常见的三种格式——DICOM、NIfTI、NRRD——各自承载的信息结构不同,读取工具的行为也不同。如果在这一步把方向、spacing 或原点理解错,后面所有基于体素的操作都会系统性偏移。
三种格式的定位与结构差异
DICOM 是临床设备输出的原始格式,通常一个序列由成百上千个文件组成,每个文件对应一层切片。它把像素数据放在 PixelData 标签里,把几何信息分散在 ImagePositionPatient、ImageOrientationPatient、PixelSpacing、SliceThickness、SpacingBetweenSlices 等标签中。DICOM 的强项是保留采集参数和患者信息,弱项是几何信息分散、层间距可能不均匀,且不同厂商的私有标签会带来兼容问题。
NIfTI 是神经影像与科研分割中最常见的格式,通常一个 .nii 或 .nii.gz 文件就包含完整三维体数据。它的几何信息集中在头信息里:pixdim 记录体素尺寸,qform 和 sform 记录体素坐标到世界坐标的仿射变换。NIfTI 的强项是单文件、几何紧凑,弱项是 qform 与 sform 可能不一致,需要明确以哪个为准。
NRRD 常用于医学影像处理工具链,支持多通道、多组件和自定义元数据,头信息以键值对形式保存,空间方向通过 space directions 和 space origin 描述。它在保留方向信息方面比较直观,适合作为中间交换格式。
SimpleITK 与 nibabel 的读取差异
SimpleITK 读取后返回的是 sitk.Image,其数组索引顺序是 (z, y, x),而 NumPy 习惯的数组顺序是 (x, y, z)。因此从 SimpleITK 转到 NumPy 时,必须用 GetArrayFromImage(),它会把轴顺序调整为 (z, y, x) 对应的 NumPy 数组;如果直接用 GetArrayViewFromImage() 再手动转置,容易在后续处理中把轴顺序弄反。SimpleITK 的 GetSpacing() 返回的是 (x, y, z) 方向的体素尺寸,GetOrigin() 返回世界坐标原点,GetDirection() 返回方向余弦矩阵。
nibabel 读取 NIfTI 后返回 Nifti1Image,通过 get_fdata() 得到 NumPy 数组,轴顺序天然是 (x, y, z)。它的 header.get_zooms() 返回体素尺寸,affine 属性给出 4×4 仿射矩阵,把体素索引映射到世界坐标。nibabel 不直接提供“方向余弦”概念,而是通过 affine 的前 3×3 子矩阵表达旋转、缩放和剪切。
两者最关键的差异在于:SimpleITK 把方向、spacing、origin 分开存储,nibabel 把它们统一进 affine。理解这一点,才能在两种工具之间安全转换。
方向与 spacing 的处理原则
方向处理的核心是“世界坐标一致性”。DICOM 的 ImageOrientationPatient 给出图像行、列方向余弦,ImagePositionPatient 给出每层左上角体素的世界坐标。SimpleITK 读取 DICOM 序列后,会把这些信息整合成 direction、spacing、origin 三件套。NIfTI 的 affine 则把这三者合并成一个矩阵。无论用哪种工具,最终都要回答同一个问题:体素索引 (i, j, k) 对应的世界坐标是什么。
spacing 处理要特别注意顺序。SimpleITK 的 GetSpacing() 返回 (sx, sy, sz),对应数组的 x、y、z 轴;而 GetArrayFromImage() 得到的数组轴顺序是 (z, y, x),所以数组第 0 轴对应的是 sz,第 2 轴对应的是 sx。nnU-Net 在预处理时会统一重采样到目标 spacing,如果这里把 spacing 顺序弄反,重采样后的解剖比例就会失真。
一个实用的检查方法是:读取后打印 image.GetSize()、image.GetSpacing()、image.GetDirection(),再用 GetArrayFromImage() 打印数组 shape,确认 shape 与 size 的轴顺序对应关系。对于 NIfTI,打印 img.shape、img.header.get_zooms() 和 img.affine,检查 affine 对角线元素是否与 zooms 一致(允许旋转和剪切时非对角元素不为零)。
实际读取示例
下面给出一个可参考的读取与几何信息检查片段,示例参数为合理假设,实际运行时以数据为准。
import SimpleITK as sitk
import nibabel as nib
import numpy as np
# SimpleITK 读取 NIfTI
sitk_img = sitk.ReadImage("case_001.nii.gz")
arr_sitk = sitk.GetArrayFromImage(sitk_img) # (z, y, x)
print("size:", sitk_img.GetSize()) # (x, y, z)
print("spacing:", sitk_img.GetSpacing()) # (sx, sy, sz)
print("origin:", sitk_img.GetOrigin())
print("direction:\n", sitk_img.GetDirection())
print("array shape:", arr_sitk.shape) # (z, y, x)
# nibabel 读取同一文件
nib_img = nib.load("case_001.nii.gz")
arr_nib = nib_img.get_fdata() # (x, y, z)
print("shape:", nib_img.shape) # (x, y, z)
print("zooms:", nib_img.header.get_zooms()) # (sx, sy, sz)
print("affine:\n", nib_img.affine)
# 轴顺序转换:nibabel (x,y,z) -> SimpleITK 数组 (z,y,x)
arr_from_nib = np.transpose(arr_nib, (2, 1, 0))
assert arr_from_nib.shape == arr_sitk.shape
这段代码的关键不是“能读出来”,而是通过打印和断言确认两种工具读到的几何信息一致。如果 arr_from_nib 与 arr_sitk 的 shape 不一致,说明轴顺序理解有误;如果 spacing 与 affine 对角线不匹配,说明方向或缩放信息在转换中丢失。
常见陷阱与判断依据
第一类陷阱是 qform 与 sform 不一致。nibabel 默认优先使用 sform,但某些数据可能只设置了 qform。此时应显式检查 header['qform_code'] 和 header['sform_code'],必要时用 nib.aff2axcodes() 查看方向编码,确认是否与预期解剖方向一致。
第二类陷阱是 DICOM 层间距不均匀。SimpleITK 读取时如果发现层间距不一致,会给出警告或按平均间距处理。对于 nnU-Net,建议先检查 SpacingBetweenSlices 与 SliceThickness 是否一致,必要时在重采样前做层间插值。
第三类陷阱是方向翻转。不同工具对 RAS、LPS 坐标系的约定不同,DICOM 使用 LPS,NIfTI 使用 RAS。SimpleITK 内部统一为 LPS,nibabel 的 affine 通常对应 RAS。跨工具转换时,必须明确当前坐标系,否则左右、前后方向可能整体翻转。判断依据是:用 nib.aff2axcodes(img.affine) 得到方向编码,再与解剖学预期比对;如果出现 L 与 R 互换,说明坐标系约定没有对齐。
把格式解析、读取工具差异、方向与 spacing 处理这三件事做扎实,后续的归一化、重采样、滑窗推理才有可靠的空间基础。读取阶段多打印、多断言、多比对,远比训练阶段反复调参更省时间。