《Android 美颜类相机开发汇总》第四章 Android OpenGLES 动态贴纸实现

96
cain_huang
1.2 2018.11.19 00:44 字数 1572

动态贴纸简介

动态贴纸是基于人脸识别SDK的一种应用。动态贴纸最常用的是二维图像,也有使用3D 图像的动态贴纸,而随着AR和三维点云技术的发展,目前的AR贴纸也流行了起来。比如抖音、快手等短视频应用,或者美颜相机、FaceU激萌等相机类应用。只要涉及图像音视频的APP基本上都会涉及。可见,动态贴纸是一种常用的功能。那么接下来我们来介绍,如何在Android APP中实现动态贴纸功能,这里仅介绍使用二维图像构建的动态贴纸,基于3D图像和AR技术构建的这两种动态贴纸,这里不做介绍。

动态贴纸分类

由于动态贴纸是基于人脸识别SDK构建的功能,那么动态贴纸又会涉及到人脸的各个器官。对此,我们需要对动态贴纸进行分类,分类如下:
头顶、耳朵、眼睛、脸颊、鼻子、下巴、脖子、前景等:
头顶 —— 一般是指头顶中心,头顶中心有可能会放一些帽子之类的贴纸
耳朵 —— 耳朵也放在额头上方,就跟动漫中娘化动物的耳朵一样
眼睛 —— 一般用于眨眼等总眼角等地方喷出花朵、贴合眼泪等功能的实现
脸颊 —— 一般会用来处理贴纸的腮红等功能
鼻子 —— 通常会贴合胡须等
脖子 —— 用来处理围脖之类的装饰
前景 —— 一般会用来模拟相框,就跟2005年前后的中学流行拍大头贴那样

总之,这些是二维图像构建的动态贴纸的常用的器官。我们知道作用之后,接下来我们需要对各个器官部分进行实现。
由于贴纸有很多种,这里我们只介绍最简单的贴纸实现,还有带彩妆、瘦脸等的贴纸这里不介绍。为了方便做成动态下载,我们需要知道贴纸的参数。下面来介绍一下如何实现整个贴纸的功能吧

动态贴纸的实现

动态贴纸参数Json构建

贴纸要做成动态下载的,我们首先需要知道贴纸的类型、名称、宽高、偏移量、相对于人脸的缩放比例、人脸的宽度、贴纸相对于人脸中心点、贴纸帧数、贴纸一帧渲染的时长、是否带音乐、是否循环、贴纸支持的最大人脸数等基本参数。
我们来构建这么一个Json,用来记录动态贴纸,各个参数的意义可以参考下面的注释:

{
    "stickerList": [{
        "type": "sticker",      // 贴纸类型,sticker表示普通贴纸
        "centerIndexList": [43],// 贴纸中心点列表
        "offsetX": 0,           // 贴纸x轴偏移量
        "offsetY": 0.03984,     // 贴纸y轴偏移量
        "baseScale": 1.7602,    // 贴纸缩放倍数(相对于人脸)
        "startIndex": 6,        // 人脸起始位置
        "endIndex": 26,         // 人脸结束位置,起始位置和结束位置用于求人脸宽度的
        "width": 345,           // 贴纸宽度
        "height": 251,          // 贴纸高度
        "frames": 12,           // 贴纸帧数
        "action": 0,            // 贴纸动作
        "stickerName": "face",  // 贴纸名称
        "duration": 50,         // 贴纸一帧的时间间隔
        "stickerLooping": 1,    // 是否循环播放
        "audioPath": "",        // 音乐路径
        "audioLooping": 1,      // 音乐是否循环播放
        "maxcount": 5           // 贴纸最大支持人脸数
    }, {
        "type": "frame",        // 贴纸类型,frame表示前景
        "alignMode":1,          // 对齐方式
        "width": 360,           // 贴纸宽度
        "height": 549,          // 贴纸高度
        "frames": 56,           // 贴纸帧数
        "action": 0,            // 贴纸动作
        "stickerName": "frame",    // 贴纸名称
        "duration": 50,         // 贴纸一帧的时间间隔
        "stickerLooping": 1,    // 贴纸是否循环播放
        "audioPath": "",        // 音乐路径
        "audioLooping": 1,      // 音乐是否循环播放
        "maxcount": 5           // 贴纸支持最大人脸数
    }]
}

