文章目录
组织架构
face3d 是一个基于python的3DMM工具包, 有好几个代码, 先介绍第一个代码pipeline.py
代码分析
pipeline.py
- 代码是将3d model变为2d图片
1. 载入网格数据example1.mat
example1.mat,由点,三角,颜色,纹理组成。
这里用颜色来代表面部纹理。
from skimage import io
from time import time
import matplotlib.pyplot as plt
sys.path.append('..')
import face3d
from face3d import mesh
# ------------------------------ 1. load mesh data
# -- mesh data consists of: vertices, triangles, color(optinal), texture(optional)
# -- here use colors to represent the texture of face surface
C = sio.loadmat('Data/example1.mat')
vertices = C['vertices']; colors = C['colors']; triangles = C['triangles']
colors = colors/np.max(colors) # 归一化, 其实最大值已经是1了,np.max取得是全部中的最大值
- 查看exmple1.mat文件的keys
>example.keys()
>>dict_keys(['__header__',
'__version__', '__globals__', 'vertices',
'colors', 'triangles', 'full_triangles'])
数据以字典形式存储。各个key的shape:
通过BFM形状模型和表情模型,可以得到最终的3D点云的图像坐标(共53215个),每个点有x,y,z 3个坐标,共有53215x3个值
2. 顶点变换
# ------------------------------ 2. modify vertices(transformation. change position of obj)
# -- change the position of mesh object in world space
# scale. target size=180 for example
s = 180/(np.max(vertices[:,1]) - np.min(vertices[:,1]))
# 这里是y的最大值减去最小值
# rotate 30 degree for example
R = mesh.transform.angle2matrix([0, 30, 0])
# no translation. center of obj:[0,0]
# 位移矩阵
t = [0, 0, 0]
transformed_vertices = mesh.transform.similarity_transform(vertices, s, R, t) # 相似变换, 表示3d坐标旋转平移后的位置
# 3D: s*R.dot(X) + t
旋转矩阵R由下述函数计算。原理在此可点击查看.
def angle2matrix(angles): # 逆时针旋转
''' get rotation matrix from three rotation angles(degree). right-handed.
Args:
angles: [3,]. x, y, z angles
x: pitch. positive for looking down.
y: yaw. positive for looking left.
z: roll. positive for tilting head right.
Returns:
R: [3, 3]. rotation matrix.
'''
x, y, z = np.deg2rad(angles[0]), np.deg2rad(angles[1]), np.deg2rad(angles[2])
# x
Rx=np.array([[1, 0, 0],
[0, cos(x), -sin(x)],
[0, sin(x), cos(x)]])
# y
Ry=np.array([[ cos(y), 0, sin(y)],
[ 0, 1, 0],
[-sin(y), 0, cos(y)]])
# z
Rz=np.array([[cos(z), -sin(z), 0],
[sin(z), cos(z), 0],
[ 0, 0, 1]])
R=Rz.dot(Ry.dot(Rx))
return R.astype(np.float32)
下面是相似变换的求法, 是对于顶点坐标而言的:
def similarity_transform(vertices, s, R, t3d):
''' similarity transform. dof = 7.
3D: s*R.dot(X) + t
Homo: M = [[sR, t],[0^T, 1]]. M.dot(X)
Args:(float32)
vertices: [nver, 3].
s: [1,]. scale factor.
R: [3,3]. rotation matrix.
t3d: [3,]. 3d translation vector.
Returns:
transformed vertices: [nver, 3]
'''
t3d = np.squeeze(np.array(t3d, dtype = np.float32))
transformed_vertices = s * vertices.dot(R.T) + t3d[np.newaxis, :] # np.newaxis增加维度, 然后又有广播机制
return transformed_vertices
3. 颜色,纹理变换
- 前置知识
简单光照模型
假设物体不透明,那么物体表面呈现的颜色仅由其反射光决定。讨论不包含透射光的简单光照明模型。该模型为计算物体表面某点处的光强度提供了简单有效的途径,并在许多应用场合获得了较好的效果。该模型构建在下列假设之上:
白光照射———各波长光比例相同
仅考虑光源直接照射在景物表面所产生的光照效果
景物表面通常被假定为不透明,且具有均匀反射率
由假设知物体不透明则物体表面呈现的颜色仅由其反射光决定。
反射光组成:
- 环境反射
环境反射假定入射光均匀地从周围环境入射至景物表面并等量地向各个方向反射出去 - 漫反射与镜面反射
漫反射分量和镜面反射分量则表示特定光源照射在景物表面上产生的反射光
要计算某一点的光亮度,就要分别求出这三个分量。
- 环境反射光
环境反射光由环境光在邻近物体上经过多次反射所产生的。光是来自四面八方的,例如从墙壁,地板及天花板等反射回来的光,可以看作是一种分布式光源。
特点:照射在物体上的光来自周围各个方向,又均匀地向各个方向反射。
这种光产生的效应简化为:它在各个方向都有均匀的光强度 I e I_e Ie ,即某一个可见物体在仅有环境光照明的条件下,其上各点明暗程度完全一样。
- 漫反射光
一个比较粗糙的,无光泽的物体表面对光的反射表现为漫反射. 漫反射分量表示特定点光源在景物表面某一点的反射光中那些向空间各方向均匀反射出去的光.这种表面对入射光在各个方向上都有强度相同的反射,因而无论从哪个角度观察,这一点的光亮度都是相同的。
郎伯(Lambert)余弦定律:对于一个漫反射体,表面的反射光亮度和光源入射角(入射光线和表面法向量的夹角)的余弦成正比,即:
对于点光源, 漫反射应该注意:
- 任一时间内,点光源发出的光都是处在一个球面上,随着时间的增大,球面半径增大,光或者说能量的总额不变,单位面积的能量在减少,具体关系为I/(r*r)
光源方向是 l l l, 法线方向是 n n n, 观测方向是 v v v, I/(r*r)是到达shading point的能量
n和l两个单位向量的点乘得到夹角θ , max(0,nl)得到的是接收到的能量
之所以和0取最大值,是为了去除负值的情况.比如从下面入射的光线,没有物理意义,排除掉
- 镜面反射
镜面反射光为朝一定方向的反射光
根据光的反射定律,反射光和入射光对称地分布于表面法向的两侧。
光滑明暗处理技术或者叫做着色方式
Gouraud明暗处理技术(Gouraud Shaing )
只在多边形顶点处按(Phong模型)准确计算光亮度,而对于多边形内各点用顶点的光亮度线性插值算出
右上图中某个四边形的四个顶点处的法向量相等,则会导致四个顶点处的光亮度相等,采用Gouraud明暗处理技术将使多边形面片内各点光亮度取常数,这显然是不正确的。对这种情况的处理方式是把多边形面片分割成更细小的多边形。由于这些多边形起到了过渡作用,因而避免了错误。
Phong明暗处理技术 Phong Shading(冯氏着色)
下面是代码部分
增加点光(光从某一点发出)。光的位置定义在世界坐标里。
# set lights
# 点光源在世界坐标系的坐标
light_positions = np.array([[-128, -128, 300]])
# 点光源的强度
light_intensities = np.array([[1, 1, 1]])
# 在已定义的点光下,变换颜色
lit_colors = mesh.light.add_light(transformed_vertices, triangles, colors, light_positions, light_intensities)
- 在已定义的点光下,变换颜色的函数如下
def add_light(vertices, triangles, colors, light_positions = 0, light_intensities = 0):
''' Gouraud shading. add point lights.
In 3d face, usually assume:
1. 反射面是朗伯氏的, 只反射低频率的光
2. 照明可以是点光源的任意组合
3. No specular 没有镜面
Args:
vertices: [nver, 3]
triangles: [ntri, 3]
light_positions: [nlight, 3]
light_intensities: [nlight, 3]
Returns:
lit_colors: [nver, 3]
'''
nver = vertices.shape[0]
normals = get_normal(vertices, triangles) # [nver, 3]
# ambient(环境光反射)
# La = ka*Ia
# diffuse(漫反射光)
# Ld = kd*(I/r^2)max(0, n*l)
direction_to_lights = vertices[np.newaxis, :, :] - light_positions[:, np.newaxis, :] # [nlight, nver, 3]
direction_to_lights_n = np.sqrt(np.sum(direction_to_lights**2, axis = 2)) # [nlight, nver]
direction_to_lights = direction_to_lights/direction_to_lights_n[:, :, np.newaxis]
normals_dot_lights = normals[np.newaxis, :, :]*direction_to_lights # [nlight, nver, 3]
normals_dot_lights = np.sum(normals_dot_lights, axis = 2) # [nlight, nver]
diffuse_output = colors[np.newaxis, :, :]*normals_dot_lights[:, :, np.newaxis]*light_intensities[:, np.newaxis, :]
diffuse_output = np.sum(diffuse_output, axis = 0) # [nver, 3]
# specular(镜面反射)
# h = (v + l)/(|v + l|) bisector
# Ls = ks*(I/r^2)max(0, nxh)^p
# increasing p narrows the reflectionlob
lit_colors = diffuse_output # only diffuse part here.
lit_colors = np.minimum(np.maximum(lit_colors, 0), 1)
return lit_colors
- 这里的着色方式是高氏着色法。其中需要求拓扑图下各个点的法向量,如下:
def get_normal(vertices, triangles):
''' calculate normal direction in each vertex
Args:
vertices: [nver, 3]
triangles: [ntri, 3]
Returns:
normal: [nver, 3]
'''
# triangls 是构成三角形点的索引
pt0 = vertices[triangles[:, 0], :] # [ntri, 3]
pt1 = vertices[triangles[:, 1], :] # [ntri, 3]
pt2 = vertices[triangles[:, 2], :] # [ntri, 3]
# np.cross向量积, 向量积又称外积、叉积(Cross product), 叉积的结果是一个垂直于两个向量平面的向量, 也就是法向量
tri_normal = np.cross(pt0 - pt1, pt0 - pt2) # [ntri, 3]. normal of each triangle
normal = np.zeros_like(vertices) # [nver, 3]
# 这里是将每个面的面法线都加到这个面的每一个顶点上
for i in range(triangles.shape[0]):
normal[triangles[i, 0], :] = normal[triangles[i, 0], :] + tri_normal[i, :]
normal[triangles[i, 1], :] = normal[triangles[i, 1], :] + tri_normal[i, :]
normal[triangles[i, 2], :] = normal[triangles[i, 2], :] + tri_normal[i, :]
# normalize to unit length
mag = np.sum(normal**2, 1) # [nver]
zero_ind = (mag == 0)
mag[zero_ind] = 1; #如果有法线为(0,0,0)的 将其变成1
normal[zero_ind, 0] = np.ones((np.sum(zero_ind)))
normal = normal/np.sqrt(mag[:,np.newaxis])
return normal
- 顶点变换
# ------------------------------ 4. modify vertices(projection. change position of camera)
# -- transform object from world space to camera space(what the world is in the eye of observer).
# -- omit if using standard camera, 如果用规范相机可以忽略
camera_vertices = mesh.transform.lookat_camera(transformed_vertices, eye = [0, 0, 200], at = np.array([0, 0, 0]), up = None)
# -- project object from 3d world space into 2d image plane. orthographic or perspective projection 将物体从3D世界坐标投影到2D投影面,可以是正交投影,也可以透视投影。
projected_vertices = mesh.transform.orthographic_project(camera_vertices)
- 世界坐标系到相机坐标系的转换函数
def lookat_camera(vertices, eye, at = None, up = None):
""" 'look at' transformation: from world space to camera space
标准相机空间:
相机位于原点。
looking down negative z-axis. 负Z轴向下
vertical vector is y-axis.垂直向量为Y轴。
Xcam = R(X - C)
Homo: [[R, -RC], [0, 1]]
Args:
vertices: [nver, 3]
eye: [3,] 相机的XYZ坐标,
at: [3,] 相机中心所在的xyz坐标
up: [3,] up direction
Returns:
transformed_vertices: [nver, 3]
"""
if at is None:
at = np.array([0, 0, 0], np.float32)
if up is None:
up = np.array([0, 1, 0], np.float32)
eye = np.array(eye).astype(np.float32)
at = np.array(at).astype(np.float32)
z_aixs = -normalize(at - eye) # look forward
x_aixs = normalize(np.cross(up, z_aixs)) # look right
y_axis = np.cross(z_aixs, x_aixs) # look up
R = np.stack((x_aixs, y_axis, z_aixs))#, 旋转矩阵# 3 x 3
transformed_vertices = vertices - eye # 平移变换
transformed_vertices = transformed_vertices.dot(R.T) # 旋转变换
return transformed_vertices
- 将物体从3D世界坐标投影到2D投影面,可以是正交投影,也可以透视投影。此时其实仅剩下X,Y坐标,保留Z坐标,只是为了后面使用方便。此处使用正交投影,
def orthographic_project(vertices):
''' scaled orthographic projection(just delete z)
assumes: variations in depth over the object is small relative to the mean distance from camera to object
x -> x*f/z, y -> x*f/z, z -> f. 这是比例关系
for point i,j. zi~=zj. so just delete z
** often used in face
Homo: P = [[1,0,0,0], [0,1,0,0], [0,0,1,0]]
Args:
vertices: [nver, 3]
Returns:
projected_vertices: [nver, 3] if isKeepZ=True. [nver, 2] if isKeepZ=False.
'''
return vertices.copy()
5. 渲染
渲染是将一个三维的物体投影到一个二维的平面上。即使用二维平面来表示三维图形。也就是已知三维图像的顶点、颜色和三角形信息,得到对应三维图像的二维平面图。
3D空间中点的坐标是(x, y, z), z-buffer保存的是图像内顶点的z坐标值(即顶点的第三维坐标值)。
投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z坐标的分辨率较大,反之较小,也就是说,投影后的z坐标在其值域上对于离开眼睛的物理距离变化来说不是线性的。通过z-buffer我们可以确定一个点是否在屏幕上显示(当点的深度值大于z-buffer中已有的深度值是,则该点没有被遮挡,即可以显示出来)。如下图所示,z-buffer值可以显示成一张图,图中每个像素点对应着我们实际图像中像素的深度值,即图中越白的地方距离我们越近,z值越大。
下面再详细介绍一下z-buffer
Z缓冲器算法也叫深度缓冲器算法,属于图像空间消隐算法
该算法有帧缓冲器和深度缓冲器。对应两个数组:
intensity(x,y) ——属性数组(帧缓冲器), 存储图像空间每个可见像素的光强或颜色
depth(x,y)——深度数组(z-buffer), 存放图像空间每个可见像素的z坐标
假定xoy面为投影面,z轴为 观察方向
过屏幕上任意像素点(x,y) 作平行于z轴的射线R,与物 体表面相交于p1和p 2 点
p1和p2点的z值称为该点的深度值
z-buffer算法比较p1和p2的z值, 将最大的z值存入z缓冲器中
显然,p1在p2前面,屏幕上(x,y) 这一点将显示p1点的颜色
- 算法思想:
-
初始化缓冲区,颜色缓冲区初始化为背景色,深度缓冲区被初始化为最大深度值。
-
计算每个三角形上每片元的z值,并与对应位置上的深度缓冲区中的值进行比较
-
如果z<=z-buffer(x, y)(即距离观察者更近),则需要同时修改两个缓冲区:将对应位置的颜色缓冲区的值修改为该片元的颜色,将对应位置的深度缓冲区的值修改为该片元的深度。即color(x,y) = color; z-buffer(x, y) = z。
下面是代码部分:
# ------------------------------ 5. render(to 2d image)
# set h, w of rendering
h = w = 256
# change to image coords for rendering, 这个是得到了图像坐标系中的坐标
image_vertices = mesh.transform.to_image(projected_vertices, h, w)
# render
rendering = mesh.render.render_colors(image_vertices, triangles, lit_colors, h, w)
- 下面具体看看转到图像坐标系的过程:
def lookat_camera(vertices, eye, at = None, up = None):
""" 'look at' transformation: from world space to camera space
标准相机空间:
相机位于原点。
looking down negative z-axis. 负Z轴向下
vertical vector is y-axis.垂直向量为Y轴。
Xcam = R(X - C)
Homo: [[R, -RC], [0, 1]]
Args:
vertices: [nver, 3]
eye: [3,] 相机的XYZ坐标,
at: [3,] 相机中心所在的xyz坐标
up: [3,] up direction
Returns:
transformed_vertices: [nver, 3]
"""
if at is None:
at = np.array([0, 0, 0], np.float32)
if up is None:
up = np.array([0, 1, 0], np.float32)
eye = np.array(eye).astype(np.float32)
at = np.array(at).astype(np.float32)
z_aixs = -normalize(at - eye) # look forward
x_aixs = normalize(np.cross(up, z_aixs)) # look right
y_axis = np.cross(z_aixs, x_aixs) # look up
R = np.stack((x_aixs, y_axis, z_aixs))#, 旋转矩阵# 3 x 3
transformed_vertices = vertices - eye # 平移变换
transformed_vertices = transformed_vertices.dot(R.T) # 旋转变换
return transformed_vertices
- 对应图片的每个像素点都有3个色道RGB, 返回image: [h, w, c]
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]
# 初始化二维图像
image = np.zeros((h, w, c))
# 初始化缓冲区
depth_buffer = np.zeros([h, w]) - 999999.
for i in range(triangles.shape[0]):
tri = triangles[i, :] # 3顶点索引
# the inner bounding box, 限制一下不要超过渲染的图片范围
# 因为对于每个三角形都需要看看渲染的图片中有没有对应的点,如果有就需要额外的计算颜色,
# 没有的就是黑色,也就是zeros 所以可以减少一些循环
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<umin or vmax<vmin:
continue
for u in range(umin, umax+1):
for v in range(vmin, vmax+1):
if not isPointInTri([u,v], vertices[tri, :2]):
# 如果图像上的点不是人脸的点, 就忽略
continue
w0, w1, w2 = get_point_weight([u, v], vertices[tri, :2])
# 每个三角形的深度是每个顶点的平均值
point_depth = w0*vertices[tri[0], 2] + w1*vertices[tri[1], 2] + w2*vertices[tri[2], 2]
if point_depth > depth_buffer[v, u]:
# 更新z-buffer的值
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
Methods: https://gamedev.stackexchange.com/questions/23743/whats-the-most-efficient-way-to-find-barycentric-coordinates
-m1.compute the area of the triangles formed by embedding the point P inside the triangle
-m2.Christer Ericson's book "Real-Time Collision Detection". faster.(used)
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 = tri_points
# vectors
v0 = tp[2,:] - tp[0,:]
v1 = tp[1,:] - tp[0,:]
v2 = point - tp[0,:]
# dot products
dot00 = np.dot(v0.T, v0)
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
- 然后展示图片
save_folder = 'results/pipline'
if not os.path.exits(save_folder):
os.mkdir(save_folder)
io.imsave('{}/rendering.jpg'.format(save_folder), rendering)
6. 总结
-
本节我们了解了如何从一个3维的模型渲染到2维图片, 对于三维模型来说, 顶点, 三角形索引(纹理), 颜色是三个必不可少的东西, 这里的
example1.mat
是正面的一个人脸, 可以在初始时定义旋转的度数, 位移的长度,人脸的大小等, 然后会对3维的点进行变换 -
光强会对原来3d点的颜色产生影响, 所以点光源的位置和强度影响的是原来存储的颜色的变化, 通过漫反射和环境光得到变换后的颜色, 因为假设没有镜面所以没有镜面反射, 源代码作者还搞笑的说除非你皮肤上全是油哈哈哈哈
-
然后是坐标系的变换, 因为现在是3d点, 需要变换到2d点, 也就是从世界坐标系变换到图像坐标系, 变换原理我也提到过:变换原理, 因为这里3d的坐标系0也是左上角, 所以其实就是把z去掉了而已, 这里的图像坐标系感觉更应该叫像素坐标系
-
最后选定要渲染的图片的大小, 注意人脸的大小不是图片的大小, 人脸的大小在之前算3d点的位置变换的时候已经确定了, 现在是选定方形的的要成像的图片的大小
-
然后遍历顶点, 一旦在图片像素位置有人脸的坐标就计算其颜色, 没有的就是黑色, 也就是zeros