从VOC到YOLO:用LabelImg标注后,你的数据集还需要这几步处理才能喂给模型

刚用LabelImg标注完几百张猫狗图片,看着满屏的XML文件,是不是觉得离训练模型还差一口气?别急,这只是数据准备的开始。VOC格式的标注文件就像原材料,而YOLO需要的是精加工后的数据套餐——包括格式转换、数据集划分和质检三道工序。下面我们一步步拆解这个"数据预处理流水线"。

1. 解剖VOC XML文件:理解标注数据的DNA

打开任意一个XML文件,你会看到类似这样的结构:

<annotation>
    <filename>cat_001.jpg</filename>
    <size>
        <width>640</width>
        <height>480</height>
        <depth>3</depth>
    </size>
    <object>
        <name>cat</name>
        <bndbox>
            <xmin>120</xmin>
            <ymin>80</ymin>
            <xmax>350</xmax>
            <ymax>400</ymax>
        </bndbox>
    </object>
</annotation>

关键字段解析表:

字段 含义 YOLO转换时需要关注的点
width/height 图像原始尺寸 用于坐标归一化计算
xmin/ymin 边界框左上角坐标(绝对像素值) 需转换为相对坐标
xmax/ymax 边界框右下角坐标(绝对像素值) 需转换为边界框宽高
name 物体类别名称 需要映射为类别ID

提示:VOC格式使用绝对坐标,而YOLO需要的是归一化后的相对坐标(0-1之间),这是转换的核心难点。

2. VOC转YOLO格式:手写Python转换脚本实战

新建一个convert_voc_to_yolo.py文件,以下是完整转换代码:

import os
import xml.etree.ElementTree as ET

# 配置区
VOC_ANNOTATIONS_DIR = "VOC2007/Annotations"
YOLO_LABELS_DIR = "yolo_labels"
CLASSES = ["cat", "dog"]  # 必须与标注时的类别顺序一致

def convert(size, box):
    """将VOC坐标转换为YOLO坐标"""
    dw = 1. / size[0]
    dh = 1. / size[1]
    x = (box[0] + box[1]) / 2.0
    y = (box[2] + box[3]) / 2.0
    w = box[1] - box[0]
    h = box[3] - box[2]
    x = x * dw
    w = w * dw
    y = y * dh
    h = h * dh
    return (x, y, w, h)

if not os.path.exists(YOLO_LABELS_DIR):
    os.makedirs(YOLO_LABELS_DIR)

for xml_file in os.listdir(VOC_ANNOTATIONS_DIR):
    if not xml_file.endswith('.xml'):
        continue
    
    tree = ET.parse(os.path.join(VOC_ANNOTATIONS_DIR, xml_file))
    root = tree.getroot()
    
    txt_file = open(os.path.join(YOLO_LABELS_DIR, xml_file.replace('.xml', '.txt')), 'w')
    
    size = root.find('size')
    w = int(size.find('width').text)
    h = int(size.find('height').text)
    
    for obj in root.iter('object'):
        cls = obj.find('name').text
        if cls not in CLASSES:
            continue
            
        cls_id = CLASSES.index(cls)
        xmlbox = obj.find('bndbox')
        b = (float(xmlbox.find('xmin').text), float(xmlbox.find('xmax').text),
             float(xmlbox.find('ymin').text), float(xmlbox.find('ymax').text))
        bb = convert((w, h), b)
        txt_file.write(f"{cls_id} {' '.join([str(a) for a in bb])}\n")
    
    txt_file.close()

关键操作说明:

  1. 先创建yolo_labels文件夹存放转换结果
  2. CLASSES列表必须与标注时的类别顺序完全一致
  3. 转换后的txt文件每行格式:类别ID x_center y_center width height

常见踩坑点:

  • 忘记创建输出目录导致程序报错
  • 类别顺序与训练时不一致导致预测混乱
  • 未处理中文路径导致的编码错误

3. 数据集科学划分:防止模型"偏科"

随机划分数据集是初学者常犯的错误。正确的做法是:

import os
import random
from sklearn.model_selection import train_test_split

# 配置区
IMAGE_DIR = "VOC2007/JPEGImages"
LABEL_DIR = "yolo_labels"
TEST_RATIO = 0.2
VAL_RATIO = 0.1  # 占训练集的比例

# 获取所有图片文件名(不带后缀)
all_images = [f.split('.')[0] for f in os.listdir(IMAGE_DIR) if f.endswith('.jpg')]

# 分层划分:保证各类别比例一致
train_val, test = train_test_split(all_images, test_size=TEST_RATIO, random_state=42)
train, val = train_test_split(train_val, test_size=VAL_RATIO, random_state=42)

