Android下实现高效的模糊效果


其实有关 android 下实现图片模糊的文章有很多,大多都是使用 renderscript 内置的 ScriptIntrinsicBlur 来实现的,这篇文章中的例子也不例外,但如果仅仅是调用一下 api 的话就没必要去写了。所以接下来会介绍均值模糊以及高斯模糊的原理、什么是 renderscript 以及如何编写 renderscript。最终的例子是将图片高斯模糊处理后再调用自己编写的 rs 对其增加一层蒙版效果(这里会提到计算机是如何处理透明度以及颜色叠加的)。

系好安全带,开车了!


上面这张图片是用于图像算法测试的国际标准图像,使用这张图片主要有两个原因:

  1. 图像包含了各种细节、平滑区域、阴影和纹理,这些对测试各种图像处理算法很有用。

  2. 图像里是一个很迷人的女子。而图像处理领域里的人大多为男性,可以吸引更多的人。

然而这张图片其实出自 1972 年的 《花花公子》,所以上面给出的图片并不完整,下面我们写一个小 demo 来展示一下完整的图片。

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    imageView = (ImageView) findViewById(R.id.image);
    drag = (ImageView) findViewById(R.id.drag);
    SeekBar progressBar = (SeekBar) findViewById(R.id.seek);

    bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.rina);
    imageView.setImageBitmap(bitmap);
    imageView.post(new Runnable() {
        @Override
        public void run() {
            float scale = bitmap.getWidth() * 1f / imageView.getWidth();
            mosaic = Bitmap.createBitmap(bitmap, (int) (drag.getX() * scale), (int) (drag.getY() * scale),
                    (int) (drag.getWidth() * scale), (int) (drag.getHeight() * scale));
            drag.setImageBitmap(BlurHelper.mosaic(mosaic, currentRadius));
        }
    });

    progressBar.setProgress(currentRadius * 5);
    progressBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
        @Override
        public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
            if (progress % 5 == 0) {
                currentRadius = progress / 5;
                drag.setImageBitmap(BlurHelper.mosaic(mosaic, currentRadius));
            }
        }

        @Override
        public void onStartTrackingTouch(SeekBar seekBar) {

        }

        @Override
        public void onStopTrackingTouch(SeekBar seekBar) {

        }
    });

    drag.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    xDown = event.getX();
                    yDown = event.getY();
                    break;
                case MotionEvent.ACTION_MOVE:
                    float targetX = event.getX() - xDown + v.getTranslationX();
                    float targetY = event.getY() - yDown + v.getTranslationY();
                    targetX = Math.min(Math.max(targetX, 0), imageView.getWidth() - drag.getWidth());
                    targetY = Math.min(Math.max(targetY, 0), imageView.getHeight() - drag.getHeight());
                    v.setTranslationX(targetX);
                    v.setTranslationY(targetY);
                    float scale = bitmap.getWidth() * 1f / imageView.getWidth();
                    b = Bitmap.createBitmap(bitmap, (int) (drag.getX() * scale), (int) (drag.getY() * scale),
                            (int) (drag.getWidth() * scale), (int) (drag.getHeight() * scale));
                    drag.setImageBitmap(BlurHelper.mosaic(mosaic, currentRadius));
                    break;
            }
            return true;
        }
    });
}

public static Bitmap mosaic(Bitmap bitmap, int radius) {
    if (radius == 0) return bitmap;
    final int width = bitmap.getWidth();
    final int height = bitmap.getHeight();
    final Bitmap outBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());
    final int[] pixels = new int[width * height];
    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);

    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            int x = j % radius;
            int y = i % radius;
            pixels[i * width + j] = pixels[(i - y) * width + j - x];
        }
    }
    outBitmap.setPixels(pixels, 0, width, 0, 0, width, height);
    return outBitmap;
}

因为原图有点少儿不宜,所以我这边手动给打了个码,在原图上盖了一层马赛克后的图片,每次拖动后都会重新计算。其实马赛克算法也是一种模糊算法,首先图片其实是由很多像素点组成的一个二维数组(或者矩阵)。上面的马赛克算法只是遍历了图片的每一个像素,然后在这个过程中对于给定的半径将所有的像素都设置成第一个像素的值。

我们由此抛砖引玉引出均值模糊(box blur),和马赛克算法差不多,他是每一个像素都取周围像素的平均值。算法也比较简单,如下:

public static Bitmap boxBlur(Bitmap bitmap, int radius) {
    final int width = bitmap.getWidth();
    final int height = bitmap.getHeight();
    final int[] pixels = new int[width * height];
    final int[] outPixels = new int[width * height];
    final Bitmap outBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());
    
    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);

    //遍历bitmap每一个像素
    for (int i = 0; i < width; i++) {
        for (int j = 0; j < height; j++) {
            //取半径为radius的矩形区域,并处理边界情况
            final int left = i - radius < 0 ? 0 : i - radius;
            final int top = j - radius < 0 ? 0 : j - radius;
            final int right = i + radius > width ? width : i + radius;
            final int bottom = j + radius > height ? height : j + radius;

            //矩形区域总像素
            final int count = (right - left) * (bottom - top);

            //分别求出矩形区域内rgb的总值
            int r = 0, g = 0, b = 0;
            for (int m = left; m < right; m++) {
                for (int n = top; n < bottom; n++) {
                    final int pixel = pixels[n * width + m];
                    r += Color.red(pixel);
                    g += Color.green(pixel);
                    b += Color.blue(pixel);
                }
            }
            //设置新的像素为矩形区域内像素的均值
            outPixels[j * width + i] = Color.rgb(r / count, g / count, b / count);
        }
    }
    outBitmap.setPixels(outPixels, 0, width, 0, 0, width, height);
    bitmap.recycle();
    return outBitmap;
}

上面这么写是为了看起来更清楚,他的时间复杂度为 O(n^2 * m^2)效率是极低的。anyway 进行均值模糊之后的效果如下图(图像大小 300 * 260,模糊半径 5 ):

原图
均值模糊后

可以看到模糊的效果并不是很平滑,仔细看可以看到一个个小格子。显然取平均的方式并不是特别好,对于图像而言我们可以认为越靠近中心点与其关系越密切,而离中心点越远的像素相关程度也就越低,采用加权平均的方式似乎更合理一些。如果你是理科生的话,不知道你是否还记得高中数学书上提到过的正态分布(高斯分布)。下图是正态分布的函数曲线:

正态分布曲线

距离中心点越近,值就越大,完全符合我们的需求,可以作为计算平均时的权值,使用正态分布曲线来进行模糊计算的方式就叫做高斯模糊(gaussian blur)。u = 0 时的二维高斯曲线的函数如下:


其中 sigma 决定了数据的离散程度,sigma 越大曲线越扁,反之依然。

下面我们用代码简单实现一下,首先生成一个高斯模糊的概率矩阵:

private static float[][] makeGaussianBlurKernel(int radius, float sigma) {
    //根据公式先计算一下2 * sigma ^ 2 便于之后计算
    final float sigmaSquare2 = sigma * sigma * 2;

    //半径是指中心点距离边界的距离,所以如果半径为1,则需要一个3 * 3的矩阵
    final int size = radius * 2 + 1;
    final float[][] matrix = new float[size][size];

    float sum = 0;
    int row = 0;
    for (int i = -radius; i <= radius; i++) {
        int column = 0;
        for (int j = -radius; j <= radius; j++) {
            //根据公式计算出值
            matrix[row][column] = (float) (1 / (Math.PI * sigmaSquare2)
                    * Math.exp(-(i * i + j * j) / sigmaSquare2));

            sum += matrix[row][column];
            column++;
        }
        row++;
  
    //算出均值,使总概率为1
    for (int i = 0; i < size; i++) {
        for (int j = 0; j < size; j++) {
            matrix[i][j] /= sum;
        }
    }
    return matrix;
}```
接下来就是根据上面算出的矩阵对图像进行加权平均了,在这里处理边界情况的时候偷了个懒,假设越界后的像素对于边界是镜面的。
```java
public static Bitmap gaussianBlur(Bitmap bitmap, int radius) {
    final int width = bitmap.getWidth();
    final int height = bitmap.getHeight();
    final int[] pixels = new int[width * height];
    final int[] outPixels = new int[width * height];
    final Bitmap outBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());
    final float[][] blurMatrix = makeGaussianBlurKernel(radius, radius / 2.5f);

    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);

    for (int i = 0; i < width; i++) {
        for (int j = 0; j < height; j++) {
            final int left = i - radius;
            final int top = j - radius;
            final int right = i + radius;
            final int bottom = j + radius;

            int row = 0;
            float r = 0, g = 0, b = 0;

            for (int n = top; n <= bottom; n++) {
                int column = 0;
                int y = n;
                if (y >= height) y = height - 1 - (y - height);
                if (y < 0) y = -y;
                for (int m = left; m <= right; m++) {
                    int x = m;
                    if (x >= width) x = width - 1 - (x - width);
                    if (x < 0) x = -x;
                    final int pixel = pixels[y * width + x];
                    r = r + blurMatrix[row][column] * Color.red(pixel);
                    g = g + blurMatrix[row][column] * Color.green(pixel);
                    b = b + blurMatrix[row][column] * Color.blue(pixel);
                    column++;
                }
                row++;
            }
            outPixels[j * width + i] = Color.rgb((int) r, (int) g, (int) b);
        }
    }
    outBitmap.setPixels(outPixels, 0, width, 0, 0, width, height);
    bitmap.recycle();
    return outBitmap;
}

