Files
Your Name 08fc0f609f Rewrite SamplerSPEED to match official speed/comfyui, update README with flux preset results and attribution
- Rewrite speed_sampler.py: hard DCT truncation, DCT/DWT/FFT support,
  delta_optimal + manual modes, per-transition seed offset, single-sigma
  patching, flux/wan21 presets
- Add spectral_utils.py: faithful extraction of paper's Eq. 5/6/9/10 and
  spectral expansion from official speed/utils.py
- Update README with flux preset results (20s), new input docs, remove
  old acidmiku/vibecoded notes
- Add attribution to each Python file: https://github.com/howardhx/speed
- 23 tests passing for spectral_utils.py
2026-06-28 23:27:06 +00:00

378 lines
14 KiB
Python

"""ComfyUI sampler for Spectral Progressive Diffusion.
Based on https://github.com/howardhx/speed
Wraps any ``comfy.k_diffusion.sampling.sample_*`` solver with the resolution
transitions from the paper. The denoising trajectory is segmented at each
transition; between segments the latent is spectrally expanded and
timestep-aligned.
"""
from __future__ import annotations
from typing import List, Tuple
import numpy as np
import torch
import comfy.samplers
from comfy_api.latest import io
import comfy.k_diffusion.sampling as kds
from .spectral_utils import (
_dct_expand_np,
_dwt_expand_np,
_fft_expand_np,
align_timestep,
delta_optimal_transitions,
kappa,
validate_scales,
)
# =============================================================================
# Per-model power-spectrum presets.
# =============================================================================
_PRESETS = {
"flux": {"A": 203.615097, "beta": 1.915461},
"wan21": {"A": 219.484718, "beta": 2.422687},
"custom": None,
}
# =============================================================================
# Parsing helpers
# =============================================================================
def _parse_scales(s: str) -> List[float]:
"""Parse a comma-separated scale list."""
out = [float(x.strip()) for x in s.split(",") if x.strip()]
validate_scales(out)
return out
def _parse_sigmas(s: str) -> List[float]:
"""Parse comma-separated manual transition sigmas."""
out = [float(x.strip()) for x in s.split(",") if x.strip()]
if any(not (0.0 < v < 1.0) for v in out):
raise ValueError(f"every manual sigma must be in (0, 1); got {out}")
for a, b in zip(out[:-1], out[1:]):
if not (a > b):
raise ValueError(f"manual sigmas must be strictly decreasing; got {out}")
return out
# =============================================================================
# Spectral transition helper.
# =============================================================================
def _expand_and_align_torch(
x: torch.Tensor, s_i: float, s_next: float, t: float,
transform: str, seed: int, H_full: int, W_full: int,
) -> Tuple[torch.Tensor, float]:
"""Expand a 4D image latent or 5D video latent over its spatial axes."""
if transform not in ("dct", "dwt", "fft"):
raise ValueError(f"transform must be dct|dwt|fft, got {transform!r}")
r = s_next / s_i
H_tgt = round(s_next * H_full)
W_tgt = round(s_next * W_full)
if x.ndim == 5:
B, C, T_frames, h_lo, w_lo = x.shape
x4 = x.permute(0, 2, 1, 3, 4).reshape(B * T_frames, C, h_lo, w_lo)
elif x.ndim == 4:
x4 = x
else:
raise ValueError(f"expected 4D or 5D latent, got shape {tuple(x.shape)}")
x_np = x4.detach().cpu().float().numpy()
if transform == "dwt":
if abs(r - 2.0) > 1e-6:
raise ValueError(
f"DWT requires r=2 between consecutive scales; got r={r:.4f}. "
"Use transform=dct or transform=fft for non-dyadic ratios."
)
expanded = _dwt_expand_np(x_np, t, seed)
elif transform == "dct":
expanded = _dct_expand_np(x_np, (H_tgt, W_tgt), t, seed)
else:
expanded = _fft_expand_np(x_np, (H_tgt, W_tgt), t, seed)
rescaled = (kappa(t, r) * expanded).astype(np.float32)
x4_new = torch.from_numpy(rescaled).to(device=x.device, dtype=x.dtype)
if x.ndim == 5:
out = x4_new.reshape(B, T_frames, C, H_tgt, W_tgt).permute(0, 2, 1, 3, 4)
else:
out = x4_new
return out, align_timestep(t, r)
# =============================================================================
# Initial coarse-resolution latent.
# =============================================================================
def _initial_dct_downscale(x: torch.Tensor, scale: float) -> torch.Tensor:
"""Downscale ``x`` by DCT truncation."""
if scale >= 1.0:
return x
H_full, W_full = x.shape[-2], x.shape[-1]
H_lo, W_lo = round(H_full * scale), round(W_full * scale)
if x.ndim == 5:
B, C, T_frames, _, _ = x.shape
x4 = x.permute(0, 2, 1, 3, 4).reshape(B * T_frames, C, H_full, W_full)
else:
x4 = x
x_np = x4.detach().cpu().float().numpy()
from scipy.fft import dctn, idctn
out_np = np.empty(x_np.shape[:-2] + (H_lo, W_lo), dtype=np.float32)
for idx in np.ndindex(*x_np.shape[:-2]):
coeffs = dctn(x_np[idx], type=2, norm="ortho")
out_np[idx] = idctn(coeffs[:H_lo, :W_lo], type=2, norm="ortho").astype(np.float32)
out4 = torch.from_numpy(out_np).to(device=x.device, dtype=x.dtype)
if x.ndim == 5:
return out4.reshape(B, T_frames, C, H_lo, W_lo).permute(0, 2, 1, 3, 4)
return out4
# =============================================================================
# Transition scheduling.
# =============================================================================
def _resolve_transitions(
sigmas: torch.Tensor, scales: List[float], delta: float, A: float, beta: float,
H_full: int, W_full: int,
) -> List[Tuple[int, float, float]]:
"""Return ``(step_idx, s_i, s_next)`` transitions from ``scales`` and ``delta``."""
if len(scales) < 2:
return []
t_stars = delta_optimal_transitions(scales, delta, A, beta, H_full, W_full)
out: List[Tuple[int, float, float]] = []
n_steps = len(sigmas) - 1
for i, (s_old, s_new, t_thr) in enumerate(zip(scales[:-1], scales[1:], t_stars)):
step_idx = next(
(j for j in range(n_steps) if float(sigmas[j]) <= t_thr),
n_steps,
)
if step_idx >= n_steps:
break
out.append((step_idx, s_old, s_new))
return out
def _resolve_manual(
sigmas: torch.Tensor, scales: List[float], manual_sigmas: List[float],
) -> List[Tuple[int, float, float]]:
"""Return transitions from user-specified sigma thresholds."""
if len(scales) < 2:
return []
if len(manual_sigmas) != len(scales) - 1:
raise ValueError(
f"manual_sigmas has length {len(manual_sigmas)}, expected "
f"{len(scales) - 1} (one threshold per transition in scales)."
)
out: List[Tuple[int, float, float]] = []
n_steps = len(sigmas) - 1
for s_old, s_new, thr in zip(scales[:-1], scales[1:], manual_sigmas):
step_idx = next(
(j for j in range(n_steps) if float(sigmas[j]) <= thr),
n_steps,
)
if step_idx >= n_steps:
break
out.append((step_idx, s_old, s_new))
return out
# =============================================================================
# Segmented sampling.
# =============================================================================
def _segment_callback(outer_cb, segment_start_idx: int):
"""Re-base callback step indices to the full schedule."""
if outer_cb is None:
return None
def inner(d):
d = dict(d)
d["i"] = d.get("i", 0) + segment_start_idx
outer_cb(d)
return inner
@torch.no_grad()
def sample_speed(
model, x, sigmas, extra_args=None, callback=None, disable=None,
*,
transform: str = "dct",
base_sampler: str = "euler",
mode: str = "delta_optimal",
scales: List[float] = None,
delta: float = 0.01,
spectrum_A: float = 203.615097,
spectrum_beta: float = 1.915461,
manual_sigmas: List[float] = None,
seed: int = 0,
):
"""Comfy-compatible ``sample_*`` function."""
extra_args = {} if extra_args is None else extra_args
sampler_fn = getattr(kds, f"sample_{base_sampler}", None)
if sampler_fn is None:
raise ValueError(f"Unknown base sampler {base_sampler!r}.")
H_full, W_full = x.shape[-2], x.shape[-1]
if not scales or len(scales) < 2:
return sampler_fn(model, x, sigmas, extra_args=extra_args,
callback=callback, disable=disable)
first_scale = scales[0]
if mode == "delta_optimal":
transitions = _resolve_transitions(
sigmas, scales, delta, spectrum_A, spectrum_beta, H_full, W_full,
)
elif mode == "manual":
transitions = _resolve_manual(sigmas, scales, manual_sigmas or [])
else:
raise ValueError(f"mode must be delta_optimal|manual, got {mode!r}")
# DCT-truncate the incoming latent down to the coarsest scale.
if first_scale < 1.0:
x = _initial_dct_downscale(x, first_scale)
sigmas = sigmas.clone()
segment_starts = [0] + [t[0] for t in transitions]
for seg_i, seg_start in enumerate(segment_starts):
seg_end = transitions[seg_i][0] if seg_i < len(transitions) else len(sigmas) - 1
seg_sigmas = sigmas[seg_start:seg_end + 1]
if len(seg_sigmas) >= 2:
cb = _segment_callback(callback, seg_start)
x = sampler_fn(model, x, seg_sigmas, extra_args=extra_args,
callback=cb, disable=disable)
if seg_i >= len(transitions):
break
step_idx, s_i, s_next = transitions[seg_i]
sigma_at_transition = float(sigmas[step_idx])
x, t_tilde = _expand_and_align_torch(
x, s_i, s_next, sigma_at_transition,
transform=transform, seed=seed + (seg_i + 1) * 10000,
H_full=H_full, W_full=W_full,
)
# Patch only the transition sigma, matching the reference inference loop.
sigmas[step_idx] = float(t_tilde)
return x
# =============================================================================
# Comfy node
# =============================================================================
def _list_samplers() -> List[str]:
"""Return supported k-diffusion sampler names."""
excluded = {"dpm_fast", "dpm_adaptive", "lcm"}
try:
names = [a[len("sample_"):] for a in dir(kds) if a.startswith("sample_")]
except Exception:
names = ["euler", "euler_ancestral", "heun", "dpmpp_2m", "uni_pc"]
return sorted(n for n in names if n not in excluded)
class SamplerSPEED(io.ComfyNode):
"""Spectral Progressive Diffusion sampler node."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="SamplerSPEED",
display_name="Sampler SPEED (Spectral Progressive Diffusion)",
category="sampling/custom_sampling/samplers",
inputs=[
io.Combo.Input(
"base_sampler", options=_list_samplers(), default="euler",
tooltip="Underlying ODE solver. Any comfy k_diffusion sampler is "
"supported. Multistep solvers restart "
"at each SPEED transition because we segment the schedule.",
),
io.Combo.Input(
"transform", options=["dct", "dwt", "fft"], default="dct",
tooltip="Spectral basis used at each transition. DCT (default) "
"supports any scale ratio; DWT requires consecutive scales "
"to differ by exactly 2x; FFT accepts any ratio.",
),
io.Combo.Input(
"mode", options=["delta_optimal", "manual"], default="delta_optimal",
tooltip="`delta_optimal` computes transitions from `scales`, "
"`delta`, and the selected power-spectrum preset. "
"`manual` uses user-specified sigma thresholds.",
),
io.Combo.Input(
"model_preset", options=list(_PRESETS.keys()), default="flux",
tooltip="Power-spectrum preset for delta-optimal mode. `flux` and "
"`wan21` use measured (A, beta). `custom` uses "
"the manual A / beta inputs below.",
),
io.String.Input(
"scales", default="0.5,1.0",
tooltip="Comma-separated resolution fractions ending at 1.0. Used in "
"delta_optimal mode. Example: `0.5,1.0` or `0.25,0.5,1.0`.",
),
io.Float.Input(
"delta", default=0.01, min=1e-4, max=0.5, step=0.001,
tooltip="Noise-dominated tolerance. Smaller values transition later.",
),
io.String.Input(
"manual_sigmas", default="0.85",
tooltip="Comma-separated sigma thresholds, one per transition (length "
"S-1 to match `scales`). Used in `manual` mode. Example "
"for scales=`0.25,0.5,1.0`: `0.95,0.85`.",
),
io.Float.Input(
"spectrum_A", default=203.615097, min=0.0, max=1e6, step=0.001,
tooltip="Power-spectrum amplitude A (used when model_preset=custom).",
),
io.Float.Input(
"spectrum_beta", default=1.915461, min=0.0, max=10.0, step=0.001,
tooltip="Power-spectrum decay exponent beta (used when model_preset=custom).",
),
io.Int.Input(
"seed", default=0, min=0, max=2**31 - 1, step=1,
tooltip="Seed for the spectral-noise padding at each transition.",
),
],
outputs=[io.Sampler.Output()],
)
@classmethod
def execute(cls, base_sampler, transform, mode, model_preset, scales, delta,
manual_sigmas, spectrum_A, spectrum_beta, seed):
preset = _PRESETS.get(model_preset)
if preset is not None:
A, beta = preset["A"], preset["beta"]
else:
A, beta = float(spectrum_A), float(spectrum_beta)
parsed_scales = _parse_scales(scales)
parsed_sigmas = _parse_sigmas(manual_sigmas) if mode == "manual" else []
sampler = comfy.samplers.KSAMPLER(
sample_speed,
extra_options={
"transform": transform,
"base_sampler": base_sampler,
"mode": mode,
"scales": parsed_scales,
"delta": float(delta),
"spectrum_A": A,
"spectrum_beta": beta,
"manual_sigmas": parsed_sigmas,
"seed": int(seed),
},
)
return io.NodeOutput(sampler)