☰
ISIC2018皮肤镜分割实战:U-Net调优与边界后处理技巧
2026/10/11 15:52:22 网站建设 项目流程

简介:这份资源面向医学图像分割初学者与深度学习实践者,围绕ISIC2018皮肤病变(黑色素瘤)分割任务,提供基于U-Net与Mask R-CNN两套模型的完整实现方案,帮助读者理解病灶区域像素级分割的建模思路与评估方法。压缩包共14个文件,约45.65MB,包含3个Jupyter Notebook(分别对应U-Net与Mask R-CNN训练流程)、4个Python脚本、1个已训练模型权重文件hdf5,以及2张结果图、2份说明文档和许可证等辅助文件,便于直接复现与二次开发。其中U-Net方案给出损失0.147、精度0.946、Jaccard距离0.723、灵敏度0.878、特异性0.97等指标,可作为性能对照基准。资源还附带Group Normalization相关实现代码,方便读者对比不同归一化策略对分割效果的影响。目前已有3476人学习下载,适合希望快速上手皮肤病变分割、积累医学影像项目经验的学习者参考。

1. 皮肤镜图像里,黑色素瘤为什么总被 CNN 分割成“毛边”

皮肤镜拍下来的病变区域,边界往往不是一条干净的线。色素沉着从中心向外渐变,边缘还混着毛发、气泡、标尺刻度,甚至医生用笔画的标记。ISIC2018 这个数据集把这类图像和对应的分割掩码放在一起,任务就是让 CNN 学会逐像素判断:这个像素属于病变,还是属于正常皮肤。黑色素瘤的早期筛查高度依赖边界形态,边界分割差几个像素,ABCD 规则里的“不对称”和“边缘不规则”评估就会跟着偏。

这个方向适合两类人:一类是想找一个医学图像分割的完整练手项目,把 U-Net 系列模型从论文落到代码;另一类是做皮肤镜辅助诊断的工程师,需要一套可复现的基线,知道 Dice 能到多少、哪里容易翻车。ISIC2018 的公开掩码质量在同类数据集里算比较扎实的,图像数量也够跑出一个有说服力的结果。但它的坑不在模型结构,而在数据本身——掩码二值化阈值、图像分辨率、训练集里病变大小分布极不均匀,这些细节不处理,CNN 分割出来的结果就会像被狗啃过一样。

2. ISIC2018 数据到手后,先做三件比选模型更重要的事

2.1 掩码不是非黑即白,二值化阈值决定 Dice 上限

ISIC2018 的分割掩码是灰度 PNG,病变区域是 255,背景是 0,但边缘过渡带存在中间灰度值。如果你直接除以 255 再四舍五入,边缘像素会被随机归到某一类,训练时模型学到的边界就是抖动的。常见做法是设一个阈值,比如大于 127 算病变,小于等于 127 算背景。这个阈值不是拍脑袋,你可以统计掩码的灰度直方图,看过渡带有多宽。

import numpy as np from PIL import Image def load_mask(mask_path, threshold=127): """读取 ISIC2018 掩码并二值化 threshold: 灰度阈值,大于该值判为病变 返回: 0/1 的 uint8 数组 """ mask = np.array(Image.open(mask_path).convert('L')) binary = (mask > threshold).astype(np.uint8) return binary # 统计一张掩码的灰度分布,确认阈值是否合理 mask = np.array(Image.open('ISIC_0000001_segmentation.png').convert('L')) vals, counts = np.unique(mask, return_counts=True) for v, c in zip(vals, counts): if v not in (0, 255): print(f'灰度 {v}: {c} 像素')

逻辑说明:convert('L')保证读进来是单通道,避免某些 PNG 带 alpha 通道导致形状对不上。阈值 127 是中间值,实际项目里我会先跑一遍统计,如果过渡带像素占比超过 2%,就把阈值往病变一侧调,比如 100,让边界稍微收紧。参数threshold没有绝对标准,但一旦定了,训练集、验证集、测试集必须用同一个值,否则评估结果没有可比性。

