简介:基于时空图卷积(ST-GCN)的骨骼动作识别项目,包含可直接运行的 Python 源码与项目说明,面向计算机相关专业学生及算法从业者,适用于毕业设计、课程设计、课程大作业或初期项目立项演示。项目聚焦人体骨骼关键点序列的动作分类,覆盖数据预处理、图卷积网络搭建、模型训练、离线评估与实时推理等完整环节,有助于系统理解 ST-GCN 从数据到应用的落地流程。压缩包共 90 个文件,约 52.55MB,以 Python 源码、YAML 配置、预训练模型、GIF 演示动画和 MP4 操作录屏为主,同时提供 TXT/MD 说明文档与编译缓存文件,目录层次清楚,便于按模块查阅与二次开发。资源内置 NTU 与 Kinetics 两类数据集预处理脚本、双流时空图卷积网络结构、训练好的权重以及可直接运行的演示入口,能够复现动作识别效果,并可替换配置迁移至自定义数据集。目前已有 230 人学习浏览,适合需要快速入手图卷积动作识别、搭建课程设计或毕业设计项目的读者参考。
1. 骨骼动作识别为什么绕不开 ST-GCN:一个工程视角的开场
基于时空图卷积(ST-GCN)的骨骼动作识别,是我在动作识别项目里用过的最稳的一条落地路线。很多人一上来就想着拿视频帧去跑 3D CNN,但真正部署过就知道:光照、背景、遮挡一变化,整帧方案就翻车;而骨骼关键点只保留人体姿态拓扑,天然抗干扰,又轻量。这份 python 源码加项目说明的压缩包,做的就是把 OpenPose 之类的姿态估计结果,变成一段可训练、可推理的动作分类流程。它适合两类人:一是刚接触动作识别、想跑通第一个模型的入门工程师,二是手头有骨骼数据、但不知道如何把时序和关节关系建模的从业者。核心就一句话——把人体骨架当成图,用图卷积在空间上提特征,再用时间卷积在帧间提动态,最后分类。
2. 从骨架坐标到图结构:ST-GCN 的输入到底长什么样
ST-GCN 的输入不是图片,也不是普通的时间序列,而是一串带有拓扑结构的关键点坐标。理解这一点,整个模型就通了一半。
2.1 骨骼数据的两种常见格式与项目文件组织方式
骨骼动作识别的数据来源主要有两条路。第一条是用现成的公开数据集,比如 NTU RGB+D(包含 60 类日常动作,每个样本是 25 个关节点的 3D 坐标)和 Kinetics-Skeleton(从视频里检测出的 18 个关节点 2D 坐标)。第二条是自采数据:先用 OpenPose 或 MediaPipe 从视频里抽人体关键点,再对齐、下采样、存成序列。无论哪条路,落到磁盘上的格式基本一致:一个动作样本就是一个三维数组,形状是 C × T × V,其中 C 是坐标通道数(2D 是 2,3D 是 3,有些还会加置信度变成 4),T 是帧数,V 是关节点数。
拿到一份源码包之后,第一件事不是打开模型,而是先看项目说明里的数据目录怎么组织。常见做法是 data/ 目录下按 train 和 val 分好,每个动作类别一个子目录或者一份 JSON 索引。我一般会先写一个几行的脚本,把样本的形状和类别分布打印出来,确认数据没有在生成阶段就丢帧或者缺关节。这一步花五分钟,后面省两天。
# 查看单个骨骼序列的形状 import numpy as np data = np.load('sample.npy') print(data.shape) # 期望看到 (3, 300, 25) 之类 print(np.isnan(data).sum()) # 不能有 NaN逻辑说明:这个脚本在做两件事。第一行是确认数据排布符合 C × T × V 约定,第二行是排查缺失值——NaN 在图卷积里会被当成有效值参与计算,直接污染邻域聚合结果。参数说明:这里的 3 代表 x、y、置信度三通道,300 是统一采样后的帧数,25 是选用的关节点数。如果你的数据是 (300, 25, 3),后面所有代码都要先做 transpose。
2.2 关键点序列如何变成图:邻接矩阵、度矩阵与归一化
把骨架变成图,要回答两个问题:谁是节点,谁和谁相连。节点就是关节点,边则是人体结构上的连接关系,比如手腕接手肘、手肘接肩膀。这个结构用邻接矩阵 A 表示,A[i][j] = 1 表示第 i 个关节点和第 j 个关节点直接相连。ST-GCN 的图卷积可以理解为:对每个节点,先把邻居节点的特征聚合起来,再经过一次线性变换。
只做一次聚合还不够。不同节点的邻居数量不一样——手肘只有两个邻居,而髋关节可能有四五个——如果不做归一化,邻居多的节点特征数值天然偏大,训练会不稳定。所以需要度矩阵 D,D[i][i] 等于第 i 个节点的邻居数。归一化后的聚合公式常见写法是 D 的负一次方与 A 相乘。这里有个细节:A 要加自环,也就是 A[i][i] = 1,让节点在聚合时保留自己的特征,否则每过一层就丢一部分原始信息,网络深了特征会退化。
提示:构建邻接矩阵时,千万不要直接把原始 A 拿去用。先加自环,再做对称归一化或者随机游走归一化,效果差异在准确率上能拉开 1 到 2 个百分点,这是源码里最常见的静默 bug。
2.3 最小可运行的数据预处理脚本
现在我们写一段真正能跑的预处理代码。假设原始数据已经从姿态估计模型里导出来了,形状是 (帧数, 关节点数, 通道数),通道为 x、y、置信度,我们现在把它归一化到固定帧数,并构造成 C × T × V。
import numpy as np def preprocess_skeleton(raw, target_frames=300): # raw: (T, V, C),C 为 x, y, confidence T, V, C = raw.shape # 帧数统一:多于目标帧就均匀采样,少于目标帧就线性插值 if T >= target_frames: idx = np.linspace(0, T-1, target_frames, dtype=int) data = raw[idx] else: data = np.zeros((target_frames, V, C)) old_idx = np.linspace(0, T-1, target_frames) for i in range(target_frames): # 最近邻插值,保证不引入异常关节位置 data[i] = raw[int(round(old_idx[i]))] # 坐标归一化:以脊柱中心为原点,消除人物在画面中的绝对位置影响 center = data[:, 1:2, :2].mean(axis=1, keepdims=True) # 取肩膀中心近似 data[:, :, :2] -= center # 转为 C × T × V 排布 data = data.transpose(2, 0, 1) # (C, T, V) return data.astype(np.float32) x = np.load('sample_raw.npy') # (298, 25, 3) out = preprocess_skeleton(x) # 期望输出 (3, 300, 25)逻辑说明:函数分三段。第一段做帧数对齐——这是训练时 batch 化的前提,PyTorch 的 DataLoader 要求同 batch 内张量形状一致。第二段做位置归一化——减去中心点后,人物站在画面左侧还是右侧不再影响分类结果,模型学到的是相对运动而不是绝对坐标。第三段做维度转置,对齐模型输入约定。参数说明:target_frames 取值 300 是常见经验值,骨架序列很少需要超过 300 帧的时序信息;中心点我用了肩膀附近的节点,实际项目中取颈部或髋部中心都可以,关键是所有样本要用同一个关节点做中心。
3. 时空图卷积网络结构拆解:图卷积、时间卷积与残差怎么搭
这一章把网络结构拆成三块来讲。理解了这三块,源码里 model 目录下的文件就不难读懂了。
3.1 图卷积层的实现要点与邻接矩阵的分区策略
图卷积层做的事可以用一句话概括:对每个节点,把自身和邻居的特征加权求和,再经过一个可学习的权重矩阵 W。对应公式是 Y = 归一化邻接矩阵 × X × W。但在动作识别场景里,直接在整个邻接矩阵上做一次卷积太粗糙,ST-GCN 的原始论文把邻接矩阵按物理含义拆成了三个部分:向心、离心、静止。这个设计是有道理的——伸手和收手的运动方向不同,特征应该分开学;如果不分区,卷积核必须自己从数据里学出这种区分,训练难度更大。
import torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_ch, out_ch, num_subset=3): super().__init__() # 每个分区一张独立的邻接矩阵(加自环并归一化) self.num_subset = num_subset self.conv = nn.ModuleList([ nn.Conv2d(in_ch, out_ch, kernel_size=1) for _ in range(num_subset) ]) self.bn = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU() def forward(self, x, A_list): # x: (N, C_in, T, V),A_list: 3 个 (V, V) 矩阵 N, C, T, V = x.shape out = 0 for i in range(self.num_subset): # 先做空间聚合: (N, C, T, V) -> (N, C, T, V) x_gathered = torch.einsum('nctv,vw->nctw', x, A_list[i]) out += self.conv[i](x_gathered) out = self.bn(out) return self.relu(out)逻辑说明:这段代码的核心是 einsum 那一行。它把第 v 个关节点的特征替换为所有邻居关节点特征的加权和,权重来自 A_list。三个分区各自卷积再相加,等价于让每个分区学到不同运动方向的特征表达。参数说明:in_ch 是输入通道数,第一次进图卷积时通常是 3(x、y、置信度);out_ch 是中间特征维度,源码里一般从 64 开始逐层翻倍到 256;num_subset 取 3 是沿用原始 ST-GCN 的向心/离心/静止分区方案,实际项目里如果关节点定义不同,也可以改成 1 或 2,但效果需要重新调。
3.2 时间卷积层与感受野的选择
空间图卷积处理的是单帧内关节之间的联系,但动作识别的核心是运动,比如“挥手”和“拍手”的空间骨架几乎一样,区别全在时间前后关系上。时间卷积的标准做法是在特征图的 T 维度上做一维卷积。落地时通常是 kernel_size=9 的一维卷积,相当于让每个关节点的特征融合前后各 4 帧的信息。
kernel_size 的选取直接决定模型能感知多长的时间窗口。9 帧对于 NTU 这类 300 帧的输入来说,经过 10 层网络后理论感受野能覆盖整个序列。如果你做的是短动作,比如手势识别,序列只有 30 帧,kernel_size 建议缩到 5,不然时间卷积的感受野一下覆盖整段序列,模型分不清动作的先后顺序。另外,时间卷积后面一定要跟 BatchNorm,图卷积输出的特征分布方差大,不做归一化后面几层会越学越飘。
3.3 模型整体结构与关键参数表
一个标准 ST-GCN 骨干是 9 个时空卷积块(每个块包含一次空间图卷积和一次时间卷积),通道数从 64 逐步升到 256,空间下采样用 stride=2 的时间卷积实现,最后经过全局平均池化输出一个固定长度的特征向量,再接全连接层分类。下面是一份我在项目里实际使用的参数表,你可以照抄:
| 层块 | 输出通道 | 时间卷积参数 | 下采样 | 残差 |
|---|---|---|---|---|
| 输入预处理 | 3 | - | - | - |
| 块 1-3 | 64 | kernel=9, stride=1 | 无 | 有 |
| 块 4-6 | 128 | kernel=9, stride=2(仅块4) | 有 | 有 |
| 块 7-9 | 256 | kernel=9, stride=2(仅块7) | 有 | 有 |
| 全局池化 | 256 | - | - | - |
| 分类层 | 类别数 | - | - | - |
参数说明:通道数翻倍的节点在块 4 和块 7,对应的残差连接必须做 1×1 卷积来对齐通道,否则残差相加时维度对不上。下采样只发生在时间维度上,空间维度即关节点数量从头到尾保持不变,因为邻接矩阵是固定的。这里有个经验:关节点数量 V 不会因为网络加深而变化,这点和图像 CNN 的 H、W 逐步变小完全不同,刚接触的人容易在这里画错结构图。
4. 训练 ST-GCN 模型:从数据划分到收敛诊断的完整流程
模型结构再花哨,最后都要落在训练流程上。这一章的每一步都对应源码里 train.py 或 main.py 中的某一段,照着改就行。
4.1 数据加载器与训练循环的写法
骨骼数据量不大,一个数据集几百 MB 到几个 GB,不需要像图像识别那样做复杂的在线增强,但数据加载器里有两个细节必须处理:一个是上面说的帧数对齐,另一个是样本级别的随机打乱。下面是一个最小可用的 PyTorch 数据加载与训练循环。
import torch from torch.utils.data import Dataset, DataLoader class SkeletonDataset(Dataset): def __init__(self, data_list, labels, target_frames=300): self.data = data_list # list of (C, T, V) 数组 self.labels = labels self.target_frames = target_frames def __len__(self): return len(self.data) def __getitem__(self, idx): x = self.data[idx] # 如果帧数不等于 target_frames,用预处理里的函数对齐 if x.shape[1] != self.target_frames: x = preprocess_skeleton(x.transpose(2, 0, 1)) return torch.FloatTensor(x), torch.LongTensor([self.labels[idx]]) train_loader = DataLoader(SkeletonDataset(train_data, train_labels), batch_size=32, shuffle=True, num_workers=4, pin_memory=True)逻辑说明:getitem返回一个 (C, T, V) 张量和一个标签。shuffle=True 保证每个 epoch 内样本顺序不同。num_workers 是数据加载的子进程数,骨骼数据预处理只有归一化和转置,CPU 密集度不高,设 4 就够了,设太高反而增加进程切换开销。参数说明:batch_size=32 对应一块 24G 显存显卡;如果只有 11G 显存,建议降到 16,同时把模型中间通道从 64/128/256 缩到 32/64/128,准确率损失在 2 个点以内。
4.2 训练超参数与学习率策略
ST-GCN 训练最容易犯的错是学习率设得太大。图卷积的输入是归一化后的坐标,数值范围通常在 -1 到 1 之间,特征幅值比图像 CNN 小很多,用默认的 0.01 学习率会导致 loss 早期剧烈震荡。我一般用初始学习率 0.001,配合余弦退火或阶梯下降,在 60 个 epoch 内能稳定收敛。
# 训练核心片段 optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60, eta_min=1e-5) criterion = torch.nn.CrossEntropyLoss() for epoch in range(60): model.train() for x, y in train_loader: x, y = x.cuda(), y.squeeze().cuda() out = model(x) loss = criterion(out, y) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()逻辑说明:optimizer 使用 Adam,weight_decay 设 1e-4 起正则作用,防止骨骼数据这类小样本集过拟合。scheduler 用余弦退火让学习率从 0.001 平滑降到 1e-5,比阶梯下降更稳,尤其适合动作识别这种对后期微调敏感的任务。参数说明:T_max=60 要和总 epoch 数一致,否则退火曲线会在中途截断;如果换用阶梯下降,建议在 epoch 30 和 50 各乘 0.1,效果接近但需要多调两个时间点。
4.3 评估指标、模型保存与推理脚本
动作识别数据集经常存在类别不平衡,光看准确率不够,还要看每个类别的召回率。保存模型时不要只存最后一轮的权重,要记录验证集上表现最好的那一次。做法是每个 epoch 结束算一次 top-1 准确率和混淆矩阵,然后只保留验证准确率最高的权重。
推理阶段有一个容易忽略的点——输入数据必须走和训练时完全相同的预处理管线。很多人训练时做了中心化归一化,推理时直接用原始坐标送进模型,结果准确率暴跌,然后怀疑模型坏了。正确做法是把 preprocess_skeleton 封装成一个函数,训练和推理共用同一份代码。我习惯把模型导出为 TorchScript 格式,预测时用 C++ 或 Python 加载都没有额外依赖,这在项目落地到嵌入式设备时尤其重要。
5. 常见问题与避坑:五个让 ST-GCN 翻车的工程细节
这五条都是我自己或同事在项目里真实踩过的坑,每条按现象、原因、解决来写,照着排查能省几天时间。
5.1 训练 loss 持续下降,但验证准确率纹丝不动
现象:训练集 loss 从 2.0 降到 0.3,训练准确率接近 100%,验证准确率却一直停在 20% 上下,和随机猜测差不多。
原因:数据泄漏。最常见的是预处理阶段把中心化归一化用全部数据的均值做了,而不是用训练集单独计算;或者数据划分时同一个人的动作既出现在训练集又出现在验证集。骨骼数据的特征维度相对低,模型很容易直接记住人物身份而不是动作模式。
解决:严格按样本划分数据集,确保同一个人的所有样本只出现在训练集或只出现在验证集。中心化归一化的均值只从训练集计算,再应用到验证集。代码上检查划分逻辑里有没有按 identity 分组。
5.2 显存溢出,或者训练速度慢到一天跑不完一个 epoch
现象:batch_size=32 时直接 OOM,调到 8 之后每轮训练要一个多小时。
原因:时间维度的计算量被忽视了。ST-GCN 的特征图是 (N, C, T, V),其中 T=300,V=25,中间层的 T 不会自动缩小,只有碰到 stride=2 的时间卷积才减半。如果模型里忘记设置时间下采样,所有层的 T 都是 300,计算量和显存压力成倍增加。
解决:确认从块 4 开始时间卷积 stride=2,让 T 从 300 降到 150 再降到 75。如果显存还是紧,把输入帧数从 300 降到 150,准确率损失一般在 1 个点以内,但训练速度直接快一倍。帧数小于 150 时建议同时把 kernel_size 从 9 改到 5,保持感受野合理。
5.3 同一个动作,换个摄像头角度识别结果就变
现象:训练集里都是正面视角的动作,识别率 95%;换成侧面 45 度摄像头录制,同样动作识别率掉到 60%。
原因:骨骼坐标对视角太敏感。ST-GCN 本身没有视角不变性,模型学到的是特定视角下的关节角度变化模式,而不是语义上的“举起手”。
解决:这是最结构性的问题,三个手段配合才能缓解。第一,数据增强——训练时对关节坐标做随机旋转扰动(±15 度内),模拟视角变化;第二,输入特征用关节之间的相对向量而不是绝对坐标,比如手肘到手碗的向量化表示,天然比绝对坐标更抗平移;第三,如果场景允许,在多个视角采集训练数据。数据增强的实现是在预处理里对 (x, y) 坐标乘以一个随机旋转矩阵,注意 z 轴上的旋转不要做,会破坏重力方向这个重要线索。
5.4 同一个动作,做得快和做得慢识别结果不同
现象:慢速挥手能识别对,快速挥手经常识别成其他类;或者反过来。
原因:时间卷积的感受野是固定的。帧数对齐之后,一个 1 秒快速动作可能只占 40 帧,一个 3 秒慢速动作占 120 帧,但卷积核覆盖的帧范围不变,快速动作的时序信息被过度压缩,慢速动作的关键瞬间又可能没被覆盖到。
解决:两个方案。一是基于运动速度做时间尺度归一化——动作序列对齐时不再均匀采样到固定帧数,而是先计算关节速度曲线,把速度峰值附近的关键帧保留、无关帧压缩,再统一到固定长度。二是用多尺度时间卷积,在同一个块里并行用 kernel=3、5、9 的三个分支同时卷积再融合,代价是参数量增加,但能显著提升对速度变化的鲁棒性。我实际测试下来,方案二更省事且稳定。
5.5 源码里的张量维度对不上,报 RuntimeError
现象:照着 README 跑训练,前向传播直接报错,显示 einsum 或 matmul 的维度不匹配。原因多半是骨骼数据的关节点顺序和源码里预定义的邻接矩阵顺序不一致。比如 NTU 数据集的 25 号关节点定义和 Kinetics 的 18 号定义完全不同,直接复用预训练权重的前几层就会维度错乱。
解决:拿到任何源码,第一件事是打开项目说明或数据字典文件,确认关节点顺序表。然后写一个可视化脚本,把第 1 个样本的骨架画出来,和标准骨架图比对,确认每个关节点连接正确。这个问题没有捷径,维度检查脚本要放在预处理的最后一步。我习惯在 DataLoader 里加一个 assert x.shape == (C, T, V),一旦数据源换过,第一时间弹出来而不是等模型炸了再查。
6. 把模型用到自己的场景:一个迁移验证的具体技巧
从公开数据集训练好的 ST-GCN 很难直接适应你的业务场景,最常见的情况是关节点定义不一样,或者类别集合完全不同。与其从头训,不如做两件事。第一步,把预训练模型的第一层图卷积权重按你的关节点顺序重排;如果关节点数量不同,直接丢掉前几层,冻结后几层当特征提取器用。第二步,只训练最后两层的分类头和全局池化层,输入换成你的骨骼数据做微调。
微调时有一件事我吃过亏:学习率要降到原来的十分之一甚至二十分之一,因为预训练特征已经足够好,学习率大了会冲掉已经学好的骨骼结构表示。我用 0.0001 的初始学习率,30 个 epoch 收工。效果验证上,不要只看整体准确率,把每个类别的混淆矩阵打出来,重点看哪些类互相混淆——比如“站立”和“走路”在骨骼图上本来就很接近,如果混淆严重,就要去检查训练数据里这两类的动作有没有区分度。还有一个验证技巧是专门录一段背景噪声大、人物在画面边缘的视频,送到模型里看输出置信度分布。如果置信度普遍低于 0.5,说明模型学到的特征里夹杂了太多背景信息,及时回到预处理里加强位置归一化而不是继续调网络结构。
这套方案从数据到训练再到迁移,我完整跑过三轮。第一轮踩了数据泄漏的坑,第二轮发现视角泛化差,第三轮稳定在 90% 以上的准确率才真正落地。如果只挑一个最重要的习惯,那就是所有环节的预处理函数必须训练、推理共用,没有例外。希望帮到你。
本文还有配套的精品资源,点击获取