《Android 美顏類相機(jī)開(kāi)發(fā)匯總》第四章 Android OpenGLES 動(dòng)態(tài)貼紙實(shí)現(xiàn)

動(dòng)態(tài)貼紙簡(jiǎn)介

動(dòng)態(tài)貼紙是基于人臉識(shí)別SDK的一種應(yīng)用。動(dòng)態(tài)貼紙最常用的是二維圖像,也有使用3D 圖像的動(dòng)態(tài)貼紙,而隨著AR和三維點(diǎn)云技術(shù)的發(fā)展,目前的AR貼紙也流行了起來(lái)。比如抖音、快手等短視頻應(yīng)用,或者美顏相機(jī)、FaceU激萌等相機(jī)類應(yīng)用。只要涉及圖像音視頻的APP基本上都會(huì)涉及??梢?jiàn),動(dòng)態(tài)貼紙是一種常用的功能。那么接下來(lái)我們來(lái)介紹,如何在Android APP中實(shí)現(xiàn)動(dòng)態(tài)貼紙功能,這里僅介紹使用二維圖像構(gòu)建的動(dòng)態(tài)貼紙,基于3D圖像和AR技術(shù)構(gòu)建的這兩種動(dòng)態(tài)貼紙,這里不做介紹。

動(dòng)態(tài)貼紙分類

由于動(dòng)態(tài)貼紙是基于人臉識(shí)別SDK構(gòu)建的功能,那么動(dòng)態(tài)貼紙又會(huì)涉及到人臉的各個(gè)器官。對(duì)此,我們需要對(duì)動(dòng)態(tài)貼紙進(jìn)行分類,分類如下:
頭頂、耳朵、眼睛、臉頰、鼻子、下巴、脖子、前景等:
頭頂 —— 一般是指頭頂中心,頭頂中心有可能會(huì)放一些帽子之類的貼紙
耳朵 —— 耳朵也放在額頭上方,就跟動(dòng)漫中娘化動(dòng)物的耳朵一樣
眼睛 —— 一般用于眨眼等總眼角等地方噴出花朵、貼合眼淚等功能的實(shí)現(xiàn)
臉頰 —— 一般會(huì)用來(lái)處理貼紙的腮紅等功能
鼻子 —— 通常會(huì)貼合胡須等
脖子 —— 用來(lái)處理圍脖之類的裝飾
前景 —— 一般會(huì)用來(lái)模擬相框,就跟2005年前后的中學(xué)流行拍大頭貼那樣

總之,這些是二維圖像構(gòu)建的動(dòng)態(tài)貼紙的常用的器官。我們知道作用之后,接下來(lái)我們需要對(duì)各個(gè)器官部分進(jìn)行實(shí)現(xiàn)。
由于貼紙有很多種,這里我們只介紹最簡(jiǎn)單的貼紙實(shí)現(xiàn),還有帶彩妝、瘦臉等的貼紙這里不介紹。為了方便做成動(dòng)態(tài)下載,我們需要知道貼紙的參數(shù)。下面來(lái)介紹一下如何實(shí)現(xiàn)整個(gè)貼紙的功能吧

動(dòng)態(tài)貼紙的實(shí)現(xiàn)

動(dòng)態(tài)貼紙參數(shù)Json構(gòu)建

貼紙要做成動(dòng)態(tài)下載的,我們首先需要知道貼紙的類型、名稱、寬高、偏移量、相對(duì)于人臉的縮放比例、人臉的寬度、貼紙相對(duì)于人臉中心點(diǎn)、貼紙幀數(shù)、貼紙一幀渲染的時(shí)長(zhǎng)、是否帶音樂(lè)、是否循環(huán)、貼紙支持的最大人臉數(shù)等基本參數(shù)。
我們來(lái)構(gòu)建這么一個(gè)Json,用來(lái)記錄動(dòng)態(tài)貼紙,各個(gè)參數(shù)的意義可以參考下面的注釋:

{
    "stickerList": [{
        "type": "sticker",      // 貼紙類型,sticker表示普通貼紙
        "centerIndexList": [43],// 貼紙中心點(diǎn)列表
        "offsetX": 0,           // 貼紙x軸偏移量
        "offsetY": 0.03984,     // 貼紙y軸偏移量
        "baseScale": 1.7602,    // 貼紙縮放倍數(shù)(相對(duì)于人臉)
        "startIndex": 6,        // 人臉起始位置
        "endIndex": 26,         // 人臉結(jié)束位置,起始位置和結(jié)束位置用于求人臉寬度的
        "width": 345,           // 貼紙寬度
        "height": 251,          // 貼紙高度
        "frames": 12,           // 貼紙幀數(shù)
        "action": 0,            // 貼紙動(dòng)作
        "stickerName": "face",  // 貼紙名稱
        "duration": 50,         // 貼紙一幀的時(shí)間間隔
        "stickerLooping": 1,    // 是否循環(huán)播放
        "audioPath": "",        // 音樂(lè)路徑
        "audioLooping": 1,      // 音樂(lè)是否循環(huán)播放
        "maxcount": 5           // 貼紙最大支持人臉數(shù)
    }, {
        "type": "frame",        // 貼紙類型,frame表示前景
        "alignMode":1,          // 對(duì)齊方式
        "width": 360,           // 貼紙寬度
        "height": 549,          // 貼紙高度
        "frames": 56,           // 貼紙幀數(shù)
        "action": 0,            // 貼紙動(dòng)作
        "stickerName": "frame",    // 貼紙名稱
        "duration": 50,         // 貼紙一幀的時(shí)間間隔
        "stickerLooping": 1,    // 貼紙是否循環(huán)播放
        "audioPath": "",        // 音樂(lè)路徑
        "audioLooping": 1,      // 音樂(lè)是否循環(huán)播放
        "maxcount": 5           // 貼紙支持最大人臉數(shù)
    }]
}

有了json,我們接下來(lái)就解析json,代碼如下:

