YOLO Vision 2026:

Как обучать 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.

Обзор архитектуры#

Потребуются два класса:

  1. COCODataset — считывает COCO JSON и преобразует ограничивающие рамки в формат YOLO в памяти во время обучения
  2. 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 пропускается.

Ключ кэша — размер файла 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)

Полный конвейер обучения работает как ожидается, включая валидацию во время обучения, сохранение контрольных точек и журналирование метрик.

Для отдельного `model.val()` требуется собственное переопределение

Только валидация во время обучения проходит через 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 должен следовать тому же порядку сортировки, что и массив COCO categories.

  • COCO JSON разбирается один раз при первом запуске обучения. Разобранные метки сохраняются в файл .cache, поэтому при последующих запусках загружаются сразу, без повторного разбора. Скорость обучения идентична стандартному обучению YOLO, поскольку аннотации хранятся в памяти. Кэш привязан к размеру файла JSON, поэтому после любого редактирования, сохраняющего длину файла, удали файл .cache.

Комментарии