简介: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 | 环境变量 | tcp | UDP 丢包会导致花屏和马赛克,检测框乱跳 |
| stimeout | 环境变量 | 5000000(5s) | 单位微秒,控制建连和读超时 |
| CAP_PROP_BUFFERSIZE | VideoCapture | 1 | 减小解码缓冲,抑制延迟累积 |
| 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 += 1fps那一行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_thres | 0.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.25 | 19/20 | 1 | 14/28 | 14 |
| 0.30 | 19/20 | 1 | 9/28 | 19 |
| 0.35 | 18/20 | 2 | 4/28 | 24 |
| 0.40 | 16/20 | 4 | 1/28 | 27 |
| 0.45 | 13/20 | 7 | 0/28 | 28 |
| 0.50 | 11/20 | 9 | 0/28 | 28 |
看完这张表,选点就很清楚了: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,也把结果表附在配置文件的注释里。希望这套流程帮到你。
本文还有配套的精品资源,点击获取