简介:本资源是一套面向高校学生与深度学习入门者的Python毕业设计项目,围绕时空图卷积网络(ST-GCN)实现骨骼动作识别,可用于健康监测、人机交互、视频监控与智能游戏等场景,帮助读者理解从骨骼点建模到动作分类的完整流程。压缩包共91个文件,约52.54MB,包含29个py源码、13个yaml配置、12个pyc编译文件、11个gif演示、9个txt说明、5个png图示、3个pt权重、3个mp4视频及prototxt、sh脚本等,覆盖数据生成、模型定义、训练配置与离线/实时识别演示等模块。已有175人学习关注。项目源码经测试可运行,答辩评审平均分达94.5分,附带训练好的模型与项目文档,读者可据此复现ST-GCN双流网络结构、对照配置调参、参考演示脚本完成推理,并借助文档梳理设计思路与实验结果,适合作为课程设计、毕业设计或骨骼动作识别方向的实践参考。
1. 从一份骨骼动作识别毕设说起:ST-GCN 到底解决了什么问题
做过动作识别毕设的人大多踩过同一个坑:拿 RGB 视频直接喂 3D 卷积,训练慢、显存炸、换个机位就翻车。骨骼动作识别换了个思路——先用姿态估计把人体抽成 18 个关节点的坐标序列,再让模型去学这些点怎么动。问题在于,关节不是孤立的像素,手肘和手腕的关系、左右腿的对称关系,都是图结构而不是网格结构。普通 CNN 处理不了这种非欧几里得数据,RNN 又只能建模时间维、丢掉空间拓扑。ST-GCN(Spatial Temporal Graph Convolutional Network,时空图卷积网络)就是冲着这个矛盾来的:把每一帧的骨架当成一张图,节点是关节点,边是人体自然连接,再沿时间轴把帧与帧连起来,形成一个时空图,用图卷积同时抽空间和时间特征。
这套方案对毕设特别友好:NTU RGB+D 这类公开数据集现成,单卡 1080Ti 就能跑,代码量可控,创新点好写(改邻接矩阵、加注意力、换骨架)。下面这份笔记按「数据怎么变成图 → 网络怎么搭 → 训练怎么调 → 坑在哪 → 怎么做出差异化」的顺序讲,目标是让你拿到源代码后能真正跑通,而不是对着报错发呆。
2. 骨骼数据怎么变成时空图:从关节坐标到邻接矩阵
2.1 为什么必须显式构建邻接矩阵
ST-GCN 的核心数据结构是一张图,用邻接矩阵 A 表示。假设一帧有 V 个关节点,A 就是 V×V 的矩阵,A[i][j]=1 表示关节 i 和 j 有物理连接。NTU 用的是 25 个关节点,Kinetics 用的是 18 个,OpenPose 输出也是 18 个。这个矩阵不是学出来的,是先验知识写死的——人体骨架长什么样,边就怎么连。
但只用一个 A 不够。ST-GCN 原文把邻接矩阵拆成三个子集:自身(self)、向心(centripetal,靠近骨架重心的邻居)、离心(centrifugal,远离重心的邻居)。这样做的原因是,同一个关节向肩膀和向手肘的信息应该被区别对待。代码里通常写成三个 A 相加,再乘一个可学习的权重掩码,让网络自己调整每条边的重要性。
import numpy as np # NTU RGB+D 25 关节的物理连接,(父节点, 子节点) edges = [(0,1),(1,20),(20,2),(2,3),(20,4),(4,5),(5,6),(6,7),(7,21),(7,22), (20,8),(8,9),(9,10),(10,11),(11,23),(11,24), (0,12),(12,13),(13,14),(14,15), (0,16),(16,17),(17,18),(18,19)] num_node = 25 A = np.zeros((num_node, num_node)) for i, j in edges: A[i][j] = 1 A[j][i] = 1 # 无向图,对称填充 # 归一化,避免度数大的节点主导梯度 D = np.diag(np.sum(A, axis=1)) D_inv = np.linalg.inv(D) A_norm = D_inv.dot(A) np.save('adjacency.npy', A_norm)这段代码做了三件事:按人体拓扑填边、对称化保证无向、按度做归一化。归一化这步很多人省掉,结果训练时度数高的髋关节梯度爆炸,loss 直接 NaN。参数上,num_node必须和你的姿态估计器输出一致,用 OpenPose 就是 18,用 MediaPipe 是 33,改网络时第一层图卷积的输入维度要跟着改。
2.2 从视频到 .npy:数据预处理的完整链路
原始数据是视频,ST-GCN 要的是形状为(N, C, T, V, M)的张量:N 是样本数,C 是通道(坐标 x,y 加置信度 score,共 3),T 是帧数,V 是关节点数,M 是人数(通常取 2,单人补零)。常见做法是用 OpenPose 或 HRNet 逐帧跑姿态估计,把结果存成 JSON,再转成 npy。
import json, numpy as np, os def json_to_npy(json_dir, out_path, max_frame=300, num_node=25, max_body=2): samples = [] for fname in sorted(os.listdir(json_dir)): with open(os.path.join(json_dir, fname)) as f: data = json.load(f) # data['people'] 是当前帧检测到的人 frame_data = np.zeros((3, num_node, max_body)) for m, person in enumerate(data['people'][:max_body]): kp = np.array(person['pose_keypoints_2d']).reshape(-1, 3) frame_data[:, :, m] = kp[:num_node].T # (3, V) samples.append(frame_data) # 补齐或截断到 max_frame arr = np.array(samples) # (T, 3, V, M) if arr.shape[0] < max_frame: pad = np.zeros((max_frame - arr.shape[0],) + arr.shape[1:]) arr = np.concatenate([arr, pad], axis=0) else: arr = arr[:max_frame] arr = arr.transpose(3, 1, 0, 2) # (M, 3, T, V) np.save(out_path, arr) json_to_npy('./pose_json', './sample.npy')逻辑说明:先逐帧读 JSON,把每个人的关键点整理成(3, V),多人时堆到 M 维;再统一帧长,短补长截;最后转置成 ST-GCN 期望的(M, C, T, V)。参数上max_frame一般取 300(NTU 标准),max_body取 2 覆盖双人交互动作。踩坑点在于 OpenPose 的 keypoints 是扁平数组,reshape 成(-1,3)后第三列是置信度,别把它当坐标用。
2.3 数据增强:毕设里最容易被忽略的加分项
骨骼数据增强比图像简单,但效果明显。常用三种:随机旋转(绕 z 轴 ±30 度)、随机缩放(0.9~1.1)、随机平移(±0.1)。这些操作直接作用在坐标上,不改变图结构。注意置信度通道不要跟着缩放,否则网络会学到错误的权重。做增强时把(C,T,V,M)里的 C=0,1 两通道单独处理,C=2 保持不变,这是血泪经验——早期我把三通道一起乘,模型收敛慢了一半。
3. 网络结构拆解:ST-GCN 的图卷积层怎么搭
3.1 空间图卷积的三种实现方式
ST-GCN 的空间卷积本质是f_out = Σ A_norm · f_in · W,其中 W 是 1×1 卷积的权重。实现上有三种写法,复杂度递增:
第一种,直接用torch.einsum做矩阵乘,代码最短但可读性差;第二种,用nn.Conv2d加邻接矩阵乘法,工程上最常用;第三种,用torch.sparse稀疏矩阵,适合超大骨架但毕设没必要。我一般用第二种,因为调试时能直接 print 中间张量形状。
import torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_node, adjacency): super().__init__() self.num_node = num_node # 三个子集邻接矩阵,注册为 buffer 不参与训练 self.register_buffer('A', adjacency) # (3, V, V) # 可学习的边权重掩码,初始化为全1 self.edge_weight = nn.Parameter(torch.ones(3, num_node, num_node)) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1) def forward(self, x): # x: (N, C, T, V) N, C, T, V = x.shape x = self.conv(x) # 先做通道变换 # 用可学习掩码调制邻接矩阵 A = self.A * self.edge_weight # (3, V, V) # 对三个子集分别做图卷积再求和 out = torch.einsum('nctv,kvw->nctw', x, A) return out逻辑说明:self.conv完成通道维的特征变换,einsum完成空间维的邻居聚合。edge_weight是关键——它让固定的物理连接变成可学习的软连接,网络能自己决定哪条边更重要。参数上in_channels和out_channels按层递增(64→64→64→128→128→256),num_node必须和邻接矩阵维度一致。注意register_buffer和nn.Parameter的区别:邻接矩阵是常量用 buffer,边权重是要训练的参数。
3.2 时间卷积与整体网络堆叠
空间卷积抽完单帧拓扑,时间维还得靠 TCN(Temporal Convolutional Network)。ST-GCN 用的是 kernel_size=9 的一维卷积,配合 padding 保持时间长度不变,再接 BN 和 Dropout。为什么用 9 而不是 3?因为动作的时序依赖跨度大,抬手到放下可能跨十几帧,9 的感受野能覆盖一个完整动作单元。
class STGCNBlock(nn.Module): def __init__(self, in_ch, out_ch, num_node, adjacency, stride=1, residual=True): super().__init__() self.gcn = SpatialGraphConv(in_ch, out_ch, num_node, adjacency) self.tcn = nn.Sequential( nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=(9,1), padding=(4,0), stride=(stride,1)), nn.BatchNorm2d(out_ch), ) self.residual = residual if not residual: self.down = lambda x: 0 elif in_ch == out_ch and stride == 1: self.down = lambda x: x else: self.down = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=(stride,1)), nn.BatchNorm2d(out_ch), ) def forward(self, x): res = self.down(x) x = self.gcn(x) x = self.tcn(x) return torch.relu(x + res)逻辑说明:每个 block 是「空间卷积 → BN → ReLU → 时间卷积 → BN → 残差相加」。残差分支在通道数或步长变化时用 1×1 卷积对齐,否则直接恒等映射。参数上stride只在每个 stage 的第一层设为 2 做下采样,其余为 1;kernel_size=(9,1)表示时间维卷 9、空间维不卷(空间已由 GCN 处理)。整个网络通常堆 10 个 block,最后全局平均池化接全连接分类。
3.3 分类头与损失函数的选择
分类头很简单:AdaptiveAvgPool2d(1)把(N, 256, T, V)压成(N, 256, 1, 1),展平后接Linear(256, num_class)。损失用交叉熵,但骨骼数据常有多人场景,建议加 label smoothing(0.1)抑制过拟合。如果做的是双人交互动作(比如 NTU 的 mutual action),还要考虑两人特征的融合,常见做法是把 M 维拼接到通道维,或者用两个分支分别提特征再 concat。
提示:分类类别数
num_class必须和你的数据集标签数严格一致,NTU-60 是 60,NTU-120 是 120,自己采集的数据要重新编号,别直接套用预训练权重后忘了改最后一层。
4. 训练调参与复现:让 loss 真正降下去
4.1 学习率、优化器与 warmup 策略
ST-GCN 训练最玄学的地方是学习率。原文用 SGD,初始 0.1,在第 10、50 epoch 各降 10 倍。但毕设数据量小(通常几千条),0.1 太大直接发散。我一般用 0.01 起步,配合 5 个 epoch 的 warmup,让网络先稳定再加速。
import torch.optim as optim from torch.optim.lr_scheduler import MultiStepLR, LambdaLR optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) # warmup:前5个epoch线性从0.001升到0.01 warmup = LambdaLR(optimizer, lr_lambda=lambda e: min((e+1)/5, 1.0)) # 之后在第30、60 epoch降10倍 scheduler = MultiStepLR(optimizer, milestones=[30, 60], gamma=0.1) for epoch in range(80): train_one_epoch() if epoch < 5: warmup.step() else: scheduler.step()逻辑说明:warmup 阶段学习率从 0.001 线性升到 0.01,避免初期梯度震荡;之后用 MultiStepLR 阶梯下降。参数上weight_decay=1e-4是骨骼数据的经验值,太大欠拟合,太小过拟合。batch size 单卡 1080Ti 用 32 或 64,显存不够就降到 16 并同步调小学习率。
4.2 训练循环里的关键监控指标
光看 loss 不够,要同时盯三个数:训练准确率、验证准确率、学习率。如果训练准确率涨但验证不涨,是过拟合,加 Dropout 或数据增强;如果两个都不涨,是欠拟合或学习率太小;如果 loss 突然 NaN,检查邻接矩阵归一化和输入里有没有 NaN 坐标。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 0 for x, label in loader: x, label = x.to(device), label.to(device) # 检查输入是否有 NaN,这是最常见的翻车点 if torch.isnan(x).any(): print('输入含 NaN,跳过该 batch') continue optimizer.zero_grad() out = model(x) loss = criterion(out, label) loss.backward() # 梯度裁剪,防止爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() correct += (out.argmax(1) == label).sum().item() total += label.size(0) return total_loss / len(loader), correct / total逻辑说明:每个 batch 先查 NaN,再做前向反向,梯度裁剪限制在 1.0 防止爆炸。参数上max_norm=1.0是通用值,骨骼数据梯度通常不大,1.0 足够。监控指标每 epoch 打印一次,方便画曲线写进毕设论文。
4.3 预训练权重加载与微调
如果做的是 NTU 子集或自采数据,强烈建议加载官方在 NTU-60 上训好的权重,冻结前几层只微调后面。加载时注意两点:一是分类头维度不同要跳过,二是邻接矩阵维度不同(18 vs 25)时整个空间卷积层都不能加载。
def load_pretrained(model, ckpt_path, num_class): ckpt = torch.load(ckpt_path, map_location='cpu') state = ckpt['state_dict'] if 'state_dict' in ckpt else ckpt model_dict = model.state_dict() # 过滤掉维度不匹配的层(分类头、邻接矩阵相关) filtered = {k: v for k, v in state.items() if k in model_dict and v.shape == model_dict[k].shape} model_dict.update(filtered) model.load_state_dict(model_dict) print(f'加载了 {len(filtered)}/{len(state)} 个参数') return model逻辑说明:用形状匹配做过滤,自动跳过分类头和维度不符的层。参数上num_class用于确认分类头是否被跳过。这个函数能打印加载比例,如果低于 50% 说明骨架定义差异太大,微调意义不大,不如从头训。
5. 避坑与排查:ST-GCN 复现时最容易翻车的 5 个点
5.1 现象:loss 从第一个 epoch 就是 NaN
原因:输入坐标里混入了 NaN 或 inf。OpenPose 在遮挡严重时会输出 0 置信度但坐标仍是数值,而某些姿态估计器会直接给 NaN。另一个可能是邻接矩阵没归一化,度数高的节点梯度爆炸。
解决:在 dataloader 里加一行x = torch.nan_to_num(x, nan=0.0),把 NaN 替换成 0;同时确认邻接矩阵做了D^-1 A归一化。如果还不行,把学习率降到 0.001 试一个 epoch。
5.2 现象:训练准确率 99%,验证准确率 40%
原因:典型过拟合。骨骼数据样本量小,模型参数量大(ST-GCN 约 300 万参数),很容易记住训练集。另一个隐藏原因是数据泄露——同一段视频的相邻帧被分到了训练集和验证集,模型其实在背帧。
解决:按视频而非按帧划分数据集,确保同一动作的不同片段不跨集;加 Dropout(0.5)和 weight_decay(1e-4);用前面说的三种数据增强。如果还不行,把网络宽度砍半。
5.3 现象:换了数据集后准确率暴跌
原因:骨架定义不一致。NTU 是 25 关节,Kinetics 是 18 关节,直接套用会导致邻接矩阵和输入维度对不上,或者边连错了位置。另一个原因是坐标归一化方式不同,有的数据集用屏幕坐标,有的用归一化坐标。
解决:换数据集时重新构建邻接矩阵,确认num_node和边列表;统一坐标到 [0,1] 范围,以髋关节中心为原点做平移归一化。这一步不做,模型学到的全是绝对位置而非相对动作。
5.4 现象:训练速度极慢,一个 epoch 要几小时
原因:数据加载是瓶颈。如果每次都在__getitem__里读 npy 文件,IO 会拖垮训练。另一个原因是 batch size 太小导致 GPU 利用率低。
解决:把所有 npy 预加载到内存(几千条数据通常几十 GB 以内),用num_workers=4多进程加载;batch size 提到 64,配合学习率同步放大。如果显存不够,用混合精度训练torch.cuda.amp,能省一半显存。
5.5 现象:推理时单人正常,双人动作全错
原因:M 维(人数)处理不当。训练时如果 M=2 但推理时只检测到 1 人,第二人补零,网络可能把零特征当成有效输入。另一个原因是两人顺序不稳定,这一帧 A 在左 B 在右,下一帧反了,网络学不到一致的特征。
解决:推理时按置信度排序,置信度低的补零;训练时对两人做随机交换增强,让网络对顺序不敏感。如果做的是交互动作,考虑用两人特征的相对位置而非绝对位置作为输入。
6. 让毕设出彩:三个可落地的改进方向与验证方法
跑通 baseline 只是及格线,毕设要拿高分得有差异化。下面三个方向我都试过,改动量可控,效果可量化。
第一个方向是自适应邻接矩阵。原始 A 是写死的物理连接,但有些动作(比如打电话)手和耳朵的关联比手和手肘更强。做法是加一个nn.Parameter形状为(V, V)的全局邻接矩阵,和物理 A 相加后一起归一化。改动只有几行,但 NTU-60 上通常能涨 1~2 个点。验证方法是做消融实验,对比固定 A 和自适应 A 的准确率,表格写进论文就是现成的创新点。
第二个方向是多流融合。ST-GCN 默认只吃关节坐标(joint stream),可以再加一路骨骼长度和方向(bone stream),两路各训一个模型,推理时 softmax 分数相加。骨骼流对动作幅度敏感,关节流对位置敏感,融合后互补。实现上把(N,C,T,V)里的 C 从坐标换成相邻关节的向量差即可。代价是训练时间翻倍,但准确率通常涨 2~3 个点,性价比很高。
第三个方向是注意力机制。在时间维加一个 SE block 或 Transformer encoder,让网络自己决定哪些帧重要。动作识别里起手和收尾帧信息量低,中间帧才是关键。做法是在每个 STGCNBlock 的时间卷积后接一个通道注意力,用全局池化算权重再乘回去。这个改动需要调参,注意力模块的学习率要比主干小 10 倍,否则训练不稳定。
验证方法统一用交叉验证 + 混淆矩阵。别只报一个准确率,把每一类的 precision、recall、F1 都算出来,找出模型最混淆的类别(通常是相似动作,比如「读书」和「写字」),针对性分析原因。混淆矩阵画出来放论文里,比干巴巴一个数字有说服力得多。
我自己的习惯是:每改一个模块,先跑 10 个 epoch 看 loss 曲线趋势,趋势对了再跑完整训练。别一上来就训 80 epoch,浪费卡时。另外所有实验都固定随机种子,torch.manual_seed(42)、np.random.seed(42),不然复现时对不上数,答辩被问就尴尬了。这套流程走下来,从跑通到出创新点大概两周,希望帮到你。
本文还有配套的精品资源,点击获取