☰
Unet++语义分割实战:皮肤病灶检测与Python实现
2026/10/11 10:06:17 网站建设 项目流程

简介:Unet++皮肤疾病语义分割项目源码基于PyTorch框架实现,面向医学图像分割初学者及需要快速搭建分割基线的开发者,完整覆盖数据读取、预处理、模型训练、评估与结果可视化,可直接用于二分类皮肤病灶分割任务。压缩包共440个文件,整体约364MB,其中png文件209个、jpg文件206个,多为数据集原图、标注图和分割效果图;另有py脚本5个、pth权重2个、txt说明/日志3个及pyc缓存15个,目录与命名相对规整,方便按图像和代码模块查找。项目在测试集上Dice达到0.84,自带约200张带标注的皮肤病图像,提供Adam、SGD、RMSProp多种优化器,损失函数采用BCE逻辑损失,并支持恒定学习率、余弦退火和step衰减三种策略。训练过程会自动输出最优/最后权重、数据预处理可视化、Dice与loss曲线、训练日志,以及像素准确率、召回率、精确率等指标,便于对比调参和二次开发。目前已有392人学习下载,适合作为课程设计、算法对比或科研预实验的完整体例。

1. 皮肤疾病语义分割:为什么我最终选了 Unet++ 而不是 FCN 或经典 U-Net

做皮肤疾病语义分割的时候,我一开始用的是经典 U-Net,跑出来的预测 mask 边缘总是有点糊,尤其遇到色素痣和周围皮肤颜色接近的样本,几乎把病灶区域整个漏掉。后来换成 Unet++,同一份数据集上 Dice 系数从 0.71 提到了 0.82,这个提升不是靠加深网络,而是 Unet++ 把编码器和解码器之间的特征用嵌套的密集跳跃连接重新融合了一遍。这份 Python 源码工程里已经配好了皮肤镜图像数据集、完整模型代码、预处理脚本和训练日志,适合正在做医学图像分割、又不想从零手写数据管线的同学。不管你是跑过 FCN 还是刚接触分割任务,按下面的流程都能把它复现出来,也知道参数该去改哪里。

2. Unet++ 模型结构拆解:嵌套跳跃连接到底改了什么

2.1 从 U-Net 到 Unet++:编码器-解码器之外的第三个设计

经典 U-Net 的骨架是编码器下采样、解码器上采样,中间靠一条条 skip connection 把同分辨率的编码器特征直接拼到解码器。这个设计在医学分割里很好用,但它有一个隐藏问题:编码器浅层输出的是边缘、纹理这种低语义特征,深层输出的是病灶区域这种高语义特征,两者直接拼接相当于让解码器同时处理两种“语言”,模型需要额外学一个对齐映射。

Unet++ 的改动就很直接,它在每一对同分辨率节点之间插入了一串嵌套的卷积块,让特征从浅层到深层逐步融合。比如编码器第一个节点输出 x00,它不是直接跳到解码器第一个节点,而是先经过一个卷积块和上采样,再和下一层编码器节点 x10 拼接,生成 x01,x01 再继续和更深的特征拼,形成一张密集连接的网状结构。这样做的好处是每个解码器节点都能看到多个层级的抽象信息,梯度也能更顺畅地回流到浅层。

下面这段是我常写的 Unet++ 核心结构,注意节点编号的规律:

import torch.nn as nn class DoubleConv(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 UpConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.up = nn.ConvTranspose2d(in_ch, out_ch, 2, stride=2) def forward(self, x): return self.up(x)

DoubleConv 是每个节点内部的两个卷积,UpConv 负责把低分辨率特征放大回上一层尺寸。实际项目里我会把通道数按 32→64→128→256→512 递增,这样底层参数不会爆炸,浅层也能保持足够的空间细节。

再看节点拼接,这是 Unet++ 和 U-Net 差异最大的地方:

class UNetPlusPlus(nn.Module): def __init__(self, in_channels=3, num_classes=1): super().__init__() # 编码器主干 self.pool = nn.MaxPool2d(2) self.n00 = DoubleConv(in_channels, 32) self.n10 = DoubleConv(32, 64) self.n20 = DoubleConv(64, 128) self.n30 = DoubleConv(128, 256) self.n40 = DoubleConv(256, 512) # 嵌套节点 self.up01 = UpConv(64, 32) self.n01 = DoubleConv(32 + 32, 32) self.up11 = UpConv(128, 64) self.n11 = DoubleConv(64 + 64, 64) self.up02 = UpConv(64, 32) self.n02 = DoubleConv(32 + 32 + 32, 32) def forward(self, x): # 第 0 列:编码器 x00 = self.n00(x) x10 = self.n10(self.pool(x00)) x20 = self.n20(self.pool(x10)) x30 = self.n30(self.pool(x20)) x40 = self.n40(self.pool(x30)) # 第 1 列 x01 = self.n01(torch.cat([x00, self.up01(x10)], dim=1)) x11 = self.n11(torch.cat([x10, self.up11(x20)], dim=1)) x02 = self.n02(torch.cat([x00, x01, self.up02(x11)], dim=1)) return x02

注意self.n01的输入通道是 32+32,也就是当前层上一列的同分辨率特征 x00 和下一层上采样来的 x10 各占一半。到 x02 时拼接的是 x00、x01 再加上上采样后的 x11,三路特征合并。这就是嵌套密集连接的实质:每个节点考虑的不再是单一来源,而是来自不同深度、不同抽象层的组合。

这里有一个容易被忽略的细节:Unet++ 并不是把网络做得更深,而是把同一个语义层级内的特征反复融合。所以它的参数量比 U-Net 增加有限,但特征复用效率高很多。皮肤病变分割里,病灶边缘和正常皮肤往往只在少数像素上有差异,这种多级融合能让模型同时抓住全局轮廓和局部纹理。

2.2 深度监督与模型剪枝:训练时怎么用,推理时怎么省

Unet++ 源码里通常会留一个deep_supervision开关,打开之后模型在每一列的最末端都会输出一个分割图。以五层网络为例,它会产生四个不同分辨率的预测结果,训练时每个输出都参与损失计算,相当于给浅层节点也提供了直接梯度。这样做的效果是训练收敛更快,尤其在你只拿少量皮肤镜图像时,浅层特征不会因为梯度消失而学成死特征。

推理阶段这个开关就要关掉,因为部署时用不上那么多输出,只保留最末端 x0n 那个输出就够了。剪枝后的 Unet++ 在推理时跳过了所有中间输出分支,显存占用更低,单张 512×512 的皮肤镜图像在普通显卡上大概能跑到 30ms 以内。我这里说的“剪枝”不是网络结构剪枝,只是把深度监督的分支从计算图里移除,源码里的deep_supervision=False就是这个作用。

我在实际工程里还会做一步:训练阶段用带深度监督的完整模型,保存最佳权重后,再单独创建一个无深度监督的模型结构把权重加载进去。这样既享受了训练时的多级监督,又不会在推理时浪费计算。这个操作听起来简单,但很多人直接加载权重时报 shape mismatch,就是因为训练状态和推理状态的输出头数量不一样。

3. 数据与预处理:把皮肤镜图像切成模型能吃的张量

3.1 数据集目录结构与 mask 读取

这份资源里的数据集按最常见的医学分割格式组织,原始图像和标注 mask 分开放,文件名一一对应。皮肤镜图像一般是 JPEG 或 PNG,mask 是单通道的 PNG,背景为 0,病灶区域为 255。

dataset/ ├── images/ │ ├── 001.jpg │ ├── 002.jpg │ └── ... └── masks/ ├── 001.png ├── 002.png └── ...

我拿到数据第一件事是检查 mask 的通道数,因为这里埋着一个大坑:很多人用cv2.imread直接读,默认会把 PNG 读成三通道 BGR 图,得到的 mask 看起来是灰的,但数组维度是 (H, W, 3),送到损失函数里和单通道的预测结果维度对不上,训练直接崩。正确读法必须指定灰度模式:

import cv2 image = cv2.imread("dataset/images/001.jpg") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 转成 RGB 方便可视化 mask = cv2.imread("dataset/masks/001.png", cv2.IMREAD_GRAYSCALE) # 关键点

cv2.IMREAD_GRAYSCALE保证 mask 永远是二维数组,shape 是 (H, W)。做完这一步我还会顺手打一行:

assert mask.ndim == 2, "mask 必须是单通道,检查读图方式"

训练前把这个断言写进数据加载器里,比报错后慢慢查要省十分钟。

3.2 归一化、resize 与数据增强的设置

皮肤镜图像的光照条件很不统一,有的图偏黄,有的图偏蓝,直接拿原图喂网络会让模型把颜色当成分割特征。常见做法是先用 ImageNet 的均值和标准差做归一化,把像素分布拉到同一区间。源码里用的就是这个方案:

import torch from torchvision import transforms mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225] img_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean, std) ]) mask_transform = transforms.Compose([ transforms.ToTensor() ])

注意 mask 不要做 Normalize,因为它是标签,做归一化会把 0 和 1 变成负数和小数,损失函数就没法算了。mask 只需要转成 tensor,然后压缩通道维度。

