1594 lines
62 KiB
Python
1594 lines
62 KiB
Python
"""
|
||
Texture Utilities
|
||
Tiling, scaling, and projection tools for PBR textures
|
||
"""
|
||
|
||
import torch
|
||
import torch.nn.functional as F
|
||
|
||
|
||
class SeamlessTiling:
|
||
"""
|
||
Make textures tileable using various methods
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"method": (["mirror", "blend_edges", "offset"], {
|
||
"default": "blend_edges",
|
||
"tooltip": "Tiling method: mirror (fastest), blend_edges (best), offset (simple)"
|
||
}),
|
||
"blend_width": ("FLOAT", {
|
||
"default": 0.1,
|
||
"min": 0.0,
|
||
"max": 0.5,
|
||
"step": 0.01,
|
||
"display": "number",
|
||
"tooltip": "Edge blend width (0-0.5, fraction of image)"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "IMAGE")
|
||
RETURN_NAMES = ("image", "edge_mask")
|
||
FUNCTION = "make_seamless"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def make_seamless(self, image, method, blend_width):
|
||
"""Make texture seamlessly tileable and generate edge mask"""
|
||
|
||
print("\n" + "="*60)
|
||
print("Seamless Tiling Maker")
|
||
print("="*60)
|
||
print(f"Input shape: {image.shape}")
|
||
print(f"Method: {method}")
|
||
|
||
if method == "mirror":
|
||
result, edge_mask = self._mirror_tiling(image, blend_width)
|
||
elif method == "blend_edges":
|
||
result, edge_mask = self._blend_edges(image, blend_width)
|
||
else: # offset
|
||
result, edge_mask = self._offset_tiling(image, blend_width)
|
||
|
||
print(f"✓ Seamless texture created")
|
||
print(f"✓ Edge mask generated")
|
||
print(f" Mask range: [{edge_mask.min():.3f}, {edge_mask.max():.3f}]")
|
||
print("="*60 + "\n")
|
||
|
||
return (result, edge_mask)
|
||
|
||
def _mirror_tiling(self, image, blend_width):
|
||
"""Mirror method - flip and blend"""
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
dtype = image.dtype
|
||
|
||
# Create mirrored versions
|
||
img_lr = torch.flip(image, [2]) # Left-right mirror
|
||
img_tb = torch.flip(image, [1]) # Top-bottom mirror
|
||
img_both = torch.flip(image, [1, 2]) # Both
|
||
|
||
# Blend with 50/50
|
||
result = (image + img_lr + img_tb + img_both) / 4.0
|
||
|
||
# Create edge mask - white at all edges
|
||
edge_mask = self._create_edge_mask(batch, height, width, blend_width, device, dtype)
|
||
|
||
return result, edge_mask
|
||
|
||
def _blend_edges(self, image, blend_width):
|
||
"""Blend edges method - smooth transition"""
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
dtype = image.dtype
|
||
|
||
# Calculate blend region size
|
||
blend_h = int(height * blend_width)
|
||
blend_w = int(width * blend_width)
|
||
|
||
if blend_h == 0 or blend_w == 0:
|
||
# No blending, return image and empty mask
|
||
edge_mask = torch.zeros((batch, height, width, 1), device=device, dtype=dtype)
|
||
return image, edge_mask.repeat(1, 1, 1, 3)
|
||
|
||
# Create blend masks
|
||
mask_h = torch.linspace(0, 1, blend_h, device=device, dtype=dtype)
|
||
mask_w = torch.linspace(0, 1, blend_w, device=device, dtype=dtype)
|
||
|
||
result = image.clone()
|
||
|
||
# Blend top-bottom
|
||
top = result[:, :blend_h, :, :]
|
||
bottom = result[:, -blend_h:, :, :]
|
||
mask_h_reshaped = mask_h.view(1, -1, 1, 1)
|
||
blended_tb = top * mask_h_reshaped + bottom * (1 - mask_h_reshaped)
|
||
result[:, :blend_h, :, :] = blended_tb
|
||
result[:, -blend_h:, :, :] = torch.flip(blended_tb, [1])
|
||
|
||
# Blend left-right
|
||
left = result[:, :, :blend_w, :]
|
||
right = result[:, :, -blend_w:, :]
|
||
mask_w_reshaped = mask_w.view(1, 1, -1, 1)
|
||
blended_lr = left * mask_w_reshaped + right * (1 - mask_w_reshaped)
|
||
result[:, :, :blend_w, :] = blended_lr
|
||
result[:, :, -blend_w:, :] = torch.flip(blended_lr, [2])
|
||
|
||
# Create edge mask - white at edges, fading to black toward center
|
||
edge_mask = self._create_edge_mask(batch, height, width, blend_width, device, dtype)
|
||
|
||
return result, edge_mask
|
||
|
||
def _offset_tiling(self, image, blend_width):
|
||
"""Offset method - shift by half"""
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
dtype = image.dtype
|
||
|
||
# Roll by half in both dimensions
|
||
result = torch.roll(image, shifts=(height // 2, width // 2), dims=(1, 2))
|
||
|
||
# Create edge mask - highlight center cross where seams now appear
|
||
edge_mask = torch.zeros((batch, height, width, 1), device=device, dtype=dtype)
|
||
|
||
# Calculate edge width
|
||
edge_h = max(1, int(height * blend_width))
|
||
edge_w = max(1, int(width * blend_width))
|
||
|
||
# Mark vertical center seam
|
||
center_w = width // 2
|
||
edge_mask[:, :, center_w - edge_w:center_w + edge_w, :] = 1.0
|
||
|
||
# Mark horizontal center seam
|
||
center_h = height // 2
|
||
edge_mask[:, center_h - edge_h:center_h + edge_h, :, :] = 1.0
|
||
|
||
# Convert to RGB
|
||
edge_mask = edge_mask.repeat(1, 1, 1, 3)
|
||
|
||
return result, edge_mask
|
||
|
||
def _create_edge_mask(self, batch, height, width, blend_width, device, dtype):
|
||
"""Create edge mask showing where seams/edges are"""
|
||
# Calculate edge region size
|
||
edge_h = max(1, int(height * blend_width))
|
||
edge_w = max(1, int(width * blend_width))
|
||
|
||
# Create mask (1.0 at edges, 0.0 in center)
|
||
edge_mask = torch.zeros((batch, height, width, 1), device=device, dtype=dtype)
|
||
|
||
# Create gradient from edge to center
|
||
mask_h = torch.linspace(1, 0, edge_h, device=device, dtype=dtype)
|
||
mask_w = torch.linspace(1, 0, edge_w, device=device, dtype=dtype)
|
||
|
||
# Top edge
|
||
edge_mask[:, :edge_h, :, :] = torch.max(
|
||
edge_mask[:, :edge_h, :, :],
|
||
mask_h.view(1, -1, 1, 1)
|
||
)
|
||
|
||
# Bottom edge
|
||
edge_mask[:, -edge_h:, :, :] = torch.max(
|
||
edge_mask[:, -edge_h:, :, :],
|
||
torch.flip(mask_h, [0]).view(1, -1, 1, 1)
|
||
)
|
||
|
||
# Left edge
|
||
edge_mask[:, :, :edge_w, :] = torch.max(
|
||
edge_mask[:, :, :edge_w, :],
|
||
mask_w.view(1, 1, -1, 1)
|
||
)
|
||
|
||
# Right edge
|
||
edge_mask[:, :, -edge_w:, :] = torch.max(
|
||
edge_mask[:, :, -edge_w:, :],
|
||
torch.flip(mask_w, [0]).view(1, 1, -1, 1)
|
||
)
|
||
|
||
# Convert to RGB for display
|
||
edge_mask_rgb = edge_mask.repeat(1, 1, 1, 3)
|
||
|
||
return edge_mask_rgb
|
||
|
||
|
||
class TextureScaler:
|
||
"""
|
||
Smart texture resolution scaling with multiple methods
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"scale_factor": ("FLOAT", {
|
||
"default": 2.0,
|
||
"min": 0.125,
|
||
"max": 8.0,
|
||
"step": 0.125,
|
||
"display": "number",
|
||
"tooltip": "Scale multiplier (0.5 = half size, 2.0 = double size)"
|
||
}),
|
||
"method": (["nearest", "bilinear", "bicubic", "lanczos"], {
|
||
"default": "bicubic",
|
||
"tooltip": "Scaling method: nearest (pixel art), bilinear (fast), bicubic (quality), lanczos (best)"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
RETURN_NAMES = ("image",)
|
||
FUNCTION = "scale"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def scale(self, image, scale_factor, method):
|
||
"""Scale texture resolution"""
|
||
|
||
print("\n" + "="*60)
|
||
print("Texture Scaler")
|
||
print("="*60)
|
||
print(f"Input shape: {image.shape}")
|
||
print(f"Scale factor: {scale_factor}x")
|
||
print(f"Method: {method}")
|
||
|
||
batch, height, width, channels = image.shape
|
||
|
||
new_height = int(height * scale_factor)
|
||
new_width = int(width * scale_factor)
|
||
|
||
# Convert to BCHW for interpolation
|
||
image_bchw = image.permute(0, 3, 1, 2)
|
||
|
||
# Map method names to PyTorch modes
|
||
mode_map = {
|
||
"nearest": "nearest",
|
||
"bilinear": "bilinear",
|
||
"bicubic": "bicubic",
|
||
"lanczos": "bicubic" # PyTorch doesn't have lanczos, use bicubic
|
||
}
|
||
|
||
mode = mode_map[method]
|
||
antialias = (method == "lanczos")
|
||
|
||
# Scale
|
||
scaled = F.interpolate(
|
||
image_bchw,
|
||
size=(new_height, new_width),
|
||
mode=mode,
|
||
align_corners=False if mode != "nearest" else None,
|
||
antialias=antialias
|
||
)
|
||
|
||
# Convert back to BHWC
|
||
result = scaled.permute(0, 2, 3, 1)
|
||
|
||
print(f"✓ Scaled to {new_width}x{new_height}")
|
||
print(f" Output shape: {result.shape}")
|
||
print("="*60 + "\n")
|
||
|
||
return (result,)
|
||
|
||
|
||
class TriplanarProjection:
|
||
"""
|
||
Apply triplanar projection to remove UV seams
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"blend_sharpness": ("FLOAT", {
|
||
"default": 1.0,
|
||
"min": 0.1,
|
||
"max": 10.0,
|
||
"step": 0.1,
|
||
"display": "number",
|
||
"tooltip": "Sharpness of projection blend (higher = sharper transitions)"
|
||
}),
|
||
"scale": ("FLOAT", {
|
||
"default": 1.0,
|
||
"min": 0.1,
|
||
"max": 10.0,
|
||
"step": 0.1,
|
||
"display": "number",
|
||
"tooltip": "Texture tiling scale"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
RETURN_NAMES = ("image",)
|
||
FUNCTION = "apply_triplanar"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def apply_triplanar(self, image, blend_sharpness, scale):
|
||
"""Apply triplanar projection blending"""
|
||
|
||
print("\n" + "="*60)
|
||
print("Triplanar Projection")
|
||
print("="*60)
|
||
print(f"Input shape: {image.shape}")
|
||
print(f"Blend sharpness: {blend_sharpness}")
|
||
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
dtype = image.dtype
|
||
|
||
# Create simple XYZ projections by rotating the image
|
||
# This is a simplified version - real triplanar needs 3D coordinates
|
||
|
||
# X projection (side view) - original
|
||
proj_x = image
|
||
|
||
# Y projection (top view) - rotate 90 degrees
|
||
proj_y = torch.rot90(image, k=1, dims=[1, 2])
|
||
|
||
# Z projection (front view) - rotate 180 degrees
|
||
proj_z = torch.rot90(image, k=2, dims=[1, 2])
|
||
|
||
# Create blend weights based on position
|
||
# Simplified - use gradient-based weights
|
||
y_pos = torch.linspace(0, 1, height, device=device, dtype=dtype)
|
||
x_pos = torch.linspace(0, 1, width, device=device, dtype=dtype)
|
||
|
||
y_grid = y_pos.view(1, -1, 1, 1).repeat(batch, 1, width, 1)
|
||
x_grid = x_pos.view(1, 1, -1, 1).repeat(batch, height, 1, 1)
|
||
|
||
# Calculate blend weights
|
||
weight_x = torch.pow(torch.abs(x_grid - 0.5) * 2, blend_sharpness)
|
||
weight_y = torch.pow(torch.abs(y_grid - 0.5) * 2, blend_sharpness)
|
||
weight_z = torch.pow(1.0 - torch.abs(x_grid + y_grid - 1.0), blend_sharpness)
|
||
|
||
# Normalize weights
|
||
total_weight = weight_x + weight_y + weight_z + 1e-8
|
||
weight_x = weight_x / total_weight
|
||
weight_y = weight_y / total_weight
|
||
weight_z = weight_z / total_weight
|
||
|
||
# Blend projections
|
||
result = proj_x * weight_x + proj_y * weight_y + proj_z * weight_z
|
||
|
||
print(f"✓ Triplanar projection applied")
|
||
print("="*60 + "\n")
|
||
|
||
return (result,)
|
||
|
||
|
||
class TextureOffset:
|
||
"""
|
||
Offset texture boundaries with X/Y shifting, rotation, and wrapping
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"offset_x": ("FLOAT", {
|
||
"default": 0.0,
|
||
"min": -1.0,
|
||
"max": 1.0,
|
||
"step": 0.01,
|
||
"display": "number",
|
||
"tooltip": "Horizontal offset (-1.0 to 1.0, fraction of width)"
|
||
}),
|
||
"offset_y": ("FLOAT", {
|
||
"default": 0.0,
|
||
"min": -1.0,
|
||
"max": 1.0,
|
||
"step": 0.01,
|
||
"display": "number",
|
||
"tooltip": "Vertical offset (-1.0 to 1.0, fraction of height)"
|
||
}),
|
||
"rotation": ("FLOAT", {
|
||
"default": 0.0,
|
||
"min": -360.0,
|
||
"max": 360.0,
|
||
"step": 1.0,
|
||
"display": "number",
|
||
"tooltip": "Rotation in degrees (0-360)"
|
||
}),
|
||
"wrap_mode": (["repeat", "clamp", "mirror"], {
|
||
"default": "repeat",
|
||
"tooltip": "Wrapping mode for edges: repeat (tile), clamp (extend), mirror (reflect)"
|
||
}),
|
||
"edge_mask_width": ("FLOAT", {
|
||
"default": 0.1,
|
||
"min": 0.0,
|
||
"max": 0.5,
|
||
"step": 0.01,
|
||
"display": "number",
|
||
"tooltip": "Edge mask width (0-0.5, fraction of image) - shows affected areas"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "IMAGE")
|
||
RETURN_NAMES = ("image", "edge_mask")
|
||
FUNCTION = "offset_texture"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def offset_texture(self, image, offset_x, offset_y, rotation, wrap_mode, edge_mask_width):
|
||
"""Offset and rotate texture with wrapping and edge mask"""
|
||
import math
|
||
|
||
print("\n" + "="*60)
|
||
print("Texture Offset")
|
||
print("="*60)
|
||
print(f"Input shape: {image.shape}")
|
||
print(f"Offset: X={offset_x:.2f}, Y={offset_y:.2f}")
|
||
print(f"Rotation: {rotation}°")
|
||
print(f"Wrap mode: {wrap_mode}")
|
||
print(f"Edge mask width: {edge_mask_width:.2f}")
|
||
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
dtype = image.dtype
|
||
|
||
# Convert image to BHWC -> BCHW for PyTorch operations
|
||
image_t = image.permute(0, 3, 1, 2)
|
||
|
||
# Create edge mask BEFORE transformations (in original coordinate space)
|
||
edge_mask_original = self._create_original_edge_mask(
|
||
batch, height, width, edge_mask_width, device, dtype
|
||
)
|
||
# Convert to BCHW for transformation
|
||
edge_mask_t = edge_mask_original.permute(0, 3, 1, 2)
|
||
|
||
# Step 1: Apply offset (roll/shift) to BOTH image and mask
|
||
if offset_x != 0.0 or offset_y != 0.0:
|
||
shift_x = int(width * offset_x)
|
||
shift_y = int(height * offset_y)
|
||
|
||
# Use torch.roll for repeat mode (supports true circular wrapping)
|
||
if wrap_mode == "repeat":
|
||
image_t = torch.roll(image_t, shifts=(shift_y, shift_x), dims=(2, 3))
|
||
edge_mask_t = torch.roll(edge_mask_t, shifts=(shift_y, shift_x), dims=(2, 3))
|
||
else:
|
||
# For non-repeat modes, use affine transform with grid_sample
|
||
padding_mode = "border" if wrap_mode == "clamp" else "reflection"
|
||
|
||
theta = torch.tensor([
|
||
[1, 0, -2.0 * offset_x],
|
||
[0, 1, -2.0 * offset_y]
|
||
], dtype=dtype, device=device).unsqueeze(0).repeat(batch, 1, 1)
|
||
|
||
grid = F.affine_grid(theta, image_t.size(), align_corners=False)
|
||
image_t = F.grid_sample(image_t, grid, mode='bilinear',
|
||
padding_mode=padding_mode, align_corners=False)
|
||
|
||
# Apply same transformation to mask
|
||
grid_mask = F.affine_grid(theta, edge_mask_t.size(), align_corners=False)
|
||
edge_mask_t = F.grid_sample(edge_mask_t, grid_mask, mode='bilinear',
|
||
padding_mode=padding_mode, align_corners=False)
|
||
|
||
# Step 2: Apply rotation to BOTH image and mask
|
||
if rotation != 0.0:
|
||
angle_rad = math.radians(rotation)
|
||
cos_a = math.cos(angle_rad)
|
||
sin_a = math.sin(angle_rad)
|
||
|
||
# For repeat mode with rotation, tile the image to avoid edge artifacts
|
||
if wrap_mode == "repeat":
|
||
# Tile 3x3, rotate center, crop back
|
||
tiled = image_t.repeat(1, 1, 3, 3)
|
||
tiled_mask = edge_mask_t.repeat(1, 1, 3, 3)
|
||
|
||
# Rotation matrix
|
||
theta = torch.tensor([
|
||
[cos_a, -sin_a, 0],
|
||
[sin_a, cos_a, 0]
|
||
], dtype=dtype, device=device).unsqueeze(0).repeat(batch, 1, 1)
|
||
|
||
grid = F.affine_grid(theta, tiled.size(), align_corners=False)
|
||
rotated = F.grid_sample(tiled, grid, mode='bilinear',
|
||
padding_mode='zeros', align_corners=False)
|
||
|
||
# Rotate mask too
|
||
grid_mask = F.affine_grid(theta, tiled_mask.size(), align_corners=False)
|
||
rotated_mask = F.grid_sample(tiled_mask, grid_mask, mode='bilinear',
|
||
padding_mode='zeros', align_corners=False)
|
||
|
||
# Crop center tile back to original size
|
||
image_t = rotated[:, :, height:height*2, width:width*2]
|
||
edge_mask_t = rotated_mask[:, :, height:height*2, width:width*2]
|
||
else:
|
||
# For clamp/mirror modes, use grid_sample directly
|
||
padding_mode = "border" if wrap_mode == "clamp" else "reflection"
|
||
|
||
theta = torch.tensor([
|
||
[cos_a, -sin_a, 0],
|
||
[sin_a, cos_a, 0]
|
||
], dtype=dtype, device=device).unsqueeze(0).repeat(batch, 1, 1)
|
||
|
||
grid = F.affine_grid(theta, image_t.size(), align_corners=False)
|
||
image_t = F.grid_sample(image_t, grid, mode='bilinear',
|
||
padding_mode=padding_mode, align_corners=False)
|
||
|
||
# Apply same rotation to mask
|
||
grid_mask = F.affine_grid(theta, edge_mask_t.size(), align_corners=False)
|
||
edge_mask_t = F.grid_sample(edge_mask_t, grid_mask, mode='bilinear',
|
||
padding_mode=padding_mode, align_corners=False)
|
||
|
||
# Convert back to BCHW -> BHWC
|
||
result = image_t.permute(0, 2, 3, 1)
|
||
edge_mask = edge_mask_t.permute(0, 2, 3, 1)
|
||
|
||
print(f"✓ Texture offset applied")
|
||
print(f" Output shape: {result.shape}")
|
||
print(f"✓ Edge mask generated and transformed")
|
||
print(f" Mask range: [{edge_mask.min():.3f}, {edge_mask.max():.3f}]")
|
||
print("="*60 + "\n")
|
||
|
||
return (result, edge_mask)
|
||
|
||
def _create_original_edge_mask(self, batch, height, width, edge_width, device, dtype):
|
||
"""Create edge mask in original coordinate space (before transformations)"""
|
||
edge_mask = torch.zeros((batch, height, width, 1), device=device, dtype=dtype)
|
||
|
||
# Calculate edge region size
|
||
edge_h = max(1, int(height * edge_width))
|
||
edge_w = max(1, int(width * edge_width))
|
||
|
||
# Create gradient from edge to center
|
||
mask_h = torch.linspace(1, 0, edge_h, device=device, dtype=dtype)
|
||
mask_w = torch.linspace(1, 0, edge_w, device=device, dtype=dtype)
|
||
|
||
# Mark all four edges (they will be transformed along with the image)
|
||
# Top edge
|
||
edge_mask[:, :edge_h, :, :] = torch.max(
|
||
edge_mask[:, :edge_h, :, :],
|
||
mask_h.view(1, -1, 1, 1)
|
||
)
|
||
|
||
# Bottom edge
|
||
edge_mask[:, -edge_h:, :, :] = torch.max(
|
||
edge_mask[:, -edge_h:, :, :],
|
||
torch.flip(mask_h, [0]).view(1, -1, 1, 1)
|
||
)
|
||
|
||
# Left edge
|
||
edge_mask[:, :, :edge_w, :] = torch.max(
|
||
edge_mask[:, :, :edge_w, :],
|
||
mask_w.view(1, 1, -1, 1)
|
||
)
|
||
|
||
# Right edge
|
||
edge_mask[:, :, -edge_w:, :] = torch.max(
|
||
edge_mask[:, :, -edge_w:, :],
|
||
torch.flip(mask_w, [0]).view(1, 1, -1, 1)
|
||
)
|
||
|
||
# Convert to RGB for display
|
||
edge_mask_rgb = edge_mask.repeat(1, 1, 1, 3)
|
||
|
||
return edge_mask_rgb
|
||
|
||
|
||
class TextureTiler:
|
||
"""
|
||
Create tiled grid of texture (e.g., 2x2, 3x3)
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"tile_x": ("INT", {
|
||
"default": 2,
|
||
"min": 1,
|
||
"max": 8,
|
||
"step": 1,
|
||
"display": "number",
|
||
"tooltip": "Number of horizontal tiles"
|
||
}),
|
||
"tile_y": ("INT", {
|
||
"default": 2,
|
||
"min": 1,
|
||
"max": 8,
|
||
"step": 1,
|
||
"display": "number",
|
||
"tooltip": "Number of vertical tiles"
|
||
}),
|
||
"scale_to_input": ("BOOLEAN", {
|
||
"default": False,
|
||
"tooltip": "If True, scales output back to input size (tiles appear smaller). If False, output size increases."
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
RETURN_NAMES = ("image",)
|
||
FUNCTION = "tile_texture"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def tile_texture(self, image, tile_x, tile_y, scale_to_input):
|
||
"""Create tiled grid of texture"""
|
||
|
||
print("\n" + "="*60)
|
||
print("Texture Tiler")
|
||
print("="*60)
|
||
print(f"Input shape: {image.shape}")
|
||
print(f"Tiles: {tile_x}x{tile_y}")
|
||
print(f"Scale to input: {scale_to_input}")
|
||
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
|
||
# Repeat horizontally
|
||
tiled_h = image.repeat(1, 1, tile_x, 1)
|
||
|
||
# Repeat vertically
|
||
tiled = tiled_h.repeat(1, tile_y, 1, 1)
|
||
|
||
# If scale_to_input, resize back to original dimensions
|
||
if scale_to_input and (tile_x > 1 or tile_y > 1):
|
||
# Convert BHWC -> BCHW for interpolate
|
||
tiled_t = tiled.permute(0, 3, 1, 2)
|
||
|
||
# Resize to original input size
|
||
tiled_t = F.interpolate(
|
||
tiled_t,
|
||
size=(height, width),
|
||
mode='bilinear',
|
||
align_corners=False
|
||
)
|
||
|
||
# Convert back BCHW -> BHWC
|
||
tiled = tiled_t.permute(0, 2, 3, 1)
|
||
|
||
print(f"✓ Tiled texture created and scaled to input size")
|
||
print(f" Output shape: {tiled.shape} (same as input)")
|
||
print(f" Each tile: {width//tile_x}x{height//tile_y} (approximate)")
|
||
else:
|
||
print(f"✓ Tiled texture created")
|
||
print(f" Output shape: {tiled.shape}")
|
||
print(f" Resolution: {tiled.shape[2]}x{tiled.shape[1]} ({tile_x*width}x{tile_y*height})")
|
||
|
||
print("="*60 + "\n")
|
||
|
||
return (tiled,)
|
||
|
||
|
||
class SmartTextureResizer:
|
||
"""
|
||
Intelligently resize textures to optimal resolutions
|
||
Ensures dimensions are multiples of specified value (e.g., 32, 64)
|
||
Perfect for GPU optimization and game engines
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"target_megapixels": ("FLOAT", {
|
||
"default": 2.0,
|
||
"min": 0.25,
|
||
"max": 16.0,
|
||
"step": 0.25,
|
||
"display": "number",
|
||
"tooltip": "Target size in megapixels (e.g., 2.0 = 2 million pixels)"
|
||
}),
|
||
"multiple_of": ([4, 8, 16, 32, 64, 128, 256], {
|
||
"default": 32,
|
||
"tooltip": "Ensure width/height are multiples of this value"
|
||
}),
|
||
"resize_mode": (["fit_within", "fit_exact", "no_upscale"], {
|
||
"default": "fit_within",
|
||
"tooltip": "fit_within (stay under target), fit_exact (closest match), no_upscale (never increase size)"
|
||
}),
|
||
"scaling_method": (["bicubic", "bilinear", "lanczos", "nearest"], {
|
||
"default": "lanczos",
|
||
"tooltip": "Resampling algorithm for quality"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
RETURN_NAMES = ("image",)
|
||
FUNCTION = "resize_smart"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def resize_smart(self, image, target_megapixels, multiple_of, resize_mode, scaling_method):
|
||
"""Smart resize to optimal resolution"""
|
||
import math
|
||
|
||
print("\n" + "="*60)
|
||
print("Smart Texture Resizer")
|
||
print("="*60)
|
||
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
|
||
current_megapixels = (width * height) / 1_000_000
|
||
|
||
print(f"Input: {width}×{height} ({current_megapixels:.2f} MP)")
|
||
print(f"Target: {target_megapixels:.2f} MP")
|
||
print(f"Multiple of: {multiple_of}")
|
||
print(f"Mode: {resize_mode}")
|
||
|
||
# Calculate optimal dimensions
|
||
new_width, new_height = self._calculate_optimal_dimensions(
|
||
width, height, target_megapixels, multiple_of, resize_mode
|
||
)
|
||
|
||
# Check if resize is needed
|
||
if new_width == width and new_height == height:
|
||
print("✓ Already at optimal resolution, no resize needed")
|
||
print("="*60 + "\n")
|
||
return (image,)
|
||
|
||
# Perform resize
|
||
image_t = image.permute(0, 3, 1, 2) # BHWC -> BCHW
|
||
|
||
# Map scaling method to interpolate mode
|
||
mode_map = {
|
||
"nearest": "nearest",
|
||
"bilinear": "bilinear",
|
||
"bicubic": "bicubic",
|
||
"lanczos": "bicubic" # PyTorch doesn't have lanczos, use bicubic
|
||
}
|
||
|
||
resized = F.interpolate(
|
||
image_t,
|
||
size=(new_height, new_width),
|
||
mode=mode_map.get(scaling_method, "bicubic"),
|
||
align_corners=False if mode_map.get(scaling_method) != "nearest" else None
|
||
)
|
||
|
||
result = resized.permute(0, 2, 3, 1) # BCHW -> BHWC
|
||
|
||
final_megapixels = (new_width * new_height) / 1_000_000
|
||
|
||
print(f"✓ Resized to optimal resolution")
|
||
print(f" Output: {new_width}×{new_height} ({final_megapixels:.2f} MP)")
|
||
print(f" Width multiple: {new_width // multiple_of} × {multiple_of}")
|
||
print(f" Height multiple: {new_height // multiple_of} × {multiple_of}")
|
||
print(f" GPU optimized: {'✓' if new_width % 32 == 0 and new_height % 32 == 0 else '✗'}")
|
||
print("="*60 + "\n")
|
||
|
||
return (result,)
|
||
|
||
def _calculate_optimal_dimensions(self, width, height, target_mp, multiple, mode):
|
||
"""Calculate optimal width and height"""
|
||
import math
|
||
|
||
current_pixels = width * height
|
||
target_pixels = target_mp * 1_000_000
|
||
|
||
# Calculate aspect ratio
|
||
aspect_ratio = width / height
|
||
|
||
# Handle different resize modes
|
||
if mode == "no_upscale" and current_pixels <= target_pixels:
|
||
# Don't upscale, just round to nearest multiple
|
||
new_width = self._round_to_multiple(width, multiple)
|
||
new_height = self._round_to_multiple(height, multiple)
|
||
return new_width, new_height
|
||
|
||
# Calculate scale factor to reach target
|
||
scale = math.sqrt(target_pixels / current_pixels)
|
||
|
||
if mode == "fit_within":
|
||
# Stay under target (scale down a bit to ensure we don't exceed)
|
||
scale *= 0.95
|
||
|
||
# Calculate new dimensions maintaining aspect ratio
|
||
new_width = width * scale
|
||
new_height = height * scale
|
||
|
||
# Round to nearest multiple
|
||
new_width = self._round_to_multiple(int(new_width), multiple)
|
||
new_height = self._round_to_multiple(int(new_height), multiple)
|
||
|
||
# Ensure we have valid dimensions
|
||
new_width = max(multiple, new_width)
|
||
new_height = max(multiple, new_height)
|
||
|
||
# For fit_exact, try to match target more closely
|
||
if mode == "fit_exact":
|
||
# Adjust if we're too far from target
|
||
actual_pixels = new_width * new_height
|
||
if abs(actual_pixels - target_pixels) > target_pixels * 0.2:
|
||
# Recalculate with adjusted scale
|
||
scale_adjust = math.sqrt(target_pixels / actual_pixels)
|
||
new_width = self._round_to_multiple(int(new_width * scale_adjust), multiple)
|
||
new_height = self._round_to_multiple(int(new_height * scale_adjust), multiple)
|
||
|
||
# Final validation
|
||
new_width = max(multiple, new_width)
|
||
new_height = max(multiple, new_height)
|
||
|
||
return new_width, new_height
|
||
|
||
def _round_to_multiple(self, value, multiple):
|
||
"""Round value to nearest multiple"""
|
||
return max(multiple, round(value / multiple) * multiple)
|
||
|
||
|
||
class SquareMaker:
|
||
"""
|
||
Convert images to square by scaling or cropping
|
||
Perfect for textures, AI models, and game engines
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"method": (["crop", "scale"], {
|
||
"default": "crop",
|
||
"tooltip": "crop (maintain aspect, remove edges) or scale (stretch to square)"
|
||
}),
|
||
"square_size": (["shortest_edge", "longest_edge", "custom"], {
|
||
"default": "shortest_edge",
|
||
"tooltip": "Base square size on shortest edge, longest edge, or custom size"
|
||
}),
|
||
"custom_size": ("INT", {
|
||
"default": 1024,
|
||
"min": 64,
|
||
"max": 8192,
|
||
"step": 64,
|
||
"display": "number",
|
||
"tooltip": "Custom square size (only used when square_size=custom)"
|
||
}),
|
||
"crop_position": (["top_left", "top_center", "top_right",
|
||
"middle_left", "center", "middle_right",
|
||
"bottom_left", "bottom_center", "bottom_right"], {
|
||
"default": "center",
|
||
"tooltip": "Where to crop from (only used when method=crop)"
|
||
}),
|
||
"scaling_method": (["bicubic", "bilinear", "lanczos", "nearest"], {
|
||
"default": "lanczos",
|
||
"tooltip": "Resampling algorithm for quality"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
RETURN_NAMES = ("image",)
|
||
FUNCTION = "make_square"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def make_square(self, image, method, square_size, custom_size, crop_position, scaling_method):
|
||
"""Convert image to square"""
|
||
|
||
print("\n" + "="*60)
|
||
print("Square Maker")
|
||
print("="*60)
|
||
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
|
||
print(f"Input: {width}×{height}")
|
||
print(f"Method: {method}")
|
||
print(f"Square size mode: {square_size}")
|
||
|
||
# Determine target square size
|
||
if square_size == "shortest_edge":
|
||
target_size = min(width, height)
|
||
elif square_size == "longest_edge":
|
||
target_size = max(width, height)
|
||
else: # custom
|
||
target_size = custom_size
|
||
|
||
print(f"Target square size: {target_size}×{target_size}")
|
||
|
||
if method == "crop":
|
||
result = self._crop_to_square(image, target_size, crop_position)
|
||
else: # scale
|
||
result = self._scale_to_square(image, target_size, scaling_method)
|
||
|
||
print(f"✓ Square image created: {target_size}×{target_size}")
|
||
print("="*60 + "\n")
|
||
|
||
return (result,)
|
||
|
||
def _crop_to_square(self, image, target_size, position):
|
||
"""Crop image to square at specified position"""
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
|
||
# If already square and correct size, return as-is
|
||
if width == height == target_size:
|
||
return image
|
||
|
||
# If need to scale first (image is smaller than target or not matching)
|
||
if width < target_size or height < target_size or (width != target_size and height != target_size):
|
||
# Scale so that the smallest dimension matches target_size
|
||
if width < height:
|
||
# Width is smaller, scale so width = target_size
|
||
scale_factor = target_size / width
|
||
new_width = target_size
|
||
new_height = int(height * scale_factor)
|
||
else:
|
||
# Height is smaller or equal, scale so height = target_size
|
||
scale_factor = target_size / height
|
||
new_height = target_size
|
||
new_width = int(width * scale_factor)
|
||
|
||
# Perform scaling
|
||
image_t = image.permute(0, 3, 1, 2)
|
||
scaled = F.interpolate(
|
||
image_t,
|
||
size=(new_height, new_width),
|
||
mode='bicubic',
|
||
align_corners=False
|
||
)
|
||
image = scaled.permute(0, 2, 3, 1)
|
||
height, width = new_height, new_width
|
||
|
||
# Calculate crop coordinates based on position
|
||
crop_x, crop_y = self._get_crop_coordinates(width, height, target_size, position)
|
||
|
||
# Perform crop
|
||
cropped = image[:, crop_y:crop_y+target_size, crop_x:crop_x+target_size, :]
|
||
|
||
print(f" Cropped from position: {position}")
|
||
print(f" Crop coordinates: ({crop_x}, {crop_y})")
|
||
|
||
return cropped
|
||
|
||
def _scale_to_square(self, image, target_size, method):
|
||
"""Scale (stretch) image to square"""
|
||
batch, height, width, channels = image.shape
|
||
|
||
# If already correct size, return as-is
|
||
if width == height == target_size:
|
||
return image
|
||
|
||
image_t = image.permute(0, 3, 1, 2) # BHWC -> BCHW
|
||
|
||
# Map scaling method
|
||
mode_map = {
|
||
"nearest": "nearest",
|
||
"bilinear": "bilinear",
|
||
"bicubic": "bicubic",
|
||
"lanczos": "bicubic"
|
||
}
|
||
|
||
scaled = F.interpolate(
|
||
image_t,
|
||
size=(target_size, target_size),
|
||
mode=mode_map.get(method, "bicubic"),
|
||
align_corners=False if mode_map.get(method) != "nearest" else None
|
||
)
|
||
|
||
result = scaled.permute(0, 2, 3, 1) # BCHW -> BHWC
|
||
|
||
print(f" Scaled from {width}×{height} to {target_size}×{target_size}")
|
||
|
||
return result
|
||
|
||
def _get_crop_coordinates(self, width, height, target_size, position):
|
||
"""Calculate crop coordinates based on position"""
|
||
# Calculate maximum offsets
|
||
max_x = max(0, width - target_size)
|
||
max_y = max(0, height - target_size)
|
||
|
||
# Position mapping
|
||
positions = {
|
||
"top_left": (0, 0),
|
||
"top_center": (max_x // 2, 0),
|
||
"top_right": (max_x, 0),
|
||
"middle_left": (0, max_y // 2),
|
||
"center": (max_x // 2, max_y // 2),
|
||
"middle_right": (max_x, max_y // 2),
|
||
"bottom_left": (0, max_y),
|
||
"bottom_center": (max_x // 2, max_y),
|
||
"bottom_right": (max_x, max_y),
|
||
}
|
||
|
||
return positions.get(position, (max_x // 2, max_y // 2))
|
||
|
||
|
||
class TextureEqualizer:
|
||
"""
|
||
Equalize textures by removing uneven lighting and shadows
|
||
Based on the High Pass + Linear Light technique
|
||
Perfect for cleaning up diffuse/albedo and height maps
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"radius": ("FLOAT", {
|
||
"default": 100.0,
|
||
"min": 1.0,
|
||
"max": 500.0,
|
||
"step": 1.0,
|
||
"display": "number",
|
||
"tooltip": "High pass radius - controls detail preservation (50-150 typical)"
|
||
}),
|
||
"strength": ("FLOAT", {
|
||
"default": 1.0,
|
||
"min": 0.0,
|
||
"max": 2.0,
|
||
"step": 0.01,
|
||
"display": "number",
|
||
"tooltip": "Effect strength (1.0 = full correction, 0.0 = no change)"
|
||
}),
|
||
"preserve_color": ("BOOLEAN", {
|
||
"default": True,
|
||
"tooltip": "Preserve original color hue (recommended for albedo)"
|
||
}),
|
||
"method": (["overlay", "soft_light", "linear_light"], {
|
||
"default": "overlay",
|
||
"tooltip": "Blend method: overlay (Photoshop standard), soft_light (subtle), linear_light (strong)"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "IMAGE")
|
||
RETURN_NAMES = ("image", "average_color")
|
||
FUNCTION = "equalize"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def equalize(self, image, radius, strength, preserve_color, method):
|
||
"""Equalize texture lighting using High Pass + blend mode technique"""
|
||
|
||
print("\n" + "="*60)
|
||
print("Texture Equalizer")
|
||
print("="*60)
|
||
print(f"Input shape: {image.shape}")
|
||
print(f"Radius: {radius}")
|
||
print(f"Strength: {strength}")
|
||
print(f"Preserve color: {preserve_color}")
|
||
|
||
batch, height, width, channels = image.shape
|
||
device = image.device
|
||
dtype = image.dtype
|
||
|
||
# Convert to BCHW for processing
|
||
image_t = image.permute(0, 3, 1, 2)
|
||
|
||
# Step 1: Calculate average color (simulates Photoshop's Average Blur)
|
||
average_color_value = torch.mean(image_t, dim=[2, 3], keepdim=True)
|
||
|
||
print(f"Average color: {average_color_value.squeeze().tolist()}")
|
||
|
||
# Step 2: Create Gaussian blur for High Pass filter
|
||
# High Pass = Original - Gaussian Blur
|
||
# Radius in pixels -> sigma for Gaussian
|
||
sigma = radius / 3.0 # Approximate conversion
|
||
|
||
# Create Gaussian kernel
|
||
kernel_size = int(radius * 2) + 1
|
||
if kernel_size % 2 == 0:
|
||
kernel_size += 1
|
||
kernel_size = max(3, min(kernel_size, 201)) # Limit kernel size
|
||
|
||
print(f"Gaussian blur: kernel_size={kernel_size}, sigma={sigma:.2f}")
|
||
|
||
# Gaussian blur using separable convolution for efficiency
|
||
blurred = self._gaussian_blur(image_t, kernel_size, sigma)
|
||
|
||
print(f"Blurred range: [{blurred.min():.3f}, {blurred.max():.3f}]")
|
||
|
||
# Step 3: High Pass filter
|
||
high_pass = image_t - blurred + 0.5 # Add 0.5 to center around mid-gray
|
||
high_pass = torch.clamp(high_pass, 0.0, 1.0) # Clamp high pass to valid range
|
||
|
||
# Step 4: Apply blend mode based on method
|
||
print(f"High Pass range: [{high_pass.min():.3f}, {high_pass.max():.3f}]")
|
||
print(f"Average color: {average_color_value.squeeze().tolist()}")
|
||
print(f"Blend method: {method}")
|
||
|
||
if method == "linear_light":
|
||
# Linear Light: base + 2 * (blend - 0.5) = base + 2 * blend - 1
|
||
result = average_color_value + 2.0 * high_pass - 1.0
|
||
elif method == "overlay":
|
||
# Overlay blend mode (Photoshop standard for equalization)
|
||
# Inverted high pass with overlay blend
|
||
# Invert the high pass first
|
||
inverted_hp = 1.0 - high_pass
|
||
# Overlay formula
|
||
result = torch.where(
|
||
average_color_value < 0.5,
|
||
2.0 * average_color_value * inverted_hp,
|
||
1.0 - 2.0 * (1.0 - average_color_value) * (1.0 - inverted_hp)
|
||
)
|
||
else: # soft_light
|
||
# Soft Light blend mode (gentler version of overlay)
|
||
inverted_hp = 1.0 - high_pass
|
||
result = torch.where(
|
||
inverted_hp < 0.5,
|
||
2.0 * average_color_value * inverted_hp + average_color_value * average_color_value * (1.0 - 2.0 * inverted_hp),
|
||
2.0 * average_color_value * (1.0 - inverted_hp) + torch.sqrt(average_color_value) * (2.0 * inverted_hp - 1.0)
|
||
)
|
||
|
||
print(f"Result BEFORE clamp: [{result.min():.3f}, {result.max():.3f}]")
|
||
|
||
# Clamp to valid range
|
||
result = torch.clamp(result, 0.0, 1.0)
|
||
print(f"Result AFTER clamp: [{result.min():.3f}, {result.max():.3f}]")
|
||
|
||
# Step 5: Blend with original based on strength
|
||
# This replaces the "layer opacity" control in Photoshop
|
||
if strength < 1.0:
|
||
result = image_t * (1.0 - strength) + result * strength
|
||
|
||
print(f"Final result (after strength blend): [{result.min():.3f}, {result.max():.3f}]")
|
||
|
||
# Step 6: Preserve color if requested
|
||
if preserve_color and channels >= 3:
|
||
# Convert to HSV, keep only V (luminance) from result, H and S from original
|
||
result = self._preserve_color_hue(image_t, result)
|
||
|
||
# Convert back to BHWC
|
||
result = result.permute(0, 2, 3, 1)
|
||
|
||
# Create average color output (expand to full image size for visualization)
|
||
average_color_expanded = average_color_value.expand(-1, -1, height, width).permute(0, 2, 3, 1)
|
||
|
||
print(f"✓ Texture equalized")
|
||
print(f" Output range: [{result.min():.3f}, {result.max():.3f}]")
|
||
print(f" Average color range: [{average_color_expanded.min():.3f}, {average_color_expanded.max():.3f}]")
|
||
print("="*60 + "\n")
|
||
|
||
return (result, average_color_expanded)
|
||
|
||
def _gaussian_blur(self, image, kernel_size, sigma):
|
||
"""Apply Gaussian blur using separable convolution"""
|
||
import math
|
||
|
||
# Create 1D Gaussian kernel
|
||
kernel_range = torch.arange(kernel_size, dtype=image.dtype, device=image.device)
|
||
kernel_range = kernel_range - (kernel_size - 1) / 2.0
|
||
|
||
kernel_1d = torch.exp(-0.5 * (kernel_range / sigma) ** 2)
|
||
kernel_1d = kernel_1d / kernel_1d.sum()
|
||
|
||
# Reshape for conv2d
|
||
kernel_h = kernel_1d.view(1, 1, kernel_size, 1).repeat(image.shape[1], 1, 1, 1)
|
||
kernel_w = kernel_1d.view(1, 1, 1, kernel_size).repeat(image.shape[1], 1, 1, 1)
|
||
|
||
# Apply separable convolution (horizontal then vertical)
|
||
padding = kernel_size // 2
|
||
|
||
# Horizontal blur
|
||
blurred = F.conv2d(image, kernel_w, padding=(0, padding), groups=image.shape[1])
|
||
# Vertical blur
|
||
blurred = F.conv2d(blurred, kernel_h, padding=(padding, 0), groups=image.shape[1])
|
||
|
||
return blurred
|
||
|
||
def _preserve_color_hue(self, original, equalized):
|
||
"""Preserve hue and saturation from original, take luminance from equalized"""
|
||
# Convert RGB to HSV
|
||
original_hsv = self._rgb_to_hsv(original)
|
||
equalized_hsv = self._rgb_to_hsv(equalized)
|
||
|
||
# Take H and S from original, V from equalized
|
||
result_hsv = torch.cat([
|
||
original_hsv[:, 0:1, :, :], # Hue from original
|
||
original_hsv[:, 1:2, :, :], # Saturation from original
|
||
equalized_hsv[:, 2:3, :, :] # Value from equalized
|
||
], dim=1)
|
||
|
||
# Convert back to RGB
|
||
result_rgb = self._hsv_to_rgb(result_hsv)
|
||
|
||
return result_rgb
|
||
|
||
def _rgb_to_hsv(self, rgb):
|
||
"""Convert RGB to HSV"""
|
||
r, g, b = rgb[:, 0:1, :, :], rgb[:, 1:2, :, :], rgb[:, 2:3, :, :]
|
||
|
||
max_val = torch.max(torch.max(r, g), b)
|
||
min_val = torch.min(torch.min(r, g), b)
|
||
delta = max_val - min_val
|
||
|
||
# Hue
|
||
hue = torch.zeros_like(max_val)
|
||
mask = delta > 1e-6
|
||
|
||
r_max = (max_val == r) & mask
|
||
g_max = (max_val == g) & mask
|
||
b_max = (max_val == b) & mask
|
||
|
||
hue[r_max] = ((g - b) / delta)[r_max] % 6.0
|
||
hue[g_max] = ((b - r) / delta + 2.0)[g_max]
|
||
hue[b_max] = ((r - g) / delta + 4.0)[b_max]
|
||
hue = hue / 6.0
|
||
|
||
# Saturation
|
||
sat = torch.where(max_val > 1e-6, delta / max_val, torch.zeros_like(max_val))
|
||
|
||
# Value
|
||
val = max_val
|
||
|
||
return torch.cat([hue, sat, val], dim=1)
|
||
|
||
def _hsv_to_rgb(self, hsv):
|
||
"""Convert HSV to RGB"""
|
||
h, s, v = hsv[:, 0:1, :, :], hsv[:, 1:2, :, :], hsv[:, 2:3, :, :]
|
||
|
||
h = h * 6.0
|
||
i = torch.floor(h)
|
||
f = h - i
|
||
|
||
p = v * (1.0 - s)
|
||
q = v * (1.0 - f * s)
|
||
t = v * (1.0 - (1.0 - f) * s)
|
||
|
||
i = i.long() % 6
|
||
|
||
# Create output tensor
|
||
rgb = torch.zeros_like(hsv)
|
||
|
||
# Assign values based on hue sector
|
||
for sector in range(6):
|
||
mask = (i == sector)
|
||
if sector == 0:
|
||
rgb[:, 0:1, :, :] = torch.where(mask, v, rgb[:, 0:1, :, :])
|
||
rgb[:, 1:2, :, :] = torch.where(mask, t, rgb[:, 1:2, :, :])
|
||
rgb[:, 2:3, :, :] = torch.where(mask, p, rgb[:, 2:3, :, :])
|
||
elif sector == 1:
|
||
rgb[:, 0:1, :, :] = torch.where(mask, q, rgb[:, 0:1, :, :])
|
||
rgb[:, 1:2, :, :] = torch.where(mask, v, rgb[:, 1:2, :, :])
|
||
rgb[:, 2:3, :, :] = torch.where(mask, p, rgb[:, 2:3, :, :])
|
||
elif sector == 2:
|
||
rgb[:, 0:1, :, :] = torch.where(mask, p, rgb[:, 0:1, :, :])
|
||
rgb[:, 1:2, :, :] = torch.where(mask, v, rgb[:, 1:2, :, :])
|
||
rgb[:, 2:3, :, :] = torch.where(mask, t, rgb[:, 2:3, :, :])
|
||
elif sector == 3:
|
||
rgb[:, 0:1, :, :] = torch.where(mask, p, rgb[:, 0:1, :, :])
|
||
rgb[:, 1:2, :, :] = torch.where(mask, q, rgb[:, 1:2, :, :])
|
||
rgb[:, 2:3, :, :] = torch.where(mask, v, rgb[:, 2:3, :, :])
|
||
elif sector == 4:
|
||
rgb[:, 0:1, :, :] = torch.where(mask, t, rgb[:, 0:1, :, :])
|
||
rgb[:, 1:2, :, :] = torch.where(mask, p, rgb[:, 1:2, :, :])
|
||
rgb[:, 2:3, :, :] = torch.where(mask, v, rgb[:, 2:3, :, :])
|
||
else: # sector == 5
|
||
rgb[:, 0:1, :, :] = torch.where(mask, v, rgb[:, 0:1, :, :])
|
||
rgb[:, 1:2, :, :] = torch.where(mask, p, rgb[:, 1:2, :, :])
|
||
rgb[:, 2:3, :, :] = torch.where(mask, q, rgb[:, 2:3, :, :])
|
||
|
||
return rgb
|
||
|
||
|
||
class UpscaleCalculator:
|
||
"""
|
||
Calculate correct scale factors for multi-pass upscaling
|
||
Accounts for cumulative effect when chaining multiple upscalers
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"target_scale": ("FLOAT", {
|
||
"default": 4.0,
|
||
"min": 0.125,
|
||
"max": 16.0,
|
||
"step": 0.125,
|
||
"display": "number",
|
||
"tooltip": "Desired final scale multiplier (e.g., 4.0 for 512→2048)"
|
||
}),
|
||
"upscaler_multiplier": ("FLOAT", {
|
||
"default": 4.0,
|
||
"min": 1.0,
|
||
"max": 8.0,
|
||
"step": 0.5,
|
||
"display": "number",
|
||
"tooltip": "The multiplier of each upscaler (e.g., 4.0 for 4× upscaler)"
|
||
}),
|
||
"number_of_passes": ("INT", {
|
||
"default": 2,
|
||
"min": 1,
|
||
"max": 10,
|
||
"step": 1,
|
||
"display": "number",
|
||
"tooltip": "How many upscalers will be chained"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "FLOAT", "INT", "INT", "STRING")
|
||
RETURN_NAMES = ("image", "scale_per_pass", "target_width", "target_height", "info")
|
||
FUNCTION = "calculate_upscale"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def calculate_upscale(self, image, target_scale, upscaler_multiplier, number_of_passes):
|
||
"""Calculate scale factors for multi-pass upscaling"""
|
||
import math
|
||
|
||
print("\n" + "="*60)
|
||
print("Upscale Calculator")
|
||
print("="*60)
|
||
|
||
batch, height, width, channels = image.shape
|
||
|
||
print(f"Input: {width}×{height}")
|
||
print(f"Target scale: {target_scale}×")
|
||
print(f"Upscaler multiplier: {upscaler_multiplier}×")
|
||
print(f"Number of passes: {number_of_passes}")
|
||
|
||
# Calculate target dimensions
|
||
target_width = int(width * target_scale)
|
||
target_height = int(height * target_scale)
|
||
|
||
print(f"\n📐 TARGET DIMENSIONS:")
|
||
print(f" {width}×{height} → {target_width}×{target_height}")
|
||
print(f" Scale: {target_scale}×")
|
||
|
||
# Calculate the scale factor to apply after each upscaler pass
|
||
# Formula: scale_per_pass = (target_scale / upscaler^passes)^(1/passes)
|
||
# Simplified: scale_per_pass = target_scale^(1/passes) / upscaler
|
||
|
||
if number_of_passes == 1:
|
||
# Simple case: only one pass
|
||
scale_per_pass = target_scale / upscaler_multiplier
|
||
else:
|
||
# Multi-pass: need to account for cumulative effect
|
||
# S^N * U^N = D, where S is scale per pass, U is upscaler, D is desired, N is passes
|
||
# S = (D / U^N)^(1/N) = D^(1/N) / U
|
||
scale_per_pass = math.pow(target_scale, 1.0 / number_of_passes) / upscaler_multiplier
|
||
|
||
print(f"\n🔢 CALCULATION:")
|
||
print(f" Formula: scale_per_pass = target_scale^(1/passes) / upscaler")
|
||
print(f" scale_per_pass = {target_scale}^(1/{number_of_passes}) / {upscaler_multiplier}")
|
||
print(f" scale_per_pass = {math.pow(target_scale, 1.0/number_of_passes):.4f} / {upscaler_multiplier}")
|
||
print(f" scale_per_pass = {scale_per_pass:.6f}")
|
||
|
||
# Verify the calculation
|
||
print(f"\n✓ VERIFICATION:")
|
||
current_size = width
|
||
for i in range(number_of_passes):
|
||
current_size = current_size * upscaler_multiplier * scale_per_pass
|
||
print(f" After pass {i+1}: {current_size:.1f}×{current_size * height / width:.1f}")
|
||
|
||
final_width = width * math.pow(upscaler_multiplier * scale_per_pass, number_of_passes)
|
||
final_height = height * math.pow(upscaler_multiplier * scale_per_pass, number_of_passes)
|
||
|
||
print(f"\n Expected final: {target_width}×{target_height}")
|
||
print(f" Calculated final: {final_width:.1f}×{final_height:.1f}")
|
||
|
||
accuracy = abs(final_width - target_width) / target_width * 100
|
||
if accuracy < 0.1:
|
||
print(f" ✓ Perfect match!")
|
||
elif accuracy < 1:
|
||
print(f" ✓ Very close (within 1%)")
|
||
else:
|
||
print(f" ⚠ Deviation: {accuracy:.2f}%")
|
||
|
||
# Create summary
|
||
info_lines = [
|
||
f"Input: {width}×{height}",
|
||
f"Target: {target_width}×{target_height} ({target_scale}×)",
|
||
f"Scale per pass: {scale_per_pass:.6f}",
|
||
f"Passes: {number_of_passes}",
|
||
f"Final: {final_width:.0f}×{final_height:.0f}"
|
||
]
|
||
info_string = " | ".join(info_lines)
|
||
|
||
print(f"\n💡 USAGE:")
|
||
print(f" Connect this image to your first upscaler")
|
||
print(f" Set EACH upscaler's scale factor to: {scale_per_pass:.6f}")
|
||
print(f" Chain {number_of_passes} upscalers together")
|
||
print(f" Result: {target_width}×{target_height} ✓")
|
||
|
||
print("="*60 + "\n")
|
||
|
||
return (image, scale_per_pass, target_width, target_height, info_string)
|
||
|
||
|
||
class UpscaleToResolution:
|
||
"""
|
||
Calculate correct scale factors for multi-pass upscaling to a target resolution
|
||
Specify exact output dimensions instead of scale multiplier
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"target_width": ("INT", {
|
||
"default": 2048,
|
||
"min": 64,
|
||
"max": 16384,
|
||
"step": 64,
|
||
"display": "number",
|
||
"tooltip": "Desired final width in pixels"
|
||
}),
|
||
"target_height": ("INT", {
|
||
"default": 2048,
|
||
"min": 64,
|
||
"max": 16384,
|
||
"step": 64,
|
||
"display": "number",
|
||
"tooltip": "Desired final height in pixels"
|
||
}),
|
||
"upscaler_multiplier": ("FLOAT", {
|
||
"default": 4.0,
|
||
"min": 1.0,
|
||
"max": 8.0,
|
||
"step": 0.5,
|
||
"display": "number",
|
||
"tooltip": "The multiplier of each upscaler (e.g., 4.0 for 4× upscaler)"
|
||
}),
|
||
"number_of_passes": ("INT", {
|
||
"default": 2,
|
||
"min": 1,
|
||
"max": 10,
|
||
"step": 1,
|
||
"display": "number",
|
||
"tooltip": "How many upscalers will be chained"
|
||
}),
|
||
"maintain_aspect": ("BOOLEAN", {
|
||
"default": True,
|
||
"tooltip": "Maintain original aspect ratio (scale to fit within target dimensions)"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "FLOAT", "INT", "INT", "FLOAT", "STRING")
|
||
RETURN_NAMES = ("image", "scale_per_pass", "final_width", "final_height", "total_scale", "info")
|
||
FUNCTION = "calculate_upscale"
|
||
CATEGORY = "Texture Alchemist/Texture"
|
||
|
||
def calculate_upscale(self, image, target_width, target_height, upscaler_multiplier,
|
||
number_of_passes, maintain_aspect):
|
||
"""Calculate scale factors for multi-pass upscaling to target resolution"""
|
||
import math
|
||
|
||
print("\n" + "="*60)
|
||
print("Upscale to Resolution Calculator")
|
||
print("="*60)
|
||
|
||
batch, height, width, channels = image.shape
|
||
|
||
print(f"Input: {width}×{height}")
|
||
print(f"Target: {target_width}×{target_height}")
|
||
print(f"Upscaler multiplier: {upscaler_multiplier}×")
|
||
print(f"Number of passes: {number_of_passes}")
|
||
print(f"Maintain aspect: {maintain_aspect}")
|
||
|
||
# Calculate required scale based on target dimensions
|
||
if maintain_aspect:
|
||
# Scale to fit within target dimensions (preserve aspect ratio)
|
||
scale_w = target_width / width
|
||
scale_h = target_height / height
|
||
|
||
# Use the smaller scale to ensure it fits within target
|
||
target_scale = min(scale_w, scale_h)
|
||
|
||
# Calculate actual final dimensions
|
||
final_width = int(width * target_scale)
|
||
final_height = int(height * target_scale)
|
||
|
||
print(f"\n📐 ASPECT RATIO PRESERVED:")
|
||
print(f" Scale width: {scale_w:.4f}×")
|
||
print(f" Scale height: {scale_h:.4f}×")
|
||
print(f" Using scale: {target_scale:.4f}× (smaller to fit within target)")
|
||
print(f" Actual output: {final_width}×{final_height}")
|
||
|
||
if final_width != target_width or final_height != target_height:
|
||
print(f" ⚠ Note: Output will be smaller than target to preserve aspect ratio")
|
||
else:
|
||
# Non-uniform scaling (may distort image)
|
||
scale_w = target_width / width
|
||
scale_h = target_height / height
|
||
|
||
if abs(scale_w - scale_h) > 0.01:
|
||
print(f"\n⚠ WARNING: Non-uniform scaling will distort the image!")
|
||
print(f" Width scale: {scale_w:.4f}×")
|
||
print(f" Height scale: {scale_h:.4f}×")
|
||
print(f" Difference: {abs(scale_w - scale_h):.4f}×")
|
||
|
||
# For simplicity, use average scale
|
||
target_scale = (scale_w + scale_h) / 2.0
|
||
final_width = target_width
|
||
final_height = target_height
|
||
|
||
print(f"\n📐 NON-UNIFORM SCALING:")
|
||
print(f" Using average scale: {target_scale:.4f}×")
|
||
|
||
print(f"\n🎯 TARGET SCALE:")
|
||
print(f" {width}×{height} → {final_width}×{final_height}")
|
||
print(f" Scale factor: {target_scale:.4f}×")
|
||
|
||
# Check if target is achievable
|
||
max_possible = width * math.pow(upscaler_multiplier, number_of_passes)
|
||
if final_width > max_possible:
|
||
print(f"\n⚠ WARNING: Target may not be achievable!")
|
||
print(f" Maximum possible: {int(max_possible)}×{int(max_possible * height / width)}")
|
||
print(f" You requested: {final_width}×{final_height}")
|
||
print(f" Consider adding more passes or using a higher multiplier upscaler")
|
||
|
||
# Calculate the scale factor to apply after each upscaler pass
|
||
if number_of_passes == 1:
|
||
scale_per_pass = target_scale / upscaler_multiplier
|
||
else:
|
||
scale_per_pass = math.pow(target_scale, 1.0 / number_of_passes) / upscaler_multiplier
|
||
|
||
print(f"\n🔢 CALCULATION:")
|
||
print(f" Formula: scale_per_pass = target_scale^(1/passes) / upscaler")
|
||
print(f" scale_per_pass = {target_scale:.4f}^(1/{number_of_passes}) / {upscaler_multiplier}")
|
||
print(f" scale_per_pass = {math.pow(target_scale, 1.0/number_of_passes):.6f} / {upscaler_multiplier}")
|
||
print(f" scale_per_pass = {scale_per_pass:.6f}")
|
||
|
||
# Verify the calculation
|
||
print(f"\n✓ VERIFICATION:")
|
||
current_w = width
|
||
current_h = height
|
||
for i in range(number_of_passes):
|
||
current_w = current_w * upscaler_multiplier * scale_per_pass
|
||
current_h = current_h * upscaler_multiplier * scale_per_pass
|
||
print(f" After pass {i+1}: {current_w:.1f}×{current_h:.1f}")
|
||
|
||
print(f"\n Target: {final_width}×{final_height}")
|
||
print(f" Result: {current_w:.1f}×{current_h:.1f}")
|
||
|
||
accuracy_w = abs(current_w - final_width) / final_width * 100
|
||
accuracy_h = abs(current_h - final_height) / final_height * 100
|
||
max_accuracy = max(accuracy_w, accuracy_h)
|
||
|
||
if max_accuracy < 0.1:
|
||
print(f" ✓ Perfect match!")
|
||
elif max_accuracy < 1:
|
||
print(f" ✓ Very close (within 1%)")
|
||
else:
|
||
print(f" ⚠ Deviation: {max_accuracy:.2f}%")
|
||
|
||
# Create summary
|
||
info_lines = [
|
||
f"Input: {width}×{height}",
|
||
f"Target: {final_width}×{final_height}",
|
||
f"Scale: {target_scale:.4f}×",
|
||
f"Scale per pass: {scale_per_pass:.6f}",
|
||
f"Passes: {number_of_passes}"
|
||
]
|
||
info_string = " | ".join(info_lines)
|
||
|
||
print(f"\n💡 USAGE:")
|
||
print(f" Connect this image to your first upscaler")
|
||
print(f" Set EACH upscaler's scale factor to: {scale_per_pass:.6f}")
|
||
print(f" Chain {number_of_passes} upscalers together")
|
||
print(f" Result: {final_width}×{final_height} ✓")
|
||
|
||
print("="*60 + "\n")
|
||
|
||
return (image, scale_per_pass, final_width, final_height, target_scale, info_string)
|
||
|
||
|
||
# Node registration
|
||
NODE_CLASS_MAPPINGS = {
|
||
"SeamlessTiling": SeamlessTiling,
|
||
"TextureScaler": TextureScaler,
|
||
"TriplanarProjection": TriplanarProjection,
|
||
"TextureOffset": TextureOffset,
|
||
"TextureTiler": TextureTiler,
|
||
"SmartTextureResizer": SmartTextureResizer,
|
||
"SquareMaker": SquareMaker,
|
||
"TextureEqualizer": TextureEqualizer,
|
||
"UpscaleCalculator": UpscaleCalculator,
|
||
"UpscaleToResolution": UpscaleToResolution,
|
||
}
|
||
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"SeamlessTiling": "Seamless Tiling Maker",
|
||
"TextureScaler": "Texture Scaler",
|
||
"TriplanarProjection": "Triplanar Projection",
|
||
"TextureOffset": "Texture Offset",
|
||
"TextureTiler": "Texture Tiler",
|
||
"SmartTextureResizer": "Smart Texture Resizer",
|
||
"SquareMaker": "Square Maker",
|
||
"TextureEqualizer": "Texture Equalizer",
|
||
"UpscaleCalculator": "Upscale Calculator (Multi-Pass)",
|
||
"UpscaleToResolution": "Upscale to Resolution (Multi-Pass)",
|
||
}
|
||
|