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

发布时间:2026/10/28 0:26:29
基于U-Net与Keras的皮肤镜黑色素瘤病灶分割实战

简介:这份皮肤病变分割实战资料围绕 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,像素值归一到 0~1;训练阶段叠一层随机增强,验证和推理阶段只做确定性变换;送进 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黑边裁剪、缩放、掩膜二值化、缓存跑一次生成 npz
notebooks/02_train_unet.ipynb模型定义、损失、训练回调产出best_unet.h5
notebooks/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.8,TensorFlow 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-256),8GB 显存的卡大概占 5.5GB,剩下的留给 TensorFlow 的显存预分配余量。如果直接把输入提到 512×512 而 batch 不变,显存需求翻四倍,8GB 必然 OOM。常见的处理是 512 输入配 batch 2~4,或者开混合精度把激活值压成 float16。

# 环境安装,版本建议锁死,避免同事之间各跑各的 pip install tensorflow-gpu==2.6.0 pip install opencv-python==4.5.5.64 pip install scikit-image==0.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, mask=None, thresh=10, pad=8): """裁掉皮肤镜图像四周的纯黑边框,掩膜同步裁剪。 img: RGB uint8 数组 mask: 同尺寸单通道掩膜,可为 None thresh: 灰度低于该值视为纯黑,10 是经验值 pad: 在包围盒外留的像素,防止把病灶边缘裁掉 """ gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) # 行方向:每行最大值 > thresh 才算有效行 rows = np.where(gray.max(axis=1) > thresh)[0] cols = np.where(gray.max(axis=0) > 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两个参数。thresh=10是针对 8 位图像的经验值,如果图像本身偏暗(比如曝光不足的样本),可以降到 5;调太高会把暗色病灶边缘误判成背景,导致裁出来的框偏小。pad=8是安全边距,因为皮肤镜的黑色遮罩边缘不是硬切的,往往有一圈渐变,不留余量的话病灶可能刚好贴在裁剪边界上,后续缩放插值会把它切掉一角。

要注意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, interpolation=cv2.INTER_NEAREST) # 掩膜必须最近邻 m = (m > 127).astype(np.float32) # 255 -> 1,噪声 -> 0 return m[..., np.newaxis]

两个参数值得单独说。cv2.IMREAD_GRAYSCALE是硬性要求,去掉它就会出现上面说的三通道问题。interpolation=cv2.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(p=0.5), A.VerticalFlip(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.10, rotate_limit=15, border_mode=cv2.BORDER_CONSTANT, p=0.7), A.RandomBrightnessContrast(brightness_limit=0.10, contrast_limit=0.10, p=0.5), ]) val_tf = A.Compose([]) # 验证集只做 resize,不做任何随机变换

这里最容易翻车的是把train_tf用在验证集上。带随机性的增强会让同一个验证样本每次评估得到不同结果,Dice 曲线开始抖,你会误以为是学习率的问题去调优化器,实际上只是评估不稳定。另一个隐蔽的坑是划分策略:如果同一个患者的多个视角图像同时出现在训练和验证集里,验证 Dice 会虚高 5 个点以上,上线就露馅。数据里如果有患者 ID 字段,按 ID 分组划分;没有的话就只能退而求其次,用固定的随机种子做 8:2 划分,并在文档里写清楚这个局限。

至于增强的强度,scale_limit=0.10和rotate_limit=15是我试下来比较稳的区间。再大就会出现病灶被转出画面、缩放后边界贴边的情况,掩膜有效性下降。亮度对比度的扰动幅度也要克制,皮肤镜的光照条件相对统一,扰动过强等于教模型忽略颜色特征,而颜色恰恰是区分病灶和正常皮肤的重要线索。

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, dropout=0.2): """两个 3x3 卷积 + BN + ReLU,U-Net 的基础单元""" x = layers.Conv2D(filters, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.Conv2D(filters, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) return layers.Dropout(dropout)(x) def build_unet(input_shape=(256, 256, 3), base=16, dropout=0.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, strides=2, padding="same")(x) x = layers.Concatenate()([x, skip]) x = conv_block(x, f, dropout) outputs = layers.Conv2D(1, 1, activation="sigmoid")(x) return Model(inputs, outputs, name="unet")

base=16是控制模型容量的总开关,五层之后最深通道是 256。如果显存吃紧或者数据量更小,把base降到 8,参数量降到四分之一,Dice 大概掉 1~2 个点;反过来提到 32,显存在 8GB 卡上就顶不住了。dropout=0.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, smooth=1.0): """逐样本计算 Dice,smooth 防止全零掩膜除零""" 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_bce=0.4, w_dice=0.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)

smooth=1.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", monitor="val_dice_coef", mode="max", save_best_only=True, verbose=1), ReduceLROnPlateau(monitor="val_dice_coef", mode="max", factor=0.5, patience=6, min_lr=1e-6, verbose=1), EarlyStopping(monitor="val_dice_coef", mode="max", patience=15, restore_best_weights=True), ] model.compile(optimizer=tf.keras.optimizers.Adam(1e-4), loss=bce_dice_loss, metrics=[dice_coef]) history = model.fit(train_ds, validation_data=val_ds, epochs=120, callbacks=callbacks)

monitor必须和mode配对,Dice 是越大越好所以用max,如果误写成min,检查点会保存最差的那一轮,训练完加载权重发现效果还不如随机初始化。patience=15配合 120 个 epoch,实际跑下来通常在第 60~80 轮触发早停。restore_best_weights=True是省心的选项,训练结束自动回滚到最优权重,不用再手动 load 一次。

