17、虚拟背景:基于Canvas的背景替换、色度键抠图(Chroma Key)、简单背景模糊

各位好,今天我们来聊聊虚拟背景。

说实话,虚拟背景这个功能现在太常见了。Zoom、腾讯会议、钉钉,几乎每个视频会议软件都有。但你有没有想过,在Web端,我们自己怎么实现?

嗯,其实核心就三个路子:Canvas像素级替换色度键抠图(Chroma Key)、还有背景模糊。今天我把这三个方案都拆开讲清楚。

一、先搞清楚:虚拟背景的本质是什么?

说白了,就是把视频帧里的「人」和「背景」分开。然后对背景做替换、模糊或者删除。

那怎么分开?

  • 色度键:靠颜色。背景是纯绿或纯蓝,直接按颜色抠。
  • AI分割:靠模型。比如BodyPix、MediaPipe,识别出人的轮廓。
  • 深度信息:靠硬件。比如iPhone的原深感摄像头。

我们今天重点讲前两个。因为第三个在Web端不太现实,硬件限制太大。

核心思路: 从摄像头拿到视频帧 → 绘制到Canvas → 逐像素处理 → 输出新帧。

二、方案一:色度键抠图(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();
}
注意: 逐像素处理非常消耗CPU。如果分辨率是1080p,每帧要处理1920×1080≈200万个像素。我建议先缩放到720p甚至480p处理,性能会好很多。

三、方案二:基于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);
}
避坑指南: 我曾经遇到一个问题——BodyPix在移动端跑不动。后来发现是模型太大了。解决方案:用quantBytes: 2量化模型,或者换用MediaPipe的SelfieSegmentation,那个模型更轻量。

四、方案三:简单背景模糊

背景模糊其实比替换更简单。不需要分割出精确的人体轮廓,只需要一个粗略的掩码就够了。

为什么?因为模糊本身就会掩盖边缘的瑕疵。

4.1 实现思路

  1. 用AI模型生成一个低分辨率的掩码(比如原图的1/4大小)
  2. 对原图做高斯模糊
  3. 用掩码把模糊后的背景和清晰的人合成在一起
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);
}
性能优化建议: 不要每帧都做全尺寸模糊。我一般把模糊处理放在一个离屏Canvas上,分辨率降到1/2或1/4。人眼根本看不出区别,但帧率能翻倍。

五、三种方案对比

方案 优点 缺点 适用场景
色度键 速度快,无需AI模型 必须纯色背景,边缘容易粗糙 直播间、录课场景
AI分割替换 任意背景都能用,效果好 模型加载慢,移动端性能差 PC端视频会议
背景模糊 对分割精度要求低,边缘瑕疵不明显 模糊效果有限,太强的模糊会穿帮 隐私保护、轻度美颜

六、知识体系总览

下面这张图,我把今天讲的三个方案和它们的技术栈串起来了:

虚拟背景实现方案总览 视频帧输入 色度键抠图 YUV色差法 + 边缘羽化 AI人体分割 BodyPix / MediaPipe 背景模糊 高斯模糊 + 低分辨率掩码 Canvas合成 → 输出帧 三种方案最终都通过Canvas像素操作完成合成

七、一些实战经验

最后分享几个我踩过的坑:

  • 性能问题:千万别在主线程做逐像素处理。用Web Worker把像素数据扔到后台线程处理,主线程只负责绘制。
  • 边缘锯齿:色度键抠出来的边缘会有锯齿。我的解决办法是:在掩码边缘做3~5像素的渐变过渡,效果会柔和很多。
  • 模型加载:BodyPix第一次加载可能要好几秒。我习惯在用户进入页面时就预加载模型,不要等到开启虚拟背景时才加载。
  • 分辨率适配:AI分割模型通常输入分辨率是固定的(比如256×256)。如果摄像头是1080p,先缩放到模型输入尺寸,分割完再放大回原尺寸。这样能省大量计算。

好了,今天的内容就到这里。三种方案各有优劣,具体选哪个,取决于你的应用场景和性能要求。我个人建议:如果条件允许,优先用AI分割方案,用户体验最好。如果追求极致性能,色度键依然是最稳的选择。


公众号:蓝海资料掘金营,微信deep3321