前言
分析完ThreeDPoseTracker来做卡通角色的身体驱动,接下来在卡通驱动领域还有一个是表情驱动。对这个真的是一窍不通啊,只能慢慢看论文了。
国际惯例,参考博客/论文:
- 《Landmark-guided deformation transfer of template facial expressions for automatic generation of avatar blendshapes》
- 《FACSvatar: An Open Source Modular Framework for Real-Time FACS based Facial Animation》
- 《Real-time Facial Animation for Untrained Users》
- 《Modeling Facial Expressions in 3D Avatars from 2D Images》
- 《Practice and Theory of Blendshape Facial Models》
- 《Real-Time Facial Motion Capture System》
- 《Semantic 3D Motion Retargeting for Facial Animation》
- 《Learning Controls for Blend Shape Based Realistic Facial Animation》
- 《Performance Driven Facial Animation using Blendshape Interpolation》
- 《Expression Cloning Jun-yong》
- 苹果的ARKit blendShapes
- 如何在Maya流程下创建一整套面部绑定
简述
此块内容受知识限制,描述内容有限或者可能有误,所以大家有何见解可在评论区或者微信公众号私信我讨论。
从参考文献来看,表情驱动大致分为三种:
- 网格形变/编辑(
mesh deform
)- 直接基于面部顶点进行网格形变,比如我之前的博客径向基函数RBF三维网格变形就是其中一种变形方法,但是在表情驱动中,不同论文也会提出各种不同的变形方法,使变形后的人脸表情更加自然,其核心就是变形算法,例如《Landmark-guided deformation transfer of template facial expressions for automatic generation of avatar blendshapes》、《Deformation transfer for triangle meshes》,可以将捕捉到的人脸网格重定向到一个数字人面部,同步他俩的动作。
- 比如Blender 2.8 Facial motion capture tutorial利用关键点和
blender
自带的形变功能,此博客中要求人脸3D模型和人脸关键点保持一致(作者进行了人工绑定),所以无法用到卡通角色上;不过如果自己能找到人脸和其它卡通角色的面部捕捉点对应关系,同时也能建立一个真人面部关键点动作到虚拟角色对应关键点运动的映射关系(因为卡通角色和人脸角色的网格差距可能会很大),也可以做卡通角色的驱动。
- 骨骼驱动。在面部创建骨骼,利用软件对面部肌肉和骨骼进行权重绑定,然后调整骨骼的时候,面部肌肉会根据绑定值自动计算面部顶点信息,此时不需要网格形变算法,直接基于权重重新计算人脸蒙皮。这个过程很接近人体蒙皮算法,后续应该会开一个博客解析如何将皮肤与骨骼绑定。
- 比如B站的这个教程使用
maya
建立面部骨骼,然后做表情绑定的教程。
- 比如B站的这个教程使用
- 使用
blendshape
融合变形;在美术领域,这个方法就是表情控制器的制作基础,每个表情控制器对应一套BS。关于详细可看苹果的ARKit blendShapes列出的BS类型,针对每个表情预先做好对应的面部模型,从无表情到有表情用一个0-1的系数即可控制每种表情的程度;人脸重建的基本方法3DMM就是基于BS的,核心在于如何获取当前人脸对应的BS系数。通常有两种方法- 一种是用最小二乘法求解,使得所有表情BS组合起来的人脸的关键点更加接近真实人脸提取的关键点。比如Realtime Facial Animation for Untrained User,StrongTrack
- 用几何的方法直接算,比如Blender & OpenCV - Windows Guide - Facial Motion Capture,预先在做好了卡通角色的面部骨骼动画控制器,然后根据人脸关键点计算出五官变化,从而控制虚拟角色的表情。
- 另一种是直接用深度学习去获取BS系数,但是非常受限于数据集,比如openface,具体应用为FACSvatar
还有直接用图像算法驱动表情的我就不说了,比如最近比较火的“蚂蚁雅黑”表情驱动就是基于first-order-model的,不想把它归到3D驱动中。
【注】上述方法不要区分太开,因为本质都是网格形变,只不过骨骼驱动是通过控制面部骨骼,利用预定义的(骨骼对面部影响)权重来自动计算对应表情的网格;BS也是用画刷结合权重,利用maya
或者其它工具调整面部网格做出来的。所以要么是利用算法做网格变形实现驱动,要么预定义网格变形实现驱动。例如还是这个B站教程使用Maya工具节点绑定角色面部表情教程 - Rig a face using Maya’s Utility nodes,或者对应的图文解析如何在Maya流程下创建一整套面部绑定,就用骨骼驱动人脸,然后用blendshape
实现微调,简单说就是“blendShapes的优势就是可以提供更精确的表情,而关节可以实现面部区域的拉伸,增加更多的表情灵活性”。因此每种方法都有自己优劣势,也可以结合使用,只不过常见代码中的做法基本都是基于BS来实现表情驱动的。
本文和接下来的系列博文,将先针对BS驱动,分析和抽取几个源码内容,来加深理解。
源码理论与实验分析
拿strongtrack的源码开刀,作者提供了效果视频,在油管上自己观看
【吐槽】本来想把视频转发到B站的,但是坑货B站把视频当做恐怖内容了,因为作者的视频只有头没有身体,就被当成恐怖镜头了,审核不通过。
准备工作
作者提供了一个人头BlendShape
模型,关于Blender
的模型可以去作者提供的谷歌网盘下载,也可以从我的百度云下载
链接:https://pan.baidu.com/s/15tlWmJ9grXw4eI6TiVC-Iw
提取码:871c
为了便于分析理论和BS的操作,我预先把所有的BS
模型从Blender
文件中导出来了,所有的BS
可以在我的github
上找到,作者做了50组BS
,名字分别如下:
'eyeBlinkLeft', 'eyeBlinkRight', 'eyeSquintLeft', 'eyeSquintRight', 'eyeLookDownLeft', 'eyeLookDownRight', 'eyeLookInLeft',
'eyeLookInRight', 'eyeWideLeft', 'eyeWideRight', 'eyeLookOutLeft', 'eyeLookOutRight', 'eyeLookUpLeft', 'eyeLookUpRight', 'browDownLeft', 'browDownRight',
'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight', 'jawOpen', 'mouthClose', 'jawLeft', 'jawRight', 'jawFwd',
'mouthUpperUpLeft', 'mouthUpperUpRight', 'mouthLowerDownLeft', 'mouthLowerDownRight', 'mouthRollUpper',
'mouthRollLower', 'mouthSmileLeft', 'mouthSmileRight', 'mouthDimpleLeft','mouthDimpleRight', 'mouthStretchLeft',
'mouthStretchRight', 'mouthFrownLeft', 'mouthFrownRight', 'mouthPressLeft', 'mouthPressRight', 'mouthPucker', 'mouthFunnel', 'mouthLeft','mouthRight',
'mouthShrugLower','mouthShrugUpper', 'noseSneerLeft', 'noseSneerRight', 'cheekPuff', 'cheekSquintLeft', 'cheekSquintRight'
至于每一组BS代表的表情,可以自行去Blender
查看,或者用meshlab
打开我导出的OBJ
文件观看。
提前说一句,从代码里面可以发现,作者只用了其中10组表情基(BS),详细如下:
Basis, jawOpen, mouthSmile, mouthSmileLeft, mouthSmileRight, mouthFrown, mouthFunnel, mouthPucker, browInnerUp, browDown
源码流程
先大概介绍一下作者算法流程,在strongtrack.py
的VideoThread
函数中可以逐步分析
- 先使用
dlib
获取人脸关键点,并且做了微调训练 - 分别记录真人的10个关键姿态(与使用的BS对应),表情分别为:
Neutral
,Jaw Open
,Closed Smile
,Smile Left
,Smile Right
,Mouth Frown
,Lip Funnel
,Lip Pucker
,Brows Up
,Brows Down
- 进入实时驱动阶段时候,核心在
decomp_function.py
文件中的findCoeffAll
函数,其步骤为- 提取嘴部系数:因为与嘴巴相关的BS有八个,因此源码中先针对关键姿态和当前帧的嘴部关键点做中心对齐,然后使用稀疏编码求解系数
- 提取眉毛系数:因为与眉毛相关的BS只有2个,而且一个向上,另一个向下,区分非常明显,所以可以直接计算,源码的方法是计算当前帧的眉毛中心相对于自然状态下眉毛中心的偏移量,分别除以两个BS相对于自然状态下眉毛中心的偏移量,就分别得到了眉毛两个BS的系数,然后对眉毛向下的情形做一下系数值的约束即可。
- 提取眨眼系数:这个更简单了,以内外眼角平均坐标值为眼睛中心,上下眼框分别计算坐标中心,除一个指定的固定值即可。
- 所有系数计算完毕以后,即可驱动模型表情
源码简化
上述的整个源码流程中,我最看好那个稀疏编码求解系数的过程,涨知识了。其它的都是利用几何关系计算,没什么技术含量。结果代码还写了好多好多,其中有很大一部分是写界面,介绍如何训练人脸关键点检测模型,以及利用OSC
建立python
和blender
的实时通信。
所以按照博客宗旨,我们仅分析系数计算这一块内容,跳过关键点检测模型的训练以及通信代码的书写。因此,在实验时候,10个真人表情对应的2D人脸关键点,我直接从对应BS
中获取(去掉深度坐标轴),然后随便组合两组BS
导出来,作为实时驱动时候的人脸关键点。
随后,在源码的基础上抽取了提取表情系数部分的代码,同时针对性修改和简化了一下:
-
对齐关键点,嘴巴和眉毛是分开提取系数的,所以它们的位置是分开做中心对齐的,流程就是分别提取关键表情和当前帧中人脸对应部位关键点,然后根据脸宽缩放,最后按照中心坐标对齐
''' 将表情基的人脸关键点与当前表情关键点对齐 可以用于处理局部关键点,源码中分别处理眼、嘴 ''' def shiftKeyPoses(new_width, centroid, keyposes, config): #Scale keypose based on head width to accomodate for translation or different video size.width_keypose = (keyposes[0][67][0]-keyposes[0][51][0]) # 表情基中第一个姿态的人脸宽度width_fac = width_keypose/new_width # 表情基脸宽/真人脸宽keyposes = np.divide(keyposes, [width_fac,width_fac]).astype(int) # 依据比例系数,将所有表情基关键点缩放到真人面部大小new_poses = []for i in range(keyposes.shape[0]): # 遍历所有的表情基#For brows we take average of eyes pointsif config == 'brows': keypose = np.array(keyposes[i][10:22])#Fo mouth we take average of mouth pointsif config == 'mouth':keypose = np.array(keyposes[i][31:51])centroid_keypose = keypose.mean(0) #表情基中眉毛或者嘴部的中心delta = centroid_keypose-centroid # 表情基眉毛或嘴中心与真人眉毛或嘴中心的偏移量new_pose = keyposes[i]-delta.astype(int) # 利用中心偏移量 重新调整表情基的位置new_poses.append(new_pose) # 将新的表情基加入数组中返回return np.array(new_poses)
-
嘴部
BS
系数直接使用sklearn
中的SparseCoder
函数进行求解,用法也很简单''' 嘴部BS ''' # 对齐keypose和真人嘴部关键点 mouth_center = testpose[31:51].mean(0) shift_kps_mouth = shiftKeyPoses(width_points,mouth_center,keyposes_mouth,"mouth") # 重组嘴部坐标,便于计算 target_mouth = testpose[31:51].reshape((1,-1)) dict_2d_mouth = [] for i in range(shift_kps_mouth.shape[0]):dict_2d_mouth.append(shift_kps_mouth[i][31:51]) dict_2d_mouth = np.array(dict_2d_mouth).reshape(shift_kps_mouth.shape[0],-1) # 提取嘴部运动的系数 coder = SparseCoder(dictionary=dict_2d_mouth.astype("float32"),transform_n_nonzero_coefs=None,transform_alpha=10,transform_algorithm='lasso_lars') coeffs = coder.transform(target_mouth.astype("float32"))
-
计算左右眉毛的上下运动,计算方法上面说过,就是关键表情的关键点和当前帧关键点的眉毛相对于自然状态下眉毛的偏移比例
# 计算眉毛 def calBrow(points, keyposes, config, config2):# 眉毛姿态集中,分别有正常,眉毛上,眉毛下if config == 'left':first = 5last = 10if config == 'right':first = 0last = 5if config2 == "up":target = 1 # 眉毛上else:target = 2 # 眉毛下# 计算挑眉的keypose相对于自然表情下眉毛移动deltashifted = keyposes[target][first:last] - keyposes[0][first:last]deltashifted = (sum(sum(abs(deltashifted))))# 计算当前人脸相对于自然表情下眉毛移动deltapoints = (points[first:last]) - (keyposes[target][first:last])deltapoints = (sum(sum(abs(deltapoints))))# 直接相除,得到比例系数if deltapoints < (deltashifted):val = 1 - (deltapoints / deltashifted)else:val = 0.0# 如果是眉毛向下,可以用垂直比例来辅助计算,不然不准if(target==2):ydelt = keypose[2][first:last] - points[first:last]ydelt = sum(ydelt.T[1])if(ydelt<=0):val = 1.0return val
眉毛向下的时候可能有点难算,或者出问题,所以额外加了个约束:
# 约束 def constraint(val,lower,upper):factor = 1 / lowerif lower > val:new_val = 0.0if lower <= val < upper:new_val = (val - lower) * factorif val >= upper:new_val = 1.0return new_val
调用时候如下:
# 对齐眉毛关键点 eye_center = testpose[10:22].mean(0) shift_kps_eye = shiftKeyPoses(width_points,eye_center,keyposes_brows,"brows") # 分别提取左右眉毛上下运动的系数 val_l_up = calBrow(testpose,shift_kps_eye,"left","up") val_r_up = calBrow(testpose,shift_kps_eye,"right","up") val_l_down = constraint(calBrow(testpose,shift_kps_eye,"left","down"),0.4,0.8) val_r_down = constraint(calBrow(testpose,shift_kps_eye,"right","down"),0.4,0.8)
-
眨眼这个过程就是计算上下眼眶个子的中心坐标以及整个眼眶中心坐标的关系,但是涉及到常量,这个常量应该是依据场景确定出来的,所以代码无法过于深究:
#左眼 eye_top_r = testpose[11:13].mean(0) eye_mid_r = testpose[[10,13]].mean(0) eye_bottom_r = testpose[14:16].mean(0) blink_r_coeff = (eye_top_r[1]-eye_mid_r[1]+28)/48 squint_r_coeff = (eye_mid_r[1]-eye_bottom_r[1]+17)/7.5 #右眼 eye_top_l = testpose[17:19].mean(0) eye_mid_l = testpose[[16,19]].mean(0) eye_bottom_l = testpose[20:22].mean(0) blink_l_coeff = (eye_top_l[1]-eye_mid_l[1]+28)/48 squint_l_coeff = (eye_mid_l[1]-eye_bottom_l[1]+17)/7.5
源码计算BS
的核心就是上面了,有点技术含量的就是计算嘴部BS
使用的稀疏编码。
验证稀疏编码和BS
结果
针对感兴趣的部分做一次验证,必须少不了可视化。提取嘴部BS
的理论和代码就不重复了。
为了验证结果,上面说过利用BS
做了两个测试用的表情模型,对应关键点如下:
关于BS
融合变形的原理,通常是基于偏移量来计算的,也就是常看到的一个公式
R=Base+∑iwiOiR = Base + \sum_i w_i O_i R=Base+i∑wiOi
其中OiO_iOi代表的是表情基相对于自然表情基的顶点偏移量
Oi=Bi−BaseO_i = B_i-Base Oi=Bi−Base
所以利用代码得到BS
融合结果,需要先把每个表情基偏移量算出来:
# 获取BS偏移量
# 0:Neutral
basicVerts = getVerts('./data/Basis.obj')
# 1:Jaw Open
jawopenVerts = getVerts('./data/jawOpen.obj')
# 2:Closed Smile
closesmileVerts = getVerts('./data/mouthSmile.obj')
# 3:Smile Left
smileleftVerts = getVerts('./data/mouthSmileLeft.obj')
# 4:Smile Right
smilerightVerts = getVerts('./data/mouthSmileRight.obj')
# 5:Mouth Frown
mouthfrownVerts = getVerts('./data/mouthFrown.obj')
# 6:Lip Funnel
lipfunnelVerts = getVerts('./data/mouthFunnel.obj')
# 7:Lip Pucker
lippuckerVerts = getVerts('./data/mouthPucker.obj')offset = []
offset.append(basicVerts-basicVerts)
offset.append(jawopenVerts-basicVerts)
offset.append(closesmileVerts-basicVerts)
offset.append(smileleftVerts-basicVerts)
offset.append(smilerightVerts-basicVerts)
offset.append(mouthfrownVerts-basicVerts)
offset.append(lipfunnelVerts-basicVerts)
offset.append(lippuckerVerts-basicVerts)offset = np.array(offset,dtype="float32")
然后再去组合得到结果:
# 根据系数组合BS
newVert = basicVerts
for i in range(offset.shape[0]):newVert = newVert + coeffs[0,i]*offset[i]
writeResult(newVert)
把结果写入到OBJ
并与测试表情模型做对比,结果如下:
可以发现上下表情几乎一模一样,所以验证成功,使用稀疏编码计算表情系数是可行的。
后记
稀疏编码看起来貌似是挺强大的,后续也可以尝试将整个表情关键点都用系数编码计算一下试试,不要手动计算了,虽然靠谱,但是有点low
啊。
还有通常BS
是被约束到(0,1)(0,1)(0,1)范围内的,这个库貌似无法保证最终表情系数在此范围,后续再继续探索一下。
完整的python
实现放在微信公众号的简介中描述的github中,有兴趣可以去找找。同时文章也同步到微信公众号中,有疑问或者兴趣欢迎公众号私信。