【Canvas 2D】day40-convolution-filters

作者:mario 发布时间: 2026-09-01 阅读量:4 评论数:0

Canvas 卷积与滤镜算法 — 一个 3×3 矩阵扫过全图,模糊锐化全靠它

昨天学会了读写像素,今天让像素"变魔法"。先学三个"逐像素独立运算"的入门滤镜:灰度(为什么不是 RGB 平均而是加权?)、反色(一行核心代码)、亮度/对比度(乘加运算)。然后进入图像处理的皇冠——卷积(Convolution):一个 3×3 小矩阵在画面上滑动,每停一步就"参考周围 8 个邻居"算出新颜色。模糊、锐化、边缘检测,全是同一套卷积代码换个矩阵数字。今天写完,你就拥有了独立实现一切图像滤镜的能力。


目录


一、入门三连:反色、灰度、亮度

1.1 反色(Negative):一行的魔法

/**
 * 反色滤镜:黑变白、白变黑、颜色反转(照片底片效果)
 * 原理:每个通道取"到 255 的距离" —— 0↔255 互换
 */
function invert(imageData: ImageData): void {
  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
    // A 不动:透明度不该被"反色"
  }
}

1.2 亮度(Brightness):整体加减

/**
 * 亮度滤镜:amount 正数提亮、负数压暗(-255 ~ 255)
 * Uint8ClampedArray 自动钳位:加过头自动变 255 ✅(Day 39 的恩惠)
 */
function brightness(imageData: ImageData, amount: number): void {
  const data = imageData.data;
  for (let i = 0; i < data.length; i += 4) {
    data[i]     += amount;
    data[i + 1] += amount;
    data[i + 2] += amount;
  }
}

1.3 对比度(Contrast):以 128 为轴缩放

/**
 * 对比度滤镜:factor > 1 拉大反差,< 1 变灰蒙蒙
 * 原理:以中灰 128 为轴心,把颜色"推离/拉近"轴心
 */
function contrast(imageData: ImageData, factor: number): void {
  const data = imageData.data;
  for (let i = 0; i < data.length; i += 4) {
    data[i]     = (data[i]     - 128) * factor + 128;
    data[i + 1] = (data[i + 1] - 128) * factor + 128;
    data[i + 2] = (data[i + 2] - 128) * factor + 128;
  }
}
// 直觉:128 是中性灰。factor=2 时,100 → 74(更远离128)?不对——
// (100-128)×2+128 = 72,比 100 更暗;200 → (200-128)×2+128 = 272 → 钳位 255 更亮
// 亮的更亮、暗的更暗 = 对比拉大 ✅

这三个滤镜的共同点:每个像素独立运算,不关心邻居——这是"逐像素滤镜"家族。它们快、简单,但效果"平"。真正高级的效果(模糊、锐化、描边)都需要邻居参与——这就是卷积登场的时刻。


二、灰度的深入:加权平均的人眼科学

2.1 错误的写法:简单平均

// ❌ 直觉写法:三个通道平均
const gray = (r + g + b) / 3;

为什么不公平? 人眼对三种颜色的敏感度天差地别:

人眼视锥细胞对不同波长的响应强度(大致):

绿色(~550nm):最敏感 ████████████████████
红色(~650nm):次之   ██████████████
蓝色(~450nm):最弱   ████████

同样物理强度的绿光 vs 蓝光,人眼觉得绿光"亮得多"。

如果简单平均:一张"绿色为主"的图和"蓝色为主"的图会显得一样亮——灰度图的明暗关系就失真了。

2.2 正确的写法:加权平均(ITU-R BT.601 标准)

/**
 * 灰度滤镜:按人眼敏感度加权
 * R×0.299 + G×0.587 + B×0.114(权重和恰好 = 1)
 * 绿色权重最大(人眼最敏感),蓝色最小
 */
function grayscale(imageData: ImageData): void {
  const data = imageData.data;
  for (let i = 0; i < data.length; i += 4) {
    const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
    data[i] = data[i + 1] = data[i + 2] = gray;   // 三通道同值 = 灰
  }
}
// 验证直觉:纯绿 (0,255,0) → gray = 150(较亮)
//          纯蓝 (0,0,255) → gray = 29(很暗)
//          人眼看起来:绿色确实比蓝色"亮" ✅