2.2 图像和掩码必须同步做几何变换,否则边界直接错位

数据增强是分割任务里最容易翻车的地方。你给图像做随机旋转 30 度,掩码也必须旋转同样的角度和中心点。用 Albumentations 这类库时,要把 image 和 mask 放进同一个 transform 调用,不能分开写。另外,ISIC2018 原图尺寸不统一,常见做法是先缩放到 256x256 或 384x384。缩放时图像用双线性插值,掩码必须用最近邻插值,否则掩码边缘会出现本不存在的灰度值。

import albumentations as A import cv2 # 训练增强:图像和掩码同步变换 train_transform = A.Compose([ A.Resize(256, 256, interpolation=cv2.INTER_LINEAR), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=30, p=0.5, border_mode=cv2.BORDER_CONSTANT), ]) # 验证集只做 resize,不做随机增强 val_transform = A.Compose([ A.Resize(256, 256, interpolation=cv2.INTER_LINEAR), ]) def apply_transform(image, mask, transform): """image: RGB uint8, mask: 0/1 uint8""" augmented = transform(image=image, mask=mask) return augmented['image'], augmented['mask']

逻辑说明:A.Compose里所有几何变换会同时作用于 image 和 mask,这是 Albumentations 的设计。ShiftScaleRotate的border_mode用BORDER_CONSTANT,填充值默认是 0,对掩码来说就是背景,合理。注意Resize的插值方式对 image 和 mask 是分开控制的,Albumentations 内部对 mask 会自动用最近邻,但显式写出来更保险。参数scale_limit=0.1表示缩放范围 0.9 到 1.1,太大可能把整个病变裁掉,ISIC2018 里小病变不少,这个值别超过 0.15。

2.3 病变面积占比差异极大,采样策略比损失函数更先影响收敛

ISIC2018 训练集里,有些图像病变占画面 60% 以上,有些不到 5%。如果按图像随机采样,小病变图像被抽到的概率和大病变一样,但小病变对梯度的贡献小,模型会偏向预测背景。常见做法是过采样小病变图像,或者用 WeightedRandomSampler 按病变面积倒数给权重。另一个思路是在损失函数里给正样本更高权重,但那是第二步,采样先做对,收敛曲线会稳很多。

import os import numpy as np from torch.utils.data import WeightedRandomSampler def compute_sample_weights(mask_dir, mask_files): """按病变面积倒数计算采样权重 病变越小,权重越大,被抽到的概率越高 """ weights = [] for f in mask_files: mask = np.array(Image.open(os.path.join(mask_dir, f)).convert('L')) lesion_ratio = (mask > 127).sum() / mask.size # 加 0.01 防止除零,病变占比越小权重越大 w = 1.0 / (lesion_ratio + 0.01) weights.append(w) weights = np.array(weights) return weights / weights.sum() # 配合 DataLoader 使用 # sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) # loader = DataLoader(dataset, batch_size=16, sampler=sampler)

逻辑说明:lesion_ratio是病变像素占全图比例,加 0.01 是防止某些掩码全黑导致除零。权重归一化后传给WeightedRandomSampler,replacement=True表示有放回采样,小病变图像会被重复抽到。参数num_samples一般设成训练集大小,太大容易过拟合小病变。这个策略在 ISIC2018 上通常能把小病变的 Dice 从 0.6 出头拉到 0.75 左右,代价是大病变的 Dice 可能掉一两个点,整体更均衡。

3. 用 U-Net 跑通 ISIC2018 分割:从编码器到 Dice 评估的完整链路

3.1 编码器选 ResNet34 还是从零搭,看你的 GPU 和耐心

U-Net 是 ISIC2018 分割最常用的基线,编码器可以用预训练的 ResNet34,也可以从零搭四层卷积。预训练编码器收敛快,通常 30 个 epoch 就能到 Dice 0.85 以上;从零搭需要 80 到 100 个 epoch,但显存占用小,适合 8GB 以下的卡。我一般先用 ResNet34 跑一个基线,确认数据管道没问题,再换轻量编码器做对比。注意预训练权重是在 ImageNet 上训的,输入要归一化到 ImageNet 的均值和方差,否则预训练的优势发挥不出来。

import torch import torch.nn as nn import torchvision.models as models class UNetResNet34(nn.Module): def __init__(self, num_classes=1): super().__init__() # 用 ResNet34 的前几层做编码器 resnet = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1) self.encoder0 = nn.Sequential(resnet.conv1, resnet.bn1, resnet.relu, resnet.maxpool) # 1/4 self.encoder1 = resnet.layer1 # 1/4 self.encoder2 = resnet.layer2 # 1/8 self.encoder3 = resnet.layer3 # 1/16 self.encoder4 = resnet.layer4 # 1/32 # 解码器:上采样 + 卷积 self.up4 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec4 = self._block(512, 256) self.up3 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec3 = self._block(256, 128) self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec2 = self._block(128, 64) self.up1 = nn.ConvTranspose2d(64, 32, 2, stride=2) self.dec1 = self._block(64, 32) self.final = nn.Conv2d(32, num_classes, 1) def _block(self, in_ch, out_ch): return 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): e0 = self.encoder0(x) e1 = self.encoder1(e0) e2 = self.encoder2(e1) e3 = self.encoder3(e2) e4 = self.encoder4(e3) d4 = self.dec4(torch.cat([self.up4(e4), e3], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e2], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e1], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e0], dim=1)) return self.final(d1)

逻辑说明:encoder0包含 conv1、bn1、relu、maxpool,输出是 1/4 分辨率,所以后面拼接时 e0 和 d1 的空间尺寸要对上。ConvTranspose2d的 kernel_size 和 stride 都设 2,正好把特征图放大一倍。_block里两个 3x3 卷积加 BN 和 ReLU,是 U-Net 解码器的标准配置。参数num_classes=1表示二分类分割,输出通道为 1,后面接 Sigmoid。如果显存不够,把encoder4换成encoder3的输出,少一层下采样,Dice 大概掉 0.02 到 0.03。

3.2 损失函数用 Dice + BCE 组合,单用 BCE 在小病变上会摆烂

二分类分割常用 BCEWithLogitsLoss,但 ISIC2018 正负样本极不平衡,单用 BCE 模型会倾向于全预测背景,Dice 卡在 0.5 左右。常见做法是 BCE 和 Dice Loss 按权重相加,比如 0.5 比 0.5。Dice Loss 直接优化重叠区域,对小病变更敏感。注意 Dice Loss 在预测全空时梯度不稳定,加一个平滑项smooth=1.0能缓解。

class DiceBCELoss(nn.Module): def __init__(self, bce_weight=0.5, smooth=1.0): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.bce_weight = bce_weight self.smooth = smooth def forward(self, logits, targets): # logits: (B,1,H,W) 未过 Sigmoid # targets: (B,1,H,W) 0/1 bce_loss = self.bce(logits, targets) probs = torch.sigmoid(logits) probs = probs.view(probs.size(0), -1) targets = targets.view(targets.size(0), -1) intersection = (probs * targets).sum(dim=1) dice = (2. * intersection + self.smooth) / ( probs.sum(dim=1) + targets.sum(dim=1) + self.smooth) dice_loss = 1 - dice.mean() return self.bce_weight * bce_loss + (1 - self.bce_weight) * dice_loss

逻辑说明:BCEWithLogitsLoss内部做 Sigmoid,所以传 logits 不要先过 Sigmoid。Dice 计算前把 probs 和 targets 展平成 (B, H*W),按样本算 Dice 再取平均,避免大病变主导。smooth=1.0在分母和分子都加,防止预测全零时除零。参数bce_weight我一般从 0.5 开始调,如果验证集 Dice 波动大,降到 0.3 让 Dice Loss 占主导;如果训练早期 loss 不降,升到 0.7 让 BCE 先稳住。

