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 惯例) |
八、自测挑战
- 公式题:灰度加权 0.299/0.587/0.114,计算纯黄 (255,255,0) 和纯青 (0,255,255) 的灰度值。哪个更亮?符合直觉吗?
- 手算卷积:一个 3×3 图像区块,R 通道为
[[10,10,10],[10,100,10],[10,10,10]],用锐化核[[0,-1,0],[-1,9,-1],[0,-1,0]]卷积后,中心像素的 R 值是多少? - 实现题:写"棕褐色(Sepia)滤镜"——把每个像素按公式变换:
R' = 0.393R+0.769G+0.189B,G' = 0.349R+0.686G+0.168B,B' = 0.272R+0.534G+0.131B(老照片色调)。 - 设计题:给第 5 节实战加一个"马赛克"滤镜:把画面按 8×8 分块,每块取左上角像素颜色填充整块。写出核心函数(提示:双层循环步长为 8)。
- 思考题:为什么"核内数字和为 0"时平坦区域(纯色)一定变黑?用卷积公式推导。
- 进阶题:实现 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 组合拳:把像素运算扔进后台线程,主线程只管画;再加上离屏预渲染(静态背景只画一次),性能焦虑一扫而空。