文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

【Android Camera2】彻底弄清图像数据YUV420_888转NV21问题/良心教学/避坑必读!

2023-09-08 16:19

关注

  只要是使用Android Camera2开发相机相关功能的小伙伴,必然会和相机数据打交道吧。本文不讲解相机相关的操作,只是详细地讲解得到相机图像后,如何将图像Image转成NV21/NV12的数据的
  你可能会说,这个问题很普通了,网上都有现成的代码,拿过来直接用就行了~然而网上的代码大多数(最少我找到的)都是存在一定错误(或者是性能过低)。你是否存在如下问题?
  是否遇到转成的NV21数据转成RGB可用,但是在使用OpenGL绘制图像时得不到正确纹理?转成的NV21数据在Java里可用,在Jni里却挂掉了?
  是否遇到图像数据用的好好的,换一个图像分辨率代码就失效了?
  这些都是我遇到的一些坑了,肯定有小伙伴也遇到了相同的问题,就比如这个老外兄弟(他的提问下采纳的答案其实还是有问题的,本文会在后面讲解):
在这里插入图片描述
  直接说结论:网上的很多YUV_420_888转NV21的代码是错误的~有一些没有考虑图像的padding,导致图像分辨率改变,得到的NV21数据就不适用了。有的代码得到的NV21数据多了w*h/4 字节的数据,导致在一些算法中处理该数据会有越界的问题。因为我做项目的过程中,最开始也是拿来主义,直接在网上copy现成的代码。虽然刚开始没问题,但是程序遇到问题,反过来排查才发现其实得到的NV21数据是不正确的。经过自己修改,得到了一个性能和正确性都没问题的算法。
  为了知道网上的一些代码错在哪里,何时有问题,就要彻底了解NV21以及YUV_420_888图像的格式的原理,然后读懂代码,最后得到最优的正确的代码。本文就按这个思路:首先讲解NV21以及YUV_420_888图像的格式的原理,然后去看网上流传的代码,他们为何不对?最后给出自己的代码。如果只想要正确代码的朋友直接请拖到文末~
