ST-GCN骨骼动作识别:图卷积如何建模人体骨架
2026/9/14 21:01:43 网站建设 项目流程

简介:面向人工智能、计算机视觉相关专业学生与开发者,基于时空图卷积(ST-GCN)的骨骼动作识别项目,覆盖数据预处理、模型训练到离线/实时演示的完整 Python 流程,可支撑毕业设计、课程设计或项目立项。压缩包共 90 个文件,约 52.55MB,核心为 29 个 Python 脚本,另含 YAML 参数配置、预训练模型、演示视频与说明文档,方便对照源码理解模型结构与参数设置。目前已有 229 人学习下载,内容包含 NTU-RGB-D 与 Kinetics 数据集生成、双流 ST-GCN 网络、训练推理主流程及离线/实时 Demo,并附测试通过的权重文件,可直接运行复现识别效果。清晰的 feeder、processor、net 等模块划分帮助快速定位代码逻辑,减少调试成本;对需要完成课设或毕设的同学,也可在此基础上扩展消融实验或改进网络。

1. ST-GCN骨骼动作识别:人体骨架为什么适合用图来建模

拿到一段动作数据,如果只有骨骼关键点坐标而没有视频画面,你会用什么模型分类?常见思路是把关键点序列排成二维矩阵丢给CNN,或者按时间顺序喂给RNN。但人体骨架本质上是图结构:25个关节点通过骨骼边相连,动作是这张图在时间轴上的演化。ST-GCN(Spatial Temporal Graph Convolutional Network)把图卷积引入骨骼动作识别,在空间维度用邻接矩阵聚合关节信息,在时间维度用卷积捕捉帧间变化,既保留人体拓扑,又避免把骨架强行铺平成图像。这个 zip 通常对应一套完整的 Python 工程,包含模型定义、训练脚本、数据预处理和项目说明文档,适合做行为识别、人机交互、运动分析方向的研究生和工程师直接改造。

2. 骨骼动作识别的时空图构造:邻接矩阵、分区策略与时间卷积

2.1 骨骼数据表示:关键点序列如何组织成张量

骨骼动作识别的原始输入不是视频帧,而是每一帧里若干关节的三维坐标。以 NTU RGB+D 数据集为例,每帧标注 25 个关节点,坐标为 (x, y, z);一段动作有 T 帧,整段数据就是一个形状为[T, V, C]的矩阵,其中 V=25 是关节点数,C=3 是坐标维度。单人的输入张量通常组织成[C, T, V],多人场景再增加一个 M 维表示人数,变成[N, C, T, V, M]

import numpy as np T, V, C = 64, 25, 3 # 64 帧、25 个关节点、3 维坐标 skeleton = np.zeros((C, T, V), dtype=np.float32) # 单样本 [C, T, V] batch = np.zeros((32, C, T, V), dtype=np.float32) # 批量 [N, C, T, V]

这种组织方式和图像张量[N, C, H, W]最大的区别在于:图像像素有天然的空间网格结构,而骨骼关节的相邻关系不是由坐标距离决定的,而是由人体骨骼连接定义的。手腕和肩膀在坐标上可能离得很远,但它们之间有一条骨骼边;左手和右手坐标上可能相邻,但没有直接连接。因此,卷积核不能直接套在关节维上,需要先构造描述关节连接关系的图。

ST-GCN 把每一帧的骨骼结构看作一个无向图 G=(V, E),节点 V 是关节,空间边 E 是骨骼连接,时间边则连接相邻帧中同一个关节。这样一段动作就构成一个时空图,空间图卷积处理单帧内的关节点聚合,时间卷积处理帧间的动态变化。

2.2 空间图卷积:度矩阵归一化与分区策略

有了图结构,下一步是把图卷积算子落到骨骼数据上。经典的图卷积公式为:

f_out = A_normalized @ f_in @ W

其中 A_normalized 是归一化邻接矩阵,W 是权重矩阵。ST-GCN 使用的是对每个关节聚合其邻居特征的方式,公式展开为:

f_out = D^(-1/2) A D^(-1/2) f_in W

