feat(ml): panel multi-juez comfyui-judge (estetico + CLIP + LLM-vision)

Cuatro funciones impuras + pagina madre del grupo comfyui-judge, el gate
objetivo de calidad de imagen para tests/DoD y el bucle de mejora de skills:

- comfyui_score_aesthetic: estetico LAION-V2 (head MLP sobre CLIP ViT-L/14),
  subproceso al venv ComfyUI (torch+open_clip).
- comfyui_score_clip_alignment: fidelidad prompt-imagen via similitud coseno CLIP.
- comfyui_critique_image_llm: critica LLM-vision (compone ask_llm_vision), JSON
  verdict+score+reasons.
- comfyui_judge_image: agregadora, vota mayoria good/bad; degrada si un juez cae.

QuickGELU (ViT-L-14-quickgelu/openai) obligatorio: sin el, los embeddings se
degradan y el ranking de fidelidad se invierte en silencio.

Validado e2e sobre imagenes reales: golden 3 votos coherentes, asserts relativos
(nitida>ruido, alineado>desalineado), split 2-1 respeta mayoria en ambos sentidos,
degradacion ante 429/model invalido/path invalido sin crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 14:54:32 +02:00
parent 70d541fca9
commit 974cc06bc7
10 changed files with 965 additions and 0 deletions
@@ -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": <numero 0-10>, "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:]))