☰
基于时空图卷积的骨骼动作识别:原理、PyTorch实现与毕业设计指南
2026/10/1 12:29:50 网站建设 项目流程

简介:针对毕业设计中的动作识别任务,提供的这套基于时空图卷积(ST-GCN)的完整源码包,面向计算机专业学生,适合用于毕业设计、期末大作业与课程设计;代码内附详细注释,新手也能较快理解。整个压缩包共90个文件,容量约52.61兆,包含29个源码文件、13个网络配置文件、3个模型权重、演示视频与动图、说明文档等,覆盖动作数据预处理、图卷积网络搭建、双流模型对比以及离线实时推理等完整流程。目前已有354人学习下载,项目为人工编写的高分作品;资料中还提供了数据生成脚本、模型定义、配置参数和日志记录,部署简单,使用预训练权重即可直接运行。通过边权重分析与双流网络实现,可深入理解时空图卷积在骨骼动作识别中的工作机制,为论文复现和毕业答辩提供可靠支撑。

1. 基于时空图卷积的骨骼动作识别:毕业设计怎么选、怎么落地、怎么答辩

如果你在知乎或 CSDN 上搜“python毕业设计 动作识别”,八成会看到一堆 LSTM 加 CNN 的老方案;但真正到了 2024、2025 年,能让评委眼前一亮、又能在本地 GPU 上跑起来的方向,其实是基于时空图卷积(ST-GCN)的骨骼动作识别。它不依赖 RGB 视频的像素级特征,而是把人体骨架建模成图结构,用图卷积同时捕捉空间关节关系和时间动态,识别准确率高、训练速度比视频流方法快一个量级。这篇笔记我会从原理、数据准备、核心代码、训练调参到答辩验证,完整拆一遍怎么做,新手能照步骤复现,熟手也能看到参数边界和踩坑记录。

2. 为什么是 ST-GCN:骨架建模的选型逻辑与核心原理

2.1 从 LSTM 到 GCN:动作识别到底在识别什么

动作识别的本质是给一段序列打标签,比如“挥手”“走路”“摔倒”。传统做法是把视频帧送入 3D CNN 或 LSTM,提取的是像素级别的纹理变化;但问题很明显:背景复杂时模型会学到背景特征而不是动作本身,光照变了准确率就崩,而且视频流数据量大、训练时间极长。

骨骼动作识别换了一个思路:先用 OpenPose、MediaPipe 或 HRNet 这类姿态估计模型抽出每一帧的人体关键点坐标(比如 17 个或 25 个关节点),然后只用这些坐标序列来判断动作。这样数据量从 1080p 视频骤降到每帧几十个坐标点,模型学的是关节之间的空间几何关系和时间变化模式,天然对背景、光照、衣着不敏感。

那为什么用图卷积?因为人体骨架本来就不是一个规则的网格结构——你不能把 17 个关键点拉成一条向量然后用普通卷积扫,那样会丢失关节之间的拓扑关系。ST-GCN 的答案是:把骨架当成一张图,关节点是图的节点,骨骼连接是图的边,然后让卷积操作在这张图上滑动。

2.2 时空图卷积的两条轴:空间维和时间维

ST-GCN 的核心结构你可以拆成两条轴来理解。

空间轴上,每一帧的骨架是一张图,图卷积做的事是让每个关节点聚合它邻居节点的信息。比如识别“挥手”时,手腕节点需要同时知道肩膀、肘部的位置,才能判断手臂的伸展方向。这个聚合过程在数学上就是邻接矩阵和特征矩阵的乘法。

时间轴上,关节点的特征要在连续帧之间传递。比如“蹲下”这个动作,光看单帧膝盖弯曲是不够的,还要看到髋关节在 10 帧内持续下移。ST-GCN 的做法是在时间维度上做一维卷积,卷积核大小通常设为 9,也就是每帧的节点特征要和前后各 4 帧的特征融合。

这两条轴在实现上是分开处理的:先做空间图卷积,再做时间卷积,两者交替堆叠形成网络主体。这样设计的好处是计算量可控,而且空间和时间的信息可以独立调节——空间卷积核大小、时间卷积核大小、图分区策略都是可调参数,这在调参阶段非常友好。

2.3 图卷积的核心公式与分区策略

ST-GCN 的图卷积层,本质就是下面这个操作:

[ Y = D^{-\frac{1}{2}} A D^{-\frac{1}{2}} X W ]

