Next.js + Mediapipe 手势识别画板:前端玩转机器学习

先放效果

Link: cygra.github.io/hand-gestur...

Github: github.com/Cygra/hand-...

欢迎大家到 github 上点星 ⭐️,谢谢支持!

以下正文:

项目准备

最近在做一个和 tldraw 有关的项目,主要的需求就是多人协作白板。用鼠标画图感觉很不方便,当然现在用 iPad + Pencil 的人也已经很多了,很少有人还会用鼠标这么古老的东西去屏幕上戳。

但是在项目开发的过程中,还是灵光一现,是不是可以用摄像头实时识别手势来画图呢。于是去搜索了一下,最现成最方便的技术栈应该就是 Google AI 旗下的 Mediapipe。大概看了一眼官网上的 demo,不得不说,谷歌官网的文档是真难懂,和前端老哥们熟悉的 React、Nextjs 等等的官网根本不是一种风格。

调包还不容易吗,说干就干。

先用 nextjs 的脚手架生成一个模板项目:

bash 复制代码
npx create-next-app@latest

然后把 mediapipe 引入进来,Mediapipe 的 Web 官方包是 www.npmjs.com/package/@me... ,直接安装,开箱即用。

bash 复制代码
npm i @mediapipe/tasks-vision

下面正式开始调包。

要实现上图的效果,主要会用到三个 html 标签:

  1. video 标签,用于开启摄像头,获取视频流
  2. canvas 标签,渲染手势点、连接线
  3. canvas 标签,作为画板,绘制画的线

这里用两个 canvas 标签分别画线是因为,手势点在每次重算手势的时候都会清空重画,需要调用一次清空方法。而画板上的内容是一直保持的,所以另用一个独立的 canvas。

读取视频流

这里主要做的是通过调用系统摄像头,拿到图像。都是标准的浏览器 api,比较简单。

process 是正式开始调用 Mediapipe 的方法,所以放在 video 标签的 loadeddata 事件里。

tsx 复制代码
  const prepareVideoStream = async () => {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: false,
      video: true,
    });

    if (videoRef.current) {
      videoRef.current.srcObject = stream;
      videoRef.current.addEventListener("loadeddata", () => {
        process();
      });
    }
  };

初始化 Mediapipe

process 方法中进行 Mediapipe 的初始化,这一块可以看官网 ai.google.dev/edge/mediap...

tsx 复制代码
    const vision = await FilesetResolver.forVisionTasks(
      "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
    );

    const gestureRecognizer = await GestureRecognizer.createFromOptions(
      vision,
      {
        baseOptions: {
          modelAssetPath:
            "https://storage.googleapis.com/mediapipe-tasks/gesture_recognizer/gesture_recognizer.task",
          delegate: "GPU",
        },
        numHands: 1,
        runningMode: "VIDEO",
      }
    );

识别和渲染

因为要获取实时的结果,并绘制到画面上,所以下面两个方法都是在 requestAnimationFrame 中调用的。

大体的结构可以表示为:

tsx 复制代码
  const process = async () => {
    // 初始化 Mediapipe
    const vision = await FilesetResolver.forVisionTasks(...);
    const gestureRecognizer = await GestureRecognizer.createFromOptions(...);

    const renderLoop = () => {
      // 获取手势识别结果
      const result = gestureRecognizer.recognizeForVideo(video, startTimeMs);
      
      // 渲染手指、手掌等
      drawLandmarks(landmarks);
      // 渲染画板上的画线
      drawLine(strokeCanvasCtx, x, y);
      
      // 通过 requestAnimationFrame 进行下一次调用
      requestAnimationFrame(() => {
        renderLoop();
      });
    };

    // 手动触发第一次调用
    renderLoop();
  };

识别

调用 recognizeForVideo 方法,传入 video 和时间戳,来获取 result 手势识别结果。

video 即是 videoRef.current,也就是 video html 标签,因为在 react 组件中,所以这里通过 ref 调用。

这个方法需要传入一个时间戳,来指明读取视频流中的帧。

返回的 result 即是识别的结果,包含了 gesture(预置的模型识别出的手掌姿势,如手掌张开 🖐🏻、胜利✌🏻、向上指等等)、landmarks (手掌上每个关节的坐标点)等等。

tsx 复制代码
      let lastWebcamTime = -1;
      
      ...
      
      lastWebcamTime = video.currentTime;
      const startTimeMs = performance.now();
      const result = gestureRecognizer.recognizeForVideo(video, startTimeMs);

渲染

Mediapipe 是支持识别多只手的,所以返回的 landmarks 结果是一个二维数组,第一维是每一只不同的手掌。

我这里希望只用一只手来画图,所以前面初始化的时候已经配置了 numHands: 1,所以第一维的长度一定是 1。

第二维是手掌上每个点的坐标,这里将整个手掌按关节分为 20 个点,所以会输出 20 个坐标。每个坐标用一个 {x: 1, y: 1} 这样的对象来表示。

下面看具体的代码。

