做目标检测,避不开一个极不浪漫的问题:数据标注文件的格式转换。不管你最初是在哪家标注平台上点出来的框,也不管你是准备喂给哪套训练流程,最终都会撞到 xml、json、txt 这三座大山身上。很多新手一开始会觉得,这不就是改个后缀名的事吗?真上手做一次就知道,后缀只是表面,三种格式不光组织方式天差地别,坐标体系都不一样,直接改后缀只会得到一堆训练时飘红的数据。
这篇文章不聊理论,就把我这些年反复被标注文件折磨出来的经验写透:三种格式到底各自藏着哪些细节,为什么要动手写转换脚本而不是贪图省事,转换过程中最容易让数据悄悄变质的坑在哪里,以及一套我现在还在用的、可复用的转换代码方案。适合所有正在被标注文件格式折磨,尤其是数据集一旦上千张图、几十个类别就开始头疼的开发者参考。
1. 三种格式差别有多大,先看核心结构
1.1 VOC XML:标注界的老员工,信息全但啰嗦
XML 格式来自早期视觉识别竞赛的标注标准,特点是每一个标注对象都给你包了一层完整的描述,一张图一个 XML 文件。我看过太多人第一次打开这种文件时被吓到,因为结构嵌套太多,比如找一张图里第二个目标框的坐标,得先找到<object>,再进<bndbox>,再取<xmin>之类的值。但也是这个原因,XML 的信息密度非常高:文件名、图片路径、图片宽高、每个目标的类别名、框的坐标、甚至被截断情况和难易程度都能存下来。
写转换脚本时最常被坑的一点是,部分标注工具生成的 XML 并不包含<size>节点。你可别笑,真遇到过好几个数据集,框坐标都是有的,但图片宽高信息丢失了。这种情况如果你做 txt 转换,就根本没有办法归一化坐标,因为归一化就得拿像素框值去除以图片宽高。所以我也养成了一个习惯,读到 XML 后先去校验<size>存在与否,如果缺失就得自己调用图像库去读真实尺寸,如果连图像文件都不在,那这个样本只能报错跳过。
另外 XML 里坐标是xmin、ymin、xmax、ymax这种整框对角表达,这是很多格式转换里最容易搞混的源头之一。
1.2 COCO JSON:顶梁柱式的大一统格式,但细节也最多
JSON 格式常见的是 COCO 风格标注,一个大 JSON 文件包含images、annotations、categories三个核心数组,整体关系用 id 来连接。图片有image_id,类别有category_id,标注条目里面用image_id和category_id把三者串起来。这种方式的好处是规模化,几万张图的标注放在一个文件里,内存友好度尚可,读取也简单;坏处是关系靠 id 关联,一旦 id 对不上,尤其是类别顺序跟你自己训练的class_names列表顺序不一致,转换出来的 txt 就全部错位。
COCO 的 bbox 字段是[x, y, width, height],注意,这跟上面 XML 的xmin/ymin/xmax/ymax又不一样,转换时必须换算。我早期写转换脚本时图省事,以为字段名差不多就直接取值,结果训练完模型一评估,框全偏到图像左下角去了,排查了半天才发现是没从 xywh 转到 xyxy。这个教训直接印在我脑子里了。
还有一类常见情况是 COCO 标注里有segmentation字段,也就是多边形分割点。如果工具只标注了轮廓没有给 bbox,某些数据集里 bbox 甚至可能是空的。在这种场景下一味读bbox就会得到一堆零值框,你需要借助segmentation中多边形的最小外接矩形来推导框坐标。
1.3 YOLO TXT:部署现场的极简版,每一行一个目标
TXT 格式是训练阶段最常用到的,也是三种格式里最“薄”的一种:一个 txt 对应一张图,每一行对应一个目标,格式就是类别id 中心点x 中心点y 宽度 高度,坐标全部是相对于图片尺寸归一化之后的浮点数,范围在 0 到 1 之间。
因为它在底部轻量又读取快,很多开源训练流程默认接的就是这种格式。但它也是最考验转换正确性的一个格式,因为所有信息被压成一行数字,丢失上下文,一旦转错,没有任何冗余信息帮你差错。坐标用相对值,意味着在模型训练时对图片输入尺寸要求没那么死板,这是一种部署方便、训练调整方便的做法。
但也是因为它简洁,若干细节就格外重要。比如有人直接把绝对坐标写进去,没有归一化,训练时 loss 直接爆炸是常态。还有人把每行写成xmin ymin xmax ymax四个绝对坐标,或者把类别写成中文标签而不是数字 id,这些都是转换过程中高频出现的错误模式。TXT 格式本身几乎不做任何防御性检查,你得在转换期和转换后都自行校验。
1.4 三兄弟的差异速查表
| 维度 | VOC XML | COCO JSON | YOLO TXT |
|---|---|---|---|
| 文件组织 | 每张图一个 XML | 整个数据集一个 JSON | 每张图一个 TXT |
| 坐标表达 | xmin/ymin/xmax/ymax 绝对像素 | [x, y, w, h] 绝对像素 | 中心坐标 + 宽高,归一化相对值 |
| 类别表达 | 字符串名 | 数字 id,另搭配 categories 表 | 数字 id |
| 附加信息 | 尺寸、难例、截断等 | 分割多边形、关键点等 | 几乎没有 |
| 读取成本 | 中,需要逐层解析 | 低,直接 JSON 结构 | 极低,逐行读即可 |
| 训练接入 | 要转为训练流程需要解析 | 可使用对应内置流程 | 很多训练流程直接原生支持 |
这张表也解释了为什么很多项目组长期保留一套内部转换工具,因为三种格式应用在不同阶段:标注阶段生成 XML 或 JSON,训练阶段需要 TXT,模型服务阶段需要 JSON 做结果包装。任何一环断掉,数据流就断了。
2. 动手前先算账:自己写脚本还是白嫖现成工具
很多人的第一反应是找个工具一键转换。网上确实有一些标注工具自带多格式支持的插件,或者某平台在线提供转换功能,甚至某些训练框架内置了一套转换逻辑。但我的经验是,这些“一键转换”看着好用,真到项目里面,你大概率还是要回头自己写。
原因在于现成转换工具最爱给你假设环境。它假设你的 XML 带有标准的size节点,假设图片文件都躺在同目录下,假设类别清单是连续的从 0 开始的编号,假设所有框都完全在图片边界内部。但只要数据量一大,这些假设一定会被打破。我遇到过某个项目的数据是从不同来源合并出来的,一部分 XML 结构里把<size>写成了<imagesize>,另一部分把<bndbox>里坐标值用字符串和浮点混着写,这种时候现成工具直接要么报错要么静默地跳过那些样本,等你训练一半了才发现某个类别的样本悄悄少了一半。
自己写脚本的另一个理由是你可以完全控制类别 id 的映射。训练框架里类别顺序通常由你指定的 yaml 或一个 class list 决定,不可能所有转换工具都猜到你的排序。用现成工具转完,如果它的 id 排序跟你训练配置里的排序差一位,那就是整批数据错位,而且这种错位在训练时不一定发散到不可救药,而是在性能上永远差一截,极其难查。
当然我不是说你永远不该用现成工具,而是用完一定要做一次抽样校验。工具只负责把你省下的时间还给你,校验工作永远得自己做。
2.1 现成工具的适用场景
大致上,以下几类情况用现成工具是划算的:数据集只有几百张、类别不超过十个、格式来源单一、属性信息不敏感、不存在畸形的标注项。这种场景下打开某开源标注软件的导出界面,选目标格式,点输出,体验极佳。
但即便是这种最简单的情况,你也要做一次抽查。具体做法是转换后随机抽十张图,把 txt 读出来的归一化坐标反算回像素级任意提取框,画在图上人工比对一下框是否贴合目标。这一步十分钟都不要,但能拦住大多数工具层面的低级错误。
2.2 手搓脚本的优势
自己写脚本还能顺带处理批量性问题。比如某个项目里我负责把一批旧数据从 XML 转到 YOLO TXT,同时还要求做一次数据集切分:80% 训练、20% 验证。用现成工具你得先转格式再切分,或者切分完文件对不齐。自己写脚本,就可以在解析 XML 的同时顺手根据文件名哈希把样本分到不同输出目录,一步到位。再比如你想统计一下每个类别的框数量,现成工具不会给你这个功能,但解析过程中顺手记一下就有了。
另外还有一个被严重低估的点:手写脚本意味着你对数据完成了一次体检。你能在转换时发现哪些样本类别缺失、哪些框坐标越界、哪些图片分辨率信息缺失。这些质量信号会直接告诉你这份数据到底能不能用来训练,而不是等到模型跑完一轮之后才通过损失函数发现问题。
3. 把三个转换写成一套 Python 工具箱
下面这套代码是我反复使用后沉淀下来的方案,核心思路很简单:先定一个统一的内存数据模型,把三种格式都“倒进”这个模型,然后再输出成任意目标格式。这样做比写六个互相调用的转换函数要清爽太多,因为你只需要维护两次映射关系:源格式到模型,模型到目标格式。
3.1 统一标注数据模型
我用的统一模型是一个字典列表,每个目标框对应一个字典,字段统一成:
sample = { "image_id": 0, "file_name": "img_0001.jpg", "width": 1280, "height": 720, "category_id": 2, "category_name": "cat", "bbox": [100, 200, 300, 400] # 统一:xmin, ymin, xmax, ymax }注意,我在模型层把框统一成xmin/ymin/xmax/ymax绝对像素形式,因为这样便于理解和验证。到输出 YOLO TXT 时再归一化,输出 COCO 时再切成x,y,w,h。这样所有格式的差异都被隔离在了解析和输出函数里,核心转换逻辑不用反复变。
代码设计里有一个比较关键的点:category_id和category_name同时保留。因为不同格式里类别信息的表现形态不一样,XML 模式通常只有名字,COCO 模式只有数字 id,到了 YOLO 模式又必须用连续的 id。在模型层将两者都存储,转换时就能做灵活映射。
我还用了一个列表class_names作为中间基准,它规定了最终 YOLO 的逻辑顺序。所有源格式里的类别,都先通过名称映射到这个列表的索引。这么做的好处是,无论来源是哪个工具,只要你把类别清单维护好,id 错位的问题就永远不会出现。
3.2 XML 转 YOLO TXT:最基础的一环
XML 转 YOLO 是整个体系里最好写的一环,解析好对象列表,再做归一化输出。注意我前面说的,先检查<size>节点,如果缺失就改用图片真实宽高。
import xml.etree.ElementTree as ET import os def xml_to_txt(xml_path, class_names, out_txt): tree = ET.parse(xml_path) root = tree.getroot() size_node = root.find("size") if size_node is None or size_node.find("width") is None: # 如果 XML 里没有宽高信息,这里需要调用图像库读取,否则直接报错 raise ValueError(f"缺少图片尺寸信息: {xml_path}") width = int(float(size_node.find("width").text)) height = int(float(size_node.find("height").text)) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in class_names: raise ValueError(f"未知类别 {name}") cls_id = class_names.index(name) box = obj.find("bndbox") xmin = float(box.find("xmin").text.strip()) ymin = float(box.find("ymin").text.strip()) xmax = float(box.find("xmax").text.strip()) ymax = float(box.find("ymax").text.strip()) # 做一次坐标次序校验,防止标注工具生成翻转框 if xmax <= xmin or ymax <= ymin: continue cx = (xmin + xmax) / 2.0 / width cy = (ymin + ymax) / 2.0 / height bw = (xmax - xmin) / width bh = (ymax - ymin) / height lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n") with open(out_txt, "w", encoding="utf-8") as f: f.writelines(lines)这段代码里的几个细节值得单独说:
一是浮点坐标不必输出过度超精度,6位小数已经足够,因为归一化坐标范围就是 0~1,小数点后六位的精度对于目标检测任务绰绰有余。二是异常处理不要只是 print 一行就飘过,而是 raise 出来。否则遇到不匹配类别这种错误,脚本会一路狂奔全部转完,最后发现在中间某个样本上就歪了,还得从头找错。
三是归一化之前其实可以顺手做一次坐标合法性检查。很多标注工具会允许坐标系超出图像边缘,比如框首部超出左侧变成负数。这类框直接转为归一化坐标后,会出现负数或大于 1 的数值,虽然训练框架一般能忍受,但最好在转换期就统一做框裁剪或过滤处理,避免后续模型评估时出怪问题。
3.3 COCO JSON 转 YOLO TXT:绕开 id 陷阱
COCO 到 YOLO 的转换比较烦人,不是解析难,而是 id 关系太容易错。COCO 数据集的category_id往往是从 1 开始的,并不等于你训练要求的从 0 开始的逻辑编号。所以必须在解析过程中,通过 categories 数组建立起category_name -> category_id的映射,再把 name 映射到class_names索引。
另一个坑是 bbox 字段的含义是[x, y, width, height],其中 x、y 是左上角坐标。
import json import os def coco_to_txt(json_path, out_dir, class_names, category_map=None): with open(json_path, encoding="utf-8") as f: coco = json.load(f) images = {img["id"]: img for img in coco["images"]} # 构建 category_id 到逻辑类的映射 coco_categories = {cat["id"]: cat["name"] for cat in coco["categories"]} anns_by_image = {} for ann in coco["annotations"]: img_id = ann["image_id"] anns_by_image.setdefault(img_id, []).append(ann) for img_id, ann_list in anns_by_image.items(): img = images[img_id] width = img["width"] height = img["height"] file_name = img["file_name"] base = os.path.splitext(file_name)[0] lines = [] for ann in ann_list: cat_id = ann["category_id"] cat_name = coco_categories.get(cat_id, "") if cat_name not in class_names: continue # 优先使用 bbox,但有些分割标注没有 bbox 字段,需要从 segmentation 推导 if "bbox" in ann and all(abs(v) > 1e-9 for v in ann["bbox"]): x, y, bw_box, bh_box = ann["bbox"] else: seg = ann.get("segmentation", []) if not seg: continue xs, ys = [], [] for poly in seg: xs.extend(poly[0::2]) ys.extend(poly[1::2]) x = min(xs) y = min(ys) bw_box = max(xs) - x bh_box = max(ys) - y cls_id = class_names.index(cat_name) cx = (x + bw_box / 2.0) / width cy = (y + bh_box / 2.0) / height norm_w = bw_box / width norm_h = bh_box / height lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {norm_w:.6f} {norm_h:.6f}\n") with open(os.path.join(out_dir, base + ".txt"), "w", encoding="utf-8") as f: f.writelines(lines)说起来,这段代码里最值得记住的是对 bbox 缺失的处理。COCO 格式本身以支持多边形分割著称,很多自动化力量生成的数据,segmentation是满的,bbox却是空数组或零值。如果你只写死读 bbox,这种数据在你手里就是全部丢框的下场。从多边形点序列里取最小外接矩形,就是一个非常实用的小算法:把所有 x 点取最小值和最大值,所有 y 点取最小值和最大值,组合出四个坐标,再算宽高。
还要提醒一点,ann["bbox"]里的宽高理论上都应该是正数,但如果源数据质量差,很可能出现 0,也就是这条标注根本没有面积。这种样本建议直接跳过,保留下来只会给训练过程贡献一堆空框,影响模型对背景的判别。
3.4 YOLO TXT 转 XML:反向还原的注意事项
很多人以为反向转换就是把公式倒过来,实际上有额外的麻烦。YOLO TXT 里只有归一化的中心点坐标和宽高,要还原绝对像素坐标,你必须知道图片真实尺寸。如果图片文件不在身边,那就真的没法精确恢复。
from PIL import Image import xml.etree.ElementTree as ET import os def txt_to_xml(txt_path, img_path, class_names, out_xml): im = Image.open(img_path) width, height = im.size annotation = ET.Element("annotation") ET.SubElement(annotation, "folder").text = "images" file_name = os.path.basename(img_path) ET.SubElement(annotation, "filename").text = file_name size_elem = ET.SubElement(annotation, "size") ET.SubElement(size_elem, "width").text = str(width) ET.SubElement(size_elem, "height").text = str(height) ET.SubElement(size_elem, "depth").text = "3" with open(txt_path, encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls_id, cx, cy, bw_box, bh_box = ( int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) ) # 从归一化还原绝对像素 xmin = (cx - bw_box / 2.0) * width ymin = (cy - bh_box / 2.0) * height xmax = (cx + bw_box / 2.0) * width ymax = (cy + bh_box / 2.0) * height xmin = max(0, min(width, int(xmin))) ymin = max(0, min(height, int(ymin))) xmax = max(0, min(width, int(xmax))) ymax = max(0, min(height, int(ymax))) if xmax <= xmin or ymax <= ymin: continue obj_elem = ET.SubElement(annotation, "object") ET.SubElement(obj_elem, "name").text = class_names[cls_id] bndbox = ET.SubElement(obj_elem, "bndbox") ET.SubElement(bndbox, "xmin").text = str(xmin) ET.SubElement(bndbox, "ymin").text = str(ymin) ET.SubElement(bndbox, "xmax").text = str(xmax) ET.SubElement(bndbox, "ymax").text = str(ymax) tree = ET.ElementTree(annotation) tree.write(out_xml, encoding="utf-8", xml_declaration=True)这段代码里有两个值得学习的细节:一是边界裁剪,因为 YOLO 训练时一般会容忍框越界,但 VOC 标注和后面的数据可视化通常要求像素坐标在图像内,加一层 clamp 能避免很多后续问题;二是反向转换时可能出现浮点取整偏差,还原出的xmax可能会比原标注少一两个像素,这是不可避免的,因为 txt 只保存了六位小数,损失无法追回。
3.5 构建批量批处理脚本
有了单个文件的转换函数,批量转换就简单了,只需要遍历所有文件,然后调用上面的函数。但批量版本里我建议加一个进度控制和错误收集机制,先让脚本跑完所有文件,最后再汇总失败样本,而不是遇到一个坏文件就中断整个流程。因为像数据这种噪声很高的东西,你看到三五个失败样本也许能接受,但如果你每次失败都要停下来,处理几千张图的时候会非常折磨。
import glob import os import traceback def batch_convert_xml_to_txt(xml_dir, out_dir, class_names, failed_list): os.makedirs(out_dir, exist_ok=True) for xml_path in glob.glob(os.path.join(xml_dir, "*.xml")): base = os.path.splitext(os.path.basename(xml_path))[0] out_txt = os.path.join(out_dir, base + ".txt") try: xml_to_txt(xml_path, class_names, out_txt) except Exception: failed_list.append(xml_path) return failed_list这种失败收集的写法还可以升级成输出一份 JSON 报告,里面把失败原因分类统计,比如哪些文件是尺寸缺失,哪些文件是类别未定义,哪些是框翻转比例,方便回过头去修正源数据。这样你每转换一个数据集,就得到一份数据质量报告,这是现成工具给不了你的。而这份报告本身就是后续数据清洗的依据。
4. 真实项目里踩过的坑与数据校验清单
转换代码写得再顺,真正让数据集变质的往往是一些很不起眼的细节。我总结一下这几个高频问题,每条我都实际遇到过,而且排查起来都不是一眼能看见的那种。
4.1 边界坐标的精度陷阱
有一次我转一个遥感影像的项目,XML 转 YOLO 后训出来的模型 mAP 结构上完全正常,但每个检测框的定位都有几十个像素的固定偏移,导致和真实目标交并比偏低。追根溯源才发现,源 XML 文件里的坐标是字符串里带中文空格,比如" 43",转 float 时虽然能过,但是解析器自动处理了奇怪的符号差异,导致读取值比真实值小了几个像素。这种问题在常规标注数据里不太出现,但你一旦做数据清洗,比如从非标准工具导出,就很容易遭中。
所以我在定校验脚本时,第一条就是抽样反算:转完 YOLO 后,随机抽几个 txt,读回坐标并反归一化,再画在图上,肉眼比对框是否对齐。只要你做过一次这种比对,后面再转换数据就知道哪里容易出错。
4.2 类别 id 排序错位是最大的隐形杀手
这是一个典型事故。某个数据集用的是 COCO 标注,里面的类别顺序是飞机、汽车、船,而训练配置里的class_names是我自己定义的汽车、船、飞机。转换后每个类别的训练样本全都错了一位,模型收敛倒是没出问题,因为数据本身自洽,但推理时输出的类别永远是错的。而且这种错位在损失曲线上根本看不出来,只有最后评估时才发现预测标签和真实标签牛头不对马嘴。
要避免这个问题,有几个习惯值得养成:一是训练配置里class_names的排序一旦确定,就要写成一份 yaml 文件,并在转换脚本里显式读取,不要东一个西一个地手打列表;二是写一个类别分布统计函数,转换完成时输出每个类别的样本数和框数,对照源数据人工核对一遍,只花五分钟就能挡住这种整体错位的灾难。
4.3 非标准 TXT 的伪装
有个朋友拿着别人给的数据找到我,说 frodo 转 YOLO 后训练 loss 实在高得离谱。我看了那个 txt 一眼,马上发现里面存的根本不是归一化坐标,而是绝对的像素坐标,只是碰巧某些像素值也小于 1。这类问题很难靠肉眼发现,因为单行数据它们之间没有可辨识的逻辑。
所以我坚持在转换脚本里加一个范围检测:如果归一化后的目标框中心坐标存在超出[0, 1]区间的记录,要么报错,要么专门打印出来。反过来如果读到 txt 坐标值都大于 1,也要意识到这大概率是没有归一化过的绝对坐标,先把它当异常样本列出来。这种防御性检查虽然多写几行代码,但效果立竿见影。
4.4 我常用的校验清单
无论哪种格式转换,完事后我都按下面这份清单检查一遍,一条条过:
- 总数核对:转换前统计的标注框数量,和转换后所有 txt 文件的行数总和是否一致。
- 图片对应:每张图片是否都有 txt 输出,没有标注的图片是否也应该有空的 txt 占位文件。
- 类别核对:转换后 txt 中出现的类别 id 集合,是否和
class_names完全对应。 - 坐标区间:所有归一化坐标是否都落在
[0, 1]内。允许轻微越界,但如果越界比例超过一定值,就要查源数据了。 - 几何合法性:每个框的宽、高是否都大于 0。
- 文件路径:图片文件名和 txt 文件名是否一一匹配,尤其注意大小写后缀的差别, Windows 和 Linux 下对文件扩展名的处理逻辑不同。
- 抽样可视化:抽三到五张图,把 txt 还原成框画出来,人工看一遍。
这份清单看起来很长,但做成脚本也就是百来行的事情,每次转换完自动执行一次,比手动找问题高效百倍。我做项目时经常把一个数据集转完后先跑校验,校验通过才敢把数据喂给训练流程。
5. 最后补几个实用技巧
写转换脚本这事,做得多了,你会发现真正的难点从来不是某个格式怎么解析,而是整个数据链路里到处都是假设。你的数据格式可能是规范的,但来源五花八门,标注工具升级换代后多多少少会改变字段结构,一个小版本差异就能让你的解析代码全部失效。所以如果你的目标是长期做检测或相关任务,我强烈建议维护一份自己的转换和校验脚本,每次遇到新问题的样本,就把它作为回归测试样例补充进去。这样以后数据再增加,你就不会反复踩同一个坑。
另外还有一个数据库分离的小经验:如果你有训练集和验证集切分的需求,不要等到转成 txt 后再去按格式语言切分,而是在转换阶段就按文件名把样本分到不同目录。因为源格式里图片和标注文件往往在同一个顶层文件夹下,直接做目录级别的划分最干净。你可以根据文件名做哈希取模,或者读一份已有的 split 清单,总之把划分逻辑和转换逻辑放在同一个脚本里,出错的概率会小很多。
最后说回我自己的体会:我见过太多项目卡在数据处理这一步,不是模型调不好,而是标注文件转换后数据质量崩了。花一个下午把转换脚本和校验脚本沉淀好,以后每个数据集都能在十分钟内完成转换和体检,这可能是整个训练流程里性价比最高的一项投资。刚入行的时候我也觉得这类工具网上到处都有,没必要自己造轮子,但如今我会建议每个做检测的开发者,手里都至少有一份属于你自己的转换工具箱,因为你的数据永远比你想象的更复杂。