跳转至

17 如何用软件编码器来编码 H264 ?

你好,我是展晓凯。今天我们来一起学习使用软件编码器来编码H264的方法。

上一节课我们学习了视频编码的基础知识,重点讲解了H264编码中的基本概念。在了解了视频编码和H264编码的基础知识之后,这节课我们就可以基于FFmpeg来书写一个编码H264的工具类,把摄像头采集下来的YUV数据编码成可播放的H264码流。

在移动平台上只要兼容性没有问题、清晰度相差不大,性能肯定是第一个要考虑的因素。iOS平台,因为硬件编码器的兼容性比较好,基本上用不到libx264这些软件编码器,所以这节课的实例只需要运行在Android平台上,结构图也会以Android平台为基础进行绘制。不过编码部分的代码都是使用C++来书写的,是跨平台的。如果你在iOS平台有需要的话,可以直接使用这个工具类。这个工具类的输入是一张纹理,输出是H264的裸流,接下来我们开始学习吧。

编码适配器

编码模块的输入我们上一节课讲过,就是摄像头预览控制器中渲染到屏幕上的纹理ID。因为下节课我们还会学习各平台硬件编码器的使用,所以这节课我们会把重点放到软件编码器上。首先抽象出一个接口,基于这个接口,我们会有一个软件编码器的实现和一个硬件编码器的实现。在摄像头预览的控制器里是怎么和编码器模块交互的?我们一起来看下。

初始化与创建编码器接口

先看这个编码器模块的初始化接口。

void init(const char* h264Path, int width, int height, int videoBitRate, float
        frameRate)

传入的参数分别是编码之后H264文件的存储路径、编码视频的宽和高、编码H264的比特率、编码的帧率等。这个接口的职责是把这些视频编码的MetaData信息存储到全局变量里,并且打开一个可写入二进制流的文件。接下来看一下创建编码器的接口。

virtual int createEncoder(EGLCore* eglCore, int inputTexId) = 0;

可以看到这个方法是一个纯虚的方法,代表由具体的子类来完成实际的编码器实例化操作。再来看一下它具体的参数,第一个参数是EGLCore,代表了OpenGL ES的上下文。第二个参数是预览控制器里渲染到屏幕上的纹理ID。把纹理ID传递进来不难理解,但是为什么需要把EGLCore传递进来呢?

因为编码的过程是一个较耗时的操作,它不应该阻塞预览线程,否则一旦出现抖动(线程CPU轮转不及时)就会造成预览线程的卡顿,影响用户预览。因此我们会把编码放到一个独立的编码线程里。不同的编码器需要的视频帧格式有一些不一样,所以我们这个类也需要负责把这个纹理对象转换成编码器需要的数据格式,那就会涉及OpenGL ES的操作了,把EGLCore传递进来,方便创建编码线程的OpenGL ES上下文。

这也就引出了编码器模块入口的接口名字——VideoEncoderAdapter。类的名字其实就是根据这个类的职责而确定的,这个类实际上就是把输入的纹理ID做一个转换,让转换之后的数据可以作为具体编码器的输入,所以这也是这个接口的名字所代表的意义。我们这节课要实现的就是这个软件编码器适配器,即SoftEncoderAdapter,下节课我们会完成硬件编码器适配器的实现,即HWEncoderAdapter。

图里的这个Init函数就需要构建出OpenGL ES环境,并且创建编码器,如果成功返回0,否则返回负数。

图片

编码接口

下面我们来看这个类的编码接口。

virtual void encode() = 0;

同样,这也是一个纯虚的方法,由子类自己来完成编码操作,这个接口的职责是利用自己构造的OpenGL ES渲染线程,来实现纹理转换适配编码器和实际编码的过程。

销毁接口

最后是销毁编码器接口。

virtual void destroyEncoder() = 0;

这也是一个纯虚的方法,由子类来完成具体实现,这个接口的职责是销毁编码器、销毁OpenGL ES渲染线程及资源。

上面的这几个方法就是我们编码适配器抽象出来的核心接口,而这节课的目标是完成软件编码适配器的实现部分,即SoftEncoderAdapter部分。

软件编码适配器

我们先来看一下软件编码器的整体结构。

图片

从图里可以看到,整个软件编码器模块的实现中包含两个线程和一个队列,其中站在这个队列的角度来看,纹理拷贝线程是生产者,它生产的视频帧会放到VideoFrameQueue里。而编码线程是一个消费者,它从VideoFrameQueue里取出视频帧,调用VideoX264Encoder进行编码,编码好的H264数据输出到目标文件中。

