上一小节介绍了几个音视频通话的实用功能,共享屏幕、使用视频替代摄像头和关闭/开启摄像头。这节就来结合模型,实现视频通话虚拟背景。
注意:这节内容都是在webrtc实现音视频通话的基础上进行拓展的!!!
这里使用的是google的开源平台,具体地址是这个,可以先看看。
要实现虚拟背景,首先是要将人物和背景分离出来(这里的人物就不再区分衣服啥的了,简单处理)。然后挑选模型,在上面这个地址中,有列举出多个模型,但我们只需要将人物和背景分离即可,所以选取的模型是SelfieSegmenter(方形),也就是下面这个:
注意到这段话 该模型会输出两个类别:背景位于索引 0 处,人物位于索引 1 处,下面写代码是需要用到的。
再然后,就是使用模型了。这里官方也给出了一个demo示例,不过是html版本的,下面就来改造一下,并且加上自定义图片作为虚拟背景。
原理
在实操之前,再来解释下是如何实现虚拟背景的。
首先,利用模型切割人物和背景后,模型会返回一个 uint8 图片形式的分割掩码,就是buffer类型数据。然后我们可以将这个buffer数据转变为ImageData对象,这个ImageData对象可以充当画布内容,显示在画布上。所以,最后的实现过程就是,在video元素中拿到摄像头内容,再将摄像头内容交给模型进行切割,之后拿到切割数据,交给画布进行绘制,这就算完成了。
注意:video元素只是一个过客,最终内容呈现是由canvas元素实现的。 其实大多数使用模型的操作过程都类似,都是利用canvas来实现的。
现在再来看看使用到的api,如下:
ImageData对象,文档地址是这里,它接收四个参数:
- ImageData.data:一个 Uint8ClampedArray 数组, 表示包含 RGBA 顺序数据的一维数组,整数值介于 0 到 255(含)之间。顺序从左上角像素到右下角按行排列。
- ImageData.colorSpace: 指示图像数据的颜色空间的字符串。
- ImageData.height:一个无符号长整数,表示 ImageData 的实际高度(以像素为单位)。
- 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)
- imageData: 包含像素值数组的 ImageData 对象。
- dx: 将图像数据放置在目标画布中的水平位置(x 坐标)。
- dy: 将图像数据放置在目标画布中的垂直位置(y 坐标)。
- dirtyX: 从中提取图像数据的左上角的水平位置(x 坐标)。默认为 0。
- dirtyY: 从中提取图像数据的左上角的垂直位置(y 坐标)。默认为 0。
- dirtyWidth: 要绘制的矩形的宽度。默认为图像数据的宽度。
- 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对象绘制到画布上。
到这里,已经实现了一对一的音视频通话的基本功能了。下一小节来看看如何实现一对多视频,即直播模式,冲!!!