最近在折腾售货柜的视觉识别方案,从需求梳理到最后能稳定跑起来,前后踩了不少坑。尤其是“IPC 拉流 → 抽帧 → YOLO 识别”这条链路,看起来就是一个标准的视频分析流水线,但实际做的时候,从摄像头选型、RTSP 地址解析、拉流稳定性,到抽帧策略、YOLO 模型推理、结果后处理,每个环节都有很多隐性坑点。这篇文章就把我这个项目的完整实战过程写出来,包括每一步的选型逻辑、代码实现、参数调优思路,以及几个印象深刻的排查经历,希望能给正在做类似项目的人一些参考。
先说下项目背景。售货柜需要在顾客开门拿取商品时,实时识别“拿了什么”“拿了几件”,从而自动生成订单。整体方案就是在柜内安装 IPC 摄像头,通过 RTSP 拉取实时视频流,抽取关键帧,用 YOLO 模型做目标检测,识别柜内商品的种类和数量变化,最终确认交易。这个场景对实时性和准确率都有要求,又不能像云端方案那样依赖稳定网络,所以整条识别链路都压在边缘计算设备上。
1. 项目全貌:从需求到方案,为什么是 IPC 拉流 + 抽帧 + YOLO
售货柜识别这条技术路线,业界其实尝试过很多种。早期有用重力感应的,货架下面铺压力传感器,靠重量变化判断商品拿取情况,成本低但没有视觉验证能力,顾客拿A放B这种“等价交换”就识别不了。后来有了视觉方案,其中一个分支是用深度相机做 3D 重建,精准度确实高,但硬件造价也高,而且柜内空间有限,普通售货柜很难大规模铺。剩下最主流的,就是用普通 2D 摄像头(IPC 或 USB 摄像头)加目标检测算法,靠识别画面中商品类别的变化来实现交易判定。
之所以选 IPC 而不是 USB 摄像头,主要是安装灵活性和布线考虑。售货柜内部结构紧凑,USB 摄像头需要靠近主机设备,走线距离受限。IPC 走网线,PoE 供电和数据同线传输,可以装在柜门顶部、层板下方这些更合理的视角位置,而且一个 IPC 可以通过 RTSP 同时供多路算法任务使用(比如识别 + 录像),后续维护也方便。安装示意图上,一个柜子通常装 2~4 个 IPC,分别覆盖不同层架,画面有少量重叠区,减少死角。
流水线环节就是标题里那三件事:
- 拉流(IPC 采集):通过 RTSP 协议从摄像头获取实时视频帧。
- 抽帧(帧率控制):视频流一般是 25fps,但目标检测模型处理一帧需要时间,不能每帧都送进模型,需要按策略抽取关键帧,同时保证不丢失关键动作。
- YOLO 识别(推理 + 后处理):将抽出的帧送入 YOLO 模型,输出目标框类别和置信度,结合前后帧差异分析顾客的拿放行为。
为什么不直接逐帧识别?因为售货柜的算力设备通常是 RK3588、Jetson Orin 这类边缘盒子,YOLO 模型跑一帧需要几十到几百毫秒,视频流的 25fps 意味着每帧间隔只有 40ms,算力根本跟不上。所以“抽帧”不是一个可选项,而是整个流水线能不能稳定运行的前提条件。
整个流水线的架构可以用一张简单的逻辑图概括:
相机端(IPC 采集 RTSP 流) → 拉流端(FFmpeg/OpenCV 解码) → 抽帧模块(按时间/事件策略选帧) → YOLO 推理模块(检测框输出) → 业务逻辑模块(前后帧比对、订单生成)。
中间任何一个环节出问题,都会导致最终识别结果异常。后面我会按流水线的顺序,逐个环节把实现细节和踩坑经历讲清楚。
2. IPC 拉流环节:RTSP 地址解析与稳定性问题
拉流是整条流水线的源头,如果这里不稳定,后面的识别再好也白搭。这一节我会先讲 RTSP 协议地址的组成方式,再讲实际拉流时常用的两种方案(OpenCV 和 FFmpeg),最后重点说几个真实项目中反复出现的拉流问题。
2.1 RTSP 地址格式与摄像头选型注意事项
RTSP 拉流的第一步,是拿到一个正确的流地址。市面上主流厂家(海康、大华、宇视等)的 IPC RTSP 地址格式大同小异,基本都是下面这种:
rtsp://用户名:密码@IP地址:端口/流路径以海康威视为例,常用格式:
rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101其中最后一段“101”表示通道 1 主码流,“102”表示通道 1 子码流。大华的格式类似:
rtsp://admin:password@192.168.1.64:554/cam/realmonitor?channel=1&subtype=0大华的subtype=0是主码流,subtype=1是子码流。主码流分辨率高(比如 2560x1440@25fps),码率大,适合本地录像;子码流分辨率低(如 704x576@25fps),码率小,适合预览或传输带宽受限的场景。
这里有个容易踩的坑:售货柜识别应该用哪个码流?很多人觉得分辨率越高识别越准,直接拉主码流,结果发现柜内空间小、商品密集,主码流虽然清晰,但帧率高、码率大,在边缘设备上解码就占了不少 CPU。我实测下来,识别场景一般用 1080p 的主码流就够了,甚至可以子码流 + 超分辨率。这个后面在 YOLO 输入尺寸部分再细说。
还有一个坑是摄像头编码格式。有些老款 IPC 默认是 H.265 编码,如果你的拉流端(比如 OpenCV 的 FFmpeg 后端)不支持硬解 H.265,CPU 直接吃满。所以选摄像头或者调试时,建议先去 Web 管理后台把编码改成 H.264,识别场景的帧率也不需要很高,15fps 足够。
2.2 基于 OpenCV 的拉流实现:简单但要注意缓冲机制
最简单的拉流方式就是 OpenCV 的VideoCapture:
import cv2 cap = cv2.VideoCapture('rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101') if not cap.isOpened(): print("拉流失败,请检查网络和地址") while True: ret, frame = cap.read() if not ret: print("读取帧失败") break # 后续处理:抽帧、检测 cv2.imshow('frame', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这个方式代码量最少,适合快速验证。但实际项目里,有个非常致命的问题:OpenCV 的缓冲区滞后严重。
cap.read()读到的不是“当前时刻”的画面,而是“若干秒前”解码完的帧。原因是 OpenCV 内部有一个帧队列,如果处理速度跟不上拉流速度,队列会积压,延迟不断增大。在售货柜场景里,顾客开门拿商品就那几秒钟,延迟 3~5 秒意味着你根本看不清顾客拿了什么。
解决办法有几种:
- 清空缓冲区:每次读取前先抓取几帧丢弃,但浪费算力。
- 使用
cv2.CAP_PROP_BUFFERSIZE设置缓冲区大小,但 OpenCV 后端对这个参数支持并不一致。 - 改用 FFmpeg 命令行拉流,通过
-fflags nobuffer和-flags low_delay等参数实现低延迟。
我实际项目里更推荐下面这种用 FFmpeg 低延迟拉流的方案。
2.3 基于 FFmpeg 的低延迟拉流方案
FFmpeg 命令行拉流再通过管道传给 Python 是一种常见做法,兼容性好,延迟也低。核心命令如下:
ffmpeg -rtsp_transport tcp -fflags nobuffer -flags low_delay \ -i "rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101" \ -f rawvideo -pix_fmt bgr24 -an -s 1920x1080 -r 5 pipe:1参数说明:
-rtsp_transport tcp:使用 TCP 传输 RTSP,避免 UDP 丢包导致的画面花屏。-fflags nobuffer:尽最大可能降低延迟。-flags low_delay:同样是为了低延迟。-f rawvideo -pix_fmt bgr24:输出原始视频帧,BGR 格式,方便 Python 直接转换。-an:不要音频。-s 1920x1080:输出分辨率。-r 5:输出帧率限制为 5fps,这相当于在源头就做了抽帧。
Python 端读取管道数据:
import subprocess import numpy as np import cv2 command = [ 'ffmpeg', '-rtsp_transport', 'tcp', '-fflags', 'nobuffer', '-flags', 'low_delay', '-i', 'rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101', '-f', 'rawvideo', '-pix_fmt', 'bgr24', '-an', '-s', '1920x1080', '-r', '5', 'pipe:1' ] proc = subprocess.Popen(command, stdout=subprocess.PIPE, bufsize=10**8) width, height, fps = 1920, 1080, 5 frame_size = width * height * 3 while True: raw = proc.stdout.read(frame_size) if len(raw) != frame_size: print("管道读取异常") break frame = np.frombuffer(raw, dtype=np.uint8).reshape(height, width, 3) # 送到检测流程用管道方案的优点是延迟低、可控性强,缺点是代码复杂、进程管理比较烦,而且 FFmpeg 崩溃时管道会卡死。需要配合超时和重启机制。
2.4 拉流稳定性:断线重连与保活机制
我在项目里最头疼的就是拉流不定时断线。IPC 在持续运行几天后,RTSP 连接经常会出现类似connection refused或超时断线的情况。查了很多资料,有说法是摄像头本身连接数限制,也有 RTSP 会话超时机制的原因。但靠等厂家修复不现实,必须在代码层做断线重连。
我给拉流模块设计了一个自动重连机制,核心逻辑很简单:
- 每帧读取时记录当前时间,如果超过 3 秒没有成功读到完整帧,判定为拉流异常。
- 关闭当前连接,等待 2 秒,重新建立连接。
- 重连超过 5 次仍失败,则发告警到后台,并尝试重启 FFmpeg 进程。
伪代码如下:
def read_frame_with_reconnect(url): cap = open_stream(url) fail_count = 0 while True: ret, frame = read_frame(cap) if ret: fail_count = 0 yield frame else: fail_count += 1 close_stream(cap) time.sleep(2) cap = open_stream(url) if fail_count >= 5: notify_admin("拉流持续异常") fail_count = 0除了重连,还可以在摄像头端配置“断线自动重拨”或看门狗功能。部分 IPC 有“心跳检测”设置项,开启后设备会主动检测 RTSP 连接状态,异常时自动清理旧连接,这对长连接稳定性很有帮助。
2.5 拉流环节的监控指标
拉流模块需要输出的核心指标,建议至少包括这几项:
| 指标 | 说明 | 参考阈值 |
|---|---|---|
| 拉流帧率 | 每秒获取的帧数 | 不低于设定值的90% |
| 帧间隔抖动 | 相邻两帧的时间差波动 | 尽量小于 30% |
| 断线次数 | 单位时间内的断线重连次数 | 一天不超过 3 次 |
| 解码耗时 | FFmpeg/OpenCV 解码一帧的耗时 | 尽量小于 30ms |
这些指标直接决定下游抽帧模块的参数设计。比如拉流帧率如果只有 3fps,那抽帧间隔就必须相应调小,否则关键动作就容易漏。
3. 抽帧策略:如何在控住算力的同时不丢关键动作
抽帧是整个流水线的“水龙头”,拧得太紧会漏掉顾客的拿放动作,拧得太松又会让 YOLO 推理线程堵车。这一节我讲清楚抽帧的几种策略、如何根据业务场景选择,以及一个很多人忽略的关键点:时间戳对齐。
3.1 为什么不能每帧都送进 YOLO
在售货柜场景,IPC 一般是 15~25fps 的视频流,但顾客拿取一个商品的动作大概在 0.5~1.5 秒之间。如果按 25fps 全量送检,一个动作会产生 12~37 帧检测结果,实际上这些帧里的目标位置变化很小,大量计算是重复的。
YOLO 模型的推理速度取决于算力。以 RK3588 的 NPU 为例,YOLOv5s 输入 640x640,推理时间在 30~50ms,看起来很快,但 CPU 解码、图像预处理、后处理(NMS)同样要时间。全流程加起来,一帧可能也就能处理 10~15 帧每秒。而边缘盒子往往还要跑多个摄像头,算力分摊下来,每个摄像头能分到的推理资源更少。
所以抽帧的本质,是在“信息量”和“算力成本”之间找平衡——既要捕捉到顾客所有关键动作,又不能把算力浪费在高度相似的连续帧上。
3.2 三种抽帧策略的适用场景对比
我把常见的抽帧策略总结成三种:定时抽帧、事件触发抽帧、动态自适应抽帧。
定时抽帧是最简单的策略,每 N 秒抽一帧。比如每 0.5 秒抽一帧,1 秒抽 2 帧。优点是实现简单、CPU 算力可控;缺点是如果顾客动作很快,比如快速拿了一瓶饮料塞进包里,0.5 秒的间隔可能会漏掉拿取瞬间的完整证据。
事件触发抽帧以“画面变化”为触发条件,常用运动检测(帧差法/背景建模)来判断是否有人或商品发生变化。没有变化时不抽帧,一旦检测到 ROI 区域有运动,马上提高抽帧频率或全量抽帧。这个策略能有效降低平均算力消耗,同时保证关键事件不遗漏。
动态自适应抽帧结合前两种,平时低频定时抽帧(如 0.5fps)做状态监控,当检测到事件时自动切换到高频模式(如 5fps)捕获细节。这个策略最推荐用于售货柜场景。
3.3 售货柜场景的推荐抽帧配置
我的实际配置如下:
# 抽帧策略配置 normal: interval_ms: 1000 # 正常状态每1秒抽一帧,用于画面监控 detect_interval: 5 # 每隔5帧抽出来的帧做一次轻量级运动检测 event: trigger: motion # 触发条件:运动检测 interval_ms: 200 # 事件触发后每200ms抽一帧,约5fps duration: 3000 # 触发后持续3秒高频抽帧,覆盖顾客拿放动作 recovery: true # 事件结束后恢复低频模式这里有个重要参数:事件持续时间设为 3000ms,覆盖了顾客拿取 + 放回 + 关门整个流程,确保拿到完整动作序列。
运动检测使用的帧差法代码也很简单:
import cv2 def detect_motion(prev_gray, curr_gray, threshold=25): diff = cv2.absdiff(prev_gray, curr_gray) _, thresh = cv2.threshold(diff, threshold, 255, cv2.THRESH_BINARY) motion_ratio = cv2.countNonZero(thresh) / thresh.size return motion_ratio如果motion_ratio超过某个阈值(比如 0.02,即画面中 2% 的像素产生了显著变化),就认为发生事件,切换高频抽帧。
3.4 抽帧环节最容易忽略的:时间戳对齐
这是我在实际项目中吃过亏的地方。抽帧模块在高频和低频之间切换时,如果每一帧没有携带统一的时间戳,下游的“前后帧对比”就会出错。
比如顾客在第 1.1 秒拿了一瓶可乐,抽帧模块因为算力排队,处理到第 1.6 秒的帧时才检测到画面变化,这时如果拿帧的时间戳当事件时间,就会产生 0.5 秒的偏差。在订单系统里,如果叠加其他传感器(重力感应、门磁)的事件时间,时间偏差会导致数据无法对齐,严重影响“变化检测”的准确性。
解决方法是在拉流模块解码的瞬间就为每帧打上时间戳,用time.time()获取系统单调时钟,这个时间戳跟随帧数据一路传到抽帧、推理、后处理模块。代码示例:
import time import cv2 frame_timestamp = time.time() # 后续所有模块都用这一帧的 timestamp 作为判断基准3.5 抽帧对画面质量的影响
标题相关搜索里有个词是“视频怎么抽帧不影响画面”,这里也可以展开说一句。抽帧既然是把帧丢弃,那对单帧画面的“画质”其实没有影响,视频是否卡顿取决于两方面:一是丢帧的均匀性(不要连续丢一长段),二是抽帧后的帧率是否能覆盖业务需要的运动周期。
如果你发现识别的画面模糊、拖影,那不是抽帧的锅,通常有两个原因:IPC 曝光时间太长(柜内光线暗,自动曝光把快门拉长,运动物体就糊了),或者码率设置偏低导致画面压缩块效应。解决方向是补光、调大码率、把快门上限设短(比如 1/100s 以上),和抽帧策略本身无关。
4. YOLO 模型选型与部署:从权重大小到推理细节
抽出来的帧要送进 YOLO 模型做检测。这个环节的坑比较隐蔽,集中在模型选型、预处理参数、后处理细节这几个方面。模型文件好拿,但跑出来效果差,往往就是预处理或后处理细节没对齐。
4.1 边缘设备上选 YOLOv5s 还是 YOLOv8s
YOLO 系列发展到现在,v5、v8、v10、v11 都有各自的使用场景。对于售货柜这种“嵌入式设备 + 实时性要求高 + 商品种类固定”的场景,我的建议是:先考虑 YOLOv5s 或 YOLOv8s,不要盲目追新。
YOLOv8s 在精度上比 v5s 略有提升,训练和部署的生态也更完善,官方仓库直接支持导出 ONNX,对部署阶段更友好。YOLOv5s 的优势是社区资料极多、问题都好查、很多硬件厂商的 NPU 适配都是优先基于 v5 做的。
我在 RK3588 上实测,同样输入 640x640:
| 模型 | 推理耗时(ms) | mAP@0.5(自定义数据集) | 模型大小 |
|---|---|---|---|
| YOLOv5s | 43 | 0.971 | 14.4 MB |
| YOLOv8s | 52 | 0.976 | 22.5 MB |
对于售货柜的商品识别,精度差异不明显,但推理速度差了 9ms,如果接两路摄像头,差距会进一步放大。所以我的建议是:模型先用 YOLOv5s 快速跑通全流程,如果精度不够再换 v8s 或上更大模型。
4.2 输入分辨率:不要无脑 640x640
YOLO 默认输入是 640x640,很多人在边缘设备上也是直接用,这不一定最优。售货柜的摄像头视角覆盖多层货架,每个商品在画面中占比不大,直接用 640x640 输入可能导致小目标检测效果差。
解决思路有两种:一是调大输入分辨率,比如 960x960 或 1280x1280,这能明显提升小目标召回率,但推理耗时也随之增加,边缘设备未必扛得住。二是做局部放大(ROI 裁剪),把每个货架层区域单独切出来,送入模型分别检测。第二种方式对算力更友好,只是需要额外做“层架区域标定”。
我实际采用的是折中方案:整柜画面输入 960x960,同时把每层货架区域做 ROI 裁剪,裁剪后的图也用 640x640 输入检测。这样既保证了整柜的全局检测(用于判断是否有商品被拿空),又对局部关键区域做了增强。
4.3 预处理细节对识别结果的影响
YOLO 的预处理包括 resize、归一化、通道变换等,看起来简单,但有个容易踩的坑:保持训练时的预处理方式与推理时一致。
如果你训练时用的是 YOLO 官方仓库默认的letterbox方式(保持长宽比缩放 + 灰色填充),那推理时也必须用相同的 letterbox 方法;如果训练时直接拉伸(将图像 resize 到固定尺寸,不保持长宽比),推理时也用拉伸。混用会导致目标变形,精度明显下降。
另一个坑是normalize的系数。YOLOv5 和 YOLOv8 默认是将像素值除以 255,映射到 0~1;但有些部署框架默认用0~255的原始像素值输入,忘了做归一化,检测出来的置信度会普遍偏低。这类问题排查起来很隐蔽,所以建议先在本地用测试图验证预处理与后处理流程没问题,再部署到边缘设备。
4.4 后处理:confidence 阈值和 NMS 参数
模型输出的原始结果是大量候选框,需要经过 confidence 阈值过滤和非极大值抑制(NMS)后,才是最终的目标框。
置信度阈值我一般设 0.35~0.45。设太高容易漏检,比如商品被部分遮挡时置信度会下降到 0.3 以下;设太低则会产生很多误检框,给下游“变化检测”造成干扰。NMS 的 IoU 阈值默认 0.45~0.5,在商品密集场景可以适当放宽到 0.55~0.6,避免相邻商品被错误合并。
处理完的检测结果需要输出这样的结构化信息:
[ { "timestamp": 1738375332.12, "object_id": 3, "class_name": "可乐", "confidence": 0.912, "bbox": [320, 180, 380, 280], "size_category": "large" } ]4.5 模型部署格式:ONNX 还是硬件厂商专用格式
边缘设备的推理通常用两种路径:一是通用 ONNX Runtime,二是硬件厂商的 NPU 加速框架(RKNN、TensorRT 等)。我的建议是:先在 ONNX Runtime 上把整条流水线跑通,再针对特定硬件做加速优化。
ONNX 的好处是跨平台好调试,CPU 上也能跑;但推理速度只有 NPU 加速的几分之一。比如 RKNN 在 RK3588 上可以用 NPU 推理,速度比 CPU 快 5~10 倍,但 RKNN 模型转换时有些算子不支持,需要逐个处理,调试成本高。
一个稳妥的推进路径:
- PyTorch 训练 → 导出 ONNX → ONNX Runtime 验证精度和速度。
- 用硬件厂商工具把 ONNX 转成 RKNN/TensorRT。
- 在推理框架里加载专用模型,逐步替换,并做精度对比验证。
4.6 训练数据:售货柜商品识别容易忽略的坑
模型训练这一块,如果你是拿公共数据集(如 COCO)直接推,效果肯定不行。售货柜商品是特定的 SKU(可口可乐、农夫山泉、乐事薯片等),必须自建数据集。有几个坑值得提:
第一,拍摄角度尽量模拟实际安装位置。不要只在网上下载商品图片,最好用与售货柜同视角的实拍图。模型对视角非常敏感,训练时都是俯视图,测试时是侧视图,识别率骤降。
第二,注意光照变化。售货柜内灯光、自然光、不同时段的色温都会有差异,训练数据要覆盖不同光照条件下的样本,必要时做数据增强(亮度、对比度、噪声)。
第三,类别不平衡问题。有些商品可能被拿得多(可乐),有些很少(某个小众零食),如果真实样本不均衡,训练出来的模型对小众商品识别率很差。建议每个类别至少收集 500 张以上有效样本,小众商品可以用在线难例挖掘或复制粘贴增强。
我的数据标注流程:先用摄像头拍一段售货柜空柜视频,人工逐帧挑选有效画面,然后用 LabelImg/LabelStudio 标注,导出 YOLO 格式的 txt 标签文件。数据集规模 3000~5000 张,标注类别控制在 20~30 个以内,在边缘设备上就能达到不错的识别效果。
5. 流水线集成:从“能跑”到“稳定跑”的工程改造
讲完单个环节,再说说把整条流水线串起来时,必须要做的工程化设计。这些设计决定了一个 demo 能不能变成一个能在店铺里 7x24 小时稳定运行的系统。
5.1 用队列解耦:拉流、抽帧、推理各自独立
流水线最忌讳的是一个环节阻塞导致全链路卡死。比如 YOLO 推理某几帧特别慢(可能是画面复杂、检测目标多),如果拉流和推理直接在同一个线程里串行执行,拉流就会被阻塞,画面延迟越来越大。
我的做法是用三个线程 + 两个队列解耦:
- 采集线程:负责从 IPC 拉流,把帧丢进原始帧队列。
- 抽帧线程:从原始帧队列取帧,做运动检测,需要时把帧丢进推理队列。
- 推理线程:从推理队列取帧,执行 YOLO 检测 + 后处理,输出结果。
队列用 Python 的queue.Queue,可以控制最大长度。如果推理速度跟不上,原始帧队列会积压,这时应该丢弃最老的帧而不是强行塞进队列导致内存膨胀。设置maxsize=10,满了就get_nowait()丢掉最早的非关键帧。
5.2 关键帧标记:变化检测的“证据链”
售货柜的交易判定的核心是“变化检测”——对比开门前后的商品检测结果,判断哪些 SKU 数量减少了(被买走),哪些增加了(被放回)。而要实现准确的变化检测,不能简单地“对相邻两帧做差”,而是要基于“关键帧序列”建立证据链。
事件触发的高频抽帧阶段,每一帧都带有时间戳和检测结果。我在设计时将整个“顾客开门事件”作为一个会话,完整保留这段时间内所有关键帧的检测结果。同时在会话初始化时,用之前低频阶段的最新一帧作为“基准帧”。交易判定时,拿基准帧的结果与当前结算时刻的检测结果做对比:
拿取/放回变化 = SKU数量差(基准帧) - SKU数量差(当前帧)这个方案的好处是,不受抽帧频率忽高忽低的影响,只要保证基准帧和当前帧都足够清晰、能准确识别,就能准确算出差值。
5.3 减少误报:置信度、目标框稳定性、防抖动
模型识别偶尔会误检,比如把“脉动”看成“可乐”、把货架上的反光看成商品。在流水线层面,可以用“目标框稳定性”来过滤单帧误检:一个目标框如果在连续 N 帧(比如 3 帧)中都出现,且位置 IoU 变化不大,才认定是真实目标;单帧的孤立误检直接丢弃。
防抖逻辑大概是这样:
STABLE_FRAMES = 3 def is_stable_detection(det, history): for old_det in history[-STABLE_FRAMES:]: if det.class_name != old_det.class_name: return False if compute_iou(det.bbox, old_det.bbox) < 0.5: return False return True这个策略会牺牲一点实时性(要等 3 帧确认),但在售货柜这种允许 1~2 秒判定延迟的场景里,误报率下降非常明显。
5.4 多摄像头调度:一机多柜台怎么处理
一个边缘盒子一般会带 2~4 路摄像头,这时要特别小心“推理线程争抢”的问题。我的方案是给每一路摄像头分配独立的采集和抽帧线程,但推理线程可以共享一个队列(带摄像头 ID 标识),用优先级或轮询调度来分配推理资源。
更合理的做法是给不同摄像头配置不同的“抽帧频率”。比如两个摄像头视角重要程度不同,主视角(正对柜门)配置事件触发高频抽帧,辅助视角(可能只覆盖某个死角)配置低频定时抽帧即可。这样可以把有限的推理算力优先分配给关键摄像头。
5.5 掉线后的自恢复与告警
长跑系统必然会出现异常。除了前面说的拉流断线重连,流水线层面的异常还包括:进程崩溃、显存/内存泄漏、存储爆满等。我在部署脚本里加了一个简单的看门狗:
- 每隔 30 秒上报一次心跳到本地监控文件。
- 看门狗脚本检查心跳超时,超过 2 分钟就尝试重启主进程。
- 重启后自动加载最新模型和服务配置,恢复现场。
这个机制不复杂,但在无人值守的售货柜上,能减少大量人力维护成本。
6. 部署中的实际问题:三个印象深刻的排查经历
这一节我会把部署过程中几个最典型的“故障现场”完整写出来,包括现象、排查链路、最终找到的根因。这些经验能让读者避免白天写代码、晚上跑现场的低效循环。
6.1 检测结果不停地跳变:是模型漂移还是数据流问题?
现象:在调试阶段,明明货架上的商品没有动,YOLO 的检测结果却时不时变化:某个商品检测出来了,过两秒又没了,再过几秒又出现了。
排查过程:
- 第一反应是模型阈值太低,误检多,直接把 confidence 阈值从 0.3 调到 0.5,结果问题依旧。
- 然后怀疑抽帧不稳定,于是加了帧率监控,发现拉流端输出帧率正常,但推理端接收到的帧时间戳存在跳变。
- 继续深挖,发现根因在 IPC 的“智能编码”模式。很多摄像头默认开启 H.264+ 或 Smart264,会动态跳过不重要的帧以降低码率,导致画面内容“静止”时,摄像头实际输出的帧率很低(甚至只有 1fps);而画面内容一变,摄像头瞬间输出高帧率。抽帧模块按“时间间隔”抽帧,但实际输入帧率不稳定,导致检测结果波动。
解决方案:在摄像头后台把“编码模式”改为“定码率/恒定帧率”,同时 RTSP 拉流 URL 里加?profile=1之类参数强制指定标准编码。对于只做识别的摄像头,智能编码没有意义,反而会打乱抽帧节奏。
6.2 商品识别错误率高:问题出在光照与白平衡
现象:白天识别准确率不错,到了晚上,同一种商品经常被识别成另一个颜色相近的品类,比如可乐被识别成其他深色包装饮料。
排查过程:
- 先怀疑模型训练数据不够暗光样本,于是补充了一批弱光场景的数据做训练,但效果改善不明显。
- 后来发现是摄像头在白平衡自动模式下,到了夜间灯光偏暖色,整体画面色调偏移,导致模型特征提取出错。
- 在摄像头后台把白平衡模式改为“荧光灯”或“LED 灯”预设,然后手动校准一次色彩,问题明显缓解。
解决方案:在摄像头配置里固定白平衡模式,不要用自动白平衡。同时柜内补光灯尽量用色温稳定的 LED 灯条,避免混用不同色温的光源。这个问题的本质是“源域偏移”,是视觉类项目里不易察觉却影响巨大的因素。
6.3 GPU/NPU 利用率低但 CPU 跑满:解码环节成为瓶颈
现象:部署到 RK3588 平台后,用 RKNN 加载 YOLO 模型,NPU 利用率看着不算高,但 CPU 经常跑到 80% 以上,整个系统整体吞吐上不去。
排查过程:
- 性能 Profile 发现,CPU 的消耗大头不在推理,而在视频解码和图像缩放。
- 视频解码虽然是 FFmpeg 做的,但默认走的是 CPU 软解,没有启用 RK3588 的硬件解码模块(MPP)。
- 图像缩放也是 CPU 上用的 OpenCV
resize,没有用硬件加速接口。
解决方案:
- FFmpeg 参数里加
-c:v h264_rkmpp,让 RK3588 的硬件解码器处理 H.264/H.265 流。 - 图像预处理用 RKNN 的
letterbox内置处理,或者使用硬件加速的缩放入口,减少 CPU 开销。 - 最终 profile 结果:CPU 占用从 80% 降到 30% 以下,整机可以同时跑 2 路摄像头,每路 5fps 的识别频率。
这个问题的教训是:视频流水线的性能优化,先看数据流瓶颈,不要一上来就降模型精度或砍输入分辨率。
7. 整体方案取舍:一些值得再思考的问题
项目做到后期,有一些方案层面的思考想分享,这些是具体代码之外更值得琢磨的东西。
7.1 为什么不用纯 IPC 的移动侦测功能做抽帧触发
很多 IPC 自带移动侦测,可以发送报警事件到后台。理论上可以借这个事件来触发高频抽帧,省掉我们自己写运动检测的开销。但在实际测试中,IPC 的移动侦测有两个问题:一是灵敏度调起来比较费劲,容易误报或漏报;二是事件上传到后台有网络延迟,不如本地帧差法直接、可控、独立于摄像头品牌。
在售货柜这种本地识别、离线可用的需求下,所有关键判断都应该尽量避免依赖 IPC 厂家的私有协议。
7.2 抽帧策略还可以怎么优化:句柄复用与跳帧模型
目前的抽帧策略是“全量画面运动检测 + 局部 ROI 识别”。如果想进一步降低算力,可以考虑“跳帧检测 + 关键帧增强识别”:平时每 N 帧只取其中 1 帧做低分辨率运动检测,发现变化后再用高分辨率帧做完整 YOLO 检测。这种“两级检测”结构在很多实时监控系统里也常用,效果类似人的视觉注意力机制——先用余光察觉变化,再转头仔细看。
7.3 识别与订单系统的时间同步问题
售货柜识别最终要落到订单,而订单系统可能有自己的计时逻辑。边缘盒子与业务后台的时间如果不一致,会导致“识别结果时间”和“订单时间”有偏差,影响对账。我建议整个系统统一使用 NTP 时间同步,同时记录“识别完成时间戳”和“订单接收时间戳”两个字段,便于追溯。
7.4 YOLO 之后的方向:骨架行为识别还是多模态融合
当前实现是纯目标检测,已经能覆盖绝大多数售货柜识别需求。如果后续想增加“防夹带”“防遮挡”等能力,单靠 YOLO 的检测框就不够了,可能要引入姿态估计(YOLO-Pose)或行为识别模型,识别顾客手臂是否异常遮挡商品。另外也可以融合称重传感器数据,做视觉与重量的多模态校验,进一步提升交易准确性。
我个人的看法是:在具体工程落地时,不要迷信单一算法的“智能”,真正稳定的系统往往是多种低成本手段的组合——视觉负责“直观证据”,传感器负责“交叉验证”,后台规则负责“兜底”。
8. 写在最后的实用提醒
如果把这篇内容压缩成几条最想告诉你的经验,我会列这样几条:
一是选型上,别在模型结构上纠结太久。跑通一条基础版本的流水线,比花两周比选 YOLOv8 还是 YOLOv11 更重要。选 YOLOv5s 起步,快速验证端到端效果,然后针对实际数据迭代优化,这个周期通常更快。
二是参数上,confidence 阈值和 NMS 参数一定要在“你的数据”上调。别用训练代码里的默认值,那通常是在 COCO 或训练集上表现好的值,你在现场跑会有差异。建议做一个离线测试脚本,将一段真实视频喂进流水线,输出所有检测框,统计每个类别的置信度分布,再确定阈值。
三是架构上,一定要把拉流、抽帧、推理解耦。这个我前面说过很多次,因为一旦耦合在一起,任何一个环节出问题,定位和排查都极其痛苦。解耦的另一个好处是,如果后续换摄像头、换模型,只需要替换对应模块,不需要重写整个系统。
四是运维上,给所有关键模块加上心跳上报和历史日志。售货柜部署在没人值守的场所,出问题不能靠顾客打电话,必须能自动恢复并能事后分析日志。日志的核心字段包括:时间戳、摄像头 ID、抽帧模式、推理耗时、检测结果、堆内存等。哪怕前期写得粗糙,也要把日志体系搭起来,否则后期会为“无法定位现场问题”付出很大的代价。
最后说一句关于“抽帧不影响画面”这个老生常谈的话题。其实做识别流水线,不需要追求“画面连续流畅”,真正该追求的是“关键帧内容完整、时间戳对齐、检测结果稳定”。搞清楚了这一点,很多“要不要上更贵摄像头”“要不要换更大模型”的纠结,其实都没有必要。先把现有设备上这条链路的每个环节打磨稳定,收益往往比换硬件更大。