core/noise_math.py — compositing modes run in uniform space (normal CDF in, inverse CDF out) and everything is re-standardised, so blended noise reaches the sampler as mean 0 / std 1. Measured on the old path: overlay had mean +0.39 at strength 0.3, soft_light std 4.2 at strength 1.0, and the inverse transform peaked near 4e4. "normal" now mixes with cos/sin, which preserves variance where (1-k)*base + k*shader fell to std 0.71 at k=0.5. core/schedule.py — one sigma schedule for the whole run, honouring denoise and using custom sigmas as given. Boundaries enforce a 2-step minimum, so the 20-step/3-injection case can no longer end in a 1-step segment. core/shader_noise.py — shape taken from the latent ([B,C,H,W], [B,C,T,H,W]), which fixes frames being confused with channels when a video has as many latent frames as channels (a 61-frame Wan clip has 16 of each). Generation runs inside fork_rng, and fractional octaves interpolate between integer renders instead of being truncated by ShaderParams.validate(). pipelines/standard.py — samples the schedule in segments. At a boundary the latent is split into a denoised estimate and its noise in the model's internal space, the noise is re-mixed, and the next segment resumes from that pair. Verified lossless to 4.8e-07 for both EPS and flow models, so at shader_strength 0 a segmented run reproduces an uninterrupted one. 179 tests pass, including the 11 legacy goldens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
5.4 KiB
Python
151 lines
5.4 KiB
Python
"""
|
|
Sampling schedule for the standard pipeline.
|
|
|
|
One sigma schedule is built for the whole run and split into segments at the
|
|
points where new shader noise enters. Legacy instead ran every stage as its own
|
|
complete sampling pass: each stage restarted from maximum noise, so on flow
|
|
models (noise_scaling is `sigma * noise + (1 - sigma) * latent`, and sigma is
|
|
1.0 at the start) the previous stage's result was multiplied by zero. Two
|
|
sequential stages were therefore a half-length generation, not two halves of
|
|
one.
|
|
|
|
The boundaries here are indices into that single schedule, so stages become
|
|
segments of one trajectory.
|
|
"""
|
|
import math
|
|
from typing import List, Optional, Sequence, Tuple
|
|
|
|
import torch
|
|
|
|
import comfy.samplers
|
|
|
|
# A segment shorter than this cannot denoise anything meaningful. Legacy's
|
|
# injection points produced ranges like (19, 20) for 20 steps with 3 stages,
|
|
# making the final image a single step from full noise.
|
|
MIN_SEGMENT_STEPS = 2
|
|
|
|
SUPPORTED_DISTRIBUTIONS = (
|
|
"uniform", "linear_decrease", "linear_increase", "gaussian",
|
|
"first_stronger", "last_stronger",
|
|
)
|
|
|
|
|
|
def stage_strengths(base_strength: float, num_stages: int, distribution: str) -> List[float]:
|
|
"""Shader strength per stage, following the chosen distribution curve."""
|
|
if num_stages <= 0:
|
|
return []
|
|
if num_stages == 1:
|
|
return [base_strength]
|
|
|
|
span = num_stages - 1
|
|
min_factor = 0.25 # keep every stage audible rather than fading one to nothing
|
|
|
|
if distribution == "linear_decrease":
|
|
factors = [max(1.0 - i / span, min_factor) for i in range(num_stages)]
|
|
elif distribution == "linear_increase":
|
|
factors = [max(i / span, min_factor) for i in range(num_stages)]
|
|
elif distribution == "gaussian":
|
|
mid, std_dev = span / 2.0, num_stages / 3.0
|
|
factors = [math.exp(-((i - mid) ** 2) / (2 * std_dev ** 2)) for i in range(num_stages)]
|
|
elif distribution == "first_stronger":
|
|
factors = [math.exp(-i) for i in range(num_stages)]
|
|
elif distribution == "last_stronger":
|
|
factors = [math.exp(i - span) for i in range(num_stages)]
|
|
else: # "uniform" and anything unrecognised
|
|
factors = [1.0] * num_stages
|
|
|
|
return [base_strength * f for f in factors]
|
|
|
|
|
|
def sequential_starts(total_steps: int, num_stages: int) -> List[int]:
|
|
"""First step of each sequential stage, spread evenly across the schedule."""
|
|
if num_stages <= 1:
|
|
return [0]
|
|
return [int(i * total_steps / num_stages) for i in range(num_stages)]
|
|
|
|
|
|
def injection_points(total_steps: int, num_stages: int) -> List[int]:
|
|
"""
|
|
Steps at which injection stages add shader noise.
|
|
|
|
These are interior points: injecting at step 0 is what a sequential stage
|
|
already does, and injecting at the last step leaves nothing to sample.
|
|
"""
|
|
if num_stages <= 0:
|
|
return []
|
|
return [int(round((i + 1) * total_steps / (num_stages + 1))) for i in range(num_stages)]
|
|
|
|
|
|
def merge_boundaries(
|
|
total_steps: int,
|
|
starts: Sequence[int],
|
|
points: Sequence[int],
|
|
min_segment: int = MIN_SEGMENT_STEPS,
|
|
) -> List[int]:
|
|
"""
|
|
Combine stage starts and injection points into ascending segment boundaries.
|
|
|
|
Always begins at 0, drops duplicates, and discards any boundary that would
|
|
leave a segment shorter than `min_segment` steps.
|
|
"""
|
|
boundaries: List[int] = []
|
|
for boundary in sorted({0, *starts, *points}):
|
|
if boundary < 0 or boundary >= total_steps:
|
|
continue
|
|
if boundaries and boundary - boundaries[-1] < min_segment:
|
|
continue
|
|
boundaries.append(boundary)
|
|
|
|
if not boundaries:
|
|
return [0]
|
|
# The tail is a segment too: drop trailing boundaries that would truncate it.
|
|
while len(boundaries) > 1 and total_steps - boundaries[-1] < min_segment:
|
|
boundaries.pop()
|
|
return boundaries
|
|
|
|
|
|
def segments(boundaries: Sequence[int], total_steps: int) -> List[Tuple[int, int]]:
|
|
"""Turn boundaries into (start, end) step ranges covering the whole schedule."""
|
|
if not boundaries:
|
|
return [(0, total_steps)]
|
|
ends = list(boundaries[1:]) + [total_steps]
|
|
return [(start, end) for start, end in zip(boundaries, ends) if end > start]
|
|
|
|
|
|
def build_sigmas(
|
|
model,
|
|
steps: int,
|
|
sampler_name: str,
|
|
scheduler: str,
|
|
denoise: float = 1.0,
|
|
custom_sigmas: Optional[torch.Tensor] = None,
|
|
) -> torch.Tensor:
|
|
"""
|
|
Build the sigma schedule for the whole run.
|
|
|
|
Custom sigmas are used as given (they define their own trajectory). Legacy
|
|
wrapped the model to override `model_sampling`, but ComfyUI reads the
|
|
schedule through `model.get_model_object("model_sampling")`, which resolves
|
|
past such a wrapper, so the supplied values never reached the sampler -- only
|
|
their count did.
|
|
|
|
Otherwise KSampler builds the schedule, which applies `denoise` (legacy
|
|
hard-coded 1.0 per stage) and the discard-penultimate-sigma samplers.
|
|
"""
|
|
if custom_sigmas is not None and torch.is_tensor(custom_sigmas) and custom_sigmas.numel() > 1:
|
|
sigmas = custom_sigmas.detach().clone().float()
|
|
if sigmas[0] < sigmas[-1]: # accept ascending input, sample descending
|
|
sigmas = sigmas.flip(0)
|
|
return sigmas
|
|
|
|
sampler = comfy.samplers.KSampler(
|
|
model,
|
|
steps=steps,
|
|
device=model.load_device,
|
|
sampler=sampler_name,
|
|
scheduler=scheduler,
|
|
denoise=denoise,
|
|
model_options=getattr(model, "model_options", {}),
|
|
)
|
|
return sampler.sigmas
|