📌 为什么记住这个公式:它是热成像伪彩、边缘检测、人脸识别预处理的第一步——都先把图变灰(只留亮度信息,去掉色相干扰)。第 42 天 BOSS 战的"热成像模式"就用它。


三、卷积:像素的"邻里投票"

3.1 生活化直觉

想象评"优秀员工":

逐像素滤镜(今天的入门三连):
  只看你自己今年的 KPI → 直接定结果

卷积(邻里投票):
  你的得分 = 自己的 KPI × 权重 + 周围 8 个同事的 KPI × 各自权重
  → "邻居都不行就你行" 可能被拉低(模糊的雏形)
  → "你和大家反差大" 会被凸显(边缘检测的雏形)

3.2 卷积核(Kernel):那张"权重表"

一个 3×3 卷积核(就是个权重表):

        | k0  k1  k2 |
    K = | k3  k4  k5 |      k4 是"自己"的权重,其余是 8 邻居的
        | k6  k7  k8 |

计算某像素的新值:
  new(x,y) = 原图(x-1,y-1)·k0 + 原图(x,y-1)·k1 + 原图(x+1,y-1)·k2
           + 原图(x-1,y)  ·k3 + 原图(x,y)  ·k4 + 原图(x+1,y)  ·k5
           + 原图(x-1,y+1)·k6 + 原图(x,y+1)·k7 + 原图(x+1,y+1)·k8

(把核"扣"在画面上,对应位置相乘再求和)

3.3 标准卷积实现(背下来的模板)

/**
 * 通用卷积函数 —— 模糊/锐化/边缘检测共用的引擎
 * @param imageData 原始像素数据(会被原地修改)
 * @param kernel    3×3 卷积核(长度 9 的一维数组,行优先)
 *
 * ⭐ 两个必须注意的点:
 *   1. 必须【先拷贝原图】再写入 —— 边算边写会把"已算过的邻居"当输入(串污染)
 *   2. 边界像素的邻居会越界 —— 这里用"跳过边界一圈"的简化策略
 */
function convolve3x3(imageData: ImageData, kernel: number[]): void {
  const { width, height, data } = imageData;
  const src = new Uint8ClampedArray(data);    // ① 原图快照(只读输入)

  // ② 从 (1,1) 遍历到 (w-2, h-2):跳过最外圈(邻居不越界)
  for (let y = 1; y < height - 1; y++) {
    for (let x = 1; x < width - 1; x++) {
      let r = 0, g = 0, b = 0;

      // ③ 3×3 邻域累加
      for (let ky = -1; ky <= 1; ky++) {
        for (let kx = -1; kx <= 1; kx++) {
          const i = ((y + ky) * width + (x + kx)) * 4;   // 邻居像素索引
          const k = kernel[(ky + 1) * 3 + (kx + 1)];     // 对应核权重
          r += src[i] * k;
          g += src[i + 1] * k;
          b += src[i + 2] * k;
        }
      }

      // ④ 写回当前像素(Uint8ClampedArray 负数自动钳 0,超 255 钳 255)
      const o = (y * width + x) * 4;
      data[o] = r;
      data[o + 1] = g;
      data[o + 2] = b;
    }
  }
}

执行顺序图解

原图 src(快照,只读)          输出 data(边算边写)
┌─────────────┐                ┌─────────────┐
│ ░░░░░░░░░░░ │                │ ▓▓▓▓▓▓▓▓▓▓▓ │ ← 外圈保持原样(未处理)
│ ░┌───────┐░ │   convolve    │ ▓┌───────┐▓ │
│ ░│  9x9  │░ │  ─────────→   │ ▓│ 卷积后 │▓ │
│ ░└───────┘░ │                │ ▓└───────┘▓ │
│ ░░░░░░░░░░░ │                │ ▓▓▓▓▓▓▓▓▓▓▓ │
└─────────────┘                └─────────────┘

四、卷积核全家福

4.1 盒式模糊(Box Blur):人人平等

// 每个邻居权重相同(全 1/9 = 归一化,保证亮度不变)
const boxBlur = [
  1/9, 1/9, 1/9,
  1/9, 1/9, 1/9,
  1/9, 1/9, 1/9,
];
// 效果:每个像素变成"自己和 8 邻居的平均值" → 突兀的颜色被抹平 → 模糊
// 特点:快,但有轻微"方块感"

