U-Net语义分割实战:从零搭建皮肤癌图像分类模型
皮肤病变的良恶性判断,在临床上是典型的高频刚需场景。而落到图像算法上,核心难点从来不是"分类"本身,而是先要把病灶区域从复杂的皮肤背景中精确剥离出来。这个"先分割、再分类"的思路,正是U-Net这类语义分割网络在医学影像分析里被反复验证的经典打法。
这篇内容我会完整复盘一个基于U-Net的皮肤癌图像分类模型实战项目,覆盖数据预处理、网络搭建、损失函数设计、训练调参、分割结果如何融合到分类决策中等全链路细节。目标读者是具备一定深度学习基础、想快速上手医学图像分割与分类任务的开发者,尤其是正在做毕设、竞赛或科研预实验的同学。文章里所有代码和配置都来自实际可运行的方案,参数我会给出理由,踩过的坑也会一并交代清楚。
1. 项目背景与整体设计思路
1.1 从语义分割到皮肤癌判别的任务拆解
皮肤癌图像判别的常规做法,是直接把整张皮肤镜图像送进一个分类网络,比如ResNet、EfficientNet之类,输出一个良恶性标签。这种方式简单,但有明显短板:皮肤镜图像里往往包含毛发、血管、墨迹标记、肤色不均等大量干扰信息,分类网络很容易被这些"背景噪声"带偏,模型学到的是"这张图整体长什么样",而不是"病灶区域本身长什么样"。
实际项目里,更好的方案是拆成两步走。第一步用U-Net做语义分割,把每个像素归属到"病灶"或"正常皮肤"两类中,得到病灶区域的精确掩码;第二步基于分割结果做分类——既可以设计一个带分割分支的多任务网络,也可以先用分割模型抠出病灶区域,再把这个区域输入分类网络。后一种做法可解释性更强,也更接近医生的工作习惯:先看哪里长了东西,再判断这个东西是良性还是恶性。
这背后的概率逻辑也很直白。语义分割模型输出的每个像素概率,天然包含了病灶的边界信息和空间分布信息,这些信息在单纯分类任务里是最容易丢失的。把分割信息作为特征引入分类决策,等于强行让分类网络"聚焦"在病变区域,而不是整张图的统计纹理上。实测下来,在ISIC数据集上用分割辅助分类,准确率和AUC通常能比纯分类网络高3到5个百分点,而且对于小病灶和大病灶的泛化更稳定。
1.2 为什么选U-Net而不是其他分割网络
医学影像分割领域,U-Net至今仍是当之无愧的baseline之王。对比DeepLabV3、PSPNet这类面向自然场景的分割网络,U-Net的架构极其简洁,但有几个针对医学图像的特殊优势。
第一,医学图像数据集普遍很小,ISIC公开数据集经过筛选清洗后通常也就两千多张。U-Net的参数量适中,编码器部分可以加载ImageNet预训练权重做迁移学习,这种"预训练编码器+轻量解码器"的组合在小数据集上抗过拟合能力很强。DeepLabV3的ASPP模块和空洞卷积虽然感受野大,但在小数据集上更容易过拟合,而且调参空间更大,复现成本高。
第二,U-Net的跳跃连接设计天然适合像素级定位任务。皮肤病灶的边缘不规则、大小差异悬殊,小的可能只有几十个像素,大的能占满半张图。U-Net通过跳跃连接把编码器各层的高分辨率细节直接送到解码器对应层,让网络在保留语义信息的同时不丢失边界细节。这一点对皮肤病灶分割来说几乎是最重要的——边缘分割得准不准,直接影响后续分类特征的质量。
第三,U-Net的变体生态成熟。如果后续想提升精度,可以无缝切换到Attention U-Net、Res-UNet或U-Net++,不需要大改训练流程。这种可扩展性在科研和竞赛中非常值钱,因为你永远不知道拿到的数据到底需要多强的模型。
2. 数据准备与预处理实战
2.1 数据集组织与ISIC格式解析
这次项目采用ISIC 2017或2018挑战赛的公开皮肤镜数据集,它提供的标注格式非常干净,适合做分割+分类。ISIC的目录结构一般是每张原始图像(.jpg)对应一张同名的分割掩码(_segmentation.png),掩码是二值图,白色区域表示病灶,黑色表示正常皮肤。此外数据集附带一个CSV表格,给出每张图的良恶性标签(benign / malignant)和病变类型(melanoma、nevus、seborrheic keratosis等)。
拿到数据后的第一步不是直接训练,而是先做数据体检。这里我踩过一个大坑:ISIC数据集中有些图像尺寸差异巨大,从几百像素到几千像素都有。如果不做统一处理,训练时每个batch的图像尺寸不一致,数据加载器会直接报错,强行resize的话会让小病灶被压缩到几乎不可见。我的处理方案是把所有图像和掩码统一缩放到256×256分辨率,同时在resize前先统计病灶像素占整张图的比例,丢弃掉那些病灶区域占比极小的极端样本,避免训练数据里混入大量"几乎全黑"的掩码。
读取ISIC数据时,注意图像的读写通道顺序。OpenCV读进来是BGR,PyTorch预训练模型默认输入是RGB,如果不做转换,模型训练时表现会非常诡异——loss能降但评估指标始终上不去,最后排查半天发现是通道顺序问题。这个细节听起来低级,但在实际项目中我至少见过三次翻车。
2.2 数据增强策略:医学图像不能瞎折腾
很多人拿到医学图像就直接套用自然图像那套增强方案,随机裁剪、色彩抖动、高斯噪声全上,结果训练出来的模型泛化能力反而变差。原因在于皮肤镜图像的物理特性:病灶的颜色、纹理本身就是诊断的重要依据,过度的色彩扰动会让模型学到错误的不变特征。
我实际采用的增强策略分为两类。空间变换类:随机水平翻转(概率0.5)、随机垂直翻转(概率0.5)、随机旋转(90度/180度/270度,而不是任意角度)、随机缩放(范围0.9到1.1)。这里注意,旋转和缩放必须对图像和掩码同步操作,否则分割标签就错位了。用albumentations库可以直接声明Compose([HorizontalFlip(p=0.5), VerticalFlip(p=0.5), Rotate(limit=90, p=0.5), RandomResizedCrop(size=256, scale=(0.85, 1.0), ratio=(1.0, 1.0), p=0.5)]),它的掩码同步机制非常省心。
像素级增强我只做了轻度亮度对比度调整(BrightnessContrast,brightness_limit=0.05, contrast_limit=0.05),刻意避开了Hue(色调)和RandomGamma的大幅扰动。训练集只有两千张左右时,数据增强相当于免费的样本扩充,但增强强度必须克制,否则模型会把小病灶和模糊边界学成常态,反而损伤定位精度。
3. U-Net模型搭建与关键参数
3.1 编码器-解码器核心结构解析
我在项目里用的U-Net遵循原始论文结构,但做了一些针对皮肤病灶的细节调整。整体流程是标准的下采样四层+上采样四层,编码器每一层包含两个卷积块(卷积+批归一化+ReLU),然后接一个2×2最大池化;解码器每一层先做2×2转置卷积上采样(步长为2),把特征图尺寸翻倍,然后与编码器对应层的特征图做通道维拼接,再接两个卷积块。
代码层面,双卷积块和整体UNet可以很简洁地实现。核心部分如下:
import torch 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) class UNet(nn.Module): def __init__(self, in_channels=3, num_classes=1, features=[64, 128, 256, 512]): super().__init__() self.enc1 = DoubleConv(in_channels, features[0]) self.enc2 = DoubleConv(features[0], features[1]) self.enc3 = DoubleConv(features[1], features[2]) self.enc4 = DoubleConv(features[2], features[3]) self.pool = nn.MaxPool2d(2) self.bottleneck = DoubleConv(features[3], features[3] * 2) self.up4 = nn.ConvTranspose2d(features[3] * 2, features[3], 2, stride=2) self.dec4 = DoubleConv(features[3] * 2, features[3]) self.up3 = nn.ConvTranspose2d(features[3], features[2], 2, stride=2) self.dec3 = DoubleConv(features[2] * 2, features[2]) self.up2 = nn.ConvTranspose2d(features[2], features[1], 2, stride=2) self.dec2 = DoubleConv(features[1] * 2, features[1]) self.up1 = nn.ConvTranspose2d(features[1], features[0], 2, stride=2) self.dec1 = DoubleConv(features[0] * 2, features[0]) self.outc = nn.Conv2d(features[0], num_classes, 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)) b = self.bottleneck(self.pool(e4)) d4 = torch.cat([self.up4(b), e4], dim=1) d4 = self.dec4(d4) d3 = torch.cat([self.up3(d4), e3], dim=1) d3 = self.dec3(d3) d2 = torch.cat([self.up2(d3), e2], dim=1) d2 = self.dec2(d2) d1 = torch.cat([self.up1(d2), e1], dim=1) d1 = self.dec1(d1) return self.outc(d1)几个关键点我需要展开说。
第一,为什么每个卷积块内部都用两个3×3卷积而不是一个5×5卷积?两个3×3卷积堆叠的有效感受野等价于一个5×5卷积,但参数量更少、非线性更强,这是VGG时代被反复验证的经典设计。医学图像里病灶边缘的局部纹理变化非常细微,更深的非线性映射对捕捉这些细节有帮助。
第二,编码器特征通道数我设为[64, 128, 256, 512],这是U-Net原论文的默认配置。如果显存有限,可以整体缩小为[32, 64, 128, 256],分割效果会有轻微下降,但不至于崩。如果设备性能好,也可以把最底层提到1024,但要注意过拟合风险——训练数据只有两千张时,通道数过大等于给模型更多机会死记硬背。
第三,分类头问题。原始U-Net的输出层是1×1卷积,输出通道数等于类别数。皮肤病灶二分类分割中num_classes=1,网络输出一个单通道feature map,配合Sigmoid函数把每个像素映射到0到1之间的概率值。这里不要用Softmax,因为Softmax是针对多类互斥场景的,二分类语义分割用Sigmoid逐像素独立判断即可。
3.2 损失函数:为什么只用BCE不够
语义分割最常被初学者忽略的就是损失函数的选择。很多人上来就用BCEWithLogitsLoss训练U-Net,结果发现loss下降得很快,但Dice系数和IoU始终在0.7左右上不去。原因是BCE是逐像素计算损失的,它会天然偏向"多数类"。皮肤病灶分割中,病灶区域通常只占整张图像的10%到30%,也就是说大部分像素是背景。BCE会把大量梯度贡献给背景像素,模型只要学会预测"全部是背景"就能拿到很低的loss,但这对分割任务毫无意义。
我采用的是BCE + Dice的组合损失,实际配置是:
class BCEDiceLoss(nn.Module): def __init__(self, bce_weight=0.4, dice_weight=0.6): super().__init__() self.bce_weight = bce_weight self.dice_weight = dice_weight self.bce = nn.BCEWithLogitsLoss() def forward(self, pred, target): bce_loss = self.bce(pred, target) pred_prob = torch.sigmoid(pred) smooth = 1e-6 intersection = (pred_prob * target).sum() dice_loss = 1 - (2.0 * intersection + smooth) / (pred_prob.sum() + target.sum() + smooth) return self.bce_weight * bce_loss + self.dice_weight * dice_lossDice系数本身是衡量两个集合相似度的指标,数值范围0到1,越接近1表示分割效果越好。把Dice转化成损失函数(1减去Dice)后,它关注的是病灶区域的整体重叠程度,而不是逐像素的准确性。这样即使病灶只占图像10%的面积,模型也会投入足够的注意力去精确分割它。
组合损失里BCE和Dice的权重分配需要调试。我早期试过纯Dice损失,发现训练前期梯度不稳定,loss曲线抖动厉害,收敛速度也慢,因为Dice损失对预测概率的梯度是非线性的。后来调整为BCE权重0.4、Dice权重0.6,前期用BCE稳住像素级优化方向,后期用Dice提升整体区域重叠度,效果最稳定。这个比例不是从论文里抄的,而是在验证集上跑了几轮对比出来的。
评估指标也要配套。训练过程中我实时监控三个指标:Dice系数、IoU(交并比)、Accuracy。Accuracy在分割任务里参考价值不大,因为背景像素占比太高,随便预测全背景都能有80%以上的准确率。核心指标是IoU和Dice,二者都要求模型对病灶区域有真正的空间感知能力。
3.3 训练配置与关键调参心得
优化器选的Adam,初始学习率1e-4,权重衰减1e-5。这里要解释一下为什么不用SGD。医学图像分割任务里,Adam的逐参数自适应学习率让模型在小数据集上更容易收敛到较优解,而且对学习率的敏感度低,不用花太多时间做学习率预热和调度。SGD在ImageNet这种大规模数据集上表现好,是因为数据量足够大、训练周期足够长,但在两千张皮肤镜图像上,SGD收敛慢且容易陷入局部震荡,不划算。
学习率调度我用的ReduceLROnPlateau,mode='max',监控验证集Dice系数,patience=8,factor=0.5。意思就是验证集Dice连续8个epoch没有提升时,学习率减半。这个方案比固定epoch数衰减或阶梯衰减更实用,因为分割模型的收敛速度受数据质量影响很大,固定策略要么衰减太早导致欠拟合,要么衰减太晚白白浪费训练时间。
训练epoch我设置为120,batch size是8。但实际运行到第60到80轮时模型基本就收敛了,后面的epoch是在等学习率降到足够低,做最后的精细化微调。每个epoch大约需要40到60秒(单卡RTX 3060,256分辨率),整个训练流程两小时左右跑完。如果loss在第30轮就停止下降了,不用硬等,提前停止就好。
还有一个极其重要的设置:验证集评估频率。我设置每个epoch结束都跑一次验证,不是为了观察分数,而是为了保存最优权重。具体做法是监控验证集Dice,只有当前Dice超过历史最高值时才保存模型权重(best_model.pth)。许多人的习惯是训练完再统一测试,一旦中间出现过拟合或者学习率调度出问题,最后的checkpoint可能已经退化得很严重。按验证指标动态保存,能确保你最终拿到的模型是训练过程的峰值状态。
4. 训练过程与分类模块融合
4.1 训练曲线分析与过拟合止损
训练过程中,我记录的loss曲线有一个非常典型的三阶段模式。第一阶段是前5到10个epoch,loss从0.8附近快速下降到0.4左右,验证Dice从0.4爬到0.75。这个阶段模型在快速学习纹理和颜色特征,如果这个阶段loss不下降,基本可以确定是代码bug或者数据加载异常,需要立刻排查。第二阶段是第10到第50个epoch,loss稳步下降但变慢,验证Dice从0.75缓慢爬到0.85左右。这个阶段模型在学习病灶边界细节和空间结构。第三阶段是第50轮往后,学习率经过两次减半后下降变得非常缓慢,验证Dice在0.85到0.88之间微调。
过拟合的典型信号是训练loss持续下降而验证Dice停滞甚至回退。我这个项目里在第40轮左右出现过一次轻微过拟合,表现是训练Dice达到了0.94,但验证Dice卡在0.84不涨。处理方法有两个方向。第一个是增大数据增强强度,比如把RandomResizedCrop的scale范围从(0.85, 1.0)改成(0.75, 1.0),强行用更激进的随机裁剪来提升模型对病灶大小变化的鲁棒性。第二个是降低模型容量,把编码器初始通道数从64改成48。我最终采用了第一种方案,改动最小、收益最直观,验证Dice回升到了0.87。
另外一个容易被忽视的隐患是验证集划分。皮肤镜数据集中同一种病变类型、甚至同一病人的多张图像可能出现在同一个文件夹里。如果你直接随机划分训练集和验证集,就可能出现同一病灶的近似图像同时出现在训练和验证里,导致验证分数虚高,上线后实际效果大打折扣。正确做法是按病人ID或图像来源分组划分,我采用的是GroupShuffleSplit,确保同一病人的图像不会跨集合出现。这一点对医学图像任务尤其重要,大家在公开数据集上跑分数时可以不在意,但放到临床场景就非常关键了。
4.2 从分割结果到分类决策的两种实现方式
分割模型训练完成后,接下来的核心问题是如何利用分割结果做良性恶性分类。我在项目中对比过两种方案,各有适用场景。
方案一是级联式分类。把U-Net输出的预测概率图当作"注意力掩码",与原始图像相乘(mask为0的位置像素置0,mask接近1的位置保留原值),得到一张背景被清除、只保留病灶区域的新图像,然后输入到分类网络(比如ResNet18)进行二分类。这种方案的优点是逻辑清晰、易调试,分割和分类模型可以独立训练和调优。缺点是推理链路长,如果分割结果有误,错误会直接传导到分类阶段。实际测试中,只要验证集Dice达到0.85以上,级联式分类的准确率就能稳定在0.88到0.92之间。
方案二是多任务联合模型。设计一个共享编码器的双头网络:一个输出头做像素级分割(U-Net解码器),另一个输出头做图像级分类(全局平均池化+全连接层)。总损失 = 分割损失 + 分类损失,联合反向传播。这种方案的优势是分割和分类互相促进——分类任务会强化编码器提取与病变类型相关的特征,分割任务则会约束编码器保留空间细节。缺点是训练难度更高,两个任务需要平衡权重,调整的工作量明显更大。
我最终采用方案二作为主力方案,并加了一个关键细节:分类头不仅输入编码器最高层特征,还输入解码器最后一层的分割特征,两者拼接后过全局平均池化再做分类。这样分类网络能同时看到全局语义信息和像素级的病灶分布信息,实测比单独使用编码器特征的分类AUC高出大约1.5个百分点。
分类头代码如下,可以直接替换掉UNet的输出层部分:
import torch.nn.functional as F class UNetWithClassification(nn.Module): def __init__(self, unet_backbone, num_classes=2): super().__init__() self.backbone = unet_backbone # 分割头已在UNet内部:self.backbone.outc # 分类头:接收解码器最后一层特征+编码器第一层特征 self.global_pool = nn.AdaptiveAvgPool2d(1) self.classifier = nn.Sequential( nn.Linear(64 + 64, 64), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(64, num_classes) ) def forward(self, x): # 复用UNet forward逻辑,同时输出分割预测和特征 e1 = self.backbone.enc1(x) e2 = self.backbone.enc2(self.backbone.pool(e1)) e3 = self.backbone.enc3(self.backbone.pool(e2)) e4 = self.backbone.enc4(self.backbone.pool(e3)) b = self.backbone.bottleneck(self.backbone.pool(e4)) d4 = self.backbone.dec4(torch.cat([self.backbone.up4(b), e4], dim=1)) d3 = self.backbone.dec3(torch.cat([self.backbone.up3(d4), e3], dim=1)) d2 = self.backbone.dec2(torch.cat([self.backbone.up2(d3), e2], dim=1)) d1 = self.backbone.dec1(torch.cat([self.backbone.up1(d2), e1], dim=1)) seg_out = self.backbone.outc(d1) cls_feat_high = self.global_pool(d1).flatten(1) cls_feat_low = self.global_pool(e1).flatten(1) cls_out = self.classifier(torch.cat([cls_feat_high, cls_feat_low], dim=1)) return seg_out, cls_out这个设计里有两个细节值得说。第一,为什么分类头同时拼接解码器特征d1和编码器特征e1?因为d1是经过多次上采样、融合了高层语义信息的空间特征,对病灶的整体形态把握更准;而e1保留了原始分辨率下的浅层细节,对病灶边缘的质感和颜色变化更敏感。两者对判断良恶性都有价值,拼接后分类性能确实更好。第二,分类头里加了Dropout层,概率取0.3。这个设计参考了迁移学习中的常见做法,因为医学数据集小,全连接层容易过拟合。Dropout相当于在训练时让分类头"看不到"部分特征,迫使它学习到更鲁棒的判别模式。
5. 常见问题与排查实录
5.1 分割掩码质量差:边缘锯齿和空洞问题
第一个高频问题是预测的掩码存在边缘锯齿和不连续空洞。U-Net输出的是逐像素概率图,通常取0.5为阈值生成二值掩码。如果阈值取得太高,病灶边缘的低置信度像素容易被误删,产生锯齿;如果阈值太低,背景噪声会被误判为病灶,产生空洞。
排查和优化的思路有几个层次。第一层是调阈值,在验证集上画出不同阈值下的Dice曲线,取峰值对应的阈值作为最终预测阈值,而不是死板地固定0.5。我实测试验中最佳阈值通常在0.35到0.5之间,具体取决于训练收敛质量。第二层是后处理,对二值掩码做形态学操作——先闭运算(先膨胀再腐蚀)填补小空洞,再开运算(先腐蚀再膨胀)去除零散小噪点。用OpenCV的morphologyEx函数即可,kernel大小取3×3或5×5。第三层是模型层面,如果空洞大量存在,说明网络的感受野不足以覆盖病灶内部的整体上下文,可以考虑在损失函数里加重Dice权重,或者换用带空洞卷积的DeepLabV3作为编码器分支。
5.2 显存不足:拉不起来大batch
RTX 3060 12G的显存跑256×256的U-Net,batch size基本上限在8。如果必须用更大的batch来稳定训练,有几个变通方案。
第一,梯度累积。PyTorch里可以每跑4个batch才执行一次optimizer.step(),等效于batch size从8变成了32。代码上就是手动控制反向传播的时机:前3个batch只调用loss.backward()不调用optimizer.step(),第4个batch再调用optimizer.step()并optimizer.zero_grad()。注意loss.backward()会在每次调用时累加梯度,这就是梯度累积的原理。第二,混合精度训练。PyTorch自带torch.cuda.amp,自动混合精度可以把显存占用量降低近一半,代价是有极小概率出现精度损失。这个概率在分割任务里影响极小,收益远大于风险。第三,降低输入分辨率。如果业务场景不要求高精度分割,把图像从256×256降到192×192,显存占用会大幅下降,同时Dice损失通常在1到2个百分点之内。
但这里我要强调一个原则:CUDA Out of Memory的根源往往是网络结构臃肿,而不是batch size。用torchsummary打印模型参数量时,如果发现单层卷积的参数量异常大,优先检查是不是卷积核尺寸或通道数设置错了。我之前遇到过把Conv2d的kernel_size误写成5导致显存直接爆掉的情况,这种低级错误排查起来很耗时间。
5.3 训练loss震荡不收敛
皮肤病变分割训练时loss曲线震荡、验证Dice曲线剧烈波动的现象,最常见的元凶是学习率过大。我第一次跑这个项目时把初始学习率设为3e-4,结果前10个epoch的loss上下跳动幅度很大,验证Dice始终在0.6附近挣扎。把学习率降到1e-4之后,曲线立刻平稳下来。
除了学习率,数据增强的随机性也会造成loss震荡。如果每个epoch用不同的随机种子做增强,loss的微小波动是正常的,但如果是剧烈震荡,就要检查数据加载器是否在每次迭代时都重新加载了整个数据集,或者数据增强库里是否有bug导致出现全黑或全白的异常图像。我建议在训练脚本里固定全局随机种子(seed=42),这样每次训练的可复现性会好很多,也方便排查问题。
还有一个隐蔽问题值得警惕:标签与图像错位。ISIC数据集中的掩码文件名与原始图像名严格对应,但如果用glob按文件名排序后分别加载图像和掩码,两个列表的排序顺序可能不一致,导致模型拿到的掩码是另一张图的。这个问题通常会表现为训练loss下降缓慢、验证指标忽高忽低。排查方法很简单:随机打印5组图像和掩码叠加的可视化结果,肉眼确认病灶位置是否一致。
6. 推理部署与效果评估心得
6.1 推理流程的标准化封装
模型训练完成后的推理流程并不复杂,但需要封装成标准化的管线,而不是只在notebook里跑通就完事。尤其是做医学图像项目,后续可能会反复测试不同的图像、调试不同的阈值,如果没有一个规范的推理函数,每次都要从头写预处理代码,效率极低且容易出错。
我的推荐做法是把预处理、模型推理、后处理整合成三个可复用的函数:preprocess_image负责读图、resize、归一化和维度转换;predict_mask负责输入图像tensor,输出分割概率图和分类概率;postprocess_mask负责把概率图转为二值掩码、应用形态学去噪、恢复原图尺寸。这样三个函数组合起来就是一个完整推理接口,传入任意皮肤镜图像路径,返回分割掩码和良恶性判断结果。
def preprocess_image(image_path, target_size=(256, 256)): image = cv2.imread(image_path) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = cv2.resize(image, target_size) image = image.astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) image = (image - mean) / std image = np.transpose(image, (2, 0, 1)) return torch.from_numpy(image).unsqueeze(0) def predict_mask_and_class(model, image_tensor, device): model.eval() with torch.no_grad(): image_tensor = image_tensor.to(device) seg_out, cls_out = model(image_tensor) seg_prob = torch.sigmoid(seg_out).cpu().numpy().squeeze() cls_prob = torch.softmax(cls_out, dim=1).cpu().numpy().squeeze() return seg_prob, cls_prob def postprocess_mask(seg_prob, original_shape, threshold=0.45): seg_mask = (seg_prob > threshold).astype(np.uint8) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) seg_mask = cv2.morphologyEx(seg_mask, cv2.MORPH_CLOSE, kernel) seg_mask = cv2.morphologyEx(seg_mask, cv2.MORPH_OPEN, kernel) seg_mask = cv2.resize(seg_mask, (original_shape[1], original_shape[0]), interpolation=cv2.INTER_NEAREST) return seg_mask这段封装里我做了几个刻意的设计。第一,归一化的mean和std用的是ImageNet的标准值,因为U-Net编码器如果用了预训练权重,输入分布必须和预训练保持一致。如果模型是从零训练的,可以简化为图像除以255后减0.5再除0.5,但一致性比选哪套参数更重要——训练和推理必须用同一套归一化方案。第二,后处理时resize用INTER_NEAREST而不是INTER_LINEAR,因为掩码是离散标签,线性插值会产生中间灰度值,破坏二值语义。第三,形态学操作kernel选了椭圆结构元素,对皮肤病灶这种圆形不规则的区域,椭圆比矩形更贴近真实形状。
6.2 评估指标怎么解读
分割模型我主要报告的三个指标是Dice、IoU和PA(像素准确率),重点参考Dice和IoU。以我在ISIC验证集上的结果为例,Dice达到0.87,IoU达到0.78,PA是0.96。PA看着很高但参考意义有限,原因是背景像素占了绝大多数,这个我在前面也强调过。如果目标是发论文或参加竞赛,建议同时报告每个类别的Dice和IoU,并提供2到3个可视化对比图,这样审稿人和评委才能直观感受到分割质量。
分类模型的评估常规看准确率、精确率、召回率、F1-score和AUC。医学场景里Recall(召回率)往往比Accuracy更重要——漏掉一个恶性黑色素瘤的代价远大于误判一个良性痣。我的分类模型在验证集上AUC为0.93,敏感度(召回率)0.90,特异度0.86,整体处于可用的水平。这里我建议在选取最优分类阈值时可以参考约登指数(Youden's Index,即敏感度+特异度-1的最大值),而不是默认0.5。用验证集算出的最优阈值往往在0.4到0.6之间,选定后推理阶段就固定使用这个阈值。
另一个容易踩的坑是数据不平衡问题。ISIC数据集中恶性样本占比通常不到20%,如果直接用原始分布训练,模型即使把所有样本都预测为良性,准确率也有80%以上,但毫无临床价值。我处理方案是分类损失用带类权重的交叉熵(class_weight),正类的权重设为负类的3到5倍。假设恶性样本占比为18%,那么负类权重约为0.2,正类权重约为0.8,按样本比例的倒数来设定是通用做法。具体可以灵活调整,目标只有一个:让模型对少数类不视而不见。
基于U-Net语义分割的皮肤癌图像分类模型,整体跑下来给我的最大体会是:医学图像任务和自然图像任务的核心差别不在模型结构,而在数据处理和评估逻辑。分割网络的选型和参数只是在解决"模型能不能拟合"的问题,而数据的质量、类别的均衡、评估指标的合理选取,才是真正决定项目能不能用的关键。这套方案里U-Net本身并不新颖,但配合合理的预处理、损失函数设计和训练策略后,它能在一千多张的小数据集上稳定达到可用的分割精度,这就是它至今仍是医学影像分割首选baseline的原因。
最后想分享一个我反复确认过的经验:训练医学图像模型,不妨在数据清洗和可视化上多花一倍时间。把每张训练图像的图像、掩码、类别标签拼接成一个格子图,一键生成全部训练样本的总览,肉眼扫一遍,就能发现标签错位、掩码缺失、类别标注错误等一堆隐藏问题。模型效果不好时,先怀疑数据,再怀疑代码,最后才怀疑网络结构。这个排查顺序能帮你少走很多弯路。