15、视频滤镜:灰度滤镜实现、反转颜色滤镜、像素化效果
各位同学,今天我们来聊聊视频滤镜。说实话,滤镜这东西,大家每天都在用——美颜、复古、漫画效果……但底层到底怎么实现的?很多人可能没深究过。
我个人习惯,讲滤镜之前先问一个问题:视频帧的本质是什么?
说白了,就是一串像素数据。每个像素由 RGBA 四个通道组成。你改一个通道的值,画面颜色就变了。你改一组像素的值,画面风格就变了。
嗯,就是这么简单。
今天我们要实现三个经典滤镜:灰度、反转颜色、像素化。这三个滤镜覆盖了从「逐像素处理」到「区域处理」的两种典型思路。搞懂了它们,你就能自己写出几十种滤镜。
15.1 滤镜的核心原理:逐帧处理
视频滤镜,本质上是对每一帧图像做处理。你想想看,视频就是一连串的图片快速播放。我们拿到每一帧的像素数据,修改它,再放回去——滤镜就生效了。
在 WebRTC 中,我们通常通过 canvas 来截取视频帧,然后操作 ImageData 对象。这个对象里有一个 data 数组,按 RGBA 顺序排列。
举个例子:
// 假设我们有一个 canvas 上下文 ctx
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data; // Uint8ClampedArray
// 每个像素占4个字节:R, G, B, A
// 第一个像素:data[0]=R, data[1]=G, data[2]=B, data[3]=A
// 第二个像素:data[4]=R, data[5]=G, data[6]=B, data[7]=A
// 以此类推
我在项目中遇到过一个问题:getImageData 的性能开销其实不小。如果你每帧都去拿整张图的数据,移动端很容易掉帧。后面我会讲优化技巧,先别急。
15.2 灰度滤镜:最经典的入门滤镜
灰度滤镜,就是把彩色图像变成黑白。原理很简单:让 R、G、B 三个通道的值相等。
但怎么算这个「相等的值」?有三种常见算法:
| 算法 | 公式 | 特点 |
|---|---|---|
| 平均值法 | gray = (R + G + B) / 3 | 最简单,但画面偏暗 |
| 加权平均法(亮度法) | gray = 0.299*R + 0.587*G + 0.114*B | 符合人眼感知,最常用 |
| 去饱和度法 | gray = (max(R,G,B) + min(R,G,B)) / 2 | 效果柔和,类似老照片 |
我个人习惯用加权平均法。为什么?因为人眼对绿色最敏感,对蓝色最不敏感。直接用平均值,绿色和蓝色的权重一样,画面看起来就不自然。
来看代码实现:
function applyGrayscale(imageData) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 加权平均法
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
data[i] = gray; // R
data[i + 1] = gray; // G
data[i + 2] = gray; // B
// data[i + 3] 是 Alpha 通道,保持不变
}
return imageData;
}
15.3 反转颜色滤镜:负片效果
反转颜色,也叫「负片效果」。原理更简单:每个通道的值用 255 减去它。
为什么是 255?因为 8 位色深下,每个通道的取值范围是 0~255。255 是最大值,减去当前值,就得到了「相反」的颜色。
function applyInvert(imageData) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i]; // R
data[i + 1] = 255 - data[i + 1]; // G
data[i + 2] = 255 - data[i + 2]; // B
// Alpha 通道不变
}
return imageData;
}
嗯,就这么几行代码。但效果非常酷——尤其是拍夜景的时候,反转后有种赛博朋克的感觉。
15.4 像素化效果:马赛克滤镜
像素化,就是大家常说的「马赛克」。原理和前面两个不一样——它不是逐像素处理,而是按块处理。
思路是这样的:
- 把图像分成若干个小方块(比如 8x8 像素一块)
- 计算每个方块内所有像素的平均颜色
- 把整个方块的所有像素都设成这个平均颜色
说白了,就是「降采样再升采样」。块越大,马赛克越明显。
function applyPixelate(imageData, blockSize = 8) {
const data = imageData.data;
const width = imageData.width;
const height = imageData.height;
for (let y = 0; y < height; y += blockSize) {
for (let x = 0; x < width; x += blockSize) {
// 计算当前块的平均颜色
let totalR = 0, totalG = 0, totalB = 0;
let count = 0;
for (let dy = 0; dy < blockSize; dy++) {
for (let dx = 0; dx < blockSize; dx++) {
const px = x + dx;
const py = y + dy;
if (px >= width || py >= height) continue;
const idx = (py * width + px) * 4;
totalR += data[idx];
totalG += data[idx + 1];
totalB += data[idx + 2];
count++;
}
}
const avgR = totalR / count;
const avgG = totalG / count;
const avgB = totalB / count;
// 把整个块设成平均颜色
for (let dy = 0; dy < blockSize; dy++) {
for (let dx = 0; dx < blockSize; dx++) {
const px = x + dx;
const py = y + dy;
if (px >= width || py >= height) continue;
const idx = (py * width + px) * 4;
data[idx] = avgR;
data[idx + 1] = avgG;
data[idx + 2] = avgB;
}
}
}
}
return imageData;
}
15.5 三个滤镜的知识体系
我把今天的内容整理成了一张图,方便你理解它们之间的关系:
15.6 性能优化:别让滤镜拖垮帧率
滤镜写好了,但一跑起来发现——卡成幻灯片。为什么会这样?
原因很简单:每帧都要遍历所有像素。一个 1080p 的视频,一帧有 1920×1080 ≈ 200 万个像素。每个像素要算 3 个通道,那就是 600 万次运算。60fps 的话,每秒要算 3.6 亿次。
嗯,这个量级,纯 CPU 运算确实吃力。
我分享几个优化经验:
- 降低分辨率: 先把 canvas 缩小到 640×360 再处理,效果差别不大,但运算量减少到原来的 1/9。
- 使用 requestAnimationFrame: 不要用 setInterval,RAF 会自动匹配屏幕刷新率,而且页面不可见时会暂停。
- 避免频繁创建对象: ImageData 对象尽量复用,不要每帧 new 一个。
- Web Worker: 如果滤镜很复杂,可以把像素计算放到 Worker 线程,避免阻塞主线程。
15.7 完整示例:把滤镜应用到视频流
最后,我们把这些滤镜串起来,应用到 WebRTC 的视频流上。核心流程是:
- 从
<video>元素获取视频帧 - 绘制到
<canvas>上 - 获取
ImageData,应用滤镜 - 把处理后的
ImageData放回 canvas - 用
canvas.captureStream()生成新的 MediaStream
// 假设 video 是 WebRTC 的 <video> 元素
// canvas 是隐藏的 <canvas> 元素
const ctx = canvas.getContext('2d');
const width = 640;
const height = 480;
canvas.width = width;
canvas.height = height;
function processFrame() {
// 1. 绘制视频帧到 canvas
ctx.drawImage(video, 0, 0, width, height);
// 2. 获取像素数据
const imageData = ctx.getImageData(0, 0, width, height);
// 3. 应用滤镜(这里以灰度为例)
applyGrayscale(imageData);
// 4. 放回 canvas
ctx.putImageData(imageData, 0, 0);
// 5. 继续下一帧
requestAnimationFrame(processFrame);
}
// 启动处理循环
processFrame();
// 生成新的 MediaStream,可以替换掉原始流
const filteredStream = canvas.captureStream(30);
// filteredStream 现在可以传给 RTCPeerConnection
canvas.captureStream() 的帧率参数不要设太高。设 30fps 就够了。设 60fps 的话,移动端很容易发热。我曾经踩过这个坑,用户反馈手机烫得能煎鸡蛋……
好了,今天的内容就到这里。灰度、反转、像素化——这三个滤镜虽然基础,但背后的「逐像素处理」和「区域处理」两种思路,是所有高级滤镜的基石。你搞懂了它们,后面学美颜、磨皮、特效,都会轻松很多。
公众号:蓝海资料掘金营,微信deep3321