☰
烟火识别落地实战:RTSP流接入、误报抑制与阈值回归
2026/11/2 2:01:44 网站建设 项目流程

简介:LNTON羚通烟火识别算法与烟雾检测工具包面向安防监控、园区消防、森林防火等场景的算法学习者和工程开发者,提供图片、RTSP实时流及mp4视频中的烟火检测与烟雾识别能力,并可将告警目标以叠框形式输出,便于快速验证与集成。压缩包共874个文件,以843张jpg样本图片为主,另含dll运行库、exe工具程序、bat批处理脚本、mp4演示视频、weights模型权重及pdf使用文档等,整体约324.75MB,工具与素材配套较完整。已有243人学习下载,适合作为烟火识别入门实践与效果测试的参考。包内附使用文档和可执行工具,样本图片量充足,便于开展模型效果比对、阈值调整、告警叠框检查和不同输入源的联调测试,也能为后续二次开发与场景适配提供可复用的脚本、依赖与排错思路。

1. 烟火识别工具落地前,先想清楚误报率这件事

某园区值班室里有块大屏,接的是仓库顶上的球机。这套烟火识别与烟雾识别工具第一次跑通,值班员看到的是满屏红框——冷却塔冒的白汽、叉车大灯扫过地面的反光、远处路灯的灯罩,全被框成了烟雾和火焰。反过来,真出问题时它又未必看得见,因为画面里烟还细。所以先给个反直觉的结论:烟火算法真正难的地方是两件事:把误报压到人能接受的量级,以及把图片、RTSP 实时流、mp4 文件这三路输入稳定地喂进模型。这套工具做的正是这件事,输入一个源,输出带检测框和时间戳的告警图。适合做安防二次开发、园区巡检、算法快速验证的从业者,也适合想拿真实视频跑通一遍检测链路的新手。下面按接入、算法、告警、踩坑、调参的顺序拆。

2. 三路输入统一接入:图片、RTSP 实时流与 mp4 的取帧链路

三种输入的区别不在「能不能读」,而在「读的节奏不一样」。图片是一次性解码,读完就结束;mp4 是按时间轴匀速推进,读快了没意义;RTSP 是网络流,快慢由对端编码器和链路决定,还会断。如果三路各写一套 for 循环,后面加跳帧、加告警、加重连就要改三遍,改到最后自己都记不清哪份是最新的。我一般会先做一层帧迭代器,把三种源归一成同一个read()语义,上层检测逻辑完全不感知输入类型。

2.1 把三种输入归一成同一个帧源

判断顺序很关键:先看是不是纯数字(摄像头序号),再看是不是 rtsp/rtsps 前缀,最后才落到本地文件。顺序反了会把"0"当成文件路径去isfile,白白多一次 IO。图片走单独的分支,因为 OpenCV 的imread在 Windows 下碰到中文路径会安静地返回None,不报错,等到后面frame.shape才炸,排查起来很费时间。

import os import re import numpy as np import cv2 RTSP_RE = re.compile(r"^rtsps?://", re.I) IMG_EXT = (".jpg", ".jpeg", ".png", ".bmp", ".webp") class ImageCapture: """把单张图片包装成和 VideoCapture 一致的 read() 接口,只吐一帧""" def __init__(self, path): self.path = path self._done = False self._frame = self._imread_unicode(path) @staticmethod def _imread_unicode(path): # cv2.imread 遇到中文/空格路径会返回 None,用二进制读 + imdecode 绕过 data = np.fromfile(path, dtype=np.uint8) if data.size == 0: return None return cv2.imdecode(data, cv2.IMREAD_COLOR) def read(self): if self._done or self._frame is None: return False, None self._done = True return True, self._frame def release(self): self._frame = None def build_capture(source, open_timeout_ms=5000): if isinstance(source, str) and source.isdigit(): source = int(source) # "0" -> 0,本机摄像头 if isinstance(source, str) and RTSP_RE.match(source): # 必须在创建 VideoCapture 之前设置,晚了不生效 os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = ( "rtsp_transport;tcp|stimeout;%d" % (open_timeout_ms * 1000) ) cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 只保留 1 帧,防缓冲堆积 return cap if isinstance(source, str) and os.path.isfile(source): if os.path.splitext(source)[1].lower() in IMG_EXT: return ImageCapture(source) return cv2.VideoCapture(source) # mp4 / avi 等容器 raise ValueError("无法识别的输入源: %r" % (source,))

