2019 年第 4 篇原创,半科普技术长文,宜精读、收藏。
伊始
元旦的时候接到一个任务,对 Android Camera 的缩略图展示性能做一些优化。作为一个 Camera 0 经验的菜鸟,各种铺面而来的专业术语瞬间就让我迷失了 ... 那么 0.1 s。
NV21、YUV420P、YUV420SP、I420、YU12等等等等,都是些神马玩意呀,毕竟我听过 RGB,也仅仅只是听过而已。所以为了完成任务,就查了很多资料,也差不多搞清楚了 YUV 是什么,以及存在的意义。
所谓好记性不如烂笔头,在此做下记录。
Android 背景知识
做 Android Camera 的朋友应该知道,Android 手机相机采集的原始帧默认是横屏格式的,且默认是左横屏状态取景。所以当你竖屏点击拍照键后,也就会出现下图所示的状态。需要旋转顺时针旋转90°后,方可得到我们想要的竖屏效果(自拍需要270°)。
使用 Android Camera,一定会使用到下面接口和回调方法。
public interface PreviewCallback {
/**
* .... the default will be the YCbCr_420_SP (NV21) format.
*/
void onPreviewFrame(byte[] data, Camera camera);
};
虽然 Camera 类已经过时(Android Lollipop后,出现Camera2),不过项目中还在使用,这个不是重点。
其中,onPreviewFrame方法传回的byte数组,默认数据格式为 YCbCr_420_SP (NV21) 格式,这个才是重点。
所以 YCbCr 是什么?420 是什么?SP 是什么?NV21 是什么??
YUV / YCbCr 是什么?
为了更好的在某些标准下,用可接受的方式对彩色加以描述,人类规定了一些颜色空间。
RGB、YUV / YCbCr 就是其中常用的两个。根据资料,其他常用的颜色空间还有:CMY/CMYK,HSV/HSB,HSI/HSL,Lab等,有兴趣的同学可以去 Google 查询下。
RGB(Red - Green - Blue)是依据人眼识别的颜色定义出的空间。因红绿蓝为三原色、更具代表性而如此命名,可表示大部分人类可识别的颜色。它是最通用的面向硬件的颜色空间,比如彩色显示屏啊,彩色监视器啊。它的颜色空间表示如下图。
图片来自维基百科,三维的。
YUV 也是一种颜色空间。
图片来自维基百科
其中的 Y 代表的是明亮度(Luminance 或 Luma);U 和 V 代表则是 色度、浓度(Chrominance、Chroma)。
在 YUV 中,每一个颜色有一个亮度信号Y和两个色度信号U和V。亮度信号代表亮度的强或弱。根据 RGB 数据,YUV 从其中产生一个黑白图像,然后从数据中提取最主要的三种颜色指定为两个额外信号来描述颜色。
从真实场景到 RGB 再到 YUV 的经典过程如下:
图来自维基百科,经个人加工。
通俗的讲,就是 Y 是亮度, UV 是色彩。
而 YCbCr 是 YUV 经过缩放和偏移的翻版。YUV 家族有很多别称或翻版,而 YCbCr 是在计算机系统中使用最多的成员,JPEG、MPEG均采用此格式。所以如果有人提到 YUV,大多情况下可以理解为 YCbCr。
其中 Y 和 YUV 中的Y相同,均表示亮度,而Cb、Cr同样指色彩。其中,Cb 对应 U,意为蓝色差;Cr对应 V,意为红色差。YCbCr和YUv只是在表示上、在针对RGB数据处理的算法上,略有不同。
下面的一张彩色图片,分别提取 Y、U(Cb)、V(Cr)信号后,如下图标注所示。可以看出 Y 信号仅仅表示呈现黑白色,而 U(Cb) 明显偏蓝,V(Cr)明显偏红,同时都带有点绿,名副其实的亮度、蓝色差、红色差。
图来自维基百科,经个人加工。
为什么要使用YUV / YCbCr?
或者说 YUV / YCbCr 的价值在哪里?
总体来说,有三个主要原因。
第一为历史原因。
不知道读者中有多少人看过黑白电视,每每想到那雪花的屏幕、唰唰的声音或者因为没有调节好信号,从左到右飘来飘去的还珠格格小燕子,就会瞬间把我拉回了童年。
图片来源网络,侵删
当初,在黑白电视和彩色电视的交接换代之际,黑白电视只有黑白画面,而新出来的彩色电视则可以使用 YUV 规格来处理彩色图像。忽略其中的 UV 信号,黑白电视就可以直接用 Y 信号来表示黑白画面。省事。如果是 RGB 来传输,从设计上来说,RGB 将亮度、色调、饱和度混淆的放在了一起表示,使用起来不太友好。
第二为传输代价原因。
在彩色电视出现伊始,有设计者将信号设计为RGB三原色传输,但这样传输带宽要求就是原来的黑白电视的三倍。费钱,不好。
即使是现如今,网络带宽提升如此大的情况下,也是使用 YUV 编码,经压缩后进行视频传输。还是省钱。
完全形态的 RGB 编码,RGB 888,一个像素的表示占用 24 bits;而 YUV 的 420 采样,可以使用 12 bits 表示一个颜色,节省了大概一半的带宽(钱),即使 RGB 555 或 RGB 565 也不及。
第三为生理原因。
人类的眼睛有着自己的喜好。
较之红蓝,对绿色更敏感;较之色彩,对亮度更敏感;同时又不是能够识别全部的颜色。
RGB_888 的 24 bits 允许它能够表示 255 * 255 * 255, 嗯,大概一千六百万种颜色,太细腻了,人类实在不需要、也识别不出这么多颜色。这样一来 RGB 就显得有些臃肿。
而又因为对亮度强度的的敏感,将亮度单独提取出来的 YUV 简直是再适合不过。
综上,到现如今,YUV 颜色空间在计算机图像处理及视频传输中有着超级广泛的应用。当然,一般人不知道而已。不过现如今,你知道了。
YUV 有哪些种类
在上面提到了两种 YUV 的种类,YUV_411 与 YUV_420。这些数字都是什么意思,下面会讲到。
对 YUV 种类的描述,更为准确的应该是:YUV 的采样种类和数据存储方式。
采样种类
关于采样种类,这里仅介绍常用的采样种类,而绝口不提实现的详细原理。因为我 ... 也不知道。
YUV 的主流采样种类有:4:4:4,4:2:2,4:2:0。
YUV 4:4:4 采样,每一个 Y 对应一组 UV 信号
YUV 4:2:2 采样,每两个 Y 共用一组 UV 信号。
YUV 4:2:0 采样,每四个Y共用一组 UV 信号。
最终,经过各个采样后,理论上信号如下图表示。
其中,空心圆代表 UV 信号,实心黑圆代表 Y 信号。从下面我圈出的红框, 可以大概看出对应采样 Y 是如何和 UV 信号结合的。
图片无法溯源,部分修改,侵删。
sleep(5)
为了方便描述,下文会省略采样数字间的冒号。
有的人在仔细琢磨上面的采样命名后,会有这样的疑问:前两个 444 和 422 还很好理解,分别对应各个信号的比。但最后一个 420 ,V 信号对应的是 0,难道就没有了 V 信号了么?
当然不是。
虽然在计算机中,图片的数据可能会用数组表示,但现实情况是:一张图片是有宽高的,是平面的。
如果图片是 100 * 100 大小,那采样时,是按行来扫描处理的。在 420 采样中,如果对第一行仅做了 U 信号采样,那第二行就只做 V 采样,间隔的来,这样就会有每行中的 U 或 V,间隔性的为 0,称之为 YUV 420。
而这几个采样方式在每像素占用的存储空间是不一样的。
在计算机中,YUV 的每个信号用 8 bits 来存储对应的信息。
所以,
YUV 444 是 8 + 8+ 8 = 24 bits -per - pixel (bpp)
YUV 422 是 8 + 4 + 4 = 16 bpp
YUV 420 是 8 + 2 + 2 = 12 bpp
这也是上面说的,为什么 YUV 比 RGB 更省带宽的原因。
上面说的是 YUV 理论上的采样方式,采样完了,这些信息在计算机中是怎么存储的?横着放竖着放还是混着放?所以就有了不同存储方式。
存储方式
主要分为两种存储方式。
这里说一句,存储方式或者说排列方式只是在内存中的排列规则。在 Android 中,接口返回的数据是以一维byte 数组存储,但是为了好理解,将一维的 byte 数组按照图片宽高,进行宽高抽象化,在文章中显示。这样好和图片实体对应,便于理解。
YUV 的存储格式有两大类:
1、Packed - 紧凑格式
Packed 模式下,Y 信号和 UV 信号你挨着我,我挨着你的存储,称之为紧凑名副其实。以一个 4 * 4 像素的图像来说,按 YUV 420 采样,是这样存储数据的:Y 信号紧挨着 UV 信号。
熊猫人代表 Y 信号,妹子是 U 或 V 信号。
看到这张图你们就开始想用 Packed 存储方式了?不好用啊不好用。因为数据是紧凑排列,不方便解析。
2、Planar - 平面格式
Planar 模式下,Y 信号和 UV 信号分开存储。首先将所有 Y 信号存储,然后再按照一定规则存储 UV 信号。
以一个 4* 4 像素的图片来举例,还是按 YUV 420 采样来举例,大体是这样的存储数据的:先是 4 * 4 的 Y 信号,再是 2 * 4 的 UV 信号 。
因为在 Android Camera 中,使用的是 420 采样,对应的存储方式是平面格式(Planar),所以下文就主要说 YUV 420 采样的平面格式。至于其他采样的细分,Google 去吧。
平面格式细分为两种:Planar(P) 与 Semi Planar(SP)。Semi 在枪械中有「半自动」的意思,在这里也就是半个平面格式。
P 就是代表 Y、U、V泾渭分明的排列在各自平面;
SP是指 Y 自己排在自己的平面, 而UV 二者混搭。这里 Y 是独立出来的,仅UV 混搭,谓之「半个平面模式」。
所以,YUV 420 中,也就分为了 YUV 420 P 和 YUV 420 SP 两种模式。而 YUV 420 P 更是根据 UV 排列不同,分为了 YU12 (I420)、YV12;YUV 420 SP 则是根据 UV 的不同排列分为 NV12、NV21。
估计你们看到这里已经晕了,这里给个树,来捋一捋关系。
对上述可爱的格式以 4 * 4 的图片示例排列方式。
在 4 * 4 图像中,前 4 行均是 Y 信号,而后两行是 UV 信号。根据对应的颜色,可以看出 4 个 Y 信号对应 1 组 UV 信号(红对红,绿配绿),且在做旋转的时候将每一组UV看做一个整体。
这四种格式(YU12、YV12、NV12、NV21)的区别就是 UV 的排列区别。
人类真是麻烦的生物。。
sleep(10)
还记的文章开头,回调接口注释中的 YCbCr_420_SP (NV21) 模式么?现在应该知道是个什么玩意了吧。
对 NV21 数据如何做旋转和镜像
只有将理论和实践相结合,记忆才能深刻。况且本来就是冲着这个目的来的。
旋转
前文讲到,对于 Android Camera 竖屏拍照需要将返回的数据旋转90度后才可以正常显示。
那对于返回的 NV21 数据, 如何旋转90度呢?是直接像下图这样旋转90度?
NO!
如果这样旋转,则会导致每一行数据排列错误,UV 和 Y 混排;而从内存的角度来看,数据是用数组存储的,这样的旋转会让原本连续的Y断开 - U1 V1 Y13 Y9 Y5 Y1 U2 V2 ... 最终数据错误,图片解析失败。
正确的该是,先旋转 Y 信号,再旋转 UV 信号(将每一组UV看成一个整体),如下图所示。
所以算法就是
Y13(的数据) -> Y1(的位置), Y9 -> Y2, Y5 -> Y3, Y1 -> Y4
Y14 -> Y5, Y10 -> Y6, Y6 -> Y7, Y2 -> Y8
以此类推后续的 Y 变换。则 Y 信号变换的伪代码实现为:
int i = 0;
for (int x = 0; x < imageWidth; ++x) {
for (int y = imageHeight - 1; y >= 0; --y) {
newData[i++] = oldData[y * imageWidth + x];
}
}
这是简单暴力的实现,时间复杂度为 O(imageWidth * imageHeight),也是我目前能想到的算法。
而对于 UV 信号,每一组 UV 信号是一个整体,变换规则为:
V2U2(的数据) -> V4U4(的位置), V4U4 -> V3U3
V3U3->V1U1, V1U1 -> V2U2
又因为与 Y 存在同一个一维数组中,存在 imageWidth * imageHeight 个 Y 的偏移量,伪代码如下:
int i = imageWidth * imageHeight * 3 / 2 - 1;
int offset = imageWidth * imageHeight;
for (int x = imageWidth - 1; x > 0; x = x - 2) {
for (int y = 0; y < imageHeight / 2; y++) {
newData[i--] = oldData[offset + (y * imageWidth) + x];
newData[i--] = oldData[offset + (y * imageWidth) + (x - 1)];
}
}
为什么 i 是 w * h * 3 - 1 ?
因为对于 4 * 4 的图片,数组长度应该是 4 * 4 个 Y + 4 * 4 / 2 个 UV。
最终,顺时针旋转90度的代码为:
private byte[] rotateYUV420Degree90(byte[] data, int w, int h) {
byte[] yuv = new byte[w * h * 3 / 2];
// Rotate the Y luma
int i = 0;
for (int x = 0; x < w; x++) {
for (int y = h - 1; y >= 0; y--) {
yuv[i++] = data[y * w + x];
}
}
// Rotate the U and V color components
i = w * h * 3 / 2 - 1;
int offset = w * h;
for (int x = w - 1; x > 0; x = x - 2) {
for (int y = 0; y < h / 2; y++) {
yuv[i--] = data[offset + (y * w) + x];
yuv[i--] = data[offset + (y * w) + (x - 1)];
}
}
return yuv;
}
而旋转270度和90度类似,不再赘述,对旋转270度算法有兴趣的同学后台回复 YUV 获取。
对于旋转180度的需求,更为巧妙的做法的将对应平面的信号逆序,具体代码有兴趣的同学可以后台回复 YUV 获取。
镜像
镜像出现的场景是在使用手机的前置摄像头的时候。
为了让用户看到的生成图像更像是在镜子里看到的自己,镜像的需求就来了。
对于镜像,我们也可以按照上述的方法,一个一个字节的对应找规律。
但由于 Android Camera 一开始生成的图片是横着躺的,这时候只需要将图片上下翻转一遍,然后旋转270度(即逆时针90度)即可。
而本来前置拍出来的照片都是需要旋转270度的。
所以我们就省去了一个一个对数据进行随机访问(对数组随机访问会导致缓存丢失,影响性能),只需要将上下行数据对调即可。
代码如下:
private byte[] mirrorYUV420(byte[] data, int w, int h) {
byte[] yuv = new byte[w * h * 3 / 2];
// Y
for (int y = 0; y < h; y++) {
System.arraycopy(data, (h - 1 - y) * w, yuv, y * w, w);
}
// UV
int wh = w * h;
int halfH = h / 2;
for (int y = 0; y < halfH; y++) {
System.arraycopy(data, wh + y * w, yuv, wh + (halfH - 1 - y) * w, w);
}
return yuv;
}
最后
这篇文章从大概10天前开始写,中间因为工作忙,就每天抽出一点时间来完成,中午挤一点,晚上挤一点。
全文下来,自认为将 YUV 讲的还算透彻。为了让大家便于理解,文章未免啰里啰嗦,还望见谅。同样的为了便于理解,绘制了相当量的图片与注释。
全文也到了将近5000字,算是我写的最长的一篇技术文了。
到这里,相信你已经知道什么是YUV了吧。那就不要吝啬你们的好看。
学习是一种态度
欢迎关注