Как обучать YOLO на COCO JSON без конвертации#
Аннотации в формате COCO JSON можно использовать напрямую для обучения Ultralytics YOLO, предварительно не преобразуя их в файлы .txt. Это работает за счёт наследования YOLODataset для разбора COCO JSON на лету и подключения его к конвейеру обучения через пользовательский тренер.
Зачем обучать напрямую на COCO JSON#
Такой подход сохраняет COCO JSON единственным источником истины — без вызова convert_coco(), реорганизации каталогов и промежуточных файлов с метками. Поддерживаются YOLO26 и все остальные модели детекции Ultralytics YOLO. Для моделей сегментации и поз требуется добавить дополнительные поля меток (см. FAQ).
Стандартный рабочий процесс convert_coco() описан в руководстве по конвертации COCO в YOLO.
Обзор архитектуры#
Потребуются два класса:
COCODataset— считывает COCO JSON и преобразует ограничивающие рамки в формат YOLO в памяти во время обученияCOCOTrainer— переопределяетbuild_dataset(), чтобы использоватьCOCODatasetвместо стандартногоYOLODataset
Реализация представляет собой упрощённую версию встроенного GroundingDataset, который также считывает аннотации JSON напрямую. Здесь переопределены три метода — get_img_files(), cache_labels() и get_labels(), — тогда как GroundingDataset переопределяет больше методов, включая собственные проверки хеша кэша и количества экземпляров.
Создание класса датасета COCO JSON#
Класс COCODataset наследуется от YOLODataset и переопределяет логику загрузки меток. Вместо чтения файлов .txt из каталога меток он открывает файл COCO JSON, перебирает сгруппированные по изображениям аннотации и преобразует каждую ограничивающую рамку из пиксельного формата COCO [x_min, y_min, width, height] в нормализованный формат YOLO с центром [x_center, y_center, width, height]. Аннотации толпы (iscrowd: 1) и рамки с нулевой площадью автоматически пропускаются.
Метод get_img_files() возвращает пустой список, поскольку пути к изображениям извлекаются из поля file_name JSON внутри cache_labels(). Идентификаторы категорий сортируются и переназначаются на начинающиеся с нуля индексы классов, поэтому корректно работают как схемы с нумерацией от 1 (стандарт COCO), так и схемы с непоследовательными идентификаторами.
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"]Разобранные метки сохраняются в файл .cache рядом с JSON (например, instances_train.cache). При последующих запусках обучения кэш загружается напрямую, поэтому разбор JSON пропускается.
get_hash() хеширует размеры и пути файлов, а не их содержимое, поэтому при повторном запуске JSON разбирается заново только при изменении количества байт в JSON. Добавление или удаление изображений также может изменить размер самого каталога изображений и запустить пересоздание, но не полагайся на это — хеш не проверяет отдельные файлы изображений, поэтому замена одного изображения другим может оставить размер неизменным. Редактирование, сохраняющее количество байт, — изменение координаты, переключение iscrowd, замена двух названий классов одинаковой длины — оставляет устаревший кэш, и обучение проходит на старых аннотациях без предупреждения; замена изображения на месте по той же причине также не обнаруживается. После редактирования аннотаций или замены изображений на месте удали файл .cache.
Подключение датасета к конвейеру обучения#
В тренере нужно переопределить только build_dataset(). Стандартный DetectionTrainer создаёт YOLODataset, который ищет файлы меток .txt. Если заменить его на COCODataset, тренер будет считывать данные из COCO JSON.
Путь к JSON-файлу берётся из пользовательского поля train_json / val_json в конфигурации данных (см. Настройка dataset.yaml). Во время обучения mode="train" разрешается в train_json; во время валидации mode="val" разрешается в val_json. Оба ключа обязательны — два поднабора считывают разные каталоги изображений, поэтому обучающий JSON не может заменить отсутствующий val_json.
Датасет также сбрасывает fraction в 1.0. BaseDataset применяет этот аргумент при сканировании каталога изображений, а COCODataset этот шаг пропускает, поэтому он не может обработать запрос на частичный датасет; сброс не даёт датасету выглядеть так, будто он принимает значение, которое игнорирует. Встроенный GroundingDataset идёт на тот же компромисс по той же причине.
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,
)Настройка dataset.yaml для COCO JSON#
dataset.yaml использует стандартные поля path, train и val для поиска каталогов изображений. Обрати внимание: здесь path указывает на корневой каталог изображений, поэтому train и val являются простыми именами поднаборов — в отличие от руководства по конвертации, где path — корень датасета, а поднаборы имеют префикс images/. Два дополнительных поля, train_json и val_json, задают файлы аннотаций COCO, которые считывает COCOTrainer. Поле names содержит названия классов в порядке сортировки categories в JSON, и количество классов выводится из него, поэтому задавать 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Ожидаемая структура каталогов:
my_dataset/
images/
train/
img_001.jpg
...
val/
img_100.jpg
...
annotations/
instances_train.json
instances_val.json
dataset.yamlЗапуск обучения на COCO JSON#
После создания класса датасета, класса тренера и конфигурации YAML обучение запускается стандартным вызовом model.train(). Единственное отличие от обычного запуска обучения — аргумент trainer=COCOTrainer, который указывает Ultralytics использовать пользовательский загрузчик датасета вместо стандартного.
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)Полный конвейер обучения работает как ожидается, включая валидацию во время обучения, сохранение контрольных точек и журналирование метрик.
Только валидация во время обучения проходит через COCOTrainer.build_dataset. Отдельный вызов model.val() создаёт стандартный YOLODataset, который ищет метки .txt рядом с изображениями и не находит их. Ошибка не возникает: изображения считаются фоновыми, поэтому валидация завершается и сообщает для каждой метрики значение 0, выводя предупреждения No labels found in ... и no labels found in detect set, cannot compute metrics without labels. Чтобы выполнять валидацию вне запуска обучения, унаследуй собственный валидатор с тем же переопределением build_dataset и передай его в model.val(validator=...).
Полная реализация#
Для удобства полная реализация приведена ниже в виде одного скрипта, который можно скопировать и вставить. Она включает пользовательский датасет, пользовательский тренер и вызов обучения. Сохрани этот код рядом с dataset.yaml и запусти его напрямую.
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)Теперь у тебя есть минимальные датасет и тренер, которые обучают Ultralytics YOLO напрямую на COCO JSON, сохраняя аннотации единственным источником истины и не создавая промежуточные файлы .txt. Расширь метод cache_labels() с помощью segments или keypoints, чтобы добавить поддержку сегментации и поз, а рекомендации по настройке гиперпараметров смотри в руководстве Советы по обучению моделей.
Часто задаваемые вопросы#
convert_coco()записывает файлы меток.txtна диск в рамках разовой конвертации. Этот подход разбирает JSON в начале каждого запуска обучения и преобразует аннотации в памяти. Используйconvert_coco(), если нужны постоянные метки в формате YOLO; используй этот подход, чтобы сохранить COCO JSON единственным источником истины без создания дополнительных файлов.Не в текущем конвейере Ultralytics, который по умолчанию ожидает метки YOLO
.txt. В этом руководстве приведён минимальный пользовательский код — один класс датасета и один класс тренера. После их определения для обучения требуется только стандартный вызовmodel.train().Это руководство посвящено детекции объектов. Чтобы добавить поддержку сегментации экземпляров, включи данные полигонов
segmentationиз аннотаций COCO в полеsegmentsкаждого словаря метки. Для оценки позы включиkeypoints. Исходный кодGroundingDatasetсодержит пример реализации обработки сегментов.Да.
COCODatasetрасширяетYOLODataset, поэтому все встроенные аугментации данных — mosaic, mixup, copy-paste и другие — работают без изменений.Категории сортируются по
idи сопоставляются с последовательными индексами, начиная с 0. Это обрабатывает идентификаторы с нумерацией от 1 (стандарт COCO), с нумерацией от 0 и непоследовательные идентификаторы. Словарьnamesвdataset.yamlдолжен следовать тому же порядку сортировки, что и массив COCOcategories.COCO JSON разбирается один раз при первом запуске обучения. Разобранные метки сохраняются в файл
.cache, поэтому при последующих запусках загружаются сразу, без повторного разбора. Скорость обучения идентична стандартному обучению YOLO, поскольку аннотации хранятся в памяти. Кэш привязан к размеру файла JSON, поэтому после любого редактирования, сохраняющего длину файла, удали файл.cache.