"""comfyui_pixelize_sprite_png — pixeliza un PNG existente a un sprite pixel-art REAL. Toma un PNG YA renderizado a alta resolucion (p.ej. 512x768 RGBA con fondo transparente) y lo convierte en un sprite pixel-art de verdad de `size` x `size`. Es la pieza reutilizable que extrae la logica de pixelizado de un PNG existente: la MISMA secuencia que `comfyui_pixelart_real_oneshot` aplica internamente en sus fases 1b/2a/2a-bis/2b, pero desacoplada de la generacion. Sirve para pixelizar cada frame de una animacion, una hoja de sprites, o cualquier render existente sin volver a pasar por la difusion. El metodo (report 0215) tiene tres etapas de post-proceso encadenadas: 1. crop al contenido (`crop_to_content`): recorta al bounding box del sujeto y lo cuadra para que llene el frame; si el sujeto ocupa el 25% del lienzo, a 32px quedaria diminuto. Best-effort: si falla, se sigue con el PNG original. 2. downscale a un grid `size` x `size`: - engine="pixeloe": downscale contrast-aware (`pixeloe_downscale`, no_upscale) que conserva la silueta — para sujetos con contorno (personajes, iconos). Si la lib no esta o falla, cae automaticamente a "nearest". - engine="nearest": downscale nearest simple (PIL) — mas barato, para tiles/texturas sin contorno. PixelOE trabaja en RGB y pierde el alpha, asi que tras el (fase 2a-bis) se downscalea el canal alpha por separado (nearest) y se reaplica al grid. 3. cuantizacion dura (`comfyui_pixelize_image`, downscale=1): clava la paleta exacta (N colores libres MEDIANCUT, o paleta fija pico-8 / nes / game-boy) sobre el grid ya hecho -> N colores + 100% grid duro, preservando el alpha. Compone funciones del registry, no reescribe su logica: crop_to_content_py_ml (autocrop al contenido + cuadrar; pura) pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe) comfyui_pixelize_image_py_ml (cuantizacion dura + alpha-aware) Funcion impura: lectura/escritura de disco (+ subprocess del bridge de pixeloe). No-throw: cualquier fallo se captura y viaja en el campo `error` del dict. """ from __future__ import annotations import os import sys import tempfile # Importa las funciones del registry (mismo arbol python/functions). _FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _FUNCTIONS_ROOT not in sys.path: sys.path.insert(0, _FUNCTIONS_ROOT) from ml.comfyui_pixelize_image import comfyui_pixelize_image from ml.crop_to_content import crop_to_content from ml.pixeloe_downscale import pixeloe_downscale def comfyui_pixelize_sprite_png( src_path: str, dst_path: str, *, size: int = 32, colors: int = 16, engine: str = "pixeloe", palette=None, transparent: bool = True, autocrop: bool = True, crop_pad_ratio: float = 0.02, mode: str = "contrast", patch_size: int = 16, thickness: int = 2, alpha_threshold: int = 128, comfy_python: str | None = None, ) -> dict: """Pixeliza un PNG existente a un sprite pixel-art REAL de `size` x `size`. Args: src_path: ruta del PNG de entrada (un render a alta resolucion, p.ej. 512x768 RGBA con fondo transparente). Debe existir. dst_path: ruta del PNG de salida size x size (se crea el directorio si falta). size: lado del grid final en pixeles (32 iconos/objetos simples, 64 personajes/sprites). Debe ser >= 1. keyword-only. colors: numero de colores de la paleta libre cuando palette es None (cuantizacion MEDIANCUT). keyword-only. engine: "pixeloe" (downscale contrast-aware, para sujetos con silueta: personajes/criaturas/iconos) o "nearest" (downscale nearest simple, mas barato, para tiles/texturas/fondos sin contorno). Si "pixeloe" falla o la lib no esta disponible, cae automaticamente a "nearest" y lo refleja en engine_used. keyword-only. palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes", "game-boy") o lista de hex. Una paleta fija ignora `colors`. keyword-only. transparent: si True (default) trata la entrada como RGBA y produce un sprite RGBA con transparencia real (el fondo transparente no entra en la paleta). Para tiles/texturas opacas, False produce salida RGB. keyword-only. autocrop: si True (default) recorta el PNG al bounding box de su contenido y lo cuadra antes del downscale, para que el sujeto llene el frame (evita el sprite diminuto). Usa el alpha si transparent, o el color de fondo si RGB. keyword-only. crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto (0.02 = 2% del lado). keyword-only. mode: modo de downscale de PixelOE ("contrast" SOTA, "k-centroid", "nearest", "center", "bicubic"); solo aplica con engine="pixeloe". keyword-only. patch_size: tamano de patch de PixelOE (default 16). keyword-only. thickness: grosor del outline expansion de PixelOE (default 2). keyword-only. alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0) en la cuantizacion final. Solo aplica si transparent. keyword-only. comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe) para el bridge; None autodetecta COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only. Returns: dict con: - ok (bool): True si se produjo el PNG final pixelizado. - out_path (str): ruta del PNG final size x size (vacio si fallo). - size (int): lado real del PNG final. - colors_final (int): numero de colores distintos en el resultado (en la zona opaca si es RGBA). - has_alpha (bool): True si el PNG final es RGBA con transparencia. - engine_used (str): "pixeloe" o "nearest" (refleja el fallback real). - autocrop_applied (bool): True si el autocrop recorto/cuadro la imagen. - error (str): mensaje de error; cadena vacia si todo OK. """ out = { "ok": False, "out_path": "", "size": int(size), "colors_final": 0, "has_alpha": False, "engine_used": engine, "autocrop_applied": False, "error": "", } # --- Validaciones (no-throw). --- if not src_path or not os.path.isfile(src_path): out["error"] = f"src_path no existe: {src_path!r}" return out if int(size) < 1: out["error"] = f"size debe ser >= 1, recibido {size!r}" return out if engine not in ("pixeloe", "nearest"): out["error"] = f"engine invalido: {engine!r} (usa 'pixeloe' o 'nearest')" return out # Directorio temporal para los intermedios (crop + mid); se limpia al final. try: tmp_dir = tempfile.mkdtemp(prefix="pixelize_sprite_") except OSError as exc: out["error"] = f"no se pudo crear directorio temporal: {exc}" return out crop_path = os.path.join(tmp_dir, "crop.png") mid_path = os.path.join(tmp_dir, "mid.png") try: # --- Fase 1b (opcional): autocrop al contenido + cuadrar. --- # La imagen sobre la que se hace el downscale: la recortada si autocrop, o la # original sin tocar. pre_ds_path = src_path if autocrop: try: from PIL import Image with Image.open(src_path) as base_im: src_im = ( base_im.convert("RGBA") if transparent else base_im.convert("RGB") ) before = src_im.size cropped = crop_to_content( src_im, pad_ratio=float(crop_pad_ratio), square=True, ) cropped.save(crop_path) pre_ds_path = crop_path out["autocrop_applied"] = cropped.size != before except (ImportError, OSError, ValueError) as exc: # Autocrop es best-effort: si falla, se sigue con el src sin recortar. pre_ds_path = src_path out["autocrop_applied"] = False if not out["error"]: out["error"] = f"autocrop fallo (no critico): {exc}" # --- Fase 2a: downscale a un grid `size` x `size` (mid). --- engine_used = engine if engine == "pixeloe": ds = pixeloe_downscale( pre_ds_path, mid_path, mode=mode, target_size=int(size), patch_size=int(patch_size), thickness=int(thickness), no_upscale=True, comfy_python=comfy_python, ) if not ds.get("ok"): # Fallback limpio: PixelOE no disponible / fallo -> nearest. engine_used = "nearest" out["error"] = f"pixeloe fallo ({ds.get('error')}); fallback a nearest" if engine_used == "nearest": # Downscale nearest simple a size x size (PIL del venv del registry). # nearest preserva el alpha por canal: si transparent, conserva la silueta. try: from PIL import Image with Image.open(pre_ds_path) as src: target_mode = "RGBA" if transparent else "RGB" small = src.convert(target_mode).resize( (int(size), int(size)), Image.NEAREST ) small.save(mid_path) except (ImportError, OSError) as exc: out["error"] = f"downscale nearest fallo: {exc}" return out if not os.path.isfile(mid_path): out["error"] = "no se genero la imagen intermedia (mid)" return out # --- Fase 2a-bis: recombinar alpha tras pixeloe (PixelOE trabaja en RGB). --- # El nucleo de PixelOE convierte a RGB: el grid `mid` sale sin transparencia. # Se downscalea el alpha de la imagen pre-downscale por separado (nearest al # mismo size) y se reaplica al grid para no perder el recorte ni la # transparencia. (engine="nearest" ya conserva su alpha, no hace falta.) if transparent and engine_used == "pixeloe": try: from PIL import Image with Image.open(pre_ds_path) as src_im: alpha = src_im.convert("RGBA").getchannel("A").resize( (int(size), int(size)), Image.NEAREST ) with Image.open(mid_path) as mid_im: mid_rgba = mid_im.convert("RGBA") mid_rgba.putalpha(alpha) mid_rgba.save(mid_path) except (ImportError, OSError) as exc: if not out["error"]: out["error"] = f"recombinacion de alpha fallo (no critico): {exc}" # --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. --- quant = comfyui_pixelize_image( mid_path, dst_path, downscale=1, colors=int(colors), palette=palette, upscale_back=False, keep_alpha=bool(transparent), alpha_threshold=int(alpha_threshold), ) if not quant.get("ok"): out["error"] = f"cuantizacion fallo: {quant.get('error')}" return out out["out_path"] = dst_path out["size"] = quant["size"][0] if quant.get("size") else int(size) out["colors_final"] = quant.get("n_colors_final", 0) out["has_alpha"] = bool(quant.get("has_alpha", False)) out["engine_used"] = engine_used out["ok"] = True return out finally: # Limpieza de intermedios + directorio temporal. for tmp in (crop_path, mid_path): try: os.remove(tmp) except OSError: pass try: os.rmdir(tmp_dir) except OSError: pass if __name__ == "__main__": import json # Demo autosuficiente: genera un PNG de prueba (circulo de color sobre fondo # transparente 512x512) y lo pixeliza a 32x32 con 16 colores y transparencia. demo_dir = tempfile.mkdtemp(prefix="pixelize_sprite_demo_") demo_src = os.path.join(demo_dir, "demo_src.png") demo_dst = os.path.join(demo_dir, "demo_sprite.png") try: from PIL import Image, ImageDraw im = Image.new("RGBA", (512, 512), (0, 0, 0, 0)) # fondo transparente draw = ImageDraw.Draw(im) draw.ellipse((96, 96, 416, 416), fill=(220, 60, 60, 255)) # circulo rojo draw.ellipse((176, 160, 256, 240), fill=(250, 230, 120, 255)) # ojo amarillo draw.ellipse((280, 160, 360, 240), fill=(60, 120, 220, 255)) # ojo azul im.save(demo_src) except ImportError as exc: print(json.dumps({"ok": False, "error": f"PIL no disponible: {exc}"})) raise SystemExit(0) res = comfyui_pixelize_sprite_png( demo_src, demo_dst, size=32, colors=16, engine="pixeloe", transparent=True, autocrop=True, ) print(json.dumps(res, indent=2))