基于Unet的医学影像分割系统:从labelme标注到mIoU评估全流程
2026/9/23 9:48:57 网站建设 项目流程

简介:面向医学影像分割任务,这份基于Unet的Python实现提供了从数据预处理到模型训练、预测、评估的完整流程,并配有UI界面与分割结果可视化,适合作为毕业设计、课程设计或深度学习入门的参考项目,尤其适合医学图像方向的学生快速搭建自己的分割系统。资源内所有代码均已调试运行通过,答辩评审平均分达96分,配套文档与安装教程齐全,便于快速上手。压缩包共76个文件,核心为13个Python脚本,涵盖Unet模型结构、数据加载、训练、测试和界面展示;另有json标注、jpg/png图片、csv评估指标、PDF原文、说明文档以及安装配置说明等,整体仅4.61MB,轻量易部署。目前已有217人学习下载。借助包内Precision、mPA、Recall评估图表可直接观察模型效果;同时代码结构清晰,model目录下的unet_parts与unet_model便于逐层理解网络实现,可通过修改数据路径和参数快速迁移到其他分割场景,或在此基础上扩展为新的毕设项目。无论是学习Unet原理还是工程落地,都能从中获得完整参考。

1. 基于Unet的医学影像分割系统:这份源码包到底能解决什么

医学影像分割里,Unet是绕不开的基线模型,尤其在皮肤病变、眼底血管这类单器官分割任务中,跑通一套完整python源码比看十篇论文都管用。这个基于Unet的医学影像分割系统源码包,把标注转换、模型训练、指标评估、单张预测和可视化界面全链路串了起来,还自带ISIC数据集样例图、训练好的模型权重和Unet原始论文pdf。跟着它的文档说明和安装教程,你能把流程从labelme标注一路跑到mIoU指标导出。适合正在做毕设、课程设计,或者想以Unet为基础做图像分割实验的在校学生和入门工程师。需要注意的是,作者在README.md里说明了运行环境依赖,下载解压后第一步就是读它,而不是急着跑train.py。

2. 数据前处理链路:labelme标注转分割掩码,从JSON到PNG的像素级对齐

解压后先不要急着装环境,按调用顺序把关键文件过一遍,基本上能看出整个项目的数据流。核心文件对应关系如下:

文件作用
dataset.py训练数据读取、resize、归一化、mask加载
model/unet_parts.pyUnet基础模块(DoubleConv、Down、Up)
model/unet_model.py完整Unet网络定义
train.py训练入口,输出模型权重
predict.py加载模型对单张图预测分割结果
utils_metrics.py混淆矩阵与mIoU、mPA等指标计算
gen_split.py训练/验证/测试数据集划分
labelme2seg.py / label2png.pylabelme标注json转分割掩码

这里最容易被忽略的是数据前处理部分。很多人换到自己数据集后训练不起来,问题就出在掩码格式和id映射上。

2.1 环境准备:依赖安装顺序与Python版本避雷

requirements.txt列了这套代码需要的python包,通常包括torch、torchvision、opencv-python、numpy、Pillow、matplotlib、labelme、tqdm这类常用依赖。最容易翻车的是torch版本和python版本不匹配,常见做法是先建虚拟环境、装好torch,再装其余依赖,顺序反了容易出现装完opencv后import报错找不到lib的问题。

# 1. 创建虚拟环境,python版本选3.8-3.10比较稳 conda create -n unet python=3.9 -y conda activate unet # 2. 安装 torch,按自己显卡cuda版本从官网复制对应命令 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 3. 安装项目其他依赖 pip install -r requirements.txt

conda那步如果机器上没装conda,可以用python -m venv unet_env代替。第2步务必单独执行,因为PyTorch的wheel包很大,和后续依赖混在一起装出错时不好定位是哪个包导致的环境冲突。下载慢的话,源码包里的"切换镜像.txt"已经写好了国内pip源配置方式,直接执行里面的pip config set命令就行。

2.2 labelme2seg.py与label2png.py:把标注多边形变成像素级掩码

labelme标注生成的json文件,里面是一组组多边形顶点坐标和对应的标签名。训练时网络要的是和原图尺寸一致的像素级掩码,所以必须先把json里的多边形填充成图像。labelme2seg.py和label2png.py在不同项目里的分工略有差别,我见过的做法通常是:labelme2seg.py把json转成彩色分割png,label2png.py再把彩色png压缩成0到n_classes-1的单通道灰度掩码供训练。

