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:
LoneOutpost
2023-10-28 13:11:29 -04:00
parent 28f497115c
commit c7b47c4d48
+231 -87
View File
@@ -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,