这段代码有三个地方值得单独说。一是OPENCV_FFMPEG_CAPTURE_OPTIONS里的stimeout单位是微秒,5000毫秒要写成5000000,写成5000等于没设超时,断流时会一直堵在那里。二是CAP_PROP_BUFFERSIZE只在部分后端有效,设了不一定生效,但不设一定更糟,属于成本极低的保险。三是ImageCapture故意只返回一帧,这样上层的while True: ok, frame = cap.read()不用为图片单独写分支,一段代码通吃。

2.2 RTSP 拉流的硬参数与断流重启

实时流是整个工具里最容易翻车的部分。它不像 mp4 有明确的 EOF,网络抖一下、相机重启一次,read()就返回False,如果外层没有重试逻辑,进程会静默退出或者挂在那里不动。更隐蔽的是「延迟滚动」:拉流线程的解码速度快于下游消费速度时,缓冲越堆越多,画面时间戳离真实时间越来越远,最后看起来是「卡住」,其实是排着队。

参数设置位置建议值说明
rtsp_transport环境变量tcpUDP 丢包会导致花屏和马赛克,检测框乱跳
stimeout环境变量5000000(5s)单位微秒,控制建连和读超时
CAP_PROP_BUFFERSIZEVideoCapture1减小解码缓冲,抑制延迟累积
fail_threshold业务层20~30 次连续失败到这个数才判定断流
retry_base业务层3.0 秒,指数退避避免对端没起来时疯狂重连
import time def frames_with_retry(source, max_retry=10, retry_base=3.0, fail_threshold=30): """带重连的帧生成器:断流后指数退避重建,不把对端打崩""" retry = 0 while retry <= max_retry: cap = build_capture(source) if not cap.isOpened(): retry += 1 time.sleep(min(retry_base * retry, 30)) continue fails = 0 try: while True: ok, frame = cap.read() if not ok or frame is None: fails += 1 if fails >= fail_threshold: # 偶发丢帧不算断流,连续失败才算 break time.sleep(0.05) continue fails = 0 retry = 0 yield frame finally: cap.release() # 不 release 会泄漏句柄,一天下来句柄数爆掉 retry += 1 time.sleep(min(retry_base * retry, 30))

fail_threshold这个阈值不能设太小。现场有相机做过码率自适应,一次关键帧丢了,后面连着三四十帧读不出来是常事;阈值设成 3,你会看到进程一天重连几百次,日志刷得比画面还热闹。反过来设成 300,断流后要等半分钟才重连,告警就漏了。20 到 30 是踩过几次之后觉得比较稳的区间。retry在成功读到一帧后必须清零,否则偶发断流累积十次就把重试额度耗尽了。

2.3 跳帧策略:算力该花在哪一帧上

25fps 的流全量跑检测,一天 216 万帧,GPU 和电费都扛不住,而且没意义——烟雾从出现到扩散开是秒级的,帧与帧之间的信息冗余极大。按时间间隔抽帧到 1~2fps 就足够抓到事件。但抽帧的实现方式有讲究:用read()循环计数跳帧,跳过的帧照样做了 BGR 转换,CPU 白烧;用grab()跳过、retrieve()取值,跳过的帧不解码成 Mat,能省下可观的 CPU。

def sample_frames(cap, interval_sec=1.0, default_fps=25.0): """按秒抽帧:grab 只推进解码,retrieve 才真正取图像""" fps = cap.get(cv2.CAP_PROP_FPS) if not fps or fps != fps or fps <= 1e-3: # 部分流返回 0 或 nan fps = default_fps step = max(1, int(round(fps * interval_sec))) idx = 0 while True: ok = cap.grab() if not ok: break if idx % step == 0: ok, frame = cap.retrieve() if ok and frame is not None: yield idx, frame idx += 1

fps那一行fps != fps是判断nan的写法,某些网络流的元数据不规范,CAP_PROP_FPS会返回nan,直接拿去算step会抛异常。interval_sec的取值和场景相关:仓库、车间这类开阔场所 1.0 秒够用;电池仓、充电区这类起烟快的地方建议 0.5 秒。抽得再密意义不大,因为误报也跟着涨。