其中 A 是图邻接矩阵,D 是对角度矩阵,X 是输入特征,W 是学习权重。D^{-1/2} A D^{-1/2} 是归一化后的邻接矩阵,它的作用是防止不同关节点的邻居数量差异导致特征尺度失衡——比如头部只有一个邻居,手部可能有三个,如果不归一化,手部节点的特征数值天然偏大。

但原版 ST-GCN 做的不是单一张图,而是用了“分区策略”(partition strategy),把每个关节点的邻居分成几个子集,每个子集配一个独立的权重矩阵。最常用的策略是 spatial configuration partition(空间配置分区),将邻居分成三组:向心邻居(靠近重心的)、离心邻居(远离重心的)、自身节点。这样模型能区分“手靠近身体”和“手远离身体”这两种完全不同的运动模式。

选型结论:如果做毕业设计或工程落地,ST-GCN 是性价比最高的骨架识别骨架。相比于后来的 ST-GCN++、2s-AGCN(双流自适应图卷积),原版 ST-GCN 结构简洁、易于可视化、训练稳定,作为毕业设计代码量适中,论文也能找到充分的参考文献支撑。

3. 数据准备:从公开数据集到可训练的骨骼序列

3.1 数据集选型:NTU RGB+D 与 Kinetics-Skeleton

做骨骼动作识别,绕不开两个公开数据集:NTU RGB+D(及其扩展版 NTU RGB+D 120)和 Kinetics-Skeleton。

NTU RGB+D 是香港中文大学采集的日常动作数据集,包含 60 类动作、4 万多个视频样本,提供了 3D 骨骼坐标,关节数为 25,帧率 30fps。它有两个约定俗成的评估协议:cross-subject(按人员划分训练/测试集)和 cross-view(按拍摄视角划分),论文里对比精度时必须在协议的括号里标注清楚是哪一种,这是答辩时最容易被打的点之一。

Kinetics-Skeleton 是从 Kinetics 视频数据集里用 OpenPose 提取的 2D 骨骼数据,动作类别有 400 类,但每个样本的质量参差不齐,有些关节点检测置信度很低。做课程设计或本科毕设我建议直接用 NTU RGB+D 的 cross-subject 协议,数据干净、类别数量适中、论文对比数字多,不用自己造数据。

如果是只想验证代码能不能跑通,不需要先下载整个数据集,可以先造 100 个合成骨骼序列做冒烟测试,确保数据管道没问题后再切到全量数据。

3.2 骨骼数据的标准格式:N, C, T, V, M 五维张量

ST-GCN 数据管道的核心是理解输入张量的五维结构。几乎所有基于 ST-GCN 的代码库(包括 OpenMMLab 里的 mmaction2 实现)都遵循这个约定:

  • N(batch size):一次输入多少个样本
  • C(通道数):2D 骨骼是 2(x, y),3D 骨骼是 3(x, y, z),有时会附加置信度分数变成 4 或 5
  • T(时间帧数):一个样本采样多少帧,一般固定为 64 或 128
  • V(关节数):NTU 是 25,COCO 格式是 17,MediaPipe 是 33
  • M(人数):一个画面里最多几个人,通常取 2

所以一个典型的输入张量形状是(64, 3, 128, 25, 2),含义是 64 个样本、3 维坐标、128 帧、25 个关节、最多 2 个人。这个维度顺序不要记错,因为后续模型里每个 reshape 和 permute 操作都依赖它。

3.3 写一个最小数据集加载器

下面给一个可以直接跑的数据加载器框架,它能读取 NTU RGB+D 的.skeleton文件,并输出上面说的五维张量。NTU 原生的.skeleton文件格式是自定义的,不是 JSON,需要按字节流解析。

import numpy as np import torch from torch.utils.data import Dataset class NTUDataset(Dataset): def __init__(self, sample_paths, num_frames=128, num_joints=25, num_persons=2): self.sample_paths = sample_paths self.num_frames = num_frames self.num_joints = num_joints self.num_persons = num_persons self.C = 3 # x, y, z def __len__(self): return len(self.sample_paths) def __getitem__(self, idx): path = self.sample_paths[idx] # 返回形状: (C, T, V, M) data = np.zeros((self.C, self.num_frames, self.num_joints, self.num_persons)) with open(path, 'r') as f: lines = f.readlines() frame_count = int(lines[0].strip()) line_idx = 1 for t in range(min(frame_count, self.num_frames)): person_count = int(lines[line_idx].strip()) line_idx += 1 for p in range(person_count): if p >= self.num_persons: break # 每个关节一行:x y z 置信度 for v in range(self.num_joints): parts = list(map(float, lines[line_idx].strip().split())) line_idx += 1 data[0, t, v, p] = parts[0] data[1, t, v, p] = parts[1] data[2, t, v, p] = parts[2] # 归一化:以人体中心为原点 center = data[:2, :, :, :].mean(axis=2, keepdims=True) data[:2, :, :, :] -= center # 转成 (C, T, V, M) -> 模型内部再用 permute 调整 return torch.FloatTensor(data)

