先给你看一个挺常见的场景:同样是腹部CT,一家医院扫出来是层厚1mm、矩阵512×512、像素间距0.7mm,另一家扫出来是层厚5mm、矩阵256×256、间距1.5mm。这两个文件要是直接扔进同一个深度学习流程里,网络输入尺寸对不上,物理尺度也对不上,别说训练,连预处理都得写两套代码。这时候你绕不开一个操作——3D Resampling,也就是三维重采样。SimpleITK是我这几年做医学图像预处理最顺手的库,这次系列第一篇文章,就把Resampling从头到尾梳理一遍,包括参数怎么定、代码怎么写、哪些坑必须躲。
这个内容适合几类人:刚入行做医学影像算法、被各种spacing搞到头大的同学;需要用Python做CT/MR体数据预处理的工程师;以及想搞清楚ResampleImageFilter内部逻辑、不想只做“调包侠”的人。看完这篇文章,你可以做到:拿到任意一个体数据,能口算出重采样后的尺寸,写出一段不会丢失几何信息的重采样代码,并且知道为什么标签图要用最近邻插值。
1. 为什么3D重采样是医学图像处理的第一道坎
1.1 从体素间距说起:一张图为什么“变形”
医学图像跟自然图像最大的区别,是每个像素(准确说体素)都对应一个物理尺寸。CT里经常提到的“层厚”,指的就是Z方向上每个体素的厚度;横断面里每个像素对应多少毫米,就是X/Y方向的spacing。SimpleITK读取一张NIfTI或者DICOM序列后,image.GetSpacing()返回的是一个三元组,比如(0.7, 0.7, 1.0),意思就是每个体素在X方向占0.7mm、Y方向占0.7mm、Z方向占1.0mm。
这个spacing不是可有可无的元数据——它直接决定了这个体数据的“真实长宽高”。一张512×512×300的CT,如果spacing是(0.7, 0.7, 1.0),那么物理范围是512*0.7=358.4mm、512*0.7=358.4mm、300*1.0=300mm。如果把这张图当成普通的三维矩阵直接送进网络,它会被人为地看作一个各向同性的大方块,这显然是错的。更麻烦的是,不同设备的spacing差异很大,核磁共振的Z方向spacing经常是5mm以上,而CT薄层扫描能做到0.5mm,对这种数据不做统一化处理,模型的感受野在不同方向上的“真实覆盖范围”就完全不一样。
1.2 什么时候必须做重采样
先说结论,以下三种情况基本绕不开Resampling:
第一,多中心数据合并训练。你从三个医院收集数据,有的扫描协议是1mm等体素,有的是层厚5mm,你不可能让模型为每种spacing都学一个分支,最省事的办法是统一到同一个物理分辨率下。
第二,配准和空间标准化。无论是把患者图像配准到标准模板空间(比如MNI、ICBM),还是做多模态融合(CT和MR对位),都需要先把图像放到同一套坐标系下。配准算法内部会做重采样,但配准前如果size差太远,速度会慢得让人崩溃。
第三,数据增强里的“随机缩放”。很多3D分割论文里会做随机缩放增强,本质也是重采样——用某个随机比例改变spacing,让模型对器官大小的变化更鲁棒。
顺带说一句,有人会觉得“重采样是不是就是把图放大缩小”,这话只对了一半。缩放矩阵尺寸只是Resampling最简单的形式,真正的重采样是把输出网格上的每个点,通过物理坐标变换映射回输入图像,再插值取灰度。理解了这一层,才会明白接下来要讲的四个几何参数有多重要。
2. 重采样前必须搞懂的四个几何参数
2.1 size、spacing、origin、direction分别管什么
SimpleITK里一张三维图像自带一套几何信息,重采样说白了就是“换一套网格,然后按物理坐标重新采集像素值”。这套网格由四个东西定义:
size是体素个数,也就是矩阵的维度;spacing是每个体素的物理尺寸;origin是图像第一个体素中心在世界坐标系里的位置;direction是一个3x3旋转矩阵,描述体素坐标轴相对于解剖坐标轴(LPS/RAS)转了多少。
打个比方,size和spacing决定了网格铺多大:size是“多少个格子”,spacing是“每个格子边长多少毫米”,两者相乘就是图像覆盖的物理范围。origin和direction决定了网格放在哪里、朝哪个方向摆:同一组size和spacing,如果origin不同,图像整体就平移了;direction不同,图像就旋转了,MRI里经常出现的斜切扫描全靠direction描述。
很多人在重采样时只设置了size和spacing,结果输出的图像发生了偏移或者翻转,就是因为没把origin和direction从原图像“搬运”过来。这一点我后面在避坑实录里会详细讲,但请记住:写重采样代码第一件事,就是把原图的这4个几何参数读出来。
2.2 一个例子:把非等体素数据变成等体素
我们直接算一道题。假设原图:
- size = (512, 512, 200)
- spacing = (0.7, 0.7, 1.5)
- origin = (-200.0, -200.0, -300.0)
- direction = (1, 0, 0, 0, 1, 0, 0, 0, 1)
现在想把它重采样成spacing = (1.0, 1.0, 1.0)的等体素数据。怎么算新的size?
新的X方向体素数 = 原X方向体素数 × 原spacing_X / 新spacing_X = 512 × 0.7 / 1.0 = 358.4,取整得到358。Y方向同理是358。Z方向 = 200 × 1.5 / 1.0 = 300。所以输出size就是(358, 358, 300)。
这个计算的本质是保证物理范围不变:512×0.7=358.4mm,358×1.0=358mm,误差只有一个体素不到,完全可接受。反过来,如果知道目标size,想让物理范围不变,也能反推新spacing = 原spacing × 原size / 新size。
这个例子看着简单,但它是所有Resampling参数计算的基础。后面我给的代码里,这个公式会直接写进去,你不需要每次手算,但一定要明白代码里int(round(...))那一行在干什么。
2.3 插值方式选型:灰度图、标签图、边缘都怎么选
网格定好了,接下来要从原图取值,这就轮到插值器(interpolator)出场。SimpleITK里最常用的三种:
sitkLinear线性插值是默认选项,速度快、数值稳定,绝大多数灰度图重采样用这个就够。它的缺点是会在梯度变化大的地方抹掉一些高频细节,但对医学图像来说,通常不影响后续分析。
sitkNearestNeighbor最近邻插值,输出永远是原始体素值,不会产生“中间值”,这是标签图(segmentation mask、标注的ROI)重采样的唯一选择。你想想,一个标签图里像素值是0、1、2这种类别编号,如果用线性插值,很可能插出来一个0.7,这既不是类别0也不是类别1,后面计算Dice系数直接就崩了。所以凡是标注图,一律最近邻。
sitkBSpline三次B样条插值,结果最平滑,放大时不容易出现明显锯齿,但计算慢,而且可能产生超出原始灰度范围的过冲值。它适合对灰度图做高质量放大,但不适合标签图,更不适合需要严格保持数值范围的场景。另外还有一个sitkGaussian插值器,本质是先做高斯平滑再采样,抗锯齿效果好,但SimpleITK里这个选项在部分版本下性能一般,新手可以先不碰。
3. SimpleITK 3D重采样实操:从API到完整代码
3.1 最核心的ResampleImageFilter
SimpleITK做重采样有两种API:一种是sitk.Resample(image, ...)直接调用,另一种是ResampleImageFilter类的写法。我推荐用filter的写法,理由很实际——filter把输入、输出网格、插值器、变换拆成独立的Set方法,每一步改哪个参数一目了然,调试起来比一长串函数参数要舒服。
一个最小可用的通用重采样函数可以写成下面这样:
import SimpleITK as sitk def resample_image(image, new_spacing=None, new_size=None, interpolator=sitk.sitkLinear, default_value=0.0): old_spacing = image.GetSpacing() old_size = image.GetSize() old_origin = image.GetOrigin() old_direction = image.GetDirection() # 核心公式:物理范围不变的情况下互推size和spacing if new_size is None and new_spacing is not None: new_size = [ int(round(old_size[i] * (old_spacing[i] / new_spacing[i]))) for i in range(3) ] elif new_spacing is None and new_size is not None: new_spacing = [ old_spacing[i] * (old_size[i] / new_size[i]) for i in range(3) ] else: raise ValueError("new_spacing 和 new_size 必须指定一个") resampler = sitk.ResampleImageFilter() resampler.SetSize(new_size) resampler.SetOutputSpacing(new_spacing) resampler.SetOutputOrigin(old_origin) resampler.SetOutputDirection(old_direction) resampler.SetInterpolator(interpolator) resampler.SetDefaultPixelValue(default_value) return resampler.Execute(image)注意这段代码把origin和direction原封不动地设回去了,这是整段代码里最容易丢的一项。很多初学者只设置size和spacing,结果出来的图像位置漂移,用ITK-Snap叠加一看两张图根本对不上,就是因为origin和direction丢了。具体的版本里为什么这两个参数这么重要,我放到第4节再展开,这里先记住结论。
3.2 需求一:等体素化重采样
医学图像深度学习里最常见的预处理操作,就是把非等体素数据变成三方向间距相同的等体素数据。基于上面的通用函数,等体素化就变得很简单:
def resample_to_isotropic(image, target_spacing=None): original_spacing = image.GetSpacing() if target_spacing is None: # 取三个方向里最小的spacing作为目标,尽量保留细节 target_spacing = (min(original_spacing),) * 3 return resample_image(image, new_spacing=target_spacing)如果target_spacing不指定,这段代码会自动取原图里最小的spacing作为三方向目标。比如原图spacing是(0.6, 0.6, 5.0),那输出就是(0.6, 0.6, 0.6),Z方向会从5mm插值到0.6mm,体素数量会大幅增加,内存占用随之上涨,这是等体素化的一个代价。如果机器内存吃紧,我会建议手动指定一个稍大的目标,比如1.0mm或1.5mm,而不是无脑追求最小spacing。
等体素化之后,模型输入的每个体素在真实空间里是“立方体”,3D卷积的感受野在XYZ方向对物理区域的影响才一致,这是一个很重要的潜规则。
3.3 需求二:固定目标尺寸重采样
另一类常见需求是:不管原始图像多大,我都要送到某个固定尺寸的输入网络,比如(128, 128, 64)。这时我们不指定spacing,而是让spacing跟着size变:
def resample_to_fixed_size(image, target_size=(128, 128, 64), interpolator=sitk.sitkLinear): return resample_image(image, new_size=target_size, interpolator=interpolator)这段代码会根据目标size自动反推新的spacing,物理范围基本不变。也就是说,一张512×512×300的CT会被压成128×128×64,但每个体素的物理尺寸变大了,图像覆盖的实际解剖区域没缩小太多。这个操作在3D分割网络里非常常见,特别是那些输入尺寸固定的经典网络结构。
但我要提醒一下:固定size重采样会导致不同图像的实际spacing不一致,如果后续要做跨样本的空间对比,最好在记录文件里单独存下重采样后的spacing,否则你无法知道一个大小为100个体素的病灶在真实物理空间里是1cm还是3cm。
3.4 场景三:配准/变换后重采样,务必复用参考图像网格
在CT-MR配准、术前术后对比这类场景里,你会拿到一个配准变换transform,要把moving图像搬到fixed图像所在的坐标系。这种情况下不需要手写size和spacing,直接用SetReferenceImage即可:
def resample_like(moving, fixed, transform=None): resampler = sitk.ResampleImageFilter() resampler.SetReferenceImage(fixed) # 直接复用fixed的size/spacing/origin/direction resampler.SetInterpolator(sitk.sitkLinear) resampler.SetDefaultPixelValue(0) if transform is not None: resampler.SetTransform(transform) return resampler.Execute(moving)SetReferenceImage(fixed)会自动把fixed图像的size、spacing、origin、direction全部拿过来,这样你不需要再手动设置输出网格,也不容易出坐标错位的问题。平时自己写通用重采样代码时,也可以考虑结合这个API,比逐个Set参数更稳。
有个细节:如果输入是灰度图,插值器用linear没问题;如果moving是标签图,记得把插值器换成sitk.sitkNearestNeighbor。同一个配准变换要同时应用于CT和它的分割标签时,最好分开调用两次resample_like,一次对CT用线性插值,一次对标签图用最近邻插值,别图省事混用一个函数。
4. 常见问题与避坑实录
4.1 表格速查:重采样翻车现场一览
这里把我在实际项目里遇到过的典型问题整理成一张表,方便你先对照定位:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 重采样后图像整体偏移,与原图对不上 | 没有设置输出origin/direction | 用SetOutputOrigin和SetOutputDirection把原图的geometry传过去 |
| 标签图重采样后出现小数类别 | 用了线性/样条插值器 | 改成sitkNearestNeighbor |
| CT灰度值出现超出合理范围的值 | 插值过冲,尤其BSpline插值 | 检查min/max,必要时clip到合理HU范围 |
| 左右翻转、上下颠倒 | direction矩阵传递错误,或源图像本身用了RAS/LPS混用坐标 | 用DICOMOrient统一方向,核对direction |
| 输出尺寸巨大导致内存溢出 | 目标spacing选得太小,体素数爆炸 | 先打印估算后的new_size,再决定是否增大spacing |
| 重采样后图像变黑或大部分为0 | default value设置错误,超出原始物理范围 | 检查输出网格物理范围是否超出了输入图像覆盖范围 |
4.2 origin和direction丢失,是重采样最隐蔽的坑
我见过太多同学写了重采样代码,MRI图像重采样出来左右是反的,或者位置完全不对,第一反应是怀疑SimpleITK哪里出了bug。实际上大多数情况就是没有把origin和direction传给ResampleImageFilter。
direction这个9个数组成的矩阵,正常人不会去逐个理解它。你只需要记住一个原则:如果不想发生任何旋转和翻转,就把原图的direction原样复制给输出;如果你要做的重采样不涉及坐标变换,这就够了。反过来,你要是自己手写了一个单位矩阵(1,0,0,0,1,0,0,0,1)传进去,那就等于把原本带旋转的MRI扫描强制转成了无旋转的扫描,图像自然就斜了。
还有一点容易被忽略:origin是体素中心的物理坐标,不是(0,0,0)。CT图像的origin经常是负值,比如(-200, -200, -300),这表示图像起始体素位于世界坐标系原点左侧和后侧。如果你重置origin,整个体数据就相对于标准空间平移了。所以在重采样代码里,我建议永远保留原图的origin和direction,除非你明确知道自己要做什么坐标变换。
4.3 标签图插出小数值,Dice直接崩
重采样标签图时使用最近邻插值,这是一个老生常谈但还是会有人犯的错。有个场景特别容易误用:为了保持和灰度图相同的预处理参数,直接把灰度图的resampler用到了标签图上,结果插出一个类别值为0.7的体素。这类问题在Dice计算时不会立刻报错,但会在注意不到的角落污染评估指标。
正确的做法是单独为标签图写一个重采样函数,强制使用sitk.sitkNearestNeighbor,并且把default_value设置成0。另外提醒一句:最近邻插值在缩小标签图时容易造成细小结构断裂,如果你要反复缩小标注数据(比如从512缩小到128),推荐用sitk.sitkLabelGaussian或者先做连通域分析,但这属于进阶话题,后面文章再聊。
def resample_label(mask, new_spacing=None, new_size=None): return resample_image( mask, new_spacing=new_spacing, new_size=new_size, interpolator=sitk.sitkNearestNeighbor, default_value=0, )4.4 内存爆了怎么办:先算一算再跑
重采样一个512×512×500的薄层CT,如果目标spacing设成(0.5, 0.5, 0.5),新size会变成(614, 614, 1000)。这个体数据按float32存储,占用内存是614×614×1000×4字节,约1.5GB,再加上原始图像和处理过程中间的临时数组,很容易把内存撑爆。
所以在跑重采样之前,我习惯先把输出size打印出来估算一下:print(new_size, new_size[0]*new_size[1]*new_size[2]*4/1024/1024, "MB")。如果发现体积太大,就调大目标spacing,或者在重采样前先做一次降采样。SimpleITK里还有一个小技巧:如果只是想做快速预览,可以用ProcessSliceBySlice对Z方向切片逐个重采样,虽然速度不快,但内存占用会小得多。
4.5 插值过冲:CT值怎么会冒出几万个?
线性插值本身不容易产生严重过冲,但BSpline插值在边缘处会产生明显的“振铃效应”,表现为灰度值超出原始数据的最小最大值。CT图像正常的HU范围往往在-1024到3000之间,如果BSpline插值后冒出-5000或5000这种值,不仅显示难看,后续归一化也会被极端值带偏。
这类问题最省事的解法是插值后做一个clip:
import numpy as np arr = sitk.GetArrayFromImage(resampled_img) arr = np.clip(arr, -1024, 3071) # 按实际需求设范围 clipped_img = sitk.GetImageFromArray(arr) clipped_img.CopyInformation(resampled_img)注意一定要用CopyInformation把几何信息复制回来,否则GetImageFromArray会丢掉spacing、origin、direction。这个细节很多人会漏,导致clip做完图像变成单位矩阵方向,又踩一次4.2里的坑。
5. 我最常用的重采样工程模板
顺便把我实际项目里沉淀下来的一个工程模板分享出来,虽然不算复杂,但它把灰度图、标签图、等体素化、固定尺寸四种场景全部覆盖了,你拿到代码后可以直接改改就用。
import SimpleITK as sitk def resample_image(image, new_spacing=None, new_size=None, interpolator=sitk.sitkLinear, default_value=0.0): old_spacing = image.GetSpacing() old_size = image.GetSize() old_origin = image.GetOrigin() old_direction = image.GetDirection() if new_size is None and new_spacing is not None: new_size = [ int(round(old_size[i] * (old_spacing[i] / new_spacing[i]))) for i in range(3) ] elif new_spacing is None and new_size is not None: new_spacing = [ old_spacing[i] * (old_size[i] / new_size[i]) for i in range(3) ] else: raise ValueError("new_spacing 和 new_size 必须指定一个") resampler = sitk.ResampleImageFilter() resampler.SetSize(new_size) resampler.SetOutputSpacing(new_spacing) resampler.SetOutputOrigin(old_origin) resampler.SetOutputDirection(old_direction) resampler.SetInterpolator(interpolator) resampler.SetDefaultPixelValue(default_value) return resampler.Execute(image) def resample_to_isotropic(image, target_spacing=None): spacing = image.GetSpacing() if target_spacing is None: target_spacing = (min(spacing),) * 3 return resample_image(image, new_spacing=target_spacing) def resample_to_fixed_size(image, target_size=(128, 128, 64)): return resample_image(image, new_size=target_size) def resample_label(mask, new_spacing=None, new_size=None): return resample_image( mask, new_spacing=new_spacing, new_size=new_size, interpolator=sitk.sitkNearestNeighbor, default_value=0, )使用非常直观:读图、调用、确认几何信息、看效果。
img = sitk.ReadImage("ct.nii.gz") iso_img = resample_to_isotropic(img, target_spacing=(1.0, 1.0, 1.0)) label_img = sitk.ReadImage("seg.nii.gz") iso_label = resample_label(label_img, new_spacing=(1.0, 1.0, 1.0))写完之后务必验证一下几何信息:
print("原图 spacing:", img.GetSpacing(), "size:", img.GetSize()) print("重采样 spacing:", iso_img.GetSpacing(), "size:", iso_img.GetSize())最后分享一个我自己的习惯:每次重采样完,我都会把处理后的图像在ITK-Snap里和原图叠加看一眼,确认没有偏移、没有翻转、标签图没有出现奇怪的中间值。这个习惯帮我少踩了许多坑,看起来多花了三分钟,实际上省下的是后面整整几天的返工时间。这些细节平时文档里不会写,但做多了自然就明白了。