效果如图(图像大小300 * 260,模糊半径 5,sigma = 2.5 ):

高斯模糊

可以看到,模糊效果相较之前过度更加平滑,不过上面这四个 for 循环真的有点蛋疼,不算生成模糊矩阵的时间,处理这张 300 * 260 的图片总共花了 387ms,我们看看能不能进行一下优化。

首先我们可以把二维的高斯模糊拆成横向与纵向两个一维高斯模糊的组合,这样时间复杂度就下降到了 O(n ^ 2 * 2 * m)。u = 0 时,一维高斯曲线的函数如下:

根据公式生成一维矩阵:

private static float[] makeGaussianBlur1DKernel(int radius, float sigma) {
    final float sigmaSquare2 = sigma * sigma * 2;
    final int size = radius * 2 + 1;
    final float[] matrix = new float[size];
    float sum = 0;
    int index = 0;
    for (int i = -radius; i <= radius; i++) {
        matrix[index] = (float) (1 / (Math.sqrt(2 * Math.PI) * sigma) * Math.exp(-(i * i) / sigmaSquare2));
        sum += matrix[index];
        index++;
  
    for (int i = 0; i < size; i++) {
        matrix[i] /= sum;
    }
    return matrix;
}

调整下之前的算法:

public static Bitmap gaussianBlur2(Bitmap bitmap, int radius) {
    final int width = bitmap.getWidth();
    final int height = bitmap.getHeight();
    final float[] blurMatrix = makeGaussianBlur1DKernel(radius, radius / 2f);
    final Bitmap outBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());
    final int[] pixels = new int[width * height];

    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);

    //横向高斯模糊
    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            final int left = j - radius;
            final int right = j + radius;

            int index = 0;
            float r = 0, g = 0, b = 0;

            for (int m = left; m <= right; m++) {
                int x = m;
                if (x >= width) x = width - 1 - (x - width);
                if (x < 0) x = -x;
                final int pixel = pixels[i * width + x];
                r = r + blurMatrix[index] * Color.red(pixel);
                g = g + blurMatrix[index] * Color.green(pixel);
                b = b + blurMatrix[index] * Color.blue(pixel);

                index++;
            }

            pixels[i * width + j] = Color.rgb((int) r, (int) g, (int) b);
        }
    }
    //纵向高斯模糊
    for (int i = 0; i < width; i++) {
        for (int j = 0; j < height; j++) {
            final int top = j - radius;
            final int bottom = j + radius;

            int index = 0;
            float r = 0, g = 0, b = 0;

            for (int m = top; m <= bottom; m++) {
                int y = m;
                if (y >= height) y = height - 1 - (y - height);
                if (y < 0) y = -y;
                final int pixel = pixels[y * width + i];
                r = r + blurMatrix[index] * Color.red(pixel);
                g = g + blurMatrix[index] * Color.green(pixel);
                b = b + blurMatrix[index] * Color.blue(pixel);

                index++;
            }

            pixels[j * width + i] = Color.rgb((int) r, (int) g, (int) b);
        }
    }

    outBitmap.setPixels(pixels, 0, width, 0, 0, width, height);
    bitmap.recycle();
    return outBitmap;
}

运行一下,处理同一张图片的时间缩短到了 98ms。然而时间复杂度还可以降的更低,有一种作法是通过多次(通常是三次)均值模糊来模拟高斯模糊的效果。因为均值模糊的权值都为 1,所以可以记录一下移动过程中半径内像素的和,在 for 循环移动的过程中完成对所有像素点的计算,这样时间复杂度就降到了 O(n*n)。

