Like Share Discussion Bookmark Smile

J.J. Huang   2026-03-25   Python OpenCV 07.物件偵測與辨識篇   瀏覽次數:次   DMCA.com Protection Status

Python | OpenCV 資料標註

📚 前言

在上一篇 資料蒐集 中,我們蒐集好了訓練所需的圖片。
接下來要進行的是 資料標註 (Data Annotation),也就是為每張圖片建立「正確答案」,讓模型知道圖片裡有什麼。

標註的品質直接影響模型的學習成效,錯誤或不一致的標註會讓模型學到錯誤的規則。

🔎 標註類型

依照任務不同,標註方式也不同:

任務類型 標註方式 說明
圖片分類 類別標籤 為整張圖片標記一個類別
物件偵測 邊界框 (Bounding Box) 框出物件位置並標記類別
語義分割 像素遮罩 (Mask) 逐像素標記所屬類別
實例分割 個別物件遮罩 為每個物件實例建立獨立遮罩
關鍵點偵測 座標點 標記特定部位的座標(如人體骨架)

🧠 標註格式說明

不同框架使用不同的標註格式,常見格式如下:

YOLO 格式

每張圖片對應一個 .txt 檔案,每行代表一個物件:

1
<class_id> <x_center> <y_center> <width> <height>
  • 所有數值皆為相對圖片寬高的比例(0.0 ~ 1.0)
  • 範例:0 0.5 0.4 0.3 0.2

COCO 格式

使用單一 JSON 檔案描述所有圖片與標註:

1
2
3
4
5
{
"images": [{"id": 1, "file_name": "0001.jpg", "width": 640, "height": 480}],
"annotations": [{"id": 1, "image_id": 1, "category_id": 0, "bbox": [100, 80, 200, 150]}],
"categories": [{"id": 0, "name": "cat"}]
}

Pascal VOC 格式

每張圖片對應一個 .xml 檔案:

1
2
3
4
5
6
7
8
9
10
<annotation>
<filename>0001.jpg</filename>
<object>
<name>cat</name>
<bndbox>
<xmin>100</xmin><ymin>80</ymin>
<xmax>300</xmax><ymax>230</ymax>
</bndbox>
</object>
</annotation>

💻 標註工具介紹

LabelImg

輕量的本地端標註工具,支援 YOLO 與 Pascal VOC 格式。

1
2
pip install labelImg
labelImg