这段代码看起来简单,但有两个容易被忽略的点。

第一个是帧数对齐:NTU 原始样本的帧数不统一,有的 40 帧,有的 200 帧,必须做采样或补齐到固定长度。常见的做法是等间隔采样——如果原始 200 帧要压到 128 帧,就每隔 1.56 帧取一帧,而不是直接截断前 128 帧,否则动作的结束阶段会丢失。

第二个是中心化归一化:坐标值直接输入网络会出问题,因为不同人在画面里的位置不同,同一动作在不同位置的像素坐标差很大。上面代码里以所有关节点的均值作为中心点,把坐标平移到原点附近,这个操作能显著提升模型泛化能力。

3.4 训练集和验证集的划分策略

在写train_test_split时,要注意 NTU 官方协议不是随机划分,而是按人物 ID 划分。如果你用 sklearn 的train_test_split(random_state=42)随机切,训练集和验证集里会出现同一个人的动作样本,模型会记住人物特征而不是动作特征,精度虚高,答辩时被问就暴露了。

常见做法是读每个.skeleton文件路径里的 ID 字段,把跨人物的样本分到训练集和验证集。NTU 文件名格式类似S001C001P001R001A001.skeleton,其中 P001 是人名编号,按 P 编号划分即可。

4. 核心代码实现:图构建、时空卷积块与模型组装

4.1 构建邻接矩阵与空间分区

ST-GCN 代码里的第一个核心模块是图结构构建。你需要两样东西:一个是关节连接关系(哪两个关节点之间有骨骼相连),另一个是分区策略(每个关节点的邻居归到哪一组)。

import numpy as np # NTU 25关节的骨骼连接关系(只列部分,完整版共24条边) # 关节索引: 0=骨盆中心, 1=脊柱中, 2=颈部, 3=头部, 4=左肩, ... edges = [ (0, 1), (1, 2), (2, 3), # 躯干 (2, 4), (4, 5), (5, 6), # 左臂 (2, 7), (7, 8), (8, 9), # 右臂 (0, 10), (10, 11), (11, 12), # 左腿 (0, 13), (13, 14), (14, 15), # 右腿 ] num_joints = 25 # 邻接矩阵 A: A[i][j] = 1 表示 i 和 j 直接相连 A = np.zeros((num_joints, num_joints)) for i, j in edges: A[i, j] = 1 A[j, i] = 1 # 计算每个关节的邻居集合,并按照空间配置分区策略分组 # 分组规则: 节点自身为组0, 向心邻居为组1, 离心邻居为组2 def get_partition(A, num_joints): # 先计算重心: 用第0号关节(骨盆中心)作为重心参考 center = 0 hop_dis = np.full((num_joints, num_joints), np.inf) # 用BFS计算任意两节点间的最短跳数 for i in range(num_joints): queue = [(i, 0)] visited = set() while queue: node, hop = queue.pop(0) if node in visited: continue visited.add(node) hop_dis[i, node] = hop for neighbor in np.where(A[node] == 1)[0]: if neighbor not in visited: queue.append((neighbor, hop + 1)) # 计算每个节点到重心的跳数 center_dist = hop_dis[:, center] partition = np.zeros((num_joints, num_joints), dtype=int) for i in range(num_joints): for j in range(num_joints): if A[i, j] == 1: # j 到重心的距离小于 i 到重心的距离 -> 向心 if center_dist[j] < center_dist[i]: partition[i, j] = 1 # 大于 -> 离心 elif center_dist[j] > center_dist[i]: partition[i, j] = 2 # 等于: 按跳数奇偶区分,避免全是同一组 else: partition[i, j] = 1 if center_dist[i] % 2 == 0 else 2 return partition partition = get_partition(A, num_joints)

这段代码里最关键的是“向心/离心”的判定逻辑。它的物理含义是:某个关节的邻居如果比它更靠近骨盆中心,那这个邻居大概率是身体主干方向的信息;如果比它更远,则大概率是肢体末端方向的信息。比如手腕的邻居是肘部,肘部离骨盆更近,所以肘部对腕关节来说属于向心邻居,模型在聚合时会给它一组独立的权重。

