本文字数:7885字
预计阅读时间:45分钟
安卓音视频开发中的一个环节是摄像头采集数据,而 Android 平台上摄像头采集的 API 有两套,camera1 和 camera2。本文主要讲的是 camera2 这套 API 在采集数据并指明 YUV420_888
格式时,获取到的摄像头 YUV 数据格式具体是怎么样的。
01
背景/问题
之所以写这篇文章也是因为作者在开发过程中遇到了一些坑,然后在网上查阅资料后总结了一下内容。首先先说一下我遇到的问题:按照 API 的写法,获取摄像头数据,是在预览的回调中去获取数据,常用的会设置获取数据的格式为 YUV_420_888
,如下:mImageReader = ImageReader.newInstance(1920, 1080, ImageFormat.YUV_420_888,2)
。
然后在回调中去获取数据,比如如下:mImageReader.setOnImageAvailableListener
private byte[] y;
private byte[] u;
private byte[] v;
private ReentrantLock lock = new ReentrantLock();
@Override
public void onImageAvailable(ImageReader reader) {
Image image = reader.acquireNextImage();
// Y:U:V == 4:2:2
if (image.getFormat() == ImageFormat.YUV_420_888) {
Image.Plane[] planes = image.getPlanes();
// 加锁确保y、u、v来源于同一个Image
lock.lock();
// 重复使用同一批byte数组,减少gc频率
if (y == null || u == null || v == null) {
y = new byte[planes[0].getBuffer().limit() - planes[0].getBuffer().position()];
u = new byte[planes[1].getBuffer().limit() - planes[1].getBuffer().position()];
v = new byte[planes[2].getBuffer().limit() - planes[2].getBuffer().position()];
}
if (image.getPlanes()[0].getBuffer().remaining() == y.length) {
planes[0].getBuffer().get(y);
planes[1].getBuffer().get(u);
planes[2].getBuffer().get(v);
encodeData(y,u,v); //拿到y,u,v数据然后处理
}
lock.unlock();
}
image.close();
}
}, mainHandler);
作者在一开始的时候,觉得拿到 y
,u
,v
数据了,那就可以直接拿去处理了,但是发现最终出来的图像是有问题的。于是觉得有点蹊跷,就开始去查阅相关资料。下面继续:
02
YUV420格式基础知识回顾
我们先回顾下 YUV420
格式相关的基础知识。
首先关于 YUV420
的采样,它并不是指只采样 U
分量而不采样 V
分量。而是指,在每一行扫描时,只扫描一种色度分量(U
或者 V
),和 Y
分量按照 2 : 1 的方式采样。比如,第一行扫描时,YU
按照 2 : 1 的方式采样,那么第二行扫描时,YV
分量按照 2:1 的方式采样。对于每个色度分量来说,它的水平方向和竖直方向的采样和 Y
分量相比都是 2:1。
而采样后就是存储格式了,在日常开发中,打交道较多的主要也是围绕存储格式。主要有 YUV 420P
和 YUV 420SP
两种类型,每个类型又对应其他具体格式,常见的基于 YUV 4:2:0
采样的格式如下表:
先U后V | 先V后U | |
YUV420P类型 | YU12格式 | YV12格式 |
YUV420SP类型 | NV12格式 | NV21格式 |
YUV420P
和 YUV420SP
都是先存储所有的 Y
分量后再存储 U
V
分量。在存储 Y
分量后,YUV420P
类型会先存储所有的 U
分量或者 V
分量,而 YUV420SP
则是按照 UV
或者 VU
的交替顺序进行存储,具体可看下图:
YUV420P 的格式(以YU12格式为例子)
YUV420SP 的格式(以NV12格式为例子)
1、YU12
YU12
和 YV12
格式都属于 YUV 420P
类型,即先存储 Y
分量,再存储 U
、V
分量,区别在于:YU12
是先 Y
再 U
后 V
,而 YV12
是先 Y
再 V
后 U
。
YV 12
的存储格式如下图所示:
而 YU 12
又称作 I420
格式,它的存储格式就是类似 YV12
但是把 V
和 U
反过来了。
2、NV12和NV21格式
NV12
和 NV21
格式都属于 YUV420SP
类型。它也是先存储了 Y
分量,但接下来并不是再存储所有的 U
或者 V
分量,而是把 UV
分量交替连续存储。
NV12
是 iOS 中常有的模式,它的存储顺序是先存 Y
分量,再 UV
进行交替存储。
而 NV21
是安卓中常有的模式,它的存储顺序是先存 Y
分量,再 VU
交替存储。
03
相关API的官方解释
在 Android 的官方文档上会对这个 YUV_420_888
获取到的格式作一些说明:https://developer.android.com/reference/android/graphics/ImageFormat.html#YUV_420_888
大体意思呢,结合网上大佬们的解读,总结如下:
它是
YCbCr
的泛化格式,不会具体指明是YU12
,YV12
,NV12
,或是NV21
。它能够表示任何4:2:0的平面和半平面格式,每个分量用8 bits 表示;带有这种格式的图像使用3个独立的
Buffer
表示,每一个Buffer
表示一个颜色平面(Plane
),除了Buffer
外,它还提供rowStride
、pixelStride
来描述对应的Plane
。这两个rowStride
、pixelStride
是重点。下面会讲解;使用
Image
的getPlanes()
获取plane
数组:Im age.Plane[] planes = image.getPlanes()
;它保证
planes[0]
总是Y
,planes[1]
总是U(Cb)
,planes[2]
总是V(Cr)
。并保证Y-Plane
永远不会和U/V
交叉;U/V-Plane
总是有相同的rowStride
和pixelStride
。
04
YUV420_888格式详解
经过上面的官方解读,能大体知道意思,但是具体是怎么样的呢?下面就来讲解一下。首先我们知道 yuv420
分为 Planar
格式(P)和 Semi-Planar
格式(SP),这两种由于存储格式不同,在这里的获取的三个 planes
中的分布也会非常不同。
1、rowStride和pixelStride
先来解读两个最重要的参数:
(1) pixelStride(通过 getPixelStride()
获得):像素步长,有可能是1、有可能是2。它代表的是行内连续两个颜色值之间的距离(步长)。
也就是说,如果是1,那么每一行中的同一个颜色分量,比如Y分量,是连续的,也就是行内索引为0,1,2,...的颜色分量都是它的。如果是2,那么每一行中的同一个颜色分量,是不连续的,是会中间间隔1个元素的,也就是行内索引为0,2,4,6,...的颜色分量才是它的。
这里还有个重要的点:假如是步长为2,意味索引间隔的原色才是有效的元素,中间间隔的元素其实是没有意义的。而 Android 中确实也是这么做的,比如某个 plane[1]
(U
分量)的步长是2,那么数组下标0,2,4,6,...的数据就是 U
分量的,而中间间隔的元素 Android 会补上 V
分量,也就是会是 UVUVUV
......这样去分布。但是当最后一个 U
分量出现后,最后一个没有意义的元素 Android 就不补了,也就是最后的 V
分量就不会补了,直接结束,即是这样分布:UVUVUV...UVUVU
。
(2) rowStride:(通过 getRowStride()
获得)“每行数据”的“宽度”,这个跟分辨率的宽度不是同个回事,它是每一行实际存储的空间宽度,下面的分析会再详细讲述。
2、其他相关参数
除了上面2个重要参数外,还有几个参数也是需要了解的,如下:
(1)width 和 height
通过 getWidth()
和 getHeight()
获得,一般与视频分辨率一致。
(2)buffer size
这个主要就是 plane
数组的大小,一般就通过 plane[i].length
获取即可。
3、YUV数据的分布和排列
解读了 rowStride
和 pixelStride
这两个参数后,我们可以假设一些例子来理解一下拿到的 YUV
数据的排列。理论上,这两个参数在每个 plane
中是可以任意设置的,这样组合起来的格式可能也是多种多样的。但是在实际场景中,由于 YUV420
分为 Planar
格式(P)和 Semi-Planar
格式(SP)两大类存储格式。所以实际上我们遇到的大体上会分为下面两大类:
(1)Planar
格式(P):
先看一下6*4的假设图片:
plane[0]
的 pixelStride
是1,说明没有间隔,Y
是连续的,rowStride
是6,也就是每行6个,length 数量是24,24/6 = 4,共4行。
plane[1]
的 pixelStride
也是1,说明没有间隔,U
是连续的,rowStride
是3,也就是每行3个,length 数量是6,6/3 = 2,共2行,符合 YUV420
的情况,横纵都是2:1采样。
plane[2]
与 plane[1]
相同。
这种其实就是 YUV420P
的标准格式,跟我们期望的差不多,不用做多解析,直接按照这样将 y
、 u
、 v
分别取出,即是正确的数据。可惜的是,目前测到的手机大部分不是这样的格式,而是下面要介绍的这类 SP 的情况出现的多一些。
(2)Semi-Planar
格式(SP):
还是先看一下6*4的假设图片:
plane[0]
的 pixelStride
是1,说明没有间隔,Y是连续的,rowStride
是6,也就是每行6个,length数量是24,24/6 = 4,共4行。这个 Y
分量跟 Planar
格式是一样的。
plane[1]
的 pixelStride
是2,说明有间隔,U是间隔采取的,这里就回到上面我们分析的两个参数的时候,当 pixelStride
为2的时候,在 U
分量中,就会间隔插入了 V
分量,因此每一行由本来是 Y
的一半也就是3,变成了6(也就是 rowStride
的值)。同时就像上面分析的一样,会放弃掉最后一个无意义的V分量,所以就 length 会看到是6*2-1=11的,行数还是2,纵向是不变的。
plane[2]
与 plane[1]
相同
对于这种 Semi-Planar
格式的,安卓提供的这种方式确实就让人很意想不到,在这种格式下,其实我们有几种取数据方式,首先Y是完整的,直接取即可。对于UV分量可以有2种方式:
plane[1]
中以索引0,2,4间隔方式去取U
分量,plane[2]
中以索引0,2,4间隔方式去取V
分量,这样就取到了最准确的U
和V
分量;我们其实可以看到,在
plane[1]
中其实就包含了U
和V
分量了,只不过丢掉了最后一个V
,对于人眼来说,少点一个V
,是完全没有影响的。因此其实可以直接拿plane[1]
的数据,就拿到U
和V
。plane[2]
同理其实也是有V
和U
,那么这样的话,其实就可以plane[0] + plane[1]
可得NV12
格式;或者plane[0] + plane[2]
可得NV21
格式。Semi-Planar
格式在大多数手机中会经常出现,经过上面的分析也能理解为什么 U 和V
的rowStride
会和Y
一样,而不是一半。以及为什么U
和V
的数量最后会少一个分量的原因。
(3)特殊情况:
rowStride
除了有 P 和 SP 格式而导致不同之外,它其实还有一个重要的作用,就是在一些特殊的摄像头sensor
采集的时候,因为芯片处理器要字节对齐取数据等原因而导致的补齐操作,从而使得每一行所占的空间比实际数据要多。
我们举个例子,比如还是图像是6*4的,但是由于字节补齐操作等原因,相机输出的时候,假如rowStride+2
了,如下:
plane[0]
的 pixelStride
是1,说明没有间隔,Y
是连续的,rowStride
本来应该是6,但是这里是8,后面补了两个空的字节,也就是每行8个,length 数量是32,32/8 = 4,共4行。这里分析的时候可以这样判断,getWidth()
和 getHeight()
获取到是6和4,6*4是24,发现32与24不对应,就可以初步判断是有补齐的情况了。而 Y
的 rowStride
是8,比 getWidth()
的6多了2,也就可以推测是每行补齐了2个字节。
这样的话在取数据的时候,就需要每行都去丢弃最后的2个空字节。同理 plane[1]
和 plane[2]
也是类似。因此对于这类特殊的 camera,我们需要根据pixelStride
和 rowStride
与分辨率的关系,去进行一些特殊的处理才行。
不过目前来说,现在的手机厂商会尽可能避免这种情况,因此出现这种特殊情况的可能性也是比较低的。
05
总结
根据上面的总体分析,可以总结如下:
根据
rowStride
、pixelStride
与分辨率的关系,判断是否有补齐的特殊情况,若有,则需要对数据进行空字节丢弃的处理。一般大部分的手机都不会有这么特殊的情况。若没有,则属于正常情况;正常情况下,先判断
pixelStride
的值,可以知道是 P 还是 SP 存储格式;若pixelStride
为1,是 P 格式,直接可以顺序取Y
、U
、V
;若pixelStride
为2,则可以考虑上述第三点中Semi-Planar
格式的取数据方式。
06
问题解决
最后回到我一开始遇到的问题,之所以会出问题就是因为我没有判断 pixelStride
和 rowStride
就直接取了,实际上我的手机是以 SP 的方式的,所以 U
和 V
的 pixelStride
是2,所以没法直接赋值。根据这种逻辑,我修改代码如下,判断 pixelStride
,并采用分别取 U
和 V
的方式,暂时不考虑字节补齐的特殊情况。
private byte[] y;
private byte[] u;
private byte[] v;
private ReentrantLock lock = new ReentrantLock();
@Override
public void onImageAvailable(ImageReader reader) {
Image image = reader.acquireNextImage();
// Y:U:V == 4:2:2
if (image.getFormat() == ImageFormat.YUV_420_888) {
Image.Plane[] planes = image.getPlanes();
// 加锁确保y、u、v来源于同一个Image
lock.lock();
// 重复使用同一批byte数组,减少gc频率
if (y == null || u == null || v == null) {
y = new byte[planes[0].getBuffer().limit() - planes[0].getBuffer().position()];
u = new byte[planes[1].getBuffer().limit() - planes[1].getBuffer().position()];
v = new byte[planes[2].getBuffer().limit() - planes[2].getBuffer().position()];
}
if (image.getPlanes()[0].getBuffer().remaining() == y.length) {
planes[0].getBuffer().get(y);
planes[1].getBuffer().get(u);
planes[2].getBuffer().get(v);
//数据前期处理
//判断是p还是sp
if(planes[1].getPixelStride() == 1){ //p
//无须处理
encodeDataTopush(y,u,v);
}else if(planes[1].getPixelStride() == 2){ //sp
//这里需要+1,需要注意,具体原理不再展开阐述
byte[] temp_u = new byte[(u.length+1)/2];
byte[] temp_v = new byte[(v.length+1)/2];
int index_u = 0;
int index_v = 0;
//提取U分量
for(int i=0;i < u.length;i++){
if(0 == (i%2)){
temp_u[index_u] = u[i];
index_u++;
}
}
//提取V分量
for(int j=0;j < v.length;j++){
if(0 == (j%2)){
temp_v[index_v] = v[j];
index_v++;
}
}
encodeData(y,temp_u,temp_v);
}
}
lock.unlock();
}
image.close();
}
}