本文将对 BVH 文件进行讲解,用 Python 代码用递归下降的方式解析。解析完成后,通过前向运动学(Forward Kinematics)方法进行计算,并使用 panda3d
库进行播放。
BVH 文件介绍
BVH 是一种通用的人体特征动画文件格式,基于人体关节(Joint)的树状结构进行存储。
BVH 文件分为 Hierarchy 和 Motion 两部分, Hierarchy部分是描述虚拟角色的树形结构,Motion 部分是记录每一帧虚拟角色运动的姿态。下面是一个标准的 BVH 文件。
HIERARCHY
ROOT RootJoint
{
OFFSET 0.000000 0.000000 0.000000
CHANNELS 6 Xposition Yposition Zposition Xrotation Yrotation Zrotation
JOINT lHip
{
OFFSET 0.100000 -0.051395 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lKnee
{
OFFSET 0.000000 -0.410000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lAnkle
{
OFFSET 0.000000 -0.390000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lToeJoint
{
OFFSET 0.000000 -0.050000 0.130000
CHANNELS 3 Xrotation Yrotation Zrotation
End Site
{
OFFSET 0.010000 0.002000 0.060000
}
}
}
}
}
JOINT pelvis_lowerback
{
OFFSET 0.000000 0.093605 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lowerback_torso
{
OFFSET 0.000000 0.100000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lTorso_Clavicle
{
OFFSET 0.001000 0.157500 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lShoulder
{
OFFSET 0.117647 0.000000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lElbow
{
OFFSET 0.245000 0.000000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT lWrist
{
OFFSET 0.240000 0.000000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
End Site
{
OFFSET 0.116353 -0.002500 0.000000
}
}
}
}
}
JOINT rTorso_Clavicle
{
OFFSET -0.001000 0.157500 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT rShoulder
{
OFFSET -0.117647 0.000000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT rElbow
{
OFFSET -0.245000 0.000000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT rWrist
{
OFFSET -0.240000 0.000000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
End Site
{
OFFSET -0.116353 -0.002500 0.000000
}
}
}
}
}
JOINT torso_head
{
OFFSET 0.000000 0.282350 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
End Site
{
OFFSET 0.000000 0.192650 0.000000
}
}
}
}
JOINT rHip
{
OFFSET -0.100000 -0.051395 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT rKnee
{
OFFSET 0.000000 -0.410000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT rAnkle
{
OFFSET 0.000000 -0.390000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT rToeJoint
{
OFFSET 0.000000 -0.050000 0.130000
CHANNELS 3 Xrotation Yrotation Zrotation
End Site
{
OFFSET -0.010000 0.002000 0.060000
}
}
}
}
}
}
MOTION
Frames: 2
Frame Time: 0.016667
-0.001735 0.855388 0.315499 2.008551 7.606260 -0.798294 11.216058 -3.286777 -1.592436 13.521250 -1.153514 -4.213484 -17.754157 -3.216621 9.232892 -7.948705 0.211932 -1.528529 2.220789 -0.981058 -1.133630 2.071938 -6.311876 2.083844 2.020309 -0.533885 -19.342332 -5.129554 -37.575293 -50.190804 0.198025 -24.741038 4.442069 0.442380 2.547494 4.858004 1.951773 -5.809334 21.100535 23.710456 30.003467 53.240376 0.414981 10.414544 1.952633 3.576914 -9.482057 6.918939 1.457480 -0.035296 0.111891 -27.722826 -1.655032 2.430426 -2.964232 -5.507982 1.444119 2.239212 -3.180259 -0.892285 -0.008100 -0.007000 0.024400
-0.003810 0.853981 0.337002 2.017405 7.825929 -1.809751 11.713970 -2.355625 0.062023 15.198954 -1.861308 -4.389417 -17.189762 -3.614663 9.244711 -9.397213 0.262158 -1.565413 2.647300 -1.021514 0.131973 1.458470 -6.632789 1.868957 1.928817 -0.148344 -19.543616 -3.937845 -37.139413 -49.957499 0.204371 -24.672734 4.317351 0.916151 2.440320 4.849158 2.068848 -5.518149 21.184327 23.795785 30.805519 52.865110 0.417817 10.118764 1.952408 3.104611 -9.774695 7.021717 1.448893 -0.004226 0.069402 -27.594885 -2.728812 3.499348 -1.670271 -5.527619 1.835016 6.676684 -3.330738 -4.015991 -0.008600 -0.007000 0.026400
Hierarchy 部分
Hierarchy 描述了骨骼的树形结构,比如 rKnee
是一个关节(Joint):
JOINT rKnee
{
OFFSET 0.000000 -0.410000 0.000000
CHANNELS 3 Xrotation Yrotation Zrotation
JOINT rAnkle
{
...
}
}
- OFFSET - 当前结点相对于父结点的相对位置;
- CHANNELS - 表示欧拉角的旋转顺序;
- JOINT - 表示子节点,可能有多个。
而 RootJoint
是一个根节点(Root):
ROOT RootJoint
{
OFFSET 0.000000 0.000000 0.000000
CHANNELS 6 Xposition Yposition Zposition Xrotation Yrotation Zrotation
JOINT lHip
{
...
}
}
ROOT 与 JOINT 的不同之处在于 CHANNELS 属性有6个维度,前三维是该骨骼对应的 X, Y, Z 三个轴的顺序。一般来说,根节点的 OFFSET 为 (0, 0, 0) 。
END Site
是骨骼的末端,即骨骼树的叶子结点。
End Site
{
OFFSET -0.010000 0.002000 0.060000
}
显然,只需要 OFFSET 即可表示。
Motion 部分
Motion 部分有以下信息:
-
Frames 表示接下来动画中帧的数量;
-
Frame Time 表示帧率,即每帧持续时间;
-
接下来每一行代表一帧中的运动数据。这些数据, 是按照前面 CHANNEL 定义顺序出现的. 按照上面 BVH 结构的定义, 首先是根关节的平移量:Xposition, Yposition, Zposition, 接下来是根关节的旋转量:Xrotation, Yrotation, Zrotation ,然后是各个关节的旋转量。
Frames: 2
Frame Time: 0.016667
-0.001735 0.855388 0.315499 2.008551 7.606260 -0.798294 11.216058 -3.286777 -1.592436 13.521250 -1.153514 -4.213484 -17.754157 -3.216621 9.232892 -7.948705 0.211932 -1.528529 2.220789 -0.981058 -1.133630 2.071938 -6.311876 2.083844 2.020309 -0.533885 -19.342332 -5.129554 -37.575293 -50.190804 0.198025 -24.741038 4.442069 0.442380 2.547494 4.858004 1.951773 -5.809334 21.100535 23.710456 30.003467 53.240376 0.414981 10.414544 1.952633 3.576914 -9.482057 6.918939 1.457480 -0.035296 0.111891 -27.722826 -1.655032 2.430426 -2.964232 -5.507982 1.444119 2.239212 -3.180259 -0.892285 -0.008100 -0.007000 0.024400
...
总而言之,每个 CHANNEL 按照顺序对应 Motion 中的每个数据。
BVH 文件解析
根据上述说明,对于 Hierarchy 部分,我们建立的骨骼树应该包括以下三种结点:
class root(object):
def __init__(self, parent, name, offset, channel):
self.parent = parent
self.name = name
self.offset = offset
self.channel = channel # 6
self.children = []
class joint(object):
def __init__(self, parent, name, offset, channel):
self.parent = parent
self.name = name
self.offset = offset
self.channel = channel # 3
self.children = []
class end(object):
def __init__(self, parent, name, offset, channel):
self.parent = parent
self.name = name
self.offset = offset
但是实际上用类的方式来储存和访问各个结点情况过于冗余了:因为骨骼树结构很简单,而且自上而下可以给每个关节都赋予一个编号,用数组记录每个关节对应的 name, parent, offset 以及 channel 的情况即可表达所有的骨骼树信息。
为了解析 Hierarchy 部分,我们先定义一个 hierarchy_parser
类并预处理得到 HIERARCHY 部分:
class hierarchy_parser(object):
def __init__(self, bvh_file_path):
self.lines = get_hierarchy_lines(bvh_file_path)
self.line_number = 0
self.root_position_channel = []
self.joint_rotation_channels = []
self.joint_names = []
self.joint_parents = []
self.joint_offsets = []
def get_hierarchy_lines(bvh_file_path):
hierarchy_lines = []
for line in open(bvh_file_path, 'r'):
line = line.strip()
if line.startwith('MOTION'):
break
else:
hierarchy_lines.append(line)
return hierarchy_lines
然后用递归下降的思想,分别编写三种类的解析函数:
class hierarchy_parser(object):
# ...
def parse_offset(self, line):
return [float(x) for x in line.split()[1:]]
def parse_channels(self, line):
return [x for x in line.split()[2:]]
def parse_root(self, parent=-1):
self.joint_parents.append(parent)
self.joint_names.append(self.lines[self.line_number].split()[1])
self.line_number += 2
if self.lines[self.line_number].startswith('OFFSET'):
self.joint_offsets.append(self.parse_offset(self.lines[self.line_number]))
else:
print('cannot find root offset')
self.line_number += 1
if self.lines[self.line_number].startswith('CHANNELS'):
channels = self.parse_channels(self.lines[self.line_number])
if self.lines[self.line_number].split()[1] == '3':
self.joint_rotation_channels.append((channels[0], channels[1], channels[2]))
elif self.lines[self.line_number].split()[1] == '6':
self.root_position_channels.append((channels[0], channels[1], channels[2]))
self.joint_rotation_channels.append((channels[3], channels[4], channels[5]))
else:
print('cannot find root channels')
self.line_number += 1
while self.lines[self.line_number].startswith('JOINT'):
self.parse_joint(0)
self.line_number += 1
def parse_joint(self, parent):
self.joint_parents.append(parent)
index = len(self.joint_names)
self.joint_names.append(self.lines[self.line_number].split()[1])
self.line_number += 2
if self.lines[self.line_number].startswith('OFFSET'):
self.joint_offsets.append(self.parse_offset(self.lines[self.line_number]))
else:
print('cannot find joint offset')
self.line_number += 1
if self.lines[self.line_number].startswith('CHANNELS'):
channels = self.parse_channels(self.lines[self.line_number])
if self.lines[self.line_number].split()[1] == '3':
self.joint_rotation_channels.append((channels[0], channels[1], channels[2]))
else:
print('cannot find joint channels')
self.line_number += 1
while self.lines[self.line_number].startswith('JOINT') or \
self.lines[self.line_number].startswith('End'):
if self.lines[self.line_number].startswith('JOINT'):
self.parse_joint(index)
elif self.lines[self.line_number].startswith('End'):
self.parse_end(index)
self.line_number += 1
def parse_end(self, parent):
self.joint_parents.append(parent)
self.joint_names.append(self.joint_names[parent] + '_end')
self.line_number += 2
if self.lines[self.line_number].startswith('OFFSET'):
self.joint_offsets.append(self.parse_offset(self.lines[self.line_number]))
else:
print('cannot find joint offset')
self.line_number += 2
最后提供一个解析的入口:
class hierarchy_parser(object):
# ...
def analyze(self):
if not self.lines[self.line_number].startswith('HIERARCHY'):
print('cannot find hierarchy')
self.line_number += 1
if self.lines[self.line_number].startswith('ROOT'):
self.parse_root()
return self.joint_names, self.joint_parents, self.joint_offsets
前向运动学(Forward Kinematics)
对于骨骼树,要想确定每个关节在每一帧的位置,应该从 Root 结点开始,向下遍历计算每个关节的旋转,从而得到每个关节的位置。
因此,我们可以通过对树进行解析的方式得到每一帧下每个关节的全局旋转和全局坐标。由于我的 BVH 文件所有 CHANNEL 的顺序都是 (X, Y, Z) ,因此并未处理其它情况,如果有特殊情况需要注意。
import numpy as np
from scipy.spatial.transform import Rotation as R
def forward_kinematics(joint_name, joint_parent, joint_offset, motion_data, frame_id):
m = len(joint_name)
joint_positions = np.zeros((m, 3), dtype=np.float64)
joint_orientations = np.zeros((m, 4), dtype=np.float64)
channels = motion_data[frame_id]
rotations = np.zeros((m, 3), dtype=np.float64)
cnt = 1
for i in range(m):
if '_end' not in joint_name[i]:
for j in range(3):
rotations[i][j] = channels[cnt * 3 + j]
cnt += 1
for i in range(m):
parent = joint_parent[i]
if parent == -1:
for j in range(3):
joint_positions[0][j] = channels[j]
joint_orientations[0] = R.from_euler('XYZ', [rotations[0][0], \
rotations[0][1], rotations[0][2]], degrees=True).as_quat()
else:
if '_end' in joint_name[i]:
joint_orientations[i] = np.array([0, 0, 0, 1])
joint_positions[i] = joint_positions[parent] + \
R.from_quat(joint_orientations[parent]).as_matrix() @ joint_offset[i]
else:
rotation = R.from_euler('XYZ', [rotations[i][0], \
rotations[i][1], rotations[i][2]], degrees=True)
joint_orientations[i] = (R.from_quat(joint_orientations[parent]) * rotation).as_quat()
joint_positions[i] = joint_positions[parent] + \
R.from_quat(joint_orientations[parent]).as_matrix() @ joint_offset[i]
return joint_positions, joint_orientations
计算中涉及一些四元数(quaternion)的相关知识,可以通过 四元数和旋转 稍作了解。
动画播放
这部分内容参考了 GAMES105 课程。首先安装依赖库,pip install panda3d
。
然后直接将仓库中的 viewer.py
、GroundScene.egg
、character_model.py
和 walk60.bvh
和文件放在同一文件夹下,调用运行即可。
完整的代码如下:
from viewer import SimpleViewer
import numpy as np
from scipy.spatial.transform import Rotation as R
class HierarchyParser(object):
def __init__(self, bvh_file_path):
self.lines = self.get_hierarchy_lines(bvh_file_path)
self.line_number = 0
self.root_position_channels = []
self.joint_rotation_channels = []
self.joint_names = []
self.joint_parents = []
self.joint_offsets = []
def get_hierarchy_lines(self, bvh_file_path):
hierarchy_lines = []
for line in open(bvh_file_path, 'r'):
line = line.strip()
if line.startswith('MOTION'):
break
else:
hierarchy_lines.append(line)
return hierarchy_lines
def parse_offset(self, line):
return [float(x) for x in line.split()[1:]]
def parse_channels(self, line):
return [x for x in line.split()[2:]]
def parse_root(self, parent=-1):
self.joint_parents.append(parent)
self.joint_names.append(self.lines[self.line_number].split()[1])
self.line_number += 2
if self.lines[self.line_number].startswith('OFFSET'):
self.joint_offsets.append(self.parse_offset(self.lines[self.line_number]))
else:
print('cannot find root offset')
self.line_number += 1
if self.lines[self.line_number].startswith('CHANNELS'):
channels = self.parse_channels(self.lines[self.line_number])
if self.lines[self.line_number].split()[1] == '3':
self.joint_rotation_channels.append((channels[0], channels[1], channels[2]))
elif self.lines[self.line_number].split()[1] == '6':
self.root_position_channels.append((channels[0], channels[1], channels[2]))
self.joint_rotation_channels.append((channels[3], channels[4], channels[5]))
else:
print('cannot find root channels')
self.line_number += 1
while self.lines[self.line_number].startswith('JOINT'):
self.parse_joint(0)
self.line_number += 1
def parse_joint(self, parent):
self.joint_parents.append(parent)
index = len(self.joint_names)
self.joint_names.append(self.lines[self.line_number].split()[1])
self.line_number += 2
if self.lines[self.line_number].startswith('OFFSET'):
self.joint_offsets.append(self.parse_offset(self.lines[self.line_number]))
else:
print('cannot find joint offset')
self.line_number += 1
if self.lines[self.line_number].startswith('CHANNELS'):
channels = self.parse_channels(self.lines[self.line_number])
if self.lines[self.line_number].split()[1] == '3':
self.joint_rotation_channels.append((channels[0], channels[1], channels[2]))
else:
print('cannot find joint channels')
self.line_number += 1
while self.lines[self.line_number].startswith('JOINT') or \
self.lines[self.line_number].startswith('End'):
if self.lines[self.line_number].startswith('JOINT'):
self.parse_joint(index)
elif self.lines[self.line_number].startswith('End'):
self.parse_end(index)
self.line_number += 1
def parse_end(self, parent):
self.joint_parents.append(parent)
self.joint_names.append(self.joint_names[parent] + '_end')
self.line_number += 2
if self.lines[self.line_number].startswith('OFFSET'):
self.joint_offsets.append(self.parse_offset(self.lines[self.line_number]))
else:
print('cannot find joint offset')
self.line_number += 2
def analyze(self):
if not self.lines[self.line_number].startswith('HIERARCHY'):
print('cannot find hierarchy')
self.line_number += 1
if self.lines[self.line_number].startswith('ROOT'):
self.parse_root()
return self.joint_names, self.joint_parents, self.joint_offsets
def forward_kinematics(joint_name, joint_parent, joint_offset, motion_data, frame_id):
m = len(joint_name)
joint_positions = np.zeros((m, 3), dtype=np.float64)
joint_orientations = np.zeros((m, 4), dtype=np.float64)
channels = motion_data[frame_id]
rotations = np.zeros((m, 3), dtype=np.float64)
cnt = 1
for i in range(m):
if '_end' not in joint_name[i]:
for j in range(3):
rotations[i][j] = channels[cnt * 3 + j]
cnt += 1
for i in range(m):
parent = joint_parent[i]
if parent == -1:
for j in range(3):
joint_positions[0][j] = channels[j]
joint_orientations[0] = R.from_euler('XYZ', [rotations[0][0], rotations[0][1], rotations[0][2]], degrees=True).as_quat()
else:
if '_end' in joint_name[i]:
joint_orientations[i] = np.array([0, 0, 0, 1])
joint_positions[i] = joint_positions[parent] + R.from_quat(joint_orientations[parent]).as_matrix() @ joint_offset[i]
else:
rotation = R.from_euler('XYZ', [rotations[i][0], rotations[i][1], rotations[i][2]], degrees=True)
joint_orientations[i] = (R.from_quat(joint_orientations[parent]) * rotation).as_quat()
joint_positions[i] = joint_positions[parent] + R.from_quat(joint_orientations[parent]).as_matrix() @ joint_offset[i]
return joint_positions, joint_orientations
def load_motion_data(bvh_file_path):
with open(bvh_file_path, 'r') as f:
lines = f.readlines()
for i in range(len(lines)):
if lines[i].startswith('Frame Time'):
break
motion_data = []
for line in lines[i+1:]:
data = [float(x) for x in line.split()]
if len(data) == 0:
break
motion_data.append(np.array(data).reshape(1,-1))
motion_data = np.concatenate(motion_data, axis=0)
return motion_data
def animation(viewer, joint_names, joint_parents, joint_offsets, motion_data):
frame_num = motion_data.shape[0]
class UpdateHandle:
def __init__(self):
self.current_frame = 0
def update_func(self, viewer_):
joint_positions, joint_orientations = forward_kinematics(joint_names, \
joint_parents, joint_offsets, motion_data, self.current_frame)
viewer.show_pose(joint_names, joint_positions, joint_orientations)
self.current_frame = (self.current_frame + 1) % frame_num
handle = UpdateHandle()
viewer.update_func = handle.update_func
viewer.run()
def main():
bvh_file_path = 'walk60.bvh'
viewer = SimpleViewer()
parser = HierarchyParser(bvh_file_path)
joint_names, joint_parents, joint_offsets = parser.analyze()
motion_data = load_motion_data(bvh_file_path)
animation(viewer, joint_names, joint_parents, joint_offsets, motion_data)
if __name__ == "__main__":
main()