D 是度矩阵,D(i,i) 表示节点 i 的邻居数量。为什么要做这个归一化?因为不同关节的连接数差异很大,躯干上的关键点连接多,四肢末端连接少,如果不归一化,特征数值的尺度会随节点度变化,训练时容易不稳定。常见的做法是对每个关节的邻居特征做均值聚合,而不是直接求和。

原始的图卷积把所有邻居当成同一种关系处理,这过于粗糙。相邻的关节可能是手腕到手指,也可能是手腕到手肘,语义完全不同。ST-GCN 引入了分区策略,把邻居节点分成不同子集,每个子集独立学习权重。三种常见分区策略对比如下:

分区策略子集数量划分方式适用场景
uniform1所有邻居共享一组权重小规模数据、快速验证
distance2根节点一组,其余邻居一组骨干网络较浅时
spatial configuration3根节点、向心邻居、离心邻居ST-GCN 默认,识别效果最好

spatial configuration 是 ST-GCN 原作者采用的方案。它先计算每个节点到骨架重心的距离,把邻居划分为比根节点更靠近重心的“向心”子集、更远离重心的“离心”子集以及根节点本身,共三个子集。这样既区分了运动方向,也不需要预定义关节的语义标签。

import numpy as np def build_spatial_config_adjacency(num_nodes, edge_list): # 构造 3 个分区邻接矩阵:根节点、向心、离心 A = np.zeros((3, num_nodes, num_nodes)) # 先按物理骨骼连接填充 A[0] 为 1 for i, j in edge_list: A[0, i, j] = 1.0 A[0, j, i] = 1.0 # 省略按重心距离填充 A[1]、A[2] 的逻辑 for k in range(3): D = np.sum(A[k], axis=1) + 1e-6 D_inv_sqrt = np.diag(1.0 / np.sqrt(D)) A[k] = D_inv_sqrt @ A[k] @ D_inv_sqrt return A

这段代码的输入edge_list是关节连接对的列表,例如(0, 1)表示节点 0 和节点 1 之间有骨骼连接。输出形状为[3, V, V]的张量,第一个维度是分区索引。每个分区的邻接矩阵独立做度归一化,避免不同分区的数值量纲不一致。实际项目中,这个矩阵只需要在初始化时计算一次,注册为模型的 buffer,不需要参与梯度更新。

2.3 时间卷积:固定核长的时序建模

空间图卷积完成了单帧内关节信息的聚合,输出张量形状为[N, C, T, V]。接下来要建模帧与帧之间的依赖关系。ST-GCN 在时间维上使用一维卷积,核大小通常取 9,只在 T 维上滑动,不在关节维上滑动。

