Add files via upload
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# ComfyUI — Pixel Snapper
|
||||
|
||||
Turn any image into clean, grid-aligned **pixel art** inside ComfyUI, using the
|
||||
compiled [SpriteFusion Pixel Snapper](https://github.com/Hugo-Dz/spritefusion-pixel-snapper)
|
||||
engine — plus color controls, classic video-game palettes, a pixel-perfect resizer,
|
||||
and an interactive preview with a magnifier.
|
||||
|
||||

|
||||
|
||||
> _Example workflow: Load Image → Pixel Snapper (Engine) → Pixel Art Preview →
|
||||
> Pixel Art Resizer → Preview Image, with a Video Game Palette node._
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Real snapping engine** — detects the native pixel grid and downscales via
|
||||
majority-color voting (not a naive resize).
|
||||
- **Color controls** — saturation / contrast / brightness applied *before* the snap.
|
||||
- **21 video-game palettes** — Game Boy, NES, PICO-8, C64, ZX Spectrum, DawnBringer,
|
||||
and more, in a dedicated node.
|
||||
- **Aspect-ratio fix** — corrects the engine's occasional "flattening" so output
|
||||
keeps the source proportions.
|
||||
- **Pixel Art Resizer** — scale up crisp (nearest) or shrink down to pixel-art
|
||||
resolution.
|
||||
- **Pixel Art Preview** — crisp nearest-neighbor rendering with an interactive
|
||||
**magnifier lens** that follows your cursor.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Nodes
|
||||
|
||||
All nodes live under **Add Node → image → pixel_art**.
|
||||
|
||||
### Pixel Snapper (Engine)
|
||||
Runs the compiled engine, then layers optional color and palette processing around it.
|
||||
|
||||
| Input | Description |
|
||||
|-------|-------------|
|
||||
| `image` | Input image |
|
||||
| `colors_k` | Number of output colors (ignored when a palette is connected) |
|
||||
| `saturation` / `contrast` / `brightness` | Color tweaks applied **before** the snap (they influence the engine's k-means) |
|
||||
| `fix_aspect` | Corrects the engine's aspect-ratio flattening (trims the over-represented axis, nearest). On by default |
|
||||
| `keep_output_size` | Upscales the result back to the original size (nearest) |
|
||||
| `palette` *(optional)* | A fixed palette from the **Video Game Palette** node |
|
||||
| `palette_dither` *(optional)* | Floyd–Steinberg dithering during palette remap |
|
||||
|
||||
### Video Game Palette
|
||||
Dropdown of classic palettes → outputs a `GAME_PALETTE`. Connect it to the Pixel
|
||||
Snapper's `palette` input. When connected, the engine's `k` becomes the palette size
|
||||
and the output is remapped to the **exact** palette colors.
|
||||
|
||||
### Pixel Art Resizer
|
||||
Resizes pixel art without blurring.
|
||||
|
||||
| Input | Description |
|
||||
|-------|-------------|
|
||||
| `mode` | `scale_factor` (multiply) or `target_size` (width/height) |
|
||||
| `scale_factor` | Factor in `scale_factor` mode (e.g. `4.0` = 4×) |
|
||||
| `width` / `height` | Target size in `target_size` mode |
|
||||
| `interpolation` | `nearest` (crisp upscale), or `box`/`lanczos`/`bicubic`/`bilinear`/`hamming` (downscale) |
|
||||
| `keep_aspect` | Preserve aspect ratio in `target_size` mode |
|
||||
|
||||
> **Tip:** use `nearest` to enlarge pixel art; use `box` to shrink a large image
|
||||
> down to a pixel-art resolution.
|
||||
|
||||
### Pixel Art Preview *(output node)*
|
||||
Displays the image with crisp (nearest-neighbor) rendering and an interactive
|
||||
magnifier — hover the image to zoom in on individual pixels.
|
||||
|
||||
| Input | Description |
|
||||
|-------|-------------|
|
||||
| `magnifier` | Toggle the lens on/off |
|
||||
| `zoom` | Magnification (2–32×) |
|
||||
| `lens_size` | Lens diameter in pixels |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ How it works
|
||||
|
||||
1. **Grid detection** — the engine analyzes intensity peaks to estimate the pixel
|
||||
step size on each axis, then an edge-following "walker" places grid cuts.
|
||||
2. **Downscale** — each grid cell is collapsed to its most frequent color
|
||||
(majority voting), producing a small, crisp pixel-art image.
|
||||
3. **Color quantization** — colors are reduced to `k` via k-means (or to a fixed
|
||||
palette when one is connected).
|
||||
4. **Wrapper layer (this repo)** — the ComfyUI nodes handle image ↔ tensor
|
||||
conversion, pre-snap color adjustments, the `fix_aspect` correction, palette
|
||||
remapping, resizing, and the crisp magnifier preview (a small front-end widget
|
||||
in `web/pixelart_preview.js`).
|
||||
|
||||
The engine runs as a bundled `.exe` invoked per image:
|
||||
|
||||
```
|
||||
spritefusion-pixel-snapper.exe <input.png> <output.png> <k>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
1. Copy the `comfyui_pixel_snapper` folder into `ComfyUI/custom_nodes/`.
|
||||
The `spritefusion-pixel-snapper.exe` binary is **included**, so it works
|
||||
standalone on any Windows machine — no path setup required.
|
||||
2. *(Optional)* To use your own build of the engine, simply replace
|
||||
`spritefusion-pixel-snapper.exe` in the node folder with your binary (same name).
|
||||
3. Restart ComfyUI, then hard-refresh the browser (**Ctrl+F5**) so the preview
|
||||
widget's JavaScript loads.
|
||||
|
||||
Dependencies (`torch`, `numpy`, `Pillow`) ship with ComfyUI.
|
||||
|
||||
> ⚠️ **Windows only** — the engine is shipped as a `.exe`. On Linux/macOS you would
|
||||
> need to build the engine from source (see credits).
|
||||
|
||||
### 🔒 Binary safety
|
||||
|
||||
The bundled `spritefusion-pixel-snapper.exe` is the compiled
|
||||
[open-source engine](https://github.com/Hugo-Dz/spritefusion-pixel-snapper). You can
|
||||
verify it yourself on VirusTotal:
|
||||
|
||||
**[VirusTotal scan report](https://www.virustotal.com/gui/file-analysis/MGE0Zjk1MjU5M2Y3ZGNlNDRlZWEwNDQ4OTk5YjZiZjA6MTc4MzM2NDE0Ng==)**
|
||||
|
||||
Prefer full trust? Build the engine from source (see credits) and replace the
|
||||
`spritefusion-pixel-snapper.exe` in the node folder with your own binary.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Included palettes (21)
|
||||
|
||||
Game Boy (DMG / Pocket) · NES (Full 54) · PICO-8 · CGA (Palette 1 / EGA 16) ·
|
||||
Sweetie 16 · DawnBringer 16 · DawnBringer 32 · Commodore 64 · ZX Spectrum ·
|
||||
Endesga 32 · Resurrect 64 · SLSO8 · Oil 6 · 1-Bit Monitor Glow · 2-Bit Demichrome ·
|
||||
Ice Cream GB · Kirokaze Gameboy · Rustic GB · Mist GB
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
- **Pixel-snapping engine** — [SpriteFusion Pixel Snapper](https://github.com/Hugo-Dz/spritefusion-pixel-snapper)
|
||||
by **Hugo Duprez**. All credit for the core snapping algorithm and the compiled
|
||||
binary goes to the original author; please refer to the upstream repository for
|
||||
its license and terms.
|
||||
- **ComfyUI custom node & integration** — implemented by **[HexaDucket](https://github.com/HexaDucket)**.
|
||||
The nodes, palettes, aspect-ratio fix, resizer, and the magnifier preview widget
|
||||
in this repository are my own work wrapping the engine for ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
<!-- Optional: add a LICENSE file. Keep it compatible with the upstream engine's license. -->
|
||||
@@ -0,0 +1,5 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"id": "c6594601-b6ca-49d6-ad7f-6b60bb1906f5",
|
||||
"revision": 0,
|
||||
"last_node_id": 6,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "VideoGamePalette",
|
||||
"pos": [
|
||||
1487.7681467795892,
|
||||
-1346.4899488120564
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "palette",
|
||||
"type": "GAME_PALETTE",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VideoGamePalette",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.7"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"NES (completa 54)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PixelArtPreview",
|
||||
"pos": [
|
||||
1773.8122799418038,
|
||||
-1606.7501614765035
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
273
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PixelArtPreview",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.7"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
true,
|
||||
4,
|
||||
140,
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PixelArtResizer",
|
||||
"pos": [
|
||||
1995.6162003659078,
|
||||
-1604.379113069304
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
178
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PixelArtResizer",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.7"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"scale_factor",
|
||||
4,
|
||||
128,
|
||||
128,
|
||||
"nearest",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1193.949206631912,
|
||||
-1604.9259539466343
|
||||
],
|
||||
"size": [
|
||||
282.83333587646484,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.27.0",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.7"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"pasted/image (226).png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
2277.9584226206034,
|
||||
-1602.6605931724057
|
||||
],
|
||||
"size": [
|
||||
355.697905475281,
|
||||
400.93793210196236
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.27.0",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.7"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PixelSnapper",
|
||||
"pos": [
|
||||
1487.9154660931863,
|
||||
-1606.385292004229
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
246
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "palette",
|
||||
"shape": 7,
|
||||
"type": "GAME_PALETTE",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PixelSnapper",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.7"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
256,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
"D:\\ComfyUI\\custom_nodes\\comfyui_pixel_snapper\\spritefusion-pixel-snapper.exe",
|
||||
false
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
6,
|
||||
1,
|
||||
"GAME_PALETTE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": [],
|
||||
"ds": {
|
||||
"scale": 1.228459735736727,
|
||||
"offset": [
|
||||
-1093.949206631912,
|
||||
1706.7501614765035
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.45.20",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 942 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
@@ -0,0 +1,305 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageEnhance
|
||||
|
||||
from .palettes import PALETTE_NAMES, get_palette_rgb
|
||||
|
||||
# The compiled engine lives next to this file, inside the custom node folder, so it
|
||||
# works standalone on any Windows machine. To use your own build, just replace this
|
||||
# .exe in the node folder.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULT_EXE = os.path.join(_HERE, "spritefusion-pixel-snapper.exe")
|
||||
|
||||
|
||||
def tensor_to_pil(img_tensor):
|
||||
arr = (img_tensor.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
|
||||
return Image.fromarray(arr, mode="RGB")
|
||||
|
||||
|
||||
def pil_to_tensor(img):
|
||||
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(arr)
|
||||
|
||||
|
||||
def remap_to_palette(img, rgb_palette, dither):
|
||||
"""Reduce the image to the exact colors of a fixed palette."""
|
||||
pal_img = Image.new("P", (1, 1))
|
||||
flat = []
|
||||
for r, g, b in rgb_palette:
|
||||
flat.extend([r, g, b])
|
||||
flat.extend([0, 0, 0] * (256 - len(rgb_palette)))
|
||||
pal_img.putpalette(flat)
|
||||
dither_mode = Image.FLOYDSTEINBERG if dither else Image.NONE
|
||||
return img.convert("RGB").quantize(
|
||||
palette=pal_img, colors=len(rgb_palette), dither=dither_mode
|
||||
).convert("RGB")
|
||||
|
||||
|
||||
def correct_aspect(img, src_size):
|
||||
"""Fix the 'flattening' from the engine: the edge-walker can emit a slightly
|
||||
different number of cells on X vs Y, distorting the aspect ratio. We trim the
|
||||
over-represented axis (nearest) so the output aspect matches the source."""
|
||||
ow, oh = img.size
|
||||
sw, sh = src_size
|
||||
if min(ow, oh, sw, sh) <= 0:
|
||||
return img
|
||||
src_aspect = sw / sh
|
||||
out_aspect = ow / oh
|
||||
if abs(out_aspect - src_aspect) < 1e-3:
|
||||
return img
|
||||
if out_aspect > src_aspect:
|
||||
new_w, new_h = max(1, round(oh * src_aspect)), oh # too wide -> trim width
|
||||
else:
|
||||
new_w, new_h = ow, max(1, round(ow / src_aspect)) # too tall -> trim height
|
||||
if (new_w, new_h) == (ow, oh):
|
||||
return img
|
||||
return img.resize((new_w, new_h), Image.NEAREST)
|
||||
|
||||
|
||||
def run_snapper(exe_path, pil_in, k):
|
||||
"""Save the image, run the spritefusion-pixel-snapper binary and read the result."""
|
||||
if not (isinstance(exe_path, str) and os.path.isfile(exe_path)):
|
||||
raise FileNotFoundError(
|
||||
f"Pixel Snapper engine not found. Place 'spritefusion-pixel-snapper.exe' "
|
||||
f"in the node folder: {_HERE}"
|
||||
)
|
||||
tmpdir = tempfile.mkdtemp(prefix="pixelsnap_")
|
||||
in_path = os.path.join(tmpdir, "in.png")
|
||||
out_path = os.path.join(tmpdir, "out.png")
|
||||
pil_in.save(in_path)
|
||||
result = subprocess.run(
|
||||
[exe_path, in_path, out_path, str(int(k))],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not os.path.isfile(out_path):
|
||||
raise RuntimeError(
|
||||
f"Pixel Snapper failed (code {result.returncode}).\n"
|
||||
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
out_img = Image.open(out_path).convert("RGB").copy()
|
||||
return out_img
|
||||
|
||||
|
||||
class PixelSnapperNode:
|
||||
"""Calls the compiled engine 'spritefusion-pixel-snapper.exe' which detects the
|
||||
native pixel art grid and reduces the image to k colors. Supports color
|
||||
adjustments (pre) and remapping to a fixed video game palette (post)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"colors_k": ("INT", {"default": 16, "min": 2, "max": 256, "step": 1}),
|
||||
"saturation": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05}),
|
||||
"contrast": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05}),
|
||||
"brightness": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05}),
|
||||
"fix_aspect": ("BOOLEAN", {"default": True}),
|
||||
"keep_output_size": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"palette": ("GAME_PALETTE",),
|
||||
"palette_dither": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "image/pixel_art"
|
||||
|
||||
def run(self, image, colors_k, saturation, contrast, brightness,
|
||||
fix_aspect, keep_output_size, palette=None, palette_dither=False):
|
||||
# If a palette is connected, the k passed to the engine is the palette size.
|
||||
k = len(palette) if palette is not None else colors_k
|
||||
|
||||
out_batch = []
|
||||
target_size = None
|
||||
for i in range(image.shape[0]):
|
||||
img = tensor_to_pil(image[i])
|
||||
orig_size = img.size
|
||||
|
||||
# Color adjustments BEFORE the snap (they affect the engine's k-means).
|
||||
if brightness != 1.0:
|
||||
img = ImageEnhance.Brightness(img).enhance(brightness)
|
||||
if contrast != 1.0:
|
||||
img = ImageEnhance.Contrast(img).enhance(contrast)
|
||||
if saturation != 1.0:
|
||||
img = ImageEnhance.Color(img).enhance(saturation)
|
||||
|
||||
result = run_snapper(DEFAULT_EXE, img, k)
|
||||
|
||||
# Fix the engine's aspect-ratio flattening (edge-walker cell mismatch).
|
||||
if fix_aspect:
|
||||
result = correct_aspect(result, orig_size)
|
||||
|
||||
# Remap to a fixed palette AFTER the snap (exact video game colors).
|
||||
if palette is not None:
|
||||
result = remap_to_palette(result, palette, palette_dither)
|
||||
|
||||
if keep_output_size:
|
||||
result = result.resize(orig_size, Image.NEAREST)
|
||||
|
||||
# Ensure consistent size across the batch (torch.stack requires it).
|
||||
if target_size is None:
|
||||
target_size = result.size
|
||||
elif result.size != target_size:
|
||||
result = result.resize(target_size, Image.NEAREST)
|
||||
|
||||
out_batch.append(pil_to_tensor(result))
|
||||
|
||||
return (torch.stack(out_batch),)
|
||||
|
||||
|
||||
class VideoGamePaletteNode:
|
||||
"""Provides a classic video game color palette for the Pixel Snapper."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"palette_name": (PALETTE_NAMES, {"default": PALETTE_NAMES[0]}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("GAME_PALETTE",)
|
||||
RETURN_NAMES = ("palette",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "image/pixel_art"
|
||||
|
||||
def run(self, palette_name):
|
||||
return (get_palette_rgb(palette_name),)
|
||||
|
||||
|
||||
RESAMPLE = {
|
||||
"nearest": Image.NEAREST,
|
||||
"box": Image.BOX,
|
||||
"bilinear": Image.BILINEAR,
|
||||
"hamming": Image.HAMMING,
|
||||
"bicubic": Image.BICUBIC,
|
||||
"lanczos": Image.LANCZOS,
|
||||
}
|
||||
|
||||
|
||||
class PixelArtResizerNode:
|
||||
"""Resizes pixel art. To scale up without blurring use 'nearest'; to shrink a
|
||||
large image down to pixel art resolution use 'box' or 'nearest'."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"mode": (["scale_factor", "target_size"], {"default": "scale_factor"}),
|
||||
"scale_factor": ("FLOAT", {"default": 2.0, "min": 0.05, "max": 64.0, "step": 0.05}),
|
||||
"width": ("INT", {"default": 128, "min": 1, "max": 8192, "step": 1}),
|
||||
"height": ("INT", {"default": 128, "min": 1, "max": 8192, "step": 1}),
|
||||
"interpolation": (list(RESAMPLE.keys()), {"default": "nearest"}),
|
||||
"keep_aspect": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "image/pixel_art"
|
||||
|
||||
def run(self, image, mode, scale_factor, width, height, interpolation, keep_aspect):
|
||||
filt = RESAMPLE[interpolation]
|
||||
out_batch = []
|
||||
target = None
|
||||
for i in range(image.shape[0]):
|
||||
img = tensor_to_pil(image[i])
|
||||
w, h = img.size
|
||||
|
||||
if mode == "scale_factor":
|
||||
nw = max(1, round(w * scale_factor))
|
||||
nh = max(1, round(h * scale_factor))
|
||||
else:
|
||||
if keep_aspect:
|
||||
ratio = min(width / w, height / h)
|
||||
nw = max(1, round(w * ratio))
|
||||
nh = max(1, round(h * ratio))
|
||||
else:
|
||||
nw, nh = int(width), int(height)
|
||||
|
||||
result = img.resize((nw, nh), filt)
|
||||
|
||||
# Batch needs a consistent size for torch.stack.
|
||||
if target is None:
|
||||
target = result.size
|
||||
elif result.size != target:
|
||||
result = result.resize(target, filt)
|
||||
|
||||
out_batch.append(pil_to_tensor(result))
|
||||
|
||||
return (torch.stack(out_batch),)
|
||||
|
||||
|
||||
class PixelArtPreviewNode:
|
||||
"""Pixel art preview with crisp rendering (nearest-neighbor) and an interactive
|
||||
magnifier. Hover over the image to zoom in. Output node."""
|
||||
|
||||
def __init__(self):
|
||||
self._prefix = "pixelart_preview"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"magnifier": ("BOOLEAN", {"default": True}),
|
||||
"zoom": ("INT", {"default": 6, "min": 2, "max": 32, "step": 1}),
|
||||
"lens_size": ("INT", {"default": 140, "min": 40, "max": 400, "step": 10}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "preview"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "image/pixel_art"
|
||||
|
||||
def preview(self, images, magnifier, zoom, lens_size, prompt=None, extra_pnginfo=None):
|
||||
import folder_paths
|
||||
|
||||
output_dir = folder_paths.get_temp_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||||
self._prefix, output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
results = []
|
||||
for image in images:
|
||||
arr = (255.0 * image.cpu().numpy()).clip(0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(arr)
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
img.save(os.path.join(full_output_folder, file), compress_level=1)
|
||||
results.append({"filename": file, "subfolder": subfolder, "type": "temp"})
|
||||
counter += 1
|
||||
|
||||
return {
|
||||
"ui": {
|
||||
"images": results,
|
||||
"magnifier": [magnifier],
|
||||
"zoom": [zoom],
|
||||
"lens_size": [lens_size],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PixelSnapper": PixelSnapperNode,
|
||||
"VideoGamePalette": VideoGamePaletteNode,
|
||||
"PixelArtResizer": PixelArtResizerNode,
|
||||
"PixelArtPreview": PixelArtPreviewNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PixelSnapper": "Pixel Snapper (Engine)",
|
||||
"VideoGamePalette": "Video Game Palette",
|
||||
"PixelArtResizer": "Pixel Art Resizer",
|
||||
"PixelArtPreview": "Pixel Art Preview",
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
"""Built-in video game color palettes (hex), used by the VideoGamePalette node."""
|
||||
|
||||
PALETTES = {
|
||||
"Game Boy (DMG Green)": [
|
||||
"0f380f", "306230", "8bac0f", "9bbc0f",
|
||||
],
|
||||
"Game Boy Pocket (Gray)": [
|
||||
"000000", "555555", "aaaaaa", "ffffff",
|
||||
],
|
||||
"NES (Full 54)": [
|
||||
"7c7c7c", "0000fc", "0000bc", "4428bc", "940084", "a80020", "a81000", "881400",
|
||||
"503000", "007800", "006800", "005800", "004058", "000000", "000000", "000000",
|
||||
"bcbcbc", "0078f8", "0058f8", "6844fc", "d800cc", "e40058", "f83800", "e45c10",
|
||||
"ac7c00", "00b800", "00a800", "00a844", "008888", "000000", "000000", "000000",
|
||||
"f8f8f8", "3cbcfc", "6888fc", "9878f8", "f878f8", "f85898", "f87858", "fca044",
|
||||
"f8b800", "b8f818", "58d854", "58f898", "00e8d8", "787878", "000000", "000000",
|
||||
"fcfcfc", "a4e4fc", "b8b8f8", "d8b8f8", "f8b8f8", "f8a4c0", "f0d0b0", "fce0a8",
|
||||
"f8d878", "d8f878", "b8f8b8", "b8f8d8", "00fcfc", "f8d8f8", "000000", "000000",
|
||||
],
|
||||
"PICO-8": [
|
||||
"000000", "1D2B53", "7E2553", "008751", "AB5236", "5F574F", "C2C3C7", "FFF1E8",
|
||||
"FF004D", "FFA300", "FFEC27", "00E436", "29ADFF", "83769C", "FF77A8", "FFCCAA",
|
||||
],
|
||||
"CGA (Palette 1 High)": [
|
||||
"000000", "55ffff", "ff55ff", "ffffff",
|
||||
],
|
||||
"CGA / EGA 16": [
|
||||
"000000", "0000aa", "00aa00", "00aaaa", "aa0000", "aa00aa", "aa5500", "aaaaaa",
|
||||
"555555", "5555ff", "55ff55", "55ffff", "ff5555", "ff55ff", "ffff55", "ffffff",
|
||||
],
|
||||
"Sweetie 16": [
|
||||
"1a1c2c", "5d275d", "b13e53", "ef7d57", "ffcd75", "a7f070", "38b764", "257179",
|
||||
"29366f", "3b5dc9", "41a6f6", "73eff7", "f4f4f4", "94b0c2", "566c86", "333c57",
|
||||
],
|
||||
"DawnBringer 16": [
|
||||
"140c1c", "442434", "30346d", "4e4a4e", "854c30", "346524", "d04648", "757161",
|
||||
"597dce", "d27d2c", "8595a1", "6daa2c", "d2aa99", "6dc2ca", "dad45e", "deeed6",
|
||||
],
|
||||
"DawnBringer 32": [
|
||||
"000000", "222034", "45283c", "663931", "8f563b", "df7126", "d9a066", "eec39a",
|
||||
"fbf236", "99e550", "6abe30", "37946e", "4b692f", "524b24", "323c39", "3f3f74",
|
||||
"306082", "5b6ee1", "639bff", "5fcde4", "cbdbfc", "ffffff", "9badb7", "847e87",
|
||||
"696a6a", "595652", "76428a", "ac3232", "d95763", "d77bba", "8f974a", "8a6f30",
|
||||
],
|
||||
"Commodore 64": [
|
||||
"000000", "ffffff", "880000", "aaffee", "cc44cc", "00cc55", "0000aa", "eeee77",
|
||||
"dd8855", "664400", "ff7777", "333333", "777777", "aaff66", "0088ff", "bbbbbb",
|
||||
],
|
||||
"ZX Spectrum": [
|
||||
"000000", "0000d7", "d70000", "d700d7", "00d700", "00d7d7", "d7d700", "d7d7d7",
|
||||
"0000ff", "ff0000", "ff00ff", "00ff00", "00ffff", "ffff00", "ffffff",
|
||||
],
|
||||
"Endesga 32": [
|
||||
"be4a2f", "d77643", "ead4aa", "e4a672", "b86f50", "733e39", "3e2731", "a22633",
|
||||
"e43b44", "f77622", "feae34", "fee761", "63c74d", "3e8948", "265c42", "193c3e",
|
||||
"124e89", "0099db", "2ce8f5", "ffffff", "c0cbdc", "8b9bb4", "5a6988", "3a4466",
|
||||
"262b44", "181425", "ff0044", "68386c", "b55088", "f6757a", "e8b796", "c28569",
|
||||
],
|
||||
"Resurrect 64": [
|
||||
"2e222f", "3e3546", "625565", "966c6c", "ab947a", "694f62", "7f708a", "9babb2",
|
||||
"c7dcd0", "ffffff", "6e2727", "b33831", "ea4f36", "f57d4a", "ae2334", "e83b3b",
|
||||
"fb6b1d", "f79617", "f9c22b", "7a3045", "9e4539", "cd683d", "e6904e", "fbb954",
|
||||
"4c3e24", "676633", "a2a947", "d5e04b", "fbff86", "165a4c", "239063", "1ebc73",
|
||||
"91db69", "cddf6c", "313638", "374e4a", "547e64", "92a984", "b2ba90", "0b5e65",
|
||||
"0b8a8f", "0eaf9b", "30e1b9", "8ff8e2", "323353", "484a77", "4d65b4", "4d9be6",
|
||||
"8fd3ff", "45293f", "6b3e75", "905ea9", "a884f3", "eaaded", "753c54", "a24b6f",
|
||||
"cf657f", "ed8099", "831c5d", "c32454", "f04f78", "f68181", "fca790", "fdcbb0",
|
||||
],
|
||||
"SLSO8": [
|
||||
"0d2b45", "203c56", "544e68", "8d697a", "d08159", "ffaa5e", "ffd4a3", "ffecd6",
|
||||
],
|
||||
"Oil 6": [
|
||||
"fbf5ef", "f2d3ab", "c69fa5", "8b6d9c", "494d7e", "272744",
|
||||
],
|
||||
"1-Bit Monitor Glow": [
|
||||
"222323", "f0f6f0",
|
||||
],
|
||||
"2-Bit Demichrome": [
|
||||
"211e20", "555568", "a0a08b", "e9efec",
|
||||
],
|
||||
"Ice Cream GB": [
|
||||
"7c3f58", "eb6b6f", "f9a875", "fff6d3",
|
||||
],
|
||||
"Kirokaze Gameboy": [
|
||||
"332c50", "46878f", "94e344", "e2f3e4",
|
||||
],
|
||||
"Rustic GB": [
|
||||
"2c2137", "764462", "a96868", "edb4a1",
|
||||
],
|
||||
"Mist GB": [
|
||||
"2d1b00", "1e606e", "5ab9a8", "c4f0c2",
|
||||
],
|
||||
}
|
||||
|
||||
PALETTE_NAMES = list(PALETTES.keys())
|
||||
|
||||
|
||||
def hex_to_rgb(h):
|
||||
h = h.lstrip("#")
|
||||
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def get_palette_rgb(name):
|
||||
return [hex_to_rgb(h) for h in PALETTES[name]]
|
||||
Binary file not shown.
@@ -0,0 +1,166 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
function viewURL(img) {
|
||||
const p = new URLSearchParams();
|
||||
p.set("filename", img.filename);
|
||||
p.set("type", img.type || "temp");
|
||||
p.set("subfolder", img.subfolder || "");
|
||||
p.set("t", "" + (+new Date()));
|
||||
return api.apiURL(`/view?${p.toString()}`);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "PixelArt.PreviewMagnifier",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "PixelArtPreview") return;
|
||||
|
||||
// Remove the blurry preview the core draws; we use our own crisp widget.
|
||||
nodeType.prototype.onDrawBackground = function () {};
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
const self = this;
|
||||
|
||||
const container = document.createElement("div");
|
||||
Object.assign(container.style, {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "120px",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#111",
|
||||
borderRadius: "6px",
|
||||
cursor: "crosshair",
|
||||
});
|
||||
|
||||
const img = document.createElement("img");
|
||||
Object.assign(img.style, {
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
imageRendering: "pixelated",
|
||||
display: "none",
|
||||
});
|
||||
container.appendChild(img);
|
||||
|
||||
const hint = document.createElement("div");
|
||||
hint.textContent = "no image — run the workflow";
|
||||
Object.assign(hint.style, {
|
||||
color: "#666",
|
||||
fontSize: "11px",
|
||||
fontFamily: "monospace",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
container.appendChild(hint);
|
||||
|
||||
const lens = document.createElement("div");
|
||||
Object.assign(lens.style, {
|
||||
position: "absolute",
|
||||
boxSizing: "border-box",
|
||||
border: "2px solid #fff",
|
||||
borderRadius: "50%",
|
||||
boxShadow: "0 0 0 1px #000, 0 2px 10px rgba(0,0,0,.7)",
|
||||
pointerEvents: "none",
|
||||
display: "none",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "border-box",
|
||||
imageRendering: "pixelated",
|
||||
zIndex: "10",
|
||||
});
|
||||
container.appendChild(lens);
|
||||
|
||||
const getW = (name, def) => {
|
||||
const w = self.widgets && self.widgets.find((x) => x.name === name);
|
||||
return w ? w.value : def;
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
if (img.style.display === "none" || !getW("magnifier", true)) {
|
||||
lens.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const rect = img.getBoundingClientRect(); // screen space (graph zoom applied)
|
||||
const iw = img.offsetWidth || rect.width; // local (untransformed) px
|
||||
const ih = img.offsetHeight || rect.height;
|
||||
// Screen->local scale from the ComfyUI graph transform.
|
||||
const scale = rect.width / iw || 1;
|
||||
|
||||
// Mouse position over the image, in the image's LOCAL pixels.
|
||||
const xLocal = (e.clientX - rect.left) / scale;
|
||||
const yLocal = (e.clientY - rect.top) / scale;
|
||||
if (xLocal < 0 || yLocal < 0 || xLocal > iw || yLocal > ih) {
|
||||
lens.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const zoom = getW("zoom", 6);
|
||||
const lensSize = getW("lens_size", 140);
|
||||
const crect = container.getBoundingClientRect();
|
||||
// Mouse position inside the container, also in LOCAL pixels.
|
||||
const cxLocal = (e.clientX - crect.left) / scale;
|
||||
const cyLocal = (e.clientY - crect.top) / scale;
|
||||
|
||||
lens.style.display = "block";
|
||||
lens.style.width = lensSize + "px";
|
||||
lens.style.height = lensSize + "px";
|
||||
// Center the lens exactly on the cursor.
|
||||
lens.style.left = (cxLocal - lensSize / 2) + "px";
|
||||
lens.style.top = (cyLocal - lensSize / 2) + "px";
|
||||
lens.style.backgroundImage = `url("${img.src}")`;
|
||||
// Background is the image blown up in LOCAL px; put the pixel under
|
||||
// the cursor at the lens center.
|
||||
lens.style.backgroundSize = `${iw * zoom}px ${ih * zoom}px`;
|
||||
lens.style.backgroundPosition =
|
||||
`${lensSize / 2 - xLocal * zoom}px ${lensSize / 2 - yLocal * zoom}px`;
|
||||
};
|
||||
|
||||
container.addEventListener("mousemove", onMove);
|
||||
container.addEventListener("mouseleave", () => {
|
||||
lens.style.display = "none";
|
||||
});
|
||||
|
||||
img.onload = () => {
|
||||
img.style.display = "block";
|
||||
hint.style.display = "none";
|
||||
self._paAspect = (img.naturalHeight && img.naturalWidth)
|
||||
? img.naturalHeight / img.naturalWidth
|
||||
: 0.75;
|
||||
// Resize the node once to fit the image (no feedback loop).
|
||||
const sz = self.computeSize();
|
||||
self.setSize([self.size[0], sz[1]]);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
this._paImg = img;
|
||||
this._paAspect = 0.75;
|
||||
const widget = this.addDOMWidget("pixelart_preview", "preview", container, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
});
|
||||
// Height depends ONLY on width + image aspect ratio, never on the node
|
||||
// height -> avoids the infinite vertical growth feedback loop.
|
||||
widget.computeSize = function (width) {
|
||||
const w = width || (self.size ? self.size[0] : 280);
|
||||
let h = Math.round(w * (self._paAspect || 0.75));
|
||||
h = Math.max(120, Math.min(h, 1024));
|
||||
return [width, h];
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted && onExecuted.apply(this, arguments);
|
||||
const imgs = message && message.images;
|
||||
if (imgs && imgs.length && this._paImg) {
|
||||
this._paImg.src = viewURL(imgs[imgs.length - 1]);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user