news 2026/10/10 19:43:03

基于ST-GCN的骨骼动作识别:从骨架坐标到动作标签的完整链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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为25),M是人数(NTU里最多两人)。模型最终输出一个(N, 类别数)的logits向量。整个主干就是空间图卷积和时间卷积交替堆叠,中间穿插残差连接和批归一化。

st_gcn.py 里最核心的图卷积操作,常见实现是这样一个自定义图层:

class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, graph, strategy='spatial'): 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_size=1) for _ in range(self.num_subset) ]) else: self.num_subset = 1 self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1) def _normalize(self, A): # 加自环后做 D^{-1/2} A D^{-1/2} 对称归一化 A = A + np.eye(self.num_node, dtype=A.dtype) D = np.sum(A, axis=0) 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(inplace=True), nn.Conv2d(out_channels, out_channels, kernel_size=(9, 1), stride=(1, 1), padding=(4, 0)), nn.BatchNorm2d(out_channels), nn.Dropout(0.5, inplace=True), )

注意这里的 padding 取值(4, 0):时间维度kernel_size是9,padding=4保证帧数不缩水;关节维度kernel_size=1,不需要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_channels=3, graph_args=None): super().__init__() self.stream_joint = STGCN(num_class=num_class, in_channels=in_channels, ...) self.stream_bone = STGCN(num_class=num_class, in_channels=in_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-D
AddEdgeSTGCN12345.pt手工加边的单流ST-GCN毕设改进实验的主力模型
kinetics-st_gcn.ptKinetics-Skeleton预训练模型做迁移学习或者直接跑demo

AddEdgeWeight_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_frame=300,超过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_class=60, in_channels=3, ...) pretrained = torch.load('models/kinetics-st_gcn.pt') # 删除最后一层 fc 的键 pretrained.pop('fc.weight', None) pretrained.pop('fc.bias', None) model.load_state_dict(pretrained, strict=False)

strict=False在这里是必须的,因为去掉全连接层后,模型参数和预训练权重并不完全一致。这段逻辑在思维上很容易被忽略,实际加载时少了strict=False就会报一堆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.001
num_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.py

5.2 现象二:加载模型报 “Missing key(s) in state_dict”

明明给了训练好的权重,torch.load之后load_state_dict却报缺了fc.weight之类的键。原因基本是模型定义和保存时的类别数不一致:kinetics-st_gcn.pt是400类,当前STGCN(num_class=60)的最后一层尺寸自然是400和60对不上。

解决方法是按4.1节的方式,把全连接层从预训练权重里剥掉,用strict=False加载,再单独重新初始化分类层。这属于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降到100,batch_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这种依赖数据管线很深的项目里,“知道每个函数输入输出是什么”比“会跑通一次训练”更有用,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 19:41:02

Laravel项目部署:从Windows 10到Gitee再到服务器的完整链路

做 Laravel 项目最尴尬的一个时刻,就是本地php artisan serve跑得好好的,给同事演示也没问题,结果一到部署就卡住。代码在 Windows 10 上写好,怎么弄到服务器上?用 U 盘拷贝?用压缩包上传再解压&#xff1f…

作者头像 李华
网站建设 2026/10/10 19:40:54

JWT认证原理与Spring Boot实践:从无状态Token到安全续签方案

做了这么多年后端,API接口被人扒得一干二净的经历真不少。很多项目一开始图省事,把用户身份直接塞进Cookie里,前后端分离一搞,跨域、CSRF、服务端Session存哪这些问题全冒出来了。后来普遍转向JWT(JSON Web Token&…

作者头像 李华
网站建设 2026/10/10 19:39:41

Playwright自动化实战指南:从原理到爬虫与AI Agent集成

我拿 Playwright 写了三年自动化代码,从最早拿来爬数据,到后来整个测试团队把脚本全部迁到这套框架上,再到现在各种内部平台把 Playwright 当作执行器来用。可以说,Playwright 这个词已经不只是某个开源库的名字,它已经…

作者头像 李华
网站建设 2026/10/10 19:39:17

Text-to-CAD实战:从自然语言到参数化3D模型的完整流程

1. 为什么“text-to-cad”突然成了硬需求先别急着把它当成又一个昙花一现的AI噱头。我在制造业和设计软件领域泡了十来年,最近半年被同行问得最多的问题就是:用一句话描述一个零件,真的能直接生成可编辑的三维模型吗?先说结论&…

作者头像 李华
网站建设 2026/10/10 19:34:17

Java线程中断机制详解:interrupt()协作式设计与实践

1. 先说清楚 interrupt() 到底做了什么很多写 Java 并发代码的人,第一次见到Thread.interrupt()都会下意识以为它跟Thread.stop()一样,能够强行把一个正在运行的线程干掉。我早年也犯过这个错,线上一个任务线程卡在循环里,我调了i…

作者头像 李华