纯文字图片缩小后像素点丢失的一种逃课式解决办法(Android)

前言

最近遇到一个需求,是当接收到一个尺寸很大的纯文字图片时,需要在屏幕上缩小若干倍显示出来且不失真。

而一般 Android 对图片的处理方法是邻近采样或者双线性采样,下面对这两种方法一一进行测试,观察图片缩小后的变化
(下面会讲一下几种图像处理方法的差异,不感兴趣的可以直接跳到解决方法)

邻近采样

邻近采样其实就是目标像素是从周围的 x 个像素中找出一个像素来对应,相当于从若干个像素点中选中一个像素,其余像素直接丢弃

其具体的公式为:

srcX = dstX * ( srcWidth / dstWidth ) 
srcY = dstY * ( srcHeight / dstHeight )

(srcX 代表源像素点的 x 坐标,dstX 代表目标矩阵像素点的 x 坐标,srcWidth 为源矩阵宽度,dstWidth 为目标矩阵宽度)

举个例子,看下面这个矩阵变换:
纯文字图片缩小后像素点丢失的一种逃课式解决办法(Android)_第1张图片
这里将 3*3 的矩阵变换为 2*2 的矩阵,具体的变换规则就按照上面的公式来

比如在该变换中,srcWidth 为源矩阵宽度为 3,dstWidth 为目标矩阵宽度为 2,所以比值为 1.5,Height 也一样

所以对于目标矩阵 (0, 0) 位置的值

  • dstX 为目标矩阵 x 坐标为 0,srcX = 0 * 1.5 = 0;
  • dstY 为目标矩阵 y 坐标为 0,srcY = 0 * 1.5 = 0;
  • 所以目标矩阵 (0, 0) 的值为源矩阵 (0, 0) 的值为 1

对于目标矩阵 (1, 0) 位置的值

  • dstX = 1,srcX = 1 * 1.5 = 1.5;
  • dstY = 0,srcY = 0 * 1.5 = 0;
  • 所以目标矩阵 (1, 0) 位置的值为源矩阵 (1.5, 0) 位置的值,按照四舍五入的规则,为源矩阵 (2, 0) 位置的值,为 7

后面的计算以此类推,可以注意到目标矩阵的值始终来源于源矩阵中某个像素点的值,其余的值丢失或者说不会作为目标矩阵值的参考

这样一来,对于纯文字图片来说,若图片中的文字线条并不是很粗的话,则缩小后像素点的取值很容易取到周围的白色像素点,造成图片失真的结果

在 Android 中使用邻近采样对图片缩放进行测试
代码:

val options = Options()
options.inSampleSize = 2  // inSampleSize 为 2 表示目标像素值会参考源矩阵的两个像素点坐标,选择一个丢弃另一个
val scaleImg = BitmapFactory.decodeFile("/sdcard/bg_test.png", options)
binding.scaleImg.setImageBitmap(scaleImg)

纯文字图片缩小后像素点丢失的一种逃课式解决办法(Android)_第2张图片
可以看到,缩小后的图片丢失像素点严重,基本不能组成完整的线条

双线性采样

双线性采样可以看成邻近采样法的一种进阶方法,它对于目标像素的选择参考了源矩阵源像素点周围的4个像素点的值,综合进行计算

依然是下面这个公式:

srcX = dstX * ( srcWidth / dstWidth ) 
srcY = dstY * ( srcHeight / dstHeight )

拿 4*4 的像素点矩阵缩放为 3*3 的矩阵举例,对于目标矩阵 (1, 1) 的点,先看下述的图:
纯文字图片缩小后像素点丢失的一种逃课式解决办法(Android)_第3张图片

  • 对于目标矩阵 (1, 1) 位置的像素点,源像素点可以经过上述公式得到 (4/3, 4/3)
  • 对于双线性采样算法来说,目标位置像素值就取决于 (4/3, 4/3) 周围四个点的取值,也就是源矩阵的 (1, 1)、(1, 2)、(2, 1)、(2, 2) 四个点的值
  • 又因为 (4/3, 4/3) 的点距离 (1, 1) 点更近,所以受到 (1, 1) 点的值影响更大一点
    (具体算法可以参考某大佬的文章三十分钟理解:线性插值,双线性插值Bilinear Interpolation算法,这里不过多讲解)

在 Android 中,典型的两种方法使用的双线性采样:
方法一:

val bitmap = BitmapFactory.decodeFile("/sdcard/bg_test.png")
val scaleImg = Bitmap.createScaledBitmap(bitmap, bitmap.width / 2, bitmap.height / 2, true)
binding.scaleImg.setImageBitmap(scaleImg)

方法二:

val bitmap = BitmapFactory.decodeFile("/sdcard/bg_test.png")
val matrix = Matrix()
matrix.setScale(0.5f, 0.5f)
val scaleImg = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
binding.scaleImg.setImageBitmap(scaleImg)

