ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于YOLOv8的火焰烟雾检测系统实战:Python源码+ONNX模型+评估曲线+精美GUI,TaoToken统一Key打通推理链路

基于YOLOv8的火焰烟雾检测系统实战:Python源码+ONNX模型+评估曲线+精美GUI,TaoToken统一Key打通推理链路 1. 火焰烟雾检测从训练到部署的真实链路火焰烟雾检测这件事很多人第一反应是找个开源权重跑一下不就行了。但真到落地环节你会发现麻烦的不是模型本身而是从数据集标注、训练调参、导出 ONNX、写推理脚本再到套一个能给人用的 GUI中间任何一环断了整个系统就跑不起来。我这次要做的就是把这条链路完整走一遍用 YOLOv8 训练 fire/smoke 两类目标导出 ONNX 模型画出 mAP、PR 这些评估曲线最后用 PyQt5 搭一个支持图片、视频、摄像头三种输入的界面。同时推理链路里我会用 TaoToken 的统一 Key 去调用 API 做端到端验证这样你本地模型和云端服务能对同一张图给出结果方便排查是模型问题还是代码问题。先说清楚这套东西适合谁。如果你是会一点 Python、装过 Anaconda、想做一个能演示能交作业的检测系统那这篇基本可以照着敲。如果你是完全没碰过深度学习的小白建议先把 ultralytics 官方文档的 quickstart 跑通再回来。环境我实测用的是 Windows10 Anaconda3 Python3.8 torch1.9.0cu111 ultralytics8.2.70这套组合在 30 系显卡上比较稳40 系显卡建议把 torch 升到 2.x否则可能报 CUDA 架构不匹配。整个系统的输入输出很直观输入是一张图、一段视频或者摄像头实时流输出是画了框的图加上左侧文本框里的检测结果类别只有 fire 和 smoke 两个。别小看只有两类火焰和烟雾在复杂背景下的误检才是真正难搞的地方后面调参章节我会重点讲。2. TaoToken 统一 Key 在推理链路里的定位为什么要在本地 ONNX 推理之外再挂一个 API因为实际项目里经常遇到这种情况你本地模型某个场景检测不出来想换个模型或者换个版本对比一下但重新训练成本太高。这时候用 TaoToken 的统一 Key 调一个多模态模型做交叉验证能快速判断是数据问题还是模型容量问题。TaoToken 官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite API Keys 管理页是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。如果你后面要做长期的编码类 Agent 任务可以看下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。模型对话调试入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。3. 可复制的数据配置与 ONNX 导出脚本先把数据集结构定下来。YOLOv8 要求的是 YOLO 格式目录长这样fire_smoke_dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yamldata.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 \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ projectruns/fire_smoke \ nameexp1训练完在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( formatonnx, imgsz640, opset12, simplifyTrue, dynamicFalse, halfFalse )导出后会在同目录生成 best.onnx。opset 建议用 12兼容性好simplifyTrue 会调用 onnxsim 做图优化能去掉一些冗余节点dynamicFalse 表示固定输入尺寸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: fdata:image/jpeg;base64,{encode_image(image_path)}}} ] } ] } headers { Authorization: fBearer {API_KEY}, Content-Type: application/json } resp requests.post(f{BASE_URL}/v1/chat/completions, jsonpayload, headersheaders, timeout60) 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 导出后输入名一般是imagesshape 是[1,3,640,640]。检查你的 preprocess 有没有漏掉 batch 维度或者 resize 尺寸写成了 416。打印sess.get_inputs()[0].shape确认一下。第二个401 Unauthorized。调 TaoToken API 时出现这个先确认环境变量TAOTOKEN_API_KEY有没有读到echo $TAOTOKEN_API_KEYWindows 用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_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。接入细节看文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。Key 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。最后提醒一句火焰烟雾检测系统上线前一定要做误报测试拿一堆没有火的场景图夕阳、红色灯光、蒸汽跑一遍把误报率压下来再谈准确率。这个环节比调模型参数重要得多。
返回列表