APP端:
因為APP端無法使用uni的camera組件,最開始考慮使用內嵌webview的方式,通過原生dom調用video渲染畫面然后通過canvas截圖。但是此方案兼容性在ios幾乎為0,如果app只考慮安卓端的話可以采用此方案。后面又想用live-pusher組件來實現,但是發現快照api好像需要真實流地址才能截取圖像。因為種種原因,也是安卓ios雙端兼容性不佳。最終決定采用5+api實現。經實測5+api兼容性還算可以,但是畢竟是調用原生能力,肯定是沒有原生開發那么絲滑的,難免會出現一些不可預測的兼容性問題。所以建議app和手機硬件交互強的話還是不要用uni開發了。不然真的是翻文檔能翻死人。社區也找不到靠譜的解決方案。
5+api 文檔
https://www.html5plus.org/doc/zh_cn/video.html#plus.video.createLivePusher
就是使用這個api調用原生的camera完成。并且可以直接在預覽模式下完成快照,也不需要真實的推流地址。
<template>
<view class="content">
<view class="c-footer">
<view class="msg-box">
<view class="msg">
1、請保證本人驗證。
</view>
<view class="msg">
2、請使頭像正面位于畫框中。
</view>
<view class="msg">
3、請使頭像盡量清晰。
</view>
<view class="msg">
4、請保證眼鏡不反光,雙眼可見。
</view>
<view class="msg">
5、請保證無墨鏡,口罩,面膜等遮擋物。
</view>
<view class="msg">
6、請不要化濃妝,不要戴帽子。
</view>
</view>
<view class="but" @click="snapshotPusher" v-if="!cilckSwitch">采集本人人臉</view>
</view>
</view>
</template>
<script>
import permission from '../../util/permission.js'
export default {
data() {
return {
type: '', //是否是補簽拉起的人臉識別
imgData: '',
pusher: null,
scanWin: null,
faceInitTimeout: null,
snapshTimeout: null,
uploadFileTask: null,
cilckSwitch: false, //防止多次點擊
};
},
methods: {
//人臉比對
handleFaceContrast(param) {
uni.hideLoading()
this.$http({
url: '/API_AUTH/AppIaiFace/faceContrast.api',
data: {
...param,
userid: uni.getStorageSync('userInfo').id
}
}).then(res => {
console.log(res)
if (res.data.compareResult == 1) {
let pages = getCurrentPages(); //獲取所有頁面棧實例列表
let nowPage = pages[pages.length - 1]; //當前頁頁面實例
let prevPage = pages[pages.length - 2]; //上一頁頁面實例
if (this.type == 'signOut') {
prevPage.$vm.signOutXh = param.xh;
prevPage.$vm.signOutPhotoPath = param.path
} else {
prevPage.$vm.xh = param.xh;
prevPage.$vm.photoPath = param.path
}
uni.navigateBack()
} else {
uni.showToast({
title: '人臉比對不通過,請重試',
icon: 'none'
})
this.cilckSwitch = false
}
}).catch(err => {
uni.showToast({
title: '人臉比對不通過,請重試',
icon: 'none'
})
this.cilckSwitch = false
})
},
//初始化
faceInit() {
uni.showLoading({
title: '請稍后...'
})
this.faceInitTimeout = setTimeout(async () => {
//創建livepusher
if (uni.getSystemInfoSync().platform === 'android') {
const data1 = await permission.requestAndroidPermission(
"android.permission.RECORD_AUDIO")
const data2 = await permission.requestAndroidPermission("android.permission.CAMERA")
console.log(data1,data2,1111)
if (data1 == 1 && data2 == 1) {
this.pusherInit();
}
} else {
this.pusherInit();
}
//覆蓋在視頻之上的內容,根據實際情況編寫
// this.scanWin = plus.webview.create('/hybrid/html/faceTip.html', '', {
// background: 'transparent'
// });
//新引入的webView顯示
// this.scanWin.show();
//初始化上傳本地文件的api
this.initUploader()
}, 500);
},
//初始化播放器
pusherInit() {
const currentWebview = this.$mp.page.$getAppWebview();
this.pusher = plus.video.createLivePusher('livepusher', {
url: '',
top: '0px',
left: '0px',
width: '100%',
height: '50%',
position: 'absolute',
aspect: '9:16',
muted: false,
'z-index': 999999,
'border-radius': '50%',
});
currentWebview.append(this.pusher);
//反轉攝像頭
this.pusher.switchCamera();
//開始預覽
this.pusher.preview();
uni.hideLoading()
},
//初始化讀取本地文件
initUploader() {
let that = this
this.uploadFileTask = plus.uploader.createUpload(
"完整的接口請求地址", {
method: "POST",
headers: {
// 修改請求頭Content-Type類型 此類型為文件上傳
"Content-Type": "multipart/form-data"
}
},
// data:服務器返回的響應值 status: 網絡請求狀態碼
(data, status) => {
// 請求上傳文件成功
if (status == 200) {
console.log(data)
// 獲取data.responseText之后根據自己的業務邏輯做處理
let result = JSON.parse(data.responseText);
console.log(result.data.xh)
that.handleFaceContrast({
xh: result.data.xh,
path: result.data.path
})
}
// 請求上傳文件失敗
else {
uni.showToast({
title: '上傳圖片失敗',
icon: 'none'
})
console.log("上傳失敗", status)
that.cilckSwitch = false
uni.hideLoading()
}
}
);
},
//快照
snapshotPusher() {
if (this.cilckSwitch) {
uni.showToast({
title: '請勿頻繁點擊',
icon: 'none'
})
return
}
this.cilckSwitch = true
uni.showLoading({
title: '正在比對,請勿退出'
})
let that = this
this.snapshTimeout = setTimeout(() => {
this.pusher.snapshot(
e => {
// this.pusher.close();
// this.scanWin.hide();
//拿到本地文件路徑
var src = e.tempImagePath;
this.uploadImg(src)
//獲取圖片base64
// this.getMinImage(src);
},
function(e) {
plus.nativeUI.alert('snapshot error: ' + JSON.stringify(e));
that.cilckSwitch = false
uni.hideLoading()
}
);
}, 500);
},
//調用原生能力讀取本地文件并上傳
uploadImg(imgPath) {
this.uploadFileTask.addFile('file://' + imgPath, {
key: "file" // 填入圖片文件對應的字段名
});
//添加其他表單字段(參數) 兩個參數可能都只支持傳字符串
// uploadFileTask.addData("參數名", 參數值);
this.uploadFileTask.start();
},
//獲取圖片base64
getMinImage(imgPath) {
plus.zip.compressImage({
src: imgPath,
dst: imgPath,
overwrite: true,
quality: 40
},
zipRes => {
setTimeout(() => {
var reader = new plus.io.FileReader();
reader.onloadend = res => {
var speech = res.target.result; //base64圖片
console.log(speech.length);
console.log(speech)
this.imgData = speech;
};
reader.readAsDataURL(plus.io.convertLocalFileSystemURL(zipRes.target));
}, 1000);
},
function(error) {
console.log('Compress error!', error);
}
);
},
},
onLoad(option) {
//#ifdef APP-PLUS
this.type = option.type
this.faceInit();
//#endif
},
onHide() {
console.log('hide')
this.faceInitTimeout && clearTimeout(this.faceInitTimeout);
this.snapshTimeout && clearTimeout(this.snapshTimeout);
// this.scanWin.hide();
},
onBackPress() {
// let pages = getCurrentPages(); //獲取所有頁面棧實例列表
// let nowPage = pages[pages.length - 1]; //當前頁頁面實例
// let prevPage = pages[pages.length - 2]; //上一頁頁面實例
// prevPage.$vm.xh = '11111';
// prevPage.$vm.photoPath = '22222'
this.faceInitTimeout && clearTimeout(this.faceInitTimeout);
this.snapshTimeout && clearTimeout(this.snapshTimeout);
// this.scanWin.hide();
},
onUnload() {
this.faceInitTimeout && clearTimeout(this.faceInitTimeout);
this.snapshTimeout && clearTimeout(this.snapshTimeout);
// this.scanWin.hide();
},
};
</script>
<style lang="scss" scoped>
.but {
margin: 50rpx auto 0;
border-radius: 10px;
width: 80%;
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #008AFF;
font-size: 16px;
color: #FFFFFF;
}
.c-footer {
width: 100%;
position: fixed;
top: 50%;
left: 0;
z-index: 10;
padding: 30rpx 0;
.msg-box {
width: 80%;
margin: 0 auto;
text-align: left;
.msg {
margin-bottom: 15rpx;
font-size: 13px;
color: #666;
}
}
}
.img-data {
width: 100%;
height: auto;
}
.content {
background-color: #fff;
}
</style>
以上是完整的包含邏輯的代碼。
關鍵代碼
//初始化播放器
pusherInit() {
const currentWebview = this.$mp.page.$getAppWebview();
this.pusher = plus.video.createLivePusher('livepusher', {
url: '',
top: '0px',
left: '0px',
width: '100%',
height: '50%',
position: 'absolute',
aspect: '9:16',
muted: false,
'z-index': 999999,
'border-radius': '50%',
});
currentWebview.append(this.pusher);
//反轉攝像頭
this.pusher.switchCamera();
//開始預覽
this.pusher.preview();
uni.hideLoading()
},
//快照
snapshotPusher() {
if (this.cilckSwitch) {
uni.showToast({
title: '請勿頻繁點擊',
icon: 'none'
})
return
}
this.cilckSwitch = true
uni.showLoading({
title: '正在比對,請勿退出'
})
let that = this
this.snapshTimeout = setTimeout(() => {
this.pusher.snapshot(
e => {
//拿到本地文件路徑
var src = e.tempImagePath;
//這里因為接口參數需要加密,用base64的話加密出來的參數太大了,所以選擇了直接讀取本地文件上傳文件流的方式。
this.uploadImg(src)
//獲取圖片base64
// this.getMinImage(src);
},
function(e) {
plus.nativeUI.alert('snapshot error: ' + JSON.stringify(e));
that.cilckSwitch = false
uni.hideLoading()
}
);
}, 500);
},
//獲取圖片base64
getMinImage(imgPath) {
plus.zip.compressImage({
src: imgPath,
dst: imgPath,
overwrite: true,
quality: 40
},
zipRes => {
setTimeout(() => {
var reader = new plus.io.FileReader();
reader.onloadend = res => {
var speech = res.target.result; //base64圖片
console.log(speech.length);
console.log(speech)
this.imgData = speech;
};
reader.readAsDataURL(plus.io.convertLocalFileSystemURL(zipRes.target));
}, 1000);
},
function(error) {
console.log('Compress error!', error);
}
);
},
//初始化讀取本地文件
initUploader() {
let that = this
this.uploadFileTask = plus.uploader.createUpload(
"完整的接口請求地址", {
method: "POST",
headers: {
// 修改請求頭Content-Type類型 此類型為文件上傳
"Content-Type": "multipart/form-data"
}
},
// data:服務器返回的響應值 status: 網絡請求狀態碼
(data, status) => {
// 請求上傳文件成功
if (status == 200) {
console.log(data)
// 獲取data.responseText之后根據自己的業務邏輯做處理
let result = JSON.parse(data.responseText);
console.log(result.data.xh)
that.handleFaceContrast({
xh: result.data.xh,
path: result.data.path
})
}
// 請求上傳文件失敗
else {
uni.showToast({
title: '上傳圖片失敗',
icon: 'none'
})
console.log("上傳失敗", status)
that.cilckSwitch = false
uni.hideLoading()
}
}
);
},
//調用原生能力讀取本地文件并上傳
uploadImg(imgPath) {
this.uploadFileTask.addFile('file://' + imgPath, {
key: "file" // 填入圖片文件對應的字段名
});
//添加其他表單字段(參數) 兩個參數可能都只支持傳字符串
// uploadFileTask.addData("參數名", 參數值);
this.uploadFileTask.start();
},
以上就是關鍵的代碼。
接下來補充幾個坑的地方。創建出來的livepusher層級很高,無法在同一頁面被別的元素遮擋。所以想要在他上面寫樣式是行不通了。只能再創建一個webview。然后將這個webview覆蓋在livepusher上,達到人臉識別頁面的樣式。
//覆蓋在視頻之上的內容,根據實際情況編寫
this.scanWin = plus.webview.create('/hybrid/html/faceTip.html', '', {
background: 'transparent'
});
//新引入的webView顯示
this.scanWin.show();
//新引入的webView影藏
this.scanWin.hide();
這種方案在ios基本沒問題。至少目前沒遇到過。但是安卓就一言難盡了。首先這個組件默認調起的是后置攝像頭,這顯然不符合我們的需求。但是官方提供的文檔里也沒有明確支持可以配置優先調起哪個攝像頭。好早提供了一個switchCamera的api可以翻轉攝像頭。
但是在安卓系統上,尤其是鴻蒙系統,調用這個api就會導致程序閃退,而且發生頻率還特別高。這個問題至今不知道該怎么解決。
除了閃退問題,安卓還存在一個麻煩事兒,那就是首次進入app,翻轉攝像頭的api沒有用,拉起的還是后置攝像頭。但是后續再進入app就無此問題了。后面折騰來折騰去,發現好像是首次進入拉起授權彈窗的時候才會出現這種問題。
然后寫了個定時器做測試,五秒之后再拉起攝像頭再去翻轉攝像頭。然后再五秒內趕緊把授權給同意了。結果發現翻轉竟然生效了。
然后決定再渲染推流元素之前先讓用戶通過權限授權,然后再拉起攝像頭。 也就是上文完整代碼中的
//創建livepusher
if (uni.getSystemInfoSync().platform === 'android') {
const data1 = await permission.requestAndroidPermission(
"android.permission.RECORD_AUDIO")
const data2 = await permission.requestAndroidPermission("android.permission.CAMERA")
console.log(data1,data2,1111)
if (data1 == 1 && data2 == 1) {
this.pusherInit();
}
} else {
this.pusherInit();
}
具體的意思就不過多贅述了,自行看permission的文檔。或者看他的代碼。很簡單
permission下載地址
https://ext.dcloud.net.cn/plugin?id=594
以上就是調用原生能力拉起攝像頭實現快照功能的所有內容了。
下面也記錄一下web端如果實現這種功能,畢竟當時搞出來也不容易,但是最終還是敗在了兼容性上
方案的話大致有兩種,一種是借助tracking js 有興趣的可以了解一下,一個web端人臉識別庫。他可以識別畫面中是否出現人臉。以及一下更高級的功能我就沒有去探索了。有需要的可以自行研究
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>人臉識別</title>
<script src="../html/js/tracking-min.js"></script>
<script src="../html/js/face-min.js"></script>
<style>
video,
canvas {
position: absolute;
}
</style>
</head>
<body>
<div class="demo-container">
<video id="video" width="320" height="240" preload autoplay loop muted></video>
<canvas id="canvas" width="320" height="240"></canvas>
</div>
<script>
window.onload = function() {
console.log(123123123)
// 視頻顯示
var video = document.getElementById('video');
// 繪圖
var canvas = document.getElementById('canvas');
var context = canvas.getContext('2d');
var time = 10000;
var tracker = new tracking.ObjectTracker('face');
// 設置識別的放大比例
tracker.setInitialScale(4);
// 設置步長
tracker.setStepSize(2);
// 邊緣密度
tracker.setEdgesDensity(0.1);
// 啟動攝像頭,并且識別視頻內容
var trackerTask = tracking.track('#video', tracker, {
camera: true
});
var flag = true;
tracker.on('track', function(event) {
if (event.data.length === 0) {
console.log('未檢測到人臉')
context.clearRect(0, 0, canvas.width, canvas.height);
} else if (event.data.length > 1) {
console.log('檢測到多張人臉')
} else {
context.clearRect(0, 0, canvas.width, canvas.height);
event.data.forEach(function(rect) {
context.strokeStyle = '#ff0000';
context.strokeRect(rect.x, rect.y, rect.width, rect.height);
context.fillStyle = "#ff0000";
//console.log(rect.x, rect.width, rect.y, rect.height);
});
if (flag) {
console.log("拍照");
context.drawImage(video, 0, 0, 320, 240);
saveAsLocalImage();
context.clearRect(0, 0, canvas.width, canvas.height);
flag = false;
setTimeout(function() {
flag = true;
}, time);
} else {
//console.log("冷卻中");
}
}
});
};
function saveAsLocalImage() {
var myCanvas = document.getElementById("canvas");
// here is the most important part because if you dont replace you will get a DOM 18 exception.
// var image = myCanvas.toDataURL("image/png").replace("image/png", "image/octet-stream;Content-Disposition: attachment;filename=foobar.png");
var image = myCanvas.toDataURL("image/png").replace("image/png", "image/octet-stream");
// window.location.href = image; // it will save locally
// create temporary link
return
var tmpLink = document.createElement('a');
tmpLink.download = 'image.png'; // set the name of the download file
tmpLink.href = image;
// temporarily add link to body and initiate the download
document.body.appendChild(tmpLink);
tmpLink.click();
document.body.removeChild(tmpLink);
}
</script>
</body>
</html>
另外一種就是純video+canvas截取一張視頻中的畫面。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>人臉采集</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="./css/index.css" />
<script src="./js/jq.js" type="text/javascript" charset="utf-8"></script>
<!-- uni 的 SDK -->
<script type="text/javascript" src="https://js.cdn.aliyun.dcloud.net.cn/dev/uni-app/uni.webview.1.5.2.js">
</script>
<script src="https://cdn.bootcdn.net/ajax/libs/vConsole/3.0.0/vconsole.min.js" type="text/javascript"
charset="utf-8"></script>
<script>
// init vConsole,app中查看
var vConsole = new VConsole();
// console.log('Hello world');
</script>
<style>
.mui-content {
margin: 0 auto;
text-align: center;
border: 0px solid red;
}
/*攝像頭翻轉180度*/
video {
transform: rotateY(180deg);
-webkit-transform: rotateY(180deg);
/* Safari 和 Chrome */
-moz-transform: rotateY(180deg);
}
</style>
</head>
<body class='body'>
<div class="mui-content">
<div style="margin: 40px auto;">
<!-- <input type="file" id='image' accept="image/*" capture='user'> -->
<video id="video" style="margin: 0 auto; border-radius: 150px;"></video>
<canvas id='canvas' width="300" height="300"
style="border: 0px solid red;margin: auto; display: none;"></canvas>
</div>
<div class="msg-box">
<div class="msg">
1、請保證本人驗證。
</div>
<div class="msg">
2、請使頭像正面位于畫框中。
</div>
<div class="msg">
3、請使頭像盡量清晰。
</div>
<div class="msg">
4、請保證眼鏡不反光,雙眼可見。
</div>
<div class="msg">
5、請保證無墨鏡,口罩,面膜等遮擋物。
</div>
<div class="msg">
6、請不要化濃妝,不要戴帽子。
</div>
</div>
<div style="width: 80%; position: absolute; bottom: 20px; left: 50%; transform: translate(-50%, -50%);">
<div class="but" id="start">采集本人人臉</div>
</div>
</div>
</body>
<script type="text/javascript">
var video, canvas, vendorUrl, interval, videoHeight, videoWidth, time = 0;
// 獲取webview頁面數據
// var data = JSON.parse(getUrlParam('data'))
// var info = data.info;
// var userInfo = data.userInfo;
// const userId = data.userId; // 當前登錄用戶id
$(function() {
// 初始化
initVideo()
// uni.app事件
document.addEventListener('UniAppJSBridgeReady', function() {
uni.getEnv(function(res) {
console.log('當前環境:' + JSON.stringify(res));
});
setInterval(() => {
uni.postMessage({
data: {
action: 'postMessage'
}
});
}, 1000)
});
})
// 獲取url攜帶的數據
function getUrlParam(name) {
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
var r = window.location.search.substr(1).match(reg);
if (r != null) return unescape(r[2]);
return null;
}
// 攝像頭初始化
function initVideo() {
console.log('攝像頭初始化')
video = document.getElementById("video");
videoHeight = 300
videoWidth = 300
setTimeout(() => {
console.log(navigator)
navigator.mediaDevices
.getUserMedia({
video: {
width: {
ideal: videoWidth,
max: videoWidth
},
height: {
ideal: videoHeight,
max: videoHeight
},
facingMode: 'user', //前置攝像頭
// facingMode: { exact: "environment" }, //后置攝像頭
frameRate: {
ideal: 30,
min: 10
}
}
})
.then(videoSuccess)
.catch(videoError);
if (
navigator.mediaDevices.getUserMedia ||
navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.mediaCapabilities
) {
console.log('調用用戶媒體設備, 訪問攝像頭')
//調用用戶媒體設備, 訪問攝像頭
getUserMedia({
video: {
width: {
ideal: videoWidth,
max: videoWidth
},
height: {
ideal: videoHeight,
max: videoHeight
},
facingMode: 'user', //前置攝像頭
// facingMode: { exact: "environment" }, //后置攝像頭
frameRate: {
ideal: 30,
min: 10
}
}
},
videoSuccess,
videoError
);
} else {}
}, 300);
}
// 獲取用戶設備
function getUserMedia(constraints, success, error) {
if (navigator.mediaDevices.getUserMedia) {
//最新的標準API
navigator.mediaDevices
.getUserMedia(constraints)
.then(success)
.catch(error);
} else if (navigator.webkitGetUserMedia) {
//webkit核心瀏覽器
navigator.webkitGetUserMedia(constraints, success, error);
} else if (navigator.mozGetUserMedia) {
//firfox瀏覽器
navigator.mozGetUserMedia(constraints, success, error);
} else if (navigator.getUserMedia) {
//舊版API
navigator.getUserMedia(constraints, success, error);
}
}
// 開始有畫面
function videoSuccess(stream) {
//this.mediaStreamTrack = stream;
console.log("=====stream")
video.srcObject = stream;
video.play();
//$("#max-bg").css('background-color', 'rgba(0,0,0,0)')
// 這里處理我的的東西
}
function videoError(error) {
alert('攝像頭獲取錯誤')
console.log('攝像頭獲取錯誤')
setTimeout(() => {
initVideo()
}, 6000)
}
// 單次拍照
function getFaceImgBase64() {
canvas = document.getElementById('canvas');
//繪制canvas圖形
canvas.getContext('2d').drawImage(video, 0, 0, 300, 300);
//把canvas圖像轉為img圖片
var bdata = canvas.toDataURL("image/jpeg");
//img.src = canvas.toDataURL("image/png");
return bdata.split(',')[1]; //照片壓縮成base位數據
}
$('#start').on('click', function() {
time = 0;
console.log("開始人臉采集,請正對屏幕");
faceGather();
})
// 人臉采集
function faceGather() {
const faceImgBase64 = getFaceImgBase64();
console.log(faceImgBase64);
}
</script>
</html>