☰
VOC转YOLO格式详解:XML标注转txt归一化坐标,训练集测试集一键划分
2026/9/28 2:36:56 网站建设 项目流程

简介:针对目标检测中VOC格式数据集与YOLO训练格式不一致的问题,这份代码包提供了一套完整的转换与划分方案,适合计算机、电子信息工程、数学等专业的学生用于课程设计、期末大作业或毕业设计,也可作为目标检测入门者的预处理参考。包内共2个文件,均为Python脚本,压缩包仅2KB,轻量紧凑;分别实现VOC标注转YOLO格式、训练集与测试集自动分割两大功能,代码采用参数化编程,路径与比例可灵活调整,注释详尽、逻辑清晰,并附带运行结果以供核对。该资源已有560人学习下载,使用者可直接套用脚本处理自己的数据集,省去从零编写和调试的麻烦;同时由于代码经过实际测试,功能稳定,适合快速集成进实验流程。作者为长期从事算法仿真的一线工程师,代码风格规范,遇到运行问题还可私信交流,方便二次开发与深入学习。

1. 先把话说透:VOC转YOLO解决的是“标注格式”和“训练入口”两个问题

把VOC格式数据集转成YOLO格式,再切出训练集和测试集,是目标检测入门里卡人最多的一道坎。很多人手里有VOC2007/2012、用LabelImg导出的自建数据集、或者从比赛里扒下来的现成数据,标注基本都长成XML样子,坐标是绝对像素;但YOLOv5、YOLOv8、Darknet训练时默认读的是以图片左上角为原点、归一化到0-1区间的txt标注。格式不转,训练代码从labels目录里什么都读不到,更别提跑通训练入口。这份资源用两个脚本把这段流程补齐:voc_to_yolo.py负责解析XML并生成归一化txt,cutdata.py负责按自定义比例把图片清单切成训练集、验证集和测试集,压缩包里还带了运行结果截图和文档说明,开始动手前可以先照着比对预期输出。适合正在准备课程设计、期末大作业,或者第一次想把自己数据集喂给YOLO系列框架的读者。

2. 认清两种格式的本质差异:从XML的绝对坐标到txt的归一化坐标

转换脚本写起来不难,真正容易出问题的,是没搞懂VOC和YOLO两种标注在“坐标基准”上根本不是一回事。这一章先把两种格式拆开,把换算公式讲透,后面改脚本参数时,每一步心里都有底。

2.1 VOC目录结构与Annotations里的关键节点

VOC格式的数据集,目录结构基本是固定的。不管是从官网下的VOC2007/VOC2012,还是用LabelImg导出的自建数据,整理完一般都长这样:

VOCdevkit/ └── VOC2007/ ├── JPEGImages/ # 原图,jpg或者png │ ├── 000001.jpg │ └── ... ├── Annotations/ # VOC标注XML │ ├── 000001.xml │ └── ... ├── ImageSets/ │ └── Main/ │ ├── train.txt # 官方自带的训练/验证划分 │ └── val.txt └── labels/ # 转换后生成,YOLO训练真正读的目录

这个结构里,JPEGImages是图片,Annotations是标注,ImageSets/Main里存的是官方给出的文件清单,内容是每行一个不带扩展名的文件名。需要的信息主要在Annotations里,一张图对应一个同名的XML。转换时要同时读Annotations和JPEGImages,输出的是labels目录和一份新的图片目录。

打开任意一个XML,核心内容长这样:

<annotation> <folder>VOC2007</folder> <filename>000001.jpg</filename> <size> <width>500</width> <height>375</height> <depth>3</depth> </size> <object> <name>car</name> <pose>Frontal</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>100</xmin> <ymin>120</ymin> <xmax>400</xmax> <ymax>300</ymax> </bndbox> </object> </annotation>

这里要记住几个关键节点:name是类别字符串,bndbox里的xmin、ymin、xmax、ymax是像素级绝对坐标,difficult标记难例样本。一个xml里可能有多个object节点,每个object代表一个目标框。转换的时候,size节点里的width和height理论上是图片真实尺寸,但实际项目中经常出现XML里写的尺寸和JPEGImages里图片真实尺寸不一致的情况,这个细节我会在避坑章节专门展开。

2.2 YOLO的txt标注格式与换算公式

