ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ISIC2018皮肤病变分割实战:U-Net与CNN训练全指南

ISIC2018皮肤病变分割实战:U-Net与CNN训练全指南 简介这是一份面向医学图像分割实战的皮肤癌病变分割资源基于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_InputISIC_0000001.jpgTraining_GroundTruthISIC_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, interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, self.size, interpolationcv2.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, 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 UNet(nn.Module): def __init__(self, in_ch3, out_ch1, base_ch64): 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, stride2) self.dec4 ConvBlock(base_ch * 16, base_ch * 8) self.up3 nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride2) self.dec3 ConvBlock(base_ch * 8, base_ch * 4) self.up2 nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride2) self.dec2 ConvBlock(base_ch * 4, base_ch * 2) self.up1 nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride2) 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], dim1) d4 self.dec4(d4) d3 self.up3(d4) d3 torch.cat([d3, e3], dim1) d3 self.dec3(d3) d2 self.up2(d3) d2 torch.cat([d2, e2], dim1) d2 self.dec2(d2) d1 self.up1(d2) d1 torch.cat([d1, e1], dim1) d1 self.dec1(d1) return self.out(d1)这个结构的核心是每一层解码器都把上采样结果和对应编码器特征做通道拼接。torch.cat之后用ConvBlock做特征融合浅层位置信息与深层语义信息在通道维度上互补。对 ISIC2018 这种边界清晰的病灶四层下采样足够继续加深到五层在小数据集上容易过拟合而且训练时间翻倍收益有限。base_ch是控制模型宽度的入口。显存充足时可以从 64 提高到 96Dice 能涨零点几个点显存紧张就降到 48。保持编码器与解码器通道数翻倍规律即可不需要另做调整。3.3 损失函数Dice Loss 与 BCE 的组合逻辑皮肤病变分割有一个很实际的痛点病灶占整张图的比例小背景像素远多于前景。如果只用二值交叉熵模型会倾向于把大片背景预测正确来降低 loss导致输出的掩码“偏瘦”甚至在某些样本上全预测为背景而整体 loss 仍然很低。Dice Loss 直接把预测和真值的重叠程度作为优化目标对前景占比小的任务更敏感。我常用的组合方式是 BCE 加上 Dice Loss两个损失相加作为最终优化目标import torch.nn.functional as F def dice_loss(pred, target, smooth1e-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(dim1) dice (2.0 * intersection smooth) / ( pred_flat.sum(dim1) target_flat.sum(dim1) 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_decay1e-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_decay1e-4防止小数据过拟合batch size8~16取决于显存太小则梯度噪声大最大 epoch40~60超过 60 基本不再提升学习率调度ReduceLROnPlateaupatience 5factor 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(p0.5), A.VerticalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomBrightnessContrast(p0.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(dim1) union (p m - p * m).sum(dim1) dice (2 * inter 1e-6) / (p.sum(dim1) m.sum(dim1) 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 有帮助。本文还有配套的精品资源点击获取
返回列表