前言
最近遇到一个需求,是当接收到一个尺寸很大的纯文字图片时,需要在屏幕上缩小若干倍显示出来且不失真。
而一般 Android 对图片的处理方法是邻近采样或者双线性采样,下面对这两种方法一一进行测试,观察图片缩小后的变化 (下面会讲一下几种图像处理方法的差异,不感兴趣的可以直接跳到解决方法)
邻近采样
邻近采样其实就是目标像素是从周围的 x 个像素中找出一个像素来对应,相当于从若干个像素点中选中一个像素,其余像素直接丢弃
其具体的公式为:
srcX = dstX * ( srcWidth / dstWidth )
srcY = dstY * ( srcHeight / dstHeight )
(srcX 代表源像素点的 x 坐标,dstX 代表目标矩阵像素点的 x 坐标,srcWidth 为源矩阵宽度,dstWidth 为目标矩阵宽度)
举个例子,看下面这个矩阵变换: 这里将 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
val scaleImg = BitmapFactory.decodeFile("/sdcard/bg_test.png", options)
binding.scaleImg.setImageBitmap(scaleImg)
可以看到,缩小后的图片丢失像素点严重,基本不能组成完整的线条
双线性采样
双线性采样可以看成邻近采样法的一种进阶方法,它对于目标像素的选择参考了源矩阵源像素点周围的4个像素点的值,综合进行计算
依然是下面这个公式:
srcX = dstX * ( srcWidth / dstWidth )
srcY = dstY * ( srcHeight / dstHeight )
拿 4*4 的像素点矩阵缩放为 3*3 的矩阵举例,对于目标矩阵 (1, 1) 的点,先看下述的图:
- 对于目标矩阵 (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)
缩小后的效果图: 可以看到,相比于临近采样来说,因为每个像素点是参考周围四个像素点的值来决定的,所以部分像素点黑色会变浅但是不至于完全为白色,断续要改善了很多,但是还是会有模糊不清的问题,特别是一个小图体现在手机屏幕上,模糊不清的问题会格外明显 (类似的算法还有双三次采样,虽然是用卷积核来计算,但是纯文字图片处理效果和双线性算法差不多,故这里不多介绍,感兴趣的可以自己了解)
解决方法
- 采用Lanczos 算法对图像进行处理,该算法使用卷积核来通过输入像素计算输出像素,理论和双三次采样一样,但是在算法表现上稍有不同,这也导致其对纯文字图片处理要比双三次采样效果好上不少,缩放后基本可以得到一个平滑完整的纯文字图像,但是 Android 并不支持对该算法的直接引入,所以如果要使用要引用FFmpeg库,可能要自己编译 .so 文件,比较麻烦,而且引用库会占用比较多的资源,不推荐此种方法处理,属于迫不得已的方法
- 第二种就是这篇文章要说的,一种“逃课式”的解决办法,具体的思路是:既然我无法控制图像处理的算法,那么我就从图像本身入手,通过将原本图片中的文字加粗或者处理后的图片中的文字锐化,来间接达到图像清晰的目的
(下面锐化代码借鉴自麦麦鱼大佬的博客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))
最终与双线性变换对比图: 明显看到文字清晰了许多(如果觉得还是模糊可以再锐化一次),问题解决
|