一个好习惯,先给结论
使用网页端深度学习框架识别人脸,做一个AR吃豆人小游戏。吃豆人会随着人脸在镜头内的移动而移动,吃完全部豆子即为获胜。
最终效果如下:
在线体验地址:点我预览
代码地址:点我github
本文首发于:https://blog.gis1024.com/web-ai-ar-pac-man.html
技术选型
vite
作为构建工具
face-api.js
作为人脸识别工具,这是基于 tensorflow.js
的一个网页人脸识别库
webrtc-adapter
作为调用摄像的兼容垫片库
下面将把主要实现思路抽出来讲一下。
调用摄像头
在 index.html
中创建 video
标签
1 2
| <video id="video"></video>
|
引入 webrtc-adapter
垫片,方便在不同平台上都能调用摄像头,一开始没用这个包,在iPhone上调用摄像头死活失败。
调用后,把摄像头拍摄的流播放到 video
标签上。
需要注意的是,摄像头拍出来的画面是镜像的,你头往左动,画面里的头会往右动,所以我们需要给 video
标签 加上 transform: rotateY(180deg);
样式,让他水平翻转180°。
既然它进行了翻转,后面识别人脸位置的x坐标,也必须要进行计算,取镜像值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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>
里
1 2 3 4 5 6 7 8 9
| 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
值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| 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; 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
标签内随机生成豆子。需要注意的是,豆子需要离边界有一定的距离,不然人脸到边上的时候部分跑出画面外了,怎么都识别不出来,豆子永远也吃不到了。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| 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,表示游戏结束。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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://blog.gis1024.com/web-ai-ar-pac-man.html