OpenGL 入门(三)—— OpenGL 与 OpenCV 共同打造大眼滤镜

news2025/1/16 17:04:51

从本篇开始,会在上一篇搭建的滤镜框架的基础上,介绍具体的滤镜效果该如何制作。本篇会先介绍大眼滤镜,先来看一下效果,原图如下:

2024-5-7.大眼滤镜效果演示(使用前)

使用手机后置摄像头对眼部放大后的效果:

2024-5-7.大眼滤镜效果演示(使用后)

制作大眼滤镜所需的主要知识点:

  • OpenCV 人脸定位
  • SeetaFace 五官定位
  • OpenGL 绘制大眼特效

下面让我们一步步来实现这个效果吧。

1、项目配置

要对眼部进行放大,那么一定需要识别到图像中眼睛的位置,通常我们会先识别到人脸,再去识别人眼,这样比从整张图片的范围内直接定位人眼要快。

人脸定位我们使用 OpenCV,虽然 OpenCV 也提供了人眼定位的模型文件,但是由于准确率一般,因此我们使用中科院开源的 SeetaFace 定位人眼。为人眼添加滤镜效果的任务自然落到 OpenGL 上。

OpenCV 在 Android Studio 上的配置,在OpenCV 入门(一) —— OpenCV 基础中已经讲过,去参考那篇文章,这里就不再赘述,下面只介绍 SeetaFace 的配置。

1.1 配置 SeetaFace

中科院开源的人脸识别引擎 SeetaFace,比 OpenCV 自带模型的识别率要好一些。GitHub 下载 SeetaFaceEngine:

2024-4-10.SeetaFace

可以看到包括三个核心模块:

  1. SeetaFace Alignment:面部特征点定位模块
  2. SeetaFace Detection:人脸检测模块
  3. SeetaFace Identification:人脸特征提取与比对模块

SeetaFace_config.docx 是在 Windows 的 VS 配置 SeetaFace 的文档,这里我们要将其配置到 AS 中。步骤如下:

  1. 将 FaceAlignment 目录下的 include 和 src 两个目录以及 CMakeLists.txt 拷贝到 /src/main/cpp/SeetaFace 目录下,其中 src 目录中有一个 test 目录我们并不需要,可以删除 test 目录,但是该目录下的 face_alignment_test.cpp 可以指导我们如何使用 SeetaFace

  2. 修改 SeetaFace 的 CMakeLists.txt,注释掉不需要的部分:

    # 低于主 CMakeLists 要求的最低版本,干掉
    #cmake_minimum_required(VERSION 2.8.4)
    
    # 不需要
    #project(seeta_fa_lib)
    
    # 不需要构建示例,可以和最后的 if (BUILD_EXAMPLES) 一起干掉
    # Build options
    #option(BUILD_EXAMPLES  "Set to ON to build examples"  ON)
    
    # Use C++11
    #set(CMAKE_CXX_STANDARD 11)
    #set(CMAKE_CXX_STANDARD_REQUIRED ON)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")
    message(STATUS "C++11 support has been enabled by default.")
    
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O2")
    
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -msse4.1")
    
    include_directories(include)
    
    set(src_files 
        src/cfan.cpp
        src/face_alignment.cpp
        src/sift.cpp
        )
    
    # 修改编译为静态库
    add_library(seeta_fa_lib STATIC ${src_files})
    set(fa_required_libs seeta_fa_lib)
    
    #[[if (BUILD_EXAMPLES)
        message(STATUS "Build with examples.")
        find_package(OpenCV)
        if (NOT OpenCV_FOUND)
            message(WARNING "OpenCV not found. Test will not be built.")
        else()
            include_directories(${OpenCV_INCLUDE_DIRS} build)
    	link_directories(build)
            list(APPEND fa_required_libs ${OpenCV_LIBS} seeta_facedet_lib)
            add_executable(fa_test src/test/face_alignment_test.cpp)
            target_link_libraries(fa_test ${fa_required_libs})
        endif()
    endif()]]
    
  3. 修改主 CMakeLists.txt:

    # 指定 SeetaFace 的 CMakeLists 文件
    add_subdirectory(${CMAKE_SOURCE_DIR}/SeetaFace/FaceAlignment)
    
    # 导入 SeetaFace 的头文件
    include_directories(${CMAKE_SOURCE_DIR}/SeetaFace/FaceAlignment/include)
    
    target_link_libraries(
            opencv
            log
            opencv_java4 # 链接 OpenCV 动态库
            android # 因为要用 ANativeWindow 渲染,因此要链接 libandroid
            seeta_fa_lib # 链接 SeetaFace 静态库
    )
    
  4. 修改模块的 build.gradle,因为 SeetaFace 的 CMakeLists.txt 中声明了使用 C++11,gradle 要做出相应的配置:

    android {
        defaultConfig {
            externalNativeBuild {
                cmake {
                    cppFlags "-std=c++11"
                }
            }
        }
    }
    
  5. 将人脸识别模型 SeetaFaceEngine-master/FaceAlignment/model/seeta_fa_v1.1.bin 拷贝到 /src/main/assets/ 目录下

SeetaFace 支持识别人脸的 5 个关键点:两只眼睛各 1 个、鼻子 1 个、嘴边两侧各 1 个。

如果 Native 层编辑 cpp 代码时没有代码提示、格式排版,甚至有错也不报,新建文件时没有 C/C++ 的选项,并且 Build -> Refresh Linked C++ Projects 也是灰色的,可能是因为没有在 build.gradle 中添加 Native 编译配置:

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

2、人脸识别与人眼识别

有关 OpenCV 人脸识别的内容,我们在 OpenCV 系列文章的OpenCV 入门(六)—— Android 下的人脸识别中详细讲过。虽然这里又添加了 SeetaFace 进行人眼识别,但主要过程没有太大的变化。

从代码结构上说,识别工作要分为两层:

  1. Native 层:具体的识别工作都是交由 OpenCV 和 SeetaFace 在 Native 层完成的,我们需要将识别的结果(人脸的坐标和宽高数据以及五官坐标)封装成一个上层的 Face 对象并返回给上层
  2. 上层:定义封装人脸数据的 Face 类,同时还需要一个 FaceTracker 作为上层与 Native 层沟通的桥梁,一方面接收外界的指令通知 Native 进行初始化、人脸检测等工作,另一方面接收 Native 层的识别结果存入 Face 并提供给外界作为 OpenGL 添加各种滤镜的依据

从过程上说,主要分为以下几个步骤:

  1. 初始化:使用指定的识别模型在 Native 层初始化 OpenCV 和 SeetaFace
  2. 开始识别:开启 OpenCV 的跟踪识别
  3. 人脸识别:OpenCV 进行人脸识别,识别到的结果保存到一个集合中,SeetaFace 再对集合中的每个人脸进行特征点识别,将包含两眼位置信息的特征点数据保存起来
  4. 反射构造上层对象:将人脸信息和特征点信息通过反射的方式封装到上层的 Face 对象中并将其返回给上层

接下来结合代码详细说明上述实现步骤。

2.1 初始化

先将 OpenCV 和 SeetaFace 识别人脸的模型文件拷贝到项目的 /src/main/res/raw 目录下,在创建渲染器时将模型文件拷贝到手机中:

class GLRender(private val mGLSurfaceView: GLSurfaceView) : GLSurfaceView.Renderer,
    SurfaceTexture.OnFrameAvailableListener {
	private val mOpencvModelPath =
        Utils.copyAsset2Dir(mGLSurfaceView.context, "lbpcascade_frontalface.xml")
    private val mSeetaFaceModelPath =
        Utils.copyAsset2Dir(mGLSurfaceView.context, "seeta_fa_v1.1.bin")
}

工具类参考代码如下:

class Utils {
    companion object {
        fun copyAsset2Dir(context: Context, assetName: String): String {
            val cascadeDir = context.getDir("cascade", Context.MODE_PRIVATE)
            val cascadeFile = File(cascadeDir, assetName)
            if (!cascadeFile.exists()) {
                context.resources.assets.open(assetName).use { inputStream ->
                    FileOutputStream(cascadeFile).use { outputStream ->
                        val buffer = ByteArray(2048)
                        var length: Int
                        while (inputStream.read(buffer).also { length = it } > 0) {
                            outputStream.write(buffer, 0, length)
                        }
                    }
                }
            }
            return cascadeFile.absolutePath
        }
    }
}

然后在渲染器监听到 GLSurfaceView 的尺寸发生变化时,创建上层的 FaceTracker:

	override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        ...
        // 创建 FaceTracker 开始检测人脸
        mFaceTracker = FaceTracker(mCameraHelper, mOpencvModelPath, mSeetaFaceModelPath)
        mFaceTracker.startTracking()
    }

FaceTracker 要调用 Native 方法进行初始化和开启检测:

class FaceTracker(
    private val mCameraHelper: CameraHelper,
    opencvModelPath: String,
    seetaFaceModelPath: String
) {
	// Native 层 FaceTracker 对象的地址
    private var mFaceTracker = 0L
    
    init {
        mFaceTracker = nativeInit(opencvModelPath, seetaFaceModelPath)
    }
    
    fun startTracking() {
        nativeStart(mFaceTracker)
    }
    
    private external fun nativeInit(opencvModelPath: String, seetaFaceModelPath: String): Long
    private external fun nativeStart(faceTracker: Long)
}

nativeInit() 会创建 Native 层的 FaceTracker 对象并将地址返回给上层,这样上层在执行后续的开启识别、结束识别、人脸检测等方法时,将该地址传入便可在 Native 层直接将地址转换成 Native 的 FaceTracker 对象进而执行相应的函数:

#include "FaceTracker.h"

extern "C"
JNIEXPORT jlong JNICALL
Java_com_opengl_filters_FaceTracker_nativeInit(JNIEnv *env, jobject thiz,
                                               jstring opencv_model_path_,
                                               jstring seeta_face_model_path_) {
    const char *opencv_model_path = env->GetStringUTFChars(opencv_model_path_, nullptr);
    const char *seeta_face_model_path = env->GetStringUTFChars(seeta_face_model_path_, nullptr);

    auto faceTracker = new FaceTracker(opencv_model_path, seeta_face_model_path);

    env->ReleaseStringUTFChars(opencv_model_path_, opencv_model_path);
    env->ReleaseStringUTFChars(seeta_face_model_path_, seeta_face_model_path);

    // 将 Native 对象的地址返回给上层
    return reinterpret_cast<jlong>(faceTracker);
}

extern "C"
JNIEXPORT void JNICALL
Java_com_opengl_filters_FaceTracker_nativeStart(JNIEnv *env, jobject thiz, jlong face_tracker) {
    if (face_tracker) {
        auto *faceTracker = reinterpret_cast<FaceTracker *>(face_tracker);
        faceTracker->startTracking();
    }
}

