Ultralytics YOLO27:
Get Started

YOLO mit COCO-JSON trainieren, ohne die Daten zu konvertieren#

Annotationen im COCO-JSON-Format können direkt für das Training von Ultralytics YOLO verwendet werden, ohne sie vorher in .txt-Dateien zu konvertieren. Dazu wird YOLODataset abgeleitet, um COCO-JSON während der Ausführung einzulesen, und über einen benutzerdefinierten Trainer in die Trainingspipeline eingebunden.

Warum direkt mit COCO-JSON trainieren?#

Dieser Ansatz bewahrt das COCO-JSON als einzige maßgebliche Quelle — ohne Aufruf von convert_coco(), ohne Umstrukturierung von Verzeichnissen und ohne zwischengeschaltete Labeldateien. YOLO26 und alle anderen Ultralytics-YOLO-Erkennungsmodelle werden unterstützt. Segmentierungs- und Pose-Modelle erfordern zusätzliche Label-Felder (siehe FAQ).

Suchst du stattdessen nach einer einmaligen Konvertierung?

Im Leitfaden zur Konvertierung von COCO in YOLO findest du den standardmäßigen Workflow mit convert_coco().

Architekturüberblick#

Es werden zwei Klassen benötigt:

  1. COCODataset — liest COCO-JSON und wandelt Begrenzungsrahmen während des Trainings im Arbeitsspeicher ins YOLO-Format um
  2. COCOTrainer — überschreibt build_dataset(), um statt des standardmäßigen YOLODataset COCODataset zu verwenden

Die Implementierung ist eine vereinfachte Version von GroundingDataset, das ebenfalls JSON-Annotationen direkt einliest. Hier werden drei Methoden überschrieben — get_img_files(), cache_labels() und get_labels() —, während GroundingDataset weitere Methoden überschreibt, darunter die Prüfungen für den eigenen Cache-Hash und die Instanzanzahl.

Die COCO-JSON-Datasetklasse erstellen#

Die Klasse COCODataset erbt von YOLODataset und überschreibt die Logik zum Laden der Labels. Statt .txt-Dateien aus einem Labelverzeichnis einzulesen, öffnet sie die COCO-JSON-Datei, durchläuft die nach Bildern gruppierten Annotationen und wandelt jeden Begrenzungsrahmen vom COCO-Pixelformat [x_min, y_min, width, height] ins normalisierte YOLO-Mittelpunktsformat [x_center, y_center, width, height] um. Crowd-Annotationen (iscrowd: 1) und Rahmen mit einer Fläche von null werden automatisch übersprungen.

Die Methode get_img_files() gibt eine leere Liste zurück, da die Bildpfade aus dem JSON-Feld file_name innerhalb von cache_labels() aufgelöst werden. Kategorie-IDs werden sortiert und auf nullbasierte Klassenindizes abgebildet, sodass sowohl das standardmäßige COCO-Schema mit IDs ab 1 als auch nicht zusammenhängende ID-Schemata korrekt funktionieren.

import json
from collections import defaultdict
from pathlib import Path

import numpy as np

from ultralytics.data.dataset import DATASET_CACHE_VERSION, YOLODataset
from ultralytics.data.utils import get_hash, load_dataset_cache_file, save_dataset_cache_file
from ultralytics.utils import TQDM

