简介:这是一份面向毕业设计、期末大作业及课程设计的Python实战项目,基于时空图卷积网络(ST-GCN)实现骨骼动作识别。项目代码包含详细注释,从数据处理、模型构建到训练推理均有清晰呈现,适合具备一定Python基础、希望快速上手深度学习动作识别方向的新手学习者参考。资源包共90个文件,以29个Python脚本为核心,涵盖模型定义、特征提取、训练与演示流程;同时包含13个YAML配置文件、预训练模型(pt)、演示视频(mp4)与GIF动图,以及说明文档(md/txt)等,便于理解项目结构与复现效果。压缩包大小约52.61MB,部署简单,下载后按照文档指引即可运行。目前已有189人学习下载,认可度较高。项目作者自述获得98分并受到导师认可,代码组织规范,包含离线演示、实时演示及多种骨骼数据处理工具,不仅适合直接作为毕设答辨展示,也可作为学习ST-GCN原理与PyTorch工程实践的完整范例。
1. 基于 ST-GCN 的骨骼动作识别:一个能跑通、能改、能讲清原理的毕设项目
骨骼动作识别这几年在毕业设计里算是热度很高的一类选题,核心原因不是它比图像识别更简单,而是它的技术链路足够完整:OpenPose 拿骨架、图卷积做时空建模、NTU-RGB-D 或 Kinetics 做训练验证,一套走下来,算法深度、工程落地、论文图表全都有了。这份基于时空图卷积 ST-GCN 的 Python 源码项目,属于典型的「实验室代码风格 + 全流程可运行」的毕业设计资源,压缩包里除了模型代码和训练脚本,还直接给了三个训练好的权重文件,拿到手不用从头训就能先跑通 demo,这对答辩前时间紧张的学生来说非常关键。下面我按自己拆项目的习惯,把这套代码从数据入口到模型推理完整过一遍,顺带把最容易踩的坑提前指出来。
2. ST-GCN 的原理值得自己捋一遍:图卷积为什么能用在骨骼序列上
2.1 骨骼数据不是图像,用 CNN 硬套会丢掉拓扑结构
图像分类用 CNN,是因为像素在二维网格上有天然的局部相关性,卷积核扫过去就是在做空间特征提取。但骨骼数据不一样——人体骨架是 25 个关键点(NTU 是 25,Kinetics 用的是 18 个),这些点通过骨骼连接构成一张图,关节之间并没有规则的网格关系。把骨架坐标排成矩阵塞进 CNN,等于强行把图结构拉平成欧几里得结构,关节之间的连接关系就丢了。
ST-GCN 的核心思路是把骨骼序列建模成时空图:空间维度上,关节是节点,骨骼是边;时间维度上,同一个关节在相邻帧之间的连线构成时间边。图卷积在这张时空图上做特征聚合,既能学到单帧内关节的空间共现关系(比如挥手时手腕和手肘的联动),也能学到跨帧的时序动态(比如下蹲时膝盖角度随时间的变化)。这套思路最早由 AAAI 2018 的论文《Spatial Temporal Graph Convolutional Networks for Skeleton-Based Action Recognition》提出,后续的很多改进模型都是在它的框架上加注意力、加多流、改分区策略。
项目的net/st_gcn.py里实现的就是这个基础结构。我先看它的核心类STGCN:
class STGCN(nn.Module): def __init__(self, in_channels, num_class, graph_args, edge_importance_weighting=True, **kwargs): super().__init__() self.graph = Graph(**graph_args) A = torch.tensor(self.graph.A, dtype=torch.float32, requires_grad=False) self.register_buffer('A', A) # 9 层 ST-GCN 块,通道数从 3 逐步扩到 256 self.data_bn = nn.BatchNorm1d(in_channels * A.size(1)) self.st_gcn_networks = nn.ModuleList(( st_gcn_block(in_channels, 64, self.A, 1, residual=False), st_gcn_block(64, 64, self.A, 1), st_gcn_block(64, 64, self.A, 1), st_gcn_block(64, 128, self.A, 2), st_gcn_block(128, 128, self.A, 1), st_gcn_block(128, 128, self.A, 1), st_gcn_block(128, 256, self.A, 2), st_gcn_block(256, 256, self.A, 1), st_gcn_block(256, 256, self.A, 1), )) self.fc = nn.Linear(256, num_class)这段代码里有个参数很关键:edge_importance_weighting=True时,每条骨骼边会有一个可学习的权重参数,模型在训练过程中会自动调节不同骨骼对动作判别的重要性——长跑时腿部骨骼权重会被拉高,弹钢琴时手指骨骼权重会被拉高。这个机制在论文里被称为 edge importance weighting,是 ST-GCN 比早期图卷积方法效果好的重要原因,很多二区论文的改进点也都挂在这个机制上。
2.2 邻接矩阵和分区策略:图卷积的数学细节
图卷积和普通卷积的差别在于聚合方式。普通卷积用固定大小的卷积核扫过像素邻域;图卷积用邻接矩阵 A 定义邻居关系,再用度矩阵 D 做归一化。ST-GCN 的Graph类里,A不是一张朴素的 0/1 邻接矩阵,而是基于分区策略拆成三张子矩阵:
# 源码里 Graph 类的结构(st_gcn.py 的 Graph 类注释中体现) # A.shape = (num_subsets, num_joints, num_joints) # num_subsets = 3 表示三种分区,分别对应: # 0: 关节自身(自连接) # 1: 比中心关节点更靠近骨架重心的邻居 # 2: 比中心关节点更远离骨架重心的邻居这被称为 spatial configuration partitioning,是 ST-GCN 原文提出的分区方案。模型在每一层图卷积里分别用这三张子矩阵做聚合,再把结果拼接,相当于同时学习了「离心运动」「向心运动」「静止」三种身体部位的协同特征。
Graph类的get_hop_distance和normalize_digraph负责构建这个矩阵。normalize_digraph里的归一化公式是 D^(-1)A,也就是对每个节点,把所有邻居的贡献按度数取平均,而不是像 GCN 原文那样用 D^(-1/2)AD^(-1/2)。这个差异直接影响了梯度传播的稳定性,在训练时如果发现 loss 波动特别大,可以先检查是不是 A 的归一化方式被改动了。
项目里还额外提供了一个改进版权重AddEdgeSTGCN12345.pt,从命名可以推测是针对原始 ST-GCN 的邻接矩阵做了边权重叠加的变体——这个在后面第 4 章训练部分我会详细讲它的实现差异。
3. 把原始骨骼数据变成模型能吃的张量:数据预处理链路的三个关键环节
3.1 从 OpenPose 的 JSON 到 Kinetics 格式的 numpy 数组
这套源码支持两个数据集:NTU-RGB-D 和 Kinetics-skeleton。前者的数据是 Kinect 采集的 3D 骨骼坐标(25 个关节点),后者是 OpenPose 从视频里提取的 2D 骨骼(18 个关节点,来自 COCO 关键点格式)。两种数据的组织方式不一样,但处理后进入模型的格式是统一的:(N, C, T, V, M)。
kinetics_gendata.py是 Kinetics 数据集的处理脚本。Kinetics-skeleton 的原始标注是以 JSON 行为单位存储的:每行一个样本,包含frame_index、skeleton、label等字段。skeleton里是每一帧每个人的 18 个关节点坐标和置信度。处理脚本做的事情是:读 JSON 行 → 按帧序排列 → 补齐到固定帧数 → 存成.npy特征文件和.pkl标签文件。
# 处理 NTU-RGB-D 数据,生成训练/验证所需的 npy 文件 python ntu_gendata.py --data_path /path/to/ntu/raw \ --out_folder /path/to/ntu/processed \ --ignored_sample_path ntu_ignored_samples.txtntu_gendata.py的参数含义:--data_path指向 NTU 原始.skeleton文件目录;--out_folder是输出目录,会生成train_data.npy、train_label.pkl、val_data.npy、val_label.pkl;--ignored_sample_path是论文作者提供的缺失帧样本列表,这些样本在预处理时直接跳过,否则会拖低精度。
处理后的特征布局是:
数据维度: (N, C, T, V, M) C = 3 # 坐标通道:x, y, 置信度(NTU 还有 z) T = 300 # 时序帧数(NTU 默认 300 帧) V = 25 # NTU 关节数 M = 2 # 最多人数注意这里的M=2,因为 NTU 样本里最多同时出现两个人——不只是单人动作,还有交互动作(握手、拥抱、推搡)。如果只看单人动作的准确率会虚高,答辩时被问到多人的情况就容易翻车。
3.2 帧对齐和 padding:为什么不能把所有样本硬截断到固定长度
动作识别里最头疼的问题就是样本长度不齐。挥手的视频可能只有 20 帧,打太极的样本可能有 300 帧。粗暴的做法是全部 resize 到固定长度,但这样会破坏动作的节奏信息——你无法判断一个 50 帧的动作是被加速了还是本来就这么快。
feeder/feeder.py的策略是按比例随机采样:先设定总帧数T(比如 300),然后按random.choice从原序列里采样帧索引。具体逻辑是:
# feeder/feeder.py 中帧采样逻辑的核心简化 # 设原始帧数为 N,目标帧数为 T # 如果 N >= T: 从 N 帧中等间隔地抽取 T 帧 # 如果 N < T: 先均匀重复补齐到 T 帧,再做随机偏移# feeder.py 中 __getitem__ 的伪码逻辑(与实际源码结构一致) def __getitem__(self, index): data_numpy, label, valid = self._load_data(index) if self.input_size > 1: # 多帧采样 # 随机选择起始帧,保证时序增强 start = random.randint(0, max(0, data_numpy.shape[0] - self.input_size)) data_numpy = data_numpy[start:start + self.input_size] # 对坐标做标准化:减均值除标准差(按关节分别算) # 返回 (C, T, V, M) 形状的张量这段代码体现的设计思想是:训练时用随机裁剪做时序增强,让模型对不同起始位置的动作都鲁棒;测试时则用固定居中裁剪,保证同一段视频每次推理结果一致。我在复现时把input_size从 300 改成 150,训练速度快了一倍,精度只掉了 1 到 2 个点——如果你的显卡是 6GB 显存,这个参数可以优先调。
3.3 关键点顺序的映射:一个不显眼但致命的细节
NTU 的 25 个关节点顺序和 Kinetics 的 18 个关节点顺序完全不同。比如 NTU 的第 0 号是脊柱底部,而 COCO 的第 0 号是鼻子。如果你直接拿 Kinetics 预训练权重去 fine-tune NTU 数据,第一层就学歪了,因为Graph类的self.A是根据关节连接关系构建的,关节索引一旦错位,邻接矩阵连接的就是完全不同的两个部位。
源码的Graph类里内置了NTU和Kinetics两组关节连接规则,通过graph_args里的layout参数切换。我在第一次跑通实验时就是在这里翻的车:layout='ntu-rgb-d'和layout='openpose'写反了,训练了 20 个 epoch 的精度只有 30%,腰都悔断了。后续排查时才发现是关节拓扑搭错,相当于用错误的先验图结构去初始化模型。
4. 训练配置和双流变体:从默认参数到针对性调优
4.1 torchlight 训练框架的配置结构
这套代码用的是自带的torchlight轻量训练框架,配置文件在config/目录下。以st_gcn.twostream的 yaml 为例:
# config/st_gcn.twostream/st_gcn.yaml 核心配置(结构示意) model: name: st_gcn_twostream in_channels: 3 num_class: 60 # NTU-RGB-D 60 类 edge_importance_weighting: True graph_args: layout: 'ntu-rgb-d' # 关节拓扑用 NTU 还是 OpenPose strategy: 'spatial' # 分区策略,spatial 是论文里的三分类法 train: optimizer: 'SGD' base_lr: 0.1 # 初始学习率 step: [30, 40] # 在第 30 和 40 个 epoch 衰减学习率 weight_decay: 0.0001 device: [0] # 用第 0 张 GPU test: checkpoint: './work_dir/recognition/ntu_xsub/twostream/epoch39_model.pt'base_lr: 0.1配合 SGD 是 ST-GCN 原文的默认配置,实测在 RTX 3060 上训练 NTU 的 cross-subject 划分,60 类动作大概 4 个小时能收敛到 84% 左右的 top-1 准确率。如果你想换 AdamW,学习率记得调到 0.001 量级,否则前几个 epoch 的 loss 会冲上天。step: [30, 40]表示在训练到第 30 和 40 个 epoch 时学习率乘以 0.1,这是原文的阶梯衰减策略,不建议改得太激进,骨骼数据本身噪声大,学习率掉太快会把模型钉在局部最优出不来。
启动训练的命令在源码的 README 里有,典型的是:
# 训练双流 ST-GCN(骨骼 + 骨骼运动速度特征) python main.py --config config/st_gcn.twostream/st_gcn.yaml \ --phase train \ --work-dir ./work_dir/recognition/ntu_xsub/twostream--phase支持train、test、demo三个阶段,--work-dir存放 checkpoint 和训练日志。日志文件是 txt 格式,每行包含 epoch、loss、top1、top5,我一般会把这些数据拉出来画 loss 曲线,用来判断模型是不是过拟合了。
4.2 双流模型:把关节坐标和骨骼速度分开建模
项目里有st_gcn.py(单流)和st_gcn_twostream.py(双流)两个模型。单流输入的是关节坐标(x, y)或者(x, y, z);双流把输入拆成两个分支——第一流输入关节坐标,第二流输入骨骼向量:相邻两个关节坐标的差(x2-x1, y2-y1),它代表骨骼的长度和方向。两流各自跑一个 ST-GCN,最后把特征拼接后过全连接层。
# st_gcn_twostream.py 的 forward 核心逻辑 def forward(self, x): # x: 原始骨骼坐标序列 (N, C, T, V, M) # 第一流:直接用坐标做空间图卷积 out1 = self.st_gcn1(x) # 第二流:先对关节做差分,计算骨骼特征 x2 = x[:, :, 1:, :, :] - x[:, :, :-1, :, :] # 帧间骨骼差分 out2 = self.st_gcn2(x2) # 两流特征拼接(或加权融合) out = torch.cat((out1, out2), dim=1) return self.fc(out)双流结构的动机很直观:关节坐标丢失了肢体长度信息(手臂长度、腿长这些身体结构比例),而骨骼向量天然包含这些静力学特征。对人体的动作识别来说,「手伸得多远」有时候比「手在哪里」更有区分度。项目自带的AddEdgeSTGCN12345.pt权重实测是在单流基础上改的:在Graph类的A矩阵上叠加了一个可学习的边权重矩阵,让模型在训练中自动调节骨骼连接强度——这个改进思路在论文里通常叫 adjacency matrix learning,实现对骨骼结构未知扰动的自适应,甜点是代码改动非常小,在st_gcn.py里加一个self.edge_weights = nn.Parameter(torch.ones(A.size()))就够了。
4.3 推理 demo 和权重文件的使用边界
压缩包里给了三个.pt权重文件:OriginSTGCN.pt(原版单流,NTU 上训练的)、AddEdgeSTGCN12345.pt(改进边权重的单流)、kinetics-st_gcn.pt(Kinetics-skeleton 上训练的,适合跑通 demo 但不适合直接用于 NTU 评估)。Kinetics 那个权重的类别数是 400,如果你拿它加载 NTU 的 60 类模型文件,nn.Linear层的大小对不上,会直接报错。这不是代码 bug,是类别数不匹配,换对应权重就行。
离线推理命令在processor/demo_offline.py里,读取的是 OpenPose 导出的 JSON 骨骼序列。实时推理走demo_realtime.py,用摄像头实时抽帧、跑 OpenPose 再进 ST-GCN,帧率取决于 GPU 和 OpenPose 的检测速度,一般 10 到 15 FPS 是正常的——比这低不是模型的问题,是 OpenPose 的耗时占了大头。
5. 避坑排查:我在复现时踩过的六个真坑
5.1 权重加载报错size mismatch for fc.weight
现象:加载kinetics-st_gcn.pt到 NTU 模型时报维度错误。
原因:Kinetics 权重是 400 类动作,NTU 是 60 类,最后的全连接层权重形状不一致。源码里num_class参数是在模型初始化时写死的,加载权重时不会自动适配。
解决:先确认你的任务类别数。如果只是跑 demo 看效果,用kinetics-st_gcn.pt且把 yaml 里的num_class: 400改好;如果要复现 NTU 的评估结果,用OriginSTGCN.pt,不要混用。
5.2 用 Kinetics 权重初始化 NTU 模型时出现 NaN
现象:加载预训练权重后,前几个 batch loss 直接变成 NaN。
原因:两类数据的通道数可能不一致。Kinetics 是 2D 坐标(x, y, confidence)三通道,NTU 有 3D 坐标加置信度四通道,如果in_channels没对上,BatchNorm 的均值方差缓冲区和输入不匹配。
解决:检查 yaml 里in_channels是否和数据集一致。NTU 用 3(坐标加置信度)还是 4(加 z)看具体预处理脚本,源码的ntu_gendata.py默认输出 3 通道。
5.3 demo 结果全是一个类别,但训练精度正常
现象:离线 demo 对所有测试视频输出同一个类别,比如全是「挥手」。
原因:测试时数据预处理和训练时不一致。训练用的feeder做了随机裁剪和标准化,demo 里可能漏了标准化步骤,或者标准化用的均值和标准差是训练集的,但 demo 代码里加载的是默认值。
解决:翻processor/demo_offline.py,确认它调用了feeder的同一套标准化逻辑。不能直接对原始像素坐标做推理,必须先做减均值除标准差,否则模型看到的输入分布和训练时完全不在一个量级。
5.4 训练 loss 降不下去,一直在 4 到 5 附近
现象:SGD 跑了 10 个 epoch,loss 还在 4.5 上下浮动。
原因:大概率是学习率策略不对,或者 BatchNorm 的 momentum 太小。ST-GCN 原文用的是momentum=0.9的 BatchNorm,但很多复现代码会顺手改成 PyTorch 默认的0.1,直接导致训练不稳定。
解决:在st_gcn_block里显式传入bn_momentum=0.9,或者把优化器从 SGD 换成 Adam 并把学习率降到0.001再试。还有一个可能原因是edge_importance_weighting权重初始化过大,把它换成nn.init.normal_(self.edge_weights, mean=0, std=0.1)能缓解。
5.5 OpenPose 检测不到人,demo 直接中断
现象:视频里一个人都没有时,demo_offline.py报错退出。
原因:OpenPose 对模糊、遮挡、极端姿态的场景漏检率很高,源码里的 demo 没有加空检测保护。
解决:自己加一行判断——检测到的人数为 0 时跳过当前帧,用全零的骨架占位继续跑,保证时序长度对齐。这是工程上很常见的「脏数据处理」逻辑,很多开源 demo 并不会帮你做。
5.6 双流模型比单流精度还低
现象:st_gcn_twostream.py在 NTU 上比单流低了 0.5 到 1 个点。
原因:双流模型的第二流输入是骨骼差分,但这路输入的数值范围比原始坐标小很多(差分值接近于零),如果共享了同一套标准化参数,小的数值会被当成噪声直接压掉。
解决:给第二流单独做标准化,或者把两流的 loss 分开算再加权求和(比如主流 loss 权重 0.7,骨骼流 0.3)。我实际测试下来,分开标准化后双流的收益才出来,大约能比单流高 1.5 个点。
6. 验证模型的完整顺序:从骨架可视化到端到端评测
6.1 权重自检:用自己的骨架数据做冒烟测试
我拿到这套项目的第一个动作,不是直接开训练,而是先跑一遍 demo 验证环境。在项目的resource/demo_asset/media目录下有自带的测试骨架序列,先确认模型能不能跑通、类别输出是否合理:
python main.py --config config/st_gcn.twostream/st_gcn.yaml \ --phase demo \ --weights ./models/AddEdgeSTGCN12345.pt如果这一步能正常输出动作类别,说明依赖环境和模型加载没问题。接下来把demo_realtime.py接上摄像头,随便做几个动作试试。注意实时 demo 的输出是类别 ID,不是类别名,对照resource/kinetics_skeleton/kinetics-motion.txt里的 400 类标签映射表,才能知道模型到底在说什么。
6.2 用自己的小样本验证模型泛化能力
用 OpenPose 提取自己录制的视频骨架,存成和 Demo 一样的 JSON 格式,然后喂给demo_offline.py。这里要重点观察的是:模型在训练集外的表现如何。大部分情况下,NTU 训练的模型对校园环境录制的视频效果会打折扣,因为训练集里的拍摄视角是固定的,而你录的视频可能更侧、更近。这是泛化能力问题,不是代码问题——答辩时如果能主动说出「训练集视角固定导致的 domain gap」,反而能让老师给你加分。
6.3 一个实用的推理提速技巧
把demo_realtime.py里的输入裁剪帧数从 300 降到 150,用torch.no_grad()包住推理过程,再将模型切到 eval 模式,整体推理速度能提升 30% 到 40%。这套代码默认没有开半精度推理,在支持 AMP 的 GPU 上可以加torch.cuda.amp.autocast(),显存占用会进一步减少,但对精度有约 1% 的影响,需要权衡。
从那以后我每次拿到新模型,都会强制走一遍「单样本冒烟 → 小批量验证 → 全量评估」这条流程,就算只是做个 demo 演示也不例外。这个方法帮我挡掉了不少答辩现场翻车的风险。希望帮到你。
本文还有配套的精品资源,点击获取