FaceTracker.h 内需要定义初始化 OpenCV 的跟踪器对象所需的 CascadeDetectorAdapter:

#ifndef OPENGL_FACETRACKER_H
#define OPENGL_FACETRACKER_H

#include <opencv2/opencv.hpp>
#include <jni.h>
#include "SeetaFace/FaceAlignment/include/face_alignment.h"

using namespace cv;

class CascadeDetectorAdapter : public DetectionBasedTracker::IDetector {
public:
    CascadeDetectorAdapter(cv::Ptr<cv::CascadeClassifier> detector) :
            IDetector(),
            Detector(detector) {
    }

    // 检测人脸的函数,Mat 相当于 Android 的一张 Bitmap。一张图片有几个人脸就会调用本方法几次
    void detect(const cv::Mat &Image, std::vector<cv::Rect> &objects) {
        Detector->detectMultiScale(Image, objects, scaleFactor,
                                   minNeighbours, 0, minObjSize, maxObjSize);
    }

    virtual ~CascadeDetectorAdapter() = default;

private:
    CascadeDetectorAdapter();

    cv::Ptr<cv::CascadeClassifier> Detector;
};

class FaceTracker {

public:
    FaceTracker(const char *opencv_model_path, const char *seeta_face_model_path);

    void startTracking();

    void stopTracking();

    void detect(const Mat& src, std::vector<Rect2f> &rectangles);

private:
    Ptr<DetectionBasedTracker> tracker = nullptr;
    Ptr<seeta::FaceAlignment> faceAlignment = nullptr;
};

#endif //OPENGL_FACETRACKER_H

FaceTracker 的构造函数要创建 OpenCV 和 SeetaFace 的检测器对象:

FaceTracker::FaceTracker(const char *opencv_model_path, const char *seeta_face_model_path) {
    // 1.创建 OpenCV 识别对象
    // 1.1 创建检测器
    Ptr<CascadeClassifier> detectorClassifier = makePtr<CascadeClassifier>(opencv_model_path);
    Ptr<CascadeDetectorAdapter> mainDetector = makePtr<CascadeDetectorAdapter>(detectorClassifier);

    // 1.2 创建跟踪器
    Ptr<CascadeClassifier> trackerClassifier = makePtr<CascadeClassifier>(opencv_model_path);
    Ptr<CascadeDetectorAdapter> trackingDetector = makePtr<CascadeDetectorAdapter>(
            trackerClassifier);

    // 1.3 创建识别对象
    DetectionBasedTracker::Parameters detectionParams;
    tracker = makePtr<DetectionBasedTracker>(mainDetector, trackingDetector, detectionParams);

    // 2.创建 SeetaFace 识别对象
    faceAlignment = makePtr<seeta::FaceAlignment>(seeta_face_model_path);
}

初始化完成,至于 startTracking() 仅需调用 tracker 的 run() 即可开始检测:

void FaceTracker::startTracking() {
    if (tracker) {
        tracker->run();
    }
}

2.2 人脸识别

进入识别流程,需要将图像数据传给 Native 层进行识别,图像数据来自 CameraHelper:

typealias CameraPreviewCallback = (data: ByteArray) -> Unit

class CameraHelper(
    private val mActivity: Activity,
    private var mCameraId: Int,
    private var mWidth: Int,
    private var mHeight: Int
) : Camera.PreviewCallback {
    
	private var mPreviewCallback: CameraPreviewCallback? = null
    
    // Camera.PreviewCallback
    override fun onPreviewFrame(data: ByteArray?, camera: Camera?) {
        data?.let {
            // 将 mBuffer 继续放入回调队列中接收数据
            mCamera.addCallbackBuffer(mBuffer)
            // 将预览画面数据回调给外界
            mPreviewCallback?.invoke(it)
        }
    }
    
    fun setPreviewCallback(callback: CameraPreviewCallback) {
        mPreviewCallback = callback
    }
}

渲染器设置 PreviewCallback 获取图像数据转发给 FaceTracker 要求检测:

	override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // 1.初始化 CameraHelper
        mCameraHelper = CameraHelper(
            mGLSurfaceView.context as Activity,
            Camera.CameraInfo.CAMERA_FACING_BACK,
            CameraHelper.WIDTH,
            CameraHelper.HEIGHT
        )

        mCameraHelper.setPreviewCallback {
            mFaceTracker.detect(it)
        }
        ...
	}

由于人脸检测是耗时操作肯定放在子线程中,FaceTracker 采用 HandlerThread 来处理检测工作:

	init {
        mFaceTracker = nativeInit(opencvModelPath, seetaFaceModelPath)

        mHandlerThread = HandlerThread("Face-Detect-Thread")
        mHandlerThread.start()
        mHandler = Handler(mHandlerThread.looper) { message ->
            mFace = nativeDetect(
                mFaceTracker,
                message.obj as ByteArray,
                mCameraHelper.getCameraId(),
                CameraHelper.WIDTH,
                CameraHelper.HEIGHT
            )
            true
        }
    }

	fun detect(data: ByteArray) {
        // 先移除之前的消息,保持检测最新的 data
        mHandler.removeMessages(MSG_DETECT)
        // 添加新的 data 到消息队列中
        mHandler.obtainMessage(MSG_DETECT, data).sendToTarget()
    }

这样就会调用 nativeDetect() 进入 Native 层:

extern "C"
JNIEXPORT jobject JNICALL
Java_com_opengl_filters_FaceTracker_nativeDetect(JNIEnv *env, jobject thiz, jlong face_tracker,
                                                 jbyteArray data_, jint camera_id, jint width,
                                                 jint height) {
    if (!face_tracker) {
        return nullptr;
    }

    jbyte *data = env->GetByteArrayElements(data_, nullptr);
    auto *faceTracker = reinterpret_cast<FaceTracker *>(face_tracker);
    
    // 1.人脸检测
    ...

    // 2.生成上层的 Face 对象返回给上层
    ...
}

主要任务有两项:

  1. 利用 OpenCV 和 SeetaFace 检测人脸信息并保存
  2. 将人脸信息封装到上层的 Face 对象中并返回

下面分别来看这两项内容实现。

检测过程

大致过程如下:

  1. 根据原图像生成用于 OpenCV 识别的图片对象 Mat
  2. 将 Mat 由 YUV NV21 格式转换为 RGBA 格式,并且旋转图像将其调正
  3. 取原图的灰度图和直方图均衡化,准备正式开始识别
  4. 调用 OpenCV 的 API 进行检测,结果保存在集合中
  5. 从 OpenCV 的结果中取出数据进行 SeetaFace 识别,将检测的关键点数据保存起来

我们先来看 nativeDetect() 的实现,包含前三步:

extern "C"
JNIEXPORT jobject JNICALL
Java_com_opengl_filters_FaceTracker_nativeDetect(JNIEnv *env, jobject thiz, jlong face_tracker,
                                                 jbyteArray data_, jint camera_id, jint width,
                                                 jint height) {
    if (!face_tracker) {
        return nullptr;
    }

    jbyte *data = env->GetByteArrayElements(data_, nullptr);
    auto *faceTracker = reinterpret_cast<FaceTracker *>(face_tracker);

    // 1.人脸检测
    // 1.1 创建 Mat 对象并做预处理
    Mat src(height * 3 / 2, width, CV_8UC1, data);
    // 将 src 的格式由 YUV NV21 转换为 RGBA
    cvtColor(src, src, COLOR_YUV2RGBA_NV21);
    // 对原始图像进行旋转调正
    if (camera_id == 1) {
        // 前置摄像头需要逆时针旋转 90°
        rotate(src, src, ROTATE_90_COUNTERCLOCKWISE);
        // 前置还需要取一个水平方向的镜像,如果传 0 就是竖直方向
        flip(src, src, 1);
    } else {
        // 后置摄像头需要顺时针旋转 90°
        rotate(src, src, ROTATE_90_CLOCKWISE);
    }

    // 1.2 对 Mat 进行人脸检测
    // 将图片转换为灰度图,可以减少杂色增加识别几率
    Mat gray;
    cvtColor(src, gray, COLOR_RGBA2GRAY);
    // 增强对比度,目的是增强轮廓(因为识别是对轮廓进行识别)
    equalizeHist(gray, gray);
    // 检测人脸,结果的矩形保存到 rectangles 中
    std::vector<Rect2f> rectangles;
    faceTracker->detect(gray, rectangles);

    // data 使命结束,及时释放
    env->ReleaseByteArrayElements(data_, data, 0);
    
    ...
}

具体的检测工作由 FaceTracker 的 detect() 完成,检测结果保存在 rectangles 中:

void FaceTracker::detect(const Mat &src, std::vector<Rect2f> &rectangles) {
    // 1.先将 OpenCV 检测到矩形保存到 faces 中
    std::vector<Rect> faces;
    // 检测
    tracker->process(src);
    // 获取结果
    tracker->getObjects(faces);

    if (!faces.empty()) {
        // 先只处理一个人脸,将其位置信息保存到 rectangles 中备用
        Rect face = faces[0];
        rectangles.emplace_back(face.x, face.y, face.width, face.height);

        // 2.使用 SeetaFace 检测人脸以获取五官位置,需要准备三个参数
        // 2.1 图像数据 ImageData,切记如果使用空参构造函数,一定要为 num_channels 赋值
        seeta::ImageData imageData = seeta::ImageData(src.cols, src.rows);
        imageData.data = src.data;

        // 2.2 人脸矩形信息 FaceInfo
        seeta::FaceInfo faceInfo;
        seeta::Rect bbox;
        bbox.x = face.x;
        bbox.y = face.y;
        bbox.width = face.width;
        bbox.height = face.height;
        faceInfo.bbox = bbox;

        // 2.3 人脸 5 个关键点的集合,是一个入参出参
        seeta::FacialLandmark landmarks[5];

        // 2.4 执行 SeetaFace 人脸定位
        faceAlignment->PointDetectLandmarks(imageData, faceInfo, landmarks);

        // 3.将关键点保存到 rectangles 中
        for (auto & landmark : landmarks) {
            // 我们只需要关键点的坐标,而无需宽高数据
            rectangles.emplace_back(landmark.x, landmark.y, 0, 0);
        }
    }
}