3. 从像素判火到目标检测:烟火识别算法的选型与叠框输出

图片、流、文件三路接进来之后,核心问题变成「这一帧里有没有火和烟」。这条路线上有几种完全不同的做法,选错了后面怎么调参都别扭。我见过不少人一上来就用 RGB 阈值判火焰像素,白天在实验室里效果拔群,装到现场第一天就被夕阳和高光反射打回原形。

3.1 三条算法路线的取舍

火焰的视觉特征相对明确:高亮、暖色、跳动。烟雾则麻烦得多——半透明、无固定边缘、形状随气流变化,而且和雾、蒸汽、粉尘、扬尘在像素层面几乎同分布。这就是为什么「烟雾分析」比「火焰识别」难,也是为什么单纯的颜色规则在烟雾上基本无效。

路线典型做法优点主要问题适用场景
颜色 + 运动阈值RGB/HSI 空间取火焰色域,配帧差法判跳动无需训练、CPU 就能跑夕阳、车灯、焊接弧光误报极高室内、光照恒定的封闭空间
背景建模 + 纹理高斯混合建模找运动区,再算纹理和梯度能抓到缓慢扩散的烟相机一抖就全废,树影、雨雪干扰大固定机位、背景干净的室内
目标检测模型单阶段检测器,火焰/烟雾两类一起出抗干扰强,泛化好需要标注数据,烟雾框抖动明显大多数现场场景
检测 + 时序分类检测框裁图后送时序模型判真假误报压得最低链路长、延迟高、部署复杂误报代价高的场合

这套工具走的是第三条路线的工程化封装,同时对图片、流、文件三种输入统一输出叠框告警图。实际用下来,单帧检测的漏报主要来自小目标——几十米外的初期火点可能就十几个像素,输入尺寸给到 640 基本就没了。如果监控距离远,要么裁 ROI 单独送检,要么把输入尺寸提到 960 以上,代价是帧率下降,得靠跳帧策略找平衡。

3.2 预处理与后处理参数:conf、IoU、输入尺寸

预处理最容易被忽略的是缩放方式。直接resize到 640×640 会把宽高比拉变形,细长的烟柱被压扁,特征就变了。正确做法是等比缩放加灰边填充,也就是常说的 letterbox,同时把缩放比例和偏移记下来,后处理时好还原坐标。

def letterbox(img, new_size=640, color=(114, 114, 114)): """等比缩放 + 灰边填充,返回图像、缩放比、左上偏移,用于坐标还原""" h, w = img.shape[:2] r = min(new_size / h, new_size / w) nh, nw = int(round(h * r)), int(round(w * r)) resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) canvas = np.full((new_size, new_size, 3), color, dtype=np.uint8) top, left = (new_size - nh) // 2, (new_size - nw) // 2 canvas[top:top + nh, left:left + nw] = resized return canvas, r, (left, top) def restore_box(box, r, pad, orig_shape): """把模型输出的 xyxy 还原回原图坐标,并裁掉越界部分""" left, top = pad h, w = orig_shape[:2] x1 = max(0, min(w - 1, (box[0] - left) / r)) y1 = max(0, min(h - 1, (box[1] - top) / r)) x2 = max(0, min(w - 1, (box[2] - left) / r)) y2 = max(0, min(h - 1, (box[3] - top) / r)) return [x1, y1, x2, y2]

后处理这块,两个参数的取舍决定误报和漏报的平衡点。置信度阈值(conf)调低,小烟小火的召回上去了,白汽、反光也跟着进来;调高,误报少了,但薄烟和远景火点漏掉。IoU 阈值控制 NMS 的合并力度,烟雾框本来就在抖动,IoU 设得太低(比如 0.3)会把同一团烟拆成两三个框,告警图看起来像有多处火情;设到 0.5 以上,相邻的两个真实目标又可能被合掉。

参数建议区间调高的后果调低的后果
conf_thres0.30~0.45漏报增加,薄烟最先丢误报激增,白汽反光全进
iou_thres(NMS)0.45~0.55同团烟多框,告警图误判为多点相邻火点被合并,数不准
输入尺寸640(常规)/960(远距)帧率下降,显存吃紧小目标丢失
最小框面积原图 0.02%~0.1%远处小火点漏掉噪点被当目标