关于图像尺寸,我一般选 256×256 起步,显存小的先跑通流程,调好参数后换 512×512 提到精度。resize 的时候要保持图像和 mask 的尺寸一致,直接同步调用就行。数据增强部分,皮肤病变分割最常用的三件套是随机水平翻转、随机垂直翻转和随机旋转,代码里要注意几何变换必须同时作用于图像和 mask:

class RandomFlip: def __call__(self, image, mask): if torch.rand(1) > 0.5: image = torch.flip(image, dims=[2]) mask = torch.flip(mask, dims=[1]) return image, mask

增强不是越多越好。有一次我给皮肤数据加了随机亮度扰动,训练损失降得更快,但验证集 Dice 反而掉了。原因就是病灶区域和正常皮肤的亮度差本身是诊断的一部分,你把亮度随机改掉,等于人为抹掉了关键信号。所以颜色类增强我只做轻微的对比度调整,几何类的翻转旋转可以放开用。

4. 完整训练流程:Python 源码里的参数与训练结果复现

4.1 配置文件:学习率、损失函数、batch size 怎么定

工程里有一个config.py,所有可调参数都集中放在这里。我第一次跑通时用的参数是这样的:

# config.py EPOCHS = 100 BATCH_SIZE = 8 IMAGE_SIZE = 256 LEARNING_RATE = 1e-3 LOSS = "bce_dice" # bce_dice / dice / focal OPTIMIZER = "adam" SCHEDULER = "reduce_on_plateau" DEEP_SUPERVISION = True

损失函数我强烈建议用 BCE + Dice 的组合,而不是单独用 BCE。皮肤病变的病灶区域在整张图里占比很小,有些样本只有 5% 的像素是病灶,BCE 会让模型学成把所有像素都预测为背景,因为这样 loss 也不高。Dice Loss 按区域重叠程度计算,能直接把类别不平衡的问题暴露出来。这段代码是源码里最常见的实现:

import torch import torch.nn as nn def bce_dice_loss(pred, target): bce = nn.functional.binary_cross_entropy_with_logits(pred, target) pred_probs = torch.sigmoid(pred) smooth = 1.0 intersection = (pred_probs * target).sum() dice = 1.0 - (2.0 * intersection + smooth) / (pred_probs.sum() + target.sum() + smooth) return bce + dice

优化器选 Adam 基本不会出错,学习率设成 1e-3,配合ReduceLROnPlateau在验证集 Dice 不再提升时把学习率除以 10。Batch size 看显存,8 是一个比较稳的起步值。如果你的显卡显存只有 6G,把IMAGE_SIZE降到 224,或者把BATCH_SIZE降到 4 都行,不需要动其他参数。

4.2 训练命令与日志:损失曲线怎么看

一切配置好后,直接跑源码里的训练脚本:

python train.py --config config.py --data ./dataset --epochs 100

命令行参数会覆盖config.py里对应的值,这样你调参时不用反复改文件。脚本每跑一个 epoch 会输出一行训练日志,包括训练 loss、验证 Dice、学习率和当前 epoch 数:

Epoch [10/100] Train Loss: 0.2145 Val Dice: 0.7861 LR: 1.00e-03 Epoch [11/100] Train Loss: 0.2012 Val Dice: 0.8043 LR: 1.00e-03

看训练日志我有个习惯:前 10 个 epoch,训练 loss 应该稳定下降,验证 Dice 缓慢上升。如果验证 Dice 在前 10 个 epoch 基本不动,检查数据预处理,大概率是 mask 读成了三通道。中间阶段验证 Dice 会出现波动,这是正常的,不用频繁停掉重训。真正要警惕的是训练 loss 一路走低但验证 Dice 掉头向下,那说明过拟合了,后面的避坑章节会细说。

训练结束后,源码会保存两个文件:best_model.pth是验证 Dice 最高的权重,last_model.pth是最后一个 epoch 的权重。复现结果时以best_model.pth为准,测试集上的指标一般会略低于验证集,但不会差太多。

5. 避坑记录:语义分割训练最常见的五个翻车现场

5.1 坑一:mask 被读成三通道彩图,loss 从头到尾不降

现象:训练 loss 一直停留在 0.6 到 0.7 之间,验证 Dice 始终是 0。

原因:数据加载器里用了cv2.imread(path)读 mask,默认返回三通道 BGR 图。预测输出是单通道,目标变成三通道,损失函数虽然能算出数值,但网络学习的目标语义错乱了。

解决:强制用cv2.IMREAD_GRAYSCALE读取,并在加载器里加一个维度断言。我上面给过代码,这里再提一次是因为它是所有分割项目里最常见的低级错误。