在这里插入图片描述

  为什么要讲解这两个格式呢?因为Android camera2 Api主推的图像格式就是这两种,在调用相机的过程,首先通过函数注册:

 mImageReader = ImageReader.newInstance(mPreviewSize.getWidth(), mPreviewSize.getHeight(),                        ImageFormat.YUV_420_888, 2);

  然后在相机的回调函数中就得到了数据类型为YUV420888的Image。Android中对图像数据的处理一般都是用NV21的,所以此时就需要将YUV420888类型的Image转成NV21类型的byte数组了。

  YUV_420_888这种图像的格式使用3个独立的Buffer表示,这三个Plane分别是代表Y,U,V三个平面,每个平面还有rowStride、pixelStride对应的属性。要读懂代码,必须了解这些属性的含义~
  (1) pixelStride(通过getPixelStride()获得
  像素步长,有可能是1、有可能是2。它代表的是行内连续两个颜色值之间的距离(步长)。
  这里还有个重要的点:假如是步长为2,意味索引间隔的原色才是有效的元素,中间间隔的元素其实是没有意义的。而Android中确实也是这么做的,比如某个plane[1](U分量)的步长是2,那么数组下标0,2,4,6,…的数据就是U分量的,而中间间隔的元素Android会补上V分量,也就是会是UVUVUV…这样去分布。但是当最后一个U分量出现后,最后一个没有意义的元素Android就不补了,也就是最后的V分量就不会补了,即是这样分布:UVUVUV…UVUVU。
  (2) rowStride(通过getRowStride()获得
  存储这个图像时,每行数据的宽度。这个和图像的宽不是一回事。由于系统的硬件等各方面的原因,存储数据的时候可能需要对齐,比如图像的宽为98字节,但是实际存储的过程中一行图像数据可能用了100字节。那么此时image.rowStride=100,而image.getWidth=98。我们并不能断定图像的宽一定等于rowStride!有些算法就是基于这个前提去处理,所以导致某些分辨率的图像转成NV21数组就不正确了,这个我们后文会有讲解。

  YUV_420_888的存储又分YUV420分为Planar格式(P)和Semi-Planar格式(SP)两大类,最主要的区别是:
  Planar格式(P)按平面分开放,先把U存放完后,再存放V。U是连续的,V也是连续的
即:YYYYYUUUUUVVVV
  Semi-Planar格式(SP)只有Y数据一个平面,UV数据合用一个平面。
即:YYYYYUVUVUV…
  注意这一点,在使用Android Api以下函数时,得到的平面式SP格式的

 mImageReader = ImageReader.newInstance(mPreviewSize.getWidth(), mPreviewSize.getHeight(),                        ImageFormat.YUV_420_888, 2);

  对于SP格式,得到的图像有两种典型类型:rowStride = Width的,(以6*4的图像进行说明)即:
在这里插入图片描述
  另一种是rowStride != Width的,即:
在这里插入图片描述
  图片来自另一个博主,链接在后面,大家可以过去看一下哦,他写的很好~

  我们现在了解了YUV_420_888图像的格式,现在我们要将它转换成NV21的byte数组。NV21的数组有什么特点呢,简单的说就是一个byte数组,先依次保存图像所有像素点的Y值,再交替保存V值和U值。以64的图像进行说明,NV21图像由64个Y值,6个V值和6个U值,一共有36个值。(不要以为看图像以为数组有48个值,上图表示的是Y,U,V三个平面是如何存放的,其中U平面和V平面是数据有重复,当我们提取数组时,是要去掉重复的数据部分的)。严格的NV21图像数据的长度为图像的 (长×宽×3/2),所以如果你的算法得出的数组长度都不满足,那这个数据一定是不正确的(有时候可能把不正确的数据拿去处理效果还是正确的,那是因为算法可能没有使用到你给到的冗余数据,但是在其它算法中,这个数组可能会导致程序异常)!
  所以我们目标很明确了对吧,要将Image通过上述的api,提取处它的y,u,v分量,然后把他们按照nv21的格式组成数组就可以了!(小伙伴们可以自己在网上了解一下NV21以及YV420数据的格式的更具体的讲解,这里就不赘述了~)

  下面开始代码分析,列举网上你最容易搜到的yuv420转NV21的代码,告诉你它们为什么不正确。
  首先是这一段代码,来自YUV_420_888toNV21代码一,这个代码很多地方都能搜到。

private static byte[] YUV_420_888toNV21(Image image) {    byte[] nv21;    ByteBuffer yBuffer = image.getPlanes()[0].getBuffer();    ByteBuffer uBuffer = image.getPlanes()[1].getBuffer();    ByteBuffer vBuffer = image.getPlanes()[2].getBuffer();    int ySize = yBuffer.remaining();    int uSize = uBuffer.remaining();    int vSize = vBuffer.remaining();    //错误点一,nv21数组大小不对。    nv21 = new byte[ySize + uSize + vSize];    //错误点二,没有考虑padding的情况。    yBuffer.get(nv21, 0, ySize);    vBuffer.get(nv21, ySize, vSize);    uBuffer.get(nv21, ySize + vSize, uSize);    return nv21;}

  首先说错误点一:nv21数组的大小不对。首先nv21图像数组的长度一定是长×宽×3/2,其中y数据的大小是长×宽(因为每个像素都有一个y值),接着所有像素的u,v值总共有(长×宽×1/2)个。我们通过image.getPlanes()[1].getBuffer()得到的Plane就是一个包含像素所有u,v值的缓存区域,它的大小就是长×宽×1/2,明白了吗?所以nv21的数据大小其实就是ySize + uSize,当然也可以是ySize+vSize。这里数据大小不对。代码中uBuffer.get(nv21, ySize + vSize, uSize)是多余的。vBuffer已经代表是像素中所有像素按照v,u,v,u,排序的结果了,我们直接将它放在y数据后边就是nv21的数据了。所以这一步多余且导致数组大小多了长×宽×1/2个值。
  错误点二,在图像没有padding的情况下,我们只用通过这两步就ok可以得到结果,但是如果数据有padding呢?这样的话数据不但多了长×宽×1/2的数据,还多了padding的数据?大家能明白吗?这个yBuffer,vBuffer和uBuffer是一组可能包含了padding的数据,我们要根据去判断rowStride是否等于Width来处理,如果等于,那么没问题;如果不等于,那么你要去除掉padding,得到真正的buffer,再进行处理。

    yBuffer.get(nv21, 0, ySize);    vBuffer.get(nv21, ySize, vSize);
private static byte[] YUV_420_888toNV21_fix(Image image) {    byte[] nv21;    ByteBuffer yBuffer = yBufferWithoutPadding(image.getPlanes()[0].getBuffer());    ByteBuffer vBuffer = uBufferWithoutPadding(image.getPlanes()[2].getBuffer());    int ySize = yBuffer.remaining();    int vSize = uBuffer.remaining();    nv21 = new byte[ySize + vSize];       yBuffer.get(nv21, 0, ySize);    vBuffer.get(nv21, ySize, vSize);    return nv21;}

  所以如果是得到NV21的数据,我们不需要使用u平面(image.getPlanes()[1]),因为v平面就是包含了图像所有像素vuvuvu的一组buffer。此外,我们需要得到真正的,去掉padding的buffer。

  这段代码分析完了,其实已经有老哥遇到相同的问题,他就是使用这段代码,随后在某些地方遇到了问题,然后发帖求助了:使用以上代码会遇到的问题
  在这个帖子中,歪果小哥哥也很热心地帮他分析了,并给上了自己代码,我在这里贴出来!通过这个代码是可以得到正确的NV21的数组的。然而里面依然存在逻辑错误,并且它是逐像素处理的,对于一个1280*720的图像,在我的手机上尝试的,一帧图像的处理时间直接从2ms上升至14ms,这显然无法接受。下面贴一下他的代码。

private static byte[] YUV_420_888toNV21(Image image) {    int width = image.getWidth();    int height = image.getHeight();     int ySize = width*height;    int uvSize = width*height/4;    byte[] nv21 = new byte[ySize + uvSize*2];    ByteBuffer yBuffer = image.getPlanes()[0].getBuffer(); // Y    ByteBuffer uBuffer = image.getPlanes()[1].getBuffer(); // U    ByteBuffer vBuffer = image.getPlanes()[2].getBuffer(); // V    int rowStride = image.getPlanes()[0].getRowStride();    assert(image.getPlanes()[0].getPixelStride() == 1);    int pos = 0;//part1: 判断了是否存在padding,然后相应地处理    if (rowStride == width) {         yBuffer.get(nv21, 0, ySize);        pos += ySize;    }    else {        long yBufferPos = -rowStride;          for (; pos<ySize; pos+=width) {            yBufferPos += rowStride;            yBuffer.position(yBufferPos);            yBuffer.get(nv21, pos, width);        }    }    rowStride = image.getPlanes()[2].getRowStride();    int pixelStride = image.getPlanes()[2].getPixelStride();    assert(rowStride == image.getPlanes()[1].getRowStride());    assert(pixelStride == image.getPlanes()[1].getPixelStride());        //part2:判断是否有padding,对uv分量做处理。    if (pixelStride == 2 && rowStride == width && uBuffer.get(0) == vBuffer.get(1)) {        byte savePixel = vBuffer.get(1);        try {        //part2.1 判断uv两个buffer是否重叠,如果是重叠,就直接赋值:            vBuffer.put(1, (byte)~savePixel);            if (uBuffer.get(0) == (byte)~savePixel) {                vBuffer.put(1, savePixel);                vBuffer.position(0);                uBuffer.position(0);                 //part2.3 认为uv重叠,直接从缓存区赋值                vBuffer.get(nv21, ySize, 1);                uBuffer.get(nv21, ySize + 1, uBuffer.remaining());                return nv21;             }        }        catch (ReadOnlyBufferException ex) {        }        vBuffer.put(1, savePixel);    }    //part2.3 判断uv两个buffer是否重叠,如果不重叠,就逐像素处理    for (int row=0; row<height/2; row++) {        for (int col=0; col<width/2; col++) {            int vuPos = col*pixelStride + row*rowStride;            nv21[pos++] = vBuffer.get(vuPos);            nv21[pos++] = uBuffer.get(vuPos);        }    }    return nv21;}

  首先呢,闻到了什么code smell了吗?学习我这篇代码重构–代码臃肿的小伙伴,一看到这个代码就皱眉头吧,就很难读懂。代码中使用了过多的参数,有着基本类型偏执的问题。建议使用提炼函数法以查询代替临时变量法减少一下参数和方法。(忍不住讲了一下代码重构,因为这样的代码看着确实不爽,有兴趣学习代码重构的小伙伴可以看我的代码重构系列教程)
  其实这个代码无非就是要判断存在padding,如果不等于,说明有padding,接着去除padding,再给nv21数组赋值接好了对吧。让我们一起看一下这一段代码的问题所在~

  首先看代码part1部分,如果所说,要先判断是否存在padding,然后做相应处理,它这里确实是判断了,如果存在了padding,就去除padding后把y分量的值赋给nv21数组。这里虽然啰嗦,单代码逻辑正确~
  首先看代码part2部分略微复杂。根据Android官方文档,其实u,v两个buffer中的数据就是uv数据交替的,我们在上面已经讲过了。但是这个博主显然忽略了这一点,它去判断u,v分量的缓存区是否重叠。也就是他在判断u[0]和v[1],v[0]和u[1]是不是同一块内存,他认为如果是同一块内存才说明uv交替,然后就可以直接赋值,否则他就逐像素区取u,v的值。
  然而实际情况是,u[0]和v[1]并不是同一块内存,但是他们的值是相同的,所以我们是没必要逐像素去做的。根据这个代码的逻辑,他的判断结果总是为false,所以他总是逐像素去做了。当然是可以得到正确的结果的,但是处理时间直接巨幅增加。
  再注意par2.3部分,虽然根据以上分析,他总是逐像素处理了,这里不会运行到,但是假设它运行到这里,他的处理逻辑也有问题?你有发现吗?他没有考虑padding的情况。对于y分量他都考虑了,对于uv分量他又忘记考虑了,如果是代码运行到这里,并且图像存在padding,程序会直接crash。

  核心部分,就根据我上面的伪代码,给一个我最后的实现吧。

  //Planar格式(P)的处理  private static ByteBuffer getuvBufferWithoutPaddingP(ByteBuffer uBuffer,ByteBuffer vBuffer, int width, int height, int rowStride, int pixelStride){        int pos = 0;        byte []byteArray = new byte[height*width/2];        for (int row=0; row<height/2; row++) {            for (int col=0; col<width/2; col++) {                int vuPos = col*pixelStride + row*rowStride;                byteArray[pos++] = vBuffer.get(vuPos);                byteArray[pos++] = uBuffer.get(vuPos);            }        }        ByteBuffer bufferWithoutPaddings=ByteBuffer.allocate(byteArray.length);        // 数组放到buffer中        bufferWithoutPaddings.put(byteArray);        //重置 limit 和postion 值否则 buffer 读取数据不对        bufferWithoutPaddings.flip();        return bufferWithoutPaddings;    }   //Semi-Planar格式(SP)的处理和y通道的数据 private static ByteBuffer getBufferWithoutPadding(ByteBuffer buffer, int width, int rowStride, int times,boolean isVbuffer){        if(width == rowStride) return buffer;  //没有buffer,不用处理。        int bufferPos = buffer.position();        int cap = buffer.capacity();        byte []byteArray = new byte[times*width];        int pos = 0;        //对于y平面,要逐行赋值的次数就是height次。对于uv交替的平面,赋值的次数是height/2次        for (int i=0;i<times;i++) {            buffer.position(bufferPos);            //part 1.1 对于u,v通道,会缺失最后一个像u值或者v值,因此需要特殊处理,否则会crash            if(isVbuffer && i==times-1){                width = width -1;            }            buffer.get(byteArray, pos, width);            bufferPos+= rowStride;            pos = pos+width;        }        //nv21数组转成buffer并返回        ByteBuffer bufferWithoutPaddings=ByteBuffer.allocate(byteArray.length);        // 数组放到buffer中        bufferWithoutPaddings.put(byteArray);        //重置 limit 和postion 值否则 buffer 读取数据不对        bufferWithoutPaddings.flip();        return bufferWithoutPaddings;    }    private static byte[] YUV_420_888toNV21(Image image) {        int width =  image.getWidth();        int height = image.getHeight();        ByteBuffer yBuffer = getBufferWithoutPadding(image.getPlanes()[0].getBuffer(), image.getWidth(), image.getPlanes()[0].getRowStride(),image.getHeight(),false);        ByteBuffer vBuffer;        //part1 获得真正的消除padding的ybuffer和ubuffer。需要对P格式和SP格式做不同的处理。如果是P格式的话只能逐像素去做,性能会降低。       if(image.getPlanes()[2].getPixelStride()==1){ //如果为true,说明是P格式。            vBuffer = getuvBufferWithoutPaddingP(image.getPlanes()[1].getBuffer(), image.getPlanes()[2].getBuffer(),                     width,height,image.getPlanes()[1].getRowStride(),image.getPlanes()[1].getPixelStride());        }else{            vBuffer = getBufferWithoutPadding(image.getPlanes()[2].getBuffer(), image.getWidth(), image.getPlanes()[2].getRowStride(),image.getHeight()/2,true);        }        //part2 将y数据和uv的交替数据(除去最后一个v值)赋值给nv21        int ySize = yBuffer.remaining();        int vSize = vBuffer.remaining();        byte[] nv21;        int byteSize = width*height*3/2;        nv21 = new byte[byteSize];        yBuffer.get(nv21, 0, ySize);        vBuffer.get(nv21, ySize, vSize);        //part3 最后一个像素值的u值是缺失的,因此需要从u平面取一下。        ByteBuffer uPlane = image.getPlanes()[1].getBuffer();        byte lastValue = uPlane.get(uPlane.capacity() - 1);        nv21[byteSize - 1] = lastValue;        return nv21;    }

  首先函数的主体是YUV_420_888toNV21,其实主要就是包含三个部分,代码中的注释已经给出了:
  part 1: 获得真正的消除padding的ybuffer和ubuffer。
  part 2: 将y数据和uv的交替数据(除去最后一个v值)赋值给nv21。
  part 3: 最后一个像素值的u值是缺失的,因此需要从u平面取一下。
  part 1部分的part1.1部分解释一下, 是因为我们逐行取出带有padding的缓存图像的时候,对于ubffer和vbuffer的最后一行,它是缺失掉一个像素点的。我上文也提到了:**“但是当最后一个U分量出现后,最后一个没有意义的元素Android就不补了,也就是最后的V分量就不会补了”**从我上面给的图中也能看出:
在这里插入图片描述
  所以在part1.1赋值的地方,对于vbuffer的最后一行赋值的width要减1,否则是会数组越界的。

  part2部分没什么要说的。

  part3和part1.1部分呼应,因为我们对于vbuffer的最后一行赋值的时候是缺失一个点的,所以我们这里要从ubuffer取出这个点,然后补充进去。当然你不补也没关系,默认是0。对于一个图像,这一个点显得微不足道。

  如我的代码重构教程所说,好的代码必须配合测试用例。我这里的测试用例就是获取到的相机支持的分辨率列表,大概有17个。经过本人测试,都可以通过这一段代码得到正确的nv21数据。另外,我这一段代码中我认为还是有很多基本数据类型,犯了基本数据类型偏执错误的,是可以进行重构的。日后我回来重构一下~

  其实YUV420_888转NV21看似简单,但是实际需要处理一些边界情况。网上的代码参差不齐,有的只是“看似”正确,实际可能会出各种问题。我写这个文章首先是帮大家避免一下坑吧,毕竟有可能自己在网上找到了这个代码,却不知道代码存在问题。另一个目的也就是希望大家能够自己理解这两种数据格式,读懂代码,这样才能在遇到问题和变化的时候从容应对。

来源地址:https://blog.csdn.net/weixin_41937380/article/details/127758173

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-移动开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