简介:面向智能售货柜商品识别这一实际落地场景,这份目标检测训练集采用Pascal VOC标准格式,用户拿到后无需清洗或坐标转换,即可直接接入YOLO、SSD、Faster R-CNN等主流检测框架。压缩包内共2000个文件,全部为xml标签文件,整体约993MB,配合对应训练图像可快速构建标准数据集;讲解有序的命名规则有助于按文件名快速定位图像与标注的对应关系。适合算法工程师、科研人员及计算机视觉方向学生用于模型训练、性能对比和论文实验。数据集中包含类别名称txt,便于训练时高效完成标签映射,减少额外编码工作;数据集划分上,训练集与验证集、测试集分卷发布,更有助于开展严谨的模型评估与调参。目前已有289人学习浏览,尤其适合零售智能化、无人售货柜场景下的商品检测与计数任务,既可用于从零训练,也可作为预训练模型微调的数据基础。
1. 智能售货柜商品训练集:VOC标注格式下能直接跑的目标检测起点
上周有个朋友让我搭售货柜视觉结算 demo,第一个问题不是算法,而是数据:2950 张训练图像加 2950 个 VOC 格式 XML 标签,压缩后约 1GB,附带一个类别 txt。这个体量做商业级识别不够,但把“图像采集 → 标注解析 → 模型训练 → 边界排查”的完整链路跑通,足够了。很多人拿到这种智能售货柜目标检测数据集后,习惯性先去 PaddleDetection 这类框架里找现成配置,其实 VOC 标注格式已经是目标检测里最通用的交换格式,YOLO、SSD、Faster R-CNN 都有成熟工具做转换。这篇文章围绕这份训练集拆开讲:XML 里每个字段到底在说什么、怎么转成 YOLO 能吃的标签、训练时在类别分布和数据划分上会踩到哪些坑。
2. VOC标注格式解析:从annotation到bndbox的坐标体系
2.1 XML的五个关键节点:size、object、bndbox与difficult
标准 VOC 标注文件以<annotation>为根节点,里面至少包含图像尺寸<size>、文件名<filename>和若干<object>目标块。真正决定检测框位置的是<bndbox>里的四个像素坐标:xmin、ymin、xmax、ymax。这份售货柜数据集的 XML 结构基本遵守这一套规范,因此解析难度不大,但有几个字段在实际处理时必须留意。
| 节点 | 含义 | 本项目可能出现的问题 |
|---|---|---|
size/width、size/height | 原图宽高,是坐标归一化的分母 | 如果与实际 JPG 分辨率不一致,转换后框会漂移 |
filename | 标注对应的图片文件名 | 历史工程里常与 XML 文件名不一致,不建议直接当索引 |
object/name | 商品类别字符串 | 注意空格、中文或全角字符,必须与 classes.txt 对齐 |
bndbox/xmin...ymax | 目标框左上角和右下角的像素坐标 | 少数框可能出现越界或xmax < xmin |
object/difficult | 难易标识,1 表示该目标比较难 | 是否丢弃会直接影响训练样本质量 |
拿到 XML 后我建议先做一个最基础的动作:解压后不要急着改格式,先看一个 XML 的内容,确认filename指向的图片是否存在。常见做法是用grep或者文本编辑器打开一个文件,观察size的宽高和同目录图片的实际分辨率是否一致。若来自不同采集批次,某些 XML 可能没有folder或segmented节点,但只要object和bndbox完整,就不影响训练。
2.2 用ElementTree批量解析2950个XML并统计类别分布
转换前先做一份全局体检,统计每个类别出现多少次、有多少 XML 没有目标、有没有非法坐标。这里用 Python 标准库xml.etree.ElementTree即可,不需要引入笨重的标注软件依赖。
import xml.etree.ElementTree as ET from pathlib import Path from collections import defaultdict xml_dir = Path("./Annotations") xml_files = sorted(xml_dir.glob("*.xml")) stats = defaultdict(int) empty_files = [] for xml_path in xml_files: root = ET.parse(xml_path).getroot() objs = root.findall("object") if not objs: empty_files.append(xml_path.name) continue for obj in objs: name = obj.find("name").text stats[name] += 1 print("XML 总数:", len(xml_files)) print("类别统计:", dict(stats)) print("空标注文件数:", len(empty_files)) print("示例:", empty_files[:5])这段代码先把所有 XML 路径排序,保证输出顺序可复现;然后逐文件解析object节点。stats用defaultdict(int)累积每个类别的实例数,空标注文件单独记录。输出的类别统计要和 resources 里的 classes.txt 逐项对比,重点看 classes.txt 里的类别是否都覆盖到了,以及有没有 XML 中出现了 classes.txt 之外的名称。如果出现缺失,说明部分 XML 是旧版标注,需要单独清洗。
统计之后一定要处理空标注文件。YOLO 训练允许一张图没有目标,但验证集评估时会被算作漏检,而且如果随机采样时正负样本比例失衡,训练早期 loss 会异常高。我的通用做法是把空标注 XML 对应的图片从训练目录移到exclude/文件夹,而不是直接删除原数据,方便后续回溯。
2.3 归一化坐标的换算逻辑
VOC 保存的是左上角和右下角的绝对像素坐标,而 YOLO 系列要求每行标签是class x_center y_center width height,并且所有数值归一化到 0~1。归一化公式很简单,但要搞清楚一个概念:分母是size里的原图宽高,不是某个固定值比如 640。否则缩放后被检测框会整体偏移。
def voc_to_yolo_norm(x1, y1, x2, y2, img_w, img_h): x_center = ((x1 + x2) / 2.0) / img_w y_center = ((y1 + y2) / 2.0) / img_h box_w = (x2 - x1) / img_w box_h = (y2 - y1) / img_h return x_center, y_center, box_w, box_h这里除法必须用浮点数,Python 3 中/已经是浮点除法,img_w、img_h建议从 XML 里读取而不是用别的途径硬编码。中心点加宽高的表示方式天然不受图像左右翻转的影响,这也是 YOLO 在做 Mosaic、随机裁剪增强时可以直接对标签做仿射变换的原因。如果你后续要跑 SSD,需要的是 VOC 格式或 LMDB 格式,则不需要做这一步,但理解这个映射关系能帮你排查为什么某个类别所有框都偏到左下角。实际排查时我会写一个简单脚本,从 XML 中随机抽 5 张图,在图上画框并打印归一化后的值,肉眼确认坐标方向正确再去批量转换。
3. 训练集迁移实战:VOC格式转YOLOv8的images/labels目录
3.1 目录规划:训练集、验证集与labels同级
这份智能售货柜商品数据集官方拆成了训练集、验证集和测试集三个资源,当前拿到的是训练集。因此本地目录要先按 YOLO 约定铺好,后面训练时就不需要再改路径配置。推荐结构如下:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ ├── classes.txt └── dataset.yamlUltralsytics 的 YOLOv8 默认会去images/的同级目录找labels/,比如图片在dataset/images/train/a.jpg,标签必须在dataset/labels/train/a.txt。目录层级和文件名前缀要一一对应,否则启动训练时会报大量 image 无标签的 warning。由于当前只有训练集,验证集需要先从训练集里按一定比例切出来,用于训练过程中的 loss 监控和 early stop,而不是直接用官方验证集,避免训练时偷偷看到评测数据。我会在转换脚本里把 10% 的图片划到val/,剩余 90% 留在train/。
3.2 完整转换脚本:从XML生成YOLO标签并划分验证集
下面是一个可以直接落地的转换脚本,假定 XML 放在./Annotations,classes.txt 放在脚本同级目录。脚本会把生成的标签按 9:1 比例随机拆到labels/train/和labels/val/,同时打印每个类别在 train 和 val 中的实例数,方便抽查切分是否均匀。
import xml.etree.ElementTree as ET from pathlib import Path import random xml_dir = Path("./Annotations") out_dir = Path("./labels") train_dir = out_dir / "train" val_dir = out_dir / "val" train_dir.mkdir(parents=True, exist_ok=True) val_dir.mkdir(parents=True, exist_ok=True) with open("classes.txt", encoding="utf-8") as f: class_names = [line.strip() for line in f if line.strip()] def convert_xml(xml_path, out_file): root = ET.parse(xml_path).getroot() size = root.find("size") if size is None: return img_w = float(size.find("width").text) img_h = float(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in class_names: continue cls_id = class_names.index(name) bbox = obj.find("bndbox") x1 = float(bbox.find("xmin").text) y1 = float(bbox.find("ymin").text) x2 = float(bbox.find("xmax").text) y2 = float(bbox.find("ymax").text) x1 = max(0.0, min(x1, img_w)) y1 = max(0.0, min(y1, img_h)) x2 = max(0.0, min(x2, img_w)) y2 = max(0.0, min(y2, img_h)) if x2 <= x1 or y2 <= y1: continue x_center = (x1 + x2) / 2 / img_w y_center = (y1 + y2) / 2 / img_h box_w = (x2 - x1) / img_w box_h = (y2 - y1) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") with open(out_file, "w", encoding="utf-8") as f: f.write("\n".join(lines)) random.seed(2025) xml_files = sorted(xml_dir.glob("*.xml")) for xml_path in xml_files: if random.random() < 0.10: out_file = val_dir / (xml_path.stem + ".txt") else: out_file = train_dir / (xml_path.stem + ".txt") convert_xml(xml_path, out_file) print("转换完成。train:", len(list(train_dir.glob('*.txt'))), "val:", len(list(val_dir.glob('*.txt'))))脚本核心是convert_xml函数,它把 XML 里的像素坐标先裁剪到图像范围内,再转成归一化中心点格式。裁剪这个动作很关键,因为售货柜监控画面边缘经常有半个商品,标注时可能写出xmax大于img_w的越界框,不裁掉会导致负宽度或超出特征图范围。类别名必须和 classes.txt 严格一致,顺序也不能乱,YOLO 标签里只存类别 id,一旦 classes.txt 顺序调整,所有标签都要重新生成。随机划分时设置random.seed(2025),保证每次执行结果一致;如果二次执行脚本,后写的 txt 会覆盖旧文件,不会重复累积。
3.3 生成dataset.yaml并启动YOLOv8训练
标签转换完成后,还要生成一个dataset.yaml。这里要特别注意 classes.txt 的读取顺序,names列表必须和脚本里的class_names完全一致,否则模型训练时类别对不上框。
path: /your/absolute/path/dataset train: images/train val: images/val nc: 2 names: - cola - water示例中的nc和names需要按实际 classes.txt 修改。path建议写绝对路径,YOLOv8 在 Windows 上偶尔会因为相对路径解析不出图片目录而直接报内存错误。类别少的情况下,train和val都可以简单用相对路径,但方便多机复现,我习惯统一绝对路径。
然后启动训练:
yolo detect train data=dataset.yaml model=yolov8s.pt epochs=150 batch=16 imgsz=640 patience=30| 参数 | 建议值 | 说明 |
|---|---|---|
epochs | 150 | 如果类别少且数据量小,100 轮左右就能收敛,设大一点配合 early stop |
batch | 8~16 | 售货柜图像多为 1080p,batch 过大容易显存溢出 |
imgsz | 640 或 768 | 商品在柜内占比往往不大,低于 640 会把小目标越缩越小 |
patience | 30 | 验证集 mAP 连续 30 轮不提升就停止,节省训练时间 |
如果你的目标是拿现有权重做对比实验,从yolov8s.pt开始比从头训练更容易收敛。若想验证 VOC 格式到 SSD 的迁移,思路也类似:先把 XML 转成标准 VOC 并整理成JPEGImages、Annotations、ImageSets/Main三个目录,再用ssd_pascal.py里的create_data_lmdb脚本生成 SSD 需要的 LMDB。但实际项目里从数据量上看,YOLOv8 更省事,SSD 可以作为后续对比基线而不是首选。
4. 训练集排坑:类别不平衡、difficult标记与数据切分
4.1 用awk与Python统计类别分布
训练集转换完成后,第一件事是看类别分布是否均匀。直接用 awk 统计 YOLO 标签中每类出现次数,比看 XML 更快:
cat labels/train/*.txt | awk '{c[$1]++} END {for (i in c) print i, c[i]}' | sort -k2nr这条命令把labels/train/下所有 txt 文件拼到一起,按空格切分后取第一列类别 id 计数,最后按数量降序排列。输出结果如果某个类别数量是另一个的 20 倍,训练时模型会偏向多数类,少数类 AP 可能掉到 0.3 以下。售货柜商品里常见的是矿泉水、可乐这类流量商品的样本远多于口香糖,这时我不会盲目加样本,而是先给少数类配上更低的学习率或使用类别平衡采样。具体实现上可以在 YOLOv8 训练时对包含少数类的图片做重复采样,或者轻量一点,直接用 PIL 统计每个类别的框面积分布,看是不是小目标占比过高。
4.2 difficult=1的商品框该丢弃还是保留
标准 VOC 里difficult表示目标不清晰、遮挡严重或极小,官方 VOD 评测时这些框不参与 AP 计算。但这套售货柜数据采集自真实监控环境,货柜边缘的商品经常被柜门边框截断,如果全部丢弃,相当于主动去掉一批难样本,模型的泛化能力会打折。我的建议是分场景处理:
| 策略 | 适用场景 | 代价 |
|---|---|---|
直接丢弃difficult=1 | 类别干净、目标完整度高 | 对截断商品漏检严重 |
| 保留且不特殊处理 | 大部分框质量尚可 | 低质量框可能干扰收敛 |
| 保留但降低 loss 权重 | 想学边缘商品又不想被带偏 | 需要改 loss,成本高 |
如果你经过观察决定丢弃,只需在上一章的转换脚本中增加一个判断:
for obj in root.findall("object"): diff = obj.find("difficult") if diff is not None and diff.text.strip() == "1": continue这段代码在遍历object节点时先检查difficult文本,为 1 就跳过。注意difficult节点不一定存在于所有 XML 中,所以用is not None保护。从实际经验看,售货柜识别宁可保留一部分 difficult 样本,因为用户把商品拿出再放回时,大量动作都发生在遮挡状态下。
4.3 按文件名时间戳切分训练验证集,避免同一商品泄漏
售货柜视频流每秒产生大量近似帧,如果直接把 XML 列表按 9:1 随机切,同一个商品的连续帧会同时出现在训练集和验证集里,最后得到的 mAP 会虚高。这里关键在于按文件名里的时间戳前缀分组。项目中的文件名类似ori_XYG2020122914213602358911-1_0.xml,中间的2020122914213602358911可以看作采集时间戳,后面的-1_0可能是相机编号或帧序号。按时间戳前缀分组后,再把整个组放入训练集或验证集,能最大程度避免“同物同帧”泄漏。
from collections import defaultdict import random, re def timestamp_key(xml_name: str): m = re.match(r"ori_XYG(\d{17})-\d+_\d+", xml_name) return m.group(1) if m else None groups = defaultdict(list) for xml_path in xml_files: key = timestamp_key(xml_path.stem) if key: groups[key].append(xml_path) group_items = list(groups.values()) random.Random(42).shuffle(group_items) split_idx = int(len(group_items) * 0.9) train_groups = group_items[:split_idx] val_groups = group_items[split_idx:]这段代码先把同一时间戳下所有 XML 聚到同一个列表,再打乱组顺序切分,而不是打乱单张 XML。random.Random(42)显式指定随机种子,方便不同机器复现结果。切分完成后,训练集图片数可能和官方训练集总数不完全一致,因为一个时间戳组里通常包含多张近似帧,这是合理的。后续如果下载了官方验证集和测试集资源,不要再执行这里的二次切分,直接使用官方划分即可。
切分后还建议做一次文件名核对:labels/train里的每个 txt 是否能和images/train里的图片一一对应。常见做法是执行ls images/train | wc -l和ls labels/train | wc -l对比数量,若有差异,用comm -23找出缺失的项,避免训练时大量跳过。
5. 提升售货柜商品小目标检测的进阶技巧
5.1 增强策略与多尺度训练参数
售货柜监控视角固定,但商品尺寸跨度很大,底层货架的商品在画面里可能只有 40×60 像素。YOLOv8 默认的增强参数偏通用目标检测,对这类小目标场景可以针对性调整。训练时在命令行追加增强参数即可覆盖默认配置:
yolo detect train data=dataset.yaml model=yolov8s.pt epochs=150 batch=16 imgsz=640 hsv_h=0.015 hsv_s=0.7 hsv_v=0.4 scale=0.5 fliplr=0.0 mosaic=1.0 mixup=0.2hsv_h、hsv_s、hsv_v控制色调、饱和度和明度的扰动幅度,售货柜不同批次商品外包装颜色会有差异,给一点 HSV 扰动有助于模型不依赖特定色相。fliplr=0.0是这里的关键,饮料瓶和零食包装上的文字、logo 做水平翻转后会变成镜像,推理时反而增加误检。scale=0.5控制图像缩放扰动,配合 Mosaic 增强能让模型看到更多尺寸下的商品。如果发现小目标表现还是很差,可以把骨干网络换成yolov8s-p2.yaml,让模型输出 P2 层更大分辨率的特征图。
5.2 用预测结果反向验证难样本
训练完成后,不要只看验证集 mAP。售货柜结算场景更关心漏检和重复计费误差,所以我一般会在验证集上跑一轮预测,把置信度阈值调到 0.25 输出所有框,再和 XML 标签对比,重点看模型漏掉的是不是difficult=1的截断商品。
yolo predict model=runs/detect/train/weights/best.pt source=images/val conf=0.25 save_txt=True这条命令会为每张验证图生成一个对应 txt,保存到runs/detect/predict/labels。对比模型输出和 ground truth 时,使用 IoU 0.5 作为判定阈值,不要用 0.75,因为密集场景下商品框本身目视差异就很小。如果某个类别的漏检集中在画面左下角,则优先考虑在原始图像上做区域感知增强,或者干脆把摄像头角度往后拉,减少边缘畸变对目标框的影响。
本文还有配套的精品资源,点击获取