news 2026/10/17 22:17:13

ST-GCN骨骼动作识别毕设实战:从时空图构建到模型调优与创新

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ST-GCN骨骼动作识别毕设实战:从时空图构建到模型调优与创新

简介:本资源是一套面向高校学生与深度学习入门者的Python毕业设计项目,围绕时空图卷积网络(ST-GCN)实现骨骼动作识别,可用于健康监测、人机交互、视频监控与智能游戏等场景,帮助读者理解从骨骼点建模到动作分类的完整流程。压缩包共91个文件,约52.54MB,包含29个py源码、13个yaml配置、12个pyc编译文件、11个gif演示、9个txt说明、5个png图示、3个pt权重、3个mp4视频及prototxt、sh脚本等,覆盖数据生成、模型定义、训练配置与离线/实时识别演示等模块。已有175人学习关注。项目源码经测试可运行,答辩评审平均分达94.5分,附带训练好的模型与项目文档,读者可据此复现ST-GCN双流网络结构、对照配置调参、参考演示脚本完成推理,并借助文档梳理设计思路与实验结果,适合作为课程设计、毕业设计或骨骼动作识别方向的实践参考。

1. 从一份骨骼动作识别毕设说起:ST-GCN 到底解决了什么问题

做过动作识别毕设的人大多踩过同一个坑:拿 RGB 视频直接喂 3D 卷积,训练慢、显存炸、换个机位就翻车。骨骼动作识别换了个思路——先用姿态估计把人体抽成 18 个关节点的坐标序列,再让模型去学这些点怎么动。问题在于,关节不是孤立的像素,手肘和手腕的关系、左右腿的对称关系,都是图结构而不是网格结构。普通 CNN 处理不了这种非欧几里得数据,RNN 又只能建模时间维、丢掉空间拓扑。ST-GCN(Spatial Temporal Graph Convolutional Network,时空图卷积网络)就是冲着这个矛盾来的:把每一帧的骨架当成一张图,节点是关节点,边是人体自然连接,再沿时间轴把帧与帧连起来,形成一个时空图,用图卷积同时抽空间和时间特征。

这套方案对毕设特别友好:NTU RGB+D 这类公开数据集现成,单卡 1080Ti 就能跑,代码量可控,创新点好写(改邻接矩阵、加注意力、换骨架)。下面这份笔记按「数据怎么变成图 → 网络怎么搭 → 训练怎么调 → 坑在哪 → 怎么做出差异化」的顺序讲,目标是让你拿到源代码后能真正跑通,而不是对着报错发呆。

2. 骨骼数据怎么变成时空图:从关节坐标到邻接矩阵

2.1 为什么必须显式构建邻接矩阵

ST-GCN 的核心数据结构是一张图,用邻接矩阵 A 表示。假设一帧有 V 个关节点,A 就是 V×V 的矩阵,A[i][j]=1 表示关节 i 和 j 有物理连接。NTU 用的是 25 个关节点,Kinetics 用的是 18 个,OpenPose 输出也是 18 个。这个矩阵不是学出来的,是先验知识写死的——人体骨架长什么样,边就怎么连。

但只用一个 A 不够。ST-GCN 原文把邻接矩阵拆成三个子集:自身(self)、向心(centripetal,靠近骨架重心的邻居)、离心(centrifugal,远离重心的邻居)。这样做的原因是,同一个关节向肩膀和向手肘的信息应该被区别对待。代码里通常写成三个 A 相加,再乘一个可学习的权重掩码,让网络自己调整每条边的重要性。

import numpy as np # NTU RGB+D 25 关节的物理连接,(父节点, 子节点) edges = [(0,1),(1,20),(20,2),(2,3),(20,4),(4,5),(5,6),(6,7),(7,21),(7,22), (20,8),(8,9),(9,10),(10,11),(11,23),(11,24), (0,12),(12,13),(13,14),(14,15), (0,16),(16,17),(17,18),(18,19)] num_node = 25 A = np.zeros((num_node, num_node)) for i, j in edges: A[i][j] = 1 A[j][i] = 1 # 无向图,对称填充 # 归一化,避免度数大的节点主导梯度 D = np.diag(np.sum(A, axis=1)) D_inv = np.linalg.inv(D) A_norm = D_inv.dot(A) np.save('adjacency.npy', A_norm)

这段代码做了三件事:按人体拓扑填边、对称化保证无向、按度做归一化。归一化这步很多人省掉,结果训练时度数高的髋关节梯度爆炸,loss 直接 NaN。参数上,num_node必须和你的姿态估计器输出一致,用 OpenPose 就是 18,用 MediaPipe 是 33,改网络时第一层图卷积的输入维度要跟着改。

