简介:面向医学图像分割学习者与皮肤病变识别研究者的 ISIC2018 实战包,围绕黑色素瘤病灶分割任务,提供 U-Net 与 Mask R-CNN 两套可运行方案,适合具备一定深度学习基础、希望复现论文思路或完成课程项目的读者。压缩包共 14 个文件,约 45.65MB,以 Jupyter Notebook 承载训练与推理流程,辅以 Python 脚本、PNG 结果图、Markdown 说明、HDF5 模型权重等,目录简洁,便于按模型路径逐一对照。已有 3476 人学习,说明其作为入门到进阶案例具备较高参考价值。读者可从中获取完整的数据处理、模型搭建、训练评估与可视化思路,并参考 U-Net 的损失 0.147、精度 0.946、Jaccard 距离 0.723、灵敏度 0.878、特异性 0.97 等指标,理解分割任务的评价方式;Mask R-CNN 部分则可拓展实例分割视角,适合用作实验复现、结果对比与排错参考。
1. 边界只差几个灰度:皮肤病变分割要解决的到底是什么
皮肤镜图像里,病灶与周围正常皮肤的过渡带往往只差几个灰度级,标注医生要放大三倍、来回比对色阶才敢落笔,这也决定了黑色素瘤分割不是“画个圈”的活。这套基于 ISIC2018 数据集的 CNN 皮肤病变分割方案,目标是把 2594 张训练图像里专家标注掩膜的那套判断口径,压缩成一个能在秒级给出概率图的模型:输入一张原始皮肤镜图,输出与原图同尺寸的二值掩膜,边界误差落在可复核的区间内。整套流程跑在 Jupyter Notebook 里,从数据清点、增强、训练到推理评估按单元格分段推进,每一段都能单独回看中间结果,适合要交毕设或课设、需要一套能跑通并讲清楚的学生,也适合手上有数据、想先拿到一个可复现基线再往上叠模块的从业者。
2. 数据管线设计:2594 张皮肤镜图怎么喂进网络
分割任务里模型能学到什么,八成在数据管线阶段就定死了。后面网络再花哨,如果掩膜在缩放时被插值成灰阶、图像和掩膜的几何变换不同步、或者是把整张图的均值方差算进去做归一化,验证集 Dice 掉十几个点是常事。这一章把数据从目录到张量的每一步拆开讲,Notebook 里对应的是三个单元格:清点、可视化抽检、构建 Dataset。
2.1 目录形态与标注口径
ISIC2018 的任务一给的是两套平行目录:图像侧是彩色皮肤镜照片,掩膜侧是同名加_segmentation后缀的二值 PNG,前景 255、背景 0。官方划分通常是 2594 张训练图、100 张验证图、1000 张测试图,测试集标注在比赛期间不公开,所以本地做方案验证时,多数人的做法是从 2594 张训练图里再切一份自己的验证与测试,官方那 100 张验证只用来做最后的横向对齐。
抽检这一步别省。我一般会在 Notebook 里随机抽 12 组“原图 + 掩膜叠加”,人眼过一遍,重点看三件事:掩膜是不是纯二值、有没有整张全黑的空标注、病灶是不是被掩膜完整包住。空掩膜样本在 ISIC 里是存在的,直接算进 Dice 会把指标拉低,训练时也会给模型一个“这里什么都别输出”的错误信号。清点阶段顺手统计每张掩膜的前景占比,输出一个直方图,后面判断不均衡程度、决定要不要加权采样全靠它。
2.2 预处理:尺寸归一、颜色恒常性与掩膜对齐
原始皮肤镜图分辨率并不统一,长边常见在七八百到一千像素之间。基线的做法是统一 resize 到 256×256。这里有个取舍:256 训练快、显存友好,但直径不到 30 像素的小病灶会被缩成一小撮像素,边界信息基本丢失。如果数据里小病灶占比不低,改用 384×384 或者“训练 256、推理 384”的组合更稳,代价是训练时间大约翻倍。
缩放时图像用双线性、掩膜必须用最近邻。双线性插值会在边界生成 0 到 255 之间的中间灰度,如果你按mask/255直接当软标签用,等于给边界像素发了“半个正样本”的标签,Dice 会虚高、边界反而更糊。掩膜统一按>127二值化,是更稳妥的做法。
颜色方面,皮肤镜的拍摄光源、白平衡差异很大,同一类病灶在不同设备下色相能差出一大截。常用的补救是灰度世界假设的颜色恒常性校正,或者简单的每通道百分位拉伸。这一步不是必须的,但如果你发现模型在验证集上对某几张偏黄或偏蓝的图特别差,先别怀疑网络,回头做一次颜色归一往往就顺了。
import os import cv2 import numpy as np import torch from torch.utils.data import Dataset # ImageNet 统计量,用于迁移学习主干的输入归一化 MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) class ISICDataset(Dataset): """ISIC2018 病变分割数据集:图像与掩膜同名,掩膜后缀 _segmentation.png""" def __init__(self, img_dir, mask_dir, names, size=256, augment=None): self.img_dir = img_dir self.mask_dir = mask_dir self.names = names self.size = size self.augment = augment def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = cv2.imread(os.path.join(self.img_dir, name + ".jpg")) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread( os.path.join(self.mask_dir, name + "_segmentation.png"), cv2.IMREAD_GRAYSCALE, ) # 图像双线性、掩膜最近邻:两者插值方式必须分开指定 img = cv2.resize(img, (self.size, self.size), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (self.size, self.size), interpolation=cv2.INTER_NEAREST) # 几何增强必须同时作用于图像和掩膜,这里由外部传入的 augment 保证 if self.augment is not None: img, mask = self.augment(img, mask) img = img.astype(np.float32) / 255.0 img = (img - MEAN) / STD img = torch.from_numpy(img.transpose(2, 0, 1)) # 阈值 127 二值化,避免插值残留的灰阶被当成软标签 mask = torch.from_numpy((mask > 127).astype(np.float32)).unsqueeze(0) return img, mask这段 Dataset 里有三个参数值得按自己数据改:size决定分辨率与显存的平衡点;augment传入的是一个同时接收图像和掩膜的增强函数,图像做翻转旋转、掩膜跟着做同样的几何变换,绝不能只对图像做;MEAN/STD用的是 ImageNet 统计量,前提是主干用了 ImageNet 预训练权重,如果主干从零训练,改成训练集自身统计的均值方差更合理。
2.3 划分、增强与不均衡处理
划分要固定随机种子,并且按图像名而不是按索引切,这样 Notebook 重跑、文件顺序变了也不会串。常见比例是 8:1:1,2594 张切出约 2075 张训练、260 张验证、259 张测试。测试集在方案定稿前不要碰,这一点后面讲验收时还会再说一次。
增强的强度要克制。水平翻转、垂直翻转、±15 度旋转、轻微亮度和对比度扰动,这四类是临床上说得通的,因为皮肤镜拍摄角度本来就自由。不建议用大幅度的弹性形变和随机裁剪,病灶的形状是有诊断意义的,把它揉变形等于在教模型忽略形状特征。
不均衡主要体现在面积上:病灶前景通常只占全图的两三成,小病灶样本甚至不足百分之一。两种处理方式,简单的是在损失函数里用 Dice 类重叠度指标抵消面积差异,复杂一点的是按前景占比做加权采样。基线上我更倾向先动损失函数,采样权重留到 Dice 卡在平台期再加,否则两个变量一起调,出了问题都不知道是谁的锅。
3. 分割网络设计:U-Net 主干、跳跃连接与损失配比
皮肤病变分割本质上是像素级二分类,输入输出同尺寸,结构上没有太多悬念,U-Net 及其变体是这类任务的默认答案。但默认答案不等于抄完就能出分,通道数怎么配、跳跃连接要不要加门控、损失里 Dice 和 BCE 各占多少,这几个决定直接反映在验证曲线是平滑收敛还是一路抖。
3.1 编码器-解码器结构与通道配置
基线配置是四层下采样、四层上采样,通道数从 32 起、每层翻倍到 256,再对称回到 32,最后 1×1 卷积出单通道 logits。这个规模在 256×256 输入、8 到 16 的 batch 下,单张消费级显卡就能跑,训练一轮几十秒,方便反复试。
跳跃连接是 U-Net 的命门。编码器浅层带着边界和纹理,深层带着“这是不是病灶”的语义,直接把浅层特征拼给解码器,边界会锐利很多。但当输入里的病灶和背景对比度很低时,浅层特征里噪声也一起被拼了过去。这种情况下加一个注意力门控,用深层特征去给浅层特征算权重图,把响应弱的区域压下去,边界附近会干净一些。门控不是必选项,先跑无门控基线,看验证集上的边界误差再决定加不加,比一上来就堆模块稳妥。
上采样方式也有讲究。转置卷积容易出现棋盘格伪影,在掩膜上表现为规则的网格状毛刺;双线性上采样加一个 3×3 卷积更平,我一般默认用后者。
import torch import torch.nn as nn class ConvBlock(nn.Module): """两次 3x3 卷积 + BN + ReLU,U-Net 的基本单元""" def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x) class UNet(nn.Module): def __init__(self, in_ch=3, out_ch=1, base=32): super().__init__() chs = [base, base * 2, base * 4, base * 8] self.enc1 = ConvBlock(in_ch, chs[0]) self.enc2 = ConvBlock(chs[0], chs[1]) self.enc3 = ConvBlock(chs[1], chs[2]) self.enc4 = ConvBlock(chs[2], chs[3]) self.pool = nn.MaxPool2d(2) # 双线性上采样替代转置卷积,规避棋盘格伪影 self.up3 = nn.Sequential(nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False), nn.Conv2d(chs[3], chs[2], 3, padding=1), nn.ReLU(inplace=True)) self.dec3 = ConvBlock(chs[2] * 2, chs[2]) self.up2 = nn.Sequential(nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False), nn.Conv2d(chs[2], chs[1], 3, padding=1), nn.ReLU(inplace=True)) self.dec2 = ConvBlock(chs[1] * 2, chs[1]) self.up1 = nn.Sequential(nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False), nn.Conv2d(chs[1], chs[0], 3, padding=1), nn.ReLU(inplace=True)) self.dec1 = ConvBlock(chs[0] * 2, chs[0]) self.head = nn.Conv2d(chs[0], out_ch, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.head(d1) # 输出 logits,不在此处做 sigmoidbase=32是通道数的起点,显存富裕就调到 48 或 64,但要注意通道翻倍带来的显存增长是按平方量级感受的。head输出的是 logits 而不是概率,这个约定很重要:后面的损失函数用binary_cross_entropy_with_logits,数值上比先 sigmoid 再取对数稳定得多,推理阶段再统一加 sigmoid。
3.2 损失函数:Dice 与 BCE 的权重配比
单独用 BCE 的问题是前景像素只占少数,模型把所有像素判成背景就能拿到很低的 loss,收敛后掩膜是一片全黑。单独用 Dice 的问题是小病灶样本梯度剧烈,训练后期容易在边界上来回震荡。工程上的默认解是两者加权:Dice 负责把重叠度拉上去,BCE 负责给每个像素稳定的梯度。
权重一般取 Dice 0.5、BCE 0.5,如果发现验证集上模型倾向于“少标”,也就是敏感度明显低于特异度,把 Dice 权重提到 0.6 到 0.7;反过来如果边界外溢严重、特异度掉,就往 BCE 偏一点。平滑项smooth的作用是防止前景为空时除零,取 1.0 在 batch 内求和的口径下够用;如果你的实现是按单张图算 Dice 再平均,这个值要相应调小。
import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, dice_weight=0.5, smooth=1.0): super().__init__() self.dice_weight = dice_weight self.smooth = smooth def forward(self, logits, target): # BCE 部分走 logits 版本,避免 sigmoid 后再取对数造成的数值下溢 bce = F.binary_cross_entropy_with_logits(logits, target) prob = torch.sigmoid(logits) dims = (2, 3) inter = (prob * target).sum(dim=dims) union = prob.sum(dim=dims) + target.sum(dim=dims) dice = (2 * inter + self.smooth) / (union + self.smooth) dice_loss = 1 - dice.mean() return self.dice_weight * dice_loss + (1 - self.dice_weight) * bce这里的dims=(2, 3)表示在空间维度上求和、保留 batch 维,得到每张图一个 Dice 值再取平均,而不是把整个 batch 的前景混在一起算。两种口径在病灶大小差异大的数据集上结果差得不小,按图平均对每张图一视同仁,对小病灶更友好,代价是梯度方差大一些。
3.3 训练循环、优化器与学习率调度
优化器用 Adam,学习率 1e-4,权重衰减 1e-5。批大小在 256×256 输入下取 8 到 16,看显存。学习率调度用余弦退火或者ReduceLROnPlateau,后者更适合不确定训练轮数的情况:验证 Dice 连续 5 轮不涨就把学习率乘 0.5。
梯度裁剪这一步别省。Dice 损失在极端样本上会给出很大的梯度,一个坏 batch 就能把前面几轮训出来的权重冲掉,表现为 loss 曲线突然翘起来再也回不去。clip_grad_norm_的阈值取 5.0 左右是个稳妥的起点。
import torch import torch.nn.utils as tnu device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(base=32).to(device) criterion = DiceBCELoss(dice_weight=0.5).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=5) best_dice, best_epoch = 0.0, -1 for epoch in range(60): model.train() for img, mask in train_loader: img, mask = img.to(device), mask.to(device, dtype=torch.float32) optimizer.zero_grad(set_to_none=True) logits = model(img) loss = criterion(logits, mask) loss.backward() # 裁剪梯度范数,防止个别极端样本把已收敛的权重冲散 tnu.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() val_dice = evaluate(model, val_loader, device) # 在验证集上算前景 Dice scheduler.step(val_dice) # 只保留验证集最优权重,测试集在此之前不参与任何决策 if val_dice > best_dice: best_dice, best_epoch = val_dice, epoch torch.save(model.state_dict(), "best_unet.pth")这段循环里scheduler.step(val_dice)传的是指标不是 loss,因为ReduceLROnPlateau在mode="max"下要监控越大越好的量。保存权重的判据用验证集 Dice 而不是训练 loss,是分割任务里最基本的一条纪律。best_epoch记下来,后面分析收敛位置、判断训练轮数够不够都用得上。
4. 推理与后处理:从概率图到能交付的掩膜
模型吐出来的是一张 0 到 1 的概率图,直接按 0.5 二值化也能看,但离“能交付”还差几步。阈值定在哪、边界外的孤立小块要不要清、多尺度推理怎么融合,这几件事对最终 Dice 的影响往往比换一个主干还大,而且几乎不花钱。
4.1 滑窗推理与测试时增强
如果训练用 256、原始图分辨率更高,直接把整图缩到 256 推理会丢细节。更稳的做法是滑窗:按 256×256 的窗口、步长 128 在原始分辨率上切块,逐块推理后拼回原尺寸,重叠区域取平均。步长取窗口的一半,重叠区足够平滑边界接缝,计算量也在可接受范围。
测试时增强则是把同一张图做几次几何变换各推一遍,再把结果逆变换回来取平均。翻转和 90 度旋转是保持图像内容的变换,不改变病灶形态,适合拿来做 TTA。四路 TTA 通常能换来 0.5 到 1.5 个 Dice 点的提升,代价是推理时间翻四倍,答辩演示时够用,如果要做实时部署就只留水平翻转这一路。
@torch.no_grad() def predict_tta(model, img, use_rot=True): """img: (1,3,H,W) 已归一化张量,返回同尺寸概率图""" model.eval() outs = [] ks = [0, 1, 2, 3] if use_rot else [0] for k in ks: x = torch.rot90(img, k, dims=(2, 3)) p = torch.sigmoid(model(x)) p = torch.rot90(p, -k, dims=(2, 3)) # 逆变换回原方向再累加 outs.append(p) return torch.stack(outs, dim=0).mean(dim=0)torch.rot90的逆变换是-k,这一步写错的话,TTA 不但不涨点还会把掩膜转得乱七八糟,是这类代码里最容易翻车的一行。use_rot=False时退化成单路推理,方便对比 TTA 到底带来了多少增益,答辩时这个对比数字很有说服力。
4.2 阈值搜索与连通域清理
0.5 不是圣旨。模型在边界上的概率分布往往偏保守,最优阈值常常落在 0.4 到 0.6 之间,具体值跟损失函数配比、数据对比度都有关。做法是在验证集上扫一遍阈值,取 Dice 最高的那个,然后把这个值冻结,拿去跑测试集。
清理部分主要针对两类噪声:一是远离主病灶的孤立小块,面积小于某个像素数就丢掉;二是主病灶内部的小空洞,用形态学闭运算填掉。面积阈值不要拍脑袋,先统计验证集上真实病灶的最小面积,取它的三分之一左右比较安全,定太大容易把小病灶整个删掉,反而掉点。
import cv2 import numpy as np def postprocess(prob, thresh=0.5, min_area_ratio=0.001): """prob: HxW 概率图(0~1),返回二值掩膜""" binary = (prob > thresh).astype(np.uint8) h, w = binary.shape min_area = int(h * w * min_area_ratio) # 闭运算填内部小空洞,核大小按分辨率调整 kernel = np.ones((5, 5), np.uint8) binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 去掉远离主病灶的孤立小块 num, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) cleaned = np.zeros_like(binary) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] >= min_area: cleaned[labels == i] = 1 return cleanedmin_area_ratio是相对整图面积的比例,取 0.001 意味着 256×256 图上约 65 个像素以下的小块会被清掉,这样写的好处是换分辨率时不用改参数。闭运算的核大小同理,分辨率翻倍的话核也要跟着放大,否则填不干净。
4.3 指标计算与结果导出
分割任务只报 Dice 是不够的,答辩现场一定会被问“边界差多少”“会不会漏标”。一套完整的指标至少包含 Dice、IoU(Jaccard)、敏感度、特异度,再加一个 Hausdorff 距离的 95 分位数(HD95)反映边界最坏情况。
敏感度和特异度在这里有临床含义:敏感度低意味着漏标病灶区域,特异度高意味着不把正常皮肤误判成病灶。黑色素瘤筛查场景下,漏标的代价远大于误判,所以敏感度通常要求不低于 0.85,这一条比总 Dice 更能说明方案的倾向性。
import numpy as np def compute_metrics(pred, gt, eps=1e-7): """pred/gt: 二值数组,形状一致""" pred, gt = pred.astype(bool), gt.astype(bool) tp = np.logical_and(pred, gt).sum() fp = np.logical_and(pred, ~gt).sum() fn = np.logical_and(~pred, gt).sum() tn = np.logical_and(~pred, ~gt).sum() dice = (2 * tp + eps) / (2 * tp + fp + fn + eps) iou = (tp + eps) / (tp + fp + fn + eps) sen = (tp + eps) / (tp + fn + eps) # 召回,漏标越少越高 spe = (tn + eps) / (tn + fp + eps) # 正常皮肤不被误判的比例 return dict(dice=dice, iou=iou, sensitivity=sen, specificity=spe)导出的结果建议按图像名逐行写 CSV,包含每张图的四个指标和阈值,方便后面按病灶面积分桶看表现。实践里经常出现整体 Dice 0.88、但小病灶那一桶只有 0.7 的情况,这张表就是定位问题的入口,也是答辩 PPT 里最有信息量的一页。
5. 常见问题与排查:训练不收敛、Dice 虚高、掩膜断裂
这套流程跑通不难,跑出可信的数字难。下面五条是我在这个任务上反复见过、也自己踩过的坑,按“现象 → 原因 → 解决”写,碰上了直接对号入座。
5.1 训练阶段的三类翻车
第一条,验证掩膜全黑,Dice 卡在 0.0 不动。现象是训练 loss 缓慢下降,但验证集输出的概率图整张都低于 0.1。原因多半是学习率太高加上前景占比低,模型在前几百步就落进了“全判背景”的局部极小。解决方式是把初始学习率降到 1e-4 以下,同时确认损失里 Dice 那一项确实生效了——有个很隐蔽的写法错误是 Dice 计算时忘了sigmoid,拿 logits 直接当概率算,值域跑到负数,那一项等于不存在。
第二条,loss 曲线突然翘起后再也回不来。现象是训练到二三十轮时 loss 从 0.3 跳到 0.9,之后一直高位震荡。原因通常是某个 batch 里混进了标注异常样本(比如掩膜覆盖了整张图),Dice 损失在这种样本上的梯度极大,一步就把权重冲散了。解决办法除了前面提的梯度裁剪,还要在数据清点阶段把前景占比超过 90% 的样本挑出来单独看,确认是标注错误就剔除,是真实的大面积病灶就单独放一个桶里评估。
第三条,训练集 Dice 0.95、验证集 0.75,差距十几点。这是过拟合的典型信号,但在分割任务里先别急着加正则。最先要排查的是数据泄漏:训练集和验证集里是不是有同一患者的多次拍摄、或者同一病灶的不同裁剪版本。ISIC 的图像名里带有采集编号信息,按编号去重比按文件名随机切要可靠得多。确认没泄漏之后,再上强度更高的增强和早停。
5.2 推理与评估阶段的三类翻车
第四条,加上 TTA 之后 Dice 反而掉了。现象是单路推理 0.86,四路 TTA 掉到 0.83。原因基本都在逆变换上:旋转和翻转的逆操作没写对,几路结果方向不一致,平均之后边界糊成一片。验证方法很简单,把单路推理和 TTA 的掩膜并排画出来,如果 TTA 的结果看起来像被“拧”过,就是逆变换的问题。另外注意不要在有方向的裁剪上做旋转 TTA,滑窗切块之后旋转会让块内内容跑到窗口外。
第五条,验证集搜出来的最优阈值拿到测试集上失效。现象是验证集上 0.42 能到 0.89,测试集上用 0.42 只有 0.85,用 0.5 反而 0.87。原因通常只有两个:一是阈值是在同一份验证集上反复搜出来的,等于对验证集过拟合,样本量只有两百多张时这种现象很明显;二是验证集和测试集的病灶面积分布不一致。稳妥的做法是划三份——训练、阈值搜索、最终测试,阈值只在中间那份上定,定完冻结。另外阈值搜索的粒度取 0.01 就够,再细就是在拟合噪声。
注意:任何在测试集上做过的决策(包括选阈值、挑权重、决定要不要加后处理)都会让最终数字失真,答辩时被问到“测试集用了多少次”要能答得出来。
6. 验收与答辩:把 Dice 讲成一个可信的数字
答辩现场最怕的不是指标低,是指标高得没人信。0.95 的 Dice 报出去,评委第一反应是“你是不是在测试集上调过”。要让数字站得住,靠的是把整条链路的约束条件讲清楚,而不是把小数点后两位抹平。
我一般会准备一张验收表,每一项都有明确的合格线和检查方式,讲的时候按表走,不跳步:
| 验收项 | 合格线 | 检查方式 |
|---|---|---|
| 数据划分 | 训练/阈值搜索/测试三份互不重叠,按采集编号去重 | 打印三份文件名列表求交集,应为空 |
| 掩膜二值性 | 所有掩膜取值仅 0 与 255 | 遍历统计唯一值 |
| 阈值来源 | 仅在阈值搜索集上确定,之后冻结 | 代码里阈值写成常量,不在测试阶段重算 |
| 整体 Dice | 验证集不低于 0.85 | 逐图计算后取均值与标准差 |
| 敏感度 | 不低于 0.85 | 同上,单独列出 |
| 小病灶分桶 | 面积最小 20% 的样本 Dice 不低于 0.75 | 按前景面积排序分桶统计 |
| 推理耗时 | 单张 256×256 在 1 秒内 | 计时 50 张取中位数 |
这张表真正的作用是逼你在交付前把该跑的检查跑一遍。小病灶分桶那一行尤其关键,整体 Dice 0.88 但小病灶只有 0.7 的方案,临床上是不敢用的,主动把它摆出来讲,比藏着掖着被问出来强得多。
答辩的讲述顺序建议按“问题—约束—取舍—证据”四段走。先用一句话说清任务:从皮肤镜图里把病灶区域和边界一起标出来。再说约束:官方测试集标注不公开,所以本地自建三份划分,测试集只在最后跑一次。然后讲取舍,这是最能体现工程判断的部分——为什么用 256 而不是 512,为什么损失里 Dice 占一半,为什么加了连通域清理,每一条都要能说出代价,比如 256 换来的是训练速度,代价是小病灶边界精度;TTA 换来一点几个 Dice,代价是推理时间翻四倍。最后给证据,就是上面那张表加上几张典型样本的叠加图,挑一张好的、一张差的、一张小病灶的,差的那张主动分析原因,比全是漂亮结果更让人信服。
如果时间允许,再补一个消融对比:基线 U-Net、加 Dice 损失、加 TTA、加后处理,四行数字列出来,每一行涨了多少一目了然。这个表做起来只要多跑几次推理,但它是整套方案里最能说明“每个模块都有用”的材料。
从那以后我每次交分割类项目,都强制自己走一遍“先固定划分种子、再定阈值、最后只跑一次测试集”的顺序,权重文件、阈值常量、评估脚本三样东西一起归档,隔一个月回头看也能复现出同一个数。希望这套流程帮到你。
本文还有配套的精品资源,点击获取