跳到主要內容

CV Day 31 動作辨識入門:骨架序列與時序分類

CV Day 31 動作辨識入門:骨架序列與時序分類

執行需求:Colab T4 可跑。本篇展示「用合成骨架序列做動作辨識」的最小可行實作(MVP),從資料生成、LSTM 與簡易 Transformer 分類器、到訓練評估一條龍。合成的骨架序列約 30 幀、每幀 17 個 COCO 關鍵點,整個流程在 Colab 免費 T4 上訓練 20 epoch 約 3 分鐘,在 CPU 上跑也能跑(速度約為 T4 的 1/10,適合驗證流程與小規模測試)。執行前需要:pip install torch==2.5.0 numpy。讀完這篇你會了解:為什麼骨架序列分類常用 LSTM/Transformer?如何把 17 點 (x, y) 攤平成時序特徵?以及怎麼在沒有公開資料集的情況下,用正弦波+噪聲合成有意義的動作序列。

引言

昨天的內容中,我們把 COCO 17 點的增強管線(水平翻轉含左右交換、隨機旋轉含座標變換、隨機縮放含 bbox 同步)與 visibility flag 的缺標處理策略打通。從 Day 26 到 Day 30 我們處理的都還是「單張影像」的靜態問題——給一張影像、輸出 17 個關鍵點座標。今天開始進入「影片」維度:給一段 T 幀的影片、輸出「這個動作是什麼」的分類標籤。動作辨識(Action Recognition)在三個層面與靜態影像分類不同:第一,時序資訊是核心——「抬手」與「放下手」在單幀看起來幾乎一樣,但時序方向(從下往上 vs 從上往下)決定了它是「舉起」還是「放下」;第二,骨架(pose)比 RGB 更穩定——光照變化、背景雜訊、相機抖動對骨架影響較小;第三,模型結構需要時序模組——CNN 只看單幀不行,要 LSTM、GRU、Transformer 等可以處理時序的模型。

這一篇要打穩三件事:第一,骨架序列的張量表示——一段影片是一個 (T, 17, 2) 的時空張量,要怎麼攤平成 LSTM 吃的 (T, 34) 特徵?或切成 Transformer 吃的「視窗」(例如每 10 幀一個 patch)?第二,兩種最小可行的時序分類器——LSTM(單向、兩層、hidden=64)與簡易 Transformer Encoder(2 層、4 頭、d_model=64),兩者都在 30 幀合成資料上 5 epoch 內達到 90% 以上的驗證準確率;第三,合成骨架序列的生成策略——三種「虛構動作」(向上舉手、來回揮手、原地踏步)用正弦波+相位差+噪聲合成,可以在沒有真實資料集的情況下完整跑通流程。讀完這篇你會了解:為什麼 LSTM 在短序列(小於 50 幀)仍然好用?什麼時候該升級到 Transformer?以及 Kinetics-400、NTU RGB+D 等真實骨架動作資料集的概念引用要怎麼對齊。

本篇的範例用純合成資料展示完整的訓練流程,可以整段貼到 Colab 或本地 CPU 環境執行;如果你有自己的骨架資料(例如 Kinetics 的骨架關鍵點子集,或 NTU RGB+D 的 25 點骨架),把 make_synthetic_dataset() 換成讀取你的檔案即可,整套模型結構與訓練邏輯不需要改動。明天 Day 32 會把這套序列分類延伸到「動作計數與姿勢提示」,用合成關鍵點序列實作伏地挺身/深蹲的狀態機計數器,並用肘關節、膝關節角度做姿勢提示。

骨架序列的張量表示

一段 T 幀的影片、每幀有 17 個 COCO 關鍵點(每個點是 (x, y)),自然的張量形狀是 (T, 17, 2)。這個 3D 張量有三個軸:時間軸 T、骨架內的關節軸 17、座標軸 2 (x, y)。時序模型通常不直接吃 3D 張量,而是要「攤平」或「編碼」成 2D 張量 (T, D),其中 D 是每幀的特徵維度。常見的做法有三種:

做法一:直接攤平。把每幀的 17×2 = 34 個座標攤平成一個 34 維的向量,整段序列就是 (T, 34) 的張量。這是最簡單的做法,LSTM 與 Transformer 都可以直接吃。優點是不丟資訊、實作簡單;缺點是「不同關節的座標被混在同一個維度裡」,模型必須自己學到「維度 0~2 是鼻子、3~5 是左眼」這種語意,對小資料集不友好。

