熟悉我的老朋友可能知道,这些年我陆续做过不少安防和边缘设备的视觉项目,踩过的坑攒下来能写个小册子。今天这篇是售货柜识别项目的一段实战总结,主题非常直接:IPC 拉流、视频抽帧、YOLO 识别,把这三个环节串成一条能稳定跑的完整流水线。

为什么想写这个?因为网上讲 RTSP 拉流的帖子很多,讲 YOLO 训练的帖子也很多,但真正把“拉流 → 抽帧 → 识别”按项目级要求组装到一起、并讲清楚中间各种坑的,少之又少。大多数教程的终点是“能跑出个框”,根本没提现场网络抖动、内存膨胀、断线重连和延迟飙升这些真实问题。这篇就重点补上这部分。

这篇适合两类人:一类是刚开始接触视频流识别、想把摄像头画面接进 YOLO 的开发者;另一类是在做售货柜、闸机、巡检等相关项目的工程师,看看我的选型思路和踩坑记录,至少能给你省两三天排查时间。项目本身不复杂,复杂的是把每一环都焊死。

1. 售货柜场景的特殊性:为什么简单的识别项目会卡在拉流这一环

1.1 售货柜对实时性和稳定性的双重要求

售货柜业务是很典型的边缘视觉场景:一个柜子就是一个微型便利店,消费者开门、取货、关门,系统必须在十几秒内判断出“谁拿了什么”“拿了几个”。这意味着视频识别不能有明显的处理延迟,又不允许因为识别模块崩溃导致整个交易流程卡死。

具体拆开看,有三个指标绕不开:一是首帧响应速度,二是识别准召率(尤其是同类不同款商品容易混淆的场景),三是 7×24 小时连续运行的稳定性。很多人上来就把精力放在第三个指标上,疯狂调 YOLO 模型结构,却发现漏检和误检根本没改善。原因往往不在模型本身,而在源头就出问题了:拉流延迟太大、抽帧太密或太疏、喂给模型的画面是花屏或时间戳错乱的帧。从源头开始捋,比一上来就调模型的收益高得多。

1.2 系统整体架构:从摄像头到识别结果的数据流

数据流程其实就四步:IPC(网络摄像头)通过 RTSP 协议把视频流传到拉流服务,拉流服务按抽帧策略截取有效画面,YOLO 推理进程拿到帧后做目标检测,最后把识别结果抛给上层业务系统。很多人以为“拉流、抽帧、识别”是三个互相独立的模块,实际项目里它们之间的缓冲策略、超时处理、重连机制,才是真正决定项目能不能上线的东西。

先给一张物理部署参考(文字描述):售货柜顶部或侧边安装 IPC,通过 PoE 网线接入交换机,边缘计算盒子(如 RK3588、Jetson Orin)或工控机连同一个交换机,跑 YOLO 推理服务;推理结果再通过 MQTT 或 HTTP 上报到云端业务后台。这套拓扑决定了视频流走的是局域网,RTSP 延迟理论上能控制在毫秒级,但实际跑起来你会发现,局域网也挡不住网线松动、交换机丢包这类物理故障。

现在说结论:好的流水线不是把三个环节堆在一起,而是让每个环节都有独立的缓冲、独立的健康检查、独立的恢复逻辑。这也是我后来做所有视频类项目都坚持的架构原则。

2. RTSP 拉流与模拟摄像头的选型取舍

2.1 RTSP 协议的基本工作方式

RTSP 本身不传视频数据,它更像一个控制播放的遥控器:客户端通过 DESCRIBE 请求拿媒体描述,通过 SETUP 建立会话,通过 PLAY 让服务器开始推流;真正承载视频编码数据的,是 RTP 包。所以排错的时候要清楚,RTSP 层的错误和 RTP 层的丢包是两回事——前者是会话断了,后者是流还在但你拿到的画面已经花成马赛克了。

拉流端的主流做法是用 FFmpeg 或 GStreamer,少量原型项目会用 OpenCV 的 VideoCapture。我的建议是:做原型用 OpenCV 确实方便,但正式项目一定要用 FFmpeg 或专门的流媒体库。原因很简单——OpenCV 的 VideoCapture 拉流时,对断线重连、超时控制、硬解码支持都很弱,一旦 IPC 网络抖动,它宁可卡死也不告诉你发生了什么;FFmpeg 至少能让你拿到清晰的首帧时间、丢包率、解码错误码,排查问题时有据可查。

