简介:这份森林火灾检测数据集面向 YOLO 系列目标检测算法的实际训练与验证,适配算法工程师、科研人员和竞赛选手,用于解决火灾目标样本获取、标注格式转换与模型快速评估等常见问题。资源包共 2000 个文件,压缩包约 58.57MB,主要包含 VOC 格式 xml 标签文件,并同时提供 YOLO 格式 txt 标签,已按训练集、验证集、测试集划分,附有 data.yaml 配置文件,可直接接入 YOLOv5/v7/v8/v9/v10/yolo11 等模型流程。标签覆盖起火与不起火两类场景,YOLO 格式以 <x_center> <y_center> 的归一化坐标描述目标框,便于直观检查与调试;两类标签分别存放在独立文件夹中,目录结构清晰,可减少格式转换环节。目前已有 308 人学习下载,适合希望快速搭建森林火灾检测基线、专注模型优化而非数据准备的中高级开发者。
1. YOLO 森林火灾数据集:2860 张带标签图像能在三天内跑出可用模型吗
如果你接手过林区监控或者野外火点预警项目,一定对“假报警”不陌生:早上的雾气、傍晚的晚霞、甚至一辆过路的卡车尾灯,都能让摄像头里的火点算法反复报警。靠纯视觉规则去处理这些场景,代码越写越长,漏检和误报依然此起彼伏。这个森林火灾数据集(2860 张图像、带 YOLO 标签、只有“不起火”和“火”两个类别)就是用来快速验证另一个路子:YOLO 这类一阶段目标检测算法,能不能用一套标准化训练流程把误报压下去、把小火苗找出来。它适合两类人:一是刚接触 yolo 训练、需要一份干净数据集练手的学习者;二是已经在做森林防火项目、想靠自己的数据微调模型的一线工程师。后文会把从数据集体检到训练、调参、避坑、验证的完整链路拆开讲,照这个流程走,三天内就能见到可用模型。
2. 数据集到手先做质量体检:标签格式、类别平衡与划分陷阱
2.1 YOLO 数据集格式是什么:先读懂标签文件再动手
标题里写的是“带标签”,落到文件夹里最常见的就是 images 和 labels 两个目录。images 放 jpg/png 原图,labels 放同名的 txt 文件,每个 txt 里一行一个目标,格式是class x_center y_center width height,五个数字都要归一化到 0 到 1 之间。这种打包好的森林火灾数据集一般还会带一份 classes.txt 或者 data.yaml,里面定义类别名。按照“不起火-火”这个顺序,常见做法是类别 0 对应不起火(也就是正常背景里的树、山、道路等),类别 1 对应火。
这段基础概念有个很实际的坑:类别编号本身没有含义,只有顺序是约定。同一个数据集在不同脚本里,如果 classes.txt 的排列顺序不一致,训练出来的模型类别就全部对不上。所以第一步不是急着配环境,而是写几行代码把标签读一遍,确认两百号文件里的类别编号是不是只有 0 和 1。
2.2 用脚本统计 2860 张图的类别分布与标签质量
解压 zip 之后,我一般先跑一个统计脚本,把整体情况摸清楚。这个脚本做的事很简单:数一数图像文件数量、标签文件数量、每个类别的目标框总数,再检查有没有标签越界。
import os from collections import Counter img_dir = "images" label_dir = "labels" # 统计文件数量与配对情况 imgs = {f.split(".")[0] for f in os.listdir(img_dir) if f.endswith((".jpg", ".jpeg", ".png"))} labels = {f.split(".")[0] for f in os.listdir(label_dir) if f.endswith(".txt")} print("图像数量:", len(imgs), "标签数量:", len(labels)) print("有图无标签:", len(imgs - labels), "有标签无图:", len(labels - imgs)) # 统计每个类别的目标框数量 cls_counter = Counter() invalid_boxes = 0 for root, _, files in os.walk(label_dir): for name in files: if not name.endswith(".txt"): continue with open(os.path.join(root, name), "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls = int(parts[0]) x, y, w, h = map(float, parts[1:]) # 归一化坐标越界检查,允许微小舍入误差 if not (0 <= x <= 1 and 0 <= y <= 1 and 0 <= w <= 1 and 0 <= h <= 1): invalid_boxes += 1 cls_counter[cls] += 1 print("类别分布:", dict(cls_counter)) print("越界标注框数量:", invalid_boxes)这段脚本的核心价值有两个。一是检查配对:YOLO 训练时是按文件名去 images 目录找图、按同名去 labels 找标签,任何一边多文件或者少文件,都会导致训练时警告甚至直接跳过样本。二是看类别分布:森林火灾场景天然不平衡,“不起火”类别的目标框数量往往比“火”多得多,如果比例悬殊到 10:1 以上,训练时就要考虑类别权重或者欠采样。
几百个 txt 的统计结果很快就知道这个数据集干不干净。如果 invalid_boxes 不为 0,不要自己在脚本里截断坐标,因为 YOLO 归一化坐标系如果出现 w 或 h 为负值,说明标注工具导出有问题,宁可直接把那几个文件拎出来重新看看原始数据。
2.3 抽图可视化标签,人眼确认比训练失败再排查快得多
统计脚本只能发现格式问题,发现不了语义问题。比如,“不起火”类别里会不会有带烟囱的图片?“火”类别里会不会混进了一堆夕阳晚霞?这些只能靠人眼抽检。我通常按类别各抽 20 张图,把标注框画出来检查。
import os import cv2 import random img_dir = "images" label_dir = "labels" save_dir = "check" os.makedirs(save_dir, exist_ok=True) names = ["no_fire", "fire"] random.seed(42) imgs = [f for f in os.listdir(img_dir) if f.endswith((".jpg", ".jpeg", ".png"))] sample = random.sample(imgs, min(40, len(imgs))) for img_name in sample: img = cv2.imread(os.path.join(img_dir, img_name)) h, w = img.shape[:2] label_path = os.path.join(label_dir, img_name.rsplit(".", 1)[0] + ".txt") if not os.path.exists(label_path): continue with open(label_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() cls = int(parts[0]) x, y, bw, bh = map(float, parts[1:]) # 归一化坐标还原成像素绝对坐标 x1 = int((x - bw / 2) * w) y1 = int((y - bh / 2) * h) x2 = int((x + bw / 2) * w) y2 = int((y + bh / 2) * h) color = (0, 0, 255) if cls == 1 else (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, names[cls], (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(os.path.join(save_dir, img_name), img) print("可视化图片已保存到", save_dir)这段代码有两个地方值得注意。第一,画框时用的是(x - bw / 2)这种中心点转左上角的换算,因为 YOLO 标签存的是中心点和宽高。第二,cv2.putText 直接写英文类别名,如果需要写中文,OpenCV 默认字体不支持,常见做法是换 PIL 绘制,否则中文会变成乱码方块。
如果在抽检图里发现“火”类别的框把整棵树都框进去了,或者“不起火”里有个框死死套住一片云,这种数据就要先清理再进训练流程。数据集的清洗优先级永远排在第一,因为模型学的是标注框的统计规律,框画歪了模型就跟着歪。
2.4 训练/验证划分:同源连续帧会让 mAP 虚高
2860 张图的数据集规模不大,划分比例我习惯用 8:1:1 或者 8:2(训练/验证,不单独留测试集)。看起来简单,但在森林火灾这种视频抽帧来源的场景里有一个隐蔽问题:如果数据集是把一段监控视频连续抽帧生成的,相邻两帧几乎一模一样。假如第 100 帧进训练集、第 101 帧进验证集,模型其实是在背答案,验证时的 mAP 会虚高到没有参考价值。
处理办法是先确认数据集来源。打包好的 zip 里如果没有提供序列 ID,就看文件名:连续数字前缀(如 frame_0001、frame_0002)大概率就是抽帧序列。遇到这种情况,划分前先按文件名的前缀分组,同一组只能整组进训练或整组进验证。用脚本做这件事时,最简单的做法是按文件名的前 7 个字符做分桶,再把桶随机分配到 train/val。
import os import random import shutil random.seed(2024) img_dir = "images" label_dir = "labels" train_ratio = 0.8 imgs = sorted(os.listdir(img_dir)) random.shuffle(imgs) split = int(len(imgs) * train_ratio) os.makedirs("split/train/images", exist_ok=True) os.makedirs("split/train/labels", exist_ok=True) os.makedirs("split/val/images", exist_ok=True) os.makedirs("split/val/labels", exist_ok=True) for i, img_name in enumerate(imgs): base = img_name.rsplit(".", 1)[0] label_name = base + ".txt" if not os.path.exists(os.path.join(label_dir, label_name)): continue if i < split: dst = "split/train" else: dst = "split/val" shutil.copy(os.path.join(img_dir, img_name), os.path.join(dst, "images", img_name)) shutil.copy(os.path.join(label_dir, label_name), os.path.join(dst, "labels", label_name)) print("划分完成:", split, "训练 /", len(imgs) - split, "验证")如果你确定数据集不是抽帧来的,直接随机划分即可;但如果你不确定,宁可先花十分钟看一眼文件名结构。这个步骤在 yolo 训练自己的数据集时经常被跳过,也是我见过最多的“mAP 很高但现场全面翻车”的根源之一。
3. 用 YOLO 训练森林火灾模型:环境、data.yaml 与最小命令
3.1 训练前环境配置:别让 CUDA 版本卡住一整天
yolo 环境配置这件事本身不难,翻车大多翻在 PyTorch 版本和显卡驱动对不上。常见做法是先装好 Anaconda,然后新建一个独立环境,Python 版本选 3.10 或 3.11 即可,等 ultralytics 安装时让 pip 自动解析依赖,比手动逐个装 opencv、torch 更省心。如果你的机器有 NVIDIA 显卡,装完 ultralytics 之后验一下 CUDA 是否真的可用,这是最容易出现“能 import 但跑不起来”的地方。
conda create -n yolo python=3.10 -y conda activate yolo pip install ultralytics python -c "import torch; print(torch.cuda.is_available()); print(torch.__version__)"上面最后一行的输出如果第一个是 False,多半是 PyTorch 装成了 CPU 版,需要到 PyTorch 官网按你的 CUDA 版本重新安装;如果第一个是 True,继续往下走。在服务器上没有 GPU 的机器也能跑,但 2860 张图训练一个 YOLOv8s,CPU 训完一轮的时间大约是 GPU 的 20 倍以上,不建议拿 CPU 做正式训练,最多用来验证代码链路通不通。
3.2 写 data.yaml:路径、类别名、类别数一个都不能错
数据集场景里训练 YOLO,所有配置聚在一个文件里,文件名我一般就叫 forest_fire.yaml。里面要写数据集根目录、训练与验证图片路径、类别数量和类别名称。
path: /home/user/forest_fire # 改成你解压后的绝对路径 train: images/train val: images/val nc: 2 names: 0: no_fire 1: fire这里有几个隐藏的坑必须说清楚。第一,path 写相对路径很容易踩坑,因为 ultralytics 是拿当前工作目录去拼的,在 Jupyter 和命令行里工作目录不同,结果就完全不一样,最稳的做法是写绝对路径。第二,train 和 val 字段指向的路径是相对于 path 的,不要在前面多加斜杠。第三,names 的写法有两种风格,一是上面这种字典形式,二是names: [no_fire, fire]这种列表形式,两种都合法,混用就报错。类别编号顺序和标签文件里的第一个数字严格对应,这一点和第 2 章说过的体检逻辑一致。
3.3 用预训练权重还是从零训练:2860 张图该怎么选
这个规模的数据集,强烈建议用预训练模型来做迁移学习。yolo 预训练模型下载是 ultralytics 自动完成的,第一次运行训练命令时,它会从官方 release 下载 yolov8s.pt 到~/.cache/ultralytics目录,不需要手动去找下载地址。预训练权重里已经学到了通用的边缘、纹理、颜色特征,森林火灾场景的火焰和小目标特征是在这些基础特征之上微调出来的。从零训练不是不行,而是需要更多数据和更长 epoch,2860 张二分类图很难撑起一个完整 backbone 的收敛。
yolo train model=yolov8s.pt data=forest_fire.yaml \ epochs=300 batch=16 imgsz=640 device=0 \ patience=50 project=runs name=fire_v8s这行命令里每个参数都有讲究。epochs 设为 300,是因为森林火灾数据只有 2860 张,单轮迭代次数少,需要更多 epoch 才能收敛;但加了 patience=50,意思是连续 50 个 epoch 验证集 mAP 没有提升就自动停,实际上一百多个 epoch 可能就停下来了,不会真正跑满 300 轮。batch=16 是在 24G 显存下跑 YOLOv8s 的常见取值,如果显存 8G 就要降到 8 或 4。imgsz=640 是默认值,火焰目标如果普遍很小,可以考虑 960 甚至 1280,但显存和训练时长会成倍上涨,先跑 640 拿到基线再说。device=0 指定第一块 GPU,没有 GPU 时写device=cpu。
3.4 训练过程中的日志怎么看:分清 loss、mAP 和混淆矩阵
训练启动后终端会打印两列进度信息:训练侧的 box_loss、cls_loss、dfl_loss,以及每过一段在验证集上算出来的 mAP50 和 mAP50-95。新手最容易只看 mAP50,这个东西对火焰这种目标还好,因为火灾检测更关心“有没有”,IoU 阈值放到 0.5 够用。mAP50-95 则是把 IoU 阈值从 0.5 一路加到 0.95 取平均,数值会明显更低,但它更能反映框的贴合精度。
训练结束之后,runs 目录下会生成 weights/best.pt 和 last.pt。best.pt 是按验证集 mAP 挑出来的最优权重,last.pt 是最后一个 epoch 的权重。常见做法是直接用 best.pt,但如果训练后期验证集 mAP 有震荡,last.pt 未必比 best.pt 差。部署前去验证集上分别测一次,用实测数据决定,不要拍脑袋。
4. 针对森林火灾调参:小目标、增广策略与模型档位怎么选
4.1 森林火灾检测难在哪:三个让损失函数“无力回天”的场景
森林火灾目标和 COCO 数据集里的猫狗汽车不一样,画风完全不同。第一是小目标占比高:相隔几百米看到的火焰可能只有十几个像素,640 分辨率下的检测头对 8×8 像素以下的目标基本无能为力。第二是语义边界模糊:火焰不是刚体,边缘时刻在跳动,人工标注的框边界本身就是不确定的,模型学到的框天然不如汽车那么规整。第三是背景干扰强:晚霞、红色岩石、浓烟,在颜色上和火焰高度相似。
在讨论 yolo 损失函数之前得先明白:这些困难更多是数据层面的,改损失函数只能解决边界回归精度的部分,解决不了“小到看不见”的问题。YOLOv8 内置的损失函数组合是 CIoU 回归损失加分类交叉熵加 DFL(Distribution Focal Loss),这个组合本身就是经过验证的,在二分类场景下不建议擅自修改权重。想改损失函数的前提是先把数据显示清楚——你的数据里到底有多少框是小于 16×16 像素的?这可以通过统计标签文件里 w 和 h 像素值来实现。
4.2 数据增广参数怎么调:给火焰颜色和形态留余量
ultralytics 把增广参数全部暴露成训练命令的可选参数,不需要改代码就能让数据增广策略变得激进。森林火灾场景下我一般重点关注颜色类增广和几何类增广的组合。
yolo train model=yolov8s.pt data=forest_fire.yaml \ epochs=300 batch=16 imgsz=640 device=0 \ hsv_h=0.015 hsv_s=0.7 hsv_v=0.5 \ degrees=10 scale=0.5 translate=0.1 fliplr=0.5 \ mosaic=1.0 mixup=0.1这些参数的含义分别是:hsv_h、hsv_s、hsv_v 控制色调、饱和度、明度的随机扰动幅度,火焰颜色从橙黄到暗红都有,把饱和度扰动开大一点,能模拟不同光照下的火焰外观;degrees=10 允许图像旋转 10 度,林区摄像头安装角度的偏差足够覆盖;scale=0.5 做 0.5 到 1.5 倍的随机缩放,让同一个火苗靠近或远离镜头;fliplr=0.5 是水平翻转。mosaic=1.0 表示训练时每张图由 4 张图拼成,这一招对小目标尤其有效,因为它把小目标放进大图里训练,等于变相增加了目标出现的上下文多样性。mixup=0.1 是两图叠加,比例不要开太大,森林火灾数据只有两个类别,mixup 比例大了容易让类别特征混在一起。
这里有一个关键点:mosaic 增广只在训练时启用,验证时自动关闭,不需要手动设置。还有一个容易忽略的位置,如果开启 mosaic,前 10 个 epoch 模型看到的全是拼图,loss 会有一个明显下降再回升的过程,这是正常的,别误会成训练崩了。
4.3 模型档位怎么选:n/s/m/l 四档的算力与精度权衡
yolo 系列对比里绕不开模型体积的取舍。YOLOv8 有 n、s、m、l、x 五档,森林火灾场景的部署端大概率是边缘盒子或者普通服务器,没有多少上 x 档的必要。我给个常见参考表:
| 模型 | 参数量约 | 640 推理速度(GPU) | 适用场景 |
|---|---|---|---|
| YOLOv8n | 3.2M | 极快 | 树莓派、边缘盒子,精度有限 |
| YOLOv8s | 11.2M | 快 | 常规服务器,性价比最高 |
| YOLOv8m | 25.9M | 中等 | 精度优先,显存 16G 以上 |
| YOLOv8l | 43.7M | 较慢 | 服务器端高精度,数据量大时适用 |
2860 张图像的数据量撑不起 l 档模型,参数越多越容易过拟合。常见的稳妥路线是先用 s 档拿到一个可靠的基线,记录下验证集 mAP50,然后换 m 档跑同样 300 个 epoch,对比差值。如果 m 档只比 s 档高 0.5 个点,说明数据量是瓶颈,该回头补数据清理和增广;如果你手头是 V100 这种大显存卡,可以直接跳过 n 档,n 档的召回在火焰小目标上会明显不够。
4.4 网络结构微调:小目标改 P2 输出,实例分割可以后续再说
话题里提到 yolo 实例分割,但对森林火灾检测来说,普通检测框就够用,实例分割的像素级掩膜对火情面积估算有价值,但那是后话。这里更实际的是小目标这个问题。YOLOv8 默认输出 P3、P4、P5 三个检测头,对应下采样 8、16、32 倍的特征图;P3 负责小目标,最小也能检出 8×8 像素的物体。想检出更小的火焰,常见做法是给模型增加一个 P2 检测头,对应 4 倍下采样,最小检出尺寸能降到 4×4 像素。
但我不建议一上来就改结构。原因是 2860 张图太少,P2 头加进去参数变多,训练不稳定风险更高,而且推理速度明显变慢。先把增广参数和 imgsz 调好,再考虑结构改进。如果调完 imgsz=960 之后,混淆矩阵里的漏检还是集中在远距离小火苗上,那时再去改 P2 才有意义。另外,还有一种不用改模型的做法:推理时把大图切块成 640×640 的 tile,分别检测再合并结果,在边缘部署里很实用,后面验证章会提到。
5. 森林火灾 YOLO 训练最容易翻车的 5 个地方及排查顺序
5.1 标签类别顺序和训练配置不一致,loss 降不下去
现象:训练到 50 个 epoch,训练 loss 一直高位震荡,mAP50-95 长期在 0 附近徘徊;或者训练正常,但推理时把“火”识别成“不起火”,把“不起火”识别成“火”。
原因:数据集里的 labels 文件中类别 0 表示火,类别 1 表示不起火,而 data.yaml 里names: [no_fire, fire]恰好相反。YOLO 只管按数字取名字,不会替你校验语义,数字顺序错了模型就学反了。
解决:再跑一遍第 2 章的统计脚本,打印每个类别编号的实际数量,同时打印 data.yaml 的 names 列表,对照两份结果人工确认“编号数量最多的类别”和“names 里对应的名字”是否合理。这是最廉价也最快的定位方式,不用看训练日志。
5.2 背景样本过多导致误报率下不来
现象:验证集 mAP50 有 0.85,看起来不错;一到实拍视频,雾气、阳光反射、红色铁皮屋顶轮流触发报警,现场根本没法用。
原因:“不起火”和“火”的类别不平衡是一回事,更重要的问题是“不起火”这个类别内部太杂,模型没有见过足够多的负样本形态,就会把没见过的红色物体都往“火”上面靠。2860 张图里如果“火”有 1800 张、“不起火”只有 1000 张,属于轻度反向不平衡,问题不大;怕的是“不起火”全集中在晴天树林,那么阴天、傍晚的样本模型完全没见过。
解决:先看第 2 章统计出来的类别分布。如果“火”类远少于“不起火”类,给训练命令加cls=0.7这类类别损失权重参数;如果“不起火”类内部形态不够多样,这个数据集本身不适合直接上线,至少要去现场补拍一段不同时段素材来做难负样本挖掘,把误报帧抽出来补标注,汇入训练集重训。误报率是森林火灾项目的生死线,误报太多监控中心就会把报警通道关掉,精度再高也没有意义。
5.3 imgsz 开得过大,小火苗反而丢失
现象:imgsz=640 能检出图片右下角的火苗,imgsz=1280 之后反而漏检了。
原因:火焰在原始图像里可能只有 10×10 像素。imgsz=1280 把图像放大的同时,火焰也确实变大了,但 YOLO 的检测头设置里,下采样 8 倍的 P3 输出也对应着更宽的感受野和更粗的网格分辨率,小目标信息在多次池化后可能被背景淹没。另一个更常见的翻车点是:数据集中很多图本身分辨率只有 640×480,直接上采样到 1280 并没有增加真实信息,只是把像素点拉大,模型看到的还是同样模糊的火苗。
解决:使用imgsz=960测试一次,对比 640 的验证集 mAP,如果 mAP 没有提升,就维持在低分辨率。真想让小目标变清晰,优先保证输入图片分辨率足够,再考虑 P2 检测头,而不是一味加大 imgsz。
5.4 训练中断后 resume,bn 统计量崩坏导致 loss 飞了
现象:显存不够或者断电导致训练中断,用resume=True恢复后,loss 突然比断点前高出一大截,甚至一路冲到 nan。
原因:resume 恢复的不仅是权重,还包括优化器状态和学习率调度状态。如果恢复的模型文件和当前代码版本不是同一套配置(比如改了 batch 大小),BatchNorm 层的 running_mean 和 running_var 与新的 batch 统计量不匹配,就会在早期产生剧烈波动。这种情况在 yolo 训练中表现为 bn 参数相关训练异常,直观感受就是模型忽然像从零开始一样。
解决:遇到中断,我的习惯不是立刻 resume,而是把 best.pt 拿出来,作为新训练的预训练权重,重新跑一遍训练命令,只保留原学习率和原 batch 配置。如果显存不够导致中断,先把 batch 降低再动学习率,两者必须同步调整,batch 减半时学习率最好也减半,否则照样崩。
5.5 导出部署模型时自定义网络层被静默丢弃
现象:训练一切正常,best.pt 在 Python 里推理正常;导出成 ONNX 或者 TensorRT 后,推理结果大量漏检,甚至输出全是空框。
原因:训练时如果用了自定义结构(比如改过 backbone 模块、加了自定义注意力层),PyTorch 的动态图可以跑,但 ONNX 导出时这些自定义算子如果没有对应的导出实现,会被静默替换或丢弃。森林火灾场景里常有工程师为了提点改进注意力机制,改完训练端没问题,一到边缘部署就暴露。
解决:改结构之前先明确部署端支持什么。如果目标是树莓派或者 RK3588 这类边缘设备,优先用标准 YOLOv8 结构训练,先用标准模型跑通整个部署链路,再回来把精度提升放到第二轮迭代里做。常见做法是保留一个“标准模型”分支,每次实验同时保留训练日志和部署导出测试结果,别让网络结构的改进和部署验证脱节。
6. 用混淆矩阵和低照度测试验证模型:从 mAP 到可上线的最后一公里
验证模型不能只看 mAP 一个数。mAP 是全体类别的平均表现,掩盖了“火漏检”和“不起火误报”的差异。而森林火灾场景里这两个错误的代价完全不对等:漏掉一个真实火点的代价是灾难性的,误报一个代价只是浪费一次人工确认。所以最后一公里,我通常写一个脚本,把验证集跑一遍,输出绝对数值的混淆矩阵,而不是直接看 ultralytics 自带的那张归一化图。
from ultralytics import YOLO import os import numpy as np model = YOLO("runs/fire_v8s/weights/best.pt") img_dir = "split/val/images" label_dir = "split/val/labels" conf = np.zeros((2, 2), dtype=int) # 行:真实类别,列:预测类别 for img_name in os.listdir(img_dir): img_path = os.path.join(img_dir, img_name) base = img_name.rsplit(".", 1)[0] label_path = os.path.join(label_dir, base + ".txt") # 读取真实类别(一张图作为一个样本,只关注“有没有火”) true_has_fire = False if os.path.exists(label_path): with open(label_path, "r", encoding="utf-8") as f: for line in f: if int(line.strip().split()[0]) == 1: true_has_fire = True results = model(img_path, conf=0.25, verbose=False) pred_has_fire = False for r in results: for box in r.boxes: if int(box.cls) == 1: pred_has_fire = True conf[int(true_has_fire)][int(pred_has_fire)] += 1 print("混淆矩阵(行真实、列预测):") print(conf)这段脚本的判定逻辑是场景化的:对于森林火灾,一张图里只要存在一个“火”类别框,就认为这张图有火;同时把所有框的置信度阈值统一设为 0.25,和训练时的默认一致。有人在看 ultralytics 输出的混淆矩阵会发现数字对不上——那是因为它默认展示的是归一化版本或者按类别统计的版本,我这里用绝对数再看一遍,目的就是确认“火”类别的召回率到底是多少。
如果脚本输出的 conf[1][0](真实有火但预测漏检)数量偏高,说明数据里小目标多或者说置信度阈值设太高,解决方向不是调阈值,而是加 P2 检测头和补标注;如果 conf[0][1](误报)数量偏高,说明“不起火”负样本不够,解决方向是补难负样本。看数据下结论,不要靠猜。
额外提一个低照度测试技巧:随手在验证集里挑几张暗光图片,用 OpenCV 转成灰度看看直方图分布,如果火焰区域和背景在灰度上的差值非常小,模型再强也很难分出来,这类图要么补进训练集做专门的低照度增广,要么在摄像头端做曝光补偿。这个步骤在真实项目里能省下大量现场调参时间。
最后讲一个我的个人习惯:所有训练实验的配置命令和验证脚本,我都会按日期存档成一个 shell 文件,和 best.pt 放同一个目录。这样哪怕三个月后现场误报率反弹,也能立刻查到当时用的增广参数、imgsz 和模型档位,而不是对着一个黑匣子式的权重文件干瞪眼。这套流程跑了几个火灾检测项目之后,最大的教训就是:把数据体检、训练配置、阈值决策当成一次实验来管理,模型才能持续迭代。希望帮到你。
本文还有配套的精品资源,点击获取