OpenH264编译与使用总结

1. 缘起

        公司项目原先使用的是x264编码,在竖屏编码时存在效率低问题。听说开源项目openH264编码效率挺高,因此需要编译后测试下编码效率是否符合要求。

openh264 githu地址:GitHub - cisco/openh264: Open Source H.264 Codec

项目介绍:

        OpenH264 是一个支持 H.264 编码和解码的编解码库。 适用于WebRTC等实时应用。 更多信息可以访问:http://www.openh264.org/ 

2. 在ubuntu上编译

        这块可以直接参考github上的介绍,但是里面也有不少的坑

为android平台构建:

你需要安装android sdk和ndk。您还需要将 **ANDROID_SDK**/tools 导出到 PATH。在 Linux 上,这可以通过

export PATH=**ANDROID_SDK**/tools:$PATH

编解码器动态库和demo可以通过

make OS=android NDKROOT=**ANDROID_NDK** TARGET=**ANDROID_TARGET**

有效的 **ANDROID_TARGET** 可以在 **ANDROID_SDK**/platforms 中找到,例如 android-12。您还可以根据您的设备和 NDK 版本设置 ARCH、NDKLEVEL。 ARCH 指定了 android 设备的架构。目前支持 arm、arm64、x86 和 x86_64,默认为 arm。 (也可以使用 mips 和 mips64,但没有针对这些架构进行特定优化。)NDKLEVEL 指定 android api 级别,默认为 12。可用的可能性可以在 **ANDROID_NDK**/platforms 中找到,例如 android-21(去掉 android- 前缀)。

默认情况下,这些命令是为 armeabi-v7a ABI 构建的。要为其他 android ABI 构建,请添加 ARCH=arm64、ARCH=x86、ARCH=x86_64、ARCH=mips 或 ARCH=mips64。要为旧的 armeabi ABI(以 armv5te 作为基线)构建,添加 APP_ABI=armeabi(ARCH=arm 是隐式的)。要为 64 位 ABI(例如 arm64)构建,请将 NDKLEVEL 显式设置为 21 或更高。

以下是我总结的编译步骤,环境是window10,使用的是Microsoft store上的ubuntu。也可以通过命令下载: 安装 WSL | Microsoft Docs

1.1 下载必要的文件

        openH264源文件:

git clone https://github.com/cisco/openh264

        android sdk:http://dl.google.com/android/android-sdk_r24.4.1-linux.tgz

        android ndk:https://developer.android.google.cn/ndk/downloads/ 

        注: 尝试了多个ndk版本,最后发现ndk12可以成功编译。下载后解压。

1.2 配置环境变量

        vi ~/. bashrc (如果安装了zsh,则是vi ~/.zshrc),直接编辑环境变量文件

export NDK_HOME=/home/rainbow/tools/android-ndk-r12b
export PATH=$PATH:$NDK_HOME
export JAVA_HOME=/home/rainbow/tools/jdk1.8.0_241
export PATH=$PATH:${JAVA_HOME}/bin
export ANDROID_HOME=/home/rainbow/tools/android-sdk-linux
export PATH=$PATH:${ANDROID_HOME}/tools
export PATH=$PATH:${ANDROID_HOME}/platform-tools

        完成后source一把

source ~/.bashrc

1.3 sdk更新

        显示所有SDK版本

android list sdk --all

        选择需要更新的版本

android update sdk -u -a -t 8,12

1.4 编译openh264

        切换到openh264目录

make -B OS=android NDKROOT=$NDK_HOME TARGET=android-27 NDKLEVEL=21

        默认编译的是armeabi-v7a版本, arm64-v8a版本如下(其他架构的也类似)

make -B OS=android NDKROOT=$NDK_HOME TARGET=android-27 ARCH=arm64 NDKLEVEL=21

        提示 BUILD SUCCESSFUL

        即可在根目录下可以找到libopenh264.so文件, 头文件在/openh264/codec/api/svc目录下

        至此编译工作结束, 可以开始撸代码了

OpenH264编译与使用总结

        

3. 摄像头数据编码后保存为.h264文件

1.1 使用cameraX获取摄像头yuv数据

         这里可以参考官网的例子。

https://developers.google.com/codelabs/camerax-getting-started

