feat(gamedev): comfyui_build_normal_map_workflow — normal/depth map de sprite para 2.5D
Builder puro (dict API format) que genera el normal map (o depth/height) de un sprite existente para iluminacion dinamica 2.5D (Godot normal_map, Unity sprite normal). Pipeline LoadImage -> preprocesador controlnet_aux -> SaveImage, ~0 VRAM. method selecciona el nodo (verificados contra /object_info): - normal (default): BAE-NormalMapPreprocessor, normal canonico azul/violeta usable directo en motor. - normal_midas: MiDaS, unico con control de intensidad (strength -> a). - normal_dsine: DSINE. depth: DepthAnythingV2 (height en gris). Nodos de normal NATIVOS, sin necesidad de depth->Sobel post (gap innecesario). 11 tests offline verdes. Probado e2e en GPU (8GB): normal map de un icono de pocion, prompt_id d47f9943, tono azul/violeta verificado. Report 0150. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: comfyui_build_normal_map_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_normal_map_workflow(image: str, *, method: str = \"normal\", strength: float = 1.0, resolution: int = 512, bg_threshold: float = 0.1, filename_prefix: str = \"normal_map\") -> dict # method='normal' -> BAE (canonico)"
|
||||
description: "Construye el dict (API format) de un workflow ComfyUI que genera el NORMAL MAP (o DEPTH/HEIGHT) de un sprite para iluminacion dinamica 2.5D (Godot CanvasItem normal_map, Unity sprite normal). Pipeline LoadImage -> preprocesador controlnet_aux -> SaveImage. method elige el nodo: 'normal' (BAE, default, normal canonico azul/violeta), 'normal_midas' (MiDaS, con strength), 'normal_dsine' (DSINE), 'depth' (DepthAnythingV2 height). Nodos de normal NATIVOS, sin necesidad de depth->Sobel post. Pura, sin red ni I/O. class_types verificados contra /object_info."
|
||||
tags: [comfyui, ml, gamedev, gamedev-2d, normal-map, depth-map, lighting, sprite, workflow, controlnet-aux]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: image
|
||||
desc: "Nombre del archivo de entrada dentro del directorio input/ de ComfyUI (lo que consume LoadImage). No vacio. Si el sprite vive en output/, copialo a input/ antes de enviar (I/O del runner)."
|
||||
- name: method
|
||||
desc: "Preprocesador: 'normal' (default, BAE-NormalMapPreprocessor, normal canonico azul/violeta usable en motor), 'normal_midas' (MiDaS, con strength, paleta no canonica), 'normal_dsine' (DSINE), 'depth' (DepthAnythingV2, height map en gris). keyword-only."
|
||||
- name: strength
|
||||
desc: "Intensidad del relieve. Solo efectivo con method='normal_midas' (MiDaS): a = clamp(strength*2pi, 0, 5pi); strength=1.0 -> a=2pi. Debe ser >= 0. Para los demas metodos se ignora. keyword-only."
|
||||
- name: resolution
|
||||
desc: "Lado en px al que el preprocesador re-escala antes de inferir (64..16384). Conviene matchear el lado del sprite. keyword-only."
|
||||
- name: bg_threshold
|
||||
desc: "Umbral de fondo de MiDaS (0..1). Solo aplica a method='normal_midas'. keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG resultante en output/. keyword-only."
|
||||
output: "dict en API format con 3 nodos (LoadImage -> preprocesador normal/depth -> SaveImage), listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["golden: 3 nodos LoadImage->BAE-NormalMapPreprocessor->SaveImage, image reflejado, sin a", "edge image reflejado en LoadImage", "edge strength escala a linealmente y satura en 5pi (method=normal_midas), default BAE sin a", "edge method='depth' selecciona DepthAnythingV2Preprocessor sin a", "edge method midas/dsine seleccionan su nodo", "edge resolution/filename_prefix reflejados", "error image vacio / method invalido / strength negativo / resolution fuera de rango -> ValueError", "determinismo"]
|
||||
test_file_path: "python/functions/ml/comfyui_build_normal_map_workflow_test.py"
|
||||
file_path: "python/functions/ml/comfyui_build_normal_map_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_build_normal_map_workflow import comfyui_build_normal_map_workflow
|
||||
|
||||
# Normal map CANONICO azul/violeta (BAE) de un sprite ya copiado a input/ de ComfyUI.
|
||||
wf = comfyui_build_normal_map_workflow(
|
||||
"item_icon_potion_00001_.png",
|
||||
method="normal", # BAE: normal tangent-space usable directo en Godot/Unity
|
||||
resolution=512,
|
||||
filename_prefix="potion_normal",
|
||||
)
|
||||
# wf -> comfyui_submit_workflow(wf) ; luego esperar y leer el PNG de output/.
|
||||
|
||||
# Variante con control de intensidad de relieve (MiDaS, paleta no canonica):
|
||||
wf_midas = comfyui_build_normal_map_workflow("sprite.png", method="normal_midas", strength=1.5)
|
||||
|
||||
# Variante depth/height (escala de grises) para parallax/height:
|
||||
wf_depth = comfyui_build_normal_map_workflow("bg_layer.png", method="depth")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando un sprite/imagen 2D debe reaccionar a **luces dinamicas** del motor (2.5D
|
||||
lighting): generas su normal map y lo enchufas en `Godot Sprite2D.material` (normal_map)
|
||||
o como secondary texture "Normal map" del sprite en Unity.
|
||||
- Cuando necesitas un **height/depth map** de un fondo o tile para parallax o
|
||||
desplazamiento (usa `method="depth"`).
|
||||
- Builder hermano de los `comfyui_build_*_workflow` del grupo `gamedev-2d`: produce el
|
||||
dict y delega el envio a `comfyui_submit_workflow`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`image` vive en input/, no en output/.** LoadImage solo ve el directorio `input/` de
|
||||
ComfyUI. Para usar un sprite generado (que esta en `output/`), copialo a `input/` antes
|
||||
de enviar el workflow. Esta funcion es pura: no copia nada.
|
||||
- **`strength` solo afecta a `method="normal_midas"` (MiDaS).** El default BAE, DSINE y
|
||||
DepthAnything no exponen intensidad — con esos metodos `strength` se ignora (documentado,
|
||||
no es bug).
|
||||
- **MiDaS (`normal_midas`) NO da el azul/violeta canonico**: su paleta domina verde/rosa
|
||||
(otra convencion de color). Para un normal map usable directo en motor usa el default
|
||||
`method="normal"` (BAE), que da el azul/violeta tangent-space (~(128,128,255) de fondo).
|
||||
Usa MiDaS solo cuando necesites variar el relieve via `strength`.
|
||||
- **`method="depth"` NO produce un normal map**, sino un depth/height en escala de grises.
|
||||
- **Primera ejecucion descarga pesos** del preprocesador (MiDaS/BAE/DSINE/DepthAnything)
|
||||
via controlnet_aux si no estan en cache. Requiere red la 1a vez; despues es local.
|
||||
- **No se necesita depth->Sobel->normal post.** Hay nodos de normal NATIVOS en el server,
|
||||
asi que una `comfyui_depth_to_normal` auxiliar es innecesaria hoy (solo se justificaria
|
||||
para derivar el normal de un depth de mas calidad — gap anotado, no creado).
|
||||
- Coste VRAM ~0 (no hay difusion): apto para el server 8GB lowvram.
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Construye un workflow ComfyUI de NORMAL MAP (o DEPTH/HEIGHT) de un sprite, en API format.
|
||||
|
||||
Para iluminacion dinamica 2.5D: un sprite plano reacciona a luces del motor si se le
|
||||
acompana de su mapa de normales (Godot CanvasItem `normal_map`, Unity Sprite secondary
|
||||
texture "Normal map"). El normal map se ve azul/violeta (XY en R/G, Z en B) — el tono
|
||||
plano dominante es ~(128,128,255).
|
||||
|
||||
Pipeline minimo (sin difusion, ~0 VRAM extra):
|
||||
|
||||
LoadImage ─IMAGE─► <preprocesador normal/depth> ─IMAGE─► SaveImage
|
||||
|
||||
El preprocesador lo elige `method`, y todos son nodos del pack controlnet_aux
|
||||
(ya instalado). class_types verificados EXACTOS contra /object_info del servidor:
|
||||
|
||||
- method="normal" -> 'BAE-NormalMapPreprocessor' inputs: image, resolution
|
||||
DEFAULT. Produce el normal map CANONICO azul/violeta (fondo plano ~(128,128,255),
|
||||
convencion tangent-space que Godot/Unity consumen directamente). Sin parametro de
|
||||
intensidad -> `strength` se ignora. Es el resultado usable out-of-the-box.
|
||||
- method="normal_midas" -> 'MiDaS-NormalMapPreprocessor'
|
||||
inputs: image(IMAGE), a(FLOAT, default 2*pi), bg_threshold(FLOAT), resolution(INT)
|
||||
UNICO normal-preprocessor con control de intensidad nativo (`a`), por eso es el que
|
||||
mapea `strength` (a = clamp(strength*2*pi, 0, 5*pi)). OJO: su paleta de color NO es
|
||||
el azul/violeta canonico (domina verde/rosa); usalo solo si necesitas variar el
|
||||
relieve via strength, no como normal map directo de motor.
|
||||
- method="normal_dsine" -> 'DSINE-NormalMapPreprocessor' inputs: image, fov, iterations, resolution
|
||||
Normal alternativo (DSINE); `strength` se ignora (usa fov/iterations por defecto).
|
||||
- method="depth" -> 'DepthAnythingV2Preprocessor' inputs: image, ckpt_name, resolution
|
||||
Produce un DEPTH/HEIGHT map (escala de grises), NO un normal. Util para
|
||||
parallax/height; `strength` se ignora (DepthAnything no tiene intensidad ajustable).
|
||||
|
||||
Nota de diseno: el default es "normal" (BAE, no "depth") porque el objetivo de la funcion
|
||||
es producir el normal map azul/violeta usable directamente. Hay nodos de normal NATIVOS en
|
||||
el servidor, asi que NO se necesita el camino depth -> Sobel -> normal como post (esa
|
||||
funcion auxiliar `comfyui_depth_to_normal` queda como posible futura solo si se quisiera
|
||||
derivar el normal de un depth de mayor calidad; hoy es innecesaria).
|
||||
|
||||
`image` es el nombre del archivo dentro del directorio `input/` de ComfyUI (lo que espera
|
||||
LoadImage). Para usar un sprite que ya esta en `output/`, hay que copiarlo a `input/`
|
||||
antes de enviar el workflow (eso es I/O del runner, no de esta funcion pura).
|
||||
|
||||
Funcion pura: sin red, sin I/O, sin mutar entradas. Devuelve un dict nuevo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
_TWO_PI = 6.283185307179586
|
||||
_MAX_A = 15.707963267948966 # 5*pi, el max que admite MiDaS-NormalMapPreprocessor.a
|
||||
|
||||
# method -> class_type EXACTO del nodo preprocesador (verificado contra /object_info).
|
||||
_METHODS = {
|
||||
"normal": "BAE-NormalMapPreprocessor",
|
||||
"normal_midas": "MiDaS-NormalMapPreprocessor",
|
||||
"normal_dsine": "DSINE-NormalMapPreprocessor",
|
||||
"depth": "DepthAnythingV2Preprocessor",
|
||||
}
|
||||
|
||||
|
||||
def comfyui_build_normal_map_workflow(
|
||||
image: str,
|
||||
*,
|
||||
method: str = "normal",
|
||||
strength: float = 1.0,
|
||||
resolution: int = 512,
|
||||
bg_threshold: float = 0.1,
|
||||
filename_prefix: str = "normal_map",
|
||||
) -> dict:
|
||||
"""Construye el dict (API format) de un workflow de normal/depth map de un sprite.
|
||||
|
||||
Args:
|
||||
image: nombre del archivo de entrada dentro del directorio `input/` de ComfyUI
|
||||
(lo que consume LoadImage). No puede estar vacio. Si el sprite vive en
|
||||
`output/`, copialo antes a `input/` (I/O del runner).
|
||||
method: preprocesador a usar. keyword-only.
|
||||
'normal' (default, BAE, normal map canonico azul/violeta usable en motor),
|
||||
'normal_midas' (MiDaS, con `strength`, paleta no canonica), 'normal_dsine'
|
||||
(DSINE), 'depth' (DepthAnythingV2, height map en gris).
|
||||
strength: intensidad del relieve del normal map. Solo tiene efecto con
|
||||
method='normal_midas' (MiDaS): se mapea a `a` = clamp(strength*2*pi, 0, 5*pi);
|
||||
strength=1.0 -> a=2*pi (relieve estandar). Debe ser >= 0. Para los demas
|
||||
metodos se ignora (el nodo no expone intensidad). keyword-only.
|
||||
resolution: lado en px al que el preprocesador re-escala antes de inferir
|
||||
(64..16384). Conviene matchear el lado del sprite. keyword-only.
|
||||
bg_threshold: umbral de fondo de MiDaS (0..1). Solo aplica a method='normal_midas'.
|
||||
keyword-only.
|
||||
filename_prefix: prefijo del PNG resultante en output/. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format con 3 nodos (LoadImage -> preprocesador -> SaveImage), listo
|
||||
para comfyui_submit_workflow.
|
||||
|
||||
Raises:
|
||||
ValueError: si image esta vacio, method no es valido, strength < 0, o
|
||||
resolution esta fuera de 64..16384.
|
||||
"""
|
||||
if not image or not image.strip():
|
||||
raise ValueError("comfyui_build_normal_map_workflow: 'image' no puede estar vacio")
|
||||
if method not in _METHODS:
|
||||
raise ValueError(
|
||||
f"comfyui_build_normal_map_workflow: method debe ser uno de "
|
||||
f"{tuple(_METHODS)}, no {method!r}"
|
||||
)
|
||||
if strength < 0:
|
||||
raise ValueError(
|
||||
f"comfyui_build_normal_map_workflow: strength debe ser >= 0, no {strength!r}"
|
||||
)
|
||||
if not (64 <= resolution <= 16384):
|
||||
raise ValueError(
|
||||
f"comfyui_build_normal_map_workflow: resolution debe estar en 64..16384, "
|
||||
f"no {resolution!r}"
|
||||
)
|
||||
|
||||
class_type = _METHODS[method]
|
||||
|
||||
preproc_inputs = {"image": ["1", 0], "resolution": resolution}
|
||||
if method == "normal_midas":
|
||||
# MiDaS expone `a` (intensidad) y `bg_threshold`; ahi se refleja `strength`.
|
||||
preproc_inputs["a"] = min(max(strength * _TWO_PI, 0.0), _MAX_A)
|
||||
preproc_inputs["bg_threshold"] = bg_threshold
|
||||
|
||||
return {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": image}},
|
||||
"2": {"class_type": class_type, "inputs": preproc_inputs},
|
||||
"3": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": ["2", 0], "filename_prefix": filename_prefix},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_normal_map_workflow(
|
||||
"item_icon_potion_00001_.png", method="normal"
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests offline de comfyui_build_normal_map_workflow (estructura del dict, sin GPU)."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ml.comfyui_build_normal_map_workflow import ( # noqa: E402
|
||||
comfyui_build_normal_map_workflow,
|
||||
)
|
||||
|
||||
_TWO_PI = 6.283185307179586
|
||||
|
||||
|
||||
def _by_class(wf, cls):
|
||||
return [n for n in wf.values() if n["class_type"] == cls]
|
||||
|
||||
|
||||
def test_golden_normal_default():
|
||||
wf = comfyui_build_normal_map_workflow("sprite.png")
|
||||
# 3 nodos: LoadImage -> BAE-NormalMapPreprocessor (canonico) -> SaveImage.
|
||||
assert len(wf) == 3
|
||||
load = _by_class(wf, "LoadImage")
|
||||
assert len(load) == 1
|
||||
assert load[0]["inputs"]["image"] == "sprite.png"
|
||||
pre = _by_class(wf, "BAE-NormalMapPreprocessor")
|
||||
assert len(pre) == 1
|
||||
# El preprocesador consume el IMAGE del LoadImage.
|
||||
assert pre[0]["inputs"]["image"] == ["1", 0]
|
||||
assert pre[0]["inputs"]["resolution"] == 512
|
||||
# BAE no expone intensidad: no hay `a`.
|
||||
assert "a" not in pre[0]["inputs"]
|
||||
save = _by_class(wf, "SaveImage")
|
||||
assert len(save) == 1
|
||||
assert save[0]["inputs"]["images"] == ["2", 0]
|
||||
assert save[0]["inputs"]["filename_prefix"] == "normal_map"
|
||||
|
||||
|
||||
def test_edge_image_reflected():
|
||||
wf = comfyui_build_normal_map_workflow("portrait_knight_woman_00001_.png")
|
||||
assert _by_class(wf, "LoadImage")[0]["inputs"]["image"] == "portrait_knight_woman_00001_.png"
|
||||
|
||||
|
||||
def test_edge_strength_reflected_in_a():
|
||||
# strength escala `a` linealmente (a = strength * 2*pi) con method='normal_midas'.
|
||||
wf = comfyui_build_normal_map_workflow("s.png", method="normal_midas", strength=0.5)
|
||||
a = _by_class(wf, "MiDaS-NormalMapPreprocessor")[0]["inputs"]["a"]
|
||||
assert math.isclose(a, 0.5 * _TWO_PI)
|
||||
# strength alto satura en 5*pi (max del nodo).
|
||||
wf2 = comfyui_build_normal_map_workflow("s.png", method="normal_midas", strength=10.0)
|
||||
a2 = _by_class(wf2, "MiDaS-NormalMapPreprocessor")[0]["inputs"]["a"]
|
||||
assert math.isclose(a2, 15.707963267948966)
|
||||
# El default (BAE) no usa strength: sin `a`.
|
||||
wf3 = comfyui_build_normal_map_workflow("s.png", strength=3.0)
|
||||
assert "a" not in _by_class(wf3, "BAE-NormalMapPreprocessor")[0]["inputs"]
|
||||
|
||||
|
||||
def test_edge_method_depth_selects_depthanything():
|
||||
wf = comfyui_build_normal_map_workflow("s.png", method="depth")
|
||||
assert len(_by_class(wf, "DepthAnythingV2Preprocessor")) == 1
|
||||
assert len(_by_class(wf, "MiDaS-NormalMapPreprocessor")) == 0
|
||||
# depth no usa `a`/bg_threshold (no expone intensidad).
|
||||
pre = _by_class(wf, "DepthAnythingV2Preprocessor")[0]
|
||||
assert "a" not in pre["inputs"]
|
||||
assert pre["inputs"]["resolution"] == 512
|
||||
|
||||
|
||||
def test_edge_method_midas_and_dsine():
|
||||
wf_midas = comfyui_build_normal_map_workflow("s.png", method="normal_midas")
|
||||
midas = _by_class(wf_midas, "MiDaS-NormalMapPreprocessor")
|
||||
assert len(midas) == 1
|
||||
assert "a" in midas[0]["inputs"] and "bg_threshold" in midas[0]["inputs"]
|
||||
wf_ds = comfyui_build_normal_map_workflow("s.png", method="normal_dsine")
|
||||
assert len(_by_class(wf_ds, "DSINE-NormalMapPreprocessor")) == 1
|
||||
|
||||
|
||||
def test_edge_resolution_and_prefix():
|
||||
wf = comfyui_build_normal_map_workflow(
|
||||
"s.png", resolution=1024, filename_prefix="potion_normal"
|
||||
)
|
||||
assert _by_class(wf, "BAE-NormalMapPreprocessor")[0]["inputs"]["resolution"] == 1024
|
||||
assert _by_class(wf, "SaveImage")[0]["inputs"]["filename_prefix"] == "potion_normal"
|
||||
|
||||
|
||||
def test_error_empty_image():
|
||||
with pytest.raises(ValueError):
|
||||
comfyui_build_normal_map_workflow("")
|
||||
with pytest.raises(ValueError):
|
||||
comfyui_build_normal_map_workflow(" ")
|
||||
|
||||
|
||||
def test_error_bad_method():
|
||||
with pytest.raises(ValueError):
|
||||
comfyui_build_normal_map_workflow("s.png", method="bogus")
|
||||
|
||||
|
||||
def test_error_negative_strength():
|
||||
with pytest.raises(ValueError):
|
||||
comfyui_build_normal_map_workflow("s.png", strength=-1.0)
|
||||
|
||||
|
||||
def test_error_resolution_out_of_range():
|
||||
with pytest.raises(ValueError):
|
||||
comfyui_build_normal_map_workflow("s.png", resolution=32)
|
||||
with pytest.raises(ValueError):
|
||||
comfyui_build_normal_map_workflow("s.png", resolution=20000)
|
||||
|
||||
|
||||
def test_determinism():
|
||||
a = comfyui_build_normal_map_workflow("s.png", strength=0.7, resolution=768)
|
||||
b = comfyui_build_normal_map_workflow("s.png", strength=0.7, resolution=768)
|
||||
assert a == b
|
||||
Reference in New Issue
Block a user