首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >基于YOLO26的燃气灶具安全检测系统(Python源码+数据集+Pyside6界面)

基于YOLO26的燃气灶具安全检测系统(Python源码+数据集+Pyside6界面)

原创
作者头像
AI小怪兽
发布于 2026-11-05 12:57:22
发布于 2026-11-05 12:57:22
740
举报
文章被收录于专栏:YOLO大作战YOLO大作战

基于YOLO26的燃气灶具安全检测系统(Python源码+数据集+Pyside6界面)

💡💡💡 本文摘要:基于YOLO26的燃气灶具安全检测系统,阐述了整个数据制作和训练可视化过程,围绕 person / flame / knob / hazard 四类目标,实现灶台火焰、旋钮状态、人员在场与危险隐患的视觉化实时监测。

基于YOLO26检测该燃气安全数据集的意义:

  1. 火焰与隐患实时预警:对 flame、hazard 进行视觉识别,为厨房明火与异常状态提供秒级感知。
  2. 旋钮状态判读:knob 属于典型小目标,可用于判断灶具是否处于开启/未关火状态,弥补纯传感器方案的盲区。
  3. 人员在场感知:结合 person 检测结果,识别"无人看管干烧"等高风险场景。
  4. 适配边缘部署:YOLO26 移除 DFL、默认端到端无 NMS 推理,nano 模型 CPU 推理最高提速 43%,适合智能灶具、家用摄像头等低功耗设备。
  5. 提供可复现基准:数据集已完成去重与固定随机种子划分,便于横向对比不同改进模块的涨点效果。

⚠️ 安全声明:数据集官方明确说明,其性能受光照、相机角度、距离与遮挡影响,模型仅用于实验研究,不能替代经过认证的燃气探测器、火灾报警器或独立安全机制。


博主简介

AI小怪兽 | 计算机视觉布道者 | 视觉检测领域创新者

深耕计算机视觉与深度学习领域,专注于目标检测前沿技术的探索与突破。长期致力于YOLO系列算法的结构性创新、性能极限优化与工业级落地实践,旨在打通从学术研究到产业应用的最后一公里。

🚀 核心专长与技术创新

  • YOLO算法结构性创新:于CSDN平台原创发布《YOLO26魔术师》、《YOLO11魔术师》等全系列深度专栏。系统性提出并开源了多项原创自研模块,在模型轻量化设计、多维度注意力机制融合、特征金字塔重构等关键方向完成了一系列突破性实践,为行业提供了具备高参考价值的技术路径与完整解决方案。
  • 技术生态建设与知识传播:独立运营 “计算机视觉大作战” 公众号(粉丝2.3万),成功构建高质量的技术交流社群。致力于将复杂算法转化为通俗易懂的解读与可复现的工程代码,显著降低了计算机视觉的技术入门门槛。

🏆 行业影响力与商业实践

  • 荣获腾讯云年度影响力作者与创作之星奖项,内容质量与专业性获行业权威平台认证。
  • 全网累计拥有 8万+ 垂直领域技术受众,专栏文章总阅读量突破百万,在目标检测领域形成了广泛的学术与工业影响力。
  • 具备丰富的企业级项目交付经验,曾为工业视觉检测、智慧城市安防等多个关键领域提供定制化的算法模型与解决方案,驱动业务智能化升级。

💡 未来方向与使命

秉持 “让每一行代码都有温度” 的技术理念,未来将持续聚焦于实时检测、语义分割及工业缺陷检测的商业化闭环等核心方向。愿与业界同仁协同创新,共同推动技术边界,以坚实的技术能力赋能实体经济与行业变革。

​

1.YOLO26原理介绍

论文:https://arxiv.org/pdf/2509.25164

摘要:本研究对Ultralytics YOLO26进行了全面分析,重点阐述了其关键架构改进及其在实时边缘目标检测中的性能基准测试。YOLO26于2025年9月发布,是YOLO系列最新、最先进的成员,专为在边缘及低功耗设备上实现高效、精确且易于部署的目标而构建。本文依次详述了YOLO26的架构创新,包括:移除了分布焦点损失(DFL);采用端到端的无NMS推理;集成了渐进损失(ProgLoss)与小目标感知标签分配(STAL);以及引入了用于稳定收敛的MuSGD优化器。除架构外,本研究将YOLO26定位为多任务框架,支持目标检测、实例分割、姿态/关键点估计、定向检测及分类。我们在NVIDIA Jetson Nano与Orin等边缘设备上呈现了YOLO26的性能基准测试,并将其结果与YOLOv8、YOLOv11、YOLOv12、YOLOv13及基于Transformer的检测器进行比较。本文进一步探讨了其实时部署路径、灵活的导出选项(ONNX、TensorRT、CoreML、TFLite)以及INT8/FP16量化技术。文章重点展示了YOLO26在机器人、制造业及物联网等领域的实际应用案例,以证明其跨行业适应性。最后,讨论了关于部署效率及更广泛影响的见解,并展望了YOLO26及YOLO系列的未来发展方向。

关键词:YOLO26;边缘人工智能;多任务目标检测;无NMS推理;小目标识别;YOLO(You Only Look Once);目标检测;MuSGD优化器

结构框图如下:

1.1 YOLO11 vs YOLO26结构差异性

1.1.1 SPPF 核心差异对比

1)池化次数灵活性:YOLO11 的 3 次池化是硬编码的,要修改必须改源码;YOLO26 通过n参数可灵活调整(比如设为 2 次或 4 次),无需改核心逻辑。

2)Shortcut 设计:YOLO26 新增的残差连接能缓解深层网络的梯度消失问题,提升特征复用能力,而 YOLO11 无此设计。

3)激活函数控制:YOLO26 禁用 Conv1 的激活函数,让特征在池化前保持更 “原始” 的状态,是工程上对特征提取的优化。

源码位置:ultralytics/nn/modules/block.py

1.1.2 C3k2 核心差异对比

