跳到主要内容

虚拟背景与超分

面向 WebRTC 实时通信场景的虚拟背景(人像分割 / 抠图 / 背景替换 / 模糊)与超分(发送端降采样 + 接收端超分恢复)工程实践。覆盖 MediaPipe Selfie Segmentation、ONNX Runtime Web、TensorFlow.js、WebGPU、WGSL compute shader、Insertable Streams 链路与性能预算。


1. 全链路架构

1.1 链路总览

虚拟背景与超分都是在 getUserMedia 拿到原始帧之后、RTCPeerConnection 编码上行之前(或下行解码之后),插入一段「读帧 → AI 推理 → 合成 → 写回 MediaStreamTrack」的处理管线。WebRTC 提供的标准入口是 MediaStreamTrack Insertable StreamsMediaStreamTrackProcessor + MediaStreamTrackGenerator),把 MediaStreamTrack 转成 ReadableStream<VideoFrame>WritableStream<VideoFrame>,这样就可以在 Worker 或主线程对 VideoFrame 做任意处理。

1.2 为什么要走 Insertable Streams 而不是 Canvas captureStream

早期实现是 <video><canvas> drawImagecanvas.captureStream()addTrack,这条链路有三个硬伤:

  • 时间戳丢失captureStream 不保留原始 VideoFrame.timestamp,丢给编码器后 RTP 时间戳抖动严重,B 帧或 SVC 编码的参考关系错乱。
  • 同步靠 rAFrequestAnimationFrame 与摄像头帧率不同步,主线程被布局阻塞时直接掉帧或叠帧。
  • 零拷贝缺失drawImage 强制走主线程 GPU upload / readback,720p 30 FPS 在中端机型已经吃 30%+ 的主线程。

Insertable Streams 直接以 VideoFrame 为基本单位,保留 timestampduration,可在 Worker 中以 GPU 纹理零拷贝交给 WebGPU / WebGL2,是当前 Chrome / Edge 上唯一工程可控的方案。Safari 17.4+ 已支持,Firefox 仍在 nightly。

1.3 什么时候不适用

  • 目标机型是 iOS 16 及以下、Firefox stable:Insertable Streams 不可用,只能退回 captureStream 或放弃虚拟背景,不要硬上。
  • 端到端加密(E2EE)已经占用 RTCRtpScriptTransform:注意 Insertable Streams 与 Encoded Transform 是两套不同 API,不冲突,但 CPU 预算要叠加估算。
  • 仅做静态背景替换且无需精度:直接在编码层用 SVC + 简单虚化也能达到接近效果,省 50%+ CPU。

2. MediaStreamTrack Insertable Streams 实战

2.1 主链路骨架(TypeScript)

// main.ts
interface VirtualBgOptions {
mode: 'blur' | 'image' | 'transparent';
bgImage?: ImageBitmap;
blurRadius?: number; // px,默认 12
}

async function setupVirtualBackground(
inputTrack: MediaStreamVideoTrack,
options: VirtualBgOptions,
): Promise<MediaStreamVideoTrack> {
if (!('MediaStreamTrackProcessor' in window)) {
throw new Error('Insertable Streams 不可用,需降级到 canvas.captureStream');
}

const processor = new MediaStreamTrackProcessor({ track: inputTrack });
const generator = new MediaStreamTrackGenerator({ kind: 'video' });

const worker = new Worker(new URL('./bg-worker.ts', import.meta.url), {
type: 'module',
});

// 把 readable / writable 直接 transfer 给 Worker,零拷贝
worker.postMessage(
{
type: 'init',
readable: processor.readable,
writable: generator.writable,
options,
},
[processor.readable, generator.writable, options.bgImage].filter(Boolean) as Transferable[],
);

return generator;
}

为什么要 transferReadableStream&lt;VideoFrame>WritableStream&lt;VideoFrame> 是 transferable,转移到 Worker 后主线程不再持有引用,所有帧处理都在 Worker 线程,主线程只负责 UI。否则 pipeTo 在主线程会和 React/Vue 渲染抢时间片。

2.2 Worker 内部处理(含 frame drop 策略)

// bg-worker.ts
let busy = false;
let dropped = 0;