class COCODataset(YOLODataset):
    """Dataset that reads COCO JSON annotations directly without conversion to .txt files."""

    def __init__(self, *args, json_file="", **kwargs):
        """Initialize the dataset with a COCO JSON annotation file."""
        self.json_file = json_file
        super().__init__(*args, data={"channels": 3}, **kwargs)

    def get_img_files(self, img_path):
        """Image paths are resolved from the JSON file, not from scanning a directory."""
        self.fraction = 1.0  # fraction is applied while scanning a directory, which this dataset skips
        return []

    def cache_labels(self, path=Path("./labels.cache")):
        """Parse COCO JSON and convert annotations to YOLO format. Results are saved to a .cache file."""
        x = {"labels": []}
        with open(self.json_file) as f:
            coco = json.load(f)

        # Sort categories by ID and map to 0-indexed classes
        categories = {cat["id"]: i for i, cat in enumerate(sorted(coco["categories"], key=lambda c: c["id"]))}

        img_to_anns = defaultdict(list)
        for ann in coco["annotations"]:
            img_to_anns[ann["image_id"]].append(ann)

        for img_info in TQDM(coco["images"], desc="reading annotations"):
            h, w = img_info["height"], img_info["width"]
            im_file = Path(self.img_path) / img_info["file_name"]
            if not im_file.exists():
                continue

            self.im_files.append(str(im_file))
            bboxes = []
            for ann in img_to_anns.get(img_info["id"], []):
                if ann.get("iscrowd", False):
                    continue
                # COCO: [x, y, w, h] top-left in pixels -> YOLO: [cx, cy, w, h] center normalized
                box = np.array(ann["bbox"], dtype=np.float32)
                box[:2] += box[2:] / 2  # top-left to center
                box[[0, 2]] /= w  # normalize x
                box[[1, 3]] /= h  # normalize y
                if box[2] <= 0 or box[3] <= 0:
                    continue
                cls = categories[ann["category_id"]]
                bboxes.append([cls, *box.tolist()])

            lb = np.array(bboxes, dtype=np.float32) if bboxes else np.zeros((0, 5), dtype=np.float32)
            x["labels"].append(
                {
                    "im_file": str(im_file),
                    "shape": (h, w),
                    "cls": lb[:, 0:1],
                    "bboxes": lb[:, 1:],
                    "segments": [],
                    "normalized": True,
                    "bbox_format": "xywh",
                }
            )
        if not x["labels"]:
            raise RuntimeError(f"No images listed in {self.json_file} were found in {self.img_path}")
        x["hash"] = get_hash([self.json_file, str(self.img_path)])
        save_dataset_cache_file(self.prefix, path, x, DATASET_CACHE_VERSION)
        return x

    def get_labels(self):
        """Load labels from .cache file if available, otherwise parse JSON and create the cache."""
        cache_path = Path(self.json_file).with_suffix(".cache")
        try:
            cache = load_dataset_cache_file(cache_path)
            assert cache["version"] == DATASET_CACHE_VERSION
            assert cache["hash"] == get_hash([self.json_file, str(self.img_path)])
            self.im_files = [lb["im_file"] for lb in cache["labels"]]
        except (FileNotFoundError, AssertionError, AttributeError, KeyError, ModuleNotFoundError):
            cache = self.cache_labels(cache_path)
        cache.pop("hash", None)
        cache.pop("version", None)
        return cache["labels"]

Geparste Labels werden in einer .cache-Datei neben der JSON-Datei gespeichert (z. B. instances_train.cache). Bei nachfolgenden Trainingsläufen wird der Cache direkt geladen und das Parsen der JSON-Datei übersprungen.

Der Cache-Schlüssel basiert auf der Dateigröße des JSON, nicht auf dessen Inhalt

get_hash() bildet Hashes aus Dateigrößen und Pfaden statt aus Dateiinhalten. Deshalb wird die JSON-Datei bei einem erneuten Lauf nur dann erneut geparst, wenn sich ihre Byteanzahl ändert. Durch das Hinzufügen oder Entfernen von Bildern kann sich auch die Größe des Bildverzeichnisses ändern und ein Neuaufbau ausgelöst werden, aber verlass dich nicht darauf — der Hash prüft keine einzelnen Bilddateien, sodass der Austausch eines Bildes gegen ein anderes die Größe unverändert lassen kann. Eine Änderung, bei der die Byteanzahl gleich bleibt — etwa das Verschieben einer Koordinate, das Umkehren von iscrowd oder der Austausch zweier Klassennamen gleicher Länge — lässt den veralteten Cache bestehen und das Training verwendet ohne Warnung die alten Annotationen. Aus demselben Grund bleibt auch der Austausch eines Bildes an Ort und Stelle unbemerkt. Lösche nach Änderungen an Annotationen oder Bildern an Ort und Stelle die Datei .cache.

Das Dataset mit der Trainingspipeline verbinden#

Im Trainer muss lediglich build_dataset() überschrieben werden. Das standardmäßige DetectionTrainer erstellt ein YOLODataset, das nach Labeldateien .txt sucht. Ersetzt du es durch COCODataset, liest der Trainer stattdessen das COCO-JSON ein.