def write_file_list(file_list, output_file):
    with open(output_file, 'w') as f:
        for name in file_list:
            f.write(f"{IMAGE_DIR}/{name}.jpg\n")

write_file_list(train, "train.txt")
write_file_list(val, "val.txt")
write_file_list(test, "test.txt")

划分策略对比表:

方法 优点 缺点 适用场景
完全随机 实现简单 可能破坏类别分布 数据分布均匀时
分层抽样 保持类别比例 需要预先统计类别 类别不平衡时
按时间划分 符合真实场景时序 可能引入时间偏差 时序相关数据
按目录划分 管理方便 需提前规划目录结构 已有明确分类的数据

注意:划分完成后建议检查每个集合的类别分布,可使用matplotlib绘制分布直方图验证。

4. 标签质量检查:揪出标注中的"害群之马"

即使是最仔细的标注也难免出错,常见问题包括:

  • 坐标越界:边界框超出图像范围
  • 尺寸异常:标注框过大或过小
  • 类别错误:猫标成了狗
  • 漏标多标:该标的没标或重复标注

用这个脚本快速检测坐标问题:

import cv2
import os

def check_labels(image_dir, label_dir):
    for label_file in os.listdir(label_dir):
        if not label_file.endswith('.txt'):
            continue
            
        image_path = os.path.join(image_dir, label_file.replace('.txt', '.jpg'))
        img = cv2.imread(image_path)
        if img is None:
            print(f"警告:找不到对应图片 {image_path}")
            continue
            
        h, w = img.shape[:2]
        
        with open(os.path.join(label_dir, label_file)) as f:
            for line in f.readlines():
                parts = line.strip().split()
                if len(parts) != 5:
                    continue
                    
                _, x, y, bw, bh = map(float, parts)
                # 转换回像素坐标检查
                x_pixel = int(x * w)
                y_pixel = int(y * h)
                bw_pixel = int(bw * w)
                bh_pixel = int(bh * h)
                
                if (x_pixel < 0 or y_pixel < 0 or 
                    x_pixel + bw_pixel > w or 
                    y_pixel + bh_pixel > h):
                    print(f"异常文件:{label_file} 坐标超出图像范围")

check_labels("VOC2007/JPEGImages", "yolo_labels")

可视化检查更直观(需要安装opencv):

def visualize_random_samples(image_dir, label_dir, num_samples=5):
    import random
    samples = random.sample(os.listdir(image_dir), num_samples)
    
    for img_file in samples:
        if not img_file.endswith('.jpg'):
            continue
            
        img_path = os.path.join(image_dir, img_file)
        label_path = os.path.join(label_dir, img_file.replace('.jpg', '.txt'))
        
        img = cv2.imread(img_path)
        h, w = img.shape[:2]
        
        with open(label_path) as f:
            for line in f.readlines():
                cls_id, x, y, bw, bh = map(float, line.strip().split())
                # 转换为像素坐标
                x1 = int((x - bw/2) * w)
                y1 = int((y - bh/2) * h)
                x2 = int((x + bw/2) * w)
                y2 = int((y + bh/2) * h)
                
                # 绘制边界框
                cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
                cv2.putText(img, CLASSES[int(cls_id)], (x1, y1-10), 
                           cv2.FONT_HERSHEY_SIMPLEX, 0.9, (36,255,12), 2)
        
        cv2.imshow('Preview', img)
        cv2.waitKey(0)
        cv2.destroyAllWindows()

5. 高级技巧:数据增强与自动修正

发现标注问题后,除了手动修正,还可以用这些自动化工具:

  • CVAT:开源标注工具,支持智能修正
  • LabelMe:具有多边形标注和自动边界功能
  • Albumentations:直接在YOLO格式上进行数据增强

示例增强代码:

import albumentations as A

transform = A.Compose([
    A.HorizontalFlip(p=0.5),
    A.RandomBrightnessContrast(p=0.2),
    A.RandomSnow(p=0.1),
], bbox_params=A.BboxParams(format='yolo'))

def augment_image(image, bboxes):
    transformed = transform(image=image, bboxes=bboxes)
    return transformed['image'], transformed['bboxes']

处理完所有步骤后,你的数据集目录结构应该是这样的:

dataset_yolo/
├── images/
│   ├── train/
│   ├── val/
│   └── test/
├── labels/
│   ├── train/
│   ├── val/
│   └── test/
├── train.txt
├── val.txt
└── test.txt

最后提醒:永远保留原始VOC格式的标注文件,这是你的"数据底片",所有转换都应通过脚本可重现。在实际项目中,我习惯把整个预处理流程封装成Makefile,这样只需一个make all命令就能从原始标注生成训练就绪的数据集。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