一看就懂的OpenGL ES教程——仿抖音滤镜的奇技淫巧之高斯模糊滤镜

2,120 阅读12分钟

通过阅读本文,你将获得以下收获:
1.图像平滑基础知识。
2.高斯模糊的定义以及理论基础。
3.如何通过shader给视频添加高斯模糊效果。

上篇回顾

上一篇博文一看就懂的OpenGL ES教程——仿抖音滤镜的之变换滤镜(实践篇)将之前讲的几何变换实战了一遍,大家想必已经对使用矩阵进行变换有了比较深刻的印象了吧,当然在图形学的世界里,矩阵的作用可不止对图像做做几何变换滤镜这么简单,它可是一名主力球员,不过具体作用的体现还容我讲3维渲染的时候再细细道来。

cfcb5949414db115e92de1a974c27f6f.gif

矩阵暂时不是今天的主角,不过这不代表今天就不涉及矩阵,今天会涉及一些看起来很有意思的内容。

高斯模糊理论基础

高斯模糊(英语:Gaussian Blur),也叫高斯平滑,是在Adobe PhotoshopGIMP以及Paint.NET等图像处理软件中广泛使用的处理效果,通常用它来减少图像噪声以及降低细节层次

效果如下图所示,左图为原图,右图为经过高斯模糊处理后的图:

image.png

要明白高斯模糊,首先就要从图像平滑说起。

图像平滑

什么是图像平滑呢,其实图像平滑就是对于某个像素点来说,让它周边的点去影响当前像素点的灰度值,比如下图中,中心点灰度值此时为2:

image.png

最简单的平滑就是将中心点周围的像素取平均值赋值给中心点,如下图:

image.png

所以平滑就是让一个点在周围点的影响下失去自己本身的特色,变得有点“泯然众人”了。模糊本质就是这样,让那些与众不同的点,比如灰度值与周边明显不同的点或者边缘处的点,变得和边缘类似。

高斯模糊就是一种图像平滑技术,从数学的角度来看,图像的高斯模糊过程就是图像与正态分布卷积。由于正态分布又叫作“高斯分布”,所以这项技术就叫作高斯模糊

什么意思呢?

这里的关键词有两个,一个是卷积,另外一个高斯分布

图像卷积

卷积是什么呢,打开维基百科:

image.png

Convolution_of_box_signal_with_itself2.gif

image.png

连续函数的卷积,可能对于数学基础不好的同学来说看不太懂,不过对于图像的卷积来说,因为图像本质可以看做离散信号,所以图像运用的是离散卷积

image.png

简单来说,就对应像素灰度值的乘积之和

这里必须讲一个图像领域十分重要的概念,叫做卷积核

卷积核

卷积核就是图像处理时,给定输入图像,输入图像中一个小区域中像素加权平均后成为输出图像中的每个对应像素,其中权值由一个函数定义,这个函数称为卷积核卷积核在图像处理领域十分重要,类似图像平滑、边缘锐化等几乎所有的图像处理都离不开它

再次打开维基百科的定义:

In image processing, a kernelconvolution matrix, or mask is a small matrix used for blurring, sharpening, embossing, edge detection, and more. This is accomplished by doing a convolution between the kernel and an image. Or more simply, when each pixel in the output image is a function of the nearby pixels (including itself) in the input image, the kernel is that function.

比如下图,假如用卷积核对原图像中的其中一块3* 3区域(即中间的绿色区块)进行卷积处理,则将它们对应像素相乘结果相加起来的最后结果赋值给3*3区域的中心点,从而实现周边像素点对中心像素点的像素加权平均

image.png

对于整个图像来说,就是用卷积核从第一个像素到最后一个像素依次进行卷积处理的过程,如下动图:

2D_Convolution_Animation.gif

卷积核通常有以下特征:

1)滤波器的大小应该是奇数,这样它才有一个中心,例如3x3,5x5或者7x7。有中心了,也有了半径的称呼,例如5x5大小的核的半径就是2。

2)滤波器矩阵所有的元素之和应该要等于1,这是为了保证滤波前后图像的亮度保持不变。当然了,这不是硬性要求了。

3)如果滤波器矩阵所有元素之和大于1,那么滤波后的图像就会比原图像更亮,反之,如果小于1,那么得到的图像就会变暗。如果和为0,图像不会变黑,但也会非常暗。

4)对于滤波后的结构,可能会出现负数或者大于255的数值。对这种情况,我们将他们直接截断到0和255之间即可。对于负数,也可以取绝对值。

我们的图像平滑就是用卷积实现的,显然,如果卷积核越大,则当前像素受到更多的像素的影响,则可以得出结论:模糊半径(即卷积核尺寸)越大,图像就越模糊。从数值角度看,就是数值越平滑。

image.png

以上分别是原图、模糊半径3像素、模糊半径10像素的效果。

既然是加权平均,那么要怎么具体确定卷积核上每个小方块上的数值呢?不同的图像处理算法会有不同方式确定数值,例如均值滤波的卷积核是

image.png

所以被处理的像素的值为其周围所有像素相加的均值:

image.png

