習(xí)的人體姿態(tài)與動(dòng)作識(shí)別系統(tǒng)原理與實(shí)戰(zhàn))
簡(jiǎn)介面向計(jì)算機(jī)相關(guān)專業(yè)畢業(yè)設(shè)計(jì)的人體姿態(tài)與動(dòng)作識(shí)別項(xiàng)目提供基于CNN深度學(xué)習(xí)的完整Python實(shí)現(xiàn)與項(xiàng)目說(shuō)明文檔。這份代碼源自大四高分畢業(yè)設(shè)計(jì)評(píng)審均分達(dá)到96.5分所有程序均經(jīng)運(yùn)行驗(yàn)證非常適合正在完成畢業(yè)設(shè)計(jì)、課程設(shè)計(jì)或期末大作業(yè)的學(xué)生也適合對(duì)深度學(xué)習(xí)實(shí)戰(zhàn)感興趣的初學(xué)者進(jìn)階學(xué)習(xí)。壓縮包共6個(gè)文件以5個(gè)Python腳本為主另附1份Markdown項(xiàng)目說(shuō)明整體大小僅7KB結(jié)構(gòu)清晰且便于快速定位關(guān)鍵模塊。源碼模塊覆蓋動(dòng)作數(shù)據(jù)采集、模型訓(xùn)練、模型測(cè)試與實(shí)時(shí)姿態(tài)檢測(cè)等完整流程項(xiàng)目說(shuō)明對(duì)運(yùn)行方式與處理邏輯進(jìn)行梳理可幫助讀者理解CNN模型的構(gòu)建思路并能在此基礎(chǔ)上修改擴(kuò)展實(shí)現(xiàn)更多自定義功能。目前已有206人學(xué)習(xí)下載如遇運(yùn)行問(wèn)題可私聊獲取遠(yuǎn)程教學(xué)支持可有效降低上手難度輔助順利完成項(xiàng)目部署與答辯展示。1. 基于CNN深度學(xué)習(xí)識(shí)別人體姿態(tài)和動(dòng)作系統(tǒng)先弄清楚它到底解決了什么問(wèn)題很多人第一次拿到“基于CNN深度學(xué)習(xí)識(shí)別人體姿態(tài)和動(dòng)作系統(tǒng)python源碼項(xiàng)目說(shuō)明.zip”這種壓縮包第一反應(yīng)是趕緊解壓裝環(huán)境結(jié)果往往折騰一整天也沒(méi)跑出像樣的效果。這套系統(tǒng)真正要解決的不是“識(shí)別出一個(gè)人”而是兩件事一是把人體的關(guān)鍵點(diǎn)肩膀、肘腕、膝蓋從圖像里定位出來(lái)二是根據(jù)這些關(guān)鍵點(diǎn)的空間分布判斷當(dāng)前動(dòng)作比如走路、站立、彎腰。它服務(wù)于動(dòng)作分析、運(yùn)動(dòng)姿態(tài)評(píng)估、看護(hù)監(jiān)測(cè)這類場(chǎng)景。適合手里有圖像/視頻標(biāo)注數(shù)據(jù)、想在本地訓(xùn)練和部署的工程師也適合正在入門(mén)姿態(tài)估計(jì)的開(kāi)發(fā)者。下面從原理、源碼結(jié)構(gòu)、最小推理、訓(xùn)練到避坑一次性講透。2. CNN在姿態(tài)和動(dòng)作識(shí)別里的分工原理、任務(wù)拆解與源碼常見(jiàn)組織任何一個(gè)能跑的“姿態(tài)動(dòng)作”系統(tǒng)背后基本都是兩條流水線一條從圖像得到關(guān)鍵點(diǎn)一條從關(guān)鍵點(diǎn)得到動(dòng)作。CNN在這條鏈路里不是干一件事而是同時(shí)扮演定位器和分類器。你在源碼包里看到兩個(gè)模型文件不要覺(jué)得冗余把它們拆清楚后面調(diào)參才有方向。2.1 姿態(tài)估計(jì)和動(dòng)作識(shí)別不是一回事任務(wù)邊界決定模型邊界姿態(tài)估計(jì)的產(chǎn)物是“位置”動(dòng)作識(shí)別的產(chǎn)物是“類別”。位置是連續(xù)坐標(biāo)類別是離散標(biāo)簽。前者用回歸損失監(jiān)督后者用交叉熵監(jiān)督。如果硬把一個(gè)CNN骨干接到動(dòng)作分類輸出上網(wǎng)絡(luò)會(huì)傾向于記住“像站姿的紋理”而不是記住“肩膀和膝蓋的相對(duì)關(guān)系”。一旦換場(chǎng)景、換衣服泛化會(huì)斷崖式下跌。這是很多入門(mén)項(xiàng)目看起來(lái)準(zhǔn)確率高、實(shí)測(cè)不敢用的主要原因。所以常見(jiàn)的源碼包會(huì)采用兩段式結(jié)構(gòu)先訓(xùn)關(guān)鍵點(diǎn)網(wǎng)絡(luò)再訓(xùn)動(dòng)作網(wǎng)絡(luò)。拿到包后先確認(rèn)models目錄下有沒(méi)有兩個(gè)類如果沒(méi)有說(shuō)明作者大多是在關(guān)鍵點(diǎn)基礎(chǔ)上用規(guī)則判斷動(dòng)作這時(shí)候動(dòng)作部分基本不用訓(xùn)練。這個(gè)判斷直接影響你后面要不要補(bǔ)數(shù)據(jù)、要不要改最后那個(gè)全連接層。我一般會(huì)先畫(huà)一張數(shù)據(jù)流圖圖像輸入到姿態(tài)網(wǎng)絡(luò)得到17個(gè)關(guān)鍵點(diǎn)關(guān)鍵點(diǎn)歸一化后進(jìn)動(dòng)作網(wǎng)絡(luò)得到N個(gè)動(dòng)作類別的概率。這張圖畫(huà)完整個(gè)項(xiàng)目的執(zhí)行順序就清楚了。邊界也決定了損失函數(shù)和優(yōu)化策略。姿態(tài)網(wǎng)絡(luò)通常用關(guān)鍵點(diǎn)熱力圖上的均方誤差動(dòng)作網(wǎng)絡(luò)用交叉熵。訓(xùn)練時(shí)先固定姿態(tài)權(quán)重調(diào)動(dòng)作網(wǎng)絡(luò)姿態(tài)網(wǎng)絡(luò)收斂穩(wěn)定后再考慮聯(lián)合微調(diào)否則動(dòng)作分支的梯度會(huì)把姿態(tài)分支的共享骨干拉偏。很多源碼包直接把兩個(gè)網(wǎng)絡(luò)分開(kāi)訓(xùn)練原因就是聯(lián)合訓(xùn)練時(shí)超參太難調(diào)容易兩頭都顧不好。你在項(xiàng)目說(shuō)明里看到類似“先跑pose_train.py再跑action_train.py”的順序時(shí)按這個(gè)順序走就對(duì)了。2.2 從輸入圖像到關(guān)鍵點(diǎn)熱力圖CNN回歸的常見(jiàn)結(jié)構(gòu)關(guān)鍵點(diǎn)回歸最穩(wěn)妥的形式不是直接回歸坐標(biāo)而是預(yù)測(cè)熱力圖。網(wǎng)絡(luò)為每個(gè)關(guān)鍵點(diǎn)輸出一個(gè)二維概率圖峰值位置就是關(guān)節(jié)位置。這樣做的好處是空間信息被保留訓(xùn)練也更穩(wěn)。以常見(jiàn)分辨率192x256輸入為例經(jīng)過(guò)下采樣后骨干網(wǎng)絡(luò)特征圖的尺寸大概是48x64最后接一個(gè)上采樣頭輸出K個(gè)通道的熱力圖。我一般會(huì)先確認(rèn)這個(gè)頭的輸出通道數(shù)是否等于數(shù)據(jù)集關(guān)鍵點(diǎn)數(shù)比如COCO標(biāo)準(zhǔn)的關(guān)鍵點(diǎn)數(shù)是17。如果源碼給的最后一個(gè)卷積是17而你自己的標(biāo)注只有14個(gè)點(diǎn)最后一層必須改。import torch import torch.nn as nn class PoseHead(nn.Module): def __init__(self, in_channels, num_keypoints, heatmap_size(48, 64)): super().__init__() self.heatmap_size heatmap_size self.decoder nn.Sequential( nn.Conv2d(in_channels, 256, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(256, num_keypoints, 1) ) def forward(self, feature_map): heatmaps self.decoder(feature_map) return heatmaps這里的in_channels是骨干網(wǎng)絡(luò)輸出的特征通道數(shù)取決于你用的backbonenum_keypoints對(duì)應(yīng)關(guān)鍵點(diǎn)數(shù)量COCO格式是17自建數(shù)據(jù)集按實(shí)際標(biāo)注數(shù)改。heatmap_size決定后續(xù)argmax后的坐標(biāo)分辨率不能隨意設(shè)。如果設(shè)成96x128關(guān)鍵點(diǎn)定位理論上更細(xì)但顯存占用會(huì)明顯漲設(shè)成32x44速度上去但關(guān)節(jié)位置容易偏肉眼可見(jiàn)地點(diǎn)錯(cuò)。我一般的做法是先用48x64跑通再根據(jù)顯存余量和PCK指標(biāo)決定要不要調(diào)。熱力圖訓(xùn)練時(shí)會(huì)把每個(gè)關(guān)鍵點(diǎn)坐標(biāo)生成一個(gè)高斯峰值推理時(shí)取每個(gè)通道的最大值位置作為關(guān)節(jié)坐標(biāo)。這里有個(gè)很容易忽略的細(xì)節(jié)如果一個(gè)人太靠近畫(huà)面邊緣某些關(guān)鍵點(diǎn)的高斯峰會(huì)被截?cái)鄬?dǎo)致argmax結(jié)果偏移。很多源碼包允許關(guān)鍵點(diǎn)帶visible標(biāo)志當(dāng)目標(biāo)是不可見(jiàn)時(shí)把對(duì)應(yīng)熱力圖全部置零并且不參與loss計(jì)算。如果你訓(xùn)練的圖片里常有遮擋一定要保留這個(gè)標(biāo)志否則模型會(huì)強(qiáng)行給遮住的關(guān)節(jié)生成一個(gè)假峰值。2.3 從關(guān)鍵點(diǎn)序列到動(dòng)作標(biāo)簽單幀CNN方案怎么把動(dòng)作做出來(lái)動(dòng)作分類可以基于RGB視頻學(xué)習(xí)但這要求3D卷積和大規(guī)模視頻數(shù)據(jù)。常見(jiàn)源碼包為了降低門(mén)檻會(huì)把動(dòng)作識(shí)別放到關(guān)鍵點(diǎn)層面用1D CNN吃時(shí)間窗口。這里的操作是標(biāo)準(zhǔn)卷積只不過(guò)卷積核沿時(shí)間維度滑動(dòng)。選用1D CNN而不是循環(huán)網(wǎng)絡(luò)是考慮到源碼包的定位訓(xùn)練快、依賴少、部署容易。窗口通常取8到16幀太短會(huì)抖太長(zhǎng)會(huì)拖慢響應(yīng)。具體來(lái)說(shuō)窗口內(nèi)每一幀的關(guān)鍵點(diǎn)被歸一化成x、y、置信度三個(gè)值拼成一個(gè)行向量連續(xù)T幀疊起來(lái)就得到一個(gè)[T, K*3]的輸入序列。1D CNN在這個(gè)序列上做卷積輸出動(dòng)作類別概率。如果只是對(duì)單幀圖做分類也可以直接把一幀的關(guān)鍵點(diǎn)向量當(dāng)作T1的輸入但穩(wěn)定性差很多。下面是這類動(dòng)作網(wǎng)絡(luò)最常見(jiàn)的實(shí)現(xiàn)骨架class ActionNet(nn.Module): def __init__(self, num_keypoints17, num_classes8, window_size8): super().__init__() in_channels num_keypoints * 3 self.conv1 nn.Conv1d(in_channels, 128, kernel_size3, padding1) self.conv2 nn.Conv1d(128, 64, kernel_size3, padding1) self.pool nn.AdaptiveAvgPool1d(1) self.fc nn.Linear(64, num_classes) def forward(self, keypoints): # keypoints: [B, T, num_keypoints*3] 歸一化坐標(biāo) x keypoints.transpose(1, 2) # [B, in_channels, T] x torch.relu(self.conv1(x)) x torch.relu(self.conv2(x)) x self.pool(x).squeeze(-1) return self.fc(x)這里第一個(gè)Conv1d的kernel_size3表示只看相鄰三幀的上下文padding1保證序列長(zhǎng)度不變。如果幀率是30fps窗口8幀約270ms適合站立、走路、坐下這類過(guò)渡不快的動(dòng)作。做摔倒這類需要更長(zhǎng)上下文的動(dòng)作時(shí)窗口要調(diào)到16以上否則“倒地”過(guò)程還沒(méi)形成完整姿態(tài)模型已經(jīng)給出了錯(cuò)誤結(jié)論。num_classes要和你的動(dòng)作類別映射表嚴(yán)格一致多一個(gè)或少一個(gè)都會(huì)讓推理結(jié)果全部錯(cuò)位。2.4 拿到源碼包第一步先分清五類目錄別急著裝環(huán)境常見(jiàn)人體姿態(tài)動(dòng)作源碼包目錄結(jié)構(gòu)大同小異。解壓后我先不看代碼邏輯先掃一遍文件布局pose_action_system/ ├── README.md ├── requirements.txt ├── config/ │ └── pose_action.yaml ├── data/ │ ├── dataset.py │ └── transforms.py ├── models/ │ ├── pose_net.py │ └── action_net.py ├── tools/ │ ├── train.py │ ├── evaluate.py │ └── inference.py └── checkpoints/一般來(lái)說(shuō)config放所有超參數(shù)models放網(wǎng)絡(luò)結(jié)構(gòu)data放數(shù)據(jù)集讀取邏輯tools放訓(xùn)練評(píng)估推理入口checkpoints放權(quán)重。真正決定系統(tǒng)行為的是config文件而不是訓(xùn)練腳本。先讀它能看出輸入分辨率、熱力圖尺寸、關(guān)鍵點(diǎn)數(shù)量和動(dòng)作類別數(shù)。接著打開(kāi)requirements看依賴的大版本。如果這些和你本機(jī)環(huán)境差得遠(yuǎn)再?zèng)Q定要不要新建虛擬環(huán)境。不要看到requirements就無(wú)腦pip install更不要在全局base環(huán)境里裝一旦版本沖突后面會(huì)非常被動(dòng)。項(xiàng)目說(shuō)明文檔里如果有“先下載權(quán)重再運(yùn)行”的字樣應(yīng)該把權(quán)重下載和放置路徑當(dāng)作前置條件圈出來(lái)。沒(méi)有權(quán)重文件時(shí)最小推理也可能跑不起來(lái)這屬于正?,F(xiàn)象不是代碼壞了。3. 在本地跑通最小推理解壓、裝環(huán)境、用預(yù)訓(xùn)練參數(shù)出結(jié)果跑通最小推理是整個(gè)項(xiàng)目的及格線。這一章我按“解壓→環(huán)境→推理→沒(méi)有權(quán)重怎么辦”的順序?qū)憽O炔灰从?xùn)練先讓一個(gè)輸入圖像能流到輸出結(jié)果。3.1 解壓和核對(duì)項(xiàng)目說(shuō)明先把依賴清單讀對(duì)拿到zip先解壓到一個(gè)純英文路徑不要放在桌面那種含中文的短路徑里。解壓后第一件事不是裝環(huán)境而是看文件樹(shù)和項(xiàng)目說(shuō)明。用下邊命令找文件unzip pose_action_system.zip -d pose_action_system cd pose_action_system find . -maxdepth 2 -type f | sort如果項(xiàng)目說(shuō)明寫(xiě)得規(guī)范會(huì)有環(huán)境要求、權(quán)重下載位置和一行推理命令。先把這一行推理命令抄出來(lái)后面90%的問(wèn)題都從它展開(kāi)。requirements.txt通常是關(guān)鍵里面一般有numpy、opencv-python、torch、torchvision這類基礎(chǔ)依賴。注意看有沒(méi)有一個(gè)單獨(dú)的requirements-gpu.txt有的話用它而不是默認(rèn)那個(gè)因?yàn)槟J(rèn)可能只適合CPU。不要小看這一步很多人翻車(chē)在第一次pip install就版本沖突最后不得不回頭清理環(huán)境。3.2 用虛擬環(huán)境隔離依賴別把基礎(chǔ)環(huán)境弄臟我一般會(huì)為這類源碼單獨(dú)建一個(gè)虛擬環(huán)境哪怕事情看起來(lái)很小。原因是公開(kāi)源碼包的依賴版本常常和你的日常項(xiàng)目沖突比如一個(gè)項(xiàng)目需要torch1.8這套代碼需要torch2.x在base環(huán)境里一定會(huì)打架。用conda創(chuàng)建環(huán)境的命令如下conda create -n pose_action python3.9 -y conda activate pose_action pip install -r requirements.txt這里python版本選3.9是為了兼容大多數(shù)這種源碼包的torch安裝輪子如果你機(jī)器上的CUDA版本比較高可以選3.10但如果項(xiàng)目說(shuō)明明確寫(xiě)了python下限以說(shuō)明為準(zhǔn)。等pip裝完先跑一句python -c import torch; print(torch.__version__)確認(rèn)torch可用。如果import cv2報(bào)錯(cuò)通常是opencv-python依賴的libgl1缺失在Linux下用系統(tǒng)包管理器安裝libgl1即可。這一步的具體坑在第5章還會(huì)展開(kāi)。3.3 跑一次推理Demo從圖像到關(guān)鍵點(diǎn)和動(dòng)作標(biāo)簽的最小命令大多數(shù)這類工程都會(huì)有一個(gè)推理入口通常叫inference.py或demo.py。使用預(yù)訓(xùn)練權(quán)重時(shí)最小命令一般長(zhǎng)這樣python tools/inference.py --image data/sample.jpg \ --config config/pose_action.yaml \ --checkpoint checkpoints/pose_action.pth \ --output output/result.jpg--image傳測(cè)試圖像--config傳配置文件--checkpoint傳權(quán)重--output寫(xiě)結(jié)果圖。如果你拿到的是源碼加項(xiàng)目說(shuō)明而沒(méi)帶權(quán)重先看說(shuō)明里的權(quán)重獲取方式別直接怪代碼。下面這段代碼展示了一個(gè)簡(jiǎn)化版推理腳本內(nèi)部最常見(jiàn)的幾行方便你理解參數(shù)是怎么被消耗的import cv2 import torch from models.pose_net import PoseNet from utils.decode import decode_heatmaps def main(): cfg load_yaml(config/pose_action.yaml) num_k cfg[num_keypoints] model PoseNet(num_keypointsnum_k) ckpt torch.load(cfg[checkpoint], map_locationcpu) model.load_state_dict(ckpt[model_state_dict]) model.eval() img cv2.imread(data/sample.jpg) orig_h, orig_w img.shape[:2] input_w, input_h cfg[input_size] resized cv2.resize(img, (input_w, input_h)) # 轉(zhuǎn)張量、歸一化 tensor torch.from_numpy(resized.transpose(2, 0, 1)).float() / 255.0 tensor tensor.unsqueeze(0) with torch.no_grad(): heatmaps model(tensor) # [1, num_k, H, W] keypoints decode_heatmaps(heatmaps[0], scale_xorig_w / input_w, scale_yorig_h / input_h) print(keypoints.shape)這段邏輯說(shuō)明模型輸入是resize后的圖片輸出是歸一化坐標(biāo)decode_heatmaps里的scale_x和scale_y負(fù)責(zé)把網(wǎng)絡(luò)坐標(biāo)還原到原圖尺寸。很多開(kāi)源實(shí)現(xiàn)習(xí)慣直接把a(bǔ)rgmax除以熱力圖尺寸再乘輸入尺寸如果你看到的代碼沒(méi)有這組scale說(shuō)明它假設(shè)輸入圖片就是網(wǎng)絡(luò)分辨率推理真實(shí)視頻時(shí)會(huì)點(diǎn)偏。參數(shù)上PoseNet的num_keypoints必須以config為準(zhǔn)如果權(quán)重是17點(diǎn)的改成14會(huì)直接加載報(bào)錯(cuò)如果代碼里沒(méi)有decode_heatmaps函數(shù)就在utils目錄下找類似heatmap2coord的函數(shù)。3.4 沒(méi)有預(yù)訓(xùn)練權(quán)重時(shí)先用隨機(jī)權(quán)重驗(yàn)證通路這是很多新手最容易卡住的地方權(quán)重文件不存在模型沒(méi)法載入。我會(huì)建議不要先糾結(jié)精度先讓一條隨機(jī)權(quán)重的空模型跑通輸入輸出。也就是說(shuō)把推理腳本的load_state_dict臨時(shí)去掉或者生成一個(gè)隨機(jī)權(quán)重文件看heatmap和關(guān)鍵點(diǎn)坐標(biāo)能不能正常打印。只要能打印說(shuō)明數(shù)據(jù)流、張量shape、后處理都沒(méi)問(wèn)題。這一步通過(guò)后再考慮訓(xùn)練或找可遷移的公開(kāi)權(quán)重。不要迷信別人說(shuō)的“隨便一個(gè)預(yù)訓(xùn)練權(quán)重就能用”關(guān)鍵點(diǎn)網(wǎng)絡(luò)的第一階段必須用目標(biāo)數(shù)據(jù)集或鄰近數(shù)據(jù)集訓(xùn)練否則關(guān)鍵點(diǎn)偏了后面的動(dòng)作分類再準(zhǔn)也沒(méi)有意義。這就是為什么很多老工程師拿到新源碼包會(huì)先跑隨機(jī)權(quán)重而不是先花幾天調(diào)訓(xùn)練參數(shù)。先驗(yàn)證通路再追求精度能把心態(tài)穩(wěn)下來(lái)。4. 訓(xùn)練自己的數(shù)據(jù)數(shù)據(jù)集格式、訓(xùn)練參數(shù)與評(píng)估方法推理跑通只是開(kāi)始。要在自己的場(chǎng)景里穩(wěn)定必須用自己的數(shù)據(jù)訓(xùn)練。很多源碼包自帶數(shù)據(jù)讀取類但格式不一定合你手頭數(shù)據(jù)。下面按數(shù)據(jù)結(jié)構(gòu)、預(yù)處理、訓(xùn)練參數(shù)、評(píng)估四條線展開(kāi)。4.1 數(shù)據(jù)集有三種常見(jiàn)樣式關(guān)鍵點(diǎn)熱力圖、關(guān)鍵點(diǎn)坐標(biāo)、動(dòng)作分類標(biāo)簽在動(dòng)手準(zhǔn)備數(shù)據(jù)前先確認(rèn)這套源碼需要什么格式。我看過(guò)的同類項(xiàng)目里姿態(tài)與動(dòng)作系統(tǒng)一般會(huì)吃三種數(shù)據(jù)之一格式核心字段適用場(chǎng)景常見(jiàn)讀取方式COCO風(fēng)格JSONimages, annotations, keypoints姿態(tài)估計(jì)預(yù)訓(xùn)練或遷移自定義Dataset按ann_id索引關(guān)鍵點(diǎn)坐標(biāo)CSVframe_id, x1,y1,x2,y2,...,label動(dòng)作分類、兩級(jí)式系統(tǒng)pandas讀取轉(zhuǎn)浮點(diǎn)Tensor熱力圖H5images, target_heatmaps離線生成熱力圖訓(xùn)練IO更快h5py讀取COCO風(fēng)格JSON適合姿態(tài)網(wǎng)絡(luò)CSV適合動(dòng)作網(wǎng)絡(luò)H5適合你已經(jīng)把熱力圖生成完畢的情況。常見(jiàn)操作是先用COCO風(fēng)格JSON訓(xùn)姿態(tài)再?gòu)淖藨B(tài)預(yù)測(cè)結(jié)果導(dǎo)出CSV訓(xùn)動(dòng)作。不要試圖讓一個(gè)數(shù)據(jù)集同時(shí)滿足所有格式能跑通源碼才是第一位。下面是一段CSV樣例frame_id, x_shoulder, y_shoulder, ..., x_ankle, y_ankle, label 0, 0.34, 0.21, ..., 0.52, 0.88, walk 1, 0.33, 0.20, ..., 0.53, 0.89, walk如果源碼的數(shù)據(jù)加載需要的是每行一個(gè)動(dòng)作樣本你要把同一動(dòng)作的連續(xù)幀窗口切出來(lái)這部分通常由動(dòng)作網(wǎng)絡(luò)的數(shù)據(jù)集類完成不要自己先把窗口硬編碼到CSV里。4.2 從原始采集到可訓(xùn)練樣本標(biāo)注和預(yù)處理腳本標(biāo)注關(guān)鍵點(diǎn)常見(jiàn)做法是用開(kāi)源標(biāo)注工具導(dǎo)出每個(gè)bbox下的關(guān)鍵點(diǎn)坐標(biāo)。拿到標(biāo)注后我一般先寫(xiě)一段腳本做兩件事把關(guān)鍵點(diǎn)從原圖坐標(biāo)轉(zhuǎn)成相對(duì)bbox的歸一化坐標(biāo)同時(shí)檢查不可見(jiàn)點(diǎn)標(biāo)記。所謂不可見(jiàn)點(diǎn)指被遮擋時(shí)標(biāo)注為0的坐標(biāo)不能當(dāng)成真實(shí)位置參與訓(xùn)練。下面這段代碼示意了歸一化邏輯適用姿態(tài)網(wǎng)絡(luò)輸入前處理import numpy as np def normalize_keypoints(kpts, bbox): x, y kpts[0], kpts[1] x1, y1, x2, y2 bbox w, h x2 - x1, y2 - y1 cx, cy x1 w / 2, y1 h / 2 nx (x - cx) / max(w, 1) ny (y - cy) / max(h, 1) return np.array([nx, ny])參數(shù)說(shuō)明bbox是單人檢測(cè)框歸一化后姿態(tài)動(dòng)作與人物在畫(huà)面里的絕對(duì)像素不再相關(guān)對(duì)動(dòng)作分類尤其重要。如果你發(fā)現(xiàn)訓(xùn)練時(shí)站著和坐著容易混淆先看是不是忘了做這一步。還有一個(gè)常見(jiàn)坑是翻轉(zhuǎn)增強(qiáng)時(shí)關(guān)鍵點(diǎn)的左右關(guān)系要交換例如左肩和右肩互換。代碼里必須維護(hù)一個(gè)關(guān)鍵點(diǎn)交換列表否則增強(qiáng)后的人體結(jié)構(gòu)是錯(cuò)位的。這個(gè)交換表一般放在config或data/transforms里要找出來(lái)確認(rèn)。4.3 訓(xùn)練腳本里的六個(gè)關(guān)鍵參數(shù)batch_size、學(xué)習(xí)率、熱力圖尺寸、增強(qiáng)開(kāi)關(guān)、類別平衡、epoch訓(xùn)練腳本能改的地方很多但下面這份yaml幾乎每次都要調(diào)data: input_size: [192, 256] heatmap_size: [48, 64] window_size: 8 train: batch_size: 32 epochs: 80 lr: 0.001 lr_decay: step pose_aug: true action_class_balance: trueinput_size和heatmap_size決定顯存與定位精度。如果你的顯存只有8GBbatch_size直接降到8或16input_size保持192x256不要一上來(lái)就用384x384。heatmap_size通常等于input_size的四分之一所以192x256對(duì)應(yīng)48x64。window_size只在訓(xùn)練動(dòng)作網(wǎng)絡(luò)時(shí)使用建議8起步幀率30FPS下約270ms的上下文。lr0.001是姿態(tài)網(wǎng)絡(luò)從頭訓(xùn)練的常見(jiàn)默認(rèn)值如果加載了骨干網(wǎng)絡(luò)預(yù)訓(xùn)練權(quán)重我把learning rate降到0.0001否則前幾個(gè)epoch容易沖崩。pose_aug開(kāi)啟隨機(jī)旋轉(zhuǎn)/縮放/翻轉(zhuǎn)augment能大幅降低過(guò)擬合但對(duì)關(guān)鍵點(diǎn)坐標(biāo)的同步變換要求很?chē)?yán)格這點(diǎn)第5章會(huì)細(xì)說(shuō)。action_class_balance在動(dòng)作類別樣本差異大時(shí)開(kāi)啟例如“蹲下”樣本只有“站立”的十分之一不做平衡模型會(huì)偏向預(yù)測(cè)多數(shù)類訓(xùn)練時(shí)準(zhǔn)確率虛高。4.4 訓(xùn)練完怎么評(píng)估PCK、OKS、分類準(zhǔn)確率三個(gè)指標(biāo)分別看什么姿態(tài)網(wǎng)絡(luò)常用PCK和OKS動(dòng)作網(wǎng)絡(luò)看分類準(zhǔn)確率。PCK的含義是預(yù)測(cè)關(guān)鍵點(diǎn)與標(biāo)簽之間的距離小于某個(gè)閾值通常按頭部框尺寸的0.1到0.2倍算合格。下面這段是PCK的樸素實(shí)現(xiàn)用來(lái)快速看單批結(jié)果def pck(pred, gt, head_size, threshold0.2): # pred/gt: [num_kpts, 2], 已經(jīng)映射到原圖坐標(biāo)系 dist np.linalg.norm(pred - gt, axis-1) return (dist head_size * threshold).mean()head_size是頭部框的對(duì)角線長(zhǎng)度不同動(dòng)作尺度下能自適應(yīng)所以比絕對(duì)像素閾值合理。OKS比PCK更細(xì)它會(huì)區(qū)分不同關(guān)鍵點(diǎn)的難易程度比如肩膀比腳踝容錯(cuò)大COCO風(fēng)格的json里可以直接用現(xiàn)成實(shí)現(xiàn)不用自己折騰。動(dòng)作網(wǎng)絡(luò)就別只看整體準(zhǔn)確率要打印混淆矩陣。摔倒被識(shí)別成躺下、站立被識(shí)別成蹲下這種混淆是部署時(shí)最要命的。逐類查漏比整體準(zhǔn)確率有意義得多。訓(xùn)練完成不要急著存最后一個(gè)epoch我會(huì)保存驗(yàn)證集指標(biāo)最好的checkpoint再配合訓(xùn)練日志確認(rèn)loss沒(méi)有異常回升。5. 避坑記錄CNN姿態(tài)與動(dòng)作系統(tǒng)訓(xùn)練推理的五個(gè)翻車(chē)點(diǎn)下面這五個(gè)坑都是我在類似源碼包里實(shí)際翻過(guò)的有的來(lái)自環(huán)境有的來(lái)自數(shù)據(jù)有的來(lái)自后處理。每一條按“現(xiàn)象-原因-解決”寫(xiě)照著排查能省幾天時(shí)間。5.1 路徑與系統(tǒng)編碼解壓到中文目錄必坑現(xiàn)象系統(tǒng)是Windows把zip解壓到“項(xiàng)目2025”這種中文目錄運(yùn)行時(shí)DataLoader報(bào)FileNotFoundError而且報(bào)錯(cuò)路徑里顯示亂碼。原因zip內(nèi)文件名是UTF-8編碼Windows自帶解壓工具會(huì)用本地編碼解出亂碼代碼里再用默認(rèn)編碼去讀路徑就對(duì)不上。另外中文路徑本身在opencv里支持不穩(wěn)容易踩到。解決解壓時(shí)直接統(tǒng)一到純英文路徑目錄名用pose_action這種不要在中間層夾中文。如果已經(jīng)解壓壞了刪掉重解別嘗試去改源碼里的編碼代價(jià)大。代碼入口統(tǒng)一用pathlib.Path來(lái)處理路徑避免手寫(xiě)字符串拼路徑。這個(gè)習(xí)慣能避開(kāi)很多詭異問(wèn)題。5.2 關(guān)鍵點(diǎn)坐標(biāo)沒(méi)跟著數(shù)據(jù)增強(qiáng)走訓(xùn)練曲線像心電圖現(xiàn)象開(kāi)啟旋轉(zhuǎn)、翻轉(zhuǎn)增強(qiáng)后姿態(tài)網(wǎng)絡(luò)的loss先降后升或者反復(fù)震蕩val的PCK還不如不增強(qiáng)。原因圖像被隨機(jī)旋轉(zhuǎn)和翻轉(zhuǎn)了但關(guān)鍵點(diǎn)坐標(biāo)沒(méi)有做同樣的變換模型看到的是“肩膀上長(zhǎng)了手”的錯(cuò)位樣本。最常見(jiàn)是忘了翻轉(zhuǎn)時(shí)交換左右關(guān)鍵點(diǎn)比如左肩右肩不換損失永遠(yuǎn)壓不下去。解決用圖像增強(qiáng)庫(kù)的keypoints參數(shù)來(lái)綁定坐標(biāo)不要自己手寫(xiě)旋轉(zhuǎn)矩陣。使用albumentations時(shí)寫(xiě)法如下import albumentations as A transform A.Compose([ A.Rotate(limit30, p0.5), A.HorizontalFlip(p0.5), ], keypoint_paramsA.KeypointParams(formatxy))這個(gè)寫(xiě)法下圖像和keypoints會(huì)使用同一組隨機(jī)參數(shù)翻轉(zhuǎn)時(shí)albumentations會(huì)自動(dòng)交換左右索引。要注意的是如果你的訓(xùn)練代碼里還有額外的RandomCrop必須把crop后的bbox也同步到坐標(biāo)上否則關(guān)鍵點(diǎn)坐標(biāo)依舊錯(cuò)位。5.3 類別編號(hào)錯(cuò)位Loss正常但推理結(jié)果全亂現(xiàn)象動(dòng)作網(wǎng)絡(luò)訓(xùn)練loss很漂亮驗(yàn)證準(zhǔn)確率95%但一到真實(shí)視頻里把站立識(shí)別成走路走路識(shí)別成坐下看起來(lái)完全隨機(jī)。原因這種翻車(chē)經(jīng)常不是模型問(wèn)題是類別編號(hào)錯(cuò)位。你訓(xùn)練時(shí)用的CSV里類別0對(duì)應(yīng)走路但推理時(shí)模型輸出0卻按站立去畫(huà)標(biāo)簽或者裁剪類別列表時(shí)少了一個(gè)類別所有編號(hào)整體偏移一位。解決把類別映射抽成單獨(dú)的class_list.yaml訓(xùn)練和推理都從同一個(gè)文件讀不硬編碼在腳本里。一般在config目錄下會(huì)有類似的類別文件。如果源碼里沒(méi)有就自己建一個(gè)并在訓(xùn)練入口和推理入口都加一段斷言驗(yàn)證類別數(shù)量一致。這樣以后新增動(dòng)作只需改一個(gè)文件。5.4 顯存不足先看熱力圖尺寸和batch別急著換顯卡現(xiàn)象一訓(xùn)練就OOM報(bào)CUDA out of memory把batch_size從32調(diào)到8還是不夠。原因顯存占用不只是batch_size輸入分辨率、熱力圖分辨率、骨干網(wǎng)絡(luò)通道數(shù)共同決定active cache。很多人只調(diào)batch_size實(shí)際上熱力圖尺寸占大頭。有些項(xiàng)目的config默認(rèn)heatmap_size96x128這比batch翻倍還吃顯存。解決先把input_size降到192x256heatmap_size降到48x64再把batch_size調(diào)到8看顯存余量。同時(shí)打開(kāi)混合精度訓(xùn)練用GradScaler減少中間張量的精度緩存scaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): heatmaps model(img) loss criterion(heatmaps, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()如果你用的是老顯卡不支持autocast再考慮把backbone換輕量版本。不要一開(kāi)始就換大卡多花冤枉錢(qián)。5.5 玄學(xué)指標(biāo)Loss很低但PCK難看先查坐標(biāo)還原現(xiàn)象模型訓(xùn)練完loss低得開(kāi)心結(jié)果畫(huà)出來(lái)的關(guān)鍵點(diǎn)整體偏左上角PCK慘不忍睹。原因這不是模型玄學(xué)是坐標(biāo)還原時(shí)漏了尺度映射。推理腳本把熱力圖argmax后直接除以熱力圖尺寸再乘熱力圖尺寸看起來(lái)沒(méi)毛病但熱力圖尺寸、網(wǎng)絡(luò)輸入尺寸、原圖尺寸三者不是一回事少一步就偏。常見(jiàn)是熱力圖是48x64網(wǎng)絡(luò)輸入是192x256原圖是1080x1920如果只在熱力圖尺度里自洽沒(méi)有乘原圖到輸入圖的scale輸出當(dāng)然偏。解決記錄原圖寬高在網(wǎng)絡(luò)輸出坐標(biāo)后做完整的反向映射。簡(jiǎn)化轉(zhuǎn)換如下scale_x orig_w / input_w scale_y orig_h / input_h pred_x heat_x * (input_w / heatmap_w) * scale_x pred_y heat_y * (input_h / heatmap_h) * scale_y這里heat_x是每個(gè)通道argmax得到的整數(shù)索引heatmap_w和heatmap_h是熱力圖寬高。很多源碼的decode_heatmaps里只處理到input_w沒(méi)有最后一個(gè)scale_x如果你發(fā)現(xiàn)推理圖比訓(xùn)練圖大就一定要補(bǔ)上這段。這個(gè)坑我栽過(guò)一次后現(xiàn)在都會(huì)先寫(xiě)一個(gè)單人單圖的繪圖腳本把坐標(biāo)直接畫(huà)在原圖上人工確認(rèn)。6. 把動(dòng)作識(shí)別做穩(wěn)的進(jìn)階技巧滑動(dòng)窗口、多數(shù)投票和置信度閾值單幀動(dòng)作分類最典型的問(wèn)題是抖相鄰兩幀可能從“走路”跳到“站立”再跳回“走路”。這不是模型完全錯(cuò)了而是某些中間姿態(tài)在兩個(gè)類別邊界上。我常用的一個(gè)做法是維護(hù)一個(gè)長(zhǎng)度為T(mén)的幀隊(duì)列模型對(duì)隊(duì)列做整體判斷然后對(duì)最近N次輸出做多數(shù)投票概率低于閾值的幀記為unknown不參與最終決策。下面的代碼示意了這種邏輯from collections import deque import numpy as np import torch CLASSES [stand, walk, sit, bend] def detect_action(queue, action_net, history, threshold0.7): keypoints np.array(queue, dtypenp.float32) keypoints keypoints.reshape(1, len(queue), -1) tensor torch.from_numpy(keypoints) with torch.no_grad(): logits action_net(tensor) prob torch.softmax(logits, dim-1).squeeze(0).numpy() vote int(prob.argmax()) if prob.max() threshold else -1 history.append(vote) if len(history) 5: history.popleft() counts [history.count(c) for c in range(-1, len(CLASSES))] best int(np.argmax(counts)) - 1 return best, prob這里的action_net接收的是形狀[1, T, K*3]的關(guān)鍵點(diǎn)序列K是姿態(tài)關(guān)鍵點(diǎn)數(shù)量3對(duì)應(yīng)x、y、置信度。threshold取0.7會(huì)讓unknown增多但誤判減少如果場(chǎng)景要求實(shí)時(shí)響應(yīng)我一般取0.6并且把history長(zhǎng)度從5降到3。窗口長(zhǎng)度T8時(shí)大約270ms的上下文處理站立、走路這類過(guò)渡動(dòng)作足夠。如果是摔倒檢測(cè)T要提高到16到21否則“倒地”過(guò)程還沒(méi)形成完整姿態(tài)模型就過(guò)早判成“躺下”。投票窗口里我需要保證unknown不會(huì)長(zhǎng)時(shí)間占據(jù)多數(shù)否則動(dòng)作開(kāi)始和結(jié)束會(huì)有明顯延遲。一個(gè)更細(xì)的做法是對(duì)每個(gè)類別統(tǒng)計(jì)平滑概率而不是粗暴計(jì)數(shù)但多數(shù)投票實(shí)現(xiàn)最直觀且瓶頸通常不在這個(gè)邏輯而在關(guān)鍵點(diǎn)提取。先做投票再?zèng)Q定要不要做更復(fù)雜的平滑。這套思路我在模擬項(xiàng)目X上驗(yàn)證過(guò)誤檢率比單幀直接輸出降低了一半以上。我現(xiàn)在的習(xí)慣是先跑通打通鏈路再調(diào)閾值和窗口最后才優(yōu)化模型本身。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取