項目介紹
由於疫情的影響,我們學校無法進行正常的畢業照流程,所以學院找到了我,希望能夠開發一款自動合成的畢業照的小程序。
解決方案
- 前端:小程序開發
- 後臺:知曉雲
- 人臉融合接口:騰訊雲人臉融合
體驗
由於騰訊雲的人臉融合需要付費,所以能夠體驗的時間爲2020年6月-2020年7月
微信搜索“廣工雲畢業”即可進行
第一步:界面設計
界面設計
-
頂部隱藏欄
微信有一個API
接口可以獲取頂部狀態欄的高度。wx.getSystemInfoSync().statusBarHeight
這樣就可以避免微信的UI擋住了功能區。
-
背景圖
由於<img>
標籤不可以嵌套內容,而且小程序的css
不可以使用URL
引用本地文件,但是由於背景圖是每一個頁面不變的,如果使用網絡路徑的話,就會導致加載是白屏時間變長,並且由於小程序中的所有的請求都是經過代理的,所以就會導致每次切換頁面獲取不到緩存文件,每次背景圖都需要重新請求,導致服務器的流量不必要的消耗。
解決方法:- 把圖片轉變成
base64
格式 - 由於小程序的2M的限制,儘量壓縮背景圖大小
- 把圖片轉變成
-
手機屏幕適配
主要使用微信官方提供的rpx
來進行設置,爲了保持圖片的比例,無論是高度與寬度都使用rpx
來進行設置。
好處:這樣就基本可以不用考慮了y軸適配的問題
缺點:X軸的高度就需要進行設置滾動區域來進行適配了。 -
組件開發
在開發過程中,會發現很多重複的樣式,我們把這些高類似的樣式進行提取,做成組件,這樣會大大節省我們的開發時間,並且也會爲未來增加功能帶來便利。
比如:在一開始開發的時候,按鈕是沒有動畫的,如果我們沒有提取成組件的話,這樣我們就需要一一對每一個按鈕進行重複地添加。而如果我們提取成組件的話,只需要修改組件就可以一下完成所有按鈕的動畫添加。
例如不同大小按鈕
,圖片選擇器
,文字選擇器
等
我這裏就拿文字選擇器
來舉例:
文字選擇器有- 選取狀態與爲選取狀態,
- 不定量的選擇
- 點擊觸發鉤子
<view class='select-layout' > <text wx:for='{{_list}}' wx:for-item='item' wx:for-index='index' wx:key='item' bindtap="onTap" class='{{item.selectStatus?"is-select":""}}' data-value='{{item.value}}' data-index='{{index}}' > {{item.label.length>2?item.label:(item.label)}} </text> </view>
/* component/textListBtn/testListBtn.wxss */ .select-layout{ display: flex; justify-content: space-around; align-items: center; padding:0 60rpx ; box-sizing: border-box; height: 110rpx; margin:20rpx 0; width: 750rpx; } .is-select{ border-bottom: 3px solid #DEA54B; }
"use strict"; Component({ lifetimes: { // 初始化文字內容與狀態 attached: function () { let _list = []; let index = 0; for (let i of this.properties.list) { let obj = Object.assign({ selectStatus: false }, i); if (index === this.properties.index) { obj.selectStatus = true; } _list.push(obj); index++; } console.log(_list); this.setData({ _list, currentSelect: this.properties.index }); } }, properties: { // 傳入的文字list list: Array, // 默認選取的index index: Number }, data: { // 內部的btn list 用於渲染 _list: new Array(), // 內部的“雙向綁定”選取index currentSelect: 0, }, methods: { // 點擊事件 onTap(e) { let { value, index } = e.currentTarget.dataset; if (index !== this.data.currentSelect) { var myEventDetail = { value, index }; // 設置選取文字顯示 let arr = this.data._list; arr[index].selectStatus = true; arr[this.data.currentSelect].selectStatus = false; this.setData({ currentSelect: index, _list: arr }); // 觸發父組件的事件鉤子 this.triggerEvent('tapEvent', myEventDetail); } } } });
父組件使用:
<ui-text-btn list='{{clothingLabelBtn}}' index="{{previewIndex}}" bind:tapEvent='textBtnClick' />
//值 data:{ previewIndex:0, clothingLabelBtn: [ { label: '男生', value: 'men' }, { label: '女生', value: 'women' }] }
功能主體
1. 學士服人臉融合
流程圖
前端展示組件
由於學士服模特需要拖動,使用的是movable-area
與movable-view
這2個組件。
代碼示例:
<movable-area id='movable-area-edit'
style="position:relative;width:625rpx;height:415rpx;margin:0 auto;">
<movable-view style="width:100%" disable='false'> <image src='{{selectBackgroundImg}}' /></movable-view>
<movable-view
data-type='clothing'
direction='all'
bindchange='bindchange'
x='{{clothingImgX}}' y='{{clothingImgY}}'
style="width:{{previewWidth}}rpx;height:{{previewHeight}}rpx;">
<image
src='{{lifeState===lifeStateEditImage?cropperImageSrc:selectClothingImg}}' />
</movable-view>
</movable-area>
-
x='{{clothingImgX}}' y='{{clothingImgY}}'
是模特圖的對應位置。 -
由於
CANVAS
畫圖的適合單位是px
,而我這裏使用的是微信官方提供的rpx
來設置高度與寬度。所以需要進一步轉化單位。 -
由於網絡異步加載圖片的原因,如果使用css來加載模特圖的話,就可能造成圖片還沒有下載完就進行合成,這樣就會拋出異常。爲了這個問題,我給每次切換模特圖設置爲同步加載,圖片加載完成才能進行下一步操作。
async selectClick(e) { wx.showLoading({ title: "圖片下載中……", mask:true }) let { index } = e.currentTarget.dataset let url = `${this.data.previewSrc}${this.data.previewList[index].id}.png` wx.getImageInfo({ src: url, success: (res) => { this.setData({ currentClothingSelect: index + 1, selectClothingImg: res.path, previewWidth: this.data.previewHeight / res.height * res.width }) wx.hideLoading() } }) },
-
合成圖片,首先創建
CAVANS
的上下文/*省略*/ var ctx = wx.createCanvasContext('outPutImage', this); // canvas高度與寬度 var w = this.data.canvasWidth; var h = this.data.canvasHeight; // 畫背景圖 await this.drawBackground(ctx, w, h) // 畫模特圖 await this.drawClothing(ctx) ctx.draw(true, () => { wx.canvasToTempFilePath({ x: 0, y: 0, width: w, height: h, destWidth: w, destHeight: h, quality: 0.9, canvasId: 'outPutImage', success: function success(res) { resolve(res.tempFilePath) } }) }) /*省略*/ // 其他函數 drawBackground(ctx, w, h) { let src = this.data.selectBackgroundImg return new Promise(resolve => { wx.getImageInfo({ src, success(res) { ctx.drawImage(res.path, 0, 0, w, h) resolve() } }) }) }, drawClothing(ctx) { let clothingImg = this.data.cropperImageSrc let c_Y = this.data.clothingImgY * this.data.canvasHeight / this.data.moveableHeight let c_x = this.data.clothingImgX * this.data.canvasWidth / this.data.moveableWidth let w = this.data.previewWidth / this.data.pxTurnRpx * this.data.canvasWidth / this.data.moveableWidth*this.data.cropperScale let h = this.data.previewHeight / this.data.pxTurnRpx * this.data.canvasHeight / this.data.moveableHeight*this.data.cropperScale return new Promise(resolve => { wx.getImageInfo({ src: clothingImg, success(res) { ctx.drawImage(res.path,c_x,c_Y,w,h) resolve() } }) }) },
這裏拿生成模特圖的來講下數據如何進行轉換,由於用戶的手機端的屏幕限制,我們不可能直接生成用戶預覽圖的大小,因爲直接生成的話,圖片會很小也不清晰,所以爲了解決這個問題,需要進行放大圖片的大小,所以需要按照一定的比例大小進行縮放。
這裏的
canvasHeight
和canvasWidth
就是我們固定輸出的圖片長寬。分別爲1500與1000.
而moveableWidth
和moveableHeight
會根據用戶使用的移動端不同而不同,所以我們需要進行按照一定的比例進行縮放,當然這裏長寬的比例也是3:2.,這樣才能保持圖片不變形。
我們從模特的橫向的座標進行計算,
this.data.clothingImgX * this.data.canvasWidth / this.data.moveableWidth
這樣,我們就能夠獲取到模特圖進行縮放之後的新的位置。
同理,模特圖的縱向座標與長寬按照這樣計算,就可以獲取到進行縮放後的真實的位置。 -
人臉融合
人臉融合我使用的是騰訊ai平臺的提供的接口,如果按照費用計算好像是百度的比較便宜一點,由於我之前就使用過騰訊ai的接口,所以這次也直接使用騰訊的接口。
由於我這個項目沒有使用到服務器,所以我的人臉融合的請求是直接在小程序上進行請求的。大家可以參考下騰訊ai平臺提供的文檔。
騰訊ai也提供了小程序的雲開發的sdk,使用這個應該可以節省很多麻煩,因爲自己手寫的接口的確很多坑。這裏我就簡單介紹下小程序如何不通過雲平臺來直接請求騰訊ai接口
。
首先是接口:
請求的域名需要添加到小程序的request域名
上(幸好是https)。
然後就是請求的參數了:
人臉融合中比較重要的是ProjectId
創建的活動id,ModelId
需要合成的模特圖ID,Image
用戶人臉素材,RspImgType
返回的數據格式,由於沒有服務器就直接返回base64了。
其他的都是固定的數據字段。我將數據處理操作提取爲一個函數進行調用:getRequest:function(data,ProjectId,ModelId){ //ProjectId 活動id //ModelId 模特素材id let SecretId = '人臉融合的id' let SecretKey = '人臉融合的key' let Action = 'FaceFusion' let Nonce = Math.floor(Math.random()*10000);// 隨機id let Language = 'zh-CN'// 中文 var timestamp = Date.parse(new Date());// 日期 timestamp = timestamp / 1000; let Timestamp = timestamp let Version= '2018-12-01'// 人臉融合的版本 // 傳入 let Image=data// 圖片的base64 // 節點選擇 let Region='ap-guangzhou' // 返回格式 let RspImgType='base64' let Signature = getSignature({Action,Image,Language,ModelId,Nonce,ProjectId,Region,RspImgType,SecretId,Timestamp,Version}) return { Action,SecretId,SecretKey,Timestamp,Nonce,Version,Signature,Language,Region,RspImgType,Region } } // 字典順序輸出 function getSignature({ Action, Image, Language, ModelId, Nonce, ProjectId, Region, RspImgType, SecretId, Timestamp, Version }) { return `Action=${(Action)}&Image=${(Image)}&Language=${(Language)}&ModelId=${(ModelId)}&Nonce=${(Nonce)}&ProjectId=${(ProjectId)}&Region=${(Region)}&RspImgType=${(RspImgType)}&SecretId=${(SecretId)}&Timestamp=${(Timestamp)}&Version=${Version}` }
使用示例:
let config = getRequest(data,ProjectId,ModelId) let sign = `POSTfacefusion.tencentcloudapi.com/?${config.Signature}`// sign是騰訊ai接口識別 let sign_sha1 = Crypto.HmacSHA1(sign,config.SecretKey).toString(Crypto.enc.Base64)// 這也是騰訊ai需要進行加密 wx.request({ url: 'https://facefusion.tencentcloudapi.com', method: "post", header: { 'Content-Type': 'application/x-www-form-urlencoded' }, data:{ Action:config.Action, Version:config.Version, Nonce:config.Nonce, SecretId:config.SecretId, Timestamp:config.Timestamp, Signature:sign_sha1, ProjectId:ProjectId, ModelId:ModelId, Image:data, RspImgType:config.RspImgType, Language:config.Language, Region:config.Region }, complete(res){ // 處理回調 } })
騰訊ai 這個接口弄起來很麻煩,因爲你很容易就漏掉了一些字段或者一些字段順序弄錯的話,都是無法進行請求的,但是騰訊那邊又不會返回具體的錯誤給你,只會告訴你
sign
錯誤,所以這裏煩了我挺久的。
後面我發現騰訊有一個簽名的合成器
有了這個合成器網址,我就可以進行驗證我自己的最終合成sign
有沒有出錯。
有了這個發現我的合成的確沒有錯誤,然後再去調用發現成功了。
2. 卡通明信片照片拖動合成
卡通明信片和畢業照的合成類似,唯一的區別在於用戶的圖片在背景圖後面,所以需要一個蒙版提供給用戶移動用戶圖片。
<movable-area id='movable-area-edit' style="position:relative;width:625rpx;height:407rpx;margin:0 auto;">
<!--用戶圖片-->
<movable-view direction='all' data-type='cropper' scale='true'
x='{{cropperImgX}}'
y='{{cropperImgY}}'
hidden='{{lifeState!==lifeStateEditImage}}'
scale-value="{{cropperScale}}"
style="width:{{cropperImageWidth}}px;height:{{cropperImageHeight}}px;">
<image src='{{cropperImageSrc}}' style="width:100%;height:100%;" />
</movable-view>
<movable-view style="width:100%" disable='{{false}}'>
<image src='{{selectBackgroundImg}}' style="width:625rpx;height:415rpx;position:absolute;" />
</movable-view>
<!--用戶蒙版-->
<movable-view direction='all' data-type='cropper' scale='true'
bindchange='bindchange'
bindtouchend='bindchange'
bindscale='bindchange'
hidden='{{lifeState!==lifeStateEditImage}}'
x='{{cropperImgX}}'
y='{{cropperImgY}}'
scale-max='2'
scale-value="{{cropperScale}}"
style="width:{{cropperImageWidth}}px;height:{{cropperImageHeight}}px;background:black;opacity:0.3;">
</movable-view>
</movable-area>
- 圖片移動,用戶移動蒙版會修改
cropperImgX
與cropperImgY
- 圖片點擊結束:修改用戶圖片的位置與大小
- 雙指縮放:修改移動蒙版的
cropperImgX
與cropperImgY
以及cropperScale
縮放值。
bindchange(e) {
if (e.type === 'touchend') {
this.setData({
cropperImgX: this.data.cropperImgX,
cropperImgY: this.data.cropperImgY,
cropperScale: this.data.cropperScale
});
}
else if (e.type === 'change') {
this.data.cropperImgX = e.detail.x;
this.data.cropperImgY = e.detail.y;
}
else if (e.type === 'scale'){
this.data.cropperImgX = e.detail.x;
this.data.cropperImgY = e.detail.y;
this.data.cropperScale = e.detail.scale;
}
},
其他注意事項
base64轉成本地緩存文件
由於人臉融合返回的是base64的文件,爲了給用戶保存,所以需要轉換成文件格式。
let file = wx.getFileSystemManager()
let data= file.readFileSync(imageData,'base64')
前幾次調用都是正常的,但是微信的緩存文件是有大小限制,所以如果超過內存限制就會報錯。
爲了解決緩存限制的問題,我們每次用戶使用的時候,進行清除緩存文件。
onLoad: async function () {
// 清除臨時文件
let file = wx.getFileSystemManager()
file.readdir({
dirPath:`${wx.env.USER_DATA_PATH}`,
success(res){
res.files.forEach(val=>{
try{
// 刪除
file.unlinkSync(`${wx.env.USER_DATA_PATH}/${val}`)
}catch(e){
}
})
}
})
}
加密方法
這裏使用的是cryptojs
提供的api,大家要在小程序使用的話直接NPM
然後把文件複製過來就可以直接使用。
引入方法:
const Crypto = require('../../utils/crypto-js/index')