简介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 拼接生成 x01x01 再继续和更深的特征拼形成一张密集连接的网状结构。这样做的好处是每个解码器节点都能看到多个层级的抽象信息梯度也能更顺畅地回流到浅层。下面这段是我常写的 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, 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): 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, stride2) 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_channels3, num_classes1): 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)], dim1)) x11 self.n11(torch.cat([x10, self.up11(x20)], dim1)) x02 self.n02(torch.cat([x00, x01, self.up02(x11)], dim1)) return x02注意self.n01的输入通道是 3232也就是当前层上一列的同分辨率特征 x00 和下一层上采样来的 x10 各占一半。到 x02 时拼接的是 x00、x01 再加上上采样后的 x11三路特征合并。这就是嵌套密集连接的实质每个节点考虑的不再是单一来源而是来自不同深度、不同抽象层的组合。这里有一个容易被忽略的细节Unet 并不是把网络做得更深而是把同一个语义层级内的特征反复融合。所以它的参数量比 U-Net 增加有限但特征复用效率高很多。皮肤病变分割里病灶边缘和正常皮肤往往只在少数像素上有差异这种多级融合能让模型同时抓住全局轮廓和局部纹理。2.2 深度监督与模型剪枝训练时怎么用推理时怎么省Unet 源码里通常会留一个deep_supervision开关打开之后模型在每一列的最末端都会输出一个分割图。以五层网络为例它会产生四个不同分辨率的预测结果训练时每个输出都参与损失计算相当于给浅层节点也提供了直接梯度。这样做的效果是训练收敛更快尤其在你只拿少量皮肤镜图像时浅层特征不会因为梯度消失而学成死特征。推理阶段这个开关就要关掉因为部署时用不上那么多输出只保留最末端 x0n 那个输出就够了。剪枝后的 Unet 在推理时跳过了所有中间输出分支显存占用更低单张 512×512 的皮肤镜图像在普通显卡上大概能跑到 30ms 以内。我这里说的“剪枝”不是网络结构剪枝只是把深度监督的分支从计算图里移除源码里的deep_supervisionFalse就是这个作用。我在实际工程里还会做一步训练阶段用带深度监督的完整模型保存最佳权重后再单独创建一个无深度监督的模型结构把权重加载进去。这样既享受了训练时的多级监督又不会在推理时浪费计算。这个操作听起来简单但很多人直接加载权重时报 shape mismatch就是因为训练状态和推理状态的输出头数量不一样。3. 数据与预处理把皮肤镜图像切成模型能吃的张量3.1 数据集目录结构与 mask 读取这份资源里的数据集按最常见的医学分割格式组织原始图像和标注 mask 分开放文件名一一对应。皮肤镜图像一般是 JPEG 或 PNGmask 是单通道的 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 的尺寸一致直接同步调用就行。数据增强部分皮肤病变分割最常用的三件套是随机水平翻转、随机垂直翻转和随机旋转代码里要注意几何变换必须同时作用于图像和 maskclass 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_classes1) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) 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 张测试图看预测效果再看指标而不是只看训练日志这个习惯帮我避开了无数次“指标好看、实际效果稀烂”的坑。这份源码里的训练结果、模型权重和推断脚本都齐了你按上面流程跑一遍再根据自己的数据调整预处理和损失函数应该很快就能出可用的皮肤病灶分割效果希望帮到你。本文还有配套的精品资源点击获取