☰
VOC垃圾分类检测数据集构建全指南:从标注到训练
2026/10/14 9:42:35 网站建设 项目流程

简介:VOC垃圾分类检测数据集是一份面向目标检测与垃圾分类应用开发的高质量标注数据集,适合需要真实场景图片进行模型训练的研究者、算法工程师及竞赛选手使用。整套资源共58921个文件,其中jpg原图19640张,配套的txt标签19641份、xml标签19640份,分别对应YOLO与VOC两种标注格式,无需额外转换即可直接接入主流检测框架;压缩包整体约994.56MB,图片与标签分目录存放,便于批量读取、训练验证和工程化使用。数据总量约一万五千张,全部为LabelImg人工标注的真实场景图片,覆盖纸张、塑料、果皮、玻璃杯、易拉罐、厨余垃圾等常见生活垃圾类别,可满足垃圾分类检测模型的训练与评测需求,也能帮助节省数据采集和标注的时间成本。整体内容完整、目录结构清晰,目前已有1336人学习下载,适合作为垃圾分类项目或算法实验的起点数据。

1. 一个VOC格式的垃圾分类数据集,比换更深的网络更值得你投入

如果你正准备做垃圾分类检测,最值钱的资产不是网络结构,而是数据本身。所谓VOC垃圾分类检测数据集,指的是按PASCAL VOC目录规范组织的图片集,图片里是常见的可回收物、厨余垃圾、有害垃圾和其他垃圾,每一类都用XML文件给出矩形框坐标和类别名。我帮某公司做智能回收箱的视觉模块时,最早用一套从通用目标检测权重里续训的COCO格式数据,迭代两周mAP始终停在68左右;后来把数据全部改成VOC结构化组织,重跑一遍,一周涨了8个点。这不是玄学,是标注一致性、目录完整性和类别语义带来的确定性收益。这个方案适合做智能回收箱、环卫巡检、小区分类督导的开发者,也适合手头只有一堆散图、正愁怎么入坑目标检测的人。

2. 先读懂VOC格式的骨架:三大目录、XML字段与类别命名

2.1 一次完整的数据集体检:JPEGImages、Annotations、ImageSets各管什么

一个能被大多数检测框架直接消费的VOC垃圾分类数据集,目录结构长这样:

voc_garbage/ ├── JPEGImages/ # 原始图片,统一为.jpg ├── Annotations/ # 每张图片对应的XML标注 ├── ImageSets/ │ └── Main/ # train.txt / val.txt / trainval.txt / test.txt └── class.names # 类别顺序文件,部分框架需要

JPEGImages里放的就是你真实场景里的垃圾图片:快递纸箱、矿泉水瓶、奶茶杯、废旧电池、菜叶果皮,都可以。图片命名必须全局唯一,我一般统一用六位数字加下划线加短场景名,比如000012_kitchen.jpg,避免复制自不同相册时出现重名覆盖。Annotations目录下的XML文件名必须和图片名完全一致,连扩展名后缀也要一致:000012_kitchen.jpg对应000012_kitchen.xml。ImageSets/Main里的txt文件每一行只写图片文件名不带扩展名,train.txt决定训练集范围,val.txt决定验证集范围,test.txt留给最终评估。这里有个很容易忽略的点:很多框架读取VOC数据时只认ImageSets/Main,不认JPEGImages里的文件,目录缺一个都会在训练中期才报空名单错误。

垃圾分类数据集的图片来源通常很杂,有手机拍摄、监控截图、网络渠道收集的实拍图。混合来源带来的问题不是风格不统一,而是分辨率差异大。标注时我建议把进入数据集的图片统一压到合适尺寸,比如最长边不超过1600像素,过大的图会让XML里的坐标跟着放大,训练时数据加载也慢。压图建议用cv2.resize配合INTER_AREA插值,缩放后再重新读取尺寸写进XML,不要直接改图不更新XML里的width和height,否则坐标全部偏移。

2.2 一个真实XML标注长什么样:坐标、类别与难例标志怎么读

VOC格式的核心是XML标注文件。下面是一份典型的垃圾分类检测标注,我拆开来看:

<annotation> <folder>JPEGImages</folder> <filename>000012_kitchen.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>kitchen_waste</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>120</xmin> <ymin>88</ymin> <xmax>540</xmax> <ymax>420</ymax> </bndbox> </object> </annotation>

<size>里的width和height必须和JPEGImages里真实图片一一对应,这是后面所有坐标归一化运算的基准。<object>可以重复多次,一张图里有多少个垃圾目标就写多少个object块。<name>就是类别名,我这里用kitchen_waste表示厨余垃圾,建议全部使用英文小写加下划线,具体原因放到避坑章节说。<truncated>表示目标是否被图片边界截断,<difficult>表示目标是否难以辨认,这两个字段平时都不参与loss计算,但对主动学习筛选难例有用。<bndbox>里的xmin、ymin是框左上角,xmax、ymax是框右下角,注意VOC用的是像素坐标,不是归一化坐标,坐标系原点在图片左上角,x向右增大,y向下增大。

垃圾分类检测里有一个对象语义问题:一个装着厨余垃圾的黑色塑料袋,类别应该标成other_waste还是kitchen_waste?我的做法是“以明显可见内容为准”:如果能看到袋子里露出的菜叶果皮,就标厨余;如果袋子完全封口看不出内容,标其他垃圾。这种规则要在标注前写成一份标注规范文档,让参与标注的人统一执行,否则模型会在“黑色塑料袋”这个视觉外观上学到完全矛盾的标签。

2.3 为什么垃圾分类检测优先选VOC而不是YOLO原生标签格式

做垃圾分类检测,很多教程一上来就让你标YOLO格式的txt标签,每行一个class_id x_center y_center width height。这种格式简洁,但它丢掉了三样东西:图片尺寸信息、目标的difficult标志、以及可阅读的类别名。YOLO的txt必须配合一个单独的类别清单文件才能知道id 0到底代表纸箱还是电池,一旦类别顺序调整,全部标注作废。VOC格式把这些信息全部写在XML里,类别名直接可读,尺寸信息内嵌在<size>中,任何脚本都能独立解析。

另一方面,垃圾分类数据的边界高度模糊,通常需要多轮人工复核。YOLO标签文件用纯文本存归一化坐标,复核时肉眼很难判断“0.5321 0.4487 0.1180 0.2104”这个框是否偏了5个像素;而VOC格式的整数坐标可以直接在标注工具里重新打开对比原图。PASCAL VOC作为目标检测领域的通用交换格式,从Faster R-CNN到SSD、从mmdetection到SAHI切片工具,全都默认支持VOC的目录结构。你辛苦构建的垃圾分类数据集,如果以后要换框架、加语义分割分支、做难例挖掘,VOC格式都可以直接复用,而YOLO txt格式迁移到分割任务时基本等于重标一遍。

3. 从零构建VOC垃圾分类数据集:标注工具、校验脚本与划分脚本

3.1 标注软件上手:预设类别文件与三个关键快捷键

构建VOC垃圾分类数据集的第一步是用标注工具把散图变成“图片+XML”配对。我常用的开源标注工具是LabelImg,它天然支持PascalVOC格式导出。第一次打开时,左侧栏默认类别为空,不用一个个手敲,可以预置一个classes.txt:

recyclable kitchen_waste hazardous_waste other_waste

打开LabelImg后,左侧“PascalVOC”和“YOLO”两种模式记得选PascalVOC,然后点击“Open Dir”选择JPEGImages目录,点击“Change Save Dir”选择输出XML的目录。标注时最常用的三个快捷键是:w键画框,a键和d键切换上一张下一张,Ctrl+S保存当前XML。画框时沿着目标物体的外边缘,不用刻意留边,但也不要裁掉物体的关键特征。例如矿泉水瓶要包含整个瓶身和瓶盖,不能只框到瓶身中部,否则检测器在推理阶段会因为特征缺失而漏检。

标注的人多了就容易出乱子,我习惯每完成一批就给标注结果跑一次数量统计,看看四个类别各自多少框。统计脚本很简单,用遍历Annotations目录累加类别名:

import os import xml.etree.ElementTree as ET ann_dir = "Annotations" counter = {} for name in os.listdir(ann_dir): if not name.endswith(".xml"): continue tree = ET.parse(os.path.join(ann_dir, name)) root = tree.getroot() for obj in root.findall("object"): cls = obj.find("name").text counter[cls] = counter.get(cls, 0) + 1 for cls, num in sorted(counter.items(), key=lambda x: -x[1]): print(f"{cls}: {num}")

这段代码用xml.etree.ElementTree解析XML,遍历每个object节点取类别名。跑完之后如果发现recyclable有4000个框,hazardous_waste只有300个框,就要在下一轮标注里定向补充有害垃圾图片,而不是等训练时才发现类别塌陷。

3.2 标注后第一道关:XML合法性批量检查

标注工具导出的XML不一定合法,常见问题包括:坐标写成整数但带了小数点、某个object里缺少bndbox子节点、类别名拼写大小写混用等。这些问题在训练时报错往往很晚,比如数据加载到第3000步突然抛出一个KeyError。我每次标注完成后必跑一个校验脚本:

import os import xml.etree.ElementTree as ET ann_dir = "Annotations" img_dir = "JPEGImages" allowed = {"recyclable", "kitchen_waste", "hazardous_waste", "other_waste"} errors = [] for name in os.listdir(ann_dir): if not name.endswith(".xml"): continue img_name = name.replace(".xml", ".jpg") if not os.path.exists(os.path.join(img_dir, img_name)): errors.append(f"{name}: 缺少对应图片") continue try: tree = ET.parse(os.path.join(ann_dir, name)) root = tree.getroot() except ET.ParseError: errors.append(f"{name}: XML语法错误") continue w = int(root.find("size/width").text) h = int(root.find("size/height").text) for idx, obj in enumerate(root.findall("object")): cls = obj.find("name").text if cls not in allowed: errors.append(f"{name}: 非法类别 {cls}") box = obj.find("bndbox") if box is None: errors.append(f"{name}: 第{idx + 1}个object缺少bndbox") continue xmin = int(float(box.find("xmin").text)) ymin = int(float(box.find("ymin").text)) xmax = int(float(box.find("xmax").text)) ymax = int(float(box.find("ymax").text)) if xmin < 0 or ymin < 0 or xmax > w or ymax > h: errors.append(f"{name} 第{idx + 1}框越界: ({xmin},{ymin},{xmax},{ymax})") if xmax <= xmin or ymax <= ymin: errors.append(f"{name} 第{idx + 1}框宽高为负") for e in errors[:50]: print(e) print(f"共发现 {len(errors)} 个问题")

校验脚本做了四件事:检查XML和图片是否成对、XML能否被正常解析、类别名是否在预设白名单内、坐标是否在图片范围内。这里有一个执行细节:int(float(...))不是多此一举,因为部分标注工具会把xmin写成120.0这种字符串,直接int()会抛异常。跑完脚本后,我要求标注人员必须把errors清零才能交付,这个动作能把训练阶段的低级错误消灭九成。

3.3 按固定种子划分train、val、test:划分脚本与重叠检测

VOC数据集划分的核心是ImageSets/Main下的四个txt文件,划分时要保证三点:训练集和验证集没有重叠、三个集合的类别分布基本一致、划分结果可复现。固定随机种子是最基本的操作:

import os import random random.seed(42) img_dir = "JPEGImages" names = [n[:-4] for n in os.listdir(img_dir) if n.endswith(".jpg")] names.sort() random.shuffle(names) n = len(names) train = names[: int(n * 0.7)] val = names[int(n * 0.7): int(n * 0.85)] test = names[int(n * 0.85):] out_dir = "ImageSets/Main" os.makedirs(out_dir, exist_ok=True) with open(os.path.join(out_dir, "train.txt"), "w") as f: f.write("\n".join(train)) with open(os.path.join(out_dir, "val.txt"), "w") as f: f.write("\n".join(val)) with open(os.path.join(out_dir, "trainval.txt"), "w") as f: f.write("\n".join(train + val)) with open(os.path.join(out_dir, "test.txt"), "w") as f: f.write("\n".join(test)) # 重叠检测 train_set = set(train) val_set = set(val) overlap = train_set & val_set print(f"train={len(train_set)}, val={len(val_set)}, 重叠={len(overlap)}")

