Compare commits

...

6 Commits

Author SHA1 Message Date
egutierrez c79f33265e fix(comfyui): pixelart_real_oneshot — sprite llena el frame + fondo transparente
Arregla los dos defectos reportados del pipeline comfyui_pixelart_real_oneshot:
el sujeto salía diminuto respecto al frame y siempre traía fondo (sin opción de
transparencia).

Causa raíz: comfyui_pixelize_image hacía convert("RGB") y descartaba el alpha;
comfyui_build_pixelart_workflow no inyectaba rembg (a diferencia de sus hermanos
item_icon/enemy_creature); y no había ningún paso de auto-crop al contenido.

Orden correcto del pipeline ahora:
generar (rembg) -> autocrop al bbox + cuadrar -> downscale (alpha aparte por
PixelOE) -> cuantización alpha-aware -> PNG RGBA transparente.

Piezas:
- comfyui_pixelize_image (1.1.0): keep_alpha/alpha_threshold. Con RGBA cuantiza
  solo el RGB (fondo transparente relleno con la moda del sujeto, fuera de la
  paleta) y preserva/binariza el alpha aparte. RGB sin alpha intacto.
- crop_to_content (NUEVA, pura PIL): bbox del contenido (alpha o diff-fondo) ->
  recorta -> margen -> cuadra centrando. No-throw; imagen vacía -> copia intacta.
- comfyui_build_pixelart_workflow (1.1.0): transparent=True + rembg_model.
  Inyecta nodo Image Rembg tras VAEDecode (patrón de item_icon).
- comfyui_pixelart_real_oneshot (1.1.0): transparent + autocrop + crop_pad_ratio
  + rembg_model. Recombina el alpha aparte tras PixelOE (trabaja en RGB). Campos
  nuevos: has_alpha, autocrop_applied.

Verificado en GPU (knight 64px): RGBA con 4 esquinas alpha==0, contenido cubre
88% del frame (antes 48%), 16 colores, 64x64. 32 tests offline en verde.
Report: reports/0218-2026-06-28-pixelart-sprite-fix.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:59:26 +02:00
egutierrez 31c2f6ac7f test(comfyui): reubicar test de pixeloe_downscale a tests/ 2026-06-28 15:27:13 +02:00
egutierrez 3bc97828e3 merge(comfyui): comfyui_pixelart_real_oneshot + pixeloe_downscale (pixelart real: PixelOE + cuantización dura) 2026-06-28 15:27:08 +02:00
egutierrez ccdd529bdc feat(comfyui): pipeline comfyui_pixelart_real_oneshot — pixelart REAL (PixelOE + cuantizacion dura)
Materializa el metodo ganador del report 0215: generar a alta-res con SDXL +
LoRA SDXL_pixel-art, downscale contrast-aware con PixelOE (engine=pixeloe para
sprites/personajes) o nearest (tiles), y cuantizacion dura con
comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy).

- pixeloe_downscale_py_ml: downscale contrast-aware via lib pixeloe con bridge
  de interprete (la lib vive en el venv de ComfyUI, no en el del registry).
  No-throw, fallback limpio si pixeloe no disponible.
- comfyui_pixelart_real_oneshot_py_pipelines: one-shot que compone build_pixelart
  + submit + wait + fetch + pixeloe_downscale + pixelize_image. Fallback
  automatico pixeloe->nearest. Sweet-spot 64px personajes, 32px iconos.