了解清楚了软件编码适配器的工作流程之后,接下来我们就来详细看一下内部的具体实现吧。

图里左边的第1点到第3点代表了createEncoder方法的内部实现,分别创建出了存储视频帧的队列VideoFrameQueue、编码线程和纹理拷贝线程。这三个对象的职责一目了然,接下来我们主要来看一下这三个对象内部是怎么实现的。

视频队列

先来看VideoFrameQueue,这是一个线程安全的队列,实际上就是一个线程安全的链表,链表里的每个Node节点的内部元素都是一个VideoFrame的结构体,结构体定义如下:

typedef struct VideoFrame_t {
      unsigned char * buffer; //YUV420P的图像数据
      int size;//图像数据的大小
      int timeMills;//所代表的时间戳
      int duration; //这一帧图像所代表的时间长度
} VideoFrame;

这个VideoFrameQueue提供了四个接口。

  • init方法,这个方法会初始化一个锁来保证线程安全,同时也会初始化头指针和尾部指针为空。此外,还会初始化一个代表是否要丢弃所有帧的布尔变量mAbortRequest。
  • put方法,在保证线程安全(即在操作指针前后上锁和解锁)的前提下,把新的元素放到这个链表的最后,并发出一个signal指令。
  • get方法,在保证线程安全性的前提下,取出头部指针指向的元素并返回,然后把头部指针指向下一个元素。如果没有元素可以取出的话,就wait,等到signal指令来了之后,再取出元素,signal指令有可能是在put函数里调用,也有可能是在abort函数里调用。
  • abort方法,当要放弃队列里所有元素的时候被调用,实现就是在保证线程安全的前提下,把队列里所有的元素销毁掉,之后put方法和get方法再次被调用时就会被忽略。

在这个类的析构函数里,也把队列里剩余的所有元素都逐一取出,并且释放掉,防止内存泄露。接下来我们一起看一下这个队列的消费者——编码线程的实现。

编码线程

编码线程的职责就是取出队列里的视频帧,然后调用实际的编码器来编码,核心代码如下:

encoder = new VideoX264Encoder();
encoder->init(videoWidth, videoHeight, videoBitRate, frameRate, h264File);
LiveVideoFrame *videoFrame = NULL;
while(true){
    if (videoFramePool->getYUY2Packet(&videoFrame, true) < 0) {
        break;
    }
    if(videoFrame){
        //调用编码器编码这一帧数据
        encoder->encode(videoFrame);
        delete videoFrame;
        videoFrame = NULL;
    }
}
if(NULL != encoder){
    encoder->destroy();
    delete encoder;
    encoder = NULL;
}

我们从代码中可以看到,在编码线程里首先实例化编码器,然后进入一个while循环,不断从VideoFrameQueue里取出视频帧元素,调用编码器去编码,如果从VideoFrameQueue里面获取元素返回值小于0的话,就跳出循环,销毁编码器。

纹理拷贝线程

因为软件编码器需要的是YUV的数据,所以我们要把显存可以控制的纹理ID转换成内存中的YUV数据,就需要调用glReadPixels来执行拷贝操作。因为纹理从显存拷贝到内存中需要耗费的时间比较长,为了尽量不阻塞预览界面的渲染线程,就建立了这个纹理拷贝线程。

这个线程首先要初始化OpenGL ES的上下文环境,然后把上下文绑定到这个纹理拷贝线程上。这个纹理拷贝线程要拷贝的纹理ID,就是createEncoder这个方法传递进来的第二个参数的纹理ID,而这个纹理是在摄像头预览线程的上下文里创建的,那怎么操作才能让我们的纹理拷贝线程也可以访问到它呢?

这也是我们要把EGLCore从预览控制类里面传递过来的原因,这里要使用OpenGL里共享上下文的概念。在创建OpenGL ES上下文的时候,使用已经存在的EGLContext而不是EGL_NO_CONTEXT,这样新创建的这个上下文就可以和已经存在的EGLContext共享所有的OpenGL ES对象了,包括纹理对象、帧缓存对象等。

createEncoder方法已经把渲染线程里封装的EGLCore对象传递过来了,也就可以获得摄像头预览线程的OpenGL ES的上下文了。创建OpenGL ES上下文的时候,把它当做第三个参数传递进去,这样在当前纹理拷贝线程中,就可以访问摄像头预览线程里创建的纹理对象了。

然后创建一个离屏渲染的Surface,代码如下:

eglCore = new EGLCore();
eglCore->init(previewGLContext);
copyTexSurface = eglCore->createOffscreenSurface(videoWidth, videoHeight);
eglCore->makeCurrent(copyTexSurface);