过程梳理:

  1. 先用 OpenCV 检测人脸,调用 tracker->process(src) 对原图进行检测,再通过 tracker->getObjects(faces) 将检测到的人脸矩形保存到 faces 集合中
  2. 调用 SeetaFace 的 faceAlignment->PointDetectLandmarks() 检测人脸的 5 个特征点,该函数需要三个参数:
    • ImageData:保存图像信息的对象,包括图像宽高以及像素数据,如果通过构造函数创建该对象,可以不用显式指定 num_channels,构造函数会为其赋默认值为 1
    • FaceInfo:人脸信息,主要是指定它的 bbox 字段,包含人脸矩形的左上角坐标以及矩形宽高
    • FacialLandmark:人脸特征点(关键点),SeetaFace 会将左眼、右眼、鼻子、左嘴角、右嘴角这 5 个特征点的坐标检测出来,这里声明了 FacialLandmark 类型的数组就是用来接收这 5 个点的
  3. 将人脸信息(起始点和宽高)以及 5 个关键点信息(主要是起始点,宽高由于不需要都被设置为 0)共 6 个矩形保存到参数的 rectangles 集合中

人脸宽高数据在本节的大眼滤镜中用不到,但是在下一篇添加贴纸效果时有用,由于是很小的点,也不适宜在下一篇中单独拿出来说,因此就在这里直接保存这个信息了。

创建上层对象

这里的上层对象就是指 Face:

class Face(
    // 关键点的左上角坐标集合
    val landmarks: FloatArray,
    // 人脸宽高
    val faceWidth: Int,
    val faceHeight: Int,
    // 被检测的图像宽高
    val imgWidth: Int,
    val imgHeight: Int
)

我们要在 Native 通过反射的方式创建该对象,主要就是先准备好构造方法内的参数数据。其中,landmarks 关键点坐标可以通过上一步中计算出的 rectangles 集合获取;人脸宽高保存在 rectangles 中的第一个矩形内;被检测的图像宽高可以通过灰度图获取。

参考代码如下:

extern "C"
JNIEXPORT jobject JNICALL
Java_com_opengl_filters_FaceTracker_nativeDetect(JNIEnv *env, jobject thiz, jlong face_tracker,
                                                 jbyteArray data_, jint camera_id, jint width,
                                                 jint height) {
    // 2.生成上层的 Face 对象
    // 2.1 先获取被检测的图片宽高数据备用
    int imgWidth = gray.cols;
    int imgHeight = gray.rows;
    src.release();
    gray.release();

    int rectSize = rectangles.size();
    if (rectSize) {
        // 2.2 创建 Face 构造方法中的关键点集合 FloatArray
        int floatArraySize = rectSize * 2;
        jfloatArray floatArray = env->NewFloatArray(floatArraySize);
        for (int i = 0; i < rectSize; ++i) {
            float f[2] = {rectangles[i].x, rectangles[i].y};
            env->SetFloatArrayRegion(floatArray, i * 2, 2, f);
        }

        // 2.3 获取人脸矩形宽高
        Rect faceRect = rectangles[0];
        int faceWidth = faceRect.width;
        int faceHeight = faceRect.height;

        // 2.4 获取 Face 类与构造函数的 ID,创建 Face 对象并返回给上层
        jclass clazz = env->FindClass("com/opengl/filters/Face");
        jmethodID constructorID = env->GetMethodID(clazz, "<init>", "([FIIII)V");

        return env->NewObject(clazz, constructorID, floatArray, faceWidth, faceHeight, imgWidth,
                              imgHeight);
    }

    return nullptr;
}

这样在 Native 层通过反射的方式创建了上层的 Face 对象并返回给上层的 FaceTracker,后者可以对外提供 Face 对象以供后续 OpenGL 绘制滤镜所用:

	fun getFace() = mFace

3、添加大眼滤镜

前面的工作保证我们能获取到人眼坐标,接下来就是使用 OpenGL 在绘制时添加滤镜效果了。

3.1 着色器

顶点着色器使用 base_vertex 即可,需要新建一个片元着色器 big_eyes_fragment.glsl:

// 声明 float 是中等精度的
precision mediump float;

// 采样点坐标
varying vec2 aCoord;

// 采样器
uniform sampler2D vTexture;
// 左眼坐标
uniform vec2 left_eye;
// 右眼坐标
uniform vec2 right_eye;

// 公式,用于计算将眼睛放大后的顶点到放大中心的距离
// r 是未放大前顶点坐标到眼睛中心的距离
// rmax 是放大后顶点到眼睛中心的最大距离
float fs(float r, float rmax) {
    // 放大系数
    float a = 0.4;

    // pow 是内置函数,用于计算幂次,虽然是计算二次方,但是也要写为 2.0
    return (1.0 - pow(r / rmax - 1.0, 2.0) * a) * r;
}

// 计算放大后的点的坐标
// coord 原来的点,eye 眼睛坐标,rmax 放大后的最大距离
vec2 calNewCoord(vec2 oldCoord, vec2 eye, float rmax) {
    vec2 newCoord = oldCoord;

    // 原来的点到眼睛中心的距离
    float dis = distance(oldCoord, eye);

    // 未到最大距离,可以进行放大
    if (dis > 0.0f && dis < rmax) {
        // 求出放大后的点到眼睛的距离
        float fsr = fs(dis, rmax);
        // 按比例计算新点坐标:(新点 - 眼睛) / (旧点 - 眼睛) = 放大后距离 / 放大前距离
        // 即 :(newCoord - eye) / (coord - eye) = fsr / dis
        newCoord = eye + (oldCoord - eye) * (fsr / dis);
    }

    return newCoord;
}

