☰
基于YOLOv8的火焰烟雾检测系统实战:Python源码+ONNX模型+评估曲线+精美GUI,TaoToken统一Key打通推理链路
2026/10/3 16:12:12 网站建设 项目流程

1. 火焰烟雾检测从训练到部署的真实链路

火焰烟雾检测这件事,很多人第一反应是"找个开源权重跑一下不就行了"。但真到落地环节,你会发现麻烦的不是模型本身,而是从数据集标注、训练调参、导出 ONNX、写推理脚本,再到套一个能给人用的 GUI,中间任何一环断了,整个系统就跑不起来。我这次要做的,就是把这条链路完整走一遍:用 YOLOv8 训练 fire/smoke 两类目标,导出 ONNX 模型,画出 mAP、PR 这些评估曲线,最后用 PyQt5 搭一个支持图片、视频、摄像头三种输入的界面。同时,推理链路里我会用 TaoToken 的统一 Key 去调用 API 做端到端验证,这样你本地模型和云端服务能对同一张图给出结果,方便排查是模型问题还是代码问题。

先说清楚这套东西适合谁。如果你是会一点 Python、装过 Anaconda、想做一个能演示能交作业的检测系统,那这篇基本可以照着敲。如果你是完全没碰过深度学习的小白,建议先把 ultralytics 官方文档的 quickstart 跑通再回来。环境我实测用的是 Windows10 + Anaconda3 + Python3.8 + torch1.9.0+cu111 + ultralytics8.2.70,这套组合在 30 系显卡上比较稳,40 系显卡建议把 torch 升到 2.x,否则可能报 CUDA 架构不匹配。

整个系统的输入输出很直观:输入是一张图、一段视频或者摄像头实时流,输出是画了框的图加上左侧文本框里的检测结果,类别只有 fire 和 smoke 两个。别小看只有两类,火焰和烟雾在复杂背景下的误检才是真正难搞的地方,后面调参章节我会重点讲。

2. TaoToken 统一 Key 在推理链路里的定位

为什么要在本地 ONNX 推理之外再挂一个 API?因为实际项目里经常遇到这种情况:你本地模型某个场景检测不出来,想换个模型或者换个版本对比一下,但重新训练成本太高。这时候用 TaoToken 的统一 Key 调一个多模态模型做交叉验证,能快速判断是数据问题还是模型容量问题。TaoToken 官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数,别拼错了。

它的核心价值是"一个 Key 打通多个模型"。你不需要为每个模型单独申请账号、单独记一套鉴权方式,拿到一个 Key 之后,改 model 字段就能切换。对于火焰烟雾这种需要反复对比验证的场景,省下来的时间很可观。我试过在同一个脚本里先用本地 ONNX 跑一遍,再把同一张图 base64 编码后发给 API,两边结果并排打印,差异一目了然。

需要强调一点:TaoToken 在这里的角色是"推理验证通道",不是替代你的本地模型。生产环境该用本地 ONNX 还是用本地,API 只在你需要对比、需要快速验证新场景时介入。另外,涉及火焰烟雾这种安全场景,API 返回的结果只能作为参考,不能作为报警的唯一依据,最终决策逻辑还是要落在你自己的业务代码里。

拿 Key 的流程很简单:进控制台,创建一个 API Key,复制出来存到环境变量里,别硬编码在源码里。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,API Keys 管理页是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。如果你后面要做长期的编码类 Agent 任务,可以看下 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。模型对话调试入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。

3. 可复制的数据配置与 ONNX 导出脚本

先把数据集结构定下来。YOLOv8 要求的是 YOLO 格式,目录长这样:

fire_smoke_dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml

data.yaml 内容如下,注意 path 用绝对路径或者相对训练脚本的路径,别写错:

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

标注文件是每行class_id x_center y_center width height,全部归一化到 0-1。如果你手上是 VOC 的 xml,用 ultralytics 自带的转换脚本或者自己写个几十行的转换函数都行,关键是别把坐标搞成像素值。

训练命令我一般这么写,参数按需改:

yolo detect train \ data=./fire_smoke_dataset/data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=20 \ project=runs/fire_smoke \ name=exp1

训练完在runs/fire_smoke/exp1/weights/下会有 best.pt 和 last.pt,同目录的 results.png 就是 mAP、PR 曲线这些评估图。如果你想要更细的曲线,可以用yolo detect val再跑一次验证,它会输出 confusion_matrix.png 和 PR_curve.png。

导出 ONNX 是重点,很多人卡在这一步:

from ultralytics import YOLO model = YOLO("runs/fire_smoke/exp1/weights/best.pt") model.export( format="onnx", imgsz=640, opset=12, simplify=True, dynamic=False, half=False )

导出后会在同目录生成 best.onnx。opset 建议用 12,兼容性好;simplify=True 会调用 onnxsim 做图优化,能去掉一些冗余节点;dynamic=False 表示固定输入尺寸,GUI 里处理起来简单。如果你要动态 batch,把 dynamic 设 True,但推理脚本要跟着改。

导出完一定要验证一下 ONNX 能不能正常推理,别等到 GUI 里才发现问题:

import onnxruntime as ort import numpy as np import cv2 sess = ort.InferenceSession("best.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) input_name = sess.get_inputs()[0].name img = cv2.imread("test_img/fire_01.jpg") img = cv2.resize(img, (640, 640)) img = img[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 img = np.expand_dims(img, 0) outputs = sess.run(None, {input_name: img}) print(outputs[0].shape)

正常输出应该是(1, 6, 8400),6 是 4 个框坐标加 2 个类别分数。如果 shape 不对,八成是导出时 nc 没对上。

4. 验证请求与 GUI 事件绑定代码

先写一个独立的 API 验证脚本,确认 TaoToken 这条链路通了,再往 GUI 里集成。用 requests 就行:

import os import base64 import requests API_KEY = os.environ.get("TAOTOKEN_API_KEY") BASE_URL = "https://taotoken.net/api" def encode_image(path): with open(path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") def ask_model(image_path, question): payload = { "model": "gpt-4o", "messages": [ { "role": "user", "content": [ {"type": "text", "text": question}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{encode_image(image_path)}"}} ] } ] } headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" } resp = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, headers=headers, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] if __name__ == "__main__": print(ask_model("test_img/fire_01.jpg", "这张图里有没有火焰或烟雾?只回答有或没有,并说明位置。"))

跑通之后你会看到模型返回类似"有,画面左下角有明火"这样的描述。这一步的意义是:当你本地 ONNX 漏检时,用 API 的结果做参照,判断是模型没学好还是图本身就没有目标。

接下来是 GUI 部分。PyQt5 的界面我用 Qt Designer 拖出来,核心是信号槽绑定。主窗口类继承 QMainWindow,工具栏上放四个 action:打开图片、打开视频、打开摄像头、退出。关键代码结构如下:

from PyQt5 import QtCore, QtGui, QtWidgets import cv2 import numpy as np import onnxruntime as ort class FireSmokeDetector(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.sess = ort.InferenceSession("best.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) self.input_name = self.sess.get_inputs()[0].name self.conf_thres = 0.25 self.iou_thres = 0.45 self.cap = None self.timer = QtCore.QTimer() self.timer.timeout.connect(self.update_frame) self.setup_ui() def setup_ui(self): self.resize(1280, 728) central = QtWidgets.QWidget(self) self.setCentralWidget(central) self.picture = QtWidgets.QLabel(central) self.picture.setGeometry(QtCore.QRect(260, 10, 1010, 630)) self.picture.setStyleSheet("background:black") self.picture.setScaledContents(True) self.le_res = QtWidgets.QTextEdit(central) self.le_res.setGeometry(QtCore.QRect(10, 240, 241, 400)) self.hs_conf = QtWidgets.QSlider(QtCore.Qt.Horizontal, central) self.hs_conf.setGeometry(QtCore.QRect(10, 100, 181, 22)) self.hs_conf.setValue(25) self.hs_conf.valueChanged.connect(self.on_conf_change) self.dsb_conf = QtWidgets.QDoubleSpinBox(central) self.dsb_conf.setGeometry(QtCore.QRect(200, 100, 51, 22)) self.dsb_conf.setSingleStep(0.01) self.dsb_conf.setValue(0.25) self.dsb_conf.valueChanged.connect(self.on_conf_spin) self.toolbar = QtWidgets.QToolBar(self) self.addToolBar(QtCore.Qt.TopToolBarArea, self.toolbar) self.act_img = QtWidgets.QAction("打开图片", self) self.act_img.triggered.connect(self.open_image) self.act_vid = QtWidgets.QAction("打开视频", self) self.act_vid.triggered.connect(self.open_video) self.act_cam = QtWidgets.QAction("打开摄像头", self) self.act_cam.triggered.connect(self.open_camera) self.toolbar.addAction(self.act_img) self.toolbar.addAction(self.act_vid) self.toolbar.addAction(self.act_cam) def on_conf_change(self, val): self.conf_thres = val / 100.0 self.dsb_conf.setValue(self.conf_thres) def on_conf_spin(self, val): self.conf_thres = val self.hs_conf.setValue(int(val * 100)) def preprocess(self, frame): img = cv2.resize(frame, (640, 640)) img = img[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 return np.expand_dims(img, 0) def postprocess(self, outputs, orig_shape): preds = outputs[0][0] boxes, scores, class_ids = [], [], [] for pred in preds.T: cls_scores = pred[4:] cls_id = int(np.argmax(cls_scores)) score = float(cls_scores[cls_id]) if score < self.conf_thres: continue cx, cy, w, h = pred[:4] x1 = (cx - w / 2) * orig_shape[1] / 640 y1 = (cy - h / 2) * orig_shape[0] / 640 x2 = (cx + w / 2) * orig_shape[1] / 640 y2 = (cy + h / 2) * orig_shape[0] / 640 boxes.append([x1, y1, x2, y2]) scores.append(score) class_ids.append(cls_id) if boxes: idx = cv2.dnn.NMSBoxes(boxes, scores, self.conf_thres, self.iou_thres) return [boxes[i] for i in idx], [scores[i] for i in idx], [class_ids[i] for i in idx] return [], [], [] def draw(self, frame, boxes, scores, class_ids): names = {0: "fire", 1: "smoke"} colors = {0: (0, 0, 255), 1: (128, 128, 128)} for box, score, cid in zip(boxes, scores, class_ids): x1, y1, x2, y2 = map(int, box) cv2.rectangle(frame, (x1, y1), (x2, y2), colors[cid], 2) label = f"{names[cid]} {score:.2f}" cv2.putText(frame, label, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors[cid], 2) return frame def open_image(self): path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "选择图片", "", "Images (*.jpg *.png *.jpeg)") if not path: return frame = cv2.imread(path) inp = self.preprocess(frame) outs = self.sess.run(None, {self.input_name: inp}) boxes, scores, class_ids = self.postprocess(outs, frame.shape) frame = self.draw(frame, boxes, scores, class_ids) self.show_frame(frame) self.le_res.setText("\n".join([f"{['fire','smoke'][c]} {s:.2f}" for c, s in zip(class_ids, scores)])) def open_video(self): path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "选择视频", "", "Videos (*.mp4 *.avi)") if not path: return self.cap = cv2.VideoCapture(path) self.timer.start(30) def open_camera(self): self.cap = cv2.VideoCapture(0) self.timer.start(30) def update_frame(self): ret, frame = self.cap.read() if not ret: self.timer.stop() return inp = self.preprocess(frame) outs = self.sess.run(None, {self.input_name: inp}) boxes, scores, class_ids = self.postprocess(outs, frame.shape) frame = self.draw(frame, boxes, scores, class_ids) self.show_frame(frame) def show_frame(self, frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QtGui.QImage(rgb.data, w, h, ch * w, QtGui.QImage.Format_RGB888) self.picture.setPixmap(QtGui.QPixmap.fromImage(qimg))

这段代码里最容易出错的是 postprocess 里的坐标还原。ONNX 输出的是 640x640 尺度下的归一化坐标,要乘回原图宽高。如果你发现框的位置整体偏移,先检查这里。另外 NMSBoxes 的输入要求 boxes 是 list of list,别传 numpy array,否则会报类型错误。

5. 本篇常见错误排查

第一个高频报错:onnxruntime.capi.onnxruntime_pybind11_state.InvalidArgument: [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Got invalid dimensions for input: images。这个基本是输入 shape 不对。YOLOv8 导出后输入名一般是images,shape 是[1,3,640,640]。检查你的 preprocess 有没有漏掉 batch 维度,或者 resize 尺寸写成了 416。打印sess.get_inputs()[0].shape确认一下。

第二个:401 Unauthorized。调 TaoToken API 时出现这个,先确认环境变量TAOTOKEN_API_KEY有没有读到,echo $TAOTOKEN_API_KEY(Windows 用echo %TAOTOKEN_API_KEY%)看一下。如果 Key 是对的还报 401,检查 header 里是不是写成了Bearer加空格加 Key,少空格也会 401。另外注意 Base URL 别写成带 UTM 的地址,API 调用统一用 https://taotoken.net/api 。

第三个:local proxy failed或者连接超时。这个通常是你本机网络环境的问题,检查一下有没有设置系统级代理,requests 默认会读环境变量里的HTTP_PROXY。如果不需要代理,在代码里显式传proxies={"http": None, "https": None}绕开。

第四个:Error in reading choices from response。这个说明请求发出去了但返回结构不对,大概率是 model 字段写错了。不同模型对图片输入的支持不一样,先用模型对话页面确认你选的模型支持视觉输入,再写进代码。返回体里choices是列表,取[0]["message"]["content"],如果返回的是错误信息,先打印完整 resp.text 看。

第五个:GUI 里视频播放卡顿。ONNX Runtime 默认可能跑在 CPU 上,检查ort.get_available_providers()有没有 CUDAExecutionProvider。如果没有,说明 onnxruntime-gpu 没装对,卸载重装pip install onnxruntime-gpu,注意版本要和 CUDA 版本匹配。另外视频帧率别设太高,30ms 一帧对 640 输入来说已经够用。

第六个:训练时 loss 不下降。火焰烟雾数据集如果样本不均衡(火焰多烟雾少),mAP 会很难看。可以在 data.yaml 同级加一个hyp.yaml,调一下cls和box的权重,或者用 copy-paste 增强烟雾样本。这个坑我踩过,后来把烟雾样本补到和火焰差不多数量,mAP50 从 0.62 涨到 0.81。

6. 端到端验证与后续扩展

把上面几块拼起来之后,完整的验证流程是这样的:先跑训练脚本得到 best.pt,导出 best.onnx,用独立推理脚本确认 ONNX 输出正常,再启动 GUI 加载 ONNX,打开 test_img 里的测试图看框和置信度。然后拿同一张图调 TaoToken API,对比两边对"有没有火"的判断。如果本地漏检而 API 说有,大概率是训练数据里这类场景太少;如果两边都说没有但你肉眼能看到,那可能是标注漏了。

后续想扩展的话,几个方向比较实用。一是把检测结果写进日志文件,带时间戳和置信度,方便回溯;二是加一个报警阈值,连续 N 帧检测到 fire 才触发声音警报,避免单帧误检;三是把 ONNX 换成 TensorRT 引擎,在 Jetson 这类边缘设备上帧率能翻倍。如果你要做多模型对比,TaoToken 的模型对话入口可以直接上传图片试不同模型,不用改代码:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。接入细节看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。

最后提醒一句,火焰烟雾检测系统上线前一定要做误报测试,拿一堆没有火的场景图(夕阳、红色灯光、蒸汽)跑一遍,把误报率压下来再谈准确率。这个环节比调模型参数重要得多。

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

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

立即咨询