简介:这份资源是面向计算机相关专业学生与深度学习入门者的肝脏分割项目源码包,可作为毕业设计或课程作业的完整参考实现。项目聚焦医学图像分析中的肝脏分割任务,采用深度卷积网络完成从数据预处理、模型训练到结果评估的全流程,涉及Python与C++混合开发,适合希望理解CNN在医学影像中落地方式的读者。压缩包共43个文件,约50KB,以18个py脚本和15个pyc缓存文件为主,另含4个xml配置、3个md说明文档及1个ipynb笔记,涵盖数据集加载、损失函数、评价指标、网络结构定义与训练测试入口等模块,目录按dataset、models、utils等分层组织,便于按功能定位代码。目前已有124人学习下载。读者可从中获得可运行的训练与测试脚本、多种分割网络实现、数据增强与评估指标代码,以及项目配置与说明文档,有助于快速复现实验并理解医学图像分割的工程结构。
1. 肝脏分割这个毕设题目,到底在做什么
打开这个压缩包之前,先想清楚一件事:肝脏分割不是把肝脏从 CT 里抠出来那么简单。腹部 CT 里肝脏和脾脏、胃壁、心脏下缘的 CT 值经常重叠,边界模糊到肉眼都要来回翻层确认。基于深度卷积网络的肝脏分割,本质是训练一个像素级二分类器,输入一张腹部 CT 切片,输出一张同尺寸的掩膜,标出每个像素属不属于肝脏。这个方向在医学影像处理里属于语义分割的经典落地场景,也是深度学习毕设和课程作业里性价比很高的选题——数据集公开、评价指标明确、网络结构成熟,跑通之后还能往肿瘤分割、肝脏体积测量这些方向延伸。适合谁做?有 Python 基础、学过 CNN 基本概念、需要一份能写进论文或答辩演示的完整项目的人。下面从数据准备到训练调参再到避坑,按我实际跑这类项目的顺序讲一遍。
2. 数据准备与预处理:从 NIfTI 到可训练张量
2.1 肝脏分割常用数据集与格式转换
做肝脏分割,绕不开两个公开数据集:LiTS 和 Sliver07。LiTS 全称 Liver Tumor Segmentation Challenge,包含 131 例训练集和 70 例测试集,每例是一个三维腹部 CT 体积,附带肝脏和肿瘤的标注掩膜。Sliver07 则是 20 例训练加 10 例测试,专门做肝脏分割。这两个数据集的文件格式都是 NIfTI,扩展名.nii或.nii.gz,用nibabel库读写。
NIfTI 里存的不只是像素值,还有仿射矩阵,记录了体素间距和原点坐标。这个信息在预处理阶段必须保留,否则重采样之后空间位置全乱。常见做法是先把三维体积沿轴向切成二维切片,再筛选出包含肝脏的层。因为腹部 CT 上下两端很多层根本没有肝脏,全量喂进去只会让模型学到大量背景。
import nibabel as nib import numpy as np import os def extract_liver_slices(ct_path, mask_path, save_dir, min_liver_pixels=100): """ 从三维 NIfTI 中提取含肝脏的二维切片 ct_path: CT 体积路径 mask_path: 对应掩膜路径 save_dir: 保存目录 min_liver_pixels: 判定该层含肝脏的最小像素数 """ ct_vol = nib.load(ct_path).get_fdata() # 形状 (H, W, D) mask_vol = nib.load(mask_path).get_fdata() os.makedirs(save_dir, exist_ok=True) saved = 0 for z in range(ct_vol.shape[2]): mask_slice = mask_vol[:, :, z] if np.sum(mask_slice > 0) < min_liver_pixels: continue # 跳过无肝脏或肝脏极小的层 ct_slice = ct_vol[:, :, z] # 归一化到 [0,1],CT 值窗宽窗位调整在下一步做 ct_norm = (ct_slice - ct_slice.min()) / (ct_slice.max() - ct_slice.min() + 1e-8) np.save(os.path.join(save_dir, f"ct_{z:03d}.npy"), ct_norm.astype(np.float32)) np.save(os.path.join(save_dir, f"mask_{z:03d}.npy"), (mask_slice > 0).astype(np.uint8)) saved += 1 print(f"保存 {saved} 层切片")这段代码的逻辑是逐层扫描,只保留肝脏像素超过阈值的层。min_liver_pixels设 100 是个经验值,太小会引入大量边界噪声层,太大可能漏掉肝脏刚出现的层。实际调的时候可以打印每层肝脏像素数分布,看拐点在哪。保存成.npy而不是图片,是为了避免 JPEG 压缩引入伪影,医学图像对像素值精度很敏感。
2.2 CT 值窗宽窗位调整与归一化
CT 值的物理范围是 -1024 到 3071 HU,但肝脏组织的 CT 值集中在 40 到 70 HU 之间。如果直接对全范围做 min-max 归一化,肝脏区域的对比度会被压得很低,模型很难区分肝脏和周围软组织。标准做法是加窗:把窗宽设到 400,窗位设到 50,这样只保留 -150 到 250 HU 的范围,肝脏和周围器官的边界会清晰很多。
def apply_window(ct_slice, window_width=400, window_level=50): """ CT 值加窗,将感兴趣区域映射到 [0,1] window_width: 窗宽,控制对比度 window_level: 窗位,控制亮度中心 """ lower = window_level - window_width // 2 upper = window_level + window_width // 2 ct_clipped = np.clip(ct_slice, lower, upper) ct_windowed = (ct_clipped - lower) / (upper - lower) return ct_windowed.astype(np.float32)窗宽 400 窗位 50 是腹部 CT 肝脏观察的常用参数,但不同数据集可能有差异。LiTS 的 CT 值范围比较标准,直接用这套参数没问题。如果发现分割结果边缘总是偏大或偏小,可以微调窗位到 40 或 60 试试。归一化之后所有像素值落在 [0,1],训练时收敛更稳。
2.3 数据增强:旋转、翻转与弹性形变
医学图像标注成本极高,一个数据集往往只有几十到上百例,数据增强是必选项。肝脏分割里最有效的增强是随机旋转(±15 度)、水平翻转、随机缩放(0.9 到 1.1 倍)和弹性形变。弹性形变模拟的是呼吸运动导致的肝脏形变,对提升模型泛化能力帮助很大。
import random from scipy.ndimage import rotate, zoom from scipy.ndimage import gaussian_filter, map_coordinates def elastic_transform(image, mask, alpha=34, sigma=4): """ 弹性形变增强 alpha: 形变强度 sigma: 高斯平滑核大小 """ random_state = np.random.RandomState(None) shape = image.shape dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma) * alpha dy = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma) * alpha x, y = np.meshgrid(np.arange(shape[1]), np.arange(shape[0])) indices = np.reshape(y + dy, (-1, 1)), np.reshape(x + dx, (-1, 1)) image_deformed = map_coordinates(image, indices, order=1, mode='reflect').reshape(shape) mask_deformed = map_coordinates(mask, indices, order=0, mode='reflect').reshape(shape) return image_deformed, mask_deformedalpha控制形变幅度,34 是经验值,太大肝脏形状会失真到不合理。sigma控制形变平滑程度,4 左右比较自然。注意掩膜用order=0最近邻插值,避免产生非 0 非 1 的中间值。增强只在训练时做,验证和测试用原始数据。
3. 深度卷积网络选型:U-Net 还是 DeepLab
3.1 U-Net 在肝脏分割上的结构适配
U-Net 是医学图像分割的基线网络,编码器-解码器结构加跳跃连接,能在小数据集上取得不错效果。原始 U-Net 输入 572×572,输出 388×388,因为当年 GPU 显存有限做了裁剪。现在显存充足,直接改成输入输出同尺寸,用 padding 保持分辨率。
肝脏分割用 U-Net 有几个适配点。第一,输入通道改成 1,因为 CT 是灰度图。第二,输出通道改成 1 加 Sigmoid,做二分类。第三,编码器深度可以减到 4 层,因为肝脏分割的特征层次没有自然图像那么复杂,太深反而过拟合。第四,跳跃连接保留,肝脏的边界信息需要浅层特征来恢复。
import torch import torch.nn as nn class UNet(nn.Module): def __init__(self, in_channels=1, out_channels=1, features=[64, 128, 256, 512]): super(UNet, self).__init__() self.downs = nn.ModuleList() self.ups = nn.ModuleList() self.pool = nn.MaxPool2d(kernel_size=2, stride=2) # 编码器 for feature in features: self.downs.append(self._block(in_channels, feature)) in_channels = feature # 解码器 for feature in reversed(features): self.ups.append(nn.ConvTranspose2d(feature*2, feature, kernel_size=2, stride=2)) self.ups.append(self._block(feature*2, feature)) self.bottleneck = self._block(features[-1], features[-1]*2) self.final_conv = nn.Conv2d(features[0], out_channels, kernel_size=1) def _block(self, in_ch, out_ch): return 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): skip_connections = [] for down in self.downs: x = down(x) skip_connections.append(x) x = self.pool(x) x = self.bottleneck(x) skip_connections = skip_connections[::-1] for idx in range(0, len(self.ups), 2): x = self.ups[idx](x) skip = skip_connections[idx//2] if x.shape != skip.shape: x = nn.functional.interpolate(x, size=skip.shape[2:]) x = torch.cat([skip, x], dim=1) x = self.ups[idx+1](x) return torch.sigmoid(self.final_conv(x))features列表控制每层通道数,肝脏分割用[64, 128, 256, 512]就够了。如果显存不够,砍到[32, 64, 128, 256]。BatchNorm2d在 batch size 大于 8 时稳定,小于 8 建议换InstanceNorm2d,医学图像分割里 InstanceNorm 对小 batch 更友好。
3.2 损失函数选择:Dice Loss 与 BCE 的组合
肝脏分割里正负样本极度不平衡,背景像素通常是肝脏像素的 10 到 20 倍。只用二元交叉熵(BCE)会让模型倾向于预测背景,Dice 系数上不去。常见做法是 BCE 和 Dice Loss 加权组合,BCE 稳定训练初期,Dice 直接优化重叠度。
class DiceBCELoss(nn.Module): def __init__(self, weight_bce=0.5, weight_dice=0.5): super(DiceBCELoss, self).__init__() self.weight_bce = weight_bce self.weight_dice = weight_dice self.bce = nn.BCELoss() def forward(self, pred, target): bce_loss = self.bce(pred, target) # Dice Loss pred_flat = pred.view(-1) target_flat = target.view(-1) intersection = (pred_flat * target_flat).sum() dice_loss = 1 - (2. * intersection + 1e-6) / (pred_flat.sum() + target_flat.sum() + 1e-6) return self.weight_bce * bce_loss + self.weight_dice * dice_lossweight_bce和weight_dice各 0.5 是起点。如果训练初期 loss 震荡厉害,把 BCE 权重提到 0.7。如果 Dice 系数卡在 0.85 上不去,把 Dice 权重提到 0.7。平滑项1e-6防止除零,不能省。
3.3 训练参数设置与学习率调度
优化器用 Adam,初始学习率 1e-4,batch size 8 到 16。学习率调度用ReduceLROnPlateau,监控验证集 Dice 系数,patience 设 5,factor 设 0.5。训练轮数 100 到 150,早停 patience 设 15。
from torch.optim import Adam from torch.optim.lr_scheduler import ReduceLROnPlateau model = UNet().cuda() optimizer = Adam(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = ReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=5) criterion = DiceBCELoss(weight_bce=0.5, weight_dice=0.5) for epoch in range(150): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() optimizer.zero_grad() pred = model(img) loss = criterion(pred, mask) loss.backward() optimizer.step() # 验证 model.eval() val_dice = evaluate(model, val_loader) scheduler.step(val_dice) if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), 'best_liver_unet.pth')weight_decay设 1e-5 做 L2 正则,防止过拟合。验证指标用 Dice 系数,因为肝脏分割最终看的就是重叠度。保存最佳模型而不是最后一轮,避免过拟合后性能下降。
4. 训练过程避坑与排查:血泪经验五条
4.1 损失降到很低但 Dice 系数不涨
现象:训练 loss 从 0.8 降到 0.1,但验证集 Dice 卡在 0.6 左右。原因通常是类别不平衡导致模型学会了全部预测背景,loss 很低但分割结果全黑。解决:检查 Dice Loss 权重是否被 BCE 压制,把weight_dice提到 0.7 以上;同时在数据加载时对含肝脏的切片做重采样,让正样本比例不低于 30%。
4.2 验证集 Dice 波动超过 0.1
现象:相邻两个 epoch 的验证 Dice 从 0.82 跳到 0.71 又跳回来。原因是 batch size 太小或者验证集样本太少。解决:把 batch size 提到 16,验证集至少留 15 例病人的数据,不要按切片随机划分,要按病人划分,否则同一病人的相邻切片会泄漏到验证集,造成虚高。
4.3 肝脏边缘分割毛刺严重
现象:分割掩膜在肝脏与胃壁、心脏交界处出现锯齿状毛刺。原因是网络感受野不够,或者跳跃连接融合时浅层噪声被放大。解决:在编码器最后加一层空洞卷积扩大感受野,或者在跳跃连接上加 1×1 卷积做通道压缩,抑制浅层噪声。后处理用形态学开运算也能去掉小毛刺,但会轻微腐蚀边界。
4.4 显存溢出导致训练中断
现象:训练到一半报 CUDA out of memory。原因是输入尺寸太大或者模型通道数太多。解决:把输入从 512×512 降到 256×256,features从[64,128,256,512]降到[32,64,128,256]。如果还不行,用梯度累积,每 4 个 batch 更新一次参数,等效 batch size 不变但显存占用降到四分之一。
4.5 推理时单张切片分割正常但三维重建错位
现象:逐层推理结果拼回三维体积后,肝脏在 Z 轴方向出现错层。原因是预处理时保存的切片顺序和原始 NIfTI 的 Z 轴方向不一致,或者重采样时没有同步更新仿射矩阵。解决:保存切片时记录原始 Z 轴索引,重建时按索引排序;重采样用nibabel的resample_from_to而不是手动插值,它会自动处理仿射矩阵。
5. 评估指标与后处理:让分割结果能写进论文
5.1 Dice、IoU、HD95 的计算与解读
肝脏分割论文里必报的三个指标:Dice 相似系数、IoU(交并比)、HD95(95% 豪斯多夫距离)。Dice 和 IoU 衡量重叠度,HD95 衡量边界误差。Dice 对内部填充敏感,HD95 对边界毛刺敏感,两个一起看才能全面评价。
from scipy.spatial.distance import directed_hausdorff import numpy as np def compute_metrics(pred, target): """ pred, target: 二值掩膜,形状 (H, W) """ pred = pred.astype(bool) target = target.astype(bool) intersection = np.logical_and(pred, target).sum() union = np.logical_or(pred, target).sum() dice = 2. * intersection / (pred.sum() + target.sum() + 1e-6) iou = intersection / (union + 1e-6) # HD95 pred_points = np.argwhere(pred) target_points = np.argwhere(target) if len(pred_points) == 0 or len(target_points) == 0: return dice, iou, float('inf') hd1 = directed_hausdorff(pred_points, target_points)[0] hd2 = directed_hausdorff(target_points, pred_points)[0] hd95 = np.percentile([hd1, hd2], 95) return dice, iou, hd95Dice 到 0.90 以上算不错,0.93 以上算优秀。HD95 在 10 毫米以内可以接受,5 毫米以内算好。注意 HD95 对离群点敏感,如果分割结果有一个孤立的假阳性区域,HD95 会飙升,所以后处理去小连通域很重要。
5.2 连通域分析与形态学后处理
网络输出经过 Sigmoid 后是概率图,阈值 0.5 二值化后可能残留小连通域。肝脏是最大的连通区域,保留最大连通域能去掉大部分假阳性。
from scipy.ndimage import label, binary_opening, binary_closing def postprocess(mask, min_size=500): """ 保留最大连通域,形态学平滑 min_size: 最小连通域像素数 """ labeled, num = label(mask) if num == 0: return mask sizes = np.bincount(labeled.ravel()) sizes[0] = 0 # 背景不计 largest_label = sizes.argmax() mask_clean = (labeled == largest_label) # 形态学闭运算填充小孔 mask_clean = binary_closing(mask_clean, structure=np.ones((3,3))) return mask_clean.astype(np.uint8)min_size设 500 是经验值,对应 256×256 切片里约 0.8% 的面积。闭运算用 3×3 结构元,太大边界会变形。开运算去毛刺,闭运算填孔洞,顺序一般是先开后闭。
5.3 三维重建与体积测量
把二维切片拼回三维体积,计算肝脏体积,这是临床最关心的量化指标。体积等于肝脏体素数乘以单个体素体积,体素体积从 NIfTI 的仿射矩阵里取。
def volume_from_slices(mask_slices, voxel_volume_mm3): """ mask_slices: 三维二值数组 (D, H, W) voxel_volume_mm3: 单个体素体积,单位立方毫米 """ liver_voxels = np.sum(mask_slices > 0) volume_mm3 = liver_voxels * voxel_volume_mm3 volume_ml = volume_mm3 / 1000.0 return volume_ml正常成人肝脏体积在 1200 到 1500 毫升之间,如果算出来偏离这个范围太多,先检查体素体积有没有算错。体素体积等于三个方向的 spacing 乘积,从nib.load(ct_path).header.get_zooms()取。
6. 从毕设到可展示:推理脚本与结果可视化技巧
训练完模型只是第一步,答辩或提交作业时需要展示分割效果。我一般会写一个推理脚本,输入一个 NIfTI 文件,输出叠加了分割掩膜的三维可视化。用matplotlib画几层代表性切片,肝脏区域用红色半透明覆盖,边界用黄色描边,这样一眼就能看出分割质量。
import matplotlib.pyplot as plt import nibabel as nib import numpy as np import torch def visualize_prediction(ct_path, model, device='cuda', slice_indices=None): """ 加载 CT,逐层推理,可视化指定层 slice_indices: 要展示的层索引列表,None 则均匀取 5 层 """ ct_vol = nib.load(ct_path).get_fdata() H, W, D = ct_vol.shape if slice_indices is None: slice_indices = np.linspace(D//4, 3*D//4, 5, dtype=int) model.eval() fig, axes = plt.subplots(1, len(slice_indices), figsize=(4*len(slice_indices), 4)) for ax, z in zip(axes, slice_indices): ct_slice = ct_vol[:, :, z] ct_windowed = apply_window(ct_slice) input_tensor = torch.from_numpy(ct_windowed).unsqueeze(0).unsqueeze(0).float().to(device) with torch.no_grad(): pred = model(input_tensor).cpu().numpy()[0, 0] pred_mask = (pred > 0.5).astype(np.uint8) pred_mask = postprocess(pred_mask) ax.imshow(ct_windowed, cmap='gray') ax.imshow(np.ma.masked_where(pred_mask == 0, pred_mask), cmap='Reds', alpha=0.4) ax.set_title(f'Slice {z}') ax.axis('off') plt.tight_layout() plt.savefig('liver_seg_result.png', dpi=150, bbox_inches='tight') plt.show()这个脚本的关键点是apply_window和训练时用同一套参数,否则输入分布不一致,推理结果会崩。alpha=0.4让红色掩膜半透明,底下的 CT 纹理还能看见,方便判断边界是否贴合。如果发现某层分割特别差,单独把那一层放大看,通常是该层肝脏与周围器官粘连严重,或者训练集里这类样本太少。
还有一个技巧:把连续几层的分割结果做成 GIF 动图,答辩时播放,比静态图直观得多。用imageio库几行代码就能搞定。另外,推理时可以用测试时增强(TTA),对输入做水平翻转和轻微旋转,取多次预测的平均,Dice 通常能再涨 0.5 到 1 个百分点,代价是推理时间翻倍。如果只是交作业,TTA 不是必须的,但写进论文里是个加分项。
最后说个我踩过的坑:有一次训练完模型,验证 Dice 0.91,兴冲冲拿去做可视化,结果发现所有切片都预测成了全黑。排查半天才发现是推理时忘了把输入归一化到和训练一致的分布,训练时用了窗宽窗位,推理时直接用了原始 CT 值。这个错误很隐蔽,因为模型输出概率都在 0.5 以下,二值化后全黑,但 loss 和 Dice 在验证集上正常,因为验证集走了正确的预处理流程。所以推理脚本一定要和训练脚本共用同一个 Dataset 类,别手写预处理。希望帮到你。
本文还有配套的精品资源,点击获取