File size: 7,257 Bytes
df8b9cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"""
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

    # Free the GPU for ComfyUI (stop the LLM).
    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 = {}  # (label, seed) -> filename or None
    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

    # Markdown contact sheet: rows = sampler, cols = seed
    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"![{f}]({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())