☰
俯拍航拍森林火灾检测数据集:双格式标注与YOLO训练避坑指南
2026/10/12 11:41:31 网站建设 项目流程

简介:面向森林火灾智能监测与目标检测研究,这份资源以俯拍航拍视角,聚焦六千余张火灾与烟雾图像的标注体系,采用Pascal VOC与YOLO两种通用标注格式,可直接用于检测模型的训练与评估,解决林火场景中火点与烟雾的精准定位问题。压缩包内为单份docx说明文档,体积仅五兆左右,系统梳理了标注规则、图像数量与类别分布:火类别标注框一万五千余个,烟雾类别标注框一万二千余个,合计近两万八千个矩形框,且全部使用labelImg工具完成精确标注。文档中同步提供图片预览与标注示例,便于使用者直观核对框选细节,降低上手成本。目前已有六十余人学习浏览,适合需要开展森林火灾检测算法研究的工程师与科研人员。借助该文档可省去自行采集与标注的工作量,让团队专注于模型结构设计与调参验证,同时需注意最终模型精度应交由使用者自行评估。

1. 俯拍航拍森林火灾检测:小目标+双格式是这份数据集的真正价值

很多人做火灾检测,第一反应都是找平拍照片训练,结果在无人机回传画面上直接漏检——俯拍航拍森林火灾检测数据集里,火焰藏在树冠下,烟雾在绿色背景下连成一片,目标框又小又密。这份6116张、2类别的俯拍航拍森林火灾检测数据集,恰好补的正是这个缺口:视角贴近真实火场,同时给VOC和YOLO两种标注格式,意味着不管用哪套训练框架,拿到手直接做校验和训练,不用先耗半天转换格式。适合两类人:刚入行想快速跑通火灾检测Demo的开发者,和已有平拍数据想补航拍泛化能力的老手。下面按真实使用顺序讲目录结构、标签校验、训练参数和五个容易翻车的点。

2. 数据集结构与标注格式:VOC和YOLO各管哪一段

2.1 先认清目录约定:6116张图在两种格式之间怎么摆放

拿到这类双格式交付的压缩包,第一步不是急着解压开训,而是先在命令行里把根目录整体看一遍。我习惯直接打印目录树,确认图片、标注、划分清单三个东西都在。

dataset_root/ ├── JPEGImages/ # 原图,jpg或png混合 ├── Annotations/ # VOC侧标注,每张图一个xml ├── ImageSets/Main/ # train.txt / val.txt等划分清单 ├── images/ # YOLO侧原图(常见做法是直接复用JPEGImages) └── labels/ # YOLO侧标注,每张图一个txt

这份6116张2类别的数据集常见的目录布局如上,但不同发布者对YOLO侧的处理不一样:有的把images和labels分别铺开,有的用软链接复用JPEGImages,有的只在说明文档里让你把train路径指到JPEGImages。目录先看「labels里面有没有东西」比什么都重要,见过有人拿到压缩包兴冲冲改data.yaml,跑半天报错找不到标签,最后发现labels被放进了另一个嵌套目录。

看目录的同时,我会随机挑三张图片和对应的标注文件,把框画回原图上人工过一眼。画框这一步看起来土,但最能发现ClassName写错、box完全偏位这类脚本检查不出来的问题。文件数量对得上不算对齐,像素位置对得上才算。另外可以对比一下Annotations和labels两个目录下文件的修改时间,如果一侧完全是批量生成的时间戳,另一侧零散不一致,多半是发布者自己转换时出过问题,这类包后续训练时更容易遇到玄学指标。

2.2 XML与TXT坐标体系:三处关键差异决定转换时会不会错位

VOC侧和YOLO侧描述同一个目标,但坐标表达方式完全不同。可以先看这张对比表。

对比项VOC XMLYOLO txt
坐标定义xmin/ymin/xmax/ymax,绝对像素值cx/cy/w/h,除以图片宽高后的0~1浮点数
类别表达<name>里存字符串,如fire文本第一个字段,整数索引从0开始
文件对应关系每张图对应一个同名xml每张图对应一个同名txt
无效框表现xmin/ymax可能为0或倒置w/h可能出现0或负数

第一处差异是归一化。YOLO的cx、cy、w、h全部是相对图片宽高的比例,所以转换时「用哪张图的宽高」很关键。如果XML的<size>和实际图片像素不一致,按XML尺寸归一化出来的txt在训练时会被正确读图的宽高还原成错误坐标,框会整体偏移——这类问题loss能降、mAP永远上不去,是转换脚本里最隐蔽的一类错位。