最小框面积这个过滤条件我是强烈建议加的,模型偶尔会在纹理复杂的地面吐出一个几像素的框,置信度还不低,肉眼一看就是噪声。按原图面积比例过一道,能干掉相当一部分无意义的框。

3.3 告警图的叠框、标注与落盘

告警图是这个工具最直接的交付物,值班员不看日志只看图,所以画得好不好直接影响可用性。有两个坑反复出现:一是cv2.putText不支持中文,直接传中文标签会显示成一串问号;二是所有框都画在缩放后的图上,再放大保存,结果是框线糊、字看不清。

import os import time def draw_alerts(frame, dets, warn_text="FIRE/SMOKE"): """在原分辨率上叠框:线宽和字号按短边自适应,避免小图看不见、大图太细""" vis = frame.copy() h, w = vis.shape[:2] thickness = max(2, int(round(min(h, w) / 400))) font_scale = max(0.6, min(h, w) / 1200.0) for d in dets: x1, y1, x2, y2 = [int(v) for v in d["box"]] # 火焰用红、烟雾用橙,BGR 顺序别写反 color = (0, 0, 255) if d["cls_name"] == "fire" else (0, 165, 255) cv2.rectangle(vis, (x1, y1), (x2, y2), color, thickness) label = "%s %.2f" % (d["cls_name"], d["score"]) tw, th = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)[0] cv2.rectangle(vis, (x1, y1 - th - 8), (x1 + tw + 6, y1), color, -1) cv2.putText(vis, label, (x1 + 3, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (255, 255, 255), thickness) stamp = time.strftime("%Y-%m-%d %H:%M:%S") cv2.putText(vis, stamp, (10, h - 12), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 255, 0), thickness) return vis def save_image_unicode(path, img, quality=90): """中文路径安全落盘:imencode 到内存缓冲,再二进制写文件""" os.makedirs(os.path.dirname(path), exist_ok=True) ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), quality]) if not ok: raise IOError("编码失败: %s" % path) buf.tofile(path)

thickness和font_scale按短边自适应这一手是从血泪经验里来的:1080p 的图上用 2 像素线宽,缩略图预览时框几乎看不见;反过来在 720p 上用 4 像素,框粗得像马赛克。时间戳一定要烧进图里,因为文件名可能被人工改名、被下载工具重命名,图本身的时间戳是最后一道追溯依据。如果确实需要中文标签,常见做法是用 PIL 加载一个中文字体画完再转回 ndarray,代价是每帧多一次转换,只建议在最终落盘那一次转,不要每帧都转。

4. 告警抑制:连续帧确认、冷却窗口与告警图归档

检测器单帧输出的结果是不能直接拿去告警的。烟雾框在相邻帧之间会来回跳,一团烟有时被框成三块、有时又消失一帧,直接按帧触发的结果是一小时刷出几百张图,值班员看两张之后就不看了。告警抑制不是可选项,是让这个工具从「能跑」变成「能用」的分界线。

4.1 连续帧确认:用滑动窗口压掉单帧误报

「连续 N 帧都命中才告警」这个思路是对的,但实现上不建议用严格的连续计数。严格连续的问题是只要中间夹一帧漏检,计数就清零,而烟雾漏检在实拍里非常常见。我一般用固定长度的滑动窗口,窗口内命中次数超过阈值就认为成立。

from collections import deque class ConfirmGate: """滑动窗口确认:窗口内命中 hit 次才放行,比严格连续更抗单帧抖动""" def __init__(self, window=8, hit=3): if hit > window: raise ValueError("hit 不能大于 window") self.window = window self.hit = hit self.buf = deque(maxlen=window) # maxlen 固定,防止无限增长吃内存 def update(self, present, cls_name="smoke"): self.buf.append(1 if present else 0) if len(self.buf) < self.window: return False return sum(self.buf) >= self.hit def reset(self): self.buf.clear()

参数要结合抽帧间隔来理解。按 1fps 抽帧、window=8就是 8 秒的观察窗,hit=3表示 8 秒里有 3 帧看到烟才确认。如果是 0.5fps,同样的数字对应 16 秒,反应就太慢了。火焰建议比烟雾激进得多:window=4, hit=2,因为火灾的确认成本远低于误报成本;烟雾则可以放宽到window=10, hit=4,尤其在户外场景,白汽和薄雾的持续时间往往比真烟更长,窗口拉长反而有助于区分。