import json import numpy as np import cv2 def labelme_json_to_mask(json_path, height, width, label_map): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) mask = np.zeros((height, width), dtype=np.uint8) for shape in data['shapes']: label = shape['label'] # 比如 'lesion' class_id = label_map.get(label, 0) # 映射成 0、1、2... pts = np.array(shape['points'], dtype=np.int32) cv2.fillPoly(mask, [pts], class_id) # 多边形填充 return mask

这段逻辑的关键是cv2.fillPoly,它按多边形顶点一次性填充整个区域,比逐像素遍历快几个数量级。label_map字典必须和dataset.py里的类别定义完全一致,比如背景是0、病变是1,两边对不上训练时指标会全部归零。height和width必须取原图的真实尺寸,否则掩码和原图发生像素级错位,训练出来就是一张错位图。

ISIC数据集本身已经带有官方掩码,不需要重新标注,这部分对入门用户来说可以先跳过。但如果你要训练自己的数据,这条链路是必须走通的,项目里images、testdata、jsons这几类目录的存在也说明作者保留了完整的标注测试数据。

2.3 gen_split.py:数据集划分与类别不平衡的直接影响

gen_split.py做的事是把配好对的图片和掩码按比例拆成训练集、验证集、测试集。划分看起来简单,但医学影像分割里这里有个隐藏的坑:如果是CT、MRI这种来自同一病人的连续切片数据,必须按病人维度切分,不能随机打散切片,否则同一病人的相似切面会同时出现在训练集和验证集里,造成数据泄漏,验证集指标虚高。

import os import random images_dir = 'images' masks_dir = 'masks' # 只保留既有原图又有掩码的样本 items = [f[:-4] for f in os.listdir(images_dir) if os.path.exists(os.path.join(masks_dir, f[:-4] + '.png'))] random.seed(42) # 固定随机种子保证可复现 random.shuffle(items) n_train = int(len(items) * 0.7) n_val = int(len(items) * 0.15) train_items = items[:n_train] val_items = items[n_train:n_train + n_val] test_items = items[n_train + n_val:]

ISIC数据集每张皮肤镜图像对应一个独立病人,随机划分问题不大。换成其他医学数据时,我一般会先确认数据是否来自同一患者的多张切片,是的话就改按病人ID分组再切分。random.seed(42)这行不要删,它是复现训练结果的后悔药,删了之后每次划分的样本不同,训练指标不具备可比性。

3. 模型构建与训练验证:unet_model.py、train.py、utils_metrics.py三条主线

3.1 unet_parts.py与unet_model.py:编码器-解码器结构与跳连的前向逻辑

Unet之所以适合医学影像,核心就在编码器-解码器加跳连的结构。编码器逐层下采样,感受野变大,能捕捉语义信息;解码器逐层上采样,恢复分辨率;跳连把编码器每层的高分辨率特征拼到解码器对应层,让细节信息不丢失。皮肤病变边缘往往不规则,跳连正是保住边缘细节的关键。

unet_parts.py里包含了DoubleConv、Down、Up、OutConv这些基础组件,把结构组装起来就是unet_model.py里的UNet类:

import torch import torch.nn as nn class UNet(nn.Module): def __init__(self, n_channels, n_classes, bilinear=True): super(UNet, self).__init__() self.inc = DoubleConv(n_channels, 64) self.down1 = Down(64, 128) self.down2 = Down(128, 256) self.down3 = Down(256, 512) self.up1 = Up(512, 256, bilinear) self.up2 = Up(256, 128, bilinear) self.up3 = Up(128, 64, bilinear) self.outc = OutConv(64, n_classes) def forward(self, x): x1 = self.inc(x) # 高分辨率特征,供跳连使用 x2 = self.down1(x1) x3 = self.down2(x2) x4 = self.down3(x3) x = self.up1(x4, x3) # 上采样结果与编码器特征拼接 x = self.up2(x, x2) x = self.up3(x, x1) return self.outc(x)