4.2 高斯模糊(Gaussian Blur):近邻权重高

// 权重按"距离中心的远近"衰减(高斯分布的近似)
const gaussianBlur = [
  1/16, 2/16, 1/16,
  2/16, 4/16, 2/16,
  1/16, 2/16, 1/16,
];
// 中心 4/16 权重最高,对角最远只有 1/16
// 效果:比盒式更"自然"的模糊(人眼觉得景深虚化就该长这样)
// ⚠ 真正的 Photoshop 级高斯模糊半径很大(几十像素),用"可分离卷积"优化(第六节)

4.3 锐化(Sharpen):中心和邻居"对着干"

// 中心 9,周围全 -1(和为 1,亮度守恒)
const sharpen = [
   0, -1,  0,
  -1,  9, -1,
   0, -1,  0,
];
// 原理:新值 = 9×自己 - 8×邻居平均 ≈ 自己 + 8×(自己 - 邻居平均)
//       "你比邻居亮多少,就再放大多少" → 边缘反差拉大 → 锐利
// ⚠ 负权重会产生负值/超 255 → Uint8ClampedArray 自动钳位(感恩 Day 39)

4.4 边缘检测(Edge Detection):找"变化剧烈"的地方

// 拉普拉斯算子(中心 -8 周围 1,或中心 8 周围 -1,符号只是反色区别)
const laplacian = [
   1,  1,  1,
   1, -8,  1,
   1,  1,  1,
];
// 原理:中心与邻居的"差值"——平坦区域差值≈0(黑),边缘差值大(亮)
// 效果:画面只剩轮廓线(先 grayscale 再卷积效果最佳:排除颜色干扰)

// 另一位明星:Sobel 算子(分 X/Y 两个方向,求梯度)—— 第 42 天挑战题

4.5 一张表总结

权重特征 效果
盒式模糊 全相等 1 平均化 → 模糊
高斯模糊 近大远小 1 自然模糊
锐化 中心正大、邻居负 1 边缘增强
拉普拉斯 中心负、邻居正 0 只留轮廓

规律:核内数字和为 1 → 整体亮度不变;和为 0 → 平坦区变黑(只剩变化量);和 ≠ 1 → 变亮/变暗。


五、实战:四滤镜一键切换

今天的实战目标:加载一张图(或用画布现画的内容),四个按钮切换滤镜,看同一画面四种处理效果

5.1 完整代码

// day40-filters.ts —— 四滤镜一键切换
import { bootCanvas } from "../day29/canvas-boot.js";

const canvas = document.querySelector("#board") as HTMLCanvasElement;
const { ctx, cssWidth, cssHeight } = bootCanvas(canvas);

const dpr = window.devicePixelRatio || 1;

// ===== 1. 准备源画面(这里直接画一幅,实际项目可 drawImage 一张图片)=====
function paintSource(): void {
  const grad = ctx.createLinearGradient(0, 0, cssWidth, cssHeight);
  grad.addColorStop(0, "#1a2740");
  grad.addColorStop(1, "#0e3a5f");
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, cssWidth, cssHeight);

  // 几个色块(便于观察滤镜差异)
  const colors = ["#ff4d4f", "#ffc53d", "#00ff88", "#00c6ff"];
  colors.forEach((c, i) => {
    ctx.fillStyle = c;
    ctx.fillRect(60 + i * 110, 80, 90, 90);
  });
  ctx.fillStyle = "#ffffff";
  ctx.font = "bold 48px sans-serif";
  ctx.fillText("AB", 220, 280);
}
paintSource();

// ===== 2. 保存原图快照(滤镜都在副本上做,随时能复原)=====
let original: ImageData;
function snapshot(): void {
  original = ctx.getImageData(0, 0, canvas.width, canvas.height);
}
snapshot();

// ===== 3. 滤镜注册表:名字 → 处理函数(策略模式的朴素实现)=====
type FilterFn = (img: ImageData) => void;

