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"代表第二行像素信息
那接下来这些就好理解了
- YUV444:每次采样时,亮度信号采样4次,同时第一行和第二行分别采样4个色度信息,因此得名“444”。这种格式保留了完整的色度信息,图像色彩最为丰富。
- YUV422:每次采样时,亮度信号采样4次,而第一行和第二行分别采样2个色度信息,因此得名“422”。这种格式在一定程度上减少了色度信息,但色彩表现仍然较为完整。
- YUV420:每次采样时,亮度信号采样4次,第一行采样2个色度信息,而第二行不采样色度信息,因此得名“420”。这种格式进一步减少了色度信息,但通过算法优化,仍然可以满足大多数视觉需求,市场主流相机采用的就是4:2:0色度采样。
- 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万像素的照片为例,完整处理流程包含:
- 解码原始图像
- 创建Bitmap对象
- 应用旋转变换
- 重新编码为JPEG/PNG
整个过程不仅耗时约500ms-1s,还会占用数十MB内存,性价比确实不高。但在实时视频直播场景下,情况截然不同。以移动端直播为例:
- 摄像头原始视频流多为横屏
- 用户可能竖持手机拍摄
- 需实时调整画面方向
若采用Bitmap转换方案处理1080P视频(约2MB/帧),将导致:
- 处理延迟:单帧超100ms,造成直播延迟累积
- CPU负载:持续90%以上占用
- 内存压力:频繁Bitmap操作引发GC抖动
- 电量消耗:设备快速耗电
可见,技术方案必须结合实际场景选择。尤其在资源有限的移动端实时场景,寻找最优解至关重要。
以上就是本人对于YUV420的理解以及它在Android端的具体使用示例,希望能帮到需要此需求的人!