前言
之前在Android使用MediaPipe + FFmpeg生成绿幕视频一文中,将视频通过MediaPipe
人像分割能力将视频中的人像位置输出,然后将其余部分的像素修改成绿色,保存成每一帧的图片,最后通过FFmpeg逐帧合成成视频。这个方法比较粗糙,主要还是因为每一帧都需要经过IO,时间和空间都需要比较大的要求 。所以本文想通过深度使用FFmpeg将IO这一部分优化掉。我们利用FFmpeg的编解码能力,再利用OpenGL将逐帧处理,最后输出一个绿幕视频。
观前提醒:
- 本文关于FFmpeg和OpenGL的部分均参考Android 音视频开发打怪升级系列文章。如果对FFmpeg和OpenGL不熟悉的同学,可以优先阅读。
- 由于本文是直接通过在C++层调用FFmpeg及OpenGL的API,所以代码模版基于与上述系列文章的一致,所以本文只介绍有关生成绿幕视频的关键部分。
本文原创只在掘金发布!!!
FFmpeg编译
关于FFmpeg的编译内容,网上也有很多文章可以参考,这里笔者将编译脚本贴出。使用的是FFmpeg6.1由于需要输出mp4格式,所以这里还需要x264。
ps:正常视频还需要音频的编解码,所以其实还需要fdk-aac,本文暂时忽略
- x264:
bash
#!/bin/bash
export NDK=/自己的路径/Library/Android/sdk/ndk/21.4.7075529 #NDK path
TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64
export API=21 #对应android sdk版本
function build_android
{
cd x264
APP_ABI=$1
echo "======== > Start build $APP_ABI"
case ${APP_ABI} in
armeabi-v7a)
HOST=armv7a-linux-android
CROSS_PREFIX=$TOOLCHAIN/bin/arm-linux-androideabi-
;;
arm64-v8a)
HOST=aarch64-linux-android
CROSS_PREFIX=$TOOLCHAIN/bin/aarch64-linux-android-
;;
esac
./configure \
--prefix=$PREFIX \
--disable-cli \
--disable-shared \
--enable-pic \
--enable-static \
--enable-strip \
--cross-prefix=$CROSS_PREFIX \
--sysroot=$SYSROOT \
--host=$HOST
make clean
make -j4
make install
cd ..
}
PREFIX=`pwd`/libs/x264/armeabi-v7a
SYSROOT=$TOOLCHAIN/sysroot
export TARGET=armv7a-linux-androideabi
export CC=$TOOLCHAIN/bin/$TARGET$API-clang
export CXX=$TOOLCHAIN/bin/$TARGET$API-clang++
build_android armeabi-v7a
PREFIX=`pwd`/libs/x264/arm64-v8a
export TARGET=aarch64-linux-android
export CC=$TOOLCHAIN/bin/$TARGET$API-clang
export CXX=$TOOLCHAIN/bin/$TARGET$API-clang++
build_android arm64-v8a
- FFmpeg:
bash
#!/bin/bash
# 用于编译android平台的脚本
# NDK所在目录
NDK_PATH=/自己的路径/Library/Android/sdk/ndk/21.4.7075529 # tag1
# macOS 平台编译,其他平台看一下 $NDK_PATH/toolchains/llvm/prebuilt/ 下的文件夹名称
HOST_PLATFORM=darwin-x86_64 #tag1
# minSdkVersion
API=21
TOOLCHAINS="$NDK_PATH/toolchains/llvm/prebuilt/$HOST_PLATFORM"
SYSROOT="$NDK_PATH/toolchains/llvm/prebuilt/$HOST_PLATFORM/sysroot"
# 生成 -fpic 与位置无关的代码
CFLAG="-D__ANDROID_API__=$API -Os -fPIC -DANDROID "
LDFLAG="-lc -lm -ldl -llog "
# 输出目录
PREFIX=`pwd`/libs/ffmpeg
# 日志输出目录
CONFIG_LOG_PATH=${PREFIX}/log
# 公共配置
COMMON_OPTIONS=
# 交叉配置
CONFIGURATION=
build() {
APP_ABI=$1
CFLAG264=-I/自己的路径/x264/$APP_ABI/include
LDFLAG264=-L/自己的路径/x264/$APP_ABI/lib
echo "======== > Start build $APP_ABI"
echo "PKG_CONFIG_PATH:${PKG_CONFIG_PATH}"
case ${APP_ABI} in
armeabi-v7a)
ARCH="arm"
CPU="armv7-a"
MARCH="armv7-a"
TARGET=armv7a-linux-androideabi
CC="$TOOLCHAINS/bin/$TARGET$API-clang"
CXX="$TOOLCHAINS/bin/$TARGET$API-clang++"
LD="$TOOLCHAINS/bin/$TARGET$API-clang"
# 交叉编译工具前缀
CROSS_PREFIX="$TOOLCHAINS/bin/arm-linux-androideabi-"
EXTRA_CFLAGS="$CFLAG -mfloat-abi=softfp -mfpu=vfp -marm -march=$MARCH "
EXTRA_LDFLAGS="$LDFLAG"
EXTRA_OPTIONS="--enable-neon --cpu=$CPU "
;;
arm64-v8a)
ARCH="aarch64"
TARGET=$ARCH-linux-android
CC="$TOOLCHAINS/bin/$TARGET$API-clang"
CXX="$TOOLCHAINS/bin/$TARGET$API-clang++"
LD="$TOOLCHAINS/bin/$TARGET$API-clang"
CROSS_PREFIX="$TOOLCHAINS/bin/$TARGET-"
EXTRA_CFLAGS="$CFLAG"
EXTRA_LDFLAGS="$LDFLAG"
EXTRA_OPTIONS=""
;;
x86)
ARCH="x86"
CPU="i686"
MARCH="i686"
TARGET=i686-linux-android
CC="$TOOLCHAINS/bin/$TARGET$API-clang"
CXX="$TOOLCHAINS/bin/$TARGET$API-clang++"
LD="$TOOLCHAINS/bin/$TARGET$API-clang"
CROSS_PREFIX="$TOOLCHAINS/bin/$TARGET-"
#EXTRA_CFLAGS="$CFLAG -march=$MARCH -mtune=intel -mssse3 -mfpmath=sse -m32"
EXTRA_CFLAGS="$CFLAG -march=$MARCH -mssse3 -mfpmath=sse -m32 "
EXTRA_LDFLAGS="$LDFLAG"
EXTRA_OPTIONS="--cpu=$CPU "
;;
x86_64)
ARCH="x86_64"
CPU="x86-64"
MARCH="x86_64"
TARGET=$ARCH-linux-android
CC="$TOOLCHAINS/bin/$TARGET$API-clang"
CXX="$TOOLCHAINS/bin/$TARGET$API-clang++"
LD="$TOOLCHAINS/bin/$TARGET$API-clang"
CROSS_PREFIX="$TOOLCHAINS/bin/$TARGET-"
#EXTRA_CFLAGS="$CFLAG -march=$CPU -mtune=intel -msse4.2 -mpopcnt -m64"
EXTRA_CFLAGS="$CFLAG -march=$CPU -msse4.2 -mpopcnt -m64 "
EXTRA_LDFLAGS="$LDFLAG"
EXTRA_OPTIONS="--cpu=$CPU "
;;
esac
echo "-------- > Start clean workspace"
make clean
echo "-------- > Start build configuration"
CONFIGURATION="$COMMON_OPTIONS"
CONFIGURATION="$CONFIGURATION --logfile=$CONFIG_LOG_PATH/config_$APP_ABI.log"
CONFIGURATION="$CONFIGURATION --prefix=$PREFIX"
CONFIGURATION="$CONFIGURATION --libdir=$PREFIX/libs/$APP_ABI"
CONFIGURATION="$CONFIGURATION --incdir=$PREFIX/includes/$APP_ABI"
CONFIGURATION="$CONFIGURATION --pkgconfigdir=$PREFIX/pkgconfig/$APP_ABI"
CONFIGURATION="$CONFIGURATION --cross-prefix=$CROSS_PREFIX"
CONFIGURATION="$CONFIGURATION --arch=$ARCH"
CONFIGURATION="$CONFIGURATION --sysroot=$SYSROOT"
CONFIGURATION="$CONFIGURATION --cc=$CC"
CONFIGURATION="$CONFIGURATION --cxx=$CXX"
CONFIGURATION="$CONFIGURATION --ld=$LD"
# nm 和 strip
CONFIGURATION="$CONFIGURATION --nm=$TOOLCHAINS/bin/llvm-nm"
CONFIGURATION="$CONFIGURATION --strip=$TOOLCHAINS/bin/llvm-strip"
CONFIGURATION="$CONFIGURATION $EXTRA_OPTIONS"
echo "-------- > Start config makefile with $CONFIGURATION --extra-cflags=${EXTRA_CFLAGS}${CFLAG264} --extra-ldflags=${EXTRA_LDFLAGS}${LDFLAG264}"
./configure ${CONFIGURATION} \
--extra-cflags="$EXTRA_CFLAGS$CFLAG264" \
--extra-ldflags="$EXTRA_LDFLAGS$LDFLAG264" \
--pkg-config="pkg-config --static" \
--pkg-config-flags=--static
echo "-------- > Start make $APP_ABI with -j1"
make -j1
echo "-------- > Start install $APP_ABI"
make install
echo "++++++++ > make and install $APP_ABI complete."
}
build_all() {
cd ffmpeg-6.1
#配置开源协议声明
COMMON_OPTIONS="$COMMON_OPTIONS --enable-gpl"
#目标android平台
COMMON_OPTIONS="$COMMON_OPTIONS --target-os=android"
#取消默认的静态库
COMMON_OPTIONS="$COMMON_OPTIONS --enable-static"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-shared"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-protocols"
#开启交叉编译
COMMON_OPTIONS="$COMMON_OPTIONS --enable-cross-compile"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-optimizations"
COMMON_OPTIONS="$COMMON_OPTIONS --disable-debug"
#尽可能小
COMMON_OPTIONS="$COMMON_OPTIONS --enable-small"
COMMON_OPTIONS="$COMMON_OPTIONS --disable-doc"
#不要命令(执行文件)
COMMON_OPTIONS="$COMMON_OPTIONS --disable-programs" # do not build command line programs
COMMON_OPTIONS="$COMMON_OPTIONS --disable-ffmpeg" # disable ffmpeg build
COMMON_OPTIONS="$COMMON_OPTIONS --disable-ffplay" # disable ffplay build
COMMON_OPTIONS="$COMMON_OPTIONS --disable-ffprobe" # disable ffprobe build
COMMON_OPTIONS="$COMMON_OPTIONS --disable-symver"
COMMON_OPTIONS="$COMMON_OPTIONS --disable-network"
COMMON_OPTIONS="$COMMON_OPTIONS --disable-x86asm"
COMMON_OPTIONS="$COMMON_OPTIONS --disable-asm"
#启用
COMMON_OPTIONS="$COMMON_OPTIONS --enable-pthreads"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-mediacodec"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-jni"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-zlib"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-pic"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-libx264"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-encoder=libx264"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-encoder=h264"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-muxer=flv"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-encoder=h264"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-decoder=mpeg4"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-decoder=mjpeg"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-decoder=png"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-decoder=vorbis"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-decoder=opus"
COMMON_OPTIONS="$COMMON_OPTIONS --enable-decoder=flac"
echo "COMMON_OPTIONS=$COMMON_OPTIONS"
echo "PREFIX=$PREFIX"
echo "CONFIG_LOG_PATH=$CONFIG_LOG_PATH"
mkdir -p ${CONFIG_LOG_PATH}
build "armeabi-v7a"
build "arm64-v8a"
#build "x86"
#build "x86_64"
cd ..
}
echo "-------- Start --------"
build_all
echo "-------- End --------"
可能遇到的问题:
- ERROR: x264 not found using pkg-config 编译FFmpeg时,可能会报x264 not found using pkg-config,可以尝试配置一下x264的pkgconfig环境变量
bash
# export PKG_CONFIG_PATH=/自己的路径/x264/armeabi-v7a/lib/pkgconfig:/自己的路径/x264/arm64-v8a/lib/pkgconfig:$PKG_CONFIG_PATH
- 华为鸿蒙4.0遇到无法找到编码器问题 笔者在使用编译好的FFmpeg动态库在华为MatePad11时,遇到了类似的问题:鸿蒙4.0 手机视频编解码时报错,4.0以下的可以正常执行-华为开发者论坛
这个问题暂时无解,实测在其他设备上没有遇到。
绿幕视频生成模型
整个生成流程如图
- 三个流程都运行在独立线程
- 人像切割在解码线程中调用,保证解码数据与人像切割的分类数据一一对应
MediaPipe接入
由于FFmpeg的编解码直接套用了上述推荐文章的模版,这里就直接跳过了,直接进入MediaPipe的使用部分。由于MediaPipe官方只提供了Java API,所以这里只能通过JNI层调用Java的形式了。
kotlin
object PortraitCuttingManager {
private val imagesegmenter: ImageSegmenter by lazy {
val baseOptions = BaseOptions.builder()
.setDelegate(Delegate.CPU)
.setModelAssetPath("selfie_segmenter.tflite")
.build()
val options = ImageSegmenter.ImageSegmenterOptions.builder()
.setRunningMode(RunningMode.VIDEO)
.setBaseOptions(baseOptions)
.setOutputCategoryMask(true)
.setOutputConfidenceMasks(false)
.build()
return@lazy ImageSegmenter.createFromOptions(BaseApp.application, options)
}
private var pixel: IntArray? = null
@JvmStatic
fun segment(frame: ByteBuffer, timestampMs: Long, width: Int, height: Int): IntArray {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val mpImage = ByteBufferImageBuilder(frame, width, height, IMAGE_FORMAT_RGBA).build()
try {
val result = imagesegmenter.segmentForVideo(mpImage, timestampMs)
val newImage = result.categoryMask().get()
val resultByteBuffer = ByteBufferExtractor.extract(newImage)
val pixels = if (this.pixel != null) this.pixel else {
this.pixel = IntArray(resultByteBuffer.capacity())
this.pixel
}
for (index in pixels!!.indices) {
// Using unsigned int here because selfie segmentation returns 0 or 255U (-1 signed)
// with 0 being the found person, 255U for no label.
// Deeplab uses 0 for background and other labels are 1-19,
// so only providing 20 colors from ImageSegmenterHelper -> labelColors
pixels[index] = if (resultByteBuffer.get(index).toUInt() > 0U) 1 else 0
}
return pixels
} catch (e: Exception) {
e.printStackTrace()
}
}
return intArrayOf()
}
}
在Java层定义了一个单例,用于接入MediaPipe。可以看到在上述的segment方法中
frame: ByteBuffer
表示当前帧的数据timestampMs: Long
表示当前帧对应的时间width,height
表示的是当前帧对应的宽高
接入的逻辑和上一篇大同小异。只是在最后输出的数组中,将非人像部分处理成1,人像部分处理成0,提供给C++层使用。
C++
class PortraitCuttingBridge {
private:
const char *TAG = "PortraitCuttingBridge";
jclass portraitCuttingManagerClass = NULL;
int *portraitCuttingResult = NULL;
public:
PortraitCuttingBridge(JNIEnv *env);
~PortraitCuttingBridge();
void segment(JNIEnv *env, OneFrame *oneFrame);
int *getLastResult();
void release();
};
在C++层定义了一个PortraitCuttingBridge
,专门用于对接上述的单例。
C++
PortraitCuttingBridge::PortraitCuttingBridge(JNIEnv *env) {
jclass portraitCuttingManagerClass = env->FindClass(
"me/xcyoung/ffmpeg_test/PortraitCuttingManager");
if (!portraitCuttingManagerClass) {
return;
}
// 因为存在多线程,所以这里的句柄要声明成全局
this->portraitCuttingManagerClass = (jclass) env->NewGlobalRef(portraitCuttingManagerClass);
}
这里需要注意的是,构造时就需要将PortraitCuttingManager对应的句柄声明成全局 ,这是由于整个过程在C++层,这里面的解码、绘制、编码均拥有独立线程运行,并不在Java层的调用线程 。所以需要在初始化(Java层的调用线程)时将句柄获取到,以免ClassLoader不一致导致无法找到该类。
C++
void PortraitCuttingBridge::segment(JNIEnv *env, OneFrame *oneFrame) {
jmethodID segmentMethod = env->GetStaticMethodID(
portraitCuttingManagerClass, "segment", "(Ljava/nio/ByteBuffer;JII)[I");
// 获取AVFrame中的图像数据
uint8_t *imageData = oneFrame->data;
// RGBA 所以需要*4
int imageSize = oneFrame->width * oneFrame->height * 4;
// 创建ByteBuffer,并将AVFrame的图像数据复制到其中
jobject byteBuffer = env->NewDirectByteBuffer(imageData, imageSize);
// 调用 segment 方法
jintArray result = static_cast<jintArray>(env->CallStaticObjectMethod(
portraitCuttingManagerClass,
segmentMethod,
byteBuffer,
oneFrame->pts,
oneFrame->width,
oneFrame->height));
// 获取整数数组的长度
jsize length = env->GetArrayLength(result);
// 获取整数数组的指针
jint* intArrayElements = env->GetIntArrayElements(result, nullptr);
// 创建C++中的整数数组并将数据复制到其中
if (this->portraitCuttingResult == NULL) {
this->portraitCuttingResult = new int[length];
}
for (int i = 0; i < length; ++i) {
this->portraitCuttingResult[i] = static_cast<int>(intArrayElements[i]);
}
// 释放整数数组的指针
env->ReleaseIntArrayElements(result, intArrayElements, JNI_ABORT);
}
int *PortraitCuttingBridge::getLastResult() {
return this->portraitCuttingResult;
}
OneFrame
是通过解码后生成的自定义类型。实际上FFmpeg解码后会生成AVFrame
视频解码:LearningVideo/app/src/main/cpp/media/decoder/video/v_decoder.cpp
C++
sws_scale(m_sws_ctx, frame->data, frame->linesize, 0,
height(), m_rgb_frame->data, m_rgb_frame->linesize);
OneFrame *one_frame = new OneFrame(m_rgb_frame->data[0],
m_rgb_frame->linesize[0],
frame->pts,
time_base(),
frame->width,
frame->height,
NULL,
false);
m_video_render->Render(one_frame);
ps:这里在解码后会将视频的yuv转换成rgb,然后实例了一个OneFrame进行渲染。
- 通过上述获取到的单例句柄,获取到segment的方法句柄进行调用。这里的方法调用就和上述的Java层对应上了。
- 返回结果后,会保存到一个全局数组变量中,这里做的目的是避免频繁申请内存地址导致OOM。
- getLastResult方法会获取到最近一次识别完的结果。
最后MediaPipe的人像分割会在每一帧渲染时调用,Render
方法对应上述的m_video_render->Render(one_frame);
C++
void PortraitCuttingDrawer::Render(OneFrame *one_frame) {
if (portraitCuttingBridge != NULL) {
this->portraitCuttingBridge->segment(m_env, one_frame);
}
cst_data = one_frame->data;
}
OpenGL处理像素
经过了上述的解码、识别后,就会经过OpenGL将这一帧进行渲染。此时会存在两个数据:
- 解码得到的视频帧数据
- 通过MediaPipe识别的分类数据
这里笔者的做法是将两组数据当作两个纹理传递给着色器,于是顶点着色器和片元着色器就长这样(ps:这里使用的是OpenGL ES2.0):
- 顶点着色器
glsl
attribute vec4 aPosition;
attribute vec2 aCoordinate;
varying vec2 vCoordinate;
void main() {
gl_Position = aPosition;
vCoordinate = aCoordinate;
}
- 片元着色器
glsl
#extension GL_OES_EGL_image_external : require
precision mediump float;
uniform sampler2D uTexture;
uniform sampler2D boolArray;
varying vec2 vCoordinate;
void main() {
float v = texture2D(boolArray, vCoordinate).r;
gl_FragColor = (v == 0.0) ? texture2D(uTexture, vCoordinate) : vec4(0.0, 1.0, 0.0, 1.0);
}
重点看片元着色器,boolArray
表示人像切割输出的分类数据,由于分类数据只是一维所以这里只取r这一位。如果为0代表是人像,所以这里输出原视频对应的颜色,否则输出绿色。
解码后视频帧数据的纹理设置
C++
//激活指定纹理单元
glActiveTexture(GL_TEXTURE0);
//绑定纹理ID到纹理单元
glBindTexture(GL_TEXTURE_2D, m_texture_id);
//将活动的纹理单元传递到着色器里面
glUniform1i(m_texture_handler, 0);
//配置边缘过渡参数
glTexParameterf(type, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(type, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(type, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(type, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0,
GL_RGBA,
origin_width(), origin_height(),
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
cst_data);
这里的cst_data即为最近一次解码出来的视频帧
C++
//激活指定纹理单元
glActiveTexture(GL_TEXTURE1);
//绑定纹理ID到纹理单元
glBindTexture(GL_TEXTURE_2D, m_portrait_cutting_texture_id);
//将活动的纹理单元传递到着色器里面
glUniform1i(m_portrait_cutting_texture_handler, 0);
//配置边缘过渡参数
glTexParameterf(type, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(type, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(type, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(type, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
unsigned char *data = reinterpret_cast<unsigned char *>(portraitCuttingResult);
glTexImage2D(GL_TEXTURE_2D, 0,
GL_RGBA,
origin_width(), origin_height(),
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
data);
portraitCuttingResult
即为最近一次人像切割的分类数据,需要注意的是这里需要将其强转成unsigned char *
。
这里就是运用了OpenGL简单的纹理贴图逻辑,其他的诸如顶点坐标、纹理坐标等都是常规操作,这里就不过多介绍了。
总结
本文是上一篇文章Android使用MediaPipe + FFmpeg生成绿幕视频的另一种实现方案。直接使用FFmpeg进行编码优化生成时的IO问题。通过OpenGL逐帧处理,对比操作Bitmap的像素点速度也有所提升。