YOLO Vision 2026:

変換せずにCOCO JSONでYOLOをトレーニングする方法#

アノテーションは、COCO JSON形式であれば、.txtファイルに変換しなくてもUltralytics YOLOのトレーニングに直接使用できます。これは、YOLODatasetをサブクラス化してCOCO JSONをオンザフライで解析し、カスタムトレーナーを通じてトレーニングパイプラインに接続することで実現します。

COCO JSONで直接トレーニングする理由#

この方法では、COCO JSONを唯一の信頼できる情報源として維持できます。convert_coco()の呼び出し、ディレクトリの再編成、中間ラベルファイルは必要ありません。YOLO26およびその他すべてのUltralytics YOLO検出モデルに対応しています。セグメンテーションモデルとポーズモデルには追加のラベルフィールドが必要です(FAQを参照してください)。

一度だけ変換したい場合はこちらですか?

標準のconvert_coco()ワークフローについては、COCOからYOLOへの変換ガイドを参照してください。

アーキテクチャの概要#

必要なクラスは2つです。

  1. COCODataset — COCO JSONを読み込み、トレーニング中にバウンディングボックスをメモリ上でYOLO形式に変換します
  2. COCOTrainerbuild_dataset()をオーバーライドし、デフォルトのYOLODatasetの代わりにCOCODatasetを使用します

実装は、組み込みのGroundingDatasetを簡略化したものです。これもJSONアノテーションを直接読み込みます。ここではget_img_files()cache_labels()get_labels()の3つのメソッドをオーバーライドしています。一方、GroundingDatasetでは、独自のキャッシュハッシュやインスタンス数のチェックなど、さらに多くの処理をオーバーライドしています。

COCO JSONデータセットクラスの構築#

COCODatasetクラスはYOLODatasetを継承し、ラベル読み込みロジックをオーバーライドします。ラベルディレクトリから.txtファイルを読み込む代わりに、COCO JSONファイルを開き、画像ごとにグループ化されたアノテーションを反復処理し、各バウンディングボックスをCOCOのピクセル形式[x_min, y_min, width, height]からYOLOの正規化された中心座標形式[x_center, y_center, width, height]に変換します。群衆アノテーション(iscrowd: 1)と面積が0のボックスは自動的にスキップされます。

get_img_files()メソッドは空のリストを返します。これは、画像パスがcache_labels()内のJSONのfile_nameフィールドから解決されるためです。カテゴリIDはソートされ、0始まりのクラスインデックスに再マッピングされるため、1始まり(標準的なCOCO)のIDスキームと、連続していないIDスキームの両方が正しく機能します。

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"]

解析されたラベルは、JSONと同じ場所にある.cacheファイルに保存されます(例:instances_train.cache)。次回以降のトレーニングでは、キャッシュが直接読み込まれるため、JSONの解析はスキップされます。

キャッシュキーは内容ではなくJSONのファイルサイズです

get_hash()はファイルの内容ではなく、ファイルサイズとパスをハッシュ化します。そのため、再実行時にJSONが再解析されるのは、JSONのバイト数が変化した場合だけです。画像の追加や削除によって画像ディレクトリ自体のサイズが変わり、再構築が実行される場合もありますが、これに依存しないでください。ハッシュは個々の画像ファイルを検査しないため、ある画像を別の画像に置き換えてもサイズが変わらない場合があります。バイト数を維持した編集(座標の微調整、iscrowdの切り替え、同じ長さのクラス名2つの入れ替えなど)では古いキャッシュが残り、警告なしで古いアノテーションを使ってトレーニングされます。画像を同じ場所で置き換えた場合も同じ理由で検出されません。アノテーションまたは画像を同じ場所で編集した後は、.cacheファイルを削除してください。

データセットをトレーニングパイプラインに接続する#

トレーナーで必要な変更は、build_dataset()をオーバーライドすることだけです。デフォルトのDetectionTrainerは、.txtラベルファイルを検索するYOLODatasetを構築します。これをCOCODatasetに置き換えることで、トレーナーは代わりにCOCO JSONから読み込むようになります。

