简介:面向目标检测与垃圾分类识别任务的Pascal VOC格式数据集,可直接用于YOLOv3/v4/v5及Darknet框架训练。数据包共44891个文件,压缩后约791.41MB,核心包含14963张jpg原图、14963个xml标注文件及14965个txt文本文件,其中txt包括YOLO格式标签与1个类别文件,结构清晰,便于直接划分训练集、验证集与测试集。标注由labelImg工具完成,以矩形框精细标记44类生活垃圾,类别名称全英文并额外提供中英文对照表,有效避免中文乱码和编码兼容问题。目前已有1900人学习下载,适合刚接触目标检测的学生、研究者以及需要构建垃圾识别模型的应用开发者。作者明确说明仅提供准确合理的标注数据,不保证训练模型精度,用户可基于此数据集自行调整网络结构、超参数与增强策略,以适配不同硬件和场景需求。
1. 拿到手就能训:一份14963张的标准VOC垃圾数据集
做垃圾分类检测的同学,八成都有过这种经历:网上下载一个标注齐全的垃圾图片集,打开一看发现是 COCO 大目录硬切出来的碎片,或者 XML 里类别名大小写飘忽不定,坐标还有负数,光清洗就要花掉一整个周末。这份 14963 张的垃圾 VOC 数据集,最大的价值是它严格按 PASCAL VOC 的目录规范组织,图片、标注一一对应,拿到手就能直接走 YOLOv3/v4/v5 的 darknet 训练流程。你要做的只有三件事:确认类别清单、把 VOC 标注转成 YOLO 要的 txt、改好 cfg 里的 classes 和 filters。它适合正在做智慧环卫、垃圾分类、可回收物分拣这类方向的检测工程师,也适合拿真实标注数据练手的目标检测新人。
2. VOC 数据集的底细:目录结构、XML 标注与类别统计
网上自称 VOC 格式的数据集不少,真正按标准目录摆好的却不多。这份 14963 张的垃圾数据集把训练要用的文件都放在了 VOCdevkit 目录下,先把结构拆开看一遍,后面所有操作才不会跑偏。
2.1 目录结构:Annotations、JPEGImages 与 ImageSets 的对应关系
打开数据集根目录,标准的 VOC 布局长这样:
VOCdevkit/VOC2024/ ├── Annotations/ # 14963 个 XML 标注文件 ├── JPEGImages/ # 14963 张 JPG 原图 └── ImageSets/ └── Main/ # 官方自带的 train.txt / val.txt 划分文件三个目录的对应关系很简单:Annotations 里的每个 XML 和 JPEGImages 里的图片通过文件名关联,比如000001.xml描述的就是000001.jpg,一张图对应一个 XML,这个数据集里 14963 张图片全部有标注,没有空标注文件。ImageSets/Main 里如果带了划分文件可以直接用,但我实际做项目时更推荐自己重新划一遍训练集和验证集,后面第 3.3 节会给出划分脚本。关键点在于 Annotations 和 JPEGImages 必须严格一一对应,缺任何一边,转换脚本都会报错或者静默跳过。
2.2 解读 XML 标注:一个目标对应一个 object 节点
VOC 的 XML 标注格式是固定的,随便打开一个文件,结构大致如下:
<annotation> <folder>VOC2024</folder> <filename>000001.jpg</filename> <size> <width>640</width> <height>480</height> <depth>3</depth> </size> <object> <name>cardboard</name> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>73</xmin> <ymin>112</ymin> <xmax>328</xmax> <ymax>435</ymax> </bndbox> </object> </annotation>几个节点是转换脚本要用的核心:
| XML 节点 | 内容 | 转换时的作用 |
|---|---|---|
| folder/filename | 数据集名、文件名 | 可以忽略,只用文件名定位图片 |
| size/width, height | 原图宽高 | 归一化坐标时的分母 |
| object/name | 目标类别名 | 映射成类别 ID |
| object/difficult | 是否难例 | darknet 不认这个标记,转换时直接跳过 |
| bndbox/xmin, ymin, xmax, ymax | 外接矩形像素坐标 | 换算成 YOLO 的中心点加宽高 |
需要注意的是,一张图里每出现一个目标就有一个<object>节点,如果一张图里有五袋垃圾,就有五个 object。有些数据集里 foreign key 标注得比较随意,difficult节点可能缺失,解析时要做默认值处理。
2.3 先摸清类别底细:一个脚本统计全部标签
拿到数据集第一件事,不是急着转格式,而是先统计类别。很多垃圾数据集的类别名带着空格、大小写混用,甚至同一类物体出现两种写法,这些都会在后面的类别映射里埋雷。
import os import xml.etree.ElementTree as ET xml_dir = "VOCdevkit/Annotations" class_counter = {} for xml_file in os.listdir(xml_dir): if not xml_file.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, xml_file)) root = tree.getroot() for obj in root.findall("object"): name = obj.findtext("name").strip() class_counter[name] = class_counter.get(name, 0) + 1 print("各类别目标总数:") for name, count in sorted(class_counter.items(), key=lambda x: -x[1]): print(f"{name}: {count}") print(f"共 {len(class_counter)} 个类别,{len(os.listdir(xml_dir))} 个 XML 文件")这段脚本会把所有 XML 里出现的类别统计出来,按数量从高到低排。统计结果里出现的每个name就是后续trash.names文件的一行,行的顺序决定了类别 ID。要先跑一遍这个脚本确认类别总数,再决定 cfg 里的classes填多少。如果发现类似cardboard和Cardboard这种大小写变体,说明标注有脏数据,转换前需要先统一。
3. 把 VOC 转成 YOLO 训练格式:转换脚本与四个边界坑
darknet 训练 YOLO 用的不是 XML,而是每张图一个同名 txt:每行一个目标,格式是“类别 ID 中心点 x 中心点 y 宽度 w 高度 h”,这些值全部做了归一化。这一步卡住的人,绝大多数不是不会写转换逻辑,而是栽在坐标公式和边界细节上。
3.1 坐标转换原理:从像素坐标到归一化坐标
VOC 存的是一对对角点坐标xmin, ymin, xmax, ymax,而 YOLO 需要的是中心点加宽高,并且要除以原图的宽和高,归一化到 0 到 1 之间。换算公式是:
x_center = ((xmin + xmax) / 2) / image_width y_center = ((ymin + ymax) / 2) / image_height box_width = (xmax - xmin) / image_width box_height = (ymax - ymin) / image_height单看公式不复杂,但有几个边界坑,实际转换时很容易翻车:
- 坐标越界:有些人工标注框会延伸到图片外面,导致归一化后的值大于 1 或者为负,darknet 读进去轻则出框怪,重则训练崩溃,转换时要对结果做 clip。
- 宽高为 0:某些目标标注退化成了点或线,
w或h小于 1 像素,这样的目标要直接跳过。 - ID 从 0 开始:YOLO 的类别 ID 是从 0 编号的,不是从 1,这个和第 2.3 节的类别排序直接相关。
- txt 与 jpg 必须同名:darknet 根据图片路径找对应 txt,不在同一个目录也会读不到,训练时直接报错。
3.2 完整转换脚本:XML 到 TXT
下面是完整的转换脚本,包含类别清单生成、坐标换算、越界处理和难以确认的 difficult 跳过逻辑:
import os import xml.etree.ElementTree as ET xml_dir = "VOCdevkit/Annotations" # 原始 XML 目录 jpg_dir = "VOCdevkit/JPEGImages" # 原图目录 out_dir = "VOCdevkit/labels" # 输出的 YOLO txt 目录 os.makedirs(out_dir, exist_ok=True) def collect_classes(): names = set() for f in os.listdir(xml_dir): if not f.endswith(".xml"): continue root = ET.parse(os.path.join(xml_dir, f)).getroot() for obj in root.findall("object"): names.add(obj.findtext("name").strip()) return sorted(names) class_names = collect_classes() with open("trash.names", "w", encoding="utf-8") as f: f.write("\n".join(class_names) + "\n") def convert_one(xml_file): xml_path = os.path.join(xml_dir, xml_file) root = ET.parse(xml_path).getroot() size = root.find("size") img_w = float(size.findtext("width")) img_h = float(size.findtext("height")) if img_w == 0 or img_h == 0: print(f"图片尺寸为0,跳过:{xml_file}") return 0 lines = [] for obj in root.findall("object"): difficult = obj.findtext("difficult", "0") if int(difficult) == 1: continue name = obj.findtext("name").strip() if name not in class_names: continue cls_id = class_names.index(name) box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) w = xmax - xmin h = ymax - ymin if w < 1 or h < 1: continue xc = min(max((xmin + xmax) / 2 / img_w, 0.0), 1.0) yc = min(max((ymin + ymax) / 2 / img_h, 0.0), 1.0) bw = min(max(w / img_w, 0.0), 1.0) bh = min(max(h / img_h, 0.0), 1.0) lines.append(f"{cls_id} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") if lines: txt_path = os.path.join(out_dir, xml_file.replace(".xml", ".txt")) with open(txt_path, "w") as f: f.write("\n".join(lines)) return len(lines) converted = 0 for xml_file in os.listdir(xml_dir): if not xml_file.endswith(".xml"): continue jpg_file = xml_file.replace(".xml", ".jpg") if not os.path.exists(os.path.join(jpg_dir, jpg_file)): print(f"警告:找不到对应图片,跳过:{xml_file}") continue if convert_one(xml_file) > 0: converted += 1 print(f"转换完成: {converted} 张图片得到有效标注") print(f"类别清单: {class_names}")脚本分三段逻辑:先遍历所有 XML 收集类别名并写入trash.names,再逐文件解析 XML 做坐标换算,最后按同名规则写出 txt。注意class_names.index(name)用的顺序就是trash.names里的行序,两者天然对应,不会错位。min(max(...))那四行就是坐标越界裁剪,all 转出来的中心点、宽高都在 0 到 1 之间。这个脚本不只对 darknet 有用,YOLOv5、v8 训练自己的数据集时吃的也是这种 txt,后面要换工具链的话转换逻辑完全不用改。
3.3 训练集与验证集划分:9:1 随机且不重不漏
标注转完了,接下来按 9:1 把 14963 张图划分成训练集和验证集。划分时要保证两个文件列表没有交集,而且不能让某一张图既出现在 train 又出现在 val。
import os import random jpg_dir = "VOCdevkit/JPEGImages" train_txt = "data/trash/train.txt" val_txt = "data/trash/val.txt" os.makedirs(os.path.dirname(train_txt), exist_ok=True) files = [f for f in os.listdir(jpg_dir) if f.endswith(".jpg")] random.seed(42) random.shuffle(files) split_idx = int(len(files) * 0.9) with open(train_txt, "w") as f: for name in files[:split_idx]: f.write(os.path.abspath(os.path.join(jpg_dir, name)) + "\n") with open(val_txt, "w") as f: for name in files[split_idx:]: f.write(os.path.abspath(os.path.join(jpg_dir, name)) + "\n") overlap = set(files[:split_idx]) & set(files[split_idx:]) print(f"训练集 {split_idx} 张,验证集 {len(files) - split_idx} 张,重叠 {len(overlap)} 张")这里把输出路径写成了绝对路径,训练时就不会出现 darknet 找不到文件的幺蛾子。random.seed(42)固定随机种子,保证每个人复现出来的划分结果一致。按 14963 张计算,训练集 13466 张、验证集 1497 张。划分完之后检查重叠数量,正常应为 0。
4. darknet 训练前的最后配置:cfg 修改、data 文件与日志判断
标注转好了,训练就能跑吗?还差几步。YOLOv3/v4/v5 在 darknet 环境下训练,最常翻车的不是数据,而是 cfg 参数。改 classes 却忘改 filters 这种事,几乎每个群里每天都有人踩。
4.1 cfg 文件改三处:classes、filters、输入尺寸
以 YOLOv3 为例,打开 cfg 后要改的参数可以列成一张表:
| 参数 | 修改位置 | 取值规则 |
|---|---|---|
| classes | 3 个 [yolo] 层内 | 等于第 2.3 节统计出的类别总数 N |
| filters | 每个 [yolo] 层前最近的一个卷积层 | (N + 5) * 3 |
| width / height | [net] 层 | 必须是 32 的倍数,如 416、608 |
| subdivisions | [net] 层 | 显存不够时调大,如 16→64 |
filters的计算公式很多人不理解:每个预测框要输出 5 个基础量(中心点 x、y、宽 w、高 h、objectness),再加上 N 个类别的置信度,得到N + 5,乘上每个尺度的 3 个 anchor,就是(N + 5) * 3。比如类别数 4,filters 就是 27;类别数 8,filters 就是 39。YOLOv4、YOLOv5 的 cfg 换汤不换药,计算方式一样。
修改时建议用编辑器全局搜索:先搜classes=,把三处全部改成 N;再搜filters=,把每个 yolo 层前最近的卷积层的 filters 都改了,别只改一处。输入尺寸也是新手容易随意填的:填 608 虽然能提升小目标检测能力,但显存占用大幅上涨,填 416 则是速度和精度的折中。垃圾检测里很多目标是小物件,可以先从 416 起步。
4.2 创建 trash.data 与启动训练
darknet 训练还差一个数据描述文件,内容很简单:
classes = 4 train = /data/trash/train.txt valid = /data/trash/val.txt names = /data/trash/trash.names backup = /data/trash/backup/names指向第 3.2 节生成的trash.names,里面每行一个类别名,行序就是类别 ID。backup目录要提前创建,否则训练时模型没法自动存权重。一切就绪后,训练命令是:
mkdir -p /data/trash/backup ./darknet detector train data/trash.data cfg/yolov3-trash.cfg darknet53.conv.74 -mapdarknet53.conv.74是官方预训练骨干权重,第一次训练用它能让模型快速收敛;如果训练中断了,把这个位置换成backup/xxx_last.weights就能断点续训。-map参数让训练过程每隔一段迭代自动在验证集上算 mAP,方便观察模型趋势。显存紧张时,把 cfg 里的subdivisions从 16 调成 32 或 64,一批图拆成更多小份送进显卡,总 batch 不变,只是变慢了。
4.3 训练日志怎么读:avg loss 与 mAP
darknet 训练时刷屏的日志里,真正要盯的是两个值:avg loss和mAP@0.5。训练集 13466 张、batch=64 的情况下,一个 epoch 大约 210 次迭代。
前两三百次迭代,loss 在 2.x 附近波动是正常的,只要不是一路涨,都不用慌。当avg loss稳定降到 1.0 以下,说明模型已经学到基本特征。这时开始关注 mAP,一般会从零点几缓慢爬升。什么时候停?不是看 loss 掉到多低,而是看验证集 mAP 连续几十个 epoch 不再增长,就可以停了,继续跑只会过拟合。如果跑了上千次迭代 loss 还在 2 以上,优先回去检查标注转换,而不是调学习率。
5. 常见问题与避坑:六个训练翻车现场的排查记录
这一节列几个用 VOC 转 darknet 训练时特别常见的坑,每条按“现象、原因、解决”写清楚。我不是没踩过,这些坑基本都亲自翻车过,对照自己的工程看一眼,能少折腾好几个晚上。
5.1 训练刚开始就报错:Failed to open image
现象:训练启动后几秒,控制台连续刷Failed to open image: data/trash/xxx.jpg,loss 完全不动。
原因:train.txt里写的是相对路径,而 darknet 的工作目录和你的项目目录不一致;或者 txt 里写的是 Windows 盘符路径,换到 Linux 上直接失效。
解决:用第 3.3 节脚本里的os.path.abspath()生成绝对路径,重新生成train.txt和val.txt,确保每一行都是完整可访问的.jpg路径。
5.2 改了 classes 忘了 filters,模型直接崩
现象:启动训练后加载权重时报维度不匹配,或者干脆报Segmentation fault,程序秒退。
原因:只改了三个classes=,没有同步改 yolo 层前面卷积层的filters=,导致最后一层输出的通道数和下一层能接受的对不上。
解决:全局搜filters=,把每个 yolo 层前最近的 filters 改成(N + 5) * 3,四个类别就填 27,八个类别就填 39。改完先不加载预训练权重跑一下./darknet detect,确认能正常启动再加权重。
5.3 类别 ID 从 1 开始编,第一类永远学不会
现象:训练不报错,loss 也正常下降,但测试时第一个类别完全不识别,第二个类别却经常把第一类的目标也框出来。
原因:生成 txt 时有人认为类别编号应该从 1 开始,结果所有标注的 ID 整体偏移了一位,第一个类别成了“无中生有”的 ID 0。
解决:类别 ID 固定从 0 开始。统计完类别后,trash.names里的行序就是 ID,转换脚本里用class_names.index(name)获取 ID,不要自己手工加 1。
5.4 loss 正常但 mAP 为 0:txt 坐标出了边界
现象:训练过程 loss 正常下降,跑验证集 mAP 却是 0,预测图上一个框都没有。
原因:标注坐标没有归一化,或者中心点和宽高公式算错,写进 txt 的是原始像素值甚至负数,darknet 读进去后过滤掉了所有预测。
解决:打开一个转换好的 txt 检查,前面几行应该是这样的数值:
for f in VOCdevkit/labels/*.txt; do awk '{if($2>1||$3>1||$4>1||$5>1) print FILENAME": 坐标越界"}' "$f" done这个循环会把所有越界文件列出来。正常文件里全部数值都应该在 0 到 1 之间,如果查出来大片越界,回到第 3.2 节检查公式里的除法是否漏了img_w和img_h。
5.5 显存溢出:CUDA out of memory
现象:训练刚启动就报 CUDA OOM,或者跑几十个 iteration 崩一次,保住小命全靠手速。
原因:cfg 默认batch=64、subdivisions=16,等于一次送 4 张图进去算,图片尺寸 608 时显存开销被放大好几倍。
解决:把subdivisions从 16 调到 64,这不会改变总 batch,只是把 64 张图拆成小批次送进去;再把random=0关掉多尺度训练;还不行就把width和height从 608 降到 416。实测 8GB 显存用 416 + subdivisions=64 能顺利跑起来。
5.6 训练到一半断电,只能从头再来
现象:训练跑了一夜,断电断网导致进程中断,之前存的权重只有backup里的中间结果,重启后有人又拿darknet53.conv.74从零开始训。
原因:忘了 darknet 支持断点续训,重新用预训练权重等于把之前的进度全丢了。
解决:中断后直接拿backup/xxx_last.weights当预训练权重启动:
./darknet detector train data/trash.data cfg/yolov3-trash.cfg backup/yolov3-trash_last.weights -map想更稳一点,可以顺手把 cfg 里的learning_rate从 0.001 降到 0.0001,避免续训时步子迈太大越过之前的收敛点。
6. 验证走一遍:mAP 计算、单图测试与新增类别的迁移训练
训练完的权重不能直接丢进文件夹就完事,先跑一次验证拿到 mAP,再做单图测试确认效果,心里才有底。darknet 的验证和测试命令都集成在同一个二进制里,只是入口参数不同。
6.1 计算验证集 mAP
./darknet detector valid data/trash.data cfg/yolov3-trash.cfg backup/yolov3-trash_last.weights -map这个命令会把验证集里每张图的预测结果写到 result 目录,同时输出 mAP@0.5。第一次跑建议开着-map,方便拿到一个可量化的基线。后面对比不同训练策略时,只看这个数字就行。
6.2 单图测试
./darknet detector test data/trash.data cfg/yolov3-trash.cfg backup/yolov3-trash_last.weights -thresh 0.25 data/test.jpg-thresh控制置信度阈值,0.25 是常用值,低于它的预测框会被过滤掉。输出的predictions.jpg就是带框结果,先扫一眼框的位置对不对,再回来看准确率数字。如果小目标漏检严重,可以逐张图试 0.1 的阈值,确认模型本身学到了,而不是阈值卡的太死。
6.3 新增一个垃圾类别怎么办
项目做大了迟早要加新类别。把新类别的图片标注成 VOC 格式放进 Annotations 和 JPEGImages,重新跑一遍第 3.2 节转换脚本——它会自动刷新trash.names和 labels。然后改 cfg:classes加 1,filters按新类别数重算,加载旧权重继续训练,但学习率要降到原来的十分之一。这样旧类别的特征不会被打乱,新类别也能在几十个 epoch 内收敛。我之前把四类垃圾扩到八类时,旧类别 mAP 只掉了 1 个百分点左右,新类别上来就有可用效果。
从那以后我每次拿到 VOC 数据集,第一件事永远是跑类别统计脚本,确认classes=、filters=、trash.names行序这三处完全对齐,再启动训练。这套检查顺序帮我避免了大半的坑,希望帮到你。
本文还有配套的精品资源,点击获取