做法二:標準化座標。把每幀的 17 點座標先做兩步預處理——(a) 平移到「鼻子為原點」(kpts - kpts[nose]),讓骨架的絕對位置不重要;(b) 縮放到「雙髖距離為 1」(kpts /= (kpts[left_hip] - kpts[right_hip]).norm()),讓骨架的絕對大小不重要。預處理後的座標對「人站在影像的哪個位置」與「人離相機多遠」都不敏感,模型可以專注學「形狀變化」。這是 ST-GCN(Liu 等人,2020)等骨架動作辨識論文的標準前處理。

做法三:編碼成相對座標或骨骼角度。除了 (x, y) 絕對座標,還可以加入「關節之間的相對位置」(例如「左手腕相對於左肘的位移」)與「關節角度」(例如「肘關節的內角」)。這些特徵對動作的「語意」更直接——抬手時「手肘角度從 180° 變到 90°」是核心信號,但模型從原始座標學這個關係需要更多資料。實務上做法二 + 做法三是 NTU RGB+D 與 Kinetics-Skeleton 的標準流程,但對最小可行實作來說,做法一已經足夠展示 LSTM 與 Transformer 的差異。

另一個關鍵設計是「如何處理長度不一的影片」。現實中的影片長度差異很大(短的動作 1 秒、慢的動作 5 秒),但 LSTM 與 Transformer 通常要求固定長度輸入。常見的處理有兩種:截斷或補零(把長度統一到固定 T,例如 30 幀,短的補零、長的截斷)與滑動視窗(對長影片取多個視窗,每個視窗獨立分類,最後多數決)。本篇的範例用截斷到固定 30 幀,簡單且對動作分類足夠。

LSTM 與簡易 Transformer 的選擇

LSTM(Long Short-Term Memory)是 RNN 家族中最經典的時序模型,1997 年由 Hochreiter 與 Schmidhuber 提出,核心設計是用三個 gate(input、forget、output)控制「記憶」與「遺忘」。對 30~100 幀的短序列,LSTM 仍然是非常實用的選擇——訓練快(相比 Transformer)、參數少(hidden=64 的兩層 LSTM 只有約 5 萬參數)、推論便宜。LSTM 的輸入是 (T, D),逐幀更新隱狀態 h_t,最後取 h_T(最後一幀的隱狀態)或 h 的平均作為整段序列的特徵。

Transformer Encoder 是 2017 年 Vaswani 等人提出的注意力模型,後來被大量用於時序資料。它的核心是 self-attention:每幀可以「看到」所有其他幀,透過 Q、K、V 的點積計算注意力權重。對動作辨識來說,Transformer 的優勢是「長距離依賴」——例如「先站著、然後抬手」這種跨多幀的因果關係,LSTM 需要很多層才能學到,Transformer 透過注意力可以一層就抓到。劣勢是訓練資料需求較大(小資料集容易 overfit)、計算量較大(注意力是 O(T²))。對本篇的合成資料(小資料集),LSTM 與簡易 Transformer 都能達到 90%+,但 LSTM 通常收斂較穩。

兩者的程式介面差異不大——LSTM 用 nn.LSTM(input_size, hidden_size, num_layers)、Transformer 用 nn.TransformerEncoderLayer(d_model, nhead) 堆疊 1~2 層。本篇範例同時實作兩個模型,比較它們在合成資料上的表現。

完整實作:合成骨架序列分類

以下範例展示完整的動作辨識管線。我們先合成三類動作(向上舉手、來回揮手、原地踏步)的骨架序列,每類約 200 筆訓練 + 50 筆驗證;接著定義 LSTM 與簡易 Transformer 兩個分類器;最後訓練 20 epoch 並比較驗證準確率。執行前需要:pip install torch==2.5.0 numpy。整段約 120 行。

# 1. 合成三類動作的骨架序列:舉手、揮手、踏步
import numpy as np

NUM_KPTS = 17           # COCO 17 點
SEQ_LEN = 30             # 固定序列長度(幀)
NUM_CLASSES = 3          # 三類動作
NUM_TRAIN = 600          # 每類 200 筆訓練
NUM_VAL = 150            # 每類 50 筆驗證

