用中位切分做像素画调色板
做像素画工具时,最先想的是「加个马赛克滤镜不就完了」。
试过就知道不行:马赛克只是把像素块放大,颜色还是原来那几万种,看起来像低分辨率的照片,不像像素画。真正的像素画有两个约束 —— 格子的数量,和颜色的数量。
第一步:硬降采样
ctx.imageSmoothingEnabled = false;
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
关键是关掉平滑。开着平滑是「取平均」,边缘会糊成渐变;关掉之后是最近邻取样,每个目标像素直接取源图一个像素的颜色,边缘是硬的。
目标宽度通常 16–128。32–64 适合头像,16 适合极小的图标。
第二步:从图里提取调色板
这里才是重点。目标是「用不超过 N 种颜色重新上色」,那 N 种颜色从哪来?
中位切分的思路
把所有不透明像素放进一个大盒子,然后重复:
- 找出当前颜色跨度最大的盒子(比较 R/G/B 三个通道的极差,取最大的那个通道)
- 沿着那个通道,按中位数把盒子劈成两半
- 直到盒子数量等于目标色数
最后每个盒子里的像素取平均,就是调色板的一个颜色。
这个过程有几个好处:
- 确定性:同样的输入永远得到同样的调色板,不会每次刷新都变
- 不需要迭代:不像 k-means 要跑几十轮
- 天然按占比分配:大色块会被反复劈开,细节多的区域反而保留得少 —— 这正好符合人眼对大面积颜色的敏感度
为什么不用 k-means
k-means 效果通常更好一点,但它是随机初始化 + 迭代收敛的:同一张图跑两次可能得到不同的调色板,用户会以为工具抽风了。
像素画是「看得见结果」的工具,稳定性比那 5% 的质量提升重要得多。
第三步:映射
调色板定了,把每个像素换成欧氏距离最近的那个颜色:
const d = (r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2;
暴力遍历所有调色板颜色即可。128×128 = 16384 个像素,调色板最多 32 色,总共约 52 万次距离计算 —— 在浏览器里是几毫秒的事,不需要空间索引。
透明像素(alpha < 16)直接跳过,保持透明,否则透明区域会被填成黑色。
固定调色板
除了自动取色,还给了两套经典配色:
- PICO-8(16 色):幻想主机的官方调色板,颜色饱和度高,适合游戏素材
- Game Boy(4 色):那种泛绿的灰度,风格化极强
选定固定调色板时跳过中位切分,直接做最近邻映射。好处是同一批素材风格统一,坏处是原图颜色会被强行拉过去 —— 比如一张暖色调照片用 Game Boy 配色会变成诡异的一片绿。
导出:整数倍放大
像素画在屏幕上显示需要放大,但不能平滑放大:
ctx.imageSmoothingEnabled = false;
ctx.drawImage(small, 0, 0, w * scale, h * scale);
导出提供 1× / 2× / 4× / 8× / 16×。8× 是常用值 —— 48px 宽的小图放大到 384px,正好适合截图分享。
一些实测数据
- 48px 宽的图,从降采样到调色板映射,整体在 10 毫秒以内
- 自动模式下,实际用到的颜色数通常比设定值少 —— 因为有些盒子平均后颜色重合了
- 图片本身是渐变(比如天空照片)时,中位切分会把大量盒子分给渐变区域,人物部分反而只剩几种颜色。这类图更适合先手动调对比度
小结
像素画工具的技术含量不在「算法多高级」,而在两个约束是否被严格执行:格子数固定、颜色数固定。只要这两条守住,出来的东西就有像素画的味道 —— 剩下的调色板差异只是风格问题。
