高斯模糊与图像卷积滤波一些知识点

1字数 4325阅读 5075

最新刚好遇到个需求是要求做高斯模糊的,虽然现有已经有一些框架可以提供调用,但关键还是要理解原理才行,思考的过程才是最重要的,高斯模糊的原理则与图像卷积滤波有些关系。

目录大纲

1.图像卷积滤波与高斯模糊
2.高斯模糊实现与优化
3.RenderScript的介绍与使用

一.图像卷积滤波与高斯模糊

1.1 图像卷积滤波

对于滤波来说,它可以说是图像处理最基本的方法,可以产生很多不同的效果。以下图来说



图中矩阵分别为二维原图像素矩阵,二维的图像滤波矩阵(也叫做卷积核,下面讲到滤波器和卷积核都是同个概念),以及最后滤波后的新像素图。对于原图像的每一个像素点,计算它的领域像素和滤波器矩阵的对应元素的成绩,然后加起来,作为当前中心像素位置的值,这样就完成了滤波的过程了。

可以看到,一个原图像通过一定的卷积核处理后就可以变换为另一个图像了。而对于滤波器来说,也是有一定的规则要求的。

  • ① 滤波器的大小应该是奇数,这样它才有一个中心,例如3x3,5x5或者7x7。有中心了,也有了半径的称呼,例如5x5大小的核的半径就是2。
  • ② 滤波器矩阵所有的元素之和应该要等于1,这是为了保证滤波前后图像的亮度保持不变。当然了,这不是硬性要求了。
  • ③ 如果滤波器矩阵所有元素之和大于1,那么滤波后的图像就会比原图像更亮,反之,如果小于1,那么得到的图像就会变暗。如果和为0,图像不会变黑,但也会非常暗。
  • ④ 对于滤波后的结构,可能会出现负数或者大于255的数值。对这种情况,我们将他们直接截断到0和255之间即可。对于负数,也可以取绝对值。

1.2 卷积核一些用法

既然知道滤波器可以用来对原图进行操作,那么,有没有一些比较具体的例子。文中卷积核相关图片来源于网络

1.2.1 空卷积核

可以看到,这个滤波器啥也没有做,得到的图像和原图是一样的。因为只有中心点的值是1。邻域点的权值都是0,对滤波后的取值没有任何影响。

1.2.2 图像锐化滤波器

图像的锐化和边缘检测很像,首先找到边缘,然后把边缘加到原来的图像上面,这样就强化了图像的边缘,使图像看起来更加锐利了。这两者操作统一起来就是锐化滤波器了,也就是在边缘检测滤波器的基础上,再在中心的位置加1,这样滤波后的图像就会和原始的图像具有同样的亮度了,但是会更加锐利。



我们把核加大,就可以得到更加精细的锐化效果


1.2.3 浮雕

浮雕滤波器可以给图像一种3D阴影的效果。只要将中心一边的像素减去另一边的像素就可以了。这时候,像素值有可能是负数,我们将负数当成阴影,将正数当成光,然后我们对结果图像加上128的偏移。这时候,图像大部分就变成灰色了。
下面是45度的浮雕滤波器

我们只要加大滤波器,就可以得到更加夸张的效果了

1.2.4 均值模糊

我们可以将当前像素和它的四邻域的像素一起取平均,然后再除以5,或者直接在滤波器的5个地方取0.2的值即可,如下图:

可以看到,这个模糊还是比较温柔的,我们可以把滤波器变大,这样就会变得粗暴了:注意要将和再除以13.

可以看到均值模糊也可以做到让图片模糊,但是它的模糊不是很平滑,不平滑主要在于距离中心点很远的点与距离中心点很近的所带的权重值相同,产生的模糊效果一样
而想要做到平滑,让权重值跟随中心点位置距离不同而不同,则可以利用正态分布(中间大,两端小)这个特点来实现。

1.3 高斯模糊

有了前面的知识,我们知道如果要想实现高斯模糊的特点,则需要通过构建对应的权重矩阵来进行滤波。

1.3.1 正态分布
正态分布

正态分布中,越接近中心点,取值越大,越远离中心,取值越小。
计算平均值的时候,我们只需要将"中心点"作为原点,其他点按照其在正态曲线上的位置,分配权重,就可以得到一个加权平均值。正态分布显然是一种可取的权重分配模式。

