简介:基于时空图卷积网络(ST-GCN)的骨骼动作识别毕业设计源码,面向计算机、人工智能相关专业的学生,适用于毕业设计、期末大作业或课程设计。项目从头搭建了完整的动作识别流程,包括常见数据集的预处理、模型定义、训练与测试脚本,并提供了源代码、配置文件、预训练模型权重以及演示用的动图与视频,共计九十个文件,压缩包大小约为五十二点六一兆字节。其中配置文件用于调整网络结构,源代码涵盖数据生成、模型训练与可视化等环节,权重文件可直接加载进行推理。代码结构清晰,涵盖数据处理、模型训练、离线演示与实时推理等模块,关键处附有注释,新手也能快速读懂。项目还提供了演示脚本,支持对离线视频和实时摄像头画面进行动作识别。已有一百八十八人学习下载,属导师认可的九十八分高分项目。资源内含多种模型权重文件,可对比不同改进策略的效果,并附带操作演示素材,下载后简单部署即可运行,便于直接参考或二次开发,是完成相关课题的高质量参考资料。
1. 骨骼动作识别与 ST-GCN:毕业设计为什么会卡在“图”这一步
做动作识别的同学第一周最容易碰壁的,不是数据集下载慢,而是拿到骨骼关键点坐标后不知道该喂给哪个网络。人体 25 个关节点之间有明确的父子连接关系,这种先验结构既不是图像里的像素网格,也不是一维时序,而是一张天然带语义的图。ST-GCN 的核心思路就是用图卷积把这类空间先验做进网络,同时用时间维卷积处理帧间运动,把动作识别从“坐标序列分类”变成“时空图序列分类”,这也是它长期被用作骨骼动作识别 baseline 的根本原因。
毕业设计选型时,ST-GCN 最大的优势在于可解释性和可复现性:网络结构由空间图卷积和时间卷积交替堆叠,损失函数用标准交叉熵,训练链路清晰,答辩时能讲清楚每一个模块存在的理由。本文不依赖现成的开源仓库封装,而是从 Python 工程出发,把邻接矩阵构造、PyTorch Dataset 数据加载、图卷积实现、训练调试和推理验证这条完整链路串起来,最终的成果就是一套可以直接运行、也方便二次改动的源代码。
下面四章按“理论 → 数据 → 模型 → 验证”的顺序推进。理解骨架如何抽象成图,是后面所有代码的前提;而训练时真正会消耗大量时间的,往往是数据维度和图卷积实现里那些容易忽略的细节。
2. 骨架图结构构建:邻接矩阵、度矩阵与归一化策略
2.1 把人体骨架看成一张图,而不是一维向量
骨骼动作识别的输入通常是每一帧上若干关键点的坐标,形式上可以组织成 (x, y, score) 这样的三元组。用 LSTM 处理时,这类坐标被展平成向量,网络并不知道“左肘”和“左手腕”之间存在物理连接;用 2D 卷积处理时,则把关键点强行排布在网格上,空间顺序依赖固定的排布规则。这两种做法都丢失了人体结构本身的信息。
图模型的做法是显式构造一个图 G=(V, E):顶点集合 V 对应关键点,边集合 E 对应骨骼连接。相邻帧同一关节的连边构成时间边,同帧内关节之间的连边构成空间边。ST-GCN 中的空间图卷积就是沿着空间边聚合相邻关节的特征,时间卷积则沿着时间边聚合连续帧的特征。
import numpy as np def build_adjacency(edges, num_joints): """根据骨骼连接关系构建邻接矩阵。 edges: List[Tuple[int, int]],每条边用 (父关节, 子关节) 表示 num_joints: 关键点总数 返回: A[i, j] = 1 表示关节 i 和 j 之间存在骨骼边 """ A = np.zeros((num_joints, num_joints), dtype=np.float32) for i, j in edges: A[i, j] = 1.0 A[j, i] = 1.0 return A # NTU 数据集中 25 个关键点的骨架链示例,这里只列出手臂和躯干主链 edges = [ (0, 1), (1, 2), (2, 3), (3, 4), # 脊柱到头 (0, 5), (5, 6), (6, 7), # 左肩到左手腕 (0, 8), (8, 9), (9, 10), # 右肩到右手腕 (0, 11), (11, 12), (12, 13), # 左髋到左脚踝 (0, 14), (14, 15), (15, 16), # 右髋到右脚踝 ] A = build_adjacency(edges, 25) print(A.shape, A.sum()) # (25, 25) 统计边的总数这段代码的输出是 25x25 的稀疏矩阵,A[i, j] = 1表示两个关节之间有骨骼直接连接。注意这里使用的是无向边:信息既可以由父节点向子节点传播,也可以反向传播,因此代码同时设置了A[i, j]和A[j, i]。实际数据集的关键点拓扑可能和 NTU 不一致,使用前要对照姿态估计模型输出的关节定义逐条修改 edges,这是一个非常容易出现坐标错位的地方。
2.2 邻接矩阵归一化:控制特征在图中传播的尺度
把原始邻接矩阵直接用于图卷积,数值规模会随着图深度增加而迅速增大:每个节点聚合邻居特征,再进入下一层,特征范数呈指数级变化。ST-GCN 采用的是最常见的对称归一化做法,先给邻接矩阵加上自环,再用度矩阵做归一化。
度矩阵 D 是一个对角阵,D[i, i]表示节点 i 的度加上自环后的值。归一化公式如下:
def normalize_adjacency(A): """对称归一化邻接矩阵。 具体做法是先加自环,再按公式 D^{-1/2} A D^{-1/2} 归一化。 返回归一化后的矩阵,这将用于图卷积中的特征聚合。 """ A_tilde = A + np.eye(A.shape[0], dtype=np.float32) # 加自环 degree = A_tilde.sum(axis=1) # 度向量 D_inv_sqrt = np.diag(1.0 / np.sqrt(degree + 1e-6)) A_norm = D_inv_sqrt @ A_tilde @ D_inv_sqrt return A_norm A_norm = normalize_adjacency(A)参数说明:A + np.eye(...)让每个节点聚合自身特征,这一步很关键,因为动作识别中单个关节自身的历史信息往往比相邻关节更重要;1e-6防止孤立节点度数为 0 导致除零错误。归一化后,特征聚合时每个节点收到的能量被限制在局部范围内,网络深度增加时数值更稳定。
ST-GCN 在此基础上还有多层图划分策略的扩展。表 1 列出了常见的归一化和图结构变体。
| 策略 | 公式形式 | 实际效果 |
|---|---|---|
| 原始邻接矩阵 | A | 不推荐,数值不稳定 |
| 加自环 | A + I | 保留自身特征,最基础操作 |
| 对称归一化 | D^{-1/2}(A+I)D^{-1/2} | ST-GCN 默认,各向同性聚合 |
| 随机游走归一化 | D^{-1}(A+I) | 聚合权重按度数平均分配 |
| 可学习图 | 在归一化矩阵上叠加 mask | 允许网络学习非物理连接,上限更高但容易过拟合 |
2.3 多子图划分:ST-GCN 处理空间局部性的核心思想
前面构造的邻接矩阵只区分“有边”和“无边”,但人体运动有一个明显规律:远离重心的关节位移幅度大,靠近躯干的关节往往承担支撑作用。ST-GCN 在原文中提出了按距离划分的策略,把邻接矩阵拆成几个子图,例如自环一组、向心边一组、离心边一组。每个子图对应一个可学习的权重矩阵,最后在模型里把多组特征相加。
Necessary 的代码准备工作是,把单个邻接矩阵扩展成 (K, V, V) 的张量,其中 K 是子图数量:
def split_by_hop(A, center_idx=0): """按到中心关节的跳数把邻接矩阵拆成多个子图,这里简化为 2 个子图。 center_idx: 重心关节索引(通常取髋关节中心) 返回: 形状为 (2, V, V) 的张量,第一维是子图索引 """ V = A.shape[0] result = np.zeros((2, V, V), dtype=np.float32) result[0] = np.eye(V, dtype=np.float32) # 子图 0: 自环 hops = np.zeros(V, dtype=np.int32) for hop in range(1, V): changed = False for i in range(V): if hops[i] == hop - 1: for j in range(V): if A[i, j] > 0 and hops[j] == 0 and j != center_idx: hops[j] = hop changed = True if not changed: break for i in range(V): for j in range(V): if A[i, j] > 0: if hops[i] <= hops[j]: result[1, i, j] = A[i, j] else: result[1, j, i] = A[i, j] return result这个简化版本展示了从单图到多子图的转换逻辑:子图 0 保留自环,子图 1 保留所有物理连接,实际工程里还会拆出第三个子图。多子图设计的直接好处是,不同空间语义的边可以学习不同的卷积核参数,网络对动作的判别力更强,解释性也更好。实现时注意中心关节的选择会影响整张图的划分结果,通常以髋关节点为坐标原点。
3. 数据加载与批量构造:把骨架序列加工成模型输入
3.1 数据维度约定:N, C, T, V, M 的排布规则
PyTorch 卷积层默认输入是 (N, C, H, W),ST-GCN 数据张量沿用类似的约定,但把 H 和 W 换成了 T、V、M 三个维度。常见排布是 (N, C, T, V, M):N 是样本数量,C 是特征通道数,T 是时间帧数,V 是关键点数量,M 是人数。以 NTU RGB+D 公开数据集为例,一个样本的原始数据就是形状为 (3, 300, 25, 2) 的数组,其中 3 代表 x、y、置信度三个通道,300 是最大帧数,25 是关键点数量,2 表示最多容纳两个人。
不同数据集帧数不一致,因此 Dataset 里要做两件事:定长截断或补齐到固定帧数,以及把人维压到模型需要的通道布局里。
import torch import numpy as np from torch.utils.data import Dataset class SkeletonDataset(Dataset): """读取预处理后的骨架数据,输出模型需要的张量。 输入数据形状: (C, T, V, M),模型输入要求: (C, T, V) 这里把 M 维的人体特征按通道拼接,适用于单人动作识别。 """ def __init__(self, data, labels, frame_len=64): self.data = data # (N, C, T, V, M) self.labels = labels self.frame_len = frame_len def __len__(self): return len(self.data) def __getitem__(self, idx): sample = self.data[idx] # (C, T, V, M) C, T, V, M = sample.shape # 按均值把任意帧数调整到固定长度 if T >= self.frame_len: sample = sample[:, :self.frame_len, :, :] else: pad = np.zeros((C, self.frame_len - T, V, M), dtype=np.float32) sample = np.concatenate([sample, pad], axis=1) # 若有多人,把不同人的特征拼接在通道维上 if M > 1: sample = sample.reshape(C * M, self.frame_len, V) else: sample = sample[:, :, :, 0] # (C, T, V) # 标准化到零均值 sample = (sample - sample.mean()) / (sample.std() + 1e-6) return torch.FloatTensor(sample), self.labels[idx]代码说明:帧数不足时用零填充会引入无意义的帧内容,实际项目中建议改成“循环填充”或“重复最后一帧”,后者对动作延续性更友好;标准化用全局均值和方差更好,这里为展示流程先用了样本内标准化。多人拼接方案在单人基准测试里已经够用,因为 M=2 的第二个人特征只是拼在通道维度上,模型输入通道数会变成 6。
3.2 坐标归一化:让模型不依赖人体在画面中的位置
骨架坐标直接来自姿态估计模型时,人体站立位置的偏移会污染特征。最常见做法是,每个样本把髋关节中心点作为坐标原点,再按骨架尺度做缩放:
def normalize_by_center(sample): """按骨架中心点做坐标平移和缩放。 sample: (C, T, V) 的 numpy 数组,C[0] 为 x,C[1] 为 y """ center_x = sample[0].mean(axis=1, keepdims=True) # 所有关节 x 均值 center_y = sample[1].mean(axis=1, keepdims=True) sample[0] -= center_x sample[1] -= center_y scale = np.sqrt((sample[0] ** 2 + sample[1] ** 2).mean()) + 1e-6 sample[:2] /= scale return sample缩放因子取所有关键点到中心的平均距离,让人体大小差异不影响分类结果。这里有一个常见的坑:不要对置信度通道做平移缩放,置信度表示关键点检测可靠度,它的数值范围需要保持在 0 到 1 之间。若数据来自 3D 姿态估计,z 轴坐标需要单独处理,因为 z 轴数值范围通常小于 x、y 轴。
3.3 DataLoader 配置与边界情况处理
Dataset 写好后,构造 DataLoader 时的参数会直接影响训练效率和稳定性。
from torch.utils.data import DataLoader dataset = SkeletonDataset(train_data, train_labels, frame_len=64) loader = DataLoader( dataset, batch_size=32, shuffle=True, num_workers=4, drop_last=True, pin_memory=True, )参数说明:drop_last=True避免最后一个 batch 形状不一致触发 BN 层报错;pin_memory=True配合 GPU 训练可以在数据加载时减少一次显存拷贝;num_workers在 Windows 上设成 0 可以绕开多进程数据加载时的中断问题。如果训练时出现“found at least two devices”这类报错,检查num_workers和主程序是否被重复执行。
4. 用 PyTorch 实现 ST-GCN 核心模块:图卷积层、时间卷积与残差结构
4.1 图卷积层的实现:多个子图分别聚合再相加
图卷积在 PyTorch 中的实现,最常见的方式是循环遍历 K 个子图矩阵,每个子图对应一个 1x1 卷积,再分别做矩阵乘法,最后求和:
import torch import torch.nn as nn class SpatialGraphConv(nn.Module): """空间图卷积层。 A: (K, V, V) 的归一化邻接矩阵,K 是子图数量 x: (B, C_in, T, V),batch 大小 B """ def __init__(self, in_channels, out_channels, A): super().__init__() self.num_subset = A.shape[0] self.A = nn.Parameter(torch.from_numpy(A), requires_grad=False) # 每个子图单独分配一组 1x1 卷积参数 self.conv_list = nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size=1) for _ in range(self.num_subset) ]) self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU() def forward(self, x): B, C, T, V = x.shape y = torch.zeros(B, C, T, V, device=x.device) for k, conv in enumerate(self.conv_list): A_k = self.A[k] # (V, V) x_k = conv(x) # (B, C_out, T, V) x_k = torch.einsum("bctv,vw->bctw", x_k, A_k) # 特征聚合 y = y + x_k return self.relu(self.bn(y))逻辑说明:einsum("bctv,vw->bctw")的含义是,对输入张量的 V 维与邻接矩阵的 V 维做匹配,输出特征被重新映射到 W 维,从而把“关节 w 的特征”按照邻接矩阵传播到“关节 v”。同一时刻,1x1 卷积先完成通道维度的线性变换,把通道数从 C_in 变成 C_out,再做空间聚合。
参数设计说明:requires_grad=False固定邻接矩阵,避免图结构在训练中漂移导致不可控;如果希望图结构可学习,可以把self.A声明为nn.Parameter(torch.from_numpy(A), requires_grad=True),并在 forward 里叠加上一份与距离先验无关的可学习 mask。初学阶段不建议直接开放图权重,容易导致训练不收敛。
4.2 时间卷积与残差连接:补完整一个 ST-GCN Block
图卷积处理了空间维,时间维的信息需要用卷积在帧方向聚合。PyTorch 的标准做法是把时间卷积做成kernel_size=(T_kernel, 1)的 Conv2d,让卷积核只在 T 维上滑动:
class TemporalConv(nn.Module): """时间卷积层,只在帧方向做卷积。 kernel_size: 时间核长度,默认 9 stride: 时间下采样步长 """ def __init__(self, channels, kernel_size=9, stride=1): super().__init__() pad = (kernel_size - 1) // 2 self.conv = nn.Conv2d( channels, channels, kernel_size=(kernel_size, 1), stride=(stride, 1), padding=(pad, 0), ) self.bn = nn.BatchNorm2d(channels) def forward(self, x): return self.bn(self.conv(x))时间卷积的 padding 设置为(kernel_size-1)//2保持帧数不变(stride=1 时);设置 stride=2 时,帧数减半,配合后续层可以扩大感受野,同时减少计算量。
残差连接在 ST-GCN 中解决的是深层网络的梯度退化问题。完整的一个 ST-GCN Block 由空间图卷积、时间卷积和残差分支组成:
class STBlock(nn.Module): """一个完整的时空图卷积 Block。 stride=2 时对时间维做下采样,残差分支用 1x1 卷积对齐通道数。 """ def __init__(self, in_channels, out_channels, A, stride=1): super().__init__() self.spatial = SpatialGraphConv(in_channels, out_channels, A) self.temporal = TemporalConv(out_channels, kernel_size=9, stride=stride) 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): out = self.temporal(self.spatial(x)) out = out + self.residual(x) return nn.ReLU()(out)残差分支只在通道数变化或时间维下采样时使用卷积,其他情况直接短路连接。这样堆叠 9 层以上不会出现梯度消失,这也是 ST-GCN 比简单 GCN 更稳定的核心原因。
4.3 网络主干堆叠与分类头的组合方式
完整的 ST-GCN 网络由多个 STBlock 顺序堆叠,最后接全局平均池化和全连接分类层:
class STGCN(nn.Module): def __init__(self, in_channels, num_classes, A): super().__init__() self.data_bn = nn.BatchNorm2d(in_channels) self.blocks = nn.Sequential( STBlock(in_channels, 64, A, stride=1), STBlock(64, 64, A, stride=1), STBlock(64, 128, A, stride=2), STBlock(128, 128, A, stride=1), STBlock(128, 256, A, stride=2), STBlock(256, 256, A, stride=1), STBlock(256, 256, A, stride=1), ) self.pool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(256, num_classes) def forward(self, x): # x 形状: (B, C, T, V),先做数据层 BN x = self.data_bn(x) x = self.blocks(x) x = self.pool(x) # (B, 256, 1, 1) x = x.view(x.size(0), -1) return self.fc(x)网络输出的 logits 直接送入交叉熵损失函数。表 2 是上述配置对应的模型参数量和计算特征。
| STBlock 层数 | 输出通道 | 时间步长 stride | 输出帧数 | 参数量(约) |
|---|---|---|---|---|
| 1-2 | 64 | 1 | T | 约 4.8 万 |
| 3-4 | 128 | 1, 2 | T/2 | 约 19 万 |
| 5-7 | 256 | 2, 1, 1 | T/8 | 约 50 万以上 |
时间维 stride=2 的层把帧数逐步压缩,能够让高层特征对应更长的时序感受野,但同时会丢失一部分单帧空间细节。对于帧数较短的动作(例如 32 帧),建议去掉最后一个 stride=2 的层,防止时间维被压缩到 4 帧以下导致信息严重损失。
4.4 训练循环:损失函数、优化器与学习率策略
训练 ST-GCN 通常使用 SGD 配合 Nesterov 动量,学习率从 0.1 开始,在第 30、40 个 epoch 时缩小 10 倍:
import torch.optim as optim model = STGCN(in_channels=3, num_classes=60, A=A).cuda() criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, nesterov=True) def adjust_lr(epoch, init_lr=0.1): if epoch >= 30: return init_lr * 0.1 if epoch >= 40: return init_lr * 0.01 return init_lr for epoch in range(50): model.train() for batch_idx, (data, label) in enumerate(loader): data, label = data.cuda(), label.cuda() optimizer.zero_grad() output = model(data) loss = criterion(output, label) loss.backward() optimizer.step() for param_group in optimizer.param_groups: param_group["lr"] = adjust_lr(epoch)这里有个容易被忽略的细节:adjust_lr(epoch)写在 epoch 循环里,但需要在每个 batch 后生效,因此放到第二个循环之外会生效得晚一个 epoch。更稳妥的做法是把学习率调整写在 epoch 迭代的末尾。Nesterov 动量配合 0.9 的 momentum 在动作识别这类中小规模数据集上表现优于 Adam。
5. ST-GCN 训练效果验证:滑窗投票与动作识别准确率排查
5.1 单个视频样本的标准推理流程
训练完模型后,先写一个能对单样本输出的脚本,这一步的作用是验证从数据到模型前向传播链路是否完整。推理代码中需要关闭 BN 和 Dropout 的训练状态:
def predict_one_sample(model, sample, device): """对单条骨架序列做预测。 sample 形状: (3, T, V),输出预测类别和所有类别的置信度得分。 """ model.eval() with torch.no_grad(): sample = torch.FloatTensor(sample).unsqueeze(0).to(device) logits = model(sample) # (1, num_classes) probs = torch.softmax(logits, dim=1) pred = torch.argmax(probs, dim=1).item() conf = probs.max(dim=1).values.item() return pred, conf pred, conf = predict_one_sample(model, test_sample, device)model.eval()写法在 PyTorch 推理里是必须的,遗忘它会让 BN 层的运行均值继续更新,导致同样输入在不同时刻得到不同输出。推理阶段不要调用torch.no_grad()之外的显存优化技巧,保持代码可读性优先。
5.2 长视频识别不稳定:滑窗投票的实用方案
训练集里的样本都是截取好的动作片段,但实际视频中一个手势或动作的起始帧并不明确。固定取前 64 帧预测,很容易把动作的中间阶段误判成其他类别。常见做法是滑窗识别:先以固定步长切出多个重叠窗口,分别预测类别,再统计所有窗口的投票结果:
def sliding_window_vote(model, sequence, window_len=64, stride=16): """长序列的滑窗投票预测。 sequence: (3, T, V),T 可以远大于 window_len 返回: 最终类别与 softmax 平均得分 """ model.eval() num_windows = max(1, (sequence.shape[1] - window_len) // stride + 1) votes = np.zeros(model.fc.out_features, dtype=np.float32) with torch.no_grad(): for i in range(num_windows): start = i * stride window = sequence[:, start:start + window_len, :] if window.shape[1] < window_len: break logits = model(torch.FloatTensor(window).unsqueeze(0).to(device)) probs = torch.softmax(logits, dim=1).cpu().numpy() votes += probs[0] final_class = int(np.argmax(votes)) final_score = float(votes.max() / num_windows) return final_class, final_score把每个窗口的 softmax 概率相加的做法,比先取每个窗口的 argmax 再投票更好,因为它保留了对两个相近动作类别的区分信息。滑窗参数的选择会影响结果:窗口越短越灵敏但误报越多,窗口越长越稳定但对短促动作不敏感。如表 3 所示,这个权衡在论文对比实验中需要明确说明。
| 窗口长度 | 步长 | 识别短促动作 | 识别连续动作 | 推理耗时 |
|---|---|---|---|---|
| 32 | 16 | 好 | 一般 | 快 |
| 64 | 16 | 一般 | 好 | 中 |
| 128 | 32 | 差 | 好 | 慢 |
5.3 一个容易忽略的阈值技巧:用置信度区分“拒绝识别”
动作识别模型在遇到完全不认识的动作类别时,也会硬性给出一个概率分布。如果毕业设计应用场景是交互系统,可以加上一个拒绝分支:当滑窗平均得分低于某个阈值时,输出“未知动作”。
阈值的选择可以用验证集统计:收集所有验证样本的最终分数,画出分数分布曲线,取两个峰值之间的谷底作为阈值。一个快速可用的经验值是把阈值设为 0.6 到 0.7 之间,低于阈值的预测直接丢弃。这样做的效果是,模型在已知 60 类的测试集上准确率不下降,而对新出现的动作保留了一定判别能力,这个亮点也适合写进毕业设计的应用展示部分。
本文还有配套的精品资源,点击获取