Converting Label Studio Exports to YOLOv8 Format

Converting Label Studio exports to YOLOv8 format requires parsing the exported JSON payload, remapping string class labels to zero-based integer indices, and transforming percentage-based bounding box coordinates into YOLO’s normalized [class_id, x_center, y_center, width, height] structure. Label Studio stores box coordinates as percentages (0–100) relative to the original image; YOLOv8 expects values in the 0–1 range representing center points and box dimensions relative to the full tile. For geospatial annotation workflows that tile GeoTIFFs or orthomosaics into fixed-resolution chips, this conversion runs as a post-processing step and writes the standard images/ and labels/ directory layout plus a data.yaml configuration file ready for yolo train.

Why Coordinate Precision Matters for Raster Chips

A naive division by 100 is only correct when Label Studio’s stored original_width and original_height exactly match the pixel dimensions of the tile on disk. In geospatial pipelines this frequently breaks: dynamic tiling scripts using rasterio or gdal_translate sometimes add overlap padding, resize for square input, or resample to a different ground sampling distance — none of which Label Studio records unless you explicitly update the task metadata. Even a 4-pixel mismatch on a 512×512 chip produces a ~0.8% coordinate shift, which compounds across thousands of training images and measurably degrades mean Average Precision. Annotations that cross a tile boundary can produce x_center + width/2 > 1.0, which triggers YOLOv8’s dataset validation assertion and aborts training entirely before a single epoch runs.


Label Studio to YOLOv8 Conversion Pipeline Five-stage pipeline: Label Studio JSON export, dimension audit, coordinate math (percentage to normalized), class mapping and directory layout, then data.yaml output ready for yolo val and yolo train. Label Studio JSON export tasks.json Dimension Audit tile px vs metadata Coordinate Math % ÷ 100 → 0–1 Class Map + Directory Layout images/ + labels/ .txt per image data.yaml + yolo val ready for training Label Studio JSON → validated YOLOv8 dataset

Step-by-Step Implementation

Step 1 — Export from Label Studio as JSON

Open your project in Label Studio, navigate to Export → JSON, and download tasks.json. Each task contains an annotations array; each annotation result of type rectanglelabels carries the fields x, y, width, height (all percentages, 0–100, top-left anchor) and rectanglelabels (the class name list).

If your project used foundation model pre-labeling before human review, confirm that annotators accepted or corrected the model suggestions. Unreviewed model outputs live in the predictions key, not annotations, and require a separate fallback path in the conversion script.

Step 2 — Audit Tile Dimensions Against Label Studio Metadata

Before any coordinate math, verify that original_width and original_height from the JSON payload match the pixel dimensions of each tile on disk. Run this audit once per export batch:

python
from pathlib import Path
import json
from PIL import Image   # pillow>=10.0.0


def audit_tile_dimensions(
    export_json: str,
    image_dir: str,
) -> list[dict]:
    """
    Return mismatches between Label Studio metadata and actual tile pixel sizes.
    An empty list means all tiles match and coordinate math is safe to apply.
    """
    mismatches: list[dict] = []

    with open(export_json, encoding="utf-8") as f:
        tasks: list[dict] = json.load(f)

    for task in tasks:
        image_url = task.get("data", {}).get("image", "")
        filename  = Path(image_url).name
        tile_path = Path(image_dir) / filename

        if not tile_path.exists():
            continue

        for annotation in task.get("annotations", []):
            for result in annotation.get("result", []):
                if result.get("type") != "rectanglelabels":
                    continue
                ls_w = result.get("original_width")
                ls_h = result.get("original_height")
                actual_w, actual_h = Image.open(tile_path).size

                if ls_w != actual_w or ls_h != actual_h:
                    mismatches.append({
                        "file": filename,
                        "label_studio": (ls_w, ls_h),
                        "actual":       (actual_w, actual_h),
                    })
                break  # one result per task is enough for the dimension check

    return mismatches