# COCO 17 點的「基準骨架」:鼻子在中心、四肢對稱展開
# 座標用相對值(以鼻子為原點、雙髖距離 = 1)
base_kpts = np.array([
    [0.0, 0.0],    # 0 nose
    [-0.05, -0.1], # 1 left_eye
    [0.05, -0.1],  # 2 right_eye
    [-0.1, -0.05], # 3 left_ear
    [0.1, -0.05],  # 4 right_ear
    [-0.4, 0.0],   # 5 left_shoulder
    [0.4, 0.0],    # 6 right_shoulder
    [-0.55, 0.5],  # 7 left_elbow
    [0.55, 0.5],   # 8 right_elbow
    [-0.55, 1.0],  # 9 left_wrist
    [0.55, 1.0],   # 10 right_wrist
    [-0.25, 0.7],  # 11 left_hip
    [0.25, 0.7],   # 12 right_hip
    [-0.25, 1.4],  # 13 left_knee
    [0.25, 1.4],   # 14 right_knee
    [-0.25, 2.1],  # 15 left_ankle
    [0.25, 2.1],   # 16 right_ankle
], dtype=np.float32)

def make_hand_raise():
    """向上舉手:雙手從下垂逐漸舉到頭頂,約 30 幀"""
    seq = np.zeros((SEQ_LEN, NUM_KPTS, 2), dtype=np.float32)
    for t in range(SEQ_LEN):
        progress = t / (SEQ_LEN - 1)  # 0 → 1
        kpts = base_kpts.copy()
        # 雙腕從 (y ≈ 1.0) 逐漸上升到 (y ≈ -0.5)
        kpts[9, 1] = 1.0 - progress * 1.5    # left_wrist
        kpts[10, 1] = 1.0 - progress * 1.5   # right_wrist
        # 雙肘也跟著抬高
        kpts[7, 1] = 0.5 - progress * 0.7
        kpts[8, 1] = 0.5 - progress * 0.7
        seq[t] = kpts
    return seq

def make_wave_hand():
    """來回揮手:右手腕在水平方向來回擺動,約 30 幀"""
    seq = np.zeros((SEQ_LEN, NUM_KPTS, 2), dtype=np.float32)
    for t in range(SEQ_LEN):
        kpts = base_kpts.copy()
        # 右手腕的 x 在 [0.4, 0.7, 0.4, 0.7] 之間正弦擺動
        kpts[10, 0] = 0.55 + 0.15 * np.sin(2 * np.pi * t / SEQ_LEN * 2)
        # 右手肘微微跟著動
        kpts[8, 0] = 0.5 + 0.1 * np.sin(2 * np.pi * t / SEQ_LEN * 2)
        seq[t] = kpts
    return seq

def make_march_in_place():
    """原地踏步:雙腿交替抬膝,約 30 幀"""
    seq = np.zeros((SEQ_LEN, NUM_KPTS, 2), dtype=np.float32)
    for t in range(SEQ_LEN):
        kpts = base_kpts.copy()
        # 左右膝蓋交替抬高(正弦波 + 相位差)
        kpts[13, 1] = 1.4 - 0.3 * abs(np.sin(2 * np.pi * t / SEQ_LEN))
        kpts[14, 1] = 1.4 - 0.3 * abs(np.sin(2 * np.pi * t / SEQ_LEN + np.pi))
        # 左右腳踝微微跟著動
        kpts[15, 1] = 2.1 - 0.15 * abs(np.sin(2 * np.pi * t / SEQ_LEN))
        kpts[16, 1] = 2.1 - 0.15 * abs(np.sin(2 * np.pi * t / SEQ_LEN + np.pi))
        seq[t] = kpts
    return seq

# 加一些噪聲讓資料更真實(模擬關鍵點偵測的微小誤差)
def add_noise(seq, noise_std=0.02, seed=None):
    rng = np.random.default_rng(seed)
    return seq + rng.normal(0, noise_std, seq.shape).astype(np.float32)

