Skip to main content

Canvas 图像处理进阶

Index.md 里只演示了一个 255 - value 的反色操作,但 Canvas 在像素级图像处理领域其实能撑起一整套"Photoshop 迷你版"——这正是 ECharts、Fabric.js、白板类应用的图像处理底色。本篇从底到顶拆一遍。

一、像素级操作的总入口:ImageData

Canvas 上所有的"画"最终都是像素。要做滤镜,第一步必须能拿到像素数组getImageData / putImageData 就是这个入口。

// 拿到一个 (x, y, w, h) 区域的像素数据
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imgData.data; // Uint8ClampedArray,每个像素占 4 字节
ctx.putImageData(imgData, 0, 0); // 写回

内存布局(R = Red, G = Green, B = Blue, A = Alpha)

  data:  [R0, G0, B0, A0,  R1, G1, B1, A1,  R2, G2, B2, A2, ...]
index: 0 1 2 3 4 5 6 7 8 9 10 11
像素: 像素 0 像素 1 像素 2

每个像素的 RGBA 严格按 [R, G, B, A] 顺序排布。没有 padding——一行像素的末尾就是下一行的开头,width * 4 字节对应一行。

Uint8ClampedArray 的"clamped"含义:写入时自动做 [0, 255] 截断(超过 255 的变 255,负数变 0)。这个特性极其重要:你做 value * 2 这种乘法时,不用手动处理溢出,省心很多。

二、高性能遍历:4 步循环的来历

新手写像素循环很容易写出 for (let i = 0; i < data.length; i++)——这在百万级像素里会慢 4 倍。原因:

// 错误示范:一个像素一个像素处理
for (let i = 0; i < data.length; i++) {
// data[i] 可能是 R, G, B, A 中的任意一个
// 每个分支都要判断走哪条路
}

// 正确示范:4 步循环
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// const a = data[i + 3];
}

为什么 4 步更快?

  1. V8 可以做循环展开:固定步长让 JIT 编译器更容易优化,省掉边界检查。
  2. CPU 预取更友好:连续访问相邻 4 个字节,缓存命中率最高。
  3. 可读性 & 正确性:拿到 r/g/b 三个局部变量后,所有运算都是纯算术,逻辑清晰。

更激进的优化:Uint32Array 视图

既然一个像素正好 4 字节,可以整像素读写:

const buf = imgData.data.buffer;
const pixels = new Uint32Array(buf); // 长度 = data.length / 4
pixels[i] = 0xff0000ff; // 一次性写入 ABGR(注意字节序)

在做"整像素填色"、"单色蒙版"等场景下,单条指令能顶 4 条字节写入,吞吐直接翻倍。

三、经典滤镜公式速查

下面列出最常用的几个滤镜,给出核心公式 + 完整实现:

1. 灰度化(Grayscale)

心理学实验证明人眼对绿色最敏感、蓝色最不敏感,所以灰度公式是加权平均,不是简单 (R+G+B)/3

function grayscale(imgData) {
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
// 心理学加权 (ITU-R BT.601)
const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
data[i] = data[i + 1] = data[i + 2] = gray;
}
return imgData;
}

2. 反色(Invert)

最简单:每个通道用 255 减。Alpha 通道不变

for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i];
data[i + 1] = 255 - data[i + 1];
data[i + 2] = 255 - data[i + 2];
// data[i+3] 不动
}

3. 亮度 / 对比度

// brightness: -255 ~ 255
// contrast: -100 ~ 100
function adjust(imgData, brightness = 0, contrast = 0) {
const data = imgData.data;
const c = (contrast / 100 + 1) ** 2; // 二次曲线让调节更自然
for (let i = 0; i < data.length; i += 4) {
for (let k = 0; k < 3; k++) {
let v = data[i + k];
v = (v - 128) * c + 128 + brightness; // 对比度围绕 128 灰度中心展开
data[i + k] = v;
}
}
return imgData;
}

对比度的物理意义:先把像素减去 128(让灰度中心归零),乘以系数 c 实现"远离/靠近灰度",再加上 128 平移回去。c > 1 拉大变亮暗差距,c < 1 趋向灰度。

4. 复古(Sepia)

复古色调 = 灰度 + 偏黄/偏棕的色调偏移。

function sepia(imgData) {
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2];
data[i] = r * 0.393 + g * 0.769 + b * 0.189;
data[i + 1] = r * 0.349 + g * 0.686 + b * 0.168;
data[i + 2] = r * 0.272 + g * 0.534 + b * 0.131;
}
return imgData;
}

四、卷积:滤镜背后的统一原理

模糊、锐化、边缘检测……这些看起来"很高级"的滤镜,本质都是同一个操作——卷积(Convolution)

核心直觉

卷积 = "用一个滑窗(卷积核)在图像上滑动,每到一个位置,就把窗口内像素和卷积核做加权求和,得到输出像素"。

  3×3 卷积核示例(锐化)       滑动过程
