Files
Æmotion StudioandClaude Opus 5 298e8bdc72 perf: batch tensor_field's channel axis too
The last generator still drew one channel at a time, and it drew them
differently from the others: each channel takes one of four visualisations,
its own scale, warp and time, and its own random perturbation of the
coordinate grid. The expensive part -- five simplex evaluations inside
compute_tensor_properties -- does not depend on the visualisation, so it now
runs once for the whole channel axis and each channel takes the cheap
visualisation its index asks for. Computing all four and selecting is less
work than grouping the channels and making four calls.

At 1344x768/124 frames, measured against the pre-batched-noise tag in one
sitting: 5.37s -> 2.08s. That completes the set -- domain_warp 6.76 -> 1.90,
curl_noise 6.54 -> 1.94, temporal_coherent 5.76 -> 1.66 -- with effective
channel rank unchanged in all four.

Two things this cost, both recorded in tests:

- Its fixtures moved. Exact at vector-aligned shapes, up to 1.3e-05 at
  others. The amplification is larger than domain_warp's for a reason worth
  knowing: tensor_field perturbs the coordinates per channel, and an ulp of
  coordinate can push a point across a simplex cell boundary and flip a
  gradient, so a 1e-07 input difference is not a 1e-07 output difference.
  It was 5.6e-04 until the per-channel `time` was held in float64 and cast at
  the point of use, which is where the scalar path's Python float was rounded.
  Rounding it any earlier moves the coordinate.
- Channel 0 must stay on the scalar path. Batching it along with the rest
  broke the identity between channel 0 of a wide draw and a one-channel draw
  at 22x38, by 6e-07 -- small, but that identity is what every travel-mode
  basis rests on, and jump and stamp are pinned byte-identical to
  pre-collapse-fix through it. The other three generators get this for free by
  handing channel 0 to fill_channels as `base`; tensor_field does not use
  fill_channels, so it needed saying explicitly.

The new test that found the second one now runs all four generators across
nine latent shapes, ragged and aligned, so the invariant is checked where it
actually breaks rather than only where it holds. 32 jump/drift draws remain
byte-identical to the tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:06:33 -07:00

567 lines
24 KiB
Python

