Skip to main content
Blog
Tutorial
Aug 19, 20263 min

Pascal VOC XML to YOLO TXT: Complete Migration Guide (with Python Script)

Migrate legacy Pascal VOC XML annotations to normalized YOLO format for YOLOv8, YOLOv11, and YOLOv12. Includes coordinate math, XML parsing, and a batch Python converter.

The Pascal VOC format (XML files with <bndbox> tags) has been a cornerstone of computer vision research for over fifteen years.

However, modern deep learning frameworks — including Ultralytics YOLO (v8, v11, v12) — require per-image .txt files with normalized center coordinates.

This guide provides the exact coordinate normalization formulas, a breakdown of VOC XML tags, and a zero-dependency Python script to convert entire directories of XML files into training-ready YOLO format.

Need an instant in-browser conversion? Try our Pascal VOC to YOLO Converter Tool.


1. Pascal VOC XML vs. YOLO TXT Structure

Pascal VOC XML Example (image_001.xml)

<annotation>
  <filename>image_001.jpg</filename>
  <size>
    <width>800</width>
    <height>600</height>
    <depth>3</depth>
  </size>
  <object>
    <name>pedestrian</name>
    <pose>Unspecified</pose>
    <truncated>0</truncated>
    <difficult>0</difficult>
    <bndbox>
      <xmin>120</xmin>
      <ymin>80</ymin>
      <xmax>240</xmax>
      <ymax>320</ymax>
    </bndbox>
  </object>
</annotation>

Corresponding YOLO TXT (image_001.txt)

0 0.225000 0.333333 0.150000 0.400000

2. Coordinate Conversion Mathematics

Pascal VOC uses corner coordinates: $[x_{\min}, y_{\min}, x_{\max}, y_{\max}]$. YOLO uses normalized center coordinates and dimensions: $[x_{\text{center}}, y_{\text{center}}, w_{\text{norm}}, h_{\text{norm}}]$.

Given image dimensions $W$ (width) and $H$ (height):

$$x_{\text{center}} = \frac{x_{\min} + x_{\max}}{2 \times W}$$

$$y_{\text{center}} = \frac{y_{\min} + y_{\max}}{2 \times H}$$

$$w_{\text{norm}} = \frac{x_{\max} - x_{\min}}{W}$$

$$h_{\text{norm}} = \frac{y_{\max} - y_{\min}}{H}$$


3. Standalone Python Batch Converter Script

This script uses standard library modules (xml.etree.ElementTree, pathlib, glob) to recursively find all XML files, build the class index list, and generate normalized YOLO labels and a data.yaml file.

#!/usr/bin/env python3
"""
voc_to_yolo.py
Converts Pascal VOC XML annotations to YOLO TXT format.
Zero external dependencies required.
"""

import xml.etree.ElementTree as ET
from pathlib import Path
from typing import List, Dict, Tuple


def convert_voc_box(
    size: Tuple[int, int],
    box: Tuple[float, float, float, float]
) -> Tuple[float, float, float, float]:
    """Convert VOC xmin, ymin, xmax, ymax to YOLO center_x, center_y, width, height."""
    dw = 1.0 / size[0]
    dh = 1.0 / size[1]
    
    xmin, ymin, xmax, ymax = box
    
    # Calculate center and dimensions
    x_center = (xmin + xmax) / 2.0
    y_center = (ymin + ymax) / 2.0
    w = xmax - xmin
    h = ymax - ymin
    
    # Normalize by image width and height
    x_center = x_center * dw
    w = w * dw
    y_center = y_center * dh
    h = h * dh
    
    # Clamp to [0, 1] range to avoid out-of-bounds training errors
    return (
        max(0.0, min(1.0, x_center)),
        max(0.0, min(1.0, y_center)),
        max(0.0, min(1.0, w)),
        max(0.0, min(1.0, h)),
    )


def batch_convert_voc_to_yolo(
    voc_xml_dir: str,
    output_labels_dir: str,
    classes_list: List[str] = None
):
    xml_path = Path(voc_xml_dir)
    out_path = Path(output_labels_dir)
    out_path.mkdir(parents=True, exist_ok=True)

    xml_files = list(xml_path.glob("*.xml"))
    print(f"Found {len(xml_files)} VOC XML files in {xml_path}")

    # Discover classes automatically if not provided
    discovered_classes = set()
    if classes_list is None:
        for xml_file in xml_files:
            tree = ET.parse(xml_file)
            root = tree.getroot()
            for obj in root.iter("object"):
                cls_name = obj.find("name").text.strip()
                discovered_classes.add(cls_name)
        classes_list = sorted(list(discovered_classes))

    class_to_id = {cls_name: idx for idx, cls_name in enumerate(classes_list)}
    print(f"Classes ({len(classes_list)}): {class_to_id}")

    total_boxes = 0
    converted_files = 0

    for xml_file in xml_files:
        tree = ET.parse(xml_file)
        root = tree.getroot()

        # Extract image dimensions
        size_node = root.find("size")
        if size_node is None:
            continue
        
        width = int(size_node.find("width").text)
        height = int(size_node.find("height").text)

        if width <= 0 or height <= 0:
            continue

        yolo_lines = []

        for obj in root.iter("object"):
            # Check difficulty tag (optional: skip difficult items)
            difficult = obj.find("difficult")
            if difficult is not None and int(difficult.text) == 1:
                pass # Can continue or include depending on your policy

            cls_name = obj.find("name").text.strip()
            if cls_name not in class_to_id:
                continue

            cls_id = class_to_id[cls_name]
            xml_box = obj.find("bndbox")
            
            xmin = float(xml_box.find("xmin").text)
            ymin = float(xml_box.find("ymin").text)
            xmax = float(xml_box.find("xmax").text)
            ymax = float(xml_box.find("ymax").text)

            # Ensure coordinates are properly ordered
            if xmax <= xmin or ymax <= ymin:
                continue

            yolo_box = convert_voc_box((width, height), (xmin, ymin, xmax, ymax))
            yolo_lines.append(f"{cls_id} {yolo_box[0]:.6f} {yolo_box[1]:.6f} {yolo_box[2]:.6f} {yolo_box[3]:.6f}")
            total_boxes += 1

        # Write output .txt
        txt_filename = out_path / f"{xml_file.stem}.txt"
        with open(txt_filename, "w", encoding="utf-8") as f:
            f.write("\n".join(yolo_lines) + ("\n" if yolo_lines else ""))

        converted_files += 1

    # Save classes.txt for reference
    with open(out_path / "classes.txt", "w", encoding="utf-8") as f:
        f.write("\n".join(classes_list) + "\n")

    print(f"Successfully converted {converted_files} files with {total_boxes} boxes.")
    print(f"Saved to: {out_path.resolve()}")


if __name__ == "__main__":
    import sys
    if len(sys.argv) < 3:
        print("Usage: python voc_to_yolo.py <voc_xml_dir> <output_labels_dir>")
    else:
        batch_convert_voc_to_yolo(sys.argv[1], sys.argv[2])

4. Converting In-Browser Without Scripts

If you do not want to set up local Python environments or install command-line tools:

  1. Drag and drop your XML files or ZIP archive into the LabelOp Format Converter.
  2. The tool automatically detects image sizes, extracts class taxonomies, and normalizes all bounding boxes.
  3. Download a ready-to-train YOLO ZIP archive with labels/ and data.yaml.

Summary

Migrating from Pascal VOC to YOLO is straightforward once coordinate normalization is applied. Use the script above for automated local pipelines or use LabelOp Tools for zero-setup browser conversions.

Let's talk about your project

Tell us what you need and we'll shape the right solution together.

Use Web Converter