/**
     * 讀取默認(rèn)動(dòng)態(tài)貼紙數(shù)據(jù)
     * @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 {
                // 如果不是貼紙又不是前景的話,則直接跳過(guò)
                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);
        }

渲染動(dòng)態(tài)貼紙

前面一步,我們構(gòu)建了動(dòng)態(tài)貼紙的json,解析得到了動(dòng)態(tài)貼紙的參數(shù)對(duì)象,接下來(lái)我們就可以構(gòu)建動(dòng)態(tài)貼紙的渲染過(guò)程了。貼紙的渲染過(guò)程無(wú)非就是逐個(gè)人臉、逐個(gè)貼紙渲染而已,并沒(méi)有什么難度。為了支持偽3D效果,模擬遠(yuǎn)小近大的貼紙效果。我們需要從人臉關(guān)鍵點(diǎn)SDK中引入姿態(tài)角來(lái)計(jì)算貼紙,結(jié)合前面的貼紙參數(shù)對(duì)象,我們需要構(gòu)建一個(gè)視椎體并計(jì)算出每一幀貼紙的頂點(diǎn)坐標(biāo),計(jì)算過(guò)程過(guò)程如下:
1、構(gòu)建視椎體:

@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);
    }

這里構(gòu)建的視椎體加入的長(zhǎng)寬比,主要是為了方便后續(xù)的計(jì)算,并且視點(diǎn)(0.0, 0.0, 6.0) 到中心點(diǎn)(0.0, 0.0, 0.0)的距離為視點(diǎn)到近平面(0.0,0.0,3.0f)的兩倍,兩倍主要是為了方便后續(xù)的計(jì)算,你也可以設(shè)置成其他倍數(shù),甚至正中心不在z軸上,只不過(guò)這樣會(huì)導(dǎo)致計(jì)算變得非常復(fù)雜。

2、計(jì)算貼紙頂點(diǎn)和總變換矩陣
經(jīng)過(guò)前面的視椎體構(gòu)建,我們得到了貼紙?jiān)谌S空間中的假想位置,接下來(lái)我們需要在這基礎(chǔ)上構(gòu)建貼紙的頂點(diǎn)以及根據(jù)人臉關(guān)鍵點(diǎn)SDK給過(guò)來(lái)的姿態(tài)角做矩陣變換。頂點(diǎn)坐標(biāo)的計(jì)算需要結(jié)合前面的貼紙參數(shù)對(duì)象進(jìn)行計(jì)算。整個(gè)計(jì)算過(guò)程如下:

 /**
     * 更新貼紙頂點(diǎn)
     * TODO 待優(yōu)化的點(diǎn):消除姿態(tài)角誤差、姿態(tài)角給貼紙偏移量造成的誤差
     * @param stickerData
     */
    private void calculateStickerVertices(DynamicStickerNormalData stickerData, OneFace oneFace) {
        if (oneFace == null || oneFace.vertexPoints == null) {
            return;
        }
        // 步驟一、計(jì)算貼紙的中心點(diǎn)和頂點(diǎn)坐標(biāo)
        // 備注:由于frustumM設(shè)置的bottom 和top 為 -1.0 和 1.0,這里為了方便計(jì)算,直接用高度作為基準(zhǔn)值來(lái)計(jì)算
        // 1.1、計(jì)算貼紙相對(duì)于人臉的寬高
        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、根據(jù)貼紙的參數(shù)計(jì)算出中心點(diǎn)的坐標(biāo)
        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、求出真正的中心點(diǎn)頂點(diǎn)坐標(biāo),這里由于frustumM設(shè)置了長(zhǎng)寬比,因此ndc坐標(biāo)計(jì)算時(shí)需要變成mRatio:1,這里需要轉(zhuǎn)換一下
        float ndcCenterX = (centerX - mRatio) * ProjectionScale;
        float ndcCenterY = (centerY - 1.0f) * ProjectionScale;

        // 1.4、貼紙的寬高在ndc坐標(biāo)系中的長(zhǎng)度
        float ndcStickerWidth = stickerWidth / mImageHeight * ProjectionScale;
        float ndcStickerHeight = ndcStickerWidth * (float) stickerData.height / (float) stickerData.width;

        // 1.5、根據(jù)貼紙參數(shù)求偏移的ndc坐標(biāo)
        float offsetX = (stickerWidth * stickerData.offsetX) / mImageHeight * ProjectionScale;
        float offsetY = (stickerHeight * stickerData.offsetY) / mImageHeight * ProjectionScale;

        // 1.6、貼紙帶偏移量的錨點(diǎn)的ndc坐標(biāo),即實(shí)際貼紙的中心點(diǎn)在OpenGL的頂點(diǎn)坐標(biāo)系中的位置
        float anchorX = ndcCenterX + offsetX * ProjectionScale;
        float anchorY = ndcCenterY + offsetY * ProjectionScale;

        // 1.7、根據(jù)前面的錨點(diǎn),計(jì)算出貼紙實(shí)際的頂點(diǎn)坐標(biāo)
        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);

        // 步驟二、根據(jù)人臉姿態(tài)角計(jì)算透視變換的總變換矩陣
        // 2.1、將Z軸平移到貼紙中心點(diǎn),因?yàn)橘N紙模型矩陣需要做姿態(tài)角變換
        // 平移主要是防止貼紙變形
        Matrix.setIdentityM(mModelMatrix, 0);
        Matrix.translateM(mModelMatrix, 0, ndcCenterX, ndcCenterY, 0);

        // 2.2、貼紙姿態(tài)角旋轉(zhuǎn)
        // TODO 人臉關(guān)鍵點(diǎn)給回來(lái)的pitch角度似乎不太對(duì)??SDK給過(guò)來(lái)的pitch角度值太小了,比如抬頭低頭pitch的實(shí)際角度30度了,SDK返回的結(jié)果才十幾度,后續(xù)再看看如何優(yōu)化
        float pitchAngle = -(float) (oneFace.pitch * 180f / Math.PI);
        float yawAngle = (float) (oneFace.yaw * 180f / Math.PI);
        float rollAngle = (float) (oneFace.roll * 180f / Math.PI);
        // 限定左右扭頭幅度不超過(guò)50°,銷毀人臉關(guān)鍵點(diǎn)SDK帶來(lái)的偏差
        if (Math.abs(yawAngle) > 50) {
            yawAngle = (yawAngle / Math.abs(yawAngle)) * 50;
        }
        // 限定抬頭低頭最大角度,消除人臉關(guān)鍵點(diǎn)SDK帶來(lái)的偏差
        if (Math.abs(pitchAngle) > 30) {
            pitchAngle = (pitchAngle / Math.abs(pitchAngle)) * 30;
        }
        // 貼紙姿態(tài)角變換,優(yōu)先z軸變換,消除手機(jī)旋轉(zhuǎn)的角度影響,否則會(huì)導(dǎo)致扭頭、抬頭、低頭時(shí)貼紙變形的情況
        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軸平移回到原來(lái)構(gòu)建的視椎體的位置,即需要將坐標(biāo)z軸平移回到屏幕中心,此時(shí)才是貼紙的實(shí)際模型矩陣
        Matrix.translateM(mModelMatrix, 0, -ndcCenterX, -ndcCenterY, 0);

        // 2.5、計(jì)算總變換矩陣。MVPMatrix 的矩陣計(jì)算是 MVPMatrix = ProjectionMatrix * ViewMatrix * ModelMatrix
        // 備注:矩陣相乘的順序不同得到的結(jié)果是不一樣的,不同的順序會(huì)導(dǎo)致前面計(jì)算過(guò)程不一致,這點(diǎn)希望大家要注意
        Matrix.setIdentityM(mMVPMatrix, 0);
        Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mViewMatrix, 0);
        Matrix.multiplyMM(mMVPMatrix, 0, mMVPMatrix, 0, mModelMatrix, 0);
    }