import torch.nn as nn class TemporalConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=9, stride=1): super().__init__() self.conv = nn.Conv2d( in_channels, out_channels, kernel_size=(kernel_size, 1), # 只在时间维卷积 padding=((kernel_size - 1) // 2, 0), stride=(stride, 1) ) self.bn = nn.BatchNorm2d(out_channels) def forward(self, x): return self.bn(self.conv(x))

为什么用卷积而不是 LSTM 建模时间?卷积有两个优势:一是可以并行计算,整段序列一次前向,不受时序递归的限制;二是感受野固定且可控,9 帧的核大小意味着每个输出时刻能看到前后各 4 帧的信息。kernel_size 值得认真调:太小,感受野不足,快速动作的帧间关联抓不住;太大,参数量上升,而且过长的依赖在骨骼动作中并不常见。常见的做法是 5 到 9 之间先试 9,在数据量小的任务上再往 5 降。如果动作片段很长且计算资源允许,也可以堆两层时间卷积来扩大感受野。

注意:NTU 数据集的采样率是 30 FPS,一个 64 帧的样本只包含约 2.1 秒的动作。时间卷积核不需要设得太大,把 T 维压缩到 32 或 16 是常见操作。

3. 骨骼动作识别项目源码的数据管线:Dataset、预处理与数据增强

3.1 公开数据集的选择:NTU RGB+D 与 Kinetics-skeleton

在动手训练之前,先要明确在哪个数据上验证。骨骼动作识别最常用的两个公开基准是 NTU RGB+D 和 Kinetics-skeleton。两者的数据形态和任务难度差异很大,选择会直接影响源码中 Dataset 的实现方式。

数据集类别数样本量每帧关键点数坐标维度评价指标
NTU RGB+D60约 5.6 万253D (x, y, z)Cross-Subject / Cross-View
Kinetics-skeleton400约 30 万182D (x, y)Top-1 / Top-5

如果只是复现 ST-GCN 效果、跑通训练流程,优先选 NTU RGB+D 的 Cross-Subject 划分,样本量适中,2D 训练约 4 万条,一张 24G 显存的卡即可承受。Kinetics-skeleton 类别多、单类样本少,更适合验证模型的泛化能力,但对训练技巧和算力要求高不少。从模型设计角度看,两类数据的关节拓扑不同,邻接矩阵需要按数据集单独构造,这也是源码中通常把graph配置独立成文件的原因。

3.2 实现可用的 SkeletonDataset

拿到原始骨骼数据后,第一步是写一个 PyTorch Dataset。这里的关键是处理不等长序列。NTU 的样本帧数从几帧到几百帧不等,而模型的时间维 T 是固定的,常见做法是:序列比 T 长时随机裁剪一段,比 T 短时补零或重复最后一帧。

import torch import numpy as np from torch.utils.data import Dataset class SkeletonDataset(Dataset): def __init__(self, data_dict, label_dict, num_frame=64, transform=None): self.data = data_dict # {sample_id: ndarray[T, V, C]} self.label = label_dict # {sample_id: int} self.num_frame = num_frame self.transform = transform self.keys = list(data_dict.keys()) def __len__(self): return len(self.keys) def __getitem__(self, idx): key = self.keys[idx] x = self.data[key] # [T, V, C] T, V, C = x.shape if T >= self.num_frame: start = np.random.randint(0, T - self.num_frame + 1) x = x[start:start + self.num_frame] else: pad = self.num_frame - T x = np.concatenate([x, np.repeat(x[-1:], pad, axis=0)], axis=0) x = x.astype(np.float32) # 转成 [C, T, V],并做坐标尺度归一化 x = np.transpose(x, (2, 0, 1)) x = (x - x.mean()) / (x.std() + 1e-8) if self.transform: x = self.transform(x) return torch.from_numpy(x), self.label[key]

代码里有几个地方需要注意。随机裁剪是为了让模型看到动作的不同片段,等价于数据增强;重复最后一帧虽然简单,但会引入一段静止数据,比直接补零稍好,因为补零会让模型学到“关节消失”的错误模式。归一化使用全局均值方差而不是逐帧归一化,保留了帧与帧之间的运动幅度信息。如果数据量很大,可以预先把所有样本 pad 到固定长度并存成 npy 文件,训练时直接索引,省去每次裁剪前的读盘开销。

3.3 数据增强与关节缺失容错

骨骼动作识别的数据增强不能像图像那样做随机裁剪缩放,随意旋转坐标会导致动作语义改变,比如挥手变成招手。常用的增强手段是:在关节坐标上叠加小幅高斯噪声、随机缩放骨骼长度、以一定概率把某些关节的坐标置为 0 模拟检测失败。

def augment_3d_skeleton(x, noise_scale=0.01, dropout_rate=0.1): # x: [C, T, V] noise = np.random.normal(0, noise_scale, size=x.shape).astype(np.float32) x = x + noise # 随机遮挡部分关节 if dropout_rate > 0: mask = np.random.rand(x.shape[2]) > dropout_rate x = x * mask[np.newaxis, np.newaxis, :] return x

噪声幅度noise_scale不宜过大,0.01 意味着在归一化后的坐标上叠加约 1% 的扰动。dropout_rate=0.1表示每帧有 10% 的关节被丢弃,训练中模型看到缺失关节时,空间图卷积会退化为只聚合剩余邻居,这能增强对低质量骨骼数据的鲁棒性。注意这里对每个样本的所有帧使用同一个 mask,而不是每帧随机,否则时间卷积会捕捉到不真实的关节闪烁。

提示:测试阶段不要启用增强,但要保留归一化。如果训练时用了某个均值和方差,测试时也用同一组统计量,否则精度会明显下降。

4. 用 PyTorch 从零实现 ST-GCN 并跑通训练闭环

4.1 图卷积层与时空基本块

模型核心是图卷积层。它接收[N, C, T, V]的特征图和一个形状为[K, V, V]的分区邻接矩阵,对每个分区单独做一次图聚合,再把结果相加。实现时用矩阵乘法完成节点聚合,用 1x1 卷积做通道变换。

import torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_subsets=3): super().__init__() self.num_subsets = num_subsets self.convs = nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size=1) for _ in range(num_subsets) ]) def forward(self, x, A): # x: [N, C, T, V], A: [K, V, V] out = None for k in range(self.num_subsets): # 跨关节聚合: x [N,C,T,V] @ A[k] [V,V] -> [N,C,T,V] x_tmp = torch.einsum('nctv,vw->nctw', x, A[k]) y = self.convs[k](x_tmp) out = y if out is None else out + y return out

