diff --git a/WAS_Node_Suite.py b/WAS_Node_Suite.py index 8833fde..b6ce1d9 100644 --- a/WAS_Node_Suite.py +++ b/WAS_Node_Suite.py @@ -11268,8 +11268,10 @@ class WAS_SAM_Image_Mask: return (image, mask, ) +#! BOUNDED IMAGES # IMAGE BOUNDS + class WAS_Image_Bounds: def __init__(self): pass @@ -11288,14 +11290,13 @@ class WAS_Image_Bounds: CATEGORY = "WAS Suite/Image/Bound" def image_bounds(self, image): - if len(image.shape) == 4: - bounds = [(0, img.shape[1] - 1, 0, img.shape[2] - 1) for img in image] - else: - bounds = [(0, image.shape[0] - 1, 0, image.shape[1] - 1)] - return (bounds,) + # Ensure we are working with batches + image = image.unsqueeze(0) if image.dim() == 3 else image + return([(0, img.shape[0]-1 , 0, img.shape[1]-1) for img in image],) # INSET IMAGE BOUNDS + class WAS_Inset_Image_Bounds: def __init__(self): pass @@ -11324,11 +11325,15 @@ class WAS_Inset_Image_Bounds: rmax -= inset_bottom cmin += inset_left cmax -= inset_right + + if rmin > rmax or cmin > cmax: + raise ValueError("Invalid insets provided. Please make sure the insets do not exceed the image bounds.") + inset_bounds.append((rmin, rmax, cmin, cmax)) return (inset_bounds,) +# BOUNDED IMAGE BLEND -# WAS BOUNDED IMAGE BLEND class WAS_Bounded_Image_Blend: def __init__(self): pass @@ -11351,61 +11356,74 @@ class WAS_Bounded_Image_Blend: CATEGORY = "WAS Suite/Image/Bound" def bounded_image_blend(self, target, target_bounds, source, blend_factor, feathering): - # Convert PyTorch tensors to PIL images - target_pil = Image.fromarray((target.squeeze(0).cpu().numpy() * 255).clip(0, 255).astype(np.uint8)) - source_pils = [] - if source.shape[0] > 1: - for source_img in source: - source_pils.append(Image.fromarray((source_img.squeeze(0).cpu().numpy() * 255).astype(np.uint8))) - else: - source_pils.append(Image.fromarray((source.squeeze(0).cpu().numpy() * 255).astype(np.uint8))) + # Ensure we are working with batches + target = target.unsqueeze(0) if target.dim() == 3 else target + source = source.unsqueeze(0) if source.dim() == 3 else source - # Extract the target bounds - rmin, rmax, cmin, cmax = target_bounds + # If number of target images and source images don't match then all source images + # will be applied only to the first target image, otherwise they will be applied + # 1 to 1 + # If the number of target bounds and source images don't match then all sourcess will + # use the first target bounds for scaling and placing the source images, otherwise they + # will be applied 1 to 1 + tgt_len = 1 if len(target) != len(source) else len(source) + bounds_len = 1 if len(target_bounds) != len(source) else len(source) - # Calculate the dimensions of the target bounds - width = cmax - cmin + 1 - height = rmax - rmin + 1 + # Convert target PyTorch tensors to PIL images + tgt_arr = [tensor2pil(tgt) for tgt in target[:tgt_len]] + src_arr = [tensor2pil(src) for src in source] result_tensors = [] - for source_pil in source_pils: + for idx in range(len(src_arr)): + src = src_arr[idx] + # If only one target image, then ensure it is the only one used + if (tgt_len == 1 and idx == 0) or tgt_len > 1: + tgt = tgt_arr[idx] + + # If only one bounds object, no need to extract and calculate more than once. + # Additionally, if only one bounds obuect, then the mask only needs created once + if (bounds_len == 1 and idx == 0) or bounds_len > 1: + # Extract the target bounds + rmin, rmax, cmin, cmax = target_bounds[idx] + + # Calculate the dimensions of the target bounds + height, width = (rmax - rmin + 1, cmax - cmin + 1) + + # Create the feathered mask portion the size of the target bounds + if feathering > 0: + inner_mask = Image.new('L', (width - (2 * feathering), height - (2 * feathering)), 255) + inner_mask = ImageOps.expand(inner_mask, border=feathering, fill=0) + inner_mask = inner_mask.filter(ImageFilter.GaussianBlur(radius=feathering)) + else: + inner_mask = Image.new('L', (width, height), 255) + + # Create a blend mask using the inner_mask and blend factor + inner_mask = inner_mask.point(lambda p: p * blend_factor) + + # Create the blend mask with the same size as the target image + tgt_mask = Image.new('L', tgt.size, 0) + # Paste the feathered mask portion into the blend mask at the target bounds position + tgt_mask.paste(inner_mask, (cmin, rmin)) + # Resize the source image to match the dimensions of the target bounds - source_resized = source_pil.resize((width, height), Image.ANTIALIAS) - - # Create the blend mask with the same size as the target image - blend_mask = Image.new('L', target_pil.size, 0) - - # Create the feathered mask portion the size of the target bounds - if feathering > 0: - inner_mask = Image.new('L', (width - (2 * feathering), height - (2 * feathering)), 255) - inner_mask = ImageOps.expand(inner_mask, border=feathering, fill=0) - inner_mask = inner_mask.filter(ImageFilter.GaussianBlur(radius=feathering)) - else: - inner_mask = Image.new('L', (width, height), 255) - - # Paste the feathered mask portion into the blend mask at the target bounds position - blend_mask.paste(inner_mask, (cmin, rmin)) + src_resized = src.resize((width, height), Image.Resampling.LANCZOS) # Create a blank image with the same size and mode as the target - source_positioned = Image.new(target_pil.mode, target_pil.size) + src_positioned = Image.new(tgt.mode, tgt.size) # Paste the source image onto the blank image using the target bounds - source_positioned.paste(source_resized, (cmin, rmin)) - - # Create a blend mask using the blend_mask and blend factor - blend_mask = blend_mask.point(lambda p: p * blend_factor).convert('L') + src_positioned.paste(src_resized, (cmin, rmin)) # Blend the source and target images using the blend mask - result = Image.composite(source_positioned, target_pil, blend_mask) + result = Image.composite(src_positioned, tgt, tgt_mask) # Convert the result back to a PyTorch tensor - result_tensors.append(torch.from_numpy(np.array(result).astype(np.float32) / 255).unsqueeze(0)) - - result = torch.cat(result_tensors, dim=0) - return (result,) + result_tensors.append(pil2tensor(result)) + return (torch.cat(result_tensors, dim=0),) # BOUNDED IMAGE CROP + class WAS_Bounded_Image_Crop: def __init__(self): pass @@ -11425,14 +11443,30 @@ class WAS_Bounded_Image_Crop: CATEGORY = "WAS Suite/Image/Bound" def bounded_image_crop(self, image, image_bounds): + # Ensure we are working with batches + image = image.unsqueeze(0) if image.dim() == 3 else image + + # If number of images and bounds don't match, then only the first bounds will be used + # to crop the images, otherwise, each bounds will be used for each image 1 to 1 + bounds_len = 1 if len(image_bounds) != len(image) else len(image) + cropped_images = [] - for rmin, rmax, cmin, cmax in image_bounds: - cropped_image = image[:, rmin:rmax + 1, cmin:cmax + 1, :] - cropped_images.append(cropped_image) - return (torch.cat(cropped_images, dim=0),) + for idx in range(len(image)): + # If only one bounds object, no need to extract and calculate more than once. + if (bounds_len == 1 and idx == 0) or bounds_len > 1: + rmin, rmax, cmin, cmax = image_bounds[idx] + + # Check if the provided bounds are valid + if rmin > rmax or cmin > cmax: + raise ValueError("Invalid bounds provided. Please make sure the bounds are within the image dimensions.") + + cropped_images.append(image[idx][rmin:rmax+1, cmin:cmax+1, :]) + + return (torch.stack(cropped_images, dim=0),) -# WAS BOUNDED IMAGE BLEND WITH MASK +# BOUNDED IMAGE BLEND WITH MASK + class WAS_Bounded_Image_Blend_With_Mask: def __init__(self): pass @@ -11456,19 +11490,90 @@ class WAS_Bounded_Image_Blend_With_Mask: CATEGORY = "WAS Suite/Image/Bound" def bounded_image_blend_with_mask(self, target, target_mask, target_bounds, source, blend_factor, feathering): - target_pil = Image.fromarray((target.squeeze(0).cpu().numpy() * 255).clip(0, 255).astype(np.uint8)) - target_mask_pil = Image.fromarray((target_mask.cpu().numpy() * 255).astype(np.uint8), mode='L') - source_pil = Image.fromarray((source.squeeze(0).cpu().numpy() * 255).astype(np.uint8)) - rmin, rmax, cmin, cmax = target_bounds - source_positioned = Image.new(target_pil.mode, target_pil.size) - source_positioned.paste(source_pil, (cmin, rmin)) - blend_mask = target_mask_pil.point(lambda p: p * blend_factor).convert('L') - if feathering > 0: - blend_mask = blend_mask.filter(ImageFilter.GaussianBlur(radius=feathering)) - result = Image.composite(source_positioned, target_pil, blend_mask) - result_tensor = torch.from_numpy(np.array(result).astype(np.float32) / 255).unsqueeze(0) - - return (result_tensor,) + # Ensure we are working with batches + target = target.unsqueeze(0) if target.dim() == 3 else target + source = source.unsqueeze(0) if source.dim() == 3 else source + target_mask = target_mask.unsqueeze(0) if target_mask.dim() == 2 else target_mask + + # If number of target masks and source images don't match, then only the first mask will be used on + # the source images, otherwise, each mask will be used for each source image 1 to 1 + # Simarly, if the number of target images and source images don't match then + # all source images will be applied only to the first target, otherwise they will be applied + # 1 to 1 + tgt_mask_len = 1 if len(target_mask) != len(source) else len(source) + tgt_len = 1 if len(target) != len(source) else len(source) + bounds_len = 1 if len(target_bounds) != len(source) else len(source) + + tgt_arr = [tensor2pil(tgt) for tgt in target[:tgt_len]] + src_arr = [tensor2pil(src) for src in source] + tgt_mask_arr=[] + + # Convert Target Mask(s) to grayscale image format + for m_idx in range(tgt_mask_len): + np_array = np.clip((target_mask[m_idx].cpu().numpy().squeeze() * 255.0), 0, 255) + tgt_mask_arr.append(Image.fromarray((np_array).astype(np.uint8), mode='L')) + + result_tensors = [] + for idx in range(len(src_arr)): + src = src_arr[idx] + # If only one target image, then ensure it is the only one used + if (tgt_len == 1 and idx == 0) or tgt_len > 1: + tgt = tgt_arr[idx] + + # If only one bounds, no need to extract and calculate more than once + if (bounds_len == 1 and idx == 0) or bounds_len > 1: + # Extract the target bounds + rmin, rmax, cmin, cmax = target_bounds[idx] + + # Calculate the dimensions of the target bounds + height, width = (rmax - rmin + 1, cmax - cmin + 1) + + # If only one mask, then ensure that is the only the first is used + if (tgt_mask_len == 1 and idx == 0) or tgt_mask_len > 1: + tgt_mask = tgt_mask_arr[idx] + + # If only one mask and one bounds, then mask only needs to + # be extended once because all targets will be the same size + if (tgt_mask_len == 1 and bounds_len == 1 and idx == 0) or \ + (tgt_mask_len > 1 or bounds_len > 1): + + # This is an imperfect, but easy way to determine if the mask based on the + # target image or source image. If not target, assume source. If neither, + # then it's not going to look right regardless + if (tgt_mask.size != tgt.size): + # Create the blend mask with the same size as the target image + mask_extended_canvas = Image.new('L', tgt.size, 0) + + # Paste the mask portion into the extended mask at the target bounds position + mask_extended_canvas.paste(tgt_mask, (cmin, rmin)) + + tgt_mask = mask_extended_canvas + + # Apply feathering (Gaussian blur) to the blend mask if feather_amount is greater than 0 + if feathering > 0: + tgt_mask = tgt_mask.filter(ImageFilter.GaussianBlur(radius=feathering)) + + # Apply blending factor to the tgt mask now that it has been extended + tgt_mask = tgt_mask.point(lambda p: p * blend_factor) + + # Resize the source image to match the dimensions of the target bounds + src_resized = src.resize((width, height), Image.Resampling.LANCZOS) + + # Create a blank image with the same size and mode as the target + src_positioned = Image.new(tgt.mode, tgt.size) + + # Paste the source image onto the blank image using the target + src_positioned.paste(src_resized, (cmin, rmin)) + + # Blend the source and target images using the blend mask + result = Image.composite(src_positioned, tgt, tgt_mask) + + # Convert the result back to a PyTorch tensor + result_tensors.append(pil2tensor(result)) + + return (torch.cat(result_tensors, dim=0),) + +# BOUNDED IMAGE CROP WITH MASK class WAS_Bounded_Image_Crop_With_Mask: def __init__(self): @@ -11493,34 +11598,72 @@ class WAS_Bounded_Image_Crop_With_Mask: CATEGORY = "WAS Suite/Image/Bound" def bounded_image_crop_with_mask(self, image, mask, padding_left, padding_right, padding_top, padding_bottom): - is_batch = len(mask.shape) == 3 - if is_batch: - cropped_images = [] - all_bounds = [] - for i in range(mask.shape[0]): - single_mask = mask[i] - single_image, bounds = self._crop_single_image(image, single_mask, padding_left, padding_right, padding_top, padding_bottom) - cropped_images.append(single_image) - all_bounds.append(bounds) + # Ensure we are working with batches + image = image.unsqueeze(0) if image.dim() == 3 else image + mask = mask.unsqueeze(0) if mask.dim() == 2 else mask + + # If number of masks and images don't match, then only the first mask will be used on + # the images, otherwise, each mask will be used for each image 1 to 1 + mask_len = 1 if len(image) != len(mask) else len(image) + + cropped_images = [] + all_bounds = [] + for i in range(len(image)): + # Single mask or multiple? + if (mask_len == 1 and i == 0) or mask_len > 0: + rows = torch.any(mask[i], dim=1) + cols = torch.any(mask[i], dim=0) + rmin, rmax = torch.where(rows)[0][[0, -1]] + cmin, cmax = torch.where(cols)[0][[0, -1]] + + rmin = max(rmin - padding_top, 0) + rmax = min(rmax + padding_bottom, mask[i].shape[0] - 1) + cmin = max(cmin - padding_left, 0) + cmax = min(cmax + padding_right, mask[i].shape[1] - 1) + + # Even if only a single mask, create a bounds for each cropped image + all_bounds.append([rmin, rmax, cmin, cmax]) + cropped_images.append(image[i][rmin:rmax+1, cmin:cmax+1, :]) + return torch.stack(cropped_images), all_bounds - else: - single_image, bounds = self._crop_single_image(image, mask, padding_left, padding_right, padding_top, padding_bottom) - return single_image, bounds - def _crop_single_image(self, image, mask, padding_left, padding_right, padding_top, padding_bottom): - rows = torch.any(mask, dim=1) - cols = torch.any(mask, dim=0) - rmin, rmax = torch.where(rows)[0][[0, -1]] - cmin, cmax = torch.where(cols)[0][[0, -1]] +# DEBUG IMAGE BOUNDS TO CONSOLE - rmin = max(rmin - padding_top, 0) - rmax = min(rmax + padding_bottom, mask.shape[0] - 1) - cmin = max(cmin - padding_left, 0) - cmax = min(cmax + padding_right, mask.shape[1] - 1) +class WAS_Image_Bounds_to_Console: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_bounds": ("IMAGE_BOUNDS",), + "label": ("STRING", {"default": 'Debug to Console', "multiline": False}), + } + } + + RETURN_TYPES = ("IMAGE_BOUNDS",) + OUTPUT_NODE = True + FUNCTION = "debug_to_console" + + CATEGORY = "WAS Suite/Debug" + + def debug_to_console(self, image_bounds, label): + label_out = 'Debug to Console' + if label.strip() == '': + lable_out = label + + bounds_out = 'Empty' + if len(bounds_out) > 0: + bounds_out = ', \n '.join('\t(rmin={}, rmax={}, cmin={}, cmax={})' + .format(a, b, c, d) for a, b, c, d in image_bounds) + + cstr(f'\033[33m{label_out}\033[0m:\n[\n{bounds_out}\n]\n').msg.print() + return (image_bounds, ) - bounds = [rmin, rmax, cmin, cmax] - return image[:, rmin:rmax+1, cmin:cmax+1, :], bounds - + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("NaN") #! NUMBERS @@ -13454,6 +13597,7 @@ NODE_CLASS_MAPPINGS = { "Bounded Image Blend with Mask": WAS_Bounded_Image_Blend_With_Mask, "Bounded Image Crop": WAS_Bounded_Image_Crop, "Bounded Image Crop with Mask": WAS_Bounded_Image_Crop_With_Mask, + "Image Bounds to Console": WAS_Image_Bounds_to_Console, "Text Dictionary Update": WAS_Dictionary_Update, "Text Add Tokens": WAS_Text_Add_Tokens, "Text Add Token by Input": WAS_Text_Add_Token_Input,