第二处差异是类别索引。XML里写的是字符串fire,YOLO txt里写的是整数,这个整数对应data.yaml里names列表的声明位置。换一个框架、声明顺序变了,之前的txt就全废了。第三处差异是边界情况:VOC允许空标注文件,YOLO对空txt的处理各框架不一致,有的训练框架会直接报错,有的会在loss里引入诡异的nan;转换时建议把空标注文件单独统计出来,而不是静默跳过。同理,双格式包里的两个目录并不保证永远同步,每个月翻出来用一次,就先跑一遍同步性检查。

2.3 两个类别怎么界定:火焰与烟雾在俯拍视角下的边界

标题写的是2类别,绝大多数火灾检测数据集的默认命名是fire与smoke,这份数据大概率也沿用这个约定。拿到手后先统计一遍类别字段,确认有没有第三类、类别名有没有大小写混用。俯拍视角下,类别的天然边界比平拍模糊得多:明火被烟挡住一半,该标fire还是smoke;树冠下只露十几像素的橙红色点,该不该标;烟雾是半透明的,边界到底画到哪一层灰度。

常见处理惯例是:肉眼可见的明火主体标fire,即使被烟遮挡也按可见边界标;只有烟的区域标smoke;小于十几个像素、放大后才能确认是火焰的目标,标注本身就带主观性,这类样本的标签噪声会成为训练误差的主要来源。不用急着修改既有标注,先按原标注跑一个baseline,再看混淆矩阵里fire和smoke互相串类的比例,如果串类严重,再考虑合并或重标。另外看类别统计时,不只关心框数,还要关心框面积分布:俯拍视角下大烟雾框会把整张图占掉大半,这类样本过多会让模型对「大面积目标」过拟合,后续对小火苗的召回更差。

3. 训练前先做标签体检与数据划分:三个脚本一次跑明白

3.1 类别统计与异常坐标检查:先知道这份数据能不能直接用

「先跑通再优化」这话在图像分类上成立,在目标检测上不成立——检测任务对标签质量零容忍。接手一个数据集的第一件事,是用一段小脚本做体检,这也是以前在某个火灾检测项目里踩坑之后留下的习惯,属于血泪经验换来的固定动作。

import os import xml.etree.ElementTree as ET ann_dir = "Annotations" # VOC标注目录 counts = {} # 类别名 -> 框数量 empty_files = [] # 没有任何object的xml error_files = [] # 坐标倒置或越界的xml for name in os.listdir(ann_dir): if not name.endswith(".xml"): continue tree = ET.parse(os.path.join(ann_dir, name)) root = tree.getroot() objects = root.findall("object") if len(objects) == 0: empty_files.append(name) continue for obj in objects: cls = obj.find("name").text counts[cls] = counts.get(cls, 0) + 1 bndbox = obj.find("bndbox") xmin = int(float(bndbox.find("xmin").text)) ymin = int(float(bndbox.find("ymin").text)) xmax = int(float(bndbox.find("xmax").text)) ymax = int(float(bndbox.find("ymax").text)) if xmax <= xmin or ymax <= ymin: error_files.append(name) if xmax < 0 or ymax < 0: error_files.append(name) print("类别统计:", counts) print("空标注xml数量:", len(empty_files), empty_files[:5]) print("异常坐标xml数量:", len(error_files), error_files[:5])

这段逻辑很直白:遍历Annotations下的每个xml,解析出object列表,统计每个类别的框数量,同时检查bndbox是否存在倒置框或负坐标。跑完重点看三个数字。一是总框数除以总图片数,6116张图的资源如果平均每张不到一个框,说明大量背景图混在训练集里,模型容易把背景也当目标。二是两个类别框数的比例,fire和smoke如果差距超过三倍,先记录这个比例,后面调类别权重会用到。三是empty_files和error_files的数量,这两类文件在数据增强阶段会被放大噪声,不要直接放进训练集。

3.2 生成ImageSets/Main划分清单:比例与随机种子

大多数目标检测框架在读取VOC格式时并不直接扫描Annotations,而是先读ImageSets/Main下的train.txt和val.txt,根据清单去取图。如果发布包里的清单缺失或不合理,用这个脚本重新生成。

import os import random random.seed(2024) # 固定种子方便复现 img_dir = "JPEGImages" train_ratio = 0.9 # 训练/验证比例 names = [f.split(".")[0] for f in os.listdir(img_dir) if f.endswith((".jpg", ".png"))] random.shuffle(names) split = int(len(names) * train_ratio) train_names, val_names = names[:split], names[split:] os.makedirs("ImageSets/Main", exist_ok=True) with open("ImageSets/Main/train.txt", "w") as f: f.write("\n".join(train_names)) with open("ImageSets/Main/val.txt", "w") as f: f.write("\n".join(val_names)) print(f"train={len(train_names)}, val={len(val_names)}")

