Ultralytics YOLO27:
Get Started

변환 없이 COCO JSON으로 YOLO 학습하기#

어노테이션은 .txt 파일로 먼저 변환하지 않고도 COCO JSON 형식 그대로 Ultralytics YOLO 학습에 사용할 수 있습니다. YOLODataset를 서브클래싱하여 COCO JSON을 즉시 파싱하고 사용자 지정 트레이너를 통해 학습 파이프라인에 연결하면 됩니다.

COCO JSON으로 직접 학습하는 이유#

이 접근 방식은 COCO JSON을 단일 정보 소스로 유지합니다. convert_coco() 호출도, 디렉터리 재구성도, 중간 레이블 파일도 필요하지 않습니다. YOLO26 및 다른 모든 Ultralytics YOLO 탐지 모델을 지원합니다. 세그멘테이션 및 포즈 모델에는 추가 레이블 필드가 필요합니다(FAQ 참조).

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

표준 convert_coco() 워크플로는 COCO에서 YOLO로 변환하는 가이드를 참조하세요.

아키텍처 개요#

두 개의 클래스가 필요합니다:

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

이 구현은 JSON 어노테이션도 직접 읽는 기본 제공 GroundingDataset의 간소화된 버전입니다. 여기서는 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)과 면적이 0인 박스는 자동으로 건너뜁니다.

이미지 경로는 cache_labels() 내부의 JSON file_name 필드에서 확인하므로 get_img_files() 메서드는 빈 목록을 반환합니다. 범주 ID를 정렬하고 0부터 시작하는 클래스 인덱스로 다시 매핑하므로, 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은 .txt 레이블 파일을 검색하는 YOLODataset를 만듭니다. 이를 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,
        )

COCO JSON용 dataset.yaml 구성하기#

dataset.yaml은 표준 path, train, val 필드를 사용해 이미지 디렉터리를 찾습니다. 여기서 path는 이미지 루트를 가리키므로, train와 val은 분할 이름만 사용합니다. 이는 path이 데이터셋 루트이고 각 분할에 images/ 접두사가 붙는 변환 가이드와 다릅니다. 추가 필드인 train_json과 val_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() 호출은 기본 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)

이제 어노테이션을 단일 정보 소스로 유지하고 중간 .txt 파일을 만들지 않으면서, COCO JSON을 직접 사용해 Ultralytics YOLO를 학습하는 최소 데이터셋 및 트레이너를 갖추었습니다. cache_labels() 메서드를 segments 또는 keypoints으로 확장해 세그멘테이션 및 포즈를 지원하고, 하이퍼파라미터 튜닝 권장 사항은 모델 학습 팁 가이드의 하이퍼파라미터 섹션을 참조하세요.

자주 묻는 질문#

  • convert_coco()은 한 번 변환할 때 .txt 레이블 파일을 디스크에 씁니다. 이 방식은 각 학습 실행이 시작될 때 JSON을 파싱하고 어노테이션을 메모리에서 변환합니다. 영구적인 YOLO 형식 레이블을 선호한다면 convert_coco()을 사용하고, 추가 파일을 생성하지 않고 COCO JSON을 단일 정보 소스로 유지하려면 이 방식을 사용하세요.

  • 현재 Ultralytics 파이프라인은 기본적으로 YOLO .txt 레이블을 필요로 하므로 불가능합니다. 이 가이드에서는 데이터셋 클래스 하나와 트레이너 클래스 하나로 구성된 최소 사용자 지정 코드를 제공합니다. 두 클래스를 정의하고 나면 표준 model.train() 호출만으로 학습할 수 있습니다.

  • 이 가이드는 객체 탐지를 다룹니다. 인스턴스 세그멘테이션을 지원하려면 COCO 어노테이션의 segmentation 폴리곤 데이터를 각 레이블 딕셔너리의 segments 필드에 포함하세요. 포즈 추정을 지원하려면 keypoints를 포함하세요. GroundingDataset 소스 코드에서 세그먼트 처리의 참고 구현을 확인할 수 있습니다.

  • 예. COCODataset은 YOLODataset을 확장하므로, 모자이크, 믹스업, CutMix 등의 기본 제공 데이터 증강을 수정 없이 사용할 수 있습니다.

  • 범주는 id을 기준으로 정렬된 후 0부터 시작하는 순차 인덱스에 매핑됩니다. 이 방식은 1부터 시작하는 ID(표준 COCO), 0부터 시작하는 ID, 연속적이지 않은 ID를 처리합니다. dataset.yaml의 names 딕셔너리는 COCO의 categories 배열과 동일한 정렬 순서를 따라야 합니다.

  • 첫 학습 실행에서 COCO JSON을 한 번 파싱합니다. 파싱한 레이블은 .cache 파일에 저장되므로 이후 실행에서는 다시 파싱하지 않고 즉시 로드합니다. 어노테이션이 메모리에 유지되므로 학습 속도는 표준 YOLO 학습과 동일합니다. 캐시 키는 JSON의 파일 크기이므로, 파일 길이를 유지하는 편집을 했다면 .cache 파일을 삭제하세요.

댓글