简介:这份资源是面向计算机相关专业学生与深度学习入门者的毕设及课程作业参考项目,聚焦医学图像分析中的肝脏分割任务,采用深度卷积网络完成从数据预处理、模型训练到结果评估的完整流程。压缩包共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,而是 HU(Hounsfield 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_max=300): """将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_max=300): """线性归一化到[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_area=50): """只保留肝脏面积达标的切片""" 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): """两次卷积+BN+ReLU,UNet的基本单元""" def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.block(x) class UNet(nn.Module): def __init__(self, in_ch=1, out_ch=1, base=32): 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, stride=2) self.dec4 = DoubleConv(base*16, base*8) self.up3 = nn.ConvTranspose2d(base*8, base*4, 2, stride=2) self.dec3 = DoubleConv(base*8, base*4) self.up2 = nn.ConvTranspose2d(base*4, base*2, 2, stride=2) self.dec2 = DoubleConv(base*4, base*2) self.up1 = nn.ConvTranspose2d(base*2, base, 2, stride=2) 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], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) 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, weight=0.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-5,batch 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(dim=0)逻辑说明:对输入做旋转和翻转,预测后再做逆变换还原到原方向,最后取平均。参数里的旋转角度和翻转方式可以按你的数据特点调整,但不要用左右翻转,原因前面说过。
5.2 后处理去掉孤岛和填洞
网络输出二值化之后,经常会有一些孤立的小区域被误判成肝脏,或者肝脏内部有空洞。用连通域分析去掉面积小于阈值的连通块,再对最大的连通块做孔洞填充,能稳定提升 0.5 到 1 个点。这个操作在 scipy 里几行就能搞定。
from scipy import ndimage def postprocess(mask, min_size=100): """去掉小连通域并填充孔洞""" 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 是怎么算的」「边界为什么这样处理」。把基础打牢,比追新架构有用得多。希望帮到你。
本文还有配套的精品资源,点击获取