ARTICLE DETAIL

资讯详情

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

时空图卷积骨骼动作识别:从数据处理到模型训练实战

时空图卷积骨骼动作识别:从数据处理到模型训练实战 简介基于时空图卷积ST-GCN的骨骼动作识别Python源码包面向计算机、电子信息等专业的毕业设计、课程设计及期末大作业场景适合需要完整工程参考并希望二次开发的读者也适合用于入门图卷积在动作识别中的应用。压缩包共91个文件约52.6MB含29个Python源码文件、13个YAML配置、12个pyc编译文件、3个PyTorch模型权重、3个MP4演示视频与11个GIF动画覆盖从模型定义、数据预处理、训练推理到离线/实时演示的完整流程。资源提供NTU-RGB-D与Kinetics两大数据集的支持附带可运行演示脚本可直接加载预训练模型进行测试或作为算法改进的基线开展实验。另有README、配置文件及技术文档便于快速梳理目录结构并复现结果。已有275人学习浏览适合作为课程设计、毕业设计的参考资料也是ST-GCN入门与实践的优质样例。1. 骨骼动作识别为什么选择时空图卷积一个值得投入的毕业设计方向当摄像头捕捉到一个人从椅子上站起来时让机器判断这是“站起来”而不是“摔倒”或者“弯腰捡东西”是骨骼动作识别的典型场景。基于时空图卷积ST-GCN的骨骼动作识别核心思路是把人体骨骼关键点建模成一张图每个关节点是节点、骨骼连接是边再用图卷积同时聚合空间结构信息和时间动态信息从而完成动作分类。比起直接用RGB视频帧做识别骨骼数据天然不受光照、背景、衣着颜色的干扰模型更轻、更好解释这也是这一方向在近些年动作识别研究里热度一直很高的原因。这个Python源码毕业设计主题特别适合两类人正在做毕设的学生想用一份完整可跑的代码把论文里的方法落地的工程师。两条路都能从这套方案里找到自己需要的部分而且它复现门槛不高一台普通显卡电脑就够跑起来。2. 准备骨骼数据从NTU RGBD到可训练的序列文件2.1 骨骼数据到底长什么样先搞清这三层结构ST-GCN的输入不是图像是一段连续帧里的骨骼关键点序列。以学术界最常用的NTU RGBD数据集为例每个动作样本包含若干帧每帧里有25个关节点的三维坐标x, y, z有些版本还带置信度。做项目之前我习惯先把数据在内存里的shape搞清楚一个样本通常是(C, T, V, M)C是通道数坐标xyz加上可能的置信度常见是3T是帧数V是关节点数NTU是25Kinetics是18M是人数。这个四维张量的顺序在后续搭建模型时非常关键PyTorch里默认的layout是(N, C, T, V, M)N是batch sizeM那一维通常用max-pooling合并掉。拿到原始数据第一步是统一格式。NTU的官方mat文件、Kinetics的json标注、自己用OpenPose或MediaPipe提取的npy来源不同存储方式也不同。我一般会做一个轻量预处理脚本把不同来源的数据统一转成npy格式只保留坐标和帧索引顺便把缺失帧处理掉。预处理做得好不好直接影响后面训练的收敛速度这一步值得多花一点时间。2.2 数据归一化和帧采样不处理这两个问题模型很难收敛骨骼数据的坐标是像素或者毫米级别的绝对值不同人的身高、离摄像头远近会导致坐标尺度差异很大。常见做法是做一个以脊柱为中心的人体归一化。我用的方式取第0帧或整个序列的平均帧的脊柱关节比如NTU的关节点1为原点把所有坐标平移过去然后除以一个尺度因子比如肩膀中心到髋部中心的距离。这样坐标就落在差不多的范围内模型不用自己去适应每个人的绝对尺寸。帧采样的坑很容易被忽略。NTU的样本帧数从几帧到上百帧都有而ST-GCN对时间维度有固定期望通常取300帧左右。做法是均匀采样不是随机裁剪。假设原始视频有120帧目标采样150帧那么每隔120/150跳一帧就行。如果原视频不足300帧我通常做循环补帧而不是简单重复最后一帧这样时间上的动作连续性更好模型能学到更平滑的运动模式。2.3 搭建数据加载器用Python代码把样本喂给模型这一节给的代码是数据加载器的骨架README里标注的“骨骼动作识别python源码”通常会包含完整版本这里给出最核心的读取与采样逻辑import numpy as np import torch from torch.utils.data import Dataset class SkeletonDataset(Dataset): def __init__(self, data_dir, label_file, num_frames300): data_dir: 存放npy文件的文件夹每个npy的shape为 (C, T, V, M) label_file: txt文件每行是 文件名 标签编号 num_frames: 统一采样到多少帧论文里常用300 self.num_frames num_frames self.samples [] with open(label_file, r) as f: for line in f: name, label line.strip().split() self.samples.append((f{data_dir}/{name}.npy, int(label))) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] data np.load(path).astype(np.float32) # (C, T, V, M) C, T, V, M data.shape # 统一采样: 先做时间维度的均匀采样 if T 0: data np.zeros((C, self.num_frames, V, M), dtypenp.float32) else: indices np.linspace(0, T - 1, self.num_frames).astype(int) data data[:, indices, :, :] # (C, num_frames, V, M) # 归一化: 以第一帧第一个人的脊柱关节为原点 spine data[:, 0, 0, :] # 取第0帧的第0个关节 data data - spine.reshape(C, 1, 1, 1) # 转成Tensor并转为 (C, T, V, M) 格式后续模型里再调整 return torch.from_numpy(data), label dataset SkeletonDataset(data/train_npy, data/train_label.txt) loader torch.utils.data.DataLoader(dataset, batch_size32, shuffleTrue, num_workers4)代码里np.linspace(0, T-1, num_frames).astype(int)做的是均匀取帧比随机选帧更稳定。归一化选脊柱为原点是因为它的位置在人体中心受四肢摆动影响小。num_workers4在Windows上有时会报错可以改成0后面避坑章节会细说。通常我会在训练前把预处理后的数据缓存成一份新的npy避免每次epoch都重复做采样和归一化能省下不少IO时间。如果有置信度通道记得在归一化时不要减脊柱坐标置信度不需要平移。3. 模型解析ST-GCN的空间图卷积和时间卷积是怎么协同工作的3.1 图结构的核心邻接矩阵、自环和分区策略ST-GCN和普通卷积神经网络最大的区别在空间维度普通CNN卷积核覆盖的是一块规则的矩形区域而骨骼关节点是不规则排列的图结构。所以我们需要把人体关节点定义成一张图并用邻接矩阵来描述关节之间的连接关系。邻接矩阵是(V, V)的方阵V是关节点数如果第i个关节和第j个关节之间有骨骼直接相连则矩阵元素为1否则为0。但这还不够Yan等人在论文里提了一个关键改进把邻接矩阵按“距离”拆分成多个子集。以当前关节点为中心把邻居节点分成三类自身节点、距离为1的向心邻居、距离为1的离心邻居。向心和离心用“到骨骼重心的距离”来区分离重心更近的算向心更远的算离心。对应到代码里邻接矩阵会被拆成三个独立的(V, V)矩阵分别参与三次独立的图卷积最后把结果相加。这个分区策略让模型能区分“手朝身体靠近”和“手远离身体”这两种截然不同的动作识别准确率能提升不少。3.2 ST-GCN block的结构先空间后时间残差连接保证梯度一个标准的ST-GCN block由两个核心部分组成GCN图卷积做空间特征提取TCN时间卷积做时间特征提取。GCN的输入是(N, C, T, V)经过图卷积后输出维度不变TCN本质是沿时间维做一维卷积卷积核大小常见为9padding设为4保证帧数不衰减。每个block最后接一个残差连接把输入直接加到输出上。残差连接几乎是所有深层网络的标配ST-GCN里也不例外。没有残差9层甚至10层的堆叠会让梯度消失问题变得很严重尤其在训练样本不多的时候模型很容易训不动。加上残差后网络退化为“在恒等映射基础上学习残差”即使层数堆上去也能稳定收敛。3.3 核心代码手写一个可运行的ST-GCN卷积层下面的代码实现了GCN部分的核心逻辑注释里标清楚了每一步的维度变化。这是整个项目最关键的一段理解透了后面调参才有的放矢import torch import torch.nn as nn import torch.nn.functional as F class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, num_nodes25, num_subsets3): in_channels: 输入通道数通常第一层是3x,y,z out_channels: 输出通道数 num_subsets: 分区策略数量论文里默认3自身、向心、离心 super().__init__() self.num_subsets num_subsets # 每个子集对应一个独立的1x1卷积用于通道变换 self.conv_list nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size1) for _ in range(num_subsets) ]) # 批量归一化让训练更稳定 self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU() def forward(self, x, adj_matrix): x: (N, C, T, V) adj_matrix: (num_subsets, V, V)已经归一化并加了自环 N, C, T, V x.shape # 交换维度变成 (N*T, C, V)方便做图卷积 x x.permute(0, 2, 3, 1).reshape(N * T, V, C) out 0 for k in range(self.num_subsets): # 图卷积核心: A_k X W_k # 先用邻接矩阵聚合邻居特征: (N*T, V, V) (N*T, V, C) x_agg torch.einsum(nvw,nwc-nvc, adj_matrix[k].to(x.device), x) x_agg x_agg.reshape(N, T, V, C).permute(0, 3, 1, 2) # (N, C, T, V) out out self.conv_list[k](x_agg) # 1x1卷积变换通道 out self.bn(out) return self.relu(out) # 使用方法示例 # adj: 已预处理好的邻接矩阵shape为 (3, 25, 25) # gcn SpatialGraphConv(3, 64) # output gcn(input_tensor, adj)代码里最关键的是torch.einsum(nvw,nwc-nvc)这一行它完成了邻接矩阵 × 特征矩阵的图聚合操作。这一步的效果等价于“把每个关节点的邻居特征求和”但用einsum写出来非常简洁也避免了显式循环带来的性能损耗。num_subsets3对应前面说的三种分区如果数据集不同比如只有18个关键点只需要换对应的邻接矩阵即可网络结构不用改。完整模型里时间卷积层一般是nn.Conv2d(in_channels, out_channels, kernel_size(9, 1), padding(4, 0))在时间维上做局部窗口的特征提取空间维的卷积核是1因为空间信息已经由图卷积处理过了。3.4 邻接矩阵的构建与归一化邻接矩阵不能直接用原始的0/1矩阵要做归一化。常用的方式是D^(-1/2) * A * D^(-1/2)D是度矩阵对角线元素等于每个节点的度数。这样做的目的是防止某个度过高的节点在聚合时主导整个特征让模型对不同骨骼结构的适应能力更强。我习惯把这一步单独放在build_adj.py里提前算好保存成npy训练时直接加载避免每次都要重新算一遍。4. 训练与调参让ST-GCN在你自己的数据上真正收敛4.1 训练入口代码模型、优化器、学习率调度一次性配好拿到源码包后第一件事不是改模型结构而是把训练入口跑通。下面这段训练代码覆盖了大部分开源实现里的关键模块我把学习率调度、日志输出都统一进了同一个流程import torch import torch.nn as nn from torch.optim import SGD from torch.optim.lr_scheduler import StepLR model STGCN(in_channels3, num_class60, num_nodes25) # 模型定义 device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer SGD(model.parameters(), lr0.1, momentum0.9, weight_decay0.0001) scheduler StepLR(optimizer, step_size30, gamma0.1) num_epochs 120 best_acc 0.0 for epoch in range(num_epochs): model.train() running_loss 0.0 for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() # 每个epoch结束后在验证集上评估 val_acc evaluate(model, val_loader) print(fEpoch {epoch1}/{num_epochs}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)优化器用SGD加momentum是论文里的标准配置PyTorch里Adam在部分稀疏骨骼数据上表现也不错但SGD的泛化能力更强尤其在测试集上你往往能拿到更稳的准确率。weight_decay0.0001是经验值过大会让权重过于稀疏过小则正则化效果不明显。学习率调度用StepLR每30个epoch缩小10倍这个节奏比较稳妥适合大多数动作识别任务。验证函数evaluate里要注意模型切换到model.eval()模式并且用torch.no_grad()包裹推理过程不然BatchNorm层的统计量会被更新验证结果就不准了。保存模型时只保存state_dict而不是整个模型后续加载更灵活也能避免PyTorch版本差异带来的兼容问题。4.2 学习率是训练里最值得调的参数batch size直接决定显存占用ST-GCN对学习率非常敏感。学习率调到0.2以上的话loss大概率直接发散到NaN调成0.001以下训练速度慢到让人怀疑人生。如果从零开始训练不加载预训练模型0.1是稳妥的起点。如果你的数据集规模比较小比如自己采集的只有几百个样本0.01起步会更安全因为小数据集上的梯度噪声大大学习率很容易跳过最优区域。Batch size的选择跟显存直接相关。输入分辨率按(300帧, 25关节点, 3通道)计算每个样本的内存占用不大32的batch size在8GB显存显卡上毫无压力。但要注意batch size如果设到64以上训练的有效学习率相当于变大了可能需要同步调低学习率来保持稳定。我一般会先跑到第10个epoch看一眼loss曲线正常情况应该从初始值稳步下降如果前三个epoch就有震荡或者不分下降的趋势直接中断重调参数。4.3 数据划分与标签文件文件命名和路径是第一个坑源码包里通常有一个label_file格式是每行一个“文件名 标签编号”比如001_001.npy 0。写数据划分脚本时我建议先统计每个类别的样本数量确保训练集和测试集的类别分布一致简单做法是train_test_split按标签分层采样。如果数据集是按人物身份划分的NTU的cross-subject协议就不能随机打乱必须保证训练集和测试集属于不同的人否则会出现数据泄露——测试集里出现了训练过的人的动作特征测试准确率虚高。这里再提醒一次文件名里的路径分隔符一定要用正斜杠Windows下反斜杠会导致加载路径直接报错这是一个虽然小但是非常常见的翻车点。5. 复现与排错5个让ST-GCN项目卡住的隐蔽问题5.1 邻接矩阵和输入节点数不匹配推理直接崩现象模型前向传播时报矩阵维度错误mat1 and mat2 shapes cannot be multiplied。原因数据集的关节点数和代码里预设的num_nodes不一致比如NTU是25个点而代码默认写的是18。解决先打印输入数据的shape确认V是多少然后把num_nodes参数换成对应的数同时重新生成邻接矩阵。源码包里通常会带一个人体关节点连接图对着图核对节点编号别只看数量对就对号入座。5.2 Windows下DataLoader的num_workers设置导致内存被疯狂占用现象训练脚本在Windows上运行时内存占用直线上升几个epoch后电脑直接卡死。原因Windows和Linux的进程管理方式不同num_workers设置得过高且没有if __name__ __main__保护会出现重复加载数据的情况。解决把num_workers改成0或者把训练主逻辑放进if __name__ __main__:代码块里。5.3 坐标原点取错整个数据的分布直接乱掉现象训练loss一直降不下去准确率停留在很低的值。原因归一化时选错了参考关节比如把左右肩当成了原点。由于人体的肩部在动作中会产生较大位移以其为原点会让坐标值随着肩膀摆动而大幅漂移模型学不到稳定的模式。解决统一用脊柱关节点作为原点并且在预处理脚本里加一段可视化代码把骨骼动画播放出来人工确认关节位置是否正确这一步看起来麻烦实际能省掉大量无效训练时间。5.4 训练集和测试集划分不遵守跨人物协议准确率虚高现象测试准确率95%以上但放到真实场景里效果一塌糊涂。原因数据划分时把同一个人的动作片段同时放进了训练集和测试集模型记住了人的特征而不是动作的特征这在行为识别里是最常见也最致命的数据泄漏问题。解决严格按照数据集官方协议划分如果是自己的采集数据按人员ID做分组划分保证训练集和测试集的人员完全不相交。5.5 时间卷积核的参数设置在短样本数据上导致帧数归零现象模型前向传播时报Expected 3D tensor, but got 2D之类的错误。原因时间卷积核设置为9且padding不够原本只有十几个帧的短样本经过几层卷积后时间维度直接变0。解决检查时间卷积层的padding设置确保padding kernel_size // 2保持帧数不变或者在数据预处理阶段把采样帧数统一放到一个较大的值短数据补帧以后再输入网络。常用的折中方案是设定num_frames150即保证了时间信息完整又不会过度增加计算量。6. 把模型用到自己的场景迁移学习与识别效果验证的技巧拿到源码包跑通训练流程只是第一步真正考验工程能力的是把模型从论文的公开数据集迁移到自己的业务场景。迁移学习有两个常用策略一是加载NTU上预训练好的权重冻结前几层GCN block只微调最后几层和分类头二是从头训练但把学习率调低一个量级。我推荐前者因为公开数据集的动作类别多、样本量大预训练模型已经学到了通用的运动表征。微调时要注意如果自己的类别数和预训练不一样分类头的全连接层必须重新初始化不能直接沿用旧权重。实测下来冻结前两层通常能在样本很少的情况下拿到还不错的基线效果。验证模型是否真正“认识”动作而不是“背样本”我有一个很实用的习惯把测试集里预测错的样本输出成短视频观察错误发生的位置。如果模型把“挥手”误判成“招手”说明动作本身在时间和空间上确实相似模型输出具有语义合理性如果模型把“站起来”误判成“坐下”那就需要检查时间方向是否搞反了或者数据标注出现了错误。这种可视化验证方式比只看准确率数字要可靠得多能帮你快速定位是数据问题还是模型结构问题。另外检查Top-5准确率也很值得做。很多动作在类间高度相似比如“双手向上伸”和“伸手拿东西”Top-5里能看到模型输出的候选类别里是否包含了正确的类别如果Top-5都没有大概率是数据或模型配置层面的问题而不是分类头不够好的问题。我自己做这个项目的经验是不要急着调模型结构先把数据流程跑通、把训练脚本稳定住再花时间在调参和迁移上这条路走的弯路最少。预算有限的话单张消费级显卡就能完成全部训练成本可控复现门槛远低于文本或图像类模型。希望帮到你这套方案做扎实了无论是答辩还是实际项目里都能真正站得住脚。本文还有配套的精品资源点击获取
返回列表