Bitmap 图像灰度变换原理浅析

上篇文章《拥抱 C/C++ : Android JNI 的使用》里提到调用 native 方法直接修改 bitmap 像素缓冲区,从而实现将彩色图片显示为灰度图片的方法。这篇文章将介绍该操作的实现原理。

开始先不讲关于 Bitmap 的相关细节,先从计算机底层存储与运算原理讲起。总所周知,计算机只识别 0 和 1,无论是八进制、十进制、十六进制,在底层都会被转换为二进制。有几个单位与概念要提及一下:

计量单位

bit(位)

计算机表示信息的最小单位,也是最小的存储单位,只有两种状态:0 和 1。即二进制位。

平时常见的 32 位出流程就是一次最多能处理 32 位的数据,也就是 4 个 byte(字节)。同理,64 位处理器一次最多能处理 64 位的数据,即 8 个字节。

byte(字节)

  • 1 KB = 1024 Byte
  • 1 MB = 1024 KB
  • 1 GB = 1024 MB

通常一个字节由 8 个二进制位(bit)组成。

一个十六进制数需要由 4 个二进制组成,即一个字节可以标识 2 个十六进制数。

基本数据类型的长度

对 C/C++ 而言,不同的操作平台分配给基本数据类型的长度(字节)是不一样的,比如 char* 指针变量在 32 位编译器里是 4 个字节(32 位的寻址空间是 2^32, 即 32 个 bit,也就是 4 个字节。64 位编译器同理),在 64 位编译器里是 8 个字节。

而 Java 是跨平台语言,JVM 里的基础数据类型的字节长度是一致的。各基本数据类型长度如下:

int:4 个字节
short:2 个字节。
long:8 个字节。
byte:1 个字节。
float:4 个字节。
double:8 个字节。
char:2 个字节。
boolean:boolean 属于布尔类型,在存储的时候不使用字节,仅使用 1 位来存储,范围仅为 0 和 1,其字面量为 true 和 false。

基本数据类型的取值范围

