webrtc之使用虚拟背景

674 阅读7分钟

上一小节介绍了几个音视频通话的实用功能,共享屏幕、使用视频替代摄像头和关闭/开启摄像头。这节就来结合模型,实现视频通话虚拟背景。

注意:这节内容都是在webrtc实现音视频通话的基础上进行拓展的!!!

这里使用的是google的开源平台,具体地址是这个,可以先看看。

要实现虚拟背景,首先是要将人物和背景分离出来(这里的人物就不再区分衣服啥的了,简单处理)。然后挑选模型,在上面这个地址中,有列举出多个模型,但我们只需要将人物和背景分离即可,所以选取的模型是SelfieSegmenter(方形),也就是下面这个:

image.png

注意到这段话 该模型会输出两个类别:背景位于索引 0 处,人物位于索引 1 处,下面写代码是需要用到的。

再然后,就是使用模型了。这里官方也给出了一个demo示例,不过是html版本的,下面就来改造一下,并且加上自定义图片作为虚拟背景。

原理

在实操之前,再来解释下是如何实现虚拟背景的。

首先,利用模型切割人物和背景后,模型会返回一个 uint8 图片形式的分割掩码,就是buffer类型数据。然后我们可以将这个buffer数据转变为ImageData对象,这个ImageData对象可以充当画布内容,显示在画布上。所以,最后的实现过程就是,在video元素中拿到摄像头内容,再将摄像头内容交给模型进行切割,之后拿到切割数据,交给画布进行绘制,这就算完成了。

注意:video元素只是一个过客,最终内容呈现是由canvas元素实现的。 其实大多数使用模型的操作过程都类似,都是利用canvas来实现的。

现在再来看看使用到的api,如下:

ImageData对象,文档地址是这里,它接收四个参数:

  1. ImageData.data:一个 Uint8ClampedArray 数组, 表示包含 RGBA 顺序数据的一维数组,整数值介于 0 到 255(含)之间。顺序从左上角像素到右下角按行排列。
  2. ImageData.colorSpace: 指示图像数据的颜色空间的字符串。
  3. ImageData.height:一个无符号长整数,表示 ImageData 的实际高度(以像素为单位)。
  4. ImageData.width:一个无符号长整数,表示 ImageData 的实际高度(以像素为单位)。

使用示例:

new ImageData(width, height)
new ImageData(width, height, settings)

new ImageData(dataArray, width)
new ImageData(dataArray, width, height)
new ImageData(dataArray, width, height, settings)

putImageData()方法,就是将上面ImageData对象绘制出来,它可以接收多个参数,如下:

putImageData(imageData, dx, dy)
putImageData(imageData, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight)
  1. imageData: 包含像素值数组的 ImageData 对象。
  2. dx: 将图像数据放置在目标画布中的水平位置(x 坐标)。
  3. dy: 将图像数据放置在目标画布中的垂直位置(y 坐标)。
  4. dirtyX: 从中提取图像数据的左上角的水平位置(x 坐标)。默认为 0。
  5. dirtyY: 从中提取图像数据的左上角的垂直位置(y 坐标)。默认为 0。
  6. dirtyWidth: 要绘制的矩形的宽度。默认为图像数据的宽度。
  7. dirtyHeight: 要绘制的矩形的高度。默认为图像数据的高度。

实操

OK,下面进入实操环节。

第一步,先下载SelfieSegmenter(方形)模型,直接在文档地址点击就能下载

第二步,安装依赖@mediapipe/tasks-vision,我建议将这个依赖下载后直接复制到public文件里,方便使用

第三步,打开官方demo地址,开始cv工程

html部分

<video autoplay controls muted class="demo1" id="videoDom"></video>
<canvas
  class="canvas"
  id="canvasDom"
></canvas>

js部分

// 初始化
const createImageSegmenter = async () => {
  const audio = await FilesetResolver.forVisionTasks("/tasks-vision/wasm");

  // 该模型可以分割人物肖像,并可用于替换或修改图像中的背景。该模型输出两个类别,索引为 0 的背景和索引为 1 的人物。
  // 该模型具有不同输入形状的版本,包括方形版本和横向版本,这对于输入始终为该形状的应用程序(例如视频通话)可能更有效。
  imageSegmenter = await ImageSegmenter.createFromOptions(audio, {
    baseOptions: {
      modelAssetPath: "/model/selfie_segmenter.tflite",
      delegate: "GPU",
    },
    runningMode: "VIDEO",
    outputCategoryMask: true,
    outputConfidenceMasks: false,
  });
};


// 自定义背景 关键
const createCustomImgData = () => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onerror = (e) => {
      reject("加载失败" + e);
    };

    // 在图片加载后 通过ctx.getImageData方法拿到图片数据
    img.onload = () => {
      const canvas = document.createElement("canvas");
      const ctx = canvas.getContext("2d");
      canvas.width = videoDom.videoWidth;
      canvas.height = videoDom.videoHeight;
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
      resolve(data); // 将获取到的 customImgData 传递给 resolve
    };
    img.src = bgImg; // 背景图片 可以自定义
  });
};