public static Bitmap boxBlur(Bitmap bitmap, int radius) {
    final int width = bitmap.getWidth();
    final int height = bitmap.getHeight();
    final Bitmap outBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());

    final int[] pixels = new int[width * height];

    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);

    //纵向模糊
    for (int i = 0; i < width; i++) {
        int r = 0, g = 0, b = 0;
        int count = 0;

        //处理边界情况先将模糊半径内像素导入并求和
        for (int k = 0; k < radius; k++) {
            final int pixel = pixels[k * width + i];
            r += Color.red(pixel);
            g += Color.green(pixel);
            b += Color.blue(pixel);
            count++;
        }
        int headIndex = 0;
        //纵向逐个像素移动
        for (int j = 0; j < height; j++) {
            //预测尾部位置
            int last = j + radius;
            //超过宽度则从和中减去队列头部像素
            if (last >= height) {
                final int headPixel = pixels[headIndex * width + i];
                r -= Color.red(headPixel);
                g -= Color.green(headPixel);
                b -= Color.blue(headPixel);
                count--;
                headIndex++;
            } else if (count <= 2 * radius + 1) {
                //队列长度不足2 * radius + 1时向队列尾部添加新的像素
                final int pixel = pixels[j * width + i];
                r += Color.red(pixel);
                g += Color.green(pixel);
                b += Color.blue(pixel);
                count++;
            } else {
                //队列长度益处后加入新像素,移除头部像素
                final int headPixel = pixels[headIndex * width + i];
                final int pixel = pixels[j * width + i];
                r = r - Color.red(headPixel) + Color.red(pixel);
                g = g - Color.green(headPixel) + Color.green(pixel);
                b = b - Color.blue(headPixel) + Color.blue(pixel);
                headIndex++;
            }
            pixels[j * width + i] = Color.rgb(r / count, g / count, b / count);
        }
    }

    //横向模糊
    for (int i = 0; i < height; i++) {
        int r = 0, g = 0, b = 0;
        int count = 0;
        for (int k = 0; k < radius; k++) {
            final int pixel = pixels[i * width + k];
            r += Color.red(pixel);
            g += Color.green(pixel);
            b += Color.blue(pixel);
            count++;
        }
        int headIndex = 0;
        for (int j = 0; j < width; j++) {
            int last = j + radius;
            if (last >= width) {
                final int headPixel = pixels[i * width + headIndex];
                r -= Color.red(headPixel);
                g -= Color.green(headPixel);
                b -= Color.blue(headPixel);
                count--;
                headIndex++;
            } else if (count <= 2 * radius + 1) {
                final int pixel = pixels[i * width + j];
                r += Color.red(pixel);
                g += Color.green(pixel);
                b += Color.blue(pixel);
                count++;
            } else {
                final int headPixel = pixels[i * width + headIndex];
                final int pixel = pixels[i * width + j];
                r = r - Color.red(headPixel) + Color.red(pixel);
                g = g - Color.green(headPixel) + Color.green(pixel);
                b = b - Color.blue(headPixel) + Color.blue(pixel);
                headIndex++;
            }
            pixels[i * width + j] = Color.rgb(r / count, g / count, b / count);
        }
    }

    outBitmap.setPixels(pixels, 0, width, 0, 0, width, height);
    bitmap.recycle();
    return outBitmap;
}

同一张图片,上面的代码花费的时间为 31ms,如果采用不同半径进行三次运算来模拟高斯模糊的话,花费的时间大约会在 100ms 左右,看来起跟上面第二种算法差别不大。我们把模糊半径设置为 20,上面的算法花费的时间为 26ms,而第二种算法的时间为 249ms。

准确的说这个算法的时间复杂度应该是 O(n * (n + m)),看起来已经快逼近极限了,那还有没有办法更快呢?聪明的你肯定会说,使用 JNI 把运算过程丢给执行效率更高的 C 语言,这几个 for 循环也完全可以并行计算。你说的很对,而且这一切都不用你自己去实现,android 为此已经提供了一套解决方案。下面就郑重有请今天的主角 RenderScript。

RenderScript 是 android 上的高性能计算密集型框架,在 native 层进行数据并行运算,并且可以充分利用多核CPU 以及 GPU 的运算能力。语言本身是基于 C99 标准的,会先用 LLVM 编译称字节码,然后会在设备运行时编译成相应的机器码,所以他是平台无关的。

