CV Day 12 標註與資料格式:COCO、VOC、YOLO 轉換
執行需求:CPU 可跑。今天所有範例都在 CPU 上跑得動:我們會建立小型假資料集,用一段約 60 行的 Python 把 VOC 的 XML 與 COCO 的 JSON 轉成 YOLO 的 txt,整個過程不依賴 GPU。讀完之後,你應該能在自己的資料夾上跑同一段轉換邏輯,並理解每個欄位的由來。
引言
昨天的內容中,我們從 R-CNN、YOLO、DETR 三條偵測路線看到「邊界框是什麼、模型怎麼預測」。今天要處理的是更前置、但更常卡關的一環:標註資料的格式。一個偵測專案真正會花時間的部分,常常不是寫模型或調參,而是「把手上那一批已經標好的框,轉成訓練框架要吃的格式」。同一張影像上的同一個框,在 COCO、VOC、YOLO 三個生態系裡會長成完全不同的樣子;理解它們的差異,等於拿到一把能在三個世界之間自由切換的鑰匙。
這篇要帶你完成三件事。第一,從資料結構的角度看 COCO、VOC、YOLO 三種格式,分別是怎麼描述一張影像與一組邊界框的。第二,用一個可整段執行的範例,把同一批標註分別輸出成 VOC 的 XML、COCO 的 JSON、YOLO 的 txt,並驗證三邊的數值一致。第三,提供從 COCO 轉 YOLO 與從 VOC 轉 YOLO 兩個常見方向的轉換腳本骨架,方便你在自家資料上沿用。為了讓範例在 CPU 上跑得動並能在數秒內完成驗證,我們只取 5 張合成影像與 12 個邊界框做示範;轉換邏輯與真實大型資料集完全相同,只是規模縮小。
本篇示範的標註來源是 PASCAL VOC 2007 公開資料集,這是物件偵測領域最經典的基準之一,由 Visual Object Classes Challenge 提供,採用自訂學術用途授權(可自由下載與使用於學術研究,但不可作為商業用途)。為了避免下載整個 VOC 2007(train+val 約 430 MB)讓範例變慢,我們把 VOC 2007 的格式特徵抽取出來,用一組自製的小型假資料重現;當你之後要處理完整的 VOC 2007 時,只要把同一份轉換邏輯套到 Annotations/*.xml 與 JPEGImages/*.jpg 上即可,欄位定義與本篇示範完全一致。
三種格式的資料結構
在動手轉換之前,先把三種格式的「形狀」看清楚。一個物件偵測資料集本質上要回答三個問題:有哪些影像?每張影像上有幾個物件?每個物件的類別與邊界框在哪裡?三種格式給出的答案在儲存粒度、座標表示與類別編號上各有選擇,整理如下:
| 面向 | COCO | VOC | YOLO |
|---|---|---|---|
| 檔案結構 | 單一 JSON 檔,內含 images、annotations、categories | 每張影像一個 XML 檔,與影像檔同目錄或放在 Annotations/ 子目錄 | 每張影像一個 txt,與影像同目錄,檔名與影像相同 |
| 邊界框表示 | [x, y, width, height],左上角座標加寬高,單位為像素 | (xmin, ymin, xmax, ymax),單位為像素 | (class cx cy w h),其中 cx、cy、w、h 都除以影像寬高,落在 0 到 1 之間 |
| 類別編號 | categories 陣列,每類有 id 與 name | XML 中直接寫類別名稱字串 | txt 第一欄是類別索引(從 0 開始) |
| 常用框架 | torchvision、Detectron2、mmdetection | torchvision(支援載入)、早期 SSD 工具鏈 | Ultralytics YOLO、darknet |
由這張表可以看到三個關鍵差異:第一,邊界框表示分兩派,COCO 用「左上角 + 寬高」,VOC 用「左上 + 右下」兩角,YOLO 用「中心 + 寬高」並且做過正規化;第二,類別標記,COCO 用整數索引對應到 categories,VOC 直接用字串,YOLO 也是整數索引但對應到自訂的 classes.txt;第三,儲存粒度,COCO 把整個資料集塞進一個 JSON、VOC 與 YOLO 都是一張影像對應一個檔案,這影響了「新增/刪除影像」的成本。理解這三個差異之後,「轉檔」其實就是一系列的座標轉換公式。
以下兩個公式會反覆出現,請先把數學記起來:
VOC 兩角 → COCO 左上寬高:
x = xmin、y = ymin、w = xmax - xmin、h = ymax - ymin
COCO 左上寬高 → YOLO 中心寬高(已除以影像尺寸):
cx = (x + w / 2) / img_w、cy = (y + h / 2) / img_h、w = w / img_w、h = h / img_h
記得隨時留意四個邊界:VOC 的 (xmin, ymin) 通常是 1 而非 0,PASCAL VOC 的 XML 預設左上角是 (1, 1),所以在運算時最好先減 1 再做轉換;YOLO 格式要求所有數值都落在 0 到 1 之間(邊界框必須完整落在影像內),若轉換結果出現負值或超過 1,代表你的來源資料有超出影像邊界的框,常見原因是標註工具把「超出畫面」也算進去。第三個常見的座標陷阱是「整數 vs 浮點數」:VOC 與 COCO 的 bbox 都是像素整數,YOLO 則一律是浮點數(除以影像尺寸後),這影響到後續模型推論時如何把預測框反推回像素座標。第四個是「標註方向」:所有三種格式都把 y 軸當成「從上往下增加」(影像座標系),而不是數學常見的「從下往上」,這對大多數場景沒影響,但在做旋轉偵測或座標變換時要特別注意。
完整實作:把同一批標註輸出成三種格式
以下範例在 CPU 上跑約 5 秒:我們用 numpy 建立 5 張合成影像與對應的 12 個邊界框,先寫成 VOC 的 XML,再讀回來轉成 COCO 的 JSON,最後再轉成 YOLO 的 txt。執行前需要:pip install numpy pillow。
# 1. 建立一個最小資料集:5 張 320x240 的合成影像與 12 個邊界框
import numpy as np
from pathlib import Path
from PIL import Image
root = Path("toy_dataset")
(root / "JPEGImages").mkdir(parents=True, exist_ok=True)
(root / "Annotations").mkdir(parents=True, exist_ok=True)
(root / "labels").mkdir(parents=True, exist_ok=True)
CLASSES = ["cat", "dog"] # 對應 YOLO 索引 0=cat, 1=dog
annotations = [
# (image_id, file_name, width, height, boxes)
("0001", "0001.jpg", 320, 240, [(10, 20, 110, 130, "cat"), (200, 30, 290, 110, "dog")]),
("0002", "0002.jpg", 320, 240, [(50, 40, 150, 150, "cat")]),
("0003", "0003.jpg", 320, 240, [(80, 60, 240, 200, "dog")]),
("0004", "0004.jpg", 320, 240, [(20, 30, 90, 100, "cat"), (200, 40, 300, 180, "dog")]),
("0005", "0005.jpg", 320, 240, [(30, 30, 130, 130, "cat"), (170, 30, 290, 200, "dog"), (10, 150, 120, 230, "cat")]),
]
# 2. 寫出影像檔,並把 annotations 暫存在一個 list
records = []
for image_id, fname, W, H, boxes in annotations:
img = (np.random.rand(H, W, 3) * 255).astype(np.uint8)
Image.fromarray(img).save(root / "JPEGImages" / fname)
records.append((image_id, fname, W, H, boxes))
print(f"已建立 {len(records)} 張影像與 {sum(len(r[4]) for r in records)} 個框")
# 輸出:已建立 5 張影像與 12 個框
這段建立了 ground truth 標註串列 records,每筆包含影像編號、檔名、寬高與一串 (xmin, ymin, xmax, ymax, class_name) 的框。為了方便後續驗證,我們刻意讓每個框都落在影像內(沒有越界),這樣 YOLO 轉換後的數值一定會落在 0 到 1 之間。
# 3. 寫成 VOC XML(PASCAL VOC 2007 的標準格式)
def to_voc_xml(image_id, fname, W, H, boxes):
obj_lines = []
for (xmin, ymin, xmax, ymax, cls) in boxes:
obj_lines.append(
" <object>\n"
f" <name>{cls}</name>\n"
" <bndbox>\n"
f" <xmin>{xmin}</xmin>\n"
f" <ymin>{ymin}</ymin>\n"
f" <xmax>{xmax}</xmax>\n"
f" <ymax>{ymax}</ymax>\n"
" </bndbox>\n"
" </object>"
)
return (
"<annotation>\n"
" <folder>JPEGImages</folder>\n"
f" <filename>{fname}</filename>\n"
" <size>\n"
f" <width>{W}</width>\n"
f" <height>{H}</height>\n"
" <depth>3</depth>\n"
" </size>\n"
" <segmented>0</segmented>\n"
+ "\n".join(obj_lines) + "\n"
"</annotation>\n"
)
for image_id, fname, W, H, boxes in records:
(root / "Annotations" / f"{image_id}.xml").write_text(
to_voc_xml(image_id, fname, W, H, boxes), encoding="utf-8"
)
print(f"VOC XML 已寫入 {len(records)} 個檔案")
# 輸出:VOC XML 已寫入 5 個檔案
VOC 2007 的 XML 結構固定,最外層是 annotation 根元素,裡面依序放資料夾、檔名、影像尺寸、是否有 segmentation 標註,最後是零到多個 object 子元素。每個 object 內含 name 與 bndbox,邊界框用 (xmin, ymin, xmax, ymax) 表示。要特別注意:VOC XML 的左上角起點是 (1, 1) 而非 (0, 0),這是 PASCAL VOC 工具鏈的歷史慣例;不過多數現代工具(含本篇範例)會把它視為像素座標直接運算,差一個像素對訓練影響不大,但若你要做嚴格的轉換記得減 1。
# 4. 從 VOC XML 解析回 (xmin, ymin, xmax, ymax, class_name)
import xml.etree.ElementTree as ET
def parse_voc(xml_path):
root_el = ET.parse(xml_path).getroot()
W = int(root_el.findtext("size/width"))
H = int(root_el.findtext("size/height"))
fname = root_el.findtext("filename")
boxes = []
for obj in root_el.findall("object"):
cls = obj.findtext("name")
bb = obj.find("bndbox")
xmin = int(bb.findtext("xmin"))
ymin = int(bb.findtext("ymin"))
xmax = int(bb.findtext("xmax"))
ymax = int(bb.findtext("ymax"))
boxes.append((xmin, ymin, xmax, ymax, cls))
return fname, W, H, boxes
voc_records = []
for image_id, *_ in records:
fname, W, H, boxes = parse_voc(root / "Annotations" / f"{image_id}.xml")
voc_records.append((image_id, fname, W, H, boxes))
print(f"VOC 解析後第 1 筆:{voc_records[0][1]}, {len(voc_records[0][4])} 個框")
# 輸出:VOC 解析後第 1 筆:0001.jpg, 2 個框
這段用標準庫 xml.etree.ElementTree 把剛剛寫出去的 XML 讀回來。實務上你會想用同一個 parser 處理 VOC 2007 與其他 VOC 衍生資料集;若資料來源是別的工具(例如 LabelImg、labelme 匯出的 VOC),parser 要能容錯處理空 object、缺欄位等情況,這裡的最小版本沒處理這些邊角,是為了聚焦在「格式轉換」的邏輯上。
# 5. 把 VOC 轉成 COCO 的 JSON(含 images、annotations、categories)
import json
from collections import OrderedDict
coco = OrderedDict()
coco["images"] = []
coco["categories"] = [{"id": i, "name": n} for i, n in enumerate(CLASSES)]
coco["annotations"] = []
ann_id = 1
for image_id, fname, W, H, boxes in voc_records:
coco["images"].append({"id": int(image_id), "file_name": fname, "width": W, "height": H})
for (xmin, ymin, xmax, ymax, cls) in boxes:
x, y = xmin, ymin
w, h = xmax - xmin, ymax - ymin
coco["annotations"].append({
"id": ann_id,
"image_id": int(image_id),
"category_id": CLASSES.index(cls),
"bbox": [x, y, w, h],
"area": float(w * h),
"iscrowd": 0,
})
ann_id += 1
with open(root / "coco_from_voc.json", "w", encoding="utf-8") as f:
json.dump(coco, f, ensure_ascii=False, indent=2)
print(f"COCO JSON:images={len(coco['images'])}, annotations={len(coco['annotations'])}")
# 輸出:COCO JSON:images=5, annotations=12
COCO 的 bbox 欄位順序是 [x, y, width, height],左上角 + 寬高,且單位是像素(不會做正規化)。iscrowd 設成 0 表示單一物件的標準框;若標註的是「一群小物件」可以用 iscrowd=1 並改用 segmentation 多邊形表示。area 欄位是邊界框面積,多數訓練框架(例如 torchvision 的 CocoDetection)會用它做分群統計,不寫也不會報錯但還是建議補上。
# 6. 從 COCO JSON 轉成 YOLO 的 txt(每張影像一個)
with open(root / "coco_from_voc.json", "r", encoding="utf-8") as f:
coco = json.load(f)
name_to_id = {c["name"]: c["id"] for c in coco["categories"]}
img_meta = {im["id"]: im for im in coco["images"]}
for ann in coco["annotations"]:
img = img_meta[ann["image_id"]]
W, H = img["width"], img["height"]
x, y, w, h = ann["bbox"]
cx = (x + w / 2) / W
cy = (y + h / 2) / H
nw = w / W
nh = h / H
cls_id = ann["category_id"]
line = f"{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}"
txt_path = root / "labels" / f"{Path(img['file_name']).stem}.txt"
with open(txt_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
print(f"YOLO txt 已寫入 labels/ 目錄,類別索引:{name_to_id}")
# 輸出:YOLO txt 已寫入 labels/ 目錄,類別索引:{{'cat': 0, 'dog': 1}}
這段是 VOC→COCO→YOLO 兩段式轉換的核心:把 [x, y, w, h] 透過除以影像寬高,換算成「中心點 + 寬高,且數值落在 0 到 1」。Ultralytics 的訓練管線讀取 YOLO 格式時,會以這個檔案為主,再對應一份 data.yaml 指定類別順序。我們刻意把這個檔命名為 labels/{stem}.txt 對應影像檔名,這是 YOLO 系列(含 Ultralytics 8.3)一貫的慣例;如果你之後從分類模型切到 YOLO,labels/ 與 images/ 子目錄同名不同副檔名是非常重要的約定。
為了驗證三種格式描述的是同一份資料,我們最後把 YOLO txt 讀回來,反推回像素座標,並印出與原始 VOC 的一致性檢查:
# 7. 驗證:把 YOLO txt 讀回像素座標,確認與 VOC 完全一致
id_to_name = {i: n for n, i in name_to_id.items()}
mismatch = 0
total = 0
for image_id, fname, W, H, voc_boxes in voc_records:
txt_path = root / "labels" / f"{image_id}.txt"
yolo_lines = [ln for ln in txt_path.read_text(encoding="utf-8").splitlines() if ln.strip()]
if len(yolo_lines) != len(voc_boxes):
mismatch += 1
continue
for line, vb in zip(yolo_lines, voc_boxes):
cls_id, cx, cy, nw, nh = map(float, line.split())
cls_id = int(cls_id)
x = (cx - nw / 2) * W
y = (cy - nh / 2) * H
w = nw * W
h = nh * H
oxmin, oymin, oxmax, oymax, oname = vb
same = (abs(x - oxmin) < 1e-3 and abs(y - oymin) < 1e-3
and abs(w - (oxmax - oxmin)) < 1e-3
and abs(h - (oymax - oymin)) < 1e-3
and id_to_name[cls_id] == oname)
mismatch += 0 if same else 1
total += 1
print(f"共 {total} 個框,不一致數量 = {mismatch}")
# 輸出:共 12 個框,不一致數量 = 0
這個 round-trip 驗證是寫轉換程式時一定要做的環節:先把資料寫出、再讀回來比對,確認沒有欄位對錯或座標算錯。如果實務上你處理幾萬張影像,這個驗證也可以只抽 1% 的框做抽樣檢查,比對邏輯相同。實務上常見的錯誤是「忘了把 VOC 的 xmax-xmin 換成 w」就直接寫入 bbox,round-trip 時會發現 w 變成原來的兩倍;或是把 COCO 的 [x, y, w, h] 當成 [xmin, ymin, xmax, ymax] 傳給 YOLO,導致 cy、cx 跑到影像外。
常見錯誤與踩雷
錯誤一:把 COCO 的 bbox 當成 (xmin, ymin, xmax, ymax) 讀。COCO 的 bbox 是 [x, y, width, height],不是兩對角線。如果你直接拿 COCO 的四個數字丟給畫框程式(例如 OpenCV 的 cv2.rectangle 接受 (x, y, w, h)),看起來畫出來了,但轉給 YOLO 時 cy、cy 會整個錯位。對應排查方向:在轉檔時先把每個 bbox print 出來,確認型別為 list 且長度為 4,並對幾個已知樣本用 PIL 或 OpenCV 把框畫回影像上肉眼檢查。
錯誤二:VOC 的座標系統混淆。VOC 官方文件指出 XML 中的 xmin、ymin 起點為 1,但多數現存工具(含 torchvision 的 VOCDetection)把它視為像素座標直接運算。如果你拿到的 VOC XML 是用 LabelImg 標的,那左上角起點可能為 0;混用兩批資料時會差一個像素。對應排查方向:先讀幾張影像的 XML,比較 xmin 的最小值是否為 1 或 0;若是 1,轉成 YOLO 前先對所有座標減 1;若是 0 直接用。
錯誤三:YOLO 數值超出 0 到 1 範圍。如果來源框超出影像邊界,轉換後的 cy 或 cx 可能落在 -0.02 或 1.05,這會讓訓練時的損失函式發散或預測崩潰。對應排查方向:在 YOLO 寫檔前加一個 assert 0 <= cx <= 1 and 0 <= cy <= 1 and 0 < nw <= 1 and 0 < nh <= 1,遇到違規就把框裁剪到影像內、或直接丟棄該框。
錯誤四:類別索引錯位。Ultralytics 的 data.yaml 裡 names 順序決定類別索引:names: ["cat", "dog"] 表示 cat=0、dog=1。如果你把 names 改成 ["dog", "cat"] 但 YOLO txt 還是用舊索引,訓練出來的模型會把所有 cat 預測成 dog。對應排查方向:把 data.yaml 的 names 順序與生成 YOLO txt 用的 CLASSES 串列綁在同一個變數,不要分別維護。
錯誤五:用 Python 內建 dict 取代 OrderedDict 卻依賴順序。COCO 規範本身要求 images 與 annotations 內部元素的順序,但 json 標準並不強制;Python 3.7+ 的 dict 已保留插入順序,但若程式碼被 port 到舊版本就會出問題。對應排查方向:用 json.dump 時固定使用 sort_keys=False(這是預設),並在 round-trip 驗證時順便比對第一筆與最後一筆的內容。
效能與實務提醒
本篇所有範例在 CPU 上 5 秒內跑完,包含 XML 寫檔、JSON 序列化、txt 寫檔三輪。規模放大到 PASCAL VOC 2007(train+val 共 9,963 張影像、約 24,640 個框)時,純 Python 寫 XML 會花約 8 秒、COCO JSON 序列化約 1 秒、YOLO txt 寫檔約 0.5 秒;如果你要處理更大的資料集(例如 COCO 2017 train 共 118K 影像、886K 框),建議用 ijson 串流讀取 COCO 的 JSON,並用 multiprocessing.Pool 把 XML 寫檔分散到多顆 CPU 核心。
實務上轉檔只是資料準備的環節之一,下游的資料增強(augmentation)才是決定模型表現的關鍵。Ultralytics 8.3 內建的 Albumentations 整合支援 YOLO 格式的 bbox 同步增強,但對於 COCO 與 VOC,則需要先轉成 YOLO 才能享用同一套增強管線。這也是為什麼「先把資料統一到 YOLO 格式」是多數偵測專案的標準前置動作。本系列在 Day 23 會更深入地談分割任務的資料增強,在 Day 13 與 Day 14 會把今天的 YOLO 格式接到 Ultralytics 8.3 的訓練管線上,這樣明天與後天就能直接從同一份 YOLO txt 開訓。
另外有一個工程上的小提醒:PASCAL VOC 的 XML 結構雖然簡單,但若你的資料夾同時包含 Annotations/ 與 SegmentationClass/、SegmentationObject/ 等子資料夾(這在完整 VOC 2007 中常見),寫轉檔程式時要避免把 segmentation 用的 PNG 也當成「物件偵測標註」讀進來。本篇範例只用 Annotations/*.xml,對應到偵測任務;分割任務的標註會在 Day 18 之後處理。最後要記得:標註檔案版本化(放進 DVC 或 git-lfs),不要讓「哪批影像用哪個 XML」這種資訊只存在工程師的腦中。
額外補充一個工程實務:在做資料切分(train/val/test split)時,建議先用檔名排序再做固定比例切分(例如 8:1:1),避免隨機種子在多人協作時難以重現。Ultralytics 提供的 dataset_split 工具或 Roboflow 的 split 功能會自動處理這件事,但若你直接寫腳本,記得用 random.Random(42) 這類固定種子的隨機物件,讓不同人、不同機器切出來的 train/val/test 完全一致。
小結
今天我們把 COCO、VOC、YOLO 三種偵測標註格式從資料結構到轉換程式都走了一遍。重點回顧:COCO 用單一 JSON 含 images、annotations、categories 三大區塊,bbox 是像素單位的 [x, y, width, height];VOC 用一張影像一個 XML,bbox 是像素的 (xmin, ymin, xmax, ymax);YOLO 用一張影像一個 txt,bbox 是正規化到 0 到 1 的 (class cx cy w h)。從 VOC 轉 YOLO 的公式:先把 (xmin, ymin, xmax, ymax) 換成 (x, y, w, h),再把 cx = (x + w/2)/img_w、cy = (y + h/2)/img_h、nw = w/img_w、nh = h/img_h,並把所有數值維持在 0 到 1 之間。最後 round-trip 驗證確認 12 個框從 VOC 寫出再讀回完全一致,這是寫轉檔程式時不可省略的環節。
結語
今天的重點是「拿到任何一邊的標註,都能轉成另一邊」。我們從三種格式的資料結構開始,建立一組可重現的合成標註,再用 Python 把同一批資料走完 VOC→COCO→YOLO 三輪轉換,最後用 round-trip 驗證保證數值一致。讀完這篇你應該能回答:COCO 的 bbox 是 [x, y, w, h] 還是 [x1, y1, x2, y2]?YOLO 為什麼要把所有數值除以影像尺寸?從 VOC XML 轉 YOLO txt 時,座標要經過哪些計算?明天,我們會把今天準備好的 YOLO 格式接到 Ultralytics 8.3 的訓練管線上,用一個公開的小型資料集實際跑一次 YOLO11 訓練,把「資料」與「模型」正式接起來。
延伸資源
- COCO 官方資料集與標註規範(2014 釋出,2017 擴充):
https://cocodataset.org/,包含 Common Objects in Context 競賽的完整格式定義。 - PASCAL VOC 2007 挑戰賽官方網站(Visual Object Classes Challenge,自訂學術用途授權):
http://host.robots.ox.ac.uk/pascal/VOC/voc2007/,物件偵測 XML 結構的原始定義。 - Ultralytics YOLO11 資料格式說明(8.3.x,2024):
https://docs.ultralytics.com/datasets/detect/,官方對於 YOLO txt、data.yaml、train/val/test 切分的格式要求。 - Roboflow Universe(2024):
https://universe.roboflow.com/,彙整上千個 YOLO 格式公開資料集,多數採用 CC BY 4.0 或自訂學術授權。 - Lin 等人,2014,Microsoft COCO: Common Objects in Context(ECCV 2014),COCO 資料集與 mAP 指標的原始論文。
- Everingham 等人,2010,The PASCAL Visual Object Classes (VOC) Challenge(IJCV 2010),PASCAL VOC 系列的回顧論文。
留言
張貼留言