7、YUV数据渲染:YUV色彩空间简介、YUV420P/NV12/NV21格式、RenderScript转换、OpenGL ES渲染YUV
说到视频渲染,YUV 是个绕不开的话题。我记得刚入行那会儿,第一次拿到摄像头采集的数据,直接当 RGB 往屏幕上怼,结果颜色全乱套了。后来才明白,原来摄像头和视频编码器用的都是 YUV,而不是我们熟悉的 RGB。
这一章,我们就来彻底搞懂 YUV。从色彩空间的基本概念,到几种常见的 YUV 格式,再到如何在 Android 上把它们渲染出来。嗯,内容不少,但都很实用。
YUV 色彩空间:为什么视频不用 RGB?
YUV 和 RGB 一样,都是描述颜色的方式。但 YUV 有个核心优势:亮度和色度分离。
- Y 代表亮度(Luma),就是画面的明暗信息。
- U 和 V 代表色度(Chroma),负责描述颜色。
人眼对亮度变化很敏感,但对颜色细节其实没那么敏感。所以视频编码时,可以保留完整的 Y 信息,而对 UV 进行压缩。这就是 YUV 采样格式的由来。
核心思想:YUV 允许我们对色度信息进行降采样,大幅减少数据量,而人眼几乎察觉不到画质损失。视频压缩能做得那么小,YUV 功不可没。
三种常见的 YUV 格式
在 Android 平台上,我们最常打交道的 YUV 格式有三种:YUV420P、NV12 和 NV21。它们都属于 YUV420 家族,意思是每 4 个 Y 像素共享一组 UV。
YUV420P(也叫 I420)
这种格式是「平面」存储的。所有 Y 数据排在一起,然后所有 U 数据,最后所有 V 数据。结构非常清晰。
// YUV420P 内存布局
[YYYYYYYYYYYYYYYY] [UUUU] [VVVV]
// 假设图像宽高为 w x h
// Y 平面大小:w * h
// U 平面大小:w/2 * h/2
// V 平面大小:w/2 * h/2
我在项目中遇到过一个问题:用 MediaCodec 解码出来的数据,默认就是 YUV420P。但有些渲染器只认 NV12,这就得做格式转换。
NV12
NV12 是「半平面」存储。Y 数据单独一个平面,UV 数据交错存放在另一个平面。顺序是 U、V、U、V……
// NV12 内存布局
[YYYYYYYYYYYYYYYY] [UVUVUVUV]
// Y 平面大小:w * h
// UV 交错平面大小:w/2 * h/2 * 2 = w * h / 2
Android 的 Camera2 API 在 YUV_420_888 格式下,默认输出就是 NV12 居多。嗯,这个要注意。
NV21
NV21 和 NV12 几乎一样,唯一的区别是 UV 交错平面的顺序:V 在前,U 在后。
// NV21 内存布局
[YYYYYYYYYYYYYYYY] [VUVUVUVU]
// 大小和 NV12 完全一致
老版本的 Camera API(Camera1)默认输出就是 NV21。如果你还在维护老项目,大概率会碰到它。
我的习惯:在代码里统一用 YUV420P 作为中间格式。不管输入是 NV12 还是 NV21,先转成 YUV420P,再交给下游处理。这样逻辑清晰,不容易出错。
YUV 格式对比一览
| 格式 | 存储方式 | UV 顺序 | 常见场景 |
|---|---|---|---|
| YUV420P | 全平面 | Y / U / V 独立 | MediaCodec 解码输出 |
| NV12 | 半平面 | UV 交错,U 在前 | Camera2、硬件编码器 |
| NV21 | 半平面 | VU 交错,V 在前 | Camera1 预览回调 |
RenderScript 转换:CPU 不够,脚本凑
早期 Android 上做 YUV 转 RGB,很多人用 RenderScript。它能在 CPU 和 GPU 之间自动调度,写起来也简单。
我曾经在一个低端机上用 RenderScript 做 YUV 转 Bitmap,效果还行。但后来发现,RenderScript 在 Android 10 以后被标记为废弃了。嗯,新项目不建议再用。
// RenderScript 转换示例(仅供参考)
RenderScript rs = RenderScript.create(context);
ScriptIntrinsicYuvToRGB yuvToRgb = ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs));
Allocation input = Allocation.createSized(rs, Element.U8(rs), yuvData.length);
Allocation output = Allocation.createTyped(rs, Type.createXY(rs, Element.RGBA_8888(rs), width, height));
input.copyFrom(yuvData);
yuvToRgb.setInput(input);
yuvToRgb.forEach(output);
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
output.copyTo(bitmap);
注意:RenderScript 的 YUV 转 RGB 只支持 NV21 格式。如果你手里是 YUV420P 或 NV12,得先转成 NV21 才能用。这其实挺坑的。
OpenGL ES 渲染 YUV:这才是正道
说实话,用 CPU 做 YUV 转 RGB 效率太低了。尤其是 1080p 甚至 4K 的视频,每一帧都要转换,CPU 根本扛不住。
正确的做法是用 OpenGL ES,把 YUV 数据上传到 GPU,在着色器里完成颜色转换。GPU 天生适合做这种并行计算。
核心思路
我们需要把 Y、U、V 分别上传到三个纹理。然后在片段着色器里,用标准的 YUV 转 RGB 矩阵做计算。
// 片段着色器:YUV420P 转 RGB
#version 300 es
precision mediump float;
uniform sampler2D yTexture;
uniform sampler2D uTexture;
uniform sampler2D vTexture;
in vec2 vTexCoord;
out vec4 fragColor;
void main() {
float y = texture(yTexture, vTexCoord).r;
float u = texture(uTexture, vTexCoord).r - 0.5;
float v = texture(vTexture, vTexCoord).r - 0.5;
vec3 rgb;
rgb.r = y + 1.402 * v;
rgb.g = y - 0.344 * u - 0.714 * v;
rgb.b = y + 1.772 * u;
fragColor = vec4(rgb, 1.0);
}
对于 NV12 和 NV21,只需要两个纹理:一个存 Y,一个存 UV 交错数据。着色器里从 UV 纹理中分别取出 U 和 V 即可。
避坑指南:我曾经在纹理上传时,忘了设置纹理的 GL_LUMINANCE 和 GL_ALPHA 格式。结果颜色一直不对。Y 纹理用 GL_LUMINANCE,UV 纹理用 GL_LUMINANCE_ALPHA,这个别搞错了。
知识体系总览
下面这张图,把 YUV 渲染的核心流程串起来了。从数据源到最终显示,每一步都离不开对 YUV 格式的理解。
小结
YUV 渲染这件事,说难不难,说简单也不简单。关键是把格式搞清楚,把纹理上传和着色器写对。我个人建议,新项目直接上 OpenGL ES,别再用 RenderScript 了。毕竟 Google 自己都废弃了,咱也没必要抱着不放。
嗯,这一章的内容就到这里。代码示例都是可以直接用的,但具体参数(比如纹理大小、对齐方式)还得根据你的实际数据来调整。多试几次,你就能找到感觉。