private fun setUpCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
        cameraProviderFuture.addListener({
            cameraProvider = cameraProviderFuture.get()
            lensFacing = when {
                hasFrontCamera() -> CameraSelector.LENS_FACING_FRONT
                hasBackCamera() -> CameraSelector.LENS_FACING_BACK
                else -> throw IllegalStateException("Back and front camera are unavailable")
            }
            updateCameraSwitchButton()

            bindCameraUseCases() //绑定useCase
        }, ContextCompat.getMainExecutor(requireContext()))

    }

         创建 ProcessCameraProvider 的实例。此实例用于将相机的生命周期绑定到生命周期所有者。由于 CameraX 具有生命周期感知能力,所以这样可以省去打开和关闭相机的任务。

private fun bindCameraUseCases() {
        val rotation = binding.viewFinder.display.rotation
        val cameraProvider =
            cameraProvider ?: throw IllegalStateException("Camera initialization failed.")
        val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build()
        preview = Preview.Builder()
            .setTargetResolution(Size(TARGET_W, TARGET_H))//设置预览宽高
            .setTargetRotation(rotation)
            .build()

        imageAnalysis = ImageAnalysis.Builder()
            .setTargetResolution(Size(TARGET_W, TARGET_H))
            .setTargetRotation(rotation)
            .build()
            .also {
                it.setAnalyzer(cameraExecutor, { imageProxy ->
                    if (pEncoder == 0L) {
                        pEncoder = h264Encoder.createEncoder(imageProxy.width,
                            imageProxy.height,
                            outputFile.absolutePath)
                    }
                    h264Encoder.encode(pEncoder,
                        getDataFromImage(imageProxy.image!!),
                        imageProxy.width,
                        imageProxy.height)
                    imageProxy.close()//调用后才会回调下一帧
                })
            }
        try {
            cameraProvider.unbindAll()
            camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis)
            preview?.setSurfaceProvider(binding.viewFinder.surfaceProvider)
        } catch (e: Exception) {
            Log.e(TAG, "Use case binding failed", e)
        }
    }

        Preview ImageAnalysis 一样继承至 UseCase ,作为参数传入cameraProvider.bindToLifecycle()之后, 会回调相机帧数据。类似于Camera时代onPreviewFrame或者Camera2时代onImageAvailable回调的数据。

        至此,在ImageProxy.image中就能拿到摄像头回调的YUV帧数据了


        回顾下安卓视频编码的流程:

OpenH264编译与使用总结

        现在已经有YUV帧数据了,为了提高性能,一般在JNI层中做YUV帧的预处理(镜像,缩放,旋转)。

        继续编写一些JNI代码,需要创建编码器及编码帧数据的native函数

    external fun createEncoder(outputPath: String): Long
    external fun encode(pEncoder: Long, data: ByteArray?, width: Int, height: Int): Long

        在native-lib.cpp中创建编码器实例

extern "C"
JNIEXPORT jlong JNICALL
Java_com_stapler_openh264demo_H264Encoder_createEncoder(JNIEnv *env, jobject thiz,jstring output_path) {
    VideoEncoder *decoder = new VideoEncoder(env, output_path);
    return (jlong)decoder;
}

1.2 初始化编码器配置

        在VideoEncoder.h定义

class VideoEncoder {
private:
    const char *TAG = "VideoDecoder";
    const char *m_path = NULL;
    JavaVM *m_jvm_for_thread = NULL;
    ISVCEncoder *ppEncoder = NULL;
    SEncParamExt paramExt;
    SSourcePicture sPicture = {0} ;
    SFrameBSInfo encoded_frame_info = {0};
    uint64_t timestamp_;
    std::ofstream out264;

public:
    VideoEncoder(JNIEnv *jniEnv, jstring path);
    virtual ~VideoEncoder();
    void Init(JNIEnv *env, jstring path);
    void Encode(JNIEnv *env, jobject thiz,uint8_t* yuv_data, int width, int height);
};

        具体实现在VideoEncoder.cpp中

        初始化编码器:

void VideoEncoder::Init(JNIEnv *jniEnv, jstring path) {
    LOGI(TAG, "VideoDecoder Init start")
    m_path = jniEnv->GetStringUTFChars(path, NULL);
    jniEnv->GetJavaVM(&m_jvm_for_thread);
    int result = WelsCreateSVCEncoder(&ppEncoder);
    if (result != cmResultSuccess) {
        LOGE(TAG, "WelsCreateSVCEncoder fail ! result=%d", result)
        return;
    }
    //获取默认参数
    ppEncoder->GetDefaultParams(&paramExt);
    ECOMPLEXITY_MODE complexityMode = HIGH_COMPLEXITY;
    RC_MODES rc_mode = RC_BITRATE_MODE;
    bool bEnableAdaptiveQuant = false;
    paramExt.iUsageType = CAMERA_VIDEO_REAL_TIME;
    //需要外面对应预览分辨率也设置成一样
    paramExt.iPicWidth = 960;
    paramExt.iPicHeight = 1280;
    paramExt.iTargetBitrate = 150000;
    paramExt.iMaxBitrate = 250000;
    paramExt.iRCMode = rc_mode;
    paramExt.fMaxFrameRate = 25;
    paramExt.iTemporalLayerNum = 1;
    paramExt.iSpatialLayerNum = 1;//iSpatialLayerNum,指定要输出几路码流,通常设为1。
    paramExt.bEnableDenoise = false;//降噪
    paramExt.bEnableBackgroundDetection = true;
    paramExt.bEnableAdaptiveQuant = false;
    paramExt.bEnableFrameSkip = false;
    paramExt.bEnableLongTermReference = false;
    paramExt.bEnableAdaptiveQuant = bEnableAdaptiveQuant;
    paramExt.bEnableSSEI = true;
    paramExt.bEnableSceneChangeDetect = true;//当检测到场景发生变换时,会插入I帧
    paramExt.uiIntraPeriod = 15u;
    paramExt.eSpsPpsIdStrategy = CONSTANT_ID;
    paramExt.bPrefixNalAddingCtrl = false;
    paramExt.iComplexityMode = complexityMode;
    paramExt.bEnableFrameSkip = false;
    paramExt.sSpatialLayers[0].fFrameRate = 64;
    paramExt.sSpatialLayers[0].iSpatialBitrate = 150000;
    paramExt.sSpatialLayers[0].iMaxSpatialBitrate = paramExt.iMaxBitrate;
    paramExt.iLoopFilterDisableIdc = 0;//去块滤波参数, 0表示开启去块滤波功能,主要滤除方块效应。
    //该参数主要影响码率控制时调控的QP范围,可支持的范围是[0, 51],但是编码QP值太小或者太大,都会对图像质量和码率带来很大影响,
    // 为了防止极端图像质量情况的出现,一般设置为范围为[16,40],因此可以将参数修改设置为:
    paramExt.iMaxQp = 40;
    paramExt.iMinQp = 16;
    int videoFormat = videoFormatI420;
    ppEncoder->SetOption(ENCODER_OPTION_DATAFORMAT, &videoFormat);
    ppEncoder->InitializeExt(&paramExt);
    out264.open(m_path, std::ios::ate | std::ios::binary);

    jniEnv->ReleaseStringUTFChars(path, m_path);
    LOGI(TAG, "VideoDecoder Init end")
}

        首先调用对象的GetDefaultParams方法获取到默认的参数结构,然后根据自己的需要配置参数,这里的宽高即为编码后输出的宽高。

        SetOption设置传入图像的格式,目前只支持I420

        最后调用InitializeExt完成初始化,部分参数含义已经添加了注释

1.3 开始编码

        摄像头数据传到Native层后,就可以开始编码了

