ST-GCN骨骼动作识别:基于PyTorch的图卷积实现与训练避坑指南
简介一套基于时空图卷积ST-GCN的骨骼动作识别毕业设计项目面向计算机视觉方向学生与研究者覆盖从模型原理、代码实现到项目文档的完整闭环。资源以Python源码为主包含29个py脚本、13个yaml配置、3个pt预训练模型、9个txt说明等共91个文件压缩包约52.54MB清晰划分tools、processor、feeder、models等模块并附有离线/实时演示与多种骨架数据预处理脚本便于二次开发。已有175人学习下载。项目采用图卷积建模人体骨骼点时空关系端到端自动学习动作特征解决了传统手工特征提取耗时且依赖专家经验的问题。除完整训练好的ST-GCN及改进模型外还提供项目文档、样例动图与视频可帮助快速复现NTU-RGB-D等数据集上的骨骼动作识别实验适合作为课程设计、毕业设计或入门时空图卷积的实践参考。1. 拿到这个python毕业设计标题先看它在解决什么问题做动作识别多数人第一反应是拿视频帧喂给卷积神经网络。但这个python毕业设计标题里明确写的是骨骼动作识别输入不是RGB图像而是一串人体关键点坐标。你不需要关心穿什么衣服、背景多乱、光照好不好只看骨架的运动轨迹就能区分“挥手”和“走路”。ST-GCN是这条路线上最有代表性的模型之一。它把骨骼关键点看成一张图关节是节点骨骼是边再用图卷积提取空间特征用时间卷积捕捉动作时序。作为毕业设计它的工程量适中、可视化效果好、数据集公开而且代码框架清晰适合在PyTorch上一步步跑通。本文按“原理拆解 → 数据预处理 → 训练推理 → 避坑 → 验证进阶”的顺序把一套可复现的落地过程讲清楚。2. ST-GCN的核心设计为什么骨架图卷积能同时吃进空间和时间做骨骼动作识别常见做法有三种把关键点坐标拼成一维向量丢给LSTM、把坐标矩阵当成图像丢给CNN、把骨骼图丢给图卷积网络。LSTM的问题在于它对空间结构不敏感手和脚的位置在特征里是平的没有拓扑关系。CNN又依赖固定尺寸的输入骨架点数一换就得改网络结构。ST-GCN不一样它把人体骨架看成图结构用邻接矩阵定义关节间的连接关系空间卷积在图上做时间卷积在帧序列上做。2.1 图卷积层到底做了什么邻接矩阵、归一化、分区策略图卷积的核心操作可以理解为“每个节点的特征等于它自己和邻居节点特征的加权求和”。关键在两点邻居怎么定义、权重怎么算。ST-GCN用的人体图把关节按人体的自然连接关系连起来比如左肘连着左肩、左腕连着左肘。A是邻接矩阵I是单位矩阵加上I是在做图卷积时把节点自身也算进聚合范围不然一层卷完节点自己的信息反而丢了。import torch import torch.nn as nn class GraphConv(nn.Module): def __init__(self, in_channels, out_channels, A, stride1): super().__init__() self.A nn.Parameter(A, requires_gradFalse) # 固定的邻接矩阵不参与训练 self.conv nn.Conv2d(in_channels, out_channels, kernel_size1) # 1x1卷积只在通道维度变换不改变空间形状 def forward(self, x): # x: [B, C_in, T, V]B是batchC是通道T是帧数V是节点数 B, C, T, V x.size() # einsum: 按节点维度做图卷积聚合 x torch.einsum(bctv,vw-bctw, x, self.A) return self.conv(x)这里的einsum做了这样一件事对每个节点t把v维度上的所有邻居特征按A的权重累加结果写到w上。A矩阵是V乘V的方阵A[v][w]非零表示“关节v到关节w有边”。实际项目中不会直接用原始A而是做归一化D^{-1} A或对称归一化D^{-1/2} A D^{-1/2}。不归一化会出现一个问题——度数高的节点比如躯干中心聚合了大量邻居特征数值膨胀训练容易震荡。ST-GCN原论文对邻居做了三种分区策略节点自身是一类、比自身更靠近重心的邻居是一类、比自身更远离重心的邻居是第三类。一张邻接矩阵被拆成三张分别做聚合再拼接。这样做是因为“手靠近躯干”和“手远离躯干”在动作语义上完全不同一套权重算不明白。2.2 时间卷积在帧序列上滑动一维卷积空间关系处理完还要处理时间维。一段视频有几十帧每帧都有V个节点坐标时间维T就是帧数。ST-GCN在每个图卷积层之后跟一个时间卷积层用kernel_size9的一维卷积沿T方向滑动。9帧左右的窗口能覆盖一个基本动作单元。如果时间核太小看不出动作的起承转合拉得太大参数暴涨还容易过拟合。class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A, stride1, temporal_kernel9): super().__init__() self.gcn GraphConv(in_channels, out_channels, A) self.tcn nn.Sequential( nn.Conv2d(out_channels, out_channels, (temporal_kernel, 1), stride(stride, 1), padding((temporal_kernel - 1) // 2, 0)), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.tcn(self.gcn(x))temporal_kernel控制时间感受野毕业设计直接用9就行不需要调。stride用于在浅层到深层之间逐步压缩时间长度——前三层步长为1先充分提取短程时序特征从第四层开始步长变2把时间维折半扩大感受野。通道数跟着翻倍从64到128再到256。整个网络结构可以理解为九个ST-GCNBlock串联通道配置是[64, 64, 64, 128, 128, 128, 256, 256, 256]最后接全局平均池化加全连接分类头。2.3 为什么选固定邻接矩阵而不是让它学习有个容易被问到的细节self.A加requires_gradFalse也就是说邻接矩阵在训练中不更新。原因很简单骨骼连接关系是先验知识——肘关节不可能在训练中自己长到头部去。让它参与训练反而可能学出不符合人体结构的连接可解释性变差在小数据集上容易过拟合。如果你做改进型毕设常见做法是加入一个可学习的残差图A α * BB初始化为零让网络自己发现数据中的额外关联。这个改进很小但答辩时能讲的东西多不少。3. 把骨骼序列变成ST-GCN能吃的张量数据组织与预处理模型结构搞清楚之后数据处理是最容易卡壳的地方。ST-GCN的输入是一个五维张量[B, C, T, V, M]其中C是通道数每个关节有x、y坐标有的还有置信度那就是3个通道T是帧数V是关键点数量M是画面中的人数。处理数据集时最常见的困难就是你的原始标注和这个形状对不上。3.1 认识两套主流数据格式NTU坐标系与OpenPose输出NTU RGBD数据集每条样本包含25个关节点的三维坐标同时提供.skeleton格式文件用matlab或python解析成numpy数组都能用。Kinetics-skeleton是用OpenPose从视频里提取的二维坐标18个关节点格式是(C, T, V, M)其中C的3个通道分别是x、y、置信度。你要做毕业设计时先把数据归一化成统一形状[通道, 帧数, 关节数, 人数]。关节数量的差异不会让你改模型结构。ST-GCN的图卷积层依赖邻接矩阵A的形状你用25个关键点就生成25×25的矩阵用18个关键点就生成18×18。网络中间的图卷积层节点数在通道维度上操作不改变V的个数所以只要把A换成对应大小的矩阵其他代码一行不用动。这也是图卷积相对CNN的一个天然优势。import numpy as np def build_adjacency(num_nodes, edges): 根据骨骼连接关系生成邻接矩阵 A np.zeros((num_nodes, num_nodes), dtypenp.float32) for i, j in edges: A[i, j] 1 A[j, i] 1 # 自连接 A A np.eye(num_nodes, dtypenp.float32) # 对称归一化防止度数高的节点特征过大 D np.sum(A, axis1) D_inv_sqrt np.diag(np.power(D, -0.5)) A_norm D_inv_sqrt A D_inv_sqrt return A_normedges是手工定义的关节连接列表比如[(0, 1), (1, 2)]表示鼻子连脖子、脖子连右肩。这是唯一需要人工介入的部分定义错了整个网络看到的人体结构就是错的。画图检查一遍再拿去训练别凭感觉写。3.2 时间对齐不同长度的动作怎么统一成固定帧数视频长度从几十帧到几百帧都有但ST-GCN要求一个batch内的时间维一致。常见做法是采样固定帧数比如统一采样到100帧或64帧。你可能会想直接 resize 或插值但这会让动作速度快慢发生变化。更合理的做法是分段采样先把整段序列等分成T段每段随机抽一帧。这样既能统一长度又保留动作节奏的稳定性还能起到轻微的数据增强作用。def temporal_sample(sequence, num_frames100): 把任意长度的骨骼序列采样到固定帧数 length sequence.shape[0] if length num_frames: # 均匀分段每段随机取一帧 indices np.linspace(0, length, num_frames, endpointFalse).astype(int) return sequence[indices] else: # 序列太短就复制末帧补齐 pad np.tile(sequence[-1:], (num_frames - length, 1, 1)) return np.concatenate([sequence, pad], axis0)训练时用随机采样让每轮看到的时间点不完全一样相当于一种时序增强验证时用固定等间距采样保证评估结果稳定可复现。如果你的数据大部分长度相近把num_frames设成它们的均值附近就行太大的话后面的时间卷积层感受野覆盖不了全局太小则动作信息被截断。3.3 数据增强怎么做才算对骨骼序列的增强和图像不太一样翻转、缩放、旋转都不能无脑套。左右翻转是安全的因为动作语义基本不会因为镜像改变——“左手挥手”翻转后变成“右手挥手”标签不变。但旋转就要小心有些动作带有方向性比如“投掷”从左上到右下旋转后可能变成别的角度。实际项目中我一般只保留三个操作随机旋转小角度±15度以内、随机缩放0.9到1.1倍、时间维随机裁剪。一个容易被忽视的点是处理多人场景。训练时每个动作可能有多个人出现输入张量的M维大于1。ST-GCN的处理方式是取置信度最高的那个人作为主要目标其余人直接丢弃。在数据集里挑出每帧中得分最高的那个人的骨骼序列简单有效。如果你做的是室外自采数据这一步要写实一点不然训练和推理时的输入分布不一致模型表现会明显变差。4. 用python把ST-GCN训练和推理落地核心代码与参数设置数据处理完毕接下来就是训练模型。这一步的关键不是把代码跑通而是理解训练脚本里每个部分为什么这样设计。很多同学的失败经验是照着开源仓库把命令一敲loss确实在降但验证准确率上不去从头到尾不知道为什么。4.1 训练循环损失函数、优化器、学习率调度分类任务用交叉熵损失这一点没有争议。优化器选择上SGD搭配Nesterov动量在ST-GCN上表现比Adam稳定见过的多份实验记录都显示SGD最终准确率更高。学习率初始0.1配合余弦退火或每30轮降10倍都行。batch size在4到32之间取多少取决于显存——ST-GCN输入的[T, V]本身不大一般16就能跑得动。逻辑说明图卷积训练的长尾风险在于梯度消失所以残差连接在每层之间都会加一遍。另一个细节是BatchNorm2d的参数统计在训练和推理时不一样训练时用当前batch统计推理时用滑动平均所以net.eval()必须调用否则验证结果会莫名波动。model build_stgcn(num_class10, in_channels3) optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, nesterovTrue) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss() for epoch in range(total_epochs): model.train() for batch in dataloader: x, y batch[skeleton], batch[label] x x.cuda() y y.long().cuda() out model(x) loss criterion(out, y) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 每个epoch结束后跑一次验证 validate(model, val_loader)这里x的形状是[B, C, T, V, M]。如果你的数据集按[B, T, V, C]存训练前要做一次permute这个维度顺序错了网络也能跑因为图卷积不强制检查输入维度但结果一定不对。建议在数据加载器里固定输出维度顺序并写一行断言在第一个epoch前检查。4.2 从源码组织到跑通文件结构怎么设计一份能跑的毕业设计代码至少要有dataset.py、model.py、train.py、inference.py四个文件外加训练好的权重和一张结果可视化图。很多已有的开源仓库已经把逻辑写好了但要跑通自己的数据核心改动集中在dataset.py因为每个数据集的解析方式都不同。project/ ├── dataset.py # 数据加载、采样、增强 ├── model.py # ST-GCN网络定义图卷积时间卷积 ├── train.py # 训练循环 ├── inference.py # 加载权重做单样本预测 ├── weights/ │ └── best_model.pth # 训练好的模型权重 └── docs/ └── 项目文档.md # 毕业设计文档inference.py有一个容易踩坑的地方。模型输出的out直接是softmax之前的值要正确拿到类别得先torch.softmax再argmax。有人会把argmax直接做在原始输出上结果类别概率排名全乱了因为最大logit并不等价于最高概率尤其当类别数量多时。更隐蔽的问题是dropout和BatchNorm在推理时的影响如果忘记model.eval()每次推理结果会抖动这个在项目文档里写清楚会加分。4.3 训练到多少轮才够早停与模型选择策略骨架数据维度低不像图像那样需要动辄200轮的训练。NTU RGBD这种大数据集通常要训练上百轮但如果你自己做的是小规模数据集每类几百条样本50轮以内基本就能收敛。我一般观察两条曲线训练损失还在降、验证损失开始回升的拐点就是模型开始过拟合的信号。best_acc 0 for epoch in range(total_epochs): # ...训练... acc validate(model, val_loader) if acc best_acc: best_acc acc torch.save(model.state_dict(), weights/best_model.pth) print(fepoch {epoch} saved, acc{acc:.4f})多保存一个last_model.pth作为兜底省得训练中断时连后悔药都没有。使用best_model.pth做最终评估因为它是在验证集上选的模型泛化能力相对有保障。至于是不是测试集也要评估一次看你们学校的规范——有的导师要求只用验证集调参、测试集只能碰一次这个要提前问清楚。5. ST-GCN训练避坑五个常见问题和排查顺序图卷积网络在实现上比普通CNN多一点“玄学”成分。训练不起来或效果差很多情况下不是模型结构写错了而是数据处理和训练细节出了偏差。下面五条按出现频率排序都是我实际排查过的坑每条按“现象 → 原因 → 解决”列清楚。5.1 损失不下降准确率稳定在随机水平现象loss在前几个epoch里从3.0掉到2.5左右就再也不动验证准确率一直在10%、20%之间像心电图一样晃。原因排查顺序先看输入数据归一化是否做了。骨骼坐标如果直接拿原始像素值数值范围从0到720图卷积的聚合会把数值放大梯度一上来就乱掉。再看标签类别是否从0开始连续编号CrossEntropyLoss要求标签是[0, num_classes-1]的整数如果数据集的类别从1开始loss前期就会乱。解决把骨架坐标除以视频宽高归一化到[0, 1]区间并检查y.unique()确认类别编号。如果归一化做了还不行把学习率从0.1调到0.01再看前几个batch的loss是不是在正常下降。5.2 验证准确率比训练低一大截且差距越来越大现象训练集准确率到了95%验证集只有60%而且每过一个epoch差距都会扩大。原因这是典型的过拟合。骨骼数据维度和信息量远小于图像模型容量本来就不需要那么大。几个ST-GCNBlock的参数对单类几百条样本来说绰绰有余。加上数据增强基本只有翻转和缩放模型很容易把训练样本的噪声细节记住。解决第一批要做的是把中间三层通道数从128压到64减少参数量。然后是增加dropout原论文在全连接层前加0.5的dropout残差块内部也可以加0.2到0.3。最后一个技巧是时间维的随机采样增强如果之前没做补上这一步往往提升最明显。5.3 显存不够或者跑着跑着内存溢出现象batch size不敢开到16一开就OOM有时显存没爆CPU内存先爆了。原因一个隐形雷在数据加载器里。如果dataloader的num_workers设得很大而数据预处理全部在__getitem__里做每个worker都会复制一份数据。骨骼数据本身不大但如果你用OpenPose跑自采视频骨骼点里面还带着每帧的置信度图或原始特征内存就撑不住了。解决离线把数据全部预处理成numpy数组存盘加载时只做切片和采样不要再在__getitem__里算特征。batch_size8对小规模数据集完全够一个ST-GCN模型权重才几十MB显存瓶颈远没有图像模型那么严重。5.4 动作类别在混淆矩阵里永远分不开现象训练完看混淆矩阵“挥手”和“招手”这类动作互相混淆“坐下”和“蹲下”也经常错判。原因两个动作在前几帧空间结构完全一样区别只在后续时序变化。ST-GCN的时间卷积窗口虽然覆盖了9帧但如果数据预处理时把帧数采样得太稀疏中间的动作过渡细节就丢了。另一个可能是这两个类别在数据集中本身标注不干净采集时动作执行得模棱两可。解决把num_frames从64提高到128时间卷积的感受野覆盖更多帧。更直接的方法是像2s-AGCN那样把骨骼的长度特征即骨骼向量的方向和大小作为第二路输入分类结果会明显拉开差距。毕设阶段加一路特征工作量不大答辩时还能讲成是“双流特征融合”的改进。5.5 推理时单条样本预测结果和训练时不一致现象训练时验证集准确率挺高一加载训练好的权重做单条样本推理同样的样本每次结果不同甚至前一帧后一帧差别很大。原因大概率是model.eval()被漏掉了。BatchNorm在训练模式下用batch内的均值和方差推理模式下用累积的全局统计量。忘了切换会让同一帧数据因为batch不同而产生不同的归一化结果。还有一处隐蔽的问题是数据预处理中的随机旋转和随机缩放没有关推理时也在做增强。解决推理脚本里在加载模型后立即调用model.eval()并确认数据增强在推理时不执行。建议代码里用一个全局变量is_train控制增强开关别在图里写死。6. 模型诊断与可视化在验证集上找到证据而不是猜训练完成不等于项目结束。毕业设计答辩时最能拉开差距的部分是把模型“为什么能工作”用可视化的方式讲出来。ST-GCN在这方面的优势很明显它本身就有图结构把注意力权重可视化出来比图片模型画热力图更直观。先做一个最基础的骨架动作可视化。把输入序列和预测结果画成逐帧的关节点折线图每帧之间用连线表示骨骼关系帧序号标在下方缓慢播放或导出成GIF。这一步的代码量很小但已经能让评审知道你做的不是黑匣子。import matplotlib.pyplot as plt def plot_skeleton_sequence(sequence, joints, save_pathNone): sequence: [T, V, 2]把多帧骨骼轨迹画在一张图里显示时空变化 for t in range(sequence.shape[0]): plt.clf() frame sequence[t] for i, j in joints: plt.plot([frame[i, 0], frame[j, 0]], [frame[i, 1], frame[j, 1]], b-, linewidth1.5) plt.scatter(frame[:, 0], frame[:, 1], cred, s8) plt.xlim([-0.1, 1.1]) plt.ylim([-0.1, 1.1]) plt.title(fframe {t}) if save_path: plt.savefig(f{save_path}/frame_{t:03d}.png, dpi100)再说混淆矩阵的可视化。训练完保存所有验证样本的预测类别和真实类别画一张混淆矩阵热力图重点检查对角线上哪个位置颜色偏淡。比如“站立”类别被大量预测成“行走”说明模型对双腿配合的时序特征区分能力弱这时候可以去数据层面找原因——是不是这两类在采集时的动作幅度太接近或者标注本来就不清晰有了混淆矩阵你可以把问题定位到“某两个类别之间的判别性不足”而不是笼统地调参。最后的进阶方向是双流融合。把关节坐标作为第一路输入骨骼向量相邻关节坐标差作为第二路输入两个分支各自过一套ST-GCN最后的特征拼接后送分类头。这个改进的代码量不大但通常能提升3到5个百分点的准确率而且在文档里可以单独拎出来写一章“模型改进”对毕业设计来说性价比极高。动手前先确认原版单流模型复现成功保存好权重做一个基线再动新结构——有基线才能证明你的改进确实有效。这个习惯我带过很多项目都验证过先把基线版本完整跑通、留下记录再开始“改良”项目返工率能低很多希望帮到你。本文还有配套的精品资源点击获取