Come addestrare YOLO su COCO JSON senza conversione#
Le annotazioni in formato COCO JSON possono essere utilizzate direttamente per l'addestramento di Ultralytics YOLO senza convertirle prima in file .txt. Questo è possibile creando una sottoclasse di YOLODataset per analizzare COCO JSON al volo e integrandola nella pipeline di addestramento tramite un trainer personalizzato.
Perché addestrare direttamente su COCO JSON#
Questo approccio mantiene COCO JSON come unica fonte di verità: nessuna chiamata a convert_coco(), nessuna riorganizzazione delle directory e nessun file di etichette intermedio. YOLO26 e tutti gli altri modelli di rilevamento Ultralytics YOLO sono supportati. I modelli di segmentazione e pose richiedono campi di etichetta aggiuntivi (vedi FAQ).
Consulta la guida alla conversione da COCO a YOLO per il flusso di lavoro standard convert_coco().
Panoramica dell'architettura#
Sono necessarie due classi:
COCODataset— legge COCO JSON e converte le bounding box nel formato YOLO in memoria durante l'addestramentoCOCOTrainer— sovrascrivebuild_dataset()per utilizzareCOCODatasetinvece diYOLODataset, il valore predefinito
L'implementazione è una versione semplificata di GroundingDataset integrato, che legge direttamente anche le annotazioni JSON. Qui vengono sovrascritti tre metodi: get_img_files(), cache_labels() e get_labels(); GroundingDataset ne sovrascrive altri, inclusi i propri controlli dell'hash della cache e del conteggio delle istanze.
Creazione della classe del dataset COCO JSON#
La classe COCODataset eredita da YOLODataset e sovrascrive la logica di caricamento delle etichette. Invece di leggere i file .txt da una directory delle etichette, apre il file COCO JSON, itera sulle annotazioni raggruppate per immagine e converte ogni bounding box dal formato in pixel COCO [x_min, y_min, width, height] al formato YOLO normalizzato con centro [x_center, y_center, width, height]. Le annotazioni di tipo crowd (iscrowd: 1) e le box ad area zero vengono ignorate automaticamente.
Il metodo get_img_files() restituisce un elenco vuoto perché i percorsi delle immagini vengono ricavati dal campo file_name del JSON all'interno di cache_labels(). Gli ID delle categorie vengono ordinati e rimappati su indici di classe a partire da zero, quindi funzionano correttamente sia gli schemi con ID a partire da 1 (lo standard COCO) sia quelli con ID non contigui.
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"]Le etichette analizzate vengono salvate in un file .cache accanto al JSON (ad esempio instances_train.cache). Nelle esecuzioni successive dell'addestramento, la cache viene caricata direttamente, evitando di analizzare nuovamente il JSON.
get_hash() calcola l'hash delle dimensioni e dei percorsi dei file anziché dei loro contenuti, quindi una nuova esecuzione analizza nuovamente il JSON solo quando cambia il numero di byte del JSON. L'aggiunta o la rimozione di immagini può anche modificare la dimensione della directory delle immagini e attivare una ricostruzione, ma non farci affidamento: l'hash non esamina mai i singoli file immagine, quindi sostituire un'immagine con un'altra può lasciare invariata la dimensione. Una modifica che mantiene invariato il numero di byte — spostare leggermente una coordinata, invertire iscrowd, scambiare due nomi di classe della stessa lunghezza — lascia intatta la cache obsoleta e addestra sulle vecchie annotazioni senza alcun avviso; per lo stesso motivo, anche sostituire un'immagine sul posto è invisibile. Dopo aver modificato le annotazioni o sostituito immagini sul posto, elimina il file .cache.
Collegamento del dataset alla pipeline di addestramento#
L'unica modifica necessaria nel trainer consiste nel sovrascrivere build_dataset(). Il valore predefinito DetectionTrainer crea un YOLODataset che cerca i file di etichette .txt. Sostituendolo con COCODataset, il trainer legge invece dal COCO JSON.
Il percorso del file JSON viene prelevato da un campo personalizzato train_json / val_json nella configurazione dei dati (vedi Configurazione di dataset.yaml). Durante l'addestramento, mode="train" viene risolto in train_json; durante la validazione, mode="val" viene risolto in val_json. Entrambe le chiavi sono obbligatorie: i due split leggono directory di immagini diverse, quindi il JSON di addestramento non può sostituire un val_json mancante.
Il dataset reimposta inoltre fraction su 1.0. BaseDataset applica questo argomento durante la scansione di una directory di immagini, un passaggio che COCODataset ignora, quindi non può rispettare una richiesta relativa a un dataset parziale; reimpostarlo impedisce al dataset di sembrare accettare un valore che ignora. Il valore predefinito integrato GroundingDataset adotta lo stesso compromesso per lo stesso 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,
)Configurazione di dataset.yaml per COCO JSON#
dataset.yaml utilizza i campi standard path, train e val per individuare le directory delle immagini. Nota che qui path punta alla radice delle immagini, quindi train e val sono semplici nomi degli split, a differenza della guida alla conversione, in cui path è la radice del dataset e gli split hanno un prefisso images/. Due campi aggiuntivi, train_json e val_json, specificano i file di annotazione COCO letti da COCOTrainer. Il campo names elenca i nomi delle classi nell'ordine ordinato di categories nel JSON, e il numero di classi viene ricavato da questo campo, quindi non è necessario impostare 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 namesStruttura delle directory prevista:
my_dataset/
images/
train/
img_001.jpg
...
val/
img_100.jpg
...
annotations/
instances_train.json
instances_val.json
dataset.yamlEsecuzione dell'addestramento su COCO JSON#
Una volta definite la classe del dataset, la classe del trainer e la configurazione YAML, l'addestramento funziona tramite la chiamata standard model.train(). L'unica differenza rispetto a una normale esecuzione dell'addestramento è l'argomento trainer=COCOTrainer, che indica a Ultralytics di utilizzare il caricatore del dataset personalizzato invece di quello predefinito.
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)L'intera pipeline di addestramento viene eseguita come previsto, inclusi la validazione durante l'addestramento, il salvataggio dei checkpoint e la registrazione delle metriche.
Solo la validazione durante l'addestramento passa attraverso COCOTrainer.build_dataset. Una chiamata separata a model.val() crea il YOLODataset standard, che cerca le etichette .txt accanto alle immagini e non ne trova. Non genera un errore: le immagini vengono conteggiate come sfondi, quindi la validazione termina e riporta ogni metrica come 0, mostrando gli avvisi No labels found in ... e no labels found in detect set, cannot compute metrics without labels. Per eseguire la validazione al di fuori di un'esecuzione di addestramento, crea una sottoclasse del validator con la stessa sovrascrittura build_dataset e passala a model.val(validator=...).
Implementazione completa#
Per comodità, l'implementazione completa è fornita di seguito come un unico script da copiare e incollare. Include il dataset personalizzato, il trainer personalizzato e la chiamata di addestramento. Salvalo accanto al tuo dataset.yaml ed eseguilo direttamente.
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)Ora disponi di un dataset e di un trainer minimi che addestrano Ultralytics YOLO direttamente su COCO JSON, mantenendo le annotazioni come unica fonte di verità e senza file .txt intermedi. Estendi il metodo cache_labels() con segments o keypoints per supportare segmentazione e pose, e consulta la guida Suggerimenti per l'addestramento dei modelli per consigli sull'ottimizzazione degli iperparametri.
FAQ#
convert_coco()scrive su disco i file di etichette.txtcome conversione una tantum. Questo approccio analizza il JSON all'inizio di ogni esecuzione di addestramento e converte le annotazioni in memoria. Usaconvert_coco()quando preferisci etichette permanenti nel formato YOLO; usa questo approccio per mantenere COCO JSON come unica fonte di verità senza generare file aggiuntivi.Non con l'attuale pipeline Ultralytics, che per impostazione predefinita si aspetta etichette YOLO
.txt. Questa guida fornisce il codice personalizzato minimo necessario: una classe del dataset e una classe del trainer. Una volta definite, per l'addestramento è necessaria solo una chiamata standard amodel.train().Questa guida tratta il rilevamento degli oggetti. Per aggiungere il supporto alla segmentazione delle istanze, includi i dati dei poligoni
segmentationdalle annotazioni COCO nel camposegmentsdi ogni dizionario delle etichette. Per la stima della posa, includikeypoints. Il codice sorgente diGroundingDatasetfornisce un'implementazione di riferimento per la gestione dei segmenti.Sì.
COCODatasetestendeYOLODataset, quindi tutte le operazioni di aumento dei dati integrate — mosaic, mixup, copy-paste e altre — vengono eseguite senza modifiche.Le categorie vengono ordinate in base a
ide mappate su indici consecutivi a partire da 0. Questo gestisce ID a partire da 1 (lo standard COCO), ID a partire da 0 e ID non contigui. Il dizionarionamesindataset.yamldeve seguire lo stesso ordine ordinato dell'array COCOcategories.COCO JSON viene analizzato una volta durante la prima esecuzione dell'addestramento. Le etichette analizzate vengono salvate in un file
.cache, quindi nelle esecuzioni successive vengono caricate immediatamente senza una nuova analisi. La velocità di addestramento è identica a quella dell'addestramento YOLO standard, poiché le annotazioni vengono mantenute in memoria. La cache utilizza la dimensione del file JSON come chiave, quindi elimina il file.cachedopo qualsiasi modifica che lasci invariata la lunghezza del file.