Der Pfad zur JSON-Datei wird aus einem benutzerdefinierten Feld train_json / val_json in der Datenkonfiguration übernommen (siehe dataset.yaml konfigurieren). Beim Training wird mode="train" zu train_json aufgelöst; bei der Validierung wird mode="val" zu val_json aufgelöst. Beide Schlüssel werden benötigt — die beiden Aufteilungen verwenden unterschiedliche Bildverzeichnisse, daher kann die Trainings-JSON nicht eine fehlende val_json ersetzen.

Das Dataset setzt außerdem fraction auf 1.0 zurück. BaseDataset wendet dieses Argument beim Durchsuchen eines Bildverzeichnisses an; diesen Schritt überspringt COCODataset, weshalb es keine Anforderung an ein Teildataset berücksichtigen kann. Durch das Zurücksetzen wird verhindert, dass das Dataset den Eindruck erweckt, einen Wert anzunehmen, den es ignoriert. Aus demselben Grund geht auch das integrierte GroundingDataset denselben Kompromiss ein.

from ultralytics.models.yolo.detect import DetectionTrainer
from ultralytics.utils import colorstr

class COCOTrainer(DetectionTrainer):
    """Trainer that uses COCODataset for direct COCO JSON training."""

    def build_dataset(self, img_path, mode="train", batch=None):
        """Build a COCODataset for the given split using the JSON file from the data config."""
        json_file = self.data["train_json"] if mode == "train" else self.data["val_json"]
        return COCODataset(
            img_path=img_path,
            json_file=json_file,
            imgsz=self.args.imgsz,
            batch_size=batch,
            augment=mode == "train",
            hyp=self.args,
            rect=self.args.rect or mode == "val",
            cache=self.args.cache or None,
            single_cls=self.args.single_cls or False,
            stride=int(self.model.stride.max()) if hasattr(self, "model") and self.model else 32,
            pad=0.0 if mode == "train" else 0.5,
            prefix=colorstr(f"{mode}: "),
            task=self.args.task,
            classes=self.args.classes,
            fraction=self.args.fraction if mode == "train" else 1.0,
        )

dataset.yaml für COCO-JSON konfigurieren#

dataset.yaml verwendet die standardmäßigen Felder path, train und val, um Bildverzeichnisse zu finden. Beachte, dass path hier auf das Bildstammverzeichnis verweist, sodass train und val einfache Namen der Datensatzaufteilungen sind — anders als im Konvertierungsleitfaden, in dem path das Dataset-Stammverzeichnis ist und den Aufteilungen das Präfix images/ vorangestellt wird. Zwei weitere Felder, train_json und val_json, geben die COCO-Annotationsdateien an, die COCOTrainer einliest. Das Feld names führt die Klassennamen in der sortierten Reihenfolge von categories in der JSON-Datei auf; die Klassenanzahl wird daraus abgeleitet, sodass nc nicht gesetzt werden muss.

path: /path/to/my_dataset/images # root with train/ and val/ image subfolders
train: train
val: val

# COCO JSON annotation files (use absolute paths; these custom keys are not resolved against `path`)
train_json: /path/to/my_dataset/annotations/instances_train.json
val_json: /path/to/my_dataset/annotations/instances_val.json

names:
    0: person
    1: bicycle
    # ... remaining class names

Erwartete Verzeichnisstruktur:

my_dataset/
  images/
    train/
      img_001.jpg
      ...
    val/
      img_100.jpg
      ...
  annotations/
    instances_train.json
    instances_val.json
  dataset.yaml

Training mit COCO-JSON ausführen#

Sobald Datasetklasse, Trainerklasse und YAML-Konfiguration eingerichtet sind, funktioniert das Training mit dem standardmäßigen Aufruf von model.train(). Der einzige Unterschied zu einem normalen Trainingslauf ist das Argument trainer=COCOTrainer, das Ultralytics anweist, statt des Standardladers den benutzerdefinierten Dataset-Lader zu verwenden.

from ultralytics import YOLO

model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)

Die vollständige Trainings-Pipeline läuft wie erwartet, einschließlich der Validierung während des Trainings, des Speicherns von Prüfpunkten und der Protokollierung von Metriken.

Für eigenständige Aufrufe von `model.val()` ist eine eigene Überschreibung erforderlich

