Stages have been segments of one trajectory since 2.0, but every stage drew the
same shader at the same zoom and differed only in strength. The trajectory is
not uniform -- early steps settle composition, late steps settle detail -- so
identical noise character at both ends wastes structure the pipeline already has.
`stage_progression`, default "uniform" so nothing changes unasked:
coarse_to_fine starts zoomed in on large features with fewer octaves and
ends zoomed out on small ones with more, lining the noise up
with what each part of the run is deciding
fine_to_coarse the mirror
The README already calls noise_scale the zoom control and octaves the detail
slider; this ties them to position in the schedule. The span is 0.5x to 2x
noise_scale and plus or minus one octave, centred on the widget values, so a
shaped run still sits on the settings the user chose rather than drifting off
them. Deliberately modest: this shapes the walk, it is not a second strength.
Position comes from the boundary's place in the schedule, not the stage index.
Sequential and injection stages interleave, and what matters is how far along
the trajectory the noise lands, not which list it came from.
Event tuples widen from (strength, seed) to (strength, seed, shaping), with
shaping empty under the default. _shaped() copies the params dict rather than
adjusting it, since one dict is shared across every stage -- there is a test for
that, because mutating it would have silently compounded stage after stage.
7 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.0 KiB
Python
188 lines
7.0 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
|
|
|
|
|
|
# How far the zoom and detail controls move across the trajectory, as a
|
|
# multiplier on noise_scale and an offset on octaves at the far end. Modest on
|
|
# purpose: this shapes the walk, it is not meant to be a second strength knob.
|
|
PROGRESSIONS = ("uniform", "coarse_to_fine", "fine_to_coarse")
|
|
_SCALE_SPAN = (0.5, 2.0)
|
|
_OCTAVE_SPAN = (-1.0, 1.0)
|
|
|
|
|
|
def stage_shaping(progression: str, progress: float) -> dict:
|
|
"""
|
|
Per-stage overrides for a boundary `progress` of the way through the schedule.
|
|
|
|
The diffusion trajectory is not uniform -- early steps settle composition and
|
|
late steps settle detail -- but every stage has always drawn the same shader
|
|
at the same zoom. `coarse_to_fine` starts zoomed in on large features and ends
|
|
on small ones, which lines the noise up with what each part of the trajectory
|
|
is actually deciding. The README already calls noise_scale the zoom control
|
|
and octaves the detail slider; this just ties them to position.
|
|
|
|
Returns multiplicative/additive adjustments, not absolute values, so the
|
|
node's own widget settings stay the centre of the range.
|
|
"""
|
|
if progression not in PROGRESSIONS or progression == "uniform":
|
|
return {}
|
|
|
|
position = min(max(progress, 0.0), 1.0)
|
|
if progression == "fine_to_coarse":
|
|
position = 1.0 - position
|
|
|
|
scale_low, scale_high = _SCALE_SPAN
|
|
octave_low, octave_high = _OCTAVE_SPAN
|
|
return {
|
|
"scale_multiplier": scale_low + (scale_high - scale_low) * position,
|
|
"octave_offset": octave_low + (octave_high - octave_low) * position,
|
|
}
|