YOLO格式的标注,每个目标占一行,每行固定五个数字:类别编号、中心点x、中心点y、宽度w、高度h。后面四个值全部归一化到0-1,不是像素值。

转换完成后的txt内容大概长这样:

1 0.500000 0.560000 0.600000 0.480000 0 0.240000 0.310000 0.120000 0.340000

第一列的0和1是类别编号,对应关系由类别清单里字符串的顺序决定。后面四个数分别是框中心点x、中心点y、框宽、框高,全部除以图片真实宽高。

从VOC的XML换算到YOLO的txt,就是下面四个公式:

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

逻辑上不复杂:先用xmin和xmax取平均算出框中心的像素位置,再除以图片宽度得到归一化值;框宽直接用xmax减xmin,再除以图片宽度。这里有个细节值得注意:除数用的是图片真实宽高,不是XML size节点里的值。归一化后的标注和原图分辨率无关,训练时不管输入尺寸怎么resize、怎么padding,标注值都不会跟着错。

两种格式的差异,用一张表对比更直观:

对比项VOC XMLYOLO txt
坐标类型绝对像素值归一化小数
类别表示字符串,如car、person整数编号,如0、1、2
框的定义xmin、ymin、xmax、ymax两点式x_center、y_center、w、h中心式
每张图的标注文件Annotations/xxx.xmllabels/xxx.txt
宽高信息来源size节点训练时由图片实际尺寸决定

这也是为什么很多人拿VOC数据直接训YOLO会失败:框架按txt解析,找不到labels目录,数据加载阶段就报错。

2.3 训练框架为什么只认txt:性能与增强逻辑

有人可能会问,YOLO为什么不直接支持XML,非要先转换一次?核心原因在数据增强。YOLOv5、YOLOv8训练时默认开mosaic增强,一张训练图由四张图拼接而成,每张子图还要随机缩放、裁剪、翻转。这过程中真实框的坐标要跟着图片一起变换,归一化坐标在这种场景下特别友好:图片resize后,标注只需要乘上新图尺寸;拼接后,再按子图所在位置加一个偏移量。整个过程都是简单乘除。

如果直接读XML,每做一次增强都要重新解析XML、处理命名空间、把字符串转成浮点数,数据加载性能会明显拖慢训练。而txt格式每行五个数字,按行读取再split,解析成本低一个数量级。另外VOC里的difficult、pose、truncated这些字段,对训练本身没有直接意义,txt这种极简格式正好把无用信息全丢掉。

3. voc_to_yolo.py实战:改三个参数就能跑通转换

这章进入正题。资源包里的voc_to_yolo.py就是格式转换的核心脚本,代码量不大,但几个参数的位置很讲究。

3.1 入口参数配置:classes顺序是第一个决策点

打开voc_to_yolo.py,顶部就是集中配置区,所有需要根据自己数据改的东西都在这里:

voc_root = 'VOCdevkit/VOC2007' # VOC数据集根路径 yolo_root = 'yolo_dataset' # YOLO格式输出根目录 classes = ['person', 'car', 'bicycle'] # 类别清单,顺序决定编号 image_ext = '.jpg' # 图片后缀,按实际数据集改

这段是我拿到脚本后第一个看的地方。三处改动里,classes顺序是优先级最高的一项。脚本会给classes里的名称依次编号,person就是0、car就是1。之后训练侧data.yaml里的names必须严格按照同一个顺序写。如果那边写的是['car', 'person', 'bicycle'],模型学出来的编号和真实类别就对不上,检测结果会张冠李戴。我一般会把classes单独保存成classes.txt,转换脚本和训练配置都从它读取,从源头避免两份文件手抄不一致。

3.2 转换主流程:解析XML、计算归一化坐标、写txt

脚本里最核心的函数,是完成“XML解析 + 坐标换算”这一段。常见实现长这样:

import os import glob import shutil import xml.etree.ElementTree as ET from PIL import Image def voc_to_yolo(xml_file, img_w, img_h): tree = ET.parse(xml_file) root = tree.getroot() lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in classes: # 不在类别清单里的目标直接跳过 continue if int(obj.find('difficult').text) > 0: # 难例跳过,防止噪声进训练 continue bnd = obj.find('bndbox') xmin = float(bnd.find('xmin').text) ymin = float(bnd.find('ymin').text) xmax = float(bnd.find('xmax').text) ymax = float(bnd.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"{classes.index(name)} {x_center:.6f} " f"{y_center:.6f} {w:.6f} {h:.6f}") return lines