操作方式:

  1. Open Dir:開啟 collect_from_video.py 的輸出目錄(例如 dataset/toy_car/
  2. Change Save Dir:設定為相同目錄,標籤 .txt 將存放於此
  3. 選擇儲存格式(YOLO 或 Pascal VOC)
  4. W 開始畫邊界框
  5. 輸入類別名稱後儲存

⌨️ 核心常用快捷鍵

快捷鍵 功能
W 畫邊界框(Create RectBox)
D 下一張圖片
A 上一張圖片
Ctrl + S 儲存當前標註
Del 刪除選取的邊界框
Space 標記為已驗證(Verified)
Ctrl + D 複製選取的邊界框
Ctrl + Shift + D 刪除當前圖片
Ctrl + 滾輪 縮放圖片

⚠️ 已知問題:按 W 標記時崩潰

labelImg 的原始 repo 已被封存(archived),不再維護,與新版 PyQt5(5.15+)存在相容性問題。

新版 PyQt5 改為嚴格型別模式,QPointF.x() 回傳 float,但 drawLinedrawRect 等方法要求 int,導致 crash。舊版 PyQt5 會自動做型別轉換,所以不會出現這個問題。





有三種處理方式:

方式 說明 缺點
降版 PyQt5 到 5.14 以下 pip install PyQt5==5.14.2 可能影響其他套件
改用 labelme pip install labelme 預設輸出 JSON,需另行轉換為 YOLO 格式
直接修原始碼 canvas.pylabelImg.py 共兩處 重裝套件後須重修

若選擇直接修,需修改兩個檔案:

canvas.py:找到以下三行:

1
2
3
p.drawRect(left_top.x(), left_top.y(), rect_width, rect_height)
p.drawLine(self.prev_point.x(), 0, self.prev_point.x(), self.pixmap.height())
p.drawLine(0, self.prev_point.y(), self.pixmap.width(), self.prev_point.y())

改為:

1
2
3
p.drawRect(int(left_top.x()), int(left_top.y()), int(rect_width), int(rect_height))
p.drawLine(int(self.prev_point.x()), 0, int(self.prev_point.x()), self.pixmap.height())
p.drawLine(0, int(self.prev_point.y()), self.pixmap.width(), int(self.prev_point.y()))

labelImg.py(修改一):找到 scroll_request 方法中的這行:

1
bar.setValue(bar.value() + bar.singleStep() * units)

改為:

1
bar.setValue(int(bar.value() + bar.singleStep() * units))

labelImg.py(修改二):找到 zoom_request 方法,共有三行需要修改:

1
2
3
4
self.add_zoom(scale * units)
# ...
h_bar.setValue(new_h_bar_value)
v_bar.setValue(new_v_bar_value)

改為:

1
2
3
4
self.add_zoom(int(scale * units))
# ...
h_bar.setValue(int(new_h_bar_value))
v_bar.setValue(int(new_v_bar_value))

💡 三處 crash 原因相同:Python 3 的除法與乘法結果為 float,但 setValueadd_zoom 只接受 int,加上 int() 即可。scroll_request 修的是滾輪平移,zoom_request 修的是滾輪縮放(縮放量 + 視窗位移),共四處都需要補上 int()

⚠️ 已知問題:存檔後 classes.txt 遺失類別

LabelImg 每次存檔時,只把這張圖用到的類別寫進 classes.txt,原本已有的其他類別會被清掉。例如你標了一張只有 cat 的圖後存檔,classes.txt 就只剩 cat,下一張有 dog(class_id=1)的圖開啟時會 crash:

1
2
IndexError: list index out of range
label = self.classes[int(class_index)]

這是 LabelImg 設計上的缺陷,repo 已封存不會修復。直接修 libs/yolo_io.pysave() 方法,在開檔寫入前先讀取現有類別:

找到以下段落:

1
2
3
4
5
6
7
8
9
10
if target_file is None:
out_file = open(
self.filename + TXT_EXT, 'w', encoding=ENCODE_METHOD)
classes_file = os.path.join(os.path.dirname(os.path.abspath(self.filename)), "classes.txt")
out_class_file = open(classes_file, 'w')

else:
out_file = codecs.open(target_file, 'w', encoding=ENCODE_METHOD)
classes_file = os.path.join(os.path.dirname(os.path.abspath(target_file)), "classes.txt")
out_class_file = open(classes_file, 'w')

改為:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if target_file is None:
out_file = open(
self.filename + TXT_EXT, 'w', encoding=ENCODE_METHOD)
classes_file = os.path.join(os.path.dirname(os.path.abspath(self.filename)), "classes.txt")
else:
out_file = codecs.open(target_file, 'w', encoding=ENCODE_METHOD)
classes_file = os.path.join(os.path.dirname(os.path.abspath(target_file)), "classes.txt")

# Read existing classes before opening for write (opening 'w' truncates the file)
if os.path.exists(classes_file):
with open(classes_file, 'r') as cf:
for line in cf:
c = line.strip()
if c and c not in class_list:
class_list.append(c)

out_class_file = open(classes_file, 'w')

CVAT (Computer Vision Annotation Tool)

功能完整的網頁標註平台,支援邊界框、多邊形、分割遮罩等多種標註類型。

  • 可自架或使用 app.cvat.ai 線上版
  • 支援多人協作標註
  • 匯出格式:YOLO、COCO、Pascal VOC 等

Roboflow

整合資料管理、標註、增強與匯出的雲端平台。

  • 可直接在瀏覽器標註並管理資料集版本
  • 支援自動標註(Auto-Label)輔助加速
  • 可直接匯出為 YOLOv5、YOLOv8、COCO 等訓練格式

✂️ 訓練集與驗證集切分

標註完成後,需要將資料分為訓練集與驗證集。有兩種做法:

做法一:拍兩支影片(建議)

用不同背景或光線各拍兩段,分別執行 collect_from_video.py 收集後各自標註,自然形成訓練集與驗證集的資料分離:

  1. toy_car_A.mp4(桌面背景、自然光):SAVE_DIR = "dataset/raw_train/" → LabelImg 標註 → 整理至 images/train/labels/train/
  2. toy_car_B.mp4(地板背景、室內燈):SAVE_DIR = "dataset/raw_val/" → LabelImg 標註 → 整理至 images/val/labels/val/

這樣驗證集場景與訓練集不同,能真正測試模型是否學到物件本身的特徵,而不是背景。

做法二:只有一支影片,按比例切分

標註完成後,圖片(.jpg)與標籤(.txt)存放在同一目錄,以下腳本將前 80% 分給訓練集、後 20% 分給驗證集,圖片與標籤同步搬移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# split_dataset.py
import os
import shutil

src_dir = "dataset/toy_car" # collect_from_video.py 的輸出目錄(含圖片與 .txt 標籤)
train_img_dir = "dataset/toy_car/images/train"
val_img_dir = "dataset/toy_car/images/val"
train_lbl_dir = "dataset/toy_car/labels/train"
val_lbl_dir = "dataset/toy_car/labels/val"

for d in [train_img_dir, val_img_dir, train_lbl_dir, val_lbl_dir]:
os.makedirs(d, exist_ok=True)

files = sorted(f for f in os.listdir(src_dir) if f.endswith(".jpg"))
split = int(len(files) * 0.8)

for i, f in enumerate(files):
img_dst = train_img_dir if i < split else val_img_dir
lbl_dst = train_lbl_dir if i < split else val_lbl_dir
shutil.copy(os.path.join(src_dir, f), os.path.join(img_dst, f))
label_f = f.replace(".jpg", ".txt")
label_src = os.path.join(src_dir, label_f)
if os.path.exists(label_src):
shutil.copy(label_src, os.path.join(lbl_dst, label_f))

print(f"訓練集:{split} 張 → {train_img_dir}")
print(f"驗證集:{len(files) - split} 張 → {val_img_dir}")


圖:split_dataset.py 將圖片與標籤同步切分為訓練集與驗證集

⚠️ 只有一支影片時,驗證集與訓練集來自同一場景、同一光線,泛化測試效果較有限,建議後續補拍第二支影片替換。

🗃️ 資料目錄結構

標註並切分完成後,資料目錄結構依任務類型如下:

分類任務:

1
2
3
4
5
6
7
dataset/
├── train/
│ ├── cat/
│ └── dog/
└── val/
├── cat/
└── dog/

PyTorch ImageFolder 與 Keras image_dataset_from_directory 都能自動讀取這個結構,資料夾名稱即為類別標籤。

物件偵測任務(YOLO 格式):

1
2
3
4
5
6
7
dataset/
├── images/
│ ├── train/
│ └── val/
└── labels/
├── train/
└── val/

圖片與標註檔案分開存放,例如 images/train/0001.jpg 對應 labels/train/0001.txt

💻 範例程式 — 以 Python 驗證標註結果

標註並切分完成後,可用以下程式將標註框繪製在圖片上,確認標註是否正確。

驗證 YOLO 格式標註

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# verify_annotation.py
import cv2
import os

image_path = "dataset/toy_car/images/train/00000.jpg"
label_path = "dataset/toy_car/labels/train/00000.txt"

img = cv2.imread(image_path)
if img is None:
print(f"❌ 無法讀取圖片:{image_path}")
exit()
h, w = img.shape[:2]

with open(label_path, "r") as f:
for line in f:
parts = line.strip().split()
class_id = int(parts[0])
cx, cy, bw, bh = float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])

