
做动作识别的人大概都会在某个阶段撞上同一个问题——拿到NTU RGBD 120数据集之后打开一个骨架文件发现里面全是一堆数字根本没法直接看出这个动作是什么。这大概是所有基于骨架的动作识别任务里最反直觉的一步明明数据里包含了完整的人体运动信息但要建立直观认识光靠看数组完全不够。本文就用Python把NTU RGBD 120的骨架数据完整地“画”出来从.mat文件的加载、25个关节点的坐标含义到逐帧动画和视频导出一条链路全部讲清楚。同时会把我在这个过程中踩过的坑一并写出来适合正在入门骨架动作识别、或者准备用这个数据集跑实验的同学参考。1. 先搞清楚NTU RGBD 120到底是什么1.1 数据集规模与基本组成NTU RGBD 120是目前基于3D骨架的动作识别领域最常用来做基准的数据集之一。很多顶会文章的消融实验、对比实验都跑在这上面所以对这个数据集的理解程度直接影响你能不能看懂别人的论文、能不能复现出一个像样的结果。这个数据集的动作类别总共有120类样本量在11万左右。它是在NTU RGBD 60的基础上扩展出来的受试者数量和相机环境都比之前复杂得多。每条样本同时包含四种模态RGB视频、深度图、红外图和3D骨架。其中骨架模态是最容易让人忽略、但实际价值最高的部分——它只有25个关节点在每一帧的3D坐标但正是这个极简表示让动作识别模型可以在不依赖外观信息的情况下完成分类。采集设备是Kinect v2所以骨架关节点的定义和Kinect v2的人体关节点一致。代码里处理骨架数据时我会直接用官方提供的.mat文件而不会自己去从深度图或者RGB图里提取骨架。我想强调一点如果你只是为了做动作识别实验直接用官方骨架数据就足够了不需要重复造轮子。1.2 NTU 120的评估协议NTU 120这个数据集在评估协议上和NTU 60有一点区别。NTU 60通常用cross-subject和cross-view而NTU 120因为相机安装的位置和角度组合更多官方主推的协议是cross-subject和cross-setup。Cross-Subject按受试者ID划分训练集和测试集。也就是说训练时模型没有见过测试集里的人考验的是跨人的泛化能力。Cross-Setup按相机的高度和水平角度组合划分。这个协议考察的是模型在不同相机安装条件下的鲁棒性。理解这两个协议很重要因为论文里报的精度数字都是基于这些设定。如果你在做完可视化之后准备训练模型别把数据划分搞错了否则实验结论在同行眼里是不成立的。2. 骨架文件里到底存了什么格式解读与加载逻辑2.1 文件命名规则与data结构体的维度含义NTU RGBD 120骨架文件的命名格式非常规范一般长这样S001C001P001R001A001_sk_skeleton.matS001做动作的受试者IDC001相机IDP001执行的重复次数performanceR001拍摄的场景/视角组合A001动作类别标签这个命名规则看似简单但做数据划分、批量处理的时候非常有用。比如你想按cross-subject划分直接解析文件名里的S前缀就行了想可视化某个类别的代表性样本用A前缀过滤就可以。真正核心的是.mat文件里的data字段。我用Keras、PyTorch训练模型时常看到有人对这个维度搞不清楚所以这里多花点篇幅讲透。官方骨架.mat文件里保存着一个名为data的数组维度是(25, 3, N, M)25关节点数量3每个关节点的x、y、z坐标N这个样本的总帧数M同时出现在画面中的人数NTU 120最多2人没有人时对应位置是0打开一个文件后data.shape就能直接告诉我们这个样本有多少帧。比如一个做了差不多2秒钟的动作Kinect是30fps采集那么N大约在60左右。官方还提供了numFrames字段对应实际有效帧数不过data维度里的N本身就是实际帧数两者一般是对齐的。2.2 用Python读取.mat文件官方骨架文件是MATLAB的.mat格式但不需要装MATLAB用Python的scipy库直接就能读。代码如下import numpy as np import scipy.io as sio skeleton_path S001C001P001R001A001_sk_skeleton.mat mat sio.loadmat(skeleton_path) data mat[data] # shape: (25, 3, N, M) num_frames mat[numFrames] print(data.shape , data.shape) print(numFrames , num_frames)这里注意如果你的环境里装的是比较老的scipy版本loadmat可能会对.mat文件里的MATLAB结构体解析不完整。遇到这种情况先升级scipy到最新版别一上来就怀疑文件损坏。实测中绝大多数读取问题都是scipy版本太老导致的。读完data之后最常用的提取方式是# 取第0个人通常第一个是主要人物 joints_sequence data[:, :, :, 0].astype(np.float32) # joints_sequence 的每一帧长这样 frame_t joints_sequence[:, :, t] # shape: (25, 3)每帧的25行分别对应25个关节点每行的3个值分别是x、y、z坐标。这里的坐标单位是毫米坐标原点在Kinect传感器。如果要喂给深度学习模型大多数情况下需要再做中心化、归一化但那是后面的事。可视化阶段直接用原始坐标就行。3. 动手前的环境准备依赖安装与目录规划3.1 最小依赖集合画骨架这件事依赖其实非常少不需要引入任何重型深度学习框架。最基本的四个就够了numpy负责数组操作scipy读取.mat文件matplotlib绘制骨架和动画opencv-python把绘制好的帧合成mp4视频如果只是先画单张静态图前三个就够了。opencv是等你想把序列导出成视频时再用。安装命令pip install numpy scipy matplotlib opencv-python我建议你在一个干净的conda环境里装别直接怼到系统Python里。因为matplotlib、opencv这类包对底层库版本还算敏感装到别的项目里容易互相污染。实际经验是用conda创建一个专门做数据可视化的环境后面装什么都不会影响到训练实验的环境。3.2 数据目录结构NTU RGBD 120的骨架文件下载下来之后建议目录结构保持简单datasets/ └── nturgbd_skeletons/ ├── S001C001P001R001A001_sk_skeleton.mat ├── S001C001P002R001A001_sk_skeleton.mat └── ...后面所有代码都基于这个目录结构。代码里用os.path.join去拼接路径不要硬编码绝对路径尤其是换电脑跑的时候硬编码路径会浪费很多时间。4. 单帧骨架描绘从坐标点到人体轮廓4.1 25个关节点和连接关系定义骨架可视化最核心的不是怎么画散点而是怎么把25个点连成一个“人”的形状。如果你只是把点画出来那一堆无序的点根本看不出动作。真正有信息量的是这些点之间的空间关系比如左手腕连着左肘左肘连着左肩这些连接关系就是我们常说的骨架拓扑。NTU的25个关节点可以用下面的顺序来理解编号关节点编号关节点1base_spine14left_knee2mid_spine15left_ankle3neck16left_foot4head17right_hip5left_shoulder18right_knee6left_elbow19right_ankle7left_wrist20right_foot8left_hand21spine_shoulder9right_shoulder22left_hand_tip10right_elbow23left_thumb11right_wrist24right_hand_tip12right_hand25right_thumb13left_hip有了这个表连接关系就非常清晰了。我在这里给出一个在代码里直接可用的edges定义注意这个列表也是后面构建图卷积邻接矩阵的基础。很多人会把这张连接关系表弄错导致画出来的骨架“骨折”所以一定要认真核对。edges [ (1, 2), (2, 21), (21, 3), (3, 4), # 躯干 (21, 5), (5, 6), (6, 7), (7, 8), # 左臂 (7, 22), (7, 23), # 左手两根分支 (21, 9), (9, 10), (10, 11), (11, 12), # 右臂 (11, 24), (11, 25), # 右手两根分支 (1, 13), (13, 14), (14, 15), (15, 16), # 左腿 (1, 17), (17, 18), (18, 19), (19, 20), # 右腿 ] # 使用的时候记得把上面每对索引都减一因为在Python里数组索引从0开始 edges_0based [(u - 1, v - 1) for u, v in edges]4.2 绘制一个完整的人形联结关系定义好之后画图的逻辑就很简单了先画25个关节点再把edges里每一对点用线连起来。下面这段代码会给出一张三维视角的骨架图因为骨架数据本身包含深度信息用三维图看会更全面。import numpy as np import scipy.io as sio import matplotlib.pyplot as plt SAMPLE_FILE S001C001P001R001A001_sk_skeleton.mat def load_skeleton(file_path): mat sio.loadmat(file_path) data mat[data] return data def draw_skeleton_3d(ax, joints_2d): # joints_2d: shape (25, 3) x joints_2d[:, 0] y joints_2d[:, 1] z joints_2d[:, 2] valid ~(np.abs(x) np.abs(y) np.abs(z) 1e-6) ax.scatter(x[valid], y[valid], z[valid], s20, cC0) for u, v in edges_0based: if valid[u] and valid[v]: ax.plot([x[u], x[v]], [y[u], y[v]], [z[u], z[v]], linewidth2, colorC1) ax.set_xlabel(X) ax.set_ylabel(Y) ax.set_zlabel(Z) data load_skeleton(SAMPLE_FILE) frame_idx 30 joints_t data[:, :, frame_idx, 0].astype(np.float32) fig plt.figure(figsize(8, 6)) ax fig.add_subplot(111, projection3d) draw_skeleton_3d(ax, joints_t) plt.title(fFrame {frame_idx}) plt.show()这段代码里我加了一个valid mask作用是把全零坐标的关节点过滤掉。为什么需要这个因为Kinect在采集时如果某个关节点被遮挡或没有被正确识别对应位置的坐标就是0。如果不做过滤原点附近会堆一堆奇怪的点看起来像一团噪声。如果你只想快速看2D投影那就更简单了。直接把z坐标去掉或者换成颜色信息用scatter画x和y坐标即可。2D图在查看“身体是否倾斜”“手臂是否举起”这类空间关系时反而比3D图更直观。5. 让骨架动起来多帧动画与mp4导出5.1 先做好帧间坐标系统一单帧画得再漂亮也只是静态图。动作识别的本质是时序信息所以把整个序列的顺序帧串成动画才是真正让你理解“动作”的关键。在导出动画之前有一个容易被忽略的步骤统一所有帧的坐标显示范围。Kinect采集的3D坐标里人可能会有小幅的左右平移甚至因为身体重心移动导致整体坐标偏移。如果你直接用matplotlib自动调整坐标轴每一帧的显示范围都在变看起来就会觉得人在“跳”视觉上非常难受。更合理的做法是先遍历全部帧找到所有有效坐标的最小值和最大值然后对整个序列用一个固定范围来画图valid_coords [] for t in range(num_frames): frame_t data[:, :, t, 0].astype(np.float32) for j in range(25): if np.any(frame_t[j] ! 0): valid_coords.append(frame_t[j]) valid_coords np.array(valid_coords) xmin, ymin, zmin valid_coords.min(axis0) xmax, ymax, zmax valid_coords.max(axis0)这样一来动画里的人体大小不会因为某几帧手举高、又放下来而产生明显的视觉缩放。做实验展示时这种稳定性很重要。5.2 用FuncAnimation做实时播放matplotlib自带的animation模块可以快速实现骨架视频的播放。比较简单的做法是用matplotlib.animation.FuncAnimation每一帧只更新三条线左臂、右臂和躯干。更新三维图像时不能简单地set_data需要调用set_data再配合set_3d_properties或者干脆每帧清空重画。清空重画在帧数少的时候问题不大但一旦样本很长性能就是个问题。一个折中方案是先创建一个axes对象预先把scatter和每一条连接线都创建好然后在update函数里用set_data和set_3d_properties分别更新坐标。这样能避免频繁重建图形对象。from matplotlib import animation fig plt.figure(figsize(8, 6)) ax fig.add_subplot(111, projection3d) # 预创建对象 scat ax.scatter([], [], [], s20, cC0) lines [] for _ in edges_0based: line, ax.plot([], [], [], linewidth2, colorC1) lines.append(line) def init(): scat._offsets3d ([], [], []) for line in lines: line.set_data([], []) line.set_3d_properties([]) return [scat] lines def update(frame): joints_t data[:, :, frame, 0].astype(np.float32) x joints_t[:, 0] y joints_t[:, 1] z joints_t[:, 2] valid ~(np.abs(x) np.abs(y) np.abs(z) 1e-6) scat._offsets3d (x[valid], y[valid], z[valid]) for idx, (u, v) in enumerate(edges_0based): if valid[u] and valid[v]: lines[idx].set_data([x[u], x[v]], [y[u], y[v]]) lines[idx].set_3d_properties([z[u], z[v]]) else: lines[idx].set_data([], []) lines[idx].set_3d_properties([]) return [scat] lines anim animation.FuncAnimation(fig, update, framesnum_frames, init_funcinit, interval1000 / 30, blitFalse) plt.show()5.3 用OpenCV合成视频摆脱编码器烦恼用FuncAnimation做交互式预览没有问题但如果我想批量处理大批样本或者导出一个可以保存在本地的mp4视频我一般不会依赖matplotlib的VideoWriter因为在不同系统上ffmpeg的安装情况差别很大碰到过一次环境缺ffmpeg导致保存失败比较折腾。更稳妥的做法是用matplotlib一帧帧生成图片不显示窗口然后用opencv的VideoWriter把所有帧合成视频。这样既不需要额外配置ffmpeg代码也容易控制。import cv2 import os os.makedirs(visualize_output, exist_okTrue) # 获取坐标系范围 frame_list [] for t in range(num_frames): joints_t data[:, :, t, 0].astype(np.float32) fig plt.figure(figsize(8, 6)) ax fig.add_subplot(111, projection3d) draw_skeleton_3d(ax, joints_t) ax.set_xlim(xmin, xmax) ax.set_ylim(ymin, ymax) ax.set_zlim(zmin, zmax) ax.view_init(elev15, azim-75) fig.canvas.draw() img np.frombuffer(fig.canvas.tostring_rgb(), dtypenp.uint8) img img.reshape(fig.canvas.get_width_height()[::-1] (3,)) img cv2.cvtColor(img, cv2.COLOR_RGB2BGR) frame_list.append(img) plt.close(fig) # 必须及时关闭否则内存占用会越来越高 # 合成为 mp4 height, width frame_list[0].shape[:2] writer cv2.VideoWriter(skeleton_animation.mp4, cv2.VideoWriter_fourcc(*mp4v), 30, (width, height)) for img in frame_list: writer.write(img) writer.release()这里有一个很容易踩的性能问题如果直接为每一帧新建一个figure并忘了关闭跑几十帧还没事跑几百帧之后内存会越来越大最后程序直接卡死。所以代码里我加了plt.close(fig)。另外为了减少文件体积可以每两帧采样一次也就是把导出的视频帧率从30fps降到15fps视觉上依然能基本还原动作过程。6. 双人交互样本处理人数维度的细节6.1 第二个人什么时候出现NTU RGBD 120里有很多双人交互动作比如拥抱、握手、指人等。这些样本的data最后一维M不为1。但是要注意并不是所有帧里都有两个人。有些动作开始前只有一个人另一个人是中途才进入场景的有些交互过程中一个人被遮挡骨架数据就可能短暂变成全0。所以处理双人样本时最重要的是逐帧判断该人物当前是否存在判断标准就是“该时刻这一整人的25个坐标是否全为0”。如果是就跳过绘制如果不是就把这个人画出来。def is_actor_present(frame_data, actor_idx): joints_actor frame_data[:, :, actor_idx] return np.any(joints_actor ! 0)用这个函数做过滤比直接用numFrames判断更稳妥。因为有些样本第一帧就只有一个人第二个人虽然在数据维度里占了一个位置但全帧都是0。6.2 两个人同时绘制的配色策略画两个人的骨架时如果颜色相同碰到两个人靠近或者交叉时根本分不清谁是谁。我习惯用蓝色和红色分别表示两个人物并在图右上角加一个不显眼的图例。这样在查看交互动作时能很清楚地区分两个人的手臂运动。这里再说一个观察双人交互的实用技巧在导出视频前先把两个人的骨架中心都计算出来然后绘制一个连接两人中心的细线或者标注两个人的距离变化。这类信息虽然对分类模型没有直接帮助但对理解数据集、校正标注、检查模型输入是否符合预期很有用。7. 实践中踩过的坑坐标轴方向、脏数据与性能7.1 左右手“镜像”问题我第一次画骨架时看着图总觉得不太对劲——人物的左手好像跑到了画面的右边。后来仔细查了官方文档才知道NTU的骨架坐标是以Kinect传感器为原点的3D坐标。人体正对Kinect时人的左侧在传感器坐标系里其实是x轴的反方向。也就是说画出来之后如果你希望“左臂出现在画面左边”往往需要再对x轴做一次反转。实现方式很简单绘图前加一行ax.invert_xaxis()或者调整view_init的视角参数。这个问题不是错误纯粹是坐标系定义的差异但如果你不做调整后续给标注或者给别人展示可视化结果时看起来会很别扭。尤其是当你把骨架和RGB视频放在一起对比时镜像问题会让两者看起来“不匹配”。7.2 全零帧、缺失关节与异常坐标NTU骨架数据的质量总体不错但在自遮挡、快速运动、多人靠近的样本里仍然会出现关节丢失和全零帧。可视化时要注意一个关节点的三个坐标全为0视为缺失一帧中所有关节点都为0视为无效帧跳过坐标中存在极端异常值可能比整个序列中位数大10倍以上多半是采集抖动可视化时可以考虑用前后两帧的平均值修复我这里给一个简单的异常值过滤函数def clean_joints(joints_t): # joints_t: (25, 3) zero_mask np.all(joints_t 0, axis1) if zero_mask.sum() 10: return joints_t valid ~zero_mask if valid.sum() 0: median np.median(joints_t[valid], axis0) diff np.abs(joints_t - median).sum(axis1) outlier_mask diff median * 5 joints_t[outlier_mask] median return joints_t这个函数你不用照搬可以根据自己的容错能力调整阈值。重点是建立“数据不完美”的意识很多数据集在采集阶段都有噪声可视化是发现这些问题的最快方式。7.3 批量导出太慢怎么办如果想把整个NTU 120里所有骨架文件都导出成视频11万条样本的数量级肯定不能逐条串行处理。我在实际批量处理时一般先用一个小数据集验证效果然后使用multiprocessing把文件按照ID分给多个进程。一个简单的并行思路from multiprocessing import Pool def export_one(file_path): # 内部实现就是上面写的单样本可视化并保存 ... file_list [os.path.join(skeleton_dir, f) for f in os.listdir(skeleton_dir) if f.endswith(.mat)] with Pool(processes8) as pool: pool.map(export_one, file_list[:200])另外一个非常关键的优化是不要每一帧都用matplotlib保存一张PNG再合成。可以直接在内存里渲染frame然后写入VideoWriter这样会快得多。上面5.3的代码已经按照这个思路写了直接复用即可。8. 从骨架可视化到动作识别怎么用图画验证模型8.1 预处理前后对比骨架可视化不只能用来“看动作”更是一种模型调试工具。很多人在跑NTU 120实验时对输入到模型里的数据做了中心化、尺度归一化、甚至角度归一化但很少关心这些操作到底改变了什么。我建议每个同学都做一次这样的实验把同一个样本分别按原始坐标、中心化坐标、归一化坐标三种方式绘制出来然后逐一对比。你会发现中心化之后人体整体位置会移动到原点附近这对模型泛化到不同位置的人是有帮助的但如果你做的是相对位置特征这点效果其实会被放大或者缩小需要结合模型结构去理解。8.2 错误样本的骨架快照分析训练完一个模型之后最直接的可视化应用就是分析那些被分错的样本。把模型预测的类别和真实类别放在一起然后对错误样本的骨架序列导出视频观察模型到底哪里看错了。举个例子NTU 120里有些动作类别在视觉上高度相似比如“打电话”和“喝水”都把一只手机到头部附近。模型如果在这两类之间产生混淆单纯看坐标误差是看不出原因的但如果你把骨架视频导出来通过逐帧观察手腕到头部的位置关系往往能发现是真值标注本身就模糊还是模型对不同速度的模式不敏感。更进一步你可以计算每个关节点的帧间位移量然后把这个位移量映射成散点的颜色。比如手腕移动快的地方显示为暖色躯干移动慢的地方显示为冷色这样可以直接从单帧图像里看到“这个动作的主要运动部位在哪”。这种可视化习惯会让你的实验过程顺利很多。每次模型出现无法解释的错误先别急着调参数把错误样本的骨架画出来很多时候问题瞬间就清楚了。