
提起动作识别NTU RGBD 120数据集基本是绕不开的一个名字。它提供大规模RGB、深度、红外和3D骨架数据其中骨架分支因为不受外观和光照干扰成为大多数论文首选的模态。可真正想用Python把这些.skeleton文件变成能看、能训练的数据很多人第一步就卡住了。我也是从零开始啃这套数据集的人最初连.skeleton里存了什么都不知道。这篇东西算是我把整套流程走通之后的工作笔记适合刚接触NTU RGBD、准备用Python做骨架动作识别或者复现ST-GCN一类模型的人。里面既有文件格式拆解也有可视化、预处理和踩坑记录可以直接当参考脚本用。1. NTU RGBD 120到底能干什么1.1 三种模态为什么骨架最值得优先研究NTU RGBD系列数据集采集的是日常动作、医疗康复动作和双人互动动作每段视频里会给出RGB彩色画面、深度图、红外图以及通过Kinect定位得到的3D人体骨架。骨架数据看起来只是一堆三维坐标点但它的优势非常明显不依赖画面纹理不受衣服颜色、环境光线和背景影响在一个纯黑房间和一间明亮的教室里同一动作的骨架序列几乎是等价的。这个特性让骨架成为动作识别任务里最“省心”的输入模态。很多初学者容易犯一个错误一上来就一帧一帧跑姿态估计模型试图从RGB视频里把骨架“现算”出来。但NTU本身就提供了高精度的骨架标注没必要重复造轮子。你需要做的只是把.skeleton文件读出来按时间顺序组织成序列再交给分类网络或者图神经网络。这也意味着用Python处理骨架数据时核心工作集中在数据解析、坐标变换、时序对齐和可视化验证这四块而不是模型本身。1.2 60和120的差异以及实际任务适配NTU RGBD 60和NTU RGBD 120之间的区别不只是类别数量翻倍那么简单。从数据规模上说60版本包含60类动作、40位受试者、大约5.6万个样本120版本扩展到120类动作、106位受试者、大约11.4万个样本。更重要的是120版本新增了一批摄像机设置和更多样的视角样本编号里包含的相机位置和setup信息更丰富用来验证模型泛化能力时会更有说服力。对比项NTU RGBD 60NTU RGBD 120动作类别60类120类受试者数量40人106人样本数量约5.6万约11.4万样本分辨率512x424左右视版本而定512x424左右视版本而定典型评估协议Cross-Subject、Cross-ViewCross-Subject、Cross-Setup如果你的目标是做算法快速验证和论文基线对比60版本训练负担小跑一轮实验很快如果追求更扎实的泛化结论120版本是更合适的基准。实际项目里我建议先拿60版本调通整条数据管线确认解析、可视化和模型输入都正确后再切成120跑全量实验。否则一个预处理维度的小错误在120数据集上可能要跑十几个小时才发现代价太高。2. 用Python打开NTU骨架文件格式、库与实际解析2.1 .skeleton文件的原始格式NTU官方发布的骨架数据是.skeleton文件和普通的文本格式很像可以直接用记事本打开看。文件顶部第一行是这个序列的总帧数。接下来每一帧单独成块先写帧序号再写这一帧里检测到的主体数量也就是同时出现在画面中的人体数量。每个主体块内部第一行是主体ID、裁剪状态和裁剪框参数第二行是关节数量再往下就是这个主体的25个关节点坐标。每个关节点一行一行里包含的数值远不止三维坐标。以Kinect v2采集格式为基础NTU骨架关节点里会同时给出相机坐标系下的x、y、z深度图像坐标系下的坐标RGB图像坐标系下的坐标以及一个表示该点置信度的数值。对绝大多数动作识别任务来说真正有用的是前三个相机坐标值但字段顺序在不同来源的解析代码里有差异。我自己见过至少三种版本顺序的解析脚本稳妥的方法是自己先打印几行原始数据确认。3 1 1 748 0 0 0 0 25 -0.345 0.432 1.234 ... ...上面是一个极简示例第一行表示3帧第二行是帧序号1第三行是主体数1第四行是主体ID等元信息第五行是关节数25后面跟着25行关节点数据。读文件的时候必须严格按这个结构走千万不要简单的按行数均分。2.2 自己写解析器的正确姿势虽然网上有很多现成处理库我还是建议至少手写一遍解析器。这个活不复杂却能让你对数据结构有非常直观的理解。下面这段是我常用的解析函数兼容常见的NTU格式化文件能直接把一个.skeleton文件读成“帧列表”每一帧是主体列表每个主体是一个(25, 3)的numpy数组。import numpy as np from pathlib import Path def parse_skeleton_file(path): path Path(path) with open(path, r, encodingutf-8) as f: lines f.readlines() idx 0 num_frames int(lines[idx].strip()) idx 1 frames [] for _ in range(num_frames): frame_id int(lines[idx].strip()) idx 1 num_bodies int(lines[idx].strip()) idx 1 bodies [] for _ in range(num_bodies): # 主体元数据行这里只做占位不参与后续计算 # body_id, clipping_state, boundary1, boundary2 idx 1 num_joints int(lines[idx].strip()) idx 1 joints [] for _ in range(num_joints): parts lines[idx].split() # 前三个数值是相机坐标x,y,z joints.append([float(parts[0]), float(parts[1]), float(parts[2])]) idx 1 bodies.append(np.array(joints, dtypenp.float32).reshape(-1, 3)) frames.append(bodies) return frames这段代码看起来简单但有三个容易忽略的细节。第一读完每一行后必须立刻更新索引否则很容易在主体数、关节数这些控制行上错位。第二主体元数据行虽然暂时没用但必须把游标跳过去。第三numpy数组的dtype尽量用float32后续喂给深度学习框架能省一半内存。2.3 用第三方库还是自己解析GitHub上有一个叫pyntu的第三方库专门用于读取NTU RGBD数据功能挺全能解析骨架、深度图和RGB视频还能做一定的裁剪对齐。如果你只是临时看几个样本pyntu够用。但我在真实项目里碰到过几个问题库对文件路径和文件命名的格式要求比较严格不同数据来源的命名规则稍微一变就容易解析失败另外它内部对骨架字段的解析顺序跟我的数据不完全一致导致坐标整体错位。所以我最终的方案是第三方库用来做交叉验证正式训练和数据处理全部用自己写的解析函数。这样做的好处是数据结构完全可控后续要加缓存、多进程、格式转换都方便。无论用什么库解析完成后最好立刻做一次“可视化解谜”随机抽几个文件把第一帧骨架画出来确认手脚关系没有搞反这一步能省下后面大量排查时间。3. 把骨架画出来从坐标到人体姿态图3.1 建立关节连接关系从Kinect v2里拿到的25个关节点每个点都有一个固定的索引编号但只看数字很难想象人体结构。比如第0号通常是脊柱底部第4号是头顶第8号是左手第13号是右手。如果你不建立关节点之间的连接关系绘图时只会得到一团散点根本看不出动作形态。我习惯先定义一个连接列表每条边由两个关节索引组成。下面这份连接表不是唯一的不同项目可能略有差异但它能覆盖大部分常用的骨架拓扑SKELETON_CONNECTIONS [ # 躯干和头 (0, 1), (1, 2), (2, 3), (3, 4), (2, 23), (23, 24), # 左上肢 (1, 5), (5, 6), (6, 7), (7, 8), (8, 9), # 右上肢 (1, 10), (10, 11), (11, 12), (12, 13), (13, 14), # 左下肢 (0, 15), (15, 16), (16, 17), (17, 18), # 右下肢 (0, 19), (19, 20), (20, 21), (21, 22), ]这里第23和第24号点并不是所有数据集版本都有明确定义的有的项目里是左右拇指尖有的项目里是肩部中心附近的关键点。画图时如果发现某条边指向了奇怪的位置可以打印所有关节点名称和名称表对比一下。更好的做法是画完骨架后直接输出一个“火柴人”侧视图人眼扫一遍就能看出连接关系是否合理。3.2 用matplotlib绘制单帧3D骨架解析得到的是相机坐标系下的三维坐标画图最顺手的方式是用matplotlib的3D坐标轴。关键点在于坐标轴的顺序Kinect坐标系里x通常指向人体的右侧y指向正上方z指向相机。matplotlib里默认的坐标轴方向需要稍作调整否则画出来的人可能是仰视视角看起来非常奇怪。import matplotlib.pyplot as plt def plot_skeleton_frame(joints, axNone): if ax is None: fig plt.figure(figsize(6, 6)) ax fig.add_subplot(111, projection3d) ax.cla() for i, j in SKELETON_CONNECTIONS: if i len(joints) or j len(joints): continue x [joints[i][0], joints[j][0]] y [joints[i][1], joints[j][1]] z [joints[i][2], joints[j][2]] ax.plot(x, y, z, o-, linewidth2) # 根据实际坐标范围调整不要照搬 ax.set_xlim(-1.5, 1.5) ax.set_ylim(-1.5, 1.5) ax.set_zlim(-1.5, 1.5) ax.set_xlabel(x) ax.set_ylabel(y) ax.set_zlabel(z) return ax绘制单帧的时候我会把相机坐标先做一次简单归一化以人体中心为原点再缩放到合理区间。不归一化直接画不同样本的尺度差异会很大有的骨架连区间范围都看不出来。归一化方法也很简单取第0号关节作为根节点把每个关节点都减去这个根节点坐标再除以所有关节点坐标绝对值的最大值。3.3 做成动画验证时序数据静态单帧看不出动作趋势所以真正验证数据质量必须做成序列动画。利用matplotlib的FuncAnimation把解析出来的每一帧依次画到同一个坐标轴上就能得到一个流畅的动作预览。这个步骤对发现标错标签、缺帧、抖动剧烈等问题非常有帮助。from matplotlib.animation import FuncAnimation def animate_skeleton(frames, subject_index0): fig plt.figure(figsize(8, 6)) ax fig.add_subplot(111, projection3d) def update(t): if t len(frames) and frames[t]: # 通常每帧只有一个主体取第0个即可 joints frames[t][subject_index] plot_skeleton_frame(joints, axax) anim FuncAnimation(fig, update, frameslen(frames), interval33) return anim如果帧数很多比如300帧的序列每次update重新绘制所有元素会非常卡。优化方式有两个一是把坐标点对象和线段对象的引用缓存起来每帧只更新位置数据二是先用隔帧抽样的方式快速预览确认大致动作正确后再全帧渲染。我实际使用时一般先抽到30帧左右预览结果符合预期再处理完整序列。4. 从可视化到模型输入骨架预处理的完整流程4.1 坐标系转换与数据清洗原始骨架坐标是相机坐标系下的绝对值同一个动作离相机近和离相机远数值范围完全不同。直接把这些值送进模型模型会分不清“动作大小”和“离相机远近”的区别。因此预处理的第一步就是坐标标准化。最常用的做法是把第0号关节也就是骨盆中心作为根节点平移到原点然后统一缩放到单位尺度。这样整个骨架序列就变成相对身体中心的运动轨迹。对于站立动作这个变换相当于把人体“摆到”坐标系中心再观察四肢如何摆动。此外NTU数据里有些帧会因为遮挡或追踪丢失而出现全部为零的异常值这类帧应该在预处理阶段直接剔除或通过线性插值补齐。def normalize_by_root(joints, root_index0): center joints[root_index].copy() joints joints - center scale np.abs(joints).max() if scale 1e-6: joints joints / scale return joints补帧也是一个常见需求。如果某几帧检测不到任何一个主体最简单的办法是直接丢弃这些帧。但有些模型要求序列等长可以把缺失段用前后帧的线性插值填上。插值逻辑并不复杂找到缺失段的起始帧和结束帧按帧数均匀生成中间坐标即可。不要用零填充零值会让模型误以为关节点都聚在原点反而引入误导信息。4.2 构建标准张量N, C, T, V, M骨架数据进入图神经网络前需要组织成统一维度的张量。ST-GCN系列模型广泛采用(N, C, T, V, M)的格式其中N是batch sizeC是通道数T是时序帧数V是关节数M是主体数。对NTU RGBD来说C通常取3或2V是25M在单人和双人动作里分别是1或2。def frames_to_tensor(frames, max_frames300, num_joints25, num_bodies2): C, T, V, M 3, max_frames, num_joints, num_bodies feat np.zeros((C, T, V, M), dtypenp.float32) for t, bodies in enumerate(frames): if t max_frames: break for m in range(min(len(bodies), M)): joints bodies[m] for v in range(min(len(joints), V)): # 取x,y,z三个坐标 feat[:, t, v, m] joints[v][:3] return feat一个容易被忽略的地方是帧长对齐。NTU数据集的视频长度并不一致有的动作只有十几帧有的长达三百多帧。多数模型会约定一个最大帧数比如300超过就截断不足就补零。补零的问题在于模型可能会把补零段当成“静止不动”的动作此时最好配合一个mask张量让模型知道哪些位置是有效帧。很多开源代码里没有mask也能训练出不错的结果但从严谨性角度讲mask对处理残缺序列更友好。4.3 数据增强与训练管线建议骨架数据的数据增强和图像不太一样不能用随机裁剪、翻转直接套。最有效的是对三维坐标做小幅旋转变换模拟相机视角变化。NTU 120数据集本身就有多视角样本但训练时做随机旋转仍然能提升泛化能力。def random_rotate_around_y(joints, angle_deg15): rad np.deg2rad(np.random.uniform(-angle_deg, angle_deg)) cos, sin np.cos(rad), np.sin(rad) rot np.array([ [cos, 0, sin], [0, 1, 0], [-sin, 0, cos] ], dtypenp.float32) return (joints rot.T).astype(np.float32)完整训练管线我会按这个顺序组织先解析所有.skeleton文件并缓存成npy或pkl文件这一步最耗时只跑一次然后写一个Dataset类负责按索引读取缓存文件、做坐标归一化、随机旋转增强、转换成(C,T,V,M)张量最后再交给ST-GCN或者简单的时序分类器。用缓存文件而不是每次训练都解析原始文件能节省大量I/O时间尤其在120数据集上效果立竿见影。5. 实战踩坑记录与排查速查表5.1 最常出现的五个问题用NTU RGBD骨架数据时我踩过不少坑有些问题非常隐蔽排查起来很费时间。下面这份速查表基本覆盖了最常见的情况。现象可能原因解决办法解析程序报越界错误原始文件里有多余空行或换行符不统一解析前先过滤空行并用strip()清理每行首尾骨架坐标数值巨大部分文件是毫米单位部分归一化过统一做以根节点为中心的尺度归一化某帧没有主体数据追踪丢失、人群遮挡丢弃该帧或线性插值补齐绘制时人体左右颠倒坐标点索引连接关系不匹配对照官方骨架拓扑修正连接表训练集读取非常慢每次训练都重新解析原始文件提前解析并缓存为npy或pkl格式头两个问题最容易出现在数据来源不统一的场景。比如从官网下载的数据和从第三方镜像下载的数据虽然都叫NTU但字段顺序和坐标单位可能有差异。我的建议是每拿到一批新数据就立刻抽几个样本做可视化人眼确认关节点位置是否合理这比任何代码检查都直接。5.2 高效读取与验证的实用技巧120数据集有超过十万个样本文件纯Python逐行解析一个文件大约需要几十毫秒全量解析下来耗时很长。我后来把所有解析结果都转成了npy格式并按固定文件名保存训练时直接numpy加载。原本需要几小时预处理的数据压缩到几分钟之内。并行解析也是一个好办法。使用Python的multiprocessing.Pool可以一次性把全部文件拆分成多个任务每个进程独立解析一部分最后合并结果。实测在8核机器上解析速度能提升六倍以上。不过要注意解析进程多的时候内存占用会明显上涨建议按文件数量分批处理避免把所有结果一次性堆进内存。此外我强烈建议训练前先跑一个“管线自检脚本”用大约0.1%的数据走一遍解析、增强、张量化、模型前向的完整流程。自检脚本的作用不是验证精度而是确认维度对不对、类型对不对、loss能不能正常下降。很多看起来玄学的问题其实都出在数据维度上这类问题在大规模实验前发现越早越好。我在实际使用中最满意的一步是先把单帧可视化脚本和批量解析器解耦。后续不管是排查某个异常样本还是切换数据划分规则我都能在几分钟内定位问题不用重跑整套预处理。如果你也准备在NTU RGBD 120上做骨架动作识别建议先把这一套解析、绘制、缓存流程搭稳再谈模型设计——数据管道稳定了后面所有实验都会顺畅很多。