mirror of
https://github.com/vladmandic/automatic
synced 2026-08-26 15:16:01 +02:00
a3407efa81
Drives txt2img/img2img with the full per-sampler scheduler-option matrix (sigma method, prediction type, timestep spacing, beta schedule, solver order, shift, low order, thresholding, dynamic shift, rescale betas), one option at a time over a pinned baseline, and measures every case against the default: applied, inert, marginal, rejected, or fallback. Detects silent fallback via the infotext Scheduler class and annotates every saved image with its full requested configuration. --sweep runs a reduced matrix per sampler and emits an empirical capability report.
658 lines
32 KiB
Python
658 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Sampler differential tester (API).
|
|
|
|
Drives /sdapi/v1/txt2img and /sdapi/v1/img2img with the full per-sampler option matrix
|
|
against a live server: every value of every scheduler option (sigma method, prediction
|
|
type, timestep spacing, beta schedule, solver order, shift, low order, thresholding,
|
|
dynamic shift, rescale betas), one option at a time over a fully pinned baseline. Each
|
|
case is measured against the default and reported as applied / inert / marginal /
|
|
rejected / fallback. The tester pins schedulers_fallback off, so a selection the
|
|
sampler does not support fails server-side and is reported as rejected.
|
|
|
|
A fallback-semantics group asserts the sampler resolution edge cases as hard
|
|
expectations: sampler_name "Default" and an omitted sampler_name run the model default
|
|
scheduler, an unknown name is rejected by the API in both fallback modes, and invalid
|
|
selections with schedulers_fallback enabled fall back or redirect instead of failing
|
|
the request.
|
|
|
|
Modes:
|
|
--sampler "ER-SDE" deep dive: full matrix for one sampler
|
|
--sampler "ER-SDE,UniPC" deep dive for each listed sampler
|
|
--sampler all every sampler the server reports
|
|
--sweep reduced per-sampler matrix + capability report instead
|
|
of the full deep matrix
|
|
|
|
The sweep emits an empirical capability report (which knobs actually change the output
|
|
per sampler), useful for auditing declared-but-inert preset keys and capability-table
|
|
drift. The infotext Scheduler class is recorded for every case: the Sampler field only
|
|
echoes the request, so the class is the ground truth for detecting silent fallback to
|
|
the model default. Laplacian variance is reported as a sharpness proxy.
|
|
|
|
The img2img pass exercises the scheduler noise-injection path: VP models use add_noise,
|
|
flow models use scale_noise. Every img2img case returning an image is the support signal.
|
|
|
|
Requires a running server with a model loaded. Scheduler settings are passed as direct
|
|
payload fields (schedulers_sigma, schedulers_shift, ...), the same path the UI uses.
|
|
|
|
python test/test-samplers-api.py --arch flux --sampler ER-SDE
|
|
python test/test-samplers-api.py --arch sdxl --sampler all --sweep --mode txt2img
|
|
python test/test-samplers-api.py --url http://127.0.0.1:7860 --sampler "DPM++ 2M" --mode img2img --denoise 0.6
|
|
|
|
Paste the printed SUMMARY block (or the written JSON) back for interpretation. Generated
|
|
images are saved to --outdir with a banner stating endpoint/sampler/case/settings, so a
|
|
folder full of test output stays attributable; case montages are saved alongside.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import textwrap
|
|
import time
|
|
|
|
import numpy as np
|
|
import requests
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
PROMPT = "close-up portrait of an elderly fisherman, deep weathered skin texture, individual silver beard hairs, sharp catchlight in the eyes, hand-knitted wool sweater with visible fibers, soft window light"
|
|
NEG = "blurry, smooth, plastic, low detail"
|
|
|
|
# one-factor-at-a-time axes: (label prefix, payload field, values to run)
|
|
# values are the UI choice lists verbatim; sliders use representative points
|
|
AXES = [
|
|
("sigma", "schedulers_sigma", ["karras", "betas", "exponential", "lambdas", "flowmatch"]),
|
|
("pred", "schedulers_prediction_type", ["default", "epsilon", "sample", "v_prediction", "flow_prediction"]),
|
|
("spacing", "schedulers_timestep_spacing", ["linspace", "leading", "trailing"]),
|
|
("beta", "schedulers_beta_schedule", ["linear", "scaled_linear", "squaredcos_cap_v2", "sigmoid"]),
|
|
("order", "schedulers_solver_order", [1, 2, 3, 4, 5]),
|
|
("shift", "schedulers_shift", [1, 6]),
|
|
("loworder", "schedulers_use_loworder", [True, False]),
|
|
("thresholding", "schedulers_use_thresholding", [True, False]),
|
|
("dynamic", "schedulers_dynamic_shift", [True, False]),
|
|
("rescale", "schedulers_rescale_betas", [True, False]),
|
|
]
|
|
|
|
|
|
# Pin every overridable scheduler knob to its option default so the server's persisted
|
|
# config cannot leak into the un-overridden baseline; each case flips exactly one knob.
|
|
# On flow models non-FlowMatch samplers must be pinned to flow_prediction, else
|
|
# create_sampler rejects them (a Flux pipe is not "flexible" like SDXL) and silently
|
|
# restores the model default.
|
|
# schedulers_fallback must be off: with it on, create_sampler redirects plain <-> FlowMatch
|
|
# variants by name on mismatched model types, so a case would no longer test the sampler
|
|
# it names (plain ER-SDE on Flux would silently run ER-SDE FlowMatch).
|
|
def baseline_for(arch):
|
|
return {
|
|
"schedulers_sigma": "default",
|
|
"schedulers_prediction_type": "flow_prediction" if arch == "flux" else "default",
|
|
"schedulers_timestep_spacing": "default",
|
|
"schedulers_beta_schedule": "default",
|
|
"schedulers_solver_order": 0,
|
|
"schedulers_shift": 3,
|
|
"schedulers_dynamic_shift": False,
|
|
"schedulers_base_shift": 0.5,
|
|
"schedulers_max_shift": 1.15,
|
|
"schedulers_use_loworder": True,
|
|
"schedulers_use_thresholding": False,
|
|
"schedulers_rescale_betas": False,
|
|
"schedulers_beta_start": 0,
|
|
"schedulers_beta_end": 0,
|
|
"schedulers_timesteps_range": 1000,
|
|
"schedulers_timesteps": "",
|
|
"uni_pc_variant": "bh2",
|
|
"override_settings": {"schedulers_fallback": False},
|
|
}
|
|
|
|
|
|
def post(url, path, payload, timeout=900):
|
|
r = requests.post(f"{url}{path}", json=payload, timeout=timeout)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def get(url, path, timeout=60):
|
|
r = requests.get(f"{url}{path}", timeout=timeout)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def decode(b64):
|
|
raw = base64.b64decode(b64.split(',', 1)[-1])
|
|
img = Image.open(io.BytesIO(raw)).convert('RGB')
|
|
return np.asarray(img, dtype=np.float64)
|
|
|
|
|
|
def lap_var(arr):
|
|
"""Variance of the discrete Laplacian = sharpness proxy (higher is sharper)."""
|
|
g = arr.mean(axis=2)
|
|
lap = 4 * g[1:-1, 1:-1] - g[:-2, 1:-1] - g[2:, 1:-1] - g[1:-1, :-2] - g[1:-1, 2:]
|
|
return float(lap.var())
|
|
|
|
|
|
def mad(a, b):
|
|
"""Mean absolute pixel difference on a 0-255 scale; nan if shapes differ."""
|
|
if a.shape != b.shape:
|
|
return float('nan')
|
|
return float(np.abs(a - b).mean())
|
|
|
|
|
|
def norm_name(s):
|
|
return re.sub(r'[^a-z0-9]', '', s.lower())
|
|
|
|
|
|
# name qualifiers that do not appear in scheduler class names
|
|
QUALIFIER_TOKENS = {"flowmatch", "a", "sgm", "edm", "1s", "2s", "3s", "2m", "3m", "sde", "inverse", "parallel", "solver"}
|
|
|
|
|
|
def scheduler_class(info):
|
|
"""The Scheduler class that actually ran. The response info JSON exposes Processed
|
|
attributes at the top level; the scheduler class only appears inside the infotext
|
|
parameter string, so it is extracted from there."""
|
|
texts = info.get("infotexts") or []
|
|
m = re.search(r'Scheduler: (\w+)', texts[0]) if texts and texts[0] else None
|
|
return m.group(1) if m else ""
|
|
|
|
|
|
def ran_requested(requested, actual_cls):
|
|
"""Heuristic match of the requested sampler name against the Scheduler class that
|
|
actually ran. A mismatch usually means silent fallback to the model default; reported
|
|
as a warning, not a failure, since some short names cannot be matched reliably."""
|
|
if not requested or requested == "Default":
|
|
return True # any scheduler class is a correct answer for the model default
|
|
if not actual_cls:
|
|
return True
|
|
a = norm_name(actual_cls)
|
|
tokens = [t for t in re.split(r'[^a-zA-Z0-9]+', requested.lower()) if t]
|
|
family = ''.join(t for t in tokens if t not in QUALIFIER_TOKENS)
|
|
for cand in (norm_name(requested), norm_name(requested.replace("FlowMatch", "")), family):
|
|
if len(cand) >= 3 and cand in a:
|
|
return True
|
|
return bool(tokens) and len(tokens[0]) >= 3 and tokens[0] in a
|
|
|
|
|
|
def case_label(prefix, value):
|
|
if value is True:
|
|
return f"{prefix}_on"
|
|
if value is False:
|
|
return f"{prefix}_off"
|
|
return f"{prefix}_{value}"
|
|
|
|
|
|
def build_matrix(sampler, arch, all_names, deep, baseline):
|
|
"""Case list + assertions for one sampler. Cases: (label, sampler_name, payload
|
|
overrides). Assertions: (label, a, b, relation) with relation same | probe.
|
|
|
|
Each AXES value becomes one case flipping a single option over the pinned baseline,
|
|
measured against the default case as a probe. Values matching the baseline pin are
|
|
covered by the default case itself. Sweep mode keeps only default and sigma=karras.
|
|
Hard "same" assertions cover known invariants: a plain sampler matches its FlowMatch
|
|
sibling on flow models, and explicit epsilon matches default on epsilon models."""
|
|
cases = [("default", sampler, {})]
|
|
assertions = []
|
|
|
|
for prefix, field, values in AXES:
|
|
for value in values:
|
|
if value == baseline.get(field):
|
|
continue # the default case already runs this exact request
|
|
label = case_label(prefix, value)
|
|
cases.append((label, sampler, {field: value}))
|
|
if not deep and (prefix, value) != ("sigma", "karras"):
|
|
continue
|
|
assertions.append((f"{prefix}={value}", label, "default", "probe"))
|
|
if not deep:
|
|
cases = [c for c in cases if c[0] in ("default", "sigma_karras")]
|
|
|
|
if deep and arch == "flux" and 'FlowMatch' not in sampler and f"{sampler} FlowMatch" in all_names:
|
|
cases.append(("flowmatch_variant", f"{sampler} FlowMatch", {}))
|
|
assertions.append(("plain == FlowMatch (shift parity)", "default", "flowmatch_variant", "same"))
|
|
if deep and arch == "sdxl":
|
|
assertions.append(("explicit epsilon == default", "pred_epsilon", "default", "same"))
|
|
|
|
if deep:
|
|
fc, fa = fallback_matrix(sampler, arch, all_names)
|
|
cases += fc
|
|
assertions += fa
|
|
|
|
return cases, assertions
|
|
|
|
|
|
def fallback_matrix(sampler, arch, all_names):
|
|
"""Sampler resolution and fallback semantics, asserted as hard expectations:
|
|
sampler_name "Default" and an omitted sampler_name both run the model default
|
|
scheduler; an unknown name is rejected by the API (404) in both fallback modes;
|
|
with schedulers_fallback enabled, invalid selections (unsupported sigma method,
|
|
out-of-range solver order, mismatched prediction type, plain name on a flow model)
|
|
still generate by falling back or redirecting instead of failing the request."""
|
|
fb_on = {"override_settings": {"schedulers_fallback": True}}
|
|
cases = [
|
|
("fb_default_name", "Default", {}),
|
|
("fb_unspecified", None, {}),
|
|
("fb_unknown_strict", "__no_such_sampler__", {}),
|
|
("fb_unknown_loose", "__no_such_sampler__", dict(fb_on)),
|
|
("fb_on_sigma_lambdas", sampler, {"schedulers_sigma": "lambdas", **fb_on}),
|
|
("fb_on_order_5", sampler, {"schedulers_solver_order": 5, **fb_on}),
|
|
]
|
|
assertions = [
|
|
('sampler "Default" generates', "fb_default_name", None, "ok"),
|
|
("omitted sampler_name generates", "fb_unspecified", None, "ok"),
|
|
("unknown sampler rejected, fallback off", "fb_unknown_strict", None, "rejected"),
|
|
("unknown sampler rejected, fallback on", "fb_unknown_loose", None, "rejected"),
|
|
("fallback on: unsupported sigma generates", "fb_on_sigma_lambdas", None, "ok"),
|
|
("fallback on: invalid solver order generates", "fb_on_order_5", None, "ok"),
|
|
]
|
|
if arch == "flux":
|
|
cases.append(("fb_on_pred_epsilon", sampler, {"schedulers_prediction_type": "epsilon", **fb_on}))
|
|
assertions.append(("fallback on: prediction mismatch generates", "fb_on_pred_epsilon", None, "ok"))
|
|
if 'FlowMatch' not in sampler and f"{sampler} FlowMatch" in all_names:
|
|
cases.append(("fb_on_redirect", sampler, {"schedulers_prediction_type": "default", **fb_on}))
|
|
assertions.append(("fallback on: plain name redirects and generates", "fb_on_redirect", None, "ok"))
|
|
return cases, assertions
|
|
|
|
|
|
def generate(url, endpoint, sampler, overrides, args, init_b64=None):
|
|
payload = {
|
|
"prompt": PROMPT, "negative_prompt": NEG,
|
|
"seed": args.seed, "steps": args.steps, "cfg_scale": args.cfg,
|
|
"width": args.width, "height": args.height,
|
|
"sampler_name": sampler, "batch_size": 1, "n_iter": 1,
|
|
"save_images": bool(args.save_images), "send_images": True,
|
|
}
|
|
if endpoint == "img2img":
|
|
payload["init_images"] = [init_b64]
|
|
payload["denoising_strength"] = args.denoise
|
|
payload.update(args.baseline)
|
|
payload.update(overrides)
|
|
if sampler is None: # exercise the API default for an omitted sampler_name
|
|
payload.pop("sampler_name", None)
|
|
t0 = time.time()
|
|
d = post(url, f"/sdapi/v1/{endpoint}", payload)
|
|
dt = time.time() - t0
|
|
if not d.get("images"):
|
|
raise RuntimeError(f"no image returned for {endpoint} {sampler} {overrides}")
|
|
b64 = d["images"][0]
|
|
info = {}
|
|
try:
|
|
info = json.loads(d.get("info", "{}"))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return decode(b64), b64, dt, info
|
|
|
|
|
|
def classify_error(e):
|
|
"""Compact error string. HTTP rejections carry the server's own reason from the JSON
|
|
error body (detail for HTTPExceptions, errors for exceptions caught by middleware)."""
|
|
if isinstance(e, requests.HTTPError) and e.response is not None:
|
|
reason = ""
|
|
try:
|
|
body = e.response.json()
|
|
reason = str(body.get("detail") or body.get("errors") or body.get("error") or "").strip()
|
|
except ValueError:
|
|
pass
|
|
return f"http {e.response.status_code}" + (f": {reason[:160]}" if reason else "")
|
|
return str(e)
|
|
|
|
|
|
def save_montage(results, tag, args):
|
|
"""Labeled contact sheet of every case for one-glance visual inspection; failed
|
|
cases appear as their black error placeholders."""
|
|
items = [(k, v) for k, v in results.items() if "arr" in v]
|
|
if not items:
|
|
return
|
|
thumb, cols, pad, labelh = 360, 4, 12, 20
|
|
rows = (len(items) + cols - 1) // cols
|
|
cellw, cellh = thumb + pad, thumb + labelh + pad
|
|
canvas = Image.new("RGB", (cols * cellw + pad, rows * cellh + pad), (25, 25, 25))
|
|
draw = ImageDraw.Draw(canvas)
|
|
f = font(14)
|
|
for i, (label, v) in enumerate(items):
|
|
r, c = divmod(i, cols)
|
|
x, y = pad + c * cellw, pad + r * cellh
|
|
thumbimg = Image.fromarray(v["arr"].astype(np.uint8)).resize((thumb, thumb))
|
|
canvas.paste(thumbimg, (x, y + labelh))
|
|
txt = f"{label} lap={v['lap_var']:.0f}" if v.get("ok") else f"{label} ERROR"
|
|
draw.text((x, y + 3), txt, fill=(230, 230, 230) if v.get("ok") else (255, 120, 120), font=f)
|
|
out = os.path.join(args.outdir, f"montage_{tag}.png")
|
|
canvas.save(out)
|
|
print(f" montage: {out}")
|
|
|
|
|
|
def run_case(url, endpoint, label, sampler, overrides, args, init_b64=None):
|
|
outpath = os.path.join(args.outdir, f"{endpoint}_{slug(sampler) if sampler else 'unspecified'}_{label}.png")
|
|
try:
|
|
arr, b64, dt, info = generate(url, endpoint, sampler, overrides, args, init_b64)
|
|
except (requests.RequestException, RuntimeError) as e:
|
|
err = classify_error(e)
|
|
placeholder = np.asarray(error_image(args.width, args.height, f"Error: {err}"), dtype=np.float64)
|
|
lines = banner_lines(endpoint, label, sampler, overrides, args, f"error={err} seed={args.seed} steps={args.steps} cfg={args.cfg}")
|
|
annotate(placeholder, lines).save(outpath)
|
|
return {"sampler": sampler, "overrides": overrides, "ok": False, "error": err, "arr": placeholder}
|
|
actual_cls = scheduler_class(info)
|
|
res = {
|
|
"sampler": sampler, "overrides": overrides,
|
|
"lap_var": round(lap_var(arr), 2),
|
|
"mean": round(float(arr.mean()), 2), "std": round(float(arr.std()), 2),
|
|
"time": round(dt, 1),
|
|
"actual_cls": actual_cls,
|
|
"ran_ok": ran_requested(sampler, actual_cls),
|
|
"degenerate": float(arr.std()) < 2.0,
|
|
"arr": arr, "b64": b64, "ok": True,
|
|
}
|
|
lines = banner_lines(endpoint, label, sampler, overrides, args, f"cls={actual_cls or '?'} lap_var={res['lap_var']} std={res['std']} seed={args.seed} steps={args.steps} cfg={args.cfg}")
|
|
annotate(arr, lines).save(outpath)
|
|
return res
|
|
|
|
|
|
def slug(s):
|
|
return re.sub(r'[^A-Za-z0-9]+', '_', s).strip('_')
|
|
|
|
|
|
def font(size):
|
|
try:
|
|
return ImageFont.load_default(size=size)
|
|
except TypeError: # Pillow < 10.1 has no size parameter
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def error_image(width, height, message):
|
|
"""Black placeholder at the case resolution with the error centered in white, so a
|
|
failed case still has a visual slot on disk and in the montage."""
|
|
img = Image.new("RGB", (width, height), (0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
f = font(max(24, width // 24))
|
|
lines = textwrap.wrap(message, width=36) or [message]
|
|
boxes = [draw.textbbox((0, 0), line, font=f) for line in lines]
|
|
spacing = 8
|
|
block = sum(b[3] - b[1] for b in boxes) + spacing * (len(lines) - 1)
|
|
y = (height - block) // 2
|
|
for line, b in zip(lines, boxes):
|
|
draw.text(((width - (b[2] - b[0])) // 2, y), line, fill=(255, 255, 255), font=f)
|
|
y += (b[3] - b[1]) + spacing
|
|
return img
|
|
|
|
|
|
def banner_lines(endpoint, label, sampler, overrides, args, tail):
|
|
"""Annotation banner: requested configuration (baseline + this case's override)."""
|
|
eff = {**args.baseline, **overrides}
|
|
return [
|
|
f"{endpoint} {sampler or '(unspecified)'} case={label}",
|
|
f"pred={eff['schedulers_prediction_type']} sigma={eff['schedulers_sigma']} spacing={eff['schedulers_timestep_spacing']} beta={eff['schedulers_beta_schedule']}",
|
|
f"order={eff['schedulers_solver_order']} shift={eff['schedulers_shift']} loworder={eff['schedulers_use_loworder']} thresh={eff['schedulers_use_thresholding']} dynamic={eff['schedulers_dynamic_shift']} rescale={eff['schedulers_rescale_betas']}",
|
|
tail,
|
|
]
|
|
|
|
|
|
def annotate(arr, lines):
|
|
"""Banner-annotate an image for disk inspection; the pixels under test stay untouched."""
|
|
img = Image.fromarray(arr.astype(np.uint8))
|
|
lineh, padx, pady = 22, 8, 5
|
|
barh = lineh * len(lines) + 2 * pady
|
|
canvas = Image.new("RGB", (img.width, img.height + barh), (15, 15, 15))
|
|
canvas.paste(img, (0, barh))
|
|
draw = ImageDraw.Draw(canvas)
|
|
f = font(16)
|
|
for i, line in enumerate(lines):
|
|
draw.text((padx, pady + lineh * i), line, fill=(235, 235, 235), font=f)
|
|
return canvas
|
|
|
|
|
|
def run_pass(endpoint, url, cases, args, tag, init_b64=None):
|
|
print(f"\n--- {endpoint} pass: {len(cases)} generations ---")
|
|
results = {}
|
|
for label, sampler, overrides in cases:
|
|
res = run_case(url, endpoint, label, sampler, overrides, args, init_b64)
|
|
results[label] = res
|
|
if res["ok"]:
|
|
sigma = overrides.get("schedulers_sigma", "-")
|
|
flags = "".join([" RAN?" if not res["ran_ok"] else "", " DEGENERATE" if res["degenerate"] else ""])
|
|
print(f" [ok] {label:18s} sampler='{sampler}' sigma={sigma:11s} lap_var={res['lap_var']:8.1f} cls={res['actual_cls']} t={res['time']:.1f}s{flags}")
|
|
else:
|
|
# rejection is data, not a test failure: probe cases run with fallback off so
|
|
# an unsupported selection is supposed to be refused, and the fb_unknown cases
|
|
# assert the refusal; pass/fail judgment happens in the assertions table
|
|
tag = "rej" if res["error"].startswith("http ") else "err"
|
|
print(f" [{tag}] {label:18s} sampler='{sampler}' {res['error']}")
|
|
save_montage(results, tag, args)
|
|
return results
|
|
|
|
|
|
def probe_verdict(res, default_res, args):
|
|
"""Classify one case against the default: rejected (server refused), fallback (a
|
|
different scheduler class ran), or applied/inert/marginal by MAD."""
|
|
if not res.get("ok"):
|
|
return "rejected", None
|
|
if not res.get("ran_ok"):
|
|
return "fallback", None
|
|
if not default_res.get("ok"):
|
|
return "no-baseline", None
|
|
m = mad(res["arr"], default_res["arr"])
|
|
return ("applied" if m > args.diff_thresh else "inert" if m < args.same_thresh else "marginal"), m
|
|
|
|
|
|
def report(endpoint, results, assertions, arch, args):
|
|
present = {k for k, v in results.items() if v.get("ok")}
|
|
print(f"\n=== {endpoint} comparisons (MAD 0-255; same<{args.same_thresh}, diff>{args.diff_thresh}) ===")
|
|
if endpoint == "img2img":
|
|
path = "add_noise (VP)" if arch == "sdxl" else "scale_noise (flow)" if arch == "flux" else "noise-injection"
|
|
print(f" (every case returning an image exercises the sampler's {path} path)")
|
|
comparisons = []
|
|
counts = {}
|
|
npass = 0
|
|
total = 0
|
|
for label, a, b, rel in assertions:
|
|
if rel in ("ok", "rejected"):
|
|
total += 1
|
|
res = results.get(a, {})
|
|
passed = bool(res.get("ok")) if rel == "ok" else not res.get("ok")
|
|
npass += passed
|
|
verdict = "PASS" if passed else "FAIL"
|
|
if rel == "ok":
|
|
detail = res.get("actual_cls") or "generated" if passed else res.get("error", "no result")
|
|
else:
|
|
detail = res.get("error", "no result") if passed else "unexpectedly generated an image"
|
|
comparisons.append({"check": label, "a": a, "b": None, "expect": rel, "verdict": verdict, "detail": detail})
|
|
print(f" [{verdict}] {label:34s} {a}: {detail}")
|
|
continue
|
|
if rel == "probe":
|
|
verdict, m = probe_verdict(results.get(a, {}), results.get(b, {}), args)
|
|
counts[verdict] = counts.get(verdict, 0) + 1
|
|
detail = results.get(a, {}).get("error") if verdict == "rejected" else results.get(a, {}).get("actual_cls") if verdict == "fallback" else None
|
|
comparisons.append({"check": label, "a": a, "b": b, "expect": rel, "mad": round(m, 2) if m is not None else None, "verdict": verdict, "detail": detail})
|
|
madtxt = f"MAD={m:7.2f}" if m is not None else f"({detail})"
|
|
print(f" [probe] {label:34s} {madtxt} -> {verdict}")
|
|
continue
|
|
if a not in present or b not in present:
|
|
continue
|
|
# a no-op comparison is meaningless when either side silently fell back to default
|
|
if rel == "same" and not (results[a]["ran_ok"] and results[b]["ran_ok"]):
|
|
print(f" [skip] {label:34s} (sampler fallback detected, parity not meaningful)")
|
|
continue
|
|
m = mad(results[a]["arr"], results[b]["arr"])
|
|
total += 1
|
|
ok = (m < args.same_thresh) if rel == "same" else (m > args.diff_thresh)
|
|
npass += ok
|
|
verdict = "PASS" if ok else "FAIL"
|
|
comparisons.append({"check": label, "a": a, "b": b, "expect": rel, "mad": round(m, 2), "verdict": verdict})
|
|
print(f" [{verdict}] {label:34s} {a} vs {b}: MAD={m:7.2f} (expect {rel})")
|
|
for label in sorted(present):
|
|
if not results[label]["ran_ok"]:
|
|
print(f" [warn] {label}: Scheduler class '{results[label]['actual_cls']}' does not match requested '{results[label]['sampler']}' (silent fallback?)")
|
|
if results[label]["degenerate"]:
|
|
print(f" [warn] {label}: degenerate output (std={results[label]['std']}), likely black/blank image")
|
|
if {"shift_1", "shift_6"} <= present:
|
|
s1, s6 = results["shift_1"]["lap_var"], results["shift_6"]["lap_var"]
|
|
print(f" sharpness: shift_1 lap_var={s1:.1f} vs shift_6 lap_var={s6:.1f}")
|
|
countstxt = " ".join(f"{k}={v}" for k, v in sorted(counts.items()))
|
|
print(f" {endpoint}: invariants {npass}/{total} passed | probes: {countstxt}")
|
|
return {"comparisons": comparisons, "passed": npass, "total": total, "probes": counts}
|
|
|
|
|
|
def strip(results):
|
|
return {k: {kk: vv for kk, vv in v.items() if kk not in ("arr", "b64")} for k, v in results.items()}
|
|
|
|
|
|
def run_deep(url, sampler, arch, all_names, args, dump):
|
|
grand_pass = grand_total = 0
|
|
cases, assertions = build_matrix(sampler, arch, all_names, deep=True, baseline=args.baseline)
|
|
if args.mode in ("txt2img", "both"):
|
|
res = run_pass("txt2img", url, cases, args, tag=f"txt2img_{slug(sampler)}")
|
|
rep = report("txt2img", res, assertions, arch, args)
|
|
dump["txt2img"] = {"cases": strip(res), **rep}
|
|
grand_pass += rep["passed"]
|
|
grand_total += rep["total"]
|
|
if args.mode in ("img2img", "both"):
|
|
print(f"\nGenerating img2img base image ({sampler} default)...")
|
|
_, base_b64, _, _ = generate(url, "txt2img", sampler, {}, args)
|
|
res = run_pass("img2img", url, cases, args, tag=f"img2img_{slug(sampler)}", init_b64=base_b64)
|
|
rep = report("img2img", res, assertions, arch, args)
|
|
dump["img2img"] = {"cases": strip(res), **rep}
|
|
grand_pass += rep["passed"]
|
|
grand_total += rep["total"]
|
|
return grand_pass, grand_total
|
|
|
|
|
|
def run_sweep(url, samplers, arch, all_names, args, dump):
|
|
"""Reduced matrix per sampler (default + karras + img2img); emits an empirical
|
|
capability table instead of hard pass/fail."""
|
|
rows = []
|
|
defaults_montage = {}
|
|
do_img = args.mode in ("img2img", "both")
|
|
for sampler in samplers:
|
|
cases, assertions = build_matrix(sampler, arch, all_names, deep=False, baseline=args.baseline)
|
|
print(f"\n##### {sampler}")
|
|
res = run_pass("txt2img", url, cases, args, tag=f"sweep_{slug(sampler)}")
|
|
row = {"sampler": sampler, "karras": None, "karras_mad": None,
|
|
"txt2img": False, "img2img": None, "actual_cls": "", "notes": []}
|
|
d = res.get("default", {})
|
|
if "arr" in d:
|
|
defaults_montage[sampler] = d
|
|
if d.get("ok"):
|
|
row["txt2img"] = True
|
|
row["actual_cls"] = d["actual_cls"]
|
|
if not d["ran_ok"]:
|
|
row["notes"].append("class mismatch (fallback?)")
|
|
if d["degenerate"]:
|
|
row["notes"].append("degenerate output")
|
|
else:
|
|
row["notes"].append(d.get("error", "generation failed"))
|
|
for _label, a, _b, rel in assertions:
|
|
if rel != "probe":
|
|
continue
|
|
verdict, m = probe_verdict(res.get(a, {}), d, args)
|
|
row["karras"] = verdict
|
|
row["karras_mad"] = round(m, 2) if m is not None else None
|
|
if verdict != "applied":
|
|
row["notes"].append(f"karras: {verdict}" + (f" (MAD={m:.2f})" if m is not None else ""))
|
|
if do_img and d.get("ok"):
|
|
ires = run_case(url, "img2img", "default", sampler, {}, args, init_b64=d["b64"])
|
|
row["img2img"] = bool(ires.get("ok"))
|
|
if not ires.get("ok"):
|
|
row["notes"].append(f"img2img: {ires.get('error')}")
|
|
rows.append(row)
|
|
dump.setdefault("sweep", {})[sampler] = {"cases": strip(res), "row": row}
|
|
save_montage(defaults_montage, "sweep_defaults", args)
|
|
|
|
print(f"\n=== sweep capability report ({arch}) ===")
|
|
lines = ["| Sampler | txt2img | Scheduler class | Karras | img2img | Notes |",
|
|
"| --- | --- | --- | --- | --- | --- |"]
|
|
for r in rows:
|
|
img = {True: "yes", False: "NO", None: "-"}[r["img2img"]]
|
|
notes = "; ".join(r["notes"]) or "-"
|
|
lines.append(f"| {r['sampler']} | {'yes' if r['txt2img'] else 'NO'} | {r['actual_cls'] or '-'} | {r['karras'] or '-'} | {img} | {notes} |")
|
|
table = "\n".join(lines)
|
|
print(table)
|
|
rpath = os.path.join(args.outdir, f"capability_report_{arch}.md")
|
|
with open(rpath, "w", encoding="utf-8") as f:
|
|
f.write(f"# Sampler capability report ({arch})\n\n{table}\n")
|
|
print(f"\nreport: {rpath}")
|
|
issues = sum(1 for r in rows if r["notes"])
|
|
return len(rows) - issues, len(rows)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Sampler differential tester (API)")
|
|
ap.add_argument("--url", default="http://127.0.0.1:7860", help="server URL (default SD.Next port 7860)")
|
|
ap.add_argument("--sampler", required=True, help="sampler name, comma-separated list, or 'all'")
|
|
ap.add_argument("--sweep", action="store_true", help="reduced per-sampler matrix + empirical capability report instead of the full deep matrix")
|
|
ap.add_argument("--arch", choices=["sdxl", "flux", "auto"], default="auto")
|
|
ap.add_argument("--mode", choices=["txt2img", "img2img", "both"], default="both")
|
|
ap.add_argument("--model", default=None, help="checkpoint name to load first (see /sdapi/v1/sd-models)")
|
|
ap.add_argument("--steps", type=int, default=28)
|
|
ap.add_argument("--seed", type=int, default=12345)
|
|
ap.add_argument("--cfg", type=float, default=None, help="cfg/guidance (default 6 sdxl, 4 flux)")
|
|
ap.add_argument("--denoise", type=float, default=0.6, help="img2img denoising strength")
|
|
ap.add_argument("--width", type=int, default=1024)
|
|
ap.add_argument("--height", type=int, default=1024)
|
|
ap.add_argument("--outdir", default=os.path.join(tempfile.gettempdir(), "samplers_api"), help="output dir for images + montage (default: <system temp>/samplers_api)")
|
|
ap.add_argument("--save-images", action="store_true", help="also save originals server-side into the standard output folders (off by default to keep automated runs out of the gallery)")
|
|
ap.add_argument("--same-thresh", type=float, default=1.0, help="MAD below this = identical")
|
|
ap.add_argument("--diff-thresh", type=float, default=3.0, help="MAD above this = different")
|
|
args = ap.parse_args()
|
|
|
|
os.makedirs(args.outdir, exist_ok=True)
|
|
|
|
if args.model:
|
|
print(f"Loading model: {args.model}")
|
|
post(args.url, "/sdapi/v1/options", {"sd_model_checkpoint": args.model})
|
|
time.sleep(2)
|
|
|
|
arch = args.arch
|
|
model_name = ""
|
|
try:
|
|
model_name = str(get(args.url, "/sdapi/v1/options").get("sd_model_checkpoint", ""))
|
|
except requests.RequestException as e:
|
|
print(f"warn: could not read options: {e}")
|
|
if arch == "auto":
|
|
low = model_name.lower()
|
|
arch = "flux" if "flux" in low else "sdxl" if "xl" in low else "generic"
|
|
print(f"auto-detected arch={arch} from model='{model_name}' (override with --arch)")
|
|
if args.cfg is None:
|
|
args.cfg = 4.0 if arch == "flux" else 6.0
|
|
case_arch = arch if arch in ("sdxl", "flux") else "generic"
|
|
args.baseline = baseline_for(case_arch)
|
|
|
|
server_samplers = get(args.url, "/sdapi/v1/samplers")
|
|
all_names = {s["name"] for s in server_samplers}
|
|
if args.sampler.strip().lower() == "all":
|
|
targets = sorted(all_names)
|
|
else:
|
|
targets = [s.strip() for s in args.sampler.split(",") if s.strip()]
|
|
for name in targets:
|
|
if name not in all_names:
|
|
ap.error(f"sampler '{name}' not in server list; see /sdapi/v1/samplers")
|
|
|
|
print(f"\nModel: {model_name}")
|
|
print(f"Arch: {arch} mode={args.mode} samplers={len(targets)} ({'sweep' if args.sweep else 'deep'}) baseline_prediction={args.baseline['schedulers_prediction_type']} steps={args.steps} seed={args.seed} cfg={args.cfg} denoise={args.denoise} res={args.width}x{args.height}")
|
|
if not args.sweep and len(targets) > 3:
|
|
print(f"warn: deep mode runs the full matrix for each of {len(targets)} samplers; --sweep is the reduced audit")
|
|
|
|
dump = {"model": model_name, "arch": arch, "mode": args.mode,
|
|
"samplers": targets, "baseline": {k: v for k, v in args.baseline.items() if k != "override_settings"},
|
|
"params": {"steps": args.steps, "seed": args.seed, "cfg": args.cfg, "denoise": args.denoise, "res": [args.width, args.height]}}
|
|
|
|
if args.sweep:
|
|
clean, total = run_sweep(args.url, targets, case_arch, all_names, args, dump)
|
|
summary = f"{clean}/{total} samplers clean"
|
|
else:
|
|
grand_pass = grand_total = 0
|
|
for name in targets:
|
|
if len(targets) > 1:
|
|
print(f"\n##### {name}")
|
|
sub = dump.setdefault("deep", {}).setdefault(name, {})
|
|
npass, total = run_deep(args.url, name, case_arch, all_names, args, sub)
|
|
grand_pass += npass
|
|
grand_total += total
|
|
summary = f"{grand_pass}/{grand_total} invariants passed"
|
|
|
|
print(f"\n=== SUMMARY: {summary} | arch={arch} mode={args.mode} | images in {args.outdir} ===")
|
|
jpath = os.path.join(args.outdir, "samplers_api_results.json")
|
|
with open(jpath, "w", encoding="utf-8") as f:
|
|
json.dump(dump, f, indent=2)
|
|
print(f"JSON: {jpath}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|