颜色三件套:转换器怎么读十六进制、提取器看多少像素、渐变器什么时候给出坏 CSS
三个页面都挂着「颜色」两个字,但里面的机器毫无关系。颜色转换器是一串手写解析加位运算,它的输出是确定的、可复现的,风险全在边界写法上。配色提取器是一台抽样聚类机:它只看极小一部分像素,中心点靠随机数选,还会主动合并和丢弃颜色,所以它给的是「一次结果」而不是「那个结果」。CSS 渐变生成器则是一个状态机,而它的状态组合里有一条会把无效 CSS 递给你。
这三件事的防法也不一样:转换器要防的是「它没报错」;提取器要防的是「它报的数字和你以为的不是一回事」;渐变器要防的是「预览是对的,代码是错的」。
下面每一条都是从三个页面自己的脚本里读出来,再在页面上实际跑一遍确认过的。
转换器:它不是在解析颜色,是在做位运算
颜色转换器的十六进制入口只有四行:去掉 #,三个字符就把每位复制一遍,然后 parseInt(h, 16),最后 (n>>16)&255、(n>>8)&255、n&255 取三个通道。问题全在这条移位链上——JS 的位运算先把数折成 32 位有符号整数,再从低位往回读三个字节。
| 你输入 | 页面读到的 | 为什么 |
|---|---|---|
#ff7e5f |
rgb(255,126,95) | 正常 |
ff7e5f |
rgb(255,126,95) | 它先删 #,没有 # 也照样吃 |
#fff |
rgb(255,255,255) | 三位扩展 |
#FF0000ff |
rgb(0,0,255) | 折成 32 位后只剩 0000ff,红色和alpha一起没了 |
#1234567889aabb |
rgb(137,170,187) | 12 位照样折回 32 位,只有末尾六个十六进制位参与结果 |
#12g456 |
rgb(0,0,18) | parseInt 在 g 处停下,12 当成 0x12 |
#ggzz |
不更新 | 一位都读不出来,isNaN 才成立 |
| `` (空) | 不更新 | 同上 |
最后一列那一句是最要紧的:这个页面没有错误提示。每个输入框的处理函数在解析失败时直接 return,屏幕上留着上一个颜色,不闪红字、不变边框、不弹提示。所以判断「我刚才粘贴的东西它到底收没收」的唯一办法是看输出有没有变。而 #12g456 这种打错一个字母的输入比失败更糟:它成功了,输出一个 #000012 的近黑色,三个框看起来完全合法。
从设计工具里带 alpha 的颜色贴进来之前,先把末尾两位删掉。这是这个页面唯一没写在界面上的使用条件。
HSL 那一格用的是严格正则:hsl 加括号,三段整数,后两段必须带 %,逗号分隔。实测之下 hsl(0, 100%, 50%) 能用,hsl(0 100% 50%)(现代空格写法)、hsl(0, 100.5%, 50%)(小数)、hsl(0deg, 100%, 50%)(带单位)都无声失败。
HSL 是显示值,不是颜色本身
页面把 rgb 换算成 HSL 时,三个通道各除以 255 算出 h、s、l,然后 Math.round 成整数——色相 0–360、饱和度 0–100、明度 0–100。这三个整数是拿来显示的,但滑块和 HSL 输入框读的就是它们,于是「抄下 HSL 再贴回来」并不等于原颜色。
把页面自己的两个函数原样跑一遍全部 16,777,216 个 RGB 值,结果是:
- 89.4% 的颜色(15,002,160 个)往返会变,剩下 10.6% 恰好是整数 HSL 能精确表示的。
- 单通道最大偏移 5。实测到的极端例子:
rgb(2,228,230)→hsl(181, 98%, 45%)→ 回来是rgb(2,223,227),绿色掉 5、蓝色掉 3。 - 整数 HSL 三元组总共只能到达 2,037,576 个颜色,占全空间的 12.1%。也就是说 361×101×101 = 3,685,481 个位置里,有三分之一是重复的,而剩下的 87.9% 颜色你用滑块根本调不出来。
结论很短:要精确就用 HEX。HSL 框和三个滑块适合找方向、找深浅,不适合定稿。
反过来的路是干净的:rgbToHex 对每个通道 Math.round 再夹到 0–255,所以 HEX 那一格永远不会出现 #00000e 之类的意外——HEX 是这个页面上唯一可信的输出。复制按钮点下去会变成 ✓ 一秒钟;剪贴板被拒绝时 .catch 是空的,按钮纹丝不动,也不告诉你为什么(非 HTTPS 环境、页面不在焦点、浏览器不允许都会走这条路)。
预设色板(点击取色)里是 24 个固定色值,从 #FF6B6B 到 #CED4DA,点了直接填色。它们是装饰用的调色板,不是任何算法的结果。
提取器:它只看你图片的千分之几
配色提取器的流程写在它自己的常见问题里,而且和代码对得上:取 alpha ≥ 128 的像素 → K-means++ 初始化中心 → 迭代 12 轮 → 合并欧氏距离 < 24 的相似色 → 压制低饱和度背景色 → 按频率和饱和度评分排序。真正需要知道的细节都在数字上。
它读全尺寸图,但只抽检 5000 个像素。 页面先按图片原始宽高建 canvas、getImageData 拿全分辨率的 RGBA 缓冲——1200 万像素就是约 48 MB 内存,这也是这个工具真正的成本(不是隐私成本,图片不上传)。然后进入聚类之前有一段抽样:像素数超过 5000 时,step = floor(pixels.length / 5000),从下标 0 开始每隔 step 个取一个,取满 5000 就停。
1200 万像素的图:floor(12000000 / 5000) = 2400,即每 2400 个不透明像素里取 1 个。这是一条固定的等距扫描线,不是随机抽样。它有两个后果:
- 等距步长在二维网格上会走出图案。步数和图片宽度有公因子时,抽样点会集中在若干条竖列上,其余整列一个都不看。Logo 里那条细细的品牌色如果正好落在没被抽到的列上,就永远不会出现在结果里。
- 抽检的前提是「不透明像素」列表。透明区域先被剔掉,所以一张四周透明的大 PNG,抽样密度和一张同尺寸的实底 JPG 完全不同。
想让某个颜色稳定进结果,最省事的办法不是提高图片质量,而是把图缩小——几百像素宽的时候 pixels.length 常常不到 5000,抽样整段被跳过,每个像素都被分析。或者裁到你真正要取色的那块区域。
聚类中心每轮重掷。 K-means++ 的初始化里,第一个中心是写死的第一像素,之后的每一个都按「到已选中心的最近距离平方」做加权随机(Math.random() * totalDist 累加选取)。这意味着同一张图点两次「重新提取」,中心起点不同,12 轮迭代的落点也不同。实测:纯色平涂图每次都完全一样;照片会在 1–2 个通道单位内漂移。第一像素被钉住这点也有可见后果——如果你的图片左上角是背景色,那个颜色已经预选了一个中心。
「颜色数量 5–9」是上限,不是配额。 滑块默认 7,范围 5 到 9,改完立刻重算。但结果数量在两道工序之后才定:先按 K 聚类,再把欧氏距离 < 24 的簇合并(RGB 空间里 24 大约是肉眼难辨的差别),然后压制饱和度——cutoff = 0.18 × 这张图里最高的饱和度,低于这条线的颜色直接丢掉;如果整张图最大饱和度低于 0.05(近黑、近白、纯灰),这一步整体跳过,不然灰图会被清空。所以「我要 7 个色,它给我 4 个」是正常输出:要么有两簇挨得太近被并了,要么有颜色被判成背景压掉了。灰色、米白、浅蓝大面积背景最容易在这里消失。
那个百分比不是占图片的比例。 评分阶段对每个存活的簇,把 colorDist(像素, 簇中心) < 24 的像素都数进去——一个像素离两个中心都小于 24 时,两个簇都算它一份。最后这些计数被分摊成总和 100% 的比例。所以它们只能互相比较,不能拿去说「这个色占了画面 30%」。
「显示分布」画的是聚类归属,不是遮罩。 它按预览的 CSS 尺寸建一块离屏 canvas,对每个显示像素做最近邻取值(srcX = floor(x * scaleX)),把「赢了这个像素」的颜色填进去,再以 alpha 180/255 叠回原图。它不判断颜色合不合适,只判断谁离得近——所以大片区域被染上一个勉强接近的颜色,恰恰是它在如实告诉你聚类边界长什么样。
渐变器:2 到 4 个停止点、没有 alpha、只有一个圆
CSS 渐变生成器是三个页面里约束最多的,而约束一条都没写在界面上。
- 只有线性与径向。 没有 conic。径向输出被写死成
radial-gradient(circle, …)——没有ellipse,也没有at 30% 70%的定位。要这两种就得复制完手工改。 - 停止点最少 2 个、最多 4 个。 到 4 个时「+ 添加颜色停止点」直接从页面上消失(
display: none),处理函数也会提前 return,两条路都堵死。只剩 2 个时,删除用的 ✕ 根本不会渲染,原位置是一个 28px 的空占位块。五个色的彩虹这页做不出来。 - 新加的颜色永远是
#888888,位置是min(100, 上一个 + 20)。 这条公式不管碰撞:如果最后一个停止点已经在 100%,新的也落在 100%。同一位置叠两个色,后者分到的带宽是零——实测「显示分布」之外更直接的办法是看预览:加之前加之后 computed background 一模一样,一个字节都不差。唯一说明它生效了的,是计数变成「3 个颜色」以及代码里多出#888888 100%。所以加停止点之前,先把最后那个从 100% 挪开。 - 没有 alpha,但十六进制框每敲一个字都校验。 颜色来自
<input type="color">加一个十六进制文本框,后者要求#加 3 位或 6 位十六进制:#abc、#ff7e5f收;ff7e5f、#ff7e5f80、rgb(1,2,3)、red全部无声忽略,你的文字还留在框里,预览还是旧颜色。因为 3 位本身就是合法颜色,输入#a1b2c3的过程是这样的:#a不收、#a1不收、#a1b收——渐变在半路跳成那个灰蓝色,#a1b2、#a1b2c都不合法所以它保持,直到第六位落下才变成#a1b2c3。如果你就停在三位,代码里写出的也是#a1b,不会展开成#aaaabbcc。要淡出到透明没有入口,只能复制出来自己加transparent或八位色值。 - 方向只有五个按钮,其中三个是关键字。 网格是「上到下」(
to bottom,进页面时选中)、「左到右」(to right)、「对角线」(to bottom right)、「径向」、「自定义」,上面还有两个类型按钮(线性渐变 / 径向渐变)。没有「上」「左」「左上」这些按钮,要它们只能走「自定义」写角度。角度滑条只在自定义模式出现,state.customAngle默认 45 但静静躺在数据里,进页面的代码框显示的是background: linear-gradient(to bottom, #ff7e5f, #feb47b 100%);,只有你按下「自定义」那一行才变成linear-gradient(45deg, …)。
| 你想要的 | 自定义里该写 |
|---|---|
to top |
0deg |
to top right |
45deg(默认值) |
to right |
90deg |
to bottom right |
135deg |
to bottom |
180deg |
to bottom left |
225deg |
to left |
270deg |
to top left |
315deg |
注意 CSS 的角度是 0 度朝上、顺时针增长,所以默认 45° 指向右上角,和「对角线」按钮的 to bottom right 是相反的两个角。从「对角线」切到「自定义」又不碰滑条,渐变就翻到对角去了——粘贴过去发现「方向反了」通常就是这个。四个正交方向和关键字是严格等价的;四个对角不等价,也不可能等价:关键字让渐变线指向真实拐角(角度随盒子长宽比变),度数固定角度(非正方形盒子上就碰不到拐角)。要线必须过拐角就用关键字,要角度本身是设计决定就用度数。
「径向渐变」和「线性渐变」这两个按钮里,只有一个是干净的。 「径向」方向按钮和「径向渐变」类型按钮设的是同一个状态,而处于径向时点任意一个关键字方向会把类型翻回线性——这两条路都对。唯独「线性渐变」类型按钮不清理方向值:它改了函数名,state.dir 还留着 radial,于是拼接出
background: linear-gradient(radial, #ff7e5f, #feb47b 100%);
这不是合法 CSS,任何浏览器读到这里都会整条忽略。更难受的是页面看起来一切正常:给预览赋无效值是静默失败,所以上一次的径向渐变继续留在画面上,代码框安安静静躺着那行坏 CSS,你按「复制」它还会变 ✓ 已复制。再点一次「线性渐变」也救不回来(类型早就是 linear 了)。唯一的出路是点任意一个方向按钮。中文页和英文页这段逻辑逐字相同,所以两边都有这个问题。习惯上:收尾永远点方向按钮,不要点类型按钮,并且贴之前扫一眼代码里有没有那个不该出现的 radial。
代码框里永远是一整条声明:background: + 函数 + ;。位置写法也不对称——0% 的停止点只写颜色不带 %,大于 0 的都带,所以默认输出是 #ff7e5f, #feb47b 100% 而不是 0%, … 100%;把两个停止点都拖到 0,那行就成了 linear-gradient(to right, #ff7e5f, #feb47b),一个位置都没有。预览下面那行小字是去掉 background: 前缀的裸表达式,需要值而非声明时看它。复制按钮失败同样是空 .catch:不提示、不帮你选中文字,按钮上还是「复制」。
十二个预设模板——日落、海洋、极光、暮色、火焰、森林、霓虹、晨曦、薰衣草、草莓、冰雪、沙漠——全是两色的 to right 线性渐变。点一个会把你手工排好的停止点整个换掉;但它只换颜色,不换形状:每个预设自带一份 stops 数组和一份独立的 CSS 字符串,那份字符串只用来画模板条上的小色块。于是色块永远是左到右的线性条,而你的渐变还停在当前模式里。实测:径向模式下点「日落」得到 background: radial-gradient(circle, #ff7e5f, #feb47b 100%);——一个径向的日落,不是你点的那个样子;自定义 200° 下点「海洋」得到 background: linear-gradient(200deg, #2193b0, #6dd5ed);,角度原封不动。以代码框为准;想要预设原样,先按一个方向按钮。
哪个页面干哪件事
| 你手上有 | 用 | 要当心 |
|---|---|---|
| 一种记法要换成另一种 | 转换器 | HSL 是取整后的显示值,定稿看 HEX |
| 带 alpha 的设计稿颜色 | 转换器,先删末两位 | 8 位十六进制从低位倒着读 |
| 打错的十六进制 | 没有提示 | #12g456 会变成一个近黑色并「成功」 |
| 从照片/Logo 抽品牌色 | 提取器 | 等距抽检可能整列跳过,小色块会消失 |
| 同一张图要稳定结果 | 提取器——平涂图稳定,照片每轮漂 1–2 | 中心点是随机掷的 |
| 要 7 个色却只给 4 个 | 提取器的正常行为 | 距离 24 合并 + 0.18 饱和度压制 |
| 一段能直接粘的 CSS 背景 | 渐变器 | 4 个停止点封顶、无 alpha、只有 circle、类型按钮能产出无效 CSS |
| 透明渐变、conic | 三个都不行 | 自己写 |
常见问题
我粘的 #FF0000ff 怎么变蓝了?
因为那个框是 parseInt(str, 16) 之后用 32 位移位取通道的,32 位之外的部分被折掉,留下的正是 0000ff。粘之前删掉末尾的 alpha 两位。
转换器是不是算错了?我的颜色是不是错了? 都不是。HSL 那格是颜色的取整显示,拿它反推回来可能一个通道偏 1 到 5。全空间实测 89.4% 的颜色会动,最大偏 5。
我能用滑块调出任何颜色吗? 不能。整数 HSL 位置覆盖 16,777,216 个颜色里的 2,037,576 个,12.1%。要精确就直接写 HEX。
页面为什么不告诉我输入非法? 它没有错误分支。每个框的处理函数在解析失败时直接返回,屏幕上保留上一个颜色。粘贴后什么都没变,就是被拒了——对照上面那张表看写法。
我 Logo 的品牌色为什么从来不进配色结果? 最可能是步长。1200 万像素的图只看不透明像素的每 2400 个之一,小色块很可能压根没被抽到。裁出你要取色的那块,或把图缩到几百像素宽,小颜色的存活率会高很多。
我选了 7 个颜色,它只给 4 个,是坏了吗? 不是。RGB 距离 24 以内的簇会合并,饱和度低于本图峰值 18% 的颜色会在截断前被丢掉(整图最大饱和度低于 0.05 时这一步跳过)。数量滑块是上限,不是保证。
那些百分比是占图片的面积吗? 不是。它们分摊的是存活簇认领到的像素数,而一个像素可以被多个簇同时认领。它们永远加起来 100%,而且只适合彼此比较。
我动了滑块又调回 7,配色为什么变了?
K-means++ 的第二及以后的中心用 Math.random() 选。每次提取都是一次新的抽取,所以结果会漂;第一个中心钉在第一个不透明像素上,其余不是。
渐变能做透明到实色的淡出吗?
界面上不行,颜色输入不带 alpha,十六进制框拒绝 8 位值。复制出来把其中一个停止点改成 transparent 或 #RRGGBB00。
为什么加不出第五个停止点? 处理函数到 4 就返回,按钮同时被隐藏。这是页面里的硬上限,不是渲染问题。
我按了「线性渐变」,粘过去的 CSS 没反应,怎么回事?
你当时在径向模式。那个按钮只改函数名,方向值还是 radial,于是写出来的是 linear-gradient(radial, …),浏览器不认。预览继续画上一个径向渐变,所以唯一线索是 radial 出现在本该是方向的位置上。点任意方向按钮即可恢复,再点「线性渐变」没用。中文页英文页都有这个缺陷。
我点的预设和色块长得不一样?
预设只给颜色,不给形状。色块用预设自己写死的 to right 串画,生成器沿用当前的类型与角度。看代码框;想要色块那个样子,先按方向按钮。
这些工具会把我的图片传上去吗?
不会。提取器用 FileReader 读文件,在同一个标签页的 canvas 上分析,什么都不发出去。代价是内存而不是隐私:它运行期间一直握着全分辨率的 RGBA 缓冲,1200 万像素约 48 MB。