feat(gamedev): ronda 1 — pixelize + luma→alpha + export-godot (grupo gamedev)
Tres funciones CPU-only del lote gamedev 2D + 2 helpers puros + grupo de capacidad: - comfyui_pixelize_image_py_ml (impure): Fase 2 pixelart — downscale nearest + cuantizacion a N colores / paleta fija (game-boy/pico-8/nes) + re-upscale nearest. - comfyui_matting_luma_to_alpha_py_ml (impure): frame VFX sobre negro -> RGBA por luminancia ponderada (translucidos con additive blend). - comfyui_export_asset_to_godot_py_pipelines (impure): puente ComfyUI -> Godot 4 — copia a res://assets/<dir> por kind + .import por tipo + filtro Nearest si pixelart + reimport headless best-effort. Compone los 2 helpers puros. - godot_map_asset_dir_py_core, godot_clean_asset_name_py_core (pure): nucleos reutilizables del pipeline. - docs/capabilities/gamedev-2d.md + INDEX: grupo nuevo gamedev. Tests 33/33 verdes (offline PIL/numpy). Golden real verificado: asset de ~/ComfyUI/output -> /tmp/godot_test_proj con .import correcto y reimport headless real de Godot 4.7. Sin GPU, sin red, sin tocar proyectos del usuario. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: godot_clean_asset_name
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str"
|
||||
description: "Normaliza el nombre de archivo de un asset salido de ComfyUI (patron <prefijo>_NNNNN_.<ext>) a un nombre limpio y seguro para res://: snake_case, minusculas, sin el sufijo numerico _NNNNN_, sin espacios ni caracteres raros, conservando la extension. Pura: solo manipula el string, no toca disco. Pensada para el puente ComfyUI -> Godot."
|
||||
tags: [godot, gamedev, core, assets, naming]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: filename
|
||||
desc: "nombre o ruta del asset de origen (se toma solo el basename)."
|
||||
- name: override
|
||||
desc: "nombre base deseado sin extension; si se pasa se usa en lugar del nombre de origen (igual se normaliza a snake_case y se le anade la extension del origen). keyword-only."
|
||||
output: "nombre de archivo limpio 'snake_case.ext' (ext en minuscula; sin punto si el origen no tenia extension)."
|
||||
tested: true
|
||||
tests: [test_strips_comfyui_suffix, test_normalizes_spaces_and_case, test_takes_basename_from_path, test_override_name, test_empty_fallback]
|
||||
test_file_path: "python/functions/core/godot_clean_asset_name_test.py"
|
||||
file_path: "python/functions/core/godot_clean_asset_name.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from core.godot_clean_asset_name import godot_clean_asset_name
|
||||
|
||||
godot_clean_asset_name("svd_motion_hi_00001_.webp") # 'svd_motion_hi.webp'
|
||||
godot_clean_asset_name("/x/ComfyUI/output/bench_00042_.png") # 'bench.png'
|
||||
godot_clean_asset_name("x.png", override="explosion loop") # 'explosion_loop.png'
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al exportar un asset de ComfyUI a Godot, para renombrar el `<prefijo>_NNNNN_.<ext>`
|
||||
a un nombre semántico y seguro (lo usa `comfyui_export_asset_to_godot`). También
|
||||
para limpiar cualquier nombre de archivo antes de meterlo en `res://`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Solo quita el sufijo numérico `_NNNNN_` del **nombre de origen**; con `override`
|
||||
se respetan los dígitos finales (p.ej. `hero_v2` no pierde el `2`).
|
||||
- Reduce cualquier carácter no `[a-z0-9_]` a `_` y colapsa repetidos; un nombre que
|
||||
quede vacío cae a `"asset"`.
|
||||
- Conserva la extensión en minúscula; si el origen no tiene extensión, devuelve solo
|
||||
el nombre sin punto.
|
||||
@@ -0,0 +1,49 @@
|
||||
"""godot_clean_asset_name — normaliza el nombre de archivo de un asset para Godot.
|
||||
|
||||
Funcion pura: toma el nombre de un asset salido de ComfyUI (patron
|
||||
`<prefijo>_NNNNN_.<ext>`, p.ej. `svd_motion_hi_00001_.webp`) y devuelve un nombre
|
||||
limpio, semantico y seguro para `res://`: snake_case, minusculas, sin el sufijo
|
||||
numerico `_NNNNN_`, sin espacios ni caracteres raros, conservando la extension.
|
||||
No toca disco. Pensada para el puente ComfyUI -> Godot (renombrar al exportar).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
_NNNNN_SUFFIX = re.compile(r"_\d{3,}_?$") # sufijo numerico de ComfyUI: _00001_ / _00001
|
||||
_NON_SAFE = re.compile(r"[^a-z0-9_]+") # cualquier cosa que no sea snake_case
|
||||
_MULTI_US = re.compile(r"_{2,}")
|
||||
|
||||
|
||||
def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str:
|
||||
"""Limpia el nombre de un asset a snake_case seguro conservando la extension.
|
||||
|
||||
Args:
|
||||
filename: nombre o ruta del asset de origen (se toma solo el basename).
|
||||
override: nombre base deseado (sin extension); si se pasa, se usa en lugar
|
||||
del nombre de origen (igual se normaliza a snake_case y se le anade la
|
||||
extension del origen). keyword-only.
|
||||
|
||||
Returns:
|
||||
Nombre de archivo limpio "snake_case.ext" (ext en minuscula, sin punto si
|
||||
el origen no tenia extension).
|
||||
"""
|
||||
base = os.path.basename(filename or "")
|
||||
stem, ext = os.path.splitext(base)
|
||||
ext = ext.lower()
|
||||
|
||||
raw = override if override is not None else stem
|
||||
name = raw.strip().lower().replace(" ", "_").replace("-", "_")
|
||||
if override is None:
|
||||
name = _NNNNN_SUFFIX.sub("", name) # quita _00001_ solo del nombre de origen
|
||||
name = _NON_SAFE.sub("_", name)
|
||||
name = _MULTI_US.sub("_", name).strip("_")
|
||||
if not name:
|
||||
name = "asset"
|
||||
return f"{name}{ext}" if ext else name
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
print(godot_clean_asset_name(sys.argv[1] if len(sys.argv) > 1 else "svd_motion_hi_00001_.webp"))
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tests de godot_clean_asset_name (pura, offline)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from core.godot_clean_asset_name import godot_clean_asset_name # noqa: E402
|
||||
|
||||
|
||||
def test_strips_comfyui_suffix():
|
||||
assert godot_clean_asset_name("svd_motion_hi_00001_.webp") == "svd_motion_hi.webp"
|
||||
assert godot_clean_asset_name("3d_robot_mesh_00001_.glb") == "3d_robot_mesh.glb"
|
||||
|
||||
|
||||
def test_normalizes_spaces_and_case():
|
||||
assert godot_clean_asset_name("My Hero Sprite.PNG") == "my_hero_sprite.png"
|
||||
assert godot_clean_asset_name("fire-flare.png") == "fire_flare.png"
|
||||
|
||||
|
||||
def test_takes_basename_from_path():
|
||||
assert godot_clean_asset_name("/home/x/ComfyUI/output/bench_00042_.png") == "bench.png"
|
||||
|
||||
|
||||
def test_override_name():
|
||||
assert godot_clean_asset_name("svd_00001_.webp", override="explosion loop") == "explosion_loop.webp"
|
||||
# override conserva digitos finales (no es sufijo ComfyUI a quitar)
|
||||
assert godot_clean_asset_name("x.png", override="hero_v2") == "hero_v2.png"
|
||||
|
||||
|
||||
def test_empty_fallback():
|
||||
assert godot_clean_asset_name("_00001_.png") == "asset.png"
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: godot_map_asset_dir
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def godot_map_asset_dir(kind: str) -> str"
|
||||
description: "Mapea el tipo de asset (sprite, pixelart, tileset, vfx, sfx, music, model) a su subcarpeta canonica bajo res://assets/ de un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot. Pura: solo resuelve una ruta relativa POSIX, no toca disco. kind desconocido -> ValueError."
|
||||
tags: [godot, gamedev, core, assets, mapping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: kind
|
||||
desc: "tipo de asset: 'sprite', 'pixelart', 'tileset', 'vfx', 'sfx', 'music' o 'model' (case-insensitive, se ignoran espacios)."
|
||||
output: "subruta relativa POSIX bajo assets/ (p.ej. 'sprites', 'tilesets', 'audio/music', 'models')."
|
||||
tested: true
|
||||
tests: [test_all_kinds, test_case_insensitive, test_unknown_kind_raises]
|
||||
test_file_path: "python/functions/core/godot_map_asset_dir_test.py"
|
||||
file_path: "python/functions/core/godot_map_asset_dir.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from core.godot_map_asset_dir import godot_map_asset_dir
|
||||
|
||||
godot_map_asset_dir("pixelart") # 'sprites'
|
||||
godot_map_asset_dir("music") # 'audio/music'
|
||||
godot_map_asset_dir("model") # 'models'
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al construir la ruta destino de un asset dentro de un proyecto Godot (lo usa el
|
||||
pipeline `comfyui_export_asset_to_godot`). Centraliza la convención tipo -> carpeta
|
||||
para no esparcir el mapeo por varias funciones.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `pixelart` cae en `sprites/` (el pixelart no es una carpeta, es un atributo: lo
|
||||
que cambia es el filtro Nearest, no la ubicación).
|
||||
- `kind` desconocido lanza `ValueError` (no devuelve un default silencioso).
|
||||
- Devuelve siempre subrutas POSIX (`/`), no rutas absolutas: el llamador antepone
|
||||
`<proyecto>/assets/`.
|
||||
@@ -0,0 +1,46 @@
|
||||
"""godot_map_asset_dir — mapea el tipo de asset a su subcarpeta res://assets/.
|
||||
|
||||
Funcion pura: dado el `kind` de un asset (sprite, pixelart, tileset, vfx, sfx,
|
||||
music, model) devuelve el subdirectorio canonico bajo `res://assets/` donde debe
|
||||
vivir en un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot
|
||||
(docs/comfyui-godot-integration.md). No toca disco: solo resuelve una ruta
|
||||
relativa. `kind` desconocido -> ValueError.
|
||||
"""
|
||||
|
||||
# kind -> subruta relativa bajo assets/ (convencion del puente ComfyUI->Godot).
|
||||
_KIND_TO_DIR = {
|
||||
"sprite": "sprites",
|
||||
"pixelart": "sprites",
|
||||
"tileset": "tilesets",
|
||||
"vfx": "vfx",
|
||||
"sfx": "audio/sfx",
|
||||
"music": "audio/music",
|
||||
"model": "models",
|
||||
}
|
||||
|
||||
|
||||
def godot_map_asset_dir(kind: str) -> str:
|
||||
"""Resuelve la subcarpeta de assets para un tipo de asset Godot.
|
||||
|
||||
Args:
|
||||
kind: tipo de asset: "sprite", "pixelart", "tileset", "vfx", "sfx",
|
||||
"music" o "model".
|
||||
|
||||
Returns:
|
||||
Subruta relativa (POSIX) bajo `assets/`, p.ej. "sprites" o "audio/music".
|
||||
|
||||
Raises:
|
||||
ValueError: si `kind` no es uno de los tipos soportados.
|
||||
"""
|
||||
key = (kind or "").strip().lower()
|
||||
if key not in _KIND_TO_DIR:
|
||||
raise ValueError(
|
||||
f"kind desconocido: {kind!r}. Soportados: {sorted(_KIND_TO_DIR)}"
|
||||
)
|
||||
return _KIND_TO_DIR[key]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
print(godot_map_asset_dir(sys.argv[1] if len(sys.argv) > 1 else "pixelart"))
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests de godot_map_asset_dir (pura, offline)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from core.godot_map_asset_dir import godot_map_asset_dir # noqa: E402
|
||||
|
||||
|
||||
def test_all_kinds():
|
||||
assert godot_map_asset_dir("sprite") == "sprites"
|
||||
assert godot_map_asset_dir("pixelart") == "sprites"
|
||||
assert godot_map_asset_dir("tileset") == "tilesets"
|
||||
assert godot_map_asset_dir("vfx") == "vfx"
|
||||
assert godot_map_asset_dir("sfx") == "audio/sfx"
|
||||
assert godot_map_asset_dir("music") == "audio/music"
|
||||
assert godot_map_asset_dir("model") == "models"
|
||||
|
||||
|
||||
def test_case_insensitive():
|
||||
assert godot_map_asset_dir("PixelArt") == "sprites"
|
||||
assert godot_map_asset_dir(" MODEL ") == "models"
|
||||
|
||||
|
||||
def test_unknown_kind_raises():
|
||||
with pytest.raises(ValueError):
|
||||
godot_map_asset_dir("hologram")
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: comfyui_matting_luma_to_alpha
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_matting_luma_to_alpha(image_path: str, *, out_path: str | None = None, gamma: float = 1.0, black_point: float = 0.0, premultiply: bool = False, luma_weights: tuple = (0.299, 0.587, 0.114)) -> dict"
|
||||
description: "Convierte un frame de VFX (humo, fuego, destello, magia) generado sobre fondo negro en RGBA usando la LUMINANCIA ponderada como canal alpha (brillante -> opaco, negro -> transparente). Tecnica gamedev correcta para translucidos con additive blend: preserva todo el falloff, a diferencia de un matting binario (rembg) que aplana el gradiente. Nucleo numpy puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, error}. Impura solo por la lectura/escritura de disco."
|
||||
tags: [comfyui, gamedev, vfx, matting, alpha, luminance, ml, pil, numpy]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image_path
|
||||
desc: "ruta del frame de entrada (generado sobre fondo negro)."
|
||||
- name: out_path
|
||||
desc: "ruta del PNG RGBA de salida; si None se escribe junto al original con sufijo '_rgba.png'. keyword-only."
|
||||
- name: gamma
|
||||
desc: "ajuste del falloff del alpha (>1 sube alpha de medios tonos, <1 lo baja). Por defecto 1.0 (lineal). keyword-only."
|
||||
- name: black_point
|
||||
desc: "piso [0,1) que limpia gris de fondo residual sin matar el efecto (re-normaliza el rango restante). Por defecto 0.0. keyword-only."
|
||||
- name: premultiply
|
||||
desc: "si True exporta RGB*alpha (materiales premultiplied / additive del motor). keyword-only."
|
||||
- name: luma_weights
|
||||
desc: "pesos (r,g,b) de la luminancia. Rec.601 (0.299,0.587,0.114) por defecto; Rec.709 = (0.2126,0.7152,0.0722). keyword-only."
|
||||
output: "dict con ok (bool), out_path (str, ruta del PNG RGBA), size ([w,h]), error (str, vacio si OK)."
|
||||
tested: true
|
||||
tests: [test_golden_radial_glow, test_edge_all_black_is_transparent, test_edge_preserves_color, test_edge_input_rgba_recomputes_from_luma, test_edge_gamma_raises_midtone_alpha, test_edge_premultiply, test_error_missing_path]
|
||||
test_file_path: "python/functions/ml/comfyui_matting_luma_to_alpha_test.py"
|
||||
file_path: "python/functions/ml/comfyui_matting_luma_to_alpha.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha
|
||||
|
||||
# Frame de humo generado sobre negro -> RGBA listo para additive blend en Godot/Unity
|
||||
res = comfyui_matting_luma_to_alpha(
|
||||
os.path.expanduser("~/ComfyUI/output/vfx_loop_00007_.png"),
|
||||
out_path="/tmp/smoke_0007_rgba.png",
|
||||
gamma=1.2, # realza la cola tenue del humo
|
||||
black_point=0.04, # limpia el gris residual del fondo
|
||||
)
|
||||
# {'ok': True, 'out_path': '/tmp/smoke_0007_rgba.png', 'size': [512, 512], 'error': ''}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras generar frames de un efecto (humo/fuego/magia/explosion) **sobre fondo negro**
|
||||
(prompt "on pure black background"), antes de montar el spritesheet o exportarlo a
|
||||
un motor. Es el recorte a alpha correcto para translucidos. NO uses rembg / matting
|
||||
binario para humo/fuego: rompe el degradado. Para sprites SOLIDOS (personaje, item)
|
||||
con silueta definida, rembg sigue siendo lo adecuado, no esta funcion.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El frame debe estar sobre NEGRO**: el alpha sale de la luminancia, asi que un
|
||||
fondo no-negro se vuelve parcialmente opaco. Genera con "on pure black background".
|
||||
- Ignora cualquier alpha de la imagen de entrada: **recomputa** alpha desde la luma
|
||||
del RGB (un input RGBA con alpha previo se sobreescribe).
|
||||
- `black_point` limpia el gris de fondo si el modelo no dio negro puro; subirlo
|
||||
demasiado come las zonas tenues del efecto.
|
||||
- `premultiply=True` para materiales additive/premultiplied; deja `False` si el
|
||||
motor espera alpha straight.
|
||||
- Todo error es **dict `ok=False`** (no excepcion): path inexistente, `gamma<=0`,
|
||||
`black_point` fuera de [0,1) -> `error` explica.
|
||||
- CPU-only (numpy): no toca la GPU ni el servidor ComfyUI. El nodo `ImageToMask` de
|
||||
ComfyUI NO sirve para esto (hace channel-pick, no luminancia ponderada).
|
||||
@@ -0,0 +1,139 @@
|
||||
"""comfyui_matting_luma_to_alpha — frame VFX sobre negro -> RGBA por luminancia.
|
||||
|
||||
Convierte un frame de efecto (humo, fuego, destello, magia) generado sobre fondo
|
||||
negro en una imagen RGBA donde el canal alpha es la LUMINANCIA del pixel: brillante
|
||||
-> opaco, negro -> transparente. Es la tecnica gamedev correcta para translucidos:
|
||||
en el motor se compone con additive blend y el solape brilla de forma natural,
|
||||
preservando todo el falloff (cosa que un matting binario tipo rembg destruye).
|
||||
|
||||
Por que en Python y no con nodos ComfyUI: el `ImageToMask` del server hace
|
||||
channel-pick (extrae un canal R/G/B/A), NO la luminancia ponderada perceptual
|
||||
0.299R + 0.587G + 0.114B. La luma correcta es trivial en numpy. Ademas mantiene el
|
||||
matting fuera del server: cero VRAM, headless, determinista, testeable.
|
||||
|
||||
El nucleo `_luma_to_rgba` es puro (numpy); la funcion publica es impura solo por la
|
||||
lectura/escritura de disco.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _luma_to_rgba(rgb01, *, gamma: float, black_point: float,
|
||||
premultiply: bool, weights):
|
||||
"""Nucleo puro: HxWx3 float [0,1] (efecto sobre negro) -> HxWx4 float [0,1].
|
||||
|
||||
Args:
|
||||
rgb01: numpy array HxWx3 en [0,1].
|
||||
gamma: realza (>1) o atenua (<1) el alpha en los medios tonos.
|
||||
black_point: piso [0,1) que limpia el gris residual de fondo antes de
|
||||
mapear a alpha (re-normaliza el rango restante a [0,1]).
|
||||
premultiply: si True multiplica el RGB de salida por el alpha (materiales
|
||||
premultiplied / additive del motor).
|
||||
weights: pesos de luminancia (r, g, b). Rec.601 (0.299,0.587,0.114) por
|
||||
defecto; Rec.709 = (0.2126,0.7152,0.0722).
|
||||
|
||||
Returns:
|
||||
numpy array HxWx4 en [0,1].
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
w = np.asarray(weights, dtype=np.float32)
|
||||
luma = rgb01 @ w # HxW, luminancia ponderada
|
||||
if black_point > 0.0:
|
||||
luma = np.clip((luma - black_point) / (1.0 - black_point), 0.0, 1.0)
|
||||
if gamma != 1.0:
|
||||
luma = np.power(np.clip(luma, 0.0, 1.0), 1.0 / gamma)
|
||||
alpha = np.clip(luma, 0.0, 1.0)
|
||||
rgb_out = rgb01 * alpha[..., None] if premultiply else rgb01
|
||||
return np.dstack([rgb_out, alpha])
|
||||
|
||||
|
||||
def comfyui_matting_luma_to_alpha(
|
||||
image_path: str,
|
||||
*,
|
||||
out_path: str | None = None,
|
||||
gamma: float = 1.0,
|
||||
black_point: float = 0.0,
|
||||
premultiply: bool = False,
|
||||
luma_weights: tuple = (0.299, 0.587, 0.114),
|
||||
) -> dict:
|
||||
"""Convierte un frame sobre negro a RGBA usando la luminancia como alpha.
|
||||
|
||||
Args:
|
||||
image_path: ruta del frame de entrada (generado sobre fondo negro).
|
||||
out_path: ruta del PNG RGBA de salida; si None se escribe junto al
|
||||
original con sufijo "_rgba.png". keyword-only.
|
||||
gamma: ajuste del falloff del alpha (>1 sube alpha de medios tonos,
|
||||
<1 lo baja). Por defecto 1.0 (lineal). keyword-only.
|
||||
black_point: piso [0,1) para limpiar gris de fondo residual sin matar el
|
||||
efecto. Por defecto 0.0. keyword-only.
|
||||
premultiply: exporta RGB*alpha si True (additive/premultiplied del motor).
|
||||
keyword-only.
|
||||
luma_weights: pesos (r,g,b) de la luminancia. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si se convirtio y guardo.
|
||||
- out_path (str): ruta del PNG RGBA generado.
|
||||
- size (list[int]): [w, h] de la imagen.
|
||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {"ok": False, "out_path": "", "size": [0, 0], "error": ""}
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
except ImportError as exc:
|
||||
out["error"] = f"falta dependencia (PIL/numpy): {exc}"
|
||||
return out
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
out["error"] = f"image_path no existe: {image_path!r}"
|
||||
return out
|
||||
if not (0.0 <= float(black_point) < 1.0):
|
||||
out["error"] = f"black_point debe estar en [0,1), recibido {black_point!r}"
|
||||
return out
|
||||
if float(gamma) <= 0.0:
|
||||
out["error"] = f"gamma debe ser > 0, recibido {gamma!r}"
|
||||
return out
|
||||
|
||||
try:
|
||||
with Image.open(image_path) as src:
|
||||
# Ignora cualquier alpha de entrada: recomputa desde la luma del RGB.
|
||||
rgb = np.asarray(src.convert("RGB"), dtype=np.float32) / 255.0
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo leer/decodificar {image_path!r}: {exc}"
|
||||
return out
|
||||
|
||||
rgba01 = _luma_to_rgba(
|
||||
rgb, gamma=float(gamma), black_point=float(black_point),
|
||||
premultiply=bool(premultiply), weights=luma_weights,
|
||||
)
|
||||
rgba8 = (np.clip(rgba01, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
|
||||
result = Image.fromarray(rgba8, mode="RGBA")
|
||||
|
||||
if out_path is None:
|
||||
base, _ = os.path.splitext(image_path)
|
||||
out_path = base + "_rgba.png"
|
||||
try:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
result.save(out_path)
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo escribir {out_path!r}: {exc}"
|
||||
return out
|
||||
|
||||
out.update(ok=True, out_path=out_path, size=list(result.size))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("uso: comfyui_matting_luma_to_alpha.py <frame_sobre_negro> [out]",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
src = sys.argv[1]
|
||||
dst = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
print(json.dumps(comfyui_matting_luma_to_alpha(src, out_path=dst), indent=2))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests de comfyui_matting_luma_to_alpha (offline, sin red ni GPU; PIL/numpy)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha # noqa: E402
|
||||
|
||||
|
||||
def _radial_glow_png(path, size=64):
|
||||
"""Degradado radial blanco (centro brillante) sobre negro."""
|
||||
yy, xx = np.mgrid[0:size, 0:size]
|
||||
cx = cy = (size - 1) / 2.0
|
||||
dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
|
||||
val = np.clip(1.0 - dist / (size / 2.0), 0.0, 1.0)
|
||||
val = val / val.max() # normaliza el pico a 1.0 (centro = blanco puro)
|
||||
arr = (val[..., None] * 255).astype(np.uint8).repeat(3, axis=2)
|
||||
Image.fromarray(arr, "RGB").save(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_golden_radial_glow(tmp_path):
|
||||
src = _radial_glow_png(str(tmp_path / "glow.png"))
|
||||
res = comfyui_matting_luma_to_alpha(src)
|
||||
assert res["ok"] is True, res["error"]
|
||||
img = Image.open(res["out_path"])
|
||||
assert img.mode == "RGBA"
|
||||
a = np.asarray(img)[..., 3]
|
||||
assert a[32, 32] >= 250 # centro brillante -> opaco
|
||||
assert a[0, 0] <= 5 # esquina negra -> transparente
|
||||
|
||||
|
||||
def test_edge_all_black_is_transparent(tmp_path):
|
||||
src = str(tmp_path / "black.png")
|
||||
Image.fromarray(np.zeros((32, 32, 3), np.uint8), "RGB").save(src)
|
||||
res = comfyui_matting_luma_to_alpha(src)
|
||||
assert res["ok"] is True # efecto invisible, NO error
|
||||
a = np.asarray(Image.open(res["out_path"]))[..., 3]
|
||||
assert int(a.max()) == 0 # alpha todo 0
|
||||
|
||||
|
||||
def test_edge_preserves_color(tmp_path):
|
||||
"""Un frame rojo brillante conserva su RGB y gana alpha por su luma."""
|
||||
src = str(tmp_path / "red.png")
|
||||
Image.fromarray(np.full((16, 16, 3), [255, 0, 0], np.uint8), "RGB").save(src)
|
||||
res = comfyui_matting_luma_to_alpha(src)
|
||||
rgba = np.asarray(Image.open(res["out_path"]))
|
||||
assert tuple(rgba[8, 8, :3]) == (255, 0, 0) # color preservado (sin premultiply)
|
||||
assert rgba[8, 8, 3] > 0 # alpha desde luma del rojo
|
||||
|
||||
|
||||
def test_edge_input_rgba_recomputes_from_luma(tmp_path):
|
||||
"""Input ya RGBA con alpha=0: se ignora y se recomputa alpha desde la luma."""
|
||||
src = str(tmp_path / "rgba_in.png")
|
||||
arr = np.zeros((16, 16, 4), np.uint8)
|
||||
arr[..., :3] = 255 # blanco
|
||||
arr[..., 3] = 0 # alpha previo nulo (debe ignorarse)
|
||||
Image.fromarray(arr, "RGBA").save(src)
|
||||
res = comfyui_matting_luma_to_alpha(src)
|
||||
a = np.asarray(Image.open(res["out_path"]))[..., 3]
|
||||
assert int(a.min()) >= 250 # alpha recomputado desde RGB blanco
|
||||
|
||||
|
||||
def test_edge_gamma_raises_midtone_alpha(tmp_path):
|
||||
"""gamma>1 sube el alpha de los medios tonos respecto a gamma=1."""
|
||||
src = str(tmp_path / "mid.png")
|
||||
Image.fromarray(np.full((16, 16, 3), 128, np.uint8), "RGB").save(src)
|
||||
base = comfyui_matting_luma_to_alpha(src, out_path=str(tmp_path / "g1.png"), gamma=1.0)
|
||||
hi = comfyui_matting_luma_to_alpha(src, out_path=str(tmp_path / "g2.png"), gamma=2.0)
|
||||
a1 = int(np.asarray(Image.open(base["out_path"]))[8, 8, 3])
|
||||
a2 = int(np.asarray(Image.open(hi["out_path"]))[8, 8, 3])
|
||||
assert a2 > a1
|
||||
|
||||
|
||||
def test_edge_premultiply(tmp_path):
|
||||
"""premultiply multiplica el RGB por el alpha (medio tono -> RGB reducido)."""
|
||||
src = str(tmp_path / "mid.png")
|
||||
Image.fromarray(np.full((16, 16, 3), 128, np.uint8), "RGB").save(src)
|
||||
res = comfyui_matting_luma_to_alpha(src, premultiply=True)
|
||||
rgba = np.asarray(Image.open(res["out_path"]))[8, 8]
|
||||
assert rgba[0] < 128 # RGB premultiplicado por alpha (<1)
|
||||
|
||||
|
||||
def test_error_missing_path(tmp_path):
|
||||
res = comfyui_matting_luma_to_alpha(str(tmp_path / "nope.png"))
|
||||
assert res["ok"] is False
|
||||
assert "no existe" in res["error"]
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: comfyui_pixelize_image
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True) -> dict"
|
||||
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, error}. Impura solo por la lectura/escritura de disco."
|
||||
tags: [comfyui, gamedev, pixelart, ml, pil, quantize, palette, image]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: src_path
|
||||
desc: "ruta de la imagen de entrada (PNG/JPG/...)."
|
||||
- name: dst_path
|
||||
desc: "ruta del PNG de salida (se crea el directorio si falta)."
|
||||
- name: downscale
|
||||
desc: "factor entero de reduccion nearest (>=1); cada bloque downscale x downscale px colapsa a 1 pixel. 1 = solo cuantiza sin colapsar el grid. keyword-only."
|
||||
- name: colors
|
||||
desc: "numero de colores objetivo (2..256) cuando palette es None; cuantizacion MEDIANCUT determinista. keyword-only."
|
||||
- name: palette
|
||||
desc: "None (auto a 'colors'), nombre de paleta fija builtin ('game-boy','pico-8','nes') o lista de hex ('#rrggbb'/'rrggbb'). Una paleta fija ignora 'colors'. keyword-only."
|
||||
- name: dither
|
||||
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
|
||||
- name: upscale_back
|
||||
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
|
||||
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores distintos del resultado), error (str, vacio si OK)."
|
||||
tested: true
|
||||
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette]
|
||||
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
|
||||
file_path: "python/functions/ml/comfyui_pixelize_image.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||
|
||||
# Crudo SDXL+pixel-art-xl 1024x1024 -> pixelart 16 colores, grid de 128
|
||||
res = comfyui_pixelize_image(
|
||||
os.path.expanduser("~/ComfyUI/output/pixelart_00001_.png"),
|
||||
"/tmp/hero_pixel.png",
|
||||
downscale=8, colors=16,
|
||||
)
|
||||
# {'ok': True, 'out_path': '/tmp/hero_pixel.png', 'size': [1024, 1024], 'n_colors_final': 16, 'error': ''}
|
||||
|
||||
# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale)
|
||||
comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png",
|
||||
palette="game-boy", upscale_back=False)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `pixel-art-xl`),
|
||||
para colapsar el grid borroso a pixeles duros y limitar la paleta. Tambien sirve
|
||||
para "pixelizar" cualquier imagen (sprite, render, foto) a estetica retro sin
|
||||
tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
|
||||
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **nearest, no lanczos**: el downscale usa NEAREST a proposito; interpolar suave
|
||||
re-difumina el grid. No lo cambies por "calidad".
|
||||
- `palette` fija (game-boy/pico-8/nes o lista de hex) **ignora** `colors`. La
|
||||
paleta se rellena internamente repitiendo su ultimo color para que `quantize`
|
||||
no introduzca un negro extra por entradas vacias (bug arreglado en v1.0.0).
|
||||
- `downscale` con `upscale_back=False` deja la imagen de `w//downscale x h//downscale`:
|
||||
util para spritesheets compactos; con `True` vuelve al tamano original con bordes
|
||||
duros (preview).
|
||||
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
|
||||
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
|
||||
- `n_colors_final` cuenta colores distintos reales del PNG escrito; con paleta fija
|
||||
puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
|
||||
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
|
||||
con Pillow.
|
||||
@@ -0,0 +1,206 @@
|
||||
"""comfyui_pixelize_image — post-proceso pixel-perfect de una imagen (Fase 2 pixelart).
|
||||
|
||||
Convierte una imagen "pixelart borroso de IA" (o cualquier PNG) en pixelart de
|
||||
verdad: downscale nearest-neighbor por factor (colapsa cada bloque borroso a un
|
||||
pixel duro) -> cuantizacion a N colores o a una paleta fija (NES / Game Boy /
|
||||
PICO-8) -> opcional re-upscale nearest conservando los pixeles duros.
|
||||
|
||||
Es la Fase 2 del pipeline pixelart (la Fase 1, generar con SDXL + pixel-art-xl
|
||||
LoRA, vive en otra funcion). Determinista y CPU-only: el nucleo `_pixelize_pil`
|
||||
es puro (PIL), no toca la GPU ni la red. La funcion publica es impura solo por la
|
||||
lectura/escritura de disco (mismo patron que comfyui_build_grid).
|
||||
|
||||
Por que nearest y no lanczos/cubic: el downscale tiene que colapsar cada bloque
|
||||
borroso a UN pixel; cualquier interpolacion suave re-difumina el grid. La
|
||||
cuantizacion (Image.quantize) limita la paleta, que es lo que da la identidad
|
||||
retro y elimina el ruido de cientos de colores de la difusion.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# Paletas retro fijas (hex sin '#'). Embebidas: cero red, deterministas.
|
||||
# Fuentes: lospec.com (game-boy, pico-8) + paleta NES clasica reducida.
|
||||
_BUILTIN_PALETTES = {
|
||||
"game-boy": ["0f380f", "306230", "8bac0f", "9bbc0f"],
|
||||
"pico-8": [
|
||||
"000000", "1d2b53", "7e2553", "008751", "ab5236", "5f574f",
|
||||
"c2c3c7", "fff1e8", "ff004d", "ffa300", "ffec27", "00e436",
|
||||
"29adff", "83769c", "ff77a8", "ffccaa",
|
||||
],
|
||||
# NES: subconjunto representativo de 16 colores de la paleta 2C02.
|
||||
"nes": [
|
||||
"000000", "fcfcfc", "f8f8f8", "bcbcbc", "7c7c7c", "0000fc",
|
||||
"0078f8", "00b800", "b8f818", "f83800", "e40058", "f878f8",
|
||||
"fca044", "f8b800", "503000", "00a800",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _hex_to_rgb(h: str) -> tuple:
|
||||
"""'1a2b3c' o '#1a2b3c' -> (26, 43, 60)."""
|
||||
h = h.strip().lstrip("#")
|
||||
if len(h) != 6:
|
||||
raise ValueError(f"hex de color invalido: {h!r} (esperado rrggbb)")
|
||||
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _normalize_palette(palette):
|
||||
"""palette: None | nombre builtin (str) | lista de hex -> list[(r,g,b)] | None."""
|
||||
if palette is None:
|
||||
return None
|
||||
if isinstance(palette, str):
|
||||
key = palette.strip().lower().replace("_", "-")
|
||||
if key not in _BUILTIN_PALETTES:
|
||||
raise ValueError(
|
||||
f"paleta builtin desconocida: {palette!r}. "
|
||||
f"Disponibles: {sorted(_BUILTIN_PALETTES)} o pasa una lista de hex."
|
||||
)
|
||||
hexes = _BUILTIN_PALETTES[key]
|
||||
else:
|
||||
hexes = list(palette)
|
||||
if not hexes:
|
||||
raise ValueError("lista de paleta vacia")
|
||||
return [_hex_to_rgb(h) for h in hexes]
|
||||
|
||||
|
||||
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
||||
"""Nucleo puro PIL: imagen RGB -> imagen RGB pixelizada.
|
||||
|
||||
Args:
|
||||
img: PIL.Image de entrada.
|
||||
downscale: factor entero de reduccion nearest (>=1).
|
||||
colors: numero de colores objetivo si no hay paleta fija.
|
||||
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
|
||||
dither: aplica Floyd-Steinberg al cuantizar si True.
|
||||
upscale_back: re-escala nearest al tamano original si True.
|
||||
|
||||
Returns:
|
||||
PIL.Image RGB pixelizada.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
img = img.convert("RGB")
|
||||
w, h = img.size
|
||||
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
|
||||
sw, sh = max(1, w // downscale), max(1, h // downscale)
|
||||
small = img.resize((sw, sh), Image.NEAREST)
|
||||
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
||||
# 2. cuantizar la paleta.
|
||||
if palette_rgb:
|
||||
pal_img = Image.new("P", (1, 1))
|
||||
flat = [c for rgb in palette_rgb for c in rgb][:768]
|
||||
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
|
||||
# quantize no puede introducir un color extra (negro) por las entradas vacias.
|
||||
if flat:
|
||||
last = flat[-3:]
|
||||
flat += last * ((768 - len(flat)) // 3)
|
||||
flat += [0] * (768 - len(flat))
|
||||
pal_img.putpalette(flat)
|
||||
small = small.quantize(palette=pal_img, dither=d)
|
||||
else:
|
||||
n = max(2, min(256, int(colors)))
|
||||
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
|
||||
out = small.convert("RGB")
|
||||
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
|
||||
if upscale_back:
|
||||
out = out.resize((w, h), Image.NEAREST)
|
||||
return out
|
||||
|
||||
|
||||
def comfyui_pixelize_image(
|
||||
src_path: str,
|
||||
dst_path: str,
|
||||
*,
|
||||
downscale: int = 8,
|
||||
colors: int = 16,
|
||||
palette=None,
|
||||
dither: bool = False,
|
||||
upscale_back: bool = True,
|
||||
) -> dict:
|
||||
"""Pixeliza una imagen y la guarda como PNG.
|
||||
|
||||
Args:
|
||||
src_path: ruta de la imagen de entrada (PNG/JPG/...).
|
||||
dst_path: ruta del PNG de salida.
|
||||
downscale: factor entero de reduccion nearest-neighbor; cada bloque de
|
||||
downscale x downscale px colapsa a 1 pixel. 1 = solo cuantiza, sin
|
||||
colapsar el grid. keyword-only.
|
||||
colors: numero de colores objetivo (2..256) cuando palette es None;
|
||||
cuantizacion MEDIANCUT determinista. keyword-only.
|
||||
palette: None (cuantizacion automatica a `colors`), nombre de paleta
|
||||
fija builtin ("game-boy", "pico-8", "nes") o lista de hex
|
||||
("#rrggbb"/"rrggbb"). Una paleta fija ignora `colors`. keyword-only.
|
||||
dither: aplica Floyd-Steinberg al cuantizar (por defecto off, pixelart
|
||||
limpio). keyword-only.
|
||||
upscale_back: re-escala nearest al tamano original (preview con pixeles
|
||||
duros). False deja la imagen pequena (sw x sh). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si se pixelizo y guardo.
|
||||
- out_path (str): ruta del PNG generado.
|
||||
- size (list[int]): [w, h] de la imagen final.
|
||||
- n_colors_final (int): numero de colores distintos en el resultado.
|
||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "error": ""}
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
|
||||
return out
|
||||
|
||||
if not os.path.isfile(src_path):
|
||||
out["error"] = f"src_path no existe: {src_path!r}"
|
||||
return out
|
||||
if int(downscale) < 1:
|
||||
out["error"] = f"downscale debe ser >= 1, recibido {downscale!r}"
|
||||
return out
|
||||
|
||||
try:
|
||||
palette_rgb = _normalize_palette(palette)
|
||||
except ValueError as exc:
|
||||
out["error"] = f"paleta invalida: {exc}"
|
||||
return out
|
||||
|
||||
try:
|
||||
with Image.open(src_path) as src:
|
||||
result = _pixelize_pil(
|
||||
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
|
||||
)
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
||||
return out
|
||||
|
||||
try:
|
||||
dst_dir = os.path.dirname(os.path.abspath(dst_path))
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
result.save(dst_path)
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
||||
return out
|
||||
|
||||
colors_found = result.getcolors(maxcolors=1 << 20)
|
||||
n_final = len(colors_found) if colors_found is not None else -1
|
||||
out.update(
|
||||
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("uso: comfyui_pixelize_image.py <src> <dst> [downscale] [colors] [palette]",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
ds = int(sys.argv[3]) if len(sys.argv) > 3 else 8
|
||||
col = int(sys.argv[4]) if len(sys.argv) > 4 else 16
|
||||
pal = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
print(json.dumps(comfyui_pixelize_image(src, dst, downscale=ds, colors=col, palette=pal),
|
||||
indent=2))
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests de comfyui_pixelize_image (offline, sin red ni GPU; PIL/numpy)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ml.comfyui_pixelize_image import comfyui_pixelize_image # noqa: E402
|
||||
|
||||
|
||||
def _noisy_png(path, w=256, h=256):
|
||||
"""PNG ruidoso con cientos de colores (simula crudo borroso de IA)."""
|
||||
rng = np.random.default_rng(7)
|
||||
arr = rng.integers(0, 256, size=(h, w, 3), dtype=np.uint8)
|
||||
Image.fromarray(arr, "RGB").save(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_golden_downscale_quantize(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "pixel.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16)
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert os.path.isfile(dst)
|
||||
assert res["size"] == [256, 256] # upscale_back=True conserva tamano
|
||||
assert res["n_colors_final"] <= 16 # cuantizado a <=16 colores
|
||||
|
||||
|
||||
def test_no_upscale_back_keeps_small(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "small.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16, upscale_back=False)
|
||||
assert res["ok"] is True
|
||||
assert res["size"] == [32, 32] # 256//8
|
||||
|
||||
|
||||
def test_edge_fixed_palette_game_boy(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "gb.png")
|
||||
res = comfyui_pixelize_image(src, dst, palette="game-boy")
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert res["n_colors_final"] <= 4 # paleta Game Boy = 4 colores
|
||||
|
||||
|
||||
def test_edge_palette_list_hex(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "pal.png")
|
||||
res = comfyui_pixelize_image(src, dst, palette=["#000000", "#ffffff", "#ff0000"])
|
||||
assert res["ok"] is True
|
||||
assert res["n_colors_final"] <= 3
|
||||
|
||||
|
||||
def test_edge_downscale_1_only_quantizes(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "q.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=1, colors=8)
|
||||
assert res["ok"] is True
|
||||
assert res["size"] == [256, 256]
|
||||
assert res["n_colors_final"] <= 8
|
||||
|
||||
|
||||
def test_error_missing_src(tmp_path):
|
||||
res = comfyui_pixelize_image(str(tmp_path / "nope.png"), str(tmp_path / "o.png"))
|
||||
assert res["ok"] is False
|
||||
assert "no existe" in res["error"]
|
||||
|
||||
|
||||
def test_error_downscale_zero(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), downscale=0)
|
||||
assert res["ok"] is False
|
||||
assert "downscale" in res["error"]
|
||||
|
||||
|
||||
def test_error_bad_palette(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette")
|
||||
assert res["ok"] is False
|
||||
assert "paleta" in res["error"].lower()
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: comfyui_export_asset_to_godot
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_export_asset_to_godot(asset_path: str, kind: str, godot_project: str, *, name: str | None = None, reimport: bool = True, godot_bin: str | None = None) -> dict"
|
||||
description: "Puente ComfyUI -> Godot: copia un asset generado a la subcarpeta correcta de un proyecto Godot 4 (res://assets/{sprites,tilesets,vfx,audio/{sfx,music},models}/ segun kind), escribe el .import adecuado al tipo (textura Lossless+mipmaps off / audio loop on-off / escena glTF), asegura el filtro Nearest del proyecto si es pixelart (default_texture_filter=0 en project.godot), y lanza reimport headless si el binario de Godot esta disponible. Compone godot_map_asset_dir + godot_clean_asset_name. NO toca ningun proyecto salvo el que se le pasa; idempotente (preserva uid existente). Devuelve {ok, dest_res_path, dest_abs_path, import_path, import_written, pixelart_filter_set, reimported, warnings, error}. Impuro: disco + subprocess."
|
||||
tags: [godot, gamedev, comfyui, pipelines, export, import, launcher]
|
||||
uses_functions: [godot_map_asset_dir_py_core, godot_clean_asset_name_py_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [godot_map_asset_dir_py_core, godot_clean_asset_name_py_core]
|
||||
params:
|
||||
- name: asset_path
|
||||
desc: "ruta del asset de origen (p.ej. en ~/ComfyUI/output/)."
|
||||
- name: kind
|
||||
desc: "tipo de asset: 'sprite', 'pixelart', 'tileset', 'vfx', 'sfx', 'music' o 'model'."
|
||||
- name: godot_project
|
||||
desc: "ruta raiz del proyecto Godot destino (debe contener project.godot)."
|
||||
- name: name
|
||||
desc: "nombre base deseado para el archivo destino (sin extension); None lo deriva del origen (snake_case sin _NNNNN_). keyword-only."
|
||||
- name: reimport
|
||||
desc: "si True intenta reimport headless con el binario de Godot. keyword-only."
|
||||
- name: godot_bin
|
||||
desc: "ruta del binario de Godot; None autodetecta (PATH y rutas conocidas como ~/godot/Godot_v4.7-stable_linux.x86_64). keyword-only."
|
||||
output: "dict con ok (bool), dest_res_path (res://...), dest_abs_path, import_path, import_written (bool), pixelart_filter_set (bool), reimported (bool), warnings (list[str]), error (str)."
|
||||
tested: true
|
||||
tests: [test_golden_pixelart, test_edge_music_loop_on, test_edge_sfx_loop_off, test_edge_model_glb_scene, test_edge_tileset_warns, test_idempotent_preserves_uid, test_error_missing_asset, test_error_bad_kind, test_error_not_a_godot_project, test_godot_cli_absent_leaves_import]
|
||||
test_file_path: "python/functions/pipelines/comfyui_export_asset_to_godot_test.py"
|
||||
file_path: "python/functions/pipelines/comfyui_export_asset_to_godot.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot
|
||||
|
||||
# Pixelart -> sprites/ con Nearest global + reimport headless automatico
|
||||
res = comfyui_export_asset_to_godot(
|
||||
os.path.expanduser("~/ComfyUI/output/hero_00001_.png"),
|
||||
"pixelart",
|
||||
os.path.expanduser("~/gamedev/projects/crossy_road"),
|
||||
)
|
||||
# {'ok': True, 'dest_res_path': 'res://assets/sprites/hero.png',
|
||||
# 'import_written': True, 'pixelart_filter_set': True, 'reimported': True, ...}
|
||||
|
||||
# Musica con loop ON
|
||||
comfyui_export_asset_to_godot("/tmp/theme.ogg", "music", "/tmp/godot_proj")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para llevar un asset recien generado en ComfyUI a un proyecto Godot sin tocar el
|
||||
import a mano: resuelve carpeta + escribe el `.import` por tipo + arregla el filtro
|
||||
pixelart + reimporta. Es el ultimo paso del flujo gen -> (pixelize / matting) ->
|
||||
export. Si solo quieres el mapeo o el nombre limpio sin copiar, usa los helpers
|
||||
`godot_map_asset_dir` / `godot_clean_asset_name` directamente.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Seguridad**: solo escribe en el `godot_project` que se le pasa. Aborta con
|
||||
`ok=False` si ese directorio no tiene `project.godot`. Nunca toca otros proyectos.
|
||||
- **Filtro Nearest (Godot 4)**: NO es un campo del `.import` por defecto; se setea
|
||||
global en `project.godot` (`default_texture_filter=0`). Con `kind="pixelart"` la
|
||||
función lo asegura (idempotente). Para sprites no-pixelart deja el filtro por
|
||||
defecto del proyecto.
|
||||
- **Godot no deriva TileSet ni SpriteFrames**: para `tileset`/`vfx` copia la textura
|
||||
y **avisa** (en `warnings`) que el `.tres` hay que crearlo en editor o por script.
|
||||
- **reimport best-effort**: si no encuentra el binario de Godot, deja el `.import`
|
||||
escrito y lo anota en `warnings` (`reimported=False`) — no falla. Autodetecta en
|
||||
PATH y en `~/godot/Godot_v4.7-stable_linux.x86_64`; override con `godot_bin`.
|
||||
- **Idempotente**: re-exportar preserva el `uid://` de un `.import` ya existente (no
|
||||
rompe las referencias de escenas que ya usan el asset).
|
||||
- **WEBP animado (SVD)**: para usarlo como animación en Godot, descomponer en frames
|
||||
PNG antes; este pipeline lo copia como textura tal cual (no lo descompone).
|
||||
- El `.import` escrito es el mínimo correcto por tipo; Godot completa los campos que
|
||||
falten en el primer reimport.
|
||||
@@ -0,0 +1,311 @@
|
||||
"""comfyui_export_asset_to_godot — lleva un asset generado a un proyecto Godot 4.
|
||||
|
||||
Pipeline del puente ComfyUI -> Godot (docs/comfyui-godot-integration.md): copia un
|
||||
asset salido de `~/ComfyUI/output/` a la subcarpeta correcta de un proyecto Godot
|
||||
(`res://assets/{sprites,tilesets,vfx,audio/{sfx,music},models}/` segun `kind`),
|
||||
escribe el archivo `.import` adecuado al tipo (textura / audio / escena glTF) con
|
||||
los settings clave, asegura el filtro Nearest del proyecto si el asset es pixelart,
|
||||
y lanza un reimport headless si el binario de Godot esta disponible.
|
||||
|
||||
Compone las funciones puras del registry:
|
||||
godot_map_asset_dir_py_core (kind -> subcarpeta)
|
||||
godot_clean_asset_name_py_core (nombre de origen -> snake_case seguro)
|
||||
|
||||
Y orquesta el I/O especifico del puente (copia, .import, project.godot, reimport).
|
||||
|
||||
Seguridad: NO toca ningun proyecto salvo el `godot_project` que se le pasa
|
||||
explicitamente. Aborta si ese directorio no es un proyecto Godot (sin
|
||||
`project.godot`). Idempotente: preserva el `uid://` de un `.import` ya existente
|
||||
(no rompe referencias de escenas en uso). Impuro: lee/escribe disco + subprocess
|
||||
(reimport).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_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 core.godot_clean_asset_name import godot_clean_asset_name
|
||||
from core.godot_map_asset_dir import godot_map_asset_dir
|
||||
|
||||
# Tipos que importan como textura 2D.
|
||||
_TEXTURE_KINDS = {"sprite", "pixelart", "tileset", "vfx"}
|
||||
# Candidatos de binario Godot 4 (autodeteccion).
|
||||
_GODOT_CANDIDATES = [
|
||||
"godot", "godot4",
|
||||
os.path.expanduser("~/godot/Godot_v4.7-stable_linux.x86_64"),
|
||||
]
|
||||
_UID_RE = re.compile(r'^uid="(uid://[^"]+)"', re.MULTILINE)
|
||||
|
||||
|
||||
def _find_godot_bin(godot_bin: str | None) -> str | None:
|
||||
"""Resuelve el binario de Godot: arg explicito -> PATH -> rutas conocidas."""
|
||||
if godot_bin:
|
||||
return godot_bin if (os.path.isfile(godot_bin) or shutil.which(godot_bin)) else None
|
||||
for cand in _GODOT_CANDIDATES:
|
||||
found = cand if os.path.isfile(cand) else shutil.which(cand)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _existing_uid(import_path: str) -> str | None:
|
||||
"""Lee el uid:// de un .import ya presente (idempotencia), si lo hay."""
|
||||
if not os.path.isfile(import_path):
|
||||
return None
|
||||
try:
|
||||
with open(import_path, encoding="utf-8") as fh:
|
||||
m = _UID_RE.search(fh.read())
|
||||
return m.group(1) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _texture_import(res_path: str, pixelart: bool, uid: str | None) -> str:
|
||||
"""Genera el .import de una textura 2D (Lossless, mipmaps off)."""
|
||||
uid_line = f'uid="{uid}"\n' if uid else ""
|
||||
return (
|
||||
"[remap]\n\n"
|
||||
'importer="texture"\n'
|
||||
f"{uid_line}"
|
||||
'type="CompressedTexture2D"\n\n'
|
||||
"[deps]\n\n"
|
||||
f'source_file="{res_path}"\n\n'
|
||||
"[params]\n\n"
|
||||
"compress/mode=0\n" # 0 = Lossless (correcto para pixelart/2D)
|
||||
"mipmaps/generate=false\n"
|
||||
"process/fix_alpha_border=true\n"
|
||||
"detect_3d/compress_to=1\n"
|
||||
)
|
||||
|
||||
|
||||
def _audio_import(res_path: str, kind: str, uid: str | None) -> str:
|
||||
"""Genera el .import de audio: wav (loop segun kind) u ogg (loop segun kind)."""
|
||||
loop = kind == "music"
|
||||
ext = os.path.splitext(res_path)[1].lower()
|
||||
uid_line = f'uid="{uid}"\n' if uid else ""
|
||||
if ext == ".ogg":
|
||||
return (
|
||||
"[remap]\n\n"
|
||||
'importer="oggvorbisstr"\n'
|
||||
f"{uid_line}"
|
||||
'type="AudioStreamOggVorbis"\n\n'
|
||||
"[deps]\n\n"
|
||||
f'source_file="{res_path}"\n\n'
|
||||
"[params]\n\n"
|
||||
f"loop={'true' if loop else 'false'}\n"
|
||||
)
|
||||
return (
|
||||
"[remap]\n\n"
|
||||
'importer="wav"\n'
|
||||
f"{uid_line}"
|
||||
'type="AudioStreamWAV"\n\n'
|
||||
"[deps]\n\n"
|
||||
f'source_file="{res_path}"\n\n'
|
||||
"[params]\n\n"
|
||||
f"edit/loop_mode={1 if loop else 0}\n" # 0=Disabled (sfx), 1=Forward (musica)
|
||||
"force/mono=false\n"
|
||||
)
|
||||
|
||||
|
||||
def _scene_import(res_path: str, uid: str | None) -> str:
|
||||
"""Genera el .import de una malla glTF/GLB (escena PackedScene)."""
|
||||
uid_line = f'uid="{uid}"\n' if uid else ""
|
||||
return (
|
||||
"[remap]\n\n"
|
||||
'importer="scene"\n'
|
||||
f"{uid_line}"
|
||||
'importer_version=1\n'
|
||||
'type="PackedScene"\n\n'
|
||||
"[deps]\n\n"
|
||||
f'source_file="{res_path}"\n\n'
|
||||
"[params]\n\n"
|
||||
"nodes/root_type=\"\"\n"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_pixelart_filter(project_godot: str) -> bool:
|
||||
"""Asegura default_texture_filter=0 (Nearest) en project.godot. Idempotente.
|
||||
|
||||
Returns True si el archivo quedo con el filtro Nearest (ya lo tenia o se anadio).
|
||||
"""
|
||||
key = "rendering/textures/canvas_textures/default_texture_filter"
|
||||
try:
|
||||
with open(project_godot, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if re.search(rf"^{re.escape(key)}=0\s*$", text, re.MULTILINE):
|
||||
return True # ya esta en Nearest
|
||||
# Reemplaza un valor existente distinto de 0, o anade la clave a [rendering].
|
||||
if re.search(rf"^{re.escape(key)}=", text, re.MULTILINE):
|
||||
text = re.sub(rf"^{re.escape(key)}=.*$", f"{key}=0", text, flags=re.MULTILINE)
|
||||
elif re.search(r"^\[rendering\]\s*$", text, re.MULTILINE):
|
||||
text = re.sub(r"^\[rendering\]\s*$", f"[rendering]\n\n{key}=0", text,
|
||||
count=1, flags=re.MULTILINE)
|
||||
else:
|
||||
text = text.rstrip() + f"\n\n[rendering]\n\n{key}=0\n"
|
||||
try:
|
||||
with open(project_godot, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def comfyui_export_asset_to_godot(
|
||||
asset_path: str,
|
||||
kind: str,
|
||||
godot_project: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
reimport: bool = True,
|
||||
godot_bin: str | None = None,
|
||||
) -> dict:
|
||||
"""Exporta un asset generado a un proyecto Godot con su .import correcto.
|
||||
|
||||
Args:
|
||||
asset_path: ruta del asset de origen (p.ej. en ~/ComfyUI/output/).
|
||||
kind: tipo de asset: "sprite", "pixelart", "tileset", "vfx", "sfx",
|
||||
"music" o "model".
|
||||
godot_project: ruta raiz del proyecto Godot destino (debe contener
|
||||
project.godot).
|
||||
name: nombre base deseado para el archivo destino (sin extension); si
|
||||
None se deriva del origen (snake_case sin sufijo _NNNNN_). keyword-only.
|
||||
reimport: si True intenta un reimport headless con el binario de Godot.
|
||||
keyword-only.
|
||||
godot_bin: ruta del binario de Godot; None autodetecta (PATH y rutas
|
||||
conocidas). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool)
|
||||
- dest_res_path (str): ruta "res://assets/.../<n>.<ext>".
|
||||
- dest_abs_path (str): ruta absoluta en disco del asset copiado.
|
||||
- import_path (str): ruta absoluta del .import escrito.
|
||||
- import_written (bool)
|
||||
- pixelart_filter_set (bool): True si se aseguro Nearest en project.godot.
|
||||
- reimported (bool): True si el reimport headless salio con exit 0.
|
||||
- warnings (list[str])
|
||||
- error (str): vacio si OK.
|
||||
"""
|
||||
out = {
|
||||
"ok": False, "dest_res_path": "", "dest_abs_path": "", "import_path": "",
|
||||
"import_written": False, "pixelart_filter_set": False, "reimported": False,
|
||||
"warnings": [], "error": "",
|
||||
}
|
||||
|
||||
if not os.path.isfile(asset_path):
|
||||
out["error"] = f"asset_path no existe: {asset_path!r}"
|
||||
return out
|
||||
|
||||
project_godot = os.path.join(godot_project, "project.godot")
|
||||
if not os.path.isfile(project_godot):
|
||||
out["error"] = f"no es un proyecto Godot (falta project.godot): {godot_project!r}"
|
||||
return out
|
||||
|
||||
try:
|
||||
subdir = godot_map_asset_dir(kind)
|
||||
except ValueError as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
clean = godot_clean_asset_name(asset_path, override=name)
|
||||
dest_dir_abs = os.path.join(godot_project, "assets", subdir)
|
||||
dest_abs = os.path.join(dest_dir_abs, clean)
|
||||
res_path = f"res://assets/{subdir}/{clean}"
|
||||
import_abs = dest_abs + ".import"
|
||||
|
||||
# 1. Copiar el asset.
|
||||
try:
|
||||
os.makedirs(dest_dir_abs, exist_ok=True)
|
||||
shutil.copy2(asset_path, dest_abs)
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo copiar el asset: {exc}"
|
||||
return out
|
||||
out["dest_abs_path"] = dest_abs
|
||||
out["dest_res_path"] = res_path
|
||||
|
||||
# 2. Escribir el .import segun el tipo (preservando uid existente).
|
||||
kind_l = kind.strip().lower()
|
||||
uid = _existing_uid(import_abs)
|
||||
if kind_l in _TEXTURE_KINDS:
|
||||
content = _texture_import(res_path, pixelart=(kind_l == "pixelart"), uid=uid)
|
||||
if kind_l in ("tileset", "vfx"):
|
||||
out["warnings"].append(
|
||||
f"{kind_l}: textura copiada; Godot no deriva el TileSet/SpriteFrames "
|
||||
"solo (crear el .tres en editor o por script)."
|
||||
)
|
||||
elif kind_l in ("sfx", "music"):
|
||||
content = _audio_import(res_path, kind_l, uid=uid)
|
||||
elif kind_l == "model":
|
||||
content = _scene_import(res_path, uid=uid)
|
||||
else: # no deberia pasar (map_asset_dir ya valido), defensa.
|
||||
out["error"] = f"kind sin importer: {kind!r}"
|
||||
return out
|
||||
|
||||
try:
|
||||
with open(import_abs, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
out["import_written"] = True
|
||||
out["import_path"] = import_abs
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo escribir el .import: {exc}"
|
||||
return out
|
||||
|
||||
# 3. Filtro Nearest del proyecto si es pixelart (mecanismo Godot 4: global).
|
||||
if kind_l == "pixelart":
|
||||
if _ensure_pixelart_filter(project_godot):
|
||||
out["pixelart_filter_set"] = True
|
||||
else:
|
||||
out["warnings"].append(
|
||||
"no se pudo asegurar default_texture_filter=0 en project.godot"
|
||||
)
|
||||
|
||||
# 4. Reimport headless (best-effort). Sin binario -> deja el .import y anota.
|
||||
if reimport:
|
||||
gbin = _find_godot_bin(godot_bin)
|
||||
if gbin is None:
|
||||
out["warnings"].append(
|
||||
"Godot CLI no encontrado: .import escrito, reimport pendiente "
|
||||
"(abre el editor o pasa godot_bin)."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[gbin, "--headless", "--path", godot_project, "--import"],
|
||||
capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
out["reimported"] = proc.returncode == 0
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
|
||||
out["warnings"].append(
|
||||
f"reimport exit {proc.returncode}: {' / '.join(tail)}"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
out["warnings"].append("reimport headless excedio el timeout (180s)")
|
||||
except OSError as exc:
|
||||
out["warnings"].append(f"reimport headless fallo: {exc}")
|
||||
|
||||
out["ok"] = True
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
if len(sys.argv) < 4:
|
||||
print("uso: comfyui_export_asset_to_godot.py <asset> <kind> <godot_project> [name]",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
a, k, p = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
nm = sys.argv[4] if len(sys.argv) > 4 else None
|
||||
print(json.dumps(comfyui_export_asset_to_godot(a, k, p, name=nm), indent=2))
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests de comfyui_export_asset_to_godot (offline; sin Godot real, reimport mockeado)."""
|
||||
|
||||
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 pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot # noqa: E402
|
||||
|
||||
|
||||
def _godot_proj(tmp_path):
|
||||
"""Crea un proyecto Godot 4 minimo (solo project.godot)."""
|
||||
proj = tmp_path / "godot_proj"
|
||||
proj.mkdir()
|
||||
(proj / "project.godot").write_text(
|
||||
'config_version=5\n\n[application]\n\nconfig/name="Test"\n', encoding="utf-8"
|
||||
)
|
||||
return str(proj)
|
||||
|
||||
|
||||
def _png(tmp_path, name="hero_00001_.png"):
|
||||
p = tmp_path / name
|
||||
Image.fromarray(np.zeros((16, 16, 3), np.uint8), "RGB").save(str(p))
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_golden_pixelart(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
asset = _png(tmp_path)
|
||||
res = comfyui_export_asset_to_godot(asset, "pixelart", proj, reimport=False)
|
||||
assert res["ok"] is True, res["error"]
|
||||
# copiado a sprites/ con nombre limpio
|
||||
assert res["dest_res_path"] == "res://assets/sprites/hero.png"
|
||||
assert os.path.isfile(os.path.join(proj, "assets", "sprites", "hero.png"))
|
||||
# .import textura con Lossless + Nearest global del proyecto
|
||||
imp = open(res["import_path"], encoding="utf-8").read()
|
||||
assert 'importer="texture"' in imp
|
||||
assert "compress/mode=0" in imp
|
||||
assert res["pixelart_filter_set"] is True
|
||||
pg = open(os.path.join(proj, "project.godot"), encoding="utf-8").read()
|
||||
assert "rendering/textures/canvas_textures/default_texture_filter=0" in pg
|
||||
|
||||
|
||||
def test_edge_music_loop_on(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
wav = tmp_path / "theme.wav"
|
||||
wav.write_bytes(b"RIFF....WAVE") # contenido falso: el .import no decodifica aqui
|
||||
res = comfyui_export_asset_to_godot(str(wav), "music", proj, reimport=False)
|
||||
assert res["ok"] is True
|
||||
assert res["dest_res_path"] == "res://assets/audio/music/theme.wav"
|
||||
imp = open(res["import_path"], encoding="utf-8").read()
|
||||
assert 'importer="wav"' in imp
|
||||
assert "edit/loop_mode=1" in imp # musica -> loop ON
|
||||
|
||||
|
||||
def test_edge_sfx_loop_off(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
wav = tmp_path / "step.wav"
|
||||
wav.write_bytes(b"RIFF....WAVE")
|
||||
res = comfyui_export_asset_to_godot(str(wav), "sfx", proj, reimport=False)
|
||||
assert res["ok"] is True
|
||||
imp = open(res["import_path"], encoding="utf-8").read()
|
||||
assert "edit/loop_mode=0" in imp # sfx -> loop OFF
|
||||
|
||||
|
||||
def test_edge_model_glb_scene(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
glb = tmp_path / "robot_00001_.glb"
|
||||
glb.write_bytes(b"glTF....")
|
||||
res = comfyui_export_asset_to_godot(str(glb), "model", proj, reimport=False)
|
||||
assert res["ok"] is True
|
||||
assert res["dest_res_path"] == "res://assets/models/robot.glb"
|
||||
imp = open(res["import_path"], encoding="utf-8").read()
|
||||
assert 'importer="scene"' in imp
|
||||
|
||||
|
||||
def test_edge_tileset_warns(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
res = comfyui_export_asset_to_godot(_png(tmp_path, "tiles_00001_.png"), "tileset", proj,
|
||||
reimport=False)
|
||||
assert res["ok"] is True
|
||||
assert any("TileSet" in w for w in res["warnings"])
|
||||
|
||||
|
||||
def test_idempotent_preserves_uid(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
asset = _png(tmp_path)
|
||||
r1 = comfyui_export_asset_to_godot(asset, "sprite", proj, reimport=False)
|
||||
# inyecta un uid en el .import como si Godot ya lo hubiera asignado
|
||||
imp_path = r1["import_path"]
|
||||
txt = open(imp_path, encoding="utf-8").read().replace(
|
||||
'importer="texture"\n', 'importer="texture"\nuid="uid://abc123xyz"\n'
|
||||
)
|
||||
open(imp_path, "w", encoding="utf-8").write(txt)
|
||||
r2 = comfyui_export_asset_to_godot(asset, "sprite", proj, reimport=False)
|
||||
assert 'uid="uid://abc123xyz"' in open(r2["import_path"], encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_error_missing_asset(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
res = comfyui_export_asset_to_godot(str(tmp_path / "nope.png"), "sprite", proj)
|
||||
assert res["ok"] is False
|
||||
assert "no existe" in res["error"]
|
||||
|
||||
|
||||
def test_error_bad_kind(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
res = comfyui_export_asset_to_godot(_png(tmp_path), "hologram", proj, reimport=False)
|
||||
assert res["ok"] is False
|
||||
|
||||
|
||||
def test_error_not_a_godot_project(tmp_path):
|
||||
asset = _png(tmp_path)
|
||||
res = comfyui_export_asset_to_godot(asset, "sprite", str(tmp_path / "empty"))
|
||||
assert res["ok"] is False
|
||||
assert "project.godot" in res["error"]
|
||||
|
||||
|
||||
def test_godot_cli_absent_leaves_import(tmp_path):
|
||||
proj = _godot_proj(tmp_path)
|
||||
asset = _png(tmp_path)
|
||||
res = comfyui_export_asset_to_godot(asset, "sprite", proj, reimport=True,
|
||||
godot_bin="/no/such/godot")
|
||||
assert res["ok"] is True
|
||||
assert res["import_written"] is True
|
||||
assert res["reimported"] is False
|
||||
assert any("Godot CLI no encontrado" in w for w in res["warnings"])
|
||||
Reference in New Issue
Block a user