简介:基于时空图卷积网络(ST-GCN)的骨骼动作识别Python项目,属于导师指导并获评98分的高分毕业设计,适合计算机专业毕设学生及需要实战练习的学习者参考,也可用于课程设计与期末大作业。项目完整覆盖从骨架数据处理、模型构建到训练推理的全流程,可帮助读者快速掌握图卷积在动作识别领域的具体落地方法。压缩包含88个文件共52.56MB,以29个Python源码文件为核心,包含st_gcn.py、st_gcn_twostream.py等模型实现,另有13个yaml参数配置、3个pt预训练权重、11个gif与3个mp4演示素材,以及9个txt说明文档,配套NTU-RGB-D数据处理工具与离线、在线演示脚本,目录划分清晰,便于按模块对照学习,目前已有424人学习使用。下载后可直接基于NTU-RGB-D或Kinetics骨架数据运行,参考config配置与模型权重快速复现实验,也可借助演示脚本直观观察识别效果,适合作为毕设项目改造或图神经网络入门实践的参考基准。
1. 基于时空图卷积(ST-GCN)的骨骼动作识别:为什么它是毕设里性价比最高的方向
第一次在实验室服务器上把 ST-GCN 跑起来,我盯着训练曲线看了十分钟,确认它比我之前调过的 LSTM 序列模型稳得多。这不是玄学,而是因为时空图卷积把人体骨骼真正当成了一张图:关节点是节点,骨骼是边,时间帧串成动态的图序列,模型一边看“人的姿态长什么样”,一边看“动作随时间怎么变化”。标题里这个 Python 源码包,本质就是一套围绕 ST-GCN 的可复现工程——模型结构、训练推理、项目说明都齐了,目标是在 NTU RGB+D 这类公开骨骼数据集上,用相对小的计算量复现接近论文效果的动作分类精度。适合三类人:正在选毕设题目的本科生、刚接触图神经网络的硕士生、想低成本验证动作识别想法的工程师。接下来我按“原理—数据—代码—避坑—进阶”的顺序把它拆开。
2. 时空图卷积的底层逻辑:骨骼不是坐标序列,是一张会动的图
2.1 空间维:从邻接矩阵到三个子图,图卷积到底卷了什么
先看输入最原始的样子:骨骼动作数据不是视频帧,而是每帧一组关节点坐标。NTU RGB+D 一个样本有 25 个关节,每个关节有 x、y、z 三个坐标,按帧排列。传统 CNN 会把这 25 个关节拉平成一个向量,或者堆成一张伪图像,这么做的问题很直接——手和脚在骨骼链上距离很远,但在编号上可能只差 1,卷积核根本分不清这种“物理邻接”和“编号邻接”的区别。ST-GCN 的对策是预先把人体骨骼构造成一张图:关节是节点,骨骼是边,然后用图卷积去聚合邻居节点信息。
图卷积的核心是邻接矩阵 A,它的形状是 (V, V),V 是关节点数。A[i][j] = 1 表示第 i 个关节和第 j 个关节有骨骼直接相连,比如肘关节和腕关节。但直接把 A 拿来做矩阵乘法会出问题——一个节点的邻居数量差异很大(脊柱这种中心节点邻居多,手指末端邻居少),如果只做平均聚合,度大的节点会把自身信息稀释掉。所以 ST-GCN 在源码里对邻接矩阵做了一步反平方根归一化,代码长这样:
import numpy as np import scipy.sparse as sp def normalize_adjacency(A): # A: (V, V) 的 0/1 邻接矩阵,V 为关节点数 # 先加自环,确保聚合时保留节点自身特征 A = A + np.eye(A.shape[0]) # 计算度矩阵 D,D[i][i] 等于第 i 行所有连接的和 degree = np.array(A.sum(axis=1)).flatten() # 反平方根归一化:D^-0.5 * A * D^-0.5 d_inv_sqrt = np.power(degree, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.0 D_inv_sqrt = sp.diags(d_inv_sqrt) return (D_inv_sqrt @ A @ D_inv_sqrt).toarray()为什么用反平方根而不是直接用 D^-1?你试一次就知道:D^-1 是行归一化,它只考虑了“我平均一下邻居”,但没有平衡“我自己被多少节点引用”。反平方根是对称归一化,让高阶邻接关系在数学上更对称,梯度传播更稳定。这个操作是整个模型里最容易被忽略但最关键的细节。源码里通常会把这一步预先算好,塞进模型参数里并且 requires_grad=False,避免训练过程里邻接矩阵被意外更新。
ST-GCN 真正的创新点在于,作者没有用一个 A 做一次图卷积,而是按照“关节到中心关节的跳数”把 A 拆成三个子图。类似普通卷积核把感受野分成左、中、右三个方向,ST-GCN 把邻域分成三档:自己、距离为 1 的邻居、距离大于 1 的邻居。对应到代码里就是先算跳数距离矩阵,再按距离阈值生成多张 0/1 矩阵:
def get_hop_distance(num_node, edge, max_hop=1): A = np.zeros((num_node, num_node)) for i, j in edge: A[i, j] = 1 A[j, i] = 1 # 计算所有节点对之间的最短跳数 hop_dis = np.zeros((num_node, num_node)) + np.inf for i in range(num_node): for j in range(num_node): if A[i, j] == 1: hop_dis[i, j] = 1 # 这里省略 floyd 多源最短路径实现,结果存 hop_dis return hop_dis def build_subgraphs(hop_dis, max_hop=2): # 返回 (P, V, V),P 是子图数量 A = [] for hop in range(max_hop + 1): A.append(np.array(hop_dis == hop, dtype=np.float32)) return np.stack(A)这套划分让同一个卷积核能学到三种语义:中心关节自身、直接相连的邻居、间接相连的更远关节。比如识别“挥手”时,“腕关节到肘关节”这种直接邻居关系贡献最大;识别“踢腿”时,“脚踝到髋关节”这种隔了两跳的远距离关系反而更关键。如果你在源码里看到 A 的初始化逻辑是先用关节连接表生成基础邻接矩阵,再循环里用 hop 条件去分块,就是这个意思。
2.2 时间维:一维卷积在帧序列上滑动,动作的“势”才有意义
空间图卷积只处理了单帧的姿态,但动作识别的难点恰恰在时序上——一个姿态停在原地可能是“站立”,也可能是“抬手到一半”。ST-GCN 的做法很朴素:空间聚合之后,对时间维做标准的一维卷积。特征图此时已经变成 (N, C, V, T) 的形状,时间卷积的核大小一般是 9×1,在 T 维上滑动,padding 设为 4,保证输出帧数不变。
这里的核心思想是“短时运动模式”。一个动作通常持续 1~2 秒,在 30fps 的数据里就是 30~60 帧,9 帧窗口能捕捉到“手从下到上抬起来”的完整弧线前段。核太短(比如 3 帧)只能看到瞬时速度,核太长(比如 15 帧)又会让起始帧和结束帧的信息互相污染。源码里常见的默认值是 kernel_size=(9, 1),你用 7 或 11 也可以,但 9 是在 NTU 数据集上折中最好的一档。
时间卷积和空间图卷积在源码里是交替堆叠的,一个 ST-GCN 块先做空间聚合、再做时序卷积,然后加残差。残差在这里尤其重要——10 层左右的图卷积堆起来,没有残差连接,梯度传到第 3 层基本就消失了。你可以自己试一次去掉残差,loss 会卡在某个值上不动,那不是学习率的问题,是网络太深后梯度断流。
2.3 对比 CNN 和 RNN:为什么 ST-GCN 能把“拓扑先验”用上
| 方法 | 输入形态 | 空间建模方式 | 时序建模方式 | 主要缺点 |
|---|---|---|---|---|
| CNN 伪图 | 骨架堆成矩阵/伪图像 | 卷积感受野固定,关节编号远距离关系丢失 | 二维卷积 | 对骨骼拓扑结构不敏感 |
| RNN/LSTM | 帧坐标序列 | 无显式空间建模,关节当成同一向量的元素 | 循环网络 | 空间结构缺失、训练慢 |
| ST-GCN | 时空图 | 图卷积按骨骼拓扑聚合 | 时间卷积 | 实现与调试门槛偏高 |
RNN 的问题在于它把所有关节坐标拉成一个长向量,本质上是在一维序列里找规律,空间关系被埋进了向量内部。而 ST-GCN 在输入端就锁死了“哪个关节连哪个关节”这个先验,模型不需要从数据里重新学一遍人体结构。这也意味着,如果你打算换成别的数据集,比如 Kinetics-Skeleton,只要重新定义关节点连接表和子图划分,模型结构一行都不用改。
3. 跑通项目的完整链路:环境、数据预处理和入口脚本
3.1 环境搭建:Python 3.8 起步,PyTorch 1.8 以上都能跑
这个项目对深度学习框架版本不算挑剔,但图卷积涉及大量张量 reshape 和 einsum 运算,PyTorch 版本太老或者太新都可能遇到 API 变动。我习惯用 conda 建独立环境,避免把系统 Python 搞乱——尤其是 numpy 和 scipy 的版本,装杂了会出现代码能跑但图卷积算出的邻接矩阵全是 NaN 的怪问题。
conda create -n stgcn python=3.8 -y conda activate stgcn pip install torch torchvision pip install numpy scipy scikit-learn tqdm unzip 基于时空图卷积(ST-GCN)的骨骼动作识别python源码+项目说明(高分毕设).zip -d stgcn_project cd stgcn_project代码逻辑上,第一步是给项目开一个干净的 Python 3.8 环境。torch 和 torchvision 直接装默认最新版即可,CPU 版也能跑通训练流程,只是速度慢。pyTorch 1.8 到 2.x 之间的接口差异对这个项目影响不大,如果遇到torch.nn.functional.conv2d报参数错误,多半不是版本问题,而是特征图维度不对,具体排查看第 5 章。最后一步的 unzip 会生成项目目录,里面有模型定义、数据集解析脚本、训练和测试入口,以及那份项目说明文档——建议先读文档里的数据集路径说明,再把数据放进去,能省下不少猜路径的时间。
3.2 数据集准备:NTU RGB+D 的目录结构、分批协议和解析脚本
NTU RGB+D 是骨骼动作识别绕不开的公开数据集,56,880 个样本、60 类动作、25 个关节,分为两个评估协议:x-sub(按被试者划分训练/测试集)和 x-view(按摄像机视角划分)。毕设里用 x-sub 最多,因为它的难度适中,分类准确率能到 85% 左右,写进论文里既有说服力又不会因为过拟合露怯。
数据集的原始文件是 .skeleton 文本,一个文件对应一个动作样本,里面按帧存储每个人的每帧 25 个关节坐标。拿到原始数据后,先别急着自己写解析器,项目里一般会带一个数据预处理脚本,作用是把这些文本转成 numpy 数组或者 .npz 缓存,方便训练时快速加载。如果你需要自己动手,核心解析逻辑长这样:
def load_ntu_skeleton(filepath, max_frame=300, num_joint=25, num_person=2): # 返回形状 (3, max_frame, num_joint, num_person) data = np.zeros((3, max_frame, num_joint, num_person), dtype=np.float32) with open(filepath, 'r') as f: frame_count = int(f.readline()) for frame_idx in range(min(frame_count, max_frame)): person_count = int(f.readline()) for person_idx in range(min(person_count, num_person)): # 跳过 3 行 body 信息 f.readline(); f.readline(); f.readline() for joint_idx in range(num_joint): line = f.readline().split() x = float(line[0]); y = float(line[1]); z = float(line[2]) data[0, frame_idx, joint_idx, person_idx] = x data[1, frame_idx, joint_idx, person_idx] = y data[2, frame_idx, joint_idx, person_idx] = z return data这段代码强调的是“宽放”思路:一个样本的帧数可能只有 60 帧,也可能长达 300 帧,统一截断或补零到 max_frame=300;视频里可能同时出现两个人,超出 num_person=2 的直接丢掉。这样所有样本的输出形状完全一致,后续组装 batch 的时候才不会因为尺寸不一致直接报错。解析出来的坐标一般是像素坐标系或深度坐标系,数值范围很大,直接喂网络会导致收敛非常慢,所以还需要做一步归一化,具体操作放到第 5 章讲,因为这是最容易翻车的地方。
3.3 入口脚本与训练参数:train.py / test.py 的常用配置
项目目录里通常会有 train.py 和 test.py 两个入口,外加一个模型定义文件(比如 models/st_gcn.py)和一个数据集加载文件(比如 dataset/ntu_dataset.py)。训练前先打开 train.py 看一眼 argparse 参数列表,下面这张表是默认值最常出现的一组:
| 参数名 | 常用默认值 | 含义 |
|---|---|---|
| --data-path | data/ntu/xsub | 预处理后数据所在目录 |
| --batch-size | 64 | 训练批大小,显存不足时降为 16/32 |
| --epochs | 50 | 训练轮数 |
| --lr | 0.1 | 初始学习率,配合 step 下降 |
| --step | [30, 40] | 在第 30 和 40 轮把学习率乘 0.1 |
| --weight-decay | 0.0001 | 权重衰减系数 |
| --num-worker | 4 | DataLoader 加载线程数 |
| --channels | 64,128,256 | 三阶段图卷积通道数 |
训练命令一般长这样:
python train.py \ --data-path data/ntu/xsub \ --batch-size 64 \ --epochs 50 \ --lr 0.1 \ --step 30 40测试命令对应为:
python test.py \ --data-path data/ntu/xsub \ --weights checkpoint/test_best_acc.pt这里有两个习惯值得保留:第一,step 下降不是随便定的,NTU 这种规模的数据集 50 轮训练,如果从头到尾用一个学习率,后期 loss 会在一个平台上反复震荡,根本压不下去;第二,测试时只看测试集最高准确率的 checkpoint,不要看最后一轮的——最后一轮往往已经因为学习率过低轻微过拟合。加载测试权重时注意类别数要对齐,如果你的数据只有 20 类,而 checkpoint 里 fc 层是 60 类,加载时会直接报维度不匹配。
4. 源码复现:从模型定义到训练循环,一行一行啃下来
4.1 输入张量组装:N, C, T, V, M 五个维度缺一不可
项目的 DataLoader 输出不是普通图像的四维张量 (N, C, H, W),而是五维张量 (N, C, T, V, M)。五个维度的含义分别是:N 是 batch 大小,C 是通道数(x、y、z 三个坐标,有时再加上置信度变成 4 通道),T 是时间帧数,V 是关节点数(NTU 是 25),M 是一个样本里出现的人数上限(通常取 2)。这个五维结构是 ST-GCN 系列项目的“通用户口”,后续所有模型代码都围绕它展开。
你在前面解析出的单个样本是 (C, T, V, M),在 DataLoader 的 collate 函数里要把它们堆成 batch:
def collate_skeleton(batch): # batch 是 list,每个元素是 (C, T, V, M) 的 numpy 数组 B = len(batch) C, T, V, M = batch[0].shape out = np.zeros((B, C, T, V, M), dtype=np.float32) for i, sample in enumerate(batch): out[i] = sample return torch.from_numpy(out).float()顺序很重要:numpy 数组默认是 (C, T, V, M),进了模型之后才做维度置换。如果你看到源码里有x.permute(0, 3, 1, 2)这类操作,那是在把 (N, C, T, V) 变成 (N, V, C, T),目的是让图卷积直接作用在 V 维上,时间卷积作用在 T 维上。两个维度的位置搞反,图卷积就会把时间帧当邻居节点去聚合,结果就是 loss 永远不降。
M 维的处理方式在不同项目里有差异。我的经验是训练时把 M 并入 N:把两个人的数据都输入网络,各自得到预测结果,最终对两个人的 logits 取平均。这样做比强行把两个人合成一个人信息丰富得多。代码这样写:
# batch_x: (N, C, T, V, M) N, C, T, V, M = batch_x.shape x = batch_x.permute(0, 4, 1, 2, 3).contiguous() # (N, M, C, T, V) x = x.view(N * M, C, T, V) logits = model(x) # (N*M, num_class) logits = logits.view(N, M, -1).mean(dim=1) # 对两个自然人取平均注意这里 view 之前必须加 contiguous(),因为 permute 之后张量在内存里不是连续排布的,不 contiguous 直接 view 会触发 copy,偶然会遇到 RuntimeError 提示不是特别明确。
4.2 图卷积层的完整 PyTorch 实现:einsum 聚合与 BatchNorm
图卷积层是源码里最关键的一个类。它的输入是一个四维张量 (N, C, T, V),第一步用 1×1 卷积把通道数从 in_channels 升到 out_channels,同时乘以子图数量 P;然后把通道维拆开,让每个子图对应一组卷积输出,再与对应的邻接矩阵相乘。用 einsum 可以把这个逻辑写得很紧凑:
class SpatialGraphConvolution(nn.Module): def __init__(self, in_channels, out_channels, A_sub, bias=True): super().__init__() # A_sub 形状是 (P, V, V),P 是子图数量(通常为 3) self.P = A_sub.shape[0] # 邻接矩阵固定,不参与梯度更新 self.register_buffer('A', torch.from_numpy(A_sub).float()) # 1x1 卷积,输出 P * out_channels,供每个子图分别使用 self.conv = nn.Conv2d(in_channels, out_channels * self.P, kernel_size=1, bias=bias) self.bn = nn.BatchNorm2d(out_channels) def forward(self, x): # x: (N, C, T, V) N, C, T, V = x.shape y = self.conv(x) # (N, P*out, T, V) y = y.view(N, self.P, -1, T, V) # (N, P, out, T, V) # 每个子图聚合自己的邻居信息,输出 (N, P, out, T, V) y = torch.einsum('npctv,pvw->npctw', y, self.A) # 把 P 个子图的结果相加,合并成 (N, out, T, V) y = y.sum(dim=1) return self.bn(y)参数说明:conv 的输出通道是 out_channels * P,不是 out_channels,这是为了给三个子图各分配一组独立的卷积权重。einsum 里的pvw就是第 p 个子图的邻接矩阵,它把输入里 v 这个维度的特征聚合到 w 维度。如果你不想用 einsum,也可以把 y 变形到 (N, P*out, T, V) 后,用循环把每个子图的结果矩阵乘再相加,效果完全一样。
这里的 BatchNorm 值得单独说一句:图卷积层的输入分布受邻接矩阵归一化影响很大,如果不加 BN,模型非常容易在前几轮出现梯度爆炸。输出通道数在源码里常见的是 64、128、256 三个阶段,对应网络越深特征越抽象。如果显存紧张,降一档为 48、96、192,精度损失在 1 到 2 个百分点以内。
4.3 残差与下采样:ST-GCN 块的组装顺序
图卷积层负责空间建模,时间卷积层负责时序建模,把两者组合起来就是一个 ST-GCN 块。代码通常这样组织:
class STGCNBlock(nn.Module): def __init__(self, in_channels, out_channels, A_sub, stride=1, residual=True): super().__init__() self.gcn = SpatialGraphConvolution(in_channels, out_channels, A_sub) self.tcn = nn.Sequential( nn.Conv2d(out_channels, out_channels, kernel_size=(9, 1), padding=(4, 0), stride=(stride, 1)), nn.BatchNorm2d(out_channels), ) self.relu = nn.ReLU(inplace=True) self.residual = residual if not residual: self.residual_block = lambda x: x else: self.residual_block = nn.Conv2d(in_channels, out_channels, kernel_size=1) def forward(self, x): res = self.residual_block(x) out = self.gcn(x) out = self.tcn(out) return self.relu(out + res)整个块的执行流程是:先空间图卷积聚合关节点信息,再时间卷积捕捉运动趋势,最后把输入分支加回去再经过 ReLU。residual 分支用 1×1 卷积把输入通道数对齐到输出通道数,这一步很关键。
源码里整个模型就是多个 STGCNBlock 串起来,典型结构是 9 到 10 个块,通道数翻倍节奏大概是这样的:
class STGCN(nn.Module): def __init__(self, in_channels, num_class, A_sub, block_args=(64, 64, 64, 128, 128, 256, 256)): super().__init__() self.blocks = nn.ModuleList() channels = [in_channels] + list(block_args) strides = [1, 1, 2, 1, 2, 1, 1] # stride=2 时时间维减半,感受野变大 for i in range(len(block_args)): self.blocks.append( STGCNBlock(channels[i], channels[i + 1], A_sub, stride=strides[i], residual=(strides[i] == 1)) ) self.fc = nn.Linear(block_args[-1], num_class) def forward(self, x): for block in self.blocks: x = block(x) # 全局池化:对时间和关节两个维度取平均 x = x.mean(dim=(2, 3)) return self.fc(x)为什么要在 stride=2 的层把时间维减半?因为动作的早期特征需要精细的时间分辨率,但到网络深层,特征已经足够抽象,继续保留全部帧会导致参数量和计算量浪费。下采样相当于强制网络把信息压缩成更紧凑的表示。仔细观察源码你会发现,真正的官方模型里 stride=2 的块不止一个,但每个阶段最多出现一次,这个约束是为了避免时间维缩水太多导致分类器拿到的特征太粗糙。
4.4 训练循环:损失函数、优化器和学习率调度
模型搭好之后,训练主循环和普通分类网络几乎一样,但有两处 ST-GCN 特有的细节。第一,损失函数直接用 CrossEntropyLoss,不需要额外加权,图卷积的类别不平衡问题比图像分类轻得多。第二,优化器选用 SGD 而不是 Adam,这在骨架动作识别里是常见做法——Adam 前期收敛快,但后期精度上限偏低;SGD 配 momentum 0.9、weight_decay 0.0001,在 NTU 数据集上更容易到 85% 以上。
criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=0.0001) scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[30, 40], gamma=0.1) for epoch in range(50): model.train() total_loss = 0.0 for batch_x, batch_y in train_loader: batch_x = batch_x.cuda() batch_y = batch_y.cuda() # 按第 4.1 节的方式处理 M 维 logits = forward_with_multiperson(batch_x) loss = criterion(logits, batch_y) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f'epoch {epoch:02d}, loss {total_loss / len(train_loader):.4f}')MultiStepLR 的 milestone 设置在 30 和 40,大概意思是前 30 轮用 0.1 的学习率做大范围搜索,30 轮后降到 0.01 做精细化收敛,40 轮后再降一档做最终打磨。如果你的数据集比 NTU 小很多,比如自建的 500 个样本,milestone 要相应提前,否则模型在 30 轮之前就已经过拟合了。
5. 避坑清单:骨骼动作识别里最容易翻车的 5 个细节
5.1 五维张量维度对不上,程序一跑就崩
现象:DataLoader 出来是 (N, C, T, V, M),模型 forward 里却假设输入是 (N, C, T, V),一跑就报size mismatch,或者 loss 正常计算但梯度回传时 shape 对不上。
原因:M 维和 batch 维混在一起。常见是把 M 直接当成 N 的一部分,但忘了 reshape 回来;另一种是数据集中某些样本只有一个人,M 维度实际是 1,而模型按 M=2 展开,导致维度错位。
解决:在 DataLoader 的 collate 里强制把 M 补齐到固定值。如果样本里只有一个人,把第二个人全零补齐;如果两个人都存在,保留两个人的数据。进入网络前统一走x.permute(0, 4, 1, 2, 3).contiguous().view(N * M, C, T, V),把 M 折叠到 batch 维,这样模型永远只需处理四维张量。
5.2 邻接矩阵归一化错误,loss 从一开始就 NaN
现象:训练第一个 epoch 就出现 NaN,或者 loss 卡在一个值上死活不降。检查数据没问题,优化器参数也没问题,最后定位到图卷积层。
原因:邻接矩阵加了自环之后没有重新归一化,或者度矩阵 D 的反平方根计算时广播维度错位。还有一个隐蔽坑:用稀疏矩阵保存邻接矩阵时,如果转成 dense 的时机不对,归一化后得到的矩阵不再是对称的,图卷积聚合方向会乱掉。
解决:把归一化后的邻接矩阵单独打印出来人工检查。对角元素应该约为 1,非对角元素与节点的度负相关。调试代码一行就够:
normalized_A = normalize_adjacency(A) print('diag:', np.diag(normalized_A)[:5])如果对角元素明显小于 1,说明度矩阵的计算有误。另一个快速验证方法是:对一个人体样本做一次前向,看输出的幅值是否在合理范围。如果输出张量里出现极大值,基本就是邻接矩阵乘出来的结果没有正确归一化。
5.3 空人物和全零帧把模型带偏
现象:训练集 loss 正常下降,验证集准确率也不差,但可视化输出时发现某些样本被分到完全无关的类别,而且置信度极高。
原因:原始动作样本里可能有一部分帧没有检测到人体,关节坐标全是 0。这些全零帧进入网络后,图卷积聚合的邻居信息全是 0,却仍然产生一个非零输出。模型把这些噪声学成了某种固定模式,关键时刻一个全零帧就能把预测结果带偏。
解决:在解析脚本里加一帧级过滤,把 “所有关节坐标都小于设定阈值” 的帧,用上一帧的数据替换,而不是留成 0。这样既能保证帧数不变,又不会引入虚假信息。注意不要在时间卷积之后再做这个替换,必须在送入网络前完成。
5.4 坐标没做中心化,训练 loss 降不下去
现象:训练 loss 能降到 2.0 附近,但再往下走非常费劲,验证集准确率长期在 40%~50% 徘徊。把输入数据画出来发现关节坐标分布范围特别大。
原因:原始骨骼坐标的绝对位置在不同视频里差异极大,一个人在画面左侧,一个人在画面右侧,同一个动作的坐标差可能上百像素。模型被迫先学“位置矫正”,把大量参数浪费在这个学习上,能用于学动作特征的容量就少了。
解决:对每个样本做全局空间归一化:先按所有关节的均值把坐标平移到以人体中心为原点,再除以整个序列的最大距离把所有坐标缩放到 -1 到 1 之间。代码很短:
# skeleton: (C, T, V, M),C 为 x, y, z center = skeleton.mean(axis=(1, 2, 3), keepdims=True) # 全局中心 skeleton = skeleton - center scale = np.abs(skeleton).max() skeleton = skeleton / max(scale, 1e-6)这里的 scale 不要按单帧算,要按整个动作序列全局算,否则动作幅度大的帧和幅度小的帧会被强行拉到同一尺度,丢失了速度信息。
5.5 显存溢出,训练到一半被系统杀掉
现象:运行到第 20 轮左右提示CUDA out of memory,或者实验室服务器直接把进程 kill 掉,前面训练全部白费。
原因:NTU 一个样本最大 300 帧、25 个关节点、3 通道,batch_size 64 时,中间特征图占用的显存非常可观。M=2 并入 batch 后,显存焦点从“每个样本”变成了“每两个人”,如果机器只有 12GB 显存,几乎必炸。
解决:三个手段叠加。第一,训练时把 max_frame 从 300 降到 128,这个操作对精度影响很小,因为大多数动作在前 2 秒已经完成;第二,batch_size 降到 16,配梯度累积;第三,给 PyTorch 开启梯度累积,等效于保持 64 的 batch 效果但显存只占 16 的量级。梯度累积的写法是关键:
model.zero_grad() accum_steps = 4 for i, (batch_x, batch_y) in enumerate(train_loader): loss = criterion(model(batch_x), batch_y) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() model.zero_grad()注意 loss 除以 accum_steps 的目的是让累积后的梯度总量与标准 batch_size 的梯度等价,这个除法不能省,省了相当于学习率被放大了 accum_steps 倍。
6. 进阶玩法:迁移学习、骨架注意力和时间卷积核的调参方向
模型在 NTU 上跑通只是起点。毕设如果想加分,最值得做的是把预训练权重迁移到自建数据集上。NTU 的 60 类动作覆盖了大部分日常行为,模型前几层已经学会了“关节如何聚合”这种通用表示。加载预训练权重时,把最后一层 fc 换掉:
model.fc = nn.Linear(256, num_class_new)自建数据集如果只有几百个样本,不要全量微调。我一般会冻结前四层图卷积,只训练后面六层和新的 fc 层,学习率设成 0.01,这样既能利用预训练特征,又不会因为数据太少导致过拟合。如果发现验证集准确率上不去,再逐层解冻,每次解冻一层并降一次学习率。
模型的可视化也是一个容易被低估的加分项。骨架动作识别还没有成熟的开箱即用 Grad-CAM,但你可以手动给最后一层图卷积的输出挂一个 hook,提取每个关节的贡献权重。具体做法是:对某个测试样本做前向,拿到 logits 里目标类别的梯度,反传到最后一个时空卷积块的特征图上,按关节维做平均,得到 25 个贡献分数,画成热力图叠加在骨架序列上。这个可视化比论文里的准确率数字直观得多,答辩时能省下大量解释成本。
时间卷积核的大小也值得单独调。9 帧在 30fps 数据里对应 0.3 秒,能覆盖“挥手”的一个完整动作弧。但如果你用 60fps 的高帧率数据,9 帧只覆盖 0.15 秒,动作弧被切碎了,这时把 kernel_size 调到 13 或 15 是更稳妥的选择。反过来,如果你做的是先验划分的短动作(键盘敲击、手势指令),5 帧就够了,太长反而引入前后无关动作的干扰。
关于数据增强,源码里常见的只有随机裁剪(随机去掉前后若干帧)和随机旋转(对三维坐标加小角度旋转矩阵)。我踩过最深的坑是旋转角度设得太大,超过 0.3 弧度就会破坏“手朝上还是朝下”这种方向语义,模型训练波动极大,验证集准确率忽高忽低。稳定做法是旋转角度在正负 0.15 弧度之间随机,并且只在训练时做,测试时必须保持原坐标不变。
最后说个查共性的习惯:我换任何一个新数据集,第一件事不做数据增强器,先拿一个 batch 的样本去跑前向,确认维度、数值范围、loss 形状全部正常,再开完整训练。这一步能筛掉八成环境问题。等你把这份源码跑通、精度过 85%、可视化也做出来,再回头看那些维度对不上的报错、怎么调都不降的 loss,基本都能一眼定位了。希望帮到你。
本文还有配套的精品资源,点击获取