提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
之前由于工作需求,需要对照片上的人脸进行处理,实现某种特定的人脸特效,例如变老特效。之后自己寻找相关项目,也很难找到在unity上用C#实现的例子(本人只对C++、C#比较熟悉),所以便自己开始研究,最后终于实现了。本文意在记录自己的实现历程,当然希望也能为想实现相关效果的朋友提供思路!
下文我将以实现人脸变老特效作为例子,来解释如何一步步实现。
一、实现人脸纹理的变形
在搜集了许多资料后,我发现人脸变老要么就是用训练好的模型来实现,要么就是直接覆盖人脸的皱纹纹理,而我选择了比较容易实现的后者。之后在掘金上找到类似的实现:https://juejin.cn/post/6844903862881550344
我们知道,每张照片上的人脸大小、轮廓都各不相同。所以首先,我们要实现根据照片的人脸不同,人脸纹理也要进行适当的变形,可以使用仿射变换,而我使用的是透视变换。当然,在进行变换之前,我们还得对人脸的特征点进行识别,所以需要OpenCV for Unity和Dlib FaceLandmark Detector两个包。
1.实现
实现代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using OpenCVForUnity;
using DlibFaceLandmarkDetector;
using OpenCVForUnity.CoreModule;
using OpenCVForUnity.Calib3dModule;
using OpenCVForUnity.ImgprocModule;
public class TextureBlender : MonoBehaviour
{
[Header("输入输出")]
public Texture2D srcFace;//需处理的人脸图像
Texture2D copyTexture;//人脸图像的拷贝
public Texture2D effectFace;//人脸纹理适配的人脸图像
public Texture2D effect;//人脸纹理
public RawImage rawImg;
//人脸检测器
FaceLandmarkDetector faceLandmarkDetector;
string dlibShapePredictorFileName = "sp_human_face_68.dat";
//图像矩阵
Mat srcMat;
Mat copyMat;
Mat effectMat;
void Start()
{
//初始化人脸检测器
faceLandmarkDetector = new FaceLandmarkDetector(DlibFaceLandmarkDetector.UnityUtils.Utils.getFilePath(dlibShapePredictorFileName));
if (faceLandmarkDetector == null)
{
Debug.LogError("faceLandmarkDetector is not found");
}
else
{
Debug.Log("faceLandmarkDetector is found");
}
rawImg = GetComponent<RawImage>();
rawImg.texture = srcFace;
rawImg.SetNativeSize();
}
public void BecomeOld()//通过点击事件调用
{
Load();//转化图像为矩阵,之后处理需要用到
ImageBlend();//混合人脸纹理与需处理的人脸图像
}
void Load()
{
srcMat = new Mat(srcFace.height, srcFace.width, CvType.CV_8UC4);
OpenCVForUnity.UnityUtils.Utils.texture2DToMat(srcFace, srcMat);
effectMat = new Mat(effect.height, effect.width, CvType.CV_8UC4);
OpenCVForUnity.UnityUtils.Utils.texture2DToMat(effect, effectMat);
//拷贝需处理的人脸图像
copyTexture = new Texture2D(srcFace.width, srcFace.height, TextureFormat.RGBA32, false);
copyTexture.SetPixels32(srcFace.GetPixels32());
copyTexture.Apply();
copyMat = new Mat(copyTexture.height, copyTexture.width, CvType.CV_8UC4);
OpenCVForUnity.UnityUtils.Utils.texture2DToMat(copyTexture, copyMat);
}
void ImageBlend()
{
//获取变换后的人脸纹理矩阵
Mat targetMat = new Mat(srcFace.height, srcFace.width, CvType.CV_8UC4);
targetMat = GetWarpedTexture();
if (targetMat == null) return;
//图像混合
for(int i = 0; i < srcMat.rows(); i++)
{
for(int j = 0; j < srcMat.cols(); j++)
{
double[] color1 = targetMat.get(i, j);
if (color1[3] > 0)//不透明度大于0
{
double[] color2 = srcMat.get(i, j);
//像素表现的颜色值人脸纹理与源人脸的权重为0.7:0.3
double r = color1[0] * 0.7 + color2[0] * 0.3;
double g = color1[1] * 0.7 + color2[1] * 0.3;
double b = color1[2] * 0.7 + color2[2] * 0.3;
double[] color = new double[] { r, g, b, 255 };
copyMat.put(i, j, color);
color = null;
}
}
}
//输出混合后的图像
OpenCVForUnity.UnityUtils.Utils.matToTexture2D(copyMat, copyTexture);
rawImg.texture = copyTexture;
}
Mat GetWarpedTexture()//返回变换后的人脸纹理矩阵
{
//源图像人脸识别
faceLandmarkDetector.SetImage(srcFace);
List<UnityEngine.Rect> srcFaces = faceLandmarkDetector.Detect();
List<Point> srcPoints = new List<Point>();
if (srcFaces.Count == 0)
{
Debug.Log("no faces detected in srcFace");
return null;
}
else
{
List<Vector2> points = faceLandmarkDetector.DetectLandmark(srcFaces[0]);
for(int i = 0; i < points.Count; i++)
{
srcPoints.Add(new Point(points[i].x, points[i].y));
}
}
//纹理人脸识别
faceLandmarkDetector.SetImage(effectFace);
List<UnityEngine.Rect> effectFaces = faceLandmarkDetector.Detect();
List<Point> effectPoints = new List<Point>();
if (effectFaces.Count == 0)
{
Debug.Log("no faces found in effectFace");
return null;
}
else
{
List<Vector2> points = faceLandmarkDetector.DetectLandmark(effectFaces[0]);
for(int i = 0; i < points.Count; i++)
{
effectPoints.Add(new Point(points[i].x, points[i].y));
}
}
//求单应矩阵
Mat warpMat = new Mat();
//用68个特征点拟合求最优单应矩阵
warpMat = Calib3d.findHomography(new MatOfPoint2f(effectPoints.ToArray()), new MatOfPoint2f(srcPoints.ToArray()));
//对人脸纹理进行透视变换
Mat targetMat = new Mat();
Imgproc.warpPerspective(effectMat, targetMat, warpMat, srcMat.size());
return targetMat;
}
}
项目所在的位置一定不能有中文路径,不然会报错加载失败sp_human_face_68.dat这个文件。
2.效果
下面看一下实现的效果:
首先我先找一张正脸的人脸,然后在PS上照着他的脸绘制一个简单的黑色纹理。
输出这个图层,得到人脸纹理(注意:图片分辨率,必须与适配人脸的分辨率一致,否则之后的人脸变换会出问题)。
导入图片到unity内(注意:Advanced内的Read/Write记得要勾上,Compression我是改成none的,最好也改一下)。
放入程序运行,需处理的人脸就是正在显示的这张:
处理后,黑色的人脸纹理就附在上面了,效果还是可以的。
二、实现人脸纹理的自然融合
掘金的项目为我提供了思路,可以使用类似ps里柔光的混合模式来处理人脸和皱纹纹理的图片,使他们能够比较自然的融合在一起,但柔光的混合模式具体底层是如何实现的,我还是需要找相关的资料,最后在知乎上找到了对混合模式解释得不错的文章:https://zhuanlan.zhihu.com/p/643960643
1.相关知识
这里我就讲一下我们需要用到的知识:
图层、颜色
图层混合模式 一共涉及三个图层分别是
1、基础图层或者叫做底图就是下方的图层,我们使用英文单词below的开头字母B来表示,符号是LayerB
2、混合图层或者叫做调整图层或绘画图层混合图层或者叫绘画图层,就是上方的图层,我们使用英文单词above的开头字母A来表示,符号是LayerA
3、结果图层就是以何种方式处理之后的结果,他是通过LayerB和LayerA的结合来表示。
那也就是说, LayerC=BlendMode(LayerB,LayerA)
具体到一个像素点上,就是c=BlendMode(b,a),其中c为结果色,b为混合色,a为基色
不透明度、填充
但是,某个像素表现出来的颜色,除了跟结果色有关,还跟另外两个值有关,一个是不透明度(Opacity),另一个就是填充(fill)。
具体两者是如何相互影响的,不同的混合模式有不同的公式,但两者一起产生效果的原则公式如下:
Fill(b,a)=BlendMode(b,a * fill)
Opacity(b,a)=op * b+(1 - op) * Fill(b,a)
最后输出的这个Opacity(b,a)就是像素表现的颜色,由以上公式我们也可知道填充影响混合模式的效果,填充越小,混合模式越弱。而不透明度则是底图和结果图层的权重,以此控制结果图层表现出来的效果。
柔光(Soft Light)计算公式
那么柔光这种混合模式的底层计算公式是什么呢,经过一番寻找,在知乎这篇文章上https://zhuanlan.zhihu.com/p/108820522,找到一条效果比较好的公式,就是下面这张图:
当然,他这里的B是指混合色,A是指底色,到时候实现的时候要注意。
了解这些之后,我们就可以着手开始实现了。
2.实现
代码实现
首先,我们把原来混合图片的函数ImageBlend稍微修改一下,顺便加上新的变量fill:
[Range(0, 1)] public double fill;//填充
void ImageBlend()
{
//获取变换后的人脸纹理矩阵
Mat targetMat = new Mat(srcFace.height, srcFace.width, CvType.CV_8UC4);
targetMat = GetWarpedTexture();
if (targetMat == null) return;
//图像混合
for(int i = 0; i < srcMat.rows(); i++)
{
for(int j = 0; j < srcMat.cols(); j++)
{
double[] color1 = targetMat.get(i, j);
if (color1[3] > 0)//不透明度大于0
{
double[] color2 = srcMat.get(i, j);
//柔光混合
double r = SoftLightBlend(color1[0], color2[0], color1[3], fill);
double g = SoftLightBlend(color1[1], color2[1], color1[3], fill);
double b = SoftLightBlend(color1[2], color2[2], color1[3], fill);
double[] color = new double[] { r, g, b, 255 };
copyMat.put(i, j, color);
color = null;
}
}
}
然后,我们再实现柔光混合的函数SoftLightBlend:
double SoftLightBlend(double _a, double _b, double _opacity, double _fill)
{
//标准化
_a /= 255;
_b /= 255;
_opacity /= 255;
//_a *= _fill;
double res = 0;
if (_a > 0.5)
{
res = 2 * _b * (1 - _a) + (2 * _a - 1) * Mathf.Sqrt((float)_b);
}
else
{
res = 2 * _a * _b + _b * _b * (1 - 2 * _a);
}
res = _fill * res + (1 - _fill) * _b;
res = _opacity * res + (1 - _opacity) * _b;
//映射回原来的色彩值
res *= 255;
return res;
}
这里fill和opacity的式子是我自己摸索出来的,但是效果还是不错的。
人脸纹理制作
做到这里,我们的代码部分已经完成了,剩下的就是最后一步,制作一个充满皱纹的人脸纹理。首先,先找一张老年人的脸部照片(注意:尽量正脸,且轮廓都在照片内),这是我找的图,已经把背景去掉了:
经过一系列处理后,得到下面的PNG图片:
具体在PS的处理步骤如下:
- 羽化人脸边缘,使混合后边缘不会过于突兀。
- 使用印章工具,用其他部位的纹理覆盖眼睛、鼻子、嘴巴、眉毛这些区域,再模糊掉,以减少纹理对这些明显特征部位的影响。
- 适当降低亮度,尽量拉高对比度,以加深皱纹的纹理。
3.效果
终于到了激动人心的地步,这次我们选择一张没什么皱纹的人脸作为待处理的人脸,然后再将上面两张人脸作为参数放进脚本,调整fill的值,处理!
我们再试试别人脸:
可以看到,效果还是可以的,即使稍微侧脸也能处理得比较好。但也有不足的地方:
- 人脸纹理边缘轮廓过渡不够自然
- 人脸皱纹不够明显
对人脸纹理边缘过渡的问题,我的两种解决方法:
- 在PS中提高人脸边缘的羽化程度
- 降低fill的值
对人脸皱纹不明显的问题,我的两种解决方法:
- 在PS中提高人脸的对比度,适当降低亮度
- 提高fill的值
由上可知,fill的取值需要根据自己的情况去调整。调高fill值皱纹确实会更明显,但是人脸纹理的边缘会变突兀;调低fill值虽然边缘过渡会更自然,但是皱纹的纹理也会变淡。
总结
虽说以上实现的是人脸老化的效果,但实际上只要你能做出相对应的人脸纹理,其他的人脸效果也能做出来,比如黑眼圈、眼窝之类的。所以只要理解了以上的思路,基本都能靠这种方法做出来。
这次是我第一次在 csdn写文章,所以文章的长度、排版之类的都把握不好,对相关知识点的解释可能也不是很好,有什么不好或者不对的地方,都欢迎大家的意见和建议!