1)注意力机制的新增:YOLO26 的 C3k2 首次引入PSABlock(金字塔注意力模块),通过attn参数控制是否启用,这是两者最核心的功能差异 —— 启用后模块会先通过 Bottleneck 提取基础特征,再通过 PSABlock 增强关键区域的特征权重,提升小目标 / 复杂场景的检测效果。

2)分支逻辑的扩展:YOLO11 的分支仅受c3k控制,而 YOLO26 的分支逻辑优先级为attn > c3k,即只要attn=True,会优先启用注意力模块,忽略c3k的配置。

代码语言:javascript
复制
重复模块m (n次迭代):
┌─────────────────────────────────────────────────────────┐
│                                                         │
│  如果 attn=True:                                        │
│    Sequential(                                          │
│        Bottleneck(self.c, self.c),                     │  ←─ 先特征提取
│        PSABlock(self.c, attn_ratio=0.5, num_heads=...) │  ←─ 后注意力增强
│    )                                                    │
│                                                         │
│  否则如果 c3k=True:                                     │
│    C3k(self.c, self.c, 2)                             │  ←─ 同YOLOv11
│                                                         │
│  否则:                                                  │
│    Bottleneck(self.c, self.c)                         │  ←─ 同YOLOv11
│                                                         │
└─────────────────────────────────────────────────────────┘

YOLO26 C3k2代码:

源码位置:ultralytics/nn/modules/block.py

1.2 YOLO26核心创新点

YOLO26引入了多项关键架构创新,使其区别于前几代YOLO模型。这些增强不仅提高了训练稳定性和推理效率,还从根本上重塑了实时边缘设备的部署流程。本节将详细描述YOLO26的四项主要贡献:(i)移除分布焦点损失(DFL),(ii)引入端到端无NMS推理,(iii)新颖的损失函数策略,包括渐进损失平衡(ProgLoss)和小目标感知标签分配(STAL),以及(iv)开发用于稳定高效收敛的MuSGD优化器。我们将详细讨论每一项架构增强,并通过对比分析突显其相对于YOLOv8、YOLOv11、YOLOv12和YOLOv13等早期YOLO版本的优势。

1.2.1 创新点1:移除分布焦点损失(DFL)

YOLO26最重要的架构简化之一是移除了分布焦点损失(DFL)模块(图3a),该模块曾存在于YOLOv8和YOLOv11等早期YOLO版本中。DFL最初旨在通过预测边界框坐标的概率分布来改进边界框回归,从而实现更精确的目标定位。虽然该策略在早期模型中展示了精度提升,但也带来了不小的计算开销和导出困难。在实践中,DFL在推理和模型导出期间需要专门处理,这使针对ONNX、CoreML、TensorRT或TFLite等硬件加速器的部署流程变得复杂。

源码位置:ultralytics/utils/loss.py

通过reg_max 设置为1,移除了分布焦点损失(DFL)

代码语言:javascript
复制
class BboxLoss(nn.Module):
    """Criterion class for computing training losses for bounding boxes."""

    def __init__(self, reg_max: int = 16):
        """Initialize the BboxLoss module with regularization maximum and DFL settings."""
        super().__init__()
        self.dfl_loss = DFLoss(reg_max) if reg_max > 1 else None

1.2.2 创新点2:端到端无NMS推理

YOLO26从根本上重新设计了预测头,以直接产生非冗余的边界框预测,无需NMS。这种端到端设计不仅降低了推理复杂度,还消除了对手动调优阈值的依赖,从而简化了集成到生产系统的过程。对比基准测试表明,YOLO26实现了比YOLOv11和YOLOv12更快的推理速度,其中nano模型在CPU上的推理时间减少了高达43%。这使得YOLO26对于移动设备、无人机和嵌入式机器人平台特别有利,在这些平台上,毫秒级的延迟可能产生重大的操作影响。

源码位置:ultralytics/utils/nms.py

1.2.3 创新点3:ProgLoss和STAL:增强训练稳定性和小目标检测

训练稳定性和小目标识别仍然是目标检测中持续存在的挑战。YOLO26通过整合两种新颖策略来解决这些问题:渐进损失平衡(ProgLoss)和小目标感知标签分配(STAL),如图(图3c)所示。

ProgLoss在训练期间动态调整不同损失分量的权重,确保模型不会过拟合于主导物体类别,同时防止在稀有或小类别上表现不佳。这种渐进式再平衡改善了泛化能力,并防止了训练后期的不稳定。另一方面,STAL明确优先为小目标分配标签,由于像素表示有限且易被遮挡,小目标尤其难以检测。ProgLoss和STAL共同为YOLO26在包含小目标或被遮挡目标的数据集(如COCO和无人机图像基准)上带来了显著的精度提升。

1.2.4 创新点4:用于稳定收敛的MuSGD优化器

YOLO26的最后一项创新是引入了MuSGD优化器(图3d),它结合了随机梯度下降(SGD)的优势与最近提出的Muon优化器(一种受大型语言模型训练中使用的优化策略启发而发展的技术)。MuSGD利用SGD的鲁棒性和泛化能力,同时融入了来自Muon的自适应特性,能够在不同数据集上实现更快的收敛和更稳定的优化。

源码位置:ultralytics/optim/muon.py


2. 燃气灶具安全检测系统

2.1 AirGuardian Mini 数据集介绍

该数据集是面向 AirGuardian Mini(视觉灶台与燃气安全监控项目)清洗去重后的 4 类 YOLO 格式数据集,Version 1 体积约 382.43 MB,标注格式为 YOLO object detection txt。

划分情况(源数据合并 → 去除跨 split 完全重复图像 → 固定随机种子按 85% / 10% / 5% 重新划分):

子集

图片数

占比

train

6,406

85%

valid

753

10%

test

378

5%

合计

7,537

100%

类别与标注实例数:

Class ID

类别

实例数

检测含义

0

person

2,484

厨房人员/是否在场

1

flame

2,985

灶具明火

2

knob

2,186

灶台旋钮(小目标)

3

hazard

2,851

危险/隐患状态(官方页面未给出细粒度定义,建议结合标注可视化确认)

—

合计

10,506

—

