ST-GCN骨骼动作识别:从原理到PyTorch实现与踩坑经验
简介一份基于时空图卷积网络ST-GCN的骨骼动作识别Python毕业设计项目涵盖完整源代码、训练好的模型与项目文档适合计算机视觉方向的高校毕业生、研究生及动作识别初学者参考。项目以人体骨骼关键点为输入通过ST-GCN捕捉时空特征实现对动作的端到端识别能够帮助读者理解图卷积在视频理解中的应用并可作为毕业设计、课程项目或算法实验的基线系统。压缩包共91个文件以Python源码、yaml配置、预训练pt模型、演示gif与mp4、项目说明md/txt为主整体约52.54MB目录划分清晰便于按模块查阅。目前已有175人学习下载。作者在完成该项目时经过充分测试并获答辩评审94.5分资料中除可直接复用的代码和模型外还附有设计思路、实验过程与结果分析等文档便于快速上手和二次开发。1. 为什么说 ST-GCN 是骨骼动作识别绕不开的模型同一个动作换个人做、换到暗光下做、从侧面做RGB 视频的识别结果可能完全不一样但换成骨骼关键点后电脑看到的是结构完全相同的一副骨架——这是骨骼动作识别过去几年迅速普及的根本原因。ST-GCNSpatial Temporal Graph Convolutional Network时空图卷积网络正是这套思路里最经典的方案把人体骨骼关键点定义成图节点用图卷积捕获关节之间的空间依赖再叠加时间卷积捕获动作节奏配合一份可复现的 python 实现成了摔倒检测、健身计数、手势识别类毕业设计的常见起点。这篇笔记面向拿着代码包想跑通、想训练、想换成自己数据集的人不讲空概念只讲建模逻辑、工程结构、训练参数和踩坑经验。2. 把骨骼序列变成图ST-GCN 的空间建模与时间卷积2.1 骨骼数据为什么能画成图节点、边与三个子图骨骼数据不是一张图但它的结构天生适合用图表达。每一帧里你能拿到一串关节点的坐标比如 NTU RGBD 数据集里有 25 个关节点Kinetics-skeleton 里是 18 个。这些点不是孤立的坐标它们通过骨骼连接成一个人体拓扑头连着脊柱手肘连着肩膀膝盖连着髋部。这个拓扑每帧都一样所以可以把它定义成一张固定的图 G(V,E)V 是关节点E 是骨骼连接关系。用代码来构建这张图核心就是写邻接矩阵import numpy as np # 以 25 个关节点为例NTU RGBD 的骨架定义每条边写作 (起点, 终点) edges [ (0, 1), (1, 20), (2, 20), (3, 2), (4, 3), # 躯干到头 (5, 20), (6, 5), (7, 6), (8, 7), # 左臂 (9, 20), (10, 9), (11, 10), (12, 11), # 右臂 (13, 0), (14, 13), (15, 14), (16, 15), # 左腿 (17, 0), (18, 17), (19, 18), (20, 19), # 右腿 ] def build_adjacency(num_nodes, edges, self_loopTrue): A np.zeros((num_nodes, num_nodes)) for i, j in edges: A[i, j] 1.0 A[j, i] 1.0 # 骨骼连接是无向的信息可以在两个方向流动 if self_loop: A np.eye(num_nodes) # 加上自连接保留节点自身的特征 return A A build_adjacency(25, edges) print(A.shape) # (25, 25)这段代码里最关键的是做了对称化和自连接。对称化让图卷积在聚合邻居信息时同时考虑“肘部到手”和“手到肘部”两个方向自连接则保证更新某个节点特征时不会丢掉它自己原来的信息这在多层堆叠时尤其重要否则每过一层节点特征都被邻居“平均”掉自身特性会快速丢失。ST-GCN 并没有直接用这一张 A而是把图拆成了三个子图。第一个子图是自连接本身负责保留关节自身特征第二个子图是一跳邻居也就是骨骼两端的直接连接比如手肘和手腕第三个子图是二跳邻居比如手到肩膀这种跨过一个关节的联系用来捕捉更大范围的空间依赖。这三个子图分别对应邻接矩阵的零次幂、一次幂和二次幂本质上是让模型在同一层里看到局部、远端和自身三种尺度的信息。很多新手直接拿原始 A 去训练精度会明显偏低原因就是缺少这种多尺度拆分。2.2 一层的图卷积在前向里做了什么hop 机制与归一化图卷积的输入是特征张量 X形状是 (N, C, T, V)其中 N 是样本数C 是通道数T 是时间帧数V 是关节点数。单层图卷积做的事情本质上就是“用邻接矩阵去聚合邻居的特征”。最朴素的写法是 X A X WA 是邻接矩阵W 是可学习的权重矩阵。但直接这么写有两个问题一是邻接矩阵的行和不一样度数大的节点比如躯干中心聚合出来的数值天然比四肢节点大训练不稳定二是 A 和 W 直接相乘W 要同时承担特征变换和归一化两件事不好学。所以 ST-GCN 先对邻接矩阵做对称归一化再按 hop 拆开每一跳配一个独立的权重矩阵import torch import torch.nn as nn def normalize_adjacency(A): # A: (V, V)加上自连接后计算 D^{-1/2} A D^{-1/2} D np.diag(A.sum(axis1)) D_inv_sqrt np.linalg.inv(np.sqrt(D)) return D_inv_sqrt A D_inv_sqrt # 三个子图对应的 hop 邻接矩阵 A0 np.eye(25) # 自连接 A1 normalize_adjacency(build_adjacency(25, edges, self_loopFalse)) # 一跳 A2 normalize_adjacency(A1 A1) # 二跳通过矩阵乘法自动得到 class SpatialGraphConv(nn.Module): def __init__(self, in_ch, out_ch, num_hop3): super().__init__() self.num_hop num_hop # 每个 hop 配一组 1x1 卷积只做通道变换不改变空间结构 self.convs nn.ModuleList([ nn.Conv2d(in_ch, out_ch, kernel_size1) for _ in range(num_hop) ]) def forward(self, x, A_list): # x: (N, C, T, V)A_list: 长度为 num_hop 的 (V, V) 张量 out 0 for hop in range(self.num_hop): # einsum 表示nctv, vw - nctw把 V 维度的特征按邻接关系聚合 x_h torch.einsum(nctv,vw-nctw, x, A_list[hop]) out out self.convs[hop](x_h) return out这里 einsum 表达式nctv,vw-nctw的含义是对每个样本 n、每个通道 c、每个时间帧 t取特征向量长度 V与邻接矩阵的第 v 行做加权求和结果仍然是长度 V 的向量。这个过程等价于“每个节点把邻居的特征加权累加到自己身上”而权重就是归一化后的邻接矩阵系数。1x1 卷积在这里的作用容易被低估。图卷积做完聚合后每个节点的特征仍然是 in_ch 维1x1 卷积负责把它变成 out_ch 维同时引入可学习的非线性变换。把三个 hop 的结果加起来模型在同一层里同时看到了“自己”“直接邻居”“隔一个关节的远端”三个范围的信息相当于空间维度的多尺度感受野。这也是 ST-GCN 比早期直接把骨架序列拉平送进全连接网络的方案强很多的核心原因。2.3 一个 st-gcn 单元空间卷积 时间卷积 残差光有空间图卷积还不够动作是一个时间过程同一关节在不同帧之间的轨迹同样携带大量信息。ST-GCN 的标准做法是每个基本单元里先做空间图卷积再做时间维度的普通卷积最后接残差连接。这个组合块是整个模型的基本重复单元堆叠 9~10 层就能达到论文里的效果。class STGCNBlock(nn.Module): def __init__(self, in_ch, out_ch, temporal_kernel9, stride1): super().__init__() self.gcn SpatialGraphConv(in_ch, out_ch) # 时间卷积只在 T 维度上滑动kernel 是 (temporal_kernel, 1) self.tcn nn.Sequential( nn.Conv2d(out_ch, out_ch, kernel_size(temporal_kernel, 1), padding((temporal_kernel - 1) // 2, 0), stride(stride, 1)), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) # 输入输出通道不一致或时间维被降采样时残差要跟着变换 if in_ch ! out_ch or stride ! 1: self.res nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size1, stride(stride, 1)), nn.BatchNorm2d(out_ch), ) else: self.res nn.Identity() def forward(self, x, A_list): res self.res(x) x self.gcn(x, A_list) x self.tcn(x) return x res时间卷积的 kernel_size 默认取 9意味着每个时间步的输出会参考前后各 4 帧的信息。这个数字不是拍脑袋定的动作识别的时序模式往往跨越几百毫秒以 30fps 估算9 帧大约覆盖 0.3 秒能捕捉到一次挥手或踢腿的核心片段。padding 用(temporal_kernel - 1) // 2是为了保持时间维长度不变这样多个 block 才能堆叠。stride 参数只在网络中间层启用用来对时间维做降采样类似 CNN 里的池化。残差连接在这类模型里意义很大。ST-GCN 网络通常有 9 层以上如果每个 block 都只做特征变换而不保留原始信息深层梯度很容易消失。加了残差后模型退化成“在上一层特征基础上做增量更新”训练曲线会平滑很多。第一次写这个结构的常见错误是把 BatchNorm 放在残差相加之后而不是 TCN 内部这会让 BN 统计量混乱后续我会在避坑部分细说。3. 拿到代码包先看什么工程结构、数据格式与 dataloader3.1 一个经典毕设工程的目录长什么样市面上的 ST-GCN 毕设代码包结构大同小异拿到手第一步不是急着跑训练而是对照目录确认每一块是干什么的。一个能正常训练、评估、推理的工程通常包含下面这些部分st-gcn-project/ ├── config/ # 训练参数配置yaml 或 py 文件 ├── data_utils/ # 邻接矩阵构建、数据加载、归一化工具 ├── model/ # st-gcn 网络结构定义 ├── processor/ # 训练、验证、推理的入口脚本 ├── main.py # 命令行入口参数解析 ├── data/ # 数据集存放目录通常不随代码包分发 ├── checkpoint/ # 训练好的 .pth 模型文件 └── docs/ # 项目文档环境配置、训练说明、实验结果我最先看的是processor/目录。这个目录决定了训练流程怎么组织数据怎么划分、每个 epoch 之后要不要验证、checkpoint 怎么保存。其次是model/确认网络层数和通道数是否和原论文一致因为有些代码包为了适配小数据集会把通道数减半这会导致后续加载预训练模型时维度对不上。docs/目录对应标题里的“项目文档”别忽略它。一份合格的毕设文档至少要覆盖五块内容Python 环境怎么装、依赖版本是什么、数据集在哪下载和怎么划分、训练和评估的命令分别是什么、以及最终模型在验证集上的精度指标。这些信息在你换电脑重跑时会救命答辩时老师也会翻。3.2 骨骼数据的标准形状N, C, T, V, M 与两种数据集骨骼数据在所有主流实现里都统一成五维张量 (N, C, T, V, M)。N 是样本数量C 是通道数通常 2 或 3 代表 x, y 坐标或 x, y, z 坐标有些数据集还会加一维置信度变成 4 通道T 是时间帧数一个样本可能是一段 2 秒、30fps 的视频对应 60 帧V 是关节点数NTU 是 25Kinetics 是 18M 是人数单人数据 M1双人交互动作 M2模型通常用一个 max pooling 把多个人合并成一个表示。拿到 npy 或 npz 文件后先用一行代码确认数据形状这一步能排除掉一大半后续问题import numpy as np # 常见加载方式npz 里同时含数据和标签 data np.load(data/ntu/xsub/train_data.npy) # (N, C, T, V, M) label np.load(data/ntu/xsub/train_label.npy) # (N,) print(data.shape, label.shape, label.min(), label.max())跑完打印你就能立刻发现几个典型问题如果 data.shape 是 (N, 3, 300, 25, 1)说明通道在前直接用如果变成 (N, 300, 25, 3)那后面要加 transpose 才能喂进模型。如果 label.max() 大于类别数说明标签从 1 开始编号而不是从 0 开始训练前必须减 1。这些细节代码包里一般已经写好但自己排查一遍能避免很多隐性 bug。NTU RGBD 和 Kinetics-skeleton 的划分方式也不一样。NTU 有两种官方协议cross-subject 按受试者划分训练集和验证集的人不重叠cross-view 按摄像头视角划分训练用两个视角、测第三个视角。毕设写实验对比时通常两个协议都要报因为 cross-view 的精度一般比 cross-subject 高几个点单独报一个容易被认为不公平。3.3 写一个能跑的 dataloader采样、归一化与动态批量数据加载器是新手最容易照抄但最常出问题的模块。核心难点在时间维数据集里每个样本的帧数不一样有的 100 帧有的 400 帧但模型输入必须固定。常见做法有两种一是随机裁剪一段固定长度二是把整段序列线性缩放/插值到固定长度。训练时适合用随机裁剪等于数据增强测试时用中心裁剪保证结果可复现。import random import numpy as np import torch from torch.utils.data import Dataset class SkeletonDataset(Dataset): def __init__(self, data, label, num_frames300, random_sampleTrue): self.data data # (N, C, T, V, M) self.label label self.num_frames num_frames self.random_sample random_sample def __len__(self): return len(self.data) def __getitem__(self, idx): x self.data[idx].astype(np.float32) # (C, T, V, M) t x.shape[1] # 训练随机取一段测试从中间取一段 if t self.num_frames: if self.random_sample: start random.randint(0, t - self.num_frames) else: start (t - self.num_frames) // 2 x x[:, start:start self.num_frames] else: # 帧数不足时在末尾补零不要补在开头避免破坏动作起点 pad self.num_frames - t x np.concatenate([x, np.zeros((x.shape[0], pad, x.shape[2], x.shape[3]))], axis1) # 空间归一化把躯干中心编号 1 或 20视数据集而定平移到原点 center x[:, :, 1:2, :] # (C, T, 1, M) x x - center return torch.from_numpy(x), torch.tensor(self.label[idx], dtypetorch.long)这里做了两件关键事。第一是帧数不足时在末尾补零而不是开头因为动作通常从中间开始发力开头补零会让模型误以为动作有一个“静止前摇”。第二是空间归一化把躯干中心点平移到坐标原点这样人在画面左边还是右边都不影响识别——模型学到的是相对位置关系而不是绝对坐标。这一步不做训练 loss 会很难降因为同样的动作在画面不同位置特征差异巨大。还有 M 维度的处理。单人数据集 M1 没问题但如果用到双人交互数据一个 batch 里不同样本的人数可能不一样Dataloader 的 default_collate 会报错。常见做法是在 collate_fn 里把 M 维统一 pad 到该 batch 的最大人数然后靠模型里的 max pooling 把多余的人合并掉。这个坑一般在训练 NTU 双人交互类目比如“握手”“拥抱”时才会遇到提前知道能省半天排错时间。4. 把模型训起来训练入口、核心参数与验证协议4.1 训练入口和第一条命令主流代码包的训练入口通常是main.py或processor/下的独立脚本常见启动命令长这样python main.py --mode train \ --dataset ntu-xsub \ --data-path ./data/ntu \ --batch-size 64 \ --num-epochs 80 \ --lr 0.1 \ --weight-decay 0.0001 \ --gpu 0这段命令里最重要的是--dataset和--data-path。很多代码包默认数据集路径指向作者机器上的绝对路径你直接跑必然报 FileNotFoundError。先改成自己的相对路径再确认--dataset的取值和config/里的配置名一致比如有的写ntu-xsub有的写ntu_xsub下划线问题最容易忽略。batch-size 64 是原论文的设置但这建立在单卡 11GB 以上显存的前提下。如果你的显卡只有 6GB把 batch-size 降到 16 或 32 更现实学习率也要跟着降具体比例见 5.1。--num-epochs 80对应原论文的完整训练周期毕设时间紧可以先跑 20 个 epoch 验证流程确认 loss 下降趋势正常再挂长训练。如果你想先验证代码包里的预训练模型能不能加载可以跑一次纯评估模式python main.py --mode eval \ --dataset ntu-xsub \ --data-path ./data/ntu \ --weights ./checkpoint/ntu_xsub_best.pth \ --gpu 0评估模式的关键参数是--weights指向具体模型文件。如果加载时报 shape mismatch基本可以断定代码包的网络配置与你手上的模型文件不匹配检查一下 model/ 里的通道数是否被魔改过。4.2 配置区优化器、学习率与损失函数怎么写训练配置通常在 config/ 里以参数对象或 yaml 形式存在。值得照着抄的配置组合是SGD Nesterov 动量 余弦退火学习率 交叉熵损失。optimizer torch.optim.SGD( model.parameters(), lr0.1, # 初始学习率 momentum0.9, # Nesterov 动量系数 nesterovTrue, weight_decay0.0001, # L2 正则 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max80, # 总 epoch 数 eta_min0.0001, # 学习率下限 ) criterion torch.nn.CrossEntropyLoss()为什么用 SGD 而不是 Adam这是图卷积模型里很反直觉但普遍有效的经验。Adam 的自适应学习率在早期收敛快但在动作识别这类高维小数据集任务上SGD Nesterov 配合余弦退火的最终精度通常更高泛化也更好。如果你手欠换成 Adam大概率看到 loss 掉得飞快但验证集 top-1 始终上不去。损失函数直接用 CrossEntropyLoss 即可。如果数据集类别严重不均衡比如某些动作样本只有其他类别的三分之一给交叉熵加类别权重class_counts np.bincount(labels) class_weights 1.0 / (class_counts 1e-6) class_weights class_weights / class_weights.sum() * len(class_counts) criterion torch.nn.CrossEntropyLoss(weighttorch.from_numpy(class_weights).float())权重归一化到平均值为 1避免过大的权重破坏 loss 尺度。学习率调度这里有个新手常踩的坑CosineAnnealingLR 的T_max必须和实际训练 epoch 数一致。如果你只训 20 个 epoch 但 T_max 还是 80cosine 曲线只走了前四分之一学习率根本没退到低位精度会差一截。改--num-epochs时记得同步改 T_max。4.3 验证不可少top-1 / top-5、混淆矩阵与模型存档训练过程里每个 epoch 结束都应该跑一次验证不能只看训练 loss。动作识别领域习惯同时报 top-1 和 top-5 精度top-5 的含义是模型预测的前 5 个类别里包含正确标签就算对。这在动作类别相似时有意义比如“喝水”和“拿杯子”视觉上非常接近top-1 可能判错但 top-5 能对上。def evaluate(model, val_loader): model.eval() correct1, correct5, total 0, 0, 0 all_preds, all_labels [], [] with torch.no_grad(): for x, y in val_loader: x, y x.cuda(), y.cuda() logits model(x) # (N, num_class) _, pred logits.topk(5, dim1) # 每样本取前 5 个 correct1 (pred[:, 0] y).sum().item() correct5 (pred y.view(-1, 1)).sum().item() total y.size(0) all_preds.extend(pred[:, 0].cpu().tolist()) all_labels.extend(y.cpu().tolist()) top1 correct1 / total top5 correct5 / total return top1, top5这段验证代码的关键在logits.topk(5, dim1)它直接返回每个样本概率最高的 5 个类别索引。pred y.view(-1, 1)利用广播机制把标签和 5 个预测值逐位比较命中任意一个都算 top-5 正确。注意这里必须包在torch.no_grad()里否则验证过程会构建计算图显存直接炸掉。模型存档建议同时保存两个文件last_model.pth是最后一个 epoch 的状态best_model.pth是验证精度最高的状态。很多代码包只存最后一个结果训练后期过拟合精度最高的模型反而被覆盖了。保存内容至少包括 model.state_dict()、optimizer.state_dict()、当前 epoch 数和最佳 top-1这样中断训练后能靠torch.load完全恢复。5. 避开这些坑ST-GCN 训练与部署常见问题5.1 训练 loss 不降反升一查是学习率过于激进现象是 loss 曲线在前几个 epoch 不但不降反而从 2 点几一路涨到 5 点几甚至出现 NaN。最普遍的原因是初始学习率 0.1 配不上你改小的 batch size。原论文 batch 64 lr 0.1 是配套的但你把 batch 降到 16 后梯度噪声变大同样的 lr 会让参数更新步子迈得过大loss 直接飞掉。解决方法是先固定小配置验证趋势lr 设 0.01 或 0.001batch 16跑 10 个 epoch。如果 loss 从 3.2 稳步降到 1.8 左右说明代码和数据没问题再把 lr 逐步加回去。养成这个“小成本烟雾测试”的习惯后我再也没在训练中期发现不合理的学习率设置不然一晚上白跑的滋味太难受了。5.2 显存溢出跑几个 batch 就 OOM现象是训练刚开始十几个 step 就报CUDA out of memory。原因很好理解ST-GCN 的中间特征张量是 (N, C, T, V)T 和 V 两个维度都很大网络加深后 C 也从 64 涨到 256一张 6GB 显卡根本顶不住原版配置。解决路径按成本从低到高排列先把 batch-size 降到 16 或 8再把时间采样长度num_frames从 300 降到 128注意这会让时序上下文变短精度可能掉 1~2 个点但训练速度翻倍还不行就用梯度累积每 4 个 step 更新一次参数效果等价于 batch 64。梯度累积的 Python 实现就是在 backward 后判断 step 数整除再 optimizer.step()细节不多但很实用。5.3 精度和论文差太多先检查协议和预处理现象是你复现出来的 top-1 比论文低 10 个点甚至更多。先别怀疑模型结构按顺序排查三个地方。第一是数据集划分协议cross-subject 和 cross-view 的精度天然差几个点你要先确认代码包里默认的是哪一种你的实验结果要和同一协议下的论文数字对比拿 cross-view 的结果去比论文的 cross-subject 数字毫无意义。第二是数据归一化方式ST-GCN 对中心点的选择很敏感有的实现把脊柱中心关节点作为原点有的用所有关节的均值中心两种做法会让同一份数据在数值上差一个偏移量精度影响可观。第三是测试增强原论文测试时对时间维做多次采样取平均代码包如果直接中心裁剪一帧精度也会打折。排查数据问题的最快方法是打印几条样本的坐标统计量看数值范围是不是在 -1 到 1 之间以及 25 个关节的中心点是不是真的平移到了原点附近。这一步能瞬间定位归一化是否被执行。5.4 推理时动作还没做完就出结果、结果来回跳现象是实时推理时动作做到一半模型就给了结论然后下一秒又跳到另一个类别来回横跳。原因是推理时使用了过短的滑动窗口或者根本没做输出平滑。训练时模型看到的是完整的 2 秒动作片段但实时推理通常只累积了 0.5 秒就开始预测模型只能靠前半个动作猜结果自然不稳。解决方法是让推理窗口的长度与训练一致然后加一层简单的指数移动平均EMAalpha 0.6 # 0.6~0.8 之间效果较好越大越平滑但响应越迟钝 smoothed alpha * current_prob (1 - alpha) * smoothedEMA 的本质是让预测概率在时间上“慢下来”偶尔一帧的误判不会立刻改变输出类别。实际使用时先拿几个真实动作调 alphaalpha 太大会让动作切换延迟明显太小又压不住抖动。这个调参过程没什么理论可讲属于纯手工经验活多试几次就有手感。5.5 老代码在 PyTorch 新版下报错兼容性成玄学现象是代码包在 Python 3.6 PyTorch 0.4 时代写的拿到 PyTorch 2.x 环境直接跑报错五花八门torch.autograd.Variable被删了、.data属性警告、einsum的维度顺序行为变化、还有 DGL 版本的 API 不兼容。最气人的是有的错误信息看不出来和版本有什么关系纯靠搜报错慢慢磨。最省力的解决路径是新建一个 Python 3.8 PyTorch 1.8 的虚拟环境跑老代码而不是在新版本里逐个改 API。如果你必须用新版本优先做三处替换torch.autograd.Variable(x)改成直接用xx.data改成x.detach()如果代码依赖 DGL 做图卷积把 DGL 版本固定到作者 requirements 里写的那个或者干脆用纯 PyTorch 的稀疏矩阵版本替代。我有一个原则老代码先跑通再优化不要在环境兼容上用还没想明白的“高级”配置。6. 再接一步把模型接到摄像头实时识别6.1 实时推理的最小流程训练好的模型最终要能演示。完整 pipeline 是摄像头帧输入 → MediaPipe 或 OpenPose 提取骨骼关键点 → 归一化 → 累积成时间窗口 → ST-GCN 前向 → softmax 取最高分。核心循环代码import cv2 import mediapipe as mp import numpy as np import torch mp_pose mp.solutions.pose pose mp_pose.Pose() window [] # 保存最近 N 帧的骨骼坐标 window_size 60 # 60 帧 ≈ 2 秒30fps 下与训练片段对齐 cap cv2.VideoCapture(0) model.eval() with torch.no_grad(): while True: ret, frame cap.read() if not ret: break results pose.process(frame) if results.pose_landmarks: # MediaPipe 输出 33 个关键点取需要的部分并保留 (x, y, z) pts np.array([[lm.x, lm.y, lm.z] for lm in results.pose_landmarks.landmark]) window.append(pts) if len(window) window_size: window.pop(0) if len(window) window_size: seq np.stack(window, axis0) # (T, V, C) seq seq.transpose(2, 0, 1) # (C, T, V) feat torch.from_numpy(seq[None, :, :, :, None]).float() # (1, C, T, V, 1) logits model(feat) label int(logits.argmax(dim1).item()) conf torch.softmax(logits, dim1).max().item() if conf 0.6: cv2.putText(frame, f{classes[label]} {conf:.2f}, (20, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 255, 0), 3) cv2.imshow(st-gcn demo, frame) if cv2.waitKey(1) 0xFF ord(q): break这个循环里有两个细节决定成败。第一个是通道数对齐训练时用的可能是 3 通道或 4 通道含置信度而 MediaPipe 只给 x, y, z没有置信度。如果你的模型是 4 通道输入就要在pts后面补一列全 1 的置信度否则模型会报 shape mismatch。第二个是窗口清空一旦画面里没有检测到人体window要立即清空而不是继续累积否则上一个人残留的骨架会跟下一个人拼在一起预测结果完全随机。6.2 让结果稳一点窗口长度与输出平滑实时演示的帧率通常只有 15~30fps窗口的“时间覆盖长度”比“帧数”更重要。如果摄像头实际是 15fps60 帧窗口覆盖 4 秒动作早做完了模型才出结果。处理办法是把 window_size 降到 30保持 2 秒覆盖。推理频率也可以降到每 2 帧推理一次中间帧沿用上一次结果视觉上没有任何差异但省了一半算力。6.3 答辩演示前必须做的三个小动作第一个是准备一段录制好的测试视频作为备选输入。现场演示最大的变数是摄像头角度、光线和网络MediaPipe 在暗光下会直接检测不到人体所以代码里要支持从视频文件读帧现场翻车时立刻切到备选视频。第二个是提前用自己的动作验证置信度阈值0.6 是保守值如果你的模型在验证集上只能到 0.5现场演示就会频繁无输出不如把阈值调到 0.4 保底。第三个是演示时固定做同一个动作一两次看到稳定输出后再换其他动作不要上来就连续切换给自己留出观察输出的时间。我自己的习惯是每次跑完整训练之前先做一个 10 epoch 的烟雾测试确认 loss 曲线、验证精度、checkpoint 存档三条链路都正常再挂长时间任务。这个习惯帮我省掉了无数次白等一晚上的时间也让我在答辩前对模型的脾气摸得清清楚楚。希望帮到你。本文还有配套的精品资源点击获取