self.onmessage = async (e: MessageEvent) => {
if (e.data.type !== 'init') return;
const { readable, writable, options } = e.data as {
readable: ReadableStream<VideoFrame>;
writable: WritableStream<VideoFrame>;
options: VirtualBgOptions;
};

const segmenter = await loadSegmenter(); // MediaPipe / ONNX 见 §3
const compositor = await createCompositor(options); // WebGPU 合成器见 §4

const reader = readable.getReader();
const writer = writable.getWriter();

while (true) {
const { value: frame, done } = await reader.read();
if (done) break;
if (!frame) continue;

// frame drop:上一帧未处理完则丢当前帧,避免延迟累积
if (busy) {
frame.close();
dropped++;
continue;
}

busy = true;
try {
const mask = await segmenter.segment(frame); // 返回 GPUTexture 或 ImageBitmap
const out = await compositor.composite(frame, mask, frame.timestamp);
frame.close();
await writer.write(out);
} catch (err) {
frame.close();
console.warn('[bg-worker] frame error', err);
} finally {
busy = false;
}
}
};

关键点

  • 必须 frame.close()VideoFrame 持有 GPU 资源,不释放会在 5~10 秒内 OOM 崩溃。
  • frame drop 而非 frame queue:实时通信优先低延迟,丢帧比累积延迟好。累积 200 ms+ 后听感和口型对不上,体验崩塌。
  • 不要 await 阻塞 reader:示例里用 busy 标志而不是串行 await,是因为串行会让 reader 内部 buffer 涨到 OOM。

2.3 与编码器 keyframe 对齐

WebRTC 编码器在带宽抖动或丢包时会请求 keyframe(PLI / FIR)。如果你在虚拟背景链路里替换了 VideoFrame,编码器看到的是合成帧,keyframe 节奏由编码器自行决定,不需要你手动插帧。但要注意:

  • 若 frame drop 率超过 30%,编码器会把丢帧当成场景突变,频繁触发 keyframe,码率瞬间打满。对策:drop 时也写一个「上一帧重复」的 VideoFrame(同 timestamp 递增),让编码器看到连续帧。
  • RTCRtpSender.getParameters().encodings[i].maxBitrate 给上行限速,避免 keyframe burst。

2.4 失败时的样子

  • 黑屏 / 卡画面:通常是没 frame.close() 或 Worker 抛异常未 catch,链路 stall。看 chrome://webrtc-internalsframesEncoded 是否还在涨。
  • 音画不同步:frame drop 后 timestamp 错位。务必把 out 的 timestamp 设成原始 frame.timestamp,而不是 performance.now()
  • 首帧 2 秒+:模型加载阻塞了第一帧。对策:模型加载期间直接透传原始 frame(不做处理),加载完成再切换。

3. 模型选型与运行时对比

3.1 三大运行时横评

运行时模型格式后端包大小1080p 延迟 (M2)1080p 延迟 (中端 Android)工程成熟度
MediaPipe Tasks (Web).tfliteWASM SIMD / WebGL / WebGPU1.2 MB(runtime)+ 250 KB(selfie_segmenter landscape)6 ms22 ms高,Google 官方维护,文档全
ONNX Runtime Web.onnxWASM SIMD / WebGL / WebGPU (1.17+)9 MB(all backends)/ 2 MB(仅 wasm-simd-threaded)8~12 ms35~55 ms中,模型生态最广,调优空间大
TensorFlow.js.json + .binWASM / WebGL / WebGPU1.5 MB(core)+ 后端10~18 ms50~80 ms中,BodyPix/SelfieSeg 老牌,新模型迭代慢

测试条件:1080p 输入、Selfie Segmentation landscape 模型、单帧推理(不含合成)。

结论

  • MediaPipe Tasks 是当前最稳的默认选项:包体小、首帧快、Google 自家模型质量高,落地阻力最小。
  • ONNX Runtime Web 适合自定义模型:要跑 RVM / MODNet / Real-ESRGAN 等社区模型,ONNX 是唯一路径。WebGPU EP 在 ORT 1.17+ 才稳定。
  • TensorFlow.js 不再推荐做新项目:维护节奏慢,WebGPU backend 仍是 alpha,包体大。仅用于已有 tfjs 模型的存量项目。

