卡通角色表情驱动系列二

Posted 风翼冰舟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了卡通角色表情驱动系列二相关的知识,希望对你有一定的参考价值。

前言

之前介绍了使用传统算法求解BS系数的表情驱动方法,其中提到过的三种方法之一是基于网格形变迁移做的,那么这篇文章就是对《Deformation Transfer for Triangle Meshes》做表情驱动的解析。

国际惯例,参考博客:

本博文实现几乎照搬大佬代码,但是大佬代码实在是太多太复杂了,搞了很多库和函数,还有很多重复的实现,所以本博客一边解析论文,一边按照大佬的代码简化实现,把几千行代码缩短到几百行。

python的预备知识

因为3D模型的网格数量一般比较大,所以论文的实现需要稀疏矩阵

  • 稀疏矩阵相关库说明scipy.sparse
  • 由于涉及到临近点查找,所以需要建立cKDTree,同时需要熟悉其查找函数query,专门用于在KD树中查找指定点的临近点。
  • 从一个网格到另一个网格的形变量,涉及到大型矩阵的求解级Ax=B ,其中AB是超大的矩阵建立的稀疏矩阵,所以求解x需要使用稀疏矩阵求解,代码使用的是LU分解

理论与实现

概述

论文主要包括两部分:

  • 形变迁移:原始模型的表情变化量(网格形变情况)迁移到目标模型中,比如角色1从正常到笑脸的表情变化量迁移到角色2的正常表情上,使角色2变成笑脸
  • 网格对齐:角色1和角色2需要找到网格面片的对应关系,告诉算法原模型的面片100发生变换时,目标模型应该是哪个网格面片发生对应变换。

论文第5章介绍的是网格对齐,第3、4章分别介绍面片和顶点做形变迁移;我们按照正常顺序解析,先找到源模型和目标模型的网格对应关系,再去做形变迁移。

网格对应关系

找网格面片对应关系的思想是将源模型尽量形变到目标模型的同时,要保证:

  • 源网格形变后能够保证自身的大概结构,不能本来是猫,形变以后变成狗了;这一步要求每个网格形变矩阵尽量接近单位阵
  • 形变过程中,具有相同顶点的网格要具有接近甚至是相同的形变矩阵,不然同一个顶点左边的网格让他往上形变,而右边的网格让他往下形变,这样就造成了这个顶点不连续的现象;这一步要求相邻网格的形变矩阵的差值为零矩阵
  • 源模型的所有顶点形变后,目标模型中与源模型指定顶点的最临近点要满足人工指定的顶点约束;即形变后,最开始的人为指定的约束顶点对的空间位置要尽量接近;这一步要求形变后,在目标模型中查找形变后源模型指定顶点的最近点的位置要与人为指定的顶点接近。

上面三个约束就对应论文第五章介绍的三种损失函数,这样我们计算两个模型对应点的目标就是
min ⁡ w s E s + w I E I + w C E c \\min w_sE_s+w_IE_I+w_CEc minwsEs+wIEI+wCEc
式子约束为将源模型的顶点映射到目标模型的某个对应顶点上去

网格信息的建立

为计算上述三个约束的损失函数值,我们需要对原始模型做一些额外信息的提取:

  • 首先是提取对应源模型和目标模型各自的相邻面片索引,如与第0个面片与 [ 4 , 5 , 6 ] [4,5,6] [4,5,6]面片相邻 等,按照边来计算,如果两个面片具有同样的边,那么他俩就是相邻面,是实现如下:

    def compute_adjacent_by_edges(objFaces):
        # 每条边涉及到哪些面
        candidates = defaultdict(set)
        for i in range(objFaces.shape[0]):
            f0, f1, f2 = sorted(objFaces[i])
            candidates[(f0, f1)].add(i) # 注意i是面索引
            candidates[(f0, f2)].add(i)
            candidates[(f1, f2)].add(i)
        # 每个面与哪些面邻接;candidates的value存的就是共享边的面
        faces_adjacent = defaultdict(set)  # Face -> Faces
        for faces in candidates.values():
            for f in faces:
                faces_adjacent[f].update(faces)
        # 按面的顺序排列所有邻接面
        faces_sort = []
        for f, adj in faces_adjacent.items():
            exclude_f = []
            for a in adj :
                if a != f:
                    exclude_f.append(a)
            faces_sort.append([f,exclude_f])
        faces_sort = sorted(faces_sort, key=lambda e: e[0])
        # 只返回邻接面
        faces_adj = []
        for _,ff in faces_sort:
            faces_adj.append(ff)
        return faces_adj
    

    可以看到流程大概是:先记录每条边属于那些面,然后按照每个边记录的面索引集合,提取每个面的相邻面,最后按照顺序将自己去除,按索引顺序记录他的相邻面,比如 [ 0 , 1 , 2 , 3 ] [0,1,2,3] [0,1,2,3]是相邻面,那么第0个面的相邻面索引记录为 [ 1 , 2 , 3 ] [1,2,3] [1,2,3],而第2个面的相邻面索引记录为 [ 0 , 1 , 3 ] [0,1,3] [0,1,3]

  • 论文写了三角面并不能准确的表现出网格的变换,需要为每个面增加额外的一个顶点,代表的方向为当前面的法线方向,所以每个面需要被重新调整一下,增加面的法线顶点

    def compute_face_norms(verts,faces):
        fnorms = []
        # 计算每个面片的三组方向
        for f in faces:
            v1 = verts[f[0]]
            v2 = verts[f[1]]
            v3 = verts[f[2]]
            a = v2 - v1
            b = v3 - v1
            tmp = np.cross(a, b)
            c = tmp.T / np.linalg.norm(tmp)
            fnorms.append([a,b,c])
        fnorms = np.array(fnorms) 
        # 更新顶点,添加第四个顶点
        v4 = v1 + fnorms[...,-1]    
        new_verts = np.concatenate((verts,v4),axis=0)
        # 更新面片顶点索引
        v4_indices = np.arange(verts.shape[0],verts.shape[0]+v4.shape[0])
        new_faces = np.concatenate((faces,v4_indices.reshape((-1,1))),axis=1)
        return np.transpose(fnorms, (0, 2, 1)),new_verts,new_faces
    
  • 找最近点的时候,我们不仅要看这个点的位置是否与目标点接近,还得考虑他的邻接面的法线方向是否一致,比如我找到模型两个顶点接近,但是一个发现向上一个法线向下,他俩也不能作为对应点,但是判断顶点附近面的法线很麻烦,所以为当前顶点利用临近面法线的平均计算了顶点法线。

    '''
    计算顶点法线:面法线的平均
    '''
    def compute_vert_norms(faceNorms,faces):
        vf = defaultdict(set)
        # 每个顶点与哪些面片有关
        for n, (f0, f1, f2) in enumerate(faces[:, :3]):
            vf[f0].add(n)
            vf[f1].add(n)
            vf[f2].add(n)
        # 计算每个顶点的法线,用面法线的平均
        vn = np.zeros((len(vf),3))
        for i in range(len(vf)):
            vn[i] = np.mean(faceNorms[list(vf[i])],axis=0)
            vn[i] = vn[i] / np.linalg.norm(vn[i])
        return vn
    
  • 求解稀疏矩阵的前提是要建立稀疏矩阵,所以需要将每个面片的信息保留在稀疏矩阵中

    '''
    创建稀疏矩阵
    '''
    row = np.array([0, 1, 2] * 4)
    def expand(f, inv, size):
        i0, i1, i2, i3 = f
        col = np.array([i0, i0, i0, i1, i1, i1, i2, i2, i2, i3, i3, i3])
        data = np.concatenate([-inv.sum(axis=0), *inv])
        return sparse.coo_matrix((data, (row, col)), shape=(3, size), dtype=float)
    def construct(faces, invVs, size):
            assert len(faces) == len(invVs)
            return sparse.vstack([expand(f, inv, size) for f, inv in zip(faces, invVs)], dtype=float)
    

    上面函数的意思是模型所有面片facesinvVs中记录了相关信息,需要把每个面片分别构建稀疏矩阵,然后组合起来;具体每个面片存储的信息就是论文公式(3)中的有面片边向量和法线向量组成的矩阵,然后按照公式(4)求逆,即
    V = [ v 2 − v 1 , v 3 − v 1 , v 4 − v 1 ] V = [v_2-v_1,v_3-v_1,v_4-v_1] V=[v2v1,v3v1,v4v1]
    因为v4代表的顶点,由 v 1 v1 v1加上法线方向组成,所以 v 4 − v 1 v4-v1 v4v1就是法线方向。

  • 因为上面仅仅是计算了 V − 1 V^{-1} V1,而我们的目标是计算损失 B − V − 1 A B-V^{-1}A BV1A,其中B是目标比如约束1的目标是单位阵,约束2的目标是零矩阵,所以还得有一个计算损失的函数

    def apply_markers(A, b, target, markers):
        """
        Ei的形变量接近单位阵
        Es的形变量是邻接面的形变量接近
        :param A: Matrix (NxM)
        :param b: Result vector (Nx3)
        :param target: Target mesh
        :param markers: Marker (Qx2) with first column the source indices and the second the target indices.
        :return: Matrix (Nx(M-Q)), result vector (Nx3)
        """
        assert markers.ndim == 2 and markers.shape[1] == 2
        invmarker = np.setdiff1d(np.arange(A.shape[1]), markers[:, 0])# 不在marker中的点
        zb = b - A[:, markers.T[0]] * target[markers.T[1]] # 使得形变量接近b
        return A[:, invmarker].tocsc(), zb
    

    上述函数的功能就是计算指定顶点的形变矩阵与目标矩阵的损失值。也即变换矩阵应该与单位阵或者零矩阵差多少。

构建保证结构的损失

为了保证变形后,猫还是猫,而不是变成狗,我们需要变形尽量是单位阵

def construct_identity_cost(faces,invVs,verts):
    AEi = construct(faces,invVs,verts.shape[0])
    AEi.eliminate_zeros()
    Bi = np.tile(np.identity(3, dtype=float), (len(faces), 1))
    assert AEi.shape[0] == Bi.shape[0]
    return AEi.tocsr(), Bi

上述返回的就是论文中的 V − 1 V^{-1} V1和单位阵,然后调用时候,计算损失的方法为:

Ei,Bi = construct_identity_cost(source_f,invVs,source_v)
EiSelect,EiLoss = apply_markers(Ei,Bi,target_v,marks)

这样就能返回指定顶点的 V − 1 V^{-1} V1和与保持原形状目标(单位阵)的损失。

构建保证平滑性的损失

上面提到过,相邻面片的形变量最好接近甚至是一致,不然左边面片让顶点往下偏移,右边面片让顶点往上偏移,这样就断裂不连续了,如果这俩面片的形变矩阵相同,那么他们都会往同一个位置偏移了,毕竟面片的形变本质上就是把形变矩阵用到面片的每个顶点上。

所以我们要对相邻的面片对构建他们的形变矩阵差值,使其目标接近零矩阵

def construct_smoothness_cost(faces,invVs,verts,adjacent):
    # 预先计算并保存
    lhs = []
    rhs = []
    face_idx = 0
    for f,inv in zip(faces,invVs):
        for adjIndex in adjacent[face_idx]:            
            lhs.append(expand(f,inv,verts.shape[0]).tocsc())
            rhs.append(expand(faces[adjIndex],invVs[adjIndex],verts.shape[0]).tocsc())
        face_idx = face_idx + 1
    AEs = sparse.vstack(lhs) - sparse.vstack(rhs)
    AEs.eliminate_zeros()    
    count_adjacent = sum(len(a) for a in adjacent)
    Bs = np.zeros((count_adjacent * 3, 3))
    assert AEs.shape[0] == Bs.shape[0]
    return AEs, Bs

上面有个lhsrhs就是对当前邻接面片的遍历,构建成对的信息,计算他俩的差值,因此后续出现了 l h s − r h s lhs-rhs lhsrhs,而目标Bs就是零矩阵。

【注】当面片数量比较大的时候,这一步的for循环和vstack大概率会把内存怼满,就无法进行后续的计算了,所以可以采用暂存的方法,把 A E s AEs AEs存起来,后面复用

sparse.save_npz("cat-lion.npz",AEs)
AEs = sparse.load_npz("head.npz")

接下来与结构损失的计算同理

Es,Bs = construct_smoothness_cost(source_f,invVs,source_v,source_face_adj)
EsSelect,EsLoss = apply_markers(Es,Bs,target_v,marks)

这样平滑损失也构建完毕

构建距离损失

这个稍微麻烦点,因为涉及到两个模型的最近点索引得查找,所以应用到了上面说过的cKDTree去快速查询临近点

def get_closet_points(kd_tree,verts,verts_norms,target_normal,max_angle=np.radians(90),ks=200):
    '''
    寻找最近点,同时满足法线角度差小于max_angle,寻找最近点的数量不大于ks
    '''
    assert len(verts) == len(verts_norms),"source verts and norms is not same length"
    closest_points = []
    di

以上是关于卡通角色表情驱动系列二的主要内容,如果未能解决你的问题,请参考以下文章

卡通角色表情驱动系列二

卡通角色表情驱动系列二

卡通角色表情驱动系列二

热门聊天表情包怎么找?怎么制作?多平台表情合集,没有找不到的表情包!搞笑-金馆长-张家辉-卡通-二次元-gif等表情大全

二次元卡通角色渲染技术概述

卡通驱动项目ThreeDPoseTracker——模型驱动解析