Android OpenGL开发实践 - 基于OpenGL ES 2.0的Android相机实时图片涂鸦实现思路

这篇文章将给大家讲解如何在Android系统上基于OpenGL ES 2.0来实现相机实时图片涂鸦效果,所涂内容跟随人脸出现、消失、移动、旋转及缩放,在这里,我们假设您:

  • 已经搭建好一个相机框架,能够获得相机的预览图像
  • 有了一个人脸检测的SDK,能够得到相机预览时每帧人脸在屏幕中的坐标及旋转角度。

在开始讲解之前,先简要介绍一下OpenGL ES 2.0的一些必要的基础知识,方便对文章的理解。

基础知识一:OpenGL的坐标系

为方便讲解,以下只讲解二维的情况,在OpenGL使用中,我们主要会涉及到以下三个坐标系:

  • 屏幕坐标系 屏幕坐标系就是我们手机屏幕的坐标系,以像素为单位,左上角是坐标系原点,即(0,0),x的取值范围为0~屏幕宽度,y的取值范围为0~屏幕高度,详见下图:
  • 世界坐标系 它是OpenGL内部的绘图区域的坐标系,x、y的取值范围都是-1~1,坐标原点在绘图区域的中心,见下图,假设绿色区域是一个OpenGL的绘图区域:
  • 纹理坐标系 就是纹理本身的坐标系,坐标原点在纹理的左上角,s(x)、t(y)的取值范围都是0~1,见下图,假设 黄色区域是一个纹理贴图:

基础知识二:Shader

Shader就是OpenGL的着色器,分为顶点着色器(Vertex Shader)和片元着色器(Fragment Shader),这两个着色器都由一段小程序来实现,用OpenGL Shading Language编写,语法类似C语言,使用时将相应shader程序代码载入OpenGL即可。

OpenGL在把点绘到屏幕上之前,点会依次经过顶点着色器和片元着色器的处理。顶点着色器是处理顶点的位置、大小、旋转等操作,比如希望显示一个经过顺时针旋转90度、并放大1倍的纹理,可以在顶点着色器中编写相应的代码;片元着色器主要处理颜色操作,比如希望将一个纹理中某个区域的颜色变成红色,可以在片元着色器中编写相应的代码。

相机实时图片涂鸦实现思路

下面开始循序渐进地讲解涂鸦的实现,首先先来实现一个简单的框架:在相机预览的界面的中央画一个贴图。

Part1: 一个简单的框架

先来定义一下Vertex Shader和Fragment Shader,这两个Shader是必不可少的。

Vertex Shader:

简要介绍一下这个Vertex Shader的含义,正如前文所说的,Vertex Shader的作用是对顶点进行一些位置、大小、旋转等变换操作,但在现在这个shader里,这些都没有涉及,只是一个最简单的Shader,各变量及其含义:

  • a_Position 顶点数据,代表了要画的每个顶点,注意,这里的a_Position只是一个点,那么它如何能代表要画的每个顶点?这是刚接触Shader时很容易会产生的疑惑之一,实际上,Shader代码会被OpenGL反复调用多次,每画一个点就会调用一次,a_Position就代表当前要画的点,反复不停地调用,a_Position就被赋上了不同顶点的值。
  • a_TextureCoordinates 纹理坐标数据,用于描述要画的纹理顶点,在这里,没有对它作任何处理,直接赋给了v_TextureCoordinates。
  • v_TextureCoordinates 用于将Vertex Shader中接受到的纹理顶点数据传递到Fragment Shader中,等会儿会看到在Fragment Shader中也有一个名字相同的变量。
  • gl_Position 最终告诉OpenGL要画的顶点位置,这里直接将a_Position赋给了它,不作任何变换。

Fragment Shader:

同样,如前文所提到的,Fragment Shader主要处理颜色操作,各变量含义:

  • u_TextureUnit java层传递过来的纹理,例如一张待绘制的图片
  • v_TextureCoordinates 这个就是刚才说的Vertex Shader中传递过来的,其值就是Vertex Shader中的a_TextureCoordinates
  • gl_FragColor 最终告诉OpenGL要画的顶点颜色,这里texture2D(u_TextureUnit, v_TextureCoordinates)是什么意思呢?就是取u_TextureUnit纹理中的v_TextureCoordinates点,而v_TextureCoordinates点又是Vertex Shader中传递过来的纹理的点,所以相当于是在这个纹理中取对应的点,Fragment Shader和Vertex Shader一样也是会反复地调用,这样gl_FragColor就取到了这个纹理每一个点的颜色,结果就是将这个纹理画了出来。