既然是纹理拷贝,就需要创建一个拷贝的目标,也就是一个输出纹理对象。此外,还需要创建一个帧缓存对象,帧缓存对象是任何一个OpenGL Program渲染的目标。当然像之前直接渲染到屏幕上的都会有一个默认的帧缓存对象,但目前的场景并不是向屏幕上绘制,而是进行纹理拷贝,所以需要我们自己创建一个帧缓存对象。创建纹理ID和帧缓存对象的代码如下:

glGenFramebuffers(1, &mFBO);
glGenTextures(1, &outputTexId);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, outputTexId, 0);

在真正进行拷贝之前,需要显式地绑定一下这个帧缓存对象,然后使用一个通用的renderer来执行渲染操作,renderer的渲染目标就是绑定的这个帧缓存对象,又因为我们把输出纹理ID绑定到了这个帧缓存对象上,所以就相当于把输入纹理的内容绘制到输出纹理上去了。完成拷贝之后,需要再跟这个帧缓存对象解绑,代码如下:

glViewport(0, 0, videoWidth, videoHeight);
glBindFramebuffer(GL_FRAMEBUFFER, mFBO);
checkGlError("glBindFramebuffer FBO");
renderer->renderToTexture(texId, outputTexId);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

在拷贝到目标纹理之后,就可以让预览线程继续去做自己的工作了,但是在拷贝成功之前,需要阻塞预览线程,具体实现就是调用encode方法的时候使用条件锁wait,等纹理拷贝线程拷贝成功了之后,发一个signal指令过来,encode方法接收到signal指令就可以让预览线程继续运行。这样就可以完成最短时间的阻塞预览线程,防止预览界面的fps降低。

接下来要做的就是,把这个输出纹理ID的内容拷贝到内存里,这就涉及显存和内存的数据交换问题了。前面我们讲过在做视频处理和编解码的时候,要遵循一个原则:尽量少做显存和内存的交换。由于使用X264进行编码,而X264的输入必须是内存中的数据,在这种不得已的情况下,必须要把显存里的数据拷贝到内存中。OpenGL ES中提供了显存到内存的数据转换API——glReadPixels。

void glReadPixels (GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void *pixels);

它一共有七个参数,第一个参数和第二个参数表示读取纹理矩形左下角的横、纵坐标,坐标系就是OpenGL的纹理坐标系;第三个参数和第四个参数表示矩形的宽度和高度;第五个参数是读取的内容格式,一般都读取RGBA格式的数据;第六个参数是读取的内容在内存里的表示格式。

第七个参数就是我们要存储到的内存区域的指针。这个函数默认会读取出RGBA格式的数据,并且会非常耗时,读取的内容区域越大,所消耗的时间也会越多。对于分辨率较大的纹理ID,读取一帧数据所耗费的时间就比较多了。所以这里我们把显存到内存中的数据交换(耗时、性能低)和拷贝纹理(速度快)分成两个阶段,来达到不阻塞摄像头预览线程的目的。

优化显存到内存的数据转换,主要思路就是减少数据量的读取,那怎么才能减少数据量的读取呢?

常用的做法就是把一张RGBA格式的纹理ID先转换成YUY2格式,YUY2格式是为每个像素保留Y分量,而UV分量在水平方向上每两个像素采样一次,也就是一个像素RGBA格式使用4个字节来表示,而YUY2格式使用2个字节来表示,这样读取的数据量就少了一半,所耗费的时间也几乎减少到了原始时间的一半。

但是需要我们做的额外工作就是,在显存中通过一个OpenGL Program把RGBA格式转换成YUY2格式,然后再进行读取,最后把YUY2格式的数据交给编码器进行编码,优化读取时间的代码我会在录制器项目里会给你一个host_gpu_copier.cpp的实现文件。

接下来我们介绍VideoX264Encoder这个类的实现,这个类的职责就是把YUY2的原始图像数据编码成H264的压缩数据,然后写到H264的文件里。

软件编码器实现

使用libx264这个库来编码H264的时候,并不是直接使用它的API,而是基于FFmpeg的API来开发。当然,在这之前要把libx264交叉编译到FFmpeg中去,现在把FFmpeg的头文件和静态库文件配置到工程里,然后建立一个video_x264_encoder的C++类文件,下面我们看一下这个类向外暴露的接口定义、职责和实现。

初始化接口

先看初始化接口的定义。

int init(int width, int height, int videoBitRate, float  frameRate, FILE* h264File)

