欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Bitmap 图像灰度变换原理浅析

程序员文章站 2024-03-15 08:47:53
...

上篇文章《拥抱 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 位得到该值。这种解法与上述的只不过是运算顺序不同,殊途同归。

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

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