人体动作识别工程实践:骨架序列与时空图卷积解析 简介这是一套基于Python深度学习的先进人体动作识别源码包面向动作识别、计算机视觉方向的开发者与研究者可应用于安全监控、体育分析、虚拟现实交互等需动作分析的场景。压缩包共44个文件核心为25个Python源码文件另含6张图像、5个文本说明、训练模型、界面与字体图标等资源整体约1.91MB结构清晰。代码覆盖YOLO目标检测、人体姿态估计、关键帧提取、特征提取以及可视化界面等完整流程并配有模型文件、依赖列表和操作脚本便于直接运行调试与二次开发。该方案以深度学习框架驱动兼顾视频处理、图像保存与批量文件操作采用模块化设计方便按需调用适合从算法原理到工程落地逐步学习。目前已有421人学习是上手人体动作识别项目时可参考的完整示例。1. 人体动作识别源码工程拆开看其实是「骨架序列 图卷积」看到一个带「人体动作识别」字样的 Python 深度学习源码工程先别急着跑 demo。这个方向的验收标准和普通图像分类完全不同图像分类只需要认出一帧画面动作识别要把一段连续时间里的姿态变化串起来才能判断「这个人是在走路还是摔倒」。基于 Python 的深度学习人体动作识别工程最常采用的落地形态是先把人变成骨架关键点序列再用时空图卷积模型做分类。它能解决的是监控场景跌倒检测、健身动作计数、康复训练评估这类问题适合想快速评估动作识别方案的算法工程师也适合从零开始搭第一个视频理解项目的 Python 开发者。源码工程本身不复杂复杂的是数据链路和训练参数里的坑。2. 为什么骨架方案成为主流三条技术路线的选择逻辑2.1 直接拿 CNN 逐个分析帧最容易翻车的路线很多从「动手深度学习」过来的读者动手做的第一个动作识别方案就是把视频帧逐张送进 2D-CNN把最后一层的特征向量接一个 LSTM 或者 Transformer。这条路线不是不能跑只是落地的时候非常难受。CNN 提取的是整张图的特征背景、光照、衣服颜色全部混在里面同样的动作换个背景、换套衣服特征分布就变了模型在验证集上精度不错一到实际场景就衰减。在现网数据里一个「举手」动作的训练样本是办公环境推理环境是监控走廊背景差异直接让准确率从九成掉到六成多这就是典型的 CNN 路线翻车现场。2D-CNNLSTM 另一个问题是时序建模太粗。LSTM 看的是 CNN 特征序列但 CNN 特征里「手的位置变化」和「背景光线的变化」是耦合在一起的时序模块很难区分哪些变化是动作相关的。处理这类问题的常见后续手段是引入注意力机制把特征维度压到关键区域上但注意力图本身依赖数据分布本质上没有解决关键点丢失的问题。2.2 骨架关键点 时空图卷积动作识别源码最常选的技术骨架相比逐帧 CNN骨架路线从一开始就把外观信息剥离掉。输入变成一组关键点坐标比如 COCO-17 格式的 17 个关节点鼻子、眼睛、肩膀、肘、腕、髋、膝、踝每个点带 x、y 坐标和置信度。模型要学的是这些点在时间维度上的相对位移模式。衣服颜色、背景纹理、光照强弱这些干扰从输入端就被丢弃了模型的泛化能力自然更强。骨架序列是图结构不是网格结构。17 个关节点之间的连接关系定义了一个人体拓扑图肘连着腕、膝连着踝这种「邻居关系」是 CNN 的卷积核表达不了的。用图卷积做空间编码让每个关节点的特征沿着骨架边聚合到相邻节点再用时间卷积沿着帧维度建模动作变化这就是 ST-GCN时空图卷积网络的核心思想。过去几年很多人体动作识别的开源工程背地里走的都是这条路线严格说「先进」不在于网络多深而在于把动作识别从图像识别问题正确转化成了图序列分类问题。2.3 常见的数据集基线与公开标准评估指标怎么对齐拿到一套动作识别源码第一步要弄清它是在什么数据集上设计的。开源工程里出现频率最高的是 NTU RGBD 和 Kinetics-Skeleton。NTU RGBD 是 Kinect 采集的室内动作数据集60 类动作包含单人动作和双人交互官方给出了两套标准评估协议X-Sub 按被试者 ID 划分训练测试集X-View 按相机视角划分。Kinetics-Skeleton 是从视频网站短视频里提取的骨架序列类别覆盖日常动作、运动、手工艺等样本量是十万级短视频片段类别多、场景杂对模型的泛化要求更高。读到源码里的准确率数字时先确认它对应哪个数据集和哪个评估协议。同一个模型在 NTU-60 X-Sub 上能做到 85% 以上在 Kinetics-Skeleton 上可能只有 30% 多因为后者类别多、帧内动作变化大两者完全不可比。做自己的数据时先把评估协议对齐到公开协议上再去比较模型效果否则任何对比都是无意义的。数据集模态类别数常用评估协议适用场景NTU RGBDRGB/深度/3D骨架60X-Sub、X-View室内动作、学术对比Kinetics-Skeleton2D骨架序列400Top-1/Top-5通用动作、预训练自建业务数据2D骨架/3D骨架定制按人划分/按场景划分业务落地3. 从视频到骨架序列完整的数据预处理链路3.1 用 MMPose 批量提取视频骨架最小可用的提取脚本动作识别源码里骨架数据一般不是自己标注的而是用姿态估计模型自动提取的。常用工具是 OpenMMLab 的 MMPose它提供现成的检测器和关键点模型可以基于 COCO 权重直接提取 2D 骨架。下面是提取单段视频骨架序列的脚本把视频帧逐张送入检测器找人体框再送入关键点模型得到 17 个关节点的坐标和置信度。import mmcv import numpy as np from mmpose.apis import inference_topdown, init_model from mmdet.apis import inference_detector, init_detector # 替换为你本地下载好的配置与权重路径mmpose 1.x mmdet 3.x 组合 det_cfg /path/to/rtmdet_m_640-8xb32-1x_coco.py det_ckpt /path/to/rtmdet_m.pth pose_cfg /path/to/td-hm_hrnet-w32_8xb64-210e_coco-256x192.py pose_ckpt /path/to/hrnet_w32.pth det_model init_detector(det_cfg, det_ckpt, devicecuda:0) pose_model init_model(pose_cfg, pose_ckpt, devicecuda:0) def extract_skeleton_seq(video_path, max_frames300): frames mmcv.VideoReader(video_path) total min(len(frames), max_frames) skeleton_seq [] for frame in frames[:total]: # 检测人体框取最大面积的人避免多目标干扰 result inference_detector(det_model, frame) bboxes result.pred_instances.bboxes.cpu().numpy() scores result.pred_instances.scores.cpu().numpy() if len(bboxes) 0: skeleton_seq.append(np.zeros((17, 3))) # 缺帧补零 continue areas (bboxes[:, 2] - bboxes[:, 0]) * (bboxes[:, 3] - bboxes[:, 1]) main_person bboxes[areas.argmax()] pose_result inference_topdown(pose_model, frame, main_person) keypoints pose_result[0][keypoints].cpu().numpy() # (17, 3) skeleton_seq.append(keypoints) return np.stack(skeleton_seq, axis0) # (T, 17, 3)这里的代码逻辑是逐帧提取inference_detector找出画面里的所有人按框面积选最大的人作为主目标目的是避免画面边缘的次要人物干扰动作分类。inference_topdown是 top-down 姿态估计流程先有人体框再做关键点定位精度比自底向上方案高代价是速度慢一些。在 GPU 上RTMDet-m 加 HRNet-w32 的组合大约能达到每帧 15-25 ms 的处理速度离线批量提取一段 300 帧的视频约 5-8 秒。姿态估计模型最好选带置信度输出的版本比如 HRNet。置信度会在后面作为模型的第三通道输入能够告诉分类模型「这个关键点到底可不可靠」。如果画面里人多、遮挡频繁置信度通道的价值会非常明显。3.2 归一化与帧长度统一让输入和模型预设对齐提取出来的原始关键点坐标是像素值直接送进模型有几个问题。第一个问题是尺度不一致同样一个「挥手」动作离镜头近的人关键点位移大离镜头远的人位移小模型会被尺度带偏。第二个问题是帧数不一致一段视频 200 帧另一段 50 帧模型的时间卷积层无法处理变长输入。所以要把骨架序列做归一化和帧长度统一。def normalize_skeleton(seq): 以左右肩中心为原点肩宽为尺度做平移缩放归一化 left_shoulder seq[:, 5, :2] # COCO-17 索引5为左肩 right_shoulder seq[:, 6, :2] # 索引6为右肩 center (left_shoulder right_shoulder) / 2.0 scale np.linalg.norm(left_shoulder - right_shoulder, axis1, keepdimsTrue) 1e-6 seq[:, :, :2] (seq[:, :, :2] - center[:, None, :]) / scale[:, None, :] return seq def pad_or_crop(seq, target_frames150): t seq.shape[0] if t target_frames: # 均匀采样到目标帧数避免只取前段或后段丢失动作信息 idx np.linspace(0, t - 1, target_frames).astype(int) return seq[idx] padded np.zeros((target_frames, seq.shape[1], seq.shape[2])) padded[:t] seq return padded归一化的关键参数是参考点和尺度。参考点选左右肩中心而不是骨盆中心原因是上半身的骨架检测稳定性高于下半身髋关节在遮挡场景下容易丢失。尺度用肩宽不同体型的肩宽差异小归一化后同类动作的关节坐标分布更紧凑。这里没有把高度也纳入归一化因为某些动作如弯腰捡东西本身需要保留竖直方向的信息。帧长度统一上我用了两种策略拼接超过目标帧数就均匀采样不足就尾部补零。均匀采样比直接截取前 N 帧好因为动作的高潮可能出现在视频中段或尾部直接截断会损失关键信息。补零之后要在训练时配合 mask 使用否则模型会把补零帧当作静止动作来学习。多数开源工程里的做法是让target_frames取训练集视频长度的中位数一般 100-200 帧之间太长会拖慢训练太短会损失长周期动作的完整度。3.3 骨架数据增强源码里最容易被删掉却又最管用的部分骨架数据增强是很多开源工程里存在但默认关闭的部分原因是在小数据集上增强过头反而掉点。实际做下来三个增强操作是安全的时间缩放、关节抖动、水平翻转。水平翻转要特别注意关键点序号的左右对换COCO-17 里 5 号左肩和 6 号右肩必须互换否则模型会学到「翻转后左手变成了右手」的错误映射。时间缩放的实现是随机选一个 0.8-1.2 的缩放系数把骨架序列的时间轴做插值重采样。这相当于在时间维度上做数据扩充让模型对动作快慢不敏感。关节抖动是给每个关键点坐标加一个标准差为 0.02 的高斯噪声模拟姿态估计模型的输出误差能明显提升模型对检测噪声的鲁棒性。水平和垂直翻转同样需要左右关键点互换同时翻转 x 坐标x 1 - x。4. 最小复现工程从图卷积到可以跑的推理脚本4.1 邻接矩阵与图卷积的代码实现骨架图卷积的核心是把人体拓扑结构编码成邻接矩阵然后在矩阵乘法中进行特征聚合。17 个关节点之间的连接关系如下鼻子连两只眼睛眼睛连耳朵鼻子连左右肩肩连同侧肘肘连腕肩连同侧髋髋连同侧膝膝连踝。代码里需要手动定义这个连接结构再构建对称邻接矩阵。import torch import torch.nn as nn import numpy as np # COCO-17 关键点连接关系0鼻子,1左眼,2右眼,3左耳,4右耳, # 5左肩,6右肩,7左肘,8右肘,9左腕,10右腕, # 11左髋,12右髋,13左膝,14右膝,15左踝,16右踝 joint_edges [ (0, 1), (0, 2), (1, 3), (2, 4), (0, 5), (0, 6), (5, 7), (7, 9), (6, 8), (8, 10), (5, 11), (11, 13), (13, 15), (6, 12), (12, 14), (14, 16) ] def build_adjacency(num_joints17): A np.zeros((num_joints, num_joints), dtypenp.float32) for i, j in joint_edges: A[i, j] 1.0 A[j, i] 1.0 A A np.eye(num_joints) # 加自环让节点保留自身特征 D np.diag(A.sum(axis1)) A_hat np.dot(np.linalg.inv(np.sqrt(D)), np.dot(A, np.linalg.inv(np.sqrt(D)))) return torch.FloatTensor(A_hat) # (V, V)这里做的是对称归一化公式是 D^{-1/2} A D^{-1/2}。归一化的存在与否直接影响训练能否收敛。如果用原始邻接矩阵做矩阵乘法度大的节点比如鼻子、肩膀这种连接数多的关节特征幅度会成倍增长多层叠加之后数值爆炸loss 直接变成 NaN。对称归一化让每行求和保持在 1 左右的量级特征传播幅度就稳定了。邻接矩阵要在模型初始化时构建一次不要在每次 forward 里重建。源代码里通常会把 A 用nn.Parameter包起来并允许训练让模型在训练过程中微调骨架连接权重这种做法在数据量充足时有用。最小复现版本里我把它设置成requires_gradFalse用固定的拓扑结构先跑通流程再考虑加权。4.2 时空图卷积块空间聚合与时间卷积的组装有了邻接矩阵下一步把它写进一个可复用的图卷积块。这个块做的事情有两步空间维度上用邻接矩阵做图卷积时间维度上用一维卷积对帧序列建模。输入的张量形状是(N, C, T, V)N 是批量大小C 是特征通道初始为 3对应 x、y、置信度T 是帧数V 是关键点数。class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A, stride1): super().__init__() self.A nn.Parameter(A, requires_gradFalse) # 固定拓扑 self.temporal_conv nn.Conv2d( in_channels, out_channels, kernel_size(9, 1), padding(4, 0), stride(stride, 1) ) self.spatial_conv nn.Conv2d(out_channels, out_channels, kernel_size1) self.bn nn.BatchNorm2d(out_channels) self.relu nn.LeakyReLU(0.1) def forward(self, x): # x: (N, C, T, V) N, C, T, V x.shape A_hat self.A / (self.A.sum(dim1, keepdimTrue) 1e-6) # 行归一化保护 # 特征置换为 (N, C*T, V)在关节维度做图卷积 x x.permute(0, 2, 3, 1).reshape(N, T * V, C) # 邻接矩阵作用于关节维度 x torch.einsum(ntv,vw-ntw, x, A_hat.to(x.device)) x x.reshape(N, T, V, C).permute(0, 3, 1, 2).contiguous() # 时间卷积对帧维度做 9 帧窗口的卷积 x self.temporal_conv(x) x self.spatial_conv(x) return self.relu(self.bn(x))时间卷积用kernel_size(9, 1)含义是只沿时间维度看 9 帧的窗口空间维度用 1 表示不加卷积因为空间信息已经在图卷积那一步处理完了。padding(4, 0)保证时间维度长度不变。strided convolution 配合 pool 层可以让时序维度逐渐压缩常见做法的网络结构是 9-10 个这样的 block通道数从 64 到 128 再到 256 逐步增加每次通道翻倍时时间维度减半最后做全局平均池化得到特征向量接全连接分类层。这里的einsum写法容易踩坑。torch.einsum(ntv,vw-ntw, x, A_hat)的意思是x 的形状是 (N, T, V)邻接矩阵 A_hat 形状 (V, V)对每个 batch 和每个时间帧在关节维度 V 上做矩阵乘法。这个操作等同于把图卷积展开成矩阵乘法GPU 上效率很高。注意代码开头先做了行归一化self.A / (self.A.sum(dim1, keepdimTrue))这是双保险防止预设邻接矩阵在一些边界条件下出现行和大于 1 的情况数值上更稳定。4.3 加载权重与推理一套可以直接跑的预测脚本组装完模型之后需要把训练好的权重加载进来做推理。下面的脚本展示了如何加载一个在 NTU-60 上训练好的权重文件对预处理后的骨架序列输出 60 类的概率分布。权重文件本身放在源码目录的checkpoints/下面加载时用load_state_dict配合strictTrue参数名对不上会直接报错这是排查权重不匹配最快的方式。import torch def load_stgcn_model(ckpt_path, num_classes60, in_channels3): A build_adjacency(num_joints17) model STGCNWithPool(num_classesnum_classes, in_channelsin_channels, AA) state torch.load(ckpt_path, map_locationcpu) # 兼容不同保存格式优先取 model_state_dict 键 if model_state_dict in state: model.load_state_dict(state[model_state_dict], strictTrue) else: model.load_state_dict(state, strictTrue) model.eval() return model.to(cuda:0) def predict(model, skeleton_seq, devicecuda:0): # skeleton_seq: (T, 17, 3) - (1, 3, T, 17) x torch.FloatTensor(skeleton_seq).permute(2, 0, 1).unsqueeze(0).to(device) with torch.no_grad(): logits model(x) prob torch.softmax(logits, dim-1) top3_idx prob.topk(3).indices.squeeze().cpu().tolist() top3_prob prob.topk(3).values.squeeze().cpu().tolist() return top3_idx, top3_prob model load_stgcn_model(checkpoints/stgcn_ntu60_xsub.pth) seq_norm normalize_skeleton(load_sequence(sample_action.npy)) seq_padded pad_or_crop(seq_norm, target_frames150) idx, prob predict(model, seq_padded) print(Top-3 类别编号:, idx, 概率:, [round(p, 4) for p in prob])输出是类别编号和概率类别编号对应训练时的类别列表需要把class_names.txt里的名称按顺序读进来才能映射成可读的动作名。推理时模型处于eval()模式no_grad()关闭梯度计算。这两件不做显存占用会翻倍速度也会慢很多。加载权重失败时优先看报错里提到的键名差异常见情况是训练脚本里给模型包了一层nn.DataParallel权重所有键都带module.前缀换个方式加载state {k.replace(module., ): v for k, v in state.items()}。5. 训练微调与避坑记录从 loss 异常到数据泄漏5.1 训练超参与损失函数先跑通再调优训练一个骨架动作识别模型起点是交叉熵损失。类别不平衡时给损失函数加类别权重torch.nn.CrossEntropyLoss(weightclass_weight)class_weight用训练集类别频率的倒数做平滑。优化器常见选择是 SGD with Momentum 或 AdamW两者在当前任务上的差距不大。数据量小几千段骨架用 SGD 更稳数据量大十万级以上用 AdamW 收敛更快。我常用的超参组合是这样的batch size 32训练 60 个 epoch初始学习率 1e-3AdamW或 1e-2SGD带 5 个 epoch 的 warmup之后 cosine annealing 衰减到 1e-5。weight decay 用 1e-4 防止过拟合。label smoothing 设 0.1对噪声标注有缓冲作用尤其是在自动提取骨架的场景下类别边界本身有误差。训练时按每 5 个 epoch 在验证集上算一次 Top-1 准确率和加权 F1这两个指标一起看只盯 accuracy 在类别不平衡时会骗人。5.2 训练 loss 不降反升先检查归一化而不是换模型现象训练第一个 epoch loss 就在 5.0 附近波动到第三个 epoch 变成 7.0完全看不到下降趋势。原因最常见的是输入张量的数值范围不对。原始像素坐标送进模型数值范围是 0-1000 甚至更大经过图卷积和 BatchNorm 之后梯度更新被数值异常主导。另一类常见原因是邻接矩阵行归一化丢失导致特征经过多层图卷积后数值指数级膨胀。解决骨架输入必须做尺度归一化把坐标范围压到 -1 到 1 附近。检查方法是在训练脚本里打印第一个 batch 的输入统计值x.mean()和x.std()正常范围应该分别在 0 左右和 0.5 以下。如果均值偏差过大说明归一化代码没有生效或者对测试数据的处理和对训练数据的处理不一致。归一化参数参考点和尺度要从训练集统计不能从单段测试序列里临时计算。5.3 验证集高、现网低的经典落差数据划分泄漏了人物 ID现象在自建数据集上验证集 Top-1 到了 88%部署到生产环境识别率只有 60% 出头且错误集中在特定某个人的动作上。原因划分训练集和验证集时按片段随机切分同一个人的不同动作片段被同时分到了两端。模型记住了「这个人的骨架特征」而不是「这个动作的模式」验证时遇到同一个人的新片段相当于开卷考试。这是骨架动作识别最典型的数据泄漏。换个说法按视频文件随机分训练集和验证集可能来自同一段视频的相邻帧。解决划分数据集时一定要按人物 ID 分同一人的全部片段进训练集或验证集两者只能留一份。公开数据集 NTU-60 的 X-Sub 协议就是这个思路按受试者编号划分。自建数据时给数据打上人物 ID 标签用sklearn.model_selection.GroupShuffleSplit按组划分。划分完了再检查一遍每个动作类别在两个集合里都有充足的样本。5.4 多人场景下预测结果抖动每个人都要有独立的时序上下文现象监控画面里两个人并肩走模型输出的动作类别在两三个类别之间来回跳单帧上看每类概率都不低。原因数据预处理里每次都只选面积最大的人但面积最大不是稳定的跟踪策略。两个人擦肩而过时检测框面积交替领先框的归属不停切换框架到一半从头开始时序信息全断了。解决在数据链路里加一个简单的人体跟踪 ID。离线提取骨架时先做一次多目标跟踪用 ByteTrack 这类轻量方案给每个目标分配固定 ID再按 ID 分组提取骨架序列。推理时对每个跟踪 ID 维护一个滑动窗口只在窗口内做类别概率平均能明显抑制单帧检测噪声造成的抖动。没有跟踪条件时退一步的方案是选画面中心点距离最近的人作为主目标比选最大面积略稳一点。5.5 微调预训练模型反而掉点冻结 BatchNorm 再试现象加载 Kinetics-Skeleton 预训练权重在自己的小数据集比如每个类别 50 段上微调 20 个 epoch验证精度反而低于从头训练。原因预训练模型的 BatchNorm 层统计量running mean 和 running var是基于大规模动作数据拟合的。小数据集微调时BN 层统计量被少量样本反复更新数值漂移严重。而 ST-GCN 的层数超过 9 层每层 BN 的漂移逐层累积最后模型直接失去泛化能力。解决微调前几个 epoch 冻结所有 BatchNorm 层让统计量保持预训练值for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval()注意这里的eval()只冻结 BN其他层仍在训练模式。训练 5 个 epoch 后再解冻 BN同时把学习率降为原来的 1/10。另外一个重要设置是初始学习率不要超过 1e-4预训练模型的大权重在小学习率下微调才稳定学习率大了反而把预训练学到的特征覆盖掉。6. 进阶部署技巧置信度平滑、指标对齐与模型导出骨架动作识别上线之前有一个容易被忽视的验证环节对输出的 softmax 概率做时序平滑。单帧姿态估计本身有噪声相邻两帧的模型输出可能跨越 70% 的置信度波动。用指数滑动平均EMA可以很好抑制这一现象smoothed 0.0 alpha 0.6 # 越大越平滑但滞后越大 for prob in prob_stream: smoothed alpha * smoothed (1 - alpha) * prob另一个值得固化的习惯是评估时必须同时看 Top-1 和 Macro-F1。骨架动作识别在类别不平衡的场景下跌倒、眩晕这类动作样本天然稀缺Top-1 被高频类别主导模型看似有 90% 的准确率实际对目标类别几乎不识别。制作混淆矩阵重点检查低频类别之间是否互相混淆。模型导出可以在验证后做。PyTorch 模型转 TorchScript 时输入张量的形状必须和训练时完全一致包括 T 帧数。导出的常见做法是用torch.jit.trace传一个固定形状的示例输入trace 得到的模型会自动固化输入尺度所以推理管线里 pad_or_crop 的目标帧数必须和 trace 时一致否则形状不匹配直接报错。如果要跨平台部署转 ONNX 也行注意时间卷积层的 padding 在旧版 ONNX 导出时可能出问题遇到问题优先升级 opset 版本到 13 以上。回滚模型最稳妥的方式是每个版本导出前把权重连同验证指标一起存一份标注训练数据协议和超参数。我之前就吃过一次亏新模型验证集高 2 个点上线后才发现是因为验证集划分漏了人物 ID真正的泛化能力还不如老模型那时已经跑了四天训练只能老实回去重来。希望这篇内容能帮你绕开这些坑把骨架动作识别的工程落地顺利一些。本文还有配套的精品资源点击获取