This is especially important when tracking annotation changes across dataset versions: a silent dimension mismatch introduced at any pipeline stage will corrupt every downstream training run without producing an obvious error message.

Step 3 — Coordinate Transformation Formula

Label Studio’s top-left anchor convention means x and y are the top-left corner of the box expressed as percentages of the image dimensions. YOLOv8 expects center-point coordinates normalized to 0–1:

code
x_center = (x_pct + width_pct / 2) / 100.0
y_center = (y_pct + height_pct / 2) / 100.0
w_norm   = width_pct  / 100.0
h_norm   = height_pct / 100.0

For geospatial tiles produced by a sliding-window tiler with overlap, subtract the pixel offset of the overlap before dividing by 100. If tiles overlap by 64 px on a 512 px chip, the coordinate correction is 64 / 512 = 0.125 in normalized space — add this to the top-left coordinate of each box before applying the center-point formula.

Step 4 — Production Conversion Script

The script below handles batch conversion, class mapping, directory generation, coordinate clamping, and boundary-overflow logging. It requires only the Python standard library and runs on Python 3.10+:

python
import json
import logging
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


def convert_labelstudio_to_yolo(
    export_json: str,
    output_dir: str,
    class_names: list[str],
    image_dir: str | None = None,
    fallback_to_predictions: bool = False,
) -> None:
    """
    Convert a Label Studio object-detection JSON export to YOLOv8 format.

    Produces:
      <output_dir>/images/   — symlinks to source tiles (or empty if image_dir is None)
      <output_dir>/labels/   — one .txt per image, one YOLO row per bounding box
      <output_dir>/data.yaml — YOLOv8-compliant dataset config
    """
    output_path = Path(output_dir)
    images_dir  = output_path / "images"
    labels_dir  = output_path / "labels"
    images_dir.mkdir(parents=True, exist_ok=True)
    labels_dir.mkdir(parents=True, exist_ok=True)

    class_map: dict[str, int] = {name: idx for idx, name in enumerate(class_names)}

    with open(export_json, encoding="utf-8") as f:
        tasks: list[dict] = json.load(f)

    converted = 0
    skipped   = 0

    for task in tasks:
        image_url      = task.get("data", {}).get("image", "")
        image_filename = Path(image_url).name if image_url else f"task_{task.get('id', 'unknown')}.jpg"

        if image_dir:
            src_img = Path(image_dir) / image_filename
            if src_img.exists():
                dest_img = images_dir / image_filename
                if not dest_img.exists():
                    dest_img.symlink_to(src_img.resolve())

        # Prefer human-reviewed annotations; fall back to model predictions if allowed
        annotation_sets = task.get("annotations", [])
        if not annotation_sets and fallback_to_predictions:
            annotation_sets = task.get("predictions", [])

        if not annotation_sets:
            skipped += 1
            continue

        label_lines: list[str] = []

        for result in annotation_sets[0].get("result", []):
            if result.get("type") != "rectanglelabels":
                continue

            value    = result["value"]
            cls_name = value.get("rectanglelabels", ["unknown"])[0]

            if cls_name not in class_map:
                logging.warning("Unknown class '%s' in %s — skipped", cls_name, image_filename)
                continue

            # Label Studio stores coordinates as percentages (0–100), top-left anchor
            x_pct = float(value["x"])
            y_pct = float(value["y"])
            w_pct = float(value["width"])
            h_pct = float(value["height"])

            # Convert to YOLO center-point normalized coordinates (0–1)
            x_center = (x_pct + w_pct / 2.0) / 100.0
            y_center = (y_pct + h_pct / 2.0) / 100.0
            w_norm   = w_pct / 100.0
            h_norm   = h_pct / 100.0

            # Clamp to valid range; values outside [0, 1] abort yolo train
            x_center = max(0.0, min(1.0, x_center))
            y_center = max(0.0, min(1.0, y_center))
            w_norm   = max(0.0, min(1.0, w_norm))
            h_norm   = max(0.0, min(1.0, h_norm))

            # Flag annotations whose boxes likely overflow the tile boundary
            if x_center + w_norm / 2.0 > 0.99 or y_center + h_norm / 2.0 > 0.99:
                logging.warning(
                    "Boundary overflow in %s class=%s — check tile padding",
                    image_filename, cls_name,
                )

            label_lines.append(
                f"{class_map[cls_name]} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}"
            )

        if label_lines:
            label_file = labels_dir / f"{Path(image_filename).stem}.txt"
            label_file.write_text("\n".join(label_lines), encoding="utf-8")
            converted += 1

    # Write YOLOv8-compliant data.yaml
    yaml_path = output_path / "data.yaml"
    yaml_path.write_text(
        f"path: {output_path.resolve()}\n"
        "train: images\n"
        "val: images\n"
        f"nc: {len(class_names)}\n"
        f"names: {class_names}\n",
        encoding="utf-8",
    )

    logging.info("Done. Converted: %d tasks, skipped (no annotations): %d", converted, skipped)
    logging.info("Dataset written to %s", output_path)

