资讯中心

ST-GCN骨骼动作识别实战:时空图卷积原理与代码详解

📅 2026/9/28 12:04:46
ST-GCN骨骼动作识别实战:时空图卷积原理与代码详解
简介一套基于时空图卷积ST-GCN的骨骼动作识别项目面向计算机专业毕业设计学生与需要项目实战练习的学习者可解决动作识别从数据处理、模型训练到可视化演示的全流程问题。压缩包共88个文件包括29个Python脚本、13个YAML配置文件、预训练模型、演示视频及说明文档等其中Python脚本涵盖模型定义、训练/测试入口、数据加载与预处理工具配置文件便于调整网络结构与超参数。包体大小约52.56MB目录按功能模块划分包含NTU-RGB-D与Kinetics两套数据支撑并附有项目说明和技术文档方便快速理解与二次开发。项目内置训练好的模型权重与可视化演示可直接运行查看动作识别效果也可依据指导说明逐步复现实验。目前已有424人学习下载适合作为高分毕设、课程设计或期末大作业的完整参考方案。1. 骨骼动作识别为什么绕不开 ST-GCN一套能直接拿去跑的高分毕设方案第一次在毕设里跑基于时空图卷积ST-GCN的骨骼动作识别 python 源码时我最大的困惑不是模型本身而是“骨骼点序列到底怎么送进卷积网络”。传统 CNN 处理的是规整的网格像素而骨骼数据是一堆散落在空间里的关键点坐标它们之间的连接关系才是真正的信息所在。ST-GCN 把人体骨架当成一张图来做图卷积完成了从“非欧几里得结构”到“可训练特征”的转换这也正是它成为骨骼动作识别方向经典选型的原因。这套方案能解决什么问题最直接的就是你有一个包含项目说明的 python 源码压缩包想把它跑通、读懂、改进甚至改造成自己的毕设或小论文实验。它适合三类人刚接触动作识别的本科生、需要快速出 baseline 的研究生以及想在企业项目里做姿态动作预研的工程师。下面我会按“原理 → 跑通 → 训练 → 排错 → 进阶”的顺序把整个项目的黑匣子拆开每一步都带上可执行的命令和参数。2. 时空图卷积的建模思路把骨骼序列当成图来算2.1 从关键点坐标到图结构graph.py 里的邻接矩阵在做什么ST-GCN 的第一个关键步骤是定义“哪两个关节点相连”。人体骨架天然是一张图关节是节点骨骼是边。以 NTU RGBD 数据集的 25 个关节点为例项目里的 graph.py 通常干两件事定义节点连接关系以及生成归一化的邻接矩阵。# graph.py 中骨架图构造的核心逻辑 import numpy as np class Graph: def __init__(self, layoutnturgbd, strategyspatial): self.num_node 25 # NTU RGBD 的关节点数 # 用关节对列表描述骨骼连接例如 (1,2) 表示颈部和脊柱相连 self.edges [(1, 2), (2, 21), (3, 21), (4, 3), (5, 21), (6, 5), (7, 6), (8, 7), (9, 21), (10, 9), (11, 10), (12, 11), (13, 1), (14, 13), (15, 14], (16, 15), (17, 1), (18, 17), (19, 18), (20, 19), (22, 23), (23, 8), (24, 25), (25, 12)] # 构建对称邻接矩阵 AA[i][j]1 表示 i 和 j 直接相连 self.A np.zeros((self.num_node, self.num_node)) for i, j in self.edges: self.A[i][j] self.A[j][i] 1 # 归一化D^{-1/2} A D^{-1/2}让不同度数的节点在卷积时权重一致 D np.sum(self.A, axis1) D_inv_sqrt np.power(D, -0.5) D_inv_sqrt[np.isinf(D_inv_sqrt)] 0.0 self.A self.A * np.expand_dims(D_inv_sqrt, 1) self.A self.A * np.expand_dims(D_inv_sqrt, 0)这段代码里最容易被忽略的是归一化操作。如果直接把原始邻接矩阵丢进卷积度数高的关节比如躯干中心会聚集更多邻居的特征导致数值偏大。D^{-1/2} A D^{-1/2}是图卷积的标准做法等效于对每个节点的邻居特征做平均而不是求和。另一个值得注意的点是 strategy 参数。除了上面这种直接相连的 spatial 方式还有一个常用策略是按关节到重心的距离分层把邻居划分成“向心、离心、自身”三组每组用不同的邻接矩阵做卷积。这样模型能学到“手向身体靠近”和“手远离身体”这两类相反的运动模式对动作识别很有帮助NTU 上的实验也会高两三个点。你拿到源码包后第一时间打开 graph.py 看strategy是什么这会直接影响模型容量和训练收敛速度。2.2 ST-GCN 的模型结构空间图卷积和时间卷积怎么串起来单个 ST-GCN 单元由两部分组成空间图卷积处理关节之间的组织结构时间卷积处理帧与帧之间的运动变化。输入张量是五维的(N, C, T, V, M)分别代表 batch 大小、通道数、序列帧数、每帧关节数、场景中的人数。这个五维顺序是源码里最容易搞混的地方PyTorch 默认的卷积是对(N, C, T)操作而图卷积要对 V 这维做聚合所以代码里经常能看到permute。# st_gcn.py 中一个时空卷积块的简化结构 import torch import torch.nn as nn class STConvBlock(nn.Module): def __init__(self, in_channels, out_channels, num_joints25, stride1): super().__init__() # 空间图卷积对关节维度 V 做邻接聚合 self.gcn GraphConv(in_channels, out_channels, num_joints) # 时间卷积沿 T 维做标准一维卷积kernel_size9 是常见设定 self.tcn nn.Sequential( nn.Conv2d(out_channels, out_channels, kernel_size(9, 1), stride(stride, 1), padding(4, 0)), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) self.residual nn.Conv2d(in_channels, out_channels, kernel_size1) \ if in_channels ! out_channels or stride ! 1 else nn.Identity() def forward(self, x): # x 形状: (N, C, T, V) res self.residual(x) x self.gcn(x) # 先聚合空间邻居信息 x self.tcn(x) # 再沿时间轴提取运动变化 return x res标准 ST-GCN 会堆叠 9 层这样的 STConvBlock通道数从 3 逐渐升到 256。每经过一个 stride 为 2 的 block时间维度减半就像图像分类里分辨率减半、通道翻倍一样。最后一层用全局平均池化把(N, C, T, V)压成(N, C)再接全连接输出到动作类别数。这里有个容易踩的细节时间卷积的 kernel 是(9, 1)宽为 1表示它只沿帧维度滑动不跨关节。如果写成(9, 9)就会把空间聚合和时间建模混在一起参数量暴涨而且容易过拟合。我在调参时一般固定住空间卷积的 kernel 大小为 1×1只改图邻接时间卷积保持 9这样可调的变量少实验对比更干净。2.3 为什么不用 RNN/CNN 做骨骼动作识别选型理由很多拿到源码的同学都会问骨骼序列本质上也是时间序列用 LSTM 不是更自然吗我在前期调研时也做过对比。RNN 族如 LSTM、GRU能建模时序依赖但人体 25 个关节之间不是链式关系而是复杂的空间拓扑。RNN 把每个时刻的所有关节点拉平成一个向量空间结构信息会被打散到隐藏状态里模型很难学到“左手和右脚的相对位置正在改变”这种强判别特征。纯 CNN 的问题则相反。它擅长提取局部纹理但骨骼点不是均匀分布在二维平面上的直接用(T, V, C)拼成伪图像关节间的实际连接关系会被破坏。比如手腕和手肘在像素网格上可能离得很远但它们其实是直接相邻的两个节点。ST-GCN 用邻接矩阵把“相邻”重新定义为“骨架上的连接”而不是坐标空间里的距离这就把领域先验正确注入了模型。从工程角度看ST-GCN 的一个重要优势是计算开销适中。同样在单卡 2080Ti 上训练 NTU 60LSTM 参数量大约在 10M 级ST-GCN 也在这个量级但收敛速度快因为它有残差连接和 BatchNorm梯度更稳定。而且项目里可改的模块边界清晰换数据集只改 graph.py加注意力只动单层结构非常适合毕设里做消融实验。3. 跑通项目的最小命令从 Python 环境到第一个评估结果3.1 环境准备Python 版本与 PyTorch 的安装pycharm/vscode 配置 python 环境的注意点拿到一个 python 源码包第一件事不是读代码而是把环境装到能跑 demo 的状态。ST-GCN 系项目依赖主要集中在 torch、numpy、pyyaml、opencv 这几个库。Python 版本我建议 3.8~3.10太新的版本偶尔会遇到 PyTorch 轮子没跟上或者某个旧依赖编译失败的问题。# 用 conda 创建独立环境避免污染系统自带的 python conda create -n stgcn python3.8 conda activate stgcn # 先装 torch 全家桶再装剩余依赖 conda install pytorch torchvision cudatoolkit11.3 -c pytorch pip install numpy pyyaml opencv-python einops经常有同学在 pycharm 或 vscode 配置 python 环境时明明 conda 激活了 stgcnIDE 里的 interpreter 却还指向系统自带的 python导致import torch报 ModuleNotFoundError。记住一个原则解释器必须选到 conda 环境目录下的 python 可执行文件而不是随便选一个。装完依赖后用python -c import torch; print(torch.__version__)验证一下这一步能避免后面一半以上的玄学问题。3.2 项目目录结构解读模型、图定义、数据处理与配置模块拿到 zip 解压之后先别急着运行花五分钟对齐目录结构。不同作者整理的源码包略有差异但核心模块是固定的数据加载器负责把原始骨骼坐标转成五维张量图定义模块负责生成邻接矩阵模型模块负责搭建 ST-GCN 主干配置文件管理训练超参数。大部分源码包里还带一个 train.py 或 main.py 作为入口加一个 test.py 用于验证。st-gcn-project/ ├── main.py # 训练入口解析 config 并启动训练循环 ├── test.py # 评估入口加载权重输出准确率 ├── config/ # yaml 配置文件存放数据集路径、batch、lr ├── graph/ # 骨骼图定义graph.py 在里面 ├── models/ # st_gcn.py、utils.py网络结构定义 ├── data/ # 数据预处理与 dataloader 实现 ├── pretrained/ # 放预训练权重文件.pth └── requirements.txt # 依赖清单pip install -r 可直接用requirements.txt 是排查依赖冲突的第一入口。如果作者的 pytorch 版本和你装的不一致最典型的表现是加载权重时报unexpected key in module state_dict这通常不是代码错而是 torch 序列化格式或模型结构对不上。我一般会先把 requirements.txt 读一遍确认 torch 主版本是不是同一个再决定是重装环境还是只改代码。3.3 运行测试脚本加载预训练权重评估的完整命令与输出含义项目能跑出结果的前提是有权重文件。如果源码包里没有带权重的 pth那你需要先做训练如果带了直接走 test 流程。评估脚本做的事很简单加载模型、加载权重、读取验证集、计算 top-1 准确率。# 测试命令示例config 里指定了数据集路径、模型结构参数、类别数 python test.py --config config/nturgbd_cross_subject.yaml \ --weights pretrained/stgcn_ntu60_top1_82.pth运行后你会看到每 10 个 batch 打印一次 loss 和当前准确率最后输出类似Top-1: 81.2%, Top-5: 95.1%的结果。这里需要明白一点NTU 60 数据集有两种官方评测协议cross-subject按人物划分训练/测试和 cross-view按相机视角划分。同一套权重在这两个协议下的准确率能差 5 个百分点以上。所以对比论文结果时先看清楚作者用的哪个协议否则你会以为自己模型翻车了其实只是比较基准不对。如果 test.py 报错找不到数据集文件常见原因是源码包作者在 config 里写的路径是他本机的绝对路径比如/home/xxx/data/nturgbd。你只需要把 data_path 改成你实际解压后的路径即可。这类问题在毕设源码包里几乎 100% 出现改完路径就能继续跑。4. 用自己的数据训练数据格式、预处理与关键参数4.1 骨骼数据长什么样NTU RGBD 与自定义骨架的整理源码包自带的训练脚本通常默认读 NTU RGBD 格式的.skeleton文件或已经打包好的.pkl。每一个动作样本是若干帧每帧有 25 个关节点的三维坐标和置信度维度可以表示为(T, V, C)其中 C 通常为 3x, y, z或 2x, y。在送入网络前还会加一维人体标识 M多人的情况每个关节属于哪一个人也需要记录。如果你要训练自己的数据集最常见的做法是写一个小脚本把自定义骨骼数据统一成 npz 或 npy。不要直接改主训练代码而是造一个预处理接口# data_preprocess.py把自定义骨架序列转成 ST-GCN 输入格式 import numpy as np def skeleton_to_input(keypoints, num_frames300, num_joints25, num_person2): 输入: keypoints 形状 (T_raw, V, C) 输出: 形状 (N1, C3, Tnum_frames, Vnum_joints, Mnum_person) T_raw keypoints.shape[0] # 时间维对齐若长度不足 300 帧则线性插值补全超长则均匀采样 if T_raw num_frames: idx np.linspace(0, T_raw - 1, num_frames).astype(int) sampled keypoints[idx] else: x np.linspace(0, 1, T_raw) x_new np.linspace(0, 1, num_frames) sampled np.array([np.interp(x_new, x, keypoints[:, j, c]) for j in range(num_joints) for c in range(keypoints.shape[2])]) sampled sampled.reshape(num_frames, num_joints, -1) # 转置到 (C, T, V, M)人数维度先补 1再把 xyz 按通道拆开 transposed sampled.transpose(2, 0, 1) # (C, T, V) return transposed[:, :, :, np.newaxis] # (C, T, V, M1)这段代码里有三个关键点。第一时间维度的对齐直接决定模型能不能学到位移速度信息如果 50 帧的动作被 padding 成 300 帧大部分帧都是重复值模型会偏向静止动作我一般优先做均匀采样而非补零。第二通道顺序必须是(C, T, V, M)这和 BatchNorm 内部的统计维度强相关顺序错了前向传播就能报错。第三单人情况下 M1 即可但多人项目如果只取一个人会导致模型漏掉交互特征algorithms 里有个常见的 trick 是把每帧按置信度排序后取前两个人放进num_person2。4.2 从原始坐标到模型输入归一化、中心化和批量读取坐标原始值大多是 0~2000 级别的像素坐标不归一化直接训练Loss 会在一个很大的量级振荡。ST-GCN 源码里常见的做法是用数据集的均值方差做标准化但我在实际调训里发现针对自定义数据集更稳的是先把每个样本的中心移到原点再除以人体包围盒的最大边长。# normalization.py样本级归一化避免全局统计量导致单个样本失稳 def normalize_sample(keypoints_xy): # keypoints_xy: (T, V, 2) 只处理 x,y 坐标 center keypoints_xy.reshape(-1, 2).mean(axis0) keypoints_xy keypoints_xy - center # 平移 scale np.abs(keypoints_xy).max() 1e-6 # 按最远点缩放 return keypoints_xy / scale # 坐标落到 [-1, 1]全局归一化的坑在于如果训练集里出现一个离群样本比如摄像头离人特别近均值和方差会被带偏所有样本都被压得特别小模型直接欠拟合。样本级归一化是对每个动作序列独立做的更鲁棒。代价是丢失了绝对空间位置信息但对于动作分类来说绝对位置本来就是跟相机位姿相关的干扰项丢掉反而更符合任务目标。批量读取阶段DataLoader 里的collate_fn也要适应五维输入。如果两个样本帧数不一致必须先把所有样本的时间维统一成同一个长度再拼到 batch 维。代码里常见做法是让skeleton_to_input的num_frames参数接收一个固定的全局值比如 300。这个值不是越大越好900 帧意味着时间卷积的感受野被拉到极大计算量翻三倍准确率却不一定涨。NTU 上 300 帧是经验平衡点。4.3 训练参数怎么调batch、学习率、epoch 和 dropout 的实战推荐值ST-GCN 的训练配置主要在 yaml 文件里改。我建议拿到源码后先沿用作者的默认参数跑一个 epoch观察 loss 的下限再决定要不要动。下面是一份 NTU 60 交叉人物协议下的常见配置# config/nturgbd_cross_subject.yaml batch_size: 64 num_epoch: 80 base_lr: 0.1 step: [30, 60] # 在第 30 和 60 个 epoch 时学习率除以 10 weight_decay: 0.0001 nesterov: True dropout: 0.5 num_class: 60这份配置有几个点需要解释。base_lr 是 0.1而不是深度学习中常见的 0.001因为模型里大量使用 BatchNorm且用了 SGD momentum大学习率在批归一化网络上是可行的。如果换成 Adam我一般会把学习率降到 0.001但 ST-GCN 系论文几乎都是 SGD 路线不建议轻易换优化器否则需要重新调整套超参。dropout 加在全连接层之前作用是降低过拟合。如果你的训练集样本只有几千个dropout 可以提到 0.7如果用了预训练权重做 fine-tune0.3 就足够。另外一个常被忽略的参数是 batch_size 和数据并行。batch 64 在 11G 显存上勉强够用如果你显存只有 6G把 batch 降到 16同时学习率按比例降到 0.025 起步否则大学习率加小 batch 训练曲线会剧烈抖动。训练开始后要盯两个指标train loss 是否平滑下降以及 val accuracy 是否在某个 epoch 后突然回退。回退通常发生在学习率 step 之后说明模型已经过拟合提前步进或加大 dropout 是两个立即可用的止血手段。如果 val 准确率一直在 10% 以下基本不是参数问题而是标签和类别数对不上或者数据加载器的索引错位需要回头查测试脚本的输出映射。5. 常见避坑与排查训练不收敛、显存溢出、数据加载慢怎么办5.1 训练 loss 一直不降甚至卡在固定值不动现象前几个 epoch 打印的 loss 稳定在一个奇怪的值比如 2.39 或者 1.79之后几乎不变。原因有两种一是类别数设置错误模型输出的 logits 维度与标签索引不匹配交叉熵在做无意义的随机映射二是学习率太大导致梯度爆炸后 BatchNorm 统计量崩坏loss 稳定在某个饱和区。解决先打印一次输出张量的 shape 和标签的取值区间确认num_class与标签 max1 是否相等。如果类别数没问题就把base_lr降到原来的十分之一或加一个 warmup前 5 个 epoch 线性从 0.01 爬到目标学习率。我在调试时习惯在第一个 epoch 里打印 10 个 batch 的 loss如果 loss 从 log(类别数) 附近开始下降说明训练流程正常如果一步都没动马上停掉查数据标签。5.2 显存 OOM五维输入张量把显存吃满现象训练刚跑几个 batch报CUDA out of memory。原因输入是(N, C, T, V, M)五维当 T300、V25、batch64 时单层中间激活就是 64×256×150×25×1 的 float 张量接近 1GB9 层叠加后显存很容易爆炸。解决优先把 batch_size 减半这是成本最低的手段。其次把T从 300 缩短到 150用均匀采样保证帧内容完整模型精度下降通常在一个点以内。还有一个隐藏参数stem_channels源码里常把第一层图卷积的通道数从 3 升到 64如果你把 64 改成 32显存可以省 30% 左右。跟硬件博弈时不要同时改这三个一次只改一个变量否则你很难判断精度下降来自哪个改动。5.3 数据加载慢训练一小时数据加载占五十分钟现象GPU 利用率只有 30%训练一圈时间很长。原因骨骼数据大多是 pickled Python 字典反序列化开销巨大且切片时频繁复制。另外如果数据源是磁盘上的.skeleton文件每次读取都在做随机 IO非常慢。解决DataLoader 里设num_workers4到8并把pin_memoryTrue打开让 GPU 拷贝不再卡在 CPU 上。如果还慢就写一个离线转换脚本把所有样本预先打包成一个超大 npy 文件训练时直接按索引切片IO 时间能降一个数量级。另外确认一下数据是否被 dtype 为 float64 保存了float64 占内存是 float32 的两倍转成 float32 能加快约 40% 的读取速度。5.4 模型输出维度与标签数量对不上load_state_dict 报错现象加载预训练权重时提示size mismatch for fc.weight: copying a param with shape (60, 256) from checkpoint, the shape in current model is (120, 256)。原因权重是在 NTU 60 上训练的而你的配置文件里num_class120或反过来又或者你换用了 NTU 120 数据集但忘了改类别数。解决先把 config 里的 num_class 改对。如果非要跨类别的权重做迁移学习常见做法是去掉最后一层全连接重新随机初始化只加载 backbone 的权重。源码库里的 test.py 不一定支持这个操作你需要自己写一小段加载逻辑# load_partial_weights.py跳过不匹配的 fc 层 state_dict torch.load(weights_path) model_dict model.state_dict() # 过滤掉形状不一致的层常见于类别数不同的迁移 state_dict {k: v for k, v in state_dict.items() if k in model_dict and v.shape model_dict[k].shape} model_dict.update(state_dict) model.load_state_dict(model_dict)这样加载后训练时只更新新初始化的 fc 层和部分骨干层学习率可以调低到 0.005能省一半的训练时间。5.5 复现结果和论文不一致准确率差好几个点现象同样的模型别人报告 85%你复现只有 79%。原因最常见的是评测协议不同。cross-subject 和 cross-view 是两个完全不同的训练/验证划分有些人写论文只说了“NTU 60”没写协议数字对不上很正常。另一个原因是测试时的数据增强和训练时不一致比如训练用了随机裁剪和随机旋转测试却什么都没加。解决先回到项目说明或 yaml 文件里确认protocol字段。如果你确认协议一致差异还在 3 个点以上检查你的骨架数据是否做了 z-score 坐标校正。NTU 官方原始坐标的关节方向定义和历史版本不同很多开源复现会在预处理里把 z 轴翻转如果你导入的是自己采集的数据这一步遗漏会让模型性能明显下降。我一般会在 issue 或项目说明里找“z-coordinate”相关的注释看作者是否做了特殊处理。最后才考虑超参数差异因为超参对 ST-GCN 的影响通常不超过 1.5 个点。6. 进阶技巧把 ST-GCN 改进到论文级效果的三个方向到这一步你已经能跑通并复现了 baseline接下来可以做让毕设答辩加分或论文有创新点的改进。我根据自己改模型的经验推荐三个低风险高回报的方向。第一个是自适应邻接矩阵。原始 ST-GCN 的邻接矩阵固定不变它只能表达人体解剖学意义上的连接表达不了不同动作中关节间动态耦合关系。改进思路是让模型在训练中学一个图中未定义的“增量”# adaptive_graph.py自适应图卷积的最小实现 class AdaptiveGraphConv(nn.Module): def __init__(self, num_joints): super().__init__() # A_fix 是原始归一化邻接矩阵A_diff 是可学习残差 self.register_buffer(A_fix, torch.from_numpy(Graph().A)) self.A_diff nn.Parameter(torch.zeros(num_joints, num_joints)) def forward(self, x): # 每个样本共享学习到的图结构A_diff 可以是非对称的 A self.A_fix self.A_diff return torch.einsum(nctv,vw-nctw, x, A)这个改动只增加了一个参数矩阵但手部动作和脚部动作在协方差结构上的差异就能被编码进去NTU 上通常能带来 2~3 个点的提升。第二个方向是在 STConvBlock 的时间卷积前加一个轻量注意力模块对帧维度做重要性加权能抑制“蹲下系鞋带”这类长动作中的无关帧。第三个方向是在自定义数据上做迁移学习先用 NTU 预训练权重初始化再你用自己的几千个样本微调比从零训练早收敛且更稳。要注意做实验对比时要固定随机种子和数据划分否则你没法分辨提升来自改进还是数据抖动。我自己的习惯是每改一个小模块先在同一协议下跑 5 个种子取平均值再做显著性判断。之前有一次我直接拿默认参数跑自己的数据结果模型几乎全部预测到同一类别后来发现问题出在归一化方式上——用了全局统计量而没有按人体尺寸缩放改成样本级归一化之后效果立刻恢复。希望帮到你。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案