news 2026/10/1 5:33:43

ST-GCN骨骼动作识别实战:从数据预处理到模型训练与推理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ST-GCN骨骼动作识别实战:从数据预处理到模型训练与推理

简介:这是一份面向毕业设计场景的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-RGB+D、Kinetics-Skeleton)可以直接下载,训练好的模型在标准 benchmark 上有明确数字可以对比,论文的“实验对比”章节很好写。

这篇文章按我实际跑通这类项目的顺序来写:先讲骨骼数据怎么变成图输入,再讲 ST-GCN 模型每一层怎么搭,然后是训练参数设置和避坑记录,最后给一个对任意视频做推理的进阶方案。如果你拿到手的项目包结构混乱、代码风格各异,这篇文章也能帮你把每个文件各自该干什么梳理清楚。

2. 骨骼数据如何变成时空图输入:邻接矩阵与预处理是第一个分水岭

2.1 骨骼数据长什么样:从 NTU-RGB+D 到通用张量格式

ST-GCN 的输入不是图片,也不是普通的时间序列,而是“关节坐标序列”。以最常用的公开数据集 NTU-RGB+D 为例,每一帧包含 25 个关节点的三维坐标(x, y, z),一个动作视频通常有几十到几百帧。存储时一般是一个(C, T, V, M)的四维张量:

  • C是通道数,最常见的是 3(x、y、z),也可以扩展成 6(加上相对位移)或 9(加上骨骼向量等)。
  • T是时间帧数,也就是这个动作持续了多少帧。
  • V是关节点数,NTU-RGB+D 是 25,Kinetics-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)的邻接矩阵A,A[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_radius=2): # 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, axis=0) # 返回 (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_frames=64, transform=True): # 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(axis=1, keepdims=True) x[:, t, :, m] = x[:, t, :, m] - center # 逐通道标准化:减均值除标准差 mean = x.mean(axis=(1, 2, 3), keepdims=True) std = x.std(axis=(1, 2, 3), keepdims=True) + 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, axis=1)], axis=1) return torch.from_numpy(x), self.label[idx]

逻辑说明:这个预处理管线里有三个关键操作。第一,逐帧去均值权重是全局统一的,不能在跑模型时临时做,否则训练集和测试集的分布就不一致了。第二,时间维采样用np.linspace做均匀抽帧,比随机抽帧稳定,因为动作的关键姿态可能分布在任意时间段,均匀采样保证信息不丢失。第三,帧数不足时用最后一帧重复填充,这是最简单的 padding 方式,比补零效果好,因为骨骼序列末端通常是动作结束后的静止姿态,重复最后一帧不会引入错误运动信息。

参数说明:num_frames=64是常见设定,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_size=1) self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) 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_size=1的卷积就是逐点线性变换,没有跨关节点共享感受野的问题,所以它的参数量和V无关,只和通道数有关。K的值必须和第 2 章建的邻接矩阵组数一致(一般 3 组),如果图划分策略换成 unified partitioning,K=1,模型精度会明显下降,训练速度略快。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_size=9, stride=1): 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(inplace=True) 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_size=9是 ST-GCN 原论文的设定,时间感受野约等于 0.3 秒(30fps 下),足够捕捉一个“挥手”“下蹲”这类动作的基本节奏。把 kernel_size 调到 5 可以提速但会损失对慢动作的建模能力;调到 13 对长动作(如“跳远”“散步”)有提升,但显存占用增加。stride一般保持 1,因为骨骼序列通常已经做了时序采样;只有在显存极度紧张时才对时间维做 stride=2 的下采样。

3.3 网络骨架:ST-GCN 块堆叠与整体参数配置

单个 ST-GCN 块 = 一个空间图卷积 + 一个时间卷积 + 残差连接。残差连接解决的是深层网络的退化问题——如果这一层学到的变换没意义,模型至少可以退化成恒等映射。每个块的输出通道逐层递增,从 64 起步,经过 9 个块逐步升到 256:

class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A, stride=1): super().__init__() self.gcn = GraphConv(in_channels, out_channels, K=A.shape[0]) self.tcn = TemporalConv(out_channels, out_channels, stride=stride) # 残差分支:通道数变化或时间维缩小时用 1x1 卷积对齐 if in_channels != out_channels or stride != 1: self.residual = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, 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 个块的时间维 stride=2,把时间长度逐级压缩到原来的 1/4,最后接一个全局平均池化 + 全连接层输出类别数。