拉流时还有一个容易忽略的参数:传输协议。IPC 一般同时支持 TCP 和 UDP 拉流,TCP 更稳定但延迟略高,UDP 延迟低但容易丢包。售货柜走局域网,我建议直接用 TCP,画面握手和重传都比 UDP 可靠,实测延迟差异可以忽略。

2.2 hiksimulator 这类模拟器的使用场景与配置

开发阶段通常没有真实售货柜和真实 IPC,有人会先拿 USB 摄像头顶着,或者用手机 RTSP 推流软件——这些算模拟源,但都不够稳定。这次我用了 hiksimulator 这类 IPC 模拟器,它用软件模拟摄像头,能输出 RTSP 流,并支持 H.264/H.265 编码、分辨率、帧率可配置。它的最大价值是能生成稳定、带时间戳、基本不丢包的流,方便你在开发阶段把流水线逻辑调通,等整个链路稳定后再去现场接真实 IPC,就不会出现“代码在本地跑得好好的,一接真机就歇菜”的情况。

模拟器配置的关键参数无外乎这几项:

  • 分辨率:建议 1080P 或 720P,根据你的算力定。算力不够时优先 720P,YOLO 输入也会缩放到 640,过高的原图分辨率只会白耗解码资源。
  • 编码格式:优先 H.264,兼容性比 H.265 好。H.265 在低带宽下更省码流,但很多边缘设备的硬解支持做得不如 H.264 成熟。
  • 帧率:20fps 或 30fps。如果抽帧间隔是 1 秒,更高的源帧率并不会让你多识别更多画面,反而会占用更多网络带宽和 CPU 解码资源。

另外,模拟器 RTSP 默认端口是 554,可以改成别的,但建议还是遵循常规端口,后面排查问题能少绕一道弯。

2.3 拉流端常见的网络问题与缓冲策略

到了真实环境,RTSP 拉流的坑主要有三类:

  1. 网络闪断或 IPC 重启后,RTSP 会话不会自动恢复,必须自己实现重连。
  2. 带宽拥塞时交换机会丢包,画面出现花屏或绿屏,需要靠解码重同步来兜底。
  3. RTSP 并发会话数超限,一些低端 IPC 允许的最大并发连接只有 4 路或 8 路,多个服务同时连会被拒绝。

缓冲策略上,我不建议把缓冲调得太大。很多人为了让画面“看起来流畅”,把缓冲区拉到几秒甚至十几秒——识别服务确实不卡了,但商品出门动作发生在 3 秒前,你第 15 秒才识别到,业务上已经没有意义。视频识别类场景的拉流缓冲尽量控制在 1 秒以内,甚至在边缘设备上采用零缓冲加丢帧处理,宁可丢弃部分帧,也要保住实时性。

提示:判断拉流缓冲有没有过大的一个简单方法——看画面里物体移动的“残影感”。残影明显说明缓冲区里积压了旧帧,识别结果的时效性已经出问题了。

3. 抽帧策略:既想让 YOLO 看得清,又不想让它“看吐”

3.1 为什么不能直接拿视频流喂给 YOLO

你如果直接把 25fps 或 30fps 的视频流全部接进 YOLO 推理,资源消耗会非常大。显卡或 NPU 要逐帧去做检测,CPU 要逐帧解码,而售货柜场景里商品从货架被拿走的过程虽然关键,但整个视频周期里大量画面是静止的,根本没有目标需要检测。实际项目中,绝大多数目标动作都是短促的,30fps 意味着 80% 以上的算力浪费在没变化或变化很小的画面上。

另一个更深层的原因是:YOLO 这类目标检测模型本身不做时间维度上的记忆,每张图都是独立推断。你想提高识别准确率,与其拼命提高输入帧率,不如在抽帧时保证每一帧的质量和信息量——清晰度、曝光、视角、商品完整性都正常。换句话说,识别系统需要的抽帧策略核心是“在正确的时间抓正确的画面”,而不是“抓得越勤越好”。

3.2 定时抽帧、帧差抽帧和事件抽帧怎么选

抽帧策略大致分三种,也可以混用:

  • 定时抽帧:每隔固定时间取一帧。实现最简单,但不够聪明,商品被拿取的瞬间可能恰好落在两帧之间。
  • 帧差抽帧:比较相邻帧的画面差异,差异大于阈值再送入识别。相当于“画面有变化才看”,非常省算力,适合检测静止背景下的移动目标。
  • 事件抽帧:配合传感器触发,比如售货柜的开门信号触发一轮高频率抽帧,关门后恢复正常频率。这是和业务耦合最紧密的做法,也最准确。

