VOC与YOLO标注格式互转:XML转TXT的完整实战指南
2026/9/15 16:22:04 网站建设 项目流程

前天接手一批用LabelImg标好的数据,导出时老老实实选了VOC格式,结果训练脚本一读标注,直接报错——它只认YOLO格式的txt。这事我估计不少人都撞上过,网上搜"voc与txt互转"也能找到一堆零散脚本,但大多只给代码不讲原理,换个目录结构就趴窝。所以我借着最近整理的这批数据,把VOC和TXT互转这件事彻底聊透:两种格式到底差在哪、转换时哪些细节最容易翻车、转换完之后怎么快速自检,一次说清楚。

1. 这个转换需求的真实来源:标注生态的两种语言

1.1 LabelImg标完导出XML,训练框架却只认txt

做目标检测的人对LabelImg不陌生,它导出标注时默认支持PascalVOC格式,也就是生成一个和后缀名为xml的同名文件。XML文件里记录着图片路径、尺寸、通道数,以及每个目标的类别名称和左上角右下角坐标,结构清晰完整。问题在于,现在大量训练框架和开源项目默认使用YOLO格式的txt标注,里面每一行是一个目标,只写类别id和归一化后的中心点坐标、宽高。两个工具链的"语言"不一致,就成了最普遍的卡点。

我做项目时的真实场景是这样的:标注员用LabelImg标注了一批工业零部件图片,导出的是VOC格式XML。到了训练阶段,我手上的检测脚本读的是txt标注,需要把图片路径和同名txt放在同一个目录下。于是中间多出来一道工序:把每个XML解析出来,算出目标框的中心点和宽高,再归一化写进txt。这个工序一旦靠手工或者不可靠的脚本完成,后面训练时会出现各种诡异问题:坐标溢出、类别错位、漏标文件。

1.2 我为什么没直接去下载现成转换工具

网上有现成的VOC转YOLO工具,有的还是图形界面。我一开始也想偷懒,但用了两个之后发现麻烦更多:有的工具写死了类别列表,换数据集就得改源码;有的工具对中文路径支持很差;还有的工具转换时会把整数值全部变成科学计数法,YOLO训练时读出来的坐标全飘了。后来我决定自己维护一个转换脚本,理由很直接:数据标注格式转换这件事看似简单,实际耦合了数据集目录结构、类别命名、坐标精度、图片读取方式等多个因素,只有自己写的脚本才能完全贴合项目。

另外还有个很重要的原因:转换脚本本身就是数据集质量的检查点。自己写一遍解析逻辑,等于把标注数据从头到尾审了一遍,能发现LabelImg里看不见的问题,比如标注框越界、目标大小异常、类别名拼写不一致等。这些隐患如果直接交给黑盒工具,到训练时才会爆炸,排查成本高出好几倍。

2. VOC XML和YOLO txt的底层逻辑差异

2.1 VOC的object结构:一张图一个XML

PASCAL VOC格式的XML内部结构并不复杂,我摘一段典型的标注内容来看:

<annotation> <folder>JPEGImages</folder> <filename>img_001.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>screw</name> <bndbox> <xmin>112</xmin> <ymin>86</ymin> <xmax>362</xmax> <ymax>401</ymax> </bndbox> </object> <object> <name>nut</name> <bndbox> <xmin>598</xmin> <ymin>200</ymin> <xmax>790</xmax> <ymax>382</ymax> </bndbox> </object> </annotation>

这里的坐标单位是像素,xmin/ymin是框左上角,xmax/ymax是框右下角。一个XML文件对应一张图片,图片里有多少个目标,就有多少个<object>节点。这种格式可读性强,也方便人工核对,缺点是文件体积大,一个XML动辄几百行,而且标签名很长,传输和读取效率都不高。

XML里最容易被忽略的是<size>节点。转换到txt时必须用到图片宽度和高度,因为归一化坐标需要用真实尺寸做分母。有的XML里这一项是空的,或者标注工具没写对,转换时就会算出错误结果。后面我会专门讲这个坑。