3.3 评估不只看 Dice,边界距离指标才能暴露“毛边”问题

Dice 衡量重叠面积,但对边界偏移不敏感。两个掩码 Dice 0.9,边界可能差十几个像素。ISIC2018 分割任务里,边界质量直接影响黑色素瘤的形态评估,所以我会同时看 Hausdorff Distance 和 Average Symmetric Surface Distance。Hausdorff 对离群点敏感,能抓出模型在某个角落预测出一小块孤立病变的情况。计算这些指标用medpy或surface-distance库,注意输入必须是二值数组。

from medpy.metric.binary import hd95, asd import numpy as np def evaluate_batch(preds, targets, threshold=0.5): """preds: 模型输出概率 (B,1,H,W) numpy targets: 0/1 掩码 (B,1,H,W) numpy """ preds_bin = (preds > threshold).astype(np.uint8) targets_bin = targets.astype(np.uint8) dice_list, hd_list, asd_list = [], [], [] for p, t in zip(preds_bin, targets_bin): p, t = p.squeeze(), t.squeeze() if p.sum() == 0 and t.sum() == 0: dice_list.append(1.0) continue if p.sum() == 0 or t.sum() == 0: dice_list.append(0.0) continue inter = (p & t).sum() dice = 2. * inter / (p.sum() + t.sum()) dice_list.append(dice) hd_list.append(hd95(p, t)) asd_list.append(asd(p, t)) return np.mean(dice_list), np.mean(hd_list), np.mean(asd_list)

逻辑说明:hd95取 95 分位数,比最大 Hausdorff 更鲁棒,避免单个离群像素拉高整体。asd是平均表面距离,单位是像素,越小越好。预测全空且标签全空时 Dice 记 1.0,这是合理的。参数threshold=0.5是概率转二值的默认值,如果验证集上 Dice 对阈值敏感,可以在 0.4 到 0.6 之间扫一遍,选 Dice 最高的。注意medpy的hd95要求输入是 bool 或 0/1,且不能全空,所以前面要加判断。

4. 训练 ISIC2018 分割模型时,这五个坑我踩过不止一次

4.1 验证集 Dice 突然掉到 0.3,检查掩码是不是被 resize 成了全黑

现象:训练 loss 正常下降,训练集 Dice 到 0.9,验证集 Dice 在某个 epoch 突然掉到 0.3 以下,之后再也上不去。原因:验证集 transform 里Resize对掩码用了双线性插值,小病变在缩放后灰度值被拉低到 127 以下,二值化后变成全黑。解决:掩码 resize 必须用最近邻插值,Albumentations 里显式写A.Resize(256, 256, interpolation=cv2.INTER_NEAREST)只对 mask 生效,或者用A.Compose的additional_targets把 mask 单独处理。

4.2 训练到一半 loss 变 NaN,Dice Loss 的平滑项没加够

现象:前 10 个 epoch 正常,第 11 个 epoch loss 突然变成 NaN,之后所有输出都是 0.5。原因:Dice Loss 在某个 batch 里预测全零,分母probs.sum() + targets.sum()接近零,除零导致梯度爆炸。解决:smooth从 1.0 加到 10.0,或者在 Dice 计算前给 probs 加一个极小值1e-6。另外,梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)也能兜底。

4.3 模型把标尺和毛发也分割成病变,增强里少了颜色扰动

现象:预测掩码里出现细长条状区域,对应原图的标尺刻度或毛发。原因:训练集里标尺和毛发总是和病变同时出现,模型学到了虚假相关。解决:增强里加A.HueSaturationValue和A.RandomBrightnessContrast,让模型对颜色和亮度不敏感;另外加A.CoarseDropout随机遮挡小块区域,强迫模型看整体形状而不是局部纹理。参数上,HueSaturationValue的 hue_shift_limit 设 10 到 20,太大颜色失真。

4.4 单卡 batch size 太小,BatchNorm 统计量不准

现象:batch size 设 4 或 8 时,验证集 Dice 比训练集低 0.1 以上,且波动大。原因:BatchNorm 在 batch 太小时,均值和方差估计不准,训练和推理行为不一致。解决:把 BatchNorm 换成 GroupNorm,或者用梯度累积把等效 batch size 提到 16 以上。GroupNorm 的组数设 8 或 16,对 batch size 不敏感,但收敛稍慢。如果坚持用 BatchNorm,推理时确保model.eval(),并且用训练集的 running mean 和 var。

4.5 测试集 Dice 比验证集低很多,检查图像归一化是否一致

现象:验证集 Dice 0.88,测试集只有 0.80。原因:测试集预处理用了不同的归一化参数,比如验证集用 ImageNet 均值方差,测试集用了 0.5 和 0.5。解决:把预处理参数写进配置文件,训练、验证、测试共用同一个Normalize。ISIC2018 图像是 RGB,用 ImageNet 的mean=[0.485, 0.456, 0.406]和std=[0.229, 0.224, 0.225]是常见做法,但如果你从零训练编码器,用数据集自身的均值和方差更合适。

5. 把 Dice 从 0.85 推到 0.90 的两个后处理技巧

5.1 连通域过滤:去掉面积小于 50 像素的孤立预测

模型输出二值化后,经常在背景区域出现零星的小块预测,面积几十个像素,Dice 影响不大但 Hausdorff 距离会被拉高。用cv2.connectedComponents找连通域,把面积小于阈值的区域置零。阈值怎么定?统计验证集上所有孤立小块的面积分布,取 95 分位数。ISIC2018 里病变通常占几百到几万像素,50 是一个保守的起点。

import cv2 import numpy as np def remove_small_components(binary_mask, min_area=50): """binary_mask: 0/1 uint8 去掉面积小于 min_area 的连通域 """ num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( binary_mask, connectivity=8) cleaned = np.zeros_like(binary_mask) for i in range(1, num_labels): # 0 是背景 if stats[i, cv2.CC_STAT_AREA] >= min_area: cleaned[labels == i] = 1 return cleaned

逻辑说明:connectedComponentsWithStats返回每个连通域的标签、外接矩形和面积。stats[i, cv2.CC_STAT_AREA]是第 i 个连通域的像素数。connectivity=8表示八邻域,比四邻域更宽松,适合不规则病变。参数min_area设 50 在 256x256 图像上大约对应 0.08% 的面积,再小就可能是噪声。这个后处理在验证集上通常能把 Hausdorff 距离降 20% 到 30%,Dice 提升 0.005 到 0.01。

5.2 形态学闭运算:填补病变内部的空洞

有些图像病变中心有反光或气泡,模型预测出来中间有个洞。闭运算先膨胀再腐蚀,能把小洞填上,同时不改变外边界。核大小用 3x3 或 5x5,太大边界会变形。注意闭运算只对二值掩码做,不要对概率图做。

def fill_holes(binary_mask, kernel_size=3): """闭运算填补内部空洞""" kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) closed = cv2.morphologyEx(binary_mask, cv2.MORPH_CLOSE, kernel) return closed

逻辑说明:MORPH_ELLIPSE比矩形核更贴合病变的圆形形态。kernel_size=3对 256x256 图像够用,如果空洞较大可以试 5。闭运算后如果边界外扩,可以再跟一个开运算,但开运算可能把细小边界磨掉,ISIC2018 里不推荐。这两个后处理顺序是先闭运算填洞,再连通域过滤去小块,顺序反了可能把填好的洞又当成小块去掉。

我自己的习惯是:每次改完后处理,都在验证集上重新算一遍 Dice、Hausdorff 和 ASD,三个指标一起看,只涨 Dice 但 Hausdorff 变差的参数直接弃用。ISIC2018 这个数据集不大,但边界质量对黑色素瘤评估的意义远大于整体重叠率,别为了刷 Dice 把边界搞毛。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询