尧图精选

ST-GCN骨骼动作识别实战:从骨架数据到图卷积模型

🕒 发布时间:2026/10/1 10:51:10 📁 来源:尧图网络
简介基于时空图卷积ST-GCN的骨骼动作识别项目包含可直接运行的 Python 源码与项目说明面向计算机相关专业学生及算法从业者适用于毕业设计、课程设计、课程大作业或初期项目立项演示。项目聚焦人体骨骼关键点序列的动作分类覆盖数据预处理、图卷积网络搭建、模型训练、离线评估与实时推理等完整环节有助于系统理解 ST-GCN 从数据到应用的落地流程。压缩包共 90 个文件约 52.55MB以 Python 源码、YAML 配置、预训练模型、GIF 演示动画和 MP4 操作录屏为主同时提供 TXT/MD 说明文档与编译缓存文件目录层次清楚便于按模块查阅与二次开发。资源内置 NTU 与 Kinetics 两类数据集预处理脚本、双流时空图卷积网络结构、训练好的权重以及可直接运行的演示入口能够复现动作识别效果并可替换配置迁移至自定义数据集。目前已有 230 人学习浏览适合需要快速入手图卷积动作识别、搭建课程设计或毕业设计项目的读者参考。1. 骨骼动作识别为什么绕不开 ST-GCN一个工程视角的开场基于时空图卷积ST-GCN的骨骼动作识别是我在动作识别项目里用过的最稳的一条落地路线。很多人一上来就想着拿视频帧去跑 3D CNN但真正部署过就知道光照、背景、遮挡一变化整帧方案就翻车而骨骼关键点只保留人体姿态拓扑天然抗干扰又轻量。这份 python 源码加项目说明的压缩包做的就是把 OpenPose 之类的姿态估计结果变成一段可训练、可推理的动作分类流程。它适合两类人一是刚接触动作识别、想跑通第一个模型的入门工程师二是手头有骨骼数据、但不知道如何把时序和关节关系建模的从业者。核心就一句话——把人体骨架当成图用图卷积在空间上提特征再用时间卷积在帧间提动态最后分类。2. 从骨架坐标到图结构ST-GCN 的输入到底长什么样ST-GCN 的输入不是图片也不是普通的时间序列而是一串带有拓扑结构的关键点坐标。理解这一点整个模型就通了一半。2.1 骨骼数据的两种常见格式与项目文件组织方式骨骼动作识别的数据来源主要有两条路。第一条是用现成的公开数据集比如 NTU RGBD包含 60 类日常动作每个样本是 25 个关节点的 3D 坐标和 Kinetics-Skeleton从视频里检测出的 18 个关节点 2D 坐标。第二条是自采数据先用 OpenPose 或 MediaPipe 从视频里抽人体关键点再对齐、下采样、存成序列。无论哪条路落到磁盘上的格式基本一致一个动作样本就是一个三维数组形状是 C × T × V其中 C 是坐标通道数2D 是 23D 是 3有些还会加置信度变成 4T 是帧数V 是关节点数。拿到一份源码包之后第一件事不是打开模型而是先看项目说明里的数据目录怎么组织。常见做法是 data/ 目录下按 train 和 val 分好每个动作类别一个子目录或者一份 JSON 索引。我一般会先写一个几行的脚本把样本的形状和类别分布打印出来确认数据没有在生成阶段就丢帧或者缺关节。这一步花五分钟后面省两天。# 查看单个骨骼序列的形状 import numpy as np data np.load(sample.npy) print(data.shape) # 期望看到 (3, 300, 25) 之类 print(np.isnan(data).sum()) # 不能有 NaN逻辑说明这个脚本在做两件事。第一行是确认数据排布符合 C × T × V 约定第二行是排查缺失值——NaN 在图卷积里会被当成有效值参与计算直接污染邻域聚合结果。参数说明这里的 3 代表 x、y、置信度三通道300 是统一采样后的帧数25 是选用的关节点数。如果你的数据是 (300, 25, 3)后面所有代码都要先做 transpose。2.2 关键点序列如何变成图邻接矩阵、度矩阵与归一化把骨架变成图要回答两个问题谁是节点谁和谁相连。节点就是关节点边则是人体结构上的连接关系比如手腕接手肘、手肘接肩膀。这个结构用邻接矩阵 A 表示A[i][j] 1 表示第 i 个关节点和第 j 个关节点直接相连。ST-GCN 的图卷积可以理解为对每个节点先把邻居节点的特征聚合起来再经过一次线性变换。只做一次聚合还不够。不同节点的邻居数量不一样——手肘只有两个邻居而髋关节可能有四五个——如果不做归一化邻居多的节点特征数值天然偏大训练会不稳定。所以需要度矩阵 DD[i][i] 等于第 i 个节点的邻居数。归一化后的聚合公式常见写法是 D 的负一次方与 A 相乘。这里有个细节A 要加自环也就是 A[i][i] 1让节点在聚合时保留自己的特征否则每过一层就丢一部分原始信息网络深了特征会退化。提示构建邻接矩阵时千万不要直接把原始 A 拿去用。先加自环再做对称归一化或者随机游走归一化效果差异在准确率上能拉开 1 到 2 个百分点这是源码里最常见的静默 bug。2.3 最小可运行的数据预处理脚本现在我们写一段真正能跑的预处理代码。假设原始数据已经从姿态估计模型里导出来了形状是 (帧数, 关节点数, 通道数)通道为 x、y、置信度我们现在把它归一化到固定帧数并构造成 C × T × V。import numpy as np def preprocess_skeleton(raw, target_frames300): # raw: (T, V, C)C 为 x, y, confidence T, V, C raw.shape # 帧数统一多于目标帧就均匀采样少于目标帧就线性插值 if T target_frames: idx np.linspace(0, T-1, target_frames, dtypeint) data raw[idx] else: data np.zeros((target_frames, V, C)) old_idx np.linspace(0, T-1, target_frames) for i in range(target_frames): # 最近邻插值保证不引入异常关节位置 data[i] raw[int(round(old_idx[i]))] # 坐标归一化以脊柱中心为原点消除人物在画面中的绝对位置影响 center data[:, 1:2, :2].mean(axis1, keepdimsTrue) # 取肩膀中心近似 data[:, :, :2] - center # 转为 C × T × V 排布 data data.transpose(2, 0, 1) # (C, T, V) return data.astype(np.float32) x np.load(sample_raw.npy) # (298, 25, 3) out preprocess_skeleton(x) # 期望输出 (3, 300, 25)逻辑说明函数分三段。第一段做帧数对齐——这是训练时 batch 化的前提PyTorch 的 DataLoader 要求同 batch 内张量形状一致。第二段做位置归一化——减去中心点后人物站在画面左侧还是右侧不再影响分类结果模型学到的是相对运动而不是绝对坐标。第三段做维度转置对齐模型输入约定。参数说明target_frames 取值 300 是常见经验值骨架序列很少需要超过 300 帧的时序信息中心点我用了肩膀附近的节点实际项目中取颈部或髋部中心都可以关键是所有样本要用同一个关节点做中心。3. 时空图卷积网络结构拆解图卷积、时间卷积与残差怎么搭这一章把网络结构拆成三块来讲。理解了这三块源码里 model 目录下的文件就不难读懂了。3.1 图卷积层的实现要点与邻接矩阵的分区策略图卷积层做的事可以用一句话概括对每个节点把自身和邻居的特征加权求和再经过一个可学习的权重矩阵 W。对应公式是 Y 归一化邻接矩阵 × X × W。但在动作识别场景里直接在整个邻接矩阵上做一次卷积太粗糙ST-GCN 的原始论文把邻接矩阵按物理含义拆成了三个部分向心、离心、静止。这个设计是有道理的——伸手和收手的运动方向不同特征应该分开学如果不分区卷积核必须自己从数据里学出这种区分训练难度更大。import torch import torch.nn as nn class SpatialGraphConv(nn.Module): def __init__(self, in_ch, out_ch, num_subset3): super().__init__() # 每个分区一张独立的邻接矩阵加自环并归一化 self.num_subset num_subset self.conv nn.ModuleList([ nn.Conv2d(in_ch, out_ch, kernel_size1) for _ in range(num_subset) ]) self.bn nn.BatchNorm2d(out_ch) self.relu nn.ReLU() def forward(self, x, A_list): # x: (N, C_in, T, V)A_list: 3 个 (V, V) 矩阵 N, C, T, V x.shape out 0 for i in range(self.num_subset): # 先做空间聚合: (N, C, T, V) - (N, C, T, V) x_gathered torch.einsum(nctv,vw-nctw, x, A_list[i]) out self.conv[i](x_gathered) out self.bn(out) return self.relu(out)逻辑说明这段代码的核心是 einsum 那一行。它把第 v 个关节点的特征替换为所有邻居关节点特征的加权和权重来自 A_list。三个分区各自卷积再相加等价于让每个分区学到不同运动方向的特征表达。参数说明in_ch 是输入通道数第一次进图卷积时通常是 3x、y、置信度out_ch 是中间特征维度源码里一般从 64 开始逐层翻倍到 256num_subset 取 3 是沿用原始 ST-GCN 的向心/离心/静止分区方案实际项目里如果关节点定义不同也可以改成 1 或 2但效果需要重新调。3.2 时间卷积层与感受野的选择空间图卷积处理的是单帧内关节之间的联系但动作识别的核心是运动比如“挥手”和“拍手”的空间骨架几乎一样区别全在时间前后关系上。时间卷积的标准做法是在特征图的 T 维度上做一维卷积。落地时通常是 kernel_size9 的一维卷积相当于让每个关节点的特征融合前后各 4 帧的信息。kernel_size 的选取直接决定模型能感知多长的时间窗口。9 帧对于 NTU 这类 300 帧的输入来说经过 10 层网络后理论感受野能覆盖整个序列。如果你做的是短动作比如手势识别序列只有 30 帧kernel_size 建议缩到 5不然时间卷积的感受野一下覆盖整段序列模型分不清动作的先后顺序。另外时间卷积后面一定要跟 BatchNorm图卷积输出的特征分布方差大不做归一化后面几层会越学越飘。3.3 模型整体结构与关键参数表一个标准 ST-GCN 骨干是 9 个时空卷积块每个块包含一次空间图卷积和一次时间卷积通道数从 64 逐步升到 256空间下采样用 stride2 的时间卷积实现最后经过全局平均池化输出一个固定长度的特征向量再接全连接层分类。下面是一份我在项目里实际使用的参数表你可以照抄层块输出通道时间卷积参数下采样残差输入预处理3---块 1-364kernel9, stride1无有块 4-6128kernel9, stride2仅块4有有块 7-9256kernel9, stride2仅块7有有全局池化256---分类层类别数---参数说明通道数翻倍的节点在块 4 和块 7对应的残差连接必须做 1×1 卷积来对齐通道否则残差相加时维度对不上。下采样只发生在时间维度上空间维度即关节点数量从头到尾保持不变因为邻接矩阵是固定的。这里有个经验关节点数量 V 不会因为网络加深而变化这点和图像 CNN 的 H、W 逐步变小完全不同刚接触的人容易在这里画错结构图。4. 训练 ST-GCN 模型从数据划分到收敛诊断的完整流程模型结构再花哨最后都要落在训练流程上。这一章的每一步都对应源码里 train.py 或 main.py 中的某一段照着改就行。4.1 数据加载器与训练循环的写法骨骼数据量不大一个数据集几百 MB 到几个 GB不需要像图像识别那样做复杂的在线增强但数据加载器里有两个细节必须处理一个是上面说的帧数对齐另一个是样本级别的随机打乱。下面是一个最小可用的 PyTorch 数据加载与训练循环。import torch from torch.utils.data import Dataset, DataLoader class SkeletonDataset(Dataset): def __init__(self, data_list, labels, target_frames300): self.data data_list # list of (C, T, V) 数组 self.labels labels self.target_frames target_frames def __len__(self): return len(self.data) def __getitem__(self, idx): x self.data[idx] # 如果帧数不等于 target_frames用预处理里的函数对齐 if x.shape[1] ! self.target_frames: x preprocess_skeleton(x.transpose(2, 0, 1)) return torch.FloatTensor(x), torch.LongTensor([self.labels[idx]]) train_loader DataLoader(SkeletonDataset(train_data, train_labels), batch_size32, shuffleTrue, num_workers4, pin_memoryTrue)逻辑说明getitem返回一个 (C, T, V) 张量和一个标签。shuffleTrue 保证每个 epoch 内样本顺序不同。num_workers 是数据加载的子进程数骨骼数据预处理只有归一化和转置CPU 密集度不高设 4 就够了设太高反而增加进程切换开销。参数说明batch_size32 对应一块 24G 显存显卡如果只有 11G 显存建议降到 16同时把模型中间通道从 64/128/256 缩到 32/64/128准确率损失在 2 个点以内。4.2 训练超参数与学习率策略ST-GCN 训练最容易犯的错是学习率设得太大。图卷积的输入是归一化后的坐标数值范围通常在 -1 到 1 之间特征幅值比图像 CNN 小很多用默认的 0.01 学习率会导致 loss 早期剧烈震荡。我一般用初始学习率 0.001配合余弦退火或阶梯下降在 60 个 epoch 内能稳定收敛。# 训练核心片段 optimizer torch.optim.Adam(model.parameters(), lr0.001, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60, eta_min1e-5) criterion torch.nn.CrossEntropyLoss() for epoch in range(60): model.train() for x, y in train_loader: x, y x.cuda(), y.squeeze().cuda() out model(x) loss criterion(out, y) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()逻辑说明optimizer 使用 Adamweight_decay 设 1e-4 起正则作用防止骨骼数据这类小样本集过拟合。scheduler 用余弦退火让学习率从 0.001 平滑降到 1e-5比阶梯下降更稳尤其适合动作识别这种对后期微调敏感的任务。参数说明T_max60 要和总 epoch 数一致否则退火曲线会在中途截断如果换用阶梯下降建议在 epoch 30 和 50 各乘 0.1效果接近但需要多调两个时间点。4.3 评估指标、模型保存与推理脚本动作识别数据集经常存在类别不平衡光看准确率不够还要看每个类别的召回率。保存模型时不要只存最后一轮的权重要记录验证集上表现最好的那一次。做法是每个 epoch 结束算一次 top-1 准确率和混淆矩阵然后只保留验证准确率最高的权重。推理阶段有一个容易忽略的点——输入数据必须走和训练时完全相同的预处理管线。很多人训练时做了中心化归一化推理时直接用原始坐标送进模型结果准确率暴跌然后怀疑模型坏了。正确做法是把 preprocess_skeleton 封装成一个函数训练和推理共用同一份代码。我习惯把模型导出为 TorchScript 格式预测时用 C 或 Python 加载都没有额外依赖这在项目落地到嵌入式设备时尤其重要。5. 常见问题与避坑五个让 ST-GCN 翻车的工程细节这五条都是我自己或同事在项目里真实踩过的坑每条按现象、原因、解决来写照着排查能省几天时间。5.1 训练 loss 持续下降但验证准确率纹丝不动现象训练集 loss 从 2.0 降到 0.3训练准确率接近 100%验证准确率却一直停在 20% 上下和随机猜测差不多。原因数据泄漏。最常见的是预处理阶段把中心化归一化用全部数据的均值做了而不是用训练集单独计算或者数据划分时同一个人的动作既出现在训练集又出现在验证集。骨骼数据的特征维度相对低模型很容易直接记住人物身份而不是动作模式。解决严格按样本划分数据集确保同一个人的所有样本只出现在训练集或只出现在验证集。中心化归一化的均值只从训练集计算再应用到验证集。代码上检查划分逻辑里有没有按 identity 分组。5.2 显存溢出或者训练速度慢到一天跑不完一个 epoch现象batch_size32 时直接 OOM调到 8 之后每轮训练要一个多小时。原因时间维度的计算量被忽视了。ST-GCN 的特征图是 (N, C, T, V)其中 T300V25中间层的 T 不会自动缩小只有碰到 stride2 的时间卷积才减半。如果模型里忘记设置时间下采样所有层的 T 都是 300计算量和显存压力成倍增加。解决确认从块 4 开始时间卷积 stride2让 T 从 300 降到 150 再降到 75。如果显存还是紧把输入帧数从 300 降到 150准确率损失一般在 1 个点以内但训练速度直接快一倍。帧数小于 150 时建议同时把 kernel_size 从 9 改到 5保持感受野合理。5.3 同一个动作换个摄像头角度识别结果就变现象训练集里都是正面视角的动作识别率 95%换成侧面 45 度摄像头录制同样动作识别率掉到 60%。原因骨骼坐标对视角太敏感。ST-GCN 本身没有视角不变性模型学到的是特定视角下的关节角度变化模式而不是语义上的“举起手”。解决这是最结构性的问题三个手段配合才能缓解。第一数据增强——训练时对关节坐标做随机旋转扰动±15 度内模拟视角变化第二输入特征用关节之间的相对向量而不是绝对坐标比如手肘到手碗的向量化表示天然比绝对坐标更抗平移第三如果场景允许在多个视角采集训练数据。数据增强的实现是在预处理里对 (x, y) 坐标乘以一个随机旋转矩阵注意 z 轴上的旋转不要做会破坏重力方向这个重要线索。5.4 同一个动作做得快和做得慢识别结果不同现象慢速挥手能识别对快速挥手经常识别成其他类或者反过来。原因时间卷积的感受野是固定的。帧数对齐之后一个 1 秒快速动作可能只占 40 帧一个 3 秒慢速动作占 120 帧但卷积核覆盖的帧范围不变快速动作的时序信息被过度压缩慢速动作的关键瞬间又可能没被覆盖到。解决两个方案。一是基于运动速度做时间尺度归一化——动作序列对齐时不再均匀采样到固定帧数而是先计算关节速度曲线把速度峰值附近的关键帧保留、无关帧压缩再统一到固定长度。二是用多尺度时间卷积在同一个块里并行用 kernel3、5、9 的三个分支同时卷积再融合代价是参数量增加但能显著提升对速度变化的鲁棒性。我实际测试下来方案二更省事且稳定。5.5 源码里的张量维度对不上报 RuntimeError现象照着 README 跑训练前向传播直接报错显示 einsum 或 matmul 的维度不匹配。原因多半是骨骼数据的关节点顺序和源码里预定义的邻接矩阵顺序不一致。比如 NTU 数据集的 25 号关节点定义和 Kinetics 的 18 号定义完全不同直接复用预训练权重的前几层就会维度错乱。解决拿到任何源码第一件事是打开项目说明或数据字典文件确认关节点顺序表。然后写一个可视化脚本把第 1 个样本的骨架画出来和标准骨架图比对确认每个关节点连接正确。这个问题没有捷径维度检查脚本要放在预处理的最后一步。我习惯在 DataLoader 里加一个 assert x.shape (C, T, V)一旦数据源换过第一时间弹出来而不是等模型炸了再查。6. 把模型用到自己的场景一个迁移验证的具体技巧从公开数据集训练好的 ST-GCN 很难直接适应你的业务场景最常见的情况是关节点定义不一样或者类别集合完全不同。与其从头训不如做两件事。第一步把预训练模型的第一层图卷积权重按你的关节点顺序重排如果关节点数量不同直接丢掉前几层冻结后几层当特征提取器用。第二步只训练最后两层的分类头和全局池化层输入换成你的骨骼数据做微调。微调时有一件事我吃过亏学习率要降到原来的十分之一甚至二十分之一因为预训练特征已经足够好学习率大了会冲掉已经学好的骨骼结构表示。我用 0.0001 的初始学习率30 个 epoch 收工。效果验证上不要只看整体准确率把每个类别的混淆矩阵打出来重点看哪些类互相混淆——比如“站立”和“走路”在骨骼图上本来就很接近如果混淆严重就要去检查训练数据里这两类的动作有没有区分度。还有一个验证技巧是专门录一段背景噪声大、人物在画面边缘的视频送到模型里看输出置信度分布。如果置信度普遍低于 0.5说明模型学到的特征里夹杂了太多背景信息及时回到预处理里加强位置归一化而不是继续调网络结构。这套方案从数据到训练再到迁移我完整跑过三轮。第一轮踩了数据泄漏的坑第二轮发现视角泛化差第三轮稳定在 90% 以上的准确率才真正落地。如果只挑一个最重要的习惯那就是所有环节的预处理函数必须训练、推理共用没有例外。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →