Cómo entrenar YOLO en COCO JSON sin convertir#
Las anotaciones en formato COCO JSON se pueden utilizar directamente para entrenar Ultralytics YOLO sin convertirlas primero en archivos .txt. Esto funciona creando una subclase de YOLODataset para analizar COCO JSON sobre la marcha e integrándola en el flujo de entrenamiento mediante un entrenador personalizado.
Por qué entrenar directamente con COCO JSON#
Este enfoque mantiene COCO JSON como única fuente de verdad: no requiere ninguna llamada a convert_coco(), reorganizar directorios ni crear archivos de etiquetas intermedios. YOLO26 y todos los demás modelos de detección de Ultralytics YOLO son compatibles. Los modelos de segmentación y pose requieren campos de etiquetas adicionales (consulta las preguntas frecuentes).
Consulta la guía de conversión de COCO a YOLO para seguir el flujo de trabajo estándar de convert_coco().
Descripción general de la arquitectura#
Se necesitan dos clases:
COCODataset— lee COCO JSON y convierte las cajas delimitadoras al formato YOLO en memoria durante el entrenamientoCOCOTrainer— sobrescribebuild_dataset()para utilizarCOCODataseten lugar delYOLODatasetpredeterminado
La implementación es una versión simplificada de GroundingDataset, integrado en el sistema y que también lee directamente las anotaciones JSON. Aquí se sobrescriben tres métodos: get_img_files(), cache_labels() y get_labels(); GroundingDataset sobrescribe más elementos, incluidas sus propias comprobaciones del hash de la caché y del recuento de instancias.
Creación de la clase de dataset COCO JSON#
La clase COCODataset hereda de YOLODataset y sobrescribe la lógica de carga de etiquetas. En lugar de leer archivos .txt desde un directorio de etiquetas, abre el archivo COCO JSON, itera sobre las anotaciones agrupadas por imagen y convierte cada caja delimitadora del formato de píxeles COCO [x_min, y_min, width, height] al formato de centro normalizado de YOLO [x_center, y_center, width, height]. Las anotaciones de multitudes (iscrowd: 1) y las cajas con á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 file_name del JSON dentro de cache_labels(). Los ID de categoría se ordenan y se reasignan a índices de clase basados en cero, por lo que funcionan correctamente tanto los esquemas basados en 1 (el estándar de COCO) como los ID no contiguos.
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 (por ejemplo, instances_train.cache). En las ejecuciones de entrenamiento posteriores, la caché se carga directamente y se omite el análisis del JSON.
get_hash() calcula el hash de los tamaños y las rutas de los archivos, no de su contenido, por lo que una nueva ejecución vuelve a analizar el JSON únicamente cuando cambia su número de bytes. Añadir o eliminar imágenes también puede modificar el tamaño del propio directorio de imágenes y activar una reconstrucción, pero no dependas de ello: el hash nunca inspecciona los archivos de imagen individuales, así que sustituir una imagen por otra puede mantener el tamaño sin cambios. Una edición que conserve el número de bytes —ajustar una coordenada, cambiar iscrowd, intercambiar dos nombres de clase con la misma longitud— deja la caché obsoleta intacta y entrena con las anotaciones antiguas sin avisar; sustituir una imagen en el mismo lugar es igualmente invisible por el mismo motivo. Elimina el archivo .cache después de editar las anotaciones o las imágenes en el mismo lugar.
Conexión del dataset al flujo de entrenamiento#
El único cambio necesario en el entrenador consiste en sobrescribir build_dataset(). El DetectionTrainer predeterminado crea un YOLODataset que busca archivos de etiquetas .txt. Al sustituirlo por COCODataset, el entrenador lee del COCO JSON.
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 en train_json; durante la validación, mode="val" se resuelve en val_json. Ambas claves son obligatorias: las dos particiones leen directorios de imágenes diferentes, por lo que el JSON de entrenamiento no puede sustituir a un val_json ausente.
El dataset también restablece fraction a 1.0. BaseDataset aplica ese argumento al explorar un directorio de imágenes, un paso que COCODataset omite, por lo que no puede respetar una solicitud de dataset parcial; restablecerlo evita que el dataset parezca aceptar un valor que ignora. El GroundingDataset integrado adopta el mismo compromiso 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 COCO JSON#
El dataset.yaml utiliza los campos estándar path, train y val para localizar los directorios de imágenes. Ten en cuenta que path apunta aquí a la raíz de las imágenes, por lo que train y val son nombres de partición simples, a diferencia de la guía de conversión, donde path es la raíz del dataset y las particiones incluyen 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 deriva de él, por lo que no es necesario 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 namesEstructura de directorios esperada:
my_dataset/
images/
train/
img_001.jpg
...
val/
img_100.jpg
...
annotations/
instances_train.json
instances_val.json
dataset.yamlEjecución del entrenamiento con COCO JSON#
Con la clase de dataset, la clase de entrenador y la configuración YAML listas, el entrenamiento funciona mediante la llamada estándar model.train(). La única diferencia respecto a una ejecución de entrenamiento normal es el argumento trainer=COCOTrainer, que indica a Ultralytics que utilice el cargador de dataset personalizado en lugar del predeterminado.
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)Todo el flujo de entrenamiento se ejecuta como se espera, incluida la validación durante el entrenamiento, el guardado de checkpoints y el registro de métricas.
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, por lo que la validación finaliza y muestra 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 proporciona la implementación completa como un único script listo para copiar y pegar. Incluye el dataset personalizado, el entrenador personalizado y la llamada de entrenamiento. Guarda este 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)Ya tienes un dataset y un entrenador mínimos que entrenan Ultralytics YOLO directamente con COCO JSON, manteniendo las anotaciones como única fuente de verdad y sin archivos .txt intermedios. Amplía el método cache_labels() con segments o keypoints para cubrir la segmentación y la pose, y consulta la guía de consejos para el entrenamiento de modelos para obtener recomendaciones sobre el ajuste de hiperparámetros.
Preguntas frecuentes#
convert_coco()escribe archivos de etiquetas.txten el disco como conversión puntual. Este enfoque analiza el JSON al comienzo de cada ejecución de entrenamiento y convierte las anotaciones en memoria. Utilizaconvert_coco()cuando prefieras etiquetas permanentes en formato YOLO; utiliza este enfoque para mantener COCO JSON como única fuente de verdad sin generar archivos adicionales.No con el flujo actual de Ultralytics, que espera etiquetas YOLO
.txtde forma predeterminada. Esta guía proporciona el código personalizado mínimo necesario: una clase de dataset y una clase de entrenador. Una vez definidas, para entrenar solo necesitas una llamada estándar amodel.train().Esta guía cubre la detección de objetos. Para añadir compatibilidad con la segmentación de instancias, incluye los datos de polígonos
segmentationde las anotaciones COCO en el camposegmentsde cada diccionario de etiquetas. Para la estimación de pose, incluyekeypoints. El código fuente deGroundingDatasetproporciona una implementación de referencia para gestionar segmentos.Sí.
COCODatasetamplíaYOLODataset, por lo que todas las aumentaciones de datos integradas —mosaico, mixup, copiar y pegar y otras— se ejecutan sin modificaciones.Las categorías se ordenan por
idy se asignan a índices secuenciales empezando por 0. Esto gestiona ID basados en 1 (el estándar de COCO), ID basados en 0 e ID no contiguos. El diccionarionamesdedataset.yamldebe seguir el mismo orden ordenado que la matrizcategoriesde COCO.COCO JSON se analiza una vez durante la primera ejecución de entrenamiento. Las etiquetas analizadas se guardan en un archivo
.cache, por lo 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é utiliza como clave el tamaño del archivo JSON, así que elimina el archivo.cachedespués de cualquier edición que mantenga el archivo con la misma longitud.