有了json,我们接下来就解析json,代码如下:

/**
     * 读取默认动态贴纸数据
     * @param folderPath      json文件所在文件夹路径
     * @return
     * @throws IOException
     * @throws JSONException
     */
    public static DynamicSticker decodeStickerData(String folderPath)
            throws IOException, JSONException {
        File file = new File(folderPath, "json");
        String stickerJson = FileUtils.convertToString(new FileInputStream(file));

        JSONObject jsonObject = new JSONObject(stickerJson);
        DynamicSticker dynamicSticker = new DynamicSticker();
        dynamicSticker.unzipPath = folderPath;
        if (dynamicSticker.dataList == null) {
            dynamicSticker.dataList = new ArrayList<>();
        }

        JSONArray stickerList = jsonObject.getJSONArray("stickerList");
        for (int i = 0; i < stickerList.length(); i++) {
            JSONObject jsonData = stickerList.getJSONObject(i);
            String type = jsonData.getString("type");
            DynamicStickerData data;
            if ("sticker".equals(type)) {
                data = new DynamicStickerNormalData();
                JSONArray centerIndexList = jsonData.getJSONArray("centerIndexList");
                ((DynamicStickerNormalData) data).centerIndexList = new int[centerIndexList.length()];
                for (int j = 0; j < centerIndexList.length(); j++) {
                    ((DynamicStickerNormalData) data).centerIndexList[j] = centerIndexList.getInt(j);
                }
                ((DynamicStickerNormalData) data).offsetX = (float) jsonData.getDouble("offsetX");
                ((DynamicStickerNormalData) data).offsetY = (float) jsonData.getDouble("offsetY");
                ((DynamicStickerNormalData) data).baseScale = (float) jsonData.getDouble("baseScale");
                ((DynamicStickerNormalData) data).startIndex = jsonData.getInt("startIndex");
                ((DynamicStickerNormalData) data).endIndex = jsonData.getInt("endIndex");
            } else {
                // 如果不是贴纸又不是前景的话,则直接跳过
                if (!"frame".equals(type)) {
                    continue;
                }
                data = new DynamicStickerFrameData();
                ((DynamicStickerFrameData) data).alignMode = jsonData.getInt("alignMode");
            }
            DynamicStickerData stickerData = data;
            stickerData.width = jsonData.getInt("width");
            stickerData.height = jsonData.getInt("height");
            stickerData.frames = jsonData.getInt("frames");
            stickerData.action = jsonData.getInt("action");
            stickerData.stickerName = jsonData.getString("stickerName");
            stickerData.duration = jsonData.getInt("duration");
            stickerData.stickerLooping = (jsonData.getInt("stickerLooping") == 1);
            stickerData.audioPath = jsonData.optString("audioPath");
            stickerData.audioLooping = (jsonData.optInt("audioLooping", 0) == 1);
            stickerData.maxCount = jsonData.optInt("maxCount", 5);

            dynamicSticker.dataList.add(stickerData);
        }

渲染动态贴纸

前面一步,我们构建了动态贴纸的json,解析得到了动态贴纸的参数对象,接下来我们就可以构建动态贴纸的渲染过程了。贴纸的渲染过程无非就是逐个人脸、逐个贴纸渲染而已,并没有什么难度。为了支持伪3D效果,模拟远小近大的贴纸效果。我们需要从人脸关键点SDK中引入姿态角来计算贴纸,结合前面的贴纸参数对象,我们需要构建一个视椎体并计算出每一帧贴纸的顶点坐标,计算过程过程如下:
1、构建视椎体:

@Override
    public void onInputSizeChanged(int width, int height) {
        super.onInputSizeChanged(width, height);
        mRatio = (float) width / height;
        Matrix.frustumM(mProjectionMatrix, 0, -mRatio, mRatio, -1.0f, 1.0f, 3.0f, 9.0f);
        Matrix.setLookAtM(mViewMatrix, 0, 0, 0, 6.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f);
    }

这里构建的视椎体加入的长宽比,主要是为了方便后续的计算,并且视点(0.0, 0.0, 6.0) 到中心点(0.0, 0.0, 0.0)的距离为视点到近平面(0.0,0.0,3.0f)的两倍,两倍主要是为了方便后续的计算,你也可以设置成其他倍数,甚至正中心不在z轴上,只不过这样会导致计算变得非常复杂。

2、计算贴纸顶点和总变换矩阵
经过前面的视椎体构建,我们得到了贴纸在三维空间中的假想位置,接下来我们需要在这基础上构建贴纸的顶点以及根据人脸关键点SDK给过来的姿态角做矩阵变换。顶点坐标的计算需要结合前面的贴纸参数对象进行计算。整个计算过程如下:

 /**
     * 更新贴纸顶点
     * TODO 待优化的点:消除姿态角误差、姿态角给贴纸偏移量造成的误差
     * @param stickerData
     */
    private void calculateStickerVertices(DynamicStickerNormalData stickerData, OneFace oneFace) {
        if (oneFace == null || oneFace.vertexPoints == null) {
            return;
        }
        // 步骤一、计算贴纸的中心点和顶点坐标
        // 备注:由于frustumM设置的bottom 和top 为 -1.0 和 1.0,这里为了方便计算,直接用高度作为基准值来计算
        // 1.1、计算贴纸相对于人脸的宽高
        float stickerWidth = (float) FacePointsUtils.getDistance(
                (oneFace.vertexPoints[stickerData.startIndex * 2] * 0.5f + 0.5f) * mImageWidth,
                (oneFace.vertexPoints[stickerData.startIndex * 2 + 1] * 0.5f + 0.5f) * mImageHeight,
                (oneFace.vertexPoints[stickerData.endIndex * 2] * 0.5f + 0.5f) * mImageWidth,
                (oneFace.vertexPoints[stickerData.endIndex * 2 + 1] * 0.5f + 0.5f) * mImageHeight) * stickerData.baseScale;
        float stickerHeight = stickerWidth * (float) stickerData.height / (float) stickerData.width;

        // 1.2、根据贴纸的参数计算出中心点的坐标
        float centerX = 0.0f;
        float centerY = 0.0f;
        for (int i = 0; i < stickerData.centerIndexList.length; i++) {
            centerX += (oneFace.vertexPoints[stickerData.centerIndexList[i] * 2] * 0.5f + 0.5f) * mImageWidth;
            centerY += (oneFace.vertexPoints[stickerData.centerIndexList[i] * 2 + 1] * 0.5f + 0.5f) * mImageHeight;
        }
        centerX /= (float) stickerData.centerIndexList.length;
        centerY /= (float) stickerData.centerIndexList.length;
        centerX = centerX / mImageHeight * ProjectionScale;
        centerY = centerY / mImageHeight * ProjectionScale;
        // 1.3、求出真正的中心点顶点坐标,这里由于frustumM设置了长宽比,因此ndc坐标计算时需要变成mRatio:1,这里需要转换一下
        float ndcCenterX = (centerX - mRatio) * ProjectionScale;
        float ndcCenterY = (centerY - 1.0f) * ProjectionScale;

        // 1.4、贴纸的宽高在ndc坐标系中的长度
        float ndcStickerWidth = stickerWidth / mImageHeight * ProjectionScale;
        float ndcStickerHeight = ndcStickerWidth * (float) stickerData.height / (float) stickerData.width;

        // 1.5、根据贴纸参数求偏移的ndc坐标
        float offsetX = (stickerWidth * stickerData.offsetX) / mImageHeight * ProjectionScale;
        float offsetY = (stickerHeight * stickerData.offsetY) / mImageHeight * ProjectionScale;

        // 1.6、贴纸带偏移量的锚点的ndc坐标,即实际贴纸的中心点在OpenGL的顶点坐标系中的位置
        float anchorX = ndcCenterX + offsetX * ProjectionScale;
        float anchorY = ndcCenterY + offsetY * ProjectionScale;

        // 1.7、根据前面的锚点,计算出贴纸实际的顶点坐标
        mStickerVertices[0] = anchorX - ndcStickerWidth; mStickerVertices[1] = anchorY - ndcStickerHeight;
        mStickerVertices[2] = anchorX + ndcStickerWidth; mStickerVertices[3] = anchorY - ndcStickerHeight;
        mStickerVertices[4] = anchorX - ndcStickerWidth; mStickerVertices[5] = anchorY + ndcStickerHeight;
        mStickerVertices[6] = anchorX + ndcStickerWidth; mStickerVertices[7] = anchorY + ndcStickerHeight;
        mVertexBuffer.clear();
        mVertexBuffer.position(0);
        mVertexBuffer.put(mStickerVertices);

        // 步骤二、根据人脸姿态角计算透视变换的总变换矩阵
        // 2.1、将Z轴平移到贴纸中心点,因为贴纸模型矩阵需要做姿态角变换
        // 平移主要是防止贴纸变形
        Matrix.setIdentityM(mModelMatrix, 0);
        Matrix.translateM(mModelMatrix, 0, ndcCenterX, ndcCenterY, 0);

        // 2.2、贴纸姿态角旋转
        // TODO 人脸关键点给回来的pitch角度似乎不太对??SDK给过来的pitch角度值太小了,比如抬头低头pitch的实际角度30度了,SDK返回的结果才十几度,后续再看看如何优化
        float pitchAngle = -(float) (oneFace.pitch * 180f / Math.PI);
        float yawAngle = (float) (oneFace.yaw * 180f / Math.PI);
        float rollAngle = (float) (oneFace.roll * 180f / Math.PI);
        // 限定左右扭头幅度不超过50°,销毁人脸关键点SDK带来的偏差
        if (Math.abs(yawAngle) > 50) {
            yawAngle = (yawAngle / Math.abs(yawAngle)) * 50;
        }
        // 限定抬头低头最大角度,消除人脸关键点SDK带来的偏差
        if (Math.abs(pitchAngle) > 30) {
            pitchAngle = (pitchAngle / Math.abs(pitchAngle)) * 30;
        }
        // 贴纸姿态角变换,优先z轴变换,消除手机旋转的角度影响,否则会导致扭头、抬头、低头时贴纸变形的情况
        Matrix.rotateM(mModelMatrix, 0, rollAngle, 0, 0, 1);
        Matrix.rotateM(mModelMatrix, 0, yawAngle, 0, 1, 0);
        Matrix.rotateM(mModelMatrix, 0, pitchAngle, 1, 0, 0);

        // 2.4、将Z轴平移回到原来构建的视椎体的位置,即需要将坐标z轴平移回到屏幕中心,此时才是贴纸的实际模型矩阵
        Matrix.translateM(mModelMatrix, 0, -ndcCenterX, -ndcCenterY, 0);

        // 2.5、计算总变换矩阵。MVPMatrix 的矩阵计算是 MVPMatrix = ProjectionMatrix * ViewMatrix * ModelMatrix
        // 备注:矩阵相乘的顺序不同得到的结果是不一样的,不同的顺序会导致前面计算过程不一致,这点希望大家要注意
        Matrix.setIdentityM(mMVPMatrix, 0);
        Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mViewMatrix, 0);
        Matrix.multiplyMM(mMVPMatrix, 0, mMVPMatrix, 0, mModelMatrix, 0);
    }

