
简介本资源是一套面向高校学生与深度学习初学者的Python毕业设计完整项目围绕时空图卷积网络ST-GCN实现骨骼动作识别可用于课程设计、毕业答辩及计算机视觉方向的入门实践。项目基于人体骨骼图结构通过图卷积同时捕捉动作的空间连接与时序变化覆盖健康监测、人机交互、视频监控等典型应用场景。压缩包共91个文件约52.54MB包含29个py源码、13个yaml配置、12个pyc编译文件、3个pt训练权重、3个mp4演示视频及gif动图、md说明文档等涵盖数据生成、模型定义、训练配置与离线/实时识别demo等模块。已有175人学习下载。读者可获得经过测试、答辩评审94.5分的可运行源码与训练好的模型并附完整项目文档便于理解ST-GCN的端到端特征学习流程、复现实验并在此基础上二次开发。1. 从一段骨架序列说起ST-GCN 骨骼动作识别到底在做什么假设你手里有一批用 Kinect 或者人体姿态估计模型OpenPose、MediaPipe导出的骨骼点数据每帧记录 18 或 25 个关节的二维/三维坐标一段动作持续几十到上百帧。现在要判断这段序列属于“挥手”“下蹲”还是“跌倒”——这就是骨骼动作识别的任务。ST-GCNSpatial Temporal Graph Convolutional Networks是把这个任务做得足够简洁又足够有效的一条路径它把每一帧的人体骨架当成一张图关节是节点、骨骼是边再沿时间轴把连续帧连起来形成一个时空图然后用图卷积同时提取空间结构特征和时间动态特征。对做 python 毕业设计的人来说它的吸引力在于数据集公开NTU RGBD、Kinetics-Skeleton、代码结构清晰、单卡就能训、论文引用量高、答辩时讲得清楚。这篇笔记就围绕“python ST-GCN 骨骼动作识别 源代码 模型 项目文档”这套组合把从环境搭建到训练出可用模型、再到写进毕业设计文档的完整路径拆开讲新手能照着跑熟手能看到参数边界和踩坑点。2. 时空图卷积的建模逻辑与数据准备为什么不是直接上 3D 卷积2.1 骨骼数据的图结构表达与邻接矩阵构造骨骼动作识别和普通视频动作识别的根本区别在于输入形式。视频是像素立方体骨骼是坐标序列。ST-GCN 的核心洞察是人体骨架天然是一张图不需要把它强行塞进规则网格。具体做法是对每一帧构建一个无向图 G (V, E)V 是 N 个关节节点E 是骨骼连接边。NTU RGBD 常用 25 个关节Kinetics-Skeleton 常用 18 个。然后定义一个邻接矩阵 A大小为 N×NA[i][j]1 表示关节 i 和 j 有骨骼连接。但直接用原始邻接矩阵有一个问题它只表达了“有没有连接”没有区分“向心”和“离心”。ST-GCN 论文里提出了三种分区策略常见做法是用空间构型分区spatial configuration partitioning把每个节点的邻居分成三组——根节点自身、比根节点更靠近骨架重心的邻居、比根节点更远离重心的邻居。这样邻接矩阵从一个变成三个分别对应三个子集卷积时对三个子集分别做图卷积再求和。代码层面这个邻接矩阵通常预计算好存成.npy文件。以 NTU 25 关节为例常见做法是生成三个矩阵A_self、A_inward、A_outward每个形状 25×25。如果你拿到的源代码里只有一个adjacency_matrix.npy那大概率是简化版只用了单一分区精度会低一些但跑通没问题。提示邻接矩阵的关节顺序必须和你的数据生成脚本一致。OpenPose 的 18 点顺序和 NTU 的 25 点顺序完全不同混用会导致模型学出“玄学”结果——训练 loss 下降但验证精度不涨。2.2 从原始骨架到训练张量数据预处理的最小命令ST-GCN 的输入张量形状是(N, C, T, V, M)其中 N 是 batch sizeC 是通道数坐标维度通常 2 或 3T 是帧数常见 300V 是关节数18 或 25M 是人数通常 1。原始数据通常是每个样本一个.skeleton文件或.npy文件里面是(T, V, C)或(帧数, 人数, 关节数, 坐标)。下面是一个把原始坐标序列转成 ST-GCN 输入格式的最小预处理脚本我一般会放在data_gen/目录下import numpy as np import os def preprocess_skeleton(raw_path, save_path, max_frames300, num_joints25, num_channels2): 读取原始骨架序列统一帧数输出 ST-GCN 可用的 npy 文件。 raw_path: 原始 npy形状 (T, V, C) 或 (T, M, V, C) save_path: 输出路径 max_frames: 统一帧数不足补零超出均匀采样 data np.load(raw_path) # 如果带人数维度取第一个人 if data.ndim 4: data data[:, 0, :, :] # (T, V, C) T, V, C data.shape # 只取前两个通道x, y忽略置信度 data data[:, :, :num_channels] # 帧数对齐 if T max_frames: idx np.linspace(0, T - 1, max_frames).astype(int) data data[idx] else: pad np.zeros((max_frames - T, V, num_channels)) data np.concatenate([data, pad], axis0) # 归一化以髋关节为中心除以肩宽 # 这里假设关节 0 是髋中心关节 4 和 8 是左右肩NTU 顺序 hip data[:, 0:1, :] # (T, 1, C) data data - hip shoulder_dist np.linalg.norm(data[:, 4, :] - data[:, 8, :], axis1).mean() if shoulder_dist 1e-6: data data / shoulder_dist # 转成 (C, T, V, 1) data np.transpose(data, (2, 0, 1)) data np.expand_dims(data, axis-1) np.save(save_path, data) return data.shape # 批量处理 raw_dir ./raw_skeleton out_dir ./processed os.makedirs(out_dir, exist_okTrue) for fname in os.listdir(raw_dir): if fname.endswith(.npy): shape preprocess_skeleton( os.path.join(raw_dir, fname), os.path.join(out_dir, fname) ) print(fname, -, shape)这段代码做了四件事统一帧数到 300、只保留 x/y 坐标、以髋关节为中心做平移归一化、除以肩宽做尺度归一化。参数max_frames是最关键的NTU 官方推荐 300但如果你自己的数据动作普遍较短比如 60 帧以内设 300 会引入大量零填充模型可能学到“零填充比例”这种捷径特征。我一般会先统计所有样本的帧数分布取 90 分位数作为max_frames。num_joints和num_channels必须和后续模型配置一致。如果你用的是 MediaPipe 的 33 点那num_joints33邻接矩阵也要重新生成。归一化那一步很多人会跳过结果就是模型对拍摄距离和人体位置极度敏感换个摄像头就翻车。3. 搭起 ST-GCN 网络并跑通训练从配置文件到第一个 checkpoint3.1 网络结构拆解与关键参数含义ST-GCN 的基本单元是 ST-GCN Block每个 Block 包含一个空间图卷积和一个时间卷积。空间图卷积负责在关节之间聚合信息时间卷积负责在相邻帧之间聚合信息。整个网络通常堆 9 到 10 个 Block通道数从 64 逐步翻倍到 256最后接全局平均池化和全连接分类层。以常见开源实现为例配置文件里几个参数直接决定模型能不能训起来参数名常见取值作用调参建议in_channels2 或 3输入坐标维度2D 骨架用 23D 用 3num_class60 / 120 / 自定义分类数必须和标签数一致graph_args{layout: ntu-rgbd, strategy: spatial}图结构和分区策略换数据集要改 layoutedge_importance_weightingTrue是否学习边权重小数据集设 False 防过拟合dropout0.5防过拟合数据少于 1000 样本时调到 0.7batch_size16 / 32 / 64批大小单卡 8G 显存用 16lr0.1 / 0.01学习率用 SGD 时 0.1Adam 时 0.001graph_args里的layout决定了邻接矩阵怎么生成。如果你用的是 NTU 25 关节写ntu-rgbd如果是 Kinetics 18 关节写kinetics。这个参数写错模型不会报错但精度会掉 10 个点以上属于典型的“静默翻车”。3.2 训练命令与日志观察第一个 epoch 该看什么假设你拿到的源代码目录结构是model/、config/、main.py、processor/训练入口通常是main.py。下面是一条我常用的训练命令python main.py \ --config config/nturgbd-cross-view/train_joint.yaml \ --work-dir work_dir/ntu_cv_joint \ --device 0 \ --batch-size 32 \ --num-epoch 80 \ --lr 0.1 \ --step 30 50 \ --save-interval 10参数说明--config指定配置文件里面已经写好了数据路径、图结构、模型深度--work-dir是日志和模型保存目录--device 0指定第一块 GPU--step 30 50表示在第 30 和 50 个 epoch 把学习率除以 10--save-interval 10每 10 个 epoch 存一次 checkpoint。启动后第一个 epoch 要盯三个数训练 loss、训练精度、验证精度。正常情况训练 loss 从 4.0 左右60 类开始下降第一个 epoch 结束验证精度在 5% 到 15% 之间。如果验证精度一直是 1/60≈1.67%说明模型没学到东西优先检查标签映射是否对齐、邻接矩阵关节顺序是否和数据一致。如果训练 loss 不降检查学习率是不是太大loss 震荡或太小loss 几乎不动。注意ST-GCN 对学习率比较敏感。用 SGD 时 0.1 是常见起点但如果你的 batch size 小于 160.1 可能太大建议按 batch size 线性缩放比如 batch 16 用 0.05。3.3 从 checkpoint 到推理加载模型跑单样本测试训练完成后work_dir里会有epoch_80.pt之类的文件。推理脚本一般长这样import torch import numpy as np from model.st_gcn import Model # 加载配置和模型 num_class 60 in_channels 2 graph_args {layout: ntu-rgbd, strategy: spatial} model Model(in_channelsin_channels, num_classnum_class, graph_argsgraph_args, edge_importance_weightingTrue) model torch.nn.DataParallel(model) checkpoint torch.load(work_dir/ntu_cv_joint/epoch_80.pt, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict]) model.eval() # 加载一个预处理好的样本 sample np.load(processed/S001C001P001R001A001.npy) # (C, T, V, 1) sample torch.from_numpy(sample).float().unsqueeze(0) # (1, C, T, V, 1) with torch.no_grad(): output model(sample) prob torch.softmax(output, dim1) pred torch.argmax(prob, dim1) print(预测类别:, pred.item(), 置信度:, prob[0, pred].item())这里有几个容易出问题的地方DataParallel保存的 state_dict 键名会带module.前缀如果推理时不加DataParallel会报键不匹配unsqueeze(0)加的 batch 维度必须在最前面model.eval()必须调用否则 BatchNorm 和 Dropout 行为不一致同一份数据两次推理结果不同。我一般会在推理脚本里加一个torch.manual_seed(42)方便复现。4. 避坑与排查骨骼动作识别里最容易翻车的 5 个地方4.1 现象训练精度很高但验证精度极低差距超过 40%原因最常见的是数据泄漏。同一段动作被切分成多个样本一部分进了训练集一部分进了验证集模型实际上在“背”这段动作。另一个原因是归一化参数在训练集和验证集上分别计算导致分布不一致。解决按原始视频或按人物 ID 划分训练/验证集不要按切分后的样本随机划分。归一化参数均值和标准差只在训练集上统计然后应用到验证集。如果用的是 NTU 官方划分直接用train_joint.yaml里给定的划分文件不要自己随机分。4.2 现象模型对某些类别始终预测为同一类混淆矩阵里一整行都是同一个数字原因类别不平衡。NTU 里“喝水”“打电话”这类动作样本多罕见动作样本少模型倾向于预测多数类。另一个可能是标签映射错误多个类别被映射到了同一个索引。解决先打印每个类别的样本数确认没有类别样本数为 0。如果确实不平衡在 loss 里加类别权重权重设为总样本数 / (类别数 * 该类样本数)。标签映射错误的话检查label_map字典确保每个动作名对应唯一索引。4.3 现象换用自己的数据后模型完全不收敛loss 在 4.0 附近震荡原因自己的数据关节顺序和预训练模型不一致或者坐标没有归一化数值范围差异大。也有可能是帧数对齐方式不对比如用了随机采样而不是均匀采样导致同一动作每次训练看到的帧序不同。解决先可视化几个样本的骨架确认关节连接关系正确。然后检查坐标数值范围正常归一化后应该在 -1 到 1 之间。帧数对齐统一用均匀采样不要用随机采样。如果数据量少于 500 个样本建议冻结前几个 Block只训后面的层。4.4 现象推理时显存溢出但训练时正常原因推理时没有用torch.no_grad()PyTorch 仍然在构建计算图。或者 batch size 设得比训练时还大。解决推理代码里加with torch.no_grad():并且把 batch size 设为 1 或 8。如果还溢出检查是不是把整个验证集一次性加载到了 GPU 上改成逐 batch 加载。4.5 现象训练 loss 正常下降但验证精度在第 20 个 epoch 后突然崩掉原因学习率 step 设置太激进第 20 个 epoch 学习率降了 10 倍模型跳出了局部最优。或者 BatchNorm 的 running mean 在验证集上不适用因为验证集分布和训练集差异大。解决把 step 往后挪比如从[30, 50]改成[40, 60]。如果怀疑 BatchNorm可以尝试用 GroupNorm 替换但 ST-GCN 原版用 BatchNorm 居多替换后需要重新调参。我一般会保留每个 epoch 的 checkpoint崩了之后回退到崩之前的那个。5. 把模型写进毕业设计文档精度验证、消融实验与一个提分技巧5.1 验证方法Top-1 精度、混淆矩阵和跨视角测试毕业设计文档里不能只写“跑通了”要有量化结果。ST-GCN 在 NTU RGBD 跨视角协议下的常见基线是 80% 到 88% Top-1 精度跨被试协议是 75% 到 82%。如果你自己训出来低于这个范围先检查配置是否和论文一致再检查数据预处理。验证时至少输出三个东西Top-1 精度、混淆矩阵、每个类别的 precision/recall。混淆矩阵能直观看出哪些类别容易混比如“阅读”和“写字”在骨骼序列上几乎一样混是正常的。跨视角测试是指训练用相机 1 的视角测试用相机 2 的视角这个协议更能反映模型的泛化能力。from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt # 假设 all_preds 和 all_labels 是推理结果 cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(12, 10)) sns.heatmap(cm, annotFalse, cmapBlues) plt.xlabel(预测类别) plt.ylabel(真实类别) plt.savefig(confusion_matrix.png, dpi200) print(classification_report(all_labels, all_preds, digits4))这段代码输出混淆矩阵热力图和分类报告。分类报告里的f1-score比单纯看精度更有说服力尤其是当类别不平衡时。如果某个类别的 f1 低于 0.5在文档里要单独分析原因这反而是加分项说明你做了深入分析。5.2 消融实验证明 ST-GCN 的时空建模确实有效毕业设计里做一个简单的消融实验能显著提升文档质量。常见做法是对比三个变体只用空间图卷积去掉时间卷积、只用时间卷积去掉图卷积、完整 ST-GCN。如果完整版比两个变体都高 5 个点以上就说明时空联合建模是有效的。另一个消融是分区策略用单一邻接矩阵 vs 用三个子集分区。这个对比能说明空间构型分区的作用。实验设置里要控制变量除了被消融的模块其他参数完全一致。结果用表格呈现每行一个变体列 Top-1 精度和参数量。5.3 一个提分技巧在时间维度上加多尺度空洞卷积如果基础版跑完精度不够可以试一个改动小但有效的技巧把时间卷积的 kernel size 从 9 改成多尺度组合比如同时用 kernel size 3、5、7 的空洞卷积然后把输出拼接。这个改动不增加太多参数但能捕捉不同时间尺度的动作动态。我试过在 NTU 跨视角协议上这个改动能带来 1.5 到 2 个点的提升。具体实现是在 ST-GCN Block 的时间卷积部分把原来的nn.Conv2d替换成一个多分支模块class MultiScaleTemporalConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_sizes[3, 5, 7]): super().__init__() self.branches nn.ModuleList() for k in kernel_sizes: padding (k - 1) // 2 self.branches.append( nn.Conv2d(in_channels, out_channels // len(kernel_sizes), kernel_size(k, 1), padding(padding, 0)) ) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU() def forward(self, x): # x: (N, C, T, V) out torch.cat([branch(x) for branch in self.branches], dim1) return self.relu(self.bn(out))这个模块把时间卷积拆成三个分支每个分支用不同 kernel size最后拼接。注意out_channels要能被len(kernel_sizes)整除否则拼接时通道数对不上。训练时这个模块的学习率可以设得比主干小一点比如 0.01避免初期震荡。我自己做毕业设计那会儿最大的教训是不要一上来就改网络结构先把数据预处理和训练配置调对基线跑稳了再动模型。很多“模型不 work”的情况最后查出来都是数据对齐的问题。希望帮到你。本文还有配套的精品资源点击获取