基于ST-GCN的骨骼动作识别:从骨架坐标到动作标签的完整链路
简介基于时空图卷积ST-GCN的骨骼动作识别Python源码项目是经导师指导并获98分的高分毕业设计适合计算机相关专业正在做毕设的学生也适配课程设计与期末大作业场景。压缩包共有八十八个文件大小约五十二点五六MB包含二十九个Python脚本、十三个YAML配置、十二个pyc缓存、十一个GIF动图、三个MP4视频、三个预训练模型及多份TXT说明和PNG示意图完整覆盖数据预处理、模型构建、训练测试与离线识别流程。已有四百二十四人学习浏览。项目提供清晰目录和说明文档包含公开数据集处理工具与可复现实验配置训练好的权重可直接加载运行视频和动图便于直观查看识别效果从配置到源码注释均体现工程化的项目组织方式能帮助读者快速理解ST-GCN实现细节并借鉴高分毕设的完整设计思路。1. 基于ST-GCN的骨骼动作识别一条从骨架坐标到动作标签的完整链路基于时空图卷积ST-GCN的骨骼动作识别是动作识别方向里一个反直觉的破局点不拿整帧RGB图像做分类而是只看人体关键点的三维坐标把每帧的关节当成图节点、骨骼当边用图卷积建模关节空间关系用时间卷积串联帧间运动。这套思路在NTU-RGB-D和Kinetics-Skeleton上把识别精度拉到当时的前列模型体量也比视频CNN小得多。我拆的这套python源码是一份完成度很高的毕业设计工程包含单流与双流ST-GCN实现、NTU-RGB-D和Kinetics两套数据预处理脚本、训练与推理主程序、离线与实时demo以及两组训练好的模型权重和手工加边的改进实验记录。它解决的是“骨架序列到动作标签”的整条链路适合正在做相关毕设或课程设计、需要直接复现和二次开发的人。下文按源码结构、数据准备、训练推理、常见坑、本地验证逐章拆解保证按步骤能跑通。2. 读懂 net/st_gcn.py单流与双流图卷积网络的构成细节2.1 st_gcn.py 单流模型图卷积模块与时间卷积模块的职责划分打开net/st_gcn.py之前先把ST-GCN的输入输出结构在脑子里过一遍。进入模型的张量形状是(N, C, T, V, M)其中N是批量大小C是通道数一般取3对应x/y/z坐标T是采样后的帧数V是关节数NTU-RGB-D为25M是人数NTU里最多两人。模型最终输出一个(N, 类别数)的logits向量。整个主干就是空间图卷积和时间卷积交替堆叠中间穿插残差连接和批归一化。st_gcn.py 里最核心的图卷积操作常见实现是这样一个自定义图层class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, graph, strategyspatial): super().__init__() self.graph graph self.num_node graph.num_node # 归一化后的邻接矩阵shape: (num_subset, V, V) self.A self._normalize(graph.A) if strategy spatial: # spatial 分区策略下A 被拆成多个子邻接矩阵 self.num_subset len(self.A) self.conv nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size1) for _ in range(self.num_subset) ]) else: self.num_subset 1 self.conv nn.Conv2d(in_channels, out_channels, kernel_size1) def _normalize(self, A): # 加自环后做 D^{-1/2} A D^{-1/2} 对称归一化 A A np.eye(self.num_node, dtypeA.dtype) D np.sum(A, axis0) D_inv_sqrt np.diag(np.power(D, -0.5)) return np.dot(np.dot(D_inv_sqrt, A), D_inv_sqrt) def forward(self, x): out None for i in range(self.num_subset): # x: (N, C, T, V)与第 i 个子邻接矩阵相乘 ax torch.einsum(nctv,vw-nctw, x, self.A[i]) if out is None: out self.conv[i](ax) else: out self.conv[i](ax) return out这段代码里值得关注的是torch.einsum(nctv,vw-nctw, x, self.A[i])这一行。它把当前帧所有关节的特征沿关节维度做了一次矩阵乘法相当于一次信息传播每个关节从它的邻居节点上聚合特征。这正是图卷积和普通卷积的本质区别——普通卷积作用在规则网格上图卷积作用在由邻接矩阵定义的任意拓扑上。毕设答辩时如果被问到“图卷积怎么实现的”把这条 einsum 讲清楚基本就能过关。有了空间图卷积时间维度的建模交给一维卷积即可。ST-GCN 对时间维的处理不是用LSTM而是用带 padding 的二维卷积把帧维度当成卷积的宽度方向self.tcn nn.Sequential( nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), nn.Conv2d(out_channels, out_channels, kernel_size(9, 1), stride(1, 1), padding(4, 0)), nn.BatchNorm2d(out_channels), nn.Dropout(0.5, inplaceTrue), )注意这里的 padding 取值(4, 0)时间维度kernel_size是9padding4保证帧数不缩水关节维度kernel_size1不需要padding。源码里不同层的时间卷积核大小并不完全一样有的层用7有的用9这属于当时论文里的经验设定不建议新手乱动。整个网络把数据依次送入SpatialGraphConv → TCN → 残差的模块通道数从64逐步扩到128、256最终经过全局平均池化接全连接层输出分类结果。2.2 st_gcn_twostream.py 双流模型关节流与骨骼流的融合逻辑net/st_gcn_twostream.py是这份毕设的加分项。它把“关节坐标流”和“骨骼向量流”两个单流ST-GCN并行起来最后在logits层做融合。为什么要双流关节流看到的是每个关节的绝对位置而骨骼流看到的是关节之间的相对关系——比如“手臂抬起”这个动作在骨骼流里体现为肩到肘、肘到腕的向量方向变化这种信息关节流不敏感。双流模型的搭建逻辑可以简化为class TwoStreamSTGCN(nn.Module): def __init__(self, num_class, in_channels3, graph_argsNone): super().__init__() self.stream_joint STGCN(num_classnum_class, in_channelsin_channels, ...) self.stream_bone STGCN(num_classnum_class, in_channelsin_channels, ...) def forward(self, joint_data, bone_data): out_joint self.stream_joint(joint_data) out_bone self.stream_bone(bone_data) return out_joint out_bone这里有个容易被忽略的点关节流和骨骼流的输入通道数虽然都是3但含义不同。关节流的3个通道是(x, y, z)骨骼流的3个通道却是(x2-x1, y2-y1, z2-z1)即相邻关节的坐标差。数据预处理时需要把原始坐标转换成骨骼向量这个转换在feeder目录下的数据加载器里完成也可以在生成npy时提前算好。毕设选题想体现“改进点”双流加权重融合是个常见方向两路输出不用简单的等权相加而是引入一个可学习的融合系数让训练自己决定哪一路更重要。2.3 torchlight 配置体系与模型权重文件梳理这套源码的配置工具是torchlight它做的事情不复杂把 yaml 配置文件里的参数注册成全局字典同时负责日志输出、模型保存和测试时的指标打印。玩过深度学习框架的人都不会陌生torchlight 在这里的作用就是把 ST-GCN 的训练评价从命令行里解放出来参数改起来不用动代码。模型目录models/下有三个值得注意的权重文件文件对应模型典型用途OriginSTGCN.pt单流ST-GCN作为baseline跑NTU-RGB-DAddEdgeSTGCN12345.pt手工加边的单流ST-GCN毕设改进实验的主力模型kinetics-st_gcn.ptKinetics-Skeleton预训练模型做迁移学习或者直接跑demoAddEdgeWeight_2.txt这份文件尤其值得看一眼。它记录了加边实验里具体的边权重配置也就是在原始骨架图基础上额外给某些关节对增加了连接边比如双手之间、双脚之间。这样改的理论动机是某些动作拍手、踢腿依赖肢体末端的对称协调原始骨架图中这两处没有直接边信息只能绕远路传播。手工加边后模型的拓扑结构变了加载对应权重时也要保证st_gcn.py里的邻接矩阵同步修改否则会出现结构和权重对不上的报错。3. 数据准备先行ntu_gendata.py 与 NTU-RGB-D 骨架数据转换3.1 NTU-RGB-D 原始骨架格式与坐标含义模型吃的是规整的npy数组但NTU-RGB-D原始数据集给的是.skeleton文本文件。每个视频样本对应一个文件文件名形如S001C001P001R001A001.skeleton其中S是受试者编号、C是相机编号、P是表演者编号、R是回合、A是动作类别。拿到这个数据集后第一步不是训练而是把文本解析成张量。NTU原始骨架文件的结构是按帧组织的文件第一行是总帧数接下来每帧先写这一帧里出现几个人然后逐个写每个人的关节数NTU是25个关节点每个关节占一行一行里有8个数值——三维坐标(x, y, z)、深度图坐标(depth_x, depth_y)、彩色图坐标(color_x, color_y)以及一个置信度。ST-GCN 只用到(x, y, z)三个值。关节编号的物理含义在源码的DrawLine.py和 NTU官方文档里是一致的比如关节0是脊柱底部、关节1是脊柱中部、关节20和21分别是左右手。写数据预处理时关节编号对应关系是第一个要核对清楚的硬指标错一位整个模型就废了。3.2 ntu_gendata.py 转换流程与样本划分tools/ntu_gendata.py做的事情可以概括为遍历原始skeleton目录 → 解析每个文件 → 做帧采样和补零 → 按训练/验证划分保存npy。解析单帧骨架的核心逻辑很直接def get_body(f, num_joint): body [] for _ in range(num_joint): line f.readline().strip().split() # 每一行前三个数字是 x, y, z x, y, z float(line[0]), float(line[1]), float(line[2]) body.append([x, y, z]) return body def load_skeleton(file_path): with open(file_path, r) as f: frame_num int(f.readline().strip()) data [] for _ in range(frame_num): body_num int(f.readline().strip()) people [] for _ in range(body_num): num_joint int(f.readline().strip()) people.append(get_body(f, num_joint)) data.append(people) return data这份代码里有几个实际项目才会注意到的细节。body_num不一定是2有些帧里可能只有1个人或者3个人处理时要做好边界判断否则数组形状会崩。NTU官方规定每个样本最多取两个人超过两个人的帧直接丢弃或只保留前两个。另外xsub和xview两种划分方式都写在了ntu_gendata.py里xsub 按受试者编号划分——部分编号的人进训练集、其余进验证集xview 按相机视角划分——一个相机进训练集、另外两个相机进验证集。毕设里跑NTU-RGB-D时最常用的基准是xsub写论文时记得标注用的是哪种划分。转换过程中还涉及帧数量对齐。原始视频的帧数参差不齐有的300多帧有的只有几十帧而ST-GCN的输入要求固定帧数。常见做法是设定max_frame300超过300帧的均匀抽帧到300帧不足300帧的尾部补零sample_idx np.linspace(0, frame_num - 1, max_frame).astype(int) data data[sample_idx] # 抽帧到固定长度3.3 输出 npy 文件的数据布局与标签存储转换完成后ntu_gendata.py会在data/NTU-RGB-D/xsub/目录下生成一组文件train_data.npy、val_data.npy、train_label.pickle、val_label.pickle。训练数据的shape是(样本数, 3, 帧数, 25, 2)5个维度的含义依次是样本索引、坐标通道、时间帧、25个关节点、最多两个人。如果某帧只有一个人第二个人对应位置上全是0。加载数据时用feeder/feeder.py里的Feeder类它接收npy路径和label路径在__getitem__里按索引取出一个样本并转成模型输入需要的张量。这里有一个实际坑NTU的label是从1开始编号的动作类别1到60而模型输出的类别索引从0开始。所以生成label时要对原始动作编号减1否则训练时的交叉熵损失会直接报错或者学出来的类别总是偏一位。源码里已经处理了这个偏移但如果自己重新生成数据一定要检查label范围是否落在[0, num_class)之间。另外提一句Kinetics数据的差异kinetics_gendata.py处理的是Kinetics-Skeleton的JSON格式而不是NTU的文本格式。Kinetics的骨架数据用(x, y, z)存储20个关节点来自OpenPose估计结果没有NTU的深度图和彩色图坐标。两套预处理脚本的输入输出设计成一致的后续模型代码完全不关心数据来自哪个数据集这是源码里做得比较干净的地方。4. 训练与推理主线从 main.py 到 recognition.py 的完整流程4.1 用 get_models.sh 准备预训练权重项目里tools/get_models.sh脚本的作用是下载预训练模型把权重放到models/目录。和自训练模型不同Kinetics预训练权重kinetics-st_gcn.pt是在Kinetics-Skeleton上训练的类别数是400而NTU-RGB-D只有60类。直接把这么大的权重拿去finetune最后的全连接层尺寸对不上需要把最后一层替换掉才能用。常见做法是先加载预训练权重再覆盖模型的分类层model STGCN(num_class60, in_channels3, ...) pretrained torch.load(models/kinetics-st_gcn.pt) # 删除最后一层 fc 的键 pretrained.pop(fc.weight, None) pretrained.pop(fc.bias, None) model.load_state_dict(pretrained, strictFalse)strictFalse在这里是必须的因为去掉全连接层后模型参数和预训练权重并不完全一致。这段逻辑在思维上很容易被忽略实际加载时少了strictFalse就会报一堆missing keys和unexpected keys的警告直接解决不了会误以为模型坏了。4.2 recognition.py 推理流程与参数说明processor/recognition.py是推理和验证的处理器它定义了train、val、test三个阶段的流程。测试阶段主要工作是加载模型权重、遍历验证集数据、前向传播得到logits、计算top-1和top-5准确率。核心推理代码大致如下def predict(self, data_loader): self.model.eval() results [] with torch.no_grad(): for data, label in data_loader: data data.to(self.device) output self.model(data) _, pred output.topk(1, 1, True, True) results.extend(pred.cpu().numpy().tolist()) return results代码逻辑不复杂但有两个指标需要自己盯。第一个是top-1/top-5准确率源码里已经实现了多分类的统计第二个是每类的平均精度毕设答辩如果评委问“哪些类别识别不好”就要用混淆矩阵来看源代码里没有直接给出建议自己补一段sklearn.metrics.confusion_matrix的统计代码。模型权重路径在配置文件里通过weights:字段指定训练完直接把最优权重填进去即可跑验证。demo_offline.py和demo_old.py是离线视频推理程序。demo_offline.py的输入是视频文件它会逐帧调用OpenPose提取人体关键点再组装成ST-GCN的输入张量完成识别。但注意这套流程依赖OpenPose环境Python 3.8和OpenPose的兼容问题比较闹心我之前在一台新机器上光是装OpenPose就折腾了大半天。如果只是想验证模型本身更推荐直接用已经转换好的npy数据跳过OpenPose这层依赖。4.3 从 train.yaml 调整关键超参数训练入口是主程序main.py启动命令是python main.py --config config/st_gcn/ntu-xsub/train.yaml配置文件config/st_gcn/ntu-xsub/train.yaml控制的参数决定了训练行为几个核心项如下参数常见值作用与调参建议batch_size64显存不够时先降到这里同时同步降低学习率base_lr0.1初始学习率batch_size减半时lr也建议减半momentum0.9SGD动量保持默认即可weight_decay0.0001L2正则系数过拟合明显时增大到0.001num_epoch80训练轮数NTU上80轮左右基本收敛optimizerSGD也可以换Adam但收敛曲线变化比较大phases[train, valid]控制当前阶段测试时改为[test]训练过程中最容易忽略的是学习率的调整策略。ST-GCN的默认调度是每30轮乘以0.1也就是阶梯式下降。如果你把batch_size从64改成16但忘了降学习率前几个epoch的loss会出现明显震荡——这不是模型写错了是学习率过大导致的。另外配置文件里如果设了save_top_k: 5训练时会保存验证集top-5最优的5个checkpoint最终使用其中验证精度最高的那个而不是最后一轮的权重。这是源码里一个容易踩的细节很多人训练完直接加载epoch80.pt却不知道还有一个验证精度更高的早期checkpoint。5. 避坑指南数据路径、模型加载到实时demo的五个坑5.1 现象一ntu_gendata.py 直接报 “No such file or directory”这个报错十有八九出现在第一次运行数据准备脚本时。原因很直白ntu_gendata.py里默认的原始数据路径指向data/NTU-RGB-D/ntu_rgb_d_skeletons如果你没有在项目根目录下建这个目录、也没把下载好的NTU数据集解压进去脚本第一行读文件就会失败。解决方法是先确认目录结构把原始skeleton文件放到预期位置或者直接改脚本头部的路径变量。我一般会先建目录再解压mkdir -p data/NTU-RGB-D/ntu_rgb_d_skeletons unzip NTU-RGB-D-skeletons.zip -d data/NTU-RGB-D/ntu_rgb_d_skeletons/ python tools/ntu_gendata.py5.2 现象二加载模型报 “Missing key(s) in state_dict”明明给了训练好的权重torch.load之后load_state_dict却报缺了fc.weight之类的键。原因基本是模型定义和保存时的类别数不一致kinetics-st_gcn.pt是400类当前STGCN(num_class60)的最后一层尺寸自然是400和60对不上。解决方法是按4.1节的方式把全连接层从预训练权重里剥掉用strictFalse加载再单独重新初始化分类层。这属于ST-GCN迁移学习的基本操作不处理和“这个模型没法用”是两回事。5.3 现象三双流模型精度不升反降双流ST-GCN在NTU上正常应该比单流高2到3个百分点如果你跑出来双流反而低了先别怀疑融合策略大概率是骨骼流的数据没算对。骨骼向量的定义是相邻关节坐标差但“相邻”的拓扑关系要和图结构的边定义一致。比如肩到肘是骨骼手腕到手指尖也是骨骼两组向量不能混。源码的feeder_kinetics.py里骨骼计算用的是沿着骨架边的差值也就是joint_a - joint_b如果改成任意关节差模型学到的就不再有骨骼物理意义。另外双流模型训练时的batch_size等于两路流各占一半显存如果显存不变、直接沿用单流的batch_size每个流实际分到的batch就减半了收敛会变慢。这种情况下的常见做法是保持总batch_size不变把学习率略微调低或者直接把两条流的backbone参数做共享。5.4 现象四demo_offline.py 一直卡在 OpenPose 检测环节离线demo跑起来没有任何报错但就是不出识别结果终端停在一行OpenPose的日志上不动。原因多数是OpenPose的模型文件没有下载完全或者视频分辨率太大导致单帧检测耗时过长。解决思路分两步先确认OpenPose是否在单独测试时能输出关键点JSON再把输入视频压缩到较短时长、分辨率降到720p以下给demo减负。如果只是想看分类效果完全可以直接把demo从“视频OpenPose”换成“npy预训练权重”在recognition.py里跑一遍测试集效果是一样的。5.5 现象五模型在CPU上推理慢到没法用用demo_realtime.py在笔记本CPU上跑帧率只有1到2 FPS。这是预期内的事ST-GCN虽然有图卷积加持但时间卷积和批量归一化在CPU上并不快再加上OpenPose的骨架提取占用了大量CPU时间实时性基本无解。实际可用方案是两条一是换带CUDA的GPU跑帧率能拉到20 FPS左右二是如果只有CPU就把输入帧数从300降到100batch_size固定为1同时关闭OpenPose的实时检测改用预先录好的骨架数据回放。分类精度会有一点损失但至少demo能转起来。6. 端到端尝试验证用 demo_realtime 与 DrawLine 检查模型是否真的可用6.1 demo_realtime 单机调试步骤demo_realtime.py是这套工程里最直观的验证入口接摄像头 → 提取骨架 → 送入ST-GCN → 在画面上叠加动作类别名称。启动前注意两点确认models/下有可用的权重确认OpenPose依赖可用。启动命令通常是python demo_realtime.py --model_path ./models/AddEdgeSTGCN12345.pt跑起来后分别做四个动作站立、举手、走路、坐下。观察画面左上角显示的类别ID和置信度如果类别ID停留在“站”这类静态动作上说明模型在工作但动作没被抓到如果会跟着挥手这类大动作切换说明前向通路没问题。这个测试主要是验证“数据加载→模型前向→结果回传”整条链路而不是验证精度——想验证精度还是用NTU验证集跑一遍top-1准确率更靠谱。6.2 DrawLine 骨架可视化与识别结果对照DrawLine.py这个工具在很多版本的ST-GCN工程里都出现过作用是把关键点坐标按骨架拓扑画成线框人体用来目测关键点提取结果和关节编号映射是否正确。我这里的具体用法是用一个已经输出的npy样本把第0帧和第30帧的骨架用DrawLine画出来对照“抬手”动作检查手肘和手腕关节的坐标是否合理。如果画出来的线框出现手肘位置长在了肩膀附近这种明显错位先别怪模型回去检查关节编号和骨架边的连接定义。骨架可视化不直接提升精度但它能把黑匣子的输入侧打开让你确认模型看到的内容是对的。从那以后我每次拿到一套骨架动作识别源码都会强制自己走一遍“先跑数据转换、再单流加载、最后实时demo”的三步流程不跳步不直接上来就训练。这样两步之间出了问题能立刻定位是数据的问题、权重的问题还是环境的问题。老话讲知行合一在ST-GCN这种依赖数据管线很深的项目里“知道每个函数输入输出是什么”比“会跑通一次训练”更有用希望帮到你。本文还有配套的精品资源点击获取