Nur die Validierung während des Trainings läuft über COCOTrainer.build_dataset. Ein separater Aufruf von model.val() erstellt das Standard-YOLODataset, das neben den Bildern nach .txt-Labels sucht, aber keine findet. Es wird kein Fehler ausgelöst: Die Bilder werden als Hintergründe gezählt, sodass die Validierung vollständig durchläuft und für alle Metriken 0 meldet; außerdem werden No labels found in ... und no labels found in detect set, cannot compute metrics without labels ausgegeben. Um außerhalb eines Trainingslaufs zu validieren, leite den Validator ab, überschreibe dieselbe Methode build_dataset und übergib ihn an model.val(validator=...).

Vollständige Implementierung#

Der Einfachheit halber folgt unten die vollständige Implementierung als einzelnes Skript zum Kopieren und Einfügen. Es umfasst das benutzerdefinierte Dataset, den benutzerdefinierten Trainer und den Trainingsaufruf. Speichere es neben deiner dataset.yaml-Datei und führe es direkt aus.

import json
from collections import defaultdict
from pathlib import Path

import numpy as np

from ultralytics import YOLO
from ultralytics.data.dataset import DATASET_CACHE_VERSION, YOLODataset
from ultralytics.data.utils import get_hash, load_dataset_cache_file, save_dataset_cache_file
from ultralytics.models.yolo.detect import DetectionTrainer
from ultralytics.utils import TQDM, colorstr

class COCODataset(YOLODataset):
    """Dataset that reads COCO JSON annotations directly without conversion to .txt files."""

    def __init__(self, *args, json_file="", **kwargs):
        """Initialize the dataset with a COCO JSON annotation file."""
        self.json_file = json_file
        super().__init__(*args, data={"channels": 3}, **kwargs)

    def get_img_files(self, img_path):
        """Image paths are resolved from the JSON file, not from scanning a directory."""
        self.fraction = 1.0  # fraction is applied while scanning a directory, which this dataset skips
        return []

    def cache_labels(self, path=Path("./labels.cache")):
        """Parse COCO JSON and convert annotations to YOLO format. Results are saved to a .cache file."""
        x = {"labels": []}
        with open(self.json_file) as f:
            coco = json.load(f)

        categories = {cat["id"]: i for i, cat in enumerate(sorted(coco["categories"], key=lambda c: c["id"]))}

        img_to_anns = defaultdict(list)
        for ann in coco["annotations"]:
            img_to_anns[ann["image_id"]].append(ann)

        for img_info in TQDM(coco["images"], desc="reading annotations"):
            h, w = img_info["height"], img_info["width"]
            im_file = Path(self.img_path) / img_info["file_name"]
            if not im_file.exists():
                continue

            self.im_files.append(str(im_file))
            bboxes = []
            for ann in img_to_anns.get(img_info["id"], []):
                if ann.get("iscrowd", False):
                    continue
                box = np.array(ann["bbox"], dtype=np.float32)
                box[:2] += box[2:] / 2
                box[[0, 2]] /= w
                box[[1, 3]] /= h
                if box[2] <= 0 or box[3] <= 0:
                    continue
                cls = categories[ann["category_id"]]
                bboxes.append([cls, *box.tolist()])

            lb = np.array(bboxes, dtype=np.float32) if bboxes else np.zeros((0, 5), dtype=np.float32)
            x["labels"].append(
                {
                    "im_file": str(im_file),
                    "shape": (h, w),
                    "cls": lb[:, 0:1],
                    "bboxes": lb[:, 1:],
                    "segments": [],
                    "normalized": True,
                    "bbox_format": "xywh",
                }
            )
        if not x["labels"]:
            raise RuntimeError(f"No images listed in {self.json_file} were found in {self.img_path}")
        x["hash"] = get_hash([self.json_file, str(self.img_path)])
        save_dataset_cache_file(self.prefix, path, x, DATASET_CACHE_VERSION)
        return x

    def get_labels(self):
        """Load labels from .cache file if available, otherwise parse JSON and create the cache."""
        cache_path = Path(self.json_file).with_suffix(".cache")
        try:
            cache = load_dataset_cache_file(cache_path)
            assert cache["version"] == DATASET_CACHE_VERSION
            assert cache["hash"] == get_hash([self.json_file, str(self.img_path)])
            self.im_files = [lb["im_file"] for lb in cache["labels"]]
        except (FileNotFoundError, AssertionError, AttributeError, KeyError, ModuleNotFoundError):
            cache = self.cache_labels(cache_path)
        cache.pop("hash", None)
        cache.pop("version", None)
        return cache["labels"]

