尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

VOC转YOLO格式数据集实战:坐标归一化与训练集分割避坑指南

发布时间:2026/9/24 18:27:58

资讯中心
01
ARTICLE

VOC转YOLO格式数据集实战:坐标归一化与训练集分割避坑指南

VOC转YOLO格式数据集实战:坐标归一化与训练集分割避坑指南
简介一套面向目标检测数据集预处理的Python源码包用于将VOC格式标注转换成YOLO格式并按比例分割训练集与测试集适合计算机、电子信息、数学等专业学生用于课程设计、期末大作业或毕业设计。压缩包共2个文件均为Python脚本分别实现VOC转YOLO的标注转换与数据集切分功能整体大小仅2KB结构精简、逻辑清晰便于直接研读和二次修改。代码采用参数化编程关键路径和划分比例均可方便调整注释明细且已附运行结果并测试通过可帮助初学者快速理解两种标注格式的对应关系与数据划分流程。目前已有560人学习下载具有一定的实践参考价值。通过这份资源读者能掌握数据集格式转换的完整实现思路并基于脚本快速生成自己的YOLO训练集与测试集大幅节省手动整理数据的时间为目标检测模型训练扫清数据准备障碍。1. 从 VOC 到 YOLO一次格式转换为什么能卡住整个训练流程从网上下载了一个带 VOC 格式标注的数据集打开一看是 Pascal 的标准目录结构JPEGImages 放图片Annotations 放 xmlImageSets/Main 里一个 txt 是图片清单。直接把这种数据丢给 YOLO 训练不到两分钟就报错——YOLO 要求的是一张图配一个同名 txt每行一个标注类别用数字 id坐标是归一化后的中心点和宽高。把 VOC 格式数据集转换成 YOLO 格式最难的不是写解析代码而是想清楚坐标怎么换算、类别表按什么顺序定、训练集和测试集怎么划分才不翻车。这篇文章按我实际跑通的处理顺序来讲所有脚本都在正文里复制下来改改路径就能用每段代码后面都写了参数含义和失败时该看什么。2. VOC 和 YOLO 两种标注格式的真正差异坐标、目录、类别名2.1 VOC 标注是给人看的YOLO 标注是给 loss 算的VOC 标注保存在 xml 里一个文件描述图片尺寸、物体类别名person、car 这类字符串以及 bndbox 的 xmin/ymin/xmax/ymax单位是像素。好处是人眼直接读得懂坏处是 YOLO 的 loss 计算根本不认识这种结构。YOLO 的标注是一行纯文本class_id x_center y_center width height四个坐标值全部归一化到 [0,1] 区间类别只能是整数索引。这个差异迫使你必须写转换逻辑而不是把文件改个后缀就能蒙混过关。目录结构上也有讲究。VOC 的标注和图片分两个目录存放靠文件名关联YOLO 常见的数据集目录是 images/train、labels/train 这种按划分分好的文件夹或者一个 filelist 文件指定路径。labelimg 默认保存成 VOC 的 xml如果你看到「labelimg 打标完 yolo 格式的标」这类说法实际是在 labelimg 的保存类型里选了 YOLO 模式它输出的是 txt和 VOC 模式完全是两个不同的按钮别选混。2.2 类别映射表转换前先把你数据集里的类别名排好序转换脚本需要一个类别列表列表的顺序就是最终的数字 id。常见做法是先收集 xml 里所有出现过的 name去重后按字母或按业务语义排成 classes.txt一行一个类别名。索引从 0 开始这个顺序一旦定下来训练时 data.yaml 里的 names 顺序必须完全一致否则训练过程正常、loss 也在降但推理结果完全是错位的而且这种错位几乎不会被训练日志暴露。类别名xml 中的 nameYOLO class_idclasses.txt 行person0personcar1cardog2dog这张表看着简单实际是整个转换流程里最不该省的一步。转换前我会先单独写一个统计脚本把 Annotations 下所有 xml 里出现过的类别名全部收集出来如果发现数据集的类别比你预期的多要么补类别要么在转换时丢弃掉这些不关心的类别。2.3 转换前先体检用一小段脚本检查 xml 的完整性很多下载的数据集里有空标注、缺 size、或者 bndbox 字段写错这类脏数据在转换时才暴露最费时间。我一般会先跑一段体检脚本把有问题的 xml 一次性列出来提前决定哪些要修、哪些直接排除import os import xml.etree.ElementTree as ET def check_voc_xml(ann_dir): problems [] for f in sorted(os.listdir(ann_dir)): if not f.endswith(.xml): continue path os.path.join(ann_dir, f) try: root ET.parse(path).getroot() size root.find(size) if size is None or size.find(width) is None or size.find(height) is None: problems.append((f, missing size)) continue objs root.findall(object) if len(objs) 0: problems.append((f, no object)) for obj in objs: box obj.find(bndbox) if box is None or box.find(xmin) is None: problems.append((f, bad bndbox)) except ET.ParseError: problems.append((f, parse error)) return problems if __name__ __main__: for f, reason in check_voc_xml(Annotations): print(f, reason)这段逻辑不复杂遍历 Annotations 目录里的所有 xml逐个检查是否有 size、是否有 object、bndbox 是否完整。出现 parse error 说明文件本身损坏通常是从压缩包解压时中断导致的重新解压即可。体检输出的问题列表是你决定「哪些 xml 要修、哪些样本直接丢弃」的依据。别在转换脚本里默默吞掉这些异常否则后面训练样本数对不上排查起来更难受。3. 写转换脚本XML 解析、坐标归一化与同名 txt 生成3.1 转换脚本主干把 bndbox 像素坐标换算成 YOLO 归一化坐标转换脚本核心就做三件事解析 xml、把像素 bndbox 转成归一化中心点加宽高、把类别名映射成整数 id 写入对应 txt。下面的脚本保存为 voc2yolo.py 即可#!/usr/bin/env python3 # 将 VOC xml 标注转换为 YOLO 格式 txt # 用法: python voc2yolo.py --xml_dir Annotations --out_dir labels --classes classes.txt import os import argparse import xml.etree.ElementTree as ET def load_classes(classes_path): with open(classes_path) as f: return [line.strip() for line in f if line.strip()] def convert_one(xml_path, classes): tree ET.parse(xml_path) root tree.getroot() size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in classes: print(f[warn] unknown class {name} in {xml_path}, skip) continue cls_id classes.index(name) 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) # 中心点坐标与宽高都除以图片宽高得到 0~1 的归一化值 x_center ((xmin xmax) / 2) / img_w y_center ((ymin ymax) / 2) / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h # 越界保护人工标注时框出图像边界是常见事不兜底会被训练流程丢弃 x_center min(max(x_center, 0.0), 1.0) y_center min(max(y_center, 0.0), 1.0) w min(max(w, 0.0), 1.0) h min(max(h, 0.0), 1.0) lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) return lines def main(): ap argparse.ArgumentParser() ap.add_argument(--xml_dir, requiredTrue, helpVOC Annotations 目录) ap.add_argument(--out_dir, requiredTrue, help转换结果输出目录) ap.add_argument(--classes, requiredTrue, help类别列表文件一行一个) args ap.parse_args() classes load_classes(args.classes) os.makedirs(args.out_dir, exist_okTrue) for f in os.listdir(args.xml_dir): if not f.endswith(.xml): continue lines convert_one(os.path.join(args.xml_dir, f), classes) txt_name os.path.splitext(f)[0] .txt with open(os.path.join(args.out_dir, txt_name), w) as out: out.write(\n.join(lines)) if __name__ __main__: main()参数说明--xml_dir 指向 VOC 的 Annotations 目录--out_dir 是转换结果的输出目录脚本会自动创建--classes 指向一行一个类别名的 classes.txt顺序就是最终训练时的 id。convert_one 函数里先取 size 拿到图片宽高再对每个 object 取出 bndbox。计算公式本质是「像素坐标除以图像宽高」中心点是 (xminxmax)/2 再除。越界保护那条 min/max 看着不起眼实际是数据集里最常遇到的坑不兜底的话 YOLO 训练时要么警告要么直接丢弃这些框框一多精度就上不去。3.2 三个容易翻车的细节空标注、缺图、扩展名大小写第一个是空标注。某张图没有任何物体时VOC 的 xml 里没有 object 节点上面的脚本会写出一个空 txt 文件。这个文件不能删YOLO 训练时代码按名字找 label文件缺失和文件为空是两回事空文件表示「这张图确实没有目标」缺失会被部分训练流程当作错误样本跳过。所以转换脚本要把每张图对应的 txt 都生成出来内容为空也行。第二个是缺图。转换前确认 JPEGImages 里的图片文件名和 Annotations 里的 xml 文件名一一对应用集合差集查一遍最直接import os img_dir JPEGImages ann_dir Annotations imgs {os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.endswith((.jpg, .jpeg, .png))} anns {os.path.splitext(f)[0] for f in os.listdir(ann_dir) if f.endswith(.xml)} print(xml 无图:, anns - imgs) print(图无 xml:, imgs - anns)第三个是图片扩展名。真实下载的数据集里 jpg 和 .JPG 混用、png 和 .PNG 混用的情况非常多凡是写过滤逻辑的地方都要考虑大小写否则分割阶段会莫名其妙丢一批图。上面这段查差集的代码也能顺带帮你发现哪些图连 xml 都没有这类图要么补标注要么在分割时排除掉。3.3 跑通最小命令从 VOCdevkit 到 YOLO labels 只需要一条命令常见做法是先把数据集整理成 VOCdevkit/VOC2007 这种目录然后执行python voc2yolo.py \ --xml_dir VOCdevkit/VOC2007/Annotations \ --out_dir VOCdevkit/VOC2007/labels \ --classes classes.txt跑完后在 labels 目录里随机挑一个 txt 打开预期看到类似0 0.531250 0.421900 0.082600 0.127000的内容。如果所有行都正常且行数和 xml 里的 object 数量一致转换这一步就算完成了。这里说明一下为什么要把 labels 单独放一个目录而不动原始 Annotationlabels 独立出来后面分割脚本直接按图片文件名找对应 txt逻辑清楚出问题时重新转换也不影响原始标注。xml 保留着就是你的后悔药。这个阶段我还会顺手统计类别分布确认没有某个类别数量少得离谱少到训练集里一个都分不到awk {print $1} VOCdevkit/VOC2007/labels/*.txt | sort | uniq -c | sort -rn输出第一列是类别 id第二列是该类别目标数。转换结果对不对这个命令一眼就能看出异常。4. 分割训练集与测试集随机种子、目录结构、空标签一个都不能少4.1 分割策略按图片划分、随机打乱、别按文件名排序训练集、验证集、测试集的分割常见比例是 8:1:1。train 定为 80%val 和 test 各 10%。val 在 yolov5/yolov8 训练里是必需品每个 epoch 结束都要在 val 上算 mAP 决定是否保存权重test 只在最终评估时用。如果你的任务对最终精度的置信区间敏感test 比例可以提到 15%但别低于 5%否则最终评估波动会很大。分割的底线是随机打乱。按文件名排序取前 80% 是典型的错误做法很多数据集按拍摄时间或场景命名排序后某个时间段、某个光照条件下的图片可能全进了训练集或全进了测试集val 上的指标虚高一到真实场景就翻车。随机打乱能缓解这种分布偏移注意是缓解不是消除数据本身分布失衡时该做的还有按场景去重。4.2 分割脚本随机打乱、按比例划分、搬运图片和标签下面这份保存为 split_dataset.py完成随机打乱、按比例划分、复制图片和标签、写 filelist 四件事#!/usr/bin/env python3 # 按 train/val/test 划分 YOLO 数据集 # 用法: python split_dataset.py --img_dir JPEGImages --lbl_dir labels --out_dir split import os import random import shutil import argparse from pathlib import Path def split(img_dir, lbl_dir, out_dir, train_ratio, val_ratio, seed): # 固定随机种子保证多次运行结果一致 random.seed(seed) exts {.jpg, .jpeg, .png} images [p for p in Path(img_dir).iterdir() if p.suffix.lower() in exts] random.shuffle(images) n_total len(images) n_train int(n_total * train_ratio) n_val int(n_total * val_ratio) parts { train: images[:n_train], val: images[n_train:n_train n_val], test: images[n_train n_val:], } for part, imgs in parts.items(): # 目标结构: out/train/images、out/train/labelsyolov5/v8 都认这个 dst_img_dir Path(out_dir) / part / images dst_lbl_dir Path(out_dir) / part / labels dst_img_dir.mkdir(parentsTrue, exist_okTrue) dst_lbl_dir.mkdir(parentsTrue, exist_okTrue) with open(Path(out_dir) / f{part}.txt, w) as fp: for img in imgs: shutil.copy2(img, dst_img_dir / img.name) lbl Path(lbl_dir) / (img.stem .txt) if lbl.exists(): shutil.copy2(lbl, dst_lbl_dir / lbl.name) else: # 缺标签时生成空文件保证训练流程不会因缺文件中断 (dst_lbl_dir / (img.stem .txt)).touch() fp.write(str(dst_img_dir / img.name) \n) if __name__ __main__: ap argparse.ArgumentParser() ap.add_argument(--img_dir, requiredTrue, help图片目录) ap.add_argument(--lbl_dir, requiredTrue, help标签目录) ap.add_argument(--out_dir, requiredTrue, help分割结果输出目录) ap.add_argument(--train_ratio, typefloat, default0.8, help训练集比例) ap.add_argument(--val_ratio, typefloat, default0.1, help验证集比例) ap.add_argument(--seed, typeint, default42, help随机种子) args ap.parse_args() split(args.img_dir, args.lbl_dir, args.out_dir, args.train_ratio, args.val_ratio, args.seed)参数说明--img_dir 指向所有图片所在的目录--lbl_dir 指向第 3 章转换出来的 labels 目录--out_dir 是分割结果输出目录--train_ratio 和 --val_ratio 控制比例剩下的全部归 test。random.seed(seed) 必须放在 shuffle 之前这样同一份数据无论跑多少次、在哪台机器跑划分结果都一致。输出采用 images/ 和 labels/ 同级目录的结构yolov5/v8 的 datasets 配置直接指到 train 和 val 目录就能用。filelisttrain.txt、val.txt、test.txt里写的是复制后的绝对路径覆盖需要读路径清单的训练工具。4.3 分割后的检查清单数量、为空、缺配对分割完第一件事不是开训练是把几个数对清楚检查项期望结果排查手段train/val/test 图片数量与 8:1:1 一致统计各目录文件数或看生成的 txt 行数labels 目录文件数等于 images 文件数两侧分别统计比对总数train.txt 每行对应图片文件存在且能打开写个 os.path.exists 批量验证空标签比例不高于预期统计 txt 中 0 字节文件数量这四类问题全踩过之后我的经验是做一张这样的清单在分割后逐项打勾五分钟能验证完。常见翻车场景全集中在这几项比例写反了、val 和 test 分错边、空标签缺失导致训练 epoch 中断。5. 转换与分割避坑实录现象、原因、解决办法5.1 类别错位训练收敛了预测框永远是错的名字现象转换、分割都正常yolov8 也能训练loss 也在降但是测试图片上跑出来明明框住一辆车label 却显示 person。原因XML 里的类别名是字符串转换时用 classes.txt 顺序映射成数字 id。如果后来改了 classes.txt 的顺序或者 data.yaml 的 names 顺序和转换时不一致数字 id 对应的类别已经变了。模型学到的是「id 和像素模式的映射」id 顺序一错语义全错但训练流程完全不会报错这是最隐蔽的错误。解决转换时必须把 classes.txt 固定下来data.yaml 的 names 直接复制同一份内容不要手打。转换后抽查 10 个 txt肉眼确认每个类别的 id 符合预期再开始训练。5.2 扩展名大小写不一致导致找图失败现象分割脚本跑完发现图片少了一批或者训练时报找不到文件打开目录一看一半图片是 .JPG。原因Windows 上解压或迁移文件时扩展名大小写被改了。脚本用 endswith((.jpg, .jpeg, .png)) 过滤时.JPG 进不来。解决遍历时全部转小写判断或者用 Path.suffix.lower()。凡是处理真实下载的数据集扩展名判断一律先 lower算是这一类问题的通用解。5.3 归一化坐标超过 1训练警告一堆精度上不去现象训练日志里频繁出现坐标越界的警告或者某些目标在最终评估里 mAP 特别低。原因标注时框拉到图片外面xmax 大于图片宽度导致 w 大于 1或者 xmin 为负导致中心点为负。XML 里这类脏数据是常态不是罕见情况。解决转换脚本里对 x_center、y_center、w、h 做 min/max 截断到 [0,1]。截断的前提是边框只有小范围越界如果越界严重说明标注质量差该丢就丢别硬留。5.4 分割后标签和图片对不上train.txt 里的图没有标签现象训练时读取到某张图去找对应 label 文件不存在进程中断。原因有些图片在 VOC 里就没有 xml转换脚本只处理了 xml这些图自然没有 txt。分割脚本里如果没有容错缺失就带进训练集。解决分割时对每个图片检查标签是否存在缺失时创建空 txt 并打印警告。空标注在 YOLO 里是合法的训练会正常跳过。这样既保证流程不断也不会把「没标注」错当成其它异常。5.5 随机种子没固定同一份数据两次划分结果完全不同现象换台机器重新跑前一次 val 上 mAP 0.87这次变成 0.81排查半天发现是划分变了。原因random 模块默认以系统时间初始化shuffle 时机不同划分集合就不同实验之间没有可比性。解决固定 seed并在脚本输出里把 seed 一并打印。之后的实验凡是涉及随机划分都把这个 seed 记在实验笔记里。随机种子就是这里的后悔药改数据后想复现实验靠它兜底。6. 用反向画框验证转换结果训练前最后一道检查转换加分割都做完开训练之前画一遍框把标签反向画回原图肉眼扫一遍。这一步能同时发现类别错位、坐标解析错误、宽高比不对这三类问题from PIL import Image, ImageDraw def draw_yolo(img_path, txt_path, classes_path, out_path): img Image.open(img_path).convert(RGB) w, h img.size draw ImageDraw.Draw(img) with open(classes_path) as f: classes [line.strip() for line in f if line.strip()] with open(txt_path) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue cls_id int(parts[0]) xc, yc, bw, bh map(float, parts[1:]) # 归一化坐标反算回像素坐标画框用 x1 (xc - bw / 2) * w y1 (yc - bh / 2) * h x2 (xc bw / 2) * w y2 (yc bh / 2) * h draw.rectangle([x1, y1, x2, y2], outlinered, width3) draw.text((x1, max(0, y1 - 12)), classes[cls_id], fillred) img.save(out_path) print(saved, out_path) if __name__ __main__: draw_yolo(split/train/images/000001.jpg, split/train/labels/000001.txt, classes.txt, check_000001.jpg)这段代码把 txt 里的归一化坐标乘回图片宽高就能得到像素框。如果画出来的框和物体位置对不上多半是转换脚本里除法用错了对象如果类别名显示不对就是 classes 顺序问题如果框明显偏移但 xml 是对的去查 xml 里的 size 和实际图片尺寸是否一致。画框抽查通过后用最小配置跑一次 yolov8 训练三个 epoch 就够目的是确认数据管道通畅不是追求精度# data.yaml path: split train: images/train val: images/val names: 0: person 1: car 2: dogyolo detect train datadata.yaml modelyolov8n.pt epochs3 imgsz640 batch8三个 epoch 后看训练日志里每张图的 loss 是否逐轮下降、val 阶段是否正常跑完。这一步相当于给整个转换流程做一次端到端验证。我自己最惨的一次教训是跳过了画框验证直接训练了 50 轮最后发现类别 id 错位从头再来。转换格式这件事代码本身不复杂复杂的是验证闭环。现在每换一个数据集我都会把抽查画框的结果存进实验记录后续排查能省下大量时间。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。