☰
基于时空图卷积的骨骼动作识别:从数据到部署的完整指南
2026/10/5 3:28:53 网站建设 项目流程

简介:这份资源围绕时空图卷积网络(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-GCN(Spatial 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], dtype=int) 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必须和你的姿态估计输出关节数一致,常见有 17(COCO)、25(NTU RGB+D);center是重心节点索引,选错会让分区失去物理意义,一般取两个髋关节的中点或直接取髋部节点。分区数 3 是 ST-GCN 论文的默认值,改成 1 就退化成普通 GCN,识别精度通常会掉几个点。

2.3 数据加载:把 .npy 骨架序列喂进 Dataset

骨骼数据一般存成形状(N, C, T, V, M)的张量,N 是样本数,C 是通道(坐标 x,y 加置信度,通常 3),T 是帧数,V 是关节数,M 是人数(单人动作取 1)。用 PyTorch 的 Dataset 封装时,重点是统一帧长和归一化。

import torch from torch.utils.data import Dataset class SkeletonDataset(Dataset): def __init__(self, data_path, label_path, max_frames=300): 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), dtype=seq.dtype) seq = np.concatenate([seq, pad], axis=1) 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, dtype=torch.float32), int(self.label[idx])

逻辑说明:__getitem__里做了两件必须做的事——帧长对齐和坐标归一化。参数说明:max_frames是统一帧长,NTU 这类数据集常用 300,太小会截断长动作,太大浪费显存;归一化用髋部做原点是为了消除拍摄距离和身高差异,这一步不做,模型很容易学到「人站得远还是近」这种无关特征。注意np.linspace采样是均匀抽帧,对快速动作可能丢关键帧,追求精度时可以改成按动作能量采样。

3. ST-GCN 模型搭建:图卷积层与时间卷积怎么拼

3.1 单层时空图卷积的实现细节

一层 ST-GCN 由两部分串联:先做空间图卷积(聚合关节),再做时间卷积(聚合帧)。空间部分的核心公式是输出 = Σ_k 邻接矩阵_k × 输入 × 权重_k,k 遍历三个分区。时间部分就是一个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, stride=1, residual=True): super().__init__() self.A = nn.Parameter(torch.tensor(A, dtype=torch.float32), requires_grad=False) # 三个分区的可学习权重 self.conv_a = nn.ModuleList([ nn.Conv2d(in_channels, out_channels, 1) for _ in range(A.shape[0]) ]) # 时间卷积:kernel_size=9 是 ST-GCN 常用值 self.tcn = nn.Sequential( nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), 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_size=9覆盖约 0.3 秒的动作片段(30fps 下),改小感受野变窄,改大会增加计算量;stride控制时间下采样,通常前几层为 1,后几层为 2;residual在通道数或步长变化时必须用带卷积的残差分支,否则相加维度对不上。这里A用requires_grad=False固定住,进阶做法是让它可学习,能自适应调整图结构。

3.2 整体网络结构与分类头

把若干 STGCNBlock 堆起来,最后做全局池化和全连接分类。典型配置是 10 层,通道数从 64 递增到 256。

class STGCN(nn.Module): def __init__(self, num_class, A, in_channels=3): 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, stride=2), STGCNBlock(128, 128, A), STGCNBlock(128, 128, A), STGCNBlock(128, 256, A, stride=2), 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(dim=1) return self.fc(x)

逻辑说明:data_bn对输入做一次批归一化,稳定训练;中间 9 层逐步提取时空特征,两次 stride=2 把时间维压缩到约 1/4;最后全局平均池化把(N, C, T, V)压成(N, C),再分类。参数说明:num_class是你的动作类别数,NTU 是 60 或 120,自建数据集按实际填;通道数 64/128/256 是精度和显存的折中,显存紧张可以整体减半,但别低于 32,否则欠拟合明显。注意输入是 5 维,多人场景 M>1 时这里用mean聚合,单人动作 M=1 不影响。

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_class=60, A=A_part).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50) 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(dim=1) correct += (pred == y).sum().item() total += y.size(0) print(f'epoch {epoch}, val acc {correct/total:.4f}')

逻辑说明:标准训练循环,每轮结束跑一次验证算准确率。参数说明:初始学习率 0.1 配 SGD 是 ST-GCN 论文的设置,换 Adam 的话降到 1e-3;weight_decay=1e-4抑制过拟合;T_max=50要和总 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), dim=1) p2 = F.softmax(model_bone(x_bone), dim=1) # 骨骼流权重通常略低 return (p1 + 0.8 * p2).argmax(dim=1)

逻辑说明:两个模型分别对关节流和骨骼流输出概率,加权求和后取最大。参数说明:骨骼流权重 0.8 是经验值,可以在验证集上网格搜一下,范围 0.5~1.0。注意两个模型的输入要来自同一段序列,时间对齐不能错。

最后说个习惯:每次改完数据管道或模型结构,先拿一个小数据集跑 5 个 epoch 看 loss 曲线,确认能过拟合再上全量。我吃过太多次「训了一晚上发现数据加载写错」的亏,这个后悔药提前吃比事后补强。骨骼动作识别这条线不算深,但细节多,把数据、邻接矩阵、归一化这三件事抠死,剩下的就是调参和堆数据。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询