做目标检测这些年,最耗我时间的一直不是模型调参,而是数据准备。前阵子给某教学项目做一批车辆检测数据,从监控视频里抽帧出来一千多张图,接下来就是逐个框车、打标签。把一堆几百兆的专业标注软件装一遍,又觉得没必要;在线平台还得上传下载来回折腾。最后我决定自己写一套 Pascal VOC 格式数据集制作工具,局域网内离线完成标注、转换、校验全流程。
这套小工具做完以后,我发现一个反直觉的事实:Pascal VOC 这个 2012 年前后定型的老格式,到今天依然是 YOLO 系列、Faster R-CNN 等目标检测框架在数据交换上的“最大公约数”。如果你也在自己做数据集、自己标注跑训练,或者想绕过那些笨重的标注软件,这篇文章里的代码、目录结构和踩坑记录,基本可以照着抄。
1. 目标检测项目里,Pascal VOC 格式为什么总也躲不开
先说结论:不是因为它先进,而是因为它的“笨”恰好成了兼容性来源。
1.1 它到底是个什么样的“老格式”
Pascal VOC(Visual Object Classes)最早来自一个公开视觉挑战赛的标注规范,用于物体检测、分类和分割任务。它的核心约定不难理解:一张图片配套一个同名的 XML 文件,XML 里记录图片文件名、图片宽高、通道数,以及图片里每一个目标的类别名和边界框坐标。图片放在约定目录下,XML 放在另一个目录下,训练脚本按目录扫描就能把数据集读进来。
我最初觉得这种设计多了一层文件麻烦,后来才懂它的妙处。图片和文本描述完全解耦,训练框架只要会解析 XML,就能读取任意标注工具产出的数据,因此很多开源训练框架的官方数据加载器里都内置了 VOC 解析模块。换句话说,你交付的不只是图片,而是一份“图片 + 说明书”的完整数据集。哪怕以后换训练框架,XML 这批资产也不会作废。
1.2 与 YOLO 和 COCO 两种主流格式的对比
三种格式我放在一起对比过,核心差异主要在坐标表示和文件组织上:
| 格式 | 文件表现 | 坐标规定 | 边界框记录方式 |
|---|---|---|---|
| Pascal VOC | 每张图片对应一个同名 .xml | 像素绝对值,原点在左上角 | xmin, ymin, xmax, ymax |
| YOLO | 每张图片对应一个同名 .txt | 归一化到 [0,1] | x_center, y_center, width, height |
| COCO | 整个数据集一个 .json | 像素绝对值,原点在左上角 | x, y, width, height |
VOC 的坐标最贴近人眼阅读习惯,调试时打开 XML 就能看到具体像素位置,非常直观;YOLO 的归一化坐标和图片分辨率无关,换分辨率不用重新标注;COCO 适合动辄几万张的大规模数据集,但 JSON 嵌套复杂,日常小项目用起来反而笨重。
落到框架支持上看:YOLO 原生用 txt,但也提供格式转换工具;很多开源检测框架的直接数据加载器支持 VOC;即便是底层用 COCO 的框架,通常也有一行命令把 VOC 转过去。所以我的习惯是:无论最终训练用哪个框架,先统一做成 VOC 格式,再按需转换。
1.3 自制工具要解决的实际问题
既然现成标注软件很多,为什么还要自己写?我的理由是这三点:一是轻量,一个 Python 脚本就能起一个标注界面,不依赖重型 GUI 程序;二是可控,标注字段、类别管理、输出目录由我完全自定义;三是离线安全,数据全程不离开本机,适合敏感或教学场景。对两三千张以内的小型数据集来说,这个方案比“为了标几百张图去装一套完整标注平台”要划算得多。
2. 工具整体设计:一个可复用的数据集制作闭环
这个工具我拆成了三个模块,分别对应数据集制作的三段流程:手工标注、批量转换、统一校验。
2.1 三个模块各自负责什么
手工标注模块解决“从零标注”的问题。它读取一张图片,显示在画布上,我用鼠标拖框选中目标,填入类别名,然后工具按 VOC 规范生成 XML。这个模块适合数据量不大、目标比较清晰的情况,比如一百张到几百张的专项数据。
批量转换模块解决“已有标注但格式不对”的问题。很多时候手头已经有 YOLO txt 标注或者某个平台的导出文件,重新手工标一遍纯属浪费。转换模块读取既定中间格式,结合图片宽高换算成 VOC 的绝对坐标,批量生成整套 XML。
校验模块是训练前的最后一道防线。它会遍历整个数据集,检查图片能否正常打开、坐标有没有越界、文件名是否一致、类别是否在预期集合内。这一步看起来不起眼,但能拦下后续训练过程中最让人崩溃的“莫名 loss 变成 nan”和“数据加载直接报错”。
2.2 目录结构与命名规范
我在项目里固定使用以下目录结构,这也是 VOC 系列数据集的通行布局:
dataset_root/ ├── JPEGImages/ │ ├── 000001.jpg │ ├── 000002.jpg │ └── ... ├── Annotations/ │ ├── 000001.xml │ ├── 000002.xml │ └── ... └── ImageSets/ └── Main/ ├── train.txt └── val.txtJPEGImages 放原始图片,Annotations 放同名 XML,ImageSets/Main 下的 txt 每行写一个不带扩展名的图片名,用来划分训练集、验证集。命名我统一用六位数字,比如 000001.jpg,原因很简单:避免中文文件名和特殊字符在不同系统间折腾,也方便按字典序排列。这个细节看起来小,实际用起来能省掉大量框架读取路径时的编码问题。
2.3 选型理由
GUI 我选了 tkinter 加 Pillow,没有引入任何重型界面库。tkinter 是 Python 自带的标准库,跨平台,Canvas 组件天然适合画矩形框;Pillow 负责读取和显示 JPEG、PNG。图像校验则用 OpenCV,它的 imread 对损坏图片的判失败行为非常干净利落。
XML 生成和解析用标准库 xml.etree.ElementTree,不需要额外安装。整体来看,这个工具唯一的第三方依赖就是 Pillow 和 OpenCV,安装成本几乎为零。对我来说,工具本身不是产品,而是服务项目的“基础设施”,越简单越耐用好维护。
3. 手工标注:在画布上框选目标,自动生成 XML
先看 XML 每个节点到底是什么意思,否则后面代码你会看得一头雾水。
3.1 XML 节点逐项拆解
一个标准的 VOC 标注文件长这样:
<annotation> <folder>JPEGImages</folder> <filename>000001.jpg</filename> <path>/data/dataset_root/JPEGImages/000001.jpg</path> <source> <database>Unknown</database> </source> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>car</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>318</xmin> <ymin>405</ymin> <xmax>712</xmax> <ymax>690</ymax> </bndbox> </object> </annotation>folder 是图片所在目录名,filename 是图片文件名,size 里的 width、height 必须是从原图读取的真实像素尺寸,不是画布显示尺寸。object 节点里最重要的两个部分:name 是类别名,bndbox 是边界框,xmin、ymin 是左上角像素坐标,xmax、ymax 是右下角像素坐标,坐标原点在图片左上角。一个 XML 里可以有多个 object,对应一张图上的多个目标。
truncated 表示目标是否被图片边缘截断,difficult 表示是否难以辨认。手工标注时我默认都写 0,也不要求 GUI 去判断,但字段要保留,很多训练脚本的解析器会去读它。
3.2 交互核心代码:拖框、记录坐标、生成 XML
GUI 部分的核心思路是:点击鼠标左键记录起点,拖动时在 Canvas 上画临时矩形,松开鼠标记录终点,并把(xmin, ymin, xmax, ymax)加入当前图片的标注列表。
import os import tkinter as tk from tkinter import filedialog, simpledialog from PIL import Image, ImageTk import xml.etree.ElementTree as ET class VocAnnotator: def __init__(self, root): self.root = root self.canvas = tk.Canvas(root, cursor="cross") self.canvas.pack(fill="both", expand=True) self.start_x = None self.start_y = None self.rect = None self.objects = [] self.current_class = "car" self.image_path = None self.tk_image = None self.raw_width = 0 self.raw_height = 0 self.canvas.bind("<ButtonPress-1>", self.on_press) self.canvas.bind("<B1-Motion>", self.on_drag) self.canvas.bind("<ButtonRelease-1>", self.on_release) def load_image(self, path): img = Image.open(path) self.raw_width, self.raw_height = img.size self.image_path = path self.tk_image = ImageTk.PhotoImage(img) self.canvas.create_image(0, 0, anchor="nw", image=self.tk_image) self.canvas.config(scrollregion=(0, 0, self.raw_width, self.raw_height)) self.objects.clear() def on_press(self, event): self.start_x = self.canvas.canvasx(event.x) self.start_y = self.canvas.canvasy(event.y) def on_drag(self, event): cur_x = self.canvas.canvasx(event.x) cur_y = self.canvas.canvasy(event.y) if self.rect: self.canvas.coords(self.rect, self.start_x, self.start_y, cur_x, cur_y) else: self.rect = self.canvas.create_rectangle( self.start_x, self.start_y, cur_x, cur_y, outline="red", width=2 ) def on_release(self, event): end_x = self.canvas.canvasx(event.x) end_y = self.canvas.canvasy(event.y) xmin, xmax = min(self.start_x, end_x), max(self.start_x, end_x) ymin, ymax = min(self.start_y, end_y), max(self.start_y, end_y) self.objects.append({ "name": self.current_class, "xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax, })这里最容易踩坑的是画布缩放。如果图片太大,我会在 Canvas 上滚动浏览而不是缩放显示,取坐标时用 canvasx、canvasy 把显示坐标换算成逻辑坐标,而不是直接用 event.x、event.y。如果你为了实现“图片适应窗口”而做了缩放,那保存坐标前必须用缩放比例反向换算,否则保存的框就和图片错位了。
保存 XML 的代码我单独封装了一个函数,这样手工标注和批量转换可以共用:
def save_xml(image_path, objects_tuples, save_dir): """objects_tuples: list of (name, xmin, ymin, xmax, ymax)""" with Image.open(image_path) as img: w, h = img.size root = ET.Element("annotation") ET.SubElement(root, "folder").text = os.path.basename(os.path.dirname(image_path)) ET.SubElement(root, "filename").text = os.path.basename(image_path) ET.SubElement(root, "path").text = os.path.abspath(image_path) source = ET.SubElement(root, "source") ET.SubElement(source, "database").text = "Unknown" size_el = ET.SubElement(root, "size") ET.SubElement(size_el, "width").text = str(w) ET.SubElement(size_el, "height").text = str(h) ET.SubElement(size_el, "depth").text = "3" ET.SubElement(root, "segmented").text = "0" for name, xmin, ymin, xmax, ymax in objects_tuples: obj_el = ET.SubElement(root, "object") ET.SubElement(obj_el, "name").text = name ET.SubElement(obj_el, "pose").text = "Unspecified" ET.SubElement(obj_el, "truncated").text = "0" ET.SubElement(obj_el, "difficult").text = "0" bnd = ET.SubElement(obj_el, "bndbox") ET.SubElement(bnd, "xmin").text = str(int(xmin)) ET.SubElement(bnd, "ymin").text = str(int(ymin)) ET.SubElement(bnd, "xmax").text = str(int(xmax)) ET.SubElement(bnd, "ymax").text = str(int(ymax)) tree = ET.ElementTree(root) xml_name = os.path.splitext(os.path.basename(image_path))[0] + ".xml" tree.write(os.path.join(save_dir, xml_name), encoding="utf-8", xml_declaration=True)一个保存习惯是:写 XML 前把坐标转换成整数。浮点坐标虽然不报错,但很多后续脚本或界面可视化对 float 的容忍度不一,转成 int 可以消除大量潜在问题。
3.3 标注过程中的交互细节
手工标注有一个我后来才重视的功能:撤销。拖错一个框,如果只能重启程序或者清空全部标注,会很难受。我在 GUI 上加了一个右键点击撤销最近一次框选的功能,核心就是在 on_release 里追加之前记录 rect 对象,右键时删除该图形并从 self.objects 弹出最后一项。就这么几行代码,标注效率提升不小。
另外,类别管理一定要在设计界面时就做好,而不是标完再批量改。我习惯把类别做成下拉列表或者快捷键切换,否则一百张图里手动输入类别名,很容易出现“car”和“Car”混用的情况,后期校验又得花时间清洗。
4. 批量转换:从已有标注导入,避免重复劳动
手工标注适合从零开始,但更多时候数据不是从零开始的。比如我经常拿到的是已经用其它工具标成 YOLO txt 的数据,又或者是一份 CSV 格式的坐标表。这时候需要的是批量转换。
4.1 为什么选 YOLO txt 作为中间格式
我最终把 YOLO txt 定成批量转换的统一入口。它的每一行格式固定:
class_id x_center y_center width height五个数字分别是类别索引、归一化后的中心点 x、中心点 y、归一化后的框宽、框高。所有坐标都除以了图片宽高,落在 0 到 1 之间,所以它和具体分辨率解耦。我选它做中间格式的理由很直接:社区普及度高,从各类导出工具的转换脚本多;格式足够简单,自己写解析也不会出错;类别是整数索引,配合 classes 文件就能映射成 VOC 的类别字符串。
4.2 转换代码:从归一化坐标到像素坐标
转换的核心是一个函数:把一行 YOLO 标注换算成 VOC 的绝对像素坐标。
def yolo_line_to_voc_box(line, img_w, img_h): parts = line.strip().split() cls_id = int(parts[0]) x_c = float(parts[1]) y_c = float(parts[2]) box_w = float(parts[3]) box_h = float(parts[4]) xmin = (x_c - box_w / 2.0) * img_w ymin = (y_c - box_h / 2.0) * img_h xmax = (x_c + box_w / 2.0) * img_w ymax = (y_c + box_h / 2.0) * img_h return cls_id, xmin, ymin, xmax, ymax这里有一个关键点:img_w 和 img_h 必须从实际图片读取,绝不能假设图片是 1920×1080 就去硬算。很多标注数据出问题,就是因为图片在采集或预处理阶段被缩放修改过,而 txt 里的归一化坐标没有跟着重新计算。我写的批量脚本会对每一张图调用一次图片读取拿到真实尺寸,虽然慢一点,但准确。
然后批量生成所有 XML:
import os from pathlib import Path from PIL import Image def batch_convert_yolo_to_voc(yolo_root, class_names, img_dir, xml_dir): for txt_path in Path(yolo_root).glob("*.txt"): stem = txt_path.stem img_path = os.path.join(img_dir, stem + ".jpg") if not os.path.exists(img_path): img_path = os.path.join(img_dir, stem + ".png") if not os.path.exists(img_path): continue with Image.open(img_path) as img: img_w, img_h = img.size objects = [] with open(txt_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue cls_id, xmin, ymin, xmax, ymax = yolo_line_to_voc_box( line, img_w, img_h ) if cls_id < 0 or cls_id >= len(class_names): continue objects.append((class_names[cls_id], xmin, ymin, xmax, ymax)) save_xml(img_path, [(n, x1, y1, x2, y2) for n, x1, y1, x2, y2 in objects], xml_dir)注意这段代码末尾我对 save_xml 的调用包装了一次,其实可以直接传 objects,这里只是强调 objects 的结构是(name, xmin, ymin, xmax, ymax)元组列表,和前面保存函数一致。
4.3 类别映射:数字索引到字符串名字
YOLO txt 里存的是类别的数字索引,VOC XML 里存的是类别字符串,二者之间的映射关系必须在转换时显式定义。比如:
class_names = ["car", "bus", "truck", "motorcycle", "bicycle"]txt 里的 class_id=0 会映射成 "car",class_id=2 映射成 "truck"。这个映射表必须和原来标注时用的表完全一致,否则索引错一位,模型训练出来就是“把卡车当公交车”。我曾见过一个数据集,训练后验证集 mAP 低得离谱,最后发现是转换时 classes.txt 少写了一行,所有类别整体错位。这种错误在训练阶段很难查,只能怪数据准备时没仔细核对。
映射完成后,还可以顺手检查一下转出来的 XML 里有没有空的 object 集合。如果大量 XML 没有标注框,说明源标注文件可能是空的,或者类别过滤过滤过头了。
5. 训练前的最后一道防线:数据集校验
我最早做数据集的习惯是标完就训练,然后经常被各种离奇错误浪费时间。后来强制自己在训练前跑一遍校验脚本,几分钟能把数据集里隐藏的问题扫个大概。
5.1 校验维度一:图片本身能不能读
第一关是图片文件本身。用 OpenCV 读图片,如果数据损坏,imread 会返回 None,代码立刻知道这张图有问题。
import cv2 def check_image_readable(img_path): img = cv2.imread(img_path) if img is None: return False return True常见问题包括:文件后缀是 .jpg 但实际编码是 PNG,某些程序强改后缀后 OpenCV 依然能读,但部分训练脚本读不了;文件下载或拷贝过程中几十个字节损坏,导致图像中间一大块花屏,imread 照样能读,但训练时特征已经异常;路径中有特殊字符或中文字符在某些环境下读不到。第一关只能是粗筛,不能覆盖所有情况,但已经能拦下常见的一类问题。
5.2 校验维度二:坐标是否越界、是否倒置
第二关比对 XML 里的坐标和图片真实尺寸。越界的典型原因是图片被压缩过了,但标注框还是按原图尺寸生成;倒置则说明 xmin 和 xmax 写反或者 ymin、ymax 写反。
def validate_boxes(xml_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) issues = [] for obj in root.findall("object"): name = obj.find("name").text bnd = obj.find("bndbox") xmin = float(bnd.find("xmin").text) ymin = float(bnd.find("ymin").text) xmax = float(bnd.find("xmax").text) ymax = float(bnd.find("ymax").text) if xmin >= xmax or ymin >= ymax: issues.append(f"box inverted in {name}") if xmin < 0 or ymin < 0: issues.append(f"box negative in {name}") if xmax > img_w or ymax > img_h: issues.append(f"box out of range in {name}") return issues遇到越界,我的处理方式分两种:如果只是超出几个像素,直接把坐标裁剪到图片范围内;如果超出很多,说明标注基于错误尺寸,这张图就要回炉重新标注,而不是强行保留。
5.3 校验维度三:文件名、类别和半开放标签统一
第三关是元信息一致性。XML 里的 filename 要和实际文件名一致,图片名去掉后缀后要等于 XML 去掉后缀后的名字;类别名要全部落在预先定义的类别集合里;还要检查有没有完全没有 object 的 XML,这种情况要么是漏标了,要么是故意保留的负样本,两种情况要区别对待。
类别大小写问题在大规模标注时特别常见。“Car”“car”“CAR”在 XML 里看起来只是写法不同,但对训练脚本来说是三个不同类别,会直接把类别数撑爆。所以我在校验脚本里会统计所有类别名,和标准集合比对后输出差异列表:
def check_class_names(xml_dir, valid_classes): found = {} for xml_path in Path(xml_dir).glob("*.xml"): tree = ET.parse(xml_path) root = tree.getroot() for obj in root.findall("object"): name = obj.find("name").text found[name] = found.get(name, 0) + 1 unknown = set(found.keys()) - set(valid_classes) return found, unknown这一层往往能提前发现标注人员随手拼写错误或者混用了不同语言的类别名。
5.4 把校验过程串成一次批量扫描
我把以上三个维度串成一个扫描函数,输入数据集根目录,输出问题清单。跑完脚本生成一份 report.txt,逐条列出问题图片和原因。这个过程本身很快,几百张图几秒内扫完。
def scan_dataset(dataset_root, valid_classes): img_dir = os.path.join(dataset_root, "JPEGImages") xml_dir = os.path.join(dataset_root, "Annotations") report = [] for xml_path in sorted(Path(xml_dir).glob("*.xml")): stem = xml_path.stem img_path = os.path.join(img_dir, stem + ".jpg") if not os.path.exists(img_path): img_path = os.path.join(img_dir, stem + ".png") if not os.path.exists(img_path): report.append(f"{stem}: image not found") continue if not check_image_readable(img_path): report.append(f"{stem}: image unreadable") tree = ET.parse(xml_path) root = tree.getroot() filename_el = root.find("filename") if filename_el is not None and filename_el.text != os.path.basename(img_path): report.append(f"{stem}: filename mismatch") report.extend(validate_boxes(xml_path)) # class check for obj in root.findall("object"): name = obj.find("name").text if name not in valid_classes: report.append(f"{stem}: unknown class {name}") return report这个脚本不需要常驻,训练前跑一遍,输出为空就可以放心开训练。长期维护数据集时,我一般把它挂在自动化流程里,每次新增图片、新增标注后自动触发一次。
6. 实际使用中的坑与扩展思路
工具能跑通是一回事,用得顺手是另一回事。最后说说我在实际使用中踩过的坑,以及值得继续扩展的方向。
6.1 我实测踩过的几个很实在的坑
第一个坑是坐标用了画布显示尺寸而不是原图尺寸。GUI 为了看图方便会把大图缩小到窗口内,我第一次实现时直接把 Canvas 上显示的坐标存进了 XML。结果训练时检测框全部偏到图片左上角,因为显示缩小的比例和坐标不成比例。解决方法是尽量用滚动而不是缩放来浏览大图,必须缩放时用一个全局 scale 系数换算。
第二个坑是中文图片名。本地调试时一切正常,换个环境跑训练就报编码错误,或者某些库对中文路径的支持时好时坏。花了一下午把整个数据集统一改成六位数字文件名后,这类问题彻底消失。从那以后我定了一个规矩:所有添加到数据集的文件,先改名再入库。
第三个坑是 ElementTree 写出来的 XML 没有缩进,一个 object 串在一行里。刚开始我以为是 bug,后来确认它根本不影响解析,各种框架读 XML 用的是元素树而不是文本缩进。想格式化的话,可以用 minidom 再处理一遍,但对训练来说没意义,不建议为这个浪费时间。
第四个坑是标注时手一抖把框拖到图片外。如果只在画布区域内做判断,拖出边缘就会生成负坐标或者超过图片宽高的坐标。我在 on_release 里做了坐标裁剪,把 xmin、ymin 强制限定不小于 0,把 xmax、ymax 限定不大于图片宽高,这一条直接消灭了校验报告里的一大批越界条目。
6.2 工具还可以往这些方向扩展
当前这个版本只支持矩形框,做普通目标检测足够了,但如果你想训练分割模型,VOC 格式其实额外支持多边形标注。XML 里 object 节点下可以加 segmentation 节点,里面是 poly 点坐标序列。扩展的思路不复杂,把 GUI 里的拖框逻辑改成点击生成多边形,save_xml 里增加对应节点即可。
另一个很实用的扩展是自动划分数据集。目前我用手工把图片名写进 train.txt、val.txt,图片多了就麻烦。可以在校验脚本后加一段随机划分逻辑,按比例生成训练、验证、测试清单,并保证同一张图片不会同时出现在两个列表里。
还可以把工具接上一个“标注效果预览”界面,把标注框画回原图让用户快速目检。这个对发现“框偏了半个车身”这类问题特别有效,毕竟 XML 数值合法性检查只能确认数字没越界,确认不了语义。
最后说一点个人体会。这类工具本质是服务“数据闭环”的,不要一开始就追求功能齐全,能稳定完成标注、转换、校验三大动作,就已经解决了 80% 的实际问题。我现在持续维护这套代码,后续还打算加一个漏检可视化对比功能,把模型预测框和真实标注框画在同一张图上,找“标注漏了”和“模型没学会”的原因都会直观很多。整体来说,这是一套值得长期打磨的资产。