Canvas 图像处理实战:getImageData 像素操作、灰度/复古滤镜与卷积(模糊、锐化、边缘检测)
做过在线图片工具、头像编辑器、或者简单的图片美化功能的前端,多半绕不开 Canvas 的像素处理。产品说"加个复古滤镜""让图片模糊一点""帮我描个边",本质上都是在对一张图的每个像素做数学运算。这篇不讲怎么用现成的滤镜库,而是把底层机制掰开:一张图在 Canvas 里到底是什么、getImageData 拿到的是什么结构、逐像素滤镜和卷积滤镜分别怎么写、以及大图场景下怎么别把浏览器卡死。代码都能直接跑,复制到一个空 HTML 里挂个 <canvas> 就能看效果。
一、getImageData:图片在内存里就是一维数组
Canvas 上的图,本质是一块连续的内存。通过 context.getImageData(x, y, w, h) 拿到的 ImageData 对象,核心是它的 data 属性——一个 Uint8ClampedArray,长度是 width * height * 4。每 4 个字节描述一个像素,依次是 R、G、B、A(红、绿、蓝、透明度),取值 0-255。Clamped 的意思是超出范围会自动截断到 0 或 255,不会溢出回环,这点在滤镜里很省心。
关键是坐标到数组下标的换算。像素 (x, y) 的红色分量在数组里的位置是 (y * width + x) * 4,后面紧跟着的 3 个格子就是 G、B、A:
// 把图片画到 canvas 上,再取出像素数据
const canvas = document.querySelector('#c');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const img = new Image();
img.src = 'photo.jpg';
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// 取出整张图的像素
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data; // Uint8ClampedArray
// 读取坐标 (x, y) 的 RGBA
const x = 10, y = 20;
const i = (y * canvas.width + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
console.log({ r, g, b, a });
};
改完像素后,用 ctx.putImageData(imageData, 0, 0) 写回画布。整个滤镜的套路都是:取数据 → 遍历 data 改值 → 写回。注意上面创建上下文时传了 willReadFrequently: true,如果你会频繁调 getImageData,加这个提示能让浏览器把画布放在更利于 CPU 读取的内存里,明显减少卡顿。
二、逐像素滤镜:每个点只看自己
最简单的一类滤镜叫"点算子"(point operator),新像素只由它自己的旧值决定,和邻居无关。遍历一遍数组就行。
灰度。把彩色转成黑白,不是简单三通道求平均,而是按人眼对不同颜色的敏感度加权。人眼对绿色最敏感、蓝色最迟钝,所以用 0.299R + 0.587G + 0.114B 这组经典系数(BT.601),出来的灰度才自然:
function grayscale(imageData) {
const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
// 加权亮度,比直接 (r+g+b)/3 更符合人眼观感
const gray = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = d[i + 1] = d[i + 2] = gray;
// d[i + 3] 是 alpha,保持不动
}
return imageData;
}
复古(sepia)。泛黄的老照片效果,本质是一个固定的颜色矩阵,把 RGB 重新混合成偏棕黄的色调:
function sepia(imageData) {
const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
// 经典 sepia 矩阵,结果由 Uint8ClampedArray 自动截断到 0-255
d[i] = 0.393 * r + 0.769 * g + 0.189 * b;
d[i + 1] = 0.349 * r + 0.686 * g + 0.168 * b;
d[i + 2] = 0.272 * r + 0.534 * g + 0.131 * b;
}
return imageData;
}
亮度与对比度。亮度是给每个通道加一个偏移量;对比度是让像素值围绕中点 128 向两端拉伸或压缩。对比度公式里的因子有个标准算法,contrast 取值范围一般是 -255 到 255:
function brightnessContrast(imageData, brightness, contrast) {
const d = imageData.data;
// 对比度因子的标准公式
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
for (let i = 0; i < d.length; i += 4) {
for (let c = 0; c < 3; c++) {
let v = d[i + c] + brightness; // 先调亮度
v = factor * (v - 128) + 128; // 再调对比度
d[i + c] = v; // 越界自动截断
}
}
return imageData;
}
这类滤镜的共同点:一层循环、步长 4、跳过 alpha、越界靠 Uint8ClampedArray 兜底。理解了灰度和 sepia,反相(255 - v)、色调分离、阈值二值化都是同一个模子里换个公式而已。
三、卷积:让每个像素"看一眼邻居"
模糊、锐化、边缘检测这类效果,光看自己不够,得看周围一圈像素。这就是卷积(convolution)。做法是准备一个小矩阵——卷积核(kernel),通常是 3×3 或 5×5,把它盖在每个像素上,核里每个数乘以对应位置的像素值,全部加起来,就是新像素。
不同的核就是不同的滤镜。比如 3×3 全是 1/9 的核,等于把自己和 8 个邻居取平均,就是均值模糊;把中心配一个大正数、周围配负数,就能突出边缘变化,实现锐化。
先写一个通用的卷积函数,之后换核就换效果:
// 通用卷积。kernel 是长度为 9 的一维数组(3×3),divisor 是归一化除数
function convolve(imageData, kernel, divisor = 1, offset = 0) {
const { width, height, data: src } = imageData;
// 必须基于源数据的副本计算,不能边读边写,否则算到一半的像素会污染后面的结果
const out = new Uint8ClampedArray(src.length);
const side = 3;
const half = 1; // side >> 1
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0;
// 遍历 3×3 邻域
for (let ky = 0; ky < side; ky++) {
for (let kx = 0; kx < side; kx++) {
// 边缘处用 clamp,把越界坐标夹回图内,避免黑边
const px = Math.min(width - 1, Math.max(0, x + kx - half));
const py = Math.min(height - 1, Math.max(0, y + ky - half));
const srcIdx = (py * width + px) * 4;
const wt = kernel[ky * side + kx];
r += src[srcIdx] * wt;
g += src[srcIdx + 1] * wt;
b += src[srcIdx + 2] * wt;
}
}
const dstIdx = (y * width + x) * 4;
out[dstIdx] = r / divisor + offset;
out[dstIdx + 1] = g / divisor + offset;
out[dstIdx + 2] = b / divisor + offset;
out[dstIdx + 3] = src[dstIdx + 3]; // alpha 原样保留
}
}
imageData.data.set(out);
return imageData;
}
有了它,各种效果就是换一组数字:
// 高斯模糊:中心权重高、越远越低,比均值模糊更自然。divisor 是核内元素之和 16
const gaussianBlur = [
1, 2, 1,
2, 4, 2,
1, 2, 1,
];
convolve(imageData, gaussianBlur, 16);
// 锐化:中心加强、四邻削弱,强化明暗交界。核和为 1,divisor 用 1
const sharpen = [
0, -1, 0,
-1, 5, -1,
0, -1, 0,
];
convolve(imageData, sharpen, 1);
// 拉普拉斯边缘检测:核和为 0,平坦区域归零变黑,只有颜色突变处留下亮线
const laplacian = [
-1, -1, -1,
-1, 8, -1,
-1, -1, -1,
];
convolve(imageData, laplacian, 1);
边缘检测还有更讲究的 Sobel 算子,它分横向和纵向两个核,分别求出水平和垂直方向的梯度,再把两者合成,出来的边缘更平滑、抗噪更好:
// Sobel 边缘检测:横竖两个方向各卷一遍,再按梯度幅值合成
function sobelEdge(imageData) {
const { width, height } = imageData;
const gray = grayscale(imageData); // 先转灰度,边缘只看亮度变化
const src = gray.data;
const out = new Uint8ClampedArray(src.length);
const gx = [-1, 0, 1, -2, 0, 2, -1, 0, 1]; // 水平梯度核
const gy = [-1, -2, -1, 0, 0, 0, 1, 2, 1]; // 垂直梯度核
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let sx = 0, sy = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const v = src[((y + ky) * width + (x + kx)) * 4]; // 灰度值取 R 即可
const k = (ky + 1) * 3 + (kx + 1);
sx += v * gx[k];
sy += v * gy[k];
}
}
const mag = Math.sqrt(sx * sx + sy * sy); // 梯度幅值
const idx = (y * width + x) * 4;
out[idx] = out[idx + 1] = out[idx + 2] = mag;
out[idx + 3] = 255;
}
}
imageData.data.set(out);
return imageData;
}
卷积里最容易踩的两个坑:一是不能原地修改,必须从源数据副本读、往新数组写,否则先算出来的像素会被当成邻居污染后面的计算;二是边缘处理,图边上的像素邻域会越界,要么像上面那样 clamp 夹回来,要么跳过最外圈,直接读越界下标会拿到 undefined 算出一片黑边。
四、性能:别在主线程上硬算大图
上面的代码逻辑没问题,但一张 4000×3000 的照片有 1200 万像素、4800 万个数组元素,卷积还要给每个像素跑 9 次邻域循环,主线程上跑完轻则掉帧、重则页面直接卡住几秒。几个实用的优化方向:
用好 TypedArray。 Uint8ClampedArray 本身就是紧凑的定型数组,别中途转成普通数组,也别用 for...of 或 forEach,老老实实用带下标的 for 循环,V8 对这种模式优化得最好。计算中间量能用 | 0 或位运算取整就别用 Math.floor。
搬到 Web Worker + OffscreenCanvas。 像素运算是纯 CPU 密集任务,天生适合丢到 Worker 里,算完再把结果传回主线程,UI 全程不卡。OffscreenCanvas 允许在 Worker 里直接操作画布,ImageData 也能通过 postMessage 高效传递:
// worker.js —— 在 Worker 线程里跑滤镜,不阻塞页面
self.onmessage = (e) => {
const { imageData, type } = e.data;
if (type === 'grayscale') grayscale(imageData);
if (type === 'blur') convolve(imageData, [1,2,1,2,4,2,1,2,1], 16);
// 用 transferable 把底层 buffer 的所有权转回主线程,零拷贝
self.postMessage({ imageData }, [imageData.data.buffer]);
};
// 主线程
const worker = new Worker('worker.js');
worker.onmessage = (e) => ctx.putImageData(e.data.imageData, 0, 0);
const src = ctx.getImageData(0, 0, canvas.width, canvas.height);
worker.postMessage({ imageData: src, type: 'blur' }, [src.data.buffer]);
postMessage 第二个参数把 buffer 列为 transferable,转移所有权而不是复制,几十兆的数据也几乎零开销。再进一步,大图可以分块(tile)处理、分帧 requestAnimationFrame 推进,或者把能表达成 filter/globalCompositeOperation 的效果交给 GPU(Canvas 的 ctx.filter = 'blur(4px)' 就是硬件加速的),只有 GPU 滤镜表达不了的自定义核才落到手写卷积。
五、工程边界:这些坑得心里有数
手写像素滤镜很适合学原理、做轻量效果,但真放到生产里,有几条边界得认清:
- 主线程阻塞是硬伤。 哪怕上了 Worker,超大图(比如手机拍的一亿像素、或多张批量处理)依然会让内存和耗时爆炸。浏览器不是干重活的地方。
- 跨域会污染画布。 往 Canvas 上画一张不同源、又没配 CORS 的图,之后调
getImageData会直接抛安全异常(tainted canvas)。要么图源加crossorigin="anonymous"且服务端返回正确的 CORS 头,要么把图先传到自己后端中转。 - 精度和色彩管理。 Canvas 默认 sRGB、8 位整数,做多步滤镜叠加会累积舍入误差,对色彩要求高的场景(印刷、专业修图)这套精度不够。
- 效果天花板。 卷积能做模糊锐化描边,但抠图、智能磨皮、老照片修复、超分放大这类,靠固定卷积核是做不出来的,得上更重的算法甚至模型。
所以实际项目里,常见的分工是:轻量、即时预览的效果(灰度、亮度、简单模糊)前端用 Canvas 自己算,体验最好;重活或对质量有要求的,接现成的图片处理工具或服务更省事,比如 squoosh、Photopea、tudingai.cn 这类,按需求挑一个,没必要什么都从像素级自己实现。把该自己写的写透、把该外包的外包出去,才是工程上划算的选择。
小结
Canvas 像素处理的全部套路,其实就三步:getImageData 把图变成一维 RGBA 数组、遍历数组做数学运算、putImageData 写回。逐像素滤镜(灰度、复古、亮度对比度)是点算子,一层循环搞定;卷积滤镜(模糊、锐化、边缘检测)靠一个小核扫过邻域,换核就换效果;性能上盯紧 TypedArray 的写法、把重活丢进 Worker 和 OffscreenCanvas。原理通了,市面上绝大多数图片滤镜你都能自己复现出来,也能一眼看出哪些该自己写、哪些该交给别人。

浙公网安备 33010602011771号