void VideoEncoder::Encode(JNIEnv *env, jobject thiz, uint8_t *yuv_data, int width, int height) {
    if (yuv_data == NULL || *yuv_data == 0 || width == 0 || height == 0) {
        LOGE(TAG, "data err!")
        return;
    }
    //视频数据预处理,根据前后置摄像头及横竖屏情况,来旋转视频数据
    RotateImage(width, height, yuv_data, 0, true,height,width,false);
    
    //配置要编码的数据源参数 SSourcePicture
    sPicture.iColorFormat = videoFormatI420;
    sPicture.iPicWidth = height;
    sPicture.iPicHeight = width;
    sPicture.iStride[0] = sPicture.iPicWidth;
    sPicture.iStride[1] = sPicture.iStride[2] = sPicture.iPicWidth / 2;
    sPicture.uiTimeStamp = timestamp_++;
    sPicture.pData[0] = yuv_data;
    sPicture.pData[1] = yuv_data + width * height;
    sPicture.pData[2] = yuv_data + width * height * 5 / 4;

    //真正开始编码, encoded_frame_info类型为SFrameBSInfo, 用来获取编码后的NAL单元数据
    int err = ppEncoder->EncodeFrame(&sPicture, &encoded_frame_info);
    free(yuv_data);
    if (err) {
        LOGE(TAG, "Encode err err=%d", err);
        return;
    }
    //取数据方法,IDR帧会有2层,第一层为avc头,第二层为I帧数据
    for (int i = 0; i < encoded_frame_info.iLayerNum; ++i) {
        SLayerBSInfo *pLayerBsInfo = &encoded_frame_info.sLayerInfo[i];
        int frameType = pLayerBsInfo->eFrameType;
        if (pLayerBsInfo != NULL) {
            int iLayerSize = 0;
            //IDR帧,NAL数据也会有2个
            int iNalIdx = pLayerBsInfo->iNalCount - 1;
            do {
                iLayerSize += pLayerBsInfo->pNalLengthInByte[iNalIdx];
                --iNalIdx;
            } while (iNalIdx >= 0);
            if (out264.is_open()) {
                out264.write((char *) ((*pLayerBsInfo).pBsBuf), iLayerSize);
            }
        }
    }

}

        由于摄像头使用的是前置摄像头,在编码前第一步就是先旋转数据 RotateImage(竖屏,前置90,后置180)

        接下来就是真正的编码了, 先看下真正编码调用EncodeFrame函数原型:

virtual int EXTAPI EncodeFrame (const SSourcePicture* kpSrcPic, SFrameBSInfo* pBsInfo) = 0;

        参考函数原型,接下来需要设置SSourcePicture, 这里存放用来编码的yuv数据及参数

                iPicWidth:待编码的图像宽度。
                iPicHeight:待编码的图像高度。
                iColorFormat:待编码的图像格式,目前只有一种videoFormatI420。
                iStride:待编码的yuv3个通道的行宽
                pData:待编码的yuv3个通道的数据指针数组。

        第二个参数SFrameBSInfo,用来获取编码后的NAL单元数据 

typedef struct {
  int           iLayerNum;
  SLayerBSInfo  sLayerInfo[MAX_LAYER_NUM_OF_FRAME];
  EVideoFrameType eFrameType;
  int iFrameSizeInBytes;
  long long uiTimeStamp;
} SFrameBSInfo, *PFrameBSInfo;

        iLayerNum: 空间层数, IDR帧为2层, P帧为1层

        pLayerBsInfo: 已编码数据的结构体, 包含以下数据

                iNalCount: NAL单元个数

                pNalLengthInByte: NAL单元长度

                pBsBuf:  已编码数据缓冲区

        这里以IDR为例, 包含2层空间层, 即iLayerNum为2, 

        同时第一层又包含了2个NAL单元,分别表示SPS和PPS数据, 第二层才是真正的视频数据(这里是I帧数据)

        将拿到的pBsBuf写入文件或者发送出去, 整个编码流程就结束了

out264.write((char *) ((*pLayerBsInfo).pBsBuf), iLayerSize);

1.4 释放

if (ppEncoder) {
        ppEncoder->Uninitialize();
        WelsDestroySVCEncoder(ppEncoder);
    }

4. openh264与x264对比

        性能对比测试

        在demo中同时集成openh264与x264, x264profile设置为high,(openh264只支持baseline profile,因此不设置), 在小米8手机上测试, 预览分辨率1280*960: 

  • openh264 CPU 的占用相对 x264低 4% 左右,
  • openh264 帧率相对x264更高, 24帧/秒对比15帧/秒
  • openh264 在画面质量上相对x264稍差一些

       其他

        x264码控质量较好,是为可接受一定延迟的直播场景设计。
        openh264 有实现变帧率、SVC、LTR 。编码效率较高, 但是存在码控质量较差问题, 是专门为实时音视频传输场景打造的。

上一篇:15、STM32位带操作


下一篇:eWebEditor粘贴图片自动上传到服务器(Java版)