☰
肺结节分割实战:医学影像数据清洗与预处理全流程指南
2026/10/4 1:25:00 网站建设 项目流程

先说一个真实感受:医疗影像AI项目里,最容易被低估的往往不是模型结构,而是数据集处理。模型不行可以换块GPU重来,但数据没弄干净,后面所有工作都在给错误结果打工。肺结节分割算是医学影像分割里入门比较友好又很典型的方向,我拿它当深度学习实战的第一站,就是因为它的数据形态相对固定、标注规范可查,但处理流程里又什么都会碰到——DICOM格式、CT值转换、XML标注解析、重采样、类别不平衡、数据增强。今天这篇先只聊第一件正事:怎么把一个原始公开数据集,变成模型真正能吃进去的训练数据。这一期内容完全围绕数据处理展开,适合刚接触医学影像深度学习、想完整跑通一个项目但卡在数据集环节的朋友。

1. 数据集的选型与整体处理思路

做肺结节分割,目前最常用也最绕不开的公开数据集就是LIDC-IDRI。它是多个机构联合发布的肺部CT影像数据集,包含1018个病例,每个病例都带放射科医生的标注信息,标注内容包括结节的边界轮廓、良恶性评分、纹理特征等。之所以选它而不是别的数据集,是因为它标注覆盖相对完整,而且公共可获取,社区里也有很多围绕它的预处理代码可以参考。

不过这里要提前泼一盆冷水:LIDC-IDRI的原始数据并不干净,它的CT影像以DICOM格式存储,标注信息存在XML文件里,目录结构第一批还不统一。直接下载下来就扔给模型训练是不可能的,必须经过一个完整的清洗和转换流程。

我的处理思路分四层,供你参考:

  1. 影像层:把DICOM系列读取出来,恢复出真实的CT值(HU),再按肺窗做窗口化,得到适合人眼和模型观察的图像。
  2. 标注层:解析XML,把放射科医生勾画的结节区域轮廓坐标提取出来,转成掩码(mask)。
  3. 样本层:按结节位置裁剪出小块(patch),解决CT大图直接训练显存不够的问题,同时缓解正负样本极端不平衡。
  4. 存储层:统一为标准的NumPy数组或图片文件,带着清洗后的元信息组织好目录结构。

这套流程不追求花哨,但每一步都有明确目的。你后面跑模型、调loss、看结果,如果发现异常,基本都能回溯到数据处理的某一步,所以前面的每一步都得留下可复现的代码和书面记录。

2. 处理前的环境准备与工具选型

工欲善其事,必先利其器。处理医学影像数据,Python是绝对的主流,核心依赖库这几个就够了:

  • pydicom:读取DICOM文件,负责解析影像头信息和像素数据。
  • SimpleITK:处理多序列CT和重采样,尤其适合做医学影像格式转换和插值。
  • numpy + scipy:数组运算、数组缩放和基础插值。
  • opencv-python / Pillow:图像读写和后续简单图像处理。
  • xml.etree.ElementTree或lxml:解析XML标注文件。
  • matplotlib:可视化检查,这一步千万别省。

安装直接用pip就行:

pip install pydicom simpleitk numpy scipy opencv-python pillow matplotlib lxml

如果你的机器用的是国内环境,建议pip换用国内镜像源,不然SimpleITK这种大包下载会很痛苦。另外提醒一点:不要一上来就把所有依赖装最新版,pydicom的API在版本间有调整,SimpleITK的读图结果在不同版本间也会有小差异。最好用虚拟环境固定版本,我的环境是Python 3.9 + pydicom 2.3 + SimpleITK 2.1,这个组合非常稳。

2.1 确认硬件与内存方案

数据集处理看着不像训练那么吃显卡,但照样吃内存。LIDC的CT切片是512x512,一个病例多的时候有300多张DICOM切片,解压出来的像素数组一次性读进内存可能要占几百MB。如果用完整3D体数据甚至更大的原始图像处理流程,内存需求会线性上升。建议内存至少16GB,如果同时开多个进程做并行处理,32GB更从容。