Verificado por PIL: personaje 64x64=16 colores, icono 32x32=16 colores (vs ~33k
de la imagen de difusion cruda). 100% grid duro + outline nitido.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:24:15 +02:00
egutierrez 741724f633 chore: auto-commit (1 archivos)
- logs/ardour_mcp_server.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-28 15:03:24 +02:00
egutierrez 2be62f6ef6 merge(comfyui): comfyui_generate_until_quality — loop generar/juzgar/refinar (best-of-N + escalate + refine_prompt) 2026-06-28 15:02:45 +02:00
15 changed files with 1823 additions and 35 deletions
File diff suppressed because one or more lines are too long
@@ -3,11 +3,11 @@ name: comfyui_build_pixelart_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
version: "1.1.0"
purity: pure
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, filename_prefix: str = \"pixelart\") -> dict"
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl]
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, transparent: bool = True, rembg_model: str = \"u2net\", filename_prefix: str = \"pixelart\") -> dict"
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Si transparent (default), inyecta un nodo 'Image Rembg' tras el VAEDecode para recortar el fondo -> sprite con alpha (mismo patron que comfyui_build_item_icon_workflow); transparent=False para tiles/fondos opacos. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl, rembg, transparent]
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
uses_types: []
returns: []
@@ -45,11 +45,15 @@ params:
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
- name: transparent
desc: "si True (default) inyecta 'Image Rembg' tras VAEDecode y el PNG sale con alpha (fondo recortado) — para sprites de sujeto (personajes/objetos). False deja fondo opaco — para tiles/texturas/fondos. keyword-only."
- name: rembg_model
desc: "modelo Rembg ('u2net' general, 'isnet-anime' anime). Solo se usa si transparent=True. keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + SaveImage."
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + nodo 'Image Rembg' antes del SaveImage si transparent + SaveImage."
tested: true
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo"]
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo", "transparent default inyecta Image Rembg + repunta SaveImage", "transparent=False sin Rembg (SaveImage lee del VAEDecode)", "rembg_model override"]
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
---
@@ -94,3 +98,15 @@ Para tilesets, genera cada tile por separado y ensambla con `comfyui_build_grid`
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
400 salta al enviar con `comfyui_submit_workflow`.
- **transparent=True (default, v1.1.0)**: inyecta el nodo `Image Rembg (Remove
Background)`. Requiere el custom node `ComfyUI-Image-Background-Remove` (o equiv.)
instalado en el server; si falta, el `submit` devuelve error en el dict (no crashea).
El sprite sale RGBA con fondo recortado — ideal para personajes/objetos. Para
tiles/texturas/fondos sin contorno usar `transparent=False` (PNG opaco).
## Capability growth log
- v1.1.0 (2026-06-28) — `transparent`/`rembg_model`: inyecta `Image Rembg` tras el
VAEDecode (mismo patron que `comfyui_build_item_icon_workflow`) para producir
sprites con fondo transparente. Cierra el bug del pipeline pixelart que no podia
generar sprites sin fondo (issue sprite-fix).
@@ -19,6 +19,7 @@ Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
from __future__ import annotations
import copy
import os
import sys
@@ -29,6 +30,44 @@ _LCM_DEFAULTS = {"steps": 8, "cfg": 1.5, "sampler_name": "lcm", "scheduler": "sg
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
def _inject_rembg(workflow: dict, model: str) -> dict:
"""Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage.
Mismo helper que comfyui_build_item_icon_workflow / comfyui_build_sprite_sheet_workflow:
el nodo recorta la silueta del sujeto dejando alpha, y se repunta SaveImage.images a
la salida del Rembg para que el PNG salga con fondo transparente. No muta el dict de
entrada (copia profunda).
"""
wf = copy.deepcopy(workflow)
vaedecode_id = next(
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
)
save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None)
if vaedecode_id is None or save_id is None:
raise ValueError(
"comfyui_build_pixelart_workflow: no se encontro VAEDecode/SaveImage para Rembg"
)
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
wf[rembg_id] = {
"class_type": "Image Rembg (Remove Background)",
"inputs": {
"images": [vaedecode_id, 0],
"transparency": True,
"model": model,
"post_processing": False,
"only_mask": False,
"alpha_matting": False,
"alpha_matting_foreground_threshold": 240,
"alpha_matting_background_threshold": 10,
"alpha_matting_erode_size": 10,
"background_color": "none",
},
}
wf[save_id]["inputs"]["images"] = [rembg_id, 0]
return wf
def comfyui_build_pixelart_workflow(
positive: str,
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
@@ -46,6 +85,8 @@ def comfyui_build_pixelart_workflow(
seed: int = 0,
sampler_name: str | None = None,
scheduler: str | None = None,
transparent: bool = True,
rembg_model: str = "u2net",
filename_prefix: str = "pixelart",
) -> dict:
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
@@ -70,15 +111,24 @@ def comfyui_build_pixelart_workflow(
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
en la Fase 2 con comfyui_pixelize_image).
seed: semilla del KSampler.
transparent: si True (default) inyecta 'Image Rembg' tras el VAEDecode y el
PNG sale con alpha (fondo recortado) — lo habitual para sprites de sujeto
(personajes, criaturas, objetos). Si False deja la imagen opaca sobre
fondo plano, para tiles/texturas/fondos que no quieren transparencia.
keyword-only.
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo
se usa si transparent=True. keyword-only.
filename_prefix: prefijo del PNG en output/.
Returns:
dict en API format listo para comfyui_submit_workflow, con el
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
SDXL_lcm-lora si use_lcm), KSampler con los params del modo y SaveImage.
SDXL_lcm-lora si use_lcm), KSampler con los params del modo, un nodo
'Image Rembg' antes del SaveImage si transparent, y SaveImage.
Raises:
ValueError: si positive esta vacio.
ValueError: si positive esta vacio, o si la base no tiene VAEDecode/SaveImage
donde inyectar el Rembg (propagado por el helper, solo si transparent).
"""
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
@@ -117,7 +167,12 @@ def comfyui_build_pixelart_workflow(
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
)
return comfyui_inject_multi_lora(base, loras)
wf = comfyui_inject_multi_lora(base, loras)
if transparent:
wf = _inject_rembg(wf, rembg_model)
return wf
if __name__ == "__main__":
@@ -67,3 +67,33 @@ def test_determinism():
a = comfyui_build_pixelart_workflow("pixel cat", seed=3)
b = comfyui_build_pixelart_workflow("pixel cat", seed=3)
assert a == b
def test_transparent_default_injects_rembg():
"""transparent default True -> nodo Image Rembg y SaveImage repuntado a el."""
wf = comfyui_build_pixelart_workflow("pixel knight, full body")
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
assert len(rembg) == 1
assert rembg[0]["inputs"]["transparency"] is True
assert rembg[0]["inputs"]["model"] == "u2net"
# SaveImage debe leer de la salida del Rembg, no del VAEDecode.
rembg_id = next(k for k, n in wf.items() if n["class_type"] == "Image Rembg (Remove Background)")
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
assert save["inputs"]["images"][0] == rembg_id
def test_transparent_false_no_rembg():
"""transparent=False -> sin nodo Rembg (tiles/fondos opacos)."""
wf = comfyui_build_pixelart_workflow("seamless grass tile", transparent=False)
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
assert len(rembg) == 0
# SaveImage lee directo del VAEDecode.
vae_id = next(k for k, n in wf.items() if n["class_type"] == "VAEDecode")
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
assert save["inputs"]["images"][0] == vae_id
def test_rembg_model_override():
wf = comfyui_build_pixelart_workflow("anime hero", rembg_model="isnet-anime")
rembg = next(n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)")
assert rembg["inputs"]["model"] == "isnet-anime"
+38 -12
View File
@@ -3,11 +3,11 @@ name: comfyui_pixelize_image
kind: function
lang: py
domain: ml
version: "1.0.0"
version: "1.1.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-2d, pixelart, ml, pil, quantize, palette, image]
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, keep_alpha: bool = True, alpha_threshold: int = 128) -> 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. Alpha-aware: si la entrada es RGBA y keep_alpha, cuantiza SOLO el RGB (el fondo transparente no entra en la paleta) y preserva/binariza el alpha por separado -> PNG RGBA con transparencia real. 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, has_alpha, error}. Impura solo por la lectura/escritura de disco."
tags: [comfyui, gamedev-2d, pixelart, ml, pil, quantize, palette, image, alpha, transparent]
uses_functions: []
uses_types: []
returns: []
@@ -29,9 +29,13 @@ params:
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)."
- name: keep_alpha
desc: "si True (default) y la entrada tiene canal alpha, preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el alpha aparte -> PNG RGBA. Sin efecto si la imagen no tiene alpha (sale RGB igual que antes). keyword-only."
- name: alpha_threshold
desc: "umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0). Solo aplica cuando se preserva el alpha. keyword-only."
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores RGB distintos; en la zona opaca si es RGBA), has_alpha (bool, True si la salida es RGBA), 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]
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_alpha_preserved_transparent_corners, test_alpha_off_flattens_to_rgb, test_rgb_input_unaffected_by_keep_alpha, test_error_all_transparent_no_crash]
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
file_path: "python/functions/ml/comfyui_pixelize_image.py"
---
@@ -54,14 +58,21 @@ res = comfyui_pixelize_image(
# 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)
# Sprite RGBA (tras rembg): preserva la transparencia, cuantiza solo el sujeto
res = comfyui_pixelize_image("/tmp/knight_rgba.png", "/tmp/knight_px.png",
downscale=1, colors=16, keep_alpha=True)
# {'ok': True, 'has_alpha': True, 'n_colors_final': 16, ...} -> fondo transparente intacto
```
## Cuando usarla
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `SDXL_pixel-art`),
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:
para colapsar el grid borroso a pixeles duros y limitar la paleta. Si la imagen
viene de `rembg` con fondo recortado (RGBA), `keep_alpha=True` mantiene la
transparencia y deja el fondo fuera de 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
@@ -76,7 +87,22 @@ tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
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.
- `n_colors_final` cuenta colores RGB distintos reales del PNG escrito; con salida
RGBA cuenta **solo la zona opaca** (el transparente no es un color del pixel-art);
con paleta fija puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
- **alpha-aware (v1.1.0)**: con entrada RGBA y `keep_alpha=True` (default), el fondo
transparente se rellena internamente con la moda del sujeto antes de cuantizar, asi
NO gasta una entrada de la paleta; el alpha se downscalea nearest aparte y se
binariza por `alpha_threshold` (0/255 = bordes duros pixel-art). Entrada sin alpha
-> comportamiento RGB identico al de antes (retrocompatible).
- Si la entrada RGBA esta **toda transparente** (rembg sin sujeto), no crashea:
devuelve `ok=True`, `has_alpha=True`, `n_colors_final=0` y el PNG sigue transparente.
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
con Pillow.
con Pillow (numpy acelera el relleno alpha; sin numpy degrada limpio).
## Capability growth log
- v1.1.0 (2026-06-28) — alpha-aware: `keep_alpha`/`alpha_threshold`. Si la entrada
es RGBA, cuantiza solo el RGB (fondo transparente fuera de la paleta) y preserva el
alpha binarizado -> PNG RGBA con transparencia real. Cierra el bug del pipeline
pixelart que perdia el fondo transparente por el `convert("RGB")` (issue sprite-fix).
+128 -14
View File
@@ -64,8 +64,60 @@ def _normalize_palette(palette):
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.
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.
@@ -74,22 +126,39 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
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 RGB pixelizada.
PIL.Image pixelizada: RGB, o RGBA si se preservo la transparencia.
"""
from PIL import Image
img = img.convert("RGB")
w, h = img.size
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 = img.resize((sw, sh), Image.NEAREST)
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.
# 2. cuantizar la paleta (siempre sobre RGB).
if palette_rgb:
pal_img = Image.new("P", (1, 1))
flat = [c for rgb in palette_rgb for c in rgb][:768]
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:
@@ -102,12 +171,42 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
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,
@@ -117,6 +216,8 @@ def comfyui_pixelize_image(
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.
@@ -135,16 +236,28 @@ def comfyui_pixelize_image(
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 distintos en el resultado.
- 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, "error": ""}
out = {
"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0,
"has_alpha": False, "error": "",
}
try:
from PIL import Image
@@ -168,7 +281,8 @@ def comfyui_pixelize_image(
try:
with Image.open(src_path) as src:
result = _pixelize_pil(
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
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}"
@@ -182,10 +296,10 @@ def comfyui_pixelize_image(
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
n_final = _count_colors(result)
out.update(
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final,
has_alpha=(result.mode == "RGBA"),
)
return out
@@ -79,3 +79,69 @@ def test_error_bad_palette(tmp_path):
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()
# --- alpha-aware (sprites con fondo transparente) ---
def _rgba_subject_png(path, canvas=256, box=120):
"""RGBA: sujeto opaco de colores variados centrado, fondo transparente."""
rng = np.random.default_rng(3)
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
o = (canvas - box) // 2
arr[o:o + box, o:o + box, :3] = rng.integers(0, 256, size=(box, box, 3), dtype=np.uint8)
arr[o:o + box, o:o + box, 3] = 255 # sujeto opaco
Image.fromarray(arr, "RGBA").save(path)
return path
def test_alpha_preserved_transparent_corners(tmp_path):
"""RGBA in -> RGBA out con esquinas transparentes y paleta limitada en lo opaco."""
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
dst = str(tmp_path / "px.png")
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, upscale_back=False)
assert res["ok"] is True, res["error"]
assert res["has_alpha"] is True
out = Image.open(dst).convert("RGBA")
a = np.asarray(out)[..., 3]
w, h = out.size
# Las 4 esquinas deben ser transparentes (alpha == 0).
assert a[0, 0] == 0 and a[0, w - 1] == 0
assert a[h - 1, 0] == 0 and a[h - 1, w - 1] == 0
# Centro opaco.
assert a[h // 2, w // 2] == 255
# Colores limitados en la zona opaca.
assert res["n_colors_final"] <= 16
def test_alpha_off_flattens_to_rgb(tmp_path):
"""keep_alpha=False sobre RGBA -> sale RGB (sin canal alpha)."""
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
dst = str(tmp_path / "flat.png")
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, keep_alpha=False)
assert res["ok"] is True
assert res["has_alpha"] is False
assert Image.open(dst).mode != "RGBA"
def test_rgb_input_unaffected_by_keep_alpha(tmp_path):
"""Imagen RGB (sin alpha) con keep_alpha=True sigue saliendo RGB, sin romper."""
src = _noisy_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "rgb.png")
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16) # keep_alpha default True
assert res["ok"] is True
assert res["has_alpha"] is False
assert res["n_colors_final"] <= 16
def test_error_all_transparent_no_crash(tmp_path):
"""RGBA toda transparente (rembg sin sujeto): no crashea, 0 colores opacos."""
arr = np.zeros((64, 64, 4), dtype=np.uint8) # alpha 0 en todo
src = str(tmp_path / "empty.png")
Image.fromarray(arr, "RGBA").save(src)
dst = str(tmp_path / "out.png")
res = comfyui_pixelize_image(src, dst, downscale=1, colors=16)
assert res["ok"] is True, res["error"]
assert res["has_alpha"] is True
assert res["n_colors_final"] == 0
out = np.asarray(Image.open(dst).convert("RGBA"))
assert out[..., 3].max() == 0 # sigue toda transparente
+76
View File
@@ -0,0 +1,76 @@
---
name: crop_to_content
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def crop_to_content(img, *, pad_ratio: float = 0.06, square: bool = True, alpha_threshold: int = 10, bg_tolerance: int = 16)"
description: "Recorta una imagen PIL al bounding box de su contenido y la cuadra, para que el sujeto llene el frame antes de un downscale a pixel-art. Detecta el contenido por alpha (region con alpha > alpha_threshold) si la imagen es RGBA/LA, o por diferencia contra el color de fondo de las esquinas (con bg_tolerance) si es RGB. Recorta al bbox, anade un margen pad_ratio y, si square, rellena a cuadrado centrando el sujeto sin deformar (fondo transparente si RGBA, color de fondo si RGB). Pura PIL (opera sobre el objeto PIL.Image, no toca disco ni red, no muta la entrada). Si no hay contenido (todo transparente o todo fondo) devuelve una copia intacta — no crashea."
tags: [pil, image, crop, bbox, pixelart, gamedev-2d, ml, alpha, sprite]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: img
desc: "PIL.Image de entrada (cualquier modo). No se muta. None lanza ValueError."
- name: pad_ratio
desc: "Margen anadido alrededor del sujeto como fraccion del lado mayor del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only."
- name: square
desc: "Si True rellena a un lienzo cuadrado de lado max(w,h)+2*pad con el sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB); si False solo recorta al bbox + margen sin cuadrar. keyword-only."
- name: alpha_threshold
desc: "Umbral de alpha (0..255) para considerar un pixel 'contenido' cuando la imagen tiene canal alpha. keyword-only."
- name: bg_tolerance
desc: "Tolerancia (0..255) de diferencia contra el color de fondo de las esquinas para imagenes sin alpha (RGB). keyword-only."
output: "PIL.Image nueva recortada (y cuadrada si square) con el sujeto llenando el frame. Si la imagen no tiene contenido detectable, devuelve una copia intacta de la entrada (mismo tamano)."
tested: true
tests: [test_golden_corner_subject_fills_frame, test_edge_centered_subject_not_overcropped, test_edge_rgb_background_bbox, test_edge_no_square_only_crops, test_error_all_transparent_returns_copy, test_error_none_raises, test_does_not_mutate_input]
test_file_path: "python/functions/ml/crop_to_content_test.py"
file_path: "python/functions/ml/crop_to_content.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from PIL import Image
from ml.crop_to_content import crop_to_content
# Sprite RGBA tras rembg: el sujeto ocupa una esquina -> recortar al bbox y cuadrar.
with Image.open("/tmp/knight_rgba.png") as im:
out = crop_to_content(im, pad_ratio=0.06, square=True)
out.save("/tmp/knight_cropped.png") # RGBA cuadrada, sujeto centrado llenando el frame
# CLI directo:
# ./fn run crop_to_content (corre los tests)
# python3 crop_to_content.py /tmp/in.png /tmp/out.png 0.06
```
## Cuando usarla
Antes de bajar una imagen a pixel-art (32/64px): si el sujeto ocupa poca area del
lienzo, al downscalear queda diminuto y tosco. `crop_to_content` recorta el aire
alrededor y cuadra para que el sujeto aproveche todos los pixeles del grid. Es el
paso de encuadre del pipeline `comfyui_pixelart_real_oneshot` (autocrop). Funciona
con sprites recortados por rembg (detecta por alpha) o con imagenes de fondo plano
(detecta por diferencia contra el color de esquina).
## Gotchas
- **Pura sobre PIL.Image**: recibe y devuelve un objeto `PIL.Image`, NO rutas. El
caller hace el `Image.open` / `.save`. No muta la imagen de entrada.
- Deteccion del contenido: con **alpha** usa `alpha > alpha_threshold`; sin alpha
usa la **moda de las 4 esquinas** como color de fondo y `bg_tolerance` de
diferencia. Si el fondo no es uniforme (gradiente) la deteccion RGB puede fallar;
para esos casos pasa la imagen ya recortada por rembg (RGBA).
- Si no hay contenido (todo transparente o todo del color de fondo) devuelve una
**copia intacta** del original (mismo tamano), nunca lanza por una imagen vacia.
Solo lanza `ValueError` si `img` es `None`.
- `square=True` (default) cuadra a `max(w,h)+2*pad`: si el sujeto es muy alargado el
lienzo crece al lado mayor y el sujeto queda centrado con barras transparentes (o
de color de fondo) a los lados — sin deformar.
- `pad_ratio` es relativo al lado **mayor del bbox**, no del lienzo original.
+162
View File
@@ -0,0 +1,162 @@
"""crop_to_content — recorta una imagen PIL al bounding box de su contenido y la cuadra.
Quita el aire alrededor del sujeto para que llene el frame antes de un downscale a
pixel-art: si el sujeto ocupa el 25% del lienzo, al bajar a 64px queda diminuto y
tosco (pocos pixeles para el detalle). Esta funcion calcula el bounding box del
contenido, recorta a ese bbox, anade un margen relativo y, opcionalmente, rellena a
cuadrado sin deformar para que el sujeto llene el frame.
Como detecta el contenido:
- Si la imagen tiene canal alpha (RGBA / LA / P con transparencia): el bbox es la
region con `alpha > alpha_threshold` (lo opaco es el sujeto, lo transparente es
fondo). Es el caso tras pasar la imagen por rembg.
- Si no tiene alpha (RGB): el bbox es la region que difiere del color de fondo,
estimado como la moda de los cuatro pixeles de esquina. Sirve para imagenes con
fondo plano sin recortar todavia.
Relleno a cuadrado (`square=True`): el lado del lienzo final es `max(w, h) + 2*pad`
y el sujeto se centra. El fondo del lienzo es transparente si la imagen tiene alpha,
o el color de fondo estimado si es RGB. Asi no se deforma el sujeto.
Funcion pura: opera sobre el objeto PIL.Image y devuelve uno nuevo; no toca disco ni
red y no muta la imagen de entrada. Si no encuentra contenido (lienzo vacio o todo
transparente), devuelve una copia intacta de la entrada — nunca lanza por una imagen
sin sujeto (contrato no-throw salvo `img` None).
"""
from __future__ import annotations
from collections import Counter
def _as_rgb_tuple(c) -> tuple:
"""Normaliza un pixel (int de modo L, o tupla RGB/RGBA) a una 3-tupla RGB."""
if isinstance(c, (tuple, list)):
return tuple(int(x) for x in c[:3])
return (int(c), int(c), int(c))
def _corner_bg_color(img) -> tuple:
"""Color de fondo estimado: la moda de los cuatro pixeles de esquina (RGB)."""
rgb = img.convert("RGB")
w, h = rgb.size
corners = [
rgb.getpixel((0, 0)),
rgb.getpixel((w - 1, 0)),
rgb.getpixel((0, h - 1)),
rgb.getpixel((w - 1, h - 1)),
]
corners = [_as_rgb_tuple(c) for c in corners]
return Counter(corners).most_common(1)[0][0]
def _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 _content_bbox(img, alpha_threshold: int, bg_tolerance: int):
"""Devuelve (l, t, r, b) del contenido o None si no hay.
Por alpha si la imagen lo tiene; si no, por diferencia contra el color de fondo
de las esquinas con tolerancia `bg_tolerance`.
"""
from PIL import Image, ImageChops
if _has_alpha(img):
alpha = img.convert("RGBA").getchannel("A")
mask = alpha.point(lambda p: 255 if p > alpha_threshold else 0)
return mask.getbbox()
rgb = img.convert("RGB")
bg = Image.new("RGB", rgb.size, _corner_bg_color(rgb))
diff = ImageChops.difference(rgb, bg).convert("L")
mask = diff.point(lambda p: 255 if p > bg_tolerance else 0)
return mask.getbbox()
def crop_to_content(
img,
*,
pad_ratio: float = 0.06,
square: bool = True,
alpha_threshold: int = 10,
bg_tolerance: int = 16,
):
"""Recorta una imagen PIL al bbox de su contenido, con margen y cuadrado opcional.
Args:
img: PIL.Image de entrada (cualquier modo). No se muta.
pad_ratio: margen anadido alrededor del sujeto como fraccion del lado mayor
del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only.
square: si True rellena a un lienzo cuadrado de lado `max(w,h)+2*pad` con el
sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB);
si False solo recorta al bbox + margen sin cuadrar. keyword-only.
alpha_threshold: umbral de alpha (0..255) para considerar un pixel "contenido"
cuando la imagen tiene canal alpha. keyword-only.
bg_tolerance: tolerancia (0..255) de diferencia contra el color de fondo de
las esquinas para imagenes sin alpha (RGB). keyword-only.
Returns:
PIL.Image nueva recortada (y cuadrada si square). Si la imagen no tiene
contenido detectable (todo transparente o todo del color de fondo), devuelve
una copia intacta de la entrada.
Raises:
ValueError: si img es None.
"""
from PIL import Image
if img is None:
raise ValueError("crop_to_content: img es None")
bbox = _content_bbox(img, int(alpha_threshold), int(bg_tolerance))
if bbox is None:
return img.copy()
left, top, right, bottom = bbox
cropped = img.crop((left, top, right, bottom))
cw, ch = cropped.size
pad = int(round(max(cw, ch) * float(pad_ratio)))
has_alpha = _has_alpha(img)
if has_alpha:
base = cropped.convert("RGBA")
bg_fill = (0, 0, 0, 0)
mode = "RGBA"
else:
base = cropped.convert("RGB")
bg_fill = _corner_bg_color(img)
mode = "RGB"
if square:
side = max(cw, ch) + 2 * pad
canvas = Image.new(mode, (side, side), bg_fill)
ox = (side - cw) // 2
oy = (side - ch) // 2
else:
if pad <= 0:
return base
canvas = Image.new(mode, (cw + 2 * pad, ch + 2 * pad), bg_fill)
ox = oy = pad
if has_alpha:
canvas.paste(base, (ox, oy), base) # usa el alpha del sujeto como mascara
else:
canvas.paste(base, (ox, oy))
return canvas
if __name__ == "__main__":
import sys
from PIL import Image
if len(sys.argv) < 3:
print("uso: crop_to_content.py <src> <dst> [pad_ratio]", file=sys.stderr)
sys.exit(2)
src, dst = sys.argv[1], sys.argv[2]
pr = float(sys.argv[3]) if len(sys.argv) > 3 else 0.06
with Image.open(src) as im:
out = crop_to_content(im, pad_ratio=pr)
out.save(dst)
print(f"ok: {src} -> {dst} {out.size} {out.mode}")
+112
View File
@@ -0,0 +1,112 @@
"""Tests de crop_to_content (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.crop_to_content import crop_to_content # noqa: E402
def _rgba_subject_in_corner(canvas=256, box=40, ox=8, oy=8):
"""RGBA con un rectangulo opaco rojo en una esquina, resto transparente."""
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
arr[oy:oy + box, ox:ox + box, 0] = 220 # R
arr[oy:oy + box, ox:ox + box, 3] = 255 # alpha opaco
return Image.fromarray(arr, "RGBA")
def _rgba_subject_centered(canvas=256, fill_ratio=0.9):
"""RGBA con un rectangulo opaco que llena ~fill_ratio del lienzo, centrado."""
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
side = int(canvas * fill_ratio)
o = (canvas - side) // 2
arr[o:o + side, o:o + side, 1] = 200 # G
arr[o:o + side, o:o + side, 3] = 255
return Image.fromarray(arr, "RGBA")
def _rgb_subject_on_bg(canvas=200, box=50, ox=10, oy=10, bg=(255, 255, 255)):
"""RGB con un cuadrado de color sobre fondo plano (sin alpha)."""
arr = np.zeros((canvas, canvas, 3), dtype=np.uint8)
arr[:, :] = bg
arr[oy:oy + box, ox:ox + box] = (0, 0, 200) # sujeto azul
return Image.fromarray(arr, "RGB")
def _alpha_bbox_coverage(img, threshold=10):
"""Fraccion del lado que ocupa el bbox del contenido (alpha>threshold)."""
a = np.asarray(img.convert("RGBA"))[..., 3]
ys, xs = np.where(a > threshold)
if xs.size == 0:
return 0.0
bw = xs.max() - xs.min() + 1
bh = ys.max() - ys.min() + 1
return max(bw, bh) / max(img.size)
def test_golden_corner_subject_fills_frame():
"""Sujeto en la esquina -> tras crop ocupa casi todo el frame (square)."""
img = _rgba_subject_in_corner()
before = _alpha_bbox_coverage(img)
out = crop_to_content(img, pad_ratio=0.06, square=True)
after = _alpha_bbox_coverage(out)
assert out.mode == "RGBA"
assert out.size[0] == out.size[1] # cuadrado
assert before < 0.25 # antes diminuto
assert after >= 0.80 # despues llena el frame
def test_edge_centered_subject_not_overcropped():
"""Sujeto ya centrado que llena ~90%: la cobertura se mantiene alta, no se rompe."""
img = _rgba_subject_centered(fill_ratio=0.9)
out = crop_to_content(img, pad_ratio=0.06, square=True)
assert out.size[0] == out.size[1]
assert _alpha_bbox_coverage(out) >= 0.80
def test_edge_rgb_background_bbox():
"""RGB con fondo plano: detecta el sujeto por diff-fondo y lo cuadra."""
img = _rgb_subject_on_bg()
out = crop_to_content(img, pad_ratio=0.05, square=True)
assert out.mode == "RGB"
assert out.size[0] == out.size[1]
# El sujeto azul debe ocupar buena parte del lienzo recortado.
arr = np.asarray(out)
is_subject = (arr[..., 2] > 120) & (arr[..., 0] < 80)
cov = is_subject.sum() / (out.size[0] * out.size[1])
assert cov >= 0.4
def test_edge_no_square_only_crops():
"""square=False: recorta al bbox + margen, sin forzar cuadrado."""
img = _rgba_subject_in_corner(box=40)
out = crop_to_content(img, pad_ratio=0.0, square=False)
# bbox del sujeto es 40x40 -> sin pad ni cuadrar, sale 40x40.
assert out.size == (40, 40)
def test_error_all_transparent_returns_copy():
"""Imagen toda transparente: no crashea, devuelve copia intacta (mismo tamano)."""
arr = np.zeros((128, 128, 4), dtype=np.uint8) # alpha 0 en todo
img = Image.fromarray(arr, "RGBA")
out = crop_to_content(img)
assert out.size == (128, 128)
assert np.asarray(out)[..., 3].max() == 0
def test_error_none_raises():
try:
crop_to_content(None)
assert False, "deberia lanzar ValueError"
except ValueError as e:
assert "None" in str(e)
def test_does_not_mutate_input():
img = _rgba_subject_in_corner()
snapshot = np.asarray(img).copy()
crop_to_content(img)
assert np.array_equal(np.asarray(img), snapshot)
+92
View File
@@ -0,0 +1,92 @@
---
name: pixeloe_downscale
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def pixeloe_downscale(src_path: str, dst_path: str, *, mode: str = 'contrast', target_size: int = 64, patch_size: int = 16, thickness: int = 2, color_matching: bool = True, no_upscale: bool = True, comfy_python: str | None = None) -> dict"
description: "Downscale contrast-aware (Contrast-Aware Outline Expansion de Kohaku, lib `pixeloe`) que colapsa una ilustracion a un grid de pixel-art pequeno (64 personajes, 32 iconos) conservando contornos/silueta. Es la etapa de downscale del metodo SOTA de pixel-art (report 0215). NO cuantiza la paleta (eso lo hace despues comfyui_pixelize_image). Resuelve el gotcha de que `pixeloe` solo vive en el venv de ComfyUI con un 'bridge' de interprete: si falta en el interprete actual, re-ejecuta su nucleo por subprocess con el python de ComfyUI. No-throw: todo error viaja en `error`. Determinista; impura por I/O de disco + subprocess. Devuelve {ok, out_path, size, mode, target_size, via, error}."
tags: [comfyui, gamedev-2d, pixelart, ml, pixeloe, downscale, contrast-aware, image, bridge]
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/...). Si no existe -> ok=False con error."
- name: dst_path
desc: "ruta del PNG de salida; se crea el directorio padre si falta."
- name: mode
desc: "algoritmo de downscale de pixeloe: 'contrast' (SOTA, conserva silueta), 'bicubic', 'nearest', 'center' o 'k-centroid'. keyword-only."
- name: target_size
desc: "lado del grid resultante en pixeles (64 para personajes, 32 para iconos). keyword-only."
- name: patch_size
desc: "tamano del patch que pixeloe colapsa por celda del grid. keyword-only."
- name: thickness
desc: "grosor de la expansion de contorno (outline expansion). keyword-only."
- name: color_matching
desc: "corrige el color de cada celda contra el original si True. keyword-only."
- name: no_upscale
desc: "True devuelve el grid real target_size x target_size (lo habitual, para luego cuantizar); False re-escala al tamano original con pixeles duros (preview). keyword-only."
- name: comfy_python
desc: "ruta a un interprete con `pixeloe` para el bridge cuando el actual no la tiene. Si None: COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen escrita), mode (str usado), target_size (int pedido), via ('inproc' si pixeloe estaba en este interprete, 'bridge' si se delego por subprocess) y error (str, vacio si OK). No lanza excepciones."
tested: true
tests: [test_golden_downscale_64_or_clean_degrade, test_edge_target_size_32, test_edge_mode_nearest_no_color_matching, test_error_missing_src_no_throw, test_error_no_interpreter_with_pixeloe]
test_file_path: "python/functions/ml/pixeloe_downscale_test.py"
file_path: "python/functions/ml/pixeloe_downscale.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.pixeloe_downscale import pixeloe_downscale
# Colapsa el render del caballero (1024x1024) a un grid de pixel-art 64x64
# conservando la silueta. NO cuantiza paleta todavia.
res = pixeloe_downscale(
os.path.expanduser("~/ComfyUI/output/pixel_compare/knight_base_00001_.png"),
"/tmp/knight_grid64.png",
mode="contrast", target_size=64, no_upscale=True,
)
# {'ok': True, 'out_path': '/tmp/knight_grid64.png', 'size': [64, 64],
# 'mode': 'contrast', 'target_size': 64, 'via': 'bridge', 'error': ''}
# Despues: dureza de color (cuantizacion) con la funcion hermana.
from ml.comfyui_pixelize_image import comfyui_pixelize_image
comfyui_pixelize_image("/tmp/knight_grid64.png", "/tmp/knight_q16.png",
downscale=1, colors=16, upscale_back=False)
```
## Cuando usarla
Primera etapa del metodo SOTA de pixel-art: cuando ya tienes una ilustracion (render
SDXL/Flux, sprite, foto) y quieres reducirla a un grid de pixel-art chico **sin perder
los contornos** (lo que arruina un resize NEAREST/lanczos normal). Usala **antes** de
la cuantizacion dura de paleta con `comfyui_pixelize_image` (paso de color). `target_size`
64 para personajes, 32 para iconos. Si solo necesitas el resize+cuantizado rapido sin
contornos finos, `comfyui_pixelize_image` sola basta; para el resultado ganador, encadena
`pixeloe_downscale` -> `comfyui_pixelize_image`.
## Gotchas
- **`pixeloe` solo esta en el venv de ComfyUI** (`~/ComfyUI/.venv`), no en el del registry.
La funcion lo resuelve con un *bridge*: si `import pixeloe` falla, re-ejecuta su nucleo
por subprocess con el python de ComfyUI. El campo `via` dice si fue `inproc` o `bridge`.
- **El modulo es `pixeloe.legacy.pixelize`**, no `pixeloe.pixelize` (ruta vieja eliminada).
- **El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba roto** por ese cambio de import;
por eso aqui se llama la lib directa (numpy + PIL, sin cv2).
- **NO cuantiza la paleta**: el resultado conserva muchos colores; la dureza retro la aplica
despues `comfyui_pixelize_image`. No esperes pocos colores en la salida.
- **No-throw**: src inexistente, pixeloe ausente en todos los interpretes, o subprocess
caido -> `ok=False` con `error` explicado, nunca excepcion. El pipeline llamante hace
fallback mirando `ok`.
- Resolucion del interprete del bridge: arg `comfy_python` -> env `COMFY_PYTHON` ->
`~/ComfyUI/.venv/bin/python3` (el primero que exista como archivo).
- `no_upscale=True` (default) devuelve el grid real `target_size x target_size`; con `False`
vuelve al tamano original con pixeles duros (preview), no el grid pequeno.
+322
View File
@@ -0,0 +1,322 @@
"""pixeloe_downscale — downscale contrast-aware a un grid de pixel-art (etapa SOTA).
Colapsa una ilustracion a un grid de pixel-art pequeno (p.ej. 64x64) usando la
libreria `pixeloe` de Kohaku (Contrast-Aware Outline Expansion), el metodo SOTA
para preservar contornos/silueta al reducir. Es la etapa de *downscale* del
metodo ganador de pixel-art (ver report 0215): NO cuantiza la paleta — esa dureza
de color la aplica despues otra funcion (`comfyui_pixelize_image`).
Gotcha de entorno (resuelto con un "bridge" de interprete): la lib `pixeloe` solo
esta instalada en el venv de ComfyUI (`~/ComfyUI/.venv`), no en el venv del
registry, y su modulo vive en `pixeloe.legacy.pixelize` (la ruta vieja
`pixeloe.pixelize` ya no existe). Por eso la funcion:
1. Intenta `import pixeloe` en el interprete actual y ejecuta el nucleo directo.
2. Si falta (`ModuleNotFoundError`), re-ejecuta este mismo archivo como subprocess
(`python pixeloe_downscale.py --bridge <json>`) con un interprete que SI la
tenga, parseando la unica linea JSON que ese hijo imprime a stdout.
3. Si no hay ningun interprete con pixeloe, devuelve ok=False (sin excepcion);
el pipeline que la llama hara fallback.
La funcion es no-throw: cualquier error se captura y viaja en el campo `error`.
Determinista; impura solo por la lectura/escritura de disco y el subprocess.
"""
import os
def _resolve_comfy_python(comfy_python):
"""Devuelve el primer interprete candidato que exista como archivo, o None.
Orden: arg comfy_python -> env COMFY_PYTHON -> ~/ComfyUI/.venv/bin/python3.
"""
candidates = []
if comfy_python:
candidates.append(comfy_python)
env = os.environ.get("COMFY_PYTHON")
if env:
candidates.append(env)
candidates.append(os.path.expanduser("~/ComfyUI/.venv/bin/python3"))
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def _run_core(src_path, dst_path, mode, target_size, patch_size, thickness,
color_matching, no_upscale):
"""Nucleo no-throw: requiere `pixeloe` importable EN ESTE interprete.
Lee src como RGB uint8 (numpy + PIL, sin cv2), llama
`pixeloe.legacy.pixelize.pixelize` y guarda el resultado como PNG. Devuelve el
dict de resultado. NO lanza excepciones: las captura en `error`.
"""
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "inproc",
"error": "",
}
try:
import numpy as np
from PIL import Image
except Exception as exc: # noqa: BLE001 - degradacion limpia, no relanzar
out["error"] = f"numpy/PIL no disponible en este interprete: {exc}"
return out
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
try:
from pixeloe.legacy.pixelize import pixelize
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo importar pixeloe.legacy.pixelize: {exc}"
return out
try:
img = np.array(Image.open(src_path).convert("RGB")) # HxWx3 uint8
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
return out
try:
res = pixelize(
img,
mode=mode,
target_size=int(target_size),
patch_size=int(patch_size),
thickness=int(thickness),
contrast=1.0,
saturation=1.0,
color_matching=bool(color_matching),
no_upscale=bool(no_upscale),
)
except TypeError as exc:
# Firma de pixelize distinta a la esperada: reseñar, no relanzar.
out["error"] = f"pixelize rechazo los kwargs (firma distinta?): {exc}"
return out
except Exception as exc: # noqa: BLE001
out["error"] = f"pixelize fallo: {exc}"
return out
try:
arr = np.asarray(res)
result_img = Image.fromarray(arr)
dst_dir = os.path.dirname(os.path.abspath(dst_path))
os.makedirs(dst_dir, exist_ok=True)
result_img.save(dst_path)
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
return out
out.update(ok=True, out_path=dst_path, size=list(result_img.size), error="")
return out
def _run_via_bridge(interp, src_path, dst_path, mode, target_size, patch_size,
thickness, color_matching, no_upscale):
"""Ejecuta el nucleo en otro interprete (que tiene pixeloe) via subprocess.
Corre `interp <este_archivo> --bridge <json_args>` y parsea la ultima linea de
stdout que sea JSON valido (pixeloe puede emitir ruido antes). No-throw.
"""
import json
import subprocess
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "bridge",
"error": "",
}
args = {
"src_path": src_path,
"dst_path": dst_path,
"mode": mode,
"target_size": int(target_size),
"patch_size": int(patch_size),
"thickness": int(thickness),
"color_matching": bool(color_matching),
"no_upscale": bool(no_upscale),
}
try:
proc = subprocess.run(
[interp, os.path.abspath(__file__), "--bridge", json.dumps(args)],
capture_output=True,
text=True,
timeout=600,
)
except Exception as exc: # noqa: BLE001
out["error"] = f"fallo el subprocess bridge ({interp}): {exc}"
return out
if proc.returncode != 0:
tail = (proc.stderr or "").strip()[-500:]
out["error"] = f"bridge salio con codigo {proc.returncode}: {tail}"
return out
# Parsea de atras hacia delante la primera linea que sea JSON valido.
parsed = None
for ln in reversed((proc.stdout or "").splitlines()):
ln = ln.strip()
if not ln:
continue
try:
parsed = json.loads(ln)
break
except Exception: # noqa: BLE001 - linea de ruido, sigue probando
continue
if parsed is None:
tail = (proc.stderr or "").strip()[-300:]
out["error"] = f"bridge no produjo salida JSON. stderr: {tail}"
return out
parsed["via"] = "bridge"
return parsed
def pixeloe_downscale(
src_path: str,
dst_path: str,
*,
mode: str = "contrast",
target_size: int = 64,
patch_size: int = 16,
thickness: int = 2,
color_matching: bool = True,
no_upscale: bool = True,
comfy_python: str | None = None,
) -> dict:
"""Downscale contrast-aware de una imagen a un grid de pixel-art (no cuantiza).
Args:
src_path: ruta de la imagen de entrada (PNG/JPG/...).
dst_path: ruta del PNG de salida (se crea el directorio si falta).
mode: algoritmo de downscale de pixeloe: "contrast" (SOTA, conserva
silueta), "bicubic", "nearest", "center" o "k-centroid". keyword-only.
target_size: lado del grid resultante en pixeles (64 personajes, 32
iconos). keyword-only.
patch_size: tamano del patch que pixeloe colapsa por celda. keyword-only.
thickness: grosor de la expansion de contorno (outline). keyword-only.
color_matching: corrige el color de cada celda contra el original si True.
keyword-only.
no_upscale: True devuelve el grid real target_size x target_size (lo
habitual para luego cuantizar); False re-escala al tamano original con
pixeles duros (preview). keyword-only.
comfy_python: ruta a un interprete con `pixeloe` para el bridge cuando el
actual no la tiene. Si None, se prueba COMFY_PYTHON y luego
~/ComfyUI/.venv/bin/python3. keyword-only.
Returns:
dict con:
- ok (bool): True si se hizo el downscale y se guardo el PNG.
- out_path (str): ruta del PNG generado.
- size (list[int]): [w, h] de la imagen escrita.
- mode (str): modo de downscale usado.
- target_size (int): lado del grid pedido.
- via (str): "inproc" si pixeloe estaba en este interprete, "bridge" si se
delego a otro interprete por subprocess.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "",
"error": "",
}
try:
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
# 1. pixeloe disponible en el interprete actual -> nucleo directo.
has_local = True
try:
import pixeloe # noqa: F401
except ModuleNotFoundError:
has_local = False
except Exception: # noqa: BLE001 - pixeloe presente pero roto -> bridge
has_local = False
if has_local:
res = _run_core(
src_path, dst_path, mode, int(target_size), int(patch_size),
int(thickness), bool(color_matching), bool(no_upscale),
)
res["via"] = "inproc"
return res
# 2. Bridge a un interprete que tenga pixeloe.
interp = _resolve_comfy_python(comfy_python)
if interp is None:
out["error"] = (
"pixeloe no disponible: no se encontro ningun interprete con "
"pixeloe (pasa comfy_python, define COMFY_PYTHON, o instala "
"~/ComfyUI/.venv)"
)
return out
return _run_via_bridge(
interp, src_path, dst_path, mode, int(target_size), int(patch_size),
int(thickness), bool(color_matching), bool(no_upscale),
)
except Exception as exc: # noqa: BLE001 - contrato no-throw
out["error"] = f"error inesperado: {exc}"
return out
if __name__ == "__main__":
import json
import sys
if "--bridge" in sys.argv:
# Modo bridge: ejecuta el nucleo y emite UNA linea JSON a stdout.
_idx = sys.argv.index("--bridge")
_payload = sys.argv[_idx + 1] if len(sys.argv) > _idx + 1 else "{}"
try:
_a = json.loads(_payload)
except Exception as _exc: # noqa: BLE001
print(json.dumps({
"ok": False, "out_path": "", "size": [0, 0], "mode": "",
"target_size": 0, "via": "inproc",
"error": f"payload --bridge invalido: {_exc}",
}))
sys.exit(0)
_res = _run_core(
_a.get("src_path", ""),
_a.get("dst_path", ""),
_a.get("mode", "contrast"),
_a.get("target_size", 64),
_a.get("patch_size", 16),
_a.get("thickness", 2),
_a.get("color_matching", True),
_a.get("no_upscale", True),
)
print(json.dumps(_res))
sys.exit(0)
# Modo CLI normal.
if len(sys.argv) < 3:
print("uso: pixeloe_downscale.py <src> <dst> [target_size] [mode]",
file=sys.stderr)
sys.exit(2)
_src, _dst = sys.argv[1], sys.argv[2]
_ts = int(sys.argv[3]) if len(sys.argv) > 3 else 64
_md = sys.argv[4] if len(sys.argv) > 4 else "contrast"
print(json.dumps(pixeloe_downscale(_src, _dst, target_size=_ts, mode=_md),
indent=2))
@@ -0,0 +1,122 @@
"""Tests de pixeloe_downscale — tolerantes al entorno.
El venv del registry NO trae `pixeloe`, asi que estas pruebas ejercitan el
"bridge" de interprete (subprocess al python de ComfyUI, que si la tiene). Si
tampoco hay ningun interprete con pixeloe disponible, la funcion debe degradar
limpiamente: ok=False con error no vacio y SIN lanzar excepcion.
Por eso cada test PASA en los dos escenarios:
- pixeloe disponible (inproc o via bridge): assert sobre el resultado real.
- pixeloe ausente en todos lados: assert sobre la degradacion no-throw.
Asi la suite es verde tanto en este PC (ComfyUI presente) como en uno sin ComfyUI,
y el contrato "no-throw" queda cubierto en ambos.
"""
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.pixeloe_downscale import pixeloe_downscale # noqa: E402
def _shapes_png(path, w=256, h=256):
"""PNG 256x256 RGB con un gradiente + formas (contraste con silueta clara)."""
yy, xx = np.mgrid[0:h, 0:w]
arr = np.zeros((h, w, 3), dtype=np.uint8)
arr[..., 0] = (xx * 255 // max(1, w - 1)).astype(np.uint8) # gradiente rojo
arr[..., 1] = (yy * 255 // max(1, h - 1)).astype(np.uint8) # gradiente verde
# Bloque azul central: borde duro para que el modo "contrast" tenga silueta.
arr[h // 4:3 * h // 4, w // 4:3 * w // 4, 2] = 255
Image.fromarray(arr, "RGB").save(path)
return path
def test_golden_downscale_64_or_clean_degrade(tmp_path):
"""Golden: 256x256 -> grid 64x64 (no_upscale). Si pixeloe no esta -> ok=False limpio."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "grid64.png")
res = pixeloe_downscale(src, dst, target_size=64, no_upscale=True)
assert isinstance(res, dict)
if res["ok"]:
assert os.path.isfile(dst)
assert res["size"] == [64, 64] # no_upscale=True -> grid real
assert res["error"] == ""
assert res["via"] in ("inproc", "bridge")
assert res["mode"] == "contrast"
assert res["target_size"] == 64
else:
# Degradacion limpia: sin pixeloe en ningun interprete.
assert res["error"] != ""
assert res["via"] in ("", "bridge", "inproc")
def test_edge_target_size_32(tmp_path):
"""Edge: grid de 32 (iconos). size==[32,32] cuando pixeloe esta presente."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "grid32.png")
res = pixeloe_downscale(src, dst, target_size=32, no_upscale=True)
if res["ok"]:
assert res["size"] == [32, 32]
assert res["target_size"] == 32
assert os.path.isfile(dst)
else:
assert res["error"] != ""
def test_edge_mode_nearest_no_color_matching(tmp_path):
"""Edge: otro modo + color_matching off; debe seguir produciendo el grid o degradar."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "near.png")
res = pixeloe_downscale(
src, dst, mode="nearest", target_size=64,
color_matching=False, no_upscale=True,
)
assert isinstance(res, dict)
if res["ok"]:
assert res["mode"] == "nearest"
assert res["size"] == [64, 64]
else:
assert res["error"] != ""
def test_error_missing_src_no_throw(tmp_path):
"""Error path: src inexistente -> ok=False, error explica, sin excepcion."""
res = pixeloe_downscale(
str(tmp_path / "nope.png"), str(tmp_path / "o.png"), target_size=64,
)
assert res["ok"] is False
assert "no existe" in res["error"]
assert res["size"] == [0, 0]
def test_error_no_interpreter_with_pixeloe(tmp_path):
"""Error path: forzar comfy_python invalido cuando el actual no tiene pixeloe.
Si el interprete que corre el test YA tiene pixeloe (inproc), el comfy_python
invalido se ignora y la llamada puede salir ok=True; el test sigue siendo
valido (no-throw). Si NO lo tiene, no hay ningun interprete con pixeloe y debe
devolver ok=False con error, nunca lanzar.
"""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "o.png")
res = pixeloe_downscale(
src, dst, target_size=64, comfy_python="/no/such/python-interpreter",
)
assert isinstance(res, dict)
try:
import pixeloe # noqa: F401
has_local = True
except Exception: # noqa: BLE001
has_local = False
if has_local:
# pixeloe en el interprete del test -> ruta inproc, comfy_python ignorado.
assert res["ok"] is True
else:
# comfy_python invalido + env vacio: si ~/ComfyUI/.venv existe, puede
# bridgear y salir ok; si no, ok=False con error. Ambos no-throw.
assert res["ok"] in (True, False)
if not res["ok"]:
assert res["error"] != ""
@@ -0,0 +1,164 @@
---
name: comfyui_pixelart_real_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.1.0"
purity: impure
signature: "def comfyui_pixelart_real_oneshot(subject: str, *, size: int = 64, colors: int = 16, engine: str = \"pixeloe\", palette=None, server: str = \"127.0.0.1:8188\", dest_dir: str = \"~/ComfyUI/output\", seed: int = 0, negative: str | None = None, mode: str = \"contrast\", patch_size: int = 16, thickness: int = 2, fill_frame: bool = True, transparent: bool = True, autocrop: bool = True, crop_pad_ratio: float = 0.06, rembg_model: str = \"u2net\", upscale_preview: int = 512, keep_base: bool = True, comfy_python: str | None = None, wait_timeout: float = 300.0, filename_prefix: str = \"pixelart_real\", **gen_kwargs) -> dict"
description: "Pipeline one-shot prompt de texto -> sprite pixel-art REAL (grid duro + paleta limitada) en disco, con fondo transparente y sujeto que llena el frame. Materializa el metodo ganador del report 0215, ahora alpha-aware: generar a alta-res con SDXL + LoRA SDXL_pixel-art (rembg recorta el fondo si transparent), AUTOCROP al bbox del contenido + cuadrado (el sujeto llena el frame, no diminuto), downscale contrast-aware con PixelOE (engine=pixeloe, sprites; alpha recombinado aparte porque PixelOE trabaja en RGB) o nearest (tiles), y cuantizacion dura alpha-aware con comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy). Salida PNG RGBA con transparencia real. Sweet-spot 64px personajes, 32px iconos. Fallback automatico pixeloe->nearest. Compone build_pixelart + submit + wait + fetch + crop_to_content + pixeloe_downscale + pixelize_image. Impuro: HTTP + disco."
tags: [comfyui, gamedev-2d, pixelart, pipelines, sprite, launcher, alpha, transparent, autocrop]
uses_functions: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: error_py_core
imports: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
params:
- name: subject
desc: "Prompt positivo (lo que se quiere ver: 'pixel art knight, full body, side view'). No puede estar vacio."
- name: size
desc: "Lado del grid final en pixeles. 64 personajes/sprites, 32 iconos/objetos simples. keyword-only."
- name: colors
desc: "Numero de colores de la paleta libre (MEDIANCUT) cuando palette es None. keyword-only."
- name: engine
desc: "'pixeloe' (downscale contrast-aware, sujetos con silueta) o 'nearest' (downscale simple, tiles/texturas). Fallback automatico a nearest si pixeloe falla. keyword-only."
- name: palette
desc: "None (paleta libre a `colors`), nombre builtin ('pico-8', 'nes', 'game-boy') o lista de hex. Una paleta fija ignora `colors`. keyword-only."
- name: server
desc: "host:port del servidor ComfyUI (sin esquema). keyword-only."
- name: dest_dir
desc: "Directorio donde guardar los PNG (se expande ~). keyword-only."
- name: seed
desc: "Semilla del KSampler. keyword-only."
- name: negative
desc: "Prompt negativo; None usa el default de build_pixelart (evita blur/gradientes/anti-alias). keyword-only."
- name: mode
desc: "Modo de downscale de PixelOE ('contrast' SOTA, 'k-centroid', 'nearest', 'center', 'bicubic'); solo con engine='pixeloe'. keyword-only."
- name: patch_size
desc: "Tamano de patch de PixelOE (default 16). keyword-only."
- name: thickness
desc: "Grosor del outline expansion de PixelOE (default 2). keyword-only."
- name: fill_frame
desc: "Si True anade un hint de encuadre al subject para que el sujeto llene el frame (mejor detalle por pixel tras el downscale). keyword-only."
- name: transparent
desc: "Si True (default) genera con fondo recortado (rembg en el workflow) y produce sprite RGBA con transparencia real. False para tiles/texturas sin alpha (PNG opaco). keyword-only."
- name: autocrop
desc: "Si True (default) recorta la imagen base al bbox del contenido + cuadrado 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 no. keyword-only."
- name: crop_pad_ratio
desc: "Margen relativo que deja el autocrop alrededor del sujeto (0.06 = 6% del lado). keyword-only."
- name: rembg_model
desc: "Modelo Rembg para recortar el fondo ('u2net' general, 'isnet-anime' anime). Solo aplica si transparent. keyword-only."
- name: upscale_preview
desc: "Si > 0 escribe ademas un PNG re-escalado nearest a ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva. keyword-only."
- name: keep_base
desc: "Si True conserva el PNG base de alta resolucion; si False lo borra tras pixelizar. keyword-only."
- name: comfy_python
desc: "Ruta al interprete de ComfyUI (con la lib pixeloe); None autodetecta. keyword-only."
- name: wait_timeout
desc: "Segundos maximos esperando al server. keyword-only."
- name: filename_prefix
desc: "Prefijo de los archivos de salida. keyword-only."
- name: gen_kwargs
desc: "Params extra para comfyui_build_pixelart_workflow (width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...). keyword-only (**gen_kwargs)."
output: "dict {ok, out_path, out_path_upscaled, base_path, size, colors_final, engine_used, has_alpha, autocrop_applied, prompt_id, error}. out_path = PNG final size x size (RGBA si transparent); out_path_upscaled = preview re-escalado; has_alpha = True si lleva transparencia; autocrop_applied = True si el autocrop recorto la base; engine_used refleja el fallback (pixeloe->nearest). Si falla, ok=False y error explica en que paso. No-throw."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/comfyui_pixelart_real_oneshot.py"
---
## Ejemplo
```bash
# Sprite de personaje 64px: RGBA transparente + autocrop (sujeto llena el frame).
./fn run comfyui_pixelart_real_oneshot "pixel art knight, full body, centered"
```
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from pipelines.comfyui_pixelart_real_oneshot import comfyui_pixelart_real_oneshot
# (a) Sprite personaje 64px: fondo transparente + autocrop (defaults).
res = comfyui_pixelart_real_oneshot(
"pixel art knight, full body, centered",
size=64, colors=16, engine="pixeloe", seed=42,
transparent=True, autocrop=True, dest_dir="~/ComfyUI/output",
)
print(res["out_path"], res["colors_final"], res["has_alpha"], res["engine_used"])
# -> 64px RGBA, ~16 colores, has_alpha=True, esquinas transparentes, sujeto ~88% del frame
# (b) Icono 32px de un item (sprite con alpha).
res = comfyui_pixelart_real_oneshot(
"pixel art sword icon, single object",
size=32, colors=16, engine="pixeloe", seed=7,
)
# (c) Tile sin silueta -> nearest + paleta fija PICO-8, SIN transparencia.
res = comfyui_pixelart_real_oneshot(
"pixel art grass texture tile, top down, seamless",
size=64, engine="nearest", palette="pico-8",
transparent=False, autocrop=False, fill_frame=False,
)
```
## Cuando usarla
Cuando quieres pixel-art **de verdad** (grid duro + paleta limitada, verificable
por conteo de colores), no la salida cruda de la difusion (que parece pixelada
pero tiene decenas de miles de colores y bordes con anti-aliasing). Una sola
llamada hace generar -> recortar -> downscale -> cuantizar. Para **sprites de
sujeto** (personajes, criaturas, objetos) deja los defaults `transparent=True` +
`autocrop=True`: salen RGBA con fondo transparente y el sujeto llena el frame. Usa
`engine="pixeloe"` para conservar la silueta. Para **tiles/texturas/fondos** sin
contorno usa `engine="nearest"`, `transparent=False`, `autocrop=False` (mas barato,
CPU puro, sin alpha). 64px es el sweet-spot de personajes; 32px solo para
iconos/objetos simples.
## Gotchas
- Impuro: requiere el **servidor ComfyUI vivo** en `server` (default
`127.0.0.1:8188`) y los modelos instalados (SDXL Juggernaut + LoRA
`SDXL_pixel-art` + `SDXL_lcm-lora`). Si esta caido, falla en submit con
`ok=False` y el error de conexion (nunca lanza).
- `engine="pixeloe"` necesita la lib `pixeloe`, que vive en el venv de ComfyUI
(no en el del registry). `pixeloe_downscale` hace el puente de interprete
automaticamente; si no la encuentra, el pipeline **cae a `nearest`** y lo
reporta en `engine_used` + `error` (no aborta).
- El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba **roto** por un import
obsoleto (`pixeloe.pixelize` -> ahora `pixeloe.legacy.pixelize`); por eso el
pipeline usa la lib directa via `pixeloe_downscale`, no el nodo del server.
- `dest_dir` es un **directorio** (se crea si no existe). Los nombres de salida
son `<prefix>_<size>px_<engine>_<paleta|qN>.png` y `..._up.png` (preview).
- Una **paleta fija** (`pico-8`/`nes`/`game-boy`/lista hex) ignora `colors` y
puede dar menos colores que `colors` si el sujeto no cubre toda la paleta.
- Encuadre: si el sujeto ocupa poca area del frame, a 64/32px queda diminuto. Dos
mecanismos lo evitan: `fill_frame=True` (hint al prompt) y, sobre todo,
`autocrop=True` (default) que recorta al bbox real del contenido + cuadrado tras
generar. Con autocrop el sujeto llena ~85-90% del frame aunque el prompt no lo
encuadre perfecto.
- **transparencia (v1.1.0)**: `transparent=True` (default) mete el nodo `Image
Rembg` en el workflow (requiere ese custom node en el server) y produce PNG
**RGBA**. Las 4 esquinas salen `alpha==0`. Para tiles/fondos opacos: `transparent=False`.
- **alpha a traves de PixelOE**: PixelOE trabaja en RGB y pierde el alpha; el
pipeline downscalea el alpha del recorte por separado (nearest al mismo `size`) y
lo recombina sobre el grid antes de cuantizar. Por eso el sprite final conserva la
transparencia con `engine="pixeloe"`.
- Si la generacion sale **toda transparente** (rembg no detecto sujeto), no crashea:
el autocrop deja la imagen sin recortar y el resto del pipeline sigue (sprite
vacio, `colors_final` bajo). Revisa el `subject` en ese caso.
- No reintenta el sampler: para mejor toma, varia `seed`.
## Capability growth log
- v1.1.0 (2026-06-28) — sprite-fix: `transparent`/`autocrop`/`crop_pad_ratio`/
`rembg_model`. Arregla los 2 bugs reportados: (1) sprite diminuto -> autocrop al
bbox del contenido + cuadrado antes del downscale (sujeto pasa de ~48% a ~88% del
frame); (2) sin transparencia -> rembg en el workflow + cuantizacion alpha-aware +
alpha recombinado tras PixelOE -> PNG RGBA con esquinas alpha==0. Anade
`crop_to_content` a la composicion. Verificado en GPU (knight 64px).
- v1.0.0 (2026-06-28) — pipeline inicial. Materializa el metodo ganador del
report 0215 (PixelOE contrast downscale -> cuantizacion dura). Compone
build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image
(issue 0087).
@@ -0,0 +1,387 @@
"""comfyui_pixelart_real_oneshot — prompt de texto -> sprite pixel-art REAL en disco.
Pipeline one-shot (issue 0087) que materializa el metodo ganador de la
investigacion (report 0215): la difusion NO sabe pintar pixel-perfect (su salida
tiene decenas de miles de colores y bordes con anti-aliasing — pixel-art FALSO),
asi que el pixel-art de verdad es siempre post-proceso en dos ejes: colapsar a un
grid duro y limitar la paleta. El metodo ganador combina:
1. Generar a alta resolucion con el look pixel-art (SDXL Juggernaut + LoRA
SDXL_pixel-art), via comfyui_build_pixelart_workflow.
2. Downscale contrast-aware con PixelOE (pixeloe_downscale): elige el pixel mas
representativo de cada zona y engrosa contornos -> silueta legible. Es lo que
distingue un sprite reconocible de una mancha. Solo para sujetos con silueta
(engine="pixeloe"); para tiles/texturas sin contorno, un downscale nearest
simple basta (engine="nearest") y es mas barato.
3. Cuantizacion dura con 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 -> 16 colores exactos + 100% grid duro.
Resultado del combo verificado por PIL: grid duro perfecto + paleta limitada +
outline nitido. Sweet-spot: 64px personajes/sprites, 32px iconos/objetos simples.
Compone funciones del registry, no reescribe su logica:
comfyui_build_pixelart_workflow_py_ml (workflow SDXL + LoRA pixel-art)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco, imagen base)
pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe)
comfyui_pixelize_image_py_ml (cuantizacion dura + nearest fallback)
Pipeline impuro: red (HTTP a ComfyUI) + escritura en disco. No-throw: cualquier
fallo se captura y se devuelve en el dict de estado (campo error).
"""
from __future__ import annotations
import os
import sys
# 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_build_pixelart_workflow import comfyui_build_pixelart_workflow
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_pixelize_image import comfyui_pixelize_image
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.crop_to_content import crop_to_content
from ml.pixeloe_downscale import pixeloe_downscale
# Sufijo de encuadre: empuja al sujeto a llenar el frame para que tras el
# downscale conserve detalle por pixel (gotcha del report: un sujeto que ocupa el
# 25% del frame queda diminuto a 64px). Solo se anade si no esta ya presente.
_FRAME_HINT = "full body, centered, fills frame, no margins"
def _frame_subject(subject: str, fill_frame: bool) -> str:
"""Anade el hint de encuadre al subject si fill_frame y no esta ya."""
if not fill_frame:
return subject
low = subject.lower()
if "fills frame" in low or "full body" in low or "centered" in low:
return subject
return f"{subject}, {_FRAME_HINT}"
def comfyui_pixelart_real_oneshot(
subject: str,
*,
size: int = 64,
colors: int = 16,
engine: str = "pixeloe",
palette=None,
server: str = "127.0.0.1:8188",
dest_dir: str = "~/ComfyUI/output",
seed: int = 0,
negative: str | None = None,
mode: str = "contrast",
patch_size: int = 16,
thickness: int = 2,
fill_frame: bool = True,
transparent: bool = True,
autocrop: bool = True,
crop_pad_ratio: float = 0.06,
rembg_model: str = "u2net",
upscale_preview: int = 512,
keep_base: bool = True,
comfy_python: str | None = None,
wait_timeout: float = 300.0,
filename_prefix: str = "pixelart_real",
**gen_kwargs,
) -> dict:
"""Genera un sprite pixel-art REAL desde un prompt de texto, end-to-end.
Args:
subject: prompt positivo (lo que se quiere ver: "pixel art knight, full
body, side view", etc.). No puede estar vacio.
size: lado del grid final en pixeles (64 personajes/sprites, 32 iconos).
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 reporta 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.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest_dir: directorio donde guardar los PNG (se expande ~). keyword-only.
seed: semilla del KSampler. keyword-only.
negative: prompt negativo; None usa el default de build_pixelart
(evita blur/gradientes/anti-alias). 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.
fill_frame: si True, anade un hint de encuadre al subject para que el
sujeto llene el frame (mejor detalle por pixel tras el downscale).
keyword-only.
transparent: si True (default) genera con fondo recortado (rembg en el
workflow) y produce un sprite RGBA con transparencia real. Para
tiles/texturas que NO quieren alpha, pasar transparent=False (el sprite
sale RGB sobre fondo opaco). keyword-only.
autocrop: si True (default) recorta la imagen base al bounding box de su
contenido y la 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 no. keyword-only.
crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto
(0.06 = 6% del lado). keyword-only.
rembg_model: modelo Rembg para recortar el fondo ('u2net' general,
'isnet-anime' para anime). Solo aplica si transparent. keyword-only.
upscale_preview: si > 0, escribe ademas un PNG re-escalado nearest a
ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva.
keyword-only.
keep_base: si True conserva el PNG base de alta resolucion; si False lo
borra tras pixelizar. keyword-only.
comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe); None
autodetecta. keyword-only.
wait_timeout: segundos maximos esperando al server. keyword-only.
filename_prefix: prefijo de los archivos de salida. keyword-only.
**gen_kwargs: params extra para comfyui_build_pixelart_workflow
(width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...).
Returns:
dict con:
- ok (bool): True si se produjo el PNG final pixelizado.
- out_path (str): ruta del PNG final size x size.
- out_path_upscaled (str): ruta del preview re-escalado, o "" si off.
- base_path (str): ruta del PNG base de alta resolucion (o "" si se borro).
- size (int): lado real del PNG final.
- colors_final (int): numero de colores distintos en el resultado (en la
zona opaca si es RGBA).
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
- has_alpha (bool): True si el PNG final es RGBA con transparencia.
- autocrop_applied (bool): True si el autocrop recorto la imagen base.
- prompt_id (str): id del trabajo en ComfyUI.
- error (str): mensaje de error; vacio si OK.
"""
out = {
"ok": False, "out_path": "", "out_path_upscaled": "", "base_path": "",
"size": int(size), "colors_final": 0, "engine_used": engine,
"has_alpha": False, "autocrop_applied": False,
"prompt_id": "", "error": "",
}
if not subject or not subject.strip():
out["error"] = "subject vacio"
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
dest = os.path.expanduser(dest_dir)
try:
os.makedirs(dest, exist_ok=True)
except OSError as exc:
out["error"] = f"no se pudo crear dest_dir {dest!r}: {exc}"
return out
# --- Fase 1: generar la imagen base de alta resolucion (look pixel-art) ---
positive = _frame_subject(subject, fill_frame)
try:
if negative is None:
workflow = comfyui_build_pixelart_workflow(
positive, seed=seed, transparent=bool(transparent),
rembg_model=rembg_model,
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
)
else:
workflow = comfyui_build_pixelart_workflow(
positive, negative, seed=seed, transparent=bool(transparent),
rembg_model=rembg_model,
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
)
except (ValueError, TypeError) as exc:
out["error"] = f"build workflow fallo: {exc}"
return out
try:
sub = comfyui_submit_workflow(workflow, server=server)
prompt_id = sub["prompt_id"]
out["prompt_id"] = prompt_id
except (RuntimeError, KeyError, OSError) as exc:
out["error"] = f"submit fallo (server {server} responde?): {exc}"
return out
try:
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (TimeoutError, RuntimeError, OSError) as exc:
out["error"] = f"wait fallo: {exc}"
return out
img = None
for node_out in outputs.values():
images = node_out.get("images") if isinstance(node_out, dict) else None
if images:
img = images[0]
break
if img is None:
out["error"] = f"el workflow no produjo imagenes (outputs={list(outputs)})"
return out
fetched = comfyui_fetch_output_image(
img["filename"], subfolder=img.get("subfolder", ""),
type_=img.get("type", "output"), server=server, dest_dir=dest,
)
if not fetched.get("ok"):
out["error"] = f"fetch de imagen base fallo: {fetched.get('error')}"
return out
base_path = fetched["path"]
out["base_path"] = base_path
# --- Fase 1b (opcional): autocrop al contenido + cuadrar (sujeto llena el frame). ---
# La imagen sobre la que se hace el downscale: la recortada si autocrop, o la base.
pre_ds_path = base_path
crop_path = ""
if autocrop:
crop_path = os.path.join(dest, f"{filename_prefix}_{size}px_crop.png")
try:
from PIL import Image
with Image.open(base_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 la base sin recortar.
crop_path = ""
pre_ds_path = base_path
if not out["error"]:
out["error"] = f"autocrop fallo (no critico): {exc}"
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
mid_path = os.path.join(dest, f"{filename_prefix}_{size}px_mid.png")
engine_used = engine
if engine == "pixeloe":
ds = pixeloe_downscale(
pre_ds_path, mid_path, mode=mode, target_size=int(size),
patch_size=patch_size, thickness=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 en el 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.
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. ---
final_tag = palette if isinstance(palette, str) else f"q{colors}"
final_path = os.path.join(
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}.png"
)
quant = comfyui_pixelize_image(
mid_path, final_path, downscale=1, colors=int(colors),
palette=palette, upscale_back=False, keep_alpha=bool(transparent),
)
if not quant.get("ok"):
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
return out
out["out_path"] = final_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
# --- Fase 3 (opcional): preview re-escalado nearest a pixeles duros. ---
if int(upscale_preview) > 0:
up_path = os.path.join(
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}_up.png"
)
try:
from PIL import Image
with Image.open(final_path) as fin:
prev_mode = "RGBA" if transparent else "RGB"
up = fin.convert(prev_mode).resize(
(int(upscale_preview), int(upscale_preview)), Image.NEAREST
)
up.save(up_path)
out["out_path_upscaled"] = up_path
except (ImportError, OSError) as exc:
# El preview es opcional: no invalida el resultado.
out["out_path_upscaled"] = ""
if not out["error"]:
out["error"] = f"preview upscale fallo (no critico): {exc}"
# Limpieza de intermedios (mid + crop temporal).
for tmp in (mid_path, crop_path):
if tmp:
try:
os.remove(tmp)
except OSError:
pass
if not keep_base:
try:
os.remove(base_path)
out["base_path"] = ""
except OSError:
pass
out["ok"] = True
return out
if __name__ == "__main__":
import json
res = comfyui_pixelart_real_oneshot(
"pixel art knight, full body, centered, game sprite",
size=64, colors=16, engine="pixeloe", seed=42,
transparent=True, autocrop=True,
dest_dir="/tmp/comfy_pixelart_real",
)
print(json.dumps(res, indent=2))