
简介这是一份基于时空图卷积网络ST-GCN的骨骼动作识别Python毕业设计源码面向计算机视觉、深度学习方向的本科生与课程设计学习者可用于人体动作识别、姿态估计等课题的快速起步。项目代码结构清晰包含完整训练与推理流程关键模块带有中文注释部署门槛较低适合作为毕业设计、期末大作业或课程设计的高分参考。资源压缩包共90个文件约52.61MB以Python源码29个py为核心搭配yaml配置、pt模型权重、prototxt网络定义以及txt说明文档同时提供gif、mp4演示素材和png图示方便直观查看识别效果与复现过程。内容还包含离线演示、实时演示、数据预处理等可运行脚本以及不同配置的模型权重对比便于直接测试与二次开发。已有189人学习下载对于需要快速搭建ST-GCN动作识别系统并了解工程化细节的读者是一份实用的参考资料。1. 基于时空图卷积的骨骼动作识别毕业设计为什么选它python 毕业设计想做点有视觉冲击力的东西基于时空图卷积ST-GCN的骨骼动作识别是这几年最稳的方向之一它不碰视频图像本身只吃骨架关键点坐标却能区分举手、鞠躬、跌倒这类动作精度还常常超过直接看视频的模型。很多同学第一次跑通 st-gcn 推理发现只需要 25 个点的坐标就能实时分类都会有点兴奋。它的门槛比目标检测低不少——你不用处理检测框、遮挡、背景噪声输入干净输出直观。适合想快速做出桌面端或网页端 demo、同时能在论文里讲清楚创新点的本科生。我当年做这个题从看懂论文到训练出自己的模型大概用了两周中间踩了不少坑。这篇笔记就把我认为最重要的原理、源代码骨架和踩坑记录写下来照着做少走弯路。2. 从骨骼数据到时空图卷积搞懂输入和计算方式很多同学拿到 st-gcn 的源代码第一反应是去看model.py里的卷积结果越看越懵。我的建议是反过来先把数据弄清楚再看网络结构。因为这个模型的输入不是图片而是一堆坐标。你如果不知道这堆坐标怎么排布后面所有训练和推理都是玄学。2.1 骨骼序列怎么变成图从 25 个关节点到邻接矩阵骨骼动作识别拿到的原始数据通常是一段视频的逐帧关节点坐标。公开数据集里NTU RGBD 用 25 个点Kinetics-skeleton 用 18 个点。每个点有 x、y 坐标有的还带置信度 confidence。把每一帧串起来就得到一个形状为(帧数, 关节点数, 坐标维度)的张量。模型里统一用(N, C, T, V, M)表示N 是样本数C 是特征通道x、y、confidence 就是 3 个通道T 是时间帧数V 是关节点数M 是人数。单人的时候 M1一帧里有多个人打架的时候 M 就不等于 1。ST-GCN 的第一步是把关节点按照人体骨骼连接关系构造成一张图。这个图不是自然语言里那种流程图而是一个带权邻接矩阵矩阵里第 i 行第 j 列的数字表示关节点 i 和关节点 j 之间有没有边、边的权重是多少。代码写出来就是这样import numpy as np num_node 25 # NTU 25 个关键点 # 这里只列出部分真实连接关系完整版要照数据集官方说明书填 edges [ (1, 2), (2, 3), (3, 4), # 左臂 (1, 5), (5, 6), (6, 7), # 左腿 (1, 0), # 脖子到头部 ] # 初始化邻接矩阵 A np.zeros((num_node, num_node)) for src, dst in edges: A[src, dst] 1 A[dst, src] 1 # 加自连接让节点也看到自己 A A np.eye(num_node) # 对称归一化D^(-1/2) A D^(-1/2) D np.diag(A.sum(axis1)) D_inv_sqrt np.linalg.inv(np.sqrt(D)) A_norm D_inv_sqrt A D_inv_sqrt print(A_norm.shape) # (25, 25)这段代码做了三件事第一把(source, target)的边列表变成邻接矩阵第二把对角线置 1让每个节点在自己聚合时能保留自身信息第三做对称归一化防止边数不同的节点在聚合时数值差异过大。这里的num_node一定要和数据集匹配如果明明是 18 点骨架你写成 25模型会多出 7 个看不见的节点训练出来的特征全是垃圾。2.2 时空卷积怎么算图卷积加时间维滑窗有了邻接矩阵图卷积的计算就和图像卷积不太一样了。图像卷积是在 3x3 的矩阵上滑窗操作的是像素网格图卷积则是做消息传递每个节点把自己的特征广播给邻居再从邻居那里收集特征。写成公式就是f_out A_norm f_in W其中是矩阵乘法。但只做这一步模型分不清手举到头顶和手放下来这两种完全不同的动作因为它们的空间拓扑是一样的。ST-GCN 的关键改进是对邻接矩阵做分区把每个节点的邻居分成三堆——第一堆是自己第二堆是比自身更靠近重心的节点向心第三堆是比自身更远离重心的节点离心。每一堆用一组独立的卷积权重最后加在一起。这个设计让它能感知肢体距离身体中心的远近变化。时间维度上ST-GCN 用一维卷积在骨架序列的时间轴上滑窗。空间图卷积处理完一帧的关节信息后时间卷积会看连续几帧之间同一个关节点是怎么动的。所以整个模型叫时空图卷积空间部分处理这个动作有没有碰到身体时间部分处理这个动作是先抬腿还是先抬手。实现上st_gcn_block就是先做空间图卷积再过一个Conv2d做时间维卷积中间夹着 BatchNorm 和 ReLU。2.3 凭什么不用 LSTM 和 3D CNN选型对比毕业设计答辩时老师一定会问为什么不用 LSTM 或者 3D CNN这里提前准备好一张对比表。方案输入精度参考计算量毕业设计友好度BiLSTM 处理骨架坐标关键点序列中等低结构简单但长期依赖差3D CNN 直接看视频RGB 视频较高很高显存压力大训练慢双流 CNNRGB光流视频光流较高极高光流提取费事ST-GCN骨骼坐标NTU 上可以到 80%较低可解释性好可视化方便ST-GCN 的优势在于输入干净不需要光流也不吃显存。我自己在 GTX 1660 上跑 NTU 的子集batch size 16 也不会显存爆掉。而且它把动作识别变成了图分类问题后面你想改成摔倒检测、健身动作计数只要换数据集和多任务头就行。相比之下3D CNN 光是预处理视频就够喝一壶的。所以如果你只有一张消费级显卡又要在答辩前出一个能看的 demoST-GCN 是更理性的选择。3. 跑通最小实现从源码下载到训练自己的动作分类器很多开源 st-gcn 项目的 README 都写得不太友好一上来就是论文引用和协议说明没有一步步教你怎么把环境跑起来。这里我整理出一套我每次在新机器上都用得到的最小流程。你不需要理解每一行先把环境拉通再回头看模型细节。3.1 环境准备python 虚拟环境与依赖安装动作识别这个方向最怕的是torch和cuda版本打架。我一般用conda创建独立环境避免影响你机器上其他项目的 python 环境。创建的时候顺手指定 3.8 或 3.9常见的 st-gcn 代码在这两个版本下问题最少conda create -n stgcn python3.8 conda activate stgcn pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy scipy pandas tqdm pyyaml如果你的机器没有 NVIDIA 显卡就把--index-url最后改成cpu模型也能跑只是训练慢很多。安装完以后用python -c import torch; print(torch.cuda.is_available())验证一下拿到True再继续。这一步虽然基础但我在线下课见过一半同学的翻车点就出现在这里——装了个 CPU 版 torch然后训练时才发现慢得离谱。参数说明--index-url指定了 PyTorch 的下载源cu118对应 CUDA 11.8。如果你用 CUDA 12就换成cu121。这个细节在命令行里占比小但错了会直接导致模型跑不起来。3.2 数据准备从 NTU 骨架文件到模型输入张量拿 NTU RGBD 举例原始数据是每个样本一个.skeleton文件内部逐帧列出人体的 25 个关节点坐标。训练前要把它变成(N, C, T, V, M)的二进制数组。下面这段脚本把每个文件的骨架点提取出来填充到固定长度 300 帧import numpy as np def load_skeleton_to_npy(path, max_frame300, num_node25): # 读取原始骨架文件解析出关键点坐标 # 这里用占位逻辑实际要根据 .skeleton 格式逐行解析 frames [] with open(path, r) as f: frame [] for line in f: parts line.strip().split() if len(parts) 3: # x y confidence frame.append([float(parts[0]), float(parts[1]), float(parts[2])]) if len(frame) num_node: frames.append(frame) frame [] if len(frames) 0: return None # 转成 (T, V, C)再补帧/截断 data np.array(frames, dtypenp.float32) # 实际可能小于 max_frame T data.shape[0] if T max_frame: pad np.zeros((max_frame - T, num_node, 3), dtypenp.float32) data np.concatenate([data, pad], axis0) else: data data[:max_frame] # 最终形状 (C, T, V)这里省略 M 维 data data.transpose(2, 0, 1) # (3, T, V) return data这块逻辑重点是transpose。原始文件是按帧排序每个帧里再按关节点顺序编号所以读进来是(T, V, C)。模型要的是(C, T, V)通道在最前面。如果你不做这一步后面模型看到的通道会是时间训练曲线会一直抖动准确率纯靠运气。max_frame这个参数值得说。NTU 原始视频长短不一从几十帧到几百帧都有。固定 300 帧之后短样本后面全是 0长样本直接截掉。这个零填充策略看上去朴素但 ST-GCN 的时间卷积对填充位置不敏感所以毕业设计里不需要用复杂策略。3.3 训练脚本超参数怎么设才能第一次就跑训练部分常见做法是在开源骨架代码里改分类数然后跑自己的数据。下面是我验证过的一套训练循环骨架放在train.py里直接执行import torch import torch.nn as nn from torch.utils.data import DataLoader from model import STGCN # 你的模型定义 device torch.device(cuda if torch.cuda.is_available() else cpu) model STGCN(num_class10, num_node25, num_layers9).to(device) optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.1) criterion nn.CrossEntropyLoss() for epoch in range(50): model.train() total_loss 0 for batch_x, batch_y in DataLoader(train_dataset, batch_size16, shuffleTrue): batch_x batch_x.to(device) # (N, C, T, V, M) batch_y batch_y.to(device) out model(batch_x) loss criterion(out, batch_y) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fepoch {epoch1}, loss {total_loss:.3f})参数说明lr0.01是 st-gcn 开源代码里常用的初始学习率配合 SGD 而不是 Adam 效果更好。step_size20表示每 20 个 epoch 学习率除以 10这个衰减节奏适合 50-80 个 epoch 的训练。weight_decay是 L2 正则防止过拟合。num_layers9是 st-gcn 的标准层数不是越多越好我在 10 类的小数据集上加到 11 层反而掉点。训练过程中如果 loss 一直不降先把学习率调到 0.001很多不收敛问题都是学习率太大。3.4 推理脚本加载模型输出动作类别训练完的模型是一个.pt或.pth文件。推理时不需要梯度所以用torch.no_grad()包住。下面是单样本推理的代码def predict(model, sequence, device): model.eval() # sequence 形状 (C, T, V)包装成 (1, C, T, V, 1) x sequence.unsqueeze(0).unsqueeze(-1).to(device) with torch.no_grad(): logits model(x) pred_id torch.argmax(logits, dim1).item() return pred_id这里的unsqueeze两次很容易出错。第一次加 batch 维变(1, C, T, V)第二次加 M 维变(1, C, T, V, 1)。如果模型在训练时用了M维的平均池化那推理时填 1 就可以。我习惯在推理脚本里把top-5也打印出来因为有些动作类别之间很像比如坐下和蹲下。只看argmax会让你误以为模型什么都认识其实第二个候选才是正确答案。打印 top-5 的代码只有三行但对答辩时解释模型行为很有帮助。4. 关键模块源码拆解图卷积层、时空卷积块和分类头第 3 章跑通了整个流程但毕业设计不能只跑别人的代码。你需要能对着源代码讲出每一层在干嘛最好还能动手改。这一章我把最核心的三个模块拆开给你可以直接抄进项目的代码骨架。4.1 图卷积层用 PyTorch 实现邻接矩阵子集卷积从 2.2 节我们知道ST-GCN 会把邻接矩阵按向心、离心分成多个子集。每个子集对应一个可学习的1x1卷积。下面这个BlockGCN就是把多子集图卷积写在一起import torch import torch.nn as nn class BlockGCN(nn.Module): def __init__(self, in_channels, out_channels, A_blocks): super().__init__() # A_blocks 是 list每个元素是归一化后的邻接矩阵 tensor形状 (V, V) self.A_blocks nn.ParameterList([ nn.Parameter(A.float(), requires_gradFalse) for A in A_blocks ]) self.gcns nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size1) for _ in A_blocks ]) def forward(self, x): # x 形状 (N, C, T, V) res 0 for A, gcn in zip(self.A_blocks, self.gcns): # 邻接矩阵在 V 维上做矩阵乘法 Ax torch.einsum(nctv,vw-nctw, x, A) res res gcn(Ax) return res这里有个细节容易踩坑requires_gradFalse表示邻接矩阵是固定的不参与训练。如果你想做自适应图卷积把它改成True模型就会自己学出关节之间的隐含连接关系。很多高分毕业设计的点就加在这里。einsum这一行是空间图卷积的核心把(T, V)中的 V 维通过邻接矩阵映射到新的 V 维通道数不变最后由1x1卷积把通道数变成out_channels。4.2 时空卷积块GCN BN ReLU Temporal Conv 的拼装图卷积做完只处理了空间。要让模型知道动作随时间怎么变化还要在时间维上做卷积。整个STGCNBlock是这样拼起来的class TemporalConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size3, stride1): super().__init__() self.conv nn.Conv2d( in_channels, out_channels, kernel_size(kernel_size, 1), stride(stride, 1), padding(kernel_size // 2, 0) ) def forward(self, x): return self.conv(x) class STGCNBlock(nn.Module): def __init__(self, in_ch, out_ch, A_blocks, stride1, residualTrue): super().__init__() self.gcn BlockGCN(in_ch, out_ch, A_blocks) self.tcn TemporalConv(out_ch, out_ch, kernel_size3, stridestride) self.bn1 nn.BatchNorm2d(out_ch) self.bn2 nn.BatchNorm2d(out_ch) self.relu nn.ReLU() self.residual residual if residual and (in_ch ! out_ch or stride ! 1): self.down_res nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size1, stride(stride, 1)), nn.BatchNorm2d(out_ch), ) else: self.down_res None def forward(self, x): res x out self.gcn(x) out self.bn1(out) out self.relu(out) out self.tcn(out) out self.bn2(out) if self.residual: if self.down_res is not None: res self.down_res(res) out out res return self.relu(out)这段代码里TemporalConv的kernel_size(3, 1)表示只在时间维 T 上滑窗空间维 V 上卷积核是 1等于不做空间卷积。padding(1, 0)是为了保持时间长度。stride(stride, 1)会在时间维上降采样残差分支里的down_res也要一起降采样否则两个分支形状对不上直接报错。为什么要有残差ST-GCN 堆了 9 层没有残差的话深层网络容易退化训练 loss 降不下去。我第一次把residual设成False跑 50 个 epoch准确率卡在 60% 上不去改成残差才到 80% 以上。这是血泪经验不是玄学。4.3 分类头与损失函数动作标签怎么从特征里出来模型最后要把(N, C, T, V)的特征变成每个动作类别的概率用全局平均池化把时间维和空间维压成一个向量再接全连接class STGCN(nn.Module): def __init__(self, num_class, A_blocks, num_node25, num_layers9): super().__init__() self.blocks nn.ModuleList() in_ch 3 out_ch 64 for _ in range(num_layers): self.blocks.append(STGCNBlock(in_ch, out_ch, A_blocks)) in_ch out_ch self.pool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(out_ch, num_class) def forward(self, x): # x: (N, C, T, V, M) N, C, T, V, M x.size() x x.mean(dim-1) # 人数 M 维平均池化 for block in self.blocks: x block(x) x self.pool(x).view(N, -1) return self.fc(x)x.mean(dim-1)是处理多人的常见做法把同一个关节点的多个人的特征平均起来。缺点是如果两个人动作不同平均完特征就糊了。想精细一点可以改成最大池化或者用注意力机制给每个人加权。这个改动工作量不大但答辩时能讲出很充分的动机。分类头之后接CrossEntropyLoss它内部已经包含了 softmax所以训练代码里不需要手动加。损失函数那一行在第 3.3 节里已经写过了用的是nn.CrossEntropyLoss()。如果你的项目类别高度不均衡比如跌倒只有 50 个样本走路有 2000 个就换成带权重的版本weights torch.tensor([1.0, 1.0, 5.0, ...]).to(device) criterion nn.CrossEntropyLoss(weightweights)权重设成样本数的倒数或者按经验调。这个细节在真实数据集上特别管用单纯加数据增强救不回来。5. 避坑ST-GCN 训练不收敛、显存爆炸和数据陷阱这一章写的是我在这条路上踩过的最深的几个坑。每一条都按现象、原因、解决顺序写你可以当作排查手册用。5.1 训练 loss 变成 NaN准确率随机跳动现象训练到第几个 epochloss 突然变成nan之后所有 epoch 都是nan准确率跟猜差不多。原因最常见的是输入数据里有关键点坐标是无穷大或者缺失值。骨架检测器偶尔会输出0.0附近的异常值后续归一化时除以标准差把 0 变成inf。另一个原因是邻接矩阵归一化时度矩阵里出现 0比如某个节点没有任何连接np.linalg.inv(np.sqrt(D))就会除零。解决在加载数据后做一次脏数据清洗把所有non-finite值统一替换成0.0。然后检查自己构造的邻接矩阵打印A.sum(axis1)如果出现 0说明边列表漏了节点必须补上。我写过一个最简单的清洗data np.nan_to_num(data, nan0.0, posinf0.0, neginf0.0)5.2 训练 loss 不下降准确率一直在 10%随机水平现象模型结构没问题数据也没问题但训练了 30 个 epochloss 在 2 点几和 3 点几之间来回晃准确率上不去。原因八成是学习率太大或者输入没有归一化。ST-GCN 的输入是关节坐标单位是像素数值从 0 到 1000这么大尺度的输入直接喂进去梯度会震荡。另一个常见原因是数据增强里做了固定平移但训练集和测试集平移量不一致。解决先把坐标缩放到 -1 到 1 之间常见做法是除以图像宽高或者用整个训练集的均值和标准差做 z-score 归一化。然后把学习率从 0.01 降到 0.001试试。我遇到不收敛时直接先跑 5 个 epoch 的lr0.001观察 loss 是否稳步下降再决定要不要用 warmup。5.3 显存 OOMbatch size 调到 4 还是爆现象训练刚开始CUDA out of memory直接弹出来。把 batch size 改成 4 甚至 2依然爆。原因ST-GCN 的名义输入是(N, C, T, V, M)但很多实现会在网络内部把V和T展开成更大的张量。如果T300V25中间特征图的 size 实际上是(N, 64, 300, 25)这一个张量就接近百万级元素。9 层叠加起来显存消耗不小。另外一个隐藏原因是 M 维没处理好如果输入里 M 大于 1有的人直接x.expand张量会大好几倍。解决先用单人数据把 M 固定成 1跑通流程再说。其次把max_frame从 300 降到 150很多动作 150 帧足够判断。如果还爆就在STGCNBlock的tcn里减小通道数比如把out_ch从 64 改成 32。显存不够时不丢人模型照样能训练只是精度略降。5.4 自采数据上准确率高换一个环境/人就不准现象自己在实验室采集的数据训练完准确率 95%换成另一台机器、另一个人做的动作准确率掉到 60%。原因这是骨架动作识别最经典的泛化问题。ST-GCN 学到的是空间拓扑和时间模式但它对坐标原点非常敏感。你的训练数据里人体总是在画面中央换到左边一点模型就懵了。另外不同采集设备Kinect、OpenPose、MediaPipe输出的关节编号和坐标语义不完全相同。解决训练时做随机平移、旋转、缩放增强让模型对摄像角度不那么敏感。我用的是下面这种轻量增强效果立竿见影def augment_skeleton(data): # data: (C, T, V)C3 通道前两个是 x, y dx np.random.uniform(-0.1, 0.1) dy np.random.uniform(-0.1, 0.1) data[0] dx data[1] dy angle np.random.uniform(-10, 10) * np.pi / 180 rot np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) data[:2] np.einsum(ij,jtv-itv, rot, data[:2]) return data另外迁移到新设备时最好用新设备的少量数据做微调哪怕每个类只有 20 个样本都能拉回不少准确率。5.5 推理时速度慢每秒只能处理两三帧现象模型训练完实时推理发现很卡一秒钟只能处理几个帧完全达不到实时。原因输入序列长度太长。如果你把max_frame设成 300每次都要计算 300 帧的图卷积推理速度当然慢。还有一个坑是数据加载器里做了复杂的transpose和拷贝这部分时间被忽略了。解决推理时把max_frame截断到 100 帧甚至 50 帧。ST-GCN 的时间卷积感受野是 3 帧对长序列的依赖并没有想象中那么强。另外用torch.backends.cudnn.benchmark True打开 cuDNN 自动调优。我的经验是50 帧的输入在 GTX 1660 上能跑到 40 FPS 左右足够做实时 demo。如果还慢就是模型层数太多把num_layers从 9 降到 6精度只掉 1-2 个点速度能翻倍。6. 毕业设计拿高分可视化、消融实验和源代码管理前面把模型跑通、坑也避开了最后这一步是让答辩老师觉得你这工作量够的关键。我见过太多人代码能跑但答辩被问住就是缺少可视化和对比实验。这里分享三个我实际用过、见效最快的提分动作。第一个动作是画训练曲线。不要只画一张 loss 图把训练集的 loss、验证集的 loss、验证集准确率画在一个图里。老师最关心的不是最终精度而是你有没有考虑过拟合。我看到验证集 loss 回升、训练集 loss 继续下降就能立刻说出这个模型在第 40 个 epoch 附近开始过拟合应该提前停止。这比泛泛地说我调了参有说服力得多。第二个动作是做一个消融实验表。拿掉 ST-GCN 的三分区策略、拿掉时间卷积、拿掉残差连接分别记录准确率。比如这样一个表格模型配置准确率完整 ST-GCN82.1%去掉离心分区78.6%去掉时间卷积71.3%去掉残差连接74.5%这张表一拿出来老师就明白你不仅会跑代码还知道每个模块为什么存在。做完消融实验顺便把结论写进论文的实验章节毕业论文的篇幅一下子就夯实了。第三个动作是把源代码管理整理好。这不是让你用 Git 做多复杂的事情而是保证别人拿到代码后能复现。我会在项目里放一个requirements.txt把环境依赖全部锁住再放一个README.md写清数据集格式、模型输入输出形状、训练命令和推理命令。代码里每个类名、每个函数都写上三行注释。你想想答辩老师如果能在三分钟内把 demo 跑起来他对你的项目印象会好很多。反之如果源码里全是model_v2_final_真改.py这种命名再好的实验结果都会被扣分。最后说一个我的教训千万不要在答辩前夜临时改num_layers、dropout这种超参数觉得能刷高一点准确率。有一次我改完发现验证集掉了 5 个点连夜回滚第二天演示时曲线都乱了。后来我固定参数后提前一周把所有实验跑完之后只做可视化、整理消融表。毕业设计不是一个模型刷到多高而是整个过程能解释、能复现。希望帮到你。本文还有配套的精品资源点击获取