Step 5 — Validate the Dataset Before Training

After conversion, run YOLOv8’s built-in dataset check before starting any training job. A clean dataset produces zero coordinate-range warnings and a legible class histogram:

python
from ultralytics import YOLO   # ultralytics>=8.0.0

# Dataset validation — does not consume GPU or training time
model = YOLO("yolov8n.pt")
model.val(data="path/to/output/data.yaml", split="train")

Alternatively use the CLI: yolo val model=yolov8n.pt data=path/to/data.yaml split=train.

For teams using DVC to version geospatial training data, pin data.yaml and the labels/ directory as tracked outputs of the conversion stage so coordinate bugs are traceable to a specific export commit and revertible without re-annotating.

Format Parameters and Coordinate Thresholds

Parameter Expected value Notes
x, y (Label Studio) 0–100 float Top-left corner; not center point
x_center, y_center (YOLO) 0.0–1.0 float Center point, fraction of tile dimension
width, height (YOLO) 0.0–1.0 float Box size as fraction of tile dimension
Class index 0-based integer Must match names list order in data.yaml
Coordinate precision 6 decimal places 4+ dp is sufficient; YOLOv8 reads standard floats
Typical GeoTIFF chip size 512×512 px or 640×640 px Larger chips reduce label count but increase GPU memory
Boundary overflow threshold > 0.99 on any edge Indicates a tile-boundary cut; log and investigate
Overlap correction (64 px on 512 px) subtract 0.125 normalized units Apply before center-point formula when tiler uses overlap

Common Errors and Fixes

AssertionError: box labels must be in [0, 1] range during yolo train : One or more .txt label files contain coordinates outside the valid range. Root cause: original_width / original_height mismatch, or a boundary annotation extending past the tile edge. Fix: run the dimension audit from Step 2, then enable coordinate clamping in the conversion script and log every clamped value for manual review.

Empty labels/ directory after conversion : The export uses the predictions key instead of annotations (model-assisted labeling workflow where annotators never confirmed the pre-labels). Fix: set fallback_to_predictions=True in the conversion call, or re-export from Label Studio after annotators have accepted the pre-labels.

KeyError: 'rectanglelabels' in the conversion script : The project template uses a different annotation type — polygonlabels for segmentation masks, or keypointlabels for skeleton annotations. Fix: add a guard on result.get("type") and route non-rectangle types to a separate handler, or skip them explicitly with a log message.

All bounding boxes offset by ~0.01–0.05 normalized units : The tiling pipeline applied overlap padding that shifted pixel origins without updating Label Studio task metadata. Fix: compute the normalized offset (overlap_px / tile_size_px) and subtract it from the raw percentage coordinates before applying the center-point formula.


This page covers the export and conversion step within the broader automating pre-labeling with foundation models workflow, where foundation model outputs feed Label Studio before the human review and export cycle described here.

Related