目录结构:

注意:官方 data.yaml 的验证集路径为 valid/images(目录名是 valid,不是模板常见的 images/val),配置时保持原样,避免路径找不到。

图 5:四类实例数介于 2,186–2,985,knob 最少;train / valid / test = 85% / 10% / 5%。生成脚本见附录 A。

​

2.2 配置Microscopy.yaml

ps:建议填写绝对路径

代码语言:javascript
复制
# Dataset unificado a partir de Final_dataset.
path: D:/YOLO26data/Microscopy

train: images/train
val: images/val
test: images/test

nc: 1

names:
  - Mycobacterium

2.3 如何训练

代码语言:javascript
复制
import warnings
warnings.filterwarnings('ignore')
from ultralytics import YOLO

if __name__ == '__main__':
    model = YOLO('ultralytics/cfg/models/26/yolo26.yaml')
    model.train(data='data/Microscopy.yaml',
                cache=False,
                imgsz=640,
                epochs=200,
                batch=8,
                close_mosaic=10,
                device='0',
                optimizer='SGD', # using SGD
                project='runs/train',
                name='exp',
                )

2.4 训练结果可视化结果

代码语言:javascript
复制
YOLO26 summary (fused): 122 layers, 2,375,616 parameters, 0 gradients, 5.3 GFLOPs
                 Class     Images  Instances      Box(P          R      mAP50  mAP50-95): 100% ━━━━━━━━━━━━ 24/24 3.2it/s 7.5s
                   all        753       1038      0.925      0.889      0.937      0.707
                person        141        255       0.95      0.887      0.957      0.746
                 flame        288        288      0.942      0.906      0.961      0.706
                  knob         92        207      0.834      0.816      0.855      0.593
                hazard        227        288      0.973      0.944      0.974      0.784

预测结果:


3. 燃气灶具安全检测系统设计

伪代码如下:

代码语言:python
复制
# -*- coding: utf-8 -*-
"""
================================================================================
 基于 YOLO26 的燃气灶具安全检测系统  —— 整体伪代码(单文件版)
================================================================================
 说明:
   1. 本文件为"伪代码",用近似 Python 的语法描述整个系统的骨架与调用关系,
      关键函数体用中文注释说明,不能直接运行。
   2. 整体分为 6 层:
        L1 配置层 Config
        L2 数据层 DatasetBuilder      (采集 / 标注 / 增强 / data.yaml)
        L3 训练层 Trainer             (YOLO26 训练 / 验证 / 导出)
        L4 推理层 Detector            (加载模型 / 单帧推理 / 跟踪)
        L5 业务层 SafetyAnalyzer      (燃气灶安全规则状态机)
        L6 界面层 MainWindow          (PySide6 界面 + 视频线程 + 告警)
   3. 目标类别(建议 6 类):
        0 stove       燃气灶台
        1 flame       火焰
        2 pot         锅具
        3 person      人
        4 smoke       烟雾
        5 spill       溢锅/溢液
================================================================================
"""

# =============================================================================
# L1  配置层
# =============================================================================
import os
import time
import json
import yaml
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple

import cv2
import numpy as np


@dataclass
class Config:
    # ---------- 路径 ----------
    ROOT_DIR: str = os.path.dirname(os.path.abspath(__file__))
    DATASET_DIR: str = os.path.join(ROOT_DIR, "datasets", "gas_stove")
    IMAGES_TRAIN: str = os.path.join(DATASET_DIR, "images", "train")
    IMAGES_VAL: str = os.path.join(DATASET_DIR, "images", "val")
    LABELS_TRAIN: str = os.path.join(DATASET_DIR, "labels", "train")
    LABELS_VAL: str = os.path.join(DATASET_DIR, "labels", "val")
    DATA_YAML: str = os.path.join(DATASET_DIR, "data.yaml")
    WEIGHT_DIR: str = os.path.join(ROOT_DIR, "weights")
    MODEL_PATH: str = os.path.join(WEIGHT_DIR, "yolo26_gas_stove.pt")
    LOG_DIR: str = os.path.join(ROOT_DIR, "logs")

    # ---------- 类别 ----------
    CLASS_NAMES: List[str] = field(default_factory=lambda: [
        "stove", "flame", "pot", "person", "smoke", "spill"
    ])
    NUM_CLASSES: int = 6

    # ---------- 训练超参 ----------
    MODEL_BASE: str = "yolo26n.pt"   # YOLO26 主干(n/s/m/l/x 可选)
    EPOCHS: int = 150
    BATCH: int = 16
    IMG_SIZE: int = 640
    LR0: float = 0.01
    DEVICE: str = "0"                # "0" 表示 GPU0,"cpu" 表示 CPU
    WORKERS: int = 8
    PATIENCE: int = 30               # 早停

    # ---------- 推理参数 ----------
    CONF_THRES: float = 0.45
    IOU_THRES: float = 0.50
    MAX_DET: int = 100
    USE_HALF: bool = True            # FP16 半精度加速

    # ---------- 安全业务规则(单位:秒 / 像素比例) ----------
    UNATTENDED_TIMEOUT: float = 60.0   # 有火无人 持续多少秒 → 报警
    NO_POT_TIMEOUT: float = 20.0       # 有火无锅 持续多少秒 → 报警
    FLAME_LOST_TIMEOUT: float = 5.0    # 火意外熄灭(灶台在、无火)持续多久 → 报警
    SMOKE_ALARM_CONF: float = 0.55     # 烟雾报警置信度
    SPILL_ALARM_CONF: float = 0.50     # 溢锅报警置信度
    FRAME_SKIP: int = 1                # 隔帧推理(性能不足时增大)
    ALARM_COOLDOWN: float = 15.0       # 同一告警冷却时间,避免刷屏

    # ---------- 界面 ----------
    WINDOW_TITLE: str = "基于 YOLO26 的燃气灶具安全检测系统"
    WINDOW_SIZE: Tuple[int, int] = (1280, 800)
    VIDEO_SOURCE: str = "0"            # "0"=本地摄像头,或 rtsp://... 或 视频文件路径