1.3.2 高斯函数

如何反映出正态分布?则需要使用高函数来实现。
上面的正态分布是一维的,而对于图像都是二维的,所以我们需要二维的正态分布。



正态分布的密度函数叫做"高斯函数"(Gaussian function)。它的一维形式是:

image.png

其中,μ是x的均值,σ是x的方差。因为计算平均值的时候,中心点就是原点,所以μ等于0。

image.png

根据一维高斯函数,可以推导得到二维高斯函数:

image.png

有了这个函数 ,就可以计算每个点的权重了。

1.3.3 获取权重矩阵

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


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

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

除以总值这个过程也叫做”归一问题“
目的是让滤镜的权重总值等于1。否则的话,使用总值大于1的滤镜会让图像偏亮,小于1的滤镜会让图像偏暗。

1.3.4 计算模糊值

有了权重矩阵,就可以计算高斯模糊的值了。
假设现有9个像素点,灰度值(0-255)如下:


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

得到

将这9个值加起来,就是中心点的高斯模糊的值。
对所有点重复这个过程,就得到了高斯模糊后的图像。对于彩色图片来说,则需要对RGB三个通道分别做高斯模糊。

1.3.5 边界值问题

既然是根据权重矩阵来进行处理的

image.png

如果一个点处于边界,周边没有足够的点,怎么办?

  • ① 对称处理,就是把已有的点拷贝到另一面的对应位置,模拟出完整的矩阵。
  • ② 赋0,想象图像是无限长的图像的一部分,除了我们给定值的部分,其他部分的像素值都是0
  • ③ 赋边界值,想象图像是无限制长,但是默认赋值的不是0而是对应边界点的值

二. 高斯模糊实现与优化

理解原理之后则可以做进一步的实现,从Android上来说。

2.1 构建权重矩阵

 public static double[][] getMatrix(int radius){
        //根据radius创建权重矩阵.
        int size = 2 * radius + 1;
        double[][] matrix = new double[size][size];
        double sigama = (double) radius / 3;
        double sigamaDouble = 2 * sigama * sigama;
        double sigamaPi = Math.PI * sigamaDouble;
        int row = 0;
        double sum = 0;
        for(int i = -radius ; i <= radius ; i++){
            int line = 0;
            for(int j = -radius ; j <= radius ; j++){
                double x = i * i;
                double y = j * j;
                matrix[row][line] = Math.exp(-(x + y)/sigamaDouble)/sigamaPi;
                sum += matrix[row][line];
                line++;
            }
            row++;
        }
        //归一
        for(int i = 0 ; i < size ; i++){
            for(int j = 0 ; j < size ; j++){
                matrix[i][j] /= sum;
            }
        }
        return matrix;
    }

对于第5行sigama的计算,参考正态分布曲线图,可以知道 3σ 距离以外的点,权重已经微不足道了。反推即可知道当模糊半径为r时,取σ为 r/3 是一个比较合适的取值。

2.2 计算

        //获取权重矩阵
        double[][] matrix = getMatrix(radius);
        int width  = scaleBitmap.getWidth();
        int height = scaleBitmap.getHeight();
        int[] currentPixels = new int[width * height];
        scaleBitmap.getPixels(currentPixels, 0, width, 0, 0, width, height)
for(int i = 0 ; i < width ; i++){
            for(int j = 0; j < height ; j++){
                int red = 0;
                int green = 0;
                int blue = 0;
                int x = i - radius;
                int y = j - radius;
                //先不处理边界值
                if(x >0 && y > 0 && (i+radius < width && j+radius < height)) {
                        for (int tempI = -radius; tempI <= radius; tempI++) {
                            for (int tempJ = -radius; tempJ <= radius; tempJ++) {
                                int color = currentPixels[(j + tempJ) * width + i + tempI];
                                red += (int) (Color.red(color) * matrix[tempI + radius][tempJ + radius]);
                                green += (int) (Color.green(color) * matrix[tempI + radius][tempJ + radius]);
                                blue += (int) (Color.blue(color) * matrix[tempI + radius][tempJ + radius]);
                            }
                        }
                        int color = currentPixels[j * width + i];
                        currentPixels[j * width + i] = Color.rgb(red, green, blue);
                    }
                }
            }
        }

