估計驅(qū)動運動計數(shù):從關(guān)鍵點角度到狀態(tài)機實現(xiàn))
說到運動計數(shù)APP開發(fā)上一篇我們把攝像頭預(yù)覽、媒體管線和基礎(chǔ)工程走通了框架搭起來之后其實真正決定項目能不能落地的是后面這兩塊硬骨頭一塊是姿態(tài)估計模型給出的原始關(guān)鍵點數(shù)據(jù)怎么變成有意義的動作信息另一塊是計數(shù)邏輯在各種真實動作下怎么做到不誤計、不漏計、不重計。這篇就把這兩個核心環(huán)節(jié)完整拆開從坐標(biāo)數(shù)據(jù)講到角度計算從狀態(tài)機講到閾值調(diào)參全是實測跑的流程。這篇文章適合兩類朋友一是已經(jīng)在用姿態(tài)估計做點東西但卡在“關(guān)鍵點拿到了卻不會用”的階段二是想做個健身計數(shù)類APP正在糾結(jié)計數(shù)邏輯怎么實現(xiàn)。不管你是剛接觸姿態(tài)估計還是已經(jīng)跑通了Demo這篇文章都能讓你少走不少彎路。1. 姿態(tài)估計選型與數(shù)據(jù)基礎(chǔ)1.1 模型怎么選從實時性和精度看移動端方案做運動計數(shù)APP模型選型基本就決定了你后續(xù)所有工作的難度。姿態(tài)估計方案常見的無非這么幾個方向一種是傳統(tǒng)的多人姿態(tài)估計模型精度不錯但模型體積大、推理速度慢在手機CPU上跑尤其是實時處理視頻流時幀率低得沒法看另一種是輕量級方案專門為移動端和嵌入式場景設(shè)計模型體積小、推理速度快精度雖然比不上大模型但對動作計數(shù)這種場景來說已經(jīng)完全夠用。我最后選的是輕量級方案核心決策因素有三個第一實時性。運動計數(shù)APP要處理的是連續(xù)視頻幀模型推理速度直接決定計數(shù)響應(yīng)是跟手還是滯后。實測中輕量級模型在主流手機上單幀推理能控制在10到30毫秒而重量級模型隨便一跑就是上百毫秒體驗差距天壤之別。第二跨平臺支持。項目如果用跨平臺框架開發(fā)模型方案最好能同時覆蓋雙端省去在多個平臺各做一套模型集成的成本。第三關(guān)鍵點輸出格式。運動計數(shù)需要穩(wěn)定的關(guān)節(jié)點坐標(biāo)目標(biāo)模型直接輸出33個關(guān)鍵點的歸一化坐標(biāo)、可見性和深度信息這就夠了不依賴額外的目標(biāo)檢測網(wǎng)絡(luò)。對比一下幾個方案的差異更直觀方案模型體積移動端推理速度關(guān)鍵點數(shù)量適合場景輕量級方案A3-5MB10-30ms33個實時移動端應(yīng)用中量級方案B10-20MB50-100ms17-25個離線分析、PC端重量級方案C200MB500ms以上多人體多個點位實驗室、服務(wù)器所以選型的結(jié)論很明確移動端做實時計數(shù)優(yōu)先考慮輕量級方案把模型精度放在第二位把實時性和集成便利性放在第一位。1.2 33個關(guān)鍵點怎么讀別把坐標(biāo)搞反了拿到模型之后第一步就是把關(guān)鍵點的數(shù)據(jù)結(jié)構(gòu)吃透。以輕量級模型輸出的33個關(guān)鍵點為例每個關(guān)鍵點包含三個核心字段x歸一化坐標(biāo)范圍0到1表示關(guān)鍵點相對輸入圖像寬度的橫向位置y歸一化坐標(biāo)范圍0到1表示關(guān)鍵點相對輸入圖像高度的縱向位置z深度信息表示關(guān)鍵點相對人體中心的深度數(shù)值越大通常表示離鏡頭越遠(yuǎn)其中x和y是歸一化坐標(biāo)這個很好理解但我在實際開發(fā)中遇到不少朋友在這里踩坑拿到坐標(biāo)后直接當(dāng)成像素坐標(biāo)用了結(jié)果畫疊加層的時候點位全部偏移。這里的關(guān)鍵是如果你想在畫面上的正確位置畫點畫線必須先把歸一化坐標(biāo)乘上畫面的實際寬高double pixelX landmark.x * imageWidth; double pixelY landmark.y * imageHeight;另外還有一個很重要的點關(guān)鍵點的索引編號是固定的這是后續(xù)角度計算的基石。運動計數(shù)常用的關(guān)鍵點索引對應(yīng)關(guān)系大致如下索引部位運動計數(shù)用途11 / 12左肩 / 右肩俯臥撐角度計算13 / 14左肘 / 右肘俯臥撐、彎舉角度計算15 / 16左腕 / 右腕俯臥撐末端判定23 / 24左髖 / 右髖深蹲角度計算起點25 / 26左膝 / 右膝深蹲、箭步蹲角度計算中樞27 / 28左踝 / 右踝深蹲角度計算終點這個索引表建議你記在便利貼上因為后續(xù)所有角度計算都要反復(fù)用到。如果記串了角度算出來就是錯的但看數(shù)據(jù)又很難立刻發(fā)現(xiàn)排查起來特別痛苦。2. 關(guān)節(jié)角度計算與動作特征提取2.1 三點法求關(guān)節(jié)角度點積公式與NaN防護拿到關(guān)鍵點坐標(biāo)之后最核心的任務(wù)就是把“坐標(biāo)系里的點”變成“有物理意義的關(guān)節(jié)角度”。這里用一個非常經(jīng)典的三點法以關(guān)節(jié)為頂點取相鄰兩個關(guān)鍵點構(gòu)成兩條向量求夾角的余弦再轉(zhuǎn)換成角度值。比如計算膝關(guān)節(jié)角度取髖點索引23/24、膝點索引25/26、踝點索引27/28以膝點為頂點分別判斷兩側(cè)叉開的角度。數(shù)學(xué)公式是這樣的cos(θ) (BA · BC) / (|BA| * |BC|) θ arccos(cos(θ))翻譯成代碼就是O(1)的向量運算import dart:math; double calculateAngle(Offset a, Offset b, Offset c) { // a、b、c分別是三個關(guān)鍵點的坐標(biāo) // 頂點是ba和c是兩側(cè)點 double baX a.dx - b.dx; double baY a.dy - b.dy; double bcX c.dx - b.dx; double bcY c.dy - b.dy; double dotProduct baX * bcX baY * bcY; double magBA sqrt(baX * baX baY * baY); double magBC sqrt(bcX * bcX bcY * bcY); if (magBA 1e-6 || magBC 1e-6) { return _lastAngle; // 關(guān)鍵點重合時返回上一幀角度 } double cosTheta dotProduct / (magBA * magBC); cosTheta cosTheta.clamp(-1.0, 1.0).toDouble(); double angleInDegrees acos(cosTheta) * 180 / pi; _lastAngle angleInDegrees; return angleInDegrees; }這里有兩個特別容易被忽略的坑第一個是向量模長可能為零。當(dāng)兩個關(guān)鍵點完全重合比如手遮擋在肘關(guān)節(jié)前面模型把兩個點估計到同一位置時除法會導(dǎo)致數(shù)值異常。必須加一個最小模長判斷否則你的角度值會出現(xiàn)抖動的怪異數(shù)據(jù)計數(shù)邏輯直接亂掉。第二個是acos的入?yún)⒎秶?。浮點計算下dotProduct / (magBA * magBC)理論上應(yīng)該在-1到1之間但由于浮點誤差可能算出1.0000001之類的值導(dǎo)致acos返回NaN。加上clamp(-1.0, 1.0)這行代碼能一次性把這個坑堵死。2.2 用角度曲線刻畫動作深蹲和俯臥撐的判定特征關(guān)節(jié)角度是一種連續(xù)變化的曲線動作本質(zhì)上就體現(xiàn)為特定關(guān)節(jié)角度的“下降-保持-回升”過程。以深蹲為例我們關(guān)注的是膝關(guān)節(jié)角度站立時膝蓋基本伸直角度在170度左右下蹲到最低點時膝關(guān)節(jié)角度降到90度以下然后再回升到170度左右。一次完整的深蹲角度曲線就是一個清晰的V字形。俯臥撐則稍有不同關(guān)注的是肘關(guān)節(jié)角度手臂伸直撐起時為180度左右身體下沉到最低點時肘關(guān)節(jié)角度降到60度以下?lián)纹饡r再回到180度。同樣是一個U字形曲線。這里有個很重要的實操經(jīng)驗深蹲和俯臥撐的計數(shù)采集方向建議讓用戶側(cè)對鏡頭。正對鏡頭時關(guān)節(jié)的深度信息不可靠而且人體容易遮擋關(guān)鍵點角度計算的誤差會明顯增大側(cè)對鏡頭時關(guān)鍵點的二維投影能比較好地反映真實的關(guān)節(jié)彎曲狀態(tài)。另外原始角度數(shù)據(jù)往往非常抖動主要是因為模型輸出本身存在噪聲加上用戶動作不是完全平滑的。直接拿原始角度做計數(shù)判斷會產(chǎn)生大量誤觸發(fā)。我這里用的是指數(shù)移動平均EMA做平滑double _smoothAngle 0; bool _isFirstFrame true; double smoothAngle(double rawAngle) { if (_isFirstFrame) { _smoothAngle rawAngle; _isFirstFrame false; } else { _smoothAngle _smoothAngle * 0.7 rawAngle * 0.3; } return _smoothAngle; }0.7和0.3這兩個權(quán)重是實測比較合適的值平滑效果夠好同時滯后不會太大。如果想讓曲線更平滑可以調(diào)到0.8/0.2但滯后會明顯增加計數(shù)響應(yīng)會顯得遲鈍反過來調(diào)到0.6/0.4響應(yīng)快了但噪聲也更明顯。具體權(quán)重可以根據(jù)你目標(biāo)動作的節(jié)奏微調(diào)。3. 運動計數(shù)邏輯的核心實現(xiàn)3.1 狀態(tài)機思路把連續(xù)動作切成離散狀態(tài)角度曲線拿到之后接下來就是計數(shù)邏輯的設(shè)計。很多初學(xué)者最容易犯的錯誤是只用簡單的閾值判斷角度小于某個值就加一。這樣做的后果很慘用戶在最低點微調(diào)一下、或者角度在閾值附近來回抖動計數(shù)就會連續(xù)加好幾次。正確的做法是用狀態(tài)機來管理動作過程。核心思路是把一次完整動作抽象成幾個離散狀態(tài)只有在狀態(tài)之間完成特定的轉(zhuǎn)換序列才算作一次有效計數(shù)。以深蹲為例我用的狀態(tài)序列是等待狀態(tài)WAITING初始狀態(tài)角度在站立區(qū)間等待動作開始下降狀態(tài)DESCENDING角度持續(xù)下降跨越半程閾值進入下蹲過程回升狀態(tài)ASCENDING角度開始回升跨越半程閾值說明正在站起只有在“下降狀態(tài)”進入“回升狀態(tài)”這個轉(zhuǎn)換發(fā)生時計數(shù)加一。這樣設(shè)計的原因是一次完整的深蹲必然包含“角度先降后升”的過程只在下降沿或只在上升沿加一都會導(dǎo)致重復(fù)計數(shù)而把“下降上升”作為一個整體單元來統(tǒng)計天然就避免了這個問題。代碼層面狀態(tài)機的骨架大致是這樣的enum RepState { waiting, descending, ascending } class RepCounter { RepState _state RepState.waiting; int _count 0; void update(double angle, double threshold, double minAngle) { switch (_state) { case RepState.waiting: if (angle threshold) { _state RepState.descending; } break; case RepState.descending: if (angle minAngle) { _state RepState.ascending; } break; case RepState.ascending: if (angle threshold) { _count; _state RepState.waiting; } break; } } }這個骨架是通用框架深蹲、俯臥撐、卷腹等動作都能套用只要替換對應(yīng)的角度計算關(guān)鍵點和閾值參數(shù)就行。3.2 自適應(yīng)閾值的取舍與代碼落地剛才的代碼里有一個關(guān)鍵參數(shù)threshold半程閾值。最簡單的方案是寫死一個值比如深蹲用125度。但實際用下來這個方案問題很大不同用戶的蹲姿深度差異很大有人蹲得很低角度能到70度有人只蹲到90度就起來了。寫死125度對前者沒問題對后者可能在下降過程中就觸發(fā)了或者在回升時因為姿勢變化導(dǎo)致漏計。解決思路是改成自適應(yīng)閾值記錄上一次完整動作的峰值和谷值然后取兩者的中值作為下一次動作的觸發(fā)閾值。這樣動作幅度大的用戶閾值自動放低動作幅度小的用戶閾值自動抬高適配性大大增強。class AdaptiveRepCounter { double _peak 170; // 站立角度初始值 double _valley 80; // 最小角度初始值 double get _threshold (_peak _valley) / 2; int _count 0; RepState _state RepState.waiting; double _currentMin 180; void update(double angle) { // 實時更新峰值 if (angle _peak) _peak angle; // 跟蹤本次動作的最小角度 if (angle _currentMin) _currentMin angle; switch (_state) { case RepState.waiting: if (angle _threshold) { _state RepState.descending; _currentMin angle; } break; case RepState.descending: if (angle _threshold) { // 直接回升跨過閾值說明下降不夠徹底 if (_currentMin _valley 15) { _valley _currentMin; } _count; _state RepState.waiting; } break; } } }順著這個邏輯你就能理解為什么狀態(tài)機里對“最低點到位”的判斷那么重要。如果用戶只蹲下去一點點就回升了角度根本沒跨過有效深度那這次動作不應(yīng)該被計數(shù)。因此需要設(shè)置一個minAngle來強制要求動作深度。這個值可以取上次谷值加15到20度的容差區(qū)間既要放掉那些半途而廢的動作又要防止真正的大幅度動作被漏掉。這里還有一個細(xì)節(jié)用戶動作的節(jié)奏快慢會影響判斷。如果用戶做了個慢蹲快起角度下降沿和上升沿的時間間隔可能很長如果做了快速深蹲間隔又很短。狀態(tài)機天然能適應(yīng)這個變化因為它只關(guān)心底角度的變化方向不關(guān)心速度。但要防止“用戶只蹲到一半就站起”這種無效動作必須加上最低有效深度約束。我在實際項目里還把“單個動作最短時長”加了進去——一次動作最少要持續(xù)0.3秒小于這個時長的角度變化大概率是抖動或者肢體晃動不是有效動作。這個參數(shù)在用戶做快速波比跳之類的動作時特別管用。4. 性能優(yōu)化與真機調(diào)參實錄4.1 推理線程與UI分離別讓模型拖垮界面姿態(tài)估計模型在手機上跑最大的問題就是推理耗時會占據(jù)主線程導(dǎo)致界面卡頓、幀率驟降。我在項目里的做法是把整條鏈路分成兩條線程UI線程負(fù)責(zé)攝像頭預(yù)覽、自定義疊加層繪制關(guān)鍵點連線、計數(shù)信息展示推理線程負(fù)責(zé)幀數(shù)據(jù)轉(zhuǎn)換、模型推理、關(guān)鍵點后處理兩條線程之間用異步通道傳遞數(shù)據(jù)UI線程只拿推理結(jié)果不做任何耗時計算。實測下來這種架構(gòu)下界面始終保持流暢即使模型單幀推理耗時較長也不會卡住預(yù)覽畫面。具體操作上有幾個參數(shù)直接影響性能表現(xiàn)配置項高配方案中配方案低配方案輸入分辨率1280x720640x480320x240模型復(fù)雜度高中低處理幀率30fps20fps15fps適用機型旗艦機型中端機型入門機型我在中端機型上測試時一開始用1280x720輸入加高模型復(fù)雜度幀率只有12fps角度曲線跳變明顯。后來降成640x480加中模型復(fù)雜度幀率直接提升到25fps角度數(shù)據(jù)也穩(wěn)定了很多。這里的關(guān)鍵認(rèn)知是姿態(tài)估計模型輸出的關(guān)鍵點坐標(biāo)都是相對輸入圖像的歸一化坐標(biāo)輸入分辨率只要滿足基本的人體尺寸比例就能保證識別效果。分辨率降一半推理速度能翻倍而精度損失遠(yuǎn)沒有想象中那么嚴(yán)重。4.2 實測幀率與發(fā)熱控制實測定幀率這件事建議放在功能完成之后立刻做不要拖到最后。我用三條不同檔位的測試機型跑過整理出來的參考數(shù)據(jù)大致是這樣的機型檔位輸入分辨率實時幀率連續(xù)運行10分鐘后的溫度變化旗艦機640x48028-30fps小幅溫?zé)嶂卸藱C640x48020-25fps溫?zé)峥山邮苋腴T機480x36015-18fps明顯發(fā)熱需降頻發(fā)熱控制是個很現(xiàn)實的問題。運動類APP一拍就是十幾二十分鐘手機發(fā)熱后系統(tǒng)會主動降頻推理速度跟著變慢計數(shù)精度就崩了。我的處理方案有三板斧第一啟用相機的幀率限制不要無腦跑滿幀。運動計數(shù)的有效幀率上限其實不需要太高25fps完全夠用更高幀率只增加發(fā)熱不會提升計數(shù)精度。第二推理線程采用“丟幀處理”策略如果模型推理跟不上幀到達的速率就自動丟棄待處理隊列里的舊幀而不是讓它無限堆積。堆積只會增加延遲而且堆積到一定程度后算出來的計數(shù)結(jié)果對應(yīng)的已經(jīng)不是當(dāng)前畫面了。第三考慮到運動場景中用戶主要是看畫面提示來完成動作不是長期盯著屏幕可以在檢測到持續(xù)運動中主動降低屏幕亮度或者提示用戶鎖定屏幕減少屏幕耗電和發(fā)熱。有一組數(shù)據(jù)可以直觀說明問題不做任何優(yōu)化時入門機連續(xù)跑10分鐘后幀率暴跌到8fps左右做了上述三板斧之后同一臺機器能穩(wěn)定在15fps左右溫度也從“燙手”變成“溫?zé)帷薄_@個差距對計數(shù)體驗是決定性的。5. 常見問題與排查技巧實錄5.1 關(guān)鍵點跳變、角度NaN等典型問題開發(fā)過程中踩過的坑我整理成了一張排查速查表遇到了直接對著查現(xiàn)象可能原因處理建議關(guān)鍵點偶爾跳到畫面外人體部分被遮擋或置信度低過濾置信度低于0.5的關(guān)鍵點跳變時不更新計數(shù)狀態(tài)角度出現(xiàn)明顯異常跳變向量模長接近零或浮點誤差越界增加最小模長判斷acos入?yún)⒈仨歝lamp(-1,1)計數(shù)重復(fù)加一狀態(tài)機缺少回升判斷或在閾值附近抖動改用“下降回升”完整狀態(tài)序列增加閾值回差帶計數(shù)不增加最低動作深度約束太嚴(yán)格或用戶動作幅度不足放寬minAngle閾值或改用自適應(yīng)閾值跟蹤谷值深蹲時角度算反了用錯了關(guān)鍵點索引或角度頂點取錯核對髖、膝、踝索引畫線調(diào)試輸出檢測到的關(guān)鍵點偏移歸一化坐標(biāo)沒有換算成像素坐標(biāo)繪制疊加層前乘上畫面實際寬高正面識別效果差深度信息受二維投影影響關(guān)鍵點在身體前方遮擋引導(dǎo)用戶側(cè)對鏡頭這里有一個非常值得注意的現(xiàn)象關(guān)鍵點置信度過濾。模型會為每個關(guān)鍵點輸出一個置信度分?jǐn)?shù)但在運動場景里手指出汗擋鏡頭、衣服和背景顏色相近、動作幅度過大導(dǎo)致關(guān)鍵點暫時出畫這些都會讓置信度驟降。硬過濾置信度低于閾值直接不用會導(dǎo)致角度數(shù)據(jù)斷檔影響計數(shù)連續(xù)性我后來用的是軟過濾置信度低于閾值時保留上一幀的值不更新角度保持曲線平滑過渡。這個思路用在跳變問題上非常有效。5.2 計數(shù)不準(zhǔn)的調(diào)參方向和調(diào)試方法計數(shù)不準(zhǔn)的調(diào)試本質(zhì)上就是對著角度曲線找異常。我強烈建議在開發(fā)階段保留一個調(diào)試模式把實時的關(guān)鍵點連線、角度數(shù)值、狀態(tài)機狀態(tài)直接繪制到預(yù)覽畫面上同時把角度數(shù)據(jù)以日志形式輸出。這樣每出現(xiàn)一次誤計或者漏計你都能精確地回放當(dāng)時發(fā)生了什么。具體調(diào)參的方向分幾種情況如果出現(xiàn)“多計”大概率是閾值回差不夠。給狀態(tài)機增加回差帶是解決這個問題的標(biāo)準(zhǔn)手段比如下降沿跨過閾值觸發(fā)下降狀態(tài)回升時要超過閾值加上一個偏移量例如15度才算回升完成。這個偏移量把閾值附近的無意義抖動全部濾掉了。如果出現(xiàn)“漏計”優(yōu)先檢查最低動作深度的約束條件。很多用戶深蹲時蹲得不夠低如果你把最低角度設(shè)定在70度他蹲到85度就起來了那永遠(yuǎn)不會觸發(fā)計數(shù)。這種情況下要么放寬minAngle要么使用自適應(yīng)閾值去循環(huán)更新谷值讓系統(tǒng)自己適應(yīng)用戶的實際動作幅度。如果出現(xiàn)“延遲計數(shù)”就是用戶已經(jīng)做完動作一秒后才加一這大概率是平滑權(quán)重太偏平滑了或者處理幀率太低導(dǎo)致狀態(tài)轉(zhuǎn)換反應(yīng)遲鈍。把EMA的平滑系數(shù)從0.7/0.3調(diào)整到0.6/0.4同時檢查推理線程是否有幀堆積。如果出現(xiàn)“邏輯錯亂”比如沒做動作也計數(shù)看狀態(tài)機的初始狀態(tài)是否被正確復(fù)位。用戶站在畫面里切換動作類型時狀態(tài)機還保留著上一個動作的狀態(tài)會直接把當(dāng)前動作的起始角度當(dāng)成回升過程導(dǎo)致誤計。每次切換動作模式時必須重置狀態(tài)機。最后分享一個調(diào)試心得運動計數(shù)的調(diào)參過程非常依賴錄制回放。如果是實時調(diào)試動作重現(xiàn)的隨機性太強你根本沒法對比參數(shù)調(diào)整前后的差異。我的做法是先用調(diào)試模式錄制一段包含多次標(biāo)準(zhǔn)動作的視頻然后把這段視頻反復(fù)回放喂給計數(shù)邏輯改一次參數(shù)跑一遍回放用同一段數(shù)據(jù)對比不同參數(shù)下的計數(shù)結(jié)果。這樣調(diào)參才有依據(jù)否則就是瞎試。我實測下來用這個方法把深蹲計數(shù)從最初的一做動作就亂跳調(diào)到能穩(wěn)定識別連續(xù)20次深蹲大概花了兩個晚上。相比之下如果靠實時測試來調(diào)參可能要花一到兩周而且調(diào)完還不能確定是參數(shù)生效了還是動作剛好做得更標(biāo)準(zhǔn)了。所以先錄視頻再調(diào)參這條路建議所有做類似項目的人優(yōu)先選擇。這個項目后期還可以繼續(xù)擴展的方向我目前正在做的是加入動作質(zhì)量評估模塊利用角度曲線的平滑度和最低點深度給每次動作打一個質(zhì)量分提醒用戶動作是否做得足夠標(biāo)準(zhǔn)。從單一計數(shù)到質(zhì)量反饋姿態(tài)估計類應(yīng)用的想象空間其實很大但底層的角度計算和狀態(tài)機邏輯永遠(yuǎn)是最值得打磨的地基。