"""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 + SDXL_pixel-art 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 _img_has_alpha(img) -> bool: """True si la imagen lleva transparencia (RGBA, LA o P con transparency).""" return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info) def _fill_transparent_with_mode(small_rgb, small_alpha, threshold): """Rellena los pixeles transparentes con el color opaco mas frecuente (moda). Asi el fondo transparente NO aporta colores nuevos a la cuantizacion: las zonas con alpha <= threshold toman un color que ya esta en el sujeto (y por tanto en la paleta resultante), sin gastar entradas de la paleta en el color de fondo. El color real de esas zonas es irrelevante para la salida porque luego reciben alpha 0. Si no hay numpy, cae a no rellenar (degradacion limpia). Args: small_rgb: PIL.Image RGB ya reducida. small_alpha: PIL.Image 'L' del alpha ya reducido (mismo tamano). threshold: umbral de alpha (0..255); <= threshold = transparente. Returns: PIL.Image RGB con el fondo transparente relleno con la moda del sujeto. """ from PIL import Image rgb = small_rgb.convert("RGB") mask = small_alpha.point(lambda p: 255 if p > threshold else 0).convert("L") try: import numpy as np except ImportError: return rgb arr = np.asarray(rgb).reshape(-1, 3) opaque = np.asarray(mask).reshape(-1) > 0 if not opaque.any(): return rgb # nada opaco: caso degenerado, deja igual op_pixels = arr[opaque] colors, counts = np.unique(op_pixels, axis=0, return_counts=True) fill = tuple(int(x) for x in colors[counts.argmax()]) bg = Image.new("RGB", rgb.size, fill) bg.paste(rgb, (0, 0), mask) # rgb donde mask=255, fill (moda) donde mask=0 return bg def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back, keep_alpha, alpha_threshold): """Nucleo puro PIL: imagen -> imagen pixelizada (RGB, o RGBA si keep_alpha). Si la imagen de entrada tiene canal alpha y keep_alpha es True, la cuantizacion de color se hace SOLO sobre el RGB (con el fondo transparente relleno con la moda del sujeto para que no entre en la paleta) y el alpha se downscalea nearest por separado y se binariza por `alpha_threshold`, recombinando a RGBA. Asi se preserva la transparencia sin que las zonas transparentes contaminen la paleta. Para imagenes sin alpha (o keep_alpha False) el comportamiento RGB es identico al de antes. 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. keep_alpha: si True y la imagen tiene alpha, preserva la transparencia. alpha_threshold: umbral (0..255) para binarizar el alpha (opaco/transparente). Returns: PIL.Image pixelizada: RGB, o RGBA si se preservo la transparencia. """ from PIL import Image has_alpha = bool(keep_alpha) and _img_has_alpha(img) if has_alpha: rgba = img.convert("RGBA") alpha_full = rgba.getchannel("A") rgb = rgba.convert("RGB") else: rgb = img.convert("RGB") alpha_full = None w, h = rgb.size # 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel). sw, sh = max(1, w // downscale), max(1, h // downscale) small = rgb.resize((sw, sh), Image.NEAREST) small_alpha = ( alpha_full.resize((sw, sh), Image.NEAREST) if alpha_full is not None else None ) # 1b. con alpha: el fondo transparente no debe entrar en la paleta. if small_alpha is not None: small = _fill_transparent_with_mode(small, small_alpha, int(alpha_threshold)) d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE # 2. cuantizar la paleta (siempre sobre RGB). if palette_rgb: pal_img = Image.new("P", (1, 1)) flat = [c for rgb_c in palette_rgb for c in rgb_c][: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") # 2b. recombinar el alpha (binarizado) -> RGBA con transparencia dura. if small_alpha is not None: out = out.convert("RGBA") hard_alpha = small_alpha.point(lambda p: 255 if p > int(alpha_threshold) else 0) out.putalpha(hard_alpha) # 3. opcional: re-upscale nearest para preview/entrega (pixeles duros). if upscale_back: out = out.resize((w, h), Image.NEAREST) return out def _count_colors(result) -> int: """Numero de colores RGB distintos en el resultado. Para salida RGBA cuenta solo los colores de la zona opaca (alpha > 0), que es lo que define el sprite; el transparente no es un "color" del pixel-art. Para RGB cuenta todos los colores. Devuelve -1 si no se pudo contar. """ if result.mode == "RGBA": try: import numpy as np except ImportError: colors_found = result.convert("RGB").getcolors(maxcolors=1 << 20) return len(colors_found) if colors_found is not None else -1 arr = np.asarray(result) opaque = arr[..., 3] > 0 rgb_op = arr[..., :3][opaque] if rgb_op.size == 0: return 0 return int(len(np.unique(rgb_op.reshape(-1, 3), axis=0))) colors_found = result.getcolors(maxcolors=1 << 20) return len(colors_found) if colors_found is not None else -1 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, keep_alpha: bool = True, alpha_threshold: int = 128, ) -> 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. keep_alpha: si True (default) y la imagen de entrada tiene canal alpha, preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el alpha por separado, devolviendo PNG RGBA. Las zonas transparentes no entran en la paleta de color. Si la imagen no tiene alpha, no tiene efecto (sale RGB igual que antes). keyword-only. alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0). Solo aplica cuando se preserva el alpha. 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 RGB distintos en el resultado (en la zona opaca si la salida es RGBA). - has_alpha (bool): True si la salida es RGBA con transparencia preservada. - error (str): mensaje de error; cadena vacia si todo OK. """ out = { "ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "has_alpha": False, "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), bool(keep_alpha), int(alpha_threshold), ) 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 n_final = _count_colors(result) out.update( ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final, has_alpha=(result.mode == "RGBA"), ) return out if __name__ == "__main__": import json import sys if len(sys.argv) < 3: print("uso: comfyui_pixelize_image.py [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))