Files
digital-garbageandClaude Sonnet 5.5 f5050a4574 Shorter in-app hints: what a setting does, where to start, what it needs
Long hints for 33 settings are cut to one or two sentences each, and the
Taste steering strength and blocks hints now give a starting value and
recommended blocks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 13:26:17 +02:00

172 lines
7.8 KiB
Python

"""Run meter: numbers that tell two runs apart, for finding which setting made a picture worse.
It adds no model call. At every step it reads the model's own answer (the picture it predicts) and takes a few
picture-sized sums: how much fine grain the answer has across the picture and over time, and how much it still
changes from the previous step's answer. The sums stay on the card until the run is over, then one line per step
is logged and a small summary is saved. The next run with the same seed is compared with the saved one, so
flipping one setting and rendering again shows what it did to the grain and to the settling.
Grain is the energy of neighbour differences divided by the energy of the picture: smooth is low, a noise
pattern left in the picture is high. Settling is how far this step's answer moved from the previous one: a
schedule that is long enough has it falling towards zero by the last step. Only the picture is read, never the
sound, and nothing is ever changed.
"""
import json
import time
import torch
from comfy.patcher_extension import WrappersMP
from ..._core import config, dit_hooks, log, streams
ID = "run_meter"
TITLE = "Run meter"
MOUNT = "generation.sampling"
STAGE = "post"
CATEGORY = "sampling"
STATUS = "experimental"
FOLDER = config.ROOT / "run_meter"
SAME = 0.02 # a change in a number under this share is not called a change
SETTINGS = {
"enabled": {
"type": "bool", "default": False,
"label": "Measure the run",
"hint": "Logs how grainy the picture is and how much it changes each step, and compares with the previous run of the same seed, steps and size. No extra model calls.",
},
}
def _rms(t):
"""Root mean square in float32, without a float copy of the whole tensor. A 0-d tensor on the same device."""
return torch.linalg.vector_norm(t, dtype=torch.float32) / (t.numel() ** 0.5)
def measure(video, before=None):
"""(across, over_time, settle) of one picture [B, C, T, H, W] as 0-d tensors on its device.
`over_time` is NaN for a single frame, `settle` NaN with no previous answer of the same shape."""
v = video.detach()
scale = _rms(v).clamp_min(1e-12)
dx, dy = v[..., 1:] - v[..., :-1], v[..., 1:, :] - v[..., :-1, :]
across = ((_rms(dx) ** 2 + _rms(dy) ** 2) / 2).sqrt() / scale
nan = torch.full((), float("nan"), device=v.device)
over = _rms(v[:, :, 1:] - v[:, :, :-1]) / scale if v.shape[2] > 1 else nan
settle = _rms(v - before.to(device=v.device, dtype=v.dtype)) / scale if before is not None and before.shape == v.shape else nan
return across, over, settle
def _value(t):
x = float(t)
return None if x != x else x
def _load(path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 -- nothing saved yet, or unreadable: nothing to compare with
return None
def _change(now, then):
if now is None or then is None or then <= 0:
return "n/a"
r = now / then - 1.0
return "same" if abs(r) < SAME else f"{r:+.0%}"
def report(rows, seed, previous):
"""The log message for a finished run, and its summary. `rows` are the per-step dicts, in step order."""
lines = []
for r in rows:
bits = [f"{r['h']}x{r['w']}", f"grain {r['across']:.3f} across"]
if r["over_time"] is not None:
bits.append(f"{r['over_time']:.3f} over time")
bits.append("settling " + (f"{r['settle']:.3f}" if r["settle"] is not None else "n/a (first call at this size)"))
lines.append(f"step {r['step'] + 1}/{r['total']}: " + ", ".join(bits))
last = rows[-1]
settles = [r["settle"] for r in rows if r["settle"] is not None]
summary = {"seed": seed, "steps": last["total"], "size": [last["h"], last["w"]], "final": last,
"settle_mean": sum(settles) / len(settles) if settles else None, "when": time.time()}
if previous is None:
lines.append("nothing saved to compare with yet")
elif previous.get("seed") != seed or previous.get("steps") != summary["steps"] or previous.get("size") != summary["size"]:
lines.append("previous run had another seed, step count or size: no comparison")
else:
was = previous["final"]
lines.append("against the previous run of this seed: final grain across " + _change(last["across"], was["across"])
+ ", over time " + _change(last["over_time"], was["over_time"])
+ ", last settling " + _change(last["settle"], was["settle"]))
return "\n".join(lines), summary
def install(patcher, values, key):
if not values.get("enabled"):
return None
state = {"rows": {}, "which": None, "before": None, "seed": None}
def reset():
state.update(rows={}, which=None, before=None, seed=None)
def outer(executor, *args, **kwargs):
reset()
state["seed"] = (list(args) + [None] * 8)[7]
try:
result = executor(*args, **kwargs)
except BaseException:
reset() # a stopped run saves nothing: half a run is not comparable
raise
try:
finish()
except Exception as exc: # noqa: BLE001 -- the meter never costs the picture
log.warning(TITLE, f"could not report: {exc}")
finally:
reset() # latent-sized tensors must not outlive the run
return result
def finish():
done = [dict(r, **{k: _value(r[k]) for k in ("across", "over_time", "settle")})
for _s, r in sorted(state["rows"].items())]
if not done:
log.warning(TITLE, "no step could be read (the model's answer holds no picture)")
return
seed = state["seed"]
seed = int(seed) if isinstance(seed, (int, float)) else None
text, summary = report(done, seed, _load(FOLDER / "last.json"))
log.info(TITLE, text)
try:
FOLDER.mkdir(parents=True, exist_ok=True)
(FOLDER / "last.json").write_text(json.dumps(summary), encoding="utf-8")
except OSError as exc:
log.once("run_meter:save", log.WARNING, TITLE, f"could not save this run for the next comparison: {exc}")
def apply_model(executor, x, t, *args, **kwargs):
named = streams.model_args(args, kwargs)
to = named.get("transformer_options")
out = executor(x, t, *args, **kwargs)
where, split = dit_hooks.current_step(to), streams.video_of(out, named)
if where is None or split is None or dit_hooks.probing(to) or dit_hooks.weak_branch(to):
return out
video = split[0]
which = tuple(int(v) for v in ((to or {}).get("cond_or_uncond") or ()))
if state["which"] is None:
state["which"] = which
if which != state["which"]:
return out # the other half of a guided step: one answer per step is enough
if where[0] in state["rows"]:
return out # a second call in the same step (a second-order sampler): the first one stands
before = state["before"]
across, over, settle = measure(video, before[1] if before is not None and where[0] >= before[0] else None)
state["before"] = (where[0], video.detach())
state["rows"][where[0]] = {"step": where[0], "total": where[1], "h": int(video.shape[-2]), "w": int(video.shape[-1]),
"across": across, "over_time": over, "settle": settle}
return out
patcher.add_wrapper_with_key(WrappersMP.OUTER_SAMPLE, f"{key}.run", outer)
patcher.add_wrapper_with_key(WrappersMP.APPLY_MODEL, key, apply_model)
return "grain and settling per step, compared with the previous run when it used the same seed, steps and size"
PROVIDES = {"modifier": install}