ST-GCN骨骼动作识别实战:从数据到部署的毕业设计指南
简介这份资源是面向高校学生与Python初学者的骨骼动作识别毕业设计项目基于时空图卷积网络ST-GCN实现适合用作毕业设计、期末大作业或课程设计的高分参考。项目围绕骨架数据的时空建模展开涵盖数据预处理、模型搭建、训练与推理等完整流程并附带详细代码注释新手也能较快理解与部署。压缩包共90个文件约52.61MB包含29个py源码、13个yaml配置、12个pyc缓存、11个gif演示、9个txt说明、5个png图示、3个pt权重、3个mp4视频及prototxt、sh脚本等覆盖模型定义、数据加载、离线与实时识别演示等模块。已有188人学习下载作者自述为手打98分项目导师认可度较高。读者可据此获得一套可直接运行的完整方案借助权重文件与演示素材快速验证效果并通过注释与配置理解ST-GCN的关键实现细节为答辩与二次开发提供参考。1. 从骨架序列到动作标签ST-GCN 骨骼动作识别到底在做什么如果你手头有一段用 Kinect、MediaPipe 或 OpenPose 提取出来的人体骨架序列每一帧记录着十几个到几十个关节点的二维或三维坐标你想让机器判断这个人是在走路、挥手还是跌倒——这就是骨骼动作识别要解决的问题。而 ST-GCNSpatial-Temporal Graph Convolutional Network时空图卷积网络是目前这条技术路线上最经典、复现成本最低、也最适合拿来做毕业设计的方案之一。它把人体骨架天然建模成一张图关节点是图的节点骨骼连接是图的边时间维度上同一关节点跨帧相连于是“空间结构 时间演化”被统一进一个图卷积框架里。相比早期把骨架拉成向量再喂 LSTM 的做法ST-GCN 显式利用了人体拓扑结构参数量小、训练快、在 NTU RGBD 这类公开集上精度也拿得出手。这篇笔记面向的是要动手复现、要写论文、要交毕业设计的同学我会把数据准备、模型搭建、训练调参、踩坑排查整条链路讲清楚让你拿到源代码后能真正跑起来而不是对着报错发呆。2. 骨骼动作识别的数据管线从关节坐标到 ST-GCN 输入张量2.1 为什么 ST-GCN 的输入不是普通视频帧很多人第一次接触骨骼动作识别会下意识想用 CNN 直接处理 RGB 视频。但骨骼动作识别的输入本质是坐标序列不是像素。以 NTU RGBD 为例每个样本是一段动作每个帧有 25 个关节点每个关节点有 3 个坐标值x, y, z再加上每个关节点在每帧的置信度分数实际特征维度是 4。一段动作通常采样成固定帧数 T比如 300 帧。于是单个样本的原始张量形状是(C3, T300, V25, M2)其中 M 是人数NTU 里最多两人。ST-GCN 的输入要求是(N, C, T, V, M)N 是 batch size。这里的关键在于空间维度 V 不是规则的网格而是有连接关系的人体骨架图。普通卷积核在 3x3 窗口上滑动但骨架图上每个节点的邻居数量不一样所以必须用图卷积按邻接矩阵定义的邻接关系聚合特征。这也是为什么你不能直接把骨架坐标丢进 ResNet——结构不匹配。2.2 用 Python 把原始骨架数据整理成训练集假设你拿到的数据是每个动作一个.skeleton文件或者一段 JSON里面是逐帧的关节点坐标。下面这段代码演示如何把变长序列统一采样成固定帧数并整理成 ST-GCN 需要的 numpy 数组。这里以常见的 JSON 格式为例实际源码包里通常会有nturgbd_raw或data_gen目录逻辑类似。import json import numpy as np def load_skeleton(json_path): with open(json_path, r) as f: data json.load(f) # data[frames] 是列表每个元素是一帧包含 body 关节点列表 frames data[frames] num_frames len(frames) num_joints 25 # NTU 标准 num_person 2 C 3 # x, y, z tensor np.zeros((C, num_frames, num_joints, num_person), dtypenp.float32) for t, frame in enumerate(frames): for m, body in enumerate(frame[bodies][:num_person]): for v, joint in enumerate(body[joints][:num_joints]): tensor[0, t, v, m] joint[x] tensor[1, t, v, m] joint[y] tensor[2, t, v, m] joint[z] return tensor def uniform_sample(tensor, target_frames300): C, T, V, M tensor.shape if T target_frames: return tensor indices np.linspace(0, T - 1, target_frames).astype(int) return tensor[:, indices, :, :] # 批量处理 sample load_skeleton(S001C001P001R001A001.skeleton.json) sample uniform_sample(sample, 300) print(sample.shape) # (3, 300, 25, 2)这段代码做了三件事读取 JSON、把坐标填进(C, T, V, M)张量、用均匀采样把变长序列对齐到 300 帧。参数说明num_joints25是 NTU 标准如果你用 MediaPipe 则是 33 个关节点需要同步改邻接矩阵num_person2对应双人场景单人动作可以设为 1target_frames一般取 300太小会丢动作细节太大显存吃紧。注意坐标最好做归一化常见做法是以髋关节中心为原点再除以肩宽或身高做尺度归一否则不同人高矮胖瘦会让模型学偏。2.3 邻接矩阵ST-GCN 的空间图怎么定义ST-GCN 的核心之一是邻接矩阵 A它描述关节点之间的连接关系。NTU 的 25 个关节点有标准的物理连接比如手腕连肘、肘连肩。但只用物理连接还不够论文里提出了三种分区策略统一分区、距离分区、空间构型分区。实际代码里通常用空间构型分区把每个节点的邻居分成三组自身、向心组更靠近骨架中心、离心组更远离中心。这样邻接矩阵从一个(V, V)变成(3, V, V)对应三个子图。下面是一个简化版的邻接矩阵构建代码。import numpy as np def build_adjacency(num_joints25): # 以 NTU 骨架为例定义物理连接边 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)] A np.zeros((num_joints, num_joints), dtypenp.float32) for i, j in edges: A[i, j] 1 A[j, i] 1 # 加上自环 A A np.eye(num_joints, dtypenp.float32) # 归一化D^{-1/2} A D^{-1/2} D np.sum(A, axis1) D_inv_sqrt np.power(D, -0.5) D_inv_sqrt[np.isinf(D_inv_sqrt)] 0 D_mat np.diag(D_inv_sqrt) A_norm D_mat A D_mat return A_norm A build_adjacency(25) print(A.shape) # (25, 25)这里构建的是最基础的单子图邻接矩阵。实际 ST-GCN 源码里会把它扩展成(3, 25, 25)分别对应自身、向心、离心三个分区。参数说明edges列表定义了骨架的物理连接不同数据集关节点编号不同必须对照官方文档改归一化用对称归一化D^{-1/2} A D^{-1/2}目的是防止度数大的节点在聚合时数值爆炸。如果你跳过归一化训练 loss 很容易变成 NaN这是血泪经验。3. 用 PyTorch 搭一个能跑的 ST-GCN模块拆解与参数设置3.1 空间图卷积层到底在算什么ST-GCN 的单层时空图卷积可以拆成两步先做空间图卷积再做时间卷积。空间图卷积的公式是f_out sum_k W_k * (A_k f_in)其中 A_k 是第 k 个分区的邻接矩阵W_k 是对应的可学习权重。直观理解对每个关节点把它邻居的特征按邻接权重加权求和再乘一个权重矩阵做特征变换。时间卷积则是在时间维度上用标准的一维卷积卷积核大小通常设为 9也就是看前后各 4 帧。下面是一个空间图卷积层的 PyTorch 实现。import torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_joints, num_subsets3): super().__init__() self.num_subsets num_subsets self.num_joints num_joints # 每个分区一个权重矩阵 self.weight nn.Parameter(torch.zeros(num_subsets, in_channels, out_channels)) nn.init.kaiming_normal_(self.weight) # 邻接矩阵作为 buffer不参与训练 self.register_buffer(A, self._build_adjacency(num_joints, num_subsets)) # 1x1 卷积做特征变换 self.conv nn.Conv2d(in_channels, out_channels * num_subsets, kernel_size1) def _build_adjacency(self, V, K): # 这里简化实际应加载预定义的三分区邻接矩阵 A torch.eye(V).unsqueeze(0).repeat(K, 1, 1) return A def forward(self, x): # x: (N, C, T, V) N, C, T, V x.shape x self.conv(x) # (N, C*K, T, V) x x.view(N, self.num_subsets, -1, T, V) # 对每个分区做图卷积 out torch.einsum(nkctv,kvw-nctw, x, self.A) return out这段代码里einsum那一行是核心nkctv,kvw-nctw表示对每个分区 k用邻接矩阵 A[k] 在节点维度 v 上做加权聚合得到新的节点特征 w。参数说明in_channels和out_channels控制特征维度常见设置是 64、128、256 逐层翻倍num_subsets3对应三分区策略kernel_size1的卷积负责通道变换。注意self.A用register_buffer注册这样保存模型时邻接矩阵会一起存下来加载时不用重新构建。3.2 时间卷积与整体网络结构时间卷积用标准nn.Conv2d把时间维度当作高度节点维度当作宽度卷积核设为(9, 1)只在时间方向滑动。每个 ST-GCN 块通常是“空间图卷积 BN ReLU 时间卷积 BN 残差连接”。下面给出一个完整的块定义。class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, num_joints, stride1, residualTrue): super().__init__() self.gcn SpatialGraphConv(in_channels, out_channels, num_joints) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.tcn nn.Conv2d(out_channels, out_channels, kernel_size(9, 1), stride(stride, 1), padding(4, 0)) self.bn2 nn.BatchNorm2d(out_channels) if not residual: self.residual lambda x: 0 elif in_channels out_channels and stride 1: self.residual lambda x: x else: self.residual nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stride(stride, 1)), nn.BatchNorm2d(out_channels) ) def forward(self, x): res self.residual(x) x self.gcn(x) x self.bn1(x) x self.relu(x) x self.tcn(x) x self.bn2(x) return self.relu(x res)参数说明stride控制时间下采样通常在第 5、8 层设为 2把 300 帧逐步降到 75 帧residual在通道数变化或 stride 不为 1 时用 1x1 卷积对齐维度。整个网络一般堆 9 到 10 个这样的块最后接全局平均池化和全连接分类层。训练时 batch size 设 16 或 32学习率初始 0.1用 SGD 加 momentum 0.9weight decay 1e-4在 NTU 上大概 50 个 epoch 收敛。如果显存不够把 batch size 降到 8学习率同步减半。3.3 训练循环里必须盯住的几个量训练脚本里除了 loss 和 accuracy还要打印学习率和当前 epoch 的耗时。ST-GCN 训练慢通常是因为数据加载成了瓶颈建议用DataLoader的num_workers4并开启pin_memoryTrue。另外骨骼数据增强很关键常见做法是随机旋转、随机缩放、随机平移以及随机遮挡部分关节点。下面是一个简单的数据增强函数。def augment_skeleton(data, angle_range(-0.3, 0.3), scale_range(0.9, 1.1)): # data: (C, T, V, M) C, T, V, M data.shape angle np.random.uniform(*angle_range) scale np.random.uniform(*scale_range) # 绕 z 轴旋转 cos_a, sin_a np.cos(angle), np.sin(angle) rot np.array([[cos_a, -sin_a], [sin_a, cos_a]]) xy data[:2].reshape(2, -1) xy rot xy data[:2] xy.reshape(2, T, V, M) * scale return data参数说明angle_range控制旋转幅度太大可能让动作语义改变scale_range控制缩放模拟不同身高的人。注意旋转只对 x、y 做z 保持不变。增强只在训练时用验证和测试必须用原始数据。4. 训练不收敛、精度上不去ST-GCN 排查清单4.1 现象loss 从第一个 epoch 就是 NaN原因通常是学习率太大或者邻接矩阵没归一化。ST-GCN 里图卷积的聚合操作会让数值随节点度数放大如果邻接矩阵没做对称归一化几层之后特征值就会爆炸。解决方法是检查build_adjacency里有没有做D^{-1/2} A D^{-1/2}以及学习率是不是设成了 0.1 以上。我一般会先把学习率降到 0.01 跑两个 epoch确认 loss 正常下降再调回去。4.2 现象训练 accuracy 很高验证 accuracy 卡在 60% 左右这是典型的过拟合。骨骼动作识别的数据量通常不大NTU 里每个类别也就几百到一千个样本。解决办法有三个加数据增强、加 dropout、减小模型宽度。具体操作是在每个 ST-GCN 块的最后加nn.Dropout(0.5)把通道数从 64-128-256 降到 32-64-128同时把 weight decay 从 1e-4 提到 5e-4。如果还不行检查一下训练集和验证集是不是同一个人做的动作——跨人验证比同人验证难很多精度掉 10 个点很正常。4.3 现象换了数据集后关节点对不上模型输出全乱不同数据集的关节点定义不一样。NTU 是 25 个点MediaPipe 是 33 个点OpenPose 是 18 或 25 个点。如果你直接拿 NTU 上训练的模型去跑 MediaPipe 数据邻接矩阵维度不匹配要么报错要么输出垃圾。解决方法是重新定义邻接矩阵的edges列表并确保输入张量的 V 维度和邻接矩阵一致。如果只是想快速验证可以把 MediaPipe 的 33 个点映射到 NTU 的 25 个点上但会损失一些精度。4.4 现象训练速度特别慢一个 epoch 要半小时先看 GPU 利用率如果低于 50%说明瓶颈在数据加载。把num_workers从 0 调到 4 或 8开启pin_memory并把数据预处理提前做好存成 numpy 或 lmdb不要每次都在__getitem__里读 JSON。另外时间卷积的 kernel size 设为 9 已经够用设成 15 或 20 只会增加计算量精度提升微乎其微。4.5 现象多人场景下模型分不清谁是谁NTU 里双人动作需要模型区分两个人。ST-GCN 原版对多人处理比较简单就是把两个人的骨架拼在一起但这样模型可能学到“人 A 在左边、人 B 在右边”这种位置偏见。解决方法是加一个注意力机制或者用更先进的 PoseC3D、CTR-GCN。如果只是毕业设计可以在数据预处理阶段按置信度把两个人排序保证输入顺序一致能缓解一部分问题。5. 把 ST-GCN 用到自己的数据上迁移与调优的几个具体技巧如果你手头不是 NTU 这种标准数据集而是自己用摄像头采集的骨架序列迁移 ST-GCN 时最需要注意的是关节点定义和动作类别数。假设你用 MediaPipe 提取了 33 个关节点动作有 10 类那么需要改三个地方邻接矩阵的edges列表、分类层的输出维度、以及数据加载器的路径。下面是一个迁移示例。# 1. 修改邻接矩阵为 MediaPipe 33 点 mediapipe_edges [(0,1),(1,2),(2,3),(3,7),(0,4),(4,5),(5,6),(6,8), (9,10),(11,12),(11,13),(13,15),(15,17),(15,19),(15,21), (17,19),(12,14),(14,16),(16,18),(16,20),(16,22),(18,20), (11,23),(23,24),(24,25),(23,27),(27,29),(29,31),(27,31), (12,24),(24,25),(24,26),(26,28),(28,30),(30,32),(28,32)] # 2. 分类层输出改为 10 model.fc nn.Linear(256, 10) # 3. 数据加载器指向自己的数据目录 train_loader DataLoader(MySkeletonDataset(data/train), batch_size16, shuffleTrue)参数说明mediapipe_edges是 MediaPipe 官方定义的骨骼连接注意左右对称model.fc的输入维度取决于你最后一层 ST-GCN 块的输出通道数通常是 256MySkeletonDataset需要自己实现__getitem__和__len__。迁移时建议先冻结前面的图卷积层只训练分类层 5 个 epoch再解冻全部微调。这样能防止随机初始化的分类层把预训练特征带偏。另一个技巧是关节点置信度过滤。MediaPipe 输出的每个关节点有 visibility 分数低于 0.5 的关节点坐标不可靠直接置零或者用前后帧插值补上。我一般会在数据预处理阶段加一步如果某个关节点连续 5 帧置信度都低于阈值就把这段动作截掉或者标记为无效样本。这个操作能明显减少训练时的噪声。最后说一个验证方法把测试集按动作类别分层抽样每类抽 10 个样本人工看模型预测结果。如果某一类总是被预测成另一类比如“坐下”和“蹲下”混淆说明这两类的骨架序列在时间维度上差异太小需要加长采样帧数或者引入速度特征。这个习惯帮我省了很多盲目调参的时间。希望帮到你。本文还有配套的精品资源点击获取