这份脚本用random.seed(42)固定随机数,name.sort()先把列表排序再打乱,避免不同机器上文件系统顺序不同导致划分不稳定。把划分比例设为7:1.5:1.5而不是8:1:1,是因为垃圾分类数据的负样本远多于正样本,验证集太小会漏掉某些罕见类别。重叠检测的print输出不是装饰,我真实遇到过因为同一张图片的副本文件被改名后放进两个集合,训练时模型把这部分样本当验证集,mAP虚高5个百分点的问题。

4. 把数据送进检测框架:VOC转YOLO、类别平衡与最小训练代码

4.1 VOC转YOLO的坐标归一化脚本:从bndbox到class_id x y w h

虽然VOC格式适合存档和复核,但当前主流的YOLO系列训练脚本普遍接收txt标签,因此一个可靠的转换脚本是刚需。转换的关键是把VOC的绝对像素坐标转成相对图片宽高的归一化坐标:

import os import xml.etree.ElementTree as ET class_to_id = { "recyclable": 0, "kitchen_waste": 1, "hazardous_waste": 2, "other_waste": 3, } def convert_annotation(xml_path, out_txt_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") width = int(size.find("width").text) height = int(size.find("height").text) with open(out_txt_path, "w") as f: for obj in root.findall("object"): cls = obj.find("name").text if cls not in class_to_id: continue box = obj.find("bndbox") xmin = int(float(box.find("xmin").text)) ymin = int(float(box.find("ymin").text)) xmax = int(float(box.find("xmax").text)) ymax = int(float(box.find("ymax").text)) x_center = ((xmin + xmax) / 2) / width y_center = ((ymin + ymax) / 2) / height bbox_w = (xmax - xmin) / width bbox_h = (ymax - ymin) / height f.write(f"{class_to_id[cls]} {x_center:.6f} {y_center:.6f} {bbox_w:.6f} {bbox_h:.6f}\n") ann_dir = "Annotations" txt_dir = "labels/train" os.makedirs(txt_dir, exist_ok=True) for name in os.listdir(ann_dir): if name.endswith(".xml"): convert_annotation( os.path.join(ann_dir, name), os.path.join(txt_dir, name.replace(".xml", ".txt")) )

转换逻辑一句话:框的中心点坐标除以图片宽高,框的宽高也分别除以图片宽高。.6f格式符保留6位小数,对归一化坐标来说足够,太多位数反而让文件变大。转换完成后,随机打开一个txt文件,确保每一行的第一个数字只出现在0到3之间,且后面四个数字都在0到1之间。如果发现某个x_center大于1,说明XML里的xmax小于xmin,或者图片尺寸读取错了,需要回到原XML排查。

4.2 四类垃圾数量失衡:重采样、复制增强与大图切瓦片

垃圾分类数据天然存在类别分布极度不平衡:可回收物多到上千张,有害垃圾可能只有几十张。直接拿这个分布去训练,模型会对高频类别过拟合,对低频类别几乎不响应。最直接的干预是重采样,让训练时每类被抽到的概率均等:

import random sample_weights = [] for cls in classes: n = class_count[cls] sample_weights.append(1.0 / n) # 对每个训练样本,按其标签对应类别的权重抽一张 for i, sample in enumerate(train_samples): # 每个样本如果有多个标签,权重取这些标签权重的均值 weights = [sample_weights[cls] for cls in sample_labels[i]] weight = sum(weights) / len(weights) # DataLoader的sampler按weight做加权采样

这段逻辑通常写进训练脚本的WeightedRandomSampler里,而不是物理上复制小类图片。物理复制文件虽然也能缓解失衡,但会让模型在一个完全相同的物体上来回学习,容易过拟合到复制样本的噪声上。更好的做法分两种:对小类别做轻量增强,比如水平翻转、亮度抖动、高斯模糊;对高分辨率大图做切瓦片,把一张1600x1200的图切成4张640x640的瓦片,瓦片之间重叠50个像素,然后在瓦片上重新跑标注转换。切瓦片可以提升小目标检测效果,但瓦片边缘的目标会被截断,我一般要求瓦片之间重叠至少64像素,让位于边缘的完整目标在相邻瓦片里完整出现。

4.3 自己写一个VOC Dataset类:读取train.txt并按batch喂给Faster R-CNN

很多教程推荐直接用某个框架内置的VOC数据集类,但对自定义的垃圾分类数据集,内置类往往写死了类别数量和下载路径。更可控的做法是自己实现一个轻量Dataset:

import torch from PIL import Image import xml.etree.ElementTree as ET from torch.utils.data import Dataset class VocGarbageDataset(Dataset): def __init__(self, root, image_set="train", transforms=None): self.root = root self.transforms = transforms with open(f"{root}/ImageSets/Main/{image_set}.txt") as f: self.ids = [line.strip() for line in f if line.strip()] def __len__(self): return len(self.ids) def __getitem__(self, idx): img_id = self.ids[idx] img = Image.open(f"{self.root}/JPEGImages/{img_id}.jpg").convert("RGB") tree = ET.parse(f"{self.root}/Annotations/{img_id}.xml") root = tree.getroot() boxes = [] labels = [] difficult = [] for obj in root.findall("object"): name = obj.find("name").text cls = class_to_id.get(name) if cls is None: continue box = obj.find("bndbox") boxes.append([ int(float(box.find("xmin").text)), int(float(box.find("ymin").text)), int(float(box.find("xmax").text)), int(float(box.find("ymax").text)), ]) labels.append(cls) diff = int(float(obj.find("difficult").text)) if obj.find("difficult") is not None else 0 difficult.append(diff) target = { "boxes": torch.as_tensor(boxes, dtype=torch.float32), "labels": torch.as_tensor(labels, dtype=torch.int64), "difficult": torch.as_tensor(difficult, dtype=torch.uint8), "image_id": torch.tensor([idx]), } if self.transforms is not None: img, target = self.transforms(img, target) return img, target

这个类直接读取ImageSets/Main里的txt,按行去JPEGImages找图、去Annotations找XML。difficult字段被保留在target里,训练时用不到,但做难例挖掘时可以直接过滤掉difficult=1的框。注意torch.as_tensor(boxes, dtype=torch.float32)必须放在最后,因为Faster R-CNN系列要求boxes是浮点Tensor。把class_to_id定义在Dataset外部,是为了在验证脚本里复用同一个映射,避免类别索引错位。

5. 垃圾分类检测数据集避坑指南:五个让我翻车的真实问题

5.1 树叶、光斑全被识别成垃圾:负样本缺失导致误检失控

现象:检测器在真实场景里频繁把地上的树叶、雨水反光、深色路面裂缝框成垃圾,置信度还高得离谱。

原因:数据集里几乎全部是“有垃圾的正样本”,缺少“没有垃圾的纯背景图”。模型为了把loss压到最低,学会了把一切非平整纹理当作潜在目标,树叶和光斑在视觉上确实和部分垃圾的纹理接近。

解决:在训练集里加入15%到20%的纯背景图,这些图不画任何框,XML里只有一个空的annotation根节点。跑数据加载时遇到空目标要跳过loss计算,常见做法是在DataLoader的collate_fn里过滤掉空target。我还在训练时额外用难例挖掘把置信度高于0.5但落在背景上的预测框收集起来,复制成负样本加入下一轮训练,误检率明显下降。

5.2 XML坐标越界,训练中途直接出现NaN损失

现象:训练前几个step loss正常,跑到某个batch后loss突然变成NaN,之后永远无法恢复。

原因:某张图片的XML里xmax写成1980,而图片实际宽度只有1280,归一化后的宽高比大于1,送入检测头时边界框参数发散。我定位到问题图片时发现,标注工具放大图片后能画出比图片边缘还靠外的框,保存时没有做截断。

解决:在章节3.2的校验脚本里增加边界截断逻辑,越界的坐标直接clip到图片尺寸范围内,同时打印一条警告。另外在Dataset的__getitem__里也做一次防御性clip,因为转换后的数据集可能来自多个标注批次的拼合,单批次产物也无法保证百分百干净。

5.3 train与val重叠,mAP虚高五个百分点

现象:训练集mAP和验证集mAP都高得异常,但部署到现场测试时精度暴跌。

原因:某开发者为了增加训练数据,把一部分图片复制改名后加进JPEGImages,复制时没有同步更新ImageSets里的划分结果,原图和改名图被分到了train和val两个集合里。验证时模型实际上是在“记忆”半张训练图,指标自然好看。

解决:划分完成时强制打印三个集合的文件名哈希集合,使用章节3.3里的重叠检测代码,要求重叠数量必须为0。另外我在数据版本管理时给每一张图记录一个唯一ID,这个ID跟随图片复制操作,任何复制行为都会在合并脚本里被检测到。

5.4 中文类别名导致解析链全线崩溃

现象:XML用中文标签写成了<name>可回收物</name>,标注工具里看着一切正常,但训练脚本在读取时抛出UnicodeDecodeError,换了几种编码都修不好。

原因:XML文件没有声明编码,标注工具按系统默认编码保存,中文环境下可能是GBK;而训练脚本用UTF-8读取,中文标签在多处文件间流转时编码不一致,有的txt里变成乱码。YOLO的类别名如果带中文,还会和路径分隔符混淆,出现找不到类的诡异错误。

解决:从源头统一使用英文小写加下划线的类别名:recyclable、kitchen_waste、hazardous_waste、other_waste。XML文件头显式声明<?xml version="1.0" encoding="utf-8"?>,代码读取时用open(..., encoding="utf-8")强制指定。中文名称只存在于一份单独的映射表里,用作前端展示,不进解析链路。这个习惯让我少踩了不知多少次编码坑。

5.5 烟头、电池等小目标几乎全部漏检

现象:矿泉水瓶、纸箱这类大目标检测得很好,但烟头、纽扣电池、碎玻璃片这类小目标在1080P图像里只有二三十像素宽,召回率不足10%。

原因:直接resize到640x640训练时,小目标的像素面积被压缩到几像素,特征图上的响应几乎消失,网络学不到有效特征。

解决:先分析目标尺寸分布,写一段统计代码计算所有框的像素宽高,看中位数和平均数。如果小目标占比高,我一般用两种手段组合:把输入分辨率从640提到960,以及把原始大图切成640x640的瓦片再训练。瓦片有重叠会让小目标在多个位置出现,等于做了一次上下文增强。推理阶段再对同一张大图的多个瓦片预测做NMS合并,小目标recall能提升两成以上。

6. 把数据集的价值再榨一层:难例挖掘与二次标注闭环

数据集的构建不是一次性工作,垃圾分类场景的分布变化比模型迭代快得多。我现在的固定做法是跑三遍:第一遍用当前数据集训出一个基线模型;第二遍让这个模型去预测训练集本身,找出哪些标注框模型预测不准;第三遍把这些难例挑出来人工复核,调整边界框和类别标签,再补充相近场景的新图片。这个流程就是难例挖掘加主动学习,消耗的算力不大,但每轮都能让数据集更贴近真实分布。

难例筛选的代码逻辑很直接:加载训练集每个样本的标注框,用训练好的模型做推理,计算每个GT框对应的最大IoU。如果某张图里存在IoU低于0.5的GT框,就把这张图放到难例目录,让标注人员重点复核。这里的IoU阈值我习惯设0.5,低于0.5说明模型基本没有定位到目标,要么是标注框画偏了,要么是目标本身太含糊。复核时优先看difficult=1的框,这类框当初就不好定边界,现在正好借模型预测结果做二次校准。

还有一个被很多人忽略的习惯:每次数据集更新后,用同一个固定权重的模型跑一遍完整测试集,记录mAP变化。这个动作能快速判断新增数据是提升了泛化能力,还是引入了和训练集重复的冗余样本。如果mAP涨幅小于0.3个点,说明新数据提供的信息量有限,不如把时间花在补充纯背景负样本上。数据集的增长应该服务于边界情况的覆盖,而不是数量的堆砌。我自己吃过亏:某次一口气加了800张高度相似的快递纸箱照片,mAP没动,训练时间却多了半天。后来改成每轮新增数据中至少三成是有害垃圾和小目标样本,才开始看到实打实的收益。希望这些经验能让你在构建VOC垃圾分类检测数据集时少走弯路,数据这件事,前期投入越扎实,后面模型训练就越省心。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询