鍵點(diǎn)識別:21點(diǎn)熱力圖與HRNet實(shí)戰(zhàn))
簡介本資源面向計(jì)算機(jī)視覺與深度學(xué)習(xí)方向的開發(fā)者、中醫(yī)藥信息化研究者及高校學(xué)生提供一套基于YOLOv8實(shí)現(xiàn)多通道手部穴位關(guān)鍵點(diǎn)識別的完整項(xiàng)目代碼與配套資料可用于智能按摩儀、健康管理APP等場景的技術(shù)驗(yàn)證與二次開發(fā)。壓縮包共1036個(gè)文件約22.13MB包含160個(gè)Python腳本、492個(gè)Markdown說明文檔、121張PNG與121張JPG圖像樣本、43個(gè)YAML及21個(gè)YML配置、12個(gè)CSV數(shù)據(jù)表另有ipynb實(shí)驗(yàn)筆記、sh運(yùn)行腳本與Dockerfile部署文件覆蓋數(shù)據(jù)預(yù)處理、模型構(gòu)建、訓(xùn)練驗(yàn)證到部署的完整鏈路。目前已有376人學(xué)習(xí)下載。資源內(nèi)含YOLOv8關(guān)鍵點(diǎn)檢測的訓(xùn)練配置、標(biāo)注數(shù)據(jù)組織方式與評估指標(biāo)記錄讀者可據(jù)此復(fù)現(xiàn)手部穴位識別流程理解OpenCV圖像增強(qiáng)與PyTorch訓(xùn)練細(xì)節(jié)并參考目錄結(jié)構(gòu)快速定位數(shù)據(jù)、模型與日志模塊適合作為課程設(shè)計(jì)或科研項(xiàng)目的實(shí)踐起點(diǎn)。1. 多通道手部穴位關(guān)鍵點(diǎn)識別從一張手掌圖到 21 個(gè)坐標(biāo)點(diǎn)手部穴位關(guān)鍵點(diǎn)識別說白了就是讓程序在一張手掌圖片里自動找出「合谷」「勞宮」「內(nèi)關(guān)」這些穴位對應(yīng)的像素坐標(biāo)。傳統(tǒng)做法靠人工標(biāo)注一個(gè)中醫(yī)師標(biāo)一只手要幾分鐘批量做數(shù)據(jù)集時(shí)效率極低。而基于 Python 實(shí)現(xiàn)多通道手部穴位關(guān)鍵點(diǎn)識別核心思路是把「多通道」理解成兩條線一條是輸入側(cè)的多通道RGB 三通道 深度圖 紅外圖或者單圖切出的多尺度特征通道另一條是輸出側(cè)的多通道每個(gè)關(guān)鍵點(diǎn)一個(gè)熱力圖通道21 個(gè)點(diǎn)就是 21 通道。這兩條線決定了你后面選什么模型、怎么標(biāo)數(shù)據(jù)、怎么部署。這篇文章面向的是想用 Python 把這件事跑起來的工程師——你可能做過 MediaPipe 手部檢測但發(fā)現(xiàn)它只給 21 個(gè)關(guān)節(jié)不給穴位也可能手里有一批手掌照片想自己訓(xùn)一個(gè)能輸出穴位坐標(biāo)的模型。我會按「數(shù)據(jù)怎么造 → 模型怎么搭 → 多通道怎么接 → 坑在哪 → 怎么驗(yàn)證」的順序講代碼全部用 PyTorch 和 OpenCV環(huán)境用 Python 3.10 CUDA 11.8你照著改路徑就能跑。先記住一個(gè)反直覺結(jié)論穴位關(guān)鍵點(diǎn)識別最難的不是模型結(jié)構(gòu)而是穴位定義本身在不同流派里就不統(tǒng)一所以你的標(biāo)注規(guī)范比網(wǎng)絡(luò)結(jié)構(gòu)重要十倍。2. 數(shù)據(jù)準(zhǔn)備穴位坐標(biāo)怎么標(biāo)、怎么增強(qiáng)、怎么轉(zhuǎn)成熱力圖2.1 穴位定義與 21 點(diǎn)標(biāo)注規(guī)范手部穴位沒有像 COCO 那樣的公開標(biāo)準(zhǔn)數(shù)據(jù)集你得自己定一套。我一般會參考 WHO 標(biāo)準(zhǔn)針灸經(jīng)穴定位再結(jié)合 MediaPipe 的 21 個(gè)手部關(guān)節(jié)做映射。為什么用 21 點(diǎn)因?yàn)?MediaPipe Hands 已經(jīng)能穩(wěn)定輸出 21 個(gè)關(guān)節(jié)坐標(biāo)你可以把穴位點(diǎn)掛在這 21 個(gè)關(guān)節(jié)的相對位置上減少標(biāo)注工作量。比如「合谷」大致在拇指食指掌骨之間對應(yīng) MediaPipe 的 INDEX_FINGER_MCP 和 THUMB_MCP 連線的中點(diǎn)偏橈側(cè)。具體標(biāo)注時(shí)我建議用 LabelMe 或 CVAT 標(biāo) 21 個(gè)點(diǎn)每個(gè)點(diǎn)一個(gè)類別名。標(biāo)注文件存成 JSON結(jié)構(gòu)如下{ image: hand_001.jpg, width: 640, height: 480, points: [ {name: hegu, x: 312, y: 287}, {name: laogong, x: 298, y: 341}, {name: neiguan, x: 275, y: 402} ] }這里 x、y 是像素坐標(biāo)原點(diǎn)在左上角。注意不同人手掌大小差異大直接回歸絕對坐標(biāo)會讓模型學(xué)偏所以后面要轉(zhuǎn)成相對坐標(biāo)或熱力圖。2.2 從 JSON 到熱力圖多通道輸出的關(guān)鍵一步關(guān)鍵點(diǎn)識別主流有兩種輸出直接回歸坐標(biāo)FC 層輸出 42 維和熱力圖每個(gè)點(diǎn)一張高斯熱力圖。熱力圖對多通道更友好因?yàn)?21 個(gè)點(diǎn)就是 21 個(gè)通道空間信息保留得好小目標(biāo)也穩(wěn)。下面是把 JSON 轉(zhuǎn)成 21 通道熱力圖的代碼import json import numpy as np import cv2 def json_to_heatmap(json_path, img_size(256, 256), sigma3): with open(json_path, r) as f: data json.load(f) h, w img_size heatmaps np.zeros((21, h, w), dtypenp.float32) # 21 個(gè)穴位按固定順序排列順序必須和訓(xùn)練時(shí)一致 keypoint_order [ hegu, laogong, neiguan, waiguan, houxi, zhongzhu, jianjing, quchi, shousanli, yangchi, wenliu, pianli, lieque, jingqu, taiyuan, yuji, yuzhen, shaofu, shenmen, daling, zhongchong ] for idx, name in enumerate(keypoint_order): for p in data[points]: if p[name] name: # 坐標(biāo)歸一化到 0-1再映射到熱力圖尺寸 cx int(p[x] / data[width] * w) cy int(p[y] / data[height] * h) # 生成高斯熱力圖sigma 控制峰值擴(kuò)散范圍 heatmap np.zeros((h, w), dtypenp.float32) if 0 cx w and 0 cy h: heatmap[cy, cx] 1.0 heatmap cv2.GaussianBlur(heatmap, (0, 0), sigma) heatmap heatmap / (heatmap.max() 1e-6) heatmaps[idx] heatmap return heatmaps邏輯說明先按固定順序遍歷 21 個(gè)穴位名找到對應(yīng)坐標(biāo)后歸一化再生成高斯熱力圖。sigma 取 3 是經(jīng)驗(yàn)值太小會讓模型對 1 像素誤差都敏感太大則相鄰穴位熱力圖會糊在一起。參數(shù) img_size 建議和網(wǎng)絡(luò)輸入一致我一般用 256×256顯存占用和精度平衡得比較好。2.3 數(shù)據(jù)增強(qiáng)別讓模型只認(rèn)一種手手部數(shù)據(jù)增強(qiáng)和通用目標(biāo)檢測不一樣旋轉(zhuǎn)和縮放要控制幅度。我常用這幾招隨機(jī)旋轉(zhuǎn) ±30 度超過 30 度穴位相對位置會失真隨機(jī)縮放 0.81.2 倍模擬不同拍攝距離隨機(jī)亮度對比度調(diào)整模擬不同光照水平翻轉(zhuǎn)要慎用因?yàn)樽笥沂盅ㄎ徊粚ΨQ翻轉(zhuǎn)后必須重新映射穴位名增強(qiáng)代碼用 Albumentations 最省事import albumentations as A transform A.Compose([ A.Rotate(limit30, p0.7), A.RandomScale(scale_limit0.2, p0.5), A.RandomBrightnessContrast(p0.5), ], keypoint_paramsA.KeypointParams(formatxy, remove_invisibleFalse))注意 keypoint_params 里的 format 要和你的坐標(biāo)格式一致remove_invisible 設(shè) False 是為了保留邊界點(diǎn)后面用熱力圖時(shí)再統(tǒng)一處理越界。3. 模型搭建多通道輸入與 21 通道熱力圖輸出怎么接3.1 為什么選 HRNet 而不是 ResNet熱力圖回歸任務(wù)里高分辨率特征保留得越好關(guān)鍵點(diǎn)定位越準(zhǔn)。ResNet 經(jīng)過多次下采樣后小穴位點(diǎn)容易丟。HRNet 全程保持高分辨率分支并行連接不同尺度在 COCO 關(guān)鍵點(diǎn)上比 ResNet 高 35 個(gè) AP。我一般用 HRNet-W18 作為 backbone參數(shù)量約 10M單張 256×256 輸入在 RTX 3060 上推理約 15ms夠用。多通道輸入怎么接如果你只有 RGB那就是 3 通道輸入。如果你有深度圖或紅外圖可以在第一層卷積把輸入通道改成 4 或 5。代碼里改一個(gè)參數(shù)就行import torch import torch.nn as nn from hrnet import HRNet # 假設(shè)你用的是開源 HRNet 實(shí)現(xiàn) class HandAcupointModel(nn.Module): def __init__(self, in_channels3, num_keypoints21): super().__init__() # HRNet 主干輸入通道可配置 self.backbone HRNet(width18, in_channelsin_channels) # 輸出頭把最后一層特征圖映射到 21 通道熱力圖 self.head nn.Conv2d(18 * 4, num_keypoints, kernel_size1) def forward(self, x): feat self.backbone(x) # 假設(shè)輸出是 concat 后的多尺度特征 heatmaps self.head(feat) return heatmaps參數(shù)說明in_channels 根據(jù)你的輸入改RGB 是 3加深度圖是 4。num_keypoints 固定 21。head 用 1×1 卷積不改變空間尺寸輸出和輸入同分辨率方便后面算損失。3.2 損失函數(shù)MSE 還是 Wing Loss熱力圖回歸常用 MSE但它對離群點(diǎn)敏感。Wing Loss 在小誤差時(shí)梯度更大收斂更快。我一般先用 MSE 跑通再換 Wing Loss 微調(diào)。PyTorch 里 Wing Loss 要自己寫class WingLoss(nn.Module): def __init__(self, omega10, epsilon2): super().__init__() self.omega omega self.epsilon epsilon def forward(self, pred, target): diff torch.abs(pred - target) loss torch.where( diff self.omega, self.omega * torch.log(1 diff / self.epsilon), diff - self.omega self.omega * torch.log(1 self.omega / self.epsilon) ) return loss.mean()omega 控制非線性區(qū)域范圍epsilon 控制曲率。經(jīng)驗(yàn)值 omega10、epsilon2 在熱力圖任務(wù)上比較穩(wěn)。注意 pred 和 target 都要?dú)w一化到 01否則 omega 要跟著調(diào)。3.3 訓(xùn)練循環(huán)與多通道輸出解碼訓(xùn)練時(shí)輸入 batch 圖像輸出 21 通道熱力圖和標(biāo)注熱力圖算損失。解碼時(shí)取每個(gè)通道最大值位置作為預(yù)測坐標(biāo)def decode_heatmaps(heatmaps): # heatmaps: (B, 21, H, W) B, K, H, W heatmaps.shape coords torch.zeros((B, K, 2), deviceheatmaps.device) for b in range(B): for k in range(K): hm heatmaps[b, k] idx torch.argmax(hm) cy, cx idx // W, idx % W coords[b, k] torch.tensor([cx, cy], dtypetorch.float32) return coords這段代碼效率不高實(shí)際部署時(shí)可以用 soft-argmax 替代 argmax得到亞像素精度def soft_argmax(heatmaps, beta100): B, K, H, W heatmaps.shape device heatmaps.device x torch.arange(W, devicedevice).float().view(1, 1, 1, W) y torch.arange(H, devicedevice).float().view(1, 1, H, 1) hm heatmaps.view(B, K, -1) hm torch.softmax(hm * beta, dim-1).view(B, K, H, W) cx (hm * x).sum(dim(2, 3)) cy (hm * y).sum(dim(2, 3)) return torch.stack([cx, cy], dim-1)beta 越大越接近 argmax越小越平滑。我一般訓(xùn)練時(shí)用 beta100推理時(shí)用 beta500 提高定位精度。4. 避坑與排查多通道穴位識別里最容易翻車的 5 個(gè)點(diǎn)4.1 穴位順序錯(cuò)亂導(dǎo)致熱力圖通道對不上現(xiàn)象訓(xùn)練 loss 一直不降預(yù)測出來的點(diǎn)全擠在手掌中心。 原因標(biāo)注 JSON 里穴位名和代碼里的 keypoint_order 順序不一致比如「合谷」在標(biāo)注里排第 3代碼里排第 1熱力圖通道就錯(cuò)位了。 解決把 keypoint_order 寫成一個(gè)常量文件標(biāo)注、訓(xùn)練、推理三處都 import 同一個(gè)列表別手抄。4.2 左右手混訓(xùn)導(dǎo)致模型左右不分現(xiàn)象驗(yàn)證集上左手樣本誤差是右手的兩倍。 原因左手和右手穴位鏡像對稱但標(biāo)注時(shí)沒有區(qū)分左右模型學(xué)混了。 解決在數(shù)據(jù)加載時(shí)加一個(gè) handedness 標(biāo)簽?zāi)P屠锛右粋€(gè)二分類頭或者干脆左右手分開訓(xùn)兩個(gè)模型。我一般選后者簡單粗暴。4.3 熱力圖 sigma 設(shè)太小導(dǎo)致梯度消失現(xiàn)象訓(xùn)練初期 loss 下降極慢預(yù)測熱力圖全是零。 原因sigma1 時(shí)高斯峰值只覆蓋 12 個(gè)像素大部分位置梯度接近零。 解決sigma 至少設(shè) 2我一般用 3。如果輸入分辨率是 128×128sigma 可以降到 2。4.4 輸入通道數(shù)改了但預(yù)訓(xùn)練權(quán)重沒改現(xiàn)象加載 HRNet 預(yù)訓(xùn)練權(quán)重時(shí)報(bào) shape mismatch。 原因預(yù)訓(xùn)練權(quán)重第一層是 3 通道你改成 4 通道后形狀對不上。 解決加載時(shí)用 strictFalse或者手動把第一層權(quán)重復(fù)制三份再補(bǔ)一個(gè)隨機(jī)通道。代碼state_dict torch.load(hrnet_w18.pth) if in_channels ! 3: old_conv state_dict[backbone.conv1.weight] new_conv torch.zeros(in_channels, old_conv.shape[1], *old_conv.shape[2:]) new_conv[:3] old_conv new_conv[3:] old_conv.mean(dim0, keepdimTrue) state_dict[backbone.conv1.weight] new_conv model.load_state_dict(state_dict, strictFalse)4.5 推理時(shí)忘記歸一化導(dǎo)致坐標(biāo)全偏現(xiàn)象訓(xùn)練時(shí) loss 很低推理時(shí)畫出來的點(diǎn)全在圖像外。 原因訓(xùn)練時(shí)輸入做了 /255 歸一化推理時(shí)直接喂原始像素。 解決把預(yù)處理寫成一個(gè)函數(shù)訓(xùn)練和推理都調(diào)用同一個(gè)def preprocess(img): img cv2.resize(img, (256, 256)) img img.astype(np.float32) / 255.0 img (img - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225] return img.transpose(2, 0, 1)均值方差用 ImageNet 的就行手部數(shù)據(jù)量不大時(shí)沒必要重算。5. 驗(yàn)證與進(jìn)階用 PCK 和可視化把模型調(diào)到位5.1 PCK 指標(biāo)怎么算才合理關(guān)鍵點(diǎn)任務(wù)常用 PCKPercentage of Correct Keypoints但閾值怎么定有講究。手部穴位定位精度要求比人臉關(guān)鍵點(diǎn)低我一般用 0.05×手掌寬度作為閾值。手掌寬度取手腕到中指根的距離。代碼def compute_pck(pred, gt, palm_width, threshold0.05): # pred, gt: (N, 21, 2) dist torch.norm(pred - gt, dim-1) # (N, 21) correct (dist threshold * palm_width).float() return correct.mean().item()palm_width 要按每張圖單獨(dú)算不能用一個(gè)固定值。如果 PCK 低于 0.8先查標(biāo)注質(zhì)量再查熱力圖 sigma。5.2 可視化排查把熱力圖疊回原圖訓(xùn)練日志看不出問題時(shí)把預(yù)測熱力圖疊回原圖最直觀def visualize(img, heatmaps, alpha0.5): # img: (H, W, 3), heatmaps: (21, H, W) hm heatmaps.max(dim0)[0].cpu().numpy() hm (hm - hm.min()) / (hm.max() - hm.min() 1e-6) hm cv2.applyColorMap((hm * 255).astype(np.uint8), cv2.COLORMAP_JET) overlay cv2.addWeighted(img, 1 - alpha, hm, alpha, 0) return overlay如果熱力圖峰值分散在多個(gè)位置說明模型不確定可能是標(biāo)注不一致或 sigma 太大。如果峰值偏移到手掌邊緣檢查歸一化參數(shù)。5.3 一個(gè)具體技巧用關(guān)節(jié)熱力圖做輔助監(jiān)督穴位點(diǎn)之間有關(guān)節(jié)約束比如「合谷」一定在拇指和食指之間。我一般會同時(shí)訓(xùn)一個(gè) 21 關(guān)節(jié)熱力圖分支用 MediaPipe 的關(guān)節(jié)坐標(biāo)做輔助監(jiān)督主分支訓(xùn)穴位。兩個(gè)分支共享 backbone關(guān)節(jié)分支的 loss 權(quán)重設(shè) 0.3。這樣穴位分支收斂更快PCK 能漲 35 個(gè)點(diǎn)。代碼上就是 head 改成兩個(gè)self.head_acupoint nn.Conv2d(18 * 4, 21, kernel_size1) self.head_joint nn.Conv2d(18 * 4, 21, kernel_size1)訓(xùn)練時(shí) loss wing_loss(acupoint_pred, acupoint_gt) 0.3 * wing_loss(joint_pred, joint_gt)。關(guān)節(jié)標(biāo)注可以用 MediaPipe 自動生成省人工。5.4 部署時(shí)的輕量化選擇如果要在移動端跑HRNet-W18 還是偏重。我一般用 MobileNetV3 做 backbone熱力圖分辨率降到 128×128PCK 會掉 23 個(gè)點(diǎn)但推理能到 5ms 以內(nèi)。導(dǎo)出 ONNX 時(shí)注意 soft-argmax 里的 arange 要改成常量否則某些推理引擎不支持動態(tài) shape。torch.onnx.export(model, dummy_input, hand_acupoint.onnx, input_names[input], output_names[heatmaps], dynamic_axes{input: {0: batch}, heatmaps: {0: batch}}, opset_version11)opset 用 11 兼容性最好別用 13 以上很多移動端推理庫還沒跟上。最后說個(gè)血淚經(jīng)驗(yàn)我最早做這個(gè)任務(wù)時(shí)花了兩個(gè)月調(diào)模型結(jié)構(gòu)PCK 卡在 0.75 上不去。后來把標(biāo)注數(shù)據(jù)重新過了一遍發(fā)現(xiàn) 30% 的樣本里「勞宮」和「少府」標(biāo)反了。改完標(biāo)注同一個(gè)模型直接到 0.89。所以如果你剛開始做先把標(biāo)注規(guī)范定死找兩個(gè)人交叉驗(yàn)證 100 張圖比換任何 backbone 都管用。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取