CFG = Config()


# =============================================================================
# L2  数据层:数据集构建
# =============================================================================
class DatasetBuilder:
    """
    职责:
      1) 采集原始视频 → 抽帧
      2) 组织 LabelImg / Roboflow 标注结果(YOLO txt 格式)
      3) 数据增强(离线扩充)
      4) 生成 data.yaml
    """
    def __init__(self, cfg: Config = CFG):
        self.cfg = cfg

    # ---------- 2.1 视频抽帧 ----------
    def extract_frames(self, video_path: str, out_dir: str, fps_step: int = 10):
        """按固定帧间隔从视频中抽帧,保存为 jpg,用于人工标注。"""
        cap = cv2.VideoCapture(video_path)
        idx, saved = 0, 0
        WHILE cap.isOpened():
            ok, frame = cap.read()
            IF NOT ok: BREAK
            IF idx % fps_step == 0:
                cv2.imwrite(os.path.join(out_dir, f"frame_{saved:06d}.jpg"), frame)
                saved += 1
            idx += 1
        cap.release()
        RETURN saved

    # ---------- 2.2 划分训练/验证集 ----------
    def split_train_val(self, all_images: List[str], val_ratio: float = 0.2):
        """按比例随机划分 images/labels 到 train/val 目录。"""
        random.shuffle(all_images)
        n_val = int(len(all_images) * val_ratio)
        val_set, train_set = all_images[:n_val], all_images[n_val:]
        FOR img IN train_set: copy_image_and_label(img → IMAGES_TRAIN / LABELS_TRAIN)
        FOR img IN val_set:   copy_image_and_label(img → IMAGES_VAL   / LABELS_VAL)
        RETURN train_set, val_set

    # ---------- 2.3 离线数据增强 ----------
    def augment(self, image_dir: str, label_dir: str, times: int = 3):
        """
        对每张图做随机增强并同步变换标注框:
          - 亮度/对比度扰动(不改变框)
          - 水平翻转(x_center → 1 - x_center)
          - 随机缩放裁剪 + 框裁剪修正
          - 高斯噪声 / 运动模糊
        注意:燃气场景慎用垂直翻转、大角度旋转(火焰方向有物理意义)。
        """
        FOR img, lbl IN zip(image_dir, label_dir):
            FOR k IN range(times):
                aug_img, aug_lbl = random_augment(img, lbl)
                save(aug_img, aug_lbl)
        PASS

    # ---------- 2.4 生成 data.yaml ----------
    def build_data_yaml(self):
        data = {
            "path": self.cfg.DATASET_DIR,
            "train": "images/train",
            "val": "images/val",
            "nc": self.cfg.NUM_CLASSES,
            "names": self.cfg.CLASS_NAMES,
        }
        WITH open(self.cfg.DATA_YAML, "w", encoding="utf-8") as f:
            yaml.safe_dump(data, f, allow_unicode=True)
        RETURN self.cfg.DATA_YAML


# =============================================================================
# L3  训练层:YOLO26 训练 / 验证 / 导出
# =============================================================================
class Trainer:
    def __init__(self, cfg: Config = CFG):
        self.cfg = cfg
        from ultralytics import YOLO      # 伪代码:实际依赖 ultralytics
        self.model = YOLO(cfg.MODEL_BASE)

    def train(self):
        """调用 YOLO26 训练接口。"""
        results = self.model.train(
            data=self.cfg.DATA_YAML,
            epochs=self.cfg.EPOCHS,
            batch=self.cfg.BATCH,
            imgsz=self.cfg.IMG_SIZE,
            lr0=self.cfg.LR0,
            device=self.cfg.DEVICE,
            workers=self.cfg.WORKERS,
            patience=self.cfg.PATIENCE,
            project=os.path.join(self.cfg.ROOT_DIR, "runs"),
            name="yolo26_gas_stove",
            pretrained=True,
            # YOLO26 特性:端到端无 NMS 推理、DFL 移除、更轻量回归头
            end2end=True,
            close_mosaic=10,          # 最后 10 轮关闭 mosaic,稳定收敛
            plots=True,
        )
        RETURN results

    def validate(self, weight_path: str):
        """在验证集上评估 mAP50 / mAP50-95 / 各类别 P-R。"""
        m = YOLO(weight_path)
        metrics = m.val(data=self.cfg.DATA_YAML, imgsz=self.cfg.IMG_SIZE,
                        conf=0.001, iou=0.6, device=self.cfg.DEVICE)
        RETURN metrics

    def export(self, weight_path: str, fmt: str = "onnx"):
        """导出为 ONNX / TensorRT,便于部署到边缘盒子。"""
        m = YOLO(weight_path)
        RETURN m.export(format=fmt, imgsz=self.cfg.IMG_SIZE, half=self.cfg.USE_HALF)


# =============================================================================
# L4  推理层:检测引擎
# =============================================================================
@dataclass
class Detection:
    """单条检测结果。"""
    cls_id: int
    cls_name: str
    conf: float
    xyxy: Tuple[float, float, float, float]

    @property
    def center(self) -> Tuple[float, float]:
        x1, y1, x2, y2 = self.xyxy
        RETURN ((x1 + x2) / 2, (y1 + y2) / 2)

    @property
    def area(self) -> float:
        x1, y1, x2, y2 = self.xyxy
        RETURN max(0.0, x2 - x1) * max(0.0, y2 - y1)