android 提供了一些内置的 api,我们可以直接在 java 层 调用,如下:

名称 说明
ScriptIntrinsic3DLUT 把 RGB 转换成 RGBA
ScriptIntrinsicBLAS Basic Linear Algebra Subprograms 提供了一些基本的向量和矩阵运算
ScriptIntrinsicBlend 混合两张图片,类似 imageView 的 tint
ScriptIntrinsicBlur 对图片进行高斯模糊运算
ScriptIntrinsicColorMatrix 将图片乘上一个色彩矩阵,可以实现诸如灰度化等色彩偏移效果
ScriptIntrinsicConvolve3x3 3 * 3 卷积运算 (事实上,均值模糊和高斯模糊都是对图像矩阵进行了卷积)
ScriptIntrinsicConvolve5x5 5 * 5 卷积运算
ScriptIntrinsicHistogram 直方图过滤器
ScriptIntrinsicLUT Lookup table
ScriptIntrinsicResize 图像缩放
ScriptIntrinsicYuvToRGB YUV 转 RGB

下面我就用 ScriptIntrinsicBlur 对图像进行高斯模糊,代码非常的简单:

public static Bitmap blur(Context context, Bitmap image, float radius) {
    RenderScript rs = RenderScript.create(context);
    Bitmap outputBitmap = Bitmap.createBitmap(image.getHeight(), image.getWidth(), Bitmap.Config.ARGB_8888);
    Allocation in = Allocation.createFromBitmap(rs, image);
    Allocation out = Allocation.createFromBitmap(rs, outputBitmap);

    ScriptIntrinsicBlur intrinsicBlur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
    intrinsicBlur.setRadius(radius);
    intrinsicBlur.setInput(in);
    intrinsicBlur.forEach(out);

    out.copyTo(outputBitmap);
    image.recycle();
    rs.destroy();
    return outputBitmap;
}

同一张图片,模糊半径设为 20,rs 处理的时间仅为 4ms。

可以看到在上面的代码中把 Bitmap 分配给了 Allocation。Allocation 用于和 RenderScript 共享内存。此外,还有 Element 和 Type。

名称 说明
Element 描述了一个内存单元,可以表示一个 rs 中的基本单位如 float,或者是一个由一系列基本单位组成的结构体
Type 描述了一段内存结构,是一个抽象的概念,并不实际分配内存。由一个 Element 和一个或多个维度组成(通常是一数组的 Element)
Allocation 某一个 Type 所描述的类型实际所分配的内存

对 intrinsicBlur 设置完相应的参数后,调用 intrinsicBlur.forEach(out) 就能并行的将数据输出到 out 中了。

至此,关于图像模糊的部分就结束了,但 rs 的部分并没有结束,除了使用系统内置的 api 外,我们是可以编写自己的rs脚本的,并且在编译期间会自动生成相应的 java 类。

有时候我们会遇到这样的需求,在对图片进行模糊之后,为了使盖在上面的文本看起来更清楚,还需在图片上加一层灰色的蒙版。当然你可以再往上盖一层背景为灰色带透明度的 view,不过有可能你的父布局是一个 linearLayout。再套一层的话是不能忍受的,其实我们完全可以减少这些由 measure、layout 所带来的不必要的性能开销。一种方式是通过给 ImageView 设置 colorfilter 或者 tint,在 PorterDuff.Mode.SRC_OVER 模式下应该可以达到同一种效果,不过有时候我们可能并没有使用 ImageView 而是设置了一个 BackgroundDrawable。既然在做高斯模糊的时候用了 rs,为什么不把这个操作紧跟着模糊之后处理呢,而且 rs 是并行计算的。

其实上面也提了内置的 ScriptIntrinsicBlend 可以实现 tint 的效果,但为了演示如何写一个 renderScript 我们自己来实现一下这个效果,而且你难道不好奇颜色在计算机中是如何处理的吗?

计算机中的颜色只是对现实中颜色进行量化后进行的抽象,最后还是由显示器根据这些值来决定具体以何种颜色呈现在我们眼前的。这也是为什么同一种色号在不同的显示器上看到的颜色可能不一样。现在比较常用的 是256 色,rgb 都用 0-255 的整数表示,正好各占 1 个字节。不过有的图片格式像是 png 还有一个 alpha 通道,如果 alpha 不等于 255 的话,他的颜色又是如何显示的呢?

在 ps 中先填充满不透明的白色,然后再在上面新建一个图层并用 #999 填充满,然后把这个图层的透明度调至 40%。

