VOC2007数据集解析、格式转换与mAP评测实战

发布时间:2026/10/30 6:56:09
VOC2007数据集解析、格式转换与mAP评测实战 简介VOC2007数据集是PASCAL VOC挑战赛发布的经典图像识别与目标检测基准面向计算机视觉学习者、算法工程师与深度学习入门研究者可用于图像分类、目标检测、语义分割等任务的训练与评估。压缩包共21282个文件约842.84MB其中jpg与xml各9963个图像与PASCAL VOC格式标注一一对应另有1264个png与92个txt多用于分割掩码、类别清单和数据集划分说明方便直接接入Faster R-CNN、YOLO、SSD等常见框架。数据覆盖20类日常物体标注包含图像尺寸、边界框坐标与类别标签训练、验证、测试划分齐全可支撑mAP指标复现与模型对比实验。已有2584人学习下载适合作为课程实验、论文复现与检测算法入门的标准数据基础。1. VOC2007 解压后先别急着训练二十类、九千多张图以及一套用了十几年的评测口径VOC2007 这个 zip 我给三个项目当过数据底座每次拆包都有人问同一个问题照着教程跑完mAP 怎么只有个位数。VOC2007 是 2007 年放出的检测与分割基准20 个类、9963 张 JPEG 图标注是每张图一个 XML框用 1-based 像素坐标存。它的价值不在“大”而在口径统一、标注干净、评测方式十几年没变过拿它验证一条检测链路是否真的跑通比拿几万张脏数据试错便宜得多。适合三类人要复现老 baseline 的、要练格式转换的、要给学生搭一个一晚上能跑完的教学 demo 的。但它有脾气test 划分、difficult 标记、坐标基准这三处踩坑的人不少。2. 解压后的目录解剖五个文件夹与三份划分清单怎么对上号很多人拿到 zip 的第一反应是直接指向根目录开训结果框架报“找不到标注”。VOC2007 的目录层级比想象中深一层而且划分信息不在图片目录里而是单独放在ImageSets/Main下的一堆 txt 里。这一层关系没理清后面所有脚本都是空中楼阁。先看骨架。常见的压缩包解开后是VOCdevkit/VOC2007/这样两层里面并排五个目录JPEGImages、Annotations、ImageSets、SegmentationClass、SegmentationObject。前两个是检测用的后两个是分割用的ImageSets是索引。如果你只用检测后两个目录可以先放着不管但别删——有些转换脚本会顺手校验目录完整性。unzip VOC2007数据集.zip -d voc2007 # 先确认层级后面所有脚本都依赖这个 root find voc2007 -maxdepth 3 -type d | sortfind的-maxdepth 3是故意设的层级太深会把每个子目录都刷一遍输出几百行反而看不清。我一般先跑这一条确认Annotations和JPEGImages是同级目录再往下写代码。2.1 JPEGImages 与 Annotations 的命名对齐图片和标注靠文件名对齐规则简单到容易被忽略六位数字补零000005.jpg对应000005.xml。图片尺寸不统一长边大多在 500 像素上下短边各不相同宽高比从接近正方到 16:9 都有。这意味着你不能假设一个固定输入尺寸后面做 resize 或 letterbox 时要按每张图的真实宽高算。对齐检查不要靠肉眼数文件数用集合差集几十行代码能把“图片有、标注没有”和“标注有、图片没有”两类问题一次找出来。我在一个二次打包的版本上就遇到过Annotations比JPEGImages少十几条的情况原因是打包时把几张损坏图删了标注忘了同步。import os root voc2007/VOCdevkit/VOC2007 imgs {os.path.splitext(f)[0] for f in os.listdir(f{root}/JPEGImages) if f.endswith(.jpg)} xmls {os.path.splitext(f)[0] for f in os.listdir(f{root}/Annotations) if f.endswith(.xml)} print(图片数:, len(imgs), 标注数:, len(xmls)) print(有图无标注:, sorted(imgs - xmls)[:10]) print(有标注无图:, sorted(xmls - imgs)[:10])这里用splitext而不是split(.)因为极少数文件名里带点。输出的前 10 条足够定位问题全量打印在几千条规模下只会刷屏。差集为空才说明这份包是干净的不为空就先把差集里的 id 从 txt 划分里剔掉别留到训练时报FileNotFoundError。2.2 ImageSets/Main 里的三份划分清单ImageSets/Main下有两类文件一类是划分清单一类是类别标签。划分清单常见的是train.txt、val.txt、trainval.txt、test.txt一份流传较广的版本里分别是 2501、2510、5011、4952 条加起来 9963正好对上图片总数。注意trainval是train和val的并集不是第四份独立数据做实验时别把trainval和train同时塞进 dataloader那样验证集会泄漏。另一类是类别名_划分.txt比如person_train.txt每行形如000012 1第二个字段是1、-1或0。1表示这张图里有该类目标-1表示没有0表示有但被标成了 difficult。这个约定在做单类检测或者按类采样时很好用比每次去解析 XML 快得多。需要留意的是test.txt。VOC2007 的 test 标注在当年是赛后补发的所以有些第三方重新打包的版本里test.txt有 4952 条但Annotations里对应的一批 XML 是缺的。这不是你代码写错了是包本身的问题。遇到这种情况要么只用trainval做训练加自划分验证要么换一份标注齐全的包。2.3 一次性体检脚本在正式写 Dataset 之前我会跑一遍体检把三类问题提前暴露XML 能不能解析、图片能不能解码、类别分布是否正常。这三件事放到训练循环里排查成本会翻好几倍。import xml.etree.ElementTree as ET from collections import Counter from PIL import Image root voc2007/VOCdevkit/VOC2007 cls_counter, bad_xml, bad_img Counter(), [], [] for fid in sorted(xmls): try: r ET.parse(f{root}/Annotations/{fid}.xml).getroot() except ET.ParseError: bad_xml.append(fid) continue for obj in r.iter(object): cls_counter[obj.find(name).text.strip()] 1 try: with Image.open(f{root}/JPEGImages/{fid}.jpg) as im: im.verify() # 只校验文件头不真正解码速度快 except Exception: bad_img.append(fid) print(坏 XML:, bad_xml) print(坏图片:, bad_img) for k, v in cls_counter.most_common(): print(f{k:14s} {v})im.verify()只检查文件头不加载像素几千张图跑完几秒钟。它的副作用是执行后该 Image 对象不能再用所以我在with里用完就丢。类别计数用Counter打出来你会看到明显的长尾person一万多个框pottedplant只有几百个。这个分布直接决定了后面按类看 AP 时小类数字波动会很大别拿单次结果下结论。体检通过再往下走。这一步花五分钟能省掉后面几小时对着 loss 曲线发呆的时间。3. 解析 XML 标注把 bbox、difficult、truncated 读成能训练的张量XML 是这套数据集的原生格式字段不多但每个都有含义理解偏差会直接体现在指标上。这一章把字段逐个拆开然后落到一个能直接用的 Dataset 类。3.1 annotation 字段逐项对照一个典型的 XML 根节点下有folder、filename、source、size、segmented然后是若干个object节点。真正影响训练的是size和object里的内容。字段位置含义训练时的处理size/widthsize/height根节点图像原始宽高归一化坐标必须用它不能用 PIL 读出来的值size/depth根节点通道数多为 3少数灰度图是 1要统一转 RGBobject/name每个目标类别名必须映射到固定的类别索引object/difficult每个目标难样本标记0 或 1评测时通常过滤训练时看策略object/truncated每个目标是否被图像边界截断一般保留可作采样权重object/bndbox每个目标xmin/ymin/xmax/ymax1-based闭区间需要裁剪difficult这个字段是最容易被忽略的一个。它标的是“人眼也很难认”的目标官方评测口径是在算 AP 时把这类框排除掉——既不作为正样本匹配也不计入漏检。如果你训练时保留了它、评测时没过滤mAP 会莫名其妙低一截反过来训练时丢了、评测时没过滤又会虚高。全流程保持一致比选哪种更重要。bndbox是 1-based 的最小值是 1 不是 0且xmax、ymax是包含在框内的。所以严格算宽度应该是xmax - xmin 1。实际工程里大多数框架直接用xmax - xmin差一个像素对 IoU 的影响在小数点后三位我一般统一不加 1但要求整条链路转换、训练、评测都用同一套中途换算法就会引入系统性偏移。3.2 写一个能直接跑的 Dataset下面这个类只依赖 PIL 和 PyTorch不做任何增强目的是把解析逻辑单独拎出来看清楚。增强部分后面用 albumentations 或 torchvision 的 transforms 接上就行。import os import xml.etree.ElementTree as ET import torch from torch.utils.data import Dataset from PIL import Image CLASSES (aeroplane, bicycle, bird, boat, bottle, bus, car, cat, chair, cow, diningtable, dog, horse, motorbike, person, pottedplant, sheep, sofa, train, tvmonitor) class VOC2007Dataset(Dataset): def __init__(self, root, splittrainval, keep_difficultFalse): self.root root self.keep_difficult keep_difficult with open(os.path.join(root, ImageSets, Main, f{split}.txt)) as f: self.ids [ln.strip() for ln in f if ln.strip()] def __len__(self): return len(self.ids) def __getitem__(self, i): fid self.ids[i] img_path os.path.join(self.root, JPEGImages, f{fid}.jpg) xml_path os.path.join(self.root, Annotations, f{fid}.xml) img Image.open(img_path).convert(RGB) # 灰度图和 CMYK 在这里统一 w, h img.size # 与 XML 的 size 应该一致 root ET.parse(xml_path).getroot() boxes, labels [], [] for obj in root.iter(object): name obj.find(name).text.strip() if name not in CLASSES: # 脏数据兜底 continue diff int(obj.find(difficult).text) if diff 1 and not self.keep_difficult: continue bb obj.find(bndbox) xmin float(bb.find(xmin).text) ymin float(bb.find(ymin).text) xmax float(bb.find(xmax).text) ymax float(bb.find(ymax).text) # 裁剪到图像范围内1-based 最小值是 1减 1 换到 0-based xmin max(0.0, min(xmin - 1, w - 1)) ymin max(0.0, min(ymin - 1, h - 1)) xmax max(0.0, min(xmax - 1, w - 1)) ymax max(0.0, min(ymax - 1, h - 1)) if xmax - xmin 1 or ymax - ymin 1: continue # 退化框直接丢 boxes.append([xmin, ymin, xmax, ymax]) labels.append(CLASSES.index(name)) target { boxes: torch.tensor(boxes, dtypetorch.float32).reshape(-1, 4), labels: torch.tensor(labels, dtypetorch.int64), image_id: torch.tensor([int(fid)]), area: torch.tensor([(b[2]-b[0])*(b[3]-b[1]) for b in boxes], dtypetorch.float32), } return img, target几个参数值得单独说。split直接对应ImageSets/Main下的文件名前缀传train、val、trainval、test都行前提是对应 txt 存在。keep_difficult默认False训练时我一般保持False让模型不去拟合那些连人眼都难判的框做消融时再打开对比。CLASSES的顺序必须和你后面写进配置文件、写进提交文件的顺序完全一致这个顺序错了指标会掉到接近零而且报错信息不会提示你任何东西——这是最典型的黑匣子故障。image_id用文件名的数字部分而不是自增索引。转换到 COCO 格式时image_id会写进 json评测脚本靠它把预测和标注对上。用自增索引一旦中间过滤过样本两边顺序错位结果是所有预测都匹配不上mAP 归零。这个坑我踩过一次排查了两小时。3.3 越界框与尺寸不一致的处理XML 里的size和 PIL 读出来的尺寸理论上一致实际不是。有些图被重新压缩过EXIF 里带了旋转标记PIL 的size和 XML 里的宽高会对调。检测框坐标是按 XML 那套存的所以要以 XML 为准或者在读图时用ImageOps.exif_transpose把方向摆正再取尺寸。两种做法都行关键是别混着用。越界框的处理要有明确策略不能默认“数据是干净的”。实际统计下来xmax超过图像宽度的框不算罕见普遍是标注时手抖多框了几个像素。裁剪到边界是安全的但裁剪后要重新判断宽高避免出现xmin xmax的退化框。这种框在算 IoU 时分母为 0某些实现会直接吐nan然后整个 batch 的 loss 变成nan你再回头找是哪张图工作量就大了。还有个细节truncated1的框不要丢。它占的比例不小丢了会让模型对贴边目标特别不敏感。如果确实想降权可以在采样阶段做而不是在解析阶段删。4. 从 VOC 转到 YOLO 和 COCO坐标换算与两个格式的边界绝大多数检测框架不吃原生 VOC 格式。YOLO 系要images/加labels/的 txtCOCO 系要一个 json。转换脚本网上一搜一大把但十个里有八个在类别索引和坐标归一化上做手脚。4.1 VOC 到 YOLO 的归一化写法YOLO 的 txt 每行是class_id xc yc w h全部归一化到 0 到 1其中xc、yc是框中心点。换算过程中最容易出岔子的是除以哪个宽高必须用该图自身的宽高不是数据集的平均尺寸也不是网络输入尺寸。import os from pathlib import Path from PIL import Image ROOT Path(voc2007/VOCdevkit/VOC2007) OUT Path(yolo_voc2007) CLASSES [...] # 与训练时完全一致顺序不能改 for split in (train, val, test): ids [l.strip() for l in (ROOT/ImageSets/Main/f{split}.txt).read_text().splitlines() if l.strip()] (OUT/images/split).mkdir(parentsTrue, exist_okTrue) (OUT/labels/split).mkdir(parentsTrue, exist_okTrue) for fid in ids: img Image.open(ROOT/JPEGImages/f{fid}.jpg).convert(RGB) w, h img.size lines [] root ET.parse(ROOT/Annotations/f{fid}.xml).getroot() for obj in root.iter(object): name obj.find(name).text.strip() if name not in CLASSES or int(obj.find(difficult).text) 1: continue bb obj.find(bndbox) x1 max(0.0, float(bb.find(xmin).text) - 1) y1 max(0.0, float(bb.find(ymin).text) - 1) x2 min(float(bb.find(xmax).text) - 1, w - 1) y2 min(float(bb.find(ymax).text) - 1, h - 1) if x2 - x1 1 or y2 - y1 1: continue xc (x1 x2) / 2 / w yc (y1 y2) / 2 / h bw (x2 - x1) / w bh (y2 - y1) / h lines.append(f{CLASSES.index(name)} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}) (OUT/labels/split/f{fid}.txt).write_text(\n.join(lines)) img.save(OUT/images/split/f{fid}.jpg){:.6f}保留六位小数是我习惯的做法。VOC 的图长边才 500 像素归一化后精度到小数点后六位已经完全够用再多位只会让文件变大。min(..., w - 1)这个上界必须加不然归一化后会出现大于 1 的坐标部分训练框架会直接断言失败。CLASSES的顺序要写进data.yaml的names字段并且和模型输出的通道顺序对应。我见过有人把类别按字母序排而CLASSES按 VOC 官方顺序排训练时 loss 降得挺漂亮验证时 mAP 只有 2%——因为每个类别都在学别人的特征。这种错最难查指标不会报错只是低。4.2 VOC 到 COCO 的 json 构造COCO 格式的坑集中在 id 上。images[].id、annotations[].image_id、annotations[].category_id三套 id 各自独立category_id在 COCO 里是从 1 开始的0 保留给背景。如果你从 0 开始编号评测脚本会把所有预测当成背景。import json coco {images: [], annotations: [], categories: []} for i, name in enumerate(CLASSES, start1): coco[categories].append({id: i, name: name, supercategory: none}) ann_id 1 for split in (train, val): for fid in ids_of(split): img Image.open(ROOT/JPEGImages/f{fid}.jpg) w, h img.size img_id int(fid) # 直接用文件名数字全局唯一 coco[images].append({id: img_id, file_name: f{fid}.jpg, width: w, height: h}) for obj in ET.parse(ROOT/Annotations/f{fid}.xml).getroot().iter(object): name obj.find(name).text.strip() if name not in CLASSES: continue bb obj.find(bndbox) x1 float(bb.find(xmin).text) - 1 y1 float(bb.find(ymin).text) - 1 bw float(bb.find(xmax).text) - x1 - 1 bh float(bb.find(ymax).text) - y1 - 1 if bw 1 or bh 1: continue coco[annotations].append({ id: ann_id, image_id: img_id, category_id: CLASSES.index(name) 1, bbox: [x1, y1, bw, bh], # COCO 是 [x, y, w, h]不是角点 area: bw * bh, iscrowd: 0, }) ann_id 1 json.dump(coco, open(voc2007_trainval.json, w))注意bbox是左上角加宽高不是[x1, y1, x2, y2]。这个格式差异导致过大量“转换后框位置整体偏移”的问题——框的左上角对了右下角全错可视化一看就是一片大小不对的框。iscrowd统一写 0。VOC 没有 crowd 概念difficult在这套 json 里表达不出来所以做 COCO 口径评测时difficult 样本会被当成普通正样本算进去指标和 VOC 口径对不上是正常的别以为是模型退化。4.3 转换后的抽检转换脚本跑完一定要抽几张画出来看。不用写复杂的可视化把框画回去叠在原图上肉眼看三张就够。import matplotlib.pyplot as plt import matplotlib.patches as patches fid 000012 img Image.open(ROOT/JPEGImages/f{fid}.jpg) fig, ax plt.subplots(1, figsize(8, 6)) ax.imshow(img) for line in open(OUT/labels/train/f{fid}.txt): c, xc, yc, bw, bh line.split() w, h img.size x1 (float(xc) - float(bw)/2) * w y1 (float(yc) - float(bh)/2) * h ax.add_patch(patches.Rectangle((x1, y1), float(bw)*w, float(bh)*h, fillFalse, edgecolorred, lw2)) ax.text(x1, y1 - 5, CLASSES[int(c)], colorred, fontsize9) plt.show()抽检要覆盖三种情况单目标小图、多目标拥挤图、有贴边目标的图。前两种验证坐标换算第三种验证裁剪逻辑。三张都对了才说明转换这条链路是通的。5. 排查清单五个真实翻车现场和它们的成因这一章记录的是我实际遇到过、并且不止一次在别人机器上重现的问题。共同点是都没有明确报错或者报错信息指向完全无关的地方。5.1 训练 loss 正常下降mAP 却接近零现象训练几十轮分类 loss 和框回归 loss 都在降验证集 mAP 稳定在 1% 到 3% 之间换个模型结构也没变化。原因类别索引错位。三种典型情况是CLASSES元组在转换脚本和训练配置里顺序不一致YOLO 的 txt 从 0 开始编号而配置文件里写了从 1 开始COCO 的category_id从 0 起编。这三种情况下模型学的是“第 k 个通道对应某类特征”但评估时按名字去匹配全部错位。解决把类别映射表单独抽成一个模块转换脚本、训练配置、评测脚本全部import同一份禁止各写一遍。写完之后做一次断言检查随机取 20 个标注框用映射表反查名字和 XML 里的name逐条比对不一致就退出。5.2 读图报错或通道数不对现象RuntimeError: expected 3 channels或者训练出来的模型对某些图预测异常。检查数据发现是个别图明明是彩色的读出来却是单通道。原因VOC2007 里混了少量灰度图和 CMYK 图PIL 默认按原格式读np.array(img).shape可能是(h, w)或(h, w, 4)。直接送进网络就会在通道维度上崩。解决Dataset 里读图后强制.convert(RGB)。这一步没有性能代价PIL 的转换是懒加载的。另外在体检脚本里加一条统计把Image.open(...).mode不等于RGB的文件 id 打印出来心里有数。5.3 test 集数量对得上标注却缺失现象test.txt里有 4952 条跑评测时有一批 id 找不到 XML抛FileNotFoundError或者被try/except吞掉之后 mAP 偏低。原因VOC2007 的 test 标注是赛后补发的一些第三方重新打包的版本只保留了trainval的标注test.txt却原样留着。这不是代码问题。解决跑一遍 2.1 的差集检查把有图无标注的 id 列出来。如果差集规模很大上千条基本可以确认是包的问题。处理方式有两种从trainval里自划一份验证集不碰 test或者换一份标注完整的包。别试图用模型去“补”test 标注那等于自己给自己出题自己判卷。5.4 训练中途 loss 变 nan现象前几个 epoch 正常某一轮开始 loss 变成nan之后再也不恢复。原因退化框。xmin xmax或ymin ymax的框在算 IoU 时分母为零梯度直接炸掉。这类框在 VOC2007 里数量不多但确实存在尤其是在图像边缘被截断的目标上。解决解析阶段就过滤掉宽或高小于 1 像素的框并且在裁剪到图像边界之后再判断一次。另外在算 IoU 的地方加一个eps即便有漏网的也不会产生nan。加了之后如果还出nan往学习率方向查别在数据上死磕。5.5 直接 resize 导致小目标消失现象模型在大目标上表现正常bottle、pottedplant这类小类的 AP 极低。原因VOC2007 短边常常只有三百多像素直接 resize 到 640×640 会把长宽比拉变形同时小目标的绝对像素被压缩。有些实现还用了resize而不是letterbox形变更严重。解决用 letterbox保持长宽比短边缩放到目标尺寸长边补灰边标注框同步做同样的变换。补边区在算 loss 时用 mask 屏蔽掉。这套处理在 YOLO 系框架里基本是标配但自己手写 dataloader 时特别容易漏。6. 把指标算对AP 计算的三个口径和一个固定习惯VOC2007 的 AP 有两套插值方式11 点插值和全点插值。2007 年之后官方评测改用全点插值但很多老论文报的是 11 点结果两者能差一到两个点。你要复现某个数字先确认对方用的是哪套否则怎么调都差一点很玄学。自己实现 AP 时匹配逻辑按这个顺序走对每个类别把该类所有预测按置信度降序排逐个取预测和同图同类的、尚未匹配的 GT 算 IoU取最大者IoU 大于阈值检测一般 0.5则记为 TP 并标记该 GT 已匹配否则记为 FPdifficult 的 GT 在匹配时跳过既不产生 TP 也不产生 FP。累积出 precision 和 recall 曲线后做包络再积分。口径插值方式适用场景VOC2007 11 点recall 取 0, 0.1, …, 1.0 共 11 点复现 2007 到 2009 年的老结果VOC2010 全点每个 recall 变化点都参与现在的通用做法建议默认用这个COCO 0.5:0.9510 个 IoU 阈值取平均和 COCO 系结果对齐时用import numpy as np def voc_ap(rec, prec, use_11_pointFalse): if use_11_point: ap 0.0 for t in np.arange(0, 1.1, 0.1): p prec[rec t].max() if np.any(rec t) else 0.0 ap p / 11.0 return ap mrec np.concatenate(([0.0], rec, [1.0])) mpre np.concatenate(([0.0], prec, [0.0])) for i in range(len(mpre) - 2, -1, -1): # 从右往左做单调包络 mpre[i] max(mpre[i], mpre[i 1]) idx np.where(mrec[1:] ! mrec[:-1])[0] return np.sum((mrec[idx 1] - mrec[idx]) * mpre[idx 1])那个从右往左的循环是包络作用是消掉 precision 曲线里的抖动。少了这一步AP 会偏低而且低得不规律你会以为是模型的问题。最后一个习惯每次跑完评测先把 20 个类别的 AP 打成一张表看分布而不是只看均值。均值 70 但pottedplant只有 20和均值 68 但每类都在 60 到 75 之间是两种完全不同的模型状态。我早期只盯均值调了两周参数后来把分类 AP 打出来才发现有一类的标注过滤写错了白折腾。从那以后我每次评测完都强制走一遍分类 AP 表先看短板再看总数。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询