Files
olivv-cs-ComfyUI-FunPack/modules/sampling/noise_merge/__init__.py
T

269 lines
14 KiB
Python

"""Noise merging: at chosen steps, run the step a second time on the same picture with
different noise and blend the two answers.
Step N is answered twice. Picture A is the run's own answer. Picture B is the answer to
the same step after A's picture is re-noised to this step's noise level with a second
seed (and so is the sound, when the model has it). The two are then mixed:
merged = base + strength * (other - base)
`Merge order` picks the base. "B into A" keeps A's picture and lets B's variation in;
"A into B" starts from B and lets A in. Two independent answers averaged lose contrast
(the independent detail partly cancels), so the mix is rescaled to the base's own spread.
"Second run" keeps a whole second video going beside yours: its first input is the second seed's noise and every
later input is its own last answer re-noised, the way a few-step sampler chains its steps. At the named steps its
answer is blended into yours the same way. It costs one extra pass at every step up to the last one named.
Costs one extra model pass per merged step. The second pass is marked as a probe, so no
learning feature records it.
"""
import random
import time
import torch
from comfy.patcher_extension import WrappersMP
from ..._core import dit_hooks, log, streams
ID = "noise_merge"
TITLE = "Noise merging"
MOUNT = "generation.sampling"
STAGE = "sampling"
CATEGORY = "sampling"
STATUS = "experimental"
REQUIRES = ["temporal_latent"]
SETTINGS = {
"enabled": {
"type": "bool", "default": False,
"label": "Noise merging",
"hint": "At the steps below, answers the step twice with different noise and blends the two. Costs one extra pass per merged step.",
},
"what": {
"type": "enum", "default": "answers", "options": [
{"value": "answers", "label": "Step answers"},
{"value": "parallel", "label": "Second run"},
{"value": "noise", "label": "Starting noise"},
],
"label": "Merge", "ui": "segmented",
"hint": "Step answers: at chosen steps the model answers twice and the two pictures are blended (one extra pass each). Second run: a whole second video is denoised alongside yours from the second seed, and at the steps you name its answer is blended into yours (one extra pass at every step up to the last named one; the overlap look). Starting noise: the run starts from your seed's noise mixed with the second seed's, at no extra cost.",
"when": {"enabled": True},
},
"steps": {
"type": "text", "default": "1",
"label": "Steps",
"hint": "Step numbers from 1, e.g. 1 or 2-3 or 1,4. Steps past the schedule's end are ignored. For Second run these are the steps where the blend happens; the second run itself goes from step 1 to the last one named.",
"when": {"enabled": True, "what": ["answers", "parallel"]},
},
"order": {
"type": "enum", "default": "b_into_a", "options": [
{"value": "b_into_a", "label": "B into A"},
{"value": "a_into_b", "label": "A into B"},
],
"label": "Merge order", "ui": "segmented",
"hint": "A is the picture your seed makes, B the same step on a second seed. The first named is blended in; the other is the base that keeps its overall look.",
"when": {"enabled": True, "what": ["answers", "parallel"]},
},
"strength": {
"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05,
"label": "Merge strength", "ui": "slider",
"hint": "0 keeps your own result untouched, 1 replaces it with the second seed's. For starting noise, 0.5 is an even mix.",
"when": {"enabled": True},
},
"fresh": {
"type": "float", "default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05,
"label": "Second pass new noise", "ui": "slider",
"hint": "How much of the step's own noise the second pass swaps for the second seed's. 1 swaps all of it; lower keeps more of your own noise, so B stays closer to A. 0 gives B = A and the merge changes nothing.",
"when": {"enabled": True, "what": "answers"},
},
"sound": {
"type": "bool", "default": True,
"label": "Merge sound too",
"hint": "Blends the sound the same way as the picture. Off keeps only the base's own sound.",
"when": {"enabled": True, "what": ["answers", "parallel"]},
},
"seed": {
"type": "int", "default": -1, "min": -1, "max": 2147483647,
"label": "Second seed",
"hint": "The noise for picture B (or the second noise). -1 picks a new one every run; a number gives the same B each time.",
"when": {"enabled": True},
},
}
def _say(message, level=log.ALERT):
log.once(f"{ID}:{message}", level, "FunUI Noise merging", message)
def merge(base, other, strength):
"""base + strength * (other - base), rescaled so each clip keeps the base's spread
(std around its own mean) -- a plain mix of two independent answers is flatter."""
mixed = base + strength * (other - base)
dims = tuple(range(1, base.dim()))
base_std = base.float().std(dim=dims, keepdim=True)
mixed_std = mixed.float().std(dim=dims, keepdim=True).clamp(min=1e-8)
mean = mixed.float().mean(dim=dims, keepdim=True)
return ((mixed.float() - mean) * (base_std / mixed_std) + mean).to(base.dtype)
def second_input(x, a, sigma, seed, step, fresh):
"""The input for picture B. Rectified flow: x = (1 - sigma) picture + sigma noise. A's answer is the picture;
the noise is the step's own noise (what is left of x once A's picture is taken out) turned `fresh` of the
way toward the second seed's noise, at unit variance. fresh 1 is all second-seed noise, 0 hands back x."""
if sigma < 1e-4:
return x
own = (x.float() - (1.0 - sigma) * a.float()) / sigma
gen = torch.Generator().manual_seed(int(seed) + 7919 * int(step))
other = torch.randn(x.shape, generator=gen).to(device=x.device)
theta = fresh * torch.pi / 2
noise = float(torch.cos(torch.tensor(theta))) * own + float(torch.sin(torch.tensor(theta))) * other
return ((1.0 - sigma) * a.float() + sigma * noise).to(x.dtype)
def _sound(packed, video):
"""The part of a packed [B, 1, N] result that follows the picture (H3's sound), or None."""
if getattr(packed, "dim", None) is None or packed.dim() != 3:
return None
n = video[0].numel()
return packed[..., n:] if packed.shape[-1] > n else None
def mix_noise(noise, other, strength):
"""cos(t) noise + sin(t) other, t = strength * 90 degrees: two independent unit-variance noises
stay unit variance (a plain average would shrink to 71% at an even mix, and the model reads that as a calmer, blurrier start)."""
theta = strength * torch.pi / 2
return (torch.cos(torch.tensor(theta)) * noise + torch.sin(torch.tensor(theta)) * other).to(noise.dtype)
def install_noise(patcher, values, key):
strength = float(values.get("strength", 0.5))
if strength <= 0.0:
_say("off: strength is 0")
return None
chosen = int(values.get("seed", -1))
def sampler_sample(executor, model_wrap, sigmas, extra_args, callback, noise,
latent_image=None, denoise_mask=None, disable_pbar=False):
if not isinstance(noise, torch.Tensor):
_say("off this run: the starting noise is not a plain tensor")
return executor(model_wrap, sigmas, extra_args, callback, noise, latent_image, denoise_mask, disable_pbar)
seed = chosen if chosen >= 0 else random.randrange(2 ** 31)
other = torch.randn(noise.shape, generator=torch.Generator().manual_seed(seed)).to(noise)
log.info("FunUI Noise merging", f"on: starting noise mixed with the second seed's ({seed}) at "
f"strength {strength:.2f}, no extra pass")
return executor(model_wrap, sigmas, extra_args, callback, mix_noise(noise, other, strength),
latent_image, denoise_mask, disable_pbar)
patcher.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, sampler_sample)
return f"starting noise, strength {strength:.2f}, second seed {'random' if chosen < 0 else chosen}"
def install(patcher, values, key):
if not values.get("enabled"):
return None
if values.get("what") == "noise":
return install_noise(patcher, values, key)
wanted, problems = dit_hooks.parse_blocks(values.get("steps"), 100000)
wanted = {i - 1 for i in wanted if i >= 1}
for p in problems:
_say(f"ignored step entry: {p}")
strength = float(values.get("strength", 0.5))
if not wanted or strength <= 0.0:
_say("off: " + ("no valid steps named" if not wanted else "strength is 0"))
return None
b_first = values.get("order", "b_into_a") == "b_into_a"
with_sound = bool(values.get("sound", True))
fresh = float(values.get("fresh", 1.0))
parallel = values.get("what") == "parallel"
last = max(wanted)
chosen = int(values.get("seed", -1))
live = {"seed": max(chosen, 0), "merged": 0, "prev": None}
def start(executor, *args, **kwargs):
live["seed"] = chosen if chosen >= 0 else random.randrange(2 ** 31)
live["merged"], live["prev"] = 0, None
log.info("FunUI Noise merging", f"on: {'second run, ' if parallel else ''}steps {values.get('steps')}, strength {strength:.2f}, "
f"second seed {live['seed']}")
try:
return executor(*args, **kwargs)
finally:
if not live["merged"]:
_say("Inactive | no step of this run matched the steps you named "
"(or the model was not called as a plain prompt step)")
patcher.add_wrapper_with_key(WrappersMP.OUTER_SAMPLE, key, start)
def apply_model(executor, x, t, *args, **kwargs):
named = streams.model_args(args, kwargs)
to = named.get("transformer_options") or {}
out = executor(x, t, *args, **kwargs)
where = dit_hooks.current_step(to)
if where is None or dit_hooks.probing(to) or where[0] > last or (where[0] not in wanted and not parallel):
return out
if any(int(i) != 0 for i in (to.get("cond_or_uncond") or [0])):
return out # a negative-prompt call: guidance is for the prompt's picture
try:
split_x, split_a = streams.video_of(x, named), streams.video_of(out, named)
if split_x is None or split_a is None:
_say("off this run: could not find the picture in this model's latent")
return out
sigma = float(t.max())
video_a, rebuild_a = split_a
tail_a = _sound(out, video_a)
gen_step = where[0]
if parallel:
# The second run: each step's input is its own last answer (zero at the start) re-noised with the
# second seed's noise, the way a few-step sampler chains its steps.
prev = live["prev"] or (torch.zeros_like(video_a), torch.zeros_like(tail_a) if tail_a is not None else None)
if prev[0].shape != video_a.shape:
_say("Inactive | the latent changed size during the run, so the second run was dropped")
return out
base_v, base_t, mix = prev[0], prev[1], 1.0
else:
base_v, base_t, mix = video_a.detach(), tail_a, fresh
x_b = split_x[1](second_input(split_x[0], base_v, sigma, live["seed"], gen_step, mix))
if tail_a is not None and with_sound:
n = x_b.shape[-1] - tail_a.shape[-1]
x_b = torch.cat([x_b[..., :n], second_input(x[..., n:], base_t.detach(), sigma, live["seed"] + 1,
gen_step, mix)], dim=-1)
args_b, kwargs_b = streams.with_options(args, kwargs, {**to, dit_hooks.PROBE: True})
began = time.perf_counter()
out_b = executor(x_b, t, *args_b, **kwargs_b)
took = time.perf_counter() - began
split_b = streams.video_of(out_b, named)
if split_b is None:
return out
video_b, rebuild_b = split_b
tail_b = _sound(out_b, video_b)
if parallel:
live["prev"] = (video_b.detach(), None if tail_b is None else tail_b.detach())
if gen_step not in wanted:
return out
live["merged"] += 1
diff = float((video_a - video_b).norm() / video_a.norm().clamp(min=1e-8))
log.info("FunUI Noise merging",
f"step {where[0] + 1}: second answer differs from the first by {diff:.1%}, "
f"merged at {strength:.2f}{' (picture and sound)' if tail_a is not None and with_sound else ''}; "
f"the second pass took {took:.1f}s")
if b_first:
base, other, base_tail, other_tail, rebuild = video_a, video_b, tail_a, tail_b, rebuild_a
else:
base, other, base_tail, other_tail, rebuild = video_b, video_a, tail_b, tail_a, rebuild_b
merged = rebuild(merge(base, other, strength).to(base.dtype))
if with_sound and base_tail is not None and other_tail is not None:
n = merged.shape[-1] - base_tail.shape[-1]
merged = torch.cat([merged[..., :n], merge(base_tail, other_tail, strength).to(merged.dtype)], dim=-1)
return merged
except Exception as exc: # noqa: BLE001
log.broke("FunUI Noise merging", exc, "merging the second answer")
return out
patcher.add_wrapper_with_key(WrappersMP.APPLY_MODEL, key, apply_model)
return (f"steps {values.get('steps')}, {'B into A' if b_first else 'A into B'}, "
f"strength {strength:.2f}, second seed {'random' if chosen < 0 else chosen}")
PROVIDES = {"modifier": install}