┌─────┬─────┬─────┐
│ 0 │ -1 │ 0 │ 图像 卷积核中心 输出
├─────┼─────┼─────┤ ┌─────┐ ▼ ┌─────┐
│ -1 │ 5 │ -1 │ ───► │ABCDE│ 逐像素滑过 ──► │新像素│
├─────┼─────┼─────┤ │FGHIJ│
│ 0 │ -1 │ 0 │ │KLMNO│
└─────┴─────┴─────┘ └─────┘

输出像素 = A*0 + B*-1 + C*0 + F*-1 + G*5 + H*-1 + K*0 + L*-1 + M*0

function convolve(imgData, kernel, divisor = 1, bias = 0) {
const { data, width, height } = imgData;
const out = new Uint8ClampedArray(data); // 拷贝一份作为输出
const side = Math.round(Math.sqrt(kernel.length));
const half = Math.floor(side / 2);

for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0,
g = 0,
b = 0;
for (let ky = 0; ky < side; ky++) {
for (let kx = 0; kx < side; kx++) {
const sy = Math.min(height - 1, Math.max(0, y + ky - half));
const sx = Math.min(width - 1, Math.max(0, x + kx - half));
const idx = (sy * width + sx) * 4;
const weight = kernel[ky * side + kx];
r += data[idx] * weight;
g += data[idx + 1] * weight;
b += data[idx + 2] * weight;
}
}
const oi = (y * width + x) * 4;
out[oi] = r / divisor + bias;
out[oi + 1] = g / divisor + bias;
out[oi + 2] = b / divisor + bias;
}
}
imgData.data.set(out);
return imgData;
}

几个常用卷积核

名称3×3 核用途
均值模糊[[1,1,1],[1,1,1],[1,1,1]] divisor=9简单模糊,效率最高
高斯模糊[[1,2,1],[2,4,2],[1,2,1]] divisor=16边缘更自然的模糊
锐化[[0,-1,0],[-1,5,-1],[0,-1,0]] divisor=1增强边缘
边缘检测 (Sobel X)[[-1,0,1],[-2,0,2],[-1,0,1]] divisor=1检测垂直边缘
浮雕[[-2,1,0],[1,1,1],[0,1,2]] divisor=1 + bias=128立体浮雕效果

踩坑点:边界处理

图像边缘的卷积会"越界"——3×3 核在 x=0 时需要访问 x=-1 的像素。常见三种处理:

  1. 忽略边界(最简单):边缘几个像素保留原值,输出会有 1-2 像素黑边。
  2. 夹紧到边界(上面代码的做法):用 Math.min/Math.maxx=-1 钳到 x=0,会有轻微"边缘亮度升高"。
  3. 环绕(toroidal):把右边的像素接到左边,工业级滤镜常用,但实现复杂。

教学和面试答 1 或 2 即可,不要过度设计。

五、性能优化:百万像素下的生存指南

getImageData 返回的 Uint8ClampedArray 在 1920×1080 图像上有约 830 万个元素(约 33MB 内存)。任何没优化的循环都会卡死主线程。

1. 离屏 Canvas(Offscreen Layer)

核心思想:把所有像素操作搬到离屏 Canvas 上做完,再 drawImage 到主画布。主线程只需要做一次 GPU 拷贝,体感丝滑。

const off = document.createElement("canvas");
off.width = canvas.width;
off.height = canvas.height;
const offCtx = off.getContext("2d", { willReadFrequently: true });

offCtx.drawImage(canvas, 0, 0); // 主画布先画到离屏
const imgData = offCtx.getImageData(0, 0, off.width, off.height);
// ... 在 imgData 上做滤镜 ...
offCtx.putImageData(imgData, 0, 0);

ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(off, 0, 0); // 一次性贴回主画布

{ willReadFrequently: true } 的意义:告诉浏览器"我接下来要大量读像素",浏览器会保留 CPU 端的位图(而不是上传到 GPU)。否则默认会把 canvas 当 GPU 纹理,每次 getImageData 都要做一次 GPU→CPU 拷贝,是性能杀手

2. 复用 ImageData

// 错误:每帧都新建 ImageData,触发大量 GC
function frame() {
const data = ctx.getImageData(0, 0, w, h);
// ...
}

// 正确:缓存 ImageData,每帧只更新 data 数组
const cached = ctx.getImageData(0, 0, w, h);
function frame() {
ctx.putImageData(cached, 0, 0);
// 直接 mutate cached.data
}

3. Web Worker 离主线程

getImageData / putImageData 在 Worker 里也能用,配合 OffscreenCanvas 可以做到完全不在主线程上做图像处理

// 主线程
const off = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: off }, [off]);

// worker
self.onmessage = (e) => {
const ctx = e.data.canvas.getContext("2d");
// 这里跑滤镜,主线程完全无感
};

