
简介基于Python与时空图卷积网络ST-GCN的骨骼动作识别毕业设计项目适合计算机视觉方向学生或开发者学习模型搭建与训练流程。包内共91个文件包含29个Python源码、13个YAML配置、12个pyc编译文件、11个GIF演示动画、3个预训练模型.pt及mp4样例视频等涵盖从数据预处理、模型定义到离线/实时识别与可视化工具链约52.56MB便于下载。目前已有159人学习。通过该项目可复现ST-GCN训练流程理解人体骨骼关键点时空图构建方式并直接利用提供权重文件测试多类动作。目录按net、processor、feeder、tools等模块划分其中net实现图卷积层processor管理训练与测试流程feeder负责数据加载tools提供可视化工具资源还附有NTU-RGB-D、Kinetics-skeleton等数据集处理脚本与官方实验日志训练日志中记录了损失与准确率变化便于分析模型收敛情况可帮助快速开展实验、二次开发或完成毕业设计答辩演示。1. 为什么骨骼动作识别更适合用 ST-GCN一段视频不如一张图一个人在做“交叉手”另一个在做“挥拳”从 RGB 画面看背景一乱、衣服一换模型很容易被干扰。骨骼动作识别绕开画面本身只保留 25 个关节点在每一帧的三维坐标把一段动作变成一组随时间变化的图信号。ST-GCN 骨骼动作识别就是在这样的图上做时空卷积先用图卷积抓住人体骨架的空间连接再用时间卷积抓动作节奏。它的输入是骨骼关键点序列输出是动作类别适合作为毕设或入门实践——数据集公开、模型结构清晰、可视化和可解释性都强。下面按一个完整的复现链路讲数据怎么洗、网络怎么搭、训练怎么调、坑在哪。2. 把原始骨骼数据喂进图卷积JSON 转 npy 预处理脚本与归一化参数2.1 骨骼数据的原始形态先搞清楚 JSON 里存了什么拿到一个 NTU 风格的 JSON别急着一股脑读成 numpy 数组。骨架数据通常按“帧”组织每帧有一组关节点每个关节点是三维坐标加一个置信度。常见文件里data字段包含num_frames、skeletons这些 keyskeletons是一个列表每个元素要么是一维数组把 25×4 拉平要么是按[frame][joint][x, y, z, score]组织的多维数组。不同发布版本格式有差异预处理脚本第一件事应该是打印一个样本的 shape 和 dtype而不是直接写解析逻辑。我在实际处理时遇到过三种变体有的把 25 个关节的坐标和置信度合并成一个长度为 100 的数组有的只保留坐标长度为 75还有的已经切成(T, 25, 3)。所以下面的脚本会先对数组做形状判断兼容两种最常见布局。预处理的目标很明确把原始 JSON 统一成(T, V, C)的 float32 数组T 是固定帧数V 是关节点数C 是坐标维度。2.2 统一帧数与坐标归一化不能直接喂给网络的三个原因第一帧数不齐。同一个动作有人做得快有人做得慢原始序列长度从几十帧到几百帧都有。ST-GCN 的时间卷积核是在固定长度上滑动的batch 内必须形状一致。第二坐标系不统一。深度相机采集的坐标是相机坐标系下的绝对坐标人离相机远近直接改变坐标数值大小同一动作在不同距离下数值差异巨大。第三噪声尺度不同。不同人的身高、臂长不同同样是抬手手臂长的关节位移更大模型容易把体格特征误当成动作特征。常见做法是先做中心化再按人体尺度归一化。中心化的参考点一般取脊柱基座NTU 25 点里编号为 0 的点把所有关节坐标整体平移到以它为原点尺度归一化则用左右肩、左右髋四个点构成的躯干范围做参考把坐标除以躯干尺度。这样处理后高矮胖瘦、离相机远近的影响都被压到最小动作本身的相对几何关系保留下来。我建议不要用全局均值方差做标准化骨架坐标的统计量在不同动作间差异很大全局标准化反而会把动作差异抹平。2.3 预处理脚本JSON 转 npy 与标签清单一次搞定# json_to_npy.py # 输入NTU 风格 JSON 骨架文件目录 # 输出定长帧 npy 文件 训练/验证清单 txt import json import os import re import numpy as np TRUNK_JOINTS [4, 8, 12, 16] # NTU 25点中左肩、右肩、左髋、右髋0起始 CENTER_JOINT 0 # 脊柱基座 def interpolate_frames(coord, target_len): 把 (T, V, C) 线性插值到 target_len 帧 src_len coord.shape[0] if src_len target_len: return coord src_idx np.arange(src_len) dst_idx np.linspace(0, src_len - 1, target_len) out np.stack([ np.interp(dst_idx, src_idx, coord[:, v, c]) for v in range(coord.shape[1]) for c in range(coord.shape[2]) ], axis-1) return out.reshape(target_len, coord.shape[1], coord.shape[2]) def process_one(json_path, target_len64): with open(json_path, r, encodingutf-8) as f: data json.load(f)[data] skeletons data[skeletons] arr np.array(skeletons, dtypenp.float32) if arr.ndim 2 and arr.shape[-1] 75: coords arr.reshape(arr.shape[0], 25, 3) elif arr.ndim 3 and arr.shape[1] 25 and arr.shape[2] 3: coords arr[:, :, :3].astype(np.float32) else: raise ValueError(f未识别的骨架数组形状: {arr.shape}) # 中心化以脊柱基座为原点 center coords[:, CENTER_JOINT:CENTER_JOINT 1, :] coords coords - center # 尺度归一化以躯干四点的最大跨度为尺度 trunk coords[:, TRUNK_JOINTS, :] scale np.max(np.linalg.norm(trunk, axis-1), axis-1) scale np.clip(scale, 1e-3, None) coords coords / scale[:, None, None] return interpolate_frames(coords, target_len).astype(np.float32) def build_dataset(src_dir, dst_dir, target_len64, split_ratio0.8): os.makedirs(dst_dir, exist_okTrue) rows [] for fn in sorted(os.listdir(src_dir)): if not fn.endswith(.json): continue arr process_one(os.path.join(src_dir, fn), target_len) out_name fn.replace(.json, .npy) np.save(os.path.join(dst_dir, out_name), arr) # NTU 文件名形如 S001C001P001R001A001A 后是动作类别 label int(re.search(rA(\d), fn).group(1)) - 1 rows.append((out_name, label)) cut int(len(rows) * split_ratio) for split, indices in [(train, rows[:cut]), (val, rows[cut:])]: with open(os.path.join(dst_dir, f{split}_list.txt), w) as f: for name, label in indices: f.write(f{name} {label}\n)这段脚本最核心的是process_one中心化、尺度归一化、帧数插值三件事按顺序做顺序不能颠倒。先中心化再算尺度尺度才不会被人的绝对位置干扰先归一化再插值插值出来的中间帧也是在统一坐标空间里。target_len64是常用默认值如果你的数据集动作普遍很长可以提到 128如果普遍很短64 反而会产生大量插值重复帧建议先看一眼原始帧长分布再定。TRUNK_JOINTS是按 NTU 25 点定义写的如果你的关节点定义不是这一套改成你自己的肩髋四点下标即可。脚本最后生成的train_list.txt和val_list.txt是纯文本清单每行一个样本名加标签训练时直接读省得每次启动都重新扫目录。2.4 归一化的两种常见误用与验证方式第一个误用是用整个数据集的均值方差做标准化。骨架坐标不像图像像素那样分布集中不同动作的关节位置可能相差很远全局标准化会把本来明显的动作差异压缩到很小的数值范围训练时 loss 降得下去验证时泛化很差。第二个误用是尺度归一化用了所有关节的全局最大距离。只要有一个异常帧的关节点坐标飘出去整个样本的尺度就被拉大所有关节坐标被压扁动作形变失真。用躯干四点更稳因为这四个点受手臂摆动影响小不会因为一个挥手动作就改变尺度基准。验证预处理是否正确最直观的办法是随机抽几个样本用 matplotlib 把 npy 里的骨架序列画出来。plt.plot(coords[:, v, 0], coords[:, v, 1])按帧连点看看动作是否连贯、躯干是否在画面中心附近、尺度是否大致一致。这一步能发现八成预处理问题比盯着 loss 曲线有效得多。3. 搭建 ST-GCN 核心网络邻接矩阵、图卷积块与 9 层模型的调参细节3.1 人体骨架为什么是图表格和序列都装不下连接关系如果把每帧骨架写成一行表格每行是 25 个关节的坐标表格天然丢失了“哪些关节是连着的”这个信息。肘和腕在坐标上可能隔得很远但在骨骼结构上直接相连卷积操作在表格上只能按行列滑动学不到这种物理连接。用 LSTM/GRU 做序列建模也有同样的短板时间维建模没问题但每一帧内部的空间拓扑关系需要网络从零开始学骨架样本量通常不大学不充分。图卷积把连接关系直接编码进邻接矩阵当作先验信息注入网络。每个节点聚合邻居节点的特征时网络不需要自己发现“手腕连着肘”这个结构已经在矩阵里写死了。ST-GCN 做的事情就是在骨架图上做空间图卷积再在时间维上做一维卷积形成“先聚合空间邻居再沿时间滑动”的双重建模。这也是它在中小规模骨骼动作数据集上表现稳定的结构性原因。3.2 邻接矩阵的构建与三种子集划分向心、离心和自环邻接矩阵的第一步是把物理连接变成 0/1 矩阵。joint_pairs是一个列表元素是(i, j)表示第 i 个关节和第 j 个关节之间有骨骼相连。构建A_base时要把无向边写成对称的两条边即同时设置A[i, j]1和A[j, i]1因为信息既要沿骨骼从头传到手也要从手传回头。ST-GCN 原论文没有止步于单一邻接矩阵而是把它划分成三个子集自环节点自身、向心边邻居离重心更近、离心边邻居离重心更远。划分的意义在于不同方向的邻居对动作的贡献不同。比如“挥手”动作中手离重心的距离变化剧烈手与躯干之间的边承载的信息远比两个手指尖之间的边重要。如果所有边共用同一套卷积权重这些差异就被平均掉了。# stgcn_layers.py import torch import torch.nn as nn import numpy as np def normalize_adj(mask): 对称归一化: D^{-1/2} A D^{-1/2} D mask.sum(axis0) D_inv_sqrt np.power(D, -0.5) D_inv_sqrt[np.isinf(D_inv_sqrt)] 0.0 D_inv_sqrt np.diag(D_inv_sqrt) return D_inv_sqrt mask D_inv_sqrt def build_masks(joint_pairs, avg_coord, center_idx0, num_joints25): joint_pairs: 物理连接列表元素为 (i, j) avg_coord: (V, 3)训练集所有样本关节坐标的平均值 dists np.linalg.norm(avg_coord - avg_coord[center_idx], axis1) self_mask np.eye(num_joints, dtypenp.float32) in_mask np.zeros((num_joints, num_joints), dtypenp.float32) out_mask np.zeros_like(in_mask) for i, j in joint_pairs: if dists[i] dists[j]: in_mask[i, j] 1.0 # 近节点向远邻居传递信息 else: out_mask[i, j] 1.0 return [normalize_adj(self_mask), normalize_adj(in_mask), normalize_adj(out_mask)]build_masks返回三个归一化后的子集矩阵。normalize_adj用的是对称归一化比随机游走归一化D^{-1}A在节点度数差异大的图上更稳定这是经验之谈。avg_coord需要在预处理时对训练集所有样本按关节位置求平均它决定了每个节点到重心的相对距离。注意这里算的是“训练集平均骨架”下的距离而不是每个样本动态变化的距离这样三个子集的划分在训练和推理时保持一致。3.3 ST-GCN 核心块代码空间图卷积 时间卷积 残差空间图卷积的实现很简洁输入张量是(N, C, T, V)分别对应 batch、坐标通道、帧数、关节点数。对每个子集矩阵做一次einsum聚合把三个子集的结果在通道维拼接再用 1×1 卷积混合信息。时间卷积则是在 T 维上做一维卷积kernel 覆盖相邻帧。class SpatialGraphConv(nn.Module): def __init__(self, in_ch, out_ch, masks): super().__init__() self.masks [torch.from_numpy(m).float() for m in masks] self.conv nn.Conv2d(in_ch * len(masks), out_ch, kernel_size1) self.bn nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) def forward(self, x): assert x.dim() 4, f输入必须是 (N,C,T,V)当前形状 {x.shape} out [] for mask in self.masks: mask mask.to(x.device) out.append(torch.einsum(nctv,vw-nctw, x, mask)) x torch.cat(out, dim1) return self.relu(self.bn(self.conv(x))) class TemporalConv(nn.Module): def __init__(self, in_ch, out_ch, kernel_size9, stride1): super().__init__() pad (kernel_size - 1) // 2 self.conv nn.Conv2d( in_ch, out_ch, kernel_size(kernel_size, 1), stride(stride, 1), padding(pad, 0) ) self.bn nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) def forward(self, x): return self.relu(self.bn(self.conv(x))) class STGCNBlock(nn.Module): def __init__(self, in_ch, out_ch, masks, temporal_kernel9, stride1, dropout0.5): super().__init__() self.gcn SpatialGraphConv(in_ch, out_ch, masks) self.tcn TemporalConv(out_ch, out_ch, temporal_kernel, stride) self.dropout nn.Dropout(dropout) self.residual (in_ch ! out_ch) or (stride ! 1) if self.residual: self.res_conv nn.Conv2d( in_ch, out_ch, kernel_size1, stride(stride, 1)) self.res_bn nn.BatchNorm2d(out_ch) def forward(self, x): res x x self.gcn(x) x self.tcn(x) if self.residual: res self.res_bn(self.res_conv(res)) return self.dropout(x res)SpatialGraphConv里的einsum(nctv,vw-nctw, x, mask)是把 mask 当作一个线性变换作用在 V 维上语义是“每个节点收集所有邻居的特征”。三个子集分别做聚合后拼接1×1 卷积再把维度压回out_ch。TemporalConv的时间卷积核是(kernel_size, 1)只在 T 维滑动不动 V 维。STGCNBlock的残差连接在通道数变化或时间维下采样时必须用 1×1 卷积对齐否则x res形状不匹配这个错误在堆叠多层时才会暴露单层测试看不出来。3.4 9 层网络的通道配置与三个必调参数阶段层号输入通道输出通道时间维 stride1136411264641136464124641282251281281261281281371282562382562561392562561最后接全局平均池化和全连接分类头。九层是 ST-GCN 原论文在 Kinetics 规模数据上的配置如果毕设用的是 NTU 单子集或自建小数据集九层容易过拟合。我一般会先砍到六层每阶段两层把通道数保持不变看验证集精度是否还涨得动再决定要不要加回第三层。def build_net(masks, num_class60): config [ (64, 1), (64, 1), (64, 1), (128, 2), (128, 1), (128, 1), (256, 2), (256, 1), (256, 1), ] blocks [] in_ch 3 for out_ch, stride in config: blocks.append(STGCNBlock(in_ch, out_ch, masks, stridestride)) in_ch out_ch return nn.Sequential( *blocks, nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(in_ch, num_class) )三个必调参数按优先级排dropout第一骨架数据规模小最后的 Dropout 建议 0.5 起步过拟合明显时直接拉到 0.7temporal_kernel第二9 是原论文的默认但数据集动作节奏偏快时 5 反而更好因为卷积核太大会把短促动作的起止信息揉在一起stride第三下采样放在第 4 和第 7 层目的是让时间维逐阶段减半如果 T 本身只有 64 帧还想保留更多时序信息可以只在第 7 层下采样一次。4. 训练一个能用的识别模型优化器、学习率与断点续训的实操4.1 文件名即标签从 NTU 命名规则生成训练清单预处理脚本已经写好了train_list.txt训练时只需要按行读入。NTU 的文件名S001C001P001R001A001里A001是动作类别S是 subject人C是相机P是 performer。按文件名顺序做 8:2 切分有一个好处如果数据集的类别样本数是均匀的这个切分天然保持了类别分布。如果你的数据来自多个受试者更严谨的划分是按 subject 切分保证同一个人的动作不同时出现在训练和验证集里否则验证集精度会虚高。# train_stgcn.py import os import numpy as np import torch import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader class SkeletonDataset(Dataset): def __init__(self, list_file, root): self.root root self.samples [] with open(list_file, r) as f: for line in f: name, label line.strip().split() self.samples.append((name, int(label))) def __len__(self): return len(self.samples) def __getitem__(self, idx): name, label self.samples[idx] x np.load(os.path.join(self.root, name)) # (T, V, C) x torch.from_numpy(x).permute(2, 0, 1).float() # (C, T, V) return x, label这里的permute(2, 0, 1)是把数组从(T, V, C)换成(C, T, V)对应网络输入的(N, C, T, V)。框架里存储用(T, V, C)是为了让 npy 文件里坐标通道在最后一个维度可视化时不需要再转置送入网络前再 permute 一次两边都顺。这里踩过的坑是预处理时直接存成(C, T, V)结果用 matplotlib 画图时坐标维被拆到不同位置画出来全是乱线建议存储格式和网络输入格式分开。4.2 类别不均衡处理别让“常做的动作”垄断梯度骨架数据集里类别不均衡很常见。NTU 60 的前 40 类动作样本数量明显多于后 20 类如果不处理模型倾向于把所有输入都预测成样本量大的类别验证集 top-1 看着还行但混淆矩阵里小类别几乎全错。from collections import Counter def make_class_weight(labels, num_class): counter Counter(labels) total len(labels) weight [total / (num_class * counter[i]) for i in range(num_class)] return torch.tensor(weight, dtypetorch.float)make_class_weight算出的权重是“每个类别的样本占比倒数”样本少的类别权重更大损失函数里对少样本类别犯错的惩罚更重。使用时注意要在ClassWeight传入CrossEntropyLoss时同步放到 GPU 上否则会报 device mismatch 的错。如果你的数据集各类别数量差距在 5 倍以内不加权重问题不大超过 10 倍这段代码就是必须的。4.3 优化器与学习率调度SGD 配阶梯下降为什么比 Adam 稳骨架动作识别这个任务上我测试过 Adam 和 SGD 的对比结论是Adam 收敛快但后期泛化精度普遍低于 SGD。原因是骨架特征本身已经经过了坐标归一化分布相对稳定Adam 的自适应学习率反而会让后期更新步长偏大在最优解附近震荡。常见的做法是 SGD momentum 阶梯下降这是 ResNet 时代沉淀下来的稳妥组合。超参数推荐值说明optimizerSGD (nesterovTrue)momentum0.9initial lr0.01batch 小lr 要按比例缩weight_decay0.0001骨架数据量小正则太强会欠拟合batch_size16单卡 8G 显存够用total_epochs12060 类动作少于 60 轮学不完milestones[60, 90]第 60、90 轮 lr × 0.1初始学习率 0.01 是经验值。原论文用的 0.1 是针对大 batch 的毕设单卡 batch 16 的时候直接用 0.1前三步 loss 大概率直接飞掉。weight_decay也别照抄 0.0001 就完事如果训练集只有几千个样本0.001 会让模型欠拟合train loss 降不下去。def train_one_epoch(model, loader, optimizer, loss_fn, device): model.train() total_loss, correct, total 0, 0, 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() out model(x) loss loss_fn(out, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) pred out.argmax(dim1) correct (pred y).sum().item() total y.size(0) return total_loss / total, correct / total这个训练循环够用了不需要额外花哨的东西。loss_fn在外部创建时传入weightclass_weight训练和验证都用同一个模型实例不要在循环里反复model Model()这会让 BatchNorm 的统计量被反复清零精度忽上忽下。4.4 训练循环与断点续训checkpoint 里该存什么训练 120 轮在单卡上可能要跑十几个小时中断一次就得从头来的感觉非常难受。所以 checkpoint 里不光要存模型权重optimizer 的状态、scheduler 的状态、当前 epoch、历史最佳精度都要一起存进去。def save_checkpoint(path, model, optimizer, scheduler, epoch, best_acc): torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), epoch: epoch, best_acc: best_acc, }, path) def resume_checkpoint(path, model, optimizer, scheduler, device): ckpt torch.load(path, map_locationdevice) model.load_state_dict(ckpt[model]) optimizer.load_state_dict(ckpt[optimizer]) scheduler.load_state_dict(ckpt[scheduler]) return ckpt[epoch] 1, ckpt[best_acc]optimizer.state_dict()里存的是 momentum 的滑动均值scheduler.state_dict()里存的是当前学习率。只恢复model.state_dict()的结果是模型权重接上了但优化器状态全丢相当于换了个新手套训练后期的精度反复横跳。每次验证集精度刷新到历史最佳时保存一份路径用best_model.pt命名最后交代码的时候也方便别人直接加载推理。5. ST-GCN 训练最常翻车的 5 个问题现象、定位与修复5.1 维度顺序搞错loss 在降但 acc 不涨现象训练 loss 正常下降验证集 top-1 却一直停留在接近 1/类别数的水平。比如 60 类动作acc 始终在 2% 上下浮动。原因输入张量的维度顺序不对。图卷积的einsum对输入维度不敏感把(N, C, T, V)错弄成(N, T, C, V)它也能算只是卷积把所有维度混在一起滑动了。关节维度和时间维度被错误地揉进同一个卷积核网络实际上在学习一个没有物理意义的混合映射。解决在SpatialGraphConv.forward里加一个断言第一行就检查x.dim() 4 and x.shape[3] VV 必须在最后一维。同时建议 DataLoader 里读取 npy 后打印一次真实 shape肉眼确认是(C, T, V)再继续。5.2 邻接矩阵缺自环或没做归一化梯度爆炸和 NaN现象训练刚开始几步 loss 就变成 NaN或者 loss 快速下降后出现锯齿状反弹验证集精度剧烈抖动。原因如果三个子集矩阵里没有自环每个节点的特征只能来自邻居经过多层堆叠后自身信息被稀释梯度传播不稳定。另一个更隐蔽的原因是直接用 0/1 矩阵参与einsum没有做D^{-1/2} A D^{-1/2}归一化度大的关节比如躯干中心聚合的特征数值远大于手脚末端BatchNorm 压不住这种量级差异。解决build_masks里第一个子集直接用np.eye(num_joints)确保自环存在所有子集矩阵都经过normalize_adj处理。检查方法很简单打印三个 mask 的每行和如果某一行和大于 1.5说明归一化没生效。5.3 全局标准化翻车训练虚高、验证崩盘现象训练集 top-1 能到 90% 以上验证集只有 60% 左右而且换一批数据再评估精度下降更明显。原因数据预处理时用了整个训练集的均值和方差做标准化。骨架坐标不像图像像素那样围绕固定均值分布不同动作的关节位置差异本身就是关键特征全局标准化把它压缩掉了。更要命的是验证集和测试集的均值方差和训练集不同部署时还得额外保存这些统计量一换场景就错。解决回到第 2 章的逐帧中心化加躯干尺度归一化。这个方案不依赖训练集统计量每个样本独立计算部署时只需要知道关节点定义不需要携带额外的归一化参数。5.4 断点恢复后学习率回到初始值精度反复横跳现象训练中断后加载 checkpoint 继续训练前几十个 epoch 精度爬到接近历史最佳就不再上升或者在一个区间内反复震荡。原因checkpoint 里只存了model.state_dict()。模型权重恢复到了中断时的状态但optimizer里的 momentum 缓冲是空的scheduler里的当前学习率也被重置回初始值。在训练后期用大学习率继续更新权重自然在最优解附近来回跳。解决按 4.4 节的save_checkpoint格式保存完整状态。如果手头已经丢了 optimizer 状态补救办法是从当前权重出发把学习率降到原计划的 1/10 再训相当于重新热身虽然能救但会多花时间。5.5 数据脏值引发 NaN某个 epoch 开始 loss 全变 NaN现象训练前几十个 epoch 正常某次迭代 loss 突然变成 NaN之后所有 batch 都是 NaN只能重开。原因某个样本的 JSON 里存在缺失坐标或异常坐标值。最常见的是两个关节在深度图上重叠导致躯干尺度算出来接近 0除以 0 后坐标变成 inf进入 BatchNorm 后梯度直接炸掉。这类问题在数据量小时不暴露因为触发它的样本刚好排在数据加载器靠后位置。解决process_one里加一行检查assert np.isfinite(coords).all()否则打印文件名并跳过。另外scale np.clip(scale, 1e-3, None)保证尺度下限不为 0。DataLoader 设置drop_lastTrue防止最后几个 batch 形状不完整带来的维度错误。6. 从能跑到能答辩混淆矩阵、双流涨点和导出演示6.1 混淆矩阵与错误样本回看训练结束后第一件事不是看 top-1而是画混淆矩阵。骨架动作识别的错误往往集中在相似动作之间比如“坐下”和“蹲下”、“挥手”和“招手”。用 sklearn 的confusion_matrix配合 seaborn 画热力图能一眼看出哪些类别混淆严重。接着把预测错误的样本按置信度排序打印出预测概率最高的前三个类别你会发现模型犯错不是随机乱猜而是真的在两个动作之间犹豫。答辩时这一页比 acc 数字更有价值。from sklearn.metrics import confusion_matrix import seaborn as sns cm confusion_matrix(y_true, y_pred) sns.heatmap(cm, annotTrue, fmtd)6.2 双流 ST-GCNJoint 流 Bone 流的简单涨点单流模型在 NTU 这类数据集上精度到瓶颈后最便宜的涨点方案是双流融合。Joint 流喂的是关节坐标Bone 流喂的是骨骼向量——每个关节的坐标减去它父节点的坐标得到的向量描述了骨骼的方向和长度。两个流各训练一个 ST-GCN推理时把两路的 softmax 输出做加权平均这个思路来自 2s-AGCN实现成本很低因为网络结构完全不用改只是预处理多出一份 Bone 特征。我在自己的实验里双流融合比单流 Joint 稳定性明显更好尤其是对上肢动作和下肢动作这类结构差异大的类别。6.3 模型导出演示与收尾心得答辩演示环节现场跑 Python 脚本有环境风险。把训练好的模型用 TorchScript 导出写一个几十行的推理脚本输入 npy 文件输出动作类别和置信度这比在笔记本上现场配 PyTorch 环境靠谱得多。如果还想省事用 PyInstaller 把推理脚本打成可执行文件双击就能跑自备一个可视化界面。交叉验证阶段我习惯把模型在验证集上所有的预测输出保存成 CSV不本文还有配套的精品资源点击获取