《Android 美顏類相機開發(fā)匯總》第四章 Android OpenGLES 動態(tài)貼紙實現(xiàn)

動態(tài)貼紙簡介

動態(tài)貼紙是基于人臉識別SDK的一種應(yīng)用总滩。動態(tài)貼紙最常用的是二維圖像纲堵,也有使用3D 圖像的動態(tài)貼紙,而隨著AR和三維點云技術(shù)的發(fā)展咳秉,目前的AR貼紙也流行了起來婉支。比如抖音鸯隅、快手等短視頻應(yīng)用澜建,或者美顏相機、FaceU激萌等相機類應(yīng)用蝌以。只要涉及圖像音視頻的APP基本上都會涉及炕舵。可見跟畅,動態(tài)貼紙是一種常用的功能咽筋。那么接下來我們來介紹,如何在Android APP中實現(xiàn)動態(tài)貼紙功能徊件,這里僅介紹使用二維圖像構(gòu)建的動態(tài)貼紙奸攻,基于3D圖像和AR技術(shù)構(gòu)建的這兩種動態(tài)貼紙,這里不做介紹虱痕。

動態(tài)貼紙分類

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

總之位谋,這些是二維圖像構(gòu)建的動態(tài)貼紙的常用的器官。我們知道作用之后堰燎,接下來我們需要對各個器官部分進行實現(xiàn)掏父。
由于貼紙有很多種,這里我們只介紹最簡單的貼紙實現(xiàn)秆剪,還有帶彩妝赊淑、瘦臉等的貼紙這里不介紹爵政。為了方便做成動態(tài)下載,我們需要知道貼紙的參數(shù)陶缺。下面來介紹一下如何實現(xiàn)整個貼紙的功能吧

動態(tài)貼紙的實現(xiàn)

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

貼紙要做成動態(tài)下載的钾挟,我們首先需要知道貼紙的類型、名稱饱岸、寬高掺出、偏移量、相對于人臉的縮放比例苫费、人臉的寬度汤锨、貼紙相對于人臉中心點、貼紙幀數(shù)百框、貼紙一幀渲染的時長闲礼、是否帶音樂、是否循環(huán)铐维、貼紙支持的最大人臉數(shù)等基本參數(shù)柬泽。
我們來構(gòu)建這么一個Json,用來記錄動態(tài)貼紙嫁蛇,各個參數(shù)的意義可以參考下面的注釋:

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

有了json棠众,我們接下來就解析json琳疏,代碼如下:

/**
     * 讀取默認(rèn)動態(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 {
                // 如果不是貼紙又不是前景的話,則直接跳過
                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);
        }

渲染動態(tài)貼紙

前面一步闸拿,我們構(gòu)建了動態(tài)貼紙的json空盼,解析得到了動態(tài)貼紙的參數(shù)對象,接下來我們就可以構(gòu)建動態(tài)貼紙的渲染過程了新荤。貼紙的渲染過程無非就是逐個人臉揽趾、逐個貼紙渲染而已,并沒有什么難度苛骨。為了支持偽3D效果篱瞎,模擬遠(yuǎn)小近大的貼紙效果。我們需要從人臉關(guān)鍵點SDK中引入姿態(tài)角來計算貼紙痒芝,結(jié)合前面的貼紙參數(shù)對象俐筋,我們需要構(gòu)建一個視椎體并計算出每一幀貼紙的頂點坐標(biāo),計算過程過程如下:
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)建的視椎體加入的長寬比澄者,主要是為了方便后續(xù)的計算,并且視點(0.0, 0.0, 6.0) 到中心點(0.0, 0.0, 0.0)的距離為視點到近平面(0.0,0.0,3.0f)的兩倍,兩倍主要是為了方便后續(xù)的計算粱挡,你也可以設(shè)置成其他倍數(shù)赠幕,甚至正中心不在z軸上,只不過這樣會導(dǎo)致計算變得非常復(fù)雜询筏。

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

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

        // 1.4、貼紙的寬高在ndc坐標(biāo)系中的長度
        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馆衔、貼紙帶偏移量的錨點的ndc坐標(biāo),即實際貼紙的中心點在OpenGL的頂點坐標(biāo)系中的位置
        float anchorX = ndcCenterX + offsetX * ProjectionScale;
        float anchorY = ndcCenterY + offsetY * ProjectionScale;

        // 1.7怨绣、根據(jù)前面的錨點角溃,計算出貼紙實際的頂點坐標(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)角計算透視變換的總變換矩陣
        // 2.1篮撑、將Z軸平移到貼紙中心點减细,因為貼紙模型矩陣需要做姿態(tài)角變換
        // 平移主要是防止貼紙變形
        Matrix.setIdentityM(mModelMatrix, 0);
        Matrix.translateM(mModelMatrix, 0, ndcCenterX, ndcCenterY, 0);

        // 2.2、貼紙姿態(tài)角旋轉(zhuǎn)
        // TODO 人臉關(guān)鍵點給回來的pitch角度似乎不太對赢笨?未蝌?SDK給過來的pitch角度值太小了,比如抬頭低頭pitch的實際角度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);
        // 限定左右扭頭幅度不超過50°,銷毀人臉關(guān)鍵點SDK帶來的偏差
        if (Math.abs(yawAngle) > 50) {
            yawAngle = (yawAngle / Math.abs(yawAngle)) * 50;
        }
        // 限定抬頭低頭最大角度桐筏,消除人臉關(guān)鍵點SDK帶來的偏差
        if (Math.abs(pitchAngle) > 30) {
            pitchAngle = (pitchAngle / Math.abs(pitchAngle)) * 30;
        }
        // 貼紙姿態(tài)角變換纸型,優(yōu)先z軸變換,消除手機旋轉(zhuǎn)的角度影響,否則會導(dǎo)致扭頭绊袋、抬頭毕匀、低頭時貼紙變形的情況
        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軸平移回到原來構(gòu)建的視椎體的位置癌别,即需要將坐標(biāo)z軸平移回到屏幕中心皂岔,此時才是貼紙的實際模型矩陣
        Matrix.translateM(mModelMatrix, 0, -ndcCenterX, -ndcCenterY, 0);

        // 2.5、計算總變換矩陣展姐。MVPMatrix 的矩陣計算是 MVPMatrix = ProjectionMatrix * ViewMatrix * ModelMatrix
        // 備注:矩陣相乘的順序不同得到的結(jié)果是不一樣的躁垛,不同的順序會導(dǎo)致前面計算過程不一致,這點希望大家要注意
        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;       // 圖像頂點坐標(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)過前面計算得到的mMVPMatrix教馆,就是需要傳遞到shader中總變換矩陣。然后inputTexture就是我們需要繪制的貼紙紋理擂达。至此土铺,貼紙的頂點和變換矩陣我們都算出來了,接下來就是逐個渲染了板鬓。這個沒啥好說的悲敷,就是一張一張紋理渲染上去就好。詳細(xì)過程請看項目中的代碼進行理解俭令。

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


動態(tài)貼紙

備注:該動態(tài)貼紙是通過asset目錄下的壓縮包資源解壓后后德,再從解壓目錄動態(tài)加載得到的。你只需要提供貼紙抄腔、json的壓縮包資源即可瓢湃。這樣我們就可以通過服務(wù)器下載貼紙的壓縮包,解壓后赫蛇,通過選中即可切換動態(tài)貼紙绵患。

動態(tài)貼紙音樂播放功能

經(jīng)過前面一步,我們實現(xiàn)了動態(tài)貼紙的渲染棍掐,接下來我們實現(xiàn)動態(tài)貼紙的音樂播放功能藏雏。有些動態(tài)貼紙會伴隨著音樂的播放。這個也沒啥好說的作煌,比較簡單掘殴,就是用MediaPlayer播放出來就好。

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末粟誓,一起剝皮案震驚了整個濱河市奏寨,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌鹰服,老刑警劉巖病瞳,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件揽咕,死亡現(xiàn)場離奇詭異,居然都是意外死亡套菜,警方通過查閱死者的電腦和手機亲善,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來逗柴,“玉大人蛹头,你說我怎么就攤上這事∠纺纾” “怎么了渣蜗?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長旷祸。 經(jīng)常有香客問我耕拷,道長,這世上最難降的妖魔是什么托享? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任骚烧,我火速辦了婚禮,結(jié)果婚禮上嫌吠,老公的妹妹穿的比我還像新娘止潘。我一直安慰自己掺炭,他們只是感情好辫诅,可當(dāng)我...
    茶點故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著涧狮,像睡著了一般炕矮。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上者冤,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天肤视,我揣著相機與錄音,去河邊找鬼涉枫。 笑死邢滑,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的愿汰。 我是一名探鬼主播困后,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼衬廷!你這毒婦竟也來了摇予?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤吗跋,失蹤者是張志新(化名)和其女友劉穎侧戴,沒想到半個月后宁昭,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡酗宋,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年积仗,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蜕猫。...
    茶點故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡斥扛,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出丹锹,到底是詐尸還是另有隱情稀颁,我是刑警寧澤,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布楣黍,位于F島的核電站匾灶,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏租漂。R本人自食惡果不足惜阶女,卻給世界環(huán)境...
    茶點故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望哩治。 院中可真熱鬧秃踩,春花似錦、人聲如沸业筏。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蒜胖。三九已至消别,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間台谢,已是汗流浹背寻狂。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留朋沮,地道東北人蛇券。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像樊拓,于是被迫代替她去往敵國和親纠亚。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內(nèi)容