整個(gè)shader就很簡(jiǎn)單,如下:
vertex shader 如下:

uniform mat4 uMVPMatrix;        // 變換矩陣
attribute vec4 aPosition;       // 圖像頂點(diǎn)坐標(biāo)
attribute vec4 aTextureCoord;   // 圖像紋理坐標(biāo)

varying vec2 textureCoordinate; // 圖像紋理坐標(biāo)

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);
}

經(jīng)過(guò)前面計(jì)算得到的mMVPMatrix,就是需要傳遞到shader中總變換矩陣。然后inputTexture就是我們需要繪制的貼紙紋理。至此,貼紙的頂點(diǎn)和變換矩陣我們都算出來(lái)了,接下來(lái)就是逐個(gè)渲染了。這個(gè)沒(méi)啥好說(shuō)的,就是一張一張紋理渲染上去就好。詳細(xì)過(guò)程請(qǐng)看項(xiàng)目中的代碼進(jìn)行理解。

實(shí)現(xiàn)的效果如下:


動(dòng)態(tài)貼紙

備注:該動(dòng)態(tài)貼紙是通過(guò)asset目錄下的壓縮包資源解壓后,再?gòu)慕鈮耗夸泟?dòng)態(tài)加載得到的。你只需要提供貼紙、json的壓縮包資源即可。這樣我們就可以通過(guò)服務(wù)器下載貼紙的壓縮包,解壓后,通過(guò)選中即可切換動(dòng)態(tài)貼紙。

動(dòng)態(tài)貼紙音樂(lè)播放功能

經(jīng)過(guò)前面一步,我們實(shí)現(xiàn)了動(dòng)態(tài)貼紙的渲染,接下來(lái)我們實(shí)現(xiàn)動(dòng)態(tài)貼紙的音樂(lè)播放功能。有些動(dòng)態(tài)貼紙會(huì)伴隨著音樂(lè)的播放。這個(gè)也沒(méi)啥好說(shuō)的,比較簡(jiǎn)單,就是用MediaPlayer播放出來(lái)就好。

詳細(xì)實(shí)現(xiàn)可以參考本人的開(kāi)源項(xiàng)目:
CainCamera

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容