n_channels是输入图片通道数,普通RGB图就是3,灰度图就是1。n_classes是分割类别数,ISIC二分割任务背景加病变就是2。bilinear=True时上采样用双线性插值,不引入额外参数,训练更稳定;改成False会换成可学习的转置卷积,表达能力强一点但参数增多,小数据集上容易过拟合。这个项目在ISIC样例数据上的效果验证了经典Unet在二类分割任务里的稳定性,先跑通这个结构,后面再谈改进。

3.2 train.py训练流程:损失函数、优化器、批次大小对收敛的影响

train.py是训练入口,整体是PyTorch标准训练循环。ISIC二分类任务里,损失函数一般用BCEWithLogitsLoss,它内部已经集成了sigmoid操作,所以模型输出的是logits而不是概率值,这一点别和输入到损失函数的标签搞混。

import torch import torch.nn as nn from torch.utils.data import DataLoader from dataset import ISICDataset from model.unet_model import UNet model = UNet(n_channels=3, n_classes=2).cuda() criterion = nn.BCEWithLogitsLoss() # 二分类分割,内部自带sigmoid optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loader = DataLoader(dataset, batch_size=4, shuffle=True, num_workers=2) for epoch in range(20): for images, masks in loader: images = images.cuda() masks = masks.cuda().float() # 掩码值域必须已经是0-1 optimizer.zero_grad() logits = model(images) # 输出没有经过sigmoid loss = criterion(logits, masks) loss.backward() optimizer.step() print(f'epoch {epoch}: loss={loss.item():.4f}')

batch_size在医学影像分割里是个需要权衡的参数。ISIC原图分辨率高,batch_size设太大容易显存溢出,太小则BN层统计量不稳定。常见做法是从2或4起步,显存充足再往上加。学习率1e-3配Adam是很多Unet项目的默认组合,如果loss震荡明显,降到1e-4比换优化器更有效。这里有个细节:masks转成float是必要的,因为BCEWithLogitsLoss期望目标值在0到1之间,整数张量会被当作多分类标签处理。

3.3 utils_metrics.py评估链路:mIoU、mPA、Recall、Precision从哪来

训练完成后,项目用utils_metrics.py做评估,输出的results目录里包含Precision.png、Recall.png、mPA.png、mIoU.png和confusion_matrix.csv。这些指标全部由混淆矩阵推导而来,对二分类任务来说,混淆矩阵记录的是每个类别下真阳、假阳、真阴、假阴的统计量。

def calculate_metrics(confusion_matrix): eps = 1e-6 num_classes = confusion_matrix.shape[0] ious = [] for c in range(num_classes): tp = confusion_matrix[c, c] fp = confusion_matrix[:, c].sum() - tp fn = confusion_matrix[c, :].sum() - tp iou = tp / (tp + fp + fn + eps) ious.append(iou) return {'mIoU': sum(ious) / num_classes}

mIoU是每个类别IoU的算术平均,mPA是每个类别像素准确率的平均,Recall和Precision则更关注正类的检出能力。在医学分割里,背景通常占图像绝大部分,如果把背景类也算进mIoU,即使病变区域分割得一般,整体mIoU仍然可能很高,这是指标里最迷惑人的地方。查看results里的Precision.png和Recall.png时,我一般会同时看病变类别的单类IoU,那个值更真实反映模型到底学到了多少病灶结构。

4. Unet实战排查:四个最容易翻车的问题与解决记录

4.1 训练loss不降、mIoU恒为0

现象:训练过程中loss从0.8缓慢下降,但验证集mIoU整个训练周期都停在0,预测出来的分割结果完全找不到目标区域。

原因:最常见是掩码值域问题。labelme转出来的掩码如果是0和255的灰度图,直接转float后送到BCEWithLogitsLoss,模型输出一直被拉向一个错误的方向,loss看似在降,实际收敛位置是错的。另一个常见原因是掩码被cv2.imread按RGB三通道读入,张量形状变成(H, W, 3)而不是(H, W),和模型输出形状对不上,损失值计算时发生了广播。

解决:在dataset.py的__getitem__里临时加两行检查,打印np.unique(mask)mask.shape。确保掩码只有0、1两个值,且形状是单通道。读取时用cv2.imread(path, cv2.IMREAD_GRAYSCALE)并除以255归一化,这一步是二分类分割的数据基础。

4.2 预测输出全黑或全白

