简介:本资源面向医学图像分割方向的初学者与进阶开发者,提供一套基于Unet3+架构、融合自适应多尺度训练策略的多类别皮肤病语义分割完整方案,可用于ISIC数据集上的病灶区域识别与分割实验复现。压缩包共约2000个文件,以png与jpg图像数据为主,另含5个Python源码文件、3个txt说明及1份readme文档,整体约192.71MB,目录结构清晰,便于按数据、代码、结果分模块查阅。项目在100轮训练后表现稳定:全局准确率0.9507,平均精确率0.9387、召回率0.9397、F1与Dice均为0.9392、mIoU达0.8865,两类目标的分项指标也一并给出,可直接对照评估模型效果。已有515人学习下载,适合希望快速上手医学分割、验证多尺度训练收益或作为课程与科研基线参考的读者,小白依照readme即可运行。
1. ISIC 皮肤病分割:Unet3+ 加自适应多尺度训练到底解决了什么
皮肤镜图像里,黑色素瘤和痣的边界经常糊在一起,低对比度、毛发遮挡、光照不均三座大山压着,单尺度模型要么把边缘切得太糙,要么把小病灶直接漏掉。ISIC 皮肤病语义分割这个任务,本质是逐像素判断「这块皮到底是病灶还是正常组织」,多类别分割意味着还要区分不同病变类型。Unet3+ 的全尺度跳跃连接把编码器每一层的信息都接到解码器,理论上能同时抓住大轮廓和小边缘;但光有结构不够,输入分辨率固定时,小病灶在浅层特征里只剩几个像素,自适应多尺度训练就是让网络在训练过程中动态看到不同尺度的输入,逼着它学会尺度不变的特征表达。这套方案适合已经跑通过基础 Unet、想在 ISIC 数据集上把 Dice 和 IoU 往上提一截的从业者,也适合需要复现多类别分割全流程的工程师。语义分割算法这两年卷得厉害,但皮肤病这个垂直场景里,数据质量和尺度策略往往比换 backbone 更管用。
2. Unet3+ 的全尺度连接与多类别分割头怎么搭
2.1 为什么选 Unet3+ 而不是 Unet 或 Unet++
Unet 的跳跃连接只把编码器同层特征拼给解码器,浅层的高分辨率特征和深层的语义特征之间缺少跨层对话。Unet++ 用嵌套密集连接补了一部分,但每个解码节点只聚合了有限尺度的信息。Unet3+ 的做法更彻底:每个解码器节点都接收来自编码器所有尺度的特征,同时用全尺度监督让每个尺度的输出都参与损失计算。对 ISIC 这种病灶大小差异极大的数据集,这个设计直接命中痛点——大病灶靠深层语义定位,小病灶靠浅层细节兜底。
多类别分割头在 Unet3+ 里通常接在最后一个解码器输出上,输出通道数等于类别数加背景。ISIC 2018 任务三有七类皮肤病变,加上背景就是八通道。损失函数我一般用 Dice Loss 加 CrossEntropy 的组合,Dice 管类别不平衡,CE 管像素级分类精度。如果某个类别样本极少,还会给 CE 加类别权重,权重取该类像素频率的倒数再归一化。
import torch import torch.nn as nn class MultiClassDiceCE(nn.Module): def __init__(self, num_classes, class_weights=None): super().__init__() self.num_classes = num_classes # CE 负责逐像素分类,weight 处理类别不平衡 self.ce = nn.CrossEntropyLoss(weight=class_weights) self.dice_weight = 0.5 # Dice 和 CE 的平衡系数,通常 0.3~0.7 def forward(self, logits, targets): # logits: [B, C, H, W], targets: [B, H, W] 且值为 0~C-1 ce_loss = self.ce(logits, targets) probs = torch.softmax(logits, dim=1) dice_loss = 0.0 for c in range(self.num_classes): pred_c = probs[:, c] true_c = (targets == c).float() intersection = (pred_c * true_c).sum() union = pred_c.sum() + true_c.sum() dice_loss += 1 - (2 * intersection + 1e-6) / (union + 1e-6) dice_loss = dice_loss / self.num_classes return ce_loss + self.dice_weight * dice_loss这段代码里class_weights建议用训练集统计出来的像素频率倒数,但别直接取倒数,先开根号再归一化,否则极稀有类别的权重会大到让训练震荡。dice_weight从 0.5 起步,如果验证集上小类别 Dice 一直上不去,可以提到 0.7;如果整体 loss 不收敛,降到 0.3 试试。注意 Dice 计算里加了1e-6防止除零,这个平滑项在空 mask 出现时很关键。
2.2 编码器选型与预训练权重加载
Unet3+ 的编码器可以用 VGG、ResNet 或 EfficientNet。ISIC 数据量不大,从零训练容易过拟合,我一般用 ImageNet 预训练的 ResNet34 或 EfficientNet-B0。加载权重时只加载编码器部分,解码器和全尺度连接层随机初始化。如果显存够,ResNet50 的 Dice 通常比 ResNet34 高 1~2 个点,但训练时间翻倍。
import segmentation_models_pytorch as smp # 用 smp 快速搭一个 Unet3+ 风格的模型,encoder 用预训练 ResNet34 model = smp.UnetPlusPlus( encoder_name="resnet34", encoder_weights="imagenet", in_channels=3, classes=8, # ISIC 2018 任务三:7 类病变 + 背景 activation=None # 输出 logits,损失函数里做 softmax ) # 只冻结编码器前两个 stage,后面微调 for name, param in model.named_parameters(): if "encoder" in name and ("layer1" in name or "layer2" in name): param.requires_grad = False这里用smp.UnetPlusPlus是因为它实现了 Unet++ 的嵌套结构,和 Unet3+ 的全尺度连接在思路上接近,但如果你要严格复现 Unet3+ 的全尺度监督,需要自己改解码器,让每个尺度的输出都接一个辅助损失。实际项目中,我见过不少人直接用 Unet++ 加多尺度训练,效果和 Unet3+ 差不了太多,但代码改动量小很多。冻结浅层是为了防止预训练权重被小数据集带偏,等 loss 稳定后再解冻全部微调。
2.3 数据管道:ISIC 多类别 mask 的读取与增强
ISIC 2018 任务三的 mask 是灰度图,像素值 0 到 6 对应不同病变,背景是 0 还是 255 取决于具体版本。我拿到的数据里,mask 像素值 0 是背景,1 到 6 是病变类别,但有些文件用 255 表示背景,需要先做一次映射。数据增强用 albumentations,水平翻转、垂直翻转、随机旋转 90 度、颜色抖动、弹性变换。注意 mask 的插值必须用最近邻,否则类别值会被插值成小数。
import albumentations as A import cv2 import numpy as np train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.2, rotate_limit=30, p=0.5), A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1, p=0.5), A.ElasticTransform(alpha=120, sigma=120 * 0.05, p=0.3), A.Resize(256, 256), # 基础尺度,多尺度训练时会在 Dataset 里动态改 ]) def load_mask(mask_path): mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 把 255 背景映射成 0,其他值保持不变 mask = np.where(mask == 255, 0, mask) return mask.astype(np.int64)ElasticTransform对皮肤病灶的形变模拟很有效,但 alpha 和 sigma 别设太大,否则病灶结构会被扭曲得不像皮肤。Resize这里固定 256 只是占位,真正的多尺度在 Dataset 的__getitem__里根据当前 epoch 或 iteration 动态选择 224、256、320、384 中的一个。颜色抖动幅度要控制,皮肤镜图像的颜色是重要诊断依据,hue 超过 0.1 可能把棕色病灶变成红色,反而有害。
3. 自适应多尺度训练:策略、实现与参数调优
3.1 多尺度训练为什么能提点:从感受野和尺度不变性说起
固定分辨率训练时,网络只见过一种尺度的病灶。ISIC 数据里,黑色素瘤可能占半张图,也可能只有几十个像素。多尺度训练让同一个 batch 或不同 iteration 看到不同缩放比例的输入,网络被迫学习尺度不变的特征。更深层的原因是,卷积核的感受野是固定的,输入缩放后,同样的卷积核覆盖的物理范围变了,相当于隐式地做了多尺度特征融合。自适应则是指缩放比例不是随机均匀采样,而是根据当前训练状态动态调整——比如验证集上小病灶 Dice 低,就多采样小尺度输入。
常见做法有三种:随机尺度(每个 iteration 随机选一个尺度)、尺度池化(把多个尺度的特征图池化到一起)、以及本方案用的动态尺度调度。随机尺度实现最简单,但训练后期可能因为尺度抖动太大导致 loss 震荡。动态调度会在训练前期用大尺度快速收敛,后期加入小尺度精细调优。
3.2 用 PyTorch Dataset 实现动态尺度采样
核心思路是在 Dataset 里维护一个尺度列表和对应的采样权重,每个 epoch 或每 N 个 iteration 更新一次权重。权重更新依据是上一个验证周期里各尺度对应的 Dice 表现——但这样需要额外验证开销。更轻量的做法是用一个基于训练进度的调度:前 30% epoch 只用 256 和 320,中间 40% 加入 224 和 384,最后 30% 全部尺度等概率。
import random import math class MultiScaleISICDataset(torch.utils.data.Dataset): def __init__(self, image_paths, mask_paths, base_size=256, scales=None): self.image_paths = image_paths self.mask_paths = mask_paths self.base_size = base_size # 尺度列表:相对于 base_size 的缩放比例 self.scales = scales or [0.75, 1.0, 1.25, 1.5] self.current_epoch = 0 self.total_epochs = 100 def set_epoch(self, epoch): self.current_epoch = epoch def _get_scale_weights(self): progress = self.current_epoch / self.total_epochs if progress < 0.3: # 前期:偏向中等偏大尺度,快速抓大轮廓 return [0.0, 0.5, 0.5, 0.0] elif progress < 0.7: # 中期:全尺度覆盖 return [0.2, 0.3, 0.3, 0.2] else: # 后期:加大小尺度权重,精细调小病灶 return [0.4, 0.3, 0.2, 0.1] def __getitem__(self, idx): image = cv2.imread(self.image_paths[idx]) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = load_mask(self.mask_paths[idx]) weights = self._get_scale_weights() scale = random.choices(self.scales, weights=weights, k=1)[0] target_size = int(self.base_size * scale) # 图像用双线性插值,mask 用最近邻 image = cv2.resize(image, (target_size, target_size), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (target_size, target_size), interpolation=cv2.INTER_NEAREST) # 再统一 resize 回 base_size,保证 batch 内尺寸一致 image = cv2.resize(image, (self.base_size, self.base_size), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (self.base_size, self.base_size), interpolation=cv2.INTER_NEAREST) transformed = train_transform(image=image, mask=mask) image = transformed["image"].transpose(2, 0, 1).astype(np.float32) / 255.0 mask = transformed["mask"].astype(np.int64) return torch.from_numpy(image), torch.from_numpy(mask)这里有个关键细节:先缩放到目标尺度再缩回 base_size,等价于让网络看到不同物理尺寸的病灶,但 batch 内尺寸统一。_get_scale_weights里的权重是我在 ISIC 2018 上试出来的经验值,前期 0.5/0.5 对应 1.0 和 1.25 倍,后期 0.4 给 0.75 倍。如果你的 GPU 显存够,可以直接用不同尺寸的输入,不用缩回 base_size,但需要自定义 collate_fn 做 padding。random.choices的权重之和不需要严格等于 1,它会自动归一化。
3.3 训练循环里的尺度调度与验证集评估
训练循环里每个 epoch 开始前调用dataset.set_epoch(epoch),让 Dataset 更新尺度权重。优化器用 AdamW,学习率 1e-4,weight decay 1e-4,余弦退火到 1e-6。Batch size 根据显存选 8 或 16。验证集评估时固定用 base_size,这样指标可比。
from torch.utils.data import DataLoader from torch.optim.lr_scheduler import CosineAnnealingLR train_dataset = MultiScaleISICDataset(train_images, train_masks, base_size=256) val_dataset = MultiScaleISICDataset(val_images, val_masks, base_size=256) val_dataset.scales = [1.0] # 验证时固定尺度 train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False, num_workers=4) model = model.cuda() criterion = MultiClassDiceCE(num_classes=8).cuda() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6) for epoch in range(100): train_dataset.set_epoch(epoch) model.train() for images, masks in train_loader: images, masks = images.cuda(), masks.cuda() logits = model(images) loss = criterion(logits, masks) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() # 验证 model.eval() dice_scores = [] with torch.no_grad(): for images, masks in val_loader: images, masks = images.cuda(), masks.cuda() logits = model(images) preds = torch.argmax(logits, dim=1) # 计算每个类别的 Dice,忽略背景 for c in range(1, 8): pred_c = (preds == c).float() true_c = (masks == c).float() intersection = (pred_c * true_c).sum().item() union = pred_c.sum().item() + true_c.sum().item() if union > 0: dice_scores.append(2 * intersection / (union + 1e-6)) print(f"Epoch {epoch}, mean Dice: {np.mean(dice_scores):.4f}")梯度裁剪max_norm=1.0在多尺度训练里很重要,因为尺度变化会导致 loss 尺度波动,不裁剪容易梯度爆炸。验证时只算前景类别的 Dice,背景占比太大,算进去会虚高。如果某个类别在验证集里一个像素都没有,union为 0,跳过该类别,否则会除零。
4. 避坑与排查:ISIC 多类别分割里最容易翻车的五件事
4.1 现象:训练 loss 正常下降,但验证集 Dice 始终在 0.3 左右
原因通常是 mask 的像素值映射错了。ISIC 2018 任务三的 mask 在不同来源里可能用 0 表示背景,也可能用 255 表示背景,还有的用 1 到 7 表示类别。如果映射错位,网络学到的类别和评估时对不上,Dice 自然上不去。解决方法是写一个脚本统计 mask 的唯一像素值,和官方类别定义逐一对齐。我一般会在 Dataset 初始化时打印前 10 个 mask 的np.unique结果,确认无误后再开始训练。
4.2 现象:小病灶类别的 Dice 在 0.1 以下,大病灶类别正常
这是典型的类别不平衡加尺度偏差。ISIC 数据里基底细胞癌和黑色素瘤的像素占比可能差几十倍。除了在损失函数里加类别权重,还要检查多尺度采样是否真的让小病灶被看到了。如果前期尺度权重偏向大尺度,小病灶在缩放后可能只剩几个像素,网络根本学不到。解决办法是在训练中期就加入 0.75 倍尺度,并且对包含小病灶的样本做 oversampling。具体做法是统计每个样本里最小类别的像素数,像素数低于阈值的样本在 DataLoader 里重复采样。
4.3 现象:验证集 Dice 波动很大,相邻两个 epoch 能差 0.1
多尺度训练本身会带来波动,但如果波动过大,通常是验证时也用了随机尺度。验证必须固定尺度,而且要和推理时的尺度一致。另外,如果验证集太小,比如只有几十张图,波动大是正常的,建议用交叉验证或者把验证集扩大到至少 200 张。还有一个隐藏原因是 BatchNorm 在训练和验证时的行为差异,如果 batch size 太小,BatchNorm 的 running mean 不稳定,可以换成 GroupNorm 或 SyncBatchNorm。
4.4 现象:训练到后期 loss 突然变成 NaN
多尺度训练里,不同尺度的输入会导致特征图数值范围变化,如果学习率没有相应调整,后期容易梯度爆炸。除了梯度裁剪,还可以在尺度切换的 epoch 把学习率临时降一半。另外,Dice Loss 里的平滑项如果设得太小,比如 1e-8,当某个类别在 batch 里完全没出现时,除零会导致 NaN。我一般用 1e-6,并且在计算 Dice 前检查union是否大于 0,不大于就跳过该类。
4.5 现象:推理时单张图 Dice 比验证集低很多
最常见的原因是推理时的预处理和验证时不一致。验证时用了Resize(256, 256),推理时如果直接送原始尺寸,或者用了不同的归一化参数,结果会对不上。建议把验证集的 transform 单独抽成一个函数,推理时直接调用同一个函数。另外,如果验证时用了 TTA(比如水平翻转),推理时也要做同样的 TTA,否则指标不可比。TTA 在 ISIC 上通常能涨 1 到 2 个点,但推理时间翻倍。
5. 把 Dice 从 0.78 推到 0.85:三个我反复验证过的技巧
第一个技巧是深监督的加权方式。Unet3+ 的全尺度监督如果每个尺度权重一样,浅层的辅助损失会拖累深层特征的学习。我一般给最深层的输出权重 1.0,每浅一层权重乘 0.5,最浅层只有 0.125。这样深层语义主导,浅层只做微调。实现时在模型 forward 里返回一个列表,损失函数里按权重求和。
第二个技巧是尺度感知的 BatchNorm。普通 BatchNorm 在多尺度输入下统计量会混在一起,我试过给每个尺度单独维护一套 BatchNorm 统计量,推理时根据输入尺度选择对应的统计量。改动量不大,但在 ISIC 上能涨 1.5 个点左右。如果不想改模型结构,可以在多尺度训练时把 BatchNorm 换成 GroupNorm,GroupNorm 对 batch 内尺度变化不敏感。
第三个技巧是后处理里的条件随机场(CRF)。ISIC 病灶边界模糊,CRF 能把网络输出的概率图按像素颜色相似度做平滑,边缘更贴合真实边界。用pydensecrf库,迭代 5 次,参数sxy=80、srgb=13、compat=3。CRF 在 CPU 上跑一张 256x256 的图大约 0.5 秒,如果推理延迟要求高,可以只在最终提交前跑一次。
| 技巧 | 预期涨幅 | 额外开销 | 适用场景 |
|---|---|---|---|
| 深监督加权 | +0.5~1.0 | 无 | 所有 Unet3+ 变体 |
| 尺度感知 BN | +1.0~1.5 | 显存增加 10% | 多尺度训练 |
| CRF 后处理 | +1.0~2.0 | 推理延迟 +0.5s/张 | 离线评估或低延迟要求 |
这三个技巧我一般按顺序加,先加深监督加权,确认稳定后再上尺度感知 BN,最后在提交前跑 CRF。如果时间紧,只做深监督加权和 CRF,性价比最高。多类别分割在 ISIC 上没有银弹,数据清洗和尺度策略的收益远大于换更深的 backbone。我自己的习惯是每加一个技巧就跑一次完整的交叉验证,确认涨幅不是随机波动再保留。希望帮到你。
本文还有配套的精品资源,点击获取