简介:本资源为基于ST-GCN自适应图卷积网络的人体动作识别系统Python源码包,面向计算机、人工智能、数据科学等专业的在校学生、教师及企业开发者,可用于课程设计、毕业设计、大作业或项目立项演示,帮助理解时空图卷积在骨骼关节点动作识别中的应用。压缩包共476个文件,约43.69MB,以258个py源码为核心,辅以60个yaml配置、26个txt说明、22个gif与14个mp4演示素材,以及npy、pkl数据文件和cu、pyx等扩展模块,覆盖模型定义、训练配置、数据预处理与推理演示等环节。项目代码完整且功能验证通过,配有使用说明,已有284人学习下载。读者可据此掌握ST-GCN自适应图卷积的建模思路与工程实现,快速复现动作识别流程,并在此基础上进行二次开发或功能DIY。
1. 从一段骨架数据到动作标签:ST-GCN 自适应图卷积到底在解决什么
假设你手里有一段用 Kinect 或 MediaPipe 采出来的骨架序列:30 帧,每帧 18 个关节点,每个点带 x、y、z 和置信度。你想让机器判断这 30 帧到底是「挥手」「下蹲」还是「跌倒」。传统做法是把骨架拍平成向量丢进 LSTM,或者把关节坐标当图像塞进 CNN,但这两条路都有硬伤——LSTM 抓不住人体左右手之间的空间关系,CNN 又把关节当成规则网格上的像素,忽略了「手只和肘相连、和脚几乎无关」这种拓扑结构。ST-GCN(Spatial-Temporal Graph Convolutional Network,时空图卷积网络)就是冲着这个痛点来的:它把每一帧的人体骨架建成一张图,关节点是图的节点,骨骼是图的边,再沿时间轴把连续帧连起来,形成一个时空图,然后在图上做卷积。而「自适应图卷积」是在原始 ST-GCN 基础上更进一步——不再死板地按人体物理连接定义邻接矩阵,而是让网络自己学出「哪些关节该互相影响」的权重。这套 Python 源码加使用说明的组合,适合做行为识别、康复评估、体育动作打分、安防异常行为检测的工程师,也适合刚入门 python、想找一个能跑通的深度学习项目练手的同学。下面我按「先讲清原理和选型、再给可复现步骤、最后说坑」的顺序,把这条路走一遍。
2. 自适应图卷积的骨架建模:邻接矩阵怎么从固定变成可学
2.1 原始 ST-GCN 的固定分区策略与它的天花板
原始 ST-GCN 的做法很直接:对每个关节,按人体骨架的自然连接,把它的邻居分成三个子集——自己、向心邻居(离骨架重心更近的)、离心邻居(更远的)。然后给这三个子集各配一个卷积核权重,做加权求和。这个分区策略在 NTU RGB+D 这类标准数据集上能到 80% 出头的准确率,但它有个隐含假设:所有人做同一个动作时,关节之间的影响模式是一样的。现实里不是这样——一个人挥手可能主要靠肩和肘联动,另一个人可能腕部甩得更凶。固定邻接矩阵没法表达这种个体差异,也没法表达「做这个动作时,左手和右髋其实有隐性协同」这种非物理连接。
2.2 自适应图卷积的三种实现路径
自适应图卷积的核心思想是:邻接矩阵不写死,让网络学。常见做法有三类。第一类是全学习式,直接定义一个可训练的 N×N 矩阵(N 是关节数),初始化为单位矩阵或物理邻接矩阵,训练中更新。第二类是相似度式,用两个嵌入函数把节点特征映射到同一空间,做点积再归一化,得到数据依赖的邻接矩阵。第三类是残差式,把物理邻接矩阵和可学习矩阵相加,既保留先验结构又允许调整。源码里通常会把这三类组合起来用,公式大致是:
A_out = A_physical + alpha * A_learned + beta * A_similarity其中 alpha、beta 是可学习或预设的缩放系数。这样网络既有人体骨架的先验知识兜底,又能根据训练数据微调连接强度。
2.3 用 Python 搭一个最小可跑的自适应图卷积层
下面这段代码用 PyTorch 实现一个自适应图卷积层,输入是 (N, C, T, V) 的张量,N 是 batch,C 是通道,T 是帧数,V 是关节数。代码里保留了物理邻接矩阵作为残差项,同时加了一个可学习的全局邻接矩阵。
import torch import torch.nn as nn import numpy as np class AdaptiveGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_nodes, physical_adj=None): super().__init__() self.num_nodes = num_nodes # 可学习的全局邻接矩阵,初始化为单位矩阵 self.A_learned = nn.Parameter(torch.eye(num_nodes)) # 物理邻接矩阵作为残差,不参与梯度更新 if physical_adj is not None: self.register_buffer('A_physical', physical_adj) else: self.register_buffer('A_physical', torch.eye(num_nodes)) # 1x1 卷积做通道变换 self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1) self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU() def forward(self, x): # x: (N, C, T, V) N, C, T, V = x.shape # 融合物理邻接和可学习邻接 A = self.A_physical + self.A_learned # 对节点维度做图卷积:x @ A^T x = torch.einsum('nctv,vw->nctw', x, A) x = self.conv(x) x = self.bn(x) return self.relu(x)逻辑说明:einsum那行是图卷积的核心,把每个节点的特征按邻接矩阵的权重聚合到邻居上。A_learned用nn.Parameter声明,训练时会自动更新。A_physical用register_buffer注册,保证它跟着模型走 GPU 但不被优化器改动。参数方面,in_channels和out_channels按你的网络宽度设,常见是 64、128、256 逐层翻倍;num_nodes取决于你用哪套骨架,NTU 是 25,MediaPipe 是 33,OpenPose 是 18。如果显存吃紧,可以把A_learned换成低秩分解,用两个 (V, r) 的矩阵相乘代替 (V, V),r 取 8 或 16。
3. 从骨架数据到训练闭环:数据准备、模型组装与训练脚本
3.1 骨架数据的两种常见格式与对齐方式
你拿到的骨架数据大概率是两种格式之一:一种是每帧一个 JSON 或 npy,里面存 (V, C) 的数组;另一种是整个序列存成一个 (T, V, C) 的 npy。不管哪种,喂给网络前都要统一成 (C, T, V, N) 或 (N, C, T, V)。对齐要注意三点:关节顺序必须和你的物理邻接矩阵一致,帧率要统一(常见 30fps,如果原始是 60fps 就隔帧采样),序列长度要统一(短了补零,长了随机裁或均匀采样)。下面这个预处理脚本把变长序列统一到固定帧数。
import numpy as np def uniform_sample(seq, target_len=30): # seq: (T, V, C) T = seq.shape[0] if T == target_len: return seq indices = np.linspace(0, T - 1, target_len).astype(int) return seq[indices] def pad_or_crop(seq, target_len=30): T = seq.shape[0] if T >= target_len: return uniform_sample(seq, target_len) pad = np.zeros((target_len - T, seq.shape[1], seq.shape[2])) return np.concatenate([seq, pad], axis=0) # 假设 raw 是 (T, V, C),V=25,C=3 raw = np.random.randn(45, 25, 3) fixed = pad_or_crop(raw, 30) # 输出 (30, 25, 3) # 转成 (C, T, V) fixed = fixed.transpose(2, 0, 1) print(fixed.shape) # (3, 30, 25)逻辑说明:uniform_sample用等间隔索引把任意长度序列压到目标长度,比直接截断保留更多时间信息。pad_or_crop处理短序列时补零,补零帧在后续可以加一个 mask 让网络忽略,但简单任务里直接补零也能跑。参数target_len一般取 30 到 300,取决于动作持续时间,NTU 里常见 300 帧,自采数据 30 到 60 帧就够。
3.2 组装一个带自适应图卷积的 ST-GCN 主干
一个完整的 ST-GCN 块通常包含:自适应图卷积层(空间维度)+ 时间卷积层(沿 T 方向做 1D 卷积)+ 残差连接。下面把这两层串起来,堆 9 个块,最后全局池化接全连接分类。
class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, num_nodes, stride=1, residual=True): super().__init__() self.gcn = AdaptiveGraphConv(in_channels, out_channels, num_nodes) # 时间卷积:kernel_size=9 是 ST-GCN 原文的常见设置 self.tcn = nn.Sequential( nn.BatchNorm2d(out_channels), nn.ReLU(), nn.Conv2d(out_channels, out_channels, kernel_size=(9, 1), stride=(stride, 1), padding=(4, 0)), 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_size=1, stride=(stride, 1)), nn.BatchNorm2d(out_channels), ) self.relu = nn.ReLU() def forward(self, x): res = self.residual(x) x = self.gcn(x) x = self.tcn(x) return self.relu(x + res)逻辑说明:tcn里的卷积核是 (9, 1),表示在时间轴上卷 9 帧,空间轴不卷(空间已经由 GCN 处理)。stride用来在时间维度下采样,通常在第 5 和第 8 个块设成 2。残差连接保证梯度能传回去,in_channels != out_channels时用 1x1 卷积对齐通道。参数上,kernel_size取 9 是经验值,取 5 或 7 也能跑但感受野变小;stride别设太大,否则时间信息丢太快。
3.3 训练脚本的关键参数与损失曲线观察
训练时我一般用 Adam,学习率 1e-3,权重衰减 1e-4,batch size 16 或 32,跑 50 到 80 个 epoch。损失用交叉熵,如果类别不均衡就加 class weight。下面是一个最小训练循环。
import torch.optim as optim model = STGCN(num_classes=10, num_nodes=25).cuda() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() for epoch in range(80): model.train() for x, y in train_loader: x, y = x.cuda(), y.cuda() optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() # 每 10 个 epoch 打印一次验证准确率 if epoch % 10 == 0: model.eval() correct = total = 0 with torch.no_grad(): for x, y in val_loader: x, y = x.cuda(), y.cuda() pred = model(x).argmax(dim=1) correct += (pred == y).sum().item() total += y.size(0) print(f"epoch {epoch}, val acc {correct / total:.4f}")逻辑说明:optimizer.zero_grad()必须在 backward 前调用,否则梯度会累加。验证时切model.eval()并关掉梯度,避免 BatchNorm 统计量被验证数据污染。观察损失曲线时,如果训练损失降但验证损失升,说明过拟合,加 dropout 或减模型宽度;如果两个都不降,检查学习率是不是太大,或者数据标签有没有错位。
4. 避坑与排查:自适应图卷积训练里最容易翻车的五件事
4.1 现象:训练准确率卡在 10% 不动,损失不降
原因:最常见的是邻接矩阵维度和关节数对不上。比如你用 MediaPipe 的 33 个关节点,但物理邻接矩阵是按 NTU 的 25 个点写的,einsum那步维度不匹配会静默广播或报错,如果广播了就会算出完全错误的结果。另一个可能是标签没转成 0 到 num_classes-1 的整数,CrossEntropyLoss 收到浮点标签会报错或算错。解决:打印A_physical.shape和x.shape确认 V 一致;检查标签用torch.unique(y)看是不是从 0 开始的整数。
4.2 现象:验证集准确率比训练集高很多
原因:这听起来反直觉,但在小数据集上常见。如果验证集和训练集有重叠样本,或者验证集太小(比如只有几十条),随机波动会让它看起来很高。另一个原因是 BatchNorm 在训练时用 batch 统计量,验证时用滑动平均,如果 batch size 太小(比如 4),训练统计量噪声大,验证反而稳定。解决:确认训练验证按人划分而不是按帧划分,同一个人不能同时出现在两边;batch size 至少 16;如果数据实在少,用交叉验证代替固定划分。
4.3 现象:加了自适应邻接矩阵后准确率反而降了
原因:可学习的A_learned初始化为单位矩阵,训练初期它和物理邻接矩阵相加后,对角线权重变成 2,非对角线还是物理值,相当于放大了自连接。如果学习率太大,A_learned会迅速跑偏,把物理先验覆盖掉。解决:把A_learned初始化成零矩阵而不是单位矩阵,让网络从纯物理邻接开始学;或者给A_learned加一个小的权重衰减,限制它偏离太远。我一般还会把A_learned和A_physical的融合系数设成可学习的标量,初始为 0.1,让网络自己决定加多少。
4.4 现象:GPU 显存爆了,batch size 降到 1 还是 OOM
原因:ST-GCN 的显存占用和 T×V 成正比。如果你用 300 帧、25 个关节,特征图在中间层可能到 (N, 256, 300, 25),这个张量很大。另外A_learned是 (V, V),V=25 时很小,但如果你的骨架有 100 多个点(比如手部细粒度),矩阵会膨胀。解决:减小 T,用均匀采样降到 60 或 30;减小通道数,把 256 降到 128;用梯度累积模拟大 batch;如果关节数确实多,把A_learned换成低秩形式。
4.5 现象:推理时同一个动作每次预测结果不一样
原因:模型里如果有 Dropout 或 BatchNorm 在 eval 模式下没切对,推理会有随机性。另一个可能是输入数据没有做和训练时一样的归一化——训练时你减了均值除了方差,推理时忘了,分布偏移导致输出不稳定。解决:推理前调model.eval();把训练集的均值和方差存下来,推理时用同一套参数归一化;如果用了随机裁剪做增强,推理时改成中心裁剪或均匀采样。
5. 把自适应图卷积用出效果的两个进阶技巧
第一个技巧是给邻接矩阵加注意力。上面代码里的A_learned是全局共享的,所有样本用同一张图。但不同动作其实需要不同的连接模式——挥手时肩肘腕的权重应该和走路时髋膝踝的权重不一样。做法是在AdaptiveGraphConv里加一个轻量的注意力分支:用全局平均池化把 (N, C, T, V) 压成 (N, C),过两层全连接得到 (N, V×V) 的注意力向量,reshape 后和A_learned逐元素相乘。这样每个样本有自己的一张邻接矩阵,参数量只增加几万,但在我做过的康复动作数据集上能把准确率拉高 3 到 5 个百分点。注意注意力分支要用 sigmoid 归一化到 0 到 1,别用 softmax,否则容易退化成 one-hot。
第二个技巧是验证时看混淆矩阵而不是只看准确率。动作识别里最常见的翻车是「相似动作混淆」——比如「抬手」和「挥手」、 「下蹲」和「坐下」。准确率 85% 听起来不错,但如果混淆矩阵显示这两个类互相错分 30%,那这个模型在实际场景里基本不能用。我一般会在验证脚本里加一段:
from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for x, y in val_loader: x = x.cuda() pred = model(x).argmax(dim=1).cpu().numpy() all_preds.extend(pred) all_labels.extend(y.numpy()) cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('Predicted') plt.ylabel('True') plt.savefig('confusion_matrix.png', dpi=150)跑完打开图,先看对角线,再看非对角线里数值最大的那几对。如果某两个类互相错分严重,回去检查这两类的骨架序列在预处理后是不是长得太像——有时候是归一化把区分度抹掉了,比如把所有序列都缩放到单位框里,坐下和蹲下的高度差就没了。这时候改成按髋关节中心对齐而不是按整体包围盒对齐,往往能救回来。
我自己踩过最狠的一次坑,是花了三天调模型结构,最后发现是训练集里有两个人的骨架数据关节顺序标错了——左手腕和右手腕的索引写反了。模型学出来的邻接矩阵在左右手腕之间有一条很强的边,看起来像「学到了协同」,其实是数据错误。从那以后我养成了一个习惯:任何骨架数据进网络之前,先写个脚本把每一帧画出来,肉眼过一遍前 20 个样本。这个习惯帮我省下的时间,比任何调参技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取