YUV420格式初探——Android Camera NV21数据

1、YUV起源

        YUV颜色编码系统的起源与黑白电视向彩色电视的过渡密切相关。20世纪中叶,为兼容黑白电视信号,工程师设计了YUV模型,将亮度(Y)与色度(UV)分离传输。

2、YUV的技术原理

YUV通过亮度(Y)和色度(UV)分量表示颜色:

  • Y(亮度):基于人眼对亮度的敏感度,与黑白电视信号完全兼容。
  • UV(色度):包含颜色信息,通过降低带宽传输(如4:2:2采样),减少数据量。

公式示例:
Y = 0.299R + 0.587G + 0.114B
U = B - Y
V = R - Y

注:以上公式表示的是YUV与RGB的转换关系,其中RGB三原色取值范围均在[0,255]。

3、YUV 色彩模型的分类

        在数字图像处理领域,YUV色彩空间的分类广为人知,其中常见的格式包括YUV444、YUV422和YUV420。尽管网络上充斥着大量关于这些格式的解析文档,但往往让人感觉晦涩难懂。因此,本文我将基于自己的理解,尽可能用通俗易懂的方式来为大家详细解释这些YUV格式的奥秘。

        为什么有YUV444、YUV422、YUV420甚至YUV400等不同的格式呢?一句话总结:这些格式通过不同程度地减少图像的色度信息,来实现数据压缩,而我们的大脑会自动对缺失的部分进行补充

关于这几个数字,我们先来了解一下这些数字分别代表着什么!以YUV444为例:

  • 第一个"4"代表采样单位宽度,其实也表示亮度信息为4,即Y=4。也就是说,每次采样,都会采集亮度信息。
  • 第二个"4"代表第一行像素信息
  • 第三个"4"代表第二行像素信息

那接下来这些就好理解了

  1. YUV444:每次采样时,亮度信号采样4次,同时第一行和第二行分别采样4个色度信息,因此得名“444”。这种格式保留了完整的色度信息,图像色彩最为丰富。
  2. YUV422:每次采样时,亮度信号采样4次,而第一行和第二行分别采样2个色度信息,因此得名“422”。这种格式在一定程度上减少了色度信息,但色彩表现仍然较为完整。
  3. YUV420:每次采样时,亮度信号采样4次,第一行采样2个色度信息,而第二行不采样色度信息,因此得名“420”。这种格式进一步减少了色度信息,但通过算法优化,仍然可以满足大多数视觉需求,市场主流相机采用的就是4:2:0色度采样。
  4. YUV400:每次采样时,亮度信号采样4次,但第一行和第二行均不采样色度信息,仅保留亮度信息,因此得名“400”。这种格式主要用于灰度图像或对色彩要求不高的场景

        其他格式的采样方式可以以此类推。至于为什么只取前两行而不涉及第三、第四乃至更多行,原因在于颜色矩阵在空间中是循环排列的。通过采样前两行的数据,已经能够推导出空间内其他位置的排列方式。虽然其他位置的具体数据值可能不同,但其排列规律是相同的,附一张图,应该就很好理解了,其中CrCb就是UV的另一种表示方式:

4、Android中的YUV420

        你可能会问:在Android系统中,YUV420格式的数据究竟用在哪里呢?答案其实就在我们身边——相机(Camera)就是最常见的使用场景,几乎每天都会接触到。

       在Android开发领域中,目前系统提供了三个相机API类:android.hardware.Camera、android.hardware.Camera2以及最新的androidx.camera.core.CameraX。这些API的迭代并非冗余设计,而是为了满足不同需求。从兼容性来看,Camera支持Android 1.0及以上版本,Camera2从Android 5.0引入,而CameraX则在Android 10.0才推出稳定版本。这些API的演进旨在不断优化开发流程,提升功能和性能,使开发者能够更便捷地调用相机模块,从而开发出用户体验更出色的应用。

        本文选用android.hardware.Camera作为示例,主要是因为该API模块能够直接获取原始YUV420数据(即NV21格式)。相比之下,其他两个模块的API已经对YUV420数据进行了封装处理,若使用它们就需要先将封装数据解包为YUV420格式,这部分内容本文不再展开说明。

        此外,选择android.hardware.Camera还有一个关键考量——它拥有更好的兼容性和普适性。这一特性不仅便于在不同设备间移植,也为嵌入式开发板的相机模块调试提供了极大的便利。

5、代码详细示例

5.1 界面布局xml

        <SurfaceView
            android:id="@+id/surfaceView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/dp_10" />

5.2 控件初始化

    override fun initOnCreate(savedInstanceState: Bundle?) {
        val viewParams = binding.surfaceView.layoutParams as ViewGroup.LayoutParams
        val videoHeight = getScreenHeight() shr 1
        val videoWidth = videoHeight * (9f / 16)
        viewParams.height = videoHeight.toInt()
        viewParams.width = videoWidth.toInt()
        binding.surfaceView.layoutParams = viewParams
        surfaceHolder = binding.surfaceView.holder
        surfaceHolder.addCallback(surfaceCallback)
    }

        上面这段代码的核心功能是动态调整相机预览画面的宽高比为9:16,并初始化SurfaceView的回调接口。

    private val surfaceCallback = object : SurfaceHolder.Callback {
        override fun surfaceCreated(holder: SurfaceHolder) {
            openCamera()
        }

        override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {

        }

        override fun surfaceDestroyed(holder: SurfaceHolder) {
            if (::camera.isInitialized) {
                camera.stopPreview()
            }
        }
    }

        上面这段代码负责监听SurfaceView的状态变化,由于涉及到相机状态的同步,必须确保两者状态一致,否则可能导致程序崩溃。

5.3 打开相机

    private fun openCamera() {
        try {
            camera = Camera.open(cameraId)
            val optimalParameters = camera.getParameters()
            optimalSize = optimalParameters?.supportedPreviewSizes?.findOptimalPreviewSize()!!
            optimalParameters.setPreviewSize(optimalSize.width, optimalSize.height)
            camera.apply {
                parameters = optimalParameters
                /**
                 *
                 * 这个设置只会影响 SurfaceView 的预览画面显示方向,但不会改变 onPreviewFrame 中返回的原始 YUV 数据的方向
                 * */
                val rotation = getCameraRotation()
                setDisplayOrientation(rotation)
                setPreviewDisplay(surfaceHolder)
                startPreview()
                if (parameters.supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) {
                    autoFocus(object : Camera.AutoFocusCallback {
                        override fun onAutoFocus(success: Boolean, camera: Camera?) {}
                    })
                }
                setPreviewCallback(this@YuvDataActivity)
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

        上面这段代码的执行流程如下:首先设置相机的基本参数,接着开启相机设备,然后配置预览数据回调(这个关键功能将在后续详细说明)。在设置相机参数时,有几个要点需要特别注意,下面我们将逐一展开说明。

5.3.1 获取当前设备最佳分辨率

    private fun List<Camera.Size>.findOptimalPreviewSize(): Camera.Size {
        val targetRatio = 16f / 9f
        val tolerance = 0.001f
        var optimalSize: Camera.Size? = null
        forEach { size ->
            val ratio = size.width.toFloat() / size.height.toFloat()
            Log.d(kTag, "[${size.width}, ${size.height}], $ratio")

            if (abs(ratio - targetRatio) <= tolerance) {
                if (optimalSize == null || size.width > optimalSize.width) {
                    optimalSize = size
                }
            }
        }

        Log.d(kTag, "最佳尺寸:[${optimalSize?.width}, ${optimalSize?.height}]")

        // 如果没有找到符合比例的,就选最大分辨率
        if (optimalSize == null) {
            optimalSize = maxByOrNull { it.width * it.height }!!
        }

        return optimalSize
    }

        在Android系统中,相机硬件的默认方向是横向模式。即使设备处于竖屏状态,相机输出的图像数据仍然保持横屏方向。因此,当计算目标比例时,我们需要将16:9的宽高比转换为9:16(即val targetRatio = 16f / 9f)。如果直接使用9:16的比例(val targetRatio = 9f / 16f),系统将无法正确匹配最佳分辨率。

        定义容差参数 tolerance = 0.001f 用于处理尺寸匹配误差。由于可能存在两种情况:1)用户设置的 targetRatio 数值不合理;2)相机硬件实际支持的宽高比无法完全匹配目标比例。这个容差值可以帮助系统筛选出最接近的合适尺寸。代码执行效果如下:

14:40:12.920 YuvDataActivity        com.example.android        D  [2400, 1080], 2.2222223
14:40:12.921 YuvDataActivity        com.example.android        D  [1920, 1080], 1.7777778
14:40:12.921 YuvDataActivity        com.example.android        D  [1600, 1200], 1.3333334
14:40:12.921 YuvDataActivity        com.example.android        D  [1600, 720], 2.2222223
14:40:12.921 YuvDataActivity        com.example.android        D  [1440, 1080], 1.3333334
14:40:12.921 YuvDataActivity        com.example.android        D  [1080, 1080], 1.0
14:40:12.921 YuvDataActivity        com.example.android        D  [1280, 960], 1.3333334
14:40:12.921 YuvDataActivity        com.example.android        D  [1280, 720], 1.7777778
14:40:12.921 YuvDataActivity        com.example.android        D  [864, 480], 1.8
14:40:12.921 YuvDataActivity        com.example.android        D  [800, 600], 1.3333334
14:40:12.921 YuvDataActivity        com.example.android        D  [720, 480], 1.5
14:40:12.921 YuvDataActivity        com.example.android        D  [640, 480], 1.3333334
14:40:12.921 YuvDataActivity        com.example.android        D  [352, 288], 1.2222222
14:40:12.921 YuvDataActivity        com.example.android        D  [320, 240], 1.3333334
14:40:12.921 YuvDataActivity        com.example.android        D  [176, 144], 1.2222222
14:40:12.921 YuvDataActivity        com.example.android        D  最佳尺寸:[1920, 1080]

5.3.2 设置预览画面方向

        如前所述,Android系统的相机硬件默认采用横向模式。若不手动设置相机预览方向,系统会按照横屏模式显示画面。由于我们之前已将SurfaceView设为竖屏模式,这会导致画面比例失调。

    fun getCameraRotation(): Int {
        // 0, 1, 2, 3 → Surface.ROTATION_0/90/180/270
        val rotation = windowManager.defaultDisplay.rotation
        var degrees = 0
        when (rotation) {
            Surface.ROTATION_0 -> degrees = 0
            Surface.ROTATION_90 -> degrees = 90
            Surface.ROTATION_180 -> degrees = 270
            Surface.ROTATION_270 -> degrees = 180
        }

        val info = Camera.CameraInfo()
        Camera.getCameraInfo(cameraId, info)
        var result = 0
        if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
            result = (info.orientation + degrees) % 360
            result = (360 - result) % 360 // 镜像翻转
        } else {
            result = (info.orientation - degrees + 360) % 360
        }
        return result
    }

        注意:前置相机还需要把画面翻转,不然会有镜像效果

