Ultralytics YOLO27:
Get Started

Cómo entrenar YOLO con COCO JSON sin convertirlo#

Las anotaciones en formato COCO JSON se pueden utilizar directamente para entrenar Ultralytics YOLO sin convertirlas primero en archivos .txt. Para ello, crea una subclase de YOLODataset que analice COCO JSON sobre la marcha e intégrala en el flujo de entrenamiento mediante un entrenador personalizado.

Por qué entrenar directamente con COCO JSON#

Este enfoque mantiene el JSON de COCO como única fuente de verdad: no hay ninguna llamada a convert_coco(), ni reorganización de directorios ni archivos de etiquetas intermedios. Se admiten YOLO26 y todos los demás modelos de detección YOLO de Ultralytics. Los modelos de segmentación y pose requieren campos de etiquetas adicionales (consulta las FAQ).

¿Buscas una conversión puntual?

Consulta la guía de conversión de COCO a YOLO para ver el flujo de trabajo estándar de convert_coco().

Descripción general de la arquitectura#

Se necesitan dos clases:

  1. COCODataset — lee el JSON de COCO y convierte las cajas delimitadoras al formato YOLO en memoria durante el entrenamiento
  2. COCOTrainer — sobrescribe build_dataset() para usar COCODataset en lugar de YOLODataset, que es el valor predeterminado

La implementación es una versión simplificada de GroundingDataset, que también lee directamente las anotaciones JSON. Aquí se sobrescriben tres métodos: get_img_files(), cache_labels() y get_labels(); por su parte, GroundingDataset sobrescribe más métodos, incluidas sus propias comprobaciones de hash de caché y recuento de instancias.

Creación de la clase de conjunto de datos COCO JSON#

La clase COCODataset hereda de YOLODataset y sobrescribe la lógica de carga de etiquetas. En lugar de leer archivos .txt de un directorio de etiquetas, abre el archivo JSON de COCO, recorre las anotaciones agrupadas por imagen y convierte cada caja delimitadora del formato de píxeles COCO [x_min, y_min, width, height] al formato YOLO normalizado con el centro [x_center, y_center, width, height]. Las anotaciones de multitudes (iscrowd: 1) y las cajas de área cero se omiten automáticamente.

El método get_img_files() devuelve una lista vacía porque las rutas de las imágenes se resuelven a partir del campo JSON file_name dentro de cache_labels(). Los ID de categoría se ordenan y se vuelven a asignar a índices de clase que empiezan en cero, por lo que funcionan correctamente tanto los esquemas de ID basados en 1 (COCO estándar) como los esquemas con ID no consecutivos.

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"]

Las etiquetas analizadas se guardan en un archivo .cache junto al JSON (p. ej., instances_train.cache). En las siguientes ejecuciones de entrenamiento, la caché se carga directamente y se omite el análisis del JSON.

La clave de caché es el tamaño del archivo JSON, no su contenido

get_hash() calcula el hash de los tamaños y las rutas de los archivos, no de su contenido, así que, al volver a ejecutar el proceso, solo analiza de nuevo el JSON si cambia su tamaño en bytes. Añadir o quitar imágenes también puede cambiar el tamaño del propio directorio de imágenes y provocar una reconstrucción, pero no cuentes con ello: el hash nunca inspecciona los archivos de imagen por separado, así que sustituir una imagen por otra puede dejar el tamaño intacto. Una edición que mantenga el mismo número de bytes —ajustar una coordenada, cambiar iscrowd o sustituir dos nombres de clase de la misma longitud— deja la caché obsoleta y el entrenamiento usa las anotaciones antiguas sin avisar; sustituir una imagen sin cambiar su ubicación tampoco se detecta por el mismo motivo. Elimina el archivo .cache después de editar las anotaciones o las imágenes sin cambiar su ubicación.

Conexión del conjunto de datos al flujo de entrenamiento#

El único cambio necesario en el entrenador es sobrescribir build_dataset(). El valor predeterminado DetectionTrainer crea un YOLODataset que busca archivos de etiquetas .txt. Al sustituirlo por COCODataset, el entrenador lee directamente del JSON de COCO.

La ruta del archivo JSON se obtiene de un campo personalizado train_json / val_json en la configuración de datos (consulta Configuración de dataset.yaml). Durante el entrenamiento, mode="train" se resuelve como train_json; durante la validación, mode="val" se resuelve como val_json. Se necesitan ambas claves: las dos particiones leen directorios de imágenes distintos, por lo que el JSON de entrenamiento no puede sustituir a un val_json que falte.

El conjunto de datos también restablece fraction a 1.0. BaseDataset aplica ese argumento al recorrer un directorio de imágenes, un paso que COCODataset omite, por lo que no puede respetar una solicitud de conjunto de datos parcial; restablecerlo evita que parezca que el conjunto de datos acepta un valor que ignora. El GroundingDataset integrado aplica la misma solución por el mismo motivo.

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,
        )