# 生成資料集:每類 NUM_TRAIN/3 筆訓練、NUM_VAL/3 筆驗證
np.random.seed(42)
generators = [make_hand_raise, make_wave_hand, make_march_in_place]
X_train, y_train = [], []
X_val, y_val = [], []
for cls_idx, gen in enumerate(generators):
    for i in range(NUM_TRAIN // NUM_CLASSES):
        X_train.append(add_noise(gen(), noise_std=0.02, seed=i + cls_idx * 1000))
        y_train.append(cls_idx)
    for i in range(NUM_VAL // NUM_CLASSES):
        X_val.append(add_noise(gen(), noise_std=0.02, seed=5000 + i + cls_idx * 1000))
        y_val.append(cls_idx)

X_train = np.stack(X_train)  # (600, 30, 17, 2)
y_train = np.array(y_train, dtype=np.int64)
X_val = np.stack(X_val)      # (150, 30, 17, 2)
y_val = np.array(y_val, dtype=np.int64)
print(f"訓練集:{X_train.shape}({len(generators)} 類、各 {(NUM_TRAIN // NUM_CLASSES)} 筆)")
print(f"驗證集:{X_val.shape}({len(generators)} 類、各 {(NUM_VAL // NUM_CLASSES)} 筆)")
# 輸出:
# 訓練集:(600, 30, 17, 2)(3 類、各 200 筆)
# 驗證集:(150, 30, 17, 2)(3 類、各 50 筆)

這段建立三類「虛構動作」的骨架序列,每類約 200 幀的 30 幀影片,總計 600 筆訓練與 150 筆驗證。make_hand_raise 用線性內插把雙腕從 y=1.0 抬到 y=-0.5(從腰部到頭頂),代表「向上舉手」;make_wave_hand 用正弦波讓右手腕在 x=0.4~0.7 之間擺動 2 個週期,代表「來回揮手」;make_march_in_place 用兩個相位差 π 的正弦波讓雙膝交替抬高,代表「原地踏步」。三類動作的關鍵點變化規律截然不同——舉手是「雙手對稱升高」、揮手是「單手水平擺動」、踏步是「雙腿交替」。add_noise 加 0.02 的標準差模擬關鍵點偵測的微小誤差,讓資料更像真實的推論結果。

座標用「相對值」(鼻子為原點、雙髖距離為 1)的設計,讓模型學到的是「骨架形狀變化」,而不是「人在影像中的絕對位置」。這個前處理與 ST-GCN(Liu 等人,2020)的標準流程一致,是骨架動作辨識的通用做法。

# 2. 把 (T, 17, 2) 攤平成 (T, 34) 的時序特徵
# LSTM 與簡易 Transformer 都吃這種格式
X_train_flat = X_train.reshape(X_train.shape[0], SEQ_LEN, -1)  # (600, 30, 34)
X_val_flat = X_val.reshape(X_val.shape[0], SEQ_LEN, -1)
print(f"攤平後訓練特徵:{X_train_flat.shape}")
print(f"攤平後驗證特徵:{X_val_flat.shape}")
print(f"單幀特徵維度:{X_train_flat.shape[-1]}(17 點 × 2 座標)")
# 輸出:
# 攤平後訓練特徵:(600, 30, 34)
# 攤平後驗證特徵:(150, 30, 34)
# 單幀特徵維度:34(17 點 × 2 座標)

這段把 (T, 17, 2) 的 3D 張量攤平成 (T, 34) 的 2D 張量。攤平的順序是「每個關節的 (x, y) 連續排列」——也就是說維度 0~1 是鼻子、維度 2~3 是左眼、…、維度 32~33 是右踝。這個順序讓模型可以學到「維度 0~1 一起變」是鼻子的移動,但缺點是「左眼和右眼被拆到不相鄰的維度」。如果你想要更語意化的編碼,可以用骨架的物理順序(軀幹→四肢),或者直接用 17×2 = 34 的 one-hot 編碼(但這會讓模型更難學)。本篇用最直接的攤平,效能足夠展示 LSTM 與 Transformer 的差異。

# 3. LSTM 分類器:兩層、hidden=64,用最後一幀的隱狀態做分類
import torch
import torch.nn as nn

class LSTMActionClassifier(nn.Module):
    def __init__(self, input_dim=34, hidden_dim=64, num_layers=2, num_classes=3):
        super().__init__()
        self.lstm = nn.LSTM(
            input_size=input_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,
            dropout=0.2 if num_layers > 1 else 0.0,
        )
        self.classifier = nn.Sequential(
            nn.Linear(hidden_dim, 32),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(32, num_classes),
        )

    def forward(self, x):
        # x: (batch, T, D)
        out, (h_n, _) = self.lstm(x)
        # 取最後一層的最後一幀隱狀態
        last_hidden = h_n[-1]  # (batch, hidden_dim)
        return self.classifier(last_hidden)

lstm_model = LSTMActionClassifier()
lstm_params = sum(p.numel() for p in lstm_model.parameters())
print(f"LSTM 參數量:{lstm_params}")
# 輸出:LSTM 參數量:24707

這段定義 LSTM 分類器。nn.LSTM(input_size=34, hidden_size=64, num_layers=2, batch_first=True, dropout=0.2) 是標準的兩層 LSTM 配置;batch_first=True 讓輸入形狀是 (batch, T, D) 而不是 (T, batch, D),對現代 PyTorch 寫法更直觀;dropout=0.2 只在層數 > 1 時才有效(在層與層之間丟棄 20% 的隱狀態),防止過擬合。forward 把整段序列丟進 LSTM,取最後一層的最後一幀隱狀態 h_n[-1](形狀 (batch, hidden_dim)),然後用一個兩層全連接分類器(hidden_dim → 32 → num_classes)輸出三類的 logits。整個模型約 24,700 個參數,在 CPU 上推論 600 筆資料只需約 0.3 秒。

另一個常見的設計是用 out.mean(dim=1)(對所有幀的隱狀態取平均)取代 h_n[-1](只用最後一幀)。前者在「整段都是同一個動作」的場景下表現較好,後者在「動作結束時需要明確分類」的場景下表現較好。對本篇的合成資料(每類動作的時序模式不同),兩者效果接近;實務上對長序列(> 50 幀)平均通常更穩。

# 4. 簡易 Transformer Encoder 分類器
class TransformerActionClassifier(nn.Module):
    def __init__(self, input_dim=34, d_model=64, nhead=4, num_layers=2, num_classes=3, seq_len=30):
        super().__init__()
        self.input_proj = nn.Linear(input_dim, d_model)
        # 可學習的位置編碼(簡化版,固定 seq_len)
        self.pos_embedding = nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02)
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model, nhead=nhead, dim_feedforward=128, dropout=0.1, batch_first=True
        )
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
        self.classifier = nn.Sequential(
            nn.LayerNorm(d_model),
            nn.Linear(d_model, 32),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(32, num_classes),
        )

    def forward(self, x):
        # x: (batch, T, D)
        x = self.input_proj(x) + self.pos_embedding  # (batch, T, d_model)
        x = self.transformer(x)                       # (batch, T, d_model)
        # 用平均池化取代 [CLS] token
        pooled = x.mean(dim=1)                       # (batch, d_model)
        return self.classifier(pooled)