5.3.3 自动对焦

        这个自不用多说,如果不设置自动对焦,画面会出现模糊不清的效果,设置下即可,如下:

                if (parameters.supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) {
                    autoFocus(object : Camera.AutoFocusCallback {
                        override fun onAutoFocus(success: Boolean, camera: Camera?) {}
                    })
                }

        注意:要在预览之后设置

        附一张预览成功的画面:

5.4 NV21数据回调

        前面提到,setPreviewCallback(this@YuvDataActivity) 是用于监听NV21数据回调的函数,其回调如下:

    override fun onPreviewFrame(data: ByteArray, camera: Camera) {
        
    }

        回调函数返回的ByteArray正是我们所需的NV21数据(即YUV420数据),这也标志着本文核心内容的开始。

        如前所述,相机回调提供的NV21数据处于横屏模式(画面顺时针旋转了90°),因此需要手动将其逆时针旋转90°(或顺时针旋转270°)。这一操作本质上是对颜色空间中的二阶矩阵进行变换处理,所以接下来我们将详细介绍如何将横向NV21数据转换为竖屏NV21数据。这个转换过程放在JNI/C++中实现,不仅效率更高,还能有效保护代码不被逆向。需要说明的是,这里展示的只是一个简易的实现方案。

5.5 JNI配置

5.5.1 新建cpp目录

        在app/src/main目录下新建名为cpp文件夹(必须这个名字),然后在该文件夹中创建以 .cpp为后缀的文件,我这里就叫yuv.cpp了,然后在此目录下新建一个CMakeLists.txt,配置如下:

project(yuv)

cmake_minimum_required(VERSION 3.10)

add_library(yuv SHARED yuv.cpp)

find_library(log-lib log)

target_link_libraries(yuv ${log-lib})

