YOLO Vision 2026:

변환 없이 COCO JSON에서 YOLO를 학습하는 방법#

AnnotationsCOCO JSON 형식으로 사용하면 먼저 .txt 파일로 변환하지 않고도 Ultralytics YOLO 학습에 직접 사용할 수 있습니다. 이 방식은 YOLODataset를 서브클래싱하여 COCO JSON을 즉시 파싱하고, custom trainer를 통해 학습 파이프라인에 연결합니다.

COCO JSON에서 직접 학습하는 이유#

이 접근 방식은 COCO JSON을 단일 소스로 유지합니다. convert_coco() 호출, 디렉터리 재구성, 중간 label 파일이 필요하지 않습니다. YOLO26 및 모든 Ultralytics YOLO detection 모델을 지원합니다. Segmentation 및 pose 모델에는 추가 label 필드가 필요합니다(FAQ 참조).

한 번만 변환하려고 하시나요?

표준 convert_coco() workflow는 COCO to YOLO Conversion guide를 참조하십시오.

아키텍처 개요#

두 개의 class가 필요합니다.

  1. COCODataset — COCO JSON을 읽고 학습 중에 bounding boxes를 메모리에서 YOLO 형식으로 변환합니다.
  2. COCOTrainerbuild_dataset()을 재정의하여 기본 YOLODataset 대신 COCODataset를 사용합니다.

이 구현은 기본 제공 GroundingDataset의 간소화된 버전이며, 이 클래스 역시 JSON annotation을 직접 읽습니다. 여기서는 get_img_files(), cache_labels(), get_labels()의 세 method를 재정의합니다. GroundingDataset는 자체 cache-hash 및 instance-count 검사 등을 포함하여 더 많은 항목을 재정의합니다.

COCO JSON Dataset Class 구축#

COCODataset class는 YOLODataset을 상속하고 label loading 로직을 재정의합니다. labels 디렉터리에서 .txt 파일을 읽는 대신 COCO JSON 파일을 열고, image별로 그룹화된 annotation을 순회하며, 각 bounding box를 COCO pixel 형식 [x_min, y_min, width, height]에서 YOLO normalized center 형식 [x_center, y_center, width, height]로 변환합니다. Crowd annotation(iscrowd: 1)과 area가 0인 box는 자동으로 건너뜁니다.

get_img_files() method는 cache_labels() 내부의 JSON file_name 필드에서 image path를 확인하므로 빈 list를 반환합니다. Category ID는 정렬된 후 0부터 시작하는 class index로 다시 매핑되므로 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"]

파싱된 label은 JSON 옆의 .cache 파일에 저장됩니다(예: instances_train.cache). 이후 학습 실행에서는 cache가 직접 로드되므로 JSON 파싱을 건너뜁니다.

Cache key는 파일 내용이 아니라 JSON의 파일 크기입니다.

get_hash()은 파일 내용이 아니라 파일 크기와 path를 hash하므로, JSON의 byte 수가 변경될 때만 재실행 시 JSON을 다시 파싱합니다. Image를 추가하거나 제거하면 image 디렉터리 자체의 크기도 변경되어 rebuild가 실행될 수 있지만, 이에 의존해서는 안 됩니다. Hash는 개별 image 파일을 검사하지 않으므로 image 하나를 다른 image로 교체해도 크기가 동일하게 유지될 수 있습니다. 좌표를 조금 변경하거나 iscrowd을 변경하거나 길이가 같은 두 class name을 서로 바꾸는 등 byte 수를 유지하는 편집은 오래된 cache를 그대로 두며, 경고 없이 이전 annotation으로 학습하게 합니다. Image를 같은 위치에서 교체하는 경우도 같은 이유로 감지되지 않습니다. Annotation을 편집하거나 image를 같은 위치에서 교체한 후에는 .cache 파일을 삭제하십시오.

Dataset을 Training Pipeline에 연결하기#

Trainer에서 필요한 유일한 변경 사항은 build_dataset()을 재정의하는 것입니다. 기본 DetectionTrainer.txt label 파일을 검색하는 YOLODataset를 생성합니다. 이를 COCODataset로 교체하면 trainer가 대신 COCO JSON에서 읽습니다.

JSON file path는 data config의 custom train_json / val_json field에서 가져옵니다(Configuring dataset.yaml 참조). 학습 중에는 mode="train"train_json로 확인되고, validation 중에는 mode="val"val_json으로 확인됩니다. 두 key가 모두 필요합니다. 두 split은 서로 다른 image directory를 읽으므로 training JSON을 누락된 val_json 대신 사용할 수 없습니다.

