简介:本资源面向目标检测方向的工程技术人员、大学生课题与论文研究者,以及希望快速查看火焰识别效果的技术入门者,提供一套可直接运行的火焰检测完整方案。包内包含YOLO格式标注数据集、已训练完成的模型权重文件,以及基于QT搭建的可视化交互界面,代码可直接运行,无需从零训练即可验证检测效果。资源共234个文件,以jpg、jpeg、png等图像样本与yaml配置文件为主,配合40个Python源码、9个pt模型权重、21个pyc缓存及csv训练日志、sh脚本、Dockerfile等工程文件,压缩包约997.51MB,覆盖数据、训练、推理与界面展示全流程。目前已有935人学习下载。读者可据此快速复现火焰检测流程,理解数据集组织与模型调用方式,并借助QT界面直观查看识别结果,适合作为工程原型参考或课题论文的实践基础。
1. 火焰检测算法 + YOLO 格式数据集 + 模型文件 + QT界面:一条能跑通的落地链路
厂区消防改造项目里,最容易被低估的不是模型精度,而是从「一堆标注图片」到「车间大屏上能实时框出火苗」之间的工程距离。火焰检测算法本身在开源社区已经相当成熟,YOLO 系列从 v5 到 v8 都能做,真正卡住人的是:数据集怎么按 YOLO 格式组织、模型文件怎么导出成推理引擎能吃的形态、QT 界面怎么把摄像头帧喂给模型再把框画回去。这三件事任何一环脱节,demo 就永远停在笔记本里。
这套方案适合两类人:一类是手里已经有火焰图片、想快速验证检测效果的算法同学;另一类是负责上位机开发、需要把现成模型集成进 QT 视频监控界面的嵌入式或工控方向工程师。下面按「数据集准备 → 模型训练与导出 → QT 界面集成 → 踩坑排查 → 进阶技巧」的顺序,把每个环节的可复现命令和参数讲清楚,能直接抄作业。
2. YOLO 格式火焰数据集的准备与校验
2.1 火焰数据集为什么必须统一成 YOLO 格式
火焰检测的数据来源通常很杂:有的是自己用手机拍的视频抽帧,有的是公开的火灾图像数据集,还有的是从监控录像里截的图。这些数据如果直接丢给训练脚本,第一关就过不去——YOLO 训练器只认一种标注格式:每张图片对应一个同名.txt文件,每行是类别编号 中心x 中心y 宽 高,且四个坐标值都是相对于图片宽高的归一化值(0 到 1 之间)。
常见做法是先用 LabelImg 打标,导出时选 YOLO 格式。LabelImg 打标完 YOLO 格式的标之后,会在图片同级目录生成classes.txt和每张图的.txt。但这里有个血泪经验:LabelImg 默认的类别顺序是按你第一次输入标签的顺序定的,如果中途改了标签名或者增删了类别,classes.txt和实际.txt里的编号会对不上,训练时模型学到的类别就是错的。所以打完标第一件事是校验类别映射。
2.2 用脚本校验标注文件与图片的一一对应
下面这段脚本做三件事:检查每张图片是否有对应的.txt、检查.txt里类别编号是否超出classes.txt的范围、统计每个类别的框数量。
import os from pathlib import Path from collections import Counter img_dir = Path("datasets/fire/images/train") lbl_dir = Path("datasets/fire/labels/train") classes_file = Path("datasets/fire/classes.txt") # 读取类别列表 classes = classes_file.read_text(encoding="utf-8").strip().splitlines() num_classes = len(classes) print(f"类别数: {num_classes}, 类别: {classes}") img_exts = {".jpg", ".jpeg", ".png", ".bmp"} imgs = [p for p in img_dir.iterdir() if p.suffix.lower() in img_exts] missing_label = [] bad_class = [] counter = Counter() for img in imgs: lbl = lbl_dir / (img.stem + ".txt") if not lbl.exists(): missing_label.append(img.name) continue for line in lbl.read_text().strip().splitlines(): parts = line.split() if len(parts) != 5: bad_class.append((img.name, "字段数不对")) continue cid = int(parts[0]) if cid < 0 or cid >= num_classes: bad_class.append((img.name, f"类别编号 {cid} 越界")) counter[cid] += 1 print(f"图片总数: {len(imgs)}") print(f"缺失标注的图片: {len(missing_label)} 张") print(f"异常标注: {len(bad_class)} 条") print(f"各类别框数: {dict(counter)}")逻辑说明:先建立图片 stem 到标注文件的映射,逐行解析标注。参数上,img_dir和lbl_dir要按你实际的数据集目录改,YOLO 官方推荐的结构是images/train和labels/train平行放置。如果missing_label不为零,说明有图片没打标,训练时这些图会被忽略但不会报错,容易让人误以为数据都用了。bad_class里出现越界编号,基本就是 LabelImg 中途改过标签,需要手动修正或重新导出。
2.3 数据集划分与 data.yaml 的写法
YOLOv8 训练自己的数据集时,靠一个data.yaml告诉训练器去哪里找图片和类别名。常见做法是训练集、验证集按 8:2 或 9:1 划分,火焰检测这种场景如果正样本本身就不多,验证集可以只留 10%。
path: /home/user/datasets/fire train: images/train val: images/val nc: 2 names: 0: flame 1: smoke参数说明:path是数据集根目录,train和val是相对路径。nc是类别数,必须和names的条目数一致。names的键从 0 开始连续,不能跳号。如果只检测火焰不检测烟雾,把nc改成 1,names只留0: flame,同时要确保所有标注文件里没有编号为 1 的行,否则训练启动时会直接报类别越界。
提示:划分脚本不要用随机数直接切,火焰视频抽帧得到的相邻帧高度相似,随机切会导致验证集里出现和训练集几乎一样的图,验证指标虚高。按视频来源分组切分更可靠。
3. 火焰检测模型的训练、验证与模型文件导出
3.1 环境配置与训练命令的最小闭环
YOLOv8 的环境配置要求不算高,Python 3.8 以上、PyTorch 1.8 以上就能跑。用 Anaconda 建环境是最省事的做法,避免和系统里的其他包打架。
conda create -n fire_yolo python=3.10 -y conda activate fire_yolo pip install ultralytics opencv-python装完之后,训练命令一行就能启动:
yolo detect train \ data=/home/user/datasets/fire/data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ device=0 \ project=runs/fire \ name=exp1参数说明:model=yolov8n.pt用的是 nano 版本预训练权重,火焰检测这种目标特征明显、背景相对固定的场景,nano 或 small 版本通常够用,推理速度还快。imgsz=640是输入分辨率,如果火焰在画面里占比很小,可以提到 960 或 1280,但显存占用会明显上升。batch=16在 8G 显存上跑 640 分辨率基本安全,显存不够就降到 8。device=0指定第一块 GPU,用 CPU 训练的话改成device=cpu,但 100 个 epoch 可能要跑一整天。
训练过程中重点看两个指标:mAP50和mAP50-95。火焰检测一般mAP50能到 0.85 以上就算可用,如果卡在 0.5 左右上不去,优先检查标注框是不是把火焰区域框得太松或者太紧。
3.2 模型文件导出:从 .pt 到推理引擎
训练完得到的是best.pt,这是 PyTorch 权重文件。如果 QT 界面用 Python 写,直接加载.pt就行;但如果 QT 是 C++ 写的,或者要部署到没有 PyTorch 环境的工控机上,就需要导出成 ONNX 或 TensorRT 引擎。
# 导出 ONNX yolo export model=runs/fire/exp1/weights/best.pt format=onnx imgsz=640 opset=12 # 导出 TensorRT(需要 NVIDIA GPU 和 TensorRT 环境) yolo export model=runs/fire/exp1/weights/best.pt format=engine imgsz=640 half=True逻辑说明:ONNX 是通用中间格式,C++ 端可以用 OpenCV 的dnn模块直接加载,不需要额外装 TensorRT。opset=12是算子集版本,太低会缺算子,太高有些推理框架不支持,12 是比较稳的选择。TensorRT 引擎导出时加half=True开启 FP16 量化,推理速度能提升接近一倍,精度损失通常在 1% 以内,火焰检测这种任务完全能接受。
注意:TensorRT 引擎和导出时的 GPU 型号、TensorRT 版本强绑定,换一台机器基本要重新导出。如果 QT 界面要分发给多台设备,优先用 ONNX。
3.3 用 Python 验证导出模型是否可用
导出后别急着往 QT 里塞,先用脚本跑一张测试图,确认输出格式和预期一致。
import cv2 import numpy as np net = cv2.dnn.readNetFromONNX("runs/fire/exp1/weights/best.onnx") img = cv2.imread("test_fire.jpg") h, w = img.shape[:2] # YOLO 输入是 640x640,保持比例缩放后填充 scale = 640 / max(h, w) nh, nw = int(h * scale), int(w * scale) resized = cv2.resize(img, (nw, nh)) canvas = np.full((640, 640, 3), 114, dtype=np.uint8) canvas[:nh, :nw] = resized blob = cv2.dnn.blobFromImage(canvas, 1/255.0, (640, 640), swapRB=True) net.setInput(blob) outputs = net.forward() print("输出形状:", outputs.shape)逻辑说明:YOLOv8 的 ONNX 输出形状通常是(1, 4+nc, 8400),8400 是候选框数量,4+nc里前 4 个是框坐标,后面是各类别置信度。swapRB=True是因为 OpenCV 读进来是 BGR,而模型训练时用的是 RGB。填充值 114 是 YOLO 官方用的灰度填充,和训练时的预处理保持一致,否则检测框会偏移。如果输出形状对不上,大概率是导出时的imgsz和推理时不一致。
4. QT 视频监控界面集成火焰检测的完整步骤
4.1 QT 界面选型:Python 端还是 C++ 端
QT 视频监控界面的实现路线有两条:PyQt5/PySide6 用 Python 写,开发快、和 YOLO 的 Python 生态无缝衔接;Qt C++ 性能好、部署干净,但需要自己处理模型推理的 C++ 接口。如果团队里没有强制 C++ 要求,我一般会选 PyQt5,因为从摄像头取帧到模型推理再到界面绘制,全在 Python 里闭环,调试成本低很多。
界面布局上,核心控件就三个:一个QLabel显示视频帧、一个QPushButton控制启停、一个QLabel或QTextEdit显示检测结果(比如「检测到火焰,置信度 0.92」)。不要一上来就搞复杂布局,先把视频流跑通。
4.2 用 QThread 把推理和界面刷新分开
QT 界面卡死的头号原因就是把模型推理放在主线程里。YOLO 推理一帧在 CPU 上可能要 100 毫秒以上,主线程被占住,界面就没法响应按钮点击和窗口拖动。正确做法是把取帧和推理放到QThread里,通过信号槽把结果传回主线程绘制。
from PyQt5.QtCore import QThread, pyqtSignal import cv2 import numpy as np class DetectThread(QThread): frame_ready = pyqtSignal(np.ndarray, list) # 帧 + 检测框列表 def __init__(self, model_path, conf_thres=0.4): super().__init__() self.net = cv2.dnn.readNetFromONNX(model_path) self.conf_thres = conf_thres self.running = True def run(self): cap = cv2.VideoCapture(0) while self.running: ret, frame = cap.read() if not ret: continue boxes = self.infer(frame) self.frame_ready.emit(frame, boxes) cap.release() def infer(self, frame): h, w = frame.shape[:2] scale = 640 / max(h, w) nh, nw = int(h * scale), int(w * scale) resized = cv2.resize(frame, (nw, nh)) canvas = np.full((640, 640, 3), 114, dtype=np.uint8) canvas[:nh, :nw] = resized blob = cv2.dnn.blobFromImage(canvas, 1/255.0, (640, 640), swapRB=True) self.net.setInput(blob) out = self.net.forward()[0].T # (8400, 4+nc) boxes = [] for row in out: scores = row[4:] cid = int(np.argmax(scores)) conf = float(scores[cid]) if conf < self.conf_thres: continue cx, cy, bw, bh = row[:4] x1 = int((cx - bw/2) / scale) y1 = int((cy - bh/2) / scale) x2 = int((cx + bw/2) / scale) y2 = int((cy + bh/2) / scale) boxes.append((x1, y1, x2, y2, cid, conf)) return boxes逻辑说明:frame_ready信号携带原始帧和检测框列表,主线程收到后只做绘制,不做推理。conf_thres=0.4是置信度门限,火焰检测建议从 0.4 起步,误报多就往上调到 0.5 或 0.6,漏报多就降到 0.3。坐标还原时除以scale是因为推理前做了等比缩放,不还原的话框会偏小且位置不对。
4.3 主线程绘制与置信度门限的动态调整
主线程里把检测框画到帧上再转成QImage显示。这里有个细节:OpenCV 的帧是 BGR,QImage需要 RGB,转换时要用cv2.cvtColor或者直接指定QImage.Format_BGR888。
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton, QVBoxLayout, QWidget from PyQt5.QtGui import QImage, QPixmap import cv2 import sys class MainWindow(QMainWindow): def __init__(self): super().__init__() self.label = QLabel("等待视频流...") self.btn = QPushButton("开始检测") self.btn.clicked.connect(self.toggle) layout = QVBoxLayout() layout.addWidget(self.label) layout.addWidget(self.btn) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) self.thread = None def toggle(self): if self.thread is None: self.thread = DetectThread("best.onnx", conf_thres=0.4) self.thread.frame_ready.connect(self.on_frame) self.thread.start() self.btn.setText("停止检测") else: self.thread.running = False self.thread.wait() self.thread = None self.btn.setText("开始检测") def on_frame(self, frame, boxes): for x1, y1, x2, y2, cid, conf in boxes: color = (0, 0, 255) if cid == 0 else (255, 0, 0) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) cv2.putText(frame, f"{conf:.2f}", (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch*w, QImage.Format_RGB888) self.label.setPixmap(QPixmap.fromImage(qimg).scaled( self.label.width(), self.label.height()))逻辑说明:toggle里用self.thread is None判断当前是否在检测,避免重复启动线程。on_frame里火焰用红色框、烟雾用蓝色框,置信度标在框上方。QImage构造时传入rgb.data要确保rgb是连续内存,cv2.cvtColor返回的数组默认连续,没问题。缩放显示用scaled保持比例,否则画面会拉伸变形。
提示:如果界面刷新时画面撕裂或延迟累积,可以在
DetectThread里加一个帧队列,只保留最新帧,丢弃来不及处理的旧帧。火焰检测对实时性要求高,宁可丢帧也不要延迟。
5. 火焰检测落地中的避坑与排查记录
5.1 训练 loss 正常但验证 mAP 极低
现象:训练日志里box_loss和cls_loss都在下降,但mAP50一直在 0.1 以下。
原因:最常见的是标注文件里的坐标没有归一化。LabelImg 导出 YOLO 格式时会自动归一化,但如果手动改过.txt或者用其他工具导出时选了绝对坐标格式,坐标值就会是几百甚至上千,训练器读到后框全在图片外面。
解决:打开任意一个.txt,看四个数值是否都在 0 到 1 之间。如果大于 1,用脚本批量除以图片宽高重新归一化。另外检查data.yaml里的nc和实际类别数是否一致,不一致时训练器会把所有类别都当成背景。
5.2 QT 界面启动后摄像头画面卡在第一帧
现象:点击「开始检测」后,QLabel只显示第一帧,之后不再更新,但程序没崩溃。
原因:DetectThread.run里的while循环没有让出 GIL 或者信号发射太频繁导致主线程事件队列堵塞。PyQt 的信号槽跨线程默认是队列连接,如果发射频率高于主线程处理速度,队列会越积越长,界面看起来就像卡住了。
解决:在run循环里加self.msleep(1)让出时间片,或者在frame_ready信号连接时用Qt.DirectConnection(不推荐,会回到主线程执行)。更稳的做法是限制推理帧率,比如每处理一帧后time.sleep(0.03),把帧率控制在 30 帧以内。
5.3 ONNX 模型在 C++ 端加载报维度错误
现象:Python 端 ONNX 推理正常,但 C++ 用 OpenCVdnn加载时提示输入维度不匹配或输出为空。
原因:导出 ONNX 时imgsz和 C++ 端blobFromImage的尺寸不一致,或者opset版本和 OpenCV 版本不兼容。OpenCV 4.5 以下对 opset 12 以上支持不好。
解决:统一imgsz为 640,导出时显式指定opset=12。C++ 端blobFromImage的size参数写(640, 640),swapRB设true,crop设false。如果还是不行,把 OpenCV 升级到 4.8 以上。
5.4 火焰检测误报:把灯光和夕阳当成火焰
现象:模型在白天把暖色灯光、夕阳反射、橙色安全帽都框成火焰,置信度还不低。
原因:训练集里负样本太少,模型没学过「像火焰但不是火焰」的样本。火焰的颜色特征和某些暖色光源高度重叠,只靠 RGB 很难区分。
解决:收集误报图片,作为负样本加入训练集(标注文件为空.txt),重新训练。同时在 QT 界面里加一个置信度门限滑动条,让现场人员根据实际误报情况动态调整。如果条件允许,在推理前加一个 HSV 颜色预筛选,把明显不是火焰颜色的区域先排除。
5.5 模型文件换机器后推理结果全乱
现象:在开发机上检测正常的best.pt,拷到工控机上后框的位置全部偏移或者类别全错。
原因:工控机上的 ultralytics 版本和开发机不一致,不同版本对 YOLOv8 输出层的解析方式有差异。或者工控机上没有 GPU,PyTorch 回退到 CPU 推理时某些算子行为不同。
解决:导出 ONNX 而不是直接拷.pt,ONNX 的算子行为是标准化的,跨平台一致性更好。如果必须用.pt,在两台机器上pip freeze对比 ultralytics 和 torch 版本,保持一致。工控机没有 GPU 时,导出 ONNX 后用 OpenCVdnn的 CPU 后端推理,速度虽然慢一些但结果稳定。
6. 火焰检测置信度门限的动态调整与误报抑制技巧
置信度门限是火焰检测落地后调得最频繁的参数。固定门限在实验室里看着挺好,一到现场就翻车:白天阳光强的时候误报多,晚上光线暗的时候漏报多。我后来养成的习惯是在 QT 界面里加一个滑动条,把门限暴露给现场人员,同时记录每次调整后的误报和漏报情况,攒一周数据再回头定一个分时段门限。
具体做法是在DetectThread里把conf_thres做成可写属性,主线程滑动条变化时通过信号槽更新。下面是一个简化的实现:
from PyQt5.QtWidgets import QSlider from PyQt5.QtCore import Qt # 在主窗口里加滑动条 self.slider = QSlider(Qt.Horizontal) self.slider.setRange(20, 80) # 对应 0.2 到 0.8 self.slider.setValue(40) self.slider.valueChanged.connect(self.on_thres_change) def on_thres_change(self, val): if self.thread: self.thread.conf_thres = val / 100.0逻辑说明:滑动条范围 20 到 80 映射到 0.2 到 0.8,步进 1 对应 0.01。valueChanged信号触发时直接改线程对象的属性,Python 里属性赋值是原子操作,不需要加锁。现场调试时让操作员一边看画面一边拖滑动条,找到当前光照条件下误报和漏报的平衡点。
除了门限,还有一个抑制误报的技巧是连续帧确认。单帧检测到火焰不报警,连续 3 帧同一位置都检测到才触发报警。这个逻辑放在主线程里维护一个最近帧的检测框列表,用 IOU 匹配。火焰在视频里是持续存在的,而灯光反射、飞鸟这类干扰通常只出现一两帧,连续帧确认能过滤掉大部分瞬时误报。
def confirm_fire(self, boxes, iou_thres=0.5, need_frames=3): fire_boxes = [b for b in boxes if b[4] == 0] matched = 0 for fb in fire_boxes: for pb in self.prev_fire_boxes: if self.iou(fb, pb) > iou_thres: matched += 1 break self.prev_fire_boxes = fire_boxes return matched >= need_frames def iou(self, a, b): x1 = max(a[0], b[0]); y1 = max(a[1], b[1]) x2 = min(a[2], b[2]); y2 = min(a[3], b[3]) inter = max(0, x2-x1) * max(0, y2-y1) area_a = (a[2]-a[0]) * (a[3]-a[1]) area_b = (b[2]-b[0]) * (b[3]-b[1]) return inter / (area_a + area_b - inter + 1e-6)参数说明:iou_thres=0.5表示前后帧的火焰框重叠超过一半才算同一目标,火焰移动快的时候可以降到 0.3。need_frames=3是连续确认帧数,设太高会导致报警延迟,设太低起不到过滤作用,3 帧在 25 帧的视频流里大约是 120 毫秒延迟,基本无感。
最后说一个我踩过的坑:不要用验证集上的最优门限直接上现场。验证集里的负样本和现场的真实干扰分布差很远,实验室里 0.5 门限 mAP 最高,到现场可能 0.6 才压得住误报。我的习惯是留一批现场采集的误报图片作为「现场测试集」,每次调完门限在这批图上跑一遍,确认误报率可接受再更新到设备上。这套流程跑顺之后,火焰检测从训练到上线基本能控制在一周以内,剩下的就是根据现场反馈微调参数。希望帮到你。
本文还有配套的精品资源,点击获取