Next.js + Mediapipe 实现 B 站弹幕防遮挡效果

546 阅读3分钟

先放效果

image.png

页面加载后,会自动调取系统摄像头采集实时画面,发送弹幕或者点击 Auto Generate 自动生成弹幕,可以看到弹幕从人像后面飞过去的效果。

Link: cygra.github.io/danmaku-mas…

Github: github.com/Cygra/danma…

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

以下正文:

B 站的弹幕防遮挡效果想必大家都很熟悉了,当画面主体中有人物时,弹幕从人像下方飘过,从而让人像可以更好地展示。如图:

image.png

关于 B 站到底是如何实现的,各位前辈已经有很多文章了。我做这个项目,也主要是为了熟悉 Next.js + Mediapipe 环境的搭建以及使用。在这里整理出来,略贡愚见。欢迎大家在评论区指点交流。

环境搭建

这一部分在 juejin.cn/post/746037… 中已经讲过,在此不再赘述。

概要

Mediapipe 能力介绍

这里主要用到了 Mediapipe 的 Image Segmentation (图像分割)来实现。Image Segmentation 可以将人像从图像的背景中分割出来,并返回相应的轮廓。

image.png

技术实现

为了实现弹幕从人像之后而背景之前飞过的效果,这里用到了三个图层,从下往上依次是:

  1. 背景图层,使用 <video /> 标签直接渲染从摄像头采集到的实时图像
  2. 弹幕图层,这里写的比较挫,在 React 组件里写了一个简单的调度,用 <div /> 配合 keyframs 实现飞行动画
  3. 人像图层,这一层利用 Mediapipe 返回的轮廓信息,从视频流中抠出人像,渲染在最上方

image.png

代码解析

读取视频流

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

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

          // 视频流加载完成,可以进行下一步操作
          
      });
    }
  };

初始化 Mediapipe

这一部分参考官方指导 ai.google.dev/edge/mediap…

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

    const imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
      baseOptions: {
        modelAssetPath:
          "https://storage.googleapis.com/mediapipe-models/image_segmenter/deeplab_v3/float32/1/deeplab_v3.tflite",
        delegate: "GPU",
      },
      outputCategoryMask: true,
      outputConfidenceMasks: false,
      runningMode: "VIDEO",
    });

渲染视频

这里会先将视频内容渲染在 canvas 上(ctx?.drawImage),然后再调用 Mediapipe 的图像分割方法,获取人像轮廓,然后将 canvas 上不属于人像的部分设为透明。

为了实现最优性能,通过 requestAnimationFrame 来调度,实现循环调用。

    const predictWebcam = async () => {
      if (!video || !maskCanvasRef.current) return;
      if (video.currentTime === lastWebcamTime) {
        requestAnimationFrame(predictWebcam);
        return;
      }

      lastWebcamTime = video.currentTime;
      ctx?.drawImage(video, 0, 0, videoWidth || 640, videoHeight || 480);
      if (imageSegmenter === undefined) {
        return;
      }
      const startTimeMs = performance.now();
      imageSegmenter.segmentForVideo(video, startTimeMs, callbackForVideo);
    };

获取人像轮廓

调用 Mediapipe 提供的 segmentForVideo 方法,传入视频流和回调函数,在回调函数中对返回的轮廓数据进行处理和渲染。

返回的 mask 即为人像的轮廓信息,先用 getAsFloat32Array 方法转为数组。

然后,遍历数组中的点,如果不是人像区域的(mask[i] === 0),则说明在最顶上的人像图层中,这一个点应该为透明。

调用 canvas 的 getImageData 方法获取 canvas 的图像信息,这里返回的 ImageData 中包含了图像中每一个点的 RGBA,这里将 A 置为 0(透明),再将处理过的图像重新绘制到 canvas 上。

      
    const callbackForVideo = (result: ImageSegmenterResult) => {
      const imageData = ctx?.getImageData(
        0,
        0,
        videoWidth || 640,
        videoHeight || 480
      ).data;
      const mask = result?.categoryMask?.getAsFloat32Array(); // Mediapipe 返回的轮廓

      let j = 0;
      if (mask && imageData) {
        for (let i = 0; i < mask.length; ++i) {
          if (mask[i] === 0) {
            imageData[j + 3] = 0;
          }
          j += 4;
        }
        const uint8Array = new Uint8ClampedArray(imageData.buffer);
        const dataNew = new ImageData(
          uint8Array,
          videoWidth || 640,
          videoHeight || 480
        );
        ctx?.putImageData(dataNew, 0, 0);
        requestAnimationFrame(predictWebcam);
      }
    };
    
  imageSegmenter.segmentForVideo(video, startTimeMs, callbackForVideo);

其他

此外还有弹幕调度、内容渲染等等,都比较简单,可以直接看源码。

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