
简介针对烟雾与火焰检测任务这份数据集提供了完整的图像与XML标注资源适合计算机视觉学习者、算法工程师以及安防消防领域研究者用于目标检测模型的训练与效果评估。压缩包共4118个文件包含2059个JPG图像与2059个配套XML标注文件标注类别覆盖“烟雾”和“火”两类目标整体大小约168.41MB。XML标签记录目标类别与边框位置信息可直接用于YOLO、Faster R-CNN等主流检测框架的训练输入省去手动标注环节类别区分明确可满足烟雾与火焰的二分类或多类别检测需求。目前已有2326人学习下载资源整理规范便于划分训练集、验证集与测试集适合快速构建烟火识别数据集支撑火灾预警、森林防护、智能安监等场景的算法开发与项目落地。对于初学者也能借此熟悉标准标注格式与数据组织方式是一份可直接投入训练使用的数据集。1. 烟雾火焰数据集VOC 标注的烟火检测数据到底能做什么烟雾火焰数据集听起来像个普通的目标检测数据集但实际用起来会发现它是烟火识别项目里最难替代的部分。2056 张图片烟雾和火两类全部带 xml 标签意味着它可以直接被 YOLO、SSD、Faster R-CNN 这些主流检测框架消费也可以先用 VOC 格式的标注工具链做检查、清洗和增强。对于正在做森林防火、工厂安防、城市早期火情预警的工程师来说这套数据解决的不是“有没有模型”而是“有没有靠谱的训练起点”。它最适合两类人一类是刚接触目标检测、需要一套干净数据跑通流程的初学者另一类是已经跑过公开数据集、想快速评估烟火场景落地效果的从业者。接下来我从标注结构讲起一直到训练和坑位排查。2. 读懂 xml 标注先检查数据再谈训练拿到任何 VOC 格式的数据集第一步不是直接进训练脚本而是先把你手里的 xml 读一遍。烟火检测这种任务对标注质量尤其敏感因为烟雾是半透明、非刚性目标边界天然模糊不同标注员画的框可能偏差很大。如果直接训练模型会把这些不一致当成特征去学轻则收敛慢重则在真实现场疯狂误报。这一章我会带你逐字段解析 xml并给出一套能直接跑的体检脚本。2.1 一张 VOC 格式的 xml 里有什么VOCPASCAL VOC格式的标注文件核心是 object 节点。一个典型的标签长这样annotation foldersmoke_fire_2024/folder filenameimg_0042.jpg/filename size width1280/width height720/height depth3/depth /size object namefire/name difficult0/difficult bndbox xmin312/xmin ymin98/ymin xmax674/xmax ymax356/ymax /bndbox /object object namesmoke/name difficult0/difficult bndbox xmin201/xmin ymin130/ymin xmax512/xmax ymax410/ymax /bndbox /object /annotationfilename 要和同目录下的图片文件名严格一致size 里的宽高是训练时用来做归一化的基准。object 节点可以出现多次也就是说一张图里可以同时标多个火头和多团烟雾这正是烟火场景最常见的形态。难点在于 bndbox 的四个值xmin、ymin 是左上角xmax、ymax 是右下角它们必须是像素坐标而且不能超出图片边界。后面转换脚本的核心就是把这四个值读出来换算成 YOLO 需要的中心点加宽高格式。2.2 训练前先跑一遍标注体检类别名、边界框、空标注多数数据集不会像文档里写得那么干净尤其这类由人工标注整理的数据常见问题包括类别名大小写混用fire 和 Fire、坐标超出图片宽高、宽高为 0 的退化框、以及“图片在但 xml 缺失”或“xml 在但图片缺失”。这些问题单个出现概率不高但 2056 张图里只要有一批训练时就会在数据加载阶段报错或者更隐蔽地让 loss 在某个值附近震荡。我一般会在拿到数据的第一时间跑下面这个检查脚本import os import xml.etree.ElementTree as ET from PIL import Image IMG_DIR images XML_DIR annotations img_files {f for f in os.listdir(IMG_DIR) if f.lower().endswith((.jpg, .png))} xml_files {f for f in os.listdir(XML_DIR) if f.lower().endswith(.xml)} # 1. 图片与标注是否一一对应 only_img img_files - {os.path.splitext(x)[0] .jpg for x in xml_files} only_xml xml_files - {os.path.splitext(f)[0] .xml for f in img_files} print(缺 xml 的图片:, only_img if only_img else 无) print(缺图片的 xml:, only_xml if only_xml else 无) # 2. 读取每个 xml检查 bndbox 合法性 for xml_name in sorted(xml_files)[:50]: tree ET.parse(os.path.join(XML_DIR, xml_name)) root tree.getroot() size root.find(size) w, h int(size.find(width).text), int(size.find(height).text) for obj in root.findall(object): name obj.find(name).text box obj.find(bndbox) x1 int(float(box.find(xmin).text)) y1 int(float(box.find(ymin).text)) x2 int(float(box.find(xmax).text)) y2 int(float(box.find(ymax).text)) if x1 x2 or y1 y2: print(f{xml_name}: {name} 框宽高异常 ({x1},{y1},{x2},{y2})) if x1 0 or y1 0 or x2 w or y2 h: print(f{xml_name}: {name} 超出图片边界 ({x1},{y1},{x2},{y2}) vs {w}x{h}) print(抽查完成)这段脚本前半段用了集合差把“有图没标”和“有标没图”的情况一次性暴露出来。后半段只抽查前 50 个 xml目的是快速看有没有明显脏数据全量检查放在转换脚本里更合适。为什么要抽查而不是全量因为 xml.etree 的解析在全量 2056 个文件上不算慢但人工去读输出才是瓶颈抽查先把最严重的问题暴露出来全量交给后面的转换脚本兜底。注意脚本里我用 int(float(...)) 而不是 int(...)是因为不少标注工具会把坐标写成小数直接 int() 会在某些写法下报错先转 float 再下取整更可靠。提示类别名出现的所有值都要记录后面转 YOLO 格式时要按固定顺序映射成 0、1。顺序一旦定了就不要改否则训练到一半再换类别索引模型权重和数据集就对不上了。2.3 统计类别分布烟雾和火的占比决定后续策略体检通过之后下一步是统计每个类别的出现频次和边界框尺寸分布。这个统计直接决定两个事情一是类别权重怎么配二是需不需要对某一类做针对性增强。烟火数据的典型特征是 fire 框通常小而集中smoke 框大而分散两种框的宽高比差异很大如果统一按同一个锚框策略跑小目标的火焰很可能直接被忽略。import os import xml.etree.ElementTree as ET from collections import Counter, defaultdict XML_DIR annotations category_counter Counter() box_sizes defaultdict(list) for xml_name in os.listdir(XML_DIR): if not xml_name.endswith(.xml): continue tree ET.parse(os.path.join(XML_DIR, xml_name)) root tree.getroot() w int(root.find(size/width).text) h int(root.find(size/height).text) for obj in root.findall(object): name obj.find(name).text category_counter[name] 1 box obj.find(bndbox) bw int(float(box.find(xmax).text)) - int(float(box.find(xmin).text)) bh int(float(box.find(ymax).text)) - int(float(box.find(ymin).text)) box_sizes[name].append((bw / w, bh / h)) # 归一化到 0-1 print(类别分布:, dict(category_counter)) for k, v in box_sizes.items(): avg_w sum(s[0] for s in v) / len(v) avg_h sum(s[1] for s in v) / len(v) print(f{k}: 框数量 {len(v)}, 平均归一化宽 {avg_w:.3f}, 平均归一化高 {avg_h:.3f})这里把边界框尺寸除以图片宽高得到归一化后的相对大小。这么做是为了和后面 YOLO 的输入尺寸对应比如图片 1280 宽、框 320 宽归一化后就是 0.25训练时如果输入缩到 640这个框在特征图上的像素占比就清楚了。统计结果出来后常见情况是 smoke 和 fire 的框数量不均衡比例悬殊到 3:1 以上。此时建议先用原始数据训一版看混淆矩阵再决定要不要做增强而不是上来就调类别权重烟火任务里增强翻车的概率比权重配错还高。3. 将 xml 转成 YOLO 格式转换脚本与四个边界坑VOC 格式虽然通用但现在的检测框架主流输入是 YOLO 格式的 txt一个标注文件对应一张图每行写类别、中心点 x、中心点 y、宽、高全部归一化到 0~1。很多初学者在这里翻车不是不会写转换代码而是漏了坐标从“左上右下”到“中心点宽高”的换算或者忘了归一化。这一章直接给出我常用的转换脚本并把转换后最容易出的四个问题一次性讲透。3.1 为什么要从 VOC 转到 YOLO 格式YOLO 系列v5、v8 以及最新的 v11的默认标注是 txt 格式每行五个数字。相比 xmltxt 没有标签名而是用数字索引加载更快也更符合 anchor-based 检测头对目标框的表达习惯。另一个原因是庞大的生态工具链Ultralytics 的 API、roboflow 导出、以及大量数据增强库都默认读 txt转过去之后你能直接用上这些工具不需要为 xml 写定制 loader。VOC 的 bndbox 是左上角 (x1, y1) 和右下角 (x2, y2)YOLO 需要的是中心点坐标 (cx, cy) 和宽高 (w, h)换算公式是cx (x1 x2) / 2 / 图片宽 cy (y1 y2) / 2 / 图片高 w (x2 - x1) / 图片宽 h (y2 - y1) / 图片高注意除法在最外层先算像素值再归一化顺序反了数值就会差一个量级。3.2 转换脚本解析 xml归一化写 txtimport os import xml.etree.ElementTree as ET IMG_DIR images XML_DIR annotations OUT_DIR labels_yolo CLASSES [smoke, fire] # 索引 0 和 1顺序固定 os.makedirs(OUT_DIR, exist_okTrue) skipped 0 for xml_name in os.listdir(XML_DIR): if not xml_name.endswith(.xml): continue tree ET.parse(os.path.join(XML_DIR, xml_name)) root tree.getroot() w int(root.find(size/width).text) h int(root.find(size/height).text) if w 0 or h 0: print(f跳过 {xml_name}: 图片尺寸异常) skipped 1 continue lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in CLASSES: print(f跳过 {xml_name}: 未知类别 {name}) skipped 1 continue box obj.find(bndbox) x1 float(box.find(xmin).text) y1 float(box.find(ymin).text) x2 float(box.find(xmax).text) y2 float(box.find(ymax).text) if x1 x2 or y1 y2 or x2 w or y2 h or x1 0 or y1 0: print(f跳过 {xml_name}: 非法框 {name}) skipped 1 continue cx (x1 x2) / 2 / w cy (y1 y2) / 2 / h bw (x2 - x1) / w bh (y2 - y1) / h lines.append(f{CLASSES.index(name)} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) base os.path.splitext(xml_name)[0] with open(os.path.join(OUT_DIR, base .txt), w) as f: f.write(\n.join(lines)) # 拷贝同名图片到 images_yolo保持一一对应 os.system(fcp {IMG_DIR}/{base}.jpg {IMG_DIR}_yolo/{base}.jpg 2/dev/null || true) print(f转换完成跳过 {skipped} 个异常文件)这段脚本把 xml 解析、合法性检查、坐标换算、文件落盘放在一个循环里前置条件是 CLASSES 列表里的顺序就是最终训练时的类别索引必须是 0 开始。脚本里跳过异常文件而不中断因为我处理 2056 张图时发现中断一次就要回头人工核对跳过错名单最后集中看打印信息更高效。转换时顺便用 os.system 拷贝同名图片是为了保证 labels 文件夹和 images 文件夹的文件名一一对应。如果你的图片不全是 jpg把 cp 那行改成匹配实际扩展名或者用 shutil.copy2 并在循环里按实际 filename 字段来找图片更可靠。3.3 转换后必须查的四个问题第一个问题是空 txt。如果某张图 xml 里有 object 但因为类别名不在 CLASSES 列表里被跳过输出的 txt 是空文件YOLO 训练时会忽略它但空文件本质上代表这张图没标注你得决定是删掉还是补标。第二个问题是归一化后的 w 或 h 出现负数这通常是 xml 里 xmin 和 xmax 填反了有些标注工具画框方向不同会导致这个转换脚本里的非法框检查能拦住一部分但填反且 x1 x2 的情况肉眼很难发现建议转换后随机抽 20 个 txt 对照原图可视化一遍。第三个问题是类别索引错位比如你 CLASSES 写 [fire, smoke]但之前统计时用的是 [smoke, fire]训练时 loss 看起来正常mAP 却一直上不去因为模型在学相反映射。第四个问题是多通道 tif 或红外图被误判为普通图烟火数据里偶尔混入热成像图PIL 或 OpenCV 读取时通道数不同YOLO 训练会报错。遇到这种情况统一转成 RGB 三通道 jpg 再进训练不要指望模型自己适应通道差异。转换完不要急着训先把 labels_yolo 里的 txt 和 images_yolo 里的图片数量对齐再用下面命令检查每行数字个数是否为 5for f in labels_yolo/*.txt; do awk NF ! 5 {print $0} $f doneawk 输出为空说明所有标注行都是五列这是 YOLO 格式最基础的红线。跑完这步数据才真正具备训练条件。4. 用 YOLOv8 训练烟火检测划分数据集与参数建议数据整理干净之后训练本身反而变成最机械的部分。但烟火检测有两个特点会让训练结果和随机划分差别很大一是同一场景的多帧图像特征高度相似二是烟和火的尺度差异极大。这一章讲如何划分数据集、怎么写 data.yaml、以及训练后必须看的指标。所有参数基于我在这类任务上的经验你可以根据显卡显存调整不建议照抄。4.1 划分数据集先按场景再随机2056 张图听起来不少但如果这些图来自有限个固定机位随机划分会让同一段视频的连续帧同时出现在训练集和验证集里模型相当于开卷考试验证集 mAP 虚高到 0.9 以上换到新场景立刻跌到 0.4。正确的做法是先看图片命名或目录结构尽量按视频片段或拍摄场景分组再把整个场景划进训练或验证。import os import random from collections import defaultdict IMG_DIR images_yolo scene_groups defaultdict(list) for f in os.listdir(IMG_DIR): if not f.endswith(.jpg): continue # 假设文件名前缀 scene_001_frame002.jpg取前两段做场景 scene f.split(_)[0] _ f.split(_)[1] scene_groups[scene].append(f) scene_names list(scene_groups.keys()) random.shuffle(scene_names) train_scenes scene_names[: int(len(scene_names) * 0.8)] val_scenes scene_names[int(len(scene_names) * 0.8):] train_txt train.txt val_txt val.txt with open(train_txt, w) as f: for s in train_scenes: for img in scene_groups[s]: f.write(f/abs/path/to/{img}\n) with open(val_txt, w) as f: for s in val_scenes: for img in scene_groups[s]: f.write(f/abs/path/to/{img}\n) print(f训练场景 {len(train_scenes)} 个, 图片 {sum(len(scene_groups[s]) for s in train_scenes)} 张) print(f验证场景 {len(val_scenes)} 个, 图片 {sum(len(scene_groups[s]) for s in val_scenes)} 张)这里示意按文件名前缀分组实际项目里如果图片没有结构化命名可以用感知哈希或者 ORB 特征把相似帧聚类代价是计算量高一些。我一般至少会分 5 播即 5 次不同随机种子划分取验证集指标最好的一次因为单次划分的运气成分在这种规模的数据集上不可忽视。另外注意 train.txt 里写的是绝对路径还是相对路径YOLO 训练时要保持前后一致否则换机器跑马上报文件不存在。4.2 data.yaml 与关键训练参数Ultralytics YOLOv8 训练时只需要一个 yaml 文件指向数据和类别列表内容极简path: /your/dataset/root train: train.txt val: val.txt names: 0: smoke 1: firetrain 和 val 可以写相对 path 的路径也可以直接写绝对路径。names 的顺序必须和转换脚本的 CLASSES 完全一致。这里的 path 是后面所有相对路径的根Ultralytics 会把 train.txt 里的图片路径拼到 path 上如果 train.txt 里已经是绝对路径path 字段可以随便填个目录。训练命令我用得最频繁的是下面这个yolo detect train \ datadata.yaml \ modelyolov8n.pt \ epochs120 \ imgsz640 \ batch16 \ patience20 \ projectruns/smoke_fire \ namev8n_640挑三个最影响烟火检测结果的参数展开说。imgsz 默认 640如果你的场景里火焰是小目标建议试试 960 或 1280代价是显存和训练时间翻倍但小目标 AP 通常能涨 3~5 个点。batch 不是越大越好梯度过大反而让模型在小数据集上过拟合更快16 在 12G 显存上跑 640 输入比较稳。patience 是早停参数我见过有人在 2056 张图上训到 120 epoch 消耗大量时间其实验证集 mAP 在 60~80 epoch 就到平台期了patience 设 20 能自动停。注意不要一上来就上 yolov8x 或加 CBAM 这些花活先跑通 v8n 基线。烟火检测的瓶颈几乎不在模型容量在数据标注的框质量和小目标密度。基线 mAP 如果低于 0.6先回数据侧找原因换大模型只会掩盖问题。4.3 训练结束后看什么指标训练结束后屏幕上会输出一长串指标重点看三个mAP50、mAP50-95、以及每个类别的 AP。mAP50 是 IoU 阈值 0.5 时的均值烟火这类边界模糊的目标框和真实框的 IoU 往往偏低mAP50-95 很难超过 0.7不用太焦虑。真正值得关注的是 smoke 和 fire 两个类别的 AP 差异如果 fire 的 AP 明显低于 smoke说明小目标漏检是主要矛盾。我还习惯性地把验证集上模型预测失败的图导出成一张拼图直接用 Ultralytics 提供的混淆矩阵图不够直观要配合看具体图片yolo detect val \ modelruns/smoke_fire/v8n_640/weights/best.pt \ datadata.yaml \ conf0.25 \ save_predTrue \ projectruns/smoke_fire \ nameval_checksave_predTrue 会在验证时把带标注框的预测图存下来。你去翻这些图的时候重点找三类样本远处的小火苗、浓烟背景下的淡淡烟迹、以及高光反射被误判成火的地方这三类基本就是烟火检测最后的难点也直接决定了模型能不能上真实场景。5. 烟火检测最容易踩的坑误报、漏报与难例排查这章内容来自我做了几轮烟火检测项目后的血泪经验每一条都是先看到现象、再查原因、最后找到解决路径的完整链路。如果你正准备拿这套数据训练建议先看完这章再动手能省下不少重训的冤枉时间。5.1 云、雾被识别成烟雾现象模型在训练集的烟雾图上表现不错但在装有户外摄像头的场景里把白云、晨雾、水蒸气全部标成 smoke误报率高到无法上线。原因烟雾本质上是半透明的非刚性物体训练集里如果烟雾图以深色浓烟为主模型学到的其实是“灰白色团块”这个表面特征而不是烟的纹理和消散形态。云和水蒸气在颜色、形状上跟烟高度重叠单靠外观特征天然分不开。解决我试过几种办法最有效的是加时间去分辨。烟雾在连续帧里有扩散和消散的运动特征云基本不动。落地方法是把模型输出接到一个轻量时序判读器上连续 N 帧都检出才算报警。如果只做单帧检测那就得往训练集里补充薄烟、远烟、逆光烟这类难例让模型见过足够多的“烟但不是云”的样本。5.2 远处小火苗漏检现象验证集 mAP 看着不错一测现场远处刚燃起的火苗框完全不触发。这类火苗在 1280 分辨率的原图上往往只有 20~30 像素宽。原因训练时 imgsz640图片缩一半小目标尺寸在特征图上只剩几个像素检测头很难召回。另一个因素是数据集的框尺寸分布里大火苗占多数模型对小目标的先验不足。解决第一选择是把推理和训练的 imgsz 提上去960 或 1280第二选择是做大图切块推理把原图切成四块分别检测再合并结果第三选择是给 YOLO 加 P2 检测层但这需要改模型结构风险大收益未必明显。我一般先试前两个效果好且不影响工程可靠性。5.3 验证集划分不当造成指标虚高现象训练的 mAP50 显示 0.92团队以为模型已经成熟结果现场评测不到 0.5。原因划分数据集时随机切分同一个摄像机机位的连续帧同时出现在训练集和验证集模型看到的验证样本和训练样本几乎同一场景等于开卷考试。这类问题在自采数据上极为常见因为自采数据天然按机位和时间聚集。解决按机位或视频片段分组划分组内不分家。另一个补救措施是加一个“外人”验证集找几个完全没参与训练的现场视频片段来跑指标可信度会高很多。划分脚本我在 4.1 里已经给出不要嫌麻烦跳过。5.4 标注文件脏数据导致训练异常现象训练在第 20 epoch 左右 loss 突然飙到 nan或者训练完精度极低翻验证集图片发现框的位置完全不对。原因xml 里有坐标越界、类别名大小写混用、以及小概率存在的 bndbox 填反。转换脚本虽然拦了一大部分但有些脏数据的坐标本来就在图片范围内只是语义上是错的比如把火焰框画到了背景高光上。解决把训练集预测结果可视化一遍重点看置信度最高和最低的那些框能发现大量标注错误。另一个技巧是训练时加 fliplr 增强后复查一遍左右翻转最容易暴露标注不对称的问题。数据清洗虽然耗时但在这类场景下回报率远高于调模型。6. 上线前最后一步用小目标专项评测验证模型模型训练完不是终点烟火检测这类任务真正麻烦的是小目标所以我在最终验收时一定会做一次小目标专项评测而不是只看验证集的 mAP。具体做法是固定一个测试视频片段从里面截帧然后把每帧按 2x2 切块分别用模型检测后合并记录小目标的召回率。import cv2 import numpy as np from ultralytics import YOLO model YOLO(runs/smoke_fire/v8n_640/weights/best.pt) img cv2.imread(night_fire_042.jpg) h, w img.shape[:2] tiles [(0, 0, w // 2, h // 2), (w // 2, 0, w, h // 2), (0, h // 2, w // 2, h), (w // 2, h // 2, w, h)] all_boxes [] for x1, y1, x2, y2 in tiles: tile img[y1:y2, x1:x2] results model(tile, conf0.25, imgsz640)[0] for box in results.boxes.data.cpu().numpy(): tx1, ty1, tx2, ty2, conf, cls box all_boxes.append((int(tx1 x1), int(ty1 y1), int(tx2 x1), int(ty2 y1), conf, int(cls)))这段代码把原图切成四块做推理再把坐标映射回原图坐标系。切图推理的收益在于小目标在切块后相对尺寸变大模型更容易召回。代价是推理时间翻倍所以这个评测脚本只用于离线验收不适合直接部署到实时链路。评测时我建议记录下面这张表每一行代表一帧的检出情况帧号真实火苗数检出的火苗数最小框宽度(px)结论0421118通过0732112小目标漏检把十几帧的结果汇总如果最小框宽度在 15 像素以下时召回率低于 60%就可以明确判断模型不适合直接上生产需要按 5.2 里的方案做增强或切图部署。我现在做烟火检测项目都会把这个小目标评测流程固定下来每次迭代模型先跑它通过再谈上线免得在现场翻车后回头找数据问题。这套数据本身质量不错但工程落地从来不是把 2056 张图喂进去就完事希望这份流程能帮你少走几趟弯路一次把烟火检测跑通。本文还有配套的精品资源点击获取