20、视频处理:分辨率适配、帧率控制、美颜滤镜基础、虚拟背景实现
视频处理这块,说实话是WebRTC开发里最“出活”也最“坑多”的部分。你想想看,用户打开摄像头,看到的画面糊成一团,或者卡得像幻灯片,那体验基本就凉了。我这些年做实时通信,踩过的坑有一半都跟视频处理有关。今天咱们就把分辨率适配、帧率控制、美颜滤镜和虚拟背景这几个核心话题掰开揉碎聊一聊。
20.1 分辨率适配:别让用户自己选
很多开发者喜欢给用户一个下拉框,让用户自己选“360p”、“720p”、“1080p”。我个人习惯是——千万别这么干。用户根本不懂这些数字意味着什么,而且网络环境一变,选好的分辨率就成了灾难。
分辨率适配的核心逻辑其实就一句话:根据网络带宽和终端能力,动态调整。
20.1.1 获取摄像头支持的分辨率
先看看你的摄像头能输出什么规格。用 getUserMedia 的 capabilities 接口就能拿到:
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);
注意 ideal 和 max 的区别。ideal 是“尽量达到”,max 是“不能超过”。我习惯把 max 设死,防止某些摄像头在光线好时飙到 60fps,把 CPU 吃光。
20.2.2 动态帧率调整
跟分辨率一样,帧率也可以动态调。我常用的策略是监听 RTCPeerConnection 的 stats 事件,根据实际发送码率来调整:
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 模型可以实时输出人像蒙版。基本流程是:
- 加载模型并初始化
- 对每一帧视频进行推理,得到人像掩码
- 将掩码与原图合成,替换背景
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 的区别,但会在意手机发烫和卡顿。
好了,视频处理这块的内容就这些。说白了就是“采集-处理-编码”三个环节,每个环节都有对应的优化手段。你在实际项目中遇到问题时,记得先定位是哪个环节出了问题——是采集端分辨率太高?还是处理算法太慢?还是编码器扛不住?定位清楚了,解决方案自然就有了。