函数接收一个XML路径和这张图真实宽高,遍历所有object节点,过滤掉难例后,按公式计算归一化坐标并拼成字符串。difficult=1的框大量出现在严重遮挡、极小目标这些场景里,特征不清晰,留到训练里只会让loss抖动更严重。判断逻辑放在归一化计算之前,直接continue,后续处理完全不碰它。

再往下是批处理主体,对Annotations目录里所有XML执行转换:

os.makedirs(os.path.join(yolo_root, 'images'), exist_ok=True) os.makedirs(os.path.join(yolo_root, 'labels'), exist_ok=True) xml_list = sorted(glob.glob(os.path.join(voc_root, 'Annotations', '*.xml'))) for xml_path in xml_list: filename = os.path.basename(xml_path).replace('.xml', image_ext) img_path = os.path.join(voc_root, 'JPEGImages', filename) with Image.open(img_path) as im: img_w, img_h = im.size lines = voc_to_yolo(xml_path, img_w, img_h) label_out = os.path.join(yolo_root, 'labels', os.path.basename(xml_path).replace('.xml', '.txt')) with open(label_out, 'w') as f: f.write('\n'.join(lines)) shutil.copy(img_path, os.path.join(yolo_root, 'images', filename))

这段做了三件事:从XML文件名推导出图片名、用PIL打开图片取真实宽高、把生成的txt写到labels目录,同时把原图复制到images目录。最终输出结构是images和labels两个兄弟目录,YOLOv5/v8的data.yaml可以直接指向它们。

运行转换:

python voc_to_yolo.py

跑完后检查yolo_dataset目录,images和labels下的文件数量应该一致。如果不一致,优先排查是不是存在没有标注的图片——自建数据集里经常混着没有目标的空图,它们的txt是0字节文件,训练时框架会忽略,不影响流程,但心里要有个数。

3.3 转换完立刻验:把txt画回原图是成本最低的正确性检查

转换完最忌讳直接开训练,等于开盲盒。我每次转完都要先用一个可视化脚本,把txt标注画回原图,肉眼确认框的位置:

