
做售货柜视觉识别这一年多我最大的体会是模型训练从来不是项目成败的真正分水岭把 IPC 拉流、抽帧、YOLO 识别这条流水线稳定跑起来才是。见过太多团队把精力全砸在标注和训练上模型在测试集上刷到 99%一接到现场摄像头就露馅——画面卡顿、延时飘到几秒、断线之后再也连不回来整个识别系统直接瘫痪。先澄清一个容易混淆的点标题里的 IPC 在网络监控语境下指的是 IP Camera网络摄像机不是计算机领域的进程间通信Inter-Process Communication。这点必须拎清楚不然后面所有工作都会围绕错误的对象展开。我们真正要做的事情是从网络摄像头拿到实时视频流在保证画面可用的前提下抽取关键帧交给 YOLO 做目标检测最后把检测结果换算成业务可用的商品拿取事件。这条流水线本质上是一个视频采集 → 关键帧提取 → 目标检测 → 业务判定的多阶段管道每一段都有各自的技术要点和坑。本文会按照实际落地的顺序把每个环节的设计思路、代码骨架和踩坑经验完整过一遍适合正在做售货柜、智慧门店、工业视觉这类项目的开发者参考。整个项目跑通之后你会发现模型调参反而成了最轻松的那部分。1. 项目背景与流水线整体架构先想清楚四个环节怎么衔接1.1 售货柜为什么需要拉流-抽帧-识别这套视觉方案售货柜无人售货柜/智能货柜的核心需求就一句话用户开门拿走商品系统准确告诉他拿了什么、该扣多少钱。传统方案有重力感应、RFID 标签但重力感应只能感知重量变化不同商品重量相同或拿取多件时容易算错RFID 需要每个商品贴标签成本和管理都很高。视觉方案只需要在柜子上方装一到两个 IPC既看不到用户操作也不需要改商品包装天然适合拿走就识别的场景。但视觉方案有自己的麻烦摄像头是连续出流的用户拿取动作通常只有几百毫秒到两三秒系统必须在这段时间里准确捕捉到商品的出现和消失。这要求视觉链路足够快、足够稳。模型再准如果拉流卡死或者抽帧错过关键动作一切都白搭。所以整个项目的重心其实是怎么把流水线的每一段都打磨到位。1.2 流水线的四个环节与实际数据流我常把这套系统拆成四段拉流从 IPC 的 RTSP 地址实时拉取视频流并解码成帧。抽帧按业务节奏从连续帧中选择送识别的关键帧附带精确时间戳。识别对关键帧做 YOLO 目标检测输出商品类别、位置、置信度。判定消费识别结果结合帧时间戳判断哪个位置商品减少/增加形成订单事件。四个环节的输入输出关系如下环节输入输出关键指标拉流解码RTSP 视频流原始帧解码不掉帧、连接稳定抽帧连续帧关键帧时间戳抽帧间隔合理、不积压YOLO 识别关键帧检测框类别置信度单帧延迟低、准确率高业务判定检测结果序列商品拿取事件不漏报、不重复计费这四个环节不是简单的串行调用而是用队列连接的生产者-消费者关系。我见过有人在项目初期图省事把拉流、抽帧、识别写在一个while True循环里串行跑结果拉流稍微抖一下推理排队时间越来越长最后整个系统延迟从几百毫秒涨到好几秒。正确做法是每一段之间用有界队列解耦各跑各的节奏。后面章节会展开讲具体怎么设计。2. RTSP拉流实战FFmpeg为主连接管理与断线重连是难点2.1 先把RTSP地址搞清楚不同厂商的URL差异巨大工业项目落地第一步永远是能不能稳定拿到画面。IPC 几乎都支持 RTSP 协议但各厂商的默认地址格式差异很大第一次接的时候经常在这里卡半天。常见格式如下海康威视rtsp://admin:password192.168.1.64:554/Streaming/Channels/101101 是通道1主码流102 是通道1子码流大华rtsp://admin:password192.168.1.64:554/cam/realmonitor?channel1subtype0subtype0 主码流subtype1 子码流宇视rtsp://admin:password192.168.1.64:554/MediaInput/h264/ch1/main/av_stream这里有一个很实际的选型点主码流 vs 子码流。主码流分辨率高比如 2560x1440画面清晰但码率大、解码 CPU 占用高子码流通常是 704x576 或 1280x720码率低解码快。对于售货柜识别场景检测商品的类别和位置用子码流或者中间的 720P 码流就足够了没有必要上主码流。硬上主码流的后果是IPC 编码负载大、带宽占用高、解码端 CPU 吃紧识别帧率反而上不去。2.2 拉流工具选型FFmpeg、OpenCV还是GStreamer选型表放在这里供参考方案优点缺点推荐场景OpenCV VideoCapture简单几行代码能跑断线处理弱TCP 选项少CPU 瓶颈常见原型验证、快速 DemoFFmpeg CLI subprocess稳定成熟RTSP 选项全占用可调需要维护子进程状态生产环境主力GStreamer延迟极低组件丰富学习曲线陡跨平台编译麻烦低延迟要求很高的场景我自己生产项目的做法是调试阶段用 OpenCV 的cv2.VideoCapture快速验证算法一旦要长期跑换 FFmpeg 命令行 管道读取。原因很简单VideoCapture 在断线之后经常卡在read()等不到返回值重连逻辑很难写好而 FFmpeg 自己处理 RTSP 协商、断流回报都很成熟重连只要重启子进程就行。FFmpeg 拉流并抽帧的典型命令ffmpeg -rtsp_transport tcp -i rtsp://admin:password192.168.1.64:554/Streaming/Channels/102 \ -vf fps5 -f rawvideo -pix_fmt bgr24 pipe:1注意两个参数-rtsp_transport tcp强制走 TCP无线网络或跨网段时 UDP 丢包会导致画面花屏、卡顿TCP 更稳定-vf fps5是在 FFmpeg 层面先抽一层帧相当于下游的抽帧压力已经减轻了一部分。配合 Python 读取import subprocess import numpy as np import cv2 RTSP_URL rtsp://admin:password192.168.1.64:554/Streaming/Channels/102 WIDTH, HEIGHT 1280, 720 CMD [ ffmpeg, -rtsp_transport, tcp, -i, RTSP_URL, -vf, fps5, -f, rawvideo, -pix_fmt, bgr24, pipe:1, ] proc subprocess.Popen(CMD, stdoutsubprocess.PIPE, stderrsubprocess.DEVNULL) while True: raw proc.stdout.read(WIDTH * HEIGHT * 3) if len(raw) ! WIDTH * HEIGHT * 3: # 帧不完整说明流中断了 break frame np.frombuffer(raw, np.uint8).reshape(HEIGHT, WIDTH, 3) # frame 就是解码好的 BGR 帧需要特别注意FFmpeg 管道读出的帧是 BGR 顺序而 YOLO 系列模型通常按 RGB 处理后面预处理时要做好通道顺序转换。2.3 断线重连不能只写一个重试循环现场环境中 IPC 掉线太常见了摄像头重启、网线松动、路由器 DHCP 租约刷新、码流参数被平台改掉……拉流进程必须自带无限重连能力否则系统跑几天就要人工干预。我的重连策略分三层帧超时检测proc.stdout.read()在设定时间内读不到数据就认为拉流异常。指数退避重连第一次失败等 1 秒重试第二次 2 秒第三次 4 秒封顶 30 秒避免疯狂重连把 IPC 打挂。重试上限与报警连续重试次数达到上限后发告警通知值班人员介入。伪代码如下import time def start_stream(): return subprocess.Popen(CMD, stdoutsubprocess.PIPE, stderrsubprocess.DEVNULL) retry 1 while True: proc start_stream() try: read_frame_loop(proc) except StreamBroken: pass finally: proc.kill() time.sleep(min(2 ** retry, 30)) retry min(retry 1, 6)还有一个细节容易忽略FFmpeg 的 stderr 不要直接丢弃到 DEVNULL至少在调试期要写入日志。RTSP 401 认证失败、404 地址不存在、连接超时这些错误信息都在 stderr 里是定位问题最快的线索。3. 抽帧策略设计按事件节奏抽帧而不是一刀切固定间隔3.1 为什么不能每帧都送 YOLO很多第一次做视频识别的人会下意识地认为识别得越频繁越准实际上在售货柜场景这是很糟糕的设计。假设 IPC 出流 25fpsYOLOv8n 在普通 CPU 上推理一帧要 100ms 左右每帧都送意味着系统最多只能处理 10fps 的帧率后面帧全部积压端到端延迟飙升。而且用户拿商品的动作持续 0.5~2 秒25fps 里大量帧内容是重复的识别它们除了浪费算力没有任何信息增益。所以抽帧的核心原则是间隔时间能覆盖动作过程又不会让关键瞬间溜走。对于拿取商品这种动作200ms~300ms 抽一帧即 3~5fps是实践中比较稳的区间既能捕捉到商品从货架上消失的瞬间又给推理留下充分余量。3.2 抽帧的两个层次FFmpeg 粗抽 业务层精抽我在实际流水线里做了两层抽帧。第一层是 FFmpeg 的fps过滤器把 25fps 的视频流降到 5fps 再输出这样网络传输和后续处理的原始帧数就已经控制在低位。第二层是业务代码里的动态抽帧正常情况下 5fps 全送识别一旦检测到画面中有运动变化比如有人在柜前、商品区域像素变化超过阈值把抽帧频率临时提升到 10fps 甚至 15fps确保关键动作不丢没有变化时则降低到 1~2fps节省算力。抽帧期间常有一个疑问视频抽帧会不会影响画面答案是只要你不把抽下来的帧再推回流媒体对原始录像没有任何影响。抽帧只是从流里取少量帧做分析录像端和画面端都是独立的。售货柜的监控录像照常 25fps 保存识别流水线只管自己的分析帧。3.3 时间戳比帧本身更重要抽帧最容易忽视的是帧时间戳。识别结果如果只带第几帧不带实际时间业务判定时根本不知道商品是几点几分被拿走的只能倒推一旦有延迟就乱套。正确做法是在抽帧那一刻记录系统时间戳import time def get_frame_with_ts(): frame read_raw_frame() # 从FFmpeg管道读一帧 return frame, time.time() def should_infer(frame_ts, last_infer_ts, threshold0.2): return (frame_ts - last_infer_ts) threshold如果将来要把识别结果和订单系统、支付系统对齐建议统一使用 NTP 校时的系统时间避免 IPC 本地时间和服务器时间不一致。4. YOLO目标检测模型选型、输入预处理与输出的工程化处理4.1 模型选型为什么要选轻量模型而不是大模型售货柜视频流的识别任务是分类定位识别柜内有哪些商品、在什么位置。YOLO 系列是当前最主流的方案但 YOLOv8x 和 YOLOv8n 在单帧推理延迟上能差 5 倍以上。我的观点是在流水线场景里优先考虑推理延迟而不是极致精度因为最终影响准确率的不只是模型本身还有抽帧时机和业务判定逻辑。对于大多数 SKU 数量在 10~50 的售货柜YOLOv8n 或 YOLOv5s 已经够用。这两个模型输入 640x640 单帧CPU 上约 80~150msGPU 上比如 RTX 3060约 15~30ms。如果你用的是 Jetson 或 RK3588 这类边缘设备还可以转 TensorRT/ONNX 进一步压延迟。训练时要注意自定义数据集标注的类别名和推理时的类别字典必须完全一致否则会出现模型认为这是可乐业务代码却找到序号对不上的标签这种低级但致命的错误。4.2 预处理细节letterbox 比直接 resize 更靠谱把视频帧送进 YOLO 之前需要做三件事BGR 转 RGBOpenCV 读出来是 BGRultralytics 内部默认处理 RGB。resize 到 640x640不要直接cv2.resize(frame, (640, 640))暴力拉伸画面变形会造成检测率明显下降用 letterbox 等比缩放加填充灰边。归一化像素值缩放到 0~1。如果用 ultralytics 的model.predict()这些细节库内部帮你处理了很方便但如果自己转 ONNX 推理预处理和后处理都得手写letterbox 必须自己实现。推理核心代码from ultralytics import YOLO model YOLO(vending_nano.pt) # 自己训练的售货柜轻量模型 def detect(frame): results model.predict(frame, imgsz640, conf0.35, iou0.5, verboseFalse) detections [] for r in results: for box in r.boxes: cls_id int(box.cls[0]) conf float(box.conf[0]) x1, y1, x2, y2 map(int, box.xyxy[0]) detections.append({ label: model.names[cls_id], conf: conf, bbox: (x1, y1, x2, y2), }) return detections置信度阈值conf建议在 0.3~0.5 之间调。阈值太低会把柜体反光、手指边缘误判成商品阈值太高又容易漏检被部分遮挡的商品。我在售货柜上一般从 0.35 起步配合业务判定里的连续 N 帧确认来压制偶发误检。4.3 单帧检测不是终点怎么把检测结果变成拿取动作YOLO 输出的是单帧里的商品框而售货柜真正需要的是哪个商品被拿走了。最简单的做法是维护一个柜内商品位置基线无人开门时每隔一段时间识别一帧记录每个商品的类别和位置作为基线。用户操作过程中持续识别比较当前帧与基线。如果某个位置原本有商品、连续 K 帧检测不到判定该位置商品被拿走如果原本没有、连续 K 帧检测到商品判定有商品放入用户退货。这里有个小技巧框位置不需要逐像素对齐只需判断两个候选框的 IoU 重叠是否超过 0.5因为摄像头轻微抖动和用户手部遮挡都会让框位置漂移。这个 K 一般取 3~5 帧能过滤掉单帧抖动造成的误判。5. 整条流水线的线程模型与队列设计5.1 用生产者-消费者模型解耦三个速度不同的环节拉流解码、抽帧、YOLO 推理三个环节的速度天然不一致。拉流 5~15fps抽帧 3~10fps推理 CPU 上可能只有 6~10fps如果全串行任何一个环节卡顿都会造成链路停顿。正确做法是每两个环节中间放一个有界队列IPC 视频流 - [拉流解码线程] 产出原始帧 - [有界队列Amaxsize2] - [抽帧线程] 选择关键帧、附加时间戳 - [有界队列Bmaxsize4] - [推理线程] 跑 YOLO输出结果 - [结果队列] - [业务判定线程]队列大小刻意设得很小A 队列 2、B 队列 4。目的是让生产者和消费者互相背压如果推理线程跟不上队列满了之后抽帧线程就会被阻塞直接把数据丢弃在源头而不是让旧帧越积越多导致系统越来越迟钝。记住一个经验视频流水线宁可丢帧不可滞后。用户拿商品的瞬间是稍纵即逝的处理 2 秒前的旧帧毫无意义。5.2 Python 的 GIL 问题到底有多大影响用 Python 写这套流水线时很多人担心 GIL 会让多线程形同虚设。实测下来在拉流和推理这两个环节 GIL 影响没有那么致命FFmpeg 解码跑在 C 层YOLO 推理跑在 PyTorch/ONNX Runtime 的 C 层只有抽帧的少量 numpy 操作会在 Python 层竞争 GIL。所以纯 Python 多线程也能跑出不错的效果。但如果你大量使用纯 Python 循环做预处理比如逐像素遍历GIL 会明显拖慢。解决办法有两个一是把所有预处理操作全部向量化numpy/OpenCV避免 Python 级循环二是干脆用 multiprocessing 把推理丢到独立进程进程间用 queue 通信。我自己的项目是混合方案拉流/抽帧在主进程多线程推理单独进程进程间用带超时的multiprocessing.Queue。5.3 一个能直接跑起来的四线程骨架import queue import time from threading import Thread frame_queue queue.Queue(maxsize2) # 原始帧 key_queue queue.Queue(maxsize4) # 带时间戳的关键帧 result_queue queue.Queue(maxsize16) # 检测结果 def grab_worker(): 拉流解码 初级抽帧 while True: frame read_raw_frame() try: frame_queue.put_nowait(frame) except queue.Full: pass # 抽帧没跟上就丢绝不阻塞拉流 def extract_worker(): 业务抽帧 写时间戳 while True: frame frame_queue.get() ts time.time() if should_infer(ts): key_queue.put((frame, ts)) def infer_worker(): YOLO 推理 model YOLO(vending_nano.pt) while True: frame, ts key_queue.get() detections detect(model, frame) result_queue.put((ts, detections)) def biz_worker(): 拿取判定 while True: ts, detections result_queue.get() update_baseline_and_check(ts, detections) for fn in (grab_worker, extract_worker, infer_worker, biz_worker): Thread(targetfn, daemonTrue).start() time.sleep(60 * 60 * 24) # 挂机跑起来这段代码是教学骨架工程上还要加异常处理、状态上报和优雅退出。但结构是对的每个环节独立线程、有界队列解耦、丢旧帧保护。6. 落地实测中的坑玻璃反光、手遮挡与重复识别6.1 反光误检玻璃柜门是视觉方案最大的干扰源之一售货柜大多有玻璃柜门光线从不同角度照过来玻璃上的反光在画面里会形成高亮区域非常容易被模型误检成白色商品或手部物体。我踩过最狠的一次白天靠窗的柜子反光把某几个 SKU 反复误检导致同一商品被计费多次。处理方法按优先级排序用 ROI感兴趣区域把识别区域限制在货架投影范围玻璃边缘、柜门把手这些高反光区域直接屏蔽。在数据增强阶段加入亮度随机变化、镜像和模糊提高模型对光照变化的鲁棒性。调整置信度阈值 连续 N 帧确认逻辑这类偶发误检通常不会连续出现。6.2 手遮挡单帧检测的天然弱点用户拿商品时手必然会在某个瞬间遮住商品YOLO 这时大概率漏检。如果业务判定逻辑是连续几帧检测不到就认为是拿走漏检就可能导致误判。解决思路不是强行要求模型在遮挡下也能检测而是承认遮挡期间检测结果不可信并修改判定策略判定拿取动作不只看当前帧有没有检测到商品还要看商品从有到无的变化是否发生在用户手部区域内。具体做法是维护一个手部/人形区域检测可以额外用一个轻量目标检测或简单的帧差法。只有检测框状态变化发生在手部区域内时才触发拿取判定。如果商品消失但手部区域不在附近大概率是误检或遮挡等下一帧确认。6.3 重复识别同一个商品被计费两次另一个高频坑是重复识别。用户把一罐可乐拿出又放回或者识别判定逻辑没有做状态锁存同一商品在几帧内被连续计费两次。我的做法是引入商品位置锁某个位置的商品一旦被判定为拿走在接下来 3~5 秒内锁住该位置期间该位置不再参与拿取判定如果用户放回同一位置解锁并恢复基线。这样既防止重复计费也照顾到拿起看看又放回去的正常操作。6.4 数据采集与光照环境的现实建议最后说数据。售货柜场景非常依赖实际拍摄数据训练集里如果只有整齐摆放的商品部署到现场一旦遇到倾斜摆放、包装揉皱、新老包装混用检测率会降得很快。建议正式部署前至少收集这些素材不同时段的自然光/灯光照片包括开柜门时外部光线进入柜内的画面。不同人的手部动作覆盖左手、右手、戴手套、拿多件等常见情况。玻璃上的反光样本雨天、晴天、夜晚分别录一段。这些数据不仅影响模型精度还直接决定了抽帧和判定逻辑的可靠性。现场环境数据收集得越充分后期调整业务逻辑时越省心。7. 性能实测数据与边缘设备部署的下一步7.1 完整链路的耗时拆解一条流水线在设计阶段是抽象的放到真实硬件上跑一次才能看到瓶颈在哪。下面是我在一个中端配置i5-12400 CPU、RTX 3060 GPU上的实测参考值环节耗时参考说明RTSP 网络传输 FFmpeg 解码80~200ms主要受网络和 IPC 编码参数影响抽帧间隔200ms/帧设置的 5fps预处理letterbox归一化等3~8msnumpy/OpenCV 向量化YOLOv8n 推理CPU 80~150ms / GPU 15~30ms输入 640x640后处理NMS业务映射2~5msultralytics 内部已含一部分从这个表能看出来在 CPU 部署时YOLO 推理是绝对瓶颈在 GPU 部署时推理延迟可以压到几十毫秒瓶颈就变成了拉流解码的 100~200ms。所以想优化端到端延迟先看你在哪个环节卡着别盲目堆模型。7.2 硬件选型与 AMD 显卡问题有朋友问过AMD RX 580 能跑 YOLO 吗这是很多手里有老显卡的人会关心的问题。答案是能但要分系统。Windows 下可以通过 DirectML 跑 PyTorch 模型安装驱动和 torch-directml 就能跑性能比同价位 NVIDIA 卡差一些但可以接受Linux 下 AMD 的 ROCm 目前对 RX 580 这类老卡支持非常不完整折腾成本高。如果只想省心NVIDIA 卡哪怕是老的 GTX 1660或者直接买边缘设备Jetson Orin Nano、RK3588 盒子都比在 AMD 老卡上较劲划算。7.3 边缘设备部署与后续扩展方向售货柜的最终形态大概率是边缘设备内嵌识别而不是拿一台带独立显卡的电脑放在柜子里。下一步可以考虑这些方向把 YOLOv8n 转成 ONNX再用 ONNX Runtime 跑CPU 延迟比 PyTorch 低在 Jetson/RK3588 上再进一步转 TensorRT/RKNN。用摄像头直连的 H.264 硬解码能力替代 FFmpeg 软解码能再省出不少 CPU。预留动作识别拿取动作开始/结束、多柜联动、异常开门报警等扩展链路。我个人建议先把本文的流水线在普通 PC 上完整跑通拿到实测数据和业务判定逻辑验证结果再考虑边缘设备上的性能迁移。边缘优化是另一个大工程硬编码、INT8 量化、算子融合每一项都能单独写一篇。最后再分享一个实际经验整条链路跑通之后一定要在项目里留一个回放诊断的入口把每次识别事件前后的关键帧、检测框叠加图都存下来。现场出了问题能不能十分钟内定位到是拉流断了、抽帧漏了还是判定逻辑错了靠的就是这些历史记录这也是整个项目最容易低估却最值得投入的部分。