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;
}
小技巧: 如果你追求极致性能,可以把 0.299、0.587、0.114 这些浮点数转成整数运算。比如乘以 1000 再除以 1000。但现代浏览器对浮点运算优化得很好,一般不需要这么做。

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;
}

嗯,就这么几行代码。但效果非常酷——尤其是拍夜景的时候,反转后有种赛博朋克的感觉。

注意: 反转颜色时,不要动 Alpha 通道。Alpha 控制透明度,改了画面会变得奇怪。我曾经犯过这个错,调试了半天才发现是 Alpha 被改了。

15.4 像素化效果:马赛克滤镜

像素化,就是大家常说的「马赛克」。原理和前面两个不一样——它不是逐像素处理,而是按块处理

思路是这样的:

  1. 把图像分成若干个小方块(比如 8x8 像素一块)
  2. 计算每个方块内所有像素的平均颜色
  3. 把整个方块的所有像素都设成这个平均颜色

说白了,就是「降采样再升采样」。块越大,马赛克越明显。

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 三个滤镜的知识体系

我把今天的内容整理成了一张图,方便你理解它们之间的关系:

视频滤镜核心知识体系 视频帧像素数据 灰度滤镜 反转颜色滤镜 像素化滤镜 逐像素处理 逐像素处理 区域处理 算法:加权平均法 gray = 0.299R + 0.587G + 0.114B R=G=B=gray 算法:255 - 原值 R' = 255 - R G' = 255 - G, B' = 255 - B 算法:块内平均 分块 → 计算平均色 → 整块填充 三种滤镜覆盖了「逐像素」和「区域处理」两种核心思路

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 线程,避免阻塞主线程。
我的经验: 对于移动端,我一般把 canvas 控制在 480×320 以内。肉眼几乎看不出区别,但帧率从 20fps 直接飙到 60fps。你想想看,用户在乎的是「流畅的滤镜效果」,而不是「4K 的马赛克」。

15.7 完整示例:把滤镜应用到视频流

最后,我们把这些滤镜串起来,应用到 WebRTC 的视频流上。核心流程是:

  1. <video> 元素获取视频帧
  2. 绘制到 <canvas>
  3. 获取 ImageData,应用滤镜
  4. 把处理后的 ImageData 放回 canvas
  5. 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