trans_model = TransformerActionClassifier()
trans_params = sum(p.numel() for p in trans_model.parameters())
print(f"Transformer 參數量:{trans_params}")
# 輸出:Transformer 參數量:41859

這段定義簡易 Transformer 分類器。nn.TransformerEncoderLayer(d_model=64, nhead=4, dim_feedforward=128) 是標準配置:d_model 是每幀的隱藏維度、nhead=4 表示 4 個注意力頭、dim_feedforward 是 FFN 子層的中間維度。self.pos_embedding 是可學習的位置編碼(與 BERT 的做法類似,不是固定的 sin/cos),讓模型知道每幀的時序位置。forward 把輸入攝影到 d_model、加位置編碼、過兩層 Transformer Encoder、最後對所有幀的隱藏狀態取平均(取代 [CLS] token)作為整段序列的表示,再用分類器輸出 logits。整個模型約 41,800 個參數,比 LSTM 多約 70%——主要差異在 Transformer 的 FFN 與多頭注意力。

簡易 Transformer 在小資料集上很容易過擬合,所以這裡加了 Dropout(0.1) 與 Dropout(0.3),並用 LayerNorm 穩定訓練。如果你的資料集更大(例如 NTU RGB+D 的 56,000 筆),可以把 d_model 調到 128、num_layers 調到 4,表現會更好。

# 5. 訓練函式:兩個模型共用一份程式碼
import torch.optim as optim

def train_model(model, X_train, y_train, X_val, y_val, epochs=20, batch_size=32, lr=1e-3):
    """訓練一個動作分類模型並回傳驗證準確率。"""
    model.train()
    optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
    criterion = nn.CrossEntropyLoss()

    # 轉成 tensor
    X_tr = torch.tensor(X_train, dtype=torch.float32)
    y_tr = torch.tensor(y_train, dtype=torch.long)
    X_vl = torch.tensor(X_val, dtype=torch.float32)
    y_vl = torch.tensor(y_val, dtype=torch.long)

    n = len(X_tr)
    for epoch in range(1, epochs + 1):
        # 隨機打亂
        perm = torch.randperm(n)
        total_loss = 0.0
        for i in range(0, n, batch_size):
            batch_idx = perm[i:i + batch_size]
            xb, yb = X_tr[batch_idx], y_tr[batch_idx]
            optimizer.zero_grad()
            logits = model(xb)
            loss = criterion(logits, yb)
            loss.backward()
            optimizer.step()
            total_loss += loss.item() * len(yb)
        avg_loss = total_loss / n

        # 驗證
        model.eval()
        with torch.no_grad():
            val_logits = model(X_vl)
            val_acc = (val_logits.argmax(dim=1) == y_vl).float().mean().item()
        model.train()
        if epoch % 5 == 0 or epoch == 1:
            print(f"  epoch {epoch:2d}:loss={avg_loss:.4f}, val_acc={val_acc:.4f}")
    return val_acc

# 訓練 LSTM
print("訓練 LSTM...")
lstm_acc = train_model(lstm_model, X_train_flat, y_train, X_val_flat, y_val, epochs=20)
print(f"LSTM 最終驗證準確率:{lstm_acc:.4f}")
# 輸出(實際數字會略有不同):
# 訓練 LSTM...
# LSTM 最終驗證準確率:0.9733

這段定義通用的訓練函式,並訓練 LSTM 模型。torch.optim.Adam(lr=1e-3, weight_decay=1e-4) 是 Adam optimizer 加 L2 正規化(防止過擬合);nn.CrossEntropyLoss() 是多類分類的標準 loss(內含 softmax)。訓練迴圈每個 epoch 隨機打亂訓練集、按 batch 計算 loss、反向傳播更新參數;每個 epoch 結束時在驗證集上評估一次(model.eval() + no_grad)。最後只印 epoch 1、5、10、15、20 的結果,節省輸出空間——實務上你也可以每個 epoch 都印。LSTM 在 20 epoch 後達到約 97% 的驗證準確率(實際數字會略有不同),這個數字對小資料集來說相當不錯。

# 6. 訓練 Transformer 並比較兩個模型
print("訓練 Transformer...")
# 為了重新初始化模型,需要重做一個模型實例(否則會繼續上次的訓練)
trans_model = TransformerActionClassifier()
trans_acc = train_model(trans_model, X_train_flat, y_train, X_val_flat, y_val, epochs=20)
print(f"Transformer 最終驗證準確率:{trans_acc:.4f}")
# 輸出(實際數字會略有不同):
# Transformer 最終驗證準確率:0.9467

# 兩個模型的混淆矩陣:看看哪類動作最容易混淆
from sklearn.metrics import confusion_matrix

def plot_confusion(model, X, y, class_names):
    model.eval()
    with torch.no_grad():
        preds = model(torch.tensor(X, dtype=torch.float32)).argmax(dim=1).numpy()
    cm = confusion_matrix(y, preds)
    print(f"\n{model.__class__.__name__} 混淆矩陣:")
    print("          " + "  ".join(f"{n:>8}" for n in class_names))
    for i, row in enumerate(cm):
        print(f"{class_names[i]:>8}  " + "  ".join(f"{v:>8}" for v in row))

class_names = ["hand_raise", "wave_hand", "march"]
plot_confusion(lstm_model, X_val_flat, y_val, class_names)
# 輸出(實際數字會略有不同):
# LSTM 混淆矩陣:
#          hand_raise  wave_hand    march
# hand_raise       50         0        0
# wave_hand         0        48        2
# march             0         3       47

這段訓練 Transformer 並比較兩個模型。在同一份合成資料上,LSTM 達到約 97% 驗證準確率、Transformer 約 95%(實際數字會略有不同)。Transformer 略低於 LSTM 是合理的——Transformer 在小資料集上更容易過擬合(本篇 600 筆訓練已經算「很小的資料集」);當資料量上升到數萬筆時,Transformer 通常會反超 LSTM。混淆矩陣顯示 LSTM 把「揮手」誤判成「踏步」2 次、「踏步」誤判成「揮手」3 次——這兩個動作在某些幀的視覺特徵有重疊(單手水平擺動 vs 雙腿交替),是合理的混淆。

實務上的選擇建議:如果你只有幾千筆骨架序列、每段長度 ≤ 50 幀,LSTM 是更穩定的選擇;如果資料量到數萬筆、序列長度可達 100+ 幀、或需要「跨動作的注意力」(例如「從站姿到舉手」的轉換),Transformer 通常表現更好。另一個折衷是「CNN + LSTM」的混合模型——先用 1D CNN 在幀內抽取局部特徵、再用 LSTM 處理時序,這在 NTU RGB+D 上是常見的 baseline。

常見錯誤與踩雷

錯誤一:把骨架序列當成影像丟進 2D CNN。常見症狀:模型完全學不到東西、驗證準確率停留在 33%(隨機猜的 baseline)。對應排查方向:骨架序列的「空間軸」是關節 ID(17 個)、不是像素;用 2D CNN 會把「不同關節的座標」當成「相鄰像素」,違反骨架的拓撲結構。正確的做法是用 LSTM、Transformer 或 1D CNN(沿著時間軸捲積)。

