资讯动态

U-Net语义分割实战:皮肤癌图像分类模型全流程解析

发布时间:2026/9/16 3:46:09 来源:尧图企业网站定制
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读进来是BGRPyTorch预训练模型默认输入是RGB如果不做转换模型训练时表现会非常诡异——loss能降但评估指标始终上不去最后排查半天发现是通道顺序问题。这个细节听起来低级但在实际项目中我至少见过三次翻车。2.2 数据增强策略医学图像不能瞎折腾很多人拿到医学图像就直接套用自然图像那套增强方案随机裁剪、色彩抖动、高斯噪声全上结果训练出来的模型泛化能力反而变差。原因在于皮肤镜图像的物理特性病灶的颜色、纹理本身就是诊断的重要依据过度的色彩扰动会让模型学到错误的不变特征。我实际采用的增强策略分为两类。空间变换类随机水平翻转概率0.5、随机垂直翻转概率0.5、随机旋转90度/180度/270度而不是任意角度、随机缩放范围0.9到1.1。这里注意旋转和缩放必须对图像和掩码同步操作否则分割标签就错位了。用albumentations库可以直接声明Compose([HorizontalFlip(p0.5), VerticalFlip(p0.5), Rotate(limit90, p0.5), RandomResizedCrop(size256, scale(0.85, 1.0), ratio(1.0, 1.0), p0.5)])它的掩码同步机制非常省心。像素级增强我只做了轻度亮度对比度调整BrightnessContrastbrightness_limit0.05, contrast_limit0.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, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels3, num_classes1, 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, stride2) self.dec4 DoubleConv(features[3] * 2, features[3]) self.up3 nn.ConvTranspose2d(features[3], features[2], 2, stride2) self.dec3 DoubleConv(features[2] * 2, features[2]) self.up2 nn.ConvTranspose2d(features[2], features[1], 2, stride2) self.dec2 DoubleConv(features[1] * 2, features[1]) self.up1 nn.ConvTranspose2d(features[1], features[0], 2, stride2) 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], dim1) d4 self.dec4(d4) d3 torch.cat([self.up3(d4), e3], dim1) d3 self.dec3(d3) d2 torch.cat([self.up2(d3), e2], dim1) d2 self.dec2(d2) d1 torch.cat([self.up1(d2), e1], dim1) 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_classes1网络输出一个单通道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_weight0.4, dice_weight0.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收敛慢且容易陷入局部震荡不划算。学习率调度我用的ReduceLROnPlateaumodemax监控验证集Dice系数patience8factor0.5。意思就是验证集Dice连续8个epoch没有提升时学习率减半。这个方案比固定epoch数衰减或阶梯衰减更实用因为分割模型的收敛速度受数据质量影响很大固定策略要么衰减太早导致欠拟合要么衰减太晚白白浪费训练时间。训练epoch我设置为120batch size是8。但实际运行到第60到80轮时模型基本就收敛了后面的epoch是在等学习率降到足够低做最后的精细化微调。每个epoch大约需要40到60秒单卡RTX 3060256分辨率整个训练流程两小时左右跑完。如果loss在第30轮就停止下降了不用硬等提前停止就好。还有一个极其重要的设置验证集评估频率。我设置每个epoch结束都跑一次验证不是为了观察分数而是为了保存最优权重。具体做法是监控验证集Dice只有当前Dice超过历史最高值时才保存模型权重best_model.pth。许多人的习惯是训练完再统一测试一旦中间出现过拟合或者学习率调度出问题最后的checkpoint可能已经退化得很严重。按验证指标动态保存能确保你最终拿到的模型是训练过程的峰值状态。4. 训练过程与分类模块融合4.1 训练曲线分析与过拟合止损训练过程中我记录的loss曲线有一个非常典型的三阶段模式。第一阶段是前5到10个epochloss从0.8附近快速下降到0.4左右验证Dice从0.4爬到0.75。这个阶段模型在快速学习纹理和颜色特征如果这个阶段loss不下降基本可以确定是代码bug或者数据加载异常需要立刻排查。第二阶段是第10到第50个epochloss稳步下降但变慢验证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的位置像素置0mask接近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_classes2): 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(inplaceTrue), 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], dim1)) d3 self.backbone.dec3(torch.cat([self.backbone.up3(d4), e3], dim1)) d2 self.backbone.dec2(torch.cat([self.backbone.up2(d3), e2], dim1)) d1 self.backbone.dec1(torch.cat([self.backbone.up1(d2), e1], dim1)) 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], dim1)) 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 显存不足拉不起来大batchRTX 3060 12G的显存跑256×256的U-Netbatch 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导致出现全黑或全白的异常图像。我建议在训练脚本里固定全局随机种子seed42这样每次训练的可复现性会好很多也方便排查问题。还有一个隐蔽问题值得警惕标签与图像错位。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], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.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, dim1).cpu().numpy().squeeze() return seg_prob, cls_prob def postprocess_mask(seg_prob, original_shape, threshold0.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]), interpolationcv2.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.87IoU达到0.78PA是0.96。PA看着很高但参考意义有限原因是背景像素占了绝大多数这个我在前面也强调过。如果目标是发论文或参加竞赛建议同时报告每个类别的Dice和IoU并提供2到3个可视化对比图这样审稿人和评委才能直观感受到分割质量。分类模型的评估常规看准确率、精确率、召回率、F1-score和AUC。医学场景里Recall召回率往往比Accuracy更重要——漏掉一个恶性黑色素瘤的代价远大于误判一个良性痣。我的分类模型在验证集上AUC为0.93敏感度召回率0.90特异度0.86整体处于可用的水平。这里我建议在选取最优分类阈值时可以参考约登指数Youdens 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的原因。最后想分享一个我反复确认过的经验训练医学图像模型不妨在数据清洗和可视化上多花一倍时间。把每张训练图像的图像、掩码、类别标签拼接成一个格子图一键生成全部训练样本的总览肉眼扫一遍就能发现标签错位、掩码缺失、类别标注错误等一堆隐藏问题。模型效果不好时先怀疑数据再怀疑代码最后才怀疑网络结构。这个排查顺序能帮你少走很多弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价