From 8e9e1e6c8a60e889dafb0f3c9456ac052c96c444 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 28 Jun 2026 15:01:37 +0200 Subject: [PATCH] feat(comfyui): pipeline comfyui_generate_until_quality (loop evaluator-optimizer) Loop tipo GAN sin entrenar: genera con un builder del registry, juzga con el panel multi-juez (comfyui_judge_image) y, si no alcanza el umbral, refina (nueva seed, mas steps/cfg, prompt corregido con el feedback del juez via ask_llm) y regenera hasta converger (verdict 'good') o agotar max_iters. Devuelve siempre la mejor candidata por score (best-of-N), nunca lanza excepcion cruda. Compone comfyui_submit_workflow + comfyui_wait_result + comfyui_fetch_output_image + comfyui_judge_image + ask_llm. Filtra kwargs por inspect.signature para ser robusto entre builders. Caso HUD verificado: itera iter0 bad -> iter1 good. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../comfyui_generate_until_quality.md | 131 +++++++ .../comfyui_generate_until_quality.py | 349 ++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 python/functions/pipelines/comfyui_generate_until_quality.md create mode 100644 python/functions/pipelines/comfyui_generate_until_quality.py diff --git a/python/functions/pipelines/comfyui_generate_until_quality.md b/python/functions/pipelines/comfyui_generate_until_quality.md new file mode 100644 index 00000000..bbcbadfe --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_until_quality.md @@ -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. diff --git a/python/functions/pipelines/comfyui_generate_until_quality.py b/python/functions/pipelines/comfyui_generate_until_quality.py new file mode 100644 index 00000000..c2743c06 --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_until_quality.py @@ -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: + + _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))