Files
Æmotion StudioandClaude Opus 5 f44aeb5c50 fix: keep the base noise's own statistics when blending
mix_noise rescaled its result to mean 0 and deviation 1 per channel. The base
noise ComfyUI generates is only approximately that, so the moment strength left 0
the starting noise moved by 1 to 3 per cent before the shader contributed anything:
at strength 0.001 that rescale was the whole change. Strength 0 returns the base
untouched, so the road away from the seed's own image started with a jump.

The blend now takes the base noise's own per-channel mean and deviation. Strength 0
is still bit-identical, and as strength approaches 0 the result approaches the base.
Measured on real renders, from strength 0 to 0.001 the image now moves 0.03 of the
typical distance between two seeds' images on SD 1.5, where it moved 0.32. On
MiniMax H3 it moves 0.38, where it moved 0.48; the rest is H3 responding to a 0.06
per cent change in its noise. At 0.05, SD 1.5 lands 0.28 of that distance from the
seed's image instead of 0.41. From 0.25 up SD 1.5 is unchanged, and H3 sits nearer
its seed's image across most of the range.

Two tests pin it: a vanishingly small strength returns the base for every blend
mode, and a base with non-unit statistics keeps them. The golden fixtures are
re-captured, since every non-zero-strength case changes by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:05:21 -07:00

239 lines
11 KiB
Python

