動(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)貼紙是通過(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