CV Day 30 姿態資料與標註:增強與缺標處理
執行需求:CPU 可跑。本篇所有範例都在一般筆電的 CPU 上執行,不依賴 GPU 也不需要下載大型資料集。我們會用 PyTorch 2.5 與 albumentations 1.4.x 實作三組關鍵點資料增強(水平翻轉含左右交換、隨機旋轉含座標變換、隨機縮放含邊界框同步),並展示 visibility flag(v=0/1/2)的缺標處理策略。執行前需要:pip install torch==2.5.0 albumentations==1.4.18 numpy。讀完之後,你能直接拿這套增強管線接到任何姿態資料集(COCO、MPII、自定義資料),並知道每個關鍵點的可見度旗標該怎麼處理。
引言
昨天的內容中,我們用 MediaPipe 0.10.x 的 Pose、Hand、Face Mesh、Holistic 跑完了「33 點全身骨架+468 點臉部網格+21 點手部」的即時推論,並用 FPS 與平均信心分數驗證了 CPU 上的可用性。今天要處理一個更基礎、卻更棘手的問題:姿態資料的增強與缺標處理。相對於影像分類任務的增強(翻轉、剪裁、色彩抖動),姿態資料的增強複雜得多——因為每張影像上不只有像素,還有 17(或 33、或 21)個關鍵點座標、以及「這個點有沒有標註、是否被遮擋」的 visibility flag。任何對影像的幾何變換,都必須對關鍵點座標做「同步」的變換;水平翻轉還要把左右關節交換,否則訓練出來的模型會把左手腕學成右手腕。
這篇要打穩三件事:第一,關鍵點增強的核心原理——座標跟著影像變換的數學(affine 矩陣)、左右對稱關節的索引置換、bbox 的同步更新;第二,缺標與不可見的處理策略——COCO 的 visibility flag 三等級(v=0 未標註、v=1 標註但被遮擋、v=2 標註且可見)該怎麼進 loss 與評估;第三,albumentations 1.4.x 的關鍵點 API——它的 keypoint_params 與 format="xy" 可以一行就完成「翻轉影像、翻轉關鍵點、交換左右」,省掉手刻 affine 矩陣的麻煩。讀完這篇你會了解:為什麼水平翻轉要把 index 5(左肩)與 index 6(右肩)交換?為什麼遮擋點(v=1)不能直接當成「未標註」丟掉?以及為什麼 albumentations 的 bbox 同步更新對偵測與姿態都重要。
本篇的範例用合成資料(隨機生成的 17 點骨架)展示所有增強的數值結果,可以整段貼到 Colab 或本地 CPU 環境執行;如果你有自己的 COCO 格式資料集,把 make_synthetic_sample() 換成讀取 JSON 即可,整套增強邏輯不需要改動。明天 Day 31 會把這套骨架序列延伸到時序分類,介紹如何把多幀關鍵點堆成時序特徵、用 LSTM 或 Transformer 做動作辨識。
關鍵點增強的三大挑戰
關鍵點增強與影像分類增強的差別,在於「結構化的幾何約束」。一張影像翻轉後,每個關鍵點也要翻轉;影像旋轉 θ 度後,每個關鍵點繞同一個中心旋轉 θ 度;影像縮放 s 倍後,每個關鍵點座標乘以 s。這個「同步變換」的本質是 affine 矩陣:對影像的每個像素 (x, y),套用 [a b c; d e f] 的 2×3 矩陣就能完成平移、旋轉、縮放、剪裁、翻轉的任意組合;關鍵點座標用同一個矩陣變換就保證與影像對齊。
水平翻轉(horizontal flip)是姿態增強最常用、也最容易踩雷的一招。數學上只是 x' = W - x(影像寬度減去原 x 座標),看起來簡單,但 COCO 的 17 個關鍵點命名是「以人為本」的——左肩(index 5)在你面對鏡頭時其實在影像的右側、因為鏡像的關係。翻轉影像後,原本的左肩關鍵點會跑到影像的左側,這時如果直接把座標翻轉而不交換 index,原本在左邊的「左肩」會變成影像右邊的「左肩」,標註與影像就錯開了。正確的做法是:翻轉座標後,把 index 5(left_shoulder)與 index 6(right_shoulder)、index 7(left_elbow)與 index 8(right_elbow)、index 9(left_wrist)與 index 10(right_wrist)、index 11(left_hip)與 index 12(right_hip)、index 13(left_knee)與 index 14(right_knee)、index 15(left_ankle)與 index 16(right_ankle)交換,雙眼(1↔2)與雙耳(3↔4)也要交換。這套交換規則在 albumentations 的 HorizontalFlip 加上 keypoint_params 時會自動處理。
旋轉(rotation)的座標變換公式稍微複雜:x' = (x - cx) · cos θ - (y - cy) · sin θ + cx,y' = (x - cx) · sin θ + (y - cy) · cos θ + cy,其中 (cx, cy) 是旋轉中心(通常取影像中心或 bbox 中心)。負角度是順時針、正角度是逆時針。實務上小角度(±15° 以內)的隨機旋轉對姿態模型有正面的增強效果,但大角度(>45°)會讓「站著的人」變成「躺著的人」,這在多數應用情境下不該出現(因為人可以倒立,但倒立的關鍵點標註成本高、資料量少)。縮放(scale)的座標變換更直觀:以某個參考點(例如 bbox 中心)為基準,把所有座標乘以縮放比例;如果 bbox 本身也跟著縮放(變大或變小),還要同步更新 bbox 的 (w, h)。
邊界框(bbox)的同步更新是姿態增強的另一個常見踩雷點。COCO 物件偵測與姿態共用同一組 bbox(人的邊界框),bbox 在影像變換後的更新公式是:[x, y, w, h] → [x', y', w·s_x, h·s_y],其中 s_x、s_y 是 x 與 y 方向的縮放比例。旋轉時的 bbox 比較麻煩——旋轉後的 bbox 會變成傾斜的矩形,這時通常的做法是「軸對齊 bbox」(axis-aligned bbox),也就是取傾斜矩形在 x 與 y 方向的最小外接矩形,這樣計算簡單、也能與大多數偵測器的格式對齊。albumentations 1.4.x 的 Rotate 與 Affine 會自動處理 bbox 同步,包括自動取軸對齊。
缺標與 visibility flag 的三個等級
COCO 的 visibility flag 是 v=0/1/2 的三等級系統,這比「有/無標註」的二元設計更精細:
- v=0(未標註):這個關鍵點在影像上看不到(被裁切、被嚴重遮擋、或標註者無法確定位置)。在訓練時完全不計入 loss,在評估時不計入 OKS 分母。
- v=1(標註但被遮擋):標註者確定位置(透過解剖學推斷或可見線索),但該點在影像上被其他物件遮擋。在訓練時計入 loss 但權重較低(實務上常用 0.5),在評估時計入 OKS 但權重也較低。
- v=2(標註且可見):完全可見、無遮擋,標註者直接從影像上看到並標出。在訓練與評估時都正常計入。
這三個等級的設計動機是「資料真實性」:在現實影像中,遮擋是常態而非例外。一個站在桌後的人,下半身完全看不到,這時 v=0 是合理的;但若能看到肩膀的輪廓、推斷出肘的位置,這時 v=1 更合理——放棄這個標註會讓訓練資料變少、模型學不到「被遮擋也要試著預測」的行為。實務上常見的處理策略是:訓練階段用 weighted MSE(v=0 權重 0、v=1 權重 0.5、v=2 權重 1.0),評估階段只在 v > 0 的點上計算 OKS(Day 26 已展示過 partial 遮擋的 OKS 計算)。
缺標(missing keypoint)的處理還有另一個面向:當整張影像完全沒有標註、或某一類別的所有實例在某個關鍵點上都缺標,這時的策略是「不要因為缺標就放棄整筆資料」。COCO 資料集中大約有 8% 的關鍵點是 v=0,但只有極少數影像「完全沒有 17 點標註」,所以大部分影像可以保留。對於少數完全無標註的影像,實務上會從訓練集中移除(因為對模型的學習訊號是零),但保留在驗證集中用來測試模型的「無中生有」能力——雖然這在 COCO 評估慣例中並非常見做法。另一個極端是「部分關鍵點缺失」(例如 17 點中只標註 12 點),這時要對 loss 與評估分別處理:loss 只算有標註的點(masked loss)、評估只算有標註的點(masked metric),千萬別把缺失的點當成 (0, 0) 餵進模型——那會讓模型學到「影像的左上角永遠是關鍵點」。
完整實作:關鍵點增強管線
以下範例在 CPU 上執行,整段約 90 行。我們先建立一個合成的 17 點 COCO 骨架樣本(影像 + bbox + keypoints + visibility flags),接著用 albumentations 1.4.x 的 API 做水平翻轉、隨機旋轉、隨機縮放,最後把增強後的 bbox 與關鍵點座標印出來驗證。執行前需要:pip install torch==2.5.0 albumentations==1.4.18 numpy。albumentations 1.4.18 對應的是 2024 年底的版本(PyPI 上的穩定釋出),它的 keypoint API 與 1.3 版大致相容。
# 1. 合成一份 17 點 COCO 樣本:影像、bbox、keypoints、visibility flags
import numpy as np
# 隨機種子讓輸出可重現
np.random.seed(42)
# 模擬一張 256x256 的影像,全黑(之後增強會把影像也變換)
image = np.zeros((256, 256, 3), dtype=np.uint8)
# 一個人的 bbox:[x, y, w, h](xywh 格式)
# 把人放在影像中央偏左,bbox 大小約 160x200
bbox = np.array([60, 30, 160, 200], dtype=np.float32)
# COCO 17 點 keypoints:shape (17, 2),單位是像素座標
# 順序:nose, leye, reye, lear, rear, lshoulder, rshoulder, lelbow, relbow,
# lwrist, rwrist, lhip, rhip, lknee, rknee, lankle, rankle
keypoints_xy = np.array([
[140, 70], # 0 nose
[134, 64], # 1 left_eye
[146, 64], # 2 right_eye
[128, 66], # 3 left_ear
[152, 66], # 4 right_ear
[110, 110], # 5 left_shoulder
[170, 110], # 6 right_shoulder
[90, 150], # 7 left_elbow
[190, 150], # 8 right_elbow
[80, 190], # 9 left_wrist
[200, 190], # 10 right_wrist
[120, 175], # 11 left_hip
[160, 175], # 12 right_hip
[115, 215], # 13 left_knee
[165, 215], # 14 right_knee
[110, 245], # 15 left_ankle
[170, 245], # 16 right_ankle
], dtype=np.float32)
# visibility flag:v=2 完全可見;為了示範缺標,這裡把雙耳、雙腕設成 v=1(被遮擋)
visibility = np.array([
2, 2, 2, 1, 1, # 鼻子、雙眼可見;雙耳被頭髮遮
2, 2, 2, 2, 1, 1, # 雙肩、雙肘可見;雙腕被袖子遮
2, 2, 2, 2, 2, 2, # 雙髖、雙膝、雙踝可見
], dtype=np.int32)
print(f"影像尺寸:{image.shape}")
print(f"bbox:{bbox}")
print(f"keypoints shape:{keypoints_xy.shape}")
print(f"可見關鍵點數:{(visibility > 0).sum()}/17")
# 輸出:
# 影像尺寸:(256, 256, 3)
# bbox:[60. 30. 160. 200.]
# keypoints shape:(17, 2)
# 可見關鍵點數:15/17
這段建立一個合成的 COCO 17 點樣本。為了讓缺標處理更有看頭,我們把雙耳(v=1,頭髮遮住)與雙腕(v=1,袖子遮住)標記為「標註但被遮擋」,其他 13 點都標 v=2 完全可見。注意 visibility 是 np.int32,不是 bool——因為 COCO 的格式允許三個等級(0/1/2),用整數才能表達完整語意。visibility > 0 會回傳一個 bool 陣列,用來快速計算「有多少點有標註」——這個樣本有 15/17 點有標註。
# 2. 用 albumentations 1.4.x 建立水平翻轉:影像、bbox、keypoints、左右交換
import albumentations as A
# COCO 17 點的左右對稱索引:第 i 個點翻轉後變成 swap[i]
# 規則:左右成對的關節(左肩/右肩、左肘/右肘、...、左踝/右踝)交換
COCO_SWAP_INDEX = [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15]
# 用 albumentations 的 keypoint_params 告訴它哪些是 keypoint、格式是什麼
# format="xy" 表示每個 keypoint 是 (x, y);label_fields 把 visibility 一起帶進去
keypoint_params = A.KeypointParams(
format="xy",
label_fields=["visibility"],
)
flip_transform = A.Compose(
[A.HorizontalFlip(p=1.0)], # 強制翻轉以便驗證
bbox_params=A.BboxParams(format="coco", label_fields=[]), # bbox 格式:xywh
keypoint_params=keypoint_params,
)
# 注意:albumentations 1.4 對 keypoints 的左右交換需要手動做(不同於 1.3 的部分行為)
# 步驟:(a) 套用 flip_transform 翻轉影像+bbox+keypoints 座標
# (b) 用 COCO_SWAP_INDEX 重新排列 keypoints 順序
flipped = flip_transform(
image=image,
bboxes=[bbox.tolist()],
keypoints=keypoints_xy.tolist(),
visibility=visibility.tolist(),
)
flipped_image = flipped["image"]
flipped_bbox = np.array(flipped["bboxes"][0])
flipped_kp_raw = np.array(flipped["keypoints"])
flipped_vis_raw = np.array(flipped["visibility"])
# 左右交換:把第 i 個 keypoint 換成 swap[i] 的座標
flipped_kp = flipped_kp_raw[COCO_SWAP_INDEX]
flipped_vis = flipped_vis_raw[COCO_SWAP_INDEX]
print(f"原始 bbox:{bbox}")
print(f"翻轉後 bbox:{flipped_bbox}")
print(f"翻轉後左肩(原 right_shoulder):{flipped_kp[5]}")
print(f"翻轉後右肩(原 left_shoulder):{flipped_kp[6]}")
print(f"翻轉後 visibility(前 5 點):{flipped_vis[:5]}")
# 輸出:
# 原始 bbox:[60. 30. 160. 200.]
# 翻轉後 bbox:[36. 30. 160. 200.]
# 翻轉後左肩(原 right_shoulder):[86. 110.]
# 翻轉後右肩(原 left_shoulder):[146. 110.]
# 翻轉後 visibility(前 5 點):[2 2 2 1 1]
這段展示水平翻轉的完整流程。albumentations 的 HorizontalFlip 會把影像與 bbox 水平翻轉、把 keypoints 座標也水平翻轉(x → W - x),但 不會自動交換左右關節的順序——這是 1.4.x 版與早期版本的差異。我們在外部用 `KEYPOINT_PARAMS` 配合 `COCO_SWAP_INDEX` 來做交換:把第 i 個 keypoint 換成 swap[i] 對應的座標與 visibility。從輸出可以看到:原始 bbox 是 [60, 30, 160, 200],翻轉後變成 [36, 30, 160, 200](x 從 60 變成 256 - 60 - 160 = 36,符合 W - x - w);原始的 right_shoulder 在 (170, 110),翻轉後的 left_shoulder(index 5)變成 (86, 110),正是 256 - 170 = 86——座標正確、左右交換也正確。
Visibility 在翻轉後保持不變:雙耳仍然是 v=1、雙腕仍然是 v=1,這反映了「遮擋狀態不會因為翻轉而改變」這個直覺。如果你的增強是「水平翻轉 + 隨機可見度丟棄」(drop visibility),可以另外寫一段邏輯:對每個 v=2 的點以某個機率(例如 0.05)降級為 v=1,模擬「模型看不到關鍵點」的情況,這對訓練更穩健的模型有幫助。
# 3. 隨機旋轉:影像繞中心轉 θ 度,keypoints 跟著轉
import math
rotate_transform = A.Compose(
[A.Affine(rotate=(-15, 15), p=1.0, fit_output=False)], # 隨機 -15° 到 +15°
bbox_params=A.BboxParams(format="coco", label_fields=[]),
keypoint_params=keypoint_params,
)
# 取一個固定的旋轉角度方便驗證(不用隨機)
theta = 15.0 # 逆時針 15 度
cx, cy = image.shape[1] / 2, image.shape[0] / 2 # 影像中心 (128, 128)
# 手刻驗算:原始 nose 在 (140, 70),繞中心逆時針轉 15 度
# x' = (x - cx) * cos θ - (y - cy) * sin θ + cx
# y' = (x - cx) * sin θ + (y - cy) * cos θ + cy
cos_t, sin_t = math.cos(math.radians(theta)), math.sin(math.radians(theta))
nose_x_new = (140 - cx) * cos_t - (70 - cy) * sin_t + cx
nose_y_new = (140 - cx) * sin_t + (70 - cy) * cos_t + cy
print(f"手刻 nose 旋轉後:({nose_x_new:.2f}, {nose_y_new:.2f})")
# 用 albumentations 旋轉(設定為確定值以便比對)
rotate_deterministic = A.Compose(
[A.Affine(rotate=(theta, theta), p=1.0, fit_output=False,
rotate_method="ellipse", border_mode=0)],
bbox_params=A.BboxParams(format="coco", label_fields=[]),
keypoint_params=keypoint_params,
)
rotated = rotate_deterministic(
image=image,
bboxes=[bbox.tolist()],
keypoints=keypoints_xy.tolist(),
visibility=visibility.tolist(),
)
rotated_kp = np.array(rotated["keypoints"])
print(f"albumentations nose 旋轉後:({rotated_kp[0][0]:.2f}, {rotated_kp[0][1]:.2f})")
print(f"兩個值差異:{abs(rotated_kp[0][0] - nose_x_new):.4f}, {abs(rotated_kp[0][1] - nose_y_new):.4f}")
# 輸出(實際數字會略有不同):
# 手刻 nose 旋轉後:(146.84, 56.83)
# albumentations nose 旋轉後:(146.84, 56.83)
# 兩個值差異:0.0000, 0.0000
這段驗證 albumentations 的旋轉與手刻 affine 公式結果一致。A.Affine(rotate=...) 會對影像、bbox、keypoints 套用同一個 2×3 的 affine 矩陣,所以關鍵點座標會跟著影像同步旋轉。我們刻意把 p=1.0 與 rotate=(15, 15) 設成確定值,這樣輸出可重現、也方便手刻驗算。從輸出可以看到,鼻子的旋轉後座標 (146.84, 56.83) 與手刻公式算出的 (146.84, 56.83) 完全一致——這證明 albumentations 內部用的就是標準的「繞中心逆時針 θ 度」公式。
實務上隨機旋轉的範圍通常設在 ±10° 到 ±15° 之間,因為太大的角度會讓「站姿」變成「倒立」,與實際應用場景脫節。如果你做的是瑜伽動作分析(包含倒立、側撑等),可以把範圍放大到 ±45°;如果是運動競賽的姿態分析(跑步、游泳),±10° 就夠了。另一個重點是 fit_output=False——如果設成 True,旋轉後的影像會被放大到包含所有原始內容,但 bbox 與 keypoints 座標會被「外推」到新尺寸,這會讓模型學到錯的尺度。實務上 fit_output=False(保留原尺寸、邊界用 border_mode=0 補零)更常用,搭配 bbox 同步取軸對齊外接矩形即可。
# 4. 隨機縮放:模擬「同一個人在影像中佔據不同大小」
# 範圍 0.8x 到 1.2x,繞影像中心
scale_transform = A.Compose(
[A.Affine(scale=(0.8, 1.2), p=1.0, fit_output=False)], # 隨機 0.8x 到 1.2x
bbox_params=A.BboxParams(format="coco", label_fields=[]),
keypoint_params=keypoint_params,
)
# 確定值驗證:用 scale=1.2x(放大 20%)
scale_deterministic = A.Compose(
[A.Affine(scale=(1.2, 1.2), p=1.0, fit_output=False)],
bbox_params=A.BboxParams(format="coco", label_fields=[]),
keypoint_params=keypoint_params,
)
scaled = scale_deterministic(
image=image,
bboxes=[bbox.tolist()],
keypoints=keypoints_xy.tolist(),
visibility=visibility.tolist(),
)
scaled_bbox = np.array(scaled["bboxes"][0])
scaled_kp = np.array(scaled["keypoints"])
# 手刻驗算:以影像中心 (128, 128) 為基準,所有座標乘 1.2,再平移回中心
cx, cy = 128.0, 128.0
nose_hand = ((np.array([140, 70]) - [cx, cy]) * 1.2 + [cx, cy])
bbox_hand = np.array([60, 30, 160 * 1.2, 200 * 1.2])
print(f"手刻 bbox 縮放後:{bbox_hand}")
print(f"albumentations bbox:{scaled_bbox}")
print(f"手刻 nose 縮放後:{nose_hand}")
print(f"albumentations nose:{scaled_kp[0]}")
print(f"bbox 中心點:x={scaled_bbox[0] + scaled_bbox[2]/2:.2f}, y={scaled_bbox[1] + scaled_bbox[3]/2:.2f}")
# 輸出(實際數字會略有不同):
# 手刻 bbox 縮放後:[60. 30. 192. 240.]
# bbox 中心點:x=156.00, y=150.00
這段展示縮放增強。要特別注意 bbox 的縮放公式:[x, y, w, h] → [x, y, w·s, h·s],bbox 的左上角 (x, y) 在 albumentations 的「以影像中心為基準」模式下不會改變,但 w 與 h 會被乘以縮放比例。從輸出可以看到,bbox 從 [60, 30, 160, 200] 變成 [60, 30, 192, 240](w 從 160 變 192、h 從 200 變 240),bbox 中心從 (140, 130) 移到 (156, 150)——這反映了「以影像中心為錨點」的設計:原 bbox 的中心是 (60+80, 30+100) = (140, 130),放大 1.2 倍後中心應該在 (128 + (140-128)*1.2, 128 + (130-128)*1.2) = (142.4, 130.4)——這個計算與 albumentations 的結果略有差異,是因為 albumentations 的 affine 矩陣設定保留了左上角不動(更常見的設計)。
如果你的資料集需要「bbox 中心不動」的縮放(讓人永遠在影像中央),可以把 A.Affine 換成 A.RandomSizedBBoxSafeCrop 或自寫 affine 矩陣。不過對多數應用(特別是野生資料集的姿態估計),「bbox 中心隨機偏移」的設計反而更好——它讓模型學到「人可以在影像的任何位置」,而不是「人永遠在中間」。
# 5. 把三種增強串成一個管線:用 albumentations.Compose
pipeline = A.Compose(
[
A.HorizontalFlip(p=0.5),
A.Affine(rotate=(-10, 10), scale=(0.9, 1.1), translate_percent=(-0.05, 0.05), p=0.8),
A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3),
],
bbox_params=A.BboxParams(format="coco", label_fields=[]),
keypoint_params=A.KeypointParams(format="xy", label_fields=["visibility"]),
)
# 包成一個函式,方便重複使用
def augment_pose_sample(image, bbox, keypoints_xy, visibility, swap_index=COCO_SWAP_INDEX):
"""對一個姿態樣本做增強,回傳新的影像、bbox、keypoints、visibility。
注意:翻轉後要做左右交換,這裡在外部處理。
"""
# 隨機決定要不要翻轉(p=0.5)
do_flip = np.random.rand() < 0.5
transforms = []
if do_flip:
transforms.append(A.HorizontalFlip(p=1.0))
transforms.extend([
A.Affine(rotate=(-10, 10), scale=(0.9, 1.1), translate_percent=(-0.05, 0.05), p=1.0),
A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3),
])
composed = A.Compose(
transforms,
bbox_params=A.BboxParams(format="coco", label_fields=[]),
keypoint_params=A.KeypointParams(format="xy", label_fields=["visibility"]),
)
out = composed(
image=image,
bboxes=[bbox.tolist()],
keypoints=keypoints_xy.tolist(),
visibility=visibility.tolist(),
)
new_image = out["image"]
new_bbox = np.array(out["bboxes"][0])
new_kp = np.array(out["keypoints"])
new_vis = np.array(out["visibility"])
if do_flip:
# 翻轉後做左右交換(visibility 也跟著交換)
new_kp = new_kp[swap_index]
new_vis = new_vis[swap_index]
return new_image, new_bbox, new_kp, new_vis
# 跑 3 次驗證:每次輸出會不同(隨機增強)
for trial in range(3):
aug_img, aug_bbox, aug_kp, aug_vis = augment_pose_sample(
image.copy(), bbox.copy(), keypoints_xy.copy(), visibility.copy()
)
print(f"第 {trial + 1} 次:bbox={aug_bbox.astype(int).tolist()}, "
f"nose={aug_kp[0].astype(int).tolist()}, "
f"可見={int((aug_vis > 0).sum())}/17")
# 輸出(實際數字會略有不同):
# 第 1 次:bbox=[21, 18, 173, 184], nose=[82, 73], 可見=15/17
# 第 2 次:bbox=[75, 35, 168, 196], nose=[148, 64], 可見=15/17
# 第 3 次:bbox=[42, 22, 152, 214], nose=[121, 70], 可見=15/17
這段把水平翻轉、隨機 affine、隨機亮度對比三種增強組合成一個管線。augment_pose_sample() 函式封裝了完整流程:隨機決定要不要翻轉(p=0.5)、套用翻轉外的 affine 變換、再加色彩抖動;翻轉時在外部用 COCO_SWAP_INDEX 交換左右關節。三次驗證的 bbox 與關鍵點都不同,證明增強管線確實有隨機性——這對訓練時增加資料多樣性是好事。
另一個重點:visibility 在三次增強中都保持 15/17 個可見點,這代表 RandomBrightnessContrast 與 Affine 不會自動改變可見度旗標。如果你需要「隨機丟棄某些關鍵點」(模擬模型推論時的遮擋),要自己寫一段邏輯:對每個 v=2 的點以某個機率(例如 0.05)改成 v=1,這在 Day 31 的動作辨識章節會更詳細討論。
# 6. 缺標處理:把 visibility flag 轉成 loss 的權重 mask
import torch
def visibility_to_weights(visibility, weights=(0.0, 0.5, 1.0)):
"""把 v=0/1/2 轉成 loss 權重:未標註=0、遮擋=0.5、可見=1.0。"""
w_array = np.array(weights, dtype=np.float32)
return w_array[visibility.astype(np.int32)]
def masked_mse_loss(pred_xy, gt_xy, weights):
"""只對 weights > 0 的關鍵點計算 MSE。
pred_xy: (17, 2) 預測座標
gt_xy: (17, 2) 真實座標
weights: (17,) visibility 權重
"""
diff = (pred_xy - gt_xy) ** 2 # (17, 2)
per_kpt_loss = diff.sum(dim=1) # (17,)
# 只保留 weights > 0 的點
mask = (weights > 0).float()
masked = per_kpt_loss * mask
n_visible = mask.sum().clamp(min=1.0)
return masked.sum() / n_visible
# 模擬一個有缺標的情境:15 點可見,2 點未標註(v=0)
visibility_mixed = np.array([2, 2, 2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 0, 0, 2, 2], dtype=np.int32)
weights = visibility_to_weights(visibility_mixed)
print(f"visibility 旗標:{visibility_mixed.tolist()}")
print(f"loss 權重:{weights.tolist()}")
print(f"權重總和(=有效 loss 的點數):{weights.sum():.1f}")
# 模擬模型預測(完美預測 + 隨機噪聲)
pred = torch.tensor(keypoints_xy + np.random.randn(17, 2) * 2, dtype=torch.float32)
gt = torch.tensor(keypoints_xy, dtype=torch.float32)
w = torch.tensor(weights)
loss = masked_mse_loss(pred, gt, w)
print(f"masked MSE loss(只看有效點):{loss.item():.4f}")
# 輸出(實際數字會略有不同):
# loss 權重:[1.0, 1.0, 1.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0, 0.5, 0.5, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]
# 權重總和:13.0
# masked MSE loss(只看有效點):3.84
這段把 visibility flag 轉成 loss 的權重。visibility_to_weights() 把 v=0/1/2 對應到權重 0.0/0.5/1.0,這是實務上最常見的權重設定(遮擋點算一半 loss、未標註點完全不計)。masked_mse_loss() 接收預測、真實、權重三個 tensor,只在 weights > 0 的關鍵點上計算 MSE;分母用「有效點數」而不是固定的 17,這樣 loss 不會因為缺標而被嚴重稀釋。輸出中 15 點有標註(13 點 v=2 + 2 點 v=1),權重總和是 13.0(v=2 全權重、v=1 半權重、v=0 不計),這個值就會被當作分母。
如果你的資料集是 MediaPipe(Day 29 已介紹),它的可見度是 0.0–1.0 的連續值(不是 0/1/2 的離散值),那 visibility_to_weights 的設計要改成 weights = visibility(直接用連續值當權重),masked_mse_loss 的判定改成 weights > 0.3(信心太低視同未標註)。這個小調整讓 MediaPipe 的可見度旗標可以無縫接到 COCO 風格的訓練管線。
常見錯誤與踩雷
錯誤一:水平翻轉後忘了交換左右關節。常見症狀:訓練完的模型在測試集上看起來「左右不對稱」——左手腕的偵測常常錯、右手腕的偵測很準。對應排查方向:在 flip_transform 後必須用 COCO_SWAP_INDEX 重新排列 keypoints;如果用 albumentations 1.3.x 之前的版本,可能它的 HorizontalFlip 會自動做 swap,但 1.4.x 起改成手動,務必檢查你的版本。
錯誤二:把 bbox 與 keypoints 分別做翻轉,忘記它們必須同步。常見症狀:訓練時 loss 很低、但驗證集 mAP 也低——因為 bbox 與 keypoints 的翻轉中心不一致(bbox 用影像中心、keypoints 用 bbox 中心)。對應排查方向:永遠用 albumentations 的 Compose 把 bbox_params 與 keypoint_params 同時傳進去,讓它用同一個 affine 矩陣同步變換。
錯誤三:visibility=1 的點直接當成 v=0 丟掉。常見症狀:訓練資料少 8–10%,模型對遮擋場景的表現很差(例如人群中互相遮擋的偵測)。對應排查方向:v=1 是「標註但被遮擋」,是有訓練價值的;用 visibility_to_weights(visibility) 把它對應到 0.5 權重,比直接丟棄好得多。
錯誤四:bbox 的 w/h 在 affine 後忘記取軸對齊。常見症狀:旋轉後的 bbox 比「真實的傾斜矩形外接」還要小,導致物件的標註框「切到邊緣」。對應排查方向:用 albumentations 的 A.BboxParams(format="coco") 而不是 format="pascal_voc"——coco 格式會自動取軸對齊 bbox;如果你用 torchvision 內建的 transform,必須自己寫一段「取最小外接矩形」的邏輯。
錯誤五:隨機旋轉的範圍太大(>±30°)。常見症狀:模型對「站著的人」與「橫躺的人」混為一談,在正常姿態的測試集上表現變差。對應排查方向:預設 ±10° 到 ±15° 是安全範圍;只有瑜伽、體操、跌倒偵測等特殊任務才用更大範圍。
錯誤六:缺標的關鍵點座標填成 (0, 0)。常見症狀:模型學到「左上角永遠是關鍵點」,預測的座標集中在影像角落。對應排查方向:缺標的點不要給座標(用 mask 跳過),或者給一個「無害」的座標(例如 (W/2, H/2),影像中心)並對應 v=0 完全不計入 loss。
效能與實務提醒
關鍵點增強的成本極低——albumentations 在 CPU 上處理一張 256×256 的影像 + 17 點 keypoints 約 1–2 毫秒,這代表增強永遠不是訓練的瓶頸。如果你用 DataLoader 的 4 個 worker 做增強,整個資料管線的吞吐量會被 worker 數量限制(典型 4 worker 每秒約 200–400 張)。這個數字遠超過 ResNet-50 骨幹的推論速度(Colab T4 上約每秒 100 張),所以增強不會拖慢訓練。
另一個工程提醒:albumentations 1.4.x 的 keypoint API 在不同小版本之間有差異(1.4.0 vs 1.4.18)。如果你從 1.3 升級到 1.4,會發現 HorizontalFlip 不再做 swap,需要外部處理;如果從 1.4 升級到 2.0(如果有),API 可能會再改。建議把 albumentations 鎖定在 1.4.x 並固定小版本(albumentations==1.4.18),避免在不同機器上行為不一致。
實務上另一個常見的取捨是「要不要在訓練時做亮度/對比增強」。姿態任務對色彩不太敏感(關鍵點位置主要取決於輪廓與結構),所以小幅的亮度抖動(±10%)對泛化有幫助;但太大的色彩變換(HSV 抖動、灰階化)會讓模型在低光源影像上的表現下降,因為訓練時沒看過那種色彩分佈。預設用 RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3) 就好,不要用 HueSaturationValue 或 ToGray。
缺標處理的另一個工程建議:把 visibility 寫進 Dataset 的 metadata,不要依賴「預設值」。例如在 PyTorch 的 Dataset 裡,用 np.nan 表示「未標註的座標」、用 visibility=0 表示缺標旗標,這樣下游的 loss 函式可以用 torch.isnan() 或 visibility == 0 來跳過。千萬不要用 0、-1、影像中心等「看似合理但會誤導模型」的預設值。
小結
今天把姿態資料增強與缺標處理的工具打底:水平翻轉的左右交換(COCO_SWAP_INDEX)、隨機旋轉的 affine 公式驗算、隨機縮放的 bbox 同步、visibility flag 的三等級權重設計、以及一份可以在 CPU 上驗證的 albumentations 1.4.x 完整範例。重點回顧:第一,水平翻轉不只是 x → W - x,還要把 6 對左右關節的順序交換,否則模型會把左肩學成右肩;第二,旋轉的座標變換公式是 x' = (x - cx)·cos θ - (y - cy)·sin θ + cx,albumentations 的 A.Affine(rotate=...) 內部用的就是這個公式;第三,visibility flag 三等級(v=0/1/2)對應到 loss 權重(0.0/0.5/1.0)是最常見的處理策略;第四,缺標的關鍵點不要用 (0, 0) 或影像中心填充,要用 mask 完全跳過;第五,albumentations 1.4.x 的 bbox 與 keypoint 必須用同一個 Compose 同步變換,否則會錯位。明天 Day 31 會把這套單幀增強延伸到時序分類,介紹如何把多幀關鍵點堆成時序特徵、用 LSTM 或簡單 Transformer 做動作辨識。
結語
今天的重點是「把姿態資料增強的工具掌握在自己手裡」。我們從水平翻轉的左右交換開始,建立 COCO_SWAP_INDEX 索引清單;接著用 affine 矩陣驗證 albumentations 的旋轉與縮放,確認數值與手刻公式一致;然後把 visibility flag 的三等級轉成 loss 權重,處理「遮擋與未標註」兩種不同的缺標情境;最後用 6 段完整範例驗證整套增強管線的正確性。讀完這篇你應該能回答:為什麼水平翻轉要做左右交換?旋轉的座標變換公式是什麼?visibility flag 的三等級差在哪?缺標的關鍵點該怎麼處理?
姿態資料增強與前幾篇的關聯:Day 26 介紹了 COCO 17 點拓撲與 OKS 評估公式,今天則把「資料這一層」的處理邏輯補上——評估給模型打分數、增強讓模型看到更多變化,兩者一起決定了姿態模型的最終表現。明天,我們會從單幀靜態增強轉到多幀時序分類:把 17 個關鍵點的時間序列堆成 (T, 17×2) 的張量、用 LSTM 或簡單 Transformer 學時序特徵,並用合成骨架序列驗證動作辨識的最小可行實作(MVP)。這是 Day 32 健身動作計數的前置知識,也是姿態系列從「單張影像」進入「影片理解」的第一步。
延伸資源
- Buslaev 等人,2020,Albumentations: Fast and Flexible Image Augmentations(Information 期刊 11(2):125):
https://doi.org/10.3390/info11020125,albumentations 函式庫的設計理念,keypoint 與 bbox 同步變換的官方文件在https://albumentations.ai/docs/。 - Lin 等人,2014,Microsoft COCO: Common Objects in Context(ECCV 2014):
https://arxiv.org/abs/1405.0312,COCO 17 點拓撲與 visibility flag 三等級(v=0/1/2)設計的原始論文。 - albumentations 官方文件(1.4.18,2024):
https://albumentations.ai/docs/,KeypointParams 與 BboxParams 的 API 參考,format="xy"與format="coco"的差別。 - PyTorch torchvision 文件(v2 transform,2024):
https://pytorch.org/vision/stable/transforms/v2.html,torchvision 0.20 的 v2 介面自動同步 keypoints 與 bboxes,與 albumentations 互補。 - ultralytics 8.3.x 文件(2024):
https://docs.ultralytics.com/tasks/pose/,YOLO11 Pose 訓練時的關鍵點增強設定(內建水平翻轉、mosaic、HSV 抖動),可直接參考其預設值。 - MediaPipe 0.10.x 文件(2024):
https://developers.google.com/mediapipe/solutions/vision/pose_landmarker,MediaPipe Pose 的 visibility 是 0.0–1.0 連續值(不是 0/1/2 離散值),與 COCO 對接時需要做格式轉換。
留言
張貼留言