3.2 分割 / 抠图模型对比

模型任务大小输入分辨率M2 推理中端 Android 推理头发 / 边缘质量适用场景
MediaPipe Selfie Segmenter (general)二值分割250 KB (tflite)256×2564 ms18 ms中,发丝糊实时会议、低端机
MediaPipe Selfie Segmenter (landscape)二值分割250 KB144×2563 ms12 ms中下16:9 横屏会议
MediaPipe Image Segmenter (Multiclass)多类(人/背景/天空)1.5 MB257×2578 ms32 ms需要类别区分
BiSeNet v2 (人像)语义分割12 MB (onnx)512×51218 ms不推荐中上PC 端高质量
MODNetmatting(带 alpha)25 MB (onnx fp32) / 7 MB (fp16)512×51225 ms不推荐录播、虚拟主播
Robust Video Matting (RVM)视频 matting(recurrent)14 MB (mobilenetv3) / 50 MB (resnet50)任意,建议 480×270 内推12 ms (mbv3)45 ms (mbv3)高,时序稳定虚拟主播、绿幕替代

判断标准

  • 实时通信 30 FPS 1080p 上行,预算 8~10 ms / 帧给分割。MediaPipe Selfie Segmenter 几乎是唯一全机型可用的选项。
  • 录播或单人主播场景,画质优先,RVM mobilenetv3 是性价比最高的,时序一致性远好于逐帧 MODNet(MODNet 在头发边缘逐帧抖动)。
  • BiSeNet 不推荐做实时,更适合服务端预处理。

3.3 超分模型对比

模型倍数大小输入 → 输出M2 GPU 推理视觉质量适用
ESPCN2x / 3x / 4x60 KB (onnx)540p → 1080p3 ms中(锐化为主)弱机型保底
FSRCNN2x / 3x90 KB540p → 1080p4 ms替代 ESPCN
Real-ESRGAN-general-x4-v34x4.7 MB270p → 1080p35 ms高(GAN 质感)离线 / 录播
Real-ESRGAN-anime / tiny4x1.5 MB360p → 1440p12 ms中上卡通 / 录屏
RealSR2x4 MB540p → 1080p15 ms摄像头实拍
自研轻量(MobileSR)2x<500 KB540p → 1080p4~6 ms移动端实时

结论

  • 实时 WebRTC 接收端超分,预算极紧(要在 16 ms 内完成解码 + 超分 + 渲染),ESPCN / FSRCNN / 自研 MobileSR 是唯一可选项,目标是「锐化 + 抗压缩块效应」,不是真正补细节。
  • Real-ESRGAN 系只能用在录播或非实时场景,或者用作云端推理。在客户端跑 Real-ESRGAN-tiny 也要 12 ms,叠加分割就超预算。

4. WebGPU 落地:WGSL Compute Shader

4.1 为什么要 WebGPU 而不是 WebGL2

维度WebGL2WebGPU
Compute Shader不支持,靠 fragment shader 模拟原生 compute pipeline
线程组 / barrier有,可写共享内存做 separable blur
与 ML 框架互操作不便,需 readbackORT/MediaPipe 的 GPUTexture 直通
浏览器支持全平台Chrome 113+、Edge、Safari 17.4+,Android Chrome 121+
调试工具成熟仍在完善

判断:2026 年起,做新项目直接走 WebGPU + WebGL2 兜底。WebGL2 已不适合做 separable Gaussian blur 这类计算密集型操作。

4.2 高斯模糊 compute shader(WGSL,分离卷积)

// gaussian_blur_h.wgsl,水平方向 1D 高斯卷积
@group(0) @binding(0) var srcTex: texture_2d<f32>;
@group(0) @binding(1) var dstTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var<uniform> params: BlurParams;

struct BlurParams {
radius: u32,
sigma: f32,
width: u32,
height: u32,
};

const MAX_RADIUS: u32 = 32u;

@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= params.width || gid.y >= params.height) {
return;
}
let r = i32(params.radius);
let sigma = params.sigma;
let twoSigmaSq = 2.0 * sigma * sigma;

