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

基于YOLO26检测该燃气安全数据集的意义:
flame、hazard 进行视觉识别,为厨房明火与异常状态提供秒级感知。knob 属于典型小目标,可用于判断灶具是否处于开启/未关火状态,弥补纯传感器方案的盲区。person 检测结果,识别"无人看管干烧"等高风险场景。⚠️ 安全声明:数据集官方明确说明,其性能受光照、相机角度、距离与遮挡影响,模型仅用于实验研究,不能替代经过认证的燃气探测器、火灾报警器或独立安全机制。
博主简介

AI小怪兽 | 计算机视觉布道者 | 视觉检测领域创新者
深耕计算机视觉与深度学习领域,专注于目标检测前沿技术的探索与突破。长期致力于YOLO系列算法的结构性创新、性能极限优化与工业级落地实践,旨在打通从学术研究到产业应用的最后一公里。
🚀 核心专长与技术创新
🏆 行业影响力与商业实践
💡 未来方向与使命
秉持 “让每一行代码都有温度” 的技术理念,未来将持续聚焦于实时检测、语义分割及工业缺陷检测的商业化闭环等核心方向。愿与业界同仁协同创新,共同推动技术边界,以坚实的技术能力赋能实体经济与行业变革。

论文: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)池化次数灵活性:YOLO11 的 3 次池化是硬编码的,要修改必须改源码;YOLO26 通过n参数可灵活调整(比如设为 2 次或 4 次),无需改核心逻辑。
2)Shortcut 设计:YOLO26 新增的残差连接能缓解深层网络的梯度消失问题,提升特征复用能力,而 YOLO11 无此设计。
3)激活函数控制:YOLO26 禁用 Conv1 的激活函数,让特征在池化前保持更 “原始” 的状态,是工程上对特征提取的优化。

源码位置:ultralytics/nn/modules/block.py
1)注意力机制的新增:YOLO26 的 C3k2 首次引入PSABlock(金字塔注意力模块),通过attn参数控制是否启用,这是两者最核心的功能差异 —— 启用后模块会先通过 Bottleneck 提取基础特征,再通过 PSABlock 增强关键区域的特征权重,提升小目标 / 复杂场景的检测效果。
2)分支逻辑的扩展:YOLO11 的分支仅受c3k控制,而 YOLO26 的分支逻辑优先级为attn > c3k,即只要attn=True,会优先启用注意力模块,忽略c3k的配置。

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

YOLO26最重要的架构简化之一是移除了分布焦点损失(DFL)模块(图3a),该模块曾存在于YOLOv8和YOLOv11等早期YOLO版本中。DFL最初旨在通过预测边界框坐标的概率分布来改进边界框回归,从而实现更精确的目标定位。虽然该策略在早期模型中展示了精度提升,但也带来了不小的计算开销和导出困难。在实践中,DFL在推理和模型导出期间需要专门处理,这使针对ONNX、CoreML、TensorRT或TFLite等硬件加速器的部署流程变得复杂。
源码位置:ultralytics/utils/loss.py
通过reg_max 设置为1,移除了分布焦点损失(DFL)
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 NoneYOLO26从根本上重新设计了预测头,以直接产生非冗余的边界框预测,无需NMS。这种端到端设计不仅降低了推理复杂度,还消除了对手动调优阈值的依赖,从而简化了集成到生产系统的过程。对比基准测试表明,YOLO26实现了比YOLOv11和YOLOv12更快的推理速度,其中nano模型在CPU上的推理时间减少了高达43%。这使得YOLO26对于移动设备、无人机和嵌入式机器人平台特别有利,在这些平台上,毫秒级的延迟可能产生重大的操作影响。
源码位置:ultralytics/utils/nms.py
训练稳定性和小目标识别仍然是目标检测中持续存在的挑战。YOLO26通过整合两种新颖策略来解决这些问题:渐进损失平衡(ProgLoss)和小目标感知标签分配(STAL),如图(图3c)所示。
ProgLoss在训练期间动态调整不同损失分量的权重,确保模型不会过拟合于主导物体类别,同时防止在稀有或小类别上表现不佳。这种渐进式再平衡改善了泛化能力,并防止了训练后期的不稳定。另一方面,STAL明确优先为小目标分配标签,由于像素表示有限且易被遮挡,小目标尤其难以检测。ProgLoss和STAL共同为YOLO26在包含小目标或被遮挡目标的数据集(如COCO和无人机图像基准)上带来了显著的精度提升。
YOLO26的最后一项创新是引入了MuSGD优化器(图3d),它结合了随机梯度下降(SGD)的优势与最近提出的Muon优化器(一种受大型语言模型训练中使用的优化策略启发而发展的技术)。MuSGD利用SGD的鲁棒性和泛化能力,同时融入了来自Muon的自适应特性,能够在不同数据集上实现更快的收敛和更稳定的优化。
源码位置:ultralytics/optim/muon.py
该数据集是面向 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。
ps:建议填写绝对路径
# Dataset unificado a partir de Final_dataset.
path: D:/YOLO26data/Microscopy
train: images/train
val: images/val
test: images/test
nc: 1
names:
- Mycobacteriumimport 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',
)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
预测结果:

伪代码如下:
# -*- 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
"""Python 是 AI 时代的首选语言,把算法打包成图形界面交付给用户时,基于 Qt 的 GUI 框架最为合适。PySide 是 Qt 公司官方产品(LGPL 协议),PyQt 为第三方(GPL 协议);PySide6 对应 Qt6,从该版本起命名与大版本号保持一致,不再出现 PySide2 / Qt5 这类混淆。
pip install --upgrade pip
pip install pyside6 -i https://mirror.baidu.com/pypi/simple基于 PySide6 开发 GUI 包含三个基本步骤:设计界面(拖拽或手写)、用信号与槽连接 UI 操作与推理业务、打包发布。
功能模块:
模块 | 功能 |
|---|---|
输入源 | 单张图片 / 图片文件夹 / 本地视频 / 摄像头 RTSP 流 |
检测显示 | 四类别彩色框 + 类别名 + 置信度,实时 FPS |
参数调节 | 置信度阈值、IoU、推理尺寸、模型权重切换 |
安全联动 | flame + knob 且 person 缺席时触发"无人看管"告警提示 |
结果导出 | 保存标注图 / 检测结果 CSV / 告警日志 |
核心推理骨架(建议把推理放进 QThread 避免界面卡顿):
pip install --upgrade pip
pip install pyside6 -i https://mirror.baidu.com/pypi/simple图 6:界面布局设计示意(非运行截图),仅展示模块划分与告警联动逻辑;实际运行截图、视频检测与告警弹窗需在部署后补拍。
本文按"原理 → 数据集 → 训练 → 系统"的链路,给出基于 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 删除。