一个让我失眠的线上事故

去年团队接了一个自动售货机的视觉识别项目。上线第三天,凌晨2点,报警群炸了。

识别模块把一瓶农夫山泉认成了元气森林。就这一单,亏了5块钱——但问题不在钱,而在于:系统判定“货不对板”,订单被冻结,用户卡在机器前面走不了。

凌晨2点,零下3度,一个用户被一台售货机困在路边。产品经理在群里@了我三次。

那个晚上我意识到一件事:99%的识别率,在真实场景中可能就是100%的事故。

────────────────────────────────────

  1. 传统售货机的“机械困境”

在聊AI方案之前,先看一眼传统售货机是怎么工作的。

传统弹簧货道的逻辑很简单:用户下单 → 电机转动 → 弹簧旋转一圈 → 商品被“推”出来掉进取物口。

// 传统售货机出货逻辑伪代码
public void dispenseProduct(int channelId) {
Channel channel = getChannel(channelId);
if (channel.getStock() <= 0) {
throw new OutOfStockException();
}
// 电机转动一圈
motor.rotate(channelId, 360);
channel.decreaseStock(1);
// 问题:如果商品卡住了呢?没人知道。
}

这套机制有两个无解的硬伤:

  • 尺寸绑架:货道宽度必须严格匹配商品直径。卖可乐的机器卖不了桶装方便面,想卖新商品?换弹簧、调间距、重新测试——没有半天搞不定。

  • 卡货玄学:弹簧推出去的商品,到底掉没掉进取物口?机器不知道。用户付了钱没拿到货,只能打客服电话。客服说“稍后核实”,用户说“我再也不用这破机器了”。

传统方案的本质问题是:机器只管“推出去”,不管“拿到了”。

────────────────────────────────────

  1. “拿不准”的视觉方案:三重挑战

转向AI视觉方案,第一反应是:这不就是个目标检测吗?YOLO训练一下不就行了?

上了线才知道自己天真了。真实场景有三个致命挑战:

挑战一:密集遮挡

货柜里商品排列紧密,一瓶可乐可能被前排的薯片挡住一半。消费者取货时手指遮挡了商品的关键特征区域。

第一次测试数据:常规YOLOv8模型在实验室环境下识别率97.3%,上真实设备后直接掉到81.6%。

挑战二:包装“套娃”

商品包装高度相似:不同口味的可乐、不同品牌的矿泉水——在摄像头上看起来几乎没有区别。

一个真实的误识别案例:
{
"predicted": "元气森林_白桃味",
"actual": "元气森林_青瓜味",
"confidence": 0.63,
"reason": "包装主色调均为白色,标志性文字区域被遮挡"
}

挑战三:光线魔幻

售货机放在户外,上午阳光直射、下午树影斑驳、晚上补光灯色温飘移。同一个商品在不同光照下拍出来像不同的东西。

────────────────────────────────────

  1. 工业级方案:四层架构+双重校验

踩完坑之后,我们重构了一套工业级架构,分四层:

Layer 1:图像采集层

货柜内部署4组不同角度的摄像头,确保商品在任意位置至少被2个摄像头完整覆盖。

摄像头布局示意(俯视图):
┌─────────────────────┐
│ [Cam1] [Cam2] │
│ │
│ 货架区域(多层) │
│ │
│ [Cam3] [Cam4] │
└─────────────────────┘

关键设计:每个摄像头配备独立补光灯,光线传感器触发自动补光,解决光照变化问题。

Layer 2:边缘计算层

识别任务在设备端完成,不上传云端。设备搭载RK3588边缘计算芯片(6TOPS算力),单帧推理耗时控制在120ms以内。

边缘端推理核心代码片段

import cv2
import numpy as np
from rknnlite.api import RKNNLite

class EdgeInference:
def init(self, model_path):
self.rknn = RKNNLite()
self.rknn.load_rknn(model_path)
self.rknn.init_runtime()

def infer(self, frame):

预处理:resize + 归一化

input_data = self.preprocess(frame)

推理

outputs = self.rknn.inference(inputs=[input_data])

后处理:NMS + 置信度过滤

detections = self.postprocess(outputs)
return detections

def preprocess(self, frame):

保持长宽比,填充至模型输入尺寸

img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (640, 640))
img = img.astype(np.float32) / 255.0
return np.expand_dims(img, axis=0)

