diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 1bb244b5..40595c4e 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -69,6 +69,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` | | [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay | | [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` | +| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct | | [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot | ## Como anadir grupo diff --git a/docs/capabilities/comfyui-judge.md b/docs/capabilities/comfyui-judge.md new file mode 100644 index 00000000..61edc07a --- /dev/null +++ b/docs/capabilities/comfyui-judge.md @@ -0,0 +1,88 @@ +# comfyui-judge — panel multi-juez de calidad de imagen + +El "modelo adversario" del pipeline ComfyUI: el sistema que distingue **producto bueno de +malo** de forma objetiva. Tres jueces independientes puntúan/critican una imagen y un +agregador vota por mayoría. Es el **gate objetivo** que consumen los tests, los contratos +DoD y el bucle de mejora de skills (grupo `comfyui-skill`): un skill no está "hecho" hasta +que la imagen que produce pasa el panel (`verdict == 'good'`). + +## Funciones del grupo + +| ID | Firma corta | Qué hace | +|---|---|---| +| `comfyui_score_aesthetic_py_ml` | `score_aesthetic(image_path) -> {ok, score_0_10}` | Calidad estética LAION-V2 (head MLP sobre CLIP ViT-L/14). Subproceso al venv ComfyUI. Barato, determinista, sin API. | +| `comfyui_score_clip_alignment_py_ml` | `score_clip_alignment(image_path, prompt) -> {ok, score_0_1}` | Fidelidad prompt↔imagen (similitud coseno CLIP). Subproceso al venv ComfyUI. | +| `comfyui_critique_image_llm_py_ml` | `critique_image_llm(image_path, prompt) -> {ok, verdict, score_0_10, reasons}` | Crítica de un LLM-vision (artefactos, anatomía, watermarks). Compone `ask_llm_vision` (claude-direct). Cuesta API. | +| `comfyui_judge_image_py_ml` | `judge_image(image_path, prompt) -> {ok, verdict, score, votes, reasons}` | **Agregadora.** Llama a los 3, vota good/bad por mayoría, agrega razones. Degrada si un juez cae. | + +Los tres jueces son ortogonales a propósito: el estético mide *belleza*, el de fidelidad mide +*que sea lo pedido*, y el crítico LLM ve *defectos finos* que un score global no penaliza. Un +único score se engaña fácil; tres votos independientes, no. + +## Ejemplo canónico (end-to-end) + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from ml.comfyui_judge_image import comfyui_judge_image + +img = os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png") +prompt = "a majestic lion standing on rocks at sunset, photorealistic" + +res = comfyui_judge_image(img, prompt) +print(res["verdict"], round(res["score"], 2), res["votes"]) +# good 7.1 {'aesthetic': 'good', 'clip': 'good', 'llm': 'good'} + +if res["verdict"] != "good": + for r in res["reasons"]: + print(" -", r) # feedback accionable para mejorar el skill +``` + +Jueces sueltos (cuando solo quieres una dimensión): + +```bash +# estético (sin API, rápido) +python/.venv/bin/python3 python/functions/ml/comfyui_score_aesthetic.py ~/ComfyUI/output/comfy_sdxl_00001_.png +# fidelidad +python/.venv/bin/python3 python/functions/ml/comfyui_score_clip_alignment.py ~/ComfyUI/output/comfy_sdxl_00001_.png "a lion at sunset" +``` + +## Cómo se enchufa a tests / DoD como gate objetivo + +- **e2e_check de un skill ComfyUI**: declarar en el `app.md` (o en el contrato del skill) un + check que genere la imagen y exija `comfyui_judge_image(img, prompt)["verdict"] == "good"`. + Es la cláusula golden: producto = imagen que el panel aprueba, no "el workflow no petó". +- **Bucle de mejora** (grupo `comfyui-skill`): tras generar, juzgar; si `verdict == 'bad'`, + las `reasons` del juez crítico indican qué corregir (más steps, otro sampler, fix de prompt) + y se re-genera. Convergencia = el panel aprueba. +- **Selección de la mejor de N**: ejecutar el panel sobre cada candidata y rankear por `score` + (o filtrar por `verdict == 'good'`). + +Umbrales por defecto (ajustables): estético `>= 6.0` → good; fidelidad `>= 0.24` → good; el +crítico da su propio verdict. Veredicto final = **mayoría** de los votos vivos; empate → `bad`. + +## Fronteras (qué NO cubre) + +- **No genera imágenes.** Eso es el grupo `comfyui` (build_*_workflow + submit + wait + fetch). + Este grupo solo *juzga* una imagen ya producida. +- **No es un detector forense de IA** ni un clasificador NSFW/seguridad: juzga *calidad de + producto*, no procedencia ni políticas de contenido. +- **No corre en el venv del registry.** Los jueces estético/fidelidad necesitan torch + + open_clip, que viven en `~/ComfyUI/.venv`; se invocan por subproceso. El crítico necesita la + API Anthropic (claude-direct). +- **No persiste resultados.** Devuelve dicts en memoria; persistir veredictos (operations.db, + e2e_runs) es responsabilidad del consumidor. + +## Prerequisitos + +- venv de ComfyUI con torch + open_clip 3.x: `~/ComfyUI/.venv/bin/python3`. +- Modelo estético LAION en `/mnt/2tb/comfyui_models/aesthetic/sac+logos+ava1-l14-linearMSE.pth`. +- CLIP ViT-L-14-quickgelu (pretrained openai) cacheado (se descarga la 1ª vez, ~900 MB). +- Token OAuth de Claude (claude-direct) para el juez crítico — lo resuelve `ask_llm_vision`. + +## Notas + +- **QuickGELU** es obligatorio en CLIP (`ViT-L-14-quickgelu`/`openai`): sin él los embeddings + se degradan en silencio y tanto el score estético como el ranking de fidelidad se desvirtúan. +- El panel **degrada con gracia**: si un juez cae (p.ej. el LLM en HTTP 429), vota con los + demás y lo anota; solo falla si caen los tres. diff --git a/python/functions/ml/comfyui_critique_image_llm.md b/python/functions/ml/comfyui_critique_image_llm.md new file mode 100644 index 00000000..1f06cf03 --- /dev/null +++ b/python/functions/ml/comfyui_critique_image_llm.md @@ -0,0 +1,74 @@ +--- +name: comfyui_critique_image_llm +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_critique_image_llm(image_path: str, prompt: str, *, model: str = 'claude-opus-4-8', max_tokens: int = 1024, token: str = '') -> dict" +description: "Critica de un LLM-vision sobre una imagen generada: detecta artefactos, anatomia rota, texto ilegible, watermarks, incoherencias de composicion. Tercer juez del panel comfyui-judge. Compone ask_llm_vision (claude-direct, API directa) con un system prompt que obliga a devolver JSON {verdict good|bad, score 0-10, reasons}. Impura: red (API Anthropic) + lectura de imagen." +tags: [comfyui-judge, ml, llm, vision, critique, scoring] +uses_functions: [ask_llm_vision_py_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: image_path + desc: "Ruta a la imagen en disco local." + - name: prompt + desc: "Prompt original de la generacion (contexto para juzgar la fidelidad). Puede ir vacio si solo se evalua calidad intrinseca." + - name: model + desc: "id del modelo Anthropic con vision. Default 'claude-opus-4-8'. keyword-only." + - name: max_tokens + desc: "Maximo de tokens de la respuesta. keyword-only." + - 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: "" +file_path: "python/functions/ml/comfyui_critique_image_llm.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from ml.comfyui_critique_image_llm import comfyui_critique_image_llm + +img = os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png") +res = comfyui_critique_image_llm(img, "a majestic lion standing on rocks at sunset, photorealistic") +print(res) +# {'ok': True, 'verdict': 'good', 'score_0_10': 8.0, +# 'reasons': ['anatomia del leon correcta', 'iluminacion coherente con el atardecer'], 'error': ''} +``` + +CLI directa: + +```bash +python/.venv/bin/python3 python/functions/ml/comfyui_critique_image_llm.py ~/ComfyUI/output/comfy_sdxl_00001_.png "a majestic lion at sunset" +``` + +## Cuando usarla + +- Cuando los scores numericos (estetico/CLIP) no bastan: el LLM ve defectos finos que el + score global no penaliza (dedos de mas, ojos asimetricos, texto basura, watermarks). +- Como el juez "humano" de `comfyui_judge_image`, para desempatar good/bad. +- Para obtener razones LEGIBLES de por que una imagen es mala (feedback accionable al skill). + +## Gotchas + +- **Cuesta tokens de API.** Cada llamada va a api.anthropic.com (claude-direct). Usar con + mesura; en validacion, pocas llamadas. +- **Rate limit (HTTP 429).** Si el rate limit compartido esta saturado, devuelve + `ok=False` con el error 429 — no crashea. El panel `comfyui_judge_image` sigue votando con + los otros dos jueces. Reintentar mas tarde o bajar a un modelo con cuota (haiku). +- **El modelo puede no devolver JSON limpio.** Se extrae el primer objeto `{...}` (tolera + fences ```json). Si no hay JSON parseable, `ok=False`. +- **verdict conservador.** Si el modelo responde algo ambiguo (ni 'good' ni 'bad'), se + normaliza a 'bad' (ante la duda, no es producto). +- **NUNCA `claude -p`.** Respeta la regla `llm_invocation`: solo claude-direct via + ask_llm_vision (arranque 0, API directa). diff --git a/python/functions/ml/comfyui_critique_image_llm.py b/python/functions/ml/comfyui_critique_image_llm.py new file mode 100644 index 00000000..fb6f3c25 --- /dev/null +++ b/python/functions/ml/comfyui_critique_image_llm.py @@ -0,0 +1,121 @@ +"""comfyui_critique_image_llm — critica de un LLM-vision sobre una imagen generada. + +Tercer juez del panel `comfyui-judge`: el criterio "humano" que detecta lo que los +scores numericos no ven — artefactos, anatomia rota, manos/dedos mal, texto ilegible, +composicion incoherente, watermarks. Compone `ask_llm_vision` (grupo claude-direct, +API directa de Anthropic) con un system prompt que obliga al modelo a devolver un +veredicto estructurado JSON: good/bad + score 0-10 + lista de razones. + +Impura: red (API Anthropic) + lectura de la imagen. Respeta la regla `llm_invocation`: +SIEMPRE via claude-direct (`ask_llm_vision`), NUNCA `claude -p`. Cada llamada cuesta +tokens de la API; usar con mesura. +""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "core")) + +from ask_llm_vision import ask_llm_vision # noqa: E402 + +DEFAULT_MODEL = "claude-opus-4-8" + +_SYSTEM = ( + "Eres un juez experto de calidad de imagenes generadas por IA. Evaluas con dureza " + "y objetividad: artefactos visuales, anatomia (manos, dedos, ojos, simetria), " + "coherencia de la composicion, fidelidad al prompt, texto ilegible, watermarks. " + "Respondes SIEMPRE y SOLO con un objeto JSON valido, sin texto adicional ni " + "markdown, con esta forma exacta: " + '{"verdict": "good"|"bad", "score": , "reasons": ["razon corta", ...]}. ' + "verdict='good' si la imagen es un producto usable y sin defectos graves; 'bad' si " + "tiene artefactos, anatomia rota o no cumple el prompt. score: 0=basura, 10=perfecta. " + "reasons: 1-4 frases cortas que justifican el veredicto." +) + + +def _extract_json(text: str) -> dict: + """Extrae el primer objeto JSON del texto del modelo (tolera fences ```json).""" + fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) + candidate = fenced.group(1) if fenced else None + if candidate is None: + brace = re.search(r"\{.*\}", text, re.DOTALL) + candidate = brace.group(0) if brace else None + if candidate is None: + raise ValueError("no se encontro objeto JSON en la respuesta") + return json.loads(candidate) + + +def comfyui_critique_image_llm( + image_path: str, + prompt: str, + *, + model: str = DEFAULT_MODEL, + max_tokens: int = 1024, + token: str = "", +) -> dict: + """Pide a un LLM-vision que critique una imagen y devuelve un veredicto estructurado. + + Args: + image_path: ruta a la imagen en disco local. + prompt: el prompt original con el que se genero la imagen (contexto para juzgar + la fidelidad). Puede ir vacio si solo se evalua calidad intrinseca. + model: id del modelo Anthropic con vision. Default "claude-opus-4-8". keyword-only. + max_tokens: maximo de tokens de la respuesta. keyword-only. + token: token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only. + + Returns: + dict ``{ok, verdict, score_0_10, reasons, error}``. En exito ``ok=True``, + ``verdict`` es '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`` describiendo la causa. Nunca lanza excepcion. + """ + user_prompt = ( + f"Prompt original de la generacion: {prompt!r}.\n" + if prompt and prompt.strip() + else "No se aporto el prompt original; evalua la calidad intrinseca.\n" + ) + "Evalua esta imagen y responde solo con el JSON pedido." + + res = ask_llm_vision( + user_prompt, image_path, + model=model, system=_SYSTEM, max_tokens=max_tokens, token=token, + ) + if not res.get("ok"): + return {"ok": False, "verdict": "", "score_0_10": 0.0, "reasons": [], + "error": res.get("error", "ask_llm_vision fallo")} + + try: + data = _extract_json(res["text"]) + except (ValueError, json.JSONDecodeError) as exc: + return {"ok": False, "verdict": "", "score_0_10": 0.0, "reasons": [], + "error": f"respuesta no parseable: {exc}; texto={res['text'][:200]!r}"} + + verdict = str(data.get("verdict", "")).strip().lower() + if verdict not in ("good", "bad"): + verdict = "bad" # conservador ante respuesta ambigua + try: + score = float(data.get("score", 0.0)) + except (TypeError, ValueError): + score = 0.0 + reasons = data.get("reasons", []) + if not isinstance(reasons, list): + reasons = [str(reasons)] + reasons = [str(r) for r in reasons] + + return {"ok": True, "verdict": verdict, "score_0_10": score, + "reasons": reasons, "error": ""} + + +def _main(argv): + if len(argv) < 1: + sys.stderr.write('uso: comfyui_critique_image_llm IMAGEN ["prompt original"]\n') + return 2 + image_path = argv[0] + prompt = " ".join(argv[1:]) + res = comfyui_critique_image_llm(image_path, prompt) + print(json.dumps(res, indent=2, ensure_ascii=False)) + return 0 if res["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/python/functions/ml/comfyui_judge_image.md b/python/functions/ml/comfyui_judge_image.md new file mode 100644 index 00000000..7f7f8373 --- /dev/null +++ b/python/functions/ml/comfyui_judge_image.md @@ -0,0 +1,80 @@ +--- +name: comfyui_judge_image +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_judge_image(image_path: str, prompt: str, *, weights: dict = None, threshold: float = 6.0, clip_threshold: float = 0.24, server: str = '127.0.0.1:8188', model: str = 'claude-opus-4-8', venv_python: str = '~/ComfyUI/.venv/bin/python3') -> dict" +description: "Panel multi-juez que vota good/bad la calidad de una imagen generada. Agregadora del grupo comfyui-judge: combina comfyui_score_aesthetic (estetico 0-10), comfyui_score_clip_alignment (fidelidad 0-1) y comfyui_critique_image_llm (LLM-vision good/bad). Cada juez vota; veredicto por MAYORIA. Si un juez falla, se excluye y se anota; el panel sigue con los restantes. Es el gate objetivo para tests/DoD y el bucle de mejora de skills." +tags: [comfyui-judge, ml, quality, gate, aggregator, scoring] +uses_functions: [comfyui_score_aesthetic_py_ml, comfyui_score_clip_alignment_py_ml, comfyui_critique_image_llm_py_ml] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: image_path + desc: "Ruta a la imagen en disco local." + - name: prompt + desc: "Prompt original de la generacion (usado por los jueces de fidelidad y critico)." + - name: weights + desc: "Pesos {aesthetic, clip, llm} para el score agregado; None = iguales. No afecta al voto por mayoria (1 juez = 1 voto). keyword-only." + - name: threshold + desc: "Umbral 0-10 para el voto del juez estetico (score>=threshold => good). keyword-only." + - name: clip_threshold + desc: "Umbral 0-1 para el voto de fidelidad (score>=clip_threshold => good). keyword-only." + - name: server + desc: "host:port del server ComfyUI (pasado al juez de fidelidad). keyword-only." + - name: model + desc: "Modelo Anthropic para el juez critico LLM. keyword-only." + - 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: "" +file_path: "python/functions/ml/comfyui_judge_image.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from ml.comfyui_judge_image import comfyui_judge_image + +img = os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png") +res = comfyui_judge_image(img, "a majestic lion standing on rocks at sunset, photorealistic") +print(res["verdict"], round(res["score"], 2), res["votes"]) +# good 7.1 {'aesthetic': 'good', 'clip': 'good', 'llm': 'good'} +``` + +CLI directa: + +```bash +python/.venv/bin/python3 python/functions/ml/comfyui_judge_image.py ~/ComfyUI/output/comfy_sdxl_00001_.png "a majestic lion at sunset" +``` + +## Cuando usarla + +- Como GATE objetivo de un test o DoD: "la imagen que produce este skill ComfyUI debe pasar + el panel (verdict='good')". Un voto mayoritario es mas robusto que un solo score. +- En el bucle de mejora de skills: si el panel vota 'bad', sus `reasons` dicen que arreglar. +- Para elegir la mejor de N generaciones: ejecutar el panel sobre cada una y rankear por + `score` (o exigir verdict='good'). + +## Gotchas + +- **Es la suma de las gotchas de sus tres jueces.** Necesita el venv de ComfyUI (torch + + open_clip) para estetico/fidelidad y la API Anthropic para el critico. +- **Degradacion, no fallo.** Si un juez cae (p.ej. el LLM en 429), se marca `votes[x]='failed'`, + se anota en `reasons` y el veredicto sale por mayoria de los 2 restantes. Solo `ok=False` + si los TRES fallan. +- **Empate => 'bad'.** Con 2 jueces vivos y voto 1-1 (o cualquier empate), el veredicto es + 'bad' por conservadurismo (ante la duda, no es producto). +- **El voto es 1 juez = 1 voto; los `weights` solo ponderan el `score` numerico**, no el + veredicto. Para cambiar el veredicto, ajusta `threshold`/`clip_threshold`. +- **El juez critico cuesta API.** Llamar al panel en bucle sobre muchas imagenes consume + tokens; considerar saltarse el LLM (futuro flag) para barridos grandes. diff --git a/python/functions/ml/comfyui_judge_image.py b/python/functions/ml/comfyui_judge_image.py new file mode 100644 index 00000000..447b003c --- /dev/null +++ b/python/functions/ml/comfyui_judge_image.py @@ -0,0 +1,138 @@ +"""comfyui_judge_image — panel multi-juez: vota good/bad la calidad de una imagen. + +Agregadora del grupo `comfyui-judge`. Es el "modelo adversario" que distingue producto +bueno de malo combinando tres jueces independientes: + + - estetico (`comfyui_score_aesthetic`): calidad visual LAION-V2, 0-10 + - fidelidad (`comfyui_score_clip_alignment`): parecido al prompt via CLIP, 0-1 + - critico (`comfyui_critique_image_llm`): veredicto de un LLM-vision, good/bad + 0-10 + +Cada juez emite un voto good/bad; el veredicto final es por MAYORIA. El score agregado es +la media (ponderable) de los tres normalizados a 0-10. Si un juez falla, se excluye del +voto y se anota: el panel sigue votando con los restantes (no crashea). Solo devuelve +``ok=False`` si los tres jueces fallan. + +Impura: compone tres funciones impuras (subprocesos torch + API Anthropic). +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from comfyui_score_aesthetic import comfyui_score_aesthetic # noqa: E402 +from comfyui_score_clip_alignment import comfyui_score_clip_alignment # noqa: E402 +from comfyui_critique_image_llm import comfyui_critique_image_llm # noqa: E402 + +DEFAULT_WEIGHTS = {"aesthetic": 1.0, "clip": 1.0, "llm": 1.0} + + +def comfyui_judge_image( + image_path: str, + prompt: str, + *, + weights: dict = None, + threshold: float = 6.0, + clip_threshold: float = 0.24, + server: str = "127.0.0.1:8188", + model: str = "claude-opus-4-8", + venv_python: str = "~/ComfyUI/.venv/bin/python3", +) -> dict: + """Juzga una imagen con el panel de tres jueces y devuelve el veredicto por mayoria. + + Args: + image_path: ruta a la imagen en disco local. + prompt: prompt original de la generacion (usado por fidelidad y critico). + weights: pesos {aesthetic, clip, llm} para el score agregado; None = iguales. + No afecta al voto por mayoria (1 juez = 1 voto). keyword-only. + threshold: umbral 0-10 para el voto del juez estetico (score>=threshold => good). + keyword-only. + clip_threshold: umbral 0-1 para el voto de fidelidad (score>=clip_threshold => good). + keyword-only. + server: host:port del server ComfyUI (pasado a fidelidad). keyword-only. + model: modelo Anthropic para el juez critico. keyword-only. + venv_python: python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only. + + Returns: + dict ``{ok, verdict, score, votes, reasons, error, details}``. + ``verdict`` 'good'|'bad' por mayoria de votos; ``score`` media ponderada 0-10 de los + jueces que respondieron; ``votes`` = {clip, aesthetic, llm} cada uno 'good'|'bad'| + 'failed'; ``reasons`` agrega las razones del critico + notas de jueces caidos; + ``details`` lleva el dict crudo de cada juez. ``ok=False`` solo si los tres fallan. + """ + w = dict(DEFAULT_WEIGHTS) + if weights: + w.update({k: float(v) for k, v in weights.items() if k in w}) + + aes = comfyui_score_aesthetic(image_path, venv_python=venv_python) + clip = comfyui_score_clip_alignment(image_path, prompt, server=server, + venv_python=venv_python) + llm = comfyui_critique_image_llm(image_path, prompt, model=model) + + votes = {} + reasons = [] + weighted = [] # (peso, score_0_10) + + # Juez estetico + if aes.get("ok"): + good = aes["score_0_10"] >= threshold + votes["aesthetic"] = "good" if good else "bad" + weighted.append((w["aesthetic"], aes["score_0_10"])) + reasons.append(f"estetico={aes['score_0_10']:.2f}/10 ({votes['aesthetic']})") + else: + votes["aesthetic"] = "failed" + reasons.append(f"estetico FALLO: {aes.get('error', '')}") + + # Juez de fidelidad + if clip.get("ok"): + good = clip["score_0_1"] >= clip_threshold + votes["clip"] = "good" if good else "bad" + weighted.append((w["clip"], clip["score_0_1"] * 10.0)) + reasons.append(f"fidelidad={clip['score_0_1']:.3f} ({votes['clip']})") + else: + votes["clip"] = "failed" + reasons.append(f"fidelidad FALLO: {clip.get('error', '')}") + + # Juez critico LLM + if llm.get("ok"): + votes["llm"] = llm["verdict"] + weighted.append((w["llm"], llm["score_0_10"])) + reasons.append(f"critico={llm['score_0_10']:.1f}/10 ({llm['verdict']})") + reasons.extend(f" - {r}" for r in llm.get("reasons", [])) + else: + votes["llm"] = "failed" + reasons.append(f"critico FALLO: {llm.get('error', '')}") + + n_good = sum(1 for v in votes.values() if v == "good") + n_bad = sum(1 for v in votes.values() if v == "bad") + n_alive = n_good + n_bad + + if n_alive == 0: + return {"ok": False, "verdict": "", "score": 0.0, "votes": votes, + "reasons": reasons, "error": "los tres jueces fallaron", + "details": {"aesthetic": aes, "clip": clip, "llm": llm}} + + # Mayoria; empate => 'bad' (conservador: ante la duda, no es producto). + verdict = "good" if n_good > n_bad else "bad" + + tot_w = sum(pw for pw, _ in weighted) + score = sum(pw * s for pw, s in weighted) / tot_w if tot_w else 0.0 + + return {"ok": True, "verdict": verdict, "score": score, "votes": votes, + "reasons": reasons, "error": "", + "details": {"aesthetic": aes, "clip": clip, "llm": llm}} + + +def _main(argv): + if len(argv) < 1: + sys.stderr.write('uso: comfyui_judge_image IMAGEN ["prompt original"]\n') + return 2 + image_path = argv[0] + prompt = " ".join(argv[1:]) + res = comfyui_judge_image(image_path, prompt) + print(json.dumps(res, indent=2, ensure_ascii=False)) + return 0 if res["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/python/functions/ml/comfyui_score_aesthetic.md b/python/functions/ml/comfyui_score_aesthetic.md new file mode 100644 index 00000000..ec05c4a6 --- /dev/null +++ b/python/functions/ml/comfyui_score_aesthetic.md @@ -0,0 +1,73 @@ +--- +name: comfyui_score_aesthetic +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_score_aesthetic(image_path: str, *, model_path: str = '/mnt/2tb/comfyui_models/aesthetic/sac+logos+ava1-l14-linearMSE.pth', venv_python: str = '~/ComfyUI/.venv/bin/python3', device: str = 'auto', timeout: float = 180.0) -> dict" +description: "Puntuacion estetica LAION-V2 (0-10) de una imagen. Juez objetivo del panel comfyui-judge: head MLP lineal sobre embeddings CLIP ViT-L/14 normalizados (modelo sac+logos+ava1-l14-linearMSE). Impura: delega la inferencia a un subproceso contra el python del venv de ComfyUI (torch + open_clip) porque el venv del registry no tiene torch." +tags: [comfyui-judge, ml, aesthetic, clip, quality, scoring] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: image_path + desc: "Ruta a la imagen (PNG/JPG/WebP) en disco local." + - name: model_path + desc: "Ruta al .pth del head estetico LAION (sac+logos+ava1-l14-linearMSE). keyword-only." + - name: venv_python + desc: "Python del venv de ComfyUI con torch + open_clip; se expande '~'. La inferencia corre ahi por subproceso porque el venv del registry no tiene torch. keyword-only." + - name: device + desc: "'auto' (cuda si hay, si no cpu), 'cuda' o 'cpu'. keyword-only." + - 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: "" +file_path: "python/functions/ml/comfyui_score_aesthetic.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from ml.comfyui_score_aesthetic import comfyui_score_aesthetic + +res = comfyui_score_aesthetic(os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png")) +print(res) +# {'ok': True, 'score_0_10': 6.536..., 'error': ''} +``` + +CLI directa: + +```bash +python/.venv/bin/python3 python/functions/ml/comfyui_score_aesthetic.py ~/ComfyUI/output/comfy_sdxl_00001_.png +``` + +## Cuando usarla + +- Cuando necesites una nota objetiva de calidad visual de una imagen generada, sin pasar + por un LLM (barato, determinista, sin coste de API). +- Como uno de los tres jueces de `comfyui_judge_image`, o suelta para comparar dos salidas + de un skill ComfyUI y quedarte con la mejor. +- Antes de promocionar una imagen a "producto": un score bajo (~<5) es señal de descarte. + +## Gotchas + +- **No corre en el venv del registry.** `python/.venv` no tiene torch; la inferencia se + ejecuta por subproceso contra `~/ComfyUI/.venv/bin/python3`. Si ese python no existe o no + tiene `open_clip`/`torch`, devuelve `ok=False`. +- **QuickGELU obligatorio.** El modelo es `ViT-L-14-quickgelu` pretrained `openai`. Crear + CLIP sin quick_gelu degrada los embeddings y desvirtua el score (mismatch silencioso). +- **Primera llamada lenta.** Si CLIP ViT-L/14 no esta cacheado, la primera ejecucion lo + descarga (~900 MB) y puede acercarse al `timeout`. Cacheado, ~3-6 s en GPU. +- **Score absoluto poco interpretable.** Util sobre todo como medida RELATIVA (A vs B) o con + umbral calibrado (~6.0 separa decente de mediocre en este modelo). Imagenes de ruido caen + a ~3-4; fotos buenas ~6-7. +- **Necesita el .pth** en `/mnt/2tb/comfyui_models/aesthetic/`; si falta, `ok=False`. diff --git a/python/functions/ml/comfyui_score_aesthetic.py b/python/functions/ml/comfyui_score_aesthetic.py new file mode 100644 index 00000000..bba26e96 --- /dev/null +++ b/python/functions/ml/comfyui_score_aesthetic.py @@ -0,0 +1,173 @@ +"""comfyui_score_aesthetic — puntuacion estetica LAION-V2 (0-10) de una imagen. + +Juez objetivo de calidad del panel `comfyui-judge`. Usa el predictor estetico +LAION "improved aesthetic" (head MLP lineal sobre embeddings CLIP ViT-L/14 +normalizados): el .pth `sac+logos+ava1-l14-linearMSE.pth` entrenado sobre AVA + +SAC + LOGOS, que devuelve un score continuo ~1-10 donde mayor = mas atractiva. + +Impura: el scoring necesita torch + open_clip + CUDA, que NO estan en el venv del +registry (`python/.venv`). Por eso esta funcion delega la inferencia a un SUBPROCESO +contra el python del venv de ComfyUI (`~/ComfyUI/.venv/bin/python3`), que si los +tiene. El subproceso reconstruye el MLP head, carga CLIP ViT-L-14-quickgelu +(pretrained openai, ya cacheado), embebe la imagen, la normaliza L2 y aplica el head. +La funcion captura un JSON marcado en stdout y nunca lanza excepcion. +""" +import json +import os +import subprocess +import sys + +DEFAULT_MODEL_PATH = "/mnt/2tb/comfyui_models/aesthetic/sac+logos+ava1-l14-linearMSE.pth" +DEFAULT_VENV_PYTHON = "~/ComfyUI/.venv/bin/python3" + +# Script autonomo ejecutado por el python del venv de ComfyUI (tiene torch + open_clip). +# Reconstruye el MLP head LAION-V2 y puntua la imagen. Emite el resultado en una +# linea marcada con _MARKER para aislarlo de warnings / barras de descarga. +_MARKER = "__AESTHETIC_RESULT__" +_INFER_SCRIPT = r''' +import json, sys +import numpy as np +import torch +import torch.nn as nn +import open_clip +from PIL import Image + +MARKER = "__AESTHETIC_RESULT__" + + +class AestheticMLP(nn.Module): + def __init__(self, input_size=768): + super().__init__() + self.layers = nn.Sequential( + nn.Linear(input_size, 1024), + nn.Dropout(0.2), + nn.Linear(1024, 128), + nn.Dropout(0.2), + nn.Linear(128, 64), + nn.Dropout(0.1), + nn.Linear(64, 16), + nn.Linear(16, 1), + ) + + def forward(self, x): + return self.layers(x) + + +def _l2norm(a, axis=-1): + l2 = np.atleast_1d(np.linalg.norm(a, 2, axis)) + l2[l2 == 0] = 1 + return a / np.expand_dims(l2, axis) + + +def run(image_path, model_path, device): + if device == "auto": + device = "cuda" if torch.cuda.is_available() else "cpu" + model, _, preprocess = open_clip.create_model_and_transforms( + "ViT-L-14-quickgelu", pretrained="openai") + model = model.to(device).eval() + head = AestheticMLP(768) + head.load_state_dict(torch.load(model_path, map_location="cpu")) + head = head.to(device).eval() + + img = preprocess(Image.open(image_path).convert("RGB")).unsqueeze(0).to(device) + with torch.no_grad(): + feats = model.encode_image(img).cpu().numpy() + norm = _l2norm(feats) + with torch.no_grad(): + pred = head(torch.from_numpy(norm).to(device).float()) + return float(pred.cpu().numpy()[0][0]) + + +if __name__ == "__main__": + image_path, model_path, device = sys.argv[1], sys.argv[2], sys.argv[3] + try: + score = run(image_path, model_path, device) + print(MARKER + json.dumps({"ok": True, "score_0_10": score, "error": ""})) + except Exception as exc: + print(MARKER + json.dumps({"ok": False, "score_0_10": 0.0, "error": repr(exc)})) +''' + + +def comfyui_score_aesthetic( + image_path: str, + *, + model_path: str = DEFAULT_MODEL_PATH, + venv_python: str = DEFAULT_VENV_PYTHON, + device: str = "auto", + timeout: float = 180.0, +) -> dict: + """Puntua la calidad estetica (0-10) de una imagen con el predictor LAION-V2. + + Args: + image_path: ruta a la imagen (PNG/JPG/WebP) en disco local. + model_path: ruta al .pth del head estetico LAION (sac+logos+ava1-l14-linearMSE). + keyword-only. + venv_python: python del venv de ComfyUI con torch + open_clip; se expande `~`. + La inferencia corre ahi por subproceso porque el venv del registry no tiene + torch. keyword-only. + device: "auto" (cuda si hay, si no cpu), "cuda" o "cpu". keyword-only. + timeout: timeout del subproceso en segundos (la primera vez puede descargar CLIP). + keyword-only. + + Returns: + 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. + """ + img = os.path.expanduser(image_path) + if not os.path.isfile(img): + return {"ok": False, "score_0_10": 0.0, "error": f"imagen no encontrada: {img}"} + + py = os.path.expanduser(venv_python) + if not os.path.isfile(py): + return {"ok": False, "score_0_10": 0.0, + "error": f"python del venv ComfyUI no encontrado: {py}"} + + mp = os.path.expanduser(model_path) + if not os.path.isfile(mp): + return {"ok": False, "score_0_10": 0.0, + "error": f"modelo estetico no encontrado: {mp}"} + + try: + proc = subprocess.run( + [py, "-c", _INFER_SCRIPT, img, mp, device], + capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + return {"ok": False, "score_0_10": 0.0, + "error": f"subproceso de inferencia excedio {timeout}s"} + + for line in proc.stdout.splitlines(): + if line.startswith(_MARKER): + try: + return json.loads(line[len(_MARKER):]) + except json.JSONDecodeError as exc: + return {"ok": False, "score_0_10": 0.0, + "error": f"JSON invalido del subproceso: {exc}"} + + tail = (proc.stderr or proc.stdout or "")[-400:] + return {"ok": False, "score_0_10": 0.0, + "error": f"sin resultado del subproceso (rc={proc.returncode}): {tail}"} + + +def _main(argv): + if not argv: + sys.stderr.write("uso: comfyui_score_aesthetic IMAGEN [--device cpu|cuda]\n") + return 2 + device = "auto" + image_path = "" + i = 0 + while i < len(argv): + if argv[i] == "--device" and i + 1 < len(argv): + device = argv[i + 1] + i += 2 + else: + image_path = argv[i] + i += 1 + res = comfyui_score_aesthetic(image_path, device=device) + print(json.dumps(res, indent=2, ensure_ascii=False)) + return 0 if res["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/python/functions/ml/comfyui_score_clip_alignment.md b/python/functions/ml/comfyui_score_clip_alignment.md new file mode 100644 index 00000000..397b8737 --- /dev/null +++ b/python/functions/ml/comfyui_score_clip_alignment.md @@ -0,0 +1,76 @@ +--- +name: comfyui_score_clip_alignment +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_score_clip_alignment(image_path: str, prompt: str, *, server: str = '127.0.0.1:8188', venv_python: str = '~/ComfyUI/.venv/bin/python3', device: str = 'auto', timeout: float = 180.0) -> dict" +description: "Fidelidad prompt<->imagen via CLIP (0-1): similitud coseno entre el embedding de la imagen y el del texto con CLIP ViT-L/14. Juez de fidelidad del panel comfyui-judge. Impura: delega a un subproceso contra el python del venv de ComfyUI (torch + open_clip); calculo local, no via el server (el param server queda reservado)." +tags: [comfyui-judge, ml, clip, alignment, fidelity, scoring] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: image_path + desc: "Ruta a la imagen en disco local." + - name: prompt + desc: "Texto contra el que medir la alineacion (idealmente en ingles, como CLIP)." + - name: server + desc: "host:port del server ComfyUI. RESERVADO para un backend futuro basado en nodos CLIPVisionEncode/CLIPTextEncode; hoy el calculo es local via open_clip. keyword-only." + - name: venv_python + desc: "Python del venv de ComfyUI con torch + open_clip; se expande '~'. keyword-only." + - name: device + desc: "'auto' (cuda si hay, si no cpu), 'cuda' o 'cpu'. keyword-only." + - 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: "" +file_path: "python/functions/ml/comfyui_score_clip_alignment.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from ml.comfyui_score_clip_alignment import comfyui_score_clip_alignment + +img = os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png") +print(comfyui_score_clip_alignment(img, "a lion standing on rocks at sunset")) +# {'ok': True, 'score_0_1': 0.2818, 'error': ''} (match) +print(comfyui_score_clip_alignment(img, "a red apple on a table")) +# {'ok': True, 'score_0_1': 0.1187, 'error': ''} (mismatch -> mas bajo) +``` + +CLI directa: + +```bash +python/.venv/bin/python3 python/functions/ml/comfyui_score_clip_alignment.py ~/ComfyUI/output/comfy_sdxl_00001_.png "a lion standing on rocks at sunset" +``` + +## Cuando usarla + +- Cuando necesites comprobar que una imagen REALMENTE muestra lo que el prompt pedia + (fidelidad), no solo que es bonita. Complementa a `comfyui_score_aesthetic`. +- Para elegir entre varias generaciones del mismo prompt la que mejor lo representa. +- Como uno de los tres jueces de `comfyui_judge_image`. + +## Gotchas + +- **No corre en el venv del registry.** Igual que el scorer estetico, la inferencia va por + subproceso contra `~/ComfyUI/.venv/bin/python3` (torch + open_clip). +- **QuickGELU obligatorio** (`ViT-L-14-quickgelu` pretrained `openai`). Sin quick_gelu los + embeddings se degradan y el ranking de prompts se invierte de forma silenciosa. +- **El score es similitud coseno, no una probabilidad.** Rangos tipicos: ~0.28-0.35 buen + match, ~0.10-0.18 contenido distinto. Usa un umbral (~0.24) o compara RELATIVAMENTE; no + esperes valores cercanos a 1.0 ni para un match perfecto. +- **Prompts en ingles.** CLIP openai esta entrenado en ingles; prompts en otros idiomas dan + scores mas bajos y menos fiables. +- **`server` no se usa hoy.** El calculo es local; el parametro queda como contrato para un + backend server futuro. diff --git a/python/functions/ml/comfyui_score_clip_alignment.py b/python/functions/ml/comfyui_score_clip_alignment.py new file mode 100644 index 00000000..d8bdcf52 --- /dev/null +++ b/python/functions/ml/comfyui_score_clip_alignment.py @@ -0,0 +1,141 @@ +"""comfyui_score_clip_alignment — fidelidad prompt<->imagen via CLIP (0-1). + +Juez de fidelidad del panel `comfyui-judge`: mide cuanto se parece el contenido de +una imagen al prompt que se pidio, usando el espacio compartido de CLIP. Embebe la +imagen y el texto con CLIP ViT-L/14, los normaliza y devuelve la similitud coseno +(clamp a 0-1). Valores tipicos: ~0.28-0.35 buen match, ~0.10-0.18 contenido distinto. + +Impura: la inferencia necesita torch + open_clip, que NO estan en el venv del registry +(`python/.venv`). Por eso delega a un SUBPROCESO contra el python del venv de ComfyUI +(`~/ComfyUI/.venv/bin/python3`). Se calcula localmente con open_clip (no via el server +ComfyUI): es el camino mas simple y fiable, reusa el mismo CLIP que el scorer estetico +y no depende del estado del grafo del server. El parametro `server` queda reservado para +un backend futuro basado en los nodos CLIPVisionEncode/CLIPTextEncode (ver Gotchas). +""" +import json +import os +import subprocess +import sys + +DEFAULT_VENV_PYTHON = "~/ComfyUI/.venv/bin/python3" + +_MARKER = "__CLIP_RESULT__" +_INFER_SCRIPT = r''' +import json, sys +import torch +import open_clip +from PIL import Image + +MARKER = "__CLIP_RESULT__" + + +def run(image_path, prompt, device): + if device == "auto": + device = "cuda" if torch.cuda.is_available() else "cpu" + model, _, preprocess = open_clip.create_model_and_transforms( + "ViT-L-14-quickgelu", pretrained="openai") + model = model.to(device).eval() + tokenizer = open_clip.get_tokenizer("ViT-L-14-quickgelu") + + img = preprocess(Image.open(image_path).convert("RGB")).unsqueeze(0).to(device) + txt = tokenizer([prompt]).to(device) + with torch.no_grad(): + imf = model.encode_image(img) + tf = model.encode_text(txt) + imf = imf / imf.norm(dim=-1, keepdim=True) + tf = tf / tf.norm(dim=-1, keepdim=True) + cos = float((imf @ tf.T).cpu().numpy()[0][0]) + return max(0.0, min(1.0, cos)) + + +if __name__ == "__main__": + image_path, prompt, device = sys.argv[1], sys.argv[2], sys.argv[3] + try: + score = run(image_path, prompt, device) + print(MARKER + json.dumps({"ok": True, "score_0_1": score, "error": ""})) + except Exception as exc: + print(MARKER + json.dumps({"ok": False, "score_0_1": 0.0, "error": repr(exc)})) +''' + + +def comfyui_score_clip_alignment( + image_path: str, + prompt: str, + *, + server: str = "127.0.0.1:8188", + venv_python: str = DEFAULT_VENV_PYTHON, + device: str = "auto", + timeout: float = 180.0, +) -> dict: + """Mide la fidelidad prompt<->imagen como similitud coseno CLIP (0-1). + + Args: + image_path: ruta a la imagen en disco local. + prompt: texto contra el que medir la alineacion (idealmente en ingles, como CLIP). + server: host:port del server ComfyUI. RESERVADO para un backend futuro; hoy el + calculo es local via open_clip (ver Gotchas). keyword-only. + venv_python: python del venv de ComfyUI con torch + open_clip; se expande `~`. + keyword-only. + device: "auto" (cuda si hay, si no cpu), "cuda" o "cpu". keyword-only. + timeout: timeout del subproceso en segundos. keyword-only. + + Returns: + 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). En error ``ok=False``, + ``score_0_1=0.0`` y ``error`` describe la causa. Nunca lanza excepcion. + """ + img = os.path.expanduser(image_path) + if not os.path.isfile(img): + return {"ok": False, "score_0_1": 0.0, "error": f"imagen no encontrada: {img}"} + if not prompt or not prompt.strip(): + return {"ok": False, "score_0_1": 0.0, "error": "prompt vacio"} + + py = os.path.expanduser(venv_python) + if not os.path.isfile(py): + return {"ok": False, "score_0_1": 0.0, + "error": f"python del venv ComfyUI no encontrado: {py}"} + + try: + proc = subprocess.run( + [py, "-c", _INFER_SCRIPT, img, prompt, device], + capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + return {"ok": False, "score_0_1": 0.0, + "error": f"subproceso de inferencia excedio {timeout}s"} + + for line in proc.stdout.splitlines(): + if line.startswith(_MARKER): + try: + return json.loads(line[len(_MARKER):]) + except json.JSONDecodeError as exc: + return {"ok": False, "score_0_1": 0.0, + "error": f"JSON invalido del subproceso: {exc}"} + + tail = (proc.stderr or proc.stdout or "")[-400:] + return {"ok": False, "score_0_1": 0.0, + "error": f"sin resultado del subproceso (rc={proc.returncode}): {tail}"} + + +def _main(argv): + if len(argv) < 2: + sys.stderr.write('uso: comfyui_score_clip_alignment IMAGEN "prompt" [--device cpu|cuda]\n') + return 2 + device = "auto" + image_path = argv[0] + prompt_parts = [] + i = 1 + while i < len(argv): + if argv[i] == "--device" and i + 1 < len(argv): + device = argv[i + 1] + i += 2 + else: + prompt_parts.append(argv[i]) + i += 1 + res = comfyui_score_clip_alignment(image_path, " ".join(prompt_parts), device=device) + print(json.dumps(res, indent=2, ensure_ascii=False)) + return 0 if res["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:]))