0x99 = 153
0xff = 255
214 = 255 * 0.6 + 153 * 0.4
看来 c = c1 * (1 - a)+ c2 * a,最终 a = 255

那如果一张透明度为 0.6(为了直观下面全部换成小数展示)的图片,与一张透明度为 0.5 的图片进行叠加最后的透明度会是多少呢?

我们可以把这两张图片想象成现实生活的两块玻璃。当光穿过透明度为 0.6 的玻璃后,只剩下 40% 的强度,这 40% 的光再穿过透明度 0.5 的玻璃后剩下 20% 强度的光,所以我们可以认为这两块玻璃叠加之后,透明度变为了 0.8。所以透明度的计算公式为:

a = 1 - (1 - a1 ) (1 - a2) = a1 + a2 - a1a2

接下来就是计算叠加后的颜色了,我们可以假设叠加后的颜色为 x,然后将 x 与一张不透明的图片叠加,得到的结果应该等于不透明图片先叠加第一张图片,再叠加第二张图片。于是我们就得到一个方程,解得 x:

x = (c1a1 * (1-a2) + c2a2 ) / (a1 + a2 - a1a2)

有了理论基础,现在我们就可以动手去实现了,首先在项目的 main 文件夹下新建一个名为 rs 的文件夹,然后在这个文件夹下新建我们的 tint.rs 文件。在编译的过程中会从这个文件夹下寻找 rs 文件,生成后的字节码文件可在 app/build/generated/res/rs/ 目录下找到。要编译自己写的rs,我们还需在 gradle 中加入以下代码:

defaultConfig {
    ...
    renderscriptTargetApi 18
    renderscriptSupportModeEnabled true
}

下面就可以编写我们的 tint.rs 了

#pragma version(1)
#pragma rs java_package_name(com.test.helper)

uchar4 maskColor = {0, 0, 0, 0};

static uchar mixRGB (uchar src, uchar mask, float inAlpha, float maskAlpha, float outAlpha) {
  return (uchar) (((src * (1 - maskAlpha) + mask * maskAlpha) / (inAlpha + maskAlpha - maskAlpha * inAlpha)) * outAlpha);
}

uchar4 __attribute__((kernel)) mask(uchar4 in) {
  uchar4 out = in;
  float inAlpha = (float)in.a / 255;
  float maskAlpha = (float)maskColor.a / 255;
  float outAlpha = 1 - (1 - maskAlpha) * (1 - inAlpha);

  out.r = mixRGB(in.r, maskColor.r, inAlpha, maskAlpha, outAlpha);
  out.g = mixRGB(in.g, maskColor.g, inAlpha, maskAlpha, outAlpha);
  out.b = mixRGB(in.b, maskColor.b, inAlpha, maskAlpha, outAlpha);
  out.a = (uchar) (outAlpha * 255);

  return out;
}

第一行注释指定了 rs 的版本,目前只有 1。第二行的话指定了对应生成的 java 文件的包名。第三行定义了蒙版的颜色,可以看到是 uchar4 类型的。这其实是一个由4个uchar类型组成的结构体,而 uchar 的话之前说过,rs 是跨平台的,所以他抽象了一层数据类型来保持类型的统一:

| | 8bits | 16bits | 32bits | 64bits |
| ---- | -----| ---- | -----|
| integer: | char, int8_t | short, int16_t | int32_t | long, long long , int64_t |
| Unsigned integer: | uchar, uint8_t | ushort, uinit16_t | uint, uint32_t | ulong, uinit64_t |
| Floating point: | | half | float | double |

在 rs 里面声明的变量会在自动生成的java文件中自动生成 get、set 方法,生成的代码如下:

private Element __U8_4;
private FieldPacker __rs_fp_U8_4;
private final static int mExportVarIdx_maskColor = 0;
private Short4 mExportVar_maskColor;
public synchronized void set_maskColor(Short4 v) {
    mExportVar_maskColor = v;
    FieldPacker fp = new FieldPacker(4);
    fp.addU8(v);
    int []__dimArr = new int[1];
    __dimArr[0] = 1;
    setVar(mExportVarIdx_maskColor, fp, __U8_4, __dimArr);
}

public Short4 get_maskColor() {
    return mExportVar_maskColor;
}

public Script.FieldID getFieldID_maskColor() {
    return createFieldID(mExportVarIdx_maskColor, null);
}