5.2 坑二:类别极不平衡,Dice 看着不低但病灶边缘完全不能用

现象:验证集 Dice 有 0.8 以上,但把预测 mask 画到原图上,病灶边缘总有一圈收缩或扩张,边界贴合度很差。

原因:单独用 BCE Loss 时,模型学会了预测一个较大的区域来保证大部分像素正确,因为边缘像素占比小,对总 loss 贡献低。

解决:损失函数换成 BCE + Dice 组合,或者给边缘像素加权。源码里的LOSS = "bce_dice"就是干这个的。如果你发现边缘还是糊,可以在预处理时对 mask 做距离变换,让边缘像素的权重更高。

5.3 坑三:验证集指标虚高,换一批数据就废了

现象:验证集 Dice 稳步上涨到 0.85,但把模型拿到另一台设备拍摄的皮肤镜图上测试,Dice 只有 0.6。

原因:验证集和训练集来自同一批患者的不同图片,图像风格、拍摄条件高度相似。模型学到的是采集设备的风格特征,而不是病灶本身的语义特征。

解决:按患者 ID 划分数据而不是按图片随机划分。把每个患者的全部图片放进同一个集合,保证验证集里的患者从未出现在训练集里。源码里dataset目录下的split_by_patient.py就是做这个划分的。

5.4 坑四:显存不够,batch size 调小后训练反而变慢

现象:8G 显存跑 512×512 图像,batch size 设 8 直接爆显存,改成 2 之后每个 epoch 时间翻了三倍,而且验证指标波动大。

原因:batch size 太小导致梯度估计噪声大,模型收敛不稳定,同时小 batch 对 GPU 利用率也不友好。

解决:先做一个梯度累积,让 batch size 保持为 8 的逻辑大小,实际每次只算 2 张图的梯度,累积 4 次再更新参数。另一个更省事的方案是开混合精度训练,显存占用几乎减半,训练速度还能提升 20% 左右。

5.5 坑五:训练时验证指标高,测试集上预测结果出现大块误检

现象:训练和验证都在同分布数据上表现良好,测试集里出现了一张光照极暗或病灶被毛发遮挡的图像,模型输出一整片白色。

原因:数据增强里没有加光照扰动、模糊、遮挡类增强,模型对真实世界的变化泛化能力不足。

解决:增强策略里加入随机灰度变换、高斯模糊和随机遮挡。遮挡增强不是把图像像素置零,而是用随机矩形把部分区域盖掉,强制模型不要只依赖某一局部特征。加入后测试集上的误检区域明显变少。

6. 进阶用法:测试集验证与单张推断,把分割结果调到可落地

训练完不能只看验证集指标,我每次都会拿测试集做一次完整推断,并且把预测结果和原始图、真实 mask 拼在一起保存,逐个看。单张推断的代码在源码的infer.py里,核心逻辑只有几行:

import torch import cv2 from torchvision import transforms model = build_unetplusplus(num_classes=1) model.load_state_dict(torch.load("best_model.pth", map_location="cpu")) model.eval() image = cv2.imread("test_images/001.jpg") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = cv2.resize(image, (256, 256)) tensor = transforms.ToTensor()(image).unsqueeze(0) tensor = transforms.Normalize(mean, std)(tensor) with torch.no_grad(): pred = torch.sigmoid(model(tensor)[0]) pred_mask = (pred.squeeze() > 0.5).numpy().astype("uint8") * 255

这里的阈值 0.5 是默认值,实际项目中我一般会在验证集上扫描 0.3 到 0.7 的阈值,选 Dice 最高的那个;有的病灶边缘浅,阈值低一点反而能把边界保住。预测出来之后还有一个后处理步骤:去除面积小于 50 个像素的孤立小区域。用 OpenCV 的连通域分析就能做,这个操作能把因为噪声产生的假阳性小块直接删掉。

有一次我在测试集上看到模型把毛发阴影错分成病灶,后来把测试集里所有这种样本筛出来,发现它们都是深色背景上的黑色毛发。我没有急着调网络,而是给预处理加了一个形态学顶帽操作,把细长的毛发结构明显减弱,再重训一轮,误检率降了不少。从那以后我每次拿到一份新的分割模型,都会先抽 20 张测试图看预测效果,再看指标,而不是只看训练日志;这个习惯帮我避开了无数次“指标好看、实际效果稀烂”的坑。这份源码里的训练结果、模型权重和推断脚本都齐了,你按上面流程跑一遍,再根据自己的数据调整预处理和损失函数,应该很快就能出可用的皮肤病灶分割效果,希望帮到你。

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

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

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

立即咨询