注意两个参数:random.seed的值决定了每次运行划分结果可复现,固定成一个和项目相关的整数就好;train_ratio设为0.9还是0.85,取决于后续要不要做模型选择或超参搜索,如果只是想验证数据集能不能跑通,0.9没问题。另外shuffle之前可以加一层过滤,把3.1里统计出的empty_files和error_files排除掉。划分完成后不要急着删旧清单,最好把新旧train.txt做一次diff,看看发布者原本的划分和随机划分重合度有多少,这个信息能帮你判断官方划分是否做了分层抽样——如果重合度极低且指标差异很大,说明原划分里可能混入了特定难度的图,后续对比实验要小心。

3.3 兜底转换:VOC的XML转YOLO的txt,尺寸以实际图片为准

如果发布包只有VOC标注,或者YOLO侧labels目录是空的,就需要自己转换。这类转换脚本网上很多,但多数会在图片尺寸从XML的<size>读还是从图片实际读这一点上埋雷。建议只认cv2实际读出来的尺寸,不要信任XML里记录的字段。

import os import cv2 import xml.etree.ElementTree as ET classes = ["fire", "smoke"] # 和后续data.yaml里names顺序保持一致 cls_map = {c: i for i, c in enumerate(classes)} ann_dir = "Annotations" out_dir = "labels" os.makedirs(out_dir, exist_ok=True) for xml_name in os.listdir(ann_dir): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(ann_dir, xml_name)) root = tree.getroot() img_name = root.find("filename").text img_path = os.path.join("JPEGImages", img_name) img = cv2.imread(img_path) if img is None: print("图片读不到: ", img_name) # 注意大小写与扩展名不一致 continue h, w = img.shape[:2] lines = [] for obj in root.findall("object"): cls_name = obj.find("name").text if cls_name not in cls_map: continue bndbox = obj.find("bndbox") xmin = int(float(bndbox.find("xmin").text)) ymin = int(float(bndbox.find("ymin").text)) xmax = int(float(bndbox.find("xmax").text)) ymax = int(float(bndbox.find("ymax").text)) x_center = (xmin + xmax) / 2 / w y_center = (ymin + ymax) / 2 / h box_w = (xmax - xmin) / w box_h = (ymax - ymin) / h lines.append(f"{cls_map[cls_name]} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") out_path = os.path.join(out_dir, xml_name[:-4] + ".txt") with open(out_path, "w") as f: f.write("\n".join(lines))

脚本里唯一需要留意的是cls_map,它把VOC XML里的字符串映射到整数索引,顺序必须和后面训练用的data.yaml里names完全一致。以这份2类别数据为例,classes定义["fire", "smoke"],那么fire训练时对应索引0、smoke对应索引1;如果某框架内部要求从1开始编号,这个映射就要整体加1。每行txt写了cls_id加中心点坐标加框宽高,使用六位小数足够保存小目标的精度。转换完再抽查三五个txt,把内容转回像素画框看位置对不对,这一步不要省。另外如果读取图片失败,最常见原因是XML里记录的文件名大小写和实际磁盘不一致,或者图片后缀是.jpg但文件名写成了.jpeg,打印出来统一rename即可。

注意:data.yaml的names顺序和txt首列索引是绑定的,改names顺序就必须重转标注,否则两个类别的训练信号会互相换位。

4. 用YOLO训练俯拍火灾数据:data.yaml、命令行与四个必调参数

4.1 先把数据集描述文件写对:路径和names的坑

不管用哪套YOLO系框架,第一步都是把数据描述文件写对。以当前主流的yolo命令行工具为例,data.yaml长这样:

path: /path/to/dataset_root # 数据集根目录 train: images/train # 训练图片相对路径 val: images/val # 验证图片相对路径 nc: 2 # 类别数 names: ['fire', 'smoke'] # 类别名列表,顺序决定txt索引

三个点值得较真。第一是path字段,有的框架允许相对路径,有的要求绝对路径,建议统一用绝对路径省得换机器就报错。第二是train和val,如果YOLO侧labels目录是平的、没有按train/val分目录,就需要先用3.2的划分脚本把图片和txt按子目录各放一份,再写这里的相对路径;有些框架支持直接传txt文件路径列表,但不同版本支持程度不一致,不推荐。第三是names顺序,表面看只是给模型看名字,实际上这个顺序和YOLO txt里每行开头的整数是一一对应的——前面XML转YOLO脚本里的cls_map顺序写反了,这里又没核查,训练出来两个类别的预测结果就会互换。对这6116张2类别的数据来说,names写错是最常见也最不值得的翻车。