项目实践中我推荐“事件 + 帧差”组合:开门瞬间立刻触发一轮 2fps 左右的密集抽帧,持续 3 到 5 秒;之后每 0.5 秒或 1 秒做一次帧差判断,有变化就送识别,没变化就跳过。这样既保证关键动作不漏,又把算力消耗压到最低。

3.3 抽帧不影响画面质量的三个关键细节

“抽帧不影响画面”这个说法严格讲不准确——你抽取的是送入识别的帧,原视频流该怎么录还怎么录。真正要保证的是“抽出来的帧不要带病”。常见的问题有三个:抽到半帧图像、抽到编码花屏的帧、抽到因曝光正在调整而忽明忽暗的帧。

为避免这些问题,我在代码里通常做三道检查:

颜色通道检查。OpenCV 读出的帧是 BGR 顺序,而 YOLO 训练时通常用 RGB 输入,取帧后必须转一次通道顺序,否则模型看到的颜色是反的,类别识别容易错乱。

缩放时机检查。取到原始帧后不要立刻做简单 resize 拉伸,先用等比例缩放再做 letterbox 填充,补到模型输入尺寸(640×640 或 416×416)。直接拉伸会让商品变形,检测框位置也会偏离真实位置。

帧时间戳记录。每一帧送进模型前打上时间戳,这样即使后面排了很长的处理队列,业务上报时也能用“识别结果的真实采集时间”而不是“处理时间”去对齐事件。很多问题(比如“识别结果比实际动作慢了 3 秒”)靠这个时间戳能快速定位。

4. YOLO 识别环节:不是下载个权重就完事

4.1 售货柜场景的模型选型逻辑

售货柜商品识别是一个“细粒度分类 + 目标检测”混合问题。同样一瓶可乐,不同角度、不同光照、部分遮挡,对模型都是挑战。如果直接用 YOLOv8n 这类轻量模型在 CPU 上跑,速度是上去了,但对细小商品、重叠商品的漏检率会比较高;如果直接上 YOLOv8x,精度好一些,边缘盒子又喘不过气。

选型要看推理硬件:

硬件平台 建议模型 单帧推理耗时参考
RK3588(NPU 加速) YOLOv8s/m 或 YOLO11s 30ms ~ 50ms
Jetson Orin NX YOLOv8m 或更大 20ms ~ 60ms
纯 CPU(如工控机 i5) YOLOv5s / YOLOv8n 80ms ~ 200ms

另外,YOLO 自带的 COCO 80 类预训练权重在这里几乎没有直接价值,它里面没有“可口可乐”“农夫山泉”这类商品类别。你必须针对自己柜内的 SKU 集数据、做标注、做训练。数据量不需要一下子上万张,每个 SKU 先保证不同角度和光照下的图片,一般 100 到 200 张/类,加上数据增强,已经能训练出一个能上线的初版模型。训练数据标注格式如果是 KITTI、COCO 或 VOC,记得先统一转成 YOLO 格式的 txt 文件,网上有现成的转换脚本,但转换完一定要抽查样本框,避免坐标归一化计算错误。

4.2 模型推理的预处理和后处理要点

预处理阶段,最容易翻车的是归一化方式。YOLO 训练时一般用 0~1 归一化,而 OpenCV 读出来的 BGR 帧值域是 0~255,如果忘了除以 255 就直接送推理,模型精度会明显下降。

后处理阶段,目标检测的原始输出是一个很大的特征张量,需要做解码——把网格坐标换算成像素坐标、做置信度过滤、做 NMS 去重、再映射回原图坐标。这些步骤每个框架的 API 不一样,但逻辑是相同的。最容易被忽略的是 NMS 的 IoU 阈值和置信度阈值,它们直接影响漏检和误检:

  • NMS 的 IoU 阈值:建议设在 0.5 左右。售货柜场景里同一个商品可能出现多个重叠框,阈值太高会导致不同检测框合并失败,阈值太低又可能把一个商品的两个框全筛掉。
  • 置信度阈值:不能一刀切。不同 SKU 的类间相似度不同,需要在验证集上分别看 Precision 和 Recall 曲线后设置。有的类 0.4 以下容易误检,有的类 0.5 以上又容易漏检,分开调更合理。

4.3 模型部署到边缘设备的三个注意点

YOLO 推理服务部署到边端,至少要注意三件事:

  • 内存管理:长时间推理最容易踩的坑是内存泄漏。每处理一帧后,中间张量必须即时释放或纳入内存池,特别在 Python 环境下,循环里创建 numpy 数组而不回收,跑一天内存就爆炸。
  • 健康检查:推理进程要有探活机制,一旦卡死或崩溃要能自动重启,不能带病运行。售货柜是无人值守设备,靠人工去现场重启的代价太高。
  • 版本绑定:模型权重文件和预处理、后处理代码的版本必须绑在一起。升级模型后忘记同步预处理参数,会导致现场结果突然变坏,而且很难查。