void main() {
    // 两眼间距离除以 2 就是放大后的最大距离
    float rmax = distance(left_eye, right_eye) / 2.0;

    // 获取左右眼放大后的坐标,左眼和右眼都要做一次,在哪个眼睛的放大区间就放大哪一个
    vec2 newCoord = calNewCoord(aCoord, left_eye, rmax);
    // 注意第一个参数要传 newCoord,如果传了 aCoord 那么就只判断了右眼
    newCoord = calNewCoord(newCoord, right_eye, rmax);

    gl_FragColor = texture2D(vTexture, newCoord);
}

fs 函数中的公式来自于 1979 年的一篇论文 “Interactive Image Warping” 的第 41 页:

2024-4-17.大眼公式

用该公式可以计算出放大后的点到眼睛的距离,当然这个距离不能大于 rmax,也就是两眼间距的一半。计算出距离后,可以通过 calNewCoord() 计算出放大后点的坐标,然后就可以让 OpenGL 进行绘制了。

3.2 实现滤镜

大眼滤镜,包括我们后续要实现的美颜滤镜和贴纸,它们都是绘制在 FBO 上的,而不是直接渲染到屏幕上,因此可以抽出一个基类将 FBO 的共同操作放入其中:

/**
 * 使用 FBO 绘制的 Filter 基类
 */
open class BaseFrameFilter(context: Context, mVertexSourceId: Int, mFragmentSourceId: Int) :
    BaseFilter(context, mVertexSourceId, mFragmentSourceId) {

    protected var mFrameBuffers: IntArray? = null
    protected var mFrameBufferTextures: IntArray? = null

    override fun onReady(width: Int, height: Int) {
        super.onReady(width, height)

        // 1.先清空 mFrameBuffers 的残留数据
        mFrameBuffers?.let {
            releaseFrameBuffer()
        }

        // 2.创建 FBO
        mFrameBuffers = IntArray(1)
        // FBO 个数、保存 FBO ID 的数组、偏移量,用数组的第几个来保存
        glGenFramebuffers(mFrameBuffers?.size ?: 1, mFrameBuffers, 0)

        // 3.创建 FBO 的纹理
        mFrameBufferTextures = IntArray(1)
        TextureHelper.generateTextures(mFrameBufferTextures!!)

        // 4.绑定 FBO 与纹理
        // 将 FBO 纹理 ID 绑定到 GL_TEXTURE_2D 目标上,目标的类型是 2D 纹理
        glBindTexture(GL_TEXTURE_2D, mFrameBufferTextures!![0])
        // 更新纹理图像数据
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, null)
        // 绑定 FBO
        glBindFramebuffer(GL_FRAMEBUFFER, mFrameBuffers!![0])
        // 将纹理附加到 FBO
        glFramebufferTexture2D(
            GL_FRAMEBUFFER,
            GL_COLOR_ATTACHMENT0,
            GL_TEXTURE_2D,
            mFrameBufferTextures!![0],
            0
        )

        // 5.解绑
        glBindFramebuffer(GL_FRAMEBUFFER, 0)
        glBindTexture(GL_TEXTURE_2D, 0)
    }

    override fun release() {
        super.release()
        releaseFrameBuffer()
    }

    protected fun releaseFrameBuffer() {
        mFrameBufferTextures?.let {
            glDeleteTextures(it.size, mFrameBufferTextures, 0)
            mFrameBufferTextures = null
        }

        mFrameBuffers?.let {
            glDeleteFramebuffers(it.size, mFrameBuffers, 0)
            mFrameBuffers = null
        }
    }
}

大眼滤镜 BigEyesFilter 直接继承该基类:

class BigEyesFilter(context: Context) :
    BaseFrameFilter(context, R.raw.base_vertex, R.raw.big_eyes_fragment) {

    private val leftEye: Int
    private val rightEye: Int
    private var leftBuffer: FloatBuffer
    private var rightBuffer: FloatBuffer
    private var face: Face? = null

    init {
        leftEye = glGetUniformLocation(mProgramId, "left_eye")
        rightEye = glGetUniformLocation(mProgramId, "right_eye")

        leftBuffer = ByteBuffer.allocateDirect(2 * 4).order(ByteOrder.nativeOrder()).asFloatBuffer()
        rightBuffer =
            ByteBuffer.allocateDirect(2 * 4).order(ByteOrder.nativeOrder()).asFloatBuffer()
    }

    override fun initCoordinator() {
        // 转 180° 调正
        val texture = floatArrayOf(
            0.0f, 0.0f,
            1.0f, 0.0f,
            0.0f, 1.0f,
            1.0f, 1.0f
        )
        mTextureBuffer.clear()
        mTextureBuffer.put(texture)
    }

    fun setFace(face: Face?) {
        this.face = face
    }

    override fun onDrawFrame(textureId: Int): Int {
        // 1.判断不符合绘制条件的情况,直接返回上一层的纹理 ID
        val landmarks = face?.landmarks
        val imgWidth = face?.imgWidth
        val imgHeight = face?.imgHeight
        if (imgWidth == null || imgHeight == null || landmarks == null) {
            return textureId
        }

        // 2.绘制前设置:设置视窗、绑定 FBO、使用着色器程序
        glViewport(0, 0, mWidth, mHeight)
        glBindFramebuffer(GL_FRAMEBUFFER, mFrameBuffers!![0])
        glUseProgram(mProgramId)

        // 3.设置顶点坐标和纹理坐标
        mVertexBuffer.position(0)
        glVertexAttribPointer(vPosition, 2, GL_FLOAT, false, 0, mVertexBuffer)
        glEnableVertexAttribArray(vPosition)

        mTextureBuffer.position(0)
        glVertexAttribPointer(vCoord, 2, GL_FLOAT, false, 0, mTextureBuffer)
        glEnableVertexAttribArray(vCoord)

        // 4.将眼睛坐标传给片元着色器
        var x = landmarks[2] / imgWidth
        var y = landmarks[3] / imgHeight
        leftBuffer.clear()
        leftBuffer.put(x)
        leftBuffer.put(y)
        leftBuffer.position(0)
        glUniform2fv(leftEye, 1, leftBuffer)

        // 右眼坐标
        x = landmarks[4] / imgWidth
        y = landmarks[5] / imgHeight
        rightBuffer.clear()
        rightBuffer.put(x)
        rightBuffer.put(y)
        rightBuffer.position(0)
        glUniform2fv(rightEye, 1, rightBuffer)

        // 5.后续常规操作,OpenGL 绘制
        // 激活图层
        glActiveTexture(GL_TEXTURE0)
        // 绑定
        glBindTexture(GL_TEXTURE_2D, textureId)
        // 传递参数
        glUniform1i(vTexture, 0)
        // 通知 OpenGL 绘制
        glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)
        // 解绑 FBO
        glBindFramebuffer(GL_FRAMEBUFFER, 0)
        glBindTexture(GL_TEXTURE_2D, 0)

        return mFrameBufferTextures!![0]
    }
}