5.5.2 修改build.gradle文件

        修改app目录下的build.gradle文件,添加如下配置:

android {
    //其他略

    defaultConfig {
        //其他略
        ndkVersion "26.1.10909125"
        ndk {
            abiFilters "arm64-v8a", "armeabi-v7a", "x86_64", "x86"
        }
    }

    externalNativeBuild {
        cmake {
            path file('src/main/cpp/CMakeLists.txt')
            version '3.22.1'
        }
    }
}

        到这一步之后,点击Android Studio右上角那个“大象”按钮,同步一下即可。

5.5.3 Kotlin添加JNI桥接函数

        在你项目的包名下随便找个位置新建 Yuv.kt ,添加如下代码:

object Yuv {
    init {
        System.loadLibrary("yuv")
    }

    /**
     * 旋转 NV21 数据
     * @param input 原始 NV21 数据
     * @param width 图像宽度
     * @param height 图像高度
     * @param rotation 需要旋转的角度,比如,画面被顺时针旋转90度,那么nv21就需要逆时针旋转90度才能正常显示(0, 90, 180, 270)
     * @return 旋转后的 NV21 数据
     */
    external fun rotate(input: ByteArray, width: Int, height: Int, rotation: Int): ByteArray
}

        在初始化时加载C++新添加的库(库名称必须与CMakeLists.txt中保持一致,否则会导致加载失败)。Kotlin对接C++的桥接函数需以external关键字开头,Java则需使用native开头,请注意区分。

5.5.4 C++添加桥接函数签名

        签名规则:Java_包名_文件名_方法名

        注意点:1) 包名的“.”全部要换成“_”,否则编译不通过

                       2)所有的JNI函数都要包裹在这个函数域下面 extern "C" {} ,否则编译不通过

                       3)返回值要用 JNIEXPORT jbyteArray JNICALL 申明

        那么,我这的C++桥接函数的签名就应该是下面这样:

JNIEXPORT jbyteArray JNICALL
Java_com_example_android_util_Yuv_rotate(JNIEnv *env, jobject thiz, jbyteArray input, jint width, jint height, jint rotation) {

}

5.5.5 C++实现矩阵转置算法

JNIEXPORT jbyteArray JNICALL
Java_com_example_android_util_Yuv_rotate(JNIEnv *env, jobject thiz,
                                         jbyteArray input, jint width, jint height, jint rotation) {
    __android_log_print(ANDROID_LOG_DEBUG, "yuv", "rotate: width=%d, height=%d", width, height);
    jbyte *data = env->GetByteArrayElements(input, nullptr);
    if (!data) return nullptr;

    int y_plane_size = width * height;
    int u_plane_size = width / 2 * height / 2;
    int v_plane_size = width / 2 * height / 2;
    int total_size = y_plane_size + u_plane_size + v_plane_size;
    __android_log_print(ANDROID_LOG_DEBUG, "yuv", "rotate: total size=%d", total_size);

    // 分配输出内存
    auto *out_data = new jbyte[total_size];

    auto *src_y = reinterpret_cast<uint8_t *>(data);
    uint8_t *src_vu = reinterpret_cast<uint8_t *>(data) + y_plane_size;

    auto *dst_y = reinterpret_cast<uint8_t *>(out_data);
    uint8_t *dst_vu = reinterpret_cast<uint8_t *>(out_data) + (width * height);

    switch (rotation) {
        case 90:
            rotate_nv21_90(src_y, src_vu, dst_y, dst_vu, width, height);
            break;
        case 180:
            rotate_nv21_180(src_y, src_vu, dst_y, dst_vu, width, height);
            break;
        case 270:
            rotate_nv21_270(src_y, src_vu, dst_y, dst_vu, width, height);
            break;
        default:
            memcpy(out_data, data, total_size); // 不旋转直接复制
            break;
    }
    jbyteArray result = env->NewByteArray(total_size);
    env->SetByteArrayRegion(result, 0, total_size, out_data);

    delete[] out_data;
    env->ReleaseByteArrayElements(input, data, 0);

    return result;
}

        这里,我只以顺时针旋转90°为例,代码如下:

