Files
rui40000-RUI-Nodes/pixelate_node.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

280 lines
14 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 -*-
"""
像素化节点(Ruinode)
=====================
把普通图像转成**能直接当素材用的像素画**,而不是"马赛克滤镜"。
两者的区别在于:滤镜只是把画面涂成方块,输出仍是原尺寸的大图;
而像素游戏要的是真实小分辨率、颜色数受控、边缘硬朗的 sprite。
三种工作模式:
- 按目标宽度 给定输出宽度(如 64),普通图/照片/插画 → 像素画
- 按像素块大小 每 N×N 原像素合成一个像素,适合已知放大倍数时精确还原
- 自动检测网格 探测图像本身隐含的像素网格并还原
—— 专治「AI 生成的伪像素图」:模型输出的 1024×1024 图看着像素风,
实际网格歪斜、边缘带抗锯齿、颜色成千上万,塞进引擎会糊。
关于方案选型(研究后的结论):
Sprite Fusion 的 Pixel Snapper(MIT)解决的是「伪像素图 → 完美像素图」,
思路是检测网格 + 按主导色重采样;而「普通图 → 像素画」是另一个问题,
核心在降采样方式与调色板量化。本节点把两条路都做进同一个节点,
算法为纯 numpy/PIL 自研实现(无额外依赖、无需模型权重),其中网格检测
按公开研究的要点处理了两类经典误判:谐波(八度)错误与内容周期冒充像素周期,
详见 pixelart/grid.py。
"""
import numpy as np
import torch
from .pixelart import (PALETTE_NAMES, PALETTES, apply_palette, detect_grid,
downsample_grid, kmeans_palette, median_cut_palette)
_MODES = ["按目标宽度", "按像素块大小", "自动检测网格(AI伪像素图还原)"]
_DOWN = {
"主导色 dominant(像素画首选)": "dominant",
"中位数 median": "median",
"均值 mean(会糊边,慎用)": "mean",
"中心像素 center(最锐)": "center",
}
_DITHER = {
"无(默认)": "none",
"Bayer 2×2": "bayer2",
"Bayer 4×4": "bayer4",
"Bayer 8×8": "bayer8",
"Floyd-Steinberg": "floyd-steinberg",
"随机噪声": "noise",
}
_PAL_ADAPTIVE = ["自适应 k-means(质量优先)", "自适应 median cut(速度优先)"]
_PAL_OPTIONS = ["不量化"] + _PAL_ADAPTIVE + PALETTE_NAMES
def _nearest_resize(img, tw, th):
"""最近邻缩放。绝不插值 —— 插值会造出调色板外的新颜色并糊掉硬边。"""
h, w = img.shape[:2]
if (w, h) == (tw, th):
return img
xi = np.clip((np.arange(tw) * (w / tw)).astype(np.int64), 0, w - 1)
yi = np.clip((np.arange(th) * (h / th)).astype(np.int64), 0, h - 1)
return img[yi][:, xi]
class RuiPixelate:
"""图像 → 像素画(面向像素游戏资产)。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mode": (_MODES, {
"default": "按目标宽度",
"tooltip": "按目标宽度:普通图转像素画,直接给输出宽度\n"
"按像素块大小:每 N×N 原像素合成一个像素\n"
"自动检测网格:探测图中隐含的像素网格并还原,\n"
" 专治 AI 生成的模糊伪像素图(网格歪、带抗锯齿)"
}),
"target_width": ("INT", {
"default": 64, "min": 8, "max": 2048, "step": 1,
"tooltip": "仅「按目标宽度」模式生效。高度按原图比例自动计算。\n"
"常见 sprite 尺寸:16 / 32 / 48 / 64 / 96 / 128"
}),
"pixel_size": ("INT", {
"default": 8, "min": 1, "max": 256, "step": 1,
"tooltip": "仅「按像素块大小」模式生效:每 N×N 原像素 → 1 像素。"
}),
"downsample": (list(_DOWN.keys()), {
"default": "主导色 dominant(像素画首选)",
"tooltip": "每个单元如何定色。\n"
"主导色:取块内出现最多的颜色,不会凭空造出新颜色(首选)\n"
"中位数:抗噪,偶尔比主导色更稳\n"
"均值:会产生新颜色并糊边,只在想要柔和过渡时用\n"
"中心像素:等价最近邻,最锐利但受噪点影响"
}),
"palette": (_PAL_OPTIONS, {
"default": "自适应 k-means(质量优先)",
"tooltip": "颜色数受控是像素画风格的一部分,也方便整套素材统一改色。\n"
"自适应:从画面自身聚类出调色板(k-means 在 CIELAB 空间,\n"
" 比 RGB 更贴合人眼,暗部层次保留更好)\n"
"固定盘:PICO-8 / Game Boy 等复古机型的真实调色板"
}),
"palette_size": ("INT", {
"default": 16, "min": 2, "max": 256, "step": 1,
"tooltip": "仅自适应调色板生效。选固定调色板时其颜色数已定,此项忽略。\n"
"参考:8~16 复古感强,32~64 细节保留更多。"
}),
"dither": (list(_DITHER.keys()), {
"default": "无(默认)",
"tooltip": "颜色数很少时用抖动能换回一些层次,代价是引入噪点。\n"
"Bayer:规则网点,复古感强、可平铺,像素画最常用\n"
"Floyd-Steinberg:过渡最自然,但纹理不规则、不利于后期手改\n"
"手绘风像素画通常不抖动,先试「无」。"
}),
"output_scale": ("INT", {
"default": 1, "min": 1, "max": 32, "step": 1,
"tooltip": "输出放大倍数(最近邻,不插值)。\n"
"1 = 真实像素尺寸,直接可用作 sprite(推荐)\n"
">1 仅为了在 ComfyUI 里看清效果,导出素材前记得改回 1"
}),
},
"optional": {
"mask": ("MASK", {
"tooltip": "可选。抠图得到的 alpha 接进来会按同一网格降采样,\n"
"并按 mask_threshold 二值化成硬边 —— sprite 需要硬边 alpha。"
}),
"dither_strength": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05,
"tooltip": "抖动幅度。已按调色板的平均色距归一化,\n"
"所以同一数值在 4 色盘和 64 色盘上观感接近。"
}),
"mask_threshold": ("FLOAT", {
"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "遮罩二值化阈值,高于它算不透明。\n"
"设为 0 则保留灰度遮罩(不二值化)。"
}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFF}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("image", "mask", "info")
FUNCTION = "pixelate"
CATEGORY = "Rui-Node🐶/图像调节🎨"
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
# ---------------------------------------------------------- 单张处理
def _one(self, img, msk, mode, target_width, pixel_size, down, pal_opt,
pal_size, dither, strength, mask_th, seed, notes):
H, W = img.shape[:2]
# ---- 1. 确定网格并降采样 ----
if mode.startswith("自动检测"):
info = detect_grid(img)
sx, sy = info["size_x"], info["size_y"]
ox, oy = info["off_x"], info["off_y"]
if info["detected"]:
notes.append(
f"检测到网格 {sx}×{sy}(相位 {ox},{oy};置信度 "
f"{info['conf_x']:.1f}/{info['conf_y']:.1f})")
else:
notes.append(
f"未检出可靠网格(置信度 {info['conf_x']:.1f}/"
f"{info['conf_y']:.1f},低于阈值)—— 该图可能本就不是"
f"放大的像素图。已按检测到的 {sx}×{sy} 处理,"
f"建议改用「按目标宽度」模式")
small = downsample_grid(img, sx, sy, ox, oy, down)
small_m = downsample_grid(msk[..., None], sx, sy, ox, oy,
"mean")[..., 0] if msk is not None else None
elif mode.startswith("按像素块"):
s = int(pixel_size)
small = downsample_grid(img, s, s, 0, 0, down)
small_m = downsample_grid(msk[..., None], s, s, 0, 0,
"mean")[..., 0] if msk is not None else None
notes.append(f"块大小 {s}×{s}")
else:
ow = int(max(1, min(target_width, W)))
oh = max(1, int(round(H * ow / W)))
# 先最近邻对齐到整数倍,再走等距网格:这样输出尺寸精确,
# 又不会像先做面积平均那样把颜色糊掉
k = max(1, int(round(W / ow)))
im2 = _nearest_resize(img, ow * k, oh * k)
small = downsample_grid(im2, k, k, 0, 0, down)
if msk is not None:
m2 = _nearest_resize(msk[..., None], ow * k, oh * k)
small_m = downsample_grid(m2, k, k, 0, 0, "mean")[..., 0]
else:
small_m = None
notes.append(f"目标宽度 {ow} → 输出 {small.shape[1]}×{small.shape[0]}")
# ---- 2. 调色板量化 ----
if pal_opt != "不量化":
if pal_opt in PALETTES:
pal = PALETTES[pal_opt] / 255.0
elif pal_opt.startswith("自适应 k-means"):
pal = kmeans_palette(small, int(pal_size), seed=int(seed))
else:
pal = median_cut_palette(small, int(pal_size))
small = apply_palette(small, pal, _DITHER.get(dither, "none"),
float(strength), int(seed))
notes.append(f"调色板 {pal_opt}({pal.shape[0]} 色)"
+ (f" + {dither}" if _DITHER.get(dither) != "none" else ""))
else:
uniq = np.unique(
(np.clip(small, 0, 1) * 255).astype(np.uint8).reshape(-1, 3),
axis=0).shape[0]
notes.append(f"未量化(实际 {uniq} 色)")
if small_m is not None and mask_th > 0:
small_m = (small_m >= float(mask_th)).astype(np.float32)
return np.clip(small, 0.0, 1.0), small_m
def pixelate(self, image, mode, target_width, pixel_size, downsample,
palette, palette_size, dither, output_scale,
mask=None, dither_strength=1.0, mask_threshold=0.5, seed=0):
down = _DOWN.get(downsample, "dominant")
B, H, W, C = image.shape
if C == 4:
image = image[..., :3]
elif C == 1:
image = image.repeat(1, 1, 1, 3)
if mask is not None:
if mask.dim() == 2:
mask = mask.unsqueeze(0)
if mask.shape[0] != B:
mask = mask[:1].repeat(B, 1, 1)
outs, masks, notes = [], [], []
for b in range(B):
img = image[b].detach().cpu().float().numpy()
msk = mask[b].detach().cpu().float().numpy() if mask is not None else None
if msk is not None and msk.shape[:2] != img.shape[:2]:
msk = _nearest_resize(msk[..., None], img.shape[1],
img.shape[0])[..., 0]
n = []
small, small_m = self._one(
img, msk, mode, target_width, pixel_size, down, palette,
palette_size, dither, dither_strength, mask_threshold,
seed + b, n)
if b == 0:
notes = n
k = int(max(1, output_scale))
if k > 1:
small = np.repeat(np.repeat(small, k, axis=0), k, axis=1)
if small_m is not None:
small_m = np.repeat(np.repeat(small_m, k, axis=0), k, axis=1)
outs.append(torch.from_numpy(np.ascontiguousarray(small)))
masks.append(torch.from_numpy(np.ascontiguousarray(
small_m if small_m is not None
else np.ones(small.shape[:2], dtype=np.float32))))
# 批内各图尺寸可能不同(原图比例不一),此时只能退回逐张,
# 但 ComfyUI 的 IMAGE 必须是同形状张量,故统一到首张尺寸
h0, w0 = outs[0].shape[:2]
for i in range(1, len(outs)):
if outs[i].shape[:2] != (h0, w0):
outs[i] = torch.from_numpy(_nearest_resize(
outs[i].numpy(), w0, h0))
masks[i] = torch.from_numpy(_nearest_resize(
masks[i].numpy()[..., None], w0, h0)[..., 0])
img_out = torch.stack(outs)
msk_out = torch.stack(masks)
info = f"{W}×{H} → {w0}×{h0}" + (f"(预览放大 {output_scale}×)"
if output_scale > 1 else "")
info += "\n" + "\n".join(notes)
print(f"[Ruinode-Pixelate] {info}")
return (img_out, msk_out, info)
NODE_CLASS_MAPPINGS = {
"RuiPixelate": RuiPixelate,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RuiPixelate": "像素化 / Pixelate",
}