2.2 YOLO txt的结构:一行一个目标

YOLO系列使用的txt标注格式,和VOC是两种思路。每个目标的标注信息压缩成一行,五个字段,空格或制表符分隔:

0 0.415625 0.370833 0.195312 0.437500

五个字段的含义是:目标类别id、归一化中心点x坐标、归一化中心点y坐标、归一化宽、归一化高。归一化的意思是把像素坐标除以图片宽高,让所有值都落在0到1范围内。这样做的好处是,模型训练时不管输入图片被resize成什么尺寸,标注都不需要跟着变,因为比例是固定的。

YOLO txt没有文件名、图片尺寸这些"元信息",只存纯粹的框和类别。它依赖和图片同名的机制来关联标注,比如图片叫img_001.jpg,标注就叫img_001.txt。这也是为什么转换时目录结构必须严格匹配,否则训练脚本读不到对应注释,轻则跳过这张图,重则直接报错。

2.3 两种格式互转的本质:信息的重新排列

理解了两种格式之后会发现,互转的本质不是复杂计算,而是信息重新排列:

  • VOC转TXT:把像素坐标变成归一化坐标,把类别字符串映射成类别id,把多个object节点变成多行文本。
  • TXT转VOC:把归一化坐标还原回像素坐标,把类别id映射回类别字符串,同时从图片文件或外部图片尺寸信息补充XML里必须有的<size>节点。

这里面最需要小心的是类别映射表。VOC里的<name>字段存的是可读字符串,比如screwnut,训练脚本里通常维护一个classes列表来确定id顺序。如果转换时用的类别顺序和训练时用的不一致,模型就会把螺栓当螺母来学,而且从loss曲线上很难直接看出问题,往往到验证阶段才暴露出来。

3. 手写VOC转TXT转换脚本的关键细节

3.1 目录结构设计与类别映射表

我习惯在项目下建立这样的目录结构:

datasets/ ├── annotations_voc/ │ ├── img_001.xml │ ├── img_002.xml │ └── ... ├── images/ │ ├── img_001.jpg │ ├── img_002.jpg │ └── ... ├── labels/ │ ├── img_001.txt │ ├── img_002.txt │ └── ... └── classes.txt

classes.txt里每行写一个类别名,顺序就是类别id。这里有个容易被忽略的细节:classes.txt必须和训练脚本里的类别顺序完全一致。我建议把classes.txt作为唯一事实来源,转换脚本直接读取它,而不是在脚本里写死一个列表。这样以后增删类别,只需要改classes.txt,不需要改代码。

读取classes.txt的代码很简单:

with open('classes.txt', 'r') as f: classes = [line.strip() for line in f.readlines() if line.strip()] class_to_id = {name: i for i, name in enumerate(classes)}

注意line.strip()这一步不能省,我自己就曾经因为某行末尾多了个换行符或多了一个空格,导致类别匹配不上。在标注数据规模大的时候,这种隐藏的字符串差异排查起来非常痛苦。

3.2 解析XML并输出YOLO格式的完整代码

核心转换逻辑用Python实现,依赖只需要xml.etree.ElementTreeos,零第三方库。下面这个函数我基本每个项目都直接复用:

import os import xml.etree.ElementTree as ET def voc_to_txt(xml_path, output_dir, class_to_id, img_width=None, img_height=None): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') if size is not None: width = int(size.find('width').text) height = int(size.find('height').text) else: width, height = img_width, img_height if not width or not height: raise ValueError(f"{xml_path} 缺少图片尺寸信息") filename = os.path.splitext(os.path.basename(xml_path))[0] out_path = os.path.join(output_dir, filename + '.txt') lines = [] for obj in root.findall('object'): name = obj.find('name').text.strip() if name not in class_to_id: print(f"警告: {xml_path} 包含未知类别 {name}") continue box = obj.find('bndbox') xmin = float(box.find('xmin').text) ymin = float(box.find('ymin').text) xmax = float(box.find('xmax').text) ymax = float(box.find('ymax').text) x_center = (xmin + xmax) / 2.0 / width y_center = (ymin + ymax) / 2.0 / height w = (xmax - xmin) / width h = (ymax - ymin) / height lines.append(f"{class_to_id[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") with open(out_path, 'w') as f: f.write('\n'.join(lines))

