Files
2026-02-05 19:08:37 -05:00

613 lines
27 KiB
Python

import torch
import numpy as np
import math
from PIL import Image, ImageDraw
from .utils import hex_to_rgb
class TextureMath:
"""Mathematical operations for combining textures, like Blender's Math node"""
@staticmethod
def apply_operation(texture1, texture2, operation, clamp=True):
"""
Apply mathematical operation between two textures
Args:
texture1, texture2: numpy arrays with texture values
operation: mathematical operation to apply
clamp: whether to clamp result to 0-1 range
"""
if operation == "add":
result = texture1 + texture2
elif operation == "subtract":
result = texture1 - texture2
elif operation == "multiply":
result = texture1 * texture2
elif operation == "divide":
# Avoid division by zero
result = np.divide(texture1, texture2, out=np.zeros_like(texture1), where=texture2!=0)
elif operation == "power":
result = np.power(np.abs(texture1), texture2)
elif operation == "minimum":
result = np.minimum(texture1, texture2)
elif operation == "maximum":
result = np.maximum(texture1, texture2)
elif operation == "round":
result = np.round(texture1 + texture2)
elif operation == "floor":
result = np.floor(texture1 + texture2)
elif operation == "ceil":
result = np.ceil(texture1 + texture2)
elif operation == "modulo":
result = np.mod(texture1, np.where(texture2 == 0, 1, texture2))
elif operation == "absolute":
result = np.abs(texture1 + texture2)
elif operation == "greater_than":
result = (texture1 > texture2).astype(float)
elif operation == "less_than":
result = (texture1 < texture2).astype(float)
elif operation == "sine":
result = np.sin(texture1 + texture2)
elif operation == "cosine":
result = np.cos(texture1 + texture2)
elif operation == "tangent":
result = np.tan(texture1 + texture2)
elif operation == "smooth_min":
# Smooth minimum function
k = 0.1 # smoothing factor
h = np.maximum(k - np.abs(texture1 - texture2), 0) / k
result = np.minimum(texture1, texture2) - h * h * k * 0.25
elif operation == "smooth_max":
# Smooth maximum function
k = 0.1 # smoothing factor
h = np.maximum(k - np.abs(texture1 - texture2), 0) / k
result = np.maximum(texture1, texture2) + h * h * k * 0.25
else: # default to add
result = texture1 + texture2
if clamp:
result = np.clip(result, 0.0, 1.0)
return result
class ColorRamp:
"""Color ramp functionality like Blender's ColorRamp node"""
@staticmethod
def apply_color_ramp(texture, color1, color2, ramp_type="linear"):
"""
Apply color ramp to a grayscale texture
Args:
texture: grayscale texture values (0-1)
color1, color2: RGB tuples for start and end colors
ramp_type: interpolation type
"""
height, width = texture.shape
result = np.zeros((height, width, 3))
color1 = np.array(color1) / 255.0
color2 = np.array(color2) / 255.0
if ramp_type == "linear":
for c in range(3):
result[:, :, c] = color1[c] * (1 - texture) + color2[c] * texture
elif ramp_type == "ease":
# Smooth step interpolation
smooth_texture = texture * texture * (3.0 - 2.0 * texture)
for c in range(3):
result[:, :, c] = color1[c] * (1 - smooth_texture) + color2[c] * smooth_texture
elif ramp_type == "b_spline":
# B-spline interpolation
t = texture
t2 = t * t
t3 = t2 * t
smooth_texture = t3 * (t * (t * 6.0 - 15.0) + 10.0)
for c in range(3):
result[:, :, c] = color1[c] * (1 - smooth_texture) + color2[c] * smooth_texture
elif ramp_type == "cardinal":
# Cardinal spline
t = texture
smooth_texture = t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
for c in range(3):
result[:, :, c] = color1[c] * (1 - smooth_texture) + color2[c] * smooth_texture
elif ramp_type == "constant":
# Hard transition at 50% with no interpolation
for c in range(3):
result[:, :, c] = np.where(texture < 0.5, color1[c], color2[c])
return result
class WaveTextureGenerator:
"""Helper class for generating wave textures as mathematical values"""
@staticmethod
def generate_wave_texture(width, height, wave_type, scale, phase, direction="horizontal", shape="sine"):
"""
Generate a wave texture with values from -1 to 1 (before normalization)
Args:
width, height: texture dimensions
wave_type: "sine", "cosine", "square", "triangle", "sawtooth"
scale: wave frequency (higher = more waves)
phase: phase offset (0 to 2*pi for full cycle)
direction: "horizontal", "vertical", "diagonal", "radial"
shape: "sine", "square", "triangle" for wave shape modification
"""
wave_texture = np.zeros((height, width))
if direction == "horizontal":
for x in range(width):
wave_val = WaveTextureGenerator._calculate_wave(x * scale + phase, wave_type, shape)
wave_texture[:, x] = wave_val
elif direction == "vertical":
for y in range(height):
wave_val = WaveTextureGenerator._calculate_wave(y * scale + phase, wave_type, shape)
wave_texture[y, :] = wave_val
elif direction == "diagonal":
for y in range(height):
for x in range(width):
wave_val = WaveTextureGenerator._calculate_wave((x + y) * scale + phase, wave_type, shape)
wave_texture[y, x] = wave_val
elif direction == "radial":
center_x, center_y = width // 2, height // 2
for y in range(height):
for x in range(width):
distance = math.sqrt((x - center_x)**2 + (y - center_y)**2)
wave_val = WaveTextureGenerator._calculate_wave(distance * scale + phase, wave_type, shape)
wave_texture[y, x] = wave_val
return wave_texture
@staticmethod
def _calculate_wave(value, wave_type, shape):
"""Calculate wave value (-1 to 1 range)"""
if wave_type == "sine":
base_wave = math.sin(value)
elif wave_type == "cosine":
base_wave = math.cos(value)
elif wave_type == "square":
base_wave = 1.0 if (value % (2 * math.pi)) < math.pi else -1.0
elif wave_type == "triangle":
normalized = (value % (2 * math.pi)) / (2 * math.pi)
if normalized < 0.5:
base_wave = 4 * normalized - 1
else:
base_wave = 3 - 4 * normalized
elif wave_type == "sawtooth":
base_wave = 2 * ((value % (2 * math.pi)) / (2 * math.pi)) - 1
else:
base_wave = math.sin(value)
# Apply shape modification
if shape == "square":
return 1.0 if base_wave > 0 else -1.0
elif shape == "triangle":
# Convert to triangle-like shape
return math.copysign(abs(base_wave) ** 0.5, base_wave)
else: # sine (default)
return base_wave
class PatternTextureGenerator:
"""Generate base pattern textures as mathematical values (0-1)"""
@staticmethod
def generate_checkerboard(width, height, square_size):
"""Generate checkerboard pattern as 0-1 texture"""
pattern = np.zeros((height, width))
for y in range(height):
for x in range(width):
if ((x // square_size) + (y // square_size)) % 2 == 0:
pattern[y, x] = 1.0
return pattern
@staticmethod
def generate_stripes(width, height, stripe_width, direction):
"""Generate stripes pattern as 0-1 texture"""
pattern = np.zeros((height, width))
if direction == "horizontal":
for y in range(height):
pattern[y, :] = 1.0 if (y // stripe_width) % 2 == 0 else 0.0
elif direction == "vertical":
for x in range(width):
pattern[:, x] = 1.0 if (x // stripe_width) % 2 == 0 else 0.0
elif direction == "diagonal_right":
for y in range(height):
for x in range(width):
pattern[y, x] = 1.0 if ((x + y) // stripe_width) % 2 == 0 else 0.0
else: # diagonal_left
for y in range(height):
for x in range(width):
pattern[y, x] = 1.0 if ((x - y) // stripe_width) % 2 == 0 else 0.0
return pattern
@staticmethod
def generate_polka_dots(width, height, dot_radius, spacing, stagger):
"""Generate polka dots pattern as 0-1 texture"""
pattern = np.zeros((height, width))
y = spacing // 2
row = 0
while y < height:
x = spacing // 2
if stagger and row % 2 == 1:
x += spacing // 2
while x < width:
# Create circular dot
for py in range(max(0, y - dot_radius), min(height, y + dot_radius + 1)):
for px in range(max(0, x - dot_radius), min(width, x + dot_radius + 1)):
if (px - x)**2 + (py - y)**2 <= dot_radius**2:
pattern[py, px] = 1.0
x += spacing
y += spacing
row += 1
return pattern
class AnimatedCheckerboardPattern:
"""
Generate animated checkerboard pattern with mathematical wave texture combination
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"square_size": ("INT", {"default": 32, "min": 8, "max": 256, "step": 8}),
"color1": ("STRING", {"default": "#FFFFFF"}),
"color2": ("STRING", {"default": "#000000"}),
"frame_count": ("INT", {"default": 30, "min": 1, "max": 300, "step": 1}),
"wave_type": (["sine", "cosine", "square", "triangle", "sawtooth"], {"default": "sine"}),
"wave_scale": ("FLOAT", {"default": 0.1, "min": 0.01, "max": 1.0, "step": 0.01}),
"wave_direction": (["horizontal", "vertical", "diagonal", "radial"], {"default": "horizontal"}),
"wave_shape": (["sine", "square", "triangle"], {"default": "sine"}),
"math_operation": (["add", "subtract", "multiply", "divide", "power", "minimum", "maximum",
"modulo", "sine", "cosine", "smooth_min", "smooth_max"], {"default": "add"}),
"wave_factor": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.05}),
"reverse_phase": ("BOOLEAN", {"default": False}),
"color_ramp_type": (["linear", "ease", "b_spline", "cardinal", "constant"], {"default": "linear"}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate_animated_pattern"
CATEGORY = "Purz/Patterns/Animated"
def generate_animated_pattern(self, width, height, square_size, color1, color2, frame_count,
wave_type, wave_scale, wave_direction, wave_shape, math_operation,
wave_factor, reverse_phase, color_ramp_type):
result = []
rgb1 = hex_to_rgb(color1)
rgb2 = hex_to_rgb(color2)
# Generate base checkerboard pattern (0-1 texture)
base_pattern = PatternTextureGenerator.generate_checkerboard(width, height, square_size)
for frame in range(frame_count):
# Calculate phase (0 to 2*pi or -2*pi to 0 if reversed)
if reverse_phase:
phase = -2 * math.pi * frame / frame_count
else:
phase = 2 * math.pi * frame / frame_count
# Generate wave texture (-1 to 1, then normalized to 0-1)
wave_texture = WaveTextureGenerator.generate_wave_texture(
width, height, wave_type, wave_scale, phase, wave_direction, wave_shape
)
# Normalize wave to 0-1 and apply factor
wave_texture = (wave_texture + 1) / 2 * wave_factor
# Apply mathematical operation between base pattern and wave texture
combined_texture = TextureMath.apply_operation(
base_pattern, wave_texture, math_operation, clamp=True
)
# Apply color ramp to convert grayscale texture to RGB
img_np = ColorRamp.apply_color_ramp(combined_texture, rgb1, rgb2, color_ramp_type)
img_tensor = torch.from_numpy(img_np.astype(np.float32))
result.append(img_tensor)
result = torch.stack(result)
return (result,)
class AnimatedStripesPattern:
"""
Generate animated stripes pattern with mathematical wave texture combination
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"stripe_width": ("INT", {"default": 20, "min": 1, "max": 256, "step": 1}),
"direction": (["horizontal", "vertical", "diagonal_right", "diagonal_left"], {"default": "vertical"}),
"color1": ("STRING", {"default": "#FFFFFF"}),
"color2": ("STRING", {"default": "#000000"}),
"frame_count": ("INT", {"default": 30, "min": 1, "max": 300, "step": 1}),
"wave_type": (["sine", "cosine", "square", "triangle", "sawtooth"], {"default": "sine"}),
"wave_scale": ("FLOAT", {"default": 0.1, "min": 0.01, "max": 1.0, "step": 0.01}),
"wave_direction": (["horizontal", "vertical", "diagonal", "radial"], {"default": "horizontal"}),
"wave_shape": (["sine", "square", "triangle"], {"default": "sine"}),
"math_operation": (["add", "subtract", "multiply", "divide", "power", "minimum", "maximum",
"modulo", "sine", "cosine", "smooth_min", "smooth_max"], {"default": "add"}),
"wave_factor": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.05}),
"reverse_phase": ("BOOLEAN", {"default": False}),
"color_ramp_type": (["linear", "ease", "b_spline", "cardinal", "constant"], {"default": "linear"}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate_animated_pattern"
CATEGORY = "Purz/Patterns/Animated"
def generate_animated_pattern(self, width, height, stripe_width, direction, color1, color2, frame_count,
wave_type, wave_scale, wave_direction, wave_shape, math_operation,
wave_factor, reverse_phase, color_ramp_type):
result = []
rgb1 = hex_to_rgb(color1)
rgb2 = hex_to_rgb(color2)
# Generate base stripe pattern (0-1 texture)
base_pattern = PatternTextureGenerator.generate_stripes(width, height, stripe_width, direction)
for frame in range(frame_count):
# Calculate phase with high precision to avoid jitter
if reverse_phase:
phase = -2.0 * math.pi * float(frame) / float(frame_count)
else:
phase = 2.0 * math.pi * float(frame) / float(frame_count)
# Generate wave texture (-1 to 1, then normalized to 0-1)
wave_texture = WaveTextureGenerator.generate_wave_texture(
width, height, wave_type, wave_scale, phase, wave_direction, wave_shape
)
# Normalize wave to 0-1 and apply factor
wave_texture = (wave_texture + 1.0) * 0.5 * wave_factor
# Apply mathematical operation between base pattern and wave texture
combined_texture = TextureMath.apply_operation(
base_pattern, wave_texture, math_operation, clamp=True
)
# Apply color ramp to convert grayscale texture to RGB
img_np = ColorRamp.apply_color_ramp(combined_texture, rgb1, rgb2, color_ramp_type)
img_tensor = torch.from_numpy(img_np.astype(np.float32))
result.append(img_tensor)
result = torch.stack(result)
return (result,)
class AnimatedPolkaDotPattern:
"""
Generate animated polka dot pattern with mathematical wave texture combination
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"dot_radius": ("INT", {"default": 10, "min": 2, "max": 100, "step": 1}),
"spacing": ("INT", {"default": 40, "min": 10, "max": 200, "step": 5}),
"background_color": ("STRING", {"default": "#FFFFFF"}),
"dot_color": ("STRING", {"default": "#000000"}),
"stagger": ("BOOLEAN", {"default": True}),
"frame_count": ("INT", {"default": 30, "min": 1, "max": 300, "step": 1}),
"wave_type": (["sine", "cosine", "square", "triangle", "sawtooth"], {"default": "sine"}),
"wave_scale": ("FLOAT", {"default": 0.1, "min": 0.01, "max": 1.0, "step": 0.01}),
"wave_direction": (["horizontal", "vertical", "diagonal", "radial"], {"default": "radial"}),
"wave_shape": (["sine", "square", "triangle"], {"default": "sine"}),
"math_operation": (["add", "subtract", "multiply", "divide", "power", "minimum", "maximum",
"modulo", "sine", "cosine", "smooth_min", "smooth_max"], {"default": "add"}),
"wave_factor": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.05}),
"reverse_phase": ("BOOLEAN", {"default": False}),
"color_ramp_type": (["linear", "ease", "b_spline", "cardinal", "constant"], {"default": "linear"}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate_animated_pattern"
CATEGORY = "Purz/Patterns/Animated"
def generate_animated_pattern(self, width, height, dot_radius, spacing, background_color, dot_color,
stagger, frame_count, wave_type, wave_scale, wave_direction, wave_shape,
math_operation, wave_factor, reverse_phase, color_ramp_type):
result = []
bg_rgb = hex_to_rgb(background_color)
dot_rgb = hex_to_rgb(dot_color)
# Generate base polka dot pattern (0-1 texture)
base_pattern = PatternTextureGenerator.generate_polka_dots(width, height, dot_radius, spacing, stagger)
for frame in range(frame_count):
# Calculate phase with high precision to avoid jitter
if reverse_phase:
phase = -2.0 * math.pi * float(frame) / float(frame_count)
else:
phase = 2.0 * math.pi * float(frame) / float(frame_count)
# Generate wave texture (-1 to 1, then normalized to 0-1)
wave_texture = WaveTextureGenerator.generate_wave_texture(
width, height, wave_type, wave_scale, phase, wave_direction, wave_shape
)
# Normalize wave to 0-1 and apply factor
wave_texture = (wave_texture + 1.0) * 0.5 * wave_factor
# Apply mathematical operation between base pattern and wave texture
combined_texture = TextureMath.apply_operation(
base_pattern, wave_texture, math_operation, clamp=True
)
# Apply color ramp to convert grayscale texture to RGB
img_np = ColorRamp.apply_color_ramp(combined_texture, bg_rgb, dot_rgb, color_ramp_type)
img_tensor = torch.from_numpy(img_np.astype(np.float32))
result.append(img_tensor)
result = torch.stack(result)
return (result,)
class AnimatedNoisePattern:
"""
Generate animated noise pattern with mathematical wave texture combination
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"noise_type": (["random", "smooth", "cloudy"], {"default": "smooth"}),
"intensity": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}),
"seed": ("INT", {"default": 0, "min": 0, "max": 9999, "step": 1}),
"frame_count": ("INT", {"default": 30, "min": 1, "max": 300, "step": 1}),
"wave_type": (["sine", "cosine", "square", "triangle", "sawtooth"], {"default": "sine"}),
"wave_scale": ("FLOAT", {"default": 0.1, "min": 0.01, "max": 1.0, "step": 0.01}),
"wave_direction": (["horizontal", "vertical", "diagonal", "radial"], {"default": "radial"}),
"wave_shape": (["sine", "square", "triangle"], {"default": "sine"}),
"math_operation": (["add", "subtract", "multiply", "divide", "power", "minimum", "maximum",
"modulo", "sine", "cosine", "smooth_min", "smooth_max"], {"default": "add"}),
"wave_factor": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.05}),
"reverse_phase": ("BOOLEAN", {"default": False}),
"color1": ("STRING", {"default": "#000000"}),
"color2": ("STRING", {"default": "#FFFFFF"}),
"color_ramp_type": (["linear", "ease", "b_spline", "cardinal", "constant"], {"default": "linear"}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate_animated_pattern"
CATEGORY = "Purz/Patterns/Animated"
def smooth_noise(self, width, height, seed):
"""Generate smooth noise using nearest-neighbor sampling (no interpolation)"""
np.random.seed(seed)
low_res = 8
low_width = max(1, width // low_res)
low_height = max(1, height // low_res)
low_noise = np.random.random((low_height, low_width))
# Nearest-neighbor upscaling (no interpolation)
noise = np.zeros((height, width))
for y in range(height):
for x in range(width):
# Use nearest neighbor - no fractional coordinates
lx = min((x * low_width) // width, low_width - 1)
ly = min((y * low_height) // height, low_height - 1)
noise[y, x] = low_noise[ly, lx]
return noise
def generate_animated_pattern(self, width, height, noise_type, intensity, seed, frame_count,
wave_type, wave_scale, wave_direction, wave_shape, math_operation,
wave_factor, reverse_phase, color1, color2, color_ramp_type):
result = []
rgb1 = hex_to_rgb(color1)
rgb2 = hex_to_rgb(color2)
for frame in range(frame_count):
current_seed = seed + frame * 10
# Generate base noise pattern (0-1 texture)
if noise_type == "random":
np.random.seed(current_seed)
base_pattern = np.random.random((height, width)) * intensity
elif noise_type == "smooth":
base_pattern = self.smooth_noise(width, height, current_seed) * intensity
else: # cloudy
cloud = np.zeros((height, width))
for octave in range(4):
scale = max(1, 2 ** octave)
octave_width = max(1, width // scale)
octave_height = max(1, height // scale)
octave_noise = self.smooth_noise(octave_width, octave_height, current_seed + octave * 7)
# Nearest-neighbor upscaling (no interpolation)
temp = np.zeros((height, width))
for y in range(height):
for x in range(width):
src_y = min(y * octave_height // height, octave_height - 1)
src_x = min(x * octave_width // width, octave_width - 1)
temp[y, x] = octave_noise[src_y, src_x]
cloud += temp / (2 ** octave)
base_pattern = cloud * intensity
# Normalize base pattern to 0-1
base_pattern = np.clip(base_pattern, 0, 1)
# Calculate phase with high precision to avoid jitter
if reverse_phase:
phase = -2.0 * math.pi * float(frame) / float(frame_count)
else:
phase = 2.0 * math.pi * float(frame) / float(frame_count)
# Generate wave texture (-1 to 1, then normalized to 0-1)
wave_texture = WaveTextureGenerator.generate_wave_texture(
width, height, wave_type, wave_scale, phase, wave_direction, wave_shape
)
# Normalize wave to 0-1 and apply factor
wave_texture = (wave_texture + 1.0) * 0.5 * wave_factor
# Apply mathematical operation between base pattern and wave texture
combined_texture = TextureMath.apply_operation(
base_pattern, wave_texture, math_operation, clamp=True
)
# Apply color ramp to convert grayscale texture to RGB
img_np = ColorRamp.apply_color_ramp(combined_texture, rgb1, rgb2, color_ramp_type)
img_tensor = torch.from_numpy(img_np.astype(np.float32))
result.append(img_tensor)
result = torch.stack(result)
return (result,)
# Animated pattern node mappings
ANIMATED_NODE_CLASS_MAPPINGS = {
"PurzAnimatedCheckerboard": AnimatedCheckerboardPattern,
"PurzAnimatedStripes": AnimatedStripesPattern,
"PurzAnimatedPolkaDots": AnimatedPolkaDotPattern,
"PurzAnimatedNoise": AnimatedNoisePattern,
}
ANIMATED_NODE_DISPLAY_NAME_MAPPINGS = {
"PurzAnimatedCheckerboard": "Animated Checkerboard (Purz)",
"PurzAnimatedStripes": "Animated Stripes (Purz)",
"PurzAnimatedPolkaDots": "Animated Polka Dots (Purz)",
"PurzAnimatedNoise": "Animated Noise (Purz)",
}