配置项默认值可选范围影响
块数96~12越多越慢,精度先升后降
初始通道6432~128决定参数量约 3M 还是 12M
时间卷积核95~13越大时间感受野越大
图划分策略spatial configK=1 或 K=2K=3 通常精度最高
全局池化meanmax / attentionmean 最稳,attention 提点有限

参数说明:这些数字不是拍脑袋定的,而是公开实现里被验证过的最优组合。如果毕设需要控制显存和训练时间,把通道数整体减半(从 64 起步)是最直接的降本方式,精度损失大约 3~5 个点,但训练时间缩短到原来的 1/3。stride=2的下采样块不要加太多,超过 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) 单人动作取 M=0 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(dim=1) == y).sum().item() total_num += x.size(0) return total_loss / total_num, total_correct / total_num # 训练流程:配置好这三行就能开始 model = STGCN(num_classes=60, in_channels=3, A=A_tensor) optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=80) criterion = nn.CrossEntropyLoss()

逻辑说明:这个循环里有一个不得不写的关键操作——x = x[:, :, :, :, 0]。前面说过数据格式是(N, C, T, V, M),M是人数,但大多数分类模型默认处理单人,所以这里显式取第一个人。如果项目是双人交互识别,需要把M维度压平拼到通道维,或者单独设计双流结构,不能简单丢弃。用 SGD 而不是 Adam,是因为 ST-GCN 原论文用 SGD 配合 momentum 效果最好;Adam 收敛快但最终精度通常低 2% 左右。

参数说明:lr=0.1看起来很高,但配合weight_decay=1e-4和CosineAnnealing的降速曲线,80 个 epoch 下来最后十几个 epoch 的学习率只有1e-4量级,这是这种高初始学习率策略能收敛的原因。如果换用 Adam,初始学习率应该降到1e-3。CosineAnnealing的T_max要等于总 epoch 数,否则学习率退火曲线会在训练中途就归零,后半段全程零学习率。

4.2 训练参数速查表与判据

训练类任务最怕的就是“loss 明明在降,但精度上不去”和“loss 不降反升”,下面这张表和判定标准是我跑多个骨骼数据集总结出来的经验值:

参数推荐值调参方向
epoch80~120loss 降得慢就加长到 150
batch_size32~64显存不够减半,配梯度累积
初始学习率 (SGD)0.1loss 震荡就降到 0.05
weight_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, dtype=torch.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(dim=1, descending=True) correct_top1 += (pred[:, 0] == y).sum().item() correct_top5 += y.unsqueeze(1).eq(pred[:, :5]).any(dim=1).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(f'class {c}: recall={recall:.3f}, total={confusion[c].sum().item()}') return correct_top1 / total, correct_top5 / total

逻辑说明:argsort(dim=1, descending=True)对所有类别得分排序,pred[:, 0]就是 top-1 预测,pred[:, :5]就是 top-5 预测,y.unsqueeze(1).eq(...).any(dim=1)判断真实类别是否出现在预测的前五名里。混淆矩阵在 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 = True,CPU 速度大约能提升 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, f'temporal dim {x.shape[2]} != 64',在脚本入口、模型 forward 入口各放一个,能省去后来人半天排查时间。

5.4 双人动作精度远低于单人动作

现象:单人的 60 个类别里,双人交互类(比如“握手”“拥抱”“推搡”)精度明显低于单人动作,有的类别召回率不到 30%。

原因:双人动作在(C, T, V, M)里有两个人的骨架,很多项目包的处理方式是直接取M=0(第一个人)或者简单把两个人拼接,前者丢了第二个人,后者引入了排列敏感性——同一个人出现在“第一个人”和“第二个人”位置时,特征完全不同,模型无法学到稳定模式。

解决:用“两人对称化”预处理:特征里同时包含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. 组装成 (C=2, T, V=18, M=1) x = np.stack(coords_seq, axis=1) # (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(axis=0, keepdims=True) 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(dim=1).item() return pred

逻辑说明:这段代码的关键在第 2 步的形状变换。MediaPipe 输出的格式是“每帧一个关键点列表”,要组装成模型期望的(C, T, V, M),C=2表示只用了 x, y 两个坐标。第 3 步的去均值只减了空间中心,没有像训练那样做逐通道标准化,因为 2D 坐标的数值范围是稳定的(图像分辨率固定),简单缩放到最大值为 1 就够了。最后x[:, :, :, :, 0]去掉M维度,和训练脚本保持一致。