同一路视频里如果有多个目标类别,确认门要按类别分开建,不能共用一个 buffer,否则火焰命中的帧会「帮」烟雾攒票,两个都变灵敏。

4.2 冷却窗口:别让一个火点刷出两百张图

确认通过之后还有一层:冷却。同一个持续燃烧的火点,在确认放行之后如果每帧都触发,一晚上能写出几千张几乎一样的图,磁盘压力和后续人工筛选成本都很高。冷却逻辑就是同类别在一个时间窗内只允许输出一次。

import time class Cooldown: """按类别分别冷却,冷却期内只计数不落图""" def __init__(self, seconds_map=None): self.seconds_map = seconds_map or {"fire": 20, "smoke": 90} self.last_ts = {} self.suppressed = {} def allow(self, cls_name, now=None): now = now or time.time() gap = self.seconds_map.get(cls_name, 60) last = self.last_ts.get(cls_name, 0) if now - last >= gap: self.last_ts[cls_name] = now self.suppressed[cls_name] = 0 return True self.suppressed[cls_name] = self.suppressed.get(cls_name, 0) + 1 return False

冷却时长的设置要看告警的处置周期。火焰设 20 秒左右,逻辑是「已经报过火警了,值班员正在处置,20 秒内再报一次没意义,但也不能太久,否则火势扩大没人知道」。烟雾设 90 秒,因为它本来就慢,10 秒内报三次完全是噪声。suppressed计数器别丢掉,它记录的是被压掉的次数,事后复盘时能看出某个点位是不是长期在误报边缘反复触发——这种点位比偶发误报更值得处理。

冷却只用时间戳,不用帧号。因为重连之后帧号会归零,用帧号判断冷却会导致重连后立刻重复告警。

4.3 告警图的命名、分桶与回查

告警图是证据,命名和目录结构决定了半年后还能不能找回来看。我见过把所有图平铺在一个目录里的做法,跑到第三天目录里两万多张图,Windows 资源管理器打开就卡死。

推荐按「点位 / 日期 / 类别」三层分桶,文件名里带时间戳和序号:

import os import time def make_alert_path(root, cam_id, cls_name, ts=None, seq=0): """按 点位/日期/类别 分桶,文件名含到毫秒的时间戳,避免同秒覆盖""" ts = ts or time.time() t = time.localtime(ts) day = time.strftime("%Y%m%d", t) stamp = time.strftime("%Y%m%d_%H%M%S", t) + "_%03d" % int((ts % 1) * 1000) safe_cam = "".join(c for c in str(cam_id) if c.isalnum() or c in "-_") or "cam" fname = "%s_%s_%04d.jpg" % (safe_cam, stamp, seq) return os.path.join(root, safe_cam, day, cls_name, fname)

safe_cam那行过滤不是多余的。点位 ID 如果是从配置文件里读的,偶尔会带斜杠或冒号,拼进路径要么报错要么写到意料之外的目录。毫秒后缀是为了防同秒覆盖:一路流在 1fps 采样下一个类别同一秒内最多出一张,但多路视频聚合到一个目录时就会撞。

配套建议写一个轻量索引,哪怕只是一个追加写的 JSONL,每行记录路径、点位、类别、置信度、框坐标、触发时间和被压掉的次数。真出事后回查时,按点位和时间段过滤 JSONL 比翻目录快一个数量级。

5. 避坑与排查:拉流卡死、误报泛滥、内存爬升、输出异常

这一章基本都是踩出来的,每条按现象、原因、解决三段写。有些问题第一次遇到会以为是模型不行,其实跟模型一点关系没有。

5.1 拉流侧:延迟越滚越大与断流不退出

现象:工具跑十几分钟后,画面里的时间戳比实际时间落后二十多秒,再往后干脆不动了,但进程还活着,日志也不报错。

原因:这是典型的解码缓冲堆积。上游相机按 25fps 稳定推流,下游检测只消费 1fps,中间的解码缓冲越积越多,read()拿到的永远是队列里最老的那一帧。表现出来是「延迟」,实际是「排队」。