class Detector:
    """
    职责:封装 YOLO26 推理,输出统一结构的 Detection 列表。
    支持:图片 / 视频帧 / 摄像头流;可选 ByteTrack 目标跟踪(保持 person ID 稳定)。
    """
    def __init__(self, cfg: Config = CFG, model_path: Optional[str] = None):
        self.cfg = cfg
        from ultralytics import YOLO
        self.model = YOLO(model_path or cfg.MODEL_PATH)
        self.names = self.model.names

    def infer(self, frame: np.ndarray, use_track: bool = False) -> List[Detection]:
        IF use_track:
            results = self.model.track(frame, persist=True,
                                       conf=self.cfg.CONF_THRES,
                                       iou=self.cfg.IOU_THRES,
                                       imgsz=self.cfg.IMG_SIZE,
                                       half=self.cfg.USE_HALF,
                                       tracker="bytetrack.yaml", verbose=False)
        ELSE:
            results = self.model.predict(frame,
                                         conf=self.cfg.CONF_THRES,
                                         iou=self.cfg.IOU_THRES,
                                         imgsz=self.cfg.IMG_SIZE,
                                         half=self.cfg.USE_HALF,
                                         max_det=self.cfg.MAX_DET,
                                         verbose=False)
        dets = []
        FOR r IN results:
            FOR box IN r.boxes:
                dets.append(Detection(
                    cls_id=int(box.cls),
                    cls_name=self.names[int(box.cls)],
                    conf=float(box.conf),
                    xyxy=tuple(box.xyxy[0].tolist()),
                ))
        RETURN dets

    @staticmethod
    def draw(frame: np.ndarray, dets: List[Detection], alarms: List[str]) -> np.ndarray:
        """在画面上绘制检测框、类别、置信度与当前告警文本。"""
        FOR d IN dets:
            color = COLOR_MAP.get(d.cls_name, (0, 255, 0))
            cv2.rectangle(frame, d.xyxy[:2], d.xyxy[2:], color, 2)
            cv2.putText(frame, f"{d.cls_name} {d.conf:.2f}",
                        (d.xyxy[0], d.xyxy[1] - 6), FONT, 0.6, color, 2)
        FOR i, msg IN enumerate(alarms):
            cv2.putText(frame, msg, (20, 40 + i * 32), FONT, 0.8, (0, 0, 255), 2)
        RETURN frame


# =============================================================================
# L5  业务层:燃气灶安全规则状态机
# =============================================================================
class SafetyAnalyzer:
    """
    核心业务:把"检测框"翻译成"安全结论"。

    空间关系判定(基于检测框几何):
      - is_flame_on   : 存在 flame 且其中心落在某个 stove 框内(或下方距离阈值内)
      - has_pot       : 存在 pot 且其框与火焰框 IoU/包含关系成立
      - has_person    : 存在 person 且距灶台水平距离 < SAFE_DISTANCE_RATIO * 画幅宽
      - has_smoke     : smoke 置信度 > SMOKE_ALARM_CONF
      - has_spill     : spill 置信度 > SPILL_ALARM_CONF

    时间维度判定(状态机):
      - 每个"异常条件"配一个计时器,条件持续成立超过阈值才真正报警;
      - 条件消失则计时器清零;
      - 报警后进入冷却期,避免逐帧重复弹窗。
    """
    def __init__(self, cfg: Config = CFG):
        self.cfg = cfg
        # 计时器:key = 告警类型, value = 条件首次成立的时间戳
        self.timers: Dict[str, float] = {}
        # 冷却:key = 告警类型, value = 上次报警时间
        self.last_alarm: Dict[str, float] = {}
        self.active_alarms: List[str] = []

    # ---------- 5.1 几何工具 ----------
    @staticmethod
    def _contains(outer: Tuple, inner: Tuple) -> bool:
        """inner 框中心是否落在 outer 框内。"""
        cx, cy = ((inner[0] + inner[2]) / 2, (inner[1] + inner[3]) / 2)
        RETURN outer[0] <= cx <= outer[2] and outer[1] <= cy <= outer[3]

    @staticmethod
    def _iou(a: Tuple, b: Tuple) -> float:
        """标准 IoU 计算。"""
        ...

    # ---------- 5.2 场景解析 ----------
    def parse_scene(self, dets: List[Detection], frame_shape) -> Dict:
        """把检测结果解析成场景语义字典。"""
        scene = {
            "stoves":  [d for d in dets if d.cls_name == "stove"],
            "flames":  [d for d in dets if d.cls_name == "flame"],
            "pots":    [d for d in dets if d.cls_name == "pot"],
            "persons": [d for d in dets if d.cls_name == "person"],
            "smokes":  [d for d in dets if d.cls_name == "smoke" and d.conf >= self.cfg.SMOKE_ALARM_CONF],
            "spills":  [d for d in dets if d.cls_name == "spill" and d.conf >= self.cfg.SPILL_ALARM_CONF],
        }
        h, w = frame_shape[:2]
        # 火焰是否在灶台上
        scene["flame_on_stove"] = [
            f for f in scene["flames"]
            IF any(self._contains(s.xyxy, f.xyxy) for s IN scene["stoves"])
        ]
        scene["is_cooking"] = len(scene["flame_on_stove"]) > 0
        # 锅具是否在火焰上
        scene["pot_on_fire"] = [
            p for p IN scene["pots"]
            IF any(self._contains(f.xyxy, p.xyxy) for f IN scene["flame_on_stove"])
        ]
        # 灶台附近是否有人(水平距离阈值)
        safe_dist = 0.5 * w
        scene["person_near"] = [
            p for p IN scene["persons"]
            IF any(abs(p.center[0] - s.center[0]) < safe_dist for s IN scene["stoves"])
        ]
        RETURN scene

    # ---------- 5.3 状态机主逻辑 ----------
    def _check_timer(self, key: str, condition: bool, timeout: float, now: float) -> bool:
        """
        通用计时器:
          condition=True  → 记录/延续计时,超过 timeout 返回 True(触发报警)
          condition=False → 清除计时,返回 False
        """
        IF NOT condition:
            self.timers.pop(key, None)
            RETURN False
        IF key NOT IN self.timers:
            self.timers[key] = now
        RETURN (now - self.timers[key]) >= timeout

    def analyze(self, dets: List[Detection], frame_shape) -> List[str]:
        """
        返回当前应触发的告警列表,元素示例:
            "【高危】灶台开火但长时间无人看管(>60s)"
            "【高危】灶台开火但未放置锅具(>20s,空烧风险)"
            "【警告】检测到烟雾,请立即检查"
            "【警告】检测到溢锅/液体溢出"
        """
        now = time.time()
        scene = self.parse_scene(dets, frame_shape)
        alarms: List[str] = []

        # 规则 1:开火 + 无人看管
        IF scene["is_cooking"]:
            IF self._check_timer("unattended",
                                 condition=(len(scene["person_near"]) == 0),
                                 timeout=self.cfg.UNATTENDED_TIMEOUT,
                                 now=now):
                alarms.append(f"【高危】灶台开火但无人看管(>{int(self.cfg.UNATTENDED_TIMEOUT)}s)")

            # 规则 2:开火 + 无锅(空烧)
            IF self._check_timer("no_pot",
                                 condition=(len(scene["pot_on_fire"]) == 0),
                                 timeout=self.cfg.NO_POT_TIMEOUT,
                                 now=now):
                alarms.append(f"【高危】灶台开火但未放置锅具(>{int(self.cfg.NO_POT_TIMEOUT)}s,空烧风险)")
        ELSE:
            self.timers.pop("unattended", None)
            self.timers.pop("no_pot", None)

        # 规则 3:有灶台但火焰意外熄灭(可能是溢锅灭火 / 燃气泄漏前兆)
        IF len(scene["stoves"]) > 0 AND len(scene["flames"]) == 0:
            IF self._check_timer("flame_lost", True, self.cfg.FLAME_LOST_TIMEOUT, now):
                alarms.append("【提示】检测到灶台但未检测到火焰,请确认是否意外熄火")
        ELSE:
            self.timers.pop("flame_lost", None)

        # 规则 4:烟雾
        IF len(scene["smokes"]) > 0:
            alarms.append("【警告】检测到烟雾,请立即检查")

        # 规则 5:溢锅
        IF len(scene["spills"]) > 0:
            alarms.append("【警告】检测到溢锅/液体溢出")

        # 冷却过滤
        filtered = []
        FOR a IN alarms:
            key = a[:6]   # 用前缀做冷却 key
            IF now - self.last_alarm.get(key, 0) >= self.cfg.ALARM_COOLDOWN:
                self.last_alarm[key] = now
                filtered.append(a)
        self.active_alarms = filtered
        RETURN filtered