var sum: vec4<f32> = vec4<f32>(0.0);
var weightSum: f32 = 0.0;

for (var i: i32 = -r; i <= r; i = i + 1) {
let x = clamp(i32(gid.x) + i, 0, i32(params.width) - 1);
let w = exp(-f32(i * i) / twoSigmaSq);
let c = textureLoad(srcTex, vec2<i32>(x, i32(gid.y)), 0);
sum = sum + c * w;
weightSum = weightSum + w;
}
textureStore(dstTex, vec2<i32>(gid.xy), sum / weightSum);
}

垂直方向写一份 gaussian_blur_v.wgsl,把 srcTex 作为水平 pass 的输出,分离卷积把复杂度从 O(r²) 降到 O(r)。半径 12 的 1080p 高斯模糊在 M2 上 1.2 ms,中端 Android (Adreno 730) 上 4 ms。

4.3 合成 shader:mask + 原图 + 背景

@group(0) @binding(0) var srcTex: texture_2d<f32>; // 原始相机帧
@group(0) @binding(1) var bgTex: texture_2d<f32>; // 模糊后背景或自定义图
@group(0) @binding(2) var maskTex: texture_2d<f32>; // 分割 mask,单通道
@group(0) @binding(3) var dstTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(4) var samp: sampler;
@group(0) @binding(5) var<uniform> p: CompositeParams;

struct CompositeParams {
width: u32,
height: u32,
featherPx: f32, // 边缘羽化像素,建议 1.5~3
};

@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= p.width || gid.y >= p.height) { return; }
let uv = vec2<f32>(f32(gid.x) / f32(p.width), f32(gid.y) / f32(p.height));

let src = textureSampleLevel(srcTex, samp, uv, 0.0);
let bg = textureSampleLevel(bgTex, samp, uv, 0.0);
var m = textureSampleLevel(maskTex, samp, uv, 0.0).r;

// 羽化:对 mask 做 smoothstep,缓解锯齿
m = smoothstep(0.5 - p.featherPx * 0.01, 0.5 + p.featherPx * 0.01, m);

let outColor = mix(bg, src, m);
textureStore(dstTex, vec2<i32>(gid.xy), outColor);
}

4.4 WGSL compute vs WASM SIMD 实测

1080p Gaussian blur(半径 12)+ mask 合成:

平台WASM SIMD(线程化)WebGPU compute提升
M2 Mac8.5 ms1.6 ms5.3x
中端 Android (Adreno 730)28 ms5.2 ms5.4x
低端 PC (Intel UHD 620)35 ms12 ms2.9x
iPhone 12 (Safari 17.4)14 ms3.8 ms3.7x

结论:WebGPU 在所有现代平台上都有 3~5 倍提升,且把 GPU 占用从 fragment shader 的「全屏 quad pingpong」降到 compute 的精确线程组,发热和功耗显著低于 WebGL2 模拟方案。

4.5 失败时的样子

  • WebGPU adapter 拿不到:在企业内网受 GPU 沙箱策略影响时常见。必须有 WebGL2 / WASM SIMD 兜底分支,不能让虚拟背景整体崩。
  • GPU 进程 crash:长时间运行后 Chrome 偶发 GPU process restart,所有 GPUTexture 失效。需要监听 device.lost 事件,重建 device 与 pipeline。
  • iOS 17 早期版本 WGSL 编译报错:iOS 17.0~17.3 的 WebGPU WGSL 编译器对 textureLoad 的 mip level 参数很敏感,建议显式传 0

5. 超分链路:上行降采样 + 下行恢复

5.1 为什么要做

会议场景多人入会时,发送端上行带宽是稀缺资源。把摄像头从 1080p 降到 540p 上行,码率从 2.5 Mbps 降到 0.9 Mbps,节省 60%+ 上行带宽;接收端用 ESPCN 2x 把 540p 还原到 1080p,主观质量介于「原生 720p」和「原生 1080p」之间,远好于直接观看 540p 拉伸。

5.2 链路设计

