简介这份资源是面向计算机相关专业学生与深度学习入门者的肝脏分割项目源码包可作为毕业设计或课程作业的完整参考实现。项目聚焦医学图像分析中的肝脏分割任务采用深度卷积网络完成从数据预处理、模型训练到结果评估的全流程涉及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_pixels100): 从三维 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_okTrue) 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, fct_{z:03d}.npy), ct_norm.astype(np.float32)) np.save(os.path.join(save_dir, fmask_{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_width400, window_level50): 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, alpha34, sigma4): 弹性形变增强 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, order1, modereflect).reshape(shape) mask_deformed map_coordinates(mask, indices, order0, modereflect).reshape(shape) return image_deformed, mask_deformedalpha控制形变幅度34 是经验值太大肝脏形状会失真到不合理。sigma控制形变平滑程度4 左右比较自然。注意掩膜用order0最近邻插值避免产生非 0 非 1 的中间值。增强只在训练时做验证和测试用原始数据。3. 深度卷积网络选型U-Net 还是 DeepLab3.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_channels1, out_channels1, features[64, 128, 256, 512]): super(UNet, self).__init__() self.downs nn.ModuleList() self.ups nn.ModuleList() self.pool nn.MaxPool2d(kernel_size2, stride2) # 编码器 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_size2, stride2)) 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_size1) def _block(self, in_ch, out_ch): return 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): 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, sizeskip.shape[2:]) x torch.cat([skip, x], dim1) x self.ups[idx1](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_bce0.5, weight_dice0.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-4batch size 8 到 16。学习率调度用ReduceLROnPlateau监控验证集 Dice 系数patience 设 5factor 设 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(), lr1e-4, weight_decay1e-5) scheduler ReduceLROnPlateau(optimizer, modemax, factor0.5, patience5) criterion DiceBCELoss(weight_bce0.5, weight_dice0.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×256features从[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交并比、HD9595% 豪斯多夫距离。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_size500): 保留最大连通域形态学平滑 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, structurenp.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, devicecuda, slice_indicesNone): 加载 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, dtypeint) 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, cmapgray) ax.imshow(np.ma.masked_where(pred_mask 0, pred_mask), cmapReds, alpha0.4) ax.set_title(fSlice {z}) ax.axis(off) plt.tight_layout() plt.savefig(liver_seg_result.png, dpi150, bbox_inchestight) plt.show()这个脚本的关键点是apply_window和训练时用同一套参数否则输入分布不一致推理结果会崩。alpha0.4让红色掩膜半透明底下的 CT 纹理还能看见方便判断边界是否贴合。如果发现某层分割特别差单独把那一层放大看通常是该层肝脏与周围器官粘连严重或者训练集里这类样本太少。还有一个技巧把连续几层的分割结果做成 GIF 动图答辩时播放比静态图直观得多。用imageio库几行代码就能搞定。另外推理时可以用测试时增强TTA对输入做水平翻转和轻微旋转取多次预测的平均Dice 通常能再涨 0.5 到 1 个百分点代价是推理时间翻倍。如果只是交作业TTA 不是必须的但写进论文里是个加分项。最后说个我踩过的坑有一次训练完模型验证 Dice 0.91兴冲冲拿去做可视化结果发现所有切片都预测成了全黑。排查半天才发现是推理时忘了把输入归一化到和训练一致的分布训练时用了窗宽窗位推理时直接用了原始 CT 值。这个错误很隐蔽因为模型输出概率都在 0.5 以下二值化后全黑但 loss 和 Dice 在验证集上正常因为验证集走了正确的预处理流程。所以推理脚本一定要和训练脚本共用同一个 Dataset 类别手写预处理。希望帮到你。本文还有配套的精品资源点击获取