//private final static int mExportForEachIdx_root = 0;
private final static int mExportForEachIdx_mask = 1;
public Script.KernelID getKernelID_mask() {
    return createKernelID(mExportForEachIdx_mask, 35, null, null);
}

他具体做了些什么我们其实并不用关心,只要知道会生成 get 和 set 方法就可以了。

之后我们定义了一个 mixRGB 方法根据公式来混合像素点,将其声明为 static 方法,这样在编译的时候不会写进 java,而作为 rs 内部的方法使用。

接下来的 mask 方法是关键,__attribute__((kernel)) 可以简写为 RS_KERNEL,因为 rs 为了方便事先做了如下定义:

#define RS_KERNEL __attribute__((kernel))

这个声明告诉 rs,这个方法作为 mapping kernel 使用,他会在并行运算的时候被调用。类似函数式编程中的 map,对数据集依次执行 function,而这里被 RS_KERNEL 声明的方法就是这个 function。此外还有一个 reduction kernel,将数据集通过此方法合并成一个单一的值,类似于函数式编程中的 reduce 或者说 fold。 reduction kernel 是这样声明的:

#pragma rs reduce(addint) accumulator(addintAccum)

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

编译时 mask 方法会在 java 文件中对应生成以下方法:

public void forEach_mask(Allocation ain, Allocation aout) {
    forEach_mask(ain, aout, null);
}

public void forEach_mask(Allocation ain, Allocation aout, Script.LaunchOptions sc) {
    // check ain
    if (!ain.getType().getElement().isCompatible(__U8_4)) {
        throw new RSRuntimeException("Type mismatch with U8_4!");
    }
    // check aout
    if (!aout.getType().getElement().isCompatible(__U8_4)) {
        throw new RSRuntimeException("Type mismatch with U8_4!");
    }
    Type t0, t1;        // Verify dimensions
    t0 = ain.getType();
    t1 = aout.getType();
    if ((t0.getCount() != t1.getCount()) ||
        (t0.getX() != t1.getX()) ||
        (t0.getY() != t1.getY()) ||
        (t0.getZ() != t1.getZ()) ||
        (t0.hasFaces()   != t1.hasFaces()) ||
        (t0.hasMipmaps() != t1.hasMipmaps())) {
        throw new RSRuntimeException("Dimension mismatch between parameters ain and aout!");
    }

    forEach(mExportForEachIdx_mask, ain, aout, null, sc);
}

现在我们就可以在进行模糊处理之后加上一层蒙版了:

public static Bitmap blur(Context context, Bitmap image, float radius, String color) {
    RenderScript rs = RenderScript.create(context);
    Bitmap outputBitmap = Bitmap.createBitmap(image.getHeight(), image.getWidth(), Bitmap.Config.ARGB_8888);
    Allocation tmp1 = Allocation.createFromBitmap(rs, image);
    Allocation tmp2 = Allocation.createFromBitmap(rs, outputBitmap);
    blur(rs, tmp1, tmp2, radius);
    tint(rs, tmp2, tmp1, color);
    tmp1.copyTo(outputBitmap);
    image.recycle();
    rs.destroy();
    return outputBitmap;
}

private static void blur(RenderScript rs, Allocation in, Allocation out, float radius) {
    ScriptIntrinsicBlur intrinsicBlur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
    intrinsicBlur.setRadius(radius);
    intrinsicBlur.setInput(in);
    intrinsicBlur.forEach(out);
}

private static void tint(RenderScript rs, Allocation in, Allocation out, String color) {
    ScriptC_tint mScript = new ScriptC_tint(rs);
    mScript.set_maskColor(convertColor2Short4(color));
    mScript.forEach_mask(in, out);
}

private static Short4 convertColor2Short4(String color) {
    int c = Color.parseColor(color);
    short b = (short) (c & 0xFF);
    short g = (short) ((c >> 8) & 0xFF);
    short r = (short) ((c >> 16) & 0xFF);
    short a = (short) ((c >> 24) & 0xFF);
    return new Short4(r, g, b, a);
}

生成的 java 类名由 ScriptC_ 加上 rs 文件名组成,我们先调用 set_maskColor 方法设置蒙版的颜色 Color.parseColor 会把 argb 按顺序存在一个 int 中,所以我们可以用位运算提取每个通道的值),最后调用 forEach_mask 并发执行我们写在 rs 中的 mask 方法。