# =============================================================================
# L6  界面层:PySide6 主窗口 + 视频线程 + 告警
# =============================================================================
from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtGui import QImage, QPixmap, QFont
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QLabel, QPushButton, QVBoxLayout,
    QHBoxLayout, QListWidget, QFileDialog, QMessageBox, QGroupBox, QComboBox
)


class VideoThread(QThread):
    """
    视频采集 + 推理线程(避免阻塞 UI)。
    信号:
      frame_ready(QImage)          —— 已绘制检测框的画面
      detections_ready(list)       —— 检测结果(供统计面板)
      alarms_ready(list[str])      —— 本次触发的告警
      fps_ready(float)             —— 实时帧率
    """
    frame_ready = Signal(QImage)
    detections_ready = Signal(list)
    alarms_ready = Signal(list)
    fps_ready = Signal(float)

    def __init__(self, source: str, detector: Detector, analyzer: SafetyAnalyzer):
        super().__init__()
        self.source = source
        self.detector = detector
        self.analyzer = analyzer
        self.running = False

    def run(self):
        cap = cv2.VideoCapture(int(self.source) if self.source.isdigit() else self.source)
        self.running = True
        frame_idx, t0, fps = 0, time.time(), 0.0
        WHILE self.running AND cap.isOpened():
            ok, frame = cap.read()
            IF NOT ok: BREAK

            # 隔帧推理,降低算力占用
            IF frame_idx % CFG.FRAME_SKIP == 0:
                dets = self.detector.infer(frame, use_track=True)
                alarms = self.analyzer.analyze(dets, frame.shape)
                frame = Detector.draw(frame, dets, alarms)
                self.detections_ready.emit(dets)
                self.alarms_ready.emit(alarms)

            # 计算 FPS
            frame_idx += 1
            IF frame_idx % 10 == 0:
                fps = 10.0 / (time.time() - t0 + 1e-6)
                t0 = time.time()
                self.fps_ready.emit(fps)

            # ndarray(BGR) → QImage(RGB)
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            h, w, _ = rgb.shape
            qimg = QImage(rgb.data, w, h, w * 3, QImage.Format_RGB888).copy()
            self.frame_ready.emit(qimg)

        cap.release()

    def stop(self):
        self.running = False
        self.wait()