GPU在这里不是必须的,数据集处理阶段纯CPU就行。不过后面如果做重采样、大矩阵插值,用GPU加速的效果不算明显,没必要为这个阶段单独上卡。

2.2 原始数据的目录浏览与备份

拿到LIDC-IDRI下载包后,先不要急着写代码。先花半小时把目录结构看明白。每个病例的目录名类似“LIDC-IDRI-0001”,里面一般有一个或多个子目录,按扫描系列存放DICOM,还有一个与病例同名的XML文件(有些版本是单独的Annotation目录)。不同来源的LIDC数据,目录组织会有差异,我建议先写一个脚本遍历所有病例目录,输出文件清单,人工抽查十几个病例确认格式统一性,再做批量处理。

这一步还有一个隐藏重点:数据备份。原始DICOM文件带有很多患者信息和扫描参数,处理完后尽量不要在原目录上覆盖修改,建议复制到独立工作目录,只保留处理所需的字段,降低隐私风险和误操作损失。

3. 核心处理流程详解

下面进入整个项目的重头戏。我按实际执行顺序逐步展开,每一步都会解释为什么这么做,以及不这么做会踩什么坑。

3.1 DICOM序列读取与CT值转换

DICOM不是一种简单的图像格式,它是一整套医学影像通信标准,每个文件里既包含像素数据,也包含大量元数据——病人ID、扫描日期、设备型号、像素间距、窗宽窗位……读取DICOM系列时,第一步是按SeriesInstanceUID把属于同一扫描序列的切片归组,因为一个病例目录下可能包含多个序列,比如平扫和增强,直接通读所有文件会导致切片顺序和空间位置错乱。

读取代码大致是这样:

import pydicom import os def load_dicom_series(series_dir): slices = [] for fname in os.listdir(series_dir): if not fname.lower().endswith('.dcm'): continue ds = pydicom.dcmread(os.path.join(series_dir, fname)) slices.append(ds) # 按切片位置排序,确保z轴顺序正确 slices.sort(key=lambda x: float(x.ImagePositionPatient[2])) return slices

读进来之后,像素数据还只是设备原始的灰度值,不是真正的CT值。CT值的单位是HU(Hounsfield Unit),它是水的衰减系数校准后的结果,空气约-1000HU,水约0HU,骨组织通常上千。DICOM头里有个RescaleSlope和RescaleIntercept,像素值到HU的转换公式很简单:

hu = pixel_value * RescaleSlope + RescaleIntercept

绝大多数情况下RescaleSlope=1,RescaleIntercept=-1024,但保险起见还是从头信息里读出来再计算。直接拿原始像素值去做窗口化或者标准化,得到的结果在不同设备间不可比,模型泛化会大打折扣。

转换背后的逻辑是:原始像素值不具备物理意义,而400HU和40HU虽然在原始数值上差10倍,但在人体组织里的意义完全不同。如果不统一到HU空间,模型学到的是设备相关的“假特征”,换个医院扫描数据就废了。

3.2 窗口化处理与灰度图生成

CT图像是16位的,HU取值范围大概从-1024到3000多。直接把完整范围映射到[0,255]的8位灰度图,对比度会很差,软组织、结节这类中低密度目标根本看不清。临床阅片时医生会调“窗宽窗位”,我们做深度学习也一样。读片默认用的是肺窗,窗宽1500HU,窗位-600HU,可以比较好地突出肺实质、血管和结节区域。

窗口化的计算逻辑:把窗位附近的HU区间映射到0-255,区间外的直接截断。具体实现:

import numpy as np def window_transform(hu_array, window_width=1500, window_level=-600): lower = window_level - window_width / 2 upper = window_level + window_width / 2 windowed = np.clip(hu_array, lower, upper) # 映射到 0~255 img_8bit = ((windowed - lower) / (upper - lower) * 255).astype(np.uint8) return img_8bit