这三件事在本地跑 demo 时都碰不到,但放到 7×24 小时的无人环境里,每一条都可能造成业务停滞。

5. 三段流水线的串联与工程化落地

5.1 用队列解耦拉流、抽帧和识别的速度差

拉流是 25fps 连续进行,抽帧是 0.5 到 2fps 按需进行,推理是 100 到 300ms 一帧——三个环节速度天生不匹配。如果用同步串联,整个系统会被最慢的环节拖死,识别一卡,拉流缓冲越积越多,最终延迟大到不可用。用队列解耦是常规做法,但要控制队列长度。

我项目里的设计思路是:拉流线程只负责把最新一帧写入一个循环缓冲区,只保留最新帧,不阻塞,满了就覆盖;抽帧线程每隔设定间隔去读这个缓冲区里的最新帧;推理线程通过另一个队列接收抽帧线程送来的帧,推理完成后把结果放到输出队列,由上报线程消费。这样做的好处是,即使推理偶尔慢了 3 秒,拉流和抽帧也不会被拖死,丢失的只是处理不过来的中间帧——在售货柜场景,这是可以接受的。

接着强调一个关键点:队列里永远只放“最新帧”,不要让旧帧排队等待。推理慢的时候,新帧应该直接替换掉队列里的旧帧,而不是继续排队堆积。

5.2 流水线核心代码骨架

下面给一个简化但能表达核心思想的 Python 代码骨架,用 OpenCV 拉流、线程池或独立线程处理,推理部分用 onnxruntime 做示例。真机部署时把 run_yolo_infer 里的实现换成你的 RKNN、TensorRT 或 OpenVINO 后端即可。

import cv2
import time
import queue
import threading
import numpy as np

RTSP_URL = "rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101"
MODEL_PATH = "best.onnx"
INPUT_SIZE = 640
SAMPLE_INTERVAL = 0.5  # 抽帧间隔,单位秒

frame_cache = {}          # 循环缓冲区,保存最新一帧
frame_lock = threading.Lock()
infer_queue = queue.Queue(maxsize=2)   # 推理队列,最多缓存2帧
result_queue = queue.Queue()           # 结果队列,供上报线程消费

def pull_stream():
    """拉流线程:只负责把最新帧写入 frame_cache"""
    global frame_cache
    while True:
        cap = cv2.VideoCapture(RTSP_URL, cv2.CAP_FFMPEG)
        cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
        if not cap.isOpened():
            time.sleep(3)
            continue
        while True:
            ret, frame = cap.read()
            if not ret:
                cap.release()
                print("stream broken, reconnecting...")
                break
            with frame_lock:
                frame_cache = {"ts": time.time(), "bgr": frame}
        cap.release()
        time.sleep(3)

def sample_frames():
    """抽帧线程:间隔取最新帧,送入推理队列,不排队堆积"""
    next_time = time.time()
    while True:
        now = time.time()
        if now >= next_time:
            with frame_lock:
                latest = frame_cache.get("bgr")
                ts = frame_cache.get("ts")
            if latest is not None:
                if infer_queue.full():
                    try:
                        infer_queue.get_nowait()   # 丢弃旧帧
                    except queue.Empty:
                        pass
                infer_queue.put((ts, latest))
            next_time += SAMPLE_INTERVAL
        time.sleep(0.01)

def yolo_process():
    """推理线程:从推理队列取帧,完成预处理 -> 推理 -> 后处理,产出结果"""
    import onnxruntime as ort
    sess = ort.InferenceSession(MODEL_PATH)
    while True:
        ts, frame_bgr = infer_queue.get()
        t0 = time.time()
        dets = run_yolo_infer(sess, frame_bgr)   # 预处理/推理/后处理
        infer_ms = (time.time() - t0) * 1000
        result_queue.put({"ts": ts, "infer_ms": infer_ms, "dets": dets})

这段代码把三个环节拆成了三个独立线程,线程间的通信只有队列和锁。你没有看错,我用 frame_cache 保存最新帧而不是用队列去堆积,这个细节很重要。抽帧线程把最新帧交给推理线程后,即使模型推理需要 300ms,新来的帧仍然在最近的位置被处理,不会出现积压导致的识别结果严重滞后。

5.3 日志、监控与异常恢复机制