这个函数有几个细节值得解释:

第一个细节是float(xmin)而不是int(xmin)。有的标注工具会导出<xmin>112.5</xmin>这样的浮点值,如果用int直接转,会损失小数精度,虽然通常影响不大,但稳妥起见用float。

第二个细节是坐标归一化时中心点用(xmin + xmax) / 2.0。这句话看起来简单,但如果不用.0,Python2时代的整数除法会直接截断。虽然现在用Python3的人多了,我还是建议显式写成2.0,减少跨环境时出现低级错误的可能。

第三个细节是输出精度用:.6f。6位小数对坐标归一化值来说已经足够,训练框架读的时候也不会有什么精度损失。如果写成:.15f,生成的txt会特别长,看着难受而且没有实际收益。

3.3 批量转换时的进度与异常处理

单文件转换写完,批量转换很容易。但实际跑批时,我更关注异常处理,因为几百上千个XML里难免有几个"脏"文件。我的做法是:

xml_dir = 'annotations_voc' output_dir = 'labels' os.makedirs(output_dir, exist_ok=True) error_list = [] for xml_file in sorted(os.listdir(xml_dir)): if not xml_file.endswith('.xml'): continue xml_path = os.path.join(xml_dir, xml_file) try: voc_to_txt(xml_path, output_dir, class_to_id) except Exception as e: error_list.append((xml_file, str(e))) print(f"转换失败: {xml_file}, 原因: {e}") print(f"完成,共处理 {len(os.listdir(xml_dir))} 个文件,失败 {len(error_list)} 个")

不要小看这个try-except,它帮我拦下过不少问题:有的XML文件实际上是个空文件,有的编码不对,有的是LabelImg编辑到一半保存的损坏文件。如果不用异常捕获,批量处理到第100个文件时突然中断,前面白跑,后面没跑,心态直接崩。把这个错误列表存下来,转完一起排查,效率高很多。

4. TXT转VOC:看似逆向,其实多出好几件事

4.1 txt没有图片尺寸信息,必须单独读图

把YOLO格式的txt还原成VOC格式,比正向转换麻烦得多。因为txt里只有归一化后的中心点和宽高,没有图片实际尺寸,你没法直接把归一化值还原成像素坐标。解决办法只能是从图片文件里读取宽高,或者从某个外部数据源查图片尺寸。

我个人推荐直接从图片读尺寸,用PIL或者cv2都行。虽然多了一点点I/O开销,但保证了准确性。读取图片尺寸的代码如下:

from PIL import Image def get_image_size(image_path): with Image.open(image_path) as img: width, height = img.size return width, height

这里有个小坑:使用Image.open后一定要用with语句或者显式img.close(),否则在Windows上文件会被占用,后面做文件清理或数据集复制时会报权限错误。我踩过这个坑,所以专门提醒一句。

4.2 归一化坐标还原像素坐标的计算

还原公式是正向过程的逆运算:

xmin = (x_center - width / 2) * img_width ymin = (y_center - height / 2) * img_height xmax = (x_center + width / 2) * img_width ymax = (y_center + height / 2) * img_height

写成代码:

def txt_to_voc(txt_path, image_path, id_to_class, output_dir): width, height = get_image_size(image_path) filename = os.path.splitext(os.path.basename(txt_path))[0] root = ET.Element('annotation') folder = ET.SubElement(root, 'folder') folder.text = 'images' fn = ET.SubElement(root, 'filename') fn.text = os.path.basename(image_path) size = ET.SubElement(root, 'size') w_node = ET.SubElement(size, 'width') w_node.text = str(width) h_node = ET.SubElement(size, 'height') h_node.text = str(height) d_node = ET.SubElement(size, 'depth') d_node.text = '3' with open(txt_path, 'r') as f: for line in f: line = line.strip() if not line: continue parts = line.split() if len(parts) != 5: print(f"跳过非法行: {line}") continue class_id = int(parts[0]) x_center = float(parts[1]) y_center = float(parts[2]) w_norm = float(parts[3]) h_norm = float(parts[4]) if class_id not in id_to_class: print(f"未知类别id: {class_id}") continue xmin = (x_center - w_norm / 2) * width ymin = (y_center - h_norm / 2) * height xmax = (x_center + w_norm / 2) * width ymax = (y_center + h_norm / 2) * height obj = ET.SubElement(root, 'object') name = ET.SubElement(obj, 'name') name.text = id_to_class[class_id] bndbox = ET.SubElement(obj, 'bndbox') xmin_node = ET.SubElement(bndbox, 'xmin') xmin_node.text = str(int(round(xmin))) ymin_node = ET.SubElement(bndbox, 'ymin') ymin_node.text = str(int(round(ymin))) xmax_node = ET.SubElement(bndbox, 'xmax') xmax_node.text = str(int(round(xmax))) ymax_node = ET.SubElement(bndbox, 'ymax') ymax_node.text = str(int(round(ymax))) tree = ET.ElementTree(root) out_path = os.path.join(output_dir, filename + '.xml') tree.write(out_path, encoding='utf-8', xml_declaration=True)

还原坐标后要不要round成整数,取决于你后续怎么用这批XML。如果只是用来人工检查或者重新导入LabelImg,整数坐标看着更干净;如果要做像素级分割,那最好保留浮点值。我的做法是在代码里加一个开关,用参数控制是否取整,灵活一点。

4.3 生成XML时别忘encoding和缩进

ET.ElementTree.write默认写出来的XML可能不带缩进,所有子节点挤成一行。这不影响读取,但如果你想用文本编辑器打开对照检查,缩进会很有帮助。不过xml.etree.ElementTree本身不支持自动缩进,我一般这样处理:

import xml.dom.minidom as minidom def pretty_xml(elem): rough_string = ET.tostring(elem, encoding='unicode') reparsed = minidom.parseString(rough_string) return reparsed.toprettyxml(indent=' ')

生成XML文件时,用这个pretty_xml函数格式化一下再写入。当然,这会让文件体积变大,如果你只是为了训练框架能读,不加缩进完全没问题。加缩进纯粹是为了给人看。

5. 转换完必须检查的几类典型坑

5.1 坐标越界与小数精度

转换过程中最常见的问题就是坐标越界。比如归一化坐标应该是0到1之间,但某些标注工具或者手工编辑的txt里可能出现1.0001这种值,还原到像素坐标时,xmax就超出了图片宽度。导致这类问题的原因主要有三个:

  • 标注时鼠标拖拽超出了图片边缘。
  • 图片resize过,但标注没有跟着更新。
  • 转换脚本里除法的精度不够,累计误差把边界值推过了1。

我的处理方式是转换后统一做一次clamp,把所有框的坐标限制在图片范围内:

xmin = max(0, xmin) ymin = max(0, ymin) xmax = min(width, xmax) ymax = min(height, ymax)

同时检测坐标倒挂的情况,也就是xmin大于xmax,或者ymin大于ymax。出现这种问题说明原始标注完全不可信,不要直接修坐标,应该回到标注源头重新核对。

5.2 空标注文件和旧标注脏数据

有些图片本身没有目标,对应的txt就是0字节,XML里一个<object>节点都没有。这种空文件在转换时很容易被脚本漏掉,但训练框架读取时如果遇到过大的空标注目录,有的框架会报"no labels found",让你误以为转换脚本写错了。

我的习惯是转换后统计一下空标注的比例。如果某个类别的图片有大量空标注,有可能是标注员漏标了,而不是图片真的没有目标。这里可以用一个简单统计脚本帮忙识别:

