Ultralytics YOLO27:
Get Started

Как обучать YOLO на JSON COCO без преобразования#

Аннотации в формате COCO JSON можно напрямую использовать для обучения Ultralytics YOLO, не преобразуя их предварительно в файлы .txt. Для этого создай подкласс YOLODataset, который разбирает COCO JSON на лету, и подключи его к конвейеру обучения с помощью пользовательского тренера.

Зачем обучать модель непосредственно на JSON COCO#

Такой подход сохраняет COCO JSON единственным источником истины — без вызова convert_coco(), реорганизации каталогов и промежуточных файлов с разметкой. Поддерживаются YOLO26 и все остальные модели Ultralytics YOLO для обнаружения объектов. Для моделей сегментации и позы нужны дополнительные поля разметки (см. FAQ).

Нужна разовая конвертация?

См. руководство Конвертация COCO в YOLO со стандартным рабочим процессом convert_coco().

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

Нужны два класса:

  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(). Идентификаторы категорий сортируются и переназначаются индексам классов, начинающимся с нуля, поэтому корректно работают и стандартная для 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 разбирается заново, только если изменилось количество его байтов. Добавление или удаление изображений также может изменить размер самого каталога с изображениями и запустить перестроение кэша, но не полагайся на это: хеш не проверяет отдельные файлы изображений, поэтому замена одного изображения другим может не изменить размер. Изменение, при котором число байтов не меняется, — например, сдвиг координаты, переключение 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, поэтому все встроенные аугментации данных — мозаика, mixup, CutMix и другие — работают без изменений.

  • Категории сортируются по id и сопоставляются последовательным индексам, начиная с 0. Поддерживаются идентификаторы с нумерацией от 1 (стандарт COCO), от 0 и непоследовательные идентификаторы. Словарь names в dataset.yaml должен иметь тот же порядок сортировки, что и массив COCO categories.

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

Комментарии