如何在 Android 中有效地动态操作 YUV 相机帧?

How to manipulate on the fly YUV Camera frame efficiently in Android?

我在从线程中的 Android CameraPreview 回调获得的 NV21 帧的感兴趣区域(中心)周围添加了一个黑色 (0) 填充。

为了避免转换为 RGB/Bitmap 和反向的开销,我试图直接操作 NV21 字节数组,但这涉及嵌套循环,这也使 preview/processing 变慢。

这是我的 run() 方法在调用方法 blackNonROI 后向检测器发送帧。

public void run() {
    Frame outputFrame;
    ByteBuffer data;
    while (true) {
        synchronized (mLock) {

            while (mActive && (mPendingFrameData == null))
                try{ mLock.wait(); }catch(InterruptedException e){ return; }

            if (!mActive) { return; }

            // Region of Interest
            mPendingFrameData = blackNonROI(mPendingFrameData.array(),mPreviewSize.getWidth(),mPreviewSize.getHeight(),300,300);

            outputFrame = new Frame.Builder().setImageData(mPendingFrameData, mPreviewSize.getWidth(),mPreviewSize.getHeight(), ImageFormat.NV21).setId(mPendingFrameId).setTimestampMillis(mPendingTimeMillis).setRotation(mRotation).build();

            data = mPendingFrameData;
            mPendingFrameData = null;

        }

        try {
            mDetector.receiveFrame(outputFrame);
        } catch (Throwable t) {
        } finally {
            mCamera.addCallbackBuffer(data.array());
        }
    }
}

下面是方法blackNonROI

private ByteBuffer blackNonROI(byte[] yuvData, int width, int height, int roiWidth, int roiHeight){

    int hozMargin = (width - roiWidth) / 2;
    int verMargin = (height - roiHeight) / 2;

    // top/bottom of center
    for(int x=0; x<width; x++){
        for(int y=0; y<verMargin; y++)
            yuvData[y * width + x] = 0;
        for(int y=height-verMargin; y<height; y++)
            yuvData[y * width + x] = 0;
    }

    // left/right of center
    for(int y=verMargin; y<height-verMargin; y++){
        for (int x = 0; x < hozMargin; x++)
            yuvData[y * width + x] = 0;
        for (int x = width-hozMargin; x < width; x++)
            yuvData[y * width + x] = 0;
    }

    return ByteBuffer.wrap(yuvData);
}

Example output frame

请注意,我没有裁剪图像,只是在图像的指定中心周围填充黑色像素,以保持协调以进行进一步的活动。这可以正常工作,但速度不够快,导致预览和帧处理延迟。

  1. 我可以进一步改进字节数组更新吗?
  2. 调用 blackNonROI time/place 可以吗?
  3. 还有其他更有效的方法/库吗?
  4. 我的简单像素迭代如此缓慢,YUV/Bitmap 库如何快速完成复杂的事情?他们使用 GPU 吗?

编辑:

我已经用下面的代码替换了两个 for 循环,现在速度非常快(详情请参考 greeble31 的回答):

    // full top padding
    from = 0;
    to = (verMargin-1)*width + width;
    Arrays.fill(yuvData,from,to,(byte)1);

    // full bottom padding
    from = (height-verMargin)*width;
    to = (height-1)*width + width;
    Arrays.fill(yuvData,from,to,(byte)1);

    for(int y=verMargin; y<height-verMargin; y++) {
        // left-middle padding
        from = y*width;
        to = y*width + hozMargin;
        Arrays.fill(yuvData,from,to,(byte)1);

        // right-middle padding
        from = y*width + width-hozMargin;
        to = y*width + width;
        Arrays.fill(yuvData,from,to,(byte)1);
    }

1. 是的。要了解原因,让我们看一下 Android Studio 为您的 "left/right of center" 嵌套循环生成的字节码:

blackNonROI 发布版本的注释摘录,AS 3.2.1):

