Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 | |||
| c79f33265e | |||
| 31c2f6ac7f | |||
| 3bc97828e3 | |||
| ccdd529bdc | |||
| 741724f633 | |||
| 2be62f6ef6 | |||
| 8e9e1e6c8a | |||
| ec46aae04c | |||
| b173ac2703 | |||
| ec0a5e53ac | |||
| 5280499df5 | |||
| 346f859b86 | |||
| 604d3d4feb | |||
| 287abbd6ee |
@@ -14,7 +14,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
|
|
||||||
| Grupo | N | Que cubre |
|
| Grupo | N | Que cubre |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [gamedev-2d](gamedev-2d.md) | 36 | Assets 2D para Godot via ComfyUI: 31 builders de workflow (pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX...) + 5 de apoyo: post-proceso (pixelize, luma->alpha) + puente de assets a Godot 4 (.import + reimport headless). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
| [gamedev-2d](gamedev-2d.md) | 47 | Assets 2D para Godot via ComfyUI: 36 builders de workflow (31 de generación desde texto: pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX... + 5 de transformación desde imagen: asset_variant/sprite_from_sketch/inpaint_asset/outpaint_asset/directional_sprite) + 11 de apoyo: post-proceso (pixelize, luma->alpha, flatten_alpha), puente de assets a Godot 4 (.import + reimport headless), style presets (get/apply_gamedev_style_preset) y pipelines one-shot (asset_pack/character_set/styled_asset). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
||||||
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
|
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
|
||||||
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
||||||
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
||||||
@@ -72,8 +72,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
||||||
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
||||||
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
|
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
|
||||||
| [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot |
|
| [comfyui](comfyui.md) | 126 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Cubre txt2img/img2img/inpaint/controlnet/sdxl-refiner/flux, upscale + hires-fix + facedetailer, vídeo (LTX/Wan/SVD), audio (ACE-Step), imagen→3D nativo (Hunyuan3D-2) + post-proceso de malla, templates oficiales, civitai harvest y control de cola. N = funciones con tag `comfyui` (incluye los sub-grupos `comfyui-skill`/`comfyui-styles` y 45 de `gamedev-2d`); las páginas madre de cada sub-grupo desglosan su parte |
|
||||||
| [comfyui-skill](comfyui-skill.md) | 11 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
| [comfyui-skill](comfyui-skill.md) | 17 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
||||||
|
| [comfyui-styles](comfyui-styles.md) | 5 | Capa de estilo reutilizable sobre los builders ComfyUI. Catálogo WAS (tag `comfyui-styles`): `curated_styles_catalog` (~190 estilos), `generate_styles_llm` (genera estilos por LLM via ask_llm), `append_styles` (merge+dedup+backup sobre el styles.json del selector WAS). Style presets gamedev (tag `gamedev-2d`): `get_gamedev_style_preset` (gameboy/ghibli/pixel-art-retro como datos puros) + `apply_style_preset` (preset+subject → kwargs de un builder gamedev-2d). El estilo se trata como dato curado, no como prompt repetido |
|
||||||
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
|
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
|
||||||
|
|
||||||
## Como anadir grupo
|
## Como anadir grupo
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ Las tres páginas madre detalladas siguen siendo la fuente de verdad por grupo:
|
|||||||
|
|
||||||
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
|
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
|
||||||
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
|
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
|
||||||
|
- [comfyui-styles.md](comfyui-styles.md) — grupo `comfyui-styles`: presets + catálogo de estilo (selector WAS).
|
||||||
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
||||||
|
- [gamedev-2d.md](gamedev-2d.md) — grupo `gamedev-2d`: 47 builders de assets 2D para Godot (45 también `comfyui`).
|
||||||
|
|
||||||
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
|
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
|
||||||
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
|
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
|
||||||
@@ -25,7 +27,9 @@ Filtros MCP: `mcp__registry__fn_search query="" tag="comfyui"` (y `tag="comfyui-
|
|||||||
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
|
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
|
||||||
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
|
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
|
||||||
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
|
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
|
||||||
|
| 04b | **styles (presets/catálogo)** | estilo reutilizable: catálogo WAS + presets gamedev | `curated_styles_catalog`, `generate_styles_llm`, `append_styles`, `get_gamedev_style_preset`, `apply_style_preset` | — | — |
|
||||||
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
||||||
|
| 05b | **audio** | texto → música/SFX/voz (ACE-Step) | `build_audio_workflow`, `fetch_output_audio` | — | — |
|
||||||
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
|
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
|
||||||
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
|
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
|
||||||
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
|
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
|
||||||
@@ -67,6 +71,16 @@ sus IDs reales cuando se ejecute `fn index`.
|
|||||||
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
|
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
|
||||||
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
|
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
|
||||||
|
|
||||||
|
### 04b · styles (presets / catálogo)
|
||||||
|
|
||||||
|
Página madre: [comfyui-styles.md](comfyui-styles.md). Estilo reutilizable como dato, no como prompt repetido.
|
||||||
|
|
||||||
|
- `comfyui_curated_styles_catalog_py_ml` (pura) — catálogo curado (~190 estilos) para el selector WAS.
|
||||||
|
- `comfyui_generate_styles_llm_py_ml` (impura) — genera N estilos de una categoría vía `ask_llm`.
|
||||||
|
- `comfyui_append_styles_py_ml` (impura) — fusiona estilos sobre el `styles.json` WAS (merge+dedup+backup).
|
||||||
|
- `comfyui_get_gamedev_style_preset_py_ml` (pura) — receta de *style preset* gamedev (gameboy/ghibli/pixel-art-retro).
|
||||||
|
- `comfyui_apply_style_preset_py_ml` (pura) — traduce un preset + subject a los kwargs de un builder gamedev-2d.
|
||||||
|
|
||||||
### 05 · video
|
### 05 · video
|
||||||
|
|
||||||
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
|
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
|
|||||||
|
|
||||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
|
||||||
|
|
||||||
|
> **Tamaño del grupo (al 28/06/2026):** 17 funciones con tag `comfyui-skill` — CRUD de recetas
|
||||||
|
> (save/load/list), compilación a workflow (`build_skill_workflow`), inyectores encadenables
|
||||||
|
> (`inject_hires_fix`/`inject_multi_lora`, `build_ipadapter_workflow`), bucle de mejora
|
||||||
|
> genera→juzga→bump (`generate_with_skill_oneshot` + `update_skill_score` + `bump_skill_version`),
|
||||||
|
> export a grafo (`export_skill_template`), mixer de capacidades (`compose_capabilities` +
|
||||||
|
> `generate_mixed_oneshot`) y cosecha de Civitai (`extract_recipe_from_png` + `harvest_civitai_skill_oneshot`).
|
||||||
|
|
||||||
## Qué es una skill
|
## Qué es una skill
|
||||||
|
|
||||||
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
|
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# ComfyUI Styles — presets y catálogo de estilo
|
||||||
|
|
||||||
|
Tag: `comfyui-styles` (+ `gamedev-2d` para los dos presets gamedev). Sub-grupo de
|
||||||
|
[`comfyui`](comfyui.md) que añade una **capa de estilo reutilizable** sobre los builders de
|
||||||
|
workflow: en vez de repetir a mano los mismos modificadores de cámara/iluminación/render en cada
|
||||||
|
prompt, el estilo se trata como un dato curado y reusable.
|
||||||
|
|
||||||
|
Dos vertientes complementarias:
|
||||||
|
|
||||||
|
- **Catálogo WAS** (`comfyui-styles`): ~190 estilos curados en el formato exacto del selector WAS de
|
||||||
|
ComfyUI (*Prompt Styles Selector* / *Prompt Multiple Styles Selector*), generación de estilos
|
||||||
|
nuevos por LLM, y fusión segura sobre el `styles.json` del usuario.
|
||||||
|
- **Style presets gamedev** (`gamedev-2d`): recetas que empaquetan como datos puros el *look* de un
|
||||||
|
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño, post-proceso) y se
|
||||||
|
traducen a los kwargs que consume un builder de sujeto del grupo [`gamedev-2d`](gamedev-2d.md).
|
||||||
|
|
||||||
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-styles"` (catálogo WAS) y
|
||||||
|
`mcp__registry__fn_search query="style preset" tag="gamedev-2d"` (presets gamedev).
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
### Catálogo WAS — dominio `ml` (tag `comfyui-styles`)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en el formato exacto `{nombre: {prompt, negative_prompt}}` que consume el selector WAS. Cada `prompt` son modificadores de estilo potentes (cámara, lente, iluminación, render engine, medio artístico, paleta, mood), no descripciones de escena. Filtra por `category`. **Pura**. |
|
||||||
|
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-20251001') -> dict` | Genera N estilos de una categoría temática usando `ask_llm` (grupo claude-direct, API directa, arranque 0), en el mismo formato `{nombre: {prompt, negative_prompt}}`. `avoid` evita duplicar nombres ya existentes. **Impura** (LLM). |
|
||||||
|
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=DEFAULT_STYLES_PATH, overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup por nombre) un dict de estilos sobre el `styles.json` del selector WAS de forma SEGURA y NO destructiva: preserva todos los existentes (ganan salvo `overwrite=True`), hace backup con timestamp antes de escribir. `dry_run=True` previsualiza sin tocar disco. **Impura** (I/O disco). |
|
||||||
|
|
||||||
|
### Style presets gamedev — dominio `ml` (tag `gamedev-2d`)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (`gameboy`, `ghibli`, `pixel-art-retro`) o el catálogo de nombres si `name=None`. Un preset empaqueta como DATOS puros el look de un juego entero: `subject_prefix`/`suffix`, `style`, `negative`, checkpoint recomendado, LoRA + strength, `size`, `transparent`, post-proceso. **Pura**. |
|
||||||
|
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev (de `get_gamedev_style_preset`) + un `subject` del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo y los kwargs comunes (`style`, `checkpoint`, `lora`, `lora_strength`, `negative`, resolución) listos para `**spread`. `style`/`negative` permiten override puntual. **Pura**. |
|
||||||
|
|
||||||
|
## Ejemplo canónico — generar un estilo, fusionarlo y aplicarlo
|
||||||
|
|
||||||
|
Dos flujos típicos: (1) ampliar el catálogo del selector WAS, y (2) usar un preset gamedev para
|
||||||
|
generar un asset con look consistente.
|
||||||
|
|
||||||
|
### A) Ampliar el catálogo WAS con estilos nuevos por LLM
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_generate_styles_llm import comfyui_generate_styles_llm
|
||||||
|
from ml.comfyui_append_styles import comfyui_append_styles
|
||||||
|
|
||||||
|
# 1. Pedir 6 estilos de una categoría. Devuelve el dict {nombre: {prompt, negative_prompt}}
|
||||||
|
# directo (best-effort: {} si el LLM falla).
|
||||||
|
nuevos = comfyui_generate_styles_llm("film noir cinematic", n=6, prefix="noir-")
|
||||||
|
|
||||||
|
# 2. Previsualizar la fusión (no escribe), luego aplicar con backup.
|
||||||
|
if nuevos:
|
||||||
|
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"]) # nº tras fusionar, sin tocar disco
|
||||||
|
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura
|
||||||
|
print(res["total_before"], "->", res["total_after"], "añadidos:", len(res["added"]))
|
||||||
|
```
|
||||||
|
|
||||||
|
### B) Aplicar un style preset gamedev a un sujeto
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||||
|
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||||
|
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||||
|
|
||||||
|
preset = comfyui_get_gamedev_style_preset("gameboy") # receta pura del look Game Boy
|
||||||
|
ap = comfyui_apply_style_preset(preset, "a wizard casting a spell")
|
||||||
|
# ap = {subject, builder_kwargs, size, transparent, post, ...} listo para un builder gamedev-2d:
|
||||||
|
wf = comfyui_build_enemy_creature_workflow(
|
||||||
|
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
El catálogo curado completo se consulta sin red (devuelve el dict plano directo):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
|
||||||
|
print(comfyui_curated_styles_catalog("__categories__")) # {'categories': {...}, 'total': 190}
|
||||||
|
todos = comfyui_curated_styles_catalog() # dict {nombre: {prompt, negative_prompt}}
|
||||||
|
print(len(todos), list(todos)[:5])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **No genera imágenes**: este sub-grupo produce y gestiona DATOS de estilo (dicts de prompt /
|
||||||
|
negative, presets). Generar el asset es trabajo de los builders del grupo [`comfyui`](comfyui.md)
|
||||||
|
y [`gamedev-2d`](gamedev-2d.md), o de los pipelines oneshot (p.ej.
|
||||||
|
`comfyui_generate_styled_asset_oneshot_py_pipelines`, que compone un preset + un builder + submit).
|
||||||
|
- **El catálogo WAS asume el custom node WAS instalado**: `append_styles` escribe sobre el
|
||||||
|
`styles.json` que lee el selector WAS en la UI. Sin ese node, el catálogo sigue siendo usable como
|
||||||
|
dict de modificadores, pero el selector no aparecerá en el grafo.
|
||||||
|
- **Los dos presets gamedev (`get`/`apply`) llevan tag `gamedev-2d`**, no `comfyui-styles`: son la
|
||||||
|
vía de estilo para los builders de assets de juego, no para el selector WAS genérico. Se listan
|
||||||
|
aquí por afinidad de capacidad (estilo reutilizable).
|
||||||
|
- **Formato exacto**: el dict de estilos es `{nombre: {prompt, negative_prompt}}`. Los prompts son
|
||||||
|
modificadores (cámara/lente/luz/render/medio/paleta/mood), no descripciones de escena — la escena
|
||||||
|
la pone el `subject` del usuario.
|
||||||
@@ -13,6 +13,17 @@ Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/c
|
|||||||
|
|
||||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
|
||||||
|
|
||||||
|
> **Tamaño del grupo (al 28/06/2026):** 126 funciones con tag `comfyui` (63 puras, 50 impuras,
|
||||||
|
> 13 pipelines). El grupo se reparte en sub-grupos con página madre propia:
|
||||||
|
> [`comfyui-skill`](comfyui-skill.md) (recetas de estilo versionadas),
|
||||||
|
> [`comfyui-styles`](comfyui-styles.md) (presets + catálogo de estilo para el selector WAS),
|
||||||
|
> [`comfyui-judge`](comfyui-judge.md) (panel de calidad) y
|
||||||
|
> [`gamedev-2d`](gamedev-2d.md) (assets 2D para Godot: 47 funciones, 45 de ellas también `comfyui`).
|
||||||
|
> Esta página documenta el **núcleo** (lifecycle del server, API HTTP, builders, I/O de workflows,
|
||||||
|
> imagen→3D, UI por CDP, audio, templates); los builders específicos de gamedev-2d viven en su
|
||||||
|
> propia página. El mapa cross-grupo de capacidades está en
|
||||||
|
> [comfyui-overview.md](comfyui-overview.md).
|
||||||
|
|
||||||
## Dos caminos, mismo motor
|
## Dos caminos, mismo motor
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -44,7 +55,7 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow`
|
|||||||
| ID | Firma corta | Qué hace |
|
| ID | Firma corta | Qué hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
|
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
|
||||||
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, unet='flux1-schnell-fp8-e4m3fn.safetensors', clip_l, t5xxl, vae='ae.safetensors', width=1024, height=1024, steps=4, guidance=3.5, seed, weight_dtype='fp8_e4m3fn', ...) -> dict` | Builder txt2img para **Flux** (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → KSampler (cfg fijo 1.0) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. fp8 + ~4 pasos para 8 GB. **Pura**. |
|
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, variant='schnell', width=1024, height=1024, steps=None, guidance=3.5, seed=0, unet_name=None, clip_l_name='clip_l.safetensors', t5xxl_name='t5xxl_fp8_e4m3fn_scaled.safetensors', vae_name='ae.safetensors', weight_dtype='default', sampler_name='euler', scheduler='simple', ...) -> dict` | Builder txt2img para **Flux** (`variant='schnell'` o `'dev'`): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → camino custom-advanced (RandomNoise + KSamplerSelect + BasicScheduler → BasicGuider → SamplerCustomAdvanced) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. `steps=None` autoselecciona por variante (~4 schnell); `unet_name=None` deduce el checkpoint de la variante; `weight_dtype='default'`. **Pura**. |
|
||||||
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
|
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
|
||||||
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
|
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
|
||||||
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
|
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
|
||||||
@@ -207,6 +218,22 @@ un error accionable, sin lanzar.
|
|||||||
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
|
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
|
||||||
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
|
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
|
||||||
|
|
||||||
|
### Estilos — presets y catálogo (sub-grupo `comfyui-styles`)
|
||||||
|
|
||||||
|
Capa de **estilo reutilizable** sobre los builders: un catálogo curado de ~190 modificadores de
|
||||||
|
estilo para el selector WAS (Prompt Styles Selector), generación de estilos por LLM, y *style
|
||||||
|
presets* gamedev (gameboy, ghibli, pixel-art-retro) que empaquetan como datos puros el look de un
|
||||||
|
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño). Página madre dedicada:
|
||||||
|
[comfyui-styles.md](comfyui-styles.md). Las 5 funciones:
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en formato `{nombre: {prompt, negative_prompt}}` para el selector WAS. Cada prompt son modificadores potentes (cámara, lente, iluminación, render, medio, paleta). **Pura**. |
|
||||||
|
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-...') -> dict` | Genera N estilos nuevos de una categoría temática vía `ask_llm` (grupo claude-direct), en el mismo formato del selector WAS. **Impura**. |
|
||||||
|
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=..., overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup) estilos nuevos sobre el `styles.json` del selector WAS de forma NO destructiva: preserva los existentes (salvo `overwrite`), backup con timestamp. **Impura**. |
|
||||||
|
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (gameboy, ghibli, pixel-art-retro) o el catálogo de nombres si `name=None`. Empaqueta el look como datos puros. **Pura**. |
|
||||||
|
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev + un subject del usuario a los kwargs que consume un builder de sujeto del grupo gamedev-2d (subject combinado + `**kwargs` listos para spread). **Pura**. |
|
||||||
|
|
||||||
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
|
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
|
||||||
|
|
||||||
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
|
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI**
|
|||||||
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
|
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
|
||||||
con sus import settings.
|
con sus import settings.
|
||||||
|
|
||||||
Tag único del grupo: `gamedev-2d` (los 31 builders de workflow + las 5 funciones de
|
Tag único del grupo: `gamedev-2d` — **47 funciones**: 36 builders de workflow (31 de
|
||||||
apoyo de post-proceso y puente). El tag plano `gamedev` quedó deprecado y unificado a
|
generación desde texto + 5 de transformación desde una imagen de entrada) + 11 de apoyo
|
||||||
|
(post-proceso, puente a Godot, style presets y pipelines one-shot). El tag plano `gamedev` quedó deprecado y unificado a
|
||||||
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
|
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
|
||||||
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
|
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
|
||||||
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
|
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -49,7 +49,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
|
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura img2img (LoadImage+VAEEncode, sin EmptyLatentImage)", "input_image/prompt reflejados en LoadImage y CLIPTextEncode positivo", "size por defecto inserta ImageScale a 512; size=None lo omite", "denoise se clampa a [0,1]", "filename_prefix/seed/lora opcional reflejados", "input_image o variant vacios -> ValueError", "determinismo: misma entrada -> mismo dict"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
|
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida del SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida del SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow (claves = node_ids string, valores = class_type + inputs). SV3D: ImageOnlyCheckpointLoader + LoadImage + SV3D_Conditioning + VideoLinearCFGGuidance + KSampler + VAEDecode + SaveImage (los N frames del orbit). Zero123: ImageOnlyCheckpointLoader + LoadImage + StableZero123_Conditioning_Batched + KSampler + VAEDecode + SaveImage (un batch de directions vistas). El frame i (i-esima imagen del SaveImage, azimuth creciente desde la frontal) = direccion i de directional_sprite_view_order(directions). El modulo expone ademas directional_sprite_view_order(directions) -> lista de nombres de direccion alineada por indice con los frames."
|
output: "dict en API format listo para comfyui_submit_workflow (claves = node_ids string, valores = class_type + inputs). SV3D: ImageOnlyCheckpointLoader + LoadImage + SV3D_Conditioning + VideoLinearCFGGuidance + KSampler + VAEDecode + SaveImage (los N frames del orbit). Zero123: ImageOnlyCheckpointLoader + LoadImage + StableZero123_Conditioning_Batched + KSampler + VAEDecode + SaveImage (un batch de directions vistas). El frame i (i-esima imagen del SaveImage, azimuth creciente desde la frontal) = direccion i de directional_sprite_view_order(directions). El modulo expone ademas directional_sprite_view_order(directions) -> lista de nombres de direccion alineada por indice con los frames."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["sv3d: estructura + orbit (video_frames=directions, size nativa 576)", "orbit_frames override", "zero123: StableZero123_Conditioning_Batched, azimuth equiespaciado, size 256", "cfg/ckpt por defecto segun modelo", "elevation/seed reflejados", "directional_sprite_view_order para 4/8/N", "errores: input vacio, model invalido, directions<1", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py"
|
||||||
file_path: "python/functions/ml/comfyui_build_directional_sprite_workflow.py"
|
file_path: "python/functions/ml/comfyui_build_directional_sprite_workflow.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: labels
|
- name: labels
|
||||||
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
|
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
|
||||||
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
|
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["grid basico: ok + out_path + cols/rows (ceil(sqrt(N)))", "cols explicito define filas", "cell define dimension del canvas", "labels reservan franja bajo cada celda", "error: lista vacia", "error: ruta inexistente", "determinismo del dict de salida"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_grid.py"
|
||||||
file_path: "python/functions/ml/comfyui_build_grid.py"
|
file_path: "python/functions/ml/comfyui_build_grid.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO la region marcada en blanco por la mascara, conservando el resto del asset, con grow_mask para difuminar la costura, escalado consistente opcional (img+mask) y LoRA de estilo opcional. Nodos modo vae_encode: CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ ImageScale/ImageToMask si size, + LoraLoader si lora). Modo noise_mask sustituye VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask)."
|
output: "dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO la region marcada en blanco por la mascara, conservando el resto del asset, con grow_mask para difuminar la costura, escalado consistente opcional (img+mask) y LoRA de estilo opcional. Nodos modo vae_encode: CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ ImageScale/ImageToMask si size, + LoraLoader si lora). Modo noise_mask sustituye VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask)."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura vae_encode (LoadImage+LoadImageMask+VAEEncodeForInpaint)", "prompt de region + grow_mask reflejados", "grow_mask se clampa a [0,64]", "mode noise_mask degrada a VAEEncode+SetLatentNoiseMask+GrowMask", "size inserta ImageScale a imagen y mascara + ImageToMask", "lora opcional + filename_prefix", "errores: input/mask/prompt vacios, mode invalido", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py
|
file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: outpaint que extiende el lienzo por los lados pedidos y genera lo nuevo con '{prompt}, {style}, seamless extension...', conservando el asset original. Nodos: CheckpointLoaderSimple '4', LoadImage '10', ImagePadForOutpaint (id nuevo, reusa el '12' que libera el LoadImageMask eliminado), VAEEncodeForInpaint '11' (pixels <- pad IMAGE, mask <- pad MASK), CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ LoraLoader si lora). El LoadImageMask de la base inpaint se elimina: la mascara la GENERA el pad."
|
output: "dict en API format listo para comfyui_submit_workflow: outpaint que extiende el lienzo por los lados pedidos y genera lo nuevo con '{prompt}, {style}, seamless extension...', conservando el asset original. Nodos: CheckpointLoaderSimple '4', LoadImage '10', ImagePadForOutpaint (id nuevo, reusa el '12' que libera el LoadImageMask eliminado), VAEEncodeForInpaint '11' (pixels <- pad IMAGE, mask <- pad MASK), CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ LoraLoader si lora). El LoadImageMask de la base inpaint se elimina: la mascara la GENERA el pad."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura outpaint (ImagePadForOutpaint, sin LoadImageMask)", "pad cableado a VAEEncodeForInpaint (pixels<-IMAGE, mask<-MASK)", "extensiones redondeadas a multiplo de 8", "sin extension (todo 0 tras redondear) -> ValueError", "feather y prompt reflejados", "lora opcional + filename_prefix", "errores: input/prompt vacios", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_outpaint_asset_workflow.py
|
file_path: python/functions/ml/comfyui_build_outpaint_asset_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ CheckpointLoaderSimple -> ... -> KSampler -> VAEDecode --IMAGE--+-> SaveImage (f
|
|||||||
`-> DepthAnythingV2Preprocessor -> SaveImage (depth)
|
`-> DepthAnythingV2Preprocessor -> SaveImage (depth)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_build_parallax_background_workflow import comfyui_build_parallax_background_workflow
|
||||||
|
|
||||||
|
# Fondo apaisado + su mapa de profundidad, 4 bandas de parallax (función pura, sin red).
|
||||||
|
wf = comfyui_build_parallax_background_workflow("forest at dusk, fantasy", layers=4, seed=7)
|
||||||
|
|
||||||
|
# El dict API format trae DOS SaveImage: el fondo y el depth map. Encólalo con submit_workflow.
|
||||||
|
saves = [n for n in wf.values() if n.get("class_type") == "SaveImage"]
|
||||||
|
print(len(saves), "SaveImage (fondo + depth)") # 2
|
||||||
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
Cuando necesites el fondo de un nivel 2D con scroll parallax y quieras las capas
|
Cuando necesites el fondo de un nivel 2D con scroll parallax y quieras las capas
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: comfyui_build_pixelart_workflow
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, filename_prefix: str = \"pixelart\") -> dict"
|
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, transparent: bool = True, rembg_model: str = \"u2net\", filename_prefix: str = \"pixelart\") -> dict"
|
||||||
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Si transparent (default), inyecta un nodo 'Image Rembg' tras el VAEDecode para recortar el fondo -> sprite con alpha (mismo patron que comfyui_build_item_icon_workflow); transparent=False para tiles/fondos opacos. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
||||||
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl]
|
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl, rembg, transparent]
|
||||||
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
|
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -45,11 +45,15 @@ params:
|
|||||||
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
|
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
|
||||||
- name: scheduler
|
- name: scheduler
|
||||||
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
|
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "si True (default) inyecta 'Image Rembg' tras VAEDecode y el PNG sale con alpha (fondo recortado) — para sprites de sujeto (personajes/objetos). False deja fondo opaco — para tiles/texturas/fondos. keyword-only."
|
||||||
|
- name: rembg_model
|
||||||
|
desc: "modelo Rembg ('u2net' general, 'isnet-anime' anime). Solo se usa si transparent=True. keyword-only."
|
||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + SaveImage."
|
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + nodo 'Image Rembg' antes del SaveImage si transparent + SaveImage."
|
||||||
tested: true
|
tested: true
|
||||||
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo"]
|
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo", "transparent default inyecta Image Rembg + repunta SaveImage", "transparent=False sin Rembg (SaveImage lee del VAEDecode)", "rembg_model override"]
|
||||||
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
|
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
|
||||||
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
|
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
|
||||||
---
|
---
|
||||||
@@ -94,3 +98,15 @@ Para tilesets, genera cada tile por separado y ensambla con `comfyui_build_grid`
|
|||||||
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
|
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
|
||||||
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
|
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
|
||||||
400 salta al enviar con `comfyui_submit_workflow`.
|
400 salta al enviar con `comfyui_submit_workflow`.
|
||||||
|
- **transparent=True (default, v1.1.0)**: inyecta el nodo `Image Rembg (Remove
|
||||||
|
Background)`. Requiere el custom node `ComfyUI-Image-Background-Remove` (o equiv.)
|
||||||
|
instalado en el server; si falta, el `submit` devuelve error en el dict (no crashea).
|
||||||
|
El sprite sale RGBA con fondo recortado — ideal para personajes/objetos. Para
|
||||||
|
tiles/texturas/fondos sin contorno usar `transparent=False` (PNG opaco).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — `transparent`/`rembg_model`: inyecta `Image Rembg` tras el
|
||||||
|
VAEDecode (mismo patron que `comfyui_build_item_icon_workflow`) para producir
|
||||||
|
sprites con fondo transparente. Cierra el bug del pipeline pixelart que no podia
|
||||||
|
generar sprites sin fondo (issue sprite-fix).
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -29,6 +30,44 @@ _LCM_DEFAULTS = {"steps": 8, "cfg": 1.5, "sampler_name": "lcm", "scheduler": "sg
|
|||||||
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
|
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_rembg(workflow: dict, model: str) -> dict:
|
||||||
|
"""Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage.
|
||||||
|
|
||||||
|
Mismo helper que comfyui_build_item_icon_workflow / comfyui_build_sprite_sheet_workflow:
|
||||||
|
el nodo recorta la silueta del sujeto dejando alpha, y se repunta SaveImage.images a
|
||||||
|
la salida del Rembg para que el PNG salga con fondo transparente. No muta el dict de
|
||||||
|
entrada (copia profunda).
|
||||||
|
"""
|
||||||
|
wf = copy.deepcopy(workflow)
|
||||||
|
vaedecode_id = next(
|
||||||
|
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
|
||||||
|
)
|
||||||
|
save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None)
|
||||||
|
if vaedecode_id is None or save_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"comfyui_build_pixelart_workflow: no se encontro VAEDecode/SaveImage para Rembg"
|
||||||
|
)
|
||||||
|
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
|
||||||
|
rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
|
||||||
|
wf[rembg_id] = {
|
||||||
|
"class_type": "Image Rembg (Remove Background)",
|
||||||
|
"inputs": {
|
||||||
|
"images": [vaedecode_id, 0],
|
||||||
|
"transparency": True,
|
||||||
|
"model": model,
|
||||||
|
"post_processing": False,
|
||||||
|
"only_mask": False,
|
||||||
|
"alpha_matting": False,
|
||||||
|
"alpha_matting_foreground_threshold": 240,
|
||||||
|
"alpha_matting_background_threshold": 10,
|
||||||
|
"alpha_matting_erode_size": 10,
|
||||||
|
"background_color": "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wf[save_id]["inputs"]["images"] = [rembg_id, 0]
|
||||||
|
return wf
|
||||||
|
|
||||||
|
|
||||||
def comfyui_build_pixelart_workflow(
|
def comfyui_build_pixelart_workflow(
|
||||||
positive: str,
|
positive: str,
|
||||||
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
|
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
|
||||||
@@ -46,6 +85,8 @@ def comfyui_build_pixelart_workflow(
|
|||||||
seed: int = 0,
|
seed: int = 0,
|
||||||
sampler_name: str | None = None,
|
sampler_name: str | None = None,
|
||||||
scheduler: str | None = None,
|
scheduler: str | None = None,
|
||||||
|
transparent: bool = True,
|
||||||
|
rembg_model: str = "u2net",
|
||||||
filename_prefix: str = "pixelart",
|
filename_prefix: str = "pixelart",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
|
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
|
||||||
@@ -70,15 +111,24 @@ def comfyui_build_pixelart_workflow(
|
|||||||
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
|
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
|
||||||
en la Fase 2 con comfyui_pixelize_image).
|
en la Fase 2 con comfyui_pixelize_image).
|
||||||
seed: semilla del KSampler.
|
seed: semilla del KSampler.
|
||||||
|
transparent: si True (default) inyecta 'Image Rembg' tras el VAEDecode y el
|
||||||
|
PNG sale con alpha (fondo recortado) — lo habitual para sprites de sujeto
|
||||||
|
(personajes, criaturas, objetos). Si False deja la imagen opaca sobre
|
||||||
|
fondo plano, para tiles/texturas/fondos que no quieren transparencia.
|
||||||
|
keyword-only.
|
||||||
|
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo
|
||||||
|
se usa si transparent=True. keyword-only.
|
||||||
filename_prefix: prefijo del PNG en output/.
|
filename_prefix: prefijo del PNG en output/.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict en API format listo para comfyui_submit_workflow, con el
|
dict en API format listo para comfyui_submit_workflow, con el
|
||||||
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
|
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
|
||||||
SDXL_lcm-lora si use_lcm), KSampler con los params del modo y SaveImage.
|
SDXL_lcm-lora si use_lcm), KSampler con los params del modo, un nodo
|
||||||
|
'Image Rembg' antes del SaveImage si transparent, y SaveImage.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: si positive esta vacio.
|
ValueError: si positive esta vacio, o si la base no tiene VAEDecode/SaveImage
|
||||||
|
donde inyectar el Rembg (propagado por el helper, solo si transparent).
|
||||||
"""
|
"""
|
||||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||||
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
|
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
|
||||||
@@ -117,7 +167,12 @@ def comfyui_build_pixelart_workflow(
|
|||||||
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
|
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
|
||||||
)
|
)
|
||||||
|
|
||||||
return comfyui_inject_multi_lora(base, loras)
|
wf = comfyui_inject_multi_lora(base, loras)
|
||||||
|
|
||||||
|
if transparent:
|
||||||
|
wf = _inject_rembg(wf, rembg_model)
|
||||||
|
|
||||||
|
return wf
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: txt2img base (CheckpointLoaderSimple '4', EmptyLatentImage '5', CLIPTextEncode '6'/'7', KSampler '3' denoise 1.0, VAEDecode '8', SaveImage '9') + rama ControlNet (LoadImage del boceto -> [Preprocessor del control_type si preprocess] -> ControlNetApply -> KSampler.positive, con ControlNetLoader del modelo CN) + LoraLoader si lora. Es UN sprite; varios objetos del mismo set -> llamar por subject/sketch_image con el mismo style/checkpoint/(lora)."
|
output: "dict en API format listo para comfyui_submit_workflow: txt2img base (CheckpointLoaderSimple '4', EmptyLatentImage '5', CLIPTextEncode '6'/'7', KSampler '3' denoise 1.0, VAEDecode '8', SaveImage '9') + rama ControlNet (LoadImage del boceto -> [Preprocessor del control_type si preprocess] -> ControlNetApply -> KSampler.positive, con ControlNetLoader del modelo CN) + LoraLoader si lora. Es UN sprite; varios objetos del mismo set -> llamar por subject/sketch_image con el mismo style/checkpoint/(lora)."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura txt2img + ControlNet (EmptyLatentImage, ControlNetLoader/Apply)", "lineart: preprocesador + modelo por defecto, ControlNetApply consume el mapa de lineas", "canny: preprocesador + modelo", "preprocess=False pasa el boceto directo al ControlNet", "controlnet_name override + strength reflejado", "strength se clampa a [0,2]", "lora opcional", "errores: sketch/subject vacios, control_type invalido", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_sprite_from_sketch_workflow.py
|
file_path: python/functions/ml/comfyui_build_sprite_from_sketch_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: token
|
- name: token
|
||||||
desc: "Token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only."
|
desc: "Token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only."
|
||||||
output: "dict {ok, verdict, score_0_10, reasons, error}. En exito ok=True, verdict 'good'|'bad', score_0_10 el score del modelo y reasons la lista de razones. En error (imagen invalida, API caida, 429, JSON no parseable) ok=False con error. Nunca lanza excepcion."
|
output: "dict {ok, verdict, score_0_10, reasons, error}. En exito ok=True, verdict 'good'|'bad', score_0_10 el score del modelo y reasons la lista de razones. En error (imagen invalida, API caida, 429, JSON no parseable) ok=False con error. Nunca lanza excepcion."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["_extract_json: fence json", "_extract_json: brace plano", "_extract_json: sin objeto -> ValueError", "flujo: veredicto estructurado good", "verdict ambiguo -> bad conservador", "API caida -> ok=False", "respuesta no parseable -> ok=False"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_critique_image_llm.py"
|
||||||
file_path: "python/functions/ml/comfyui_critique_image_llm.py"
|
file_path: "python/functions/ml/comfyui_critique_image_llm.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: nsfw
|
- name: nsfw
|
||||||
desc: "Marca provenance.nsfw. keyword-only."
|
desc: "Marca provenance.nsfw. keyword-only."
|
||||||
output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable."
|
output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["_slugify (normaliza y acota a 6 tokens)", "_loras_from_prompt", "_dims_from_prompt + _checkpoint_from_prompt", "_detect_base_workflow (flux/txt2img)", "_from_civitai_meta (mapea steps/cfg/size/modelo/prompts)", "flujo fallback a civitai_meta sin workflow embebido", "slug derivado del prompt", "error: sin workflow ni meta"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py"
|
||||||
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
|
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: resample
|
- name: resample
|
||||||
desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only."
|
desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only."
|
||||||
output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)."
|
output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["aplana transparente sobre blanco -> RGB sin alpha", "color de fondo personalizado", "size redimensiona a cuadrado", "out_path por defecto con sufijo _flat", "error: imagen inexistente", "determinismo (mismos bytes de salida)"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py"
|
||||||
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
|
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ params:
|
|||||||
- name: timeout
|
- name: timeout
|
||||||
desc: "Timeout HTTP en segundos. keyword-only."
|
desc: "Timeout HTTP en segundos. keyword-only."
|
||||||
output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica."
|
output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["API format se devuelve tal cual (format=api)", "UI graph se normaliza a API (descarta Note, resuelve conexiones)", "JSON invalido -> error", "formato no reconocido -> error", "JSON no es objeto -> error", "archivo inexistente -> error", "determinismo"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_import_workflow_json.py"
|
||||||
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
|
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ params:
|
|||||||
- name: venv_python
|
- name: venv_python
|
||||||
desc: "Python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only."
|
desc: "Python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only."
|
||||||
output: "dict {ok, verdict, score, votes, reasons, error, details}. verdict 'good'|'bad' por mayoria; score media ponderada 0-10 de los jueces vivos; votes = {clip, aesthetic, llm} cada uno 'good'|'bad'|'failed'; reasons agrega razones del critico + notas de jueces caidos; details lleva el dict crudo de cada juez. ok=False solo si los tres fallan."
|
output: "dict {ok, verdict, score, votes, reasons, error, details}. verdict 'good'|'bad' por mayoria; score media ponderada 0-10 de los jueces vivos; votes = {clip, aesthetic, llm} cada uno 'good'|'bad'|'failed'; reasons agrega razones del critico + notas de jueces caidos; details lleva el dict crudo de cada juez. ok=False solo si los tres fallan."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["tres votos good -> verdict good + score medio", "mayoria bad", "empate -> bad conservador", "juez caido se excluye sin crashear", "los tres jueces fallan -> ok=False", "weights afectan score pero no el voto"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_judge_image.py"
|
||||||
file_path: "python/functions/ml/comfyui_judge_image.py"
|
file_path: "python/functions/ml/comfyui_judge_image.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: comfyui_pixelize_image
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
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"
|
signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True, keep_alpha: bool = True, alpha_threshold: int = 128) -> dict"
|
||||||
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. 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."
|
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Alpha-aware: si la entrada es RGBA y keep_alpha, cuantiza SOLO el RGB (el fondo transparente no entra en la paleta) y preserva/binariza el alpha por separado -> PNG RGBA con transparencia real. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, has_alpha, error}. Impura solo por la lectura/escritura de disco."
|
||||||
tags: [comfyui, gamedev-2d, pixelart, ml, pil, quantize, palette, image]
|
tags: [comfyui, gamedev-2d, pixelart, ml, pil, quantize, palette, image, alpha, transparent]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -29,9 +29,13 @@ params:
|
|||||||
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
|
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
|
||||||
- name: upscale_back
|
- name: upscale_back
|
||||||
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
|
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
|
||||||
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores distintos del resultado), error (str, vacio si OK)."
|
- name: keep_alpha
|
||||||
|
desc: "si True (default) y la entrada tiene canal alpha, preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el alpha aparte -> PNG RGBA. Sin efecto si la imagen no tiene alpha (sale RGB igual que antes). keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0). Solo aplica cuando se preserva el alpha. keyword-only."
|
||||||
|
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores RGB distintos; en la zona opaca si es RGBA), has_alpha (bool, True si la salida es RGBA), error (str, vacio si OK)."
|
||||||
tested: true
|
tested: true
|
||||||
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette]
|
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette, test_alpha_preserved_transparent_corners, test_alpha_off_flattens_to_rgb, test_rgb_input_unaffected_by_keep_alpha, test_error_all_transparent_no_crash]
|
||||||
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
|
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
|
||||||
file_path: "python/functions/ml/comfyui_pixelize_image.py"
|
file_path: "python/functions/ml/comfyui_pixelize_image.py"
|
||||||
---
|
---
|
||||||
@@ -54,14 +58,21 @@ res = comfyui_pixelize_image(
|
|||||||
# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale)
|
# 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",
|
comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png",
|
||||||
palette="game-boy", upscale_back=False)
|
palette="game-boy", upscale_back=False)
|
||||||
|
|
||||||
|
# Sprite RGBA (tras rembg): preserva la transparencia, cuantiza solo el sujeto
|
||||||
|
res = comfyui_pixelize_image("/tmp/knight_rgba.png", "/tmp/knight_px.png",
|
||||||
|
downscale=1, colors=16, keep_alpha=True)
|
||||||
|
# {'ok': True, 'has_alpha': True, 'n_colors_final': 16, ...} -> fondo transparente intacto
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `SDXL_pixel-art`),
|
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `SDXL_pixel-art`),
|
||||||
para colapsar el grid borroso a pixeles duros y limitar la paleta. Tambien sirve
|
para colapsar el grid borroso a pixeles duros y limitar la paleta. Si la imagen
|
||||||
para "pixelizar" cualquier imagen (sprite, render, foto) a estetica retro sin
|
viene de `rembg` con fondo recortado (RGBA), `keep_alpha=True` mantiene la
|
||||||
tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
|
transparencia y deja el fondo fuera de la paleta. Tambien sirve para "pixelizar"
|
||||||
|
cualquier imagen (sprite, render, foto) a estetica retro sin tocar la GPU. Para
|
||||||
|
llevar el resultado a Godot con filtro Nearest:
|
||||||
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
|
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
@@ -76,7 +87,22 @@ tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
|
|||||||
duros (preview).
|
duros (preview).
|
||||||
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
|
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
|
||||||
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
|
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
|
||||||
- `n_colors_final` cuenta colores distintos reales del PNG escrito; con paleta fija
|
- `n_colors_final` cuenta colores RGB distintos reales del PNG escrito; con salida
|
||||||
puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
|
RGBA cuenta **solo la zona opaca** (el transparente no es un color del pixel-art);
|
||||||
|
con paleta fija puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
|
||||||
|
- **alpha-aware (v1.1.0)**: con entrada RGBA y `keep_alpha=True` (default), el fondo
|
||||||
|
transparente se rellena internamente con la moda del sujeto antes de cuantizar, asi
|
||||||
|
NO gasta una entrada de la paleta; el alpha se downscalea nearest aparte y se
|
||||||
|
binariza por `alpha_threshold` (0/255 = bordes duros pixel-art). Entrada sin alpha
|
||||||
|
-> comportamiento RGB identico al de antes (retrocompatible).
|
||||||
|
- Si la entrada RGBA esta **toda transparente** (rembg sin sujeto), no crashea:
|
||||||
|
devuelve `ok=True`, `has_alpha=True`, `n_colors_final=0` y el PNG sigue transparente.
|
||||||
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
|
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
|
||||||
con Pillow.
|
con Pillow (numpy acelera el relleno alpha; sin numpy degrada limpio).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — alpha-aware: `keep_alpha`/`alpha_threshold`. Si la entrada
|
||||||
|
es RGBA, cuantiza solo el RGB (fondo transparente fuera de la paleta) y preserva el
|
||||||
|
alpha binarizado -> PNG RGBA con transparencia real. Cierra el bug del pipeline
|
||||||
|
pixelart que perdia el fondo transparente por el `convert("RGB")` (issue sprite-fix).
|
||||||
|
|||||||
@@ -64,8 +64,60 @@ def _normalize_palette(palette):
|
|||||||
return [_hex_to_rgb(h) for h in hexes]
|
return [_hex_to_rgb(h) for h in hexes]
|
||||||
|
|
||||||
|
|
||||||
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
def _img_has_alpha(img) -> bool:
|
||||||
"""Nucleo puro PIL: imagen RGB -> imagen RGB pixelizada.
|
"""True si la imagen lleva transparencia (RGBA, LA o P con transparency)."""
|
||||||
|
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_transparent_with_mode(small_rgb, small_alpha, threshold):
|
||||||
|
"""Rellena los pixeles transparentes con el color opaco mas frecuente (moda).
|
||||||
|
|
||||||
|
Asi el fondo transparente NO aporta colores nuevos a la cuantizacion: las zonas
|
||||||
|
con alpha <= threshold toman un color que ya esta en el sujeto (y por tanto en la
|
||||||
|
paleta resultante), sin gastar entradas de la paleta en el color de fondo. El
|
||||||
|
color real de esas zonas es irrelevante para la salida porque luego reciben
|
||||||
|
alpha 0. Si no hay numpy, cae a no rellenar (degradacion limpia).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
small_rgb: PIL.Image RGB ya reducida.
|
||||||
|
small_alpha: PIL.Image 'L' del alpha ya reducido (mismo tamano).
|
||||||
|
threshold: umbral de alpha (0..255); <= threshold = transparente.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image RGB con el fondo transparente relleno con la moda del sujeto.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
rgb = small_rgb.convert("RGB")
|
||||||
|
mask = small_alpha.point(lambda p: 255 if p > threshold else 0).convert("L")
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
return rgb
|
||||||
|
|
||||||
|
arr = np.asarray(rgb).reshape(-1, 3)
|
||||||
|
opaque = np.asarray(mask).reshape(-1) > 0
|
||||||
|
if not opaque.any():
|
||||||
|
return rgb # nada opaco: caso degenerado, deja igual
|
||||||
|
op_pixels = arr[opaque]
|
||||||
|
colors, counts = np.unique(op_pixels, axis=0, return_counts=True)
|
||||||
|
fill = tuple(int(x) for x in colors[counts.argmax()])
|
||||||
|
bg = Image.new("RGB", rgb.size, fill)
|
||||||
|
bg.paste(rgb, (0, 0), mask) # rgb donde mask=255, fill (moda) donde mask=0
|
||||||
|
return bg
|
||||||
|
|
||||||
|
|
||||||
|
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back,
|
||||||
|
keep_alpha, alpha_threshold):
|
||||||
|
"""Nucleo puro PIL: imagen -> imagen pixelizada (RGB, o RGBA si keep_alpha).
|
||||||
|
|
||||||
|
Si la imagen de entrada tiene canal alpha y keep_alpha es True, la cuantizacion
|
||||||
|
de color se hace SOLO sobre el RGB (con el fondo transparente relleno con la moda
|
||||||
|
del sujeto para que no entre en la paleta) y el alpha se downscalea nearest por
|
||||||
|
separado y se binariza por `alpha_threshold`, recombinando a RGBA. Asi se
|
||||||
|
preserva la transparencia sin que las zonas transparentes contaminen la paleta.
|
||||||
|
Para imagenes sin alpha (o keep_alpha False) el comportamiento RGB es identico al
|
||||||
|
de antes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
img: PIL.Image de entrada.
|
img: PIL.Image de entrada.
|
||||||
@@ -74,22 +126,39 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
|||||||
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
|
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
|
||||||
dither: aplica Floyd-Steinberg al cuantizar si True.
|
dither: aplica Floyd-Steinberg al cuantizar si True.
|
||||||
upscale_back: re-escala nearest al tamano original si True.
|
upscale_back: re-escala nearest al tamano original si True.
|
||||||
|
keep_alpha: si True y la imagen tiene alpha, preserva la transparencia.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha (opaco/transparente).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL.Image RGB pixelizada.
|
PIL.Image pixelizada: RGB, o RGBA si se preservo la transparencia.
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
img = img.convert("RGB")
|
has_alpha = bool(keep_alpha) and _img_has_alpha(img)
|
||||||
w, h = img.size
|
if has_alpha:
|
||||||
|
rgba = img.convert("RGBA")
|
||||||
|
alpha_full = rgba.getchannel("A")
|
||||||
|
rgb = rgba.convert("RGB")
|
||||||
|
else:
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
alpha_full = None
|
||||||
|
|
||||||
|
w, h = rgb.size
|
||||||
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
|
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
|
||||||
sw, sh = max(1, w // downscale), max(1, h // downscale)
|
sw, sh = max(1, w // downscale), max(1, h // downscale)
|
||||||
small = img.resize((sw, sh), Image.NEAREST)
|
small = rgb.resize((sw, sh), Image.NEAREST)
|
||||||
|
small_alpha = (
|
||||||
|
alpha_full.resize((sw, sh), Image.NEAREST) if alpha_full is not None else None
|
||||||
|
)
|
||||||
|
# 1b. con alpha: el fondo transparente no debe entrar en la paleta.
|
||||||
|
if small_alpha is not None:
|
||||||
|
small = _fill_transparent_with_mode(small, small_alpha, int(alpha_threshold))
|
||||||
|
|
||||||
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
||||||
# 2. cuantizar la paleta.
|
# 2. cuantizar la paleta (siempre sobre RGB).
|
||||||
if palette_rgb:
|
if palette_rgb:
|
||||||
pal_img = Image.new("P", (1, 1))
|
pal_img = Image.new("P", (1, 1))
|
||||||
flat = [c for rgb in palette_rgb for c in rgb][:768]
|
flat = [c for rgb_c in palette_rgb for c in rgb_c][:768]
|
||||||
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
|
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
|
||||||
# quantize no puede introducir un color extra (negro) por las entradas vacias.
|
# quantize no puede introducir un color extra (negro) por las entradas vacias.
|
||||||
if flat:
|
if flat:
|
||||||
@@ -102,12 +171,42 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
|||||||
n = max(2, min(256, int(colors)))
|
n = max(2, min(256, int(colors)))
|
||||||
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
|
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
|
||||||
out = small.convert("RGB")
|
out = small.convert("RGB")
|
||||||
|
|
||||||
|
# 2b. recombinar el alpha (binarizado) -> RGBA con transparencia dura.
|
||||||
|
if small_alpha is not None:
|
||||||
|
out = out.convert("RGBA")
|
||||||
|
hard_alpha = small_alpha.point(lambda p: 255 if p > int(alpha_threshold) else 0)
|
||||||
|
out.putalpha(hard_alpha)
|
||||||
|
|
||||||
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
|
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
|
||||||
if upscale_back:
|
if upscale_back:
|
||||||
out = out.resize((w, h), Image.NEAREST)
|
out = out.resize((w, h), Image.NEAREST)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _count_colors(result) -> int:
|
||||||
|
"""Numero de colores RGB distintos en el resultado.
|
||||||
|
|
||||||
|
Para salida RGBA cuenta solo los colores de la zona opaca (alpha > 0), que es lo
|
||||||
|
que define el sprite; el transparente no es un "color" del pixel-art. Para RGB
|
||||||
|
cuenta todos los colores. Devuelve -1 si no se pudo contar.
|
||||||
|
"""
|
||||||
|
if result.mode == "RGBA":
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
colors_found = result.convert("RGB").getcolors(maxcolors=1 << 20)
|
||||||
|
return len(colors_found) if colors_found is not None else -1
|
||||||
|
arr = np.asarray(result)
|
||||||
|
opaque = arr[..., 3] > 0
|
||||||
|
rgb_op = arr[..., :3][opaque]
|
||||||
|
if rgb_op.size == 0:
|
||||||
|
return 0
|
||||||
|
return int(len(np.unique(rgb_op.reshape(-1, 3), axis=0)))
|
||||||
|
colors_found = result.getcolors(maxcolors=1 << 20)
|
||||||
|
return len(colors_found) if colors_found is not None else -1
|
||||||
|
|
||||||
|
|
||||||
def comfyui_pixelize_image(
|
def comfyui_pixelize_image(
|
||||||
src_path: str,
|
src_path: str,
|
||||||
dst_path: str,
|
dst_path: str,
|
||||||
@@ -117,6 +216,8 @@ def comfyui_pixelize_image(
|
|||||||
palette=None,
|
palette=None,
|
||||||
dither: bool = False,
|
dither: bool = False,
|
||||||
upscale_back: bool = True,
|
upscale_back: bool = True,
|
||||||
|
keep_alpha: bool = True,
|
||||||
|
alpha_threshold: int = 128,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Pixeliza una imagen y la guarda como PNG.
|
"""Pixeliza una imagen y la guarda como PNG.
|
||||||
|
|
||||||
@@ -135,16 +236,28 @@ def comfyui_pixelize_image(
|
|||||||
limpio). keyword-only.
|
limpio). keyword-only.
|
||||||
upscale_back: re-escala nearest al tamano original (preview con pixeles
|
upscale_back: re-escala nearest al tamano original (preview con pixeles
|
||||||
duros). False deja la imagen pequena (sw x sh). keyword-only.
|
duros). False deja la imagen pequena (sw x sh). keyword-only.
|
||||||
|
keep_alpha: si True (default) y la imagen de entrada tiene canal alpha,
|
||||||
|
preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el
|
||||||
|
alpha por separado, devolviendo PNG RGBA. Las zonas transparentes no
|
||||||
|
entran en la paleta de color. Si la imagen no tiene alpha, no tiene
|
||||||
|
efecto (sale RGB igual que antes). keyword-only.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o
|
||||||
|
transparente (0). Solo aplica cuando se preserva el alpha. keyword-only.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con:
|
dict con:
|
||||||
- ok (bool): True si se pixelizo y guardo.
|
- ok (bool): True si se pixelizo y guardo.
|
||||||
- out_path (str): ruta del PNG generado.
|
- out_path (str): ruta del PNG generado.
|
||||||
- size (list[int]): [w, h] de la imagen final.
|
- size (list[int]): [w, h] de la imagen final.
|
||||||
- n_colors_final (int): numero de colores distintos en el resultado.
|
- n_colors_final (int): numero de colores RGB distintos en el resultado
|
||||||
|
(en la zona opaca si la salida es RGBA).
|
||||||
|
- has_alpha (bool): True si la salida es RGBA con transparencia preservada.
|
||||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
"""
|
"""
|
||||||
out = {"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "error": ""}
|
out = {
|
||||||
|
"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0,
|
||||||
|
"has_alpha": False, "error": "",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -168,7 +281,8 @@ def comfyui_pixelize_image(
|
|||||||
try:
|
try:
|
||||||
with Image.open(src_path) as src:
|
with Image.open(src_path) as src:
|
||||||
result = _pixelize_pil(
|
result = _pixelize_pil(
|
||||||
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
|
src, int(downscale), colors, palette_rgb, bool(dither),
|
||||||
|
bool(upscale_back), bool(keep_alpha), int(alpha_threshold),
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
||||||
@@ -182,10 +296,10 @@ def comfyui_pixelize_image(
|
|||||||
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
||||||
return out
|
return out
|
||||||
|
|
||||||
colors_found = result.getcolors(maxcolors=1 << 20)
|
n_final = _count_colors(result)
|
||||||
n_final = len(colors_found) if colors_found is not None else -1
|
|
||||||
out.update(
|
out.update(
|
||||||
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
|
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final,
|
||||||
|
has_alpha=(result.mode == "RGBA"),
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ params:
|
|||||||
- name: png_path
|
- name: png_path
|
||||||
desc: "Ruta local del PNG generado por ComfyUI."
|
desc: "Ruta local del PNG generado por ComfyUI."
|
||||||
output: "dict {ok, prompt, parameters, error}. prompt = workflow API format embebido (dict); parameters = {model, seed, steps, cfg, sampler_name, scheduler, denoise, positive, negative} extraido del KSampler y nodos conectados; error = motivo si ok=False."
|
output: "dict {ok, prompt, parameters, error}. prompt = workflow API format embebido (dict); parameters = {model, seed, steps, cfg, sampler_name, scheduler, denoise, positive, negative} extraido del KSampler y nodos conectados; error = motivo si ok=False."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["extrae prompt embebido + parametros del KSampler (seed/steps/cfg/sampler/scheduler/denoise/positive/negative/model)", "error: archivo inexistente", "error: PNG sin chunk prompt", "error: chunk prompt no es JSON", "error: no es un PNG valido", "determinismo"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_read_png_metadata.py"
|
||||||
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
|
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ params:
|
|||||||
- name: server
|
- name: server
|
||||||
desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info."
|
desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info."
|
||||||
output: "dict {ok, missing_nodes, missing_models, suggestions, error}. ok = se pudo consultar el servidor; missing_nodes = class_type ausentes (nodos custom); missing_models = lista de {node, input, value}; suggestions = lista de {kind, name, action, hint, ...} (una por nodo/modelo faltante) con la funcion a usar; error = motivo si ok=False."
|
output: "dict {ok, missing_nodes, missing_models, suggestions, error}. ok = se pudo consultar el servidor; missing_nodes = class_type ausentes (nodos custom); missing_models = lista de {node, input, value}; suggestions = lista de {kind, name, action, hint, ...} (una por nodo/modelo faltante) con la funcion a usar; error = motivo si ok=False."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["traduce nodos y modelos faltantes en suggestions (install_custom_node / search_and_download)", "sin faltantes -> suggestions vacio", "servidor caido -> ok=False con error propagado"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py"
|
||||||
file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py"
|
file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: timeout
|
- name: timeout
|
||||||
desc: "Timeout del subproceso en segundos (la primera vez puede descargar CLIP). keyword-only."
|
desc: "Timeout del subproceso en segundos (la primera vez puede descargar CLIP). keyword-only."
|
||||||
output: "dict {ok, score_0_10, error}. En exito ok=True y score_0_10 es el score continuo (~1-10, mayor = mejor). En error ok=False, score_0_10=0.0 y error describe la causa. Nunca lanza excepcion."
|
output: "dict {ok, score_0_10, error}. En exito ok=True y score_0_10 es el score continuo (~1-10, mayor = mejor). En error ok=False, score_0_10=0.0 y error describe la causa. Nunca lanza excepcion."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["error: imagen inexistente (guard previo al subproceso)", "error: python del venv ComfyUI ausente", "error: .pth del modelo ausente", "nunca lanza excepcion + determinismo del error"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_score_aesthetic.py"
|
||||||
file_path: "python/functions/ml/comfyui_score_aesthetic.py"
|
file_path: "python/functions/ml/comfyui_score_aesthetic.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ params:
|
|||||||
- name: timeout
|
- name: timeout
|
||||||
desc: "Timeout del subproceso en segundos. keyword-only."
|
desc: "Timeout del subproceso en segundos. keyword-only."
|
||||||
output: "dict {ok, score_0_1, error}. En exito ok=True y score_0_1 es la similitud coseno clamp a [0,1] (mayor = mas fiel al prompt; tipico 0.28-0.35 buen match, 0.10-0.18 distinto). En error ok=False, score_0_1=0.0 y error describe la causa. Nunca lanza excepcion."
|
output: "dict {ok, score_0_1, error}. En exito ok=True y score_0_1 es la similitud coseno clamp a [0,1] (mayor = mas fiel al prompt; tipico 0.28-0.35 buen match, 0.10-0.18 distinto). En error ok=False, score_0_1=0.0 y error describe la causa. Nunca lanza excepcion."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["error: imagen inexistente", "error: prompt vacio", "error: python del venv ComfyUI ausente", "nunca lanza excepcion + determinismo del error"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_score_clip_alignment.py"
|
||||||
file_path: "python/functions/ml/comfyui_score_clip_alignment.py"
|
file_path: "python/functions/ml/comfyui_score_clip_alignment.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: crop_to_content
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def crop_to_content(img, *, pad_ratio: float = 0.06, square: bool = True, alpha_threshold: int = 10, bg_tolerance: int = 16)"
|
||||||
|
description: "Recorta una imagen PIL al bounding box de su contenido y la cuadra, para que el sujeto llene el frame antes de un downscale a pixel-art. Detecta el contenido por alpha (region con alpha > alpha_threshold) si la imagen es RGBA/LA, o por diferencia contra el color de fondo de las esquinas (con bg_tolerance) si es RGB. Recorta al bbox, anade un margen pad_ratio y, si square, rellena a cuadrado centrando el sujeto sin deformar (fondo transparente si RGBA, color de fondo si RGB). Pura PIL (opera sobre el objeto PIL.Image, no toca disco ni red, no muta la entrada). Si no hay contenido (todo transparente o todo fondo) devuelve una copia intacta — no crashea."
|
||||||
|
tags: [pil, image, crop, bbox, pixelart, gamedev-2d, ml, alpha, sprite]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: img
|
||||||
|
desc: "PIL.Image de entrada (cualquier modo). No se muta. None lanza ValueError."
|
||||||
|
- name: pad_ratio
|
||||||
|
desc: "Margen anadido alrededor del sujeto como fraccion del lado mayor del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only."
|
||||||
|
- name: square
|
||||||
|
desc: "Si True rellena a un lienzo cuadrado de lado max(w,h)+2*pad con el sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB); si False solo recorta al bbox + margen sin cuadrar. keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "Umbral de alpha (0..255) para considerar un pixel 'contenido' cuando la imagen tiene canal alpha. keyword-only."
|
||||||
|
- name: bg_tolerance
|
||||||
|
desc: "Tolerancia (0..255) de diferencia contra el color de fondo de las esquinas para imagenes sin alpha (RGB). keyword-only."
|
||||||
|
output: "PIL.Image nueva recortada (y cuadrada si square) con el sujeto llenando el frame. Si la imagen no tiene contenido detectable, devuelve una copia intacta de la entrada (mismo tamano)."
|
||||||
|
tested: true
|
||||||
|
tests: [test_golden_corner_subject_fills_frame, test_edge_centered_subject_not_overcropped, test_edge_rgb_background_bbox, test_edge_no_square_only_crops, test_error_all_transparent_returns_copy, test_error_none_raises, test_does_not_mutate_input]
|
||||||
|
test_file_path: "python/functions/ml/crop_to_content_test.py"
|
||||||
|
file_path: "python/functions/ml/crop_to_content.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from PIL import Image
|
||||||
|
from ml.crop_to_content import crop_to_content
|
||||||
|
|
||||||
|
# Sprite RGBA tras rembg: el sujeto ocupa una esquina -> recortar al bbox y cuadrar.
|
||||||
|
with Image.open("/tmp/knight_rgba.png") as im:
|
||||||
|
out = crop_to_content(im, pad_ratio=0.06, square=True)
|
||||||
|
out.save("/tmp/knight_cropped.png") # RGBA cuadrada, sujeto centrado llenando el frame
|
||||||
|
|
||||||
|
# CLI directo:
|
||||||
|
# ./fn run crop_to_content (corre los tests)
|
||||||
|
# python3 crop_to_content.py /tmp/in.png /tmp/out.png 0.06
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de bajar una imagen a pixel-art (32/64px): si el sujeto ocupa poca area del
|
||||||
|
lienzo, al downscalear queda diminuto y tosco. `crop_to_content` recorta el aire
|
||||||
|
alrededor y cuadra para que el sujeto aproveche todos los pixeles del grid. Es el
|
||||||
|
paso de encuadre del pipeline `comfyui_pixelart_real_oneshot` (autocrop). Funciona
|
||||||
|
con sprites recortados por rembg (detecta por alpha) o con imagenes de fondo plano
|
||||||
|
(detecta por diferencia contra el color de esquina).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Pura sobre PIL.Image**: recibe y devuelve un objeto `PIL.Image`, NO rutas. El
|
||||||
|
caller hace el `Image.open` / `.save`. No muta la imagen de entrada.
|
||||||
|
- Deteccion del contenido: con **alpha** usa `alpha > alpha_threshold`; sin alpha
|
||||||
|
usa la **moda de las 4 esquinas** como color de fondo y `bg_tolerance` de
|
||||||
|
diferencia. Si el fondo no es uniforme (gradiente) la deteccion RGB puede fallar;
|
||||||
|
para esos casos pasa la imagen ya recortada por rembg (RGBA).
|
||||||
|
- Si no hay contenido (todo transparente o todo del color de fondo) devuelve una
|
||||||
|
**copia intacta** del original (mismo tamano), nunca lanza por una imagen vacia.
|
||||||
|
Solo lanza `ValueError` si `img` es `None`.
|
||||||
|
- `square=True` (default) cuadra a `max(w,h)+2*pad`: si el sujeto es muy alargado el
|
||||||
|
lienzo crece al lado mayor y el sujeto queda centrado con barras transparentes (o
|
||||||
|
de color de fondo) a los lados — sin deformar.
|
||||||
|
- `pad_ratio` es relativo al lado **mayor del bbox**, no del lienzo original.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""crop_to_content — recorta una imagen PIL al bounding box de su contenido y la cuadra.
|
||||||
|
|
||||||
|
Quita el aire alrededor del sujeto para que llene el frame antes de un downscale a
|
||||||
|
pixel-art: si el sujeto ocupa el 25% del lienzo, al bajar a 64px queda diminuto y
|
||||||
|
tosco (pocos pixeles para el detalle). Esta funcion calcula el bounding box del
|
||||||
|
contenido, recorta a ese bbox, anade un margen relativo y, opcionalmente, rellena a
|
||||||
|
cuadrado sin deformar para que el sujeto llene el frame.
|
||||||
|
|
||||||
|
Como detecta el contenido:
|
||||||
|
- Si la imagen tiene canal alpha (RGBA / LA / P con transparencia): el bbox es la
|
||||||
|
region con `alpha > alpha_threshold` (lo opaco es el sujeto, lo transparente es
|
||||||
|
fondo). Es el caso tras pasar la imagen por rembg.
|
||||||
|
- Si no tiene alpha (RGB): el bbox es la region que difiere del color de fondo,
|
||||||
|
estimado como la moda de los cuatro pixeles de esquina. Sirve para imagenes con
|
||||||
|
fondo plano sin recortar todavia.
|
||||||
|
|
||||||
|
Relleno a cuadrado (`square=True`): el lado del lienzo final es `max(w, h) + 2*pad`
|
||||||
|
y el sujeto se centra. El fondo del lienzo es transparente si la imagen tiene alpha,
|
||||||
|
o el color de fondo estimado si es RGB. Asi no se deforma el sujeto.
|
||||||
|
|
||||||
|
Funcion pura: opera sobre el objeto PIL.Image y devuelve uno nuevo; no toca disco ni
|
||||||
|
red y no muta la imagen de entrada. Si no encuentra contenido (lienzo vacio o todo
|
||||||
|
transparente), devuelve una copia intacta de la entrada — nunca lanza por una imagen
|
||||||
|
sin sujeto (contrato no-throw salvo `img` None).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
def _as_rgb_tuple(c) -> tuple:
|
||||||
|
"""Normaliza un pixel (int de modo L, o tupla RGB/RGBA) a una 3-tupla RGB."""
|
||||||
|
if isinstance(c, (tuple, list)):
|
||||||
|
return tuple(int(x) for x in c[:3])
|
||||||
|
return (int(c), int(c), int(c))
|
||||||
|
|
||||||
|
|
||||||
|
def _corner_bg_color(img) -> tuple:
|
||||||
|
"""Color de fondo estimado: la moda de los cuatro pixeles de esquina (RGB)."""
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
w, h = rgb.size
|
||||||
|
corners = [
|
||||||
|
rgb.getpixel((0, 0)),
|
||||||
|
rgb.getpixel((w - 1, 0)),
|
||||||
|
rgb.getpixel((0, h - 1)),
|
||||||
|
rgb.getpixel((w - 1, h - 1)),
|
||||||
|
]
|
||||||
|
corners = [_as_rgb_tuple(c) for c in corners]
|
||||||
|
return Counter(corners).most_common(1)[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_alpha(img) -> bool:
|
||||||
|
"""True si la imagen lleva transparencia (RGBA, LA o P con transparency)."""
|
||||||
|
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_bbox(img, alpha_threshold: int, bg_tolerance: int):
|
||||||
|
"""Devuelve (l, t, r, b) del contenido o None si no hay.
|
||||||
|
|
||||||
|
Por alpha si la imagen lo tiene; si no, por diferencia contra el color de fondo
|
||||||
|
de las esquinas con tolerancia `bg_tolerance`.
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
if _has_alpha(img):
|
||||||
|
alpha = img.convert("RGBA").getchannel("A")
|
||||||
|
mask = alpha.point(lambda p: 255 if p > alpha_threshold else 0)
|
||||||
|
return mask.getbbox()
|
||||||
|
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
bg = Image.new("RGB", rgb.size, _corner_bg_color(rgb))
|
||||||
|
diff = ImageChops.difference(rgb, bg).convert("L")
|
||||||
|
mask = diff.point(lambda p: 255 if p > bg_tolerance else 0)
|
||||||
|
return mask.getbbox()
|
||||||
|
|
||||||
|
|
||||||
|
def crop_to_content(
|
||||||
|
img,
|
||||||
|
*,
|
||||||
|
pad_ratio: float = 0.02,
|
||||||
|
square: bool = True,
|
||||||
|
alpha_threshold: int = 10,
|
||||||
|
bg_tolerance: int = 16,
|
||||||
|
):
|
||||||
|
"""Recorta una imagen PIL al bbox de su contenido, con margen y cuadrado opcional.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: PIL.Image de entrada (cualquier modo). No se muta.
|
||||||
|
pad_ratio: margen anadido alrededor del sujeto como fraccion del lado mayor
|
||||||
|
del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only.
|
||||||
|
square: si True rellena a un lienzo cuadrado de lado `max(w,h)+2*pad` con el
|
||||||
|
sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB);
|
||||||
|
si False solo recorta al bbox + margen sin cuadrar. keyword-only.
|
||||||
|
alpha_threshold: umbral de alpha (0..255) para considerar un pixel "contenido"
|
||||||
|
cuando la imagen tiene canal alpha. keyword-only.
|
||||||
|
bg_tolerance: tolerancia (0..255) de diferencia contra el color de fondo de
|
||||||
|
las esquinas para imagenes sin alpha (RGB). keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image nueva recortada (y cuadrada si square). Si la imagen no tiene
|
||||||
|
contenido detectable (todo transparente o todo del color de fondo), devuelve
|
||||||
|
una copia intacta de la entrada.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si img es None.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if img is None:
|
||||||
|
raise ValueError("crop_to_content: img es None")
|
||||||
|
|
||||||
|
bbox = _content_bbox(img, int(alpha_threshold), int(bg_tolerance))
|
||||||
|
if bbox is None:
|
||||||
|
return img.copy()
|
||||||
|
|
||||||
|
left, top, right, bottom = bbox
|
||||||
|
cropped = img.crop((left, top, right, bottom))
|
||||||
|
cw, ch = cropped.size
|
||||||
|
pad = int(round(max(cw, ch) * float(pad_ratio)))
|
||||||
|
has_alpha = _has_alpha(img)
|
||||||
|
|
||||||
|
if has_alpha:
|
||||||
|
base = cropped.convert("RGBA")
|
||||||
|
bg_fill = (0, 0, 0, 0)
|
||||||
|
mode = "RGBA"
|
||||||
|
else:
|
||||||
|
base = cropped.convert("RGB")
|
||||||
|
bg_fill = _corner_bg_color(img)
|
||||||
|
mode = "RGB"
|
||||||
|
|
||||||
|
if square:
|
||||||
|
side = max(cw, ch) + 2 * pad
|
||||||
|
canvas = Image.new(mode, (side, side), bg_fill)
|
||||||
|
ox = (side - cw) // 2
|
||||||
|
oy = (side - ch) // 2
|
||||||
|
else:
|
||||||
|
if pad <= 0:
|
||||||
|
return base
|
||||||
|
canvas = Image.new(mode, (cw + 2 * pad, ch + 2 * pad), bg_fill)
|
||||||
|
ox = oy = pad
|
||||||
|
|
||||||
|
if has_alpha:
|
||||||
|
canvas.paste(base, (ox, oy), base) # usa el alpha del sujeto como mascara
|
||||||
|
else:
|
||||||
|
canvas.paste(base, (ox, oy))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("uso: crop_to_content.py <src> <dst> [pad_ratio]", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
src, dst = sys.argv[1], sys.argv[2]
|
||||||
|
pr = float(sys.argv[3]) if len(sys.argv) > 3 else 0.06
|
||||||
|
with Image.open(src) as im:
|
||||||
|
out = crop_to_content(im, pad_ratio=pr)
|
||||||
|
out.save(dst)
|
||||||
|
print(f"ok: {src} -> {dst} {out.size} {out.mode}")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: pixeloe_downscale
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def pixeloe_downscale(src_path: str, dst_path: str, *, mode: str = 'contrast', target_size: int = 64, patch_size: int = 16, thickness: int = 2, color_matching: bool = True, no_upscale: bool = True, comfy_python: str | None = None) -> dict"
|
||||||
|
description: "Downscale contrast-aware (Contrast-Aware Outline Expansion de Kohaku, lib `pixeloe`) que colapsa una ilustracion a un grid de pixel-art pequeno (64 personajes, 32 iconos) conservando contornos/silueta. Es la etapa de downscale del metodo SOTA de pixel-art (report 0215). NO cuantiza la paleta (eso lo hace despues comfyui_pixelize_image). Resuelve el gotcha de que `pixeloe` solo vive en el venv de ComfyUI con un 'bridge' de interprete: si falta en el interprete actual, re-ejecuta su nucleo por subprocess con el python de ComfyUI. No-throw: todo error viaja en `error`. Determinista; impura por I/O de disco + subprocess. Devuelve {ok, out_path, size, mode, target_size, via, error}."
|
||||||
|
tags: [comfyui, gamedev-2d, pixelart, ml, pixeloe, downscale, contrast-aware, image, bridge]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: src_path
|
||||||
|
desc: "ruta de la imagen de entrada (PNG/JPG/...). Si no existe -> ok=False con error."
|
||||||
|
- name: dst_path
|
||||||
|
desc: "ruta del PNG de salida; se crea el directorio padre si falta."
|
||||||
|
- name: mode
|
||||||
|
desc: "algoritmo de downscale de pixeloe: 'contrast' (SOTA, conserva silueta), 'bicubic', 'nearest', 'center' o 'k-centroid'. keyword-only."
|
||||||
|
- name: target_size
|
||||||
|
desc: "lado del grid resultante en pixeles (64 para personajes, 32 para iconos). keyword-only."
|
||||||
|
- name: patch_size
|
||||||
|
desc: "tamano del patch que pixeloe colapsa por celda del grid. keyword-only."
|
||||||
|
- name: thickness
|
||||||
|
desc: "grosor de la expansion de contorno (outline expansion). keyword-only."
|
||||||
|
- name: color_matching
|
||||||
|
desc: "corrige el color de cada celda contra el original si True. keyword-only."
|
||||||
|
- name: no_upscale
|
||||||
|
desc: "True devuelve el grid real target_size x target_size (lo habitual, para luego cuantizar); False re-escala al tamano original con pixeles duros (preview). keyword-only."
|
||||||
|
- name: comfy_python
|
||||||
|
desc: "ruta a un interprete con `pixeloe` para el bridge cuando el actual no la tiene. Si None: COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
|
||||||
|
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen escrita), mode (str usado), target_size (int pedido), via ('inproc' si pixeloe estaba en este interprete, 'bridge' si se delego por subprocess) y error (str, vacio si OK). No lanza excepciones."
|
||||||
|
tested: true
|
||||||
|
tests: [test_golden_downscale_64_or_clean_degrade, test_edge_target_size_32, test_edge_mode_nearest_no_color_matching, test_error_missing_src_no_throw, test_error_no_interpreter_with_pixeloe]
|
||||||
|
test_file_path: "python/functions/ml/pixeloe_downscale_test.py"
|
||||||
|
file_path: "python/functions/ml/pixeloe_downscale.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.pixeloe_downscale import pixeloe_downscale
|
||||||
|
|
||||||
|
# Colapsa el render del caballero (1024x1024) a un grid de pixel-art 64x64
|
||||||
|
# conservando la silueta. NO cuantiza paleta todavia.
|
||||||
|
res = pixeloe_downscale(
|
||||||
|
os.path.expanduser("~/ComfyUI/output/pixel_compare/knight_base_00001_.png"),
|
||||||
|
"/tmp/knight_grid64.png",
|
||||||
|
mode="contrast", target_size=64, no_upscale=True,
|
||||||
|
)
|
||||||
|
# {'ok': True, 'out_path': '/tmp/knight_grid64.png', 'size': [64, 64],
|
||||||
|
# 'mode': 'contrast', 'target_size': 64, 'via': 'bridge', 'error': ''}
|
||||||
|
|
||||||
|
# Despues: dureza de color (cuantizacion) con la funcion hermana.
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
comfyui_pixelize_image("/tmp/knight_grid64.png", "/tmp/knight_q16.png",
|
||||||
|
downscale=1, colors=16, upscale_back=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Primera etapa del metodo SOTA de pixel-art: cuando ya tienes una ilustracion (render
|
||||||
|
SDXL/Flux, sprite, foto) y quieres reducirla a un grid de pixel-art chico **sin perder
|
||||||
|
los contornos** (lo que arruina un resize NEAREST/lanczos normal). Usala **antes** de
|
||||||
|
la cuantizacion dura de paleta con `comfyui_pixelize_image` (paso de color). `target_size`
|
||||||
|
64 para personajes, 32 para iconos. Si solo necesitas el resize+cuantizado rapido sin
|
||||||
|
contornos finos, `comfyui_pixelize_image` sola basta; para el resultado ganador, encadena
|
||||||
|
`pixeloe_downscale` -> `comfyui_pixelize_image`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **`pixeloe` solo esta en el venv de ComfyUI** (`~/ComfyUI/.venv`), no en el del registry.
|
||||||
|
La funcion lo resuelve con un *bridge*: si `import pixeloe` falla, re-ejecuta su nucleo
|
||||||
|
por subprocess con el python de ComfyUI. El campo `via` dice si fue `inproc` o `bridge`.
|
||||||
|
- **El modulo es `pixeloe.legacy.pixelize`**, no `pixeloe.pixelize` (ruta vieja eliminada).
|
||||||
|
- **El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba roto** por ese cambio de import;
|
||||||
|
por eso aqui se llama la lib directa (numpy + PIL, sin cv2).
|
||||||
|
- **NO cuantiza la paleta**: el resultado conserva muchos colores; la dureza retro la aplica
|
||||||
|
despues `comfyui_pixelize_image`. No esperes pocos colores en la salida.
|
||||||
|
- **No-throw**: src inexistente, pixeloe ausente en todos los interpretes, o subprocess
|
||||||
|
caido -> `ok=False` con `error` explicado, nunca excepcion. El pipeline llamante hace
|
||||||
|
fallback mirando `ok`.
|
||||||
|
- Resolucion del interprete del bridge: arg `comfy_python` -> env `COMFY_PYTHON` ->
|
||||||
|
`~/ComfyUI/.venv/bin/python3` (el primero que exista como archivo).
|
||||||
|
- `no_upscale=True` (default) devuelve el grid real `target_size x target_size`; con `False`
|
||||||
|
vuelve al tamano original con pixeles duros (preview), no el grid pequeno.
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""pixeloe_downscale — downscale contrast-aware a un grid de pixel-art (etapa SOTA).
|
||||||
|
|
||||||
|
Colapsa una ilustracion a un grid de pixel-art pequeno (p.ej. 64x64) usando la
|
||||||
|
libreria `pixeloe` de Kohaku (Contrast-Aware Outline Expansion), el metodo SOTA
|
||||||
|
para preservar contornos/silueta al reducir. Es la etapa de *downscale* del
|
||||||
|
metodo ganador de pixel-art (ver report 0215): NO cuantiza la paleta — esa dureza
|
||||||
|
de color la aplica despues otra funcion (`comfyui_pixelize_image`).
|
||||||
|
|
||||||
|
Gotcha de entorno (resuelto con un "bridge" de interprete): la lib `pixeloe` solo
|
||||||
|
esta instalada en el venv de ComfyUI (`~/ComfyUI/.venv`), no en el venv del
|
||||||
|
registry, y su modulo vive en `pixeloe.legacy.pixelize` (la ruta vieja
|
||||||
|
`pixeloe.pixelize` ya no existe). Por eso la funcion:
|
||||||
|
|
||||||
|
1. Intenta `import pixeloe` en el interprete actual y ejecuta el nucleo directo.
|
||||||
|
2. Si falta (`ModuleNotFoundError`), re-ejecuta este mismo archivo como subprocess
|
||||||
|
(`python pixeloe_downscale.py --bridge <json>`) con un interprete que SI la
|
||||||
|
tenga, parseando la unica linea JSON que ese hijo imprime a stdout.
|
||||||
|
3. Si no hay ningun interprete con pixeloe, devuelve ok=False (sin excepcion);
|
||||||
|
el pipeline que la llama hara fallback.
|
||||||
|
|
||||||
|
La funcion es no-throw: cualquier error se captura y viaja en el campo `error`.
|
||||||
|
Determinista; impura solo por la lectura/escritura de disco y el subprocess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_comfy_python(comfy_python):
|
||||||
|
"""Devuelve el primer interprete candidato que exista como archivo, o None.
|
||||||
|
|
||||||
|
Orden: arg comfy_python -> env COMFY_PYTHON -> ~/ComfyUI/.venv/bin/python3.
|
||||||
|
"""
|
||||||
|
candidates = []
|
||||||
|
if comfy_python:
|
||||||
|
candidates.append(comfy_python)
|
||||||
|
env = os.environ.get("COMFY_PYTHON")
|
||||||
|
if env:
|
||||||
|
candidates.append(env)
|
||||||
|
candidates.append(os.path.expanduser("~/ComfyUI/.venv/bin/python3"))
|
||||||
|
for c in candidates:
|
||||||
|
if c and os.path.isfile(c):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_core(src_path, dst_path, mode, target_size, patch_size, thickness,
|
||||||
|
color_matching, no_upscale):
|
||||||
|
"""Nucleo no-throw: requiere `pixeloe` importable EN ESTE interprete.
|
||||||
|
|
||||||
|
Lee src como RGB uint8 (numpy + PIL, sin cv2), llama
|
||||||
|
`pixeloe.legacy.pixelize.pixelize` y guarda el resultado como PNG. Devuelve el
|
||||||
|
dict de resultado. NO lanza excepciones: las captura en `error`.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": [0, 0],
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"via": "inproc",
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
except Exception as exc: # noqa: BLE001 - degradacion limpia, no relanzar
|
||||||
|
out["error"] = f"numpy/PIL no disponible en este interprete: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if not os.path.isfile(src_path):
|
||||||
|
out["error"] = f"src_path no existe: {src_path!r}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pixeloe.legacy.pixelize import pixelize
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"no se pudo importar pixeloe.legacy.pixelize: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = np.array(Image.open(src_path).convert("RGB")) # HxWx3 uint8
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = pixelize(
|
||||||
|
img,
|
||||||
|
mode=mode,
|
||||||
|
target_size=int(target_size),
|
||||||
|
patch_size=int(patch_size),
|
||||||
|
thickness=int(thickness),
|
||||||
|
contrast=1.0,
|
||||||
|
saturation=1.0,
|
||||||
|
color_matching=bool(color_matching),
|
||||||
|
no_upscale=bool(no_upscale),
|
||||||
|
)
|
||||||
|
except TypeError as exc:
|
||||||
|
# Firma de pixelize distinta a la esperada: reseñar, no relanzar.
|
||||||
|
out["error"] = f"pixelize rechazo los kwargs (firma distinta?): {exc}"
|
||||||
|
return out
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"pixelize fallo: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
arr = np.asarray(res)
|
||||||
|
result_img = Image.fromarray(arr)
|
||||||
|
dst_dir = os.path.dirname(os.path.abspath(dst_path))
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
result_img.save(dst_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(ok=True, out_path=dst_path, size=list(result_img.size), error="")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _run_via_bridge(interp, src_path, dst_path, mode, target_size, patch_size,
|
||||||
|
thickness, color_matching, no_upscale):
|
||||||
|
"""Ejecuta el nucleo en otro interprete (que tiene pixeloe) via subprocess.
|
||||||
|
|
||||||
|
Corre `interp <este_archivo> --bridge <json_args>` y parsea la ultima linea de
|
||||||
|
stdout que sea JSON valido (pixeloe puede emitir ruido antes). No-throw.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": [0, 0],
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"via": "bridge",
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
args = {
|
||||||
|
"src_path": src_path,
|
||||||
|
"dst_path": dst_path,
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"patch_size": int(patch_size),
|
||||||
|
"thickness": int(thickness),
|
||||||
|
"color_matching": bool(color_matching),
|
||||||
|
"no_upscale": bool(no_upscale),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[interp, os.path.abspath(__file__), "--bridge", json.dumps(args)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"fallo el subprocess bridge ({interp}): {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
tail = (proc.stderr or "").strip()[-500:]
|
||||||
|
out["error"] = f"bridge salio con codigo {proc.returncode}: {tail}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Parsea de atras hacia delante la primera linea que sea JSON valido.
|
||||||
|
parsed = None
|
||||||
|
for ln in reversed((proc.stdout or "").splitlines()):
|
||||||
|
ln = ln.strip()
|
||||||
|
if not ln:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(ln)
|
||||||
|
break
|
||||||
|
except Exception: # noqa: BLE001 - linea de ruido, sigue probando
|
||||||
|
continue
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
tail = (proc.stderr or "").strip()[-300:]
|
||||||
|
out["error"] = f"bridge no produjo salida JSON. stderr: {tail}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
parsed["via"] = "bridge"
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def pixeloe_downscale(
|
||||||
|
src_path: str,
|
||||||
|
dst_path: str,
|
||||||
|
*,
|
||||||
|
mode: str = "contrast",
|
||||||
|
target_size: int = 64,
|
||||||
|
patch_size: int = 16,
|
||||||
|
thickness: int = 2,
|
||||||
|
color_matching: bool = True,
|
||||||
|
no_upscale: bool = True,
|
||||||
|
comfy_python: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Downscale contrast-aware de una imagen a un grid de pixel-art (no cuantiza).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_path: ruta de la imagen de entrada (PNG/JPG/...).
|
||||||
|
dst_path: ruta del PNG de salida (se crea el directorio si falta).
|
||||||
|
mode: algoritmo de downscale de pixeloe: "contrast" (SOTA, conserva
|
||||||
|
silueta), "bicubic", "nearest", "center" o "k-centroid". keyword-only.
|
||||||
|
target_size: lado del grid resultante en pixeles (64 personajes, 32
|
||||||
|
iconos). keyword-only.
|
||||||
|
patch_size: tamano del patch que pixeloe colapsa por celda. keyword-only.
|
||||||
|
thickness: grosor de la expansion de contorno (outline). keyword-only.
|
||||||
|
color_matching: corrige el color de cada celda contra el original si True.
|
||||||
|
keyword-only.
|
||||||
|
no_upscale: True devuelve el grid real target_size x target_size (lo
|
||||||
|
habitual para luego cuantizar); False re-escala al tamano original con
|
||||||
|
pixeles duros (preview). keyword-only.
|
||||||
|
comfy_python: ruta a un interprete con `pixeloe` para el bridge cuando el
|
||||||
|
actual no la tiene. Si None, se prueba COMFY_PYTHON y luego
|
||||||
|
~/ComfyUI/.venv/bin/python3. keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si se hizo el downscale y se guardo el PNG.
|
||||||
|
- out_path (str): ruta del PNG generado.
|
||||||
|
- size (list[int]): [w, h] de la imagen escrita.
|
||||||
|
- mode (str): modo de downscale usado.
|
||||||
|
- target_size (int): lado del grid pedido.
|
||||||
|
- via (str): "inproc" si pixeloe estaba en este interprete, "bridge" si se
|
||||||
|
delego a otro interprete por subprocess.
|
||||||
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": [0, 0],
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"via": "",
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not os.path.isfile(src_path):
|
||||||
|
out["error"] = f"src_path no existe: {src_path!r}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# 1. pixeloe disponible en el interprete actual -> nucleo directo.
|
||||||
|
has_local = True
|
||||||
|
try:
|
||||||
|
import pixeloe # noqa: F401
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
has_local = False
|
||||||
|
except Exception: # noqa: BLE001 - pixeloe presente pero roto -> bridge
|
||||||
|
has_local = False
|
||||||
|
|
||||||
|
if has_local:
|
||||||
|
res = _run_core(
|
||||||
|
src_path, dst_path, mode, int(target_size), int(patch_size),
|
||||||
|
int(thickness), bool(color_matching), bool(no_upscale),
|
||||||
|
)
|
||||||
|
res["via"] = "inproc"
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 2. Bridge a un interprete que tenga pixeloe.
|
||||||
|
interp = _resolve_comfy_python(comfy_python)
|
||||||
|
if interp is None:
|
||||||
|
out["error"] = (
|
||||||
|
"pixeloe no disponible: no se encontro ningun interprete con "
|
||||||
|
"pixeloe (pasa comfy_python, define COMFY_PYTHON, o instala "
|
||||||
|
"~/ComfyUI/.venv)"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
return _run_via_bridge(
|
||||||
|
interp, src_path, dst_path, mode, int(target_size), int(patch_size),
|
||||||
|
int(thickness), bool(color_matching), bool(no_upscale),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - contrato no-throw
|
||||||
|
out["error"] = f"error inesperado: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if "--bridge" in sys.argv:
|
||||||
|
# Modo bridge: ejecuta el nucleo y emite UNA linea JSON a stdout.
|
||||||
|
_idx = sys.argv.index("--bridge")
|
||||||
|
_payload = sys.argv[_idx + 1] if len(sys.argv) > _idx + 1 else "{}"
|
||||||
|
try:
|
||||||
|
_a = json.loads(_payload)
|
||||||
|
except Exception as _exc: # noqa: BLE001
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": False, "out_path": "", "size": [0, 0], "mode": "",
|
||||||
|
"target_size": 0, "via": "inproc",
|
||||||
|
"error": f"payload --bridge invalido: {_exc}",
|
||||||
|
}))
|
||||||
|
sys.exit(0)
|
||||||
|
_res = _run_core(
|
||||||
|
_a.get("src_path", ""),
|
||||||
|
_a.get("dst_path", ""),
|
||||||
|
_a.get("mode", "contrast"),
|
||||||
|
_a.get("target_size", 64),
|
||||||
|
_a.get("patch_size", 16),
|
||||||
|
_a.get("thickness", 2),
|
||||||
|
_a.get("color_matching", True),
|
||||||
|
_a.get("no_upscale", True),
|
||||||
|
)
|
||||||
|
print(json.dumps(_res))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Modo CLI normal.
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("uso: pixeloe_downscale.py <src> <dst> [target_size] [mode]",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
_src, _dst = sys.argv[1], sys.argv[2]
|
||||||
|
_ts = int(sys.argv[3]) if len(sys.argv) > 3 else 64
|
||||||
|
_md = sys.argv[4] if len(sys.argv) > 4 else "contrast"
|
||||||
|
print(json.dumps(pixeloe_downscale(_src, _dst, target_size=_ts, mode=_md),
|
||||||
|
indent=2))
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests de estructura/determinismo para comfyui_build_asset_variant_workflow (func pura, img2img)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_build_asset_variant_workflow import comfyui_build_asset_variant_workflow
|
||||||
|
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(wf):
|
||||||
|
return [n["inputs"].get("text", "") for n in wf.values() if n["class_type"] == "CLIPTextEncode"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_img2img():
|
||||||
|
# img2img: parte de una imagen (LoadImage + VAEEncode), NO de EmptyLatentImage.
|
||||||
|
wf = comfyui_build_asset_variant_workflow("enemy.png", "ice element")
|
||||||
|
assert_api_format(wf)
|
||||||
|
cts = class_types(wf)
|
||||||
|
for ct in ("CheckpointLoaderSimple", "LoadImage", "VAEEncode", "CLIPTextEncode",
|
||||||
|
"KSampler", "VAEDecode", "SaveImage"):
|
||||||
|
assert ct in cts, f"falta nodo {ct}"
|
||||||
|
assert "EmptyLatentImage" not in cts # img2img no genera desde ruido
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_image_y_prompt_reflejados():
|
||||||
|
wf = comfyui_build_asset_variant_workflow(" enemy_creature_00001_.png ", "fire element")
|
||||||
|
# input_image se strippea y llega al LoadImage.
|
||||||
|
assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "enemy_creature_00001_.png"
|
||||||
|
# el positivo contiene la variante + el refuerzo de composicion.
|
||||||
|
pos = [t for t in _texts(wf) if "same composition" in t]
|
||||||
|
assert pos and "fire element" in pos[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_default_inserta_imagescale():
|
||||||
|
# size=512 por defecto -> normaliza la base con un ImageScale a 512x512.
|
||||||
|
wf = comfyui_build_asset_variant_workflow("enemy.png", "golden tier 2")
|
||||||
|
scale = node_by_ct(wf, "ImageScale")["inputs"]
|
||||||
|
assert scale["width"] == 512 and scale["height"] == 512
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_none_sin_imagescale():
|
||||||
|
wf = comfyui_build_asset_variant_workflow("enemy.png", "frozen", size=None)
|
||||||
|
assert "ImageScale" not in class_types(wf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_denoise_se_clampa():
|
||||||
|
assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=2.0),
|
||||||
|
"KSampler")["inputs"]["denoise"] == 1.0
|
||||||
|
assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=-1.0),
|
||||||
|
"KSampler")["inputs"]["denoise"] == 0.0
|
||||||
|
assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=0.5),
|
||||||
|
"KSampler")["inputs"]["denoise"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_filename_prefix_y_seed():
|
||||||
|
wf = comfyui_build_asset_variant_workflow("e.png", "v", seed=123, filename_prefix="mio")
|
||||||
|
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio"
|
||||||
|
assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_inyecta_loraloader():
|
||||||
|
sin = comfyui_build_asset_variant_workflow("e.png", "v")
|
||||||
|
con = comfyui_build_asset_variant_workflow("e.png", "v", lora="SD15_dark.safetensors")
|
||||||
|
assert "LoraLoader" not in class_types(sin)
|
||||||
|
assert "LoraLoader" in class_types(con)
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_image_vacio_lanza():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_asset_variant_workflow(" ", "v")
|
||||||
|
|
||||||
|
|
||||||
|
def test_variant_vacio_lanza():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_asset_variant_workflow("e.png", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista():
|
||||||
|
a = comfyui_build_asset_variant_workflow("e.png", "ice", seed=7, denoise=0.5)
|
||||||
|
b = comfyui_build_asset_variant_workflow("e.png", "ice", seed=7, denoise=0.5)
|
||||||
|
assert a == b
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Tests de estructura/determinismo para comfyui_build_directional_sprite_workflow (func pura, 2.5D)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_build_directional_sprite_workflow import (
|
||||||
|
comfyui_build_directional_sprite_workflow,
|
||||||
|
directional_sprite_view_order,
|
||||||
|
)
|
||||||
|
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
|
||||||
|
|
||||||
|
|
||||||
|
def test_sv3d_estructura_y_orbit_default():
|
||||||
|
wf = comfyui_build_directional_sprite_workflow("goblin.png", directions=8, model="sv3d")
|
||||||
|
assert_api_format(wf)
|
||||||
|
cts = class_types(wf)
|
||||||
|
for ct in ("LoadImage", "ImageOnlyCheckpointLoader", "SV3D_Conditioning",
|
||||||
|
"VideoLinearCFGGuidance", "KSampler", "VAEDecode", "SaveImage"):
|
||||||
|
assert ct in cts, f"falta nodo {ct}"
|
||||||
|
cond = node_by_ct(wf, "SV3D_Conditioning")["inputs"]
|
||||||
|
# video_frames default = directions; size nativa sv3d = 576.
|
||||||
|
assert cond["video_frames"] == 8
|
||||||
|
assert cond["width"] == 576 and cond["height"] == 576
|
||||||
|
|
||||||
|
|
||||||
|
def test_sv3d_orbit_frames_override():
|
||||||
|
wf = comfyui_build_directional_sprite_workflow("g.png", directions=8, orbit_frames=21)
|
||||||
|
assert node_by_ct(wf, "SV3D_Conditioning")["inputs"]["video_frames"] == 21
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero123_estructura_y_azimuth():
|
||||||
|
wf = comfyui_build_directional_sprite_workflow("g.png", directions=4, model="zero123")
|
||||||
|
assert_api_format(wf)
|
||||||
|
cts = class_types(wf)
|
||||||
|
assert "StableZero123_Conditioning_Batched" in cts
|
||||||
|
assert "SV3D_Conditioning" not in cts # camino distinto al sv3d
|
||||||
|
cond = node_by_ct(wf, "StableZero123_Conditioning_Batched")["inputs"]
|
||||||
|
# batch = directions; size nativa zero123 = 256; azimuth equiespaciado 360/N.
|
||||||
|
assert cond["batch_size"] == 4
|
||||||
|
assert cond["width"] == 256 and cond["height"] == 256
|
||||||
|
assert cond["azimuth_batch_increment"] == 90.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cfg_y_ckpt_default_por_modelo():
|
||||||
|
sv3d = comfyui_build_directional_sprite_workflow("g.png", model="sv3d")
|
||||||
|
z123 = comfyui_build_directional_sprite_workflow("g.png", model="zero123")
|
||||||
|
assert node_by_ct(sv3d, "KSampler")["inputs"]["cfg"] == 2.5
|
||||||
|
assert node_by_ct(z123, "KSampler")["inputs"]["cfg"] == 4.0
|
||||||
|
assert node_by_ct(sv3d, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"] == "3D_sv3d_p.safetensors"
|
||||||
|
assert node_by_ct(z123, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"] == "3D_stable_zero123.ckpt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_elevation_y_seed_reflejados():
|
||||||
|
wf = comfyui_build_directional_sprite_workflow("g.png", model="sv3d", elevation=15.0, seed=42)
|
||||||
|
assert node_by_ct(wf, "SV3D_Conditioning")["inputs"]["elevation"] == 15.0
|
||||||
|
assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_order_helper():
|
||||||
|
assert directional_sprite_view_order(8) == ["S", "SE", "E", "NE", "N", "NW", "W", "SW"]
|
||||||
|
assert directional_sprite_view_order(4) == ["S", "E", "N", "W"]
|
||||||
|
# N no canonico -> etiquetas por azimuth.
|
||||||
|
assert directional_sprite_view_order(6) == ["az0", "az60", "az120", "az180", "az240", "az300"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_errores():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_directional_sprite_workflow("")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_directional_sprite_workflow("g.png", model="turbo")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_directional_sprite_workflow("g.png", directions=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista():
|
||||||
|
a = comfyui_build_directional_sprite_workflow("g.png", directions=8, seed=7, elevation=15.0)
|
||||||
|
b = comfyui_build_directional_sprite_workflow("g.png", directions=8, seed=7, elevation=15.0)
|
||||||
|
assert a == b
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Tests offline para comfyui_build_grid (impura PIL: lee N imagenes -> PNG grid).
|
||||||
|
|
||||||
|
Sin red, sin GPU, sin servidor: crea PNGs reales en un tmp_path y monta el grid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_build_grid import comfyui_build_grid
|
||||||
|
|
||||||
|
PIL = pytest.importorskip("PIL")
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _png(path, size=(64, 64), color=(120, 30, 30)):
|
||||||
|
Image.new("RGB", size, color).save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_basico(tmp_path):
|
||||||
|
paths = [_png(tmp_path / f"i{i}.png") for i in range(4)]
|
||||||
|
out = tmp_path / "grid.png"
|
||||||
|
res = comfyui_build_grid(paths, out_path=str(out))
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["error"] == ""
|
||||||
|
assert os.path.isfile(res["out_path"]) and res["out_path"] == str(out)
|
||||||
|
# 4 imagenes -> ceil(sqrt(4)) = 2 columnas, 2 filas.
|
||||||
|
assert res["cols"] == 2 and res["rows"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cols_explicito_y_filas(tmp_path):
|
||||||
|
paths = [_png(tmp_path / f"i{i}.png") for i in range(5)]
|
||||||
|
res = comfyui_build_grid(paths, cols=5, out_path=str(tmp_path / "g.png"))
|
||||||
|
assert res["cols"] == 5 and res["rows"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_define_dimension_del_canvas(tmp_path):
|
||||||
|
paths = [_png(tmp_path / f"i{i}.png") for i in range(2)]
|
||||||
|
res = comfyui_build_grid(paths, cols=2, cell=128, out_path=str(tmp_path / "g.png"))
|
||||||
|
with Image.open(res["out_path"]) as im:
|
||||||
|
# 2 columnas x 128 cell = 256 ancho; 1 fila x 128 = 128 alto.
|
||||||
|
assert im.size == (256, 128)
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_reservan_franja(tmp_path):
|
||||||
|
paths = [_png(tmp_path / f"i{i}.png") for i in range(2)]
|
||||||
|
res = comfyui_build_grid(paths, cols=2, cell=64, labels=["a", "b"],
|
||||||
|
out_path=str(tmp_path / "g.png"))
|
||||||
|
with Image.open(res["out_path"]) as im:
|
||||||
|
# con labels se reservan 22px bajo cada celda: alto = 64 + 22.
|
||||||
|
assert im.size == (128, 86)
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_lista_vacia():
|
||||||
|
res = comfyui_build_grid([])
|
||||||
|
assert res["ok"] is False and "vacio" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_ruta_inexistente(tmp_path):
|
||||||
|
res = comfyui_build_grid([str(tmp_path / "no_existe.png")])
|
||||||
|
assert res["ok"] is False and "no existen" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista_mismo_dict(tmp_path):
|
||||||
|
paths = [_png(tmp_path / f"i{i}.png") for i in range(3)]
|
||||||
|
a = comfyui_build_grid(paths, out_path=str(tmp_path / "a.png"))
|
||||||
|
b = comfyui_build_grid(paths, out_path=str(tmp_path / "b.png"))
|
||||||
|
# rows/cols/ok son determableinistas para las mismas entradas.
|
||||||
|
assert (a["ok"], a["rows"], a["cols"]) == (b["ok"], b["rows"], b["cols"])
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests de estructura/determinismo para comfyui_build_inpaint_asset_workflow (func pura, inpaint)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_build_inpaint_asset_workflow import comfyui_build_inpaint_asset_workflow
|
||||||
|
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(wf):
|
||||||
|
return [n["inputs"].get("text", "") for n in wf.values() if n["class_type"] == "CLIPTextEncode"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_vae_encode():
|
||||||
|
wf = comfyui_build_inpaint_asset_workflow("asset.png", "mask.png", "a golden sword")
|
||||||
|
assert_api_format(wf)
|
||||||
|
cts = class_types(wf)
|
||||||
|
for ct in ("CheckpointLoaderSimple", "LoadImage", "LoadImageMask",
|
||||||
|
"VAEEncodeForInpaint", "CLIPTextEncode", "KSampler", "VAEDecode", "SaveImage"):
|
||||||
|
assert ct in cts, f"falta nodo {ct}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_region_y_grow_mask():
|
||||||
|
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "blue shield", grow_mask=8)
|
||||||
|
pos = [t for t in _texts(wf) if "seamless blend" in t]
|
||||||
|
assert pos and "blue shield" in pos[0]
|
||||||
|
assert node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]["grow_mask_by"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_grow_mask_se_clampa():
|
||||||
|
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", grow_mask=999)
|
||||||
|
assert node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]["grow_mask_by"] == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_modo_noise_mask_degrada():
|
||||||
|
# noise_mask reemplaza VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask).
|
||||||
|
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", mode="noise_mask", grow_mask=6)
|
||||||
|
cts = class_types(wf)
|
||||||
|
assert "VAEEncodeForInpaint" not in cts
|
||||||
|
assert "VAEEncode" in cts and "SetLatentNoiseMask" in cts and "GrowMask" in cts
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_inserta_imagescale_a_imagen_y_mascara():
|
||||||
|
# size en modo vae_encode escala imagen Y mascara de forma consistente.
|
||||||
|
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", size=768)
|
||||||
|
scales = [n for n in wf.values() if n["class_type"] == "ImageScale"]
|
||||||
|
assert len(scales) == 2 # una para la imagen, otra para la mascara
|
||||||
|
assert all(s["inputs"]["width"] == 768 and s["inputs"]["height"] == 768 for s in scales)
|
||||||
|
assert "ImageToMask" in class_types(wf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_y_filename():
|
||||||
|
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", lora="x.safetensors",
|
||||||
|
filename_prefix="mio")
|
||||||
|
assert "LoraLoader" in class_types(wf)
|
||||||
|
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_errores():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_inpaint_asset_workflow("", "m.png", "p")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_inpaint_asset_workflow("a.png", "", "p")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_inpaint_asset_workflow("a.png", "m.png", "")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", mode="otro")
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista():
|
||||||
|
a = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "orb", seed=7, grow_mask=6)
|
||||||
|
b = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "orb", seed=7, grow_mask=6)
|
||||||
|
assert a == b
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Tests de estructura/determinismo para comfyui_build_outpaint_asset_workflow (func pura, outpaint)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_build_outpaint_asset_workflow import comfyui_build_outpaint_asset_workflow
|
||||||
|
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_outpaint():
|
||||||
|
wf = comfyui_build_outpaint_asset_workflow("bg.png", "more forest", right=256)
|
||||||
|
assert_api_format(wf)
|
||||||
|
cts = class_types(wf)
|
||||||
|
for ct in ("CheckpointLoaderSimple", "LoadImage", "ImagePadForOutpaint",
|
||||||
|
"VAEEncodeForInpaint", "CLIPTextEncode", "KSampler", "VAEDecode", "SaveImage"):
|
||||||
|
assert ct in cts, f"falta nodo {ct}"
|
||||||
|
# outpaint genera su mascara con el pad: NO usa LoadImageMask.
|
||||||
|
assert "LoadImageMask" not in cts
|
||||||
|
|
||||||
|
|
||||||
|
def test_pad_cableado_a_vaeencode():
|
||||||
|
# VAEEncodeForInpaint toma pixels de la IMAGE del pad y mask de la MASK del pad.
|
||||||
|
wf = comfyui_build_outpaint_asset_workflow("bg.png", "sky", top=128)
|
||||||
|
pad_id = next(nid for nid, n in wf.items() if n["class_type"] == "ImagePadForOutpaint")
|
||||||
|
enc = node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]
|
||||||
|
assert enc["pixels"] == [pad_id, 0]
|
||||||
|
assert enc["mask"] == [pad_id, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extensiones_redondeadas_a_8():
|
||||||
|
# _round8 normaliza al multiplo de 8 mas cercano.
|
||||||
|
wf = comfyui_build_outpaint_asset_workflow("bg.png", "p", right=10)
|
||||||
|
pad = node_by_ct(wf, "ImagePadForOutpaint")["inputs"]
|
||||||
|
assert pad["right"] == 8 and pad["left"] == 0 and pad["top"] == 0 and pad["bottom"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_extension_lanza():
|
||||||
|
# las cuatro extensiones a 0 (tras redondear) -> no hay nada que extender.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_outpaint_asset_workflow("bg.png", "p", left=3, right=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feather_y_prompt():
|
||||||
|
wf = comfyui_build_outpaint_asset_workflow("bg.png", "open sky", top=64, feather=30)
|
||||||
|
assert node_by_ct(wf, "ImagePadForOutpaint")["inputs"]["feathering"] == 30
|
||||||
|
pos = [n["inputs"]["text"] for n in wf.values()
|
||||||
|
if n["class_type"] == "CLIPTextEncode" and "seamless extension" in n["inputs"].get("text", "")]
|
||||||
|
assert pos and "open sky" in pos[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_y_filename():
|
||||||
|
wf = comfyui_build_outpaint_asset_workflow("bg.png", "p", right=64, lora="x.safetensors",
|
||||||
|
filename_prefix="mio")
|
||||||
|
assert "LoraLoader" in class_types(wf)
|
||||||
|
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_errores_vacios():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_outpaint_asset_workflow("", "p", right=64)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_outpaint_asset_workflow("bg.png", "", right=64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista():
|
||||||
|
a = comfyui_build_outpaint_asset_workflow("bg.png", "forest", right=256, seed=7)
|
||||||
|
b = comfyui_build_outpaint_asset_workflow("bg.png", "forest", right=256, seed=7)
|
||||||
|
assert a == b
|
||||||
+30
@@ -67,3 +67,33 @@ def test_determinism():
|
|||||||
a = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
a = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
||||||
b = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
b = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
||||||
assert a == b
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_transparent_default_injects_rembg():
|
||||||
|
"""transparent default True -> nodo Image Rembg y SaveImage repuntado a el."""
|
||||||
|
wf = comfyui_build_pixelart_workflow("pixel knight, full body")
|
||||||
|
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
|
||||||
|
assert len(rembg) == 1
|
||||||
|
assert rembg[0]["inputs"]["transparency"] is True
|
||||||
|
assert rembg[0]["inputs"]["model"] == "u2net"
|
||||||
|
# SaveImage debe leer de la salida del Rembg, no del VAEDecode.
|
||||||
|
rembg_id = next(k for k, n in wf.items() if n["class_type"] == "Image Rembg (Remove Background)")
|
||||||
|
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||||
|
assert save["inputs"]["images"][0] == rembg_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_transparent_false_no_rembg():
|
||||||
|
"""transparent=False -> sin nodo Rembg (tiles/fondos opacos)."""
|
||||||
|
wf = comfyui_build_pixelart_workflow("seamless grass tile", transparent=False)
|
||||||
|
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
|
||||||
|
assert len(rembg) == 0
|
||||||
|
# SaveImage lee directo del VAEDecode.
|
||||||
|
vae_id = next(k for k, n in wf.items() if n["class_type"] == "VAEDecode")
|
||||||
|
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||||
|
assert save["inputs"]["images"][0] == vae_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_rembg_model_override():
|
||||||
|
wf = comfyui_build_pixelart_workflow("anime hero", rembg_model="isnet-anime")
|
||||||
|
rembg = next(n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)")
|
||||||
|
assert rembg["inputs"]["model"] == "isnet-anime"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Tests de estructura/determinismo para comfyui_build_sprite_from_sketch_workflow (func pura, ControlNet)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_build_sprite_from_sketch_workflow import comfyui_build_sprite_from_sketch_workflow
|
||||||
|
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_txt2img_mas_controlnet():
|
||||||
|
# txt2img (EmptyLatentImage, denoise alto) guiado por ControlNet atado al boceto.
|
||||||
|
wf = comfyui_build_sprite_from_sketch_workflow("sketch.png", "armored knight")
|
||||||
|
assert_api_format(wf)
|
||||||
|
cts = class_types(wf)
|
||||||
|
for ct in ("CheckpointLoaderSimple", "EmptyLatentImage", "CLIPTextEncode", "KSampler",
|
||||||
|
"VAEDecode", "SaveImage", "LoadImage", "ControlNetLoader", "ControlNetApply"):
|
||||||
|
assert ct in cts, f"falta nodo {ct}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lineart_default_preprocesador_y_modelo():
|
||||||
|
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", control_type="lineart")
|
||||||
|
assert "LineArtPreprocessor" in class_types(wf)
|
||||||
|
assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \
|
||||||
|
"control_v11p_sd15_lineart_fp16.safetensors"
|
||||||
|
# el ControlNetApply consume el mapa de lineas del preprocesador, no el LoadImage directo.
|
||||||
|
pre_id = next(nid for nid, n in wf.items() if n["class_type"].endswith("Preprocessor"))
|
||||||
|
assert node_by_ct(wf, "ControlNetApply")["inputs"]["image"] == [pre_id, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_canny_preprocesador_y_modelo():
|
||||||
|
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "chest", control_type="canny")
|
||||||
|
assert "CannyEdgePreprocessor" in class_types(wf)
|
||||||
|
assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \
|
||||||
|
"control_v11p_sd15_canny_fp16.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preprocess_false_pasa_boceto_directo():
|
||||||
|
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "k", preprocess=False)
|
||||||
|
assert not any(n["class_type"].endswith("Preprocessor") for n in wf.values())
|
||||||
|
load_id = next(nid for nid, n in wf.items() if n["class_type"] == "LoadImage")
|
||||||
|
assert node_by_ct(wf, "ControlNetApply")["inputs"]["image"] == [load_id, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlnet_name_override_y_strength():
|
||||||
|
wf = comfyui_build_sprite_from_sketch_workflow(
|
||||||
|
"s.png", "k", control_type="lineart",
|
||||||
|
controlnet_name="control_v11p_sd15_canny_fp16.safetensors", strength=0.65)
|
||||||
|
assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \
|
||||||
|
"control_v11p_sd15_canny_fp16.safetensors"
|
||||||
|
assert node_by_ct(wf, "ControlNetApply")["inputs"]["strength"] == 0.65
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_se_clampa():
|
||||||
|
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "k", strength=5.0)
|
||||||
|
assert node_by_ct(wf, "ControlNetApply")["inputs"]["strength"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_inyecta():
|
||||||
|
assert "LoraLoader" in class_types(
|
||||||
|
comfyui_build_sprite_from_sketch_workflow("s.png", "k", lora="x.safetensors"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_errores():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_sprite_from_sketch_workflow("", "k")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_sprite_from_sketch_workflow("s.png", "")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
comfyui_build_sprite_from_sketch_workflow("s.png", "k", control_type="depth")
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista():
|
||||||
|
a = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", seed=7, strength=0.8)
|
||||||
|
b = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", seed=7, strength=0.8)
|
||||||
|
assert a == b
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests offline para comfyui_critique_image_llm (impura: critica LLM-vision via ask_llm_vision).
|
||||||
|
|
||||||
|
Sin red, sin API: prueba el parser de JSON puro (_extract_json) y el flujo con ask_llm_vision
|
||||||
|
monkeypatcheado (veredicto estructurado, ambiguo->bad conservador, API caida, texto no parseable).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
import ml.comfyui_critique_image_llm as mod
|
||||||
|
from ml.comfyui_critique_image_llm import comfyui_critique_image_llm, _extract_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_json_fenced():
|
||||||
|
txt = 'blah\n```json\n{"verdict": "good", "score": 8}\n```\nfin'
|
||||||
|
assert _extract_json(txt) == {"verdict": "good", "score": 8}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_json_brace_plano():
|
||||||
|
assert _extract_json(' {"verdict": "bad", "score": 2} ') == {"verdict": "bad", "score": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_json_sin_objeto_lanza():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_extract_json("no hay json aqui")
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_vision(text, ok=True):
|
||||||
|
return lambda user_prompt, image_path, **kw: {"ok": ok, "text": text, "error": "" if ok else "429"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_flujo_veredicto_estructurado(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "ask_llm_vision",
|
||||||
|
_fake_vision('{"verdict": "good", "score": 8.5, "reasons": ["nitida"]}'))
|
||||||
|
res = comfyui_critique_image_llm("i.png", "a cat")
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["verdict"] == "good" and res["score_0_10"] == 8.5
|
||||||
|
assert res["reasons"] == ["nitida"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_ambiguo_cae_a_bad(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "ask_llm_vision",
|
||||||
|
_fake_vision('{"verdict": "maybe", "score": 5}'))
|
||||||
|
res = comfyui_critique_image_llm("i.png", "p")
|
||||||
|
assert res["ok"] is True and res["verdict"] == "bad" # conservador ante ambiguo
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_caida_ok_false(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "ask_llm_vision", _fake_vision("", ok=False))
|
||||||
|
res = comfyui_critique_image_llm("i.png", "p")
|
||||||
|
assert res["ok"] is False and res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_respuesta_no_parseable_ok_false(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "ask_llm_vision", _fake_vision("lo siento, no puedo"))
|
||||||
|
res = comfyui_critique_image_llm("i.png", "p")
|
||||||
|
assert res["ok"] is False and "no parseable" in res["error"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests offline para comfyui_extract_recipe_from_png (impura: destila PNG -> receta de skill).
|
||||||
|
|
||||||
|
Sin red, sin servidor: prueba los helpers puros de extraccion y el flujo de degradacion a la
|
||||||
|
`meta` de Civitai cuando el PNG no trae workflow embebido (PNG inexistente -> sin workflow).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_extract_recipe_from_png import (
|
||||||
|
comfyui_extract_recipe_from_png,
|
||||||
|
_slugify,
|
||||||
|
_loras_from_prompt,
|
||||||
|
_dims_from_prompt,
|
||||||
|
_checkpoint_from_prompt,
|
||||||
|
_detect_base_workflow,
|
||||||
|
_from_civitai_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify():
|
||||||
|
assert _slugify("A Red Apple!", "fb") == "a_red_apple"
|
||||||
|
assert _slugify("", "fallback") == "fallback"
|
||||||
|
# acota a 6 tokens.
|
||||||
|
assert _slugify("one two three four five six seven eight", "fb").count("_") == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_loras_from_prompt():
|
||||||
|
prompt = {"7": {"class_type": "LoraLoader",
|
||||||
|
"inputs": {"lora_name": "style.safetensors",
|
||||||
|
"strength_model": 0.8, "strength_clip": 0.7}}}
|
||||||
|
loras = _loras_from_prompt(prompt)
|
||||||
|
assert loras == [{"name": "style.safetensors", "strength_model": 0.8, "strength_clip": 0.7}]
|
||||||
|
assert _loras_from_prompt({}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dims_y_checkpoint_from_prompt():
|
||||||
|
prompt = {
|
||||||
|
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "dream.safetensors"}},
|
||||||
|
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216}},
|
||||||
|
}
|
||||||
|
assert _dims_from_prompt(prompt) == {"width": 832, "height": 1216}
|
||||||
|
assert _checkpoint_from_prompt(prompt) == "dream.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_base_workflow():
|
||||||
|
assert _detect_base_workflow({"1": {"class_type": "UNETLoader", "inputs": {}}}) == "flux"
|
||||||
|
assert _detect_base_workflow({"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}}) == "txt2img"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_civitai_meta():
|
||||||
|
meta = {"steps": 25, "sampler": "Euler a", "Size": "832x1216", "seed": 7,
|
||||||
|
"cfgScale": 6.5, "Model": "mymodel", "prompt": "a cat", "negativePrompt": "blurry"}
|
||||||
|
out = _from_civitai_meta(meta)
|
||||||
|
assert out["checkpoint"] == "mymodel"
|
||||||
|
assert out["positive"] == "a cat" and out["negative"] == "blurry"
|
||||||
|
assert out["params"]["steps"] == 25 and out["params"]["cfg"] == 6.5
|
||||||
|
assert out["params"]["width"] == 832 and out["params"]["height"] == 1216
|
||||||
|
|
||||||
|
|
||||||
|
def test_flujo_fallback_civitai_meta(tmp_path):
|
||||||
|
# PNG inexistente -> sin workflow embebido; cae a la meta de Civitai (utilizable).
|
||||||
|
res = comfyui_extract_recipe_from_png(
|
||||||
|
str(tmp_path / "no.png"),
|
||||||
|
civitai_meta={"prompt": "a knight", "Model": "dream.safetensors", "steps": 20})
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["has_workflow"] is False
|
||||||
|
recipe = res["recipe"]
|
||||||
|
assert recipe["checkpoint"] == "dream.safetensors"
|
||||||
|
assert recipe["prompt_scaffold"]["positive"] == "a knight"
|
||||||
|
assert recipe["provenance"]["source"] == "civitai" and recipe["score_n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_derivado_del_prompt(tmp_path):
|
||||||
|
res = comfyui_extract_recipe_from_png(
|
||||||
|
str(tmp_path / "no.png"), civitai_meta={"prompt": "Fire Goblin Warrior"})
|
||||||
|
assert res["ok"] is True and res["slug"] == "fire_goblin_warrior"
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_sin_workflow_ni_meta(tmp_path):
|
||||||
|
res = comfyui_extract_recipe_from_png(str(tmp_path / "no.png"))
|
||||||
|
assert res["ok"] is False and res["recipe"] == {}
|
||||||
|
assert "no trae workflow" in res["error"]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Tests offline para comfyui_flatten_alpha_on_color (impura PIL: aplana RGBA sobre fondo solido).
|
||||||
|
|
||||||
|
Sin red, sin GPU, sin servidor: crea un PNG RGBA real y verifica el RGB resultante.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_flatten_alpha_on_color import comfyui_flatten_alpha_on_color
|
||||||
|
|
||||||
|
PIL = pytest.importorskip("PIL")
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba(path, size=(32, 32), color=(0, 0, 0, 0)):
|
||||||
|
Image.new("RGBA", size, color).save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aplana_transparente_sobre_blanco(tmp_path):
|
||||||
|
src = _rgba(tmp_path / "sprite.png", color=(0, 0, 0, 0)) # totalmente transparente
|
||||||
|
out = tmp_path / "flat.png"
|
||||||
|
res = comfyui_flatten_alpha_on_color(src, out_path=str(out), color=(255, 255, 255))
|
||||||
|
assert res["ok"] is True and res["error"] == ""
|
||||||
|
with Image.open(res["out_path"]) as im:
|
||||||
|
assert im.mode == "RGB" # sin alpha
|
||||||
|
# sobre alpha 0 queda el fondo solido: blanco.
|
||||||
|
assert im.getpixel((0, 0)) == (255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_color_de_fondo_personalizado(tmp_path):
|
||||||
|
src = _rgba(tmp_path / "s.png", color=(0, 0, 0, 0))
|
||||||
|
res = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "o.png"), color=(10, 20, 30))
|
||||||
|
with Image.open(res["out_path"]) as im:
|
||||||
|
assert im.getpixel((0, 0)) == (10, 20, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_redimensiona_cuadrado(tmp_path):
|
||||||
|
src = _rgba(tmp_path / "s.png", size=(32, 16))
|
||||||
|
res = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "o.png"), size=64)
|
||||||
|
assert res["size"] == [64, 64]
|
||||||
|
with Image.open(res["out_path"]) as im:
|
||||||
|
assert im.size == (64, 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_path_default_sufijo_flat(tmp_path):
|
||||||
|
src = _rgba(tmp_path / "sprite.png")
|
||||||
|
res = comfyui_flatten_alpha_on_color(src) # out_path None -> <base>_flat.png
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["out_path"].endswith("sprite_flat.png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_imagen_inexistente(tmp_path):
|
||||||
|
res = comfyui_flatten_alpha_on_color(str(tmp_path / "no.png"))
|
||||||
|
assert res["ok"] is False and "no existe" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista(tmp_path):
|
||||||
|
src = _rgba(tmp_path / "s.png", color=(5, 5, 5, 128))
|
||||||
|
a = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "a.png"), color=(200, 0, 0))
|
||||||
|
b = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "b.png"), color=(200, 0, 0))
|
||||||
|
with Image.open(a["out_path"]) as ia, Image.open(b["out_path"]) as ib:
|
||||||
|
assert ia.tobytes() == ib.tobytes()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests offline para comfyui_import_workflow_json (impura: lee disco/URL + normaliza a API format).
|
||||||
|
|
||||||
|
Sin red, sin servidor: lee workflows desde archivos locales. Para el caso UI graph monkeypatchea
|
||||||
|
comfyui_object_info (devuelve None) para no consultar el servidor; se valida la resolucion de
|
||||||
|
conexiones y el descarte de nodos virtuales (Note).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
import ml.comfyui_import_workflow_json as mod
|
||||||
|
from ml.comfyui_import_workflow_json import comfyui_import_workflow_json
|
||||||
|
from _comfyui_wf_assert import assert_api_format, class_types
|
||||||
|
|
||||||
|
|
||||||
|
_API = {
|
||||||
|
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m.safetensors"}},
|
||||||
|
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0], "vae": ["1", 2]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_UI_GRAPH = {
|
||||||
|
"nodes": [
|
||||||
|
{"id": 1, "type": "CheckpointLoaderSimple", "inputs": [], "widgets_values": ["m.safetensors"]},
|
||||||
|
{"id": 2, "type": "Note", "inputs": []},
|
||||||
|
{"id": 3, "type": "VAEDecode",
|
||||||
|
"inputs": [{"name": "samples", "link": 10}, {"name": "vae", "link": 11}]},
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[10, 1, 0, 3, 0, "LATENT"],
|
||||||
|
[11, 1, 2, 3, 1, "VAE"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path, name, obj):
|
||||||
|
p = tmp_path / name
|
||||||
|
p.write_text(json.dumps(obj))
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_format_se_devuelve_tal_cual(tmp_path):
|
||||||
|
res = comfyui_import_workflow_json(_write(tmp_path, "api.json", _API))
|
||||||
|
assert res["ok"] is True and res["format_detected"] == "api"
|
||||||
|
assert res["workflow"] == _API
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_graph_se_normaliza(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "comfyui_object_info", lambda server="", timeout=5.0: None)
|
||||||
|
res = comfyui_import_workflow_json(_write(tmp_path, "ui.json", _UI_GRAPH))
|
||||||
|
assert res["ok"] is True and res["format_detected"] == "ui_graph"
|
||||||
|
api = res["workflow"]
|
||||||
|
assert_api_format(api)
|
||||||
|
# el nodo virtual Note se descarta; las conexiones del VAEDecode se resuelven al CheckpointLoader.
|
||||||
|
assert "Note" not in class_types(api)
|
||||||
|
assert "2" not in api
|
||||||
|
assert api["3"]["inputs"]["samples"] == ["1", 0]
|
||||||
|
assert api["3"]["inputs"]["vae"] == ["1", 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_invalido_error(tmp_path):
|
||||||
|
p = tmp_path / "bad.json"
|
||||||
|
p.write_text("no soy json {")
|
||||||
|
res = comfyui_import_workflow_json(str(p))
|
||||||
|
assert res["ok"] is False and "JSON invalido" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_formato_no_reconocido(tmp_path):
|
||||||
|
res = comfyui_import_workflow_json(_write(tmp_path, "x.json", {"foo": "bar"}))
|
||||||
|
assert res["ok"] is False and "no reconocido" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_no_es_objeto(tmp_path):
|
||||||
|
res = comfyui_import_workflow_json(_write(tmp_path, "lst.json", [1, 2, 3]))
|
||||||
|
assert res["ok"] is False and "no es un objeto de workflow" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_archivo_inexistente(tmp_path):
|
||||||
|
res = comfyui_import_workflow_json(str(tmp_path / "no.json"))
|
||||||
|
assert res["ok"] is False and "no se pudo leer" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista(tmp_path):
|
||||||
|
path = _write(tmp_path, "api.json", _API)
|
||||||
|
assert comfyui_import_workflow_json(path) == comfyui_import_workflow_json(path)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests offline para comfyui_judge_image (impura: panel multi-juez por mayoria).
|
||||||
|
|
||||||
|
Sin GPU, sin red, sin servidor: monkeypatchea los tres jueces (estetico, fidelidad CLIP,
|
||||||
|
critico LLM) con stubs para probar la LOGICA de voto, agregacion y exclusion de jueces caidos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
import ml.comfyui_judge_image as mod
|
||||||
|
from ml.comfyui_judge_image import comfyui_judge_image
|
||||||
|
|
||||||
|
|
||||||
|
def _aes(score, ok=True):
|
||||||
|
return lambda image_path, **kw: {"ok": ok, "score_0_10": score, "error": "" if ok else "boom"}
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(score, ok=True):
|
||||||
|
return lambda image_path, prompt, **kw: {"ok": ok, "score_0_1": score, "error": "" if ok else "boom"}
|
||||||
|
|
||||||
|
|
||||||
|
def _llm(verdict, score=7.0, ok=True):
|
||||||
|
return lambda image_path, prompt, **kw: {
|
||||||
|
"ok": ok, "verdict": verdict, "score_0_10": score,
|
||||||
|
"reasons": ["motivo"], "error": "" if ok else "boom"}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(monkeypatch, aes, clip, llm):
|
||||||
|
monkeypatch.setattr(mod, "comfyui_score_aesthetic", aes)
|
||||||
|
monkeypatch.setattr(mod, "comfyui_score_clip_alignment", clip)
|
||||||
|
monkeypatch.setattr(mod, "comfyui_critique_image_llm", llm)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tres_good_verdict_good(monkeypatch):
|
||||||
|
_patch(monkeypatch, _aes(8.0), _clip(0.30), _llm("good"))
|
||||||
|
res = comfyui_judge_image("i.png", "a cat")
|
||||||
|
assert res["ok"] is True and res["verdict"] == "good"
|
||||||
|
assert res["votes"] == {"aesthetic": "good", "clip": "good", "llm": "good"}
|
||||||
|
# score = media de 8, 3.0(=0.30*10), 7 = 6.0
|
||||||
|
assert abs(res["score"] - 6.0) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_mayoria_bad(monkeypatch):
|
||||||
|
# estetico bajo (bad) + clip bajo (bad) + llm good -> 2 bad, 1 good -> bad.
|
||||||
|
_patch(monkeypatch, _aes(2.0), _clip(0.05), _llm("good"))
|
||||||
|
res = comfyui_judge_image("i.png", "p")
|
||||||
|
assert res["verdict"] == "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empate_es_bad_conservador(monkeypatch):
|
||||||
|
# 1 good (estetico) + 1 bad (clip) + 1 failed (llm) -> empate -> bad.
|
||||||
|
_patch(monkeypatch, _aes(8.0), _clip(0.05), _llm("good", ok=False))
|
||||||
|
res = comfyui_judge_image("i.png", "p")
|
||||||
|
assert res["votes"]["llm"] == "failed"
|
||||||
|
assert res["verdict"] == "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def test_juez_caido_se_excluye_no_crashea(monkeypatch):
|
||||||
|
# estetico falla pero el panel sigue votando con los otros dos.
|
||||||
|
_patch(monkeypatch, _aes(0.0, ok=False), _clip(0.30), _llm("good"))
|
||||||
|
res = comfyui_judge_image("i.png", "p")
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["votes"]["aesthetic"] == "failed"
|
||||||
|
assert res["verdict"] == "good"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tres_fallan_ok_false(monkeypatch):
|
||||||
|
_patch(monkeypatch, _aes(0.0, ok=False), _clip(0.0, ok=False), _llm("", ok=False))
|
||||||
|
res = comfyui_judge_image("i.png", "p")
|
||||||
|
assert res["ok"] is False and "los tres jueces fallaron" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_weights_afectan_score_no_voto(monkeypatch):
|
||||||
|
_patch(monkeypatch, _aes(10.0), _clip(0.30), _llm("good", score=0.0))
|
||||||
|
base = comfyui_judge_image("i.png", "p")
|
||||||
|
# subir el peso del estetico (10) y anular el del llm (0) sube el score agregado.
|
||||||
|
weighted = comfyui_judge_image("i.png", "p", weights={"aesthetic": 5.0, "llm": 0.0})
|
||||||
|
assert weighted["score"] > base["score"]
|
||||||
|
assert weighted["verdict"] == base["verdict"] == "good"
|
||||||
+66
@@ -79,3 +79,69 @@ def test_error_bad_palette(tmp_path):
|
|||||||
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette")
|
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette")
|
||||||
assert res["ok"] is False
|
assert res["ok"] is False
|
||||||
assert "paleta" in res["error"].lower()
|
assert "paleta" in res["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- alpha-aware (sprites con fondo transparente) ---
|
||||||
|
|
||||||
|
def _rgba_subject_png(path, canvas=256, box=120):
|
||||||
|
"""RGBA: sujeto opaco de colores variados centrado, fondo transparente."""
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
|
||||||
|
o = (canvas - box) // 2
|
||||||
|
arr[o:o + box, o:o + box, :3] = rng.integers(0, 256, size=(box, box, 3), dtype=np.uint8)
|
||||||
|
arr[o:o + box, o:o + box, 3] = 255 # sujeto opaco
|
||||||
|
Image.fromarray(arr, "RGBA").save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_alpha_preserved_transparent_corners(tmp_path):
|
||||||
|
"""RGBA in -> RGBA out con esquinas transparentes y paleta limitada en lo opaco."""
|
||||||
|
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
|
||||||
|
dst = str(tmp_path / "px.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, upscale_back=False)
|
||||||
|
assert res["ok"] is True, res["error"]
|
||||||
|
assert res["has_alpha"] is True
|
||||||
|
out = Image.open(dst).convert("RGBA")
|
||||||
|
a = np.asarray(out)[..., 3]
|
||||||
|
w, h = out.size
|
||||||
|
# Las 4 esquinas deben ser transparentes (alpha == 0).
|
||||||
|
assert a[0, 0] == 0 and a[0, w - 1] == 0
|
||||||
|
assert a[h - 1, 0] == 0 and a[h - 1, w - 1] == 0
|
||||||
|
# Centro opaco.
|
||||||
|
assert a[h // 2, w // 2] == 255
|
||||||
|
# Colores limitados en la zona opaca.
|
||||||
|
assert res["n_colors_final"] <= 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_alpha_off_flattens_to_rgb(tmp_path):
|
||||||
|
"""keep_alpha=False sobre RGBA -> sale RGB (sin canal alpha)."""
|
||||||
|
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
|
||||||
|
dst = str(tmp_path / "flat.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, keep_alpha=False)
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["has_alpha"] is False
|
||||||
|
assert Image.open(dst).mode != "RGBA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rgb_input_unaffected_by_keep_alpha(tmp_path):
|
||||||
|
"""Imagen RGB (sin alpha) con keep_alpha=True sigue saliendo RGB, sin romper."""
|
||||||
|
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||||
|
dst = str(tmp_path / "rgb.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16) # keep_alpha default True
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["has_alpha"] is False
|
||||||
|
assert res["n_colors_final"] <= 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_all_transparent_no_crash(tmp_path):
|
||||||
|
"""RGBA toda transparente (rembg sin sujeto): no crashea, 0 colores opacos."""
|
||||||
|
arr = np.zeros((64, 64, 4), dtype=np.uint8) # alpha 0 en todo
|
||||||
|
src = str(tmp_path / "empty.png")
|
||||||
|
Image.fromarray(arr, "RGBA").save(src)
|
||||||
|
dst = str(tmp_path / "out.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=1, colors=16)
|
||||||
|
assert res["ok"] is True, res["error"]
|
||||||
|
assert res["has_alpha"] is True
|
||||||
|
assert res["n_colors_final"] == 0
|
||||||
|
out = np.asarray(Image.open(dst).convert("RGBA"))
|
||||||
|
assert out[..., 3].max() == 0 # sigue toda transparente
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Tests offline para comfyui_read_png_metadata (impura stdlib: parsea metadata de un PNG ComfyUI).
|
||||||
|
|
||||||
|
Sin red, sin GPU, sin servidor: fabrica PNGs con chunk de texto 'prompt' y verifica el parsing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_read_png_metadata import comfyui_read_png_metadata
|
||||||
|
|
||||||
|
PIL = pytest.importorskip("PIL")
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
from PIL.PngImagePlugin import PngInfo # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
_PROMPT = {
|
||||||
|
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "model.safetensors"}},
|
||||||
|
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat on a table"}},
|
||||||
|
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "blurry, lowres"}},
|
||||||
|
"4": {"class_type": "KSampler", "inputs": {
|
||||||
|
"seed": 42, "steps": 20, "cfg": 7.0, "sampler_name": "euler",
|
||||||
|
"scheduler": "normal", "denoise": 1.0,
|
||||||
|
"positive": ["2", 0], "negative": ["3", 0], "model": ["1", 0], "latent_image": ["5", 0]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _png_with_prompt(path, prompt_obj=_PROMPT, text=None):
|
||||||
|
info = PngInfo()
|
||||||
|
info.add_text("prompt", text if text is not None else json.dumps(prompt_obj))
|
||||||
|
Image.new("RGB", (8, 8), (0, 0, 0)).save(path, pnginfo=info)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _png_plain(path):
|
||||||
|
Image.new("RGB", (8, 8), (0, 0, 0)).save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extrae_prompt_y_parametros(tmp_path):
|
||||||
|
res = comfyui_read_png_metadata(_png_with_prompt(tmp_path / "g.png"))
|
||||||
|
assert res["ok"] is True and res["error"] == ""
|
||||||
|
assert res["prompt"] == _PROMPT
|
||||||
|
p = res["parameters"]
|
||||||
|
assert p["seed"] == 42 and p["steps"] == 20 and p["cfg"] == 7.0
|
||||||
|
assert p["sampler_name"] == "euler" and p["scheduler"] == "normal" and p["denoise"] == 1.0
|
||||||
|
assert p["positive"] == "a cat on a table" and p["negative"] == "blurry, lowres"
|
||||||
|
assert p["model"] == "model.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_archivo_inexistente(tmp_path):
|
||||||
|
res = comfyui_read_png_metadata(str(tmp_path / "no.png"))
|
||||||
|
assert res["ok"] is False and "no se pudo leer" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_png_sin_chunk_prompt(tmp_path):
|
||||||
|
res = comfyui_read_png_metadata(_png_plain(tmp_path / "plain.png"))
|
||||||
|
assert res["ok"] is False and "no contiene chunk 'prompt'" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_prompt_no_json(tmp_path):
|
||||||
|
res = comfyui_read_png_metadata(_png_with_prompt(tmp_path / "bad.png", text="no soy json {"))
|
||||||
|
assert res["ok"] is False and "no es JSON valido" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_no_es_png(tmp_path):
|
||||||
|
bad = tmp_path / "fake.png"
|
||||||
|
bad.write_bytes(b"esto no es un PNG")
|
||||||
|
res = comfyui_read_png_metadata(str(bad))
|
||||||
|
assert res["ok"] is False and res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinista(tmp_path):
|
||||||
|
path = _png_with_prompt(tmp_path / "g.png")
|
||||||
|
assert comfyui_read_png_metadata(path) == comfyui_read_png_metadata(path)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Tests offline para comfyui_resolve_workflow_deps (impura: compone comfyui_validate_workflow).
|
||||||
|
|
||||||
|
Sin red, sin servidor: monkeypatchea comfyui_validate_workflow para probar la traduccion de
|
||||||
|
nodos/modelos faltantes en sugerencias accionables y el error path cuando el servidor no responde.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
import ml.comfyui_resolve_workflow_deps as mod
|
||||||
|
from ml.comfyui_resolve_workflow_deps import comfyui_resolve_workflow_deps
|
||||||
|
|
||||||
|
_WF = {"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x.safetensors"}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_traduce_nodos_y_modelos_faltantes(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": {
|
||||||
|
"ok": True,
|
||||||
|
"missing_nodes": ["FooNode"],
|
||||||
|
"missing_models": [{"node": "1", "input": "ckpt_name", "value": "x.safetensors"}],
|
||||||
|
})
|
||||||
|
res = comfyui_resolve_workflow_deps(_WF)
|
||||||
|
assert res["ok"] is True and res["error"] == ""
|
||||||
|
assert res["missing_nodes"] == ["FooNode"]
|
||||||
|
kinds = {s["kind"] for s in res["suggestions"]}
|
||||||
|
assert kinds == {"node", "model"}
|
||||||
|
node_sug = next(s for s in res["suggestions"] if s["kind"] == "node")
|
||||||
|
assert node_sug["action"] == "install_custom_node" and node_sug["name"] == "FooNode"
|
||||||
|
model_sug = next(s for s in res["suggestions"] if s["kind"] == "model")
|
||||||
|
assert model_sug["action"] == "search_and_download" and model_sug["name"] == "x.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_faltantes_suggestions_vacio(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": {
|
||||||
|
"ok": True, "missing_nodes": [], "missing_models": []})
|
||||||
|
res = comfyui_resolve_workflow_deps(_WF)
|
||||||
|
assert res["ok"] is True and res["suggestions"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_servidor_caido_propaga_error(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": {
|
||||||
|
"ok": False, "error": "no se pudo conectar al servidor"})
|
||||||
|
res = comfyui_resolve_workflow_deps(_WF)
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert "no se pudo conectar" in res["error"]
|
||||||
|
assert res["suggestions"] == []
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests offline para comfyui_score_aesthetic (impura: scoring LAION-V2 via subproceso torch).
|
||||||
|
|
||||||
|
Sin GPU, sin torch, sin servidor: ejercita SOLO los guards previos al subproceso (imagen,
|
||||||
|
python del venv ComfyUI y .pth del modelo ausentes), que cortan antes de tocar la GPU.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_score_aesthetic import comfyui_score_aesthetic
|
||||||
|
|
||||||
|
PIL = pytest.importorskip("PIL")
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _png(path):
|
||||||
|
Image.new("RGB", (8, 8), (0, 0, 0)).save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_imagen_inexistente(tmp_path):
|
||||||
|
res = comfyui_score_aesthetic(str(tmp_path / "no.png"))
|
||||||
|
assert res["ok"] is False and res["score_0_10"] == 0.0
|
||||||
|
assert "imagen no encontrada" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_venv_python_inexistente(tmp_path):
|
||||||
|
# imagen valida pero venv_python ausente -> corta antes del subproceso.
|
||||||
|
res = comfyui_score_aesthetic(_png(tmp_path / "i.png"),
|
||||||
|
venv_python=str(tmp_path / "no_python"))
|
||||||
|
assert res["ok"] is False and "python del venv ComfyUI no encontrado" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_modelo_inexistente(tmp_path):
|
||||||
|
# imagen + python validos, .pth ausente -> error de modelo, sin lanzar el subproceso.
|
||||||
|
res = comfyui_score_aesthetic(_png(tmp_path / "i.png"),
|
||||||
|
venv_python=sys.executable,
|
||||||
|
model_path=str(tmp_path / "no.pth"))
|
||||||
|
assert res["ok"] is False and "modelo estetico no encontrado" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nunca_lanza_y_es_determinista(tmp_path):
|
||||||
|
img = _png(tmp_path / "i.png")
|
||||||
|
a = comfyui_score_aesthetic(img, venv_python=str(tmp_path / "x"))
|
||||||
|
b = comfyui_score_aesthetic(img, venv_python=str(tmp_path / "x"))
|
||||||
|
assert a == b and a["ok"] is False
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests offline para comfyui_score_clip_alignment (impura: similitud CLIP via subproceso torch).
|
||||||
|
|
||||||
|
Sin GPU, sin torch, sin servidor: ejercita SOLO los guards previos al subproceso (imagen
|
||||||
|
ausente, prompt vacio, python del venv ComfyUI ausente).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_score_clip_alignment import comfyui_score_clip_alignment
|
||||||
|
|
||||||
|
PIL = pytest.importorskip("PIL")
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _png(path):
|
||||||
|
Image.new("RGB", (8, 8), (0, 0, 0)).save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_imagen_inexistente(tmp_path):
|
||||||
|
res = comfyui_score_clip_alignment(str(tmp_path / "no.png"), "a cat")
|
||||||
|
assert res["ok"] is False and res["score_0_1"] == 0.0
|
||||||
|
assert "imagen no encontrada" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_prompt_vacio(tmp_path):
|
||||||
|
res = comfyui_score_clip_alignment(_png(tmp_path / "i.png"), " ")
|
||||||
|
assert res["ok"] is False and "prompt vacio" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_venv_python_inexistente(tmp_path):
|
||||||
|
res = comfyui_score_clip_alignment(_png(tmp_path / "i.png"), "a cat",
|
||||||
|
venv_python=str(tmp_path / "no_python"))
|
||||||
|
assert res["ok"] is False and "python del venv ComfyUI no encontrado" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nunca_lanza_y_es_determinista(tmp_path):
|
||||||
|
img = _png(tmp_path / "i.png")
|
||||||
|
a = comfyui_score_clip_alignment(img, "a cat", venv_python=str(tmp_path / "x"))
|
||||||
|
b = comfyui_score_clip_alignment(img, "a cat", venv_python=str(tmp_path / "x"))
|
||||||
|
assert a == b and a["ok"] is False
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests de crop_to_content (offline, sin red ni GPU; PIL/numpy)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from ml.crop_to_content import crop_to_content # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba_subject_in_corner(canvas=256, box=40, ox=8, oy=8):
|
||||||
|
"""RGBA con un rectangulo opaco rojo en una esquina, resto transparente."""
|
||||||
|
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
|
||||||
|
arr[oy:oy + box, ox:ox + box, 0] = 220 # R
|
||||||
|
arr[oy:oy + box, ox:ox + box, 3] = 255 # alpha opaco
|
||||||
|
return Image.fromarray(arr, "RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba_subject_centered(canvas=256, fill_ratio=0.9):
|
||||||
|
"""RGBA con un rectangulo opaco que llena ~fill_ratio del lienzo, centrado."""
|
||||||
|
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
|
||||||
|
side = int(canvas * fill_ratio)
|
||||||
|
o = (canvas - side) // 2
|
||||||
|
arr[o:o + side, o:o + side, 1] = 200 # G
|
||||||
|
arr[o:o + side, o:o + side, 3] = 255
|
||||||
|
return Image.fromarray(arr, "RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_subject_on_bg(canvas=200, box=50, ox=10, oy=10, bg=(255, 255, 255)):
|
||||||
|
"""RGB con un cuadrado de color sobre fondo plano (sin alpha)."""
|
||||||
|
arr = np.zeros((canvas, canvas, 3), dtype=np.uint8)
|
||||||
|
arr[:, :] = bg
|
||||||
|
arr[oy:oy + box, ox:ox + box] = (0, 0, 200) # sujeto azul
|
||||||
|
return Image.fromarray(arr, "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _alpha_bbox_coverage(img, threshold=10):
|
||||||
|
"""Fraccion del lado que ocupa el bbox del contenido (alpha>threshold)."""
|
||||||
|
a = np.asarray(img.convert("RGBA"))[..., 3]
|
||||||
|
ys, xs = np.where(a > threshold)
|
||||||
|
if xs.size == 0:
|
||||||
|
return 0.0
|
||||||
|
bw = xs.max() - xs.min() + 1
|
||||||
|
bh = ys.max() - ys.min() + 1
|
||||||
|
return max(bw, bh) / max(img.size)
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_corner_subject_fills_frame():
|
||||||
|
"""Sujeto en la esquina -> tras crop ocupa casi todo el frame (square)."""
|
||||||
|
img = _rgba_subject_in_corner()
|
||||||
|
before = _alpha_bbox_coverage(img)
|
||||||
|
out = crop_to_content(img, pad_ratio=0.06, square=True)
|
||||||
|
after = _alpha_bbox_coverage(out)
|
||||||
|
assert out.mode == "RGBA"
|
||||||
|
assert out.size[0] == out.size[1] # cuadrado
|
||||||
|
assert before < 0.25 # antes diminuto
|
||||||
|
assert after >= 0.80 # despues llena el frame
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_centered_subject_not_overcropped():
|
||||||
|
"""Sujeto ya centrado que llena ~90%: la cobertura se mantiene alta, no se rompe."""
|
||||||
|
img = _rgba_subject_centered(fill_ratio=0.9)
|
||||||
|
out = crop_to_content(img, pad_ratio=0.06, square=True)
|
||||||
|
assert out.size[0] == out.size[1]
|
||||||
|
assert _alpha_bbox_coverage(out) >= 0.80
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_rgb_background_bbox():
|
||||||
|
"""RGB con fondo plano: detecta el sujeto por diff-fondo y lo cuadra."""
|
||||||
|
img = _rgb_subject_on_bg()
|
||||||
|
out = crop_to_content(img, pad_ratio=0.05, square=True)
|
||||||
|
assert out.mode == "RGB"
|
||||||
|
assert out.size[0] == out.size[1]
|
||||||
|
# El sujeto azul debe ocupar buena parte del lienzo recortado.
|
||||||
|
arr = np.asarray(out)
|
||||||
|
is_subject = (arr[..., 2] > 120) & (arr[..., 0] < 80)
|
||||||
|
cov = is_subject.sum() / (out.size[0] * out.size[1])
|
||||||
|
assert cov >= 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_no_square_only_crops():
|
||||||
|
"""square=False: recorta al bbox + margen, sin forzar cuadrado."""
|
||||||
|
img = _rgba_subject_in_corner(box=40)
|
||||||
|
out = crop_to_content(img, pad_ratio=0.0, square=False)
|
||||||
|
# bbox del sujeto es 40x40 -> sin pad ni cuadrar, sale 40x40.
|
||||||
|
assert out.size == (40, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_all_transparent_returns_copy():
|
||||||
|
"""Imagen toda transparente: no crashea, devuelve copia intacta (mismo tamano)."""
|
||||||
|
arr = np.zeros((128, 128, 4), dtype=np.uint8) # alpha 0 en todo
|
||||||
|
img = Image.fromarray(arr, "RGBA")
|
||||||
|
out = crop_to_content(img)
|
||||||
|
assert out.size == (128, 128)
|
||||||
|
assert np.asarray(out)[..., 3].max() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_none_raises():
|
||||||
|
try:
|
||||||
|
crop_to_content(None)
|
||||||
|
assert False, "deberia lanzar ValueError"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "None" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_mutate_input():
|
||||||
|
img = _rgba_subject_in_corner()
|
||||||
|
snapshot = np.asarray(img).copy()
|
||||||
|
crop_to_content(img)
|
||||||
|
assert np.array_equal(np.asarray(img), snapshot)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Tests de pixeloe_downscale — tolerantes al entorno.
|
||||||
|
|
||||||
|
El venv del registry NO trae `pixeloe`, asi que estas pruebas ejercitan el
|
||||||
|
"bridge" de interprete (subprocess al python de ComfyUI, que si la tiene). Si
|
||||||
|
tampoco hay ningun interprete con pixeloe disponible, la funcion debe degradar
|
||||||
|
limpiamente: ok=False con error no vacio y SIN lanzar excepcion.
|
||||||
|
|
||||||
|
Por eso cada test PASA en los dos escenarios:
|
||||||
|
- pixeloe disponible (inproc o via bridge): assert sobre el resultado real.
|
||||||
|
- pixeloe ausente en todos lados: assert sobre la degradacion no-throw.
|
||||||
|
Asi la suite es verde tanto en este PC (ComfyUI presente) como en uno sin ComfyUI,
|
||||||
|
y el contrato "no-throw" queda cubierto en ambos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from ml.pixeloe_downscale import pixeloe_downscale # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _shapes_png(path, w=256, h=256):
|
||||||
|
"""PNG 256x256 RGB con un gradiente + formas (contraste con silueta clara)."""
|
||||||
|
yy, xx = np.mgrid[0:h, 0:w]
|
||||||
|
arr = np.zeros((h, w, 3), dtype=np.uint8)
|
||||||
|
arr[..., 0] = (xx * 255 // max(1, w - 1)).astype(np.uint8) # gradiente rojo
|
||||||
|
arr[..., 1] = (yy * 255 // max(1, h - 1)).astype(np.uint8) # gradiente verde
|
||||||
|
# Bloque azul central: borde duro para que el modo "contrast" tenga silueta.
|
||||||
|
arr[h // 4:3 * h // 4, w // 4:3 * w // 4, 2] = 255
|
||||||
|
Image.fromarray(arr, "RGB").save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_downscale_64_or_clean_degrade(tmp_path):
|
||||||
|
"""Golden: 256x256 -> grid 64x64 (no_upscale). Si pixeloe no esta -> ok=False limpio."""
|
||||||
|
src = _shapes_png(str(tmp_path / "raw.png"))
|
||||||
|
dst = str(tmp_path / "grid64.png")
|
||||||
|
res = pixeloe_downscale(src, dst, target_size=64, no_upscale=True)
|
||||||
|
assert isinstance(res, dict)
|
||||||
|
if res["ok"]:
|
||||||
|
assert os.path.isfile(dst)
|
||||||
|
assert res["size"] == [64, 64] # no_upscale=True -> grid real
|
||||||
|
assert res["error"] == ""
|
||||||
|
assert res["via"] in ("inproc", "bridge")
|
||||||
|
assert res["mode"] == "contrast"
|
||||||
|
assert res["target_size"] == 64
|
||||||
|
else:
|
||||||
|
# Degradacion limpia: sin pixeloe en ningun interprete.
|
||||||
|
assert res["error"] != ""
|
||||||
|
assert res["via"] in ("", "bridge", "inproc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_target_size_32(tmp_path):
|
||||||
|
"""Edge: grid de 32 (iconos). size==[32,32] cuando pixeloe esta presente."""
|
||||||
|
src = _shapes_png(str(tmp_path / "raw.png"))
|
||||||
|
dst = str(tmp_path / "grid32.png")
|
||||||
|
res = pixeloe_downscale(src, dst, target_size=32, no_upscale=True)
|
||||||
|
if res["ok"]:
|
||||||
|
assert res["size"] == [32, 32]
|
||||||
|
assert res["target_size"] == 32
|
||||||
|
assert os.path.isfile(dst)
|
||||||
|
else:
|
||||||
|
assert res["error"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_mode_nearest_no_color_matching(tmp_path):
|
||||||
|
"""Edge: otro modo + color_matching off; debe seguir produciendo el grid o degradar."""
|
||||||
|
src = _shapes_png(str(tmp_path / "raw.png"))
|
||||||
|
dst = str(tmp_path / "near.png")
|
||||||
|
res = pixeloe_downscale(
|
||||||
|
src, dst, mode="nearest", target_size=64,
|
||||||
|
color_matching=False, no_upscale=True,
|
||||||
|
)
|
||||||
|
assert isinstance(res, dict)
|
||||||
|
if res["ok"]:
|
||||||
|
assert res["mode"] == "nearest"
|
||||||
|
assert res["size"] == [64, 64]
|
||||||
|
else:
|
||||||
|
assert res["error"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_missing_src_no_throw(tmp_path):
|
||||||
|
"""Error path: src inexistente -> ok=False, error explica, sin excepcion."""
|
||||||
|
res = pixeloe_downscale(
|
||||||
|
str(tmp_path / "nope.png"), str(tmp_path / "o.png"), target_size=64,
|
||||||
|
)
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert "no existe" in res["error"]
|
||||||
|
assert res["size"] == [0, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_no_interpreter_with_pixeloe(tmp_path):
|
||||||
|
"""Error path: forzar comfy_python invalido cuando el actual no tiene pixeloe.
|
||||||
|
|
||||||
|
Si el interprete que corre el test YA tiene pixeloe (inproc), el comfy_python
|
||||||
|
invalido se ignora y la llamada puede salir ok=True; el test sigue siendo
|
||||||
|
valido (no-throw). Si NO lo tiene, no hay ningun interprete con pixeloe y debe
|
||||||
|
devolver ok=False con error, nunca lanzar.
|
||||||
|
"""
|
||||||
|
src = _shapes_png(str(tmp_path / "raw.png"))
|
||||||
|
dst = str(tmp_path / "o.png")
|
||||||
|
res = pixeloe_downscale(
|
||||||
|
src, dst, target_size=64, comfy_python="/no/such/python-interpreter",
|
||||||
|
)
|
||||||
|
assert isinstance(res, dict)
|
||||||
|
try:
|
||||||
|
import pixeloe # noqa: F401
|
||||||
|
has_local = True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
has_local = False
|
||||||
|
if has_local:
|
||||||
|
# pixeloe en el interprete del test -> ruta inproc, comfy_python ignorado.
|
||||||
|
assert res["ok"] is True
|
||||||
|
else:
|
||||||
|
# comfy_python invalido + env vacio: si ~/ComfyUI/.venv existe, puede
|
||||||
|
# bridgear y salir ok; si no, ok=False con error. Ambos no-throw.
|
||||||
|
assert res["ok"] in (True, False)
|
||||||
|
if not res["ok"]:
|
||||||
|
assert res["error"] != ""
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_generate_until_quality
|
||||||
|
kind: pipeline
|
||||||
|
lang: py
|
||||||
|
domain: pipelines
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "comfyui_generate_until_quality(builder, subject, *, threshold=6.0, clip_threshold=0.24, max_iters=4, strategy='reroll+escalate+refine_prompt', server='127.0.0.1:8188', dest_dir='~/ComfyUI/output', judge_prompt=None, seed=0, refine_model='claude-haiku-4-5-20251001', judge_model='claude-opus-4-8', wait_timeout=300.0, **builder_kwargs) -> dict"
|
||||||
|
description: "Loop evaluator-optimizer (GAN sin entrenar): genera una imagen con un builder del registry, la juzga con el panel multi-juez, y si no alcanza la calidad pedida refina (nueva seed, mas calidad, prompt corregido con el feedback del juez) y regenera hasta pasar el umbral o agotar intentos. Siempre devuelve la mejor candidata por score (best-of-N)."
|
||||||
|
tags: [comfyui, comfyui-skill, pipeline, launcher, generate, judge, quality-loop, evaluator-optimizer]
|
||||||
|
uses_functions:
|
||||||
|
- comfyui_submit_workflow_py_ml
|
||||||
|
- comfyui_wait_result_py_ml
|
||||||
|
- comfyui_fetch_output_image_py_ml
|
||||||
|
- comfyui_judge_image_py_ml
|
||||||
|
- ask_llm_py_core
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: error_py_core
|
||||||
|
imports: [comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_judge_image_py_ml, ask_llm_py_core]
|
||||||
|
params:
|
||||||
|
- name: builder
|
||||||
|
desc: "Callable o nombre (str) de un builder comfyui_build_*_workflow del registry. El subject se pasa como primer positional (builders de asset: ui_hud, item_icon, enemy_creature...)."
|
||||||
|
- name: subject
|
||||||
|
desc: "Descripcion del elemento a generar (p.ej. 'RPG health and mana bars'). Se inyecta en el builder y, si se refina, se reescribe con el feedback del juez."
|
||||||
|
- name: threshold
|
||||||
|
desc: "Umbral estetico 0-10 que el juez usa para votar good/bad."
|
||||||
|
- name: clip_threshold
|
||||||
|
desc: "Umbral de fidelidad CLIP 0-1 del juez (prompt<->imagen)."
|
||||||
|
- name: max_iters
|
||||||
|
desc: "Numero maximo de iteraciones de generacion."
|
||||||
|
- name: strategy
|
||||||
|
desc: "Tacticas de mejora separadas por '+': reroll (seed nueva), escalate (mas steps/cfg en iters tardias), refine_prompt (reescribe el subject con ask_llm usando las razones del juez)."
|
||||||
|
- name: server
|
||||||
|
desc: "host:port del servidor ComfyUI sin esquema."
|
||||||
|
- name: dest_dir
|
||||||
|
desc: "Directorio local donde guardar los PNG."
|
||||||
|
- name: judge_prompt
|
||||||
|
desc: "Texto que se pasa al juez para medir fidelidad. None = se extrae el positive del workflow construido."
|
||||||
|
- name: seed
|
||||||
|
desc: "Semilla base; los rerolls derivan de ella de forma determinista."
|
||||||
|
- name: refine_model
|
||||||
|
desc: "Modelo de ask_llm para el refine del prompt (barato, haiku por defecto)."
|
||||||
|
- name: judge_model
|
||||||
|
desc: "Modelo del juez critico LLM-vision."
|
||||||
|
- name: wait_timeout
|
||||||
|
desc: "Segundos maximos esperando cada generacion."
|
||||||
|
- name: builder_kwargs
|
||||||
|
desc: "Parametros extra del builder (ui_style, checkpoint, size, transparent...). Solo se pasan los que el builder acepta (filtrados por inspect.signature)."
|
||||||
|
output: "dict {ok, converged, best_image_path, best_score, best_verdict, iterations, error}. iterations = lista de {iter, seed, params, score, verdict, reasons, image, error}. converged=True si alguna iteracion logro verdict 'good'. best_* apuntan a la mejor candidata por score aunque ninguna convergiera."
|
||||||
|
file_path: "python/functions/pipelines/comfyui_generate_until_quality.py"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# comfyui_generate_until_quality
|
||||||
|
|
||||||
|
Loop **evaluator-optimizer** sobre ComfyUI: el patrón de una GAN (generador vs.
|
||||||
|
discriminador) pero **sin entrenar nada**. Un builder genera una imagen, el panel
|
||||||
|
multi-juez (`comfyui_judge_image`) la puntúa, y si no llega al umbral el pipeline
|
||||||
|
**refina** (nueva seed, más calidad, prompt corregido con las quejas del juez) y
|
||||||
|
regenera, hasta converger (`verdict == 'good'`) o agotar `max_iters`. Devuelve
|
||||||
|
**siempre la mejor candidata por score** (best-of-N): nunca basura por agotar
|
||||||
|
intentos.
|
||||||
|
|
||||||
|
Es la promoción a pipeline one-shot (issue 0087) del bucle de mejora del grupo
|
||||||
|
`comfyui-skill`: build → submit → wait → fetch → judge → (refine) → repeat.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, json
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from pipelines.comfyui_generate_until_quality import comfyui_generate_until_quality
|
||||||
|
|
||||||
|
res = comfyui_generate_until_quality(
|
||||||
|
"comfyui_build_ui_hud_workflow", # builder por nombre
|
||||||
|
"RPG health and mana bars, clean game UI", # subject
|
||||||
|
ui_style="fantasy game UI, clean vector, high contrast, sharp edges",
|
||||||
|
threshold=6.5, max_iters=3,
|
||||||
|
dest_dir="/tmp/comfy_until_quality", transparent=False, seed=1000,
|
||||||
|
)
|
||||||
|
print(res["converged"], round(res["best_score"], 2), res["best_verdict"])
|
||||||
|
print("scores:", [it["score"] for it in res["iterations"]]) # historial subiendo
|
||||||
|
print("mejor imagen:", res["best_image_path"])
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lanzar directo (caso HUD del ejemplo __main__)
|
||||||
|
~/fn_registry/python/.venv/bin/python3 \
|
||||||
|
python/functions/pipelines/comfyui_generate_until_quality.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando pides un asset (HUD, icono, sprite) y la primera generación sale
|
||||||
|
borrosa/floja y quieres que el sistema **itere solo** hasta una versión usable,
|
||||||
|
en vez de re-tirar seeds a mano.
|
||||||
|
- Cuando quieres un **gate de calidad objetivo** que devuelva lo mejor de N
|
||||||
|
intentos rankeado por el panel multi-juez, no la primera que salga.
|
||||||
|
- Como bloque del bucle reactivo del grupo `comfyui-skill`: un skill no está
|
||||||
|
"hecho" hasta que su imagen pasa el panel; este pipeline es ese bucle.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impuro**: red (HTTP a ComfyUI), GPU (generación), disco (PNG), API
|
||||||
|
(juez crítico LLM + refine de prompt). Necesita ComfyUI vivo en `server` y el
|
||||||
|
venv de jueces (`~/ComfyUI/.venv`, ver `comfyui-judge`).
|
||||||
|
- **El `subject` se pasa como PRIMER positional del builder**. Vale para los
|
||||||
|
builders de asset (`comfyui_build_ui_hud_workflow`, `_item_icon_`,
|
||||||
|
`_enemy_creature_`...), cuyo primer arg es el elemento. NO para
|
||||||
|
`comfyui_build_txt2img_workflow` (primer arg = `ckpt`): para texto crudo, envuélvelo
|
||||||
|
o pasa un builder de asset.
|
||||||
|
- **Filtra kwargs con `inspect.signature`**: solo pasa al builder los que acepta,
|
||||||
|
así `escalate` (sube `steps`/`cfg`) y `reroll` (set `seed`) no rompen entre
|
||||||
|
builders con firmas distintas. Si un builder no expone `steps`/`seed`, esa
|
||||||
|
táctica simplemente no aplica en él.
|
||||||
|
- **`escalate` sube `steps`+`cfg`**, no inyecta hires-fix (no todos los builders
|
||||||
|
lo soportan y ui_hud lleva Rembg). Para upscale dedicado, usar
|
||||||
|
`comfyui_build_hires_fix_workflow` como builder.
|
||||||
|
- **Degrada con gracia**: si el juez cae (HTTP 429) la imagen se conserva con
|
||||||
|
score 0/verdict 'unknown' y el loop sigue; si una iteración falla en
|
||||||
|
submit/wait/fetch se registra su `error` y se reintenta la siguiente. Solo
|
||||||
|
devuelve `ok=False` si NINGUNA iteración produjo imagen.
|
||||||
|
- **VRAM (8GB)**: entre familias de generación, liberar con
|
||||||
|
`POST /free {"unload_models":true,"free_memory":true}` si el juez estético
|
||||||
|
(CLIP+LAION en el venv ComfyUI) compite por VRAM con el checkpoint SD.
|
||||||
|
- **Determinista en estructura**: nunca lanza excepción cruda; siempre dict de
|
||||||
|
estado. El refine usa `ask_llm` (best-effort): si falla, mantiene el subject.
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""comfyui_generate_until_quality — loop evaluator-optimizer (GAN sin entrenar).
|
||||||
|
|
||||||
|
Genera una imagen con un builder del registry, la juzga con el panel multi-juez
|
||||||
|
(`comfyui_judge_image`), y si no alcanza la calidad pedida REFINA (nueva seed,
|
||||||
|
mas calidad, prompt corregido con el feedback del juez) y regenera, hasta que
|
||||||
|
pasa el umbral (`verdict == 'good'`) o se agotan los intentos. Siempre devuelve
|
||||||
|
la MEJOR candidata por score (best-of-N): nunca devuelve basura por agotar
|
||||||
|
iteraciones.
|
||||||
|
|
||||||
|
Es la doctrina del issue 0087 (promover una secuencia repetida a un pipeline
|
||||||
|
one-shot) aplicada al bucle de mejora del grupo `comfyui-skill`: build -> submit
|
||||||
|
-> wait -> fetch -> judge -> (refine) -> repeat. Compone funciones del registry:
|
||||||
|
|
||||||
|
<builder>_py_ml (workflow de nodos en API format)
|
||||||
|
comfyui_submit_workflow_py_ml (POST /prompt)
|
||||||
|
comfyui_wait_result_py_ml (poll /history)
|
||||||
|
comfyui_fetch_output_image_py_ml (GET /view -> disco)
|
||||||
|
comfyui_judge_image_py_ml (panel estetico + CLIP + critica LLM)
|
||||||
|
ask_llm_py_core (refine del prompt con el feedback)
|
||||||
|
|
||||||
|
Pipeline impuro: red (HTTP), GPU (generacion), disco (PNG), y API (juez critico
|
||||||
|
+ refine de prompt). Determinista en estructura: nunca lanza excepcion cruda,
|
||||||
|
siempre devuelve un dict de estado.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Importa las funciones del registry (mismo arbol python/functions).
|
||||||
|
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _FUNCTIONS_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS_ROOT)
|
||||||
|
|
||||||
|
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||||
|
from ml.comfyui_judge_image import comfyui_judge_image
|
||||||
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
|
||||||
|
|
||||||
|
# Primo grande para derrochar el espacio de seeds entre rerolls de forma
|
||||||
|
# determinista (mismo subject + mismo base_seed -> misma traza de seeds).
|
||||||
|
_SEED_STRIDE = 101_117
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_builder(builder):
|
||||||
|
"""Devuelve el callable del builder.
|
||||||
|
|
||||||
|
Acepta un callable directo o el nombre de la funcion (string), que se
|
||||||
|
resuelve desde el paquete `ml` (convencion del registry: el modulo se llama
|
||||||
|
igual que la funcion, p.ej. `comfyui_build_ui_hud_workflow`).
|
||||||
|
"""
|
||||||
|
if callable(builder):
|
||||||
|
return builder
|
||||||
|
if isinstance(builder, str):
|
||||||
|
mod = importlib.import_module(f"ml.{builder}")
|
||||||
|
return getattr(mod, builder)
|
||||||
|
raise TypeError(
|
||||||
|
f"builder debe ser callable o str (nombre de funcion ml.*), no {type(builder)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_positive_prompt(workflow: dict) -> str:
|
||||||
|
"""Extrae el prompt positivo textual del workflow para pasarselo al juez.
|
||||||
|
|
||||||
|
Sigue el input `positive` del KSampler hasta su CLIPTextEncode. Fallback: el
|
||||||
|
CLIPTextEncode con el texto mas largo (heuristica: el positive suele serlo).
|
||||||
|
"""
|
||||||
|
if not isinstance(workflow, dict):
|
||||||
|
return ""
|
||||||
|
for node in workflow.values():
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
continue
|
||||||
|
if node.get("class_type") in ("KSampler", "KSamplerAdvanced"):
|
||||||
|
pos = node.get("inputs", {}).get("positive")
|
||||||
|
if isinstance(pos, list) and pos:
|
||||||
|
tgt = workflow.get(str(pos[0]))
|
||||||
|
if isinstance(tgt, dict) and tgt.get("class_type") == "CLIPTextEncode":
|
||||||
|
txt = tgt.get("inputs", {}).get("text")
|
||||||
|
if isinstance(txt, str) and txt.strip():
|
||||||
|
return txt
|
||||||
|
texts = [
|
||||||
|
n["inputs"]["text"]
|
||||||
|
for n in workflow.values()
|
||||||
|
if isinstance(n, dict)
|
||||||
|
and n.get("class_type") == "CLIPTextEncode"
|
||||||
|
and isinstance(n.get("inputs", {}).get("text"), str)
|
||||||
|
]
|
||||||
|
return max(texts, key=len) if texts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _builder_default(sig: inspect.Signature, name: str, fallback):
|
||||||
|
"""Default declarado de un parametro del builder, o el fallback dado."""
|
||||||
|
p = sig.parameters.get(name)
|
||||||
|
if p is None or p.default is inspect.Parameter.empty:
|
||||||
|
return fallback
|
||||||
|
return p.default if isinstance(p.default, (int, float)) else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _refine_subject(subject: str, judge_prompt: str, reasons, model: str) -> str:
|
||||||
|
"""Reescribe el subject corrigiendo lo que el juez senalo, via ask_llm.
|
||||||
|
|
||||||
|
Devuelve el subject mejorado (string corto) o el original si el LLM falla.
|
||||||
|
"""
|
||||||
|
from core.ask_llm import ask_llm
|
||||||
|
|
||||||
|
complaints = "; ".join(str(r) for r in (reasons or []) if r) or "(sin razones)"
|
||||||
|
system = (
|
||||||
|
"Eres un prompt-engineer de generacion de imagenes. Recibes el SUBJECT de "
|
||||||
|
"una imagen rechazada por un juez de calidad y la lista de quejas del juez. "
|
||||||
|
"Devuelve un SUBJECT mejorado y conciso (una frase, en ingles) que conserve la "
|
||||||
|
"intencion original pero corrija las quejas anadiendo descriptores visuales "
|
||||||
|
"concretos (p.ej. 'clean vector UI, sharp edges, high contrast, crisp lines' "
|
||||||
|
"si era borroso). NO escribas explicaciones, NO uses comillas: responde SOLO "
|
||||||
|
"con el subject mejorado."
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
f"SUBJECT original: {subject}\n"
|
||||||
|
f"Prompt completo generado: {judge_prompt}\n"
|
||||||
|
f"Quejas del juez: {complaints}\n"
|
||||||
|
"SUBJECT mejorado:"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out = ask_llm(user, model=model, system=system, echo=False)
|
||||||
|
out = (out or "").strip().strip('"').strip()
|
||||||
|
return out or subject
|
||||||
|
except Exception: # noqa: BLE001 — refine es best-effort; nunca rompe el loop.
|
||||||
|
return subject
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_generate_until_quality(
|
||||||
|
builder,
|
||||||
|
subject: str,
|
||||||
|
*,
|
||||||
|
threshold: float = 6.0,
|
||||||
|
clip_threshold: float = 0.24,
|
||||||
|
max_iters: int = 4,
|
||||||
|
strategy: str = "reroll+escalate+refine_prompt",
|
||||||
|
server: str = "127.0.0.1:8188",
|
||||||
|
dest_dir: str = "~/ComfyUI/output",
|
||||||
|
judge_prompt: str | None = None,
|
||||||
|
seed: int = 0,
|
||||||
|
refine_model: str = "claude-haiku-4-5-20251001",
|
||||||
|
judge_model: str = "claude-opus-4-8",
|
||||||
|
wait_timeout: float = 300.0,
|
||||||
|
**builder_kwargs,
|
||||||
|
) -> dict:
|
||||||
|
"""Genera y refina hasta alcanzar la calidad pedida (o agotar intentos).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
builder: callable o nombre (str) de un builder `comfyui_build_*_workflow`
|
||||||
|
del registry. El `subject` se pasa como PRIMER positional del builder
|
||||||
|
(caso de los builders de asset: ui_hud, item_icon, enemy_creature...,
|
||||||
|
cuyo primer arg es el elemento/sujeto).
|
||||||
|
subject: descripcion del elemento a generar (p.ej. "RPG health and mana
|
||||||
|
bars" para `comfyui_build_ui_hud_workflow`). Se inyecta en el builder
|
||||||
|
y, si se refina, se reescribe con el feedback del juez.
|
||||||
|
threshold: umbral estetico (0-10) que el juez usa para votar good/bad.
|
||||||
|
keyword-only.
|
||||||
|
clip_threshold: umbral de fidelidad CLIP (0-1) del juez. keyword-only.
|
||||||
|
max_iters: numero maximo de iteraciones de generacion. keyword-only.
|
||||||
|
strategy: combinacion de tacticas de mejora separadas por '+':
|
||||||
|
'reroll' (seed nueva cada iter), 'escalate' (mas steps/cfg en iters
|
||||||
|
tardias) y 'refine_prompt' (reescribe el subject con ask_llm usando
|
||||||
|
las razones del juez). keyword-only.
|
||||||
|
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
|
||||||
|
dest_dir: directorio local donde guardar los PNG. keyword-only.
|
||||||
|
judge_prompt: texto que se pasa al juez para medir fidelidad. Si None,
|
||||||
|
se extrae el prompt positivo del workflow construido. keyword-only.
|
||||||
|
seed: semilla base; los rerolls derivan de ella de forma determinista.
|
||||||
|
keyword-only.
|
||||||
|
refine_model: modelo de ask_llm para el refine del prompt (barato).
|
||||||
|
judge_model: modelo del juez critico LLM-vision. keyword-only.
|
||||||
|
wait_timeout: segundos maximos esperando cada generacion. keyword-only.
|
||||||
|
**builder_kwargs: parametros extra del builder (ui_style, checkpoint,
|
||||||
|
size, transparent...). Solo se pasan los que el builder acepta.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok, converged, best_image_path, best_score, best_verdict,
|
||||||
|
iterations, error}. `iterations` es una lista de
|
||||||
|
{iter, seed, params, score, verdict, reasons, image, error}. `converged`
|
||||||
|
True si alguna iteracion logro verdict 'good'. `best_*` apuntan a la
|
||||||
|
candidata de mayor score (aunque ninguna convergiera). Si nada se pudo
|
||||||
|
generar, ok=False y error explica.
|
||||||
|
"""
|
||||||
|
parts = {p.strip() for p in str(strategy).split("+") if p.strip()}
|
||||||
|
do_reroll = "reroll" in parts
|
||||||
|
do_escalate = "escalate" in parts
|
||||||
|
do_refine = "refine_prompt" in parts
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder_fn = _resolve_builder(builder)
|
||||||
|
except (ImportError, AttributeError, TypeError) as exc:
|
||||||
|
return {
|
||||||
|
"ok": False, "converged": False, "best_image_path": "",
|
||||||
|
"best_score": None, "best_verdict": "", "iterations": [],
|
||||||
|
"error": f"no se pudo resolver el builder: {exc}",
|
||||||
|
}
|
||||||
|
|
||||||
|
sig = inspect.signature(builder_fn)
|
||||||
|
accepts = set(sig.parameters)
|
||||||
|
base_steps = builder_kwargs.get("steps", _builder_default(sig, "steps", 28))
|
||||||
|
base_cfg = builder_kwargs.get("cfg", _builder_default(sig, "cfg", 7.0))
|
||||||
|
prefix = builder_kwargs.get("filename_prefix", "until_quality")
|
||||||
|
|
||||||
|
dest = os.path.expanduser(dest_dir)
|
||||||
|
subject_cur = subject
|
||||||
|
iterations: list[dict] = []
|
||||||
|
best: dict | None = None
|
||||||
|
converged = False
|
||||||
|
|
||||||
|
for i in range(max(1, int(max_iters))):
|
||||||
|
# --- parametros de esta iteracion segun la estrategia ---
|
||||||
|
cur_seed = (seed + i * _SEED_STRIDE) if do_reroll else seed
|
||||||
|
kw = dict(builder_kwargs)
|
||||||
|
if "seed" in accepts:
|
||||||
|
kw["seed"] = cur_seed
|
||||||
|
if do_escalate and i > 0:
|
||||||
|
if "steps" in accepts:
|
||||||
|
kw["steps"] = int(base_steps) + i * 8 # mas pasos = mas nitidez
|
||||||
|
if "cfg" in accepts:
|
||||||
|
kw["cfg"] = round(min(float(base_cfg) + i * 0.5, 12.0), 2)
|
||||||
|
if "filename_prefix" in accepts:
|
||||||
|
kw["filename_prefix"] = f"{prefix}_i{i}"
|
||||||
|
# Solo pasamos kwargs que el builder acepta (evita TypeError entre builders).
|
||||||
|
kw = {k: v for k, v in kw.items() if k in accepts}
|
||||||
|
params = {
|
||||||
|
"seed": cur_seed,
|
||||||
|
"steps": kw.get("steps", base_steps),
|
||||||
|
"cfg": kw.get("cfg", base_cfg),
|
||||||
|
"subject": subject_cur,
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = {"iter": i, "seed": cur_seed, "params": params, "score": None,
|
||||||
|
"verdict": "", "reasons": [], "image": "", "error": ""}
|
||||||
|
|
||||||
|
# --- build ---
|
||||||
|
try:
|
||||||
|
workflow = builder_fn(subject_cur, **kw)
|
||||||
|
except Exception as exc: # noqa: BLE001 — registra y reintenta siguiente iter.
|
||||||
|
rec["error"] = f"build fallo: {exc}"
|
||||||
|
iterations.append(rec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
jp = judge_prompt if judge_prompt else _extract_positive_prompt(workflow)
|
||||||
|
|
||||||
|
# --- submit ---
|
||||||
|
try:
|
||||||
|
sub = comfyui_submit_workflow(workflow, server=server)
|
||||||
|
prompt_id = sub["prompt_id"]
|
||||||
|
except (RuntimeError, KeyError) as exc:
|
||||||
|
rec["error"] = f"submit fallo: {exc}"
|
||||||
|
iterations.append(rec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- wait ---
|
||||||
|
try:
|
||||||
|
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
|
||||||
|
except (TimeoutError, RuntimeError) as exc:
|
||||||
|
rec["error"] = f"wait fallo: {exc}"
|
||||||
|
iterations.append(rec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- localizar el PNG ---
|
||||||
|
img = None
|
||||||
|
for node_out in outputs.values():
|
||||||
|
images = node_out.get("images") if isinstance(node_out, dict) else None
|
||||||
|
if images:
|
||||||
|
img = images[0]
|
||||||
|
break
|
||||||
|
if img is None:
|
||||||
|
rec["error"] = f"el workflow no produjo imagenes (outputs={list(outputs)})"
|
||||||
|
iterations.append(rec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- fetch ---
|
||||||
|
fetched = comfyui_fetch_output_image(
|
||||||
|
img["filename"], subfolder=img.get("subfolder", ""),
|
||||||
|
type_=img.get("type", "output"), server=server, dest_dir=dest,
|
||||||
|
)
|
||||||
|
if not fetched.get("ok"):
|
||||||
|
rec["error"] = f"fetch fallo: {fetched.get('error')}"
|
||||||
|
iterations.append(rec)
|
||||||
|
continue
|
||||||
|
rec["image"] = fetched["path"]
|
||||||
|
|
||||||
|
# --- judge (degrada con gracia si un juez cae) ---
|
||||||
|
try:
|
||||||
|
verdict = comfyui_judge_image(
|
||||||
|
fetched["path"], jp, threshold=threshold,
|
||||||
|
clip_threshold=clip_threshold, server=server, model=judge_model,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — un juez caido no debe tumbar el loop.
|
||||||
|
verdict = {"ok": False, "verdict": "unknown", "score": 0.0,
|
||||||
|
"reasons": [f"juez no disponible: {exc}"]}
|
||||||
|
|
||||||
|
rec["score"] = float(verdict.get("score") or 0.0)
|
||||||
|
rec["verdict"] = verdict.get("verdict", "unknown")
|
||||||
|
rec["reasons"] = list(verdict.get("reasons") or [])
|
||||||
|
iterations.append(rec)
|
||||||
|
|
||||||
|
# --- best-of-N: guarda siempre la mejor por score ---
|
||||||
|
if best is None or rec["score"] > best["score"]:
|
||||||
|
best = rec
|
||||||
|
|
||||||
|
# --- convergencia ---
|
||||||
|
if rec["verdict"] == "good":
|
||||||
|
converged = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# --- refine para la siguiente iteracion ---
|
||||||
|
if do_refine and i < max_iters - 1:
|
||||||
|
subject_cur = _refine_subject(subject_cur, jp, rec["reasons"], refine_model)
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
last_err = iterations[-1]["error"] if iterations else "sin iteraciones"
|
||||||
|
return {
|
||||||
|
"ok": False, "converged": False, "best_image_path": "",
|
||||||
|
"best_score": None, "best_verdict": "", "iterations": iterations,
|
||||||
|
"error": f"ninguna iteracion produjo imagen ({last_err})",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"converged": converged,
|
||||||
|
"best_image_path": best["image"],
|
||||||
|
"best_score": best["score"],
|
||||||
|
"best_verdict": best["verdict"],
|
||||||
|
"iterations": iterations,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
res = comfyui_generate_until_quality(
|
||||||
|
"comfyui_build_ui_hud_workflow",
|
||||||
|
"RPG health and mana bars, clean game UI",
|
||||||
|
ui_style="fantasy game UI, clean vector, high contrast",
|
||||||
|
threshold=6.5,
|
||||||
|
max_iters=3,
|
||||||
|
dest_dir="/tmp/comfy_until_quality",
|
||||||
|
transparent=False,
|
||||||
|
)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_pixelart_real_oneshot
|
||||||
|
kind: pipeline
|
||||||
|
lang: py
|
||||||
|
domain: pipelines
|
||||||
|
version: "1.1.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_pixelart_real_oneshot(subject: str, *, size: int = 64, colors: int = 16, engine: str = \"pixeloe\", palette=None, server: str = \"127.0.0.1:8188\", dest_dir: str = \"~/ComfyUI/output\", seed: int = 0, negative: str | None = None, mode: str = \"contrast\", patch_size: int = 16, thickness: int = 2, fill_frame: bool = True, transparent: bool = True, autocrop: bool = True, crop_pad_ratio: float = 0.06, rembg_model: str = \"u2net\", upscale_preview: int = 512, keep_base: bool = True, comfy_python: str | None = None, wait_timeout: float = 300.0, filename_prefix: str = \"pixelart_real\", **gen_kwargs) -> dict"
|
||||||
|
description: "Pipeline one-shot prompt de texto -> sprite pixel-art REAL (grid duro + paleta limitada) en disco, con fondo transparente y sujeto que llena el frame. Materializa el metodo ganador del report 0215, ahora alpha-aware: generar a alta-res con SDXL + LoRA SDXL_pixel-art (rembg recorta el fondo si transparent), AUTOCROP al bbox del contenido + cuadrado (el sujeto llena el frame, no diminuto), downscale contrast-aware con PixelOE (engine=pixeloe, sprites; alpha recombinado aparte porque PixelOE trabaja en RGB) o nearest (tiles), y cuantizacion dura alpha-aware con comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy). Salida PNG RGBA con transparencia real. Sweet-spot 64px personajes, 32px iconos. Fallback automatico pixeloe->nearest. Compone build_pixelart + submit + wait + fetch + crop_to_content + pixeloe_downscale + pixelize_image. Impuro: HTTP + disco."
|
||||||
|
tags: [comfyui, gamedev-2d, pixelart, pipelines, sprite, launcher, alpha, transparent, autocrop]
|
||||||
|
uses_functions: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: error_py_core
|
||||||
|
imports: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
||||||
|
params:
|
||||||
|
- name: subject
|
||||||
|
desc: "Prompt positivo (lo que se quiere ver: 'pixel art knight, full body, side view'). No puede estar vacio."
|
||||||
|
- name: size
|
||||||
|
desc: "Lado del grid final en pixeles. 64 personajes/sprites, 32 iconos/objetos simples. keyword-only."
|
||||||
|
- name: colors
|
||||||
|
desc: "Numero de colores de la paleta libre (MEDIANCUT) cuando palette es None. keyword-only."
|
||||||
|
- name: engine
|
||||||
|
desc: "'pixeloe' (downscale contrast-aware, sujetos con silueta) o 'nearest' (downscale simple, tiles/texturas). Fallback automatico a nearest si pixeloe falla. keyword-only."
|
||||||
|
- name: palette
|
||||||
|
desc: "None (paleta libre a `colors`), nombre builtin ('pico-8', 'nes', 'game-boy') o lista de hex. Una paleta fija ignora `colors`. keyword-only."
|
||||||
|
- name: server
|
||||||
|
desc: "host:port del servidor ComfyUI (sin esquema). keyword-only."
|
||||||
|
- name: dest_dir
|
||||||
|
desc: "Directorio donde guardar los PNG (se expande ~). keyword-only."
|
||||||
|
- name: seed
|
||||||
|
desc: "Semilla del KSampler. keyword-only."
|
||||||
|
- name: negative
|
||||||
|
desc: "Prompt negativo; None usa el default de build_pixelart (evita blur/gradientes/anti-alias). keyword-only."
|
||||||
|
- name: mode
|
||||||
|
desc: "Modo de downscale de PixelOE ('contrast' SOTA, 'k-centroid', 'nearest', 'center', 'bicubic'); solo con engine='pixeloe'. keyword-only."
|
||||||
|
- name: patch_size
|
||||||
|
desc: "Tamano de patch de PixelOE (default 16). keyword-only."
|
||||||
|
- name: thickness
|
||||||
|
desc: "Grosor del outline expansion de PixelOE (default 2). keyword-only."
|
||||||
|
- name: fill_frame
|
||||||
|
desc: "Si True anade un hint de encuadre al subject para que el sujeto llene el frame (mejor detalle por pixel tras el downscale). keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "Si True (default) genera con fondo recortado (rembg en el workflow) y produce sprite RGBA con transparencia real. False para tiles/texturas sin alpha (PNG opaco). keyword-only."
|
||||||
|
- name: autocrop
|
||||||
|
desc: "Si True (default) recorta la imagen base al bbox del contenido + cuadrado antes del downscale, para que el sujeto llene el frame (evita el sprite diminuto). Usa el alpha si transparent, o el color de fondo si no. keyword-only."
|
||||||
|
- name: crop_pad_ratio
|
||||||
|
desc: "Margen relativo que deja el autocrop alrededor del sujeto (0.06 = 6% del lado). keyword-only."
|
||||||
|
- name: rembg_model
|
||||||
|
desc: "Modelo Rembg para recortar el fondo ('u2net' general, 'isnet-anime' anime). Solo aplica si transparent. keyword-only."
|
||||||
|
- name: upscale_preview
|
||||||
|
desc: "Si > 0 escribe ademas un PNG re-escalado nearest a ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva. keyword-only."
|
||||||
|
- name: keep_base
|
||||||
|
desc: "Si True conserva el PNG base de alta resolucion; si False lo borra tras pixelizar. keyword-only."
|
||||||
|
- name: comfy_python
|
||||||
|
desc: "Ruta al interprete de ComfyUI (con la lib pixeloe); None autodetecta. keyword-only."
|
||||||
|
- name: wait_timeout
|
||||||
|
desc: "Segundos maximos esperando al server. keyword-only."
|
||||||
|
- name: filename_prefix
|
||||||
|
desc: "Prefijo de los archivos de salida. keyword-only."
|
||||||
|
- name: gen_kwargs
|
||||||
|
desc: "Params extra para comfyui_build_pixelart_workflow (width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...). keyword-only (**gen_kwargs)."
|
||||||
|
output: "dict {ok, out_path, out_path_upscaled, base_path, size, colors_final, engine_used, has_alpha, autocrop_applied, prompt_id, error}. out_path = PNG final size x size (RGBA si transparent); out_path_upscaled = preview re-escalado; has_alpha = True si lleva transparencia; autocrop_applied = True si el autocrop recorto la base; engine_used refleja el fallback (pixeloe->nearest). Si falla, ok=False y error explica en que paso. No-throw."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/pipelines/comfyui_pixelart_real_oneshot.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sprite de personaje 64px: RGBA transparente + autocrop (sujeto llena el frame).
|
||||||
|
./fn run comfyui_pixelart_real_oneshot "pixel art knight, full body, centered"
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from pipelines.comfyui_pixelart_real_oneshot import comfyui_pixelart_real_oneshot
|
||||||
|
|
||||||
|
# (a) Sprite personaje 64px: fondo transparente + autocrop (defaults).
|
||||||
|
res = comfyui_pixelart_real_oneshot(
|
||||||
|
"pixel art knight, full body, centered",
|
||||||
|
size=64, colors=16, engine="pixeloe", seed=42,
|
||||||
|
transparent=True, autocrop=True, dest_dir="~/ComfyUI/output",
|
||||||
|
)
|
||||||
|
print(res["out_path"], res["colors_final"], res["has_alpha"], res["engine_used"])
|
||||||
|
# -> 64px RGBA, ~16 colores, has_alpha=True, esquinas transparentes, sujeto ~88% del frame
|
||||||
|
|
||||||
|
# (b) Icono 32px de un item (sprite con alpha).
|
||||||
|
res = comfyui_pixelart_real_oneshot(
|
||||||
|
"pixel art sword icon, single object",
|
||||||
|
size=32, colors=16, engine="pixeloe", seed=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
# (c) Tile sin silueta -> nearest + paleta fija PICO-8, SIN transparencia.
|
||||||
|
res = comfyui_pixelart_real_oneshot(
|
||||||
|
"pixel art grass texture tile, top down, seamless",
|
||||||
|
size=64, engine="nearest", palette="pico-8",
|
||||||
|
transparent=False, autocrop=False, fill_frame=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieres pixel-art **de verdad** (grid duro + paleta limitada, verificable
|
||||||
|
por conteo de colores), no la salida cruda de la difusion (que parece pixelada
|
||||||
|
pero tiene decenas de miles de colores y bordes con anti-aliasing). Una sola
|
||||||
|
llamada hace generar -> recortar -> downscale -> cuantizar. Para **sprites de
|
||||||
|
sujeto** (personajes, criaturas, objetos) deja los defaults `transparent=True` +
|
||||||
|
`autocrop=True`: salen RGBA con fondo transparente y el sujeto llena el frame. Usa
|
||||||
|
`engine="pixeloe"` para conservar la silueta. Para **tiles/texturas/fondos** sin
|
||||||
|
contorno usa `engine="nearest"`, `transparent=False`, `autocrop=False` (mas barato,
|
||||||
|
CPU puro, sin alpha). 64px es el sweet-spot de personajes; 32px solo para
|
||||||
|
iconos/objetos simples.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Impuro: requiere el **servidor ComfyUI vivo** en `server` (default
|
||||||
|
`127.0.0.1:8188`) y los modelos instalados (SDXL Juggernaut + LoRA
|
||||||
|
`SDXL_pixel-art` + `SDXL_lcm-lora`). Si esta caido, falla en submit con
|
||||||
|
`ok=False` y el error de conexion (nunca lanza).
|
||||||
|
- `engine="pixeloe"` necesita la lib `pixeloe`, que vive en el venv de ComfyUI
|
||||||
|
(no en el del registry). `pixeloe_downscale` hace el puente de interprete
|
||||||
|
automaticamente; si no la encuentra, el pipeline **cae a `nearest`** y lo
|
||||||
|
reporta en `engine_used` + `error` (no aborta).
|
||||||
|
- El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba **roto** por un import
|
||||||
|
obsoleto (`pixeloe.pixelize` -> ahora `pixeloe.legacy.pixelize`); por eso el
|
||||||
|
pipeline usa la lib directa via `pixeloe_downscale`, no el nodo del server.
|
||||||
|
- `dest_dir` es un **directorio** (se crea si no existe). Los nombres de salida
|
||||||
|
son `<prefix>_<size>px_<engine>_<paleta|qN>.png` y `..._up.png` (preview).
|
||||||
|
- Una **paleta fija** (`pico-8`/`nes`/`game-boy`/lista hex) ignora `colors` y
|
||||||
|
puede dar menos colores que `colors` si el sujeto no cubre toda la paleta.
|
||||||
|
- Encuadre: si el sujeto ocupa poca area del frame, a 64/32px queda diminuto. Dos
|
||||||
|
mecanismos lo evitan: `fill_frame=True` (hint al prompt) y, sobre todo,
|
||||||
|
`autocrop=True` (default) que recorta al bbox real del contenido + cuadrado tras
|
||||||
|
generar. Con autocrop el sujeto llena ~85-90% del frame aunque el prompt no lo
|
||||||
|
encuadre perfecto.
|
||||||
|
- **transparencia (v1.1.0)**: `transparent=True` (default) mete el nodo `Image
|
||||||
|
Rembg` en el workflow (requiere ese custom node en el server) y produce PNG
|
||||||
|
**RGBA**. Las 4 esquinas salen `alpha==0`. Para tiles/fondos opacos: `transparent=False`.
|
||||||
|
- **alpha a traves de PixelOE**: PixelOE trabaja en RGB y pierde el alpha; el
|
||||||
|
pipeline downscalea el alpha del recorte por separado (nearest al mismo `size`) y
|
||||||
|
lo recombina sobre el grid antes de cuantizar. Por eso el sprite final conserva la
|
||||||
|
transparencia con `engine="pixeloe"`.
|
||||||
|
- Si la generacion sale **toda transparente** (rembg no detecto sujeto), no crashea:
|
||||||
|
el autocrop deja la imagen sin recortar y el resto del pipeline sigue (sprite
|
||||||
|
vacio, `colors_final` bajo). Revisa el `subject` en ese caso.
|
||||||
|
- No reintenta el sampler: para mejor toma, varia `seed`.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — sprite-fix: `transparent`/`autocrop`/`crop_pad_ratio`/
|
||||||
|
`rembg_model`. Arregla los 2 bugs reportados: (1) sprite diminuto -> autocrop al
|
||||||
|
bbox del contenido + cuadrado antes del downscale (sujeto pasa de ~48% a ~88% del
|
||||||
|
frame); (2) sin transparencia -> rembg en el workflow + cuantizacion alpha-aware +
|
||||||
|
alpha recombinado tras PixelOE -> PNG RGBA con esquinas alpha==0. Anade
|
||||||
|
`crop_to_content` a la composicion. Verificado en GPU (knight 64px).
|
||||||
|
- v1.0.0 (2026-06-28) — pipeline inicial. Materializa el metodo ganador del
|
||||||
|
report 0215 (PixelOE contrast downscale -> cuantizacion dura). Compone
|
||||||
|
build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image
|
||||||
|
(issue 0087).
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""comfyui_pixelart_real_oneshot — prompt de texto -> sprite pixel-art REAL en disco.
|
||||||
|
|
||||||
|
Pipeline one-shot (issue 0087) que materializa el metodo ganador de la
|
||||||
|
investigacion (report 0215): la difusion NO sabe pintar pixel-perfect (su salida
|
||||||
|
tiene decenas de miles de colores y bordes con anti-aliasing — pixel-art FALSO),
|
||||||
|
asi que el pixel-art de verdad es siempre post-proceso en dos ejes: colapsar a un
|
||||||
|
grid duro y limitar la paleta. El metodo ganador combina:
|
||||||
|
|
||||||
|
1. Generar a alta resolucion con el look pixel-art (SDXL Juggernaut + LoRA
|
||||||
|
SDXL_pixel-art), via comfyui_build_pixelart_workflow.
|
||||||
|
2. Downscale contrast-aware con PixelOE (pixeloe_downscale): elige el pixel mas
|
||||||
|
representativo de cada zona y engrosa contornos -> silueta legible. Es lo que
|
||||||
|
distingue un sprite reconocible de una mancha. Solo para sujetos con silueta
|
||||||
|
(engine="pixeloe"); para tiles/texturas sin contorno, un downscale nearest
|
||||||
|
simple basta (engine="nearest") y es mas barato.
|
||||||
|
3. Cuantizacion dura con comfyui_pixelize_image (downscale=1): clava la paleta
|
||||||
|
exacta (N colores libres MEDIANCUT, o paleta fija pico-8 / nes / game-boy)
|
||||||
|
sobre el grid ya hecho -> 16 colores exactos + 100% grid duro.
|
||||||
|
|
||||||
|
Resultado del combo verificado por PIL: grid duro perfecto + paleta limitada +
|
||||||
|
outline nitido. Sweet-spot: 64px personajes/sprites, 32px iconos/objetos simples.
|
||||||
|
|
||||||
|
Compone funciones del registry, no reescribe su logica:
|
||||||
|
comfyui_build_pixelart_workflow_py_ml (workflow SDXL + LoRA pixel-art)
|
||||||
|
comfyui_submit_workflow_py_ml (POST /prompt)
|
||||||
|
comfyui_wait_result_py_ml (poll /history)
|
||||||
|
comfyui_fetch_output_image_py_ml (GET /view -> disco, imagen base)
|
||||||
|
pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe)
|
||||||
|
comfyui_pixelize_image_py_ml (cuantizacion dura + nearest fallback)
|
||||||
|
|
||||||
|
Pipeline impuro: red (HTTP a ComfyUI) + escritura en disco. No-throw: cualquier
|
||||||
|
fallo se captura y se devuelve en el dict de estado (campo error).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Importa las funciones del registry (mismo arbol python/functions).
|
||||||
|
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _FUNCTIONS_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS_ROOT)
|
||||||
|
|
||||||
|
from ml.comfyui_build_pixelart_workflow import comfyui_build_pixelart_workflow
|
||||||
|
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
from ml.crop_to_content import crop_to_content
|
||||||
|
from ml.pixeloe_downscale import pixeloe_downscale
|
||||||
|
|
||||||
|
# Sufijo de encuadre: empuja al sujeto a llenar el frame para que tras el
|
||||||
|
# downscale conserve detalle por pixel (gotcha del report: un sujeto que ocupa el
|
||||||
|
# 25% del frame queda diminuto a 64px). Solo se anade si no esta ya presente.
|
||||||
|
_FRAME_HINT = "full body, centered, fills frame, no margins"
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_subject(subject: str, fill_frame: bool) -> str:
|
||||||
|
"""Anade el hint de encuadre al subject si fill_frame y no esta ya."""
|
||||||
|
if not fill_frame:
|
||||||
|
return subject
|
||||||
|
low = subject.lower()
|
||||||
|
if "fills frame" in low or "full body" in low or "centered" in low:
|
||||||
|
return subject
|
||||||
|
return f"{subject}, {_FRAME_HINT}"
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_pixelart_real_oneshot(
|
||||||
|
subject: str,
|
||||||
|
*,
|
||||||
|
size: int = 64,
|
||||||
|
colors: int = 16,
|
||||||
|
engine: str = "pixeloe",
|
||||||
|
palette=None,
|
||||||
|
server: str = "127.0.0.1:8188",
|
||||||
|
dest_dir: str = "~/ComfyUI/output",
|
||||||
|
seed: int = 0,
|
||||||
|
negative: str | None = None,
|
||||||
|
mode: str = "contrast",
|
||||||
|
patch_size: int = 16,
|
||||||
|
thickness: int = 2,
|
||||||
|
fill_frame: bool = True,
|
||||||
|
transparent: bool = True,
|
||||||
|
autocrop: bool = True,
|
||||||
|
crop_pad_ratio: float = 0.06,
|
||||||
|
rembg_model: str = "u2net",
|
||||||
|
upscale_preview: int = 512,
|
||||||
|
keep_base: bool = True,
|
||||||
|
comfy_python: str | None = None,
|
||||||
|
wait_timeout: float = 300.0,
|
||||||
|
filename_prefix: str = "pixelart_real",
|
||||||
|
**gen_kwargs,
|
||||||
|
) -> dict:
|
||||||
|
"""Genera un sprite pixel-art REAL desde un prompt de texto, end-to-end.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject: prompt positivo (lo que se quiere ver: "pixel art knight, full
|
||||||
|
body, side view", etc.). No puede estar vacio.
|
||||||
|
size: lado del grid final en pixeles (64 personajes/sprites, 32 iconos).
|
||||||
|
keyword-only.
|
||||||
|
colors: numero de colores de la paleta libre cuando palette es None
|
||||||
|
(cuantizacion MEDIANCUT). keyword-only.
|
||||||
|
engine: "pixeloe" (downscale contrast-aware, para sujetos con silueta:
|
||||||
|
personajes/criaturas/iconos) o "nearest" (downscale nearest simple,
|
||||||
|
mas barato, para tiles/texturas/fondos sin contorno). Si "pixeloe"
|
||||||
|
falla o la lib no esta disponible, cae automaticamente a "nearest" y
|
||||||
|
lo reporta en engine_used. keyword-only.
|
||||||
|
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
|
||||||
|
"game-boy") o lista de hex. Una paleta fija ignora `colors`.
|
||||||
|
keyword-only.
|
||||||
|
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
|
||||||
|
dest_dir: directorio donde guardar los PNG (se expande ~). keyword-only.
|
||||||
|
seed: semilla del KSampler. keyword-only.
|
||||||
|
negative: prompt negativo; None usa el default de build_pixelart
|
||||||
|
(evita blur/gradientes/anti-alias). keyword-only.
|
||||||
|
mode: modo de downscale de PixelOE ("contrast" SOTA, "k-centroid",
|
||||||
|
"nearest", "center", "bicubic"); solo aplica con engine="pixeloe".
|
||||||
|
keyword-only.
|
||||||
|
patch_size: tamano de patch de PixelOE (default 16). keyword-only.
|
||||||
|
thickness: grosor del outline expansion de PixelOE (default 2).
|
||||||
|
keyword-only.
|
||||||
|
fill_frame: si True, anade un hint de encuadre al subject para que el
|
||||||
|
sujeto llene el frame (mejor detalle por pixel tras el downscale).
|
||||||
|
keyword-only.
|
||||||
|
transparent: si True (default) genera con fondo recortado (rembg en el
|
||||||
|
workflow) y produce un sprite RGBA con transparencia real. Para
|
||||||
|
tiles/texturas que NO quieren alpha, pasar transparent=False (el sprite
|
||||||
|
sale RGB sobre fondo opaco). keyword-only.
|
||||||
|
autocrop: si True (default) recorta la imagen base al bounding box de su
|
||||||
|
contenido y la cuadra antes del downscale, para que el sujeto llene el
|
||||||
|
frame (evita el sprite diminuto). Usa el alpha si transparent, o el color
|
||||||
|
de fondo si no. keyword-only.
|
||||||
|
crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto
|
||||||
|
(0.06 = 6% del lado). keyword-only.
|
||||||
|
rembg_model: modelo Rembg para recortar el fondo ('u2net' general,
|
||||||
|
'isnet-anime' para anime). Solo aplica si transparent. keyword-only.
|
||||||
|
upscale_preview: si > 0, escribe ademas un PNG re-escalado nearest a
|
||||||
|
ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva.
|
||||||
|
keyword-only.
|
||||||
|
keep_base: si True conserva el PNG base de alta resolucion; si False lo
|
||||||
|
borra tras pixelizar. keyword-only.
|
||||||
|
comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe); None
|
||||||
|
autodetecta. keyword-only.
|
||||||
|
wait_timeout: segundos maximos esperando al server. keyword-only.
|
||||||
|
filename_prefix: prefijo de los archivos de salida. keyword-only.
|
||||||
|
**gen_kwargs: params extra para comfyui_build_pixelart_workflow
|
||||||
|
(width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si se produjo el PNG final pixelizado.
|
||||||
|
- out_path (str): ruta del PNG final size x size.
|
||||||
|
- out_path_upscaled (str): ruta del preview re-escalado, o "" si off.
|
||||||
|
- base_path (str): ruta del PNG base de alta resolucion (o "" si se borro).
|
||||||
|
- size (int): lado real del PNG final.
|
||||||
|
- colors_final (int): numero de colores distintos en el resultado (en la
|
||||||
|
zona opaca si es RGBA).
|
||||||
|
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
|
||||||
|
- has_alpha (bool): True si el PNG final es RGBA con transparencia.
|
||||||
|
- autocrop_applied (bool): True si el autocrop recorto la imagen base.
|
||||||
|
- prompt_id (str): id del trabajo en ComfyUI.
|
||||||
|
- error (str): mensaje de error; vacio si OK.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False, "out_path": "", "out_path_upscaled": "", "base_path": "",
|
||||||
|
"size": int(size), "colors_final": 0, "engine_used": engine,
|
||||||
|
"has_alpha": False, "autocrop_applied": False,
|
||||||
|
"prompt_id": "", "error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not subject or not subject.strip():
|
||||||
|
out["error"] = "subject vacio"
|
||||||
|
return out
|
||||||
|
if int(size) < 1:
|
||||||
|
out["error"] = f"size debe ser >= 1, recibido {size!r}"
|
||||||
|
return out
|
||||||
|
if engine not in ("pixeloe", "nearest"):
|
||||||
|
out["error"] = f"engine invalido: {engine!r} (usa 'pixeloe' o 'nearest')"
|
||||||
|
return out
|
||||||
|
|
||||||
|
dest = os.path.expanduser(dest_dir)
|
||||||
|
try:
|
||||||
|
os.makedirs(dest, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
out["error"] = f"no se pudo crear dest_dir {dest!r}: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- Fase 1: generar la imagen base de alta resolucion (look pixel-art) ---
|
||||||
|
positive = _frame_subject(subject, fill_frame)
|
||||||
|
try:
|
||||||
|
if negative is None:
|
||||||
|
workflow = comfyui_build_pixelart_workflow(
|
||||||
|
positive, seed=seed, transparent=bool(transparent),
|
||||||
|
rembg_model=rembg_model,
|
||||||
|
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
workflow = comfyui_build_pixelart_workflow(
|
||||||
|
positive, negative, seed=seed, transparent=bool(transparent),
|
||||||
|
rembg_model=rembg_model,
|
||||||
|
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
out["error"] = f"build workflow fallo: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
sub = comfyui_submit_workflow(workflow, server=server)
|
||||||
|
prompt_id = sub["prompt_id"]
|
||||||
|
out["prompt_id"] = prompt_id
|
||||||
|
except (RuntimeError, KeyError, OSError) as exc:
|
||||||
|
out["error"] = f"submit fallo (server {server} responde?): {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
|
||||||
|
except (TimeoutError, RuntimeError, OSError) as exc:
|
||||||
|
out["error"] = f"wait fallo: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
img = None
|
||||||
|
for node_out in outputs.values():
|
||||||
|
images = node_out.get("images") if isinstance(node_out, dict) else None
|
||||||
|
if images:
|
||||||
|
img = images[0]
|
||||||
|
break
|
||||||
|
if img is None:
|
||||||
|
out["error"] = f"el workflow no produjo imagenes (outputs={list(outputs)})"
|
||||||
|
return out
|
||||||
|
|
||||||
|
fetched = comfyui_fetch_output_image(
|
||||||
|
img["filename"], subfolder=img.get("subfolder", ""),
|
||||||
|
type_=img.get("type", "output"), server=server, dest_dir=dest,
|
||||||
|
)
|
||||||
|
if not fetched.get("ok"):
|
||||||
|
out["error"] = f"fetch de imagen base fallo: {fetched.get('error')}"
|
||||||
|
return out
|
||||||
|
base_path = fetched["path"]
|
||||||
|
out["base_path"] = base_path
|
||||||
|
|
||||||
|
# --- Fase 1b (opcional): autocrop al contenido + cuadrar (sujeto llena el frame). ---
|
||||||
|
# La imagen sobre la que se hace el downscale: la recortada si autocrop, o la base.
|
||||||
|
pre_ds_path = base_path
|
||||||
|
crop_path = ""
|
||||||
|
if autocrop:
|
||||||
|
crop_path = os.path.join(dest, f"{filename_prefix}_{size}px_crop.png")
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(base_path) as base_im:
|
||||||
|
src_im = base_im.convert("RGBA") if transparent else base_im.convert("RGB")
|
||||||
|
before = src_im.size
|
||||||
|
cropped = crop_to_content(
|
||||||
|
src_im, pad_ratio=float(crop_pad_ratio), square=True,
|
||||||
|
)
|
||||||
|
cropped.save(crop_path)
|
||||||
|
pre_ds_path = crop_path
|
||||||
|
out["autocrop_applied"] = cropped.size != before
|
||||||
|
except (ImportError, OSError, ValueError) as exc:
|
||||||
|
# Autocrop es best-effort: si falla, se sigue con la base sin recortar.
|
||||||
|
crop_path = ""
|
||||||
|
pre_ds_path = base_path
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"autocrop fallo (no critico): {exc}"
|
||||||
|
|
||||||
|
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
|
||||||
|
mid_path = os.path.join(dest, f"{filename_prefix}_{size}px_mid.png")
|
||||||
|
engine_used = engine
|
||||||
|
|
||||||
|
if engine == "pixeloe":
|
||||||
|
ds = pixeloe_downscale(
|
||||||
|
pre_ds_path, mid_path, mode=mode, target_size=int(size),
|
||||||
|
patch_size=patch_size, thickness=thickness, no_upscale=True,
|
||||||
|
comfy_python=comfy_python,
|
||||||
|
)
|
||||||
|
if not ds.get("ok"):
|
||||||
|
# Fallback limpio: PixelOE no disponible / fallo -> nearest.
|
||||||
|
engine_used = "nearest"
|
||||||
|
out["error"] = (
|
||||||
|
f"pixeloe fallo ({ds.get('error')}); fallback a nearest"
|
||||||
|
)
|
||||||
|
|
||||||
|
if engine_used == "nearest":
|
||||||
|
# Downscale nearest simple a size x size (PIL en el venv del registry).
|
||||||
|
# nearest preserva el alpha por canal: si transparent, conserva la silueta.
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(pre_ds_path) as src:
|
||||||
|
target_mode = "RGBA" if transparent else "RGB"
|
||||||
|
small = src.convert(target_mode).resize(
|
||||||
|
(int(size), int(size)), Image.NEAREST
|
||||||
|
)
|
||||||
|
small.save(mid_path)
|
||||||
|
except (ImportError, OSError) as exc:
|
||||||
|
out["error"] = f"downscale nearest fallo: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if not os.path.isfile(mid_path):
|
||||||
|
out["error"] = "no se genero la imagen intermedia (mid)"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- Fase 2a-bis: recombinar alpha tras pixeloe (PixelOE trabaja en RGB). ---
|
||||||
|
# El nucleo de PixelOE convierte a RGB: el grid `mid` sale sin transparencia. Se
|
||||||
|
# downscalea el alpha de la imagen pre-downscale por separado (nearest al mismo
|
||||||
|
# size) y se reaplica al grid para no perder el recorte ni la transparencia.
|
||||||
|
if transparent and engine_used == "pixeloe":
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(pre_ds_path) as src_im:
|
||||||
|
alpha = src_im.convert("RGBA").getchannel("A").resize(
|
||||||
|
(int(size), int(size)), Image.NEAREST
|
||||||
|
)
|
||||||
|
with Image.open(mid_path) as mid_im:
|
||||||
|
mid_rgba = mid_im.convert("RGBA")
|
||||||
|
mid_rgba.putalpha(alpha)
|
||||||
|
mid_rgba.save(mid_path)
|
||||||
|
except (ImportError, OSError) as exc:
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"recombinacion de alpha fallo (no critico): {exc}"
|
||||||
|
|
||||||
|
# --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
|
||||||
|
final_tag = palette if isinstance(palette, str) else f"q{colors}"
|
||||||
|
final_path = os.path.join(
|
||||||
|
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}.png"
|
||||||
|
)
|
||||||
|
quant = comfyui_pixelize_image(
|
||||||
|
mid_path, final_path, downscale=1, colors=int(colors),
|
||||||
|
palette=palette, upscale_back=False, keep_alpha=bool(transparent),
|
||||||
|
)
|
||||||
|
if not quant.get("ok"):
|
||||||
|
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["out_path"] = final_path
|
||||||
|
out["size"] = quant["size"][0] if quant.get("size") else int(size)
|
||||||
|
out["colors_final"] = quant.get("n_colors_final", 0)
|
||||||
|
out["has_alpha"] = bool(quant.get("has_alpha", False))
|
||||||
|
out["engine_used"] = engine_used
|
||||||
|
|
||||||
|
# --- Fase 3 (opcional): preview re-escalado nearest a pixeles duros. ---
|
||||||
|
if int(upscale_preview) > 0:
|
||||||
|
up_path = os.path.join(
|
||||||
|
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}_up.png"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(final_path) as fin:
|
||||||
|
prev_mode = "RGBA" if transparent else "RGB"
|
||||||
|
up = fin.convert(prev_mode).resize(
|
||||||
|
(int(upscale_preview), int(upscale_preview)), Image.NEAREST
|
||||||
|
)
|
||||||
|
up.save(up_path)
|
||||||
|
out["out_path_upscaled"] = up_path
|
||||||
|
except (ImportError, OSError) as exc:
|
||||||
|
# El preview es opcional: no invalida el resultado.
|
||||||
|
out["out_path_upscaled"] = ""
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"preview upscale fallo (no critico): {exc}"
|
||||||
|
|
||||||
|
# Limpieza de intermedios (mid + crop temporal).
|
||||||
|
for tmp in (mid_path, crop_path):
|
||||||
|
if tmp:
|
||||||
|
try:
|
||||||
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not keep_base:
|
||||||
|
try:
|
||||||
|
os.remove(base_path)
|
||||||
|
out["base_path"] = ""
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out["ok"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
res = comfyui_pixelart_real_oneshot(
|
||||||
|
"pixel art knight, full body, centered, game sprite",
|
||||||
|
size=64, colors=16, engine="pixeloe", seed=42,
|
||||||
|
transparent=True, autocrop=True,
|
||||||
|
dest_dir="/tmp/comfy_pixelart_real",
|
||||||
|
)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
Reference in New Issue
Block a user