【技术学习】边开发简易修图 APP 边熟悉基础 JNI C++ 用法(下)

227 阅读9分钟

写在前面

本文中所提到的项目已开源在 GitHub 上,仓库位于 github.com/AkatsukiRik… ,目前已迭代至 1.2.0 版本,支持全图滤镜、美颜及调节效果。

前情提要

上篇文档中,我们从零开始创建了一个 Android JNI 项目,在 C++ 中引入了 OpenCV、TensorFlow Lite 库,并使用开源模型对图片的皮肤区域进行了分割。在这篇文档中,我们将引入一个开源的渲染引擎,实现图片上屏并支持磨皮、美白两种美颜效果。

接入渲染引擎

这部分使用了国人大佬 pixpark 在 GitHub 上开源的 gpupixel 渲染引擎,基于 OpenGL/ES,且带有内置的美颜效果滤镜。代码仓库位于 github.com/pixpark/gpu… 。克隆仓库后,可以先用 Android Studio 打开 src/android/java 路径,运行一下官方 Demo 项目。

官方 Demo 是基于摄像头输入的实时处理,而我们正在开发的是图片编辑项目,需要引入 gpupixel 的图片渲染模块并加以定制和修改。Demo 本身就将渲染模块独立了出来,位于 src/android/java/gpupixel,先直接将其引入当前 Android 项目:

image

尝试编译,会发现 C++ 部分编译不过,这是因为 Demo 里的 gpupixel 子项目只包含 Java 代码,C++ 代码在上两层的 src 目录下。为了后续在 Android Studio 中开发 C++ 更简便,将其下所有代码文件均拷贝至当前 gpupixel 模块下的 src/main/cpp 目录:

image

其中,android 子目录下只需要拷贝 jni 这个文件夹。然后重新设置 build.gradle 中的 CMake 项目根目录,重新编译就能编过了:

android {
    externalNativeBuild {
        cmake {
            path file('src/main/cpp/CMakeLists.txt')
            version '3.18.1'
        }
    }
}

图片上屏

这一部分不涉及深层次的渲染知识,只做最基本的分析。

整个渲染引擎基本上是流水线 / 管线(Pipeline)的模式,以摄像头或图片输入为 Source,经过一系列的滤镜处理后输出至 Target,其中滤镜部分可以自由叠加多个。官方的示意图如下:

image

Source 部分,官方 Android Demo 只示范了摄像头输入的用法,但已经帮我们封装好了三种输入的 Java 类,类名分别是 GPUPixelSourceCamera(摄像头输入)、GPUPixelSourceImage(图片输入)和 GPUPixelSourceRawInput(像素输入)。

创建一个新的 Activity,在界面上添加一个 GPUPixelView,这是一个封装好的 Target,内部通过 GLSurfaceView 实现了上屏逻辑。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/dark_bg">
    
    <com.pixpark.gpupixel.GPUPixelView
        android:id="@+id/surface_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
    
</androidx.constraintlayout.widget.ConstraintLayout>

onCreate 生命周期中,先初始化 GPUPixel,在进行完上篇文档中提到的皮肤分割处理后,通过以下代码搭建一个最基本的渲染管线:

private lateinit var binding: ActivityEditBinding
private var sourceImage: GPUPixelSourceImage? = null

private fun startImageFilter(bitmap: Bitmap) {
    sourceImage = GPUPixelSourceImage(bitmap)
    sourceImage?.addTarget(binding.surfaceView)
    sourceImage?.proceed()
}

这段代码首先从 Bitmap 创建了一个 Source,直接为 Source 添加了 GPUPixelView 作为 Target,然后通过调用 proceed 函数触发渲染。运行项目,可以看到图片成功被显示到屏幕上。

将滤镜加入到渲染管线

自带的美颜/美白滤镜被封装在 BeautyFaceFilter 类中,它继承 GPUPixelFilter 父类,该类同时继承 GPUPixelSource 类、实现 GPUPixelTarget 接口,说明滤镜既可以作为 Source 又可以作为 Target 使用,因此可以在管线中自由连接、叠加组合。

更改上一 Part 的渲染管线代码,使管线从 SourceImage 出发先连接到 BeautyFaceFilter,将滤镜的处理结果再传递给 Target 上屏:

private fun startImageFilter(bitmap: Bitmap) {
    beautyFaceFilter = BeautyFaceFilter()
    sourceImage = GPUPixelSourceImage(bitmap)
    sourceImage?.addTarget(beautyFaceFilter)
    beautyFaceFilter?.addTarget(binding.surfaceView)
    sourceImage?.proceed()
}