这样子会有什么问题么?如果使用简单平均,显然不是很合理,因为图像都是连续的,越靠近的点关系越密切,越远离的点关系越疏远。因此,加权平均更合理,距离越近的点权重越大,距离越远的点权重越小

于是使用高斯分布的高斯模糊应运而生。

高斯分布

其实就是大家中学学过的正态分布啦,不过还是复习下定义,再再次打开维基百科:

正态分布,是一个非常常见的连续概率分布。正态分布在统计学上十分重要,经常用在自然社会科学来代表一个不明的随机变量。正态分布是自然科学行为科学中的定量现象的一个方便模型。各种各样的心理学测试分数和物理现象比如光子计数都被发现近似地服从正态分布。

image.png

image.png

从上图可以看出,当x等于μ\mu的时候为其中心点,也就是最高点,2边逐渐递减。且σ\sigma越小,则中心的凸起部分高度越高、宽度越窄。

高斯模糊是一种图像模糊滤波器,它用正态分布μ\mu取0的情况)计算图像中每个像素的变换

N维空间正态分布方程为:

image.png

其中r在这里称为模糊半径,σ是正态分布的标准偏差

在二维空间定义为:

image.png

这里相当于: image.png

在二维空间中,这个公式生成的曲面的等高线是从中心开始呈正态分布的同心圆

image.png

也是典型的中间高,2边逐渐递减,中心点(0,0)最高,即μ\mu=0,v=0的点最高。

那么高斯模糊为什么要使用正态分布计算卷积核的权值呢?前面已经说过,均值滤波不是很科学,加权平均更合理,距离越近的点权重越大,距离越远的点权重越小。而正态分布恰好满足中心点值最大,距离中心越远值越小

举个栗子?

d4f241a2efa04f622a5db85f4c5fc625.jpeg

以下例子引用于阮一峰老师的博文高斯模糊的算法

假定中心点的坐标是(0,0),那么距离它最近的8个点的坐标如下:

更远的点以此类推。

为了计算权重矩阵,需要设定σ的值。假定σ=1.5,则模糊半径为1的权重矩阵如下:

这9个点的权重总和等于0.4787147,如果只计算这9个点的加权平均,还必须让它们的权重之和等于1,因此上面9个值还要分别除以0.4787147,得到最终的权重矩阵。

五、计算高斯模糊

有了权重矩阵,就可以计算高斯模糊的值了。

假设现有9个像素点,灰度值(0-255)如下:

每个点乘以自己的权重值:

得到

将这9个值加起来,就是中心点的高斯模糊的值。

对所有点重复这个过程,就得到了高斯模糊后的图像。如果原图是彩色图片,可以对RGB三个通道分别做高斯模糊。

滤镜升级打怪

钻石

高斯模糊滤镜

滤镜升级打怪继续进行~前面说了那么多理论知识,该来点实物展示了:

blur.gif

不是你忘了戴眼镜,也不是视频录制问题,要的就是这种类似近视的观看效果。

134e18f14322f87b365454639198bf53.jpg

先看下顶点着色器:

        #version 300 es
        layout (location = 0) 
        in vec4 aPosition;//输入的顶点坐标,会在程序指定将数据输入到该字段
        //如果传入的向量是不够4维的,自动将前三个分量设置为0.0,最后一个分量设置为1.0

        layout (location = 1) 
        in vec2 aTextCoord;//输入的纹理坐标,会在程序指定将数据输入到该字段

        out vec2 vTextCoord;//输出的纹理坐标;
        uniform mat4 uMatrix;//变换矩阵
        const int GAUSSIAN_SAMPLES = 9;
        out vec2 blurCoordinates[GAUSSIAN_SAMPLES];

        void main() {
            //这里其实是将上下翻转过来(因为安卓图片会自动上下翻转,所以转回来)
             vTextCoord = vec2(aTextCoord.x, 1.0 - aTextCoord.y);
            //直接把传入的坐标值作为传入渲染管线。gl_Position是OpenGL内置的
            gl_Position = uMatrix * aPosition;
            //横向和纵向的步长
            vec2 widthStep = vec2(10.0/1080.0, 0.0);
            vec2 heightStep = vec2(0.0, 10.0/1920.0);
            //计算出当前片段相邻像素的纹理坐标
            blurCoordinates[0] = vTextCoord.xy - heightStep - widthStep; // 左上
            blurCoordinates[1] = vTextCoord.xy - heightStep; // 上
            blurCoordinates[2] = vTextCoord.xy - heightStep + widthStep; // 右上
            blurCoordinates[3] = vTextCoord.xy - widthStep; // 左中
            blurCoordinates[4] = vTextCoord.xy; // 中
            blurCoordinates[5] = vTextCoord.xy + widthStep; // 右中
            blurCoordinates[6] = vTextCoord.xy + heightStep - widthStep; // 左下
            blurCoordinates[7] = vTextCoord.xy + heightStep; // 下
            blurCoordinates[8] = vTextCoord.xy + heightStep + widthStep; // 右下
        };

乍一看有点复杂,容我解释就很快就明白了。

6cf0713c53c6ae66864e102e797d6543.jpeg