解决:三件事一起做。第一,cap.set(cv2.CAP_PROP_BUFFERSIZE, 1),让缓冲尽量只留一帧;第二,抽帧用grab()/retrieve(),跳过的帧不做 BGR 转换,消费速度跟得上;第三,如果下游有多线程处理,给帧队列设maxsize,队列满时丢最老的帧而不是阻塞。

现象:相机断电或交换机重启之后,进程既不退出也不重连,一直挂着。

原因:read()在某些后端下会阻塞很长一段时间,或者返回False之后外层没有重试分支,循环直接跳出但主线程还在等。

解决:设stimeout,让阻塞有上限;外层套重连循环并做指数退避;日志里打上重连次数和累计失败帧数,方便判断是偶发还是链路彻底断了。

5.2 误报侧:白汽、车灯、夕阳与反光

现象:冷却塔、蒸汽管道、冬季呼吸口附近,烟雾告警一天几十条,截图点开看全是白汽。

原因:白汽和烟在像素层面高度相似,都是低对比度、边缘模糊的灰白色团块。检测器学到的是「模糊的浅色区域」,两者在特征空间里挨得很近。

解决:三个方向。一是补难负样本重训,把现场的白汽截图加进负样本集,这是最有效的;二是给这些固定点位配 ROI 掩膜,水塔、排气口区域直接屏蔽,别让模型看;三是提高烟雾的窗口阈值,window=10, hit=5,白汽往往一阵一阵的,真烟则是持续扩散,窗口拉长对两者区分有帮助。

现象:夜间车灯扫过地面、工地电焊弧光、夕阳直射镜头,被判成火焰。

原因:火焰的高亮和暖色特征和这些光源重叠。尤其是焊接弧光,颜色分布几乎和火焰一致。

解决:加框内亮度上限过滤,像素几乎饱和到 255 的框直接丢掉;对已知有焊接作业的时段或区域,临时降灵敏度或者干脆停用火焰通道。还有一个取巧的办法是加运动一致性判断,真火焰的轮廓是持续抖动的,车灯扫过是一次性的,用相邻帧的框 IoU 序列能筛掉一部分。

5.3 资源侧:内存与显存缓慢爬升

现象:连续跑 8 小时后 RSS 从 400MB 涨到 3GB,最后被系统 OOM 掉。

原因:重连时没有release(),句柄和解码上下文泄漏;告警图列表忘了设maxlen;日志里把整帧图像对象挂在了记录上。这三个是最常见的。

解决:重连路径用try/finally保证release();所有deque显式设maxlen;日志只打路径和元数据,不打图像对象。上线前建议挂个简单的内存采样,每 10 分钟记一次 RSS,曲线抬头就能定位到是哪次改动引入的。

现象:GPU 显存随着运行时间线性上涨,最后报 OOM。

原因:推理没包在torch.no_grad()里,计算图被保留;或者每次推理都新建张量而没有复用缓冲区。

解决:推理统一加with torch.no_grad():;把预处理结果写进预分配的数组;如果确实需要长期稳定,加一个定时重启策略(比如每 24 小时重启进程),虽然不优雅,但在现场是最省事的兜底。

5.4 输出侧:中文路径、编码器不可用与糊图

现象:告警图有时候保存成功、有时候静默失败,日志里没有任何异常。

原因:cv2.imwrite遇到中文路径返回False而不抛异常,如果代码没检查返回值,就变成「静默失败」。

解决:统一走imencode+tofile,并检查imencode的返回值,失败就抛出来。读图侧同理,np.fromfile+imdecode。

现象:导出带框视频时报Could not open codec,或者文件生成出来是 0 字节。

原因:容器和编码器不匹配。mp4v在多数 OpenCV 发行版里可用,avc1/H264依赖系统里的编码库,不一定装了。

解决:先试mp4v,失败再降级到XVID+.avi;同时确认写入尺寸和帧的实际尺寸完全一致,宽高必须是偶数,奇数尺寸会导致编码器直接拒绝。

现象:告警图点开看,框的位置偏了十几像素,或者框线糊成一团。

原因:框是在 letterbox 之后的图上画的,或者坐标还原时忘了减 padding、忘了除缩放比。

