17、虚拟背景:基于Canvas的背景替换、色度键抠图(Chroma Key)、简单背景模糊
各位好,今天我们来聊聊虚拟背景。
说实话,虚拟背景这个功能现在太常见了。Zoom、腾讯会议、钉钉,几乎每个视频会议软件都有。但你有没有想过,在Web端,我们自己怎么实现?
嗯,其实核心就三个路子:Canvas像素级替换、色度键抠图(Chroma Key)、还有背景模糊。今天我把这三个方案都拆开讲清楚。
一、先搞清楚:虚拟背景的本质是什么?
说白了,就是把视频帧里的「人」和「背景」分开。然后对背景做替换、模糊或者删除。
那怎么分开?
- 色度键:靠颜色。背景是纯绿或纯蓝,直接按颜色抠。
- AI分割:靠模型。比如BodyPix、MediaPipe,识别出人的轮廓。
- 深度信息:靠硬件。比如iPhone的原深感摄像头。
我们今天重点讲前两个。因为第三个在Web端不太现实,硬件限制太大。
二、方案一:色度键抠图(Chroma Key)
这个方案最老,但也最稳。我在做直播项目时用过,效果非常干净。
前提条件:背景必须是纯色,通常是绿色或蓝色。为什么?因为人的肤色里几乎没有纯绿或纯蓝,不容易误抠。
2.1 算法原理
对每个像素,判断它的颜色是否接近绿色(或蓝色)。如果是,就把这个像素替换成新背景的像素。如果不是,保留原像素。
判断公式有很多种,我习惯用欧几里得距离:
// 判断像素是否接近绿色
function isGreen(r, g, b) {
// 目标绿色 (0, 255, 0)
const dr = r - 0;
const dg = g - 255;
const db = b - 0;
const distance = Math.sqrt(dr*dr + dg*dg + db*db);
return distance < threshold; // threshold 阈值,比如 150
}
但实际项目中,直接用RGB距离效果不太好。为什么?因为RGB空间里,颜色距离和人眼感知不一致。
我后来改用YUV色差法,效果明显更好:
function isGreenYUV(r, g, b) {
// 转YUV
const y = 0.299 * r + 0.587 * g + 0.114 * b;
const u = -0.147 * r - 0.289 * g + 0.436 * b;
const v = 0.615 * r - 0.515 * g - 0.100 * b;
// 绿色在U-V空间的特征
const du = u - (-0.1); // 绿色U分量参考值
const dv = v - 0.1; // 绿色V分量参考值
const dist = Math.sqrt(du*du + dv*dv);
return dist < 0.3; // 阈值可调
}
2.2 完整实现
async function chromaKeyProcess(video, canvas, bgCanvas) {
const ctx = canvas.getContext('2d');
const bgCtx = bgCanvas.getContext('2d');
// 每帧处理
function processFrame() {
// 1. 绘制当前视频帧
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 2. 获取像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// 3. 绘制背景图(缩放到相同尺寸)
bgCtx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
const bgData = bgCtx.getImageData(0, 0, canvas.width, canvas.height);
const bgPixels = bgData.data;
// 4. 逐像素处理
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (isGreenYUV(r, g, b)) {
// 替换为背景像素
data[i] = bgPixels[i];
data[i + 1] = bgPixels[i + 1];
data[i + 2] = bgPixels[i + 2];
// alpha 保持不变
}
// 否则保留原像素
}
// 5. 写回Canvas
ctx.putImageData(imageData, 0, 0);
requestAnimationFrame(processFrame);
}
processFrame();
}
三、方案二:基于Canvas的背景替换(AI分割)
色度键虽然简单,但要求背景必须是纯色。现实中哪有那么多纯色背景?
所以更实用的方案是AI人体分割。我用过TensorFlow.js的BodyPix,也用过MediaPipe的SelfieSegmentation。
3.1 使用BodyPix
BodyPix会输出一个分割掩码(mask),每个像素的值表示「属于人的概率」,范围0~1。
import * as bodyPix from '@tensorflow-models/body-pix';
// 加载模型
const net = await bodyPix.load({
architecture: 'MobileNetV1',
outputStride: 16,
multiplier: 0.75,
quantBytes: 2
});
// 每帧分割
async function segmentFrame(video) {
const segmentation = await net.segmentPerson(video, {
flipHorizontal: false,
internalResolution: 'medium',
segmentationThreshold: 0.7
});
// segmentation.data 是Uint8Array,0表示背景,1表示人
return segmentation;
}
3.2 用掩码合成新背景
function compositeWithMask(video, mask, canvas, bgCanvas) {
const ctx = canvas.getContext('2d');
const bgCtx = bgCanvas.getContext('2d');
// 绘制视频帧
ctx.drawImage(video, 0, 0);
const frameData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 绘制背景
bgCtx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
const bgData = bgCtx.getImageData(0, 0, canvas.width, canvas.height);
// 根据mask合成
for (let i = 0; i < mask.data.length; i++) {
const pixelIndex = i * 4;
if (mask.data[i] === 1) {
// 保留原像素(人)
// 不做任何操作
} else {
// 替换为背景
frameData.data[pixelIndex] = bgData.data[pixelIndex];
frameData.data[pixelIndex + 1] = bgData.data[pixelIndex + 1];
frameData.data[pixelIndex + 2] = bgData.data[pixelIndex + 2];
}
}
ctx.putImageData(frameData, 0, 0);
}
四、方案三:简单背景模糊
背景模糊其实比替换更简单。不需要分割出精确的人体轮廓,只需要一个粗略的掩码就够了。
为什么?因为模糊本身就会掩盖边缘的瑕疵。
4.1 实现思路
- 用AI模型生成一个低分辨率的掩码(比如原图的1/4大小)
- 对原图做高斯模糊
- 用掩码把模糊后的背景和清晰的人合成在一起
function applyBlurBackground(video, mask, canvas) {
const ctx = canvas.getContext('2d');
// 1. 绘制视频帧
ctx.drawImage(video, 0, 0);
// 2. 复制一份做模糊
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(canvas, 0, 0);
// 3. 应用高斯模糊(使用CSS filter)
tempCtx.filter = 'blur(15px)';
tempCtx.drawImage(canvas, 0, 0);
// 4. 用掩码合成
const frameData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const blurData = tempCtx.getImageData(0, 0, canvas.width, canvas.height);
// 掩码需要缩放到原图尺寸
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const maskX = Math.floor(x * mask.width / canvas.width);
const maskY = Math.floor(y * mask.height / canvas.height);
const maskIndex = maskY * mask.width + maskX;
if (mask.data[maskIndex] === 0) {
// 背景区域:使用模糊后的像素
const pixelIndex = (y * canvas.width + x) * 4;
frameData.data[pixelIndex] = blurData.data[pixelIndex];
frameData.data[pixelIndex + 1] = blurData.data[pixelIndex + 1];
frameData.data[pixelIndex + 2] = blurData.data[pixelIndex + 2];
}
}
}
ctx.putImageData(frameData, 0, 0);
}
五、三种方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 色度键 | 速度快,无需AI模型 | 必须纯色背景,边缘容易粗糙 | 直播间、录课场景 |
| AI分割替换 | 任意背景都能用,效果好 | 模型加载慢,移动端性能差 | PC端视频会议 |
| 背景模糊 | 对分割精度要求低,边缘瑕疵不明显 | 模糊效果有限,太强的模糊会穿帮 | 隐私保护、轻度美颜 |
六、知识体系总览
下面这张图,我把今天讲的三个方案和它们的技术栈串起来了:
七、一些实战经验
最后分享几个我踩过的坑:
- 性能问题:千万别在主线程做逐像素处理。用Web Worker把像素数据扔到后台线程处理,主线程只负责绘制。
- 边缘锯齿:色度键抠出来的边缘会有锯齿。我的解决办法是:在掩码边缘做3~5像素的渐变过渡,效果会柔和很多。
- 模型加载:BodyPix第一次加载可能要好几秒。我习惯在用户进入页面时就预加载模型,不要等到开启虚拟背景时才加载。
- 分辨率适配:AI分割模型通常输入分辨率是固定的(比如256×256)。如果摄像头是1080p,先缩放到模型输入尺寸,分割完再放大回原尺寸。这样能省大量计算。
好了,今天的内容就到这里。三种方案各有优劣,具体选哪个,取决于你的应用场景和性能要求。我个人建议:如果条件允许,优先用AI分割方案,用户体验最好。如果追求极致性能,色度键依然是最稳的选择。