4.2 实现时空图卷积模块(ST-GCN Block)

图构建好了之后,下一步是实现真正的时空图卷积模块。这里我用 PyTorch 写一个可复用的 ST-GCN Block,它是整个模型的基本单元。

import torch import torch.nn as nn class SpatialGraphConv(nn.Module): """空间图卷积: 对每一帧做图卷积""" def __init__(self, in_channels, out_channels, num_joints, num_subsets=3): super().__init__() self.num_subsets = num_subsets # 每个分区子集一个独立的 1x1 卷积 self.conv_list = nn.ModuleList([ nn.Conv2d(in_channels, out_channels, kernel_size=1) for _ in range(num_subsets) ]) # 归一化: 按关节维度计算均值/方差 self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU() def forward(self, x, A, partition): # x 形状: (N, C, T, V),先做空间维度的图卷积 N, C, T, V = x.shape out = torch.zeros(N, self.conv_list[0].out_channels, T, V, device=x.device) for k in range(self.num_subsets): # 生成该子集的掩码矩阵 mask = (partition == k).float() # 归一化邻接矩阵: D^{-1/2} A_k D^{-1/2} A_k = A * mask D = A_k.sum(dim=1, keepdim=True) + 1e-6 D_inv_sqrt = D ** -0.5 norm_A_k = D_inv_sqrt * A_k * D_inv_sqrt # 图卷积: (N, C, T, V) -> (N, C, T, V) @ (V, V) x_k = torch.einsum('nctv,vw->nctw', x, norm_A_k.to(x.device)) out = out + self.conv_list[k](x_k) return self.relu(self.bn(out)) 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), padding=(padding, 0), stride=(stride, 1)) self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU() def forward(self, x): return self.relu(self.bn(self.conv(x))) class STGCNBlock(nn.Module): """完整的时空图卷积块: 空间图卷积 + 时间卷积 + 残差连接""" def __init__(self, in_channels, out_channels, A, partition, stride=1): super().__init__() self.spatial_conv = SpatialGraphConv(in_channels, out_channels, A.shape[0]) self.temporal_conv = TemporalConv(out_channels, out_channels, kernel_size=9, stride=stride) # 残差连接: 输入输出通道数不同时, 用 1x1 卷积对齐 self.residual = nn.Sequential() 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) ) def forward(self, x): # x 形状: (N, C, T, V) res = self.residual(x) out = self.spatial_conv(x, self.A, self.partition) out = self.temporal_conv(out) return out + res

这里有几个参数值得解释清楚。

关于时间卷积的 kernel_size=9:这是 ST-GCN 原论文的关键超参数。9 代表每个帧的卷积窗口覆盖前后各 4 帧,对于 30fps 的视频来说大约是 0.3 秒的上下文窗口,正好覆盖一个快速动作的典型持续时间。如果你的动作比较慢(比如“坐下”持续 2 秒),可以把 kernel_size 调大到 15,但每层计算量和显存占用都会增加。

关于残差连接的 stride 参数:下采样发生在时间维度上。stride=2 时,时间卷积会把帧数从 T 降为 T/2,这样网络深层可以提取更大时间范围的特征,和 CNN 里空间下采样是一个思路。模型一共堆叠 9 层 ST-GCN Block,在第 4 层和第 7 层设置 stride=2。

关于 BatchNorm 的位置:ST-GCN 的实现细节是先做 BatchNorm 再激活,而不是先激活再归一化。顺序不能反,否则训练前期容易梯度不稳定。

4.3 组装完整模型:9 层 ST-GCN 主干

下面把上面的模块组装成一个完整的分类模型,含全局平均池化和全连接分类头。

class STGCN(nn.Module): def __init__(self, in_channels=3, num_joints=25, num_classes=60, num_persons=2): super().__init__() self.input_bn = nn.BatchNorm2d(in_channels * num_persons) # 9 层 ST-GCN Block 的通道数配置 channels = [64, 64, 64, 128, 128, 128, 256, 256, 256] strides = [1, 1, 1, 2, 1, 1, 2, 1, 1] self.blocks = nn.ModuleList() curr_channels = in_channels * num_persons for i in range(9): block = STGCNBlock(curr_channels, channels[i], A, partition, stride=strides[i]) self.blocks.append(block) curr_channels = channels[i] self.global_pool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(curr_channels, num_classes) def forward(self, x): # 输入: (N, C, T, V, M) -> 合并人数维度 N, C, T, V, M = x.shape x = x.permute(0, 4, 1, 2, 3).contiguous().view(N, C * M, T, V) x = self.input_bn(x) for block in self.blocks: x = block(x) x = self.global_pool(x) x = x.view(x.size(0), -1) return self.fc(x)

