Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Thoughts on YOLO annotation format conversion? #69

Open
Jordan-Pierce opened this issue Mar 27, 2025 · 16 comments · May be fixed by #74
Open

Thoughts on YOLO annotation format conversion? #69

Jordan-Pierce opened this issue Mar 27, 2025 · 16 comments · May be fixed by #74

Comments

@Jordan-Pierce
Copy link

Hello,

Thanks for the great new model! Just curious if you're open to a PR that allows the current implementation to work with a YOLO-formatted dataset instead of just COCO?

(no worries if not, I can guess why).

Cheers.

@mario-dg
Copy link

If I am correct, @SkalskiP talked about it in the YouTube livestream today, and they definitely consider it for the future, since many roboflow user are already very comfortable with the YOLO format, but it's not their priority. There is a lot of different, more important work to be done.

@josephofiowa
Copy link
Contributor

In the interim, one thing the roboflow hosted service is useful for is converting annotation formats. There are 37 import formats (https://roboflow.com/formats?data-import=true) and 31 export formats (https://roboflow.com/formats?data-export=true). For example YOLO TXT to COCO JSON: https://roboflow.com/convert/yolo-keras-txt-to-coco-json

@SkalskiP
Copy link
Collaborator

Hi @Jordan-Pierce @mario-dg 👋🏻! Exactly! Adding YOLO format loading is definitely on our radar. It would be great if someone from the community added this functionality. If not, we'll add it ourselves, but it is not a key priority at the moment. In the meantime, I recommend using the resources linked by @josephofiowa. And of course, you can also use supervision, which allows conversion from PASCAL VOC and YOLO to COCO.

@mario-dg
Copy link

@SkalskiP, I am thinking about 2 ways to implement this.

  • Add an additional argument to main.py called something like --yolo-format that enables us to pass in a YOLO dataset as the --dataset-dir, which then uses supervision to convert the dataset before training starts.
  • Use the necessary .yaml file in YOLO datasets, as an "automatic" way to detect if a YOLO dataset has been passed in. This is a naive approach, since any yaml file in the root directory of the dataset would result a conversion attempt. This should probably be more sophisticated, in terms of checking all yaml files in the root of the dataset and checking each if they contain the necessary properties.

Let me know, what you guys prefer, so I can proceed 🚀

@SkalskiP
Copy link
Collaborator

@mario-dg good thinking! Wouldn't it be even simpler if we just used the existing --dataset-dir and automatically detected whether it’s a COCO or YOLO dataset based on the contents? That way, we avoid introducing extra arguments and make the UX smoother.

We could define clear rules for detection (e.g., presence of data.yaml for YOLO or _annotations.json for COCO) and load accordingly. What do you think?

@mario-dg
Copy link

@SkalskiP, yes that's what I was suggesting with my 2nd approach 😄

Currently, we pass in the root directory of the dataset to the --dataset-dir argument, whereas ultralytics wants the path to the data.yaml file. I think we should stay consistent here and also use the root of the YOLO dataset.

The name of the yaml should be fixed to data.yaml, to make things easier for us.

Will draft a first PR later today.

@SkalskiP
Copy link
Collaborator

@mario-dg awesome! I'm pretty busy today as I need to prepare for GitHub stream. But I'll take a look at all the PRs next week.

@Jordan-Pierce
Copy link
Author

@SkalskiP, yes that's what I was suggesting with my 2nd approach 😄

Currently, we pass in the root directory of the dataset to the --dataset-dir argument, whereas ultralytics wants the path to the data.yaml file. I think we should stay consistent here and also use the root of the YOLO dataset.

The name of the yaml should be fixed to data.yaml, to make things easier for us.

Will draft a first PR later today.

@mario-dg a YOLO dataset for detection / segmentation is defined by the structure (train/valid/test) and the data.yaml. You could either pass in the data.yaml file and confirm the folder structure, or pass in the directory and confirm the folder structure as well as the existence of data.yaml.

Having the data.yaml means it's not an image classification dataset (also in classification, the folder for validation is "val" not "valid"); so if a data.yaml exists it's fair game to use for this model, as if it's instance segmentation data, you can convert them in the dataloader to use.

@Jordan-Pierce
Copy link
Author

In the interim, one thing the roboflow hosted service is useful for is converting annotation formats. There are 37 import formats (https://roboflow.com/formats?data-import=true) and 31 export formats (https://roboflow.com/formats?data-export=true). For example YOLO TXT to COCO JSON: https://roboflow.com/convert/yolo-keras-txt-to-coco-json

Sorry for being lazy, but does roboflow provide these converters via python API? Or is it just available from their platform?

@mario-dg
Copy link

@Jordan-Pierce, thanks for the clarification. I already have a draft and create the PR soon.
As far as I am aware, RF-DETR is Object Detection only, just like RT-DETR, so we don't have any mask data.

@Jordan-Pierce
Copy link
Author

@Jordan-Pierce, thanks for the clarification. I already have a draft and create the PR soon. As far as I am aware, RF-DETR is Object Detection only, just like RT-DETR, so we don't have any mask data.

If someone passes mask data, we can convert it into bounding boxes.

@mario-dg
Copy link

@Jordan-Pierce, thanks for the clarification. I already have a draft and create the PR soon. As far as I am aware, RF-DETR is Object Detection only, just like RT-DETR, so we don't have any mask data.

If someone passes mask data, we can convert it into bounding boxes.

Good idea! Do you have a quick implementation at hand before I take a closer look on how to do that?

@mario-dg mario-dg linked a pull request Mar 28, 2025 that will close this issue
1 task
@Jordan-Pierce
Copy link
Author

Depends on where you do it in your code, but at some point you're going to be reading the YOLO label.txt files, line-by-line. The coordinates are normalized (so if users resize the images, it doesn't matter, the values still work).

When you read them in, determine if it's a bounding box, or polygon: bounding box will (for every row) always contain the same shape. Polygons will vary. So that's a quick check.

Once you determine it's a polygon, you can just use the utils in supervision to convert from polygon. Note that YOLO format are polygons, not masks.

@mario-dg
Copy link

Yes, I am well aware of the YOLO format. As you can see in the PR, the code changes were quite minimal. If supervision already provides a convert from polygon method, it should be easy to implement. In that case, we probably won't have to touch the dataloader and just can ensure that the during the conversion process polygons are converted to bounding boxes.

@isaacrob-roboflow
Copy link
Collaborator

we plan to support masking at some point. I don't want to build infra that makes it harder to add that later

@mkrupczak3
Copy link

mkrupczak3 commented Mar 28, 2025

Here is a Python script which converts a YOLO format dataset to COCO format for use with rf-detr. Note that for single class datasets, you should use the --add_dummy argument to replicate @picjul 's fix in #48 :

LICENSE: Creative Commons 0

convert_yolo_to_coco.py

#!/usr/bin/env python
import os
import json
import glob
import shutil
import random
import argparse
from PIL import Image

# For multi-class YAML support.
try:
    import yaml
except ImportError:
    yaml = None

def convert_yolo_to_coco(image_paths, labels_dir, class_name="custom", add_dummy=False, categories=None):
    """
    Converts a list of images with corresponding YOLO annotations to a COCO-style JSON.
    
    For each annotation line in a YOLO file, the expected format is:
      <class_index> <x_center> <y_center> <width> <height>
    where coordinates are normalized relative to image dimensions.
    
    If `categories` is provided, it is assumed that the dataset is multi-class.
      In that case, the YOLO class index is used (plus one) as the COCO category ID.
    
    For single-class datasets (when categories is None), the conversion will assign
    every annotation the same class ID (1) unless the dummy workaround is enabled,
    in which case a dummy background class (id 0) is added and the single object is id 1.
    """
    if categories is None:
        # Single-class conversion.
        if add_dummy:
            categories = [
                {"id": 0, "name": "Workers", "supercategory": "none"},
                {"id": 1, "name": class_name, "supercategory": "Workers"}
            ]
        else:
            categories = [{"id": 1, "name": class_name, "supercategory": "none"}]
        use_multiclass = False
    else:
        # Multi-class: use the provided categories.
        use_multiclass = True

    coco = {
        "images": [],
        "annotations": [],
        "categories": categories
    }
    
    annotation_id = 1
    image_id = 1
    for img_path in image_paths:
        filename = os.path.basename(img_path)
        # Get image dimensions.
        with Image.open(img_path) as img:
            width, height = img.size
        
        coco["images"].append({
            "id": image_id,
            "file_name": filename,
            "width": width,
            "height": height
        })
        
        # YOLO annotation file: same basename with .txt extension.
        base, _ = os.path.splitext(filename)
        label_file = os.path.join(labels_dir, base + ".txt")
        
        if os.path.exists(label_file):
            with open(label_file, "r") as f:
                lines = f.readlines()
            for line in lines:
                parts = line.strip().split()
                if len(parts) != 5:
                    continue  # Skip improperly formatted lines.
                
                # In multi-class mode, use the provided YOLO class index.
                if use_multiclass:
                    cls_idx = int(parts[0])
                    # COCO categories are 1-indexed (assuming YAML names are in order).
                    category_id = cls_idx + 1
                else:
                    # For single-class datasets, always assign the same class.
                    category_id = 1
                
                # Parse and convert normalized coordinates.
                _, x_center, y_center, w_norm, h_norm = map(float, parts)
                x_center_abs = x_center * width
                y_center_abs = y_center * height
                w_abs = w_norm * width
                h_abs = h_norm * height
                x_min = x_center_abs - (w_abs / 2)
                y_min = y_center_abs - (h_abs / 2)
                
                annotation = {
                    "id": annotation_id,
                    "image_id": image_id,
                    "category_id": category_id,
                    "bbox": [x_min, y_min, w_abs, h_abs],
                    "area": w_abs * h_abs,
                    "iscrowd": 0
                }
                coco["annotations"].append(annotation)
                annotation_id += 1
        
        image_id += 1
    
    return coco

def create_coco_dataset_from_yolo(yolo_dataset_dir, coco_dataset_dir, class_name="custom",
                                  add_dummy=False, split_ratios=(0.8, 0.1, 0.1), categories=None):
    """
    Converts a YOLO-formatted dataset (with subdirectories "images" and "labels") into a COCO-formatted dataset.
    
    The output directory will include three subdirectories: "train", "valid", and "test".
    Each split directory will contain:
      - A copy of the corresponding image files.
      - An "_annotations.coco.json" file with COCO-style annotations.
    
    If `categories` is provided, the dataset is assumed to be multi-class.
    """
    images_dir = os.path.join(yolo_dataset_dir, "images")
    labels_dir = os.path.join(yolo_dataset_dir, "labels")
    
    # Gather image file paths (supports common image extensions).
    image_extensions = ("*.jpg", "*.jpeg", "*.png")
    # Unless we're running on Windows (which matches case-insensitive), add uppercase file extensions to list
    if sys.platform != "win32":                                                             
        image_extensions += tuple(ext.upper() for ext in image_extensions)
    image_paths = []
    for ext in image_extensions:
        image_paths.extend(glob.glob(os.path.join(images_dir, ext)))
    
    if not image_paths:
        raise ValueError("No images found in the provided images directory.")
    
    # Shuffle images and split into train/valid/test sets.
    random.shuffle(image_paths)
    num_images = len(image_paths)
    train_end = int(split_ratios[0] * num_images)
    valid_end = train_end + int(split_ratios[1] * num_images)
    
    splits = {
        "train": image_paths[:train_end],
        "valid": image_paths[train_end:valid_end],
        "test": image_paths[valid_end:]
    }
    
    os.makedirs(coco_dataset_dir, exist_ok=True)
    
    for split_name, paths in splits.items():
        split_dir = os.path.join(coco_dataset_dir, split_name)
        os.makedirs(split_dir, exist_ok=True)
        
        # Copy images to the split directory.
        for img_path in paths:
            shutil.copy(img_path, os.path.join(split_dir, os.path.basename(img_path)))
        
        # Convert annotations for this split.
        coco_annotations = convert_yolo_to_coco(paths, labels_dir, class_name=class_name,
                                                  add_dummy=add_dummy, categories=categories)
        json_path = os.path.join(split_dir, "_annotations.coco.json")
        with open(json_path, "w") as f:
            json.dump(coco_annotations, f, indent=4)
        print(f"Created {json_path}: {len(coco_annotations['images'])} images, {len(coco_annotations['annotations'])} annotations.")
    
    return coco_dataset_dir

def main():
    parser = argparse.ArgumentParser(
        description="Convert a YOLO-formatted dataset to COCO JSON format. "
                    "Supports both single-class and multi-class datasets. "
                    "For multi-class, provide a YOLO YAML file."
    )
    parser.add_argument("--yolo_dataset_dir", type=str, required=True,
                        help="Path to the YOLO dataset directory (should contain 'images' and 'labels' subdirectories).")
    parser.add_argument("--coco_dataset_dir", type=str, default="converted_coco_dataset",
                        help="Output directory where the COCO formatted dataset will be saved.")
    parser.add_argument("--class_name", type=str, default="custom",
                        help="Name of the object class (for single-class datasets).")
    parser.add_argument("--add_dummy", action="store_true",
                        help="Add a dummy background class for single-class datasets (workaround for RF-DETR).")
    parser.add_argument("--yaml_file", type=str, default=None,
                        help="Path to the YOLO YAML file (for multi-class datasets).")
    args = parser.parse_args()

    # Determine categories based on whether a YAML file is provided.
    categories = None
    if args.yaml_file:
        if yaml is None:
            raise ImportError("PyYAML is required for YAML file parsing. Install it via 'pip install pyyaml'.")
        with open(args.yaml_file, "r") as f:
            yaml_data = yaml.safe_load(f)
        names = yaml_data.get("names")
        if not names:
            raise ValueError("The YAML file does not contain a 'names' key with class names.")
        # For multi-class, create COCO categories (1-indexed).
        categories = [{"id": i + 1, "name": name, "supercategory": "none"} for i, name in enumerate(names)]
        print(f"Loaded {len(categories)} classes from YAML file.")
    
    print("Starting conversion from YOLO to COCO format...")
    create_coco_dataset_from_yolo(
        yolo_dataset_dir=args.yolo_dataset_dir,
        coco_dataset_dir=args.coco_dataset_dir,
        class_name=args.class_name,
        add_dummy=args.add_dummy,
        categories=categories
    )
    print("Conversion complete.")

if __name__ == "__main__":
    main()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants