图像的数字化过程

图像的数字化主要分两个过程,采样和量化。

采样

用多少点来描述一张图像

例如一张 600x400 尺寸的图, 会采样 240000 个点, 又叫 24 万像素

量化

把采样点上对应的亮度连续变化区间转化为单个特定数值的过程

  • 每个像素点都有一个色值, 代表一种颜色
  • 色值在不同的颜色模型下会用不同的分量表示

图像的颜色模型

  • RGB: 任何一种颜色都可用三种基本颜色(红、绿、蓝)按不同的比例混合得到。在 RGB 模型中,一个像素值往往用 RGB 三个分量表示。当 RGB 的每个分量数值不同,混合得到的颜色就不同。
  • CMY: 任何一种颜色都可以用三种基本颜料(青色(Cyan)、品红(Magenta)和黄色(Yellow)按一定比例混合得到。CMY 模型常用于打印。
  • YUV: 多用于描述模拟信号。每一个颜色有一个亮度信号 Y,和两个色度信号 U 和 V。YUV 使用 RGB 的信息,但它从全彩色图像中产生一个黑白图像,然后提取出三个主要的颜色变成两个额外的信号来描述颜色。产生的目的是当年黑白电视和彩色电视的过渡,可以减少图像传输的数据量,因为 RGB 的颜色太多,每个像素占带宽太多,YUV 中就可以通过减少对色度信号的抽样,减少带宽占用,而且YUV可以单独传输,容易兼容黑白电视,黑白电视只用亮度信号就可以。根据信号抽样的频率不同,YUV 又可以细分。
  • YCrCb: 描述数字信号的 YUV 模型。
  • HSL: RGB 模型的另外一种描述,在HSL模型中,H 定义颜色的波长,称为色调; S 定义颜色的强度(intensity),表示颜色的深浅程度,称为饱和度; L 定义掺入的白光量,称为亮度。

RGB 模型

在 RGB 模型下,每个像素色值用 RGB 3 个分量表示, 每个分量可以通过占用不同的 bit 数来达到不同的色彩效果。

RGB 每个分量用 1 bit 表示

如果每个像素的每个颜色分量只用二进制的 1 位来表示,那每个颜色的分量只有 “1” 和 “0” 这两个值,也就是说,每种颜色的强度要么是 100%,要么就是 0%. RGB 每个分量有2种可能(0和1), 3 个分量就有 8($2^3$) 种组合, 能表示 8 种颜色。 图像大小就为 width * height * 3(1个像素3个分量) * 1(每个分量占1位) * / 8(bits 转 bytes) => W x H x 3 / 8 个字节

RGB888 每个分量用 8 bit 表示

如果每个像素的每个颜色分量用 8 位二进制来表示, 则 RGB 每个分量有 256($2^8$) 种值, 3 个分量就有 16777216 ($2^24$) 种组合, 能表示 1600 万种颜色(也就是我们常说的1600万色)。 图像大小就为 width * height * 3(1个像素3个分量) * 8(每个分量占8位) * / 8(bits 转 bytes) => W x H x 24 / 8 个字节


RGB 的排列

抽象排列是指我们在思维上认为图片应该是以行列分布的, 实际在内存中, 图像其实更常见是以一维数组做存储, 以 RGB RGB RGB 的顺序存储. 待需要做一些矩阵运算时再转换为二维数组形式.

Android 上的 RGB 模型

在 Android 上的 Bitmap 有几种图像模型, 其区别就在于: 用不同的位数来存储各个分量和是否带 Alpha 通道, 我们可以在解析 Bitmap 时通过 Bitmap.Config 指定期望的模型,但是最终系统使用什么模型还要根据解析的图片的颜色模型来决定. 比如: 即使我们设置使用 RGB_565 去解析一张带 Alpha 通道的图片, 但最终系统还是会使用 ARGB_8888 模型来解析. 如果我们想减少内存占用则可以使用 ARGB_4444 来解析, 但是质量会降低许多; 或者可以去掉原图的 Alpha 通道, 然后使用 RGB_565 解析.

格式 描述
RGB_565 只有 RGB 通道,R 和 B 用 5位表示,G 用 6位表示,一个像素 (5+6+5) = 16 bits 占 2 byte
ARGB_8888 包含 Alpha 通道的 RGB 图像。每个通道用8位表示,每个通道可取值 0~255,一个像素 4*8 = 32 bits 占 4 byte。每个通道色值丰富,图像质量高
ARGB_4444 包含 Alpha 通道的 RGB 图像。每个通道用4位表示,每个通道可取值 0~15,一个像素 4*4 = 16 bits 占 2 byte
ALPHA_8 只有 Alpha 通道, Alpha 通道用 8 位表示, 一个像素占 1 byte. 该格式可用来加载只有 Alpha 通道的蒙版类图像
RGBA_F16 按 RGBA 排列的图像, 每个通道 8 位表示, 一个像素占 8 bytes. 该格式应该是解析质量最高, 内存占用最大的模式

其他几种颜色模型也有类似 RGB 这种的机制,每个分量可以用不同的位数表示。在合适的地方用合适的分量位数。

YUV 模型

YUV 是 Android 相机开发中输出图像的默认模型

现代, YUV 一般是从 RGB 图像中进行采样, 先产生一个使用 Y 通道表示的黑白图像, 然后提取出图像中三个主要的颜色变成两个额外的信号(U 和 V)来描述颜色. YUV 根据不同的采样比和 YUV 分量的排列方式有很多细分的格式.

格式 采样过程 YUV分量采样比 垂直采样比 水平采样比 每像素位数 排列方式 格式别名
YUV444 从 RGB 图像里每个像素里提取 YUV 分量,然后按照 VUY 排列 4:4:4 1:1 1:1 32 bits $V_{1}$$U_{1}$$Y_{1}$$A_{1}$ $V_{2}$$U_{2}$$Y_{2}$$A_{2}$ 每个像素的4个分量(多个一个 Alpha 分量)按顺序排列 YUV444
YUV422 从 RGB 图像里每个像素提取 Y 分量, 每隔一个像素提取一对 UV 分量 4:2:2 2:1 1:1 16 bits $Y_{1}$$U_{1}$$Y_{2}$$V_{1}$ $Y_{2}$$U_{2}$$Y_{3}$$V_{2}$ 两个Y 一个U或V YUY2 UYVY
YUV420 从 RGB 图像里每个像素提取 Y 分量, 每隔两个像素提取一对 UV 分量 4:2:0 2:1 2:1 16 or 12 bits 先排所有的 Y, 然后所有的 V, 然后所有的 U IMC1 IMC2 IMC3 IMC4 YV12 NV12 NV21 YUV420P

Android 相机图像处理

Android Camera 接口

Android 的 Camera 默认的预览格式是 NV21(YUV的一种,又称 YUV420P), 也支持通过 setPreviewFormat 设置其他编码,但是其他编码不一定在每台设备上支持。所以为了安全和方便,使用最通用的 NV21。
拿取 Camera 的实时数据也有几种方式, 这里使用的是给 Camera 设置 previewCallBack, 在 onPreviewCallBack 中拿取.

onPreviewCallBack 这个方法需要注意: 它是相机每帧都会回调的, 基本是 33 帧每秒, 每 33ms 左右系统就会回调该方法, 如果在 onPreviewCallBack 内发生了超过 33ms 的阻塞, 就会出现丢帧现象。

NV21 转 RGBA

由于算法需要的图像数据是 RGB 模型下的, 所以需要进行 NV21 到 RGB 的转换.

这里我尝试了3种方式:

  • 使用 Android 提供的 YuvImage (简单方便、但耗时太多; 如果图片不进行压缩, 耗时陡增)
  • 使用 libyuv (耗时相对较少,但是需要自己写 JNI; 如果图片不进行压缩, 耗时也会增大)
  • 使用 RenderScript(耗时最少, 需要理解 RenderScript 的机制; 如果图片不进行压缩, 耗时也较稳定)

压缩旋转

针对上面的3种方式, 尝试了 3 种方法:

  • YuvImage 写入到 Bitmap, 通过对 Bitmap 做 Matrix 和边界压缩, 构造新的 Bitmap, 然后从 Bitmap 中获取像素数据
  • 从格式转换到压缩、旋转、镜像都由 libyuv 做
  • 从格式转换到压缩、旋转、镜像都由 RenderScript 做

相比之下, 也是 RenderScript 耗时最少.

获取单通道

灰度图就是只有一个颜色通道的图片,算法要求是只要有 RGB 任意一个通道就行, 所以我的做法是遍历所有像素, 取出一个通道的. 3 种方式做法都类似。

  • 从 Bitmap 的像素中拿

从 Bitmap 中拿到的像素数据,每一个值都是 RGBA 的一个复合值, 需要通过 Color.xxx 拿到对应的实际值。然后从连续的 RGBA 数组中拿一个通道, 就通过遍历做.

1
2
3
4
5
6
7
8
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val grayBytes = ByteArray(pixels.size)
for (i in 0.until(grayBytes.size)) {
// 获取单个通道存入
val red = Color.red(pixels[i])
grayBytes[i] = red.toByte()
}
  • 从通过 RenderScript 处理的 RGBA 的像素中拿
1
2
3
4
5
6
val singleChannel = ByteArray(pixels.size/4)
val range = IntProgression.fromClosedRange(0, pixels.size - 1, 4)
for (i in range) {
// 每4个像素的第一个像素是红色 R
singleChannel[i / 4] = triple.first[i]
}

RGBA -> BGR

遍历 RGBA 数据, 进行变换:

1
2
3
4
5
6
7
8
9
10
11
12
val bgrPixel = ByteArray(rgbaPixels.size / 4 * 3)
val bgrRange = IntProgression.fromClosedRange(0, bgrPixel.size - 1, 3)
var i = 0
for (j in bgrRange) {
val r = rgbaPixels[i]
val g = rgbaPixels[i+1]
val b = rgbaPixels[i+2]
bgrPixel[j] = b
bgrPixel[j + 1] = g
bgrPixel[j + 2] = r
i += 4
}

参考