这里currentPixels则是图像的像素矩阵。获取到的则是图片的像素的color,而通过Color对应的方法则可以转成对应的RGB形式,
也可以直接

red = (p & 0xff0000) >> 16;
green = (p & 0x00ff00) >> 8;
blue = (p & 0x0000ff);

当你跑程序后,你会等到怀疑世界。手机一些低端手机来说,手机性能是硬伤。而且上面的算法是最最最菜的算法,跑起来差不多要几十秒甚至几分钟。

2.3 优化

优化上可以分为很多种,一种是从图片像素上的优化,一种是算法的优化,另一种是调用层的优化,从java层改为jni层实现

2.3.1 图片像素上的优化

前面可以看到算法的主要循环在于width,height,radius,那么可以从降低像素点,也就是压缩图片上入手。既然模糊后的图片相比于原图来说是不清晰的,那么我也可以先对图片做压缩,然后再高斯,最终再放大,得到的结果也与原图直接模糊结果一样,都是不清晰的。当然如果对于清晰度来说的话,可以通过模糊半径radius来做调整。压缩太大就比较模糊,可以通过减小radius,相反,压缩太小则通过增加radius即可。

Matrix matrix = new Matrix();
matrix.setScale(0.1f , 0.1f);
Bitmap scaleBitmap = Bitmap.createBitmap(srcBitmap , 0 , 0 , srcBitmap.getWidth() , srcBitmap.getHeight() , matrix , true);
2.3.2 算法上的优化

前面可以看到,当前算法的复杂度则是 O(width×height×(2×radius)2),radius为模糊半径。
前面讲到的处理方式都是建立在二维的情况下进行的。高斯模糊也可以在二维图像上对两个独立的一维空间分别进行计算,这叫作线性可分。这也就是说,使用二维矩阵变换得到的效果也可以通过在水平方向进行一维高斯矩阵变换加上竖直方向的一维高斯矩阵变换得到。

回到前面一维高斯的计算公式

一维高斯

跟前面一样,不过这里的权重矩阵变为一维的

     //根据radius创建权重矩阵.
        int size = 2 * radius + 1;
        double[] matrix = new double[size];
        double sigama = (double) radius / 3;
        double sigamaDouble = 2 * sigama * sigama;
        double sqlPi = Math.sqrt(2 * Math.PI);
        double sigamaPi = sigama * sqlPi;
        int row = 0;
        double sum = 0;
        for(int i = -radius ; i <= radius ; i++){
            double x = i * i;
            matrix[row] = Math.exp(-x/sigamaDouble)/sigamaPi;
            sum += matrix[row];
            row++;
        }
        //归一处理目的是让权重总值等于1。
        //否则的话,使用总值大于1的滤镜会让图像偏亮,小于1的滤镜会让图像偏暗。
        for(int i = 0 ; i < size ; i++){
            matrix[i] /= sum;
        }

分别对横纵方向进行处理

  double[] matrix = getOneMatrix(radius);
        int width  = scaleBitmap.getWidth();
        int height = scaleBitmap.getHeight();
        int[] currentPixels = new int[width * height];
        int red[] = new int[width * height];
        int green[] = new int[width * height];
        int blue[] = new int[width * height];
        scaleBitmap.getPixels(currentPixels, 0, width, 0, 0, width, height);
        for(int j = 0 ; j < height ; j++){
            for(int i = 0 ; i < width ; i++){
                int n = 0;
                int x = i - radius;
                int y = j - radius;
                //先过滤边界值
                if(x >=0 && y >= 0 && (i+radius < width && j+radius < height)) {
                    for (int temp = -radius; temp <= radius; temp++) {
                        int point = temp + i;
                        int colorPoint = j * width + point;
                        int color = currentPixels[colorPoint];
                        red[colorPoint] += Color.red(color) * matrix[n];
                        green[colorPoint] += Color.green(color) * matrix[n];
                        blue[colorPoint] += Color.blue(color) * matrix[n];
                        n++;
                    }
                }
            }
        }

        for(int i = 0 ; i < width ; i++){
            for(int j = 0 ; j < height ; j++){
                int n = 0;
                int r = 0 , b = 0 , g = 0;
                int x = i - radius;
                int y = j - radius;
                //先过滤边界值
                if(x >=0 && y >= 0 && (i+radius < width && j+radius < height)) {
                    for (int temp = -radius; temp <= radius; temp++) {
                        int currentPoint = (j + temp) * width + i;
                        Log.e(TAG, "temp = " + temp + "  i = " + i + " j : " + j + " currentPoint = " + currentPoint
                        );
                        r += red[currentPoint] * matrix[n];
                        g += green[currentPoint] * matrix[n];
                        b += blue[currentPoint] * matrix[n];
                        n++;
                    }
                    currentPixels[j*width + i] = Color.rgb(r, g, b);
                }
            }
        }