void rotate_nv21_90(const uint8_t *y_src, const uint8_t *vu_src, uint8_t *y_dst, uint8_t *vu_dst,
                    int width, int height) {
    // 把原始yuv(一维数组)转为临时矩阵,C++不支持动态二维数组
    auto y_temp = std::make_unique<uint8_t[]>(width * height);
    for (int i = 0; i < height; ++i) {
        for (int j = 0; j < width; ++j) {
            y_temp[j * height + i] = y_src[i * width + j];
        }
    }

    // 矩阵转置
    for (int i = 0; i < width; ++i) {
        for (int j = 0; j < height; ++j) {
            y_dst[i * height + j] = y_temp[i * height + (height - 1 - j)];
        }
    }

    int uv_width = width / 2;
    int uv_height = height / 2;
    auto uv_temp = std::make_unique<uint8_t[]>(uv_width * uv_height * 2); // 每个像素是 VU 对
    for (int i = 0; i < uv_height; ++i) {
        for (int j = 0; j < uv_width; ++j) {
            int src_index = (i * width + j * 2); // 源中的 VU 对起始位置
            uv_temp[(j * uv_height + i) * 2] = vu_src[src_index];     // V
            uv_temp[(j * uv_height + i) * 2 + 1] = vu_src[src_index + 1]; // U
        }
    }

    // 矩阵转置
    int dst_index = 0;
    for (int i = 0; i < uv_width; ++i) {
        for (int j = uv_height - 1; j >= 0; --j) {
            vu_dst[dst_index++] = uv_temp[(i * uv_height + j) * 2];     // V
            vu_dst[dst_index++] = uv_temp[(i * uv_height + j) * 2 + 1]; // U
        }
    }
}

        上述逻辑及注释已详细说明,无需重复。具体实现方法为:先用临时一维数组分别处理Y分量和UV分量,通过两次循环构建二阶矩阵,最后执行矩阵转置操作即可。

5.6 旋转结果展示

        在相应的位置调用桥接函数即可,如:

        binding.yuvButton.setOnClickListener {
            val width = optimalSize.width
            val height = optimalSize.height
            val bytes = Yuv.rotate(nv21, width, height, degrees)

            val rotatedWidth: Int
            val rotatedHeight: Int
            // 90和270旋转后宽高互换
            if (degrees == 90 || degrees == 270) {
                rotatedWidth = height
                rotatedHeight = width
            } else {
                rotatedWidth = width
                rotatedHeight = height
            }

            val bitmap = createBitmap(rotatedWidth, rotatedHeight, Bitmap.Config.ARGB_8888)

            // 提取 Y 平面并复制到 IntArray 中(转换为灰度)
            val ySize = rotatedWidth * rotatedHeight
            val pixels = IntArray(ySize)
            for (i in 0 until ySize) {
                val y = bytes[i].toInt() and 0xFF
                pixels[i] = 0xFF000000.toInt() or (y shl 16) or (y shl 8) or y
            }

            bitmap.setPixels(pixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight)
            binding.yuvImageView.setImageBitmap(bitmap)
        }

        上述bytes数据是通过旋转NV21格式图像获得的,格式仍为YUV420,仅调整了画面方向。随后提取其中的Y分量,最终呈现为灰度图,如下所示:

6、总结

        或许有人会问:为了调整图像方向,值得这么大费周章吗?这要视具体情况而定。在普通图片处理场景中,若只需旋转单张静态图片,这种先转Bitmap再旋转的方式确实得不偿失。以2000万像素的照片为例,完整处理流程包含:

  1. 解码原始图像
  2. 创建Bitmap对象
  3. 应用旋转变换
  4. 重新编码为JPEG/PNG

整个过程不仅耗时约500ms-1s,还会占用数十MB内存,性价比确实不高。但在实时视频直播场景下,情况截然不同。以移动端直播为例:

  • 摄像头原始视频流多为横屏
  • 用户可能竖持手机拍摄
  • 需实时调整画面方向

若采用Bitmap转换方案处理1080P视频(约2MB/帧),将导致:

  1. 处理延迟:单帧超100ms,造成直播延迟累积
  2. CPU负载:持续90%以上占用
  3. 内存压力:频繁Bitmap操作引发GC抖动
  4. 电量消耗:设备快速耗电

可见,技术方案必须结合实际场景选择。尤其在资源有限的移动端实时场景,寻找最优解至关重要。

        以上就是本人对于YUV420的理解以及它在Android端的具体使用示例,希望能帮到需要此需求的人!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值