简介:本资源面向医学图像分割方向的初学者与算法工程师,提供一套基于DenseUnet的CT肺部左右肺分割完整实战方案,覆盖从数据准备到模型评估的全流程。压缩包共约2000个文件,以1984张png格式的CT切片与掩膜图像为主体,另含8个Python脚本、5个xml标注文件及2个txt说明文档,整体约236.76MB,目录结构清晰,便于按训练、验证、推理模块检索。其中train脚本可输出训练集与验证集的loss、IoU曲线、学习率衰减曲线及训练日志,evaluate脚本用于计算测试集的IoU、Recall、Precision与像素准确率,predict脚本则生成gt及gt叠加原图的掩膜结果,代码注释详尽,参照README即可快速训练自有数据。目前已有159人学习,适合希望掌握肺部多类别分割、理解DenseUnet网络结构与评估指标的读者参考实践。
1. 从一张 512×512 的 CT 切片说起:DenseUnet 左右肺分割到底能落地成什么
手里有一批新冠 CT 切片,文件名是coronacases_org_002_93.png这种格式,打开一看是灰度图,肺部和背景灰度接近,手动勾左右肺轮廓勾到怀疑人生。这个资源要解决的就是这件事:用 DenseUnet 把 CT 切片里的背景、左肺、右肺分成三类,输出掩膜,直接拿去做体积量化或者后续病灶分析。它包含完整代码和数据集,train、evaluate、predict 三个脚本各管一段,训练日志、loss 曲线、IoU 曲线、学习率衰减曲线、数据集可视化图都会自动生成,代码注释写得比较细,README 里写了怎么换成自己的数据。适合谁?做医学图像分割的算法工程师、需要快速搭一个肺部预处理流水线的研究生,以及想拿一个能跑通的多类别分割 baseline 来改的人。不适合指望开箱即用做临床诊断的——它是个工程起点,不是医疗器械。
2. DenseUnet 为什么适合做肺部分割:结构选型与数据组织
2.1 从 Unet 到 DenseUnet:密集连接解决了什么
Unet 的编码器-解码器加跳跃连接已经是分割任务的标配,但普通 Unet 在层数加深后容易出现梯度消失和特征复用不足。DenseUnet 把 DenseNet 的密集块塞进 Unet 的编码器和解码器里,每一层的输入来自前面所有层的输出拼接。对肺部 CT 这种目标边界模糊、左右肺之间只隔一条纵隔线的场景,密集连接让浅层的边缘纹理能更直接地传到深层,减少小目标(比如肺尖和肺底)被漏掉的情况。
具体到这份代码,编码器用 DenseBlock 堆叠,每个 DenseBlock 内部做BN → ReLU → Conv的瓶颈结构,块与块之间用过渡层做下采样。解码器对应地上采样,和编码器同分辨率层做跳跃拼接。最后 1×1 卷积输出 3 通道,对应背景、左肺、右肺。选 DenseUnet 而不是原始 Unet 的理由很实际:在数据量不大的情况下(这份数据集是几十张切片级别),密集连接带来的特征复用能缓解过拟合,同时参数量比同深度的 ResNet 编码器更可控。
2.2 数据目录怎么摆:图像和掩膜必须同名
拿到资源后第一件事不是跑训练,是确认目录结构。常见做法是把图像放一个文件夹,掩膜放另一个文件夹,文件名一一对应。这份资源里的切片命名是coronacases_org_002_93.png这种,掩膜通常同名但放在masks或labels目录下。如果掩膜是灰度图,像素值 0 是背景、1 是左肺、2 是右肺;如果是 RGB 彩色掩膜,需要先转成单通道索引图。
# 典型目录结构,先确认再动代码 dataset/ ├── images/ │ ├── coronacases_org_002_93.png │ ├── coronacases_org_002_123.png │ └── ... └── masks/ ├── coronacases_org_002_93.png ├── coronacases_org_002_123.png └── ...逻辑说明:训练脚本一般会遍历images目录,按文件名去masks目录找对应掩膜。如果掩膜缺失或者文件名对不上,训练会在数据加载阶段报错或者静默跳过,导致实际训练样本数比预期少。参数上,图像尺寸建议统一 resize 到 512×512 或 256×256,前者保留更多肺尖细节,后者省显存。我一般会先写个脚本统计一下掩膜里 0、1、2 的像素占比,如果某一类占比低于 1%,说明数据不平衡,需要在 loss 里加权重。
2.3 训练脚本里那几个必须改的参数
train 脚本会生成训练集和验证集的 loss、IoU 曲线、学习率衰减曲线、训练日志和数据集可视化图像。跑之前重点看这几个参数:batch_size、lr、num_epochs、num_classes。num_classes必须是 3,对应背景、左肺、右肺,改成 2 或者 4 都会让输出通道和标签对不上。lr初始值常见做法是 1e-3 或 1e-4,配合余弦退火或者 StepLR。如果 loss 曲线在前几个 epoch 就炸成 NaN,先把 lr 降到 1e-4 再试。
# 训练脚本里数据加载和模型初始化的关键片段(示意) import torch from torch.utils.data import DataLoader from dataset import LungDataset from model import DenseUnet # num_classes=3 是硬约束:背景、左肺、右肺 model = DenseUnet(num_classes=3).cuda() train_dataset = LungDataset(root_dir='dataset', split='train', img_size=512) val_dataset = LungDataset(root_dir='dataset', split='val', img_size=512) # batch_size 根据显存调,8G 显存从 4 开始试 train_loader = DataLoader(train_dataset, batch_size=4, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=4, shuffle=False, num_workers=2) # 交叉熵损失,如果类别不平衡可以加 weight 参数 criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)逻辑说明:LungDataset负责读图、resize、归一化和标签映射。CrossEntropyLoss默认对每个像素做 softmax 后算交叉熵,适合多类别分割。如果左肺和右肺像素数远少于背景,可以在CrossEntropyLoss里传weight=torch.tensor([0.1, 1.0, 1.0])这种,把背景权重压低。num_workers在 Windows 上设 0 更稳,Linux 上可以设 2 到 4。训练日志里重点看验证集 IoU,如果训练集 IoU 一直涨但验证集不涨,就是过拟合,加数据增强或者减模型深度。
3. 评估与推理:IoU、Recall、Precision 怎么读,掩膜怎么出
3.1 evaluate 脚本输出的四个指标分别意味着什么
evaluate 脚本用于评估模型,计算测试集的 IoU、Recall、Precision、像素准确率。这四个指标在医学分割里各有侧重。IoU 是交并比,衡量预测掩膜和真实掩膜的重叠程度,对边界敏感。Recall 是召回率,衡量真实肺部像素里有多少被找出来了,漏检多的时候 Recall 低。Precision 是精确率,衡量预测为肺部的像素里有多少是真的,误检多的时候 Precision 低。像素准确率是整体像素分类正确的比例,在背景占大头的时候会虚高,不能单独看。
# 评估指标计算的核心逻辑(示意) def compute_metrics(pred_mask, true_mask, num_classes=3): ious, recalls, precisions = [], [], [] for cls in range(1, num_classes): # 跳过背景类 pred_cls = (pred_mask == cls) true_cls = (true_mask == cls) intersection = (pred_cls & true_cls).sum().item() union = (pred_cls | true_cls).sum().item() ious.append(intersection / union if union > 0 else 0) recalls.append(intersection / true_cls.sum().item() if true_cls.sum() > 0 else 0) precisions.append(intersection / pred_cls.sum().item() if pred_cls.sum() > 0 else 0) return ious, recalls, precisions逻辑说明:这里跳过背景类,只算左肺和右肺。如果测试集里某张切片没有左肺(比如切片位置偏上),true_cls.sum()为 0,除法会出问题,所以加了判断。实际跑的时候,evaluate 脚本会把所有测试样本的指标平均。如果 Recall 明显低于 Precision,说明模型偏保守,漏检多,可以试着降低分类阈值或者增加正样本权重。如果 Precision 低,说明误检多,可能是掩膜标注噪声或者模型把纵隔误判成肺。
3.2 predict 脚本生成的 gt 和 gt+image 掩膜怎么用
predict 脚本用于推理图像,会生成 gt 以及 gt+image 的掩膜图像。gt 是真实标签的彩色掩膜,gt+image 是把预测掩膜叠加在原图上的效果图。这两张图是给你做定性检查的:打开 gt+image,看预测的左右肺边界是不是贴合真实轮廓,有没有把气管或者肋骨误分进去。如果发现某几张切片效果特别差,把它们挑出来单独看,通常是切片本身对比度低或者有伪影。
# 推理脚本常见调用方式 python predict.py --checkpoint checkpoints/best_model.pth --input dataset/images --output results/逻辑说明:--checkpoint指向训练保存的最优权重,--input是待推理图像目录,--output是输出目录。推理时模型处于 eval 模式,不计算梯度。输出掩膜一般用调色板映射:背景黑色、左肺红色、右肺绿色,方便肉眼区分。如果输出全黑,先检查权重路径对不对,再检查输入图像归一化方式是不是和训练时一致。常见坑是训练用了 ImageNet 均值方差归一化,推理时忘了做,导致输入分布偏移,预测全错。
3.3 训练曲线怎么判断模型有没有收敛
train 脚本生成的 loss 曲线、IoU 曲线、学习率衰减曲线是判断训练状态的直接依据。loss 曲线正常是训练 loss 和验证 loss 都下降,最后趋于平稳。如果训练 loss 降但验证 loss 升,是过拟合。如果两条都降不下去,是欠拟合或者学习率太小。IoU 曲线看验证集 IoU,一般在前 20 到 50 个 epoch 快速上升,然后缓慢爬升。学习率衰减曲线用来确认调度器有没有生效,如果 lr 一直是一条水平线,说明调度器没配好。
提示:训练日志里会记录每个 epoch 的 loss 和 IoU,建议用
tail -f train.log实时看,比等训练完再看曲线更早发现问题。
4. 避坑与排查:从数据到推理的五个血泪经验
4.1 掩膜像素值不是 0/1/2,训练直接崩
现象:训练启动后 loss 一直是 NaN,或者 IoU 始终为 0。原因:掩膜图像保存时被 JPEG 压缩或者用 RGB 保存,像素值变成了 0/128/255 或者 0/85/170 这种,不是连续的 0/1/2。解决:用 PIL 或 OpenCV 读掩膜后先打印唯一值,确认是 0、1、2 再做标签映射。如果是 0/128/255,写个映射表转成 0/1/2。
import numpy as np from PIL import Image mask = np.array(Image.open('masks/coronacases_org_002_93.png')) print(np.unique(mask)) # 先看唯一值 # 如果是 [0, 128, 255],做映射 mask = np.where(mask == 128, 1, mask) mask = np.where(mask == 255, 2, mask)4.2 训练集和验证集按切片随机划分,同一病人的切片泄漏
现象:验证集 IoU 高得离谱,测试集一跑就掉。原因:同一病人的 CT 切片被随机分到了训练集和验证集,切片之间高度相似,等于变相泄漏。解决:按病人 ID 划分,同一病人的所有切片只出现在一个集合里。这份资源的文件名里coronacases_org_002就是病人标识,按这个前缀分组再划分。
4.3 推理时忘了做和训练一致的归一化
现象:predict 输出的掩膜全是背景或者全是肺。原因:训练时对图像做了(img - mean) / std归一化,推理时直接喂原始像素。解决:把训练时的归一化参数存下来,推理时复用。常见做法是在 dataset 类里写一个preprocess函数,训练和推理都调它。
4.4 显存不够,batch_size 降到 1 还是 OOM
现象:CUDA out of memory,batch_size 降到 1 依然报错。原因:输入图像尺寸太大(比如 1024×1024),或者模型在验证阶段没加torch.no_grad(),梯度图一直累积。解决:先把输入 resize 到 512×512,验证和推理包在with torch.no_grad():里,再不行就换小一点的 DenseUnet 配置(减少 DenseBlock 层数)。
4.5 学习率衰减曲线是直线,模型后期不收敛
现象:训练后期 loss 震荡,IoU 上不去。原因:学习率调度器没生效,lr 一直保持初始值。解决:检查 scheduler 的step()调用位置,常见错误是写在 epoch 循环外面或者忘了调。如果是余弦退火,确认T_max设成了总 epoch 数。
5. 换自己的数据怎么改:从 README 到进阶调参的实操路径
5.1 替换数据集的三步走
README 里写了怎么训练自己的数据,核心就三步。第一步,把自己的图像和掩膜按同名规则放进images和masks目录。第二步,改 dataset 类里的num_classes和标签映射,如果你的数据只有背景和肺(不区分左右),num_classes改成 2,标签映射改成 0 和 1。第三步,改 train 脚本里的数据路径和输出目录,跑一遍确认数据加载没问题。
# 自定义数据集类的最小改动点 class MyLungDataset(Dataset): def __init__(self, root_dir, split='train', img_size=512): self.img_dir = os.path.join(root_dir, 'images') self.mask_dir = os.path.join(root_dir, 'masks') self.img_size = img_size self.files = sorted(os.listdir(self.img_dir)) # 按病人 ID 划分训练验证,避免切片泄漏 self.files = self._split_by_patient(self.files, split) def __getitem__(self, idx): img = Image.open(os.path.join(self.img_dir, self.files[idx])).convert('L') mask = Image.open(os.path.join(self.mask_dir, self.files[idx])) img = img.resize((self.img_size, self.img_size)) mask = mask.resize((self.img_size, self.img_size), Image.NEAREST) img = np.array(img, dtype=np.float32) / 255.0 mask = np.array(mask, dtype=np.int64) return torch.from_numpy(img).unsqueeze(0), torch.from_numpy(mask)逻辑说明:convert('L')把 CT 转成单通道灰度,resize对掩膜用NEAREST避免插值出小数标签。_split_by_patient按文件名前缀分组,保证同一病人的切片不跨集合。归一化用简单的/255.0,如果效果不好再换成 ImageNet 均值方差。
5.2 提升 IoU 的几个调参方向
如果 baseline 的 IoU 在 0.85 左右想往上推,可以试这几个方向。损失函数从交叉熵换成 Dice Loss 或者交叉熵加 Dice 的混合损失,Dice 对类别不平衡更鲁棒。数据增强加随机旋转、弹性形变、灰度扰动,肺部 CT 的形变增强效果比较明显。模型层面可以把 DenseUnet 的编码器换成预训练的 DenseNet121,用 ImageNet 权重初始化,小数据集上收敛更快。后处理加连通域分析,把面积小于阈值的预测区域去掉,减少孤立误检。
| 调参方向 | 具体操作 | 预期影响 |
|---|---|---|
| 损失函数 | 交叉熵 + Dice Loss 按 0.5:0.5 加权 | 边界更贴合,小目标召回提升 |
| 数据增强 | 随机旋转 ±15°、弹性形变、灰度 ±10% | 验证集 IoU 提升 1~3 个点 |
| 预训练编码器 | DenseNet121 ImageNet 权重 | 前 20 epoch 收敛更快 |
| 后处理 | 去除面积小于 50 像素的连通域 | 减少孤立误检,Precision 提升 |
5.3 验证模型有没有真正学到肺部特征
训练完别只看指标,做两个验证。第一个,把验证集里 IoU 最低的 5 张切片挑出来,看 gt+image 叠加图,确认是模型问题还是标注问题。第二个,拿一张训练集里没出现过的病人切片做推理,如果效果明显差于验证集,说明模型泛化不够,需要加数据或者加正则。我一般会在训练结束后强制走一遍这两个检查,有一次就是靠看低 IoU 切片发现掩膜标注把气管标成了肺,改完标注后 IoU 直接涨了 4 个点。从那以后我每次换数据集都先抽查 10 张掩膜再开训。希望这份资源和这些踩坑记录能帮你少走点弯路。
本文还有配套的精品资源,点击获取