简介:这份资源是面向计算机相关专业学生与深度学习入门者的肝脏分割项目源码包,可作为毕业设计或课程作业的完整参考方案。项目以深度卷积网络为核心,围绕医学图像中肝脏区域的自动分割展开,涵盖数据预处理、模型定义、训练与评估等环节,并涉及Python与C++在计算密集型任务中的配合使用。压缩包共43个文件,约50KB,以18个py脚本为主体,辅以15个pyc编译文件、4个xml配置、3个md说明及1个ipynb笔记,目录中可见UNet、ResUNet、KiUNet、SegNet等网络实现,以及损失函数、评价指标、数据集加载与训练测试脚本,结构清晰便于按模块研读。目前已有124人学习下载。读者可借此理解医学图像分割的完整流程,掌握CNN模型搭建、训练调优与Dice系数等评估方法,并参考项目组织方式完成自己的实验与报告撰写。
1. 肝脏分割毕设资源:从 CT 切片到掩膜输出的完整链路
做医学图像方向的毕设,最怕的不是模型跑不动,而是数据拿不到、标注看不懂、跑出来的掩膜全是噪点。这份「基于深度卷积网络的肝脏分割」资源包,解决的就是这条链路——它把肝脏 CT 切片和对应掩膜、深度卷积网络训练脚本、推理与可视化代码打包在一起,让你不用从零攒数据就能跑通一个完整的语义分割流程。适合正在做计算机、人工智能、生物医学工程方向毕设或课程作业的同学,也适合想入门医学图像分割的深度学习初学者。核心任务就一件事:输入腹部 CT 切片,输出肝脏区域的二值掩膜。技术栈是 Python + 深度学习框架,属于典型的 CNN 语义分割落地项目,也是「深度学习毕设」里检索量很高的选题方向。
2. 肝脏分割任务拆解:为什么选深度卷积网络而不是传统方法
2.1 从阈值分割到 CNN:肝脏分割的技术演进逻辑
传统肝脏分割靠区域生长、水平集、图割这些方法,核心问题是依赖手工特征和先验参数。肝脏和周围器官(胃、脾、肾)在 CT 上的灰度值经常重叠,阈值一调就漏或者溢。我早期用区域生长试过,种子点选偏一点,分割结果直接崩掉,这种玄学调参在毕设答辩时很难讲清楚。
深度卷积网络改变了这个局面。卷积核在空间维度上滑动,自动学习边缘、纹理、形状的层级特征,不需要你手动定义「肝脏长什么样」。编码器逐层下采样提取语义,解码器逐层上采样恢复分辨率,跳跃连接把浅层的位置信息和深层的语义信息拼起来——这套 U 型结构在医学分割里几乎是标配。肝脏分割的难点在于:目标器官体积大但边界模糊,相邻器官灰度接近,不同病人肝脏形态差异大。CNN 通过大量样本学习到的特征表达,比任何手工规则都稳。
选这个方向做毕设,还有一个现实原因:肝脏分割是腹部多器官分割的基础任务,做完肝脏可以扩展到肿瘤、血管,论文里能写的创新点也多。检索「深度学习图像识别」「深度学习实战项目案例」的人,很多最后都落到医学分割这个场景。
2.2 资源包里的数据组织与预处理流程
拿到资源包先别急着跑训练,第一步是搞清楚数据长什么样。典型的肝脏分割数据集结构是这样的:
dataset/ ├── images/ # CT 切片,PNG 或 NIfTI 格式 │ ├── case_001_slice_00.png │ ├── case_001_slice_01.png │ └── ... ├── masks/ # 对应掩膜,二值图,肝脏区域为 255 │ ├── case_001_slice_00.png │ ├── case_001_slice_01.png │ └── ... └── splits/ ├── train.txt # 训练集文件列表 ├── val.txt # 验证集文件列表 └── test.txt # 测试集文件列表预处理是肝脏分割里最容易翻车的一环。CT 原始值是 HU(Hounsfield Unit),范围从 -1024 到 3000 以上,直接送进网络梯度会炸。常见做法是先做窗宽窗位截断,把腹部窗(窗宽 400,窗位 40)之外的像素裁掉,再归一化到 [0,1]。代码大概长这样:
import numpy as np import cv2 def preprocess_ct(ct_slice, window_width=400, window_center=40): """ CT 窗宽窗位截断 + 归一化 window_width: 窗宽,腹部常用 400 window_center: 窗位,腹部常用 40 """ lower = window_center - window_width // 2 # 40 - 200 = -160 upper = window_center + window_width // 2 # 40 + 200 = 240 ct_clipped = np.clip(ct_slice, lower, upper) ct_norm = (ct_clipped - lower) / (upper - lower) # 映射到 [0,1] return ct_norm.astype(np.float32) def load_pair(img_path, mask_path, target_size=(256, 256)): """加载图像和掩膜,统一尺寸""" img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, target_size, interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, target_size, interpolation=cv2.INTER_NEAREST) img = preprocess_ct(img) mask = (mask > 127).astype(np.float32) # 二值化 return img, mask这里有几个参数需要根据你的数据调整。window_width和window_center决定保留的灰度范围,腹部 CT 常用 400/40,如果数据是增强 CT 可能要改。target_size统一到 256×256 是权衡显存和精度的结果,想保留更多细节可以上 512×512,但 batch size 要相应减小。掩膜 resize 必须用最近邻插值,双线性会把二值边缘插出灰色过渡带,训练时标签就不干净了。
注意:如果资源包里是 NIfTI 格式(.nii/.nii.gz),需要用 nibabel 读取,而且要注意体素间距(spacing)不一致的问题,不同层厚的数据直接 resize 会引入形变。
3. 深度卷积网络搭建与训练:编码器-解码器结构的参数配置
3.1 U-Net 风格网络结构的关键层设计
肝脏分割最常用的骨干是 U-Net 及其变体。资源包里的网络结构大概率是编码器-解码器加跳跃连接的形式。编码器用卷积+池化逐层降维,解码器用转置卷积或上采样恢复尺寸,跳跃连接把编码器对应层的特征图拼到解码器上。下面是一个可直接用的简化实现:
import torch import torch.nn as nn class DoubleConv(nn.Module): """两层 3x3 卷积 + BN + ReLU""" def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), 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_ch=64): super().__init__() # 编码器 self.enc1 = DoubleConv(in_ch, base_ch) self.enc2 = DoubleConv(base_ch, base_ch * 2) self.enc3 = DoubleConv(base_ch * 2, base_ch * 4) self.enc4 = DoubleConv(base_ch * 4, base_ch * 8) self.pool = nn.MaxPool2d(2) # 瓶颈层 self.bottleneck = DoubleConv(base_ch * 8, base_ch * 16) # 解码器 self.up4 = nn.ConvTranspose2d(base_ch * 16, base_ch * 8, 2, stride=2) self.dec4 = DoubleConv(base_ch * 16, base_ch * 8) self.up3 = nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride=2) self.dec3 = DoubleConv(base_ch * 8, base_ch * 4) self.up2 = nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride=2) self.dec2 = DoubleConv(base_ch * 4, base_ch * 2) self.up1 = nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride=2) self.dec1 = DoubleConv(base_ch * 2, base_ch) # 输出层 self.out_conv = nn.Conv2d(base_ch, 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 torch.sigmoid(self.out_conv(d1))base_ch=64是通道基数,显存不够可以降到 32,但特征表达能力会下降。bias=False是因为后面接了 BatchNorm,偏置项会被抵消,省掉能减少参数量。跳跃连接用torch.cat在通道维度拼接,这是 U-Net 的核心设计,让解码器能同时看到「这是什么」和「在哪里」。
3.2 损失函数选择与训练参数设置
肝脏分割有个绕不开的问题:正负样本极度不均衡。一张 CT 切片里肝脏区域可能只占 10% 到 20%,剩下全是背景。如果直接用二值交叉熵(BCE),网络会倾向于全预测背景,准确率看着很高但 IoU 惨不忍睹。常见做法是 Dice Loss 和 BCE 加权组合:
class DiceBCELoss(nn.Module): def __init__(self, bce_weight=0.5): super().__init__() self.bce_weight = bce_weight self.bce = nn.BCELoss() def forward(self, pred, target): # BCE 部分 bce_loss = self.bce(pred, target) # Dice 部分 smooth = 1e-6 pred_flat = pred.view(-1) target_flat = target.view(-1) intersection = (pred_flat * target_flat).sum() dice_loss = 1 - (2. * intersection + smooth) / (pred_flat.sum() + target_flat.sum() + smooth) return self.bce_weight * bce_loss + (1 - self.bce_weight) * dice_lossbce_weight=0.5是经验值,如果小目标分割效果差可以调到 0.3,让 Dice 占更大比重。优化器用 Adam,学习率 1e-4 起步,配合 ReduceLROnPlateau 在验证集 IoU 不涨时降学习率。batch size 根据显存来,8GB 显存跑 256×256 大概能到 16。训练轮数一般 100 到 200 轮,早停耐心值设 20。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', factor=0.5, patience=10 ) criterion = DiceBCELoss(bce_weight=0.5)验证指标重点看 Dice 系数和 IoU,不要只看 loss。Dice 到 0.90 以上算不错,0.85 到 0.90 是及格线,低于 0.80 要检查数据预处理和标签质量。
4. 推理、后处理与可视化:把掩膜变成能看的结果
4.1 滑窗推理与掩膜拼接
训练完模型,推理阶段有个容易忽略的点:如果测试图像尺寸和训练尺寸不一致,直接 resize 会损失精度。常见做法是滑窗推理——把大图切成有重叠的小块,逐块预测后再拼回去。重叠区域取平均能减少拼接缝。
def sliding_window_inference(model, image, window_size=256, stride=128): """ 滑窗推理,适用于大尺寸 CT 切片 window_size: 窗口大小,与训练尺寸一致 stride: 滑动步长,小于 window_size 保证重叠 """ model.eval() h, w = image.shape output = np.zeros((h, w), dtype=np.float32) count = np.zeros((h, w), dtype=np.float32) with torch.no_grad(): for y in range(0, h - window_size + 1, stride): for x in range(0, w - window_size + 1, stride): patch = image[y:y+window_size, x:x+window_size] patch_tensor = torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).float() pred = model(patch_tensor).squeeze().numpy() output[y:y+window_size, x:x+window_size] += pred count[y:y+window_size, x:x+window_size] += 1 output = output / np.maximum(count, 1e-6) return outputstride=128是 window_size 的一半,保证 50% 重叠。步长越小拼接越平滑但推理越慢,实际用的时候根据时间预算调。
4.2 连通域后处理去噪
网络输出的是概率图,二值化之后经常有一些孤立的噪点区域。肝脏是最大的连通域,用连通域分析把小的去掉:
import cv2 import numpy as np def postprocess_mask(pred_prob, threshold=0.5, min_area=500): """ 后处理:二值化 + 连通域过滤 threshold: 概率阈值 min_area: 最小连通域面积,小于此值的区域被移除 """ binary = (pred_prob > threshold).astype(np.uint8) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) cleaned = np.zeros_like(binary) for i in range(1, num_labels): # 跳过背景 if stats[i, cv2.CC_STAT_AREA] >= min_area: cleaned[labels == i] = 1 return cleanedmin_area=500是 256×256 图像下的经验值,图像更大要相应调大。阈值 0.5 是默认的,如果发现漏分割多可以降到 0.4,过分割多就升到 0.6。
4.3 叠加可视化与 Dice 指标计算
把分割结果叠在原图上,答辩和论文里都用得上:
def visualize_overlay(ct_slice, mask, alpha=0.4): """将掩膜以红色半透明叠加到 CT 上""" ct_rgb = cv2.cvtColor((ct_slice * 255).astype(np.uint8), cv2.COLOR_GRAY2BGR) overlay = ct_rgb.copy() overlay[mask == 1] = [0, 0, 255] # 红色 result = cv2.addWeighted(overlay, alpha, ct_rgb, 1 - alpha, 0) return result def compute_dice(pred_mask, gt_mask): """计算 Dice 系数""" intersection = (pred_mask * gt_mask).sum() return (2. * intersection) / (pred_mask.sum() + gt_mask.sum() + 1e-6)alpha=0.4是透明度,太高会盖住 CT 细节,太低看不清分割边界。Dice 计算里加 1e-6 是防止分母为零。
5. 避坑与排查:肝脏分割训练中最容易翻车的五个地方
5.1 损失不下降,Dice 卡在 0.3 上不去
现象:训练几十轮,loss 在 0.6 附近震荡,验证集 Dice 始终低于 0.4。
原因:最常见的是标签问题——掩膜不是二值的,或者图像和掩膜没对齐。另一个可能是学习率太大,梯度直接炸了。
解决:先可视化几张「图像-掩膜」对,确认掩膜只覆盖肝脏区域且边缘干净。然后检查掩膜像素值分布,确保只有 0 和 255(或 0 和 1)。学习率从 1e-4 降到 1e-5 试试,或者加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。
5.2 验证集 Dice 很高但测试集一塌糊涂
现象:验证集 Dice 0.92,换一批数据跑出来 0.65。
原因:数据分布不一致。训练集和测试集可能来自不同设备、不同扫描参数,灰度分布差异大。或者划分数据时没有按病人划分,同一个病人的切片同时出现在训练和验证集里,造成信息泄漏。
解决:按病人 ID 划分数据集,确保同一个病人的所有切片只出现在一个集合里。如果数据来自多个来源,做直方图匹配或者更严格的归一化。增强策略也可以加随机窗宽窗位扰动,提升泛化。
5.3 显存溢出(CUDA out of memory)
现象:训练到一半报 OOM,或者 batch size 调到 4 都跑不起来。
原因:图像尺寸太大、模型通道数太多、batch size 太高,三者叠加。
解决:优先降 batch size 到 2 或 1,配合梯度累积模拟大 batch。然后降base_ch从 64 到 32。还可以用混合精度训练:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): pred = model(inputs) loss = criterion(pred, targets) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度能省 30% 到 40% 显存,速度也快,但要注意有些操作在 fp16 下会溢出,遇到 NaN 就关掉。
5.4 分割结果边缘毛糙,有零散小区域
现象:掩膜主体没问题,但边缘锯齿严重,周围散布小噪点。
原因:上采样用转置卷积容易产生棋盘效应,加上没有做后处理。
解决:把转置卷积换成双线性插值上采样 + 卷积,能减少棋盘伪影。推理后加连通域过滤,把小于min_area的区域去掉。还可以用形态学开运算平滑边缘:
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) smoothed = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)5.5 训练速度慢,一个 epoch 要半小时
现象:数据加载是瓶颈,GPU 利用率只有 30%。
原因:DataLoader 的num_workers设成 0,数据加载在主进程里串行执行。
解决:num_workers设成 CPU 核心数的一半左右,加pin_memory=True加速 CPU 到 GPU 的传输。如果数据预处理复杂,可以提前把预处理结果缓存成 npy 文件,训练时直接读。
train_loader = DataLoader( dataset, batch_size=16, shuffle=True, num_workers=4, pin_memory=True, drop_last=True )6. 进阶技巧:用 TTA 和模型集成把 Dice 再提两个点
训练完一个模型,想再榨点性能出来,测试时增强(TTA)是性价比最高的手段。思路很简单:推理时对输入做多种变换(水平翻转、垂直翻转、旋转 90 度),每种变换各预测一次,然后把结果逆变换回去取平均。模型见过同一张图的不同视角,预测会更稳。
def tta_inference(model, image): """测试时增强:水平翻转 + 垂直翻转 + 原图""" model.eval() preds = [] with torch.no_grad(): # 原图 t = torch.from_numpy(image).unsqueeze(0).unsqueeze(0).float() preds.append(model(t).squeeze().numpy()) # 水平翻转 t_flip = torch.flip(t, dims=[3]) p_flip = model(t_flip).squeeze().numpy() preds.append(np.fliplr(p_flip)) # 垂直翻转 t_vflip = torch.flip(t, dims=[2]) p_vflip = model(t_vflip).squeeze().numpy() preds.append(np.flipud(p_vflip)) return np.mean(preds, axis=0)TTA 一般能提 1 到 2 个点 Dice,代价是推理时间翻三倍。如果时间紧,只做水平翻转也有效果。模型集成是另一个路子:训练 3 到 5 个不同随机种子的模型,推理时取平均。集成比 TTA 提升更明显,但训练成本成倍增加。
还有个容易被忽略的点:阈值搜索。默认 0.5 不一定最优,在验证集上扫一遍 0.3 到 0.7,找 Dice 最高的阈值,测试时用这个值。我见过不少情况,光调阈值就能提一个点。
def find_best_threshold(model, val_loader, thresholds=np.arange(0.3, 0.71, 0.05)): """在验证集上搜索最优二值化阈值""" best_thresh, best_dice = 0.5, 0.0 all_preds, all_targets = [], [] model.eval() with torch.no_grad(): for imgs, masks in val_loader: preds = model(imgs).cpu().numpy() all_preds.append(preds) all_targets.append(masks.numpy()) all_preds = np.concatenate(all_preds) all_targets = np.concatenate(all_targets) for thresh in thresholds: binary_preds = (all_preds > thresh).astype(np.float32) dice = compute_dice(binary_preds, all_targets) if dice > best_dice: best_dice, best_thresh = dice, thresh return best_thresh, best_dice从那以后我每次做完分割训练,都强制走一遍「TTA + 阈值搜索 + 连通域后处理」这三板斧,基本能把裸模型的 Dice 从 0.88 推到 0.91 以上。这套流程不挑模型,换成 DeepLab 或者 nnU-Net 一样适用。希望帮到你。
本文还有配套的精品资源,点击获取