医学图像分割全流程讲透:从 DICOM 预处理到评估指标(万字长文 + 代码)
写给已经会写一点 PyTorch、但一上手医学图像就懵的人。从拿到一堆
.dcm文件开始,到你能说清楚"我的模型到底哪里不行",把这条链路一次走完。
医学图像分割是个看起来很美好、做起来很折磨的方向。美好在于目标清晰——输入一张影像,输出每个像素/体素的类别;折磨在于,你几乎在每一个环节都能踩到坑,而且很多坑不会报错,只会让你的指标悄悄变差。
这篇文章按真实工程顺序组织:数据格式 → 预处理 → 数据增强 → 网络结构 → 损失函数 → 训练技巧 → 评估指标 → 后处理与部署 → 实战清单。每一节都有能跑的代码。读完你不一定能立马刷榜,但至少不会在"坐标系搞反了"这种问题上浪费一周。
先说一个可能不太受欢迎的判断:医学图像分割,70% 的功夫在数据和预处理,30% 在网络。你换一个最新的 Transformer 结构,收益往往不如老老实实把 spacings 对齐、把窗宽窗位调对、把背景采样比例改一下。下面这套流程,就是围绕这个判断展开的。
目录
- 为什么医学图像分割这么难
- 数据格式:DICOM / NIfTI / Nrrd 到底是怎么回事
- 预处理:把原始数据变成模型能吃的样子
- 数据增强:小样本下的救命稻草
- 网络结构:从 CNN 到 U-Net 再到 Transformer
- 损失函数:类别不平衡下怎么选
- 训练技巧:让模型真的收敛到好结果
- 评估指标:别被一个 Dice 骗了
- 后处理与部署
- 实战清单、常见坑与学习路线
- 实操:从零跑通一个 2D U-Net(完整代码)
1. 为什么医学图像分割这么难
如果你做过自然图像分割(Cityscapes、COCO),转到医学图像会有一种"怎么全都反过来"的感觉。难点主要来自这几处:
- 数据量小、标注贵。自然图像动辄百万级,医学数据一个公开数据集可能就几十到几百例。标注更贵——一张 CT 的器官勾画,放射科医生要花几十分钟到几小时,而且不同医生的勾画还不一致(观察者间差异有时比模型误差还大)。
- 类别极度不平衡。一个 512×512 的切片里,肿瘤可能只占几百个像素,背景占了 99.9%。模型什么都不学、直接全预测背景,准确率就能到 99.9%——但毫无意义。这也是为什么医学分割不能只看 Accuracy。
- 边界模糊、目标定义不唯一。肿瘤的边界在影像上本身就是渐变的,"哪里算肿瘤"连专家都有分歧。这直接决定了 Dice 这种边界敏感的指标会有天花板。
- 影像格式复杂、带物理信息。DICOM 不只是像素,还带着像素间距、层厚、坐标系方向、CT 值含义。你忽略任何一个,都可能让预处理出大错。
- 3D 数据、各向异性。CT/MRI 是体数据,但层内分辨率(如 0.7mm)通常远高于层厚(如 3mm,甚至 5mm)。这种各向异性会让 3D 卷积很难受。
- 一个病例是一个整体。切片之间不独立,不能随便对 slice 做随机划分就当成独立样本。
所以医学图像分割本质上是一个数据工程问题,只是恰好用深度学习来解决。下面每一节,你都会看到"数据"二字反复出现。
2. 数据格式:DICOM / NIfTI / Nrrd 到底是怎么回事
2.1 为什么不是 PNG / JPG
PNG/JPG 只存"像素值",不存物理含义。而医学影像里,一个像素值1024到底代表骨、血还是空气,取决于元数据。你把 DICOM 直接转成 JPG,等于把"地图"变成了"照片"——能看,但没法定位。
常见的几种格式:
| 格式 | 常见场景 | 特点 |
|---|---|---|
DICOM(.dcm) | 医院导出、CT/MRI/超声原始数据 | 一个文件 = 一层切片;头部带大量标签(几何、物理、患者信息) |
NIfTI(.nii/.nii.gz) | 科研、深度学习训练 | 一个文件 = 一个 3D/4D 体数据;带 affine 坐标变换 |
| Nrrd / MHA / MHD | 部分医学工具、SimpleITK 生态 | 单文件或"头文件+数据文件"分离 |
| PNG / TIFF 序列 | 病理 WSI 切片、已预处理数据 | 丢失物理信息,仅用于纯图像任务 |
科研/训练里最主流的是NIfTI,因为它一个文件装一整个 volume,读起来方便,还带坐标信息。但从医院拿到的原始数据几乎都是DICOM,所以第一步通常是 DICOM → NIfTI。
2.2 DICOM 拆解
DICOM 文件由一个个Tag(数据元素)组成,每个 tag 有组号、元素号、VR(数据类型)和值。你不需要记住所有 tag,但下面这几个必须清楚:
| Tag | 名称 | 作用 |
|---|---|---|
| (0028,0030) | PixelSpacing | 层内像素间距[row, col],单位 mm |
| (0018,0050) | SliceThickness | 层厚,单位 mm |
| (0020,0032) | ImagePositionPatient | 该切片左上角在患者坐标系中的位置 |
| (0020,0037) | ImageOrientationPatient | 图像方向(两个方向余弦向量) |
| (0028,1052) | RescaleIntercept | 线性变换的截距(CT 要用) |
| (0028,1053) | RescaleSlope | 线性变换的斜率 |
| (0028,1050) | WindowCenter | 建议窗位 |
| (0028,1051) | WindowWidth | 建议窗宽 |
| (0028,0004) | PhotometricInterpretation | 像素解读方式,如MONOCHROME2、RGB |
最容易被忽略的一步:原始 DICOM 的像素值不是 HU(Hounsfield Unit),要经过一次线性变换才是:
importpydicomimportnumpyasnpdefread_ct_hu(dcm_path):ds=pydicom.dcmread(dcm_path)arr=ds.pixel_array.astype(np.float32)slope=float(getattr(ds,"RescaleSlope",1)or1)intercept=float(getattr(ds,"RescaleIntercept",0)or0)hu=arr*slope+intercept# 这才是 CT 值returnhu,ds很多人直接把ds.pixel_array当成 HU 用,结果窗宽窗位全设错——数据看起来"也能跑",但模型学到的东西是错的。
2.3 把 DICOM 序列拼成 3D 体数据
一个序列就是一堆 slice 文件。千万不要按文件名排序,文件名顺序和真实空间顺序经常不一致。正确做法是按空间位置排序:
importglobimportnumpyasnpimportpydicomdefload_series(folder):slices=[pydicom.dcmread(p)forpinglob.glob(f"{folder}/*.dcm")]iop=np.array(slices[0].ImageOrientationPatient,dtype=float)normal=np.cross(iop[:3],iop[3:])# 层法向slices.sort(key=lambdas:float(np.dot(np.array(s.ImagePositionPatient,dtype=float),normal)))volume=np.stack([s.pixel_arrayforsinslices]).astype(np.float32)returnvolume,slices用方向余弦投影到法向来排序,能稳健处理任意体位,比只比较某一个坐标轴靠谱。
2.4 NIfTI 与坐标系统
NIfTI 的核心是affine——一个 4×4 矩阵,把"体素坐标"映射到"世界坐标"(mm)。通过它你才能知道一个体素在患者体内真实的位置。
importnibabelasnib img=nib.load("case.nii.gz")data=img.get_fdata()# float64,shape 通常为 (X, Y, Z) 或 (X, Y, Z, T)spacing=img.header.get_zooms()# (sx, sy, sz),单位 mmaffine=img.affine# 4x4 世界坐标变换坐标系的坑:医学影像有两套常用坐标——LPS(Left-Posterior-Superior,DICOM 默认)和RAS(Right-Anterior-Superior,NIfTI/神经影像常用)。轴方向还分正向、反向。
一个实用习惯:把所有数据统一到 RAS 方向再送进模型。判别"左肺还是右肺"“肿瘤在左侧还是右侧”,靠的就是这个。用 MONAI 一行就能搞定(后面预处理会看到Orientationd(axcodes="RAS"))。
2.5 格式转换工具
常用库各有所长:
pydicom:读 DICOM 原始数据,处理元数据灵活。nibabel:读写 NIfTI,科研标配。SimpleITK:通用医学图像处理,重采样/配准很强,支持 DICOM 序列读取。dicom2nifti/dcm2niix:批量 DICOM→NIfTI 转换,dcm2niix是 C++ 工具,速度最快。
一个最小 DICOM→NIfTI 转换(用 SimpleITK,自动处理几何信息):
importSimpleITKassitkdefdcm_series_to_nifti(dcm_dir,out_path):reader=sitk.ImageSeriesReader()series_ids=reader.GetGDCMSeriesIDs(dcm_dir)# 一个目录可能有多个序列files=reader.GetGDCMSeriesFileNames(dcm_dir,series_ids[0])reader.SetFileNames(files)image=reader.Execute()sitk.WriteImage(image,out_path,useCompression=True)returnimage注意GetGDCMSeriesIDs:一个被试目录里可能混着多个序列(平扫、增强、定位像),得先按SeriesInstanceUID归组,别一股脑全读进来。
2.6 成像模态:先搞懂像素从哪来,再谈分割
不同模态的像素值含义完全不同,预处理方式也就完全不同。用错模态的归一化方式,是新手最常见的翻车点之一。
- CT(计算机断层):像素值是 HU,有绝对物理意义。骨、水、气各有固定区间。适合做固定区间窗化归一化。缺点是软组织对比度一般(所以 CT 上分肝脏、胰腺这类器官比 MRI 难)。
- MRI(磁共振):信号强度是相对的——同一个组织在不同机器、不同序列(T1/T2/DWI/FLAIR)上数值天差地别。所以必须做样本级归一化(Z-score / 百分位),而且同一模型最好只用同一序列或固定的多模态组合。MRI 软组织对比好,是脑、前列腺、心脏分割的主力。
- 超声(US):噪声大、有斑点噪声(speckle)、视野扇形且取决于探头位置,标注一致性最差。乳房、甲状腺、心脏超声的分割难度普遍高于 CT/MRI。
- 病理(WSI):整张切片可达十万×十万像素级别,通常存成金字塔多分辨率格式(
.svs/.tiff),必须切 tile 处理。染色(H&E)的色彩差异需要用染色归一化(如 Macenko、Vahadane)来消除不同实验室的色偏。 - PET / SPECT:功能性成像,分辨率低、信噪比低,常和 CT 做融合(PET-CT)来提供解剖参考。
一句话总结:**CT 看物理量,MRI 看相对对比,超声看操作,病理看分辨率。**预处理策略要跟着模态走,别一视同仁。
3. 预处理:把原始数据变成模型能吃的样子
这一节是全文最长的,也是最重要的。预处理做得好,模型训练会顺很多。
3.1 灰度与通道
医学影像绝大多数是单通道灰度。即便某些多模态(如 BraTS 的 T1/T1ce/T2/FLAIR 四模态),也应该把它们当成多个通道而不是三通道 RGB。转灰度时要小心:如果PhotometricInterpretation是MONOCHROME1,像素值越小越亮,需要反相。
3.2 HU 与窗宽窗位(Windowing)
CT 值范围大约 -1024 到 3071 HU。不同组织的 HU 区间大致是:
- 空气:-1000 左右
- 脂肪:-100 ~ -50
- 水:0
- 软组织 / 血:30 ~ 70
- 骨:300 ~ 1500+
显示器只有 256 级灰度,没法直接显示 4000 多个 HU 范围。窗宽窗位(Window Width / Window Level)就是选一个感兴趣区间映射到显示范围:
- 窗位 WL(Window Level):区间中心
- 窗宽 WW(Window Width):区间跨度
常用窗口:
- 肺窗:WW=1500, WL=-600,看肺实质、结节
- 纵隔窗:WW=350, WL=40,看心脏、血管、淋巴结
- 骨窗:WW=2000, WL=400,看骨折、骨质
实现很简单:
defapply_window(hu,wc,ww,out_range=(0,255)):lo,hi=wc-ww/2,wc+ww/2img=np.clip(hu,lo,hi)img=(img-lo)/(hi-lo)# 归一化到 [0,1]img=img*(out_range[1]-out_range[0])+out_range[0]returnimg.astype(np.uint8)lung_window=apply_window(hu,wc=-600,ww=1500)窗宽窗位的意义不止于"看得清":它其实是一种手工的特征选择。你把肺窗和骨窗分别送进网络,等于告诉模型"分别关注这两个 HU 区间"。多窗融合(多通道输入)在肺结节、骨折等任务上经常有效。
3.3 归一化:CT 和 MRI 完全不是一回事
归一化做错,是训练不收敛的头号原因之一。要分模态讨论:
- CT:物理量是绝对值(HU),不要用 Z-score,因为它依赖每个样本的均值和方差,会破坏 HU 的物理意义。正确做法是固定区间裁剪 + 线性映射,比如腹部 CT 常用
[-175, 250]或[-1000, 1000]映射到[0, 1]。 - MRI:没有绝对物理量(信号强度是相对的),必须用样本级归一化——Z-score 或百分位裁剪,否则同一组织在不同扫描仪上数值完全不同。
defnorm_ct(x,lo=-1000,hi=1000):x=np.clip(x,lo,hi)return(x-lo)/(hi-lo)defnorm_zscore(x,eps=1e-8):return(x-x.mean())/(x.std()+eps)defnorm_percentile(x,pl=1.0,ph=99.0):lo,hi=np.percentile(x,[pl,ph])x=np.clip(x,lo,hi)return(x-lo)/(hi-lo+1e-8)为什么推荐百分位裁剪而不是直接 Min-Max?因为医学图像里常有离群的高值(金属伪影、标记物)。直接用 min/max,一个异常亮点就能把整张图压扁到很窄的范围,对比度全没了。用 1%~99% 分位数裁剪再归一化,鲁棒得多。
3.4 重采样:统一 spacing
不同机器的层内间距和层厚都不一样:可能是 0.6×0.6×5.0 mm,也可能是 0.98×0.98×1.0 mm。如果不统一,网络被迫去学"尺度无关"的特征,很难。
标准做法是把所有数据重采样到统一 spacing(如 1×1×1 mm)。注意:图像用线性插值,mask 必须用最近邻插值,否则标签会被插值破坏。
importSimpleITKassitkdefresample_to_spacing(image,new_spacing=(1.0,1.0,1.0),is_label=False):old_spacing=image.GetSpacing()old_size=image.GetSize()new_size=[int(round(o*s/n))foro,s,ninzip(old_size,old_spacing,new_spacing)]interp=sitk.sitkNearestNeighborifis_labelelsesitk.sitkLinearreturnsitk.Resample(image,new_size,sitk.Transform(),interp,image.GetOrigin(),new_spacing,image.GetDirection(),0.0,image.GetPixelID())各向异性的特殊处理:如果层厚(5mm)远大于层内间距(0.7mm),强行重采样到 1×1×1 会把体积插值放大好几倍,既费显存又引入虚假细节。这时更实用的策略是:
- 层内和层间用不同的 spacing(如 0.7×0.7×3.0),或者在层间方向做插值前先确认任务是否需要层间连续性。
- 干脆按 2D 处理,把每个 slice 当成独立样本(很多肺结节、视网膜、病理任务就是 2D 做的)。
3.5 方向统一与空间对齐
- 方向统一:把所有 volume 转到同一方向(如 RAS),保证"左右、前后、上下"一致。
- 空间对齐(配准):同一患者的多模态(CT/MRI/PET)或时序数据,需要配准到同一空间才能融合。配准是个大话题,工程上常用 SimpleITK 的刚性/仿射配准,深度学习配准(VoxelMorph 等)也越来越成熟。绝大多数单模态任务不做精细配准,但至少要保证mask 和 image 严格共享同一 geometry。
3.6 Patch、裁剪与前景采样
医学 3D 数据太大(512×512×300 的 CT 直接塞不进显存),必须切 patch。
- 整图 2D:适合层内分辨率高的 2D 任务,简单,但丢上下文。
- Patch 训练:随机切 3D/2D 小块,省显存,还能通过重叠增加样本量。
- 滑窗推理:大图分成带重叠的窗口分别预测,再加权拼接(边缘权重低、中心权重高),避免边界伪影。
关键技巧是前景平衡采样:如果纯随机切 patch,大部分 patch 是纯背景,模型学不到前景。要保证一定比例的 patch 落在前景上:
defrandom_foreground_patch(img,mask,size=128,p_fg=0.7,rng=None):rng=rngornp.random.default_rng(0)H,W=img.shape[1:3]fg=np.argwhere(mask[0]>0)iflen(fg)>0andrng.random()<p_fg:cy,cx=fg[rng.integers(len(fg))]else:cy,cx=rng.integers(H),rng.integers(W