【Canvas 2D】day39-imagedata-basics

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

Canvas ImageData 像素基础 — 直接读写每一个像素

前三天的变换还在"绘制 API"的框架内——你告诉 Canvas 画什么,它替你算像素。今天换一条路:getImageData 把整幅画面的像素抄出来给你(一个巨大的数字数组),你随便改,putImageData 再写回去。这条路绕过了所有绘制 API,代价是要自己管理"每个像素的颜色",回报是任何图像效果都能做——取色器、反色、灰度、马赛克、热成像伪彩,本质全是"遍历像素 + 数学运算"。今天打地基:数组结构、安全遍历、以及那个 Day 35 画板埋下的伏笔——getImageData 无视变换的坑。


目录


一、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,撤销都救不了,只能重建画布。


八、自测挑战

  1. 计算题:300×200 的画布,像素 (57, 88) 的 R 分量在 data 里的索引是多少?(手算)
  2. 默写题:不看文档,写出 pixelIndex 函数和"遍历所有像素"的双层 for 模板。
  3. 预测题data[0] = 256; data[1] = -1; data[2] = 100.6; 之后这三个值分别是多少?
  4. 实现题:写一个 fillRegion(imageData, x0, y0, w, h, color) —— 把 ImageData 中指定矩形区域涂成纯色(边界要钳制:x0+w 可能超出画面)。
  5. 思考题:为什么取色器用 mousemove 触发 pickColor 没有性能问题,但把 pickColor 挪进 rAF 循环每帧调用就可能卡?(提示:mousemove 的触发频率 vs rAF;getImageData 的回读开销)
  6. 进阶题:实现"点击处 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 循环。

评论