20、视频处理:分辨率适配、帧率控制、美颜滤镜基础、虚拟背景实现

视频处理这块,说实话是WebRTC开发里最“出活”也最“坑多”的部分。你想想看,用户打开摄像头,看到的画面糊成一团,或者卡得像幻灯片,那体验基本就凉了。我这些年做实时通信,踩过的坑有一半都跟视频处理有关。今天咱们就把分辨率适配、帧率控制、美颜滤镜和虚拟背景这几个核心话题掰开揉碎聊一聊。

20.1 分辨率适配:别让用户自己选

很多开发者喜欢给用户一个下拉框,让用户自己选“360p”、“720p”、“1080p”。我个人习惯是——千万别这么干。用户根本不懂这些数字意味着什么,而且网络环境一变,选好的分辨率就成了灾难。

分辨率适配的核心逻辑其实就一句话:根据网络带宽和终端能力,动态调整

20.1.1 获取摄像头支持的分辨率

先看看你的摄像头能输出什么规格。用 getUserMediacapabilities 接口就能拿到:

const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();

console.log('支持的分辨率范围:', capabilities.width, capabilities.height);
// 输出示例:{ width: { min: 160, max: 1920 }, height: { min: 120, max: 1080 } }

嗯,这里要注意:getCapabilities() 返回的是范围,不是具体列表。如果你想拿到所有支持的离散分辨率,得自己枚举。我在项目中遇到过某些老旧摄像头,标称支持720p,但实际只能稳定输出480p。所以建议你做一个“阶梯降级”策略。

20.1.2 动态分辨率切换

WebRTC 提供了 setParameters 方法,可以在不重启视频流的情况下动态调整分辨率。我一般这样用:

async function adaptResolution(track, targetWidth, targetHeight) {
    const constraints = {
        width: { ideal: targetWidth },
        height: { ideal: targetHeight }
    };
    await track.applyConstraints(constraints);
    console.log(`分辨率已调整为:${targetWidth}x${targetHeight}`);
}

但这里有个坑——applyConstraints 不是所有浏览器都支持实时切换。Chrome 表现最好,Safari 偶尔会抽风。我的建议是:切换前先检查 track.readyState 是否为 'live',否则会抛异常。

核心原则:分辨率适配不是“一刀切”,而是“动态平衡”。带宽充足时上720p,紧张时降到360p,实在不行就240p保底。别让视频断流,比画质更重要。

20.2 帧率控制:流畅度与性能的博弈

帧率这东西,说白了就是每秒刷多少帧。30fps 是行业标准,但如果你在做屏幕共享或者文档演示,15fps 其实就够了。我做过一个在线教育项目,老师端用 30fps 推流,结果学生端低端手机直接卡死。后来我们把老师端降到 20fps,学生端用 15fps 接收,体验反而好了。

20.2.1 设置帧率约束

getUserMedia 阶段就可以指定帧率:

const constraints = {
    video: {
        frameRate: { ideal: 30, max: 30 }
    }
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);

注意 idealmax 的区别。ideal 是“尽量达到”,max 是“不能超过”。我习惯把 max 设死,防止某些摄像头在光线好时飙到 60fps,把 CPU 吃光。

20.2.2 动态帧率调整

跟分辨率一样,帧率也可以动态调。我常用的策略是监听 RTCPeerConnectionstats 事件,根据实际发送码率来调整:

async function adjustFrameRateBasedOnBandwidth(pc, track) {
    const stats = await pc.getStats();
    let availableBandwidth = 0;
    stats.forEach(report => {
        if (report.type === 'candidate-pair' && report.state === 'succeeded') {
            availableBandwidth = report.availableOutgoingBitrate;
        }
    });

    let targetFps = 30;
    if (availableBandwidth < 500000) { // 小于 500kbps
        targetFps = 15;
    } else if (availableBandwidth < 1000000) { // 小于 1Mbps
        targetFps = 20;
    }

    await track.applyConstraints({
        frameRate: { ideal: targetFps, max: targetFps }
    });
}

小技巧:帧率降低后,记得同步调整编码器的 maxFramerate 参数。否则编码器还在按 30fps 的节奏工作,浪费性能。

20.3 美颜滤镜基础:从像素操作开始

美颜滤镜,说白了就是图像处理。WebRTC 里做美颜,最直接的方式是操作 Canvas 上的像素数据。我最早做美颜时,天真地以为有现成的 SDK 可以直接用,后来发现大部分场景都得自己写核心逻辑。

20.3.1 获取视频帧并绘制到 Canvas

const video = document.getElementById('localVideo');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

function processFrame() {
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    ctx.drawImage(video, 0, 0);

    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    // 在这里处理像素数据
    applyBeautyFilter(imageData.data, canvas.width, canvas.height);
    ctx.putImageData(imageData, 0, 0);

    requestAnimationFrame(processFrame);
}

20.3.2 简单的磨皮算法

磨皮的核心是“模糊”+“保留边缘”。我常用的是一种叫“双边滤波”的简化版:

function applyBeautyFilter(pixels, width, height) {
    const temp = new Uint8ClampedArray(pixels);
    const radius = 2; // 模糊半径

    for (let y = radius; y < height - radius; y++) {
        for (let x = radius; x < width - radius; x++) {
            let r = 0, g = 0, b = 0, count = 0;
            for (let dy = -radius; dy <= radius; dy++) {
                for (let dx = -radius; dx <= radius; dx++) {
                    const idx = ((y + dy) * width + (x + dx)) * 4;
                    r += temp[idx];
                    g += temp[idx + 1];
                    b += temp[idx + 2];
                    count++;
                }
            }
            const idx = (y * width + x) * 4;
            // 混合原图和模糊结果,控制磨皮强度
            const strength = 0.3;
            pixels[idx]     = temp[idx]     * (1 - strength) + (r / count) * strength;
            pixels[idx + 1] = temp[idx + 1] * (1 - strength) + (g / count) * strength;
            pixels[idx + 2] = temp[idx + 2] * (1 - strength) + (b / count) * strength;
        }
    }
}

性能警告:在 JavaScript 里逐像素操作非常慢。上面的代码只是教学演示,生产环境请使用 WebGL 或 WebAssembly 加速。我曾经在低端机上跑纯 JS 磨皮,帧率直接掉到 5fps,用户骂声一片。

20.4 虚拟背景实现:从抠图到合成

虚拟背景这两年火得不行。实现方案主要有两种:一种是基于 MediaPipe 的人体分割,另一种是用 TensorFlow.js 的 BodyPix 模型。我个人更推荐 MediaPipe,因为它性能更好,而且有 Web 版本。

20.4.1 使用 MediaPipe 实现背景替换

MediaPipe 的 selfie_segmentation 模型可以实时输出人像蒙版。基本流程是:

  1. 加载模型并初始化
  2. 对每一帧视频进行推理,得到人像掩码
  3. 将掩码与原图合成,替换背景
import { SelfieSegmentation } from '@mediapipe/selfie_segmentation';

const selfieSegmentation = new SelfieSegmentation({
    locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation/${file}`
});

selfieSegmentation.setOptions({
    modelSelection: 1, // 0: 通用, 1: 景观(更快)
});

selfieSegmentation.onResults((results) => {
    // results.segmentationMask 是二值掩码
    // results.image 是原图
    compositeBackground(results.image, results.segmentationMask, backgroundImage);
});

// 每帧调用
function onFrame(video) {
    selfieSegmentation.send({ image: video });
    requestAnimationFrame(() => onFrame(video));
}

20.4.2 合成背景的 Canvas 实现

function compositeBackground(videoFrame, mask, bgImage) {
    const canvas = document.getElementById('outputCanvas');
    const ctx = canvas.getContext('2d');

    // 1. 绘制背景
    ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);

    // 2. 用蒙版抠出人像
    ctx.globalCompositeOperation = 'destination-out';
    ctx.drawImage(mask, 0, 0, canvas.width, canvas.height);

    // 3. 绘制原图(只保留人像部分)
    ctx.globalCompositeOperation = 'source-atop';
    ctx.drawImage(videoFrame, 0, 0, canvas.width, canvas.height);

    // 重置混合模式
    ctx.globalCompositeOperation = 'source-over';
}

避坑指南:我曾经在移动端用 BodyPix 做虚拟背景,结果 iPhone 8 上只能跑 8fps。后来换成 MediaPipe 的轻量模型,帧率提升到 20fps。记住:模型大小和推理速度是直接 trade-off,别追求完美分割而牺牲实时性。

20.5 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到,视频处理从摄像头采集开始,经过分辨率/帧率控制,再到美颜和虚拟背景,最后通过编码器推流。每一步都有对应的优化策略。

视频处理知识体系 摄像头采集 分辨率适配 + 帧率控制 美颜滤镜 虚拟背景 其他滤镜/特效 编码器 → 推流 动态调整策略:带宽检测 → 降分辨率/帧率 → 保流畅度

20.6 性能优化建议

视频处理是 CPU/GPU 密集型任务。我总结了几条铁律:

  • 不要在主线程做像素操作——用 Web Worker 或 OffscreenCanvas 分流
  • 美颜和虚拟背景二选一——同时开两个特效,低端机直接崩
  • 帧率优先于画质——用户对卡顿的容忍度远低于模糊
  • 用 requestAnimationFrame 驱动处理循环——别用 setInterval,会掉帧

我的经验:在移动端做视频处理时,先把分辨率降到 480p 以下再跑算法。这样既能保证帧率,又能省电。用户不会在意 480p 和 720p 的区别,但会在意手机发烫和卡顿。

好了,视频处理这块的内容就这些。说白了就是“采集-处理-编码”三个环节,每个环节都有对应的优化手段。你在实际项目中遇到问题时,记得先定位是哪个环节出了问题——是采集端分辨率太高?还是处理算法太慢?还是编码器扛不住?定位清楚了,解决方案自然就有了。