免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

基于时空图卷积的骨骼动作识别:从数据到部署的完整指南

基于时空图卷积的骨骼动作识别:从数据到部署的完整指南 简介这份资源围绕时空图卷积网络ST-GCN实现骨骼动作识别面向计算机、数学、电子信息等专业做课程设计、期末大作业或毕业设计的学生以及希望入门图卷积与人体骨架行为分析的研究者。项目包含完整 Python 源码与项目说明可直接运行调试也便于在此基础上二次开发。压缩包共 91 个文件、约 52.6MB以 29 个 py 脚本为核心配合 13 个 yaml 配置、3 个 pt 预训练权重、12 个 pyc 缓存及 gif、mp4、png 等演示素材另有 txt、md 说明文档与 sh 脚本覆盖数据生成、模型定义、训练与推理全流程。目录中可见 feeder、net、processor、tools、torchlight 等模块以及 NTU-RGB-D、Kinetics 两套骨骼数据配置和离线、实时识别演示脚本方便对照理解 ST-GCN 的图构建与时空卷积细节。目前已有 275 人学习适合作为骨骼动作识别方向的参考范例与排错思路来源。1. 从一段骨架序列说起ST-GCN 骨骼动作识别到底在解决什么问题摄像头拍到一个人挥手画面里是像素换成 Kinect 或姿态估计模型同一个人就变成了一串关节坐标。骨骼动作识别要做的就是拿这串坐标判断他在做什么。相比直接吃 RGB 视频骨骼序列天然抗背景干扰、抗光照变化数据量还小一个量级这也是它在毕设和工程落地里被反复选中的原因。而 ST-GCNSpatial Temporal Graph Convolutional Network时空图卷积网络是把「图卷积」这套思路搬到骨骼上的代表作把每一帧的人体骨架当成一张图关节是节点、骨骼是边再沿时间轴把帧与帧连起来形成一个时空图用图卷积同时抽空间结构特征和时间运动特征。这篇笔记围绕「基于时空图卷积的骨骼动作识别」这条线把数据怎么组织、模型怎么搭、训练怎么调、推理怎么落地讲清楚适合正在做相关毕业设计、或想把骨骼动作识别接进自己项目的同学。整套方案用 Python 实现依赖 PyTorch跑通不需要多卡的机器单卡甚至 CPU 小批量都能验证。2. 骨骼数据怎么变成时空图从关节坐标到邻接矩阵2.1 为什么骨骼天然适合图卷积而不是普通卷积普通 CNN 处理图像时卷积核在一个规则网格上滑动每个像素的邻居数量固定。骨骼不是网格手肘连着肩膀和手腕但髋关节和手腕之间没有直接连接每个关节的邻居数不一样强行排成矩阵会丢掉拓扑关系。图卷积的核心思想是不要求邻居数量固定而是用邻接矩阵描述「谁和谁相连」卷积时按邻接关系聚合邻居特征。人体骨架正好是一张天然的图关节是节点骨骼是边这就是 ST-GCN 的出发点。时空图在此基础上多了一个维度。假设一段动作有 T 帧每帧 V 个关节那么节点总数是 T×V。空间边是同一帧内关节之间的连接时间边是同一关节在相邻帧之间的连接。模型要学的就是在这张 T×V 的图上做卷积空间维聚合关节时间维聚合帧。理解这一点后面所有代码和参数才有落脚点。2.2 用 Python 构建邻接矩阵三种分区策略ST-GCN 的关键设计之一是「空间配置分区」spatial configuration partitioning。它不只用一张原始邻接矩阵而是按关节到重心的距离把邻居分成三类向心、离心、自身。这样卷积核能区分「靠近身体中心」和「远离身体中心」的运动方向对识别挥手、踢腿这类动作很关键。下面这段代码演示如何从骨架连接关系构建邻接矩阵并做三种分区。实际项目里骨架定义哪些关节相连通常写在一个配置文件里这里用简化版说明逻辑。import numpy as np # 假设 17 个关节edges 是骨骼连接对父节点, 子节点 num_node 17 edges [(0,1),(1,2),(2,3),(1,4),(4,5),(5,6),(1,7),(7,8),(8,9), (1,10),(10,11),(11,12),(10,13),(13,14),(14,15),(13,16)] # 1. 构建基础邻接矩阵无向 A np.zeros((num_node, num_node)) for i, j in edges: A[i, j] 1 A[j, i] 1 # 2. 计算每个节点到重心的距离用于分区 center 0 # 通常取髋部中心这里简化为节点 0 def bfs_distance(adj, root): dist -np.ones(adj.shape[0], dtypeint) dist[root] 0 queue [root] while queue: cur queue.pop(0) for nxt in np.where(adj[cur] 0)[0]: if dist[nxt] -1: dist[nxt] dist[cur] 1 queue.append(nxt) return dist dist bfs_distance(A, center) # 3. 三种分区向心(距离更小)、离心(距离更大)、自身 A_part np.zeros((3, num_node, num_node)) for i in range(num_node): for j in np.where(A[i] 0)[0]: if dist[j] dist[i]: A_part[0, i, j] 1 # 向心 elif dist[j] dist[i]: A_part[1, i, j] 1 # 离心 else: A_part[2, i, j] 1 # 自身/同层 print(A_part.shape) # (3, 17, 17)逻辑说明先由骨骼连接对构建无向邻接矩阵再用 BFS 算出每个关节到重心的跳数距离最后按距离大小把每条边归入三个分区之一。参数说明num_node必须和你的姿态估计输出关节数一致常见有 17COCO、25NTU RGBDcenter是重心节点索引选错会让分区失去物理意义一般取两个髋关节的中点或直接取髋部节点。分区数 3 是 ST-GCN 论文的默认值改成 1 就退化成普通 GCN识别精度通常会掉几个点。2.3 数据加载把 .npy 骨架序列喂进 Dataset骨骼数据一般存成形状(N, C, T, V, M)的张量N 是样本数C 是通道坐标 x,y 加置信度通常 3T 是帧数V 是关节数M 是人数单人动作取 1。用 PyTorch 的 Dataset 封装时重点是统一帧长和归一化。import torch from torch.utils.data import Dataset class SkeletonDataset(Dataset): def __init__(self, data_path, label_path, max_frames300): self.data np.load(data_path) # (N, C, T, V, M) self.label np.load(label_path) # (N,) self.max_frames max_frames def __len__(self): return len(self.label) def __getitem__(self, idx): seq self.data[idx] # (C, T, V, M) C, T, V, M seq.shape # 帧长对齐不足补零超出均匀采样 if T self.max_frames: pad np.zeros((C, self.max_frames - T, V, M), dtypeseq.dtype) seq np.concatenate([seq, pad], axis1) else: index np.linspace(0, T - 1, self.max_frames).astype(int) seq seq[:, index] # 归一化以髋部为原点除以躯干长度 seq seq - seq[:, :, 0:1, :] # 简化以 0 号关节为参考 return torch.tensor(seq, dtypetorch.float32), int(self.label[idx])逻辑说明__getitem__里做了两件必须做的事——帧长对齐和坐标归一化。参数说明max_frames是统一帧长NTU 这类数据集常用 300太小会截断长动作太大浪费显存归一化用髋部做原点是为了消除拍摄距离和身高差异这一步不做模型很容易学到「人站得远还是近」这种无关特征。注意np.linspace采样是均匀抽帧对快速动作可能丢关键帧追求精度时可以改成按动作能量采样。3. ST-GCN 模型搭建图卷积层与时间卷积怎么拼3.1 单层时空图卷积的实现细节一层 ST-GCN 由两部分串联先做空间图卷积聚合关节再做时间卷积聚合帧。空间部分的核心公式是输出 Σ_k 邻接矩阵_k × 输入 × 权重_kk 遍历三个分区。时间部分就是一个kernel_size × 1的普通二维卷积在 T 维上滑动。import torch.nn as nn import torch.nn.functional as F class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A, stride1, residualTrue): super().__init__() self.A nn.Parameter(torch.tensor(A, dtypetorch.float32), requires_gradFalse) # 三个分区的可学习权重 self.conv_a nn.ModuleList([ nn.Conv2d(in_channels, out_channels, 1) for _ in range(A.shape[0]) ]) # 时间卷积kernel_size9 是 ST-GCN 常用值 self.tcn nn.Sequential( nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), nn.Conv2d(out_channels, out_channels, (9, 1), (stride, 1), (4, 0)), nn.BatchNorm2d(out_channels), ) self.stride stride 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, 1, (stride, 1)), nn.BatchNorm2d(out_channels), ) def forward(self, x): # x: (N, C, T, V) res self.residual(x) N, C, T, V x.shape x x.permute(0, 2, 3, 1).contiguous() # (N, T, V, C) out 0 for k in range(self.A.shape[0]): # 邻接矩阵乘聚合邻居关节 agg torch.einsum(nvc,vw-nwc, x, self.A[k]) out out self.conv_a[k](agg.permute(0, 3, 1, 2)) out out.permute(0, 2, 3, 1) # 回到 (N, C, T, V) out self.tcn(out) return F.relu(out res)逻辑说明einsum那行是空间聚合把每个关节的特征按邻接矩阵加权求和到邻居上conv_a是每个分区独立的 1×1 卷积负责变换通道tcn在时间维做 kernel 为 9 的卷积。参数说明kernel_size9覆盖约 0.3 秒的动作片段30fps 下改小感受野变窄改大会增加计算量stride控制时间下采样通常前几层为 1后几层为 2residual在通道数或步长变化时必须用带卷积的残差分支否则相加维度对不上。这里A用requires_gradFalse固定住进阶做法是让它可学习能自适应调整图结构。3.2 整体网络结构与分类头把若干 STGCNBlock 堆起来最后做全局池化和全连接分类。典型配置是 10 层通道数从 64 递增到 256。class STGCN(nn.Module): def __init__(self, num_class, A, in_channels3): super().__init__() self.data_bn nn.BatchNorm1d(in_channels * A.shape[1]) self.layers nn.ModuleList([ STGCNBlock(in_channels, 64, A), STGCNBlock(64, 64, A), STGCNBlock(64, 64, A), STGCNBlock(64, 128, A, stride2), STGCNBlock(128, 128, A), STGCNBlock(128, 128, A), STGCNBlock(128, 256, A, stride2), STGCNBlock(256, 256, A), STGCNBlock(256, 256, A), ]) self.fc nn.Linear(256, num_class) def forward(self, x): # x: (N, C, T, V, M) N, C, T, V, M x.shape x x.permute(0, 4, 3, 1, 2).contiguous().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).contiguous() x x.view(N * M, C, T, V) for layer in self.layers: x layer(x) x F.avg_pool2d(x, x.shape[2:]) # 全局池化 x x.view(N, M, -1).mean(dim1) return self.fc(x)逻辑说明data_bn对输入做一次批归一化稳定训练中间 9 层逐步提取时空特征两次 stride2 把时间维压缩到约 1/4最后全局平均池化把(N, C, T, V)压成(N, C)再分类。参数说明num_class是你的动作类别数NTU 是 60 或 120自建数据集按实际填通道数 64/128/256 是精度和显存的折中显存紧张可以整体减半但别低于 32否则欠拟合明显。注意输入是 5 维多人场景 M1 时这里用mean聚合单人动作 M1 不影响。4. 训练与调参让模型真正收敛的几个关键设置4.1 训练循环与学习率策略ST-GCN 训练对学习率比较敏感用错策略很容易出现 loss 不降或震荡。常见做法是 SGD 余弦退火前几轮 warmup。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) model STGCN(num_class60, AA_part).to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50) for epoch in range(50): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() out model(x) loss criterion(out, y) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() correct total 0 with torch.no_grad(): for x, y in val_loader: x, y x.to(device), y.to(device) pred model(x).argmax(dim1) correct (pred y).sum().item() total y.size(0) print(fepoch {epoch}, val acc {correct/total:.4f})逻辑说明标准训练循环每轮结束跑一次验证算准确率。参数说明初始学习率 0.1 配 SGD 是 ST-GCN 论文的设置换 Adam 的话降到 1e-3weight_decay1e-4抑制过拟合T_max50要和总 epoch 一致否则余弦退火周期对不上。如果 loss 在前几轮就爆掉先检查输入归一化再考虑加 warmup。4.2 必调的四个参数与显存权衡参数常用值调大影响调小影响batch_size16~64显存涨梯度更稳显存省可能震荡max_frames300保留长动作显存涨省显存丢时序信息学习率0.1(SGD)收敛快但易震荡稳但慢通道数64/128/256精度高显存大省显存易欠拟合显存不够时优先降 batch_size 和 max_frames这两个对显存影响最直接通道数最后动因为它直接决定模型容量。单卡 8G 显存跑 60 类、batch 16、300 帧基本够用。4.3 数据增强骨骼序列能怎么扩骨骼数据不像图像那么好做增强但有几招实用随机旋转绕垂直轴、随机缩放、随机平移、随机抽帧。旋转和缩放能模拟不同拍摄角度和距离抽帧能提升对帧率变化的鲁棒性。注意别做水平翻转除非你的动作本身左右对称否则「左手挥手」翻成「右手挥手」标签就错了这是血泪经验。5. 避坑与排查骨骼动作识别里最容易翻车的五件事5.1 准确率卡在随机水平不涨现象训练几十轮验证准确率一直在 1/类别数 附近。原因多半是标签和数据的对应关系错了或者归一化把有效信息抹掉了。解决先拿 10 个样本过拟合如果连 10 个都记不住一定是数据管道问题检查 label 是否从 0 连续编号检查归一化后坐标是否全变成 0。5.2 换数据集后精度暴跌现象在 NTU 上训好的模型换到自采数据精度掉一半。原因关节定义不一致17 关节 vs 25 关节、坐标系不同有的 y 轴朝上有的朝下、帧率不同。解决统一关节定义重算邻接矩阵确认坐标系方向必要时翻转某个轴重采样到统一帧率。5.3 训练 loss 正常但推理结果全一样现象训练时 loss 在降推理时所有输入输出同一类。原因推理时忘了切model.eval()BatchNorm 用了训练时的统计量或者输入没有做和训练一致的归一化。解决推理前model.eval()加torch.no_grad()并把训练时的归一化参数固定下来在推理时复用。5.4 显存溢出OOM现象跑几个 batch 就报 CUDA out of memory。原因max_frames 或 batch_size 太大或者验证时没关梯度。解决降 batch_size 到 8 试验证循环包在torch.no_grad()里必要时用梯度累积模拟大 batch。5.5 邻接矩阵维度和关节数对不上现象报错 shape mismatch矩阵是 17×17 但数据是 25 个关节。原因骨架定义文件和实际数据不一致。解决把邻接矩阵构建和数据集关节数绑在一起写个断言assert A.shape[1] V早报错早发现。6. 推理落地与精度再提一档的实用技巧模型训完只是开始真正要用起来还得解决推理速度和精度。先说推理单样本推理时把max_frames对齐到训练值输入形状保持(1, C, T, V, 1)用torch.no_grad()包住CPU 上单帧推理大概几十毫秒GPU 上可以做到实时。如果要接摄像头建议把姿态估计和 ST-GCN 分成两个进程姿态估计出骨架、ST-GCN 吃骨架中间用队列缓冲避免互相阻塞。精度再提一档有几个我实际用过有效的招。第一把固定的邻接矩阵改成可学习的自适应图让模型自己调整关节连接权重通常能涨 1~2 个点。第二加多流融合除了关节坐标再算一遍骨骼向量关节之间的差和关节运动速度三个流各训一个模型推理时分数相加这是 ST-GCN 系列里性价比最高的提点手段。第三测试时增强TTA对同一段序列做几次不同采样取平均。# 多流融合推理示例 def ensemble_predict(model_joint, model_bone, x_joint, x_bone): model_joint.eval(); model_bone.eval() with torch.no_grad(): p1 F.softmax(model_joint(x_joint), dim1) p2 F.softmax(model_bone(x_bone), dim1) # 骨骼流权重通常略低 return (p1 0.8 * p2).argmax(dim1)逻辑说明两个模型分别对关节流和骨骼流输出概率加权求和后取最大。参数说明骨骼流权重 0.8 是经验值可以在验证集上网格搜一下范围 0.5~1.0。注意两个模型的输入要来自同一段序列时间对齐不能错。最后说个习惯每次改完数据管道或模型结构先拿一个小数据集跑 5 个 epoch 看 loss 曲线确认能过拟合再上全量。我吃过太多次「训了一晚上发现数据加载写错」的亏这个后悔药提前吃比事后补强。骨骼动作识别这条线不算深但细节多把数据、邻接矩阵、归一化这三件事抠死剩下的就是调参和堆数据。希望帮到你。本文还有配套的精品资源点击获取
返回列表