"""
Tensor Field Noise Generator.
This module implements tensor field noise patterns that can be used to
influence the sampling process in image generation.
"""
import torch
import math
import logging
from typing import Dict, Any, Optional, Tuple
from .base import BaseNoiseGenerator
from .registry import shader_generator
from ..utils.color_utils import apply_color_scheme, interpolate_colors, COLOR_SCHEMES
from ..utils.shape_masks import apply_shape_mask, apply_mask_to_tensor
from ..utils.noise_utils import create_coordinate_grid
from ..core.params import ShaderParams, get_param_value
from .simplex import simplex_2d, simplex_3d
from ..core.constants import DEFAULT_CHANNELS
logger = logging.getLogger(__name__)
def _as_field(value, reference):
"""Round a per-channel scalar into the field's dtype, no earlier than needed."""
return value.to(reference.dtype) if torch.is_tensor(value) else value
@shader_generator("tensor_field", metadata={"description": "Tensor field noise for directional patterns"})
class TensorFieldGenerator(BaseNoiseGenerator):
"""
PyTorch implementation of tensor field animation.
This class generates tensor field patterns that can be used to influence the
sampling process in image generation.
"""
@staticmethod
def generate(
batch_size: int,
height: int,
width: int,
params: ShaderParams,
device: torch.device,
seed: int = 0,
target_channels: int = DEFAULT_CHANNELS
) -> torch.Tensor:
"""
Generate tensor field noise tensor.
Args:
batch_size: Number of images in batch
height: Height of tensor
width: Width of tensor
params: Shader parameters
device: Device to create tensor on
seed: Random seed for deterministic results
target_channels: Number of output channels
Returns:
Tensor with shape [batch_size, target_channels, height, width]
"""
# Extract parameters
scale = params.scale
warp_strength = params.warp_strength
phase_shift = params.phase_shift
octaves = params.octaves
time = params.time
base_seed = params.get("base_seed", seed)
use_temporal_coherence = params.use_temporal_coherence
shape_type = params.shape_type
shape_strength = params.shape_strength
color_scheme = params.color_scheme
color_intensity = params.color_intensity
# Get target channels from params or use default
target_channels = params.get("target_channels", target_channels)
# Handle model-specific channel overrides
model_class = params.get("model_class", "")
inner_model_class = params.get("inner_model_class", "")
if inner_model_class == "CosmosVideo" or model_class == "CosmosVideo":
target_channels = 16
elif inner_model_class == "ACEStep":
target_channels = 8
elif inner_model_class == "WAN21" or model_class == "WAN21":
target_channels = 16
# Create coordinate grid [B, H, W, 2] in [0, 1] range
coords = create_coordinate_grid(batch_size, height, width, device)
# Seed management
loop_seed = base_seed if use_temporal_coherence else seed
viz_type = int(octaves % 4)
# Generate all channels
all_channels = []
# Get color channel variations if color scheme is active
color_variations = TensorFieldGenerator._get_color_variations(
color_scheme, color_intensity, octaves, scale, warp_strength, time
)
# Drawn once rather than per channel: every argument is loop-invariant, so
# the other three generators hoist it too. At LTXV's 128 channels this was
# 127 identical masks. Safe despite apply_shape_mask reseeding the global
# RNG, because the reseed at the top of each iteration overwrites it before
# the randn_like below ever reads it.
hoisted_mask = None
if shape_type not in ["none", "0"] and shape_strength > 0:
hoisted_mask = apply_shape_mask(coords, shape_type, time, base_seed, shape_strength)
if len(hoisted_mask.shape) == 3:
hoisted_mask = hoisted_mask.unsqueeze(-1)
# Channels 0-2 are the palette's own when a colour scheme is active, each
# with its own bespoke parameters; everything past them is formulaic and
# goes through in a single batched draw below.
# Channel 0 always stays on the scalar path, the way the other three
# generators keep theirs outside fill_channels. Every travel-mode basis is
# built from one-channel draws, and a test pins channel 0 of a wide draw to
# be that same draw; batching it would break the identity at shapes whose
# per-slice element count is not vector-aligned.
scalar_channels = 3 if (color_scheme != "none" and color_intensity > 0) else 1
scalar_channels = min(scalar_channels, target_channels)
for i in range(scalar_channels):
current_seed = loop_seed + 700 + (i * 130)
torch.manual_seed(current_seed)
# No clone: the RGB path never writes to this, and the other path
# rebinds it to a fresh tensor on the next line.
current_coords = coords
current_time = time
current_viz_type = viz_type
current_scale = scale
current_warp = warp_strength
is_rgb_color = color_scheme != "none" and color_intensity > 0 and i < 3
if is_rgb_color:
if i == 0: # Red
current_seed = base_seed if use_temporal_coherence else seed
elif i == 1: # Green
current_time = time + color_variations["g_time"]
current_viz_type = color_variations["g_viz"]
current_scale = color_variations["g_scale"]
current_warp = color_variations["g_warp"]
current_seed = (base_seed + 42) if use_temporal_coherence else (seed + 42)
elif i == 2: # Blue
current_time = time + color_variations["b_time"]
current_viz_type = color_variations["b_viz"]
current_scale = color_variations["b_scale"]
current_warp = color_variations["b_warp"]
current_seed = (base_seed + 123) if use_temporal_coherence else (seed + 123)
else:
# Structured variation for non-RGB channels
pert_scale = 0.005 + (i * 0.001)
current_coords = current_coords + (torch.randn_like(coords) * pert_scale)
current_coords = torch.clamp(current_coords, 0.0, 1.0)
current_time = time + (i * 0.02)
current_viz_type = (viz_type + i) % 4
current_scale = scale * (1.0 + ((i % 5 - 2) * 0.03))
current_warp = warp_strength * (1.0 + ((i % 7 - 3) * 0.03))
# Generate single channel
channel = TensorFieldGenerator.tensor_field(
current_coords, current_viz_type, current_scale, current_warp,
current_time, device, current_seed, use_temporal_coherence
)
if len(channel.shape) == 3:
channel = channel.unsqueeze(-1)
# Apply contrast and clamp
contrast = 1.0 + phase_shift
channel = torch.clamp(channel * contrast, -1.0, 1.0)
# Special handling for blue_red G channel
if is_rgb_color and i == 1 and color_scheme.lower() == "blue_red":
channel = torch.full_like(channel, -1.0)
# Apply shape mask
if hoisted_mask is not None:
channel = torch.lerp(channel, channel * hoisted_mask, shape_strength)
channel = torch.clamp(channel, -1.0, 1.0)
# Convert to BCHW
channel = channel.permute(0, 3, 1, 2)
all_channels.append(channel)
if target_channels > scalar_channels:
indices = range(scalar_channels, target_channels)
perturbed, seeds, times, vizzes, scales, warps = [], [], [], [], [], []
for i in indices:
channel_seed = loop_seed + 700 + (i * 130)
# A generator of its own on the coordinates' own device, which is
# bit-identical to the global manual_seed + randn_like this replaces
# and does not disturb the caller's stream.
channel_generator = torch.Generator(device=coords.device).manual_seed(channel_seed)
perturbation = torch.randn(coords.shape, generator=channel_generator,
device=coords.device, dtype=coords.dtype)
perturbed.append(torch.clamp(
coords + perturbation * (0.005 + i * 0.001), 0.0, 1.0).unsqueeze(0))
seeds.append(channel_seed)
times.append(time + (i * 0.02))
vizzes.append((viz_type + i) % 4)
scales.append(scale * (1.0 + ((i % 5 - 2) * 0.03)))
warps.append(warp_strength * (1.0 + ((i % 7 - 3) * 0.03)))
def column(values, dtype=torch.float32):
return torch.tensor(values, device=device, dtype=dtype).reshape(-1, 1, 1, 1, 1)
fields = TensorFieldGenerator.tensor_field_many(
torch.cat(perturbed, dim=0), vizzes, column(scales), column(warps),
column(times, torch.float64), device, column(seeds, torch.int64),
use_temporal_coherence
)
fields = torch.clamp(fields * (1.0 + phase_shift), -1.0, 1.0)
if hoisted_mask is not None:
fields = torch.clamp(
torch.lerp(fields, fields * hoisted_mask, shape_strength), -1.0, 1.0)
# [N, B, H, W, 1] -> [B, N, H, W]
all_channels.append(fields.squeeze(-1).permute(1, 0, 2, 3))
# Concatenate all channels
result = torch.cat(all_channels, dim=1)
# Ensure correct channel count
if result.shape[1] != target_channels:
corrected = torch.zeros((batch_size, target_channels, height, width), device=device)
min_ch = min(result.shape[1], target_channels)
corrected[:, :min_ch] = result[:, :min_ch]
result = corrected
return result
@staticmethod
def get_tensor_field(batch_size, height, width, shader_params, device="cuda", seed=0):
"""
Legacy interface for tensor field generation.
Maintained for backward compatibility with existing code.
"""
if isinstance(shader_params, dict):
params = ShaderParams(shader_params).validate()
else:
params = shader_params
target_channels = shader_params.get("target_channels", 4) if isinstance(shader_params, dict) else params.get("target_channels", 4)
return TensorFieldGenerator.generate(
batch_size, height, width, params, device, seed, target_channels
)
@staticmethod
def _get_color_variations(
color_scheme: str,
color_intensity: float,
octaves: int,
scale: float,
warp_strength: float,
time: float
) -> Dict[str, Any]:
"""Get color channel variations based on scheme."""
viz_type = int(octaves % 4)
intensity = 0.5 + color_intensity * 0.5
# Default values
result = {
"g_time": 0.2 * intensity,
"b_time": 0.4 * intensity,
"g_viz": viz_type,
"b_viz": viz_type,
"g_scale": scale * (1.0 + 0.05 * intensity),
"b_scale": scale * (1.0 - 0.05 * intensity),
"g_warp": warp_strength * (1.0 - 0.1 * intensity),
"b_warp": warp_strength * (1.0 + 0.1 * intensity),
}
scheme = color_scheme.lower()
if scheme == "rainbow":
result["g_time"] = 0.33 * intensity
result["b_time"] = 0.66 * intensity
result["g_viz"] = (viz_type + 1) % 4
result["b_viz"] = (viz_type + 2) % 4
elif scheme == "plasma":
result["g_time"] = 0.25 * intensity
result["b_time"] = 0.55 * intensity
result["g_viz"] = (viz_type + 2) % 4
elif scheme == "viridis":
result["g_time"] = 0.20 * intensity
result["b_time"] = 0.40 * intensity
result["g_viz"] = (viz_type + 1) % 4
result["b_viz"] = (viz_type + 3) % 4
elif scheme == "inferno":
result["g_time"] = 0.15 * intensity
result["b_time"] = 0.35 * intensity
result["g_viz"] = (viz_type + 3) % 4
result["b_viz"] = (viz_type + 1) % 4
elif scheme == "magma":
result["g_time"] = 0.40 * intensity
result["b_time"] = 0.70 * intensity
result["g_viz"] = (viz_type + 2) % 4
elif scheme == "jet":
result["g_time"] = 0.30 * intensity
result["b_time"] = 0.60 * intensity
result["g_viz"] = (viz_type + 1) % 4
result["b_viz"] = (viz_type + 3) % 4
elif scheme == "hot":
result["g_time"] = 0.1 * intensity
result["b_time"] = 0.7 * intensity
elif scheme == "cool":
result["g_time"] = 0.6 * intensity
result["b_time"] = 0.2 * intensity
elif scheme == "blue_red":
result["g_time"] = 0.0
result["g_viz"] = 0
result["g_scale"] = scale * 0.01
result["g_warp"] = 0.0
result["b_time"] = 0.5 * intensity if viz_type != 2 else math.pi
return result
@staticmethod
def tensor_field(p, viz_type, scale, warp_strength, time, device, seed, use_temporal_coherence=False):
"""
Generate tensor field noise for given coordinates.
Args:
p: Coordinate tensor [batch, height, width, 2]
viz_type: Visualization type (0-3)
scale: Scale factor
warp_strength: Warping strength
time: Animation time
device: Target device
seed: Random seed
use_temporal_coherence: Whether to use temporal coherence
Returns:
Noise tensor [batch, height, width, 1]
"""
batch, height, width, _ = p.shape
# Compute tensor properties (eigenvalues and eigenvectors)
lambda1, lambda2, v1, v2 = TensorFieldGenerator.compute_tensor_properties(
p, scale, warp_strength, time, device, seed, use_temporal_coherence
)
# Visualize based on type
if viz_type == 0:
# Eigenvalue magnitude
result = (torch.abs(lambda1) + torch.abs(lambda2)) * 0.5
elif viz_type == 1:
# Eigenvalue difference. compute_tensor_properties returns [B, H, W, 1]
# tensors, so unsqueezing here made it 5D and broke the permute below.
result = lambda1 - lambda2
elif viz_type == 2:
# Hyperstreamlines
angle = torch.atan2(v1[..., 1:2], v1[..., 0:1])
result = torch.sin(angle * 4.0 + time)
else:
# Eigenvector direction
result = v1[..., 0:1] * v1[..., 1:2] * 2.0
# Ensure result has correct shape
if len(result.shape) == 3:
result = result.unsqueeze(-1)
# Normalize to [-1, 1]
result = (result - result.mean()) / (result.std() + 1e-8)
result = torch.clamp(result * 0.5, -1.0, 1.0)
return result
@staticmethod
def tensor_field_many(p, viz_types, scale, warp_strength, time, device, seed,
use_temporal_coherence=False):
"""
`tensor_field` for a whole channel axis at once.
The expensive part -- five simplex evaluations inside
compute_tensor_properties -- does not depend on the visualisation, so it
runs once for every channel together. The four visualisations are then
cheap elementwise reads of the same eigen-decomposition, so all four are
computed and each channel takes the one its index asks for. That is less
work than grouping the channels by visualisation and making four calls.
`p` is [N, B, H, W, 2]; `scale`, `warp_strength`, `time` and `seed` are
[N, 1, 1, 1, 1]; `viz_types` is a sequence of N integers in [0, 4).
"""
lambda1, lambda2, v1, v2 = TensorFieldGenerator.compute_tensor_properties(
p, scale, warp_strength, time, device, seed, use_temporal_coherence
)
angle = torch.atan2(v1[..., 1:2], v1[..., 0:1])
options = torch.stack([
(torch.abs(lambda1) + torch.abs(lambda2)) * 0.5,
lambda1 - lambda2,
torch.sin(angle * 4.0 + _as_field(time, angle)),
v1[..., 0:1] * v1[..., 1:2] * 2.0,
]) # [4, N, B, H, W, 1]
chooser = torch.tensor(list(viz_types), device=options.device, dtype=torch.int64)
chooser = chooser.reshape(1, -1, *([1] * (options.dim() - 2))).expand_as(options[:1])
result = options.gather(0, chooser).squeeze(0)
# Per channel, exactly as each would be normalised on its own.
dims = tuple(range(1, result.dim()))
result = (result - result.mean(dim=dims, keepdim=True)) \
/ (result.std(dim=dims, keepdim=True) + 1e-8)
return torch.clamp(result * 0.5, -1.0, 1.0)
@staticmethod
def compute_tensor_properties(p, scale, warp_strength, time, device, seed, use_temporal_coherence=False):
"""
Compute tensor field properties (eigenvalues and eigenvectors).
Args:
p: Coordinate tensor [batch, height, width, 2]
scale: Scale factor
warp_strength: Warping strength
time: Animation time
device: Target device
seed: Random seed
use_temporal_coherence: Whether to use temporal coherence
Returns:
Tuple of (lambda1, lambda2, v1, v2)
"""
# `scale`, `warp_strength`, `time` and `seed` may each be a tensor holding
# one value per channel, shaped to broadcast against `p`'s leading axes.
if torch.is_tensor(time):
# Held in float64 and cast here, not before. The scalar path computes
# `time * 0.05` as a Python float and only then rounds it into the
# field's dtype; rounding `time` first instead shifts the coordinate by
# an ulp, and simplex noise turns that into an O(1) change wherever a
# point crosses a cell boundary -- 5.6e-04 in the draw, not 1e-07.
shifted = (time * 0.05).to(p.dtype)
offset = torch.cat([shifted, torch.zeros_like(shifted)], dim=-1)
else:
offset = torch.tensor([[[[time * 0.05, 0.0]]]], device=device, dtype=p.dtype)
p1 = p * scale + offset
# Every channel's warp is the same sign as the generator's, so whether the
# warp runs is still a scalar decision even when its strength is per channel.
warp_active = bool((warp_strength > 0.0).all()) if torch.is_tensor(warp_strength) \
else warp_strength > 0.0
if warp_active:
if use_temporal_coherence:
warp_noise1 = TensorFieldGenerator.simplex_noise_3d(p1 * 0.3, seed, _as_field(time * 0.2, p))
warp_noise2 = TensorFieldGenerator.simplex_noise_3d(p1 * 0.3, seed + 1, _as_field(time * 0.2 + 3.33, p))
else:
warp_noise1 = TensorFieldGenerator.simplex_noise(p1 * 0.3, seed)
warp_noise2 = TensorFieldGenerator.simplex_noise(p1 * 0.3, seed + 1)
p1 = p1 + torch.cat([warp_noise1, warp_noise2], dim=-1) * warp_strength
# Compute tensor field derivatives
eps = 0.01
if use_temporal_coherence:
n00 = TensorFieldGenerator.simplex_noise_3d(p1, seed + 2, _as_field(time * 0.1, p))
n10 = TensorFieldGenerator.simplex_noise_3d(p1 + torch.tensor([[[[eps, 0]]]], device=device), seed + 2, _as_field(time * 0.1, p))
n01 = TensorFieldGenerator.simplex_noise_3d(p1 + torch.tensor([[[[0, eps]]]], device=device), seed + 2, _as_field(time * 0.1, p))
else:
n00 = TensorFieldGenerator.simplex_noise(p1, seed + 2)
n10 = TensorFieldGenerator.simplex_noise(p1 + torch.tensor([[[[eps, 0]]]], device=device), seed + 2)
n01 = TensorFieldGenerator.simplex_noise(p1 + torch.tensor([[[[0, eps]]]], device=device), seed + 2)
# Compute gradients
dx = (n10 - n00) / eps
dy = (n01 - n00) / eps
# Build tensor
a = dx * dx
b = dx * dy
c = dy * dy
# Compute eigenvalues
trace = a + c
det = a * c - b * b
discriminant = torch.sqrt(torch.clamp(trace * trace * 0.25 - det, min=0))
lambda1 = trace * 0.5 + discriminant
lambda2 = trace * 0.5 - discriminant
# Compute eigenvectors
angle = torch.atan2(2.0 * b.squeeze(-1), (a - c).squeeze(-1)) * 0.5
v1 = torch.stack([torch.cos(angle), torch.sin(angle)], dim=-1)
v2 = torch.stack([-torch.sin(angle), torch.cos(angle)], dim=-1)
return lambda1, lambda2, v1, v2
@staticmethod
def simplex_noise(p, seed):
"""2D simplex noise, unrotated. See shaders/simplex.py."""
return simplex_2d(p, seed, rotate=False)
@staticmethod
def simplex_noise_3d(coords, seed=0, time_offset=0.0):
"""3D simplex noise with time as the third axis, first corner only."""
z = torch.ones_like(coords[..., 0:1]) * time_offset
return simplex_3d(torch.cat([coords[..., 0:1], coords[..., 1:2], z], dim=-1),
seed, corners=1)
def add_tensor_field_to_tensor(tensor_class):
"""Legacy function for backward compatibility."""
pass
def register_shader_generator(generators_dict):
"""Legacy function for backward compatibility."""
generators_dict["tensor_field"] = generate_tensor_field_tensor
def generate_tensor_field_tensor(
shader_params: Dict[str, Any],
height: int,
width: int,
batch_size: int = 1,
device: str = "cuda",
seed: int = 0,
target_channels: int = DEFAULT_CHANNELS,
**kwargs
) -> torch.Tensor:
"""
Generate tensor field noise tensor.
This is a wrapper function for backward compatibility with the old API.
Args:
shader_params: Dictionary of shader parameters
height: Height of the output tensor
width: Width of the output tensor
batch_size: Number of images in batch
device: Device to create tensor on
seed: Random seed
target_channels: Number of output channels
**kwargs: Additional arguments (ignored)
Returns:
Tensor with shape [batch_size, target_channels, height, width]
"""
# Convert dict to ShaderParams if needed
if isinstance(shader_params, dict):
params = ShaderParams(shader_params)
else:
params = shader_params
# Override target_channels if provided in params
if "target_channels" in shader_params:
target_channels = shader_params["target_channels"]
return TensorFieldGenerator.generate(
batch_size=batch_size,
height=height,
width=width,
params=params,
device=torch.device(device),
seed=seed,
target_channels=target_channels
)