基于U-Net的医学图像分割系统实战:从环境配置到工程落地 简介一套基于Python与深度学习的医学图像分割系统完整项目涵盖源码、数据集与配套文档专为毕业设计、课程设计及项目开发场景准备。项目以U-Net为核心结构围绕医学影像分割的典型任务展开代码结构清晰训练与预测流程明确并已通过严格测试可直接参考或在此基础上二次扩展。压缩包共138个文件约13.66MB。其中Python脚本6个py为系统核心源码6个xml文件对应标注或配置信息120张png图片提供数据集样例与分割结果可视化另有项目说明、开源许可证及Git配置等文件便于快速理解项目结构和运行方式。整体分类清晰检索使用方便。目前已有266人学习/下载。借助该项目可以掌握深度学习方法在医学图像分割中的落地流程包括数据准备、模型构建、训练调试与结果展示等环节同时为课程设计或毕业答辩提供可演示的完整方案。1. 这套医学图像分割系统毕设、课设都能直接落地的U-Net工程如果你的毕业设计题目是“基于深度学习的医学图像分割”大概率会被师兄师姐推荐先跑通一个U-Net。手头这套基于Python深度学习实现的医学图像分割系统就是这类项目里最经典的一档源码、数据集、文档三件套齐整压缩包里还带了可以直接推理验证的png测试图。它适合三类人准备毕业设计的学生、做课程设计需要演示系统的本科生、以及刚入门深度学习想找一个完整工程参考的开发者。这套工程不是玩具代码训练、验证、预测的链路都是齐的你拿到手要做的第一件事不是读论文而是把环境配好、把脚本跑起来看它输出的分割掩膜到底长什么样。2. 把源码包变成能跑的工程目录结构、环境安装与首轮训练验证2.1 拿到压缩包先看清这四类东西解压之后第一眼看到的是一堆散文件我按用途把它们分成四类。第一类是和工程元信息相关的文件.gitignore、LICENSE、README.md、U-Net.iml。.gitignore告诉Git哪些目录不要提交LICENSE规定了代码的授权方式README.md是这个包最重要的阅读入口——数据怎么放、环境怎么配、训练命令是什么一般都会写在里面。U-Net.iml是IDEA系列IDE的模块文件说明这套工程用PyCharm可以直接打开ImL文件本身不参与运行删掉也不影响训练。第二类是源码也就是压缩包里真正值钱的部分。U-Net工程的源码通常按模块拆成几个文件模型结构定义、数据加载与预处理、训练循环、验证与推理。你在包里找unet_model.py或者model.py就能定位到核心网络结构找train.py、predict.py这类带main入口的脚本就是项目的启动点。第三类是数据集。第四类是那几张可以直接用来测试的png图片21.png、23.png、24.png、25.png、28.png。这几张图是拿来验证模型推理效果的样本训练完之后拿它们跑一遍分割能直观看到模型输出。2.2 环境配置Python版本与PyTorch依赖安装这套系统用的是Python生态里的深度学习框架最常见的是PyTorch。配置环境前先把Python装好我建议直接用Anaconda创建独立环境别把依赖装到系统Python里否则后面装别的项目会互相打架。conda create -n unet_seg python3.8 conda activate unet_seg pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy opencv-python pillow matplotlib tqdm第一行创建Python 3.8版本的独立环境命名unet_seg避免和别的项目混淆。第二行激活环境。第三行安装PyTorchcu118代表CUDA 11.8版本如果你电脑没有NVIDIA显卡把这行换成pip install torch torchvision就行PyTorch会自动装CPU版本训练会慢一些但能跑通。第四行装的是图像处理和训练辅助库opencv-python用于图像读写和形态学操作pillow是Python最基础的图像库matplotlib用来画loss曲线和展示分割结果tqdm在训练时显示进度条。装完之后验证一下python -c import torch; print(torch.__version__); print(torch.cuda.is_available())如果输出True说明CUDA可用训练会走GPU。如果输出False说明当前是CPU模式也能跑但训练时间会拉长。2.3 跑通一次推理用测试图验证工程可用性环境配好后先用测试图把推理链路跑通。U-Net工程里一般会有一个预测脚本常见名字是predict.py它做的事是读入一张测试图加载训练好的权重输出分割掩膜。假设它在命令行里这样调用python predict.py --image 21.png --model best_model.pth --output result_21.png--image指定输入图像--model指定权重文件路径--output指定结果保存位置。如果包里没有预训练权重best_model.pth还不存在那就先跑一小段训练把权重生成出来。我拿到这类工程的第一习惯是先不细读代码把推理脚本跑一遍看到输入图、输出图、中间日志三者对得上说明环境没问题、代码路径是通的后面再去改结构、调参才有意义。如果这一步就报ModuleNotFoundError多半是依赖没装全如果报路径错误多半是数据目录和代码里写的不一致。跑通之后看一眼输出的分割图前景对象是不是被完整标出来了边界是不是干净如果输出一片黑后面避坑章节会专门讲这种情况。3. U-Net的结构设计与数据预处理医学图像分割的默认起点3.1 为什么是U-Net编码器-解码器结构对医学影像的优势U-Net在医学图像分割里的地位相当于ResNet在图像分类里的地位它是最稳妥的baseline。它的网络结构是一个U字形左边是编码器逐层下采样提取特征分辨率逐渐降低通道数逐渐增加右边是解码器逐层上采样恢复分辨率同时把编码器对应层的特征通过skip connection拼过来。这有两个直接好处。第一医学图像数据集普遍不大DRIVE眼底血管分割、ISIC皮肤病变分割这些公开数据集通常只有几十到几百张图U-Net的参数量相对小不容易在小数据集上过拟合。第二分割任务对边界精度要求高skip connection把高分辨率的浅层特征直接传递到解码器让网络在恢复分辨率时不丢失边缘细节这对血管、病灶这类边界模糊的目标尤其重要。在工程的源码里unet_model.py文件的核心就是两个模块一个DoubleConv做两次卷积加ReLU激活一个Down做下采样。你可以打开文件数一下里面有几个Down层一般四个下采样块对应四层编码器。如果你想加深网络就在编码器和解码器里各加一个块同时把每层的通道数按倍数调整。3.2 数据加载与预处理医学图像的尺寸和灰度通道医学图像分割训练时数据加载是第一个容易翻车的环节。自然图像数据集每张图大小不一ImageNet可以直接缩放但医学图像分割对尺寸比较敏感训练时最好统一到固定尺寸常见做法是padding到256x256或者缩放后裁剪到256x256。另一个坑是通道数很多医学图像是单通道灰度图但PyTorch模型的第一层卷积通常按3通道输入所以加载时要用cv2.IMREAD_GRAYSCALE读图再unsqueeze成单通道或者直接复制成三通道。class MedicalDataset(Dataset): def __init__(self, image_dir, mask_dir, img_size256, augmentNone): self.image_paths sorted(glob.glob(os.path.join(image_dir, *.png))) self.mask_paths sorted(glob.glob(os.path.join(mask_dir, *.png))) self.img_size img_size self.augment augment def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img cv2.imread(self.image_paths[idx], cv2.IMREAD_GRAYSCALE) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (self.img_size, self.img_size), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (self.img_size, self.img_size), interpolationcv2.INTER_NEAREST) img img.astype(np.float32) / 255.0 mask mask.astype(np.float32) / 255.0 img torch.from_numpy(img).unsqueeze(0) mask torch.from_numpy(mask).unsqueeze(0) return img, mask这段代码最值得注意的是两处。第一mask的resize插值方式用了cv2.INTER_NEAREST也就是最近邻插值。mask是分割标签像素值是类别编号如果用线性插值会在类别边界产生中间值把标签弄脏。第二图像归一化到了0-1范围这对U-Net训练的稳定性很重要不做归一化直接输入0-255的图像卷积输出的特征值会偏大梯度更新容易震荡。3.3 数据增强翻转让小数据集变成两倍以上医学图像分割的数据增强和图像分类有个核心区别对图像做的任何几何变换必须对mask做完全相同的变换。如果图像随机翻转了mask不跟着翻模型学到的就和标签对不上。def augment_pair(img, mask): if np.random.rand() 0.5: img np.flip(img, axis1) mask np.flip(mask, axis1) if np.random.rand() 0.5: img np.rot90(img, knp.random.randint(1, 4)) mask np.rot90(mask, knp.random.randint(1, 4)) return img.copy(), mask.copy()这段代码做了两类操作水平翻转和90度旋转。np.flip(img, axis1)对图像做水平镜像np.rot90做90度整数倍旋转。注意翻转和旋转作用在图像的同一个轴上mask的位置信息才保持一致。我在实际项目里还会加cv2的随机亮度扰动和少量高斯噪声。亮度扰动可以模拟不同设备采集的明暗差异噪声可以让模型对低质量图像更鲁棒。但有一个约束这些像素级增强不能让mask跟着变因为mask是标签亮度变化不影响类别归属。灰度医学图像尤其注意别做太多对比度扭曲过度增强会让模型在原始图像上反而掉点。关于数据集划分医学图像分割常用做法是按患者划分而不是按图像划分防止同一个患者的切片同时出现在训练集和验证集里造成数据泄露。如果你的数据集每张图都是独立样本随机划分80%训练、10%验证、10%测试就行。验证集要留一整份不要用数据增强用来观察真实泛化能力。4. 核心训练链路损失函数、评估指标与推理参数设置4.1 损失函数选型Dice Loss与BCE的组合U-Net训练最经典的损失函数是Dice Loss它直接优化分割结果和标签的重叠度。Dice系数的公式是2*|X∩Y|/(|X||Y|)取值范围0到1越大表示重叠越多。把它转成损失就是1 - Dice。实际工程里我更推荐Dice Loss和BCE按比例叠加。原因很简单Dice Loss在正负样本极不平衡时容易导致训练不稳定尤其当预测全为0时梯度计算会出现分母为0的情况BCE能提供一个稳定的梯度信号两者加上去比单独用哪个都稳。class DiceBCELoss(nn.Module): def __init__(self, weight0.5): super().__init__() self.weight weight self.bce nn.BCEWithLogitsLoss() def forward(self, pred, target): pred_prob torch.sigmoid(pred) bce_loss self.bce(pred, target) smooth 1e-6 intersection (pred_prob * target).sum() dice_coeff (2.0 * intersection smooth) / (pred_prob.sum() target.sum() smooth) dice_loss 1 - dice_coeff return self.weight * bce_loss (1 - self.weight) * dice_lossBCEWithLogitsLoss把sigmoid和BCE合并计算数值上更稳定避免先sigmoid再BCE可能出现的log(0)问题。smooth这一项是平滑项防止分母为0在训练初期预测很差时特别有用。weight控制两个损失的比重我一般设0.5如果发现loss曲线震荡就调成0.3对Dice Loss降权。优化器我用Adam初始学习率1e-4配合CosineAnnealingLR做学习率衰减。Adam对初始学习率比较敏感1e-4是比较保守的默认值如果loss下降太慢可以试2e-4但超过5e-4容易在训练初期就发散。如果你用SGD需要把学习率调到1e-2附近还需要手动设置momentum所以我还是推荐Adam。4.2 训练循环与Early Stopping给训练设置止损线训练循环的写法比较固定但有几个细节会影响成败。一种是每个epoch结束后要在验证集上算指标不能只关心训练loss二是一定要保存验证集上表现最好的模型而不是最后一个epoch的模型。best_dice 0.0 patience 20 bad_epochs 0 for epoch in range(epochs): model.train() train_loss 0.0 for imgs, masks in train_loader: imgs, masks imgs.to(device), masks.to(device) preds model(imgs) loss criterion(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() * imgs.size(0) valid_dice evaluate(model, valid_loader, device) train_loss train_loss / len(train_loader.dataset) print(fEpoch {epoch1}/{epochs} | Loss {train_loss:.4f} | Val Dice {valid_dice:.4f}) if valid_dice best_dice: best_dice valid_dice torch.save(model.state_dict(), best_model.pth) bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: print(Early stopping at epoch, epoch1) breakbest_dice记录验证集上出现过的最优Dice值只有验证Dice超过历史最优才保存权重这个设计保证了你最后拿到的模型是泛化最好的那版。patience是早停的容忍度连续20个epoch没有超过历史最优就停止训练省时间也防止后期过拟合。注意torch.save只保存了state_dict没有保存整个模型对象加载时要先用和训练时一样的模型结构创建实例再load_state_dict加载权重。训练时还要在脚本开头固定随机种子否则每次跑出来的结果都不一样复现实验时会很被动。PyTorch里这样设置torch.manual_seed(42) np.random.seed(42)固定种子对答辩尤其重要导师问你这个结果是不是随机的好结果时你至少能说明实验是可复现的。4.3 评估指标Dice、IoU和像素准确率怎么算医学图像分割的评估指标和图像分类不同不能只看准确率。一个数据集里如果背景占了95%模型把所有像素都预测成背景准确率是95%但分割任务完全失败。所以医学分割的论文几乎都用Dice和IoU来评估。def calculate_metrics(pred_mask, true_mask): pred_mask pred_mask 0.5 true_mask true_mask 0.5 intersection (pred_mask true_mask).sum() union (pred_mask | true_mask).sum() iou intersection / (union 1e-6) dice 2 * intersection / (pred_mask.sum() true_mask.sum() 1e-6) return iou.item(), dice.item()pred_mask 0.5是把模型的概率输出转成0/1二值mask阈值默认0.5。在实际病灶分割项目里如果目标是血管或者细小病灶0.5的阈值可能会让细小的目标被滤掉可以降低到0.3或0.4这会在后续避坑章节展开。intersection是预测和标签的交集像素数union是并集这两个指标对类别不平衡不敏感能真实反映分割质量。4.4 推理与可视化测试图的分割结果保存逻辑训练完成后用测试图走一遍推理把模型输出可视化保存下来。def predict_single(model, image_path, device, save_path): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) h, w img.shape img_resized cv2.resize(img, (256, 256)) input_tensor torch.from_numpy(img_resized).unsqueeze(0).unsqueeze(0).float().to(device) / 255.0 with torch.no_grad(): pred torch.sigmoid(model(input_tensor)).cpu().numpy().squeeze() pred_mask (pred 0.5).astype(np.uint8) * 255 pred_mask cv2.resize(pred_mask, (w, h), interpolationcv2.INTER_NEAREST) cv2.imwrite(save_path, pred_mask)这段代码的核心是最后一行的resize方式又用了cv2.INTER_NEAREST。原因和训练时一样分割结果是类别标签不是灰度图最近邻插值不会在边界产生过渡像素。torch.no_grad()告诉PyTorch这段推理不需要计算梯度能省显存和计算时间。把原图和预测图并排保存在一起对答辩展示和论文配图都很方便你可以用np.hstack([orig, pred])把两张图拼起来再保存。5. 医学图像分割的避坑清单训练阶段与答辩阶段的四个翻车现场5.1 训练阶段的三类经典翻车loss不降、预测全黑、显存爆炸先说loss不降或者收敛特别慢。现象训练10个epoch后loss还在0.6以上波动验证Dice始终在0.1以下。原因最常见的是学习率设得太高导致梯度震荡或者mask标签和图像没有对齐。解决把Adam学习率从默认的1e-3降到1e-4同时检查数据加载代码里_mask的resize是否用了INTER_NEAREST标签一旦被插值污染loss永远降不到理想值。其次说预测全黑或全白。现象训练时loss曲线正常Dice也不低但用测试图推理出来是全黑图像或者一片乱码的白噪点。原因预测时忘了做torch.sigmoid模型输出的logits被当成0/1直接二值化数值范围完全不对。另一类原因是归一化不一致训练时图像除以了255推理时没有做同样的归一化输入分布偏移导致输出异常。解决在predict脚本里检查model(input_tensor)后有没有过一层sigmoid然后打印一下预测值的范围正常应该在0到1之间。最后说显存爆炸。现象训练刚开始报CUDA out of memory或者batch size设了16跑几分钟就崩了。原因医学图像分割常用256x256输入输入尺寸越大显存占用指数上升。解决先把batch size降到4或2如果还爆就是输入尺寸的问题把img_size从256改成192或者160。我的经验是256x256、batch size 8、单卡10G显存能稳定训练如果你的卡是6G就把batch降到4。还有一个隐蔽的坑是数据集中mask灰度值不是0和255而是0和1。现象训练loss正常但Dice指标一直在0.9左右上不去。原因mask标签文件中前景的像素值是1而非255模型预测在0-1的范围内用sigmoid输出阈值0.5能正常分类评估时如果直接把mask和预测相减当误差数值看起来就不对。解决加载mask后统一做一次mask (mask 127).astype(np.float32)把任意灰度值都转成标准的0/1标签。5.2 验收阶段的坑给答辩老师一个能复现的结局如果你是用这套系统做毕业设计光把代码跑通不够还要防住验收时的两个问题。第一个问题是评审老师现场要跑一遍。没有GPU的笔记本跑100个epoch要几个小时现场等待就是灾难。我的建议是训练时顺便保存一个只训了20个epoch的quick_model.pth单独写一个predict_demo.py加载它用测试图跑推理几秒出结果。给老师演示时用快速模型答辩材料里放完整训练的结果图这样现场效果和论文指标两不误。第二个问题问的是“你改了哪里”。直接拿原版U-Net跑出来的结果在答辩时很容易被问“工作量在哪”。常见做法是至少做一个可视化改进比如把原图、真实标签、预测mask、叠加轮廓图四张图拼成一张对比图输出。这个功能实现起来也就十几行代码但展示效果完全不同老师一眼能看到验证集上的预测mask是否贴合真实边界。overlay cv2.addWeighted(orig, 0.7, mask_color, 0.3, 0)上面这行用addWeighted把半透明的预测mask叠加到原图上边界处有颜色偏差的地方就能直接看出来。配合高质量的对比图答辩时项目完整度会提升一个档次。6. 迁移到自己的数据集与模型轻量化让这套U-Net在毕设里做出增量6.1 用Labelme标注数据并转成U-Net可用的mask如果你不想直接用包里自带的数据集想针对某个器官或病灶做自己的分割最省事的路径是Labelme标注 一个转换脚本。Labelme画的多边形保存成json文件你需要把json转成和原图同尺寸的灰度mask。import json, cv2, numpy as np with open(label.json, r) as f: data json.load(f) mask np.zeros((data[imageHeight], data[imageWidth]), dtypenp.uint8) for shape in data[shapes]: points np.array(shape[points], dtypenp.int32) cv2.fillPoly(mask, [points], 255) cv2.imwrite(label_mask.png, mask)这段代码遍历json里的每个多边形用cv2.fillPoly在mask上填充白色前景就是255、背景0。如果你的数据集有多个类别就把255改成类别编号多类分割时改模型输出层即可。转换之后进入第3章的数据加载流程尺寸和归一化逻辑都不用改。6.2 在传入输出层之间做轻量升级U-Net到UNet的改动点如果课题要求比你现有的U-Net再提高一点分割精度性价比最高的改动是换成UNet。UNet在编码器和解码器之间加了密集的skip connection解码器的每一层不仅接收编码器的对应层特征还接收来自前面解码层的输出。实现上的改动集中在模型前向传播部分每个解码层的输入从“编码器特征”改成“编码器特征 前面所有解码层输出的拼接”其他训练代码、损失函数完全不用动。还有一个更轻量的方案只改输入分辨率。把原来256x256改成384x384输入大多数分割模型都能直接涨几个点的Dice代价是显存占用增加。这个改动甚至不需要动模型文件。我在自己的实验里试过同样的模型、同样的数据384输入通常比256输入Dice高2到3个点适合数据量够的场景数据少时大分辨率反而容易过拟合。至于模型的部署和导出如果毕设要求做一个可以演示的界面把PyTorch模型导出成ONNX再接入推理服务即可python -c import torch; mtorch.load(best_model.pth); torch.onnx.export(m, torch.randn(1,1,256,256), unet.onnx)从拿到这套U-Net工程到我第一次把它的预测mask做成视频演示中间翻过最狠的车就是标签resize用了线性插值导致全白。从那以后我每次拿到新数据集都强制走一遍“读图—看mask灰度值—打印shape”三件套确认数据链路没问题才开训。这个习惯帮我省了至少十次白跑的调参。希望帮到你。本文还有配套的精品资源点击获取