深度学习入门实战:基于UNet的肝脏CT图像分割全流程
发布时间:2026/9/30 1:07:22来源:尧图网络
简介这份资源是面向计算机相关专业学生与深度学习入门者的毕设及课程作业参考项目聚焦医学图像分析中的肝脏分割任务采用深度卷积网络完成从数据预处理、模型训练到结果评估的完整流程。压缩包共43个文件以18个Python脚本和15个pyc编译文件为主另含4个xml配置、3个md说明文档及1个ipynb笔记整体约50KB涵盖数据集加载、多种网络结构定义、训练与测试入口及指标计算等模块目录组织清晰便于按功能定位代码。项目涉及Python与C混合开发思路适合希望理解CNN分割原理、掌握医学图像处理流程的读者参考。目前已有124人学习可作为毕设选题复现、课程设计借鉴或分割模型二次开发的实践素材。1. 肝脏分割项目为什么值得当成深度学习入门的第一块试金石如果你正在找深度学习毕设或者课程作业的题目肝脏分割这个方向大概率已经在你候选清单里出现过。它属于医学图像分割里最经典的任务之一给一张腹部 CT 切片让模型逐像素判断哪些区域是肝脏、哪些是背景。听起来简单但它把深度学习项目该有的环节全串了一遍——数据读取、预处理、卷积网络搭建、损失函数选择、训练调参、指标评估、结果可视化。你把这一个项目吃透后面换到肺结节、脑肿瘤、口腔疾病图像识别套路基本是通的。我见过太多人做深度学习项目时直接找一个开源仓库跑一遍loss 降下去了就截图交差。但答辩老师或者面试官只要追问一句「你的数据怎么切的」「为什么用 Dice 而不是交叉熵」「肝脏边界为什么糊」就露馅了。肝脏分割这个题目的好处在于它的坑足够具体CT 的 HU 值范围、肝脏在切片中的占比、类别极度不平衡、边界模糊每一个都能逼你去理解背后的原理。这篇文章就是按「能复现、能讲清、能扛住追问」的标准来写的适合刚入门深度学习、想拿一个完整项目练手的人也适合已经跑通过但说不清细节的人。2. 从 CT 切片到肝脏掩膜数据管线怎么搭才不翻车2.1 先搞清楚肝脏分割的输入输出到底是什么肝脏分割的输入通常是一组腹部 CT 体积数据格式多为 DICOM 或已经转好的 NIfTI。一个 CT 体积可以看成沿 Z 轴堆叠的若干张二维切片每张切片是一个灰度矩阵像素值不是普通的 0 到 255而是 HUHounsfield Unit范围大致在 -1000 到 3000 之间。空气接近 -1000水是 0肝脏实质大概在 40 到 70 之间。这个数值范围如果你不处理直接送进网络梯度会炸得你怀疑人生。输出是一张和输入切片同尺寸的二值掩膜肝脏区域为 1其余为 0。如果是三维方案输出就是和输入体积同尺寸的三维掩膜。常见做法是先用二维切片训练一个分割网络跑通之后再考虑要不要上三维。我一般建议新手从二维 UNet 起步因为显存友好、调试直观、可视化方便等二维的 Dice 稳定在 0.9 以上再去碰 3D 方案。这里有个容易被忽略的点肝脏在整张 CT 切片里的面积占比通常只有 5% 到 15%背景占绝大多数。这意味着如果你用普通的交叉熵损失网络只要把所有像素都预测成背景准确率就能到 85% 以上但 Dice 会是 0。这就是类别不平衡的典型表现也是为什么医学分割几乎不用准确率作为主要指标。2.2 数据预处理的四个关键步骤数据管线搭得好不好直接决定你后面是调参还是调 bug。下面这套流程是我在多个分割项目里反复用过的按顺序执行。第一步是 HU 值截断。把小于 -100 和大于 300 的值裁掉因为肝脏相关的组织基本落在这个窗口内。这一步能显著提升对比度。import numpy as np def clip_hu(volume, hu_min-100, hu_max300): 将CT体积的HU值截断到肝脏感兴趣窗口 volume np.clip(volume, hu_min, hu_max) return volume逻辑说明np.clip把超出窗口的值压到边界保留窗口内的原始值。参数hu_min和hu_max是经验值腹部 CT 常用 -100 到 300如果你做的是增强 CT窗口可以适当放宽到 -100 到 400。第二步是归一化。截断之后把数值映射到 0 到 1 之间让网络输入分布稳定。def normalize(volume, hu_min-100, hu_max300): 线性归一化到[0,1] volume (volume - hu_min) / (hu_max - hu_min) return np.clip(volume, 0, 1)逻辑说明先减最小值再除以跨度得到 0 到 1 的分布。最后再 clip 一次是防止有异常值溢出。参数就是上一步的窗口边界两处必须一致否则归一化会错位。第三步是切片筛选。一个 CT 体积有几百张切片头尾很多张根本没有肝脏。如果全部拿来训练网络会学到大量纯背景样本收敛变慢。常见做法是根据掩膜统计只保留肝脏面积大于某个阈值的切片比如大于 50 个像素。def filter_slices(volume, mask, min_area50): 只保留肝脏面积达标的切片 valid_indices [] for i in range(mask.shape[0]): if mask[i].sum() min_area: valid_indices.append(i) return volume[valid_indices], mask[valid_indices]逻辑说明遍历每张切片统计掩膜中为 1 的像素数低于阈值就丢弃。参数min_area设太小会引入噪声切片设太大会丢掉肝脏边缘的有用信息50 到 100 是比较稳的区间。第四步是数据增强。医学图像标注成本极高数据量通常只有几十到几百例不做增强很容易过拟合。常用的增强包括随机旋转、随机缩放、弹性形变、随机翻转。注意翻转要慎用左右翻转因为肝脏在人体右侧左右翻转会造出解剖上不合理的样本。我一般用随机旋转正负 15 度、随机缩放 0.9 到 1.1、再加轻微弹性形变。2.3 训练集验证集划分的一个血泪教训很多人按切片随机划分训练集和验证集这是错的。同一个病人的相邻切片高度相似如果随机打散验证集里会出现和训练集几乎一样的切片指标虚高实际泛化能力很差。正确做法是按病人划分同一个病人的所有切片要么全在训练集要么全在验证集。这个坑我在早期项目里踩过验证 Dice 0.95换一批数据直接掉到 0.7翻车翻得很彻底。划分比例常见是 8:2 或 7:3如果数据量特别小可以用交叉验证。划分时还要注意让两个集合的肝脏面积分布尽量接近避免验证集全是小面积样本。3. 卷积网络怎么选UNet 为什么是肝脏分割的默认答案3.1 UNet 的编码器解码器结构到底解决了什么问题肝脏分割最常用的网络是 UNet它的结构可以拆成三部分编码器负责逐层下采样提取语义特征解码器负责逐层上采样恢复空间分辨率跳跃连接把编码器同层的特征直接拼到解码器对应层。为什么这个结构适合分割因为分割要求输出和输入同尺寸而且边界要准。编码器不断下采样会丢失空间细节跳跃连接就是把丢失的细节补回来让解码器在恢复尺寸时还能看到原始的高分辨率信息。用一句话概括编码器回答「这是什么」解码器回答「在哪里」跳跃连接保证「在哪里」足够精确。肝脏分割的难点恰恰在边界肝脏和邻近的胃、脾、肾脏在灰度上可能很接近没有跳跃连接边界会糊成一片。3.2 用 PyTorch 搭一个能跑的 UNet下面是一个精简版 UNet输入单通道 CT 切片输出单通道概率图。这个版本我去掉了花哨的模块保证你能看懂每一层在干什么。import torch import torch.nn as nn class DoubleConv(nn.Module): 两次卷积BNReLUUNet的基本单元 def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.block(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1, base32): super().__init__() # 编码器 self.enc1 DoubleConv(in_ch, base) self.enc2 DoubleConv(base, base*2) self.enc3 DoubleConv(base*2, base*4) self.enc4 DoubleConv(base*4, base*8) self.pool nn.MaxPool2d(2) # 瓶颈层 self.bottleneck DoubleConv(base*8, base*16) # 解码器 self.up4 nn.ConvTranspose2d(base*16, base*8, 2, stride2) self.dec4 DoubleConv(base*16, base*8) self.up3 nn.ConvTranspose2d(base*8, base*4, 2, stride2) self.dec3 DoubleConv(base*8, base*4) self.up2 nn.ConvTranspose2d(base*4, base*2, 2, stride2) self.dec2 DoubleConv(base*4, base*2) self.up1 nn.ConvTranspose2d(base*2, base, 2, stride2) self.dec1 DoubleConv(base*2, base) self.out nn.Conv2d(base, out_ch, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)逻辑说明DoubleConv是重复使用的卷积块两次 3x3 卷积配合 BN 和 ReLU。编码器每经过一个DoubleConv后用MaxPool2d下采样一次特征图尺寸减半、通道翻倍。解码器用ConvTranspose2d上采样然后和编码器同层特征在通道维度拼接再过一个DoubleConv。最后用 1x1 卷积把通道压到输出类别数。参数说明base控制基础通道数32 是显存和效果的平衡点显存紧张可以降到 16追求精度可以升到 64。in_ch单通道 CT 就是 1如果你把相邻切片堆叠成多通道输入改成对应数量。out_ch二分类分割就是 1配合 Sigmoid 使用。3.3 损失函数选 Dice 还是交叉熵前面说过肝脏占比小交叉熵容易被背景主导。Dice 损失直接优化预测和真值的重叠度对类别不平衡更鲁棒。但 Dice 在训练初期梯度不稳定尤其是预测全为 0 的时候。我一般用组合损失交叉熵加 Dice权重各占一半。这样既有交叉熵稳定的梯度又有 Dice 对重叠度的直接优化。class DiceBCELoss(nn.Module): def __init__(self, weight0.5): super().__init__() self.weight weight self.bce nn.BCEWithLogitsLoss() def forward(self, pred, target): bce_loss self.bce(pred, target) pred_prob torch.sigmoid(pred) intersection (pred_prob * target).sum() dice_loss 1 - (2 * intersection 1e-6) / (pred_prob.sum() target.sum() 1e-6) return self.weight * bce_loss (1 - self.weight) * dice_loss逻辑说明BCEWithLogitsLoss内部做了 Sigmoid数值更稳定。Dice 部分先算预测和真值的交集再除以两者之和加1e-6防止除零。参数weight控制两者比例0.5 是常用起点如果发现边界不够准可以适当提高 Dice 的权重。4. 训练肝脏分割模型时最容易踩的五个坑4.1 损失降了但 Dice 不动现象训练日志里 loss 稳步下降但验证集 Dice 一直在 0.3 附近徘徊。原因通常是数据归一化没做对或者掩膜标签的数值不是 0 和 1 而是 0 和 255。解决方法是打印一批数据和标签的数值范围确认输入在 0 到 1 之间、标签只有 0 和 1 两个值。如果标签是 255除以 255 即可。4.2 验证集指标高得离谱现象验证 Dice 0.98但拿新数据一测惨不忍睹。原因几乎都是按切片随机划分导致的数据泄漏同一病人的相邻切片同时出现在训练集和验证集。解决办法是按病人 ID 划分确保同一个病人的数据不跨集合。这个坑没有后悔药只能重划。4.3 显存爆了但 batch size 已经降到 1现象batch size 设为 1 还是 OOM。原因可能是输入切片尺寸太大比如 512x512 直接送进去中间层特征图占用惊人。解决办法是先把切片 resize 到 256x256 或 320x320或者改用梯度累积模拟大 batch。另外检查是不是忘了加torch.no_grad()在验证阶段验证时计算图不释放也会吃显存。4.4 肝脏边界预测得像狗啃的现象肝脏主体分割得不错但边缘锯齿严重Dice 卡在 0.88 上不去。原因通常是下采样太狠导致细节丢失或者损失函数对边界不敏感。解决办法有两个方向一是减少下采样次数比如把 UNet 深度从 5 层降到 4 层二是在损失里加入边界加权对掩膜边缘像素给更高权重。我一般先试第一个改动小见效快。4.5 训练到一半 loss 突然变 NaN现象前几十个 epoch 正常突然 loss 变成 NaN。原因可能是学习率太大、BN 层在 batch size 太小时统计量不稳、或者数据里有异常值。解决办法是把学习率降到 1e-4 甚至 1e-5batch size 尽量不低于 4如果实在上不去就把 BN 换成 GroupNorm。另外在数据管线里加一步检查确保没有 NaN 或 Inf 进入网络。5. 把 Dice 从 0.85 推到 0.93 的三个进阶技巧5.1 用测试时增强把边界磨平模型训练完之后推理阶段可以对同一张切片做多次轻微变换比如旋转正负 5 度、水平翻转分别预测再把结果平均回去。这个技巧叫测试时增强几乎不增加训练成本但能把 Dice 提升 1 到 2 个点尤其是边界区域。代价是推理时间翻几倍如果对实时性没要求值得加。def tta_predict(model, image): 测试时增强原图旋转翻转的平均预测 model.eval() preds [] with torch.no_grad(): preds.append(torch.sigmoid(model(image))) preds.append(torch.sigmoid(model(torch.rot90(image, 1, [2, 3]))).rot90(-1, [2, 3])) preds.append(torch.sigmoid(model(torch.flip(image, [3]))).flip([3])) return torch.stack(preds).mean(dim0)逻辑说明对输入做旋转和翻转预测后再做逆变换还原到原方向最后取平均。参数里的旋转角度和翻转方式可以按你的数据特点调整但不要用左右翻转原因前面说过。5.2 后处理去掉孤岛和填洞网络输出二值化之后经常会有一些孤立的小区域被误判成肝脏或者肝脏内部有空洞。用连通域分析去掉面积小于阈值的连通块再对最大的连通块做孔洞填充能稳定提升 0.5 到 1 个点。这个操作在 scipy 里几行就能搞定。from scipy import ndimage def postprocess(mask, min_size100): 去掉小连通域并填充孔洞 labeled, num ndimage.label(mask) if num 0: return mask sizes ndimage.sum(mask, labeled, range(1, num 1)) keep np.where(sizes min_size)[0] 1 cleaned np.isin(labeled, keep) filled ndimage.binary_fill_holes(cleaned) return filled.astype(np.uint8)逻辑说明ndimage.label给每个连通域编号ndimage.sum统计每个连通域的面积保留面积达标的最后binary_fill_holes填内部空洞。参数min_size根据你的切片分辨率调整256x256 的切片设 100 左右比较合适。5.3 用学习率预热和余弦退火稳住训练学习率设太大前期会震荡设太小收敛慢。我一般用预热加余弦退火前 5 个 epoch 学习率从 1e-6 线性升到 1e-3之后按余弦曲线慢慢降到 1e-6。这样前期稳定后期精细。配合 AdamW 优化器和权重衰减 1e-4大部分分割任务都能稳住。from torch.optim.lr_scheduler import LambdaLR import math def warmup_cosine(optimizer, warmup_epochs, total_epochs): def lr_lambda(epoch): if epoch warmup_epochs: return epoch / warmup_epochs progress (epoch - warmup_epochs) / (total_epochs - warmup_epochs) return 0.5 * (1 math.cos(math.pi * progress)) return LambdaLR(optimizer, lr_lambda)逻辑说明预热阶段学习率线性上升之后余弦下降。参数warmup_epochs一般设总 epoch 的 5% 到 10%total_epochs根据数据量定几百例数据通常 100 到 200 个 epoch 足够。这套流程跑下来二维 UNet 在公开肝脏数据集上 Dice 到 0.93 左右是正常水平。如果你做完这些还想继续压榨可以试 3D 方案或者换 Swin UNet 这类带注意力的结构但那是另一个量级的工作量了。我自己做项目有个习惯先把二维基线跑稳把所有指标和可视化都存好再去碰复杂模型。因为答辩或者汇报的时候老师问的往往不是「你为什么不用最新模型」而是「你这个 Dice 是怎么算的」「边界为什么这样处理」。把基础打牢比追新架构有用得多。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网