Rework of and fixes to the bounded image nodes. Added a debug node to write bounds to console. Now supports single images or batches across all bounded image nodes.
Fixes: WASasquatch/was-node-suite-comfyui#253 WASasquatch/was-node-suite-comfyui#238 WASasquatch/was-node-suite-comfyui#229 WASasquatch/was-node-suite-comfyui#202
This commit is contained in:
+231
-87
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user