ARTICLE DETAIL

资讯详情

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

烧伤图像分割实战:U-Net选型、数据标注与训练部署全流程

烧伤图像分割实战:U-Net选型、数据标注与训练部署全流程 简介基于深度学习的皮肤烧伤检测算法项目面向医疗科研人员、人工智能开发者与高校学生利用卷积神经网络对皮肤烧伤图像进行自动检测与分类重点关注烧伤范围、深度及愈合阶段可有效缓解人工目测的主观性和低效率。资源包为zip压缩格式共包含168个文件总大小约41.69MB内有43个Python脚本、38个YAML模型配置、16个Jupyter Notebook教程还提供烧伤图像样本、CSV评估结果、Docker部署文件等覆盖数据加载、模型训练、结果分析与环境复现的全流程。目前已有105人学习下载适合需要参考完整医疗影像项目范例、希望快速理解深度学习检测流程的读者。源码可直接运行配套教程按数据预处理、模型设计、训练调参、测试验证、部署上线逐步拆解并给出yolov5与yolov7等多组实验结果与可视化图表便于对比不同网络结构在烧伤检测上的表现也能迁移到其他皮肤病变识别任务作为毕业设计或行业落地的实用起点。1. 治好一张烧伤照片靠的从来不是模型皮肤烧伤检测这个方向做出来的人和没做过的人认知差得非常大。没碰过的以为难点在网络结构实际做过的知道难点全在数据和标注上。你拿一张临床照片要模型把烧伤区域从正常皮肤里分出来背后是分割任务、类别不均衡、颜色分布极不规则等一系列问题撞在一起。这篇博文按一个真实可落地的项目脉络来讲从网络选型、数据构造、训练调参到推理部署的完整路径。适合手里有图像数据、想跑通一个端到端深度学习项目的人也适合准备拿医疗图像方向做实战项目、正在写简历作品的工程师。项目源码和流程教程在标题里下文会给你一份可以照着改的工程方案。2. 网络与技术选型为什么图像分割比分类更贴合烧伤检测2.1 烧伤检测的三个任务粒度烧伤检测按输出粒度可以分成三个层次。第一层是图像分类只告诉医生这张图是轻度、中度还是重度输出是个一维向量。第二层是目标检测用边界框把烧伤区域框出来输出是坐标和类别。第三层是图像分割对每个像素做预测输出一张和输入同尺寸的 mask。真正常见的临床诉求是第三层医生需要知道烧伤面积占体表百分比而面积必须从像素级 mask 算出来边界框算出来的面积根本不可靠分类更是只能给严重程度。所以一份把“检测算法”作为标题的项目核心网络一般落在分割上。选分割网络时主流方案是 U-Net 及其变体。U-Net 结构上用编码器逐级下采样提取语义特征再用解码器逐级上采样恢复到原图分辨率中间用跳跃连接把编码器各层的细节特征拼回解码器。烧伤区域边缘不规则有的地方皮肤颜色变化很小有的地方水泡和正常皮肤边界模糊跳跃连接能保留浅层的高频细节对这类边界不清晰的病灶很有用也正因如此 U-Net 在医学分割社区成了默认基准。DeepLabV3 也常用但它的空洞卷积对显存要求更高在小数据集上不如 U-Net 稳。这是一个适合作为新手切入点的选择。2.2 编码器用预训练权重解码器从零学实际做项目时U-Net 的编码器我一般会用 ResNet34 的预训练权重解码器从头训练。ResNet34 比 ResNet50 轻不少参数量约 2100 万ResNet50 约 2500 万对烧伤这种类内差异极大的任务收益有限而且 ResNet34 在 256×256 输入下推理速度快一倍左右。PyTorch 里可以用现成的 segmentation_models_pytorch 库来搭也可以自己写一个精简版。自己写的优势是方便改结构方便加自定义的损失函数。下面这份代码是一个可运行的 U-Net 构建脚本编码器部分直接从 torchvision 加载预训练 ResNet34。import torch import torch.nn as nn from torchvision.models import resnet34 class ResNetUNet(nn.Module): def __init__(self, num_classes1, pretrainedTrue): super().__init__() # 编码器加载预训练的 ResNet34去掉最后的全局池化和全连接层 backbone resnet34(pretrainedpretrained) self.enc1 nn.Sequential(backbone.conv1, backbone.bn1, backbone.relu) self.enc2 nn.Sequential(backbone.maxpool, backbone.layer1) self.enc3 backbone.layer2 self.enc4 backbone.layer3 self.enc5 backbone.layer4 # 解码器每层输入通道数翻倍用 3x3 卷积 上采样恢复分辨率 self.dec4 self._decoder_block(512 256, 256) self.dec3 self._decoder_block(256 128, 128) self.dec2 self._decoder_block(128 64, 64) self.dec1 self._decoder_block(64 64, 64) self.final nn.Conv2d(64, num_classes, kernel_size1) def _decoder_block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Upsample(scale_factor2, modebilinear, align_cornersFalse) ) def forward(self, x): e1 self.enc1(x) e2 self.enc2(e1) e3 self.enc3(e2) e4 self.enc4(e3) e5 self.enc5(e4) d4 self.dec4(torch.cat([e5, e4], dim1)) d3 self.dec3(torch.cat([d4, e3], dim1)) d2 self.dec2(torch.cat([d3, e2], dim1)) d1 self.dec1(torch.cat([d2, e1], dim1)) return self.final(d1)这个网络的核心思路是编码器五个阶段输出的特征图分辨率逐级减半通道数逐级增加语义信息越来越强但空间位置越来越模糊解码器再把语义特征逐步恢复成和输入一样大的特征图每次上采样后和对应编码器层的输出拼接把丢失的边缘细节找回来。最后的 1×1 卷积把 64 个通道压缩成 1 个通道得到每个像素属于烧伤区域的概率。pretrained 这个参数在数据量不足时非常关键ResNet34 在 ImageNet 上学到的纹理和边缘特征可以迁移到皮肤图像上代码里默认打开。2.3 推理流程和灰度图后处理推理时的完整流程一般是读取图片缩放到 256×256归一化送入模型得到 1×256×256 的 logits用 sigmoid 转成概率再按 0.5 阈值转成 0/1 mask最后把 mask 上采样回原图尺寸。这个过程在原项目源码里的流程教程部分通常对应一个 inference.py 或 predict.py。值得说明的是阈值不要固定用 0.5。烧伤图像里的阳性区域一般比正常皮肤小模型输出的概率分布往往整体偏向低值可以先用验证集算一次最佳阈值按 Dice 系数搜索 0.3 到 0.7 之间取最优。def predict_mask(model, image_path, thresh0.5): from PIL import Image import torchvision.transforms as T img Image.open(image_path).convert(RGB) orig_w, orig_h img.size transform T.Compose([ T.Resize((256, 256)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) tensor transform(img).unsqueeze(0) model.eval() with torch.no_grad(): logits model(tensor) prob torch.sigmoid(logits).squeeze().cpu().numpy() mask (prob thresh).astype(uint8) # 恢复原图尺寸mask 是 256x256 的 0/1 矩阵 return mask注意这里用的是 ImageNet 的均值和标准差因为编码器是在 ImageNet 上预训练的输入分布必须和预训练时保持一致。如果模型是自己从零训练的则可以重新统计数据集的均值方差。sigmoid 之后的概率值代表像素属于烧伤区域的置信度不是分类概率所以用阈值而非 argmax 做二值化。这一步很多人做错会在后文避坑部分细说。3. 数据准备与标注决定项目上限的环节3.1 数据从哪来怎么组织文件夹烧伤检测项目的数据来源主要有三类公开医学数据集、医院合作脱敏数据、爬虫抓取临床图片后人工筛选。公开数据集规模通常不大几百到一千张左右因为烧伤图片涉及患者隐私真正公开的很少。所以这类项目训练数据不足才是常态一个能用的项目往往混用多个来源并且通过数据增强硬生生把有效样本量撑上去。文件夹结构我一般这样组织。images 放原始图片masks 放标注好的 0/1 灰度图test 目录放未参与训练的测试图片。标注时用 labelme 或 CVAT 这类工具画多边形导出为 JSON再写脚本把 JSON 转成 mask 灰度图。整个过程约 2 到 3 个小时能处理两百张左右的图。如果标题附带的源码里已经带了标注好的数据那更省事可以直接跳到训练部分。import os import numpy as np import cv2 # 把所有图片的路径写进 txt方便 torch Dataset 读取 def build_file_list(image_dir, mask_dir, output_path): names [f for f in os.listdir(image_dir) if f.endswith(.jpg)] with open(output_path, w) as f: for name in names: mask_path os.path.join(mask_dir, name.replace(.jpg, .png)) if os.path.exists(mask_path): f.write(f{os.path.join(image_dir, name)} {mask_path}\n)这个脚本做的事是把配对好的图片和 mask 路径写进同一个文本文件后续 Dataset 类按行读取即可。如果 mask 是彩色标注还需要做一个灰度化转换确保 mask 是单通道且只有 0 和 255 两个灰度值这一步出问题会直接在训练损失上体现出来模型永远学不会收敛。3.2 Dataset 类的写法与归一化写 Dataset 类是数据准备里最需要动手的部分。除了读图和 mask还要做一件事对输入图像做标准化对 mask 做归一化到 0 到 1。很多人容易漏掉 mask 的归一化直接用 0/255 的值去算损失数值范围不对会让 Dice loss 的计算结果异常。另一个关键点是 mask 在 resize 时的插值方式必须用最近邻插值不能使用双线性插值。双线性插值会在 0 和 1 边界处产生 0.5 这样的中间值导致 mask 边缘出现模糊带训练时模型被迫去拟合这些不存在的中间状态。from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class BurnDataset(Dataset): def __init__(self, file_list, augmentFalse): self.pairs [line.strip().split() for line in open(file_list)] self.augment augment def __len__(self): return len(self.pairs) def __getitem__(self, idx): img_path, mask_path self.pairs[idx] img Image.open(img_path).convert(RGB).resize((256, 256)) mask Image.open(mask_path).convert(L).resize((256, 256), Image.NEAREST) img T.ToTensor()(img) mask torch.from_numpy(np.array(mask) / 255.0).float() if self.augment: # 简单的随机翻转适合小数据量场景 if torch.rand(1) 0.5: img torch.flip(img, dims[2]) mask torch.flip(mask, dims[1]) return img, mask.unsqueeze(0)图像部分用了 ToTensor 自动把像素值缩到 0 到 1mask 部分手动除以 255这样两边数值范围一致。翻转增强时图片和 mask 必须同步操作这里分别对 dims 做了水平翻转方向是匹配的。如果 later 想加入旋转增强需要旋转同一个角度不能图用 30 度、mask 用 90 度这种不对称增强会让模型学到完全错误的空间对应关系。做项目时这层很容易忽略因为训练不会崩但验证集指标会莫名变差。3.3 数据增强的强度要怎么控制烧伤图像的数据增强和自然图像不太一样。随机裁剪和缩放可以用但要注意不要裁掉烧伤区域的主要部分。颜色抖动要谨慎烧伤区域的颜色特征比如红斑、水泡、焦痂本身就是诊断依据把颜色 HSV 偏移拉太大会让模型学到错误的颜色关联。我一般只用水平翻转、垂直翻转、90 度旋转、小角度旋转、轻微亮度对比度调整。这类项目数据量小翻转类增强几乎无害几何变换的尺度要控制在不让解剖结构失真的范围内。有些人会用 CutMix 或 MixUp 做增强但在医疗分割场景我不建议。烧伤区域的位置和形状对面积估算有直接影响CutMix 会把两张图的病灶区域拼在一起训练时模型看到的 mask 形状和真实临床情况不一致学出来的边缘反而更差。这条经验是反复对比后得出的结论代价是白白多训了两轮。4. 训练与调参损失函数、学习率与 epoch 的取舍4.1 损失函数为什么不能只用 BCE烧伤分割里最常见的损失函数组合是 BCE Loss 加 Dice Loss。只用 BCE 的问题在于类别不平衡一张 256×256 的图里烧伤区域可能只占 10% 到 20% 的像素模型只要把全部像素预测为背景BCE 也能得到一个很低的 loss但这样的模型毫无价值。Dice Loss 直接优化区域重叠度对类别不平衡天然不敏感但单独使用的时候梯度在小目标上会不稳定尤其是训练初期模型输出接近 0.5 时Dice 的梯度变化很剧烈。两个相加是为了互相制衡。import torch.nn.functional as F def combined_loss(pred, target, bce_weight0.5): # pred: N x 1 x H x W, target: 同形状 bce F.binary_cross_entropy_with_logits(pred, target) prob torch.sigmoid(pred) smooth 1.0 intersection (prob * target).sum(dim(2, 3)) union prob.sum(dim(2, 3)) target.sum(dim(2, 3)) dice 1 - (2 * intersection smooth) / (union smooth) return bce_weight * bce (1 - bce_weight) * dice.mean()注意这里计算 Dice 用的是经过 sigmoid 的概率图而不是 logits这是分割任务的标准做法。smooth 参数用来防止分母为 0一般设为 1 就足够不需要调大调太大反而会掩盖小目标的梯度信号。bce_weight 是两者的平衡系数我一般从 0.5 开始训到后期如果发现 mask 边缘不够锐利可以改成 0.3 让 Dice 主导大多数情况下效果会有提升。4.2 超参数表照着这个区间调不会翻车下面是整理的超参数推荐表基于常见做法和实际踩坑记录适合 1000 张级别的小数据集。超参数推荐值说明输入尺寸256×256显存 6GB 可跑通8GB 以上可试 384batch size84GB 显存可以降到 4初始学习率1e-4编码器用预训练权重时不宜太大优化器AdamWweight_decay 设 5e-4 防过拟合学习率调度CosineAnnealing周期长度设为总 epoch 数终点 1e-6epoch50~80小数据集 80 epoch 内基本收敛编码器ResNet34 预训练不预训练会明显掉指标阈值0.3~0.7 搜索在验证集上按 Dice 搜索最优预训练编码器的学习率可以单独设置小一些比如主学习率 1e-4编码器部分 5e-5因为迁移过来的特征已经比较稳定学习率太大会把预训练权重冲乱这在迁移学习场景里是常见问题。优化器用 AdamW 而不用 Adamweight_decay 的效果更规范加不加在分割任务上差别可能在 1 到 2 个点的 Dice。4.3 训练循环里必须加 EarlyStopping写训练循环时除了常规的前向、反向、优化器 step还必须加上两个机制验证集评估和 EarlyStopping。很多项目的源码里只有训练脚本没有验证逻辑这是不行的因为你没法知道模型在哪个 epoch 开始过拟合。EarlyStopping 的判定逻辑是每个 epoch 结束之后在验证集上计算 Dice如果连续 10 个 epoch 都没有超过历史最高值就停止训练并加载历史最优模型。best_dice 0.0 patience 10 no_improve 0 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, scheduler) val_dice validate(model, val_loader) if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), best_model.pth) no_improve 0 else: no_improve 1 if no_improve patience: print(fEarly stop at epoch {epoch}, best dice: {best_dice:.4f}) break这个逻辑的核心是只在验证集指标改善时保存模型后面即使训练 loss 继续下降也不用管因为那很可能是在拟合训练集的噪声。save 之后别忘了在最终推理前用 load_state_dict 把最优权重加回来否则用的还是最后一个 epoch 的参数。这个坑非常常见几乎每个跑完训练的人都会碰上一次。5. 避坑指南烧伤检测里最容易翻车的 5 个点5.1 mask 标注边界有杂色模型学到的是轮廓而不是病灶现象训练中 Dice 很高但测试集在真实照片上预测出的 mask 边缘有一圈环形误报看起来像在描边。原因标注工具导出时抗锯齿开启mask 边缘的灰度值不是纯 0 和 255而是带着过渡带。模型看到这些中间值学到的是“边缘性的纹理”而不是“烧伤区域的整体纹理”。解决加载 mask 后先做一次二值化处理灰度大于 127 的像素置为 255其余置为 0再归一化。代码就一行cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)。验证 mask 正确性最直接的方法是随机挑一批图把 mask 叠加到原图上检查边缘。5.2 训练损失骤降但验证 Dice 不动可能是数据泄漏现象前 5 个 epoch 训练 loss 一直在降但验证集 Dice 始终在 0.1 到 0.2 徘徊。原因最常见的泄漏是同一张图既出现在训练集又出现在验证集。烧伤数据量少时很多人喜欢把所有图混在一起随机划分但出院记录里的同一患者多张图片往往高度相似训练集见过的图几乎在验证集里又出现了模型实际是在背图不是在学习泛化。解决按患者 ID 划分数据集同一个患者的所有图片只能出现在同一侧。没有患者 ID 时先做图片去重算一下感知哈希相似度高于 0.95 的只保留一张。这是医疗图像项目里最容易被忽略但最影响结果的问题。5.3 正常皮肤被大片误判为烧伤肤色成了主要特征现象验证集 Dice 大概 0.7 左右但新输入的图片中所有裸露皮肤区域都被预测为烧伤连正常手背也标红了。原因训练数据里正常皮肤区域的占比不够模型学到的不是“烧伤区域和正常皮肤的差异”而是“皮肤和非皮肤的区别”。这时模型发现了捷径把所有肤色区域归为正类因为这样在训练集上能拿到不低的 Dice。解决在数据里加入负样本也就是完全没有烧伤区域的正常皮肤图。带正常皮肤图进入训练让模型知道肤色本身不是判别特征。另一个做法是把掩码中背景区域的比例下限控制在 40% 左右过少就加正常皮肤图。这个坑在训练集规模小于 500 张时特别容易出现需要一开始就留意。5.4 水泡区域识别不准高光部分直接被跳过现象烧伤照片中水泡区域在光线照射下有高光反射模型把高光部分预测成了背景导致面积被低估。原因水泡区域的亮度和正常皮肤有重叠加上拍摄环境不稳定高光反射使这部分像素特征和正常皮肤更接近。模型没有被显式要求关注这部分区域梯度被大面积背景稀释了。解决一种做法是在损失函数里给水泡区域加权重也就是对正样本 mask 做加权让模型更关注难以分类的像素。另一种做法是做色彩空间扩展把原始 RGB 和 HSV 的 V 通道叠加成多通道输入让亮度信息更显式。实际项目里我一般先试加权加不动再改输入。5.5 推理时 mask 尺寸不对输出和原图对不齐现象预测出来的 mask 是 256×256 的而原图是 1024×768 的直接把 mask 和原图叠加几何位置完全错位。原因推理脚本里只做了模型前向忘了把 mask 上采样回原图尺寸。或者用 cv2.resize 时默认用了双线性插值把 0/1 mask 变成了灰度渐变图边界模糊导致面积计算不准确。解决统一用 PIL 或 cv2 的 INTER_NEAREST 插值把 mask 恢复到原始尺寸。面积计算时用 mask 中白色像素数除以原图总像素数得到烧伤面积占比。注意不要用 cv2.resize(mask, (orig_w, orig_h)) 这种写反参数的调用OpenCV 的尺寸参数是 (width, height)和 PIL 恰好相反写错以后 mask 会整体旋转 90 度且位置不对这个细节极容易翻车。6. 验证与部署进阶从一张测试图到能用的推理结果模型训练完之后先不要急着部署先把验证做扎实。评估指标选择上Dice 系数和 IoU 是核心Dice 更直观、对类不平衡更稳健。分类准确率在分割场景里意义有限因为背景像素占比太高All-Pixel-Background 也有 90% 的准确率。参考常见项目的报告方式输出一张图同时包含原图、预测 mask、人工标注 mask、叠加效果四个部分这样医疗背景的协作者一眼就能看懂模型的行为。def visualize_result(image_path, pred_mask, true_mask, save_path): import matplotlib.pyplot as plt img plt.imread(image_path) fig, axes plt.subplots(1, 4, figsize(16, 4)) axes[0].imshow(img); axes[0].set_title(Original) axes[1].imshow(pred_mask, cmapgray); axes[1].set_title(Prediction) axes[2].imshow(true_mask, cmapgray); axes[2].set_title(Ground Truth) overlay img.copy() # 红色半透明层标记预测区域 overlay[:,:,0] np.maximum(overlay[:,:,0], pred_mask * 200) axes[3].imshow(overlay); axes[3].set_title(Overlay) plt.savefig(save_path, bbox_inchestight)四联图是验证阶段最直观的诊断工具。预测 mask 和真实 mask 放在一起能直接看出误检发生在边缘、高光区还是正常皮肤区。我在做烧伤检测项目时几乎每一轮训练结束都要随机抽 20 张图看叠加结果指标只能告诉你模型大概有多好叠加图才能告诉你模型哪里不好。部署阶段的进阶做法是把模型导出成 ONNX 格式这样可以在 CPU 环境下用 ONNX Runtime 跑推理摆脱对 PyTorch 运行时的依赖。导出后要核对输出尺寸和 dtype浮点数格式尽量用 float32 而不用 float16后者在 CPU 端容易产生精度波动。对于临床或工程落地场景还要写一个面积计算函数输入 mask 后输出占比数值。我在这个项目上最大的经验教训是模型结构永远不是瓶颈数据组织才是。烧伤检测做到 0.85 的 Dice 不难难的是在多变的光线、肤色、拍摄角度下保持泛化这要求数据里必须覆盖足够多的真实场景变化而不是在训练技巧上反复精雕细琢。先把数据管明白再谈调参和部署顺序反了后面每一步都在补前面的坑。希望这份流程能帮你少走一段我走过的弯路做出一版自己心里有底、对外讲得清的项目。本文还有配套的精品资源点击获取
返回列表