简介:面向目标检测学习者和开发者,这份数据集专门针对“垃圾箱”这一常见城市物体,提供一套经过人工标注的VOC格式样本,适用于需要识别垃圾箱的检测任务。包内共包含457张JPG图片与457个XML标注文件,总计914个文件,压缩包约26.79MB,体积小巧便于获取;图片尺寸在1-500KB之间,类别统一为dustbin,涵盖不同场景下的垃圾箱目标,标注信息完整。所有标注均使用labelImg工具逐张完成,并严格遵循精确框选目标边界、不漏标目标以及多标注者交叉一致性检查等规范,确保标注质量可靠,可直接用于模型训练与验证。目前已有94人学习,可作为目标检测算法对比实验的数据基础,也适合作为教学案例;解压后图片与标注文件分目录存放,无需解压密码,符合VOC标准组织方式,能够快速接入YOLO、Faster R-CNN等常见检测框架。
1. 垃圾箱目标检测为什么盯上VOC格式的457张小数据集
一个智慧环卫项目常见的数据形态是:巡检车在园区跑几圈,拍下几百张含垃圾箱的照片,标注员用 LabelImg 框出目标,导出就是一份 VOC 格式的 XML 标注。457 张这个量级在目标检测里不算大,但它恰好覆盖「一个场景、一类目标、固定视角」的专用识别任务,比如判断某个垃圾箱有没有满溢、有没有被移走、箱门有没有打开。很多人拿到这种数据的第一反应是「太少」,转头去混入公开数据集,结果引入外观差异极大的垃圾桶,反而把模型训崩。VO C格式的优势在于标注信息完整、目录规范,转换到 YOLO 或 COCO 都方便;小样本的难点则在于划分和增强。下面要讲的流程,就是围绕这 457 张 VOC 标注数据,把目录拆开、把标注查一遍、再把它喂进检测模型。
2. 拆开垃圾箱数据的VOC标注格式:三个目录和XML字段各管什么
2.1 典型目录结构:JPEGImages、Annotations、ImageSets 怎么组织457张样本
VOC 格式是从 Pascal VOC 挑战赛沿袭下来的标注规范,标注工具(LabelImg)默认就导出这种结构。拿到数据先看目录,标准布局通常是:
trash_bin_dataset/ ├── Annotations/ # 457 个 XML 标注文件 ├── JPEGImages/ # 457 张原始图像 └── ImageSets/ └── Main/ ├── train.txt ├── val.txt ├── trainval.txt └── test.txtJPEGImages放原图,Annotations放同名 XML,ImageSets/Main下的 txt 记录训练和验证用的图像文件名(不带扩展名,每行一个)。后面做数据划分、写数据集类、转 YOLO 格式,都围绕这三个目录操作。注意一点:ImageSets有时不存在,有些标注工具只导出图片和 XML,那么 train/val 划分就需要你自己生成,后面第 4 章会讲。
2.2 一个垃圾箱XML里值得读的字段:bndbox、truncated、difficult
打开一个 Annotations 下的 XML,核心内容长这样:
<annotation> <folder>JPEGImages</folder> <filename>trash_001.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>trash_bin</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>512</xmin> <ymin>300</ymin> <xmax>720</xmax> <ymax>610</ymax> </bndbox> </object> </annotation>size字段记录图像实际宽高,这个值在转换坐标时要用;object里name是类别名,bndbox四个值分别表示目标的左上角 x、y 和右下角 x、y,单位是像素。truncated表示目标是否超出图像边界,difficult表示该目标是否属于难例,部分训练框架(如 MMDetection 的 VOC 数据集类)默认会过滤掉difficult=1的框,所以这两个字段会直接影响训练样本数,别忽略。
2.3 VOC、COCO、YOLO三种坐标语义的差异决定了转换方式
目标检测数据集标注格式里,VOC、COCO、YOLO 是三种最常见的坐标表示方式,差异集中在两处:边框表达方式和坐标是否归一化。
| 格式 | 存储方式 | 边框字段 | 坐标特点 |
|---|---|---|---|
| VOC | XML 文件,每张图一个 | xmin, ymin, xmax, ymax | 绝对像素坐标 |
| COCO | 单个 JSON 文件 | x, y, width, height | 绝对像素坐标,左上角+宽高 |
| YOLO | txt 文件,每行一个目标 | class, x_center, y_center, width, height | 相对图像尺寸归一化到 0~1 |
这里有个新手容易踩的坑:VOC 的bndbox给的是两个对角点,转 YOLO 时要先算中心点再除以图像宽高;转 COCO 时要先算宽高再写进 JSON。如果直接把xmax、ymax当宽高用,模型训练时边框会整体偏到右下角,而且验证 mAP 会异常高——因为模型学会了「预测一个右下的框就能命中一半区域」。后面第 3 章的转换代码会把这个逻辑完整走一遍。
3. 用Python解析垃圾箱VOC标注:类别统计、画框核对和批量转YOLO
3.1 用ElementTree统计457个XML里的目标类别和空标注
拿到 457 个 XML,先别急着训练,第一步是统计类别分布。垃圾箱这类数据容易出现类别名不统一的情况:有人标trash_bin,有人标garbage_can,甚至有的文件里混入了Trash_Bin。用 Python 标准库的xml.etree.ElementTree就能快速盘点,不需要额外装 lxml:
import os import xml.etree.ElementTree as ET from collections import Counter annotations_dir = "Annotations" name_counter = Counter() empty_files = [] for f in os.listdir(annotations_dir): if not f.endswith(".xml"): continue tree = ET.parse(os.path.join(annotations_dir, f)) root = tree.getroot() objects = root.findall("object") if not objects: empty_files.append(f) for obj in objects: name = obj.findtext("name").strip() name_counter[name] += 1 print("XML 文件总数:", len([f for f in os.listdir(annotations_dir) if f.endswith(".xml")])) print("类别统计:", dict(name_counter)) print("空标注文件:", empty_files)findall("object")拿到一个 XML 里的所有目标框,findtext("name")读取类别名,.strip()去掉标注时可能带入的首尾空格。这里用 Counter 而不是手动维护字典,后续如果要按类别过滤或者做长尾分析会方便很多。空标注文件单独列出来,后面训练时要决定是剔除还是保留——如果一张图没有垃圾箱但出现在验证集里,它会影响模型的误报率评估,不能简单忽略。
3.2 把bndbox画回JPEGImages,肉眼核对标框位置
统计完类别,下一步是抽样可视化。标注文件是文本,边界框是否贴合目标,只有画回原图才能判断。用 OpenCV 读取图片,把 XML 里的bndbox画成矩形:
import cv2 import xml.etree.ElementTree as ET def draw_voc_boxes(image_path, xml_path, color_map): img = cv2.imread(image_path) if img is None: print("图像读取失败:", image_path) return None tree = ET.parse(xml_path) for obj in tree.getroot().findall("object"): name = obj.findtext("name").strip() bnd = obj.find("bndbox") xmin = int(float(bnd.findtext("xmin"))) ymin = int(float(bnd.findtext("ymin"))) xmax = int(float(bnd.findtext("xmax"))) ymax = int(float(bnd.findtext("ymax"))) color = color_map.get(name, (0, 255, 0)) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), color, 2) cv2.putText(img, name, (xmin, max(ymin - 6, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) return img img = draw_voc_boxes("JPEGImages/trash_001.jpg", "Annotations/trash_001.xml", {"trash_bin": (0, 255, 0), "trash_lid": (0, 200, 255)}) cv2.imwrite("check_trash_001.jpg", img)int(float(...))是因为 XML 里读出的是字符串,有些标注工具还会写成带小数的数值,先转 float 再转 int 最稳。ymin - 6可能为负,max(ymin - 6, 0)保证文字不会画到图像外面。抽查时优先挑类别统计里出现次数少的文件名,以及空标注文件,这两类最容易发现问题。
3.3 批量把VOC格式转成YOLO txt:归一化坐标一行搞定
训练 YOLO 系列模型需要 txt 格式的标注,所以转换是刚需。转换逻辑一句话说清:把 VOC 的左上右下角点换算成中心点坐标和宽高,再分别除以图像宽高。其中图像宽高应该从 XML 的size字段读,而不是自己另读图片,这样可以顺带校验 XML 和图像是否匹配:
import os import xml.etree.ElementTree as ET CLASSES = ["trash_bin", "trash_lid"] # 按实际类别名调整 def voc_to_yolo(xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") img_w = int(size.findtext("width")) img_h = int(size.findtext("height")) lines = [] for obj in root.findall("object"): name = obj.findtext("name").strip() if name not in CLASSES: continue cls_id = CLASSES.index(name) bnd = obj.find("bndbox") xmin = float(bnd.findtext("xmin")) ymin = float(bnd.findtext("ymin")) xmax = float(bnd.findtext("xmax")) ymax = float(bnd.findtext("ymax")) x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h box_w = (xmax - xmin) / img_w box_h = (ymax - ymin) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") base = os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(out_dir, base + ".txt"), "w") as f: f.write("\n".join(lines)) os.makedirs("labels", exist_ok=True) for x in os.listdir("Annotations"): if x.endswith(".xml"): voc_to_yolo(os.path.join("Annotations", x), "labels")归一化公式是(xmin + xmax) / 2 / img_w,不是xmax / 2,也不是(xmax - xmin) / 2。中心点取两个角点的平均值,宽高取差值,这是最基础的几何换算。输出cls_id是类别在CLASSES列表里的下标,这份列表在训练配置里要完全一致,顺序错一个,类别标签就全乱了。
3.3.1 转换代码里为什么要从XML的size读宽高
很多转换脚本会用cv2.imread重新读图片拿宽高,但这里选择 XML 的size字段,有两个原因。一是效率,457 张图少读一遍磁盘;二是校验,转换时如果发现某张图的 XML 尺寸和实际不一致,说明这批数据的标注在某个环节被改动过,应该先排查。转换后检查labels目录下的 txt 文件数量和 JPEGImages 里的 jpg 数量是否一致,不一致的文件名记录下来,通常是 XML 缺失或图片损坏。
4. 457张垃圾箱图的训练路线:按场景划分、增强同步bbox、参数怎么设
4.1 按场景划分train/val,别让同一地点的照片同时出现在两边
457 张小数据集最容易被忽视的问题是数据划分。垃圾箱照片往往是按拍摄时间连续采集的,同一地点可能拍了 5 到 10 张,画面里只有垃圾箱的角度、光照、遮挡略有不同。如果随机打乱再划分,同一个地点的照片会同时出现在训练集和验证集里,验证指标虚高,模型一上手新场景就露馅。我一般按文件名前缀或序列号把同一场景的照片分到同一侧。
假设文件名是spot01_001.jpg、spot01_002.jpg这种带场景编号的命名,划分脚本这样写:
import os import random image_ids = [f[:-4] for f in os.listdir("JPEGImages") if f.endswith(".jpg")] # 按文件名前6位分组,按你的命名规则调整切片范围 groups = {} for img_id in image_ids: scene = img_id[:6] groups.setdefault(scene, []).append(img_id) scene_names = list(groups.keys()) random.Random(42).shuffle(scene_names) val_scenes = set(scene_names[: int(len(scene_names) * 0.2)]) train_ids = [img_id for scene, ids in groups.items() for img_id in ids if scene not in val_scenes] val_ids = [img_id for scene, ids in groups.items() for img_id in ids if scene in val_scenes] with open("ImageSets/Main/train.txt", "w") as f: f.write("\n".join(train_ids)) with open("ImageSets/Main/val.txt", "w") as f: f.write("\n".join(val_ids))random.Random(42)固定随机种子,保证每次重跑划分结果一致。分组逻辑是全脚本的核心:先按场景分组,再对场景列表做 shuffle,而不是对每张图做 shuffle。这样同一地点的照片要么全进训练、要么全进验证,评估结果才接近真实部署环境。划分完成后,把图片和对应的 txt 标注按 YOLO 的目录习惯整理:
import shutil def copy_split(split_file, dst_img_dir, dst_label_dir): os.makedirs(dst_img_dir, exist_ok=True) os.makedirs(dst_label_dir, exist_ok=True) for name in open(split_file).read().split(): shutil.copy(f"JPEGImages/{name}.jpg", dst_img_dir) shutil.copy(f"labels/{name}.txt", dst_label_dir) copy_split("ImageSets/Main/train.txt", "datasets/trash_bin/images/train", "datasets/trash_bin/labels/train") copy_split("ImageSets/Main/val.txt", "datasets/trash_bin/images/val", "datasets/trash_bin/labels/val")这一步把第 3 章的转换结果和训练框架的目录要求接上了。复制的开销对 457 张图可以忽略,但一劳永逸,后续换框架、换机器都不用再改数据路径。
4.2 增强策略:旋转、亮度、遮挡都要同步bbox,给出albumentations配置
小数据集全靠增强撑多样性。垃圾箱是刚性物体,适合的增强手段要围绕拍摄环境来选:白天晚上光照不同,加亮度对比度扰动;树影和雨水会带来模糊,加轻微运动模糊;巡检视角有轻微倾斜,加小角度旋转。动目标框坐标的增强(旋转、裁剪、缩放)必须同步更新 bbox,这也是很多自写增强代码出错的地方。
如果走离线增强或需要可视化增强效果,用 albumentations 是最省事的方案,它自动同步 bbox:
import albumentations as A train_transform = A.Compose([ A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.15, p=0.8), A.HueSaturationValue(hue_shift_limit=5, sat_shift_limit=15, val_shift_limit=10, p=0.5), A.Rotate(limit=10, border_mode=0, p=0.5), A.HorizontalFlip(p=0.5), A.RandomSizedBBoxSafeCrop(height=640, width=640, erosion_rate=0.2, p=0.3), A.MotionBlur(blur_limit=5, p=0.2) ], bbox_params=A.BboxParams(format="pascal_voc", label_fields=["class_labels"]))format="pascal_voc"表示输入的 bbox 是[xmin, ymin, xmax, ymax],正好对应 VOC XML 里的四个值。label_fields指定和 bbox 绑定的类别列表,增强后类别标签会和框一起重排。RandomSizedBBoxSafeCrop保证裁剪窗口不会切掉任何 bbox,这是垃圾箱这类中等大小目标的关键——普通 RandomCrop 很容易把垃圾箱裁掉一半。
提示:如果直接用 Ultralytics YOLO 训练,内置增强已经支持坐标同步。上面这套 albumentations 配置更适合用来生成增强样本、人工检查增强是否合理,或者用于自定义训练管线。两条路选一条,不要同时开两套增强。
4.3 训练参数参考:小模型、低学习率、早停和data.yaml写法
457 张图训练目标检测,模型规模必须克制。我用 Ultralytics YOLO 时,首选yolo11s.pt或同级的yolov8s.pt,这类小模型参数量在千万级左右,抗过拟合能力强,单卡就能训。数据配置写成trash_bin.yaml:
path: /data/trash_bin train: images/train val: images/val names: 0: trash_bin 1: trash_lidpath指到第 4.1 节整理的datasets/trash_bin目录,train和val用相对 path 的目录。names的类别顺序必须和第 3.3 节CLASSES列表完全一致。训练命令:
yolo detect train \ data=trash_bin.yaml \ model=yolo11s.pt \ epochs=200 \ batch=32 \ imgsz=640 \ lr0=0.005 \ lrf=0.01 \ patience=30 \ degrees=10.0 \ fliplr=0.5 \ hsv_h=0.02 hsv_s=0.6 hsv_v=0.4 \ mosaic=0.5参数的选择逻辑:lr0=0.005比默认的 0.01 低一半,小数据集学习率太猛容易在头几个 epoch 就把特征学偏;patience=30开启早停,验证指标连续 30 个 epoch 不提升就自动终止,省时间;mosaic=0.5意味着有一半概率做马赛克增强,四个小图拼一张,能显著缓解小样本的过拟合,训练后期 Ultralytics 会自动关闭 mosaic 做最后的精调。degrees=10和hsv_v=0.4分别对应小幅旋转和亮度变化,和 4.2 节 albumentations 配置的思路一致。
训练中间看两个指标:train/box_loss是否持续下降但val/box_loss不再动,这是典型的过拟合信号,此时应加大mosaic概率或提前停止;metrics/mAP50如果早期就冲到 0.9 以上,先怀疑数据划分有没有泄漏,回看 4.1 节的分组逻辑,而不是高兴太早。
5. 交付垃圾箱VOC数据前必查的五项和一键排出脚本
5.1 五项必查清单
VOC 格式的标注数据交付前,最怕的是问题藏在 457 个文件里,训练时才爆出来。我每次都会跑一遍五项检查,任何一项不过都先修数据再谈训练。
| 检查项 | 判定标准 | 常见问题 |
|---|---|---|
| 图像与XML一一对应 | Annotations 和 JPEGImages 文件名集合完全一致 | 缺 XML、孤儿图片 |
| size字段一致性 | XML 里的 width/height 等于图像实际像素 | 图像被压缩过、XML 被复制改名 |
| 坐标合法性 | xmin <= xmax、ymin <= ymax,且都在图像范围内 | 手滑标出界、坐标顺序填反 |
| 类别名统一 | 所有 name 值在预设类别集合内,无大小写/空格差异 | trash_bin 和 Trash_Bin 混用 |
| 空标注与难例占比 | 空标注文件列表明确,difficult=1 的数量已知 | 漏标导致空文件、难例被误标 |
5.2 一段脚本跑完五项检查,错误逐条打印
下面的脚本把五项检查合到一起,输出格式是「文件名 + 具体问题」,排查时直接按文件名定位:
import os import cv2 import xml.etree.ElementTree as ET ann_dir, img_dir = "Annotations", "JPEGImages" ann_ids = {f[:-4] for f in os.listdir(ann_dir) if f.endswith(".xml")} img_ids = {os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.lower().endswith((".jpg", ".jpeg", ".png"))} print("缺XML的图片:", sorted(img_ids - ann_ids)) print("缺图片的XML:", sorted(ann_ids - img_ids)) allowed_names = {"trash_bin", "trash_lid"} for name in sorted(ann_ids & img_ids): tree = ET.parse(os.path.join(ann_dir, name + ".xml")) root = tree.getroot() size = root.find("size") xml_w, xml_h = int(size.findtext("width")), int(size.findtext("height")) img = cv2.imread(os.path.join(img_dir, name + ".jpg")) if img is None: print("图片损坏:", name) continue if img.shape[1] != xml_w or img.shape[0] != xml_h: print("尺寸不一致:", name, "XML:", xml_w, xml_h, "图像:", img.shape[1], img.shape[0]) for obj in root.findall("object"): obj_name = obj.findtext("name").strip() if obj_name not in allowed_names: print("未知类别:", name, obj_name) bnd = obj.find("bndbox") x1, y1 = int(float(bnd.findtext("xmin"))), int(float(bnd.findtext("ymin"))) x2, y2 = int(float(bnd.findtext("xmax"))), int(float(bnd.findtext("ymax"))) if not (0 <= x1 <= x2 <= xml_w and 0 <= y1 <= y2 <= xml_h): print("坐标越界:", name, obj_name, (x1, y1, x2, y2))脚本运行完没有任何输出,说明五项检查全部通过。img.shape[1]是图像的宽度,img.shape[0]是高度,这个顺序经常有人写反,检查时会报出一堆不存在的尺寸错误。最后再随机挑 5 张图上一步的可视化脚本,确认框贴合程度,这份数据就可以放心交付或进入训练流程了。把这段检查脚本和训练流程放在一起,每次数据更新后先跑一遍,能省下后面排错的大量时间。
本文还有配套的精品资源,点击获取