import numpy as np import torch from PIL import Image, ImageDraw, ImageFilter from ..log import log from ..utils import np2tensor, pil2tensor, tensor2np, tensor2pil class MTB_Bbox: """The bounding box (BBOX) custom type used by other nodes""" @classmethod def INPUT_TYPES(cls): return { "required": { # "bbox": ("BBOX",), "x": ( "INT", {"default": 0, "max": 10000000, "min": 0, "step": 1}, ), "y": ( "INT", {"default": 0, "max": 10000000, "min": 0, "step": 1}, ), "width": ( "INT", {"default": 256, "max": 10000000, "min": 0, "step": 1}, ), "height": ( "INT", {"default": 256, "max": 10000000, "min": 0, "step": 1}, ), } } RETURN_TYPES = ("BBOX",) FUNCTION = "do_crop" CATEGORY = "mtb/crop" def do_crop(self, x: int, y: int, width: int, height: int): # bbox return ((x, y, width, height),) class MTB_SplitBbox: """Split the components of a bbox""" @classmethod def INPUT_TYPES(cls): return { "required": {"bbox": ("BBOX",)}, } CATEGORY = "mtb/crop" FUNCTION = "split_bbox" RETURN_TYPES = ("INT", "INT", "INT", "INT") RETURN_NAMES = ("x", "y", "width", "height") def split_bbox(self, bbox): return (bbox[0], bbox[1], bbox[2], bbox[3]) class MTB_UpscaleBboxBy: @classmethod def INPUT_TYPES(cls): return { "required": { "bbox": ("BBOX",), "scale": ("FLOAT", {"default": 1.0}), }, } CATEGORY = "mtb/crop" RETURN_TYPES = ("BBOX",) FUNCTION = "upscale" def upscale( self, bbox: tuple[int, int, int, int], scale: float ) -> tuple[tuple[int, int, int, int]]: x, y, width, height = bbox # scaled = (x * scale, y * scale, width * scale, height * scale) scaled = ( int(x * scale), int(y * scale), int(width * scale), int(height * scale), ) return (scaled,) class MTB_BboxFromMask: """From a mask extract the bounding box""" @classmethod def INPUT_TYPES(cls): return { "required": { "mask": ("MASK",), "invert": ("BOOLEAN", {"default": False}), }, "optional": { "image": ("IMAGE",), }, } RETURN_TYPES = ( "BBOX", "IMAGE", ) RETURN_NAMES = ( "bbox", "image (optional)", ) FUNCTION = "extract_bounding_box" CATEGORY = "mtb/crop" def extract_bounding_box( self, mask: torch.Tensor, invert: bool, image=None ): # if image != None: # if mask.size(0) != image.size(0): # if mask.size(0) != 1: # log.error( # f"Batch count mismatch for mask and image, it can either be 1 mask for X images, or X masks for X images (mask: {mask.shape} | image: {image.shape})" # ) # raise Exception( # f"Batch count mismatch for mask and image, it can either be 1 mask for X images, or X masks for X images (mask: {mask.shape} | image: {image.shape})" # ) # we invert it _mask = tensor2pil(1.0 - mask)[0] if invert else tensor2pil(mask)[0] alpha_channel = np.array(_mask) non_zero_indices = np.nonzero(alpha_channel) min_x, max_x = np.min(non_zero_indices[1]), np.max(non_zero_indices[1]) min_y, max_y = np.min(non_zero_indices[0]), np.max(non_zero_indices[0]) # Create a bounding box tuple if image != None: # Convert the image to a NumPy array imgs = tensor2np(image) out = [] for img in imgs: # Crop the image from the bounding box img = img[min_y:max_y, min_x:max_x, :] log.debug(f"Cropped image to shape {img.shape}") out.append(img) image = np2tensor(out) log.debug(f"Cropped images shape: {image.shape}") bounding_box = (min_x, min_y, max_x - min_x, max_y - min_y) return ( bounding_box, image, ) class MTB_Crop: """Crops an image and an optional mask to a given bounding box The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type The BBOX input takes precedence over the tuple input """ @classmethod def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), }, "optional": { "mask": ("MASK",), "x": ( "INT", {"default": 0, "max": 10000000, "min": 0, "step": 1}, ), "y": ( "INT", {"default": 0, "max": 10000000, "min": 0, "step": 1}, ), "width": ( "INT", {"default": 256, "max": 10000000, "min": 0, "step": 1}, ), "height": ( "INT", {"default": 256, "max": 10000000, "min": 0, "step": 1}, ), "bbox": ("BBOX",), }, } RETURN_TYPES = ("IMAGE", "MASK", "BBOX") FUNCTION = "do_crop" CATEGORY = "mtb/crop" def do_crop( self, image: torch.Tensor, mask=None, x=0, y=0, width=256, height=256, bbox=None, ): image = image.numpy() if mask is not None: mask = mask.numpy() if bbox is not None: x, y, width, height = bbox cropped_image = image[:, y : y + height, x : x + width, :] cropped_mask = None if mask is not None: cropped_mask = ( mask[:, y : y + height, x : x + width] if mask is not None else None ) crop_data = (x, y, width, height) return ( torch.from_numpy(cropped_image), torch.from_numpy(cropped_mask) if cropped_mask is not None else None, crop_data, ) # def calculate_intersection(rect1, rect2): # x_left = max(rect1[0], rect2[0]) # y_top = max(rect1[1], rect2[1]) # x_right = min(rect1[2], rect2[2]) # y_bottom = min(rect1[3], rect2[3]) # return (x_left, y_top, x_right, y_bottom) def bbox_check(bbox, target_size=None): if not target_size: return bbox new_bbox = ( bbox[0], bbox[1], min(target_size[0] - bbox[0], bbox[2]), min(target_size[1] - bbox[1], bbox[3]), ) if new_bbox != bbox: log.warn(f"BBox too big, constrained to {new_bbox}") return new_bbox def bbox_to_region(bbox, target_size=None): bbox = bbox_check(bbox, target_size) # to region return (bbox[0], bbox[1], bbox[0] + bbox[2], bbox[1] + bbox[3]) class MTB_Uncrop: """Uncrops an image to a given bounding box The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type The BBOX input takes precedence over the tuple input """ @classmethod def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), "crop_image": ("IMAGE",), "bbox": ("BBOX",), "border_blending": ( "FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}, ), } } RETURN_TYPES = ("IMAGE",) FUNCTION = "do_crop" CATEGORY = "mtb/crop" def do_crop(self, image, crop_image, bbox, border_blending): def inset_border(image, border_width=20, border_color=(0)): width, height = image.size bordered_image = Image.new( image.mode, (width, height), border_color ) bordered_image.paste(image, (0, 0)) draw = ImageDraw.Draw(bordered_image) draw.rectangle( (0, 0, width - 1, height - 1), outline=border_color, width=border_width, ) return bordered_image single = image.size(0) == 1 if image.size(0) != crop_image.size(0): if not single: raise ValueError( "The Image batch count is greater than 1, but doesn't match the crop_image batch count. If using batches they should either match or only crop_image must be greater than 1" ) images = tensor2pil(image) crop_imgs = tensor2pil(crop_image) out_images = [] for i, crop in enumerate(crop_imgs): if single: img = images[0] else: img = images[i] # uncrop the image based on the bounding box bb_x, bb_y, bb_width, bb_height = bbox paste_region = bbox_to_region( (bb_x, bb_y, bb_width, bb_height), img.size ) # log.debug(f"Paste region: {paste_region}") # new_region = adjust_paste_region(img.size, paste_region) # log.debug(f"Adjusted paste region: {new_region}") # # Check if the adjusted paste region is different from the original crop_img = crop.convert("RGB") log.debug(f"Crop image size: {crop_img.size}") log.debug(f"Image size: {img.size}") if border_blending > 1.0: border_blending = 1.0 elif border_blending < 0.0: border_blending = 0.0 blend_ratio = (max(crop_img.size) / 2) * float(border_blending) blend = img.convert("RGBA") mask = Image.new("L", img.size, 0) mask_block = Image.new("L", (bb_width, bb_height), 255) mask_block = inset_border(mask_block, int(blend_ratio / 2), (0)) mask.paste(mask_block, paste_region) log.debug(f"Blend size: {blend.size} | kind {blend.mode}") log.debug( f"Crop image size: {crop_img.size} | kind {crop_img.mode}" ) log.debug(f"BBox: {paste_region}") blend.paste(crop_img, paste_region) mask = mask.filter(ImageFilter.BoxBlur(radius=blend_ratio / 4)) mask = mask.filter( ImageFilter.GaussianBlur(radius=blend_ratio / 4) ) blend.putalpha(mask) img = Image.alpha_composite(img.convert("RGBA"), blend) out_images.append(img.convert("RGB")) return (pil2tensor(out_images),) __nodes__ = [ MTB_BboxFromMask, MTB_Bbox, MTB_Crop, MTB_Uncrop, MTB_SplitBbox, MTB_UpscaleBboxBy, ]