实战精通OpenCV第一章--基于Android的图片卡通化及肤色改变(二)

转载请注明出处:https://blog.csdn.net/mymottoissh/article/details/86723580

第一章 基于Android的图片卡通化及肤色改变

一、基于Visual Studio的图片卡通化

二、基于Visual Studio的肤色改变

三、Android代码移植

书接上文,上一篇实现了图片的卡通化。回顾一下卡通化的基本思路:首先通过中值滤波+拉普拉斯算子得到图像边缘,然后通过多次双边滤波得到平坦化的图片,最后将边缘与图片叠加得到卡通化的图片。本篇继续在人脸上做文章:让人的脸变绿!具体来讲还是希望实现一个基于手机的app。当用户拿着手机高兴自拍完之后,却发现照片上自己的脸是绿的。

本篇涉及到的主要知识点包括:

  • 脸部定位与皮肤识别的思考
  • 色彩空间的变换
  • 图像的腐蚀与膨胀
  • API之floodfill -- 漫水填充

 处理流程

上效果图

原图    222

 

对,我们就是做了这样一件小事。为了获得上图效果,应该很容易能够想到,可以首先获取一个脸部形状的掩膜,然后将脸部区域内涂成绿色,外部不作处理,最终得到效果图。如果采取这种策略,那么我们至少需要解决以下几个问题:

1、这是一个运行在手机上的程序,换句话说,我们希望从手机摄像头抓拍任意一张图片来进行处理,而用户自拍的方式可能是任意的。这样一来,如何准确的检测到脸部皮肤区域呢?

2、好了,假设我们解决了第一个问题,现在我可以准确地知道这区域就是脸部位置。但是,同时也应该注意到,不同人的皮肤有黑有白,不同时间的光照有明有暗,即便在同一张脸上的不同位置,也可能会产生色差。那么,如何能够得到完成的脸部模板呢?

下面我们就围绕着这两个问题,展开思考,并逐步实现。

为了识别人脸各个区域,你能想到的方法可能有很多种。从简单的阈值过滤到复杂的神经网络。但是在实际情况中,由于在实际情况中,光照、肤色、传感器的响应程度都将对图片产生影响。在这种情况下,复杂的算法未必能够产生更好的效果。另一方面,现在要做的是基于手机的实时图像采集及处理,不进行任何的训练,所以需要有一种更加简单,更加通用的方法来实现效果。

说到这,书中提出了两种方法。

HSV阈值过滤:在HSV色彩空间中,如果色相呈红色,并且饱和度和亮度都适中,那么可以认为这是皮肤的颜色。但是考虑到手机的传感器白平衡往往较差,所以这种方式不适用。

级联低通滤波:查看滤波后图像中间部分区域,可以认为与中间部分像素值类似的像素点位置就是皮肤。这种做法的优点是排除了皮肤颜色和图像传感器的影响。但是级联滤波速度很慢,也不适合我们的实时应用。

这也不行,那也不行,如果能让我们告诉自拍者他应该把脸放在什么位置那就好了。为什么不呢,更何况我们现在要做的只是一个娱乐性的项目,不是吗?其实以前很多App采用了同样的策略,即首先指定一个脸的轮郭让用户放进去,然后才可以成功识别人脸。现在我们就这么干。

为了使交互更加友好,首先画出一个脸的轮廓,并配上文字。

outline

由于现在的demo是在VisualStudio上开发的,所以先找一张网上的头像假装是我自拍的,并与脸部轮廓叠加得到下图。事实上,如果用户遵守我们的规则,那他自拍后的效果应该和下图应该类似。

faceline

至此,基于实际的应用,似乎找到了一种还算不错的方式圈定脸的范围。接下来就是面部填充了。相比之下,这里采用了一种更加好的方式来处理:生成一个脸部形状的半透明模板,然后叠加到原图上。这样不仅能将脸部变色,同时保留了原有脸部的细节。

