ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ST-GCN骨骼动作识别实战:图卷积网络原理与训练避坑指南

ST-GCN骨骼动作识别实战:图卷积网络原理与训练避坑指南 简介一份基于时空图卷积ST-GCN的骨骼动作识别Python项目压缩包内含可直接运行的完整工程源码与配套项目说明主要面向计算机、数学、电子信息等专业学生可作为课程设计、期末大作业或毕设的参考资料也适合对动作识别、图神经网络感兴趣的开发者学习研究。包内共90个文件大小约52.55MB29个Python源码覆盖数据预处理、模型构建、训练与推理完整流程13个YAML配置便于调节实验参数3个PT权重文件提供预训练模型GIF/MP4演示动画和可视化脚本方便直观查看识别效果另有说明文档帮助快速上手。目前已有586人学习下载。项目基于NTU-RGBD等骨骼数据集包含原始ST-GCN及双流变体实现目录结构清晰代码注释配合项目说明可支撑读者在现有框架上二次开发用于特定动作类别的训练、测试与效果可视化。1. 骨骼动作识别为什么绕不开 ST-GCN它到底解决了什么问题骨骼动作识别这几年成了视频理解里最“省事”的方向——不需要 RGB 图像里那些衣服、背景、光照干扰只要 25 个关节点NTU 数据集或者 18 个关节点Kinetics 骨架版本的坐标序列就能判断一个人在做什么动作。但早期做法要么把骨骼坐标拉平成向量丢给 LSTM要么把关节点铺成伪图像丢给 CNN都很别扭LSTM 抓得住时序却把骨骼拓扑关系丢了CNN 反过来把关节点的连接结构硬编码进卷积核换个数据集就失效。ST-GCNSpatial Temporal Graph Convolutional Network时空图卷积网络直接把这个任务定义成“在图中做卷积”关节点是图的节点骨骼连接是图的边时间维度的同一个关节点前后帧之间再连成一条边。这个项目正好是带 Python 源码和说明的完整实现对想跑通骨骼动作识别、理解图卷积怎么落地的人来说是最合适的一块敲门砖。2. 从骨骼数据到时空图邻接矩阵、采样与数据预处理2.1 骨骼数据到底是什么格式关节点坐标与时空图拿到手的骨骼数据一般是一个四维结构(样本数, 帧数, 关节点数, 坐标维度)。NTU RGBD 是 25 个关节点、每个点(x, y, z)三维坐标Kinetics 骨架版本是 18 个点、(x, y)二维坐标。一个动作序列就是几十上百帧每一帧是一组关节点坐标帧与帧之间同一个关节点有明确的对应关系——比如第 10 帧的右手腕和第 11 帧的右手腕是同一个节点在时间上的延续。这就是“时空图”的基本素材空间边来自人体骨骼连接手肘连手腕、膝盖连脚踝时间边来自相邻帧的同一节点。构建时空图最核心的一步是把“连接关系”变成机器能算的矩阵形式。骨骼连接关系是一张图节点之间的邻接矩阵就是图卷积的“卷积核作用范围”。我一般习惯先用一个简单的骨骼拓扑定义文件把边的列表写清楚比如(源节点, 目标节点)的元组列表再交给函数生成邻接矩阵。import numpy as np # 以 NTU RGBD 的 25 节点为例只列一部分骨骼连接 # 节点索引对应关系需要查数据集的 skeleton 定义文档 edges [ (0, 1), (1, 2), (2, 3), (3, 4), # 躯干到头部 (1, 5), (5, 6), (6, 7), # 左臂 (1, 8), (8, 9), (9, 10), # 右臂 (0, 11), (11, 12), (12, 13), # 左腿 (0, 14), (14, 15), (15, 16), # 右腿 ] def build_adjacency_matrix(num_nodes, edge_list): A np.zeros((num_nodes, num_nodes), dtypenp.float32) for src, dst in edge_list: A[src, dst] 1.0 A[dst, src] 1.0 # 骨骼连接是双向的信息可以双向传播 return A A build_adjacency_matrix(num_nodes25, edge_listedges) # 加自环保证每个节点在卷积时能保留自身特征 A_hat A np.eye(25)这段代码生成的邻接矩阵是图卷积的“空间聚合范围”来源。加自环这一步非常关键如果对角线上是 0图卷积算出来的每个节点特征就完全没有自己上一层的分量信息流等于每次只沿着骨骼边传播深层网络的表达能力会明显下降。ST-GCN 的官方实现里邻接矩阵都是带自环的这个细节直接关系到精度后面避坑章节会再展开。2.2 构建时空邻接矩阵一张图搞定空间建模空间邻接矩阵只解决“同一帧里哪些关节点能互相传递信息”但动作识别里一个挥手动作的判别往往靠的是手在时间上连续几帧的运动轨迹。所以 ST-GCN 的关键创新是把空间图和时间线拼在一起把某一帧的 25 个节点平铺成一排下一帧的 25 个节点排在后面空间边连同一帧内的骨骼节点时间边连相邻帧的同一个节点。这样整段骨骼序列就变成了一张大图图卷积同时在空间和时间两个维度上做特征聚合。在实践中不需要像上面那样把所有帧的边都显式写出来。ST-GCN 的实现通常是把骨骼序列表示成(C, T, V)的特征张量然后空间卷积用邻接矩阵乘法做时间卷积用 kernel 大小为(9, 1)的普通 2D 卷积在时间维上滑动。空间和时间是在网络层内部解耦的但信息和效果上等价于在同一张时空图上做卷积。def normalize_adjacency(A): # 度归一化D^{-1} A或者更稳定的 D^{-1/2} A D^{-1/2} D np.sum(A, axis1) D_inv_sqrt np.power(D, -0.5) D_inv_sqrt[np.isinf(D_inv_sqrt)] 0.0 D_inv_sqrt np.diag(D_inv_sqrt) return np.dot(np.dot(D_inv_sqrt, A), D_inv_sqrt) A_norm normalize_adjacency(A_hat)度归一化是为了防止图卷积堆深之后数值爆炸。如果不做邻接矩阵乘一次特征相当于把所有邻居的值加起来节点度越高聚合后的数值越大层数一多就溢出或者梯度爆炸。D^{-1/2} A D^{-1/2}是对称归一化兼顾了聚合数值稳定和节点自身特征保留是大多数 ST-GCN 复现里默认的做法。2.3 数据归一化与时间采样决定模型收敛速度的细节很多人把精力全放在网络结构上结果训练时发现 loss 怎么都不降最后定位到骨骼坐标没归一化。NTU 原始坐标来自 Kinect 深度相机坐标范围可能到几百甚至上千直接喂给 BN 层虽然理论上能压住但训练的起始阶段数值波动极大收敛很慢。我的习惯是先做两件事以髋关节中心为原点做平移再除以一个固定尺度。import numpy as np def normalize_skeleton(sequence): sequence shape: (T, V, C) 返回 shape: (C, T, V)这是 ST-GCN 的输入布局 # 以 0 号节点髋关节中心为原点 center sequence[:, 0:1, :] # (T, 1, C) sequence sequence - center # 除以躯干长度或一个固定经验值让坐标落到 -1~1 量级 scale np.abs(sequence).max() 1e-6 sequence sequence / scale # 转成 ST-GCN 期望的 (C, T, V) sequence sequence.transpose(2, 0, 1) return sequence.astype(np.float32)时间采样是另一个决定性细节。一段动作的原始帧数是变长的比如“喝水”可能 30 帧“走路”可能 100 帧但模型输入需要固定长度。常见做法是把所有序列统一采样到 64 帧或 128 帧。我一般先用线性插值把序列拉到目标帧数而不是简单跳帧抽取因为跳帧会把一些快动作的关键姿态丢掉。def temporal_resample(sequence, target_frames64): sequence shape: (C, T, V) C, T, V sequence.shape indices np.linspace(0, T - 1, target_frames).astype(int) return sequence[:, indices, :]参数说明target_frames是超参数NTU 上的经验值是 64Kinetics 上常用 100 或 150。帧数越大模型能看到的时间细节越多但显存和训练时间也线性涨。采样时用linspace取等间距索引是复现 ST-GCN 最常见的做法比随机采样稳定评测结果可复现。3. ST-GCN 网络结构拆解图卷积、时间卷积与残差设计3.1 ST-GCN Block空间图卷积和时间卷积如何交替ST-GCN 的基本单元可以理解成“先做空间图卷积再做时间卷积”。空间图卷积把邻接矩阵和特征张量做矩阵乘法特征张量是(N, C, T, V)在最后一维乘上归一化邻接矩阵(V, V)相当于每个节点把邻居节点的特征加权求和。时间卷积则是把特征当成(N, C, T, V)的张量用Conv2d在时间维上滑动kernel 大小设成(9, 1)意思是每次只看 9 帧、只看同一个关节点。空间图卷积负责“同一时刻谁影响谁”时间卷积负责“同一个点前后几帧怎么变化”。这两个操作要交替堆叠因为空间聚合和时间聚合是互补的图卷积把相邻关节点的信息合并时间卷积把连续帧信息合并。只堆空间不堆时间模型对“站着挥手”和“躺着挥手”的区分能力就很弱只堆时间不堆空间左右手动作容易混淆。ST-GCN 一共堆叠 9 个这样的 Block通道数从 64 逐渐涨到 256。3.2 层级设计通道数、stride 和感受野是怎么定的ST-GCN 的网络通道设计类似 ResNet3 个阶段每阶段 3 个 Block通道数分别是 64、128、256。前两阶段内部的最后一个 Block stride 设为 2目的是在时间维上做下采样——把 64 帧压到 32 帧再压到 16 帧和图像分类网络里每层分辨率减半是一个道理。这样做的好处是逐层扩大时间感受野把局部动作模式逐步组合成全局动作模式。stride 下采样的实现方式比较隐蔽不是在时间卷积里直接设 stride2而是先额外用一个Conv2d对特征做时间维的步长为 2 的卷积再接正常的 Block。我记得第一次自己搭的时候直接改 TCN 卷积的 stride结果后面层的输入帧数对不上报 shape 错折腾了很久才意识到 ST-GCN 的 stride 是在残差连接之前单独处理的。这是源码里最容易看漏但训练时必须对齐的细节。3.3 用 PyTorch 搭一个可训练的 ST-GCN 核心模块下面是一个可以直接跑通的简化版 ST-GCN Block。为了降低理解门槛我把空间图卷积实现成矩阵乘法而不是论文里的分区策略精度会略低但结构完整。import torch import torch.nn as nn import torch.nn.functional as F class SpatialGraphConv(nn.Module): 空间图卷积对每个时刻 t 做特征聚合 x: (N, C, T, V) A: (V, V) 归一化邻接矩阵 def __init__(self, in_channels, out_channels, num_nodes): super().__init__() self.conv nn.Conv2d(in_channels, out_channels, kernel_size1) self.A nn.Parameter(torch.from_numpy(A_norm).float(), requires_gradFalse) self.num_nodes num_nodes def forward(self, x): N, C, T, V x.shape x self.conv(x) # (N, out, T, V) # 把空间维移到最后一维做矩阵乘法 x x.permute(0, 2, 3, 1) # (N, T, V, out) x torch.einsum(ntvc,vw-ntwc, x, self.A) x x.permute(0, 3, 1, 2) # (N, out, T, V) return x class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, num_nodes, stride1): super().__init__() self.gcn SpatialGraphConv(in_channels, out_channels, num_nodes) self.tcn nn.Sequential( nn.Conv2d(out_channels, out_channels, kernel_size(9, 1), padding(4, 0)), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) # 通道或时间分辨率变化时残差用 1x1 卷积对齐 if in_channels ! out_channels or stride ! 1: self.residual nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stride(stride, 1)), nn.BatchNorm2d(out_channels), ) else: self.residual nn.Identity() def forward(self, x): res self.residual(x) out self.gcn(x) out self.tcn(out) return F.relu(out res)逻辑说明SpatialGraphConv先用1x1卷积把通道数变换到目标维度再用einsum做邻接矩阵乘法。einsum的写法ntvc,vw-ntwc表示在 V 维度上聚合邻居节点信息。A是固定的归一化邻接矩阵不需要梯度更新所以requires_gradFalse。STGCNBlock里tcn是时间卷积块kernel_size(9, 1)只覆盖时间维度padding(4, 0)保证帧数不变。残差连接里stride(stride, 1)让时间维减半从而撑起多层堆叠。参数说明in_channels和out_channels是每层的通道数按照 3 阶段设计分别设成 64→64→64→128→128→128→256→256→256。num_nodes是关节点数NTU 用 25Kinetics 骨架用 18。stride只在第 3 层和第 6 层设为 2其余为 1。时间卷积核大小 9 是论文的原始设定改小到 5 会降低感受野改大到 13 会增加参数量且容易过拟合。4. 训练配置与效果验证跑通一个动作识别任务的参数清单4.1 数据集划分与评测协议cross-subject vs cross-view骨骼动作识别领域最容易让复现者翻车的不是模型代码而是评测协议。NTU RGBD 数据集的官方评测有两种cross-subject 是把 40 个受试者的数据分成训练集和测试集训练的人不在测试集里出现cross-view 是按摄像机视角划分。两者的精度差异很大——cross-subject 通常比 cross-view 低 3 到 5 个点因为跨人的骨骼体型差异比跨视角更难适应。我见过有人拿着 cross-view 的结果去对比别人 cross-subject 的论文然后一头雾水地找精度差距原因其实是用错评测口径。Kinetics 骨架版本的划分逻辑又不同按视频样本名哈希分 train/val不做跨人划分。所以训练前第一步是确认自己手里的源码用的是哪个协议数据加载代码里有没有按官方 split 文件切分。4.2 关键超参数和训练策略试过才知道的配置我在 NTU RGBD 的 cross-subject 协议上跑 ST-GCN 时超参数大致是这批batch size 64初始学习率 0.1权重衰减 0.0001总共 80 个 epoch学习率在第 40 和第 60 epoch 各除以 10。优化器选 SGDmomentum 0.9没用 Adam——ST-GCN 这类深层图卷积网络用 Adam 前期收敛快但后期精度通常不如带 momentum 的 SGD。optimizer torch.optim.SGD( model.parameters(), lr0.1, momentum0.9, weight_decay0.0001, ) scheduler torch.optim.lr_scheduler.MultiStepLR( optimizer, milestones[40, 60], gamma0.1, )为什么把初始学习率给到 0.1ST-GCN 网络里的 BatchNorm 层很多BN 对这种大学习率有天然的稳定性0.1 在 8 卡同步训练下是论文原版配置。如果你只有单卡batch size 降到 16学习率最好也同步降到 0.02 左右不然 BN 的统计量不稳定loss 会出现锯齿抖动。这是一个很容易踩的隐性问题单卡跑原版学习率前 10 个 epoch 看起来 loss 在降但到中期开始震荡最后精度比标准结果低 2~3 个点。4.3 从混淆矩阵看模型在哪些动作上翻车训练完成后不要只盯 Top-1 精度我会额外画一张混淆矩阵按动作类别统计错误配对。做这个项目的经验是ST-GCN 最容易混淆的是“读文件”和“写字”这类手部动作接近、但幅度很小的类别其次是“坐下”和“站起来”这类互为反向的动作。原因是骨骼数据只包含关节坐标缺少手部朝向和受力信息纯几何信息不足以区分这些动作。from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt cm confusion_matrix(y_true, y_pred) plt.figure(figsize(20, 20)) plt.imshow(cm, cmapBlues) plt.colorbar() plt.xlabel(Predicted Label) plt.ylabel(True Label) plt.savefig(confusion_matrix.png, dpi100)看到混淆矩阵之后可以针对性地做两件事一是对高频混淆的动作对做数据增强——训练时对骨骼序列随机旋转、缩放、时间裁剪让模型看到更多动作幅度变化二是检查数据标注本身有没有问题NTU 里部分样本同一动作的起止帧切得不干净导致类别边界模糊。这类问题靠调网络结构是解决不了的需要从数据侧处理。注意混淆矩阵对类别数多的数据集NTU 60 类画出来很大建议只抽高频混淆的 10~15 个类别单独画不然热力图色块挤在一起根本看不出谁和谁混。5. ST-GCN 常见坑与排查笔记现象、原因、解法5.1 训练 loss 不降数据归一化才是第一责任人现象模型结构确认和论文一致batch size 和学习率也都合理但训练到第 5 个 epochloss 还在 4.5 左右下不去连续几个 epoch 几乎不动。原因骨骼坐标没有做任何归一化。NTU 原始坐标范围横跨几百个像素BN 层在输入数值量级差异大的情况下均值和方差的滑动统计不稳定网络很难学出稳定的特征映射。我遇到过坐标范围在 -800 到 800 之间的输入直接喂进去训练 loss 卡住不动的情况。解决先按第 2 章的方式做中心化和尺度缩放把坐标压到 -1~1 之间。步骤是取每个序列第一帧的髋关节坐标作为原点做平移再除以全数据集坐标绝对值最大值。这个操作对精度的影响通常是决定性的——同一套代码归一化前后 Top-1 可以差 10 个点以上。5.2 邻接矩阵没归一化深层网络的图卷积数值爆炸现象前 20 个 epoch 一切正常第 30 个 epoch 之后 loss 突然变成 NaN训练中断。原因邻接矩阵直接用 0/1 值参与图卷积没有做度归一化。0 度节点聚合邻居特征后数值随层数上升经过 9 个 Block 的反复聚合数值涨到 fp32 的上界变成 NaN。这个问题和网络深度强相关——只堆 3~4 层时一般不会爆堆到 9 层必爆。解决邻接矩阵生成后立刻做D^{-1/2} A D^{-1/2}对称归一化。同时把模型前向里的邻接矩阵转成torch.float32不要在 CPU 上算好再传进 GPU 时不小心转成 float64——我在一次实验中张量类型不一致图卷积的矩阵乘法报类型错误排查半天才看到是float64和float32不匹配。5.3 张量维度顺序N、C、T、V 还是 N、T、V、C现象模型 forward 报错提示某一维的 size 对不上或者输出精度极低但代码能跑通不报错。原因ST-GCN 的输入约定是(N, C, T, V)但很多基于 RNN/LSTM 的老代码习惯(N, T, V, C)。两者看着差不多实际效果差很多——图卷积的einsum是按 V 在最后一维设计的传错维度后相当于对 C 维做了图卷积模型还在“训练”但学的完全不是骨骼拓扑特征精度自然低。解决在数据加载函数末尾强制加一行断言shape 不合法直接报错而不是让错误在训练中途才暴露def to_stgcn_input(sequence): # sequence: (T, V, C) - (C, T, V) sequence sequence.transpose(2, 0, 1) assert sequence.shape[0] C, fexpect C{C}, got {sequence.shape[0]} return sequence5.4 精度比论文低多数是评测协议用错了现象训练流程看起来正常loss 也收敛得不错但测试精度比论文低 5~8 个点。原因评测协议不一致。有人用 cross-subject 的训练/测试划分却拿着 cross-view 的测试集评测也有人把训练集里的一部分样本又放进测试集造成数据泄漏测试精度反而异常高。骨骼动作识别领域对协议极其敏感一旦划分错误模型对比就没有意义。解决去数据集官方页确认划分文件加载时逐条检查训练集和测试集的样本 ID 是否有交集。用set快速过滤一次train_ids set(np.load(train_ids.npy)) test_ids set(np.load(test_ids.npy)) overlap train_ids test_ids assert len(overlap) 0, ftrain/test overlap: {len(overlap)} samples5.5 显存不够时间窗口和 batch size 的取舍现象batch size 64、帧数 128 的配置在 11G 显存的卡上直接 OOM。原因ST-GCN 的显存占用和帧数 T、batch size N 都线性相关9 层 Block 叠加后激活值累积很大。单卡跑论文原版配置几乎都会 OOM。解决在帧数不超过 64 的前提下先把 batch size 降到 16再不行降到 8。如果帧数必须保持 128 才能达到目标精度就关掉中间特征存储用torch.utils.checkpoint对 Block 做梯度检查点用时间换显存。还有一个容易被忽略的点数据集加载时不要一次性把所有骨骼序列全部转成float32放进内存用np.memmap或者按需读取不然 CPU 内存先爆再好的 GPU 也白搭。6. 进阶用法把训练好的 ST-GCN 接进真实识别链路6.1 从姿态估计输出到 ST-GCN 输入的坐标映射实际落地时动作识别很少直接拿到干净的骨骼数据而是先经过姿态估计这一步常见开源方案可以把视频帧转成 18 或 25 个关节点坐标。从姿态估计输出到 ST-GCN 输入有三个必须处理的点。第一是坐标缩放姿态估计输出的坐标是像素坐标要和训练时的数据分布对齐否则会在网络入口处产生分布漂移。第二是缺失节点补全姿态估计经常在人被遮挡时丢关节点最简单的策略是把缺失节点补零但帧间位移会突变不如用前后帧线性插值补。第三是滑窗推理视频长度不定不能整段视频一次性塞进 ST-GCN它需要固定帧数用一个 64 帧的滑动窗口逐步做预测对窗口输出的类别概率做投票或平均作为整段动作的最终判断。6.2 用小数据验证模型是否正确——比盲调超参数更高效训练完的第一件事不是看精度而是做一个“反向验证”取测试集里一个样本把骨骼序列画出来同时打印模型对这个样本各类别的预测概率。如果概率分布集中在某个类别但类别不对先去查训练集的标签质量如果概率分布均匀说明模型没学会区分这种动作。这比对着 loss 曲线猜慢但能少走很多弯路。我在跑这个项目时养成了一个习惯每次改网络结构或数据预处理逻辑先用最小的数据子集比如每个类别 10 个样本跑 20 个 batch确认 loss 从初始值开始下降且没有报错再上全量数据训练。这样把结构问题隔离在分钟级而不是小时级。ST-GCN 这个方向真正值得投入的地方在于骨架数据天然轻量——不需要视频解码和光流一个动作序列才几千个浮点数模型参数量也远小于视频 CNN做实时推理和边缘端部署都有明显优势。希望这套从数据到训练再到排查的流程能帮你少踩几个坑。本文还有配套的精品资源点击获取
返回列表