☰
烧伤图像分割实战:U-Net选型、数据标注与训练部署全流程
2026/10/7 6:39:05 网站建设 项目流程

简介:基于深度学习的皮肤烧伤检测算法项目,面向医疗科研人员、人工智能开发者与高校学生,利用卷积神经网络对皮肤烧伤图像进行自动检测与分类,重点关注烧伤范围、深度及愈合阶段,可有效缓解人工目测的主观性和低效率。资源包为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_classes=1, pretrained=True): super().__init__() # 编码器:加载预训练的 ResNet34,去掉最后的全局池化和全连接层 backbone = resnet34(pretrained=pretrained) 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_size=1) def _decoder_block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False) ) 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], dim=1)) d3 = self.dec3(torch.cat([d4, e3], dim=1)) d2 = self.dec2(torch.cat([d3, e2], dim=1)) d1 = self.dec1(torch.cat([d2, e1], dim=1)) 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, thresh=0.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, augment=False): 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 到 1,mask 部分手动除以 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_weight=0.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 以上可试 384
batch size84GB 显存可以降到 4
初始学习率1e-4编码器用预训练权重时不宜太大
优化器AdamWweight_decay 设 5e-4 防过拟合
学习率调度CosineAnnealing周期长度设为总 epoch 数,终点 1e-6
epoch50~80小数据集 80 epoch 内基本收敛
编码器ResNet34 预训练不预训练会明显掉指标
阈值0.3~0.7 搜索在验证集上按 Dice 搜索最优

预训练编码器的学习率可以单独设置小一些,比如主学习率 1e-4,编码器部分 5e-5,因为迁移过来的特征已经比较稳定,学习率太大会把预训练权重冲乱,这在迁移学习场景里是常见问题。优化器用 AdamW 而不用 Adam,weight_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(f"Early 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, cmap="gray"); axes[1].set_title("Prediction") axes[2].imshow(true_mask, cmap="gray"); 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_inches="tight")

四联图是验证阶段最直观的诊断工具。预测 mask 和真实 mask 放在一起,能直接看出误检发生在边缘、高光区还是正常皮肤区。我在做烧伤检测项目时,几乎每一轮训练结束都要随机抽 20 张图看叠加结果,指标只能告诉你模型大概有多好,叠加图才能告诉你模型哪里不好。

部署阶段的进阶做法是把模型导出成 ONNX 格式,这样可以在 CPU 环境下用 ONNX Runtime 跑推理,摆脱对 PyTorch 运行时的依赖。导出后要核对输出尺寸和 dtype,浮点数格式尽量用 float32 而不用 float16,后者在 CPU 端容易产生精度波动。对于临床或工程落地场景,还要写一个面积计算函数,输入 mask 后输出占比数值。

我在这个项目上最大的经验教训是:模型结构永远不是瓶颈,数据组织才是。烧伤检测做到 0.85 的 Dice 不难,难的是在多变的光线、肤色、拍摄角度下保持泛化,这要求数据里必须覆盖足够多的真实场景变化,而不是在训练技巧上反复精雕细琢。先把数据管明白,再谈调参和部署,顺序反了,后面每一步都在补前面的坑。希望这份流程能帮你少走一段我走过的弯路,做出一版自己心里有底、对外讲得清的项目。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询