一个好习惯,先给结论

使用网页端深度学习框架识别人脸,做一个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官方地址)可以识别 imgcanvas 甚至 video 标签,所以我们上面的 video 标签是可以直接用的。

根据官方文档,这个库是支持好几种机器学习模型识别的,模型包有大有小。

我一眼就相中了 tiny Face Detector 这个模型,只有 190KB 大,对于网页来说也太友好了。

但是的但是,我用了第一张静态图片测试就没识别出来,😅。再用动态的摄像头画面识别,效果也不理想。

Snipaste_2022-05-06_11-22-27.png

于是最终选择了 SSD Mobilenet V1 这个模型,有 5.4MB,大是大了点,但是识别效果好就行了。

Snipaste_2022-05-06_11-23-53.png

单次识别的代码比较简单,如下,表示对 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>topleft 值。

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;
// 摄像头是镜像的,通过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 标签内随机生成豆子。需要注意的是,豆子需要离边界有一定的距离,不然人脸到边上的时候部分跑出画面外了,怎么都识别不出来,豆子永远也吃不到了。

代码如下:

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