4397 lines
175 KiB
Python
4397 lines
175 KiB
Python
"""
|
||
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 📦",
|
||
}
|
||
|