简介基于ST-GCN时空图卷积网络自适应图卷积的人体动作识别系统以Python源码形式交付面向计算机视觉、深度学习及相关专业的在校学生、教师与算法工程师可用于动作识别方法研究、竞赛复现与毕业设计开发。系统利用人体骨骼关节点序列通过自适应图卷积自动学习关节点间的空间依赖与时间动态从而完成多种人体动作的准确分类。资源包共476个文件、43.69MB既有258个Python核心代码也配备了60个YAML配置文件、数据集与模型权重npy/pkl、测试视频mp4、演示动画gif、可视化页面html以及详细说明文档md/txt覆盖数据准备、模型训练、推理评估和结果展示全流程。目前已有284人学习下载。项目代码经过完整验证解压重命名为英文路径后即可按步骤运行使用说明清晰环境配置和目录结构一目了然。在理解算法原理的基础上还可基于此二次开发增加自定义动作类别或扩展至实时摄像头检测具有较高的学习与工程参考价值。1. 基于ST-GCN自适应图卷积网络的人体动作识别系统这套源码到底解决什么问题你在做健身动作计数或者给摄像头加一个摔倒检测最容易卡住的不是模型选型而是“骨架数据都有了接下来怎么把时序关系利用起来”。基于ST-GCN自适应图卷积网络的人体动作识别系统就是典型的一站式解法它把人体的关节点序列建模成时空图用图卷积同时学习关节之间的空间关系和帧与帧之间的时间演变再配合自适应邻接矩阵让网络不依赖手工固定的骨骼拓扑。这套python源码加上使用说明覆盖了从骨架数据预处理、图卷积模型定义、训练脚本到st-gcn推理的完整链路。适合三类人有OpenPose或MediaPipe输出的关节点数据、但不想从零手写图卷积的开发者拿NTU数据集做课题验证、需要快速跑通一个baseline的研究生以及做课程设计时需要一份“能讲清楚原理、能改参数、能复现结果”的工程参考的人。读完之后你能跑通训练和推理也能定位训练不收敛、推理结果异常这些常见问题。2. ST-GCN原理与自适应图卷积的选型理由为什么骨架动作识别绕不开它2.1 时空图的两条轴空间边与时间边如何组织骨架序列先把输入结构搞清楚。一段动作视频经过姿态估计后会得到每帧每个人的一组关节点坐标比如OpenPose输出的18个点每个点有x、y和置信度。ST-GCN不把这组坐标当成普通矩阵而是组织成一张图同一帧里人体骨骼本来就连接的关节对之间连一条“空间边”不同帧之间同一个关节的前后帧连一条“时间边”。两种边合在一起就是一张时空图。图卷积在这一张图上的传播逻辑很直接。设邻接矩阵为A加上自环得到A_hat再算度数矩阵D那么一层空间图卷积可以写成H_new sigma(D_hat^{-1/2} A_hat D_hat^{-1/2} H W)。H是当前帧所有节点的特征矩阵W是可学习的权重矩阵。这个公式的含义就是每个关节在更新特征时把直接相连的邻居关节信息聚合过来然后再做一次线性变换。我一般会把一段骨架序列的输入张量整理成五维形状(N, C, T, V, M)N是batch大小C是每个关节点的特征通道数通常是2或3对应x/y坐标加置信度T是帧数V是每帧的关节点数量M是帧内的人数。训练和推理前的第一步就是把原始数组reshape成这个形状ST-GCN的模型定义和损失计算都依赖这个约定。2.2 自适应图卷积与固定邻接矩阵的差异从拓扑固定到数据驱动原始ST-GCN用一套预先定义好的邻接矩阵描述关节连接关系这套图拓扑在训练和推理过程中是固定不变的。问题在于固定的拓扑不一定是任务最优的。比如“拍手”这个动作双手在拓扑上离得很远但语义上相关性极强再比如跨人、跨视角的数据固定拓扑很难泛化。自适应图卷积的改进思路是在原始邻接矩阵A上加一个可学习的残差项B最终的聚合矩阵变成A_final A B。B的维度与A一致都是(V, V)既可以是一个全局可学习参数也可以设计成与输入特征相关。多数开源实现的轻量做法是直接用nn.Parameter去学这个矩阵模型从“固定的图拓扑”变成了“数据驱动可调整的图拓扑”这是这套方案最核心的选型理由。选型还有一个原因是落地损失小。在NTU RGBD这类标准数据集上固定邻接矩阵的ST-GCN已经是强baseline而自适应版本通常能再稳定提升2到4个点的Top-1准确率。换成自定义数据时不需要我们为每个动作重新设计骨骼连接网络会自己调整关节之间的聚合权重这对“不知道哪种拓扑最优”的工程场景特别友好。2.3 源码里常见的图卷积实现路径与参数含义一份典型ST-GCN源码包里图卷积层的PyTorch实现大致长这样。核心是把邻接矩阵聚合并到节点维度上只把图卷积限制在关节维度操作时间维度留给后面的时间卷积层处理。import torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_nodes, stride1): super().__init__() # 1x1 卷积用于通道变换等价于对每个节点做一次线性映射 self.conv nn.Conv2d(in_channels, out_channels, kernel_size1) # 自适应残差项一个 (num_nodes, num_nodes) 的可学习矩阵 # 初始化为 0保证训练起点与固定邻接矩阵的 ST-GCN 一致 self.bias nn.Parameter(torch.zeros(num_nodes, num_nodes)) self.stride stride def forward(self, x, A): # x: (batch, channels, frames, num_nodes) n, c, t, v x.shape # 用归一化后的邻接矩阵 A 加上自适应残差 A_hat A self.bias # einsum 只在节点维度做矩阵乘法速度比显式循环快得多 out torch.einsum(nctv,vw-nctw, x, A_hat) return self.conv(out)这里有两个细节值得多说。einsum里的nctv表示batch、通道、帧、节点四个维度vw表示邻接矩阵的维度输出nctw让每个节点都聚合到了其他节点的信息。bias初始化为0非常关键——如果随机初始化模型一开始的自适应矩阵会产生严重扰动的邻接关系训练loss通常会崩。在完整的ST-GCN块里空间图卷积之后一般接着一个时间卷积层再跟BatchNorm和ReLU最后加残差连接。时间卷积层的kernel size通常设为(9, 1)含义是只在帧维度上滑动把时间窗口内的特征融合到一起而不是把不同关节点混在一起算。把这两个子模块叠加十层左右就是ST-GCN的标准深度。3. 从源码跑通到数据准备把骨架序列喂给ST-GCN的最小流程3.1 环境安装与项目结构拿到zip后先看哪几个文件拿到一个带使用说明的ST-GCN源码zip我建议先别急着装依赖花五分钟把目录结构过一遍。典型的源码包会包含模型定义模块、图结构与邻接矩阵生成模块、数据预处理模块、训练入口脚本、推理与评估脚本以及一份使用说明或requirements文件。用表格梳理一下常见文件的作用方便你对号入座。文件或目录常见作用model/ 或 stgcn.pyST-GCN网络结构定义包含图卷积层和时间卷积层graph/ 或 adjacency.py根据骨骼连接关系构建邻接矩阵通常基于OpenPose或Kinect关节点定义dataset/ 或 dataloader.py读取骨架数据做归一化、时序对齐、切窗train.py训练入口包含优化器、学习率策略、训练循环test.py 或 inference.pyst-gcn推理入口加载模型权重输出动作类别使用说明.md 或 README环境要求、数据目录结构、运行命令环境安装最省心的做法是创建一个干净的conda环境再按使用说明里的requirements装PyTorch、numpy和opencv。安装时我会额外注意PyTorch版本与CUDA版本的匹配很多爬坑经验里提到的“源码在自己机器上跑不起来”八成都是torch版本差异带来的API兼容问题这个放到第5章专门讲。3.2 python构建邻接矩阵从人体骨骼关系到图结构邻接矩阵是ST-GCN的第一个黑匣子也是翻车重灾区。构建逻辑不复杂核心是把“哪两个关节是一根骨骼”的语义转换成0/1矩阵。下面这段代码是常见做法以OpenPose的18个关节点为例先定义连接关系再生成对称矩阵并做归一化。import numpy as np # OpenPose 18个关节点的骨骼连接按 (父节点, 子节点) 定义 edges [ (0, 1), (1, 2), (2, 3), (3, 4), # 躯干与头 (1, 5), (5, 6), (6, 7), # 右臂 (1, 8), (8, 9), (9, 10), # 左臂 (1, 11), (11, 12), (12, 13), # 右腿 (1, 14), (14, 15), (15, 16), # 左腿 (2, 17), # 脖子到背部 ] num_nodes 18 def build_adjacency(edges, num_nodes, self_loopTrue): A np.zeros((num_nodes, num_nodes)) for src, dst in edges: A[src, dst] 1 A[dst, src] 1 # 图是无向的两个方向都要置1 if self_loop: A A np.eye(num_nodes) # 对称归一化: D^-1/2 A D^-1/2 d np.sum(A, axis1) d_inv np.power(d, -0.5, whered ! 0) d_inv np.diag(d_inv) return d_inv A d_inv A build_adjacency(edges, num_nodes)这段代码有三个关键点。一是每条边要双向置1否则信息只能单向流动这在无向图建模里是物理错误。二是必须加自环让每个节点在聚合邻居时保留自身特征。三是对称归一化不能省度大的关节如果被多个邻居累计特征数值会被放大归一化之后所有节点的特征尺度才可控。如果你用的是Kinect的25个关节点或者MediaPipe的33个点edges的索引映射关系要按对应骨架定义重写。我踩过的一个坑就是直接用OpenPose索引去读Kinect数据训练出来精度惨不忍睹。拿到任何新数据源第一步就确认关节点索引语义这一步错后面全部白做。3.3 数据预处理与归一化把原始关节点变成网络输入骨架数据的预处理质量直接影响模型上限。常见的预处理流程分三步坐标归一化、时序对齐、格式转换。坐标归一化最常用的是按图像宽高做缩放把像素坐标变成0到1区间再减去整段序列的均值消除摄像机位置带来的偏移。时序对齐是让所有样本的帧数统一比如固定取T60帧不足60帧的就用最后一帧重复填充超过的就均匀采样截断。格式转换要把shape从(T, V, C)转成(C, T, V)再补上batch维和人数维。下面是一个常见的预处理函数结构我会把它放在dataloader里而不是在训练循环里反复调。import numpy as np def preprocess_skeleton(skeleton, target_frames60): # skeleton 原始输入 shape: (T, V, C)C为2或3 T, V, C skeleton.shape # 1. 按图像宽高归一化这里假设宽高已经在外部传入 # 2. 时间维度对齐过短重复最后一帧过长均匀采样 if T target_frames: idx np.linspace(0, T - 1, target_frames, dtypeint) skeleton skeleton[idx] else: pad np.repeat(skeleton[-1:], target_frames - T, axis0) skeleton np.concatenate([skeleton, pad], axis0) # 3. 转成 (C, T, V)去掉置信度通道只保留坐标 x skeleton[..., :2] # (T, V, 2) x x.transpose(2, 0, 1) # (2, T, V) return x.astype(np.float32)这里有个容易被忽视的细节归一化一定要在裁剪和填充之前做否则坐标量纲会乱。另外很多生产场景会去掉置信度通道只保留x和y坐标作为特征因为置信度的分布在不同姿态估计器之间差异很大留着反而可能让模型学到错误先验。如果你的数据里有明显缺失的关节点常见的做法是做一个mask矩阵标记哪些关节点是有效的避免把插值后的假数据当作真实观测参与训练。4. 训练与推理用源码跑通动作分类的核心命令与参数调整4.1 训练入口与关键超参数batch_size、lr、epochs怎么设训练脚本的骨架在各家实现里大同小异但超参数设置是“玄学”重灾区。以NTU RGBD这个公开数据集为例常见设定是batch_size取32或64初始学习率0.001训练100到120个epoch用cosine退火调度weight_decay设1e-4。下面是训练循环里最核心的一段。optimizer torch.optim.Adam(model.parameters(), lr0.001, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) criterion nn.CrossEntropyLoss() for epoch in range(100): model.train() total_loss 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() logits model(batch_x, A) loss criterion(logits, batch_y) loss.backward() optimizer.step() total_loss loss.item() scheduler.step() # 每个epoch结束后跑一次验证集 val_acc evaluate(model, val_loader, A)我特别想提醒的是学习率策略。很多入门代码喜欢用固定的0.01结果loss在某个水平震荡下不去。图卷积网络的特征尺度对学习率敏感0.001起步、配合cosine退火是最少调的方案。batch_size方面如果显存不够优先降低时序长度T而不是batch_size因为T直接参与时间卷积的感受野计算后面推理时T也要保持一致。weight_decay的作用比想象中大它对自适应邻接矩阵的B参数尤其重要。B是一个(V, V)的稠密矩阵如果不加约束很容易出现过拟合——训练集loss降得漂亮验证集表现平平。常见做法是对B单独设置一个较小的weight_decay或者干脆给B加一个soft orthogonality正则但这会引入额外超参我会在baseline稳定之后再去调。4.2 st-gcn推理从骨架序列到动作标签训练完成后推理流程比训练简单得多。核心只有四步加载模型权重、把单段骨架序列预处理成网络输入、前向传播、对softmax输出取argmax。下面这段代码是一个最小可用的st-gcn推理函数。import torch def stgcn_infer(model, skeleton_seq, A, devicecuda): # skeleton_seq 原始 shape: (T, V, C) x preprocess_skeleton(skeleton_seq, target_frames60) # 添加 batch 维和人数维得到 (1, C, T, V, 1) x torch.FloatTensor(x).unsqueeze(0).unsqueeze(-1).to(device) model.eval() with torch.no_grad(): logits model(x, A) # (1, num_classes) prob torch.softmax(logits, dim1) pred_cls torch.argmax(prob, dim1).item() conf prob[0, pred_cls].item() return pred_cls, conf这里两个细节会直接影响正确性。一是model.eval()必须调用否则BatchNorm层的统计量会继续用当前batch的数据更新推理结果会漂移。二是torch.no_grad()必须包住前向传播否则推理时会产生计算图显存泄漏是小事速度会慢不少。pred_cls返回的是类别的整数索引你需要一份类别到动作名称的映射表来输出“跑步”而不是“7”。推理性能上如果序列长度固定为60帧、单人场景一个10层左右的ST-GCN在GPU上单次推理大约在几毫秒量级CPU上也能达到实时性要求。瓶颈通常不在图卷积本身而在姿态估计部分这是整套系统的前置开销。4.3 评估指标与模型保存怎么看结果而不是只看loss训练过程中如果只盯loss曲线容易误判模型质量。我一般会同时关注验证集Top-1准确率和混淆矩阵这两个指标能反映出类别混淆的具体模式。比如摔倒检测里“趴下”和“蹲下”容易被搞混loss虽然下降但这类语义近似类别的错分才是实际部署时需要关注的。保存模型时建议同时保存模型参数和优化器状态以便中断后恢复训练。PyTorch的常见写法是torch.save({model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch}, path)。只保存权重的话恢复训练比较麻烦。推理部署则只需导出model.state_dict()并用与训练时完全相同的模型结构去加载。评估脚本不要自己手写Top-1计算直接用torchmetrics的Accuracy类就行它内部已经处理好了batch维度对齐这类边界问题。如果你是做学术对比还要注意分类头输出和标签编码对齐——有的源码把类别从0开始、有的从1开始这个错位会让准确率暴跌到接近0而且很难排查。5. ST-GCN落地避坑让我翻过车的5个细节与排查方法5.1 现象训练loss不降反升甚至直接NaN原因最常见的有三个——邻接矩阵没有归一化导致特征尺度爆炸自适应矩阵B初始化成了随机值训练一开始就破坏了图拓扑学习率设置过高导致梯度震荡。解决先把A_hat打印出来检查对角线是不是1、行和是不是都等于1附近。如果没归一化回到3.2节的代码做对称归一化。B的初始化改成torch.zeros这是自适应图卷积里最容易被忽略的后悔药。同时把学习率从0.001往下调到1e-4跑10个epoch看趋势。如果主loss正常、某一层激活值出现NaN用torch.autograd.set_detect_anomaly(True)定位是哪一层炸的。5.2 现象推理结果全部预测成同一个类别原因模型确实训练过但eval阶段代码没有调用model.eval()BatchNorm行为异常也可能是数据预处理时训练和推理的归一化统计量不一致——比如训练时做过整段均值减法推理时忘了减输入分布整体偏移。解决先在推理代码里确认model.eval()被调用过。再对比训练和推理的预处理逻辑把均值和方差的计算方式对齐。如果是类别样本不均衡导致的模型偷懒检查训练集的类别分布必要时给CrossEntropyLoss传weight参数。我自己实际排查时这种“全预测同一类”的问题往往是数据预处理不一致导致的模型本身没问题。5.3 现象显存爆掉batch_size稍微调大就OOM原因ST-GCN的输入张量里包含V×V的邻接矩阵运算V越大、batch和时序长度越长中间激活值越占显存。自适应图卷积的B参数本身不大但它在einsum中要与每个batch样本逐层相乘。解决优先把target_frames从120降到60这是显存消耗最敏感的参数。其次把batch_size减半。如果还是不够用梯度累积模拟更大的batch。梯度累积的做法是每执行4个小batch再更新一次参数代码里在loss.backward()之后用if (step 1) % 4 0: optimizer.step()控制。对训练效果影响很小但显存压力能缓解很多。5.4 现象自定义数据集识别率远低于公开数据集原因自定义数据的关节点定义与模型训练时不一致比如源码用Kinect 25点你喂的是MediaPipe 33点索引错位会导致模型学到完全错误的图结构。另一个原因是动作类别数量差异大而分类头还是原来NTU的60类。解决拿到新数据先做一个关节点索引映射层把不同骨架定义的关节统一到模型期望的顺序。比如上一个工程里把MediaPipe手的21个点合并裁剪到模型需要的几组关键点。分类头维度改用自定义数据的动作类别数并重新随机初始化最后一层权重前面层的预训练权重可以保留。训练时先用小学习率微调前面层在加大学习率专门调分类头。5.5 现象源码在自己机器上跑不起来导入就报错原因这类源码大多是两三年之前写的PyTorch版本迭代后部分API路径变了。最常见的是torch.nn.init被移动、老版本的数据加载接口与新版本不兼容以及模型权重pickle文件在不同torch版本之间加载报错。解决严格按使用说明里的依赖版本建环境不要直接用本机最新的torch。如果使用说明没写版本优先搭一个torch 1.8到1.12之间的环境去尝试。代码里遇到ModuleNotFoundError先查是不是torch内部模块的路径问题用git grep找出旧API替换成新API。模型权重加载报错时把torch.load的weights_only参数设为False或者用pickle的encodingbytes参数兼容老文件。提示这类历史源码最大的坑不是算法而是环境。建议在项目目录里放一个requirements-lock.txt把torch、numpy、opencv的具体版本固定下来能省下后续大量重复排错时间。6. 验证一个ST-GCN系统值不值得信可视化、迁移测试与一个推理加速技巧训练报告上的准确率只是第一步。我判断一套基于ST-GCN自适应图卷积的动作识别系统能不能真正落地会从三个角度验证。第一是可视化自适应邻接矩阵把B矩阵和固定邻接矩阵A叠加后画成热力图看网络是否学出了符合直觉的关节对关系。例如“挥手”动作里左右肩关节之间的聚合权重如果明显增大说明自适应机制确实捕捉到了跨拓扑的语义关联而不是在硬记训练样本。这一步特别治愈强迫症因为你能直接看到“黑匣子”内部学出了什么。第二是迁移测试。在NTU上训练好的模型直接拿一段完全没见过的场景视频做st-gcn推理看类别是否正确。如果公开数据集合集上准确率高但真实场景一塌糊涂问题多半出在数据分布差异上这时要回到预处理阶段重新做归一化。第三是速度剖面统计姿态估计、数据预处理、图卷积推理三段各自的耗时。ST-GCN本身只占一小部分瓶颈在姿态估计时优化模型结构是无效投入。一个可以动手试的推理加速技巧测试阶段把自适应矩阵B与固定邻接矩阵A合并成单一矩阵再对这个合并矩阵做预处理预热。因为推理时B已经固定不再更新A_hat A B可以提前算好存起来省掉每一层前向时的加法运算。代码上改动很小在模型加载后对graph layer做一次重写即可。更深一层的做法是把多人的M维度问题拆开处理而不是把所有人同时塞进同一张时空图。如果同一个视频里多人交互每个角色的骨架序列先单独过网络再用一个时序模型融合多个角色的分类结果这样单人的ST-GCN模型可以稳定复用在多人的场景里这也是生产项目里常见的落地结构。我自己最深的教训是把大量时间花在调网络结构上最后发现效果提升最大的反而是预处理里一个归一化参数。做动作识别先把数据入口管好再去折腾模型结构顺序反了会绕很多弯路。希望帮到你。本文还有配套的精品资源点击获取