ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ST-GCN骨骼动作识别实战:从数据预处理到训练避坑指南

ST-GCN骨骼动作识别实战:从数据预处理到训练避坑指南 简介基于时空图卷积ST-GCN的骨骼动作识别毕业设计源码包面向需要完成毕业设计、期末大作业或课程设计的学生。提供带注释的完整实现覆盖数据预处理、模型训练、离线识别与实时演示等环节。包内共九十个文件包括二十九个Python脚本、十三个配置文件、十二个编译缓存文件以及预训练模型、演示动图、示例视频和说明文档压缩包大小约五十二点六一兆。目录按模型、配置、数据接口、处理流程等模块划分便于快速定位和二次开发。目前已有约一百八十九人学习下载适合希望在毕业设计中快速搭建动作识别系统并进行答辩展示的开发者。资源注释详尽简单部署即可运行属于导师认可的九十八分高分项目可借机掌握时空图卷积原理、图卷积网络实现与骨骼数据集的构建方法。1. 为什么毕业设计选 ST-GCN骨骼动作识别不是黑匣子答辩现场最怕的事情是你明明把模型跑通了却说不出每一层在算什么。基于时空图卷积ST-GCN的骨骼动作识别正好相反——它把人体骨架建模成图让网络直接在骨骼点上做卷积空间结构就是图的拓扑关系时间结构就是帧与帧的连接关系。你不仅能把准确率跑到 80% 以上还能把每一层学到的权重可视化出来讲清楚“网络在看哪里”。这套方案在姿态估计落地上很常见先用 OpenPose 或 MediaPipe 抽出骨骼点再交给 ST-GCN 做分类。本文按这个方向拆开讲从数据格式、模型结构到训练推理最后给出 5 个最容易踩的坑和答辩加分的可视化技巧适合有 Python 和 PyTorch 基础、正在做毕业设计或课程项目的读者。2. 动手前先理顺数据骨架格式、公开数据集与预处理2.1 骨架数据的标准形状N, C, T, V, MST-GCN 的输入不是图像不是视频而是一个五维张量形状是(N, C, T, V, M)。很多初学者拿到代码第一件事就是改模型结构结果模型没改错数据喂错了训练 loss 直接不降。先把这个张量拆明白Nbatch size一次送进网络多少个样本C特征通道数一般是 2 或 3对应二维坐标 (x, y) 或三维坐标 (x, y, z)T时间帧数即一个动作序列采样了多少帧V每帧的骨骼关键点数量比如 OpenPose 的 25 个点、COCO 的 17 个点、NTU 数据集的 25 个点M人数一帧里出现多少人单人就是 1我见过很多人把维度搞成(N, T, C, V, M)也能跑因为 PyTorch 的张量操作很灵活但一旦要接官方预处理代码或者切换数据集就必须统一到(N, C, T, V, M)。这是 ST-GCN 代码里约定俗成的数据布局改布局等于给自己挖坑。2.2 公开数据集怎么选NTU RGBD 与自定义视频做动作识别方向的项目NTU RGBD 60 和 NTU RGBD 120 是绕不开的两个主流数据集。NTU 60 有 60 个动作类别、约 4 万个样本NTU 120 扩展到 120 类、约 11 万个样本。它们自带三维骨骼坐标适合直接做 ST-GCN 训练。日常做毕设不建议上来就全量训练 NTU 120数据量大、训练周期长普通单卡要跑一两天。常见做法是先拿 NTU 60 的交叉视角cross-view或交叉人物cross-subject划分跑通流程再决定要不要探索全量。如果你做的是自定义动作识别比如识别“举手、下蹲、挥拳”这类几个固定动作可以用 OpenPose 或 MediaPipe 从自己录的视频里抽骨骼点存成 numpy 文件。注意一个细节MediaPipe 输出的是归一化到 [0, 1] 的二维坐标OpenPose 输出的是像素坐标。不同来源的坐标尺度不一样直接混用会让模型训练崩掉。我的习惯是每个自定义数据集统一先归一化再存成.npy文件。import numpy as np def normalize_pose(keypoints, image_w, image_h): keypoints: shape (T, V, 2) 或 (T, V, 3) image_w / image_h: 原图宽和高 统一归一化到 [0, 1]避免像素坐标和归一化坐标混用 out keypoints.copy().astype(np.float32) # 二维坐标归一化 out[..., 0] / image_w out[..., 1] / image_h # 如果有 z 轴按深度范围粗略归一化或者保持原值 if keypoints.shape[-1] 3: z_max np.percentile(out[..., 2], 95) out[..., 2] np.clip(out[..., 2] / (z_max 1e-6), 0, 1) return out.astype(np.float32)这段代码做的事很简单把二维坐标压到 [0, 1] 区间的同一尺度三维数据里的 z 轴用百分位裁剪防止极端值拉偏分布。注意最后 1e-6目的是防止除零报错这是处理骨骼数据时该有的边界意识。如果你要换自己的数据集只需保证输出文件的 shape 是(T, V, C)之后在 Dataloader 里转成模型要求的五维格式即可。2.3 训练/验证划分先定好规则再动手划分逻辑上有个绕不开的决策按人物划分还是按样本划分。NTU 的 cross-subject 是把不同人的样本拆到训练集和测试集防止模型“记住人脸”。如果你自己做数据集也要按人物划分而不是把所有视频随机打散。否则同一个人的相似动作可能同时出现在训练集和验证集验证准确率虚高答辩时老师一问就露馅。我一般把数据存成这样的文件结构便于后续管理和扩展dataset/ train/ label_001/ sample_001.npy sample_002.npy label_002/ val/每个.npy文件保存一个动作序列的骨骼点shape 为(T, V, C)另外用一个label_map.json保存标签名到数字 id 的映射关系。这样做的好处是换数据集时不用改训练代码只需要改数据加载路径和映射文件。热词里经常搜“python 构建邻接矩阵”其实数据管理阶段的关键反而是文件组织邻接矩阵是模型内部的事别在数据阶段混为一谈。import os import json import numpy as np from torch.utils.data import Dataset class SkeletonDataset(Dataset): 把 (T, V, C) 的骨骼序列转成 ST-GCN 的 (C, T, V, M) 输入格式 def __init__(self, root_dir, label_map_path, num_frames64): self.samples [] self.labels [] with open(label_map_path, r) as f: self.label_map json.load(f) for label_name in os.listdir(root_dir): label_id self.label_map[label_name] label_dir os.path.join(root_dir, label_name) for np_file in os.listdir(label_dir): if np_file.endswith(.npy): self.samples.append(os.path.join(label_dir, np_file)) self.labels.append(label_id) self.num_frames num_frames def __len__(self): return len(self.samples) def __getitem__(self, idx): data np.load(self.samples[idx]).astype(np.float32) # 帧数对齐少于 num_frames 就重复补帧多于就均匀抽帧 if data.shape[0] self.num_frames: repeat self.num_frames // data.shape[0] 1 data np.repeat(data, repeat, axis0) data data[:self.num_frames] else: indices np.linspace(0, data.shape[0] - 1, self.num_frames, dtypeint) data data[indices] # 转成 (C, T, V, M)单人置 M1 data data.transpose(2, 0, 1) # (C, T, V) data data[:, :, :, np.newaxis] # (C, T, V, 1) return torch.from_numpy(data), self.labels[idx]这里用np.repeat补帧而不是插值原因是骨骼坐标序列是离散的关节位置线性插值会制造不存在的中间位置对动作识别没有帮助。均匀抽帧则能让模型见到的动作速度分布更接近真实场景。num_frames64是个经验值NTU 序列大多 30 到 300 帧不等统一到 64 帧可以控制显存占用和训练时长。如果动作本身很短比如只有 20 帧把 64 调成 32 可能效果更好。这个参数值得多做几次实验对比它往往是最终准确率的几个百分点差距来源。3. ST-GCN 模型结构拆解图卷积、邻接矩阵与核心参数3.1 图卷积在做什么为什么普通卷积处理不了骨骼数据图像卷积很好理解每个像素和周围固定网格里的像素做加权求和卷积核的尺寸决定了感受野。但骨骼点之间不是规则的网格关系一个人的手和肩膀在像素空间上可能隔得很远从动作语义上看它们却直接相连。如果用普通卷积处理骨骼数据网络需要很大感受野才能学到“手抬起”和“肩膀联动”的关系计算量不可接受。ST-GCN 的思路是把人体骨架当成一张图每个骨骼点是一个节点骨骼连接是边。图卷积在每一层做的事情是让每个节点聚合它相邻节点的特征再经过可学习权重变换。这样不管两个点在像素空间上离多远只要它们是相邻节点就能在第一层就直接交换信息。这正好对应热词里常搜的“python 构建邻接矩阵”——邻接矩阵就是描述这张图谁和谁相连的数学形式。3.2 邻接矩阵与归一化三个分区不是拍脑袋ST-GCN 原论文里有一个关键设计把邻接矩阵分解成三个子矩阵分别对应向心、离心、自身三个分区。不是所有相邻节点对特征的影响都一样。以手肘为例手肘向肩膀方向移动是“靠近身体中心”向手指方向移动是“远离身体中心”这两种运动对动作识别的意义完全不同。分区让网络可以分别学习这两类运动的权重表达能力更强。代码实现上用adjacency matrix A表示原始连接关系然后做对称归一化A_norm D^(-1/2) * A * D^(-1/2)。这里 D 是度矩阵对角线上的值是每个节点的邻居数量。归一化的目的是防止度数高的节点比如躯干上的点连接很多在聚合时特征被放大过多度数低的点比如手指尖被淹没。如果忽略这一步直接做矩阵乘法训练过程会很敏感同一个学习率下损失曲线跳来跳去这也是后面避坑章节要展开讲的一点。import numpy as np def build_adjacency_matrix(num_nodes, edges, self_loopTrue): 构建骨骼图的邻接矩阵 edges: list of (i, j) 表示节点 i 与节点 j 相连 num_nodes: 骨骼点总数 self_loop: 是否加自环加自环可以让节点保留自身特征 A np.zeros((num_nodes, num_nodes), dtypenp.float32) for i, j in edges: A[i, j] 1.0 A[j, i] 1.0 # 无向图对称 if self_loop: A np.eye(num_nodes, dtypenp.float32) # 对称归一化D^(-1/2) * A * D^(-1/2) degree A.sum(axis1) degree_inv_sqrt np.zeros_like(degree) nonzero_idx degree 0 degree_inv_sqrt[nonzero_idx] 1.0 / np.sqrt(degree[nonzero_idx]) D_inv_sqrt np.diag(degree_inv_sqrt) A_norm D_inv_sqrt A D_inv_sqrt return A_norm这里注意度矩阵的处理degree 0的掩码判断必不可少因为存在孤立节点的可能性不大但万一骨架提取漏检导致某个点始终是 0不处理就会出现除零警告训练时 loss 变成 NaN。自环要不要加是个可调项原论文是加的加上以后每个节点至少能保留自己的原始特征信息不会只依赖邻居传递。如果你发现网络的梯度消失可以先排查是否把自环去掉了。3.3 ST-GCN 层的标准堆叠方式一个标准的 ST-GCN 层包含空间图卷积和时间卷积两个部分正好对应“时空”这个名字。空间图卷积处理单帧内骨骼点之间的连接时间卷积处理相邻帧之间同一骨骼点的运动变化。在 PyTorch 里空间图卷积常用nn.Conv2d配合A矩阵实现时间卷积用nn.Conv2d配合核大小(kernel_size, 1)实现这里第 3 个维度是时间卷积核沿时间轴滑动。我见过不少初学者想自己写图卷积层用torch.sparse做矩阵乘法直接对邻接矩阵做torch.mm。能跑但速度慢、显存开销大训练一个 epoch 的时间是标准实现的 3 倍以上。工程上更常见的做法是把图卷积等效成1x1的卷积操作因为邻接矩阵是固定常量可以先做矩阵乘法再进卷积层也可以用 einsum 优雅地表达。下面给出一个精简但可以直接用的 ST-GCN 层实现。import torch import torch.nn as nn class STGCNBlock(nn.Module): 一个标准的时空图卷积块 def __init__(self, in_channels, out_channels, A, stride1): super().__init__() self.register_buffer(A, torch.from_numpy(A)) self.conv_spatial nn.Conv2d(in_channels, out_channels, kernel_size1) self.bn_spatial nn.BatchNorm2d(out_channels) self.conv_temporal nn.Conv2d( out_channels, out_channels, kernel_size(9, 1), padding(4, 0), stride(stride, 1) ) self.bn_temporal nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) # 残差连接输入输出通道不一致时用 1x1 卷积对齐 self.residual None 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) ) def forward(self, x): # x shape: (N, C, T, V) N, C, T, V x.size() # 空间图卷积先变换通道再聚合邻居特征 res x out self.conv_spatial(x) # (N, out_c, T, V) out torch.einsum(nctv,vw-nctw, out, self.A) # 邻居聚合 out self.bn_spatial(out) out self.relu(out) # 时间卷积沿 T 维滑动 out self.conv_temporal(out) out self.bn_temporal(out) if self.residual is not None: res self.residual(res) out out res return self.relu(out)register_buffer是关键细节A 矩阵不是模型参数不需要梯度但需要随模型一起搬到 GPU 上。如果用普通成员变量存 Amodel.cuda()的时候 A 不会自动转移前向传播会直接报设备不匹配。einsum做的事情是让每个节点聚合邻居特征输出特征out的第w个节点是 A 矩阵第w行所有邻居特征的和。时间卷积的 kernel size 取 9 是原论文的实验结果对大多数动作序列 9 帧以内的运动变化建模效果最好如果换用帧数少的自定义数据集可以尝试 5 到 7训练速度更快。3.4 关键参数速查表参数常见取值作用与调整建议时间卷积核大小9影响时间感受野帧少的数据集可降到 5 或 7ST-GCN 块数量9 层333 结构层数过少拟合不足过多梯度消失通道数64 → 128 → 256 逐段增加跟数据集规模走NTU 60 至少 64 起步dropout0.3 到 0.5防止过拟合先从 0.5 开始测帧采样数64显存不足就降到 32准确率下降约 1%学习率0.01 配合 StepLR 衰减SGD 比 Adam 在 ST-GCN 上更稳通道不是越多越好。我做过对比实验同样的数据把首层通道从 64 升到 128训练时间多了一倍准确率只涨 0.3%。所以初期先用 64/128/256 这种经典倍数跑通再根据验证集曲线决定要不要加宽而不是想当然地认为模型越大越“高分”。4. 跑通训练与推理从零到准确率 80% 的最小闭环4.1 数据加载与模型装配PyTorch 训练循环的标准写法把前面准备的数据集和模型块组装成一个能训起来的程序是毕设项目最核心的一步。这一步除了正确性还要关注代码结构因为答辩时老师会直接打开你的 main.py 看组织方式。清晰的结构本身就是“高分”的一部分。可以先从最简单的训练循环开始确保能过拟合一个 batch 再谈优化。import torch from torch.utils.data import DataLoader from torch import nn def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 correct 0 total 0 for batch_idx, (inputs, labels) in enumerate(dataloader): inputs inputs.to(device) labels labels.to(device) outputs model(inputs) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() if batch_idx % 20 0: print(fBatch {batch_idx}/{len(dataloader)}, Loss: {loss.item():.4f}) return total_loss / len(dataloader), correct / total训练循环的标准套路是前向、算 loss、清梯度、反传、更新五步缺一不可。optimizer.zero_grad()为什么每步都要调用因为 PyTorch 默认累积梯度不手动清零的话第二次迭代的梯度会叠加到第一次上loss 曲线会完全乱掉这是新手最常见的翻车点之一。打印训练信息用 batch index 而不是准确率即可因为训练集的准确率参考意义有限真正要盯的是验证集表现。4.2 验证函数的边界处理torch.no_grad 与 eval 模式验证阶段和训练阶段有三处关键差异代码层面必须体现否则验证结果会严重失真。第一验证不需要计算梯度整个验证过程包在torch.no_grad()里可以节省显存和计算时间第二模型要切换到model.eval()模式这会关闭 Dropout 和 BatchNorm 的训练行为使用运行时的统计量否则同一份数据每次验证结果都不同第三验证时不更新梯度只统计准确率。def evaluate(model, dataloader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for inputs, labels in dataloader: inputs inputs.to(device) labels labels.to(device) outputs model(inputs) loss criterion(outputs, labels) total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss total_loss / len(dataloader) acc correct / total print(fValidation Loss: {avg_loss:.4f}, Accuracy: {acc:.4f}) return avg_loss, acc注意一个很容易被忽视的现象验证 loss 和训练 loss 的数值不可直接对比。训练时 BatchNorm 用的是当前 batch 的统计量验证时用的是长期的滑动平均统计量而且训练 loss 是实时变化的验证 loss 是稳定状态的。如果验证 acc 比训练 acc 高很多这不是模型多厉害往往说明数据划分出了问题尤其是按样本随机划分而不是按人物划分时同一个人的视频片段会同时出现在两边造成“数据泄露”。4.3 训练主脚本保存最优模型与训练曲线观察主脚本要解决的问题有三个训练多少轮、在哪里保存模型、loss 不降时先看什么。我的经验是先把epochs50跑一遍观察前 10 个 epoch 的 loss 下降趋势。如果前 5 个 epoch 以内 loss 从 2.3 降到了 1.5 附近说明数据流没问题如果 loss 纹丝不动或反而上升先返回检查数据加载和标签对齐而不是调整学习率。import torch.optim as optim def main(): device torch.device(cuda if torch.cuda.is_available() else cpu) train_dataset SkeletonDataset(dataset/train, label_map.json, num_frames64) val_dataset SkeletonDataset(dataset/val, label_map.json, num_frames64) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) # A: 用你自己的骨骼连接定义 A build_adjacency_matrix(num_nodes25, edgesbone_edges) model build_stgcn(num_class10, in_channels2, AA) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, nesterovTrue) scheduler optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.1) best_acc 0.0 for epoch in range(50): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step() if val_acc best_acc: best_acc val_acc torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), val_acc: val_acc, }, best_model.pth) print(fEpoch {epoch}: saved best model, val_acc{val_acc:.4f})SGD 加 Nesterov 动量是 ST-GCN 原论文的训练配置工程上普遍比 Adam 收敛更稳尤其在骨架数据这种高维稀疏输入上。StepLR每 30 轮把学习率乘以 0.1作用是让模型在训练后期不要在原学习率上震荡而用小步长精调。这里注意保存模型的最佳实践是保存完整 checkpoint 而不是只有state_dict因为在中断训练想恢复时优化器状态也是必需的。没有人想从头再跑 50 个 epoch——这算是训练实验里最宝贵的“后悔药”。4.4 推理脚本加载模型并输出分类结果推理是毕设展示环节的重头戏。你不可能在演示现场跑训练但要展示“摄像头实时识别”或“对一段视频做分类”推理代码必须干净、容错、输出直观。核心是把训练好的模型权重加载进来对输入数据做与训练时完全相同的预处理然后输出类别和置信度。import torch import numpy as np def inference(model, skeleton_data, device, label_map): skeleton_data: (T, V, C) 的 numpy 数组或 torch.Tensor label_map: {id: label_name} 返回 top-3 类别和对应的置信度 model.eval() if isinstance(skeleton_data, np.ndarray): data skeleton_data.astype(np.float32) else: data skeleton_data.numpy().astype(np.float32) # 帧数对齐等预处理要复用训练时的逻辑 data preprocess_single_sample(data, num_frames64) data data.transpose(2, 0, 1)[:, :, :, np.newaxis] # (1, C, T, V, 1) data torch.from_numpy(data).unsqueeze(0).to(device) with torch.no_grad(): logits model(data) probs torch.softmax(logits, dim1).cpu().numpy().squeeze() top3_indices probs.argsort()[::-1][:3] results [] for idx in top3_indices: results.append((label_map[str(idx)], float(probs[idx]))) return results推理代码最大的隐藏风险是预处理不一致。训练时数据经过了归一化、补帧、抽帧三步处理推理时如果少做任何一步输入分布就和训练时完全不同准确率会断崖式下跌而且输出结果看起来“像是模型错了”实际是数据错了。所以我在实际项目里会把训练和推理共用的预处理拆成独立函数两边直接调用同一份代码而不是各写一套。这个习惯能省去无数排查时间。5. 常见避坑与排查5 个最容易让模型翻车的地方5.1 维度顺序写错模型不报错但准确率低下现象训练和推理都能跑通loss 也下降了但验证准确率始终在 20% 左右徘徊和随机猜测差不多。原因数据张量(N, C, T, V, M)里 C 和 T 的位置被写反了。ST-GCN 的空间卷积期望第 2 维是特征通道时间卷积期望第 3 维是帧数。一旦反了模型实际是在“帧”这个维度上做通道混合空间结构信息完全没有被利用。解决在数据加载和模型输入处各打印一次x.shape确认是(32, 2, 64, 25, 1)而不是(32, 64, 2, 25, 1)。还有一个笨办法准备两个标签完全不同的样本把训练集减到 10 个样本强行过拟合如果 loss 能降到接近 0说明维度和数据管道都对了再换回全量数据。5.2 邻接矩阵没有随模型搬到 GPU前向传播直接崩溃现象代码在 CPU 上跑得好好的一切到model.cuda()就报错提示张量 device 不匹配。原因A 矩阵是 Python 成员变量或普通 torch.Tensor.cuda()不会自动迁移。在 CPU 模式下所有操作都在同一设备上所以正常GPU 模式下 A 还在 CPUeinsum操作就会旅行到 CPU 或直接抛错。解决在模块中用self.register_buffer(A, torch.from_numpy(A))注册这样模型迁移设备时 A 自动跟随。如果项目已经写完了最简单的方式是在 forward 开头加一行self.A self.A.to(x.device)虽然不优雅但是有效的后悔药。长期维护建议还是改到 buffer 里。5.3 骨骼点不完全连通图卷积信息传不出去现象loss 下降得比其他实验慢很多准确率最高只能到 50%且无论怎么调学习率都上不去。原因手动定义的骨骼连接边不完整。比如忘了把左右肩连接起来或漏了耳朵和眼睛的连接导致图被切成不连通的分量某些骨骼点的信息需要经过很多层才能传给其他部位。解决画一张 25 点骨骼连接图把每个连接逐一比对。更好的做法是在构建邻接矩阵后单独做一个连通性检查。写一个循环从任意一个节点出发做 BFS 或 DFS确认能访问到所有节点。如果检查出来有孤立子图优先检查尾部、耳朵这些边缘骨骼点的连接。有些数据集自带参考连接表直接复制过来再按需增删比从头手搓可靠。5.4 帧数压缩太狠短动作直接变形现象训练集和验证集准确率都达不到预期下调 num_frames 到 32 以后准确率再降 3 个百分点。原因有些动作本身就很快例如挥手、点头整个时长只有 15 帧。如果统一采样到 32 帧每帧都会被重复或拉伸动作时序信息被稀释。时间卷积的感受野虽然在但输入的时间分辨率已经不足以区分“快速挥拳”和“缓慢抬手”。解决按动作类型分桶设置采样帧数或使用滑动窗口切分长序列而不是全局均匀采样。如果项目时间紧可以做一个简单的验证统计数据集中每个样本的原始帧数分布取 P50 和 P90。如果 P90 小于 64直接把 num_frames 改成 32 也许反而效果更好。5.5 验证集准确率虚高答辩时被追问“数据是不是分错了”现象验证集准确率 95%远超测试集准确率。导师一问“训练集和验证集怎么分的”答不上来。原因数据划分时按样本随机 split而不是按人物或视频序列 split。同一个人的多段视频之间存在高度相关性模型在训练阶段已经“见过”这个人大部分动作模式验证时相当于开卷考试。解决按人物身份划分训练/验证集保证同一人的所有视频片段只出现在一边。对于 NTU 数据集官方已经提供了 cross-subject 的划分文件直接沿用自采视频则在录制时就给每个受试者分配组别。这是答辩时的一个经典追问点提前想好划分逻辑并给出准确率差距对比数据反而能成为加分项。6. 毕业设计加分项可视化验证与源码管理6.1 注意力权重可视化让“黑匣子”开口说话ST-GCN 有一定可解释性因为图卷积的邻接矩阵是显式的。一个实用的可视化工夫是把模型学到的空间注意力或者最后一层权重映射回骨架图用热力图或者线宽表示哪些骨骼连接在分类时被激活得更多输出结果有时会非常直观识别“挥手”时肩膀和手肘连接线变粗“下蹲”时膝盖和髋部连接线变粗。这类可视化用 matplotlib 或者直接在视频帧上叠加颜色即可不需要额外训练模型。实现上在 forward 里把你想要观察的中间层输出保存到模型的self.last_attention推理完成后取出并求和归一化在骨架上按连接权重画线。答辩时放三张图原始帧、MediaPipe 骨骼覆盖图、ST-GCN 注意力热力图比任何文字都更有说服力。6.2 消融实验没有对比就没有“高分”毕设评分通常看重实验设计而不仅是一个漂亮的准确率。强烈建议在最终模型之外做两组消融去掉邻接矩阵三分区改用单矩阵时间卷积核从 9 改成 3。两组实验各跑 20 个 epoch记录准确率变化。结果大概率是单矩阵版本掉 2% 左右时间卷积核 3 掉 4% 以上。这样一来答辩时你就能说清楚每个设计点贡献了多少准确率这就是“高分”论文和“能跑就行”之间的分水岭。6.3 源码管理的两个决定性习惯标题里带了“源代码”很多同学会把代码堆在一个文件夹里用“最终版_v5_真的最终版.py”命名这是大忌。实际做项目时用 Git 管理和用文件夹管理是两种完全不同的体验。从第一个能跑的版本开始就git init每次实验改动一个变量就提交一次commit message 写“frame_size_32”“lr_0.005”这样明确的信息。训练曲线的对比和模型 checkpoints 的归档也同步做。你会发现排查问题时能快速回退到任一个实验状态而不是重新训练一轮。答辩前整理代码时把训练、推理、可视化拆成三个独立模块README 里写清楚数据格式和跑通命令。这份“源代码”别人能否快速复现直接决定了它值不值得被叫高分。这是我的血泪教训一个实验记录混乱的项目准确率再高也经不起追问。6.4 一个实用的验证技巧混淆矩阵与单类别错误分析准确率看着不错但答辩老师一定会问“错在哪”。用 PyTorch 在验证集上跑一遍用 sklearn 的 confusion_matrix 算出分类混淆矩阵横向纵向找错误最集中的类别组合。比如“下蹲”经常被误判成“坐下”说明这两种动作的时序特征相似度太高可以考虑增加关键帧对齐或时间卷积核大小。这一步不用花费很多额外时间但对项目的完成度和可信度提升非常明显。牛顿曾被问“你是怎么想到用图卷积做骨骼识别的”时回答“因为我盯着邻接矩阵看了一下午”这个故事未必真实但可视化看数据确实比瞎调参有用得多希望帮到你。本文还有配套的精品资源点击获取
返回列表