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:
2026-06-26 23:01:08 +02:00
parent 4886305d49
commit 3980fbbffb
4 changed files with 338 additions and 0 deletions
+1
View File
@@ -38,6 +38,7 @@ VFX (ver `reports/0143`).
| `comfyui_build_item_icon_workflow_py_ml` | `(item, *, style="game icon, clean, centered", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN icono de item de inventario (espada/poción/anillo/libro/escudo): txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg (alpha). Set coherente = mismo style/checkpoint/lora por item. SD1.5. |
| `comfyui_build_portrait_avatar_workflow_py_ml` | `(character, *, style="character portrait", ref_face=None, checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN retrato/avatar de personaje (busto centrado, cara al espectador, fondo simple): txt2img + prompt scaffold de retrato + FaceDetailer (cara nítida) + LoRA estilo opcional; `ref_face` → IPAdapter-FaceID para rostro consistente entre retratos. Diálogo/perfil/selección. SD1.5. |
| `comfyui_build_parallax_background_workflow_py_ml` | `(scene, *, style="game background, side-scroller…", layers=3, checkpoint="dreamshaper_8…", depth_node="DepthAnythingV2Preprocessor", width=1024, height=512, …) -> dict` | Fondo en capas para parallax 2.5D: genera el fondo apaisado (txt2img) + su depth map (`DepthAnythingV2Preprocessor` sobre el VAEDecode), dos SaveImage. El split en N bandas por profundidad es post (GAP: `split_parallax_layers`, aún no creada). Probado e2e en GPU (`reports/0149`). SD1.5. |
| `comfyui_build_normal_map_workflow_py_ml` | `(image, *, method="normal", strength=1.0, resolution=512, bg_threshold=0.1, filename_prefix="normal_map") -> dict` | Normal/depth map de un sprite existente para iluminación dinámica 2.5D (Godot CanvasItem `normal_map`, Unity sprite normal). `LoadImage → preprocesador controlnet_aux → SaveImage`. `method`: `normal` (default, `BAE-NormalMapPreprocessor`, normal canónico **azul/violeta** usable directo en motor), `normal_midas` (MiDaS, único con `strength``a`, paleta no canónica), `normal_dsine` (DSINE), `depth` (`DepthAnythingV2`, height en gris). `image` debe estar en `input/` de ComfyUI. Coste VRAM ≈0. Probado e2e en GPU (`reports/0150`). |
## Funciones de post-proceso y puente (`gamedev`, CPU)
@@ -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