三维人体运动合成系列之一:运动捕捉数据ASF/AMC文件解析与绘制
虚拟人运动合成是我研究生阶段的研究课题,目前我将已完成的工作整理出来,预计写三篇博客介绍,分为:
1.三维人体运动合成系列之一:运动捕捉数据ASF/AMC文件解析与绘制
3.三维人体运动合成系列之三:优化的Dijkstra算法原理及实现
本人目前还在为一篇SCI论文奋斗,选题是基于稀疏样本的三维人体运动合成,那个恐怕要等结果完成之后再整理成文添加到这个系列之中。而这篇要讲的内容如题所述:运动捕捉数据ASF/AMC文件解析与绘制。因为这个步骤是所有后续工作的基础,说白了就是将以文本形式保存的运动数据解析并绘制出来。我将分为几个部分来分别讲述,其中最关键的部分就是所有骨节点全局坐标的计算,全局坐标计算出来之后再用OpenGL或者其他工具绘制出来是很容易的事情。
1.ASF/AMC文件的解析
ASF/AMC是一种运动数据格式,是由Vicon运动捕捉设备进行运动捕捉时记录的,运动捕捉简而言之就是将运动数据化,将生动的、直观的运动变成一个个枯燥乏味的数据,这么做的好处就是可以引入计算机对其进行分析、处理,因为计算机虽然没有人对运动的美学鉴赏能力,但是却很擅长数据的处理和分析。其中ASF文件保存的是骨架数据,而AMC文件保存的则是运动数据,具体而言,它们是长这样的:
以上是各个骨节点数据,包括id,名字,从父节点到该节点的向量方向及长度,绑定于各个骨节点的局部坐标系相对全局坐标系的旋转欧拉角,以及各节点的旋转自由度;除以上信息之外ASF文件的末尾还包括各个骨节点之间的继承关系,这个信息同样很重要,因为后续的计算都有赖于它:
其中每一行第一个骨节点是该行后续骨节点的父亲节点。而AMC文件是长这样的:
我们可以看到AMC文件就是由一阵阵的数据构成的,每一帧的最开头都会有该帧的编号,每一帧具体存储的其实就是各个骨节点的旋转欧拉角,注意这里的旋转角度是和ASF文件中各个骨节点的旋转自由度一一对应的,没有自由度的分量当然也就没有相应的旋转角度。
自由度的概念很好理解,人并不是所有的骨头都有三个旋转自由度的,也就是并不是全部都能围绕三个方向旋转。比如你的头有三个旋转自由度,因为头是能够上下左右前后旋转的,但是你试试你的hand看能不能以手臂为轴围绕其旋转,所以这里head有三个自由度然而hand只有两个,其余道理类似。
为了保留以上信息以及方便后续计算,我一共设计了三个类,分别是:骨骼类(Bone),骨架类(Skeleton),运动类(Motion),很显然这里我们并不需要提取出一个抽象基类出,也无需用到继承和多态等面向对象机制,相反,它们之间是组合的关系。Skeleton由Bone组成,Motion则由一帧帧的Skeleton组成,具体是以成员变量的形式来实现的。代码如下:
//定义骨骼类 class Bone { public: Bone(); //默认构造函数 public: VECTOR direc; //骨骼初始方向,从父节点到该节点,全局坐标系下的 VECTOR local_coord; //在其父节点的“初始局部坐标系”下的局部坐标,辅助计算 VECTOR local_coord_axis; //绑定于该节点的“初始局部坐标系”,重要 VECTOR global_coord; //*全局坐标,绘制时真正用到的就是这个。 mutable VECTOR aligned_coord;//新增,全局坐标经过“空间对齐”之后的坐标;这里居然用到mutable了。 vector<int> child_id; //该骨骼的儿子节点id,但可能有多个儿子 string name; //骨骼名称 double length; //骨骼长度 double weight; //*新增。权重值,越靠近根节点权值越大。 double dof[3]; //amc文件里面的旋转角度,用以后续坐标的计算 int dofflag[3]; //自由度标记数组,用以辅助amc存储 int id; //骨骼id int father_id; //该骨骼的父亲节点id,只可能有一个父亲 };
//定义骨架类。 class Skeleton { public: Skeleton():bone_num(0){}; //构造函数 void set_weight(); //给每一个骨节点设置权重值,离根节点越近越“重” void cal_local_coord(); //计算全部骨节点在其父节点的“初始局部坐标系”下的局部坐标 void cal_global_coord(); //*计算全部骨节点的世界坐标 public: Bone bones[MAX_BONE_NUM]; //核心数据结构,用以存储骨架骨骼数据,通过对象直接访问 map<string,int> name_to_id; //还是定义为类的成员变量,用于绑定骨骼名字和id的 int bone_num; //骨骼总数目。 };
最后是Motion类:
//定义运动类。 class Motion { public: Motion(); //构造函数 void Read_ASF(string filename); //读取.ASF文件内容。 void Read_AMC(string filename); //读取.AMC文件内容,存储第N帧数据 void cal_position(); //计算每一帧骨架坐标信息 private: Skeleton skeletons[5000]; //难怪之前超过5000帧就不行了~~o(>_<)o ~ int total_frame_num; //该运动片段总帧数。 //friend可放在任意位置,不受private或者public影响 friend void render_text(); friend void render_frame(); friend void reshape(int w,int h); friend void idle(); friend void specialkey(int key,int x,int y); };
2.全局坐标的计算(重点)
要想正确的绘制出最终的动画效果,就要正确的绘制出每一帧;而要想正确的绘制出每一帧,就要正确的计算出各个骨节点的全局坐标。那么现在的问题就归结为如何算出每一帧各个骨节点的全局坐标了。这个过程大体而言分为两步:求解任一骨节点在其父节点的局部坐标系下的局部坐标;经过旋转和平移变换,求解其在世界坐标系下的全局坐标。先看第一步,局部坐标的求解:
1.每个骨骼由两个骨节点组成,起点和终点。在每块骨骼的起点处绑定了一个“局部坐标系”;
2.“局部坐标系”是由“原始坐标系”旋转变换而来,旋转角度即ASF文件中的axis分量。所有骨节点的“原始坐标系”均平行于“世界坐标系”;
3.ASF文件中的direction是从该节点的父节点指向该节点本身的,且这个方向是“原始坐标系”意义下的向量,要求在“局部坐标系”下的局部坐标需要进行旋转变换。
基于以上三点,我们就可以求解任何一个骨节点在其父节点的局部坐标系下的局部坐标了,代码如下:
//计算全部骨骼在其“初始局部坐标系”下的局部坐标,之后的变换均围绕它来进行。 void Skeleton::cal_local_coord() { //1、初始时刻,每块骨骼的“起点”处绑定一个与世界坐标系平行的“原始局部坐标系”; //我们首先计算每块骨骼在“原始局部坐标系”下的原始局部坐标向量; //注1:每个骨骼都有方向;每个骨骼的初始局部坐标系位于该骨骼的起点处骨节点。 //注2:这样一来,部分骨节点上可能绑定多个局部坐标系,不过这并不会带来问题。 for(int i=1;i<=bone_num;i++) { bones[i].local_coord.x=bones[i].length*bones[i].direc.x; bones[i].local_coord.y=bones[i].length*bones[i].direc.y; bones[i].local_coord.z=bones[i].length*bones[i].direc.z; } //2、随后,原始局部坐标系经过——旋转——成为该骨骼的“初始局部坐标系”; //接下来我们计算各个骨骼在“初始局部坐标系”下的坐标,由于原始局部坐标系依次绕z-y-x轴旋转而形成初始局部坐标系 //要计算初始局部坐标向量,就将原始局部坐标向量按照“相同顺序”“反向”旋转即可得到。 //这实质上是一个:向量不变,坐标系旋转,然后计算原向量在新坐标系下的新向量的几何问题。“逐步跟进”策略 for(int i=1;i<=bone_num;i++) { //注意:1、角度前面加负号;2、旋转顺序应为:z-y-x. bones[i].local_coord=Matrix_RotateZ(-bones[i].local_coord_axis.z,bones[i].local_coord); bones[i].local_coord=Matrix_RotateY(-bones[i].local_coord_axis.y,bones[i].local_coord); bones[i].local_coord=Matrix_RotateX(-bones[i].local_coord_axis.x,bones[i].local_coord); } }
局部坐标算出来之后,剩下的就是逐步向上追溯至根节点,通过一系列的旋转和迁移变换至世界坐标系下,得到全局坐标。这里重点就是把握两种变换:旋转变换和迁移变换。每一次向上追溯都会依次进行这两种变换。我们在这里将所有的局部坐标系都理解为固定不变的,亦即都跟初始时一样,后续的旋转均是旋转的骨节点,坐标系不变,骨节点绕其旋转那么坐标当然会改变,这就是旋转变换的过程。
而迁移变换的过程要复杂一些,它的目的是很明确的,就是逐步将每一个骨节点向根节点迁移:首先将当前节点在父节点的局部坐标系下的局部坐标迁移至其爷爷节点的局部坐标系下的局部坐标,...依次向上直至根节点。
一图胜千言:
于是我们可以得到如下推导:
注:这里的Xk当然是任一骨节点在其局部坐标系下的局部坐标,Rk为从原始坐标系(与世界坐标系平行)到局部坐标系的欧拉旋转角;Tk则是原始坐标系和世界坐标系之间的平移向量,因为这两者是平行的,所以只需要一个平移变换即可。细心的读者应该能够发现,这里的Tk - Tk-1其实就等于ASF文件中的direction点乘length,而Rk即是ASF文件中的axis分量,所以综上所述,ASF文件搞定迁移变换,AMC文件搞定旋转变换。
具体实现步骤我在代码中注释的很详细了,代码如下:
//**非常重要。用以计算所有骨骼的全局坐标;后续绘制都是基于它的计算结果;其余计算均为了辅助它而存在。 void Skeleton::cal_global_coord() { //1、首先计算各个节点在其初始局部坐标系下的局部坐标; //注:之后各个骨骼绕其初始局部坐标系旋转,假设各个骨骼的初始局部坐标系方向一直不变,这样以来它们之间的关系也不会变化。 cal_local_coord(); //2、在此坐标基础之上,对各个骨骼进行两种变换:1、“旋转变换”;2、“迁移变换”。 for(int i=1;i<=bone_num;i++) { //临时变量,用以存储每一块骨骼在其初始局部坐标系下的局部坐标。 VECTOR temp=bones[i].local_coord; //对每一个骨骼进行计算,都要追溯到根节点 int current_id=i; //当前骨骼id int father_id=bones[current_id].father_id; //当前骨骼的父亲骨骼id while(current_id!=0) //id==0则表明已经追溯到父节点了 { //1、“旋转变换”,围绕当前骨骼的“初始局部坐标系”旋转,旋转角度为.AMC文件中的数据,已经存储在bones[i].dof中 //此时问题的实质变为:坐标系固定,向量围绕固定坐标系旋转求解新的向量坐标。 //*注:这里遵循的旋转顺序是:x-y-z。 temp=Matrix_RotateX(bones[current_id].dof[0],temp); temp=Matrix_RotateY(bones[current_id].dof[1],temp); temp=Matrix_RotateZ(bones[current_id].dof[2],temp); //2、“迁移变换”,将该骨骼在当前骨骼的初始局部坐标系下的局部坐标迁移至其父骨骼的初始局部坐标系下的坐标 //注:迁移变换的理论依据:空间任意一点可以平等的在两套坐标系下转换到世界坐标系中去。 //现在关键是要理清骨骼局部坐标到世界坐标的转换过程,初始局部坐标系下的局部坐标向量首先还原至在原始局部坐标系下 //这是关键的步骤,之后进行平移变换即可转换至世界坐标系下。 //问题可概括为两类:一类是在变换后的坐标系下的向量通过逆序还原到以前的坐标系下的向量; //第二类是:在原来的坐标系下的向量逐步顺序跟进到新的坐标系下的向量。 //其实可以统一理解为逐步跟进。 //===========================首先定义旋转矩阵============================== //定义绕X轴旋转矩阵: Matrix temp_current_1={0.0}; Matrix temp_father_1={0.0}; temp_current_1.Index[0][0]=1.0; temp_current_1.Index[1][1]=cos(bones[current_id].local_coord_axis.x); temp_current_1.Index[1][2]=-sin(bones[current_id].local_coord_axis.x); temp_current_1.Index[2][1]=sin(bones[current_id].local_coord_axis.x); temp_current_1.Index[2][2]=cos(bones[current_id].local_coord_axis.x); temp_father_1.Index[0][0]=1.0; temp_father_1.Index[1][1]=cos(bones[father_id].local_coord_axis.x); temp_father_1.Index[1][2]=-sin(bones[father_id].local_coord_axis.x); temp_father_1.Index[2][1]=sin(bones[father_id].local_coord_axis.x); temp_father_1.Index[2][2]=cos(bones[father_id].local_coord_axis.x); ///定义绕Y轴旋转矩阵: Matrix temp_current_2={0.0}; Matrix temp_father_2={0.0}; temp_current_2.Index[0][0]=cos(bones[current_id].local_coord_axis.y); temp_current_2.Index[0][2]=sin(bones[current_id].local_coord_axis.y); temp_current_2.Index[1][1]=1.0; temp_current_2.Index[2][0]=-sin(bones[current_id].local_coord_axis.y); temp_current_2.Index[2][2]=cos(bones[current_id].local_coord_axis.y); temp_father_2.Index[0][0]=cos(bones[father_id].local_coord_axis.y); temp_father_2.Index[0][2]=sin(bones[father_id].local_coord_axis.y); temp_father_2.Index[1][1]=1.0; temp_father_2.Index[2][0]=-sin(bones[father_id].local_coord_axis.y); temp_father_2.Index[2][2]=cos(bones[father_id].local_coord_axis.y); ///定义绕Z轴旋转矩阵: Matrix temp_current_3={0.0}; Matrix temp_father_3={0.0}; temp_current_3.Index[0][0]=cos(bones[current_id].local_coord_axis.z); temp_current_3.Index[0][1]=-sin(bones[current_id].local_coord_axis.z); temp_current_3.Index[1][0]=sin(bones[current_id].local_coord_axis.z); temp_current_3.Index[1][1]=cos(bones[current_id].local_coord_axis.z); temp_current_3.Index[2][2]=1.0; temp_father_3.Index[0][0]=cos(bones[father_id].local_coord_axis.z); temp_father_3.Index[0][1]=-sin(bones[father_id].local_coord_axis.z); temp_father_3.Index[1][0]=sin(bones[father_id].local_coord_axis.z); temp_father_3.Index[1][1]=cos(bones[father_id].local_coord_axis.z); temp_father_3.Index[2][2]=1.0; //==============旋转矩阵定义完毕,以下将其相乘构成组合旋转矩阵============== //这里的顺序仍然很重要 Matrix rotate_matrix_current=MatrixMult(temp_current_2,temp_current_1); rotate_matrix_current=MatrixMult(temp_current_3,rotate_matrix_current); Matrix rotate_matrix_father=MatrixMult(temp_father_2,temp_father_1); rotate_matrix_father=MatrixMult(temp_father_3,rotate_matrix_father); //===========================组合旋转矩阵定义完毕。========================== //正式开始迁移: //迁移步骤一: temp=MatrixMultVec(rotate_matrix_current,temp); //迁移步骤二: //首先定义迁移平移向量,是基于原始局部坐标系下的。 VECTOR temp_vec; temp_vec.x=bones[father_id].length*bones[father_id].direc.x; temp_vec.y=bones[father_id].length*bones[father_id].direc.y; temp_vec.z=bones[father_id].length*bones[father_id].direc.z; temp.x+=temp_vec.x; temp.y+=temp_vec.y; temp.z+=temp_vec.z; //迁移步骤三: //这里就只用调用一次矩阵求逆,避免之前的四次调用,提高程序效率。 temp=MatrixMultVec(MatrixInverse(rotate_matrix_father),temp); //以上,实现将当前骨骼的局部坐标迁移至其父骨骼的局部坐标系下。 //最后更新临时变量:当前骨骼->父亲骨骼;父亲骨骼—>爷爷骨骼。 current_id=father_id; father_id=bones[current_id].father_id; } //End of while //此时,current_id=0,即表示它的父亲是“根节点”了。此时已经将其迁移至根节点初始局部坐标系下的局部坐标了。 //接下来完成最后一步,将其旋转、迁移至世界坐标系下,这一步是平凡的。 //整个骨架的“旋转变换”,依旧遵循X-Y-Z的顺序进行。 temp=Matrix_RotateX(bones[0].dof[0],temp); temp=Matrix_RotateY(bones[0].dof[1],temp); temp=Matrix_RotateZ(bones[0].dof[2],temp); //整个骨架的“平移变换”。 temp.x+=bones[0].global_coord.x; temp.y+=bones[0].global_coord.y; temp.z+=bones[0].global_coord.z; //至此,两种变换完毕,更新全局坐标 bones[i].global_coord=temp; } //End of for() 计算完毕! }
3.绘制结果展示
Skeleton骨架绘制出来是这样的:
以jump运动为例绘制出来的效果是这样的:(由于插入视频不方便,就放几个关键帧的截图展示一下最终的效果)
起跳:
最高点:
落地:
至此,我们就成功的实现了从静态的文本运动数据到动态的三维人物动画的转换,万里长征走完了第一步,欲知后续,且听下回分解吧。刚统计了下,我个人实现的整个代码量在1200行左右,是用C++结合OpenGL在Visual Studio 2010平台下实现的。关键代码其实都已经放在前面了,如若需要完整的实现代码可E-Mail联系本人:floristt#126.com ,后续我也会将项目代码上传到个人的GitHub上面去。