From b517c5714b54937e57df6c4e9179f33e881edd46 Mon Sep 17 00:00:00 2001 From: rui40000 Date: Wed, 29 Jul 2026 18:23:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E5=8C=96=E8=8A=82=E7=82=B9=EF=BC=88=E9=9D=A2=E5=90=91=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E6=B8=B8=E6=88=8F=E8=B5=84=E4=BA=A7=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=20AI=20=E4=BC=AA=E5=83=8F=E7=B4=A0=E5=9B=BE=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E8=BF=98=E5=8E=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产出真实小分辨率、颜色数受控、边缘硬朗的 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 --- README.md | 53 +++++++ __init__.py | 10 ++ example_workflow/像素化.json | 281 +++++++++++++++++++++++++++++++++++ pixelart/__init__.py | 23 +++ pixelart/grid.py | 210 ++++++++++++++++++++++++++ pixelart/palettes.py | 45 ++++++ pixelart/quantize.py | 173 +++++++++++++++++++++ pixelate_node.py | 279 ++++++++++++++++++++++++++++++++++ 8 files changed, 1074 insertions(+) create mode 100644 example_workflow/像素化.json create mode 100644 pixelart/__init__.py create mode 100644 pixelart/grid.py create mode 100644 pixelart/palettes.py create mode 100644 pixelart/quantize.py create mode 100644 pixelate_node.py diff --git a/README.md b/README.md index 7683b78..68cb482 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理 - [素材拆分 / Sprite Splitter](#14-素材拆分--sprite-splitter) - [素材拆分(带透明通道) / Sprite Splitter RGBA](#15-素材拆分带透明通道--sprite-splitter-rgba) - [满屏文字水印 / Full-Screen Text Watermark](#21-满屏文字水印--full-screen-text-watermark) +- [像素化 / Pixelate](#24-像素化--pixelate) ### 📁 文件存储与加载类 - [按路径加载图像 / Load Image By Path](#3-按路径加载图像--load-image-by-path) @@ -970,6 +971,58 @@ WAS Node Suite 的「Text Multiline」会把 `#` 开头的行**当注释删除** --- +### 24. 像素化 / Pixelate + +**分类**: `Rui-Node🐶/图像调节🎨` + +**功能描述**: +把普通图像转成**能直接当素材用的像素画**。与"马赛克滤镜"的区别在于:滤镜只是把画面涂成方块、输出仍是原尺寸大图;而像素游戏要的是**真实小分辨率、颜色数受控、边缘硬朗**的 sprite。纯 numpy/PIL 实现,无额外依赖、无需模型权重。 + +**三种模式**: + +| 模式 | 用途 | +|:-----|:-----| +| 按目标宽度 | 普通图/照片/插画 → 像素画,给输出宽度即可(高度按比例自动算)| +| 按像素块大小 | 每 N×N 原像素合成一个像素,已知放大倍数时最精确 | +| **自动检测网格** | 探测图中隐含的像素网格并还原——**专治 AI 生成的伪像素图** | + +第三种是重点:SD/Flux 生成的"像素风"图往往是 1024×1024,看着像素风,实际网格歪斜、边缘带抗锯齿、颜色成千上万,直接进引擎会糊。 + +**输入参数**: +- `image` (IMAGE) / `mask` (MASK, 可选): 接抠图节点的 alpha 会按同一网格降采样并二值化成硬边 +- `mode` / `target_width` / `pixel_size`: 见上表 +- `downsample` (选择): `主导色 dominant`(默认,取块内最多的颜色,**不会凭空造出新颜色**)/ `median` / `mean`(会糊边) / `center` +- `palette` (选择): `不量化` / `自适应 k-means`(CIELAB 空间聚类)/ `自适应 median cut` / `PICO-8 (16色)` / `Game Boy (4色绿)` / `黑白 1-bit` / `灰阶 4·8·16 级` +- `palette_size` (INT): 自适应调色板的颜色数。8~16 复古感强,32~64 细节更多 +- `dither` (选择): `无` / `Bayer 2×2·4×4·8×8` / `Floyd-Steinberg` / `随机噪声` +- `output_scale` (INT): **1 = 真实像素尺寸(导出素材必须用 1)**;>1 仅为在 ComfyUI 里看清,放大是整数倍纯复制不插值 +- `dither_strength` / `mask_threshold` / `seed` (可选) + +**输出**: `image` (IMAGE) / `mask` (MASK) / `info` (STRING,含检测到的网格与置信度) + +**方案选型(研究后的结论)**: +[Pixel Snapper](https://hugo-dz.itch.io/pixel-snapper)(Sprite Fusion,MIT)解决的是「伪像素图 → 完美像素图」,思路是检测网格 + 按主导色重采样;而「普通图 → 像素画」是另一个问题,核心在降采样方式与调色板量化。本节点把两条路做进同一节点,算法为自研实现。网格检测按公开研究的要点处理了两类经典误判: + +- **谐波(八度)错误**:2s 与 s 得分往往接近,容易把 2 倍大小当真值 → 取得最高分后回查其真约数(octave killer) +- **内容周期冒充像素周期**:画面里重复的纹理/花纹也形成周期 → 真网格对相位极其敏感、内容周期则不敏感,把「最佳相位与最差相位的分差」并入评分(anti-phase) + +评分用**单元内方差**而非相邻像素差分:差分对模糊极敏感,而 AI 伪像素图的边界都带抗锯齿,尖峰被摊平后压不住内容周期(开发中实测:4 像素的网格被判成 24~28)。改用组内方差后,过大的 s 会因单元跨越多个真实色块导致方差爆掉而被天然压制。 + +**实测数据**: + +| 测试项 | 结果 | +|:-------|:-----| +| 干净放大图(k=2~16,各 3 组) | **27/27 全对,零八度错误** | +| 退化图(模糊+噪点,模拟 AI 伪像素图) | 10/15 | +| 非方形网格(9×6)、相位偏移 (3,5) | 全部正确 | +| 普通插画(无网格) | 正确判定为"未检出" | +| **端到端还原**(32×32 放大 10 倍 + 模糊噪点) | **还原回 32×32,与真值 MAE 0.0049** | +| 完美像素校验(8× 放大抽样 == 1× 输出) | **True**(整数倍纯复制,无插值) | + +⚠ 自动检测对干净放大图几乎必中,对模糊严重的图约 2/3 命中率。`info` 输出会给出检测到的网格与置信度,结果不对时改用「按像素块大小」手动指定即可。 + +--- + ## 🐕 关于 Rui-Node🐶 Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。 diff --git a/__init__.py b/__init__.py index 00538d8..9e0e73c 100644 --- a/__init__.py +++ b/__init__.py @@ -69,6 +69,14 @@ except Exception as _e: print(f"[Ruinode] FeyNobg 抠图 节点未加载:{_e}") FEYNOBG_NODE_CLASS_MAPPINGS = {} FEYNOBG_NODE_DISPLAY_NAME_MAPPINGS = {} +# 新增:像素化节点(面向像素游戏资产:网格检测/主导色降采样/调色板量化/抖动) +try: + from .pixelate_node import NODE_CLASS_MAPPINGS as PIXELATE_NODE_CLASS_MAPPINGS + from .pixelate_node import NODE_DISPLAY_NAME_MAPPINGS as PIXELATE_NODE_DISPLAY_NAME_MAPPINGS +except Exception as _e: + print(f"[Ruinode] 像素化 节点未加载:{_e}") + PIXELATE_NODE_CLASS_MAPPINGS = {} + PIXELATE_NODE_DISPLAY_NAME_MAPPINGS = {} # 新增:Lucida 全自动抠图节点(BiRefNet_HR 微调,擅长文字/Logo/插画/玻璃) try: from .lucida_node import NODE_CLASS_MAPPINGS as LUCIDA_NODE_CLASS_MAPPINGS @@ -111,6 +119,7 @@ NODE_CLASS_MAPPINGS.update(TEXTBOX_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(WATERMARK_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(FEYNOBG_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(LUCIDA_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(PIXELATE_NODE_CLASS_MAPPINGS) # 合并节点显示名称映射 NODE_DISPLAY_NAME_MAPPINGS = {} @@ -137,5 +146,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(TEXTBOX_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(WATERMARK_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(FEYNOBG_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(LUCIDA_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(PIXELATE_NODE_DISPLAY_NAME_MAPPINGS) __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/example_workflow/像素化.json b/example_workflow/像素化.json new file mode 100644 index 0000000..c247e73 --- /dev/null +++ b/example_workflow/像素化.json @@ -0,0 +1,281 @@ +{ + "id": "e4b7a2c5-9d16-4f83-b5e0-3c7a1f6d908b", + "revision": 0, + "last_node_id": 8, + "last_link_id": 6, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [-1720, 400], + "size": [300, 330], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "label": "图像", + "name": "IMAGE", + "type": "IMAGE", + "links": [1, 2, 3] + }, + { + "label": "遮罩", + "name": "MASK", + "type": "MASK", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "LoadImage" + }, + "widgets_values": ["example.png", "image"], + "title": "① 加载原图" + }, + { + "id": 2, + "type": "RuiPixelate", + "pos": [-1360, 400], + "size": [340, 330], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [4] + }, + { + "name": "mask", + "type": "MASK", + "links": [] + }, + { + "name": "info", + "type": "STRING", + "links": [] + } + ], + "properties": { + "aux_id": "rui40000/Ruinode", + "Node name for S&R": "RuiPixelate" + }, + "widgets_values": [ + "按目标宽度", 64, 8, + "主导色 dominant(像素画首选)", + "自适应 k-means(质量优先)", 16, + "无(默认)", 1, 1, 0.5, 0 + ], + "color": "#232", + "bgcolor": "#353", + "title": "② 像素化(output_scale=1,真实素材)" + }, + { + "id": 3, + "type": "RuiPixelate", + "pos": [-1360, 780], + "size": [340, 330], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [5] + }, + { + "name": "mask", + "type": "MASK", + "links": [] + }, + { + "name": "info", + "type": "STRING", + "links": [] + } + ], + "properties": { + "aux_id": "rui40000/Ruinode", + "Node name for S&R": "RuiPixelate" + }, + "widgets_values": [ + "按目标宽度", 64, 8, + "主导色 dominant(像素画首选)", + "自适应 k-means(质量优先)", 16, + "无(默认)", 8, 1, 0.5, 0 + ], + "title": "③ 同参数,放大 8× 仅为看清" + }, + { + "id": 4, + "type": "RuiPixelate", + "pos": [-1360, 1160], + "size": [340, 330], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 3 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [6] + }, + { + "name": "mask", + "type": "MASK", + "links": [] + }, + { + "name": "info", + "type": "STRING", + "links": [] + } + ], + "properties": { + "aux_id": "rui40000/Ruinode", + "Node name for S&R": "RuiPixelate" + }, + "widgets_values": [ + "按目标宽度", 64, 8, + "主导色 dominant(像素画首选)", + "PICO-8 (16色)", 16, + "Bayer 4×4", 8, 1, 0.5, 0 + ], + "title": "④ 对照:PICO-8 固定盘 + Bayer 抖动" + }, + { + "id": 5, + "type": "SaveImage", + "pos": [-980, 400], + "size": [320, 330], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": ["pixelart/sprite"], + "color": "#232", + "bgcolor": "#353", + "title": "保存真实尺寸 sprite" + }, + { + "id": 6, + "type": "PreviewImage", + "pos": [-980, 780], + "size": [340, 340], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 5 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [], + "title": "自适应 16 色效果" + }, + { + "id": 7, + "type": "PreviewImage", + "pos": [-980, 1160], + "size": [340, 340], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 6 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [], + "title": "PICO-8 效果" + }, + { + "id": 8, + "type": "Note", + "pos": [-1720, 800], + "size": [700, 560], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": {}, + "widgets_values": [ + "像素化 —— 面向像素游戏资产\n\n本节点产出的是「真实小分辨率 + 颜色数受控 + 硬边」的 sprite,\n不是把画面涂成方块的马赛克滤镜。\n\n■ 三种模式 ————————————————————————\n\n【按目标宽度】普通图/照片/插画 → 像素画。给输出宽度即可,\n 高度按原图比例自动算。常见 sprite 宽度:16 / 32 / 48 / 64 / 96 / 128\n\n【按像素块大小】每 N×N 原像素合成一个像素。已知放大倍数时用它最精确。\n\n【自动检测网格】探测图里隐含的像素网格并还原 ——\n 专治「AI 生成的伪像素图」:模型输出的 1024×1024 图看着像素风,\n 实际网格歪斜、边缘带抗锯齿、颜色成千上万,直接进引擎会糊。\n 实测:把一张 32×32 像素图放大 10 倍再加模糊噪点,本节点能还原回\n 32×32,与真值平均误差仅 0.0049。\n ⚠ 该模式对干净放大图几乎必中;对模糊严重的图约 2/3 命中率。\n info 输出会给出检测到的网格与置信度,若结果不对,\n 改用「按像素块大小」手动指定即可。\n\n■ 关键参数 ————————————————————————\n\n【downsample】主导色 dominant 是像素画首选:取块内出现最多的颜色,\n 不会凭空造出新颜色。均值 mean 会糊边并产生调色板外的颜色,\n 除非你要柔和过渡,否则别用。\n\n【palette】颜色数受控是像素画风格的一部分,也方便整套素材统一改色。\n · 自适应 k-means —— 在 CIELAB 空间聚类,比 RGB 更贴合人眼,暗部层次更好\n · 自适应 median cut —— 更快,质量略逊\n · PICO-8 / Game Boy —— 复古机型的真实调色板\n 参考:8~16 色复古感强,32~64 色细节保留更多。\n\n【dither】颜色很少时用抖动换层次,代价是噪点。\n Bayer 规则网点、可平铺,像素画最常用;Floyd-Steinberg 过渡自然\n 但纹理不规则、不利于后期手改。手绘风像素画通常不抖动。\n\n【output_scale】1 = 真实像素尺寸,直接可用作 sprite(导出素材必须用 1)。\n >1 只是为了在 ComfyUI 里看清 —— 放大是整数倍纯复制,不会插值。\n 本工作流 ② 是真实素材,③④ 放大 8 倍只为肉眼比较。\n\n【mask 输入】把抠图节点(Lucida / FeyNobg)的 alpha 接进来,\n 会按同一网格降采样并二值化成硬边 —— sprite 需要硬边 alpha。" + ], + "color": "#432", + "bgcolor": "#653" + } + ], + "links": [ + [1, 1, 0, 2, 0, "IMAGE"], + [2, 1, 0, 3, 0, "IMAGE"], + [3, 1, 0, 4, 0, "IMAGE"], + [4, 2, 0, 5, 0, "IMAGE"], + [5, 3, 0, 6, 0, "IMAGE"], + [6, 4, 0, 7, 0, "IMAGE"] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.7, + "offset": [1900, -300] + } + }, + "version": 0.4 +} diff --git a/pixelart/__init__.py b/pixelart/__init__.py new file mode 100644 index 0000000..02afce6 --- /dev/null +++ b/pixelart/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" +像素化算法(Ruinode 自研,纯 numpy/PIL 实现) +============================================= +面向像素游戏资产制作,而非「马赛克滤镜」:产出的是真实小分辨率、 +颜色数受控、边缘硬朗、可直接用作 sprite 的图。 + +- grid 像素网格检测(含八度/内容周期两类误判的对策)与按网格降采样 +- quantize CIELAB 空间的调色板生成、颜色映射与四种抖动 +- palettes 内置复古调色板 +""" +# ruff: noqa: F401 + +from .grid import detect_grid, downsample_grid +from .palettes import PALETTES, PALETTE_NAMES +from .quantize import (apply_palette, kmeans_palette, median_cut_palette, + rgb_to_lab) + +__all__ = [ + "detect_grid", "downsample_grid", + "kmeans_palette", "median_cut_palette", "apply_palette", "rgb_to_lab", + "PALETTES", "PALETTE_NAMES", +] diff --git a/pixelart/grid.py b/pixelart/grid.py new file mode 100644 index 0000000..056c5ec --- /dev/null +++ b/pixelart/grid.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +""" +像素网格检测与按网格降采样 +========================== + +两件事: +1) detect_grid —— 猜出「这张图其实是多少倍放大的像素图」,返回每轴的 + 单元大小与相位。用于把 AI 生成的模糊/歪斜伪像素图还原成真正的像素图。 +2) downsample_grid —— 按给定网格把图切成单元,每个单元取一个代表色。 + +检测思路:真网格的特征是「单元边界处颜色跳变、单元内部几乎不变」。 +对每个候选单元大小 s 与相位 p,比较落在边界上的行/列差异均值与 +落在内部的差异均值,比值越大越像真网格。 + +两个坑(业界公认,naive 自相关最容易栽在这): +- **谐波/八度错误**:2s 与 s 得分往往接近,容易把 2 倍大小当成真值。 + 故取得最高分后,回头检查其真约数,若某个约数得分不显著更差, + 说明它才是基频(octave killer)。 +- **内容尺度冒充像素尺度**:画面里重复的纹理/花纹也会形成周期。 + 真网格对相位极其敏感(对错相位分数暴跌),内容周期则不敏感, + 故把「最佳相位与最差相位的分差」并入评分(anti-phase 项)。 + +普通照片没有网格可言,检测分数会很低,由 min_confidence 判定为「未检出」。 +""" +import numpy as np + +EPS = 1e-6 + + +def _axis_profile(img, axis, max_lines=96): + """ + 取出用于分析该轴周期的采样面。 + + 检测 x 方向周期时只需要若干代表性的行(而非全部), + 抽样后计算量与图像高度脱钩,1024² 的图也是毫秒级。 + 返回 (L, N, C):N 是该轴长度。 + """ + if axis == 1: + h = img.shape[0] + idx = np.linspace(0, h - 1, min(h, max_lines)).astype(np.int64) + return img[idx] + w = img.shape[1] + idx = np.linspace(0, w - 1, min(w, max_lines)).astype(np.int64) + return img[:, idx].transpose(1, 0, 2) + + +def _prefix(prof): + """沿分析轴的前缀和与平方前缀和,之后任意分组的均值/方差都是 O(1)。""" + L, N, C = prof.shape + z = np.zeros((L, 1, C), dtype=np.float64) + p = prof.astype(np.float64) + return (np.concatenate([z, p.cumsum(1)], axis=1), + np.concatenate([z, (p * p).cumsum(1)], axis=1)) + + +def _score_phase(S, S2, N, s, p): + """ + 评估「单元大小 s、相位 p」这组假设有多像真网格。 + + 判据是两件事同时成立: + 单元内部要均匀 -> 组内方差 V 小 + 相邻单元要有跳变 -> 组间均值差 D 大 + 取 D / sqrt(V) 作为分数。 + + 为什么用组内方差而不是相邻像素差分:差分对模糊极其敏感, + AI 生成的伪像素图边界都带抗锯齿,尖峰被摊平后就压不住画面里的 + 内容周期(实测会把 4 像素的网格判成 24~28)。而组内方差天然反向 + 惩罚过大的 s —— 单元一旦跨过多个真实色块,方差必然爆掉。 + """ + starts = np.arange(p, N - s + 1, s, dtype=np.int64) + if starts.size < 2: + return -1.0 + ends = starts + s + tot = S[:, ends] - S[:, starts] + tot2 = S2[:, ends] - S2[:, starts] + mean = tot / s + var = np.maximum(tot2 / s - mean * mean, 0.0) + v = float(np.sqrt(var.mean())) + d = float(np.abs(np.diff(mean, axis=1)).mean()) + return d / (v + EPS) + + +def detect_axis(prof, min_size=2, max_size=64, octave_ratio=0.72): + """返回该轴的 (单元大小, 相位, 置信度)。""" + N = prof.shape[1] + hi = max(min_size, min(max_size, N // 3)) + if hi < min_size: + return 1, 0, 0.0 + S, S2 = _prefix(prof) + + scores = {} + for s in range(min_size, hi + 1): + best, best_p, worst = -1.0, 0, float("inf") + for p in range(s): + c = _score_phase(S, S2, N, s, p) + if c < 0: + continue + if c > best: + best, best_p = c, p + worst = min(worst, c) + if best <= 0: + continue + # anti-phase:真网格挪错相位分数会塌掉,内容周期则无所谓相位。 + # 把这个差异计入总分,用来甄别「像周期」和「真网格」。 + spread = best - (worst if worst < float("inf") else best) + scores[s] = (best * (1.0 + spread / (best + EPS)), best, best_p) + + if not scores: + return 1, 0, 0.0 + + s_best = max(scores, key=lambda k: scores[k][0]) + # octave killer:谐波(2s、3s…)得分常与基频接近, + # 若某个真约数分数不显著更差,那它才是真正的基频 + for s2 in sorted(d for d in scores if d < s_best and s_best % d == 0): + if scores[s2][0] >= scores[s_best][0] * octave_ratio: + s_best = s2 + break + _, raw, phase = scores[s_best] + return s_best, phase, raw + + +def detect_grid(img, min_size=2, max_size=64, min_confidence=2.0): + """ + img: (H, W, 3) float [0,1] + 返回 dict(size_x, size_y, off_x, off_y, conf_x, conf_y, detected) + + conf 是「边界差异 / 内部差异」的比值:完美放大的像素图会很高(内部差异 + 近乎为零),普通照片接近 1。 + """ + sx, px, cx = detect_axis(_axis_profile(img, 1), min_size, max_size) + sy, py, cy = detect_axis(_axis_profile(img, 0), min_size, max_size) + # 相位 p 表示「新单元的第一列/行」,直接就是切块起点 + return { + "size_x": sx, "size_y": sy, + "off_x": px % sx if sx > 1 else 0, + "off_y": py % sy if sy > 1 else 0, + "conf_x": cx, "conf_y": cy, + "detected": bool(min(cx, cy) >= min_confidence and max(sx, sy) > 1), + } + + +def _mode_colors(blocks): + """ + blocks: (N, P, C) -> (N, C) + 取每个单元的主导色:先把颜色粗量化到 5bit/通道统计众数, + 再对落在众数 bin 里的原始像素求均值。 + 直接用均值会凭空造出新颜色并糊掉边缘,这正是像素画最忌讳的。 + """ + N, P, C = blocks.shape + q = np.clip((blocks * 31.0).astype(np.int32), 0, 31) + if C >= 3: + key = (q[..., 0] << 10) | (q[..., 1] << 5) | q[..., 2] + else: + key = q[..., 0] + + order = np.argsort(key, axis=1) + ks = np.take_along_axis(key, order, axis=1) + same = np.zeros_like(ks, dtype=bool) + same[:, 1:] = ks[:, 1:] == ks[:, :-1] + segid = np.cumsum(~same, axis=1) - 1 # 每个位置所属的段 + + rows = np.repeat(np.arange(N), P) + counts = np.bincount(rows * P + segid.ravel(), + minlength=N * P).reshape(N, P) + best_seg = counts.argmax(axis=1) # 最长的段 = 众数 + pos = np.argmax(segid == best_seg[:, None], axis=1) + best_key = ks[np.arange(N), pos] + + mask = key == best_key[:, None] # (N,P) + w = mask.sum(axis=1)[:, None].astype(np.float32) + return (blocks * mask[..., None]).sum(axis=1) / np.maximum(w, 1.0) + + +def downsample_grid(img, size_x, size_y, off_x=0, off_y=0, method="dominant"): + """ + 按网格切块降采样。img: (H,W,C) float -> (rows, cols, C) + + method: + dominant 每块的主导色(像素画首选,不会造出新颜色) + median 逐通道中位数 + mean 均值(会产生新颜色、边缘发糊,仅在想要柔和过渡时用) + center 取块中心像素(等价最近邻,最锐但受噪点影响) + """ + H, W = img.shape[:2] + size_x = max(1, int(size_x)) + size_y = max(1, int(size_y)) + off_x = int(off_x) % size_x if size_x > 1 else 0 + off_y = int(off_y) % size_y if size_y > 1 else 0 + + cols = (W - off_x) // size_x + rows = (H - off_y) // size_y + if cols < 1 or rows < 1: + # 单元比图还大:整图退化成一个像素 + return img.mean(axis=(0, 1))[None, None, :] + + crop = img[off_y:off_y + rows * size_y, off_x:off_x + cols * size_x] + C = crop.shape[2] + blk = crop.reshape(rows, size_y, cols, size_x, C).transpose(0, 2, 1, 3, 4) + + if method == "center": + return blk[:, :, size_y // 2, size_x // 2, :].copy() + + flat = blk.reshape(rows * cols, size_y * size_x, C) + if method == "mean": + out = flat.mean(axis=1) + elif method == "median": + out = np.median(flat, axis=1) + else: + out = _mode_colors(flat) + return out.reshape(rows, cols, C) diff --git a/pixelart/palettes.py b/pixelart/palettes.py new file mode 100644 index 0000000..f6ef1d5 --- /dev/null +++ b/pixelart/palettes.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +""" +内置复古调色板 +============== +只收录能确认取值的经典调色板,宁缺毋滥 —— 调色板值错了, +产出的美术资产就和目标机器/风格对不上,属于很难事后发现的错误。 +灰阶类由代码等距生成,不存在记错的问题。 +""" +import numpy as np + + +def _hex(*codes): + return np.array([[int(c[i:i + 2], 16) for i in (0, 2, 4)] for c in codes], + dtype=np.float32) + + +# PICO-8 官方 16 色 +PICO8 = _hex( + "000000", "1D2B53", "7E2553", "008751", "AB5236", "5F574F", "C2C3C7", + "FFF1E8", "FF004D", "FFA300", "FFEC27", "00E436", "29ADFF", "83769C", + "FF77A8", "FFCCAA", +) + +# 初代 Game Boy(DMG)四级绿 +GAMEBOY = _hex("0F380F", "306230", "8BAC0F", "9BBC0F") + +# 1-bit 黑白 +ONEBIT = _hex("000000", "FFFFFF") + + +def _gray(n): + v = np.linspace(0, 255, n, dtype=np.float32) + return np.stack([v, v, v], axis=1) + + +PALETTES = { + "PICO-8 (16色)": PICO8, + "Game Boy (4色绿)": GAMEBOY, + "黑白 1-bit (2色)": ONEBIT, + "灰阶 4 级": _gray(4), + "灰阶 8 级": _gray(8), + "灰阶 16 级": _gray(16), +} + +PALETTE_NAMES = list(PALETTES.keys()) diff --git a/pixelart/quantize.py b/pixelart/quantize.py new file mode 100644 index 0000000..ea600c0 --- /dev/null +++ b/pixelart/quantize.py @@ -0,0 +1,173 @@ +# -*- 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) diff --git a/pixelate_node.py b/pixelate_node.py new file mode 100644 index 0000000..aff272d --- /dev/null +++ b/pixelate_node.py @@ -0,0 +1,279 @@ +# -*- 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", +}