解决:所有绘制都在原分辨率图上做,缩放比和偏移在还原时一次性算清;调试阶段可以先把还原后的框画出来和原图对比,偏差肉眼可见。

6. 用一段真实视频做阈值回归,把参数钉死

置信度阈值这个东西,靠肉眼看几张图调出来的结果基本不可靠。同一段视频,conf 从 0.30 调到 0.35,可能只是某团白汽消失了,也可能顺带把一处真烟也丢了,肉眼盯十帧是看不出来的。我后来固定下来的做法是做一个最小回归集,每次改参数都跑一遍,用数字说话。

回归集不需要大,但要分层。正样本我一般选 15~20 段,覆盖白天明火、夜间明火、初期薄烟、浓烟四类,每段 30 秒到 1 分钟;难负样本选 25~30 段,专挑现场最容易误报的:蒸汽排放、雾天清晨、车灯扫射、焊接作业、水面反光、夕阳直射。每段标注只需要一句话:这段里有没有真火真烟、大概在第几秒出现,不用逐帧框标。

然后写一个批量跑格点的脚本,固定模型和输入尺寸,只扫 conf:

import json import subprocess def run_grid(video_list, conf_list, out_json="grid_result.json"): """对每个 conf 跑一遍回归集,统计命中段数和误报段数""" result = [] for conf in conf_list: hit, miss, false_alarm, clean = 0, 0, 0, 0 for item in video_list: cmd = [ "python", "detect_cli.py", "--source", item["path"], "--conf", str(conf), "--save-dir", "./_tmp_grid", "--json-out", "./_tmp_grid/events.json", ] subprocess.run(cmd, check=True, capture_output=True) with open("./_tmp_grid/events.json", "r", encoding="utf-8") as f: events = json.load(f) fired = len(events) > 0 if item["has_target"]: hit += 1 if fired else 0 miss += 0 if fired else 1 else: false_alarm += 1 if fired else 0 clean += 0 if fired else 1 result.append({ "conf": conf, "hit": hit, "miss": miss, "false_alarm": false_alarm, "clean": clean, }) with open(out_json, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) return result if __name__ == "__main__": with open("regression_set.json", "r", encoding="utf-8") as f: videos = json.load(f) rows = run_grid(videos, [0.25, 0.30, 0.35, 0.40, 0.45, 0.50]) for r in rows: print(r)

脚本本身没什么技巧,价值在于把「感觉」换成一张表。跑完通常长这样:

conf正样本命中正样本漏报难负样本误报干净通过
0.2519/20114/2814
0.3019/2019/2819
0.3518/2024/2824
0.4016/2041/2827
0.4513/2070/2828
0.5011/2090/2828

看完这张表,选点就很清楚了:0.35 是命中率还守得住、误报已经明显下降的拐点;再往上调到 0.40,误报几乎清零,但正样本掉了 4 段,多掉的那 2 段里如果有初期薄烟,这个代价在现场是不能接受的。最后通常落在 0.32~0.38 之间,配合前面说的连续帧确认,能在保住召回的同时把误报再压一档。

参数选定之后一定要做快照,别让它散落在代码里。我一般写一个config.yaml,把 conf、iou、输入尺寸、抽帧间隔、确认窗口、冷却时长、ROI 掩膜路径全部列进去,随模型文件和回归结果一起版本化:

# config.yaml —— 一次调参一次快照,改之前先复制一份带日期后缀 model: path: models/fire_smoke.onnx input_size: 640 detect: conf: 0.35 iou: 0.50 min_box_ratio: 0.0005 classes: [fire, smoke] sample: interval_sec: 1.0 confirm: fire: {window: 4, hit: 2} smoke: {window: 10, hit: 4} cooldown_sec: fire: 20 smoke: 90 roi: mask_dir: masks/ enable: true

这套流程跑熟之后,改任何参数都有据可依:先改 yaml,再跑格点,对照表看是赢了还是输了,输了就回滚。之前吃过一次亏,为了压白汽把 conf 从 0.34 直接提到 0.55,现场确实不报了,两周后一处纸箱阴燃从头到尾没触发,事后翻录像才看见。从那以后我每次动阈值,都强制先把回归集跑一遍,哪怕只改 0.01,也把结果表附在配置文件的注释里。希望这套流程帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询