einsum'nctv,vw->nctw'表示对 V 维做矩阵乘法,语义清晰。每个分区的 1x1 卷积只作用于自己的聚合结果,三个子集的权重互不共享。num_subsets=3对应 spatial configuration 分区;如果换用 uniform 分区,只需把该参数改为 1。

时空基本块把这个图卷积层与时间卷积串起来,并加残差连接。

class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, dropout=0.5): super().__init__() self.gcn = SpatialGraphConv(in_channels, out_channels) self.tcn = nn.Sequential( nn.Conv2d(out_channels, out_channels, kernel_size=(9, 1), padding=(4, 0), stride=(stride, 1)), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), nn.Dropout(dropout) ) if stride != 1 or in_channels != out_channels: self.residual = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=(stride, 1)), nn.BatchNorm2d(out_channels)) else: self.residual = nn.Identity() def forward(self, x, A): residual = self.residual(x) x = self.gcn(x, A) x = self.tcn(x) return x + residual

stride只作用于时间维。因为骨骼序列的 T 通常在 64 或 300,空间关节数固定不变,降采样只压缩时间长度,不做空间压缩。dropout放在时间卷积之后,对特征图的通道维做随机失活,能明显降低过拟合,特别是在 Kinetics 这类类别多、样本少的数据上。

4.2 完整模型结构与前向传播

ST-GCN 原文使用 9 层时空块,按通道数分为三段:前 3 层通道 64,中间 3 层通道 128,最后 3 层通道 256。每段的第一个 block 的 stride 设为 2,把时间维减半。实现时不必逐层手写,可以用循环生成。

class STGCN(nn.Module): def __init__(self, in_channels, num_class, A, num_layers=9): super().__init__() self.register_buffer('A', torch.tensor(A, dtype=torch.float32)) self.data_bn = nn.BatchNorm1d(in_channels * A.shape[1]) channels = [64, 64, 64, 128, 128, 128, 256, 256, 256] self.blocks = nn.ModuleList() for i in range(num_layers): stride = 2 if i in (3, 6) else 1 # 第 4、7 层降采样 self.blocks.append(STGCNBlock( channels[i - 1] if i > 0 else in_channels, channels[i], stride=stride)) self.fc = nn.Linear(channels[-1], num_class) def forward(self, x): # x: [N, C, T, V] N, C, T, V = x.shape x = x.permute(0, 3, 1, 2).contiguous().view(N, C * V, T) x = self.data_bn(x) x = x.view(N, V, C, T).permute(0, 2, 3, 1) # [N, C, T, V] for block in self.blocks: x = block(x, self.A) x = x.mean(dim=[2, 3]) # 全局平均池化 return self.fc(x)

register_buffer把邻接矩阵注册为模型状态的一部分,随模型一起迁移到 GPU,但不参与训练。输入经过一个BatchNorm1d做数据归一化,这里对每个关节点独立统计,而不是对整个骨骼序列。全局平均池化把[N, 256, T, V]压成[N, 256],再送入全连接层。如果动作样本非常长,也可以在池化前只对时间维池化,保留关节维信息,然后接一个 1x1 卷积代替全连接,但做法会因为关节数固定而不通用。

4.3 训练配置与超参设置

训练骨骼动作识别模型和训练图像分类模型没有本质区别,但超参设置上有一些成熟经验。下面是一组在 NTU RGB+D 上常用的配置。

超参数推荐值说明
优化器SGD比 Adam 泛化好,尤其在大数据上
momentum0.9标准动量
weight_decay0.0001过大会让空间图卷积权重退化
初始学习率0.1配合 warmup 使用
学习率策略Cosine Annealing或每 30 epoch 乘 0.1
batch_size32显存不足时降到 16 并同步调学习率
warmup_epochs5从 0.01 线性升至 0.1