如此一来,现在要解决的就是如何生成面部模板的问题了。这时,我们用到了OpenCV中的漫水填充floodfill。

The function cv::floodFill fills a connected component starting from the seed point with the specified
color. The connectivity is determined by the color/brightness closeness of the neighbor pixels.

而函数原型是这个样子滴

CV_EXPORTS_W int floodFill( InputOutputArray image, InputOutputArray mask,
                            Point seedPoint, Scalar newVal, CV_OUT Rect* rect=0,
                            Scalar loDiff = Scalar(), Scalar upDiff = Scalar(),
                            int flags = 4 );

结合上面的函数描述,可以很容易地理解图像、种子像素、上下限这几个入参。但这个mask是干嘛用的呢?看一下参数说明吧

@param mask Operation mask that should be a single-channel 8-bit image, 2 pixels wider and 2 pixels
taller than image. Since this is both an input and output parameter, you must take responsibility
of initializing it. Flood-filling cannot go across non-zero pixels in the input mask. For example,
an edge detector output can be used as a mask to stop filling at edges. On output, pixels in the
mask corresponding to filled pixels in the image are set to 1 or to the a value specified in flags
as described below. Additionally, the function fills the border of the mask with ones to simplify
internal processing. It is therefore possible to use the same mask in multiple calls to the function
to make sure the filled areas do not overlap.

 首先,floodfill不能填充非0区域。其次,mask也将作为输出矩阵被设置,即与原图像填充位置对应的像素灰度值会被置1。而这个脸部区域与其它区域的灰度差正是需要的。当然,为了使填充时不蔓延到脸轮廓外,mask应该是提取好的轮廓作为输入的。

回过头来再看floodfill入参中的上下限值。如果我们采用RGB空间的彩色图像,那么上下限将是对应的RBG上下限,但这样真的好吗?我们希望的是对于脸部各个位置,亮度可以有适当的差异,但是颜色要尽量保持一致。从这个角度讲,选用HSV色彩空间更加合适,因为HSV将亮度与色相进行了分离。然而HSV有一个缺点,让作者最终放弃了HSV而选用YUV。这是为什么呢?先卖个关子,这个问题放到后面的理论部分来阐述。现在只需知道,最后选用色彩空间是YUV。另外,为了进一步减小脸部不同区域光照不同的影响,将选取6个种子像素(额头部分三个,脸颊部分三个),共进行6次填充,得到最终的模板。

现在整理一下思路吧,为了得到模板,首先要获取图像的边缘轮廓作为mask,随后将图像转换到YUV空间。在图像上选取6个种子点,做6次漫水填充得到模板。看一下效果。

maskmask2mask3

第一幅为填充前的轮廓,第二幅为填充后并做了二值化的模板。有了这两幅图,将它们相减,就得到了最终的脸部模板。

PS:获取轮廓时,用了闭运算使其连通性更好,这个会在理论部分说明。

现在好了,有了脸部模板,最后直接add一下就得到了绿脸图。

上代码吧。

