ARTICLE DETAIL

资讯详情

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

烟火图像识别与分类:从数据构建到误报抑制的工程实践

烟火图像识别与分类:从数据构建到误报抑制的工程实践 简介这份资源面向计算机视觉入门者与安全监控、火灾预警方向的开发者围绕烟火图像的识别与分类任务提供一套可运行的完整实践方案。包内共3627个文件以3617张bmp格式的烟火样本图片为主体另含xml标注文件、jpg示例图、py训练脚本、ipynb预处理与实验笔记及项目配置压缩包约11.18MB目录结构便于按类别直接取用数据。内容覆盖图像去噪、增强、灰度化与二值化等预处理环节SIFT、SURF及CNN特征提取思路以及SVM、随机森林与AlexNet、VGG、ResNet等模型的训练、评估与超参数调优流程并涉及数据扩增与准确率、召回率、F1分数等指标分析。已有217人学习下载适合希望快速跑通烟火分类基线、理解从数据标注到模型部署全链路的读者参考。1. 烟火图像的识别与分类从误报到漏报一条可复现的工程路径安防项目里最让人头疼的不是模型跑不动而是它把夕阳下的云层、车灯反光、甚至厨师颠勺的火苗都当成火灾报警。烟火图像的识别与分类本质上要解决的是「在复杂背景里把真实烟火区域框出来并判断它属于烟还是火」这两件事。它适合有基础深度学习经验、手头有监控截图或公开烟火数据集的工程师也适合想从通用目标检测切入垂直场景的开发者。我做过几版从最早直接拿分类网络硬套到后来改成检测加分类的两阶段中间踩的坑足够写满一页纸。这篇就把这条路径拆开讲清楚数据怎么整、模型怎么选、参数怎么调、误报怎么压。2. 烟火数据集的构建与预处理别急着上模型先把数据这关过了烟火识别翻车十次有八次不是模型的问题是数据的问题。公开数据集里烟火样本往往来自几个固定场景模型学到的可能是「这片树林这团橙色」而不是「火」。所以第一步不是找 SOTA 模型而是把数据分布摸清楚。2.1 公开数据集怎么选与怎么补常见的公开烟火数据集有几个来源一类是火灾检测竞赛放出的图片标注以分类标签为主另一类是视频抽帧后人工框选的检测数据集标注格式多为 VOC 或 COCO。我一般会先统计三件事烟火区域占整图的比例、白天与夜间样本比例、有无烟无火但颜色相近的负样本比例。如果负样本少于正样本的三分之一模型几乎必然高误报。补数据有两个方向。一是从监控视频里抽帧用间隔抽帧避免相邻帧高度相似二是做困难负样本挖掘把模型在验证集上误报的图挑出来人工确认后加入训练集。这一步很枯燥但比换模型管用。import cv2 import os # 从视频中按固定帧间隔抽帧避免相邻帧冗余 def extract_frames(video_path, out_dir, interval30): os.makedirs(out_dir, exist_okTrue) cap cv2.VideoCapture(video_path) idx 0 saved 0 while True: ret, frame cap.read() if not ret: break # 每 interval 帧保存一张 if idx % interval 0: # 统一缩放到宽 640保持比例 h, w frame.shape[:2] scale 640 / w frame cv2.resize(frame, (640, int(h * scale))) cv2.imwrite(os.path.join(out_dir, fframe_{saved:05d}.jpg), frame) saved 1 idx 1 cap.release() print(f共保存 {saved} 帧) extract_frames(fire_video.mp4, frames, interval30)这段代码的逻辑是按固定间隔抽帧避免同一秒内多帧几乎一样导致训练集虚高。interval这个参数要看视频长度和烟火持续时间短视频可以设 15 到 30长监控视频设 60 到 120保证一个烟火事件能抽到十几张不同阶段的图。缩放宽度设 640 是为了后续统一输入尺寸如果显存够也可以保留原分辨率再在训练时缩放。2.2 标注格式统一与边界框清洗烟火检测的标注有两个容易出问题的地方。一是烟区域边界模糊不同标注员框的大小能差一倍二是火焰边缘跳动框太紧会把背景像素带进去。我的做法是统一按「可见烟/火主体外扩 5%」来框并在标注规范里写清楚烟只框浓烟主体薄烟不框火只框连续火焰区域火星不框。格式上如果拿到的 VOC 标注转成 YOLO 格式最省事。转换时注意坐标归一化和类别映射。import xml.etree.ElementTree as ET import os # 将 VOC 标注转为 YOLO 格式类别映射smoke0, fire1 def voc_to_yolo(xml_dir, out_dir, class_map{smoke: 0, fire: 1}): os.makedirs(out_dir, exist_okTrue) for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, xml_file)) root tree.getroot() size root.find(size) w int(size.find(width).text) h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text.strip().lower() if name not in class_map: continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # YOLO 格式类别 cx cy bw bh均为相对值 cx (xmin xmax) / 2 / w cy (ymin ymax) / 2 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{class_map[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) if lines: out_name xml_file.replace(.xml, .txt) with open(os.path.join(out_dir, out_name), w) as f: f.write(\n.join(lines)) voc_to_yolo(annotations, labels)转换逻辑里最关键的是归一化那一步cx、cy、bw、bh都除以宽高保证不同分辨率图片的标注一致。class_map按自己的类别改如果只有火没有烟就只留一个类别。转换完一定要抽查几张用可视化脚本把框画回原图确认没有坐标错位。我见过有人把xmin和xmax写反训练时 loss 一直不降查了半天才发现是标注问题。3. 模型选型与训练检测加分类的两阶段为什么比单阶段稳烟火识别到底用分类还是检测这个问题我纠结过很久。分类网络只能告诉你「这张图里有火」但监控画面里火可能只占角落一小块分类网络很容易被背景主导。检测网络能框出位置但烟火边界模糊框的精度本身就不高。我的结论是用检测做定位再用一个轻量分类头判断框内是烟还是火两阶段比单阶段直接出类别更稳。3.1 YOLO 系列做烟火检测的输入尺寸与锚框调整YOLO 系列在烟火检测上落地最多原因是速度快、部署方便。但直接用 COCO 预训练权重锚框尺寸和烟火目标不匹配。烟火在监控画面里往往偏小尤其是早期烟雾可能只占几十个像素。所以训练前要重新聚类锚框。# 用 k-means 对训练集标注框聚类生成适合烟火的锚框 # 假设 labels 目录下是 YOLO 格式标注 python -c import numpy as np import os boxes [] for f in os.listdir(labels): with open(os.path.join(labels, f)) as fp: for line in fp: parts line.strip().split() if len(parts) 5: _, _, _, bw, bh map(float, parts) boxes.append([bw, bh]) boxes np.array(boxes) # 简单 k-meansk9 from sklearn.cluster import KMeans kmeans KMeans(n_clusters9, random_state0).fit(boxes) anchors kmeans.cluster_centers_ # 按面积排序输出 anchors anchors[np.argsort(anchors[:, 0] * anchors[:, 1])] for a in anchors: print(f{a[0]:.4f} {a[1]:.4f}) 聚类出来的锚框要按面积从小到大排替换模型配置文件里的anchors字段。输入尺寸我一般设 640如果烟火目标普遍很小可以提到 832 甚至 1024但显存和速度要权衡。监控场景帧率不高的话速度压力没那么大优先保召回。3.2 分类头怎么接在检测框上做烟与火的二次判断检测模型输出框之后把框裁剪出来送进一个小的分类网络判断是烟还是火。这个分类网络不需要很深MobileNetV3 或 ResNet18 足够。关键是训练数据要单独准备从检测框里裁剪出的图像块标签是烟或火。这样做的另一个好处是如果某个场景只需要检测火可以把烟的框过滤掉减少误报。import torch import torch.nn as nn from torchvision import models # 基于 ResNet18 的烟火二分类头 class SmokeFireClassifier(nn.Module): def __init__(self, num_classes2): super().__init__() self.backbone models.resnet18(pretrainedTrue) # 替换最后的全连接层 in_features self.backbone.fc.in_features self.backbone.fc nn.Linear(in_features, num_classes) def forward(self, x): return self.backbone(x) # 训练时用交叉熵输入是检测框裁剪并缩放到 224x224 model SmokeFireClassifier(num_classes2) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-4)分类头的训练数据要从训练集的检测框里裁注意裁剪时外扩 10% 到 20%把边缘背景带进去一点让分类器学会区分「框内主体是烟还是火」而不是「框内有没有背景」。学习率设 1e-4 是因为用了预训练权重太大容易把特征打乱。如果自己的数据量少于五千张冻结 backbone 前几层只训最后两层效果更稳。4. 避坑与排查烟火识别里那些让你白干一周的细节这一章是我自己踩过的坑按现象、原因、解决来写能帮你省几天时间。4.1 模型在验证集上指标很好一上监控就疯狂误报现象是验证集 mAP 0.85部署到实际摄像头后傍晚云层、路灯、汽车尾灯全被框成火。原因是验证集和实际场景的数据分布不一致验证集里负样本太少模型没见过这些困难负样本。解决办法是拿实际监控画面跑一遍把误报的图截下来人工标注为背景类加入训练集重新训。一般加两三轮误报能降一个数量级。4.2 烟雾检测召回率低早期烟雾完全框不出来现象是明火能检测到但早期白烟、灰烟漏检严重。原因是烟雾颜色和背景对比度低且形状弥散锚框聚类时烟雾框偏小模型学不到大面积的弥散区域。解决办法是在锚框聚类时把烟雾样本单独统计增加大尺寸锚框同时在数据增强里加对比度扰动和灰度化逼模型学纹理而不是颜色。另外输入尺寸提到 832 对烟雾召回帮助明显。4.3 训练 loss 震荡不收敛换了好几个优化器都没用现象是 loss 在 2 到 5 之间来回跳学习率调小也没改善。原因大概率是标注里有大量空标注文件或坐标越界。YOLO 格式里如果cx或cy超过 1训练时会产生异常梯度。解决办法是写个脚本检查所有标注文件把坐标不在 0 到 1 之间的行删掉或修正。另外检查有没有图片和标注不对应的情况比如图片删了标注还在。4.4 推理速度慢单卡跑不到实时现象是 640 输入下 YOLOv5s 在服务器上只有 20 FPS达不到 30 FPS 要求。原因是后处理 NMS 耗时占比高且分类头串行执行。解决办法是把检测和分类做成批处理检测框攒够一批再送分类头NMS 用 GPU 版本如果还不行把输入降到 512 或换更小的模型。实际监控场景 15 FPS 通常够用不必死磕 30。4.5 不同摄像头画面亮度差异大模型表现忽好忽坏现象是同一个模型在 A 摄像头准在 B 摄像头漏报多。原因是不同摄像头的曝光、白平衡、安装角度不同导致输入分布偏移。解决办法是在预处理里加自适应直方图均衡化或者用少量 B 摄像头的图做微调。更彻底的做法是训练时做随机亮度、对比度、色调增强让模型对光照变化鲁棒。5. 把烟火识别推到可用的最后一公里阈值调优与误报抑制模型训完只是开始真正决定能不能上线的是后处理。我一般会在验证集上画 PR 曲线然后根据业务容忍度选阈值。安防场景通常宁可误报也不能漏报所以置信度阈值会设得偏低比如 0.25然后用其他规则压误报。一个实用的技巧是时序滤波。监控视频是连续的真实烟火在连续帧里位置变化不会太剧烈而车灯、反光往往是瞬时的。可以维护一个滑动窗口统计最近 N 帧里同一区域被检测为烟火的次数超过阈值才报警。from collections import deque # 简单的时序滤波同一区域连续多帧命中才报警 class TemporalFilter: def __init__(self, window10, hit_threshold6, iou_threshold0.3): self.window window self.hit_threshold hit_threshold self.iou_threshold iou_threshold self.history deque(maxlenwindow) def iou(self, box1, box2): # 计算两个框的 IoU x1 max(box1[0], box2[0]) y1 max(box1[1], box2[1]) x2 min(box1[2], box2[2]) y2 min(box1[3], box2[3]) inter max(0, x2 - x1) * max(0, y2 - y1) area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) return inter / (area1 area2 - inter 1e-6) def update(self, detections): # detections: list of [x1, y1, x2, y2, score, class] self.history.append(detections) if len(self.history) self.window: return [] # 统计当前帧每个检测在历史帧中的命中次数 confirmed [] for det in detections: hits 0 for past in self.history: for p in past: if self.iou(det[:4], p[:4]) self.iou_threshold: hits 1 break if hits self.hit_threshold: confirmed.append(det) return confirmed这段代码的逻辑是维护最近window帧的检测结果对当前帧每个框统计历史帧里有多少帧存在 IoU 超过阈值的框。hit_threshold设 6 表示 10 帧里至少 6 帧命中才确认。iou_threshold设 0.3 是因为烟火框本身抖动大设太高会漏掉真实目标。这个滤波能把车灯、反光这类瞬时误报压掉大部分代价是报警延迟几帧实际场景完全可接受。另一个技巧是分区域阈值。画面里如果有固定光源、烟囱、厨房排烟口可以手动划区域降低灵敏度或直接屏蔽。这听起来不优雅但工程上最有效。我一般会留一个配置文件把每个摄像头的屏蔽区域和阈值单独写部署时按摄像头加载。最后说一个我自己的习惯每次模型更新一定拿同一段包含真实烟火的视频和一段纯背景视频跑一遍对比误报数和漏报数记录在表格里。不靠感觉判断模型好坏靠数字。这个习惯帮我避免了好几次「以为改进了其实退步」的翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表