Comment entraîner YOLO avec COCO JSON sans conversion#
Les annotations au format COCO JSON peuvent être utilisées directement pour entraîner Ultralytics YOLO, sans les convertir d'abord en fichiers .txt. Pour cela, crée une sous-classe de YOLODataset afin d'analyser COCO JSON à la volée, puis intègre-la au pipeline d'entraînement au moyen d'un entraîneur personnalisé.
Pourquoi entraîner directement avec COCO JSON#
Cette approche conserve le JSON COCO comme source unique de vérité : aucun appel à convert_coco(), aucune réorganisation des répertoires, aucun fichier d’étiquettes 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’étiquette supplémentaires (voir la FAQ).
Consulte le guide de conversion COCO vers YOLO pour suivre le processus standard convert_coco().
Vue d’ensemble de l’architecture#
Deux classes sont nécessaires :
COCODataset— lit le JSON COCO et convertit les boîtes englobantes au format YOLO en mémoire pendant l’entraînementCOCOTrainer— remplacebuild_dataset()pour utiliserCOCODatasetau lieu de la valeur par défautYOLODataset
L’implémentation est une version simplifiée de GroundingDataset, intégré au système, qui lit également directement les annotations JSON. Trois méthodes sont remplacées ici — get_img_files(), cache_labels() et get_labels() — tandis que GroundingDataset en remplace davantage, notamment ses propres vérifications de hachage du cache et de nombre d’instances.
Création de la classe de jeu de données COCO JSON#
La classe COCODataset hérite de YOLODataset et remplace la logique de chargement des étiquettes. Au lieu de lire les fichiers .txt dans un répertoire d’étiquettes, elle ouvre le fichier JSON COCO, 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 YOLO normalisé centré [x_center, y_center, width, height]. Les annotations de foule (iscrowd: 1) et les boîtes de surface nulle sont ignorées automatiquement.
La méthode get_img_files() renvoie une liste vide, car les chemins des images sont résolus à partir du champ JSON file_name dans cache_labels(). Les identifiants de catégorie sont triés et remappés en indices de classe commençant à zéro : les schémas d’identifiants standard COCO commençant à 1 et les schémas non contigus fonctionnent donc correctement.
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 étiquettes 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, sans nouvelle analyse du JSON.
get_hash() hache les tailles et les chemins des fichiers plutôt que leur contenu ; le JSON n’est donc analysé à nouveau lors d’une nouvelle exécution que si son nombre d’octets change. L’ajout ou la suppression d’images peut également modifier la taille du répertoire d’images et déclencher une reconstruction, mais ne compte pas là-dessus : le hachage n’inspecte jamais les fichiers image individuellement, donc le remplacement d’une image par une autre peut laisser la taille inchangée. Une modification qui conserve le nombre d’octets — décaler une coordonnée, inverser iscrowd, échanger deux noms de classe de même longueur — laisse le cache obsolète en place ; l’entraînement utilise alors les anciennes annotations sans avertissement. Le remplacement d’une image sur place est invisible pour la même raison. Après avoir modifié les annotations ou les images sur place, supprime le fichier .cache.
Connexion du jeu de données au pipeline d’entraînement#
La seule modification nécessaire dans le trainer consiste à remplacer build_dataset(). Par défaut, DetectionTrainer construit un YOLODataset qui recherche les fichiers d’étiquettes .txt. En le remplaçant par COCODataset, le trainer lit plutôt le JSON COCO.
Le chemin du fichier JSON est extrait d’un champ personnalisé train_json / val_json dans la configuration des données (voir Configurer dataset.yaml). Pendant l’entraînement, mode="train" pointe vers train_json ; pendant la validation, mode="val" pointe vers val_json. Les deux clés sont nécessaires : les deux divisions utilisent des répertoires d’images différents, donc le JSON d’entraînement ne peut pas remplacer un val_json manquant.
Le jeu de données réinitialise également fraction à 1.0. BaseDataset applique cet argument lors du parcours d’un répertoire d’images, une étape ignorée par COCODataset ; il ne peut donc pas respecter une demande portant sur un sous-ensemble du jeu de données. La réinitialisation évite que le jeu de données semble 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 JSON COCO#
Le dataset.yaml utilise les champs standard path, train et val pour localiser les répertoires d’images. Note que path désigne ici la racine des images ; train et val sont donc de simples noms de divisions, contrairement au guide de conversion, où path désigne la racine du jeu de données et où les divisions sont précédées de images/. Deux champs supplémentaires, train_json et val_json, indiquent les fichiers d’annotation COCO lus par COCOTrainer. Le champ names répertorie les noms de classe 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 un JSON COCO#
Une fois la classe de jeu de données, la classe de trainer et la configuration YAML en place, l’entraînement s’exécute avec l’appel standard model.train(). La seule différence par rapport à un entraînement normal est l’argument trainer=COCOTrainer, qui indique à Ultralytics d’utiliser le chargeur de jeu de données personnalisé plutôt que celui par défaut.
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)Le pipeline complet d’entraînement fonctionne comme prévu, y compris la validation pendant l’entraînement, l’enregistrement des points de contrôle et la journalisation des métriques.
Seule la validation effectuée pendant l’entraînement passe par COCOTrainer.build_dataset. Un appel distinct à model.val() construit le YOLODataset standard, qui recherche les étiquettes .txt à côté des images et n’en trouve aucune. Il ne génère pas d’erreur : les images sont comptées comme des arrière-plans, la validation va jusqu’au bout et toutes les métriques sont indiquées 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, crée une sous-classe du validateur avec le même remplacement build_dataset et transmets-la à model.val(validator=...).
Implémentation complète#
Pour plus de commodité, l’implémentation complète est fournie ci-dessous dans un seul script à copier-coller. Elle comprend le jeu de données personnalisé, le trainer personnalisé et l’appel d’entraînement. Enregistre le script à côté de ton dataset.yaml et 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 jeu de données et d’un trainer minimaux qui entraînent Ultralytics YOLO directement sur un JSON COCO, en gardant les annotations comme source unique de vérité et sans fichiers .txt intermédiaires. Étends la méthode cache_labels() avec segments ou keypoints pour prendre en charge la segmentation et la pose, et 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’étiquettes.txtsur le disque lors d’une conversion ponctuelle. Cette approche analyse le JSON au début de chaque entraînement et convertit les annotations en mémoire. Utiliseconvert_coco()si tu préfères des étiquettes permanentes au format YOLO ; utilise cette approche pour conserver le JSON COCO 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 étiquettes YOLO
.txt. Ce guide fournit le code personnalisé minimal nécessaire : une classe de jeu de données 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 de polygones
segmentationdes annotations COCO dans le champsegmentsde chaque dictionnaire d’étiquettes. 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; toutes les augmentations de données intégrées — mosaïque, mixup, CutMix et autres — fonctionnent sans modification.Les catégories sont triées par
id, puis associées à des indices séquentiels commençant à 0. Cela fonctionne avec les identifiants commençant à 1 (COCO standard), ceux commençant à 0 et les identifiants non contigus. Le dictionnairenamesdansdataset.yamldoit respecter le même ordre de tri que le tableau COCOcategories.Le JSON COCO est analysé une fois lors du premier entraînement. Les étiquettes analysées sont enregistrées dans un fichier
.cache; les exécutions suivantes le chargent donc instantanément, sans nouvelle analyse. La vitesse d’entraînement est identique à celle d’un entraînement YOLO standard, car les annotations sont conservées en mémoire. Le cache dépend de la taille du fichier JSON : supprime le fichier.cacheaprès toute modification qui ne change pas la longueur du fichier.