Files
amtarr-ComfyUI-TextureAlchemy/texture_utils.py
T
2025-12-10 20:45:34 -08:00

1594 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)",
}