Files
rui40000andClaude Opus 4.8 5d3834503e fix: 八方向拆分不再切断角色;全仓库参数补齐中文 tooltip
【修复】角色被格线切断(脚、手杖、飘起的斗篷被削掉)
新增 expand_beyond_cell(默认开启):格子只用来判定「这是哪个方向」,
角色的实际范围由它自身的连通区域决定,按质心归属确保邻居不混入。
实测 8/8 方向的裁剪框边缘 alpha 从 1.00(内容顶到边界=被切断)
降到 0.00,S 方向高度 326→356、E 方向宽度 150→188 把缺的部分找了回来。
代价是需要两遍扫描(先求全序列并集框再提取),耗时 4.7s→14.9s。

【规则】每个参数都必须有中文 tooltip,作为以后的统一约定
全仓库 26 个节点 169 个参数,此前缺 115 个,现已 100% 覆盖。
tooltip 写「怎么调」而不只是「是什么」:给取值区间的实际影响、
推荐值与踩坑提示(如 OpenAI/ZenMux 的地址栏不能带 :// ,
素材拆分节点用于动画序列时顺序会漂移等)。

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

288 lines
14 KiB
Python
Raw Permalink 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", {
"tooltip": "待像素化的图像。批量输入时若各图比例不同,\n"
"会统一到首张的输出尺寸(ComfyUI 的 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,
"tooltip": "随机种子。影响 k-means 调色板的初始化与随机噪声抖动;\n"
"同一张图换种子会得到略有差异的配色,可多试几个挑顺眼的。\n"
"固定调色板与 Bayer 抖动不受它影响。"
}),
},
}
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",
}