这里建议保留两个版本:一个原始HU数组用于训练时实时窗口化增强,一个固定窗口的8位图像用于人工检查。窗口宽高的选择会影响模型能看到的纹理细节,我做过对比实验,肺窗训练出来的模型在肺结节这类低对比度目标上明显优于固定全范围窗口,这也是很多医学影像论文里默认用肺部窗的原因。

需要注意的一点是:如果你在做混合数据集训练(比如既有CT又有其他模态),不要对所有数据用同一套窗口化参数,一定先观察各数据集的HU分布再做统一或分别处理。

3.3 XML标注解析与掩码生成

LIDC的标注是XML格式,里面每个射线科医生对结节的标注都叫一个unblindedReadNodule节点。结节按大小分成两类:直径>=3mm的标注了完整轮廓,<3mm的只标注中心点。做分割任务,我们关注的是前者。

每个结节标注里有一串roi节点,每个roi就是一个切片层面上的轮廓点集合,坐标是图像像素坐标:

import xml.etree.ElementTree as ET def parse_nodule_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() nodules = [] for readSession in root.findall('.//readingSession'): for nodule in readSession.findall('.//unblindedReadNodule'): # 只处理带完整轮廓的结节 rois = nodule.findall('.//roi') if not rois: continue outlines = [] for roi in rois: edgeMap = [] for point in roi.findall('.//x') + roi.findall('.//y'): pass # 实际取值时需要同时取x和y # 简化处理:采集所有轮廓点 xs = [float(p.text) for p in roi.findall('.//x')] ys = [float(p.text) for p in roi.findall('.//y')] outlines.append(list(zip(xs, ys))) nodules.append(outlines) return nodules

拿到轮廓点后,用OpenCV的fillPoly填充成二值掩码:

import cv2 def poly_to_mask(points, shape=(512, 512)): mask = np.zeros(shape, dtype=np.uint8) pts = np.array([points], dtype=np.int32) cv2.fillPoly(mask, pts, 1) return mask

这里最容易踩的坑是坐标系错位。CT影像的像素坐标系、XML标注的坐标系、重采样后的世界坐标系,三者不一定一致。LIDC标注坐标基于未插值的原始切片图像,所以在解析XML前,不要对图像做任何裁剪、旋转或缩放。如果需要重采样,请先完成标注映射再生成掩码,或者干脆先重采样图像,再把轮廓点按相同的空间变换映射到新坐标。

我在早期版本里犯过这个错误,图像做完重采样后直接生成掩码,结果模型训练时loss降不下去,可视化一看,掩码和结节位置整体偏移了好几个像素,结节的边缘全被切碎了。后面排查了两天才发现是坐标系映射搞错了,这个坑真心希望你不要踩。

3.4 体素重采样与统一间距

不同医院的CT扫描设备不同,切片厚度和像素间距也不一样,有的层厚是1mm,有的是2.5mm,有的像素间距是0.6mm,有的是0.8mm。深度学习模型期望输入是固定大小的矩阵,但物理尺寸不同的输入会导致模型学到的是“不同缩放尺度”的特征。打个比方,同样是5mm结节,在0.5mm间距的图像上是10个像素,在1mm间距的图像上是5个像素,模型看到的形态完全不一样。

因此做重采样非常关键:把所有体数据统一到相同的体素间距,比如1mm x 1mm x 1mm(各向同性)。SimpleITK直接支持这个功能:

import SimpleITK as sitk def resample_to_spacing(image, new_spacing=(1.0, 1.0, 1.0)): original_spacing = image.GetSpacing() original_size = image.GetSize() new_size = [ int(round(original_size[0] * original_spacing[0] / new_spacing[0])), int(round(original_size[1] * original_spacing[1] / new_spacing[1])), int(round(original_size[2] * original_spacing[2] / new_spacing[2])) ] resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(new_spacing) resampler.SetSize(new_size) resampler.SetOutputDirection(image.GetDirection()) resampler.SetOutputOrigin(image.GetOrigin()) resampler.SetInterpolator(sitk.sitkLinear) return resampler.Execute(image)