添加磨皮、美白效果强度的滑杆,在滑杆数值变化时实时更新滤镜里的值,并触发渲染:

binding.composeView.setContent {
    EditScreen(callback = object : EditScreenCallback {
        override fun onSetSmoothLevel(level: Float) {
            beautyFaceFilter?.smoothLevel = level
            sourceImage?.proceed()
        }

        override fun onSetWhiteLevel(level: Float) {
            beautyFaceFilter?.whiteLevel = level
            sourceImage?.proceed()
        }
    })
}

这样就能实现效果的上屏和实时强度调节了。

应用皮肤掩膜

多次调节美白效果的滑杆,明显能观察出整个图片的色调都在变化,而不仅是皮肤区域。在这一部分,我们要对滤镜进行一点简单的定制化开发,利用模型输出的皮肤掩膜,使美白效果仅在皮肤区域生效。

美颜滤镜对应的 C++ 文件是 beauty_face_filter.cc,查看其 init() 函数代码,能发现它本身也是由多个滤镜叠加而成的。其中通过传入参数实现美白、磨皮效果的是 BeautyFaceUnitFilter 这个滤镜。

bool BeautyFaceFilter::init() {
  if (!FilterGroup::init()) {
    return false;
  }

  boxBlurFilter = BoxBlurFilter::create();
  addFilter(boxBlurFilter);

  boxHighPassFilter = BoxHighPassFilter::create();
  addFilter(boxHighPassFilter);

  beautyFilter = BeautyFaceUnitFilter::create();
  addFilter(beautyFilter);

  boxBlurFilter->addTarget(beautyFilter, 1);
  boxHighPassFilter->addTarget(beautyFilter, 2);

  setTerminalFilter(beautyFilter);

  boxBlurFilter->setTexelSpacingMultiplier(4);
  setRadius(4);

  registerProperty("whiteness", 0, "The whiteness of filter with range between -1 and 1.", [this](float& val) {
      setWhite(val);
  });

  registerProperty("skin_smoothing", 0, "The smoothing of filter with range between -1 and 1.", [this](float& val) {
      setBlurAlpha(val);
  });
  return true;
}

BeautyFaceUnitFilter 滤镜接收纹理输入,经过 OpenGL Shader 渲染后输出处理过的颜色。这个 Shader 的顶点着色器代码如下:

attribute vec3 position; attribute vec2 inputTextureCoordinate;

varying vec2 textureCoordinate;
varying vec4 textureShift_1;
varying vec4 textureShift_2;
varying vec4 textureShift_3;
varying vec4 textureShift_4;

uniform float widthOffset;
uniform float heightOffset;
void main(void) {
  gl_Position = vec4(position, 1.0);
  textureCoordinate = inputTextureCoordinate;
  textureShift_1 = vec4(inputTextureCoordinate + vec2(-widthOffset, 0.0),
                        inputTextureCoordinate + vec2(widthOffset, 0.0));
  textureShift_2 = vec4(inputTextureCoordinate + vec2(0.0, -heightOffset),
                        inputTextureCoordinate + vec2(0.0, heightOffset));
  textureShift_3 =
      vec4(inputTextureCoordinate + vec2(widthOffset, heightOffset),
           inputTextureCoordinate + vec2(-widthOffset, -heightOffset));
  textureShift_4 =
      vec4(inputTextureCoordinate + vec2(-widthOffset, heightOffset),
           inputTextureCoordinate + vec2(widthOffset, -heightOffset));
}

片段着色器代码如下:

precision highp float; 
varying highp vec2 textureCoordinate;
varying highp vec4 textureShift_1;
varying highp vec4 textureShift_2;
varying highp vec4 textureShift_3;
varying highp vec4 textureShift_4;

uniform sampler2D inputImageTexture;
uniform sampler2D inputImageTexture2;
uniform sampler2D inputImageTexture3;
uniform sampler2D lookUpGray;
uniform sampler2D lookUpOrigin;
uniform sampler2D lookUpSkin;
uniform sampler2D lookUpCustom;

uniform highp float sharpen;
uniform highp float blurAlpha;
uniform highp float whiten;

const float levelRangeInv = 1.02657;
const float levelBlack = 0.0258820;
const float alpha = 0.7;

