在姿态估计这个方向混久了,基本都会在同一个地方栽一次跟头:OpenPose跑通了,结果一查关键点坐标,完全不知道第8个点到底是右胯还是左胯。更常见的是,你把OpenPose的结果拿去训练YOLOv8-Pose,或者和别人对接标注数据,两边对不上——明明都是"COCO",顺序怎么就不一样呢。
这个问题说大不大,说小不小,但一旦数据量大起来,索引错一位,整个训练集就废了。OpenPose官方提供了两套常用的关键点定义:COCO 18点和BODY_25点。我先直接说结论:OpenPose的COCO 18点并不等于官方COCO数据集的17点,BODY_25也不是简单地在18点后面加6个脚部点,其中间还插了一个MidHip。这两套模型的关键点排列顺序、左右手/脚的先后习惯,甚至和原始COCO标注的定义都不一样。这篇文章就把这些对应关系彻底理清楚,并给出可以直接抄走的索引映射代码。
1. 为什么OpenPose的"COCO模型"和官方COCO数据集总对不上
1.1 三种"COCO"点集,来源完全不同
很多人第一次接触这个问题时最懵的就是:我用的OpenPose模型文件明明叫pose_deploy_linevec.prototxt,里面注释写的是COCO,模型输出也是18个通道,为什么和网上COCO数据集的17个关键点对不上?
原因在于,这里其实存在三种不同的关键点定义:
- 官方COCO数据集关键点:严格来说是17个点,分别是鼻子、左右眼、左右耳、左右肩、左右肘、左右腕、左右胯、左右膝、左右踝,没有脖子,没有骨盆中心。
- OpenPose的COCO 18点模型:在官方17点基础上额外加了一个Neck(脖子/颈部中心),插入到Nose之后,变成18点。它的左右排列习惯也沿用了OpenPose一贯的风格:先右后左。
- OpenPose的BODY_25模型:在COCO 18点基础上又在索引8的位置插入了MidHip(骨盆中心),并且在最后追加了6个脚部点,最终形成25个点。
稍等,这里有个非常反直觉的地方:BODY_25是25个点,但它不是简单地把COCO 18点的索引整体往后挪,而是在中间插队,把原来的RHip挤到了9号位。这就是很多转换代码出错的根本原因——你以为25点取前18个就是COCO 18,实际上取出来的是"Nose, Neck, RShoulder...",到第8个MidHip那里就全部错位了。
1.2 Neck和MidHip:两个"官方数据集里没有"的点
在官方COCO的标注文件里,你是找不到Neck这个点的。为什么?因为COCO数据集的标注逻辑是,每个人实例标注17个关键点,缺少的点用visibility=0表示。脖子这个位置在很多视角下会被遮挡,标注难度大,所以官方干脆没有定义。
OpenPose为了后续做姿态分析(比如计算躯干角度、判断身体朝向),必须有躯干中心,于是强行把Neck加了进来。它不是一个解剖学意义上的"脖子",更准确地说是躯干顶部的中心点,算法根据肩部和骨盆的位置拟合出来的。
MidHip的定位则更贴近"骨盆中心",在BODY_25的索引里被放在8号位,处于RHip之前。这个点对于衡量髋部旋转、区分正面背面、判断走路姿态都非常有用,因此OpenPose宁可破坏与COCO 18的索引连续性,也要把它塞进中间。
1.3 索引错位会引发什么连锁问题
如果你没有意识到上面这套排列逻辑,直接在代码里写kpts_25[:18]当成18点数据用,那么从索引8开始,你拿到的点就全是错的:
kpts_25[8]其实是MidHip,不是RHip;kpts_25[9]才是RHip,但你会把它当成RKnee;- 依次往后,所有下半身的点都会错位。
更隐蔽的是,如果你反过来用,把18点数据映射到25点,只给前18个位置赋值,那么RHip、RKnee、RAnkle、LHip、LKnee、LAnkle这些关键点就会被写进MidHip、RHip、RKnee、RAnkle的位置,直接就全乱了。
索引错位不会报错,程序照样跑,但画出来的骨架是扭曲的,算出来的角度全部没有意义。这种错误在调试阶段极难发现,因为你第一时间不会去怀疑"索引居然不是顺序对应的"。
2. COCO 18点索引明细:从Nose到LEar,逐点对照
2.1 18点完整索引与名称对照表
OpenPose的COCO 18点模型输出18个关键点热图(实际forward出来可能是19通道,多出来的一个是背景,后面第6章再细说)。0到17的索引排列如下:
| 索引 | 关键点名称 | 说明 |
|---|---|---|
| 0 | Nose | 鼻子 |
| 1 | Neck | 颈部中心(OpenPose自造) |
| 2 | RShoulder | 右肩 |
| 3 | RElbow | 右肘 |
| 4 | RWrist | 右手腕 |
| 5 | LShoulder | 左肩 |
| 6 | LElbow | 左肘 |
| 7 | LWrist | 左手腕 |
| 8 | RHip | 右胯 |
| 9 | RKnee | 右膝 |
| 10 | RAnkle | 右踝 |
| 11 | LHip | 左胯 |
| 12 | LKnee | 左膝 |
| 13 | LAnkle | 左踝 |
| 14 | REye | 右眼 |
| 15 | LEye | 左眼 |
| 16 | REar | 右耳 |
| 17 | LEar | 左耳 |
注意看这个排列逻辑:上半身骨架从脖子开始,先画右边整条手臂,再画左边整条手臂;然后回到躯干,先右胯再左胯,顺着大腿小腿下来;最后是脸部的眼睛和耳朵。在整个序列中,右(Right)和左(Left)是分组的,而且右在前、左在后。
这一点和很多人的直觉不一样,因为自然语言里常说"左肩右肩",但OpenPose内部自始至终坚持"右先左后",后面第5章还原官方COCO 17点时还会因为这个左右顺序再踩一次坑。
2.2 从Caffe模型输出中读取18个热图
实际操作中,用OpenCV的DNN模块读取这套模型很常见。核心代码如下:
import cv2 import numpy as np protoFile = "pose/coco/pose_deploy_linevec.prototxt" weightsFile = "pose/coco/pose_iter_440000.caffemodel" net = cv2.dnn.readNetFromCaffe(protoFile, weightsFile) inWidth, inHeight = 368, 368 blob = cv2.dnn.blobFromImage( img, 1.0 / 255, (inWidth, inHeight), (0, 0, 0), swapRB=False, crop=False ) net.setInput(blob) out = net.forward() print(out.shape) # 常见输出 (1, 19, 46, 46) 或 (1, 18, 46, 46)out[0, i]就是第i个关键点的置信度热图。如果shape[1]是19,说明前18个通道是关节点热图,最后一个是背景通道;如果shape[1]恰好是18,说明导出模型时已经把背景通道去掉了。拿到热图后,用minMaxLoc找响应最大的位置,再映射回原图尺寸,就是关键点坐标:
nPoints = 18 keypoints = [] for i in range(nPoints): probMap = out[0, i, :, :] _, conf, _, point = cv2.minMaxLoc(probMap) x = point[0] * frameWidth / out.shape[3] y = point[1] * frameHeight / out.shape[2] keypoints.append((int(x), int(y), conf))这里frameWidth和frameHeight是原图尺寸,out.shape[3]和out.shape[2]是热图的宽高。这种缩放方式比简单除以系数更稳妥,因为不同输入尺寸下热图大小会变。
2.3 COCO 18的骨架连线(limbs)
拿到点坐标后要画骨架,连线关系同样有固定顺序:
POSE_PAIRS_COCO18 = [ (0, 1), (1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10), (1, 11), (11, 12), (12, 13), (0, 14), (0, 15), (14, 16), (15, 17) ]这些连线的含义是:Nose连Neck,Neck分叉到两肩,然后沿手臂往下;Neck连两胯,沿腿往下;Nose连两眼,眼睛连耳朵。注意躯干部分没有把RHip和LHip直接连起来,因为这两个点之间没有PAF(Part Affinity Fields,部件亲和场)分支直接建模。如果你画出来的骨架中间少了一根"腰带",不是代码错了,是模型本来就没有这条边。
3. BODY_25索引明细:脚部点与MidHip排布规律
3.1 BODY_25完整索引对照表
BODY_25是OpenPose性能最好、应用最广的模型。它的25个关键点排布如下:
| 索引 | 关键点名称 | 说明 |
|---|---|---|
| 0 | Nose | 鼻子 |
| 1 | Neck | 颈部中心 |
| 2 | RShoulder | 右肩 |
| 3 | RElbow | 右肘 |
| 4 | RWrist | 右手腕 |
| 5 | LShoulder | 左肩 |
| 6 | LElbow | 左肘 |
| 7 | LWrist | 左手腕 |
| 8 | MidHip | 骨盆中心 |
| 9 | RHip | 右胯 |
| 10 | RKnee | 右膝 |
| 11 | RAnkle | 右踝 |
| 12 | LHip | 左胯 |
| 13 | LKnee | 左膝 |
| 14 | LAnkle | 左踝 |
| 15 | REye | 右眼 |
| 16 | LEye | 左眼 |
| 17 | REar | 右耳 |
| 18 | LEar | 左耳 |
| 19 | LBigToe | 左脚大脚趾 |
| 20 | LSmallToe | 左脚小脚趾 |
| 21 | LHeel | 左脚跟 |
| 22 | RBigToe | 右脚大脚趾 |
| 23 | RSmallToe | 右脚小脚趾 |
| 24 | RHeel | 右脚跟 |
有没有发现一个奇怪的地方:脚部这6个点是先左脚后右脚,和身体部分"先右后左"的顺序正好相反。这确实容易让人搞混,官方就是这样设计的,没有太多道理可讲,只能死记:19-21是左脚,22-24是右脚。
3.2 连接关系表(含脚部)
BODY_25的骨架连线比COCO 18多了躯干中线和脚部连接:
POSE_PAIRS_BODY25 = [ (0, 1), (1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10), (10, 11), (1, 8), (8, 12), (12, 13), (13, 14), (0, 15), (0, 16), (15, 17), (16, 18), (11, 24), (11, 22), (22, 23), (14, 21), (14, 19), (19, 20) ]注意这里躯干部位,Neck(1)和MidHip(8)相连,MidHip(8)分叉到RHip(9)和LHip(12),这样躯干中心就被明确建模了。脚部则是从脚踝出发连到脚跟和脚趾。我用这个连线画出来的骨架比COCO 18模型要稳不少,尤其在跑动、跳跃这类动作里,脚部信息能明显减少下半身的抖动。
3.3 为什么我推荐直接用BODY_25作为中间格式
在OpenPose框架内部,如果条件允许,建议优先用BODY_25,原因有三个:
- 信息量更大:多了骨盆中心和6个脚部点,做动作分析时能计算的关节角度更多,尤其是髋关节和踝关节的角度。
- 模型更稳:官方在BODY_25上的训练数据更充分,实测对遮挡的鲁棒性普遍好于COCO 18模型。
- 转换路径更短:BODY_25可以无损截取成COCO 18,也可以映射成官方COCO 17。反过来,从COCO 18想得到BODY_25,缺失的MidHip和脚部点只能补零,信息就丢了。
如果你在自己的项目里同时涉及老代码(用COCO 18)和新代码(用BODY_25),最省心的做法是内部统一存BODY_25格式,只在需要对接外部接口时做映射。
4. 18与25互转:核心映射关系与Python代码
4.1 18→25:注意RHip之后所有索引都要+1
先给出最核心的映射关系。从COCO 18转到BODY_25,前8个点(索引0-7)直接对应,从第8个点RHip开始,全部要往后挪一位:
coco18_to_body25 = [ 0, # 0 Nose -> 0 Nose 1, # 1 Neck -> 1 Neck 2, # 2 RShoulder -> 2 RShoulder 3, # 3 RElbow -> 3 RElbow 4, # 4 RWrist -> 4 RWrist 5, # 5 LShoulder -> 5 LShoulder 6, # 6 LElbow -> 6 LElbow 7, # 7 LWrist -> 7 LWrist 9, # 8 RHip -> 9 RHip 10, # 9 RKnee -> 10 RKnee 11, # 10 RAnkle -> 11 RAnkle 12, # 11 LHip -> 12 LHip 13, # 12 LKnee -> 13 LKnee 14, # 13 LAnkle -> 14 LAnkle 15, # 14 REye -> 15 REye 16, # 15 LEye -> 16 LEye 17, # 16 REar -> 17 REar 18, # 17 LEar -> 18 LEar ]转换成25点数组时,索引8的MidHip、19-24的脚部点全部置零。如果只是临时使用,直接补(0, 0, 0)即可;如果还要继续做跟踪或角度计算,建议给这些点一个confidence=0,方便后续过滤:
def convert_18_to_25(kpts_18): # kpts_18: shape (18, 3) 或 (18, 2) kpts_25 = np.zeros((25, kpts_18.shape[1] if kpts_18.ndim > 1 else 1)) for src, dst in enumerate(coco18_to_body25): kpts_25[dst] = kpts_18[src] return kpts_254.2 25→18:删掉MidHip后收拢
反过来,从BODY_25还原成COCO 18,就是把第8位MidHip删掉,后面19-24的脚部点直接丢弃:
body25_to_coco18 = [0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] def convert_25_to_18(kpts_25): return kpts_25[body25_to_coco18]这里的body25_to_coco18列表里的数值,是BODY_25的索引;按这个顺序取出来,自然就是COCO 18的排列。用NumPy的花式索引一步就能完成,非常简洁。
4.3 批量转换与坐标保持
实际项目里不可能只转一个人,大概率是批量转换。批量处理时,先确认数组形状是(N, 25, 3)还是(N, 3, 25),我建议统一成(N, 25, C),后面不管做可视化还是训练集转换都少很多麻烦:
def batch_convert_25_to_18(kpts_batch): # kpts_batch: (N, 25, C) return kpts_batch[:, body25_to_coco18, :]坐标本身就是数值拷贝,转换过程不需要做任何插值或缩放,x、y、confidence直接原样搬运。唯一需要注意的是,如果25点里有MidHip的坐标,而你要算的角度恰好和骨盆中心相关,删掉它就等于丢了这部分信息,后面代码要按实际点数重新组织,不要越界访问索引。
5. 还原官方COCO 17点:删点之后还要左右交换
5.1 官方COCO 17点定义与顺序
官方COCO数据集的关键点顺序,和OpenPose的两套模型都有明显差异。官方COCO的排列是:
| 索引 | 关键点名称 |
|---|---|
| 0 | nose |
| 1 | left_eye |
| 2 | right_eye |
| 3 | left_ear |
| 4 | right_ear |
| 5 | left_shoulder |
| 6 | right_shoulder |
| 7 | left_elbow |
| 8 | right_elbow |
| 9 | left_wrist |
| 10 | right_wrist |
| 11 | left_hip |
| 12 | right_hip |
| 13 | left_knee |
| 14 | right_knee |
| 15 | left_ankle |
| 16 | right_ankle |
注意两个关键差异:
- 左右顺序反了:官方COCO是先左后右(left_eye, right_eye; left_shoulder, right_shoulder),OpenPose是先右后左。所以从OpenPose映射到COCO时,眼睛、耳朵、肩、肘、腕、胯、膝、踝全部要做左右互换。
- 没有Neck:官方COCO没有脖子点,也没有MidHip,这两个点必须删除。
如果你只是把BODY_25按索引顺序挑出来,不交换左右,那么转出来的COCO关键点标签就是左右颠倒的。这个问题非常隐蔽,因为画在图上看起来还是一个人形,只有当你单独看某个关键点(比如left_eye)时才会发现它其实落在右眼上。
5.2 25→17映射核心逻辑
正确做法是既删点又交换左右:
body25_to_coco17 = { 0: 0, # Nose -> nose 15: 2, # REye -> right_eye 16: 1, # LEye -> left_eye 17: 4, # REar -> right_ear 18: 3, # LEar -> left_ear 2: 6, # RShoulder -> right_shoulder 5: 5, # LShoulder -> left_shoulder 3: 8, # RElbow -> right_elbow 6: 7, # LElbow -> left_elbow 4: 10, # RWrist -> right_wrist 7: 9, # LWrist -> left_wrist 9: 12, # RHip -> right_hip 12: 11, # LHip -> left_hip 10: 14, # RKnee -> right_knee 13: 13, # LKnee -> left_knee 11: 16, # RAnkle -> right_ankle 14: 15, # LAnkle -> left_ankle } def convert_25_to_coco17(kpts_25): kpts_17 = np.zeros((17, kpts_25.shape[1])) for src, dst in body25_to_coco17.items(): kpts_17[dst] = kpts_25[src] return kpts_17这个字典的键是BODY_25索引,值是COCO 17索引。看清楚几个典型映射:BODY_25的2 RShoulder映射到COCO的6 right_shoulder,而不是5 left_shoulder;BODY_25的15 REye映射到COCO的2 right_eye,而不是1 left_eye。这种细节必须靠注释写清楚,否则转头就忘。
5.3 示例:把OpenPose结果转成YOLOv8-Pose训练标签
YOLOv8-Pose的训练标签用的就是官方COCO 17点顺序。从OpenPose的BODY_25跑完结果,保存成YOLO格式时,可以直接复用上面的转换函数。
假设你已经用OpenPose对一张图做了推理,拿到(25, 3)的数组,每个点是(x, y, confidence)。转换过程如下:
kpts_17 = convert_25_to_coco17(kpts_25) # shape (17, 3) # YOLO格式:归一化的 x, y, visibility x_norm = kpts_17[:, 0] / img_width y_norm = kpts_17[:, 1] / img_height vis = (kpts_17[:, 2] > 0.3).astype(int) yolo_line = [0, center_x / img_width, center_y / img_height] for i in range(17): yolo_line.extend([x_norm[i], y_norm[i], vis[i]])注意这里关键点数组要拉平成一维,才能和YOLO的标签格式对齐。visibility的计算阈值可以根据置信度分布调整,建议先统计一下OpenPose输出的置信度范围再定,别拍脑袋用0.5,有时候OpenPose对脚部点的置信度整体偏低,阈值设高了会把有效点全过滤掉。
6. 踩坑实录:模型输出、可视化、训练标签三处重灾区
6.1 坑1:把OpenPose COCO 18当成官方COCO 17用
这个坑我当年就踩过。项目里要用官方COCO 17点格式的标签训练关键点检测网络,我拿到OpenPose的18点输出后想当然地以为"OpenPose的COCO就是官方COCO,18比17多一个点,去掉一个背景类就行",结果直接取了前17个点。训练出来的模型测试效果很差,精度始终上不去,排查了很久才发现是把Neck当成一个有效关键点喂进去了,导致网络学习的时候,第8个点(RHip)对应的监督信号是错的。
正确的做法是必须先做一次索引重排,把18点还原成官方COCO的17点顺序(删除Neck,然后按官方顺序重新排列)。类似的"差一个点"的问题,在MediaPipe、AlphaPose等不同框架里也会遇到,每次换框架都要重新核对关键点顺序,不要相信框架名里的"COCO"三个字母。
6.2 坑2:画骨架时把左右连反
画线这步相对隐蔽。比如你想把右肩(RShoulder)和右肘(RElbow)用一条线连起来,用OpenPose的索引就是(2, 3)。但如果你参考的是官方COCO的连线习惯,right_shoulder和right_elbow分别是6和8。两边索引对不上,一旦混用,画出的人体就是"右手肘接到了左肩上"这种诡异形态。
稳妥的作图思路是,定义一套自己的"语义连线"列表,比如('Nose', 'Neck')、('RShoulder', 'RElbow')这类带名字的对,然后每个模型定义自己的索引字典,通过名字查索引再画线。这样比直接写死数字对要安全得多:
keypoint_names_25 = [ "Nose", "Neck", "RShoulder", "RElbow", "RWrist", "LShoulder", "LElbow", "LWrist", "MidHip", "RHip", "RKnee", "RAnkle", "LHip", "LKnee", "LAnkle", "REye", "LEye", "REar", "LEar", "LBigToe", "LSmallToe", "LHeel", "RBigToe", "RSmallToe", "RHeel" ] sematic_limbs = [("Nose", "Neck"), ("Neck", "RShoulder"), ("Neck", "LShoulder")] for a, b in sematic_limbs: idx_a = keypoint_names_25.index(a) idx_b = keypoint_names_25.index(b) if conf[idx_a] > thr and conf[idx_b] > thr: cv2.line(img, pts[idx_a], pts[idx_b], color, 2)用名字查索引虽然慢一点,但代码可读性和可维护性提升一大截。预处理时直接把索引字典算好,运行时不会慢多少。
6.3 坑3:模型输出通道数到底是18还是19
用OpenCV DNN加载OpenPose时,输出张量形状经常让人困惑。COCO 18模型的net.forward()结果可能是(1, 18, H, W),也可能是(1, 19, H, W)。这个差异取决于你用的prototxt文件是哪个版本。BODY_25模型同理,可能输出26个通道(25个关键点加1个背景),也可能只输出25个通道。
我见过不少人在out.shape[1]写死数字,换了个模型就崩了。正确处理方式是动态判断:
nPoints = out.shape[1] if nPoints == 19 and proto_contains_background: nPoints -= 1更简单的判断逻辑是:如果你知道当前加载的是哪个模型,直接用对应的点数;如果不知道,就检查最后一个通道是否是"全局背景"——背景通道在整张图上通常是较均匀的低响应,不像关键点热图那样有清晰峰值,可以用这个特征来区分。
6.4 自查清单
这几个方法是我用来验证关键点顺序是否正确用的,每次接入新数据源或者训练完新模型后都会跑一遍:
- 打印索引和名称:加载模型后,打印输出通道数,和已知的关键点名称列表对比,确认通道顺序。
- 画点不连线:先把所有关键点以不同颜色画在原图上,人工比对位置,而不是直接画骨架。点对了,连线才可能对。
- 左右对称性检查:找一个正对镜头的标定图像,验证左肩和右肩的x坐标是否大致对称,如果左右颠倒,立刻能发现。
- 和官方Demo对比:OpenPose官方Demo自带可视化,如果关键点位置和官方可视化中的标签对不上,说明你自己的解析或映射有问题。
最后再分享一个从实际项目中总结出来的小经验:关键点顺序问题,越早验证越好。等跑到训练阶段再发现标签索引错了,浪费的不只是时间,还有重新标注、重新预处理的大量成本。每次从外部拿到一组标注好的关键点数据,先画出来人眼过一遍,这种笨办法反而是最高效的。