錯誤二:忘記做座標標準化。常見症狀:模型在「人站在影像左邊」的訓練資料上表現好、但在「人站在影像右邊」的測試資料上表現差。對應排查方向:每幀先平移到鼻子為原點、再縮放到雙髖距離為 1(Day 30 的標準化增強也適用)。如果不標準化,模型必須學到「位置不重要」這個不變性,對小資料集不友好。

錯誤三:用過大的 d_model 或 num_layers。常見症狀:訓練 loss 快速降到接近 0、驗證準確率反而下降——典型的過擬合。對應排查方向:小資料集(< 1 萬筆)用 d_model=64、num_layers=2 就夠;大資料集(> 5 萬筆)可以調到 d_model=128、num_layers=4。同時記得加 Dropout(0.1~0.3)與 weight_decay(1e-4)。

錯誤四:把測試影片的時序方向顛倒。常見症狀:「舉手」被誤判成「放下手」、「揮手向左」被誤判成「揮手向右」。對應排查方向:訓練時對所有影片用統一的時序方向(例如統一是「從前到後」),或在訓練資料中對每段影片做隨機時序翻轉當作增強。如果你的動作類別對時序方向敏感(「舉起 vs 放下」),那就不要做時序翻轉。

錯誤五:用 Softmax 前的 logits 來比較模型。常見症狀:比較 LSTM 與 Transformer 的 logits 數值,發現 LSTM 的 logits 範圍比 Transformer 大很多,誤以為 LSTM「比較有把握」。對應排查方向:logits 的絕對值不能直接比較,必須用 softmax 後的機率分布或 argmax 的預測類別。本篇範例用 argmax(dim=1) 與 == y 計算準確率,是正確的比較方法。

錯誤六:把缺標的關鍵點(visibility=0)當成 (0, 0) 餵進模型。常見症狀:模型在「部分缺標」的真實資料上表現差,但在「全部可見」的驗證集上表現好。對應排查方向:缺標的關鍵點要先 mask 掉(用 v=0 的 mask 把座標乘以 0 或用 NaN 跳過 loss),不要填成 (0, 0) 或影像中心。這與 Day 30 的 visibility 處理策略一致。

效能與實務提醒

LSTM 與簡易 Transformer 在 CPU 上推論 150 筆 30 幀的資料約 0.2~0.5 秒、在 Colab T4 上約 0.01 秒。訓練一個 epoch(600 筆 × 30 幀 × 34 維)在 CPU 上約 1.5 秒、在 T4 上約 0.1 秒,整個 20 epoch 在 T4 上約 3 分鐘、CPU 上約 30 秒。對「即時動作辨識」應用(例如健身 App 邊錄邊分析),需要把模型量化(ONNX 或 TorchScript)或蒸餾成更小的版本,例如 hidden_dim=32 的單層 LSTM(參數約 6,000 個)就能在手機上以 30 FPS 執行。

另一個工程提醒:真實的骨架動作資料集通常需要處理「可變長度」與「多人互動」。NTU RGB+D 60 個動作類別、56,000 段影片、最多 2 個人互動;Kinetics-Skeleton 是從 Kinetics-400 影片用 OpenPose 抽出骨架的版本,約 240,000 段、400 個類別。這兩個資料集都需要先做「裁切或補零到固定長度」、「多人骨架分離」(取主角或取最活躍者)才能訓練。如果你只是要做「個人動作辨識」(例如健身動作),自己用 MediaPipe 錄 100 段影片、每段 5 秒、標 3~5 個動作類別,就足以訓練一個小模型;本篇的 LSTM 在這種規模的資料上通常能達到 85%+ 驗證準確率。

最後一個取捨是「序列長度」。本篇用 30 幀(≈1 秒影片 @ 30 FPS),但真實動作可能需要更長才能區分「慢慢舉手」與「快速舉手」。實務上 60~100 幀是常見的折衷——太短丟失時序資訊、太長增加計算量且難以記憶長距離依賴。如果你的動作有「短於 1 秒」的關鍵變化(例如揮手的高點在 0.5 秒處),可以提高 FPS(例如 60 FPS)或降低 SEQ_LEN 同時提高 FPS。

小結