2.2 从视频到 .npy:数据预处理的完整链路

原始数据是视频,ST-GCN 要的是形状为(N, C, T, V, M)的张量:N 是样本数,C 是通道(坐标 x,y 加置信度 score,共 3),T 是帧数,V 是关节点数,M 是人数(通常取 2,单人补零)。常见做法是用 OpenPose 或 HRNet 逐帧跑姿态估计,把结果存成 JSON,再转成 npy。

import json, numpy as np, os def json_to_npy(json_dir, out_path, max_frame=300, num_node=25, max_body=2): samples = [] for fname in sorted(os.listdir(json_dir)): with open(os.path.join(json_dir, fname)) as f: data = json.load(f) # data['people'] 是当前帧检测到的人 frame_data = np.zeros((3, num_node, max_body)) for m, person in enumerate(data['people'][:max_body]): kp = np.array(person['pose_keypoints_2d']).reshape(-1, 3) frame_data[:, :, m] = kp[:num_node].T # (3, V) samples.append(frame_data) # 补齐或截断到 max_frame arr = np.array(samples) # (T, 3, V, M) if arr.shape[0] < max_frame: pad = np.zeros((max_frame - arr.shape[0],) + arr.shape[1:]) arr = np.concatenate([arr, pad], axis=0) else: arr = arr[:max_frame] arr = arr.transpose(3, 1, 0, 2) # (M, 3, T, V) np.save(out_path, arr) json_to_npy('./pose_json', './sample.npy')

逻辑说明:先逐帧读 JSON,把每个人的关键点整理成(3, V),多人时堆到 M 维;再统一帧长,短补长截;最后转置成 ST-GCN 期望的(M, C, T, V)。参数上max_frame一般取 300(NTU 标准),max_body取 2 覆盖双人交互动作。踩坑点在于 OpenPose 的 keypoints 是扁平数组,reshape 成(-1,3)后第三列是置信度,别把它当坐标用。

2.3 数据增强:毕设里最容易被忽略的加分项

骨骼数据增强比图像简单,但效果明显。常用三种:随机旋转(绕 z 轴 ±30 度)、随机缩放(0.9~1.1)、随机平移(±0.1)。这些操作直接作用在坐标上,不改变图结构。注意置信度通道不要跟着缩放,否则网络会学到错误的权重。做增强时把(C,T,V,M)里的 C=0,1 两通道单独处理,C=2 保持不变,这是血泪经验——早期我把三通道一起乘,模型收敛慢了一半。

3. 网络结构拆解:ST-GCN 的图卷积层怎么搭

3.1 空间图卷积的三种实现方式

ST-GCN 的空间卷积本质是f_out = Σ A_norm · f_in · W,其中 W 是 1×1 卷积的权重。实现上有三种写法,复杂度递增:

第一种,直接用torch.einsum做矩阵乘,代码最短但可读性差;第二种,用nn.Conv2d加邻接矩阵乘法,工程上最常用;第三种,用torch.sparse稀疏矩阵,适合超大骨架但毕设没必要。我一般用第二种,因为调试时能直接 print 中间张量形状。

import torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_node, adjacency): super().__init__() self.num_node = num_node # 三个子集邻接矩阵,注册为 buffer 不参与训练 self.register_buffer('A', adjacency) # (3, V, V) # 可学习的边权重掩码,初始化为全1 self.edge_weight = nn.Parameter(torch.ones(3, num_node, num_node)) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1) def forward(self, x): # x: (N, C, T, V) N, C, T, V = x.shape x = self.conv(x) # 先做通道变换 # 用可学习掩码调制邻接矩阵 A = self.A * self.edge_weight # (3, V, V) # 对三个子集分别做图卷积再求和 out = torch.einsum('nctv,kvw->nctw', x, A) return out

逻辑说明:self.conv完成通道维的特征变换,einsum完成空间维的邻居聚合。edge_weight是关键——它让固定的物理连接变成可学习的软连接,网络能自己决定哪条边更重要。参数上in_channels和out_channels按层递增(64→64→64→128→128→256),num_node必须和邻接矩阵维度一致。注意register_buffer和nn.Parameter的区别:邻接矩阵是常量用 buffer,边权重是要训练的参数。

3.2 时间卷积与整体网络堆叠

