Files
rui40000-RUI-Nodes/pixelart/quantize.py
T
rui40000andClaude Opus 4.8 b517c5714b feat: 新增像素化节点(面向像素游戏资产,含 AI 伪像素图网格还原)
产出真实小分辨率、颜色数受控、边缘硬朗的 sprite,而非马赛克滤镜。
纯 numpy/PIL 自研实现,无额外依赖、无需模型权重。

三种模式:按目标宽度 / 按像素块大小 / 自动检测网格。第三种专治
AI 生成的伪像素图(看着像素风,实际网格歪斜、边缘抗锯齿、上千颜色)。

选型说明:Pixel Snapper(MIT)解决的是「伪像素图 → 完美像素图」,
而「普通图 → 像素画」是另一个问题,核心在降采样与调色板量化,
故两条路都做进同一节点。

网格检测处理了两类经典误判:
- 谐波(八度)错误:取得最高分后回查真约数(octave killer)
- 内容周期冒充像素周期:把最佳/最差相位的分差并入评分(anti-phase)
评分用单元内方差而非相邻差分——差分对模糊极敏感,实测会把 4 像素
网格判成 24~28;组内方差则天然压制过大的 s。

实测:干净放大图 k=2~16 共 27/27 全对零八度错误;非方形网格与相位
偏移全对;普通插画正确判为未检出;端到端把 32×32 放大 10 倍加模糊
噪点的图还原回 32×32,与真值 MAE 0.0049;整数倍放大为纯复制无插值。

调色板在 CIELAB 空间聚类(RGB 距离与人眼感受相差很远,会丢暗部层次),
降采样默认主导色(均值会造出新颜色并糊边),内置 PICO-8/Game Boy 等
可确认取值的复古调色板。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:23:43 +08:00

