☰
烟雾火焰数据集从VOC xml到YOLO训练全流程避坑指南
2026/10/26 22:14:02 网站建设 项目流程

简介:这份烟雾火焰数据集面向计算机视觉方向的学习者与算法开发者,聚焦烟火目标检测这一典型场景,可用于训练和验证烟雾、火焰两类目标的识别模型,适合课程设计、毕业项目或算法入门练手。资源包共收录4118个文件,包含2059张jpg图像与2059个同名xml标注文件,图像与标签一一对应,压缩包整体约168.41MB,xml采用常见目标检测标注格式,可直接接入主流检测框架进行训练与评估。目前已有2327人学习下载,说明其在烟火检测任务中具备一定的参考价值。数据集覆盖烟雾与火两类目标,样本数量较为充足,标注文件完整,省去自行采集与标注的成本,读者拿到后即可快速搭建训练流程,用于模型调参、效果对比或迁移学习实验,也能作为烟火预警类应用的原型数据基础。

1. 烟雾火焰数据集:2056 张图、xml 标签,拿到手先别急着训练

你从网上拖下来一个烟雾火焰数据集,解压一看:2056 张图,两个类别,烟雾和火,每张图配一个 xml 标签文件。第一反应大概率是“太好了,直接丢进 YOLO 开跑”。我劝你先停一下。xml 标签是 PASCAL VOC 格式,不是 YOLO 的 txt,也不是 COCO 的 json。你如果直接拿 ultralytics 的 YOLO 去读,它会告诉你找不到标签,或者更糟——它不报错,但训练时 loss 一直不降,你以为是模型不行,其实是标签根本没被读进去。这个数据集真正的价值在于:它已经帮你省掉了最耗时的“画框”环节,2056 张带 xml 的烟雾火焰图,在公开数据里算中等偏上的量级,足够训出一个能用的检测模型。但前提是,你得先把 xml 转成训练框架认识的格式,再搞清楚烟雾和火这两类在标注上的分布是否均衡。这篇文章就是讲这件事:从拿到 xml 到跑通训练,中间每一步怎么做、参数怎么设、哪里容易翻车。

2. 先看清 xml 里到底存了什么:VOC 结构逐字段拆解

2.1 一个 xml 文件里哪些字段真正影响训练

PASCAL VOC 格式的 xml 文件,结构是固定的。你打开任意一个,会看到<annotation>下面挂着<folder>、<filename>、<size>、<object>这几块。真正影响训练的只有三处:<filename>决定这张图对应哪个文件;<size>里的<width>和<height>决定坐标归一化的分母;每个<object>里的<name>是类别名,<bndbox>里的xmin/ymin/xmax/ymax是框的绝对像素坐标。其他字段像<folder>、<path>、<segmented>在转换时可以直接忽略,它们不影响检测任务。

我一般会先写个脚本统计一下所有 xml 里的类别名到底有几种。标题说“包括烟雾和火两类”,但实际文件里可能写的是smoke和fire,也可能是Smoke、smoke、yanwu混着来。类别名不一致是转换后训练失败的头号原因,没有之一。

import os import xml.etree.ElementTree as ET from collections import Counter xml_dir = "annotations" # 存放 xml 的目录 class_counter = Counter() file_count = 0 for fname in os.listdir(xml_dir): if not fname.endswith(".xml"): continue file_count += 1 tree = ET.parse(os.path.join(xml_dir, fname)) root = tree.getroot() for obj in root.findall("object"): name = obj.find("name").text.strip() class_counter[name] += 1 print("xml 文件总数:", file_count) print("类别分布:", class_counter)

这段代码做两件事:遍历所有 xml,统计文件总数和每个类别名出现的次数。ET.parse解析 xml,findall("object")拿到所有目标框,find("name").text取类别名。跑完之后你会得到类似Counter({'fire': 3200, 'smoke': 1800})的结果。如果出现Counter({'fire': 3200, 'smoke': 1800, 'Smoke': 12})这种,说明有 12 个框的类别名大小写不一致,必须统一,否则训练时会被当成第三个类,而那个类只有 12 个样本,模型根本学不会。

2.2 坐标越界和宽高为 0 的框怎么提前发现

