
简介这是一份面向毕业设计场景的Python骨骼动作识别项目资源基于时空图卷积网络ST-GCN实现动作分类适合计算机视觉方向学生、研究者及对姿态识别感兴趣的开发者参考与二次开发。资源共91个文件压缩包大小约52.56MB构成上以Python源码、YAML配置、训练好的PyTorch模型pt权重、GIF演示动画与MP4视频为主同时包含TXT说明文档、Markdown报告和Shell脚本兼顾代码复现、效果预览与文档阅读。目前已有159人学习使用。资源内包含完整的ST-GCN模型实现含单流与双流结构、数据处理与骨骼特征提取模块、可视化工具以及离线/实时演示脚本并提供多种动作类别的示例数据与日志可直接帮助理解图卷积在人体动作识别中的建模思路。对于毕业设计可从环境配置、模型训练到推理展示全流程复用省去大量从零搭建的时间。1. 拿到 ST-GCN 骨骼动作识别项目包后先别急着跑训练毕业设计选“Python 基于时空图卷积ST-GCN的骨骼动作识别”这个方向的人多半已经吃过一次亏压缩包解压出来文件不少一个train.py、一个model.py、一堆.npy或.pkl数据信心满满敲下python train.py结果要么报维度对不上要么训练两小时 loss 纹丝不动要么显卡显存直接爆掉。这个方向看着简单——输入骨骼关节点坐标输出动作类别标签但实际把数据、图卷积、时间卷积三块拼到一起时处处是维度约定和预处理细节。ST-GCN 的核心价值在于它把人体骨骼建模成一张时空图空间上用邻接矩阵描述关节点之间的连接关系时间上用跨帧的边描述同一关节点随时间的变化再用图卷积和时间卷积交替提取特征。相比传统方法手工设计关节角度特征 SVM它不需要任何手工特征工程相比把骨骼坐标当成普通序列喂给 LSTM 的方法它显式利用了骨骼的拓扑结构所以精度高出一截。适合做毕设的原因也很现实有公开数据集NTU-RGBD、Kinetics-Skeleton可以直接下载训练好的模型在标准 benchmark 上有明确数字可以对比论文的“实验对比”章节很好写。这篇文章按我实际跑通这类项目的顺序来写先讲骨骼数据怎么变成图输入再讲 ST-GCN 模型每一层怎么搭然后是训练参数设置和避坑记录最后给一个对任意视频做推理的进阶方案。如果你拿到手的项目包结构混乱、代码风格各异这篇文章也能帮你把每个文件各自该干什么梳理清楚。2. 骨骼数据如何变成时空图输入邻接矩阵与预处理是第一个分水岭2.1 骨骼数据长什么样从 NTU-RGBD 到通用张量格式ST-GCN 的输入不是图片也不是普通的时间序列而是“关节坐标序列”。以最常用的公开数据集 NTU-RGBD 为例每一帧包含 25 个关节点的三维坐标(x, y, z)一个动作视频通常有几十到几百帧。存储时一般是一个(C, T, V, M)的四维张量C是通道数最常见的是 3x、y、z也可以扩展成 6加上相对位移或 9加上骨骼向量等。T是时间帧数也就是这个动作持续了多少帧。V是关节点数NTU-RGBD 是 25Kinetics-Skeleton 是 18。M是人数单人动作就是 1双人交互动作是 2。在 PyTorch 的数据管道里这个张量的形状通常是(N, C, T, V, M)N是 batch size。拿到项目包后第一步要做的就是确认代码里读数据时的维度调整逻辑和数据集原始存储格式完全一致。这个环节最容易出现的问题是关节顺序对不上NTU 的 25 个关节点有自己的编号顺序Kinetics 的 18 个关节点是另一个顺序如果预处理脚本和模型代码各自用了不同的排序训练出来的模型精度会直接崩掉而且很难排查。我在处理这类数据时通常会在预处理阶段就统一成(N, C, T, V, M)然后把归一化、去均值、窗口采样这些操作封装进 Dataset 类里而不是散落在训练脚本各处。这样做的好处是后续换数据集或改通道数时只需要改一个类不用满项目找散落的np.reshape。2.2 构建邻接矩阵三种图划分策略与归一化ST-GCN 空间建模的核心是一张(V, V)的邻接矩阵AA[i][j]表示关节点 i 和关节点 j 在骨架图中是否相连。骨架图的边不是随便定义的而是按照人体结构来的比如左肩连接左肘、左肘连接左腕。这个图结构在任何骨骼数据集里都有官方定义代码里通常用一个edge列表手动列出来。更关键的是“图划分策略”。ST-GCN 原论文提出了三种把邻接矩阵拆成多个子矩阵的方式策略划分方式特点unified partitioning所有邻居节点包含自身归为一组参数量最少实现最简单distance partitioning按节点到重心的距离分多组能区分离心/向心运动精度中等spatial configuration按节点相对重心的位置分三组向心、本身、离心精度最高是默认推荐配置代码实现上常用做法是先生成hop_distance矩阵记录任意两个关节点之间的最短跳数然后根据跳数 0、1、2 分别生成对应的子邻接矩阵。跳数为 0 表示节点自身自连接跳数为 1 表示相邻节点跳数大于等于 2 的在多数实现里归为第三组离心组。最终这些子矩阵会被np.stack成一个(K, V, V)的张量K就是划分的组数。import numpy as np def normalize_digraph(A): # A: (V, V) 的邻接矩阵带自连接 Dl np.sum(A, 0) # 按列求和得到度矩阵 num_node A.shape[0] Dn np.zeros((num_node, num_node)) for i in range(num_node): if Dl[i] 0: Dn[i, i] Dl[i] ** (-0.5) # 归一化D^(-1/2) * A * D^(-1/2) return np.dot(np.dot(Dn, A), Dn) def build_spatial_graph(num_node, edge, hop_radius2): # edge: [(u, v), ...] 骨架连接关系列表 A np.zeros((num_node, num_node)) for u, v in edge: A[u, v] 1 A[v, u] 1 # 计算任意两节点间的最短跳数Floyd-Warshall 或 BFS hop_dis np.full((num_node, num_node), np.inf) for i in range(num_node): hop_dis[i, i] 0 for u, v in edge: hop_dis[u, v] 1 hop_dis[v, u] 1 # Floyd-Warshall 求传递闭包 for k in range(num_node): hop_dis np.minimum(hop_dis, hop_dis[k, :][:, None] hop_dis[:, k][None, :]) # spatial configuration 分组0自身 1向心/相邻 2离心/更远 A_out [] for hop in range(hop_radius 1): A_hop np.where(hop_dis hop, A, 0) # 只保留恰好 hop 跳的边 A_out.append(normalize_digraph(A_hop)) return np.stack(A_out, axis0) # 返回 (K, V, V)逻辑说明normalize_digraph对每个子邻接矩阵做对称归一化原因是图卷积论文里反复验证过的结论——直接乘未归一化的邻接矩阵会让数值随层数增长特征向量被度大的节点主导而归一化后矩阵的谱半径被约束在 1 附近训练稳定性好得多。build_spatial_graph里用 Floyd-Warshall 算最短跳数是为了分类每个节点对之间的“距离”距离为 1 的边保留在相邻组距离为 2 但不相邻的节点在多数实现里不建边距离超过 2 的直接丢弃。参数说明hop_radius默认取 2这个值决定了邻接矩阵拆成几组一般不要超过 3因为更远的节点之间在物理上几乎没有直接关联强行建边只增加参数量还引入噪声。实际项目中这三个子矩阵会在模型初始化时算好然后通过register_buffer或直接存入模型权重文件训练和推理时保持不变。2.3 数据预处理与 Dataset 封装窗口采样、去均值、标准化骨骼动作识别最反直觉的一点是模型对“人体在画面中的绝对位置”完全不感兴趣只在乎“动作的形状”。所以预处理的第一要务是去均值——把每一帧所有关节点的坐标减去该帧的重心让人体重心挪到原点。有些人还会做缩放把骨架归一化到固定尺度这样同一个动作由不同身高的人做出来模型看到的输入是一致的。import torch from torch.utils.data import Dataset import numpy as np class SkeletonDataset(Dataset): def __init__(self, data, label, num_frames64, transformTrue): # data: (N, C, T, V, M) 原始骨骼序列 self.data data self.label label self.num_frames num_frames self.transform transform def __len__(self): return len(self.label) def __getitem__(self, idx): x self.data[idx].astype(np.float32) # (C, T, V, M) C, T, V, M x.shape if self.transform: # 逐帧去均值消除人体绝对位置影响 for t in range(T): for m in range(M): center x[:, t, :, m].mean(axis1, keepdimsTrue) x[:, t, :, m] x[:, t, :, m] - center # 逐通道标准化减均值除标准差 mean x.mean(axis(1, 2, 3), keepdimsTrue) std x.std(axis(1, 2, 3), keepdimsTrue) 1e-6 x (x - mean) / std # 时间维采样/插值到固定帧数 if T self.num_frames: indices np.linspace(0, T - 1, self.num_frames).astype(int) x x[:, indices, :, :] elif T self.num_frames: pad self.num_frames - T x np.concatenate([x, np.repeat(x[:, -1:, :, :], pad, axis1)], axis1) return torch.from_numpy(x), self.label[idx]逻辑说明这个预处理管线里有三个关键操作。第一逐帧去均值权重是全局统一的不能在跑模型时临时做否则训练集和测试集的分布就不一致了。第二时间维采样用np.linspace做均匀抽帧比随机抽帧稳定因为动作的关键姿态可能分布在任意时间段均匀采样保证信息不丢失。第三帧数不足时用最后一帧重复填充这是最简单的 padding 方式比补零效果好因为骨骼序列末端通常是动作结束后的静止姿态重复最后一帧不会引入错误运动信息。参数说明num_frames64是常见设定NTU 数据集的动作平均长度在 40~120 帧之间64 能覆盖大多数动作且 GPU 显存够用。如果用的是 RTX 3060 这种 12GB 显存级别的卡可以试 100~128 帧精度略有提升显存不足就降到 32 帧速度翻倍但精度掉 2~4 个点。标准化用的均值/标准差是在整个训练集上算的还是逐样本算的会影响结果。逐样本标准化会让模型对数值幅度不敏感但好在对不同采集设备鲁棒如果项目包里数据是同一设备采集的用全局统计量的效果更稳定。3. 手写 ST-GCN 模型图卷积层、时间卷积层与残差连接3.1 图卷积层用邻接矩阵做空间特征聚合ST-GCN 的图卷积和图像卷积有本质区别。图像卷积是在 3×3 的像素网格上做加权求和而图卷积是在任意拓扑结构的骨骼图上做特征聚合每个关节点的输出特征 它自身特征 所有邻居节点特征 × 可学习权重。数学形式上一层的计算是f_out A_norm f_in W其中A_norm是归一化邻接矩阵形状(V, V)f_in是输入特征每个节点有 C 维特征W是形状(C_in, C_out)的可学习权重矩阵。用 einsum 实现非常简洁import torch import torch.nn as nn class GraphConv(nn.Module): def __init__(self, in_channels, out_channels, K): super().__init__() self.K K # 邻接矩阵的组数分区策略决定的 self.conv nn.Conv2d(in_channels, out_channels, kernel_size1) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x, A): # x: (N, C, T, V) N, C, T, V x.shape x self.conv(x) # 1x1 卷积做通道变换等价于 W x x.permute(0, 2, 3, 1).contiguous() # (N, T, V, C) # 图聚合每组邻接矩阵分别聚合再在维度上求和 out 0 for k in range(self.K): A_k A[k] # (V, V) out_k torch.einsum(ntvc,vw-ntwc, x, A_k.to(x.device)) out out_k out out.permute(0, 3, 1, 2).contiguous() # (N, C, T, V) out self.bn(out) return self.relu(out)逻辑说明这里先用一个 1×1 卷积做通道变换相当于对每个节点独立做线性映射然后用einsum把邻接矩阵乘到节点维度上实现“邻居特征求和”。为什么不用矩阵乘法而用einsum因为einsum直接表达“对每一帧、每个通道把 V 个节点的特征按 A 的关系加权求和”语义清晰且不会出现维度混乱。BN层放在图聚合之后、激活之前这是参考 ResNet 的设计习惯稳定训练效果。A里包含多组子矩阵时每组的结果直接相加相当于多尺度感受野自身、相邻节点、离心节点各做一次聚合然后融合。参数说明kernel_size1的卷积就是逐点线性变换没有跨关节点共享感受野的问题所以它的参数量和V无关只和通道数有关。K的值必须和第 2 章建的邻接矩阵组数一致一般 3 组如果图划分策略换成 unified partitioningK1模型精度会明显下降训练速度略快。einsum里ntvc,vw的操作要注意顺序一旦写成vcw或wv计算图不会报错但结果完全不对这是手写图卷积最容易翻车的地方。3.2 时间卷积在帧序列上做标准卷积做完空间聚合后每个关节点的特征序列仍然是一个时间序列需要在时间维度上建模运动模式。ST-GCN 的常见做法是直接在(C, T, V)的时间维上做标准 2D 卷积等价于每个关节点共享时间卷积核。具体实现是把特征重排成(N, C, T, V)然后对输入用kernel_size(9, 1)的卷积核在时间维上卷积 9 帧在节点维上卷积核大小为 1。class TemporalConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size9, stride1): super().__init__() padding (kernel_size - 1) // 2 self.conv nn.Conv2d(in_channels, out_channels, kernel_size(kernel_size, 1), stride(stride, 1), padding(padding, 0)) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): # x: (N, C, T, V) return self.relu(self.bn(self.conv(x)))逻辑说明时间卷积的核在T维度上滑动kernel_size 决定每步聚合多少帧padding(4, 0)保证时间维长度不缩水9 帧卷积核左右各补 4 帧。节点维度上用核大小为 1 是为了让所有关节点共享同样的时间卷积参数——这符合骨骼动作识别的直觉同一个动作模式无论发生在左臂还是右臂时间上的运动规律是相似的。参数说明kernel_size9是 ST-GCN 原论文的设定时间感受野约等于 0.3 秒30fps 下足够捕捉一个“挥手”“下蹲”这类动作的基本节奏。把 kernel_size 调到 5 可以提速但会损失对慢动作的建模能力调到 13 对长动作如“跳远”“散步”有提升但显存占用增加。stride一般保持 1因为骨骼序列通常已经做了时序采样只有在显存极度紧张时才对时间维做 stride2 的下采样。3.3 网络骨架ST-GCN 块堆叠与整体参数配置单个 ST-GCN 块 一个空间图卷积 一个时间卷积 残差连接。残差连接解决的是深层网络的退化问题——如果这一层学到的变换没意义模型至少可以退化成恒等映射。每个块的输出通道逐层递增从 64 起步经过 9 个块逐步升到 256class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A, stride1): super().__init__() self.gcn GraphConv(in_channels, out_channels, KA.shape[0]) self.tcn TemporalConv(out_channels, out_channels, stridestride) # 残差分支通道数变化或时间维缩小时用 1x1 卷积对齐 if in_channels ! out_channels or stride ! 1: self.residual nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stride(stride, 1)), nn.BatchNorm2d(out_channels)) else: self.residual nn.Identity() def forward(self, x, A): res self.residual(x) x self.gcn(x, A) x self.tcn(x) return x res完整网络就是把多个块串起来。经典配置是 9 个 ST-GCN 块通道数[64, 64, 64, 128, 128, 128, 256, 256, 256]其中第 4 个和第 7 个块的时间维 stride2把时间长度逐级压缩到原来的 1/4最后接一个全局平均池化 全连接层输出类别数。配置项默认值可选范围影响块数96~12越多越慢精度先升后降初始通道6432~128决定参数量约 3M 还是 12M时间卷积核95~13越大时间感受野越大图划分策略spatial configK1 或 K2K3 通常精度最高全局池化meanmax / attentionmean 最稳attention 提点有限参数说明这些数字不是拍脑袋定的而是公开实现里被验证过的最优组合。如果毕设需要控制显存和训练时间把通道数整体减半从 64 起步是最直接的降本方式精度损失大约 3~5 个点但训练时间缩短到原来的 1/3。stride2的下采样块不要加太多超过 3 次时间信息损失会很严重长动作直接识别失败。4. 跑通训练损失函数、优化器与训练参数的设置经验4.1 最小训练脚本一个能跑起来的闭环模型搭好之后训练部分反而是最容易写的。这里给出的训练循环逻辑可以直接替换进项目包里的train.py不管项目原来的代码长什么样这个框架都能复用import torch import torch.nn as nn from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, criterion, device, A): model.train() total_loss, total_correct, total_num 0, 0, 0 for x, y in loader: x, y x.to(device), y.to(device) # x: (N, C, T, V, M) - (N, C, T, V) 单人动作取 M0 x x[:, :, :, :, 0] optimizer.zero_grad() out model(x, A) # 默认每帧最后做 mean-pooling 再分类 loss criterion(out, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) total_correct (out.argmax(dim1) y).sum().item() total_num x.size(0) return total_loss / total_num, total_correct / total_num # 训练流程配置好这三行就能开始 model STGCN(num_classes60, in_channels3, AA_tensor) optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max80) criterion nn.CrossEntropyLoss()逻辑说明这个循环里有一个不得不写的关键操作——x x[:, :, :, :, 0]。前面说过数据格式是(N, C, T, V, M)M是人数但大多数分类模型默认处理单人所以这里显式取第一个人。如果项目是双人交互识别需要把M维度压平拼到通道维或者单独设计双流结构不能简单丢弃。用 SGD 而不是 Adam是因为 ST-GCN 原论文用 SGD 配合 momentum 效果最好Adam 收敛快但最终精度通常低 2% 左右。参数说明lr0.1看起来很高但配合weight_decay1e-4和CosineAnnealing的降速曲线80 个 epoch 下来最后十几个 epoch 的学习率只有1e-4量级这是这种高初始学习率策略能收敛的原因。如果换用 Adam初始学习率应该降到1e-3。CosineAnnealing的T_max要等于总 epoch 数否则学习率退火曲线会在训练中途就归零后半段全程零学习率。4.2 训练参数速查表与判据训练类任务最怕的就是“loss 明明在降但精度上不去”和“loss 不降反升”下面这张表和判定标准是我跑多个骨骼数据集总结出来的经验值参数推荐值调参方向epoch80~120loss 降得慢就加长到 150batch_size32~64显存不够减半配梯度累积初始学习率 (SGD)0.1loss 震荡就降到 0.05weight_decay1e-4过拟合就升到 5e-4学习率调度CosineAnnealing避免 StepLR 的断崖式下降warmup epoch5~10首轮 batch 用低学习率热启动判断训练是否正常的核心指标不是 loss 而是“第一个 epoch 的 top-1 精度是否明显高于随机猜测”。如果数据集有 60 个类别随机猜测是 1.67%训练一个 epoch 后应该能到 10% 以上。如果第一个 epoch 精度还在 2% 以下多半是数据预处理有问题继续训练只会浪费时间。loss 在 80 个 epoch 后仍然高于 0.5说明模型容量不够或输入通道数不对优先检查in_channels是否和预处理后的数据通道数一致。4.3 评估不只是算 top-1还要看混淆矩阵动作识别项目的论文必带一个 top-1 / top-5 精度表格所以评估脚本要能输出这两个数字。但只输出一个整体精度对排错没帮助我习惯同时打印每个类别的精确率和召回率再生成一个混淆矩阵的数值版本def evaluate(model, loader, device, A, num_classes): model.eval() correct_top1, correct_top5, total 0, 0, 0 confusion torch.zeros(num_classes, num_classes, dtypetorch.long) with torch.no_grad(): for x, y in loader: x x[:, :, :, :, 0].to(device) out model(x, A) # (N, num_classes) pred out.argsort(dim1, descendingTrue) correct_top1 (pred[:, 0] y).sum().item() correct_top5 y.unsqueeze(1).eq(pred[:, :5]).any(dim1).sum().item() total y.size(0) for i in range(y.size(0)): confusion[y[i], pred[i, 0]] 1 # 打印每个类别的召回率找短板 for c in range(num_classes): recall confusion[c, c] / max(confusion[c].sum().item(), 1) if recall 0.6: print(fclass {c}: recall{recall:.3f}, total{confusion[c].sum().item()}) return correct_top1 / total, correct_top5 / total逻辑说明argsort(dim1, descendingTrue)对所有类别得分排序pred[:, 0]就是 top-1 预测pred[:, :5]就是 top-5 预测y.unsqueeze(1).eq(...).any(dim1)判断真实类别是否出现在预测的前五名里。混淆矩阵在 CPU 上用torch.zeros累加y[i]是真实类别pred[i, 0]是预测类别对角线上的值就是该类别被预测正确的样本数。参数说明recall 0.6这个阈值不是固定的。如果整体精度 90%那召回率低于 60% 的类别一定是典型的“混淆重灾区”值得单独拿出来可视化。常见的病根是这两个动作在关节空间上确实长得像比如“坐下”和“蹲下”只差髋关节角度或者该类别训练样本太少。打印出来之后再去数据里抽几个样本看骨架可视化通常能发现模型学到的其实是你没注意到的特征。5. ST-GCN 常见问题避坑与排查那些让人想砸电脑的瞬间5.1 训练不收敛loss 卡在 4.0 附近不动现象正常跑训练loss 从初始值降了一点点之后就长期横盘精度一直维持在 5%~8% 之间跟随机猜测没区别。原因最常见的不是模型问题而是输入数据没做标准化。关节坐标的数值范围很大NTU 原始数据是 Kinect 深度坐标绝对值可能是几千没去均值、没缩放到合理范围时梯度被大数值的坐标主导模型学不到任何有效模式。第二个常见原因是数据通道数和模型期望的不一致——比如预处理里把 z 坐标丢了但模型in_channels还是 3。解决回到第 2.3 节的预处理类检查是否正确执行了去均值和标准化打印一个 batch 的数据统计量如果x.mean()的绝对值大于 10 或者x.std()大于 50说明预处理没生效。再确认输入张量的通道数x.shape[1]必须等于模型的in_channels。5.2 CPU 上训练慢到无法忍受一个 epoch 要 6 小时现象笔记本只有 CPU跑一个 epoch 要几个小时算一下 80 个 epoch 要 20 天直接绝望。原因骨骼动作识别的计算量集中在图卷积的einsum和时间卷积的 2D 卷积上CPU 执行矩阵乘法的效率比 GPU 低几十倍。不少拿到项目包的同学第一反应是“让代码跑起来再说”结果第一天就卡死在速度上。解决三种方案按优先级排。方案一租云 GPU 实例用 AutoDL 这类平台的 RTX 3090 跑一天几块钱80 个 epoch 大概 4~6 小时跑完。方案二降低通道数和帧数把初始通道降到 32、帧数降到 32精度损失可以接受。方案三启用torch.set_num_threads(8)并开启torch.backends.mkldnn.enabled TrueCPU 速度大约能提升 1.5~2 倍但治标不治本。顺便提一个常识训练脚本里如果没加if __name__ __main__:保护Windows 上多进程 DataLoader 会无限递归创建进程直接把系统卡死这是 python 环境配置的一个老坑。5.3 推理时维度对不上明明是同一个模型训练好好的一跑测试就崩现象训练过程完全正常但把model.eval()切换成推理模式后输入一个自然视频提取的骨骼数据模型报错The size of tensor a (32) must match the size of tensor b (64)。原因训练时数据经过 Dataset 做了统一采样到 64 帧但推理脚本直接把原始帧数比如 32 帧送入模型。ST-GCN 的时间卷积核是固定的时间维长度必须在数据进模型前被采样到固定值。这是骨骼动作识别里最容易犯的维度失误。解决把第 2.3 节 Dataset 里的“采样到 64 帧”逻辑抽成一个独立函数训练和推理共用。不能只在训练里做推理入口也要调用。代码里加一行形状断言assert x.shape[2] 64, ftemporal dim {x.shape[2]} ! 64在脚本入口、模型 forward 入口各放一个能省去后来人半天排查时间。5.4 双人动作精度远低于单人动作现象单人的 60 个类别里双人交互类比如“握手”“拥抱”“推搡”精度明显低于单人动作有的类别召回率不到 30%。原因双人动作在(C, T, V, M)里有两个人的骨架很多项目包的处理方式是直接取M0第一个人或者简单把两个人拼接前者丢了第二个人后者引入了排列敏感性——同一个人出现在“第一个人”和“第二个人”位置时特征完全不同模型无法学到稳定模式。解决用“两人对称化”预处理特征里同时包含person1和person2的特征差和特征和具体做法是把两个(C, T, V)按关节维度拼成(2C, T, V)同时把两个人的坐标中心化后交换顺序再做一次让模型对“谁是谁”不敏感。训练时数据增强里加一个 50% 概率交换两人的操作。这个方法简单但有效能把双人动作的召回率提升 15~20 个点。5.5 BatchNorm 在推理时表现异常训练精度高测试集掉 20 个点现象训练集 top-1 达到 95%测试集只有 75%怎么看都是过拟合但加大 weight_decay 也没用。仔细看发现验证脚本里model.train()忘了切回model.eval()。原因nn.BatchNorm2d在训练和推理两种模式下的行为完全不同。训练时用的是每个 batch 内的均值方差推理时用训练阶段累积的全局均值方差。如果验证时模型停留在train()模式BN 用的还是当前 batch 的统计量而且 batch 大小变化时结果会抖动。解决验证和推理前强制调model.eval()并在 PyTorch 的no_grad上下文里执行。这是在评估脚本里最常见也最隐蔽的坑。更稳妥的做法是在模型定义时记录一个training状态开关每次 forward 前检查assert model.training is_train_stage防呆。6. 进阶用自己的模型对任意视频做推理以及模型轻量化技巧毕设做完训练和评测后往往还有一个需求随便录一段视频让模型识别里面的人在做什么动作。这需要一条完整的推理链路视频 - 逐帧人体姿态估计 - 骨骼坐标 - ST-GCN 模型 - 动作标签。常见做法是用 MediaPipe 的 Pose 或 OpenPose 做人体关键点提取得到 33 个 2D 关键点坐标然后映射到模型训练时的关节点数量比如取其中 18 个与 Kinetics-Skeleton 对应的点再中心化和缩放后送进模型。def infer_single_video(video_path, model, pose_extractor, device, A): # 1. 提取骨骼序列: 每帧一个 (18, 2) 的 2D 关键点 coords_seq pose_extractor(video_path) # list of (T, 18, 2) # 2. 组装成 (C2, T, V18, M1) x np.stack(coords_seq, axis1) # (T, 18, 2) - (18, T, 2) x x.transpose(2, 1, 0)[None, :, :, :, None].astype(np.float32) # 3. 去均值 缩放到固定尺度 for t in range(x.shape[2]): x[0, :, t, :, 0] x[0, :, t, :, 0] - x[0, :, t, :, 0].mean(axis0, keepdimsTrue) x[..., 0] x[..., 0] / np.abs(x[..., 0]).max() # 4. 时间采样到 64 帧转 Tensor送入模型 x torch.from_numpy(x)[:, :, :, :, 0].to(device) model.eval() with torch.no_grad(): logits model(x, A) pred logits.argmax(dim1).item() return pred逻辑说明这段代码的关键在第 2 步的形状变换。MediaPipe 输出的格式是“每帧一个关键点列表”要组装成模型期望的(C, T, V, M)C2表示只用了 x, y 两个坐标。第 3 步的去均值只减了空间中心没有像训练那样做逐通道标准化因为 2D 坐标的数值范围是稳定的图像分辨率固定简单缩放到最大值为 1 就够了。最后x[:, :, :, :, 0]去掉M维度和训练脚本保持一致。这个推理链路最大的坑是训练集和推理输入的分布不一致训练用了 3D 骨骼Kinect 深度坐标推理只有 2D 关键点视频里提取的直接推理精度会明显下降。应对方式有两种一是训练时就用 2D 骨骼数据比如直接用 Kinetics-Skeleton它本来就是从视频提取的 2D 坐标二是推理时额外加一个高度维度估计强行凑成 3D。前者是正道这也是为什么毕设选题时如果打算做“对任意视频推理”数据集最好选 Kinetics-Skeleton 而不是 NTU-RGBD。模型轻量化方面最简单的技巧不是换模型结构而是剪通道数。把初始通道从 64 减到 32块数从 9 减到 6参数量大约缩小 4 倍CPU 上推理一帧从 120ms 降到 35ms精度只掉 2~3 个点。如果还想再快把时间卷积核从 9 改成 5又是一倍提速。这两个改动只需要改配置文件里的两个数字不需要动模型代码是收益最高的优化手段。训练一个轻量模型的成本也很低80 epoch 在单张消费级 GPU 上 3 小时能跑完。我现在拿到一个新动作类别第一件事不是调模型而是先找个样本做骨架可视化确认姿态提取那一步没崩。这个习惯帮我挡掉了至少三次“模型训练没问题但推理结果对不上”的翻车事故。ST-GCN 这个方向能做到什么程度很大程度上取决于你把预处理和维度管理做得有多干净模型本身反而不容易出错。希望帮到你。本文还有配套的精品资源点击获取