整活!使用webAI做一个网页AR吃豆人小游戏
一个好习惯,先给结论
使用网页端深度学习框架识别人脸,做一个AR吃豆人小游戏。吃豆人会随着人脸在镜头内的移动而移动,吃完全部豆子即为获胜。
在线体验地址:点我预览
代码地址:点我github
本文首发于:blog.gis1024.com/web-ai-ar-p…
技术选型
vite
作为构建工具face-api.js
作为人脸识别工具,这是基于tensorflow.js
的一个网页人脸识别库webrtc-adapter
作为调用摄像的兼容垫片库
下面将把主要实现思路抽出来讲一下。
调用摄像头
在 index.html
中创建 video
标签
<!-- 视频画面 -->
<video id="video"></video>
引入 webrtc-adapter
垫片,方便在不同平台上都能调用摄像头,一开始没用这个包,在iPhone上调用摄像头死活失败。
调用后,把摄像头拍摄的流播放到 video
标签上。
需要注意的是,摄像头拍出来的画面是镜像的,你头往左动,画面里的头会往右动,所以我们需要给 video
标签 加上 transform: rotateY(180deg);
样式,让他水平翻转180°。
既然它进行了翻转,后面识别人脸位置的x坐标,也必须要进行计算,取镜像值。
import adapter from "webrtc-adapter";
navigator.mediaDevices.getUserMedia({ video: {} }).then(success).catch(error);
async function success(stream) {
video.srcObject = stream;
video.play();
}
function error(error) {
alert(
`访问用户媒体设备失败,请打开摄像头权限${error.name}, ${error.message}`
);
console.log(`访问用户媒体设备失败${error.name}, ${error.message}`);
}
人脸识别
人脸识别使用 face-api.js
,这是基于 tensorflow.js
的一个网页人脸识别库,我们这术语是站在两个巨人的肩膀上了,2333。
face-api.js
(官方地址)可以识别 img
、 canvas
甚至 video
标签,所以我们上面的 video
标签是可以直接用的。
根据官方文档,这个库是支持好几种机器学习模型识别的,模型包有大有小。
我一眼就相中了 tiny Face Detector
这个模型,只有 190KB
大,对于网页来说也太友好了。
但是的但是,我用了第一张静态图片测试就没识别出来,😅。再用动态的摄像头画面识别,效果也不理想。
于是最终选择了 SSD Mobilenet V1
这个模型,有 5.4MB
,大是大了点,但是识别效果好就行了。
单次识别的代码比较简单,如下,表示对 video
里的内容进行人脸识别,识别结果为result,包含了人脸的位置信息、描述、准确率等,然后将识别结果绘制到 index.html
的 <canvas id="devCanvas"></canvas>
里
const devCanvasEl = document.getElementById("devCanvas");
const videoEl = document.getElementById("video");
const result = await faceapi.ssdMobilenetv1(videoEl);
const dims = faceapi.matchDimensions(devCanvasEl, videoEl, true);
faceapi.draw.drawDetections(
devCanvasEl,
faceapi.resizeResults(result, dims)
);
这只是单次的识别,如果要对 video
里的内容一直进行动态跟踪识别,就需要使用到 requestAnimationFrame
函数,重复进行识别操作。
绘制游戏画面
html
中的 <div id="man"></div>
是吃豆人,我们提前给他写好了css样式,具体看代码。
每次 requestAnimationFrame
识别人脸时,应该将吃豆人放到人脸的位置上,也就是改变 <div id="man"></div>
的 top
、 left
值。
const manDiv = document.getElementById("man");
const points = initPoints();
document.querySelector("#loading-wrap").style.display = "none";
async function animate() {
requestAnimationFrame(animate);
const result = await faceapi.ssdMobilenetv1(videoEl);
console.log(result);
if (result.length) {
const x = result[0].box.width / 2 + result[0].box.left;
const y = result[0].box.height / 2 + result[0].box.top;
// 摄像头是镜像的,通过css transform: rotateY(180deg)调整过了,相应坐标也要取镜像的
const mirrorX = videoEl.offsetWidth - x;
manDiv.style.left = mirrorX + "px";
manDiv.style.top = y + "px";
checkEaten(manDiv, points);
}
// 如果是开发环境,把人脸识别的边框画出来,方便调试
// 所有环境都画出来,方便识别
const dims = faceapi.matchDimensions(devCanvasEl, videoEl, true);
faceapi.draw.drawDetections(
devCanvasEl,
faceapi.resizeResults(result, dims)
);
}
await animate();
上面代码里的 initPoints
是初始化豆子,在 video
标签内随机生成豆子。需要注意的是,豆子需要离边界有一定的距离,不然人脸到边上的时候部分跑出画面外了,怎么都识别不出来,豆子永远也吃不到了。
代码如下:
function initPoints() {
const videoEl = document.getElementById("video");
const videoHeight = videoEl.offsetHeight;
const videoWidth = videoEl.offsetWidth;
const playGroundWrapEl = document.getElementById("play-ground-wrap");
const list = [];
// 随机生成豆子
for (let i = 0; i < 5; i++) {
// 豆子离边界要有一定距离
const x = videoWidth * getRandom(0.25, 0.75);
const y = videoHeight * getRandom(0.25, 0.75);
const div = document.createElement("div");
div.classList.add("point");
div.style.left = x + "px";
div.style.top = y + "px";
playGroundWrapEl.append(div);
list.push(div);
}
return list;
}
function getRandom(n, m) {
return Math.random() * (m - n) + n;
}
checkEaten
是判断当前吃豆人与豆子的位置关系,如果位置有重叠,则表示豆子被吃到了,将其从dom中删除。
当所有豆子都被吃完时,数组长度为0,表示游戏结束。
function checkEaten(manEl, pointsEl) {
if (!pointsEl.length) {
return;
}
const manLeft = Number(manEl.style.left.replace("px", ""));
const manTop = Number(manEl.style.top.replace("px", ""));
// 判断是否吃到了豆子
for (let i = pointsEl.length - 1; i >= 0; i--) {
const pointLeft = Number(pointsEl[i].style.left.replace("px", ""));
const pointTop = Number(pointsEl[i].style.top.replace("px", ""));
const distance2 =
Math.pow(manLeft - pointLeft, 2) + Math.pow(manTop - pointTop, 2);
const distance = Math.sqrt(distance2);
if (distance <= 10 + 20) {
pointsEl[i].remove();
pointsEl.splice(i, 1);
}
}
}
总结
到这里整个的流程也就结束了,其中的一些细节,比如怎么用css绘制吃豆人、怎么让游戏画面和视频画面大小一致等、什么时候控制loading和胜利的画面等,比较简单,也就不赘述了。
整体总结来就是 调用摄像头
-> 绘制video
-> 绘制豆子
-> 对video进行识别
-> 将吃豆人位置与识别结果对齐
-> 判断吃豆人与豆子位置
。
番外
一开始胜利结算页面用的是这个,在电脑上看挺好,在iPhone上直接就卡死了🤦🏻。
卡死我还以为是主体程序有问题,又是翻来覆去倒腾半天才发现是结算页面卡住不是主体程序卡住。
最后只能换成了这个。
转载自:https://juejin.cn/post/7094484574381539359