关键决策

  • 发送端降采样必须在编码前,让编码器拿到的就是 540p。如果在编码后做,码率根本省不下来。
  • 接收端超分必须在解码后。在解码前对编码流做 SR 是错的:H.264/VP9 比特流不能在客户端 SR。
  • 超分倍数选 2x 而不是 4x:4x 模型大、延迟高,且 540p → 2160p 大多数场景显示不出来(用户屏幕没那么大)。

5.3 发送端降采样代码

async function downscaleFrame(
frame: VideoFrame,
targetW: number,
targetH: number,
): Promise<VideoFrame> {
// 优先用 VideoFrame.copyTo + 自定义 layout,零拷贝最快
// 退化方案:drawImage 到 OffscreenCanvas
const canvas = new OffscreenCanvas(targetW, targetH);
const ctx = canvas.getContext('bitmaprenderer') ??
canvas.getContext('2d')!;
if ('transferFromImageBitmap' in ctx) {
const bmp = await createImageBitmap(frame, {
resizeWidth: targetW,
resizeHeight: targetH,
resizeQuality: 'high',
});
(ctx as ImageBitmapRenderingContext).transferFromImageBitmap(bmp);
} else {
(ctx as OffscreenCanvasRenderingContext2D).drawImage(frame, 0, 0, targetW, targetH);
}
return new VideoFrame(canvas, {
timestamp: frame.timestamp,
duration: frame.duration ?? undefined,
});
}

注意createImageBitmapresizeQuality: 'high' 在 Chrome 上走 GPU,但在 Safari 仍是 CPU。Safari 上建议直接走 WebGPU compute 做 bilinear / Lanczos 缩放。

5.4 接收端超分(ONNX Runtime Web + WebGPU)

import * as ort from 'onnxruntime-web/webgpu';

let sr: ort.InferenceSession | null = null;

async function loadESPCN() {
ort.env.wasm.numThreads = 4;
sr = await ort.InferenceSession.create('/models/espcn_x2.onnx', {
executionProviders: ['webgpu', 'wasm'], // webgpu 失败自动 fallback
graphOptimizationLevel: 'all',
});
}

async function superResolve(frame: VideoFrame): Promise<VideoFrame> {
if (!sr) return frame;
const w = frame.codedWidth;
const h = frame.codedHeight;

// 用 GPUBuffer 直通(ORT 1.17+ 支持 ml-tensor + GPUTexture 互操作)
const tensor = await ort.Tensor.fromImage(frame, {
format: 'YCbCr', // ESPCN 仅在 Y 通道做超分
tensorFormat: 'NCHW',
dataType: 'float32',
});
const output = await sr.run({ input: tensor });
const outTensor = output.output as ort.Tensor;

// outTensor 已是 2x 尺寸,转回 VideoFrame
const bitmap = await tensorToImageBitmap(outTensor, w * 2, h * 2);
return new VideoFrame(bitmap, { timestamp: frame.timestamp });
}

ESPCN 只对 Y 通道超分,UV 双线性放大:这是论文原始做法,也是带宽 / 质量平衡最优解。色度信息在 H.264/VP9 已经是 4:2:0,超分对色度收益极低。

5.5 性能与失败

  • 首帧延迟:ESPCN 60 KB 模型加载 < 100 ms,编译 + warmup ~150 ms,首帧总延迟 ~250 ms 内可控。
  • 帧率不足时:监测 outFrame 实际 FPS < 24 时自动关闭超分,直接渲染原始 540p 拉伸。
  • 失败信号:超分输出出现棋盘格 artifact(model checkerboard),多半是 deconv 模型选错;ESPCN 用 sub-pixel conv,正确实现不会有棋盘格。

6. 性能预算

6.1 30 FPS 1080p 各方案占用

每帧预算 33.3 ms,扣掉浏览器合成与编码 8 ms,留给虚拟背景 + 超分约 25 ms