class MainWindow(QMainWindow):
    """
    界面布局:
      ┌──────────────────────────────────────────────────────┐
      │ 顶部工具栏:模型选择 | 视频源 | 开始/暂停 | 截图        │
      ├───────────────────────────────┬──────────────────────┤
      │                               │  实时检测结果列表      │
      │        视频显示区              │  ─────────────────   │
      │        (QLabel)               │  告警日志列表          │
      │                               │  ─────────────────   │
      │                               │  统计:FPS / 各类计数   │
      ├───────────────────────────────┴──────────────────────┤
      │ 状态栏:模型信息 / 检测状态                             │
      └──────────────────────────────────────────────────────┘
    """
    def __init__(self):
        super().__init__()
        self.setWindowTitle(CFG.WINDOW_TITLE)
        self.resize(*CFG.WINDOW_SIZE)

        self.detector: Optional[Detector] = None
        self.analyzer = SafetyAnalyzer(CFG)
        self.video_thread: Optional[VideoThread] = None
        self.is_running = False

        self._build_ui()
        self._bind_signals()
        self._load_model(CFG.MODEL_PATH)

    # ---------- 6.1 构建界面 ----------
    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)

        # --- 视频显示 ---
        self.video_label = QLabel("视频未加载")
        self.video_label.setAlignment(Qt.AlignCenter)
        self.video_label.setMinimumSize(880, 620)
        self.video_label.setStyleSheet("background:#111; color:#888; font-size:18px;")

        # --- 顶部工具栏 ---
        self.btn_start   = QPushButton("开始检测")
        self.btn_stop    = QPushButton("停止")
        self.btn_open    = QPushButton("选择视频/摄像头")
        self.btn_snapshot= QPushButton("截图保存")
        self.combo_source= QComboBox(); self.combo_source.addItems(["摄像头 0", "摄像头 1", "本地视频"])
        self.combo_model = QComboBox(); self.combo_model.addItems(["yolo26n", "yolo26s", "yolo26m"])

        top_bar = QHBoxLayout()
        FOR w IN [self.combo_model, self.combo_source, self.btn_open,
                  self.btn_start, self.btn_stop, self.btn_snapshot]:
            top_bar.addWidget(w)

        # --- 右侧信息面板 ---
        box_det  = QGroupBox("实时检测结果")
        self.list_det = QListWidget()
        QVBoxLayout(box_det).addWidget(self.list_det)

        box_alarm = QGroupBox("安全告警日志")
        self.list_alarm = QListWidget()
        self.list_alarm.setStyleSheet("QListWidget{color:#c00; font-weight:bold;}")
        QVBoxLayout(box_alarm).addWidget(self.list_alarm)

        self.lbl_stat = QLabel("FPS: --\n目标数: --")
        self.lbl_stat.setFont(QFont("Consolas", 11))

        right = QVBoxLayout()
        right.addWidget(box_det, 3)
        right.addWidget(box_alarm, 3)
        right.addWidget(self.lbl_stat, 1)

        # --- 整体布局 ---
        body = QHBoxLayout()
        body.addWidget(self.video_label, 7)
        body.addLayout(right, 3)

        main_layout = QVBoxLayout(central)
        main_layout.addLayout(top_bar)
        main_layout.addLayout(body)

        self.statusBar().showMessage("就绪")

    # ---------- 6.2 信号绑定 ----------
    def _bind_signals(self):
        self.btn_open.clicked.connect(self.on_open_source)
        self.btn_start.clicked.connect(self.on_start)
        self.btn_stop.clicked.connect(self.on_stop)
        self.btn_snapshot.clicked.connect(self.on_snapshot)

    # ---------- 6.3 加载模型 ----------
    def _load_model(self, path: str):
        TRY:
            self.detector = Detector(CFG, path)
            self.statusBar().showMessage(f"模型已加载:{path}")
        EXCEPT Exception as e:
            QMessageBox.critical(self, "模型加载失败", str(e))
            self.detector = None

    # ---------- 6.4 选择视频源 ----------
    def on_open_source(self):
        IF self.combo_source.currentText().startswith("摄像头"):
            CFG.VIDEO_SOURCE = self.combo_source.currentText().split()[-1]
        ELSE:
            path, _ = QFileDialog.getOpenFileName(
                self, "选择视频文件", "", "视频文件 (*.mp4 *.avi *.mkv *.mov)")
            IF path: CFG.VIDEO_SOURCE = path
        self.statusBar().showMessage(f"视频源:{CFG.VIDEO_SOURCE}")

    # ---------- 6.5 开始 / 停止 ----------
    def on_start(self):
        IF self.is_running: RETURN
        IF self.detector IS None:
            QMessageBox.warning(self, "提示", "请先加载模型权重"); RETURN

        self.video_thread = VideoThread(CFG.VIDEO_SOURCE, self.detector, self.analyzer)
        self.video_thread.frame_ready.connect(self.update_frame)
        self.video_thread.detections_ready.connect(self.update_detections)
        self.video_thread.alarms_ready.connect(self.update_alarms)
        self.video_thread.fps_ready.connect(self.update_fps)
        self.video_thread.start()

        self.is_running = True
        self.statusBar().showMessage("检测中…")

    def on_stop(self):
        IF self.video_thread:
            self.video_thread.stop()
        self.is_running = False
        self.statusBar().showMessage("已停止")

    # ---------- 6.6 槽函数 ----------
    def update_frame(self, qimg: QImage):
        """刷新画面。"""
        pix = QPixmap.fromImage(qimg).scaled(
            self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
        self.video_label.setPixmap(pix)

    def update_detections(self, dets: List[Detection]):
        """刷新检测列表与统计。"""
        self.list_det.clear()
        counter: Dict[str, int] = {}
        FOR d IN dets:
            counter[d.cls_name] = counter.get(d.cls_name, 0) + 1
            self.list_det.addItem(f"{d.cls_name:<8s} conf={d.conf:.2f}  "
                                  f"xyxy={tuple(round(v) for v in d.xyxy)}")
        stat_text = "\n".join(f"{k}: {v}" for k, v in counter.items()) or "无目标"
        self.lbl_stat.setText(f"{self.lbl_stat.text().splitlines()[0]}\n{stat_text}")

    def update_alarms(self, alarms: List[str]):
        """刷新告警列表 + 弹窗 + 声音提示。"""
        FOR a IN alarms:
            ts = time.strftime("%H:%M:%S")
            self.list_alarm.insertItem(0, f"[{ts}] {a}")
            self._popup_alarm(a)
            self._play_beep()

    def update_fps(self, fps: float):
        lines = self.lbl_stat.text().splitlines()
        lines[0] = f"FPS: {fps:.1f}"
        self.lbl_stat.setText("\n".join(lines))

    # ---------- 6.7 告警提示 ----------
    def _popup_alarm(self, msg: str):
        """高危告警弹窗(非模态,避免阻塞视频线程)。"""
        IF "高危" IN msg:
            box = QMessageBox(self)
            box.setIcon(QMessageBox.Warning)
            box.setWindowTitle("安全告警")
            box.setText(msg)
            box.setStandardButtons(QMessageBox.Ok)
            box.setModal(False)
            box.show()

    def _play_beep(self):
        """调用系统蜂鸣 / 播放 wav 告警音。"""
        TRY:
            import winsound
            winsound.Beep(1000, 300)     # Windows
        EXCEPT:
            print("\a")                  # 跨平台兜底

    # ---------- 6.8 截图 ----------
    def on_snapshot(self):
        IF self.video_label.pixmap() IS None: RETURN
        path, _ = QFileDialog.getSaveFileName(
            self, "保存截图", f"snapshot_{int(time.time())}.jpg", "图片 (*.jpg *.png)")
        IF path:
            self.video_label.pixmap().save(path)
            self.statusBar().showMessage(f"已保存:{path}")

    # ---------- 6.9 关闭事件 ----------
    def closeEvent(self, event):
        self.on_stop()
        event.accept()


# =============================================================================
# 主入口
# =============================================================================
def main():
    """
    运行模式:
       python main.py train      → 训练模型
       python main.py val        → 验证模型
       python main.py export     → 导出 ONNX
       python main.py            → 启动 GUI 检测系统
    """
    import sys
    mode = sys.argv[1] if len(sys.argv) > 1 else "gui"

    IF mode == "train":
        Trainer(CFG).train()
    ELIF mode == "val":
        Trainer(CFG).validate(CFG.MODEL_PATH)
    ELIF mode == "export":
        Trainer(CFG).export(CFG.MODEL_PATH, fmt="onnx")
    ELIF mode == "dataset":
        DatasetBuilder(CFG).build_data_yaml()
    ELSE:
        app = QApplication(sys.argv)
        win = MainWindow()
        win.show()
        sys.exit(app.exec())


IF __name__ == "__main__":
    main()


# =============================================================================
# 附:完整工程目录结构建议
# =============================================================================
"""
gas_stove_safety/
├── main.py                     # 本文件(入口 + 伪代码全流程)
├── config.py                   # Config 拆分出去
├── dataset_builder.py          # DatasetBuilder
├── trainer.py                  # Trainer
├── detector.py                 # Detector + Detection
├── safety_analyzer.py          # SafetyAnalyzer(规则状态机)
├── ui/
│   ├── main_window.py          # MainWindow
│   ├── video_thread.py         # VideoThread
│   └── resources/              # 图标、告警音
├── datasets/gas_stove/
│   ├── images/{train,val}/
│   ├── labels/{train,val}/
│   └── data.yaml
├── weights/yolo26_gas_stove.pt
├── runs/                       # 训练输出
├── logs/                       # 告警日志(jsonl)
└── requirements.txt            # ultralytics, torch, opencv-python, PySide6, pyyaml
"""

3.1 PySide6介绍

Python 是 AI 时代的首选语言,把算法打包成图形界面交付给用户时,基于 Qt 的 GUI 框架最为合适。PySide 是 Qt 公司官方产品(LGPL 协议),PyQt 为第三方(GPL 协议);PySide6 对应 Qt6,从该版本起命名与大版本号保持一致,不再出现 PySide2 / Qt5 这类混淆。

3.2 安装PySide6

代码语言:javascript
复制
pip install --upgrade pip
pip install pyside6 -i https://mirror.baidu.com/pypi/simple

基于 PySide6 开发 GUI 包含三个基本步骤:设计界面(拖拽或手写)、用信号与槽连接 UI 操作与推理业务、打包发布。

3.3 系统设计

功能模块:

模块

功能

输入源

单张图片 / 图片文件夹 / 本地视频 / 摄像头 RTSP 流

检测显示

四类别彩色框 + 类别名 + 置信度,实时 FPS

参数调节

置信度阈值、IoU、推理尺寸、模型权重切换

安全联动

flame + knob 且 person 缺席时触发"无人看管"告警提示

结果导出

保存标注图 / 检测结果 CSV / 告警日志

核心推理骨架(建议把推理放进 QThread 避免界面卡顿):

代码语言:javascript
复制
​pip install --upgrade pip
pip install pyside6 -i https://mirror.baidu.com/pypi/simple

图 6:界面布局设计示意(非运行截图),仅展示模块划分与告警联动逻辑;实际运行截图、视频检测与告警弹窗需在部署后补拍。


4. 总结与展望

本文按"原理 → 数据集 → 训练 → 系统"的链路,给出基于 YOLO26 的 AirGuardian Mini 燃气灶具安全检测方案。YOLO26 的 DFL-Free + 无 NMS + ProgLoss/STAL + MuSGD 组合,恰好对应本数据集"小目标多、需边缘实时、类别轻度不均衡"的三个痛点,工程上可直接复用官方 data.yaml 与训练脚本。

后续可推进的方向:

1)小目标头对照实验:yolo26n.pt vs yolo26n-p2.yaml(P2 头),量化 knob 类的召回提升与算力代价。

