
最近在做售货柜的视觉识别方案从IPC拉流到YOLO识别整条链路踩了不少坑也积累了一些可复用的经验。这套流水线的核心思路很直接网络摄像头IPC通过RTSP协议推视频流服务端按策略抽帧再把帧交给YOLO模型做目标检测最终输出“拿了什么商品、拿了几件”这样的结构化结果。整体不复杂但每个环节都有不少细节环环相扣一个地方没处理好整条链路就会卡壳。这篇文章就把我实际跑通的方案完整拆开讲一遍从拉流到识别结果落库适合正在做售货柜、智能货架、安防巡检这类视觉项目的朋友参考。1. 售货柜识别流水线的整体设计与方案选型1.1 为什么非要用“IPC 拉流 抽帧 YOLO”这套组合售货柜场景和普通安防监控有个本质区别识别结果直接关联交易扣款不能出错。柜门打开用户拿走一瓶可乐又放回一包薯片系统必须准确感知这个变化过程。如果像监控一样24小时全帧率跑目标检测GPU根本扛不住商用成本也没法看。所以业内普遍的做法就是“拉流 抽帧 检测”分层处理摄像头持续推流服务端按策略抽取关键帧再把抽出来的帧交给检测模型分析。这个组合最大的优势是解耦。拉流只负责拿画面不关心画面里是什么抽帧负责控制数据量决定什么时刻的画面值得分析YOLO负责理解画面内容。每一层都能独立优化、独立替换。今天摄像头从海康换成大华只需要改RTSP地址流水线其它环节完全不动。今天模型从YOLOv5升级到YOLOv8也只改检测层拉流和抽帧逻辑无需变化。这种可插拔的架构在项目迭代期特别重要。还有一个现实原因成本。售货柜大多部署在便利店、办公室、学校等场景网络环境参差不齐摄像头端侧算力也有限。把识别放在服务端而不是摄像头端可以用一台GPU服务器带几十路摄像头摊到单柜成本就非常低。这也是为什么“IPC拉流到服务端识别”比“摄像头内部识别”在售货柜行业更常见的根本原因。1.2 方案选型背后的关键考量先说IPC选型。售货柜内部空间狭小摄像头通常装在柜子顶部或层板边缘视角覆盖整个货架区域。我实测下来选择支持RTSP协议、分辨率不低于200万像素1080p、支持H.264编码的IPC基本够用。H.265虽然码率更低但部分老款IPC的H.265编码实现有兼容问题OpenCV拉流时容易出现花屏前期调试建议优先H.264。再说YOLO版本选型。YOLO系列目前有v5、v6、v8、v9、v11等多个版本对于售货柜商品识别这种任务我的建议是别盲目追新稳定优先。YOLOv8是当前综合性价比最高的选择训练工具链完善、部署生态好、中文资料多、量化方案成熟。如果项目对检测速度有极致要求可以考虑YOLOv8的n/s小模型版本如果追求最高精度且GPU资源充足可以上YOLOv8x。v11虽然更新COCO指标也更好但部署时部分框架的算子支持还不完善边缘设备上踩坑概率更高。最后是整体架构。我采用的是经典的生产者-消费者模型生产者每路IPC对应一个拉流线程负责RTSP解码和抽帧把帧放入队列。消费者检测线程从队列取帧做YOLO推理输出结果。中间层一个带超时淘汰的帧队列起到削峰填谷的作用。这套架构的好处是拉流速度不受检测速度拖累检测慢时队列堆积检测快时队列空闲天然适配视频流的不均匀特性。具体的代码实现会在后面章节展开。2. RTSP拉流与抽帧模块的完整实现2.1 RTSP协议基础与拉流前的准备工作RTSPReal Time Streaming Protocol是IPC最通用的流媒体控制协议它本身不传输视频数据而是负责协商传输参数真正的视频数据走RTP协议。我们通过RTSP地址拿到的是H.264裸流需要解码成YUV或RGB图像才能交给模型处理。OpenCV的VideoCapture模块内部集成了FFmpeg一条cv2.VideoCapture(rtsp_url)就能完成RTSP协商、RTP接收、H.264解码的整套流程这是大多数项目选择OpenCV拉流的原因。拉流之前有几个准备工作必须做。第一确认RTSP地址格式。不同品牌IPC地址格式有差异海康通常是rtsp://用户名:密码IP:554/Streaming/Channels/101大华是rtsp://用户名:密码IP:554/cam/realmonitor?channel1subtype0。这个地址可以在摄像头Web管理后台的“媒体设置”里查到如果懒得记格式用VLC播放器打开摄像头的RTSP地址验证一下能出画面再搬到代码里最稳妥。第二确认摄像头码流类型。主码流subtype0或Channels/101分辨率高、码率大适合清晰识别子码流subtype1或Channels/102分辨率低、码率小适合预览。售货柜场景建议拉主码流识别子码流容易漏检小件商品。注意IPC的用户名密码不要使用默认的admin/12345售货柜一般是公网或半公网环境弱密码被扫描后摄像头会被恶意控制画面被篡改会导致识别系统误判直接影响交易准确性。2.2 基于OpenCV的RTSP拉流代码实现下面是我在项目中实际使用的拉流代码做了断线重连和参数调优比网上的demo要健壮很多import cv2 import time import threading class RTSPCapture: def __init__(self, rtsp_url, frame_queue, max_queue_size128): self.rtsp_url rtsp_url self.frame_queue frame_queue self.max_queue_size max_queue_size self.cap None self.running False self.reconnect_interval 3 # 断线重连间隔秒 self._lock threading.Lock() def _open(self): 建立RTSP连接设置缓存参数降低延迟 cap cv2.VideoCapture(self.rtsp_url) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 核心优化限制内部缓存 cap.set(cv2.CAP_PROP_FPS, 15) # 按需设置帧率 return cap def read_loop(self): 拉流主循环读帧 简单抽帧 入队 self.running True while self.running: if self.cap is None: self.cap self._open() if not self.cap.isOpened(): print(f[RTSP] 连接失败{self.reconnect_interval}s 后重试...) time.sleep(self.reconnect_interval) self.cap.release() self.cap None continue ret, frame self.cap.read() if not ret: print([RTSP] 读取失败尝试重连...) self.cap.release() self.cap None time.sleep(self.reconnect_interval) continue # 帧队列满则丢弃旧帧保证检测实时性 if self.frame_queue.qsize() self.max_queue_size: self.frame_queue.put(frame) else: # 队列满说明检测速度跟不上此时应该丢弃最旧的数据 try: self.frame_queue.get_nowait() self.frame_queue.put(frame) except: pass这段代码有几个关键细节值得说明。cv2.CAP_PROP_BUFFERSIZE设为1是极其重要的OpenCV底层FFmpeg默认会缓存大量视频帧如果不去限制网络抖动时读到的画面会延迟好几秒。对售货柜这种需要实时响应的场景画面延迟是致命的——用户都拿着商品走人了识别系统还在分析几分钟前的画面。另外当检测端处理不过来时我选择丢弃最早未处理的帧而不是阻塞拉流线程这个取舍保证系统在极端情况下仍能尽快响应当前画面。2.3 抽帧策略怎么抽帧才能既不丢关键动作又不浪费算力很多新手在抽帧这里容易犯两个极端错误要么每帧都送检测GPU负载飙高要么每秒只抽1帧结果用户快速拿放的动作被漏掉。售货柜场景下我需要的是一个平衡策略。我的做法是固定间隔抽帧 动作触发抽帧混合策略。固定间隔方面实测15帧/秒的拉流中每2帧抽取1帧送检即约7-8次/秒的检测频率能够稳定捕捉正常的拿取动作对于快速连续拿取的情况单纯固定间隔仍可能漏检所以我在柜门状态变化或画面出现大面积差异时触发额外抽帧。这个方案既控制了算力开销又保证了召回率。class FrameSampler: 混合抽帧器固定间隔 运动触发 def __init__(self, interval2, motion_threshold8000): self.interval interval # 每隔几帧抽一帧 self.frame_count 0 self.last_frame None # 用于运动检测的上一帧 self.motion_threshold motion_threshold # 像素差异阈值 def should_sample(self, frame): 判断当前帧是否需要送检 self.frame_count 1 # 策略1固定间隔抽帧 if self.frame_count % self.interval 0: self.last_frame frame.copy() return True # 策略2帧间差异超过阈值时强制抽帧捕捉快速动作 if self.last_frame is not None: diff cv2.absdiff(frame, self.last_frame) gray_diff cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY) non_zero_count cv2.countNonZero(gray_diff) if non_zero_count self.motion_threshold: self.last_frame frame.copy() return True return False这里的运动阈值需要根据实际场景调参柜内灯光稳定时阈值可以设低一些外界光线变化大比如有人经过柜前挡住灯光时阈值必须调高否则误触发会很频繁。我自己用的8000是在办公室灯光环境下调出来的不同场景需要在现场微调。2.4 没有实体摄像头时用IPC模拟器调试流水线实际开发中有个很现实的问题算法工程师写代码时往往手上没有售货柜的实体IPC或者只有一两台但测试需要模拟多路并发。这时就用得上IPC模拟器比如热词里提到的hiksimulator这类工具以及更通用的方案是FFmpeg推流模拟RTSP源。我常用的做法是用FFmpeg把视频文件循环推成RTSP流ffmpeg -re -stream_loop -1 -i test_video.mp4 -c copy -f rtsp rtsp://127.0.0.1:554/live/test这样本地就多了一个“虚拟IPC”地址是rtsp://127.0.0.1:554/live/test代码逻辑完全可以按真实IPC来调。实测下来用三四个模拟流同时压测拉流、抽帧、检测的整条链路能提前暴露不少并发问题比如线程资源耗尽、队列积压、内存溢出等。调试建议先用模拟流跑通全流程再接入真实IPC。真实IPC的画面质量、网络稳定性都和本地模拟环境不同但至少可以确认代码逻辑本身没有大问题节省现场排查时间。3. YOLO识别模型的选择、训练与推理接入3.1 商品识别场景下YOLO模型选型建议售货柜里识别的是具体商品不是COCO数据集里的80类通用物体。常见的饮料瓶、薯片袋、巧克力盒都不是COCO类别所以直接用官方YOLO预训练模型肯定不行必须用自己的商品数据微调。模型选型上我对比过YOLOv5、YOLOv8和YOLOv11最终落在YOLOv8s上。原因有几个售货柜商品大多是静态摆放、背景固定识别难度不算极端小模型就能达到97%以上的准确率没必要上大模型增加推理延迟。YOLOv8s在1080P输入下单张推理约10-15msRTX 3060一柜多人并发拿取时也能保证实时性。部署生态好ONNX导出、TensorRT量化、OpenVINO都有成熟配套后续迁移到边缘设备如Jetson Orin、RK3588资料齐全。如果你跑的硬件是RK3588这种端侧NPU优先选YOLOv8n或YOLOv5nu量化后单帧推理控制在30ms内问题不大。如果是纯CPU服务器建议干脆换更轻量的方案比如YOLOv8n配合输入尺寸压缩到640x640以下顺序处理也能跑到20帧/秒左右。3.2 训练数据标注与格式转换从KITTI到YOLO的适配训练商品识别模型最耗时的不是训练本身是数据准备。以一个SKU约500种的售货柜为例每种商品至少要采集300-500张不同角度、光照、摆放方式下的图像再逐一标注工作量非常客观。这里分享一个数据清洗的实操经验采集时不要只在实验室拍多放一些在真实柜体内的照片特别是模拟用户拿放中途的画面模型见过这类数据后在动态场景下的漏检率能明显下降。标注格式上我主力用LabelImg和Label Studio导出YOLO格式最直接。YOLO标注格式很简单每张图对应一个txt文件每行表示一个目标类别ID 中心点x 中心点y 目标宽度 目标高度坐标都是相对图像的归一化值0~1。有些项目早期数据是从KITTI格式或COCO格式转过来的转换逻辑不复杂中心点坐标计算不同而已但一定要写脚本批量做校验防止坐标越界。归一化坐标出现大于1的值训练时会被当作非法样本模型收敛效果大打折扣。注意标注类别从0开始编号类别ID和类别名称的映射表一定要单独保存好。售货柜商品很多长得像比如不同口味的同品牌饮料标注时要格外仔细类别混淆的脏数据会让模型精度上限被锁死。3.3 YOLO推理接入的代码实现与后处理细节训练完成后导出模型推理端用的是ONNX Runtime。完整推理流程分三步预处理、模型推理、后处理。预处理包括缩放、归一化、通道转换后处理包括置信度过滤和NMS非极大值抑制。下面是我用的推理核心代码import cv2 import numpy as np import onnxruntime as ort class YOLOv8Inference: def __init__(self, onnx_path, conf_thres0.35, iou_thres0.5, input_size640): self.session ort.InferenceSession(onnx_path, providers[CUDAExecutionProvider, CPUExecutionProvider]) self.conf_thres conf_thres self.iou_thres iou_thres self.input_size input_size # 获取模型输入输出信息 self.input_name self.session.get_inputs()[0].name self.output_names [o.name for o in self.session.get_outputs()] def preprocess(self, frame): 缩放图像到模型输入尺寸保持宽高比并填充边缘 h, w frame.shape[:2] scale min(self.input_size / w, self.input_size / h) new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(frame, (new_w, new_h)) canvas np.full((self.input_size, self.input_size, 3), 114, dtypenp.uint8) canvas[:new_h, :new_w] resized # HWC - CHWBGR - RGB归一化到0~1 blob cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 blob np.transpose(blob, (2, 0, 1)) blob np.expand_dims(blob, axis0) return blob, scale, new_w, new_h def postprocess(self, output, org_shape, scale, new_w, new_h): 解析模型输出过滤置信度并做NMS # YOLOv8输出为 [1, 4类别数, 8400] predictions np.squeeze(output[0]) boxes predictions[:4, :].T class_scores predictions[4:, :].T # 筛选置信度高于阈值的候选框 class_ids np.argmax(class_scores, axis1) confidences np.max(class_scores, axis1) mask confidences self.conf_thres boxes boxes[mask] confidences confidences[mask] class_ids class_ids[mask] if len(confidences) 0: return [] # 坐标还原到原始画面尺寸 boxes[:, [0, 2]] - (self.input_size - new_w) / 2 boxes[:, [1, 3]] - (self.input_size - new_h) / 2 boxes[:, [0, 2]] / scale boxes[:, [1, 3]] / scale # xywh - xyxy boxes_xyxy np.zeros_like(boxes) boxes_xyxy[:, 0] boxes[:, 0] - boxes[:, 2] / 2 boxes_xyxy[:, 1] boxes[:, 1] - boxes[:, 3] / 2 boxes_xyxy[:, 2] boxes[:, 0] boxes[:, 2] / 2 boxes_xyxy[:, 3] boxes[:, 1] boxes[:, 3] / 2 # NMS去除重叠框 indices cv2.dnn.NMSBoxes( boxes_xyxy.tolist(), confidences.tolist(), self.conf_thres, self.iou_thres ) results [] for idx in indices: if isinstance(idx, (list, np.ndarray)): idx idx[0] results.append({ box: boxes_xyxy[idx].astype(int), conf: float(confidences[idx]), class_id: int(class_ids[idx]) }) return results很多人容易忽略的是预处理阶段的letterbox填充操作直接把任意尺寸画面resize到640x640会把目标拉变形严重影响小目标检测精度。填充边缘后后处理阶段必须把坐标偏移量减掉再还原这个还原逻辑要仔细测试否则检测框全部错位。3.4 识别结果的业务映射从检测框到“拿了什么商品”YOLO输出的是“类别ID 置信度 包围框”对售货柜的下游业务来说这还不够需要把类别ID映射成SKU编号再把检测结果与历史状态做差分才能计算出用户本次拿了哪些商品。这一步我单独封装了一个类class SKUMapper: def __init__(self, class_to_sku_map, sku_info): self.class_to_sku class_to_sku_map self.sku_info sku_info # SKU编号 - 商品名、价格等 def detect_to_result(self, predictions): 将模型输出转换为业务可用的商品列表 sku_changes {} for pred in predictions: class_id pred[class_id] if class_id not in self.class_to_sku: continue sku self.class_to_sku[class_id] # 同一SKU出现多次则计数加1 sku_changes[sku] sku_changes.get(sku, 0) 1 return sku_changes业务差分逻辑上我维护了一个“当前柜内商品状态”字典。柜门关闭瞬间拍摄画面计算基准状态柜门开启后每次检测到新的画面与基准状态对比出现的新类别和新数量就是用户拿取的商品检测到的数量少于基准状态的说明用户放回了商品。这个差分模型比单独依赖单帧检测结果更稳定能过滤掉少部分单帧漏检带来的误判。4. 整条流水线串联从拉流到识别结果落库4.1 多线程协同与队列缓存机制前面提到我用生产者-消费者模型串联整条流水线。具体到代码我会为每个柜子分配一个独立的拉流线程和共享帧队列检测线程统一从队列中取帧。这个设计支持一个检测服务复用于多路摄像头关键是队列长度要控制好。队列长度要根据检测速度和拉流速度的关系来设定。如果拉流是15fps检测能力是10fps队列积压速度是5fps队列长度设128约能缓冲25秒足够应对大部分网络抖动。超过阈值时丢弃旧帧保留新帧这是保证实时性的关键选择。为了跟踪流水线状态我还会给每帧打上时间戳存入队列的是dict而不是裸帧方便统计端到端延迟和判断数据新鲜度。import queue import threading import time from collections import deque class DetectionPipeline: def __init__(self, rtsp_urls, yield_model): self.frame_queues {url: queue.Queue(maxsize64) for url in rtsp_urls} self.captures [] for url in rtsp_urls: cap RTSPCapture(url, self.frame_queues[url]) self.captures.append(cap) self.yield_model yield_model self.result_buffer deque(maxlen100) self.running False def start(self): 启动所有拉流线程和检测线程 self.running True for cap in self.captures: t threading.Thread(targetcap.read_loop, daemonTrue) t.start() detect_thread threading.Thread(targetself.detect_loop, daemonTrue) detect_thread.start() def detect_loop(self): 统一从队列取帧做检测 while self.running: for url, q in self.frame_queues.items(): try: frame_meta q.get(timeout0.2) frame frame_meta[frame] timestamp frame_meta[timestamp] # 跳过过于陈旧的帧 if time.time() - timestamp 3: continue predictions self.yield_model(frame) self.result_buffer.append({ url: url, timestamp: timestamp, predictions: predictions }) except queue.Empty: continue time.sleep(0.005)这里有个容易被忽略的性能瓶颈Python GIL。多线程做I/O密集型操作拉流、图像缩放效果不错但YOLO推理如果使用CPU多线程同时推理会因为GIL互相等待吞吐量不升反降。解决方案是用多进程承载推理或者推理时释放GILONNX Runtime的CPU实现会自动释放大部分操作。我在生产环境里是开多个推理进程每个进程绑一个GPU流进程间用multiprocessing.Queue传递帧数据。4.2 频繁断流怎么办重连机制与看门狗IPC设备跑在售货柜这种商用环境下网络稳定性没法保证。路由器重启、网线松动、IPC死机这些都是家常便饭。没有健壮的重连机制流水线跑几天就会全部卡死。我总结了一套三级容错方案一级拉流线程内自动重连断流后每3秒重试一次连续失败10次则告警。二级看门狗线程监控每个拉流线程的帧间隔超过30秒没有新帧则强制重启该拉流线程有些死锁状态是重连解决不了的比如FFmpeg底层卡在某个网络调用上。三级应用层记录完整的断流日志包括断流时间、恢复时间、断流时长定期分析这些日志可以发现网络弱的地方提前干预。看门狗的实现很简单核心是记录每个线程最后成功读帧的时间戳然后定期检查class Watchdog: def __init__(self, thread_heartbeats, timeout30): self.heartbeats thread_heartbeats # {线程名: 最后心跳时间} self.timeout timeout def check(self): 发现超时未心跳的线程则记录日志 now time.time() for name, last_beat in self.heartbeats.items(): if now - last_beat self.timeout: print(f[WATCHDOG] 线程 {name} 已超过 {self.timeout}s 未心跳疑似卡死) # 这里可以对接告警回调比如企业微信/钉钉通知注意看门狗发现卡死线程后直接kill线程在Python里是做不到的正确做法是让卡死的线程自己退出——拉流线程每次循环检查一个“退出标志”看门狗置位标志后线程会在下一次循环退出然后由主线程重新创建该线程。4.3 多条流水线的性能优化和硬件资源评估流水线跑通后就要考虑资源问题了。我自己用一台双GPU服务器两张RTX 3060 12G带过12路售货柜摄像头占用的资源大致是资源项单路占用12路合计备注CPU拉流抽帧1 核12 核主要是H.264解码GPU推理约0.6GB显存8GB批处理可降低显存占用内存帧队列300MB3.6GB1080p BGR约6MB/帧带宽2-4Mbps50MbpsH.264主码流12路摄像头跑在两张3060上还有余量但如果扩充到30路以上就该考虑用TensorRT对模型做FP16量化推理速度还能提升30%左右显存占用更低。具体的优化手法我在第5节详细展开。5. 性能优化与推理加速实战5.1 输入尺寸裁剪分辨率与精度的平衡很多人的误区是摄像头是1080p模型输入也用1080p认为这样识别最准。实际上YOLO模型对输入尺寸有固定要求不管你输入多大多小都会缩放到模型输入尺寸过大输入只会增加缩放耗时不会提升精度。我实测YOLOv8s在640x640输入下把1080p画面缩放后送入模型和先裁剪目标区域再放大送入模型对小目标识别效果有明显差别。裁剪区域怎么确定售货柜的商品通常集中在固定区域可以先采集一张柜内全景图人工标出商品区域运行时只裁剪商品区域再缩放进模型。这个操作相当于让模型“看”到了更高分辨率的商品细节。我项目的柜子商品区域大约只占画面上半部分裁剪后送入模型小瓶饮料的识别率从91%提升到96%以上。5.2 TensorRT量化加速与部署细节如果GPU推理速度不够TensorRT是最直接的加速方案。ONNX模型转TensorRT的流程我已经跑通核心命令如下# 先导出ONNX模型YOLOv8官方命令 yolo export modelbest.pt formatonnx opset12 simplifyTrue # 再用TensorRT构建引擎FP16精度 trtexec --onnxbest.onnx --saveEnginebest_fp16.engine --fp16这里有几个坑要提前说。第一TensorRT构建引擎时会做层融合但部分自定义算子不支持导出ONNX时simplifyTrue可以消除很多冗余计算节点减少后续构建失败的概率。第二FP16精度对绝大多数商品识别任务足够了精度损失通常低于0.5个百分点但速度提升明显。第三如果目标是Jetson设备板子上构建TensorRT引擎非常慢建议在PC服务器上构建好引擎文件再拷贝到板子上需要用相同的TensorRT版本和CUDA版本跨版本引擎会加载失败。5.3 批量推理并发路数多时的吞吐量杀手锏当一路摄像头在跑推理时GPU利用率通常只有10%左右大部分时间在等待数据拷贝。如果管理着多路摄像头可以把多路的帧拼成一个batch一次性推理。YOLOv8的ONNX模型天然支持任意batch输入只需要把多张预处理后的blob在batch维度拼接def batch_inference(session, blobs): 多路画面拼接批量推理 batch_blob np.concatenate(blobs, axis0) outputs session.run(None, {input_name: batch_blob}) return outputs我实测用batch4推理总耗时只比batch1多30%左右等效吞吐量提升了3倍多。但批次管理有一个额外要求多路帧的到达时间并不一样批量凑一批会引入等待延迟。我的做法是设置一个5ms的等待窗口窗口内到齐的帧一起推理没到齐的部分批次照常推理保证实时性和吞吐量的平衡。批量推理工程实现有个注意点返回的检测框是相对各自原始画面的后处理时要按每条流的缩放和填充参数还原。如果batch内画面尺寸各不相同预处理时就要记录每帧的scale, new_w, new_h后处理时按各自的参数还原不能混用。6. 常见问题与排查技巧实录6.1 拉流常见问题现象原因解决方案拉流成功但画面花屏H.265编码兼容性问题摄像头改H.264编码画面延迟持续升高OpenCV底层缓冲区堆积设置CAP_PROP_BUFFERSIZE1拉流线程假死无日志FFmpeg底层网络阻塞看门狗线程超时重启多路同时拉流卡顿单线程拉多路或带宽不足每路独立线程检查交换机带宽偶发读取失败网络瞬时抖动自动重连记录断流日志6.2 抽帧与检测效果问题漏检小瓶饮料不止一次被这类问题折磨。排查时首先确认画面是否在识别区域被缩得太小。裁剪放大方案能解决大部分小目标漏检如果还不行就检查是否抽帧跳过了关键动作帧适当提高固定抽帧频率或降低运动触发阈值。另外夜间或弱光环境IPC画面噪点会显著影响小目标检测建议在摄像头端打开补光灯或设置较慢快门。误检率高非目标物体频繁触发柜外行人走动、用户手部、反光等都会产生误检。这类问题分两层解决模型层增加负样本训练数据专门标注“手”、“其它无关物体”作为背景类逻辑层增加区域过滤只在商品区域内保留检测结果区域外直接丢弃。6.3 YOLO训练阶段常见问题模型准确率上不去最常见原因是数据问题而不是模型问题。训练前先统计每个类别的样本数量品类少于50张的类别要优先补充数据。类别不平衡会导致大样本类别主导损失函数小样本类别几乎学不出来。我建议用Focal Loss代替默认的BCE Loss遇到硬样本更稳定。过拟合柜体光照条件单一训练集和测试集如果都来自同一个柜子相同视角模型容易背题。解决思路是训练时加入随机亮度变化、随机裁剪、Mosaic增强等让模型学到商品的本质特征而不是灯光变化。训练数据标注质量差标注框不贴合目标太大或太小、类别标错这些脏数据对训练的影响非常大。检查方法很简单训练前随机抽500张标注图可视化逐个框确认贴合度。我最初一个1000样本的小数据集里标注不合格的比例达到8%直接影响模型mAP约2-3个百分点。6.4 队列与并发问题实录检测结果乱序用多进程做推理时每个进程处理速度可能不同结果写入顺序和帧到达顺序不一致。售货柜业务对时序要求高顺序乱了就没法正确判断拿放动作。我给每个帧一个全局递增的帧ID结果写出时按帧ID排序再进入业务层。内存持续上涨帧队列里的图像是原始BGR大数组1080p单帧约6MB如果队列积压且消费慢内存会快速膨胀。建议队列里不要存原始帧而是先压缩成JPEG字节流再入队内存占用直接降到约1/20。代价是每次消费时需要先解码多消耗一些CPU但换来的是内存稳定性。内存优化的妥协策略队列里存JPEG压缩帧推理前再解码。拉流和检测的速度差异较大时这个trade-off是值得的。6.5 实际项目中的3条血泪经验永远不要在生产环境用裸的OpenCV拉流默认参数。默认的CAP_PROP_BUFFERSIZE是可能很大的画面延迟会悄无声息地增大而且很难排查。务必显式设置缓冲区大小。模型更新必须灰度发布。售货柜直接扣费模型误判会导致客诉。我们的做法是先在测试柜跑一天对比新旧模型在同一柜子的识别结果差异差异率超过阈值就回滚。这个灰度机制救过我好几次有一版模型在训练集指标提升了1.5%但上线后发现对特定包装的零食漏检率高了3倍。网络模块的日志比业务日志更重要。排查问题70%的时间花在网络和流的问题上RTSP断流的日志要详细到秒级。我自己在日志里会记录每次重连的耗时、失败原因、自上次恢复的稳定运行时长这样运营商网络波动时能快速定位到是哪一段链路的问题。7. 整条流水线的最终实现效果与扩展建议我把整条流水线跑通后的效果贴出来12路售货柜摄像头每路1080p主码流按7次/秒的检测频率单张RTX 3060可以稳定承载4路双卡部署12路整体延迟从用户拿取动作发生到识别结果输出约为1.2秒。商品识别准确率按SKU粒度统计达到96.5%客诉率从最早的每周3-4起下降到每月不到1起。这个指标在售货柜行业属于中上水平还有优化空间但已经具备商用价值。后续可以扩展的方向有两个。一是把模型检测结果与重力传感器数据融合重力数据能精确感知重量变化视觉数据能识别具体商品类型两者交叉验证可以大幅降低误判率这是目前头部售货柜厂商的主流方案。二是引入多帧时序模型不只分析单帧画面而是分析用户拿取动作的时间序列用动作的连续性过滤瞬时误检这套方案在用户反复拿放同一商品的场景下特别有效。另一个值得投入的方向是模型蒸馏和量化部署到端侧。我当前方案是服务端集中识别对网络带宽要求较高。如果换用RK3588这类自带NPU的边缘盒子做柜端识别单柜硬件成本能够控制在合理范围网络中断时柜子仍能独立完成识别断网续传更可靠。从服务端迁移到端侧最核心的工作就是TensorRT或RKNN量化。好在YOLOv8在端侧部署的资料已经很完善迁移难度不大。最后再分享一个心得这套流水线的核心价值不在每一个单点技术而在把拉流、抽帧、识别、业务映射串起来时的边界处理。队列满时丢帧还是阻塞、断流时重连还是报警、检测慢时降抽帧频率还是淘汰旧帧这些边界决策决定了系统在极端情况下的表现。开发时多花一点时间把边界问题想清楚上线后能省出十倍的时间来救火。