據(jù)到實(shí)時(shí)推理:PyTorch實(shí)現(xiàn)0-9手勢(shì)識(shí)別完整教程)
簡(jiǎn)介一套完整的手勢(shì)識(shí)別神經(jīng)網(wǎng)絡(luò)實(shí)戰(zhàn)項(xiàng)目文件面向希望系統(tǒng)掌握?qǐng)D像分類與深度學(xué)習(xí)的Python開(kāi)發(fā)者。項(xiàng)目基于Jupyter Notebook環(huán)境覆蓋數(shù)據(jù)預(yù)處理、CNN模型構(gòu)建、訓(xùn)練評(píng)估、超參數(shù)調(diào)優(yōu)與部署準(zhǔn)備等關(guān)鍵環(huán)節(jié)并配有數(shù)據(jù)集圖像、模型結(jié)構(gòu)圖、.ipynb分步演示代碼與.py訓(xùn)練腳本便于逐步復(fù)現(xiàn)與二次開(kāi)發(fā)。壓縮包共32個(gè)文件其中包含20張png過(guò)程截圖、6個(gè)notebook、3個(gè)py腳本及配置文件等整體僅2.78MB輕量易得。已有281人學(xué)習(xí)下載適合計(jì)算機(jī)視覺(jué)初學(xué)者及需要完成手勢(shì)識(shí)別課程設(shè)計(jì)或比賽方案的學(xué)習(xí)者。通過(guò)對(duì)照源碼與筆記可快速理解卷積層、池化層、全連接層的運(yùn)作方式并掌握數(shù)據(jù)增強(qiáng)、損失函數(shù)與優(yōu)化器的實(shí)際應(yīng)用。1. 手勢(shì)識(shí)別為什么難一次 Jupyter Notebook 實(shí)驗(yàn)得到的教訓(xùn)在 Jupyter Notebook 里把手勢(shì)識(shí)別準(zhǔn)確率練到 98%可能只需要一個(gè)下午真正麻煩的是把同一個(gè)模型推到攝像頭面前它在真實(shí)畫(huà)面上往往會(huì)跌到 60% 左右。光照變了、手離鏡頭遠(yuǎn)了、背景出現(xiàn)了桌子紋理都會(huì)成為模型眼中的“新樣本”。這個(gè)反差點(diǎn)說(shuō)明手勢(shì)識(shí)別的核心不在選多大的網(wǎng)絡(luò)而在數(shù)據(jù)分布是否貼近真實(shí)使用場(chǎng)景。這篇文章以“0 到 9 手勢(shì)識(shí)別”為任務(wù)用 Python 和 Jupyter Notebook 走完從數(shù)據(jù)整理、CNN 搭建、訓(xùn)練評(píng)估到攝像頭實(shí)時(shí)推理的完整路徑。適合剛接觸 PyTorch、想復(fù)現(xiàn)一個(gè)完整 CV 小項(xiàng)目的讀者也適合需要快速給手勢(shì)交互做原型的工程師。2. 手勢(shì)識(shí)別數(shù)據(jù)集構(gòu)建從 0-9 手部圖片到可訓(xùn)練的 DataLoader2.1 用 ImageFolder 管理 10 類手勢(shì)目錄結(jié)構(gòu)約定標(biāo)簽手勢(shì)識(shí)別最簡(jiǎn)單的數(shù)據(jù)管理方式是按標(biāo)簽建目錄讓每個(gè)子目錄的名字直接當(dāng)類別。數(shù)字 0 到 9 各建一個(gè)文件夾里面放對(duì)應(yīng)手勢(shì)的圖片這個(gè)方式在多人協(xié)作時(shí)尤其省心誰(shuí)補(bǔ)數(shù)據(jù)就往對(duì)應(yīng)文件夾里丟圖不需要改代碼。gesture_data/ ├── train/ │ ├── 0/ │ │ ├── 001.jpg │ │ └── 002.jpg │ ├── 1/ │ ├── 2/ │ └── ... └── val/ ├── 0/ ├── 1/ └── ...把目錄交給torchvision.datasets.ImageFolder之后它會(huì)自動(dòng)把0、1這些文件夾名按字典序映射成整數(shù)標(biāo)簽。我用下面兩行代碼先確認(rèn)映射關(guān)系避免訓(xùn)練到一半才發(fā)現(xiàn)類別順序和想象中不一樣。from torchvision import datasets dataset datasets.ImageFolder(./gesture_data/train) print(dataset.classes) # 輸出 [0, 1, 2, ...] print(dataset.class_to_idx) # 輸出 {0: 0, 1: 1, ...}classes是原始目錄名class_to_idx是目錄名到標(biāo)簽的映射。手勢(shì)識(shí)別里經(jīng)常有人把“6”和“9”放在相鄰目錄看起來(lái)沒(méi)什么問(wèn)題但訓(xùn)練和驗(yàn)證時(shí)兩個(gè)文件夾的排序必須一致否則混淆矩陣?yán)锼袛?shù)字都會(huì)錯(cuò)位。目錄里圖片數(shù)量差距過(guò)大也要留意比如“0”有 800 張“5”只有 200 張模型會(huì)偏向樣本多的類別最好先統(tǒng)計(jì)一遍每個(gè)文件夾的圖片數(shù)量。2.2 關(guān)鍵預(yù)處理參數(shù)size、rotation、brightness 的選擇在 Jupyter Notebook 里做手勢(shì)識(shí)別預(yù)處理參數(shù)決定了模型能不能收斂。第一個(gè)參數(shù)是圖片尺寸64x64足夠區(qū)分手指張開(kāi)和握拳這種粗粒度差異又比224x224省下大量訓(xùn)練時(shí)間。第二個(gè)參數(shù)是旋轉(zhuǎn)手勢(shì)在鏡頭前不會(huì)完全端平RandomRotation(10)讓模型看到輕微轉(zhuǎn)動(dòng)的樣本。第三個(gè)容易被忽略的是亮度膚色在不同光照下差異很大ColorJitter(brightness0.2, contrast0.2)模擬這種變化。參數(shù)推薦值作用與風(fēng)險(xiǎn)Resize64統(tǒng)一輸入尺寸過(guò)小會(huì)丟失指尖細(xì)節(jié)RandomRotation10增加角度魯棒性過(guò)大會(huì)把手勢(shì)轉(zhuǎn)成另一個(gè)姿態(tài)ColorJitter亮度 0.2、對(duì)比度 0.2模擬光照變化過(guò)強(qiáng)會(huì)讓手和背景邊界模糊Normalizemean0.5, std0.5把像素歸一化到 -1 到 1加速收斂把訓(xùn)練集和驗(yàn)證集分開(kāi)寫(xiě)轉(zhuǎn)換器驗(yàn)證集不做旋轉(zhuǎn)和亮度增強(qiáng)否則驗(yàn)證結(jié)果會(huì)被增強(qiáng)噪聲干擾不能反映真實(shí)水平。from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform transforms.Compose([ transforms.Resize(64), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_transform transforms.Compose([ transforms.Resize(64), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) train_ds datasets.ImageFolder(./gesture_data/train, transformtrain_transform) val_ds datasets.ImageFolder(./gesture_data/val, transformval_transform) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers2)batch_size32在大部分機(jī)器上都能跑顯存小就降到 16shuffleTrue只給訓(xùn)練集用驗(yàn)證集保持順序方便后面逐批對(duì)比預(yù)測(cè)結(jié)果。num_workers2是常見(jiàn)的并行加載配置在 Jupyter Notebook 里如果遇到“Broken pipe”這類內(nèi)核報(bào)錯(cuò)先把它改成 0 排查。2.3 快速可視化確認(rèn)標(biāo)簽和圖像沒(méi)有錯(cuò)位模型訓(xùn)練前我會(huì)先在 notebook 里打印一個(gè) 2x5 的網(wǎng)格每列對(duì)應(yīng)一類手勢(shì)。這個(gè)步驟花不了兩分鐘卻能把“數(shù)據(jù)加載是否正確”“預(yù)處理是否讓圖像變形”兩個(gè)問(wèn)題一次性暴露出來(lái)。注意顯示時(shí)要反歸一化否則像素被縮放到 -1 到 1圖像會(huì)黑成一團(tuán)。import matplotlib.pyplot as plt fig, axes plt.subplots(2, 5, figsize(12, 5)) for i, ax in enumerate(axes.flat): image, label train_ds[i] img image.squeeze().numpy() * 0.5 0.5 # 反歸一化 ax.imshow(img, cmapgray) ax.set_title(train_ds.classes[label]) ax.axis(off)看到每個(gè)類別都被正確顯示后我再進(jìn)入建模環(huán)節(jié)。如果某張圖和標(biāo)題對(duì)不上不要急著訓(xùn)練先修數(shù)據(jù)源模型不會(huì)糾正錯(cuò)位標(biāo)注只會(huì)學(xué)著預(yù)測(cè)錯(cuò)誤答案。2.4 把預(yù)處理后的數(shù)據(jù)緩存為 npy 文件可選如果圖片數(shù)量多每次重新起 notebook 都跑一遍增廣會(huì)浪費(fèi)時(shí)間。常見(jiàn)做法是把預(yù)處理過(guò)的張量一次性存成npy文件下次直接用np.load加載。手勢(shì)識(shí)別這類小規(guī)模任務(wù)尤其適合一個(gè)數(shù)組裝所有訓(xùn)練樣本加載速度比一張張讀圖片快不少。import numpy as np X_list, y_list [], [] for img, label in train_ds: X_list.append(img.numpy()) y_list.append(label) np.save(gesture_train_X.npy, np.array(X_list)) np.save(gesture_train_y.npy, np.array(y_list))這個(gè)方案的代價(jià)是增廣被固定在了保存那一刻之后想調(diào)旋轉(zhuǎn)角度就得重新生成。所以我通常把它當(dāng)作快速驗(yàn)證的緩存而不是正式訓(xùn)練的唯一數(shù)據(jù)源。正式實(shí)驗(yàn)還是從ImageFolder讀取保證每次訓(xùn)練能重新采樣增廣。3. 搭建手勢(shì)識(shí)別 CNNPyTorch 模型結(jié)構(gòu)、訓(xùn)練循環(huán)與超參數(shù)設(shè)置3.1 為什么手勢(shì)識(shí)別網(wǎng)絡(luò)用 CNN 而不是全連接全連接網(wǎng)絡(luò)把圖片展開(kāi)成向量后每個(gè)像素位置都被當(dāng)作獨(dú)立特征手稍微平移幾個(gè)像素激活值就會(huì)劇烈變化。卷積神經(jīng)網(wǎng)絡(luò)用滑動(dòng)窗口提取局部模式手指邊緣、指縫這種特征無(wú)論出現(xiàn)在畫(huà)面左側(cè)還是右側(cè)都能被同一個(gè)卷積核捕捉到。手勢(shì)識(shí)別屬于典型的位置變化大、類別差異小的任務(wù)用 CNN 比全連接穩(wěn)得多。設(shè)計(jì)網(wǎng)絡(luò)時(shí)不追求層數(shù)深。手勢(shì) 0-9 的區(qū)分靠的是指尖數(shù)量和手指張開(kāi)角度這類特征在淺層卷積里就能提取到。我一般用兩層卷積加一層分類頭參數(shù)量小在 CPU 上也能快速訓(xùn)練迭代調(diào)試反而比大模型高效。3.2 一個(gè)可復(fù)用的輕量 CNN 結(jié)構(gòu)import torch.nn as nn class GestureNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2) ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(64, num_classes) ) def forward(self, x): return self.classifier(self.features(x))第一層卷積把單通道灰度圖從 1 維映射到 32 個(gè)特征圖kernel_size3配合padding1保持尺寸不變MaxPool2d(2)把寬高各減半。第二層從 32 維擴(kuò)到 64 維進(jìn)一步抽象局部特征。AdaptiveAvgPool2d((1, 1))把任意尺寸的特征圖壓縮成 1x1這樣換輸入分辨率時(shí)不需要改分類頭的輸入維度。整體結(jié)構(gòu)對(duì) 0 到 9 手勢(shì)識(shí)別足夠新增類別時(shí)只需改num_classes。3.3 訓(xùn)練循環(huán)損失函數(shù)、優(yōu)化器與學(xué)習(xí)率訓(xùn)練手勢(shì)識(shí)別模型的損失函數(shù)用CrossEntropyLoss內(nèi)部已經(jīng)包含 softmax不需要在網(wǎng)絡(luò)末尾再接。優(yōu)化器我用 AdamW它在 Adam 基礎(chǔ)上加了權(quán)重衰減的解耦訓(xùn)練更穩(wěn)定。學(xué)習(xí)率設(shè)置 1e-3如果損失震蕩明顯降到 3e-4 再跑。import torch import torch.optim as optim from torch import nn device cuda if torch.cuda.is_available() else cpu model GestureNet().to(device) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) for epoch in range(30): model.train() total_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) preds model(images) loss criterion(preds, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) print(fepoch {epoch 1}/30, loss: {avg_loss:.4f})每個(gè) batch 里先做前向拿到預(yù)測(cè)值然后計(jì)算損失、清空梯度、反向傳播、更新參數(shù)。打印平均損失是為了確認(rèn)整體趨勢(shì)在下降而不是只看某一個(gè) batch 的值。我在 Jupyter Notebook 里通常把epoch打印放在同一個(gè) cell 里用tqdm包進(jìn)度條跑起來(lái)比默認(rèn)輸出清晰。3.4 超參數(shù)表一組能直接用的建議值參數(shù)推薦值說(shuō)明輸入尺寸64x64在細(xì)節(jié)和速度之間取平衡batch_size32顯存不夠時(shí)改成 16學(xué)習(xí)率1e-3AdamW 常用起點(diǎn)權(quán)重衰減1e-4抑制過(guò)擬合最大 epoch30配 Early Stopping 更穩(wěn)數(shù)據(jù)增強(qiáng)旋轉(zhuǎn) 10 度、亮度 0.2超過(guò)這個(gè)幅度會(huì)破壞手勢(shì)語(yǔ)義這組參數(shù)在同人采集的數(shù)據(jù)集上容易達(dá)到 95% 以上。如果訓(xùn)練損失降得很慢優(yōu)先檢查歸一化是否丟失了手部邊緣信息而不是急著調(diào)大學(xué)習(xí)率。4. 評(píng)估與排錯(cuò)不要只盯準(zhǔn)確率要看混淆矩陣和跨人泛化4.1 混淆矩陣暴露的問(wèn)題哪兩個(gè)數(shù)字最容易被認(rèn)錯(cuò)準(zhǔn)確率只能給一個(gè)總體分?jǐn)?shù)無(wú)法告訴模型把哪個(gè)數(shù)字當(dāng)成了哪個(gè)。手勢(shì)識(shí)別里“3”和“8”容易混淆因?yàn)槭中投及瑥澢种傅膭?dòng)作結(jié)構(gòu)“6”和“0”也常搞混因?yàn)槎叨贾挥猩倭渴种干斐觥S没煜仃嚳梢粤⒖潭ㄎ诲e(cuò)誤集中點(diǎn)。from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay model.eval() y_true, y_pred [], [] with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1) y_pred.extend(preds.cpu().numpy()) y_true.extend(labels.numpy()) cm confusion_matrix(y_true, y_pred) disp ConfusionMatrixDisplay(cm, display_labelsval_ds.classes) disp.plot()argmax(dim1)取每個(gè)樣本概率最大的類別作為預(yù)測(cè)結(jié)果confusion_matrix的行是真實(shí)標(biāo)簽列是預(yù)測(cè)標(biāo)簽。矩陣對(duì)角線越亮代表識(shí)別越準(zhǔn)對(duì)角線上某個(gè)暗點(diǎn)對(duì)應(yīng)兩類之間的混淆。看到“3”和“8”互認(rèn)之后我會(huì)先檢查這兩類圖片是否在視覺(jué)上確實(shí)相似再?zèng)Q定是補(bǔ)數(shù)據(jù)還是增加一層卷積提取更細(xì)的指尖特征。4.2 按人劃分?jǐn)?shù)據(jù)避免手勢(shì)識(shí)別的數(shù)據(jù)泄漏一個(gè)很容易踩的坑是把同一個(gè)人的所有照片隨機(jī)分進(jìn)訓(xùn)練集和驗(yàn)證集。這種劃分下驗(yàn)證集表現(xiàn)極高模型一換到另一個(gè)人身上就失效因?yàn)槟P蛯W(xué)到的是這個(gè)人手掌大小、膚色和打光習(xí)慣而不是通用手勢(shì)。手勢(shì)識(shí)別的評(píng)估應(yīng)該按“人”劃分訓(xùn)練集和驗(yàn)證集來(lái)自不同的人。# 目錄結(jié)構(gòu)做成 train/person_01/0/xxx.jpg 的形式 dataset datasets.ImageFolder(./gesture_data/by_person) person_ids set() for path, _ in dataset.samples: person_ids.add(path.split(/)[-3]) # 提取人名 # 把人員列表拆成訓(xùn)練和驗(yàn)證兩部分或者從 CSV 里記錄每張圖片的拍攝者 ID按 ID 分組。這樣驗(yàn)證集準(zhǔn)確率普遍比隨機(jī)劃分低 5 到 10 個(gè)百分點(diǎn)但這個(gè)數(shù)字才是模型真實(shí)能力的底線。如果跨人驗(yàn)證掉點(diǎn)嚴(yán)重先檢查手勢(shì)標(biāo)注是否統(tǒng)一比如“4”的四指并攏程度不同人的動(dòng)作差異可能超出模型泛化范圍。4.3 訓(xùn)練曲線異常損失上升、震蕩、收斂太慢訓(xùn)練曲線能看出三類問(wèn)題。損失先降后升多半是學(xué)習(xí)率太大模型在最優(yōu)點(diǎn)附近來(lái)回震蕩甚至發(fā)散降到 1e-4 重跑。損失持續(xù)下降但驗(yàn)證準(zhǔn)確率原地踏步說(shuō)明數(shù)據(jù)增強(qiáng)尺度不夠手勢(shì)圖片之間的差異性不足加大RandomRotation角度或補(bǔ)充不同光照樣本。驗(yàn)證損失比訓(xùn)練損失低很多看起來(lái)奇怪但其實(shí)是常見(jiàn)假象通常來(lái)自數(shù)據(jù)泄漏需要回到按人劃分那一節(jié)檢查數(shù)據(jù)清洗方式。5. 實(shí)時(shí)手勢(shì)識(shí)別驗(yàn)證攝像頭推理、置信度平滑與關(guān)鍵點(diǎn)增強(qiáng)5.1 用 OpenCV 抓幀并轉(zhuǎn)成模型輸入模型訓(xùn)練完我把攝像頭打開(kāi)把每一幀縮放成和訓(xùn)練集一致的尺寸再喂給網(wǎng)絡(luò)。這里有一個(gè)關(guān)鍵點(diǎn)攝像頭畫(huà)面是彩色 RGB 圖像而訓(xùn)練時(shí)用的是灰度圖推理前必須做相同轉(zhuǎn)換否則模型看到的特征分布完全不同。import cv2 import torch import numpy as np from torchvision import transforms cap cv2.VideoCapture(0) inference_transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize(64), transforms.Grayscale(num_output_channels1), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) model.eval() with torch.no_grad(): while True: ret, frame cap.read() if not ret: break tensor inference_transform(frame).unsqueeze(0).to(device) output model(tensor) pred output.argmax(dim1).item() cv2.putText(frame, fgesture: {pred}, (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(gesture recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()unsqueeze(0)給單幀圖片增加一個(gè) batch 維度滿足模型輸入(1, 1, 64, 64)的形狀。預(yù)測(cè)出的數(shù)字直接打印在畫(huà)面上按q退出。這個(gè)過(guò)程會(huì)讓前 5 到 10 幀比較卡因?yàn)閿z像頭首次預(yù)熱的開(kāi)銷在那后續(xù)幀率會(huì)穩(wěn)定下來(lái)。5.2 預(yù)測(cè)平滑5 幀投票降低閃爍單幀預(yù)測(cè)最容易出現(xiàn)的問(wèn)題是手勢(shì)偶爾跳變比如“1”連續(xù)兩幀變成“7”。常見(jiàn)做法是維護(hù)一個(gè)長(zhǎng)度為 5 的預(yù)測(cè)緩沖區(qū)讓前幾幀一起投票取出現(xiàn)次數(shù)最多的結(jié)果作為最終輸出。這樣延遲增加 100 到 200 毫秒換來(lái)的是不會(huì)有鬼畜式跳動(dòng)。from collections import deque, Counter buffer deque(maxlen5) with torch.no_grad(): while True: ret, frame cap.read() if not ret: break tensor inference_transform(frame).unsqueeze(0).to(device) pred model(tensor).argmax(dim1).item() buffer.append(pred) stable_pred Counter(buffer).most_common(1)[0][0] cv2.putText(frame, fgesture: {stable_pred}, (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)deque(maxlen5)會(huì)在緩沖區(qū)滿時(shí)自動(dòng)丟棄最舊預(yù)測(cè)Counter(...).most_common(1)返回投票最高的結(jié)果。這個(gè)平滑策略對(duì)手勢(shì)切換的響應(yīng)速度是 5 幀幀率 30 fps 時(shí)切換約 0.17 秒體感足夠快。想更快響應(yīng)就把窗口改成 3想更穩(wěn)定就改成 10。5.3 關(guān)鍵點(diǎn)增強(qiáng)用 MediaPipe 補(bǔ)足指尖位置直接對(duì)圖像建模的優(yōu)點(diǎn)是省事缺點(diǎn)是對(duì)拍攝角度太敏感。如果攝像頭視角從正前方變成斜上方同一只手在圖像里的投影完全不同CNN 很容易失效。換用 MediaPipe 提取手部 21 個(gè)關(guān)鍵點(diǎn)坐標(biāo)再把這些關(guān)鍵點(diǎn)序列喂給一個(gè)小型全連接網(wǎng)絡(luò)對(duì)視角的魯棒性會(huì)好很多。這個(gè)分支適合對(duì)實(shí)時(shí)穩(wěn)定性要求更高的場(chǎng)景代價(jià)是依賴手部檢測(cè)手離開(kāi)畫(huà)面時(shí)沒(méi)有輸出。判斷用哪條路線就看你的攝像頭位置是否固定固定用它移動(dòng)用關(guān)鍵點(diǎn)。最后做一次可以落地的驗(yàn)證把攝像頭固定在距離桌面約 50 厘米的位置連續(xù)做 100 次隨機(jī)手勢(shì)記錄準(zhǔn)確率。這個(gè)數(shù)字如果比訓(xùn)練時(shí)的驗(yàn)證集低 10 個(gè)百分點(diǎn)以上回查inference_transform是否和train_transform保持一致尤其是灰度轉(zhuǎn)換這一步。本文還有配套的精品資源點(diǎn)擊獲取