现象:训练指标已经到0.7以上,但predict.py对单张图预测后,输出整个画面全黑或全白,或者病变区域完全没有标出来。

原因:典型原因是模型输出logits后没有经过sigmoid就直接按0-255范围存图,logits里很大概率是负值,被强行截断成0;反过来全白则是没有压缩的logits值远大于255,溢出被截断成255。另一个可能原因是多分类任务里用argmax时索引选错了通道。

解决:在predict.py里对模型输出先做torch.sigmoid(logits),再乘255转成uint8存图。二分类时用阈值0.5把概率图转成二值掩码,多分类时用torch.argmax(logits, dim=1)取类别索引。调试阶段把中间变量直接保存下来对比,先确认输出值域范围再谈后处理。

4.3 显存溢出

现象:batch_size设成8,训练跑两步就报CUDA out of memory,模型直接中断,换小batch_size也一样。

原因:ISIC原图往往在1024x1024以上,Unet编码器第一层就产生64通道的特征图,分辨率不降下来,中间张量体积非常惊人。这种情况不是batch_size一个参数能解决的,核心瓶颈是输入分辨率。

解决:在dataset.py的transform里先把图片resize到256x256或512x512,降低特征图尺寸。batch_size从2开始,确认稳定后再往4、8尝试。训练循环里适当加torch.cuda.empty_cache()释放碎片显存。如果是CPU训练,则优先保证resize到256,否则一个epoch就能跑十几分钟。

4.4 换自己的数据集后类别数对不齐

现象:用ISIC自带数据能正常跑通,换成自己labelme标注的两类或三类数据后,训练loss变成nan,或者验证mIoU直接归零。

原因:模型初始化时n_classes还是2,但自己的掩码里出现了2、3等更大的类别id;或者CrossEntropyLoss期望输出通道数等于类别数,但模型输出通道数对不上,导致损失计算时张量形状不匹配。label_map映射表也可能和dataset.py里的类别id不一致,同一个标签一个地方是1一个地方是2。

解决:先确认数据里到底有几类,用np.unique(mask)查实际出现的类别id最大值加1就是n_classes,修改unet_model.py构造参数。再检查label_map,确保标注名到类别id的映射在转换脚本和训练脚本里完全一致。最后把dataset.py里的类别列表打印出来人工核对一遍,这类问题靠肉眼静态检查很难发现。

5. 进阶验证技巧:先跑通最小链路,再谈调参与模型改进

很多人拿到Unet源码后第一件事就是直接跑完整训练,但完整训练跑一次在GPU上也要几十分钟到几小时,如果数据加载、掩码读取、损失函数形状有bug,整个训练就是白跑。我一般会先写一个冒烟测试脚本,用两张图、两个epoch、batch_size为1快速验证全链路通不通。

# smoke_test.py -- 最小链路验证脚本 import torch from dataset import ISICDataset model = UNet(n_channels=3, n_classes=2).cuda() criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # 只取训练集前2个样本 loader = DataLoader(ISICDataset(split='train'), batch_size=1, shuffle=False) model.train() for epoch in range(2): for i, (images, masks) in enumerate(loader): if i >= 2: break logits = model(images.cuda()) loss = criterion(logits, masks.cuda()) optimizer.zero_grad() loss.backward() optimizer.step() print(f'epoch {epoch} step {i}: loss={loss.item():.4f}') print('smoke test passed')

这个脚本跑通后,再启动全量训练,至少能排除八成低级错误。它验证的不是模型精度,而是数据读取、张量形状、损失函数、反向传播这条链路是否顺畅。如果loss在第二个epoch有明显下降,环境基本没问题。这种冒烟测试也省电,一张不带独立显卡的开发机几分钟就能跑完。

等全链路稳定后,Unet的改进方向就多了,加注意力机制、换残差连接、做深监督都是常见思路。但每次改动都要回到冒烟测试这条基线,对比改进前后在同样数据上的loss下降曲线,而不是只看最终一两个指标。我有一次换数据集后跳过冒烟测试直接全量训练,跑了一整天才发现dataset.py里掩码路径写错,mIoU恒为0,白白烧了一天电费。从那以后,我每次换数据、换模型结构都强制先跑一遍冒烟测试,把那些能提前暴露的错误都挡在前面,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询