拓冰建站拓冰建站
首页 / 资讯中心 / 正文

ST-GCN骨骼动作识别实战:源码解析、数据处理与训练推理

简介面向计算机、电子信息等专业毕业设计及课程设计场景的骨骼动作识别项目基于时空图卷积网络ST-GCN实现提供完整可运行的 Python 源码和配套项目说明。包内共 91 个文件覆盖 29 个 Python 脚本、13 个 YAML 训练配置、多个预训练权重、离线与实时演示代码以及 GIF/MP4 演示和文本日志等整体约 52.6MB目录按 tools、feeder、processor、model 等模块组织方便按需学习与调试。支持 NTU-RGB-D 和 Kinetics 两类主流骨骼动作数据集包含单流与双流 ST-GCN 模型构建、数据预处理、训练验证流程并提供了 AddEdgeWeight 等边权重调节示例可用于动作识别算法复现、模型对比或二次开发。已有 275 人学习下载适合希望快速跑通图卷积动作识别链路、深入理解时空建模细节的开发者参考。1. 先搞懂 ST-GCN 在做什么骨骼序列不是图但可以当作图ST-GCN 做骨骼动作识别这几年算是毕业设计和课程设计里的常青树了。它不走 RGB 图像的老路而是把每一帧的人体关节点坐标连成一张图沿着空间边和时间轴同时做卷积直接对骨骼序列分类。这份资源打包了完整的 Python 源码、预训练权重、NTU-RGB-D 和 Kinetics 的数据处理脚本外加离线和实时两套推理 demo正好覆盖从数据处理到模型训练再到效果展示的全流程。适合手里有骨骼数据、或者想完整读懂 ST-GCN 图卷积落地细节的人照着这份源码能省掉大量翻论文和调库的时间。2. 拆解源码结构先看懂 st_gcn.py 和双流模型再动手2.1 目录里那堆文件其实只有一条主线打开压缩包第一眼会觉得文件很杂其实真正的主线非常短utils里两个 gendata 脚本负责把原始骨骼转成 numpy 和 pklfeeder负责把转好的数据一条条喂给模型net里的st_gcn.py定义单流模型、st_gcn_twostream.py定义双流模型processor里的processor.py和main.py是训练与测试入口再往外是demo_offline.py和demo_realtime.py两个推理演示。路径作用utils/ntu_gendata.pyNTU-RGB-D 原始 skeleton 转 npy/pklutils/kinetics_gendata.pyKinetics-skeleton JSON 转 npy/pklfeeder/feeder.pyNTU 数据加载器负责采样和 batch 组织net/st_gcn.py单流时空图卷积模型net/st_gcn_twostream.py双流模型关节流加骨骼流processor/processor.py 与 main.py训练和评估入口demo_offline.py / demo_realtime.py离线与实时推理演示models/*.pt三个预训练权重文件config/st_gcn.twostream双流训练配置还有一部分是历史遗留OLD_README.md是旧版说明AddEdgeWeight_2.txt看起来是调试邻接矩阵边权重时导出的文本快照__pycache__、tmp、logData是从原机器一起打包过来的可以忽略。第一次读代码我建议按 utils → feeder → net → processor → demo 的顺序一条线读下去比逐个文件扫省时间。另一个常见的误区是上来就双击main.py然后报错找不到.pt路径。config/st_gcn.twostream里权重路径默认指向 models 目录启动训练前先确认这几个路径存在否则会在加载模型那一步直接崩掉。2.2 st_gcn.py 的核心邻接矩阵、图卷积层和九层堆叠ST-GCN 的本质一句话把骨架序列当成一张图。关节点是图的顶点骨骼是图的边边只存在于空间相邻的关节点之间。但动作是动态的时间维不能丢所以空间图卷积和时序卷积要拆开做这就是名字里 Spatial Temporal 的由来。先看空间图卷积自己怎么写。图的卷积和图像卷积最大的区别在于邻域不是规则的网格而是由邻接矩阵 A 决定。class SpatialGraphConv(nn.Module): def __init__(self, in_channels, out_channels, A): super().__init__() # A: V x V 的邻接矩阵A[i][j] 1 表示关节点 i 和 j 由骨骼直接相连 self.register_buffer(A, A) self.conv nn.Conv2d(in_channels, out_channels, kernel_size1) def forward(self, x): # x: N x C x T x VN 是 batchC 是通道T 是帧数V 是关节点数 N, C, T, V x.shape x x.permute(0, 2, 3, 1).reshape(N * T, V, C) # D 是度矩阵D[i][i] 等于 i 号关节点的邻居数量 D self.A.sum(dim1).diag().inverse().sqrt() A_norm D self.A D # 对称归一化 out torch.einsum(nvw,nwc-nvc, A_norm, x) # 邻居特征聚合 out out.reshape(N, T, V, C).permute(0, 3, 1, 2) return self.conv(out)这里最核心的是 einsum 那一步它对每个节点做邻居特征的加权求和权重由归一化后的邻接矩阵给出。不做归一化的话度数高的关节点躯干特征会被放大肢体末端的特征被稀释模型注意力全被躯干带走识别精度会掉得很难看。kernel_size1的卷积相当于对每个节点的 C 维特征做线性变换不改变 T 和 V 的尺寸。A 注册成 buffer 而不是 parameter是因为它只参与前向聚合不参与梯度更新。实际工程里的 ST-GCN 是九个这样的块堆叠每个块在图卷积之后还要跟一个时序卷积处理帧与帧之间的依赖。class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A, stride1): super().__init__() self.gcn SpatialGraphConv(in_channels, out_channels, A) self.tcn nn.Conv2d(out_channels, out_channels, kernel_size(9, 1), # 时间维覆盖 9 帧 stride(stride, 1), padding(4, 0)) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): res x x self.relu(self.bn(self.tcn(self.gcn(x)))) return x res # 残差连接防止九层堆叠后梯度消失tcn 的卷积核是(9, 1)第一个维度 9 是时间窗一次卷积融合 9 帧的信息第二个维度 1 是空间维空间关系已经由 gcn 处理过。stride 只在通道数翻倍的那层设为 2对时间维做下采样。通道数沿九层从 64 涨到 128 再涨到 256最后全局平均池化接 softmax 分类。这里有个容易误读的细节原始工程还会把邻接矩阵按空间划分拆成向心、离心、静止三个子集各配一个 mask 矩阵卷积时分开聚合再相加因为靠近重心和远离重心的运动语义不同分开表达力更强。2.3 双流模型关节流和骨骼流为什么要分开单流模型只拿关节坐标当特征。但很多动作的判别信息其实在骨骼上比如挥手和招手关节位置变化很接近手肘和手腕的相对方向才是关键。st_gcn_twostream.py就是为这个设计的。class STGCNTwoStream(nn.Module): def __init__(self, in_channels, num_class, A): super().__init__() self.stream_joint STGCN(in_channels, num_class, A) self.stream_bone STGCN(in_channels, num_class, A) def forward(self, x_joint, x_bone): out_joint self.stream_joint(x_joint) # joint 流原始坐标特征 out_bone self.stream_bone(x_bone) # bone 流骨骼向量特征 # softmax 分数级融合 return torch.softmax(out_joint, dim1) torch.softmax(out_bone, dim1)骨骼流怎么构造输入常见做法是取相邻两个关节点的坐标差NTU 的 25 个关节点能拼出 24 条骨骼向量向量的长度和方向代表这段骨骼的伸展程度与朝向。源工程里AddEdgeWeight_2.txt我推测就是调这个环节时导出的边权重快照用来检查有没有把不该连的关节接进去。两个流结构相同但权重不共享否则两个流退化成一个流融合就失去意义。融合用加法是省参数量的做法如果想更精细可以改成 concat 再接一个全连接层精度略有提升但不明显。3. 数据准备不能跳NTU-RGB-D 和 Kinetics 的处理链路3.1 NTU-RGB-D 的 25 个关节点与 ntu_gendata.pyNTU-RGB-D 是动作识别领域常用的骨骼数据集60 个动作类别每个样本由 25 个三维关节点坐标组成。原始文件是文本存储每个文件逐帧记录关节点序号和 x、y、z 坐标。ntu_gendata.py的第一步就是把文本解析成 T×V×3 的数组。def read_skeleton(file_path): with open(file_path, r) as f: lines f.readlines() num_frames int(lines[0].strip()) # 第一行声明总帧数 data [] idx 1 for _ in range(num_frames): num_joints int(lines[idx].strip()) idx 1 frame [] for _ in range(num_joints): vals list(map(float, lines[idx].strip().split())) # NTU 每行格式关节点id x y z 置信度等 if len(vals) 4: frame.append([vals[1], vals[2], vals[3]]) idx 1 data.append(frame) return np.array(data) # 返回 T x V x 3这个解析逻辑有两个坑。第一文件头写的 num_frames 经常和实际行数不一致采集中断会导致实际帧数偏少严格按 num_frames 循环会在中途 IndexError稳妥做法是解析到文件末尾。第二NTU 官方有一份缺失样本编号列表gendata 之前必须把这些坏样本过滤掉否则 pkl 里会出现空数组训练时报错还很隐蔽。我一般会在解析前先读一遍黑名单把对应编号直接跳过。解析完要做归一化。常见方案是把整段序列平移到以脊柱中心关节点为原点再除以躯干长度这样不同身高、不同站位的人体差异被抹平。归一化和裁剪帧数的先后顺序有讲究先归一化再裁剪序列两端会有轻微跳变先裁剪到窗口内再归一化可以保证每个窗口的坐标基准一致。参数方面max_frame 通常设为 300NTU 大部分样本在 100 帧左右超过 300 的做均匀下采样不足的补帧。补帧我建议用重复最后一帧而不是补零补零会让模型学习到关节点突然消失这种假模式。3.2 kinetics_gendata.py从 JSON 到 18 关节点序列Kinetics 方向的骨骼数据来自对视频做姿态估计得到的关键点存储格式是 JSON和 NTU 完全不同。每个视频对应一个文件夹里面每帧一个 JSON 文件记录这一帧检测到的所有人体和他们每个人的 18 个关键点。kinetics_gendata.py要处理两个麻烦检测不稳定导致每帧人数变化以及多人场景下选谁当主体。def load_one_frame(json_path): with open(json_path, r) as f: data json.load(f) people data[people] if len(people) 0: return None # 该帧没有人标记为缺失 # 常见策略选检测框面积最大的人作为主体 best max(people, keylambda p: bbox_area(p[pose_keypoints_2d])) pts best[pose_keypoints_2d] # 18 * 3: x, y, score 交替排列 return np.array(pts).reshape(-1, 3)选谁是整段序列质量的关键。我一般会在选出主体之后再加一道置信度过滤关键点 score 小于 0.3 的坐标全部置零并额外保存一个 mask 张量让模型知道这个点不可信否则模型会把噪点当成真实关节位置。Kinetics 的人体框大小差异很大距离镜头近的人框大远的人框小如果直接拿原始坐标训练模型会学到框大的动作类别这种伪规律所以还要按框尺寸做一次尺度归一化。参数说明kinetics_gendata.py输出的每个样本维度是 18×帧数和 NTU 的 25 关节不一致。两个数据集混训前要统一关节数常见做法是只保留两者共有的躯干和四肢关节不想混训就直接单独训练 Kinetics 权重models目录里的kinetics-st_gcn.pt就是这么来的。3.3 feeder.py 的采样与 batch 组织数据处理完是静态的 npy喂给模型是另一套逻辑。feeder/feeder.py的职责就是读 npy、随机裁剪窗口、组装 batch、转成 PyTorch 张量。class Feeder(Dataset): def __init__(self, data_path, label_path, window_size150, random_startTrue): self.data np.load(data_path, allow_pickleTrue) self.label np.load(label_path, allow_pickleTrue) self.window_size window_size self.random_start random_start def __getitem__(self, index): seq self.data[index] # 形状 C x T x V T seq.shape[1] if self.random_start and T self.window_size: start random.randint(0, T - self.window_size) else: start 0 seq seq[:, start:start self.window_size, :] return torch.from_numpy(seq).float(), self.label[index]随机 start 的作用等价于数据增强让模型在训练时看到同一动作的不同时间截取这对时序模型来说是非常关键的一步。推理阶段必须把 random_start 关掉固定从第 0 帧开始或者取序列中间窗口否则同一段序列每次推理的结果都会抖。参数上注意 window_size 和 gendata 时的 max_frame 不需要一致gendata 存的是整段序列feeder 里再裁到 window_size。我习惯 window_size 先从 150 起显存不够再降到 64不要盲目加到 300训练时间成倍增加但准确率往往不再涨。4. 训练与推理从 main.py 到离线与实时 demo4.1 processor 的入口和调参processor/processor.py定义了完整的训练循环processor/main.py是入口。启动训练的命令很直接python main.py --config config/st_gcn.twostream在 Windows 上跑之前先看两件事config 里的weights和work_dir路径有没有硬编码的 Linux 风格斜杠get_models.sh是给 Linux 下自动下载权重用的Windows 直接忽略它因为权重已经在 models 目录里了。训练循环的执行顺序是加载配置、构建模型和数据加载器、读取预训练权重、进入 epoch 循环。每个 epoch 里先 train 再 val控制台会打印 loss、top1、top5。关键超参数我通常这样起手参数含义我常用的起点batch_size每个 batch 的样本数64显存小就 32base_lr初始学习率0.1lr_decay_rate学习率衰减系数0.1step在第几个 epoch 衰减[30, 40]num_epoch训练轮数50这个配置是原版 ST-GCN 仓库延续下来的。base_lr0.1 在现在看来偏高但配合 momentum0.9 和 nesterov 是能收敛的关键是一定要让学习率在 step 处降下来否则后期 loss 会在一个平台上来回震荡。还有一个容易忽略的点config/st_gcn.twostream不是标准 JSON是一行行键值对torchlight 用它管理配置和日志。想改类别数、输入通道、权重复用哪个文件都先来这里查字段名别去代码里硬搜。4.2 demo_offline.py拿一段序列跑分类训练完或者直接用预训练权重离线推理是最容易先跑通的路。demo_offline.py的逻辑很短读一个 npy 序列、转成模型输入张量、前向、输出 softmax 分数、打印 top 类别。def offline_infer(model, npy_path): seq np.load(npy_path) # C x T x V x torch.from_numpy(seq).float().unsqueeze(0).unsqueeze(-1) # x 现在是 1 x C x T x V x 1最后的 1 表示单个人体 model.eval() with torch.no_grad(): scores torch.softmax(model(x), dim1) # 1 x num_class top2 scores.topk(2, dim1) return top2 pred offline_infer(model, demo_asset/sample_001.npy) print(top1:, classes[pred.indices[0][0]], pred.values[0][0]) print(top2:, classes[pred.indices[0][1]], pred.values[0][1])这里看 top2 是我的习惯骨架识别偶尔会把坐着和蹲下这类空间构图接近的动作搞混top2 能判断模型是犹豫还是完全判错。输入张量最后多出来的那个维是 M代表人数ST-GCN 设计上支持多人同时识别demo 里只有一个人就填 1。参数上要注意 npy 的通道 C 必须和模型输入一致单流关节模型是 3对应 x、y、z双流模型的 joint 流是 3bone 流输入骨骼向量也是 3。通道数喂错模型不一定报错但输出分数会全部乱掉。4.3 demo_realtime.py实时演示的真相demo_realtime.py是答辩时的加分项也是最容易翻车的地方。它本身不包含姿态估计只负责把姿态估计输出的每帧关节点坐标攒成一个滑动窗口窗口满了就丢给 ST-GCN 分类。window deque(maxlenwindow_len) # window_len 比如 30 帧 def on_frame(pose_result): # pose_result: 25 x 3 的关节点坐标来自上游姿态估计 if pose_result is not None: window.append(pose_result) if len(window) window_len: seq np.stack(window, axis1) # 3 x 30 x 25 x torch.from_numpy(seq).float().unsqueeze(0).unsqueeze(-1) with torch.no_grad(): scores torch.softmax(model(x), dim1) label scores.argmax(dim1).item() # 连续几帧分类结果一致后再刷新显示不要每帧都更新window_len 的选择有讲究太长模型看到的是几秒前的动作反应迟钝太短关键动作只截到一半准确率掉。我用 30 帧大约 1 秒通常是比较好的平衡点。判断当前动作可以用一个滑窗投票最近 5 次分类结果取众数超过 3 次一致才更新显示这比每帧刷新标签靠谱得多答辩现场不会被误检弄得标签来回跳。deque(maxlenwindow_len)满了会自动弹出最老帧省去手动维护数组的麻烦pose_result 缺失的帧直接跳过窗口长度按有人体的帧计数而不是按时间帧计数。实时延迟主要消耗在上游姿态估计如果卡得没法看先降上游输入分辨率再考虑减 window_len。5. 避坑记录模型权重、邻接矩阵与训练收敛的常见翻车点5.1 加载预训练权重报维度不匹配现象用models/AddEdgeSTGCN12345.pt初始化模型时报 size mismatch比如 fc 层期望 60 实际是 400或者干脆 key 对不上。原因压缩包里三个 .pt 不是同一个配置训出来的。OriginSTGCN.pt对应单流原始模型kinetics-st_gcn.pt在 Kinetics 上训练类别数是 400AddEdgeSTGCN12345.pt从命名看是边权重调整后的扩展版本结构或类别数可能和当前 config 不一致。解决先确认 config 里 model_class 是st_gcn还是st_gcn.twostream然后加载时跳过最后一层pretrained torch.load(models/AddEdgeSTGCN12345.pt, map_locationcpu) model.load_state_dict(pretrained, strictFalse) # strictFalse 跳过不匹配层strictFalse会把 backbone 权重载入再把分类层随机初始化让它适应当前数据集。但要注意如果 backbone 的 key 也对不上说明结构根本不匹配这时候先打印state_dict的 key 列表逐段核对不要盲目往下跑。5.2 复现准确率比 README 低一大截现象按 README 流程训练 50 个 epochtop1 只有 40% 上下预期是 80% 以上。原因绝大多数是数据处理链路少了一步。最常见的是缺失样本黑名单没过滤坏样本直接进训练集loss 被几个异常样本带偏其次是 feeder 里 random_start 被关掉了模型看到的序列形态单一泛化能力差。解决先把 gendata 里的黑名单逻辑跑通确认训练集样本总量和 README 一致再检查 feeder 是否开了 random_start。我的习惯是换任何数据集都先记录 train 集的样本总数任何偏差都先查数据不要急着调模型结构。5.3 训练 loss 停在 log(1/60) 附近不降现象训练到第 5 个 epochloss 还在 4.1 附近没有任何下降趋势。原因60 类动作的随机初始化交叉熵约等于 log(60)≈4.09。loss 卡在这里说明模型输出接近均匀分布梯度基本没更新。最常见的两个诱因是学习率过大导致 BN 层发散以及 batch_size 被改成了 1BatchNorm 在单样本下统计量完全不可靠。解决先查 config 里的 batch_size复现时手滑改成 1 的情况真的不少。然后把 base_lr 降到 0.01 试跑 3 个 epoch确认 loss 在下降再把学习率调回去。如果是从别人机器拷贝的 config还可能是里面带了旧的优化器状态直接删掉 work_dir 重新开始。5.4 Windows 下脚本跑不通现象get_models.sh双击没反应main.py运行到一半报路径不存在。原因get_models.sh是 Linux 下用 wget 下载权重的脚本Windows 没有对应环境工程里路径硬编码了 Linux 风格的分隔符在 Windows 上拼接直接出错。解决权重文件已经在 models 目录里根本不需要执行get_models.sh只改 config 里的 weights 路径就行。所有路径拼接我统一改成os.path.join启动前先打印一遍全部要用到的路径确认存在再继续。这个小动作能省掉大量排错时间。5.5 邻接矩阵和输入数据对不上分类结果全是同一个类现象模型能跑loss 也降但所有测试样本都被分到同一个类别。原因模型里加载的邻接矩阵 A 是 NTU 的 25 点定义而输入数据是 18 点或自定义的 17 点空间聚合把不存在的边也算了进去特征整体错乱。解决确认输入数据的关节点数量和 A 的维度一致。自定义数据集时不要沿用 NTU 的邻接矩阵要根据自己的骨骼连接关系从零构建 A再重新归一化。可视化验证一下 A 乘上输入张量后的中间输出能直接看出聚合过程是不是符合直觉这一步比反复调参高效得多。6. 把 ST-GCN 用到自己的动作类别最小改造路径如果毕设场景不是 NTU 也不是 Kinetics而是自己录的一批动作视频改造路径不复杂。先把每帧的姿态估计结果导出成关节点坐标按 C×T×V 存成 npy写一个和 feeder 兼容的数据集类然后把分类层改成自己的类别数重训。最省事的起手式是冻结 backbone只训练最后的全连接层for name, param in model.named_parameters(): if fc not in name: param.requires_grad False optimizer torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr0.01, momentum0.9 )先冻结 backbone 跑 10 个 epoch确认 loss 能从初始值降到合理范围再解冻全部参数微调。这样就算自定义数据和 NTU 的分布差异很大也不会一上来就把预训练特征冲坏。验证阶段我习惯加一个置信度阈值top1 的分数低于 0.5 就显示未识别而不是硬分到某一个类这个逻辑在答辩演示里特别好用能避免模型对完全陌生的动作给出一个煞有介事的错误答案。数据格式转换时我曾经漏了关节点置信度过滤模型把大量噪声当成真实位置准确率怎么调都上不去。从那以后我每次换数据集都强制走一遍可视化检查随机抽 5 个样本把骨骼序列画成视频确认坐标语义正确再谈训练。外人看起来像玄学但真的能救命。希望帮到你。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门