一、引言
目前,2D人脸对齐技术已经相当成熟,而3D 人脸重构和3D 人脸对齐一直是一个研究的热点问题。目前普遍的方法是基于3DMM或者3D face template,本文直接通过深度学习,建立从2D图片到3D模版的映射关系。
本项目的目的是将目标人脸融合到源人脸得到新的人脸。其过程包括:
1、人脸密集对齐
2、3D人脸建模
3、人脸融合
二、人脸三维重建
2.1 三维图像
对于一个三维图像来说其关键信息包括三个:顶点(vertices)、颜色(colors)和三角形(triangles)。三角形是构成三维图像的基本单位,顶点是构成三角形的基本单位,颜色表示了图形的基本纹理和颜色特征。例如,blond-2d图,
加入已知这个二维图片的三维信息,即顶点、颜色和三角形,并写成obj文件,即可使用软件meshlab打开,如图所示。
因此,要想得到二维人脸的三维信息,必须得到该二维人脸的以上三个信息点。本文使用深度学习的方法学习二维图像和三维图像之间的密相关性,在使用密相关性来计算3DMM的参数。
本文使用UV position map作为三维人脸结构的表达,该表达比较简短,而且能够精准的表达三维图像中各个顶点。在以往的研究中,UV空间或UV坐标系是三维空间中表示二维平面图的参数,用来表达人脸的纹理信息,而在本文中我们用UV空间来存储3D人脸模型中点的3D坐标系,如下图所示。在左笛卡尔坐标系上定义了3D人脸点云,3D空间与输入图像的左上方重叠,x轴的正方向指向输入图像的右边。当3D人脸点云投影到x-y平面上时,ground truth3D人脸点云与2D的人脸完全匹配,因此用x, y, x坐标替换纹理图中的r, g, b值,就可以很好的理解了。(即用第二排来表示第一排的三维信息)
为了保持position map中点的语义信息,我们使用3DMM来创建UV坐标系。我们想要拟合3D模型的完整结构,因此训练模型时需要无约束的2D人脸和相应的3D形状。
2.2 渲染---z-buffer
渲染是将一个三维的物体投影到一个二维的平面上。即使用二维平面来表示三维图形。也就是已知三维图像的顶点、颜色和三角形信息,得到对应三维图像的二维平面图。
3D空间中点的坐标是(x, y, z), z-buffer保存的是图像内顶点的z坐标值(即顶点的第三维坐标值)。投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z坐标的分辨率较大,反之较小,也就是说,投影后的z坐标在其值域上对于离开眼睛的物理距离变化来说不是线性的。通过z-buffer我们可以确定一个点是否在屏幕上显示(当点的深度值大于z-buffer中已有的深度值是,则该点没有被遮挡,即可以显示出来)。如下图所示,z-buffer值可以显示成一张图,图中每个像素点对应着我们实际图像中像素的深度值,即图中越白的地方距离我们越近,z值越大。
z-buffer算法过程(代码见附录):
初始化缓冲区,颜色缓冲区初始化为背景色,深度缓冲区被初始化为最大深度值。
-
计算每个三角形上每片元的z值,并与对应位置上的深度缓冲区中的值进行比较
如果z<=z-buffer(x, y)(即距离观察者更近),则需要同时修改两个缓冲区:将对应位置的颜色缓冲区的值修改为该片元的颜色,将对应位置的深度缓冲区的值修改为该片元的深度。即color(x,y) = color; z-buffer(x, y) = z。
上述3D图片经过z-buffer渲染之后得到如下2D图。在三维重建时,只重建了人脸部分,因此渲染回二维的图片时只有人脸部分。
2.2 三维人脸重建网络
我们的三维人脸重建网络如图所示,该网络将输入的RGB人脸图转换为position map图像,我们使用encoder-decoder结构来学习这个转换过程。
在encoder部分,输入图像经过一个卷积层之后,接着10个残差层块,将输入的2562563的RGB图转换成88512的特征图;在decoder部分,88512的特征图经过17个反卷积层生成2562563的position map。其中所有的卷积核大小都设为4,使用ReLU作为激活函数。position map 包括所有的3D信息和密对齐信息,我们不需要额外的网络去做另外的多任务处理。结构图如下图所示。
为了学习模型的参数,我们定义了一个新的损失函数来测量生成的position map和ground truth之间的差。如下图所示,权重mask表示position map中每个点的权重,根据我们的目标,我们将position map中的点分成四个部分(眼睛,鼻子,嘴巴,脸),没有部分共享他们自己的权重。人脸的68个脸部关键点享有最高的权重,以保证网络学习的准确率。脖子、头发和衣服等是不太关心的区域,以此设置为0。因此,损失函数定义为:
![](http://img.e-com-net.com/image/info10/99f2b196d1f349589cc152b5e96b7c0e.png)
权重比例设置68个关键点:(眼睛,鼻子,嘴巴):脸部:脖子=16:4:3:0。
2.3 网络结构主要代码解析
import tensorflow as tf
import tensorflow.contrib.layers as tcl
from tensorflow.contrib.framework import arg_scope
import numpy as np
size = 16
# x: s x s x 3
se = tcl.conv2d(x, num_outputs=size, kernel_size=4, stride=1) # 256 x 256 x 16
# 10 resblock described as followed
se = resBlock(se, num_outputs=size * 2, kernel_size=4, stride=2) # 128 x 128 x 32
se = resBlock(se, num_outputs=size * 2, kernel_size=4, stride=1) # 128 x 128 x 32
se = resBlock(se, num_outputs=size * 4, kernel_size=4, stride=2) # 64 x 64 x 64
se = resBlock(se, num_outputs=size * 4, kernel_size=4, stride=1) # 64 x 64 x 64
se = resBlock(se, num_outputs=size * 8, kernel_size=4, stride=2) # 32 x 32 x 128
se = resBlock(se, num_outputs=size * 8, kernel_size=4, stride=1) # 32 x 32 x 128
se = resBlock(se, num_outputs=size * 16, kernel_size=4, stride=2) # 16 x 16 x 256
se = resBlock(se, num_outputs=size * 16, kernel_size=4, stride=1) # 16 x 16 x 256
se = resBlock(se, num_outputs=size * 32, kernel_size=4, stride=2) # 8 x 8 x 512
se = resBlock(se, num_outputs=size * 32, kernel_size=4, stride=1) # 8 x 8 x 512
# 18 transpose conv
pd = tcl.conv2d_transpose(se, size * 32, 4, stride=1) # 8 x 8 x 512
pd = tcl.conv2d_transpose(pd, size * 16, 4, stride=2) # 16 x 16 x 256
pd = tcl.conv2d_transpose(pd, size * 16, 4, stride=1) # 16 x 16 x 256
pd = tcl.conv2d_transpose(pd, size * 16, 4, stride=1) # 16 x 16 x 256
pd = tcl.conv2d_transpose(pd, size * 8, 4, stride=2) # 32 x 32 x 128
pd = tcl.conv2d_transpose(pd, size * 8, 4, stride=1) # 32 x 32 x 128
pd = tcl.conv2d_transpose(pd, size * 8, 4, stride=1) # 32 x 32 x 128
pd = tcl.conv2d_transpose(pd, size * 4, 4, stride=2) # 64 x 64 x 64
pd = tcl.conv2d_transpose(pd, size * 4, 4, stride=1) # 64 x 64 x 64
pd = tcl.conv2d_transpose(pd, size * 4, 4, stride=1) # 64 x 64 x 64
pd = tcl.conv2d_transpose(pd, size * 2, 4, stride=2) # 128 x 128 x 32
pd = tcl.conv2d_transpose(pd, size * 2, 4, stride=1) # 128 x 128 x 32
pd = tcl.conv2d_transpose(pd, size, 4, stride=2) # 256 x 256 x 16
pd = tcl.conv2d_transpose(pd, size, 4, stride=1) # 256 x 256 x 16
pd = tcl.conv2d_transpose(pd, 3, 4, stride=1) # 256 x 256 x 3
pd = tcl.conv2d_transpose(pd, 3, 4, stride=1) # 256 x 256 x 3
pos = tcl.conv2d_transpose(pd, 3, 4, stride=1, activation_fn = tf.nn.sigmoid)#, padding='SAME', weights_initializer=tf.random_normal_initializer(0, 0.02))
return pos
resblock 代码块:
def resBlock(x, num_outputs, kernel_size = 4, stride=1, activation_fn=tf.nn.relu, normalizer_fn=tcl.batch_norm, scope=None):
assert num_outputs%2==0 #num_outputs must be divided by channel_factor(2 here)
with tf.variable_scope(scope, 'resBlock'):
shortcut = x
if stride != 1 or x.get_shape()[3] != num_outputs:
shortcut = tcl.conv2d(shortcut, num_outputs, kernel_size=1, stride=stride,
activation_fn=None, normalizer_fn=None, scope='shortcut')
x = tcl.conv2d(x, num_outputs/2, kernel_size=1, stride=1, padding='SAME')
x = tcl.conv2d(x, num_outputs/2, kernel_size=kernel_size, stride=stride, padding='SAME')
x = tcl.conv2d(x, num_outputs, kernel_size=1, stride=1, activation_fn=None, padding='SAME', normalizer_fn=None)
x += shortcut
x = normalizer_fn(x)
x = activation_fn(x)
return x
三、人脸融合过程
将模板图(图5)的脸融合到源图(图6)的脸上。
过程:将模板图的转成原图的姿势,然后粘贴在原图的脸上。
step1:获得原图的姿势,将原图经过转换网络得到position map,通过position map得到顶点的信息。
pos = prn.process(image)
vertices = prn.get_vertices(pos)
step2:使用相同的方法得到模板图的position,进而得到其color。
ref_image = imread(args.ref_path)
ref_pos = prn.process(ref_image)
ref_vertices = prn.get_vertices(ref_pos)
new_texture = ref_texture#(texture + ref_texture)/2.
new_colors = prn.get_colors_from_texture(new_texture)
step3:将模板人脸姿态转换成原图人脸的姿态。使用原图的顶点信息和模板图的color,及其三角形可以渲染成2D的人脸姿态,如下图所示。
new_image = render_texture(vertices.T, new_colors.T, prn.triangles.T, h, w, c = 3)
# 注意:使用的是源图的顶点信息和模板图的颜色信息,说明顶点信息包含了姿态,而颜色信息表明任务的主要特征
step4:获得原图人脸的mask图,以便拼图。
## 将姿态部分设为1,其他部分设为0
vis_colors = np.ones((vertices.shape[0], 1))
face_mask = render_texture(vertices.T, vis_colors.T, prn.triangles.T, h, w, c = 1)
step5:将渲染之后的2D人脸姿态贴在原图的脸上,如下图所示。
new_image = image*(1 - face_mask[:,:,np.newaxis]) + new_image*face_mask[:,:,np.newaxis]
step6:通过OpenCV将颜色调和一下。
vis_ind = np.argwhere(face_mask>0)
vis_min = np.min(vis_ind, 0)
vis_max = np.max(vis_ind, 0)
center = (int((vis_min[1] + vis_max[1])/2+0.5), int((vis_min[0] vis_max[0])/2+0.5))
output = cv2.seamlessClone((new_image*255).astype(np.uint8), (image*255).astype(np.uint8), (face_mask*255).astype(np.uint8)
四、总结
涉及到的技术领域
1. 人脸三维重建
2. 从三维图像到二维人脸的渲染过程
3. 深度学习
参考文献
[1] Joint 3D Face Reconstruction and Dense Alignment with Position Map Regression Network.
[2] Regressing Robust and Discriminative 3D Morphable Models with a very Deep Neural Network.
[3] BFM
[4] Face Alignment Across Large Poses: A 3D Solution
附录
UV空间坐标
UV空间坐标是指所有的图像文件是二维的一个平面,水平方向是U(对应于x轴),垂直方向是V(对应于y轴)。有助于将图像纹理贴图在3D的曲面上。UV作为标记点,用于控制纹理贴图上的像素点与网格中的顶点对应。
本文的position map是 UV position map是一个二维的平面图像,可以表示图像在UV空间中所有点的坐标。
本文的UV position map的生成过程如下:
伪代码:
输入:二维人脸图片,顶点, 三角形,颜色
输出:对应的position map
### 三角形triangle的shape为(num, 3)
### 每行的三个元素代表顶点vertices中的索引
for i in range(三角形个数):
获取表示三角形的相应顶点的索引,tri1(x1, y1), tri2(x2, y2), tri3(x3, y3)
在顶点坐标,0和图像长宽中,取横坐标的最大最小值vmin, vmax,纵坐标的最大最小值umin, umax
for x in range(vmin, vmax+1):
for y in range(umin, umax+1):
获取点(x, y)和三个顶点组成的立体图形的重心坐标相对于三个顶点的权重w1, w2, w3
获取点的深度信息,即三个顶点深度信息的权重之和
判断该点的深度信息与缓存区的深度信息的大小,并用较高的深度值更新缓存区的深度值。
输出图像点(x, y)的RGB值为三个顶点的三颜色的加权和
### 函数
def render_colors(vertices, triangles, colors, h, w, c = 3):
''' render mesh with colors
Args:
vertices: [nver, 3]
triangles: [ntri, 3]
colors: [nver, 3]
h: height
w: width
Returns:
image: [h, w, c].
'''
assert vertices.shape[0] == colors.shape[0]
# initial
image = np.zeros((h, w, c))
depth_buffer = np.zeros([h, w]) - 999999.
colors = np.array(colors)
vertices = np.array(vertices)
# triangles = np.array(triangles)
for i in range(triangles.shape[0]):
tri = triangles[i, :] # 3 vertex indices
# the inner bounding box
umin = max(int(np.ceil(np.min(vertices[tri, 0]))), 0)
umax = min(int(np.floor(np.max(vertices[tri, 0]))), w-1)
vmin = max(int(np.ceil(np.min(vertices[tri, 1]))), 0)
vmax = min(int(np.floor(np.max(vertices[tri, 1]))), h-1)
if umax depth_buffer[v, u]:
depth_buffer[v, u] = point_depth
image[v, u, :] = w0*colors[tri[0], :] + w1*colors[tri[1], :] + w2*colors[tri[2], :]
return image
求立体图形的重心权重
## 获得重心权重的函数
def get_point_weight(point, tri_points):
''' Get the weights of the position
Args:
point: (2,). [u, v] or [x, y]
tri_points: (3 vertices, 2 coords). three vertices(2d points) of a triangle.
Returns:
w0: weight of v0
w1: weight of v1
w2: weight of v3
'''
tp = np.array(tri_points)
# vectors
v0 = tp[2,:] - tp[0,:]
v1 = tp[1,:] - tp[0,:]
v2 = np.array(point) - tp[0,:]
# dot products
dot00 = np.dot(v0.T, v0) # distance of p2 and p0
dot01 = np.dot(v0.T, v1) #
dot02 = np.dot(v0.T, v2)
dot11 = np.dot(v1.T, v1)
dot12 = np.dot(v1.T, v2)
# barycentric coordinates
if dot00*dot11 - dot01*dot01 == 0:
inverDeno = 0
else:
inverDeno = 1/(dot00*dot11 - dot01*dot01)
u = (dot11*dot02 - dot01*dot12)*inverDeno
v = (dot00*dot12 - dot01*dot02)*inverDeno
w0 = 1 - u - v
w1 = v
w2 = u
return w0, w1, w2
texture渲染
def render_texture(vertices, colors, triangles, h, w, c = 3):
''' render mesh by z buffer
# 初始化渲染好的二维图像
image = np.zeros((h, w, c))
# 初始化缓存区
depth_buffer = np.zeros([h, w]) - 999999.
# 定义每个三角形的深度:组成三角形的每个顶点的深度的平均值
tri_depth = (vertices[2, triangles[0,:]] + vertices[2,triangles[1,:]] + vertices[2, triangles[2,:]])/3.
# 定义每个三角形的颜色:组成三角形的每个顶点的颜色的平均值
tri_tex = (colors[:, triangles[0,:]] + colors[:,triangles[1,:]] + colors[:, triangles[2,:]])/3.
for i in range(triangles.shape[1]):
tri = triangles[:, i] # 顶点的三个索引值
# the inner bounding box
umin = max(int(np.ceil(np.min(vertices[0,tri]))), 0)
umax = min(int(np.floor(np.max(vertices[0,tri]))), w-1)
vmin = max(int(np.ceil(np.min(vertices[1,tri]))), 0)
vmax = min(int(np.floor(np.max(vertices[1,tri]))), h-1)
if umax depth_buffer[v, u] and isPointInTri([u,v], vertices[:2, tri]):
depth_buffer[v, u] = tri_depth[i]
image[v, u, :] = tri_tex[:, i] ### 与color渲染的不同之处
return image