// 回调函数 处理模型返回的数据 从中提取出人物 混合自定义图片 绘制到canvas上  实现虚拟背景  
const callbackForVideo = async (result) => {
  if (!customImgData.value) {
    customImgData.value = await createCustomImgData();
  }

  let imageData = canvasCtx.getImageData(
    0,
    0,
    videoDom.videoWidth,
    videoDom.videoHeight
  ).data;
  const mask = result.categoryMask.getAsFloat32Array();
  let j = 0;
  for (let i = 0; i < mask.length; ++i) {
    if (mask[i] === 0) {
      const maskVal = Math.round(mask[i] * 255.0);
      const legendColor = legendColors[maskVal % legendColors.length];
      imageData[j] = (legendColor[0] + imageData[j]) / 2;
      imageData[j + 1] = (legendColor[1] + imageData[j + 1]) / 2;
      imageData[j + 2] = (legendColor[2] + imageData[j + 2]) / 2;
      imageData[j + 3] = (legendColor[3] + imageData[j + 3]) / 2;
    } else {
      imageData[j] = customImgData.value[j];
      imageData[j + 1] = customImgData.value[j + 1];
      imageData[j + 2] = customImgData.value[j + 2];
      imageData[j + 3] = customImgData.value[j + 3];
    }
    j += 4;
  }

  // 格式化 
  const uint8Array = new Uint8ClampedArray(imageData.buffer);
  const dataNew = new ImageData(
    uint8Array,
    videoDom.videoWidth,
    videoDom.videoHeight
  );

  // 绘制
  canvasCtx.putImageData(dataNew, 0, 0);

  // 不断触发执行
  if (webcamRunning.value === true) {
    window.requestAnimationFrame(predictWebcam);
  }
};

// 执行模型
const predictWebcam = async () => {
  if (videoDom.currentTime === lastWebcamTime) {
    window.requestAnimationFrame(predictWebcam);
    return;
  }

  lastWebcamTime = videoDom.currentTime;
  canvasCtx.drawImage(
    videoDom,
    0,
    0,
    videoDom.videoWidth,
    videoDom.videoHeight
  );
  // Do not segmented if imageSegmenter hasn't loaded
  if (!imageSegmenter) {
    return;
  }
  startTimeMs = window.performance.now();

  // Start segmenting the stream.
  imageSegmenter.segmentForVideo(videoDom, startTimeMs, callbackForVideo);
};


// 点击确定 在video元素中有数据后开始执行
const onSubmit = async () => {
  if (!hasGetUserMedia()) {
    ElMessage({
      showClose: true,
      message: "浏览器不支持摄像头",
      type: "error",
    });
  }

  if (!form.audioInId || !form.audioOutId || !form.videoId) {
    return;
  }

  if (!imageSegmenter) {
    return;
  }

  webcamRunning.value = true;

  videoDom.srcObject = await getTargetDeviceMedia();
  videoDom.addEventListener("loadeddata", predictWebcam);
};

// 初始化
createImageSegmenter();
onMounted(async () => {
  videoDom = document.getElementById("videoDom");
  canvasDom = document.getElementById("canvasDom");
  canvasCtx = canvasDom.getContext("2d");
  await init();
});

这里大部分代码还是使用的官方demo,只是改成了vue3版本。在它的基础上,添加了自定义背景图片。这里是写死的背景,也可以替换为动态的。

这里是刚进入页面就进行模型初始化,也可以改成点击使用虚拟背景后再初始化,只是这样的话则需要用户等待一段时间才能使用。

整个实现过程是在用户选择摄像头后,监听video元素的loadeddata方法,在这里调用predictWebcam方法来执行模型,不断地切割人物和背景,将背景替换为自定义图片,然后进行混合绘制,实现类似视频效果。当然,canvas也有方法可以将绘制的内容当成流来处理,使用canvasDom.captureStream()方法就可以拿到画布流,然后交给video元素显示,也是可以的。这里在直播模式时会介绍。

注意callbackForVideo中的mask,这里是关键点,就是在这里处理人物和自定义背景,如果想要修改人物的颜色,也是在这里处理。在这里处理完后,才会交给canvas进行绘制。使用其他模型,也是类似的处理方式。一招鲜吃遍天!

其实看下来也是很简单的,毕竟有demo示例,改改就能用。思路通了,怎么改,改成啥样都了如指掌。

小节

本小节介绍了如何实现虚拟背景。首先确定要实现效果,挑选合适模型,然后结合官方demo示例,在此基础上进行修改,将自定义图片作为背景,与模型切割出来的人物进行混合,达到虚拟背景的效果。在实现过程中涉及到的canvas相关api也有所提及,重点是putImageData方法,该方法可以将ImageData对象绘制到画布上。

到这里,已经实现了一对一的音视频通话的基本功能了。下一小节来看看如何实现一对多视频,即直播模式,冲!!!