| """
|
| sampler_ab.py — A/B test ComfyUI samplers on ONE character's base gen.
|
|
|
| Everything is held fixed (prompt, negative, seeds, steps, cfg, scheduler,
|
| resolution) EXCEPT the sampler, so any difference you see is the sampler's.
|
| Runs N seeds × each sampler and writes a markdown contact sheet.
|
|
|
| .venv/bin/python3 tools/sampler_ab.py # yuki, 3 seeds
|
| .venv/bin/python3 tools/sampler_ab.py --char sakura --tries 3
|
| .venv/bin/python3 tools/sampler_ab.py --samplers er_sde flow_euler euler
|
|
|
| Sampler resolution: the requested list is checked against ComfyUI's actual
|
| KSampler sampler_name options. If `flow_euler` isn't registered, it falls
|
| back to plain `euler` (deduped). Unknown samplers are skipped with a note.
|
|
|
| VRAM: stops the LLM first (frees the GPU for ComfyUI). Restart your LLM
|
| after, or just run app.py which manages it.
|
|
|
| Output: static/sprites/_sampler_test/<char>/<sampler>_seed<seed>.png
|
| static/sprites/_sampler_test/<char>/REPORT.md
|
| """
|
| import argparse
|
| import os
|
| import sys
|
|
|
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
| from cast_pipeline import (
|
| PRESET_CHARACTERS, SPRITE_NEGATIVE, SCHEDULER, GENERATED_DIR,
|
| UNET_NAME, CLIP_NAME, VAE_NAME,
|
| _comfy_health, _comfy_submit, _comfy_wait, _comfy_fetch_image, _session,
|
| COMFY_HOST,
|
| )
|
|
|
| TEST_DIR = os.path.join(os.path.dirname(GENERATED_DIR), "_sampler_test")
|
| STEPS, CFG, WIDTH, HEIGHT = 30, 4.0, 512, 768
|
|
|
|
|
| def _valid_samplers() -> set:
|
| """ComfyUI's actual KSampler sampler_name options."""
|
| try:
|
| obj = _session().get(f"{COMFY_HOST}/object_info/KSampler", timeout=15).json()
|
| opts = obj["KSampler"]["input"]["required"]["sampler_name"][0]
|
| return set(opts) if isinstance(opts, list) else set()
|
| except Exception as e:
|
| print(f"[ab] couldn't fetch sampler list ({e}); assuming all requested are valid")
|
| return set()
|
|
|
|
|
| def _resolve(requested, valid):
|
| """Map requested samplers onto installed ones (flow_euler→euler fallback),
|
| deduped, order-preserving. Returns [(label, sampler_name)]."""
|
| out, seen = [], set()
|
| for name in requested:
|
| if valid and name not in valid:
|
| if name == "flow_euler" and "euler" in (valid or {"euler"}):
|
| resolved, label = "euler", "flow_euler→euler (fallback)"
|
| print(f"[ab] '{name}' not installed — falling back to 'euler'")
|
| else:
|
| print(f"[ab] '{name}' not installed and no fallback — skipping")
|
| continue
|
| else:
|
| resolved, label = name, name
|
| if resolved in seen:
|
| print(f"[ab] '{resolved}' already queued — skipping duplicate ({label})")
|
| continue
|
| seen.add(resolved)
|
| out.append((label, resolved))
|
| return out
|
|
|
|
|
| def _base_workflow(prompt, negative, seed, sampler):
|
| return {
|
| "1": {"inputs": {"filename_prefix": "ab", "images": ["8", 0]}, "class_type": "SaveImage"},
|
| "8": {"inputs": {"samples": ["19", 0], "vae": ["15", 0]}, "class_type": "VAEDecode"},
|
| "11": {"inputs": {"text": prompt, "clip": ["45", 0]}, "class_type": "CLIPTextEncode"},
|
| "12": {"inputs": {"text": negative, "clip": ["45", 0]}, "class_type": "CLIPTextEncode"},
|
| "15": {"inputs": {"vae_name": VAE_NAME}, "class_type": "VAELoader"},
|
| "19": {"inputs": {
|
| "seed": seed, "steps": STEPS, "cfg": CFG,
|
| "sampler_name": sampler, "scheduler": SCHEDULER, "denoise": 1.0,
|
| "model": ["44", 0], "positive": ["11", 0], "negative": ["12", 0],
|
| "latent_image": ["28", 0],
|
| }, "class_type": "KSampler"},
|
| "28": {"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1}, "class_type": "EmptyLatentImage"},
|
| "44": {"inputs": {"unet_name": UNET_NAME}, "class_type": "UnetLoaderGGUF"},
|
| "45": {"inputs": {"clip_name": CLIP_NAME, "type": "stable_diffusion", "device": "default"}, "class_type": "CLIPLoader"},
|
| }
|
|
|
|
|
| def main() -> int:
|
| ap = argparse.ArgumentParser(description="A/B test samplers on one character's base gen")
|
| ap.add_argument("--char", default="yuki", help="preset character key (default: yuki)")
|
| ap.add_argument("--tries", type=int, default=3, help="seeds per sampler (default: 3)")
|
| ap.add_argument("--samplers", nargs="+",
|
| default=["er_sde", "flow_euler", "euler"],
|
| help="samplers to compare")
|
| args = ap.parse_args()
|
|
|
| preset = PRESET_CHARACTERS.get(args.char)
|
| if not preset:
|
| print(f"Unknown character '{args.char}'. Options: {list(PRESET_CHARACTERS)}")
|
| return 1
|
|
|
| if not _comfy_health():
|
| print(f"ComfyUI not reachable at {COMFY_HOST}. Start it first.")
|
| return 1
|
|
|
|
|
| try:
|
| from model_client import release_vram
|
| release_vram()
|
| except Exception as e:
|
| print(f"[ab] release_vram failed ({e}); continuing")
|
|
|
| samplers = _resolve(args.samplers, _valid_samplers())
|
| if not samplers:
|
| print("No usable samplers after resolution. Aborting.")
|
| return 1
|
|
|
| base_seed = preset["seed"]
|
| seeds = [base_seed + i for i in range(args.tries)]
|
| out_dir = os.path.join(TEST_DIR, args.char)
|
| os.makedirs(out_dir, exist_ok=True)
|
| prompt, negative = preset["identity_prompt"], SPRITE_NEGATIVE
|
|
|
| print(f"\n[ab] {args.char}: {len(samplers)} sampler(s) × {len(seeds)} seed(s), "
|
| f"scheduler={SCHEDULER}, steps={STEPS}, cfg={CFG}\n")
|
|
|
| grid = {}
|
| for label, sampler in samplers:
|
| for seed in seeds:
|
| tag = f"{sampler}_seed{seed}"
|
| print(f" [{label}] seed {seed}…", end=" ", flush=True)
|
| dest = os.path.join(out_dir, f"{tag}.png")
|
| try:
|
| pid = _comfy_submit(_base_workflow(prompt, negative, seed, sampler),
|
| f"ab_{args.char}_{tag}")
|
| result = _comfy_wait(pid)
|
| ok = bool(result and _comfy_fetch_image(result, dest))
|
| except Exception as e:
|
| print(f"FAILED ({e})")
|
| grid[(label, seed)] = None
|
| continue
|
| print("ok" if ok else "no output")
|
| grid[(label, seed)] = f"{tag}.png" if ok else None
|
|
|
|
|
| lines = [f"# Sampler A/B — {args.char}", "",
|
| f"Fixed: scheduler `{SCHEDULER}`, steps {STEPS}, cfg {CFG}, "
|
| f"{WIDTH}×{HEIGHT}. Only the sampler varies.", "",
|
| "| sampler | " + " | ".join(f"seed {s}" for s in seeds) + " |",
|
| "|" + "---|" * (len(seeds) + 1)]
|
| for label, _ in samplers:
|
| cells = []
|
| for s in seeds:
|
| f = grid.get((label, s))
|
| cells.append(f"" if f else "✗")
|
| lines.append(f"| **{label}** | " + " | ".join(cells) + " |")
|
| report = os.path.join(out_dir, "REPORT.md")
|
| with open(report, "w") as fp:
|
| fp.write("\n".join(lines) + "\n")
|
|
|
| print(f"\n[ab] Done. Images + REPORT.md in: {out_dir}")
|
| print("[ab] LLM was stopped — restart it (or run app.py) before playing.")
|
| return 0
|
|
|
|
|
| if __name__ == "__main__":
|
| sys.exit(main())
|
|
|