174 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
调色板生成、颜色映射与抖动
==========================
像素游戏资产通常要求「颜色数受控」——不是为了压缩,而是风格本身的一部分,
也方便后续做整套素材的统一改色。
聚类与最近邻匹配都在 CIELAB 空间做:RGB 空间里的欧氏距离与人眼感受
相差很远(绿色区域被严重高估),直接在 RGB 里挑色会丢暗部层次。
"""
import numpy as np
EPS = 1e-8
# ------------------------------------------------------------------ 色彩空间
def rgb_to_lab(rgb):
"""rgb: (...,3) in [0,1] -> CIELAB(D65)。"""
rgb = np.clip(rgb, 0.0, 1.0)
lin = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
m = np.array([[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041]], dtype=np.float32)
xyz = lin @ m.T
xyz = xyz / np.array([0.95047, 1.0, 1.08883], dtype=np.float32)
d = 6.0 / 29.0
f = np.where(xyz > d ** 3, np.cbrt(np.maximum(xyz, EPS)),
xyz / (3 * d * d) + 4.0 / 29.0)
return np.stack([116.0 * f[..., 1] - 16.0,
500.0 * (f[..., 0] - f[..., 1]),
200.0 * (f[..., 1] - f[..., 2])], axis=-1)
# ------------------------------------------------------------------ 调色板
def kmeans_palette(pixels, k, iters=24, seed=0, sample=20000):
"""
在 LAB 空间做 k-means,返回 (k,3) RGB[0,1] 调色板。
簇心取该簇像素的 RGB 均值 —— 免去 LAB->RGB 反变换,也保证结果一定在色域内。
"""
rng = np.random.default_rng(seed)
px = pixels.reshape(-1, 3)
if px.shape[0] > sample: # 大图抽样,聚类结果几乎不变
px = px[rng.choice(px.shape[0], sample, replace=False)]
k = int(max(1, min(k, px.shape[0])))
lab = rgb_to_lab(px)
# k-means++ 初始化:随机播种容易把多个簇心挤在同一片颜色里
centers = np.empty((k, 3), dtype=np.float32)
centers[0] = lab[rng.integers(px.shape[0])]
d2 = ((lab - centers[0]) ** 2).sum(1)
for i in range(1, k):
prob = d2 / (d2.sum() + EPS)
centers[i] = lab[rng.choice(px.shape[0], p=prob)]
d2 = np.minimum(d2, ((lab - centers[i]) ** 2).sum(1))
labels = np.zeros(px.shape[0], dtype=np.int64)
for _ in range(iters):
dist = ((lab[:, None, :] - centers[None]) ** 2).sum(-1)
new = dist.argmin(1)
if np.array_equal(new, labels):
break
labels = new
for i in range(k):
m = labels == i
if m.any():
centers[i] = lab[m].mean(0)
out = np.zeros((k, 3), dtype=np.float32)
for i in range(k):
m = labels == i
out[i] = px[m].mean(0) if m.any() else px[rng.integers(px.shape[0])]
return np.clip(out, 0.0, 1.0)
def median_cut_palette(pixels, k):
"""PIL 的 median cut,速度快、结果稳定,作为 k-means 之外的备选。"""
from PIL import Image
arr = np.clip(pixels.reshape(-1, 3) * 255.0, 0, 255).astype(np.uint8)
side = int(np.ceil(np.sqrt(arr.shape[0])))
pad = np.zeros((side * side, 3), dtype=np.uint8)
pad[:arr.shape[0]] = arr
pad[arr.shape[0]:] = arr[-1] if arr.shape[0] else 0
im = Image.fromarray(pad.reshape(side, side, 3), "RGB")
q = im.quantize(colors=int(max(1, k)), method=Image.Quantize.MEDIANCUT)
pal = np.array(q.getpalette()[:int(max(1, k)) * 3],
dtype=np.float32).reshape(-1, 3) / 255.0
return np.clip(pal, 0.0, 1.0)
# ------------------------------------------------------------------ 抖动
def bayer_matrix(n):
"""递归生成 n×n(n 为 2 的幂)有序抖动阈值矩阵,取值 [0,1)。"""
m = np.array([[0.0]], dtype=np.float32)
size = 1
while size < n:
m = np.block([[4 * m, 4 * m + 2],
[4 * m + 3, 4 * m + 1]])
size *= 2
return m / (size * size)
def _nearest(lab_px, lab_pal):
"""逐像素找 LAB 距离最近的调色板项,分块算以免一次性开出巨大矩阵。"""
n = lab_px.shape[0]
out = np.empty(n, dtype=np.int64)
step = max(1, int(4_000_000 / max(1, lab_pal.shape[0])))
for i in range(0, n, step):
chunk = lab_px[i:i + step]
d = ((chunk[:, None, :] - lab_pal[None]) ** 2).sum(-1)
out[i:i + step] = d.argmin(1)
return out
def apply_palette(img, palette, dither="none", strength=1.0, seed=0):
"""
把 img (H,W,3) float[0,1] 映射到 palette (k,3),返回同形状图像。
dither:
none 直接取最近色,边界干净,像素画默认
bayer2/4/8 有序抖动,规则网点,复古感强且可平铺
floyd-steinberg 误差扩散,过渡最自然但纹理不规则
noise 随机抖动,打散色带又不产生规则网点
strength 是抖动幅度相对「调色板平均色距」的比例。
"""
H, W = img.shape[:2]
pal = np.clip(np.asarray(palette, dtype=np.float32), 0.0, 1.0)
lab_pal = rgb_to_lab(pal)
# 抖动幅度参照调色板里相邻颜色的典型间距,否则同一强度在
# 4 色盘和 64 色盘上的观感天差地别
if pal.shape[0] > 1:
d = np.sqrt(((pal[:, None, :] - pal[None]) ** 2).sum(-1))
np.fill_diagonal(d, np.inf)
amp = float(np.median(d.min(axis=1))) * float(strength)
else:
amp = 0.0
work = img.astype(np.float32).copy()
if dither.startswith("bayer") and amp > 0:
n = int(dither[5:] or 4)
m = bayer_matrix(n)
tile = np.tile(m, (H // n + 1, W // n + 1))[:H, :W]
work = work + ((tile - 0.5) * amp)[..., None]
elif dither == "noise" and amp > 0:
rng = np.random.default_rng(seed)
work = work + (rng.random((H, W, 1), dtype=np.float32) - 0.5) * amp
if dither == "floyd-steinberg":
# 误差扩散必须串行;像素画输出通常很小(几十到几百像素),
# 这点循环开销可以接受
out = np.empty((H, W, 3), dtype=np.float32)
buf = work
for y in range(H):
for x in range(W):
old = buf[y, x]
i = int(_nearest(rgb_to_lab(old[None]), lab_pal)[0])
new = pal[i]
out[y, x] = new
err = old - new
if x + 1 < W:
buf[y, x + 1] += err * (7 / 16)
if y + 1 < H:
if x > 0:
buf[y + 1, x - 1] += err * (3 / 16)
buf[y + 1, x] += err * (5 / 16)
if x + 1 < W:
buf[y + 1, x + 1] += err * (1 / 16)
return out
idx = _nearest(rgb_to_lab(np.clip(work, 0, 1).reshape(-1, 3)), lab_pal)
return pal[idx].reshape(H, W, 3)