Dataset은 fraction1.0로 재설정합니다. BaseDataset는 image directory를 검색할 때 해당 argument를 적용하지만, COCODataset은 이 단계를 건너뛰므로 partial-dataset 요청을 처리할 수 없습니다. 이를 재설정하면 dataset이 무시하는 값을 허용하는 것처럼 보이는 일을 방지할 수 있습니다. 기본 제공 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은 표준 path, train, val field를 사용하여 image directory를 찾습니다. 여기서 path는 image root를 가리키므로 trainvalconversion guide와 달리 단순한 split name입니다. 해당 guide에서는 path이 dataset root이고 split에 images/ prefix가 포함됩니다. 추가 field인 train_jsonval_jsonCOCOTrainer가 읽을 COCO annotation file을 지정합니다. names field에는 JSON의 categories가 정렬된 순서로 class name이 나열되며, class count도 여기서 파생되므로 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에서 학습 실행하기#

Dataset class, trainer class 및 YAML config를 준비하면 표준 model.train() 호출을 통해 학습할 수 있습니다. 일반적인 학습 실행과의 유일한 차이는 trainer=COCOTrainer argument이며, 이를 통해 Ultralytics가 기본 dataset loader 대신 custom dataset loader를 사용하도록 지정합니다.

from ultralytics import YOLO

model = YOLO("yolo26n.pt")
model.train(data="dataset.yaml", epochs=100, imgsz=640, trainer=COCOTrainer)

전체 training pipeline은 학습 중 validation, checkpoint 저장 및 metric logging을 포함하여 예상대로 실행됩니다.

Standalone `model.val()`에는 자체 override가 필요합니다.

학습 중 validation만 COCOTrainer.build_dataset을 거칩니다. 별도의 model.val() 호출은 image 옆에서 .txt label을 검색하는 기본 YOLODataset를 생성하지만 label을 찾지 못합니다. 오류를 발생시키지는 않습니다. Image가 background로 집계되므로 validation은 완료되지만 모든 metric을 0로 보고하고 No labels found in ...no labels found in detect set, cannot compute metrics without labels을 warning합니다. 학습 실행 외부에서 validation하려면 동일한 build_dataset override를 적용하여 validator를 서브클래싱하고 이를 model.val(validator=...)에 전달하십시오.

전체 구현#

편의를 위해 전체 구현을 하나의 copy-paste script로 아래에 제공합니다. Custom dataset, custom trainer 및 training call이 포함되어 있습니다. 이 script를 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)

이제 annotation을 단일 소스로 유지하고 중간 .txt 파일을 생성하지 않으면서 COCO JSON에서 직접 Ultralytics YOLO를 학습하는 최소 dataset 및 trainer를 갖추었습니다. Segmentation 및 pose를 지원하려면 cache_labels() method를 segments 또는 keypoints과 함께 확장하고, hyperparameter tuning 권장 사항은 Model Training Tips guide를 참조하십시오.

FAQ#

  • convert_coco()은 일회성 변환으로 .txt label file을 disk에 기록합니다. 이 방식은 각 학습 실행 시작 시 JSON을 파싱하고 annotation을 메모리에서 변환합니다. 영구적인 YOLO 형식 label이 필요하면 convert_coco()을 사용하고, 추가 file을 생성하지 않고 COCO JSON을 단일 소스로 유지하려면 이 방식을 사용하십시오.

  • 기본적으로 YOLO .txt label을 요구하는 현재 Ultralytics pipeline에서는 불가능합니다. 이 guide는 필요한 최소 custom code인 하나의 dataset class와 하나의 trainer class를 제공합니다. 한 번 정의하면 학습에는 표준 model.train() 호출만 필요합니다.

  • 이 guide는 object detection을 다룹니다. instance segmentation을 지원하려면 COCO annotation의 segmentation polygon data를 각 label dictionary의 segments field에 포함하십시오. pose estimation의 경우 keypoints를 포함하십시오. GroundingDataset source code는 segment 처리를 위한 reference implementation을 제공합니다.

  • 예. COCODatasetYOLODataset을 확장하므로 모든 기본 제공 data augmentationmosaic, mixup, copy-paste 등이 수정 없이 실행됩니다.

  • Category는 id을 기준으로 정렬되고 0부터 시작하는 연속 index에 매핑됩니다. 따라서 1부터 시작하는 ID(표준 COCO), 0부터 시작하는 ID 및 연속적이지 않은 ID를 처리할 수 있습니다. dataset.yamlnames dictionary는 COCO categories array와 동일한 정렬 순서를 따라야 합니다.

  • COCO JSON은 첫 번째 학습 실행에서 한 번 파싱됩니다. 파싱된 label은 .cache file에 저장되므로 이후 실행에서는 다시 파싱하지 않고 즉시 로드합니다. Annotation이 메모리에 유지되므로 학습 속도는 표준 YOLO 학습과 동일합니다. Cache는 JSON의 file size를 기준으로 하므로 file 길이를 변경하지 않는 편집을 수행한 후에는 .cache file을 삭제하십시오.

댓글