class COCOTrainer(DetectionTrainer):
    """Trainer that uses COCODataset for direct COCO JSON training."""

    def build_dataset(self, img_path, mode="train", batch=None):
        """Build a COCODataset for the given split using the JSON file from the data config."""
        json_file = self.data["train_json"] if mode == "train" else self.data["val_json"]
        return COCODataset(
            img_path=img_path,
            json_file=json_file,
            imgsz=self.args.imgsz,
            batch_size=batch,
            augment=mode == "train",
            hyp=self.args,
            rect=self.args.rect or mode == "val",
            cache=self.args.cache or None,
            single_cls=self.args.single_cls or False,
            stride=int(self.model.stride.max()) if hasattr(self, "model") and self.model else 32,
            pad=0.0 if mode == "train" else 0.5,
            prefix=colorstr(f"{mode}: "),
            task=self.args.task,
            classes=self.args.classes,
            fraction=self.args.fraction if mode == "train" else 1.0,
        )

model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)

Du hast jetzt ein minimales Dataset und einen Trainer, mit denen Ultralytics YOLO direkt mit COCO-JSON trainiert werden kann. Die Annotationen bleiben dabei die einzige maßgebliche Quelle und es werden keine zwischengeschalteten .txt-Dateien erstellt. Erweitere die Methode cache_labels() um segments oder keypoints, um Segmentierung und Pose abzudecken, und lies im Leitfaden Tipps zum Modelltraining die Empfehlungen zur Hyperparameter-Abstimmung.

Häufig gestellte Fragen#

  • convert_coco() schreibt .txt-Labeldateien bei einer einmaligen Konvertierung auf die Festplatte. Bei diesem Ansatz wird die JSON-Datei zu Beginn jedes Trainingslaufs geparst und die Annotationen werden im Arbeitsspeicher umgewandelt. Verwende convert_coco(), wenn dauerhaft gespeicherte Labels im YOLO-Format erwünscht sind. Verwende diesen Ansatz, wenn das COCO-JSON die einzige maßgebliche Quelle bleiben soll, ohne zusätzliche Dateien zu erzeugen.

  • Nicht mit der aktuellen Ultralytics-Pipeline, die standardmäßig YOLO-Labels .txt erwartet. Dieser Leitfaden enthält den minimal erforderlichen benutzerdefinierten Code — eine Datasetklasse und eine Trainerklasse. Sobald diese definiert sind, genügt für das Training ein standardmäßiger Aufruf von model.train().

  • Dieser Leitfaden behandelt die Objekterkennung. Um die Instanzsegmentierung zu unterstützen, füge die Polygondaten segmentation aus den COCO-Annotationen in das Feld segments jedes Label-Dictionarys ein. Für die Posenschätzung fügst du keypoints hinzu. Der GroundingDataset-Quellcode enthält eine Referenzimplementierung zur Verarbeitung von Segmenten.

  • Ja. COCODataset erweitert YOLODataset, sodass alle integrierten Datenaugmentierungen — Mosaik, MixUp, CutMix und weitere — ohne Änderungen ausgeführt werden.

  • Kategorien werden nach id sortiert und fortlaufenden Indizes ab 0 zugeordnet. Damit werden IDs ab 1 (standardmäßiges COCO), IDs ab 0 und nicht zusammenhängende IDs verarbeitet. Das Dictionary names in dataset.yaml sollte dieselbe Sortierreihenfolge wie das COCO-Array categories aufweisen.

  • Das COCO-JSON wird beim ersten Trainingslauf einmal geparst. Die geparsten Labels werden in einer .cache-Datei gespeichert, sodass sie bei späteren Läufen sofort und ohne erneutes Parsen geladen werden. Die Trainingsgeschwindigkeit entspricht dem standardmäßigen YOLO-Training, da die Annotationen im Arbeitsspeicher gehalten werden. Der Cache basiert auf der Dateigröße der JSON-Datei. Lösche daher nach jeder Änderung, bei der die Dateilänge gleich bleibt, die Datei .cache.

Kommentare