这个接口需要传入编码视频的宽、高、码率和帧率,最后一个参数是编码之后要写入的H264文件。这个接口的职责是初始化编码器上下文以及编码之前的AVFrame结构体,如果成功返回0,否则返回负数。核心实现如下:

  • 使用avcodec_register_all方法,注册所有格式和编解码器。
  • 创建并配置编码器上下文AVCodecContext

  • 找出H264的encoder

  • 创建AVCodecContext
  • 为AVCodecContext配置对应的参数
  • 打开编码器
  • 分配AVFrame,存储编码之前YUV420P的原始数据。

编码接口

接下来我们看实际的编码接口。

int encode(VideoFrame *videoFrame);

这个接口需要传入的参数是一个VideoFrame的结构体,这个结构体里实际包含了这一帧图片的YUY2数据和时间信息。这个方法的职责就是把这一帧图像编码成H264的数据,并写入文件。如果编码失败返回负数,正确编码这一帧返回0。内部实现比较简单,就是把YUV数据封装成AVFrame的结构体,然后调用FFmpeg方法avcodec_encode_video2来执行编码,得到的是一个Annexb格式的H264的数据,然后可以直接写入文件。

考虑到性能,在显卡里读出来的是YUY2格式,所以这里的输入格式是YUY2的视频帧表示格式,而libx264输入一般都是YUV420P格式,所以要在送给libx264之前,把它转换成YUV420P格式的数据。从YUY2到YUV420P格式的具体转换过程已做优化,在armv7平台上利用Neon指令集来做加速,在x86平台使用SSE指令集来做加速,这些加速操作其实都是SIMD指令集的应用,这里我们就不展开说了,后面的视频录制器项目会给出代码实例。

销毁接口

最后一个是销毁接口,这个接口负责销毁编码器的上下文,以及销毁分配的AVFrame等资源。

void destroy();

小结

图片

最后,我们可以一起来回顾一下这节课的重点内容。

  • 我们通过分析预览和编码场景引出了编码适配器的架构,主要实现了3个方法:初始化和创建编码器的接口、编码接口和销毁接口。
  • 软件编码适配器的内部工作流程以及核心实现,其核心实现主要包括视频队列、编码线程和纹理拷贝线程,这其中重点是共享上下文与加快显存到内存的拷贝速度。
  • 最后我们基于FFmpeg书写了一个编码H264码流的工具类。

希望通过这节课你可以明白,这套架构为什么这样设计,其中的性能优化点是怎么考虑的。而其中涉及的具体API接口的调用很简单,在后面视频录制器项目中我会给你这些API的调用方法,拿到之后你可以自己练习练习。

思考题

这节课的软件编码器编码出的是Annexb格式的H264码流,码率是在初始化的过程中设置进去的,而如果在直播场景中,因网络抖动问题动态更改码率是一个基础的操作,那在软件编码中应该怎么实现呢?欢迎把你的答案留在评论区,也欢迎你把这节课分享给更多对音视频感兴趣的朋友,我们一起交流、共同进步。下节课再见!

精选留言(2)
  • peter 👍(0) 💬(1)

    请教老师几个问题: Q1:实现硬件编码器,是模拟硬件编码吗? 文中提到“基于这个接口,我们会有一个软件编码器的实现和一个硬件编码器的实现”, 硬件编码是由硬件实现,不需要实现啊。这里是指模拟硬件吗? Q2:编码线程和纹理拷贝线程有同步、通信关系吗? Q3:“纹理拷贝线程”的一个功能是“建立OpenGL线程”,是两个线程吗? 理解1:一个线程,“建立OpenGL线程”就是建立“纹理拷贝线程”。 理解2:两个线程,“纹理拷贝线程”是一个线程,该线程会创建另外一个“OpenGL线程”。 Q4:“OpenGL线程”是特殊的与OpenGL相关的线程吗? 理解1:就是普通线程,其作用是执行OpenGL操作。 理解2:与OpenGL库有关,需要调用OpenGL库来创建该线程。 Q4:视频队列是用Android SDK的队列吗?还是自己写的队列? Q5:离屏渲染的Surface是Java层的还是Native层的? Q6:“YUY2”是笔误吗?应该是“YUV2”吧。 Q7:最后写入的h264文件,是二进制文件吗?

    2022-08-31

  • xueerfei007 👍(0) 💬(0)

    老师您好,我最近在使用ffmpeg编码工业相机sdk提供的raw帧数据。目前编码后,视频的时间与原始视频流对不上,编码长度比录制时长多了一倍。猜测可能与pts/dts的设置有关。这个需要如何设置,或者有什么方法定位到问题的具体原因?

    2023-08-05