之前拿COCO预训练模型直接跑超市货架识别,效果其实有点惨。通用模型能告诉你这里有“瓶子”有“盒子”,但具体到“这瓶是可乐还是无糖可乐”、“这包薯片是原味还是青柠味”,它就完全懵了。而超市商品识别系统的价值恰恰就在这个粒度上——它要求识别到具体的SKU,而不是笼统的类别。做这个“基于YOLOv8的超市商品识别检测系统”的时候,我最大的感受是:整个项目真正难的其实不是训练代码,而是数据、业务和部署之间的衔接。
这篇博客就把我搭建这套系统的完整过程拆开来讲,包括YOLO格式数据集怎么准备、YOLOv8训练时哪些参数真正影响结果、模型导出到推理服务时怎么绕开坑,以及最后的UI界面是如何跟模型联动的。适合正在做类似毕设、工程实训,或者想往边缘端零售识别方向转的开发者参考。
1. 项目边界的拆解:这套系统到底要做什么
很多刚开始接触目标检测的人容易犯一个错误:拿到数据集就开训,训完就觉得自己做完了。但实际做超市商品识别系统,第一步必须先想清楚应用场景。判定边界不同,整个数据采集方式和模型要求完全不一样。
1.1 目标场景的三种可能
一套“超市商品识别检测系统”听起来很笼统,但落到实际场景,至少有三条完全不同的路线:
- 货架盘点辅助:摄像头上望式安装,识别货架上的商品,目的是统计堆头、缺货状态。特点是视角固定、商品密集遮挡严重、小目标多。
- 结账台快速识别:摄像头俯视对准结账区域,顾客把商品放上台面,识别后生成账单。特点是背景干净、商品一般不会重叠、通常需要跟扫码或重量秤做交叉验证。
- 自助购物车/无人柜:移动视角或固定视角拍购物车内部,识别放进车里的商品。特点和前两者都不一样,常常会拍到商品侧面、包装褶皱,光线条件也完全不可控。
我做的这套系统按第一种加第二种的混合场景来设计,因为单一做货架盘点,数据采集工作量会大得吓人,而混合两种场景后,数据集里自然就包含了不同背景、不同姿态的样本,对模型的泛化能力反而有好处。
1.2 为什么选择YOLOv8
市面上能做检测的模型太多了,Faster R-CNN、SSD、YOLOv5、YOLOv7,我最后选了YOLOv8,不是因为它最花哨,而是它对这种中小型实训项目来说,综合成本最合适。
| 对比项 | Faster R-CNN | SSD | YOLOv5 | YOLOv8 |
|---|---|---|---|---|
| 推理速度 | 慢(约5-10 FPS,GPU) | 中等 | 快(实时) | 快(实时) |
| 训练难度 | 需要多阶段训练 | 简单 | 简单 | 简单 |
| 小目标表现 | 较好 | 一般 | 一般 | 中上 |
| 部署友好度 | 差 | 中等 | 好 | 好 |
| 生态/资料量 | 少 | 少 | 多 | 多且持续更新 |
YOLOv8的核心改动在于它正式转成了Anchor-Free结构,输出端不再需要预设一大堆锚框尺寸,减少了一个非常容易在自定义数据集上翻车的环节。之前用YOLOv5训练自建商品数据时,锚框的尺寸跟数据集不匹配会导致召回率严重偏低,那时候还得手动算anchors。YOLOv8把TaskAlignedAssigner作为正样本分配策略,让分类得分和回归质量对齐,对那种同类包装长得很像、只是细微色差区别的商品,训练时收敛明显更稳。
1.3 系统整体架构
完整系统的模块划分其实不复杂,但每个模块之间都有依赖关系:
- 数据层:image数据加YOLO格式标签,里面包含原始素材、标签文件、类别定义文件。
- 训练层:YOLOv8模型训练脚本、配置参数、训练产物(weights权重)。
- 推理层:加载最佳权重,对输入图像进行推理,把输出的坐标框、类别id、置信度统一封装为结构化结果。
- 应用层:UI界面,用于选择图片、打开摄像头、展示检测结果、统计识别数量。
整套系统能在Windows环境下直接跑起来,核心依赖是PyTorch、OpenCV、PySide6(或PyQt5)。下面我会按这个链路逐步展开。
2. 数据集准备:控制模型上限的关键工程
有一句话我虽然不太喜欢用,但确实符合事实:垃圾进,垃圾出。在训练YOLOv8之前,你必须有一个标注质量够硬的YOLO格式数据集。图像标注这种事,看起来谁都能做,但真要影响模型推理效果,细节全在这些环节里。
2.1 理解YOLO格式的存储逻辑
YOLO格式的标注并不把标签存在图片里,而是每张图片对应一个同名txt文本,txt里每一行表示一个目标:
2 0.535156 0.545139 0.192188 0.311806这五个数字的含义依次是:类别编号、归一化中心点x坐标、归一化中心点y坐标、归一化宽度、归一化高度。所有坐标值都是除以图片宽高后的比例值,所以数值都在0到1之间。类别编号从0开始,具体的映射关系由一个classes.txt文件记住。
训练前我需要反复检查这个文件和数据集的对应关系,不然会出现很隐蔽的错误:模型训练的标签和推理时索引对不上,导致A商品的结果显示成了B商品的名称。
2.2 数据采集的路数与规模把控
超市商品数据的现实门槛在于公开数据集极少有贴近真实货架的,这里我采用的是自采数据进行独立标注。
- 对每个目标商品,在三个不同场景采集:货架正拍、货架斜拍、结账台俯拍。
- 每个场景下再变化距离:近景占一部分,远景占一部分,保证模型见到大小不同的目标。
- 刻意模拟遮挡情况:用另一商品挡住目标的一半边缘、用手部分遮挡、商品前后叠放。
整套数据做下来,单个类别的图像数量是不均衡的,有些常见商品如可乐、农夫山泉很容易拍到上百张,有些小众商品只有二三十张。但实际训练效果告诉我:每个类别至少要有50张以上有效标注图,否则这一类别的泛化能力会明显比其它类别差。对于不足的类别,我做了简单但有效的数据补充:把已经标注的图做亮度扰动、镜像、小角度旋转,生成了额外样本,然后再人工筛掉明显失真的。
2.3 类别定义粒度:粗细之间的取舍
商品识别的类别粒度,是整个数据层最需要想清楚的事。粒度定义太粗,比如把所有薯片统称“薯片”,系统在结账台上完全没有实用价值;定义太细,比如按“330ml可乐”和“500ml可乐”各设一个类别,模型需要学到的判别特征会变得极细,数据量要求是指数级上升的。
我当时的处理策略是这样的:按包装外观来定类别,外观一致就归一类,不再区分容量。比如同样设计的330ml罐装可乐和500ml瓶装可乐,它们包装图案差距巨大,所以分成两类。同一商品换了新包装,我训练时保留了新旧包装混合样本,让它作为一个类别内的多形态去学习。这套规则一直沿用到了后续模型迭代,效果稳定。
2.4 数据集清洗与增强配置
标注完成之后不能直接开训,要先做一轮清洗。我在清洗时主要看三类问题:
- 错标:目标明明在遮挡物后面,还把遮挡物框进去。
- 漏标:画面里存在明显的目标商品,但txt里没有对应行。
- 重复框:同一个目标被标了两个框。
清洗这一步我建议人工抽检比例达到30%以上,重点抽检那些密集场景的图。训练时YOLOv8自带的增强策略,包括mosaic、mixup、copy-paste等,默认配置并不同时打开所有开关,训练时需要根据自己的数据集规模做取舍。数据量在几百张规模时,不建议开太强的mosaic,因为mosaic是把四张图拼接成一张,如果本身数据量小,增强出来的样本会存在大量缝隙或失真的边缘,反而干扰模型学习。
3. 训练阶段的关键配置:不同参数对结果的影响
数据集准备好后,进入训练阶段。YOLOv8的命令行训练非常简单,但这不意味着随便跑都能训出好用模型。下面是从实际训练中提取的参数调优经验。
3.1 数据集描述文件的写法
YOLOv8会读取一个data.yaml文件来定位数据集。我的目录结构如下:
datasets/ merchandise/ # 项目根目录 images/ train/ val/ labels/ train/ val/ data.yamldata.yaml核心内容:
path: ../datasets/merchandise train: images/train val: images/val nc: 10 # 类别总数,必须写对 names: [ 'coca_cola_330ml', 'coca_cola_500ml', 'lays_classic', 'lays_blue', 'pepsi_330ml', 'noodle_bowl', 'water_500ml', 'oreo', 'chocolate_bar', 'instant_coffee' ]有一个细节必须说明:train和val的分割不要用随机脚本直接分,要先按“商品摆位”分组。意思是同一个货架层或同一批次拍摄的图片,尽量全部放进同一个集合,避免训练集和验证集出现同一个商品同一角度的图像,导致val指标虚高而真正部署时效果打折扣。
3.2 YOLOv8训练脚本及超参含义
我实际使用的训练命令:
yolo train data=data.yaml model=yolov8s.pt epochs=150 imgsz=640 batch=16 optimizer='SGD' lr0=0.01 close_mosaic=30 amp=True device=0几个参数逐个解释:
- model=yolov8s.pt:从官方预训练权重继续训练,也就是迁移学习。s版本是速度与精度的平衡点。n版本跑得快但精度差一些,在商品密集场景中小目标会被漏掉很多;m版本精度更好,但训练时间和显存开销都上来了,且推理在CPU环境会明显变慢。
- epochs=150:是对这个数据规模比较合适的量级。如果数据只有几百张,其实100轮就能看到收敛趋势。
- imgsz=640:默认分辨率。商品识别里很多目标非常小,把分辨率提到960可以明显提升小目标召回率,但显存占用是平方级增长。我训练环境是单张8G显存,所以妥协在640,后续部署时再用滑动窗口弥补。
- close_mosaic=30:末尾30轮关闭mosaic增强,让模型从细粒度特征上做最后精修。这是个很有用的细节,直接用默认值的话,最后阶段的loss很容易波动。
- optimizer='SGD':YOLOv8官方默认是auto,会根据数据规模自动选。我自己测试下来,小数据集上SGD收敛比AdamW慢一点,但泛化更稳。商品识别数据量不大,更看重泛化表现,所以我用了SGD。如果追求快速看到结果,AdamW是个更省心的选择。
3.3 训练过程监控和早停
YOLOv8自带的训练日志会实时打印每个epoch的box_loss、cls_loss、mAP指标。我重点看的是验证集上的mAP@0.5和mAP@0.5:0.95这两个指标,后者更严格,要求预测框和真实框高度重合,能直接反映商品位置预估的精度。
训练过程一旦发现train_loss持续下降但val_loss震荡不降,基本就是过拟合信号。遇到这种情况,我通常先关掉数据增强里的翻转和旋转操作,再dropout一些尾部epoch,也可以把早停耐心值设小一点。用patience=20是合理的,50轮的耐心太大会把时间浪费在已经退化的模型上。
3.4 训练完必做的Badcase分析
训完模型后,直接在验证集上看几个指标的分布,比看总mAP有用得多。主要看混淆矩阵,它能暴露出A类和B类互相误判的问题。在超市商品场景里,这种误判非常常见,尤其是百事可乐和可口可乐这种整体配色相似的包装。我当时遇到的就是经典口味薯片和青柠薯片互相误判。解决办法不是盲目增大数据量,而是单独为这组易混淆类别补充“肩并肩”对比样本,让模型能稳定看到二者的细节差异。
还有个小工具层面的经验:训练生成的results.png和confusion_matrix.png这些图在runs目录下,推荐看完整个训练过程的图表,别只看命令行输出,图表暴露的问题要直观得多。
4. 推理性能优化:从训练模型到可部署服务
很多人把模型训练当成项目终点,这个想法在工程实践里要不得。训练出来的weights是PyTorch框架产物,直接拿来塞进UI程序里不仅推理速度慢,还特别容易因为环境依赖不一致而出问题。我更习惯把模型导出成ONNX,再用onnxruntime做推理。
4.1 PyTorch转ONNX的坑与解法
导出命令其实就一行:
yolo export model=runs/train/exp/weights/best.pt format=onnx dynamic=False simplify=True但有几个点必须注意,否则导出时没问题,部署时各种妖魔鬼怪:
- dynamic=False:动态输入shape会让onnxruntime在CPU上执行时多出很多不必要的动态内存分配,速度下降明显。给定固定640输入尺寸,性能会稳定很多。
- simplify=True:用onnxsim简化计算图,能去掉一些多余算子,某些环境实测能拿到10%到20%的加速。
- opset版本:如果你用的onnxruntime版本比较老,导出时指定opset=12更兼容。太新的opset在某些CPU环境反而不支持。
导出之后,用onnxruntime加载模型推理:
import cv2 import numpy as np import onnxruntime as ort session = ort.InferenceSession( "merchandise.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"] ) def preprocess(image): img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (640, 640)) img = img.astype(np.float32) / 255.0 img = np.transpose(img, (2, 0, 1))[None, ...] return np.ascontiguousarray(img) def postprocess(outputs, conf_thres=0.25, iou_thres=0.45): # outputs shape: (1, 84, 8400) # 84 = 4个bbox坐标 + 80个COCO类分数,自己数据集会变成 4 + n_classes preds = np.squeeze(outputs[0]) ...4.2 推理前处理的字节序陷阱
这里有一个很坑的问题是cv2和onnxruntime之间的数据布局。OpenCV读出来的是BGR、HWC布局,而模型要求输入是RGB、CHW布局,而且必须是连续内存。我一开始漏掉了np.ascontiguousarray这一步,结果推理出来的框全部偏到画面角落,排查了半天才发现是内存排列不连续导致的数据错乱。
在这个问题上,建议顺序拍平所有的前处理步骤:BGR转RGB、resize、归一化、HWC转CHW、确保内存连续。任何一步的顺序写错,模型输出的坐标都会乱。
4.3 CPU/GPU推理路线的取舍
onnxruntime在Windows上可以分别指定CUDAExecutionProvider或CPUExecutionProvider。CPU推理的延迟在640分辨率下大约80-150ms,对UI界面来说,只能做到每秒钟七八帧,看起来会有点卡。GPU推理(哪怕是入门级显卡)能把这个延迟压到20-40ms,体验好得多。
如果没有独立显卡,有几个退而求其次的技巧:
- 降低推理分辨率到480,速度能提升一半左右,但小目标识别能力明显下降。
- 使用OpenVINO导出格式替换ONNX,在某些核显CPU上能拿到额外的性能收益。
- 设置成batch=1,关闭动态shape,减小线程池竞争。
4.4 推理结果封装成结构化数据
为了让UI代码不被模型细节污染,我定义了一个简单的数据类来做推理结果封装:
@dataclass class Detection: class_id: int label: str confidence: float bbox: tuple # (x1, y1, x2, y2) class MerchandiseDetector: def __init__(self, model_path, labels, conf_thres=0.25, iou_thres=0.45): ... def detect(self, frame_bgr: np.ndarray) -> list[Detection]: # 内部完成前处理 -> 推理 -> NMS后处理 ...这样UI层只需要调用detect并拿到Detection列表就行。工程上这种解耦可以避免“UI代码里全是NMS逻辑”的糟糕局面,后续换模型版本也只需替换这个推理类。
5. UI界面与模型联动的设计思路
标题里写了“UI界面”,说明这是很多类似项目的核心交付物。超市商品识别系统不能只停留在命令行,得有界面给使用者看。我用PySide6搭了一套简单的桌面界面,整体思路是多线程处理,避免视频流卡住界面。
5.1 界面布局和交互流程
界面主要分三个区域:
- 左侧主体:视频/图片显示区。检测结果会实时绘制在帧上,你可以直观看到识别框和商品名称。
- 右侧信息区:当前帧检测出的商品明细表格,含商品名、数量、置信度。表格会统计整个视频流里持续出现的商品数量。
- 底部工具栏:打开图片按钮、打开摄像头按钮、识别开关、置信度阈值滑块。
用户操作逻辑非常简单:选图片或开摄像头,程序读取帧并送入检测器,检测结果发到界面刷新显示。
5.2 线程模型:界面和推理不能塞在同一个线程
我最开始的版本把cv2.VideoCapture的读取和模型推理都放在了主线程,然后界面就肉眼可见地卡成PPT,甚至直接未响应。因为PySide6的主线程要处理窗口事件循环,一旦被推理阻塞,它就没法刷新界面。
正确做法是开一个QThread专门做“读取帧+推理+绘制”,然后通过Signal把处理完的帧传给主线程里的QLabel。具体结构:
class VideoThread(QThread): frame_ready = Signal(QImage) detection_result = Signal(list) def __init__(self, detector): super().__init__() self.detector = detector self.running = True def run(self): cap = cv2.VideoCapture(0) while self.running: ret, frame = cap.read() if not ret: break detections = self.detector.detect(frame) for det in detections: cv2.rectangle(...) cv2.putText(...) rgb_img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb_img.shape qimg = QImage(rgb_img.data, w, h, ch * w, QImage.Format_RGB888) self.frame_ready.emit(qimg.copy()) self.detection_result.emit(detections)这里emitted的QImage必须使用拷贝,因为cv2的frame数据在下一轮循环里会被覆盖,而Qt显示需要缓存这份数据。byte数组提前释放会导致画面花屏或者崩溃。
5.3 商品明细统计逻辑
明细表不是把每帧结果直接累加,那样会导致同一件商品在连续帧里被重复计数很多次。我采用了一个相对简单的帧间去重策略:连续多帧对同一位置重复出现的同一个类别,只算一次。具体实现不展开太深,大意是维护一个已记录的目标中心点列表,新来的检测框如果跟列表里的点距离小于一定阈值,就不再新增计数。这种去重方式在货架盘点场景下够用,但真要做无人收银台级别的精准计数,就需要引入ByteTrack之类的追踪算法了,复杂度会上升一个量级。
5.4 置信度阈值交互
UI上的置信度阈值滑块不是摆设。货架场景模型容易在低置信度时产生大量误检,调高到0.5能大大减少误框;结账台场景商品通常完整可见,低阈值0.2反而能抓住那些包装模糊的样本。这点做成可调项,在演示时特别好用,拿一个滑块就能适配不同场景。
6. 整套系统联调中的问题速查与维护经验
系统从代码层面跑通到真正稳定工作,中间隔着很多“意外”。这里整理一份我在搭建过程中踩到的典型问题和对应解法。
6.1 问题排查速查表
| 症状 | 根本原因 | 解决办法 |
|---|---|---|
| 界面一闪就崩 | PySide6和OpenCV版本冲突 | 优先用opencv-python 4.x附带的基本功能,避免同时装多个视觉库 |
| 摄像头打不开 | OpenCV访问摄像头的后端问题 | 尝试cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) |
| 推理结果框全部偏到角落 | 前处理缺少np.ascontiguousarray | 保证数据转成CHW后内存连续 |
| UI显示花屏 | QImage引用被释放 | 用qimg.copy()再传入Signal |
| 模型把A商品识别成B商品 | 类别标注不均衡或标签错位 | 检查txt标签和classes.txt顺序 |
| 训练时loss出现NaN | 学习率过大或数据集有损坏样本 | 降低lr0,检查图像文件是否完整 |
| 导出ONNX后预测结果全乱 | 动态shape或opset不兼容 | dynamic=False,指定opset=12 |
6.2 中文路径问题
这个坑非常隐蔽。Windows下如果图像路径里有中文,cv2.imread读到的可能是空图。当你用OpenCV直接加载含中文路径的图片时,它内部调用的API不支持utf-8编码。解决方案有两种:一种是用np.fromfile加cv2.imdecode组合读图;另一种是训练数据和部署路径全用英文,后一种更省事,但这种处理方式对“工程可用性”很有帮助。
6.3 模型上线后的维护:增量训练
商品识别项目的特殊性在于:超市商品会换包装、出新品,模型上线并不意味着结束。每隔一段时间就需要把新商品的图片补进数据集,做增量训练。我踩过的一个大坑是:增量训练时如果只拿新商品图片去训练,模型会对老商品产生灾难性遗忘,原本识别得很好的商品开始批量漏检。解决办法是把旧数据集按一定比例混入训练,同时把旧样本适当随机抽稀,比如每类挑选三分之一的旧样本加上全部新样本,重新训练一个版本。这样做之后,新旧商品的识别效果才能同时保持住。
这条维护链路最好写成一个半自动脚本:新图放进待标注文件夹,标完自动并入数据集,然后重新启动训练。整个过程不需要改动推理代码。
6.4 部署环境的踩坑顺序
最后提一下环境装机的顺序。我推荐在项目最开始就把依赖锁死,而不是边跑边补。Python环境建议固定为一个特定版本,PyTorch版本和CUDA版本也得对应好,可以在装完后先跑一段demo确认cuda可用。OpenCV建议用opencv-python,它自带了绝大多数运行库,但在Linux服务器上经常会缺libGL,在Windows桌面环境反而很少遇到。onnxruntime如果要GPU加速,得装onnxruntime-gpu而不是底座版本,很多人栽在这上面——装的是CPU推理包,代码里写了CUDAExecutionProvider,结果一直报找不到provider。
7. 一些训练和工程上的个人经验
项目做到后期,我越来越觉得模型训练代码反而是整个流程中最容易的部分。真正让项目走向可用状态的是背后那些细碎工程决策。
先说说数据效率。公开的通用检测数据集在超市商品这种细分场景下基本只能用来做预训练初始化,真正决定系统性能的永远是自采数据的质量。我在迭代过程中发现,与其一次性采集大批低质量数据,不如小批量多次采集,每次针对上一版模型暴露的badcase去补数据,这种闭环方式的数据使用效率高出很多。比如有一版模型总是把红色包装的薯片跟可乐混淆,我就在训练集里额外加了十几张它们同框的图,下一版模型的混淆率立刻下来了。
另一个经验是模型文件的管理。每次训练完,不要只留一个best.pt就完事,把对应的data.yaml、训练日志、验证集badcase图一起归档。否则三个月后再去看这个项目,你根本不知道当时的model是拿什么数据训出来的,出一个问题也只能干瞪眼。
最后分享一个小技巧,专门给做演示或者答辩场景的朋友。置信度阈值这个参数别写死在代码里,我做了一个小面板让演示者现场滑动调整。货架相机推得近、画面里商品大,阈值可以拉高一点;推得远、商品密集,阈值就得放低,不然识别框断断续续看着很不专业。这种交互上的小设计在演示时给你省不少解释成本。