x1 = int((cx - bw / 2) * w)
y1 = int((cy - bh / 2) * h)
x2 = int((cx + bw / 2) * w)
y2 = int((cy + bh / 2) * h)

cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(img, str(class_id), (x1, y1 - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)

max_display = 900
if max(h, w) > max_display:
scale = max_display / max(h, w)
display = cv2.resize(img, (int(w * scale), int(h * scale)))
else:
display = img

cv2.imshow("Annotation Check", display)
cv2.waitKey(0)
cv2.destroyAllWindows()


圖:讀取 YOLO 格式標註檔並將邊界框繪製在圖片上,驗證標註位置是否正確

批次統計標註數量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# count_labels.py
import os

label_dir = "dataset/toy_car/labels/train"
class_count = {}

for fname in os.listdir(label_dir):
if not fname.endswith(".txt"):
continue
with open(os.path.join(label_dir, fname), "r") as f:
for line in f:
class_id = int(line.strip().split()[0])
class_count[class_id] = class_count.get(class_id, 0) + 1

for cls, count in sorted(class_count.items()):
print(f"Class {cls}: {count} 個標註")


圖:批次掃描標籤目錄統計各類別的標註數量並輸出結果

💡 class ID 對應的類別名稱記錄在 LabelImg 產生的 classes.txt(與圖片存放於同一目錄),可對照查詢。

⚠️ 注意事項

  • 標註一致性:同一個類別的邊界框應涵蓋範圍一致,不要有時框緊、有時框鬆。
  • 遮擋物件:被遮擋超過 50% 以上的物件,可考慮不標註,避免干擾訓練。
  • 類別命名:整個資料集的類別名稱與 ID 對應必須統一,避免混淆。
  • 定期備份:標註結果是耗時的人工成果,務必做好版本備份。

📊 應用場景

  • 工廠瑕疵偵測:標註良品與瑕疵位置,訓練自動品管模型。
  • 醫學影像分析:標註病灶區域,協助輔助診斷模型訓練。
  • 自駕車感知:標註車輛、行人、號誌等物件,建立環境感知模型。

🎯 結語

資料標註是整個訓練流程中最耗費人力的環節,但也是品質的關鍵所在。
善用標註工具並建立一致的標註規範,能大幅提升後續訓練的效果。
下一步是進入 模型選擇與訓練,將標註好的資料送入模型進行學習。

📖 如在學習過程中遇到疑問,或是想了解更多相關主題,建議回顧一下 Python | OpenCV 系列導讀,掌握完整的章節目錄,方便快速找到你需要的內容。

註:以上參考了
OpenCV 官方文件 — Tutorials
LabelImg GitHub
CVAT 官方網站
Roboflow 官方網站
COCO Dataset 格式說明