简介:一份围绕皮肤烧伤检测的深度学习实战项目,基于卷积神经网络自动识别烧伤范围、深度与愈合阶段,利用深度学习提升烧伤诊断的准确性与效率,主要面向医疗AI研究者、算法工程师与计算机视觉学习者,既适合算法验证,也便于二次开发。压缩包共有168个文件,以43个Python脚本、38个YAML配置、16个Jupyter Notebook及多格式图片、CSV记录文件为主,其中py为训练与推理工具、yaml用于模型参数配置、ipynb保留实验分析过程,另有Dockerfile、Shell脚本辅助环境部署,覆盖数据预处理、模型搭建、训练调参、日志输出与结果可视化等完整环节,整体大小仅41.69MB,目录组织清晰,可直接进入训练流程。目前已有105人学习下载,资源额外配套了从数据收集、预处理、模型设计、训练测试到部署验证的流程教程,帮助初学者理解烧伤识别任务中CNN如何提取关键特征,也可基于源码快速复现实验结果,或迁移到其他医学图像分类场景。整体是一套端到端的优质实战案例,能提供可复用框架与系统化工程思路。
1. 皮肤烧伤检测为什么选深度学习:先把“测什么”和“能测准多少”对齐
“皮肤烧伤检测”这个标题拆到底,其实是一件事:让深度学习算法在创面图片上给出烧伤深度分级,而不是做“有伤/无伤”的二分类。医生在处理早期烧伤时,最吃不准的就是浅Ⅱ度还是深Ⅱ度,这直接决定保守换药还是手术切痂,而肉眼判断本来就有明显的主观性。正因为这个痛点,“基于深度学习的烧伤检测”才成为医疗 AI 项目里既现实又有动手空间的课题。适合谁看?想用一份带源码和流程教程的项目入门医学图像分类的开发者,或者手里只有几百张标注图、想在本地跑通训练和验证的工程人员。下文按我复现这类项目的习惯拆开讲:任务定义、数据集组织、训练参数,再讲四个复现时最常见的坑。
2. 复现这套皮肤烧伤检测项目:任务拆分、数据集来源与最小依赖环境
2.1 烧伤检测不等于一块模型:分类做分级,分割做定位
拿到“基于深度学习实现的皮肤烧伤检测算法”这类项目时,先别急着解压训练,得确定你要交付的是“整张图的烧伤等级”还是“图上哪一块是几度烧伤”。这两个问题对应的模型完全不同。
常见做法是先把任务分成两类。第一类是分类:输入一张裁剪好的创面局部图,输出四个类别——I 度、浅Ⅱ度、深Ⅱ度、Ⅲ度。这类模型实现简单,一个 ResNet 加一个分类头就能跑,训练脚本干净,验证时看混淆矩阵也直观。第二类是分割:逐像素输出每个位置的烧伤深度,适合面积大、同时存在多种深度的创面,术前规划更需要这种,但标注成本高,模型出来也重。
我的建议是:如果这个 zip 里的流程教程只给了 train.py 和 infer.py,默认它大概率是分类任务。先按分类把它跑通,得到 baseline 之后再考虑给模型加分割分支。烧伤创面往往混合了红、白、黄灰和焦痂,分类模型对这个信号的抓取比想象中更直接,而分割网络在小数据集上反而容易学到错误的边界。源码项目复现的第一步,不是追求“模型高级”,而是把任务边界先立住。
2.2 数据怎么攒成标准结构:目录划分与标签映射
复现时最容易卡住的是数据。烧伤图像不像 ImageNet 一条命令就能下,来源一般三块:公开医学图像库、医院脱敏数据、从公开竞赛和教科书资料中人工整理。量通常不大,几百到两三千张都算正常。你手里的 zip 里如果带了数据,优先看它的目录结构;如果没带,自己得先按下面的方式搭一份。
data/ train/ superficial/ # 浅Ⅱ度 deep/ # 深Ⅱ度 full/ # Ⅲ度 / 全层 val/ test/这种按类别分文件夹的结构,配合 torchvision 的ImageFolder直接能读,是“源码+流程教程”类项目最常见的格式。我一般会在数据目录里再放一个label_map.json,把文件夹名映射到整数,避免训练时类别顺序被 filesystem 顺序搞乱:
{ "first_degree": 0, "superficial": 1, "deep": 2, "full": 3 }划分数据集时有一个关键原则:按病人划分,而不是按图片划分。同一患者的多张创面照片高度相似,如果同一患者照片同时出现在 train 和 val,验证集分数会被数据泄漏虚高,等部署到新病人身上就直接“翻车”。我经历过一次看起来 val acc 95%、换新数据掉到 60% 的情况,后来反复查才发现是划分时没按patient_id做 group split。这是烧伤检测这类小样本医学项目最典型的坑。
2.3 环境固定:用尽量少的依赖先把训练脚本跑起来
不要一上来就装一堆深度学习全家桶。复现源码项目的核心是先让入口脚本能跑,依赖越少越好排查。我通常会先把 requirements 钉成下面这几行:
torch>=2.0.0 torchvision>=0.15.0 opencv-python>=4.5.0 tqdm scikit-learn pandas然后新建虚拟环境安装:
python -m venv .venv source .venv/bin/activate pip install -r requirements.txt逻辑说明:torch 和 torchvision 必须保持主版本对应,否则加载预训练权重时容易报size mismatch或直接ImportError。opencv 用于读图和导出,scikit-learn 用来算混淆矩阵和分类报告,这几样够覆盖整个流程。如果你机器没有 GPU,pip 会自动装 CPU 版还是 CUDA 版取决于 PyTorch 官方给出的安装命令,这一步在第五章会展开;这里只要保证 import 不出错就行。
还有一条血泪经验:解压路径不要带中文和空格。很多 zip 里的脚本用的是相对路径,路径里一旦出现非 ASCII 字符,opencv 读图和 torch 保存权重都可能直接报错。这种错误表面上看是环境问题,实际上改一个文件夹名字就好了。
3. 把训练脚本改造成能收敛的闭环:从过拟合单batch到参数稳定区
3.1 第一口饭:过拟合四十张图,先让代码“能学”
拿到源码后的第一个动作不是完整训练,而是先在几十张图上把 loss 打下来。这一步的目的很简单:确认数据加载、模型前向、损失计算、梯度回传这条链路是通的,而不是花两小时跑完一个 epoch 才发现 loss 压根没动。
# 固定取一个 batch,看模型能不能记住它 sample_images, sample_labels = next(iter(train_loader)) model.train() for step in range(200): logits = model(sample_images.to(device)) loss = criterion(logits, sample_labels.to(device)) optimizer.zero_grad() loss.backward() optimizer.step() if step % 20 == 0: print(f"step {step:3d} | loss {loss.item():.4f}")这个脚本里只用一个 batch,比如 16 张图,跑 200 步。如果代码没问题,loss 应当在前几十步内明显下降,最后能逼近 0.1 以下。如果 loss 降不下去,先查三件事:标签是否传错、模型参数是否被requires_grad=False钉死、数据读取后像素值是否异常。这一步是动手深度学习里最廉价的“体检”,比看任何教程都管用。
3.2 模型结构选型:预训练 ResNet 替换分类头
烧伤图像在 ImageNet 里几乎不存在,但预训练权重依然值得用。原因在于,烧伤深度判级依赖创面的颜色纹理和表皮结构特征,而这些低级视觉特征——边缘、颜色渐变、组织块状纹理——和 ImageNet 上学到的通用表征是共享的。直接用随机初始化在小数据集上从零训,基本会被 sample size 打回原形。
import torchvision from torch import nn model = torchvision.models.resnet50( weights=torchvision.models.ResNet50_Weights.IMAGENET1K_V1 ) model.fc = nn.Linear(model.fc.in_features, num_classes=4)替换掉最后的fc是因为原来的输出是 1000 类,我们的分类数是 4。in_features从model.fc.in_features拿,不要手写 2048,这样以后换 resnet18 时不用改第二行。选定这个结构之后,我建议先全冻结骨干只训练分类头,跑几个 epoch 看到 val 能到基线后,再解冻最后一段进行微调。常见的误用是一上来就全参数requires_grad=True加上大学习率,后面 4.2 会专门说这个。
3.3 三个开箱参数:图像尺寸、学习率与早停
训练参数直接决定你这个源码项目能跑出多少分。下面这组是烧伤分类项目比较省事的起点,我复现同类项目时基本沿用这个底座,再按数据量微调:
| 参数 | 起点值 | 调整策略 |
|---|---|---|
| 输入尺寸 | 224 x 224 | 创面纹理细节不足时升到 384,但训练时间翻倍 |
| batch size | 16 | 显存不够降到 8,同时学习率减半 |
| 优化器 | AdamW | weight_decay 建议 0.01,比 SGD 稳 |
| 学习率 | 分类头 3e-4,骨干 1e-5 | 解冻骨干后一定要降学习率 |
| epoch | 30 起 | 加到 val loss 不再下降为止 |
训练循环里最关键的是“按 val loss 存模型,而不是按 val acc”。
best_val_loss = float("inf") no_improve_epochs = 0 for epoch in range(max_epochs): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) val_loss = evaluate(model, val_loader, criterion) if val_loss < best_val_loss: best_val_loss = val_loss no_improve_epochs = 0 torch.save(model.state_dict(), "best.pt") else: no_improve_epochs += 1 if no_improve_epochs >= 5: print(f"early stop at epoch {epoch}") break逻辑说明:evaluate只返回 val loss 不返回 acc,是故意的。烧伤数据类别不均衡,准确率在“全猜浅Ⅱ度”时也能到 70% 以上,用它判断是否保留模型会把最需要关注的深Ⅱ度和Ⅲ度样本带偏。val loss 不降反升持续 5 个 epoch,就加载历史最优权重,这是最常见也最有效的早停方案。
3.4 多任务和分割分支:先别贪
有些“优质项目实战”会附一套 U-Net 风格的分割分支,同时输出分类和像素级掩膜。做法通常是在 ResNet 骨干后并行接一个解码器,总 loss 设为0.8 * DiceLoss + 0.2 * CrossEntropyLoss。原理上合理,但第一次复现时我不建议开双头训练。原因很实际:两个任务的收敛速度不同,尤其在几十个 epoch 内,联合训练很容易出现分类没学好、分割也没学好的两头空局面。
4. 烧伤检测复现避坑:四个高频问题和排查线索
4.1 类不均衡伪装成高准确率
现象:训练集和验证集准确率都到了 90% 以上,看起来“项目跑通了”,但一打印分类报告,深Ⅱ度和Ⅲ度的 recall 只有 20%。原因:烧伤样本里浅Ⅱ度占大头,深Ⅱ度和全层烧伤样本少,模型偏向学大类的统计规律。解决方法:先看混淆矩阵,再给损失函数加权。
from sklearn.metrics import classification_report, confusion_matrix y_true, y_pred = [], [] for images, labels in val_loader: logits = model(images.to(device)) y_pred.extend(logits.argmax(1).cpu().tolist()) y_true.extend(labels.tolist()) print(classification_report(y_true, y_pred, digits=3))加权方法是给CrossEntropyLoss传weight,权重按“类别样本数的倒数”做平滑:
import torch # 手动给一个类的权重,顺序与 label_map 一致 class_weights = torch.tensor([1.0, 1.5, 3.0, 8.0]) criterion = torch.nn.CrossEntropyLoss(weight=class_weights)权重大小不是越大越好。我试过按严格样本数反比把全层烧伤权重拉到 20 倍,结果模型反而把大量浅Ⅱ度误判成Ⅲ度。一般先给 2~4 倍梯度,观察 confusion matrix 里低频类的 recall 是否抬升,再逐步加大。医学判级场景里,模型宁可过度倾向严重类别,也不该把深Ⅱ度漏成浅Ⅱ度。
4.2 迁移学习反而掉点:预训练特征被大学习率打散
现象:训练脚本如果直接全量微调,学习率设成标准的 1e-3,loss 第一个 epoch 就震荡,val loss 远高于随机初始化。原因:ImageNet 预训练的骨干特征已经被训练得很好,新换上的分类头是随机权重。梯度刚开始主要来自随机头,大学习率回传到骨干,把预训练学到的纹理和颜色过滤器毁得七七八八。
解决分两步。第一步,分类头单独训几个 epoch;第二步,解冻骨干,把学习率降到 1e-5 左右继续微调。
# 第一阶段:只训练分类头 for name, param in model.named_parameters(): param.requires_grad = name.startswith("fc") optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=3e-4, weight_decay=0.01 ) # 第二阶段:解冻全部骨干 for param in model.parameters(): param.requires_grad = True optimizer = torch.optim.AdamW( model.parameters(), lr=1e-5, weight_decay=0.01 )这套“先头后干”的顺序基本适用于所有医疗图像小数据集的迁移学习场景。如果你发现某个“源码包”里直接写了lr=1e-3且没有任何冻结逻辑,大概率作者只在小规模 demo 上跑通,并没有仔细考虑过泛化。
4.3 颜色增广把烧伤判据“洗掉”
现象:训练时加入了RandomBrightnessContrast、RandomSaturation,甚至随机色调偏移,然后发现验证集崩溃。原因:烧伤深度分级的核心判据就是创面颜色——红色区域多为浅Ⅱ度,苍白或蜡白是深Ⅱ度,焦黑坏死则是Ⅲ度。一旦颜色增广过猛,模型只能退而寻找纹理和形状信息,而这些信号在真实照片里稳定性远不如颜色。
我一般把颜色增广限制在极窄范围:
from torchvision import transforms as T train_transform = T.Compose([ T.RandomResizedCrop(224, scale=(0.8, 1.0)), T.RandomHorizontalFlip(), T.ColorJitter(brightness=0.1, contrast=0.1, saturation=0, hue=0), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])饱和度和色调直接设为 0,亮度对比度各只给 0.1,这个幅度足够应付拍摄环境差异,又不会把“红”变成“白”。如果项目自带的增广里有RandomGrayscale,建议直接删掉,灰度图对烧伤判级基本没有可用信息。
4.4 zip 源码跑不通的“三件套”排查
现象:解压后运行python train.py,报错要么是ModuleNotFoundError,要么是加载预训练权重时size mismatch。原因大概是三类:requirements 没装全但不报依赖错、预训练权重文件缺失或路径不对、Python 版本与 torch 安装版本错位。
排查顺序固定成“三件套”。先看入口文件最前面的 import,凡是第三方库先 pip 补上;再找模型加载那一段,如果代码写了torch.load("weights/resnet50.pth")而项目目录里没有这个文件,那这就是第一问题;最后用一个短命令确认 torch 和 torchvision 能同时导入:
python -c "import torch, torchvision; print(torch.__version__, torchvision.__version__)"如果还出现size mismatch,多半是加载的权重来自另一种模型架构,比如代码期望 ResNet50 你却在 torchvision 里取了 ResNet101。不要试图硬改state_dict,把加载方式改成 torchvision 自带的weights=...IMAGENET1K_V1最省事。
5. 没有GPU怎么办:用CPU把烧伤检测源码跑出有效结果
5.1 思路转变:别用“训练”的思路,用“特征再利用”
很多人的困惑是手里只有一台普通笔记本,CPU 上跑 ResNet50 全量微调,一个 epoch 几十分钟,看起来完全做不下去。解决办法不是去买 GPU,而是把任务降维成“线性探测”(linear probe):用预训练模型把图像转成特征向量,然后用一个线性层做分类。
这么做的道理在于,烧伤分级数据量本来就少,模型真正需要学习的是一个“如何在预训练特征空间里划界”的浅层问题,没必要重新训练卷积核。CPU 上跑 ResNet 推理一张 224 的图确实要时间,但如果只提取一次特征保存到内存,成本是一次性的。这比硬上剪枝算法实在得多——剪枝算法是同一模型反复迭代时用的加速手段,第一次复现动剪枝很容易把精度压垮。
5.2 一个能下班的 CPU 训练脚本
import torch import torchvision from torch import nn # 主干提取特征,但不做分类 base = torchvision.models.resnet18( weights=torchvision.models.ResNet18_Weights.IMAGENET1K_V1 ) base.fc = nn.Identity() # 去掉分类头,输出变成 512 维特征 for param in base.parameters(): param.requires_grad = False head = nn.Linear(512, 4)这个base一旦加载完毕,训练阶段它完全处于“数据搬运”状态,不参与梯度计算。整个模型的待训练参数只有head的权重和偏置,也就 2048 个参数,CPU 上跑十来个 epoch 几分钟就能完成。配合前面的数据集划分,效果虽然不如全量微调,但对几百张图的烧伤分类项目已经够用。
# 把特征放进线性头里训练 features = base(images) # [batch, 512] logits = head(features) # [batch, 4] loss = criterion(logits, labels)如果嫌 ResNet18 还慢,可以把输入尺寸从 224 降到 160,ResNet18 的瓶颈在最后几个 stage,降分辨率带来的速度收益远大于精度损失。160 尺寸配合随机裁剪缩放,基本不影响读色块。
5.3 CPU 推理出口:导出 ONNX 做轻量验证
训练完成后,建议导出 ONNX。这样可以在不依赖 torch 的 Python 进程里做推理,也方便你把它接到 Web 端或手机端做后续项目演示。
model.eval() dummy = torch.randn(1, 3, 160, 160) torch.onnx.export( model, (dummy,), "burn_classifier.onnx", input_names=["image"], output_names=["logits"], opset_version=12 )导出后可以用 onnxruntime 加载,输入直接传 NCHW 的 float32 张量,不需要再引入 torch。逻辑说明:这个藏在“最后一章”的操作往往是整个源码项目最容易被忽略的部分,但实际部署价值很高,因为医生端目标机器通常没有 GPU,ONNX 推理一次只需一次前向。
6. 把“大概率对”变成“可交付”:验证模型与不确定性设计
模型在验证集上跑出 85% 准确率,不等于可以直接交给医生。烧伤检测的代价是非对称的,漏掉一个深Ⅱ度比多报一个浅Ⅱ度危险得多。所以我最后都会再加一道不确定性过滤:当模型对某个样本的预测概率低于阈值时,输出“无法判断”。这个设计在医疗场景中叫“拒判”,比硬着头皮猜一个结果更安全。
probs = torch.softmax(logits, dim=1) conf, pred = torch.max(probs, dim=1) # 低于 0.7 直接交给人工复核 if conf.item() < 0.7: pred = -1这个技巧对深Ⅱ度和Ⅲ度的边界尤其有用,因为这两类的视觉相似度天然高,强行细分不如主动承认不确定。阈值 0.7 是我常用起点,你可以画一条“阈值—准确率”曲线来决定:让漏诊率足够低时,保留多少样本给人工。
我现在的习惯是,接触任何一份皮肤烧伤检测源码,到手第一件事不是看模型结构,而是跑两个 20 行以内的脚本:一个确认过拟合单个 batch,另一个打印验证集的 confusion matrix。两个都不超过十分钟,但能挡掉后面大半的无效调试。这个顺序帮我躲过不止一次返工,希望你也能在复现这个项目时少走一段弯路,希望帮到你。
本文还有配套的精品资源,点击获取