最近遇到一个需求,是当接收到一个尺寸很大的纯文字图片时,需要在屏幕上缩小若干倍显示出来且不失真。
而一般 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) 位置的值:
对于目标矩阵 (1, 0) 位置的值:
后面的计算以此类推,可以注意到目标矩阵的值始终来源于源矩阵中某个像素点的值,其余的值丢失或者说不会作为目标矩阵值的参考
这样一来,对于纯文字图片来说,若图片中的文字线条并不是很粗的话,则缩小后像素点的取值很容易取到周围的白色像素点,造成图片失真的结果
在 Android 中使用邻近采样对图片缩放进行测试
代码:
val options = Options()
options.inSampleSize = 2 // 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) 的点,先看下述的图:
在 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 图像处理—锐化效果)
锐化方法:
/**
* 图片锐化(拉普拉斯变换)
*/
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) 为拉普拉斯图像变换方法