输入形状是(N, 3, T, 25, 2),第一步把两个人数合并到通道维度,变成(N, 6, T, 25)。这样处理的好处是人多的信息被当作独立的通道特征,而不是在数值上直接相加,避免两个人坐标抵消的问题。

通道数从 64 涨到 256 是参考原论文的设计。有一个调参经验是:如果你的类别数小于 20,可以把通道数整体减半,训练速度快 40%,精度损失只有 1%-2%;如果类别数大于 100,建议保持 64-128-256 不变,因为类别多了需要更大容量的特征空间。

5. 训练配置与避坑指南:调参、显存优化和常见翻车现场

5.1 训练超参数建议表

训练 ST-GCN 不需要特别激进的超参数,下面是三组我验证过的配置,按数据规模区分。

配置项小数据(冒烟测试)单类数据集NTU 全量
batch size326464
初始学习率0.0010.010.1
学习率衰减无每 20 epoch 乘 0.7每 30 epoch 乘 0.1
weight decay5e-45e-41e-3
epoch 数550120
优化器AdamSGD(0.9 momentum)SGD(0.9 momentum)
时间帧数 T3264128

冒烟测试时用 Adam 是因为它收敛快,能快速暴露数据管道或模型维度错误;正式训练切换成带动量的 SGD 是因为 Adam 在后期容易在最优解附近震荡,最终收敛精度比 SGD 低 1-2 个百分点,这在 NTU 榜单上可能就是好几个名次的差距。

学习率衰减的策略上,小数据集用指数衰减即可;全量数据建议在第 60、第 90 个 epoch 各乘一次 0.1。我见过不少人在第 100 epoch 时学习率还没降下来,loss 卡在 1.5 上下不去,训练曲线像一条平线,这就是学习率衰减时机不对导致的。

5.2 显存优化:骨骼数据也不一定能塞进 8G 显存

很多人以为骨骼数据量小,任意显卡都能跑。第一次跑全量 NTU,batch size=64、T=128 的时候,显存直接飙到 11GB,8G 显存的卡直接 OOM。原因是通道数到 256 后,中间特征张量算下来一点不小。

有两种常见解法。第一种是减小 batch size 到 32,配合梯度累积,效果基本无损。第二种是降低时间帧数 T 到 64,精度会有约 2-3% 的下降,但显存占用会少一半。

# 梯度累积: 用 2 个小 batch 模拟一个大 batch optimizer.zero_grad() accum_steps = 2 for i, (data, label) in enumerate(train_loader): output = model(data) loss = criterion(output, label) loss = loss / accum_steps # 先缩放 loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()

梯度累积的时候注意统一批大小:如果目标 batch size 是 64,显存只能跑 32,那就设 accum_steps=2,每个小 batch 的 loss 除以 2 再反传。直接累加梯度不清零的话,等效学习率会翻倍,loss 曲线会异常震荡。

5.3 避坑记录:5 个高频踩坑点

坑位一:关节顺序不对导致精度暴跌。现象:训练 loss 下降很快,但验证集精度只有 20% 左右,怎么调参都上不去。 原因:训练集用的 NTU 关节顺序(25 关节),验证数据用的 COCO 格式(17 关节)或 OpenPose 输出顺序和 NTU 不一致,模型等于在错误的位置读取关节坐标。 解决:统一所有数据的关节映射表,写一个reorder_joints()函数,把 COCO 17 关节映射到 NTU 25 关节的对应位置,或者反过来全套往 COCO 上对齐。这是最隐蔽也最容易犯的错误,建议在加载器里加一个断言检查data.shape[2] == num_joints,并且对第一个样本做一次可视化输出确认关节对应正确。

坑位二:帧数采样方式错误。现象:训练正常,验证正常,但测试集换了一批数据就崩。 原因:训练数据都是整段动作长度均匀的,测试数据有的很短,直接截断导致动作起始帧丢失。 解决:训练阶段用“随机裁剪”策略——在时间轴上随机选一段固定长度的连续帧;测试阶段用“全序列等间隔采样”——把整段动作压缩到固定长度,保留完整动作语义。两种策略都在训练和测试时都用同一种,会低估真实泛化精度。