void main() {
  vec4 iColor = texture2D(inputImageTexture, textureCoordinate);
  vec4 meanColor = texture2D(inputImageTexture2, textureCoordinate);
  vec4 varColor = texture2D(inputImageTexture3, textureCoordinate);

  vec3 color = iColor.rgb;
  if (blurAlpha > 0.0) {
    float theta = 0.1;
    float p =
        clamp((min(iColor.r, meanColor.r - 0.1) - 0.2) * 4.0, 0.0, 1.0);
    float meanVar = (varColor.r + varColor.g + varColor.b) / 3.0;
    float kMin;
    highp vec3 resultColor;
    kMin = (1.0 - meanVar / (meanVar + theta)) * p * blurAlpha;
    kMin = clamp(kMin, 0.0, 1.0);
    resultColor = mix(iColor.rgb, meanColor.rgb, kMin);

    vec3 sum = 0.25 * iColor.rgb;
    sum += 0.125 * texture2D(inputImageTexture, textureShift_1.xy).rgb;
    sum += 0.125 * texture2D(inputImageTexture, textureShift_1.zw).rgb;
    sum += 0.125 * texture2D(inputImageTexture, textureShift_2.xy).rgb;
    sum += 0.125 * texture2D(inputImageTexture, textureShift_2.zw).rgb;
    sum += 0.0625 * texture2D(inputImageTexture, textureShift_3.xy).rgb;
    sum += 0.0625 * texture2D(inputImageTexture, textureShift_3.zw).rgb;
    sum += 0.0625 * texture2D(inputImageTexture, textureShift_4.xy).rgb;
    sum += 0.0625 * texture2D(inputImageTexture, textureShift_4.zw).rgb;

    vec3 hPass = iColor.rgb - sum;
    color = resultColor + sharpen * hPass * 2.0;
  }

  if (whiten > 0.0) {
    vec3 colorEPM = color;
    color =
        clamp((colorEPM - vec3(levelBlack)) * levelRangeInv, 0.0, 1.0);
    vec3 texel = vec3(texture2D(lookUpGray, vec2(color.r, 0.5)).r,
                      texture2D(lookUpGray, vec2(color.g, 0.5)).g,
                      texture2D(lookUpGray, vec2(color.b, 0.5)).b);
    texel = mix(color, texel, 0.5);
    texel = mix(colorEPM, texel, alpha);

    texel = clamp(texel, 0., 1.);
    float blueColor = texel.b * 15.0;
    vec2 quad1;
    quad1.y = floor(floor(blueColor) * 0.25);
    quad1.x = floor(blueColor) - (quad1.y * 4.0);
    vec2 quad2;
    quad2.y = floor(ceil(blueColor) * 0.25);
    quad2.x = ceil(blueColor) - (quad2.y * 4.0);
    vec2 texPos2 = texel.rg * 0.234375 + 0.0078125;
    vec2 texPos1 = quad1 * 0.25 + texPos2;
    texPos2 = quad2 * 0.25 + texPos2;
    vec3 newColor1Origin = texture2D(lookUpOrigin, texPos1).rgb;
    vec3 newColor2Origin = texture2D(lookUpOrigin, texPos2).rgb;
    vec3 colorOrigin =
        mix(newColor1Origin, newColor2Origin, fract(blueColor));
    texel = mix(colorOrigin, color, alpha);

    texel = clamp(texel, 0., 1.);
    blueColor = texel.b * 15.0;
    quad1.y = floor(floor(blueColor) * 0.25);
    quad1.x = floor(blueColor) - (quad1.y * 4.0);
    quad2.y = floor(ceil(blueColor) * 0.25);
    quad2.x = ceil(blueColor) - (quad2.y * 4.0);
    texPos2 = texel.rg * 0.234375 + 0.0078125;
    texPos1 = quad1 * 0.25 + texPos2;
    texPos2 = quad2 * 0.25 + texPos2;
    vec3 newColor1 = texture2D(lookUpSkin, texPos1).rgb;
    vec3 newColor2 = texture2D(lookUpSkin, texPos2).rgb;
    color = mix(newColor1.rgb, newColor2.rgb, fract(blueColor));
    color = clamp(color, 0., 1.);

    highp float blueColor_custom = color.b * 63.0;
    highp vec2 quad1_custom;
    quad1_custom.y = floor(floor(blueColor_custom) / 8.0);
    quad1_custom.x = floor(blueColor_custom) - (quad1_custom.y * 8.0);
    highp vec2 quad2_custom;
    quad2_custom.y = floor(ceil(blueColor_custom) / 8.0);
    quad2_custom.x = ceil(blueColor_custom) - (quad2_custom.y * 8.0);
    highp vec2 texPos1_custom;
    texPos1_custom.x = (quad1_custom.x * 1.0 / 8.0) + 0.5 / 512.0 +
                       ((1.0 / 8.0 - 1.0 / 512.0) * color.r);
    texPos1_custom.y = (quad1_custom.y * 1.0 / 8.0) + 0.5 / 512.0 +
                       ((1.0 / 8.0 - 1.0 / 512.0) * color.g);
    highp vec2 texPos2_custom;
    texPos2_custom.x = (quad2_custom.x * 1.0 / 8.0) + 0.5 / 512.0 +
                       ((1.0 / 8.0 - 1.0 / 512.0) * color.r);
    texPos2_custom.y = (quad2_custom.y * 1.0 / 8.0) + 0.5 / 512.0 +
                       ((1.0 / 8.0 - 1.0 / 512.0) * color.g);
    newColor1 = texture2D(lookUpCustom, texPos1_custom).rgb;
    newColor2 = texture2D(lookUpCustom, texPos2_custom).rgb;
    vec3 color_custom =
        mix(newColor1, newColor2, fract(blueColor_custom));
    color = mix(color, color_custom, whiten);
  }

  gl_FragColor = vec4(color, 1.0);
}

