Files

4397 lines
175 KiB
Python
Raw Permalink 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
def _fill_pad_corners_from_crop(
padded: torch.Tensor, cropped: torch.Tensor, y_off: int, x_off: int, h: int, w: int
) -> None:
"""
In-place. When extending a crop to a larger canvas, edge/mirror side fills
do not cover the four corner rectangles; they stay at the initial value (e.g. black).
Replicate the crop's four corner colors into those corner bands (common with pad to square).
"""
if h < 1 or w < 1:
return
Hp, Wp = int(padded.shape[1]), int(padded.shape[2])
y_end, x_end = y_off + h, x_off + w
if y_off > 0 and x_off > 0:
padded[:, :y_off, :x_off, :] = cropped[:, 0:1, 0:1, :].repeat(1, y_off, x_off, 1)
if y_off > 0 and x_end < Wp:
tw = Wp - x_end
if tw > 0:
padded[:, :y_off, x_end:Wp, :] = cropped[:, 0:1, w - 1 : w, :].repeat(1, y_off, tw, 1)
if y_end < Hp and x_off > 0:
bh = Hp - y_end
if bh > 0:
padded[:, y_end:Hp, :x_off, :] = cropped[:, h - 1 : h, 0:1, :].repeat(1, bh, x_off, 1)
if y_end < Hp and x_end < Wp:
tw, bh = Wp - x_end, Hp - y_end
if tw > 0 and bh > 0:
padded[:, y_end:Hp, x_end:Wp, :] = cropped[:, h - 1 : h, w - 1 : w, :].repeat(1, bh, tw, 1)
def _source_window_square(
x_min: int, y_min: int, crop_w: int, crop_h: int, padding: int, image_width: int, image_height: int
) -> tuple[int, int, int]:
"""
Center a square in the full image on the mask bounds, side
L = max(cw, ch) + 2*padding, clamped to the largest square that fits.
Returns (x0, y0, L) in source image coordinates.
"""
l = max(crop_w, crop_h) + 2 * padding
max_l = min(image_width, image_height)
l = min(l, max_l)
if l < 1:
l = 1
cx = x_min + 0.5 * crop_w
cy = y_min + 0.5 * crop_h
x0 = int(round(cx - 0.5 * l))
y0 = int(round(cy - 0.5 * l))
x0 = max(0, min(x0, image_width - l))
y0 = max(0, min(y0, image_height - l))
return int(x0), int(y0), int(l)
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)
class PaddingCalculator:
"""
Calculate exact padding dimensions for percentage-based padding
Always rounds to whole numbers for perfect cropping
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {
"default": 1024,
"min": 1,
"max": 16384,
"step": 1,
"display": "number",
"tooltip": "Original image width in pixels"
}),
"height": ("INT", {
"default": 1024,
"min": 1,
"max": 16384,
"step": 1,
"display": "number",
"tooltip": "Original image height in pixels"
}),
"pad_percentage": ("FLOAT", {
"default": 10.0,
"min": 0.0,
"max": 100.0,
"step": 0.1,
"display": "number",
"tooltip": "Padding percentage (will be rounded to whole pixels)"
}),
"rounding_mode": (["round", "floor", "ceil"], {
"default": "round",
"tooltip": "How to round fractional pixels: round (nearest), floor (down), ceil (up)"
}),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "INT", "STRING")
RETURN_NAMES = ("new_width", "new_height", "pad_pixels_width", "pad_pixels_height", "info")
FUNCTION = "calculate_padding"
CATEGORY = "Texture Alchemist/Texture"
def calculate_padding(self, width, height, pad_percentage, rounding_mode):
"""
Calculate padding that rounds to whole numbers for perfect cropping
Pads evenly on both sides
"""
print("\n" + "="*60)
print("Padding Calculator")
print("="*60)
print(f"Original dimensions: {width} × {height}")
print(f"Requested padding: {pad_percentage}%")
print(f"Rounding mode: {rounding_mode}")
# Calculate raw padding per side
raw_pad_width = (width * pad_percentage) / 100.0
raw_pad_height = (height * pad_percentage) / 100.0
print(f"\nRaw padding calculation:")
print(f" Width: {raw_pad_width:.2f} pixels per side")
print(f" Height: {raw_pad_height:.2f} pixels per side")
# Round according to mode
import math
if rounding_mode == "round":
pad_pixels_width = round(raw_pad_width)
pad_pixels_height = round(raw_pad_height)
elif rounding_mode == "floor":
pad_pixels_width = math.floor(raw_pad_width)
pad_pixels_height = math.floor(raw_pad_height)
else: # ceil
pad_pixels_width = math.ceil(raw_pad_width)
pad_pixels_height = math.ceil(raw_pad_height)
# Calculate new dimensions (original + padding on both sides)
new_width = width + (pad_pixels_width * 2)
new_height = height + (pad_pixels_height * 2)
# Calculate actual percentage applied
actual_percent_width = (pad_pixels_width / width) * 100.0 if width > 0 else 0
actual_percent_height = (pad_pixels_height / height) * 100.0 if height > 0 else 0
print(f"\n✓ Rounded padding:")
print(f" Width: {pad_pixels_width} pixels per side")
print(f" Height: {pad_pixels_height} pixels per side")
print(f"\n✓ New dimensions:")
print(f" {width} × {height} → {new_width} × {new_height}")
print(f"\n✓ Actual padding percentage:")
print(f" Width: {actual_percent_width:.2f}%")
print(f" Height: {actual_percent_height:.2f}%")
# Create info string
info_lines = [
f"Original: {width} × {height}",
f"Requested: {pad_percentage}% padding",
f"Rounded: {pad_pixels_width}px (W), {pad_pixels_height}px (H) per side",
f"New size: {new_width} × {new_height}",
f"Actual: {actual_percent_width:.2f}% (W), {actual_percent_height:.2f}% (H)"
]
info_string = " | ".join(info_lines)
print(f"\n💡 Usage:")
print(f" 1. Pad image by {pad_pixels_width}px (W) and {pad_pixels_height}px (H)")
print(f" 2. Process the {new_width} × {new_height} image")
print(f" 3. Crop back using the same padding values")
print("="*60 + "\n")
return (new_width, new_height, pad_pixels_width, pad_pixels_height, info_string)
class InpaintCropExtractor:
"""
Extract masked region as a cropped image for processing
Finds bounding box of mask and crops image + mask to that region
Perfect for efficient inpainting workflows
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"padding": ("INT", {
"default": 0,
"min": 0,
"max": 500,
"step": 1,
"display": "number",
"tooltip": "Extra padding around mask bounding box (pixels)"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert the mask before processing (e.g., for white background masks)"
}),
}
}
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "INT", "INT", "INT", "INT", "STRING", "BBOX_DATA")
RETURN_NAMES = ("cropped_image", "masked_crop", "cropped_mask", "bbox_x", "bbox_y", "bbox_width", "bbox_height", "info", "bbox_data")
FUNCTION = "extract_crop"
CATEGORY = "Texture Alchemist/Inpainting"
def extract_crop(self, image, mask, padding, invert_mask):
"""
Find mask bounding box and extract cropped region
"""
print("\n" + "="*60)
print("Inpaint Crop Extractor")
print("="*60)
print(f"Image shape: {image.shape}")
print(f"Mask shape: {mask.shape}")
print(f"Padding: {padding}px")
print(f"Invert mask: {invert_mask}")
batch, height, width, channels = image.shape
# Ensure mask matches image dimensions
if len(mask.shape) == 3: # (B, H, W)
mask_2d = mask[0] # Take first in batch
else: # (B, H, W, C)
mask_2d = mask[0, :, :, 0]
# Resize mask if needed
if mask_2d.shape != (height, width):
mask_reshaped = mask.unsqueeze(1) if len(mask.shape) == 3 else mask.permute(0, 3, 1, 2)
mask_resized = F.interpolate(
mask_reshaped,
size=(height, width),
mode='bilinear',
align_corners=False
)
if len(mask.shape) == 3:
mask = mask_resized.squeeze(1)
mask_2d = mask[0]
else:
mask = mask_resized.permute(0, 2, 3, 1)
mask_2d = mask[0, :, :, 0]
# Apply inversion if requested
if invert_mask:
mask_2d = 1.0 - mask_2d
print("✓ Mask inverted for processing.")
# Debugging mask analysis
print("\n🔍 Analyzing mask:")
print(f" Mask shape: {mask_2d.shape}")
print(f" Mask range: [{mask_2d.min():.3f}, {mask_2d.max():.3f}]")
print(f" Pixels > 0.01: {(mask_2d > 0.01).sum().item()}")
print(f" Pixels > 0.1: {(mask_2d > 0.1).sum().item()}")
print(f" Pixels > 0.5: {(mask_2d > 0.5).sum().item()}")
print(f" Pixels > 0.9: {(mask_2d > 0.9).sum().item()}")
# Debugging edge analysis
print("\n🔍 Edge analysis:")
if mask_2d.shape[0] > 0 and mask_2d.shape[1] > 0:
print(f" Top-left corner (0,0): {mask_2d[0, 0].item():.3f}")
print(f" Top-right corner (0,-1): {mask_2d[0, -1].item():.3f}")
print(f" Bottom-left corner (-1,0): {mask_2d[-1, 0].item():.3f}")
print(f" Bottom-right corner (-1,-1): {mask_2d[-1, -1].item():.3f}")
print(f" Top row max: {mask_2d[0, :].max().item():.3f}")
print(f" Bottom row max: {mask_2d[-1, :].max().item():.3f}")
print(f" Left column max: {mask_2d[:, 0].max().item():.3f}")
print(f" Right column max: {mask_2d[:, -1].max().item():.3f}")
# Find bounding box of mask (where mask > 0.01)
mask_binary = (mask_2d > 0.01).float()
# Find non-zero coordinates
nonzero = torch.nonzero(mask_binary, as_tuple=False)
print(f" Non-zero count: {nonzero.shape[0]}")
if nonzero.shape[0] == 0:
# Empty mask - return full image
print("\n⚠️ Empty mask detected (after thresholding) - returning full image")
bbox_x, bbox_y = 0, 0
bbox_width, bbox_height = width, height
else:
# Get bounding box
y_min = nonzero[:, 0].min().item()
y_max = nonzero[:, 0].max().item()
x_min = nonzero[:, 1].min().item()
x_max = nonzero[:, 1].max().item()
# Add padding (expand bounding box within original image bounds)
y_min = max(0, y_min - padding)
y_max = min(height - 1, y_max + padding)
x_min = max(0, x_min - padding)
x_max = min(width - 1, x_max + padding)
bbox_x = x_min
bbox_y = y_min
bbox_width = x_max - x_min + 1
bbox_height = y_max - y_min + 1
print(f"\n✓ Bounding box found:")
print(f" Position: ({bbox_x}, {bbox_y})")
print(f" Size: {bbox_width} × {bbox_height}")
print(f" Original canvas: {width} × {height}")
print(f" Coverage: {(bbox_width * bbox_height) / (width * height) * 100:.1f}% of image")
print("\n✂️ Cropping:")
# Crop image
cropped_image = image[:, bbox_y:bbox_y+bbox_height, bbox_x:bbox_x+bbox_width, :]
# Crop mask (use the processed mask_2d, then reconstruct full mask if needed)
# If we inverted, we need to work with inverted data
cropped_mask_2d = mask_2d[bbox_y:bbox_y+bbox_height, bbox_x:bbox_x+bbox_width]
# Expand to batch if needed
if batch > 1:
cropped_mask = cropped_mask_2d.unsqueeze(0).repeat(batch, 1, 1)
else:
cropped_mask = cropped_mask_2d.unsqueeze(0)
print(f" Cropped image: {cropped_image.shape}")
print(f" Cropped mask: {cropped_mask.shape}")
print(f" Expected: {batch, bbox_height, bbox_width}")
# Create masked crop (image with mask applied)
# Expand mask to match image channels
mask_expanded = cropped_mask.unsqueeze(-1).repeat(1, 1, 1, channels)
masked_crop = cropped_image * mask_expanded
# Create info string
info_lines = [
f"BBox: ({bbox_x}, {bbox_y})",
f"Size: {bbox_width}×{bbox_height}",
f"Original: {width}×{height}",
f"Padding: {padding}px"
]
if invert_mask:
info_lines.append("Mask inverted")
info_string = " | ".join(info_lines)
print(f"\n✓ Outputs:")
print(f" Cropped Image: {cropped_image.shape}")
print(f" Masked Crop: {masked_crop.shape}")
print(f" Cropped Mask: {cropped_mask.shape}")
print(f" BBox: x={bbox_x}, y={bbox_y}, w={bbox_width}, h={bbox_height}")
# Print mask statistics
mask_min = cropped_mask.min().item()
mask_max = cropped_mask.max().item()
mask_mean = cropped_mask.mean().item()
coverage = (cropped_mask > 0.01).float().mean().item() * 100
print(f"\n📊 Cropped Mask Statistics:")
print(f" Range: [{mask_min:.3f}, {mask_max:.3f}]")
print(f" Mean: {mask_mean:.3f}")
print(f" Coverage: {coverage:.1f}% of crop")
print("="*60 + "\n")
# Create bbox data bundle for simplified workflow
bbox_data = {
"x": bbox_x,
"y": bbox_y,
"width": bbox_width,
"height": bbox_height,
"original_width": width,
"original_height": height
}
return (cropped_image, masked_crop, cropped_mask, bbox_x, bbox_y, bbox_width, bbox_height, info_string, bbox_data)
class InpaintStitcher:
"""
Stitch processed crop back onto original image
Uses bounding box coordinates from InpaintCropExtractor
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"original_image": ("IMAGE",),
"processed_crop": ("IMAGE",),
"bbox_x": ("INT", {
"default": 0,
"min": 0,
"max": 16384,
"display": "number"
}),
"bbox_y": ("INT", {
"default": 0,
"min": 0,
"max": 16384,
"display": "number"
}),
"bbox_width": ("INT", {
"default": 512,
"min": 1,
"max": 16384,
"display": "number"
}),
"bbox_height": ("INT", {
"default": 512,
"min": 1,
"max": 16384,
"display": "number"
}),
"blend_mode": (["replace", "blend"], {
"default": "replace",
"tooltip": "Replace: hard composite, Blend: soft edges"
}),
"feather": ("INT", {
"default": 0,
"min": 0,
"max": 100,
"step": 1,
"display": "number",
"tooltip": "Feather/blend edges (pixels, only for blend mode)"
}),
},
"optional": {
"blend_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("stitched_image",)
FUNCTION = "stitch"
CATEGORY = "Texture Alchemist/Inpainting"
def stitch(self, original_image, processed_crop, bbox_x, bbox_y, bbox_width, bbox_height,
blend_mode, feather, blend_mask=None):
"""
Composite processed crop back onto original image
"""
print("\n" + "="*60)
print("Inpaint Stitcher")
print("="*60)
print(f"Original image: {original_image.shape}")
print(f"Processed crop: {processed_crop.shape}")
print(f"BBox: ({bbox_x}, {bbox_y}) size {bbox_width}×{bbox_height}")
print(f"Blend mode: {blend_mode}")
batch, height, width, channels = original_image.shape
# Create result as copy of original
result = original_image.clone()
# Resize processed crop if it doesn't match bbox dimensions
crop_batch, crop_h, crop_w, crop_c = processed_crop.shape
# Handle channel mismatch (e.g., RGB vs RGBA)
if crop_c != channels:
print(f" Channel mismatch detected: crop={crop_c}, original={channels}")
if crop_c == 3 and channels == 4:
# Crop is RGB, original is RGBA - add alpha channel to crop
alpha_channel = torch.ones((crop_batch, crop_h, crop_w, 1),
device=processed_crop.device,
dtype=processed_crop.dtype)
processed_crop = torch.cat([processed_crop, alpha_channel], dim=-1)
crop_c = 4
print(f" Added alpha channel to crop")
elif crop_c == 4 and channels == 3:
# Crop is RGBA, original is RGB - drop alpha channel
processed_crop = processed_crop[:, :, :, :3]
crop_c = 3
print(f" Dropped alpha channel from crop")
if (crop_h, crop_w) != (bbox_height, bbox_width):
print(f" Resizing crop from {crop_w}×{crop_h} to {bbox_width}×{bbox_height}")
processed_crop = F.interpolate(
processed_crop.permute(0, 3, 1, 2),
size=(bbox_height, bbox_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
# Handle negative bbox coordinates (from padding)
# Calculate the visible region
crop_x_start = max(0, -bbox_x) # Offset into crop if bbox_x is negative
crop_y_start = max(0, -bbox_y) # Offset into crop if bbox_y is negative
paste_x = max(0, bbox_x) # Where to paste in original (0 if bbox_x negative)
paste_y = max(0, bbox_y) # Where to paste in original (0 if bbox_y negative)
# Calculate the actual region that fits
paste_x_end = min(paste_x + (bbox_width - crop_x_start), width)
paste_y_end = min(paste_y + (bbox_height - crop_y_start), height)
actual_width = paste_x_end - paste_x
actual_height = paste_y_end - paste_y
# Extract the visible portion of the crop
crop_x_end = crop_x_start + actual_width
crop_y_end = crop_y_start + actual_height
if crop_x_start > 0 or crop_y_start > 0 or crop_x_end < bbox_width or crop_y_end < bbox_height:
print(f" Cropping to visible region: crop[{crop_y_start}:{crop_y_end}, {crop_x_start}:{crop_x_end}] → paste[{paste_y}:{paste_y_end}, {paste_x}:{paste_x_end}]")
processed_crop = processed_crop[:, crop_y_start:crop_y_end, crop_x_start:crop_x_end, :]
if blend_mode == "replace":
# Hard composite - direct replacement
result[:, paste_y:paste_y_end, paste_x:paste_x_end, :] = processed_crop
print(f"✓ Hard composite applied")
else: # blend
# Create blend mask
if blend_mask is not None:
# Use provided mask
print(f" Using provided blend mask")
if len(blend_mask.shape) == 3: # (B, H, W)
mask_2d = blend_mask[0]
else: # (B, H, W, C)
mask_2d = blend_mask[0, :, :, 0]
# Extract the same region from the mask as we did from the crop
if mask_2d.shape[0] == bbox_height and mask_2d.shape[1] == bbox_width:
# Mask matches original bbox size, extract the visible portion
mask_2d = mask_2d[crop_y_start:crop_y_end, crop_x_start:crop_x_end]
blend_alpha = mask_2d
print(f" Extracted mask region: {blend_alpha.shape}")
elif mask_2d.shape != (actual_height, actual_width):
# Resize to actual dimensions
mask_reshaped = blend_mask.unsqueeze(1) if len(blend_mask.shape) == 3 else blend_mask.permute(0, 3, 1, 2)
mask_resized = F.interpolate(
mask_reshaped,
size=(actual_height, actual_width),
mode='bilinear',
align_corners=False
)
if len(blend_mask.shape) == 3:
blend_alpha = mask_resized[0, 0]
else:
blend_alpha = mask_resized[0, 0, :, :]
print(f" Resized mask: {blend_alpha.shape}")
else:
blend_alpha = mask_2d[:actual_height, :actual_width]
elif feather > 0:
# Create feathered edge mask
blend_alpha = torch.ones((actual_height, actual_width),
device=original_image.device,
dtype=original_image.dtype)
# Apply feather to edges
feather_pixels = min(feather, actual_width // 2, actual_height // 2)
if feather_pixels > 0:
for i in range(feather_pixels):
alpha_value = i / feather_pixels
# Top
blend_alpha[i, :] = alpha_value
# Bottom
blend_alpha[actual_height - 1 - i, :] = alpha_value
# Left
blend_alpha[:, i] = torch.minimum(blend_alpha[:, i],
torch.tensor(alpha_value, device=blend_alpha.device))
# Right
blend_alpha[:, actual_width - 1 - i] = torch.minimum(
blend_alpha[:, actual_width - 1 - i],
torch.tensor(alpha_value, device=blend_alpha.device))
print(f" Applied {feather_pixels}px feather")
else:
# No feather, just alpha blend
blend_alpha = torch.ones((actual_height, actual_width),
device=original_image.device,
dtype=original_image.dtype)
# Expand alpha to match channels
blend_alpha = blend_alpha.unsqueeze(0).unsqueeze(-1).repeat(batch, 1, 1, channels)
# Alpha blend
original_region = result[:, paste_y:paste_y_end, paste_x:paste_x_end, :]
result[:, paste_y:paste_y_end, paste_x:paste_x_end, :] = (
processed_crop * blend_alpha + original_region * (1.0 - blend_alpha)
)
print(f"✓ Soft blend applied")
print(f"\n✓ Stitched result: {result.shape}")
print("="*60 + "\n")
return (result,)
class SimpleInpaintCrop:
"""
SIMPLIFIED INPAINTING - CROP
One-click crop with bundled bbox data for easy stitching
Perfect for streamlined inpainting workflows
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"padding": ("INT", {
"default": 32,
"min": 0,
"max": 500,
"step": 1,
"display": "number",
"tooltip": "Extra padding around mask (pixels)"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert mask before processing"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "BBOX_DATA", "STRING")
RETURN_NAMES = ("cropped_image", "cropped_mask", "masked_composite", "bbox_data", "info")
FUNCTION = "crop_for_inpaint"
CATEGORY = "Texture Alchemist/Inpainting/Simple"
def crop_for_inpaint(self, image, mask, padding, invert_mask):
"""
Extract masked region for inpainting with bundled bbox data
"""
print("\n" + "="*60)
print("Simple Inpaint Crop")
print("="*60)
print(f"Image: {image.shape}")
print(f"Mask: {mask.shape}")
print(f"Padding: {padding}px")
batch, height, width, channels = image.shape
device = image.device
dtype = image.dtype
# Ensure mask matches image dimensions
if len(mask.shape) == 3: # (B, H, W)
mask_2d = mask[0]
else: # (B, H, W, C)
mask_2d = mask[0, :, :, 0]
# Resize mask if needed
if mask_2d.shape != (height, width):
mask_reshaped = mask.unsqueeze(1) if len(mask.shape) == 3 else mask.permute(0, 3, 1, 2)
mask_resized = F.interpolate(
mask_reshaped,
size=(height, width),
mode='bilinear',
align_corners=False
)
if len(mask.shape) == 3:
mask = mask_resized.squeeze(1)
mask_2d = mask[0]
else:
mask = mask_resized.permute(0, 2, 3, 1)
mask_2d = mask[0, :, :, 0]
# Apply inversion if requested
if invert_mask:
mask_2d = 1.0 - mask_2d
print("✓ Mask inverted")
# Find bounding box
mask_binary = (mask_2d > 0.01).float()
nonzero = torch.nonzero(mask_binary, as_tuple=False)
if nonzero.shape[0] == 0:
# Empty mask - return full image
print("⚠️ Empty mask - using full image")
bbox_x, bbox_y = 0, 0
bbox_width, bbox_height = width, height
else:
y_min = nonzero[:, 0].min().item()
y_max = nonzero[:, 0].max().item()
x_min = nonzero[:, 1].min().item()
x_max = nonzero[:, 1].max().item()
# Add padding
y_min = max(0, y_min - padding)
y_max = min(height - 1, y_max + padding)
x_min = max(0, x_min - padding)
x_max = min(width - 1, x_max + padding)
bbox_x = x_min
bbox_y = y_min
bbox_width = x_max - x_min + 1
bbox_height = y_max - y_min + 1
print(f"✓ BBox: ({bbox_x}, {bbox_y}) {bbox_width}×{bbox_height}")
# Crop image and mask
cropped_image = image[:, bbox_y:bbox_y+bbox_height, bbox_x:bbox_x+bbox_width, :]
cropped_mask_2d = mask_2d[bbox_y:bbox_y+bbox_height, bbox_x:bbox_x+bbox_width]
# Expand mask to batch
if batch > 1:
cropped_mask = cropped_mask_2d.unsqueeze(0).repeat(batch, 1, 1)
else:
cropped_mask = cropped_mask_2d.unsqueeze(0)
# Create masked composite (image with mask applied)
mask_expanded = cropped_mask.unsqueeze(-1).repeat(1, 1, 1, channels)
masked_composite = cropped_image * mask_expanded
# Create bbox data bundle
bbox_data = {
"x": bbox_x,
"y": bbox_y,
"width": bbox_width,
"height": bbox_height,
"original_width": width,
"original_height": height
}
# Info string
info = f"Crop: {bbox_width}×{bbox_height} at ({bbox_x},{bbox_y}) | Orig: {width}×{height}"
print(f"✓ Outputs: Crop {bbox_width}×{bbox_height}")
print("="*60 + "\n")
return (cropped_image, cropped_mask, masked_composite, bbox_data, info)
class SimpleInpaintStitch:
"""
SIMPLIFIED INPAINTING - STITCH
One-click stitch using bundled bbox data from SimpleInpaintCrop
Perfect for streamlined inpainting workflows
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"original_image": ("IMAGE",),
"processed_crop": ("IMAGE",),
"bbox_data": ("BBOX_DATA",),
"blend_mode": (["replace", "blend"], {
"default": "replace",
"tooltip": "Replace: hard edge, Blend: soft edge"
}),
"feather": ("INT", {
"default": 8,
"min": 0,
"max": 100,
"step": 1,
"display": "number",
"tooltip": "Edge feathering in pixels (blend mode only)"
}),
},
"optional": {
"blend_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("result",)
FUNCTION = "stitch_inpaint"
CATEGORY = "Texture Alchemist/Inpainting/Simple"
def stitch_inpaint(self, original_image, processed_crop, bbox_data, blend_mode, feather, blend_mask=None):
"""
Stitch processed crop back onto original using bundled bbox data
"""
print("\n" + "="*60)
print("Simple Inpaint Stitch")
print("="*60)
# Extract bbox data from bundle
bbox_x = bbox_data["x"]
bbox_y = bbox_data["y"]
bbox_width = bbox_data["width"]
bbox_height = bbox_data["height"]
print(f"Original: {original_image.shape}")
print(f"Processed crop: {processed_crop.shape}")
print(f"BBox: ({bbox_x}, {bbox_y}) {bbox_width}×{bbox_height}")
print(f"Mode: {blend_mode}")
batch, height, width, channels = original_image.shape
# Create result as copy of original
result = original_image.clone()
# Resize processed crop if needed
crop_batch, crop_h, crop_w, crop_c = processed_crop.shape
# Handle channel mismatch (e.g., RGB vs RGBA)
if crop_c != channels:
print(f" Channel mismatch detected: crop={crop_c}, original={channels}")
if crop_c == 3 and channels == 4:
# Crop is RGB, original is RGBA - add alpha channel to crop
alpha_channel = torch.ones((crop_batch, crop_h, crop_w, 1),
device=processed_crop.device,
dtype=processed_crop.dtype)
processed_crop = torch.cat([processed_crop, alpha_channel], dim=-1)
crop_c = 4
print(f" Added alpha channel to crop")
elif crop_c == 4 and channels == 3:
# Crop is RGBA, original is RGB - drop alpha channel
processed_crop = processed_crop[:, :, :, :3]
crop_c = 3
print(f" Dropped alpha channel from crop")
if (crop_h, crop_w) != (bbox_height, bbox_width):
print(f" Resizing crop: {crop_w}×{crop_h} → {bbox_width}×{bbox_height}")
processed_crop = F.interpolate(
processed_crop.permute(0, 3, 1, 2),
size=(bbox_height, bbox_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
# Handle negative bbox coordinates (from padding)
# Calculate the visible region
crop_x_start = max(0, -bbox_x) # Offset into crop if bbox_x is negative
crop_y_start = max(0, -bbox_y) # Offset into crop if bbox_y is negative
paste_x = max(0, bbox_x) # Where to paste in original (0 if bbox_x negative)
paste_y = max(0, bbox_y) # Where to paste in original (0 if bbox_y negative)
# Calculate the actual region that fits
paste_x_end = min(paste_x + (bbox_width - crop_x_start), width)
paste_y_end = min(paste_y + (bbox_height - crop_y_start), height)
actual_width = paste_x_end - paste_x
actual_height = paste_y_end - paste_y
# Extract the visible portion of the crop
crop_x_end = crop_x_start + actual_width
crop_y_end = crop_y_start + actual_height
if crop_x_start > 0 or crop_y_start > 0 or crop_x_end < bbox_width or crop_y_end < bbox_height:
print(f" Cropping to visible region: crop[{crop_y_start}:{crop_y_end}, {crop_x_start}:{crop_x_end}] → paste[{paste_y}:{paste_y_end}, {paste_x}:{paste_x_end}]")
processed_crop = processed_crop[:, crop_y_start:crop_y_end, crop_x_start:crop_x_end, :]
if blend_mode == "replace":
# Hard composite
result[:, paste_y:paste_y_end, paste_x:paste_x_end, :] = processed_crop
print("✓ Hard composite")
else: # blend
# Create blend mask
if blend_mask is not None:
# Use provided mask
print(" Using custom blend mask")
if len(blend_mask.shape) == 3:
mask_2d = blend_mask[0]
else:
mask_2d = blend_mask[0, :, :, 0]
# Extract the same region from the mask as we did from the crop
if mask_2d.shape[0] == bbox_height and mask_2d.shape[1] == bbox_width:
# Mask matches original bbox size, extract the visible portion
mask_2d = mask_2d[crop_y_start:crop_y_end, crop_x_start:crop_x_end]
blend_alpha = mask_2d
print(f" Extracted mask region: {blend_alpha.shape}")
elif mask_2d.shape != (actual_height, actual_width):
# Resize to actual dimensions
mask_reshaped = blend_mask.unsqueeze(1) if len(blend_mask.shape) == 3 else blend_mask.permute(0, 3, 1, 2)
mask_resized = F.interpolate(
mask_reshaped,
size=(actual_height, actual_width),
mode='bilinear',
align_corners=False
)
blend_alpha = mask_resized[0, 0] if len(blend_mask.shape) == 3 else mask_resized[0, 0, :, :]
print(f" Resized mask: {blend_alpha.shape}")
else:
blend_alpha = mask_2d[:actual_height, :actual_width]
elif feather > 0:
# Create feathered edge mask
blend_alpha = torch.ones((actual_height, actual_width),
device=original_image.device,
dtype=original_image.dtype)
feather_pixels = min(feather, actual_width // 2, actual_height // 2)
if feather_pixels > 0:
for i in range(feather_pixels):
alpha_value = i / feather_pixels
# Feather all edges
blend_alpha[i, :] = alpha_value
blend_alpha[actual_height - 1 - i, :] = alpha_value
blend_alpha[:, i] = torch.minimum(blend_alpha[:, i],
torch.tensor(alpha_value, device=blend_alpha.device))
blend_alpha[:, actual_width - 1 - i] = torch.minimum(blend_alpha[:, actual_width - 1 - i],
torch.tensor(alpha_value, device=blend_alpha.device))
print(f"✓ Feathered blend ({feather_pixels}px)")
else:
# No feathering
blend_alpha = torch.ones((actual_height, actual_width),
device=original_image.device,
dtype=original_image.dtype)
print("✓ Soft blend (no feather)")
# Apply blend
blend_alpha_expanded = blend_alpha.unsqueeze(0).unsqueeze(-1).repeat(batch, 1, 1, channels)
original_region = result[:, paste_y:paste_y_end, paste_x:paste_x_end, :]
result[:, paste_y:paste_y_end, paste_x:paste_x_end, :] = (
processed_crop * blend_alpha_expanded +
original_region * (1 - blend_alpha_expanded)
)
print(f"✓ Result: {result.shape}")
print("="*60 + "\n")
return (result,)
class QwenImagePrep:
"""
Prepare images for Qwen vision encoders
Fixes dimension/shape errors by resizing to compatible resolutions
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"resolution": (["224x224", "280x280", "336x336", "448x448", "560x560", "672x672", "custom"], {
"default": "448x448",
"tooltip": "Target resolution (must be multiple of 14 for Qwen)"
}),
"resize_mode": (["stretch", "crop", "pad"], {
"default": "crop",
"tooltip": "How to fit image: stretch (distort), crop (maintain aspect), pad (add borders)"
}),
"interpolation": (["bilinear", "bicubic", "lanczos", "nearest"], {
"default": "bicubic",
"tooltip": "Resampling method"
}),
},
"optional": {
"custom_width": ("INT", {
"default": 448,
"min": 14,
"max": 4096,
"step": 14,
"display": "number",
"tooltip": "Custom width (must be multiple of 14)"
}),
"custom_height": ("INT", {
"default": 448,
"min": 14,
"max": 4096,
"step": 14,
"display": "number",
"tooltip": "Custom height (must be multiple of 14)"
}),
}
}
RETURN_TYPES = ("IMAGE", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "width", "height", "info")
FUNCTION = "prep_image"
CATEGORY = "Texture Alchemist/Texture"
def prep_image(self, image, resolution, resize_mode, interpolation,
custom_width=448, custom_height=448):
"""
Resize and format image for Qwen encoder compatibility
"""
print("\n" + "="*60)
print("Qwen Image Prep")
print("="*60)
print(f"Input shape: {image.shape}")
print(f"Target resolution: {resolution}")
print(f"Resize mode: {resize_mode}")
batch, in_height, in_width, channels = image.shape
# Parse target resolution
if resolution == "custom":
# Ensure custom dimensions are multiples of 14
target_width = (custom_width // 14) * 14
target_height = (custom_height // 14) * 14
if target_width != custom_width or target_height != custom_height:
print(f"⚠️ Adjusted custom size to nearest multiple of 14:")
print(f" {custom_width}×{custom_height} → {target_width}×{target_height}")
else:
# Parse preset resolution
target_width, target_height = map(int, resolution.split('x'))
print(f"Target dimensions: {target_width}×{target_height}")
print(f"Input dimensions: {in_width}×{in_height}")
# Determine interpolation mode
if interpolation == "bicubic":
mode = "bicubic"
elif interpolation == "lanczos":
mode = "bicubic" # PyTorch doesn't have lanczos, use bicubic
elif interpolation == "nearest":
mode = "nearest"
else: # bilinear
mode = "bilinear"
# Process based on resize mode
if resize_mode == "stretch":
# Simple resize - may distort aspect ratio
result = self._resize_stretch(image, target_height, target_width, mode)
elif resize_mode == "crop":
# Resize and center crop - maintains aspect ratio
result = self._resize_crop(image, target_height, target_width, mode)
else: # pad
# Resize and pad - maintains aspect ratio, no cropping
result = self._resize_pad(image, target_height, target_width, mode)
# Ensure proper channel count (RGB)
if result.shape[-1] == 1:
# Grayscale to RGB
result = result.repeat(1, 1, 1, 3)
print(" Converted grayscale to RGB")
elif result.shape[-1] == 4:
# RGBA to RGB (drop alpha)
result = result[:, :, :, :3]
print(" Converted RGBA to RGB")
elif result.shape[-1] != 3:
# Other channel counts - take first 3 or pad
if result.shape[-1] > 3:
result = result[:, :, :, :3]
else:
# Pad to 3 channels
pad_channels = 3 - result.shape[-1]
padding = torch.zeros((batch, target_height, target_width, pad_channels),
device=result.device, dtype=result.dtype)
result = torch.cat([result, padding], dim=-1)
print(f" Adjusted channels to 3")
# Clamp to valid range
result = torch.clamp(result, 0.0, 1.0)
# Create info string
aspect_in = in_width / in_height
aspect_out = target_width / target_height
info_lines = [
f"Input: {in_width}×{in_height}",
f"Output: {target_width}×{target_height}",
f"Mode: {resize_mode}",
f"Aspect: {aspect_in:.2f} → {aspect_out:.2f}"
]
info_string = " | ".join(info_lines)
print(f"\n✓ Output shape: {result.shape}")
print(f" Range: [{result.min():.3f}, {result.max():.3f}]")
print(f" Channels: {result.shape[-1]}")
print(f" Qwen-ready: ✓")
print("="*60 + "\n")
return (result, target_width, target_height, info_string)
def _resize_stretch(self, image, target_h, target_w, mode):
"""Simple stretch resize"""
# Convert to BCHW for interpolate
image_bchw = image.permute(0, 3, 1, 2)
resized = F.interpolate(
image_bchw,
size=(target_h, target_w),
mode=mode,
align_corners=False if mode != "nearest" else None
)
return resized.permute(0, 2, 3, 1)
def _resize_crop(self, image, target_h, target_w, mode):
"""Resize and center crop to maintain aspect ratio"""
batch, in_h, in_w, channels = image.shape
# Calculate scale to fill target while maintaining aspect
scale_h = target_h / in_h
scale_w = target_w / in_w
scale = max(scale_h, scale_w) # Scale to fill
# Resize to intermediate size
inter_h = int(in_h * scale)
inter_w = int(in_w * scale)
image_bchw = image.permute(0, 3, 1, 2)
resized = F.interpolate(
image_bchw,
size=(inter_h, inter_w),
mode=mode,
align_corners=False if mode != "nearest" else None
)
resized = resized.permute(0, 2, 3, 1)
# Center crop
crop_y = (inter_h - target_h) // 2
crop_x = (inter_w - target_w) // 2
cropped = resized[:, crop_y:crop_y+target_h, crop_x:crop_x+target_w, :]
return cropped
def _resize_pad(self, image, target_h, target_w, mode):
"""Resize and pad to maintain aspect ratio"""
batch, in_h, in_w, channels = image.shape
# Calculate scale to fit within target
scale_h = target_h / in_h
scale_w = target_w / in_w
scale = min(scale_h, scale_w) # Scale to fit
# Resize to intermediate size
inter_h = int(in_h * scale)
inter_w = int(in_w * scale)
image_bchw = image.permute(0, 3, 1, 2)
resized = F.interpolate(
image_bchw,
size=(inter_h, inter_w),
mode=mode,
align_corners=False if mode != "nearest" else None
)
resized = resized.permute(0, 2, 3, 1)
# Create padded image (black borders)
result = torch.zeros((batch, target_h, target_w, channels),
device=image.device, dtype=image.dtype)
# Center the resized image
pad_y = (target_h - inter_h) // 2
pad_x = (target_w - inter_w) // 2
result[:, pad_y:pad_y+inter_h, pad_x:pad_x+inter_w, :] = resized
return result
class CropToMaskWithPadding:
"""
Crop image to mask bounds and expand canvas with padding
Padding adds new pixels (expands canvas), not just includes more source
Option to pad to square for uniform dimensions
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"padding": ("INT", {
"default": 50,
"min": 0,
"max": 500,
"step": 1,
"display": "number",
"tooltip": "Padding in pixels (expands canvas)"
}),
"pad_to_square": ("BOOLEAN", {
"default": False,
"tooltip": "Expand to square canvas (uses longest dimension)"
}),
"padding_color": (["black", "white", "mirror", "edge_extend", "from_crop", "use_image", "average", "custom"], {
"default": "black",
"tooltip": "use_image (with pad to square): crop a square from the full input, centered on the mask—real image pixels, no solid/mirror. Other modes: from_crop/edge/mirror = fill from the tight mask crop; black/white/average/custom = solid/avg"
}),
"custom_color": ("STRING", {
"default": "#000000",
"multiline": False,
"tooltip": "Hex color for custom padding (e.g., #FF5733)"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert mask (swap black/white)"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "INT", "INT", "INT", "BBOX_DATA", "STRING")
RETURN_NAMES = (
"image",
"mask",
"original_width",
"original_height",
"bbox_x",
"bbox_y",
"bbox_width",
"bbox_height",
"bbox_data",
"info",
)
FUNCTION = "crop_and_pad"
CATEGORY = "Texture Alchemist/Texture"
def crop_and_pad(self, image, mask, padding, pad_to_square, padding_color, custom_color, invert_mask):
"""
Crop to mask bounds and expand canvas with padding
"""
print("\n" + "="*60)
print("Crop to Mask with Padding")
print("="*60)
print(f"Image shape: {image.shape}")
print(f"Mask shape: {mask.shape}")
print(f"Padding: {padding}px")
print(f"Pad to square: {pad_to_square}")
print(f"Padding color: {padding_color}")
batch, height, width, channels = image.shape
device = image.device
dtype = image.dtype
# Ensure mask is 2D for this image
if len(mask.shape) == 3: # (B, H, W)
mask_2d = mask[0]
else: # (H, W)
mask_2d = mask
# Resize mask if needed
if mask_2d.shape != (height, width):
mask_reshaped = mask.unsqueeze(0).unsqueeze(0) if len(mask.shape) == 2 else mask.unsqueeze(1)
mask_resized = F.interpolate(
mask_reshaped,
size=(height, width),
mode='bilinear',
align_corners=False
)
mask_2d = mask_resized[0, 0] if len(mask.shape) == 2 else mask_resized[0, 0]
# Invert mask if requested
if invert_mask:
mask_2d = 1.0 - mask_2d
print(f"✓ Mask inverted")
# Find bounding box of mask
print(f"\n🔍 Analyzing mask:")
print(f" Mask shape: {mask_2d.shape}")
print(f" Mask range: [{mask_2d.min():.3f}, {mask_2d.max():.3f}]")
print(f" Pixels > 0.01: {(mask_2d > 0.01).sum().item()}")
print(f" Pixels > 0.1: {(mask_2d > 0.1).sum().item()}")
print(f" Pixels > 0.5: {(mask_2d > 0.5).sum().item()}")
print(f" Pixels > 0.9: {(mask_2d > 0.9).sum().item()}")
# Check edges for scattered pixels
print(f"\n🔍 Edge analysis:")
print(f" Top-left corner (0,0): {mask_2d[0, 0]:.3f}")
print(f" Top-right corner (0,-1): {mask_2d[0, -1]:.3f}")
print(f" Bottom-left corner (-1,0): {mask_2d[-1, 0]:.3f}")
print(f" Bottom-right corner (-1,-1): {mask_2d[-1, -1]:.3f}")
print(f" Top row max: {mask_2d[0, :].max():.3f}")
print(f" Bottom row max: {mask_2d[-1, :].max():.3f}")
print(f" Left column max: {mask_2d[:, 0].max():.3f}")
print(f" Right column max: {mask_2d[:, -1].max():.3f}")
# Use higher threshold to avoid noise
mask_binary = (mask_2d > 0.95).float() # Even higher threshold!
nonzero = torch.nonzero(mask_binary, as_tuple=False)
print(f" Non-zero count: {nonzero.shape[0]}")
if nonzero.shape[0] == 0:
# Empty mask - return original image
print("\n⚠️ Empty mask detected - returning original")
bbox_data = {
"x": 0,
"y": 0,
"width": width,
"height": height,
"original_width": width,
"original_height": height
}
return (image, mask, width, height, 0, 0, width, height, bbox_data, "Empty mask")
# Get bounding box
y_min = nonzero[:, 0].min().item()
y_max = nonzero[:, 0].max().item()
x_min = nonzero[:, 1].min().item()
x_max = nonzero[:, 1].max().item()
# Calculate crop dimensions
crop_width = x_max - x_min + 1
crop_height = y_max - y_min + 1
print(f"\n✓ Mask bounds found:")
print(f" Position: ({x_min}, {y_min})")
print(f" Size: {crop_width} × {crop_height}")
print(f" Original canvas: {width} × {height}")
print(f" Crop reduces canvas by: {((1 - (crop_width * crop_height) / (width * height)) * 100):.1f}%")
# Crop image and mask to bounds
cropped_image = image[:, y_min:y_max+1, x_min:x_max+1, :]
cropped_mask = mask_2d[y_min:y_max+1, x_min:x_max+1]
# If we inverted for detection, invert back for output
if invert_mask:
cropped_mask = 1.0 - cropped_mask
print(f"\n✂️ Cropping:")
print(f" Cropped image: {cropped_image.shape}")
print(f" Cropped mask: {cropped_mask.shape}")
print(f" Expected: [{batch}, {crop_height}, {crop_width}, {channels}]")
# Add padding (expand canvas)
if padding > 0 or pad_to_square:
fill_mode = padding_color
if padding_color == "use_image" and not pad_to_square:
fill_mode = "from_crop"
# Square window from the full input (centered on mask) — no synthetic fill
if padding_color == "use_image" and pad_to_square:
x0, y0, l = _source_window_square(
x_min, y_min, crop_width, crop_height, padding, width, height
)
print(f"\n✓ use_image: square crop from full frame at ({x0},{y0}), size {l}×{l}")
result_image = image[:, y0 : y0 + l, x0 : x0 + l, :]
out_mask2 = mask_2d[y0 : y0 + l, x0 : x0 + l]
if invert_mask:
out_mask2 = 1.0 - out_mask2
result_mask = out_mask2.unsqueeze(0)
final_width = final_height = l
bbox_data = {
"x": x0,
"y": y0,
"width": final_width,
"height": final_height,
"original_width": width,
"original_height": height
}
info_lines = [
f"Original: {width}×{height}",
f"Mask BBox: {crop_width}×{crop_height} at ({x_min},{y_min})",
f"Full-frame square: {l}×{l} at ({x0},{y0})"
]
info_string = " | ".join(info_lines)
print(f"\n✓ Output: {result_image.shape}, bbox ({x0},{y0}) {l}×{l}\n" + "="*60 + "\n")
return (result_image, result_mask, width, height, x0, y0, l, l, bbox_data, info_string)
# Calculate final dimensions
if pad_to_square:
max_dim = max(crop_width, crop_height)
final_width = max_dim + (padding * 2)
final_height = max_dim + (padding * 2)
else:
final_width = crop_width + (padding * 2)
final_height = crop_height + (padding * 2)
print(f"\n✓ Expanding canvas:")
print(f" From: {crop_width} × {crop_height}")
print(f" To: {final_width} × {final_height} (mode={fill_mode})")
# Create padded canvas
if fill_mode == "black":
padded_image = torch.zeros((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
elif fill_mode == "white":
padded_image = torch.ones((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
elif fill_mode == "average":
# Calculate average color of the cropped image
avg_color = cropped_image.mean(dim=[1, 2], keepdim=True) # Average across H,W
padded_image = avg_color.expand(batch, final_height, final_width, channels).clone()
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
print(f"✓ Using average color: R={avg_color[0,0,0,0]:.3f}, G={avg_color[0,0,0,1]:.3f}, B={avg_color[0,0,0,2]:.3f}")
elif padding_color == "custom":
# Parse hex color
hex_color = custom_color.strip().lstrip('#')
if len(hex_color) == 6:
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
# Create color tensor [1, 1, 1, channels]
if channels == 4:
color = torch.tensor([r, g, b, 1.0], device=device, dtype=dtype).view(1, 1, 1, 4)
else:
color = torch.tensor([r, g, b], device=device, dtype=dtype).view(1, 1, 1, 3)
padded_image = color.expand(batch, final_height, final_width, channels).clone()
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
print(f"✓ Using custom color #{hex_color}: R={r:.3f}, G={g:.3f}, B={b:.3f}")
else:
print(f"⚠️ Invalid hex color '{custom_color}', using black")
padded_image = torch.zeros((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
else:
# For mirror and edge_extend, start with zeros
padded_image = torch.zeros((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
# Calculate center position
y_offset = (final_height - crop_height) // 2
x_offset = (final_width - crop_width) // 2
# Place cropped content in center
padded_image[:, y_offset:y_offset+crop_height, x_offset:x_offset+crop_width, :] = cropped_image
padded_mask[y_offset:y_offset+crop_height, x_offset:x_offset+crop_width] = cropped_mask
# Apply padding style
if fill_mode == "mirror":
padded_image = self._apply_mirror_padding(
padded_image, cropped_image,
y_offset, x_offset, crop_height, crop_width
)
elif fill_mode in ("edge_extend", "from_crop"):
padded_image = self._apply_edge_extend(
padded_image, cropped_image,
y_offset, x_offset, crop_height, crop_width
)
result_image = padded_image
result_mask = padded_mask.unsqueeze(0) # Add batch dim
else:
result_image = cropped_image
result_mask = cropped_mask.unsqueeze(0)
final_width = crop_width
final_height = crop_height
# Top-left in original of the *output* canvas for stitch: padded canvas extends
# (x_offset, y_offset) left/above the tight mask crop, so origin is not (x_min, y_min)
if (padding > 0 or pad_to_square) and not (padding_color == "use_image" and pad_to_square):
y_off = (final_height - crop_height) // 2
x_off = (final_width - crop_width) // 2
stitch_x = x_min - x_off
stitch_y = y_min - y_off
print(f"✓ Stitch origin (padded): ({stitch_x}, {stitch_y}) [tight mask: ({x_min}, {y_min})]")
else:
stitch_x = x_min
stitch_y = y_min
# Create bundled bbox data (for SimpleInpaintStitch / InpaintStitcher + bbox)
bbox_data = {
"x": stitch_x,
"y": stitch_y,
"width": final_width,
"height": final_height,
"original_width": width,
"original_height": height
}
# Create info string
info_lines = [
f"Original: {width}×{height}",
f"Mask (tight): {crop_width}×{crop_height} at ({x_min},{y_min})",
f"Stitch: {final_width}×{final_height} at ({stitch_x},{stitch_y})"
]
info_string = " | ".join(info_lines)
print(f"\n✓ Output:")
print(f" Result image: {result_image.shape}")
print(f" Result mask: {result_mask.shape}")
print(f" Original dimensions: {width}×{height}")
print(f" Final dimensions: {final_width}×{final_height}")
print(f" Stitch in original: ({stitch_x}, {stitch_y})")
print(f" Canvas (output): {width}×{height} → {final_width}×{final_height}")
print("="*60 + "\n")
return (result_image, result_mask, width, height, stitch_x, stitch_y, final_width, final_height, bbox_data, info_string)
def _apply_mirror_padding(self, padded, cropped, y_off, x_off, h, w):
"""Mirror edges for padding"""
batch = padded.shape[0]
# Mirror top
if y_off > 0:
mirror_h = min(y_off, h)
padded[:, y_off-mirror_h:y_off, x_off:x_off+w, :] = torch.flip(
cropped[:, :mirror_h, :, :], [1]
)
# Mirror bottom
if y_off + h < padded.shape[1]:
mirror_h = min(padded.shape[1] - (y_off + h), h)
padded[:, y_off+h:y_off+h+mirror_h, x_off:x_off+w, :] = torch.flip(
cropped[:, h-mirror_h:h, :, :], [1]
)
# Mirror left
if x_off > 0:
mirror_w = min(x_off, w)
padded[:, y_off:y_off+h, x_off-mirror_w:x_off, :] = torch.flip(
cropped[:, :, :mirror_w, :], [2]
)
# Mirror right
if x_off + w < padded.shape[2]:
mirror_w = min(padded.shape[2] - (x_off + w), w)
padded[:, y_off:y_off+h, x_off+w:x_off+w+mirror_w, :] = torch.flip(
cropped[:, :, w-mirror_w:w, :], [2]
)
_fill_pad_corners_from_crop(padded, cropped, y_off, x_off, h, w)
return padded
def _apply_edge_extend(self, padded, cropped, y_off, x_off, h, w):
"""Extend edges for padding"""
batch = padded.shape[0]
# Extend top
if y_off > 0:
padded[:, :y_off, x_off:x_off+w, :] = cropped[:, 0:1, :, :].repeat(1, y_off, 1, 1)
# Extend bottom
if y_off + h < padded.shape[1]:
padded[:, y_off+h:, x_off:x_off+w, :] = cropped[:, -1:, :, :].repeat(
1, padded.shape[1] - (y_off + h), 1, 1
)
# Extend left
if x_off > 0:
padded[:, y_off:y_off+h, :x_off, :] = cropped[:, :, 0:1, :].repeat(1, 1, x_off, 1)
# Extend right
if x_off + w < padded.shape[2]:
padded[:, y_off:y_off+h, x_off+w:, :] = cropped[:, :, -1:, :].repeat(
1, 1, padded.shape[2] - (x_off + w), 1
)
_fill_pad_corners_from_crop(padded, cropped, y_off, x_off, h, w)
return padded
class SimpleCropToMaskWithPadding:
"""
SIMPLE CROP TO MASK WITH PADDING
Simplified crop-to-mask with canvas padding
Compatible with SimpleInpaintStitch workflow
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"padding": ("INT", {
"default": 50,
"min": 0,
"max": 500,
"step": 1,
"display": "number",
"tooltip": "Padding in pixels (expands canvas)"
}),
"pad_to_square": ("BOOLEAN", {
"default": False,
"tooltip": "Expand to square canvas (uses longest dimension)"
}),
"padding_color": (["black", "white", "mirror", "edge_extend", "from_crop", "use_image", "average", "custom"], {
"default": "black",
"tooltip": "use_image (with pad to square): square crop from the full input, centered on the mask. Other: from_crop/edge/mirror from tight crop; black/white/average/custom = solid/avg"
}),
"custom_color": ("STRING", {
"default": "#000000",
"multiline": False,
"tooltip": "Hex color for custom padding (e.g., #FF5733)"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert mask (swap black/white)"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "BBOX_DATA", "STRING")
RETURN_NAMES = ("cropped_image", "cropped_mask", "masked_composite", "bbox_data", "info")
FUNCTION = "crop_and_pad"
CATEGORY = "Texture Alchemist/Inpainting/Simple"
def crop_and_pad(self, image, mask, padding, pad_to_square, padding_color, custom_color, invert_mask):
"""
Crop to mask bounds, expand canvas with padding, output simplified
"""
print("\n" + "="*60)
print("Simple Crop to Mask with Padding")
print("="*60)
print(f"Image: {image.shape}")
print(f"Mask: {mask.shape}")
print(f"Padding: {padding}px")
print(f"Pad to square: {pad_to_square}")
print(f"Padding: {padding_color}")
batch, height, width, channels = image.shape
device = image.device
dtype = image.dtype
# Ensure mask is 2D for this image
if len(mask.shape) == 3: # (B, H, W)
mask_2d = mask[0]
else: # (H, W)
mask_2d = mask
# Resize mask if needed
if mask_2d.shape != (height, width):
mask_reshaped = mask.unsqueeze(0).unsqueeze(0) if len(mask.shape) == 2 else mask.unsqueeze(1)
mask_resized = F.interpolate(
mask_reshaped,
size=(height, width),
mode='bilinear',
align_corners=False
)
mask_2d = mask_resized[0, 0] if len(mask.shape) == 2 else mask_resized[0, 0]
# Invert mask if requested
if invert_mask:
mask_2d = 1.0 - mask_2d
print("✓ Mask inverted")
# Find bounding box
mask_binary = (mask_2d > 0.95).float()
nonzero = torch.nonzero(mask_binary, as_tuple=False)
if nonzero.shape[0] == 0:
# Empty mask - return full image
print("⚠️ Empty mask - using full image")
bbox_x, bbox_y = 0, 0
bbox_width, bbox_height = width, height
cropped_image = image
cropped_mask_2d = mask_2d
else:
y_min = nonzero[:, 0].min().item()
y_max = nonzero[:, 0].max().item()
x_min = nonzero[:, 1].min().item()
x_max = nonzero[:, 1].max().item()
# Calculate crop dimensions
crop_width = x_max - x_min + 1
crop_height = y_max - y_min + 1
print(f"✓ Mask bounds: ({x_min}, {y_min}) {crop_width}×{crop_height}")
# Crop image and mask to bounds
cropped_image = image[:, y_min:y_max+1, x_min:x_max+1, :]
cropped_mask_2d = mask_2d[y_min:y_max+1, x_min:x_max+1]
bbox_x = x_min
bbox_y = y_min
bbox_width = crop_width
bbox_height = crop_height
# Add padding (expand canvas)
if padding > 0 or pad_to_square:
fill_mode = padding_color
if padding_color == "use_image" and not pad_to_square:
fill_mode = "from_crop"
if padding_color == "use_image" and pad_to_square:
x0, y0, l = _source_window_square(
bbox_x, bbox_y, bbox_width, bbox_height, padding, width, height
)
print(f"✓ use_image: full-frame square at ({x0},{y0}), {l}×{l}")
result_image = image[:, y0 : y0 + l, x0 : x0 + l, :]
# Same mask basis as tight crop: mask_2d after optional user invert, no second flip
result_mask = mask_2d[y0 : y0 + l, x0 : x0 + l].unsqueeze(0)
final_width = final_height = l
bbox_x, bbox_y = x0, y0
else:
if pad_to_square:
max_dim = max(bbox_width, bbox_height)
final_width = max_dim + (padding * 2)
final_height = max_dim + (padding * 2)
else:
final_width = bbox_width + (padding * 2)
final_height = bbox_height + (padding * 2)
print(f"✓ Expanding: {bbox_width}×{bbox_height} → {final_width}×{final_height} (mode={fill_mode})")
if fill_mode == "black":
padded_image = torch.zeros((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
elif fill_mode == "white":
padded_image = torch.ones((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
elif fill_mode == "average":
avg_color = cropped_image.mean(dim=[1, 2], keepdim=True)
padded_image = avg_color.expand(batch, final_height, final_width, channels).clone()
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
print(f"✓ Using average color: R={avg_color[0,0,0,0]:.3f}, G={avg_color[0,0,0,1]:.3f}, B={avg_color[0,0,0,2]:.3f}")
elif padding_color == "custom":
hex_color = custom_color.strip().lstrip('#')
if len(hex_color) == 6:
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
if channels == 4:
color = torch.tensor([r, g, b, 1.0], device=device, dtype=dtype).view(1, 1, 1, 4)
else:
color = torch.tensor([r, g, b], device=device, dtype=dtype).view(1, 1, 1, 3)
padded_image = color.expand(batch, final_height, final_width, channels).clone()
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
print(f"✓ Using custom color #{hex_color}: R={r:.3f}, G={g:.3f}, B={b:.3f}")
else:
print(f"⚠️ Invalid hex color '{custom_color}', using black")
padded_image = torch.zeros((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
else:
padded_image = torch.zeros((batch, final_height, final_width, channels),
device=device, dtype=dtype)
padded_mask = torch.zeros((final_height, final_width),
device=device, dtype=dtype)
y_offset = (final_height - bbox_height) // 2
x_offset = (final_width - bbox_width) // 2
padded_image[:, y_offset:y_offset+bbox_height, x_offset:x_offset+bbox_width, :] = cropped_image
padded_mask[y_offset:y_offset+bbox_height, x_offset:x_offset+bbox_width] = cropped_mask_2d
if fill_mode == "mirror":
padded_image = self._apply_mirror_padding(
padded_image, cropped_image,
y_offset, x_offset, bbox_height, bbox_width
)
elif fill_mode in ("edge_extend", "from_crop"):
padded_image = self._apply_edge_extend(
padded_image, cropped_image,
y_offset, x_offset, bbox_height, bbox_width
)
result_image = padded_image
result_mask = padded_mask.unsqueeze(0)
bbox_x = bbox_x - x_offset
bbox_y = bbox_y - y_offset
print(f"✓ Adjusted bbox for padding: ({bbox_x}, {bbox_y})")
else:
result_image = cropped_image
result_mask = cropped_mask_2d.unsqueeze(0)
final_width = bbox_width
final_height = bbox_height
# Create masked composite (image with mask applied)
mask_expanded = result_mask.unsqueeze(-1).repeat(1, 1, 1, channels)
masked_composite = result_image * mask_expanded
# Create bbox data bundle (for SimpleInpaintStitch)
bbox_data = {
"x": bbox_x,
"y": bbox_y,
"width": final_width,
"height": final_height,
"original_width": width,
"original_height": height
}
# Info string
info = f"Crop: {final_width}×{final_height} at ({bbox_x},{bbox_y}) | Orig: {width}×{height}"
print(f"✓ Output: {final_width}×{final_height}")
print("="*60 + "\n")
return (result_image, result_mask, masked_composite, bbox_data, info)
def _apply_mirror_padding(self, padded, cropped, y_off, x_off, h, w):
"""Mirror edges for padding"""
batch = padded.shape[0]
# Mirror top
if y_off > 0:
mirror_h = min(y_off, h)
padded[:, y_off-mirror_h:y_off, x_off:x_off+w, :] = torch.flip(
cropped[:, :mirror_h, :, :], [1]
)
# Mirror bottom
if y_off + h < padded.shape[1]:
mirror_h = min(padded.shape[1] - (y_off + h), h)
padded[:, y_off+h:y_off+h+mirror_h, x_off:x_off+w, :] = torch.flip(
cropped[:, h-mirror_h:h, :, :], [1]
)
# Mirror left
if x_off > 0:
mirror_w = min(x_off, w)
padded[:, y_off:y_off+h, x_off-mirror_w:x_off, :] = torch.flip(
cropped[:, :, :mirror_w, :], [2]
)
# Mirror right
if x_off + w < padded.shape[2]:
mirror_w = min(padded.shape[2] - (x_off + w), w)
padded[:, y_off:y_off+h, x_off+w:x_off+w+mirror_w, :] = torch.flip(
cropped[:, :, w-mirror_w:w, :], [2]
)
_fill_pad_corners_from_crop(padded, cropped, y_off, x_off, h, w)
return padded
def _apply_edge_extend(self, padded, cropped, y_off, x_off, h, w):
"""Extend edges for padding"""
batch = padded.shape[0]
# Extend top
if y_off > 0:
padded[:, :y_off, x_off:x_off+w, :] = cropped[:, 0:1, :, :].repeat(1, y_off, 1, 1)
# Extend bottom
if y_off + h < padded.shape[1]:
padded[:, y_off+h:, x_off:x_off+w, :] = cropped[:, -1:, :, :].repeat(
1, padded.shape[1] - (y_off + h), 1, 1
)
# Extend left
if x_off > 0:
padded[:, y_off:y_off+h, :x_off, :] = cropped[:, :, 0:1, :].repeat(1, 1, x_off, 1)
# Extend right
if x_off + w < padded.shape[2]:
padded[:, y_off:y_off+h, x_off+w:, :] = cropped[:, :, -1:, :].repeat(
1, 1, padded.shape[2] - (x_off + w), 1
)
_fill_pad_corners_from_crop(padded, cropped, y_off, x_off, h, w)
return padded
class CropFromBboxData:
"""
Crop an image to the rectangle in bbox_data (x, y, width, height in "original" space).
If the image is not the same size as original_width/height, the box is scaled before clamping
to the current image (useful if you resized the image after the bbox was created).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"bbox_data": ("BBOX_DATA",),
},
"optional": {
"mask": ("MASK",),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "BBOX_DATA", "STRING")
RETURN_NAMES = ("cropped_image", "cropped_mask", "bbox_data", "info")
FUNCTION = "execute"
CATEGORY = "Texture Alchemist/Inpainting"
def execute(self, image, bbox_data, mask=None):
print("\n" + "="*60)
print("Crop from Bbox Data")
print("="*60)
b, h, w, c = image.shape
print(f"Image: {b}×{h}×{w}×{c}")
bw = int(bbox_data.get("width", 0) or 0)
bh = int(bbox_data.get("height", 0) or 0)
bx = int(bbox_data.get("x", 0) or 0)
by = int(bbox_data.get("y", 0) or 0)
orig_w = int(bbox_data.get("original_width", 0) or 0) or w
orig_h = int(bbox_data.get("original_height", 0) or 0) or h
if orig_w < 1:
orig_w = w
if orig_h < 1:
orig_h = h
m_full = self._mask_bhw_batched(mask, b, h, w, image.device, image.dtype)
if bw < 1 or bh < 1:
print("⚠️ Invalid width/height in bbox — using full image")
out = image
m_out = m_full if m_full is not None else torch.ones((b, h, w), device=image.device, dtype=image.dtype)
else:
if (orig_w, orig_h) == (w, h):
x0, y0, x1, y1 = bx, by, bx + bw, by + bh
else:
sx = w / float(orig_w)
sy = h / float(orig_h)
x0 = int(bx * sx)
y0 = int(by * sy)
x1 = int(round((bx + bw) * sx))
y1 = int(round((by + bh) * sy))
print(f" Scaled from {orig_w}×{orig_h} to {w}×{h}: box ({x0},{y0})—({x1},{y1})")
x0, y0 = max(0, x0), max(0, y0)
x1, y1 = min(w, x1), min(h, y1)
if x0 >= x1 or y0 >= y1:
print("⚠️ Bbox out of range after clamp — using full image")
out, m_out = image, m_full
else:
out = image[:, y0:y1, x0:x1, :].contiguous()
m_out = m_full[:, y0:y1, x0:x1] if m_full is not None else torch.ones(
(b, y1 - y0, x1 - x0), device=image.device, dtype=image.dtype
)
if m_out is None:
ch, cx = int(out.shape[1]), int(out.shape[2])
m_out = torch.ones((b, ch, cx), device=image.device, dtype=image.dtype)
ch, cx = int(out.shape[1]), int(out.shape[2])
out_bbox = {
"x": 0,
"y": 0,
"width": cx,
"height": ch,
"original_width": w,
"original_height": h,
}
info = f"Crop: {cx}×{ch} (source image {w}×{h})"
print(f"✓ {info}\n" + "="*60 + "\n")
return (out, m_out, out_bbox, info)
@staticmethod
def _mask_bhw_batched(mask, batch, h, w, device, dtype):
if mask is None:
return None
if len(mask.shape) == 2:
m = mask.unsqueeze(0)
elif len(mask.shape) == 3:
m = mask
elif len(mask.shape) == 4:
m = mask[:, :, :, 0]
else:
m = mask
if m.shape[0] == 1 and batch > 1:
m = m.expand(batch, -1, -1)
if m.shape[1] != h or m.shape[2] != w:
m = F.interpolate(m.unsqueeze(1), size=(h, w), mode="bilinear", align_corners=False).squeeze(1)
if m.shape[0] > batch:
m = m[:batch]
return m.to(device=device, dtype=dtype)
class ScaleImageToReferenceBbox:
"""
Uniform scale from mask bbox vs reference W×H. Default canvas is reference_size:
output is exactly ref W×H with the scaled image centered on the subject (stitch window).
scaled_full keeps the whole scaled frame.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"reference_bbox": ("BBOX_DATA",),
"canvas": (["reference_size", "scaled_full"], {
"default": "reference_size",
"tooltip": "reference_size: output exactly ref W×H (stitch-friendly); scaled frame centered on subject. scaled_full: entire scaled image (W×S, H×S)"
}),
"match_mode": (["larger_side", "smaller_side", "width", "height", "area"], {
"default": "larger_side",
"tooltip": "larger_side=max(w,h ratios)—typical to match undersized edits; smaller_side=min; width/height=one axis; area=sqrt(ref area / src area)"
}),
"padding": ("INT", {
"default": 0,
"min": 0,
"max": 500,
"step": 1,
"display": "number",
"tooltip": "Extra pixels around mask bbox before resize"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert mask before bbox detection"
}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "BBOX_DATA", "STRING")
RETURN_NAMES = ("image", "mask", "bbox_data", "info")
FUNCTION = "execute"
CATEGORY = "Texture Alchemist/Inpainting"
def execute(self, image, mask, reference_bbox, canvas, match_mode, padding, invert_mask):
print("\n" + "="*60)
print("Scale Image to Reference Bbox")
print("="*60)
batch, height, width, channels = image.shape
device, dtype = image.device, image.dtype
tw = int(reference_bbox.get("width", 0) or 0)
th = int(reference_bbox.get("height", 0) or 0)
if tw < 1:
tw = 1
if th < 1:
th = 1
mask_2d = self._mask_to_2d(mask, height, width)
if invert_mask:
mask_2d = 1.0 - mask_2d
mask_binary = (mask_2d > 0.01).float()
nonzero = torch.nonzero(mask_binary, as_tuple=False)
if nonzero.shape[0] == 0:
print("⚠️ Empty mask — scale factor 1.0 (no change)")
s = 1.0
bx, by, bw, bh = 0, 0, width, height
else:
y_min = nonzero[:, 0].min().item()
y_max = nonzero[:, 0].max().item()
x_min = nonzero[:, 1].min().item()
x_max = nonzero[:, 1].max().item()
y_min = max(0, y_min - padding)
y_max = min(height - 1, y_max + padding)
x_min = max(0, x_min - padding)
x_max = min(width - 1, x_max + padding)
bx, by = x_min, y_min
bw = x_max - x_min + 1
bh = y_max - y_min + 1
sw, sh = float(max(1, bw)), float(max(1, bh))
rw, rh = float(tw), float(th)
if match_mode == "larger_side":
s = max(rw / sw, rh / sh)
elif match_mode == "smaller_side":
s = min(rw / sw, rh / sh)
elif match_mode == "width":
s = rw / sw
elif match_mode == "height":
s = rh / sh
else:
s = ((rw * rh) / (sw * sh)) ** 0.5
print(f"Subject bbox {bw}×{bh} at ({bx},{by}) → ref {tw}×{th}, S={s:.4f} ({match_mode})")
w2 = max(1, int(round(width * s)))
h2 = max(1, int(round(height * s)))
img_scaled = F.interpolate(
image.permute(0, 3, 1, 2), size=(h2, w2), mode="bilinear", align_corners=False
).permute(0, 2, 3, 1)
m_bhw = CropFromBboxData._mask_bhw_batched(mask, batch, height, width, device, dtype)
if m_bhw is None:
m_bhw = torch.ones((batch, height, width), device=device, dtype=dtype)
m_scaled = F.interpolate(
m_bhw.unsqueeze(1), size=(h2, w2), mode="bilinear", align_corners=False
).squeeze(1)
sx = w2 / float(max(1, width))
sy = h2 / float(max(1, height))
cx2 = (bx + 0.5 * bw) * sx
cy2 = (by + 0.5 * bh) * sy
if canvas == "reference_size":
img_out, m_out = self._composite_centered(
img_scaled, m_scaled, tw, th, cx2, cy2, device, dtype
)
out_w, out_h = tw, th
print(f" Canvas: reference_size {tw}×{th}, centered on subject ({cx2:.1f},{cy2:.1f})")
else:
img_out, m_out = img_scaled, m_scaled
out_w, out_h = w2, h2
print(f" Canvas: scaled_full {w2}×{h2}")
ow = int(reference_bbox.get("original_width", 0) or 0) or out_w
oh = int(reference_bbox.get("original_height", 0) or 0) or out_h
out_bbox = {
"x": 0,
"y": 0,
"width": out_w,
"height": out_h,
"original_width": ow,
"original_height": oh,
}
sbx = int(round(bx * sx))
sby = int(round(by * sy))
sbw = int(round(bw * sx))
sbh = int(round(bh * sy))
info = (
f"Out {out_w}×{out_h} ({canvas}) | scaled {w2}×{h2} S={s:.4f} | subject~{sbw}×{sbh}@({sbx},{sby}) "
f"| ref {tw}×{th} ({match_mode})"
)
print(f"✓ {info}\n" + "="*60 + "\n")
return (img_out, m_out, out_bbox, info)
@staticmethod
def _composite_centered(img, m, tw, th, cx2, cy2, device, dtype):
"""Place scaled image on a tw×th canvas so (cx2, cy2) lands at canvas center."""
b, h2, w2, c = img.shape
out = torch.zeros((b, th, tw, c), device=device, dtype=dtype)
out_m = torch.zeros((b, th, tw), device=device, dtype=dtype)
ox0 = int(round(tw / 2.0 - cx2))
oy0 = int(round(th / 2.0 - cy2))
y1d, x1d = max(0, oy0), max(0, ox0)
y2d, x2d = min(th, oy0 + h2), min(tw, ox0 + w2)
y1s, x1s = y1d - oy0, x1d - ox0
y2s = y1s + (y2d - y1d)
x2s = x1s + (x2d - x1d)
if y2d > y1d and x2d > x1d:
out[:, y1d:y2d, x1d:x2d, :] = img[:, y1s:y2s, x1s:x2s, :]
out_m[:, y1d:y2d, x1d:x2d] = m[:, y1s:y2s, x1s:x2s]
return out, out_m
@staticmethod
def _mask_to_2d(mask, height, width):
if len(mask.shape) == 2:
mask_2d = mask
elif len(mask.shape) == 3:
mask_2d = mask[0]
else:
mask_2d = mask[0, :, :, 0]
if mask_2d.shape != (height, width):
if len(mask.shape) == 2:
mask_reshaped = mask_2d.unsqueeze(0).unsqueeze(0)
elif len(mask.shape) == 3:
mask_reshaped = mask.unsqueeze(1)
else:
mask_reshaped = mask.permute(0, 3, 1, 2)
mask_resized = F.interpolate(
mask_reshaped, size=(height, width), mode="bilinear", align_corners=False
)
mask_2d = mask_resized[0, 0]
return mask_2d
class MatchSubjectToReferenceImage:
"""
Compare mask bounding boxes on a reference image (e.g. pre–Qwen Edit) vs the
current image (e.g. post-edit, same canvas but smaller subject). Uniformly scale
the current image so the subject footprint matches the reference, then center
on the output canvas (same size as the current image) for stitch / overlay.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"reference_image": ("IMAGE",),
"reference_mask": ("MASK",),
"match_mode": (["larger_side", "smaller_side", "width", "height", "area"], {
"default": "larger_side",
"tooltip": "How to pick S from ref vs current subject bbox (same as Scale Image to Reference Bbox). larger_side: grow subject to match ref (typical after edit shrinks it)"
}),
"padding": ("INT", {
"default": 0,
"min": 0,
"max": 500,
"step": 1,
"display": "number",
"tooltip": "Padding around mask bbox on both images (pixels)"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert both masks before bbox detection"
}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "BBOX_DATA", "STRING")
RETURN_NAMES = ("image", "mask", "bbox_data", "info")
FUNCTION = "execute"
CATEGORY = "Texture Alchemist/Inpainting"
def execute(self, image, mask, reference_image, reference_mask, match_mode, padding, invert_mask):
print("\n" + "="*60)
print("Match Subject to Reference Image")
print("="*60)
batch, h, w, c = image.shape
device, dtype = image.device, image.dtype
if reference_image.shape[1] != h or reference_image.shape[2] != w:
print(
f" Note: reference_image {reference_image.shape[2]}×{reference_image.shape[1]} "
f"≠ current {w}×{h}; subject size uses reference_mask resampled to current canvas"
)
m_src = ScaleImageToReferenceBbox._mask_to_2d(mask, h, w)
m_ref = ScaleImageToReferenceBbox._mask_to_2d(reference_mask, h, w)
if invert_mask:
m_src = 1.0 - m_src
m_ref = 1.0 - m_ref
bs = self._bbox_from_mask(m_src, h, w, padding)
br = self._bbox_from_mask(m_ref, h, w, padding)
if bs is None or br is None:
print("⚠️ Empty mask on reference or current image — no scale change")
s = 1.0
bx_s, by_s, bw_s, bh_s = 0, 0, w, h
bx_r, by_r, bw_r, bh_r = 0, 0, w, h
else:
bx_s, by_s, bw_s, bh_s = bs
bx_r, by_r, bw_r, bh_r = br
sw, sh = float(max(1, bw_s)), float(max(1, bh_s))
rw, rh = float(max(1, bw_r)), float(max(1, bh_r))
if bs is not None and br is not None:
if match_mode == "larger_side":
s = max(rw / sw, rh / sh)
elif match_mode == "smaller_side":
s = min(rw / sw, rh / sh)
elif match_mode == "width":
s = rw / sw
elif match_mode == "height":
s = rh / sh
else:
s = ((rw * rh) / (sw * sh)) ** 0.5
print(f"Ref subject bbox {bw_r}×{bh_r} @({bx_r},{by_r}) | current {bw_s}×{bh_s} @({bx_s},{by_s}) → S={s:.4f} ({match_mode})")
w2 = max(1, int(round(w * s)))
h2 = max(1, int(round(h * s)))
img_scaled = F.interpolate(
image.permute(0, 3, 1, 2), size=(h2, w2), mode="bilinear", align_corners=False
).permute(0, 2, 3, 1)
m_bhw = CropFromBboxData._mask_bhw_batched(mask, batch, h, w, device, dtype)
if m_bhw is None:
m_bhw = torch.ones((batch, h, w), device=device, dtype=dtype)
m_scaled = F.interpolate(
m_bhw.unsqueeze(1), size=(h2, w2), mode="bilinear", align_corners=False
).squeeze(1)
sx = w2 / float(max(1, w))
sy = h2 / float(max(1, h))
cx2 = (bx_s + 0.5 * bw_s) * sx
cy2 = (by_s + 0.5 * bh_s) * sy
img_out, m_out = ScaleImageToReferenceBbox._composite_centered(
img_scaled, m_scaled, w, h, cx2, cy2, device, dtype
)
out_bbox = {
"x": 0,
"y": 0,
"width": w,
"height": h,
"original_width": w,
"original_height": h,
}
info = (
f"Canvas {w}×{h} | scaled {w2}×{h2} S={s:.4f} | ref subject {bw_r}×{bh_r} vs was {bw_s}×{bh_s} ({match_mode})"
)
print(f"✓ {info}\n" + "="*60 + "\n")
return (img_out, m_out, out_bbox, info)
@staticmethod
def _bbox_from_mask(mask_2d, height, width, padding):
mb = (mask_2d > 0.01).float()
nz = torch.nonzero(mb, as_tuple=False)
if nz.shape[0] == 0:
return None
y_min = nz[:, 0].min().item()
y_max = nz[:, 0].max().item()
x_min = nz[:, 1].min().item()
x_max = nz[:, 1].max().item()
y_min = max(0, y_min - padding)
y_max = min(height - 1, y_max + padding)
x_min = max(0, x_min - padding)
x_max = min(width - 1, x_max + padding)
bw = x_max - x_min + 1
bh = y_max - y_min + 1
return x_min, y_min, bw, bh
# Node registration
class BBoxReposition:
"""
Reposition an image on a base canvas using bbox data
Supports 9 anchor positions (corners, edges, center)
Outputs repositioned image with updated bbox data
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"base_image": ("IMAGE",),
"bbox_data": ("BBOX_DATA",),
"position": (["top_left", "top_center", "top_right",
"center_left", "center", "center_right",
"bottom_left", "bottom_center", "bottom_right"], {
"default": "center",
"tooltip": "Where to position the image on the base canvas"
}),
"blend_mode": (["replace", "blend"], {
"default": "replace",
"tooltip": "Replace or alpha blend with base"
}),
}
}
RETURN_TYPES = ("IMAGE", "BBOX_DATA", "STRING")
RETURN_NAMES = ("image", "bbox_data", "info")
FUNCTION = "reposition"
CATEGORY = "Texture Alchemist/Inpainting"
def reposition(self, image, base_image, bbox_data, position, blend_mode):
"""
Reposition image on base canvas according to anchor position
"""
print("\n" + "="*60)
print("BBox Reposition")
print("="*60)
print(f"Image: {image.shape}")
print(f"Base: {base_image.shape}")
print(f"Position: {position}")
print(f"BBox Data: {bbox_data}")
batch, img_h, img_w, channels = image.shape
base_batch, base_h, base_w, base_channels = base_image.shape
# Match channels if needed
if channels != base_channels:
print(f" Channel mismatch: image={channels}, base={base_channels}")
if channels == 3 and base_channels == 4:
# Add alpha to image
alpha = torch.ones((batch, img_h, img_w, 1), device=image.device, dtype=image.dtype)
image = torch.cat([image, alpha], dim=-1)
channels = 4
print(" Added alpha channel to image")
elif channels == 4 and base_channels == 3:
# Drop alpha from image
image = image[:, :, :, :3]
channels = 3
print(" Dropped alpha channel from image")
# Calculate position on base canvas
if position == "top_left":
new_x, new_y = 0, 0
elif position == "top_center":
new_x, new_y = (base_w - img_w) // 2, 0
elif position == "top_right":
new_x, new_y = base_w - img_w, 0
elif position == "center_left":
new_x, new_y = 0, (base_h - img_h) // 2
elif position == "center":
new_x, new_y = (base_w - img_w) // 2, (base_h - img_h) // 2
elif position == "center_right":
new_x, new_y = base_w - img_w, (base_h - img_h) // 2
elif position == "bottom_left":
new_x, new_y = 0, base_h - img_h
elif position == "bottom_center":
new_x, new_y = (base_w - img_w) // 2, base_h - img_h
elif position == "bottom_right":
new_x, new_y = base_w - img_w, base_h - img_h
# Clamp to boundaries
new_x = max(0, min(new_x, base_w - 1))
new_y = max(0, min(new_y, base_h - 1))
# Calculate actual paste region
paste_w = min(img_w, base_w - new_x)
paste_h = min(img_h, base_h - new_y)
print(f"✓ New position: ({new_x}, {new_y})")
print(f" Paste region: {paste_w}×{paste_h}")
# Create result (copy of base)
result = base_image.clone()
# Paste image
if blend_mode == "replace":
result[:, new_y:new_y+paste_h, new_x:new_x+paste_w, :] = image[:, :paste_h, :paste_w, :]
else: # blend
# Simple alpha blend
result[:, new_y:new_y+paste_h, new_x:new_x+paste_w, :] = (
result[:, new_y:new_y+paste_h, new_x:new_x+paste_w, :] * 0.5 +
image[:, :paste_h, :paste_w, :] * 0.5
)
# Create updated bbox data
new_bbox_data = {
"x": new_x,
"y": new_y,
"width": img_w,
"height": img_h,
"original_width": base_w,
"original_height": base_h
}
info = f"Repositioned to {position}: ({new_x}, {new_y}) on {base_w}×{base_h} canvas"
print(f"✓ Updated BBox: {new_bbox_data}")
print("="*60 + "\n")
return (result, new_bbox_data, info)
class PatchUpscale:
"""
Upscale a cropped patch to target megapixels for high-quality processing
Outputs scale data for PatchFit to restore original dimensions
Perfect for inpainting workflows: crop → upscale → process → downscale → stitch
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"target_megapixels": ("FLOAT", {
"default": 2.0,
"min": 0.5,
"max": 16.0,
"step": 0.1,
"display": "number",
"tooltip": "Target resolution in megapixels (e.g., 2.0 = 2MP)"
}),
"max_dimension": ("INT", {
"default": 2048,
"min": 512,
"max": 8192,
"step": 64,
"tooltip": "Maximum width/height (prevents extreme upscaling)"
}),
"upscale_method": (["bicubic", "bilinear", "lanczos", "nearest"], {
"default": "bicubic",
"tooltip": "Interpolation method for upscaling"
}),
},
"optional": {
"mask": ("MASK", {
"tooltip": "Optional mask to upscale with image"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "PATCH_SCALE_DATA", "STRING")
RETURN_NAMES = ("upscaled_image", "upscaled_mask", "scale_data", "info")
FUNCTION = "upscale_patch"
CATEGORY = "Texture Alchemist/Inpainting"
def upscale_patch(self, image, target_megapixels, max_dimension, upscale_method, mask=None):
"""
Upscale patch to target megapixels while preserving aspect ratio
"""
print("\n" + "="*60)
print("Patch Upscale")
print("="*60)
batch, height, width, channels = image.shape
original_mp = (width * height) / 1_000_000
aspect_ratio = width / height
print(f"Original: {width}×{height} ({original_mp:.2f}MP)")
print(f"Aspect ratio: {aspect_ratio:.3f}")
print(f"Target: {target_megapixels}MP")
# Calculate target dimensions
# total_pixels = target_megapixels * 1_000_000
# width * height = total_pixels
# width / height = aspect_ratio
# width = aspect_ratio * height
# (aspect_ratio * height) * height = total_pixels
# height^2 = total_pixels / aspect_ratio
target_pixels = target_megapixels * 1_000_000
new_height = int((target_pixels / aspect_ratio) ** 0.5)
new_width = int(new_height * aspect_ratio)
# Clamp to max dimension
if new_width > max_dimension or new_height > max_dimension:
if new_width > new_height:
scale = max_dimension / new_width
else:
scale = max_dimension / new_height
new_width = int(new_width * scale)
new_height = int(new_height * scale)
print(f" Clamped to max dimension: {max_dimension}")
# Don't downscale (only upscale or keep same)
if new_width < width or new_height < height:
new_width = width
new_height = height
print(f" Already at or above target MP - keeping original size")
final_mp = (new_width * new_height) / 1_000_000
scale_factor = new_width / width
print(f"✓ New dimensions: {new_width}×{new_height} ({final_mp:.2f}MP)")
print(f" Scale factor: {scale_factor:.3f}x")
# Perform upscale
if new_width != width or new_height != height:
# Map method names to torch interpolation modes
mode_map = {
"bicubic": "bicubic",
"bilinear": "bilinear",
"lanczos": "bicubic", # torch doesn't have lanczos, use bicubic
"nearest": "nearest"
}
mode = mode_map.get(upscale_method, "bicubic")
upscaled = F.interpolate(
image.permute(0, 3, 1, 2),
size=(new_height, new_width),
mode=mode,
align_corners=False if mode != "nearest" else None
).permute(0, 2, 3, 1)
print(f" Method: {upscale_method}")
else:
upscaled = image
print(f" No upscaling needed")
# Upscale mask if provided
if mask is not None:
print(f" Upscaling mask: {mask.shape}")
# Ensure mask is in correct format
if len(mask.shape) == 3: # (B, H, W)
mask_to_scale = mask.unsqueeze(1) # (B, 1, H, W)
elif len(mask.shape) == 4: # (B, H, W, C)
mask_to_scale = mask.permute(0, 3, 1, 2) # (B, C, H, W)
else:
mask_to_scale = mask
if new_width != width or new_height != height:
upscaled_mask = F.interpolate(
mask_to_scale,
size=(new_height, new_width),
mode='bilinear', # Always use bilinear for masks
align_corners=False
)
else:
upscaled_mask = mask_to_scale
# Convert back to (B, H, W) format
if len(mask.shape) == 3:
upscaled_mask = upscaled_mask.squeeze(1)
elif len(mask.shape) == 4:
upscaled_mask = upscaled_mask.permute(0, 2, 3, 1)
print(f" Upscaled mask: {upscaled_mask.shape}")
else:
# Create empty mask if none provided
upscaled_mask = torch.zeros((batch, new_height, new_width),
device=image.device, dtype=image.dtype)
print(f" No mask provided")
# Create scale data for PatchFit
scale_data = {
"original_width": width,
"original_height": height,
"upscaled_width": new_width,
"upscaled_height": new_height,
"scale_factor": scale_factor,
"target_megapixels": target_megapixels,
"actual_megapixels": final_mp
}
info = f"Upscaled {width}×{height} → {new_width}×{new_height} ({scale_factor:.2f}x, {final_mp:.2f}MP)"
print(f"✓ Scale data saved for PatchFit")
print("="*60 + "\n")
return (upscaled, upscaled_mask, scale_data, info)
class PatchFit:
"""
Downscale processed patch back to original dimensions
Uses scale data from PatchUpscale to restore exact original size
Completes the workflow: crop → upscale → process → fit → stitch
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"scale_data": ("PATCH_SCALE_DATA",),
"downscale_method": (["bicubic", "bilinear", "lanczos", "area"], {
"default": "area",
"tooltip": "Interpolation method for downscaling (area is best for downscaling)"
}),
},
"optional": {
"mask": ("MASK", {
"tooltip": "Optional mask to downscale with image"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("fitted_image", "fitted_mask", "info")
FUNCTION = "fit_patch"
CATEGORY = "Texture Alchemist/Inpainting"
def fit_patch(self, image, scale_data, downscale_method, mask=None):
"""
Downscale processed patch back to original dimensions
"""
print("\n" + "="*60)
print("Patch Fit")
print("="*60)
batch, height, width, channels = image.shape
target_width = scale_data["original_width"]
target_height = scale_data["original_height"]
original_scale = scale_data["scale_factor"]
print(f"Current: {width}×{height}")
print(f"Target: {target_width}×{target_height}")
print(f"Original scale factor: {original_scale:.3f}x")
# Check if dimensions match expected upscaled size
expected_width = scale_data["upscaled_width"]
expected_height = scale_data["upscaled_height"]
if width != expected_width or height != expected_height:
print(f"⚠️ Warning: Image size doesn't match expected upscaled size")
print(f" Expected: {expected_width}×{expected_height}")
print(f" Actual: {width}×{height}")
print(f" Continuing with downscale anyway...")
# Perform downscale
if width != target_width or height != target_height:
# Map method names to torch interpolation modes
mode_map = {
"bicubic": "bicubic",
"bilinear": "bilinear",
"lanczos": "bicubic", # torch doesn't have lanczos, use bicubic
"area": "area",
"nearest": "nearest"
}
mode = mode_map.get(downscale_method, "area")
fitted = F.interpolate(
image.permute(0, 3, 1, 2),
size=(target_height, target_width),
mode=mode,
align_corners=False if mode not in ["nearest", "area"] else None
).permute(0, 2, 3, 1)
actual_scale = target_width / width
print(f"✓ Downscaled: {width}×{height} → {target_width}×{target_height}")
print(f" Scale factor: {actual_scale:.3f}x")
print(f" Method: {downscale_method}")
else:
fitted = image
print(f"✓ Already at target size - no downscaling needed")
# Downscale mask if provided
if mask is not None:
print(f" Downscaling mask: {mask.shape}")
# Ensure mask is in correct format
if len(mask.shape) == 3: # (B, H, W)
mask_to_scale = mask.unsqueeze(1) # (B, 1, H, W)
elif len(mask.shape) == 4: # (B, H, W, C)
mask_to_scale = mask.permute(0, 3, 1, 2) # (B, C, H, W)
else:
mask_to_scale = mask
if width != target_width or height != target_height:
fitted_mask = F.interpolate(
mask_to_scale,
size=(target_height, target_width),
mode='bilinear', # Always use bilinear for masks
align_corners=False
)
else:
fitted_mask = mask_to_scale
# Convert back to original format
if len(mask.shape) == 3:
fitted_mask = fitted_mask.squeeze(1)
elif len(mask.shape) == 4:
fitted_mask = fitted_mask.permute(0, 2, 3, 1)
print(f" Downscaled mask: {fitted_mask.shape}")
else:
# Create empty mask if none provided
fitted_mask = torch.zeros((batch, target_height, target_width),
device=image.device, dtype=image.dtype)
print(f" No mask provided")
info = f"Fitted {width}×{height} → {target_width}×{target_height} (ready for stitching)"
print(f"✓ Ready for stitching back into composition")
print("="*60 + "\n")
return (fitted, fitted_mask, info)
class BBoxEditor:
"""
Manually edit/override bbox data values
Useful for fine-tuning or correcting bbox coordinates
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"bbox_data": ("BBOX_DATA",),
"override_x": ("INT", {
"default": -1,
"min": -1,
"max": 10000,
"tooltip": "Override X position (-1 = keep original)"
}),
"override_y": ("INT", {
"default": -1,
"min": -1,
"max": 10000,
"tooltip": "Override Y position (-1 = keep original)"
}),
"override_width": ("INT", {
"default": -1,
"min": -1,
"max": 10000,
"tooltip": "Override width (-1 = keep original)"
}),
"override_height": ("INT", {
"default": -1,
"min": -1,
"max": 10000,
"tooltip": "Override height (-1 = keep original)"
}),
"override_original_width": ("INT", {
"default": -1,
"min": -1,
"max": 10000,
"tooltip": "Override original canvas width (-1 = keep original)"
}),
"override_original_height": ("INT", {
"default": -1,
"min": -1,
"max": 10000,
"tooltip": "Override original canvas height (-1 = keep original)"
}),
}
}
RETURN_TYPES = ("BBOX_DATA", "STRING")
RETURN_NAMES = ("bbox_data", "info")
FUNCTION = "edit_bbox"
CATEGORY = "Texture Alchemist/Inpainting"
def edit_bbox(self, bbox_data, override_x, override_y, override_width, override_height,
override_original_width, override_original_height):
"""
Override bbox data values with user-specified values
"""
print("\n" + "="*60)
print("BBox Editor")
print("="*60)
print(f"Original BBox: {bbox_data}")
# Create new bbox data with overrides
new_bbox_data = {
"x": bbox_data.get("x", 0) if override_x == -1 else override_x,
"y": bbox_data.get("y", 0) if override_y == -1 else override_y,
"width": bbox_data.get("width", 0) if override_width == -1 else override_width,
"height": bbox_data.get("height", 0) if override_height == -1 else override_height,
"original_width": bbox_data.get("original_width", 0) if override_original_width == -1 else override_original_width,
"original_height": bbox_data.get("original_height", 0) if override_original_height == -1 else override_original_height
}
# Build info string showing what changed
changes = []
if override_x != -1:
changes.append(f"x: {bbox_data.get('x')} → {new_bbox_data['x']}")
if override_y != -1:
changes.append(f"y: {bbox_data.get('y')} → {new_bbox_data['y']}")
if override_width != -1:
changes.append(f"width: {bbox_data.get('width')} → {new_bbox_data['width']}")
if override_height != -1:
changes.append(f"height: {bbox_data.get('height')} → {new_bbox_data['height']}")
if override_original_width != -1:
changes.append(f"orig_w: {bbox_data.get('original_width')} → {new_bbox_data['original_width']}")
if override_original_height != -1:
changes.append(f"orig_h: {bbox_data.get('original_height')} → {new_bbox_data['original_height']}")
if changes:
info = "Modified: " + ", ".join(changes)
print(f"✓ Changes: {len(changes)}")
for change in changes:
print(f" - {change}")
else:
info = "No changes (all overrides set to -1)"
print(" No changes made")
print(f"✓ New BBox: {new_bbox_data}")
print("="*60 + "\n")
return (new_bbox_data, info)
NODE_CLASS_MAPPINGS = {
"SeamlessTiling": SeamlessTiling,
"TextureScaler": TextureScaler,
"TriplanarProjection": TriplanarProjection,
"TextureOffset": TextureOffset,
"TextureTiler": TextureTiler,
"SmartTextureResizer": SmartTextureResizer,
"SquareMaker": SquareMaker,
"TextureEqualizer": TextureEqualizer,
"UpscaleCalculator": UpscaleCalculator,
"UpscaleToResolution": UpscaleToResolution,
"PaddingCalculator": PaddingCalculator,
"InpaintCropExtractor": InpaintCropExtractor,
"InpaintStitcher": InpaintStitcher,
"SimpleInpaintCrop": SimpleInpaintCrop,
"SimpleInpaintStitch": SimpleInpaintStitch,
"QwenImagePrep": QwenImagePrep,
"CropToMaskWithPadding": CropToMaskWithPadding,
"SimpleCropToMaskWithPadding": SimpleCropToMaskWithPadding,
"CropFromBboxData": CropFromBboxData,
"ScaleImageToReferenceBbox": ScaleImageToReferenceBbox,
"MatchSubjectToReferenceImage": MatchSubjectToReferenceImage,
"BBoxReposition": BBoxReposition,
"BBoxEditor": BBoxEditor,
"PatchUpscale": PatchUpscale,
"PatchFit": PatchFit,
}
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)",
"PaddingCalculator": "Padding Calculator",
"InpaintCropExtractor": "Inpaint Crop Extractor (Advanced)",
"InpaintStitcher": "Inpaint Stitcher (Advanced)",
"SimpleInpaintCrop": "Simple Inpaint Crop ⚡",
"SimpleInpaintStitch": "Simple Inpaint Stitch ⚡",
"QwenImagePrep": "Qwen Image Prep",
"CropToMaskWithPadding": "Crop to Mask (with Padding)",
"SimpleCropToMaskWithPadding": "Simple Crop to Mask (with Padding) ⚡",
"CropFromBboxData": "Crop (from Bbox Data) ⚡",
"ScaleImageToReferenceBbox": "Scale Image to Reference Bbox ⚡",
"MatchSubjectToReferenceImage": "Match Subject to Reference Image ⚡",
"BBoxReposition": "BBox Reposition 📐",
"BBoxEditor": "BBox Editor ✏️",
"PatchUpscale": "Patch Upscale 🔍",
"PatchFit": "Patch Fit 📦",
}