一条流水线如果不上日志、不做监控,基本等于裸奔。至少要记录:拉流会话的连接、断开、重连事件;每一帧的采集时间、推理耗时;识别结果、置信度;系统 CPU 和内存水位。

异常恢复机制我强烈建议用 systemd 或 supervisor 管理进程:拉流断线时自动重连,重连次数超过阈值就重启拉流进程,同时把告警推到钉钉或企业微信。实测中,线上环境出现的大部分问题都能从日志里定位,关键是日志要“有内容”而不是只打 INFO 级别的一堆废话。每一帧的耗时和错误码都打全,排查效率完全不一样。

注意:不要把业务逻辑和识别逻辑写在同一个普通函数里层层调用。拆成独立进程或独立线程之后,即使推理进程崩溃,拉流、抽帧和上报还能继续跑,重启推理服务后流水线能自动恢复。这个架构带来的韧性,在无人值守的售货柜上价值巨大。

6. 踩坑记录与性能调优:现场环境的意外永远比你想的多

6.1 拉流断开不自动重连,程序卡死

真机接上后遇到的第一个坑就是 IPC 重启后,RTSP 连接断了,程序没有感知,一直卡在读帧上。OpenCV 的 VideoCapture 在断流时,不是返回 False 就是一直阻塞,最气人的是它可能直接崩溃。后来我统一改成后台拉流线程,读帧失败计数,连续失败超过阈值就销毁并重建拉流会话,同时给 RTSP 增加心跳检测,超过设定时间没有收到流数据就主动触发重连。

排查工具上,我用 FFmpeg 命令行直接拉流测试,能快速确认是不是 IPC 本身的问题。比如 ffmpeg -rtsp_transport tcp -i rtsp://... -t 10 -f null - ,这条命令跑 10 秒,如果输出里的 frame= 数量稳定增长,说明推流正常,问题在组件;如果中途报 Error while decoding stream ,那就要查网络和编码参数了。

6.2 抽帧间隔和识别速度不匹配导致的漏检

第二个坑出现在同时跑两路摄像头时:你以为 0.5 秒抽一帧,但推理一帧要 250ms,抽帧队列里的帧越来越多,模型处理完的“新帧”其实是 1 秒前的旧帧。商品动作早就过去了,系统还在识别旧画面,动作结束之后结果才报出来。

解决思路就是前面代码骨架里写的:抽帧不是往队列里“塞”,而是往缓冲里“替换最新帧”,新帧来了旧帧直接丢掉。这样即使推理慢一点,处理的最多也只是“最新的画面”,结果不至于滞后太多。顺带说一句,这个方案要比单纯增大队列容量靠谱得多,队列容量再大也只是延迟爆炸前的缓冲。

6.3 降低 CPU 和内存占用的实测技巧

最后说几个我在售货柜盒子上实测有效的降资源技巧:

  • 多路摄像头如果型号和编码相同,可以复用同一个解码上下文,尤其 H.264 流优先走硬解,软解几路同时开 CPU 直接拉满。
  • YOLO 推理的 batchsize 不要乱设,边缘盒子上设为 1 最稳,batchsize 调大并不会提升单帧速度,反而容易触发显存或 NPU 内存峰值。
  • 限制队列和环形缓冲区长度,不要让数据无限制堆积。长时间跑内存膨胀的根因,绝大多数是无界队列。
  • 把识别进程和业务进程分开,不要写在同一个进程里。否则一旦内存膨胀,你根本分不清是业务代码泄漏还是识别代码泄漏。

按这四条调完,我那个 RK3588 盒子的 CPU 占用从 80% 多降到了 40% 左右,连续跑一周再没出现过死机。GPU/NPU 的利用率也稳定在一个合理的区间,不像之前那样时不时打满。

调优是边跑边做的,不是开工前一次性做完的。先保证功能正确、流水线稳定,再根据监控数据做针对性的资源压缩,这个顺序能省很多无效工作。

整个售货柜项目做下来,我最大的体会是:视频识别的难点从来不在 YOLO 模型本身,而在“把稳定、低延迟的画面送到模型面前”这段路上。拉流会话管理、抽帧时序、中间缓冲策略、异常恢复机制,每一环都比想象中重要。尤其当你面对的是 7×24 小时无人值守的设备,任何一个环节的脆弱都会被时间无限放大。希望这篇流水线实战记录能帮你少走点弯路,尤其是那些只会在本地跑 demo 时看不出来的细节坑。如果你也在做类似的视频识别项目,建议先花一天时间把拉流和抽帧这两个环节打扎实,再回头去调模型,你会发现原本以为复杂的项目突然变简单了不少。

Logo

邀请您加入社区

更多推荐