COCO (Common Objects in Context) JSON and YOLO TXT are the two most prevalent annotation formats in modern computer vision.
However, training modern detectors like Ultralytics YOLOv8, YOLOv11, or YOLOv12 requires individual .txt files per image with normalized center coordinates, whereas COCO aggregates all annotations into a single JSON with absolute pixel coordinates.
This guide explains the coordinate mathematics, provides a zero-dependency Python conversion script, and demonstrates how to verify the resulting bounding boxes.
Need an instant conversion without running scripts? Use the free in-browser COCO to YOLO Converter Tool.
1. Coordinate Mathematics: COCO vs. YOLO
COCO Bounding Box Format
COCO stores boxes in absolute pixels measuring from the top-left corner: $$\text{bbox} = [x_{\min}, y_{\min}, \text{width}, \text{height}]$$
YOLO Bounding Box Format
YOLO requires values normalized between $0.0$ and $1.0$, representing the center point of the box: $$\text{YOLO} = [\text{class_id}, x_{\text{center}}, y_{\text{center}}, w_{\text{norm}}, h_{\text{norm}}]$$
Conversion Formulas
Given an image with dimensions $W$ (width) and $H$ (height):
$$x_{\text{center}} = \frac{x_{\min} + \frac{\text{width}}{2}}{W}$$
$$y_{\text{center}} = \frac{y_{\min} + \frac{\text{height}}{2}}{H}$$
$$w_{\text{norm}} = \frac{\text{width}}{W}$$
$$h_{\text{norm}} = \frac{\text{height}}{H}$$
2. Production-Ready Python Conversion Script
Here is a robust Python script using only the standard library (json, pathlib, collections). It automatically handles category re-indexing (ensuring zero-based class IDs) and generates a ready-to-train data.yaml.
#!/usr/bin/env python3
"""
coco_to_yolo.py
Converts a COCO JSON annotation file into YOLO TXT format with data.yaml.
Zero external dependencies required.
"""
import json
from pathlib import Path
from collections import defaultdict
def convert_coco_to_yolo(
coco_json_path: str,
output_dir: str,
img_folder_name: str = "images"
):
json_path = Path(coco_json_path)
out_path = Path(output_dir)
labels_dir = out_path / "labels"
labels_dir.mkdir(parents=True, exist_ok=True)
print(f"Loading {json_path}...")
with open(json_path, "r", encoding="utf-8") as f:
coco_data = json.load(f)
# 1. Build Category Mapping (Ensure 0-indexed contiguous IDs)
categories = coco_data.get("categories", [])
categories.sort(key=lambda c: c["id"])
cat_id_to_yolo_id = {}
class_names = []
for yolo_idx, cat in enumerate(categories):
cat_id_to_yolo_id[cat["id"]] = yolo_idx
class_names.append(cat["name"])
print(f"Mapped {len(categories)} categories: {class_names}")
# 2. Map Images by ID
images = {img["id"]: img for img in coco_data.get("images", [])}
# 3. Group Annotations by Image ID
image_annotations = defaultdict(list)
for ann in coco_data.get("annotations", []):
# Ignore crowd annotations if needed
if ann.get("iscrowd", 0) == 1:
continue
image_annotations[ann["image_id"]].append(ann)
# 4. Generate YOLO TXT Files
converted_count = 0
box_count = 0
for img_id, img_info in images.items():
img_w = float(img_info["width"])
img_h = float(img_info["height"])
file_name = Path(img_info["file_name"]).stem
txt_file_path = labels_dir / f"{file_name}.txt"
anns = image_annotations.get(img_id, [])
yolo_lines = []
for ann in anns:
cat_id = ann["category_id"]
if cat_id not in cat_id_to_yolo_id:
continue
yolo_class_id = cat_id_to_yolo_id[cat_id]
x_min, y_min, w, h = ann["bbox"]
# Guard against negative dimensions or zero sizes
if w <= 0 or h <= 0:
continue
# Convert to YOLO normalized coordinates
x_center = (x_min + (w / 2.0)) / img_w
y_center = (y_min + (h / 2.0)) / img_h
norm_w = w / img_w
norm_h = h / img_h
# Clamp coordinates to [0.0, 1.0]
x_center = max(0.0, min(1.0, x_center))
y_center = max(0.0, min(1.0, y_center))
norm_w = max(0.0, min(1.0, norm_w))
norm_h = max(0.0, min(1.0, norm_h))
yolo_lines.append(
f"{yolo_class_id} {x_center:.6f} {y_center:.6f} {norm_w:.6f} {norm_h:.6f}"
)
box_count += 1
# Write txt file (empty file created if image has no boxes, important for negative samples)
with open(txt_file_path, "w", encoding="utf-8") as out_f:
out_f.write("\n".join(yolo_lines) + ("\n" if yolo_lines else ""))
converted_count += 1
# 5. Generate data.yaml for Ultralytics
yaml_path = out_path / "data.yaml"
with open(yaml_path, "w", encoding="utf-8") as yf:
yf.write(f"path: {out_path.resolve()}\n")
yf.write(f"train: {img_folder_name}\n")
yf.write(f"val: {img_folder_name}\n\n")
yf.write(f"nc: {len(class_names)}\n")
yf.write("names:\n")
for idx, name in enumerate(class_names):
yf.write(f" {idx}: {name}\n")
print(f"Successfully converted {converted_count} images with {box_count} total boxes.")
print(f"Labels saved to: {labels_dir}")
print(f"YAML config saved to: {yaml_path}")
if __name__ == "__main__":
import sys
if len(sys.argv) < 3:
print("Usage: python coco_to_yolo.py <annotations.json> <output_directory>")
else:
convert_coco_to_yolo(sys.argv[1], sys.argv[2])
3. Visualizing & Verifying Converted YOLO Bounding Boxes
To verify that coordinates did not drift, run this simple OpenCV script to draw the converted boxes back onto your images:
# verify_yolo_boxes.py
import cv2
from pathlib import Path
def draw_yolo_boxes(image_path: str, label_txt_path: str, class_names: list):
img = cv2.imread(image_path)
h, w, _ = img.shape
with open(label_txt_path, "r") as f:
lines = [line.strip().split() for line in f if line.strip()]
for line in lines:
class_id, xc, yc, bw, bh = map(float, line)
class_id = int(class_id)
# Convert back to absolute pixels
x1 = int((xc - bw / 2) * w)
y1 = int((yc - bh / 2) * h)
x2 = int((xc + bw / 2) * w)
y2 = int((yc + bh / 2) * h)
label = class_names[class_id] if class_id < len(class_names) else str(class_id)
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(img, label, (x1, max(y1 - 10, 15)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
cv2.imshow("YOLO Box Verification", img)
cv2.waitKey(0)
cv2.destroyAllWindows()
4. Common Pitfalls to Avoid
- Missing 0-index categories: COCO datasets from the web (e.g. COCO 2017) often have category IDs ranging from 1 to 90 with gaps (e.g. ID 12 missing). YOLO expects continuous indices from
0tonum_classes - 1. - Top-Left vs. Center Coordinates: Forgetting to add half the width/height is the #1 bug in custom converters, causing boxes to shift towards the top-left.
- Empty Background Images: Do not skip images without annotations; write an empty
.txtfile so YOLO trains on true negative background samples.
Summary
Converting COCO to YOLO requires careful category mapping and normalized center coordinates.
For automated dataset conversion, multi-format exports, and dataset health checks without writing code, try LabelOp Free Tools.