ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Python U-Net的医学影像分割:皮肤病病灶识别与PyTorch实现

基于Python U-Net的医学影像分割:皮肤病病灶识别与PyTorch实现 简介一套基于Python Unet的医学影像分割系统源码聚焦皮肤病图像检测与自动分割旨在帮助医学影像开发者快速搭建从数据准备到模型推理的完整流程适用于深度学习入门实践与辅助诊断工具开发。资源共76个文件压缩包仅4.61MB主体为Python源码包括模型结构、训练与预测脚本、数据转换和评估工具同时附带PNG/JPG皮肤病图像及其JSON/PNG标注、CSV评估结果、Unet原文PDF和README说明目录组织清晰便于分模块检索使用。已有1712人学习使用适合直接运行复现分割任务。包内不仅覆盖了Unet经典的跳跃连接网络定义还提供数据预处理、分割图标注转换、训练验证、UI交互等模块读者可基于自带数据集快速跑通模型并进一步调整网络层数、损失函数或优化器用于对比实验和科研扩展。1. 基于Python U-Net的医学影像分割系统落地前先看清它能干什么皮肤病的病灶区域和正常肤色之间的边界往往很模糊靠肉眼标注费时且主观自动分割就成了刚需。基于Python U-Net的医学影像分割系统做的就是“给一张皮肤图像模型自动把病灶区域从背景皮肤里分出来”这件事输入是普通RGB皮肤照片或皮肤镜图像输出是一张和原图等尺寸的掩膜白色区域就是模型认为的病变区域。这类源码项目通常自带皮肤病训练数据和预训练好的分割模型跑通后可以直接用于病灶定位、面积估算也能作为临床辅助诊断的前置步骤。适合想快速验证U-Net在皮肤影像上效果的算法工程师、医学图像方向的初学者以及需要把分割能力集成进自有系统的开发者。下面从模型实现、数据准备、训练调参一路写到避坑和评估。2. 用PyTorch实现U-Net主干Encoder-Decoder结构与跳跃连接的落地写法2.1 Encoder四次下采样与通道翻倍为什么适合皮肤病灶分割U-Net这个名字来源于它的U形结构左侧是编码器逐层下采样提取语义特征右侧是解码器逐层上采样恢复空间分辨率。对于皮肤病分割来说病灶区域的边界依赖浅层的纹理信息而病灶类型判断依赖深层的语义信息U-Net正好两头都占。Encoder部分常用的做法是四次下采样每次由一个双卷积块加一次最大池化组成通道数依次翻倍64、128、256、512、1024。下采样让特征图尺寸从原图的1/2一路缩到1/16感受野变大模型才能看到“这块皮肤纹理是不是异常”的全局上下文而不只是盯着单个像素看。病灶和正常皮肤的灰度差异很多时候非常小没有足够大的感受野根本分不出来。Bottleneck层是Encoder和Decoder中间的桥梁通道数最高特征图最小负责把抽象语义信息压缩在瓶颈里。在皮肤病分割里瓶颈特征决定了模型对病变类型差异的敏感度比如色素痣和脂溢性角化病的纹理特征不同靠的就是这个层去区分。2.2 Decoder与跳跃连接融合浅层纹理与深层语义的关键在哪Decoder负责把缩小的特征图一步步恢复到原图分辨率每次上采样后要和Encoder对应层的输出做拼接这就是U-Net最核心的跳跃连接设计。跳跃连接的意义在于深层特征知道“这里大概是不是病灶”浅层特征知道“病灶边界具体在哪里”两者拼起来模型既不会丢失定位能力也不会让边界变得模糊。如果在代码里把跳跃连接去掉变成纯FCN结构皮肤病分割的边界会明显变粗糙Dice指标通常掉3到8个百分点。原因很简单皮肤病病灶边缘经常有过渡带颜色渐变区域占了不少像素单纯依靠深层特征上采样会把过渡带抹平浅层特征刚好把这些边界细节捞回来。Decoder里每一次上采样我习惯先做2倍双线性插值再拼接跳跃连接传过来的特征最后过双卷积。这个顺序比先拼接再一起上采样更稳因为拼接前的上采样会让两个特征图的尺寸对齐拼接时不会出现尺寸不一致导致的隐性bug。2.3 最小可运行的U-Net模型代码与参数对照一个能直接用于皮肤病分割训练的U-Net用PyTorch实现的核心代码大致如下import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): 双卷积块U-Net的基本单元每个卷积后接BN和ReLU def __init__(self, in_ch, out_ch): super(DoubleConv, self).__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): 基础U-Net输入3通道RGB皮肤图像输出1通道病灶概率图 def __init__(self, in_channels3, out_channels1, features64): super(UNet, self).__init__() # Encoder: 4次下采样通道数64/128/256/512 self.enc1 DoubleConv(in_channels, features) self.enc2 DoubleConv(features, features * 2) self.enc3 DoubleConv(features * 2, features * 4) self.enc4 DoubleConv(features * 4, features * 8) self.pool nn.MaxPool2d(kernel_size2, stride2) # Bottleneck self.bottleneck DoubleConv(features * 8, features * 16) # Decoder: 4次上采样每次先上采样再与跳跃连接拼接 self.up4 nn.ConvTranspose2d(features * 16, features * 8, kernel_size2, stride2) self.dec4 DoubleConv(features * 16, features * 8) self.up3 nn.ConvTranspose2d(features * 8, features * 4, kernel_size2, stride2) self.dec3 DoubleConv(features * 8, features * 4) self.up2 nn.ConvTranspose2d(features * 4, features * 2, kernel_size2, stride2) self.dec2 DoubleConv(features * 4, features * 2) self.up1 nn.ConvTranspose2d(features * 2, features, kernel_size2, stride2) self.dec1 DoubleConv(features * 2, features) self.final nn.Conv2d(features, out_channels, kernel_size1) def forward(self, x): # Encoder e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) # Bottleneck b self.bottleneck(self.pool(e4)) # Decoder每次上采样后把Encoder对应层的输出拼进来 d4 self.up4(b) d4 torch.cat((d4, e4), dim1) d4 self.dec4(d4) d3 self.up3(d4) d3 torch.cat((d3, e3), dim1) d3 self.dec3(d3) d2 self.up2(d3) d2 torch.cat((d2, e2), dim1) d2 self.dec2(d2) d1 self.up1(d2) d1 torch.cat((d1, e1), dim1) d1 self.dec1(d1) return self.final(d1)这段代码里features参数默认是64决定了模型宽度。如果你的显存只有8G处理512x512输入时建议把features降到32否则batch size只能开到2甚至更小显存充足的情况下features64是性价比最高的配置往上加到128收益会明显变小训练时间却接近翻倍。卷积核固定为3x3padding1保证特征图尺寸不变这是U-Net里约定俗成的配置轻易不要改成5x5参数量涨得厉害皮肤病分割的病灶尺度用3x3感受野足够。上采样用的是ConvTranspose2d它带可学习参数比单纯双线性插值效果略好但偶尔会在特征图边缘出现棋盘格伪影。如果训练时发现分割结果有规律的网格状噪点把上采样换成F.interpolate(scale_factor2, modebilinear, align_cornersTrue)再跟一个卷积层问题通常就消失了。输入通道in_channels3对应RGB图像如果你的数据有灰度图或者加了深度通道改这里就行。输出通道out_channels1是因为做二分类每个像素属于病灶或非病灶最后通过Sigmoid转成概率。如果要同时分割多种皮肤病out_channels要改成类别数损失函数也相应改成多分类交叉熵这个后面章节再细说。3. 皮肤病数据集的加载与预处理从皮肤镜图像到可训练张量3.1 数据集目录组织与掩膜文件的一一对应关系拿到皮肤病分割源码之后最先要确认的是数据目录长什么样。常见做法是一个主目录下分images和masks两个子目录图像和掩膜通过文件名一一对应。文件名可能是相同前缀加不同后缀比如ISIC_001.jpg对应ISIC_001_mask.png也可能掩膜单独放在masks目录下且文件类型不同但前缀必须一致否则你的数据加载器会懵。写数据加载器时我一般先扫描出所有图像路径再根据文件名在masks目录里拼出掩膜路径拼不出来就报错绝不静默跳过。这个严格检查很重要因为皮肤病数据集的掩膜经常有缺失或命名不一致的情况不报错的话训练到一半才发现loss异常再回头查数据太浪费时间。import os import cv2 import numpy as np from torch.utils.data import Dataset class SkinLesionDataset(Dataset): 皮肤病分割数据集加载器假设images和masks目录同级且文件名前缀一致 def __init__(self, img_dir, mask_dir, image_size256, transformNone): self.img_dir img_dir self.mask_dir mask_dir self.image_size image_size self.transform transform self.img_names sorted(os.listdir(img_dir)) # 检查掩膜文件是否存在缺失直接报错防止训练时静默失败 self.mask_names [] for name in self.img_names: base os.path.splitext(name)[0] mask_candidates [f{base}.png, f{base}.jpg, f{base}.jpeg, f{base}_mask.png] mask_found None for m in mask_candidates: if os.path.exists(os.path.join(mask_dir, m)): mask_found m break if mask_found is None: raise FileNotFoundError(f找不到 {name} 对应的掩膜文件) self.mask_names.append(mask_found) def __len__(self): return len(self.img_names) def __getitem__(self, idx): img_path os.path.join(self.img_dir, self.img_names[idx]) mask_path os.path.join(self.mask_dir, self.mask_names[idx]) # 用IMREAD_COLOR读彩色图IMREAD_GRAYSCALE读掩膜 image cv2.imread(img_path, cv2.IMREAD_COLOR) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # BGR转RGB避免之后可视化时颜色分裂 image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 统一缩放尺寸插值方式要区分图像和掩膜 image cv2.resize(image, (self.image_size, self.image_size), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (self.image_size, self.image_size), interpolationcv2.INTER_NEAREST) # 掩膜二值化把标注中的255归一化为1方便算BCE Loss mask (mask 127).astype(np.float32) # 转Tensor并归一化图像到[0,1] image torch.from_numpy(image).permute(2, 0, 1).float() / 255.0 mask torch.from_numpy(mask).unsqueeze(0).float() return image, mask这段代码里有三个关键细节。第一掩膜缩放用INTER_NEAREST最近邻插值不能用线性插值。用INTER_LINEAR缩放掩膜会在病灶边界产生灰色过渡带后续阈值分割时边界会抖动Dice指标会莫名掉1到2个点这是皮肤病分割里最常见的预处理坑之一。第二掩膜做了(mask 127)的二值化因为有些标注工具导出的掩膜是0和255有些是0和1统一转成0/1浮点数之后计算BCE Loss才能直接拿模型输出和它比较。第三图像归一化直接除以255没有做均值和方差标准化。对医学影像来说这通常够用但如果你发现训练不收敛或者收敛慢再考虑用ImageNet的均值和方差做标准化。3.2 数据增强怎么做不破坏掩膜同步变换的Trick皮肤病数据集通常不会太大几百到几千张是常态。数据增强不是锦上添花是防止过拟合的关键手段。但增强必须图像和掩膜同步做否则病灶区域和标注对不上模型学到的全是错误映射。import random import torch from torchvision import transforms class SyncTransform: 图像与掩膜同步增强旋转、翻转、裁剪必须对img和mask做相同操作 def __call__(self, image, mask): # 随机水平翻转 if random.random() 0.5: image torch.flip(image, dims[2]) mask torch.flip(mask, dims[2]) # 随机旋转90度的整数倍避免边界填充带来的伪影 k random.choice([0, 1, 2, 3]) if k 0: image torch.rot90(image, k, dims[1, 2]) mask torch.rot90(mask, k, dims[1, 2]) return image, mask # 使用示例不破坏掩膜语义 transform SyncTransform() image, mask transform(image, mask)随机旋转时我只用90度的整数倍不用任意角度。原因是任意角度旋转需要插值掩膜用最近邻还好图像用线性插值会引入轻微模糊而且旋转后的四角会有黑色填充区域这些填充区域里的像素会被当成背景参与训练干扰模型对真正皮肤区域的判断。90度整数倍是完全无损的旋转没有填充问题对于皮肤病灶这种没有方向性偏好的目标来说足够。亮度对比度增强这类颜色增强只能作用于图像不能作用于掩膜。皮肤病分割对颜色变化敏感适当做亮度扰动可以提升泛化性但要注意扰动幅度不能太大否则病灶区域的颜色特征被破坏模型可能把正常的肤色误判成病灶。3.3 归一化、类不平衡与病灶小目标的应对皮肤病分割的类不平衡问题非常突出。一张皮肤图像里病灶区域往往只占整张图的5%到20%剩下全是背景皮肤。如果直接用普通交叉熵训练模型很快学会把所有像素预测成背景因为这样loss已经很低了Dice指标却惨不忍睹。应对类不平衡第一道防线是Loss函数的选择。BCE Loss对正负样本一视同仁在病灶占比低于10%时几乎必翻车Dice Loss直接优化区域重叠度对类别不平衡天然免疫一些。更常用的做法是把两者组合起来用BCE保证每个像素都被约束用Dice保证前景区域能真正被分割出来后面第4章会给出具体实现。第二道防线是数据层面的采样策略。如果数据集中病灶占比差异很大训练时可以做在线硬样本挖掘计算每个batch里病灶像素占比低于某个阈值就把这个batch重新采样。这个策略代码上不复杂但对训练稳定性的提升明显尤其是当你处理的数据里有大量“只有一个小黑点”的图像时。第三道防线和图像尺寸相关。小病灶在256x256输入下可能只有几十个像素模型很难抓得住。常见做法是先用448或512的输入训练再用256输入finetune几个epoch更简单的做法是保证resize时尺寸不要小于384x384代价是显存占用和训练时间上升。显存不够时优先保输入尺寸而不是batch size因为分辨率对分割小目标的影响比batch size大得多。4. 训练配置与损失函数让U-Net在皮肤病数据上收敛4.1 Dice Loss与BCE组合皮肤病分割的标准配置损失函数是训练配置里最重要的一个环节。单用BCE Loss训练皮肤病分割训练过程看起来loss一直在降但打开预测结果一看输出图全黑或者只有一个模糊的灰团这就是类不平衡把模型带偏了。单用Dice Loss则相反小病灶区域容易不稳定因为Dice对前景区域的梯度在病灶极小时会变得异常大训练过程震荡。标准做法是把BCE和Dice按1:1加权组合。BCE给每个像素一个稳定的梯度信号Dice把优化目标拉回到“病灶区域是否被正确分割”上。训练早期让BCE占比大一点稳定收敛训练中后期让Dice主导提升分割精度很多现成的源码里直接用固定权重也能取得不错的效果。import torch import torch.nn as nn class BCEDiceLoss(nn.Module): BCE Loss与Dice Loss的组合解决皮肤病分割的类别不平衡问题 def __init__(self, bce_weight0.5, dice_weight0.5): super(BCEDiceLoss, self).__init__() self.bce_weight bce_weight self.dice_weight dice_weight self.bce nn.BCEWithLogitsLoss() def forward(self, pred, target): # pred是未经过Sigmoid的原始logits bce_loss self.bce(pred, target) # 计算Dice系数加smooth防止分母为零 pred_prob torch.sigmoid(pred) smooth 1e-6 intersection (pred_prob * target).sum() union pred_prob.sum() target.sum() dice_coef (2.0 * intersection smooth) / (union smooth) dice_loss 1.0 - dice_coef return self.bce_weight * bce_loss self.dice_weight * dice_loss这个Loss函数有两点需要注意。第一BCEWithLogitsLoss内部自带Sigmoid所以传入的pred必须是模型最后一层卷积的原始输出不能在外面先做Sigmoid再传进来否则梯度会被Sigmoid链式法则放大或缩小训练不稳定。第二Dice计算时把pred过了Sigmoid用的是概率值而不是二值化的0/1预测这样Dice是可微的梯度能正常回传如果在算Dice之前就做0.5的二值化整个分支的梯度都断了等同于没加Dice Loss。实测下来病灶占比在10%到50%之间的数据这个组合Loss比纯BCE的收敛速度快一倍左右最终Dice能高5到8个百分点。如果你的数据里病灶占比极小比如小于3%可以考虑把dice_weight调到0.7BCE降到0.3让区域重叠约束更强势。4.2 训练脚本的必调参数批次大小、学习率、轮数与模型保存策略训练参数里影响最大的四个是batch size、学习率、epoch数和模型保存策略。学习率初始值我一般设在1e-4到3e-4之间配合AdamW优化器。Adam本身对学习率不敏感但3e-4是个安全上界超过它训练loss很容易在初期就发散尤其是配合Dice Loss这种本身梯度不平稳的Loss时。import torch from torch.utils.data import DataLoader def train_unet(model, train_loader, val_loader, epochs100, lr1e-4): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience8 ) criterion BCEDiceLoss() best_val_loss float(inf) for epoch in range(epochs): model.train() train_loss 0.0 for images, masks in train_loader: images images.to(device) masks masks.to(device) # 前向传播 preds model(images) # 压缩输出通道去掉channel维和mask形状对齐 loss criterion(preds.squeeze(1), masks.squeeze(1)) # 反向传播与参数更新 optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() * images.size(0) # 验证并调整学习率 model.eval() val_loss 0.0 with torch.no_grad(): for images, masks in val_loader: images images.to(device) masks masks.to(device) preds model(images) loss criterion(preds.squeeze(1), masks.squeeze(1)) val_loss loss.item() * images.size(0) train_loss train_loss / len(train_loader.dataset) val_loss val_loss / len(val_loader.dataset) scheduler.step(val_loss) # 保存验证集loss最低的模型这是防止过拟合的关键 if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_unet_skin.pth) print(fEpoch {epoch1}: 保存模型val_loss{val_loss:.4f})batch size的选择逻辑和显存强相关。输入尺寸256时batch size设为16在12G显存上比较舒服输入尺寸512时batch size降为4到6。训练过程中如果发现val loss在震荡而不是平稳下降优先降学习率而不是降batch size因为batch size太小会导致BN层的统计量不稳定模型学到的特征在验证集上不适应。epoch数没有标准答案我的建议是设100到150配合早停。具体做法是连续15个epoch验证集loss都没有下降就停止训练取历史上val loss最低的那一次权重。很多医学分割训练代码默认跑满200个epoch实话说250张数据3个epoch就开始过拟合了后面全是浪费电。一个很重要的细节是model.eval()和torch.no_grad()都必须写全。eval()让BN层使用训练阶段统计好的均值和方差no_grad()关闭梯度计算节省显存两者功能不同不能互相替代。漏写eval()会导致推理时的分割结果不稳定同样的输入每次输出有细微差异这会让后续的评估指标忽高忽低很难定位是模型问题还是代码问题。4.3 从零训练还是迁移学习数据集小时该怎么做皮肤病分割的数据规模通常在几百到几千张这个量级从零训练U-Net完全可行因为U-Net参数量不大分割任务的归纳偏置足够强不像ViT那样需要海量数据。如果你手上的源码是纯随机初始化的权重直接训练就行不用费力找预训练权重。但有两个例外情况建议用迁移学习。第一你的数据包含多种皮肤病类型且某些类别样本极少比如只有二三十张这时随机初始化的模型会在这些类别上完全学不动输出全黑。找一个在ImageNet或医学分割大模型上预训练过的Encoder冻结前几层只训练Decoder小样本类别的表现会好不少。第二图像分辨率很高但样本量小比如1000张的1024x1024图像随机初始化容易过拟合预训练Encoder能显著加速收敛。如果你用的是现成源码要重点检查预训练权重是否真的被加载了。很多源码里写了加载预训练权重的分支但模型结构对不上时它不报错只是把权重丢弃。判断方法很简单加载权重后打印几个层的参数和加载前的数值对比变了就说明加载成功。这种黑匣子问题在医学分割源码里特别常见我至少遇到过三次。5. 皮肤病分割的5个避坑记录从标注错位到推理漂移的真实翻车案例5.1 训练Loss不降反升病灶全被预测成背景现象训练前几个epoch Loss在下降到了第10个epoch左右突然反弹之后一直震荡验证集上的Dice始终在0.1以下预测输出几乎全黑模型退化成了“把所有像素都判为背景”的懒模型。原因类不平衡加上没有使用Dice Loss是这种情况的头号原因。病灶像素占比过低时BCE Loss的最优解就是输出全0模型很快学到的“正确”策略是什么都不分割因为这样loss反而更低。如果只看loss曲线会以为模型在正常收敛实际上它已经彻底学偏了。解决换用BCEDiceLoss让损失函数同时对区域重叠度敏感检查训练数据中病灶占比是否过于极端如果某张图病灶占比低于2%考虑在线丢弃或加强对这类图的增广否则它提供的训练信号接近噪声。改完之后Loss会出现先升高后下降的过程这是正常的Dice Loss的梯度在初期比BCE剧烈不用慌。5.2 训练正常但推理结果错位分割区域比实际病灶偏了几个像素现象训练时Dice能到0.85验证集上看着也很好但拿到一张新图像做推理时分割出来的区域和原图病灶位置有明显偏移尤其在图像边缘区域偏移更严重。原因训练和推理时resize步调不一致是常见原因。训练时图像被缩放到256x256掩膜也跟着缩放模型学到的是“在256x256坐标系下的位置”推理时如果直接把原图缩放到256x256做预测再把预测结果缩放回原图尺寸两次缩放叠加会引入亚像素级偏移。另一个常见原因是训练做了随机旋转或裁剪增强但推理时没有做中心裁剪对齐。解决推理时用和训练完全一致的预处理管线。具体做法是把预处理逻辑封装成同一个函数训练和推理都调用它不要各自写一遍。对于亚像素偏移预测完成后不要对mask做插值缩放用cv2.resize(mask, (w,h), interpolationcv2.INTER_NEAREST)做硬尺寸转换能保留二值边界的锐利度。5.3 掩膜中的病灶区域被错误地当成背景现象训练loss一直很高Dice始终在0.3左右上不去检查训练样本时发现很多掩膜文件里病灶是黑色、背景是白色和训练代码里约定的“白色为前景”正好相反。原因标注规范不统一。有些数据集的掩膜是“白底黑病灶”有些是“黑底白病灶”更隐蔽的是同一个数据集里混着两种规范的文件。数据加载器如果只做灰度读入加阈值二值化不做前景背景校验拿到白底黑病灶的掩膜直接反转了语义模型学到的映射就是错的。解决加载数据集后先做一轮自动校验。随机抽20张图计算每张掩膜中像素值为255的占比如果超过50%说明背景是白色需要做mask 255 - mask反转后再二值化。把校验函数写进数据加载器每次初始化时自动跑一遍后续新增数据也不会再踩这个坑。5.4 推理时分割结果有规律的棋盘格纹理现象模型训练Dice很不错但推理预测的掩膜放大后能看到细密的网格状纹理病灶边界看起来像锯齿不是平滑的曲线。原因这是转置卷积带来的棋盘格伪影。Decoder中如果全部用ConvTranspose2d做上采样且卷积核大小和步长不匹配时会产生频谱上的高频噪声在输出上表现为规则网格。皮肤病病灶的边界本身是平滑曲线网格伪影叠加之后边界质量明显下降。解决把ConvTranspose2d替换成nn.Upsample(scale_factor2, modebilinear, align_cornersTrue)再加上一个普通卷积。这个组合也被称为upsampleconv边界质量好很多代价是可学习参数少了一截但分割精度不会因此下降反而因为边界更干净让Dice略有提升。5.5 显存OOM模型加数据集没改也跑不起来现象按源码默认配置启动训练刚加载第一个batch就报CUDA out of memory但代码本身没有任何改动。原因大部分开源源码的默认配置是参考特定显卡设定的常见的是在24G或更大显存的卡上调好换到8G或12G的卡上直接跑不动。显存占用的大头不是模型参数而是中间特征图输入尺寸512时特征图比256时占的显存多4倍。解决按优先级调三个参数。先把batch size从16降到8或4再把输入尺寸从512降到384或256最后把U-Net的features从64降到32。这三步每一步都能省出一大块显存通常三步走完问题就解决了。如果还OOM检查DataLoader的num_workers是否设得过高Windows下num_workers0能避开很多奇怪的内存错误。6. 推理脚本与分割评估用Dice系数和可视化确认模型真的能用6.1 单张图像推理的最小脚本训练好模型之后最终要验证的是它能不能处理任意输入的皮肤图像。推理脚本和训练脚本最大的区别是没有mask输入模型输出的是概率图需要自己做阈值二值化且推理要记录原始图像尺寸把预测mask还原回原图大小方便在原始图像上做叠加可视化。import cv2 import torch import numpy as np def predict_single_image(model, image_path, device, image_size256, threshold0.5): 单张皮肤病图像推理。 model: 训练好的U-Net image_path: 待预测图像路径 threshold: 分割概率阈值默认0.5 model.eval() # 读取原始图像并记录原始尺寸 raw_image cv2.imread(image_path, cv2.IMREAD_COLOR) raw_h, raw_w raw_image.shape[:2] # 预处理BGR转RGB、缩放、归一化和训练时保持严格一致 image cv2.cvtColor(raw_image, cv2.COLOR_BGR2RGB) image cv2.resize(image, (image_size, image_size), interpolationcv2.INTER_LINEAR) image torch.from_numpy(image).permute(2, 0, 1).unsqueeze(0).float() / 255.0 image image.to(device) with torch.no_grad(): pred model(image) # 过Sigmoid得到概率图并压缩batch维度 prob torch.sigmoid(pred).cpu().numpy().squeeze() # 阈值二值化概率大于threshold的像素判定为病灶 mask (prob threshold).astype(np.uint8) * 255 # 还原到原始图像尺寸插值必须用NEAREST防止边界退化 mask cv2.resize(mask, (raw_w, raw_h), interpolationcv2.INTER_NEAREST) # 生成红色半透明的叠加结果方便直接查看 overlay raw_image.copy() overlay[mask 0] [0, 0, 255] # BGR下的红色 return raw_image, mask, overlay推理脚本里有三个值得注意的参数。第一个是threshold在0.35到0.65之间调。皮肤病边界模糊的区域概率通常在0.3到0.7之间分布阈值设0.5是通用做法但如果你发现分割区域偏大可以上调到0.6偏小就下调到0.4。阈值不是固定的调它不改变模型只改变最终提取的掩膜范围这是推理阶段性价比最高的旋钮。第二个是输入尺寸推理时建议和训练时保持一致不要为了快把图像缩得太小小病灶会在缩放过程中被直接抹掉。第三是no_grad()推理不需要计算梯度不关的话显存开销大了一倍同时推理速度也会明显变慢这个必须在API封装前后都检查一遍。6.2 Dice系数的计算与分割效果的可视化验证模型能不能用不能只看手感。如果手上有验证集的掩膜计算Dice系数是最直接的做法。Dice的定义是两个集合交集的2倍除以两个集合的像素总数范围在0到1之间0意味着完全没分割出来1意味着预测和标注完全一致。皮肤病分割的参考文献里Dice在0.85以上通常被认为是可用水平0.9以上是优秀水平。def dice_coefficient(pred_mask, gt_mask, smooth1e-6): 计算两个二值掩膜的Dice系数输入都是0和1的np数组 pred pred_mask.astype(np.float32).flatten() gt gt_mask.astype(np.float32).flatten() intersection (pred * gt).sum() return (2.0 * intersection smooth) / (pred.sum() gt.sum() smooth)注意传入这个函数的两个掩膜必须经过完全相同的resize且都做了二值化直接用灰度图计算会得到一个偏高或偏低的值原因很简单255和1这两个值在像素相乘时结果完全不同。smooth参数用在两个掩膜都全零的边缘情况防止除零错误对正常样本的结果影响可以忽略。可视化验证里我建议同时输出三张图原图、预测掩膜、原图与掩膜的叠加图。叠加图的营销价值不高但工程价值极高因为Dice是个整体平均指标某个区域分割得特别差时Dice不一定能体现出来。肉眼扫一遍叠加图重点看三件事病灶边缘有没有明显外扩或内缩有没有把正常皮肤误判成大块病灶小病灶有没有被漏掉。这三类问题在Dice指标上可能只差零点零几但对临床场景来说性质完全不同。进阶一点的验证方法是按病灶占比分桶统计Dice。把验证集按病灶占全图比例分成小于5%、5%到20%、大于20%三组分别计算Dice。如果小病灶组的Dice明显低于大病灶组说明模型对尺度敏感下一步优化方向是提升输入分辨率或增加针对小病灶的增强策略而不是盲目堆训练时间。这个统计方法不需要改模型只用现有推理结果就能定位模型短板是你决定是否要继续投入这个方向的最快路径。我现在的习惯是每次训练完都固定跑一遍这项统计输出三组的Dice和可视化图再决定要不要存档这个权重。做这个项目最大的教训是训练指标漂亮不等于推理可用Dice高也不等于边界可靠。每次拿到新的皮肤病图像先用它跑一遍预测把叠加图放大到100%比例盯着边界看几秒比任何loss曲线都让人安心。希望这篇笔记能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表