简介基于时空图卷积ST-GCN的骨骼动作识别项目面向计算机、数学、电子信息等专业课程设计、期末大作业与毕设场景提供可直接运行的Python源码与配套项目说明便于快速复现从骨架序列到动作类别的识别流程。压缩包共90个文件以29个Python源码为核心另有13个YAML配置、3个预训练模型.pt、11个演示GIF与3个MP4视频、9个TXT数据说明及3个Markdown文档整体约52.55MB目录覆盖数据预处理、ST-GCN与双流ST-GCN网络、离线/实时推理演示等模块。项目内不仅包含NTU-RGB-D和Kinetics数据处理脚本、参考模型权重如原始ST-GCN与增强边权重版本、推理与演示代码还提供动作类别映射、边权重文件等细节资料有助于对照论文理解图卷积如何建模骨骼关节点时空拓扑。配套的说明文档与演示动画能降低环境搭建和调试门槛方便课程汇报、算法对比或毕业设计原型选用。目前已有588人学习下载适合具备Python基础、希望深入研究动作识别方向的进阶学习者参考。1. 骨骼动作识别不是看视频是看骨架ST-GCN 这个源码包到底能做什么摄像头捕捉到的画面里一个人在挥手、走路、摔倒传统做法是逐帧分析 RGB 像素但光照变了、背景乱了、衣服换了模型就翻车。基于时空图卷积ST-GCN的骨骼动作识别换了一条路先用姿态估计算法把人体的关节点坐标抽出来再把这些坐标组成的骨架序列交给图卷积网络去分类。你手里这份python源码项目说明.zip就是一套完整的 ST-GCN 工程——从骨架数据构建、邻接矩阵生成、网络训练到动作分类推理都有代码可改。适合正在做行为识别、老人跌倒检测、健身动作计数或者想从图像分类转向图神经网络落地的人。源码包不是黑匣子真正跑通它你需要理解三个东西输入张量长什么样、图卷积怎么在关节上滑动、以及训练时哪些参数值得调。2. ST-GCN 的原理骨架时空图卷积的两个关键设计2.1 为什么是图不是网格骨骼数据没法用普通卷积普通卷积假设输入是规则的网格结构比如图片的像素矩阵、语音的频谱图。但人体骨架是一堆关节点的集合——手腕连着肘部肘部连着肩膀膝盖连着髋部——这种连接关系是不规则的图结构没法直接套 3×3 卷积核。ST-GCN 的核心贡献就是把「图卷积」和「时序卷积」拼在一起空间上用图卷积聚合相邻关节的特征时间上用一维卷积沿着帧序列滑动捕捉动作的节奏变化。这么做还有一个实际好处骨骼坐标对背景、光照、衣着不敏感模型的鲁棒性比纯 RGB 方案高而且输入数据量小得多一条 30 帧的序列只有十几个关节的坐标训练和推理都快。图卷积的数学形式不复杂f_out D^(-1/2) * (A I) * D^(-1/2) * f_in * W其中 A 是邻接矩阵表示关节之间的连接关系I 是单位矩阵相当于给每个关节加一条自连接让节点在聚合邻居信息的同时保留自身特征D 是度矩阵用来做归一化防止不同关节的邻居数量差异导致特征尺度失衡。W 就是可学习的权重矩阵。这段公式看着唬人但你不需要手推梯度只需要理解图卷积做的事情是「把每个关节的特征用它自己和它邻居的特征加权求和后过一个全连接层」。2.2 邻接矩阵与分区策略一个矩阵决定模型能学到什么邻接矩阵是 ST-GCN 里最值得花时间研究的对象。默认做法是用人体自然连接关系构建 A比如手腕连手肘、手肘连肩膀、髋部连膝盖矩阵里对应位置为 1没有连接为 0。但原文作者更进一步提出了分区策略——不是把所有邻居一视同仁而是把邻居分成几组。最常见的三种分区分区策略分组逻辑适用场景统一标签所有邻居含自身一组简单动作、小数据集距离分区自身一组、近邻一组需要区分自运动与相对运动空间配置分区自身、向心、离心三组原文默认效果最稳向心和离心的判断标准是邻居关节到骨架重心的距离比当前关节近就是向心远就是离心。这个分区的直觉是——一个动作里肢体末端向躯干收拢还是向外伸展往往是区分动作类别的关键信号。在源码里这个逻辑通常体现为一个get_adjacency_matrix函数输入关节连接关系和分区策略输出归一化后的邻接矩阵列表。后续图卷积的每一层会对每一组邻接矩阵分别做一次聚合再把结果拼接起来。2.3 数据准备从视频到骨骼序列的落地流程要做 ST-GCN你首先得把原始视频变成骨骼序列。常见做法是用姿态估计算法OpenPose 或 MediaPipe逐帧提取关节点坐标。以 MediaPipe 为例它输出 33 个关节点每个点有 x、y、z 归一化坐标和一个可见度分数。你需要把这些点按固定顺序排好然后按时间轴堆叠成张量。这一节是源码包里「数据处理」目录的核心任务。import cv2 import mediapipe as mp mp_pose mp.solutions.pose pose mp_pose.Pose(static_image_modeFalse, min_detection_confidence0.5) cap cv2.VideoCapture(demo.mp4) frames [] while cap.isOpened(): ret, frame cap.read() if not ret: break # 将 BGR 转 RGB 再送入姿态估计 results pose.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) if results.pose_landmarks: # 按固定的 33 个关节点顺序取坐标 seq [[lm.x, lm.y, lm.z] for lm in results.pose_landmarks.landmark] frames.append(seq) cap.release()这段代码把每一帧的 33 个关节点坐标按顺序存进列表。min_detection_confidence是检测置信度阈值低于这个值的关键点会被忽略实际项目中我习惯设 0.5太低会引入噪声关节太高会导致小目标人物直接被跳过。得到frames后还需要统一序列长度。两个视频一个 30 帧一个 50 帧不能直接进网络要做采样或补帧超过设定帧数就均匀抽帧不足就重复最后一帧或插值。这个预处理直接决定了后续张量的 T 维大小。3. 把源码跑起来环境搭建、训练与推理的最小路径3.1 环境准备与依赖清单源码包是 Python 写的模型主体基于 PyTorch。先确认基础环境Python 3.8 或 3.9PyTorch 1.8 以上这几个版本组合最稳新版本 PyTorch 的 API 变化会影响部分算子兼容性。需要安装的核心依赖有 numpy、opencv-python、scipy、PyTorch如果要用 MediaPipe 提取骨骼数据还需要额外装 mediapipe。# 建议先创建独立的虚拟环境避免污染系统 Python python -m venv stgcn_env source stgcn_env/bin/activate # 安装 CPU 版 PyTorch无 GPU 机器先用这个跑通流程 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 安装其他依赖 pip install numpy opencv-python scipy mediapipe--index-url指定了 PyTorch 官方 CPU 版 wheel 源这样装到的 torch 不依赖 CUDA适合先验证代码能否跑通。如果你的机器有 NVIDIA 显卡并且已经装好了 CUDA 11.x 和对应驱动就把--index-url换成--index-url https://download.pytorch.org/whl/cu118训练速度会快几十倍。装完依赖后先在 Python 里执行import torch; print(torch.__version__)确认没有报错这是排查环境问题最快的方式。3.2 构建邻接矩阵图卷积的输入地基邻接矩阵是图卷积的输入地基源码包里通常会有一个单独的文件来处理它。这里按最常见的「空间配置分区」写清楚构建逻辑。import numpy as np # 以 OpenPose 的 18 个关节点为例定义人体自然连接 # 编号规则0-鼻子, 1-脖子, 2-右肩, 3-右肘, 4-右手腕, # 5-左肩, 6-左肘, 7-左手腕, 8-右髋, 9-右膝, 10-右踝, # 11-左髋, 12-左膝, 13-左踝, 14-右眼, 15-左眼, 16-右耳, 17-左耳 num_node 18 self_link [(i, i) for i in range(num_node)] # 自连接 inner_link [(1, 2), (2, 3), (3, 4), (1, 5), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10), (1, 11), (11, 12), (12, 13), (0, 1), (0, 14), (14, 16), (0, 15), (15, 17)] # 自然骨骼连接 neighbor_link inner_link # 计算每个关节到重心的平均距离用于判断向心/离心 def get_hop_distance(num_node, edge, max_hop1): A np.zeros((num_node, num_node)) for i, j in edge: A[j, i] 1 A[i, j] 1 hop_dis np.zeros((num_node, num_node)) np.inf for i in range(num_node): hop_dis[i, i] 0 for hop in range(max_hop 1): for i in range(num_node): for j in range(num_node): if hop_dis[i, j] hop: for k in range(num_node): if A[k, j] 1 and hop_dis[i, k] hop 1: hop_dis[i, k] hop 1 return hop_dis hop_dis get_hop_distance(num_node, neighbor_link) # 按空间配置分区生成三个邻接矩阵 A [] for hop in range(3): a np.zeros((num_node, num_node)) for i in range(num_node): for j in range(num_node): if hop_dis[j, i] hop: # 重心所有关节坐标的平均位置这里先用连接关系近似 center 1 # 假设 1 号(脖子)为重心 if hop_dis[j, center] hop_dis[i, center]: a[j, i] 1 # 向心 else: a[j, i] 2 # 离心 A.append(a)这段代码是 ST-GCN 预处理的核心三组邻接矩阵分别对应「自身」「向心邻居」「离心邻居」。get_hop_distance用 BFS 方式计算任意两个关节点之间的最短距离把距离为 1 的邻居视为近邻然后根据它们与重心的距离差决定向心还是离心。注意这里为了方便演示重心直接取了脖子节点完整实现里应该用所有关节点坐标的平均值。分区矩阵的数值 1、2 只是标识真正输入网络之前每一组矩阵还需要做度归一化否则聚合后的特征尺度会失衡。3.3 ST-GCN 核心网络结构拆解图卷积层与时空卷积块理解了输入和邻接矩阵接下来看网络主体。ST-GCN 的基本单元是「空间图卷积 时间卷积」的组合块整个网络由多个这样的块堆叠而成。import torch import torch.nn as nn class SpatialGraphConvolution(nn.Module): 空间图卷积对每组邻接矩阵分别做聚合再融合 def __init__(self, in_channels, out_channels, num_nodes, num_subsets3): super().__init__() self.num_subsets num_subsets # 每组邻接矩阵对应一个独立的 1x1 卷积本质是特征变换 self.convs nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size1) for _ in range(num_subsets) ]) # 可学习的边权重初始化为全 1 self.edge_weights nn.Parameter(torch.ones(num_subsets, num_nodes, num_nodes)) def forward(self, x, A): # x 形状: (N, C, T, V)N 是 batchC 是通道T 是帧数V 是关节数 N, C, T, V x.shape out None for i in range(self.num_subsets): # A[i] 与可学习边权重逐元素相乘再做归一化 adj A[i] * self.edge_weights[i] # 将邻接矩阵作用到关节维度先调整维度再做矩阵乘法 x_aggr torch.einsum(nctv,vw-nctw, x, adj.to(x.device)) # 每组结果过一个 1x1 卷积再累加 y self.convs[i](x_aggr) out y if out is None else out y return out class TemporalConvolution(nn.Module): 时间维卷积在帧序列上做一维卷积 def __init__(self, in_channels, out_channels, kernel_size9): super().__init__() padding (kernel_size - 1) // 2 # 保持时间维长度不变 self.conv nn.Conv2d(in_channels, out_channels, kernel_size(kernel_size, 1), padding(padding, 0)) def forward(self, x): return self.conv(x)SpatialGraphConvolution的输入 x 形状是(N, C, T, V)四个维度分别代表样本数、特征通道数、序列帧数、关节数。torch.einsum(nctv,vw-nctw, x, adj)是在关节维度上做矩阵乘法——把每个关节的特征向量替换为它自己和邻居加权聚合后的结果edge_weights是 ST-GCN 原文里的一个优化点它允许模型在训练中自动调整邻居之间的权重初始化为全 1表示「先按邻接矩阵的拓扑结构聚合再通过反向传播学习哪些连接更重要」。TemporalConvolution是标准的时间卷积kernel_size9意味着每次看到前后各 4 帧的上下文padding 保证输出帧数与输入一致。这两个模块交替堆叠就形成了完整的 ST-GCN 骨干网络。3.4 训练与推理的最小命令源码包通常把训练和推理逻辑分开train.py负责读数据、算损失、更新权重test.py负责加载权重、推理并输出分类结果。跑通训练一般只需要一条命令python train.py --dataset ntu --data-path ./data/ntu_skeleton --epochs 80 --batch-size 32 --lr 0.1 --weight-decay 0.0001--dataset指定数据集类型ntu是 NTU RGBD 骨架数据集的简称这是 ST-GCN 最常用的公开基准--data-path指向预处理之后的骨架数据目录数据文件通常是.npy格式--epochs和--batch-size控制训练轮数和每批样本数--lr 0.1用 SGD 优化器时是常用初始值配合--weight-decay 0.0001做正则化防止过拟合。如果你的数据集很小比如只有几千条自采动作建议--epochs降到 30、--lr降到 0.01否则模型会在训练集上快速过拟合。训练完成后推理命令更简单python test.py --weights ./checkpoints/best_model.pt --video-path ./demo/test.mp4test.py内部会先对视频帧调用姿态估计提取关节点坐标组成与训练时相同格式的张量再加载best_model.pt权重输出每个动作类别的概率。注意推理时的关节点数量和顺序必须与训练时完全一致——用 MediaPipe 训练却用 OpenPose 推理或者反过来都会因为关节编号对不上而直接报错或得到荒谬的预测结果。这个坑在下一章详细展开。4. 避坑指南从能跑到跑得对的 5 条踩坑记录4.1 关节数量不一致导致权重加载失败检查通道数而不是报错信息现象torch.load()加载预训练权重时报size mismatch错误指向某个卷积层的weight shape对不上。原因预训练模型是基于 OpenPose 的 18 个关节点训练的第一层图卷积的权重形状是(out_channels, in_channels, 1, 18)而你的输入用了 MediaPipe 的 33 个关节点输入通道数对不上。解决要么把数据统一成预训练模型的关节格式要么修改网络第一层的num_nodes参数并重新初始化该层权重。我一般会在源码里加一段检查逻辑打印model.state_dict()中第一层卷积的权重形状对比训练数据里关节点的数量不一致就停在这里不要靠猜。4.2 骨骼点缺失导致的序列断裂补点比丢帧更值得做现象动作视频里的人被桌子挡住半边身体姿态估计结果里连续十几帧的右手腕坐标全是 0 或 NaN训练 loss 忽高忽低推理时该动作被错分成其他类别。原因骨骼点缺失时坐标被填充为 0在归一化和图卷积后这些 0 值会作为「真实位置」参与聚合误导模型。解决不要直接丢弃缺失帧因为时间维度的连续性对 ST-GCN 至关重要。常见做法是线性插值取缺失点前后最近的可见帧坐标按帧索引比例插值填补如果整段序列缺失超过三分之一直接丢弃这条样本更省事。数据质量优先于数据量这个判断做不好后面调参全是徒劳。4.3 未做坐标归一化导致 loss 不降或震荡现象训练几十个 epoch训练 loss 一直在 2 到 4 之间震荡验证准确率几乎不变。原因输入的关节坐标是原始像素值范围在 0 到 1920 之间而图卷积的权重初始值在 0 附近大数值输入会放大梯度导致优化过程不稳定。解决在数据预处理阶段把坐标按视频宽高归一化到 0~1 区间再做一次整体均值和方差标准化。源码包里如果有normalize.py或preprocess.py先看它对坐标做了什么处理如果没做归一化补上之后再训练loss 通常会在前三个 epoch 内降到 1 以下。4.4 显存不足与 batch size 的取舍现象CUDA out of memory报错发生在训练开始的第一个 batch但你的模型看起来不大。原因ST-GCN 的中间特征图在时间维和关节维上都有较大的张量特别是kernel_size9的时间卷积会为每个样本保留多帧的中间结果此外batch-size 32对 GPU 显存的要求远超同样参数量 CNN。解决先把--batch-size降到 8 或 4确认能跑通后再逐步增大还不行就减少时间卷积核大小到 5这会轻微降低感受野但能显著省显存。如果用的是 CPU 机器合理设置 DataLoader 的num_workers4避免每轮迭代都在等数据读取。4.5 PyTorch 与 CUDA 版本不一致导致训练直接崩溃现象torch.cuda.is_available()返回False但nvidia-smi能正常看到显卡。原因PyTorch 是 CPU 版编译的或者 PyTorch 版本对应编译时的 CUDA 版本与你机器上的驱动不兼容。解决先看nvidia-smi输出的 CUDA Driver 版本再到 PyTorch 官网选择对应版本的 wheel 安装命令不要用pip install torch默认装到的版本——它通常会装 CPU 版。卸载旧版本后用pip install torch --index-url 对应whl地址重装确认torch.cuda.is_available()变成True再继续。5. 一个值得试的进阶操作可视化关节注意力验证模型到底学了什么模型训练好之后除了看准确率我更建议你做一件事把训练好的edge_weights参数导出来可视化。edge_weights是空间图卷积里可学习的边权重训练完成后它记录了模型认为哪些关节之间的连接对分类最重要。把这个权重按关节连接关系画到骨架图上——线条越粗代表权重越大——你就能直观地看到模型在做「挥手」分类时是否真的依赖手肘和手腕的连接还是靠肩膀的运动学特征。import matplotlib.pyplot as plt # 取出第一层空间图卷积的边权重 edge_weights model.layers[0].spatial_conv.edge_weights.detach().cpu().numpy() # 对三组邻接矩阵的权重取平均得到每个连接的贡献度 avg_weights np.mean(edge_weights, axis0) # 把权重大于阈值的连接画出来阈值可以按数据分布调整 threshold np.percentile(avg_weights, 80) # 只看最大的 20%可视化结果如果显示模型过度依赖躯干中心的几个连接而对四肢末端的连接几乎不敏感说明你的数据里四肢末端运动被噪声淹没或者姿态估计在细小关节上的坐标不准。这时候与其盲目调网络结构不如回头检查数据标注和数据增强——比如是否有人在样本里做了左右翻转增强、关节坐标是否被平滑滤波过。这个习惯帮我排除过好几次「模型没学好」其实「数据有问题」的情况。ST-GCN 这类模型的可解释性本来就比纯 CNN 好不利用起来有点可惜。希望这些经验对你跑通这个源码包有帮助。本文还有配套的精品资源点击获取