ARTICLE DETAIL

资讯详情

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

森林桩燃烧识别:图像分类数据集与ResNet50训练实践

森林桩燃烧识别:图像分类数据集与ResNet50训练实践 简介面向无人机遥感与森林火灾监测场景的图像分类数据集包含大型无人机视角下森林桩燃烧与未燃烧两类样本适用于图像分类网络训练、yolov5分类任务及燃烧识别算法验证。资源已按训练集和测试集文件夹清晰划分训练集图片总数约2万张、测试集约8.6千张无需额外拆分同时附带类别字典json文件方便读取标签映射并配有show脚本可随机抽样可视化图片辅助观察样本质量与类别分布。压缩包共2000个文件以jpg图片为主另有python脚本和json配置整体807.61MB7z格式便于一次性下载解压。已有86人学习适合需要现成数据集开展燃烧识别实验的研究者、竞赛选手或深度学习入门者可直接套用常见分类网络或接入yolov5分类流程省去数据采集与整理环节。1. 图像分类数据集里的特殊场景大型无人机视角下如何识别森林桩燃烧做森林防火巡检的人大多有同感最难的从来不是大片林火识别而是树桩这类不起眼的残余燃烧。采伐迹地上遗留的树桩被点燃后从大型无人机上百米的高度看下去火焰区域常常只占画面几十个像素烟和树影纠缠在一起肉眼都要盯几秒才能确认。这个「图像分类数据集」就是为这个场景准备的——它把大型无人机视角下的森林桩燃烧图像按图像分类任务组织好带着划分好的数据文件夹和类别字典文件训练时不用再花一周时间做数据整理。它能直接回答一个问题一张俯拍图里的目标树桩到底处于燃烧、已熄灭还是未燃烧状态。适合做森林防火算法研发、无人机巡检落地以及想拿真实域数据练手图像分类模型的人。2. 拿到数据先别急着训练把文件夹划分和类别字典文件摸透这类数据集的交付方式通常走「文件夹保存」而不是单一 CSV 或 TFRecord原因是图像分类任务里文件夹结构能直接被深度学习框架消费也能让使用者一眼看清每个类别的样本规模。标题里明确写了包含划分好的数据和类别字典文件那就先说清楚这两样东西到底怎么组织、拿到手后第一件事该做什么。2.1 文件夹划分的底层逻辑一张图对应一个类别标签常见的划分方式是train / val / test三个平级目录每个目录下再按类别建子文件夹类名即文件夹名。比例上多数数据集按 8:1:1 或 7:2:1 切分切分时保证每个类别在三份数据里的分布尽量一致。对无人机俯拍数据来说我还会特别留意一个点划分必须按「航线/架次」而不是按单张图随机打散否则同一棵树桩连续几帧里一帧进训练集、一帧进验证集评估结果会虚高。这点后面避坑章再展开。拿到数据集后先扫目录结构不要直接开训。下面的脚本会把每个 split 下每个类别的图片数统计出来import os def scan_dataset(root): stats {} for split in sorted(os.listdir(root)): split_path os.path.join(root, split) if not os.path.isdir(split_path): continue stats[split] {} for cls in sorted(os.listdir(split_path)): cls_path os.path.join(split_path, cls) if os.path.isdir(cls_path): stats[split][cls] len(os.listdir(cls_path)) return stats for split, cls_count in scan_dataset(ForestStumpBurn).items(): print(split, cls_count)这段代码用os.listdir做两层扫描第一层拿到三个 split 目录第二层拿到每个 split 下的类别文件夹并统计图片数量。运行后立刻能看到数据是否完整如果某个类别只在 test 里出现、train 里没有那就是划分事故训练时会直接报错。如果某个类别图片数为 0说明目录里有空文件夹ImageFolder 在装载时会跳过它静默造成类别数与字典不一致。顺带提醒一句无人机导出的影像偶尔会有坏帧JPEG 头完整但打不开。统计数量的同时可以抽查几张图能否被 PIL 正常解码把坏帧提前清掉别等训练到一半 DataLoader 抛异常才排查。2.2 类别字典文件怎么用三行代码完成加载与校验类别字典文件是这个数据集的另一个交付物常见形态是一个class_to_idx.json内容大概是{burning: 0, smoldering: 1, none: 2}这种「类名到数字索引」的映射有时候也会给一个纯文本classes.txt每行一个类名。它的作用是把文件夹名翻译成模型输出层能用的整数标签。加载方式如下import json with open(class_to_idx.json, r, encodingutf-8) as f: class_to_idx json.load(f) idx_to_class {idx: name for name, idx in class_to_idx.items()} print(class_to_idx) print(idx_to_class)加载后千万不要直接开训先和文件夹做一次交叉校验这是最常见也最值得防的坑字典里写了三个类目录里却有四个文件夹或者文件夹改名后字典没同步训练时要么 KeyError要么标签错位。校验脚本root ForestStumpBurn/train folders sorted( d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d)) ) missing [c for c in folders if c not in class_to_idx] extra [c for c in class_to_idx if c not in folders] print(文件夹有但字典没有:, missing) print(字典有但文件夹没有:, extra)这里missing和extra两个列表分别指向两个方向的信息缺失。文件夹有而字典没有说明字典漏了类需要补进去字典有而文件夹没有说明目录不完整训练时会因样本缺失导致类别数对不上模型输出。两条都不能放过。2.3 别把「燃烧」当成二值标签先摸清类别语义再谈训练图像分类新手常犯的一个错是拿到数据集后不读类别字典就开始训练默认「燃烧 vs 未燃烧」是清晰的二分类。实际上森林桩燃烧的语义是一个连续光谱明火燃烧中的树桩、火焰已灭但仍在冒烟的树桩、表面焦黑但内部有余温的树桩、以及完全冷却的未燃烧树桩边界非常模糊。类别字典里如果写了三个甚至四个类那说明数据标注本来就是按多分类状态去切分的。我的习惯是在训练前先把类别字典的语义排列出来给每一类写一句话判定标准。比如burning必须肉眼可见明火或显著白烟smoldering强调无明火但有烟或焦黑痕迹none指无任何燃烧迹象的树桩或背景。这个动作有两个实际收益一是训练后看混淆矩阵时能定位哪些类别互相打架二是给后期清洗标签提供依据——两个标注员对同一张smoldering的图可能给出不同标签这种天然标签噪声光靠模型是压不下去的。明确了类别语义后训练时可以考虑加一点label_smoothing对边界模糊的样本宽容一些这个参数到第 3 章的代码里再细说。3. 用图像分类模型在本地跑通森林桩燃烧识别模型选型、预处理与超参数清单目录结构摸清后下一步就是把图像分类模型训练跑起来。这一章按「选模型 → 定预处理 → 训练 → 评估」的顺序展开给出一套可以直接复现的最小流程。以下代码基于 PyTorch这是做森林图像识别最稳妥的生态数据集消费、预训练权重、部署工具链都全。3.1 图像分类模型怎么选从 ResNet50 起步别急着上 Transformer针对这个数据集的规模——类别数通常在三到五个训练样本量在数千张的级别——我的选型建议是第一版基线用 ResNet50而不是一上来就搬 Vision Transformer 或者当前最新的图像分类模型。原因很实际Transformer 系模型的效果依赖大规模预训练和大数据微调几千张俯拍图喂进去容易学不动泛化反而不如卷积网络。ResNet50 的残差结构在这个样本量级下不容易翻车微调收敛快出问题也好排查。如果后续要上机载端做实时识别考虑的就不是准确率而是无人机视觉感知场景下的推理时延这时可以换 MobileNetV3 或 EfficientNet 的轻量版本用蒸馏的方式把 ResNet50 的知识迁过去。各选项的定位如下模型输入分辨率适用场景注意点ResNet18224x224快速验证、资源受限对火焰细纹理可能欠拟合ResNet50256x256基线首选兼顾精度与稳定训练时间适中部署偏重EfficientNet-B0256x256精度与算力的折中缩放系数敏感需调输入尺寸ViT 小模型224x224预训练充分时才建议试样本少容易过拟合慎用第一版强烈建议就用 ResNet50 配合官方 ImageNet 预训练权重冻结前几层只微调后面几层几十分钟就能看到可用的结果后面再根据瓶颈调整。3.2 预处理与数据增强面向俯视视角的小目标场景调参森林桩燃烧图像和普通分类图集的最大差别在于目标小、背景杂、光照不稳定。飞行高度越高树桩和火焰在画面里占的比例越小这时候预处理的第一决策是分辨率而不是增强方式。224x224 是最低标准如果 GPU 显存允许我一般直接上 256 甚至 320——小目标场景里输入分辨率对精度的影响比模型宽度更明显。数据增强方面俯视图没有「上下颠倒」的物理问题所以水平翻转、垂直翻转都能用随机旋转角度控制在 15 度以内避免把目标的朝向语义破坏。森林场景的颜色本身就是强判别信号烧焦的黑色、明火的橙黄、活木材的浅色差异很大因此色彩抖动类增强要克制尤其不要做大幅度的色调偏移否则模型学到的是被扭曲的色彩空间而不是真实的燃烧特征。CutMix、MixUp 这类混合增强对标签噪声有天然容忍度可以加在训练里。相应的预处理管线这样写from torchvision import transforms IMG_SIZE 256 train_tf transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.5), transforms.RandomRotation(15), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])这里的Resize会把所有图统一到 256x256避免 batch 内尺寸不一致RandomHorizontalFlip和RandomVerticalFlip各开 0.5 概率因为俯视角图不存在「天空在上」的约束RandomRotation(15)旋转幅度控制在 15 度超过这个值会让焦黑痕迹和树桩形状发生形变。Normalize 用的是 ImageNet 的统计值因为我们用的是预训练权重输入分布必须和预训练时保持一致。验证集不做任何随机增强只做 Resize 和归一化保证评估结果稳定可复现。3.3 最小训练流程PyTorch 从 ImageFolder 到 best.pt数据加载直接用torchvision.datasets.ImageFolder它天然适配 2.1 节的文件夹结构会把每个子文件夹名映射为类别索引打开文件夹那一刻就顺手完成了文件夹和标签的绑定。完整训练代码import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, models train_ds datasets.ImageFolder(ForestStumpBurn/train, transformtrain_tf) val_ds datasets.ImageFolder(ForestStumpBurn/val, transformval_tf) train_dl DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_dl DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) model.fc nn.Linear(model.fc.in_features, len(train_ds.classes)) model model.to(cuda) criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20) best_acc 0.0 for epoch in range(20): model.train() train_loss 0.0 for imgs, labels in train_dl: imgs, labels imgs.to(cuda), labels.to(cuda) optimizer.zero_grad() loss criterion(model(imgs), labels) loss.backward() optimizer.step() train_loss loss.item() * imgs.size(0) scheduler.step() model.eval() correct total 0 with torch.no_grad(): for imgs, labels in val_dl: imgs, labels imgs.to(cuda), labels.to(cuda) preds model(imgs).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) val_acc correct / total print(fepoch {epoch:02d} | loss {train_loss / len(train_ds):.4f} | fval_acc {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save({ model: model.state_dict(), class_to_idx: train_ds.class_to_idx, }, best.pt)几个参数说清楚为什么这么定。label_smoothing0.1是我在标签噪声明显的场景里的固定操作森林桩燃烧本身的边界模糊硬标签 0/1 让模型对边界样本过于自信平滑后相当于告诉模型「这个样本有 10% 概率属于别的类」梯度更稳泛化更好。AdamW配lr3e-4是微调预训练权重的常用起点比从头训练的 1e-3 保守如果发现 loss 前期不降把 lr 调到 1e-4 而不是加大。CosineAnnealingLR配合T_max20让学习率在 20 个 epoch 里从 3e-4 余弦降到接近 0省去手动调下降节点的麻烦。保存模型时把class_to_idx一起存进去推理时不需要再额外读字典文件直接加载这个 dict 就能把输出索引映射回类名。3.4 评估不能只看 accuracy混淆矩阵与 PR 曲线训练完如果只看val_acc这一个数字很容易被误导。森林桩燃烧这种类别天然不平衡的场景可能 90% 的图都是「未燃烧」模型全预测成「未燃烧」也有 90% 的准确率但这个模型毫无用处。每次训练结束我必做两件事打印混淆矩阵画 PR 曲线。from sklearn.metrics import confusion_matrix, precision_recall_fscore_support all_preds, all_labels [], [] model.eval() with torch.no_grad(): for imgs, labels in val_dl: imgs imgs.to(cuda) preds model(imgs).argmax(dim1).cpu() all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) print(confusion_matrix(all_labels, all_preds)) print(precision_recall_fscore_support( all_labels, all_preds, labelslist(range(len(train_ds.classes)))))混淆矩阵看的是「真实燃烧」里有多少被分错到「冒烟」或「未燃烧」——对巡检场景来说漏报比误报危险得多燃烧类别的 recall 必须优先保证。PR 曲线则用来定推理时的置信度阈值这个在最后一章展开。到此训练闭环跑通后面真正耗时间的其实是各种隐蔽的翻车点。4. 森林桩燃烧识别实践中的五个典型翻车现场从标签噪声到类别不平衡训练代码能跑只是起点。下面这些坑是我在类似项目里实打实踩过的每一条都按「现象 → 原因 → 解决」说清楚希望能帮你把排查时间省下来。4.1 按图片随机切分的隐性泄露验证集虚高外场崩掉现象验证集准确率能到 98%模型导出后拿到新的外场数据一测直接掉到 70% 出头。原因无人机采集的森林数据是连续航拍帧同一棵树桩会在相邻帧里反复出现。如果划分train/val时按单张图随机打散同一目标的近似帧同时进了训练集和验证集模型相当于提前看到了答案。这是典型的「数据泄漏」表现就是验证集虚高、外场泛化崩。解决划分数据必须按航线或架次分组保证同一架次的所有帧要么全在训练集、要么全在验证集。拿到手的数据集如果是随机切分的自己按文件夹重切一次import random import shutil from pathlib import Path def split_by_mission(src_root, out_root, val_ratio0.2): missions [p for p in Path(src_root).iterdir() if p.is_dir()] random.shuffle(missions) val_count max(1, int(len(missions) * val_ratio)) for i, mission in enumerate(missions): target val if i val_count else train for img in mission.glob(*): cls mission.name # 假设每个架次目录下直接是图片 dst_dir Path(out_root) / target / cls dst_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy(img, dst_dir / img.name)这段代码的假设是原始数据按架次分文件夹、然后按类别归并。切分的关键是random.shuffle(missions)只在我们最终要放进同一个集合的架次之间做随机架次本身的完整性不被破坏。如果架次数量很少val_ratio可以调到 0.3保证验证集至少有几十个独立架次评估才有统计意义。4.2 类别字典文件读乱码BOM 和编码的前后踩坑现象json.load读字典文件报UnicodeDecodeError或者读出来类名前面带\ufeff这种不可见字符导致class_to_idx里的 key 和文件夹名永远对不上。原因Windows 下用记事本保存文本文件时默认带 UTF-8 BOMPython 的utf-8编码不认 BOM 头utf-8-sig能自动去掉 BOM 但很多人不知道。中文类名场景尤其容易踩因为这类数据集经常直接拿中文做文件夹名。解决统一用utf-8-sig读并在加载后立即打印检查。改动只有一行with open(classes.txt, r, encodingutf-8-sig) as f: classes [line.strip() for line in f if line.strip()]utf-8-sig对无 BOM 的文件也兼容是读取文本类字典文件最稳的编码选项。存文件的时候反过来统一用utf-8写不要带 BOM避免后续其他脚本读不了。4.3 标签噪声烟与影的边界让模型反复横跳现象训练 loss 一直降不下来验证集里被分错的样本集中在「冒烟」和「未燃烧」之间用热力图看注意力模型盯着阴影和蓝色烟雾区域而不是树桩本体。原因森林桩燃烧的标签天然带主观性明火阶段好标暗火和冒烟阶段两个标注员可能给不同标签。训练集存在不少「错误但被硬编码」的标签模型在矛盾样本上反复横跳学不到稳定的判别边界。解决先用硬标签训练一版然后取出验证集里 loss 最高的前 100 张图做人工复核。被模型认为「极难」的样本大概率是标注边界最模糊的图人工看一遍能快速发现哪些标签该修正。复核代码model.eval() losses {} with torch.no_grad(): for imgs, labels in val_dl: imgs, labels imgs.to(cuda), labels.to(cuda) logits model(imgs) loss nn.functional.cross_entropy(logits, labels, reductionnone) for i, l in enumerate(loss): losses[val_ds.samples[len(losses)]] l.item() sorted_samples sorted(losses.items(), keylambda x: x[1], reverseTrue)[:100] for (path, _), loss in sorted_samples: print(f{loss:.4f} {path})这里用reductionnone拿到每个样本独立的 loss再按 loss 降序排。这些样本值得花一个下午人工过一遍如果发现某类标签确实标错了直接改文件夹归属如果只是难就留着给训练集增加难度。清洗一轮后重训验证集准确率通常能涨两三个点。4.4 类别不平衡accuracy 虚高掩盖了燃烧类 recall 不足现象整体 accuracy 97%打开混淆矩阵发现燃烧类别的 recall 只有 40%超过一半的燃烧桩被漏报。原因森林里未燃烧树桩的数量远多于燃烧中的树桩数据天然不平衡。训练时模型发现「全预测为负类」也能拿到很低的 loss于是一味偏向多数类。解决评估指标切到 PR-AUC 和混淆矩阵训练时对少数类加权。最简单是给CrossEntropyLoss传weight权重按类别样本数反比from collections import Counter counts Counter([label for _, label in train_ds.samples]) total sum(counts.values()) weights torch.tensor( [total / counts[i] for i in range(len(train_ds.classes))], dtypetorch.float32, devicecuda) criterion nn.CrossEntropyLoss(weightweights, label_smoothing0.1)权重这样算让样本少的类别在 loss 里获得更大梯度。注意加了weight后学习率可能要再降一点否则少数类容易过拟合。另一个更稳的替代方案是用WeightedRandomSampler做训练集重采样两者原理相同但前者实现更直接推荐起步先用它。4.5 无人机影像本身的格式坑RGBA 通道和超宽条带现象DataLoader报维度错误或者训练时损失正常但精度极低检查发现部分图片是 RGBA 四通道模型输入被强制截断后颜色分布发生了偏移。原因部分无人机导出的影像带透明通道或 16 位位深ImageFolder默认读成 RGB 时会发生静默截断模型看到的颜色分布和原始图像不一致特征全偏。解决数据装载前统一做一次通道检查和格式转换from PIL import Image def normalize_image(path): img Image.open(path) if img.mode ! RGB: img img.convert(RGB) return img train_ds datasets.ImageFolder( ForestStumpBurn/train, transformtrain_tf, loaderlambda p: normalize_image(p), )loader参数允许我们替换默认的读图函数。img.mode ! RGB时主动转成 RGB从源头杜绝四通道和灰度图混入。位深高于 8 位的图在convert时会自动缩放到 8 位颜色信息不会丢太多。5. 从分类到可靠判断用类激活图检验注意力把阈值设成两个挡位训练收敛只是第一步。在真实巡检里模型输出一个类别还不够还要让现场人员知道「该不该信」。这一章说两个我每次部署前必做的动作。5.1 用 Grad-CAM 把模型的注意力显形排除捷径图像分类模型在森林场景里最容易走捷径学到的不是「树桩在燃烧」而是「画面偏橙色」或「有烟雾」。这类捷径在外场数据上一碰就碎。Grad-CAM 能让我们直观检查模型到底在盯哪里。对 ResNet50在最后一个卷积层注册 hook 拿到梯度即可model.eval() target_layer model.layer4[-1] activations {} def hook_fn(module, input, output): activations[feat] output.detach() h target_layer.register_forward_hook(hook_fn) img, _ val_ds[0] logits model(img.unsqueeze(0).to(cuda)) pred logits.argmax(dim1).item() feat activations[feat] grad torch.autograd.grad(logits[0, pred], feat)[0] weights grad.mean(dim(2, 3), keepdimTrue) cam (weights * feat).sum(dim1, keepdimTrue).relu() cam torch.nn.functional.interpolate( cam, size(IMG_SIZE, IMG_SIZE), modebilinear) cam cam.squeeze().cpu().numpy()逐行拆一下register_forward_hook在前向传播后把最后一层卷积的特征图存下来torch.autograd.grad计算预测类别对特征图的梯度梯度全局平均得到每个通道的权重特征图按通道加权求和再取 ReLU就是类激活热力图。把热力图叠加在原图上观察判断标准就两条明火样本的注意力是否集中在树桩区域烟雾样本的注意力是否和烟柱位置重叠。如果模型盯着天空、阴影或者画面边缘说明训练集里存在捷径样本需要回去补数据而不是调模型。5.2 置信度阈值设两个挡位把误报率压到巡检可接受范围分类模型直接argmax输出的是唯一类别但在巡检告警里我一般不用 argmax而是取「燃烧类」的 softmax 概率设高低两个阈值概率大于 0.7 直接告警在 0.4 到 0.7 之间标为待人工核查低于 0.4 不告警。这样做的原因是森林场景里漏报的代价远高于误报双阈值给现场复核留了缓冲。阈值怎么定用验证集画出 PR 曲线找拐点import numpy as np from sklearn.metrics import precision_recall_curve probs [] with torch.no_grad(): for imgs, _ in val_dl: probs.extend(torch.softmax(model(imgs.to(cuda)), dim1) .cpu().numpy()) burn_probs np.array(probs)[:, class_to_idx[burning]] binary_labels (np.array(all_labels) class_to_idx[burning]).astype(int) precision, recall, thresholds precision_recall_curve(binary_labels, burn_probs) for t in [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]: idx np.searchsorted(thresholds, t, sideright) print(fthr{t} precision{precision[idx]:.3f} recall{recall[idx]:.3f})这段代码把验证集所有样本的燃烧类概率提取出来逐个阈值打印对应的精确率和召回率。选阈值的习惯是先保证 recall 到 0.95 以上再谈 precision因为对火情来说放掉真实燃烧桩的代价最大。双阈值方案也是从这类 PR 曲线上推出来的——往往存在一个区间阈值往下挪一点 recall 涨很多而 precision 跌得不多区间内的值都适合做低告警线。过去我接过不少类似项目最深的教训是数据没洗清楚之前不要碰模型结构类别字典和文件夹对不上、按图片随机切分、标签互相矛盾这三个问题能消耗比训练多一倍的时间。现在拿到任何新的图像分类数据集我的固定动作永远是先扫目录、再读字典、跑一版基线看混淆矩阵这三步做完再谈别的。这一套流程下来森林桩燃烧识别这类真实域分类问题基本不会走偏——希望帮到你。本文还有配套的精品资源点击获取
返回列表