在目标检测任务中,数据标注格式的差异常常让开发者头疼。YOLO格式和VOC格式是两种最常见的标注格式,它们各有优缺点。YOLO格式采用简单的txt文件存储标注信息,每行对应一个目标的类别和归一化后的坐标,这种格式体积小、加载快,特别适合YOLO系列模型的训练。而VOC格式则采用XML文件存储更丰富的元信息,包括图像尺寸、目标位置、类别等,这种格式可读性强,被更多标注工具支持。
实际项目中经常会遇到这样的场景:你从公开数据集下载的数据是VOC格式,但需要训练YOLOv8模型;或者你的标注团队习惯用LabelImg等工具生成VOC格式标注,但训练环节需要YOLO格式。这时候就需要进行格式转换。我最近在一个工业质检项目中就遇到了这个问题 - 客户提供的原始数据是VOC格式,而我们的训练框架基于YOLOv8,不得不进行格式转换。
YOLO格式的标注文件是纯文本文件,与图像文件同名但扩展名为.txt。每个标注文件对应一张图像中的所有目标。例如:
code复制0 0.4671875 0.4854166666666667 0.128125 0.14166666666666666
1 0.728125 0.5729166666666666 0.08125 0.10833333333333334
每行表示一个目标,包含5个字段:
这种格式非常紧凑,但缺乏图像本身的元数据信息。
VOC格式采用XML文件存储标注,包含更丰富的信息。一个典型的VOC标注文件如下:
xml复制<annotation>
<folder>images</folder>
<filename>000001.jpg</filename>
<size>
<width>800</width>
<height>600</height>
<depth>3</depth>
</size>
<object>
<name>person</name>
<bndbox>
<xmin>374</xmin>
<ymin>291</ymin>
<xmax>450</xmax>
<ymax>375</ymax>
</bndbox>
</object>
</annotation>
VOC格式不仅包含目标位置(以像素为单位的绝对坐标),还包含图像尺寸、目标类别名称等元数据。这种格式更易读,但文件体积更大。
首先需要准备好YOLO格式的数据集。以COCO128数据集为例,典型的目录结构如下:
code复制coco128/
├── images/
│ └── train2017/
│ ├── 000000000009.jpg
│ └── ...
└── labels/
└── train2017/
├── 000000000009.txt
└── ...
我们需要创建一个对应的VOC格式目录结构:
code复制my_datasets/
├── Annotations/
├── images/
└── ImageSets/
转换的核心是将YOLO的归一化坐标转换为VOC的绝对像素坐标。关键步骤如下:
以下是核心转换函数:
python复制from xml.dom.minidom import Document
import os
import cv2
def makexml(picPath, txtPath, xmlPath):
"""将YOLO格式转换为VOC格式"""
files = os.listdir(txtPath)
for name in files:
# 读取图像获取尺寸
img = cv2.imread(picPath + name[:-4] + '.jpg')
height, width, _ = img.shape
# 创建XML文档结构
doc = Document()
annotation = doc.createElement("annotation")
doc.appendChild(annotation)
# 添加图像基本信息
size = doc.createElement("size")
for tag, val in [('width', width), ('height', height), ('depth', 3)]:
elem = doc.createElement(tag)
elem.appendChild(doc.createTextNode(str(val)))
size.appendChild(elem)
annotation.appendChild(size)
# 解析YOLO标注文件
with open(txtPath + name) as f:
lines = f.readlines()
for line in lines:
data = line.strip().split()
class_id, x_center, y_center, w, h = map(float, data[:5])
# 反归一化计算坐标
x_center *= width
y_center *= height
w *= width
h *= height
xmin = int(x_center - w/2)
ymin = int(y_center - h/2)
xmax = int(x_center + w/2)
ymax = int(y_center + h/2)
# 创建object节点
obj = doc.createElement("object")
for tag, val in [('name', class_id),
('bndbox', {'xmin':xmin, 'ymin':ymin,
'xmax':xmax, 'ymax':ymax})]:
if tag == 'bndbox':
box = doc.createElement(tag)
for k, v in val.items():
elem = doc.createElement(k)
elem.appendChild(doc.createTextNode(str(v)))
box.appendChild(elem)
obj.appendChild(box)
else:
elem = doc.createElement(tag)
elem.appendChild(doc.createTextNode(str(val)))
obj.appendChild(elem)
annotation.appendChild(obj)
# 保存XML文件
with open(xmlPath + name[:-4] + '.xml', 'w') as f:
doc.writexml(f, indent='\t', newl='\n', encoding='utf-8')
转换完成后,通常需要将数据集划分为训练集、验证集和测试集。可以使用以下函数:
python复制import random
import os
def split_dataset(xml_path, output_path, trainval_percent=0.9, train_percent=0.9):
"""划分训练集、验证集和测试集"""
xml_files = os.listdir(xml_path)
num = len(xml_files)
indices = list(range(num))
tv = int(num * trainval_percent)
tr = int(tv * train_percent)
trainval = random.sample(indices, tv)
train = random.sample(trainval, tr)
# 创建输出目录
os.makedirs(output_path, exist_ok=True)
# 写入划分文件
for phase, names in [('trainval', trainval),
('train', train),
('val', [x for x in trainval if x not in train]),
('test', [x for x in indices if x not in trainval])]:
with open(f'{output_path}/{phase}.txt', 'w') as f:
for i in names:
f.write(xml_files[i][:-4] + '\n')
有时候我们也需要将VOC格式转回YOLO格式,比如在使用某些数据增强工具后。这个转换过程需要注意坐标归一化。
关键步骤:
python复制import xml.etree.ElementTree as ET
def voc2yolo(xml_path, txt_path, class_names):
"""将VOC格式转换为YOLO格式"""
tree = ET.parse(xml_path)
root = tree.getroot()
size = root.find('size')
width = int(size.find('width').text)
height = int(size.find('height').text)
lines = []
for obj in root.iter('object'):
cls = obj.find('name').text
if cls not in class_names:
continue
cls_id = class_names.index(cls)
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)
# 转换为YOLO格式
x_center = (xmin + xmax) / 2 / width
y_center = (ymin + ymax) / 2 / height
w = (xmax - xmin) / width
h = (ymax - ymin) / height
lines.append(f"{cls_id} {x_center} {y_center} {w} {h}\n")
# 写入YOLO格式文件
with open(txt_path, 'w') as f:
f.writelines(lines)
在实际项目中,路径处理常常是第一个坑。我建议:
os.path模块处理路径,而不是直接拼接字符串python复制import os
from pathlib import Path
# 更安全的路径处理方式
base_dir = Path(__file__).parent
image_dir = base_dir / "images"
image_dir.mkdir(exist_ok=True) # 自动创建目录
当YOLO的类别索引与VOC的类别名称不一致时,需要建立映射关系。我通常使用一个YAML配置文件来管理:
yaml复制# dataset.yaml
names:
0: person
1: car
2: bicycle
然后在代码中加载这个配置:
python复制import yaml
with open("dataset.yaml") as f:
config = yaml.safe_load(f)
class_names = config["names"]
处理大规模数据集时,转换速度可能成为瓶颈。可以考虑:
lxmlpython复制from multiprocessing import Pool
def process_single(args):
"""包装单文件处理函数用于多进程"""
makexml(*args)
if __name__ == "__main__":
# 准备参数列表
args_list = [(picPath, f, xmlPath) for f in os.listdir(txtPath)]
# 使用4个进程并行处理
with Pool(4) as p:
p.map(process_single, args_list)
一个完整的格式转换项目通常包含以下文件和目录:
code复制yolo2voc/
├── configs/
│ └── dataset.yaml
├── scripts/
│ ├── convert.py
│ └── split.py
├── src/
│ ├── yolo2voc.py
│ └── voc2yolo.py
├── input_data/
│ ├── yolo_format/
│ └── voc_format/
└── output_data/
├── yolo_format/
└── voc_format/
这种结构清晰分离了配置、代码和数