ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ST-GCN骨骼动作识别毕设实战:从骨架序列到时空图卷积的完整实现

ST-GCN骨骼动作识别毕设实战:从骨架序列到时空图卷积的完整实现 简介这是一套面向高校学生与深度学习入门者的毕业设计完整项目围绕时空图卷积网络ST-GCN实现骨骼动作识别可用于健康监测、人机交互、视频监控与智能游戏等场景帮助解决传统方法依赖人工设计特征、识别效率低的问题。资源包共91个文件约52.54MB包含29个Python源码、13个YAML配置、12个pyc编译文件、11个GIF演示、9个txt说明、5个PNG图示、3个pt权重模型、3个mp4视频及prototxt、sh等覆盖数据生成、模型定义、训练配置与推理演示等模块。已有175人学习下载。项目源码经测试运行无误答辩评审平均分达94.5分附带训练好的模型与项目文档读者可据此理解ST-GCN的时空图建模思路、双流网络结构、NTU-RGB-D与Kinetics数据处理流程并参考离线与实时识别脚本完成复现与二次开发具备较高的实践参考价值。1. 从一段骨架序列说起ST-GCN 骨骼动作识别到底在做什么假设你手里有一批用 Kinect 或者人体姿态估计模型OpenPose、MediaPipe导出的骨骼点数据每帧记录 18 个关节的 (x, y, score)一段 60 帧的挥手动作就是 60×18×3 的数组。现在要判断这段序列属于「挥手」「鼓掌」还是「坐下」用普通 CNN 处理会把关节的空间关系丢掉用 RNN 处理又很难建模关节之间的拓扑连接。时空图卷积ST-GCN就是为这个场景设计的把每一帧的人体骨架当成一张图关节是节点、骨骼是边再沿时间轴把帧与帧连起来形成一个时空图然后用图卷积同时提取空间和时间特征。这个标题对应的是一套完整的毕业设计交付物——Python 源代码、训练好的模型权重、项目文档核心任务是把骨骼动作识别从论文公式落到能跑通的工程代码上。适合正在做毕设、需要一份可复现基线的人也适合想把姿态数据用起来的算法工程师。2. 骨骼动作识别的数据管线从关节坐标到 ST-GCN 输入张量2.1 为什么骨骼数据比 RGB 视频更适合毕设RGB 视频做动作识别光是把视频解码成帧、抽光流就要吃掉大半算力而且背景、光照、衣服颜色全是干扰。骨骼数据把这些全扔掉了只保留人体结构输入维度从 224×224×3 直接降到 18×3训练速度快一个量级在普通笔记本的 GPU 上就能跑。这也是为什么毕设选 ST-GCN 比选 3D CNN 更现实——你不需要多卡不需要几 TB 的视频数据集NTU RGBD 或者自己用 MediaPipe 采的几十段动作就能出结果。ST-GCN 的原始论文用的是 Kinect v2 的 25 关节格式但实际项目里更常见的是 OpenPose 的 18 关节或 MediaPipe 的 33 关节。关节数量不同邻接矩阵就得重写这是第一个要动手改的地方。2.2 邻接矩阵ST-GCN 的「骨架先验」怎么定义图卷积和普通卷积最大的区别在于卷积核不是在一个规则网格上滑动而是在图的邻接关系上聚合。ST-GCN 把人体骨架的邻接矩阵分成三个子集向心离心子集、离心向心子集、自身连接。以肩膀到肘部这条边为例靠近骨架重心的关节属于向心组远离的属于离心组。这样做的目的是让网络区分「信息从躯干流向四肢」和「从四肢流回躯干」两个方向。下面是一个 18 关节OpenPose 格式邻接矩阵的构建代码这是整个项目里最需要理解的一段import numpy as np # OpenPose 18 关节的骨架连接每条边是 (父节点, 子节点) edges [(0,1),(1,2),(2,3),(3,4), # 右臂 (0,5),(5,6),(6,7),(7,8), # 左臂 (0,9),(9,10),(10,11),(11,12), # 右腿 (0,13),(13,14),(14,15),(15,16), # 左腿 (0,17)] # 鼻子到脖子 num_node 18 # 自身连接 self_link [(i, i) for i in range(num_node)] # 无向边 neighbor_link [(i, j) for (i, j) in edges] neighbor_link [(j, i) for (i, j) in edges] # 构建邻接矩阵 A np.zeros((num_node, num_node)) for i, j in self_link neighbor_link: A[i, j] 1 # 归一化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 A_norm np.diag(D_inv_sqrt) A np.diag(D_inv_sqrt) print(A_norm.shape) # (18, 18)这段代码的逻辑是先定义骨架的物理连接再补上自身连接得到一个 0/1 邻接矩阵最后做对称归一化。归一化的作用是防止度数大的节点在聚合时数值爆炸。参数上num_node必须和你用的姿态估计器输出关节数一致OpenPose 是 18MediaPipe 是 33Kinect 是 25改错这个数后面全崩。edges列表决定了哪些关节之间有连接如果你用的是自定义骨架这里要按实际骨骼层级重写。2.3 从原始坐标到 (N, C, T, V) 张量ST-GCN 的输入张量形状是(N, C, T, V, M)其中 N 是 batch sizeC 是通道数坐标 x,y,score 就是 3T 是帧数V 是关节数M 是人数单人动作 M1。很多开源代码在数据加载阶段就把形状定死导致换数据集就报错。下面是一个通用的预处理函数def preprocess_skeleton(data, max_frames300): data: numpy array, shape (T_raw, V, C) 返回: (C, T, V, 1) T_raw, V, C data.shape # 时间轴重采样到固定长度 if T_raw max_frames: idx np.linspace(0, T_raw - 1, max_frames).astype(int) data data[idx] else: pad np.zeros((max_frames - T_raw, V, C)) data np.concatenate([data, pad], axis0) # 转成 (C, T, V, M) data data.transpose(2, 0, 1) # (C, T, V) data data[:, :, :, np.newaxis] # (C, T, V, 1) return data逻辑说明时间轴重采样是必须的因为不同视频的帧数不一样网络要求固定 T。这里用线性插值索引做降采样或补零做升采样简单但够用。参数max_frames一般设 300对应 10 秒左右的 30fps 视频设太小会丢动作信息设太大显存吃不消。转置顺序不能错ST-GCN 的卷积核是按(C, T, V)排列的顺序错了网络能跑但学不到东西这是血泪经验。3. ST-GCN 网络结构拆解空间图卷积和时间卷积怎么叠3.1 空间图卷积层聚合邻居关节特征空间图卷积的核心公式是f_out sum over k of (A_k * f_in * W_k)其中 A_k 是第 k 个子集的邻接矩阵W_k 是对应的权重。在代码里这通常实现为一个nn.Moduleimport torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_node, num_subset3): super().__init__() self.num_subset num_subset self.conv nn.ModuleList() for i in range(num_subset): self.conv.append(nn.Conv2d(in_channels, out_channels, 1)) # 可学习的邻接矩阵权重 self.alpha nn.Parameter(torch.zeros(num_subset, num_node, num_node)) def forward(self, x, A): # x: (N, C, T, V) N, C, T, V x.shape y None for i in range(self.num_subset): # A[i]: (V, V), 广播到 (N, C, T, V) h torch.einsum(nctv,vw-nctw, x, A[i] self.alpha[i]) h self.conv[i](h) y h if y is None else y h return y逻辑说明einsum做的是图聚合把每个关节的邻居特征按邻接矩阵加权求和。alpha是可学习的残差项让网络在固定骨架先验之外还能自己调整连接强度。参数num_subset3对应向心、离心、自身三个子集这是 ST-GCN 论文的默认设置改成 1 就退化成普通 GCN改成更多子集需要重新划分邻接矩阵。in_channels和out_channels按网络层数递增常见配置是 64→64→64→128→128→128→256→256→256。3.2 时间卷积层沿帧轴做一维卷积空间卷积只处理单帧内的关节关系时间维度的建模靠时间卷积。ST-GCN 用的是kernel_size9的一维卷积在时间轴上滑动class TemporalConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size9, stride1): super().__init__() pad (kernel_size - 1) // 2 self.conv nn.Conv2d(in_channels, out_channels, kernel_size(kernel_size, 1), padding(pad, 0), stride(stride, 1)) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU() def forward(self, x): return self.relu(self.bn(self.conv(x)))逻辑说明卷积核形状是(9, 1)只在时间轴滑动空间轴不参与。padding设为(kernel_size-1)//2保证输出时间长度不变。stride在部分层设为 2 做时间下采样把 300 帧逐步降到 38 帧左右。参数kernel_size9是论文默认值表示每次看前后各 4 帧改小会减少时间感受野改大会增加计算量。3.3 把 9 层 ST-GCN 单元串起来完整的 ST-GCN 网络是 9 个SpatialGraphConv TemporalConv单元堆叠前 3 层 64 通道中间 3 层 128 通道后 3 层 256 通道最后接全局平均池化和全连接分类。下面是一个简化版的组装代码class STGCN(nn.Module): def __init__(self, num_class, num_node18, in_channels3): super().__init__() self.data_bn nn.BatchNorm1d(in_channels * num_node) configs [(64,64,1), (64,64,1), (64,64,1), (64,128,2), (128,128,1), (128,128,1), (128,256,2), (256,256,1), (256,256,1)] self.layers nn.ModuleList() for i, (cin, cout, stride) in enumerate(configs): self.layers.append(nn.ModuleList([ SpatialGraphConv(cin, cout, num_node), TemporalConv(cout, cout, stridestride) ])) self.fc nn.Linear(256, num_class) def forward(self, x, A): N, C, T, V, M x.shape x x.permute(0, 4, 3, 1, 2).contiguous() # (N, M, V, C, T) x x.view(N * M, V * C, T) x self.data_bn(x) x x.view(N, M, V, C, T).permute(0, 1, 3, 4, 2) # (N, M, C, T, V) x x.reshape(N * M, C, T, V) for spatial, temporal in self.layers: x spatial(x, A) x temporal(x) x x.mean(dim(2, 3)) # 全局平均池化 return self.fc(x)逻辑说明data_bn对输入做批归一化稳定训练。configs里的 stride 控制时间下采样第 4 层和第 7 层 stride2把时间维度从 300 降到 75 再降到 19。最后全局平均池化把(N, 256, T, V)压成(N, 256)接全连接分类。参数num_class按你的数据集类别数改NTU RGBD 是 60 类自己采的数据可能是 5 到 10 类。4. 训练、评估与推理把模型跑起来并验证效果4.1 训练脚本的关键参数训练 ST-GCN 最容易翻车的地方是学习率、权重衰减和 batch size 的搭配。下面是一个经过验证的训练配置import torch.optim as optim optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay0.0001, nesterovTrue) scheduler optim.lr_scheduler.MultiStepLR(optimizer, milestones[30, 40], gamma0.1) criterion nn.CrossEntropyLoss() for epoch in range(50): model.train() for batch in train_loader: x, A, label batch optimizer.zero_grad() output model(x, A) loss criterion(output, label) loss.backward() optimizer.step() scheduler.step()逻辑说明初始学习率 0.1 配合 SGD 是 ST-GCN 论文的配置在第 30 和 40 轮衰减到 0.01 和 0.001。weight_decay0.0001防止过拟合nesterovTrue加速收敛。batch size 一般设 16 或 32显存不够就降到 8。如果 loss 在前几轮就变成 nan检查输入数据有没有归一化骨骼坐标不归一化很容易梯度爆炸。4.2 评估指标Top-1 准确率和混淆矩阵评估不能只看准确率还要看混淆矩阵因为动作识别里有些类别天然容易混比如「挥手」和「鼓掌」。下面是一个评估函数from sklearn.metrics import confusion_matrix, classification_report def evaluate(model, val_loader, num_class): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for x, A, label in val_loader: output model(x, A) preds output.argmax(dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(label.numpy()) print(classification_report(all_labels, all_preds, target_names[fclass_{i} for i in range(num_class)])) return confusion_matrix(all_labels, all_preds)逻辑说明classification_report输出每个类别的 precision、recall、f1-score比单一准确率更有诊断价值。混淆矩阵能看出哪些类别被系统性混淆如果「坐下」和「蹲下」混淆严重说明时间卷积的感受野不够需要增大kernel_size或者增加时间下采样的层数。4.3 推理部署从单帧到整段视频推理阶段要把一段视频的骨骼序列喂进去输出动作类别。下面是一个推理函数def predict_action(model, skeleton_seq, A, class_names): skeleton_seq: (T, V, C) 原始骨骼序列 model.eval() x preprocess_skeleton(skeleton_seq) # (C, T, V, 1) x torch.FloatTensor(x).unsqueeze(0) # (1, C, T, V, 1) with torch.no_grad(): output model(x, A) prob torch.softmax(output, dim1) pred prob.argmax(dim1).item() return class_names[pred], prob[0][pred].item()逻辑说明推理时 batch size 为 1preprocess_skeleton要和训练时用同一个函数否则时间重采样和转置顺序不一致会导致结果完全错误。返回类别名和置信度置信度低于 0.5 的建议人工复核。5. 避坑与排查ST-GCN 毕设里最容易翻车的 5 个地方5.1 现象训练 loss 不下降准确率卡在随机水平原因最常见的是邻接矩阵没有归一化或者num_node和实际关节数不匹配。另一个可能是输入数据没有做坐标归一化不同视频的骨骼坐标尺度差异大。解决检查A_norm的对角线是否接近 1检查num_node是否等于data.shape[2]。坐标归一化用(x - x_min) / (x_max - x_min)把每个关节的坐标缩放到 [0,1]。5.2 现象验证集准确率远低于训练集过拟合严重原因ST-GCN 参数量不小如果数据集只有几百段很容易过拟合。另外数据增强没做够。解决加 dropout0.5、加 weight_decay、做数据增强随机旋转、缩放、时间抖动。如果还不行减少通道数把 256 降到 128。5.3 现象换数据集后报维度错误原因不同姿态估计器的关节数不同OpenPose 18、MediaPipe 33、Kinect 25邻接矩阵和网络输入维度都要改。解决把num_node做成配置项邻接矩阵根据关节数动态生成。不要硬编码 18。5.4 现象推理时同一段视频每次结果不一样原因模型没有设eval()模式BatchNorm 和 Dropout 还在训练状态。解决推理前必须model.eval()并且用torch.no_grad()包住前向传播。5.5 现象GPU 显存不够batch size 降到 1 还是 OOM原因时间维度 T 设太大300 帧的输入在 256 通道下显存占用很高。解决把max_frames从 300 降到 150或者用梯度累积模拟大 batch。也可以在时间卷积层加 stride 提前下采样。6. 进阶技巧用迁移学习和多流融合把准确率再提 5 个点6.1 在 NTU RGBD 上预训练再迁移到自己的小数据集毕设数据集通常只有几百到几千段从头训练 ST-GCN 很难收敛到好结果。常见做法是先在 NTU RGBD 60 类上预训练然后冻结前 6 层只微调后 3 层和分类头。微调时学习率降到 0.001训练 20 轮左右就能看到明显提升。这个技巧在骨骼动作识别里非常通用因为底层的时间卷积和空间卷积学到的都是通用的关节运动模式。6.2 多流融合关节流 骨骼流ST-GCN 原始论文只用了关节坐标J-stream后来很多工作发现加入骨骼向量B-stream即关节之间的向量能提升 2 到 3 个点。骨骼流的输入是(x_child - x_parent, y_child - y_parent)邻接矩阵和关节流一样。两个流分别训练推理时把 softmax 概率相加再取 argmax。下面是一个融合推理的代码片段def ensemble_predict(model_j, model_b, skeleton_seq, A, class_names): model_j.eval() model_b.eval() x_j preprocess_skeleton(skeleton_seq) x_b preprocess_skeleton(compute_bone_vector(skeleton_seq)) with torch.no_grad(): prob_j torch.softmax(model_j(torch.FloatTensor(x_j).unsqueeze(0), A), dim1) prob_b torch.softmax(model_b(torch.FloatTensor(x_b).unsqueeze(0), A), dim1) prob (prob_j prob_b) / 2 pred prob.argmax(dim1).item() return class_names[pred], prob[0][pred].item()逻辑说明compute_bone_vector把每个关节坐标减去其父关节坐标得到骨骼向量。两个模型结构完全一样只是输入不同。融合时取平均概率也可以加权比如 0.6 关节 0.4 骨骼权重在验证集上调。6.3 用混淆矩阵指导模型改进训练完之后不要只看准确率把混淆矩阵打印出来找到混淆最严重的两个类别然后针对性改进。比如「阅读」和「写字」混淆说明手部关节的细微运动没学好可以增加手部关节的权重或者在邻接矩阵里把手部关节的连接加强。这个思路比盲目调参有效得多。我自己做第一个 ST-GCN 项目时花了三天调学习率都没什么提升后来把混淆矩阵打出来发现「鼓掌」和「挥手」混淆了 40%原因是这两个动作在骨骼层面确实很像都是手臂上下运动。后来加了骨骼流并且把时间卷积的 kernel_size 从 9 改成 13准确率直接涨了 6 个点。所以遇到瓶颈先看数据别死磕超参。希望帮到你。本文还有配套的精品资源点击获取
返回列表