跳到主要內容

CV Day 30 姿態資料與標註:增強與缺標處理

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 對接時需要做格式轉換。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...