const filters: Record<string, FilterFn> = {
  原图: () => { /* 无操作 */ },

  反色: (img) => {
    const d = img.data;
    for (let i = 0; i < d.length; i += 4) {
      d[i] = 255 - d[i]; d[i + 1] = 255 - d[i + 1]; d[i + 2] = 255 - d[i + 2];
    }
  },

  灰度: (img) => {
    const d = img.data;
    for (let i = 0; i < d.length; i += 4) {
      const g = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
      d[i] = d[i + 1] = d[i + 2] = g;
    }
  },

  盒式模糊: (img) => convolve3x3(img, [
    1/9, 1/9, 1/9,
    1/9, 1/9, 1/9,
    1/9, 1/9, 1/9,
  ]),

  锐化: (img) => convolve3x3(img, [
     0, -1,  0,
    -1,  9, -1,
     0, -1,  0,
  ]),
};

// convolve3x3 见第 3.3 节(完整拷贝进本文件)

// ===== 4. 应用滤镜:复制原图 → 处理 → 写回 =====
function applyFilter(name: string): void {
  // ⭐ 关键:每次从 original 的拷贝开始(连续切换滤镜不会叠加)
  const img = new ImageData(new Uint8ClampedArray(original.data), original.width, original.height);

  const t0 = performance.now();          // 计时(性能意识)
  filters[name](img);
  const elapsed = performance.now() - t0;

  ctx.putImageData(img, 0, 0);           // 物理像素直通道(Day 39)
  console.log(`滤镜「${name}」耗时 ${elapsed.toFixed(1)}ms`);
}

// ===== 5. UI:按钮组 =====
Object.keys(filters).forEach((name) => {
  const btn = document.createElement("button");
  btn.textContent = name;
  btn.addEventListener("click", () => applyFilter(name));
  document.querySelector("#toolbar")!.appendChild(btn);
});

5.2 结构复盘

original(快照,只读圣域)
    │ 每次 new 一份拷贝
    ▼
ImageData 副本 ──→ filters[name](img) ──→ putImageData 写回画布
                  (纯函数式:改副本,不碰原件)

好处:
- 切换滤镜永远基于原图 → 效果可预期、可反复
- 滤镜函数签名统一 (img) => void → 注册表随便加新滤镜
- BOSS 战(Day 42)升级为"滤镜链":多个滤镜按序 pipe 应用

六、性能与优化:可分离卷积

6.1 3×3 卷积的计算量

每像素:9 次乘 + 8 次加(×3 通道 ≈ 51 次浮点运算)
800×600 = 48 万像素 → 约 2400 万次运算 → JS 里约 10-20ms(勉强 60fps)

如果是 5×5 高斯(大半径模糊):25 次乘 → 625 万像素级 → 100ms+ 卡死

6.2 高斯的救命特性:可分离

/**
 * 可分离卷积:把 2D 卷积拆成两次 1D 卷积
 * 数学性质:高斯核 = 水平向量 × 垂直向量(秩 1 矩阵)
 *
 * 3×3 高斯 = [1 2 1](横向)卷积一遍,再用 [1 2 1](纵向)卷积一遍
 * 计算量:9 次乘 → 3+3 = 6 次乘(5×5:25 → 5+5=10,半径越大收益越夸张)
 */

/** 一维卷积(水平或垂直方向) */
function convolve1D(imageData: ImageData, vec: number[], horizontal: boolean): void {
  const { width, height, data } = imageData;
  const src = new Uint8ClampedArray(data);
  const half = Math.floor(vec.length / 2);

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let r = 0, g = 0, b = 0;
      for (let k = -half; k <= half; k++) {
        // 水平卷积:邻居在 x 方向;垂直:邻居在 y 方向
        const nx = horizontal ? x + k : x;
        const ny = horizontal ? y : y + k;
        // 边界钳制(clamp 到画面内,比跳过边界更平滑)
        const cx = Math.min(Math.max(nx, 0), width - 1);
        const cy = Math.min(Math.max(ny, 0), height - 1);
        const i = (cy * width + cx) * 4;
        const w = vec[k + half];
        r += src[i] * w; g += src[i + 1] * w; b += src[i + 2] * w;
      }
      const o = (y * width + x) * 4;
      data[o] = r; data[o + 1] = g; data[o + 2] = b;
    }
  }
}