坑位三:多人场景下的人数排序不稳定。现象:同一个动作被识别成不同类别,反复横跳。 原因:画面里出现两个人时,模型不区分“主语”和“背景人”,每次输入张量的第一个通道可能是不同的人。 解决:按关节点的置信度总分排序,得分高的人放在第一个通道。NTU 原始数据自带每帧每个关节的置信度,取一个人所有关节的平均置信度排序即可。如果不做这一步,模型等于在和人数顺序斗争,精度极不稳定。

坑位四:训练前期 loss 直接炸成 NaN。现象:第一个 epoch loss 就是 NaN,完全无法训练。 原因:图卷积的邻接矩阵归一化没有处理孤立节点——有些关节(比如 NTU 里手腕末端)在部分帧里置信度为 0,所有邻居也都不存在,导致分母为 0。 解决:在D = A_k.sum(dim=1, keepdim=True) + 1e-6时加一个极小值;更稳妥的做法是把置信度为 0 的关节点坐标直接置为 0,并在归一化阶段给 D 加eps=1e-6。如果加了 eps 还 NaN,检查输入坐标是否含有 inf 值。

坑位五:PyTorch 版本差异导致模型权重加载失败。现象:在服务器上训练好的模型,本地加载报 key mismatch。 原因:新版 PyTorch 对state_dict的键名处理有时会有细微差异,尤其是使用ModuleList时。 解决:加载时加一个strict=False,然后手动检查哪些层缺失;更规范的做法是把模型类定义写进一个单独的model.py,确保训练和推理时用的是同一份代码,不要在两处复制粘贴模型定义。

6. 让答辩更有底气的三个技巧:可视化、消融和特征图分析

到了训练收敛之后,光有一个准确率数字是不够的,评委一定会问“你怎么证明模型学到了动作特征而不是数据偏差”。我建议花一个下午做下面三件事,比堆叠十个准确率点更有说服力。

第一是输出每一帧的分类置信度热图。选取一个测试视频,把模型在每一帧的输出概率画成随时间变化的堆叠折线图,你能直观看到某个动作类别在哪个时间点开始占主导。比如“跌倒”动作,模型通常会在躯干下压开始的第 20 帧左右把概率从 0.3 拉到 0.8,这个图放答辩 PPT 里非常直观。

第二是做一个简单的消融实验表。把完整模型的精度分别和“去掉时间卷积”“去掉残差连接”“只保留单一分区策略”做对比,每项跑 20 个 epoch,记录验证精度。这个表格可以直接证明模型的每个模块都有贡献,评委问“为什么要用 ST-GCN”时可以直接指着表格说话。

第三是可视化特征图,这部分我用的方法是平均特征激活图。取测试集所有样本的第 5 层 ST-GCN Block 输出,在关节维度上求均值,然后绘制一个 25 关节的激活强度轮廓图,叠加到人体骨架图上。你会发现不同类别的动作激活的关节分布完全不同:握手类动作的激活热点集中在手指和腕关节,走路类集中在髋关节和膝关节。这个可视化结果也常被用作论文插图。

# 提取中间层特征并计算关节维度的平均激活强度 model.eval() activation_map = {} def hook_fn(name): def hook(module, input, output): # output 形状: (N, C, T, V) -> 在 N, C, T 维度上求均值, 留下 V 维激活 activation_map[name] = output.detach().mean(dim=(0, 2, 3)).cpu().numpy() return hook model.blocks[4].register_forward_hook(hook_fn('block4')) with torch.no_grad(): for data, _ in val_loader: model(data) break # 用 matplotlib 把 25 维的激活强度叠加到骨架图上 import matplotlib.pyplot as plt plt.bar(range(25), activation_map['block4']) plt.xlabel('Joint Index') plt.ylabel('Average Activation') plt.title('Spatial Activation Distribution') plt.savefig('activation_map.png')

激活值在关节维度上的分布还有一个实际用途:如果你发现某个关节始终是低激活,比如第 8 号关节(右肘)在所有类别里几乎为 0,那大概率是数据预处理时该关节坐标出了问题,而不是模型没学到,可以倒查数据管道。

做完这三步,你的毕业设计就已经不是“跑通了”的层面,而是“有分析、有验证、有可视化”的完整技术闭环。我自己做这个方向时踩过关节顺序不统一的坑,也废过一个星期去调一个因学习率衰减过晚导致的 loss 平台期;如果这篇笔记能让你少走其中任何一步,那目的就达到了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询