资讯动态

VOC标注转YOLO标签:从XML到txt的完整转换流程与避坑指南

发布时间:2026/10/23 4:51:36 来源:尧图企业网站定制
做目标检测的同学十有八九都经历过这个环节手里的标注是VOC格式而训练框架只认YOLO格式。我在实际项目里接过来自不少渠道的数据VOC公开数据集、标注工具导出的XML、别人交接过来的历史标注基本都得先转成一套统一的YOLO标签才能喂给训练。这篇就把整条链路完整走一遍从格式差异、目录规整、Python脚本到转换后的验证一次讲清楚。这个转换任务本质上是把框的角点坐标类别名改写成归一化的中心点坐标宽高类别ID听起来简单实操里翻车的地方不少类别列表对不上、difficult目标没过滤、坐标直接除了图片宽却忘了归一化、图片后缀写死导致批量转换时静默丢数据。这篇适合正在跑YOLO系列训练、手头有VOC数据要处理的读者也适合那些刚用标注工具导出XML、准备把数据接入公开训练代码的入门玩家。1. 先把两个格式的底层逻辑捋清楚转换脚本人人能写但多数人写出来的脚本只适配自己那一份数据换个数据集就爆。要想写出通用性强的转换工具必须先把VOC和YOLO两种格式在设计层面的差异弄明白。1.1 VOC标注到底存了什么VOC格式用一个XML文件描述一张图片中的所有目标。核心字段包括size节点图片宽度、高度、通道数和若干个object节点每个object内部有目标的类别name、是否难例difficult、是否截断truncated以及bndbox框的四个角点xmin、ymin、xmax、ymax。这种角点坐标是绝对像素值优点是人眼可直接阅读调试时打开XML就能知道框在哪。缺点是它依赖图片本身的分辨率同一目标换成更高分辨率图片后这些绝对坐标数字就失效了。VOC格式还有一个容易被忽略的环节它把训练集、验证集、测试集的划分单独放在ImageSets/Main目录下每个txt文件里只有图片文件名不含扩展名一张图片归哪个集合由txt决定而不是由存储目录决定。转换时如果只处理XML而不管划分文件最后得到的标签目录往往是一锅粥。1.2 YOLO标签为什么长这样YOLO格式的标签文件与图片同名后缀换成txt。每行描述一个目标内容为五个数值class_id x_center y_center width height。前一个整数是类别ID从0开始计数后面三个浮点数是归一化后的值。这里有两个关键设计意图。第一归一化坐标与图片分辨率解耦训练时无论把图片resize成什么尺寸归一化标签都不需要跟着改。第二中心点加宽高的表达方式天然适配YOLO的检测头因为YOLO系列模型的回归目标就是预测中心点相对所在网格的偏移量以及宽高相对先验框的比例标签字段与网络输出字段直接对应。另一个特点是标签文件名必须与图片名完全一致。训练时数据加载模块读入一张图片就直接按图片名去找同名txt文件如果找不到会静默跳过或报错。这种约定成俗的命名规范决定了转换脚本输出文件时必须严格保留原图片的主名部分。1.3 归一化的数学原理从VOC角点坐标转换到YOLO中心点坐标核心是两个公式x_center (xmin xmax) / 2 / image_width y_center (ymin ymax) / 2 / image_height width (xmax - xmin) / image_width height (ymax - ymin) / image_height注意三个细节。一是分母必须用图片的实际宽高而不是默认值或目标框尺寸二是先求中心点绝对坐标再除以宽高顺序不能反三是所有值都要转成浮点数计算避免整数除法直接截断。我见过有人用(xmin xmax) / (2 * image_width)写得很对也有人写(xmin xmax) // 2 / image_width在Python里前者对后者在xmin xmax为奇数时直接掉精度。坐标误差虽然通常只有0.5个像素但对小目标来说这个误差可能占框宽度的10%以上训练时影响不小。2. 转换前的目录规划与类别清单很多转换脚本跑不通不是代码的问题是数据摆放的问题。拿到原始数据后先别急着写脚本花十分钟把目录结构看明白后面能省两个小时。2.1 标准VOC目录长什么样一份标准的VOC数据集目录结构是固定的VOCdevkit/ ├── VOC2007/ │ ├── Annotations/ # 所有XML标注文件 │ ├── JPEGImages/ # 所有原图 │ ├── ImageSets/ │ │ └── Main/ │ │ ├── train.txt │ │ ├── val.txt │ │ └── trainval.txt │ └── ...Annotations下每一个xml文件对应JPEGImages下同名的一张图片xml里的文件名节点不一定与磁盘实际文件名一致但一般情况下是一致的。Main下的txt每行一个图片主名没有后缀名。如果你的数据不是这个结构比如XML散落在多个子目录、图片后缀全是png、标注和图片不在一层也没关系转换前把它们规整成这个结构或者让你的脚本具备递归扫描能力。我个人的习惯是宁可多写几行扫描代码也不去手动移动几百个文件。2.2 YOLO训练数据的目录结构标准YOLO数据目录结构分为图片目录和标签目录两层统一放在一个根目录下dataset/ ├── images/ │ ├── train/ │ │ ├── 000001.jpg │ │ └── ... │ └── val/ │ ├── 000001.jpg │ └── ... └── labels/ ├── train/ │ ├── 000001.txt │ └── ... └── val/ ├── 000001.txt └── ...images和labels两个目录必须在同一父目录下且train下图片与train下标签文件名一一对应这是YOLO系列框架的硬性约定。原因在于底层数据加载逻辑直接拿着图片路径去拼标签路径如果目录层级不对就会找不到标签。磁盘空间有限的情况下可以用软链接代替复制。但要注意如果目标挂载在移动硬盘或云盘上软链接可能失效稳妥起见还是老老实实复制。批量复制几百GB数据时用shutil.copy2保留原文件属性顺便打印个进度条避免干等。2.3 类别清单和data.yaml的准备转换脚本里必须有一份类别列表这个列表的索引顺序直接决定标签文件里的class_id。常见错误是把YOLO格式的类别ID从1开始或者转换时用了一套顺序写data.yaml时又用了另一套顺序两者一对比就会错位。VOC公开数据集有20个类别我这边转换时通常这样写VOC_CLASSES [ aeroplane, bicycle, bird, boat, bottle, bus, car, cat, chair, cow, diningtable, dog, horse, motorbike, person, pottedplant, sheep, sofa, train, tvmonitor ]如果是自己的数据集把列表替换成自己的类别名即可但顺序一旦确定就不要再改。训练之前建议确认一遍data.yaml里的names列表与这个类别列表完全一致顺序一致内容一致大小写也一致。path: /path/to/dataset train: images/train val: images/val nc: 20 names: [aeroplane, bicycle, bird, boat, bottle, bus, car, cat, chair, cow, diningtable, dog, horse, motorbike, person, pottedplant, sheep, sofa, train, tvmonitor]nc是类别总数必须和names长度一致。这个文件写完之后整个数据准备才算闭环。3. 手写转换脚本从XML到txt的核心实现别人写好的转换脚本可以应急但真正要适配自己的数据集还是得理解核心逻辑才能应对各种杂七杂八的标注变体。下面我会给出一个完整可运行的转换脚本并逐步拆解每个函数的作用。3.1 解析单个XML并输出单行标签先实现最底层的函数给定一个XML路径和类别列表返回该图片对应的YOLO标签行列表。import xml.etree.ElementTree as ET import os def parse_voc_xml(xml_path, classes): tree ET.parse(xml_path) root tree.getroot() size root.find(size) if size is None: print(f[警告] {xml_path} 缺少size节点跳过) return [] try: width int(float(size.find(width).text)) height int(float(size.find(height).text)) except (TypeError, ValueError): print(f[警告] {xml_path} 的size字段异常跳过) return [] if width 0 or height 0: return [] lines [] for obj in root.iter(object): name obj.find(name).text if name not in classes: continue difficult 0 difficult_node obj.find(difficult) if difficult_node is not None and difficult_node.text: try: difficult int(float(difficult_node.text)) except ValueError: difficult 0 # 公开VOC数据集里difficult1的目标在YOLO训练中一般直接过滤 if difficult 1: continue bndbox obj.find(bndbox) if bndbox is None: continue xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) # 防止角点值超出图片范围 xmin max(0, xmin) ymin max(0, ymin) xmax min(width, xmax) ymax min(height, ymax) if xmax xmin or ymax ymin: continue x_center (xmin xmax) / 2.0 / width y_center (ymin ymax) / 2.0 / height bw (xmax - xmin) / width bh (ymax - ymin) / height cls_id classes.index(name) lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}) return lines这里有两个处理很关键。第一xmax和ymax要限制在图片宽高范围内因为标注框偶尔会贴边溢出这在实际标注中不少见。第二xmin、ymin等字段全部用float()转换而不是int()因为部分标注工具会保存浮点坐标直接int()会把0.8变成0造成误差。root.iter(object)比root.findall(object)更稳因为有些程序在序列化XML时会套额外的层级iter()不管嵌套多深都能找出来。3.2 批量转换与目录自动创建有了基础解析函数接下来写批量转换逻辑。这一步要做的事情是遍历所有XML文件、逐个解析、生成同名txt文件、并保持子目录层级不变。def convert_all(xml_root, output_root, classes, keep_difficultFalse): if not os.path.exists(output_root): os.makedirs(output_root) xml_files [] for root_dir, _, files in os.walk(xml_root): for f in files: if f.lower().endswith(.xml): xml_files.append(os.path.join(root_dir, f)) if len(xml_files) 0: print([错误] 没有找到任何XML文件请检查路径) return count 0 for xml_path in xml_files: relative_path os.path.relpath(xml_path, xml_root) txt_relative os.path.splitext(relative_path)[0] .txt txt_path os.path.join(output_root, txt_relative) os.makedirs(os.path.dirname(txt_path), exist_okTrue) lines parse_voc_xml(xml_path, classes) # 即使没有目标也生成空txt避免训练时找不到标签文件 with open(txt_path, w, encodingutf-8) as f: f.write(\n.join(lines) (\n if lines else )) count 1 print(f转换完成{count} 个XML已处理)用os.walk递归扫描的好处是自己的数据集即使不规整成标准目录结构也能处理。比如XML按子类别分目录放置转换后依然保持同样的相对路径关系在输出目录中图片和标签的对应关系不乱。这里我要特意强调空txt文件的问题。早期版本代码里没有目标的XML我会直接跳过不生成txt。结果训练时数据加载器按图片名找标签找不到就直接跳过错失部分训练样本或者报索引错误。现在统一生成空txt一行都不写训练逻辑遇到空文件会自动跳过。这个小改动让转换程序对各种脏数据容错率大幅提升。3.3 结合ImageSets划分数据到训练目录转换出txt还只是第一步还需要按VOC的划分文件把图片和标签整理到约定的dataset目录。这个函数读取ImageSets/Main下的划分txt再把对应文件复制到目标目录。import shutil def prepare_split(split_txt, images_src, labels_src, target_root, split_name): image_out_dir os.path.join(target_root, images, split_name) label_out_dir os.path.join(target_root, labels, split_name) os.makedirs(image_out_dir, exist_okTrue) os.makedirs(label_out_dir, exist_okTrue) with open(split_txt, r) as f: names [line.strip() for line in f if line.strip()] for name in names: image_path None for ext in [.jpg, .jpeg, .png, .bmp, .tif, .webp]: candidate os.path.join(images_src, name ext) if os.path.exists(candidate): image_path candidate break if image_path is None: print(f[警告] 找不到图片: {name}) continue label_path os.path.join(labels_src, name .txt) if not os.path.exists(label_path): print(f[警告] 找不到标签: {name}.txt) continue shutil.copy2(image_path, os.path.join(image_out_dir, os.path.basename(image_path))) shutil.copy2(label_path, os.path.join(label_out_dir, name .txt)) print(f{split_name} 集整理完成共 {len(names)} 个条目)图片后缀不写死是必须的。很多人用LabelImg导出的图片是jpg但也有不少情况是png尤其是截屏或小数据集。逐个尝试常见后缀是最省事的做法。正式执行时只需要调用三次prepare_split(VOCdevkit/VOC2007/ImageSets/Main/train.txt, VOCdevkit/VOC2007/JPEGImages, yolo_labels, dataset, train) prepare_split(VOCdevkit/VOC2007/ImageSets/Main/val.txt, VOCdevkit/VOC2007/JPEGImages, yolo_labels, dataset, val)如果原始数据里没有划分文件或者划分文件内容不理想可以随机划分。用random.shuffle打乱所有xml主名列表按比例切分让脚本自动生成train.txt和val.txt。这里建议在划分时按类别平衡一下不要让某个类别全部落入验证集。3.4 转换结果可视化验证代码跑通之后先别急着训练花几分钟做一次可视化验证。把YOLO标签画回到原图上如果框的位置与目标吻合再训练否则前面所有努力可能都白费。import cv2 def visualize_label(image_path, label_path, classes): img cv2.imread(image_path) if img is None: print(f图片读取失败: {image_path}) return h, w img.shape[:2] with open(label_path, r) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue cls_id int(parts[0]) x_center float(parts[1]) * w y_center float(parts[2]) * h bw float(parts[3]) * w bh float(parts[4]) * h xmin int(x_center - bw / 2) ymin int(y_center - bh / 2) xmax int(x_center bw / 2) ymax int(y_center bh / 2) xmin max(0, xmin) ymin max(0, ymin) xmax min(w, xmax) ymax min(h, ymax) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) label_text classes[cls_id] if cls_id len(classes) else str(cls_id) cv2.putText(img, label_text, (xmin, max(15, ymin - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) cv2.imshow(check, img) cv2.waitKey(0) cv2.destroyAllWindows()可视化时如果发现框整体偏移问题大概率出在归一化公式检查分母是不是用错了宽高。如果发现类别名对不上检查classes顺序与ID映射。这一步能拦下绝大多数低级错误。4. 踩过的坑与排查技巧转换脚本这套流程看着简单但我在不同数据集上来回折腾过很多次积累了几个高发问题。整理成速查表你遇到类似情况可以直接对照。4.1 转换后训练集报找不到标签训练时日志里大量出现could not find label file或者某个阶段的mAP一直很低。排查思路是先统计转换产出的txt文件数量如果txt数量远少于图片数量说明部分XML没有成功生成标签。常见根因有三个XML文件里的size节点缺失或损坏类别名称与代码里的类别列表不一致导致全部目标被过滤XML里目标框坐标超出图片边界被clamp后宽高为负被continue跳过。我的建议是转换脚本里把所有跳过原因都打出来不要静默处理。上面代码里关键位置都打印了警告实战中调试效率很高。4.2 可视化时所有框位置错乱画出来的框要么整体往左上角偏要么宽高比例不对。先检查归一化是否用了原图尺寸。有一种情况比较隐蔽某些数据集把标注坐标对应的图片尺寸和实际图片尺寸不一致典型场景是之前做过裁剪XML里的size没有同步更新。这种情况脚本很难完全自动规避可视化抽样检查是最可靠的手段。另一个隐蔽问题x_center和y_center的计算顺序写反了把x方向坐标除以了高度。这种错误一眼看不出来但可视化时框会呈现出横向比例被拉伸的特征。逐个检查第一个目标的坐标值和真实图片尺寸手动算一遍就能定位。4.3 类别ID错误导致训练损失异常训练loss正常下降但验证集mAP接近0这种时候优先怀疑类别映射错位。例如原本的person类别在转换时排在第0位但data.yaml里把person排在第5位模型学到的特征和标签完全对不上。解决办法只有一个全流程统一类别列表转换脚本里写死一份data.yaml里写死一份两份必须逐字符一致。最好是用一个Python脚本同时生成类别列表和yaml文件从源头杜绝不一致。4.4 超大数据集转换效率问题几千张图片的转换几秒就能跑完但几十万张图片的工程逐文件解析XML就会慢很多。优化方向有两个使用多进程并行转换或者将XML标签数据提前序列化到内存一次性处理。我目前的做法是multiprocessing.Pool配合每进程处理一批文件速度能提升近一个数量级。代码结构上只需要把convert_all里的循环替换成进程池映射即可。还有一种场景是XML不标准比如标注工具导出的XML带了命名空间或者标签与VOC字段不完全一致。遇到这类数据先随便打开一个xml人工看一眼结构用ET.parse后直接打印根目录tag确认是否带命名空间前缀再决定是strip还是映射属性。5. 自己的数据集没有标准VOC方案怎么办很多人问的不是公开VOC数据集的转换而是我用LabelImg标了一批数据怎么接进YOLO。这个场景下有两类方案。5.1 LabelImg直接导出YOLO格式如果数据量不大框不多直接用LabelImg手动导出YOLO格式是最快的。打开软件后在默认保存路径时选择YOLO格式保存后会自动生成同名txt。这种方式赢在小数据量下的便捷性但对已有VOC格式数据做批量迁移就不现实了。它也没法处理公开VOC数据集里那类带difficult标记的复杂标注。我通常的建议是小项目从标注开始就统一YOLO格式大项目或复用已有VOC数据时用脚本转换并做好目录规整。5.2 先把非标准标注转成VOC再统一处理有些标注工具导出的是JSON或CSV格式比如部分语义分割工具或近似标注软件。与其为每种格式写一套YOLO转换逻辑不如先写个轻量脚本把JSON/CSV统一转成VOC XML结构再走上面整个流程。原因很简单VOC格式保留的是绝对坐标加图片尺寸中间环节更具备可调试性数据检验和人工复核都方便。你的核心资产是标注内容本身格式永远只是载体中间多一层通用格式有利于后续各种算法切换。这种中转方案在团队协作中也更友好。标注同学导出的JSON格式往往包含各种额外字段统一转成VOC后算法同学一眼就能看出标注内容是否有问题而不是陷在每种框架的标签格式细节里。转换其实只是数据工程里一点小事但这一步做不好后面所有训练实验都要为数据错误买单。我自己经历的教训是一开始图省事用了别人现成脚本后来训练效果始终差一口气排查了两天才发现是类别顺序错位。从那以后每个数据集我都坚持用自己的脚本转换、抽样可视化、随机挑几张和原标注XML交叉核对。这套流程虽然多花二十分钟但在后续训练阶段省下的时间远超这个数。如果你手头也有批量的VOC数据要喂进YOLO建议先把上面的脚本改造成适合自己目录结构的版本跑通一次之后以后每个新数据集都复用这套流程。等积累几次经验后再考虑并行处理、自动清洗等进阶优化。我实际用下来的感受是转换脚本写得顺手了数据预处理焦虑能少一大半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