void alienFace(Mat& src)
{
	Size size = src.size();
	Mat gray;	
	Mat yuv = Mat(size, CV_8UC3);
	Mat faceOutline = Mat::zeros(size, CV_8UC3);	
	Mat srcClone = src.clone();
	Scalar color = CV_RGB(255, 255, 0);
	cvtColor(src, gray, COLOR_BGR2GRAY);
	cvtColor(src, yuv, CV_BGR2YCrCb); // 色彩空间转换

	//画笑脸开始,桌面demo没有用到
	int sw = size.width;
	int sh = size.height;
	int thickness = 4;
	int faceH = sh / 2 * 70 / 100;
	int faceW = faceH * 72 / 100;
	ellipse(faceOutline, Point(sw / 2, sh / 2), Size(faceW, faceH), 0, 0, 360, color, thickness, 500);
	int eyeW = faceW * 23 / 100;
	int eyeH = faceH * 11 / 100;
	int eyeX = faceW * 48 / 100;
	int eyeY = faceH * 13 / 100;
	Size eyeSize = Size(eyeW, eyeH);
	int eyeA = 15; 
	int eyeYshift = 11;
	ellipse(faceOutline, Point(sw / 2 - eyeX, sh / 2 - eyeY), eyeSize, 0, 180 + eyeA, 360 - eyeA, color, thickness, CV_AA);
	ellipse(faceOutline, Point(sw / 2 - eyeX, sh / 2 - eyeY - eyeYshift), eyeSize, 0, 0 + eyeA, 180 - eyeA, color, thickness, CV_AA);
	ellipse(faceOutline, Point(sw / 2 + eyeX, sh / 2 - eyeY), eyeSize, 0, 180 + eyeA, 360 - eyeA, color, thickness, CV_AA);
	ellipse(faceOutline, Point(sw / 2 + eyeX, sh / 2 - eyeY - eyeYshift), eyeSize, 0, 0 + eyeA, 180 - eyeA, color, thickness, CV_AA);
	int mouthY = faceH * 48 / 100;
	int mouthW = faceW * 45 / 100;
	int mouthH = faceH * 6 / 100;
	ellipse(faceOutline, Point(sw / 2, sh / 2 + mouthY), Size(mouthW, mouthH), 0, 0, 180, color, thickness, CV_AA);
	int fontFace = FONT_HERSHEY_COMPLEX;
	float fontScale = 1.0f;
	int fontThickness = 2;
	cv::String szMsg = "Put your face here";
	putText(faceOutline, szMsg, Point(sw * 23 / 100, sh * 10 / 100), fontFace, fontScale, color, fontThickness, CV_AA);
	imwrite("F:/pic/faceoutline.jpg", faceOutline);
	addWeighted(srcClone, 1.0, faceOutline, 0.7, 0, srcClone, CV_8UC3);
	imshow("src with line", srcClone);
	//画笑脸结束
	
	//中值+拉普拉斯+闭运算进行轮廓提取
	Mat mask, maskPlusBorder;
	maskPlusBorder = Mat::zeros(sh + 2, sw + 2, CV_8UC1);
	mask = maskPlusBorder(Rect(1, 1, sw, sh));
	medianBlur(gray, gray, 7);
	Mat edges = gray.clone();
	Laplacian(gray, edges, CV_8U, 5);
	resize(edges, mask, size);
	const int EDGES_THRESHOLD = 80;
	threshold(mask, mask, EDGES_THRESHOLD, 255, THRESH_BINARY);
	dilate(mask, mask, Mat());
	erode(mask, mask, Mat());
	
	//种子像素点
	int const NUM_SKIN_POINTS = 6;
	Point skinPts[NUM_SKIN_POINTS];
	skinPts[0] = Point(sw / 2, sh / 2 - sh / 6);
	skinPts[1] = Point(sw / 2 - sw / 11, sh / 2 - sh / 6);
	skinPts[2] = Point(sw / 2 + sw / 11, sh / 2 - sh / 6);
	skinPts[3] = Point(sw / 2, sh / 2 - sh / 16);
	skinPts[4] = Point(sw / 2 - sw / 9, sh / 2 - sh / 16);
	skinPts[5] = Point(sw / 2 + sw / 9, sh / 2 - sh / 16);

	//上下限
	const int LOWER_Y = 60;
	const int UPPER_Y = 80;
	const int LOWER_Cr = 25;
	const int UPPER_Cr = 15;
	const int LOWER_Cb = 20;
	const int UPPER_Cb = 15;
	Scalar lowerDiff = Scalar(LOWER_Y, LOWER_Cr, LOWER_Cb);
	Scalar upperDiff = Scalar(UPPER_Y, UPPER_Cr, UPPER_Cb);

	//漫水填充
	const int CONNECTED_COMPONENTS = 4;
	const int flags = CONNECTED_COMPONENTS | FLOODFILL_FIXED_RANGE | FLOODFILL_MASK_ONLY;
	Mat edgeMask = mask.clone(); 
	for (int i = 0; i < NUM_SKIN_POINTS; i++) {
		floodFill(yuv, maskPlusBorder, skinPts[i], Scalar(), NULL, lowerDiff, upperDiff, flags);
	}

	//最后叠加矩阵
	threshold(mask, mask, 0, 255, THRESH_BINARY);
	mask -= edgeMask;
	int Red = 0;
	int Green = 70;
	int Blue = 0;
	add(src, CV_RGB(Red, Green, Blue), src, mask);
}

理论部分

关于floodfill函数,上面已经说得差不多了。在这个部分,有选择地讲述两个知识点色彩空间和图像的腐蚀与膨胀。

色彩空间HSV和YUV

人眼能看到的正常彩色图片大多是RBG空间下的表示。但是在计算机处理时,根据需要要将其转换到其他色彩空间。如上面的实现过程中,我们允许不同脸部区域的亮度可以有变化,但是需要颜色尽可能稳定。这时就要将RGB图像进行转换来实现亮度和颜色的分离。

关于RGB和其他色彩空间的转换关系,网上嗷嗷的多,这里就不多说了,仅po两张图来直观感受一下

左图为HSV空间的颜色表示,Hue以红色为原点表示的角度,Saturate表示横向延伸的饱和度,Value为纵向延伸的亮度。

右图其实为YCbCr图谱,是YUV的一个变体,在这里认为与YUV空间一样。亮度Y在这张图里没有表示出来。

这里不继续深究这些色彩空间存在的意义了,其实我看的也是云里雾里,有兴趣的同学可以自行查一些资料。只考虑一个问题,既然两种色彩空间都将亮度进行了分离,那为什么选择了YUV而不选HSV呢?答案就是,由于皮肤往往是偏红色的,而HSV的色相以红色为起点,这就意味着当我们规定漫水填充的上下限时,需要定义两个范围:Hue的前10%范围和后10%范围,计算也因此而复杂。反观YUV,一个范围搞定。这也是在实验中选用了YUV的原因。

图像的腐蚀与膨胀

二者都属于图像形态学的范畴。先来看看腐蚀的定义

让原本位于图像原点的结构元素S在整个作用域Z移动。如果当S的原点平移至z点时,S能够完全包含于图像A中,则所有这样的点z构成的集合称为A的腐蚀图像。

 

上图展示了两种结构元素对于图像A进行腐蚀得到后的结果。可以认为,腐蚀可以给原图像瘦身,具体瘦多少由结构元素的尺寸决定。(图盗自《数字图像处理》)

再来看膨胀。

让结构元素S在整个作用域Z上进行移动,当S自身的原点平移至z点时,能够保证S与原图像A至少有一个像素的重叠,这样的z点构成的集合称为A的膨胀图像。 

 

上图展示了两种结构元素对于图像A进行膨胀后的结果。与腐蚀相反,它让图像变胖。 (图又盗自《数字图像处理》)

在膨胀和腐蚀的基础上再来定义两个操作:开运算和闭运算。

开运算:先腐蚀后膨胀。作用是断开原图中比较纤细的连接。

闭运算:先膨胀后腐蚀。作用正相反,是融合靠的比较近的区域块。在获取脸部模板之前时,我们首先获取了轮廓线条。但是在上一篇提到过,拉普拉斯变换对噪点较为敏感,所以直接滤波后的图片在脸部区域内的范围中,会有很多错误的边缘。为了消减这些错边缘,运用了闭运算来处理,最后得到理想的轮廓。

基于膨胀和腐蚀另外一个重要的应用是顶帽变换和黑帽变换,用来解决光照不均的问题。在以后的实验中,如果涉及到再进行介绍。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值