JSONファイルのパスは、データ設定内のカスタムtrain_json / val_jsonフィールドから取得されます(dataset.yamlの設定を参照してください)。トレーニング中はmode="train"train_jsonに解決され、検証中はmode="val"val_jsonに解決されます。2つの分割は異なる画像ディレクトリを読み込むため、両方のキーが必要です。したがって、トレーニング用JSONでval_jsonの欠落を補うことはできません。

このデータセットは、fraction1.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,
        )

COCO JSON用のdataset.yamlを設定する#

dataset.yamlは、標準のpathtrainvalフィールドを使用して画像ディレクトリを特定します。ここではpathが画像ルートを指しているため、trainvalは分割名だけになります。変換ガイドでは、pathがデータセットルートで、分割にimages/プレフィックスが付く点と異なります。追加の2つのフィールドtrain_jsonval_jsonでは、COCOTrainerが読み込むCOCOアノテーションファイルを指定します。namesフィールドには、JSON内のcategoriesをソートした順序でクラス名を記載します。クラス数はそこから導出されるため、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()呼び出しでは、画像の隣にある.txtラベルを検索する標準のYOLODatasetが構築されますが、ラベルは見つかりません。エラーは発生しません。画像は背景としてカウントされるため、検証は最後まで実行され、すべてのメトリクスが0として報告され、No labels found in ...no labels found in detect set, cannot compute metrics without labelsの警告が表示されます。トレーニング実行外で検証するには、同じbuild_datasetオーバーライドを使用してバリデーターをサブクラス化し、model.val(validator=...)に渡してください。

完全な実装#

利便性のため、完全な実装を1つのコピー&ペースト可能なスクリプトとして以下に示します。カスタムデータセット、カスタムトレーナー、トレーニング呼び出しが含まれています。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)

これで、アノテーションを唯一の信頼できる情報源として維持し、中間の.txtファイルを生成せずに、COCO JSONでUltralytics YOLOを直接トレーニングできる最小構成のデータセットとトレーナーが完成しました。セグメンテーションとポーズに対応するには、cache_labels()メソッドをsegmentsまたはkeypointsで拡張してください。また、ハイパーパラメーターのチューニングに関する推奨事項については、モデルのトレーニングのヒントガイドを参照してください。

FAQ#

  • convert_coco()は、一度限りの変換として.txtラベルファイルをディスクに書き込みます。この方法では、各トレーニング実行の開始時にJSONを解析し、アノテーションをメモリ上で変換します。永続的なYOLO形式のラベルが必要な場合はconvert_coco()を使用してください。追加ファイルを生成せず、COCO JSONを唯一の信頼できる情報源として維持する場合は、この方法を使用してください。

  • 現行のUltralyticsパイプラインではできません。デフォルトでYOLOの.txtラベルを想定しているためです。このガイドでは、必要最小限のカスタムコードとして、1つのデータセットクラスと1つのトレーナークラスを提供します。定義後のトレーニングには、標準のmodel.train()呼び出しだけが必要です。

  • このガイドでは物体検出を扱います。インスタンスセグメンテーションに対応するには、COCOアノテーションのsegmentationポリゴンデータを各ラベル辞書のsegmentsフィールドに含めてください。ポーズ推定では、keypointsを含めてください。GroundingDatasetソースコードに、セグメントを処理するための実装例があります。

  • はい。COCODatasetYOLODatasetを拡張しているため、組み込みのデータ拡張であるモザイクMixUpコピー&ペーストなどが変更なしで実行されます。

  • カテゴリはidでソートされ、0から始まる連続したインデックスにマッピングされます。これにより、1始まりのID(標準的なCOCO)、0始まりのID、連続していないIDに対応できます。dataset.yaml内のnames辞書は、COCOのcategories配列と同じソート順にする必要があります。

  • COCO JSONは最初のトレーニング実行時に一度だけ解析されます。解析済みのラベルは.cacheファイルに保存されるため、次回以降の実行では再解析なしですぐに読み込まれます。アノテーションはメモリ上に保持されるため、トレーニング速度は標準的なYOLOトレーニングと同じです。キャッシュはJSONのファイルサイズをキーにするため、ファイルの長さが変わらない編集を行った場合は.cacheファイルを削除してください。

コメント