4.2 最小训练命令:跑通一个baseline再谈调参

数据校验完,先把训练跑起来,目标是尽快拿到一条能看曲线的baseline。

yolo detect train \ data=data.yaml \ model=yolov8n.pt \ imgsz=960 \ batch=16 \ epochs=100 \ patience=20 \ device=0

参数选择逻辑:model先用nano体积的预训练权重,因为俯拍火灾检测的首要目标是快速验证数据路径和标注没问题,而不是一步到位刷精度;等baseline跑通再往上换s或m。imgsz从默认640提到960,是航拍小目标场景性价比很高的改动,树冠间的小火焰在640下映射到特征图上的尺寸常常只有几个像素,960能明显改善。batch设16是常见单卡显存下的安全值,如果用大显存卡可以提到24或32。epochs给100配合patience=20做早停,实际训练中通常六七十轮就收敛,个别场景会到九十轮。

训练日志主要看三块:P、R、mAP50、mAP50-95四条曲线是否同步上升;验证集最终值是否在一个能接受的区间;关闭patience后是否出现验证集震荡但训练集继续下降的过拟合信号。如果R明显低于P,说明漏检问题大于误检,这在俯拍小目标场景很常见;如果P低R高,说明误检多,先检查是不是把绿色裸地或深色树冠学成了背景正样本。

4.3 四个必调参数:从640到960只是第一步

除了imgsz,还有四个参数在火灾检测上值得动。第一是mosaic增强。数据增强对目标检测是双刃剑,mosaic把四张图拼在一起,能让模型在小目标密度上更鲁棒,但火灾数据里烟雾是半透明且边界模糊的目标,拼接切出的烟块形状完全没有物理意义。常见做法是把mosaic降到0.5以下,或者干脆关闭,只在最后几十轮再打开。实测效果因数据而异,所以要先跑一次默认配置,再跑一次关闭mosaic的配置,对比mAP50-95。

第二是anchor或自适应锚框。俯拍航拍视角下目标尺寸分布和平拍差异极大,目标框普遍较小且分布集中,训练框架如果提供自动计算anchor的功能,建议在首次训练时打开,并回看它对当前数据算出的锚框尺度。第三是confidence阈值。训练完成后推理时conf默认0.25,对火灾场景偏保守;火焰检测允许一定误报,代价是漏报在实际火场不可接受,实际部署时通常把conf降到0.1到0.15,配合NMS的IoU阈值调整来压重复框。第四是batch在显存不足时的取舍:宁可保持imgsz=960加batch=16,也不要为了开大imgsz把batch降到4,BatchNorm统计量在小batch下会飘,航拍大图造成的梯度波动会被进一步放大。

当然以上参数调整都建立在一个前提上:已经有一个可复现的baseline。我一般会在第一个训练跑完后,把模型权重存下来,记录imgsz、batch、epoch数、mAP50和mAP50-95,之后每次只改一个变量。火灾检测的调参空间比比赛题小得多,真正决定上限的是数据分布而不是参数组合,这句话在航拍小目标场景里尤其明显。

5. 避坑:俯拍火灾数据集训练中最常见的五个翻车点

5.1 fire类别的正样本太少,模型全去学smoke

现象:训练完整体mAP能看,但按类别打印PR曲线时,fire的召回率远低于smoke,回看验证集预测图,火焰区域基本没框出来。原因:俯拍视角下smoke的标注面积大、数量多,fire是又小又不规则的正样本,训练梯度被smoke主导。解决:先跑体检脚本统计两个类别的框数比例,如果超过三倍,对fire做过采样,或者给fire类别设置更高的loss权重。很多框架支持按类别加权,设完后重新训练对比PR,这条往往比调imgsz更有效。

5.2 imgsz拉到1280反而翻车:显存、batch和收益的三角关系

现象:把imgsz从960调到1280,显存告急,于是把batch从16降到4,结果训练loss震荡,mAP50不升反降。原因:图片变大让单张显存占用暴涨,batch被压到4之后,BatchNorm统计量不稳定,模型在航拍大图上反而学不到稳定特征。解决:控制batch不低于16的前提下再考虑提高imgsz,比如卡不动就用960固定,卡有余量就提到1100左右;真正想让大图小目标都被覆盖,更靠后面讲的切片推理,而不是单靠训练端提分辨率。

