Android OpenGLES3绘图:YUV420与RGB转换
之前写过一篇 Android YUV图像转换算法和检测工具,里面实现了YUV420的四种格式的相互转换,和与RGB之间的转换。因为是直接用CPU计算的,所以对CPU有一定的消耗和占用。这里我们用OpenGL实现GPU转换。
我们用相机作为YUV420图像输入,上一篇 Android OpenGLES3绘图 - 使用CameraX 中实现了用OpenGL的OES纹理直接显示相机的SurfaceTexture。如果要进行格式转换,就不能这样用一个纹理直接显示了,显示YUV420数据的方法是:创建三个纹理,分别接受Y、U、V数据,在片段着色器中取出当前位置的Y、U、V数据,用公式转换成rgb格式显示。
如图,中间是相机预览画面,左下角是相机的YUV420数据通过OpenGL转换成RGB显示到GLSurfaceView上。
1 创建YUVShader
跟绘制普通的纹理类似,创建vao、vbo、ebo,绘制一个矩形。不同的是,需要用for循环创建3个纹理tex0、tex1和tex2,传给着色器。
class YUVShader {
var vertices = floatArrayOf( // ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 -
-1f, -1f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
1f, -1f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-1f, 1f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 左上
1f, 1f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f // 右上
)
val indices = intArrayOf( // 注意索引从0开始!
0, 1, 2, // 第一个三角形
1, 2, 3 // 第二个三角形
)
var program = 0
var vertexBuffer: FloatBuffer? = null
var intBuffer: IntBuffer? = null
var vao: IntArray = IntArray(1)
var tex: IntArray = IntArray(3) // yuv
var transform = FloatArray(16)
fun init() {
program = ShaderUtils.loadProgramYUV()
//分配内存空间,每个浮点型占4字节空间
vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
//传入指定的坐标数据
vertexBuffer!!.put(vertices)
vertexBuffer!!.position(0)
vao = IntArray(1)
glGenVertexArrays(1, vao, 0)
glBindVertexArray(vao[0])
val vbo = IntArray(1)
glGenBuffers(1, vbo, 0)
glBindBuffer(GL_ARRAY_BUFFER, vbo[0])
glBufferData(GL_ARRAY_BUFFER, vertices.size * 4, vertexBuffer, GL_STATIC_DRAW)
intBuffer = IntBuffer.allocate(indices.size * 4)
intBuffer!!.put(indices)
intBuffer!!.position(0)
val ebo = IntArray(1)
glGenBuffers(1, ebo, 0)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo[0])
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size * 4, intBuffer, GL_STATIC_DRAW)
glUseProgram(program)
glGenTextures(3, tex, 0)
for (i in 0..2) {
glActiveTexture(GL_TEXTURE0 + i)
glBindTexture(GL_TEXTURE_2D, tex[i])
// 为当前绑定的纹理对象设置环绕、过滤方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
// val bitmap: Bitmap = ShaderUtils.loadImageAssets("face.png")
// GLUtils.texImage2D(GL_TEXTURE_2D, 0, bitmap, 0)
// glGenerateMipmap(GL_TEXTURE_2D)
val loc0 = glGetUniformLocation(program, "tex$i")
glUniform1i(loc0, i)
}
// Load the vertex data
glVertexAttribPointer(0, 3, GL_FLOAT, false, 8 * 4, 0)
glEnableVertexAttribArray(0)
glVertexAttribPointer(1, 3, GL_FLOAT, false, 8 * 4, 3 * 4)
glEnableVertexAttribArray(1)
glVertexAttribPointer(2, 2, GL_FLOAT, false, 8 * 4, 6 * 4)
glEnableVertexAttribArray(2)
glBindBuffer(GL_ARRAY_BUFFER, 0)
glBindVertexArray(0)
Matrix.setIdentityM(transform, 0)
}
2 数据传给纹理
这是最关键的一步
每一帧画面需要更新YUV数据,如何将数据传给纹理呢?就需要用到glTexImage2D
这个方法,方法有很多参数,主要是设置纹理格式、宽高等。方法的第3个和第8个参数是相同的,Y平面这个参数传GL_LUMINANCE,至于U和V平面:I420和YV12格式下,传GL_LUMINANCE参数;NV12和NV21格式下,传GL_LUMINANCE_ALPHA。这里如果弄错了,画面必错乱,我也是调了很久,搜索了不少地方才总结下来的。
至于宽高参数,Y平面就是图像宽高,UV平面的宽高都是一半。最后一个参数是真正的数据,传入ByteBuffer格式的。
其实NV12和NV21格式,UV平面的数据放在一起了,因此使用前两个纹理就够了。后面在着色器里使用时可以看出来。
fun draw(ib: ImageBytes) {
val w0 = ib.width
val h0 = ib.height
val w1 = w0 / 2
val h1 = h0 / 2
glUseProgram(program)
glActiveTexture(tex[0])
glBindTexture(GL_TEXTURE_2D, tex[0])
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, w0, h0, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, ib.bufY)
glActiveTexture(tex[1])
glBindTexture(GL_TEXTURE_2D, tex[1])
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, w1, h1, 0, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, ib.bufU)
// glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, w1, h1, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, ib.bufU)
glActiveTexture(tex[2])
glBindTexture(GL_TEXTURE_2D, tex[2])
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, w1, h1, 0, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, ib.bufV)
// glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, w1, h1, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, ib.bufV)
val loc = glGetUniformLocation(program, "transform")
glUniformMatrix4fv(loc, 1, false, transform, 0)
glBindVertexArray(vao[0])
glDrawElements(GL_TRIANGLES, vertices.size, GL_UNSIGNED_INT, 0)
}
3 数据获取和封装
Camra1与Camera2/X获取数据的方法不同,格式也不一样。
Camera1是在onPreviewFrame回调方法中获取byte[]格式的数据
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
if(camera == null) return;
Camera.Size size = camera.getParameters().getPreviewSize(); //获取预览大小
final int w = size.width;
final int h = size.height;
ydv.inputAsync(data, w, h);
}
Camera2/X是在ImageAnalysis.Analyzer的analyze回调方法中获取Image格式的数据
private inner class LuminosityAnalyzer(private val listener : LumaListener) : ImageAnalysis.Analyzer {
private fun ByteBuffer.toByteArray() : ByteArray {
rewind()
val data = ByteArray(remaining())
get(data)
return data
}
@SuppressLint("UnsafeExperimentalUsageError")
override fun analyze(image: ImageProxy) {
// 暂时用SurfaceTexture中获取的变换矩阵
this@MainActivity.yuvDetectView.yuvRender.yuvShader.transform = this@MainActivity.cameraRender.transform
this@MainActivity.yuvDetectView.input(image.image!!)
image.close()
}
}
为了方便处理,自己封装一个ImageBytes类,将byte[]或Image格式的数据转换成对应YUV的三个ByteBuffer。
Image格式的直接取三个Plane包装成ByteBuffer就行;byte[]格式的需要根据宽高确定YUV的长度,将对应长度的子数组包装成ByteBuffer。(注意:Camera1的NV21格式,需要所有的UV数据都放到第二个ByteBuffer里面,否则画面只有一半显示正常,这应该是相机扫描方向跟数据读取方向不同导致的)。
public class ImageBytes {
public byte[] bytes;
public int width;
public int height;
public ByteBuffer bufY;
public ByteBuffer bufU;
public ByteBuffer bufV;
public ImageBytes(byte[] bytes, int width, int height) {
this.bytes = bytes;
this.width = width;
this.height = height;
int r0 = width * height;
int u0 = r0 / 4;
int v0 = u0;
bufY = ByteBuffer.allocate(r0).put(bytes, 0, r0);
// bufU = ByteBuffer.allocate(u0).put(bytes, r0, u0);
// camera1的nv21格式,需要把uv全部放到bufU中,否则画面只有一半正常
// 实际上bufU只使用了一半,这跟相机画面扫描的方向有关
bufU = ByteBuffer.allocate(u0 + v0).put(bytes, r0, u0 + v0);
bufV = ByteBuffer.allocate(v0).put(bytes, r0 + u0, v0);
bufY.position(0);
bufU.position(0);
bufV.position(0);
}
public ImageBytes(Image image) {
final Image.Plane[] planes = image.getPlanes();
Image.Plane p0 = planes[0];
Image.Plane p1 = planes[1];
Image.Plane p2 = planes[2];
ByteBuffer b0 = p0.getBuffer();
ByteBuffer b1 = p1.getBuffer();
ByteBuffer b2 = p2.getBuffer();
int r0 = b0.remaining();
int w0 = p0.getRowStride();
int h0 = r0 / w0;
if(r0 % w0 != 0) h0++;
this.width = w0;
this.height = h0;
this.bufY = b0;
this.bufU = b1;
this.bufV = b2;
}
}
4 格式转换
在片段着色器中进行转换,这一步也很重要。
首先将yuv分量从三个tex纹理中取出来,y取tex0里面的r分量,u取tex1里面的r分量减去0.5,而v取tex1里面的a分量减去0.5;再根据固定的公式将yuv转换成rgb,用一个矩阵计算表示,这个公式我在网上发现了各种各样的数值,找到两个应该是比较标准的,它们差别不大,至少我看不出来。
注意这是NV12格式的取法,如果是NV21格式,将u和v交换就行了。
#version 300 es
precision mediump float;
in vec3 aColor;
in vec2 aTexCoord;
out vec4 fragColor;
uniform sampler2D tex0;
uniform sampler2D tex1;
uniform sampler2D tex2;
void main() {
float y = texture(tex0, aTexCoord).r;
float u = texture(tex1, aTexCoord).r - 0.5;
float v = texture(tex1, aTexCoord).a - 0.5;
vec3 yuv = vec3(y, u, v);
// 下面两种视觉上差异不大
// // BT.601, which is the standard for SDTV is provided as a reference
// vec3 rgb = mat3( 1, 1, 1,
// 0, -.39465, 2.03211,
// 1.13983, -.58060, 0) * yuv;
// Using BT.709 which is the standard for HDTV
vec3 rgb = mat3( 1, 1, 1,
0, -.21482, 2.12798,
1.28033, -.38059, 0) * vec3(y,u,v);
fragColor = vec4(rgb, 1.0);
}
下面是YV12格式的取法,y取tex0里面的r分量,u取tex2里面的r分量减去0.5,而v取tex1里面的r分量减去0.5;如果是I420格式,将u和v交换就行了。
float y = texture(tex0, aTexCoord).r;
float u = texture(tex2, aTexCoord).r - 0.5;
float v = texture(tex1, aTexCoord).r - 0.5;
5 小结
结合从tex中取yuv的规律,我们再来总结一下前面glTexImage2D方法里的GL_LUMINANCE和GL_LUMINANCE_ALPHA参数。
GL_LUMINANCE是将单个u或v打包到一个纹理的vec4里面,放到第一位:vec4(u,0,0,0)
GL_LUMINANCE_ALPHA是将相邻的两个uv打包到一个纹理的vec4里面,放到第一和最后一位:vec4(u,0,0,v)
有这两种参数就能直接创建不同YUV格式的纹理,避免了需要循环从数组里面取数据,比CPU运算方便多了,而着色器大规模并行转换数据也比CPU快多了。
当然这里的数据是片段着色器转换完直接显示的,不能输出到其他地方如编码器等,如果需要输出,可以用计算着色器或者更高级的GPU缓存机制。