xml 里的bndbox是人工画的,难免有手抖的时候。常见问题有三种:xmin > xmax、ymin > ymax、框的宽或高为 0。这些框如果不提前清掉,转换后归一化坐标会出现负数或大于 1 的值,训练时要么报错,要么产生 NaN loss。更隐蔽的是框超出图像边界,比如xmax比<width>还大,归一化后大于 1,YOLO 默认会忽略这种框,但你会损失一个正样本。

def check_bbox(root): size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) issues = [] for obj in root.findall("object"): bbox = obj.find("bndbox") xmin = int(float(bbox.find("xmin").text)) ymin = int(float(bbox.find("ymin").text)) xmax = int(float(bbox.find("xmax").text)) ymax = int(float(bbox.find("ymax").text)) if xmin >= xmax or ymin >= ymax: issues.append(("宽高异常", xmin, ymin, xmax, ymax)) if xmin < 0 or ymin < 0 or xmax > w or ymax > h: issues.append(("越界", xmin, ymin, xmax, ymax)) return issues

check_bbox接收一个 xml 的 root 节点,先读图像宽高,再逐个框检查。int(float(...))是为了兼容 xml 里写成123.0这种浮点字符串的情况。返回的 issues 列表里,每条记录包含问题类型和四个坐标。我一般会把这个函数套在遍历循环里,把所有有问题的文件名和框打印出来,人工确认是删掉还是修正。2056 张图里通常会有十几到几十个这种框,占比不大,但不处理就是隐患。

3. 把 VOC 的 xml 转成 YOLO 的 txt:脚本、参数与四个边界坑

3.1 转换脚本的核心逻辑与归一化公式

YOLO 格式的 txt 每行是class_id x_center y_center width height,全部归一化到 0 到 1 之间。转换公式不复杂:x_center = (xmin + xmax) / 2 / img_w,y_center = (ymin + ymax) / 2 / img_h,width = (xmax - xmin) / img_w,height = (ymax - ymin) / img_h。class_id 从 0 开始,你需要维护一个类别名到 id 的映射字典。我一般把fire设为 0,smoke设为 1,顺序无所谓,但训练和推理时必须一致。

import os import xml.etree.ElementTree as ET classes = ["fire", "smoke"] # 类别顺序固定,不要随意改 xml_dir = "annotations" out_dir = "labels" os.makedirs(out_dir, exist_ok=True) for fname in os.listdir(xml_dir): if not fname.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, fname)) root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in classes: continue # 跳过未知类别,避免写入错误 id cid = classes.index(name) bbox = obj.find("bndbox") xmin = float(bbox.find("xmin").text) ymin = float(bbox.find("ymin").text) xmax = float(bbox.find("xmax").text) ymax = float(bbox.find("ymax").text) # 裁剪到图像边界内,防止归一化越界 xmin = max(0, min(xmin, w)) xmax = max(0, min(xmax, w)) ymin = max(0, min(ymin, h)) ymax = max(0, min(ymax, h)) if xmax <= xmin or ymax <= ymin: continue # 裁剪后无效的框直接丢弃 xc = (xmin + xmax) / 2 / w yc = (ymin + ymax) / 2 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{cid} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") out_path = os.path.join(out_dir, fname.replace(".xml", ".txt")) with open(out_path, "w") as f: f.write("\n".join(lines))

这段脚本的关键点有三个。第一,classes列表的顺序就是最终 class_id 的顺序,fire是 0,smoke是 1,训练时的 data.yaml 必须和它一致。第二,max(0, min(xmin, w))这行做了边界裁剪,把越界坐标拉回图像范围内,比直接丢弃整个框更温和。第三,if xmax <= xmin or ymax <= ymin: continue在裁剪之后再做一次有效性判断,防止裁剪后框退化成一条线。{:.6f}保留六位小数,YOLO 读取时精度足够,文件也不会太大。

3.2 图像和标签的目录结构怎么摆才不会被框架漏读

YOLO 训练时对目录结构有约定。常见做法是建一个datasets目录,下面放images和labels,各自再分train和val。图像和标签的文件名必须一一对应,只是扩展名不同。比如images/train/fire_001.jpg对应labels/train/fire_001.txt。如果你把标签全放在一个目录里,框架在找fire_001.txt时可能因为路径拼接规则不同而找不到,然后它不会报错,只是把这张图当成负样本。这就是为什么很多人训练时 mAP 一直是 0,查了半天才发现标签没被加载。

