简介:这份资源面向医学图像分割方向的初学者与进阶开发者,围绕ISIC2018皮肤病变(黑色素瘤)数据集,提供基于U-Net与Mask R-CNN两种主流架构的完整分割实现,帮助读者理解病灶区域提取的建模思路与评估方法。压缩包共14个文件,约45.65MB,包含3个Jupyter Notebook用于分步实验、4个Python脚本支撑模型与训练流程、1个hdf5权重文件可直接加载推理,另有png结果图、md说明文档、license及gitignore等辅助内容。其中U-Net方案给出损失0.147、精度0.946、Jaccard距离0.723、灵敏度0.878、特异性0.97等指标,便于横向对比模型表现。目前已有3476人学习下载,适合希望快速复现皮肤病变分割基线、并在此基础上调参改进的读者参考。
1. 皮肤病变分割这件事,为什么 ISIC2018 是绕不开的起点
如果你正在找一套能跑通、能复现、还能写进论文或产品原型的皮肤病变分割方案,ISIC2018 大概率是你第一个撞上的数据集。它来自国际皮肤影像协作组织,包含数千张皮肤镜图像,每张都配了专家标注的病变区域掩码,任务本质是二分类语义分割:把黑色素瘤等病变从正常皮肤里抠出来。这件事的临床价值很直接——病变边界清不清楚,直接影响后续的良恶性判断和切除范围规划。但真正动手你会发现,难点不在模型结构,而在数据极不平衡、边界模糊、毛发干扰和图像分辨率参差。CNN 卷积神经网络在这里不是万能钥匙,它更像一把需要反复调校的手术刀。这篇笔记按我实际做过的路径,从数据准备、模型选型、训练策略到推理后处理,把能抄的代码和会翻车的地方都摊开讲。适合已经会 PyTorch 基础、想快速把 ISIC2018 跑出可用指标的工程师和研究生。
2. 把 ISIC2018 读进内存:数据清洗与增强的四个关键决策
2.1 先搞清楚你拿到的 ISIC2018 到底长什么样
ISIC2018 官方发布的任务分三块:病变分割、病变属性分类和疾病分类。我们只取分割任务,图像是 JPEG 或 PNG,掩码是单通道二值图,白色为病变,黑色为背景。常见做法是只保留掩码非空且病变面积占比大于 1% 的样本,否则大量小目标会让损失函数被背景淹没。我一般会先写一个统计脚本,把每张图的病变像素占比算出来,画个直方图,你会看到大量样本集中在 5% 到 30% 之间,但尾部有低于 1% 的极端值。这些极端值不是不能要,而是要在采样时给它们更高权重,或者直接剔除,取决于你的指标容忍度。
import os import numpy as np from PIL import Image from tqdm import tqdm def analyze_mask_ratio(image_dir, mask_dir): ratios = [] for fname in tqdm(os.listdir(mask_dir)): mask_path = os.path.join(mask_dir, fname) mask = np.array(Image.open(mask_path).convert('L')) # 二值化,阈值 127 是常见做法,因为标注边缘有灰度过渡 binary = (mask > 127).astype(np.uint8) ratio = binary.sum() / binary.size ratios.append(ratio) ratios = np.array(ratios) print(f"病变占比均值: {ratios.mean():.4f}") print(f"低于 1% 的样本数: {(ratios < 0.01).sum()}") print(f"高于 50% 的样本数: {(ratios > 0.5).sum()}") return ratios这段代码的逻辑很直白:遍历掩码目录,转灰度、二值化、算占比。参数上唯一需要留意的是二值化阈值,ISIC2018 的掩码边缘有抗锯齿产生的灰度值,用 127 是经验值,如果你发现边缘被吞掉,可以降到 100 试试。输出结果帮你决定后续是剔除还是加权。
2.2 图像增强不是越多越好,这四类最稳
皮肤镜图像增强有个反直觉的点:颜色抖动和随机旋转确实有用,但过度使用弹性形变会让边界变得更模糊,反而拉低 Dice。我固定用四类增强:随机水平垂直翻转、±15 度旋转、亮度对比度微调、以及随机裁剪后缩放回原尺寸。前三个不用解释,第四个是为了让模型见过不同尺度的病变。注意不要用 Cutout 或 Random Erasing,因为病变区域被遮挡后,模型会学到错误的背景关联。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.Rotate(limit=15, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), A.RandomResizedCrop(height=256, width=256, scale=(0.8, 1.0), p=0.5), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2() ])这里 Normalize 用的 ImageNet 均值方差,是因为我们后面用预训练编码器。RandomResizedCrop 的 scale 下限设 0.8,再低就会把病变裁掉一半,反而有害。如果你从零训练,可以把 Normalize 换成数据集自身的统计值,但差别不大。
2.3 划分训练验证集时,别让同一患者的图像跨集
ISIC2018 里同一个患者可能有多张不同角度的图像,如果随机划分,验证集里会出现训练集见过的患者,指标虚高。常见做法是按患者 ID 分组划分,官方没有直接给患者 ID,但图像文件名里通常有规律,或者你可以用图像哈希做近似去重。我一般会先算感知哈希,把相似度高于阈值的图像聚成一簇,再按簇划分。这一步多花二十分钟,但能避免你被虚高的验证 Dice 骗到。
import imagehash from PIL import Image from collections import defaultdict def group_by_hash(image_dir, threshold=5): hash_dict = defaultdict(list) for fname in os.listdir(image_dir): img = Image.open(os.path.join(image_dir, fname)) h = imagehash.phash(img) hash_dict[str(h)].append(fname) # 简单合并相似哈希,实际可用并查集 groups = list(hash_dict.values()) return groups感知哈希的阈值 5 是经验值,越小越严格。分组后按 8:2 划分,确保同一组只出现在一个集合里。
2.4 数据加载器里必须处理的类别不平衡
病变区域通常只占图像 10% 到 20%,背景占大头。如果损失函数用普通交叉熵,模型会倾向于全预测背景,Dice 照样能到 0.7 以上,但边界一塌糊涂。我一般用 Dice Loss 加 BCE 的混合损失,再在 DataLoader 里用 WeightedRandomSampler 给病变占比高的样本更高采样概率。注意采样权重不要设得太极端,否则小病变样本被反复采样,模型过拟合。
from torch.utils.data import WeightedRandomSampler import torch def make_sampler(ratios): # ratios 是每个样本的病变占比 weights = 1.0 / (torch.tensor(ratios) + 0.01) sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) return sampler权重取倒数加 0.01 是防止除零,这个平滑项让占比 0.5 和 0.01 的样本权重差距在百倍以内,不会失控。
3. CNN 分割网络选型:U-Net 还是 DeepLab,编码器怎么换
3.1 U-Net 仍然是 ISIC2018 上最稳的基线
如果你只跑一个模型,选 U-Net。它的跳跃连接对皮肤病变这种边界模糊的目标特别友好,浅层特征直接送到解码器,边缘细节保留得比纯编码器-解码器结构好。原始 U-Net 用 5 层下采样,输入 256×256 时感受野足够覆盖大部分病变。我试过把深度加到 6 层,Dice 没涨,参数量翻倍,推理变慢,不划算。常见做法是保持 5 层,每层两个 3×3 卷积加 BN 和 ReLU,下采样用最大池化。
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, 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)DoubleConv 是 U-Net 的基本砖块,padding=1 保证尺寸不变,BN 加速收敛。注意 inplace=True 能省一点显存,但在某些版本里会影响梯度,如果遇到奇怪报错就改成 False。
3.2 把编码器换成 ResNet 或 EfficientNet 的实操细节
从零训练 U-Net 在 ISIC2018 上大概能到 0.85 Dice 左右,但如果你用 ImageNet 预训练的 ResNet34 做编码器,同样训练轮数能到 0.89 以上。换编码器的关键是处理下采样倍率和通道数对齐。ResNet34 有四个 stage,输出通道分别是 64、128、256、512,空间下采样 4、8、16、32 倍。U-Net 解码器要对应上采样并拼接。我一般用 segmentation_models_pytorch 库,一行代码换编码器,但要注意它的预处理和你的增强是否一致。
import segmentation_models_pytorch as smp model = smp.Unet( encoder_name="resnet34", encoder_weights="imagenet", in_channels=3, classes=1, activation=None # 输出 logits,损失函数里再 sigmoid )activation 设 None 是因为混合损失里 BCEWithLogitsLoss 自带 sigmoid,如果这里再 sigmoid 会重复。encoder_weights 用 imagenet 预训练,如果你做的是多通道输入比如加上皮肤镜的偏振信息,就把 in_channels 改掉,但预训练权重只能复制前三个通道,其余随机初始化。
3.3 DeepLabV3+ 在边界上的表现与代价
DeepLabV3+ 用空洞卷积扩大感受野,对大面积病变的整体形状把握更好,但边界细节不如 U-Net。我在 ISIC2018 上对比过,DeepLabV3+ 的 Dice 和 U-Net 差不多,但 Hausdorff 距离差 2 到 3 个像素,意味着边界更毛糙。如果你后续要做边界精确测量,U-Net 更合适;如果只做区域分类,DeepLabV3+ 可以接受。它的参数量也更大,推理速度慢 30% 左右,移动端部署要慎重。
3.4 损失函数组合:Dice + BCE + 边界损失
单独 Dice Loss 在极端不平衡时梯度不稳定,单独 BCE 又容易被背景主导。我固定用 0.5×BCE + 0.5×Dice,再加一个 0.1 权重的边界损失。边界损失用掩码的形态学梯度提取边缘,然后算预测边缘和真实边缘的 L1 距离。这个组合在 ISIC2018 上比纯 Dice 提升约 1.5 个点 Dice,边界更干净。
import torch import torch.nn as nn import torch.nn.functional as F class BCEDiceLoss(nn.Module): def __init__(self, bce_weight=0.5, dice_weight=0.5): super().__init__() self.bce_weight = bce_weight self.dice_weight = dice_weight def forward(self, pred, target): bce = F.binary_cross_entropy_with_logits(pred, target) pred_sigmoid = torch.sigmoid(pred) intersection = (pred_sigmoid * target).sum() dice = 1 - (2 * intersection + 1e-6) / (pred_sigmoid.sum() + target.sum() + 1e-6) return self.bce_weight * bce + self.dice_weight * dice平滑项 1e-6 防止除零,Dice 的分子乘 2 是标准写法。如果你发现训练初期 loss 震荡,把 bce_weight 调到 0.7 试试。
4. 训练与推理的工程细节:从 256 到 512 的取舍
4.1 输入分辨率到底选 256 还是 512
ISIC2018 原图大小不一,常见做法是统一缩放到 256×256 或 512×512。256 训练快,显存占用小,但小病变的边界会糊掉;512 能保留更多细节,Dice 通常高 1 到 2 个点,但 batch size 只能开到 4 或 8,训练时间翻倍。我的经验是:如果你有 11GB 以上显存,直接上 512,用混合精度训练把速度拉回来;如果只有 8GB,256 加更好的增强也能接受。不要用 384 这种非 2 的幂,下采样时尺寸对不齐会报错。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, masks in train_loader: images, masks = images.cuda(), masks.cuda() optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度训练里 autocast 自动把卷积转到 FP16,GradScaler 防止梯度下溢。注意损失函数里如果有 sum 操作,最好在 autocast 外面算,或者强制转 FP32,否则 Dice 的分子可能溢出。
4.2 学习率调度和早停策略
初始学习率设 1e-4,用余弦退火降到 1e-6,训练 100 个 epoch。早停看验证集 Dice,连续 15 个 epoch 不涨就停。我一般会保存验证 Dice 最高的那个权重,而不是最后一个。注意余弦退火的周期要设成总 epoch 数,不要用 step 调度,否则后期学习率跳变会让模型震荡。
from torch.optim.lr_scheduler import CosineAnnealingLR optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6)AdamW 的 weight_decay 设 1e-4 比 Adam 的 L2 正则更稳,eta_min 不要设 0,留一点学习率让模型在后期微调。
4.3 推理时的滑动窗口与后处理
如果训练用 256 而测试图是 512,直接缩放会丢细节。常见做法是滑动窗口推理:把大图切成有重叠的 256 小块,逐块预测,再拼回去。重叠区域取平均。后处理用连通域分析去掉面积小于 50 像素的孤立预测,再填上内部空洞。这两步能提升约 0.5 个点 Dice,但不要过度,否则小病变会被误删。
import cv2 import numpy as np def postprocess(mask, min_area=50): binary = (mask > 0.5).astype(np.uint8) num, labels, stats, _ = cv2.connectedComponentsWithStats(binary) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] < min_area: binary[labels == i] = 0 # 填洞 kernel = np.ones((5,5), np.uint8) binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return binarymin_area 设 50 是经验值,如果你的图像分辨率更高,按比例放大。形态学闭操作核大小 5 能填小洞,太大就会把边界糊掉。
4.4 评估指标不只看 Dice
Dice 高不代表边界好。我固定报三个指标:Dice、IoU 和 Hausdorff 距离 95 分位。Hausdorff 对边界异常值敏感,能暴露模型在某个样本上的崩溃。如果 Hausdorff 突然很大,去查那张图是不是有毛发遮挡或者标注错误。ISIC2018 里确实有少量标注质量差的样本,不要盲目调模型。
5. 避坑与排查:ISIC2018 训练中常见的五个翻车现场
5.1 验证 Dice 很高但推理一塌糊涂
现象:训练时验证集 Dice 到 0.92,拿几张图可视化发现全黑或者全白。原因:验证集和训练集来自同一患者,模型记住了患者特征而不是病变特征。解决:按患者分组划分数据集,或者用感知哈希去重后再划分。这个坑我踩过两次,血泪经验是划分完先可视化几张验证图,确认没有和训练图长得一样的。
5.2 损失降到很低但 Dice 不涨
现象:BCE 损失从 0.5 降到 0.01,Dice 卡在 0.75。原因:模型全预测背景,因为背景占 80% 以上,BCE 被背景主导。解决:换 Dice Loss 或混合损失,加 WeightedRandomSampler。检查方法是算一下预测掩码的病变占比,如果接近 0,就是这个问题。
5.3 训练到一半 loss 变成 NaN
现象:前 20 个 epoch 正常,突然 loss 变 NaN。原因:混合精度训练里 Dice 的除法没有加平滑项,或者学习率太大导致梯度爆炸。解决:在 Dice 分母加 1e-6,把学习率降到 1e-5 再试,加梯度裁剪 max_norm=1.0。如果还不行,检查数据里有没有全黑掩码,全黑样本会让 Dice 分母为 0。
5.4 推理结果边界呈锯齿状
现象:预测掩码边缘像楼梯。原因:上采样用了最近邻插值,或者输入分辨率太低。解决:解码器最后上采样用双线性插值,训练分辨率提到 512。如果显存不够,至少推理时用滑动窗口在 512 上跑。
5.5 换编码器后指标反而下降
现象:ResNet34 换 EfficientNet-B3,Dice 从 0.89 掉到 0.85。原因:EfficientNet 的预处理均值方差和 ImageNet 不同,或者下采样倍率没对齐导致跳跃连接错位。解决:检查 encoder 的预处理参数,用 smp 库时它会自动处理,但如果你自己写加载器,要手动对齐。另外 EfficientNet 的 stage 输出通道和 U-Net 解码器不匹配时,加 1×1 卷积调整通道。
6. 把 Dice 从 0.89 推到 0.92 的三个进阶技巧
第一个技巧是测试时增强。推理时对同一张图做水平翻转、垂直翻转和原图三次预测,取平均。这个操作不增加训练成本,推理时间乘三,但 Dice 通常能涨 0.5 到 1 个点。注意只做几何变换,不要做颜色抖动,因为颜色抖动会改变病变的视觉特征,平均后反而模糊。
def tta_predict(model, image): preds = [] preds.append(torch.sigmoid(model(image))) preds.append(torch.flip(torch.sigmoid(model(torch.flip(image, [3]))), [3])) preds.append(torch.flip(torch.sigmoid(model(torch.flip(image, [2]))), [2])) return torch.stack(preds).mean(dim=0)翻转维度 3 是宽度,2 是高度,取决于你的张量布局。平均后再二值化,边界更平滑。
第二个技巧是伪标签半监督。用训练好的模型在测试集上预测,挑置信度高于 0.95 的样本加入训练集,重新训练一轮。ISIC2018 的测试集没有公开标注,但你可以用验证集做这个实验。我试过加入 200 张高置信伪标签,Dice 涨了 0.8 个点。注意伪标签的损失权重设 0.5,不要和真实标签同等对待。
第三个技巧是模型集成。训练三个不同编码器的 U-Net:ResNet34、EfficientNet-B3、DenseNet121,推理时取平均。三个模型 Dice 都在 0.89 左右,集成后能到 0.92。代价是推理显存乘三,如果部署在服务器上可以接受,移动端就别想了。集成时注意三个模型的输出都要先 sigmoid 再平均,不要平均 logits。
最后一个习惯:每次实验都固定随机种子,记录数据划分、增强参数、学习率和损失权重。我见过太多人调了半天发现是数据划分变了。ISIC2018 不大,但细节多,把配置写进 YAML 文件,跑之前检查一遍。希望帮到你。
本文还有配套的精品资源,点击获取