验证一下,效果跟直接覆盖一层view是完全一样的,但是性能完胜。

现在基本上加载图片都是用一些三方库来完成的,所以最后我们可以稍微封装一下,比如你用 picasso 来加载图片可以自定义一个 Transformation,而且如果你的图片模糊的比较厉害的话为了性能我们可以把原图缩小一点,图片缩放也可以用 rs 来完成,还记得上面提到的 ScriptIntrinsicResize 吗?

public static Bitmap blur(Context context, Bitmap image, float scale, float radius, String color) {
    RenderScript rs = RenderScript.create(context);
    final int width = (int) (image.getWidth() * scale);
    final int height = (int) (image.getHeight() * scale);

    Bitmap outputBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

    Allocation in = Allocation.createFromBitmap(rs, image);
    Type t = Type.createXY(rs, in.getElement(), width, height);
    Allocation tmp1 = Allocation.createTyped(rs, t);
    Allocation tmp2 = Allocation.createTyped(rs, t);

    //缩放
    ScriptIntrinsicResize theIntrinsic = ScriptIntrinsicResize.create(rs);
    theIntrinsic.setInput(in);
    theIntrinsic.forEach_bicubic(tmp1);

    //模糊
    ScriptIntrinsicBlur intrinsicBlur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
    intrinsicBlur.setRadius(radius);
    intrinsicBlur.setInput(tmp1);
    intrinsicBlur.forEach(tmp2);

    //蒙版
    ScriptC_tint mScript = new ScriptC_tint(rs);
    mScript.set_maskColor(convertColor2Short4(color));
    mScript.forEach_mask(tmp2, tmp1);

    tmp1.copyTo(outputBitmap);
    image.recycle();
    rs.destroy();
    return outputBitmap;
}
public class BlurTransformation implements Transformation {
    private Context context;
    private String color = "#00000000";
    private float radius = 20f;
    private int maxSize = 32;
    private boolean shouldScale = false;

    public BlurTransformation(Context context) {
        this.context = context;
    }

    public BlurTransformation setMaskColor(String color) {
        this.color = color;
        return this;
    }

    public BlurTransformation setRadius(float radius) {
        this.radius = radius;
        return this;
    }

    public BlurTransformation setMaxSize(int maxSize) {
        this.maxSize = maxSize;
        return this;
    }

    public BlurTransformation shouldScale(boolean shouldScale) {
        this.shouldScale = shouldScale;
        return this;
    }
    
    @Override
    public Bitmap transform(Bitmap source) {
        final int width = source.getWidth();
        final int height = source.getHeight();
        float scale = 1;
        if (shouldScale) {
            scale = width > height ? (float) maxSize / width : (float) maxSize / height;
        }
        return BlurHelper.blur(context, source, scale, radius, color);
    }

    @Override
    public String key() {
        return "rounded_corners";
    }
}

最后只需要这样调用一下就 ok 了

Picasso.with(this)
        .load(R.drawable.b)
        .transform(new BlurTransformation(this)
                .setMaskColor("#661e1433")
                .shouldScale(true)
                .setMaxSize(32)
                .setRadius(20))
        .into(imageView);

参考资料

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 161,664评论 24 692
  • 不同图像灰度不同,边界处一般会有明显的边缘,利用此特征可以分割图像。需要说明的是:边缘和物体间的边界并不等同,边缘...
    大川无敌阅读 12,136评论 0 29
  • 最新刚好遇到个需求是要求做高斯模糊的,虽然现有已经有一些框架可以提供调用,但关键还是要理解原理才行,思考的过程才是...
    Hohohong阅读 11,274评论 1 37
  • 2015年6月17日14:57 我穿梭在海洋里,这里的海水黑得吓人。我看不到周围的一切,时不时有群鱼划过自己,伤口...
    晴空A阅读 147评论 0 0
  • 简述 最近和第三方数据接触较多,数据量也开始陡增,从一开始的1KW行,最大到了1亿行,这让我这个常年处理‘小数据’...
    成鹏9阅读 17,002评论 6 9
  • 这样看: 定义了一个变量,给它取个名字叫a,这个名字是给你程序员看的,计算机跟本不看这个a,a对计算机来说只是一个...
    zxpzwbs阅读 946评论 0 0
  • 黄灯细雨夜孤舟 峰回路转潜洄游 柔声蜜语贴耳愁 还看昨夜尽风流
    倚诗爱世阅读 252评论 1 2