简介:面向计算机专业毕设与课程作业的医学图像语义分割项目包,内容覆盖深度学习理论、Python 与 C++ 混合实现以及端到端系统集成,适合准备复现 U-Net、CNN 分割流程的本科高年级学生或研究生。压缩包共 438 个文件,整体约 11.12MB;文件类型以 cpp、cu、hpp 等 C++/CUDA 源码、prototxt/cmake 配置、py/ipynb 脚本、md 文档为主体,另含 h5、npy 模型权重、mat 数据与图像样例。已有 151 人学习,可视为一份结构完整的小型项目资源。内部代码从数据预处理、网络搭建、损失函数设计到训练预测逐层展开,并附 Caffe 风格配置、工具脚本与可视化结果;对照肿瘤、血管等分割场景,可帮助快速跑通全流程,并便于替换数据、调整模型做进一步实验。适合课程设计答辩前系统走查一遍,也适合作为后续医学影像分割研究的基线代码。
1. 医学图像语义分割毕设:这份资源里到底有什么
第一次接触医学图像语义分割的人,很容易把它当成图像分类的进阶版。真上手才会发现,分类只回答“图里有没有病灶”,分割要把每个像素标成组织或病灶,标注颗粒度完全不同。这份《毕设&课程作业_基于深度学习的医学图像语义分割》拆开来看是一条完整的技术链路:Python 端的图像预处理与 U-Net 训练,CMake 组织的 C++ 推理与系统集成,连 gtest 单测和依赖管理都在里面。适合正在做毕设、赶课程大作业,或者想把深度学习模型真正接到工程里的从业者。省掉最尴尬的那段路——模型在 Python 里跑通了,却不知道编译、部署、验收怎么闭环。
2. 语义分割算法从原理到选型:为什么是 U-Net 而不是 FCN
2.1 任务定义与 U-Net 结构:像素级分类为什么难
语义分割的目标是为输入图像的每一个像素分配一个类别标签。医学场景里最常见的是把 CT、MRI 或超声图像里的器官、肿瘤、血管区域标出来。它与目标检测的本质区别在于:检测输出的是包围盒坐标,分割输出的是与输入分辨率一致的 mask。很多人会拿 YOLO 系列来对比,但 YOLO 那类模型解决的是实例级别的定位任务,语义分割是“同类别不区分个体”的像素级预测,两者在输出空间和 loss 设计上都不是一回事。
U-Net 是这类任务里被验证次数最多的骨架。它的结构分三块:编码器逐层下采样提取语义特征,解码器逐层上采样恢复分辨率,把深层语义信息和浅层空间细节连接起来。医学图像的特点是边界模糊、目标小、样本量少,U-Net 通过跳跃连接(skip connection)让浅层的高分辨率特征直接送到解码器,等于给精细边缘开了一条“高速通道”。FCN 也有编码解码思路,但少了跳跃连接这种跨层特征复用,在小数据集上表现明显不稳定。这也是为什么 U-Net 在医学分割里几乎成了默认选择。
下面这段是 U-Net 核心结构的最小骨架,只保留编码器两层和解码器一层的跳跃连接写法,方便看清特征图是怎么拼起来的。
import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = 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.conv(x) class MiniUNet(nn.Module): def __init__(self, in_ch=1, num_classes=2): super().__init__() self.enc1 = ConvBlock(in_ch, 64) self.pool = nn.MaxPool2d(2) self.enc2 = ConvBlock(64, 128) self.up = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = ConvBlock(128, 64) self.out = nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 = self.enc1(x) # 保留浅层特征 e2 = self.enc2(self.pool(e1)) d = self.up(e2) # 上采样到与 e1 同尺寸 d = torch.cat([d, e1], dim=1) # 跳跃连接:通道拼接 d = self.dec1(d) return self.out(d)torch.cat([d, e1], dim=1)这一步就是跳跃连接的核心,把编码器下采样前的细节信息和解码器上采样后的语义信息在通道维度拼接。特征图尺寸减半时,通道数翻倍,这是 U-Net 保持信息容量的常用做法。num_classes在医学分割里通常是 2(背景+目标)或更多类别,输出层的Conv2d(64, num_classes, 1)把特征映射成每个像素的类别得分。
2.2 损失函数与评估指标:Dice 比交叉熵好在哪里
训练分割模型,最先遇到的就是损失函数怎么选。像素级交叉熵是最直接的方案,但医学图像存在一个残酷的现实:背景像素占比经常超过 95%,肿瘤或血管区域可能只占百分之几。交叉熵会让模型把大量梯度花在背景上,训练出来的结果往往是“全背景也差不多”。Dice Loss 专门处理这种类别不平衡,它的分母是预测结果和真实标签的像素总和,占比小的前景一旦预测错,损失变化非常明显。
常见做法是把 CrossEntropy 和 Dice Loss 按权重叠加,比如loss = 0.5 * ce_loss + 0.5 * dice_loss。训练初期交叉熵帮助模型快速学会基本轮廓,后期 Dice Loss 负责把边界和细小结构修细。我自己一般会把 Dice Loss 权重放到 0.6 甚至更高,具体看前景占比,病灶越小、边界越细,Dice 权重越大。
评估指标则要分清 Dice 和 IoU。Dice 计算公式等同于2 * |X∩Y| / (|X| + |Y|),IoU 是|X∩Y| / |X∪Y|。同样的预测结果,Dice 数值会高于 IoU,所以验收标准要说清楚用的是哪个指标。医学影像论文里的“Dice 0.85”一般是前景区域的 Dice,不是所有类别的简单平均。看验证结果时,按类别分别统计比看平均分可靠得多,这个问题我放在第 6 章专门说。
2.3 框架选型与语言分工:Python 训练 + C++ 部署的合理性
这套资源涉及的另一个问题是:为什么训练和部署要用两种语言。深度学习实验阶段追求快速迭代,PyTorch 的 tensorboard 集成、调试体验和生态资料目前最顺手,TensorFlow/Keras 的上手曲线更平,但实验灵活性稍差。训练写好一套数据加载、验证循环、权重保存逻辑之后,Python 的优势已经足够明显。
部署阶段则是另一回事。实时推理、资源受限设备、与现有 C++ 系统整合,这些场景下 Python 解释器带来的开销和依赖管理成本会变成负担。C++ 可以直接调用 LibTorch 或 ONNX Runtime,把模型加载、预处理、前向推理全部编进一个可执行文件,配合 CMake 管理依赖,闭环才算真正完成。下面这张表是常用的选型对照。
| 阶段 | 常用方案 | 选型理由 |
|---|---|---|
| 模型训练 | PyTorch 或 TensorFlow | Python API 灵活、可视化方便 |
| 模型导出 | TorchScript 或 ONNX | 跨语言部署的标准桥接方式 |
| 推理部署 | LibTorch 或 ONNX Runtime | C++ 性能高、依赖可控 |
摘要里提到的“系统集成”其实就是把数据预处理、模型推理、结果可视化串成一个端到端流程。Python 管好训练侧,C++ 管好部署侧,中间用 TorchScript 或 ONNX 做模型交换,这是当前最常用的工程落地模式。
3. 数据与训练配置:把医学图像切成模型能吃的样子
3.1 数据目录与预处理:读图、归一化、插值方式
医学图像格式跟自然图像差异很大。CT、MRI 通常是单通道灰度图,像素值范围可能跟普通 JPEG 完全不同;标签 mask 一般是 PNG 或 nii 格式,像素值直接对应类别编号。拿到资源后第一件事不是跑训练,而是把数据读取链路先验证一遍。
目录结构我一般这样组织:images/放原始图像,masks/放标注,train.txt和val.txt记录文件名列表。这样 DataLoader 只需要读文本文件,不用维护一套复杂的数据库依赖。
import cv2 import torch from torch.utils.data import Dataset class SegDataset(Dataset): def __init__(self, img_dir, mask_dir, file_list, size=512): self.img_dir = img_dir self.mask_dir = mask_dir self.size = size with open(file_list, 'r') as f: self.names = [line.strip() for line in f.readlines()] def __getitem__(self, idx): name = self.names[idx] img = cv2.imread(f"{self.img_dir}/{name}", cv2.IMREAD_GRAYSCALE) mask = cv2.imread(f"{self.mask_dir}/{name}", cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (self.size, self.size), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (self.size, self.size), interpolation=cv2.INTER_NEAREST) img = torch.from_numpy(img).float() / 255.0 mask = torch.from_numpy(mask).long() img = img.unsqueeze(0) # 单通道 -> (1, H, W) return img, mask这里最需要注意的是mask的插值方式必须用cv2.INTER_NEAREST。如果用线性插值,会凭空出现类别的中间值,比如类别 1 和类别 2 之间产生 1.5,标签直接损坏。图像用线性插值没太大问题,但标注文件永远只能用最近邻。除以 255.0 是一种轻量归一化,如果训练数据来自不同设备,建议改用均值方差归一化,后面部署时保持一致即可。
3.2 数据增强与训练循环:参数怎么设、模型怎么存
医学图像分割的增强策略和平常分类任务不太一样。随机裁剪、水平翻转、旋转是最基础的组合;血管、视网膜这类结构还可以加弹性形变,模拟组织变形。训练集增强可以让模型对尺度、方向和形变更鲁棒,但验证集合一般只做 resize 和归一化,不做随机增强,否则指标会失真。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), ]) def dice_loss(pred, target, smooth=1e-6): pred = torch.softmax(pred, dim=1) # 只取前景类别计算 pred_fg = pred[:, 1] target_fg = (target == 1).float() intersection = (pred_fg * target_fg).sum() return 1 - (2.0 * intersection + smooth) / (pred_fg.sum() + target_fg.sum() + smooth) model = MiniUNet(in_ch=1, num_classes=2) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) criterion = nn.CrossEntropyLoss() for epoch in range(epochs): model.train() for img, mask in train_loader: optimizer.zero_grad() output = model(img) loss = criterion(output, mask) + dice_loss(output, mask) loss.backward() optimizer.step()torch.optim.AdamW带权重衰减,能抑制过拟合。学习率从 1e-4 起步是当前 PyTorch 训练视觉模型比较稳的起点,过大容易振荡,过小收敛太慢。训练过程中每跑完一个 epoch,就在验证集上算一次前景 Dice,按历史最优保存权重,这样就算后面调坏了参数,也有一个能回滚的检查点。
下表是训练阶段最常用到的几个超参数,具体值还要按显存和数据量微调。
| 参数 | 常见取值 | 说明 |
|---|---|---|
| image_size | 512x512 | 分辨率越高显存占用越大 |
| batch_size | 4~8 | 受 GPU 显存限制,U-Net 很吃显存 |
| learning_rate | 1e-4 | AdamW 起步值,loss 不降时往小调 |
| epochs | 50~100 | 看验证指标收敛情况,不用死磕数值 |
| loss 权重 | CE:Dice=0.5:0.5 | 前景占比小的时候加大 Dice 权重 |
num_workers在 DataLoader 里按 CPU 核数设置,一般 4~8 就够,设太大反而可能因为进程切换拖慢速度。显存不够时优先把batch_size降下来,而不是急着缩减image_size,因为分辨率直接决定分割边界的精细度,这是分割任务里非常关键的取舍。
4. C++ 与 CMake 工程集成:从 Python 训练到高性能部署
4.1 文件树里的 CMake 模块:Cuda.cmake、Dependencies.cmake 各自负责什么
这套资源的工程文件里出现了大量 CMake 模块,包括Cuda.cmake、Dependencies.cmake、FindLAPACK.cmake、ProtoBuf.cmake、ConfigGen.cmake、Utils.cmake、Targets.cmake、Summary.cmake,外加一个gtest_main.cc。把它们拆成独立模块,说明作者是按“构建可维护的工程系统”来组织的,不是随手写一个 CMakeLists 一把梭。
这种拆分逻辑很清晰:每个.cmake文件只管一类事情,主 CMakeLists 只负责按顺序调用它们。Cuda.cmake负责检测 CUDA 工具链、设置算力架构;Dependencies.cmake统一查找 OpenCV、LAPACK 等第三方库;ProtoBuf.cmake处理 protobuf 编译,这通常是数据序列化或者模型配置读取要用到的;Targets.cmake定义编译目标和链接关系;Summary.cmake在配置阶段把依赖和编译选项打印出来,方便一眼看出环境对不对。
cmake_minimum_required(VERSION 3.16) project(seg_infer LANGUAGES CXX CUDA) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) include(cmake/Cuda.cmake) include(cmake/Dependencies.cmake) include(cmake/Utils.cmake) find_package(OpenCV REQUIRED) find_package(Torch REQUIRED) add_executable(segmenter src/main.cpp src/preprocess.cpp src/inference.cpp ) target_include_directories(segmenter PRIVATE src ${TORCH_INCLUDE_DIRS} ${OpenCV_INCLUDE_DIRS} ) target_link_libraries(segmenter ${TORCH_LIBRARIES} ${OpenCV_LIBS} ) include(cmake/ConfigGen.cmake) include(cmake/Summary.cmake)这里把 CUDA 和 C++17 一起写进project声明,便于一次性开启。find_package(Torch)是 LibTorch 自带的支持,它会返回TORCH_LIBRARIES和TORCH_INCLUDE_DIRS两个变量供链接使用。这套组织方式的新手友好度在于:环境缺什么依赖,CMake 配置阶段直接报错,比运行时崩溃容易排查得多。
4.2 C++ 推理落地:LibTorch 加载模型与预处理对齐
Python 端训练完成的模型,先导出为 TorchScript 或 ONNX,再交给 C++ 加载推理。导出操作最简单的方式是在 Python 里用torch.jit.trace或torch.jit.script生成一个model.pt文件。C++ 端用 LibTorch 加载后,关键工作集中在输入数据准备上。
#include <torch/script.h> #include <opencv2/opencv.hpp> cv::Mat preprocess(const std::string& path) { cv::Mat img = cv::imread(path, cv::IMREAD_GRAYSCALE); cv::resize(img, img, cv::Size(512, 512)); img.convertTo(img, CV_32F, 1.0 / 255.0); return img; } torch::Tensor forward(torch::jit::Module& model, const cv::Mat& img) { torch::Tensor tensor = torch::from_blob( img.data, {1, 1, img.rows, img.cols}, torch::kFloat32 ).clone(); auto output = model.forward({tensor}).toTensor(); auto prob = torch::softmax(output, 1); return torch::argmax(prob, 1); }torch::from_blob直接复用 OpenCV 的内存,生成 Tensor 后必须调用.clone(),否则cv::Mat析构后 Tensor 指向的内存已经失效,这是 C++ 推理最常见的崩溃点。{1, 1, img.rows, img.cols}对应 batch size、通道数、高度、宽度。灰度图是 1 通道,如果训练时用的是 RGB 三通道输入,这里要改成cv::cvtColor先把图转成 BGR 或 RGB,并用{1, 3, ...}。
C++ 端最容易翻车的不是模型推理,而是预处理和 Python 端不一致。Python 里用了均值[0.485, 0.456, 0.406]和方差[0.229, 0.224, 0.225],C++ 里就必须原样复刻一遍;Python 里用的是 BGR 顺序,C++ 用 OpenCV 默认读取结果保持一致即可。建议把归一化参数写进一个单独的头文件,两端共用同一份数值,避免魔数散落各处。
注意:模型在 Python 端跑通了不等于部署没有问题,先用一张固定输入图在两端分别输出中间 tensor,逐位对比,数值一致再继续。
资源里还带了gtest_main.cc,这说明工程已经把单元测试基础设施准备好了。预处理函数、后处理函数、参数解析这类纯逻辑代码,很适合用 GoogleTest 写回归测试。每次改完代码跑一遍测试,比靠肉眼观察分割效果靠谱得多。
5. 避坑清单:医学图像分割里的五个血泪现场
5.1 Loss 不降,曲线像心电图
现象:训练几十个 epoch,loss 基本不动,或者震荡幅度大到根本看不出下降趋势。
原因:最常见的是学习率太大,模型在损失平面上来回横跳;其次是数据没归一化,像素值范围差异太大导致梯度不稳;再一个是标签读取时插值用错了,mask 里混进非整数类别,模型学到的全是噪声。
解决:先把学习率降到 1e-4 甚至 5e-5,观察前几个 epoch 的 loss 是否稳定下降。同时打印一个 batch 的输入和标签,确认图像像素范围在 0~255 或 0~1 之间,标签严格是 0、1、2 这样的整数。不要急着换模型,先排除数据链路的问题。
5.2 一调大 Batch Size 就显存溢出
现象:batch size 从 4 调到 8,程序直接报 CUDA out of memory。
原因:U-Net 在解码器部分要保留大量中间特征图,512x512 输入配合大通道数,显存占用会成倍上涨。很多人以为显存溢出是显卡不够好,实际上是特征图数量太多,而这些问题在训练初期就会暴露。
解决:优先减小 batch size,这是对训练效果影响最小的方案。如果 batch size 减到 2 还是不够,就把训练图像分辨率降到 256x256,验证时再用全分辨率原图推理。也可以利用梯度累积,多个小 batch 更新一次参数,相当于变相扩大 batch size,只增加训练时间,不增加显存。
5.3 分割结果全是背景
现象:训练完毕,模型输出的预测 mask 基本是全黑的,目标区域一个像素都没分出来。
原因:背景像素占比太高。交叉熵在这个场景下发现“全部输出背景”的 loss 已经很低,模型失去了学习前景的动力。血管、早期肿瘤、细小病灶这类目标占比经常在 1% 以下,最容易中招。
解决:换用 Dice Loss 或 Focal Loss,或者把交叉熵和 Dice Loss 按权重叠加。前景占比越小,Dice 权重越要放大。还可以对前景类别的权重做上采样,让模型在训练时更“关注”少数类。这一步是医学图像分割里最值得花时间的调整。
5.4 C++ 部署结果和 Python 完全对不上
现象:Python 验证集 Dice 达到 0.9,导出的模型在 C++ 里跑出来的图全是噪声,或者整体偏移。
原因:预处理细节没对齐。常见的有:Python 端用的是 RGB,C++ 端 OpenCV 读出来是 BGR;Python 里归一化用了均值方差,C++ 端只除了 255;输入尺寸不一致导致特征图分布全乱了。
解决:先把一张固定图片分别在两端跑一遍,打印输入 tensor 的前几个数值和常量;然后逐步对比 softmax 输出,找到第一个出现差异的位置。把预处理参数写成一个共享配置,Python 和 C++ 都从同一份配置读取,而不是在两端各写一遍魔数。部署这类问题,九成都是预处理不一致。
5.5 测试指标和训练指标“打架”
现象:验证集 Dice 涨得很好,一上测试集就崩。
原因:数据泄漏是最常见的元凶。医学影像数据经常来自少数病人,同一个病人的多张切片如果同时出现在训练集和验证集,模型等于提前见过答案。另一个原因是增强不够,模型过拟合了训练集的亮度、方位等表面特征。
解决:按病人划分数据集,同一个病人的所有切片必须放进同一个集合,不能随机切分。训练时加上旋转、翻转、缩放增强,让模型学到的是结构特征而不是静态特征。条件允许的话,跑一遍交叉验证,看 Dice 的方差,方差过大说明划分方式有问题。
6. 模型验证与进阶:用 Dice/IoU 守住交付底线
6.1 Dice 和 IoU 的正确计算方式
很多毕设和课程作业只给一张训练 Loss 曲线图就结了,但验收的人最关心的其实是量化指标。计算 Dice 时最容易犯的错误是直接对整张图做计算,医学影像里背景占绝大多数,这样算出来的 Dice 永远虚高。正确做法是只算目标类别,或者逐类别分别计算后取平均值。
def dice_iou(pred_mask, gt_mask, num_classes=2): dice_per_class = [] iou_per_class = [] for c in range(num_classes): p = pred_mask == c g = gt_mask == c intersection = (p & g).sum() union = (p | g).sum() dice = (2 * intersection + 1e-6) / (p.sum() + g.sum() + 1e-6) iou = (intersection + 1e-6) / (union + 1e-6) dice_per_class.append(dice) iou_per_class.append(iou) return dice_per_class, iou_per_class这里用了+1e-6的平滑项,避免某一类在 ground truth 中不存在时分母为 0。输出的dice_per_class是一个列表,顺序对应类别编号,检查时重点看前景类别的 Dice,背景类别永远接近 1.0,没有参考价值。
6.2 TTA 与后处理:把分割边界再磨一磨
如果模型在验证集上 Dice 已经能到 0.85,但对个别边界模糊的切片表现不稳定,可以试测试时增强(TTA)。推理时把同一张输入做水平翻转,分别跑前向,把两次 softmax 结果平均后再取 argmax,相当于模型自己做了一次投票,能明显抑制单次推理的噪声。后处理方面,条件随机场(CRF)是语义分割的经典收尾手段,将类别概率图与原图像像素间的关联建模,能消掉独立的小噪点、补平边缘毛刺。如果项目报告需要展示可视化结果,这一步往往能让分割边界看起来“专业”很多。
带这套资源做项目时,我第一版模型平均 Dice 到 0.91,看起来体面,一按类别拆开看,病灶占比最小的那个类只有 0.63,直接被临床同事打回。从那以后,我每次训练完都强制自己把每个类别的 Dice 打出来看,而不是只盯平均值。希望帮到你。
本文还有配套的精品资源,点击获取