Layer 3:识别算法层

选型YOLOv8s作为基础模型,针对商品识别场景做了三个关键改进:

  1. 引入注意力机制:在Backbone中插入CBAM模块,增强模型对商品纹理细节的关注。

  2. 度量学习辅助:增加一个Embedding分支,同类商品特征向量距离小、异类距离大。

  3. 数据增强策略:模拟遮挡、亮度变化、模糊等真实场景,泛化能力提升18%。

训练配置关键参数(yaml格式):
model: yolov8s-cbam.yaml
epochs: 300
batch_size: 32
lr0: 0.001
lrf: 0.01
mosaic: 1.0 # 保持mosaic增强
mixup: 0.5 # 新增mixup,提高泛化

Layer 4:融合校验层

核心思路:视觉是“猜”,称重是“证”。两者交叉验证。

设备底部安装4个高精度压力传感器(精度±1g),实时采集货柜总重量。

class FusionValidator:
def init(self, weight_sensor, vision_model):
self.weight_sensor = weight_sensor
self.vision_model = vision_model
self.weight_history = []

def validate(self, before_frame, after_frame):

1. 视觉识别:取走商品列表

goods_taken = self.vision_model.detect_goods(before_frame, after_frame)

2. 称重计算:理论重量变化

theoretical_weight = sum([goods.weight for goods in goods_taken])

3. 实际重量变化

before_weight = self.weight_sensor.read()
after_weight = self.weight_sensor.read()
actual_weight_diff = before_weight - after_weight

4. 交叉验证(允许±5g误差)

if abs(theoretical_weight - actual_weight_diff) <= 5:
return {"status": "success", "goods": goods_taken}
else:

触发重识别

return self.retry_recognition(before_frame, after_frame)

双重校验的效果:单一视觉方案在遮挡场景下可能翻车,但称重数据不会说谎。两者结合后,综合识别准确率从91.7%提升至98.6%。

────────────────────────────────────

  1. 踩坑实录:你可能会遇到的三个坑

坑一:称重传感器漂移

现象:设备运行一周后,空载重量缓慢变化,导致校验误报。

原因:传感器受温度影响,零点发生漂移。

解决方案:每次开门关门的瞬间,自动执行一次“归零校准”:

每次关门前后的自动归零

def auto_tare_on_door_close():
if door_status == "closed":

取10次读数的中位数作为当前基准

readings = [sensor.read() for _ in range(10)]
current_base = np.median(readings)
sensor.set_tare(current_base)

坑二:多件商品同时取走

现象:用户一把抓走三件商品,视觉模型只识别出两件。

原因:检测框重叠导致NMS(非极大值抑制)误删了目标。

解决方案:降低NMS的IoU阈值,从0.5降至0.3,减少漏检。

NMS参数调优

nms_config = {
"iou_threshold": 0.3, # 原为0.5,降低后密集场景召回率提升
"score_threshold": 0.25
}

坑三:关门前商品被放回

现象:用户拿了一瓶可乐又放回去,视觉模型只记录“拿取”未记录“放回”。

解决方案:帧差法追踪。比较取货前后的每一帧,判断商品是增加还是减少:

基于帧差的动作判断

def detect_action(prev_frame, curr_frame, detections):

检测新增的检测框(放回)

new_boxes = find_new_boxes(prev_frame, curr_frame)

检测消失的检测框(取走)

removed_boxes = find_removed_boxes(prev_frame, curr_frame)
return {"taken": removed_boxes, "returned": new_boxes}

────────────────────────────────────

  1. 效果与数据

重构后的方案在实际项目中的表现:

指标 传统视觉方案 双重识别方案
综合识别准确率 91.7% 98.6%
遮挡场景准确率 78.3% 96.2%
平均结算耗时 2.3s 3.1s
用户投诉率 3.2% 0.4%

准确率上去了,耗时多了0.8秒——但这个代价,值得。

────────────────────────────────────

写在最后

回到开头那个凌晨2点的线上事故。后来我们给所有设备加装了称重传感器,代码重构成双重校验架构。同样的场景再也没有发生过。

技术选型有时候不是选“最先进的”,而是选“最可靠的”。

视觉识别很酷,但酷不能当饭吃。当你的用户凌晨2点在寒风中掏出手机扫码,他要的不是99%的准确率,而是100%的确定性。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