import cv2 img_path = 'yolo_dataset/images/000001.jpg' label_path = 'yolo_dataset/labels/000001.txt' names = ['person', 'car', 'bicycle'] colors = [(0, 255, 0), (0, 165, 255), (255, 0, 0)] img = cv2.imread(img_path) h, w = img.shape[:2] with open(label_path) as f: for line in f: cls_id, xc, yc, bw, bh = map(float, line.split()) x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), colors[int(cls_id)], 2) cv2.putText(img, names[int(cls_id)], (x1, max(15, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors[int(cls_id)], 2) cv2.imwrite('check_000001.jpg', img)

脚本先把归一化坐标乘回图片真实宽高,还原成像素坐标再画框。重点看两点:框是否完整包住目标,类别名跟框里的物体是否匹配。如果出现所有框整体偏移,几乎可以断定是归一化时除错了宽高,或者XML里size节点和实际图片尺寸不一致。这一步十分钟能做完,但能让后面好几个小时的训练不白跑。

注意:画框脚本里的labels数组顺序,必须和voc_to_yolo.py里classes顺序一致,否则名字会错位。

4. cutdata.py分割训练集与测试集:用文件清单而不是搬图片

转换完成后,下一步就是数据集切分。voc2yolo包里配套的cutdata.py,解决的就是这个环节。

4.1 分割脚本的核心逻辑:生成文件清单而不是移动图片

数据切分常见两种做法。一种是把图片按目录物理拆开,复制到train和test两个文件夹,直观但浪费磁盘空间,以后要调比例还得重新复制。另一种是只生成三个txt文件,每个txt每行写一张图片的完整路径,训练时框架按txt里的清单去读图片。YOLO生态标准做法是后者,其实VOC官方ImageSets/Main里存放的本来就是txt清单。

cutdata.py的核心逻辑是按比例打乱图片名单,再分别写进三个txt:

import os import random image_dir = 'yolo_dataset/images' # 图片目录 label_dir = 'yolo_dataset/labels' # 标注目录,用于存在性检查 train_txt = 'train.txt' # 训练集清单输出 val_txt = 'val.txt' test_txt = 'test.txt' train_ratio = 0.8 val_ratio = 0.1 random_seed = 42 all_images = [f for f in os.listdir(image_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.seed(random_seed) random.shuffle(all_images) train_n = int(len(all_images) * train_ratio) val_n = int(len(all_images) * val_ratio) def write_list(path, imgs): with open(path, 'w') as f: for name in imgs: full = os.path.join(os.path.abspath(image_dir), name) label_path = os.path.join(label_dir, os.path.splitext(name)[0] + '.txt') if os.path.exists(label_path): f.write(full + '\n') else: print(f'[warn] missing label: {name}') write_list(train_txt, all_images[:train_n]) write_list(val_txt, all_images[train_n:train_n + val_n]) write_list(test_txt, all_images[train_n + val_n:])

参数说明:train_ratio和val_ratio都是0到1的小数,脚本用整数截断算出训练集和验证集份数,剩下的全部自动归到测试集。random_seed用来固定随机顺序,保证两次运行切分结果一致。write_list里加了一道标注文件存在性检查,图片没有对应txt的直接跳过,并在终端打印warn,这样可以提前暴露漏标注问题,而不是等训练时读到空路径才报错。

跑分割:

python cutdata.py

运行完得到train.txt、val.txt、test.txt三个文件。当你在YOLOv5或YOLOv8上训练自己的数据集时,data.yaml里的train和val直接指向这两个txt路径,test字段可以留空。

4.2 比例设置建议:不同数据规模不能照抄一个值

切分比例没有万能答案,数据规模不同,最优组合差别很大。我实际用下来的经验大致如下:

总样本量trainvaltest说明
小于200张0.70.150.15样本少,测试集比例不能太高,留足训练量
200到2000张0.80.10.1最常用组合,通用性强
2000张以上0.850.10.05测试集可以压缩,验证集保持0.1左右

train比例不是越高越好。训练集取到0.9以上,val和test样本太少,评估结果方差会很大,可能换一次随机种子精度就波动好几个点。小数据集上发现测试集影响判断时,把test从0.2降到0.1再重新切一次,这也是一种常见的“后悔药”。好处是整个过程不用重新转换格式,只改两个比例值重跑cutdata.py就行。

4.3 玄学时刻:随机种子解决不了跨平台漂移

固定random_seed只能保证同一环境下复现。换一台机器、换一个Python小版本,random.shuffle的底层实现可能有差异,切分结果照样不一样。常见现象就是:同事跑出来的train.txt和你跑出来的对不上,训练进度和验证曲线也不一致。

最稳妥的做法,是第一次切分后把清单备份:

cp train.txt train_backup.txt cp val.txt val_backup.txt cp test.txt test_backup.txt

后续不管谁重跑,都和这份备份比对。另一个方向是给cutdata.py加“只读模式”:第一次切分后把图片名序列化保存,之后再次运行直接读存档而不是重新shuffle。这样能彻底消灭“为什么训练集和别人不一样”的玄学问题。

提示:如果数据集路径移动过,不要手动改txt里的路径,直接把cutdata.py按照新路径重新跑一遍,比任何文本替换都可靠。

5. 避坑记录:转换与分割阶段最容易翻车的五个细节

这个资源我前后看过不少次,结合自己跑自建数据集的实际经历,把最容易翻车的五个点按“现象、原因、解决”拆开讲。

5.1 XML里的size和图片真实尺寸不一致,框全部偏移

现象:画框检查时发现所有框都比目标大一圈或往左上角偏,部分框完全罩不住物体。

原因:XML的size节点里记录的是标注当时的图片尺寸,但图片后来可能被压缩、旋转,或者标注软件本身写入错误。转换脚本如果直接用XML里的width和height做归一化分母,而实际图片尺寸和它不一致,还原画框时位置和大小全是错的。

解决:转换时用PIL或OpenCV重新打开图片,取img.size或img.shape作为真实宽高。voc_to_yolo.py里推荐的做法就是打开JPEG后取尺寸,再传入换算函数。改动只有一行,但能避免大批“看起来转换成功,画出来全是歪框”的问题。

5.2 difficult=1的难例没有过滤,训练loss一直抖动

现象:训练曲线看似收敛,但验证集精度始终上不去,小目标类别尤其明显。

原因:VOC标注里difficult=1表示目标难以辨识,如严重遮挡、极小尺寸、边界模糊。不过滤时这些框会被当成正常样本参与loss计算,模型从这些不清晰的框里学到的梯度基本是噪声。

解决:解析object节点时判断difficult字段,大于0直接跳过。部分任务还会把truncated=1的截断目标一并过滤,看业务需求。过滤判断要放在归一化计算之前,而不是生成行之后再删,这样整行逻辑更干净。

5.3 classes清单的编号顺序在训练配置里被改乱

现象:验证集上person的框全部被识别成car,其他类别也依次错位。

原因:voc_to_yolo.py里classes=['person','car','bicycle'],person编号0。但训练data.yaml里如果写成names=['car','person','bicycle'],框架把0号输出对应成car,于是所有person都背上了car的标签。这种错位在训练日志里很难直接看出来,因为loss照样下降、mAP曲线一样涨。

解决:建立单一数据源。classes写进classes.txt,转换脚本和data.yaml都从它生成。运行前把classes.txt内容复制到data.yaml的names字段,绝不在训练侧手工调整顺序。多人协作或换模型时,这一步最容易出岔子。

5.4 路径带中文、空格,或数据集移动后txt失效

现象:训练日志显示0 images,或者某个epoch开始图片读取报错,文件名看起来是对的但OpenCV打不开。

原因:Darknet系和OpenCV在部分平台对非ASCII路径支持很差;train.txt里写的是绝对路径,数据集换位置后路径全部失效;Windows环境下路径分隔符混用也会引发类似问题。

解决:数据集复制到纯英文、无空格的路径下。train.txt/val.txt统一由cutdata.py生成,不手写。txt里的路径分隔符统一用“/”,Linux和Windows下都能正常解析。数据集被移动过,直接重新跑一次cutdata.py,不要试图手改txt。

5.5 小样本类别全部落进测试集,训练集直接缺类

现象:训练过程一切正常,但验证时某个类的precision和recall全部为0,查完发现训练集里根本没有这类样本。

原因:cutdata.py用的是纯随机切分,当某个类别只有几张图时,随机性可能把这几张图全部推进测试集或验证集。

解决:切分前先按图片统计类别分布,识别小样本类。切分时按“图片包含的类别集合”做分层抽样,保证train、val、test三个集合都覆盖到所有类别。如果数据实在太小,可以改成只切train和val,test从源数据里单独挑几张,这也是常见变体。至少要在切完后再对各清单做一次类别统计,确认没有类别从训练集消失。

6. 训练之前别偷懒:一轮可视化抽检加类别分布检查,能省一整天排障

转换和切分都跑通后,我建议把训练前的检查动作固定成一套流程,每次都强制走完,不跳过。

先把第3章的单图画框脚本扩展成批量抽检。用随机数挑30张图,挨个把标注画回去:

import os import glob import random import cv2 names = ['person', 'car', 'bicycle'] imgs = glob.glob('yolo_dataset/images/*.jpg') random.seed(7) sample = random.sample(imgs, 30) for img_path in sample: label_path = (img_path.replace('/images/', '/labels/') .replace('.jpg', '.txt')) img = cv2.imread(img_path) h, w = img.shape[:2] with open(label_path) as f: for line in f: cls, xc, yc, bw, bh = map(float, line.split()) x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, names[int(cls)], (x1, max(15, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite('sample_' + os.path.basename(img_path), img)

30张样本足够覆盖常见错误,如果这批图里框的位置都和物体对得上,再做下一步。类别分布用一行awk就能统计:

cat yolo_dataset/labels/*.txt | awk '{print $1}' | sort | uniq -c

输出结果会显示每个类别编号在全部标注文件里出现多少次。拿这份统计结果,分别和train.txt、val.txt里的图片对应标注再比一次,重点确认小样本类没有漏进某个集合。我自己的固定顺序是:转换完先跑一张单图验证,再批量抽检30张,然后统计类别分布,最后执行cutdata.py切分,切完对三个清单各统计一次类别分布。四条检查都过了,才允许执行train命令。这套流程是从一次转完直接训练、跑了六个小时才发现类号错位的血泪经验里总结出来的。从那以后,我每次换数据集都强制走完这套检查再碰train,再也没有因为标注转换问题浪费过训练时间。资源包里的文档说明和运行结果截图,正好可以按这个顺序对照着验收,希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询