| """ |
| 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()) |
|
|