设备方案分割Blur合成超分编码总计 / 33.3 msCPU%GPU%是否可行
M2 MacMediaPipe + WebGPU blur4 ms1.2 ms0.8 ms-4 ms10 ms18%22%
M2 MacMediaPipe + WebGPU + ESPCN 2x41.20.83413 ms22%35%
M2 MacRVM mbv3 + WebGPU + ESPCN121.20.83421 ms30%55%
中端 Android (SD 8 Gen1)MediaPipe + WebGPU blur1842-630 ms35%60%勉强
中端 AndroidMediaPipe + WebGPU + ESPCN 2x18428638 ms50%80%不可行,需降到 720p
中端 AndroidMediaPipe + WebGPU blur,720p921-416 ms22%40%
低端 PC (Intel UHD 620)MediaPipe + WASM SIMD blur22358-873 ms95%30%不可行
低端 PCMediaPipe + WebGL2 blur22145-849 ms60%70%不可行,需降到 540p
低端 PCMediaPipe + WebGL2,540p852-419 ms35%55%

6.2 降级策略

按优先级从高到低自动降级:

  1. 目标 1080p 30 FPS + 完整虚拟背景 + 超分。
  2. 帧间隔超过 40 ms 持续 2 秒 → 关闭超分
  3. 帧间隔仍超 → 分辨率降到 720p
  4. 仍超 → WebGPU blur 半径从 12 降到 6
  5. 仍超 → 降到 540p,关闭 mask 羽化。
  6. 仍超 → 关闭虚拟背景,仅保留原始视频。

实测电池供电的 MacBook 在 powerSave 模式下 GPU 频率被限制到 50%,必须监听 navigator.getBattery()MediaStreamTrack.getStats()framesPerSecond,主动降级。

6.3 内存与 GC

VideoFrame 持有 GPU 资源,未 close 会让 V8 在 30 秒内 OOM。压测:1080p 30 FPS 持续 1 小时,正确实现下 JS Heap 稳定在 80120 MB,GPU 内存 200300 MB。任何泄漏(漏 close 一个 frame)都会以 ~100 MB / 分钟速度涨。


7. 反模式

反模式现象正确做法
主线程跑模型UI 卡顿、滚动掉帧、React commit 阻塞Worker + Insertable Streams transfer
不做 frame drop延迟从 30 ms 涨到 500 ms+,音画不同步busy 标志立即丢帧,不入队
canvas.captureStream 做新项目时间戳错乱、码率不稳、CPU 高Insertable Streams(MediaStreamTrackProcessor / Generator
frame.close()5~10 秒内 GPU 内存 OOM 崩溃所有分支必走 close,含 try/catch
超分模型 > 5 MB 直接上首帧 2 秒+,弱网用户白屏选 ESPCN / FSRCNN,模型 < 500 KB
用 fragment shader 模拟 compute1080p blur 14 ms,GPU 占用 70%+WebGPU compute pipeline + workgroup barrier
不做掉电降级MacBook 拔电后 GPU 降频,链路立刻爆延迟监听 battery + framesPerSecond,自动关超分
头发边缘不羽化锯齿严重,肉眼可见马赛克mask 出来后做 smoothstep + 半径 1~2 的小高斯
在 RTCRtpScriptTransform 里跑模型阻塞编码线程,码率震荡encoded transform 仅用于 E2EE,AI 走 raw insertable
让用户上传任意背景图XSS / 违规图、内存爆炸先 canvas decode 限尺寸 4K 内,再压成 ImageBitmap
把超分用在编码前上行码率不降反升,毫无意义超分只在接收端解码后做
模型 warmup 用真实第一帧用户看到首帧延迟 2 秒+启动时用 dummy tensor warmup,期间透传原始帧

8. 落地清单

实施顺序建议:

  1. 先用 MediaPipe Selfie Segmenter + WebGL2 blur 跑通最小链路,确认 Insertable Streams 在目标浏览器矩阵全部可用。
  2. 加 Worker 隔离与 frame drop,跑 1 小时压测,确认 JS Heap 与 GPU memory 稳定。
  3. 用 WebGPU compute shader 替换 blur,做 A/B 对比,确认中低端 Android 与 Intel 集显机型的提升。
  4. 接入降级策略,覆盖电池、热降频、网络抖动三类触发条件。
  5. 选定一个目标场景(多人会议 / 主播 / 教育)再决定是否上超分。超分不是默认必备,是带宽紧张时的额外手段。
  6. 灰度发布前必做:弱光、强逆光、深肤色、长发、戴眼镜五个固定测试 case,覆盖头发抠图穿帮与边缘锯齿。

9. 权威资料

核对日期:2026-06-22