
简介LNTON羚通烟火识别算法工具包面向需要快速验证烟火检测与烟雾识别能力的开发者、算法学习者和项目集成人员围绕图片、RTSP实时流及mp4视频三类输入提供可运行的检测工具输出带告警框的识别图片可用于安防监控、园区消防预警等场景的效果验证与二次开发。压缩包共874个文件约324.75MB包含大量jpg样例与告警叠框图、mp4测试视频、模型权重、动态库、可执行程序以及bat批处理、sh脚本、pdf/txt使用文档等覆盖样例生成、批量处理、环境依赖与运行说明。已有243人学习说明其在烟火分析、烟雾分析类工具中具备一定参考价值。读者可借助现成工具与文档完成图片、实时流和视频文件的检测演示结合样例素材核对告警叠框效果并据此理解模型调用、依赖配置与工程集成思路减少从零搭建调试环境的时间。1. 从一张误报图说起烟火识别工具到底能替你干哪段活凌晨两点摄像头把远处厂区的路灯拍成一团橘黄系统连推了十七条告警。这套烟火识别与烟雾检测工具包解决的正是这类能不能用的问题一张图、一段 mp4、一路 RTSP 实时流喂进去吐出来的是叠了告警框的画面框上带类别烟 / 火和置信度。它面向的并非实验室而是园区、仓储、工地、林区这些摄像头早就装好、缺的只是一层结构化识别能力的现场。适合两类人一类是要在一两周内做出可演示效果的应用开发者另一类是手上囤了一堆历史录像、想先离线验证算法到底敢不敢上线的实施人员。硬件不用换链路能先跑通这两点决定了它值不值得占一块硬盘。2. 烟和火不是一类目标算法侧的特征差异与后处理逻辑2.1 火焰靠颜色与闪烁烟雾靠纹理与半透明火焰在像素层面非常好认高饱和度的红橙黄、亮度饱和溢出、边缘不规则且随时间高频抖动。早期方案用 HSV 阈值加面积增长率就能筛出大半但太阳反光、车尾灯、焊接火花会一起被捞进来所以现在通行的做法是训一个目标检测模型把颜色先验当特征交给网络自己学而不是写死在阈值里。烟雾完全是另一回事。它几乎没有固定颜色浅灰、偏白、偏蓝都可能边缘是软的和背景之间不存在清晰梯度还会随着风缓慢扩散、变淡、消失。这意味着单帧图像上烟雾的可分性远低于火焰模型往往要靠上下文——烟雾下方的火源、烟雾遮挡背景造成的纹理丢失、区域内的运动模糊。我给一个经验值同一套数据训出来的模型火焰类别的 mAP 通常比烟雾类别高出 15 到 25 个百分点这不是模型差是任务本身难。所以评估这套工具时必须把两类分开看指标别被一个平均值糊弄过去。还有时间维度。火焰有闪烁频率正常燃烧大致在 5 到 15 Hz 之间波动这是区分火和红色物体最有效的线索之一烟雾则是持续的单向扩散。图片输入拿不到这个信息所以单图检测的误报率天然高于视频。如果你只有图片能测就把置信度阈值抬到 0.5 以上再下结论。2.2 检测头与后处理置信度、NMS、面积过滤三件套推理输出通常是一堆候选框每个框带类别分数。真正决定这条告警出不出的是三道闸门依次过滤。第一道是置信度阈值。它卡的是模型有多确定不是你有多想报警。设太低路灯、晚霞、蒸汽管道全进来设太高早期小火苗因为目标小、被遮挡而漏掉。烟火检测里有个反直觉的现象漏报的代价远大于误报所以阈值不能一味抬高得靠 ROI 和冷却窗口去压误报而不是把闸门焊死。第二道是 NMS非极大值抑制。同一团烟会被模型预测出十几个框NMS 按 IoU 合并。IoU 设太大比如 0.7框会重叠堆积画出来的告警图糊成一坨设太小比如 0.3相邻的两个真实目标会被误合并成一个。烟火场景的常见取值是 0.45 到 0.5。第三道是面积过滤也是最容易被忽略的一道。模型经常在大片昏暗区域里给出一个覆盖半个画面的巨大烟雾框置信度还不低。我的做法是按输入分辨率设面积上下限框面积小于原图 0.05% 的直接丢噪声大于 60% 的也丢多为背景误判并且要求宽高比落在 0.2 到 5 之间。def area_filter(box, w, h, min_ratio0.0005, max_ratio0.6): 按相对面积过滤异常框w/h 为原始帧尺寸 bw box[2] - box[0] bh box[3] - box[1] ratio (bw * bh) / float(w * h) aspect bw / max(bh, 1e-6) return min_ratio ratio max_ratio and 0.2 aspect 5.0这段过滤放在 NMS 之后执行最省事此时框数量已经收敛逐框算一次相对面积开销可以忽略。参数上min_ratio对 1080p 画面大约等于 1000 个像素刚好能滤掉远处的零星噪点max_ratio设 0.6 是给近距离大烟雾留余量如果你的机位普遍贴得很近可以放到 0.75。2.3 三类输入的预处理差异图片、mp4、RTSP 走的是同一个模型和同一套后处理但喂进去之前各有各的讲究。图片输入最简单注意两件事一是保持原始分辨率做 letterbox 缩放别直接用cv2.resize拉伸否则宽高比失真会让细长的火苗被压扁二是批量处理时控制并发GPU 显存不够时宁可按 4 张一批也不要开 32 个进程各自加载一份模型。mp4 文件的核心是解码别成为瓶颈。常见的坑是用cv2.VideoCapture逐帧读然后每帧都做一次推理结果处理速度只有解码速度的三分之一一段十分钟的视频要跑半小时。实际部署里一般会按固定间隔跳帧检测比如每 5 帧检一次、中间帧复用上一次的结果或者直接用CAP_PROP_POS_FRAMES跳到指定帧。RTSP 实时流比前两者都麻烦。默认的视频采集对象会缓存若干帧你的推理速度一旦跟不上延迟就会越积越大最后画面延迟几十秒。解决办法是把缓冲区设成 1并单独开一个线程持续read()丢弃旧帧主线程只取最新帧做推理。另外 RTSP 连接是会断的网络抖动、摄像头重启都可能导致read()返回 False必须有重连逻辑并且重连要有退避间隔否则会在几秒内疯狂重连把设备拖死。3. 跑通三条输入链路图片、mp4、RTSP 的调用与叠框输出3.1 环境与模型文件约定依赖尽量少能用推理运行时解决的不要引入整套深度学习框架。常规组合是 OpenCV 负责解码和画图NumPy 负责张量搬运ONNX Runtime 或同类推理后端负责前向计算。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install opencv-python4.8 numpy1.24 onnxruntime-gpu如果机器上没有独立显卡把onnxruntime-gpu换成onnxruntime即可代码一字不改只是会话初始化时 CUDA 提供者不可用会自动回退到 CPU。目录按下面的结构放后面所有示例都基于这个约定project/ ├── models/fire_smoke.onnx # 检测模型 ├── data/ # 待检测图片、视频 ├── output/ # 告警图、日志 └── detector.py # 推理封装模型输入通常是 1×3×640×640 的归一化张量输出是 [1, 84, 8400] 或 [1, 8400, 85] 这类形状具体看导出的检测头版本。后处理里我会先做一次形状判断避免换模型时整段重写。3.2 单帧推理letterbox、前向、解码、叠框先写一个可复用的检测器类三条链路共用。import cv2 import numpy as np import onnxruntime as ort CLASSES [smoke, fire] # 顺序必须和训练时一致 COLORS [(0, 200, 255), (0, 0, 255)] # 烟用橙黄火用红 class FireSmokeDetector: def __init__(self, model_path, conf0.35, iou0.45, size640): self.conf, self.iou, self.size conf, iou, size providers [CUDAExecutionProvider, CPUExecutionProvider] self.sess ort.InferenceSession(model_path, providersproviders) self.in_name self.sess.get_inputs()[0].name def _letterbox(self, img): 等比缩放 灰边填充返回画布、缩放比、左/上偏移 h, w img.shape[:2] r min(self.size / h, self.size / w) nw, nh int(round(w * r)), int(round(h * r)) canvas np.full((self.size, self.size, 3), 114, np.uint8) left, top (self.size - nw) // 2, (self.size - nh) // 2 canvas[top:top nh, left:left nw] cv2.resize(img, (nw, nh)) return canvas, r, left, top def _nms(self, boxes, scores, thr): 标准 NMS按分数降序贪心合并 idx np.argsort(scores)[::-1] keep [] while idx.size: i idx[0] keep.append(i) if idx.size 1: break rest idx[1:] xx1 np.maximum(boxes[i, 0], boxes[rest, 0]) yy1 np.maximum(boxes[i, 1], boxes[rest, 1]) xx2 np.minimum(boxes[i, 2], boxes[rest, 2]) yy2 np.minimum(boxes[i, 3], boxes[rest, 3]) inter np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None) a_i (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1]) a_r (boxes[rest, 2] - boxes[rest, 0]) * (boxes[rest, 3] - boxes[rest, 1]) iou inter / np.maximum(a_i a_r - inter, 1e-9) idx rest[iou thr] # 保留与当前框重叠度不高的 return keep def detect(self, frame): 输入 BGR 帧输出 [(x1,y1,x2,y2,cls_id,score), ...] h, w frame.shape[:2] canvas, r, left, top self._letterbox(frame) blob canvas[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 out self.sess.run(None, {self.in_name: blob})[0] pred np.squeeze(out) if pred.shape[0] pred.shape[1]: # [84,8400] - [8400,84] pred pred.T boxes, scores, clses [], [], [] for row in pred: cls_scores row[4:] cid int(np.argmax(cls_scores)) score float(cls_scores[cid]) if score self.conf: continue cx, cy, bw, bh row[0], row[1], row[2], row[3] x1 (cx - bw / 2 - left) / r y1 (cy - bh / 2 - top) / r x2 (cx bw / 2 - left) / r y2 (cy bh / 2 - top) / r x1, y1 max(0, x1), max(0, y1) x2, y2 min(w - 1, x2), min(h - 1, y2) if x2 - x1 2 or y2 - y1 2: # 逆映射后塌成点的丢掉 continue boxes.append([x1, y1, x2, y2]); scores.append(score); clses.append(cid) if not boxes: return [] boxes, scores, clses map(np.array, (boxes, scores, clses)) keep self._nms(boxes, scores, self.iou) return [(*(boxes[i].astype(int)), int(clses[i]), float(scores[i])) for i in keep]几个关键点值得单独说。_letterbox里的填充值固定用 114这不是随手写的数字而是主流检测训练流程默认的灰边推理时保持一致能避免边缘处的特征偏移。返回的偏移量left、top是逆映射的关键——很多框画歪了的问题本质上就是后处理时忘了减去这两个值再除以缩放比。_nms用循环实现看着不优雅但对烟火场景足够单帧经过置信度过滤后通常只剩几十个框写复杂了反而容易出错。IoU 阈值通过构造参数传入方便按机位调整。detect里先判断输出形状再转置是为了兼容不同检测头的导出格式。如果你的模型输出是 [1, 8400, 85]多一类背景或坐标数量不同这行判断会自动跳过转置。类别数和CLASSES长度要对应row[4:]的起始下标也要跟着坐标维度走这些地方换模型时最容易翻车。画框和落盘单独抽出来方便不同链路复用def draw_and_save(frame, dets, save_path, min_area_ratio0.0005): h, w frame.shape[:2] vis frame.copy() for x1, y1, x2, y2, cid, score in dets: if (x2 - x1) * (y2 - y1) / float(w * h) min_area_ratio: continue # 小噪点不画但保留在结果里供统计 color COLORS[cid % len(COLORS)] cv2.rectangle(vis, (x1, y1), (x2, y2), color, 2) label f{CLASSES[cid]} {score:.2f} (tw, th), _ cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1) cv2.rectangle(vis, (x1, y1 - th - 8), (x1 tw 4, y1), color, -1) cv2.putText(vis, label, (x1 2, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1, cv2.LINE_AA) cv2.imwrite(save_path, vis) return vis标签底色用类别色、文字用白色是为了在逆光或夜间画面上仍然能读清。min_area_ratio只影响可视化不影响返回的检测列表——后续要做统计的时候小框数据还在不会被悄悄吞掉。3.3 mp4 逐帧检测与告警图落盘视频文件的处理逻辑是跳帧推理 命中即存。不跳帧的话1080p 视频在 CPU 上跑起来大概 3 到 5 FPS十分钟片子要跑一小时以上。import os, cv2, time def run_video(det, video_path, out_dir, stride5, cooldown15): os.makedirs(out_dir, exist_okTrue) cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) or 25.0 total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) idx, last_hit, hits 0, -10 ** 9, 0 t0 time.time() while True: ok, frame cap.read() if not ok: break if idx % stride ! 0: # 非关键帧直接跳过解码后的处理 idx 1 continue dets det.detect(frame) if dets and idx - last_hit cooldown * fps / stride: name f{os.path.basename(video_path)}_{idx:06d}.jpg draw_and_save(frame, dets, os.path.join(out_dir, name)) last_hit idx hits 1 idx 1 cap.release() print(fframes{total} hits{hits} cost{time.time()-t0:.1f}s)stride5表示每 5 帧检一次25 fps 的视频相当于每秒检 5 次对烟火这种持续时间通常超过 2 秒的目标完全够用。cooldown的单位是秒代码里换算成了帧序号差意思是同一段视频里两次落盘至少隔 15 秒避免同一团烟存出上百张几乎一样的图。这个冷却值在调的时候要注意它乘的是stride的倒数关系改了跳帧步长之后冷却的实际时长会变别只改一个参数。idx是逐帧累加的不用CAP_PROP_POS_FRAMES去 seek因为部分编码格式下 seek 的代价比顺序解码还高。文件名里带上帧号是为了出问题时能直接定位回原视频的那一秒。3.4 RTSP 拉流缓冲、跳帧与断线重连实时流的核心矛盾是摄像头一直在推、模型算不了那么快。理想状态是永远处理最新的一帧旧帧该丢就丢。import threading, time, cv2 class StreamReader: 独立线程持续读取只保留最新一帧 def __init__(self, url): self.url url self.frame None self.ok False self._stop False self._open() def _open(self): self.cap cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 关键缓冲压到最小 def start(self): threading.Thread(targetself._loop, daemonTrue).start() return self def _loop(self): fail 0 while not self._stop: ok, frame self.cap.read() if not ok: fail 1 if fail 30: # 连续失败才重连避免抖动误判 self.cap.release() time.sleep(min(2 ** (fail // 30), 30)) # 指数退避封顶 30s self._open() fail 0 continue fail 0 self.frame, self.ok frame, True time.sleep(0.005) # 让出 CPU防止空转吃满单核消费端按固定节奏取帧取不到就跳过这一轮不做阻塞等待def run_stream(det, url, out_dir, interval0.2, save_every30): os.makedirs(out_dir, exist_okTrue) reader StreamReader(url).start() time.sleep(1.0) # 等首帧 last_save, n 0.0, 0 while True: frame reader.frame if frame is None: time.sleep(0.05) continue dets det.detect(frame.copy()) now time.time() if dets and now - last_save save_every: draw_and_save(frame, dets, f{out_dir}/rtsp_{int(now)}.jpg) last_save now n 1 time.sleep(interval) # 控制推理节奏别把 GPU 打满CAP_PROP_BUFFERSIZE设 1 是最有效的一行很多实时流延迟越来越大的问题都出在这里。重连用指数退避并且要求连续失败 30 次以上才触发是为了区分真断了和网络抖了一下。interval0.2意味着每秒最多推理 5 次和 mp4 的跳帧节奏对齐这个值要结合显卡算力调模型单帧耗时 100 ms 的话interval 设 0.1 就已经跑满了。4. 参数怎么定置信度、ROI、告警冷却与分辨率的四组取值4.1 置信度与 NMS 的联动这两个参数经常被分开调其实它们相互影响。置信度降低会放进更多候选框NMS 的压力变大此时如果 IoU 阈值又偏大就会出现几十个框叠在一起、画面上密密麻麻的情况。反过来置信度抬高、IoU 调小又可能把真实目标合并掉。我的调法是固定一个再动另一个先把 IoU 定在 0.45用一批带标注的验证帧扫置信度从 0.2 到 0.7 每 0.05 取一档记录每档的检出数和误报数画出曲线找拐点拐点定下来之后再回头微调 IoU看有没有明显的漏合并或误合并。参数建议区间调高的后果调低的后果置信度 conf0.30 ~ 0.45早期小火苗漏检明显增加路灯、反光、蒸汽大量误报NMS IoU0.45 ~ 0.50同一目标出多个重叠框相邻两个目标被合成一个最小面积比0.0003 ~ 0.001远处小火点被滤掉噪点被当成目标最大面积比0.6 ~ 0.75近景大烟雾被滤掉整片背景被判为烟雾表格里的区间是通用起点不是标准答案。换个机位、换个季节冬天蒸汽多、夏天逆光多最优点会漂移所以每次上线前都要用现场素材重扫一遍。4.2 用 ROI 把误报源挡在框外调参调不掉的误报八成能靠 ROI 解决。典型误报源都很固定路口的红绿灯、厂区的探照灯、食堂的排烟口、阳光穿过玻璃的反光区。这些位置在画面里是不变的与其和模型较劲不如直接框掉。做法有两种。软 ROI 是在检测后过滤判断框中心是否落在允许区域内不落就丢弃硬 ROI 是推理前把 ROI 外的像素涂黑让模型根本看不到。我更倾向软 ROI——硬 ROI 会破坏画面的上下文信息烟雾判断本来就依赖周边区域的对比涂黑之后烟雾边缘的特征会变反而容易漏。def in_roi(box, roi): roi 为 (x1, y1, x2, y2)判断框中心是否落在区域内 cx (box[0] box[2]) / 2 cy (box[1] box[3]) / 2 return roi[0] cx roi[2] and roi[1] cy roi[3] # 用法dets [d for d in dets if in_roi(d, (120, 200, 1700, 1000))]ROI 不要一次画得太紧边上留 5% 到 10% 的余量因为烟雾会扩散中心点可能飘出硬边界。一个画面支持多个 ROI 时把判断改成落在任一区域内即保留即可。4.3 告警冷却窗口与去重没有冷却的告警系统等于没有告警系统。同一团烟在画面里持续二十分钟逐帧推送会生成上万条记录值班人员第三天就会把通知关掉。冷却时间怎么定看目标的最短持续时长。火焰从起火到明显可见通常有几十秒的爬升期烟雾扩散到能被检测到一般也在十秒以上。所以冷却窗口设 20 到 60 秒是合理的我一般从 30 秒起调。设得太短比如 5 秒等于没设设得太长比如 10 分钟会漏掉同一区域内新起的第二处火点。更稳的做法是带类别和空间的双重去重同类目标、中心点距离在画面宽度的 10% 以内、且在上次告警的冷却窗口内就视为同一次事件只更新最后出现时间不重复推送。def is_duplicate(det, last_events, now, w, cooldown30.0, dist_ratio0.1): cx (det[0] det[2]) / 2.0 cy (det[1] det[3]) / 2.0 for (ecx, ecy, ecid, ets) in last_events: if ecid ! det[4] or now - ets cooldown: continue if ((cx - ecx) ** 2 (cy - ecy) ** 2) ** 0.5 w * dist_ratio: return True return Falsedist_ratio用画面宽度做归一化换分辨率时不用改参数这一点比写死像素距离省心得多。4.4 分辨率与跳帧的吞吐权衡很多人第一反应是把输入尺寸从 640 调到 1280觉得看得更清就能检出更小的火点。实测下来收益有限代价却很明显计算量按面积增长1280 的耗时差不多是 640 的四倍而小目标检出率的提升往往不到 10 个百分点。真正影响小目标检出的是原始画面的分辨率和机位距离不是推理尺寸。如果摄像头本身是 720p放大到 1280 推理只是插值放大不会凭空多出细节。我的处理顺序是先降跳帧频率每秒检 5 次降到 3 次收益立竿见影再降推理尺寸640 到 512耗时降三成左右精度损失可控最后才考虑降画面分辨率。反过来如果算力富余优先加跳帧频率而不是加输入尺寸——时间维度上的连续性对烟雾识别帮助更大。5. 避坑与排查五类翻车现场的现象、原因与解决5.1 框在飘同一团烟被反复告警现象一段视频跑下来输出的告警图里同一团烟的位置帧帧不同框的大小抖得厉害冷却窗口完全压不住。原因模型对烟雾这种无固定边界的目标准确性本来就低相邻帧的预测结果差异大再加上跳帧之后帧间间隔被拉长视觉连续性丢失检测结果更难保持一致。解决把去重逻辑从框位置比对改成框中心点距离 时间窗距离阈值放宽到画面宽度的 10%同时在推理侧把跳帧步长从 5 降到 2 到 3让相邻检测帧的间隔小于 0.2 秒预测结果自然稳定下来。不要把希望寄托在调 IoU 上那是合并同一帧内的框管不了跨帧抖动。5.2 白天好好的一入夜误报翻十倍现象同一台设备白天误报个位数晚上动辄上百条翻看告警图全是车灯、手电、远处的窗户反光。原因夜间画面整体亮度低、噪点重彩色信息基本丢失模型依赖的颜色先验失效同时补光灯造成的局部过曝会形成高亮斑块形态上和火焰接近。解决给夜间单独一套参数置信度上浮 0.1 到 0.15 并启用 ROI 排除固定光源位置有条件的换成带红外补光的机位红外画面下火焰的高亮特征其实更明显但要注意红外画面是灰度的前处理时别强行转成三通道再送进去直接在原图上做通道复制更稳。5.3 RTSP 跑十几分钟就卡死不动现象实时流刚开始正常十几分钟后画面停在某一帧日志也不报错进程还在但不再输出。原因采集对象内部缓冲持续增长读取线程被阻塞或者连接被设备侧静默断开read()一直返回 False 而代码没有重试分支。解决把CAP_PROP_BUFFERSIZE设为 1用独立线程持续读取并只保留最新帧给读取加连续失败计数超过 30 次触发重连并采用指数退避。另外记得在退出时显式release()否则摄像头的会话数会被占满下次连不上还得等设备自己超时。5.4 换了个模型框全画在左上角现象模型文件一换检测结果全挤在画面左上角或者整体偏移几百像素。原因后处理的逆映射没跟着改。不同导出流程的 letterbox 填充值、是否居中填充、坐标是绝对像素还是归一化值都可能不一样直接套用旧代码就会偏。解决拿一张有明显地标的测试图先用已知坐标手算一遍期望框位置再和代码输出比对把 letterbox 的填充值、缩放比、偏移量打印出来核对。归一化坐标的模型要在解码时先乘回输入尺寸再逆映射漏了这一步就会得到一堆 0 到 1 之间的小数坐标画出来全在左上角。5.5 批量处理图片时显存爆掉现象单张图跑得好好的一批量处理就报显存不足或者进程被系统杀掉。原因每个进程或线程各自加载了一份模型会话显存按份数线性增长或者输入张量没及时释放累积占用。解决模型会话全局单例多线程共享同一个会话推理运行时本身是线程安全的批量处理改成生产者-消费者队列控制并发在 2 到 4 之间输入张量用完主动置空别把它挂在长生命周期的对象上。实在要并行优先用多进程分配任务、每个进程处理一批而不是每张图起一个进程。6. 从单次检测到可验证链路告警去重、回归集与一次线上复盘的教训工具跑通只是起点真正决定它能不能留在生产环境里的是有没有一套能自我验证的闭环。我在一个园区项目里吃过亏模型在测试视频上表现很好上线两周后误报率突然翻倍排查半天才发现是新换的一批摄像头白平衡偏冷画面整体泛蓝模型把远处的水汽当成了烟。从那以后我每次接入新设备都会先攒一个最小回归集。回归集的做法很朴素从每个机位取 20 段 30 秒的片段其中 5 段有真实烟火、15 段是已知的误报高发场景晨雾、蒸汽、车灯、夕阳、焊接。每段预先标注好应该在第几秒报警。然后用待上线的参数跑一遍统计两个数命中率和误报数。指标计算方式上线门槛我的习惯事件命中率真事件被检出且时间误差 10s 的比例≥ 85%每小时误报数误报条数 / 总时长≤ 2平均响应延迟从目标出现到首次告警的秒数≤ 5s单帧耗时推理 后处理 150ms这套指标的价值在于它把感觉效果还行变成了可比的数字。换阈值、换模型、换机位跑一遍回归集就知道是变好还是变差不用靠人眼盯屏幕。有个细节值得单独提命中率的统计必须按事件而不是按帧。同一场火被检测到 200 帧和 20 帧对使用者来说没有区别但只要有一帧漏了就会拉低帧级准确率指标会失真。所以我在统计前先按冷却窗口把连续检测结果聚合成事件再比对标注。还有一个经常被忽略的验证手段把告警图和原始帧一起存下来。存告警图是为了事后复核存原始帧是为了回放时能复现问题——很多当时到底是不是误报的争议靠一张叠了框的图是说不清的得看到未标注的原图才能判断。现在每次参数调整我都会强制走一遍回归集 → 对比四项指标 → 留档参数与结果哪怕只是把置信度从 0.35 改成 0.40 这种小改动。看着麻烦但省下的是上线后被叫去现场排查的整晚时间。希望帮到你。本文还有配套的精品资源点击获取