Comment entraîner YOLO sur du JSON COCO sans conversion#
Les annotations au format COCO JSON peuvent être utilisées directement pour entraîner Ultralytics YOLO sans les convertir au préalable en fichiers .txt. Cela fonctionne en sous-classant YOLODataset pour analyser le COCO JSON à la volée et l'intégrer au pipeline d'entraînement via un trainer personnalisé.
Pourquoi entraîner directement sur du COCO JSON#
Cette approche conserve le COCO JSON comme source unique de vérité — aucun appel à convert_coco(), aucune réorganisation des répertoires, aucun fichier d'annotations intermédiaire. YOLO26 et tous les autres modèles de détection Ultralytics YOLO sont pris en charge. Les modèles de segmentation et de pose nécessitent des champs d'annotations supplémentaires (voir la FAQ).
Consulte le guide de conversion de COCO vers YOLO pour suivre le workflow standard convert_coco().
Vue d’ensemble de l’architecture#
Deux classes sont nécessaires :
COCODataset— lit le COCO JSON et convertit les boîtes englobantes au format YOLO en mémoire pendant l'entraînementCOCOTrainer— surchargebuild_dataset()pour utiliserCOCODatasetà la place deYOLODatasetpar défaut
L'implémentation est une version simplifiée de GroundingDataset intégré, qui lit également les annotations JSON directement. Trois méthodes sont surchargées ici — get_img_files(), cache_labels() et get_labels() — tandis que GroundingDataset en surcharge davantage, notamment ses propres contrôles de hachage du cache et de comptage des instances.
Créer la classe de dataset COCO JSON#
La classe COCODataset hérite de YOLODataset et surcharge la logique de chargement des annotations. Au lieu de lire les fichiers .txt depuis un répertoire d'annotations, elle ouvre le fichier COCO JSON, parcourt les annotations regroupées par image et convertit chaque boîte englobante du format pixel COCO [x_min, y_min, width, height] au format de centre normalisé YOLO [x_center, y_center, width, height]. Les annotations de foule (iscrowd: 1) et les boîtes de surface nulle sont automatiquement ignorées.
La méthode get_img_files() renvoie une liste vide, car les chemins des images sont résolus à partir du champ JSON file_name situé dans cache_labels(). Les identifiants de catégories sont triés et remappés vers des indices de classes commençant à zéro, ce qui permet de gérer correctement les schémas d'identifiants commençant à 1 (COCO standard) ainsi que les identifiants non contigus.
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"]Les annotations analysées sont enregistrées dans un fichier .cache à côté du JSON (par exemple instances_train.cache). Lors des entraînements suivants, le cache est chargé directement, ce qui évite d'analyser à nouveau le JSON.
get_hash() hache les tailles et les chemins des fichiers plutôt que leur contenu. Une nouvelle exécution n'analyse donc à nouveau le JSON que lorsque son nombre d'octets change. Ajouter ou supprimer des images peut également modifier la taille du répertoire d'images et déclencher une reconstruction, mais ne t'y fie pas : le hachage n'inspecte jamais les fichiers image individuellement, si bien que remplacer une image par une autre peut laisser la taille inchangée. Une modification qui conserve le même nombre d'octets — ajuster une coordonnée, inverser iscrowd, remplacer deux noms de classes de même longueur — laisse l'ancien cache en place et entraîne le modèle sur les anciennes annotations sans avertissement. Remplacer une image sur place est invisible pour la même raison. Supprime le fichier .cache après avoir modifié les annotations ou les images sur place.
Connecter le dataset au pipeline d'entraînement#
La seule modification nécessaire dans le trainer consiste à surcharger build_dataset(). Par défaut, DetectionTrainer construit un YOLODataset qui recherche les fichiers d'annotations .txt. En le remplaçant par COCODataset, le trainer lit à la place les données depuis le COCO JSON.
Le chemin du fichier JSON est récupéré depuis les champs personnalisés train_json / val_json de la configuration des données (voir Configurer dataset.yaml). Pendant l'entraînement, mode="train" se résout en train_json ; pendant la validation, mode="val" se résout en val_json. Les deux clés sont nécessaires : les deux partitions lisent des répertoires d'images différents, de sorte que le JSON d'entraînement ne peut pas remplacer un val_json manquant.
Le dataset réinitialise également fraction à 1.0. BaseDataset applique cet argument lors de l'analyse d'un répertoire d'images, une étape ignorée par COCODataset, qui ne peut donc pas respecter une demande portant sur un sous-ensemble du dataset. Le réinitialiser empêche le dataset de donner l'impression d'accepter une valeur qu'il ignore. Le GroundingDataset intégré fait le même compromis pour la même raison.
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,
)Configurer dataset.yaml pour le COCO JSON#
dataset.yaml utilise les champs standard path, train et val pour localiser les répertoires d'images. Note que path pointe ici vers la racine des images, si bien que train et val sont de simples noms de partitions — contrairement au guide de conversion, où path correspond à la racine du dataset et où les partitions portent un préfixe images/. Deux champs supplémentaires, train_json et val_json, indiquent les fichiers d'annotations COCO lus par COCOTrainer. Le champ names répertorie les noms de classes dans l'ordre trié de categories dans le JSON, et le nombre de classes en est déduit : il n'est donc pas nécessaire de définir 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 namesStructure de répertoires attendue :
my_dataset/
images/
train/
img_001.jpg
...
val/
img_100.jpg
...
annotations/
instances_train.json
instances_val.json
dataset.yamlLancer l'entraînement sur du COCO JSON#
Une fois la classe de dataset, la classe de trainer et la configuration YAML définies, l'entraînement s'exécute via l'appel standard model.train(). La seule différence avec un entraînement normal est l'argument trainer=COCOTrainer, qui indique à Ultralytics d'utiliser le chargeur de dataset personnalisé au lieu de celui par défaut.
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)L'ensemble du pipeline d'entraînement s'exécute comme prévu, y compris la validation pendant l'entraînement, la sauvegarde des checkpoints et la journalisation des métriques.
Seule la validation pendant l'entraînement passe par COCOTrainer.build_dataset. Un appel distinct à model.val() construit le YOLODataset standard, qui recherche des annotations .txt à côté des images et n'en trouve aucune. Il ne lève pas d'erreur : les images sont comptées comme des arrière-plans, la validation s'achève donc et signale chaque métrique comme 0, avec les avertissements No labels found in ... et no labels found in detect set, cannot compute metrics without labels. Pour valider en dehors d'un entraînement, sous-classe le validateur avec la même surcharge build_dataset et transmets-le à model.val(validator=...).
Implémentation complète#
Pour plus de commodité, l'implémentation complète est fournie ci-dessous sous la forme d'un script unique prêt à être copié-collé. Elle inclut le dataset personnalisé, le trainer personnalisé et l'appel d'entraînement. Enregistre-le à côté de ton dataset.yaml, puis exécute-le directement.
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)Tu disposes maintenant d'un dataset et d'un trainer minimaux qui entraînent Ultralytics YOLO directement sur du COCO JSON, les annotations restant la source unique de vérité et aucun fichier .txt intermédiaire n'étant créé. Étends la méthode cache_labels() avec segments ou keypoints pour prendre en charge la segmentation et la pose, puis consulte le guide Conseils pour l'entraînement des modèles pour des recommandations de réglage des hyperparamètres.
FAQ#
convert_coco()écrit les fichiers d'annotations.txtsur le disque lors d'une conversion unique. Cette approche analyse le JSON au début de chaque entraînement et convertit les annotations en mémoire. Utiliseconvert_coco()lorsque tu préfères conserver des annotations permanentes au format YOLO ; utilise cette approche pour garder le COCO JSON comme source unique de vérité sans générer de fichiers supplémentaires.Pas avec le pipeline Ultralytics actuel, qui attend par défaut des annotations YOLO
.txt. Ce guide fournit le code personnalisé minimal nécessaire — une classe de dataset et une classe de trainer. Une fois ces classes définies, l'entraînement ne nécessite qu'un appel standard àmodel.train().Ce guide couvre la détection d'objets. Pour ajouter la prise en charge de la segmentation d'instances, inclus les données polygonales
segmentationdes annotations COCO dans le champsegmentsde chaque dictionnaire d'annotations. Pour l'estimation de pose, incluskeypoints. Le code source deGroundingDatasetfournit une implémentation de référence pour gérer les segments.Oui.
COCODatasetétendYOLODataset, donc toutes les augmentations de données intégrées — mosaic, mixup, copy-paste et autres — s'exécutent sans modification.Le COCO JSON est analysé une fois lors du premier entraînement. Les annotations analysées sont enregistrées dans un fichier
.cache, si bien que les exécutions suivantes les chargent instantanément sans nouvelle analyse. La vitesse d'entraînement est identique à celle d'un entraînement YOLO standard, puisque les annotations sont conservées en mémoire. Le cache est indexé sur la taille du fichier JSON : supprime donc le fichier.cacheaprès toute modification qui conserve la même longueur de fichier.
Les catégories sont triées par
idet mappées vers des indices séquentiels commençant à 0. Cela prend en charge les identifiants commençant à 1 (COCO standard), ceux commençant à 0 et les identifiants non contigus. Le dictionnairenamesdansdataset.yamldoit respecter le même ordre trié que le tableau COCOcategories.