"""
Noise composition for the standard sampling pipeline.
Samplers expect their noise to be standard normal. The blend modes come from
image compositing, where values live in [0, 1] -- `1 - (1 - b) * (1 - s)` is
meaningless for b = -2.3 -- so applying them straight to N(0, 1) noise shifts
the mean and variance. Measured on unit-Gaussian inputs, the old blending gave
a mean of +0.39 for overlay at strength 0.3 and a standard deviation of 4.2 for
soft_light at strength 1.0, which reaches the model as a colour cast and as
washed-out or burnt contrast.
So the compositing modes run in uniform space: map the noise through the normal
CDF to (0, 1), composite there, map back, interpolate by strength, and give the
result the base noise's own mean and deviation. The mode still shapes the
*structure* of the noise, but what reaches the sampler keeps the distribution the
model was trained on.
Legacy mode keeps the old, unnormalised behaviour on purpose.
"""
import math
import torch
# Modes whose formulas assume [0, 1] data and are composited in uniform space.
_UNIT_SPACE_MODES = ("multiply", "screen", "overlay", "soft_light", "hard_light", "difference")
SUPPORTED_MODES = ("normal", "add") + _UNIT_SPACE_MODES
# How much shader the sampler actually receives, per mode, at strength 0.0 to 1.0
# in steps of 0.05. Measured as the cosine between the mixed noise and the shader
# noise: mix_noise standardises both operands, so the result lies on the unit
# sphere they span and that cosine is exactly the shader's share of it.
#
# The modes disagree wildly. At a strength of 0.5, `normal` hands over 0.71 of
# the shader and `difference` 0.03 -- a factor of twenty-three for the same
# number on the same slider. That is why one strength value cannot be carried
# from one mode to another, and it lines up with what the modes do to a picture:
# on MiniMax H3, `add` breaks down around 0.15 and `soft_light` is still clean at
# 0.3.
#
# Regenerate with tests/test_noise_math.py::test_blend_calibration_is_current,
# which re-measures this and fails on drift. The curves vary by at most 0.07
# across shader types, shape masks and latent ranks, so one table serves all.
BLEND_SHADER_FRACTION = {
"normal": (0.0000, 0.0780, 0.1560, 0.2330, 0.3086, 0.3823, 0.4537, 0.5222, 0.5875, 0.6492, 0.7069, 0.7603, 0.8089, 0.8526, 0.8910, 0.9239, 0.9510, 0.9724, 0.9877, 0.9969, 1.0000),
"add": (0.0000, 0.0495, 0.0990, 0.1479, 0.1957, 0.2421, 0.2869, 0.3299, 0.3710, 0.4100, 0.4469, 0.4816, 0.5142, 0.5447, 0.5732, 0.5997, 0.6245, 0.6474, 0.6688, 0.6886, 0.7069),
"multiply": (0.0000, 0.0284, 0.0584, 0.0895, 0.1216, 0.1546, 0.1885, 0.2232, 0.2585, 0.2941, 0.3300, 0.3660, 0.4018, 0.4372, 0.4719, 0.5059, 0.5388, 0.5705, 0.6009, 0.6298, 0.6571),
"screen": (0.0000, 0.0321, 0.0660, 0.1010, 0.1370, 0.1740, 0.2118, 0.2502, 0.2889, 0.3279, 0.3668, 0.4054, 0.4434, 0.4806, 0.5167, 0.5516, 0.5850, 0.6168, 0.6469, 0.6752, 0.7016),
"overlay": (0.0000, 0.0295, 0.0591, 0.0883, 0.1169, 0.1450, 0.1725, 0.1992, 0.2252, 0.2505, 0.2750, 0.2987, 0.3215, 0.3436, 0.3648, 0.3852, 0.4048, 0.4237, 0.4417, 0.4591, 0.4756),
"soft_light": (0.0000, 0.0182, 0.0367, 0.0551, 0.0733, 0.0914, 0.1092, 0.1269, 0.1443, 0.1615, 0.1784, 0.1951, 0.2115, 0.2276, 0.2435, 0.2590, 0.2743, 0.2893, 0.3039, 0.3183, 0.3324),
"hard_light": (0.0000, 0.0555, 0.1134, 0.1726, 0.2325, 0.2925, 0.3518, 0.4095, 0.4652, 0.5181, 0.5678, 0.6139, 0.6563, 0.6950, 0.7299, 0.7612, 0.7892, 0.8139, 0.8358, 0.8551, 0.8720),
"difference": (0.0000, 0.0016, 0.0040, 0.0065, 0.0093, 0.0122, 0.0154, 0.0188, 0.0224, 0.0260, 0.0295, 0.0330, 0.0362, 0.0391, 0.0416, 0.0437, 0.0453, 0.0466, 0.0475, 0.0480, 0.0484),
}
# The mode the normalised scale is expressed in, so a workflow that never leaves
# the default sees the same numbers it always did.
CALIBRATION_REFERENCE = "multiply"
def _interpolate(curve, position: float) -> float:
"""Read a value off a 21-point 0..1 curve."""
if position <= 0.0:
return curve[0]
if position >= 1.0:
return curve[-1]
index = position * (len(curve) - 1)
low = int(index)
return curve[low] + (curve[low + 1] - curve[low]) * (index - low)
def _invert(curve, value: float) -> float:
"""Smallest strength on a monotone curve reaching `value`, 1.0 if unreachable."""
if value <= curve[0]:
return 0.0
for i in range(1, len(curve)):
if curve[i] >= value:
span = curve[i] - curve[i - 1]
frac = 1.0 if span <= 0 else (value - curve[i - 1]) / span
return (i - 1 + frac) / (len(curve) - 1)
return 1.0
def normalized_strength(mode: str, strength: float) -> float:
"""
Re-express `strength` so it hands the sampler the same amount of shader
whichever blend mode is chosen.
The scale is the reference mode's, so at the default `multiply` this returns
`strength` unchanged. A mode that cannot reach the requested share of shader
saturates at 1.0 rather than reporting an error -- `difference` tops out at
0.048, well under the reference's 0.657, which is why it stays subtle at any
setting.
"""
if mode == CALIBRATION_REFERENCE or mode not in BLEND_SHADER_FRACTION:
return strength
target = _interpolate(BLEND_SHADER_FRACTION[CALIBRATION_REFERENCE], strength)
return _invert(BLEND_SHADER_FRACTION[mode], target)
SUPPORTED_TRANSFORMS = (
"none", "reverse", "inverse", "absolute", "square", "sqrt", "log", "sin", "cos",
)
# Keeps |inverse| bounded: 1/0.25 = 4, in range for unit-scale noise. Without it
# the reciprocal of near-zero samples reached ~4e4 on a single latent.
_INVERSE_FLOOR = 0.25
def standardize(noise: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
"""
Rescale to mean 0, standard deviation 1, per sample and per channel.
Statistics are taken over the spatial (and temporal) axes only, so channels
stay independent and a batch item cannot be skewed by its neighbours.
"""
dims = tuple(range(2, noise.ndim)) if noise.ndim > 2 else (-1,)
mean = noise.mean(dim=dims, keepdim=True)
std = noise.std(dim=dims, keepdim=True)
return (noise - mean) / std.clamp_min(eps)
def _match_statistics(result: torch.Tensor, reference: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
"""
Give `result` the per-sample, per-channel mean and deviation of `reference`.
A reference channel with no spread keeps unit statistics instead, so a flat
input cannot erase the blend.
"""
dims = tuple(range(2, reference.ndim)) if reference.ndim > 2 else (-1,)
mean = reference.mean(dim=dims, keepdim=True)
std = reference.std(dim=dims, keepdim=True)
flat = std <= eps
mean = torch.where(flat, torch.zeros_like(mean), mean)
std = torch.where(flat, torch.ones_like(std), std)
return standardize(result, eps) * std + mean
def _to_unit(x: torch.Tensor) -> torch.Tensor:
"""N(0, 1) -> (0, 1) through the normal CDF."""
return torch.special.ndtr(x)
def _from_unit(u: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
"""(0, 1) -> N(0, 1) through the inverse normal CDF, clamped off the poles."""
return torch.special.ndtri(u.clamp(eps, 1.0 - eps))
def _composite(base: torch.Tensor, shader: torch.Tensor, mode: str) -> torch.Tensor:
"""Standard compositing formulas, on values in [0, 1]."""
if mode == "multiply":
return base * shader
if mode == "screen":
return 1.0 - (1.0 - base) * (1.0 - shader)
if mode == "overlay":
return torch.where(base < 0.5, 2 * base * shader, 1.0 - 2 * (1.0 - base) * (1.0 - shader))
if mode == "hard_light":
return torch.where(shader < 0.5, 2 * base * shader, 1.0 - 2 * (1.0 - base) * (1.0 - shader))
if mode == "soft_light":
return (1.0 - 2.0 * shader) * base ** 2 + 2.0 * shader * base
if mode == "difference":
return (base - shader).abs()
raise ValueError(f"unknown compositing mode: {mode}")
def mix_noise(base: torch.Tensor, shader: torch.Tensor, mode: str, strength: float,
normalize: bool = False) -> torch.Tensor:
"""
Blend shader noise into base noise without changing the distribution.
Args:
base: base noise, normally from comfy.sample.prepare_noise
shader: shader noise, same shape as base
mode: one of SUPPORTED_MODES
strength: 0.0 returns base untouched, 1.0 is full shader influence
normalize: re-express `strength` on the reference mode's scale, so the
same value hands over the same amount of shader in every mode
Returns:
Noise with the per-sample, per-channel mean and standard deviation of
`base`.
The result takes the base noise's own statistics rather than exactly 0 and 1,
so it is continuous with strength: strength 0 returns base untouched, and as
strength approaches 0 the result approaches base. Forcing 0 and 1 moved the
noise by the base's own sampling offsets at any strength above 0, which on
SD 1.5 moved the image as far as a whole 0.05 step of shader.
"""
if strength <= 0.0:
return base
if shader.shape != base.shape:
raise ValueError(f"shape mismatch: base {tuple(base.shape)} vs shader {tuple(shader.shape)}")
strength = float(min(strength, 1.0))
if normalize:
strength = normalized_strength(mode, strength)
if strength <= 0.0:
return base
b = standardize(base)
s = standardize(shader.to(dtype=b.dtype, device=b.device))
if mode == "normal":
# Variance preserving for independent unit-variance inputs:
# cos^2 + sin^2 = 1, unlike (1-k)*b + k*s which dips to 0.71 at k=0.5.
theta = strength * math.pi / 2.0
return _match_statistics(math.cos(theta) * b + math.sin(theta) * s, base)
if mode == "add":
return _match_statistics(b + s * strength, base)
blended = _from_unit(_composite(_to_unit(b), _to_unit(s), mode))
return _match_statistics(b * (1.0 - strength) + blended * strength, base)
def transform_noise(noise: torch.Tensor, transform: str) -> torch.Tensor:
"""
Apply a mathematical transform, then restore the noise distribution.
Several transforms are not symmetric (absolute, square, sqrt, log), so
without re-standardising they hand the sampler a large DC offset.
"""
if transform == "none":
return noise
if transform == "reverse":
return -noise
if transform == "inverse":
result = torch.sign(noise) / noise.abs().clamp_min(_INVERSE_FLOOR)
elif transform == "absolute":
result = noise.abs()
elif transform == "square":
result = noise ** 2
elif transform == "sqrt":
result = noise.abs().sqrt()
elif transform == "log":
result = torch.log(noise.abs() + 1.0)
elif transform == "sin":
result = torch.sin(noise * math.pi)
elif transform == "cos":
result = torch.cos(noise * math.pi)
else:
return noise
return standardize(result)