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目录下
至此编译工作结束, 可以开始撸代码了
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帧数据了
回顾下安卓视频编码的流程:
现在已经有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(¶mExt);
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(¶mExt);
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 。编码效率较高, 但是存在码控质量较差问题, 是专门为实时音视频传输场景打造的。