在 Figma、PS 里对着参考图吸一个颜色很简单,但在网页里做同样的事,背后要经过三步:把图片画进 Canvas、按坐标读像素、再把 RGBA 换算成 HEX / RGB / HSL。这篇文章把这套链路讲透,最后分享一个我在用的在线工具——鸭鸭图片取色器,支持上传、粘贴、拖拽和 URL 加载,还能自动提取主色色板,图片全程本地处理。
一、先画进 Canvas:为什么不能直接读 <img>
浏览器出于安全考虑,不允许 JS 直接读取 <img> 的像素数据。要拿到像素,必须先绘制到 <canvas> 上,再用 getImageData() 读取:
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', {
willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const {
data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
// data 是 Uint8ClampedArray,每 4 个元素表示一个像素的 RGBA
两个细节值得注意:
willReadFrequently: true是性能提示,告诉浏览器这个上下文会频繁读像素,避免走 GPU 加速路径导致每次读取都回读。getImageData()返回的data是 RGBA 顺序:data[i]、data[i+1]、data[i+2]、data[i+3]分别对应 R、G、B、A。
二、坐标映射:从“点击位置”到“源像素”
页面上显示的通常是缩放后的图片,要取到原图颜色,得把事件坐标换算回源像素坐标:
const rect = visible.getBoundingClientRect();
const vx = (event.clientX - rect.left) / rect.width * visible.width;
const vy = (event.clientY - rect.top) / rect.height * visible.height;
const sx = Math.floor(vx * source.width / visible.width);
const sy = Math.floor(vy * source.height / visible.height);
const pixel = sourceCtx.getImageData(sx, sy, 1, 1).data;
关键点:始终用原尺寸 Canvas 取像素,而不是从显示尺寸的 Canvas 上读。否则图片缩放后,一个显示像素可能对应原图多个像素,取到的是插值结果而不是真实像素。鸭鸭工具的做法就是把原图画进隐藏的原尺寸 Canvas,页面上只显示缩放副本,取色永远命中原始分辨率。
三、颜色换算:HEX / RGB / HSL
取到 RGB 后,前端最常见的三种格式各有用途:
| 格式 | 示例 | 适合场景 |
|---|---|---|
| HEX | #2596be |
CSS 与设计稿最常见的写法 |
| RGB | rgb(37, 150, 190) |
需要分量值、透明度或参与算法计算 |
| HSL | hsl(196, 67%, 45%) |
调色相、饱和度、明度,配色计算的基础 |
RGB 转 HEX:
function rgbToHex(r, g, b) {
return '#' + [r, g, b]
.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
.join('');
}
RGB 转 HSL(标准公式):
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return {
h: 0, s: 0, l: Math.round(l * 100) };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
default: h = (r - g) / d + 4;
}
h /= 6;
return {
h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
为什么三种都要?HEX 给 CSS 和设计稿用,RGB 分量适合算法处理,HSL 把“色相 / 饱和度 / 明度”拆开,后面讲到的互补色、相近色都建立在它之上。
四、放大镜:像素级精确定位
肉眼很难在缩略图上点准一个像素,尤其是边缘和渐变区域。常见做法是做一个网格放大镜:取光标周围 N×N 像素,放大成固定尺寸的格子逐格填色,中间画十字准星。
const grid = 13, cell = 11;
const size = grid * cell;
const block = sourceCtx.getImageData(x0, y0, grid, grid);
for (let gy = 0; gy < grid; gy++) {
for (let gx = 0; gx < grid; gx++) {
const i = (gy * block.width + gx) * 4;
lctx.fillStyle = `rgb(${
block.data[i]},${
block.data[i + 1]},${
block.data[i + 2]})`;
lctx.fillRect(gx * cell, gy * cell, cell, cell);
}
}
实现时要用最近邻采样(关闭 imageSmoothingEnabled),避免边缘被平滑糊掉;还要处理越界:光标贴近图片边缘时截断读取范围,缺失的格子用最近的像素补齐。触屏设备没有 hover,放大镜要自动隐藏,改成点击取色。
五、主色提取:量化 + 频次统计
自动提取“图片主色”最常见的思路是颜色量化:把相近的颜色归到同一个桶,统计每桶的像素数量,取前 N 桶的平均色。
鸭鸭工具的做法:
- 先把图片等比缩小到最长边不超过 200px,减少计算量;
- 每个像素按 RGB 的高两位归桶(R、G、B 各取
>>6,拼成 6 位 key,共 4×4×4 = 64 个桶),相当于把 1670 万种颜色粗化为 64 类; - 透明像素(alpha < 128)跳过;
- 统计每桶的像素数和分量和,按像素数降序取前 8,输出桶内平均值。
const key = ((r >> 6) << 4) | ((g >> 6) << 2) | (b >> 6);
// 累加 r/g/b 与计数,最后按计数排序取前 8,均值归桶
这个思路简单、快、结果稳定,适合“色板”这种对精度不敏感的用途。要更严格的色彩聚类,可以换成中位切分(median cut)或 K-means,代价是复杂度和耗时上升。
六、互补色和相近色:HSL 的实战价值
拿到主色后,配色常常需要“和谐色”。基于 HSL 实现非常直接:
- 互补色:色相 +180°;
- 相近色:色相 ±30°、±60°,饱和度和明度保持不变。
鸭鸭工具的“颜色详情”面板就按这个规则给出互补色和相近色,点击色块可以直接选中使用。
七、避坑清单
- 跨域图片:URL 加载要设置
img.crossOrigin = 'anonymous',且图片服务器必须返回 CORS 头(Access-Control-Allow-Origin),否则getImageData()直接抛SecurityError。工具会捕获异常并提示改用上传或粘贴。 - 不要从缩放后的 Canvas 读像素,坐标换算请参考第二节。
- 触屏设备没有 hover,放大镜不可用,粗指针设备应隐藏它并依赖点击取色。
- 隐私:整套取色都在浏览器本地完成,图片不离开设备。这也是在线取色器相比上传类服务更让人放心的原因。
八、推荐:在线图片取色器
如果不想从零实现这套逻辑,可以直接用 鸭鸭图片取色器(英文版):上传、粘贴、拖入或输入 URL 加载图片,点击任意像素取色,同时输出 HEX、RGB、HSL 并一键复制;自动提取 8 个主色色板;支持互补色和相近色查看;图片只在本地处理。设计稿对色、前端调试、数据可视化配色都能用上。想继续调色的话,搭配它的颜色选择器做格式互转和随机取色,取色与调色正好闭环。