初始学习率1e-4是 Adam 在分割任务上的保守选择,配合ReduceLROnPlateau的factor=0.5,整个训练过程学习率会衰减 3~4 次。如果 loss 在前几个 epoch 就剧烈震荡,说明学习率偏大,降到 5e-5;反过来如果 20 轮之后 loss 几乎不动,那就是偏小,加倍重来。这些调整不值得用自动搜索,手动试两三次就有手感了。

4.4 指标与阈值:Dice 从 0.78 到 0.85 的那一步

模型输出的 sigmoid 概率图默认按 0.5 二值化,但 0.5 不是最优阈值。因为正样本少,模型整体偏向保守,概率分布集中在 0.3~0.6 之间,0.5 会把一部分真实病灶判成背景。做法是在验证集上扫一遍阈值,取 Dice 最高的那个。

def search_threshold(model, val_ds, lo=0.3, hi=0.7, step=0.02): """在验证集上搜索最优二值化阈值""" probs, gts = [], [] for x, y in val_ds: probs.append(model.predict(x, verbose=0)) 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(f"best threshold={best_t:.2f}, dice={best_d:.4f}") return best_t

扫描区间取 0.3~0.7 就够了,超出这个范围说明模型本身有问题。实测下来最优阈值常在 0.40~0.45 之间,比 0.5 能多 1~2 个点。注意阈值必须在验证集上搜,不能拿测试集调,否则指标没法用。

真要冲到 0.85 以上,光调阈值不够,还得加两个后处理:一是只保留面积最大的连通域,把零散的小预测块删掉;二是用cv2.morphologyEx做一次开运算填掉内部小孔洞。这两步能再贡献 2~3 个点,而且几乎不增加成本。代价是对多病灶样本会误删,如果你的数据里存在同一张图多个病灶的情况,连通域过滤要改成保留面积大于某个绝对阈值的所有连通块。

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全部判成 0,Dice 自然接近 0。

现象:验证 Dice 到了 0.9,换一批数据就掉到 0.6。典型的泄漏或过拟合。先查增强有没有误用到验证集——验证集上的val_tf必须是空的 Compose,任何随机变换都会让评估不稳定。再查划分:如果同一患者的多个视角图分别落在训练和验证集,验证指标会虚高 5 个点以上,正式数据上立刻打回原形。还有一种情况是阈值在验证集上搜过之后又在同一批数据上报告指标,这是自我欺骗,正确做法是拿训练集切一小块做阈值搜索,验证集只用来最终确认。

现象:预测掩膜上出现了规律网格。这是Conv2DTranspose的棋盘格伪影,上采样核重叠不均造成的。临时处理是把最后一层上采样换成UpSampling2D(interpolation="bilinear")接一个 3×3 卷积,或者把输入尺寸调成能被 16 整除之外的数值(比如 224),往往能让伪影变得不明显。根治方式是用双线性上采样加卷积替代转置卷积,参数量增加不多,边界质量更稳。

6. 二次开发:换骨干、换数据、导出推理模型的改造路径

拿到这个项目的人,最后大概率要做三件事之一:换成更强的编码器、接到自己的数据上、或者导出成能部署的模型。三条路的改造成本差别很大,先说结论——换数据最省事,换骨干性价比最高,导出推理最容易被低估。

换骨干的改动集中在build_unet里。把conv_block构成的编码器替换成预训练分类网络的前若干层,跳连从对应的 stage 输出取。经验上,从随机初始化换成 ImageNet 预训练编码器,在数据量不变的情况下能涨 2~3 个 Dice 点,代价是训练时间增加,且输入必须保持三通道、归一化方式要跟着骨干走。下面这张表是我在自己机器上试过的三种组合,供参考。

编码器参数量256 输入显存验证 Dice训练耗时/epoch
原生 U-Net(base=16)~1.9M~5.5GB0.85约 45 秒
原生 U-Net(base=32)~7.8M~9GB(OOM 风险)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 就用 127,0/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, name="input"),) model.output_names = ["mask"] onnx_model, _ = tf2onnx.convert.from_keras(model, input_signature=spec, opset=13) with open("unet.onnx", "wb") as f: f.write(onnx_model.SerializeToString())

opset=13是个折中值,低于 11 时部分上采样算子不被支持,高于 15 有些推理框架还没跟上。model.output_names必须手动设,否则导出的 ONNX 输出节点名是一串自动生成的编号,线上代码按名字取输出时会拿不到。

还有一个改造方向值得提:把后处理从 Notebook 里抽出来做成独立函数。阈值搜索、连通域过滤、形态学开运算这三步在训练阶段反复调用,写死在单元里会导致“换个阈值就得重跑整个 Notebook”。抽成postprocess.py之后,可以直接拿保存的概率图做离线调参,几分钟就能扫完一组参数组合。

我最早的版本就是把所有逻辑堆在一个 Notebook 里,改一次阈值要重跑前面的模型加载和推理,一次十几分钟,一天下来改不了几次。后来强制自己每写完一段就把它挪进src/,Notebook 只负责调用和看结果。从那以后我每次开新项目,都先把数据、模型、后处理三层目录建好再写第一行代码,这个习惯省下的时间比任何调参技巧都多。希望帮到你。

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