Canvas ImageData 像素基础 — 直接读写每一个像素
前三天的变换还在"绘制 API"的框架内——你告诉 Canvas 画什么,它替你算像素。今天换一条路:getImageData 把整幅画面的像素抄出来给你(一个巨大的数字数组),你随便改,putImageData 再写回去。这条路绕过了所有绘制 API,代价是要自己管理"每个像素的颜色",回报是任何图像效果都能做——取色器、反色、灰度、马赛克、热成像伪彩,本质全是"遍历像素 + 数学运算"。今天打地基:数组结构、安全遍历、以及那个 Day 35 画板埋下的伏笔——getImageData 无视变换的坑。
目录
- 一、ImageData:画面的"基因序列"
- 二、Uint8ClampedArray:会自动钳位的字节数组
- 三、像素的定位与读写
- 四、getImageData / putImageData 与变换的恩怨
- 五、实战:鼠标取色器
- 六、性能:像素操作的开销意识
- 七、常见坑点与最佳实践
- 八、自测挑战
- 九、总结与知识图谱
一、ImageData:画面的"基因序列"
1.1 拿到像素数据
// 从画布抠出一块像素数据:
const imageData = ctx.getImageData(x, y, width, height);
// imageData 长这样:
// {
// width: number, // 区域宽(像素)
// height: number, // 区域高(像素)
// data: Uint8ClampedArray, // ⭐ 像素本体:一维数组,每 4 个数一个像素
// colorSpace: "srgb" // 颜色空间(新属性,暂时不用管)
// }
// 写回去:
ctx.putImageData(imageData, x, y); // 把(改过的)数据贴回画布 (x, y) 处
1.2 data 数组的结构
一幅 100×100 的画面有 1 万个像素,data 数组长度 = 100 × 100 × 4 = 40000:
索引: 0 1 2 3 | 4 5 6 7 | 8 ...
含义: R G B A | R G B A | R ...
像素: 第0个(左上角) | 第1个(第一行第二个) | 第2个...
值域:每个分量 0 ~ 255(整数)
R=255,G=0,B=0 → 红 R=0,G=255,B=0 → 绿
R,G,B 全 0 → 黑 全 255 → 白
A=0 全透明 A=255 不透明
心算验证:canvas.width × canvas.height × 4 = data.length。画布 800×600 → 192 万元素。这就是为什么像素操作"能做一切但要想性能"(第六节)。
二、Uint8ClampedArray:会自动钳位的字节数组
2.1 两个天生自带的保护
const data = imageData.data; // 类型:Uint8ClampedArray
// 保护 1:自动钳位(Clamped)——超出 0~255 自动截断
data[0] = 300; // 实际存 255
data[1] = -20; // 实际存 0
data[2] = 128.7; // 实际存 129(四舍五入取整)
// 保护 2:赋值非数字
data[3] = NaN; // 实际存 0(普通数组会存 NaN 然后渲染出诡异结果)
2.2 为什么这个设计是"恩人"?
// 写滤镜时最常做的事:颜色值加减乘除后写回。
// 没有钳位的世界(比如普通 number 数组):
let r = pixelR + 60; // 200 + 60 = 260
// 写回 Canvas 前必须手动:if (r > 255) r = 255; —— 每个通道都要写,漏了就出花屏
// 有 Uint8ClampedArray:
data[i] = pixelR + 60; // 260 → 自动变 255 ✅ 直接赋值,不用管越界
反色滤镜因此只需一行核心代码:data[i] = 255 - data[i]——无需任何边界检查。
2.3 与普通数组的转换(偶尔需要)
// 拷贝成普通数组(比如要在 Worker 间传输、或做复杂数值计算):
const normal = Array.from(data); // 慢,只在边界处用
const copy = new Uint8ClampedArray(data); // 拷贝一份同类型(撤销栈常用!)
// ⚠ 直接赋值是引用不是拷贝:
const ref = data; // ref 和 data 是同一个数组,改 ref 就是改 data
三、像素的定位与读写
3.1 一维数组里的二维坐标
data 是一维的,但画面是二维的——坐标换算是像素操作的第一课:
/**
* 像素坐标 → data 数组索引
* @param x 列坐标(0 ~ width-1)
* @param y 行坐标(0 ~ height-1)
* @returns 该像素 R 分量的索引(G/B/A 依次 +1)
*
* 推导:前面有 y 整行(每行 width 个像素)+ 本行 x 个像素 → 共 (y·width + x) 个像素
* 每像素 4 个分量 → 索引 = (y·width + x) × 4
*/
function pixelIndex(x: number, y: number, width: number): number {
return (y * width + x) * 4;
}
// 读一个像素:
const i = pixelIndex(10, 5, width);
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
// 写一个像素(改成纯红):
data[i] = 255; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 255;
3.2 标准遍历模板(背下来)
/**
* 遍历整幅 ImageData 的标准模板
* 变体几乎都基于它:反色 / 灰度 / 亮度调节……
*/
function forEachPixel(imageData: ImageData, fn: (r: number, g: number, b: number, a: number, i: number) => [number, number, number, number]): void {
const { data, width, height } = imageData;
// 写法一:按像素遍历(每轮跳 4)—— 最常用、最不容易错
for (let i = 0; i < data.length; i += 4) {
const [r, g, b, a] = fn(data[i], data[i + 1], data[i + 2], data[i + 3], i);
data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = a;
}
// 写法二:按行列遍历(需要知道 x/y 时用——比如只处理某一半画面)
// for (let y = 0; y < height; y++) {
// for (let x = 0; x < width; x++) {
// const i = (y * width + x) * 4;
// // ... data[i] 是 (x,y) 的 R
// }
// }
}
3.3 边界:能 getImageData 整块画布吗?
// ① 全量抓取(最常见):
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
// ② 只抓一个区域(性能优化:只处理需要处理的块):
const region = ctx.getImageData(50, 50, 100, 100); // 从 (50,50) 抓 100×100
// ⚠ 区域版的数据里,width = 100(不是画布宽!)—— 定位公式用的 width 必须一致
// putImageData 回去时坐标要对应:
ctx.putImageData(region, 50, 50); // 贴回原位
四、getImageData / putImageData 与变换的恩怨
4.1 伏笔回收:画板快照的坑
Day 35 画板撤销用 getImageData(0, 0, canvas.width, canvas.height) 而不是 CSS 尺寸——今天讲透原理:
// 关键事实:getImageData / putImageData 走的是【物理像素直通道】,
// 不经过绘制管线 → 完全无视 ctx 的变换(scale/rotate/translate 都当不存在)!
// Day 29 我们做过:canvas.width = cssWidth × dpr; ctx.scale(dpr, dpr)
// 所以:
// ❌ 用 CSS 尺寸抓:只抓到了左上角 1/dpr 的区域
ctx.getImageData(0, 0, cssWidth, cssHeight); // 高分屏上画面缺一大块
// ✅ 用物理尺寸抓:全量
ctx.getImageData(0, 0, canvas.width, canvas.height);
// putImageData 同理:贴回的位置也是物理像素坐标
ctx.putImageData(img, 0, 0); // (0,0) 物理像素 = 左上角,OK
ctx.putImageData(img, 100, 100); // 是物理像素 100(CSS 像素的 100/dpr)
4.2 对比:绘制类 API 全部"经过"变换
API 走绘制管线? 受 ctx.scale 等变换影响? 坐标单位
-----------------------------------------------------------------------
fillRect / drawImage ✅ ✅ CSS 像素(DPR 后)
clearRect ✅ ✅ CSS 像素
getImageData / putImageData ❌ 直通道 ❌ 完全无视 物理像素
记忆口诀(Day 35 已出,再次强调):put/get ImageData 用物理,绘制类 API 用 CSS。
4.3 变换后想抓"变换后的画面"怎么办?
// getImageData 抓的是【画布上已经渲染出来的像素】——变换已经生效在画面上了!
// 所以"抓变换后画面"天然就是对的:
ctx.save();
ctx.translate(100, 100);
ctx.rotate(deg(30));
ctx.fillRect(-20, -20, 40, 40); // 画面上:旋转过的方块
ctx.restore();
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
// img 里就是旋转后的样子 ✅(变换影响"画上去的过程",不影响"抓取结果")
// 会踩坑的场景是反过来:你想用 putImageData 在"旋转的坐标系"里贴图
ctx.rotate(deg(45));
ctx.putImageData(img, 0, 0); // ❌ 贴图不旋转!putImageData 无视变换,直接物理像素贴
// 要"带着变换贴图"用 drawImage(它走绘制管线):
// ctx.drawImage(sourceCanvas, 0, 0); // ✅ 会跟着 rotate 转
五、实战:鼠标取色器
今天的实战目标:鼠标移到画布上,放大镜式显示该处像素颜色(RGB/HEX),点击复制色值——设计工具的取色器原理,也是工业组态"点击像素查设备状态"的基础。
5.1 完整代码
// day38-picker.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. 先画一幅"值得取色"的画面(渐变 + 彩色方块)=====
function paintScene(): void {
const grad = ctx.createLinearGradient(0, 0, cssWidth, 0); // Day 31
grad.addColorStop(0, "#001529");
grad.addColorStop(1, "#00c6ff");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, cssWidth, cssHeight);
const colors = ["#ff4d4f", "#ffc53d", "#00ff88", "#b37feb"];
colors.forEach((c, i) => {
ctx.fillStyle = c;
ctx.fillRect(80 + i * 120, 100, 80, 80);
});
}
paintScene();
// ===== 2. 核心工具:读取指定 CSS 坐标的像素颜色 =====
/**
* 读取 (cssX, cssY) 处的像素 RGBA
* ⭐ 两次坐标系转换是本函数的灵魂:
* CSS 像素 → 物理像素(×dpr)→ data 索引(y·width + x)×4
*/
function pickColor(cssX: number, cssY: number): [number, number, number, number] {
// ① CSS 坐标 → 物理坐标(getImageData 是物理像素直通道!第 4 节)
const px = Math.floor(cssX * dpr);
const py = Math.floor(cssY * dpr);
// ② 只抓 1×1 像素(性能最优:不用抓全屏)
const pixel = ctx.getImageData(px, py, 1, 1);
return [pixel.data[0], pixel.data[1], pixel.data[2], pixel.data[3]];
}
// ===== 3. 显示层:色板 + 色值文字 =====
let hover: { x: number; y: number } | null = null;
function renderOverlay(): void {
if (!hover) return;
const [r, g, b, a] = pickColor(hover.x, hover.y);
const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
// 色板(跟随鼠标)
ctx.save();
ctx.fillStyle = `rgba(${r},${g},${b},${a / 255})`;
ctx.fillRect(hover.x + 16, hover.y + 16, 44, 44);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.strokeRect(hover.x + 16, hover.y + 16, 44, 44);
ctx.restore();
// 色值文字
ctx.fillStyle = "#ffffff";
ctx.font = "13px monospace";
ctx.fillText(`${hex} rgb(${r},${g},${b})`, hover.x + 16, hover.y + 76);
}
// ===== 4. 交互 =====
// ⚠ 坑:overlay 画上去会污染原画面,鼠标移动前要"恢复原画面再画新 overlay"
// 方案:把 paintScene 的结果存成离屏画布,每次重画 = 贴原图 + 画 overlay
// (离屏画布明天 Day 41 正式讲,今天先用起来)
const sceneCanvas = document.createElement("canvas"); // 离屏备份
sceneCanvas.width = canvas.width;
sceneCanvas.height = canvas.height;
sceneCanvas.getContext("2d")!.drawImage(canvas, 0, 0); // 把当前画面抄一份
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
hover = { x: e.clientX - rect.left, y: e.clientY - rect.top };
// 重绘 = 贴原始场景 + 画 overlay
ctx.setTransform(1, 0, 0, 1, 0, 0); // 物理像素直接贴(put/drawImage 混用注意)
ctx.drawImage(sceneCanvas, 0, 0); // 恢复原画面
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 切回 CSS 坐标系画 overlay
renderOverlay();
});
// 点击复制(navigator.clipboard 需 https 或 localhost)
canvas.addEventListener("click", () => {
if (!hover) return;
const [r, g, b] = pickColor(hover.x, hover.y);
const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
navigator.clipboard?.writeText(hex);
console.log(`已复制: ${hex}`);
});
5.2 代码里的三次"坐标系切换"复盘
| 位置 | 坐标系 | 为什么 |
|---|---|---|
eventToCanvas 后的 hover |
CSS 像素 | 鼠标交互统一 CSS 像素思维(Day 34 惯例) |
pickColor 里 ×dpr |
物理像素 | getImageData 是直通道(第 4 节) |
drawImage(sceneCanvas, 0, 0) 前 setTransform(1…) |
物理像素 | 离屏画布是物理分辨率,且要 1:1 贴回 |
这就是 DPR + 像素操作的复杂度全部所在——想清楚"现在这条数据在哪个坐标系里",代码就对了一半。
六、性能:像素操作的开销意识
6.1 数据量心算表
| 画布尺寸 | 像素数 | data.length | 一次全量遍历的量级 |
|---|---|---|---|
| 300×200 | 6 万 | 24 万 | ~0.5ms 轻松 |
| 800×600 | 48 万 | 192 万 | ~3-5ms 要注意 |
| 1920×1080 | 207 万 | 830 万 | ~15-30ms 掉帧! |
| 4K (3840×2160) | 829 万 | 3300 万 | 100ms+ 卡成狗 |
6.2 三条性能军规
// 军规 1:只处理需要的区域
// 全屏反色 → 只反色用户选中的选区:
const region = ctx.getImageData(selX, selY, selW, selH); // 而不是整屏
// 军规 2:别在循环里做重复计算( JIT 也救不了每次调函数的往返)
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];
// ❌ 反例:const [r,g,b] = getPixel(data, i); setPixel(data, i, 255-r, ...)
// 每像素两次函数调用 + 数组解构,慢 3-5 倍
}
// 军规 3:大图处理挪进 Worker(明天 Day 41 展开)
// 1080p 以上 + 复杂滤镜(模糊卷积)→ 主线程必卡 → Worker
6.3 getImageData 本身也有开销
getImageData 会强制浏览器"把 GPU 里的画面同步拷到 CPU 内存"
(Canvas 硬件加速时代,这是一次昂贵的 GPU→CPU 回读)
教训:
- 不要在动画的每帧循环里 getImageData(取色器是 mousemove 触发,不是 rAF,可以接受)
- 一次抓全屏,处理完一次 put 回 —— 避免"抓一点改一点"的高频往返
七、常见坑点与最佳实践
| # | 坑 | 症状 | 解法 |
|---|---|---|---|
| 1 | getImageData 用 CSS 尺寸 | 高分屏上抓到残缺画面 | 用 canvas.width/height(物理) |
| 2 | 索引算错(忘了 ×4) | 花屏 / 颜色错乱 | pixelIndex 公式:(y·width+x)×4 |
| 3 | 区域版的 width 用错 | 小图索引越界错乱 | 定位公式里的 width 必须用 ImageData 自己的 width |
| 4 | 以为 putImageData 会跟变换 | 旋转后贴图没转 | put 无视变换;要变换用 drawImage |
| 5 | 跨域图片后 getImageData | SecurityError(tainted canvas) | img.crossOrigin=“anonymous” + 服务器 CORS(Day 33) |
| 6 | 每帧 getImageData | 帧率暴跌 | 事件驱动(如 mousemove)或缓存 |
| 7 | 直接引用 data 以为是拷贝 | 两处同时被改 | new Uint8ClampedArray(data) 拷贝 |
| 8 | putImageData 后 ctx 状态怪异 | 想当然"贴图影响状态" | put/get 完全不碰 ctx 状态(这也是它的优点) |
坑 5 补充:跨域污染是"单向门"——只要画过一个未授权跨域图,整块画布就永久 tainted,之后任何 getImageData/toDataURL 都抛 SecurityError,撤销都救不了,只能重建画布。
八、自测挑战
- 计算题:300×200 的画布,像素 (57, 88) 的 R 分量在 data 里的索引是多少?(手算)
- 默写题:不看文档,写出 pixelIndex 函数和"遍历所有像素"的双层 for 模板。
- 预测题:
data[0] = 256; data[1] = -1; data[2] = 100.6;之后这三个值分别是多少? - 实现题:写一个
fillRegion(imageData, x0, y0, w, h, color)—— 把 ImageData 中指定矩形区域涂成纯色(边界要钳制:x0+w 可能超出画面)。 - 思考题:为什么取色器用 mousemove 触发 pickColor 没有性能问题,但把 pickColor 挪进 rAF 循环每帧调用就可能卡?(提示:mousemove 的触发频率 vs rAF;getImageData 的回读开销)
- 进阶题:实现"点击处 30×30 像素马赛克"——把该区域每 6×6 块取一次平均色填充整个小块。
九、总结与知识图谱
Day 39 ImageData 像素基础
├── 数据结构
│ ├── data: Uint8ClampedArray(每 4 个 = 1 像素 RGBA)
│ └── length = width × height × 4
├── Uint8ClampedArray 特性
│ ├── 自动钳位 0~255(滤镜代码免边界检查)
│ └── 赋值即拷贝陷阱(引用 vs new U8CA(data))
├── 定位与遍历
│ ├── pixelIndex = (y·width + x) × 4
│ └── 双循环模板 / 步进 4 模板
├── 与变换的关系 ⭐
│ ├── get/put 无视变换(物理像素直通道)
│ ├── put/get 用物理,绘制用 CSS
│ └── put 不跟变换走 → 要变换贴图用 drawImage
├── 实战:取色器
│ └── CSS → 物理 → 索引 的三层换算
└── 性能
├── 区域化处理
├── 循环内联运算
└── GPU 回读开销(避免每帧 get)
明天预告(Day 40):今天的地基上盖楼——滤镜算法。灰度的加权公式(为什么绿 weight 最大?)、反色一行流、以及图像处理皇冠上的明珠卷积:一个 3×3 小矩阵扫过全图,模糊/锐化/边缘检测全是它。你会发现"高级感"的效果,内核只是两层 for 循环。