ARTICLE DETAIL

资讯详情

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

NTU RGB+D 120骨架数据处理全攻略:Python解析、3D可视化与预处理

NTU RGB+D 120骨架数据处理全攻略:Python解析、3D可视化与预处理 做动作识别研究尤其是基于3D骨架的动作识别NTU RGBD 120是一个绕不开的基准数据集。它提供了RGB、深度、3D骨架和红外四种模态其中25个关节点组成的3D骨架序列是我个人最常用也最推荐优先尝试的一路数据。但拿到手只是第一步一堆以_skel.txt结尾的骨架文件用Python怎么读、怎么画、怎么把坐标变成能看的姿态动画这一步其实拦住了不少刚入门的朋友。这篇就专门讲我处理这套数据集的完整流程从文件格式解析到3D骨架可视化再到训练集划分和后续预处理全部是直接用Python可复现的方案。不管你是刚接触动作识别的新手还是已经在训练ST-GCN、CTR-GCN这类模型的老手只要需要手工解析或可视化NTU骨架数据这篇都能帮你少踩几个坑。1. 先搞清楚数据集本身才知道骨架文件为什么难处理1.1 从NTU RGBD到NTU RGBD 120数据家族的来龙去脉NTU RGBD最早发布于2016年由新加坡南洋理工大学的Rose Lab制作最开始的版本包含60个动作类别、约56880个样本。后来团队又扩展了一批动作把类别数增加到120个样本量翻倍到114480个这就是我们今天说的NTU RGBD 120。采集设备是三个Microsoft Kinect v2相机摆在不同高度的水平角度同时记录下四种模态的数据RGB视频常规彩色画面每帧分辨率1920x1080Depth序列深度图每帧分辨率512x424记录到相机的距离3D Skeleton每帧最多两个人体每个人体25个关节点的三维坐标IR序列红外图像用于低光环境下的辅助定位。参与采集的被试者共106人年龄段从10岁到57岁包含不同身材、不同国籍、不同穿着动作则分三大类日常动作喝水、鼓掌等、医疗护理相关动作摔倒、呼吸困难等、双人交互动作拥抱、握手等。从NTU 60扩展到NTU 120核心动机在于原来的60类动作虽然经典但很多类别在不同视角下区分度不明显而且被试数量、动作覆盖范围都不够大。120类版本里增加了更多细粒度动作比如“用手指比数字”“用笔写名字”这类需要精细运动捕捉的类别对模型的泛化能力要求更高。这也是为什么现在论文里做骨架动作识别主流benchmark基本都是NTU RGBD 120的cross-subjectX-Sub和cross-setupX-Setup两个协议。1.2 骨架数据在动作识别里的独特地位四种模态里3D骨架数据是一种“去外观化”的表征。它不包含纹理、颜色、背景信息只保留人体的关节点三维坐标。这么做有几个直接好处第一数据量小。一段几秒钟的动作RGB视频可能需要几百MB而骨架文件往往只有几十KB到几百KB。做算法原型验证时骨架数据的加载和训练速度快一个量级。第二对背景和外观不敏感。骨架坐标天然去除了衣服颜色、光照变化、背景干扰等因素模型学到的是动作本身的结构模式而不是场景伪影。这一点在实际部署时常被低估——很多视觉模型在换场景后性能崩盘骨架方案反而稳得多。第三隐私友好。散点图级别的人体姿态很难还原出可辨识的人脸或环境信息这让骨架数据在敏感场景里的合规成本更低。所以即使现在有各种多模态融合方案我仍然建议新同学先把骨架这一路吃透。它不仅是理解时空图网络ST-GCN这类模型的正宗输入也是快速跑通整个数据pipeline的最短路径。2. 骨架文件到底长什么样从文件名到文件体2.1 文件命名规则一行行拆开看在NTU RGBD 120官网下载后数据按Sample组织每个Sample的文件名格式长这样s001c001p001r001a001_skel.txt下划线前面是样本编号后面是模态类型。_skel.txt自然就是骨架数据。编号字段的含义如下字段示例含义s001subject被试者编号范围1~106c001setup采集设置编号范围1~32不是简单相机视角p001performer同一被试者重复拍摄次数编号范围1~10r001replication重复采集次数编号范围1~2a001action动作类别编号范围1~120这里有个特别容易踩的坑很多从NTU 60时代过来的人习惯把c理解成camera视角。NTU 60里的确是视角但到了NTU RGBD 120c字段被重新定义为setup也就是采集时的相机配置组合编号。cross-setup协议就是按c字段来划分训练和测试集合的如果还按视角理解划分就会出错。把文件名拆成结构化字段是后面做数据划分和批量处理的第一步。直接用字符串切片最省事比如def parse_ntu_filename(filepath): filename filepath.split(/)[-1] sample_id filename.split(.)[0] # 形如 s001c001p001r001a001 s int(sample_id[1:4]) c int(sample_id[5:8]) p int(sample_id[9:12]) r int(sample_id[13:16]) a int(sample_id[17:20]) return {subject: s, setup: c, performer: p, replication: r, action: a}2.2 skel文件内部结构逐行拆解骨架文件不是固定行数的CSV开头几行是元数据之后才是坐标数据。很多人在这一步就栽了原因在于文件头并不是简单的“前五行固定格式”。一个标准的_skel.txt结构是第1行帧数N第2行每帧的主体body数M通常为1或2第3行主体ID列表第4行每具身体的关节数通常是25第5行坐标维度数通常是3X, Y, Z某些版本可能是4多一个confidence置信度。第6行开始才是数据区顺序按“帧-身体-关节”循环排列。也就是说第1帧的第1个身体的所有25个关节坐标排在最前然后是该帧第2个身体的所有关节依次类推。每个关节一行每行是3个float单位是毫米。我用一个最小可用的解析函数来演示这个函数在我的实际项目里跑过很多次def read_ntu_skel(filepath): with open(filepath, r) as f: lines [line.strip() for line in f.readlines()] frame_num int(lines[0]) body_num int(lines[1]) body_ids lines[2].strip().split() joint_num int(lines[3]) dim_num int(lines[4]) # 如果存在维度为4说明带了置信度只用前3维即可 data_start 5 per_frame body_num * joint_num frames [] for fi in range(frame_num): bodies [] for bi in range(body_num): joints [] for ji in range(joint_num): idx data_start fi * per_frame bi * joint_num ji values list(map(float, lines[idx].split())) joints.append(values[:3]) # (x, y, z) bodies.append(joints) frames.append(bodies) return frames, { frame_num: frame_num, body_num: body_num, body_ids: body_ids, joint_num: joint_num, dim_num: dim_num }这里用了两层索引嵌套先按帧再按帧内的身体最后按关节。如果文件中出现了joint_num不是25的情况那就说明这个文件可能是NTU 60早期的20关节点版本需要特殊处理。实际下载的NTU 120标准包基本都是25个关节点。2.3 25个关节点和骨骼连接关系读出来的25个关节点每个编号都对应固定的身体位置。理解这个映射画图时才知道哪些点应该连起来。NTU数据集的关节点编号定义如下编号身体位置编号身体位置1脊柱底部骨盆14左膝2脊柱中部15左踝3颈部16左脚4头部17右髋5左肩18右膝6左肘19右踝7左腕20右脚8左手21脊柱上端胸椎9右肩22左手尖10右肘23左手拇指11右腕24右手尖12右手25右手拇指13左髋有了语义映射骨骼连接关系就顺理成章了。常用的一组连接边定义如下画线时按这个列表把关节对连起来NTU_BONES [ (1, 2), (2, 21), (21, 3), (3, 4), (3, 5), (5, 6), (6, 7), (7, 8), (8, 22), (8, 23), (3, 9), (9, 10), (10, 11), (11, 12), (12, 24), (12, 25), (1, 13), (13, 14), (14, 15), (15, 16), (1, 17), (17, 18), (18, 19), (19, 20), ]关节点1、2、21、3连起来就是躯干主干左右手、左右脚各自延展下去这套连接定义能直接画出完整的人体骨架。注意代码里我用的是1-based编号取坐标时索引要减1别把“编号1”当成数组的第一个元素取错了。3. 用Python画出3D骨架从静态单帧到动态视频3.1 环境准备其实不需要重型依赖画NTU骨架本质是读坐标、画三维点、连三维线。用到的库就三个numpy、matplotlib以及可选但强烈推荐的imageio或pillow用来输出gif。不需要opencv也不需要深度学习框架。安装也很简单pip install numpy matplotlib pillowPython版本只要3.8以上都没问题。网上有些人说必须装官方提供的ntu120工具箱我觉得不是必须的。官方工具箱确实能处理文件解析和划分但代码风格偏学术prototype用起来不够Pythonic。自己写一个轻量解析器反而能更清楚地掌握数据格式后面接模型也更灵活。如果你非要别人写好的轮子可以看官方仓库ntu120-dataset-toolbox里面既有Python版本也有Matlab版本但直接用它的函数时要注意路径参数格式和自写代码的输入习惯不太一样。3.2 绘制单帧骨架核心代码与几个关键细节先从一个最简单的目标开始把第30帧的骨架画出来。代码如下import numpy as np import matplotlib.pyplot as plt def draw_skeleton_on_ax(joints, ax, colorb, line_colorr): # joints: 25x3 numpy array ax.scatter(joints[:, 0], joints[:, 1], joints[:, 2], ccolor, s30, depthshadeFalse) for (start, end) in NTU_BONES: pts joints[[start - 1, end - 1]] ax.plot(pts[:, 0], pts[:, 1], pts[:, 2], cline_color, linewidth2) return ax frames, meta read_ntu_skel(s001c001p001r001a001_skel.txt) frame_idx 30 body_idx 0 # 如果双人动作先取第一个人 joints np.array(frames[frame_idx][body_idx]) fig plt.figure(figsize(6, 8)) ax fig.add_subplot(111, projection3d) draw_skeleton_on_ax(joints, ax) # 三件事固定坐标范围、设定视角、反转坐标轴让它看起来像真实人体 max_range np.abs(joints).max() 50 ax.set_xlim(-max_range, max_range) ax.set_ylim(-max_range, max_range) ax.set_zlim(-max_range, max_range) ax.view_init(elev15, azim-60) ax.set_xlabel(X (mm)) ax.set_ylabel(Y (mm)) ax.set_zlabel(Z (mm)) plt.title(fFrame {frame_idx}, Body {body_idx}) plt.show()这里有几个细节值得说明一下。第一个是坐标范围必须用手动固定。如果不设set_xlim/set_ylim/set_zlimmatplotlib会根据散点坐标自动缩放结果就是画面会随着动作的幅度一会儿大一会儿小看动画时会非常难受。固定范围后整个人体始终以相同尺度出现在视野里动作变化才直观。第二个是视角函数view_init的取值。NTU骨架的坐标系里Y轴通常是竖直方向自上而下或自下而上取决于Kinect朝向X轴水平Z轴是深度。默认的3D视角会让你看到一个斜着的“火柴人”高度不对称。我习惯把elev设在10~20度之间azim设在-60~-90度看起来最接近正面观察效果。第三个是毫米单位的直接显示。坐标数值动辄几百上千直接画没问题但如果你想做可视化对比不同样本之间数值范围差异很大最好先做一个全局缩放或归一化后面第5章会说。3.3 逐帧动画让骨架真正动起来静态图画出来离“描绘骨架”这个目标还差一步——把连续帧串成动画。这里我推荐matplotlib.animation.FuncAnimation代码量小输出gif或mp4都方便。from matplotlib.animation import FuncAnimation fig plt.figure(figsize(6, 8)) ax fig.add_subplot(111, projection3d) body_idx 0 def update(frame_idx): ax.clear() joints np.array(frames[frame_idx][body_idx]) draw_skeleton_on_ax(joints, ax) max_range np.abs(joints).max() 50 ax.set_xlim(-max_range, max_range) ax.set_ylim(-max_range, max_range) ax.set_zlim(-max_range, max_range) ax.view_init(elev15, azim-60) ax.set_title(fFrame {frame_idx}) return ax anim FuncAnimation(fig, update, frameslen(frames), interval33) anim.save(ntu_skeleton.gif, writerpillow, fps30)interval33对应约30fps的显示速度和Kinect v2的采集帧率基本一致。实际数据集里有些动作序列只有几十帧有些长达两三百帧用这个代码都能顺畅播放。有一个重要的性能问题如果你直接对一段300帧的序列逐帧渲染matplotlib在for循环里反复执行ax.clear()和重新绘制速度会有点感人。个人经验是两个手段并行一是先做抽帧比如每2帧或每3帧取一帧动作连续性损失不大但渲染量直接减半二是把画布尺寸调小比如figsize(4, 5)输出像素降低后动画生成速度会快很多。如果最终目标是导出给论文里用我建议把figsize加大、dpi设高但要牺牲一些生成速度。如果是调试用怎么快怎么来。4. 实操避坑指南骨架数据可视化里最容易翻车的几个地方4.1 文件头解析错位两块主体数据怎么处理我在2.2节给的读取函数里默认按“每个关节一行”的规则读取。但真实数据里有几个变数。第一个变数是body_num跨文件不稳定。单人动作时M1双人交互动作时M2。如果代码里硬编码了per_frame 25一旦遇到双人文件就会读错位画出来的“骨架”会变成乱七八糟的点云。所以读文件头时一定要把M读出来再计算实际数据行数。第二个变数是双人动作画谁。NTU 120里双人交互动作会同时输出两个人的骨架但每个frame下两个人的数据顺序并不保证一致。我的策略是如果只想看主活动者就固定取body_idx0如果想对比两个人就把两个body都画出来用不同颜色区分。实际操作中还有不少文件会出现某一帧只有一个body的情况这时要做判断if len(frames[frame_idx]) body_idx 1: continue第三个变数是坐标数据里偶尔混入“0 0 0”的异常值。这是Kinect丢失追踪时的典型表现。画图碰到无所谓但训练模型前需要做插值或过滤否则会引入很大噪声。4.2 可视化效果问题坐标轴、残影与抖动画动态骨架时最烦的三件事我一次说清。第一坐标轴范围跳动。前面提到过自动缩放会让动作幅度大时人“变小”幅度小时人“放大”。解决办法就是用全局固定范围或者取整个序列所有帧的联合max值。推荐后者all_joints np.array([np.array(frames[i][0]) for i in range(len(frames)) if len(frames[i]) 0]) global_max np.abs(all_joints).max() 50第二动画残影。ax.clear()之后如果没重新设置projection3d残留对象会叠加在新帧上最后画面全是线条。所以update函数里第一件事就是ax.clear()然后再传新坐标。第三动作看起来像“抽搐”。这一般不是数据问题而是某几帧坐标缺失或跳变。排查方法很简单把每帧相邻关节的欧氏距离打出来如果某帧距离突然暴涨就是数据质量问题。这类帧我在后续处理里通常直接滤掉或插值不会拿来训练。4.3 环境与依赖报错排查速查表这个主题下面也顺手整理一下新手环境级的问题。我见过不少同学卡在安装和导入环节报错本身离业务逻辑很远但很劝退。报错现象常见原因解决建议python was not found; run without arguments to install from the Microsoft StoreWindows下环境变量里没有Python或进入了应用商店别名状态到Microsoft Store安装Python或在“应用执行别名”里关闭那两个Python别名项再装一次PythonModuleNotFoundError: No module named matplotlib当前解释器里没装依赖用pip install matplotlib numpy最好先python -m pip install --upgrade pipImportError: something is missing虚拟环境没激活就装包检查命令行前的(venv)或(conda)标志确保在正确环境里装中文路径或文件名解码失败Windows下路径含中文或文件名编码不一致统一用英文路径代码里在打开文件时指定encodingutf-8核心原则是始终用同一个Python解释器。我个人的习惯是用conda建一个独立环境比如conda create -n ntu python3.9所有NTU相关依赖都装在里面既不影响其他项目也避免全局环境的包冲突。5. 从“看得见”到“用得着”骨架数据的工程化处理5.1 用文件名做训练集划分X-Sub和X-Setup可视化只是确认数据正确性的第一步真正做动作识别时你需要把数据划分成训练集和测试集。NTU RGBD 120的标准评测协议有两个。Cross-SubjectX-Sub按被试者划分。训练集指定一批subject编号测试集用另一批比如常见的划分是训练集用subject 1~53测试集用subject 56~120去掉部分编号具体以官方文件为准。这样测的是模型对“没见过的人”的泛化能力。Cross-SetupX-Setup按采集设置划分。训练集用一半setup编号比如c001~c016测试集用另一半c017~c032。这样测的是模型对“没见过视角/配置”的泛化能力。划分代码的本质就是解析文件名提取subject字段或setup字段然后查表。官方工具箱会提供划分后的train/test文件列表但如果你拿到的包没有现成文件自己用几行Python就能生成def split_by_subject(all_files, train_subjects, test_subjects): train_files, test_files [], [] for fp in all_files: meta parse_ntu_filename(fp) if meta[subject] in train_subjects: train_files.append(fp) elif meta[subject] in test_subjects: test_files.append(fp) return train_files, test_files实际上在MMAction2、PYSKL这类开源框架里NTU数据集的划分已经内置好了。你只需要按照它们的目录格式把数据摆好框架会自动生成.npy标签和划分文件。我建议第一次接触时还是手动跑一遍上面的逻辑对理解整个流程有不可替代的帮助。5.2 骨架数据的预处理要点归一化、补帧与增广把骨架数据喂给模型前有三个绕不开的预处理环节。第一个是坐标归一化。原始坐标以毫米为单位数值范围大且场景相关。常见做法是以人体中心关节比如关节点1脊柱底部为原点做去中心化再除以整个人体的尺寸比如肩膀宽度或身高把骨架变换到相对坐标空间。这样模型学的是动作结构而不是绝对位置。第二个是序列长度对齐。一个样本可能是30帧另一个可能是200帧模型要求batch内长度一致。常用策略是截断或插值到固定长度比如统一到64帧。用numpy的interp做插值非常快def resample_frames(frames, target_len): current_len len(frames) new_indices np.linspace(0, current_len - 1, target_len) # 对每个关节的每个坐标都做线性插值 return np.stack([np.interp(new_indices, np.arange(current_len), frames[:, j, c]) for j in range(25) for c in range(3)], axis-1).reshape(target_len, 25, 3)第三个是数据增强。骨架数据同样可以像图像一样做增强常用的有随机旋转绕竖直轴正负15度、随机缩放、随机平移以及关节点的随机丢失dropout。这些操作的目的是提高模型对视角变化和噪声的鲁棒性。增强时要小心旋转角度不能太大否则会破坏动作的真实语义随机丢失的关节要按骨骼连接成组丢比如丢整只左手而不是随机丢几个散点。预处理做完下一步才是把(T, 25, 3)的序列整理成(N, C, T, V, M)的张量格式其中C是通道数3或更大T是帧数V是关节点数25M是人数2。到了这一步你的数据就能直接喂给ST-GCN、CTR-GCN这类模型了。我自己的经验是在没跑模型之前先把可视化、归一化、插值这套流程完整做一遍后面调参时的效率会高很多。骨架数据最大的优势是轻量透明每一步预处理的影响都能直接画出来看不像RGB视频那样黑盒。所以哪怕你最终要做多模态融合也建议先从骨架这一路入手把数据处理基本功打扎实。不过这套代码我现在还留着每次拿到新的骨架数据集第一件事永远是先画几段动画亲眼确认数据没问题再谈建模和训练。这个习惯帮我排查过不少脏数据也算是给刚入坑的朋友一个过来人的建议。
返回列表