简介:这份资源面向医学影像AI方向的开发者与学习者,提供一套基于深度学习的肝癌影像诊断完整代码方案,适合具备Python基础、希望上手医学图像分割与分类实践的中级读者。压缩包共7个文件,以4个Python脚本为核心,涵盖数据预处理、数据集加载、模型定义与训练流程,另附2个txt说明与1个md文档,整体仅8KB,轻量便于快速阅读与二次开发。环境基于x64 Linux与Anaconda3、Python3.6搭建,需安装TensorFlow 1.8.0及MedPy、nibabel、SimpleITK、scikit-image、opencv-python等医学影像与科学计算库,GPU机器可选用tensorflow-gpu版本自动配置CUDA与CUDNN依赖。目前已有37人学习,读者可借此理解肝癌影像从读取、预处理到建模训练的完整链路,掌握医学图像处理常用工具与排错思路,为复现或改进相关诊断模型提供参考。
1. 肝癌影像 AI 诊断:从 DICOM 到推理结果,一条能跑通的工程链路
影像科每天产出成千上万张腹部增强 CT,肝癌的早筛与随访高度依赖医生逐帧阅片,一个病人动辄三四百层,漏诊和疲劳是绕不开的现实。大数据医疗与 AI 诊断这两个词被反复提起,但真正落到肝癌影像上,从业者最关心的不是概念,而是:一堆 DICOM 文件怎么变成模型能吃的张量,肝脏和病灶怎么分出来,模型输出怎么回到医生看得懂的层面。这个方向适合有 Python 基础、懂一点深度学习、手上有影像数据或能拿到公开数据集的工程师和影像科研人员。它不需要你从零训练一个基础模型,更多是把数据工程、分割、分类、可视化这几段拼成一条稳定链路。下面按我实际做过的顺序,把这条链路拆开讲清楚,包括参数怎么设、哪里容易翻车。
2. 数据准备:DICOM 序列怎么变成可训练的体数据
2.1 为什么肝癌影像不能直接当普通图片处理
腹部增强 CT 是三维体数据,一次检查包含平扫、动脉期、门脉期、延迟期多个序列,每个序列又是几百层切片。直接拿单张切片训练,会丢掉层间连续性,而肝癌的强化特征恰恰依赖三期对比:动脉期明显强化、门脉期强化减退,这是鉴别肝癌和血管瘤、转移瘤的关键。所以工程上第一步不是写模型,而是把同一病人的多期序列对齐、重采样到统一体素间距,再切成三维块或带上下文的多通道切片。
常见做法是用 SimpleITK 或 pydicom 读取,按 SeriesInstanceUID 分组,用 ImagePositionPatient 的 z 值排序切片。这里有个血泪经验:不同设备、不同扫描协议的层厚和像素间距差异很大,有的 0.7mm,有的 5mm,不重采样直接混着训练,模型学到的全是扫描参数而不是病灶特征。
2.2 用 pydicom 读取并重采样的最小脚本
import pydicom import numpy as np import SimpleITK as sitk from pathlib import Path def load_series(series_dir): """读取一个 DICOM 序列,按 z 轴位置排序,返回体数据和元信息""" slices = [] for f in Path(series_dir).glob("*.dcm"): ds = pydicom.dcmread(str(f)) # 跳过定位像和没有像素数据的文件 if not hasattr(ds, "PixelData"): continue slices.append(ds) # 按 ImagePositionPatient 的 z 值排序,保证层序正确 slices.sort(key=lambda s: float(s.ImagePositionPatient[2])) # 用 RescaleSlope/Intercept 把像素值转成 HU volume = np.stack([ s.pixel_array.astype(np.float32) * float(s.RescaleSlope) + float(s.RescaleIntercept) for s in slices ]) spacing = ( float(slices[0].SliceThickness), float(slices[0].PixelSpacing[0]), float(slices[0].PixelSpacing[1]), ) return volume, spacing def resample_volume(volume, spacing, target=(1.0, 1.0, 1.0)): """把体数据重采样到统一体素间距,默认 1mm 各向同性""" img = sitk.GetImageFromArray(volume) img.SetSpacing(spacing) resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target) resampler.SetSize([ int(round(volume.shape[i] * spacing[i] / target[i])) for i in range(3) ]) resampler.SetInterpolator(sitk.sitkLinear) out = resampler.Execute(img) return sitk.GetArrayFromImage(out)这段代码的逻辑是:先按物理位置排序切片,避免层序错乱;再用 RescaleSlope 和 RescaleIntercept 把原始像素转成 HU 值,这一步不做的话,不同机器的灰度范围完全不可比;最后统一重采样到 1mm 各向同性。参数上,target 设成 (1.0, 1.0, 1.0) 是腹部 CT 的常用选择,层厚太薄的可以适当放宽到 1.5mm 省显存。插值方式选线性,标签掩膜重采样时要换成最近邻,否则会出现不存在的类别值。
2.3 窗宽窗位与归一化的取舍
CT 的 HU 范围从 -1000 到 3000 以上,直接送进网络,肝脏和病灶的对比度会被压缩。常见做法是截断到腹部窗,比如 [-100, 300] HU,再线性归一化到 [0,1]。也有用多窗融合的,把肝窗、软组织窗分别归一化后拼成多通道。我一般先用单窗 [-100, 300] 跑基线,效果不够再加通道。注意别用整幅图的 min-max 归一化,因为不同病人体内气体和骨骼占比不同,会导致同一组织在不同病人间灰度漂移,这是很多人翻车的地方。
3. 分割与分类:肝脏、病灶、良恶性三段式怎么搭
3.1 先分割后分类,还是端到端
肝癌 AI 诊断的落地路径基本分两派:一派是端到端分类,直接把三期 CT 送进 3D CNN 输出良恶性;另一派是先分割肝脏和病灶,再基于病灶区域做分类。端到端省事,但可解释性差,医生不信任一个说不清依据的结论。先分割后分类的好处是,分割结果本身就是医生能核对的中间产物,病灶的形态、强化方式可以量化成特征,分类器的输入也更聚焦。
我一般推荐三段式:肝脏分割 → 病灶分割 → 良恶性分类。肝脏分割用 nnU-Net 这类成熟框架,公开数据集上 Dice 能到 0.95 以上;病灶分割难度大得多,小病灶、边界模糊的病灶 Dice 经常只有 0.6 到 0.7,这时候不要死磕分割精度,可以把病灶周围一定范围的肝实质一起裁出来做分类,让模型自己学上下文。
3.2 病灶裁剪与数据增强的参数设置
import numpy as np from scipy.ndimage import rotate, zoom def crop_lesion(volume, mask, margin=16, size=(64, 128, 128)): """以病灶为中心裁剪固定大小的块,margin 是病灶外扩的体素""" coords = np.argwhere(mask > 0) if len(coords) == 0: return None center = coords.mean(axis=0).astype(int) # 按 size 裁剪,边界不足时用最小值填充 starts = [max(0, center[i] - size[i] // 2) for i in range(3)] ends = [min(volume.shape[i], starts[i] + size[i]) for i in range(3)] patch = volume[starts[0]:ends[0], starts[1]:ends[1], starts[2]:ends[2]] # 补齐到固定尺寸 pad = [(0, size[i] - patch.shape[i]) for i in range(3)] patch = np.pad(patch, pad, mode="minimum") return patch def augment_patch(patch): """训练时的轻量增强:随机翻转、小角度旋转、强度扰动""" if np.random.rand() < 0.5: patch = patch[::-1, :, :] if np.random.rand() < 0.5: patch = patch[:, ::-1, :] angle = np.random.uniform(-10, 10) patch = rotate(patch, angle, axes=(1, 2), reshape=False, order=1) patch = patch * np.random.uniform(0.9, 1.1) + np.random.uniform(-0.05, 0.05) return patch裁剪的逻辑是以病灶质心为中心取固定大小的块,这样每个样本尺寸一致,方便组 batch。margin 参数控制外扩范围,设 16 到 32 体素比较常见,太小会丢掉病灶边缘的强化特征,太大则引入过多无关肝实质。增强只做翻转、小角度旋转和强度扰动,不要做大幅弹性形变,肝脏是刚性器官,形变过大会产生不真实的解剖结构。强度扰动模拟不同扫描剂量和重建参数的差异,对跨中心泛化有帮助。
3.3 分类头的选型与损失函数
分类网络可以用 3D ResNet 或 EfficientNet 的 3D 版本,输入裁剪后的多期 patch,输出良恶性概率。类别不平衡是常态,肝癌阳性样本往往只占少数,损失函数用 Focal Loss 或带类别权重的交叉熵。我一般先用加权交叉熵,权重按训练集类别频率的倒数设置,再观察验证集上的敏感性和特异性。如果敏感性明显偏低,再换 Focal Loss,gamma 设 2 左右。评估指标不要只看 AUC,临床上更关心固定特异性下的敏感性,比如特异性 90% 时敏感性能不能到 85% 以上。
4. 训练与推理:显存、batch、学习率这些参数怎么定
4.1 3D 网络的显存账要提前算
3D 卷积的显存占用是 2D 的数倍,一个 64×128×128 的 patch,单样本前向就要几百 MB。常见做法是用混合精度训练,把显存压下来,同时用梯度累积模拟大 batch。batch size 设 2 到 4 是 3D 分割的常态,分类任务因为 patch 小一些,可以到 8 或 16。学习率用 1e-4 起步,配合余弦退火,warmup 几百步避免初期震荡。如果显存不够,优先降 patch 尺寸而不是降通道数,通道数降太多会损失表达能力。
4.2 训练循环里的关键检查点
import torch from torch.cuda.amp import autocast, GradScaler def train_one_epoch(model, loader, optimizer, criterion, scaler, device): model.train() total_loss = 0.0 for step, (images, labels) in enumerate(loader): images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with autocast(): logits = model(images) loss = criterion(logits, labels) # 混合精度反向传播,scaler 自动处理梯度缩放 scaler.scale(loss).backward() # 梯度裁剪防止 3D 网络梯度爆炸 scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() total_loss += loss.item() # 每 50 步打印一次,观察 loss 是否稳定下降 if step % 50 == 0: print(f"step {step}, loss {loss.item():.4f}") return total_loss / len(loader)这段训练循环里,混合精度和梯度裁剪是两个保命设置。3D 网络参数量大,不加梯度裁剪很容易在某个 batch 上梯度爆炸,loss 直接变 NaN。max_norm 设 1.0 是常用值,太大起不到保护作用,太小会拖慢收敛。打印频率设 50 步,是为了及早发现 loss 不降或震荡,别等到跑完一个 epoch 才看。验证集要每个 epoch 跑一次,记录敏感性和特异性,保存特异性 90% 时敏感性最高的那个 checkpoint,而不是只存 loss 最低的。
4.3 推理阶段的滑窗与后处理
整卷推理时,体数据太大没法一次送进网络,要用滑窗。窗口大小和训练 patch 一致,步长设窗口的 1/2 到 2/3,重叠区域取平均或投票。分割结果出来后,去掉小于一定体素的连通域,比如小于 50 个体素的当作噪声。分类结果可以结合病灶体积、强化幅度做简单规则后处理,比如动脉期强化幅度低于某个阈值的直接判良性,减少假阳性。这些阈值要在验证集上定,不要拍脑袋。
5. 避坑与排查:肝癌影像 AI 落地最常见的五个坑
5.1 层序错乱导致模型学到伪影
现象:训练 loss 正常下降,但验证集表现远差于训练集,可视化发现模型关注的区域和病灶对不上。原因:DICOM 切片按文件名排序,而文件名顺序和实际层序不一致,导致体数据在 z 轴上是乱的。解决:一律按 ImagePositionPatient 的 z 值排序,排序后打印首尾层的 z 值确认单调。这个坑在跨设备数据上尤其常见,我见过文件名从 IM0 到 IM200 但实际层序是反的。
5.2 标签泄漏让评估虚高
现象:验证集 AUC 到 0.98,上线后一塌糊涂。原因:同一病人的不同病灶被分到了训练集和验证集,模型记住了病人特征而不是病灶特征。解决:按病人 ID 划分数据集,不是按切片或病灶。如果数据来自多个中心,还要做中心间的交叉验证,留一个中心完全不参与训练,看泛化能力。
5.3 窗宽窗位设置不当淹没小病灶
现象:小病灶在预处理后的图像上几乎看不见,模型敏感性很低。原因:用了全 HU 范围归一化,肝脏和病灶的灰度差被压缩到很小。解决:截断到腹部窗 [-100, 300],或者针对小病灶单独用更窄的窗。可以在训练时随机在几个窗之间切换,让模型适应不同对比度。
5.4 类别不平衡导致模型偏向阴性
现象:准确率很高,但敏感性只有 50% 左右,模型几乎全判阴性。原因:阳性样本占比低,交叉熵被阴性样本主导。解决:用加权交叉熵或 Focal Loss,权重按类别频率倒数设置。同时调整分类阈值,不要用默认的 0.5,在验证集上找使敏感性达标的最大阈值。
5.5 推理滑窗步长过大漏掉病灶
现象:整卷推理的分割结果里,小病灶时有时无。原因:滑窗步长太大,病灶刚好落在窗口边缘,被截断后网络识别不出。解决:步长设窗口的 1/2 以下,重叠区域做平均。如果显存允许,窗口可以适当放大,减少边缘效应。推理完还要检查连通域,把被截断的病灶合并回来。
6. 把模型输出变成医生能用的东西:可视化与量化报告
模型跑通只是第一步,真正让影像科愿意用的是把输出变成可核对、可量化的东西。我一般会做三件事:在原始 CT 上叠加病灶分割的彩色掩膜,标注良恶性概率和关键量化指标;生成病灶的体积、最大径、动脉期和门脉期的强化幅度对比;把三期图像和分割结果拼成一张对比图,医生一眼能看出模型关注的是不是病灶本身。
量化指标里,强化幅度比单纯的概率更有说服力。计算方式是病灶区域在动脉期和门脉期的平均 HU 差值,肝癌通常表现为动脉期高强化、门脉期强化减退,这个差值在良恶性之间有区分度。把概率和这些指标一起呈现,医生可以自己判断模型结论是否合理,而不是被动接受一个黑匣子。
验证方法上,除了常规的 AUC、敏感性、特异性,我习惯做一层病灶级别的分析:把验证集按病灶大小分层,看模型在不同大小病灶上的表现差异。小病灶(小于 2cm)的敏感性往往明显低于大病灶,这个差距如果太大,说明模型对小病灶的识别能力不足,需要针对性补充小病灶样本或调整裁剪策略。另一个习惯是固定随机种子跑三次训练,看指标的波动范围,波动超过 3 个百分点的,说明数据量或模型稳定性不够,别急着上线。
这套链路我从数据清洗一路踩到可视化,最大的教训是:别在模型结构上反复折腾,数据质量和预处理的一致性才是决定上限的东西。同一批数据,把层序、重采样、窗宽这三件事做扎实,比换三个网络结构带来的提升都大。希望帮到你。
本文还有配套的精品资源,点击获取