做目标检测的,早晚都会撞上这么一堵墙:模型结构和训练代码都准备好了,结果手里的标注数据格式对不上。别人交付的是VOC格式的xml,你的训练脚本只认YOLO格式的txt;从开源项目里扒下来的是COCO格式的json,你的标注工具导出的却是另一套目录结构。很多时候项目进度卡住,不是因为模型调参调不明白,而是卡在检测数据集的格式互转这个不起眼但又极其磨人的环节。
这篇文章把我这些年做检测数据集的全流程——从数据收集、清洗、标注,到VOC、COCO、YOLO三种格式的相互转换——完整梳理一遍。不管你是刚接触检测的小白,还是被格式转换折磨过好几轮的老人,看完应该都能把这条链路走通。适不适合你?只要你的工作里出现过"标注完发现格式不对""下载的数据集跟模型不匹配"这类问题,这篇文章就是写给你看的。
1. 整体流程设计与格式选型思路
1.1 一条流水线解决三类格式
检测数据集的完整链路其实比我下面列的还要琐碎,但核心骨架是固定的:需求定义→数据收集→清洗筛选→标注→格式整理→数据集划分→训练前校验→进模型。很多新手容易犯的错是把"标注"当成最重的一步,实际上格式整理和校验花费的时间一点不比标注少。
我自己的做法是先把整个流程串成一条流水线,每个环节都产出标准化产物。数据进来之后先统一命名规则,图片和标注文件保持同名;标注完成之后先落成一套"主格式",然后通过脚本分发到其他格式。主格式我一般选VOC的xml,因为xml可读性好、单图单文件、用文本编辑器就能检查,出了问题定位也快。需要训练YOLO时再从VOC转txt,需要交给mmdetection这类框架时再转COCO。
这套思路最大的好处是解耦。标注结果只维护一份,其他格式都是生成物,随时可以重新生成。如果你把VOC、COCO、YOLO的标注当成三份独立资产去维护,改个类别名要改三处,加一批数据要同步三套文件,迟早要出事。
1.2 格式选型到底怎么定
很多人问我:我应该把数据存成什么格式?这其实取决于下游训练框架,而不是取决于个人喜好。判断规则很粗暴:
- 用Ultralytics YOLOv5/v8系列训练,就用YOLO的txt,配合data.yaml。
- 用mmdetection、Detectron2、PaddleDetection这些框架,COCO json是通用语言,几乎所有框架都内置了COCO数据集的加载器。
- 用一些早期代码、教学示例或者部分工业部署工具链,VOC xml仍然是兼容性最好的格式。
没有特殊要求的话,我建议主格式存VOC,数据交付和训练分发时再转换。原因前面说了,可读性和排查友好性最强。但如果你整个项目组都深度依赖COCO生态,那也可以把COCO当主格式,YOLO txt当生成物。关键是"一份标注源、多格式分发"这个原则,而不是纠结具体选哪个当源。
2. 数据收集与清洗:决定模型上限的第一环
2.1 数据来源有哪些,怎么选
你需要的数据类型决定了获取方式。通用目标检测可以直接用公开数据集,COCO、PASCAL VOC、BDD100K这些质量高、标注规范,拿来就能训。但像鸟类识别、传送带异物检测、开关闭合检测、电力红外设备检测、火灾监控这类垂直场景,市面上几乎没有像样的公开数据集,绝大多数情况都得自己造。
自采数据我常用的方法有这么几类。第一种是固定相机连续采集,然后抽帧,关键点在于抽帧策略,不能等间隔瞎抽,要结合场景变化率动态抽,画面变化大的多抽,几乎静止的少抽。第二种是手机或手持设备补拍,专门收集角度变化、光照变化的样本,这一条对泛化能力的提升比增加绝对数量还有用。第三种是拖网式网络图片搜集,这类渠道的效率最高,但版权和使用授权一定要先搞清楚,商用项目更要谨慎,我一般只用明确允许的图片来源或者自有拍摄素材。
2.2 清洗、去重与分布控制
收集回来的原始数据十有八九是脏的,直接标注等于把噪声灌进模型。我的清洗流程分四步:先做感知哈希去重,把重复帧、相似帧干掉,不然训练集里同一目标出现几十次,模型对这张图的记忆会异常深刻,反而伤害泛化;再删模糊、过曝、严重遮挡图,这类图即使标注了也是负资产;然后统一图片格式和分辨率,避免格式转换时读不出尺寸;最后看类别分布,如果某个类的数量远低于其他类,就得定向补充。
分布控制是很多人忽略的环节。类别不平衡、场景单一这两个问题,不是靠模型技巧能救回来的。我做过一个开关闭合检测项目,一开始正样本里全是白天顺光的图,模型训练出来一到逆光场景直接崩,后来专门补了一批逆光、阴影、不同角度的图,效果立刻上来了。所以收集阶段就要把场景多样性当成硬指标,不同光照、不同季节、不同设备型号都要覆盖,宁可某类少一点,也不能全是同一个样子的图。
3. 标注工具与标注规范:细节决定成败
3.1 标注工具选型
标注工具我前后用过不少,说几个最常用的:
- labelImg:老牌工具,轻量、免配置、原生支持VOC和YOLO格式导出,适合个人小项目和快速标注。缺点是年代久远,交互体验一般,多人协作基本靠手动汇总。
- labelme:适合需要多边形标注的场景,标注结果也是json,但它默认格式跟VOC、COCO、YOLO都不直接兼容,后面转换成本会高一点。
- CVAT:网页版,适合团队协作,支持任务分配、多人并行、自动标注辅助,导出格式覆盖VOC、COCO、YOLO,正规项目我推荐直接上这个。
- X-AnyLabeling:集成了SAM等分割模型的辅助标注工具,对复杂场景和密集小目标特别友好,能省不少时间。
工具选型的一个实际经验:标注效率的瓶颈往往在交互,而不在自动化。如果你是小项目,数量在几千张以内,用labelImg完全够;如果是批量做数据集,直接上CVAT这类有任务管理和协作能力的平台。别一上来就追求自动化标注,自动标注出来的框质量参差不齐,你依然要逐张过一遍,省的时间有限,引入的错误却不少。
3.2 标注规范与质检流程
标注规范一定在开工前定死,不然返工成本巨大。我通常会在开工前写一个非常具体的标注说明书,里面至少包含这几条:
- bbox要贴着目标外轮廓,但不要压到边缘,什么叫"贴"、什么叫"压",用几张示例图说清楚,标注员的主观判断差异惊人。
- 遮挡目标标注完整物体范围,只要能从上下文推断出整体位置就标注完整实体;实在无法推断的,只标注可见部分,并在备注里说明。
- 目标极小(比如小于图片尺寸5%)或模糊到无法确认类别的,要么不标,要么挂到difficult属性里。
- 类别名统一用小写英文单词,不要用中文,不要带空格和特殊字符,这是格式转换时踩坑最多的地方。
质检环节至少要过两遍。第一遍由标注员自查,第二遍由另一个人抽检,抽检比例我一般做到30%以上。抽检时重点看bbox是否贴边、类别是否错标、漏标多不多。用脚本统计每个类别的数量、每张图的平均框数、框的面积分布,任何异常都能快速暴露出来。我自己习惯把统计结果画成直方图,一眼就能看出某个类是不是样本太少、某些框是不是面积小到离谱。
4. 三大格式深度拆解:VOC、COCO、YOLO
4.1 VOC格式:XML里藏着什么
VOC格式是PASCAL VOC数据集定的标准,特点是一张图对应一个xml文件,文件名和图片同名,目录结构与图片目录镜像。
<annotation> <folder>train</folder> <filename>000001.jpg</filename> <path>E:/dataset/train/000001.jpg</path> <source> <database>Unknown</database> </source> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>cat</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>100</xmin> <ymin>150</ymin> <xmax>300</xmax> <ymax>400</ymax> </bndbox> </object> </annotation>重点看两个部分。一个是size节点里的width和height,这是后面所有格式转换的"标尺";另一个是object节点里的bndbox,四个值分别是左上角x、左上角y、右下角x、右下角y,单位是像素。difficult标记难例样本,转换时经常要决定保留还是剔除,不同训练框架对difficult的处理逻辑不一样,这点后面会细说。
4.2 COCO格式:JSON的三大核心字段
COCO格式把整个数据集的标注塞进一个json文件,核心字段只有三个:images、annotations、categories。
{ "images": [ { "id": 1, "file_name": "000001.jpg", "width": 1920, "height": 1080 } ], "annotations": [ { "id": 1, "image_id": 1, "category_id": 1, "bbox": [100, 150, 200, 250], "area": 50000, "iscrowd": 0 } ], "categories": [ {"id": 1, "name": "cat", "supercategory": "animal"} ] }COCO的bbox和VOC不一样,它给的是[x, y, width, height],也就是左上角坐标加宽高,单位仍然是像素。area是框面积,严格来说应该是分割掩码的面积,但对纯目标检测任务来说,直接用width * height也没问题。category_id从1开始,并且id的顺序不一定是训练时想要的类别顺序,转换YOLO格式时必须自己维护一个映射表,直接把category_id当YOLO的class_id用,是最经典的错误之一。
4.3 YOLO格式:一行txt的坐标哲学
YOLO格式是目前训练效率最高的格式,一张图对应一个txt文件,每一行代表一个目标:
0 0.208333 0.254630 0.104167 0.231481 1 0.675000 0.802083 0.150000 0.277778五个数字分别是:类别id、中心点x坐标、中心点y坐标、目标宽度、目标高度。后四个值全部是归一化到0到1之间的相对值,也就是用实际像素值除以图片宽高得到的。类别id从0开始,这是和VOC、COCO最大的区别。
很多新手直接拿像素坐标写进txt,训练时loss飞上天。要记住,YOLO txt里的一切坐标都是相对图片尺寸的。另外,txt文件本身不记录图片尺寸信息,所以转换时你必须从VOC xml的size节点、COCO json的image字段,或者直接从原图读取宽高,这一步错了,后面全错。
4.4 三种格式关键差异对照
| 维度 | VOC | COCO | YOLO |
|---|---|---|---|
| 文件形式 | 每张图一个xml | 每个数据集一个json | 每张图一个txt |
| 坐标表示 | xmin, ymin, xmax, ymax(像素) | x, y, width, height(像素) | 中心点x、y、宽、高(归一化) |
| 类别id起点 | xml内无id,靠外部映射 | 从1开始 | 从0开始 |
| 是否记录图片尺寸 | size节点,每图自带 | images字段,每图自带 | 不记录,需自行读取 |
| 可读性 | 最好,记事本能看 | 中等,字段规整但结构嵌套 | 最差,只有一排数字 |
这张表建议收藏。格式互转的每一步本质上都是在这些坐标表示和类别id规则之间做翻译,把表里的差异吃透了,转换脚本就是纯粹的数学题。
5. 格式互转的完整代码方案
5.1 写转换脚本前先想清楚的事
动手写代码之前,有三件事必须定下来,不然转换出来的标注就是废的。
第一,图片尺寸从哪里来。VOC和COCO格式本身带了图片宽高,YOLO格式没有,所以凡是从YOLO往其他格式转,必须提前拿到原图尺寸,我都是用PIL或OpenCV直接读图获取,而不是依赖某个"统一的resize尺寸"。
第二,类别映射表怎么建。VOC的xml里没有class_id,COCO的json里有category_id但起点是1,YOLO的class_id起点是0。转换时必须有一个类别名列表,按你训练时想要的顺序排好,再生成映射关系。比如你要训练三类cat、dog、person,那映射表就是{cat:0, dog:1, person:2},任何一步跳过映射直接用原始id,都会错位。
第三,转换后的目录结构。YOLO训练一般要求图片在images/train、标注在labels/train;VOC一般是JPEGImages和Annotations;COCO就是一个json文件。转换脚本最后要自动生成对应目录结构,并输出一份转换日志,记录哪些文件成功、哪些文件失败。
5.2 VOC与YOLO互转代码
VOC转YOLO的核心公式很简单,把(xmin, ymin, xmax, ymax)换算成(中心点, 宽度, 高度)再归一化:
import os import xml.etree.ElementTree as ET from PIL import Image class_map = {"cat": 0, "dog": 1, "person": 2} def voc2yolo_one(xml_path, class_map): tree = ET.parse(xml_path) root = tree.getroot() img_path = root.find("filename").text img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in class_map: continue difficult = int(obj.find("difficult").text) if difficult == 1: continue b = obj.find("bndbox") xmin = float(b.find("xmin").text) ymin = float(b.find("ymin").text) xmax = float(b.find("xmax").text) ymax = float(b.find("ymax").text) x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{class_map[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") return img_w, img_h, lines反向转换YOLO转VOC就是把归一化坐标还原成像素的四个角点。这里有个细节:还原出来的xmin、ymin要做clip,确保不会因为浮点误差出现负数或者超出图片边界。同时xmin和xmax要注意保持xmin < xmax的约束,如果原标注异常,脚本应该直接报错并记录文件名,而不是默默生成一个坏掉的xml。
5.3 COCO与YOLO互转代码
COCO转YOLO的关键点在于类别映射。categories里的id可能不是连续的,也可能是按名称排序后的顺序,转换时必须按categories在json里出现的顺序或按名称排序后的顺序重新映射到0开始:
import json import os def coco2yolo(coco_json, output_dir): with open(coco_json, encoding="utf-8") as f: data = json.load(f) cats = sorted(data["categories"], key=lambda c: c["name"]) cat_id_map = {c["id"]: idx for idx, c in enumerate(cats)} img_dict = {img["id"]: img for img in data["images"]} anns_by_img = {} for ann in data["annotations"]: if not ann.get("bbox"): continue anns_by_img.setdefault(ann["image_id"], []).append(ann) os.makedirs(output_dir, exist_ok=True) for img_id, anns in anns_by_img.items(): img = img_dict[img_id] img_w = img["width"] img_h = img["height"] stem = os.path.splitext(img["file_name"])[0] lines = [] for ann in anns: if ann.get("iscrowd", 0): continue x, y, w, h = ann["bbox"] if w <= 0 or h <= 0: continue x_center = (x + w / 2) / img_w y_center = (y + h / 2) / img_h lines.append(f"{cat_id_map[ann['category_id']]} {x_center:.6f} {y_center:.6f} {w / img_w:.6f} {h / img_h:.6f}") with open(os.path.join(output_dir, stem + ".txt"), "w") as f: f.write("\n".join(lines)) return cat_id_map看到sorted(data["categories"], key=lambda c: c["name"])这一行没有,这就是我反复强调的映射陷阱。如果不做这一步,直接把category_id当class_id用,COCO里id=3的类别到YOLO里可能排在第一位,训练出来的模型预测类别全是错乱的,而且这种错乱在损失函数上很难看出来,最后只能通过可视化推理结果发现,排查成本极高。
5.4 转换后的校验脚本
转换完成不等于转换正确,我强烈建议任何格式转换之后都跑一遍校验脚本,随机抽样画框可视化。这个习惯帮我挡掉了无数次隐蔽错误。
校验脚本的核心逻辑分三层。第一层检查数值合法性:txt里的每个值是否在0到1之间、宽度和高度是否大于0、每行是否正好5个数。第二层检查字段一致性:每张图片是否都有对应标注文件(允许空文件但必须存在)、标注数量与源标注数量是否对得上。第三层可视化:随机抽20张图,把标注框画在原图上另存,人眼扫一遍,重点看框的位置是否贴目标、是否出现整体偏移、是否有一堆本来不该出现的框。不要小看这一步,格式转换脚本的bug基本都是靠可视化抓出来的,光看数值和行数,很多错误是发现不了的。
6. 常见问题与排错实录
6.1 高频问题速查表
| 现象 | 直接原因 | 解决方案 |
|---|---|---|
| 训练loss正常但预测框整体偏移 | 转换时用了错误的图片尺寸,或归一化除以了训练时的resize尺寸 | 统一从原图或源标注的size字段读取宽高 |
| 类别预测结果乱七八糟 | COCO的category_id没有重新映射成0开始 | 按类别名称排序或按原顺序重新编号 |
| 转换后框数变少 | difficult=1、iscrowd=1被过滤,或类别不在映射表 | 明确过滤策略,生成日志检查被排除的标注 |
| txt里出现负数或大于1的值 | 源标注越界、xmin大于xmax、目标本身超出图片边界 | 转换时做clip并告警,修改源标注 |
| 训练时报找不到标注文件 | txt文件名与图片名不一致、大小写不一致 | 转换脚本统一用os.path.splitext生成同名文件 |
| 中文路径或中文文件名导致解析失败 | 部分解析库对非ASCII路径支持不好 | 数据集文件统一重命名为英文 |
6.2 实测翻车现场与排查思路
讲两个我实际踩过的坑。第一个是图片尺寸来源不一致导致的整体偏移。当时从一个电力红外数据集转YOLO格式,VOC xml里的size字段是1920x1080,但实际图片因为管线问题被压缩成了1280x720,转换脚本用的是一半的旧分辨率,结果出来的框全部偏到右下角。这种错误数值上完全合法,每行都在0到1之间,框也都是正数,如果不是抽查可视化,根本发现不了。从那以后,我的转换脚本一律强制从原图读取宽高,而不是信任标注文件里的size字段。
第二个坑是类别映射的隐性错位。同事转一份COCO数据集时,直接用json里categories的id作为YOLO的class_id,没有先做从1到0的重新映射。结果就是id=3的"bus"变成了class_id=3,而训练配置里class_id=3对应的是"truck",整个模型训练完,预测结果里bus和truck完全互换了。这种错位最坑的地方在于:训练指标非常正常,准确率、召回率都漂亮,直到你拿真实图片去测试才发现全错。排查思路很简单,核验一遍转换后的txt里各类别数量和源json里各类别数量是否一一对应,多看一眼映射表,能省一整天的排查时间。
最后,再分享几句掏心窝的经验
我自己现在的习惯是:任何数据进来,先转成一套通用标准格式存档,然后统一用脚本来分发,绝不手工改标注。每次转换完,必跑一遍数值校验加可视化抽查,跑完再把图片和标注一起打压缩包,给压缩包算个校验值,标注文件的版本管理就是这么做的。听起来繁琐,但做数据集的都知道,模型效果的下限在数据质量,上限才在模型结构。把标注格式这条链路理顺,你的训练流程能少踩一半的坑。