假设你已经用 VSCode 把 Python 环境配置好并安装了 PyTorch,训练脚本的命令行通常长这样:

python train.py \ --dataset ntu60 \ --mode cross_subject \ --num-epochs 80 \ --batch-size 32 \ --lr 0.1 \ --weight-decay 0.0001 \ --warmup-epochs 5

对应 Python 侧的优化器配置是:

optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=0.0001) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=80 - 5, eta_min=0.0001)

warmup 阶段用较小的学习率训练前几个 epoch,避免模型一开始就震荡。Cosine Annealing 在训练后期把学习率平滑降到很低,配合较大的初始学习率 0.1,比固定学习率 0.01 能多出 1 到 2 个百分点的 Top-1 准确率。如果训练到一半发现 loss 不降,不要急着加学习率,先确认数据集中是否混入了空序列或全零样本,这种情况在骨骼数据集里比想象的常见。

5. ST-GCN 骨骼动作识别模型的验证、可视化与实时推理

5.1 测试命令与混淆矩阵

训练完成后,先看整体准确率,再看细分到每个类别的表现。测试脚本通常按数据集自带的评价协议划分,比如 NTU RGB+D 的 Cross-View。把测试集过一遍模型,统计 Top-1、Top-5 并输出混淆矩阵。

def evaluate(model, loader): model.eval() preds, gts = [], [] with torch.no_grad(): for x, y in loader: x = x.cuda() out = model(x) preds.extend(out.argmax(dim=1).cpu().numpy()) gts.extend(y.numpy()) return preds, gts # 交给 sklearn.metrics 计算混淆矩阵

如果某个类别的准确率异常低,比如“喝水”和“喝饮料”互相混淆,不要急着改模型结构,先去数据里对比这两个类别的骨架序列,很可能是骨骼标注本身就区分度不够,此时增加输入通道或引入两流信息比加深网络更有效。

5.2 把识别结果画回骨骼序列

只看到准确率数字不够直观,把预测结果可视化到骨骼序列上,能快速定位模型是在动作的哪个阶段做出判断。用 matplotlib 按帧绘制三维关键点,并用不同颜色标记预测类别。

import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D def draw_skeleton(frame, ax): # frame: [V, 3] for i, j in BONE_PAIRS: # 关节连接对,按数据集定义 ax.plot3D([frame[i, 0], frame[j, 0]], [frame[i, 1], frame[j, 1]], [frame[i, 2], frame[j, 2]], 'o-', markersize=3)

建议逐帧保存为图片再合成 GIF,检查时注意两个点:模型是在动作中途就给出正确判断,还是等到动作快结束才转弯;以及误差集中出现在关节遮挡还是快速运动中。这两个观察直接决定下一步是增强时间卷积还是扩充训练数据。

5.3 实时推理的滑动窗口技巧

把离线模型搬到实时摄像头场景时,输入不再是一段完整动作,而是一个不断增长的骨骼序列。常见做法是维护一个长度固定为 T 的滑动窗口,每新到一帧就丢最旧的一帧,窗口滑动步长设为 10 帧左右,对输出做时间维度的投票或平滑。

def inference_real_time(frame_queue, model, A, window_size=64, step=10): # frame_queue 是大小为 window_size 的 deque,存放关键点坐标 x = np.stack(frame_queue, axis=0) # [T, V, C] x = x.transpose(2, 0, 1)[np.newaxis] # [1, C, T, V] x = (x - mean) / std with torch.no_grad(): out = model(torch.from_numpy(x).cuda()) return torch.softmax(out, dim=1).cpu().numpy()

步长越小,输出越平滑,但计算开销越大;步长越大,延迟越高。在 30 FPS 的摄像头场景下,步长 10 帧意味着模型每 0.33 秒输出一次预测结果,对“站立-坐下-行走”这类慢动作已经足够。如果连续几帧的预测类别在抖动,常见的做法是取最近 5 次预测的众数作为最终类别,而不是直接置信度阈值,因为骨骼动作识别模型通常对类别置信度比较自信。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询