feat(sampling): add deterministic seed variation
This commit is contained in:
@@ -18,7 +18,7 @@ The pack now covers model loading, regional prompting and segmentation, high-res
|
||||
- ADetailer-style `[SEP]` prompt batches, masked conditioning, and regional samplers, with optional Prompt Control scheduling and LoRA hooks.
|
||||
- WD14 and external vision LLM tagging that stays aligned with the right regions.
|
||||
- Ordered image and mask loading, GPU Lanczos resizing, tiled VAE options, and provenance-aware latent tools.
|
||||
- WebUI-inspired sampler and scheduler extras including A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and beta57.
|
||||
- WebUI-inspired sampling extras including seed variation, A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and beta57.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -152,6 +152,8 @@ The external LLM nodes use a configured OpenAI-compatible provider. **Tag SEGS w
|
||||
|
||||
**KSampler (Extras)** adds the A1111/k-diffusion-style `euler_a_a1111` sampler, AYS SD1 and SDXL schedules, GITS, the `automatic_a1111` scheduler, and a local implementation of the RES4LYF beta57 preset. It keeps Comfy's regular seed handling, partial denoise behavior, progress callbacks, and conditioning inputs.
|
||||
|
||||
**Seed Variation** patches a MODEL so Comfy-native samplers mix their normal initial noise toward a second deterministic seed. Strength `0` keeps the sampler seed unchanged, while strength `1` uses variation-seed initial noise. Ancestral and SDE samplers continue to use the sampler seed for additional noise introduced after initialization.
|
||||
|
||||
The remaining utilities are **Latent Diagnostics**, **Scale Factor**, and **Seed**. Latent Diagnostics reports the latent shape, dtype, device, and tiled-sampling compatibility while passing it through unchanged.
|
||||
|
||||
## Settings and optional integrations
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Define validated seed-variation sampling settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
MIN_SEED = 0
|
||||
MAX_SEED = 0xFFFFFFFFFFFFFFFF
|
||||
MIN_VARIATION_STRENGTH = 0.0
|
||||
MAX_VARIATION_STRENGTH = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SeedVariationSettings:
|
||||
"""Hold one deterministic initial-noise interpolation request."""
|
||||
|
||||
variation_seed: int
|
||||
strength: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject settings outside ComfyUI's public seed and strength ranges."""
|
||||
|
||||
if isinstance(self.variation_seed, bool) or not isinstance(
|
||||
self.variation_seed,
|
||||
int,
|
||||
):
|
||||
raise TypeError("Variation seed must be an integer.")
|
||||
if not MIN_SEED <= self.variation_seed <= MAX_SEED:
|
||||
raise ValueError(
|
||||
f"Variation seed must be between {MIN_SEED} and {MAX_SEED}."
|
||||
)
|
||||
if isinstance(self.strength, bool) or not isinstance(
|
||||
self.strength,
|
||||
(int, float),
|
||||
):
|
||||
raise TypeError("Variation strength must be a number.")
|
||||
normalized_strength = float(self.strength)
|
||||
if not isfinite(normalized_strength):
|
||||
raise ValueError("Variation strength must be finite.")
|
||||
if (
|
||||
not MIN_VARIATION_STRENGTH
|
||||
<= normalized_strength
|
||||
<= (MAX_VARIATION_STRENGTH)
|
||||
):
|
||||
raise ValueError(
|
||||
"Variation strength must be between "
|
||||
f"{MIN_VARIATION_STRENGTH} and {MAX_VARIATION_STRENGTH}."
|
||||
)
|
||||
object.__setattr__(self, "strength", normalized_strength)
|
||||
@@ -115,6 +115,20 @@ SAMPLING_SEED = (
|
||||
"Seed used to create sampling noise. Reusing it with matching settings makes "
|
||||
"results repeatable."
|
||||
)
|
||||
SEED_VARIATION_MODEL_INPUT = (
|
||||
"Model whose sampler-provided initial noise will receive seed variation."
|
||||
)
|
||||
VARIATION_SEED = (
|
||||
"Seed for the second noise pattern. Change it to explore another variation "
|
||||
"direction from the sampler's seed."
|
||||
)
|
||||
VARIATION_STRENGTH = (
|
||||
"Noise interpolation strength. 0 keeps the sampler seed unchanged; 1 uses the "
|
||||
"variation seed for initial noise."
|
||||
)
|
||||
SEED_VARIATION_MODEL_OUTPUT = (
|
||||
"Model that interpolates initial sampling noise toward the variation seed."
|
||||
)
|
||||
SAMPLING_STEPS = (
|
||||
"Number of denoising steps. More steps can add refinement but take longer."
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ def get_nodes() -> list[type[object]]:
|
||||
from .load_mask_batch import LoadMaskBatchV3
|
||||
from .mask_to_segs import MaskToSEGSV3
|
||||
from .scale_factor import ScaleFactorV3
|
||||
from .seed_variation import SeedVariationV3
|
||||
from .simple_load_checkpoint import SimpleLoadCheckpointV3
|
||||
from .simple_load_flux import SimpleLoadFluxV3
|
||||
from .simple_load_flux2 import SimpleLoadFlux2V3
|
||||
@@ -114,6 +115,7 @@ def get_nodes() -> list[type[object]]:
|
||||
ScaleFactorV3,
|
||||
ConceptAttentionSEGSV3,
|
||||
SeedV3,
|
||||
SeedVariationV3,
|
||||
SimpleLoadAnimaV3,
|
||||
SimplePreviewSEGSV3,
|
||||
SimpleLoadCheckpointV3,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Expose seed variation as a Comfy v3 MODEL patch node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from ..domain.seed_variation import (
|
||||
MAX_SEED,
|
||||
MAX_VARIATION_STRENGTH,
|
||||
MIN_SEED,
|
||||
MIN_VARIATION_STRENGTH,
|
||||
)
|
||||
from ..nodes import tooltips
|
||||
from ..services.seed_variation_model_service import SEED_VARIATION_MODEL_SERVICE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class _ComfyNodeBase:
|
||||
"""Type-checking base for Comfy v3 nodes."""
|
||||
|
||||
RETURN_TYPES: ClassVar[list[str]]
|
||||
RETURN_NAMES: ClassVar[list[str]]
|
||||
|
||||
else:
|
||||
_ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode
|
||||
|
||||
_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io
|
||||
|
||||
|
||||
class SeedVariationV3(_ComfyNodeBase):
|
||||
"""Derive a MODEL that varies sampler-provided initial noise."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Declare the seed-variation MODEL patch contract."""
|
||||
|
||||
return _comfy_io.Schema(
|
||||
node_id="SimpleSyrup.SeedVariation",
|
||||
display_name="Seed Variation",
|
||||
category="SimpleSyrup/Sampling",
|
||||
description=(
|
||||
"Creates related generations by mixing sampler noise toward a "
|
||||
"second deterministic seed."
|
||||
),
|
||||
search_aliases=["variation seed", "subseed", "seed interpolation"],
|
||||
inputs=[
|
||||
_comfy_io.Model.Input(
|
||||
"model",
|
||||
tooltip=tooltips.SEED_VARIATION_MODEL_INPUT,
|
||||
),
|
||||
_comfy_io.Int.Input(
|
||||
"variation_seed",
|
||||
default=0,
|
||||
min=MIN_SEED,
|
||||
max=MAX_SEED,
|
||||
control_after_generate=True,
|
||||
tooltip=tooltips.VARIATION_SEED,
|
||||
),
|
||||
_comfy_io.Float.Input(
|
||||
"variation_strength",
|
||||
default=0.0,
|
||||
min=MIN_VARIATION_STRENGTH,
|
||||
max=MAX_VARIATION_STRENGTH,
|
||||
step=0.01,
|
||||
round=0.01,
|
||||
tooltip=tooltips.VARIATION_STRENGTH,
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Model.Output(
|
||||
"model",
|
||||
tooltip=tooltips.SEED_VARIATION_MODEL_OUTPUT,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
model: object,
|
||||
variation_seed: int,
|
||||
variation_strength: float,
|
||||
) -> tuple[object]:
|
||||
"""Return the source or a MODEL carrying initial-noise variation."""
|
||||
|
||||
return (
|
||||
SEED_VARIATION_MODEL_SERVICE.prepare(
|
||||
model=model,
|
||||
variation_seed=variation_seed,
|
||||
variation_strength=variation_strength,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Portions of this file are adapted from AUTOMATIC1111 stable-diffusion-webui.
|
||||
# See third_party/manifest.toml and third_party/NOTICE.md.
|
||||
|
||||
"""Apply deterministic seed variation at ComfyUI's outer sampling boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import torch
|
||||
from comfy.patcher_extension import WrappersMP
|
||||
|
||||
from ..domain.seed_variation import SeedVariationSettings
|
||||
from .model_patcher_mutations import ModelKeyedWrapperMutation
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE
|
||||
|
||||
SEED_VARIATION_WRAPPER_KEY = "simple_syrup.seed_variation"
|
||||
_SLERP_EPSILON = 1e-6
|
||||
|
||||
|
||||
class SeedVariationNoiseInterpolator:
|
||||
"""Generate and interpolate one variation tensor without global RNG mutation."""
|
||||
|
||||
def interpolate(
|
||||
self,
|
||||
base_noise: torch.Tensor,
|
||||
settings: SeedVariationSettings,
|
||||
) -> torch.Tensor:
|
||||
"""Return base-to-variation spherical interpolation for one sampling call."""
|
||||
|
||||
self._validate_noise(base_noise)
|
||||
if settings.strength == 0.0 or self._is_disabled_noise(base_noise):
|
||||
return base_noise
|
||||
|
||||
variation_noise = self._variation_noise_like(
|
||||
base_noise,
|
||||
settings.variation_seed,
|
||||
)
|
||||
if settings.strength == 1.0:
|
||||
return variation_noise
|
||||
return self._slerp(base_noise, variation_noise, settings.strength)
|
||||
|
||||
@staticmethod
|
||||
def _validate_noise(noise: torch.Tensor) -> None:
|
||||
"""Require the dense floating batched tensor used by Comfy samplers."""
|
||||
|
||||
if not isinstance(noise, torch.Tensor):
|
||||
raise TypeError("Seed variation requires sampler noise as a torch.Tensor.")
|
||||
if not noise.is_floating_point():
|
||||
raise TypeError("Seed variation requires floating-point sampler noise.")
|
||||
if noise.layout is not torch.strided:
|
||||
raise TypeError("Seed variation requires dense strided sampler noise.")
|
||||
if noise.ndim < 2:
|
||||
raise ValueError("Seed variation requires batched sampler noise.")
|
||||
|
||||
@staticmethod
|
||||
def _is_disabled_noise(noise: torch.Tensor) -> bool:
|
||||
"""Preserve an explicit all-zero noise request from the sampling node."""
|
||||
|
||||
return noise.numel() == 0 or not bool(torch.count_nonzero(noise).item())
|
||||
|
||||
@staticmethod
|
||||
def _variation_noise_like(noise: torch.Tensor, seed: int) -> torch.Tensor:
|
||||
"""Create Comfy-compatible seeded CPU noise and move it to the base tensor."""
|
||||
|
||||
generator = torch.Generator(device="cpu")
|
||||
generator.manual_seed(seed)
|
||||
variation = torch.randn(
|
||||
tuple(noise.shape),
|
||||
dtype=torch.float32,
|
||||
device="cpu",
|
||||
generator=generator,
|
||||
)
|
||||
return variation.to(device=noise.device, dtype=noise.dtype)
|
||||
|
||||
@staticmethod
|
||||
def _slerp(
|
||||
base_noise: torch.Tensor,
|
||||
variation_noise: torch.Tensor,
|
||||
strength: float,
|
||||
) -> torch.Tensor:
|
||||
"""Spherically interpolate each batch item with stable linear fallbacks."""
|
||||
|
||||
batch_size = int(base_noise.shape[0])
|
||||
base_flat = base_noise.to(dtype=torch.float32).reshape(batch_size, -1)
|
||||
variation_flat = variation_noise.to(dtype=torch.float32).reshape(
|
||||
batch_size,
|
||||
-1,
|
||||
)
|
||||
base_norm = torch.linalg.vector_norm(base_flat, dim=1, keepdim=True)
|
||||
variation_norm = torch.linalg.vector_norm(
|
||||
variation_flat,
|
||||
dim=1,
|
||||
keepdim=True,
|
||||
)
|
||||
valid_norms = (base_norm > _SLERP_EPSILON) & (variation_norm > _SLERP_EPSILON)
|
||||
base_unit = base_flat / base_norm.clamp_min(_SLERP_EPSILON)
|
||||
variation_unit = variation_flat / variation_norm.clamp_min(_SLERP_EPSILON)
|
||||
cosine = (base_unit * variation_unit).sum(dim=1, keepdim=True)
|
||||
cosine = cosine.clamp(min=-1.0, max=1.0)
|
||||
angle = torch.acos(cosine)
|
||||
sine = torch.sin(angle)
|
||||
stable_angle = sine.abs() > _SLERP_EPSILON
|
||||
|
||||
base_weight = torch.sin((1.0 - strength) * angle) / sine.clamp_min(
|
||||
_SLERP_EPSILON
|
||||
)
|
||||
variation_weight = torch.sin(strength * angle) / sine.clamp_min(_SLERP_EPSILON)
|
||||
spherical = base_weight * base_flat + variation_weight * variation_flat
|
||||
linear = torch.lerp(base_flat, variation_flat, strength)
|
||||
mixed = torch.where(valid_norms & stable_angle, spherical, linear)
|
||||
return mixed.reshape_as(base_noise).to(dtype=base_noise.dtype)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SeedVariationOuterSampleWrapper:
|
||||
"""Replace only the initial sampler noise before Comfy prepares the model."""
|
||||
|
||||
settings: SeedVariationSettings
|
||||
interpolator: SeedVariationNoiseInterpolator = field(
|
||||
default_factory=SeedVariationNoiseInterpolator
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
executor: Callable[..., object],
|
||||
noise: torch.Tensor,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> object:
|
||||
"""Forward sampling with interpolated noise and every other argument intact."""
|
||||
|
||||
varied_noise = self.interpolator.interpolate(noise, self.settings)
|
||||
return executor(varied_noise, *args, **kwargs)
|
||||
|
||||
|
||||
class SeedVariationModelPatchBackend:
|
||||
"""Derive a MODEL carrying one namespaced outer-sample wrapper."""
|
||||
|
||||
def derive(self, model: object, settings: SeedVariationSettings) -> object:
|
||||
"""Return the source for a no-op or a lifecycle-safe patched MODEL."""
|
||||
|
||||
if settings.strength == 0.0:
|
||||
return model
|
||||
wrapper = SeedVariationOuterSampleWrapper(settings)
|
||||
return PATCHER_LIFECYCLE.derive_model(
|
||||
model,
|
||||
(
|
||||
ModelKeyedWrapperMutation(
|
||||
wrapper_type=WrappersMP.OUTER_SAMPLE,
|
||||
key=SEED_VARIATION_WRAPPER_KEY,
|
||||
wrapper=wrapper,
|
||||
),
|
||||
),
|
||||
operation="seed variation MODEL patch",
|
||||
)
|
||||
|
||||
|
||||
SEED_VARIATION_MODEL_PATCH_BACKEND = SeedVariationModelPatchBackend()
|
||||
@@ -0,0 +1,45 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Prepare a sampling MODEL with deterministic initial-noise variation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..domain.seed_variation import SeedVariationSettings
|
||||
from ..runtime.seed_variation import SEED_VARIATION_MODEL_PATCH_BACKEND
|
||||
from ..shared.logging import get_logger
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
class SeedVariationModelService:
|
||||
"""Validate seed variation and delegate MODEL derivation to the runtime backend."""
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
*,
|
||||
model: object,
|
||||
variation_seed: int,
|
||||
variation_strength: float,
|
||||
) -> object:
|
||||
"""Return a MODEL that varies the sampler's initial noise when enabled."""
|
||||
|
||||
settings = SeedVariationSettings(
|
||||
variation_seed=variation_seed,
|
||||
strength=variation_strength,
|
||||
)
|
||||
derived = SEED_VARIATION_MODEL_PATCH_BACKEND.derive(model, settings)
|
||||
LOGGER.info(
|
||||
"Seed variation MODEL prepared",
|
||||
extra={
|
||||
"operation": "seed_variation_model_patch",
|
||||
"variation_seed": variation_seed,
|
||||
"variation_strength": settings.strength,
|
||||
"enabled": settings.strength > 0.0,
|
||||
},
|
||||
)
|
||||
return derived
|
||||
|
||||
|
||||
SEED_VARIATION_MODEL_SERVICE = SeedVariationModelService()
|
||||
@@ -57,6 +57,7 @@ BASE_NODE_IDS = [
|
||||
"SimpleSyrup.ScaleFactor",
|
||||
"SimpleSyrup.ConceptAttentionSEGS",
|
||||
"SimpleSyrup.Seed",
|
||||
"SimpleSyrup.SeedVariation",
|
||||
"SimpleSyrup.SimpleLoadAnima",
|
||||
"SimpleSyrup.SimplePreviewSEGS",
|
||||
"SimpleSyrup.SimpleLoadCheckpoint",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Tests for validated seed-variation settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from simple_syrup.domain.seed_variation import (
|
||||
MAX_SEED,
|
||||
SeedVariationSettings,
|
||||
)
|
||||
|
||||
|
||||
def test_seed_variation_settings_normalize_valid_values() -> None:
|
||||
"""Keep the full Comfy seed range and normalize numeric strength to float."""
|
||||
|
||||
settings = SeedVariationSettings(variation_seed=MAX_SEED, strength=1)
|
||||
|
||||
assert settings.variation_seed == MAX_SEED
|
||||
assert settings.strength == 1.0
|
||||
assert isinstance(settings.strength, float)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variation_seed", [-1, MAX_SEED + 1])
|
||||
def test_seed_variation_settings_reject_out_of_range_seeds(
|
||||
variation_seed: int,
|
||||
) -> None:
|
||||
"""Reject seeds outside ComfyUI's public unsigned 64-bit range."""
|
||||
|
||||
with pytest.raises(ValueError, match="Variation seed must be between"):
|
||||
SeedVariationSettings(variation_seed=variation_seed, strength=0.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variation_seed", [True, 1.5, "1"])
|
||||
def test_seed_variation_settings_reject_non_integer_seeds(
|
||||
variation_seed: object,
|
||||
) -> None:
|
||||
"""Reject boolean and coercible values at the domain boundary."""
|
||||
|
||||
with pytest.raises(TypeError, match="Variation seed must be an integer"):
|
||||
SeedVariationSettings(variation_seed=variation_seed, strength=0.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strength", [-0.01, 1.01, float("inf"), float("nan")])
|
||||
def test_seed_variation_settings_reject_invalid_strength(strength: float) -> None:
|
||||
"""Reject non-finite and out-of-range interpolation strengths."""
|
||||
|
||||
with pytest.raises(ValueError, match="Variation strength must"):
|
||||
SeedVariationSettings(variation_seed=1, strength=strength)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strength", [True, "0.5", None])
|
||||
def test_seed_variation_settings_reject_non_numeric_strength(
|
||||
strength: object,
|
||||
) -> None:
|
||||
"""Reject values that would require implicit numeric coercion."""
|
||||
|
||||
with pytest.raises(TypeError, match="Variation strength must be a number"):
|
||||
SeedVariationSettings(variation_seed=1, strength=strength) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,269 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Tests for seed variation at ComfyUI's outer sampling boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from comfy.patcher_extension import WrappersMP
|
||||
|
||||
from simple_syrup.domain.seed_variation import SeedVariationSettings
|
||||
from simple_syrup.runtime.seed_variation import (
|
||||
SEED_VARIATION_WRAPPER_KEY,
|
||||
SeedVariationModelPatchBackend,
|
||||
SeedVariationNoiseInterpolator,
|
||||
SeedVariationOuterSampleWrapper,
|
||||
)
|
||||
|
||||
|
||||
def test_strength_zero_and_disabled_noise_are_exact_passthroughs() -> None:
|
||||
"""Avoid allocation and preserve explicit sampler noise-disable behavior."""
|
||||
|
||||
interpolator = SeedVariationNoiseInterpolator()
|
||||
base_noise = torch.randn((2, 4, 8, 8), generator=_generator(10))
|
||||
disabled_noise = torch.zeros_like(base_noise)
|
||||
|
||||
unchanged = interpolator.interpolate(
|
||||
base_noise,
|
||||
SeedVariationSettings(variation_seed=20, strength=0.0),
|
||||
)
|
||||
disabled = interpolator.interpolate(
|
||||
disabled_noise,
|
||||
SeedVariationSettings(variation_seed=20, strength=1.0),
|
||||
)
|
||||
|
||||
assert unchanged is base_noise
|
||||
assert disabled is disabled_noise
|
||||
|
||||
|
||||
def test_strength_one_matches_comfy_noise_without_global_rng_mutation() -> None:
|
||||
"""Use one local CPU generator and make the variation endpoint exact."""
|
||||
|
||||
base_noise = torch.randn((2, 4, 3, 5), generator=_generator(11))
|
||||
expected = torch.randn(
|
||||
base_noise.shape,
|
||||
dtype=torch.float32,
|
||||
generator=_generator(29),
|
||||
)
|
||||
torch.manual_seed(101)
|
||||
expected_global_next = torch.randn((3,))
|
||||
torch.manual_seed(101)
|
||||
|
||||
result = SeedVariationNoiseInterpolator().interpolate(
|
||||
base_noise,
|
||||
SeedVariationSettings(variation_seed=29, strength=1.0),
|
||||
)
|
||||
actual_global_next = torch.randn((3,))
|
||||
|
||||
assert torch.equal(result, expected)
|
||||
assert torch.equal(actual_global_next, expected_global_next)
|
||||
|
||||
|
||||
def test_interpolation_is_deterministic_per_batch_item_for_video_noise() -> None:
|
||||
"""Preserve shape and dtype while applying canonical per-item SLERP."""
|
||||
|
||||
base_noise = torch.randn(
|
||||
(2, 4, 3, 5, 7),
|
||||
dtype=torch.float64,
|
||||
generator=_generator(37),
|
||||
)
|
||||
settings = SeedVariationSettings(variation_seed=41, strength=0.35)
|
||||
interpolator = SeedVariationNoiseInterpolator()
|
||||
|
||||
first = interpolator.interpolate(base_noise, settings)
|
||||
second = interpolator.interpolate(base_noise, settings)
|
||||
variation = torch.randn(
|
||||
base_noise.shape,
|
||||
dtype=torch.float32,
|
||||
generator=_generator(41),
|
||||
).to(dtype=base_noise.dtype)
|
||||
expected = _reference_slerp(base_noise, variation, settings.strength)
|
||||
|
||||
assert first.shape == base_noise.shape
|
||||
assert first.dtype == base_noise.dtype
|
||||
assert torch.equal(first, second)
|
||||
assert torch.allclose(first, expected, atol=1e-6, rtol=1e-6)
|
||||
assert not torch.equal(first[0], first[1])
|
||||
|
||||
|
||||
def test_identical_noise_uses_stable_linear_fallback() -> None:
|
||||
"""Avoid undefined spherical division for collinear seeded tensors."""
|
||||
|
||||
variation_seed = 53
|
||||
base_noise = torch.randn(
|
||||
(1, 4, 6, 6),
|
||||
dtype=torch.float32,
|
||||
generator=_generator(variation_seed),
|
||||
)
|
||||
|
||||
result = SeedVariationNoiseInterpolator().interpolate(
|
||||
base_noise,
|
||||
SeedVariationSettings(variation_seed=variation_seed, strength=0.5),
|
||||
)
|
||||
|
||||
assert torch.all(torch.isfinite(result))
|
||||
assert torch.allclose(result, base_noise)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("noise", "message"),
|
||||
[
|
||||
(torch.ones((1, 2), dtype=torch.int64), "floating-point"),
|
||||
(torch.ones((4,), dtype=torch.float32), "batched"),
|
||||
],
|
||||
)
|
||||
def test_interpolator_rejects_unsupported_noise_tensors(
|
||||
noise: torch.Tensor,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Fail clearly before unsupported sampler noise enters interpolation."""
|
||||
|
||||
with pytest.raises((TypeError, ValueError), match=message):
|
||||
SeedVariationNoiseInterpolator().interpolate(
|
||||
noise,
|
||||
SeedVariationSettings(variation_seed=1, strength=0.5),
|
||||
)
|
||||
|
||||
|
||||
def test_outer_sample_wrapper_changes_only_noise_argument() -> None:
|
||||
"""Forward sampler, latent, callbacks, and base seed without modification."""
|
||||
|
||||
base_noise = torch.randn((1, 4, 4, 4), generator=_generator(61))
|
||||
latent = torch.zeros_like(base_noise)
|
||||
sampler = object()
|
||||
sigmas = torch.tensor([1.0, 0.0])
|
||||
callback = object()
|
||||
calls: list[tuple[torch.Tensor, tuple[object, ...], dict[str, object]]] = []
|
||||
|
||||
def executor(
|
||||
noise: torch.Tensor,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""Record wrapper forwarding and return a stable sentinel."""
|
||||
|
||||
calls.append((noise, args, kwargs))
|
||||
return "sampled"
|
||||
|
||||
wrapper = SeedVariationOuterSampleWrapper(
|
||||
SeedVariationSettings(variation_seed=67, strength=1.0)
|
||||
)
|
||||
result = wrapper(
|
||||
executor,
|
||||
base_noise,
|
||||
latent,
|
||||
sampler,
|
||||
sigmas,
|
||||
None,
|
||||
callback,
|
||||
False,
|
||||
1234,
|
||||
latent_shapes=[tuple(latent.shape)],
|
||||
)
|
||||
|
||||
assert result == "sampled"
|
||||
assert len(calls) == 1
|
||||
varied_noise, forwarded_args, forwarded_kwargs = calls[0]
|
||||
assert torch.equal(
|
||||
varied_noise,
|
||||
torch.randn(base_noise.shape, generator=_generator(67)),
|
||||
)
|
||||
assert forwarded_args == (
|
||||
latent,
|
||||
sampler,
|
||||
sigmas,
|
||||
None,
|
||||
callback,
|
||||
False,
|
||||
1234,
|
||||
)
|
||||
assert forwarded_kwargs == {"latent_shapes": [tuple(latent.shape)]}
|
||||
|
||||
|
||||
def test_model_patch_backend_uses_one_collision_safe_outer_wrapper() -> None:
|
||||
"""Preserve source state and direct lineage through the native wrapper surface."""
|
||||
|
||||
source = _patcher()
|
||||
backend = SeedVariationModelPatchBackend()
|
||||
settings = SeedVariationSettings(variation_seed=71, strength=0.4)
|
||||
|
||||
derived = cast(Any, backend.derive(source, settings))
|
||||
|
||||
assert derived is not source
|
||||
assert derived.parent is source
|
||||
assert (
|
||||
source.get_wrappers(WrappersMP.OUTER_SAMPLE, SEED_VARIATION_WRAPPER_KEY) == []
|
||||
)
|
||||
wrappers = derived.get_wrappers(
|
||||
WrappersMP.OUTER_SAMPLE,
|
||||
SEED_VARIATION_WRAPPER_KEY,
|
||||
)
|
||||
assert len(wrappers) == 1
|
||||
assert isinstance(wrappers[0], SeedVariationOuterSampleWrapper)
|
||||
assert wrappers[0].settings == settings
|
||||
|
||||
|
||||
def test_model_patch_backend_returns_source_when_variation_is_disabled() -> None:
|
||||
"""Avoid a redundant MODEL clone when strength makes the operation a no-op."""
|
||||
|
||||
source = _patcher()
|
||||
|
||||
result = cast(
|
||||
Any,
|
||||
SeedVariationModelPatchBackend().derive(
|
||||
source,
|
||||
SeedVariationSettings(variation_seed=79, strength=0.0),
|
||||
),
|
||||
)
|
||||
|
||||
assert result is source
|
||||
assert source.wrappers == {}
|
||||
|
||||
|
||||
def _generator(seed: int) -> torch.Generator:
|
||||
"""Return an isolated CPU generator for deterministic expectations."""
|
||||
|
||||
return torch.Generator(device="cpu").manual_seed(seed)
|
||||
|
||||
|
||||
def _reference_slerp(
|
||||
base_noise: torch.Tensor,
|
||||
variation_noise: torch.Tensor,
|
||||
strength: float,
|
||||
) -> torch.Tensor:
|
||||
"""Calculate independent canonical batch SLERP for behavior comparison."""
|
||||
|
||||
outputs: list[torch.Tensor] = []
|
||||
for base_item, variation_item in zip(base_noise, variation_noise, strict=True):
|
||||
base_flat = base_item.float().flatten()
|
||||
variation_flat = variation_item.float().flatten()
|
||||
cosine = torch.dot(
|
||||
base_flat / torch.linalg.vector_norm(base_flat),
|
||||
variation_flat / torch.linalg.vector_norm(variation_flat),
|
||||
).clamp(-1.0, 1.0)
|
||||
angle = torch.acos(cosine)
|
||||
sine = torch.sin(angle)
|
||||
mixed = (
|
||||
torch.sin((1.0 - strength) * angle) / sine * base_flat
|
||||
+ torch.sin(strength * angle) / sine * variation_flat
|
||||
)
|
||||
outputs.append(mixed.reshape_as(base_item).to(dtype=base_item.dtype))
|
||||
return torch.stack(outputs)
|
||||
|
||||
|
||||
def _patcher() -> Any:
|
||||
"""Create a real CPU Comfy MODEL patcher for wrapper integration."""
|
||||
|
||||
from comfy.model_patcher import ModelPatcher
|
||||
|
||||
device = torch.device("cpu")
|
||||
return ModelPatcher(
|
||||
torch.nn.Linear(1, 1),
|
||||
load_device=device,
|
||||
offload_device=device,
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Tests for the Seed Variation Comfy v3 MODEL patch node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from simple_syrup.nodes import tooltips
|
||||
from simple_syrup.nodes_v3 import seed_variation
|
||||
from simple_syrup.nodes_v3.seed_variation import SeedVariationV3
|
||||
|
||||
|
||||
def test_seed_variation_v3_schema_exposes_complete_model_patch_contract() -> None:
|
||||
"""Pin the public id, controls, descriptions, and MODEL output metadata."""
|
||||
|
||||
schema = SeedVariationV3.define_schema()
|
||||
|
||||
assert schema.node_id == "SimpleSyrup.SeedVariation"
|
||||
assert schema.display_name == "Seed Variation"
|
||||
assert schema.category == "SimpleSyrup/Sampling"
|
||||
assert "mixing sampler noise" in schema.description
|
||||
assert [item.id for item in schema.inputs] == [
|
||||
"model",
|
||||
"variation_seed",
|
||||
"variation_strength",
|
||||
]
|
||||
model_input, seed_input, strength_input = schema.inputs
|
||||
assert model_input.io_type == "MODEL"
|
||||
assert model_input.tooltip == tooltips.SEED_VARIATION_MODEL_INPUT
|
||||
assert seed_input.io_type == "INT"
|
||||
assert seed_input.default == 0
|
||||
assert seed_input.min == 0
|
||||
assert seed_input.max == 0xFFFFFFFFFFFFFFFF
|
||||
assert seed_input.control_after_generate is True
|
||||
assert seed_input.tooltip == tooltips.VARIATION_SEED
|
||||
assert strength_input.io_type == "FLOAT"
|
||||
assert strength_input.default == 0.0
|
||||
assert strength_input.min == 0.0
|
||||
assert strength_input.max == 1.0
|
||||
assert strength_input.step == 0.01
|
||||
assert strength_input.tooltip == tooltips.VARIATION_STRENGTH
|
||||
assert [output.id for output in schema.outputs] == ["model"]
|
||||
assert schema.outputs[0].io_type == "MODEL"
|
||||
assert schema.outputs[0].tooltip == tooltips.SEED_VARIATION_MODEL_OUTPUT
|
||||
|
||||
|
||||
def test_seed_variation_v3_execute_delegates_to_service(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Keep the Comfy-facing node thin and return the service result exactly."""
|
||||
|
||||
source = object()
|
||||
derived = object()
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class RecordingService:
|
||||
"""Record one node-to-service request."""
|
||||
|
||||
def prepare(self, **kwargs: object) -> object:
|
||||
"""Capture keyword arguments and return a stable MODEL sentinel."""
|
||||
|
||||
calls.append(kwargs)
|
||||
return derived
|
||||
|
||||
monkeypatch.setattr(
|
||||
seed_variation,
|
||||
"SEED_VARIATION_MODEL_SERVICE",
|
||||
RecordingService(),
|
||||
)
|
||||
|
||||
result = SeedVariationV3.execute(source, 83, 0.25)
|
||||
|
||||
assert result == (derived,)
|
||||
assert calls == [
|
||||
{
|
||||
"model": source,
|
||||
"variation_seed": 83,
|
||||
"variation_strength": 0.25,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_seed_variation_v3_execute_surfaces_invalid_settings() -> None:
|
||||
"""Expose actionable domain validation before a MODEL can be mutated."""
|
||||
|
||||
with pytest.raises(ValueError, match="Variation strength must be between"):
|
||||
SeedVariationV3.execute(object(), 1, 1.5)
|
||||
@@ -91,7 +91,7 @@ def test_automatic1111_sampler_integration_provenance_is_recorded() -> None:
|
||||
)
|
||||
components = {component["name"]: component for component in manifest["component"]}
|
||||
|
||||
automatic1111 = components["AUTOMATIC1111 Euler a sampler integration"]
|
||||
automatic1111 = components["AUTOMATIC1111 sampling integration"]
|
||||
license_path = REPO_ROOT / automatic1111["license_file"]
|
||||
|
||||
assert automatic1111["license"] == "AGPL-3.0"
|
||||
@@ -111,6 +111,7 @@ def test_automatic1111_sampler_integration_provenance_is_recorded() -> None:
|
||||
"simple_syrup/runtime/a1111_sampling.py",
|
||||
"simple_syrup/runtime/sampling_samplers.py",
|
||||
"simple_syrup/runtime/sampling_schedulers.py",
|
||||
"simple_syrup/runtime/seed_variation.py",
|
||||
]
|
||||
|
||||
|
||||
@@ -187,7 +188,7 @@ def test_notice_records_sampler_and_tiled_diffusion_provenance() -> None:
|
||||
|
||||
notice = (REPO_ROOT / "third_party" / "NOTICE.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "AUTOMATIC1111 Euler a sampler integration" in notice
|
||||
assert "AUTOMATIC1111 sampling integration" in notice
|
||||
assert "k-diffusion Euler ancestral sampler" in notice
|
||||
assert "Mixture of Diffusers and MultiDiffusion tiled diffusion behavior" in notice
|
||||
assert "regional prompt mask blending" in notice
|
||||
|
||||
Vendored
+4
-3
@@ -25,12 +25,13 @@ The preset uses ComfyUI's beta scheduler with `alpha=0.5` and `beta=0.7`.
|
||||
SimpleSyrup resolves the preset locally for `KSampler (Extras)` and does not
|
||||
patch ComfyUI's global scheduler registry.
|
||||
|
||||
## AUTOMATIC1111 Euler a sampler integration
|
||||
## AUTOMATIC1111 sampling integration
|
||||
|
||||
SimpleSyrup vendors selected AUTOMATIC1111 WebUI sampler integration behavior
|
||||
under AGPL-3.0. This provenance covers the `Euler a` sampler mapping, the
|
||||
`Automatic` scheduler fallback behavior, and the documented decision not to
|
||||
port AUTOMATIC1111 ENSD or RNG hijacking behavior.
|
||||
`Automatic` scheduler fallback behavior, and seed-variation interpolation at
|
||||
ComfyUI's model-carried outer sampling boundary. SimpleSyrup does not port
|
||||
AUTOMATIC1111 ENSD or global RNG hijacking behavior.
|
||||
|
||||
## k-diffusion Euler ancestral sampler
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -76,7 +76,7 @@ vendored_files = [
|
||||
]
|
||||
|
||||
[[component]]
|
||||
name = "AUTOMATIC1111 Euler a sampler integration"
|
||||
name = "AUTOMATIC1111 sampling integration"
|
||||
license = "AGPL-3.0"
|
||||
license_file = "third_party/licenses/automatic1111.LICENSE.txt"
|
||||
source = "https://github.com/AUTOMATIC1111/stable-diffusion-webui"
|
||||
@@ -91,6 +91,7 @@ vendored_files = [
|
||||
"simple_syrup/runtime/a1111_sampling.py",
|
||||
"simple_syrup/runtime/sampling_samplers.py",
|
||||
"simple_syrup/runtime/sampling_schedulers.py",
|
||||
"simple_syrup/runtime/seed_variation.py",
|
||||
]
|
||||
|
||||
[[component]]
|
||||
|
||||
Reference in New Issue
Block a user