From 3980fbbffbfe1ab83d8c859ce6833d1779897dcb Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 26 Jun 2026 23:01:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(gamedev):=20comfyui=5Fbuild=5Fnormal=5Fmap?= =?UTF-8?q?=5Fworkflow=20=E2=80=94=20normal/depth=20map=20de=20sprite=20pa?= =?UTF-8?q?ra=202.5D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/capabilities/gamedev-2d.md | 1 + .../ml/comfyui_build_normal_map_workflow.md | 88 ++++++++++++ .../ml/comfyui_build_normal_map_workflow.py | 135 ++++++++++++++++++ .../comfyui_build_normal_map_workflow_test.py | 114 +++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 python/functions/ml/comfyui_build_normal_map_workflow.md create mode 100644 python/functions/ml/comfyui_build_normal_map_workflow.py create mode 100644 python/functions/ml/comfyui_build_normal_map_workflow_test.py diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index 74d4d7c9..aa948492 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -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) diff --git a/python/functions/ml/comfyui_build_normal_map_workflow.md b/python/functions/ml/comfyui_build_normal_map_workflow.md new file mode 100644 index 00000000..9ed18f75 --- /dev/null +++ b/python/functions/ml/comfyui_build_normal_map_workflow.md @@ -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. diff --git a/python/functions/ml/comfyui_build_normal_map_workflow.py b/python/functions/ml/comfyui_build_normal_map_workflow.py new file mode 100644 index 00000000..adb8ac90 --- /dev/null +++ b/python/functions/ml/comfyui_build_normal_map_workflow.py @@ -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─► ─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)) diff --git a/python/functions/ml/comfyui_build_normal_map_workflow_test.py b/python/functions/ml/comfyui_build_normal_map_workflow_test.py new file mode 100644 index 00000000..b5a59c94 --- /dev/null +++ b/python/functions/ml/comfyui_build_normal_map_workflow_test.py @@ -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