ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

超市货架空置缺货检测数据集:VOC/YOLO双格式与YOLOv8实战解析

超市货架空置缺货检测数据集:VOC/YOLO双格式与YOLOv8实战解析 简介面向超市货架管理与零售补货场景的目标检测数据集收录4470张货架实拍图像针对商品空置与缺货情况提供两类标注Empty-Space空置区域和Reduced货量减少其中空置类标注7570个框缺货类标注16205个框合计23775个边界框。压缩包共2000个文件以Pascal VOC格式的XML标注文件为主1999个另含1个说明txt整体约251MBXML标注均由labelImg绘制矩形框完成类别定义清晰既可直接用于目标检测训练也可导入标注工具进行二次校对与补充。数据覆盖不同货架排列、商品密度和光照条件贴近真实门店环境可用于训练货架空置/缺货识别模型支撑自动补货提醒、门店运营分析等任务。当前已有380人学习下载适合正在学习目标检测、或从事零售视觉分析的中高级开发者快速获取合规训练数据与实战样例。1. 超市货架空置缺货检测数据集这4470张双格式样本到底解决什么问题超市商品货架空置缺货检测数据集粗看是一个普通的目标检测数据包真正被它解决的是门店补货巡检里最难自动化的那个环节——货架货道空没空、缺了几瓶水、哪个层板空了过去只能靠店员巡场用眼睛扫。这个数据包给的是一批标注好的货架图像把「空置区域」和「正常商品」分别框出来搭配VOC和YOLO两种标签格式供检测模型直接训练。4470张对固定机位视角的货架场景来说不算大但结构化的货架排列意味着可迁移性很强。适合三类人准备做智慧零售视觉方案的工程师、想用现成数据快速验证检测效果的算法同学、以及负责无人货架或门店自动化盘点项目、需要先跑通一条数据到模型链路的产品技术负责人。它不适合做移动视角的密集商品识别那是另一个课题后面会讲到边界。2. 拆开数据包VOC与YOLO双格式背后的坐标体系、目录结构与校验方法拿到数据包先别急着解压丢给训练脚本。货架空置检测数据集的常见结构是images目录放原图Annotations或labels目录放标签一份VOC格式XML、一份YOLO格式txt。这两套东西坐标体系完全不同很多翻车都发生在格式混用上。2.1 两类标签的标注语义「空置区」与「正常商品」的边界怎么定理解这个数据集关键是理解2类标签的标注对象。常见做法是定义成empty和normal两类empty框住的是货道里本该有商品、但当前没有商品覆盖的连续空置区域normal框住的是正常陈列的一个个商品或商品组。想清楚这个边界很重要货架空置检测不是SKU识别它不关心这瓶是可乐还是雪碧只关心这个位置「该有的东西还在不在」。正因为不涉及细粒度分类两个类别就能让模型学会判断空与不空这也是这份数据量只有4470张时训练仍然能收敛的前提类别语义足够简单。标注边界上最容易扯皮的是两种情况一种是商品被前面的商品挡住了一部分只露出个瓶盖这在框normal时算不算完整目标。常见约定是可见面积超过商品整体约五分之一就算有效目标否则不框。另一种是货道深处没货、但前排放着几瓶此时空置区域要从遮挡物后面开始算还是整条货道都算空置如果标注约定是「对可见空置区域标注被商品遮挡不可见的空区不标」模型学到的就是镜头直接可见的缺货状态这对补货提醒已经够用。使用这份数据前建议先确认包内标注说明或classes文件里的类名顺序YOLO的类别ID是0还是1别在配置阶段就搞反了。2.2 VOC转YOLO的坐标换算像素框变成归一化中心点VOC格式把边界框存在XML里用的是绝对像素坐标左上角和右下角各一对(x, y)YOLO格式用归一化的中心点坐标加宽高即cx、cy、w、h全部除以图片宽度和高度。两者的对应关系是项目VOC XMLYOLO txt存储单位像素整数归一化0~1浮点坐标定义xmin, ymin, xmax, ymaxcx, cy, bw, bh类别标签名字符串类别ID整数每行内容一个object节点一行一个目标如果数据包只用其中一种格式转录脚本是免不了的。我常用的转换逻辑如下import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, out_txt, class_map): tree ET.parse(xml_path) root tree.getroot() size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_map: continue cls_id class_map[name] box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h bw (xmax - xmin) / img_w bh (ymax - ymin) / img_h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(out_txt, w) as f: f.write(\n.join(lines))这段脚本的逻辑很简单但有两个参数容易被忽略。第一是归一化必须用真实图片宽高如果XML里的width和height缺失或写的是缩放后的值转换出来的框全部偏移训练时loss不下降又找不到原因。第二是class_map必须由你根据包内classes.txt显式定义不要用字典遍历顺序自动生成ID因为Python 3.7以后字典虽然保序但如果你中途过滤了某些类名ID就会错位。2.3 训练前必跑的标签校验四类脏数据一次查清双格式数据集最常见的问题是不同步你改了XML忘了重新生成txt或者标注工具导出时把空白txt也导出来了。加载进训练框架之前先跑一遍标签自检脚本比训练到一半再排查高效得多。from pathlib import Path def validate_labels(img_dir, label_dir): problems [] for img_path in sorted(Path(img_dir).glob(*.jpg)): label_path Path(label_dir) / (img_path.stem .txt) if not label_path.exists(): problems.append(f【缺标签】{img_path.name}) continue lines label_path.read_text().strip().splitlines() if not lines: problems.append(f【空标签】{label_path.name}) continue for line in lines: parts line.split() if len(parts) ! 5: problems.append(f【非法行】{label_path.name}: {line}) continue cls_id int(parts[0]) cx, cy, bw, bh map(float, parts[1:]) if cls_id not in (0, 1): problems.append(f【类别越界】{label_path.name}: {cls_id}) if not (0 cx 1 and 0 cy 1 and 0 bw 1 and 0 bh 1): problems.append(f【坐标越界】{label_path.name}: {line}) return problems需要特别说明的是这段脚本假设所有图像是jpg如果包里有pngglob要写成*.jpg和*.png都查。缺标签和空标签是两类不同的问题缺标签意味着文件名对不上检查是否有多余空格或中文字符空标签则说明这一帧确实没有目标在训练时会被当作背景样本本身不算错误但要看数量占比如果空标签超过总量的十分之一模型会偏向把什么都预测成背景这时候要考虑是否删掉这些负样本或增加正样本。3. 用ultralytics跑通最小训练闭环目录组织、数据划分与首轮参数数据集校验没问题下一步就是让它在YOLO框架下跑起来。当前社区里零基础到可用状态最快的路径是ultralytics它同时支持yolov8、yolo11等多个版本命令风格统一数据格式约定也简单。这一章按从零到训练出权重的完整顺序写。3.1 环境安装与数据集目录组织这一步决定你能不能一次跑通ultralytics的安装对新手很友好一条pip命令就能解决但要注意Python版本和PyTorch的CUDA匹配。我一般这么装# 新建虚拟环境Python 3.10 比较稳 conda create -n yolo python3.10 -y conda activate yolo pip install ultralytics装完先跑一句yolo detect predict modelyolov8n.pt sourcehttps://ultralytics.com/images/bus.jpg验证环境如果这步能出图说明推理链路通了。注意不要跳过这步直接训练因为训练时报错和推理时报错的排查难度不是一个量级。目录组织方面ultralytics要求图像和标签按train/val两级或train/val/test三级分开放data.yaml指定路径。常见做法是把解压后的数据整理成下面的结构datasets/shelf_empty/ |-- data.yaml |-- images/ | |-- train/ | |-- val/ | -- test/ -- labels/ |-- train/ |-- val/ -- test/data.yaml的内容是训练的入口配置其中names列表的顺序必须和标签txt里的类别ID一一对应# datasets/shelf_empty/data.yaml path: datasets/shelf_empty train: images/train val: images/val test: images/test names: 0: empty 1: normal这里的path字段可以写绝对路径也可以写相对于你执行训练命令所在目录的相对路径。最容易踩的坑是Windows系统下路径分隔符和盘符处理建议在Linux服务器或WSL下跑训练省掉一堆路径转义问题。3.2 数据划分脚本固定随机种子按8:1:1拆份数据包里如果只给了全部图片和标签没有划分train/val/test需要自己动手分。划分逻辑有三个要点随机种子要固定划分比例要合理而且图片和对应标签必须同进同出。import random import shutil from pathlib import Path src_images Path(datasets/shelf_empty/images_all) src_labels Path(datasets/shelf_empty/labels_all) out_root Path(datasets/shelf_empty) random.seed(42) images sorted(src_images.glob(*.jpg)) sorted(src_images.glob(*.png)) random.shuffle(images) total len(images) train_end int(total * 0.8) val_end train_end int(total * 0.1) for idx, img_path in enumerate(images): label_path src_labels / (img_path.stem .txt) if not label_path.exists(): print(f跳过缺标签文件: {img_path.name}) continue if idx train_end: subset train elif idx val_end: subset val else: subset test img_dst out_root / images / subset / img_path.name label_dst out_root / labels / subset / label_path.name img_dst.parent.mkdir(parentsTrue, exist_okTrue) label_dst.parent.mkdir(parentsTrue, exist_okTrue) shutil.copy(img_path, img_dst) shutil.copy(label_path, label_dst) print(f划分完成: train {train_end}, val {val_end - train_end}, test {total - val_end})这段脚本有两个细节值得说。第一用shutil.copy而不是move是为了保留原始数据万一划分后发现某类样本在训练集里分布失衡还能重新分。第二随机种子固定成42只是习惯重点是固定一个值才能让实验可复现否则每次跑出来的模型没法比较。划分后建议打印一下每个子集里empty和normal各自的数量确认两类标签在两个集合里都有足够的正样本。3.3 训练参数怎么设imgsz、epochs、batch、patience的取值逻辑训练命令本身不长参数才是影响结果的关键。以下是我在货架空置这类固定场景下的常用起步配置yolo detect train \ datadatasets/shelf_empty/data.yaml \ modelyolov8s.pt \ epochs100 \ imgsz640 \ batch16 \ patience30 \ device0逐个说参数。modelyolov8s.pt表示从COCO预权重继续训练这比从头训练收敛快得多如果显存只有6G换成yolov8n.pt。epochs100是以早停为前提的配合patience30意思是连续30轮验证指标不上升就自动停实际跑下来通常到60-80轮就停了。imgsz640是速度和精度的折中货架上的商品是小目标但也不建议直接上1280因为训练显存翻倍速度降一半收益没有想象中大。batch16在16G显存下配imgsz640是安全的如果爆显存先降batch而不是降imgsz。训练中要盯日志里的三行lossbox_loss、cls_loss、dfl_loss。这三项对应YOLO损失函数的三个组成部分前两项好理解dfl是分布焦点损失负责让边界框回归更精准。如果box_loss在20轮以后还在0.02以上徘徊大概率是标签框本身不干净回去查标注而不是调参。验证集的mAP50、mAP50-95是最终判断依据但每轮只看mAP50容易被假象骗过要结合precision和recall一起看。3.4 算力与路数估算T4跑1080p25帧视频能支撑几路训练完之后落到项目里绕不开算力预算问题。经常有人问起T4上用TensorRT跑YOLO 640分辨率25帧每秒的1080p视频流能支持多少路。常见经验是YOLOv8s在T4上FP16推理大约能到60-100FPS25FPS输入意味着单卡并行挂3-4路比较稳如果做的是货架巡检而不是实时监控把采样降到每秒1帧单卡支撑十几路也没压力瓶颈反而在视频解码和内存带宽上。这个估算的前提是预处理和后处理也做了TensorRT优化纯Python推理扛不住这个路数。4. 从检测框到「货架空置率」推理后处理、指标验证与多帧投票训练产出best.pt只是第一步货架空置检测项目的交付物不是一个模型文件而是一个能输出「哪个区域缺货」的判断逻辑。这一章解决推理侧的问题。4.1 读取YOLO输出并统计两类目标面积占比比数个数更可靠模型输出的是边界框但业务要的是空置程度。有两种统计口径数每个类别检测到了几个框或者计算两个类别的检测框面积占比。数个数的问题是一个空置区域可能因为被层板隔断被模型框成两个框这时候空置计数翻倍但面积占比不变所以面积占比更稳。from ultralytics import YOLO model YOLO(runs/detect/train/weights/best.pt) results model.predict(frame_0421.jpg, conf0.35, imgsz640) empty_boxes [] normal_boxes [] for result in results: for box in result.boxes: x1, y1, x2, y2 box.xyxy[0].tolist() cls_id int(box.cls[0]) conf float(box.conf[0]) if cls_id 0: empty_boxes.append([x1, y1, x2, y2, conf]) elif cls_id 1: normal_boxes.append([x1, y1, x2, y2, conf]) empty_area sum((x2-x1)*(y2-y1) for x1, y1, x2, y2, _ in empty_boxes) normal_area sum((x2-x1)*(y2-y1) for x1, y1, x2, y2, _ in normal_boxes) total_area empty_area normal_area empty_rate empty_area / total_area if total_area 0 else 0 print(f检测到空置框 {len(empty_boxes)} 个, 正常商品框 {len(normal_boxes)} 个) print(f货架空置率(面积占比): {empty_rate:.2%})这段代码里conf0.35是置信度阈值货架场景比户外检测更容易受反光干扰建议新手先用0.35再看结果把阈值往0.25或0.45调。特别说明的是area占比只具备相对意义同一组货架不同机位角度算出来的空置率不同所以它更适合做同一机位的趋势对比而不是跨店绝对比较。如果要跨店比较需要引入第6章说的ROI分区。4.2 用混淆矩阵和PR曲线验证类别是否失衡只看mAP会把类别失衡问题藏起来。货架空置场景里empty类在整份数据里通常占比偏低normal类更多如果训练结果mAP50有0.85看fine但一查混淆矩阵发现empty的召回率只有0.5意味着有一半的空置区域根本没检测到。训练结束后ultralytics会在runs/detect/train目录下生成混淆矩阵图。看的时候只盯两个格子真实empty被预测成background的比例以及真实normal被预测成empty的比例。前者高说明空置样本不够或特征不明确后者高说明误检集中在正常商品被误判为空。解决类别失衡的方式有两个一是训练时给empty类加更高的cls_loss权重二是简单粗暴地复制empty样本做拷贝增强让模型多看几遍少数类。先试后者代码改动最小效果不够再加权重。4.3 固定机位连续帧融合5帧里4帧判空才报警货架监控是固定机位每一帧单独判断会有抖动某帧因为顾客的手挡住货道模型把normal判成了empty下一帧又恢复正常。真实项目里如果单帧就触发缺货告警运营会被假报警淹没。常见做法是把最近N帧的判定结果做滑动投票。from collections import deque class FrameVoter: def __init__(self, window5, min_votes4): self.window window self.min_votes min_votes self.history deque(maxlenwindow) def push(self, empty_flags): self.history.append(empty_flags) def vote(self): if not self.history: return [] n_roi len(self.history[-1]) final_flags [] for i in range(n_roi): votes sum(1 for frame_flags in self.history if frame_flags[i]) final_flags.append(votes self.min_votes) return final_flags voter FrameVoter(window5, min_votes4) voter.push([True, False, True]) voter.push([True, False, True]) voter.push([True, True, True]) print(voter.vote())两个参数里window是参与投票的帧数min_votes触发报警所需的最低票数。5帧里连续3帧判空才报警是偏保守配置如果现场确实缺货一般会持续几分钟选择更稳妥。这个类的输入empty_flags是每个ROI的空置判定结果它本身不含检测置信度置信度过滤要在上一环做完。多帧投票的另一层好处是遮挡问题手推车或顾客停留造成的短暂遮挡会导致误检投票机制天然滤掉了这类高频毛刺。5. 避坑货架空置检测在真实门店翻车的五个原因数据集没问题、训练能收敛不等于线上能用。这一章写的是我把这类方案落地时踩过和帮别人排查过的坑每一条都是现象、原因、解决三段式。5.1 双格式不同步改了一边标签另一边是旧的现象训练时用YOLO格式loss曲线前半段正常后期出现抖动验证集上总有几张图漏检严重手动看图片又觉得标注没错。排查发现txt标签里的框和图片内容对不上框偏了半个货道。原因数据包里同时提供VOC和YOLO格式标注人员修正了XML但txt没有重新生成训练的却是txt。解决强制约定以YOLO格式为准任何标注修改后跑一遍转换脚本重新生成txt每次训练前先跑2.3节的校验脚本把「最后修改时间晚于txt生成时间」的XML全部挑出来重新转换。5.2 空置类样本太少mAP好看但缺货漏检严重现象训练完看总体mAP50有0.87但到了线上空货架摆在镜头前愣是没报警。原因数据包里empty类本身占比可能不到三成模型在训练时学到了「大部分目标是normal」的先验推理时会倾向于把模糊区域判成normal。解决先看混淆矩阵里empty的召回率如果低于0.6把训练集中empty类的图片复制一份同时做左右翻转和马赛克增强再训一轮对比。如果还没效果才考虑给empty类的cls_loss加权重ultralytics中通过设置class weights参数传权重字典。5.3 灯光和反光是货架场景最大的分布偏移现象白天测试效果好晚上灯光切换后误检率上升20个点高货架上的塑料包装产生镜面反射把normal商品反出一块空白区域被模型判成empty。原因部分训练图是白天采的货架灯没开模型学到的是亮度特征而不是结构特征反光造成的空白区域和真实空置区域在纹理上高度相似。解决数据采集时把时段分开至少保证三成样本是灯光全开的晚间照片训练时加随机亮度、对比度增强推理前在预处理里对高光区域做一次饱和度压制把镜面反射的高光斑削弱后再送模型。5.4 层板边缘和价签造成结构误判现象空置区域和相邻正常商品的分界线常常不在商品边缘而在层板边缘价签被模型当成小目标商品框进normal。原因货架的结构线在图像里非常明显层板是横贯整张图的长直线模型很容易把层板边缘当作目标边界导致框的位置整体偏移价签的形状和尺寸接近小型商品。解决标注时约定框必须贴合商品本身不能贴到层板边缘价签如果和商品粘连建议忽略不标如果单独出现并且面积超过一定阈值标成normal再在推理端用小目标过滤逻辑处理把宽高小于30像素的检测框直接丢弃。5.5 反复用同一批验证集调阈值模型过拟合到验证集上现象在验证集上反复调conf阈值和NMS参数调到验证集召回率0.95换一批门店实拍图召回率掉回0.6。原因验证集成了调参的测试集本质上是拿验证集做过拟合真实分布被消耗掉了。解决数据划分时把test集彻底隔离训练、调参、选阈值都只看train和valtest集只在最终评估时动用一次另外建议从不同门店各抽一部分图组成test集保证分布覆盖而不是从同一批次里随机抽样。6. 进阶把空置检测落成「单层货架补货报表」的三个小技巧模型稳定后要让它真正进入巡检流程需要把检测结果从「图片上的框」转化成「哪个层板的哪个货道缺货」。这一步有三个技巧直接把项目推进到可用状态。先把单张图的检测结果映射到固定ROI。用标注工具在货架俯视图或正面图上框出每个层板对应的图像区域推理时只保留落在ROI内部的检测框按ROI分别计算空置面积占比。这样输出就从「整张图空置率20%」变成「第2层板第4货道空置率75%」补货员能直接按坐标去找货而不是看一张整图自己找位置。其次是采样策略。实时视频流对算力浪费很大货架缺货是一个缓慢变化的过程商品被买走到店员补货通常间隔几分钟到几十分钟。常见做法是设定每5分钟抓一帧关键帧做检测结合第4章的滑动投票既保证对缺货状态的捕捉又把平均计算负载降到接近零。我们在T4上以1路视频流为例持续全帧检测占用接近三成卡资源改成5分钟一帧后占用可以忽略不计省下来的资源还能挂更多门店。置信度平滑也是值得做的一件事。单帧检测置信度在阈值附近反复横跳时直接用conf做EMA平滑比直接二值化后做投票更早发现缺货趋势。做法很简单对每个ROI维护ema_conf alpha * ema_conf (1-alpha) * conf默认alpha0.7平滑后的置信度低于阈值才触发告警。这套组合做完缺货报表可以按小时、按天自动生成每层板缺货次数和持续时间都能统计出来。最后提一个我自己的教训上线前一定要留一周人工复核期。我们第一次部署时把所有缺货告警都当成真的结果发现某个货架的反光区域持续误报直到人工抽检才发现。后来强制要求每百条告警抽20条人工比对连续一周确认误报率降到可接受范围才放开自动通知。做这类视觉检测项目数据里有多少脏线上就有多少坑前期多花时间校验标注和验证集比后期半夜被报警电话叫醒划算得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进