:goto_27
sub-int v2, p2, p4         ;for(int y=verMargin; y<height-verMargin; y++)
if-ge v1, v2, :cond_45
const/4 v2, 0x0
:goto_2c
if-ge v2, p3, :cond_36     ;for (int x = 0; x < hozMargin; x++)
mul-int v3, v1, p1
add-int/2addr v3, v2
.line 759
aput-byte v0, p0, v3
add-int/lit8 v2, v2, 0x1
goto :goto_2c
:cond_36
sub-int v2, p1, p3 
:goto_38
if-ge v2, p1, :cond_42     ;for (int x = width-hozMargin; x < width; x++)
mul-int v3, v1, p1
add-int/2addr v3, v2
.line 761
aput-byte v0, p0, v3
add-int/lit8 v2, v2, 0x1
goto :goto_38
:cond_42
add-int/lit8 v1, v1, 0x1
goto :goto_27
.line 764
:cond_45                   ;all done with the for loops!

无需费心逐行破译整个事情,很明显,您的每个小内部循环都在执行:

  • 1 比较
  • 1个整数乘法
  • 1 加法
  • 1 家商店
  • 1 转到

很多,当你考虑到你真正需要这个内部循环做的就是将一定数量的连续数组元素设置为 0 时。

此外,其中一些字节码需要多条机器指令才能实现,因此如果您正在查看超过 20 个周期,我不会感到惊讶,只是为了对其中一个内部循环进行一次迭代。 (我还没有测试过这段代码被 Dalvik VM 编译后的样子,但我真诚地怀疑它是否足够智能来优化这些循环中的乘法。)

可能的修复

您可以通过消除一些冗余计算来提高性能。例如,每个内部循环都在重新计算 y * width 每次 。相反,您可以预先计算该偏移量,将其存储在局部变量中(在外循环中),并在计算索引时使用它。

当性能绝对关键时,我有时会在本机代码中执行此类缓冲区操作。如果您可以合理地确定 mPendingFrameDataDirectByteBuffer,这是一个更有吸引力的选择。缺点是 1.) 更高的复杂性,以及 2.) 如果出现 wrong/crashes.

则更少 "safety net"

最合适的修复

在您的情况下,最合适的解决方案可能只是使用 Arrays.fill(),这更有可能以优化的方式实施。

请注意,顶部和底部块是大的、连续的内存块,每个块可以由一个 Arrays.fill() 处理:

Arrays.fill(yuvData, 0, verMargin * width, 0);   //top
Arrays.fill(yuvData, width * height - verMargin * width, width * height, 0);    //bottom

然后侧面可以这样处理:

for(int y=verMargin; y<height-verMargin; y++){
    int offset = y * width;
    Arrays.fill(yuvData, offset, offset + hozMargin, 0);  //left
    Arrays.fill(yuvData, offset + width, offset + width - hozMargin, 0);   //right
}

这里有更多的优化机会,但我们已经到了减少的地步 returns。例如,由于每一行的末尾都与下一行的开头相邻(在内存中),您实际上可以将两个较小的 fill() 调用组合成一个更大的调用,同时覆盖第 N 行的右侧和第 N + 1 行的左侧。依此类推。

2. 不确定。如果您的预览显示时没有任何 corruption/tearing,那么它可能是一个 安全 调用函数的地方(从线程安全的角度来看),因此可能也是一个很好的地方和任何人一样。

3 和 4. 可以有库来完成这个任务;对于基于 Java 的 NV21 帧,我不知道有什么副手。您必须进行一些格式转换,我认为这不值得。在我看来,使用 GPU 来完成这项工作是过度优化,但它可能适用于某些专门的应用程序。在考虑使用 GPU 之前,我会考虑使用 JNI(本机代码)。

我认为你选择直接对 NV21 进行操作,而不是转换为位图,这是一个很好的选择(考虑到你的需求以及任务足够简单以避免需要图形库这一事实)。

显然,传递图像进行检测的最有效方法是将 ROI 矩形传递给检测器。我们所有的图像处理函数都接受边界框作为参数。

如果黑边用于显示,请考虑使用黑色覆盖遮罩进行预览布局,而不是像素操作。

如果像素操作不可避免,请检查是否可以将其限制为 Y 好的,您已经这样做了!

如果您的检测器在缩小的图像上工作(就像我的人脸识别引擎所做的那样),对调整大小的帧应用黑色可能是明智的。

无论如何,请保持循环干净整洁,删除所有重复计算。使用 Arrays.fill() 操作可能会有很大帮助,但不是很大。