简介面向森林火灾智能监测与目标检测训练需求这份文档系统说明了一套俯拍航拍森林火灾数据集适合遥感视觉、应急预警算法研究与工程实践人员参考。资源包内为1个docx文件约5.06MB集中梳理数据集的格式组成、类别设置、标注统计、标注工具与使用说明。数据集共6116张图像采用Pascal VOC与YOLO双格式标注VOC以jpg图像配xml记录位置类别YOLO以txt保存坐标类别为fire与smoke标注框分别15380和12613个总计27993个均由labelImg按矩形框规则完成。两类目标在俯拍场景中尺度差异明显文档附有图片预览与标注示例便于核对标注细节、理解类别边界并说明模型精度不作保证提醒使用者独立评估。目前已有62人浏览学习可作为森林火灾与烟雾检测任务的数据准备、标注核查、模型训练与验证及实验设计参考。1. 俯拍视角下的火焰与烟雾一份能直接开训的双格式数据集去年帮一个做林区监测的团队看模型他们自己拿无人机拍了两个月标了不到八百张fire 和 smoke 两类混在一起训出来的模型见了云就报警。问题不在模型在样本——俯拍航拍视角下的火点和烟雾跟地面拍摄完全是两个分布目标小、对比度低、背景纹理高度重复。这份俯拍航拍森林火灾检测数据集正好补的就是这一块6116 张 jpg配套 6116 个 VOC 的 xml 和 6116 个 YOLO 的 txt两类标签 fire 与 smoke总框数 27993fire 15380、smoke 12613labelImg 画的矩形框。它解决的是「没有航拍火情样本、从零标注成本太高」这个最现实的问题适合做目标检测落地、需要快速拉起一个可训练基线的算法和工程同学。要注意的是数据集本身不承诺任何模型精度标注对不对、够不够用得你自己跑脚本验。2. 先摸清标注分布6116 张图背后的三个数字拿到一个检测数据集很多人第一件事是解压、改 yaml、开训。我一般反过来先花二十分钟把标注统计跑一遍因为后面所有的参数选择——输入分辨率、anchor、增广强度、甚至要不要做切片推理——都由这几个数字决定。这份数据集的三个关键量是每图平均框数 27993 / 6116 ≈ 4.58fire 与 smoke 的框数比约 1.22:1以及框的面积分布这一项原文档没给必须自己算。平均 4.58 个框意味着绝大部分图里目标并不密集单图目标数少、但单目标尺寸可能很小这正是航拍检测的典型形态也决定了后面不能用太激进的 mosaic。2.1 VOC 与 YOLO 两套文件怎么对应数据集是双格式并存的理解它们的映射关系是后面所有脚本的前提。VOC 侧是三件套JPEGImages/xxx.jpg、Annotations/xxx.xmlxml 里用size记录宽高、用若干object记录name和bndboxxmin/ymin/xmax/ymax绝对像素坐标左上右下。YOLO 侧是两件套images/xxx.jpg、labels/xxx.txttxt 每行五个字段class cx cy w h全部归一化到 0~1且 cx/cy 是框中心。两边靠同名不同后缀配对stem 必须严格一致——这是第一个容易翻车的地方后面避坑章会展开。目录大致是这样dataset/ ├── JPEGImages/ # 6116 张 jpg ├── Annotations/ # 6116 个 xmlVOC ├── images/ # 6116 张 jpgYOLO 侧通常是同一批图的拷贝或软链 └── labels/ # 6116 个 txtYOLO提示先确认images和JPEGImages是同一批图文件名集合完全相等有些发布包两边会有缺漏直接拿 images 训练、拿 Annotations 做校验就会对不上账。类别顺序也要先钉死。这份数据集只有两个类YOLO 侧约定0fire、1smoke但这个约定必须用脚本从 txt 里反查确认不能靠猜——一旦顺序反了模型会把烟雾全判成火mAP 看着还行实际完全不可用。2.2 用一段脚本把框面积分位数打出来下面这段脚本做四件事遍历 xml 拿到每张图的宽高、统计每个类的框数、算每个框的面积占全图比例、检查有没有退化框x2x1 或 y2y1 的脏标注。跑完你就有了一张分布表后面调分辨率全靠它。import os, glob import xml.etree.ElementTree as ET from collections import Counter ANN_DIR Annotations cls_cnt Counter() # 每个类的框数 area_ratio {fire: [], smoke: []} # 框面积占全图比例 per_img [] # 每图框数 bad_boxes [] # 退化框 sizes Counter() # 图像宽高组合 for xml_path in glob.glob(os.path.join(ANN_DIR, *.xml)): root ET.parse(xml_path).getroot() sz root.find(size) W, H float(sz.find(width).text), float(sz.find(height).text) sizes[(int(W), int(H))] 1 n 0 for obj in root.iter(object): name obj.find(name).text.strip() bb obj.find(bndbox) x1, y1 float(bb.find(xmin).text), float(bb.find(ymin).text) x2, y2 float(bb.find(xmax).text), float(bb.find(ymax).text) if x2 x1 or y2 y1: bad_boxes.append((os.path.basename(xml_path), name)) continue cls_cnt[name] 1 area_ratio.setdefault(name, []).append((x2 - x1) * (y2 - y1) / (W * H)) n 1 per_img.append(n) def pct(lst, ps(10, 25, 50, 75, 90)): lst sorted(lst) if not lst: return {} return {p: round(lst[int(len(lst) * p / 100)], 6) for p in ps} print(类别框数:, dict(cls_cnt)) print(图像尺寸分布 Top5:, sizes.most_common(5)) print(每图框数 均值/最大:, round(sum(per_img)/len(per_img), 2), max(per_img)) for k, v in area_ratio.items(): print(f{k} 面积占比分位:, pct(v)) print(退化框数量:, len(bad_boxes), bad_boxes[:5])逻辑说明size节点给的是原图像素宽高用它做归一化分母area_ratio按类分开存是因为 fire 和 smoke 的尺度差异通常很大混在一起看会掩盖问题per_img用来判断有没有「一张图几十个框」的极端样本这种样本在 mosaic 里会主导梯度。参数怎么改pct里的分位数可以按需加 95、99看长尾如果sizes里出现多种分辨率航拍数据常见要记下来因为后面统一 resize 时小图会被放大、放大倍率过高会让烟雾纹理糊成一团。结果怎么读如果 fire 的面积占比中位数明显小于 smoke说明火点是小目标、烟雾是大目标这两类不能用同一套 anchor 尺度去照顾训练时要么提高输入分辨率保小目标要么对烟雾单独放宽 NMS 阈值防止大面积框被抑制。如果 90 分位的面积占比接近 0.5 以上说明存在几乎占满全图的大框这类框在切片推理时会被切断需要额外处理。2.3 fire 与 smoke 的标注差异决定了调参方向两个类的物理形态完全不同。火点边界相对清晰labelImg 画框时人能看出一个明确的亮斑范围框比较紧烟雾是弥散的从浓烟核心到边缘过渡几十上百像素不同人标注的框大小能差一倍框普遍偏松偏大。这个差异会直接反映在训练上smoke 的定位损失收敛慢、mAP50-95 明显低于 mAP50因为高 IoU 阈值下松框根本对不齐。应对方式不是去改标注6116 张重标不现实而是在训练侧做两件事。第一评价时把两个类分开看别只看总体 mAPsmoke 的 mAP50-95 才是真实水位。第二回归损失用 CIoU 或 EIoU 这类对尺度敏感的别用纯 IoU 系列里对大框宽容度太高的变体同时 NMS 的 iou 阈值不要压太低0.5 左右比较稳妥压到 0.3 会把相邻的烟雾碎片全吃掉。另外12613 个 smoke 框对应 6116 张图意味着有不少图里 smoke 是多块分布的一片主烟加几缕飘散这些碎块在低分辨率下会连成一片或者直接消失。这一点在选输入尺寸时必须考虑进去下一章会具体给数。3. 从原始包到可训练划分、转换与自检三步走原始包是「能看」的状态不是「能训」的状态。要做的事有三件把 6116 张图切成 train/val可选 test把 xml 转成规范的 YOLO txt如果发布包里的 txt 已经规范这一步就退化成校验最后写一份 data.yaml 并做一次 dry-run。这三步任何一步偷懒后面训练出来的曲线都是假的。我见过最典型的情况是 val 集里混进了 train 的相邻帧mAP 一路 0.9换真实航拍视频直接崩到 0.3。3.1 按文件名哈希划分别用随机种子用random.shuffle划分的问题是不可复现——换台机器、换个 Python 版本、哪怕只是中间插入一行代码划分结果就变了实验没法对比。更稳的做法是对文件名做哈希取模天然可复现而且天然打散。代码import os, glob, hashlib, shutil IMG_DIR JPEGImages OUT split VAL_RATIO 0.15 # 验证集比例 TEST_RATIO 0.05 # 测试集比例不要就设 0 for sub in (train, val, test): os.makedirs(os.path.join(OUT, sub, images), exist_okTrue) os.makedirs(os.path.join(OUT, sub, labels), exist_okTrue) def bucket(stem: str) - str: # 用 md5 前 8 位转整数稳定且与文件顺序无关 h int(hashlib.md5(stem.encode()).hexdigest()[:8], 16) % 10000 if h VAL_RATIO * 10000: return val if h (VAL_RATIO TEST_RATIO) * 10000: return test return train stat {train: 0, val: 0, test: 0} for img in glob.glob(os.path.join(IMG_DIR, *.jpg)): stem os.path.splitext(os.path.basename(img))[0] split bucket(stem) if split test and TEST_RATIO 0: split train shutil.copy(img, os.path.join(OUT, split, images, stem .jpg)) src_txt os.path.join(labels, stem .txt) if os.path.exists(src_txt): shutil.copy(src_txt, os.path.join(OUT, split, labels, stem .txt)) stat[split] 1 print(stat)逻辑说明bucket把文件名映射到 0~9999按比例切三段同一文件名永远落同一桶。参数上VAL_RATIO取 0.15 是我在六千量级数据上的常用起点验证集九百多张够跑出稳定的 mAPTEST_RATIO设 0 时全部并入 train适合只关心相对效果的场景。一个必须提醒的点如果这批图是从连续航拍视频里抽帧来的相邻帧几乎一样哈希划分挡不住这种泄漏。判断方法是看文件名里有没有连续编号——有的话先按编号分段比如每 30 帧取 1 帧入 val再对段做哈希。这一步多花十分钟能省掉后面「线上效果和验证集差一倍」的排查。3.2 VOC 转 YOLO归一化、越界裁剪与精度即使发布包带了 txt我也建议自己跑一遍转换再和原 txt 对一遍因为标注质量是这份数据里最需要验证的部分。转换的核心就三行公式但边界处理是坑点。import os, glob import xml.etree.ElementTree as ET CLASSES [fire, smoke] # 顺序必须和 data.yaml 完全一致 SAVE_DIR labels_converted os.makedirs(SAVE_DIR, exist_okTrue) def clip(v, lo, hi): return max(lo, min(hi, v)) for xml_path in glob.glob(Annotations/*.xml): root ET.parse(xml_path).getroot() sz root.find(size) W, H float(sz.find(width).text), float(sz.find(height).text) lines [] for obj in root.iter(object): name obj.find(name).text.strip() if name not in CLASSES: continue # 未登记类别直接丢弃不猜 cid CLASSES.index(name) bb obj.find(bndbox) x1 clip(float(bb.find(xmin).text), 0, W) y1 clip(float(bb.find(ymin).text), 0, H) x2 clip(float(bb.find(xmax).text), 0, W) y2 clip(float(bb.find(ymax).text), 0, H) if x2 - x1 1 or y2 - y1 1: # 裁剪后退化跳过 continue cx (x1 x2) / 2 / W cy (y1 y2) / 2 / H bw (x2 - x1) / W bh (y2 - y1) / H lines.append(f{cid} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) stem os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(SAVE_DIR, stem .txt), w) as f: f.write(\n.join(lines))逻辑说明clip是因为航拍标注里确实存在 xmax 略大于 width 的情况画框时鼠标甩出去了不裁的话归一化后坐标大于 1训练时要么被框架静默丢弃、要么直接报错。1e-6精度保留六位是惯例够用且不会让文件膨胀。框宽高裁剪后小于 1 像素的直接丢这种框训练时只会贡献噪声。参数上唯一要动的是CLASSES的顺序。想省事可以直接从已有 txt 里统计出现的 class id 集合和 xml 的 name 集合对一遍两边数量一致再继续。3.3 data.yaml 与开训前的 dry-run文件齐了不代表能训。写一份最小的 yaml然后跑一次单 batch 的前向看有没有「标签越界」「找不到图片」这类报错。# data.yaml path: /abs/path/to/split train: train/images val: val/images names: 0: fire 1: smokedry-run 用一条命令就够目的是让框架自己把标签读一遍yolo detect train datadata.yaml modelyolov8n.pt epochs1 imgsz640 batch4 \ workers0 plotsFalse valFalseworkers0是为了让报错直接打在终端上而不是被多进程吞掉valFalse跳过验证只验证数据管道通不通一个 epoch、batch4 是最小开销。跑通了再放大参数这一步别省。4. 训练参数分辨率、增广与指标口径参数没有标准答案但有明确的因果链。这份数据的三个特征——平均框数少、烟雾大而糊、火点小而亮——决定了参数调整的优先级分辨率 增广 学习率。六千张图的量级训一个中等大小的检测器在单卡上几个小时内能出结果真正耗时间的是反复试错所以参数要在开训前想清楚。4.1 输入分辨率640 是底线不是默认imgsz小目标保留情况显存与速度适用场景640远距离小烟点可能只剩几像素最省迭代快快速验证管道、粗调超参960多数框能保留结构中等常规训练起点1280小框纹理基本不丢显存翻倍以上batch 要降最终出模型、远距离监测判断依据还是第 2 章算出来的面积占比分位数。如果 fire 的面积占比 10 分位低于 0.001在一张 1920×1080 的图上意味着不到 45×45 像素640 输入下这个框只剩 15×15 左右特征图上的响应很弱recall 上不去。这种情况我会直接从 960 起步训完再用 1280 微调若干 epoch而不是一上来就 1280 硬扛显存。还要注意一点数据集里如果混有多种原始分辨率统一 resize 到 imgsz 会让小图被放大放大倍率超过 2 时烟雾纹理明显发虚反而伤模型。这种情况用 letterbox 保持长宽比、灰边填充比直接拉伸稳妥。4.2 增广别把烟雾的低对比度削没了通用的强增广套件放到这个场景里有几个是要打折的。mosaic 会把四张图拼一起单图里本来就不多的目标被进一步缩小小烟点容易在拼接边缘被切断建议mosaic从默认的 1.0 降到 0.5 左右并在最后 10~15 个 epoch 关掉close_mosaic让模型在真实分布上收尾。色彩方面hsv_v适当加大是有益的——真实烟雾在逆光、阴天、黄昏下亮度差异极大亮度扰动相当于免费的域增广但hsv_s不要开太猛烟雾和背景灰雾主要靠饱和度差异区分饱和度过扰动会让模型学不到颜色线索。明确不建议的随机擦除Random Erasing和小目标裁剪类增广。烟雾本身就低对比度擦掉一块很可能正好擦掉唯一的目标区域模型学成「看到被擦除的块就预测有烟」。翻转可以用但垂直翻转要谨慎——俯拍视角下地形纹理的上下方向性不强垂直翻转问题不大水平和垂直都开是合理的。4.3 指标口径漏检率比 mAP 更该盯火情监测这个场景误报的代价是人工复核一次漏报的代价是一次火情没被及时发现。所以评价指标不能只看总体 mAP50。我一般要求同时看四个数fire 的 recall、smoke 的 recall、两类各自的 mAP50-95、以及按框面积分层小/中/大的 recall。前两个直接对应业务后两个定位问题——如果小框 recall 明显低于大框就是分辨率不够如果 smoke 的 mAP50 高但 mAP50-95 低就是标注框松的问题跟模型无关。实操上训练日志里的metrics/recall(B)是 batch 平均受 batch 组成影响不如训完在 val 集上单独跑一次yolo detect val拿混淆矩阵可靠。混淆矩阵里fire被判成背景的那一格就是漏检数直接除以 fire 总框数就是漏检率这个数字比 mAP 更能说服人。5. 避坑与排查五个把我拖到凌晨的点下面五条都是真踩过的按「现象 → 原因 → 解决」写遇到同类问题可以直接对号。5.1 现象转换后 txt 数量比 jpg 少了几十个原因那些图里确实没有目标纯林地、纯天空labelImg 存出来就是空文件有些打包脚本会把 0 字节文件过滤掉或者你以为它是损坏文件手动删了。 解决YOLO 里空 txt 是合法的负样本它告诉模型「这张图没有目标」对降低误报很有价值必须保留。先核对labels目录里到底有没有这些空文件再决定是补空文件还是从训练集里剔除对应图片。补空文件用一行touch就行但补之前确认图里真的没目标别把漏标的图当负样本喂进去。5.2 现象训练报Label class X is invalid或框明显偏移原因xml 里 xmax 超出图像宽度或者 xmin/xmax 写反。归一化后 cx 变成负数或大于 1。 解决回第 3.2 节的转换脚本clip到[0, W]/[0, H]并且加一条退化框判断宽或高小于 1 像素直接跳过。别指望框架帮你裁不同框架对越界坐标的处理策略不一样有的直接丢样本你会莫名其妙少一批数据。5.3 现象val mAP 0.9换真实航拍片段只有 0.4原因划分泄漏。同一段航线连续拍摄的帧被随机分到了 train 和 val两边的图几乎一模一样。 解决不要按单张图随机划分按「拍摄段前缀」分组后再分组划分或者干脆按文件名序号分段取样。判断有没有泄漏的土办法从 val 里抽 10 张肉眼在 train 里找有没有几乎同视角的图找到一张就说明泄漏了。5.4 现象找不到 label日志里全是 background原因文件名大小写或后缀不一致。图片是.JPG标签是.jpg.txt或.txt前缀不匹配也有xxx(1).jpg这种带括号的命名在不同系统上处理不一致。 解决写一个集合比对脚本把 images 和 labels 的 stem 各自 set 一遍求差集差集非空就先修文件名不要靠框架的容错去猜。命名统一成纯小写字母数字下划线最保险。5.5 现象模型把所有目标都预测成 fire原因data.yaml里names的顺序和 txt 里的 class id 对不上或者类别名带了空格/大小写差异Firevsfire。 解决从 txt 里统计实际出现的 id 集合从 xml 里统计实际出现的 name 集合两边同时打印出来对照确认0对应哪个、1对应哪个。这一步一分钟比训完两小时发现全错强。改完 yaml 记得清掉缓存文件有些框架会把数据集信息缓存在labels.cache不删的话新配置不生效。6. 进阶切片推理把大图的小烟点捞回来训练分辨率定了 960 或 1280不代表推理时也得整图缩放。真实航拍图动辄 4000×3000整图缩到 1280 送进网络远处那个几十像素的初起烟点基本就没了——而初起烟点恰恰是最有价值的。工程上的解法是滑窗切片把大图按固定窗口切块、逐块推理、再把所有框映射回原图坐标做一次全局 NMS 合并。代价是推理次数按切片数量线性增长所以切片大小和重叠率要算。切片边长重叠推理耗时倍数相对整图收益640128约 20~30 倍极小目标召回提升最明显适合离线批处理960192约 10~15 倍大多数场景的平衡点1280256约 6 倍兼顾速度边缘切断裂问题少核心实现不复杂用 numpy 做切块和 NMS避免引额外依赖import numpy as np from ultralytics import YOLO def nms(boxes, scores, iou_thr0.5): # boxes: (N,4) xyxy order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) if order.size 1: break xx1 np.maximum(boxes[i, 0], boxes[order[1:], 0]) yy1 np.maximum(boxes[i, 1], boxes[order[1:], 1]) xx2 np.minimum(boxes[i, 2], boxes[order[1:], 2]) yy2 np.minimum(boxes[i, 3], boxes[order[1:], 3]) inter np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) area_i (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1]) area_o (boxes[order[1:], 2] - boxes[order[1:], 0]) * (boxes[order[1:], 3] - boxes[order[1:], 1]) iou inter / (area_i area_o - inter 1e-9) order order[1:][iou iou_thr] return keep def sliced_infer(model, img, slice_size960, overlap192, conf0.25): h, w img.shape[:2] stride slice_size - overlap # 保证最后一块贴边避免右下角漏检 ys sorted(set(list(range(0, max(h - slice_size, 0) 1, stride)) [max(h - slice_size, 0)])) xs sorted(set(list(range(0, max(w - slice_size, 0) 1, stride)) [max(w - slice_size, 0)])) all_boxes, all_scores, all_cls [], [], [] for y in ys: for x in xs: patch img[y:y slice_size, x:x slice_size] r model.predict(patch, confconf, verboseFalse)[0] if r.boxes is None or len(r.boxes) 0: continue b r.boxes.xyxy.cpu().numpy() b[:, [0, 2]] x # 映射回原图坐标 b[:, [1, 3]] y all_boxes.append(b) all_scores.append(r.boxes.conf.cpu().numpy()) all_cls.append(r.boxes.cls.cpu().numpy()) if not all_boxes: return np.zeros((0, 6)) boxes np.concatenate(all_boxes) scores np.concatenate(all_scores) clses np.concatenate(all_cls) out [] for c in np.unique(clses): # 分类别做 NMS别把两类混在一起抑制 idx np.where(clses c)[0] keep nms(boxes[idx], scores[idx], 0.5) for k in keep: out.append([*boxes[idx][k], scores[idx][k], c]) return np.array(out) model YOLO(runs/detect/train/weights/best.pt) img __import__(cv2).imread(your_large_aerial.jpg) res sliced_infer(model, img, slice_size960, overlap192, conf0.25) print(合并后目标数:, len(res))逻辑说明ys/xs里显式补了max(h - slice_size, 0)是为了让最右、最下那一块贴边否则边角区域会有一块永远不被覆盖——航拍图里的边角恰恰常有目标。坐标加x/y的偏移必须在拼回原图后做不能先做 NMS 再映射。按类别分别做 NMS 是硬性要求fire 和 smoke 框空间上高度重叠混着做会把烟雾框当成火的重复框抑制掉。参数怎么调overlap不小于slice_size的 20%否则跨切片的完整目标会被两半切断、每半都不够置信度conf在切片模式下可以适当放低到 0.2因为切片后目标变大、置信度分布上移低阈值多召回的框靠 NMS 收掉。合并阈值 0.5 是折中如果想进一步压误报可以提到 0.6但 smoke 的召回会掉。验证方法很直接挑二十张有代表性的原图分别跑整图推理和切片推理把两者的框画出来叠在一起看重点看边角和远景区域——切片多出来的那些框就是整图缩放丢掉的。如果多出来的框大部分是误报说明conf放太低了回调到 0.3 再看。从那以后我每次拿到新的航拍检测数据集都会先按第 2 章那段统计脚本跑一遍面积分位数再决定输入分辨率最后一定用切片推理在几张原图上做对比验证——顺序搞反了就得靠训完几轮再回头怀疑数据来补课成本高得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?