简介:面向无人机视角下森林桩燃烧状态识别任务,这份图像分类数据集提供完整可直接使用的训练与测试划分。数据分为两类:燃烧与未燃烧,训练集约2万张、测试集约8600张,已按文件夹形式整理,配套类别字典json文件,可无缝接入YOLOv5分类分支或各类CNN分类网络;内置show脚本可快速可视化样本分布与预测结果。压缩包共2000个文件,以1998张jpg图像为主体,另含1个Python脚本与1个json配置文件,整体约807.61MB,适合计算机视觉初学者练习完整分类流程,也可作为森林防火、遥感监测方向的实验数据。目前已有86人学习下载,数据划分清晰、组织规范,省去自行采集与清洗的时间,便于直接聚焦模型调参与效果对比。
1. 无人机视角下的森林桩燃烧识别:为什么这份图像分类数据集值得用
森林消防的无人机巡检里,最容易被低估的是“火点目标识别”这一步。飞手在几公里外的山脊线上拍回一组画面,树桩阴燃的烟和高海拔石壁的阴影很容易糊成一片。真正可落地的方案,往往从一个按文件夹分好、带类别字典文件的图像分类数据集开始。它做的事很朴素:把无人机视角下的树桩燃烧与否做成二分类或更细的多分类,训练集、验证集、测试集路径结构固定,标签映射存在 JSON 文件里,拿来就能接 PyTorch、TensorFlow 或任意图像分类算法。适合两类人:一是拿图像分类练手、想尽快跑通全流程的开发者;二是要落地森林防火或生态监测边缘端模型的团队。
2. 数据集的目录组织:划分好的文件夹与类别字典文件要怎么读
2.1 三段式划分的目录结构:为什么 train / val / test 必须分开建
拿到这份数据集,先别急着写训练脚本,第一步是把目录结构摸清楚。常见做法是根目录下放 train、val、test 三个主目录,每个主目录内部再按类别建子文件夹,子文件夹的名字就是类别名。PyTorch 的torchvision.datasets.ImageFolder默认就把这种结构当索引,类别名取文件夹名,类别顺序按 ASCII 排序生成。
dataset/ ├── train/ │ ├── normal_stump/ │ ├── burning_stump/ │ ├── smoldering_stump/ │ └── background/ ├── val/ │ ├── normal_stump/ │ ├── burning_stump/ │ ├── smoldering_stump/ │ └── background/ ├── test/ │ ├── normal_stump/ │ ├── burning_stump/ │ ├── smoldering_stump/ │ └── background/ └── labels.json之所以坚持三段式而不是只分 train 和 val,是因为在图像分类任务里,test 子目录是最终选型时才碰的“黑匣子”。日常训练调参只允许用 train 和 val,val 用来决定什么时候早停、哪个学习率合适;模型全部定稿后再在 test 上跑一次,得到真正的泛化指标。很多初学者贪方便只分两段,调着调着就把 val 当 test 反复试,最终报告出来的准确率虚高,部署时就翻车。
另一个值得注意的点:类别子文件夹的名字尽量不要用中文,也不要带空格。ImageFolder虽然能读中文路径,但后续做迁移学习、跨平台部署时,TensorFlow 服务、ONNX Runtime 和嵌入式推理框架对中文路径的编码处理并不可靠。用normal_stump、burning_stump这类下划线命名,配合 labels.json 做中文显示名的映射,是最省事的组合。
2.2 类别字典文件的核心:name2idx 与 idx2name 两种组织方式
labels.json 是这份数据集里最容易用错的文件。它看起来就是一个字典,但存在两种组织风格,直接影响训练代码怎么写。
第一种叫 name2idx,key 是类别名,value 是整数标签:
{ "normal_stump": 0, "burning_stump": 1, "smoldering_stump": 2, "background": 3 }第二种叫 idx2name,key 是整数标签的字符串形式,value 是类别名:
{ "0": "normal_stump", "1": "burning_stump", "2": "smoldering_stump", "3": "background" }两种都能完成映射,但使用场景不同。训练阶段,ImageFolder内部生成的class_to_idx就是 name2idx 结构,所以如果 labels.json 采用 name2idx,可以直接把class_to_idx和 labels.json 做比对,快速检查文件夹顺序有没有意外变化。部署阶段,模型输出的是整数 argmax,需要把 int 转回可读类名,这时候 idx2name 更顺手。
真正坑人的是 JSON 本身的类型:JSON 的 key 永远是字符串。如果 labels.json 是 idx2name 风格,代码里写labels[0]会直接 KeyError,必须写labels[str(0)]。下面的核对脚本会说明怎么处理。
另外,这份数据集的标签设计我还想多说一句:类别里同时出现burning_stump和smoldering_stump,说明数据不只是简单的“着火/没着火”二分类。明火是火焰可见、亮度高;阴燃是内部缓慢氧化,外表发黑但偶尔有烟气。这两类在可见光图像里边界非常模糊,分出来对训练和评估都有好处,后面第 4 章会展开讲。
2.3 打开数据集后的第一件事:写一段脚本核对类别数、样本数与标签映射
任何数据集到手,我都建议先写一个一次性核对脚本,把三件事确认清楚:每个类别文件夹下的图片数量、labels.json 里的类别数量、文件夹顺序和字典映射是否一致。这个脚本不耗时间,但能拦住后面 80% 的“莫名其妙”的报错。
import json import os from collections import Counter dataset_root = "dataset" with open(os.path.join(dataset_root, "labels.json"), "r", encoding="utf-8") as f: labels = json.load(f) # 兼容 name2idx 和 idx2name 两种风格 if isinstance(list(labels.values())[0], int): name2idx = labels else: name2idx = {v: int(k) for k, v in labels.items()} print("类别字典中的映射关系:", name2idx) for split in ["train", "val", "test"]: split_dir = os.path.join(dataset_root, split) if not os.path.exists(split_dir): print(f"[警告] 缺少 {split} 目录") continue counts = Counter() for class_name in os.listdir(split_dir): class_dir = os.path.join(split_dir, class_name) if os.path.isdir(class_dir): counts[class_name] = len([f for f in os.listdir(class_dir) if f.lower().endswith((".jpg", ".jpeg", ".png"))]) print(f"{split} 目录类别与数量:{dict(counts)}") # 检查文件夹类名是否都在 labels.json 里 missing = set(counts.keys()) - set(name2idx.keys()) if missing: print(f"[警告] {split} 中这些类名不在 labels.json 里:{missing}")这段脚本里有三个关键点。第一,读取 JSON 时必须带encoding="utf-8",否则 Windows 下遇到中文字段会直接崩溃或乱码。第二,通过判断 value 的类型来兼容字典风格,不需要手工改文件。第三,把统计结果直接打印出来,肉眼看一眼 train 里四个类别的样本数是不是接近,如果某个类别只有几十张而其他类别上千张,后面就要考虑采样策略或类别加权,这块坑在第 5 章会单独说。
值得提醒的是:脚本跑通了只代表目录结构和标签文件没有低级错误,并不代表图片都能被正常解码。建议顺手用PIL.Image.open随机抽查几十张,确认没有损坏文件。无人机图像在低速传输、断点续传过程中容易出现半张图,这类文件训练时会触发奇怪的解码警告,而且往往不是报错,是静默跳过,严重影响数据总量却不留痕迹。
3. 用 PyTorch 跑通最小训练:从 ImageFolder 到自定义 Dataset
3.1 最快路径:ImageFolder 直接加载
如果只是想在十分钟内看到模型在这个数据集上的大概表现,ImageFolder是最省事的选择。它把目录结构、类别顺序、图像读取全部封装好,写三行就能用。
from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder("dataset/train", transform=train_transform) print("类别映射:", train_ds.class_to_idx) print("样本总数:", len(train_ds))这里class_to_idx是ImageFolder自动生成的字典,顺序由文件夹名的 ASCII 排序决定。normal_stump、burning_stump、smoldering_stump、background排序后依次对应 0、1、2、3,和 labels.json 里的 idx2name 是否一致,必须以第 2.3 节的脚本核对为准。
这个方案的缺点是:ImageFolder不允许不同类别使用不同的图像预处理,也无法在数据加载阶段打印“这张图来自哪个文件夹”。调试时想确认某个 batch 里四类样本是否均衡,需要额外从train_ds.samples里自行统计。对于快速验证网络结构够不够用,这没问题;但要精细控制标签映射,需要换自定义 Dataset。
3.2 自定义 Dataset:把 labels.json 的映射显式接进来
自定义 Dataset 的核心价值,是把 labels.json 作为唯一标签权威,而不是依赖文件夹名的排序。这样做的好处是:即使将来有人调整了子文件夹的名字或顺序,训练代码的标签逻辑也不会静默改变。
import json import os from PIL import Image from torch.utils.data import Dataset from torchvision import datasets, transforms class StumpBurnDataset(Dataset): def __init__(self, root, labels_path, transform=None): super().__init__() self.transform = transform # 先用 ImageFolder 扫出所有样本路径和对应的整数标签 self.image_folder = datasets.ImageFolder(root) # 载入 labels.json,并转成 idx -> class_name 的映射 with open(labels_path, "r", encoding="utf-8") as f: labels = json.load(f) if isinstance(list(labels.values())[0], int): # name2idx 风格 self.idx2name = {v: k for k, v in labels.items()} else: # idx2name 风格,key 是字符串 self.idx2name = {int(k): v for k, v in labels.items()} # 核对 ImageFolder 的类别顺序与 labels.json 是否一致 folder_idx2name = {v: k for k, v in self.image_folder.class_to_idx.items()} assert folder_idx2name == self.idx2name, \ f"类别顺序不一致: {folder_idx2name} vs {self.idx2name}" def __len__(self): return len(self.image_folder) def __getitem__(self, idx): path, label = self.image_folder.samples[idx] image = Image.open(path).convert("RGB") if self.transform is not None: image = self.transform(image) class_name = self.idx2name[int(label)] return image, label, class_name这个类做了一件关键的事:在初始化时直接比较ImageFolder的类别顺序和 labels.json 的映射,如果不一致就抛异常,而不是让模型在错误映射下默默训练几十个 epoch。返回值里多带了一个class_name,训练循环里打印日志时可以直接看到当前 batch 里每个样本的真实类名,而不是只看整数标签,这对排查标注错位有直接帮助。
一个容易踩的细节:Image.open返回的图片可能是灰度模式或带透明通道的 PNG,必须.convert("RGB"),否则后面ToTensor()出来的张量形状不一致,DataLoader 在拼 batch 时直接报错。
3.3 训练脚本:迁移学习、batch 与学习率的关键参数
有了 Dataset,训练脚本就顺理成章。这份数据集的内容是无人机俯拍影像,和 ImageNet 的自然图像分布差异不小,但直接用 ResNet 的 ImageNet 预训练权重做迁移,仍然比从零训练收敛快得多。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import models, transforms from torch.optim import AdamW from torch.optim.lr_scheduler import ReduceLROnPlateau train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=90), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = StumpBurnDataset("dataset/train", "dataset/labels.json", train_transform) val_ds = StumpBurnDataset("dataset/val", "dataset/labels.json", val_transform) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_classes = len(train_ds.idx2name) model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.cuda() optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=3, verbose=True) criterion = nn.CrossEntropyLoss()几个参数是这类任务的血泪经验。第一,RandomResizedCrop的scale不要设成默认的(0.08, 1.0),无人机图像里的树桩往往占到画面的 30% 以上,裁得太碎会把火苗和烟气的整体结构切掉,导致训练时看到的全是局部纹理,验证时却要看全图。(0.7, 1.0)更贴近真实分布。
第二,RandomRotation(degrees=90)对无人机图像特别重要。机头朝向任意,树桩在画面里没有固定的“正立”方向,旋转增强的泛化收益非常明显,这个在第 4 章会继续展开。
第三,AdamW配合weight_decay=1e-4比传统 SGD 更省心,学习率初始值1e-3对微调 ResNet18 是安全范围。ReduceLROnPlateau监控 val 准确率,连续 3 个 epoch 不涨就把学习率减半,基本能避免手动调学习率的玄学。
关于是否冻结主干网络:如果训练数据总量不到三千张,冻结除最后两层以外的全部参数会更稳,过拟合风险低;如果数据量过万,全部微调能拿到更好的精度。建议先用model.fc只看最后一层跑一轮,再决定要不要解冻更多层。这个决策不靠理论推算,直接看 val 准确率曲线更可靠。
4. 无人机视角的类别边界与数据增强:为什么普通分类模型会翻车
4.1 无人机与固定相机差异:旋转、俯拍视角与运动模糊
很多图像分类教科书里的增强组合,是针对手机照片或道路监控设计的,直接搬到无人机图像上效果并不好。无人机视角的树桩和常见的猫狗、车型分类有三个本质差异。
第一,旋转无方向性。地面相机拍到的物体有重力方向作为先验,但无人机的 yaw 角在飞行中不停变化,树桩在画面里可能朝任意方向“歪着”。如果只用水平翻转增强,模型学到的是“树桩朝上或朝下”,而不是“任意角度下的树桩”。
第二,尺度差异剧烈。同一棵树桩,在 30 米高度拍摄可能只占画面几十个像素,在 10 米高度拍摄则充满整个画面。树桩燃烧的烟柱在不同高度下形态差异也很大。固定缩放到 224 会让模型丢掉尺度信息,但图像分类任务本身不做框回归,模型需要靠增强模拟多尺度输入。
第三,运动模糊和大气扰动。无人机悬停不稳定或者风力大时,画面会有轻微拖影;远距离拍摄时大气湍流也会让边缘变“软”。训练时适当加入高斯模糊,能让模型对这类降质图像更鲁棒。
4.2 别只用水平翻转:针对俯拍重新设计增强组合
这里给出一套针对无人机俯拍场景的 Albumentations 增强组合,替换掉第 3 章里的 torchvision 版本。Albumentations 的速度更快,而且能精细控制每个操作的概率和参数范围。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_aug = A.Compose([ A.Resize(256, 256), A.RandomCrop(224, 224), A.RandomRotate90(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.2, rotate_limit=15, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.3, contrast_limit=0.3, p=0.8), A.GaussianBlur(blur_limit=(3, 5), p=0.2), A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=20, val_shift_limit=10, p=0.3), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ToTensorV2(), ])这套组合的核心思路是“让模型学会容忍变化”。RandomRotate90等价于四个正方向随机切换,呼应无人机机头朝向不确定的特点。ShiftScaleRotate里的scale_limit=0.2模拟飞行高度小幅变化,rotate_limit=15模拟轻微倾斜。GaussianBlur的概率只设 0.2,太高会把火苗边缘的纹理细节洗掉,太低则对运动模糊没有抵抗力。RandomBrightnessContrast是模拟逆光和阴影变化,森林里树桩经常在树冠遮挡下,明暗跨度非常大。
这套增强下训练得到的模型,在真正无人机拍摄的新数据上通常比 torchvision 默认增强高 3 到 5 个点的准确率。代价是训练速度略慢,但交换很划算。
4.3 类别边界细化:明火、阴燃、正常、背景四类如何定边界
数据集的类别字典里如果有四类而不是两类,训练前最好把每类的定义边界想清楚,否则容易出现在标注阶段就埋下错误、训练阶段怎么也学不会的现象。常见的四类划分是:
- normal_stump:采伐残留或风倒后的树桩,表面干燥或腐烂,但没有可见的烟和火光。
- burning_stump:有明火或者大面积通红炭化的树桩,视觉特征最强。
- smoldering_stump:内部阴燃,外观看是黑色炭化,但边缘或裂缝处有可见白烟,温度高但不够形成火焰。
- background:草地、裸土、石头、倒木、水体等干扰背景。
最容易混淆的往往是normal_stump和smoldering_stump:正常树桩在阳光直射下也可能局部发黑,和阴燃的黑色炭化表面在外观上高度重叠。真正的区分点是烟气的有无和分布形态。如果发现训练后的混淆矩阵里这两类互相错判严重,建议不要只加数据,而是回到标注标准上:把“有可见烟气或红外热信号”作为阴燃的硬标准,肉眼看着黑但没有烟的样本一律标为正常。
background类之所以单独留一个,是因为无人机巡飞过程中大部分画面其实没有树桩,全是林地、草地背景。如果不设置背景类,模型会对所有非树桩区域强行输出一个树桩类别,部署时假阳性率高到没法用。
5. 图像分类数据集落地避坑:5 个最常见的翻车点
5.1 现象:train 准确率接近 100%,val 却卡在 70% 上不去
原因:模型严重过拟合,而且验证集分布与训练集不一致。常见诱因有两个:一是训练增强太弱,模型直接把训练图的纹理背下来了;二是随机划分时把同一架次连续拍摄的相似帧同时分进 train 和 val,导致 val 里“看似没见过的图”其实和训练图高度相似,评估结果虚高,换到真正新拍的数据就掉点。
解决:先看混淆矩阵,确定是哪几个类别在互相错判。如果是normal_stump与smoldering_stump混淆,回到标注标准上重新检查这批样本;如果所有类别都准确率低下,增强里加入更重的RandomRotate90和随机亮度变化。更重要的是按“架次”而不是按“文件”划分数据集,同一架无人机在同一次飞行任务中拍到的所有帧都必须全部进入同一个 split,这个规则要在划分脚本里强制实现。
5.2 现象:labels.json 是 idx2name 风格,代码里labels[0]报 KeyError
原因:JSON 的 key 是字符串,写成labels[0]时 Python 不会做隐式类型转换,直接抛异常。这个问题在代码审查时极难发现,因为 IDE 不报错,运行时才暴露。
解决:统一在 Dataset 的__init__里做一次显式转换,把 key 全部转成 int,之后所有代码只跟 int 打交道。就是第 3 章里{int(k): v for k, v in labels.items()}那行。只要这个转换做一次,后面无论是训练、验证还是部署推理,都不用再担心类型问题。
5.3 现象:训练 loss 正常下降,但 val 准确率波动巨大,每个 epoch 差 5 个点以上
原因:验证集样本量太少,或者验证集内部存在很强的时序相关性。无人机视频抽帧出来的帧之间是强相关的,如果 val 里碰巧集中了某个光线条件下的帧,就会出现这个 epoch 验证结果特别好、下一个 epoch 验证结果特别差的“蹦极”现象。
解决:扩大 val 集到总样本量的 15% 到 20%,并且保证每个类别的验证样本不少于 50 张。如果原始数据总量本身就不够,建议采用分层采样,按类别和架次双重约束来划分。验证集宁可牺牲一点训练数据量,也不能让它变成“玄学得分榜”。
5.4 现象:模型在训练集和 val 上表现尚可,部署后大量误报,尤其把白色石头判成烟
原因:训练数据里背景类覆盖不足。烟和白色石头、浅色裸土在亮度、纹理上都有近似之处,如果background类样本里没包含足够的白色干扰物,模型就会把没见过的东西硬生生分到最像的那个类。
解决:回到数据集,补充背景类样本,特别是包含白色石头、浅色土壤、雾气的画面。如果原始数据已经固定,可以用第 4 章里的增强组合提升背景多样性,但增强只能缓解,不能根治。最直接的做法是把背景类拆分成background_rock、background_soil、background_fog等多个子类,训练后再合并,能显著压制这类误报。
5.5 现象:Windows 下训练到一半报 UnicodeDecodeError
原因:文件路径或 labels.json 里存在非 ASCII 字符,PyTorch 在 Windows 默认编码下读取失败。这个坑在数据集文件用中文命名、或者 labels.json 里写了中文类别显示名时几乎必现。
解决:代码文件第一行加# -*- coding: utf-8 -*-,读取 JSON 时明确指定encoding="utf-8",所有字典文件统一用 UTF-8 无 BOM 保存。如果路径里已经出现了中文文件夹,建议直接重命名成英文,不要试图在代码里做字符集转换——转换方案在训练机跑通,换到推理服务器大概率又挂,板端部署更是重灾区。
6. 把类别字典文件用出价值:映射校验、混淆矩阵与置信度阈值
训练收敛后,labels.json 不是用完就扔的配置文件,它应该继续承担三个职责:映射校验、混淆矩阵对照、部署拒识阈值。
第一个职责是校验。把训练代码里生成的class_to_idx和 labels.json 做一次哈希比对,确认两边完全一致后,再生成模型导出文件。否则模型导出后,部署端如果用了不同顺序的类别,推理结果就是错位的。
import json, hashlib def dict_sha256(d): raw = json.dumps(d, sort_keys=True, ensure_ascii=False) return hashlib.sha256(raw.encode("utf-8")).hexdigest() with open("dataset/labels.json", "r", encoding="utf-8") as f: labels = json.load(f) # 假设训练时保存了 class_to_idx with open("class_to_idx.json", "r", encoding="utf-8") as f: class_to_idx = json.load(f) assert dict_sha256(labels) == dict_sha256(class_to_idx), "映射不一致,禁止导出模型"第二个职责是指导难例分析。在 val 上运行模型,输出混淆矩阵后,把每个错误样本的class_name打印出来,对照 labels.json 里的定义重新看一遍每张图,标注错误的直接修正后重新训练。很多项目精度上不去不是因为模型不行,而是标注噪声把模型搞糊涂了。
第三个职责是设置部署端的敏感度。模型输出的 softmax 概率是校准过的,但对未知类别并不友好。无人机巡飞时经常拍到数据集里不存在的物体,如果直接用 argmax,模型一定会给出一个答案但准确性毫无保障。我一般会在部署代码里加一道置信度过滤:最大 softmax 概率低于 0.7 时输出“未知”,不参与告警。这个阈值需要拿实飞数据调,不是拍脑袋定的。
这套流程我实践下来的最大教训是:不管数据集划分多精细、增强多完善,如果 labels.json 和实际文件夹顺序没有在训练前核对,后面所有实验结果都不可信。吃了一次大亏之后,我把映射校验固化在了每个项目的训练脚本入口,成了条件反射。希望帮到你。
本文还有配套的精品资源,点击获取