# 推荐的目录结构 datasets/ images/ train/ # 放训练集图片,比如 1645 张 val/ # 放验证集图片,比如 411 张 labels/ train/ # 对应训练集的 txt val/ # 对应验证集的 txt

划分比例我一般按 8:2 走,2056 张里拿 1645 张做训练,411 张做验证。如果烟雾和火两类在数量上差距很大,比如火比烟雾多一倍,那划分时要保证验证集里两类的比例和整体一致,否则验证集的 mAP 会偏向多的那一类,看起来很高,实际部署时烟雾漏检严重。可以用sklearn.model_selection.train_test_split加stratify参数按类别分层抽样,但前提是你得先从 xml 里把每张图的主类别提取出来。

3.3 data.yaml 里三个必须对齐的字段

YOLO 训练需要一个 data.yaml 文件,里面path、train、val、nc、names这几个字段必须和实际目录、类别数对齐。nc是类别数,这里是 2。names是类别名列表,顺序必须和转换脚本里的classes完全一致。train和val写相对于path的路径。我见过有人把names写成['smoke', 'fire'],但转换时fire是 0,结果训练出来的模型把火识别成烟雾,这种错误在验证集上 mAP 看不出来,只有实际跑图才会发现。

path: ./datasets train: images/train val: images/val nc: 2 names: 0: fire 1: smoke

提示:改完 data.yaml 后,先跑一次yolo detect train data=data.yaml model=yolov8n.pt epochs=1,只训一个 epoch,看输出里train和val的图片数量是不是和你预期的一致。如果数量是 0 或者少了很多,说明路径或文件名对应有问题,别急着跑完整训练。

4. 训练参数怎么设:从 2056 张图里榨出可用模型

4.1 小数据集防过拟合的三个关键参数

2056 张图在目标检测里不算大,尤其是烟雾这种形态多变、边界模糊的目标。如果直接用默认参数跑 100 个 epoch,训练集 loss 会降得很低,但验证集 mAP 到 0.5 左右就上不去了,这是典型的过拟合。我一般会调三个参数:epochs设 80 到 120 之间,配合patience=20做早停;lr0初始学习率从默认的 0.01 降到 0.005,让模型在前期不要冲得太猛;weight_decay加到 0.0005,给权重加一点约束。这三个参数组合下来,验证集 mAP 通常能比默认参数高 3 到 5 个点。

from ultralytics import YOLO model = YOLO("yolov8n.pt") # 用预训练权重,小数据集必须迁移学习 model.train( data="data.yaml", epochs=100, patience=20, # 20 轮验证集没提升就停 lr0=0.005, # 初始学习率降低 weight_decay=0.0005,# 权重衰减防过拟合 batch=16, # 根据显存调整,8G 显存用 16 够 imgsz=640, # 输入尺寸,烟雾火焰用 640 足够 augment=True, # 开启默认数据增强 mosaic=1.0, # 马赛克增强,对小数据集很有效 mixup=0.1, # 混合增强,比例不要太高 device=0 # 用第一块 GPU )

yolov8n.pt是 nano 版本,参数量小,适合 2056 张这种量级。如果你用yolov8l.pt,参数量大十倍,过拟合会更严重,除非你有上万张图。mosaic=1.0表示每张图都做马赛克增强,把四张图拼成一张,等效于增加了数据多样性。mixup=0.1是混合两张图,比例低一点,太高会让烟雾和火的边界更模糊,反而不好学。batch=16在 8G 显存上跑 640 尺寸没问题,如果显存不够就降到 8,但 batch 太小 BN 层统计不稳定,mAP 会抖。

4.2 验证集 mAP 卡在 0.5 上不去时先查什么

训练跑完,验证集 mAP50 在 0.5 左右,mAP50-95 只有 0.2 出头,这是烟雾火焰检测的常见水平。先别急着换模型,按顺序查三件事。第一,看验证集的标注框有没有漏标。烟雾的边界很主观,不同人画出来的框可能差很多,如果验证集里有些烟雾没标,模型检出来了反而算误报,mAP 会被拉低。第二,看conf阈值。YOLO 验证时默认conf=0.001,会输出大量低置信度框,如果烟雾的置信度普遍偏低,调低conf能召回更多,但精度会降。我一般会在验证时加conf=0.25跑一次,对比一下 mAP 变化。第三,看图像尺寸。如果原图是 1920x1080,你直接缩到 640 训练,烟雾的小目标会变得很小,模型学不到。这种情况可以把imgsz提到 960 或 1280,但显存占用会翻倍。

metrics = model.val( data="data.yaml", conf=0.25, # 验证时置信度阈值 iou=0.6, # NMS 的 IoU 阈值 imgsz=640, batch=16 ) print("mAP50:", metrics.box.map50) print("mAP50-95:", metrics.box.map) print("每类 AP:", metrics.box.ap_class_index)

metrics.box.map50是 IoU 0.5 时的 mAP,metrics.box.map是 0.5 到 0.95 的平均。ap_class_index会告诉你每个类别的 AP,如果火的 AP 是 0.6,烟雾的 AP 是 0.3,说明烟雾更难学,可能需要单独对烟雾做增强,或者检查烟雾的标注质量。我遇到过烟雾 AP 只有 0.2 的情况,最后发现是烟雾的框画得太紧,只框住了最浓的部分,边缘的薄烟没框进去,模型学到的特征不完整。

4.3 推理时烟雾和火同时出现怎么调 NMS

实际场景里烟雾和火经常同时出现,而且位置重叠。YOLO 默认的 NMS 是类别无关的,也就是说一个烟雾框和一个火框如果 IoU 超过阈值,其中一个会被抑制掉。这会导致明明有火有烟,结果只检出一个。解决办法是把 NMS 改成类别内做,或者调高iou阈值。在 ultralytics 里,推理时加agnostic_nms=False就是类别内 NMS,默认就是 False,但有些人为了提速会改成 True,那就出问题了。另外iou=0.5是默认值,如果烟雾和火重叠严重,可以提到 0.7,让两个框都保留。

results = model.predict( source="test_images", conf=0.3, iou=0.7, # 提高 IoU 阈值,减少重叠框被误抑制 agnostic_nms=False, # 类别内 NMS,烟雾和火互不抑制 imgsz=640, save=True )

conf=0.3是推理时的置信度阈值,比验证时的 0.25 稍高,减少误报。iou=0.7让重叠的烟雾和火框都能保留。agnostic_nms=False是关键,它保证烟雾和火各自做 NMS,不会互相压制。如果你发现推理结果里只有火没有烟,或者只有烟没有火,先检查这个参数。

5. 避坑与排查:xml 标签处理中最容易翻车的五件事

5.1 现象:训练 loss 正常下降但 mAP 始终为 0

原因:标签文件没有被正确加载。最常见的情况是图像和标签的目录结构不符合框架约定,或者文件名没有一一对应。YOLO 在找不到标签时不会报错,而是把该图当成没有目标的负样本,loss 会降,但模型学不到任何东西。

解决:训练前先跑一个检查脚本,遍历images/train下的每张图,确认labels/train下有同名 txt。再打开几个 txt 看内容是否为空。如果 txt 是空的,说明转换脚本里类别名没匹配上,所有框都被continue跳过了。

5.2 现象:转换后的 txt 里坐标出现负数或大于 1

原因:xml 里的bndbox坐标超出了图像宽高,或者xmin > xmax。人工标注时画框拖出了边界,保存下来的坐标就是越界的。归一化时除以宽高,自然得到负数或大于 1 的值。

解决:在转换脚本里加边界裁剪,用max(0, min(x, w))把坐标拉回有效范围。裁剪后如果xmax <= xmin,直接丢弃这个框。不要试图保留越界框,YOLO 对越界坐标的处理是不确定的,不同版本行为不一样。

5.3 现象:类别名大小写不一致导致多出一个类

原因:xml 里有的写fire,有的写Fire,有的写FIRE。转换时如果直接按字符串匹配,会生成多个 class_id,而其中一些类只有几个样本,模型完全学不会,还会拉低整体 mAP。

解决:在统计类别分布时就用.lower()统一转小写,或者在转换脚本里维护一个别名映射表,把Fire、FIRE、fire都映射到fire。转换完成后,用Counter再统计一次 txt 里的 class_id 分布,确认只有 0 和 1 两个值。

5.4 现象:烟雾和火两类样本数量差距大,模型偏向多数类

原因:如果火有 3000 个框,烟雾只有 800 个框,模型在训练时看到的火远多于烟雾,梯度更新会偏向火,导致烟雾召回率很低。验证集 mAP 看起来还行,是因为火把平均值拉高了。

解决:两种做法。一是对烟雾类做过采样,在训练时重复采样含烟雾的图;二是用 YOLO 的cls损失权重参数,给烟雾类更高的权重。我一般先用过采样,简单直接。如果差距超过 3 倍,再考虑加权损失。

5.5 现象:xml 格式文件没有标签怎么办

原因:你打开 xml 发现<object>节点是空的,或者根本没有<object>。这可能是标注工具导出时出了问题,或者这个 xml 对应的是负样本图(没有目标的背景图)。标题里的数据集有 2056 张,不可能每张都有目标,有一部分是纯背景图是正常的。

解决:先统计有多少个 xml 没有<object>。如果数量在合理范围内(比如 5% 到 10%),这些图可以作为负样本保留,对应的 txt 文件留空即可。YOLO 支持空 txt,表示这张图没有目标。但如果超过 30% 的 xml 没有 object,说明标注过程有问题,需要联系数据提供方确认。另外检查一下 xml 的编码,有些文件是 GBK 编码,用 UTF-8 读会报错,ET.parse默认能处理,但如果报错就加encoding="utf-8"参数。

6. 用 2056 张图训完之后:验证模型是否真的学到了烟雾和火

训练跑完,权重文件存下来了,但你怎么知道它真的能区分烟雾和火,而不是靠背景颜色蒙的?我一般会做三件事。第一,拿一批训练集里没出现过的图,最好是不同场景的,跑推理,看烟雾和火的框是否合理。第二,用 Grad-CAM 或者 YOLO 自带的可视化,看模型关注的是烟雾本身的纹理,还是背景里的天空、墙壁。如果热力图集中在背景上,说明模型学偏了。第三,做一个简单的混淆测试:找一张只有烟没有火的图,看模型会不会误检出火。如果误检,说明两类之间的特征区分度不够,可能需要增加负样本或者调整类别权重。

import cv2 from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") img = cv2.imread("test_scene.jpg") results = model(img, conf=0.3, iou=0.7, agnostic_nms=False) for r in results: for box in r.boxes: cls_id = int(box.cls[0]) conf = float(box.conf[0]) xyxy = box.xyxy[0].tolist() label = "fire" if cls_id == 0 else "smoke" print(f"{label} {conf:.2f} {xyxy}") cv2.rectangle(img, (int(xyxy[0]), int(xyxy[1])), (int(xyxy[2]), int(xyxy[3])), (0, 255, 0), 2) cv2.putText(img, f"{label} {conf:.2f}", (int(xyxy[0]), int(xyxy[1]) - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite("result.jpg", img)

这段代码加载训练好的best.pt,对单张图做推理,把每个框的类别、置信度和坐标打印出来,并在图上画框。box.cls[0]是类别 id,box.conf[0]是置信度,box.xyxy[0]是左上角和右下角坐标。跑完之后看result.jpg,如果烟雾和火的框都贴合目标,置信度在 0.5 以上,说明模型基本可用。如果某个类的框明显偏移或者置信度普遍低于 0.3,回去检查那个类的标注质量和样本数量。

我自己的习惯是,每次训完模型,先拿十张验证集里的图跑一遍,肉眼过一遍框。这一步花不了五分钟,但能发现很多指标看不出来的问题。比如有一次 mAP 到了 0.65,看起来不错,但可视化之后发现模型把夕阳的橙色区域全检成了火,这就是典型的背景过拟合。后来在训练时加了hsv_h=0.015、hsv_s=0.7、hsv_v=0.4的颜色增强,让模型不要那么依赖颜色特征,误检才降下来。烟雾火焰检测这个方向,数据集的标注质量比模型结构重要得多,2056 张图如果标注得干净,用 nano 模型也能跑出可用的效果;如果标注里一堆漏标错标,换多大的模型都是白搭。希望帮到你。

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

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

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

立即咨询