核心思路就是从 Face 中提取出左眼和右眼的坐标,传递给片元着色器 big_eyes_fragment 中定义的两个变量 left_eye 和 right_eye。

3.3 装配大眼滤镜

在渲染器中创建 BigEyesFilter 并将其添加到绘制的责任链中:

	private lateinit var mBigEyesFilter: BigEyesFilter
	
	override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        ...
        // 3.创建滤镜对象
        mScreenFilter = ScreenFilter(mGLSurfaceView.context)
        mCameraFilter = CameraFilter(mGLSurfaceView.context)
        mBigEyesFilter = BigEyesFilter(mGLSurfaceView.context)
    }

	override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        ...
        // 设置 OpenGL 的绘制视窗
        mCameraFilter.onReady(width, height)
        mBigEyesFilter.onReady(width, height)
        mScreenFilter.onReady(width, height)
        ...
    }

	override fun onDrawFrame(gl: GL10?) {
        ...
        // 3.交给滤镜进行具体的绘制工作
        mCameraFilter.setMatrix(mMatrix)
        var textureId = mCameraFilter.onDrawFrame(mTextureIds[0])

        mBigEyesFilter.setFace(mFaceTracker.getFace())
        textureId = mBigEyesFilter.onDrawFrame(textureId)

        mScreenFilter.onDrawFrame(textureId)
    }

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1653707.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Qt应用开发(拓展篇)——图表 QChart

一、前言 QChart是一个图形库模块&#xff0c;它可以实现不同类型的序列和其他图表相关对象(如图例和轴)的图形表示。要在布局中简单地显示图表&#xff0c;可以使用QChartView来代替QChart。此外&#xff0c;线条、样条、面积和散点序列可以通过使用QPolarChart类表示为极坐标…

PRL:新型量子传感方案突破纳米测量极限

朴茨茅斯大学的研究人员近期宣布了一项令人振奋的量子传感方案&#xff0c;该方案在测量两个干涉光子之间的横向位移方面达到了前所未有的量子灵敏度。 这一技术的突破为超分辨率成像技术带来了新的可能性。目前&#xff0c;这些技术通常采用单光子源作为探针&#xff0c;用于在…

无刷电机和有刷电机的区别

无刷电机和有刷电机的区别 无刷电机的定子上绕着线圈&#xff0c;线圈通常是成对出现的&#xff0c;通过控制电路为每一对线圈按照一定顺序输入电流&#xff0c;就可以产生旋转的磁场 它还有一个永磁体转子&#xff0c;现在多采用高磁能级的稀土铷铁硼材料&#xff0c;体积更小…

Python练习(函数)

目录 6-1 使用函数求素数和 函数接口定义&#xff1a; 裁判测试程序样例&#xff1a; 输入样例&#xff1a; 输出样例&#xff1a; 6-2 使用函数输出指定范围内Fibonacci数的个数 函数接口定义&#xff1a; 裁判测试程序样例&#xff1a; 输入样例&#xff1a; 输出样…

【AI绘画】Midjourney 工笔画 水蓝色衣服的少女

using Midjourney 提示词&#xff1a; highly detailed,细节刻画细腻,超高清晰度,32k,HD,大师作品,高质量,动漫少女,水墨人像,20岁年轻身材很好的中国少女,惊人的美貌,五官精致,精致的妆容,华丽的水蓝色衣服,古风服饰,华丽的珠宝,飞扬的黑色长发,大风吹起头发,宝石发光,黄金装饰…

tf2使用savemodel保存之后转化为onnx适合进行om模型部署

tf2使用savemodel保存之后转化为onnx适合进行om模型部署 tf保存为kears框架h5文件将h5转化为savemodel格式&#xff0c;方便部署查看模型架构将savemodel转化为onnx格式使用netrononnx模型细微处理代码转化为om以及推理代码&#xff0c;要么使用midstudio tf保存为kears框架h5文…

Windows注册表

注册表 一.概述 注册表&#xff08;Registry&#xff09;是Microsoft Windows中的一个重要的数据库&#xff0c;用于[存储系统]和[应用程序]的设置信息。早在[Windows 3.0]推出[OLE]技术的时候&#xff0c;注册表就已经出现。随后推出的[Windows NT]是第一个从系统级别广泛使…