以最常见的 int 为例,Java 中 int 是 4 个字节,那 int 的取值范围是多少呢?熟悉 api 的同学都知道,Integer 类里定义了 MAX_VALUE = 0x7fffffff,那就来推算一下 Java 定义的这个值对不对(大雾

int 占 4 个字节 32 位,因此就是 8 位数的十六进制。因为 int 值有正负之分,所以最高位表示符号,0 代表正数,1 代表负数。显而易见,int 能表示的最大值的二进制为 0111 1111 1111 1111 1111 1111 1111 1111 ,最高位 0,后面跟 31 个 1。换算成十六进制就是 0x7FFFFFFF,该值与 Jdk 中定义的相同,可见 Jdk 还是很严谨的(2333),Java 大法好!同理,最小值的二进制为 1111 1111 1111 1111 1111 1111 1111,换算成十六进制就是 0xFFFFFFFF,再对照一下 Jdk 中定义的最小值 MIN_VALUE = 0x80000000。纳尼?Jdk 有 bug!(2333)

想都不用想,肯定是我自己有 bug,那为什么推算出的和 Jdk 中定义的不符呢。其实是二进制表示方法不对而已。二进制除了上述可直观计算得出的逢二进一的原码外,另外还有几种表示方法。

原码 反码 补码

原码很直观易懂,但也有其缺点,就比如最高位为符号位为这个槽点,就诞生了 0000 ~ 0000,1000 ~ 000,分别代表 +0 和 -0。至于数学里有没有 +0 和 -0,二者参与运算是怎么个计算法,我读书少我也不清楚。但这说明了一个问题,使用原码存储和运算会存在二义性。计算机在运算时使用的并非原码而是补码。补码和反码的计算公式如下:

  • 正数
    原码、反码、补码都相同

  • 负数
    反码:原码保留符号位,其他位取反
    补码:反码+1

  • 补码转原码
    如果符号位为1,其余各位取反,然后再整个数加1。

上面提到的 +0 (0000 ~ 0000),其补码也为 000 ~ 0000,而 -0(1000 ~ 0000),其反码为 1111 ~ 1111,补码为反码 + 1 ,为 0000 ~ 0000,可见补码消除了关于 0 的二义性,使用补码并不会存在两个 0。

回到上面推算的 int 值得最小值 1111 ~ 1111,其反码为 1000 ~ 0000,补码为 1000 ~ 0001,转换为十六进制为 0x80000001。而这与 Jdk 规定的最小值 MIN_VALUE = 0x80000000 并不相同,说明还遗漏了什么。再回看补码,除了消除二义性,还有个好处是可以把减法当做加法。都知道 01111 ~ 1111 代表正数的最大值,最高位只代表符号,那么将其由 0 变 1,用 1111 ~ 1111 来代表负数的最大值从某种角度上也说得通,补码(1111 ~ 1111) = 十进制(-1),将 补码(1111 ~ 1111) 往前迭代 1 位(做 + 1 的运算),舍弃溢出位,得到 补码(0000 ~ 0000) = 十进制(0),符合 -1 + 1 = 0 的运算结果。将 补码(1111 ~ 1111) 往后迭代 1 位,得到 补码(1111 ~ 1110) = 原码(1000~ 0010) = 十进制(-2),符合 -1 - 1 = -2 的运算结果。则同理,将负数最大值 补码(1111 ~ 1111) 一直往后迭代,直到无法再小,则最小值应为 补码(1000 ~ 000) = 原码(1000 ~ 000) = 十进制(-0) = 十六进制(0x80000000)。也就是原码空出来的那个代表 -0 的数,被计算机用来表示 int 的最小值。

Bitmap 像素

提及 Bitmap ,先介绍一下 Android 中Bitmap 类中定义的枚举类 Config 里的几个值,也是比较多见的 Android 中的 Biamap 显示参数。

Bitmap 参数

  • ARGB_4444
    四个通道 A(透明度)、R(红色)、G(绿色)、B(蓝色)各占 4 位,总共 16 位,即每个像素占用 2 个字节。

  • ARGB_8888
    四个通道各占 8 位,总共 32 位,每个像素占用 4 个字节。因为 RGB 通道精度更高,所以颜色显示更丰富,同时占用内存也更大。

  • RGB_565
    没有透明度信息,RGB 通道各占用 5 位、6 位、5 位,总共 16 位,每个像素占用 2 个字节。

知道了每个像素占用的字节长度,就可以计算一张图片显示时所占用的内存大小,以 ARGB_8888 为例,一张像素为 16 * 16 的图片占用的内存为:16 * 16 * 4 = 1024 byte,即 1 KB。

轻松愉快又简单!可梦想很美好,显示很骨感。在 Android 中,在不压缩计算的情况下(例如显示 assets 目录下的图片),内存大小就是上面计算所得,但因为 Android 中的图片一般存放在不同的资源目录:

资源目录对应的 dpi
mdpi -> 120 dpi
mdpi -> 160 dpi
hdpi -> 240 dpi
xdpi -> 320 dpi
xxdpi -> 480 dpi
xxxdpi -> 640 dpi

Android 中显示不同的资源目录图片时,会对图片做缩放处理,缩放比例为 设备dpi / 资源目录对应 dpi,以 小米8SE 为例,设备屏幕密度为 440 dpi,该设备显示存放在 xxdpi(480dpi)目录中的像素为 300 * 300 的图片时,实际显示图片的宽和高将换算为 440 / 480 * 300 (结果四舍五入),计算得到图片在手机显示的宽高为 275,再根据计算所得实际的图片宽高计算所占内存:

275 * 275 * 4 = 302500(byte)

可以调用 Bitmap 类自带的方法 getByteCount() 方法验证一下。

顺带提一下,Android 中 Bitmap 的占用内存大小与显示图片的容器(例如 Android 上的 ImageView)尺寸无关。

Bitmap 像素的定义

介绍完 Bitmap 内存占用大小后,回到 Bitmap 本身来。Bitmap 将图像定义为由像素组成,以 ARGB_8888 为例,上面提到过,A/R/G/B 各占 8 位,各由两个十六进制数表示,依次排列,比如常见的色值 #FF234567,即各通道值为:透明度 alpha 0xFF,红色 red 0x23,绿色 green 0x45,蓝色 blue 0x67。

因此一张分辨率 100 * 100 的彩色图片,无非就是 100 * 100 个像素,每个像素显示对应的颜色,所有像素组合在一起便成了彩色的图片。所以只要拿到了 Bitmap,想要如何修改图像的显示,只要对各个像素显示的颜色做相应的处理就好了。

彩色转换为灰色的计算方式暂且不提。要改变图像的显示,首要任务是获取到各像素点的颜色。

Android 中可以调用 Bitmap 类自带的方法获取到具体某个点的像素颜色:

int color = bitmap.getPixel(200, 300);

那么问题来了,如何才能从一个 int 值中获取各个通道(RGB)的颜色呢?

从像素中提取各通道色值

老司机们可能秒懂,这个简单,Color 类自带的方法就可以做到:

int redColor = Color.red(color);

再看一下该方法的实现:

@IntRange(from = 0, to = 255)
public static int red(int color) {
    return (color >> 16) & 0xFF;
}

其实计算方法也很简单,用到了位运算,那就顺带回顾一下位运算。

位运算符

从最低位到最高位一一对齐,每一位都做运算(也是对补码做运算),各运算符含义如下:

  • &
    都是 1,则结果为1。否则为 0。
  • |
    都是 0,则结果为0。否则为 1。
  • ~ 取反
    对数的每一位取反。
  • ^ 异或
    数值相同,则结果为 0,不为 1。
  • >>右移
    从 0 位起整体向右移动,空出的高位正数补 0,负数补1。
  • >>> 无符号右移
    从 0 位起(连符号位)整体向右移动,空出的高位一律补 0。
    对于正数而言,>>和>>>没区别。
  • << 左移
    整体向左移动,右边的空位一律补 0。

现在再来回看上面提到的取色方法:

// Color
public static int red(int color) {
    return (color >> 16) & 0xFF;
}

还以 #FF234567 为例,转换为二进制为
1111 1111 | 0010 0011 | 0100 0101 | 0110 0111 (这里我用了 | 符号方便划分),其中 第二阵列 0010 0011,即右起第 17 ~25 位代表红色色值。将二进制右移 16位,等同于舍弃了红色右边 的 16 位用于存储绿色、蓝色的色值,得到 0000 0000 | 0000 0000 | 1111 1111 | 0010 0011,再与 0xFF 即二进制 1111 1111 做与运算,运算时高位为空则补0,与 0 做 &与运算结果必为0,等同于与舍弃了右边代表透明度的高八位,最终得到红色的色值 0010 0011

取红色色值也还有另一种解法:

(color & 0x00FF0000) >> 16

先和 0x00FF0000 做与运算,舍弃除红色外所有色值,再右移 16 位得到该值。这种解法与上述的只不过是运算顺序不同,殊途同归。

至此,获取到了色值,想要怎么改变图片的显示就是算法上的事了,各凭本事各显神通。

今天的分享就到这,如有纰漏欢迎指正,下篇博客见。

推荐阅读更多精彩内容