做无人售货柜项目到现在,最大的感受就是:识别出商品只是入场券,把识别结果变成一笔不冤枉用户、也不亏掉货损的账单,才叫真正的事。这篇是「无人售货柜・RK+YOLO」系列的第7篇,专门聊围绕 YOLO 在 RK 边缘平台上的商品检测、开门前后商品比对,以及自动结算这一整条核心逻辑。适用对像是正在做视觉称重柜、或者准备把目标检测模型部署到嵌入式设备的研发和产品同学,也适合想搞懂纯视觉无人售货机到底怎么扣钱的旁观者。
我要先说一句可能让不少人意外的话:这种柜子最难的往往不是模型精度,而是“敢不敢直接扣款”。精度不够可以用兜底和复核,逻辑有漏洞就会变成无休止的客诉。
1. 业务闭环的首要问题:识别之后如何取信于人
1.1 视觉识别在整条链路里的真实位置
无人售货柜的完整业务链路,大概是这样走的:
- 用户扫码/刷脸开锁,柜门解锁。
- 用户打开柜门,自由取货。
- 用户关门,触发结算。
- 系统判定“拿走了什么”,生成订单并扣款。
在这个链路里,视觉识别并不是每一步都要跑的。真正需要用 YOLO 的方向就两个时刻:开门前柜内完整画面,和关门后柜内完整画面。其余时间,摄像头更多是作为一个“事情存在”的记录仪,而不是主判据。
这个定位很关键。我在项目初期一度想把摄像头做成全程分析,识别每一次伸手、每一件商品的拿取轨迹,做成类似自动驾驶那种“感知-跟踪-预测”的方案。结果发现两个问题:一是 RK 平台虽然能跑模型,但全程跑视频流 + 多目标跟踪会吃掉大量 CPU 和 NPU 资源;二是跟踪目标一多,ID 跳变、误匹配的问题会让你多出一堆根本解释不清的边界 case。
后来我彻底想明白了:商业上不需要知道“用户怎么拿的”,只需要知道“最后少了什么”。少了什么就是一个集合差,拿两帧静态画面做差,远比追踪每一秒靠谱。
所以视觉识别在整个链路里的真实角色,不是实时监控大脑,而是“收银台前那个核对库存的店员”。它只在开门和关门两个时点被唤醒,做两件事:识别当前货架上有哪些商品,以及这些商品和开门前比发生了什么变化。
1.2 为什么非要用“开门前/关门后”两帧,而不是全程视频
这也是我被产品同学问过很多次的问题。直接说结论:纯视觉无人柜,在全行业对比下来,最省钱、最可控、最容易 debug 的方案就是“前后两帧比对”。理由有三层。
第一层是算力性价比。RK 平台的 NPU 跑一个 YOLOv5s 的 int8 量化模型,单帧大概在十几到二十几毫秒量级。如果只处理两帧,只要整机有 0.2 秒左右的时间窗口就绰绰有余。但如果你要跑全程视频流,哪怕只在门开期间做到每秒 5 帧的抽样,也需要持续占用 NPU,这会直接影响同板卡上的其他任务,比如屏幕 UI、门锁控制、网络上报。
第二层是错误率。视觉方案最大的敌人不是“识别不到”,而是“识别结果不稳定”。同一瓶水,角度略变、光照略变、被手半遮挡,都会导致单帧识别结果抖动。如果走全程视频流,你会发现每一帧的检测结果都在变,最后反而不知道应该信哪一帧。而两帧比对的优势在于:只有确定性的两个状态,每一帧都可以用姿态、置信度和历史位置反复校验,结果可解释性非常强。
第三层是处理体验。做零售最怕用户纠纷。全程视频流意味着你有大量很难向用户解释的中间状态;但两帧对比很简单——“你开门之前货架上有什么,你关门之后货架上剩下什么,差多少我就扣多少”,哪怕用户来投诉,你也能把两张图直接甩出来。
当然,两帧方案也并不是万能的,后面我会专门讲它的漏洞和补齐手段。但作为主结算逻辑,它是目前兼顾成本、准确率和可解释性最好的起点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 商品识别:YOLO 模型在 RK 板卡上的落地细节
2.1 SKU 级数据:训练集必须来自柜内真实视角
这个项目的识别对象不是“饮料”“零食”这种粗分类,而是具体的 SKU,因为结算是按 SKU 单价来的。型号相同、不同口味的两包薯片,对模型来说是两个完全不同的类别。这就意味着数据采集和标注的工作量至少是一个中大型项目的量级。
我踩过很深的一个坑:初期为了快,用网上找的商品图直接训练模型,跑出来的 mAP 在测试集上很好看,一到真实柜子里就崩塌。原因非常朴素——柜内摄像头通常是斜上方俯拍,商品包装上的文字和图案在这个视角下会发生明显透视变形,还有柜内 LED 灯造成的反光。网上找的正面商品图,跟你实际能看到的画面完全是两码事。
正确做法是,用和现场一致的分辨率、机位高度、俯仰角去采集每个 SKU 的画面。采集时不要只拍整齐摆好的状态,至少覆盖三种场景:
- 满货状态:同类商品并排、堆叠 2 到 3 层。
- 稀疏状态:货架上只剩两三件。
- 遮挡状态:部分商品被前面的商品挡住,只露出一角。
标注的粒度也要想清楚。我建议只标注“当前这一堆里完整可分辨的主体区域”,宽度或高度小于真实物体 30% 的残缺目标直接不标。否则模型会被一堆一半的遮挡物噪声干扰,学出一些“看半瓶就兴奋”的错误特征。
每个 SKU 的底图数量,我的经验是最少 500 张,并且要覆盖不同批次的包装版本。同一款饮料如果换了包装,新旧包装必须同时在训练集里做两个类或者合并成同一类并大量混入样本,不然上线后漏检率立刻起飞。
2.2 模型选型与 RKNN 转换经验
在 RK 平台上跑 YOLO,第一步是在 PyTorch 里训练,然后把权重导出到 ONNX,再用 RKNN Toolkit 转成 RKNN 格式。这个过程有几个地方特别容易出问题。
模型结构方面,如果 SKU 数量在 20 类以下,YOLOv5n 或 YOLOv5s 就够用了。SKU 在 50 类左右时用 YOLOv5s 相对稳妥。我自己日常用的是 YOLOv5s,输入分辨率 640×640,它在 RK 系列板卡上能够做到精度和速度的平衡。
转换时要注意:ONNX 导出的时候不要带 NMS 层。很多 YOLO 仓库会在导出脚本里强制集成后处理,但在 RK 上这会增加转换失败的概率,还会把输出的自由度焊死。我会导出干净的三头输出(80×80 / 40×40 / 20×20 那组特征图),把 NMS 放到板端的 C 或 Python 代码里自己写,灵活得多。
量化这一步是整个部署中最影响精度的环节。直接用默认配置量化是省事,但经常出现目标漏检变多的问题,尤其是小目标。我的补救经验是:
- 校准图片尽量真实。我从柜内历史帧里随机抽 300 到 500 张画面用于校准,而不是用训练集里的图片,这样量化时统计的激活分布更贴近部署环境。
- 如果发现量化后精度掉得厉害,改用混合量化,把最后一个检测头的几层保留 fp16。
- 输出端的置信度阈值不要在量化前拍脑袋定,要结合 RK 板端实际推理结果重新统计一遍再定。
模型上板之后,推理时间通常会比 PC 上的 ONNX 快很多。以常见的 RK 系列板卡为例,YOLOv5s int8 模型跑一遍前向大概十几毫秒,再加上图像缩放、归一化、NMS 这些前后处理,总耗时也能控制在 30 毫秒左右,用来做两帧比对几乎是绰绰有余。
3. 开门前后比对:从“检测结果”到“差异清单”
3.1 抓帧时机:门磁事件与画面稳定缺一不可
只谈模型不谈业务的人,会在“拿到开门帧”这件事上栽跟头。你以为门磁一响就是开门前/关门后的稳定画面了吗?不是。
实际场景是这样的:用户扫完码去拉门,手的动作会短暂伸进柜内;用户关门的同时,另一只手可能还停留在柜口;甚至门关上后,柜门会回弹一下,这个振动会让摄像头画面出现轻微的位移模糊。如果你在门磁触发的那一毫秒立刻抓帧,抓到的多半是一张半模糊图。
所以我的做法是给两个“稳定等待时间”:
- 开门抓帧:门磁给出开门事件后,等待 800ms 左右,确认没有手在柜内取货动作,再抓第一帧。
- 关门抓帧:门磁给出关门事件后,等待 1.2 到 1.5 秒,等柜门不再位移、用户手也彻底离开,再抓第二帧。
有的柜子还会加一个“门锁锁定检测”信号,关门后锁舌到位才代表门真正关严了。这个信号可以作为抓第二帧的启动条件,比单纯靠门磁更可靠。
一味加延时会拖慢结算速度,用户会觉得关门半天才出账单,体验也很差。我建议不要用固定 1.5 秒死等,而是同时做画面静止度检测:在当前等待窗口内连续取低分辨率灰度图做帧差,如果连续 3 帧的帧差都小于阈值,就立即触发正式抓帧。这样既快又稳。
3.2 差异计算:类别计数只是起点,位置关联才是关键
拿到两张图之后,很多人会想:不就是分别跑一遍模型,数一下每个类别的数量,然后做差吗?
如果真这么简单,我也不会写这一篇了。纯数量做差有两个致命问题。
第一个是漏检。开门前画面里有一整排商品,其中有几瓶因为边缘位置光照暗而漏检了;关门后用户拿走了漏检商品旁边的另一样东西,漏检商品这次却被检测出来了。两边都数一遍之后,数量差可能正好抵消,结果就是模型认为“什么都没发生”,实际用户拿走了商品。
第二个是遮挡变化。用户在取货时可能把里面的一瓶向外挪了挪,或者把拿出来的又放回了另一个货道,造成“总数量没变,但位置变了”的局面。如果只看数量,就会误判成无事发生。
所以我设计了“类别计数 + 位置关联”的双重比对策略。
完整流程是这样的:
- 两帧分别用同样参数跑 YOLO。
- 统一置信度阈值(比如都取 0.35),过滤低质量框。
- 把两帧的同类检测框做两两匹配,匹配指标用 IoU,超过 0.4 就认为同一个商品。
- 匹配不上的框,再分两类:出现在开门前、但没出现在关门后的,进入“减少列表”;出现在关门后、但没出现在开门前的,进入“新增列表”。
- 同一 SKU 的减少数量和新增数量先内部对消,得出净差异。
位置关联这一层,本质是在防漏检的“错觉”。因为真实货架上的商品位置相对固定,开门前看到的商品和关门后看到的商品,只要都是同一视角,它们的检测框中心应该是高度重合的。重合度足够高,才能将这两帧里的目标视为同一个实物,才不会让漏检去污染数量差。
我贴一段核心伪代码,方便理解:
python复制def compute_diff(prev_boxes, curr_boxes, iou_thresh=0.4):
removed_boxes = []
added_boxes = []
matched_curr = [False] * len(curr_boxes)
for prev in prev_boxes:
best_idx = -1
best_iou = 0.0
for i, curr in enumerate(curr_boxes):
if curr.cls != prev.cls:
continue
iou_val = compute_iou(prev.xyxy, curr.xyxy)
if iou_val > best_iou:
best_iou = iou_val
best_idx = i
if best_idx != -1 and best_iou >= iou_thresh:
matched_curr[best_idx] = True
else:
removed_boxes.append(prev)
for i, curr in enumerate(curr_boxes):
if not matched_curr[i]:
added_boxes.append(curr)
return removed_boxes, added_boxes
实际工程里还要加一个防抖逻辑:如果同一 SKU 减少 1 瓶、又新增 1 瓶,且两条框的距离小于某个物理阈值(比如 30 像素),我倾向于判定为“误检”,不结算。因为这种情况大概率是用户拿起又放回同一位置,或者模型在相近区域误报了一个框。当然,如果减少的是「可乐」、新增的是「雪碧」,即便数量对等,也要作为真实差异上报,不能对消。
4. 自动结算核心逻辑与状态机设计
4.1 从差异清单到扣款金额
差异清单出来之后,结算金额的计算是一个很朴素的乘法:Σ(减少数量 × 单价) - Σ(新增数量 × 单价)。但它藏在业务层的处理才是最容易出问题的。
很多人没想过的一个场景:用户拿起了 A 商品,看了两眼不想买,顺手放在 B 商品的旁边。两张图一对,A 少了,B 多了。如果按刚才那个朴素公式算,用户会得到一个“A 被扣款、B 被退款”的账单。从金额角度看可能不亏不赚,但对用户体验来说就是“我明明什么都没干,凭什么账单里有一堆加减项”。
所以我的做法是,把对消逻辑做细一点:如果新增列表里有一个商品,它出现在了一个“原本被拿走过”的区域附近,并且数量与另一处减少的商品一致,就优先执行“放回对消”,而不是直接计算金额。只有在对消之后仍有多余的减少项,才真正进入扣款账单。
单价表的管理也和商品识别密不可分。无人柜的 SKU 条目是会经常变动的,上新品、下旧品、改价格,都需要同步给板端。我建议不要每次跑检测都去查云端价目表,而是在每台柜机本地维护一份轻量 JSON 映射表,只在收到推送时更新,这样结算时零网络延迟。万一出现本地没有价目表里的新 SKU,就返回“未定价商品”,挂起异常单,等待云端人工确认。
扣款动作不要等到 0.1 秒内完成。我会在计算出账单后先弹一个确认页面,上面展示两张图片(开门前画面、关门后画面)和对应的商品明细,让用户点“确认”或“申诉”。这不是可选的锦上添花,而是纯视觉方案减少客服纠纷最便宜的手段。用户确认后,订单立刻进入支付通道;用户申诉或者超时未确认,订单转人工审核。
4.2 状态机设计:把整条链路变成可回溯的事务
自动结算如果说成一张状态机,很多模糊不清的边界 case 就自动暴露了。我用的状态集合是这样一组:
| 状态 | 触发条件 | 主要动作 |
|---|---|---|
| IDLE | 柜机待机,无用户 | 等待扫码 |
| DOOR_OPENING | 用户扫码,门锁触发 | 等待开门稳定,抓基准帧 |
| PICKING | 打开状态 | 可选低频运动检测,记录异常事件 |
| DOOR_CLOSING | 触发门磁关闭 | 等待画面稳定,抓结算帧 |
| DIFF_COMPUTING | 两张帧均就绪 | 执行 YOLO 检测和差异比对 |
| SETTLE_CONFIRM | 差异清单完成 | 展示账单,等待确认或申诉 |
| COMPLETED | 用户确认/自动确认 | 调用支付接口,生成订单 |
| EXCEPTION | 任何环节出现异常 | 上送人工审核,保留现场帧 |
这个状态机里最容易被忽略的是“短时间内反复开门”的情况。用户可能在关门结算时又想起没拿想要的,立刻开了第二回。如果状态机此时已经跑到 SETTLE_CONFIRM,就会拿旧账单去扣款,把第二回的取货漏掉。
针对这一点,我会在 SETTLE_CONFIRM 里加一个“最近 3 秒是否有再次开门”的事件标记。一旦检测到开门信号,立即作废当前结算流程,回到 DOOR_OPENING 状态重新抓基准帧。这种回退能力直接决定了一台柜机在真实世界里的存活率,绝对不要省。
补货场景也要单独处理。补货员跟普通用户走的是同一条开门链路,但补货员身份是提前绑定在账户里的。当识别到当前使用人是补货员,结算逻辑会切换到“库存增量流程”:把新增的商品全部转入库存,不生成扣款订单。这一步如果没有写清楚,每次补货都会给后台制造一个巨大的“异常赔付单”,后台同事会疯的。
还有一个经常被忽视的时间维度:选购超时。用户把门打开后一直不走,或者站在柜前手机上看半天,商品被反复遮挡、放下、拿起。我设了一个最长选购时长,比如 180 到 240 秒,超时后语音提示用户尽快关门,并把状态锁定为“超时待复核”,即使门关闭也不会自动结算,必须人工介入。这样能把恶意滞留和异常遮挡产生的高噪声场景隔离出来,不让它在无人看管的环境里直接影响到收银结果。
5. 踩坑实录与现场排查技巧
5.1 漏检引发的“灵异多货”和隐形逃单
我在试运行期间遇到过最常见的故障,是“多货”和“少货”。
多货案例:用户什么都没买,关门后系统却提示柜内多了一瓶饮料。这个问题的源头就是漏检。开门前那帧里有三个相似包装的杯子叠放在一起,模型只检出了两个;关门后同一区域因为用户开门时不小心碰了一下杯子,姿态变了,模型反而检出三个。数量差从 0 变成了 +1,系统就给用户退了一瓶的钱——但其实用户什么都没干。
排查这类问题,首先要看对比图里的检测框。我会把两帧的检测框叠加画在一张图上存进本地日志,遇到投诉直接打开看。如果发现多货或少货都集中在某个特定货道,先检查那个位置的打光,因为柜内 LED 如果偏暗或者反光太强,模型会对那一列的商品集体不敏感。
另一个更危险的情况:用户在取货时故意用身体或衣服大面积遮挡摄像头。视觉模型一旦遇到大面积未知遮挡,很可能出现整片目标框消失。这时分两种处理:一种是我的两帧比对里没有任何差异,但开门期间的运动检测记录到了“大面积遮挡事件”,这种情况下我不会直接按无差异放行,而是把订单标记为“高风险待复核”;另一种是差异列表直接出现负数,也就是新增数量大于减少数量,这种更是直接送人工,绝不去自动扣款。
5.2 反光、换包装与低频运动检测
商品表面的反光是个行业级难题。铝罐饮料、塑料瓶标签、膨化食品袋子,在 LED 灯照射下都会出现高光区域,YOLO 检测可能会因此丢失特征。市面上常见的对策无非三种:
- 调整柜内灯光角度,尽量用柔光板或者侧向 LED 条,避免垂直正射。
- 在训练集里加入大量反光样本,让模型看到高光也能认出商品。
- 对检测框内的亮度分布做统计,如果反光区域占比过高,把该帧的置信度阈值降一步,给算法多一次容错机会。
我自己的经验是第 1 种最治本。很多柜子的灯光设计是为了让用户看清,不考虑算法,结果就是算法被高光折磨得死去活来。为了让视觉方案稳定,宁可牺牲一点展示美观,也要把反光压下来。
换包装这个问题,除非你对每个新包装都做一次模型迭代,否则不可能完全避免。务实的做法是:运营后台提供一个“商品外观登记”入口,上架新包装时先人工拍一段柜内真实照片传上来,系统自动生成一条“疑似新增 SKU”的记录。检测模型如果对某张图的置信度特别低,就先把商品判为“未知商品”,锁定整单进入人工审核,而不是乱归到最接近的类别去自动扣款。
低频运动检测是我在纯两帧方案基础上加的轻量增强。具体做法:在门开期间,每 2 秒抽一次小分辨率帧,和前一次抽样帧做像素差,判断有没有大范围运动。它不是为了做商品识别,而是为了标记“这个用户有拿取的可能性”。这笔信息不会直接参与差价计算,但它会在两帧差异为零的时候提醒后台:这里有一起需要复核的取货事件。用了这个小机制之后,我这边恶意遮挡逃单的漏检率明显下降了一截。
5.3 数据回流与双轨运行
线上环境永远会出现训练集里没有的长尾情况。为了不让这些长尾慢慢腐蚀识别精度,我把每次结算的前后帧、检测结果图、最终差异清单都压缩存进板卡,并定期上传到样本库。每周我会从异常单里抽一批样本,重新标注、重新训练,再走一遍 RKNN 量化流程。
上线新模型时,我不会停旧模型直接切换,而是先做一段双轨运行:新旧模型并行跑几天,把两套差异清单和实际上产生的订单金额做对比。只有当新模型没有引入新的“多货/少货”问题,才把旧模型下线。这个习惯救了我好几次,有次新版模型把某个容易混淆的 SKU 对的 mAP 提升了,但同一组 SKU 的置信度分数整体偏低了,导致一批本可自动结算的订单全部转入人工审核,双轨运行立刻把我拦住了。
最后说几句
如果让我给正在做类似项目的同学一个建议,那就是:不要一开始就去追求 99.9% 的识别准确率,把精力花在搭建一个“即使识别错了也不至于扣错钱”的闭环上。YOLO 模型再准,也只是闭环里的一颗螺丝钉;真正决定一台无人柜能不能长期稳定运营的,是对比逻辑的稳健度、状态机的完整度、以及异常单的兜底链路。
我在实际调试中还养成了一个习惯:每次排查客诉单,都会保存一张“前后帧叠加对比图”和对应的差异 JSON,存成一份可以回放的记录文件。这个文件除了排查用,后来也成了我迭代数据集的素材库。很多看起来玄学的识别错误,翻到原图一对照,都是些非常具体的环境问题——某盏灯坏了、某个包装批次印刷偏色、某列货道被促销卡片挡了一半。这些问题都不是换一个更强模型就能解决的,它们属于工程细节,需要在真实运营里一点点磨。
下一篇我会写关于多柜并发、异常单人工审核工作台以及云端数据回流的思路,有兴趣的可以继续关注。