此时的时间复杂度则为 O(width×height×2×radius×2)对比前面来说则少了radius倍。
当然这里算法的优化只是其中之一,网上有很多优化后的高斯模糊,比如android-stackblurFastBlur等,FastBlur则是参考Javascript来做个实现的,不过FastBlur的实现使用了很多额外的内存(它会复制整个位图到一个缓充区中),因此它适用于小位图,对于大图来说则比较容易造成OOM

2.3.3 转换到JNI层

对于Java与JNI来说,同样的代码在JNI层调用所耗的时间要比Java的调用要少的多,特别是在一些图像算法,或者游戏逻辑的时候。

JNI层面来说能够带来性能提升,它可以突破VM的内存限制,由自己来管理内存,而Java的内存管理全部由虚拟机来管理的。

三.RenderScript

官网介绍:RenderScript是Android平台上用于运行计算密集任务的框架。RenderScript主要是面向数据并行计算,当然了,RenderScript中使用串行计算效率也很好。RenderScript是充分利用手机GPU,CPU的计算能力,让开发者专注于算法而不在于调度。我们编写的代码无需关心具体的硬件的不同,都能写出高性能的代码。

RenderScript是基于C99语言的,我们需要通过写一个RenderScript脚本来控制。
结合官网上来做个入门。

3.1 编写rs内核脚本

在项目的代码目录下(即src根目录下/src/)创建rs文件,表示是脚本文件。这里新建一个image.rs,输入