这里可以无需关注具体的实现,只关注片段着色器代码的开头以 uniform 开头的变量定义。这部分共有 7 个 sampler2D 类型和 3 个 float 类型的变量。sampler2D 类型代表的是纹理,float 变量则是效果的强度,都是要从 C++ 代码传递过来的参数。

首先要做的是添加一个皮肤掩膜的纹理参数:

uniform sampler2D inputImageTexture;
uniform sampler2D inputImageTexture2;
uniform sampler2D inputImageTexture3;
uniform sampler2D lookUpGray;
uniform sampler2D lookUpOrigin;
uniform sampler2D lookUpSkin;
uniform sampler2D lookUpCustom;
uniform sampler2D skinMask;

然后在 main 函数中,对于每个片段获取皮肤掩膜在该片段上的颜色(可以将其简单理解为像素):

void main() {
  vec4 iColor = texture2D(inputImageTexture, textureCoordinate);
  vec4 meanColor = texture2D(inputImageTexture2, textureCoordinate);
  vec4 varColor = texture2D(inputImageTexture3, textureCoordinate);
  vec4 skinMaskColor = texture2D(skinMask, textureCoordinate);
  
  // ......
}

跳过中间的处理过程,在 main 函数的末尾,将原图的颜色与处理后的颜色以皮肤掩膜的颜色为比例进行混合。由于皮肤掩膜只有 0 和 1 两种颜色,在皮肤区域值为 1,其他区域值为 0,这段代码只在皮肤区域输出处理过的颜色,其他区域输出原图的颜色。

void main() {
  // ......
  
  color = mix(iColor.rgb, color, skinMaskColor.r);
  gl_FragColor = vec4(color, 1.0);
}

在 C++ 代码中,按照与其他纹理参数相同的方式,从图片文件获取纹理,传递给 OpenGL 渲染:

// 头文件里定义变量
std::shared_ptr<SourceImage> skinMaskImage_;

// init 函数中用图片文件名初始化
skinMaskImage_ = SourceImage::create(Util::getResourcePath("skin_mask.png"));

// proceed 函数中传入 OpenGL
glActiveTexture(GL_TEXTURE8);
glBindTexture(GL_TEXTURE_2D, skinMaskImage_->getFramebuffer()->getTexture());
_filterProgram->setUniformValue("skinMask", 8);

之后需要调整上层代码,将皮肤掩膜保存为 PNG 格式的图片即可。

要注意的是皮肤掩膜图片需以四通道形式存储,以三通道形式存储会有内存对齐问题,导致 SIGBUS 错误的发生。具体可以参考这篇博文: blog.csdn.net/zsJum/artic…

结语

写到这里,这个简易修图 APP 的基本功能(磨皮、美白)就已经开发完成了。通过开发这个 APP,我们在项目实践过程中学习了 JNI C++ 的用法、简单的 CMake 写法和第三方 C++ 库引入方法,还连带着学习了机器学习模型和渲染引擎的扫盲型基本常识。

C++、图像处理和渲染相关的知识广度和深度都非常大,这里也介绍一些可以用于慢慢深入这个世界的教程以供参考: