首页 / 资讯中心 / 文章详情

基于U-Net与Keras的皮肤镜黑色素瘤病灶分割实战

基于U-Net与Keras的皮肤镜黑色素瘤病灶分割实战 ★ FEATURED ARTICLE
简介这份皮肤病变分割实战资料围绕 ISIC2018 黑色素瘤分割任务展开面向具备一定深度学习基础、希望系统掌握医学图像分割流程的学习者与研究者可用于 U-Net 与 Mask R-CNN 两类模型的训练、评估和结果复现。压缩包共 14 个文件、约 45.65MB包含 3 个 Jupyter Notebook 与 4 个 Python 脚本便于直接运行和二次修改另有 2 张 png 结果图、2 份 md 说明文档、1 个 hdf5 模型文件及许可证、忽略配置等辅助内容。资源给出 U-Net 的损失 0.147、精度 0.946、Jaccard 距离 0.723、灵敏度 0.878、特异性 0.97 等指标并保留 Mask R-CNN 实验入口便于对照两种路线。包内还带有 Group Normalization 相关代码与结果可视化材料可帮助理解归一化层在分割网络中的实现方式并迁移到同类医学影像任务。已有 3476 人学习下载适合按 notebook 复现预处理、训练与评估环节也可借鉴其指标组织和模型保存方式形成完整的皮肤癌分割项目实践参考。1. 皮肤镜图像分割一个能在本地跑通的黑色素瘤病灶分割项目皮肤镜照片铺在屏幕上病灶边缘像被水晕开的墨迹人工描一遍要三分钟两个人描出来的形状还未必一致。这个基于 Jupyter Notebook 的皮肤病变分割项目做的事情很直接输入一张皮肤镜图像输出同尺寸的二值掩膜把黑色素瘤区域从皮肤背景里抠出来。整条链路跑在 Keras 上训练入口就是几个 Notebook 单元不需要多卡也不需要分布式。它适合两类人想把 U-Net 从数据加载一路走到推理的初学者以及手里已经有皮肤镜数据、需要一套干净 baseline 的算法工程师。反直觉的地方在于决定最终 Dice 的常常不是网络结构而是预处理里那几行去黑边和对齐的代码后面会展开讲。2. 项目全貌与技术选型U-Net 加 Keras 为什么成了这类任务的默认答案皮肤病变分割是个像素级二分类问题每个像素回答“是不是病灶”。这类任务在医学影像里被验证过太多次编码器负责把图像压成语义特征解码器负责把特征还原回原图分辨率中间的跳连把浅层的边缘信息直接送到深层避免上采样之后边界糊成一团。项目选 U-Net 不是因为它最新而是因为它在这个数据量级上最稳2594 张带标注的训练图换成参数量更大的分割网络过拟合会比收敛来得更快。Keras 这一层选型也值得说一句。Notebook 里做实验最怕的是“改一行要重跑半天”Keras 的model.fit加回调机制把训练循环、检查点、学习率衰减全封装好了想换损失函数只是换一个函数名。代价是对底层算子的控制力弱一些比如自定义 Dice 里涉及argmax的操作要绕一下。对这个项目的目标——快速验证一套预处理加 U-Net 的组合能不能到 0.85 的 Dice——这个取舍是划算的。2.1 一条从原始 JPG 到二值掩膜的完整流水线整个项目的主干可以画成一条直线中间没有分支读入 JPG 和对应的分割标注 PNG裁掉图像四周的黑色边框统一缩放到 256×256像素值归一到 01训练阶段叠一层随机增强验证和推理阶段只做确定性变换送进 U-Net 得到每个像素的 sigmoid 概率按搜索出来的阈值二值化再做一次连通域过滤去掉零星噪点最后写出一张单通道 PNG。这条链路上真正容易出问题的只有三处黑边裁剪的顺序必须在缩放之前、掩膜的通道和数值范围、以及增强不能作用到验证集。剩下的大部分工作量在调阈值和后处理上。我在别的分割项目里也踩过同样的坑——模型输出的概率图看着很漂亮一到 0.5 阈值就碎成一片最后靠阈值搜索加形态学开运算救回来。所以“模型训完就结束”是这个项目最典型的误读训练只是把概率图做出来把它变成能提交的掩膜是另一段工程。从数据规模看训练集 2594 张、验证集 100 张、测试集 1000 张这个量级刚好卡在“从头训一个 U-Net 够用、训一个大模型不够”的位置上。所以项目里没有做复杂的预训练权重加载编码器从随机初始化开始靠数据增强和早停控制过拟合。如果你的数据更少比如只有几百张那就得换成带 ImageNet 预训练权重的编码器这一点在最后一章的改造部分会讲。2.2 目录结构每个文件夹和 Notebook 单元在干什么项目以 Notebook 为主线常见的组织方式是把流程拆成三段预处理、训练、推理评估各一个.ipynb公共函数抽到src/下面供 Notebook 导入。这样既能一个个单元调试又不至于把所有逻辑堆在一个几千行的 Notebook 里。整理后的目录大致是下面这个样子。路径作用备注data/ISIC2018_Task1-2_Training_Input/2594 张训练原图JPG尺寸不统一data/ISIC2018_Task1_Training_GroundTruth/对应的分割标注PNG文件名带_segmentation后缀data/ISIC2018_Task1-2_Validation_Input/100 张验证图无标注用于阶段自测data/ISIC2018_Task1-2_Test_Input/1000 张测试图最终推理对象notebooks/01_data_preprocessing.ipynb黑边裁剪、缩放、掩膜二值化、缓存跑一次生成 npznotebooks/02_train_unet.ipynb模型定义、损失、训练回调产出best_unet.h5notebooks/03_inference_evaluate.ipynb阈值搜索、指标计算、可视化产出提交用掩膜src/unet.py网络结构被 02 导入src/losses.pyDice、BCE、组合损失被 02 导入src/dataset.py数据生成器与增强被 01、02 导入models/权重文件按 epoch 存outputs/预测掩膜和指标 CSV每次运行带时间戳文件名里的_segmentation后缀不是装饰它是原图和标注之间唯一的对应关系。ISIC_0024306.jpg对应的标注叫ISIC_0024306_segmentation.png拼路径的时候去掉原扩展名再拼后缀写错一个字符就是“找不到文件”的报错。我在dataset.py里会把文件名列表先做一次集合校验缺一张就提前报错而不是等到训练到第 300 步才崩。src/和notebooks/分开还有一个好处Notebook 的输出尤其是图片会让文件体积迅速膨胀到几十兆导入src之后可以随时清空输出重跑不影响逻辑。如果你的 Notebook 里到处是plt.imshow的缓存输出用jupyter nbconvert --clear-output --inplace清一次提交和分享都清爽很多。2.3 技术栈与显存预算版本卡死比模型调参更要紧依赖层没有花哨的东西Python 3.7 或 3.8TensorFlow 2.x 自带 Keras加上 numpy、opencv-python、pandas、scikit-image、matplotlib。真正需要留意的是版本对齐——TensorFlow 2.6 之后model.fit的steps_per_epoch语义、tf.data的prefetch行为都有微调混合精度训练的接口也从tf.keras.mixed_precision.experimental挪到了tf.keras.mixed_precision。同一个 Notebook 在别人机器上报AttributeError八成就是版本对不上。显存这块给个实测参考256×256 输入、batch 8、标准 U-Net编码器通道 16-32-64-128-2568GB 显存的卡大概占 5.5GB剩下的留给 TensorFlow 的显存预分配余量。如果直接把输入提到 512×512 而 batch 不变显存需求翻四倍8GB 必然 OOM。常见的处理是 512 输入配 batch 24或者开混合精度把激活值压成 float16。# 环境安装版本建议锁死避免同事之间各跑各的 pip install tensorflow-gpu2.6.0 pip install opencv-python4.5.5.64 pip install scikit-image0.19.3 pandas matplotlib上面这三行不是让你照抄版本号而是提醒你把版本写进requirements.txt。医学影像项目复现性差有一半原因出在环境漂移上三个月后你想重跑一次numpy 已经升了两个大版本np.float变成了np.float64代码直接挂。我一般会在 Notebook 第一个单元里打印tf.__version__和 GPU 信息出问题时看一眼截图就知道对方是什么环境。3. 数据预处理与增强2594 张皮肤镜图像怎么喂进网络预处理这一步决定了模型的上限。皮肤镜图像的原始尺寸从 767×1024 到 4499×6748 都有四角还带一圈纯黑的圆形遮罩边框。如果直接把整张图 resize 到 256×256病灶在画面里可能只占很小一块背景的黑色边框反而占了大量像素网络很快会学会“预测大面积背景”这个偷懒策略训练 loss 降得飞快Dice 却纹丝不动。所以整套预处理的顺序是固定的先按灰度阈值找非黑区域并裁掉再缩放到统一尺寸最后做数值归一化。顺序反了黑边已经被插值糊进有效区域再也裁不干净。这个细节在原论文的实现里通常也会提一句但在自己动手写的时候很容易漏。3.1 去掉黑色边框让病灶占满输入视野去黑边的核心思路是找出行和列上“有内容”的范围。把图转成灰度沿列方向取最大值如果某一列的最大值都低于阈值说明这一列全是黑的行方向同理。找到第一个和最后一个有效行列就是一个包围盒。import cv2 import numpy as np def crop_black_border(img, maskNone, thresh10, pad8): 裁掉皮肤镜图像四周的纯黑边框掩膜同步裁剪。 img: RGB uint8 数组 mask: 同尺寸单通道掩膜可为 None thresh: 灰度低于该值视为纯黑10 是经验值 pad: 在包围盒外留的像素防止把病灶边缘裁掉 gray cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) # 行方向每行最大值 thresh 才算有效行 rows np.where(gray.max(axis1) thresh)[0] cols np.where(gray.max(axis0) thresh)[0] if len(rows) 0 or len(cols) 0: # 极端情况整张接近全黑 return img, mask r0 max(rows[0] - pad, 0) r1 min(rows[-1] pad 1, img.shape[0]) c0 max(cols[0] - pad, 0) c1 min(cols[-1] pad 1, img.shape[1]) img_c img[r0:r1, c0:c1] mask_c mask[r0:r1, c0:c1] if mask is not None else None return img_c, mask_c这段逻辑的关键在thresh和pad两个参数。thresh10是针对 8 位图像的经验值如果图像本身偏暗比如曝光不足的样本可以降到 5调太高会把暗色病灶边缘误判成背景导致裁出来的框偏小。pad8是安全边距因为皮肤镜的黑色遮罩边缘不是硬切的往往有一圈渐变不留余量的话病灶可能刚好贴在裁剪边界上后续缩放插值会把它切掉一角。要注意img和mask必须用同一组r0,r1,c0,c1裁剪这一点比裁剪本身更容易出错。我见过有实现里对图像用了cv2.resize之后才对掩膜做裁剪两张图直接错位训练时模型在学一个永远对不上的目标loss 卡在 0.6 上下不动排查了半天才发现是预处理的锅。3.2 掩膜二值化0/255 与 0/1 之间那点事标注 PNG 常见有两种存法一种是 0 和 255 的 8 位单通道一种是 0 和 1。OpenCV 读进来默认是三通道 BGR如果不去处理掩膜形状会变成(H, W, 3)和模型输出的(H, W, 1)广播之后能算但不报错结果就是损失值看起来正常、指标一塌糊涂。def load_mask(path, size(256, 256)): 读取分割标注并转成 (H, W, 1) 的 float32 0/1 掩膜 m cv2.imread(path, cv2.IMREAD_GRAYSCALE) # 关键强制单通道 if m is None: raise FileNotFoundError(path) m cv2.resize(m, size, interpolationcv2.INTER_NEAREST) # 掩膜必须最近邻 m (m 127).astype(np.float32) # 255 - 1噪声 - 0 return m[..., np.newaxis]两个参数值得单独说。cv2.IMREAD_GRAYSCALE是硬性要求去掉它就会出现上面说的三通道问题。interpolationcv2.INTER_NEAREST同样不能改掩膜是类别标签不是连续信号用双线性插值会在边界生成 0.3、0.7 这种中间值虽然 127之后还能二值化但边缘会多出一圈台阶状的锯齿缩小到 256 时尤其明显直接拉低边界处的 Dice。(m 127)这个阈值对 0/255 的标注是安全的但如果你的标注是 0/1 存的读进来最大值就是 1全部会被判成 0掩膜变成全黑。稳妥的写法是先看m.max()大于 1 才做 127 判断否则直接m 0。这种“数据集换了但代码没换”的问题在二次开发阶段最常出现。3.3 增强与划分随机种子固定与验证集策略数据量只有 2594 张不增强几乎必然过拟合。这个项目里常用的是几何变换加轻度色彩扰动水平翻转、垂直翻转、±15° 旋转、±10% 缩放、亮度对比度微调。旋转和缩放要用cv2.warpAffine对图像和掩膜施加同一个变换矩阵或者直接用 albumentations 这类库把 image 和 mask 一起传进去避免手动同步。import albumentations as A train_tf A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.ShiftScaleRotate(shift_limit0.05, scale_limit0.10, rotate_limit15, border_modecv2.BORDER_CONSTANT, p0.7), A.RandomBrightnessContrast(brightness_limit0.10, contrast_limit0.10, p0.5), ]) val_tf A.Compose([]) # 验证集只做 resize不做任何随机变换这里最容易翻车的是把train_tf用在验证集上。带随机性的增强会让同一个验证样本每次评估得到不同结果Dice 曲线开始抖你会误以为是学习率的问题去调优化器实际上只是评估不稳定。另一个隐蔽的坑是划分策略如果同一个患者的多个视角图像同时出现在训练和验证集里验证 Dice 会虚高 5 个点以上上线就露馅。数据里如果有患者 ID 字段按 ID 分组划分没有的话就只能退而求其次用固定的随机种子做 8:2 划分并在文档里写清楚这个局限。至于增强的强度scale_limit0.10和rotate_limit15是我试下来比较稳的区间。再大就会出现病灶被转出画面、缩放后边界贴边的情况掩膜有效性下降。亮度对比度的扰动幅度也要克制皮肤镜的光照条件相对统一扰动过强等于教模型忽略颜色特征而颜色恰恰是区分病灶和正常皮肤的重要线索。4. 模型搭建与训练U-Net 的编码解码链路和损失函数设计网络部分没有魔改标准 U-Net四次下采样四次上采样每次上采样和同级编码器输出做一次 concat。通道数从 16 起逐层翻倍到 256最后 1×1 卷积加 sigmoid 输出单通道概率图。参数量在 190 万左右这个规模在 2594 张图上训练刚好既不会欠拟合到学不动也不至于一个 epoch 就记住全部样本。真正需要动脑的是损失函数。二分类交叉熵在正负样本极度不均衡时会被背景像素主导——皮肤镜图像里病灶平均只占画面 15%25%模型把所有像素预测成背景就能拿到 0.8 左右的准确率但 Dice 是 0。所以项目里用的是 BCE 和 Dice 的加权组合让优化目标直接对齐评测指标。4.1 卷积块与上采样把 U-Net 拆成可读的四个函数与其写一个巨大的Model子类不如拆成conv_block、encoder_block、decoder_block三个小函数结构一眼能看清改通道数也只改一个地方。from tensorflow.keras import layers, Model def conv_block(x, filters, dropout0.2): 两个 3x3 卷积 BN ReLUU-Net 的基础单元 x layers.Conv2D(filters, 3, paddingsame, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.Conv2D(filters, 3, paddingsame, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) return layers.Dropout(dropout)(x) def build_unet(input_shape(256, 256, 3), base16, dropout0.2): inputs layers.Input(input_shape) skips [] x inputs # 编码器4 次下采样通道 16 - 128 for f in [base, base * 2, base * 4, base * 8]: x conv_block(x, f, dropout) skips.append(x) x layers.MaxPooling2D(2)(x) # 瓶颈层 x conv_block(x, base * 16, dropout) # 解码器4 次上采样 跳连 for f, skip in zip([base * 8, base * 4, base * 2, base], reversed(skips)): x layers.Conv2DTranspose(f, 2, strides2, paddingsame)(x) x layers.Concatenate()([x, skip]) x conv_block(x, f, dropout) outputs layers.Conv2D(1, 1, activationsigmoid)(x) return Model(inputs, outputs, nameunet)base16是控制模型容量的总开关五层之后最深通道是 256。如果显存吃紧或者数据量更小把base降到 8参数量降到四分之一Dice 大概掉 12 个点反过来提到 32显存在 8GB 卡上就顶不住了。dropout0.2放在每个卷积块末尾是这套结构里主要的正则手段验证 loss 开始抬头时先动这个参数比动学习率有效。Concatenate的位置不能挪到Conv2DTranspose之前那样跳连的特征图尺寸和上采样结果对不上会直接报维度错误。另外上采样用Conv2DTranspose而不是UpSampling2D加卷积前者参数更多但能学到上采样核边界恢复质量更好代价是容易在输出上产生棋盘格伪影——如果预测掩膜上出现规律网格把Conv2DTranspose换成UpSampling2D(bilinear)加一个 3×3 卷积通常能缓解。4.2 损失函数BCE 与 Dice 加权组合的实际写法Dice 系损失直接优化重叠度但对小目标早期梯度不稳定BCE 梯度稳定但被背景主导。组合起来是常见解法loss 0.4 * bce 0.6 * dice权重偏向 Dice 让优化目标贴近评测。import tensorflow as tf from tensorflow.keras import backend as K def dice_coef(y_true, y_pred, smooth1.0): 逐样本计算 Dicesmooth 防止全零掩膜除零 y_true tf.reshape(y_true, [-1]) y_pred tf.reshape(y_pred, [-1]) inter tf.reduce_sum(y_true * y_pred) return (2. * inter smooth) / (tf.reduce_sum(y_true) tf.reduce_sum(y_pred) smooth) def dice_loss(y_true, y_pred): return 1.0 - dice_coef(y_true, y_pred) def bce_dice_loss(y_true, y_pred, w_bce0.4, w_dice0.6): bce tf.keras.losses.binary_crossentropy(y_true, y_pred) bce tf.reduce_mean(bce) return w_bce * bce w_dice * dice_loss(y_true, y_pred)smooth1.0这个平滑项不是可有可无的。训练早期模型可能把整张图预测成 0此时分子分母都是 0没有平滑项会得到 NaN一轮之后所有参数变成 NaN表现为 loss 直接打印nan。加 1 之后这种极端情况的 Dice 是 0梯度虽然小但不会炸。w_bce和w_dice的配比可以调。我试过 0.5/0.5 和 0.3/0.7前者训练更稳但收敛后 Dice 略低后者能多拿 1 个点左右但要盯着前 10 个 epoch 有没有震荡。如果数据里存在大量空掩膜完全没有病灶的样本Dice 会被这些样本拉偏更稳的做法是给dice_coef加一个tf.where分支跳过真空样本或者干脆在采样阶段过滤掉。另外要注意这个dice_coef是在整个 batch 上展平后算的全局 Dice不是逐样本平均。两者在 batch 内样本大小差异大时结果不同用作监控指标时行为会和官方评测脚本不一致。评估阶段建议换成逐样本计算再求均值和提交格式对齐。4.3 训练回调检查点、学习率衰减与早停训练本身没什么好写的model.compile加model.fit就完了价值都在回调里。三个回调各管一件事ModelCheckpoint只存验证 Dice 最高的权重ReduceLROnPlateau在指标停滞时砍学习率EarlyStopping在连续多轮没提升时终止。from tensorflow.keras.callbacks import ModelCheckpoint, ReduceLROnPlateau, EarlyStopping callbacks [ ModelCheckpoint(models/unet_best.h5, monitorval_dice_coef, modemax, save_best_onlyTrue, verbose1), ReduceLROnPlateau(monitorval_dice_coef, modemax, factor0.5, patience6, min_lr1e-6, verbose1), EarlyStopping(monitorval_dice_coef, modemax, patience15, restore_best_weightsTrue), ] model.compile(optimizertf.keras.optimizers.Adam(1e-4), lossbce_dice_loss, metrics[dice_coef]) history model.fit(train_ds, validation_dataval_ds, epochs120, callbackscallbacks)monitor必须和mode配对Dice 是越大越好所以用max如果误写成min检查点会保存最差的那一轮训练完加载权重发现效果还不如随机初始化。patience15配合 120 个 epoch实际跑下来通常在第 6080 轮触发早停。restore_best_weightsTrue是省心的选项训练结束自动回滚到最优权重不用再手动 load 一次。初始学习率1e-4是 Adam 在分割任务上的保守选择配合ReduceLROnPlateau的factor0.5整个训练过程学习率会衰减 34 次。如果 loss 在前几个 epoch 就剧烈震荡说明学习率偏大降到 5e-5反过来如果 20 轮之后 loss 几乎不动那就是偏小加倍重来。这些调整不值得用自动搜索手动试两三次就有手感了。4.4 指标与阈值Dice 从 0.78 到 0.85 的那一步模型输出的 sigmoid 概率图默认按 0.5 二值化但 0.5 不是最优阈值。因为正样本少模型整体偏向保守概率分布集中在 0.30.6 之间0.5 会把一部分真实病灶判成背景。做法是在验证集上扫一遍阈值取 Dice 最高的那个。def search_threshold(model, val_ds, lo0.3, hi0.7, step0.02): 在验证集上搜索最优二值化阈值 probs, gts [], [] for x, y in val_ds: probs.append(model.predict(x, verbose0)) gts.append(y.numpy()) probs np.concatenate(probs) gts np.concatenate(gts) best_t, best_d 0.5, 0.0 for t in np.arange(lo, hi 1e-6, step): pred (probs t).astype(np.float32) inter (pred * gts).sum(axis(1, 2, 3)) union pred.sum(axis(1, 2, 3)) gts.sum(axis(1, 2, 3)) d np.mean((2 * inter 1e-6) / (union 1e-6)) if d best_d: best_t, best_d t, d print(fbest threshold{best_t:.2f}, dice{best_d:.4f}) return best_t扫描区间取 0.30.7 就够了超出这个范围说明模型本身有问题。实测下来最优阈值常在 0.400.45 之间比 0.5 能多 12 个点。注意阈值必须在验证集上搜不能拿测试集调否则指标没法用。真要冲到 0.85 以上光调阈值不够还得加两个后处理一是只保留面积最大的连通域把零散的小预测块删掉二是用cv2.morphologyEx做一次开运算填掉内部小孔洞。这两步能再贡献 23 个点而且几乎不增加成本。代价是对多病灶样本会误删如果你的数据里存在同一张图多个病灶的情况连通域过滤要改成保留面积大于某个绝对阈值的所有连通块。5. 常见问题排查从读不进数据到 Dice 卡住的五类翻车这个项目的代码量不大但每一个环节都有能让人卡半天的地方。下面五条是我和同事实际遇到过的按“现象、原因、解决”记录遇到问题可以先对着排查一遍。5.1 环境和数据读取三个让训练根本跑不起来的坑现象FileNotFoundError但文件明明在目录里。原因多半出在文件名拼接上标注文件带_segmentation后缀有的版本还区分Task1和Task1-2两个目录路径拼错时cv2.imread不报错只返回None直到后面resize才抛异常报错位置离真正的原因隔了好几行。解决方式是在load_mask里显式判断if m is None: raise FileNotFoundError(path)让错误提前暴露。另外路径里如果含中文或空格cv2.imread在部分平台上会静默失败改用cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_GRAYSCALE)更稳。现象掩膜读出来是全黑mask 的max()是 0。原因有两种一是标注本身是 0/1 存储代码里写了 127全被判成 0二是用cv2.imread读 PNG 时没指定IMREAD_GRAYSCALE读到的是三通道后续按单通道索引取到了错误的切片。解决方式是先打印m.shape和m.max()做一次断言把(m 127)改成自适应的写法m (m (0.5 * m.max() if m.max() 1 else 0.5))。现象训练到第一个 batch 就 OOM但显存看起来还够。原因通常是tf.data的prefetch加上cache把整个数据集解码后驻留内存2594 张 768×1024 的图算下来是几十 GB。解决方式是只在缩放之后做cache顺序写成map(load).map(resize).cache().shuffle().batch().prefetch(tf.data.AUTOTUNE)把缓存放在 resize 之后内存占用降到 1GB 以内。5.2 训练与指标两个让结果看着能跑其实没用的坑现象loss 正常下降但val_dice_coef一直在 0.1 以下。排除预处理问题之后八成是评估逻辑和训练目标不一致训练用的是概率图加 Dice 损失评估时却先做了argmax或者用了错误的axis。二分类单通道输出上不存在argmax的问题正确的写法是(y_pred 0.5).astype(float)再算重叠。另一个可能是模型最后一层忘了 sigmoid输出是 logits负值被 0.5全部判成 0Dice 自然接近 0。现象验证 Dice 到了 0.9换一批数据就掉到 0.6。典型的泄漏或过拟合。先查增强有没有误用到验证集——验证集上的val_tf必须是空的 Compose任何随机变换都会让评估不稳定。再查划分如果同一患者的多个视角图分别落在训练和验证集验证指标会虚高 5 个点以上正式数据上立刻打回原形。还有一种情况是阈值在验证集上搜过之后又在同一批数据上报告指标这是自我欺骗正确做法是拿训练集切一小块做阈值搜索验证集只用来最终确认。现象预测掩膜上出现了规律网格。这是Conv2DTranspose的棋盘格伪影上采样核重叠不均造成的。临时处理是把最后一层上采样换成UpSampling2D(interpolationbilinear)接一个 3×3 卷积或者把输入尺寸调成能被 16 整除之外的数值比如 224往往能让伪影变得不明显。根治方式是用双线性上采样加卷积替代转置卷积参数量增加不多边界质量更稳。6. 二次开发换骨干、换数据、导出推理模型的改造路径拿到这个项目的人最后大概率要做三件事之一换成更强的编码器、接到自己的数据上、或者导出成能部署的模型。三条路的改造成本差别很大先说结论——换数据最省事换骨干性价比最高导出推理最容易被低估。换骨干的改动集中在build_unet里。把conv_block构成的编码器替换成预训练分类网络的前若干层跳连从对应的 stage 输出取。经验上从随机初始化换成 ImageNet 预训练编码器在数据量不变的情况下能涨 23 个 Dice 点代价是训练时间增加且输入必须保持三通道、归一化方式要跟着骨干走。下面这张表是我在自己机器上试过的三种组合供参考。编码器参数量256 输入显存验证 Dice训练耗时/epoch原生 U-Netbase16~1.9M~5.5GB0.85约 45 秒原生 U-Netbase32~7.8M~9GBOOM 风险0.87约 90 秒预训练轻量骨干 U-Net 解码~5M~6GB0.88约 70 秒换数据时最需要改的不是模型而是load_mask和路径拼接。不同来源的标注命名规则、通道数、数值范围都不一样我的习惯是在dataset.py顶部写一个DATASET_CONFIG字典把根目录、图像扩展名、标注后缀、二值化阈值全放进去换数据集只改这一个字典。这样做的另一个好处是排查问题时能快速确认“到底是数据变了还是代码变了”。DATASET_CONFIG { root: data/ISIC2018_Task1-2_Training_Input, gt_dir: data/ISIC2018_Task1_Training_GroundTruth, img_ext: .jpg, gt_suffix: _segmentation.png, bin_thresh: 127, # 标注是 0/255 就用 1270/1 就用 0.5 img_size: (256, 256), }导出推理模型是最容易被忽略的一步。Notebook 里跑得通不代表能部署h5权重依赖完整的 Keras 环境线上服务里塞一个 TensorFlow 太重。常见做法是转成 ONNX 或者 TensorFlow Lite。转 ONNX 时要把输入签名固定成[1, 256, 256, 3]动态 batch 会让部分推理引擎的优化失效转完之后必须用同一批输入对比 ONNX 和原模型的输出最大绝对误差超过 1e-4 就说明有算子没对齐通常是Conv2DTranspose或者 BN 的训练/推理模式问题。import tf2onnx spec (tf.TensorSpec((None, 256, 256, 3), tf.float32, nameinput),) model.output_names [mask] onnx_model, _ tf2onnx.convert.from_keras(model, input_signaturespec, opset13) with open(unet.onnx, wb) as f: f.write(onnx_model.SerializeToString())opset13是个折中值低于 11 时部分上采样算子不被支持高于 15 有些推理框架还没跟上。model.output_names必须手动设否则导出的 ONNX 输出节点名是一串自动生成的编号线上代码按名字取输出时会拿不到。还有一个改造方向值得提把后处理从 Notebook 里抽出来做成独立函数。阈值搜索、连通域过滤、形态学开运算这三步在训练阶段反复调用写死在单元里会导致“换个阈值就得重跑整个 Notebook”。抽成postprocess.py之后可以直接拿保存的概率图做离线调参几分钟就能扫完一组参数组合。我最早的版本就是把所有逻辑堆在一个 Notebook 里改一次阈值要重跑前面的模型加载和推理一次十几分钟一天下来改不了几次。后来强制自己每写完一段就把它挪进src/Notebook 只负责调用和看结果。从那以后我每次开新项目都先把数据、模型、后处理三层目录建好再写第一行代码这个习惯省下的时间比任何调参技巧都多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站