ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于DenseUnet的CT左右肺分割:从数据到部署的实战指南

基于DenseUnet的CT左右肺分割:从数据到部署的实战指南 简介本资源面向医学图像分割方向的初学者与进阶开发者提供基于DenseUnet的CT肺部左右肺分割完整实战方案覆盖从数据到推理的全流程。压缩包共2000个文件以1984张png图像数据为主另含8个Python脚本、5个xml标注文件、2个txt说明及1份readme整体约236.76MB结构清晰便于按模块查阅。训练脚本会输出训练集与验证集的loss、IoU曲线、学习率衰减曲线、训练日志及数据集可视化图像evaluate脚本用于评估模型计算测试集的IoU、Recall、Precision与像素准确率predict脚本则生成gt及gt叠加image的掩膜图像方便直观核对分割效果。代码注释详尽README给出训练自有数据的傻瓜式流程读者可据此掌握DenseUnet在肺部CT分割中的网络搭建、训练调参与推理可视化方法。目前已有159人学习适合希望快速复现并迁移到自有数据集的学习者参考。1. 从一张看不清边界的 CT 说起DenseUnet 做左右肺分割到底解决什么问题放射科医生在阅片时最怕遇到两种情况一是肺部大面积实变或胸腔积液肺实质和周围组织的灰度几乎糊在一起二是纵隔旁的病灶紧贴心脏或胸壁边界像被橡皮擦蹭过。这两种场景下人工勾画左右肺轮廓不仅耗时不同医生之间的勾画差异也很大。基于 DenseUnet 对 CT 扫描下的左右肺部分割要解决的就是把这一层「糊」从像素级别拆开——输入是一叠 DICOM 序列输出是每个像素属于左肺、右肺还是背景的标签图。这个方向适合三类人手里有几十到几百例胸部 CT、想做器官定量分析肺体积、病灶占比的临床研究者想拿医学分割练手、但苦于找不到干净数据集和可复现代码的算法工程师以及需要把肺部分割作为下游任务如肺结节检测、COVID 病灶量化预处理环节的开发者。它不追求 SOTA 刷榜追求的是在普通单卡上跑得动、边界不崩、左右不串。下面从数据准备一路讲到训练排错代码和数据集组织方式都给到能直接抄的程度。2. 数据准备与 DenseUnet 的选型逻辑为什么不是普通 Unet2.1 肺部 CT 数据集长什么样怎么整理成可训练格式公开的胸部 CT 分割数据常见来源是 LIDC-IDRI、LUNA16 这类带肺掩膜的集合也有医院自采的 DICOM。原始数据一般是每个病例一个文件夹里面是几百张.dcm切片。训练前要做三件事把 DICOM 转成 HU 值、把 HU 值截断到肺窗、把左右肺标签分开。常见做法是用pydicom读像素并乘RescaleSlope加RescaleIntercept转 HU然后截断到[-1000, 400]再归一化到[0,1]。标签如果是整肺掩膜需要用连通域把左右肺拆开——取最大两个连通域按质心横坐标排序左边为左肺、右边为右肺。import pydicom import numpy as np from scipy import ndimage def dcm_to_hu(dcm_path): ds pydicom.dcmread(dcm_path) img ds.pixel_array.astype(np.float32) # 转 HU斜率与截距来自 DICOM 头 hu img * float(ds.RescaleSlope) float(ds.RescaleIntercept) # 肺窗截断保留肺实质与部分纵隔 hu np.clip(hu, -1000, 400) # 归一化到 0-1便于网络收敛 hu (hu 1000) / 1400.0 return hu def split_lung_label(mask): # mask 为整肺二值掩膜拆成左右肺 labeled, num ndimage.label(mask) if num 2: return mask, np.zeros_like(mask) sizes ndimage.sum(mask, labeled, range(1, num 1)) # 取最大的两个连通域 top2 np.argsort(sizes)[-2:] 1 comps [labeled i for i in top2] # 按质心 x 坐标排序左小右大 cx [ndimage.center_of_mass(c)[1] for c in comps] order np.argsort(cx) left comps[order[0]].astype(np.uint8) right comps[order[1]].astype(np.uint8) return left, rightdcm_to_hu里RescaleSlope和RescaleIntercept必须从每个序列的头信息读不能写死不同设备这两个值不同。截断范围[-1000, 400]是肺窗常用区间再宽会引入过多骨与软组织干扰。split_lung_label依赖连通域数量如果某层只有单侧肺比如对侧全切num 2时直接返回原掩膜训练时要保证这类样本标签不参与左右分类损失。整理后的目录建议按病例切分避免同一病人切片同时进训练和验证dataset/ train/ patient_001/ img_000.npy mask_000.npy val/ patient_020/ ...2.2 DenseUnet 相比 Unet 在肺分割上的真实收益普通 Unet 的编码器每层只接前一层浅层纹理传到深层时已经衰减。肺部 CT 的难点在于胸膜、血管、支气管壁这些细结构在灰度上和肺实质接近普通 Unet 容易把血管断面误判成肺外。DenseUnet 把每一层都通过密集连接接到后面所有层特征复用率高浅层的边缘信息能直接参与深层的解码。具体到肺分割Dense block 里的growth_rate一般设 12 或 16每个 block 内 4 到 6 层。growth_rate太小特征复用不足太大显存涨得快且容易过拟合小数据集。我一般在小样本100 例上用 12数据量上去后用 16。另一个关键是过渡层的压缩系数compression通常 0.5把通道数压一半再进下一个 block控制参数量。和 Unet 对比DenseUnet 在肺边界处的 Dice 通常能高 1 到 3 个点代价是显存多 30% 左右。如果只有 8G 显存输入 patch 要降到256×256batch 设 4 或 8。如果显存够512×512的 patch 对胸膜细节更友好。2.3 把 DenseUnet 搭起来编码器、密集块与解码器的最小实现下面是一个可直接训练的 DenseUnet 主体输入单通道 CT输出 3 类背景、左肺、右肺。密集块用Bottleneck结构先 1×1 降维再 3×3 卷积减少计算量。import torch import torch.nn as nn class DenseLayer(nn.Module): def __init__(self, in_ch, growth_rate): super().__init__() # 1x1 降维通道压到 4*growth_rate self.conv1 nn.Conv2d(in_ch, 4 * growth_rate, 1, biasFalse) self.bn1 nn.BatchNorm2d(4 * growth_rate) self.conv2 nn.Conv2d(4 * growth_rate, growth_rate, 3, padding1, biasFalse) self.bn2 nn.BatchNorm2d(growth_rate) self.relu nn.ReLU(inplaceTrue) def forward(self, x): out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) # 密集连接新特征与输入拼接 return torch.cat([x, out], dim1) class DenseBlock(nn.Module): def __init__(self, in_ch, num_layers, growth_rate): super().__init__() layers [] for i in range(num_layers): layers.append(DenseLayer(in_ch i * growth_rate, growth_rate)) self.block nn.Sequential(*layers) def forward(self, x): return self.block(x) class DenseUnet(nn.Module): def __init__(self, in_ch1, num_classes3, growth_rate12): super().__init__() # 编码器 self.enc0 nn.Sequential( nn.Conv2d(in_ch, 32, 3, padding1, biasFalse), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue)) self.enc1 DenseBlock(32, 4, growth_rate) self.trans1 nn.Sequential( nn.Conv2d(32 4 * growth_rate, 64, 1, biasFalse), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2)) self.enc2 DenseBlock(64, 4, growth_rate) self.trans2 nn.Sequential( nn.Conv2d(64 4 * growth_rate, 128, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2)) # 瓶颈 self.bottleneck DenseBlock(128, 4, growth_rate) # 解码器上采样后与编码器对应层拼接 self.up2 nn.ConvTranspose2d(128 4 * growth_rate, 64, 2, stride2) self.dec2 DenseBlock(64 64 4 * growth_rate, 4, growth_rate) self.up1 nn.ConvTranspose2d(64 4 * growth_rate, 32, 2, stride2) self.dec1 DenseBlock(32 32 4 * growth_rate, 4, growth_rate) self.out nn.Conv2d(32 4 * growth_rate, num_classes, 1) def forward(self, x): e0 self.enc0(x) e1 self.enc1(e0) t1 self.trans1(e1) e2 self.enc2(t1) t2 self.trans2(e2) b self.bottleneck(t2) u2 self.up2(b) d2 self.dec2(torch.cat([u2, e2], dim1)) u1 self.up1(d2) d1 self.dec1(torch.cat([u1, e1], dim1)) return self.out(d1)DenseLayer里4 * growth_rate是瓶颈层的标准做法把输入通道先压到较小值再做 3×3 卷积否则密集连接会让通道数爆炸。DenseBlock的num_layers设 4 是显存和表达力的折中设 6 在小数据上容易过拟合。解码器的torch.cat顺序是「上采样结果 编码器同层特征」拼接后通道数要对上DenseBlock的输入。输出层 1×1 卷积把通道压到num_classes训练时直接接CrossEntropyLoss。3. 训练流程与参数设置从损失函数到学习率调度3.1 损失函数选 Dice 还是 CE左右肺不串类的关键肺分割里背景像素远多于肺像素纯交叉熵会让网络偏向预测背景肺边界被吞掉。常见做法是CrossEntropyLoss和DiceLoss加权相加权重各 0.5。Dice 直接优化重叠度对类别不平衡更稳。但 Dice 对左右肺的区分没有额外约束如果左右肺在图像上位置接近比如纵隔偏移网络可能把左肺预测成右肺。我一般会在 CE 里给左肺、右肺、背景分别设权重背景权重低一些左右肺权重相同。这样即使某层只有单侧肺也不会因为背景过多而压掉肺信号。另一个技巧是在标签里保证左右肺标签值固定左肺1右肺2不要按病例随机交换否则网络学不到左右语义。import torch.nn.functional as F def dice_loss(pred, target, num_classes3): # pred: [B, C, H, W] logits, target: [B, H, W] probs F.softmax(pred, dim1) target_onehot F.one_hot(target, num_classes).permute(0, 3, 1, 2).float() dims (0, 2, 3) inter (probs * target_onehot).sum(dims) union probs.sum(dims) target_onehot.sum(dims) dice (2 * inter 1e-5) / (union 1e-5) # 背景类不参与 dice只算左右肺 return 1 - dice[1:].mean() def total_loss(pred, target): # CE 权重背景 0.2左肺 1.0右肺 1.0 weight torch.tensor([0.2, 1.0, 1.0]).to(pred.device) ce F.cross_entropy(pred, target, weightweight) dc dice_loss(pred, target) return ce dcdice_loss里dice[1:]只取左右肺背景不参与避免背景 Dice 接近 1 拉高整体。total_loss的 CE 权重背景设 0.2是为了让网络更关注肺边界。如果发现验证集上左右肺 Dice 差距大比如左肺 0.95、右肺 0.85可以把右肺权重临时调到 1.2 再训几个 epoch但不要长期偏置否则左肺会掉。3.2 学习率、batch 与 patch 采样小显存也能跑肺 CT 单张512×512直接整图训练 batch 只能设 1 到 2。常见做法是随机裁256×256或320×320的 patch保证每个 patch 里至少包含一定比例的肺像素。如果纯随机裁很多 patch 全是背景训练效率低。我一般用「以肺像素为中心」的采样先算肺掩膜的坐标随机选一个肺像素以它为中心裁 patch这样每个 batch 都有足够肺信号。学习率初始设1e-3用Adam优化器weight_decay设1e-5。训练 50 到 100 个 epoch在第 30 和 60 个 epoch 时各降 10 倍。如果 loss 在前 5 个 epoch 不降先检查数据归一化是不是没做或者标签值是不是从 0 开始。from torch.utils.data import Dataset, DataLoader import numpy as np class LungPatchDataset(Dataset): def __init__(self, img_paths, mask_paths, patch_size256): self.img_paths img_paths self.mask_paths mask_paths self.patch_size patch_size def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img np.load(self.img_paths[idx]) mask np.load(self.mask_paths[idx]) # 以肺像素为中心采样 lung_coords np.argwhere(mask 0) if len(lung_coords) 0: cy, cx img.shape[0] // 2, img.shape[1] // 2 else: cy, cx lung_coords[np.random.randint(len(lung_coords))] half self.patch_size // 2 y0 np.clip(cy - half, 0, img.shape[0] - self.patch_size) x0 np.clip(cx - half, 0, img.shape[1] - self.patch_size) img_p img[y0:y0 self.patch_size, x0:x0 self.patch_size] mask_p mask[y0:y0 self.patch_size, x0:x0 self.patch_size] # 加通道维 img_t torch.from_numpy(img_p).float().unsqueeze(0) mask_t torch.from_numpy(mask_p).long() return img_t, mask_tlung_coords为空时退化为中心裁剪防止某些层没有肺标签导致报错。np.clip保证 patch 不越界靠近图像边缘时自动贴边。patch_size设 256 在 8G 显存上 batch 可以到 8设 320 则 batch 降到 4。如果显存更小可以设 192但太小的 patch 会丢失肺的整体形状Dice 会掉。3.3 训练循环与验证指标Dice 之外还要看什么训练循环里每个 epoch 跑完在验证集上算左右肺 Dice 和 Hausdorff 距离。Dice 看重叠Hausdorff 看边界最远偏差。肺分割里如果 Dice 高但 Hausdorff 大说明大部分像素对但边界有尖刺可能是上采样时棋盘效应。验证时用滑动窗口或整图推理不要用 patch否则拼接处会有缝。def validate(model, loader, device): model.eval() dices {1: [], 2: []} with torch.no_grad(): for img, mask in loader: img, mask img.to(device), mask.to(device) pred model(img).argmax(dim1) for cls in [1, 2]: p (pred cls).float() t (mask cls).float() inter (p * t).sum() dice (2 * inter 1e-5) / (p.sum() t.sum() 1e-5) dices[cls].append(dice.item()) return {k: sum(v) / len(v) for k, v in dices.items()}argmax(dim1)把 logits 转成类别图cls取 1 和 2 分别算左右肺。如果验证时显存不够整图可以分块推理再拼但块之间要留 overlap否则边界处会断。验证频率不用每个 epoch 都做每 5 个 epoch 一次省时间。4. 避坑与排查肺分割训练里最常见的 5 个翻车现场4.1 左右肺标签互换导致 Dice 突然掉一半现象训练到一半左肺 Dice 从 0.94 掉到 0.5右肺反而升到 0.9loss 震荡。原因数据整理时按质心排序拆左右肺但某些病例纵隔偏移或单侧肺不张质心顺序反了标签左右互换。解决拆完左右肺后用解剖位置校验——左肺在图像左侧像素 x 小右肺在右侧。如果某病例不符合人工检查或直接丢弃。更稳的做法是训练前把左右肺标签可视化抽查 20 例。4.2 肺窗截断范围设错肺实质被当背景现象验证集 Dice 只有 0.6 左右预测图里肺边缘缺一块。原因HU 截断上限设成了 200 或更低肺实变区域 HU 值在 200 到 400 之间被截掉后和背景一样。解决截断范围用[-1000, 400]如果数据里实变多上限可以到 600但不要超过 800否则骨头信号太强。截断后归一化分母要跟着改保证[0,1]映射正确。4.3 纯随机 patch 采样导致训练慢且不收敛现象loss 前 20 个 epoch 几乎不降验证 Dice 在 0.3 徘徊。原因512×512里肺只占 20% 到 30%随机裁256×256有大量 patch 全是背景网络大部分时间在学背景。解决改成以肺像素为中心采样或者先算肺掩膜 bounding box在 box 内随机裁。如果不想改采样可以把背景类 CE 权重降到 0.1但效果不如改采样直接。4.4 上采样用双线性导致边界模糊现象Dice 不低但 Hausdorff 距离大边界像糊了一层。原因解码器用nn.Upsample(modebilinear)再卷积双线性插值本身会平滑边缘。解决换成ConvTranspose2d让网络自己学上采样核。如果已经用了转置卷积还有棋盘效应检查stride和kernel_size是否匹配kernel_size2, stride2比kernel_size4, stride2更不容易出棋盘。4.5 验证集和训练集同一病人切片泄漏现象验证 Dice 0.97换一批新数据掉到 0.7。原因按切片随机切分同一病人的相邻切片同时进了训练和验证网络记住了病人特征而不是肺特征。解决按病人切分一个病人的所有切片只进训练或只进验证。如果数据量少用 5 折交叉验证每折按病人分。切分后检查训练和验证的病人 ID 有没有交集。5. 进阶技巧用测试时增强和模型集成把边界再压一压训练收敛后如果还想把 Dice 往上推 1 到 2 个点最省事的是测试时增强TTA。对同一张输入做水平翻转、小角度旋转分别推理后把概率图平均再取 argmax。肺是近似对称器官水平翻转对左右肺分割尤其有效但要注意翻转后左右标签会互换平均前要把翻转输出的左右通道换回来。def tta_predict(model, img, device): model.eval() with torch.no_grad(): img img.to(device) # 原始推理 p1 torch.softmax(model(img), dim1) # 水平翻转推理 img_f torch.flip(img, dims[3]) p2 torch.softmax(model(img_f), dim1) # 翻转回来左右通道互换 p2 torch.flip(p2, dims[3]) p2 p2[:, [0, 2, 1], :, :] # 平均概率 p (p1 p2) / 2 return p.argmax(dim1)p2[:, [0, 2, 1], :, :]把左肺和右肺通道对调因为水平翻转后原图左侧变右侧。TTA 的代价是推理时间翻倍如果做 4 种变换原图、翻转、旋转 90 度、翻转加旋转时间乘 4但 Dice 通常能再涨 0.5 到 1.5。如果部署时延迟敏感可以只保留水平翻转这一种。模型集成是另一个路子用不同随机种子训 3 个 DenseUnet推理时把 3 个模型的 softmax 概率平均。集成对边界不稳的样本提升明显但显存和训练时间都乘 3。我一般先上 TTA如果还不够再考虑集成。最后提醒一句所有提升都要在独立测试集上验证别在验证集上反复调 TTA 参数否则又是另一种泄漏。希望帮到你。本文还有配套的精品资源点击获取
返回列表