ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于CNN的ISIC2018皮肤镜黑色素瘤分割实战:从U-Net到Dice 0.90

基于CNN的ISIC2018皮肤镜黑色素瘤分割实战:从U-Net到Dice 0.90 简介这份资源面向医学图像处理与深度学习入门者聚焦ISIC2018皮肤病变黑色素瘤分割任务提供基于U-Net与Mask R-CNN两套完整实现方案适合希望掌握语义分割流程、对比不同网络结构效果的学习者。压缩包共14个文件约45.65MB包含3个Jupyter Notebook分别对应U-Net与Mask R-CNN训练推理、4个Python脚本、2张结果图、2份说明文档、1个HDF5模型权重及许可证等覆盖从数据读取、模型搭建到评估的完整链路。其中U-Net方案损失0.147、精度0.946、Jaccard距离0.723、灵敏度0.878、特异性0.97指标表现清晰可复现。目前已有3476人学习下载。读者可借此快速理解皮肤病变分割的评估体系参考Group Normalization等模块的工程实现并对照结果图与权重文件复现实验为医学影像分割项目提供可迁移的代码框架与调参思路。1. 从一张 ISIC2018 皮肤镜图像说起CNN 分割黑色素瘤到底在分什么皮肤镜拍下来的病变区域肉眼看着边界挺清楚但真让标注医生去勾十个医生能勾出十条不一样的轮廓线。ISIC2018 这个数据集就是干这个用的——它把皮肤镜图像和对应的病变分割掩码配对放出来让你训练 CNN 去学「哪里是病变、哪里是正常皮肤」。黑色素瘤的早期筛查里病变边界是否规则、面积是否在扩大是判断良恶性的重要依据而自动分割就是把这个判断过程量化。这个方向适合两类人一类是想拿医学图像分割练手的深度学习工程师ISIC2018 数据量适中、标注质量高比很多工业数据集干净另一类是做皮肤科辅助诊断的产品团队需要一套能跑通的分割基线。CNN 在这里的角色不是分类是逐像素的二分类——每个像素要么属于病变要么不属于。U-Net 及其变体是目前最主流的做法编码器降采样提特征解码器升采样恢复分辨率跳跃连接把浅层细节直接送到深层。后面几章我会把数据准备、模型搭建、训练调参、避坑排查和进阶技巧依次讲透你照着能复现出一条可用的分割流水线。2. ISIC2018 数据集的读取、清洗与增强从原图到可训练张量2.1 数据集结构与你需要关心的三个目录ISIC2018 官方放出来的训练数据通常包含三个部分原始皮肤镜图像JPEG 格式、对应的分割掩码PNG 格式二值图、以及一份 CSV 标注文件记录每张图的属性。图像分辨率不统一常见的有 600×450、1024×768、4499×6748 等掩码和原图文件名一一对应。你拿到手第一件事不是写模型是把文件清单对齐——我见过太多人在这步翻车图像和掩码错位了还浑然不知训练 loss 降得挺好看推理出来全是噪声。常见做法是用 pandas 读 CSV构建一个 DataFrame包含 image_path、mask_path、lesion_id 三列。然后按 8:1:1 切训练/验证/测试切分时按 lesion_id 分组同一个病变的不同角度照片不能跨集否则验证集精度虚高。import pandas as pd from sklearn.model_selection import GroupShuffleSplit df pd.read_csv(ISIC2018_Task1-2_Training_Input/ground_truth.csv) df[image_path] ISIC2018_Task1-2_Training_Input/ df[image] .jpg df[mask_path] ISIC2018_Task1-2_Training_Input/ df[image] _segmentation.png gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(df, groupsdf[lesion_id])) train_df df.iloc[train_idx].reset_index(dropTrue) val_df df.iloc[val_idx].reset_index(dropTrue)这段代码的关键在GroupShuffleSplit的groups参数它保证同一个 lesion_id 的所有样本只出现在训练集或验证集其中一边。test_size0.2是验证集比例random_state固定住方便复现。如果你拿到的 CSV 没有 lesion_id 列至少按图像文件名前缀分组别直接随机切。2.2 预处理尺寸、归一化和掩码二值化ISIC2018 原图尺寸差异大直接 resize 到 256×256 或 384×384 是常规操作。但要注意长宽比——皮肤镜图像多数接近 4:3强行拉成正方形会让病变形状变形影响边界学习。我一般用 letterbox 方式按长边缩放到目标尺寸短边补零同时记录缩放比例和填充偏移推理时再映射回原图坐标。归一化用 ImageNet 的均值和标准差就行虽然皮肤镜图像和自然图像分布有差异但编码器通常用预训练权重保持一致的归一化能减少微调时的震荡。掩码处理更简单读进来是 0/255 的灰度图除以 255 变成 0/1 的 float再扩一维成 (H, W, 1)。import cv2 import numpy as np def letterbox_resize(img, target384): h, w img.shape[:2] scale target / max(h, w) nh, nw int(h * scale), int(w * scale) resized cv2.resize(img, (nw, nh), interpolationcv2.INTER_LINEAR) canvas np.zeros((target, target, 3), dtypenp.uint8) top (target - nh) // 2 left (target - nw) // 2 canvas[top:topnh, left:leftnw] resized return canvas, scale, top, left def load_pair(img_path, mask_path, target384): img cv2.imread(img_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) img, scale, top, left letterbox_resize(img, target) mask cv2.resize(mask, (target, target), interpolationcv2.INTER_NEAREST) mask (mask 127).astype(np.float32)[..., None] 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 return img.transpose(2, 0, 1), mask.transpose(2, 0, 1)letterbox_resize里interpolationcv2.INTER_LINEAR用于图像掩码必须用INTER_NEAREST否则边缘会出现 0.5 这种中间值二值化后边界抖动。scale、top、left三个值要存下来推理时把预测掩码贴回原图尺寸用得上。归一化的 mean/std 是 ImageNet 统计值如果你不用预训练权重可以换成 ISIC2018 训练集自己算的均值和方差但差别通常不大。2.3 在线增强别让模型记住病变的绝对位置医学图像增强和自然图像不太一样。水平翻转、垂直翻转、90 度旋转都可以用因为病变没有固定的方向语义。但颜色抖动要小心——皮肤镜的色偏有时是设备差异过度抖动会让模型把颜色当成噪声而不是特征。我一般用轻微的亮度对比度调整±0.1不做强烈的 HSV 偏移。另一个容易被忽略的点是弹性形变。皮肤病变边界本身就不规则弹性形变能模拟不同医生的勾画差异提升模型对边界模糊的鲁棒性。albumentations 里ElasticTransform的 alpha 设 1、sigma 设 50 是比较温和的参数再大就可能把病变形状扭曲得不合理。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), A.ElasticTransform(alpha1, sigma50, p0.2), ])注意 albumentations 对图像和掩码的同步处理图像用双线性插值掩码用最近邻这个在A.Compose里通过additional_targets或直接传mask参数自动处理。如果你自己写 Dataset 类增强要在__getitem__里做不要提前把增强后的图存盘否则每个 epoch 看到的都是同一批变换结果增强就白做了。3. U-Net 编码器-解码器搭建从 ResNet 骨干到注意力跳跃连接3.1 为什么选 U-Net 而不是 FCN 或 DeepLabFCN 是最早的全卷积分割网络但它只做一次上采样边界恢复得糙。DeepLab 系列用空洞卷积扩大感受野在自然图像上很强但 ISIC2018 的病变区域有大有小空洞卷积对小病变的细节捕捉不如 U-Net 的跳跃连接直接。U-Net 的编码器-解码器对称结构加上跳跃连接能把编码器浅层的高分辨率特征直接拼到解码器对应层边界定位精度明显更好。骨干网络选择上原始 U-Net 用 VGG 式堆叠参数量大且没有预训练。现在更常见的做法是换 ResNet34 或 EfficientNet-B0 做编码器用 ImageNet 预训练权重初始化解码器随机初始化。这样在 ISIC2018 这种几千张图的数据集上收敛更快验证集 Dice 通常能高 3 到 5 个点。3.2 编码器替换与跳跃连接处的注意力模块用segmentation_models_pytorch这个库能省很多事它把常见骨干和 U-Net 解码器封装好了。但如果你想自己控制细节比如在跳跃连接处加注意力就得手动搭。注意力跳跃连接的做法是把编码器传来的特征图先做通道注意力SE block再做空间注意力然后才拼到解码器上。这样能抑制背景区域的响应让模型更关注病变边界。import torch import torch.nn as nn import torch.nn.functional as F class SEBlock(nn.Module): def __init__(self, channels, reduction16): super().__init__() self.fc nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(channels, channels // reduction, 1), nn.ReLU(inplaceTrue), nn.Conv2d(channels // reduction, channels, 1), nn.Sigmoid() ) def forward(self, x): return x * self.fc(x) class AttentionGate(nn.Module): def __init__(self, F_g, F_l, F_int): super().__init__() self.W_g nn.Conv2d(F_g, F_int, 1) self.W_x nn.Conv2d(F_l, F_int, 1) self.psi nn.Conv2d(F_int, 1, 1) self.se SEBlock(F_l) def forward(self, g, x): g1 self.W_g(g) x1 self.W_x(x) psi F.relu(g1 x1) psi torch.sigmoid(self.psi(psi)) x self.se(x) return x * psiAttentionGate里g是解码器上采样的特征x是编码器跳跃连接的特征。W_g和W_x把两者映射到同一通道数F_int相加后过 sigmoid 得到空间注意力图psi再乘到x上。SEBlock先做通道重标定让注意力门控在通道和空间两个维度都起作用。F_int一般取F_l // 2太小会丢信息太大参数量上去了收益不明显。3.3 损失函数Dice BCE 的组合与权重调整医学分割里类别极度不平衡——病变区域通常只占整张图的 10% 到 30%纯 BCE 会让模型倾向于全预测背景。Dice Loss 直接优化重叠度对不平衡不敏感但训练初期梯度不稳定。常见做法是两者加权loss 0.5 * BCE 0.5 * Dice。如果验证集上边界一直糊可以把 Dice 权重提到 0.7。class DiceBCELoss(nn.Module): def __init__(self, dice_weight0.5): super().__init__() self.dice_weight dice_weight self.bce nn.BCEWithLogitsLoss() def forward(self, pred, target): bce_loss self.bce(pred, target) pred_sig torch.sigmoid(pred) intersection (pred_sig * target).sum(dim(2, 3)) union pred_sig.sum(dim(2, 3)) target.sum(dim(2, 3)) dice (2. * intersection 1e-6) / (union 1e-6) dice_loss 1 - dice.mean() return self.dice_weight * dice_loss (1 - self.dice_weight) * bce_lossBCEWithLogitsLoss内部做了 sigmoid所以pred传 logits 就行不要再手动 sigmoid。Dice 计算时1e-6是平滑项防止分母为零。dice_weight默认 0.5如果训练集病变面积普遍偏小可以调到 0.6 到 0.7。注意 Dice 是对 batch 里每个样本算完再平均不是把所有像素混在一起算这样小病变样本不会被大病变淹没。4. 训练循环、学习率调度与验证指标让 Dice 真正涨上去4.1 优化器选择与分层学习率编码器用预训练权重解码器随机初始化两者对学习率的敏感度不一样。常见做法是编码器学习率设小一点1e-4解码器设大一点1e-3用参数组分开传。优化器选 AdamW 比 Adam 更稳权重衰减设 1e-4 能抑制过拟合。如果你用 SGD初始学习率可以设 1e-2 配 cosine 退火但收敛慢ISIC2018 这种规模的数据集不太划算。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts encoder_params list(model.encoder.parameters()) decoder_params list(model.decoder.parameters()) optimizer AdamW([ {params: encoder_params, lr: 1e-4}, {params: decoder_params, lr: 1e-3} ], weight_decay1e-4) scheduler CosineAnnealingWarmRestarts(optimizer, T_010, T_mult2)CosineAnnealingWarmRestarts的T_010表示第一个周期 10 个 epochT_mult2表示后续周期翻倍。这种调度在验证集 Dice 停滞时能通过重启学习率跳出局部最优。如果你发现训练 loss 震荡厉害把T_0调到 15 或 20。4.2 验证指标Dice、IoU 和边界距离Dice 系数是最常用的分割指标公式是2 * |A ∩ B| / (|A| |B|)值域 0 到 1。IoU 是|A ∩ B| / |A ∪ B|和 Dice 单调对应但数值上 Dice 通常比 IoU 高一些。除了这两个边界距离指标如 Hausdorff Distance 95%能反映预测边界和真实边界的最大偏差对黑色素瘤这种边界不规则的病变更有参考价值。验证时要把预测掩码二值化阈值一般取 0.5。但如果你发现模型输出的概率图在边界处比较模糊可以试 0.4 或 0.6看验证集 Dice 哪个高。这个阈值不要用测试集调只能在验证集上选。def compute_metrics(pred_logits, target, threshold0.5): pred (torch.sigmoid(pred_logits) threshold).float() intersection (pred * target).sum(dim(2, 3)) union pred.sum(dim(2, 3)) target.sum(dim(2, 3)) dice (2. * intersection 1e-6) / (union 1e-6) iou (intersection 1e-6) / (union - intersection 1e-6) return dice.mean().item(), iou.mean().item()threshold默认 0.51e-6防止除零。返回的是 batch 平均 Dice 和 IoU。训练时每 5 个 epoch 在验证集上跑一次记录最佳 Dice 对应的模型权重。如果验证 Dice 连续 15 个 epoch 不涨就停。4.3 混合精度训练与显存优化ISIC2018 图像 resize 到 384×384 后batch size 设 8 在 8GB 显存上差不多能跑。如果显存不够开混合精度AMP能省 30% 到 40% 显存速度也快。PyTorch 的torch.cuda.amp用起来很简单但要注意 loss scaling 和梯度裁剪的配合。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for epoch in range(num_epochs): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() optimizer.zero_grad() with autocast(): pred model(img) loss criterion(pred, mask) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) scaler.step(optimizer) scaler.update() scheduler.step()autocast上下文里前向计算用 float16GradScaler负责放大 loss 防止梯度下溢。unscale_之后再裁剪梯度max_norm1.0是经验值如果你发现梯度爆炸频繁降到 0.5。scheduler.step()放在 epoch 末尾和CosineAnnealingWarmRestarts的周期对齐。5. 训练分割模型时最容易翻车的五个地方5.1 掩码和图像错位Dice 虚高但推理全糊现象训练 loss 稳定下降验证 Dice 能到 0.85 以上但拿单张图推理预测掩码和病变位置对不上。原因图像和掩码文件名匹配时用了错误的排序或索引比如sorted(os.listdir())对图像和掩码分别排序但两个目录里文件数量不一致导致错位。解决用 DataFrame 显式构建 image_path 和 mask_path 的对应关系构建完后随机抽 10 对可视化检查确认掩码轮廓和图像病变区域重合。5.2 验证集 Dice 比训练集高一截别高兴太早现象训练集 Dice 0.78验证集 Dice 0.83。原因验证集样本少且病变面积大Dice 天然偏高或者数据切分时同一个 lesion 的样本泄漏到了验证集。解决按 lesion_id 分组切分验证集至少留 15% 的独立病变。如果验证集 Dice 仍然异常高检查验证集的病变面积分布和训练集对比一下差异大就重新分层采样。5.3 学习率太大导致 Dice 震荡调小后又收敛太慢现象训练初期 Dice 在 0.3 到 0.6 之间反复跳调小学习率后 Dice 涨得极慢。原因编码器和解码器共用一个学习率编码器预训练权重被大学习率破坏解码器又学不动。解决分层学习率编码器 1e-4、解码器 1e-3配 cosine 退火。如果还震荡加 warmup前 3 个 epoch 学习率从 1e-6 线性升到设定值。5.4 边界预测毛刺多Dice 不低但临床不可用现象Dice 能到 0.88但预测掩码边界锯齿严重小突起和凹陷被抹平。原因损失函数只用了 BCE模型对边界像素的权重不够或者解码器最后上采样用了转置卷积产生棋盘效应。解决损失函数换成 Dice BCE 组合Dice 权重 0.5 到 0.7上采样改用双线性插值 卷积避免转置卷积的棋盘伪影。后处理可以加条件随机场CRF或简单的形态学闭运算但别过度否则小病变会被吞掉。5.5 显存溢出发生在验证阶段而不是训练阶段现象训练时 batch size 8 能跑验证时 batch size 8 就 OOM。原因验证时没有torch.no_grad()计算图还在构建或者验证时忘了把模型切到eval()BatchNorm 的 running stats 还在更新占用额外显存。解决验证循环包在with torch.no_grad():里模型调model.eval()验证完再model.train()切回来。如果还 OOM验证 batch size 减半。6. 把 Dice 从 0.85 推到 0.90 的三个进阶技巧6.1 测试时增强TTA的叠加方式与阈值选择TTA 的思路是推理时对同一张图做多种变换水平翻转、垂直翻转、90 度旋转分别预测后再把结果逆变换回原空间取平均。这样能平滑模型对局部噪声的敏感度Dice 通常能涨 1 到 2 个点。但 TTA 不是越多越好——8 种变换全用上推理时间翻 8 倍收益可能只有 0.5 个点。我一般用 4 种原图、水平翻转、垂直翻转、水平垂直翻转。def predict_with_tta(model, img_tensor): model.eval() preds [] with torch.no_grad(): preds.append(torch.sigmoid(model(img_tensor))) preds.append(torch.flip(torch.sigmoid(model(torch.flip(img_tensor, [3]))), [3])) preds.append(torch.flip(torch.sigmoid(model(torch.flip(img_tensor, [2]))), [2])) preds.append(torch.flip(torch.sigmoid(model(torch.flip(img_tensor, [2, 3]))), [2, 3])) return torch.stack(preds, dim0).mean(dim0)torch.flip的第二个参数是维度列表[3]是宽[2]是高。翻转后预测再翻回来保证空间对齐。平均后的概率图再按 0.5 阈值二值化。如果你发现 TTA 后 Dice 反而降了检查翻转维度有没有搞错——宽和高翻反了预测图就对不上。6.2 用伪标签把测试集利用起来ISIC2018 的测试集没有公开掩码但你可以用训练好的模型在测试集上预测挑置信度高的样本比如预测概率在 0.1 以下或 0.9 以上的像素占比超过 95%作为伪标签加入训练集重新训练。这个过程可以迭代两到三轮每轮用上一轮的最佳模型生成伪标签。注意伪标签样本的损失权重设低一点0.3 到 0.5避免噪声主导梯度。6.3 模型集成不同骨干和不同随机种子的加权平均单模型 Dice 到 0.88 左右后继续调参收益递减。这时候用集成更划算训三个模型分别用 ResNet34、EfficientNet-B0、ResNet50 做编码器各自跑一遍训练推理时把三个模型的概率图加权平均。权重可以按验证集 Dice 分配比如 0.4、0.35、0.25。集成后 Dice 通常能到 0.90 到 0.92但推理成本翻三倍。如果部署环境算力有限可以只集成两个差异最大的模型。策略验证 Dice推理耗时单张 384×384适用场景单模型 ResNet340.8712ms移动端/实时 TTA 4 变换0.8948ms服务端批量三模型集成0.9136ms离线筛查三模型 TTA0.92144ms科研/竞赛这张表是我在 2080Ti 上跑出来的参考值实际数字随硬件和实现细节浮动。选哪个方案取决于你的延迟容忍度——如果做移动端 App单模型加量化就够了如果是云端批量处理三模型集成加 TTA 能把精度拉满。我自己踩过最深的坑是过早追求集成单模型还没调稳就训了五个模型做平均结果每个都欠拟合集成完 Dice 还不如好好训一个。后来养成习惯先把单模型 Dice 推到验证集不再涨再考虑 TTA最后才上集成。这个顺序能帮你省下大量算力和时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表