空间卷积抽完单帧拓扑,时间维还得靠 TCN(Temporal Convolutional Network)。ST-GCN 用的是 kernel_size=9 的一维卷积,配合 padding 保持时间长度不变,再接 BN 和 Dropout。为什么用 9 而不是 3?因为动作的时序依赖跨度大,抬手到放下可能跨十几帧,9 的感受野能覆盖一个完整动作单元。

class STGCNBlock(nn.Module): def __init__(self, in_ch, out_ch, num_node, adjacency, stride=1, residual=True): super().__init__() self.gcn = SpatialGraphConv(in_ch, out_ch, num_node, adjacency) self.tcn = nn.Sequential( nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=(9,1), padding=(4,0), stride=(stride,1)), nn.BatchNorm2d(out_ch), ) self.residual = residual if not residual: self.down = lambda x: 0 elif in_ch == out_ch and stride == 1: self.down = lambda x: x else: self.down = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=(stride,1)), nn.BatchNorm2d(out_ch), ) def forward(self, x): res = self.down(x) x = self.gcn(x) x = self.tcn(x) return torch.relu(x + res)

逻辑说明:每个 block 是「空间卷积 → BN → ReLU → 时间卷积 → BN → 残差相加」。残差分支在通道数或步长变化时用 1×1 卷积对齐,否则直接恒等映射。参数上stride只在每个 stage 的第一层设为 2 做下采样,其余为 1;kernel_size=(9,1)表示时间维卷 9、空间维不卷(空间已由 GCN 处理)。整个网络通常堆 10 个 block,最后全局平均池化接全连接分类。

3.3 分类头与损失函数的选择

分类头很简单:AdaptiveAvgPool2d(1)把(N, 256, T, V)压成(N, 256, 1, 1),展平后接Linear(256, num_class)。损失用交叉熵,但骨骼数据常有多人场景,建议加 label smoothing(0.1)抑制过拟合。如果做的是双人交互动作(比如 NTU 的 mutual action),还要考虑两人特征的融合,常见做法是把 M 维拼接到通道维,或者用两个分支分别提特征再 concat。

提示:分类类别数num_class必须和你的数据集标签数严格一致,NTU-60 是 60,NTU-120 是 120,自己采集的数据要重新编号,别直接套用预训练权重后忘了改最后一层。

4. 训练调参与复现:让 loss 真正降下去

4.1 学习率、优化器与 warmup 策略

ST-GCN 训练最玄学的地方是学习率。原文用 SGD,初始 0.1,在第 10、50 epoch 各降 10 倍。但毕设数据量小(通常几千条),0.1 太大直接发散。我一般用 0.01 起步,配合 5 个 epoch 的 warmup,让网络先稳定再加速。

import torch.optim as optim from torch.optim.lr_scheduler import MultiStepLR, LambdaLR optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) # warmup:前5个epoch线性从0.001升到0.01 warmup = LambdaLR(optimizer, lr_lambda=lambda e: min((e+1)/5, 1.0)) # 之后在第30、60 epoch降10倍 scheduler = MultiStepLR(optimizer, milestones=[30, 60], gamma=0.1) for epoch in range(80): train_one_epoch() if epoch < 5: warmup.step() else: scheduler.step()

逻辑说明:warmup 阶段学习率从 0.001 线性升到 0.01,避免初期梯度震荡;之后用 MultiStepLR 阶梯下降。参数上weight_decay=1e-4是骨骼数据的经验值,太大欠拟合,太小过拟合。batch size 单卡 1080Ti 用 32 或 64,显存不够就降到 16 并同步调小学习率。

4.2 训练循环里的关键监控指标

光看 loss 不够,要同时盯三个数:训练准确率、验证准确率、学习率。如果训练准确率涨但验证不涨,是过拟合,加 Dropout 或数据增强;如果两个都不涨,是欠拟合或学习率太小;如果 loss 突然 NaN,检查邻接矩阵归一化和输入里有没有 NaN 坐标。

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 0 for x, label in loader: x, label = x.to(device), label.to(device) # 检查输入是否有 NaN,这是最常见的翻车点 if torch.isnan(x).any(): print('输入含 NaN,跳过该 batch') continue optimizer.zero_grad() out = model(x) loss = criterion(out, label) loss.backward() # 梯度裁剪,防止爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() correct += (out.argmax(1) == label).sum().item() total += label.size(0) return total_loss / len(loader), correct / total

逻辑说明:每个 batch 先查 NaN,再做前向反向,梯度裁剪限制在 1.0 防止爆炸。参数上max_norm=1.0是通用值,骨骼数据梯度通常不大,1.0 足够。监控指标每 epoch 打印一次,方便画曲线写进毕设论文。

