
简介面向人工智能、计算机视觉相关专业学生与开发者基于时空图卷积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-GCNSpatial Temporal Graph Convolutional Network把图卷积引入骨骼动作识别在空间维度用邻接矩阵聚合关节信息在时间维度用卷积捕捉帧间变化既保留人体拓扑又避免把骨架强行铺平成图像。这个 zip 通常对应一套完整的 Python 工程包含模型定义、训练脚本、数据预处理和项目说明文档适合做行为识别、人机交互、运动分析方向的研究生和工程师直接改造。2. 骨骼动作识别的时空图构造邻接矩阵、分区策略与时间卷积2.1 骨骼数据表示关键点序列如何组织成张量骨骼动作识别的原始输入不是视频帧而是每一帧里若干关节的三维坐标。以 NTU RGBD 数据集为例每帧标注 25 个关节点坐标为 (x, y, z)一段动作有 T 帧整段数据就是一个形状为[T, V, C]的矩阵其中 V25 是关节点数C3 是坐标维度。单人的输入张量通常组织成[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), dtypenp.float32) # 单样本 [C, T, V] batch np.zeros((32, C, T, V), dtypenp.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 WD 是度矩阵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], axis1) 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_size9, stride1): 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 RGBD 与 Kinetics-skeleton在动手训练之前先要明确在哪个数据上验证。骨骼动作识别最常用的两个公开基准是 NTU RGBD 和 Kinetics-skeleton。两者的数据形态和任务难度差异很大选择会直接影响源码中 Dataset 的实现方式。数据集类别数样本量每帧关键点数坐标维度评价指标NTU RGBD60约 5.6 万253D (x, y, z)Cross-Subject / Cross-ViewKinetics-skeleton400约 30 万182D (x, y)Top-1 / Top-5如果只是复现 ST-GCN 效果、跑通训练流程优先选 NTU RGBD 的 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_frame64, transformNone): 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, axis0)], axis0) 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_scale0.01, dropout_rate0.1): # x: [C, T, V] noise np.random.normal(0, noise_scale, sizex.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_rate0.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_subsets3): super().__init__() self.num_subsets num_subsets self.convs nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size1) 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 outeinsum用nctv,vw-nctw表示对 V 维做矩阵乘法语义清晰。每个分区的 1x1 卷积只作用于自己的聚合结果三个子集的权重互不共享。num_subsets3对应 spatial configuration 分区如果换用 uniform 分区只需把该参数改为 1。时空基本块把这个图卷积层与时间卷积串起来并加残差连接。class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1, dropout0.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(inplaceTrue), nn.Dropout(dropout) ) if stride ! 1 or in_channels ! out_channels: 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, A): residual self.residual(x) x self.gcn(x, A) x self.tcn(x) return x residualstride只作用于时间维。因为骨骼序列的 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_layers9): super().__init__() self.register_buffer(A, torch.tensor(A, dtypetorch.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], stridestride)) 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 RGBD 上常用的配置。超参数推荐值说明优化器SGD比 Adam 泛化好尤其在大数据上momentum0.9标准动量weight_decay0.0001过大会让空间图卷积权重退化初始学习率0.1配合 warmup 使用学习率策略Cosine Annealing或每 30 epoch 乘 0.1batch_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(), lr0.1, momentum0.9, weight_decay0.0001) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max80 - 5, eta_min0.0001)warmup 阶段用较小的学习率训练前几个 epoch避免模型一开始就震荡。Cosine Annealing 在训练后期把学习率平滑降到很低配合较大的初始学习率 0.1比固定学习率 0.01 能多出 1 到 2 个百分点的 Top-1 准确率。如果训练到一半发现 loss 不降不要急着加学习率先确认数据集中是否混入了空序列或全零样本这种情况在骨骼数据集里比想象的常见。5. ST-GCN 骨骼动作识别模型的验证、可视化与实时推理5.1 测试命令与混淆矩阵训练完成后先看整体准确率再看细分到每个类别的表现。测试脚本通常按数据集自带的评价协议划分比如 NTU RGBD 的 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(dim1).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-, markersize3)建议逐帧保存为图片再合成 GIF检查时注意两个点模型是在动作中途就给出正确判断还是等到动作快结束才转弯以及误差集中出现在关节遮挡还是快速运动中。这两个观察直接决定下一步是增强时间卷积还是扩充训练数据。5.3 实时推理的滑动窗口技巧把离线模型搬到实时摄像头场景时输入不再是一段完整动作而是一个不断增长的骨骼序列。常见做法是维护一个长度固定为 T 的滑动窗口每新到一帧就丢最旧的一帧窗口滑动步长设为 10 帧左右对输出做时间维度的投票或平滑。def inference_real_time(frame_queue, model, A, window_size64, step10): # frame_queue 是大小为 window_size 的 deque存放关键点坐标 x np.stack(frame_queue, axis0) # [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, dim1).cpu().numpy()步长越小输出越平滑但计算开销越大步长越大延迟越高。在 30 FPS 的摄像头场景下步长 10 帧意味着模型每 0.33 秒输出一次预测结果对“站立-坐下-行走”这类慢动作已经足够。如果连续几帧的预测类别在抖动常见的做法是取最近 5 次预测的众数作为最终类别而不是直接置信度阈值因为骨骼动作识别模型通常对类别置信度比较自信。本文还有配套的精品资源点击获取