注意插值的选型:图像用线性插值没问题,但掩码一定用最近邻插值(sitk.sitkNearestNeighbor),否则标签会变成带过渡灰度的浮点图,对分割任务的硬标签来说这是灾难。重采样的物理意义是让不同来源的数据对齐到同一个特征空间,这一步做得好,模型的泛化能力会明显提升。

4. 训练样本构建:裁剪策略与数据增强

4.1 样本裁剪与正负样本平衡

把整幅CT直接喂给分割网络是不现实的,512x512的图像不算大,但3D网络要处理几十张切片堆出来的体数据,显存和计算量都吃不消。所以我的做法是以结节的标注位置为中心,裁剪固定尺寸的立方体块,比如64x64x32,或者128x128x64,覆盖结节及其周围上下文。

正负样本比例是肺结节任务里很恼人的问题。结节的体素占比通常不到整个肺体积的百分之一,如果直接把所有区域都拿来训练,模型会倾向于把所有像素都预测成背景,因为这样准确率也很高。缓解这个问题的常用方法是:正样本(包含结节的块)全部取样,负样本(背景块)按一定比例随机取样。我实际用的比例是正负1:1到1:3之间,加一点随机筛选,训练效果比较稳定,既不会过拟合背景,也不会因为负样本太少导致误检激增。

裁剪时别忘了标签块和图像块要使用同一套变换,并且负样本也要确保完全不包含任何结节区域,不然模型会学到“所有结节区域都出现在图片中央”这种位置偏置。

4.2 数据增强:哪些该用,哪些慎用

医学影像数据量通常不大,LIDC一千多个病例做完裁剪后,可用块数也不会特别多,所以数据增强基本是必须的。但医学影像有它的特殊性,不是所有通用增强都能直接用。

可以用:

  • 随机翻转:CT图像左右翻转不影响病理含义,但前后翻转要确认设备扫描协议的侧向标记,不可一概而论。
  • 小角度旋转:比如±15度以内,对结节这类近圆形目标安全,旋转角度过大可能改变空间上下文。
  • 随机亮度和对比度扰动:模拟不同扫描参数,但要在HU空间做,不是RGB空间乱调。
  • 弹性形变:小幅度弹性形变对医学图像分割是常用的,能提升模型对组织形变的鲁棒性。

慎用或需要特殊处理的:

  • 大角度旋转:破坏了肺叶的解剖方向感,模型可能学到错误的位置先验。
  • 随机裁剪后缩放:容易改变结节的实际物理尺寸,干扰医生对良恶性的判断。
  • 色彩抖动:CT单通道图像只有强度信息,没有色彩,别拿照片的增强管线直接套。

4.3 数据存储格式的选择

处理完的数据怎么存,直接决定后续训练的IO效率,也影响调试时看图的方便程度。常见选择有:

  • NumPy数组格式(.npy):简单直接,加载快,但单个文件大,不方便可视化。
  • 图像文件PNG/JPG系列:方便查看,但8位存储会损失HU精度。
  • HDF5:支持大数据集统一管理,存取高效,推荐训练时用这种。
  • NIfTI格式(.nii):

这个在医学影像处理里也很常见,支持头信息、保持空间坐标,3D体数据一个文件搞定。

我的推荐方案是:中间产物全部用NumPy数组保存,带完整HU精度;按病例为单位建立索引文件(JSON/CSV),记录每个块的坐标、标签路径、原始间距等信息。训练时再用自定义Dataset读取,按需加载。原因是NumPy格式简单、通用、跨环境不受干扰,NIfTI虽然信息更完整但在通用深度学习框架里还得再套一层转换。

索引文件长这样:

{ "LIDC-IDRI-0001": { "image_path": "processed/LIDC-IDRI-0001_volume.npy", "nodules": [ {"center": [256, 310, 80], "size": [64, 64, 32], "mask_path": "processed/LIDC-IDRI-0001_nodule_0_mask.npy"} ] } }

