简介:这是一份面向计算机、数学、电子信息类专业学生及研究者的骨骼动作识别Python源码项目,基于时空图卷积网络(ST-GCN)实现从骨骼关键点序列到动作类别的端到端识别,可直接用于课程设计、期末大作业或毕业设计参考。整个压缩包共91个文件,约52.6MB,以Python源码(29个py)为主,辅以yaml配置文件、pyc预编译模块、pt模型权重、mp4/gif演示动画及md/txt说明文档,涵盖数据预处理、模型构建、训练与推理全流程,整体目录结构清晰,便于按模块查阅。目前已有275人学习下载。附带预训练模型与演示素材,便于快速验证效果;代码结构清晰,包含双流ST-GCN、实时演示等扩展,适合进阶调试与二次开发。下载解压即可运行,能帮助理解时空图卷积在动作识别中的实际应用,为论文写作与答辩提供有力支撑。
1. 骨骼动作识别为什么绕不开时空图卷积(ST-GCN):骨架是图,不是像素
你在检索栏里敲“动作识别”的时候,大概率会碰到这组关键词:时空图卷积(ST-GCN)、骨骼动作识别、python源码。骨架方案这几年之所以能成为动作识别的主流路子之一,核心就一句话:人体关键点坐标天然不受光照、衣着和背景的干扰。基于时空图卷积(ST-GCN)的骨骼动作识别,把每一帧的人体连接关系当成一张图,用图卷积在关节之间交换特征,时间维度再用卷积并行扫过。整套思路比LSTM直观,比直接拿CNN处理关节坐标要贴切得多。市面上用python源码跑的ST-GCN毕业设计项目很常见,结构清晰、改造成本低,适合做行为分析、康复评估和运动姿态校验。这篇笔记会按落地顺序把图怎么建、卷积怎么算、代码怎么跑、参数怎么调讲透,最后给一条验证习惯,能帮你少走不少弯路。
2. 从骨骼数据到时空图卷积:图怎么建、卷积怎么算、分区策略为什么决定精度上限
这一章先把原理讲透。很多源码包拿到手就能训练,但如果不知道图是怎么构造的,后面换数据集、调分支、调精度会处处受制。
2.1 骨架数据的本质:一张写好人际拓扑的图
骨骼动作识别的输入是一段时序骨架序列:总共有 T 帧,每一帧有 V 个关键点,每个关键点是 C 维坐标。NTU RGB+D 数据集每帧 25 个关键点,覆盖头、颈、双肩、双肘、双腕、双手、躯干、双髋、双膝、双踝和双脚;Kinetics 上常用的 COCO 格式是 18 个点。节点之间有天然的物理连接——脊柱连肩膀,肩膀连手肘,手肘连手腕。这种连接关系本身就是先验知识。
如果用图像 CNN 来处理,就得把 25 个关键点拉平成一个向量。问题是卷积核在空间上共享权重,而拉平之后“左肘”和“右肘”明明是不同的关节,却会被同一个位置的卷积权重处理,等于把人体左右对称结构当成平移不变性来学,前置知识完全错配。RNN/LSTM 能建模时序,但沿帧方向串行计算,前面帧的信息很难有效地流到后面关键时刻,训练速度也慢。
ST-GCN 的做法是把骨架数据建模成图:节点集合就是 V 个关键点,空间边 Es 是人体骨骼连接,时间边 Et 是同一个关键点在相邻帧之间的对应连接。输入张量组织成 (C, T, V) 三个维度,C=3 时分别存 x、y、z 坐标。图卷积沿 Es 在关节之间传递特征,时间卷积沿 Et 在帧与帧之间建模运动,两者各管各的,backbone 结构非常干净。
2.2 空间图卷积的计算:邻接矩阵、自环和度归一化
图卷积的一层传播公式可以写成:
f_out = D^(-1/2) (A + I) D^(-1/2) f_in W
其中 A 是邻接矩阵,A[i][j] = 1 表示关节 i 和关节 j 之间有骨骼连接;I 是单位矩阵,用来给每个节点加自环,这样节点在一次更新里既能聚合邻居信息,也能保留自身特征;D 是度矩阵,D[i][i] 等于节点 i 的邻居数量(加上自环后要 +1);W 是这层卷积的可学习权重。
这个公式里最关键的是度归一化。颈部、髋部这类中心关节邻居多,聚合之后特征范数天然偏大;手脚这类末端关节邻居少,特征会偏小。如果不做归一化,模型学出来的特征尺度会被中心关节点带偏,训练过程容易不稳。D^(-1/2) A D^(-1/2) 是对称归一化,比直接除以度数值更平滑,这也是图神经网络里的标准做法。
但 ST-GCN 没有止步于对整张图做一次卷积。它把邻接矩阵按分区策略拆成多个子矩阵 A_1, A_2, ..., A_k,每一份子矩阵配一个独立的权重 W_k,最后把 k 个分支的结果加起来。这样做等于让同一个模型拥有多种“关系视角”:一个视角看自己,一个视角看邻居,一个视角看邻居相对身体重心的方向。参数多了,表达能力也上去了。
2.3 三种分区策略:为什么空间构型划分最常用
ST-GCN 论文里提出了三种分区策略,源码里都有现成实现,但选哪个直接决定模型上限。
单一标签分区(Uni-labeling):所有邻居节点加上自己归为一类,k=1,相当于全局共享权重的最简图卷积。优点是参数最少、不容易过拟合;缺点很明显:模型分不清邻居是靠近身体还是远离身体,对“挥手”和“放下手”这类方向敏感的动作几乎要靠时序分支硬猜。
距离划分(Distance partitioning):根节点自己一类,1-hop 邻居一类,k=2。模型能区分“自己”和“别人”,但还是分不清这个邻居是向心还是离心,对肢体相对躯干的运动方向仍然不敏感。
空间构型划分(Spatial configuration partitioning):以骨架重心为参考点,把邻居节点分成向心(离重心更近)和离心(离重心更远)两类,根节点自己单独一类,一共 k=3 类。这是动作识别场景里最常用的策略,因为“手向身体移动”和“手离开身体”是挥拳、举手、投掷这类动作的核心判据。三个子矩阵分别建模三个语义,最后一层相加,代价是权重数量变成三份。
完整的 ST-GCN backbone 是 9 个时空块堆叠。每个块内部结构是:先做空间图卷积,接 BatchNorm、ReLU、Dropout,再做时间维卷积。时间维卷积用的是标准 Conv2d,把卷积核放在帧维度上扫,NTU 场景下 kernel_size=9 比较稳,小了感受不到帧间时序,大了参数膨胀明显。通道数从 3 开始,经过 64、128、256 三个量级递进;第 4 和第 7 块在时间维做 stride=2 的下采样,降低帧率换取更大感受野。最后接全局平均池化和线性分类器,输出动作类别概率。
3. 把 python 源码跑通:环境配置、数据整理与第一次训练
原理立住之后,这个项目就变成纯工程问题了。源码包解压之后会看到什么、依赖怎么装、数据怎么喂进去、训练命令长什么样,我按实际操作的顺序写。
3.1 拿到代码先别训练:目录结构、依赖版本和 python 环境配置
解压 zip 之后,常见源码包的目录结构大概长这样:
st-gcn/ ├── config/ # 训练与测试的 yaml 配置 ├── graph/ # 人体图结构定义(ntu / kinetics) ├── nets/ # st_gcn.py 网络定义 ├── processor/ # 数据加载、训练循环、识别入口 ├── main.py # 命令行入口 ├── data/ # 预处理后的 npy 数据 └── pretrained/ # 预训练权重(可能为空)动手跑之前先把 python 环境配好。网上 python 安装教程很多,核心要点只有两条:用 conda 单独建一个虚拟环境,别动系统自带的解释器;torch 的版本必须和本机 CUDA 版本对得上,否则程序能跑但显卡不干活。配环境时 vscode 或 pycharm 都行,选解释器时认准刚才创建的那个环境。
# 用 conda 建一个干净环境,python 3.8~3.10 都可以 conda create -n stgcn python=3.9 conda activate stgcn # 装 torch,按你自己的 CUDA 版本选择 cu118 / cu121 等 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 其余依赖 pip install numpy opencv-python pyyaml tqdm这里有个常见的坑:有人直接pip install cv2,必报错。opencv 的包名是opencv-python,导入名才是cv2。装完之后在终端里敲一句验证:
python -c "import torch, cv2, numpy; print(torch.__version__, cv2.__version__)"能打印出版本号,这张环境就过了。只有 CPU 的机器也能跑通流程,只是训练速度慢 10 倍以上,毕业设计阶段建议至少找一块 8G 显存的显卡。
3.2 骨骼数据是怎么进模型的:从 NTU 命名规则到 npy 数组
NTU RGB+D 原始数据是一堆.skeleton文本文件,文件名带编码规则。比如S001C001P001R001A001.skeleton表示第 1 个被拍摄者、第 1 台相机、第 1 个动作类别。S是 subject(人物),C是 camera(视角),P是 performer(演员编号),R是 replication(重复次数),A是 action(动作类别)。这个命名规则很重要:cross-subject 划分按S分,cross-view 划分按C分,代码里解析文件名用的就是这个规则。
skeleton 文本格式也不复杂:文件头写总帧数,之后每一帧先写该帧关节数,然后每个关节一行数字,前三个字段是 x、y、z 坐标。我一般建议自己写一遍解析函数,后面换数据集时就不用依赖别人的预处理脚本了:
import numpy as np def parse_skeleton(path): """把 NTU 的 skeleton 文本文件解析成 (T, V, 3) 数组""" with open(path, 'r') as f: lines = [line.strip() for line in f.readlines()] idx = 0 num_frames = int(lines[idx]) idx += 1 frames = [] for _ in range(num_frames): num_joints = int(lines[idx]) idx += 1 joints = [] for _ in range(num_joints): parts = lines[idx].split(' ') # 前 3 个是 x, y, z;其余是颜色、深度信息,这里丢弃 joints.append([float(parts[0]), float(parts[1]), float(parts[2])]) idx += 1 frames.append(joints) return np.array(frames) # 形状 (T, V, 3)这段代码只用了 python 基础语法,逻辑很清楚:先读帧数,再循环读每一帧。注意split(' ')按空格切分,NTU 文件里字段之间恰好是一个空格,如果从别的数据集复制数据来,要先确认分隔符是空格还是逗号。解析完成后,源码包里的 processor 会把 (T, V, 3) 转置成 (3, T, V) 输入网络,再按人物编号划分训练集和测试集,存成 npy 加速后续加载。
3.3 训练与验证:配置文件参数表、终端命令和日志判断
源码包的 config 目录下会按数据集和划分方式放几个 yaml 文件,训练之前先把参数过一遍。需要改的核心参数就这些:
| 参数 | 常见取值 | 作用 |
|---|---|---|
| lr | 0.1(SGD) | 初始学习率,ST-GCN 配 SGD 比 Adam 稳 |
| momentum | 0.9 | SGD 动量,配合 nesterov 加速收敛 |
| weight_decay | 0.0001 | L2 正则,防过拟合 |
| nesterov | true | 打开 Nesterov 动量 |
| batch_size | 64 | 显存 8G 建议 32~64,显存小就调低 |
| num_epoch | 80 | 完整训练轮数,小数据集 50 轮可先看趋势 |
| temporal_kernel_size | 9 | 时间卷积核大小,NTU 场景推荐 9 |
| gpus | 0 | 使用哪张卡,多卡写 "0,1" |
配置文件确认之后,训练就一条命令:
# 训练 python main.py --config config/st_gcn/nturgbd-cross-view/train.yaml # 用训练好的权重做测试 python main.py --config config/st_gcn/nturgbd-cross-view/test.yaml \ --weights pretrained/checkpoint.pt训练日志每个 batch 会打印一次 loss。前 5 个 epoch loss 应该从初始值(一般在 3~4)快速掉到 1.5 以下;到第 20 个 epoch 左右开始在 0.1~0.3 区间震荡,这是正常现象。如果 50 个 epoch 了 loss 还在 1.0 以上不下来,先怀疑学习率和数据预处理,别急着调网络结构。单卡 1080Ti 级别训练 NTU 60 的 cross-view 划分,80 个 epoch 大概 5~8 小时,中途中断了不用担心,checkpoint 每 10 个 epoch 存一次,完整体验一把“后悔药”。
4. 避坑记录:ST-GCN 的五个翻车现场、原因与解决
这一章全部是实际操作里的血泪经验。每一条我都按“现象 → 原因 → 解决”的线索写,照着排查比翻 issue 快得多。
4.1 loss 变成 NaN,或者训着训着直接崩掉
现象:训练到某个 epoch,终端突然打出一堆nan,然后 loss 永远回不来。或者从第一个 epoch 就是 NaN,但日志前面完全正常。
原因:最常见的是学习率太激进。ST-GCN 用 SGD 加 nesterov 动量时,lr=0.1 在权重初始化不理想的情况下确实会出现梯度爆炸。另一个隐蔽原因是数据里有无效值:比如 NTU 数据集中骨骼缺失时某些坐标会被填成 0,如果你用的预处理脚本没有做 mask,网络会把缺失点当作真实坐标参与图卷积,特征里混入超大异常值。
解决:先查数据,把加载后的数组跑一遍np.isnan(data).any()和np.isfinite(data).all(),确认输入干净。数据没问题再降学习率,lr 从 0.01 起步,前 20 个 epoch 用 warmup 逐步升到 0.1,或者干脆全程用 0.01 训练,牺牲一点收敛速度换稳定性。顺便把梯度裁剪打开,torch.nn.utils.clip_grad_norm_(model.parameters(), 5)一行代码的事,能挡住绝大多数梯度爆炸。
4.2 显存 OOM,batch_size 怎么调都不行
现象:显存 8G 的卡,batch_size 设 64,跑到第 3 个 epoch 直接CUDA out of memory。改成 16 之后勉强能跑,但训练速度肉眼可见地变慢。
原因:很多人以为 ST-GCN 输入很小(25 个点),显存一定省。实际上时间维卷积核是 9,中间特征图是 (B, 256, T, V),T 在 300 帧时单张特征图就有 200 万左右个元素,全连接之前还要做全局池化,显存消耗大头全在中间特征上,不在输入。
解决:先把 batch_size 降到 32 确认能跑通,再从数据端省显存。NTU 原始数据每段序列 300 帧,很多样本的动作主体在前 150 帧就结束了,可以把序列长度先裁到 150 帧预热训练,稳定后再恢复到完整长度。另外,数据加载默认每个 step 从磁盘读 npy,IO 会把 CPU 占满,建议预处理时把训练集一次性载入内存,虽然吃内存,但训练速度能快三分之一以上。
4.3 验证集精度比论文低十几个点
现象:完整训完 80 个 epoch,验证集 top-1 准确率只有 75% 左右,而论文表里写的是接近 90%,差距大到让人怀疑代码是不是有 bug。
原因:这个落差九成不是 bug,而是你只跑了一个 stream。ST-GCN 论文里 89% 以上的精度是四个 stream 集成出来的——关节坐标(joint)、骨骼向量(bone)、关节运动(joint motion)、骨骼运动(bone motion),每个 stream 单独训练,最后把 softmax 分数相加。你单独跑 joint stream,cross-view 也就 82% 左右,cross-subject 大概 78%,这是正常的单流 baseline,不是模型坏了。
解决:先确认自己复现的是单流 baseline 还是四流集成。如果是毕设,建议把四个 stream 全训出来再做分数融合,融合代码在 processor 里一般都有现成实现,没有的话自己写也很简单:加载四个权重,分别输出每个类别的概率,取平均后 argmax。这是整个项目里性价比最高的一次精度提升。
4.4 换了一个验证集,模型精度直接崩到不可用
现象:训练日志显示 acc 稳定在 85% 以上,换一个划分方式(比如把 cross-view 换成 cross-subject)重新测,直接掉到 20%,跟随机猜差不多。
原因:数据划分泄漏了。cross-subject 和 cross-view 的文件名解析逻辑不一样,如果你在 config 里选了 cross-view 的划分文件,但训练时不小心用了 cross-subject 的预处理结果,或者测试集里混入了训练集里同一个人的样本,模型学到的就是“认人”而不是“认动作”。这种泄漏很隐蔽,因为 loss 曲线完全正常,acc 还特别好看——好看到不正常。
解决:训练前先打印训练集和测试集的文件名前缀,确认同一 subject 或同一 camera 的样本只出现在一边。NTU 官方划分是枚举出人的编号列表,代码里通常写死了一组常量;换成自己的数据时,一定要自己检查拆分逻辑,不要直接复用别人的train.txt/val.txt。
4.5 自定义数据集上完全乱来,换 18 点骨架就崩
现象:不用 NTU,改用 OpenPose 输出的 18 点骨架之后,网络训练 loss 能降,但识别结果几乎没有语义,几个类别来回误判。
原因:关节编号对不上。NTU 的 25 点拓扑和 COCO 的 18 点拓扑不是简单删几个关节的关系:NTU 里有髋中心、脊柱、颈部这些中轴点,COCO 里没有独立的髋中心,左右髋直接连到骨盆中点。直接把 18 点数据塞进为 25 点定义的图里,邻接矩阵从第一层就指错边,消息传递的物理含义全乱了。
解决:换数据集时,graph 目录下对应数据集的邻接矩阵定义要重写。最稳的做法是写一个脚本,把新骨架点位映射到 NTU 的关节索引体系,映射不了的关节坐标置 0 并在图里断开连接。不要怕麻烦,这一步不做好,后面所有调参都是在黑匣子里瞎猜。
5. 先画出来再谈训练:可视化骨架与预测结果的验证习惯
最后一章分享一个我每次拿到新骨骼数据都先做的事:把骨架画回视频帧上,用眼睛确认数据没毛病,再谈训练。
可视化代码不复杂,核心是用 cv2 画点和连线。关键点坐标是归一化到 [0, 1] 区间的,画之前要乘回图像的宽高:
import cv2 import numpy as np def draw_skeleton(frame, keypoints, edges): """ keypoints: (V, 2) 的归一化坐标 edges: [(i, j), ...] 骨骼连接列表 """ h, w = frame.shape[:2] for (i, j) in edges: x1, y1 = int(keypoints[i, 0] * w), int(keypoints[i, 1] * h) x2, y2 = int(keypoints[j, 0] * w), int(keypoints[j, 1] * h) cv2.line(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) for (x, y) in keypoints: cx, cy = int(x * w), int(y * h) cv2.circle(frame, (cx, cy), 4, (0, 0, 255), -1) return frame这段代码里的edges就是你在 graph 目录里定义的那份邻接列表。把第 2 章讲过的边数据直接拿过来用,可视化出来的骨架如果和视频里的人物对不上,比如左手连到了右肩、腿画到了天上,那一定是关节索引映射错了,趁早回头改图定义;如果对得上,再开始训练。
预测结果也要可视化。推理的时候把 top-5 类别和置信度画在图像左上角,和原视频逐帧合成一个 demo 视频。这一步看着简单,但它能暴露一个很隐晦的问题:模型可能学到了场景特征而不是动作特征,比如只要画面里出现某种背景就输出“跑步”,画出来一眼就能发现。
我自己的习惯是,预处理脚本、图定义、可视化脚本这三样永远放在训练之前写好。可视化脚本不用多精致,哪怕只是把 10 帧连起来存成 gif 都行,但它能在你投入几小时训练之前拦住数据错误,这比任何调参技巧都省钱。ST-GCN 这个方向本身不难,难的是养成“先验证输入,再相信输出”的工程习惯。希望帮到你。
本文还有配套的精品资源,点击获取