java关键代码

首先创建两个类,CameraView继承GLSurfaceView并实现SurfaceTexture.OnFrameAvailableListener接口,MyRenderer实现GLSurfaceView.Renderer接口,在CameraView的构造函数里做一些OpenGL必要的初始化:

值得一提的是setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY),OpenGL可以将渲染设置为每帧都自动渲染或者是你要求它渲染它才渲染,这里的GLSurfaceView.RENDERMODE_WHEN_DIRTY属于后者,在onFrameAvailable()回调里调用GLSurfaceView的requestRender()方法触发渲染,也就是触发onDrawFrame()的调用。

下面在MyRenderer类中,我们先将刚才的那两个Shader给Load进来:

然后在onSurfaceCreated中做一些变量的初始化:

其中IMAGE_POSITION_VERTEX是纹理图片的位置坐标数组,它的作用是确定要把纹理图片画在屏幕的什么地方,它里面的坐标值是对应世界坐标系中的坐标值,IMAGE_TEXTURE_VERTEX是纹理图片本身的顶点坐标数组,它的作用是确定要画这个纹理图片的什么部分,如下图所示:

IMAGE_POSITION_VERTEX所指定的位置即相当于上图中“绘制位置”,IMAGE_TEXTURE_VERTEX指定的纹理绘制部分即相当于上图中的“绘制部分”。

如果想把一个纹理图片的全部部分画在屏幕中央,可以将IMAGE_POSITION_VERTEX及IMAGE_TEXTURE_VERTEX取值如下:

这里注意一点,vertex点的取法是和画法有关的,这里采用的画法是GLES20.GL_TRIANGLE_FAN,如果采用其它画法,vertex点数组要作相应地调整,否则画面会错乱。

然后在onDrawFrame中绘制图片:

至此,我们有了一个简单的框架,可以在相机预览界面绘制一个图片了。

Part2: 涂鸦画布

简介

下面来介绍涂鸦画布的创建以及将手指在屏幕上触摸的位置绘制贴图。 涂鸦画布是一个独立于相机预览帧的绘图区域,它的作用是可以将已绘制好的涂鸦暂存起来,否则因为相机预览帧每一帧都是新的,需要把之前绘制过的东西再重新绘制一次,即就算涂鸦结束了,每帧也都需要调用多次OpenGL绘制方法将之前涂鸦的内容绘制到相机预览帧上,否则在新的帧上就看不见之前涂的内容,示意图如下:

有了涂鸦画布后,就可以将涂鸦内容画到涂鸦画布上,然后对每一个新的相机预览帧,直接将整个画布画上去,将画布画上去只需要调用一次OpenGL绘图方法:

这里的画布实际上就是一个空的texture,创建方法和创建一个普通的texture是一个样的,即用GLES20.glGenTextures()来创建,然后进行一些初始化等操作:

为什么需要framebuffer?因为OpenGL默认是渲染到屏幕的,我们往画布上画东西并不希望马上显示出来,因为画布还要贴到脸上,之后再显示出来。

坐标变换

有了涂鸦画布之后,下一步就是如何将涂鸦的内容画到画布上。首先讨论坐标系的转换,引入画布之后,现在相关的坐标系又多了一个画布的坐标系,手指在屏幕上触摸之后,如何让图案最终在触摸的位置画出来呢?

手指在屏幕上触摸之后,onTouchEvent()中所得到的坐标是屏幕坐标系中的坐标,而相机有一个预览宽高的设置,这个宽高可以和屏幕宽高不一样,比如1080*1920的屏幕,相机的预览宽高可以设置为720*960,因此第一个坐标系的转换就是将屏幕坐标系中的触摸点坐标转换成与相机预览宽高相对应的坐标,相机预览的坐标系原点及x、y轴方向与屏幕坐标系相同:

得到了触摸点在相机预览画面中的坐标之后,下一步是转换成它在画布中的坐标,因为画布是跟随人脸移动、旋转及缩放的,因此这一步稍微有一点复杂,这里画布贴到人脸上采用的方案是将画布中心对准人脸的鼻尖位置(鼻尖坐标由人脸检测SDK得到),示意图如下:

可能有人会问,从图中看,屏幕中有些部分超出了画布,这部分是否能涂上去?是涂不上去的,只能涂在涂鸦画布上,因此实际使用的时候,会把涂鸦画布设置成比屏幕大一些,一般可以自己试一下,比如把手机放远,看看人脸缩小后画布要设置能多大还能覆盖屏幕,一般不用设置得太大,因为人脸缩得太小后,人脸通常也识别不出来了,这时候也不用担心画布被缩得太小了。

继续沿用之前的例子,前面是得到了触摸点在相机预览画面中的坐标是(200,400),它如何对应到涂鸦画面上面呢?这里的方法是先计算触摸点相对于人脸鼻尖的位置,因为涂鸦画布是将画布中心对准了人脸鼻尖位置,所以再通过算出来的相对位置转换成涂鸦画布上的对应位置,以保证它在涂鸦画布上还是手指触摸的那个地方。假设画布的实际尺寸设置为600*600,画布中心点坐标是(300,300),人脸鼻尖坐标是(360,320)先从简单的情况看起,假设画布贴上去之前,没有进行移动、旋转和缩放,那么将是:

以上是一种简单的情况,那么如果人脸先旋转了一下呢?这时画布也是跟着旋转了,这时的坐标如何转换?其实思路很简单,就是画的时候,计算点坐标时把它当作还没转的情况来计算,算出来后再转相应的角度就行了:

如何计算点(x,y)的值呢?有个神奇的公式,它可以计算一个点绕某个点逆时针旋转后的点坐标:

其中x、y是旋转前的点坐标,x0、y0是绕着旋转的点坐标,x’、y’是旋转后的点坐标,α是旋转角度。

下面来看看,如果人脸缩放了,如何计算正确的坐标,这里采取的方法是,当第一次把涂鸦画布贴到人脸上的时候,先记录人脸的初始宽度,之后的帧里再用当前人脸的宽度和记录的初始人脸宽度就行对比,从而得知人脸缩放的比例。人脸宽度的计算要依赖于人脸检测SDK,只需要用人脸检测出的人脸两边边的对应点相减就行了:

人脸缩放后,要保持触摸点转换成涂鸦画布上的正确位置,只需要把触摸点与人脸鼻尖点之间的差值相应地缩放就可以了:

这里有一点需要注意的是,假设涂鸦画布的实际尺寸是600*600,它随人脸进行缩放后,它的实际尺寸仍然是600*600,只不过显示的时候被缩放了,因此在将触摸点转换成涂鸦画布上的对应点时,仍要按涂鸦画布是600*600来计算。

另外,还可以给画布设置一个显示的缩放比例,这个是什么意思呢?之前说过,涂鸦画布在实际使用的时候,会设置成比屏幕大一些,以确保在人脸缩小后,画布不至于被跟着缩小至比屏幕还小,不然有些地方就涂不上去了,将涂鸦画布设大,可以把它的实际尺寸设大,也可以是把它进行显示放大,为什么需要进行显示放大?因为如果涂鸦画布实际尺寸设置得很大,相当于画布的分辨率很高,这样画出的东西就比较精细,从而耗时也会增加,而进行显示放大不会增加涂鸦画布的实际尺寸,只相当于把一个小的东西在显示时扯大了,会稍微变模糊一些。因此,可以将涂鸦画布的实际大小设置得适中一些,再进行适当地显示放大,来使得画布不至于被跟着缩小至比屏幕还小,同时又让画布的分辨不会过高而增加绘制耗时。

加上了涂鸦画布显示缩放比例后,坐标换转的计算逻辑也要相应地作修改,假设display_scale是设置的画布显示缩放比例,沿用之前的例子,如果画布被放大显示了,算出的点会有相应的偏移,调整示意图如下:

现在可以将手指在屏幕上触摸时在onTouchEvent()回调中所得到的触摸坐标正确地转换成涂鸦画布中的坐标了,那么如何在对应的坐标点画涂鸦图案呢?前面已经讲解了一个简单的绘图框架,现在实际就是确定一下前文所说的IMAGE_POSITION_VERTEX以及IMAGE_TEXTURE_VERTEX该如何取值。将一个贴图画到一个位置上,那么这张图的哪个部分对准到这个点上呢?为了解决这个问题,这里引入一个概念叫“锚点”,所谓锚点就是纹理图片上用于对准的点,如下图所示:

实际上,锚点的设置并不是OpenGL本身的功能,不过我们可以对IMAGE_POSITION_VERTEX稍作修改便可以指定自己想要的锚点,例如我们指定锚点为纹理贴图的中心:

至此,涂鸦画布的坐标系转换就讲完了

涂鸦画布的平移、旋转及缩放

下面这部分讲解如何实现涂鸦画布随人脸平移、旋转及缩放,前面提到过,Vertex Shader会对每个要画的点都调一次,因此对每个点做对应的变换,也就实现了对涂鸦画布的变换,平移、旋转及缩放都有对应地矩阵操作可以方便地实现,将这些操作写在Vertex Shader中对传进Vertex Shader中的点进行变换就行了。

以下均假设变换前的点为x0、y0,变换后的点为x、y。

平移变换:

其中Δx、Δy分别表示在x、y轴上的平移量。

旋转变换:

其中θ表示绕原点逆时针旋转的角度。 tips:如果希望绕某个特定点旋转,可以先作平移操作,让特定点在平衡后处于原点的位置,再进行旋转操作,旋转结束后再按原路平移回去,如下图所示:

缩放变换:

其中k1、k2分别表示x、y坐标的缩放比例。

至此,本文已接近尾声,总结一下几个关键点:

  • 涂鸦画布的创建,本质上是创建一个空的texture当作画板
  • 坐标转换,关系着涂鸦位置是否正确,涉及到多个坐标系的转换,一旦某步出错,可能导致最后结果存在很大偏差
  • Vertext Shader中平移、旋转及缩放代码的编写,本质上是套用变换矩阵

作者简介:kenneyqin(覃华峥),天天P图Android工程师

原文发布于微信公众号 - 天天P图攻城狮(ttpic_dev)

原文发表时间:2017-08-25

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Android知识点总结

Android关于Path你所知道的和不知道的一切

41960
来自专栏柠檬先生

css3 动画应用 animations 和transtions transform在加上JavaScript 可以实现硬件加速动画。

transitions(过渡) 被应用于元素指定的属性变化时,该属性经过一段时间逐渐的过渡到最终想要的值。   主要包括四个属性:     执行变换的属性:...

257100
来自专栏向治洪

自定义绘制柱形图

设计思路: 1.画柱状图  2.画竖线  3.画顶部横线  4.画文字 1.画柱状图 画柱状图的方法很简单,就是使用canvas.drawRect...

24770
来自专栏柠檬先生

Sass 基础(七)

Sass Maps 的函数-map-remove($map,$key),keywords($args)     map-remove($map,$key) ...

22750
来自专栏Linux驱动

31.QPainter-rotate()函数分析-文字旋转不倾斜,图片旋转实现等待

30.QT-渐变之QLinearGradient、 QConicalGradient、QRadialGradient

14630
来自专栏Jack的Android之旅

自定义天气显示温度变化的LinearChart控件

这次发表的是前几个月搞定的一个自定义控件,那时自己在写一个小的查看天气的软件,在这过程中就涉及了显示天气变化的折线图,一开始想用一些画图框架来解决问题,不过考虑...

29310
来自专栏数据小魔方

sparklines迷你图系列14——Correlation(HeatMap)

今天跟大家分享的是sparklines迷你图系列13——Correlation(HeatMap)。 热力图在excel中可以轻松的通过自带的条件格式配合单元格数...

34260
来自专栏yang0range

CSS的继承性和层叠性

有一些属性,当给自己设置的时候,自己的后代都继承上了,这个就是继承性。 哪些属性能继承? color、 text-开头的、line-开头的、font-开头的...

19720
来自专栏java小白

CSS三大特性

17140
来自专栏mySoul

css3动画

此为动画样式中的关键帧,用关键帧来控制css动画中的关键样式。相比较过渡更加的容易空值中间的部分

16340

扫码关注云+社区

领取腾讯云代金券