feat(gamedev): ronda 1 — pixelize + luma→alpha + export-godot (grupo gamedev)

Tres funciones CPU-only del lote gamedev 2D + 2 helpers puros + grupo de capacidad:

- comfyui_pixelize_image_py_ml (impure): Fase 2 pixelart — downscale nearest +
  cuantizacion a N colores / paleta fija (game-boy/pico-8/nes) + re-upscale nearest.
- comfyui_matting_luma_to_alpha_py_ml (impure): frame VFX sobre negro -> RGBA por
  luminancia ponderada (translucidos con additive blend).
- comfyui_export_asset_to_godot_py_pipelines (impure): puente ComfyUI -> Godot 4 —
  copia a res://assets/<dir> por kind + .import por tipo + filtro Nearest si pixelart
  + reimport headless best-effort. Compone los 2 helpers puros.
- godot_map_asset_dir_py_core, godot_clean_asset_name_py_core (pure): nucleos
  reutilizables del pipeline.
- docs/capabilities/gamedev-2d.md + INDEX: grupo nuevo gamedev.

Tests 33/33 verdes (offline PIL/numpy). Golden real verificado: asset de
~/ComfyUI/output -> /tmp/godot_test_proj con .import correcto y reimport headless
real de Godot 4.7. Sin GPU, sin red, sin tocar proyectos del usuario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 19:43:47 +02:00
parent 9508fff282
commit e57da2f6d5
17 changed files with 1529 additions and 0 deletions
@@ -0,0 +1,75 @@
---
name: comfyui_matting_luma_to_alpha
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_matting_luma_to_alpha(image_path: str, *, out_path: str | None = None, gamma: float = 1.0, black_point: float = 0.0, premultiply: bool = False, luma_weights: tuple = (0.299, 0.587, 0.114)) -> dict"
description: "Convierte un frame de VFX (humo, fuego, destello, magia) generado sobre fondo negro en RGBA usando la LUMINANCIA ponderada como canal alpha (brillante -> opaco, negro -> transparente). Tecnica gamedev correcta para translucidos con additive blend: preserva todo el falloff, a diferencia de un matting binario (rembg) que aplana el gradiente. Nucleo numpy puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, error}. Impura solo por la lectura/escritura de disco."
tags: [comfyui, gamedev, vfx, matting, alpha, luminance, ml, pil, numpy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: image_path
desc: "ruta del frame de entrada (generado sobre fondo negro)."
- name: out_path
desc: "ruta del PNG RGBA de salida; si None se escribe junto al original con sufijo '_rgba.png'. keyword-only."
- name: gamma
desc: "ajuste del falloff del alpha (>1 sube alpha de medios tonos, <1 lo baja). Por defecto 1.0 (lineal). keyword-only."
- name: black_point
desc: "piso [0,1) que limpia gris de fondo residual sin matar el efecto (re-normaliza el rango restante). Por defecto 0.0. keyword-only."
- name: premultiply
desc: "si True exporta RGB*alpha (materiales premultiplied / additive del motor). keyword-only."
- name: luma_weights
desc: "pesos (r,g,b) de la luminancia. Rec.601 (0.299,0.587,0.114) por defecto; Rec.709 = (0.2126,0.7152,0.0722). keyword-only."
output: "dict con ok (bool), out_path (str, ruta del PNG RGBA), size ([w,h]), error (str, vacio si OK)."
tested: true
tests: [test_golden_radial_glow, test_edge_all_black_is_transparent, test_edge_preserves_color, test_edge_input_rgba_recomputes_from_luma, test_edge_gamma_raises_midtone_alpha, test_edge_premultiply, test_error_missing_path]
test_file_path: "python/functions/ml/comfyui_matting_luma_to_alpha_test.py"
file_path: "python/functions/ml/comfyui_matting_luma_to_alpha.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha
# Frame de humo generado sobre negro -> RGBA listo para additive blend en Godot/Unity
res = comfyui_matting_luma_to_alpha(
os.path.expanduser("~/ComfyUI/output/vfx_loop_00007_.png"),
out_path="/tmp/smoke_0007_rgba.png",
gamma=1.2, # realza la cola tenue del humo
black_point=0.04, # limpia el gris residual del fondo
)
# {'ok': True, 'out_path': '/tmp/smoke_0007_rgba.png', 'size': [512, 512], 'error': ''}
```
## Cuando usarla
Tras generar frames de un efecto (humo/fuego/magia/explosion) **sobre fondo negro**
(prompt "on pure black background"), antes de montar el spritesheet o exportarlo a
un motor. Es el recorte a alpha correcto para translucidos. NO uses rembg / matting
binario para humo/fuego: rompe el degradado. Para sprites SOLIDOS (personaje, item)
con silueta definida, rembg sigue siendo lo adecuado, no esta funcion.
## Gotchas
- **El frame debe estar sobre NEGRO**: el alpha sale de la luminancia, asi que un
fondo no-negro se vuelve parcialmente opaco. Genera con "on pure black background".
- Ignora cualquier alpha de la imagen de entrada: **recomputa** alpha desde la luma
del RGB (un input RGBA con alpha previo se sobreescribe).
- `black_point` limpia el gris de fondo si el modelo no dio negro puro; subirlo
demasiado come las zonas tenues del efecto.
- `premultiply=True` para materiales additive/premultiplied; deja `False` si el
motor espera alpha straight.
- Todo error es **dict `ok=False`** (no excepcion): path inexistente, `gamma<=0`,
`black_point` fuera de [0,1) -> `error` explica.
- CPU-only (numpy): no toca la GPU ni el servidor ComfyUI. El nodo `ImageToMask` de
ComfyUI NO sirve para esto (hace channel-pick, no luminancia ponderada).
@@ -0,0 +1,139 @@
"""comfyui_matting_luma_to_alpha — frame VFX sobre negro -> RGBA por luminancia.
Convierte un frame de efecto (humo, fuego, destello, magia) generado sobre fondo
negro en una imagen RGBA donde el canal alpha es la LUMINANCIA del pixel: brillante
-> opaco, negro -> transparente. Es la tecnica gamedev correcta para translucidos:
en el motor se compone con additive blend y el solape brilla de forma natural,
preservando todo el falloff (cosa que un matting binario tipo rembg destruye).
Por que en Python y no con nodos ComfyUI: el `ImageToMask` del server hace
channel-pick (extrae un canal R/G/B/A), NO la luminancia ponderada perceptual
0.299R + 0.587G + 0.114B. La luma correcta es trivial en numpy. Ademas mantiene el
matting fuera del server: cero VRAM, headless, determinista, testeable.
El nucleo `_luma_to_rgba` es puro (numpy); la funcion publica es impura solo por la
lectura/escritura de disco.
"""
import os
def _luma_to_rgba(rgb01, *, gamma: float, black_point: float,
premultiply: bool, weights):
"""Nucleo puro: HxWx3 float [0,1] (efecto sobre negro) -> HxWx4 float [0,1].
Args:
rgb01: numpy array HxWx3 en [0,1].
gamma: realza (>1) o atenua (<1) el alpha en los medios tonos.
black_point: piso [0,1) que limpia el gris residual de fondo antes de
mapear a alpha (re-normaliza el rango restante a [0,1]).
premultiply: si True multiplica el RGB de salida por el alpha (materiales
premultiplied / additive del motor).
weights: pesos de luminancia (r, g, b). Rec.601 (0.299,0.587,0.114) por
defecto; Rec.709 = (0.2126,0.7152,0.0722).
Returns:
numpy array HxWx4 en [0,1].
"""
import numpy as np
w = np.asarray(weights, dtype=np.float32)
luma = rgb01 @ w # HxW, luminancia ponderada
if black_point > 0.0:
luma = np.clip((luma - black_point) / (1.0 - black_point), 0.0, 1.0)
if gamma != 1.0:
luma = np.power(np.clip(luma, 0.0, 1.0), 1.0 / gamma)
alpha = np.clip(luma, 0.0, 1.0)
rgb_out = rgb01 * alpha[..., None] if premultiply else rgb01
return np.dstack([rgb_out, alpha])
def comfyui_matting_luma_to_alpha(
image_path: str,
*,
out_path: str | None = None,
gamma: float = 1.0,
black_point: float = 0.0,
premultiply: bool = False,
luma_weights: tuple = (0.299, 0.587, 0.114),
) -> dict:
"""Convierte un frame sobre negro a RGBA usando la luminancia como alpha.
Args:
image_path: ruta del frame de entrada (generado sobre fondo negro).
out_path: ruta del PNG RGBA de salida; si None se escribe junto al
original con sufijo "_rgba.png". keyword-only.
gamma: ajuste del falloff del alpha (>1 sube alpha de medios tonos,
<1 lo baja). Por defecto 1.0 (lineal). keyword-only.
black_point: piso [0,1) para limpiar gris de fondo residual sin matar el
efecto. Por defecto 0.0. keyword-only.
premultiply: exporta RGB*alpha si True (additive/premultiplied del motor).
keyword-only.
luma_weights: pesos (r,g,b) de la luminancia. keyword-only.
Returns:
dict con:
- ok (bool): True si se convirtio y guardo.
- out_path (str): ruta del PNG RGBA generado.
- size (list[int]): [w, h] de la imagen.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {"ok": False, "out_path": "", "size": [0, 0], "error": ""}
try:
import numpy as np
from PIL import Image
except ImportError as exc:
out["error"] = f"falta dependencia (PIL/numpy): {exc}"
return out
if not os.path.isfile(image_path):
out["error"] = f"image_path no existe: {image_path!r}"
return out
if not (0.0 <= float(black_point) < 1.0):
out["error"] = f"black_point debe estar en [0,1), recibido {black_point!r}"
return out
if float(gamma) <= 0.0:
out["error"] = f"gamma debe ser > 0, recibido {gamma!r}"
return out
try:
with Image.open(image_path) as src:
# Ignora cualquier alpha de entrada: recomputa desde la luma del RGB.
rgb = np.asarray(src.convert("RGB"), dtype=np.float32) / 255.0
except OSError as exc:
out["error"] = f"no se pudo leer/decodificar {image_path!r}: {exc}"
return out
rgba01 = _luma_to_rgba(
rgb, gamma=float(gamma), black_point=float(black_point),
premultiply=bool(premultiply), weights=luma_weights,
)
rgba8 = (np.clip(rgba01, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
result = Image.fromarray(rgba8, mode="RGBA")
if out_path is None:
base, _ = os.path.splitext(image_path)
out_path = base + "_rgba.png"
try:
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
result.save(out_path)
except OSError as exc:
out["error"] = f"no se pudo escribir {out_path!r}: {exc}"
return out
out.update(ok=True, out_path=out_path, size=list(result.size))
return out
if __name__ == "__main__":
import json
import sys
if len(sys.argv) < 2:
print("uso: comfyui_matting_luma_to_alpha.py <frame_sobre_negro> [out]",
file=sys.stderr)
sys.exit(2)
src = sys.argv[1]
dst = sys.argv[2] if len(sys.argv) > 2 else None
print(json.dumps(comfyui_matting_luma_to_alpha(src, out_path=dst), indent=2))
@@ -0,0 +1,90 @@
"""Tests de comfyui_matting_luma_to_alpha (offline, sin red ni GPU; PIL/numpy)."""
import os
import sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha # noqa: E402
def _radial_glow_png(path, size=64):
"""Degradado radial blanco (centro brillante) sobre negro."""
yy, xx = np.mgrid[0:size, 0:size]
cx = cy = (size - 1) / 2.0
dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
val = np.clip(1.0 - dist / (size / 2.0), 0.0, 1.0)
val = val / val.max() # normaliza el pico a 1.0 (centro = blanco puro)
arr = (val[..., None] * 255).astype(np.uint8).repeat(3, axis=2)
Image.fromarray(arr, "RGB").save(path)
return path
def test_golden_radial_glow(tmp_path):
src = _radial_glow_png(str(tmp_path / "glow.png"))
res = comfyui_matting_luma_to_alpha(src)
assert res["ok"] is True, res["error"]
img = Image.open(res["out_path"])
assert img.mode == "RGBA"
a = np.asarray(img)[..., 3]
assert a[32, 32] >= 250 # centro brillante -> opaco
assert a[0, 0] <= 5 # esquina negra -> transparente
def test_edge_all_black_is_transparent(tmp_path):
src = str(tmp_path / "black.png")
Image.fromarray(np.zeros((32, 32, 3), np.uint8), "RGB").save(src)
res = comfyui_matting_luma_to_alpha(src)
assert res["ok"] is True # efecto invisible, NO error
a = np.asarray(Image.open(res["out_path"]))[..., 3]
assert int(a.max()) == 0 # alpha todo 0
def test_edge_preserves_color(tmp_path):
"""Un frame rojo brillante conserva su RGB y gana alpha por su luma."""
src = str(tmp_path / "red.png")
Image.fromarray(np.full((16, 16, 3), [255, 0, 0], np.uint8), "RGB").save(src)
res = comfyui_matting_luma_to_alpha(src)
rgba = np.asarray(Image.open(res["out_path"]))
assert tuple(rgba[8, 8, :3]) == (255, 0, 0) # color preservado (sin premultiply)
assert rgba[8, 8, 3] > 0 # alpha desde luma del rojo
def test_edge_input_rgba_recomputes_from_luma(tmp_path):
"""Input ya RGBA con alpha=0: se ignora y se recomputa alpha desde la luma."""
src = str(tmp_path / "rgba_in.png")
arr = np.zeros((16, 16, 4), np.uint8)
arr[..., :3] = 255 # blanco
arr[..., 3] = 0 # alpha previo nulo (debe ignorarse)
Image.fromarray(arr, "RGBA").save(src)
res = comfyui_matting_luma_to_alpha(src)
a = np.asarray(Image.open(res["out_path"]))[..., 3]
assert int(a.min()) >= 250 # alpha recomputado desde RGB blanco
def test_edge_gamma_raises_midtone_alpha(tmp_path):
"""gamma>1 sube el alpha de los medios tonos respecto a gamma=1."""
src = str(tmp_path / "mid.png")
Image.fromarray(np.full((16, 16, 3), 128, np.uint8), "RGB").save(src)
base = comfyui_matting_luma_to_alpha(src, out_path=str(tmp_path / "g1.png"), gamma=1.0)
hi = comfyui_matting_luma_to_alpha(src, out_path=str(tmp_path / "g2.png"), gamma=2.0)
a1 = int(np.asarray(Image.open(base["out_path"]))[8, 8, 3])
a2 = int(np.asarray(Image.open(hi["out_path"]))[8, 8, 3])
assert a2 > a1
def test_edge_premultiply(tmp_path):
"""premultiply multiplica el RGB por el alpha (medio tono -> RGB reducido)."""
src = str(tmp_path / "mid.png")
Image.fromarray(np.full((16, 16, 3), 128, np.uint8), "RGB").save(src)
res = comfyui_matting_luma_to_alpha(src, premultiply=True)
rgba = np.asarray(Image.open(res["out_path"]))[8, 8]
assert rgba[0] < 128 # RGB premultiplicado por alpha (<1)
def test_error_missing_path(tmp_path):
res = comfyui_matting_luma_to_alpha(str(tmp_path / "nope.png"))
assert res["ok"] is False
assert "no existe" in res["error"]
@@ -0,0 +1,82 @@
---
name: comfyui_pixelize_image
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True) -> dict"
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, error}. Impura solo por la lectura/escritura de disco."
tags: [comfyui, gamedev, pixelart, ml, pil, quantize, palette, image]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: src_path
desc: "ruta de la imagen de entrada (PNG/JPG/...)."
- name: dst_path
desc: "ruta del PNG de salida (se crea el directorio si falta)."
- name: downscale
desc: "factor entero de reduccion nearest (>=1); cada bloque downscale x downscale px colapsa a 1 pixel. 1 = solo cuantiza sin colapsar el grid. keyword-only."
- name: colors
desc: "numero de colores objetivo (2..256) cuando palette es None; cuantizacion MEDIANCUT determinista. keyword-only."
- name: palette
desc: "None (auto a 'colors'), nombre de paleta fija builtin ('game-boy','pico-8','nes') o lista de hex ('#rrggbb'/'rrggbb'). Una paleta fija ignora 'colors'. keyword-only."
- name: dither
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
- name: upscale_back
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores distintos del resultado), error (str, vacio si OK)."
tested: true
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette]
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
file_path: "python/functions/ml/comfyui_pixelize_image.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_pixelize_image import comfyui_pixelize_image
# Crudo SDXL+pixel-art-xl 1024x1024 -> pixelart 16 colores, grid de 128
res = comfyui_pixelize_image(
os.path.expanduser("~/ComfyUI/output/pixelart_00001_.png"),
"/tmp/hero_pixel.png",
downscale=8, colors=16,
)
# {'ok': True, 'out_path': '/tmp/hero_pixel.png', 'size': [1024, 1024], 'n_colors_final': 16, 'error': ''}
# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale)
comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png",
palette="game-boy", upscale_back=False)
```
## Cuando usarla
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `pixel-art-xl`),
para colapsar el grid borroso a pixeles duros y limitar la paleta. Tambien sirve
para "pixelizar" cualquier imagen (sprite, render, foto) a estetica retro sin
tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
## Gotchas
- **nearest, no lanczos**: el downscale usa NEAREST a proposito; interpolar suave
re-difumina el grid. No lo cambies por "calidad".
- `palette` fija (game-boy/pico-8/nes o lista de hex) **ignora** `colors`. La
paleta se rellena internamente repitiendo su ultimo color para que `quantize`
no introduzca un negro extra por entradas vacias (bug arreglado en v1.0.0).
- `downscale` con `upscale_back=False` deja la imagen de `w//downscale x h//downscale`:
util para spritesheets compactos; con `True` vuelve al tamano original con bordes
duros (preview).
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
- `n_colors_final` cuenta colores distintos reales del PNG escrito; con paleta fija
puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
con Pillow.
@@ -0,0 +1,206 @@
"""comfyui_pixelize_image — post-proceso pixel-perfect de una imagen (Fase 2 pixelart).
Convierte una imagen "pixelart borroso de IA" (o cualquier PNG) en pixelart de
verdad: downscale nearest-neighbor por factor (colapsa cada bloque borroso a un
pixel duro) -> cuantizacion a N colores o a una paleta fija (NES / Game Boy /
PICO-8) -> opcional re-upscale nearest conservando los pixeles duros.
Es la Fase 2 del pipeline pixelart (la Fase 1, generar con SDXL + pixel-art-xl
LoRA, vive en otra funcion). Determinista y CPU-only: el nucleo `_pixelize_pil`
es puro (PIL), no toca la GPU ni la red. La funcion publica es impura solo por la
lectura/escritura de disco (mismo patron que comfyui_build_grid).
Por que nearest y no lanczos/cubic: el downscale tiene que colapsar cada bloque
borroso a UN pixel; cualquier interpolacion suave re-difumina el grid. La
cuantizacion (Image.quantize) limita la paleta, que es lo que da la identidad
retro y elimina el ruido de cientos de colores de la difusion.
"""
import os
# Paletas retro fijas (hex sin '#'). Embebidas: cero red, deterministas.
# Fuentes: lospec.com (game-boy, pico-8) + paleta NES clasica reducida.
_BUILTIN_PALETTES = {
"game-boy": ["0f380f", "306230", "8bac0f", "9bbc0f"],
"pico-8": [
"000000", "1d2b53", "7e2553", "008751", "ab5236", "5f574f",
"c2c3c7", "fff1e8", "ff004d", "ffa300", "ffec27", "00e436",
"29adff", "83769c", "ff77a8", "ffccaa",
],
# NES: subconjunto representativo de 16 colores de la paleta 2C02.
"nes": [
"000000", "fcfcfc", "f8f8f8", "bcbcbc", "7c7c7c", "0000fc",
"0078f8", "00b800", "b8f818", "f83800", "e40058", "f878f8",
"fca044", "f8b800", "503000", "00a800",
],
}
def _hex_to_rgb(h: str) -> tuple:
"""'1a2b3c' o '#1a2b3c' -> (26, 43, 60)."""
h = h.strip().lstrip("#")
if len(h) != 6:
raise ValueError(f"hex de color invalido: {h!r} (esperado rrggbb)")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def _normalize_palette(palette):
"""palette: None | nombre builtin (str) | lista de hex -> list[(r,g,b)] | None."""
if palette is None:
return None
if isinstance(palette, str):
key = palette.strip().lower().replace("_", "-")
if key not in _BUILTIN_PALETTES:
raise ValueError(
f"paleta builtin desconocida: {palette!r}. "
f"Disponibles: {sorted(_BUILTIN_PALETTES)} o pasa una lista de hex."
)
hexes = _BUILTIN_PALETTES[key]
else:
hexes = list(palette)
if not hexes:
raise ValueError("lista de paleta vacia")
return [_hex_to_rgb(h) for h in hexes]
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
"""Nucleo puro PIL: imagen RGB -> imagen RGB pixelizada.
Args:
img: PIL.Image de entrada.
downscale: factor entero de reduccion nearest (>=1).
colors: numero de colores objetivo si no hay paleta fija.
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
dither: aplica Floyd-Steinberg al cuantizar si True.
upscale_back: re-escala nearest al tamano original si True.
Returns:
PIL.Image RGB pixelizada.
"""
from PIL import Image
img = img.convert("RGB")
w, h = img.size
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
sw, sh = max(1, w // downscale), max(1, h // downscale)
small = img.resize((sw, sh), Image.NEAREST)
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
# 2. cuantizar la paleta.
if palette_rgb:
pal_img = Image.new("P", (1, 1))
flat = [c for rgb in palette_rgb for c in rgb][:768]
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
# quantize no puede introducir un color extra (negro) por las entradas vacias.
if flat:
last = flat[-3:]
flat += last * ((768 - len(flat)) // 3)
flat += [0] * (768 - len(flat))
pal_img.putpalette(flat)
small = small.quantize(palette=pal_img, dither=d)
else:
n = max(2, min(256, int(colors)))
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
out = small.convert("RGB")
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
if upscale_back:
out = out.resize((w, h), Image.NEAREST)
return out
def comfyui_pixelize_image(
src_path: str,
dst_path: str,
*,
downscale: int = 8,
colors: int = 16,
palette=None,
dither: bool = False,
upscale_back: bool = True,
) -> dict:
"""Pixeliza una imagen y la guarda como PNG.
Args:
src_path: ruta de la imagen de entrada (PNG/JPG/...).
dst_path: ruta del PNG de salida.
downscale: factor entero de reduccion nearest-neighbor; cada bloque de
downscale x downscale px colapsa a 1 pixel. 1 = solo cuantiza, sin
colapsar el grid. keyword-only.
colors: numero de colores objetivo (2..256) cuando palette es None;
cuantizacion MEDIANCUT determinista. keyword-only.
palette: None (cuantizacion automatica a `colors`), nombre de paleta
fija builtin ("game-boy", "pico-8", "nes") o lista de hex
("#rrggbb"/"rrggbb"). Una paleta fija ignora `colors`. keyword-only.
dither: aplica Floyd-Steinberg al cuantizar (por defecto off, pixelart
limpio). keyword-only.
upscale_back: re-escala nearest al tamano original (preview con pixeles
duros). False deja la imagen pequena (sw x sh). keyword-only.
Returns:
dict con:
- ok (bool): True si se pixelizo y guardo.
- out_path (str): ruta del PNG generado.
- size (list[int]): [w, h] de la imagen final.
- n_colors_final (int): numero de colores distintos en el resultado.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "error": ""}
try:
from PIL import Image
except ImportError:
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
return out
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
if int(downscale) < 1:
out["error"] = f"downscale debe ser >= 1, recibido {downscale!r}"
return out
try:
palette_rgb = _normalize_palette(palette)
except ValueError as exc:
out["error"] = f"paleta invalida: {exc}"
return out
try:
with Image.open(src_path) as src:
result = _pixelize_pil(
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
)
except OSError as exc:
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
return out
try:
dst_dir = os.path.dirname(os.path.abspath(dst_path))
os.makedirs(dst_dir, exist_ok=True)
result.save(dst_path)
except OSError as exc:
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
return out
colors_found = result.getcolors(maxcolors=1 << 20)
n_final = len(colors_found) if colors_found is not None else -1
out.update(
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
)
return out
if __name__ == "__main__":
import json
import sys
if len(sys.argv) < 3:
print("uso: comfyui_pixelize_image.py <src> <dst> [downscale] [colors] [palette]",
file=sys.stderr)
sys.exit(2)
src, dst = sys.argv[1], sys.argv[2]
ds = int(sys.argv[3]) if len(sys.argv) > 3 else 8
col = int(sys.argv[4]) if len(sys.argv) > 4 else 16
pal = sys.argv[5] if len(sys.argv) > 5 else None
print(json.dumps(comfyui_pixelize_image(src, dst, downscale=ds, colors=col, palette=pal),
indent=2))
@@ -0,0 +1,81 @@
"""Tests de comfyui_pixelize_image (offline, sin red ni GPU; PIL/numpy)."""
import os
import sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.comfyui_pixelize_image import comfyui_pixelize_image # noqa: E402
def _noisy_png(path, w=256, h=256):
"""PNG ruidoso con cientos de colores (simula crudo borroso de IA)."""
rng = np.random.default_rng(7)
arr = rng.integers(0, 256, size=(h, w, 3), dtype=np.uint8)
Image.fromarray(arr, "RGB").save(path)
return path
def test_golden_downscale_quantize(tmp_path):
src = _noisy_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "pixel.png")
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16)
assert res["ok"] is True, res["error"]
assert os.path.isfile(dst)
assert res["size"] == [256, 256] # upscale_back=True conserva tamano
assert res["n_colors_final"] <= 16 # cuantizado a <=16 colores
def test_no_upscale_back_keeps_small(tmp_path):
src = _noisy_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "small.png")
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16, upscale_back=False)
assert res["ok"] is True
assert res["size"] == [32, 32] # 256//8
def test_edge_fixed_palette_game_boy(tmp_path):
src = _noisy_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "gb.png")
res = comfyui_pixelize_image(src, dst, palette="game-boy")
assert res["ok"] is True, res["error"]
assert res["n_colors_final"] <= 4 # paleta Game Boy = 4 colores
def test_edge_palette_list_hex(tmp_path):
src = _noisy_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "pal.png")
res = comfyui_pixelize_image(src, dst, palette=["#000000", "#ffffff", "#ff0000"])
assert res["ok"] is True
assert res["n_colors_final"] <= 3
def test_edge_downscale_1_only_quantizes(tmp_path):
src = _noisy_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "q.png")
res = comfyui_pixelize_image(src, dst, downscale=1, colors=8)
assert res["ok"] is True
assert res["size"] == [256, 256]
assert res["n_colors_final"] <= 8
def test_error_missing_src(tmp_path):
res = comfyui_pixelize_image(str(tmp_path / "nope.png"), str(tmp_path / "o.png"))
assert res["ok"] is False
assert "no existe" in res["error"]
def test_error_downscale_zero(tmp_path):
src = _noisy_png(str(tmp_path / "raw.png"))
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), downscale=0)
assert res["ok"] is False
assert "downscale" in res["error"]
def test_error_bad_palette(tmp_path):
src = _noisy_png(str(tmp_path / "raw.png"))
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette")
assert res["ok"] is False
assert "paleta" in res["error"].lower()