Configuración de dataset.yaml para JSON de COCO#

dataset.yaml usa los campos estándar path, train y val para localizar los directorios de imágenes. Ten en cuenta que aquí path apunta a la raíz de las imágenes, por lo que train y val son nombres de partición sin prefijo, a diferencia de la guía de conversión, donde path es la raíz del conjunto de datos y las particiones llevan el prefijo images/. Dos campos adicionales, train_json y val_json, especifican los archivos de anotaciones COCO que lee COCOTrainer. El campo names enumera los nombres de clase en el orden ordenado de categories en el JSON, y el número de clases se obtiene a partir de él, así que no hace falta establecer nc.

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

Estructura de directorios esperada:

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

Ejecución del entrenamiento con JSON de COCO#

Una vez configurados la clase del conjunto de datos, la clase del entrenador y el archivo YAML, el entrenamiento funciona con la llamada estándar model.train(). La única diferencia con una ejecución de entrenamiento normal es el argumento trainer=COCOTrainer, que indica a Ultralytics que use el cargador de conjuntos de datos personalizado en lugar del predeterminado.

from ultralytics import YOLO

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

El flujo completo de entrenamiento funciona como se espera, incluida la validación durante el entrenamiento, el guardado de puntos de control y el registro de métricas.

`model.val()` independiente necesita su propia sobrescritura

Solo la validación durante el entrenamiento pasa por COCOTrainer.build_dataset. Una llamada independiente a model.val() crea el YOLODataset estándar, que busca etiquetas .txt junto a las imágenes y no encuentra ninguna. No genera ningún error: las imágenes se cuentan como fondos, así que la validación termina y devuelve todas las métricas como 0, con las advertencias No labels found in ... y no labels found in detect set, cannot compute metrics without labels. Para validar fuera de una ejecución de entrenamiento, crea una subclase del validador con la misma sobrescritura build_dataset y pásala a model.val(validator=...).

Implementación completa#

Para mayor comodidad, a continuación se incluye la implementación completa en un único script listo para copiar y pegar. Incluye el conjunto de datos personalizado, el entrenador personalizado y la llamada de entrenamiento. Guarda el script junto a tu dataset.yaml y ejecútalo directamente.

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)

Ahora tienes un conjunto de datos y un entrenador mínimos que entrenan Ultralytics YOLO directamente con JSON de COCO, mantienen las anotaciones como única fuente de verdad y no generan archivos .txt intermedios. Amplía el método cache_labels() con segments o keypoints para incluir segmentación y pose, y consulta la guía Consejos para entrenar modelos para ver recomendaciones de ajuste de hiperparámetros.

Preguntas frecuentes#

  • convert_coco() escribe archivos de etiquetas .txt en el disco como conversión puntual. Este enfoque analiza el JSON al inicio de cada ejecución de entrenamiento y convierte las anotaciones en memoria. Usa convert_coco() si prefieres etiquetas permanentes en formato YOLO; usa este enfoque para mantener el JSON de COCO como única fuente de verdad sin generar archivos adicionales.

  • No con el flujo actual de Ultralytics, que espera etiquetas .txt de YOLO de forma predeterminada. Esta guía proporciona el código personalizado mínimo necesario: una clase de conjunto de datos y una clase de entrenador. Una vez definidas, solo hace falta una llamada estándar a model.train() para entrenar.

  • Esta guía explica la detección de objetos. Para añadir compatibilidad con la segmentación de instancias, incluye los datos de polígonos segmentation de las anotaciones COCO en el campo segments de cada diccionario de etiquetas. Para la estimación de pose, incluye keypoints. El código fuente de GroundingDataset ofrece una implementación de referencia para gestionar segmentos.

  • Sí. COCODataset amplía YOLODataset, así que todas las aumentaciones de datos integradas —mosaico, mixup, CutMix y otras— funcionan sin modificaciones.

  • Las categorías se ordenan por id y se asignan a índices secuenciales que empiezan en 0. Esto permite gestionar ID basados en 1 (COCO estándar), ID basados en 0 e ID no consecutivos. El diccionario names de dataset.yaml debe seguir el mismo orden que la matriz COCO categories ordenada.

  • El JSON de COCO se analiza una vez en la primera ejecución de entrenamiento. Las etiquetas analizadas se guardan en un archivo .cache, así que las ejecuciones posteriores se cargan al instante, sin volver a analizarlo. La velocidad de entrenamiento es idéntica a la del entrenamiento YOLO estándar, ya que las anotaciones se mantienen en memoria. La caché se basa en el tamaño del archivo JSON, así que elimina el archivo .cache después de cualquier edición que mantenga la misma longitud del archivo.

Comentarios