整个shader就很简单,如下:
vertex shader 如下:

uniform mat4 uMVPMatrix;        // 变换矩阵
attribute vec4 aPosition;       // 图像顶点坐标
attribute vec4 aTextureCoord;   // 图像纹理坐标

varying vec2 textureCoordinate; // 图像纹理坐标

void main() {
    gl_Position = uMVPMatrix * aPosition;
    textureCoordinate = aTextureCoord.xy;
}

fragment shader 如下:

precision mediump float;
varying vec2 textureCoordinate;
uniform sampler2D inputTexture;

void main() {
    gl_FragColor = texture2D(inputTexture, textureCoordinate);
}

经过前面计算得到的mMVPMatrix,就是需要传递到shader中总变换矩阵。然后inputTexture就是我们需要绘制的贴纸纹理。至此,贴纸的顶点和变换矩阵我们都算出来了,接下来就是逐个渲染了。这个没啥好说的,就是一张一张纹理渲染上去就好。详细过程请看项目中的代码进行理解。

实现的效果如下:


动态贴纸

备注:该动态贴纸是通过asset目录下的压缩包资源解压后,再从解压目录动态加载得到的。你只需要提供贴纸、json的压缩包资源即可。这样我们就可以通过服务器下载贴纸的压缩包,解压后,通过选中即可切换动态贴纸。

动态贴纸音乐播放功能

经过前面一步,我们实现了动态贴纸的渲染,接下来我们实现动态贴纸的音乐播放功能。有些动态贴纸会伴随着音乐的播放。这个也没啥好说的,比较简单,就是用MediaPlayer播放出来就好。

详细实现可以参考本人的开源项目:
CainCamera