#pragma version(1)
#pragma rs java_package_name(com.rdc.zzh.stackblurtest)
uchar4 __attribute__((kernel)) invert(uchar4 in)
{
  uchar4 out = in;
  out.r =255- in.r;
  out.g = 255-in.g;
  out.b = 255-in.b;
  return out;

}
  • 对于第一行来说,#pragma version(1)是指版本号,表示当前脚本所使用的版本,不过这里只能是1才是有效的,#pragma是标记给编译器看的。
  • 同样第二行,可以看出这里是告诉编译器当前应用的包名。因为每个rs文件都会自动生成对应的Java代码,比如,我们新建的hello.rs文件,会自动生成ScriptC_hello类,因此,我们需要在rs声明包的名称。
  • 这里使用到了 uchar4 __attribute__((kernel)) invert(uchar4 in) 可以知道这是一个调用方法。
    首先对于RenderScript来说它有两种计算内核形式,分别是 映射(mapping)内核减少(reduction)内核
  • 参数类型,uchar4表示的是一个4字节的类型,uchar4 in中可以直接用in.r,in.g,in.b,in.a 取出对应像素点的色值信息,每个值各占一个字节,取值范围则是在(0`255)也比较说得通。上面函数则表示对一个像素点做处理。
3.1.1 映射内核mapping kernel

映射内核:它是一个对相同维度的Allocations集合进行操作的并行函数。通常是将一个输入Allocations分配集转成另一个输出Allocations分配集。比如

uchar4 RS_KERNEL invert(uchar4 in, uint32_t x, uint32_t y) {
  uchar4 out = in;
  out.r = 255 - in.r;
  out.g = 255 - in.g;
  out.b = 255 - in.b;
  return out;
}

这里将输入in(传递的是Allocations),输出则是另一个out。
在多数情况下,这与C语言的标准函数语法一样,在这里 RS_KERNEL 它则是一个宏定义常量,表示的是一个映射内核而不是一个可调用函数。它的定义为
#define RS_KERNEL __attribute__((kernel))
由上面例子可知道我们用的是映射内核的形式,会有一个Allocations做为输入和输出。

此外,一个映射内核可有一个或者多个输入Allocations,有一个或者两个Allocation输出

3.1.2 减少内核reduction kernel

减少内核则是在相同维数的对输入Allocations进行操作的函数族。它主要用于“降维”一个输入的Allocation集合成一个单独的值。

下面则是一个减少内核将输入元素累加起来的例子

#pragma rs reduce(addint) accumulator(addintAccum)

static void addintAccum(int *accum, int val) {
  *accum += val;
}

在这里例子中,#pragma rs reduce则是用于定义内核的名字(这里表示的是addint内核),而后面的addintAccum则表示它是一个accumulator方法,所有这样的方法都应该是static的。而一个减少内核通常需要一个accumulator方法,它的返回值必须是void。

此外,一个减少内核拥有一个或者多个输入Allocation,但是没有输出Allocation。详细内容还得参考官方API文档。

3.2 Java代码调用


        mInBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test);
        mOutBitmap = Bitmap.createBitmap(mInBitmap.getWidth(),mInBitmap.getHeight(), mInBitmap.getConfig());
        mSrcImageView.setImageBitmap(mInBitmap);

        RenderScript rs = RenderScript.create(this);
        mScript = new ScriptC_image(rs);

       Allocation aIn = Allocation.createFromBitmap(rs, mInBitmap);
       Allocation aOut = Allocation.createFromBitmap(rs, mInBitmap);


        mScript.forEach_invert(aIn, aOut);
        aOut.copyTo(mOutBitmap);
        mDstImageView.setImageBitmap(mOutBitmap);
        rs.destroy();

首先这里先创建一个RenderScript对象,接着则是将编写的rs文件对应的自动生成的java类ScriptC_image初始化,接着则是创建两个Allocation,从名字可以看出是用来分配内存,作为映射内核的输入和输出,createFromBitmap则是根据Bitmap分配内存,把Bitmap的像素值传递到Allocation里面。

这两个Allocation的Element类型必须相同,在函数调用时RenderScript会检查,如果不想同会抛异常。这里提到了Element,Elemtent是指Allocation里的一项。比如我们要处理的是Bitmap,则Element表示的类型是像素。

接着调用到forEach_invert,后面的invert则是我们在rs里面编写的方法名字,这里则是映射到了ScriptC_image里面了,RenderScript会自动将aIn里的每个元素(Element)并行的去执行invert函数.得到的结果放入aOut里。最后调用Allocation的copyTo函数把计算的结果转入到Bitmap中。

当然这里所列举的都是比较简单的例子,很多关于图像间的操作都可以编写对应的rs文件来进行处理,RenderScript所涉及到的知识点比较多,详细的还得多参考官网API。

3.3 RenderScript高斯模糊

前面可以看到,实现的关键在于编写对应的rs文件生成响应的Script类,以此来进行调用。官方也已经给出了对应高斯模糊的实现类ScriptIntrinsicBlur,使用时的调用则为

//先对图片进行压缩然后再blur
Bitmap inputBitmap = Bitmap.createScaledBitmap(bitmap, Math.round(bitmap.getWidth() * bitmap_scale),
                Math.round(bitmap.getHeight() * bitmap_scale), false);
//创建空的Bitmap用于输出
Bitmap outputBitmap = Bitmap.createBitmap(inputBitmap);
//①、初始化Renderscript
RenderScript rs = RenderScript.create(context);
//②、Create an Intrinsic Blur Script using the Renderscript
ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
//③、native层分配内存空间
Allocation tmpIn = Allocation.createFromBitmap(rs, inputBitmap);
Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap);
//④、设置blur的半径然后进行blur
theIntrinsic.setRadius(blur_radius);
theIntrinsic.setInput(tmpIn);
theIntrinsic.forEach(tmpOut);
//⑤、拷贝blur后的数据到java缓冲区中
 tmpOut.copyTo(outputBitmap);
//⑥、销毁Renderscript
rs.destroy();
bitmap.recycle();

不过这里的限制条件则是API要大于17才可以调用,也可以自己导入一些v8兼容包。

3.4 对比

对于大图来说,FastBlur的实现效果是不如RenderScript好的,甚至会发生OOM问题。
而对于RenderScript来说,无论大图小图耗时则都是1050mm左右,而FastBlur在小图的情况下可以达到1050mm,甚至比RenderScript要好

四.参考链接

图片资料来源

推荐阅读更多精彩内容