from collections import Counter empty_files = [] class_counts = Counter() for txt_file in os.listdir(labels_dir): path = os.path.join(labels_dir, txt_file) if os.path.getsize(path) == 0: empty_files.append(txt_file) continue with open(path, 'r') as f: for line in f: if line.strip(): class_id = int(line.split()[0]) class_counts[class_id] += 1 print("空标注数量:", len(empty_files)) print("各类别目标数量:", class_counts)

这里输出的类别目标数量还有个附带作用:如果你发现某个类别只有一个目标,或者明显偏少,多半是标注或类别映射出了问题,趁早回去检查。

5.3 类别id映射不一致导致的隐性错误

YOLO训练脚本里的data.yamlclasses.txt通常定义了类别顺序,比如['screw', 'nut', 'washer'],那么id 0是screw,1是nut,2是washer。如果你在转换时用的类别顺序是['nut', 'washer', 'screw'],txt里的id就会整个错位,模型训练出来等于学了一堆错标签。

这个问题隐蔽在:loss不会崩,mAP看起来也有点样子,但混淆矩阵会非常乱。我在项目中遇到过类似情况,排查了两天才发现是转换和数据配置用了两份不同的类别列表。后来我强制约定:所有脚本和数据集统一读取同一个classes.txt,不允许在训练配置里再写一份。

6. 一些扩展用法和我的个人习惯

6.1 把转换脚本改造成双向往返稳定

我自己的脚本通常同时支持VOC转TXT和TXT转VOC,并且加一个--direction参数。这样在调试数据集时,可以先转过去再用再转回来,对比还原的XML和原始XML的差异,检查转换过程有没有丢信息。理论上VOC转TXT再转回VOC,坐标应该完全一致(除了浮点精度的舍入误差),如果差异过大,说明某个环节出问题。

这个往返测试很重要,因为它能发现那些"偶然正确"的转换逻辑。比如某些工具在VOC转TXT时会自动把类别排序,但你没注意到,反向转回来时类别顺序对不上,往返测试就能暴露这种问题。

6.2 对图片做resize后如何同步转换

实际项目里,训练用的图片尺寸通常是640x640或者416x416,但原始标注是1920x1080的图片上画的。有人resize图片之后直接拿原始标注去训练,坐标自然全都错位。正确做法是先对原图做resize,然后按缩放比例同步修改标注,或者干脆先转成归一化txt,再resize图片。这也是YOLO格式相对VOC更灵活的地方:归一化坐标天然适应不同尺寸,只要保持比例不变,哪个模型输入尺寸都能用。如果你需要在不同分辨率数据集之间迁移,用归一化txt作为中间格式是最省事的。

6.3 转换脚本也是数据集质量检查器

我前面提到过,转换脚本应该承担质检的职责。除了统计类别和空标注,还可以顺便检查:标注框的面积是不是太小、宽高比是否异常、有没有超出边界的框。这些检查能帮你在训练之前就把数据问题揪出来。比如工业零件检测中,如果有一个标注框宽度只有2像素,那大概率是哪只手误操作留下的残次标注,直接过滤掉或者删除,效果好过让模型硬学。

最后再分享一个小技巧

我每次转换完数据集,都会用OpenCV在图片上画一遍转换后的标注框,随机抽几十张图看一眼。画框代码只有十来行,但比任何检查脚本都直观。框位置对了、类别对上了、边界没溢出,一眼就能确认。这一步看起来浪费时间,实际上能省掉后面训一个错误模型的好几个小时。

import cv2 import random sample_files = random.sample(os.listdir(labels_dir), 20) for label_file in sample_files: img_name = label_file.replace('.txt', '.jpg') img_path = os.path.join('images', img_name) img = cv2.imread(img_path) h, w = img.shape[:2] with open(os.path.join(labels_dir, label_file), 'r') as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls, xc, yc, bw, bh = map(float, parts) x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, str(int(cls)), (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(f'check_{label_file}.jpg', img)

这个画框脚本顺手还能做一件事:把类别id显示在框上方,这样类别映射错位的问题一眼就能看出来,比看数字靠谱得多。等到画的框全部正常,再放进训练流程里,基本不会再被标注格式的问题打断。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询