项目介绍
由于疫情的影响,我们学校无法进行正常的毕业照流程,所以学院找到了我,希望能够开发一款自动合成的毕业照的小程序。
解决方案
- 前端:小程序开发
- 后台:知晓云
- 人脸融合接口:腾讯云人脸融合
体验
由于腾讯云的人脸融合需要付费,所以能够体验的时间为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')