4. 何时该上 WebGL / WebGPU

经验法则

  • < 500×500 像素 + 简单滤镜:纯 Canvas 2D 即可,60fps 无压力。
  • 1080p + 卷积类滤镜(3×3 卷积每个像素 9 次乘法):CPU 勉强 30fps,建议上 WebGL(用 fragment shader 一行 GLSL 顶替整个卷积循环)。
  • 4K 或实时视频滤镜:必须 GPU。Canvas 2D 走 ctx.filter = "blur(5px)" 这种 GPU 加速的内置滤镜,是最省事的折中方案。
// 简单 GPU 滤镜:CSS filter 也能作用在 canvas 元素上
canvas.style.filter = "blur(5px) contrast(1.2) sepia(0.3)";

ctx.filter 属性:Canvas 2D 原生支持的 GPU 滤镜,语法和 CSS filter 一致(blur / brightness / contrast / grayscale / sepia / hue-rotate 等)。无需写 GLSL 就能享受 GPU 加速。简单滤镜的"银弹"。

六、实战:手写马赛克 / 直方图

马赛克

原理:把图像分成 N×N 的小方块,每个方块取该区域所有像素的平均色,用平均色填充整个方块。

function mosaic(imgData, size = 10) {
const { data, width, height } = imgData;
const out = new Uint8ClampedArray(data);
for (let y = 0; y < height; y += size) {
for (let x = 0; x < width; x += size) {
// 1. 计算当前方块的平均色
let r = 0,
g = 0,
b = 0,
count = 0;
for (let dy = 0; dy < size && y + dy < height; dy++) {
for (let dx = 0; dx < size && x + dx < width; dx++) {
const i = ((y + dy) * width + (x + dx)) * 4;
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
}
r = (r / count) | 0;
g = (g / count) | 0;
b = (b / count) | 0;
// 2. 用平均色填回整个方块
for (let dy = 0; dy < size && y + dy < height; dy++) {
for (let dx = 0; dx < size && x + dx < width; dx++) {
const i = ((y + dy) * width + (x + dx)) * 4;
out[i] = r;
out[i + 1] = g;
out[i + 2] = b;
}
}
}
}
imgData.data.set(out);
return imgData;
}

直方图(用于自动曝光 / 自动对比度)

直方图 = 统计每个亮度值 [0, 255] 上有多少个像素。是图像处理里"调色阶"的依据。

function histogram(imgData) {
const data = imgData.data;
const buckets = new Uint32Array(256); // 0~255 共 256 个桶
for (let i = 0; i < data.length; i += 4) {
// 用亮度公式(灰度的加权系数)做统计
const lum =
(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0;
buckets[lum]++;
}
return buckets; // buckets[k] 表示亮度为 k 的像素数
}

直方图的应用:如果发现 buckets[0] 和 buckets[255] 都很大、中间很少,说明对比度偏低,可以做自动拉伸:

// 找到亮度 1% 分位和 99% 分位的值,作为新的黑/白点
const total = width * height;
const lo = findPercentile(buckets, 0.01);
const hi = findPercentile(buckets, 0.99);
// 把 [lo, hi] 线性映射到 [0, 255]
for (let i = 0; i < data.length; i += 4) {
for (let k = 0; k < 3; k++) {
data[i + k] = (((data[i + k] - lo) / (hi - lo)) * 255) | 0;
}
}

七、面试高频追问

Q:为什么 Canvas 2D 像素操作比 WebGL 慢一个数量级?

  1. Canvas 2D 的 getImageData 走 CPU(除非显式开 willReadFrequently),每帧都是 CPU 遍历。
  2. WebGL 的 fragment shader 是 GPU 并行——一个像素一个线程,1080p 屏幕的 200 万个像素同时算。
  3. Canvas 2D 没有 SIMD 指令,CPU 循环只能靠 V8 JIT 优化,单核上限就是单核上限。

Q:跨域图片为什么 getImageData 会抛 SecurityError?

浏览器会标记被跨域图片"污染"的画布为 tainted canvasgetImageData / toDataURL / toBlob 都会抛错。解决:图片加 crossOrigin="anonymous",且服务端返回 Access-Control-Allow-Origin: * 响应头。

Q:ImageData 的 data 长度是 width * height * 4 吗?

严格来说是的,但 getImageData 的 4 个参数 (sx, sy, sw, sh) 决定了区域大小。data.length === sw * sh * 4。面试时一定要强调"区域"概念,别脱口而出 canvas.width * canvas.height * 4

小结

像素级图像处理 = ImageData 入口 + 卷积核变换 + 离屏/Worker 优化。记住以下三件事足以应对 90% 的面试追问:

  1. 4 步循环 + Uint8ClampedArray 是基本功
  2. 卷积核 = 滑窗加权 统一了模糊/锐化/边缘检测
  3. 复杂场景一定要上 GPUctx.filter 或 WebGL fragment shader)