做目标检测的应该没有哪个没为标注文件头疼过。手头数据是从某个标注平台导出的可能清一色是XML拉到开源数据集一看又是COCO的JSON结构回头训练YOLO系列模型框架指明要TXT格式的归一化坐标。三套格式来回倒腾我用过网上不少现成脚本也踩过不少坑后来干脆自己整理了一套可靠的转换方案今天把这个过程完整写出来希望对正在被格式问题折磨的朋友有帮助。这套方案解决的是目标检测数据预处理中最绕不开的环节在Pascal VOC的XML标注、COCO的JSON标注、YOLO的TXT标注之间实现无损、可控、可复现的相互转换。内容适合刚入门目标检测、被数据格式折磨的新手也适合需要批量处理大规模数据的开发者参考。1. 三种标注格式的底层逻辑1.1 它们分别来自哪里先说清楚三套格式各自的“出身”这决定了它们的字段含义和数据组织方式。XML格式主要对应Pascal VOC数据集的标准标注工具像LabelImg导出默认就是这套结构。它的特点是层次清晰、人类可读性好每个object节点代表一个目标框包含类别名称和左上角、右下角两对绝对像素坐标。JSON格式对应MS COCO数据集的标注标准现代标注工具如Labelme、X-AnyLabeling、CVAT导出时经常用这套。COCO的核心特点是所有信息都放进了一个大的JSON对象里面分images、annotations、categories三个数组通过ID进行关联。坐标在annotation的bbox字段里注意它的格式是[x, y, width, height]也就是左上角加宽高而不是两对角点。TXT格式是YOLO系列框架约定俗成的标注形式每张图片对应一个同名TXT文件每行代表一个目标格式是“类别ID 中心点x 中心点y 宽度 高度”全部归一化到0到1之间。归一化坐标消除了分辨率差异训练时无论图片缩放到什么尺寸标注都能正确映射。1.2 核心差异用一个例子说透假设一张宽100像素、高200像素的图片上有一只猫所在区域从坐标(20, 30)延伸到(60, 100)。VOC的XML会写成这样object namecat/name bndbox xmin20/xmin ymin30/ymin xmax60/xmax ymax100/ymax /bndbox /objectCOCO的JSON里对应的是{id: 1, image_id: 1, category_id: 1, bbox: [20, 30, 40, 70]}这里40是宽度60减2070是高度100减30。YOLO的TXT里是0 0.4 0.325 0.4 0.35计算方式是中心点x等于(20加60)除以2再除以100得到0.4中心点y等于(30加100)除以2再除以200得到0.325宽度是40除以100得到0.4高度是70除以200得到0.35。这三组数字描述的是同一个标注框但表达方式完全不同。转换的本质就是在“对角点坐标”和“中心点加宽高”之间换算同时决定要不要除以图片宽高做归一化。1.3 实际场景中什么时候需要转换最常见的场景有三个。第一开源数据集是COCO格式但你用的是YOLO框架需要把JSON转成TXT。第二标注团队用LabelImg产出XML但你的训练管线只吃COCO格式需要把XML转成JSON。第三数据增强或模型集成时需要统一到一种格式避免维护多套标注体系。我自己遇到最头疼的情况是接手一个历史项目标注文件三种格式混着放训练前光统一格式就花了大半天。从那之后我决定自己维护一套转换脚本把所有格式都清洗规范这件事后来帮我省下了大量重复劳动。2. 转换方案的整体设计与技术选型2.1 为什么不能只写两两转换的脚本XML转JSON、JSON转TXT、TXT转XML看着是六个脚本实际维护起来就是灾难。这些脚本之间大量重复代码不说更难受的是逻辑散落在各个文件中改一个坐标计算逻辑要动六个地方容易改出不一致。我采用的思路是“统一中间表示”。设计一个Python字典结构作为中转站内部统一用绝对坐标的[x1, y1, x2, y2]即左上角右下角表示类别统一用字符串名称。每种输入格式只负责把标注解析成中间表示每种输出格式只负责把中间表示写为目标格式。这样做的好处是在架构上降低了耦合度。新增一种格式只要写两个解析和导出函数老格式完全不受影响。2.2 图片尺寸从哪里获取YOLO的TXT格式和COCO的JSON都有归一化坐标需求而JSON里的images字段本身就包含了width和height读取时可以直接拿到。但XML格式里不存图片尺寸转成YOLO时必须另想办法。最稳妥的方式是读取图片文件本身拿到真实的宽高通过PIL或者OpenCV读取图片的尺寸信息。这样能确保尺寸值一定是对的但代价是速度比较慢处理万张图片集时要等一会儿。如果图片集是固定分辨率我可以先在XML里存一份尺寸信息作为缓存但我不推荐盲目信任XML里的字段因为很多标注工具并不把这个字段写完整。图像尺寸获取要放在转换脚本最核心的位置因为后续所有的归一化计算都要以此为基准一旦这里错了整批标注框都会偏移。2.3 类别管理策略YOLO的TXT格式只存类别ID不存类别名所以类别ID到类别名称的映射关系必须单独维护。我习惯在项目里维护一份classes.txt每个类名占一行行号就是类别ID。也可以是YAML或JSON结构核心是保持“类别名到ID”的映射稳定。转换前先扫描数据集里出现的所有类别名称自动建一个类别列表处理多数据集合并时尤其管用。但注意如果是多个来源的数据合并一定要人工检查类别列表的顺序一致性不能依赖自动生成的顺序否则A数据集的“猫”和B数据集的“猫”可能ID对不上。2.4 目录结构设计建议我的实践是用images、labels、annotations三个目录分别存放图片、YOLO的TXT、XML或JSON标注。转换脚本通过命令行参数指定输入目录、输出目录、输入格式、输出格式灵活切换。dataset/ ├── images/ # 原始图片 ├── annotations/ # 存放XML或JSON └── labels/ # 存放YOLO TXT这种目录结构的好处是格式之间转换时图片目录不需要动只需要在annotations和labels两个目录间同步生成新的标注文件。3. 核心代码实现3.1 公共中间表示定义在设计中间表示时我定义了一个统一的标注对象结构每张图片对应一份字典image_annotation { filename: 000001.jpg, width: 100, height: 200, objects: [ {name: cat, bbox: [20, 30, 60, 100]}, {name: dog, bbox: [70, 20, 95, 80]} ] }所有具体的解析函数都把XML或JSON格式转成这个结构所有导出函数都从这个结构出发生成目标文件。这个数据模型是整个工具链的地基写的时候要考虑清楚字段名后面不要轻易改动。3.2 XML转成统一结构解析XML用Python标准库xml.etree.ElementTree即可不需要额外安装依赖。核心逻辑是遍历每一个object节点把bndbox下的四个坐标值取出来。需要注意XML标签名和大小写要完全对上如果解析不到任何对象多半是标签结构不同。import xml.etree.ElementTree as ET def parse_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() filename root.findtext(filename) size_node root.find(size) width int(size_node.findtext(width)) height int(size_node.findtext(height)) objects [] for obj in root.findall(object): name obj.findtext(name) bndbox obj.find(bndbox) xmin float(bndbox.findtext(xmin)) ymin float(bndbox.findtext(ymin)) xmax float(bndbox.findtext(xmax)) ymax float(bndbox.findtext(ymax)) # 统一的中间表示使用 xyxy 格式 objects.append({ name: name, bbox: [xmin, ymin, xmax, ymax] }) return { filename: filename, width: width, height: height, objects: objects }解析时有个细节坐标值在XML里可能是整数也可能是浮点数读取后用float()而不是int()转换防止某些标注工具导出带小数的坐标时被截断。3.3 JSON转为统一结构COCO格式的JSON解析稍微麻烦一点因为它的数据结构是扁平的images和annotations是两个独立数组要靠id字段关联。所以解析时要先构建image_id到图片信息的映射再把annotations按image_id聚合。import json def parse_coco_json(json_path): with open(json_path, r, encodingutf-8) as f: coco_data json.load(f) # 类别ID到名称映射 cat_id_to_name {} for cat in coco_data[categories]: cat_id_to_name[cat[id]] cat[name] # 图片ID到信息映射 image_id_to_info {} for img in coco_data[images]: image_id_to_info[img[id]] { filename: img[file_name], width: img[width], height: img[height] } # 聚合标注对象 anns_by_image {} for ann in coco_data[annotations]: image_id ann[image_id] if image_id not in anns_by_image: anns_by_image[image_id] [] x, y, w, h ann[bbox] name cat_id_to_name.get(ann[category_id], unknown) anns_by_image[image_id].append({ name: name, bbox: [x, y, x w, y h] }) result [] for image_id, info in image_id_to_info.items(): result.append({ filename: info[filename], width: info[width], height: info[height], objects: anns_by_image.get(image_id, []) }) return result这段代码会返回一个列表因为COCO的JSON可以包含多张图片的信息而单个XML文件只描述一张图。整体结构上JSON的信息密度更高一个文件承载整个数据集的标注。3.4 从统一结构导出YOLO格式导出YOLO的TXT时关键步骤是坐标换算。中心点坐标是取上下左右两端的均值宽高是两端坐标的差值最终都要除以图片宽高做归一化。图片宽高来自中间表示里保存的尺寸信息不依赖外部读取图片文件所以速度较快。def export_yolo_txt(image_ann, class_list, output_path): width image_ann[width] height image_ann[height] lines [] for obj in image_ann[objects]: class_name obj[name] if class_name not in class_list: class_list.append(class_name) class_id class_list.index(class_name) xmin, ymin, xmax, ymax obj[bbox] # 计算中心点与宽高并归一化 center_x ((xmin xmax) / 2) / width center_y ((ymin ymax) / 2) / height box_width (xmax - xmin) / width box_height (ymax - ymin) / height lines.append(f{class_id} {center_x:.6f} {center_y:.6f} {box_width:.6f} {box_height:.6f}) with open(output_path, w, encodingutf-8) as f: f.write(\n.join(lines))归一化坐标保留几位小数是个经验问题。我保存6位在宽高上万像素的图片上精度已经足够。保留位数越多文件越大但实际训练时浮点数加载后差异微乎其微。这里有个容易踩的坑class_list在脚本运行期间要保持稳定。如果在转换过程中动态往列表里追加类别前面图片已经写好的TXT类别ID就不会变但新增类的ID会接在后面最终导出结果和class_list的最终顺序是一致的只要确保导出完成后再保存class_list为classes.txt就可以。3.5 从统一结构导出XML和JSON导出XML其实是对中间表示的逆变换把中心点加宽高还原成左上角右下角坐标。编写时用ElementTree构造XML树注意节点的嵌套顺序要符合VOC的格式规范。导出COCO格式时需要为每张图片生成ID、为每个标注框生成ID并且统计类别列表生成categories字段。这些ID建议用确定性规则生成比如图片ID是文件名按顺序递增这样才能保证多次转换结果一致Git追踪时不会产生大量无意义的diff改动。3.6 命令行主流程整合把所有功能集成到一个脚本入口通过参数指定转换方向。我用的是标准库的argparse不需要额外引入click或fire降低环境依赖。import argparse import os def main(): parser argparse.ArgumentParser(description目标检测标注格式转换工具) parser.add_argument(--input_dir, typestr, requiredTrue) parser.add_argument(--input_format, typestr, choices[xml, json, txt], requiredTrue) parser.add_argument(--output_dir, typestr, requiredTrue) parser.add_argument(--output_format, typestr, choices[xml, json, txt], requiredTrue) parser.add_argument(--classes_file, typestr, default) args parser.parse_args()这里建议主流程设计得简单直接不要嵌套太多抽象层级。转换脚本的价值在于稳定可靠架构上花哨没有意义。4. 实际转换过程中的常见问题4.1 坐标精度和边界问题YOLO格式归一化到0到1后理论上坐标值不应超出这个范围。但我遇到过很多次因为标注框部分超出图片边界导致的异常值比如某人体的头部框打到了图片边缘外xmax比width还大归一化后变成1.02。这类数据直接拿去训练模型会在训练时报错或者损失异常。我的建议是在转换脚本中加入一个过滤或修正步骤把超出边界的坐标裁剪回合法区间。裁剪的逻辑很简单xmin小于0则置0xmax大于width则置width。但要注意有些情况下目标框超出边界是标注错误直接裁剪会掩盖错误。我倾向于在裁剪的同时把异常情况打印出来输出到一个日志文件转换结束后人工检查有没有大量边界问题。4.2 类别映射不一致多数据集合并时类别列表的冲突是最大的坑。两个数据集都有“汽车”但A数据集的类别ID是1B数据集的类别ID是2直接拼接会导致模型学到错位的语义。我采取的方案是转换阶段就统一类别映射表。维护一个全局的类别字典比如{cat: 0, dog: 1, car: 2}所有输入格式在解析时就把类别名称映射到这个统一字典上而不是沿用各自数据集的原始ID。输出TXT时再根据这个统一字典生成ID。类别名称里的大小写、空格、下划线也是常见问题。比如一个数据集标注为“Cat”另一个是“cat”合并时会被当作两个类。脚本里做一个简单的字符串规范化处理全部转小写并去空格能减少很多麻烦。4.3 图片文件名对应不上YOLO的TXT文件和图片同名只是后缀不同。但有些数据集里图片叫000001.jpg标注文件叫000001.xml看起来对应没问题实际上标注内容的filename字段写的可能是img_0001.jpg乱套了。我的处理方式是转换时以实际扫描到的文件为准不依赖标注内部记录的filename字段。遍历图片目录生成文件名集合然后对每个图片文件查找对应的标注文件。标注内部的filename字段只做参考不用于文件匹配。4.4 批量处理性能优化处理几千张图片时如果每张图片都重新解析一次类别列表性能会很差。我的优化策略是先扫描所有标注文件建立类别名称到ID的映射表然后把映射表传入转换函数使用避免每张图都动态扩展列表。另一个性能瓶颈是用PIL读取图片尺寸。如果数据结构里已经包含宽高如XML的size节点或COCO的images字段我直接读取不重复读图片文件。只有当缺失尺寸信息时才回退到读取图片这样能大幅减少IO开销。5. 转换脚本的质量保证措施5.1 转换后必须回读校验我每次批量转换后都会做一个回读校验把输出的文件再解析成中间表示和原始输入做一个数值对拍。比对坐标是否在允许的误差范围内类别名称是否一致图片数量是否对齐。这个环节看起来费时间但能发现很多隐蔽问题。比如某个XML漏了size节点导致宽度高度读成0归一化产生除零错误输出TXT全是inf或NaN。这种错误如果不校验直接进入训练损失函数直接崩溃。校验脚本本身写起来很简单先读取原标注再读取新标注比较两类对象数量和各数值的差值。允许误差设为1e-4即可因为坐标换算是浮点操作存在极微小的精度损失是正常的。5.2 抽样可视化标注框数值对拍能检查坐标是否正确但看不清“语义上”是否正确。我建议从转换后的数据中随机抽几张图片画上标注框看一眼。画框用OpenCV的rectangle函数一行代码就能标记出坐标位置。根据我的经验这种可视化检查往往能发现数值校验发现不了的问题。比如某个标注框的类别名称没错、坐标数值也对但画出来发现框偏移到了完全无关的位置那大概率是不同图片的标注文件和图片文件配对错乱导致的。5.3 增量转换与断点处理数据量大的时候一次脚本跑不完很常见。我在设计脚本时加入了简单的断点机制转换前先查看输出目录里是否已经存在目标文件如果存在就跳过。这样脚本中断后重新运行不需要重头开始。当然这个机制有个前提就是转换过程必须是确定的即输入相同输出相同。我的代码里所有ID生成都基于固定规则不使用随机数所以幂等性满足断点续传不会产生不一致。6. 完整脚本结构说明与扩展思路6.1 文件组织建议整个工具我建议分成两个文件核心逻辑模块和命令行入口。核心逻辑模块包含各种格式的解析与导出函数命令行入口只负责参数处理和流程编排。这样测试时可以直接import函数做单元测试不用反复跑命令行。工程的代码量不大大概400行左右但结构和注释要写好因为这类脚本往往要长期维护。我在函数注释里说明了坐标的约定格式在文件顶部写清了整体流程也会定期来更新防止过几个月自己都忘了某个函数是干嘛的。6.2 如果面对的是新格式现在标注格式远不止这三类像Labelme的JSON、五十音图标注、DOTA的TXT格式都是常见的变种。但只要理解了这套“统一中间表示”的思路换一种新格式无非是写一个解析函数加一个导出函数。我实际做过一个项目的DOTA格式转YOLODOTA用四个顶点坐标表示任意四边形目标框而我用的检测器只需要水平矩形框。我的做法是把四个顶点的坐标取最小外接矩形转换成水平框后再走统一的导出流程。虽然损失了一点精度但对水平框检测器来说已经足够。6.3 从转换延伸到数据管线标注格式转换只是数据预处理的起点。我用这套工具跑通转换流程后后面陆续加了数据划分、数据增强、类别统计等功能。整个数据管线的第一步就是从原始标注到统一格式之后所有环节都围绕统一格式展开各模块间不需要再关心格式兼容问题。这套架构帮我节省的时间是实实在在的。之前每次拿到新数据集都要写一个一次性脚本用完丢弃下次遇到类似问题又从头写。现在只需要把新数据集的解析函数补充到转换工具里整个流程顺下来就能用维护成本大大降低。7. 写在最后的实操经验我的实际体会是标注格式转换这件事看起来简单但因为涉及批量数据、潜在异常实际执行时坑特别多。我踩过最深的坑是一次转换时没做校验直接拿结果去训练训练集和验证集的标注框错位模型训了几天才发现数据有问题白白浪费了时间。从那之后我定下一条规矩任何格式转换都必须走完“数值对拍加抽样可视化”这套验证流程才能进入训练环节。流程虽然多花了十分钟但换来的是整个训练周期的稳定安心。最后分享一个小技巧转换脚本中所有路径相关的参数都不要在代码里写死全部通过命令行参数传入。这能让同一个工具在多个数据集上复用不用每次复制粘贴后手动改路径。还有建议给转换脚本加上日志输出功能记录转换数量、异常数量、耗时等信息出问题时排查起来方便得多。这些工具代码我维护了很久中间多次调整过细节但整体架构一直没变。如果你也要处理多格式标注数据建议直接按这个思路搭一套自己的工具链前期稍微花点时间后面就能一劳永逸。
阅读完成 · 觉得有帮助?