缩小后的效果图:
纯文字图片缩小后像素点丢失的一种逃课式解决办法(Android)_第4张图片
可以看到,相比于临近采样来说,因为每个像素点是参考周围四个像素点的值来决定的,所以部分像素点黑色会变浅但是不至于完全为白色,断续要改善了很多,但是还是会有模糊不清的问题,特别是一个小图体现在手机屏幕上,模糊不清的问题会格外明显
(类似的算法还有双三次采样,虽然是用卷积核来计算,但是纯文字图片处理效果和双线性算法差不多,故这里不多介绍,感兴趣的可以自己了解)

解决方法

  1. 采用Lanczos 算法对图像进行处理,该算法使用卷积核来通过输入像素计算输出像素,理论和双三次采样一样,但是在算法表现上稍有不同,这也导致其对纯文字图片处理要比双三次采样效果好上不少,缩放后基本可以得到一个平滑完整的纯文字图像,但是 Android 并不支持对该算法的直接引入,所以如果要使用要引用FFmpeg库,可能要自己编译 .so 文件,比较麻烦,而且引用库会占用比较多的资源,不推荐此种方法处理,属于迫不得已的方法
  2. 第二种就是这篇文章要说的,一种“逃课式”的解决办法,具体的思路是:既然我无法控制图像处理的算法,那么我就从图像本身入手,通过将原本图片中的文字加粗或者处理后的图片中的文字锐化,来间接达到图像清晰的目的

(下面锐化代码借鉴自麦麦鱼大佬的博客android 图像处理—锐化效果)

锐化方法:

/**
 * 图片锐化(拉普拉斯变换)
 */
fun sharpenImageAmeliorate(bmp: Bitmap): Bitmap? {
    // 拉普拉斯矩阵
    val laplacian = intArrayOf(-1, -1, -1, -1, 9, -1, -1, -1, -1)
    val width = bmp.width
    val height = bmp.height
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    var pixR = 0
    var pixG = 0
    var pixB = 0
    var pixColor = 0
    var newR = 0
    var newG = 0
    var newB = 0
    var idx = 0
    val alpha = 1f
    // 原图像素点数组
    val pixels = IntArray(width * height)
    // 创建一个新数据保存锐化后的像素点
    val pixels_1 = IntArray(width * height)
    bmp.getPixels(pixels, 0, width, 0, 0, width, height)
    var i = 1
    val length = height - 1
    while (i < length) {
        var k = 1
        val len = width - 1
        while (k < len) {
            idx = 0
            for (m in -1..1) {
                for (n in -1..1) {
                    pixColor = pixels[(i + n) * width + k + m]
                    pixR = Color.red(pixColor)
                    pixG = Color.green(pixColor)
                    pixB = Color.blue(pixColor)
                    newR = newR + (pixR * laplacian[idx] * alpha).toInt()
                    newG = newG + (pixG * laplacian[idx] * alpha).toInt()
                    newB = newB + (pixB * laplacian[idx] * alpha).toInt()
                    idx++
                }
            }
            newR = Math.min(255, Math.max(0, newR))
            newG = Math.min(255, Math.max(0, newG))
            newB = Math.min(255, Math.max(0, newB))
            pixels_1[i * width + k] = Color.argb(255, newR, newG, newB)
            newR = 0
            newG = 0
            newB = 0
            k++
        }
        i++
    }
    bitmap.setPixels(pixels_1, 0, width, 0, 0, width, height)
    return bitmap
}

简单解释一下拉普拉斯变换的思路,其本质是采用微分计算对图像进行逆运算来突出图像细节
(比如双线性算法得到的像素点是对原图像周围像素点的平均计算,而逆运算就是将该过程反过来)

拉普拉斯变换将拉普拉斯图像通过一定系数转换并与原图像叠加,通过将转换后的矩阵在原图像的逐行移动,将其数值与其重合的像素相乘后求值,得到与移动矩阵中心重合像素的值,对于无法计算的值做赋 0 操作

说起来很玄乎,其实看公式就懂了,比如:
                  A, B, C                                      -1, -1, -1
原矩阵为:D, E, F             拉普拉斯矩阵:-1,  9, -1
                  G, H, I                                       -1, -1, -1

则对于目标矩阵 E 点的值 xE,有:
xE = [ (-1) * xA ] + [ (-1) * xB ] + ··· + ( 9 * xE ) + ··· + [ (-1) * xH ] + [ (-1) * xI ]

每个点都是如此计算,而对于边缘的点(周围不足 8 个点的),缺失的点值按 0 计算,最终得到新的矩阵
(这里的拉普拉斯转换矩阵系数为 1,有可能系数为其他)

加入拉普拉斯变换的图像处理代码:

val bitmap = BitmapFactory.decodeFile("/sdcard/bg_test.png")
val scaleImg = Bitmap.createScaledBitmap(bitmap, bitmap.width / 2, bitmap.height / 2, true)
binding.scaleImg.setImageBitmap(sharpenImageAmeliorate(scaleImg))  // sharpenImageAmeliorate(bitmap) 为拉普拉斯图像变换方法

最终与双线性变换对比图:
纯文字图片缩小后像素点丢失的一种逃课式解决办法(Android)_第5张图片
明显看到文字清晰了许多(如果觉得还是模糊可以再锐化一次),问题解决

你可能感兴趣的:(Android,图像处理,图片缩放,kotlin,Android,算法)