YAOTU INSIGHTS

ST-GCN骨骼动作识别实战:从关键点提取到模型部署

ST-GCN骨骼动作识别实战:从关键点提取到模型部署
简介一套基于时空图卷积ST-GCN的骨骼动作识别毕业设计项目面向计算机视觉方向的高校学生与入门研究者解决人体骨骼动作分类与时空特征建模问题。项目采用图卷积网络将人体骨骼建模为时空图通过端到端训练自动提取空间与时间维度特征适用于健康监测、人机交互、视频监控等场景。压缩包共91个文件含29个Python脚本、3个预训练模型权重pt、13个YAML配置、11个GIF演示及3个MP4视频并附带项目文档与说明整体约52.54MB。已有175人学习下载源码经测试运行无误答辩评审平均分达94.5分具备较高的完成度与参考价值。文档完整覆盖设计思路、实验过程与结果分析同时提供离线与实时demo及工具脚本便于理解ST-GCN在骨骼序列建模中的实现细节并快速进行二次开发与实验复现。1. 为什么毕业设计想做基于ST-GCN的骨骼动作识别骨骼动作识别不把整张画面送给网络而是先抽出一组人体关键点坐标让模型只看“骨架怎么动”。天气、背景、衣服甚至摄像头画质的变化都会被过滤掉一张桌面级显卡甚至纯 CPU 就能完成训练这在本科毕业设计里是性价比很高的选题。ST-GCN 是这类任务最早也最常用的时空图卷积模型它把每帧的关键点当作图节点把骨骼连接当作边用图卷积做空间特征、用时间卷积做动作趋势。很多号称“源代码模型项目文档”的下载包里核心也就是三件事数据管线、模型结构、训练推理脚本。下面把这三个环节拆开给出你在本地能直接复现的最小实现顺便说一下最容易踩的坑。2. ST-GCN的数据准备从关键点提取到骨骼图的邻接矩阵2.1 MediaPipe提取关键点装环境到拿到33个坐标ST-GCN 不消费原始像素所以数据准备的第一步是做人脸、躯干和四肢的关键点检测。业界最常用的是 MediaPipe 和 OpenPose 两条路OpenPose 精度高但环境配置繁琐MediaPipe 胜在安装简单、跨平台单帧推理在 CPU 上也能到几十毫秒。毕业设计里我建议优先用 MediaPipe先把流程跑通后面再换更复杂的检测器也不迟。假设你已经装好了 Python 3.8 和 PyTorch用下列命令补齐依赖pip install mediapipe opencv-python numpy torch下面的代码演示如何从一帧图片里提取 Pose 的 33 个三维关键点import cv2 import mediapipe as mp mp_pose mp.solutions.pose pose mp_pose.Pose( static_image_modeTrue, model_complexity1, min_detection_confidence0.5, ) image cv2.imread(frame_001.jpg) rgb cv2.cvtColor(image, cv2.COLOR_BGR2RGB) result pose.process(rgb) if result.pose_landmarks: coords [(lm.x, lm.y, lm.z) for lm in result.pose_landmarks.landmark] print(len(coords)) # 33这里static_image_modeTrue表示对单张图片做检测视频流里改成False会启用跟踪缓存速度更快。model_complexity接受 0、1、2数字越大越准但越慢CPU 环境下先设为 1 即可。关键点归一化到 [0,1]可以减少视频分辨率差异带来的影响。2.2 把骨骼连接变成邻接矩阵A拿到关键点坐标之后显式的图结构还需要一张邻接矩阵。节点是 33 个关键点边来自 MediaPipe 预定义的POSE_CONNECTIONS。下面这段代码自动构建邻接矩阵并立刻做对称归一化省去手写边表的麻烦。import numpy as np num_nodes 33 edges list(mp_pose.POSE_CONNECTIONS) A np.zeros((num_nodes, num_nodes), dtypenp.float32) for i, j in edges: A[i, j] 1.0 A[j, i] 1.0 A[np.eye(num_nodes, dtypebool)] 1.0 # 自环 deg np.diag(A.sum(axis1) ** -0.5) A_norm deg A deg # D^-1/2 A D^-1/2加入自环是图卷积的惯例不然每次消息传递后节点会丢失自身特征。对称归一化能平衡度数差异比如躯干节点连接十几条边指尖节点只有一条边直接聚合会让高连接度节点主导特征。后面的模型代码里直接使用A_norm。提示如果你用的是 COCO 17 点或自研关键点只要保证edges里的索引与坐标数组第二轴一一对应邻接矩阵构建逻辑完全一样。2.3 动作样本怎么组成张量N、C、T、V四个轴一个视频不是一帧而是几十帧关键点序列。ST-GCN 的输入张量固定为(N, C, T, V)N是样本数C是特征维数x、y、z 或再加置信度T是时间帧数V是节点数。模型不在乎关键点从哪来只要求第四轴索引和邻接矩阵一致。视频长度通常不固定训练前要采样成固定帧数def frame_sampling(coords, target_t32): t len(coords) if t target_t: idx np.linspace(0, t - 1, target_t).astype(int) else: idx np.arange(t) pad np.repeat(idx[-1], target_t - t) idx np.concatenate([idx, pad]) return coords[idx] # coords shape: (100, 33, 3) - (32, 33, 3) sample frame_sampling(coords, target_t32) sample sample.transpose(1, 0, 2) # (V, T, C) sample sample.transpose(2, 0, 1) # (C, V, T) sample sample[None] # (1, C, V, T)上面这个结果还需要再permute成(1, C, T, V)才能进入模型。为了统一我更建议把所有预处理后的动作保存成.npz字段名写死为keypoints形状是(T, V, C)。这样数据集部分只需要负责采样和组装不用在训练里反复看视频。3. ST-GCN网络结构图卷积与时空卷积的PyTorch实现3.1 图卷积带可学习mask的实现图卷积的核心表达式是X D^-1/2 A D^-1/2 X W其中X是当前层的节点特征W是共享的 1x1 卷积权重。原始 ST-GCN 还在邻接矩阵上叠加了一个可学习的 mask让网络自己决定哪条边更重要。下面给出一个可读性优先的实现import torch import torch.nn as nn class GraphConv(nn.Module): def __init__(self, in_channels, out_channels, A): super().__init__() self.register_buffer(A, A) self.mask nn.Parameter(torch.ones_like(A)) self.conv nn.Conv2d(in_channels, out_channels, kernel_size1) def forward(self, x): # x shape: (N, C, T, V) A self.A * torch.relu(self.mask) x torch.einsum(nctv,vw-nctw, x, A) return self.conv(x)register_buffer把归一化邻接矩阵变成模型的一部分但不会参与梯度更新。mask 初始值为 1乘以relu限定非负避免反向传播把邻接关系学成负数。einsum里的nctv,vw-nctw意思是让当前时间帧的每个节点特征沿边聚合到邻居W随后对特征维做混合。你可以把 mask 改成nn.Parameter(torch.zeros_like(A))再relu效果等价但全 1 初始化更容易保证训练初期稳定。3.2 时空卷积块时间维度只做一维卷积图卷积只处理了空间关系动作还需要跨帧建模。ST-GCN 的标准做法是在图卷积后面接一个时间维的一维卷积卷积核长度常用 9。为了减少参数量这里选择 kernel 为(9, 1)的二维卷积因为第二维长度是 1实际只在时间轴滑动。class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A, stride1, kernel_size9): super().__init__() self.gcn GraphConv(in_channels, out_channels, A) self.tcn nn.Sequential( nn.Conv2d(out_channels, out_channels, kernel_size(kernel_size, 1), stride(stride, 1), padding((kernel_size - 1) // 2, 0)), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), nn.Dropout(0.2), ) def forward(self, x): return self.tcn(self.gcn(x))padding只在时间轴设置空间轴为 0因此节点数量始终不变。stride大于 1 时时间长度减半但通道数会增加模拟池化层的分辨率下降。Dropout 放在 BatchNorm 和 ReLU 之后比较常见训练时能明显防过拟合。3.3 组装成分类网络并验证前向传播下面把三个时空块堆成一个小型 ST-GCN适配 33 个节点、6 分类动作class STGCN(nn.Module): def __init__(self, num_classes6, in_channels3, num_nodes33, ANone): super().__init__() self.data_bn nn.BatchNorm1d(in_channels * num_nodes) self.block1 STGCNBlock(in_channels, 64, A) self.block2 STGCNBlock(64, 128, A, stride2) self.block3 STGCNBlock(128, 256, A, stride2) self.pool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(256, num_classes) def forward(self, x): N, C, T, V x.shape x x.permute(0, 3, 1, 2).reshape(N, V * C, T) x self.data_bn(x) x x.reshape(N, V, C, T).permute(0, 2, 3, 1) x self.block1(x) x self.block2(x) x self.block3(x) x self.pool(x).view(N, -1) return self.fc(x)注意data_bn对每个关键点的特征通道做归一化输入维度是V*C。前向验证时用随机数据跑一遍能提前发现维度不对的问题A_tensor torch.from_numpy(A_norm).float() model STGCN(num_classes6, in_channels3, num_nodes33, AA_tensor) out model(torch.randn(4, 3, 32, 33)) print(out.shape) # torch.Size([4, 6])各模块参数量大致如下表读者可根据自己的数据量调整维度。模块输入形状输出形状作用data BN(N, VC, T)同左关键点特征归一化block1(N,3,T,V)(N,64,T,V)空间聚合与时间建模block2(N,64,T,V)(N,128,T//2,V)时间下采样block3(N,128,T//2,V)(N,256,T//4,V)高层语义提取poolfc(N,256,T//4,V)(N,6)全局池化分类时间长度按T32计算经过两个 stride2 的输出是 8 帧自适应池化不影响分类。如果你的输入帧数是奇数AdaptiveAvgPool2d也能自动处理不需要额外 padding。4. 训练ST-GCN数据增强、损失函数和超参数的调法4.1 用Dataset封装npz文件写训练脚本不急着套框架一个标准的torch.utils.data.Dataset就够用。把每个动作样本存成(T, V, C)的npz标签用整数编号from torch.utils.data import Dataset class SkeletonDataset(Dataset): def __init__(self, file_list, label_list, target_t32): self.files file_list self.labels label_list self.target_t target_t def __len__(self): return len(self.files) def __getitem__(self, idx): coords np.load(self.files[idx])[keypoints].astype(np.float32) coords frame_sampling(coords, self.target_t) # (T, V, C) x torch.from_numpy(coords.transpose(2, 0, 1)) # (C, T, V) y self.labels[idx] return x, yfile_list和label_list要一一对应。这里只做了最小封装加了其他预处理函数之后训练代码不会出现任何视频读取逻辑出 Bug 的概率会低很多。4.2 骨骼数据增强抖动、时间遮掩和速度扰动骨骼数据量通常不大增强直接作用在坐标上非常便宜。最常用的三种是高斯抖动、时间片段随机遮掩、关键点整体旋转。下面给出一个可插拔函数def augment_skeleton(x, noise0.02, mask_prob0.1): # x shape: (T, V, C) x x np.random.normal(0, noise, sizex.shape) T x.shape[0] keep np.random.rand(T) mask_prob x x * keep[..., None, None] # 随机旋转水平角 theta np.random.uniform(-0.15, 0.15) rot np.array([ [np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1], ], dtypenp.float32) x x rot.T return x注意旋转操作要求坐标以骨盆或髋部中点为中心否则身体会绕原点甩出去。如果坐标来自 MediaPipe最好先全部减去 11 号点的坐标再增强训练完推理时也做同样的去中心化。片段遮掩只把某几帧置零让模型学会从残缺序列里推断动作对真实摄像头丢帧有很好的鲁棒性。4.3 训练循环与检查点保存训练超参不必一开始就找最先进的先用一组稳定配置跑通再逐步调。下面这段是精简版训练循环epochs 100 model.train() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxepochs) criterion nn.CrossEntropyLoss(label_smoothing0.1) for epoch in range(epochs): total_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * len(x) scheduler.step() if (epoch 1) % 10 0: torch.save({ epoch: epoch, state_dict: model.state_dict(), optimizer: optimizer.state_dict(), loss: total_loss / len(train_loader.dataset), }, epoch_%03d.pt % (epoch 1)) print(epoch %d loss %.4f % (epoch 1, total_loss / len(train_loader.dataset)))CosineAnnealingLR会让学习率先保持在中段后期平滑下降比固定学习率的训练更稳。label_smoothing0.1让正确类别的目标概率变成 0.9其余 0.1 分给其他类能减少模型对训练标签过度自信对小数据集尤其有效。每 10 个 epoch 保存一次完整 checkpoint字典里包含 epoch、优化器状态和 loss这样后来的实验可以恢复到任意断点继续训练。4.4 超参数从哪组开始调下表是一组经过大量 ST-GCN 复现验证的起点适用 6 类动作、几千个样本级别的小项目。超参数推荐值失败时怎么调帧数 T32精度不够时改成 48过拟合时降到 24学习率 lr0.01模型不收敛时改 0.001batch size32显存不足时改成 16优化器SGD momentum0.9换成 Adam lr0.001weight decay1e-4严重过拟合时加到 5e-4节点数 V33可裁剪到手部或上半身减少噪声dropout0.2数据集很小时提高到 0.5训练日志不要只看 loss每个 epoch 结束顺便记录下验证集 top-1 准确率。如果 train loss 一直在降、val acc 不升就是过拟合信号如果两者同步不高优先怀疑数据标签或邻接矩阵建错了而不是调参。骨骼动作识别的特征空间比图像小模型结构不当往往表现为训练 loss 卡在某个值附近不下降此时先检查输入坐标是否被正确归一化。5. 用训练好的ST-GCN做推理把识别结果画到视频上5.1 加载权重和分类推理训练完的模型在推理阶段不要加载 optimizer 状态只取state_dict。输入侧依然走关键点提取、采样、归一化三步import torch model STGCN(num_classes6, in_channels3, num_nodes33, AA_tensor) state torch.load(epoch_100.pt, map_locationcpu) model.load_state_dict(state[state_dict]) model.eval() def predict(clip): # clip shape: (T, V, C) clip clip - clip.mean(axis(1, 2), keepdimsTrue) # 去中心化 x torch.from_numpy(clip.transpose(2, 0, 1)).unsqueeze(0) with torch.no_grad(): logits model(x) prob torch.softmax(logits, dim1) return prob.argmax(dim1).item(), prob.max().item()这里的去中心化不是把每帧坐标都减去均值而是减去整段序列的时间均值能消除拍摄位置偏移带来的差别。如果你的增强代码使用了固定参考点推理时也要用同一个参考点不能两个逻辑不一致。model.eval()会关闭 BatchNorm 的均值和方差更新这是推理最常漏掉的一行。5.2 把骨架和标签画回视频帧可视化是答辩演示里最直观的部分。对当前帧调用 MediaPipe 拿到landmarks再用 OpenCV 画点和骨架线。模型需要看到一段时间的序列所以通过滑动窗口保存最近 32 帧关键点得到预测标签后再画到当前帧上。import cv2 import mediapipe as mp def draw_skeleton(frame, landmarks): for lm in landmarks.landmark: h, w, _ frame.shape cx, cy int(lm.x * w), int(lm.y * h) cv2.circle(frame, (cx, cy), 3, (0, 255, 0), -1) for i, j in mp.solutions.pose.POSE_CONNECTIONS: h, w, _ frame.shape p1 landmarks.landmark[i] p2 landmarks.landmark[j] x1, y1 int(p1.x * w), int(p1.y * h) x2, y2 int(p2.x * w), int(p2.y * h) cv2.line(frame, (x1, y1), (x2, y2), (255, 0, 0), 2) return frame点用绿色圆点线用蓝色直线颜色可以根据场景调整。POSE_CONNECTIONS是一个frozenset迭代顺序不固定但绘图不依赖顺序所以没问题。绘制前先判断landmarks是否为空否则整段视频会出现空帧闪跳。5.3 导出为ONNX模型能减轻部署负担毕业设计答辩环境不一定有完整的 PyTorch 和 MediaPipe把分类网络单独导出成 ONNX可以脱离训练脚本运行dummy torch.randn(1, 3, 32, 33) torch.onnx.export( model, dummy, stgcn.onnx, input_names[clip], output_names[logits], dynamic_axes{clip: {0: batch}}, opset_version12, ) print(saved stgcn.onnx)dynamic_axes只指定了 batch 维T和V保持固定这样可以省去处理动态时间维度的麻烦。加载时用onnxruntime.InferenceSession输入一个(batch, 3, 32, 33)的 float32 数组就能在只装了 CPU 的机器上跑。用这个方式模型文件本身大约几 MB已经能覆盖“源代码模型”这个包里演示程序的大部分需求。6. 算力不够时怎么把ST-GCN项目文档写扎实6.1 文档里放一份“从零复现”实验记录下载包里的项目文档最容易写成环境安装流水账。一份能打的文档核心是让任何一个人拿到代码之后能在不问你问题的情况下把同一组实验跑出来。建议按下面这张表组织文档章节必须写清的内容数据说明关键点来源、节点数、帧数、训练测试划分比例环境与依赖Python 版本、CUDA 版本、核心依赖清单预处理原始视频如何变成 npz采样函数入口模型结构邻接矩阵构建方式和模型代码位置训练复现启动命令、超参数、checkpoint 保存路径结果呈现混淆矩阵、各类别准确率、可视化视频很多代码包只有训练脚本和权重缺了数据说明换台机器连数据长什么样都不知道。我建议在README.md里直接放一条测试用例给出一段 5 秒的视频从关键点提取到分类输出最终预期的标签是什么。这样答辩老师和评审专家不需要逐行读代码也能验证项目完整性。6.2 CPU训练的三个缩水参数和日志落盘技巧如果手头只有普通笔记本先把训练目标降级成“能跑通、有可视化、loss 下降”。三个参数建议立刻调整T16、batch_size8、block1/2/3的通道数减半。这样模型参数量会缩到原来的四分之一左右CPU 上 50 个 epoch 也能在几小时内完成。另一个技巧是把训练日志同时输出到控制台和文件python train.py --epochs 50 --frames 16 --batch-size 8 \ --out-dir runs/exp01 21 | tee runs/exp01/train.logtee让日志既留在终端又落盘后面画 loss 曲线时可以直接解析这个文本文件不需要重新训练。日志里每行最好带上epoch、train_loss、val_acc三个字段形成结构化文本答辩报告里的实验表格就能直接从日志汇总出来。如果换到更高配的机器再逐步把帧数、通道数和 batch size 往回收模型权重和文档都不用重写。本文还有配套的精品资源点击获取