有了这个索引,训练时就不会漫无目的地扫描所有文件了,哪个病例有哪几个结节一目了然,后面调试也方便。

5. 实操流程与关键细节盘点

这里给你一个可以直接照着走的最小化完整流程,包含了从原始下载文件夹到可训练样本的每一步。我假设你的原始数据目录是/data/lidc_raw,输出目录是/data/lidc_processed。

  1. 用脚本遍历/data/lidc_raw下所有病例目录,把每个病例的DICOM文件按SeriesInstanceUID分组,挑选体数据层数最多、层厚最薄的序列作为主序列。
  2. 读取该序列的全部切片,按z轴排序,转换为三维HU数组(形状为D x H x W)。
  3. 对每个病例的三维数组做重采样,统一到1.0 x 1.0 x 1.0mm。
  4. 解析同目录下的XML标注,提取所有带轮廓结节的轮廓点列表。
  5. 将轮廓点按原始图像坐标的映射关系,在重采样后的体数据上生成三维掩码。这里推荐先把每个roi的二维掩码按原图坐标填到对应切片,再整体重采样,可以最大限度避免坐标错位。
  6. 以结节中心为锚点,裁剪出包含结节和一定周围区域的立方体块(如64x64x32),保存为volume.npy和mask.npy。同时从无结节区域随机裁剪等量的负样本块。
  7. 生成索引文件,记录所有正负样本路径和元信息。
  8. 写一个可视化脚本,随机挑几个样本,把图像块和掩码叠加显示,肉眼检查裁剪位置和掩码质量。检查通过之后,才开始进入训练环节。

每一步都建议打印日志:病例ID、处理后的体积形状、体素间距、结节数量、每个结节掩码的最大值等等。这类日志是后面排查问题的第一手资料,别偷懒省掉。

5.1 可视化检查的必要性

我见过不少同学跳过可视化检查直接开训练,然后GPU烧了一下午,loss曲线诡异,最后才发现数据集里混了不少空白切片和错误掩码。如果你在数据处理阶段就做充分的检查,这种问题几乎可以提前发现。

可视化检查最简单的形式是切片展示:从每个裁剪块里拿中间几层,把图像灰度图、掩码边缘或半透明叠加图同时画出来。

import matplotlib.pyplot as plt def visualize_sample(image_3d, mask_3d, slice_idx=None): if slice_idx is None: slice_idx = image_3d.shape[0] // 2 plt.figure(figsize=(12, 4)) plt.subplot(1, 3, 1) plt.imshow(image_3d[slice_idx], cmap='gray') plt.title('CT Image') plt.subplot(1, 3, 2) plt.imshow(mask_3d[slice_idx], cmap='hot', alpha=0.6) plt.title('Mask') plt.subplot(1, 3, 3) plt.imshow(image_3d[slice_idx], cmap='gray') plt.imshow(mask_3d[slice_idx], cmap='hot', alpha=0.3) plt.title('Overlay') plt.show()

肉眼看一遍基本就能发现坐标错位、掩码缺失、裁剪边界擦到结节等明显问题。这一步值得花时间,因为发现的每一个问题,背后省掉的都是几小时的无效训练时间。

6. 常见问题与排查技巧实录

数据处理的坑多且隐蔽。我把实操中真实遇到的高频问题和排查思路整理成一张速查表,你在跑流程的时候碰见了,对照着看能省不少事。