4.3 预训练权重加载与微调

如果做的是 NTU 子集或自采数据,强烈建议加载官方在 NTU-60 上训好的权重,冻结前几层只微调后面。加载时注意两点:一是分类头维度不同要跳过,二是邻接矩阵维度不同(18 vs 25)时整个空间卷积层都不能加载。

def load_pretrained(model, ckpt_path, num_class): ckpt = torch.load(ckpt_path, map_location='cpu') state = ckpt['state_dict'] if 'state_dict' in ckpt else ckpt model_dict = model.state_dict() # 过滤掉维度不匹配的层(分类头、邻接矩阵相关) filtered = {k: v for k, v in state.items() if k in model_dict and v.shape == model_dict[k].shape} model_dict.update(filtered) model.load_state_dict(model_dict) print(f'加载了 {len(filtered)}/{len(state)} 个参数') return model

逻辑说明:用形状匹配做过滤,自动跳过分类头和维度不符的层。参数上num_class用于确认分类头是否被跳过。这个函数能打印加载比例,如果低于 50% 说明骨架定义差异太大,微调意义不大,不如从头训。

5. 避坑与排查:ST-GCN 复现时最容易翻车的 5 个点

5.1 现象:loss 从第一个 epoch 就是 NaN

原因:输入坐标里混入了 NaN 或 inf。OpenPose 在遮挡严重时会输出 0 置信度但坐标仍是数值,而某些姿态估计器会直接给 NaN。另一个可能是邻接矩阵没归一化,度数高的节点梯度爆炸。

解决:在 dataloader 里加一行x = torch.nan_to_num(x, nan=0.0),把 NaN 替换成 0;同时确认邻接矩阵做了D^-1 A归一化。如果还不行,把学习率降到 0.001 试一个 epoch。

5.2 现象:训练准确率 99%,验证准确率 40%

原因:典型过拟合。骨骼数据样本量小,模型参数量大(ST-GCN 约 300 万参数),很容易记住训练集。另一个隐藏原因是数据泄露——同一段视频的相邻帧被分到了训练集和验证集,模型其实在背帧。

解决:按视频而非按帧划分数据集,确保同一动作的不同片段不跨集;加 Dropout(0.5)和 weight_decay(1e-4);用前面说的三种数据增强。如果还不行,把网络宽度砍半。

5.3 现象:换了数据集后准确率暴跌

原因:骨架定义不一致。NTU 是 25 关节,Kinetics 是 18 关节,直接套用会导致邻接矩阵和输入维度对不上,或者边连错了位置。另一个原因是坐标归一化方式不同,有的数据集用屏幕坐标,有的用归一化坐标。

解决:换数据集时重新构建邻接矩阵,确认num_node和边列表;统一坐标到 [0,1] 范围,以髋关节中心为原点做平移归一化。这一步不做,模型学到的全是绝对位置而非相对动作。

5.4 现象:训练速度极慢,一个 epoch 要几小时

原因:数据加载是瓶颈。如果每次都在__getitem__里读 npy 文件,IO 会拖垮训练。另一个原因是 batch size 太小导致 GPU 利用率低。

解决:把所有 npy 预加载到内存(几千条数据通常几十 GB 以内),用num_workers=4多进程加载;batch size 提到 64,配合学习率同步放大。如果显存不够,用混合精度训练torch.cuda.amp,能省一半显存。

5.5 现象:推理时单人正常,双人动作全错

原因:M 维(人数)处理不当。训练时如果 M=2 但推理时只检测到 1 人,第二人补零,网络可能把零特征当成有效输入。另一个原因是两人顺序不稳定,这一帧 A 在左 B 在右,下一帧反了,网络学不到一致的特征。

解决:推理时按置信度排序,置信度低的补零;训练时对两人做随机交换增强,让网络对顺序不敏感。如果做的是交互动作,考虑用两人特征的相对位置而非绝对位置作为输入。

6. 让毕设出彩:三个可落地的改进方向与验证方法

跑通 baseline 只是及格线,毕设要拿高分得有差异化。下面三个方向我都试过,改动量可控,效果可量化。

第一个方向是自适应邻接矩阵。原始 A 是写死的物理连接,但有些动作(比如打电话)手和耳朵的关联比手和手肘更强。做法是加一个nn.Parameter形状为(V, V)的全局邻接矩阵,和物理 A 相加后一起归一化。改动只有几行,但 NTU-60 上通常能涨 1~2 个点。验证方法是做消融实验,对比固定 A 和自适应 A 的准确率,表格写进论文就是现成的创新点。

