ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手提袋检测数据集制作:VOC与YOLO标签格式转换实战指南

2026/9/15 3:42:39 拓冰建站 浏览量
手提袋检测数据集制作:VOC与YOLO标签格式转换实战指南 简介手提袋检测数据集是从COCO2017中提取并按目标检测常用格式整理的第二部分数据面向需要训练YOLO手提袋识别模型或搭建VOC格式训练集的开发者可直接用于模型训练、验证与数据增广。资源目标类别统一为handbag有效图像样本7133张图像来自真实场景覆盖不同角度、遮挡和光照条件。zip压缩包约395.33MB按文件类型明细统计资源包内文件总数超过2.1万个主要包含7133张jpg原图、7134个txt标签YOLO格式和7133个xml标签VOC格式标注与图像一一对应解压后即可对接常见训练脚本也能将xml转换为COCO等格式。目前已有490人学习浏览。与第一部分数据配合使用可进一步扩大样本规模适合需要公开真实场景数据快速验证检测算法或补充手提袋专项数据集的开发者。1. 手提袋检测数据集为什么绕不开 VOC 格式与 YOLO 格式标签很多人第一次做手提袋检测数据集往往不是被标注工作本身难住而是被标签格式折腾到返工VOC 格式要求把每个目标写成xmin, ymin, xmax, ymax像素坐标YOLO 格式要求归一化到 0 到 1 的浮点数类别还要从字符串变成整数编号。同一个机型、同一批图片只是换一个训练框架就必须在两种格式之间来回转换。实际做零售、商超或垃圾分类项目时通常先手工标注一批再交给检测模型训练中间必然会碰到格式解析、目录组织和坐标校验的问题。这篇文章会把手提袋检测数据集从采集规范、labelme 标注、VOC XML 生成、YOLO txt 转换到最终验证的路径完整走一遍让算法工程师和数据工程师拿到图片后不会被标签处理卡住读完你至少能自己写出转换脚本并在训练前把坐标和类别错误揪出来。2. 手提袋检测数据集制作前的三类准备采集、标注、类别平衡采集和标注决定数据集的上限VOC 与 YOLO 格式只是同一个标注的不同表达。手提袋这类目标看起来边界简单但袋口折叠、提手遮挡、半透明材质都会让标注口径漂移如果一开始不统一采集场景和标注边界后面转出 XML、txt 都会带着隐性错误俗称“垃圾进垃圾出”。2.1 采集多样性与最少样本量我一般把初始规模定在不少 1500 张图、每个类别的实例不少于 300 个分辨率尽量大于 640×640。YOLO 训练时会做 letterbox 缩小原图太小会让提手和袋口的纹理糊掉原图太大则标注成本翻倍后期还可以靠增强补充。更关键的指标是实例的分布手提袋出现在收银台、货架、地堆、手持行走和室外五种环境光线和遮挡模式完全不同只拍一个机位会让验证集虚高。采集场景需要覆盖的变量建议占比收银台袋口折叠、反光、手柄反向20%货架与地堆半遮挡、袋体挤压、标签遮挡25%手持行走运动模糊、背景杂乱、手柄变形30%室外与光照突变过曝、阴影、逆光15%负样本背景无袋子但含类似纹理的物体10%负样本在表格里占 10%很多人会忽略。检测器在训练时如果没有“空样本”参与上线后容易把塑料袋、背包、纸箱当作手提袋负样本图片不放进标注集让训练框架在生成正样本的同时也能看到背景干扰。2.2 labelme 标注手提袋的边界细节与 JSON 标签字段标注阶段我会用 labelme 保存 JSON 文件再转格式而不是直接在标注工具里导出 XML。常见做法是执行下面这条命令labelme /data/handbags/images --labels labels.txt --autosave --nodata--labels labels.txt指定下拉列表里的类别名比如每行一个paper_bag、plastic_bag、cloth_bag防止手打同一个类别出现大小写不一致--autosave标注完一张自动落盘--nodata不把图片 base64 写进 JSON单文件体积小很多批量转换时不会撑爆内存。注意 labelme 的 JSON 结构里shapes[].label存类别名shapes[].points存多边形的原始点坐标后续转 VOC 或 YOLO 都要依赖这两个字段。标注手提袋时最好先约定边界规则提手呈拱形时把整个外轮廓框进去包含提手最高点提手被手或物品挡住则从遮挡位置沿最短路径闭合。不要只标袋身而把提手切出去否则模型的预测框会在提手和袋身之间来回跳。转格式前先统计一下类别分布避免某个类别只有几十个实例而其它类别上千个。下面这段脚本读所有 JSON 并打印每个类别的数量import glob import json import collections counts collections.Counter() for json_path in glob.glob(/data/handbags/images/*.json): with open(json_path, r, encodingutf-8) as f: data json.load(f) for shape in data[shapes]: counts[shape[label]] 1 print(total instances:, sum(counts.values())) for label, num in counts.items(): print(f{label}\t{num})脚本遍历images目录下所有 labelme 生成的 JSON用Counter按shape[label]聚合得到每个类别的手提袋实例总数。encodingutf-8是必须的类别名如果是中文或带特殊字符默认编码会上出错。如果发现某个类别占比低于 10%先扩充采集再继续不要靠简单复制粘贴来凑数重复样本会让训练集和验证集审美失调。2.3 类别编号先定死避免翻工VOC 标签里类别是字符串YOLO 标签里类别是整数两者靠classes.txt的排序对齐。很多项目在标注中途增加类别或调整顺序结果所有 YOLO txt 都要重新生成一遍教训都来自“先标后想”的习惯。所以我在动手标注前就固定一个classes.txt例如只保留手提袋这个大类时一行handbag就够如果细分材质则必须按下面的表约定类别名VOCname标签YOLOclass_id纸袋paper_bag0塑料袋plastic_bag1布袋cloth_bag2这个映射表要放进数据集根目录并同步提供一个classes.yaml或classes.json方便训练框架读取。注意不要给“无袋”“背景”单独设置类别编号负样本图片直接放进没有标签的文件夹训练框架会把它们当作背景参与 loss 计算而不是让模型学一个“无袋”类别后者的特征空间和真实类别混在一起会让置信度输出变得不可解释。3. VOC 格式手提袋数据集XML 标签结构、批量生成与合法性校验VOC 格式是目标检测领域用得最久、兼容性也最好的标签结构之一。它把每张图片的标注放在一个独立 XML 文件里坐标是像素绝对值训练前由框架自行解析许多老版 SSD、Faster R-CNN 和自定义数据加载脚本也只认这种目录。对手提袋检测来说VOC 格式的优势是查看方便、便于人工修订出问题时一眼就能看出xmin和ymax是否越界。3.1 理解 VOC XML 字段与数据集目录组织VOC 格式的目录通常是这样排布的VOC2007/JPEGImages放原图VOC2007/Annotations放 XMLVOC2007/ImageSets/Main放训练、验证和测试的图片列表。换到实际项目里我一般把数据集根目录命名为handbag_voc但内部保持JPEGImages和Annotations这两个大写开头的目录不变因为很多训练脚本硬编码了这些路径。XML 里不记录图片的绝对路径而是用folder和filename提示这点和 COCO 格式完全不同。下面的表格列出手提袋检测数据集里最常用的 XML 字段批量生成脚本时只要保证这些字段存在训练框架就不会报错。字段路径含义手提袋标注示例/annotation/folder原图所在目录名JPEGImages/annotation/filename图片文件名000001.jpg/annotation/size/width图像宽度640/annotation/size/height图像高度480/annotation/size/depth通道数3/annotation/object/name类别名paper_bag/annotation/object/truncated是否截断0/annotation/object/difficult是否难例0/annotation/object/bndbox外包框坐标xmin, ymin, xmax, ymaxtruncated表示物体是否被图像边界截断difficult表示目标太小或遮挡严重二者在训练时通常会被过滤。手提袋检测场景里我默认都填 0除非明确要做难例挖掘否则模型会因为样本量不足而更快过拟合。3.2 用 Python 从 labelme JSON 批量生成 VOC XML从 labelme JSON 转成 VOC XML 的核心逻辑很简单把多边形的所有顶点重新映射为外接矩形。虽然 labelme 支持画矩形但多人协作时难免出现五边形或不规则形状统一取最小和最大的 x、y 坐标生成 bndbox至少能保证训练框是矩形。下面这段脚本是按单张图片处理的适合放在循环里跑整个目录import glob import os import json import xml.etree.ElementTree as ET CLASSES [paper_bag, plastic_bag, cloth_bag] def labelme_to_voc(json_path, xml_out_dir): with open(json_path, r, encodingutf-8) as f: data json.load(f) image_path data[imagePath] img_name os.path.basename(image_path).rsplit(., 1)[0] .jpg width data[imageWidth] height data[imageHeight] root ET.Element(annotation) ET.SubElement(root, folder).text JPEGImages ET.SubElement(root, filename).text img_name source ET.SubElement(root, source) ET.SubElement(source, database).text HandbagDataset size ET.SubElement(root, size) ET.SubElement(size, width).text str(width) ET.SubElement(size, height).text str(height) ET.SubElement(size, depth).text str(3) for shape in data[shapes]: label shape[label] if label not in CLASSES: continue points shape[points] xs [p[0] for p in points] ys [p[1] for p in points] obj ET.SubElement(root, object) ET.SubElement(obj, name).text label ET.SubElement(obj, pose).text Unspecified ET.SubElement(obj, truncated).text 0 ET.SubElement(obj, difficult).text 0 bndbox ET.SubElement(obj, bndbox) ET.SubElement(bndbox, xmin).text str(int(min(xs))) ET.SubElement(bndbox, ymin).text str(int(min(ys))) ET.SubElement(bndbox, xmax).text str(int(max(xs))) ET.SubElement(bndbox, ymax).text str(int(max(ys))) tree ET.ElementTree(root) out_name img_name.replace(.jpg, .xml) tree.write(os.path.join(xml_out_dir, out_name), encodingutf-8, xml_declarationTrue) for jp in glob.glob(/data/handbags/images/*.json): labelme_to_voc(jp, /data/handbags/handbag_voc/Annotations)外层循环先用glob找到全部 JSONlabelme_to_voc负责单个文件转换。imagePath在标注时如果被移动过取basename再拼.jpg比直接用原始路径更安全imageWidth和imageHeight是从 JSON 头部读出的必须与当前图片一致否则后面 YOLO 归一化会整体偏移。min(xs)和max(xs)把多边形转成矩形int()把浮点数取整XML 里不能写小数。3.3 XML 合法性校验坐标越界与重复文件名批量转换完成后不要急着删 JSON先用一个短脚本检查 XML 是否合法。我习惯把校验放在生成之后、格式转换之前这样可以反查 labelme 原文件而不是等 YOLO 训练到一半才发现坐标全是负数。下面这个脚本会遍历所有 XML找坐标越界和宽高为 0 的框import glob import os import xml.etree.ElementTree as ET xml_dir /data/handbags/handbag_voc/Annotations image_dir /data/handbags/handbag_voc/JPEGImages for xml_path in glob.glob(os.path.join(xml_dir, *.xml)): root ET.parse(xml_path).getroot() width int(root.findtext(size/width)) height int(root.findtext(size/height)) filename root.findtext(filename) if not os.path.exists(os.path.join(image_dir, filename)): print(missing image:, xml_path, filename) for obj in root.iter(object): xmin int(obj.findtext(bndbox/xmin)) ymin int(obj.findtext(bndbox/ymin)) xmax int(obj.findtext(bndbox/xmax)) ymax int(obj.findtext(bndbox/ymax)) if xmin 0 or ymin 0: print(negative coordinate:, xml_path, xmin, ymin) if xmax width or ymax height: print(out of bounds:, xml_path, xmax, ymax) if xmin xmax or ymin ymax: print(zero area box:, xml_path)findtext(size/width)取size节点下的width比连续find更简洁。越界判断用而不是是因为很多图像解码库会把右下角坐标当作“不超过 width 减 1”的像素如果xmax等于宽度实际已经超出画布。文件名重名问题也在这里暴露如果两个 JSON 的imagePath撞名后写入的 XML 会覆盖先前的标注最好在采集阶段就统一重命名为000001.jpg这样的递增名。4. YOLO 格式手提袋标签坐标归一化换算与数据集划分YOLO 系列训练框架通常直接读取.txt标签文件每行表示一个目标不再需要额外的描述文件。因为坐标经过归一化所以同一个标签文件在不同分辨率下都能复用这是它比 VOC XML 更受模型训练脚本欢迎的原因。但这也带来一个要求转换时必须拿到正确的图片宽高一旦宽高填错整个数据集的框都会偏移。4.1 从 VOC XML 到 YOLO txt 的换算公式与类别映射VOC XML 中记录的是绝对像素坐标xmin, ymin, xmax, ymaxYOLO 需要的是归一化后的中心坐标与宽高。换算公式可以是x_center ((xmin xmax) / 2) / width y_center ((ymin ymax) / 2) / height w (xmax - xmin) / width h (ymax - ymin) / height四个结果都会落到 0 到 1 之间。注意这里用的是加法和除法不是把xmax减xmin之后直接除以宽中心点计算错误会让框整体平移。下面这个例子用于验证公式一个手提袋在 640×480 的图中xmin100, ymin50, xmax300, ymax200则x_center0.3125y_center≈0.2604w0.3125h≈0.3125。如果转出的x_center等于 0.7 以上基本可以断定把原始坐标顺序写反了。下面这段代码遍历 VOC 的 XML生成对应的 YOLO txt 标签import os import glob import xml.etree.ElementTree as ET CLASSES [paper_bag, plastic_bag, cloth_bag] def voc_xml_to_yolo_txt(xml_path, label_out_dir): root ET.parse(xml_path).getroot() width int(root.findtext(size/width)) height int(root.findtext(size/height)) filename root.findtext(filename) label_name os.path.splitext(filename)[0] .txt out_path os.path.join(label_out_dir, label_name) with open(out_path, w, encodingutf-8) as f: for obj in root.iter(object): name obj.findtext(name) if name not in CLASSES: continue cls_id CLASSES.index(name) xmin float(obj.findtext(bndbox/xmin)) ymin float(obj.findtext(bndbox/ymin)) xmax float(obj.findtext(bndbox/xmax)) ymax float(obj.findtext(bndbox/ymax)) x_center ((xmin xmax) / 2) / width y_center ((ymin ymax) / 2) / height w (xmax - xmin) / width h (ymax - ymin) / height f.write(f{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}\n) xml_dir /data/handbags/handbag_voc/Annotations label_dir /data/handbags/handbag_yolo/labels os.makedirs(label_dir, exist_okTrue) for xml_path in glob.glob(os.path.join(xml_dir, *.xml)): voc_xml_to_yolo_txt(xml_path, label_dir)代码中CLASSES.index(name)直接取 list 下标所以CLASSES的顺序必须和标注阶段定死的classes.txt一致。:.6f保留六位小数对 640 分辨率的图足够精确如果图片分辨率超过 4000×3000建议保留八位小数避免框边缘出现亚像素误差。encodingutf-8同样要写防止类别名非 ASCII 时写入失败。4.2 目录组织与训练集/验证集划分脚本YOLO 训练目录的最常见布局是images和labels两个平级目录下面再分子集文件夹例如images/train、images/val、labels/train、labels/val。另一种做法是把所有图片和标签放在一个大目录再写一个train.txt列出每张图片的绝对路径训练框架从路径推导标签路径。下面这段脚本按 8:1:1 划分训练、验证和测试并生成两个文本索引import os import glob import random voc_xml_dir /data/handbags/handbag_voc/Annotations image_src /data/handbags/handbag_voc/JPEGImages yolo_root /data/handbags/handbag_yolo train_txt os.path.join(yolo_root, train.txt) val_txt os.path.join(yolo_root, val.txt) test_txt os.path.join(yolo_root, test.txt) xml_paths glob.glob(os.path.join(voc_xml_dir, *.xml)) random.seed(42) random.shuffle(xml_paths) n len(xml_paths) split1 int(n * 0.8) split2 int(n * 0.9) sets { train: xml_paths[:split1], val: xml_paths[split1:split2], test: xml_paths[split2:], } for subset_name, file_items in sets.items(): out {train: train_txt, val: val_txt, test: test_txt}[subset_name] with open(out, w, encodingutf-8) as f: for xml_path in file_items: img_name os.path.basename(xml_path).replace(.xml, .jpg) img_path os.path.join(image_src, img_name) label_name os.path.basename(xml_path).replace(.xml, .txt) label_path os.path.join(yolo_root, labels, label_name) if os.path.exists(img_path) and os.path.exists(label_path): f.write(img_path \n)random.seed(42)保证每次运行划分结果一致第 80% 到 90% 的样本进验证集最后 10% 作为测试集。写入前同时检查图片和标签是否存在防止把只有 XML 没有图片的坏样本写进索引。如果你的数据集后续会做量化或重新训练测试集最好单独保留不要每次划分都重新洗牌否则不同实验之间没有可比性。4.3 转换后自查用 OpenCV 画框验证归一化坐标训练前必须用可视化脚本抽检至少十几张图片因为数学公式写错时文件不会报错只有画出来的框会表现得非常离谱。下面这段代码读取 YOLO 标签把归一化坐标回算成像素再用 OpenCV 在图片上画矩形框import cv2 img_path /data/handbags/handbag_yolo/images/train/000001.jpg label_path img_path.replace(/images/, /labels/).replace(.jpg, .txt) img cv2.imread(img_path) ih, iw img.shape[:2] with open(label_path, r, encodingutf-8) as f: for line in f: cls_id, x_c, y_c, w, h map(float, line.split()) x1 int((x_c - w / 2) * iw) y1 int((y_c - h / 2) * ih) x2 int((x_c w / 2) * iw) y2 int((y_c h / 2) * ih) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, str(int(cls_id)), (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 1) cv2.imwrite(/tmp/handbag_check.jpg, img)label_path这一行的replace依赖相对目录结构图片在images/train标签必须在labels/train如果你改成其它目录替换逻辑也要跟着改。(x_c - w / 2)是把中心点转换成左上角(x_c w / 2)是右下角再乘iw或ih恢复到像素坐标。检查时重点看框是否套在提包外侧、是否出现半个框在图像外以及类别编号是否对应classes.txt里的顺序。可视化验证通过之后这个数据集才算真正能喂给训练脚本。5. 数据增强后的标签一致性检查翻转、拼接与抽检验证5.1 水平翻转和 Mosaic 拼接时的标签同步手提袋检测数据集在训练时通常要开启 Mosaic 增强把四张图拼到一张画布上这样能显著提升小目标检测效果。增强不是只改图片标签必须跟住目标的新位置。水平翻转最简单翻转后x_center变成1 - x_centerw不变垂直翻转则处理y_center。Mosaic 拼接时四张子图各自缩放后平移到画布的四个象限子图的归一化坐标要先还原成原图像素再按缩放比例scale和偏移量dx, dy映射到画布最后除以画布宽高。关键代码可以抽象成这样def move_to_canvas(box, src_shape, canvas_shape, dx, dy, scale): x_c, y_c, w, h box x_c * src_shape[1] y_c * src_shape[0] w * src_shape[1] h * src_shape[0] x_c (x_c * scale dx) / canvas_shape[1] y_c (y_c * scale dy) / canvas_shape[0] w (w * scale) / canvas_shape[1] h (h * scale) / canvas_shape[0] return x_c, y_c, w, hsrc_shape是(high, width)canvas_shape是拼接后画布尺寸。传入的box是原始 YOLO 归一化坐标先乘宽度还原像素再缩放和平移。dx、dy是子图左上角在画布上的像素坐标通常是 0 或画布边长的一半。这个函数只处理单框实际增强框架会在__getitem__里对每个目标循环调用。颜色抖动、随机亮度和对比度不改变坐标不需要额外处理但旋转增强会同时改变宽高比和归一化坐标的换算就不是这么简单了手提袋检测场景里我一般只开小角度旋转或干脆关闭。5.2 抽检十个文件把 VOC 与 YOLO 坐标对齐检查增强逻辑写完还要做一次全流程抽检。我每次转换完会随机挑十张图同时打开 VOC XML 和 YOLO txt把 XML 里的像素坐标手动转换成归一化值与 txt 里的值比较偏差超过 1 个像素就要查代码。直接用 4.3 的画框脚本也可以但更稳妥的办法是写一个独立比对函数先解析 XML 得到x1, y1, x2, y2再读取 txt把回算出的像素坐标算出来最后比较两个矩形的中心点偏移和面积比例。中心点偏移超过 2 像素、宽高差超过 1 像素就说明imageWidth或imageHeight与实际图不一致。常见原因有两类一类是标注后图片被脚本压缩或重编码尺寸变化但 XML 没更新另一类是classes.txt顺序被改过导致 txt 里class_id对应的类别名和 XML 里的name对不上。校验通过后再做增强或训练标签一致性才算真正闭环。本文还有配套的精品资源点击获取