2)特征融合改造:对比 dual P3+P4 fusion 与 single P4 fusion 两种 YAML 变体,观察细粒度旋钮/隐患特征在 P3 层的保留程度与 mAP/GFLOPs 权衡。

3)强特征 backbone:引入 DINOv3 等自监督视觉特征做 backbone 或颈部融合,提升蒸汽、反光、遮挡下的鲁棒性。

4)部署闭环:导出 ONNX / TensorRT 并做 INT8、FP16 量化,在 Jetson 或国产边缘板卡上实测端侧帧率。

5)合规边界:始终保留认证燃气报警器作为最终安全屏障,视觉模型仅作辅助预警。

​

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 基于YOLO26的燃气灶具安全检测系统(Python源码+数据集+Pyside6界面)
  • 1.YOLO26原理介绍
    • 1.1 YOLO11 vs YOLO26结构差异性
      • 1.1.1 SPPF 核心差异对比
      • 1.1.2 C3k2 核心差异对比
    • 1.2 YOLO26核心创新点
      • 1.2.1 创新点1:移除分布焦点损失(DFL)
      • 1.2.2 创新点2:端到端无NMS推理
      • 1.2.3 创新点3:ProgLoss和STAL:增强训练稳定性和小目标检测
      • 1.2.4 创新点4:用于稳定收敛的MuSGD优化器
    • 2. 燃气灶具安全检测系统
      • 2.1 AirGuardian Mini 数据集介绍
    • 2.2 配置Microscopy.yaml
    • 2.3 如何训练
    • 2.4 训练结果可视化结果
    • 3. 燃气灶具安全检测系统设计
      • 3.1 PySide6介绍
      • 3.2 安装PySide6
      • 3.3 系统设计
    • 4. 总结与展望
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档