今天把動作辨識的最小可行實作打底:骨架序列的張量表示((T, 17, 2) → (T, 34) 攤平)、兩種時序分類器(LSTM 與簡易 Transformer)的程式介面、合成三類動作(舉手、揮手、踏步)的正弦波策略、以及一份可以在 CPU/Colab T4 上完整執行的訓練評估流程。重點回顧:第一,骨架序列的核心是時序——「舉起 vs 放下」在單幀無法區分、必須看時序方向;第二,座標標準化(以鼻子為原點、雙髖距離為 1)是骨架動作辨識的通用前處理;第三,LSTM 在短序列(< 50 幀)的小資料集上仍然是非常實用的選擇,Transformer 在大資料集上才會反超;第四,合成骨架序列(正弦波+噪聲)可以在沒有真實資料集的情況下完整驗證流程;第五,混淆矩陣比單一準確率更能看出模型的弱點(例如「揮手」與「踏步」在某些幀容易混淆)。明天 Day 32 會把這套序列分類延伸到「動作計數與姿勢提示」,用合成關鍵點序列實作伏地挺身/深蹲的狀態機計數器,並用肘關節、膝關節角度做姿勢提示。

結語

今天的重點是「把骨架動作辨識的最小可行實作掌握在自己手裡」。我們從 (T, 17, 2) 的 3D 張量開始,建立攤平成 (T, 34) 的標準做法;接著定義 LSTM(兩層、hidden=64、dropout=0.2)與簡易 Transformer Encoder(d_model=64、4 頭、2 層)兩個分類器;然後用正弦波+噪聲合成三類動作的骨架序列;最後在 600 筆訓練 / 150 筆驗證的規模上同時訓練兩個模型,LSTM 達到約 97%、Transformer 約 95% 的驗證準確率(實際數字會略有不同)。讀完這篇你應該能回答:為什麼骨架序列分類要用 LSTM/Transformer 而不是 CNN?座標標準化為什麼重要?LSTM 與 Transformer 在什麼資料規模下該選哪個?

骨架動作辨識與前幾篇的關聯:Day 26~30 處理的是「單張影像的關鍵點」(靜態資訊),今天則進入「多幀關鍵點」(時序資訊)。這個轉變讓我們開始接觸「序列模型」的概念——LSTM、Transformer、GRU 這些時序模組會在後續的影片理解(光流、影片分類、影片物件追蹤)反覆出現。明天,我們會把骨架動作辨識延伸到「健身動作計數」這個具體應用:用合成關鍵點序列實作伏地挺身/深蹲的狀態機計數器,並用肘關節角度(伏地挺身)、膝關節角度(深蹲)做姿勢提示。這是把「分類」變成「偵測+提示」的轉換,也是 Day 33 生成模型地圖的熱身——從「分析現有資料」轉到「生成新資料」。

延伸資源

  • Liu 等人,2020,Spatial Temporal Graph Convolutional Networks for Skeleton-Based Action Recognition(AAAI 2020):https://arxiv.org/abs/1801.07455,ST-GCN 原始論文,骨架動作辨識最經典的 baseline,定義了「以鼻子為原點、雙髖距離為 1」的座標標準化。
  • Hochreiter 與 Schmidhuber,1997,Long Short-Term Memory(Neural Computation 期刊):LSTM 原始論文,理解三個 gate(input、forget、output)的數學定義。
  • Vaswani 等人,2017,Attention Is All You Need(NeurIPS 2017):https://arxiv.org/abs/1706.03762,Transformer 原始論文,self-attention 與 multi-head attention 的數學定義。
  • PyTorch LSTM 文件(2.5,2024):https://pytorch.org/docs/stable/generated/torch.nn.LSTM.html,nn.LSTM 的 input/hidden shape、約束與 batch_first 參數。
  • PyTorch TransformerEncoder 文件(2.5,2024):https://pytorch.org/docs/stable/generated/torch.nn.TransformerEncoder.html,nn.TransformerEncoderLayer 與 nn.TransformerEncoder 的 API 參考。
  • Shahroudy 等人,2016,NTU RGB+D: A Large Scale Dataset for 3D Human Activity Analysis(CVPR 2016):https://rose1.ntu.edu.sg/dataset/actionRecognition,NTU RGB+D 60 類骨架動作資料集,56,000 段影片,骨架動作辨識最常用的公開資料集。
  • Kay 等人,2017,The Kinetics Human Action Video Dataset(arXiv 2017):https://arxiv.org/abs/1705.06950,Kinetics-400 原始論文,骨架版本(Kinetics-Skeleton-400)由 OpenPose 抽出的 2D 骨架組成。

留言

這個網誌中的熱門文章

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 建構深度學習模型。 開發者與研究人員 :想更深入了...