Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccdd529bdc | |||
| 741724f633 | |||
| 2be62f6ef6 | |||
| 8e9e1e6c8a | |||
| ec46aae04c | |||
| b173ac2703 | |||
| 5280499df5 | |||
| 346f859b86 | |||
| 287abbd6ee | |||
| f8793f96ac |
File diff suppressed because one or more lines are too long
@@ -49,7 +49,9 @@ params:
|
||||
- name: filename_prefix
|
||||
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."
|
||||
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
|
||||
---
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ params:
|
||||
- name: filename_prefix
|
||||
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."
|
||||
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"
|
||||
---
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: labels
|
||||
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)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_build_grid.py"
|
||||
file_path: "python/functions/ml/comfyui_build_grid.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ params:
|
||||
- name: filename_prefix
|
||||
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)."
|
||||
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
|
||||
---
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ params:
|
||||
- name: filename_prefix
|
||||
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."
|
||||
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
|
||||
---
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ params:
|
||||
- name: filename_prefix
|
||||
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)."
|
||||
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
|
||||
---
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: token
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_critique_image_llm.py"
|
||||
file_path: "python/functions/ml/comfyui_critique_image_llm.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: nsfw
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py"
|
||||
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_fetch_civitai_image_meta(image_ref, *, token: str | None = None, timeout: float = 15.0) -> dict"
|
||||
signature: "def comfyui_fetch_civitai_image_meta(image_ref, token: str | None = None, timeout: float = 15.0) -> dict"
|
||||
description: "Recupera los detalles de generacion de una imagen de Civitai por su id o URL (civitai.com/images/<id>): prompt, prompt negativo, modelo, sampler, steps, cfg, seed, dimensiones, recursos (checkpoint + LoRAs) y nivel NSFW. Es el paso 'entrar al link y observar como lo hicieron'. Usa los endpoints tRPC image.getGenerationData + image.get que consume la propia web de civitai.com, porque la API v1 publica (GET /api/v1/images) hoy devuelve meta=null y un JPEG recomprimido sin workflow embebido. Si la meta trae un workflow ComfyUI embebido (campo comfy) lo devuelve en API format. NO descarga la imagen ni reconstruye workflow: solo lee. Impura: HTTP a civitai.com + subprocess (pass para el token)."
|
||||
tags: [comfyui, civitai, replicate, ml, metadata, http, stable-diffusion]
|
||||
uses_functions: []
|
||||
|
||||
@@ -128,15 +128,15 @@ def _extract_comfy_workflow(meta):
|
||||
return {}
|
||||
|
||||
|
||||
def comfyui_fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0):
|
||||
def comfyui_fetch_civitai_image_meta(image_ref, token=None, timeout=15.0):
|
||||
"""Recupera los detalles de generación de una imagen de Civitai por id/URL.
|
||||
|
||||
Args:
|
||||
image_ref: id numérico de la imagen (int o str), o su URL
|
||||
`https://civitai.com/images/<id>` (con o sin query string).
|
||||
token: API token de Civitai (header Authorization Bearer). Si None se
|
||||
resuelve de `pass civitai/api-token`. No hardcodear. keyword-only.
|
||||
timeout: timeout HTTP en segundos por petición. keyword-only.
|
||||
resuelve de `pass civitai/api-token`. No hardcodear.
|
||||
timeout: timeout HTTP en segundos por petición.
|
||||
|
||||
Returns:
|
||||
dict {ok, image_id, meta, resources, process, comfy_workflow, width,
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: resample
|
||||
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)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py"
|
||||
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ params:
|
||||
- name: timeout
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_import_workflow_json.py"
|
||||
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict"
|
||||
signature: "def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict"
|
||||
description: "Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI. Lee el chunk 'prompt' (API format) y/o 'workflow' (UI graph) de los chunks tEXt/zTXt/iTXt con stdlib (struct, zlib). Acepta path local o URL. Impura: red opcional + lectura de disco."
|
||||
tags: [comfyui, ml, import, png, workflow, stable-diffusion]
|
||||
uses_functions: []
|
||||
|
||||
@@ -14,12 +14,12 @@ import urllib.request
|
||||
import zlib
|
||||
|
||||
|
||||
def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict:
|
||||
def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict:
|
||||
"""Devuelve el/los workflow(s) embebido(s) en un PNG de ComfyUI.
|
||||
|
||||
Args:
|
||||
png_path_or_url: ruta local de un PNG, o URL http(s) de un PNG.
|
||||
timeout: timeout HTTP en segundos (solo si es URL). keyword-only.
|
||||
timeout: timeout HTTP en segundos (solo si es URL).
|
||||
|
||||
Returns:
|
||||
dict {ok, prompt, workflow, format_detected, error}:
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_interrupt_queue(*, clear_pending: bool = False, server: str = \"127.0.0.1:8188\", timeout: float = 10.0) -> dict"
|
||||
signature: "def comfyui_interrupt_queue(clear_pending: bool = False, server: str = \"127.0.0.1:8188\", timeout: float = 10.0) -> dict"
|
||||
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y, si clear_pending=True, vacia ademas la cola de pendientes (POST /queue {\"clear\":true}). Consulta GET /queue al final para reportar queue_remaining. Devuelve {ok, interrupted, cleared, queue_remaining, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes salvo clear_pending. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
|
||||
tags: [comfyui, ml, queue, interrupt, control, http]
|
||||
uses_functions: []
|
||||
|
||||
@@ -32,9 +32,9 @@ params:
|
||||
- name: venv_python
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_judge_image.py"
|
||||
file_path: "python/functions/ml/comfyui_judge_image.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_list_skills(*, library_dir: str = None, include_nsfw: bool = False) -> dict"
|
||||
signature: "def comfyui_list_skills(library_dir: str = None, include_nsfw: bool = False) -> dict"
|
||||
description: "Lista las skills ComfyUI guardadas en la libreria de disco con su metadata de resumen: slug, title, base_workflow, version, score_mean/score_n y nsfw (de provenance.nsfw), mas n_versions. Respeta include_nsfw=False (oculta las NSFW por defecto). Libreria inexistente o vacia -> lista vacia sin error. library_dir default ~/ComfyUI/skills_library."
|
||||
error_type: error_go_core
|
||||
tags: [comfyui, comfyui-skill, ml, skill, library]
|
||||
|
||||
@@ -28,13 +28,12 @@ def _n_versions(skill_dir):
|
||||
if f.startswith("v") and f.endswith(".json")])
|
||||
|
||||
|
||||
def comfyui_list_skills(*, library_dir: str = None, include_nsfw: bool = False) -> dict:
|
||||
def comfyui_list_skills(library_dir: str = None, include_nsfw: bool = False) -> dict:
|
||||
"""Lista las skills de la libreria con su metadata de resumen.
|
||||
|
||||
Args:
|
||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
|
||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
|
||||
include_nsfw: si False (default), oculta las skills con `provenance.nsfw == True`.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict ``{ok, skills, count, error}`` donde `skills` es una lista de dicts
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_load_skill(slug: str, *, version=None, library_dir: str = None) -> dict"
|
||||
signature: "def comfyui_load_skill(slug: str, version=None, library_dir: str = None) -> dict"
|
||||
description: "Lee una receta de skill ComfyUI de la libreria de disco: recipe.json (version actual) o un snapshot versions/vN.json. Hermana inversa de comfyui_save_skill; el round-trip save(recipe)->load(slug) devuelve un dict identico. library_dir default ~/ComfyUI/skills_library. Slug, version o archivo inexistente -> {ok:False} sin lanzar."
|
||||
error_type: error_go_core
|
||||
tags: [comfyui, comfyui-skill, ml, skill, library]
|
||||
|
||||
@@ -36,14 +36,14 @@ def _version_filename(version):
|
||||
return None
|
||||
|
||||
|
||||
def comfyui_load_skill(slug: str, *, version=None, library_dir: str = None) -> dict:
|
||||
def comfyui_load_skill(slug: str, version=None, library_dir: str = None) -> dict:
|
||||
"""Lee la receta de una skill (version actual o un snapshot concreto).
|
||||
|
||||
Args:
|
||||
slug: slug de la skill (nombre de su carpeta en la libreria).
|
||||
version: si None, lee `recipe.json` (version actual). Si se pasa (int, "1" o
|
||||
"v1"), lee el snapshot `versions/vN.json`. keyword-only.
|
||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
|
||||
"v1"), lee el snapshot `versions/vN.json`.
|
||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
|
||||
|
||||
Returns:
|
||||
dict ``{ok, recipe, slug, path, version, error}``. En exito ``ok=True`` y `recipe`
|
||||
|
||||
@@ -18,9 +18,9 @@ params:
|
||||
- name: png_path
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_read_png_metadata.py"
|
||||
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ params:
|
||||
- name: server
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py"
|
||||
file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_save_skill(recipe: dict, *, library_dir: str = None) -> dict"
|
||||
signature: "def comfyui_save_skill(recipe: dict, library_dir: str = None) -> dict"
|
||||
description: "Persiste una receta de skill ComfyUI (schema comfyui-skill) en la libreria de disco: valida el schema minimo y escribe <library_dir>/<slug>/recipe.json + un snapshot inmutable versions/vN.json (N incremental) + bitacora growth_log.jsonl + regenera INDEX.md. No muta la receta (round-trip identico con comfyui_load_skill). library_dir default ~/ComfyUI/skills_library. Devuelve dict {ok, slug, path, version_file, n_versions, error}; nunca lanza."
|
||||
error_type: error_go_core
|
||||
tags: [comfyui, comfyui-skill, ml, skill, library, persistence]
|
||||
|
||||
@@ -91,13 +91,13 @@ def _rewrite_index(lib):
|
||||
fh.write("\n".join(lines))
|
||||
|
||||
|
||||
def comfyui_save_skill(recipe: dict, *, library_dir: str = None) -> dict:
|
||||
def comfyui_save_skill(recipe: dict, library_dir: str = None) -> dict:
|
||||
"""Valida y persiste una receta de skill en la libreria de disco.
|
||||
|
||||
Args:
|
||||
recipe: dict de la receta (schema `comfyui-skill`). Requiere al menos `slug`,
|
||||
`base_workflow` y `version` (strings no vacios). No se muta.
|
||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
|
||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
|
||||
|
||||
Returns:
|
||||
dict ``{ok, slug, path, recipe_path, version_file, n_versions, error}``. En error de
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: timeout
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
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: "python/functions/ml/tests/test_comfyui_score_aesthetic.py"
|
||||
file_path: "python/functions/ml/comfyui_score_aesthetic.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ params:
|
||||
- name: timeout
|
||||
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."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["error: imagen inexistente", "error: prompt vacio", "error: python del venv ComfyUI ausente", "nunca lanza excepcion + determinismo del error"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_score_clip_alignment.py"
|
||||
file_path: "python/functions/ml/comfyui_score_clip_alignment.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -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,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,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
|
||||
@@ -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"
|
||||
@@ -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,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,133 @@
|
||||
---
|
||||
name: comfyui_pixelart_real_oneshot
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.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, 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. Materializa el metodo ganador del report 0215: generar a alta-res con SDXL + LoRA SDXL_pixel-art, downscale contrast-aware con PixelOE (engine=pixeloe, sprites) o nearest (tiles), y cuantizacion dura con comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy). Sweet-spot 64px personajes, 32px iconos. Fallback automatico pixeloe->nearest. Compone build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image. Impuro: HTTP + disco."
|
||||
tags: [comfyui, gamedev-2d, pixelart, pipelines, sprite, launcher]
|
||||
uses_functions: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_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, 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: 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, prompt_id, error}. out_path = PNG final size x size; out_path_upscaled = preview re-escalado; 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
|
||||
# Personaje 64px, 16 colores, motor pixeloe (sprites con silueta).
|
||||
./fn run comfyui_pixelart_real_oneshot "pixel art knight, full body, side view, game sprite"
|
||||
```
|
||||
|
||||
```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) Personaje 64px, paleta libre 16 colores, PixelOE contrast.
|
||||
res = comfyui_pixelart_real_oneshot(
|
||||
"pixel art knight, full body, side view, game sprite",
|
||||
size=64, colors=16, engine="pixeloe", seed=42,
|
||||
dest_dir="~/ComfyUI/output",
|
||||
)
|
||||
print(res["out_path"], res["colors_final"], res["engine_used"]) # ~16 colores, pixeloe
|
||||
|
||||
# (b) Icono 32px de un item.
|
||||
res = comfyui_pixelart_real_oneshot(
|
||||
"pixel art sword icon, single object",
|
||||
size=32, colors=16, engine="pixeloe", seed=7,
|
||||
)
|
||||
|
||||
# (c) Tile sin silueta -> nearest (mas barato) + paleta fija PICO-8.
|
||||
res = comfyui_pixelart_real_oneshot(
|
||||
"pixel art grass texture tile, top down, seamless",
|
||||
size=64, engine="nearest", palette="pico-8", 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 -> downscale -> cuantizar. Usa `engine="pixeloe"` para
|
||||
personajes/criaturas/iconos con silueta (conserva el contorno) y
|
||||
`engine="nearest"` para tiles/texturas/fondos sin contorno (mas barato, CPU puro).
|
||||
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.
|
||||
`fill_frame=True` (default) empuja al sujeto a llenar el frame; aun asi, para
|
||||
sprites conviene un subject que pida "full body, centered".
|
||||
- No reintenta el sampler: para mejor toma, varia `seed`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- 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,312 @@
|
||||
"""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.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,
|
||||
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.
|
||||
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.
|
||||
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
|
||||
- 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,
|
||||
"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, filename_prefix=f"{filename_prefix}_base",
|
||||
**gen_kwargs,
|
||||
)
|
||||
else:
|
||||
workflow = comfyui_build_pixelart_workflow(
|
||||
positive, negative, seed=seed,
|
||||
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 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(
|
||||
base_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).
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(base_path) as src:
|
||||
small = src.convert("RGB").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 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,
|
||||
)
|
||||
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["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:
|
||||
up = fin.convert("RGB").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 opcional de la base y del intermedio.
|
||||
try:
|
||||
os.remove(mid_path)
|
||||
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, side view, game sprite",
|
||||
size=64, colors=16, engine="pixeloe", seed=42,
|
||||
dest_dir="/tmp/comfy_pixelart_real",
|
||||
)
|
||||
print(json.dumps(res, indent=2))
|
||||
Reference in New Issue
Block a user