ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ST-GCN自适应图卷积实战:Python骨架动作识别从原理到避坑

ST-GCN自适应图卷积实战:Python骨架动作识别从原理到避坑 简介本资源为基于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-GCNSpatial-Temporal Graph Convolutional Network时空图卷积网络就是冲着这个痛点来的它把每一帧的人体骨架建成一张图关节点是图的节点骨骼是图的边再沿时间轴把连续帧连起来形成一个时空图然后在图上做卷积。而「自适应图卷积」是在原始 ST-GCN 基础上更进一步——不再死板地按人体物理连接定义邻接矩阵而是让网络自己学出「哪些关节该互相影响」的权重。这套 Python 源码加使用说明的组合适合做行为识别、康复评估、体育动作打分、安防异常行为检测的工程师也适合刚入门 python、想找一个能跑通的深度学习项目练手的同学。下面我按「先讲清原理和选型、再给可复现步骤、最后说坑」的顺序把这条路走一遍。2. 自适应图卷积的骨架建模邻接矩阵怎么从固定变成可学2.1 原始 ST-GCN 的固定分区策略与它的天花板原始 ST-GCN 的做法很直接对每个关节按人体骨架的自然连接把它的邻居分成三个子集——自己、向心邻居离骨架重心更近的、离心邻居更远的。然后给这三个子集各配一个卷积核权重做加权求和。这个分区策略在 NTU RGBD 这类标准数据集上能到 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 是 batchC 是通道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_adjNone): 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_size1) 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 是 25MediaPipe 是 33OpenPose 是 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_len30): # 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_len30): 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], axis0) # 假设 raw 是 (T, V, C)V25C3 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, stride1, residualTrue): super().__init__() self.gcn AdaptiveGraphConv(in_channels, out_channels, num_nodes) # 时间卷积kernel_size9 是 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_size1, 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-4batch size 16 或 32跑 50 到 80 个 epoch。损失用交叉熵如果类别不均衡就加 class weight。下面是一个最小训练循环。import torch.optim as optim model STGCN(num_classes10, num_nodes25).cuda() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-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(dim1) correct (pred y).sum().item() total y.size(0) print(fepoch {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)V25 时很小但如果你的骨架有 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(dim1).cpu().numpy() all_preds.extend(pred) all_labels.extend(y.numpy()) cm confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.savefig(confusion_matrix.png, dpi150)跑完打开图先看对角线再看非对角线里数值最大的那几对。如果某两个类互相错分严重回去检查这两类的骨架序列在预处理后是不是长得太像——有时候是归一化把区分度抹掉了比如把所有序列都缩放到单位框里坐下和蹲下的高度差就没了。这时候改成按髋关节中心对齐而不是按整体包围盒对齐往往能救回来。我自己踩过最狠的一次坑是花了三天调模型结构最后发现是训练集里有两个人的骨架数据关节顺序标错了——左手腕和右手腕的索引写反了。模型学出来的邻接矩阵在左右手腕之间有一条很强的边看起来像「学到了协同」其实是数据错误。从那以后我养成了一个习惯任何骨架数据进网络之前先写个脚本把每一帧画出来肉眼过一遍前 20 个样本。这个习惯帮我省下的时间比任何调参技巧都多。希望帮到你。本文还有配套的精品资源点击获取
返回列表