问题现象可能原因排查与解决方案
读取DICOM后切片顺序乱没有按ImagePositionPatient排序,仅按文件名排序用z轴坐标排序;同一系列内文件名顺序不一定等于空间顺序
图像全黑或全白HU转换时RescaleIntercept没应用,或窗口化范围设置错误先打印HU数组的min/max,再用窗口化映射到8位
XML解析后轮廓点数量为0XML节点路径写错,或者这个结节只有中心点没有轮廓用xpath先打印出所有节点名和层级,再定位正确路径
重采样后图像变形严重新spacing和方向矩阵设置不对,或插值方法选错确认原始spacing读取无误;掩码必须用最近邻插值
掩码和图像错位对图像做了裁剪/缩放后,没有对标注坐标做相同变换严格保持“图像变换志、标注同步变换”原则,每步打印输出形状
正样本太少,训练集严重不平衡只取了完整轮廓结节,忽略部分标注,或裁剪尺寸太小漏掉了部分结节检查每个病例的结节数和掩码体素总数;调整裁剪窗口大小
训练集和验证集有重叠随机划分时同一个病例的多个块被分到了不同集合按病例ID划分数据集,不要按样本块划分,防止数据泄漏
加载数据时内存爆炸一次性把所有Numpy数组读进内存改用生成器或按索引逐个读取,控制单批加载大小

6.1 数据泄漏问题:按病例划分是不可妥协的底线

数据泄漏是医学影像项目里特别容易犯、又特别隐蔽的错误。如果按处理好的样本块随机划分训练集和测试集,同一个病例的相邻切片块会同时出现在两边,模型在测试集上的指标会虚高,而且这种虚高在真实部署时不会体现,因为新病人本来就是全新的扫描体数据。所以划分类别时必须以病例为单位,同一个病例下的所有块要么进训练,要么进验证,绝不分家。

6.2 关于掩码空洞和边缘光滑处理

XML标注的轮廓点通常比较稀疏,填充出来的掩码可能会存在空洞,或者边缘呈锯齿状。我的建议是先不要着急平滑处理:模型的输出是概率图,后处理阶段你完全可以再用条件随机场或形态学操作细化结果。训练数据保持标注原貌,可以让模型学会应对边缘锯齿,等推理时再做优化,效果会更好。如果硬在训练前平滑掩码,反而可能抹掉边界附近的真实梯度信息。

7. 后续项目衔接:这套数据怎么用

数据集处理到这个程度,后面紧接着做的事情就是构建训练管线了。整理好的volume.npy和mask.npy可以直接被PyTorch的Dataset类读取,然后在__getitem__里做数据增强和归一化。一般推荐对图像块做z-score标准化(减均值除以标准差),均值标准差从训练集所有体素里统计一次,存成参数供推理时复用,不做逐样本归一化,保证输入分布的一致性。

再往后就是选模型了。肺结节分割现在主流是3D U-Net,它是对儿科体数据最友好的基线模型。你也可以尝试VNet、Attention U-Net这些加了注意力机制和改进连接方式的模型,它们通常能在结节这种小目标上多提取一点有效特征。但我想强调一点:模型设计改变的是上限,数据处理决定的是下限。数据做得干净、合理,哪怕用最普通的3D U-Net也能达到不错的效果——这是我这几个项目下来最深的体会。

所以我建议你如果准备入坑医学影像分割,不要急着上大模型,先把数据处理流程练成肌肉记忆,代码模块化、可复用、可解释,后面换数据集、换任务,只需要替换解析部分即可。肺结节分割搞通之后,肺叶分割、肺纹理分析、甚至其他脏器的影像分析,处理逻辑基本都是同一个套路。

比较重要的一点是:处理完的数据要留一份“原始处理版本”和“可训练版本”。原始处理版本是重采样后、窗口化前的HU数组,为了调试和换窗口方案时重新处理;可训练版本才是真正输入模型的。这两个分开,能避免每次实验前都重新跑一遍重采样。

最后分享一个小技巧:处理大批量病例时,强烈建议加断点续跑机制。每个病例处理完,在输出目录里做个名为.done的空文件标记,遇到程序中断后,下次启动时跳过已经处理完的病例。这个机制能让你在几十上百个病例的批处理过程中安心很多,不用每次从头再来。

关于数据处理第一期,我就先聊到这里。整套流程下来,你就已经拥有了一个干净、规范、可复现的肺结节分割数据集。下一期我会顺着这个数据往下写训练管线的搭建和3D U-Net的实际实现,到时候见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询