ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ST-GCN骨骼动作识别源码实战:从环境配置到模型改进

2026/9/28 12:02:11 拓冰建站 浏览量
ST-GCN骨骼动作识别源码实战:从环境配置到模型改进 简介基于时空图卷积ST-GCN的骨骼动作识别Python项目包面向计算机视觉与行为理解方向的开发者、学生解决从骨骼关键点序列到动作分类的建模和工程落地问题。项目提供经典ST-GCN及双流网络实现覆盖训练、测试、离线与实时演示流程可适配NTU-RGB-D、Kinetics等常见数据集。资源共90个文件主要有29个Python脚本网络、处理器、数据接口等模块、13个YAML配置、3个预训练模型权重、演示GIF、MP4视频以及说明文档压缩包约52.54MB目录划分清晰便于按模块对照阅读。已有504人学习下载。借助源码、项目说明与演示素材可理解时空图卷积的构图方式、训练策略和部署细节同时可基于自带预训练模型快速开展推理或改进实验缩短复现与二次开发周期适合有一定深度学习基础的读者直接运行调试。1. 拿到 ST-GCN 骨骼动作识别源码后先别急着跑搜到这份「基于时空图卷积ST-GCN的骨骼动作识别」压缩包的人通常带着两个问题代码能不能跑通跑通之后能不能换成自己的数据。我的回答是能跑但请你先做好连踩三坑的准备——环境、数据、训练。ST-GCNSpatial Temporal Graph Convolutional Network把人体骨骼关键点组织成时空图空间上用图卷积做邻居节点聚合时间上用普通卷积建模帧间运动在 NTU RGBD 这类基准上能稳定做到 80% 以上的识别准确率。这篇笔记不重复论文直接拆解你拿到的源码包里每个模块怎么读、怎么改、怎么训新手能跟着复现熟手能看到参数边界。如果你正准备拿它做毕设或工程基线这篇正合适。2. ST-GCN 为什么能识别动作时空图构建与源码模块对照2.1 骨骼数据为什么不能用普通卷积骨骼动作识别的输入不是图像而是一串关节点的三维坐标比如 NTU RGBD 每帧给 25 个关节点每个点有 (x, y, z)一个动作片段可能有 300 帧。这类数据的本质是图结构节点是关节点边是骨骼连接关系。普通卷积神经网络假设数据分布在规则的网格上比如图像像素卷积核在固定大小的矩形邻域内滑动骨骼关节的连接是不规则的肩关节和肘关节之间的距离在拓扑上很近在坐标空间里却可能隔着背景。把关节点强行排列成伪图像再喂给 2D CNN等于让卷积核去学一套本不存在的空间排列规则效果往往不如直接建模拓扑。RNN/LSTM 可以把每帧关节特征作为时间步输入适合建模时序但空间关系完全依赖网络隐式学习关节之间的父子层级、左右对称这些先验信息都被浪费了。对比之下ST-GCN 的解法更直接用邻接矩阵显式定义关节连接每个节点聚合邻居节点的特征再沿时间轴做卷积。三种方法对骨骼数据的适配度可以看这张表方法输入形式空间关系建模主要问题普通 CNN关节坐标拼成伪图像固定矩形邻域与骨骼拓扑无关关节连接不规则卷积核覆盖不到真实父子关系RNN/LSTM按帧输入的关节特征序列不显式建模关节间关系长序列信息衰减空间关系依赖隐式学习ST-GCN时空图节点关节边骨骼跨帧连接邻接矩阵显式定义关节连接需要额外处理图结构和多人物实现成本略高选 ST-GCN 不是因为它是最新的而是它把空间拓扑和时间动态拆成两个清晰的子问题源码的可读性和可改造性都更好。拿到项目后理解这个建模思路是第一道门槛。2.2 时空图的边从哪里来邻接矩阵与图划分ST-GCN 的图有两类边。空间边连接同一帧内存在骨骼连接关系的两个关节点比如肘关节和腕关节时间边连接相邻帧中的同一个关节点用来传递运动信息。从实现角度看空间边体现在邻接矩阵 A 上矩阵大小是 [V, V]V 是关节点数时间边则由时域卷积处理不需要显式建图。邻接矩阵不是直接用 0/1 表示连接就完事还要做图划分。原论文里把单帧内的邻居按「到重心的距离」分成三组根节点自身、向心节点比根节点更靠近骨架重心、离心节点比根节点更远离重心。这样每个空间图卷积就有三组邻接矩阵分别提取不同层级的运动特征。更简单的做法是只按 hop 距离分也就是 hop0 的 self 连接一组hop1 的相邻节点一组。常见源码里生成邻接矩阵的代码长这样import numpy as np def build_adjacency(edges, num_nodes, max_hop1): 根据骨骼连接关系生成按 hop 距离划分的邻接矩阵 # edges: 骨骼连接对列表比如 [(0, 1), (1, 2), ...] hop_dis np.full((num_nodes, num_nodes), np.inf) for i, j in edges: hop_dis[i, j] 1 hop_dis[j, i] 1 # 计算任意两个节点之间的最短 hop 数BFS 或 Floyd 均可 for k in range(num_nodes): for i in range(num_nodes): for j in range(num_nodes): if hop_dis[i, k] hop_dis[k, j] hop_dis[i, j]: hop_dis[i, j] hop_dis[i, k] hop_dis[k, j] A [] for hop in range(max_hop 1): # 每个 hop 距离一层邻接矩阵0 为 self1 为直接邻居 A.append((hop_dis hop).astype(np.float32)) return np.stack(A) # 输出 [max_hop1, V, V]这里关键点是输出的 A 是一个三维张量第一维是 hop 层数。图卷积做聚合时每一层邻接矩阵单独与特征相乘结果在通道维拼接。如果你后续要自己加边比如把左右手之间的隐含连接也加进图里改 edges 列表即可不需要动模型代码。注意骨骼连接对的定义依赖数据集的关节点编号NTU 和 Kinetics 的编号不一样这块在第 5 章会展开讲。2.3 源码包里的 graph.py 和 stgcn.py 各负责什么解压 zip 后先别急着跑花十分钟把源码文件浏览一遍。常见的 ST-GCN 项目会按职责拆成几个文件模块划分大体如下文件职责核心输出graph.py定义关节点连接关系生成邻接矩阵A 集合shape [K, V, V]stgcn.py 或 model.py定义图卷积层、时域卷积层堆叠网络每个 block 的输出和最终分类 logitsfeeder.py 或 data_loader.py读取骨架文件、帧采样、归一化模型输入张量 [N, C, T, V, M]main.py参数解析、训练、评估、断点恢复checkpoint 文件和测试集准确率理解了这个对应关系你就知道改模型从哪个文件下手换数据集从哪个文件下手。项目说明文档里一般会写明数据集路径和运行命令先按说明里的流程跑通一次再动代码。我见过不少人拿到源码先改模型结构结果数据加载早就在报错白改半天。3. 把 NTU 骨骼数据喂进模型文件解析、张量组装与预处理3.1 NTU 文件命名规则与 skeleton 文本结构NTU RGBD 是目前骨骼动作识别最常用的基准分为 NTU60 和 NTU120 两个版本。每个样本文件名形如S001C001P001R001A001分别代表实验者编号、摄像头编号、表演者编号、重复次数、动作类别。下载后你会得到大量.skeleton后缀的文本文件一个文件就是一个动作样本。skeleton 文件的结构是纯文本第一行写帧数之后每一帧先写该帧的人体数量再逐个人体写关节数量最后是每个关节的 3D 坐标和置信度。读取代码通常长这样def read_skeleton(file_path): 解析 NTU 的 .skeleton 文件返回每帧每人的关节坐标 with open(file_path, r, encodingutf-8) as f: lines f.readlines() num_frames int(lines[0].strip()) skeleton [] idx 1 for _ in range(num_frames): num_bodies int(lines[idx].strip()) # 当前帧有几人体 idx 1 frame [] for _ in range(num_bodies): body_info lines[idx].strip().split() num_joints int(body_info[0]) idx 1 joints np.zeros((num_joints, 3)) for j in range(num_joints): vals lines[idx].strip().split() # 前三个数字是 x, y, z 坐标后面是置信度 joints[j, 0] float(vals[0]) joints[j, 1] float(vals[1]) joints[j, 2] float(vals[2]) idx 1 frame.append(joints) skeleton.append(frame) return skeleton # 结构为 [帧数][人数][关节数, 3]解析逻辑不复杂但有一个细节容易忽视NTU 每帧最多两个人单人样本的 frame 里只有一个元素双人交互样本会有两个。如果不处理人数维度后续组装张量时维度会对不上。一般做法是固定最多取两个人不足补零超过截断。3.2 从 skeleton 到 (C, T, V, M) 张量的组装代码模型吃的输入张量形状是 [N, C, T, V, M]N 是 batch sizeC 是通道数这里是 3对应 xyz 坐标T 是帧数V 是关节点数M 是最大人数。这个维序是 ST-GCN 源码里的约定别自己改成 [N, T, V, C] 之类的顺序否则图卷积的 einsum 会算错。def build_input(skeleton, max_frames300, num_joints25, max_person2): 把解析后的 skeleton 组装成模型输入张量 [C, T, V, M] C 3 # x, y, z out np.zeros((C, max_frames, num_joints, max_person), dtypenp.float32) for t, bodies in enumerate(skeleton): if t max_frames: break for m, joints in enumerate(bodies[:max_person]): # joints 是 [V, 3]转置成 [3, V] 后放入对应位置 out[:, t, :, m] joints.T return out这段代码里两个参数要特别注意。max_frames 控制时间维长度NTU 原始帧数最高 300但很多动作实际只有几十帧如果直接截断会丢掉后半段信息一般做法是帧数不足 300 时用零填充或重复最后一帧帧数超过 300 时在训练阶段随机裁剪测试阶段均匀采样。max_person 固定为 2 是因为 NTU 双人交互样本最多两人如果你换到单人数据集这个值设置为 1 即可但要注意模型最后的分类头可能依赖 M 维度做池化改成 1 后需要同步调整池化逻辑。3.3 归一化、帧采样与多人物对齐的三个细节归一化直接决定训练能不能收敛。常见做法是先把所有关节坐标平移让骨架中心落在原点再按身体尺度缩放。NTU 的 25 个关节点里0 号通常是脊柱中心用它的坐标做平移原点即可缩放可以用两肩之间的距离作为尺度基准。这里给一个参考实现def normalize_skeleton(joints, center_idx0, scale_idx(4, 8)): 平移骨架到原点并按身体尺度归一化 center_idx: 作为坐标原点的关节点编号 scale_idx: 用来计算身体尺度的两个关节点编号 center joints[:, center_idx:center_idx1, :] joints joints - center # 计算躯干参考长度防止除零 scale np.linalg.norm(joints[:, scale_idx[0]] - joints[:, scale_idx[1]], axis1) scale np.mean(scale) 1e-6 return joints / scale注意 scale_idx 的编号是 NTU 的约定换数据集必须查对应的骨架定义文档。帧采样方面训练阶段推荐随机裁剪一段固定长度测试阶段均匀采样保持评估结果稳定。多人物对齐的原则是按人体置信度排序置信度高的放前面这样双人动作里主 performer 始终占据 M0 的位置避免同一个动作因为人物顺序不同产生完全不同的输入张量。4. 拆开源码里的 ST-GCN 模型图卷积、时域卷积与训练配置4.1 图卷积层einsum 实现与维度说明图卷积的核心是把邻接矩阵和关节特征做矩阵乘法再经过一个 1x1 卷积变换特征通道。矩阵乘法完成「邻居特征聚合」1x1 卷积完成「个体特征变换」两者组合就是一层空间图卷积。用 einsum 实现最直观import torch import torch.nn as nn class GraphConv(nn.Module): ST-GCN 空间图卷积层 输入 x: [N, C, T, V] A: [K, V, V]K 是邻接矩阵分组数 def __init__(self, in_channels, out_channels, A): super().__init__() self.register_buffer(A, A) # 不参与梯度更新 self.conv nn.Conv2d(in_channels * A.size(0), out_channels, kernel_size1) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU() def forward(self, x): N, C, T, V x.size() K self.A.size(0) # 每组邻接矩阵分别做聚合结果在通道维拼接 x torch.einsum(nctv,kvw-nkctw, x, self.A) x x.reshape(N, K * C, T, V) x self.conv(x) return self.relu(self.bn(x))einsum 的nctv,kvw-nkctw含义是把每个节点 v 的特征按 A[k, v, w] 加权求和w 遍历所有邻居节点结果在 v 维度上仍是 V 个节点。然后 reshape 把 K 组结果在通道维拼起来1x1 卷积把通道数从 K*C 压缩到 out_channels。这里最容易出错的点是 A 的方向A[k, v, w] 中 v 是目标节点w 是源节点方向反了会导致特征流向反掉训练时 loss 会震荡不降。4.2 时域卷积和残差连接ST-GCN 的基本 block空间图卷积处理完单帧内的关节关系后时域卷积负责沿时间轴建模运动。实现上用普通 Conv2d但卷积核尺寸要设置成 (kernel_size, 1)第二个维度是 1 意味着卷积不在 V 维度滑动因为空间关系已经由图卷积处理过时域卷积不要再碰空间拓扑class TemporalConv(nn.Module): 时域卷积只沿时间维滑动卷积核 def __init__(self, in_channels, out_channels, kernel_size9): super().__init__() self.conv nn.Conv2d(in_channels, out_channels, kernel_size(kernel_size, 1), padding(kernel_size // 2, 0)) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU() def forward(self, x): return self.relu(self.bn(self.conv(x)))把空间图卷积和时域卷积拼起来加上残差连接就是一个完整的 ST-GCN block。残差连接的作用是让梯度跨 block 直接回传堆叠 9 层网络时尤为重要class STGCNBlock(nn.Module): def __init__(self, in_ch, out_ch, A, stride1, t_kernel9): super().__init__() self.gcn GraphConv(in_ch, out_ch, A) self.tcn TemporalConv(out_ch, out_ch, t_kernel) self.residual nn.Sequential() if stride ! 1 or in_ch ! out_ch: # 通道数或时间维长度变化时残差需要做投影 self.residual nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size1, stride(stride, 1)), nn.BatchNorm2d(out_ch)) def forward(self, x): return self.tcn(self.gcn(x)) self.residual(x)这里 stride 参数作用于时间维用于在深层 block 中压缩时间分辨率减少计算量。网络堆叠时通道数从 64 逐渐翻倍到 256时间维 T 逐步减半最后接全局池化和全连接层输出类别数。4.3 训练命令与超参batch、学习率、epoch 的常见取值拿到源码后先按项目说明跑一次原始实验确认环境没问题再调参。训练入口通常是 main.py下面的命令格式是常见做法具体参数名要以你的项目说明为准python main.py \ --phase train \ --dataset NTU60 \ --data-path ./data/ntu60/xsub \ --batch-size 32 \ --lr 0.1 \ --weight-decay 0.0001 \ --epochs 80 \ --num-workers 8超参选择上有一些经验值可以直接套用参数常见取值说明batch-size16 / 32T300 帧时显存压力大不够就降到 8学习率0.1SGD配合 momentum 0.9多卡时可降到 0.01epochs80 ~ 120一般 60 轮后准确率才明显上升别提前放弃weight-decay0.0001L2 正则防止过拟合输入帧数 T150 / 300T 越大精度越高显存开销线性增长网络层数9 ~ 10 个 block深层次要与数据量匹配数据少时减层训练曲线在初期可能长时间横在低位这是 ST-GCN 的常见现象因为空间图卷积参数初始化后需要一段时间才能让梯度传播到所有节点。我习惯前 10 个 epoch 只观察 loss 是否在缓慢下降不要因为准确率不动就立刻调参。5. 跑 ST-GCN 源码的 5 个常见坑现象、原因与排查方法5.1 Python 环境和 PyTorch 版本不匹配现象按照项目说明装完依赖后运行训练脚本直接报ImportError或CUDA error: no kernel image is available for execution on the device。新装 PyTorch 的用户还经常发现torch.cuda.is_available()返回 False但显卡驱动明明正常。原因项目说明里写的 PyTorch 版本和你的 CUDA 驱动不匹配或者安装时用了默认的 PyPI 源装成了 CPU 版 PyTorch。解决先卸载已有 PyTorch再用带 CUDA 标识的安装命令例如pip install torch1.12.1cu113 --extra-index-url https://download.pytorch.org/whl/cu113具体版本号以项目说明要求为准。用 VSCode 配好 Python 环境后先跑import torch; print(torch.cuda.is_available())确认 GPU 可用再继续。这一步能省掉后面一半的报错排查时间。5.2 维度顺序错乱导致训练 loss 一直不降现象loss 在初始值附近震荡或者直接变成 NaN训练几十轮毫无起色。原因输入张量的维序被改过。ST-GCN 约定输入是 [N, C, T, V, M]有同学把骨架数据存成 [N, T, V, C] 喂给模型einsum 的nctv维度对不上图卷积实际在错误维度上做聚合梯度自然无法有效传播。解决在 GraphConv 的 forward 第一行加一句调试输出print(x.shape)确认进入模型的张量是 [N, C, T, V]。同时检查邻接矩阵 shape 是 [K, V, V]其中 V 必须和输入张量的 V 维度一致。这个自检脚本建议保留在源码里换任何数据集都能用到。5.3 换自定义骨骼数据后准确率崩盘现象用项目自带的 NTU 数据训练正常换成自己用姿态估计模型提取的骨骼数据后准确率断崖式下跌甚至训练时报维度不匹配。原因不同数据源的关节点编号完全不同。OpenPose 输出的 18 个关节点顺序和 NTU 的 25 个关节点顺序不一样编号 0 代表的关节不是同一个位置。直接沿用 NTU 的邻接矩阵等于把左手腕的边接到了右脚踝上。解决先查清楚数据源的关节定义文档明确每个编号对应的实际骨骼位置然后写一个映射表把自定义数据重新排列成项目约定的关节点顺序。如果两者关节点数量不同需要在做数据转换时就统一到同一个拓扑上而不是在模型里打补丁。5.4 训练集 loss 很低验证集准确率上不去现象训练集 top-1 能到 95% 以上验证集却卡在 60% 左右不涨典型的过拟合特征。原因骨骼动作识别同样会过拟合尤其是自采数据量只有几千个样本时。模型层数 9 层、通道数 256参数量足够记住整个训练集。源码默认可能没有开 dropout也没有数据增强。解决在分类头全连接层前加一个nn.Dropout(0.5)。训练阶段对骨骼数据做随机旋转、随机平移、随机缩放的数据增强测试阶段关闭所有随机操作。控制模型规模把通道数从 256 降到 128 或 64通常能显著缓解验证集不涨的问题。5.5 复现论文指标总是差几个点现象严格按照论文的超参、自己重新实现了一套最终准确率比论文低 3 到 5 个百分点反复调参也追不回来。原因预处理不一致是最常见的坑。论文训练时随机裁剪帧序列、随机旋转但测试时用的是固定均匀采样有的实现把归一化的均值和标准差用了训练集的统计量测试时没固定下来导致训练和测试分布不一致。解决把预处理拆成 train_transform 和 test_transform 两个独立流程测试流程只保留均匀采样和已保存的归一化参数不掺入任何随机操作。多人样本在训练和测试时都按置信度排序保证 M 维度的语义一致。保存模型时连同预处理参数一起存成 json 或 yaml方便复现。6. 用消融实验和 ONNX 推理验证你的 ST-GCN 改进了多少6.1 手算一次图卷积验证聚合逻辑模型改动前先验证图卷积的实现是对的。用一个 3 节点的极简例子手算一遍确认邻接矩阵方向、聚合结果都符合预期import torch # 邻接矩阵节点0和节点2相连节点1孤立 A torch.tensor([[[1., 0., 1.], [0., 1., 0.], [1., 0., 1.]]]) # [K1, V3, V3] # 输入特征节点0、1、2的特征分别是1、2、3 x torch.tensor([[[[1., 2., 3.]]]]) # [N1, C1, T1, V3] x_agg torch.einsum(nctv,kvw-nkctw, x, A) print(x_agg) # 期望结果节点0聚合为 134节点1保持2节点2聚合为 314如果输出和手算结果不一致优先检查邻接矩阵的转置问题把nctv,kvw-nkctw换成nctv,kwv-nkctw对比一次。这个自检方法比盯着训练曲线猜问题高效得多改任何图结构相关的代码后都应该跑一遍。6.2 消融实验时间边和空间边到底起了多大作用想验证你的改进有没有用最直接的办法是做消融。把邻接矩阵只保留 self 那组跑一次完整的训练和评估再和完整邻接矩阵的结果对比差出来的指标就是空间聚合的贡献。同理把 TemporalConv 的 kernel_size 改成 1 相当于去掉时间建模对比后能看到时间维度的价值。# 只保留 self 连接的消融 A_self A[:, 0:1, :, :] # 取 K 维的第一组邻接矩阵 model_ablated STGCNBlock(in_ch, out_ch, A_self)不过消融实验要控制变量只改一个模块跑完整训练不要多个改动一起上否则分不清准确率变化来自哪里。我一般会在项目说明里的实验记录表上直接加一列把每个改动的指标记下来方便复盘。6.3 导出 ONNX 并用 onnxruntime 对比输出训练完成后把模型导出成 ONNX 格式可以脱离 PyTorch 环境做推理部署到服务端或嵌入式设备时延迟更低import torch model.eval() dummy_input torch.randn(1, 3, 300, 25, 2) # [N, C, T, V, M] torch.onnx.export(model, dummy_input, stgcn.onnx, input_names[skeleton_input], output_names[action_logits], dynamic_axes{skeleton_input: {0: batch_size}, action_logits: {0: batch_size}})dynamic_axes 允许推理时 batch size 可变否则导出的模型只能固定 batch 跑。导出后用 onnxruntime 加载模型喂同一份输入数据对比 PyTorch 输出和 ONNX 输出数值误差在 1e-4 以内就说明转换成功。我在这类项目上吃过的亏超过一半发生在数据准备阶段每次换数据集都先把自检脚本跑一遍再改模型代码最后才谈部署优化。希望帮到你。本文还有配套的精品资源点击获取