From c7e8cf22c65c637c3280e008fa2f34434f55014f Mon Sep 17 00:00:00 2001 From: Ashlee Martino-Tarr Date: Thu, 29 Jan 2026 23:12:55 -0800 Subject: [PATCH] New Color Picker , Color Image, Crop to Mask utils --- color_utils.py | 95 +++++++++ texture_utils.py | 490 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 553 insertions(+), 32 deletions(-) diff --git a/color_utils.py b/color_utils.py index 2779655..3549f9a 100644 --- a/color_utils.py +++ b/color_utils.py @@ -632,12 +632,106 @@ class ColorCode: return (hex_out, rgb_out, rgba_out, alpha) +class GetAverageColor: + """ + Calculate the average color of an image and output as hex code + Useful for color picking and padding + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "mask": ("MASK", { + "tooltip": "Optional mask to only average visible regions" + }), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("hex_color",) + FUNCTION = "get_average" + CATEGORY = "Texture Alchemist/Color" + + def get_average(self, image, mask=None): + """ + Calculate average color and return as hex + """ + + print("\n" + "="*60) + print("Get Average Color") + print("="*60) + print(f"Image: {image.shape}") + + batch, height, width, channels = image.shape + + # Use first batch item + img = image[0] # (H, W, C) + + if mask is not None: + print(f"Mask: {mask.shape}") + # Get mask for first batch + if len(mask.shape) == 3: + mask_2d = mask[0] # (H, W) + else: + mask_2d = mask + + # Resize mask if needed + if mask_2d.shape != (height, width): + import torch.nn.functional as F + mask_reshaped = mask_2d.unsqueeze(0).unsqueeze(0) + mask_resized = F.interpolate( + mask_reshaped, + size=(height, width), + mode='bilinear', + align_corners=False + ) + mask_2d = mask_resized[0, 0] + + # Expand mask to match channels + mask_expanded = mask_2d.unsqueeze(-1).expand(-1, -1, channels) + + # Apply mask + masked_img = img * mask_expanded + + # Calculate average only in masked regions + mask_sum = mask_2d.sum() + if mask_sum > 0: + avg_color = masked_img.sum(dim=[0, 1]) / mask_sum + else: + # Empty mask - use full image + avg_color = img.mean(dim=[0, 1]) + print("āš ļø Empty mask - using full image average") + else: + # No mask - average entire image + avg_color = img.mean(dim=[0, 1]) + + # Convert to 0-255 range + r = int(avg_color[0].item() * 255) + g = int(avg_color[1].item() * 255) + b = int(avg_color[2].item() * 255) + + # Format as hex + hex_color = f"#{r:02X}{g:02X}{b:02X}" + + print(f"āœ“ Average color:") + print(f" RGB: ({r}, {g}, {b})") + print(f" Hex: {hex_color}") + print("="*60 + "\n") + + return (hex_color,) + + NODE_CLASS_MAPPINGS = { "ColorRamp": ColorRamp, "SimpleRecolor": SimpleRecolor, "HSVAdjuster": HSVAdjuster, "ColorImage": ColorImage, "ColorCode": ColorCode, + "GetAverageColor": GetAverageColor, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -646,5 +740,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "HSVAdjuster": "HSV Adjuster", "ColorImage": "Color Image šŸŽØ", "ColorCode": "Color Code šŸŽØ", + "GetAverageColor": "Get Average Color šŸŽØ", } diff --git a/texture_utils.py b/texture_utils.py index 02460c9..b5f97ef 100644 --- a/texture_utils.py +++ b/texture_utils.py @@ -1959,6 +1959,24 @@ class InpaintStitcher: # 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( @@ -1968,19 +1986,32 @@ class InpaintStitcher: align_corners=False ).permute(0, 2, 3, 1) - # Clamp bbox to image boundaries - x_end = min(bbox_x + bbox_width, width) - y_end = min(bbox_y + bbox_height, height) - actual_width = x_end - bbox_x - actual_height = y_end - bbox_y + # 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 - if (actual_width, actual_height) != (bbox_width, bbox_height): - print(f" Clamping to image boundaries: {actual_width}Ɨ{actual_height}") - processed_crop = processed_crop[:, :actual_height, :actual_width, :] + 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[:, bbox_y:y_end, bbox_x:x_end, :] = processed_crop + result[:, paste_y:paste_y_end, paste_x:paste_x_end, :] = processed_crop print(f"āœ“ Hard composite applied") else: # blend @@ -1993,8 +2024,14 @@ class InpaintStitcher: else: # (B, H, W, C) mask_2d = blend_mask[0, :, :, 0] - # Resize to crop size - if mask_2d.shape != (actual_height, actual_width): + # 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, @@ -2006,6 +2043,7 @@ class InpaintStitcher: 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: @@ -2042,8 +2080,8 @@ class InpaintStitcher: blend_alpha = blend_alpha.unsqueeze(0).unsqueeze(-1).repeat(batch, 1, 1, channels) # Alpha blend - original_region = result[:, bbox_y:y_end, bbox_x:x_end, :] - result[:, bbox_y:y_end, bbox_x:x_end, :] = ( + 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") @@ -2255,6 +2293,24 @@ class SimpleInpaintStitch: # 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( @@ -2264,19 +2320,32 @@ class SimpleInpaintStitch: align_corners=False ).permute(0, 2, 3, 1) - # Clamp bbox to image boundaries - x_end = min(bbox_x + bbox_width, width) - y_end = min(bbox_y + bbox_height, height) - actual_width = x_end - bbox_x - actual_height = y_end - bbox_y + # 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 - if (actual_width, actual_height) != (bbox_width, bbox_height): - print(f" Clamping to boundaries: {actual_width}Ɨ{actual_height}") - processed_crop = processed_crop[:, :actual_height, :actual_width, :] + 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[:, bbox_y:y_end, bbox_x:x_end, :] = processed_crop + result[:, paste_y:paste_y_end, paste_x:paste_x_end, :] = processed_crop print("āœ“ Hard composite") else: # blend # Create blend mask @@ -2288,8 +2357,14 @@ class SimpleInpaintStitch: else: mask_2d = blend_mask[0, :, :, 0] - # Resize to crop size - if mask_2d.shape != (actual_height, actual_width): + # 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, @@ -2298,6 +2373,7 @@ class SimpleInpaintStitch: 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: @@ -2327,8 +2403,8 @@ class SimpleInpaintStitch: # Apply blend blend_alpha_expanded = blend_alpha.unsqueeze(0).unsqueeze(-1).repeat(batch, 1, 1, channels) - original_region = result[:, bbox_y:y_end, bbox_x:x_end, :] - result[:, bbox_y:y_end, bbox_x:x_end, :] = ( + 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) ) @@ -2584,10 +2660,15 @@ class CropToMaskWithPadding: "default": False, "tooltip": "Expand to square canvas (uses longest dimension)" }), - "padding_color": (["black", "white", "mirror", "edge_extend"], { + "padding_color": (["black", "white", "mirror", "edge_extend", "average", "custom"], { "default": "black", "tooltip": "How to fill padding area" }), + "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)" @@ -2595,12 +2676,12 @@ class CropToMaskWithPadding: } } - RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "INT", "STRING") - RETURN_NAMES = ("image", "mask", "original_width", "original_height", "crop_x", "crop_y", "info") + RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "INT", "BBOX_DATA", "STRING") + RETURN_NAMES = ("image", "mask", "original_width", "original_height", "crop_x", "crop_y", "bbox_data", "info") FUNCTION = "crop_and_pad" CATEGORY = "Texture Alchemist/Texture" - def crop_and_pad(self, image, mask, padding, pad_to_square, padding_color, invert_mask): + 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 """ @@ -2669,7 +2750,15 @@ class CropToMaskWithPadding: if nonzero.shape[0] == 0: # Empty mask - return original image print("\nāš ļø Empty mask detected - returning original") - return (image, mask, width, height, 0, 0, "Empty mask") + bbox_data = { + "x": 0, + "y": 0, + "width": width, + "height": height, + "original_width": width, + "original_height": height + } + return (image, mask, width, height, 0, 0, bbox_data, "Empty mask") # Get bounding box y_min = nonzero[:, 0].min().item() @@ -2726,6 +2815,35 @@ class CropToMaskWithPadding: device=device, dtype=dtype) padded_mask = torch.zeros((final_height, final_width), device=device, dtype=dtype) + elif padding_color == "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), @@ -2761,6 +2879,16 @@ class CropToMaskWithPadding: final_width = crop_width final_height = crop_height + # Create bundled bbox data (for SimpleInpaintStitch compatibility) + bbox_data = { + "x": x_min, + "y": y_min, + "width": final_width, + "height": final_height, + "original_width": width, + "original_height": height + } + # Create info string info_lines = [ f"Original: {width}Ɨ{height}", @@ -2779,7 +2907,303 @@ class CropToMaskWithPadding: print(f" Canvas reduced: {width}Ɨ{height} → {final_width}Ɨ{final_height}") print("="*60 + "\n") - return (result_image, result_mask, width, height, x_min, y_min, info_string) + return (result_image, result_mask, width, height, x_min, y_min, 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] + ) + + 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 + ) + + 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", "average", "custom"], { + "default": "black", + "tooltip": "How to fill padding area" + }), + "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: + # Calculate final dimensions + 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}") + + # Create padded canvas + if padding_color == "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 padding_color == "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 padding_color == "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 (where original content will be placed) + y_offset = (final_height - bbox_height) // 2 + x_offset = (final_width - bbox_width) // 2 + + # Place cropped content in center + 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 + + # Apply padding style + if padding_color == "mirror": + padded_image = self._apply_mirror_padding( + padded_image, cropped_image, + y_offset, x_offset, bbox_height, bbox_width + ) + elif padding_color == "edge_extend": + 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) # Add batch dim + + # CRITICAL: Adjust bbox position to account for padding offset + # The original content is now at (x_offset, y_offset) within the padded crop + # So we need to shift the bbox position back by this offset for proper alignment + 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""" @@ -2861,6 +3285,7 @@ NODE_CLASS_MAPPINGS = { "SimpleInpaintStitch": SimpleInpaintStitch, "QwenImagePrep": QwenImagePrep, "CropToMaskWithPadding": CropToMaskWithPadding, + "SimpleCropToMaskWithPadding": SimpleCropToMaskWithPadding, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -2881,5 +3306,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SimpleInpaintStitch": "Simple Inpaint Stitch ⚔", "QwenImagePrep": "Qwen Image Prep", "CropToMaskWithPadding": "Crop to Mask (with Padding)", + "SimpleCropToMaskWithPadding": "Simple Crop to Mask (with Padding) ⚔", }