简介:本资源是一套基于OpenPose的实时姿态估计与动作识别实战项目,面向计算机视觉方向的初学者与开发者,旨在解决从视频流中提取人体关键点并对常见动作进行分类的问题。项目涵盖完整的工作流:视频采集、人体姿态估计、关键点预处理、动作分类输出,并附带可运行的Python源码与预训练模型,便于快速复现和二次开发。压缩包共33个文件,以Python脚本为主,包含模型推理与网络定义、动作分类、工具函数等模块;另含pb格式的预训练模型、gif格式的演示效果、配置文件及说明文档,整体大小33.66MB。已有360人学习下载,适合希望通过实战案例理解OpenPose原理并实现动作识别功能的读者。通过研读源码和运行示例,可以掌握姿态估计模型的使用方法、动作特征的组织方式以及完整项目的工程结构,为后续开发智能监控、人机交互等应用打下基础。
1. 姿态估计不只是“画骨架”,它是动作识别的第一层抽象
当我们需要计算机判断“这个人是在走路还是跑步”“这个工人在操作台上是否完成了规定动作”时,最直接的做法是看视频、做分类。但视频是一堆像素,光照变了、衣服换了、摄像机角度偏了,同样的动作在像素层面可能完全不同。于是行业里沉淀出一条更稳的路径:先把人还原成一组关键点坐标,再基于这些坐标做动作判断。
OpenPose 就是这条路径上最经典的开源实现。它能在单张图片或连续视频帧里,同时检测多人的身体、手、脚共 135 个关键点,输出每个点的坐标和置信度。这一层输出天然具备尺度不变性和一定的外观无关性——骨架拉远了还是一副骨架,换件衣服骨架也不变。把关键点序列喂给分类模型或规则引擎,就构成了一个完整的实时动作识别系统。
这篇文章面向的是已经写过一些 Python、用过 OpenCV、但还没完整搭过姿态估计落地方案的开发者。我会从 OpenPose 1.7.0 的模型结构与配置说起,给出可复现的推理代码,再讲从关键点序列到动作分类的两种主流做法:规则法和时序分类法。最后讨论性能瓶颈怎么破,以及一个很实用的技巧:用关键点差分替代光流来检测动作边界。文中全部代码基于官方 C++/Python 接口的常见用法编写,你拿到任何一份 OpenPose 项目源码,都能对照本文把“从视频到动作标签”这条链路跑通。
2. OpenPose 实时姿态估计的模型构成与复现要点
OpenPose 的核心创新是 Part Affinity Fields (PAF),即部件亲和场。早期姿态估计方法(如 CPM)只对关键点做响应图预测,然后依赖推理算法来配对左右肩、左右肘,当多人出现在画面里时,这种全局配对很容易错乱。PAF 的思路是,对每一对相邻关键点之间的“连线方向”也做逐像素预测,这样每个关键点归属到哪个人体就变成了一个有方向的匹配问题,而不是盲目的组合问题。
2.1 PAF 与置信图的双分支结构
网络主干(VGG19 的前 10 层)提取特征后,分成两个分支重复迭代 T 个 stage:一个分支输出关键点置信图,另一个分支输出 PAF 亲和场。每个 stage 都会把两个分支的结果和原始特征拼接,再送入下一个 stage。这种做法让浅层的高分辨率细节和深层的语义信息不断融合,对遮挡和小目标更友好。
推理时,OpenPose 的 pipeline 分为四步:
- 输入图片归一化到固定尺寸,通常是 368x368 或 656x368,保持人物完整入框。
- 网络同时输出热力图和 PAF,两者都是多通道的张量。
- 从热力图中提取每个关键点的局部峰值坐标。
- 利用 PAF 的向量场做匈牙利匹配,把属于同一个人的关键点连接成骨架。
这套设计使得同一张图里的人再多,计算量也几乎不增长,因为卷积计算是共享的,增长的只是一些匹配耗时。这也是 OpenPose 在多人场景下仍然能保持实时性的原因。
2.2 不同版本的模型文件选型
OpenPose 官方发布的预训练模型主要有三个版本,分别针对不同推理环境做了取舍:
| 模型 | 参数量 | 通道数 | 适用设备 | 关键点数 | FPS 参考(GTX 1080) |
|---|---|---|---|---|---|
| pose/coco/pose_iter_440000.caffemodel | 约 46M | 3 分支 | CPU/GPU 均可 | COCO 18 点 | 约 9 |
| pose/coco/pose_iter_440000.caffemodel 的 TensorRT 版本 | 压缩后约 22M | 3 分支 | Jetson 等嵌入式 | COCO 18 点 | 约 22 |
| pose/mpi/pose_iter_160000.caffemodel | 约 66M | 3 分支 | 更关注四肢精度 | MPI 15 点 | 约 7 |
这里出现的3是 OpenPose 的一个常见配置项:number_people_max影响的是匹配阶段的最大人数假设,而不是网络本身。实际上网络输出通道数由body_part数量决定,COCO 18 点意味着热力图有 18 个通道,PAF 有 38 个通道(19 对连线 x 2 个方向分量)。
提示:如果是在 GPU 上做实时识别,推荐直接用 COCO 18 点模型,它比 MPI 15 点多出了脚踝关键点,对“走/跑/跳”这类动作的区分度明显更好。
2.3 最小复现命令:一行代码跑通单人姿态估计
拿到项目源码后,最常见的入口是build/examples/openpose/openpose.bin。先做一次最基本的推理验证环境是否通:
build/examples/openpose/openpose.bin \ --model_pose COCO \ --image_dir examples/media/ \ --write_json output/ \ --write_images output_vis/ \ --display 0 \ --render_pose 1 \ --net_resolution 656x368逐项解释各个参数:
--model_pose COCO指定使用 COCO 关键点定义,对应 18 个身体关键点。--image_dir指向输入图片目录,支持批量处理。--write_json把关键点坐标和置信度以 JSON 格式写入指定目录,后续动作识别直接消费这些 JSON。--write_images输出渲染了骨架的可视化图片,方便快速检查关键点是否对齐。--display 0关闭 GUI 窗口,在无显示器的服务器上必须加。--render_pose 1表示在输出图中画骨架;设为 0 则只输出数据不渲染,能省一点耗时。--net_resolution 656x368是网络的输入分辨率,w x h,必须是 16 的倍数。
跑完这一步,你会看到output/下每个图片对应一个 JSON,里面有people数组,每个人体包含pose_keypoints_2d数组,按x, y, confidence三元组排列。
2.4 用 Python 接口嵌入到自有服务
如果你不想依赖openpose.bin这个独立进程,而是把姿态估计嵌入到 Python 后端里,官方提供了 C++ 封装后的 Python 模块。一种常见的稳定做法是使用 OpenCV 读取帧序列,逐帧喂给openpose模块并收集 JSON 数据结构。示例代码如下:
import cv2 import openpose import json params = dict() params["model_folder"] = "./models/" params["net_resolution"] = "656x368" params["number_people_max"] = 1 op = openpose.OpenPose(params) cap = cv2.VideoCapture("demo.mp4") while cap.isOpened(): ret, frame = cap.read() if not ret: break keypoints, output_image = op.forward(frame, render=True) # keypoints: shape (num_people, 18, 3) 的 numpy 数组 if keypoints.shape[0] > 0: person = keypoints[0] nose = person[0] print(f"nose: ({nose[0]:.1f}, {nose[1]:.1f}) conf={nose[2]:.2f}")这段代码的逻辑是:打开视频文件,逐帧调用op.forward(),拿到所有人体的关键点数组。keypoints的维度是(人数, 关键点类型数, 3),最后一维的前两个分量是归一化到输入分辨率下的整数坐标,第三个分量是模型置信度。render=True会让返回值第二项带上骨架渲染图,方便保存或推流。
需要特别注意params里的number_people_max,设成 1 可以迫使 OpenPose 只返回最自信的那个人体,在单人动作识别场景里不仅能省掉匹配开销,还能减少误检抖动。
3. 从关键点到动作:规则式动作识别的工程实现
有了每帧的 18 个关键点,接下来要回答的核心问题是:怎么把这些坐标翻译成“举手”“弯腰”“下蹲”这类语义标签。先说不依赖任何训练数据的规则式方案,它特别适合动作类别少、约束场景明确的生产项目。
3.1 为什么先做规则式而不是直接训分类网络
很多人拿到关键点序列后第一反应是“堆一个 LSTM”。但冷静下来想一想,动作识别的落地场景往往是受限的:监控摄像头固定机位、工人站在工位前、患者在做康复训练。这些场景里,动作本身的几何特征非常强。
以“检测一个人是否举起右手”为例,规则只需要判断:右手腕关键点的 y 坐标是否显著小于右肩的 y 坐标,同时两者间距是否超过某个阈值。这类几何规则的计算延迟是纳秒级的,且不需要标注数据,可以在采集数据的同时并行开发,让项目先跑起来。
规则式的另一个优势是可解释。医疗康复或安全生产场景里,监管方会问“为什么判断为异常动作”,规则引擎可以直接给出“右肘角度 162 度,超出 150 度阈值”这样的量化依据,而神经网络只能给出一堆特征权重。
当然,规则式方案的代价是泛化能力弱,不同人的身高臂长差异需要做归一化。下面给出一种标准解法。
3.2 关键点归一化:让规则对身高和画面距离不敏感
OpenPose 输出的坐标是像素值,同样一个“弯腰”动作,身高 160cm 和 190cm 的人在手肘弯曲角度上差异不大,但手腕关键点的绝对像素位移差异很大。因此规则引擎的输入不应是原始坐标,而是归一化后的相对特征。
最常用的归一化基准是脊柱长度——即左肩和右髋之间的距离。这个距离定义了“这个人的骨架尺度”,所有其他坐标都除以它,就能得到一个尺度无关的骨架描述。
import numpy as np def normalize_skeleton(pose): """ 输入 pose: (18, 3) 数组,COCO关键点顺序 返回 scale_skeleton: (18, 2) 归一化后的 x,y 坐标 """ # COCO 索引: 2=右耳, 5=左肩, 8=右髋 left_shoulder = pose[5][:2] right_hip = pose[8][:2] spine_len = np.linalg.norm(left_shoulder - right_hip) + 1e-6 scale_skeleton = pose[:, :2] / spine_len return scale_skeleton3.3 如何设计动作规则的阈值体系
基于归一化骨架,就可以定义关键动作的判定规则。以“检测下蹲”为例,需要同时看两个关节角度:
这种判定需要两个条件同时满足才判定为下蹲。条件一是髋关节角度,条件二是膝关节角度。
def angle_between(p1, p2, p3): """计算 p2 处的夹角(度),p1、p3 是相邻点""" a = np.array(p1) - np.array(p2) b = np.array(p3) - np.array(p2) cos_a = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-6) return np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0))) def is_squat(pose, hip_angle_max=100, knee_angle_max=110): # COCO: 8=右髋, 9=右膝, 10=右踝 hip = angle_between(pose[5][:2], pose[8][:2], pose[9][:2]) knee = angle_between(pose[8][:2], pose[9][:2], pose[10][:2]) return hip < hip_angle_max and knee < knee_angle_max参数hip_angle_max=100的意思是:当髋关节弯曲到 100 度以内时,认为躯干已有明显前倾;knee_angle_max=110同理,膝关节弯曲超过 110 度意味着大腿和小腿折叠明显。两个阈值都可以根据实际观测的机位和人的体型微调,但一般不要设成更宽松的值,否则“半蹲”和“弯腰拾物”容易混淆。
注意:实际项目中,姿态估计的关键点带有抖动,直接对单帧做阈值判定会产生大量毛刺。常见的做法是加一个时序平滑层,最轻量的方法是滑动窗口投票——取最近 5 帧里
is_squat的众数作为当前判定结果。
3.4 规则式动作识别为何在真实场景中失败
最常见的失败模式不是规则写得不对,而是输入的关键点本身质量差。OpenPose 处理侧身或背对镜头的人时,自遮挡会导致肩、髋关键点置信度跌破 0.3,而我们的归一化代码里用了这些低置信度的点做分母和角度基准,噪声会被放大。
一个强制的保护措施是置信度过滤:
conf_threshold = 0.3 valid_indices = pose[:, 2] > conf_threshold if not valid_indices[[5, 8, 9, 10]].all(): return None # 关键点不足,跳过该帧另外,画面里出现多个人时,OpenPose 返回的 person 顺序不保证稳定,同一人可能在不同帧中被分配到不同索引。如果你的场景是固定单人,用number_people_max=1即可规避;如果是多人,就要引入追踪算法(如按关键点中心点做最近邻匹配)来保持人物 ID。
4. 用关键点序列训练动作分类模型:LSTM 与时空图网络
规则式方案做到一定程度就会遇到天花板:动作边界模糊(走和跑)、动作组合复杂(太极的云手)、个体差异极大(儿童和成人做同一动作)时,手工调的规则很难继续提升。这时候就需要切换范式,让模型自己从关键点时序里学习动作的时空特征。
4.1 为什么选择关键点序列作为模型输入,而不是原始视频
一个常被忽略的事实:姿态估计已经把原始视频压缩了大约两个数量级。以 640x360 分辨率 30fps 的视频为例,单帧数据量约 691KB;而 18 个关键点(x, y, confidence)的单帧数据量是 54 个 float,约 216B。这种压缩不仅让模型规模可以很小,也把训练从“图像分类”问题降维成了“序列拟合”问题,数据需求大幅减小。
另一个优势是隐私保护。很多工厂和医院场景不允许把原始视频传到云端训练,但关键点坐标是脱敏数据,可以直接上传到云端做模型迭代,合规成本低很多。
4.2 构造训练数据:从 OpenPose JSON 到样本序列
训练样本的构造逻辑是:把一段连续视频帧的骨架堆叠成张量。通常取 30 帧(1 秒)为窗口,窗口重叠 15 帧做滑窗采样,得到大量样本。
import json import numpy as np import os def load_json_to_sequence(json_dir, window_size=30, stride=15): files = sorted(os.listdir(json_dir)) all_frames = [] for f in files: with open(os.path.join(json_dir, f)) as fp: data = json.load(fp) if len(data["people"]) == 0: all_frames.append(np.zeros((18, 3), dtype=np.float32)) else: kp = np.array(data["people"][0]["pose_keypoints_2d"]).reshape(-1, 3) all_frames.append(kp) sequences = [] for start in range(0, len(all_frames) - window_size + 1, stride): seq = np.stack(all_frames[start:start+window_size], axis=0) # (30, 18, 3) sequences.append(seq) return np.array(sequences)window_size=30表示输入 30 帧,大约 1 秒动作;stride=15让相邻样本有 50% 重复,能平滑过拟合。pose_keypoints_2d被 reshape 成(18, 3),第三个通道是置信度。关键点缺失(OpenPose 没检测到)时用全零占位,模型需要学会忽略这些帧。
4.3 一个极简的 LSTM 分类模型
对于 30 帧的骨架序列,一个单层 LSTM 往往就够用了,因为序列很短,且每帧特征维度也只有 36(18 点 x 2 坐标或再加置信度)。
import torch import torch.nn as nn class SkeletonLSTM(nn.Module): def __init__(self, input_dim=36, hidden_dim=64, num_classes=5): super().__init__() self.lstm = nn.LSTM(input_dim, hidden_dim, batch_first=True, num_layers=1) self.classifier = nn.Linear(hidden_dim, num_classes) def forward(self, x): # x: (batch, seq_len=30, input_dim=36) out, _ = self.lstm(x) out = out[:, -1, :] # 取最后时间步 return self.classifier(out)这里的input_dim=36是把 x 和 y 坐标拼接,丢弃置信度,用置信度做 mask 需要额外处理,初级项目直接拼接坐标最简单。hidden_dim=64的 LSTM 对 30 步序列有足够容量。取最后时间步的输出作为整段序列的表示,再加一个线性层做分类。
4.4 训练策略与特征顺序的影响
训练这个模型时要注意,骨架数据做归一化是必须的,否则不同身高的人在坐标空间里差异过大,LSTM 很难收敛。推荐的预处理顺序是:先用 3.2 节的方法对每一帧做尺度归一化,再对每个关键点做 z-score 标准化(按训练集的均值方差)。
另一个容易踩的坑是:不要把面朝左和面朝右的动作合并训练。骨架坐标是绝对的,一个人面向左“挥手”和面向右“挥手”,x 坐标序列基本互为镜像,如果标签只有“挥手”一个类,模型会学到“x 坐标减小等于挥手”,导致泛化崩溃。常见做法是在训练时做水平翻转数据增强:把 x 坐标映射为 width - x,标签不变。
这样训练出来的模型在测试集上的准确率通常能到 90% 以上,但实际部署到新环境时可能会有明显掉点,主要原因是 OpenPose 在训练集和使用环境里的检测误差分布不同。
5. 用关键点差分做动作边界检测:一个高性价比技巧
训练完分类模型后,有一个问题很少有人提前想清楚:一段连续的视频里,动作什么时候开始、什么时候结束?如果模型每 30 帧滑动一次分类,你会在两个动作的过渡期得到一堆乱序标签。
5.1 关键点位移差分的定量表达
一个有效的边界检测信号是“关键点位移能量”。对连续两帧的同一个关键点求欧氏距离,把所有关键点的距离累加,得到一个标量值,描述这一帧的瞬时运动强度。动作开始前,人体是静止的,能量趋于零;动作进行中,能量显著上升;动作结束回到静止,能量回落。
def motion_energy(pose_t1, pose_t2): """ 输入两帧关键点 (18, 2),输出一个标量表示运动强度 """ sq_diff = (pose_t1 - pose_t2) ** 2 dist = np.sqrt(np.sum(sq_diff, axis=1)) # 过滤掉置信度低的点,这里假定输入已做过置信度过滤 return np.mean(dist)np.mean(dist)而非np.sum(dist),是为了让人数不同时的能量值可比;动作识别场景中单人检测,两者差别不大。也可以做加权平均,给手腕和脚踝更大的权重,因为这些远端点的运动幅度最能反映意图。
5.2 双阈值判定与静默期合并
单阈值判定边界会产生抖动:在零点和峰值之间反复横跳。更好的方案是双阈值状态机。
def segment_by_energy(energy_list, hi=0.8, lo=0.3, min_duration=5): state = "idle" segments = [] start = 0 for i, e in enumerate(energy_list): if state == "idle" and e > hi: state = "active" start = i elif state == "active" and e < lo: state = "idle" if i - start >= min_duration: segments.append((start, i)) return segmentshi=0.8是进入动作态的触发阈值,必须选得足够高,确保不是噪声引起的误触发;lo=0.3是退出动作态的释放阈值,小于进入阈值以形成滞后,避免在阈值附近震荡。min_duration=5过滤掉少于 5 帧的瞬时闪烁,这类闪烁通常是姿态估计抖动造成的。动作低谷时的短暂停顿(如举重时的保持)可能会被误切为两个动作,需要根据具体业务设置最大暂停长度来合并。
5.3 把边界分割接入完整推理链路
联调时的流程是:读视频帧 -> OpenPose 推理获取关键点 -> 计算相邻帧运动能量 -> 用双阈值切出动作片段 -> 对片段内的每 30 帧窗口用 LSTM 分类 -> 对片段内多个窗口的分类做投票,得到该动作片段的最终标签。
这套流程的关键收益在于:分类器不再需要每帧都做,只在边界检测判定为“动作进行中”时才调用 LSTM,CPU 占用大幅下降。在树莓派或边缘盒子上,这种优化往往比换轻量级姿态模型更有效。
5.4 一个工程上的补充做法:用置信度辅助边界判断
运动能量在跟丢目标时会产生较大的失真。如果 OpenPose 出现多帧关键点置信度全部低于阈值但检测程序仍输出垃圾坐标,能量信号就会异常放大,出现假边界。所以实践里我会把上一节的能量公式升级为带置信度门控的版本:
def motion_energy_conf(pose_t1, pose_t2, conf_t2, conf_th=0.3): mask = conf_t2 >= conf_th if mask.sum() < 6: return 0.0 # 有效关键点太少,视为静止 dist = np.sqrt(((pose_t1 - pose_t2) ** 2).sum(axis=1)) return dist[mask].mean()这个改动在工程上很关键。它不会让你拿 6 个点以下的帧直接报错,而是视作无效帧返回零能量,让后续状态机停留在当前状态。从视频流稳定性角度看,这样就避免了丢失一帧导致整个动作被切断的尴尬情况。动作识别系统的最后一公里往往不在模型结构,而在这种异常信号的兜底设计上。
本文还有配套的精品资源,点击获取