ARTICLE DETAIL

资讯详情

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

AMC动作文件解析与Python三维可视化实战:从ASF骨架到Matplotlib动画

AMC动作文件解析与Python三维可视化实战:从ASF骨架到Matplotlib动画 如果你最近在折腾动作数据相关的项目八成绕不开CMU动作捕捉数据集和AMC文件。我第一次拿到这套数据时对着几百兆的文本文件愣了很久——ASF、AMC、骨架、通道这些名词堆在一起想用Python把它可视化又不知道从哪里下手。网上能搜到的教程大多是零碎的要么只讲了怎么下载要么只给了解析代码却没讲清楚坐标变换跑出来的动作全是乱扭。这篇文章我就把从AMC文件到Python可视化的完整流程捋一遍包括ASF骨架怎么解析、AMC动作帧如何变成关节坐标、Matplotlib怎么把骨架画出来并动起来以及我调试时踩过的几个坑。适合想拿公开动作数据做研究、做游戏动画或者做数据可视化的同学阅读。1. 动手之前先搞明白ASF管骨架AMC管动作1.1 这套数据集为什么被这么多人用CMU动作捕捉数据集是卡内基梅隆大学图形学实验室发布的公共动作捕获数据里面包含大量测试者录制的真实人体动作。走路、跑步、跳跃、跳舞、日常交互甚至一些复杂的体育动作都能在对应编号的目录里找到。因为动作类型全、骨骼定义规范、文件是纯文本格式它成了动画研究、姿态估计、机器人运动规划、游戏行业里最常见的素材库之一。很多新手一开始把它和BVH格式混在一起。BVH是把骨架和动作放在同一个文件里CMU这套则把它拆成了两个文件ASF是骨架文件描述角色有哪些关节、关节之间怎么连接、每个关节的初始朝向AMC是动作文件按帧记录每个关节的角度变化。这个拆分的最大好处是同一个角色的骨架可以复用多个动作换动作只需要换AMC文件不需要重新加载骨架。1.2 AMC文件的真实长相随便打开一个AMC文件能看到类似这样的内容#!AMC## :UNITS length 1.0 angles degree :begin 1 root 0.32 -0.05 0.00 lowerback -5.12 -2.38 -0.05 upperback 5.27 -0.64 1.36 thorax -1.28 3.16 0.22 ... :end:begin到:end是一帧第一行是帧编号后面的每一行都是“骨骼名 数值”。root后面的数值通常是 tx ty tz rx ry rz六个值前三个是全局平移后三个是欧拉角旋转其它骨骼一般是 rx ry rz 三个值。注意这里的角度单位是度处理时一定记得转弧度。#!ASF## :units length 0.45 mass 1.0 angle deg :bonedata begin id 1 name root direction 0 0 0 length 0 axis 0 0 0 XYZ damping 0 end ...ASF里最关键的内容集中在:bonedata和:hierarchy两个区段。:bonedata定义了每个关节的默认方向向量 direction、骨骼长度 length、局部坐标系轴朝向 axis、以及允许的自由度 dof:hierarchy定义了父子关系告诉解析程序这个骨架是一棵怎样的树。这里有个容易忽略的点CMU的骨架并不是严格的人体解剖学结构而是为了动捕方便做了简化。常见的关节是 root、lowerback、upperback、thorax、lhipjoint、lfemur、ltibia、lfoot、rhipjoint、rfemur、rtibia、rfoot、lclavicle、lhumerus、lradius、lhand 这些名字。每个名字在ASF里对应id、name、direction、length等字段解析时要把它们组装成一个可计算的关节对象。2. 环境准备装一个能跑动3D可视化的Python环境2.1 依赖安装和镜像源这套流程需要的第三方库很少核心是 NumPy 和 Matplotlib加上标准库里的文件操作就足够了。NumPy负责矩阵计算Matplotlib负责画图。Python版本建议3.9以上太低的话Matplotlib动画接口和NumPy的数组方法用起来会有兼容性问题。如果你还没有装Python去官网下载安装包安装时记得勾选“Add Python to PATH”这一步能省掉后面一堆环境变量折腾。装好之后打开命令行执行python --version能正常输出版本号再装依赖pip install numpy matplotlib如果在国内网络环境下下载速度很慢可以临时加一个镜像源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple numpy matplotlib装上之后可以用一段极短代码验证环境import numpy as np import matplotlib.pyplot as plt fig plt.figure() ax fig.add_subplot(111, projection3d) ax.scatter(0, 0, 0) plt.show()弹出一个带坐标轴的3D窗口就说明基础环境没问题。2.2 项目组织方式我建议把工程拆成三层数据层、解析层、可视化层。数据层放从官网下载解压后的 ASF/AMC 文件解析层写两个脚本一个用于解析ASF骨架一个用于解析AMC动作帧可视化层单独写一个脚本负责渲染。这样后面换数据、换动作、换渲染方式都不用动核心代码。用VSCode的话打开工程目录后按 CtrlShiftP 选择 Python 解释器确认使用的是你刚装好的那个Python环境。开发过程中可以先用命令行跑纯解析部分等骨架和动作坐标都打印正确了再跑可视化。很多人一上来就图方便把解析和渲染写在一个文件里出了问题根本分不清是数据错了还是画图错了。这里顺便提一句网上经常搜到的redis可视化客户端、可视化大屏那一类“可视化”本质是管理工具或报表工具和动作捕捉的可视化不是一回事。动作捕捉可视化处理的是三维空间坐标逻辑上更接近3D建模和动画工具不要被搜索词带偏。3. 骨架加载把ASF文本变成一棵能计算的关节树3.1 骨架结构与关节坐标系的定义在动手写代码前先理解ASF里的关键字段是怎么组合出一棵骨架树的。每个关节本质上包含两个信息相对于父关节的位置偏移以及自身局部坐标系的朝向。direction 和 length 合起来决定位置偏移。direction 是一个方向向量length 是骨骼长度两者相乘得到这个关节在父坐标系下的平移量。比如 lfemur 的 direction 是 (0.42, -0.45, -0.80)length 是 3.27那么这个关节相对于父关节的偏移向量就是二者的乘积。axis 字段决定局部坐标系的初始朝向它给出三个欧拉角和一个旋转顺序常见的是 XYZ解析时要根据这个顺序构造旋转矩阵。:hierarchy区段记录了骨架的树结构:hierarchy begin root lowerback lhipjoint rhipjoint lowerback upperback upperback thorax ... thorax lclavicle rclavicle ... :end每一行最左边的名字是父关节后面跟的是它的子关节。解析时把这种关系记录到每个关节对象的 parent 和 children 字段上骨架就算建好了。3.2 ASF解析代码我用一个简单的 Joint 类来承载关节数据import numpy as np class Joint: def __init__(self): self.name self.id -1 self.parent None self.children [] self.direction np.zeros(3) self.length 0.0 self.axis [0.0, 0.0, 0.0] self.dof [] self.offset_matrix np.eye(4) self.global_matrix np.eye(4)解析ASF时按区段处理重点抓:bonedata和:hierarchydef parse_asf(filepath): joints {} order [] section None current_joint None hierarchy_started False with open(filepath, r) as f: lines f.readlines() for line in lines: line line.strip() if not line or line.startswith(#): continue if line.startswith(:): section line[1:].strip() current_joint None hierarchy_started False continue if section bonedata: if line.startswith(begin): current_joint Joint() continue if line.startswith(end): joints[current_joint.name] current_joint order.append(current_joint.name) current_joint None continue if current_joint is None: continue parts line.split() if parts[0] id: current_joint.id int(parts[1]) elif parts[0] name: current_joint.name parts[1] elif parts[0] direction: current_joint.direction np.array( [float(x) for x in parts[1:4]]) elif parts[0] length: current_joint.length float(parts[1]) elif parts[0] axis: current_joint.axis [float(x) for x in parts[1:4]] elif parts[0] dof: current_joint.dof parts[1:] if section hierarchy: if line.startswith(begin): hierarchy_started True continue if line.startswith(end): hierarchy_started False continue if hierarchy_started: parts line.split() if len(parts) 2: parent_name parts[0] for child_name in parts[1:]: joints[child_name].parent joints[parent_name] joints[parent_name].children.append(joints[child_name]) return joints, order解析完ASF之后还要构建每个关节的 offset_matrix。它包含两部分旋转部分由 axis 的三个欧拉角生成平移部分是 direction 乘以 length。def euler_to_matrix(rx, ry, rz, orderXYZ): rx, ry, rz np.radians(rx), np.radians(ry), np.radians(rz) Rx np.array([ [1, 0, 0], [0, np.cos(rx), -np.sin(rx)], [0, np.sin(rx), np.cos(rx)] ]) Ry np.array([ [np.cos(ry), 0, np.sin(ry)], [0, 1, 0], [-np.sin(ry), 0, np.cos(ry)] ]) Rz np.array([ [np.cos(rz), -np.sin(rz), 0], [np.sin(rz), np.cos(rz), 0], [0, 0, 1] ]) if order XYZ: return Rz Ry Rx elif order ZYX: return Rx Ry Rz return Rz Ry Rx def build_offset_matrix(joint): matrix np.eye(4) matrix[:3, :3] euler_to_matrix(*joint.axis, orderXYZ) matrix[:3, 3] joint.direction * joint.length return matrix这里有一个关键约定orderXYZ 时我用的矩阵是 Rz Ry Rx意思是先绕X轴转再绕Y轴转最后绕Z轴转。如果以后你下载的AMC动作怎么调都不对劲优先怀疑这个矩阵乘法顺序需要交换试试。3.3 初始化姿态把骨架树铺到三维空间有了每个关节的 offset_matrix 和父子关系就可以深度优先遍历骨架树计算每个关节的初始全局矩阵def compute_initial_pose(joints, root_name): for joint in joints.values(): joint.offset_matrix build_offset_matrix(joint) joint.global_matrix np.eye(4) stack [joints[root_name]] while stack: joint stack.pop() if joint.parent is not None: joint.global_matrix joint.parent.global_matrix joint.offset_matrix else: joint.global_matrix joint.offset_matrix.copy() stack.extend(joint.children)这一步结束后每个关节的 global_matrix 最后一列就是它在初始姿态下的世界坐标。你可以先打印几个关节的坐标和数据集文档里描述的T字站或直立姿态对比如果root在原点附近、身体其他关节按垂直方向展开说明骨架解析基本正确。4. 最关键的一环AMC动作帧读取与关节坐标解算4.1 逐帧解析AMC数据AMC文件本质上是按帧组织的文本块。解析时遇到:begin开始组帧遇到:end结束当前帧。每一行的第一个字段是骨骼名后面的数值对应该骨骼在当前帧的旋转或平移。需要注意不是每一帧里所有骨骼都会出现。有些AMC文件在局部帧里只记录发生了明显运动的骨骼其它骨骼默认保持上一帧状态或角度为0。为了稳妥解析时对缺失骨骼直接填0或者用上一帧的数据继承。def load_amc(filepath): frames [] frame_data None in_frame False with open(filepath, r) as f: lines f.readlines() for line in lines: line line.strip() if not line or line.startswith(#): continue if line.startswith(:begin): in_frame True frame_data {} continue if line.startswith(:end): in_frame False frames.append(frame_data) continue if in_frame: parts line.split() if len(parts) 1: frame_data[_frame] int(parts[0]) else: name parts[0] values np.array([float(x) for x in parts[1:]]) frame_data[name] values return frames4.2 从欧拉角到旋转矩阵的细节AMC里的角度值是欧拉角不能直接拿去和坐标向量相乘必须先构造旋转矩阵。欧拉角构造矩阵的顺序直接决定了关节向哪个方向转这是动作会不会乱扭的分水岭。前面我已经给出了 euler_to_matrix 函数。在计算每一帧时对root以外的每个骨骼取 AMC 帧数据里的 rx ry rz调用这个函数得到局部旋转矩阵。对root需要同时处理 tx ty tz 平移和 rx ry rz 旋转def compute_frame_global_matrix(joints, frame_data, root_name): for name, joint in joints.items(): if name root_name: continue rot frame_data.get(name, np.zeros(3)) advance np.zeros(3) if len(rot) 3: advance rot[:3] joint.frame_rotation euler_to_matrix( advance[0], advance[1], advance[2], orderXYZ ) root_data frame_data.get(root_name, np.zeros(6)) if len(root_data) 6: root_data np.zeros(6) root_matrix np.eye(4) root_matrix[:3, 3] root_data[:3] root_matrix[:3, :3] euler_to_matrix( root_data[3], root_data[4], root_data[5], orderXYZ ) joints[root_name].global_matrix root_matrix stack [joints[root_name]] while stack: joint stack.pop() if joint.parent is not None: joint.global_matrix ( joint.parent.global_matrix joint.offset_matrix joint.frame_rotation ) stack.extend(joint.children)这里矩阵更新的顺序是先乘父节点的全局矩阵再乘自身的偏移矩阵最后乘当前帧的局部旋转。理解这个顺序很重要偏移矩阵把关节放到父坐标系下的初始位置和朝向局部旋转让骨骼从初始姿态转到当前动作角度父节点全局矩阵把整个结果从父坐标系变换到世界坐标系。如果这个顺序弄反动作会出现整体错位比如手长到腰上、腿穿到身体里。4.3 预计算一整个动作的坐标数组动画循环里逐帧逐关节计算矩阵不是不行但性能很差。更好的做法是在进入可视化之前把所有帧的关节世界坐标一次性算出来存成一个三维数组形状是 (帧数, 关节数, 3)。Matplotlib画图时直接按帧索引取坐标不涉及重复计算。def precompute_positions(joints, order, frames, root_name): joint_names order joint_count len(joint_names) frame_count len(frames) all_positions np.zeros((frame_count, joint_count, 3)) for f in range(frame_count): compute_frame_global_matrix(joints, frames[f], root_name) for j, name in enumerate(joint_names): all_positions[f, j] joints[name].global_matrix[:3, 3] return joint_names, all_positions这一步跑完你就拥有了一个可以直接喂给可视化层的干净数据。再生成骨骼连线时只需要知道每根骨头的父关节和子关节在关节列表里的索引即可。4.4 数据单位与坐标朝向CMU数据集的默认长度单位在ASF文件的:units区段里定义不同批次的数据可能不一样。有些是英寸有些是抽象单位直接画出来后人物尺寸可能偏大或偏小。可视化时如果发现角色比例异常不必慌把整个坐标数组乘以一个缩放系数就好。坐标系朝向也要留意。CMU数据通常是Y轴朝上或者Z轴朝上Matplotlib默认的3D坐标系是Z轴朝上。如果画出来的人是躺着的调整视角或者把坐标做一次轴交换即可。这些都是显示问题不影响数据本身。5. 可视化让CMU骨架在三维空间里动起来5.1 先画一帧静态骨架静态骨架的目的是快速验证解析结果。根据关节父子关系生成一组边然后在三维坐标轴上画线import matplotlib.pyplot as plt def build_edges(joint_names, joints): edges [] for child_name in joint_names: child joints[child_name] if child.parent is not None: parent_name child.parent.name if parent_name in joint_names and child_name in joint_names: edges.append((joint_names.index(parent_name), joint_names.index(child_name))) return edges def draw_static_skeleton(joint_names, joints, all_positions, frame_idx0): edges build_edges(joint_names, joints) positions all_positions[frame_idx] fig plt.figure(figsize(8, 8)) ax fig.add_subplot(111, projection3d) for i, j in edges: ax.plot( [positions[i, 0], positions[j, 0]], [positions[i, 1], positions[j, 1]], [positions[i, 2], positions[j, 2]], colorsteelblue, lw2 ) ax.scatter(positions[:, 0], positions[:, 1], positions[:, 2], cdarkorange, s20) ax.set_xlabel(X) ax.set_ylabel(Y) ax.set_zlabel(Z) plt.show()画出来如果是一个站立的骨架线条人形姿态合理、四肢朝向正确就可以进入动画阶段。如果这一帧就是乱的先回到解析层查问题别去调渲染参数。5.2 动态播放和性能优化动态播放用 Matplotlib 的 FuncAnimation。为了让动画流畅不要在 update 里重新创建线条对象而是预先创建好所有 Line3D 对象每次更新坐标import numpy as np from matplotlib.animation import FuncAnimation def animate_skeleton(joint_names, joints, all_positions, fps120): edges build_edges(joint_names, joints) frame_count all_positions.shape[0] fig plt.figure(figsize(8, 8)) ax fig.add_subplot(111, projection3d) lines [] for i, j in edges: line, ax.plot([], [], [], colorsteelblue, lw2) lines.append(line) scatter ax.scatter([], [], [], cdarkorange, s20) def update(frame_idx): positions all_positions[frame_idx] for line, (i, j) in zip(lines, edges): line.set_data( [positions[i, 0], positions[j, 0]], [positions[i, 1], positions[j, 1]] ) line.set_3d_properties( [positions[i, 2], positions[j, 2]] ) scatter._offsets3d ( positions[:, 0], positions[:, 1], positions[:, 2] ) return lines [scatter] interval max(1, int(1000 / fps)) ani FuncAnimation(fig, update, framesframe_count, intervalinterval, blitTrue) plt.show() return ani这里有几个性能点是我实际跑过之后才体会到的。第一blitTrue 会快不少前提是 update 函数返回所有被更新的图形对象。第二帧率不是越高越好原始数据可能达到120fps但 Matplotlib 的3D渲染能力有限强行高帧率只会让窗口卡顿建议播放时降到30fps左右。第三坐标数组预计算后动画里只剩索引取值这个环节几乎不耗时。如果数据量很大还可以在 precompute_positions 阶段做降采样比如每隔3帧取1帧。这样动画流畅度会明显改善而且动作看起来还是连贯的。6. 从能跑到好用我调试动作数据时踩过的值得说出来的坑6.1 动作乱扭问题八成出在旋转顺序最让人头疼的问题是骨架静态姿态正常一旦加载AMC动作整个身体就像提线木偶被扯断了。这种情况绝大多数是欧拉角的旋转顺序和axis定义不一致导致的。我之前调试一个 subject 的数据腿部动作全程反方向扭曲膝盖弯成了反关节。排查了很久最后把 order 从 XYZ 换成 ZYX问题立刻消失。所以如果你的动作出现局部翻转、整体漂移、关节粘连这几种典型症状不要先去怀疑数据损坏先做旋转顺序的排列组合测试。最快的方法是把 euler_to_matrix 里的矩阵乘法顺序调整一下重新预计算所有帧看静态帧是否恢复正常。还有一个细节ASF的 axis 字段末尾会带一个旋转顺序标记比如 XYZ。解析时最好把这个顺序也存下来构造 offset_matrix 时按它生成旋转矩阵。我上面的代码为了篇幅简化默认都按 XYZ 处理实际项目中请务必读取这个字段。6.2 帧缺失和异常值的处理CMU数据集虽然质量很高但个别AMC文件里会存在帧不完整的情况。解析时可能遇到某一帧缺少某个骨骼的数据如果直接填0那个关节会瞬间跳回初始姿态画面会出现明显的闪断。我处理这个问题的方案是逐帧解析时记录每个骨骼是否出现对缺失骨骼沿用上一帧的数据或者做一个简单的mask标记。如果某个骨骼连续几十帧都没有数据说明它在整个动作里可能确实没有活动填0反而更合理。关键是不能让缺失数据变成全局坐标里的明显跳变。6.3 性能瓶颈和数据内存管理当动作时长比较长、骨骼数量超过30个时如果直接在动画update里反复做矩阵乘法会明显感觉到卡。上面的代码已经通过预计算规避了这个瓶颈但如果动作文件特别大预计算本身也会消耗一些时间和内存。一个实用的优化是用 np.float32 存储坐标数组精度足够内存直接减半。另一个技巧是只保留渲染用得到的关节数据比如只画上半身就只算上半身的坐标。还有一点不要试图在 Matplotlib 里表现太炫酷的3D效果它本身不是实时渲染引擎。真需要高质量渲染把预计算好的坐标数组导出成文件丢给 Blender 或者 Pyrender 做后期才是正道。6.4 骨架树遍历顺序的坑我在递归遍历骨架树时遇到过一个小问题Python默认递归深度有限如果骨架树的深度过大深度优先遍历可能报 RecursionError。我不太建议用递归去写骨架遍历改用显式栈更稳。上面的 compute_initial_pose 和 compute_frame_global_matrix 都用 stack 来实现就是避免这个隐患。另外因为骨架里同时存在多条分支遍历时要保证父节点的 global_matrix 一定在子节点之前计算。先压入栈、再弹出处理子节点只要入栈顺序不乱通常没问题。根据我个人的经验第一次跑通整套流程的人最容易栽的地方不是数学公式而是对旋转顺序和数据缺失的疏忽。我建议你先下载一个样本数较少的动作把静态骨架打印出来确认无误后再逐帧加载AMC这样每一步的问题都能被准确定位。我自己的流程就是从一团乱麻里改成解析层、坐标计算层、可视化层分离的结构之后接BVH、接FBX导出都顺畅了很多。等你这套流程跑通了再往上加滑条选帧、加多角色对比、甚至导出骨骼动画都是水到渠成的事。
返回列表