第二个方向是多流融合。ST-GCN 默认只吃关节坐标(joint stream),可以再加一路骨骼长度和方向(bone stream),两路各训一个模型,推理时 softmax 分数相加。骨骼流对动作幅度敏感,关节流对位置敏感,融合后互补。实现上把(N,C,T,V)里的 C 从坐标换成相邻关节的向量差即可。代价是训练时间翻倍,但准确率通常涨 2~3 个点,性价比很高。

第三个方向是注意力机制。在时间维加一个 SE block 或 Transformer encoder,让网络自己决定哪些帧重要。动作识别里起手和收尾帧信息量低,中间帧才是关键。做法是在每个 STGCNBlock 的时间卷积后接一个通道注意力,用全局池化算权重再乘回去。这个改动需要调参,注意力模块的学习率要比主干小 10 倍,否则训练不稳定。

验证方法统一用交叉验证 + 混淆矩阵。别只报一个准确率,把每一类的 precision、recall、F1 都算出来,找出模型最混淆的类别(通常是相似动作,比如「读书」和「写字」),针对性分析原因。混淆矩阵画出来放论文里,比干巴巴一个数字有说服力得多。

我自己的习惯是:每改一个模块,先跑 10 个 epoch 看 loss 曲线趋势,趋势对了再跑完整训练。别一上来就训 80 epoch,浪费卡时。另外所有实验都固定随机种子,torch.manual_seed(42)、np.random.seed(42),不然复现时对不上数,答辩被问就尴尬了。这套流程走下来,从跑通到出创新点大概两周,希望帮到你。

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

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

Docker 安装和使用【Rocky Linux 9】

目录 一&#xff0c;Docker 安装 1. 为什么选 Rocky Linux 9 2. 环境准备 3. 安装 Docker 4. 镜像加速器配置 二&#xff0c;镜像操作 1. 搜索镜像 2. 下载镜像 3. 查看镜像 4. 删除镜像 5. 载入镜像 6. 提交改变 7. 镜像传输 8. 推送远程仓库 三&#xff0c;容…

作者头像 李华
网站建设 2026/10/17 22:16:17

传统程序员3个月转型AI应用开发,高薪之路轻松开启!

本文分析了AI应用开发工程师的职业前景&#xff0c;指出传统开发者转型AI是必然趋势。文章拆解了转型认知误区&#xff0c;提供3个月系统化学习计划&#xff0c;涵盖Python基础、AI框架、实战项目等。同时全景展示必备技术栈和零基础实战项目&#xff0c;帮助程序员快速掌握AI应…

作者头像 李华
网站建设 2026/10/17 22:16:13

把几百 MB 的三维模型,变成对方点开就能看的一个网页

内测免费不用注册模型不上传断网也能用中文界面 把几百 MB 的三维模型&#xff0c; 变成对方点开就能看的一个网页 导入 .glb 模型 → 导出一个网页文件 → 把这个文件发给客户。 客户不用装 UG / SolidWorks / 任何三维软件&#xff0c;手机、平板、电脑有浏览器就能打开&am…

作者头像 李华
网站建设 2026/10/17 22:15:32

自动化测试面试十道高频题:从认知到工程化一次讲透

干了十年测试&#xff0c;面过的人少说也上百了。我发现一个特别有意思的现象&#xff1a;很多候选人在简历上把自动化测试、pytest、Appium、接口自动化测试框架写得满满当当&#xff0c;结果一开口问“你这套东西到底解决了什么问题”&#xff0c;人当场卡住。自动化测试面试…

作者头像 李华
网站建设 2026/10/16 12:35:06

企业协同管理平台技术落地指南:权限、流程与安全实战

简介&#xff1a;本资源是一份面向企业管理者、IT架构师及数字化转型实践者的《企业互联协同管理平台》深度解析PPT&#xff0c;聚焦解决跨组织、跨地域、跨业务场景下的实时协同难题&#xff0c;涵盖组织形态演进、三级经营体构建、小前台大中台强后台架构设计等核心议题。文件…

作者头像 李华
网站建设 2026/10/16 8:22:00

stm32 14-2 WDG 看门狗:例题代码

&#xff08;88&#xff09;IWDG 例题&#xff1a; 以上是开启 IWDG 的步骤&#xff0c;接着是使用的库函数&#xff1a; void IWDG_WriteAccessCmd(uint16_t IWDG_WriteAccess); //写使能控制&#xff0c;操作键寄存器 void IWDG_SetPrescaler(uint8_t IWDG_Prescaler);…

作者头像 李华