Как обучать 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().
Обзор архитектуры#
Нужны два класса:
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(). Идентификаторы категорий сортируются и переназначаются индексам классов, начинающимся с нуля, поэтому корректно работают и стандартная для 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 разбирается заново, только если изменилось количество его байтов. Добавление или удаление изображений также может изменить размер самого каталога с изображениями и запустить перестроение кэша, но не полагайся на это: хеш не проверяет отдельные файлы изображений, поэтому замена одного изображения другим может не изменить размер. Изменение, при котором число байтов не меняется, — например, сдвиг координаты, переключение 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, поэтому все встроенные аугментации данных — мозаика, mixup, CutMix и другие — работают без изменений.Категории сортируются по
idи сопоставляются последовательным индексам, начиная с 0. Поддерживаются идентификаторы с нумерацией от 1 (стандарт COCO), от 0 и непоследовательные идентификаторы. Словарьnamesвdataset.yamlдолжен иметь тот же порядок сортировки, что и массив COCOcategories.COCO JSON разбирается один раз при первом запуске обучения. Разобранные метки сохраняются в файл
.cache, поэтому при следующих запусках они загружаются мгновенно, без повторного разбора. Скорость обучения такая же, как при стандартном обучении YOLO, поскольку аннотации хранятся в памяти. Кэш зависит от размера JSON-файла, поэтому после любых изменений, при которых длина файла остаётся прежней, удали файл.cache.