这个推理链路最大的坑是训练集和推理输入的分布不一致:训练用了 3D 骨骼(Kinect 深度坐标),推理只有 2D 关键点(视频里提取的),直接推理精度会明显下降。应对方式有两种:一是训练时就用 2D 骨骼数据(比如直接用 Kinetics-Skeleton,它本来就是从视频提取的 2D 坐标),二是推理时额外加一个高度维度估计,强行凑成 3D。前者是正道,这也是为什么毕设选题时如果打算做“对任意视频推理”,数据集最好选 Kinetics-Skeleton 而不是 NTU-RGB+D。

模型轻量化方面,最简单的技巧不是换模型结构,而是剪通道数。把初始通道从 64 减到 32,块数从 9 减到 6,参数量大约缩小 4 倍,CPU 上推理一帧从 120ms 降到 35ms,精度只掉 2~3 个点。如果还想再快,把时间卷积核从 9 改成 5,又是一倍提速。这两个改动只需要改配置文件里的两个数字,不需要动模型代码,是收益最高的优化手段。训练一个轻量模型的成本也很低,80 epoch 在单张消费级 GPU 上 3 小时能跑完。

我现在拿到一个新动作类别,第一件事不是调模型,而是先找个样本做骨架可视化,确认姿态提取那一步没崩。这个习惯帮我挡掉了至少三次“模型训练没问题,但推理结果对不上”的翻车事故。ST-GCN 这个方向能做到什么程度,很大程度上取决于你把预处理和维度管理做得有多干净,模型本身反而不容易出错。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 5:33:21

多线程排序为什么更慢?小数据量并行开销揭秘

1. 问题从哪来&#xff1a;一次让我尴尬的排序实验前两天一个用C#写业务的同事跑过来问我一个很有意思的问题&#xff1a;“我写了个数据聚合demo&#xff0c;大概2万条记录&#xff0c;想用多线程排序提高速度&#xff0c;结果加完线程反而慢了将近一倍&#xff0c;这合理吗&a…

作者头像 李华
网站建设 2026/10/1 5:30:57

谷歌开源ARTEMIS:视觉驱动AI Agent操作手机的新方案

做移动端自动化的人&#xff0c;应该都懂这种滋味&#xff1a;脚本跑得正欢&#xff0c;App一次版本更新把页面结构改了&#xff0c;整套case全线飘红。传统自动化框架的根扎在UI控件树、resource-id、xpath这些“内部结构”上&#xff0c;一旦结构变了&#xff0c;再维护下去就…

作者头像 李华
网站建设 2026/10/1 5:30:54

起重机目标检测实战:YOLO标注数据与YOLOv8训练全流程指南

简介&#xff1a;面向计算机视觉初学者、目标检测算法研究者及工地、港口等工业场景开发者&#xff0c;这份起重机图像目标检测数据集包含约2900张已标注图片及对应标签&#xff0c;类别仅起重机一类&#xff0c;并已完成训练集与验证集划分&#xff0c;采用YOLO标准标注格式&a…

作者头像 李华
网站建设 2026/10/1 5:30:50

JMeter+Prometheus+Grafana:打造压测实时监控链路

我最早做压测时&#xff0c;最头疼的就是压完才看聚合报告。跑一次一小时的压力测试&#xff0c;中途完全不知道服务是不是已经打挂了&#xff0c;CPU是不是早就飙满&#xff0c;接口响应时间是不是已经涨了十倍。直到我把 JMeter、Prometheus、Grafana 三个开源工具串成一套实…

作者头像 李华
网站建设 2026/10/1 5:30:21

Redis 8.0接入AI实战:语义缓存、Vector Set与MCP Server深度解析

这几天身边不少做后端的朋友都在问同一个问题&#xff1a;AI时代&#xff0c;Redis还有戏吗&#xff1f;我的回答是&#xff0c;去看Redis 8.0的GA公告&#xff0c;官方已经把AI接成了原生的能力。这不是营销号说的那种“蹭热点”&#xff0c;Redis这次是实打实多了几个能直接落…

作者头像 李华
网站建设 2026/10/1 5:29:55

纯命令行环境下用QEMU运行Ubuntu虚拟机完整指南

如果你手上只有一台没有桌面环境的Ubuntu服务器&#xff0c;又想在里面跑一个Ubuntu虚拟机&#xff0c;第一反应可能是VirtualBox或者VMware&#xff0c;但这两个在纯命令行下都不算友好&#xff0c;尤其是远程SSH进去操作的时候&#xff0c;基本上等于没法用。我上周正好把一个…

作者头像 李华