简介:本资源面向火焰检测方向的工程开发人员与高校学生,提供一套可直接运行的完整方案,涵盖YOLO格式数据集、已训练模型文件与QT可视化界面,既能支撑实际工程项目落地,也适合作为大学生课题或论文的实验基础。压缩包共234个文件,约997.51MB,包含jpg、jpeg、png等图像样本,yaml与cfg配置,py与pyc源码,pt权重文件,以及csv训练日志、sh脚本、Dockerfile、md说明和xml标注等,覆盖数据、训练、推理与部署各环节。目前已有935人学习下载,说明该方案在火焰识别场景中具备一定参考价值。读者可借助现成模型快速查看检测效果,结合训练曲线与结果文件复盘调参过程,并基于QT界面完成演示与二次开发,省去从零搭建数据与模型的时间成本。
1. 火焰检测算法落地:从 yolo 数据集到 QT 界面的完整链路
厂区仓库的消防改造项目里,最容易被低估的不是模型精度,而是从「一堆标注图片」到「值班员能点开就用的桌面程序」之间那条链路。火焰检测算法本身在开源社区已经相当成熟,yolo 系列把检测门槛压得很低,但真正交付时你会发现:数据集格式不对、模型文件加载报错、QT 界面卡在主线程、误报把烟雾当成火焰——这些才是让项目翻车的地方。这篇笔记面向的是手里已经有一批火焰/烟雾图片、想把它做成一个能跑在工控机上的桌面检测工具的工程师。我会按「数据集怎么整理成 yolo 格式 → 模型怎么训练和导出 → QT 界面怎么接推理 → 怎么避坑」的顺序讲,每一步都给可抄的命令和参数,新手能跟着跑通,熟手能直接看到边界条件。
2. 火焰数据集整理成 yolo 格式:标注、划分与两个易错点
2.1 火焰检测的数据特点决定了标注策略
火焰和烟雾这类目标有几个和常规 COCO 目标不一样的地方,直接照搬通用检测经验会吃亏。第一,火焰边界模糊,火苗外焰和背景之间是渐变过渡,标注框画大画小全凭标注员手感,同一张图两个人标出来的框可能差 15% 以上。第二,火焰形态高度依赖场景:蜡烛火、酒精灯、木材堆火、电缆短路电弧,视觉差异极大,如果数据集里只有一种火,模型换场景就废。第三,负样本极其关键,夕阳、车灯、焊接火花、红色反光板这些「像火但不是火」的样本如果不进训练集,现场误报率会高到没法用。
我一般建议火焰检测数据集按「场景 + 火源类型」两个维度去凑,每个维度至少覆盖 3 到 5 类,正样本和困难负样本的比例控制在 1:1 到 1:2 之间。困难负样本就是那些模型容易误判的图,比如黄昏天空、暖色灯光、金属反光。这部分数据不用标框,但必须进训练集,否则模型学不会「什么不是火」。
2.2 从 VOC/COCO 转成 yolo txt 的脚本
大多数人手头的数据集是 VOC 的 XML 或者 LabelImg 直接导出的,yolo 要的是每张图对应一个 txt,每行类别 中心x 中心y 宽 高,且全部归一化到 0~1。下面这个脚本处理 VOC 转 yolo,同时做训练/验证集划分。
import os import random import xml.etree.ElementTree as ET import shutil # 类别映射,火焰检测一般就 fire / smoke 两类,多类按需扩展 CLASSES = ["fire", "smoke"] # 输入:VOC 格式的图片和 xml 目录;输出:yolo 数据集根目录 VOC_IMG_DIR = "./voc/JPEGImages" VOC_XML_DIR = "./voc/Annotations" OUT_ROOT = "./fire_yolo" VAL_RATIO = 0.2 # 验证集比例,火焰场景样本少时别超过 0.2 def convert_box(size, box): # size: (w, h),box: (xmin, ymin, xmax, ymax) dw, dh = 1.0 / size[0], 1.0 / size[1] x = (box[0] + box[2]) / 2.0 * dw y = (box[1] + box[3]) / 2.0 * dh w = (box[2] - box[0]) * dw h = (box[3] - box[1]) * dh return x, y, w, h def convert_annotation(xml_path, txt_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w, h = int(size.find("width").text), int(size.find("height").text) lines = [] for obj in root.iter("object"): cls = obj.find("name").text if cls not in CLASSES: continue cls_id = CLASSES.index(cls) bnd = obj.find("bndbox") box = (float(bnd.find("xmin").text), float(bnd.find("ymin").text), float(bnd.find("xmax").text), float(bnd.find("ymax").text)) x, y, bw, bh = convert_box((w, h), box) # 过滤掉宽高为 0 的脏标注,这类框会让训练 loss 直接 NaN if bw <= 0 or bh <= 0: continue lines.append(f"{cls_id} {x:.6f} {y:.6f} {bw:.6f} {bh:.6f}") with open(txt_path, "w") as f: f.write("\n".join(lines)) # 建目录 for sub in ["images/train", "images/val", "labels/train", "labels/val"]: os.makedirs(os.path.join(OUT_ROOT, sub), exist_ok=True) xml_files = [f for f in os.listdir(VOC_XML_DIR) if f.endswith(".xml")] random.shuffle(xml_files) val_count = int(len(xml_files) * VAL_RATIO) val_set = set(xml_files[:val_count]) for xml_file in xml_files: name = os.path.splitext(xml_file)[0] split = "val" if xml_file in val_set else "train" txt_path = os.path.join(OUT_ROOT, "labels", split, name + ".txt") convert_annotation(os.path.join(VOC_XML_DIR, xml_file), txt_path) img_src = os.path.join(VOC_IMG_DIR, name + ".jpg") if os.path.exists(img_src): shutil.copy(img_src, os.path.join(OUT_ROOT, "images", split, name + ".jpg"))这段脚本的逻辑是:先按比例随机划分文件,再逐张解析 XML,把绝对坐标转成归一化中心点格式。几个参数要留意:VAL_RATIO在火焰样本少于 2000 张时建议设 0.15 到 0.2,样本多了可以降到 0.1;CLASSES的顺序必须和后面训练时 data.yaml 里的names完全一致,顺序错了模型会把火识别成烟。转换完一定要抽查几张 txt,确认坐标没越界、没有空文件。
2.3 data.yaml 与目录结构
yolo 训练靠一个 yaml 描述数据位置和类别,火焰检测的典型配置如下:
path: ./fire_yolo train: images/train val: images/val nc: 2 names: 0: fire 1: smokepath用相对路径时,训练命令要在同一级目录执行,否则会找不到图。nc是类别数,必须和 names 条数一致。常见翻车点是图片和标签文件名对不上——yolo 靠同名匹配,abc.jpg必须配abc.txt,差一个字符这张图就被静默跳过,训练日志里看不出来,只能靠train前打印数据集统计去核对。
3. 火焰检测模型训练与导出:参数怎么设、模型文件怎么来
3.1 训练命令与关键超参
数据集就绪后,用 yolo 官方 CLI 或 Python API 都能训。火焰检测我一般从预训练权重起步,而不是从头训,因为火焰样本通常不够多。命令如下:
yolo detect train \ data=./fire_yolo/data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.01 \ lrf=0.01 \ patience=30 \ device=0 \ project=./runs_fire \ name=exp1参数逐个说:model选 n 还是 s 取决于部署硬件,工控机 CPU 推理就选 n,有 GPU 可以上 s;epochs火焰检测一般 100 到 200 够用,再多容易过拟合小数据集;imgsz=640是速度和精度的平衡点,火焰目标通常占画面比例不小,640 够用,如果检测远处小火苗可以提到 960;batch按显存调,8G 显存跑 640 用 16 比较稳;patience=30是早停,验证集 30 轮不涨就停,省时间;lr0初始学习率 0.01 是默认值,小数据集可以降到 0.005 更稳。
训练过程中重点看三个指标:mAP50看整体精度,火焰检测能到 0.85 以上基本可用;precision看误报,这个对消防场景比 recall 还重要,误报多了值班员会直接关掉系统;recall看漏检,漏检意味着真着火没报,同样致命。如果 precision 低,加困难负样本;如果 recall 低,检查标注框是不是画太小。
3.2 模型文件导出与格式选择
训练完在runs_fire/exp1/weights/下会有best.pt和last.pt,部署用best.pt。但.pt是 PyTorch 格式,QT 里如果直接用 Python 推理没问题,如果要嵌 C++ 或者上 TensorRT 就得转格式。
# 导出 ONNX,通用性最好,QT + OpenCV DNN 能直接读 yolo export model=./runs_fire/exp1/weights/best.pt format=onnx imgsz=640 opset=12 # 有 NVIDIA 显卡且追求帧率,导出 TensorRT engine yolo export model=./runs_fire/exp1/weights/best.pt format=engine imgsz=640 half=True device=0ONNX 的opset=12兼容性最好,QT 里用 OpenCV 的readNetFromONNX直接加载。TensorRT 的half=True开 FP16,速度能翻倍但精度掉一点点,火焰检测这种二分类任务基本无感。注意 TensorRT engine 和显卡型号、驱动版本绑定,换机器要重新导出,这是很多人部署时踩的坑——在开发机上导出的 engine 拷到现场工控机上直接报错。
3.3 推理后处理:置信度和 NMS 怎么调
模型输出的是原始框,要经过置信度过滤和 NMS 才能用。火焰检测的阈值和通用检测不一样:
| 参数 | 通用检测常用值 | 火焰检测建议值 | 原因 |
|---|---|---|---|
| conf 置信度阈值 | 0.25 | 0.4~0.5 | 火焰误报代价高,宁可漏一点 |
| iou NMS 阈值 | 0.45 | 0.5~0.6 | 火焰框重叠多,阈值太低会误删 |
| max_det 最大检测数 | 300 | 20 | 画面里火焰目标不会太多 |
conf 提到 0.4 以上能砍掉大部分「疑似火焰」的误报,代价是远处小火苗可能漏掉。如果场景对漏检零容忍,就保持 0.3 但必须加困难负样本重训。NMS 阈值调高是因为火焰区域经常被模型拆成多个框,阈值低了会把相邻的真框也删掉。
4. QT 界面接火焰检测:线程、绘制与实时性
4.1 为什么不能把推理放在主线程
QT 的主线程负责界面刷新和事件循环,推理一帧在 CPU 上可能几十到几百毫秒,放主线程界面直接卡死,按钮点不动、窗口拖不动。血泪经验是:只要推理耗时超过 30ms,就必须开独立线程。QT 里标准做法是QThread加信号槽,工作线程跑推理,结果通过信号发回主线程绘制。
from PyQt5.QtCore import QThread, pyqtSignal import cv2 import numpy as np class InferThread(QThread): # 信号:传回绘制好的帧和检测结果列表 frame_ready = pyqtSignal(np.ndarray, list) def __init__(self, model_path, conf=0.45, iou=0.55): super().__init__() self.net = cv2.dnn.readNetFromONNX(model_path) self.conf = conf self.iou = iou self.running = True def run(self): cap = cv2.VideoCapture(0) # 现场换成 RTSP 地址或视频文件 while self.running: ret, frame = cap.read() if not ret: continue blob = cv2.dnn.blobFromImage(frame, 1/255.0, (640, 640), swapRB=True, crop=False) self.net.setInput(blob) outputs = self.net.forward() detections = self.postprocess(outputs, frame.shape) for det in detections: x1, y1, x2, y2, score, cls_id = det color = (0, 0, 255) if cls_id == 0 else (255, 0, 0) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) cv2.putText(frame, f"{score:.2f}", (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) self.frame_ready.emit(frame, detections) cap.release() def postprocess(self, outputs, shape): # yolo 输出形状 (1, 4+nc, 8400),转置后逐行处理 h, w = shape[:2] outputs = np.squeeze(outputs).T boxes, scores, class_ids = [], [], [] for row in outputs: cls_scores = row[4:] cls_id = int(np.argmax(cls_scores)) score = float(cls_scores[cls_id]) if score < self.conf: continue cx, cy, bw, bh = row[:4] x1 = int((cx - bw / 2) * w / 640) y1 = int((cy - bh / 2) * h / 640) x2 = int((cx + bw / 2) * w / 640) y2 = int((cy + bh / 2) * h / 640) boxes.append([x1, y1, x2 - x1, y2 - y1]) scores.append(score) class_ids.append(cls_id) indices = cv2.dnn.NMSBoxes(boxes, scores, self.conf, self.iou) results = [] if len(indices) > 0: for i in indices.flatten(): x, y, bw, bh = boxes[i] results.append((x, y, x + bw, y + bh, scores[i], class_ids[i])) return results def stop(self): self.running = False self.wait()这段代码的关键点:blobFromImage的swapRB=True是因为 OpenCV 读图是 BGR,模型训练用 RGB,不换通道颜色会反,火焰可能识别不出来;1/255.0是归一化,必须和训练时一致;后处理里坐标要从 640 缩放回原图尺寸,缩放比例算错框会画偏。frame_ready信号把帧和结果一起发出去,主线程只负责setPixmap显示,不做任何计算。
4.2 主线程绘制与界面布局
主线程收到信号后更新 QLabel 显示画面,同时把检测结果写进一个列表控件,方便值班员回溯。
from PyQt5.QtWidgets import (QMainWindow, QLabel, QVBoxLayout, QWidget, QListWidget, QPushButton, QHBoxLayout) from PyQt5.QtGui import QImage, QPixmap from PyQt5.QtCore import Qt class FireDetectWindow(QMainWindow): def __init__(self, model_path): super().__init__() self.setWindowTitle("火焰检测系统") self.video_label = QLabel("等待视频流...") self.video_label.setAlignment(Qt.AlignCenter) self.alert_list = QListWidget() self.btn_start = QPushButton("开始检测") self.btn_stop = QPushButton("停止") right = QVBoxLayout() right.addWidget(self.alert_list) right.addWidget(self.btn_start) right.addWidget(self.btn_stop) main = QHBoxLayout() main.addWidget(self.video_label, stretch=3) main.addLayout(right, stretch=1) container = QWidget() container.setLayout(main) self.setCentralWidget(container) self.thread = InferThread(model_path) self.thread.frame_ready.connect(self.on_frame) self.btn_start.clicked.connect(self.thread.start) self.btn_stop.clicked.connect(self.thread.stop) def on_frame(self, frame, detections): # BGR 转 RGB,再转 QImage,注意 stride 参数 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(qimg)) for det in detections: x1, y1, x2, y2, score, cls_id = det label = "火焰" if cls_id == 0 else "烟雾" self.alert_list.addItem(f"{label} 置信度{score:.2f} 位置({x1},{y1})")QImage构造时的ch * w是每行字节数,漏了这个参数图像会斜。alert_list每帧都加会爆,实际项目里要加去重逻辑,比如同一位置 2 秒内只记一次。按钮直接连thread.start和thread.stop,注意QThread不能重复 start,停止后要重建线程对象才能再启动,这是 QT 多线程的经典坑。
4.3 实时性优化:从 15 帧到 30 帧的几个手段
工控机上跑 1080p 火焰检测,默认配置可能只有 10 到 15 帧。几个立竿见影的优化:第一,推理分辨率从 640 降到 416,速度提升接近一倍,火焰目标大时精度掉得不多;第二,用 TensorRT engine 替代 ONNX,N 卡上能到 2 到 3 倍;第三,跳帧推理,每 2 帧推一次,中间帧复用上次结果,画面看起来仍然流畅;第四,把cv2.dnn换成onnxruntime的 GPU provider,CPU 场景提升有限但 GPU 场景明显。这几招组合下来,1080p 25 帧在带独显的工控机上是可以做到的。
5. 火焰检测落地避坑:五条现场踩出来的经验
5.1 误报:夕阳和暖色灯光被识别成火焰
现象是每天傍晚系统疯狂报警,白天正常。原因是训练集里缺少黄昏、暖色照明这类困难负样本,模型只学会了「橙红色 = 火」。解决办法是专门采集一批夕阳、路灯、红色广告牌的图,不标框直接进训练集,同时把 conf 阈值从 0.25 提到 0.45。重训后误报能降一个数量级。
5.2 模型文件加载失败:ONNX 和 engine 的兼容性
现象是开发机上跑得好好的模型,拷到现场工控机上报readNetFromONNX失败或者输出全零。原因是 ONNX 的 opset 版本和现场 OpenCV 版本不匹配,或者 TensorRT engine 和显卡架构不匹配。解决办法是 ONNX 导出时固定opset=12,现场 OpenCV 至少 4.5.4;engine 必须在目标机器上重新导出,不能跨机器拷贝。
5.3 界面卡顿:推理和绘制抢主线程
现象是视频画面一顿一顿,点按钮要等好几秒才响应。原因是推理写在了主线程,或者信号槽用了Qt.DirectConnection导致跨线程直接调用。解决办法是确认推理在QThread.run里,信号槽用默认的Qt.AutoConnection,让 QT 自动排队到主线程。另外绘制时不要每帧都addItem,列表长了会拖慢界面。
5.4 漏检:小火苗和远距离火焰检测不到
现象是近处大火能报,远处小火苗一直不报。原因是训练集里小目标样本太少,或者推理分辨率太低导致小目标特征丢失。解决办法是训练时开mosaic增强(yolo 默认开),把imgsz提到 960,同时在数据里补充远距离火焰样本。如果还不行,考虑用切片推理,把大图切成小块分别检测。
5.5 颜色通道错误:火焰框位置对但类别反了
现象是框的位置准确,但火被标成烟、烟被标成火。原因是训练时用了 RGB,推理时blobFromImage没设swapRB=True,通道反了导致颜色特征错乱。解决办法是检查推理预处理,确保swapRB=True,并且归一化系数和训练一致。这个坑很隐蔽,因为框的位置不受颜色影响,只有类别会错。
6. 把火焰检测做成可交付系统的一个进阶技巧
前面讲的都是单模型单场景,但真实项目里经常遇到「一个模型覆盖不了所有场景」的情况:室内蜡烛火和室外木材堆火,用同一个模型精度都一般。我一般会用一个轻量的场景路由:先跑一个极小的分类模型判断当前画面属于哪类场景,再路由到对应的专用检测模型。分类模型可以用 MobileNet 这种级别的,推理耗时几乎可以忽略,但检测精度能提升 10 个点以上。
具体做法是训练 2 到 3 个专用火焰检测模型,每个模型只负责一类场景,然后在 QT 推理线程里先跑分类再选模型。代码结构上就是把InferThread里的self.net换成一个字典,根据分类结果动态选。
# 场景路由:分类结果 -> 检测模型 self.det_models = { "indoor": cv2.dnn.readNetFromONNX("fire_indoor.onnx"), "outdoor": cv2.dnn.readNetFromONNX("fire_outdoor.onnx"), } self.cls_net = cv2.dnn.readNetFromONNX("scene_cls.onnx") def pick_model(self, frame): blob = cv2.dnn.blobFromImage(frame, 1/255.0, (224, 224), swapRB=True) self.cls_net.setInput(blob) cls_out = self.cls_net.forward() scene = "indoor" if np.argmax(cls_out) == 0 else "outdoor" return self.det_models[scene]这个方案的代价是要维护多个模型文件,训练和标注工作量翻倍,所以只适合场景差异确实很大、单模型精度死活上不去的项目。如果场景比较统一,老老实实把单模型的数据集做扎实,比堆模型更划算。
验证这套系统是否真的可用,我的习惯是拿一段没进过训练集的现场录像跑一遍,人工数漏检和误报,算一个「每千帧误报数」和「漏检率」。这两个数比 mAP 更能说明问题,因为 mAP 是平均出来的,现场关心的是最差情况。火焰检测这行,模型精度只是及格线,真正决定能不能交付的是误报控制和界面稳定性。我踩过最深的坑就是模型指标很漂亮,结果现场因为一个夕阳误报被值班员投诉到项目差点黄掉。希望帮到你。
本文还有配套的精品资源,点击获取