5.3 loss正常降、mAP不涨,先怀疑类别索引这类玄学问题

现象:训练loss曲线温和下降,但验证mAP从第一轮到最后一轮都接近0,预测框全部落在图片边缘或全部输出为同一类别。原因:最常见是VOC转YOLO时cls_map顺序和data.yaml的names不一致,导致fire框被当smoke训练;其次是归一化时用了XML里错误的尺寸字段,导致所有框的位置整体偏移。解决:训练前抽十个txt,把归一化坐标乘回图片宽高,画框看位置;再打印data.yaml解析后的类别表和训练日志里首次validation输出的标签,确认每个batch里两个类别都在出现。这类问题特征是loss正常、指标不动,靠调超参永远解决不了。

5.4 误检集中在太阳、云、裸土和高亮石头上

现象:模型的precision表面对但推理视频时,太阳、亮白云、干燥裸土被反复框成fire,浅色烟也常被当成天空背景漏掉。原因:火灾高温区域在高亮颜色特征上和这些目标重叠,如果训练数据里「像火但不是火」的负样本少,模型自然学成只看颜色不看纹理形状。解决:先不用急着删漏检图,打开亮度、饱和度、色调的随机抖动增强,很多误检会自己消失;如果还有顽固误检,把误检图收集一小批,标注成没有目标,作为训练集负样本重训一次,效果一般比调IoU阈值明显。

5.5 验证集混入大量无目标背景帧,mAP虚高

现象:验证集mAP忽高忽低,换一个随机种子结果差异很大,但最终视频里实拍效果很差。原因:航拍数据里有很多帧是没有火灾的纯森林背景,如果划分时没有对这些帧做约束,验证集里大量背景帧会让AP计算变得不稳定——没有目标就没有TP,但几乎也没有FP,分母变来变去自然波动。解决:划分前按「有fire的图、有smoke的图、纯背景图」三类分组,每组内部再随机划分,保证val里目标图占大多数;同时记录val集合里每张图的框数量分布,避免某个epoch恰好抽到极端子集。

5.6 数据检查顺序:先怀疑标注,再怀疑参数

除了上面五条,训练火灾检测还有一个不成文的顺序:当结果异常时,先花半小时看标注本身,而不是去调优化器。常见该修的情况包括:同一个烟头被重复框成两个重叠框、矩形框只框到烟雾的一半透明度区域、树冠阴影被当成fire标上。对6116张2类别这种体量,标注噪声通常集中在树冠遮挡和烟雾边缘,手工把明显错误的几十张挑出来修正并保存为一份修复清单,比盲目换模型、调anchor更有效。

6. 进阶:对整张大图做切片推理,把小目标从树冠里挖出来

俯拍航拍数据的另一个关键矛盾是:原始图可能很大,直接resize进模型,目标被压缩得只剩十几个像素;不resize又超出模型输入尺寸。切片推理是针对性解法,把原图切成一堆瓦片,每个瓦片独立过模型,再把结果坐标系映射回原图。

import cv2 tile_size = 640 stride = 512 def slice_infer(img_path, model): img = cv2.imread(img_path) h, w = img.shape[:2] all_boxes = [] for y in range(0, h - tile_size + 1, stride): for x in range(0, w - tile_size + 1, stride): tile = img[y:y + tile_size, x:x + tile_size] results = model(tile, conf=0.15, iou=0.5, imgsz=640, verbose=False) for det in results[0].boxes: x1, y1, x2, y2 = det.xyxy[0].cpu().numpy() all_boxes.append( [x + x1, y + y1, x + x2, y + y2, float(det.conf)] ) return all_boxes

切片大小用640和模型输入保持一致,stride小于tile_size保留重叠区域,避免目标恰好被切在瓦片交界处而被丢掉;重叠部分的重复框收集完后做一次NMS合并,可以直接调框架自带的后处理。如果图特别大,超过4000像素,单尺度切片会让显存频繁申请释放,可以先统计数据集的宽度分布,再决定切片尺寸。我自己在某个林区巡检Demo上吃过全图直接推理的亏,整张4K航拍图输入模型,树冠下的小火苗一个都没框出来,切成640的瓦片后,几十个像素的暗火点反而能稳定检出——这套切片逻辑比在训练端无限提imgsz更贴近真实的航拍部署。火灾检测不同于普通目标检测,漏检的真实代价远超误检,切片重叠区宁可多算一遍,也不要让目标从边界溜走。希望帮到你。

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

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

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

立即咨询