IT项目管理 选择/判断 【太原理工大学】

第一章、IT项目管理 判断题 1、搬家属于项目。&#xff08; 对 &#xff09; 2、项目是为了创造一个唯一的产品或提供一个唯一的服务而进行的永久性的努力。&#xff08; 错 &#xff09; 3、项目具有临时性的特征。&#xff08; 对 &#xff09; 4、项目开发过程…

基于RTI Connext使用Simulink的DDS Blockset

MathWorks一直是数据分发服务&#xff08;DDS&#xff09;标准的长期支持者。RTI Connext基于DDS&#xff0c;已与Simulink集成多年&#xff0c;使用户能够导入数据进行更逼真的模拟工作。 2021年&#xff0c;MathWorks通过其新推出的Simulink附加产品DDS Blockset提高了标准。…

怎么制作好玩的gif?试试这个工具轻松制作

视频之所以受大众的喜爱是因为有声音、画面的搭配&#xff0c;让观者深入其中体验感会更强。但是视频的体积较大、时长也比较长&#xff0c;给我们的传播和保存造成了一定的影响。那么&#xff0c;我们可以将视频制作成gif图片来使用&#xff0c;不需要下载软件&#xff0c;使用…

在哪个网站找视频素材?在哪可以下视频素材?

在这个视频内容极为重要的时代&#xff0c;高质量的视频素材成为了创作的关键。特别是4K和无水印视频素材&#xff0c;它们不仅提升了视觉效果&#xff0c;也为作品增加了专业度。以下是一些优秀的国内外视频素材网站&#xff0c;希望能助您一臂之力。 1. 蛙学府 专注于为中国…

01-基本概念- 索引,文档和 REST API

# kibana_sample_data_ecommerce 为es 索引#查看索引相关信息 GET kibana_sample_data_ecommerce#查看索引的文档总数 GET kibana_sample_data_ecommerce/_count#查看前10条文档&#xff0c;了解文档格式 POST kibana_sample_data_ecommerce/_search { }#_cat indices API #查看…

福州网站建设如何设计极简风格合理?

福州网站建设如何设计极简风格合理&#xff1f;企业网站逐渐流行&#xff0c;每个人的审美也发生着巨大的改变&#xff0c;开始追求一种极简的风格。简单的 风格才能够凸显原有的主题&#xff0c;不会太过主次不分。 越来越多的网站建设中选择极简的风格&#xff0c;简单的页面…

8款好用的电脑监控软件分享丨好资源不私藏!

电脑已经成为我们日常生活和工作的重要工具。随之而来的是&#xff0c;电脑监控的需求也逐渐增加。为了帮助大家更好地管理和监控电脑使用情况&#xff0c;本文将为您推荐8款好用的电脑监控软件。这些软件功能强大&#xff0c;易于使用&#xff0c;适用于各种场景&#xff0c;让…

什么情况下 MySQL 连查询都能被阻塞?

MySQL 的锁也是不少&#xff0c;在哪种情况下会连查询都能被阻塞&#xff1f;这是一个有意思的问题。 工作中&#xff0c;很多开发和 DBA 可能接触较多的锁也就行锁了。对于行锁&#xff0c;阻塞写能理解&#xff0c;阻塞读实在是想不到。能阻塞读的那肯定是颗粒度更大的锁了&…

电脑怎么压缩图片?压缩图片并不难

电脑怎么压缩图片&#xff1f;随着数字时代的来临&#xff0c;我们每天都在与大量的图片打交道&#xff0c;无论是社交媒体上的个人照片&#xff0c;还是工作中的设计图片&#xff0c;或是网页上的广告图片。然而&#xff0c;高质量的图片往往意味着大文件大小&#xff0c;这不…

如何将jsp项目转成springboot项目

昨天说过&#xff0c;springboot推荐使用Thymeleaf作为前后端渲染的模板引擎&#xff0c;为什么推荐用Thymeleaf呢&#xff0c;有以下几个原因&#xff1a; 动静结合&#xff1a;Thymeleaf支持HTML原型&#xff0c;允许在HTML标签中增加额外的属性来实现模板与数据的结合。这样…

Linux的基础IO:文件系统

目录 学前补充 磁盘的存储结构 OS如何对磁盘的存储进行逻辑抽象 细节内容 文件的增删改查 学前补充 问题&#xff1a;计算机只认二进制&#xff0c;即0、1&#xff0c;什么是0、1&#xff1f; 解释&#xff1a;0、1在物理层面可能有不同的表现&#xff0c;0、1是数字逻辑…

美股订单类型有哪些

美股交易中&#xff0c;订单类型是投资者执行交易指令的重要工具。了解不同类型的订单&#xff0c;可以帮助投资者制定更有效的交易策略&#xff0c;并控制风险。 1. 市价单&#xff1a;快速成交&#xff0c;不惧踏空 市价单&#xff08;Market Order&#xff09;是一种以当时…

【NodeMCU实时天气时钟温湿度项目 5】获取关于城市天气实况和天气预报的JSON信息(心知天气版)

| 今天是第五专题内容&#xff0c;主要是介绍如何从心知天气官网&#xff0c;获取包含当前天气实况和未来 3 天天气预报的JSON数据信息。 在学习获取及显示天气信息前&#xff0c;我们务必要对JSON数据格式有个深入的了解。 如您需要了解其它专题的内容&#xf…