YOLO Vision 2026:

如何在不转换的情况下使用 COCO JSON 训练 YOLO#

标注采用 COCO JSON 格式时,可以直接用于 Ultralytics YOLO 训练,无需先转换为 .txt 文件。具体做法是继承 YOLODataset,实时解析 COCO JSON,并通过自定义训练器将其接入训练流程。

为什么直接使用 COCO JSON 训练#

这种方法将 COCO JSON 保留为唯一事实来源——无需调用 convert_coco(),无需重新组织目录,也无需生成中间标注文件。YOLO26 以及所有其他 Ultralytics YOLO 检测模型均受支持。分割和姿态模型需要额外的标注字段(参见 常见问题)。

只想进行一次性转换?

请参阅 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,并重写标签加载逻辑。它不再从 labels 目录读取 .txt 文件,而是打开 COCO JSON,遍历按图像分组的标注,并将每个边界框从 COCO 像素格式 [x_min, y_min, width, height] 转换为 YOLO 归一化中心格式 [x_center, y_center, width, height]。人群标注(iscrowd: 1)和零面积框会自动跳过。

get_img_files() 方法返回空列表,因为图像路径是从 cache_labels() 内 JSON 的 file_name 字段解析得到的。类别 ID 会排序并重新映射为从零开始的类别索引,因此以 1 开始的 ID(标准 COCO)和不连续的 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、替换两个长度相同的类别名称——会使旧缓存继续存在,训练时使用过时的标注且不会发出警告;出于同样的原因,原位替换图像也无法被检测到。原位编辑标注或图像后,请删除 .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.0BaseDataset 在扫描图像目录时会应用该参数,而 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/ 前缀。另外两个字段 train_jsonval_json 指定 COCO 标注文件,COCOTrainer 会读取这些文件。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() 时,会构建标准的 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)

现在,你已经拥有一个最小化的数据集和训练器,可以直接在 COCO JSON 上训练 Ultralytics YOLO,同时让标注保持为唯一事实来源,并且不生成中间 .txt 文件。你可以使用 segmentskeypoints 扩展 cache_labels() 方法,以支持分割和姿态;有关超参数调优建议,请参阅 模型训练技巧指南。

常见问题#

  • convert_coco() 会将 .txt 标签文件写入磁盘,作为一次性转换。此方法会在每次训练运行开始时解析 JSON,并在内存中转换标注。如果你更希望使用永久的 YOLO 格式标签,请使用 convert_coco();如果希望将 COCO JSON 保留为唯一事实来源且不生成额外文件,请使用此方法。

  • 按照当前的 Ultralytics 流程不行,因为它默认需要 YOLO .txt 标签。本指南提供了所需的最少自定义代码——一个数据集类和一个训练器类。定义完成后,训练只需调用标准的 model.train()

  • 本指南介绍目标检测。要添加实例分割支持,请将 COCO 标注中的 segmentation 多边形数据加入每个标签字典的 segments 字段。对于姿态估计,请加入 keypointsGroundingDataset 源代码提供了处理分割数据的参考实现。

  • 支持。COCODataset 扩展了 YOLODataset,因此所有内置的数据增强——mosaicmixupcopy-paste 等——都无需修改即可运行。

  • 类别会按照 id 排序,并映射为从 0 开始的连续索引。这可以处理从 1 开始的 ID(标准 COCO)、从 0 开始的 ID 以及不连续的 ID。dataset.yaml 中的 names 字典应与 COCO 的 categories 数组保持相同的排序顺序。

  • COCO JSON 会在首次训练运行时解析一次。解析后的标签会保存到 .cache 文件中,因此后续运行无需重新解析即可立即加载。由于标注保存在内存中,训练速度与标准 YOLO 训练相同。缓存以 JSON 的文件大小为键,因此任何使文件长度保持不变的编辑完成后,都应删除 .cache 文件。

评论