简介:面向安防监控、智慧消防与视频分析方向的开发者及算法实践者,这套烟火识别与烟雾检测工具聚焦图片、RTSP实时流和mp4视频中的火焰、烟雾检测,可输出带告警框的图片,适合做场景验证、算法联调、样本筛选与二次开发。资源包共874个文件,整体约324.75MB,包含大量jpg样例与测试图、dll运行库、exe工具、mp4演示视频,以及模型权重、pdf使用文档和批处理脚本等,以轻量工具化方式兼顾直接运行、批量处理与样本整理。已有242人学习下载,说明其在烟火分析实践中有一定参考价值。读者可据此快速验证检测效果、了解告警叠框输出形式,并借助随附文档与脚本完成环境准备、批量测试和结果排查,减少从零搭建工具链的时间。
1. 白烟、水汽和真火之间:烟火识别工具解决的是哪一段问题
凌晨两点,某仓库的监控画面里飘起一股白烟,值班的人正盯着十六宫格,等他抬头看见时已经过去了三分钟。事后复盘发现,难的不是“看见火”,而是在几百路画面里判断哪一路值得抬头看。烟火识别、烟雾识别这类算法要做的就是这件事:它不负责灭火,只在画面里出现疑似火焰或烟雾时,第一时间输出带检测框的告警图,让人有时间反应。
这套烟火检测工具支持三种输入:单张图片、RTSP 实时流、本地 MP4 文件;输出带检测框的告警图片。适合做安防集成、园区仓储厂区视频分析的从业者,也适合需要离线复核历史录像的人。下面按我拆包时的顺序讲,参数、坑和调法都尽量落到能照着抄的程度。
2. 三种输入一条流水线:图片、RTSP 与 MP4 的接入参数怎么定
三种输入在算法层走的是同一条流水线:解码、预处理、推理、后处理、叠框、落盘。真正变的只有三件事——谁送帧过来、隔多久送一帧、送不过来怎么办。这一点想通了,后面所有参数都有对应的调整位置。很多人一上来就问“置信度设多少”,其实先该问的是抽帧策略,因为烟火识别对时间维度的敏感度远高于单帧:一团烟在单帧里可能就是块灰白色区域,但它在连续帧里的扩散方向、亮度变化是有规律的,抽帧抽稀了,这些规律就没了。
2.1 为什么三种输入不能共用一套调度
图片没有时间概念,一帧就是一个样本,可以多进程满负载跑,跑完就结束,不存在“追不上”的问题。MP4 有稳定帧率、可以 seek、可以反复重跑同一段,适合做离线复核和回归测试——同一批样本跑两遍结果不一致,那基本是抽帧逻辑或后处理有随机性,拿 MP4 排查最省事。
RTSP 完全是另一回事。它是一条长连接,帧率会抖,网络抖动时解码器会积压,你调一次read()拿到的可能是十几秒前的旧帧。这个坑很隐蔽:日志里推理耗时正常、告警也出来了,但现场的烟已经烧了两分钟,告警图上的时间戳对不上。常见做法是把解码缓冲设成 1,并且用一个独立线程只保留“最新一帧”,推理线程按自己的节奏去取,取不到就跳过,绝不排队。
提示:按帧号抽帧只适合 MP4 这种定帧率文件。RTSP 上必须按时间间隔抽帧,否则网络一抖,你的“每 15 帧一次”就变成了“每 3 秒一次”甚至“每 20 秒一次”。
2.2 图片与 MP4:批量跑和离线复核的骨架
图片批处理最简单,麻烦的是 MP4 的抽帧效率。直接循环cap.read()会把每一帧都解码一遍,25fps 的十分钟视频就是一万五千次解码,绝大多数都白扔了。用grab()和retrieve()分开调用,能省掉一大半解码开销。
import cv2 from pathlib import Path # 抽帧步长:烟火持续时间按秒计,25fps 视频取 15 约等于 0.6 秒一帧 SAMPLE_STEP = 15 def iter_images(root): """遍历目录下的图片,返回 (标识, 图像帧, 时间戳)""" for p in Path(root).rglob("*"): if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".bmp"): img = cv2.imread(str(p)) if img is not None: yield str(p), img, None # 图片没有时间戳 def iter_video(path, step=SAMPLE_STEP): """按固定帧号步长抽帧,时间戳由帧号换算,保证可复现""" cap = cv2.VideoCapture(path) fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 idx = 0 while True: ok = cap.grab() # 只做解复用,不解码,开销约为 read 的一半 if not ok: break if idx % step == 0: ok, frame = cap.retrieve() # 只对需要的帧解码 if ok: yield f"{path}#{idx}", frame, idx / fps idx += 1 cap.release()逻辑上分两段:grab()推进解码器但不产出图像,retrieve()才真正解码当前帧。参数上,SAMPLE_STEP是唯一需要调的东西——火焰蔓延快,步长可以取小一点,比如 8 到 10;烟雾扩散慢,15 到 25 都够。时间戳一定要用帧号除以 fps 换算,别用time.time(),否则离线重跑两次得到的时间戳完全不同,回溯就没意义了。
2.3 RTSP:按时间抽帧,不按帧号
实时流我一般写成两个线程:一个只负责读帧并覆盖共享变量,另一个按固定间隔取最新帧去做推理。读线程要带断流重连和退避,退避时间别设太短,1 秒以下会让日志被重连失败刷满,反而看不到真正的问题。
import cv2, threading, time class RtspGrabber: def __init__(self, url, backoff=3.0): self.url = url self.backoff = backoff # 断流后的重连间隔,秒 self.frame = None self.ts = 0.0 self.lock = threading.Lock() self.running = True self.thread = threading.Thread(target=self._loop, daemon=True) def start(self): self.thread.start() return self def _open(self): cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 只留一帧,避免读到过期画面 return cap if cap.isOpened() else None def _loop(self): cap = None while self.running: if cap is None: cap = self._open() if cap is None: time.sleep(self.backoff) continue ok, frame = cap.read() if not ok: cap.release() cap = None # 标记需要重连,下一轮重新打开 continue with self.lock: self.frame = frame self.ts = time.time() # 实时流用系统时间,方便和现场对时 def latest(self): with self.lock: return self.frame, self.ts推理侧就简单了,主循环里按1 / sample_interval的节奏去调latest(),拿到 None 就跳过这一拍,不要 sleep 等帧。sample_interval我一般从 0.5 秒开始试:太密,后端扛不住,而且同一场烟会产出几百张几乎一样的告警图;太疏,比如 5 秒一帧,明火从起火到铺开可能就漏掉了。真实场景里 0.3 到 1 秒是比较稳的区间。
2.4 告警去重:别让一场火烧掉你的磁盘
同一场火连续报几十帧是常态,直接落盘等于自杀。要加冷却窗口:同一个通道、同一个类别,在窗口 T 内只保留置信度最高的那一张。T 取 30 到 60 秒比较合适,因为人的响应时间大概就是这个量级,再密集的告警也只是噪音。
具体实现没必要搞复杂,一个内存字典就够:键是(通道号, 类别),值是这个窗口内已经落盘的最高置信度和落盘时间。新告警进来时先比对窗口,窗口内且置信度更低就直接丢弃;窗口外或者置信度更高,就落盘并更新字典。这个字典要做定期清理,否则跑一周内存会慢慢涨上去。
3. 告警图怎么落盘:叠框、置信度与告警字段的配置拆解
叠框这件事技术上不难,难的是“这张图三个月后还能不能被人看懂”。我见过太多告警图,一个红框扣在画面上,没有通道名、没有时间、没有类别,事后想定位是哪个摄像头、几点几分,只能靠文件名去猜。这一章把叠框和落盘拆成可配置的几块。
3.1 叠框的四个要素,少一个都会后悔
框本身只是其一。第二是类别标签,烟雾和火焰必须能区分——处理方式完全不同,火焰要立刻出动,烟雾可能是误报(蒸汽、灰尘)。第三是置信度,方便运维判断这张图是“确凿”还是“擦边”。第四是时间戳和通道标识,最好直接烧在画面左上角,而不是只写在文件名里,因为图片经常被截图转发,文件名会丢。
线宽和字号要跟分辨率挂钩,别写死成 2 和 0.6。1080P 上刚刚好的线宽,放到 4K 上就细得像头发丝,放到 720P 上又把目标糊住了。字体建议用 OpenCV 自带的 Hershey 系列,省去中文字体路径的麻烦,标签用英文或者拼音缩写。
3.2 置信度阈值与 NMS IoU:两个最容易搞混的参数
这两个参数经常被当成一个东西调。简单说,conf_thres决定“报不报”,iou_thres决定“同一个目标留几个框”。烟雾是弥散目标,一块烟在特征图上可能被切成好几段,NMS 的 IoU 阈值设成 0.45(很多默认值)时会留下三四个重叠框,告警图上看着像出了三处烟。烟雾场景我一般把 IoU 抬到 0.55 到 0.6,合并得更狠一些。火焰框一般比较紧凑,IoU 影响不大,0.45 到 0.5 都行。
| 参数 | 作用 | 常见取值 | 调高会怎样 | 调低会怎样 |
|---|---|---|---|---|
| conf_thres | 低于该分数的检测框直接丢弃 | 0.35~0.5 | 误报少,小目标烟雾容易漏 | 报得多,蒸汽水汽全进来 |
| iou_thres | NMS 时两个框重叠超过该比例就合并 | 0.45~0.6 | 重叠目标只留一个,密集火焰会漏 | 同一团烟出现多个框 |
| min_box_area | 小于该像素面积的框丢弃 | 按分辨率定,1080P 取 400~900 | 远距离小火苗漏检 | 噪点、反光被当成目标 |
min_box_area是个常被忽略但极其好用的参数。夜里画面噪点多,模型偶尔会在角落吐出一两个十几个像素的小框,面积过滤一次就全清了,比调置信度温和得多,不会连带把真目标干掉。
3.3 命名与目录:让告警图能被检索
命名规则我习惯用站点_通道_年月日时分秒_置信度.jpg,时间放中间,这样按文件名排序天然就是时间序。目录按天分,一天一个子目录,否则跑一个月单个目录几万张图,文件管理器打开就会卡。
import cv2, json, time from pathlib import Path def draw_and_save(frame, dets, site, channel, conf_thres=0.45, out_root="./alarm", quality=85): """在帧上叠框并落盘,同时写一份同名 json 便于程序检索""" h, w = frame.shape[:2] kept = [d for d in dets if d["score"] >= conf_thres] if not kept: return None for d in kept: x1, y1, x2, y2 = [int(v) for v in d["box"]] # 火焰红、烟雾橙,颜色区分比标签更直观 color = (0, 0, 255) if d["label"] == "flame" else (0, 165, 255) cv2.rectangle(frame, (x1, y1), (x2, y2), color, max(2, w // 400)) tag = f'{d["label"]}:{d["score"]:.2f}' cv2.putText(frame, tag, (x1, max(22, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, max(0.6, w / 1600), color, 2) ts = time.strftime("%Y%m%d%H%M%S") day = time.strftime("%Y%m%d") out_dir = Path(out_root) / day out_dir.mkdir(parents=True, exist_ok=True) best = max(d["score"] for d in kept) name = f"{site}_{channel}_{ts}_{best:.2f}.jpg" path = out_dir / name cv2.imwrite(str(path), frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) meta = { "site": site, "channel": channel, "ts": ts, "objects": [{"label": d["label"], "score": round(d["score"], 3), "box": [round(v, 1) for v in d["box"]]} for d in kept], } path.with_suffix(".json").write_text( json.dumps(meta, ensure_ascii=False), encoding="utf-8") return str(path)这段的关键在最后几行:图片和 json 同名同目录,程序要做统计、要做二次筛选、要把告警推到别的系统,读 json 就行,不用再跑一遍模型。quality参数别设 100,85 足够人眼判读,体积能小一半以上,长期跑流省下来的磁盘很可观。
3.4 结构化字段:图片之外还得有记录
| 字段 | 含义 | 示例 |
|---|---|---|
| site | 站点或项目代号 | plant_a |
| channel | 通道号 | ch07 |
| ts | 告警时间,精确到秒 | 20240612031542 |
| label | flame 或 smoke | smoke |
| score | 置信度,保留三位 | 0.782 |
| box | 框坐标,原图坐标系 | [812, 340, 967, 455] |
| frame_id | 对应视频帧号,便于回放定位 | 128640 |
有了frame_id,运维点开告警图之后可以直接跳到录像的对应位置,前后各看十秒,判断是真火还是蒸汽。这个字段不加,回看录像就得靠人肉拖进度条,实际用起来会让人非常崩溃。
4. 误报压不下来?烟火识别里最常见的五类排查
模型在实验室的指标好看,上了现场一天报八十次,八成不是算法不行,是场景没对齐。下面五条是我踩过的,按出现频率排。
现象一:白天报得少,傍晚开始疯狂报。原因基本是夕阳直射镜头、暖色路灯、车灯打在墙面上的反光,这些在色彩和亮度上都接近火焰。解决办法分两步:先把这些区域从 ROI 里划掉,比如朝西的窗口、正对路灯的墙面;如果划不掉,就要求连续 N 帧(N 取 3 到 5)在同一位置都检出才告警,车灯扫过是一瞬间,火焰会持续。
现象二:蒸汽、开水房、厨房排风口天天报。烟雾识别本质上是在找“灰白色、边缘模糊、向上扩散”的区域,蒸汽长得一模一样,纯靠单帧分类很难分开。可靠的区分手段是动态特征:烟雾在连续帧里会持续扩散变形,蒸汽在排风口附近形态变化更剧烈、更快消散。工具如果支持连续帧确认,就把窗口拉长到 5 帧以上;不支持,就只能靠 ROI 把排风口排掉,这是最省事也最有效的办法。
现象三:夜间整片画面报,框大得离谱。红外补光下画面噪点重,模型会把大片噪点聚成一个巨大框。先看min_box_area是不是没设,再确认框的宽高比——真实烟雾框的宽高比很少超过 4:1,横贯整个画面的扁框基本都是噪点或者镜头被遮挡。加一条宽高比过滤,误报能掉一大截。
现象四:RTSP 画面花屏或绿屏,同时告警量暴增。这个属于典型的基础设施问题被算法背了锅。原因通常是解码丢了关键帧,或者 H.265 码流在某些机器上硬解异常。排查方式很简单,把流地址改成用 ffmpeg 命令行拉一段存成 mp4,本地播放看花不花。花就是码流问题,去查网络丢包和关键帧间隔;不花就是解码器配置问题,换软解或换后端再试。
注意:花屏导致的误报是有迷惑性的,告警图上框扣在色块上,看着还真像那么回事。凡是“告警突然变多且集中在某几路”,第一反应都应该是先去拉原始流看看。
现象五:磁盘一周就满了,服务被写挂。一场持续几小时的烟,即使有冷却窗口,一小时也能产出几十张图,加上 json 和日志,几百个通道叠加起来很吓人。要做两件事:一是按天分目录并加保留策略,超过 30 天的自动清;二是给告警图总量设上限,触发上限时降级为只写 json 不写图,保证服务不挂。这个后悔药不吃,迟早会在半夜被叫起来。
5. 从通用到现场:烟雾分析阈值与 ROI 的调参表
前面讲的都是“能跑起来”,这一章讲“跑得准”。烟火分析的调参本质是把通用模型往你的现场掰,掰的手段就两个:划定它该看的地方,告诉它什么程度才值得喊人。
5.1 先划 ROI,再谈阈值
我一般先划 ROI,再谈阈值。原因很直接:阈值调低是为了抓远处的早期烟雾,但同一个阈值会把非检测区域里的干扰一起放进来。先把 ROI 划干净——走廊、货架通道、堆场作业面留,天空、树冠、反光墙面、显示屏、排风口全部排掉——你会发现阈值可以放心往下压,因为进来的东西本来就少。
ROI 用多边形而不是矩形,配置文件里存归一化坐标(0 到 1),这样换分辨率不用重画。一个通道支持多个多边形,取并集。划完之后一定要用历史录像回放验证一遍:把过去一周的录像按 1 帧/秒跑一遍,统计 ROI 内外的告警分布,如果 ROI 外还在报,说明掩膜没生效或者坐标算错了。
5.2 六个参数的调参表
| 参数 | 典型值 | 调它解决什么 | 调过头的后果 |
|---|---|---|---|
| smoke_conf | 0.30~0.40 | 烟雾对比度低,分数普遍偏低,要比火焰松 | 水汽灰尘全进来 |
| flame_conf | 0.45~0.55 | 火焰特征明显,可以卡严一点 | 远处小火苗漏报 |
| iou_thres | 0.55~0.60 | 合并同一团烟的多个框 | 相邻两处火只报一处 |
| min_box_area | 400~900(1080P) | 干掉噪点小框 | 远距离早期烟雾漏检 |
| confirm_frames | 3~5 | 压制瞬时干扰(车灯、闪电) | 响应延迟增加 1~2 秒 |
| cooldown_sec | 30~60 | 合并持续告警,控制落盘量 | 复燃可能被并进上一条 |
这六个参数里,confirm_frames和cooldown_sec的收益最高、风险最低,建议先调这两个。smoke_conf最敏感,动它之前一定先划好 ROI。
5.3 三个典型场景的预设
室内仓库:光照稳定,主要干扰是叉车灯和装卸口的扬尘。smoke_conf取 0.35,flame_conf取 0.5,confirm_frames取 3,ROI 覆盖货架通道和月台,把高棚灯的灯罩排掉。
室外堆场:干扰最多,阳光直射、雨雾、车辆尾气。confirm_frames要拉到 5,min_box_area提到 800 以上,cooldown_sec给 60。另外建议加时段策略,日出日落前后一小时的灵敏度临时下调,这两个时段是误报高发窗口。
地下车库:光照差、噪点多,车灯扫过频繁。优先把min_box_area提上去,smoke_conf反而可以保持 0.35 左右,因为烟雾在暗环境里通常有补光照射,分数不会太低。宽高比过滤在这个场景收益特别明显。
6. 长时跑流的验证方法:一个我常备的抽帧压测脚本
模型跑通一次不算数,能连续跑七十二小时不出事才算。我自己有个习惯:任何一路流正式接入前,先用脚本空跑五分钟,看三个指标——解码是否稳定、单帧推理耗时分布、告警是否异常密集。这三个数字正常,后面基本不会出大问题;哪一个不对,当场就能定位是网络、算力还是参数。
import time, statistics, cv2 def bench(url, seconds=300, sample_interval=0.5, infer=None): """压测一路流:统计解码成功率、推理耗时分布、有效样本数""" cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) lat, frames, dropped = [], 0, 0 t_end = time.time() + seconds last = 0.0 while time.time() < t_end: ok, frame = cap.read() if not ok: dropped += 1 time.sleep(0.5) # 读失败多半是断流,等一下再试 continue now = time.time() if now - last < sample_interval: continue # 没到采样间隔就丢弃,保证喂进去的是新帧 last = now t0 = time.perf_counter() if infer: infer(frame) # 这里换成实际的推理调用 lat.append((time.perf_counter() - t0) * 1000) frames += 1 cap.release() if lat: lat.sort() print(f"有效样本 {frames},读帧失败 {dropped}," f"P50 {statistics.median(lat):.1f}ms," f"P95 {lat[int(len(lat) * 0.95) - 1]:.1f}ms," f"最大 {lat[-1]:.1f}ms") bench("rtsp://127.0.0.1:8554/test", seconds=300, sample_interval=0.5)sample_interval要和线上实际配置一致,不然测出来的耗时没有参考意义。看结果时我关注三件事:读帧失败次数应该接近 0,超过个位数说明链路不稳;P95 和 P50 的比值最好在 1.5 以内,差太多说明有卡顿尖峰,可能是 GC 或者别的进程抢资源;最大耗时如果超过采样间隔,那实际运行时会持续丢帧,必须降分辨率或者降采样频率。
| 观察项 | 健康区间 | 异常时的第一动作 |
|---|---|---|
| 读帧失败次数 | 0~2 次 / 5 分钟 | 查网络丢包和关键帧间隔 |
| P95 / P50 比值 | < 1.5 | 查进程资源争抢,限制并发路数 |
| 单帧最大耗时 | < 采样间隔 | 降输入分辨率或换小模型 |
| 告警数 / 5 分钟 | 与历史基线持平 | 对比同时间段历史告警图找共性 |
真正吃过亏是在一个冬天的项目上。当时用白天录像调好的参数上线,头两天好好的,第三天凌晨开始每小时报上百条,全是雾气。教训很直接:调参只用了白天样本,等于没验证。从那以后我每次上线前都强制走一遍“白天加夜间各一段、共不少于 24 小时”的回放验证,宁可多花一天,也不愿意半夜被电话叫起来。希望这些能帮你少走点弯路。
本文还有配套的精品资源,点击获取