CT扫描做3D图像分类这个题目,我第一次看到Keras官方示例库里的那期教程时,第一反应是“模型结构肯定很复杂”。等我把整个示例的代码和数据处理流程走了一遍之后,才反应过来:真正劝退大多数人的根本不是3D卷积长什么样,而是“怎么把一堆几百层的CT切片喂进模型”这件听起来简单、做起来全是细节的事情。
这篇内容我想从原理到实操完整拆解一遍CT扫描3D图像分类任务:讲清楚CT数据为什么天生就是“3D图片”,3D卷积到底在算什么,预处理阶段的几个关键数字(比如-1000和400)从哪里来,以及搭一个能跑通的训练管线最容易被忽略的环节。适合已经开始学深度学习、想从2D图像分类跨到3D医学影像分类,或者被CT数据加载逼疯的读者,直接拿这条思路去套你自己的数据集。
1. 3D卷积到底在算什么,以及CT这类“3D图片”和普通照片的本质差异
1.1 CT扫描为什么天生就是3D数据:从切片堆叠到体素密度
先说清楚一个基础认知:CT本质上不是“一张图”,而是“一叠图”。CT机绕人体扫一圈之后,得到的是沿身体长轴方向一层一层切出来的横断面图像,每一层的分辨率通常是512×512或者略低,层与层之间有一定的间距,比如1mm到3mm。这一叠切片按顺序堆起来,就构成了一个三维体数据。
这个三维体数据里的基本单位叫“体素”(voxel),它对标的是普通2D图像里的“像素”(pixel)。像素只有平面上的x、y两个坐标,体素则额外多了一个z坐标,代表层与层之间的厚度。所以在代码里,一个完整的CT扫描样本的长相是(depth, height, width),也就是常说的D、H、W三个维度,后续在送入神经网络时再补上通道维,变成(depth, height, width, channel)。
这里还得补充一个概念:CT图像里每个体素对应的数值不是随便的灰度,而是“CT值”,也叫亨氏单位(HU)。这个值的物理含义是组织对X射线的衰减程度,空气大约是-1000,水是0,骨头可以达到几百甚至上千。也就是说,CT数据的每一个值都携带了物理意义,这和普通照片里“0到255表示亮度”完全不同。
这带来的影响是:你不能把CT图当成普通灰度图直接扔进网络。比如空气区域大量集中在-1000附近,而肺组织大概在-500到100之间,如果你想识别肺部相关病灶,就必须把数值窗口截断到真正有信息量的区间,否则模型会把大量算力花在区分“空气”和“肋骨”这种无意义的任务上。
1.2 3D卷积和2D卷积的对应关系:多出来的那个维度去哪了
理解了体素概念之后,3D卷积其实很好理解。2D卷积是拿一个小窗口(比如3×3的卷积核)在图像的宽和高两个方向上滑动;3D卷积则再多一个深度方向,也就是拿一个3×3×3的小立方体,在体素空间里同时沿宽、高、深三个方向滑动。
从代码维度上说,2D卷积输入是(batch, height, width, channels),3D卷积输入则变成(batch, depth, height, width, channels)。输出维度除了空间上的宽高之外,也会保留一个深度维,所以整个特征图也是三维的。
代价当然也摆在明面上:3D卷积的参数量和计算量不是线性增长,而是呈立方级扩张。一个3×3×3的卷积核,如果输入输出通道都是64,那么这一层的权重数量就是3×3×3×64×64,算下来超过11万参数,是同等通道数2D卷积的3倍。所以3D网络通常设计得比2D网络更浅,通道数也更克制,否则显存和训练时间都吃不消。
1.3 这个示例真正难的地方不在模型,而在数据准备
我在把Keras这个官方示例完整跑一遍之后,最大的感受是:模型代码可能二十行就能写完,但数据从原始CT文件到能送入网络的张量,中间要经历读文件、解析元信息、重采样、截断数值、加掩膜、切片采样、归一化,每一步都可能出错,而且错误还特别隐蔽。
官方案例的流程大致是:先从某个公开的肺部CT数据集里读取携带病灶标注的扫描数据,这些数据通常是NIfTI或NRRD格式,每个扫描对应一个三维数组和一个描述体素间距的元信息文件。然后做重采样和数值截断,再通过数据加载器在每次读取时随机抽取一个固定深度的子块,既缓解了显存压力,也算一种在线增广。最后把子块送入一个堆叠了若干3D卷积层的分类模型,去判断这个扫描里是否含有某个肺部疾病病灶。
所以这篇文章的后半部分,我会按照这条流水线逐段拆解,重点放在那些你照着官方示例能跑通、但自己换数据就翻车的细节上。
2. 拿到CT原始数据之后的几步“洗数据”:那个-1000到400的截断到底在干嘛
2.1 CT值是物理量,不是随便的灰度图
第一次接触CT数据的人,最容易犯的错就是把CT值当成普通图像像素做归一化。实际上CT值范围极大,从-1000的空气到接近3000以上的金属假体都有。如果直接对整个数据做0到1的缩放,绝大多数数值会被压缩到很窄的区间里,软组织之间的差异会变得几乎不可见。
正确的做法是先做“窗宽窗位”截断,也就是只保留我们关心的CT值范围,超出范围的直接置为边界值。对于肺部这个场景,常见的截断范围是[-1000, 400],因为有诊断意义的区域基本都落在肺实质、血管和软组织的CT值区间里,超过400的基本是骨头或金属,低于-1000的基本是纯空气,这些信息对分类不仅没帮助,反而会占用模型容量。
截断之后再做归一化,比如把[-1000, 400]线性映射到[0, 1],这时候模型看到的输入数值才是有意义的相对密度。这个步骤和在2D图像里做标准化不太一样,不是简单的“减均值除方差”,而是先剪到物理上有意义的区间,再做线性缩放。如果你处理的不是肺部CT,这套数字要跟着改。比如看腹部就把窗口调宽,看骨窗就把上限调到1000以上。
2.2 体素间距重采样:为什么要统一成某个固定间距
CT扫描来自不同设备、不同医疗机构,层厚和面内分辨率五花八门。有的数据层厚1mm,有的层厚3mm;有的面内分辨率是0.6mm,有的是0.8mm。如果我们不处理这个差异,模型在这个数据集上学到的东西,到另一个数据集上就会失效,原因很简单:同一个病灶,在不同间距下呈现的尺寸和形态在体素空间里完全不同。
解决办法是重采样到统一间距。Keras官方示例采用的是把整个扫描重采样到(2mm, 2mm, 2mm)左右的各向同性体素。具体做法是根据原始间距和目标间距的比值,计算出重采样后的体素尺寸,再用线性插值重建三维数组。这一步有一个便宜的写法:直接用scipy.ndimage.zoom对每个维度的比例缩放数组,快捷而且效果足够好。
重采样之后别忘了另一件事——标注掩膜也要同步变换。数据集里每个扫描会附带一个标注区域掩膜,它标记了病灶在哪些体素上存在。如果你只对图像重采样而不对掩膜做同样的变换,后续模型看到的“病灶位置”就全错位了。官方示例在预处理阶段同步对掩膜做重采样,然后把它叠加到CT体数据上,这样每个子块被抽出来时,就知道里面有没有病灶、占比多大。
2.3 掩膜标注与病灶区域:模型看的不是整颗肺
刚才说到掩膜,其实这是CT分类相对自然图像分类一个很大的区别:自然图像分类通常给整张图一个标签,而医学影像很多情况下是在给定的区域内有病灶,其他地方都是正常组织。直接用整张CT的标签去做分类,训练信号会被大量正常体素稀释掉。
处理起来有两类策略:一是只取有掩膜的体素区域进行训练,让模型只关注病灶特征;二是把掩膜作为额外输入通道喂给模型,让模型自己去学哪些部位重要。官方示例的做法偏向前者,数据加载器在做切片时会更倾向于抽取包含掩膜的区块,让每个训练子块里都有可见的病灶特征。
我自己换数据集实测的时候发现一个坑:有些公开数据集的掩膜标注得粗,边界往外扩了一大圈,或者在某个层上是断的。这时候如果无脑采样子块,很可能抽出来的子块里病灶只占半个角落,或者根本被边缘切片截断。更稳妥的做法是除了掩膜本身,再计算一个“病灶中心坐标”列表,采样时优先以中心点为核心,在它周围随机偏移几个体素,保证子块里病灶完整。
3. 内存友好才是3D训练的真本事:构建按需读取的数据管线
3.1 一次性把整批volume全读进显存?这不是方案
我最早试过的一个简陋做法,是在训练开始前把所有CT扫描全部加载到内存里,然后做数据预处理,再转成NumPy数组作为训练数据。当时用的数据集不算大,几十个扫描,但每个扫描重采样后也有大约(150, 256, 256),一个样本的浮点数组就有几十MB。几十个样本还好,扩大到一个完整数据集的几百个扫描,内存直接吃紧。
就算内存够大,还有显存这道坎。一个完整的(150, 256, 256)体积数据,就算只放一个样本,输入张量也有接近千万的体素,在显存里光输入就占掉几十MB。但你训练时还要算中间特征图、梯度、优化器状态,一个3D网络跑起来,单样本显存轻松上GB。所以把整颗volume直接喂进模型,基本等于给自己挖坑。
正确的思路是:让数据管线“按需读取”并“按块采样”。每次只从三维体积中抽取一个固定大小的子块,比如(32, 128, 128)左右,把子块作为模型输入。这样显存占用从“整颗肺”降到了“一小筒”,batch size才有得谈。
3.2 自定义DataLoader的核心逻辑
当时官方示例里的做法,我简化之后觉得非常值得讲清楚,核心逻辑就四步:一是从磁盘上读取预处理好的三维数组和掩膜,二是从掩膜区域或全图区域随机选一个中心点,三是从中心点周围抠出固定尺寸的三维子块,四是把子块数值、标签组装成一个sample返回。
关键点在于“每次读取时随机采样”。这意味着同一个CT扫描,每轮训练看到的子块位置可能不同,既实现了空间增广,又让每个epoch里模型接触到的有效样本数量比整图数量大得多。下面是这个自定义数据加载器的骨架,我精简过,但思路完全保留:
import numpy as np from tensorflow import keras TARGET_SIZE = (32, 128, 128) POS_RATIO = 0.5 # 阳性样本(包含病灶)的采样比例 class CtVolumeSequence(keras.utils.Sequence): def __init__(self, sample_ids, labels, volume_dir, batch_size=4): self.sample_ids = sample_ids self.labels = labels self.volume_dir = volume_dir self.batch_size = batch_size def __len__(self): return int(np.ceil(len(self.sample_ids) / self.batch_size)) def __getitem__(self, idx): batch_ids = self.sample_ids[idx * self.batch_size:(idx + 1) * self.batch_size] batch_x = [] batch_y = [] for sid in batch_ids: volume, mask = load_volume_and_mask(sid, self.volume_dir) sub_volume, label = self._sample_patch(volume, mask, self.labels[sid]) batch_x.append(sub_volume) batch_y.append(label) return np.stack(batch_x), np.array(batch_y) def _sample_patch(self, volume, mask, label): depth, height, width = volume.shape d, h, w = TARGET_SIZE if label == 1: # 从病灶区域采样:优先病灶中心,小幅随机偏移 pos_voxels = np.argwhere(mask > 0) center = pos_voxels[np.random.randint(len(pos_voxels))] center += np.random.randint(-8, 9, size=3) else: # 从非病灶区域随机采样 center = np.array([ np.random.randint(d // 2, depth - d // 2), np.random.randint(h // 2, height - h // 2), np.random.randint(w // 2, width - w // 2), ]) # 边界裁剪 start = center - np.array([d, h, w]) // 2 start = np.clip(start, 0, np.array([depth, height, width]) - np.array([d, h, w])) patch = volume[start[0]:start[0] + d, start[1]:start[1] + h, start[2]:start[2] + w] patch = patch / 1.0 # 数值已在预处理阶段截断并缩放 return patch[..., np.newaxis], label这里有几个值得展开的细节。batch_size在3D训练里不能照搬2D的经验,官方示例里用的batch size非常小,4就已经相当吃显存了,我自己实验时甚至试过1和2。数据处理阶段使用的Sequence类由Keras提供,好处是训练时会做多进程预取,不会让GPU空等CPU切片。
另一个细节是阳性样本的采样比例。为了让每个batch里不是清一色正常体素,我设了一个POS_RATIO,或者像上面这样简单粗暴地区分阳性和阴性采样逻辑。实际使用时还可以加入更细腻的控制:让阳性子块和阴性子块按比例混合在每个batch里,这样模型能同时看到两类样本,梯度方向更稳定。
3.3 在线增强与切块策略的权衡
3D数据的在线增强,自由度比2D小很多。旋转和翻转要尤其谨慎,因为医学影像是有方向语义的,左右翻转还好,上下翻转或者绕z轴旋转90度,在很多任务里会导致解剖结构错位。我见过不少初学者把2D图像处理里的随机旋转直接搬过来,结果模型在验证集上表现时好时坏,找半天原因才发现是增广把器官朝向搞乱了。
比较安全的增广方式是随机平移几个体素、随机加一点强度扰动、随机翻转某个轴(如果任务对镜像对称不敏感)。平移增广其实已经包含在随机采样逻辑里了,因为每次采样中心点都有随机偏移,这个比显式的RandomTranslation更自然,因为它发生在三维层面,而不是作用在渲染出来的二维切片上。
还有一个细节是“采样顺序”。如果阳性样本很少,纯靠随机均匀采样,抽到病灶子块的概率会非常低,模型几乎学不到正类特征。这也是为什么要把病灶中心坐标单独拎出来,在阳性样本上做“中心采样”。如果你用的数据集病灶特别小,还可以考虑用小步长的密集采样策略,让每个病灶体素都有机会被抽到,代价是正样本之间相关性变大,可能造成过拟合,需要配合更重的Dropout使用。
4. 搭建3D CNN骨架的取舍:哪一层值得花钱
4.1 从卷积到全连接的每一层设计逻辑
3D CNN的经典骨架其实不复杂,官方示例也走的是保守路线:几个3D卷积层配合3D池化提取特征,之后接全局池化或者直接拉平接全连接层。我把它的层间逻辑拆开讲一下,你换自己的任务时也能知道每层为什么放在那里。
第一组卷积通常从较小的通道数开始,比如8到16个通道,配合一个3D最大池化把空间尺寸迅速降下来。这里有一个细节:3D最大池化窗口和步长要匹配,如果你输入子块是(32, 128, 128),池化两次之后空间分辨率降到(8, 32, 32),体素数降了64倍,后续卷积就算通道数多了,计算量也完全可控。
中段卷积层逐步加大通道数,从16翻到32、64,同时继续保持空间下采样。这个阶段的作用是把低级特征组合成高级语义特征,比如局部纹理、边缘轮廓逐步组合成病灶形态。最后一组特征图经过拉平或全局池化,接一个或多个全连接层做分类决策。
一个关键取舍在“拉平”和“全局池化”之间。全局平均池化可以大幅减少参数量,还能保证输入尺寸灵活,但它会丢掉空间位置信息;而拉平再接全连接层,保留了每个位置的特征,代价是参数暴增。官方示例的做法是拉平后接一个带Dropout的Dense层,因为数据量本来有限,全连接层在这里更像是一个“特征组合器”,用Dropout压一压过拟合风险。我自己实践下来,在医学小数据集上这个选择比直接全局池化更稳,尤其是病灶只占很小区域的场景,位置信息确实有价值。
4.2 训练参数与收敛速度的现实考量
3D网络训练比2D慢一个数量级,这是很多初次接触的人没有心理预期的。官方示例给出的epoch数通常不大(几十个以内),每个epoch也跑得很快,因为数据量小、batch小,子块采样又很廉价。但如果你自己换大数据集,训练时长会急剧拉升。
优化器方面,示例用的是Adam配上默认学习率。3D任务里梯度方差比较大,Adam的逐参数自适应能给训练过程带来不小的稳定性,省去手动调学习率的精力。如果后续发现验证集精度上不去,可以先试着把学习率降一个数量级,再考虑改网络结构。
关于batch size,3D训练里一个直观的尴尬是:batch大一点,收敛确实更稳,但显存总和“差那么一点”。我当时的折中方案是batch设4,子块尺寸控制在(32, 128, 128),模型通道数最高只到64,这样可玩性最好。下表是我在单卡上测试不同配置的显存占用情况(近似值):
| 输入子块尺寸 | batch size | 峰值显存占用(约) | 说明 |
|---|---|---|---|
| (32, 128, 128) | 2 | 约4GB | 保守,训练很稳 |
| (32, 128, 128) | 4 | 约7GB | 大部分单卡可跑 |
| (64, 128, 128) | 2 | 约8GB | 需要中高端显卡 |
| (64, 128, 128) | 4 | 超12GB | 容易爆显存 |
这个表只针对浅层3D CNN,挖深之后数据会整体上浮。想要增大batch而不爆显存,优先压缩子块深度,而不是压缩batch。深度维采样得越短,每个样本包含的空间上下文越少,但batch内多样性更高,在很多小块病灶任务里反而表现不差。
4.3 数据不平衡问题:要不要第一轮就上class weight
CT分类里阳性和阴性样本数量往往相差悬殊,尤其是病灶稀少的场景。处理数据不平衡有一种做法是给少数类加大损失权重,这方向是对的,但我不建议第一轮训练就这么干。先把模型跑通、把基准精度拿下来,等验证集正常了再引入class weight,能省很多排查时间。
不先上class weight的原因在于:在一开始模型还在“啥都没学会”的阶段,加大少数类权重会让损失被少数类主导,梯度方向剧烈抖动,表现为训练曲线上下乱窜。与其这样,不如先让模型学会区分batch内部相对容易的特征,再通过后续的微调、阈值调整甚至Focal Loss把正类的召回拉起来。这是很多医学影像比赛参与者会用到的“先易后难”的节奏。
5. 实操比预期难缠的几件事:我的踩坑记录与最终训练流水线
5.1 全零判定为正常样本的阴差阳错
第一次跑通这个项目时,我惊喜地发现模型验证集精度高达0.95以上,一度以为自己的参数设置天赋异禀。后来把预测结果可视化出来才发现,模型对几乎所有样本都预测成了“正常”,因为阴性样本占比高,全猜阴性也能拿到漂亮的精度。这就是典型的类别不平衡陷阱,只看总体准确率根本无济于事。
想避免这个坑,一要在验证集上同时看每一类的精确率、召回率和混淆矩阵,不能只看accuracy;二要在数据加载器层面控制阳性子块的采样比例,让每个batch里都有足够多的正样本;三要调整决策阈值,不要默认0.5,而是根据验证集上precision-recall曲线的拐点去选分类边界。
5.2 验证集精度不错,可视化却全预测成类别0
这个现象比上一条更隐蔽。模型训练过程中,由于子块是随机采样的,验证集上每个样本只抽了一次子块,而这个子块可能恰好从无病灶区域采样,导致预测结果偏向阴性。这种情况下,模型本身未必差,而是评估方式和推演方式不一致。
解决办法是在推理阶段做“多子块投票”,也就是从每个扫描的多个位置采样固定数量的子块,分别过模型,把输出的概率取平均作为最终预测。这个技巧在2D图像里叫测试时增强,在3D里一样适用,而且由于CT扫描天然是三维的,多位置采样的成本比2D做多视角裁剪低得多。我实测下来,从单子块改成8个子块投票,在多数小病灶数据上都有明显提升。
5.3 显存爆掉之后的workaround
训练到一半报ResourceExhaustedError基本是3D训练的家常便饭。我遇到时的排查顺序是:先看是不是输入子块太大,压缩深度维;然后看是不是中间层通道数堆得太高,检查网络里有没有通道翻倍过于激进的地方;最后看是不是batch size的问题,降到2甚至1起步。这里有个小经验:在Keras里训练到中途爆显存,不要只靠改显存配置,先确认模型输入shape是否和你实际喂进去的能对上,有很多“爆显存”其实是因为某个维度计算失误导致特征图爆炸。
5.4 一套可以直接复制的训练流水线清单
这条流水线是我把Keras官方示例和自己在其他数据集上的实践结合起来之后归纳出来的一套固定流程,基本每一步都踩过坑才定下来的:
- 数据读取:使用自定义的
Sequence数据加载器,从磁盘按需读取NIfTI/NRRD等格式的体积数组,不预先一次性加载到内存。 - 预处理:统一体素间距(推荐各向同性2mm),对CT值做物理范围截断(肺部场景约-1000到400),再做线性归一化。
- 采样策略:阳性样本以病灶中心为锚点做随机偏移采样,阴性样本在全图范围内随机采样,batch内两类样本保持一定比例。
- 增广策略:以随机平移和少量强度扰动为主,避免任意旋转;可选的镜像翻转要看任务方向语义是否安全。
- 模型结构:3D卷积层+3D池化逐步下采样,末端拉平接Dropout+Dense分类,通道数克制,宁浅勿深。
- 训练策略:先用小batch、小子块把流水线跑通,验证集上不用只求高准确率,要看混淆矩阵与各类指标;确认无误后,再循序渐进地加大batch、引入类别权重,并尝试多子块投票推理。
这套流程里每一步都可以按自己的数据规模调整,但流程顺序尽量别乱跳。数据加载和采样策略是3D医学影像分类的地基,模型结构反而是最不容易出错的部分。
写在最后的几句实在话
玩CT扫描3D分类这段时间,我个人觉得最大的收获不是“学会了3D卷积”,而是开始理解医学影像数据和自然图像数据在底层逻辑上的不同。2D图像是“给人看的”,像素值本身没有绝对物理含义;CT是“给机器测量的”,每个数值都对应真实的组织密度。尊重这个本质区别,数据预处理就不会跑偏。
如果你第一次跑这个示例,别急着在模型上做花样,先把数据管线和可视化验证做扎实。把训练集里采样的子块画出来看一眼,确认病灶确实在子块里、数值范围确实合理,再启动训练。这一步能省下后续大量排查时间。等到你能稳定复现一套完整流程之后,后续的迁移学习、Focal Loss、多模态融合这些进阶操作,都不过是在这条已经跑通的地基上继续加砖罢了。