likes
comments
collection
share

整活!使用webAI做一个网页AR吃豆人小游戏

作者站长头像
站长
· 阅读数 23

一个好习惯,先给结论

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

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

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

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

整活!使用webAI做一个网页AR吃豆人小游戏

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

整活!使用webAI做一个网页AR吃豆人小游戏

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

 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上直接就卡死了🤦🏻‍。

卡死我还以为是主体程序有问题,又是翻来覆去倒腾半天才发现是结算页面卡住不是主体程序卡住。

最后只能换成了这个

本文首发于:blog.gis1024.com/web-ai-ar-p…