效果图:
一, 项目说明
- 两张相片融合使用的是百度人脸融合接口(https://ai.baidu.com/tech/face/merge)
- 制作动态相册用的是腾讯人像渐变接口(https://cloud.tencent.com/product/ft?fromSource=gwzcw.3561173.3561173.3561173&utm_medium=cpc&utm_id=gwzcw.3561173.3561173.3561173)
- 上传图片可以是URL或者base64 。如果用URL方式上传,需要搭建自已的图片服务器,或者使用图床,本项目使用的是搭建图片服务器。
二, 实现技术
-
后端 node.js 搭建图片服务器,用于接收并保存用户上传的图片,并把图片链接返回给前端提交到相应的接口。
-
前端 uniapp 开发 H5 页面,打开相册或者相机拍摄上传人脸图片,等待后端接口返回处理好的图片和视频并显示到页面。
三, 核心代码
1. 后端 node.js 代码
var https = require('https');
var qs = require('querystring');
var express=require('express'); //引入模块
var app=express();
var path=require('path');
var fs = require('fs');
var bodyParser = require('body-parser'); //接收base64格式文件用到的模块
// 设置跨域访问
app.all("*",function(req,res,next){
res.header("Access-Control-Allow-Origin","*"); //设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Headers","content-type"); //允许的header类型
res.header("Access-Control-Allow-Methods","DELETE,PUT,POST,GET,OPTIONS"); //跨域允许的请求方式
if (req.method.toLowerCase() == 'options'){
res.sendStatus(200); //让options尝试请求快速结束
}else{
next();
}
})
app.use(express.static(path.join(__dirname,'facePic'))); //设置facePic文件夹下的文件能通过网址打开 如: http://192.168.1.100:8888/logo.png
app.use(express.static(path.join(__dirname,'imgup')));
app.use(bodyParser.json({limit: '10mb'})); //限制前端POST请求方式上传数据的大小
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
//* 获取token -- 使用百度AI人脸融合接口需要鉴权token *//
app.get('/token',function (req,res) {
var dataString="";
const param = qs.stringify({
'grant_type': 'client_credentials',
'client_id': 'Gd*********************HH', //改成自己的
'client_secret': '89***************KaV' //改成自己的
});
https.get(
{
hostname: 'aip.baidubce.com',
path: '/oauth/2.0/token?' + param,
agent: false
},
function (res2) {
// 在标准输出中查看运行结果
//res2.pipe(process.stdout);
// 写入文件
//res2.pipe(fs.createWriteStream('./baidu-token.json'));
//需要用的时候可以读取文件内容
//fs.readFileSync('./baidu-token.json', 'utf8');
res2.on("data",function(data){
dataString+=data;
});
res2.on("end",function(){
console.log(dataString); //这里是https.get()返回的完整的数据
});
}
);
//等待三秒,让数据完全写入变量后再发给前端
setTimeout(()=>{
res.send(dataString)
},3000)
});
//* 接收图片 *//
app.post('/up',function(req,res) {
//req.body 解析前端POST传递过来的数据
var imgBase64 = req.body.imgBase64 ;
var base64Data = imgBase64.replace(/^data:image\/\w+;base64,/, ""); //过滤 data:image/jpeg;base64,
var dataBuffer = new Buffer.from(base64Data, 'base64');
var save_path = './imgup/' //保存路径
var file_name = Date.now() + '.png' //以时间戳做文件名
var save_name = save_path + file_name
fs.writeFile(save_name, dataBuffer, function(err) {
if(err){
res.send(err);
}else{
res.send(file_name);
console.log("保存成功!")
}
});
});
var jobId = '';
//* 生成动态相册 --使用腾讯 ai 接口 *//
app.get('/video',function(req,res) {
//req.query 解析前端get传递过来的数据
var urlArrStr = req.query.urlArr;
// 字符串转为数组
var urlArr = urlArrStr.split(",");
console.log(urlArr)
// Depends on tencentcloud-sdk-nodejs version 4.0.3 or higher
const tencentcloud = require("tencentcloud-sdk-nodejs");
const FtClient = tencentcloud.ft.v20200304.Client;
// 实例化一个认证对象,入参需要传入腾讯云账户secretId,secretKey,此处还需注意密钥对的保密
// 密钥可前往https://console.cloud.tencent.com/cam/capi网站进行获取
const clientConfig = {
credential: {
secretId: "AKI**************lxwI", //改成自己的
secretKey: "LO***************mEG", //改成自己的
},
region: "ap-guangzhou",
profile: {
httpProfile: {
endpoint: "ft.tencentcloudapi.com",
},
},
};
// 实例化要请求产品的client对象,clientProfile是可选的
// https://console.cloud.tencent.com/api/explorer?Product=ft&Version=2020-03-04&Action=MorphFace
const client = new FtClient(clientConfig);
const params = {
"Urls": [
urlArr[0],
urlArr[1],
urlArr[2]
]
};
client.MorphFace(params).then(
(data) => {
console.log(data);
jobId = data.JobId;
res.send(data);
},
(err) => {
console.log("生成动态相册失败", err);
res.send(err);
}
);
});
/* 根据jobId查询动态相册视频--生成视频需要10秒左右 */
app.get('/query',function(req,res) {
// Depends on tencentcloud-sdk-nodejs version 4.0.3 or higher
const tencentcloud = require("tencentcloud-sdk-nodejs");
const FtClient = tencentcloud.ft.v20200304.Client;
// 实例化一个认证对象,入参需要传入腾讯云账户secretId,secretKey,此处还需注意密钥对的保密
// 密钥可前往https://console.cloud.tencent.com/cam/capi网站进行获取
const clientConfig = {
credential: {
secretId: "AKI**************lxwI", //改成自己的
secretKey: "LO***************mEG", //改成自己的
},
region: "ap-guangzhou",
profile: {
httpProfile: {
endpoint: "ft.tencentcloudapi.com",
},
},
};
// 实例化要请求产品的client对象,clientProfile是可选的
// https://console.cloud.tencent.com/api/explorer?Product=ft&Version=2020-03-04&Action=MorphFace
const client = new FtClient(clientConfig);
const params = {
"JobId": jobId
};
client.QueryFaceMorphJob(params).then(
(data) => {
console.log(data);
res.send(data);
},
(err) => {
console.log("查询动态相册失败", err);
res.send(err);
}
);
});
// 创建服务, 上线后需要把 '127.0.0.1' 修改为 '0.0.0.0' , 否则服务没有响应
var server =app.listen(8888,'127.0.0.1',function(req,res,next){
var host = server.address().address
var port = server.address().port
console.log("服务开启成功,访问地址为 http://%s:%s", host, port)
});
2. 前端 uniapp 代码
// index.vue
<template>
<view>
<view class='all'>
<!-- 图片 -->
<view class="images-box">
<block v-for="(item, index) in templateArr" :key="index">
<view class='img-box'>
<image class='img' :src='item' mode='aspectFill'></image>
<view class='img-delete' @click='imgDelete1' :data-delindex="index">
<image class='img' src='../../static/delete.png' ></image>
</view>
</view>
</block>
<view class='img-box' @click='addPic1' v-if="templateArr.length<1">
<image class='img-camera' src='../../static/camera.png'></image>
</view>
</view>
<!-- 图片 -->
<view class="images-box">
<block v-for="(item, index) in targetArr" :key="index">
<view class='img-box'>
<image class='img' :src='item' mode='aspectFill'></image>
<view class='img-delete' @click='imgDelete2' :data-delindex="index">
<image class='img' src='../../static/delete.png' ></image>
</view>
</view>
</block>
<view class='img-box' @click='addPic2' v-if="targetArr.length<1">
<image class='img-camera' src='../../static/camera.png'></image>
</view>
</view>
<button @click='uploadimage'>上传图片</button>
<button @click='viewPic'>查看融合图</button>
</view>
</view>
</template>
<script>
// 后端服务地址, 外网必须能够访问
var serverUrl = 'http://4h2***********.com:8888/'
var resArr = []
var access_token = ''
export default {
data() {
return {
templateArr: [],
targetArr:[],
isUp: true
}
},
onLoad() {
// 域名写入缓存,以方便其它页面使用
uni.setStorageSync('serverUrl',serverUrl)
// 调用请求token函数
this.getToken()
},
methods: {
// 选择图片
addPic1: async function() {
this.isUp = true
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album','camera'],
success: (res) => {
this.templateArr = res.tempFilePaths
}
});
},
// 选择图片
addPic2: async function() {
this.isUp = true
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album','camera'],
success: (res) => {
this.targetArr = res.tempFilePaths
}
});
},
// 上传图片
uploadimage: function () {
if(!this.isUp){
uni.showToast({
title: "请不要重复上传",
icon: 'none',
duration: 2000,
})
}else{
if (this.templateArr.length != 0 && this.targetArr.length != 0 ) { //数组不为空的时候才执行
let arr1 = this.templateArr
let arr2 = this.targetArr
arr1= arr1.concat(arr2) //合并数组
this.imgCompress(arr1) //调用函数imgCompress
this.isUp = false
uni.showLoading({
title: "上传中!",
icon: 'loading',
})
} else {
uni.showToast({
title: "请选择图片!",
icon: 'none',
duration: 1000,
})
}
}
},
// 循环调用压缩图片
imgCompress(tempFilePaths) {
let compressImgs = [];
let results = [];
tempFilePaths.forEach((item, index) => {
compressImgs.push(new Promise((resolve, reject) => {
// #ifndef H5
uni.compressImage({
src: item,
quality: 0.5,
success: res => {
//console.log('compressImage', res.tempFilePath)
results.push(res.tempFilePath);
resolve(res.tempFilePath);
},
fail: (err) => {
//console.log(err.errMsg);
reject(err);
},
complete: () => {
//uni.hideLoading();
}
})
// #endif
// #ifdef H5
//调用压缩图片函数 canvasDataURL
this.canvasDataURL(item, {quality: 0.5}, (base64Codes) => {
//this.imgUpload(base64Codes);
results.push(base64Codes);
resolve(base64Codes);
})
// #endif
}))
});
Promise.all(compressImgs) //执行所有需请求的接口
.then((results) => {
//uni.hideLoading();
//this.imgUpload(results);
})
.catch((res, object) => {
//uni.hideLoading();
});
},
//压缩图片
canvasDataURL(path, obj, callback) {
var this_ = this; //指向本页
var img = new Image();
img.src = path;
img.onload = function() {
var that = this; //指向本函数
// 按比例压缩
var w, h, scale ;
if(that.height >= 3000 || that.width >= 3000){
w = that.width / 5,
h = that.height / 5,
scale = w / h;
}
else if(that.height >= 2000 || that.width >= 2000){
w = that.width / 4,
h = that.height / 4,
scale = w / h;
}
else if(that.height >= 1000 || that.width >= 1000){
w = that.width / 2,
h = that.height / 2,
scale = w / h;
}else{
w = that.width ,
h = that.height ,
scale = w / h;
}
var quality = 0.5; // 图片质量 quality值越小,所绘制出的图像越模糊
//生成canvas
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
// 创建属性节点
var anw = document.createAttribute("width");
anw.nodeValue = w;
var anh = document.createAttribute("height");
anh.nodeValue = h;
canvas.setAttributeNode(anw);
canvas.setAttributeNode(anh);
ctx.drawImage(that, 0, 0, w, h);
// if (obj.quality && obj.quality <= 1 && obj.quality > 0) {
// quality = obj.quality;
// }
var base64 = canvas.toDataURL('image/jpg', quality);
callback(base64); // 回调函数返回base64的值
this_.upBase64(base64) //调用函数
}
},
// 向后端上传图片
upBase64(img_Base64){
uni.request({
url: serverUrl + 'up',
data: {
imgBase64: img_Base64
},
method: 'POST',
header: { 'content-type': 'application/json' },
//请求成功
success: (res) => {
if(resArr.length >= 3){
resArr = [] //清空数组
resArr.push(res.data)
uni.setStorageSync('imgName',resArr) // 写入缓存
// console.log(resArr)
}else{
resArr.push(res.data) //追加到数组
uni.setStorageSync('imgName',resArr) // 写入缓存
// console.log(resArr)
}
},
fail: (err) => {
console.log('错误:' , err)
},
complete: () => {
//uni.hideLoading()
if(resArr.length == 2){
this.getData()
}
}
})
},
// 请求百度token
getToken(){
uni.request({
url: serverUrl + 'token',
data: { },
method: 'GET',
success: (res) => {
console.log(res)
access_token = res.data.access_token
},
fail: (err) => {
console.log('请求百度token失败:',err)
},
complete: () => {
}
})
},
// 请求百度人脸融合接口
getData(){
let url_a = serverUrl + resArr[0]
let url_b = serverUrl + resArr[1]
uni.request({
url: ' https://aip.baidubce.com/rest/2.0/face/v1/merge?access_token=' + access_token ,
data: {
"version":"2.0",
"image_template":{"image":url_a,"image_type":"URL"},
"image_target":{"image":url_b,"image_type":"URL"}
},
method: 'POST',
header: {
'Content-Type': 'application/json' //请求头信息
},
//请求成功
success: (res) => {
console.log('成功融合:' , res)
let imgBase64 = 'data:image/png;base64,' + res.data.result.merge_image
this.upBase64(imgBase64) //调用函数,把融合后的图片上传到后端保存
setTimeout(()=>{
this.getVideo() //调用函数
uni.navigateTo({ //跳转到指定页面
url: "../faceView/faceView",
})
},2000)
},
fail: (err) => {
console.log('错误:' , err)
},
complete: () => {
uni.hideLoading()
}
})
},
// 删除已经选择的图片
imgDelete1: function (e) {
//let index = e.currentTarget.dataset.delindex;
//获取要删除的图片的下标,否则删除的永远是第一张 ,对应 <view class='img-delect' @click='imgDelete1' :data-delindex="index">
this.templateArr.splice(0, 1);
},
imgDelete2: function (e) {
//let index = e.currentTarget.dataset.delindex;
//获取要删除的图片的下标,否则删除的永远是第一张 ,对应 <view class='img-delect' @click='imgDelete1' :data-delindex="index">
this.targetArr.splice(0, 1);
},
// 查看融合图
viewPic(){
if(this.isUp){
uni.showToast({
title:'请先上传图片',
icon:'error',
duration:2000
})
}else{
uni.navigateTo({ //跳转到指定页面
url: "../faceView/faceView",
})
}
},
/* 请求合成动态相册 */
getVideo(){
let urlArr = []
if(resArr.length >= 3){
// 域名+图片名称 ,循环拼接成完整的url 例:http://4h237******oip.com:8888/1664094464371.png
for(let i=0 ; i<resArr.length; i++){
urlArr.push(serverUrl + resArr[i])
}
uni.request({
url: serverUrl + 'video',
data: {
urlArr: urlArr, //赋值给变量并传送到后端
},
method: 'GET',
success: (res) => {
console.log(res)
},
fail: (err) => {
console.log(err)
},
complete: () => {
}
})
}else{
console.log('resArr长度不够3')
}
}
}
}
</script>
<style>
@import "./index.css";
</style>
// faceView.vue
<template>
<view class="">
<view class="img-box">
<image class="img" :src="imgUrl" mode="aspectFill"></image>
</view>
<video :src="videoUrl" controls object-fit="cover" autoplay="true" loop="true" ></video>
<!-- <button @click='doQuery' :disabled="isDisabled">刷新视频</button> -->
<button @click='backToHome'>返回主页</button>
</view>
</template>
<script>
var serverUrl = ''
export default {
data() {
return {
imgUrl:'',
videoUrl:'',
isDisabled:true
}
},
onLoad() {
serverUrl = uni.getStorageSync('serverUrl')
this.getUrl()
},
onShow() {
// this.videoUrl = uni.getStorageSync('morphUrl')
// 默认视频
this.videoUrl = serverUrl + 'loading.mp4'
// 10秒后调用请求视频函数,因为腾讯接口合成视频需要几秒时间
setTimeout(()=>{
this.doQuery()
this.isDisabled = false
},10000)
},
methods: {
backToHome() {
// 跳转到指定页面
uni.navigateTo({
url: "../index/index",
})
},
/* 从缓存中读取融合后的相片链接 */
getUrl(){
try {
let value = uni.getStorageSync('imgName');
if (value) {
// console.log(value)
this.imgUrl = serverUrl + value[2];
}else{
this.imgUrl = '../../static/noPic.png';
}
} catch (e) {
// error
this.imgUrl = '../../static/noPic.png';
}
},
/* 查询动态相册视频 */
doQuery(){
uni.request({
url: serverUrl + 'query',
data: { },
method: 'GET',
success: (res) => {
console.log(res)
let morphUrl = res.data.FaceMorphOutput.MorphUrl
this.videoUrl = morphUrl
// uni.setStorageSync('morphUrl',morphUrl)
},
fail: (err) => {
console.log('查询失败',err)
},
complete: () => {
}
})
}
}
}
</script>
<style>
.img-box {
display: flex;
justify-content: center;
}
.img{
width: 700rpx;
height: 600rpx;
}
button {
width: 90%;
margin-top: 20rpx;
background-color: #ffaa00;
}
video{
width: 94%;
height: 500rpx;
margin: 24rpx;
}
</style>
源码下载地址:
链接:https://pan.baidu.com/s/1AVB71AjEX06wpc4wbcV_tQ?pwd=l9zp
提取码:l9zp