如上图所示,假如中心点为当前顶点着色器处理的片段坐标,为了方便简单,这里我们取卷积核为3*3.则这里aPosition为(0,0),widthStep和heightStep分别表示横向和纵向的步长,即用来计算高斯模糊的相邻片段中心之间的距离,在上图这里即为1,实际代码取了10.0/1080.0(记得屏幕是归一化的)。计算出来的这块区域9个点的坐标值按照从左到右,从上到下一一存放在blurCoordinates数组中,最后传给片段着色器

(这里为了简单方便,直接写死了widthStep和heightStep,实际上运用一般要从外部传进去更合理)

再看看片段着色器:

#version 300 es

precision mediump float;
//纹理坐标
in vec2 vTextCoord;
//输入的yuv三个纹理
uniform sampler2D yTexture;//采样器
uniform sampler2D uTexture;//采样器
uniform sampler2D vTexture;//采样器
out vec4 FragColor;
const lowp int GAUSSIAN_SAMPLES = 9;
in highp vec2 blurCoordinates[GAUSSIAN_SAMPLES];
//卷积核
mat3 kernelMatrix = mat3(
                0.0947416f, 0.118318f, 0.0947416f,
                0.118318f,  0.147761f, 0.118318f,
                0.0947416f, 0.118318f, 0.0947416f
);
//yuv转rgb计算矩阵
mat3 colorConversionMatrix = mat3(
                   1.0, 1.0, 1.0,
                   0.0, -0.39465, 2.03211,
                   1.13983, -0.58060, 0.0
);

//yuv转化得到的rgb向量数据
vec3 yuv2rgb(vec2 pos)
{
       vec3 yuv;
       yuv.x = texture(yTexture, pos).r;
       yuv.y = texture(uTexture, pos).r - 0.5;
       yuv.z = texture(vTexture, pos).r - 0.5;
       return colorConversionMatrix * yuv;
}

void main() {
       //采样到的yuv向量数据
       vec3 yuv;
       //卷积处理
       lowp vec3 sum = (yuv2rgb(blurCoordinates[0]) * kernelMatrix[0][0]);
       sum += (yuv2rgb(blurCoordinates[1]) * kernelMatrix[0][1]);
       sum += (yuv2rgb(blurCoordinates[2]) * kernelMatrix[0][2]);
       sum += (yuv2rgb(blurCoordinates[3]) * kernelMatrix[1][0]);
       sum += (yuv2rgb(blurCoordinates[4]) * kernelMatrix[1][1]);
       sum += (yuv2rgb(blurCoordinates[5]) * kernelMatrix[1][2]);
       sum += (yuv2rgb(blurCoordinates[6]) * kernelMatrix[2][0]);
       sum += (yuv2rgb(blurCoordinates[7]) * kernelMatrix[2][1]);
       sum += (yuv2rgb(blurCoordinates[8]) * kernelMatrix[2][2]);
       FragColor = vec4(sum,1.0);
};

之前讲过的yuv转rgb就不讲了,不清楚的童鞋可以看看一看就懂的OpenGL ES教程——渲染宫崎骏动漫重拾童年

首先kernelMatrix就是上面列出的卷积核:

通过yuv2rgb函数获取blurCoordinates上每个坐标对应的片段上的rgb数值,然后通过kernelMatrix做卷积操作,也就是乘积叠加操作。

看起来代码挺长,其实捋一捋就是这么简单。

dc3214823284b12412d1e29b0dea0803.jpeg

这时候你可能会有个疑问,不是说定点着色器执行的次数和顶点个数一样,这样子输出的blurCoordinates数组不就只有4个(矩形),那么到了片段着色器怎么又能每个片段都有对应的blurCoordinates数组可以供其采样相邻片段呢?如果还有这个疑问,这个时候,就要复习下之前的这篇博文了 一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形之渲染渐变色及光栅化插值原理

所谓学而时习之,不亦说乎~~

总结

不知不觉又是几千字,今天主要先从理论介绍了图像平滑技术,然后重点讲了高斯滤波的原理,最后讲了OpenGL的shader如何对视频进行高斯模糊滤镜处理。总的来说,如果理解了高斯滤波理论以及之前的系列博文,那么理解代码就是水到渠成的事。接下来的博文,视频滤镜系列还会讲下一些酷炫的仿抖音滤镜,然后就要开始3D的征程了~

74baf4008794b34ff46b353aa2932407.jpg

项目代码

opengl-es-study-demo 不断更新中,欢迎各位来star~

参考:

高斯模糊的算法
Gaussian blur
卷积
图像处理基本知识(理解卷积非常有用)
OpenGL.Shader:志哥教你写一个滤镜直播客户端(10)视觉滤镜:高斯滤波 / 高斯模糊 原理实现

原创不易,如果觉得本文对自己有帮助,别忘了随手点赞和关注,这也是我创作的最大动力~

系列文章目录

体系化学习系列博文,请看音视频系统学习总目录

实践项目: 介绍一个自己刚出炉的安卓音视频播放录制开源项目 欢迎各位来star~

相关专栏:

C/C++基础与进阶之路

音视频理论基础系列专栏

音视频开发实战系列专栏

一看就懂的OpenGL es教程