/** 快速高斯模糊:水平一遍 + 垂直一遍 */
function gaussianBlurFast(imageData: ImageData): void {
  convolve1D(imageData, [1/4, 2/4, 1/4], true);    // 横向
  convolve1D(imageData, [1/4, 2/4, 1/4], false);   // 纵向
}

6.3 更大的模糊半径:多次小模糊叠加

// 想要"半径 6"的大模糊?
// ❌ 直接造 13×13 的大核 → 169 次乘/像素,卡爆
// ✅ 3×3 高斯连续卷 3~4 遍 → 视觉等效,计算量小得多(业界通行做法)
function bigBlur(imageData: ImageData): void {
  for (let i = 0; i < 4; i++) gaussianBlurFast(imageData);
}

七、常见坑点与最佳实践

# 症状 解法
1 卷积时不拷贝原图 模糊出"拖影"怪纹 new Uint8ClampedArray(data) 快照
2 灰度用简单平均 绿色偏暗/蓝色偏亮,明暗失真 加权 0.299/0.587/0.114
3 反色把 Alpha 也反了 全图变半透明 A 通道不动
4 滤镜直接改 original 连续切换效果叠加,越切越怪 每次从拷贝开始处理
5 核权重和 ≠ 1(无意中) 全图莫名变亮/变暗 检查归一化(除以核内总和)
6 边界处理跳过 vs 钳制 跳过法留 1px 原始边框 需要完美边缘用钳制法(第 6.2 节)
7 大图 + 大核在主线程 UI 冻住半秒 区域化 / 可分离 / Worker(明天)
8 putImageData 用 CSS 坐标 贴图位置偏移 物理像素直通道(Day 39 惯例)

八、自测挑战

  1. 公式题:灰度加权 0.299/0.587/0.114,计算纯黄 (255,255,0) 和纯青 (0,255,255) 的灰度值。哪个更亮?符合直觉吗?
  2. 手算卷积:一个 3×3 图像区块,R 通道为 [[10,10,10],[10,100,10],[10,10,10]],用锐化核 [[0,-1,0],[-1,9,-1],[0,-1,0]] 卷积后,中心像素的 R 值是多少?
  3. 实现题:写"棕褐色(Sepia)滤镜"——把每个像素按公式变换:R' = 0.393R+0.769G+0.189BG' = 0.349R+0.686G+0.168BB' = 0.272R+0.534G+0.131B(老照片色调)。
  4. 设计题:给第 5 节实战加一个"马赛克"滤镜:把画面按 8×8 分块,每块取左上角像素颜色填充整块。写出核心函数(提示:双层循环步长为 8)。
  5. 思考题:为什么"核内数字和为 0"时平坦区域(纯色)一定变黑?用卷积公式推导。
  6. 进阶题:实现 Sobel 边缘检测:先灰度化,再分别用 Gx=[[-1,0,1],[-2,0,2],[-1,0,1]]Gy(转置)卷积,输出 √(gx²+gy²)

九、总结与知识图谱

Day 40 卷积与滤镜算法
├── 逐像素滤镜(无邻居)
│   ├── 反色 255-v
│   ├── 亮度 +=amount
│   ├── 对比度 (v-128)·f+128
│   └── 灰度 加权 0.299/0.587/0.114 ⭐
├── 卷积(邻里投票)
│   ├── 核 = 3×3 权重表
│   ├── 实现:快照原图 → 跳边遍历 → 9 邻累加 → 写回
│   └── 归一化:和=1 亮度不变 / 和=0 只剩变化
├── 卷积核全家福
│   ├── 盒式模糊(全等权)
│   ├── 高斯(近大远小)
│   ├── 锐化(9 中心 -1 邻居)
│   └── 拉普拉斯(边缘检测)
├── 性能
│   ├── 可分离卷积 2D → 1D×2
│   └── 大半径 = 小核多次卷
└── 实战架构
    └── original 快照 + 拷贝处理 + 注册表(Day 42 升级为滤镜链)

明天预告(Day 41):今天的滤镜在 800×600 上要跑 10-20ms——大屏是 1920×1080 起步,主线程必卡。明天的 OffscreenCanvas + Worker 组合拳:把像素运算扔进后台线程,主线程只管画;再加上离屏预渲染(静态背景只画一次),性能焦虑一扫而空。

评论