Mediapipe 输出的坐标点其实是归一化的,也就是说数值是在 [0-1] 这样的区间,因此要先获取 canvas 的宽和高,再乘以归一化的坐标值,才能获取到映射到 canvas 上的真正的坐标值。

Mediapipe 官方的 gesture 中没有提供 👌🏻 这个手势的模型,所以这里事实上没有使用到 gesture 的能力,只使用了 landmark。我看到官网上有关于自定义模型的内容,准备以后再做尝试。

这里我暂时通过一种比较简单粗暴的方式来识别是否满足 👌🏻 的条件,即拇指指尖和食指指尖的坐标点足够相近。如果 △x 和 △y 都足够小,则认为两个指尖连在了一起,构成了 👌🏻 的手势。这里的 50 是在屏幕上试出来一个比较适当的距离。

如果计算认为构成了 👌🏻,则按食指指尖的坐标点在 canvas 上画线。因为 video 读入的视频流是镜像的,所以 x 坐标会取 1-x 来镜像。(video 标签可以通过 css 样式来镜像)

画线调用的 drawLine 方法和画手掌的 drawLandmarks 方法在后文叙述。

tsx 复制代码
      if (result.landmarks) {
        const width = landmarkCanvas.width;
        const height = landmarkCanvas.height;
        if (result.landmarks.length === 0) {
          landmarkCanvasCtx.clearRect(0, 0, width, height);
        } else {
          result.landmarks.forEach((landmarks) => {
            const thumbTip = landmarks[THUMB_TIP_INDEX];
            const indexFingerTip = landmarks[INDEX_FINGER_TIP_INDEX];

            const dx = (thumbTip.x - indexFingerTip.x) * width;
            const dy = (thumbTip.y - indexFingerTip.y) * height;

            const connected = dx < 50 && dy < 50;
            if (connected) {
              const x = (1 - indexFingerTip.x) * width;
              const y = indexFingerTip.y * height;
              drawLine(strokeCanvasCtx, x, y);
            } else {
              prevX = prevY = 0;
            }
            drawLandmarks(
              landmarks,
              landmarkCanvasCtx,
              width,
              height,
              connected
            );
          });
        }
      }

画线

这里为了让画出的线更平滑,所以引入了一个 SMOOTHING_FACTOR

tsx 复制代码
const SMOOTHING_FACTOR = 0.3;

  const drawLine = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
    if (!prevX || !prevY) {
      prevX = x;
      prevY = y;
    }

    const smoothedX = prevX + SMOOTHING_FACTOR * (x - prevX);
    const smoothedY = prevY + SMOOTHING_FACTOR * (y - prevY);
    ctx.lineWidth = 5;
    ctx.moveTo(prevX, prevY);
    ctx.lineTo(smoothedX, smoothedY);
    ctx.strokeStyle = "white";
    ctx.stroke();
    ctx.save();

    prevX = smoothedX;
    prevY = smoothedY;
  };

画手掌

Mediapipe 提供了官方的 DrawingUtils 类,直接调用即可。

tsx 复制代码
  const drawLandmarks = (
    landmarks: NormalizedLandmark[],
    ctx: CanvasRenderingContext2D,
    width: number,
    height: number,
    connected: boolean
  ) => {
    const drawingUtils = new DrawingUtils(ctx);
    // 先清空画布
    ctx.clearRect(0, 0, width, height);
    // 画关节点之间的连接线
    drawingUtils.drawConnectors(landmarks, GestureRecognizer.HAND_CONNECTIONS, {
      color: "#00FF00",
      lineWidth: connected ? 5 : 2,
    });
    // 画关节点、指尖
    drawingUtils.drawLandmarks(landmarks, {
      color: "#FF0000",
      lineWidth: 1,
    });
  };

其他还有一些监听窗口尺寸、定义 ref、页面布局相关的代码,这里就不再赘述了。

到这里就结束啦,感谢您的观看,如果能去 github 上点个小星星 ⭐️ 就更好啦 ~

相关推荐
疯狂的沙粒20 分钟前
如何将一个数组转换为字符串?
开发语言·前端·javascript·柔性数组
lifelalala22 分钟前
多个页面一张SQL表,前端放入type类型
前端
java冯坚持26 分钟前
AI大模型开发—1、百度的千帆大模型调用(文心一言的底层模型,ENRIE等系列)、API文档目的地
人工智能·百度·文心一言
网络安全queen35 分钟前
Web 学习笔记 - 网络安全
前端·笔记·学习
Lojarro37 分钟前
【Vue】Vue组件--上
前端·javascript·vue.js
answerball1 小时前
💥Babel:前端魔法师的炼金术,让你的代码“返老还童”!👴➡️👶
前端·javascript·react.js
在线OJ的阿川1 小时前
大数据、人工智能、云计算、物联网、区块链序言【大数据导论】
大数据·人工智能·物联网·云计算·区块链
凡人的AI工具箱1 小时前
每日学习30分轻松掌握CursorAI:多文件编辑与Composer功能
人工智能·python·学习·ai·ai编程·composer·cursor
坐吃山猪1 小时前
卷积神经05-GAN对抗神经网络
人工智能·神经网络·生成对抗网络