简介:这是一份面向医学图像分割实战的皮肤癌病变分割资源,基于ISIC2018数据,提供Unet与Mask R-CNN两条技术路线,帮助图像分割学习者、算法工程师和医工交叉研究者解决黑色素瘤病灶自动识别与像素级分割问题,并降低相关方向的上手门槛。Unet部分给出了损失0.147、精度0.946、Jaccard距离0.723、灵敏度0.878和特异性0.97等关键指标,便于训练效果对照;Mask R-CNN分支则展示实例级分割思路,可对比不同架构在同一任务中的表现。压缩包共14个文件,核心为3个Jupyter Notebook,另有4个Python辅助脚本、预训练权重文件(hdf5)、测试结果示例图(png)、两份README与许可证说明,整体约45.65MB。当前已有3476人浏览学习。读者可借助notebook复现完整训练推理流程,利用Python脚本与权重快速验证,参考结果图和README理解调参细节与Group Normalization工具模块,进而迁移到其他医学影像分割项目中,获得端到端的分割实践思路。
1. ISIC2018 与 CNN 分割:先把这个任务想清楚
皮肤病变分割,尤其是黑色素瘤区域的提取,已经成为 CNN 在医学图像分析中最典型的落地场景之一。ISIC2018 这个基准把一张皮肤镜图像和一张与输入等大的逐像素掩码绑在一起,要求模型准确描述病灶的边界——分类只回答“有没有”,分割则要回答“边界在哪里、形状是什么”。这个差别决定了整个技术选型思路。
它为什么值得做?因为 ISIC2018 的样本规模、标注质量和指标口径都很适合工程复现,把医学图像分割的数据组织、模型选型、训练调参和后处理完整串成一条链路。无论你是想验证一个 U-Net 改动,还是第一次进入分割领域,拿它当试验台都比一上来啃城市街景类大数据集更友好。这篇笔记不聊泛泛的“AI 医疗”,只讲数据怎么组织不翻车、CNN 分割模型怎么选怎么训,以及哪些环节会直接影响最终 Dice。
2. ISIC2018 的数据准备:先把图像与掩码配对好,训练才不翻车
2.1 数据集结构和文件配对
ISIC2018 Task 1 分割数据集下载下来是典型的按目录划分:ISIC2018_Task1_Training_Input放输入图像,ISIC2018_Task1_Training_GroundTruth放分割掩码,官方还单独给了验证集。训练集一共 2594 张皮肤镜图像,验证集约 100 张,都带同名 ID。图像是.jpg,掩码是.png,但掩码文件名不是简单替换后缀,而是比图像多了_segmentation后缀。
| 目录 | 文件名示例 |
|---|---|
| Training_Input | ISIC_0000001.jpg |
| Training_GroundTruth | ISIC_0000001_segmentation.png |
所以第一步不是写模型,而是写一个文件扫描函数:遍历图像目录,提取 ID,再检查掩码目录里是否存在对应文件,把两边都对得上的 ID 收集成列表。这步骤看着简单,但如果不做存在性判断,训练到一半一旦文件缺失,整个 DataLoader 的 worker 直接卡死,报错信息还经常被多进程吞掉,排查起来非常被动。
官方训练集和验证集建议保持原划分用于最终汇报。如果要在训练集内再拆一部分做调参,我一般会固定随机种子,从 2594 张里拆出 10% 作为内部验证集,并且保证拆完后各类病灶面积分布和整体大致一致。没有固定种子的话,每次启动实验数据分布都不一样,后面比较模型改动是否有效就失去了意义。
2.2 输入尺寸、插值方式与掩码二值化
ISIC 原始图像分辨率参差不齐,边长普遍在 600 到 1000 像素以上。如果直接以原始尺寸输入,显存占用高,batch size 上不去,训练速度也被拖慢。我一般统一缩放到 512x512:这个尺寸足够保留病灶的精细边界,也让 U-Net 四层下采样后的特征图不至于太小。384 可以加快训练,但边界细节比 512 略钝;224 则明显偏低,边缘毛刺增多,验证 Dice 掉两个点毫不奇怪。
缩放这里有两个必须一次写对的细节。第一,图像用cv2.INTER_LINEAR,掩码必须用cv2.INTER_NEAREST。如果给掩码也用线性插值,边界就会产生 0.7、0.3 这类连续灰度值,训练时模型会被这些“不该存在的软标签”误导,评估时还要再做一次阈值化,白白引入误差。第二,掩码要二值化:ISIC 的 PNG 掩码标注值可能不止 0 和 255,偶尔有杂点,统一转成 0/1 对 BCE 和 Dice 计算更干净。
归一化参数我建议直接写死 ImageNet 的 mean 和 std,前提是编码器使用了预训练权重。目前 U-Net 类实现基本都从 resnet 或 efficientnet 的 ImageNet 权重起步,输入统计量保持一致才不会把第一层特征分布带偏。如果整个网络完全从零训练,再统计训练集自身的均值方差也不迟。
2.3 用 PyTorch Dataset 把图像与掩码“锁”在一起
下面这段 Dataset 代码是可以直接跑通 ISIC2018 的最小实现,处理了文件名配对、缩放、二值化和归一化:
import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class ISIC2018Dataset(Dataset): def __init__(self, image_dir, mask_dir, size=(512, 512)): self.image_dir = image_dir self.mask_dir = mask_dir self.size = size self.ids = [] for name in os.listdir(image_dir): if name.endswith('.jpg'): base = name.replace('.jpg', '') mask_name = base + '_segmentation.png' if os.path.exists(os.path.join(mask_dir, mask_name)): self.ids.append(base) self.ids.sort() def __len__(self): return len(self.ids) def __getitem__(self, idx): base = self.ids[idx] img_path = os.path.join(self.image_dir, base + '.jpg') mask_path = os.path.join(self.mask_dir, base + '_segmentation.png') img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, self.size, interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, self.size, interpolation=cv2.INTER_NEAREST) mask = (mask > 0).astype(np.float32) img = img.astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406]) std = np.array([0.229, 0.224, 0.225]) img = (img - mean) / std img = torch.from_numpy(img.transpose(2, 0, 1)).float() mask = torch.from_numpy(mask).unsqueeze(0).float() return img, mask这段代码把 ID 扫描放在__init__里,后续每次访问都是直接按路径读取,避免在训练循环里反复做字符串拼接和存在性判断。掩码用unsqueeze(0)变成[1, H, W],和模型输出的[B, 1, H, W]对齐,不需要在 loss 函数里再补维度。
注意:在
__getitem__末尾最好加一句尺寸断言,例如assert img.shape[1:] == mask.shape[1:]。多进程 DataLoader 下,一张异常尺寸的图会让训练进程直接挂掉,日志里往往看不到具体是哪张图出错,有断言能省下大量排查时间。
3. CNN 分割模型选型:为什么 U-Net 这类编解码结构在 ISIC2018 上最稳
3.1 分割模型可选路线对比
ISIC2018 分割任务常见的成熟路线有三类:U-Net 为代表的编码器-解码器结构,DeepLabV3+ 这类空洞卷积结构,以及加入 Transformer 模块的混合结构。后者在小数据集上收敛慢且不稳定,除非你有大量外部预训练数据,否则不推荐作为首发方案。
| 模型路线 | 对不规则边界的适应 | 训练成本 | 小目标表现 |
|---|---|---|---|
| U-Net 编码器-解码器 | 好,跳跃连接保细节 | 低 | 稳定 |
| DeepLabV3+ 空洞卷积 | 中,边界偏平滑 | 中 | 一般 |
| Transformer 混合结构 | 好,但依赖大数据 | 高 | 容易过拟合 |
从验证集表现看,U-Net 对 ISIC2018 这种病灶边界不规则、前景占比小的场景非常对口。空洞卷积为了扩大感受野,会牺牲部分局部细节,而皮肤镜图像里病灶边缘经常伴随晕圈、色斑过渡,这部分细节直接决定 IoU 高低。U-Net 的跳跃连接让解码器每一步都能拿到编码器对应层的边缘纹理,边界恢复更稳。
三者的选择其实不玄学,就看你手里有多少数据。2594 张训练图对 Transformer 来说太少,对 DeepLab 来说显存占用偏高,对 U-Net 来说正合适。我的建议是首发 U-Net,等 Baseline 稳定之后再尝试把编码器换成 resnet34 这类预训练主干,收益比换整个模型骨架更直接。
3.2 从零搭建一个可替换主干的 U-Net
用 PyTorch 实现一个基础版 U-Net,方便理解每个模块的作用:
import torch import torch.nn as nn class ConvBlock(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 UNet(nn.Module): def __init__(self, in_ch=3, out_ch=1, base_ch=64): super().__init__() self.enc1 = ConvBlock(in_ch, base_ch) self.pool1 = nn.MaxPool2d(2) self.enc2 = ConvBlock(base_ch, base_ch * 2) self.pool2 = nn.MaxPool2d(2) self.enc3 = ConvBlock(base_ch * 2, base_ch * 4) self.pool3 = nn.MaxPool2d(2) self.enc4 = ConvBlock(base_ch * 4, base_ch * 8) self.pool4 = nn.MaxPool2d(2) self.bottleneck = ConvBlock(base_ch * 8, base_ch * 16) self.up4 = nn.ConvTranspose2d(base_ch * 16, base_ch * 8, 2, stride=2) self.dec4 = ConvBlock(base_ch * 16, base_ch * 8) self.up3 = nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride=2) self.dec3 = ConvBlock(base_ch * 8, base_ch * 4) self.up2 = nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride=2) self.dec2 = ConvBlock(base_ch * 4, base_ch * 2) self.up1 = nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride=2) self.dec1 = ConvBlock(base_ch * 2, base_ch) self.out = nn.Conv2d(base_ch, out_ch, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool1(e1)) e3 = self.enc3(self.pool2(e2)) e4 = self.enc4(self.pool3(e3)) b = self.bottleneck(self.pool4(e4)) d4 = self.up4(b) d4 = torch.cat([d4, e4], dim=1) d4 = self.dec4(d4) d3 = self.up3(d4) d3 = torch.cat([d3, e3], dim=1) d3 = self.dec3(d3) d2 = self.up2(d3) d2 = torch.cat([d2, e2], dim=1) d2 = self.dec2(d2) d1 = self.up1(d2) d1 = torch.cat([d1, e1], dim=1) d1 = self.dec1(d1) return self.out(d1)这个结构的核心是每一层解码器都把上采样结果和对应编码器特征做通道拼接。torch.cat之后用ConvBlock做特征融合,浅层位置信息与深层语义信息在通道维度上互补。对 ISIC2018 这种边界清晰的病灶,四层下采样足够;继续加深到五层在小数据集上容易过拟合,而且训练时间翻倍,收益有限。
base_ch是控制模型宽度的入口。显存充足时可以从 64 提高到 96,Dice 能涨零点几个点;显存紧张就降到 48。保持编码器与解码器通道数翻倍规律即可,不需要另做调整。
3.3 损失函数:Dice Loss 与 BCE 的组合逻辑
皮肤病变分割有一个很实际的痛点:病灶占整张图的比例小,背景像素远多于前景。如果只用二值交叉熵,模型会倾向于把大片背景预测正确来降低 loss,导致输出的掩码“偏瘦”,甚至在某些样本上全预测为背景,而整体 loss 仍然很低。
Dice Loss 直接把预测和真值的重叠程度作为优化目标,对前景占比小的任务更敏感。我常用的组合方式是 BCE 加上 Dice Loss,两个损失相加作为最终优化目标:
import torch.nn.functional as F def dice_loss(pred, target, smooth=1e-6): pred = torch.sigmoid(pred) pred_flat = pred.view(pred.size(0), -1) target_flat = target.view(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) dice = (2.0 * intersection + smooth) / ( pred_flat.sum(dim=1) + target_flat.sum(dim=1) + smooth ) return 1.0 - dice.mean() def combined_loss(pred, target): bce = F.binary_cross_entropy_with_logits(pred, target) dice = dice_loss(pred, target) return bce + dicepred是模型输出的 logits,没有经过 sigmoid,所以bce_with_logits和dice_loss内部的sigmoid各自处理一次,不会重复。Dice 的平滑项smooth取值 1e-6 足够,过大的平滑值会拉低小病灶的损失权重。
组合损失里 BCE 提供稳定的梯度,让模型在前几个 epoch 不会因为 Dice 的非凸特性剧烈震荡;Dice Loss 则负责把前景的重合度拉高。经验上两者各占一半权重即可,不需要刻意调优。
提示:训练初期可以先把学习率调到 1e-4 以下,或者前几个 epoch 只使用 BCE,等模型输出不再全零之后再加入 Dice Loss。Dice 的梯度在小目标上很容易波动,尤其是第一个 epoch,损失曲线会像心跳一样不稳定,属于正常现象。
4. 训练与调参:优化器、学习率、增强和验证指标怎么设
4.1 优化器与学习率策略
ISIC2018 的数据量不大,优化器选择并不复杂。AdamW 相比 Adam 增加了权重衰减的解耦,配合weight_decay=1e-4在小数据集上过拟合现象更少。学习率初始值我固定用 1e-4,并加上前 5 个 epoch 的线性 warmup,从 1e-5 逐步升到 1e-4。没有 warmup 时,BCE 和 Dice 组合损失在第一个 epoch 经常冲高,后续要花更多 epoch 才能回稳。
| 参数 | 推荐值 | 备注 |
|---|---|---|
| 优化器 | AdamW | 比 Adam 稳定,weight_decay 解耦 |
| 初始学习率 | 1e-4 | 输入 512、batch 8~16 时合适 |
| weight_decay | 1e-4 | 防止小数据过拟合 |
| batch size | 8~16 | 取决于显存,太小则梯度噪声大 |
| 最大 epoch | 40~60 | 超过 60 基本不再提升 |
| 学习率调度 | ReduceLROnPlateau | patience 5,factor 0.5 |
调度器我习惯用ReduceLROnPlateau而不是固定步长衰减。因为验证集 Dice 并不一定每个 epoch 都上升,固定步长可能在模型刚越过一个平台时把学习率降得太狠。ReduceLROnPlateau跟踪验证集 Dice,连续 5 个 epoch 不提升就把学习率乘 0.5,效率更高。
4.2 数据增强:同步图像与掩码是关键
分割任务的增强和分类不一样:几何变换必须同步作用在图像和掩码上,否则模型学到的边界是错位的。用 albumentations 的Compose最省心,它要求同时对image和mask传入,内部保证同一个随机参数:
import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.RandomBrightnessContrast(p=0.3), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ])这套增强组合对 ISIC2018 足够。水平翻转、垂直翻转和旋转 90 度都是无痛增强,不改变病灶形态;亮度对比度抖动模拟不同皮肤镜设备的采集差异。要注意的是不要加 RandomErasing 或强模糊这类破坏纹理的增强,皮肤镜图像中的色素网络纹理是边界判断的重要线索,破坏它会让模型学到错误特征。
增强只应在训练集启用,验证集只需要缩放和归一化。验证时使用增强会让指标不稳定,也不符合实际推理场景。
4.3 训练循环与验证指标计算
训练循环本身不复杂,关键是验证阶段指标计算要符合分割任务习惯。Dice 和 IoU 是两个最常用的指标,它们的换算关系是 IoU = Dice / (2 - Dice)。计算时我建议按样本逐个算,再取平均,而不是把所有图像拼成大矩阵统一算——后者会被大面积病灶主导,小病灶的退化被掩盖。
def evaluate(model, val_loader, device): model.eval() dice_sum = 0.0 iou_sum = 0.0 n = 0 with torch.no_grad(): for imgs, masks in val_loader: imgs = imgs.to(device) masks = masks.to(device) logits = model(imgs) pred = (torch.sigmoid(logits) > 0.5).float() p = pred.view(pred.size(0), -1) m = masks.view(masks.size(0), -1) inter = (p * m).sum(dim=1) union = (p + m - p * m).sum(dim=1) dice = (2 * inter + 1e-6) / (p.sum(dim=1) + m.sum(dim=1) + 1e-6) iou = (inter + 1e-6) / (union + 1e-6) dice_sum += dice.sum().item() iou_sum += iou.sum().item() n += imgs.size(0) return dice_sum / n, iou_sum / n验证时阈值默认取 0.5,这是 Baseline 阶段最通用的选择。pred和mask展平成[N, H*W]之后按行计算,每个样本的 Inter 和 Union 独立,最后sum().item()累加再除以样本数,得到的是样本平均 Dice。这里不要先对整批像素累加再统一算,那样大病灶样本会占更高权重,指标反映不了弱势样本的表现。
实际训练中,我每 2 个 epoch 跑一次验证,并记录最佳 Dice 对应的模型权重,训练结束后再单独做一轮阈值扫描和测试集推理。
5. 避坑:ISIC2018 分割最容易翻车的五个细节
5.1 掩码维度与图像维度不对齐
现象:训练进入第一个 batch 直接报错,Tensor 形状提示是[B, 1, 512, 512]和[B, 512, 512, 1],维度顺序不一致,或者尺寸一个是 512 一个是 480。
原因:图像读取后经过transpose(2, 0, 1)变成了 CHW,掩码读取后没有补通道维;或者 Resize 时图像和掩码用了不同的目标尺寸参数,导致两者在空间维度上不一致。多进程 DataLoader 下这个问题尤其难发现,因为报错堆栈往往指向 dataloader,而不是 Dataset。
解决:在__getitem__最后加上断言,明确检查img.shape[1:] == mask.shape[1:]。同时统一把掩码unsqueeze(0)变成[1, H, W],让模型输出和标签始终在同一坐标系下比较。
5.2 小病灶样本让验证 Dice 剧烈波动
现象:训练损失缓慢下降,但验证集 Dice 在不同 epoch 之间大幅跳动,有时 0.79,下一个 epoch 掉到 0.71,再下一个又回到 0.78。
原因:ISIC2018 中有相当一部分病灶只占图像几个百分点,这些样本对边界偏移极其敏感。模型预测边界偏了十几个像素,大病灶的 IoU 几乎不变,小病灶的 IoU 可能直接掉一半。样本平均 Dice 把这些高方差样本一起平均进来,指标自然不稳定。
解决:一是训练损失里保留 Dice 分量,它比 BCE 更关注小目标召回;二是评估时同时记录验证集 Dice 的中位数和均值,中位数更能反映模型整体水平,防止少数极端样本误导判断;三是如果项目允许,可以按病灶面积分层评估,拆分来看模型在哪些尺寸区间掉点。
5.3 预测阈值直接拍脑袋选 0.5
现象:验证时模型用 0.5 做二值化,Dice 0.81;团队里另一版本代码改成 0.3,结果变成 0.83;再改成 0.7,又变成 0.79。指标波动完全被阈值左右。
原因:模型输出的概率分布并不是天然以 0.5 为分界。皮肤镜图像中病灶边缘本身就存在过渡区域,模型对边缘像素的输出通常在 0.3 到 0.7 之间。固定阈值越界,这部分像素要么全部保留,要么全部丢弃,直接改变边界形状。
解决:把阈值当作超参数来调。在验证集上从 0.1 到 0.9,按 0.05 步长扫描,选择 IoU 最高或 Dice 最高的阈值,再用这个阈值做最终预测。ISIC 官方的评估指标和我实践中的一致,Jaccard Index 对低阈值更敏感,扫描之后通常能带来 1 到 2 个点的提升,是性价比最高的优化手段。
5.4 数据增强不同步导致掩码错位
现象:模型训练正常,但输出的掩码边界出现不自然的直线或锯齿,视觉效果和病灶轮廓对不上,验证集分数也上不去。
原因:训练代码里用了自定义增强,比如把图像旋转了 30 度,却忘了对掩码做同样旋转;或者图像使用线性插值、掩码使用最近邻后,两者的几何变换参数不一致。albumentations 的Compose能避免这个问题,但如果手写增强逻辑,很容易漏掉同步。
解决:训练和增强阶段始终使用同一个变换对象,同时传入image和mask。手写增强时,可以给几何变换一个固定随机种子,让图像和掩码共享同样的旋转角度、缩放系数和平移量,才能保证逐像素对齐。
5.5 验证集与训练集用了不同归一化参数
现象:模型在验证集上的 Dice 比训练集内部验证低 1 到 2 个点,反复检查模型和数据都没有问题,最后发现验证阶段用了数据集统计均值,训练阶段用了 ImageNet 均值。
原因:归一化参数不统一,输入图像分布被改变,预训练编码器提取的特征也随之偏移。这个小差异在分类任务上可能只有零点几个点,但对像素级分割来说,边缘像素的响应本身接近阈值,稍微偏移就会改变最终掩码。
解决:把 mean 和 std 常量单独放到一个配置模块里,训练和验证代码都从同一处读取,避免两份脚本各自维护一套参数。这属于“黑匣子”问题,卡住半天才发现是配置不对,非常冤枉。
6. 从验证到上线:阈值扫描、形态学后处理与测试时增强
训练到验证 Dice 稳定之后,离真正可用还差几步后处理。最常见也最有效的是测试时增强(TTA):同一张图分别预测原图、水平翻转、垂直翻转、水平加垂直翻转四种形态,把 sigmoid 输出平均后再按最优阈值二值化。这个方法不需要重新训练,通常能在 IoU 上提升 0.5 到 1.5 个点,代价是推理时间增加到原来的四倍。
形态学后处理我的建议是先做开闭运算再判断是否使用 CRF。cv2.morphologyEx用 3x3 或 5x5 的结构元去掉孤立的预测点,再把面积小于全图千分之一的连通域删除。这个步骤对 ISIC 验证集的提升稳定,不会像 CRF 那样改变病灶细支结构。CRF 在传统医学分割中很常见,但对黑色素瘤这种本身边界颜色与皮肤接近的目标,空间一致性约束容易把病灶外围的浅色延伸部分抹掉,收益不如形态学处理可控。
一套完整的验证流程应该按固定顺序执行:先对验证集做阈值扫描确定最优阈值,再用 TTA 和形态学后处理评估一次,确认每个环节都正向提升后再冻结管线。不要在同一份测试数据上反复调整这些后处理参数,那是拿测试集当验证集用,会让指标虚高。黑色素瘤分割的实际价值不在把 Dice 刷到多高,而是让边界更符合临床判断习惯,少一些零散误报。这也是我每次交付前都会确认的一点:宁可边界保守一点,也不要漏掉病灶边缘的细支。希望这些经验对你跑通 ISIC2018 有帮助。
本文还有配套的精品资源,点击获取