From 4e3f58552c92eeee73ba496bc0eec42c1b707d9f Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 18:52:30 -0700 Subject: [PATCH 01/31] Added controlnet_utils.py with code from lvmin for high quality resize, crop+resize, fill+resize --- invokeai/app/util/controlnet_utils.py | 235 ++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 invokeai/app/util/controlnet_utils.py diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py new file mode 100644 index 0000000000..920bca081b --- /dev/null +++ b/invokeai/app/util/controlnet_utils.py @@ -0,0 +1,235 @@ +import torch +import numpy as np +import cv2 +from PIL import Image +from diffusers.utils import PIL_INTERPOLATION + +from einops import rearrange +from controlnet_aux.util import HWC3, resize_image + +################################################################### +# Copy of scripts/lvminthin.py from Mikubill/sd-webui-controlnet +################################################################### +# High Quality Edge Thinning using Pure Python +# Written by Lvmin Zhangu +# 2023 April +# Stanford University +# If you use this, please Cite "High Quality Edge Thinning using Pure Python", Lvmin Zhang, In Mikubill/sd-webui-controlnet. + +lvmin_kernels_raw = [ + np.array([ + [-1, -1, -1], + [0, 1, 0], + [1, 1, 1] + ], dtype=np.int32), + np.array([ + [0, -1, -1], + [1, 1, -1], + [0, 1, 0] + ], dtype=np.int32) +] + +lvmin_kernels = [] +lvmin_kernels += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_kernels_raw] + +lvmin_prunings_raw = [ + np.array([ + [-1, -1, -1], + [-1, 1, -1], + [0, 0, -1] + ], dtype=np.int32), + np.array([ + [-1, -1, -1], + [-1, 1, -1], + [-1, 0, 0] + ], dtype=np.int32) +] + +lvmin_prunings = [] +lvmin_prunings += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_prunings_raw] + + +def remove_pattern(x, kernel): + objects = cv2.morphologyEx(x, cv2.MORPH_HITMISS, kernel) + objects = np.where(objects > 127) + x[objects] = 0 + return x, objects[0].shape[0] > 0 + + +def thin_one_time(x, kernels): + y = x + is_done = True + for k in kernels: + y, has_update = remove_pattern(y, k) + if has_update: + is_done = False + return y, is_done + + +def lvmin_thin(x, prunings=True): + y = x + for i in range(32): + y, is_done = thin_one_time(y, lvmin_kernels) + if is_done: + break + if prunings: + y, _ = thin_one_time(y, lvmin_prunings) + return y + + +def nake_nms(x): + f1 = np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0]], dtype=np.uint8) + f2 = np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8) + f3 = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.uint8) + f4 = np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=np.uint8) + y = np.zeros_like(x) + for f in [f1, f2, f3, f4]: + np.putmask(y, cv2.dilate(x, kernel=f) == x, x) + return y + + +########################################################################### +# Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet +# modified for InvokeAI +########################################################################### +# def detectmap_proc(detected_map, module, resize_mode, h, w): +@staticmethod +def np_img_resize( + np_img: np.ndarray, + resize_mode: str, + h: int, + w: int, + device: torch.device = torch.device('cpu') +): + print("in np_img_resize") + # if 'inpaint' in module: + # np_img = np_img.astype(np.float32) + # else: + # np_img = HWC3(np_img) + np_img = HWC3(np_img) + + def safe_numpy(x): + # A very safe method to make sure that Apple/Mac works + y = x + + # below is very boring but do not change these. If you change these Apple or Mac may fail. + y = y.copy() + y = np.ascontiguousarray(y) + y = y.copy() + return y + + def get_pytorch_control(x): + # A very safe method to make sure that Apple/Mac works + y = x + + # below is very boring but do not change these. If you change these Apple or Mac may fail. + y = torch.from_numpy(y) + y = y.float() / 255.0 + y = rearrange(y, 'h w c -> 1 c h w') + y = y.clone() + # y = y.to(devices.get_device_for("controlnet")) + y = y.to(device) + y = y.clone() + return y + + def high_quality_resize(x: np.ndarray, + size): + # Written by lvmin + # Super high-quality control map up-scaling, considering binary, seg, and one-pixel edges + inpaint_mask = None + if x.ndim == 3 and x.shape[2] == 4: + inpaint_mask = x[:, :, 3] + x = x[:, :, 0:3] + + new_size_is_smaller = (size[0] * size[1]) < (x.shape[0] * x.shape[1]) + new_size_is_bigger = (size[0] * size[1]) > (x.shape[0] * x.shape[1]) + unique_color_count = np.unique(x.reshape(-1, x.shape[2]), axis=0).shape[0] + is_one_pixel_edge = False + is_binary = False + if unique_color_count == 2: + is_binary = np.min(x) < 16 and np.max(x) > 240 + if is_binary: + xc = x + xc = cv2.erode(xc, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + xc = cv2.dilate(xc, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + one_pixel_edge_count = np.where(xc < x)[0].shape[0] + all_edge_count = np.where(x > 127)[0].shape[0] + is_one_pixel_edge = one_pixel_edge_count * 2 > all_edge_count + + if 2 < unique_color_count < 200: + interpolation = cv2.INTER_NEAREST + elif new_size_is_smaller: + interpolation = cv2.INTER_AREA + else: + interpolation = cv2.INTER_CUBIC # Must be CUBIC because we now use nms. NEVER CHANGE THIS + + y = cv2.resize(x, size, interpolation=interpolation) + if inpaint_mask is not None: + inpaint_mask = cv2.resize(inpaint_mask, size, interpolation=interpolation) + + if is_binary: + y = np.mean(y.astype(np.float32), axis=2).clip(0, 255).astype(np.uint8) + if is_one_pixel_edge: + y = nake_nms(y) + _, y = cv2.threshold(y, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + y = lvmin_thin(y, prunings=new_size_is_bigger) + else: + _, y = cv2.threshold(y, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + y = np.stack([y] * 3, axis=2) + + if inpaint_mask is not None: + inpaint_mask = (inpaint_mask > 127).astype(np.float32) * 255.0 + inpaint_mask = inpaint_mask[:, :, None].clip(0, 255).astype(np.uint8) + y = np.concatenate([y, inpaint_mask], axis=2) + + return y + + # if resize_mode == external_code.ResizeMode.RESIZE: + if resize_mode == "just_resize": # RESIZE + print("just resizing") + np_img = high_quality_resize(np_img, (w, h)) + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img + + old_h, old_w, _ = np_img.shape + old_w = float(old_w) + old_h = float(old_h) + k0 = float(h) / old_h + k1 = float(w) / old_w + + safeint = lambda x: int(np.round(x)) + + # if resize_mode == external_code.ResizeMode.OUTER_FIT: + if resize_mode == "fill_resize": # OUTER_FIT + print("fill + resizing") + k = min(k0, k1) + borders = np.concatenate([np_img[0, :, :], np_img[-1, :, :], np_img[:, 0, :], np_img[:, -1, :]], axis=0) + high_quality_border_color = np.median(borders, axis=0).astype(np_img.dtype) + if len(high_quality_border_color) == 4: + # Inpaint hijack + high_quality_border_color[3] = 255 + high_quality_background = np.tile(high_quality_border_color[None, None], [h, w, 1]) + np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (h - new_h) // 2) + pad_w = max(0, (w - new_w) // 2) + high_quality_background[pad_h:pad_h + new_h, pad_w:pad_w + new_w] = np_img + np_img = high_quality_background + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img + else: # resize_mode == "crop_resize" (INNER_FIT) + print("crop + resizing") + k = max(k0, k1) + np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (new_h - h) // 2) + pad_w = max(0, (new_w - w) // 2) + np_img = np_img[pad_h:pad_h + h, pad_w:pad_w + w] + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img From 3a987b2e722e0f16e35f007d285109d254cca0e0 Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:01:14 -0700 Subject: [PATCH 02/31] Added revised prepare_control_image() that leverages lvmin high quality resizing --- invokeai/app/util/controlnet_utils.py | 61 +++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py index 920bca081b..67fd7bb43e 100644 --- a/invokeai/app/util/controlnet_utils.py +++ b/invokeai/app/util/controlnet_utils.py @@ -107,7 +107,6 @@ def np_img_resize( w: int, device: torch.device = torch.device('cpu') ): - print("in np_img_resize") # if 'inpaint' in module: # np_img = np_img.astype(np.float32) # else: @@ -192,7 +191,6 @@ def np_img_resize( # if resize_mode == external_code.ResizeMode.RESIZE: if resize_mode == "just_resize": # RESIZE - print("just resizing") np_img = high_quality_resize(np_img, (w, h)) np_img = safe_numpy(np_img) return get_pytorch_control(np_img), np_img @@ -207,7 +205,6 @@ def np_img_resize( # if resize_mode == external_code.ResizeMode.OUTER_FIT: if resize_mode == "fill_resize": # OUTER_FIT - print("fill + resizing") k = min(k0, k1) borders = np.concatenate([np_img[0, :, :], np_img[-1, :, :], np_img[:, 0, :], np_img[:, -1, :]], axis=0) high_quality_border_color = np.median(borders, axis=0).astype(np_img.dtype) @@ -224,7 +221,6 @@ def np_img_resize( np_img = safe_numpy(np_img) return get_pytorch_control(np_img), np_img else: # resize_mode == "crop_resize" (INNER_FIT) - print("crop + resizing") k = max(k0, k1) np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) new_h, new_w, _ = np_img.shape @@ -233,3 +229,60 @@ def np_img_resize( np_img = np_img[pad_h:pad_h + h, pad_w:pad_w + w] np_img = safe_numpy(np_img) return get_pytorch_control(np_img), np_img + +def prepare_control_image( + # image used to be Union[PIL.Image.Image, List[PIL.Image.Image], torch.Tensor, List[torch.Tensor]] + # but now should be able to assume that image is a single PIL.Image, which simplifies things + image: Image, + # FIXME: need to fix hardwiring of width and height, change to basing on latents dimensions? + # latents_to_match_resolution, # TorchTensor of shape (batch_size, 3, height, width) + width=512, # should be 8 * latent.shape[3] + height=512, # should be 8 * latent height[2] + # batch_size=1, # currently no batching + # num_images_per_prompt=1, # currently only single image + device="cuda", + dtype=torch.float16, + do_classifier_free_guidance=True, + control_mode="balanced", + resize_mode="just_resize_simple", +): + # FIXME: implement "crop_resize_simple" and "fill_resize_simple", or pull them out + if (resize_mode == "just_resize_simple" or + resize_mode == "crop_resize_simple" or + resize_mode == "fill_resize_simple"): + image = image.convert("RGB") + if (resize_mode == "just_resize_simple"): + image = image.resize((width, height), resample=PIL_INTERPOLATION["lanczos"]) + elif (resize_mode == "crop_resize_simple"): # not yet implemented + pass + elif (resize_mode == "fill_resize_simple"): # not yet implemented + pass + nimage = np.array(image) + nimage = nimage[None, :] + nimage = np.concatenate([nimage], axis=0) + # normalizing RGB values to [0,1] range (in PIL.Image they are [0-255]) + nimage = np.array(nimage).astype(np.float32) / 255.0 + nimage = nimage.transpose(0, 3, 1, 2) + timage = torch.from_numpy(nimage) + + # use fancy lvmin controlnet resizing + elif (resize_mode == "just_resize" or resize_mode == "crop_resize" or resize_mode == "fill_resize"): + nimage = np.array(image) + timage, nimage = np_img_resize( + np_img=nimage, + resize_mode=resize_mode, + h=height, + w=width, + # device=torch.device('cpu') + device=device, + ) + else: + pass + print("ERROR: invalid resize_mode ==> ", resize_mode) + exit(1) + + timage = timage.to(device=device, dtype=dtype) + cfg_injection = (control_mode == "more_control" or control_mode == "unbalanced") + if do_classifier_free_guidance and not cfg_injection: + timage = torch.cat([timage] * 2) + return timage From 6affe42310c0bdce08d87fcb8e10315e98972821 Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:17:24 -0700 Subject: [PATCH 03/31] Added resize_mode param to ControlNet node --- invokeai/app/invocations/controlnet_image_processors.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/invokeai/app/invocations/controlnet_image_processors.py b/invokeai/app/invocations/controlnet_image_processors.py index 43cad3dcaf..911fede8fb 100644 --- a/invokeai/app/invocations/controlnet_image_processors.py +++ b/invokeai/app/invocations/controlnet_image_processors.py @@ -85,8 +85,8 @@ CONTROLNET_DEFAULT_MODELS = [ CONTROLNET_NAME_VALUES = Literal[tuple(CONTROLNET_DEFAULT_MODELS)] CONTROLNET_MODE_VALUES = Literal[tuple( ["balanced", "more_prompt", "more_control", "unbalanced"])] -# crop and fill options not ready yet -# CONTROLNET_RESIZE_VALUES = Literal[tuple(["just_resize", "crop_resize", "fill_resize"])] +CONTROLNET_RESIZE_VALUES = Literal[tuple( + ["just_resize", "crop_resize", "fill_resize", "just_resize_simple",])] class ControlNetModelField(BaseModel): @@ -111,7 +111,8 @@ class ControlField(BaseModel): description="When the ControlNet is last applied (% of total steps)") control_mode: CONTROLNET_MODE_VALUES = Field( default="balanced", description="The control mode to use") - # resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use") + resize_mode: CONTROLNET_RESIZE_VALUES = Field( + default="just_resize", description="The resize mode to use") @validator("control_weight") def validate_control_weight(cls, v): @@ -161,6 +162,7 @@ class ControlNetInvocation(BaseInvocation): end_step_percent: float = Field(default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)") control_mode: CONTROLNET_MODE_VALUES = Field(default="balanced", description="The control mode used") + resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode used") # fmt: on class Config(InvocationConfig): @@ -187,6 +189,7 @@ class ControlNetInvocation(BaseInvocation): begin_step_percent=self.begin_step_percent, end_step_percent=self.end_step_percent, control_mode=self.control_mode, + resize_mode=self.resize_mode, ), ) From e918168f7aabced1833381371cb7f4552745e7e8 Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:21:17 -0700 Subject: [PATCH 04/31] Removed diffusers_pipeline prepare_control_image() -- replaced with controlnet_utils.prepare_control_image() Added resize_mode to ControlNetData class. --- .../stable_diffusion/diffusers_pipeline.py | 53 +------------------ 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 228fbd0585..8acfb100a6 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -219,6 +219,7 @@ class ControlNetData: begin_step_percent: float = Field(default=0.0) end_step_percent: float = Field(default=1.0) control_mode: str = Field(default="balanced") + resize_mode: str = Field(default="just_resize") @dataclass @@ -653,7 +654,7 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): if cfg_injection: # Inferred ControlNet only for the conditional batch. # To apply the output of ControlNet to both the unconditional and conditional batches, - # add 0 to the unconditional batch to keep it unchanged. + # prepend zeros for unconditional batch down_samples = [torch.cat([torch.zeros_like(d), d]) for d in down_samples] mid_sample = torch.cat([torch.zeros_like(mid_sample), mid_sample]) @@ -954,53 +955,3 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): debug_image( img, f"latents {msg} {i+1}/{len(decoded)}", debug_status=True ) - - # Copied from diffusers pipeline_stable_diffusion_controlnet.py - # Returns torch.Tensor of shape (batch_size, 3, height, width) - @staticmethod - def prepare_control_image( - image, - # FIXME: need to fix hardwiring of width and height, change to basing on latents dimensions? - # latents, - width=512, # should be 8 * latent.shape[3] - height=512, # should be 8 * latent height[2] - batch_size=1, - num_images_per_prompt=1, - device="cuda", - dtype=torch.float16, - do_classifier_free_guidance=True, - control_mode="balanced" - ): - - if not isinstance(image, torch.Tensor): - if isinstance(image, PIL.Image.Image): - image = [image] - - if isinstance(image[0], PIL.Image.Image): - images = [] - for image_ in image: - image_ = image_.convert("RGB") - image_ = image_.resize((width, height), resample=PIL_INTERPOLATION["lanczos"]) - image_ = np.array(image_) - image_ = image_[None, :] - images.append(image_) - image = images - image = np.concatenate(image, axis=0) - image = np.array(image).astype(np.float32) / 255.0 - image = image.transpose(0, 3, 1, 2) - image = torch.from_numpy(image) - elif isinstance(image[0], torch.Tensor): - image = torch.cat(image, dim=0) - - image_batch_size = image.shape[0] - if image_batch_size == 1: - repeat_by = batch_size - else: - # image batch size is the same as prompt batch size - repeat_by = num_images_per_prompt - image = image.repeat_interleave(repeat_by, dim=0) - image = image.to(device=device, dtype=dtype) - cfg_injection = (control_mode == "more_control" or control_mode == "unbalanced") - if do_classifier_free_guidance and not cfg_injection: - image = torch.cat([image] * 2) - return image From c2b99e7545311325348c9ffb559d6921a85534bd Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:26:49 -0700 Subject: [PATCH 05/31] Switching over to controlnet_utils prepare_control_image(), with added resize_mode. --- invokeai/app/invocations/latent.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index cd15fe156b..b4c3454c88 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -30,6 +30,7 @@ from .compel import ConditioningField from .controlnet_image_processors import ControlField from .image import ImageOutput from .model import ModelInfo, UNetField, VaeField +from invokeai.app.util.controlnet_utils import prepare_control_image from diffusers.models.attention_processor import ( AttnProcessor2_0, @@ -288,7 +289,7 @@ class TextToLatentsInvocation(BaseInvocation): # and add in batch_size, num_images_per_prompt? # and do real check for classifier_free_guidance? # prepare_control_image should return torch.Tensor of shape(batch_size, 3, height, width) - control_image = model.prepare_control_image( + control_image = prepare_control_image( image=input_image, do_classifier_free_guidance=do_classifier_free_guidance, width=control_width_resize, @@ -298,13 +299,18 @@ class TextToLatentsInvocation(BaseInvocation): device=control_model.device, dtype=control_model.dtype, control_mode=control_info.control_mode, + resize_mode=control_info.resize_mode, ) control_item = ControlNetData( - model=control_model, image_tensor=control_image, + model=control_model, + image_tensor=control_image, weight=control_info.control_weight, begin_step_percent=control_info.begin_step_percent, end_step_percent=control_info.end_step_percent, control_mode=control_info.control_mode, + # any resizing needed should currently be happening in prepare_control_image(), + # but adding resize_mode to ControlNetData in case needed in the future + resize_mode=control_info.resize_mode, ) control_data.append(control_item) # MultiControlNetModel has been refactored out, just need list[ControlNetData] @@ -601,7 +607,7 @@ class ResizeLatentsInvocation(BaseInvocation): antialias: bool = Field( default=False, description="Whether or not to antialias (applied in bilinear and bicubic modes only)") - + class Config(InvocationConfig): schema_extra = { "ui": { @@ -647,7 +653,7 @@ class ScaleLatentsInvocation(BaseInvocation): antialias: bool = Field( default=False, description="Whether or not to antialias (applied in bilinear and bicubic modes only)") - + class Config(InvocationConfig): schema_extra = { "ui": { From 09dfcc42776357cdeba43e6a897cdbc8b9367c08 Mon Sep 17 00:00:00 2001 From: user1 Date: Thu, 20 Jul 2023 00:38:20 -0700 Subject: [PATCH 06/31] Added pixel_perfect_resolution() method to controlnet_utils.py, but not using yet. To be usable this will likely require modification of ControlNet preprocessors --- invokeai/app/util/controlnet_utils.py | 56 ++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py index 67fd7bb43e..342fa147c5 100644 --- a/invokeai/app/util/controlnet_utils.py +++ b/invokeai/app/util/controlnet_utils.py @@ -94,12 +94,66 @@ def nake_nms(x): return y +################################################################################ +# copied from Mikubill/sd-webui-controlnet external_code.py and modified for InvokeAI +################################################################################ +# FIXME: not using yet, if used in the future will most likely require modification of preprocessors +def pixel_perfect_resolution( + image: np.ndarray, + target_H: int, + target_W: int, + resize_mode: str, +) -> int: + """ + Calculate the estimated resolution for resizing an image while preserving aspect ratio. + + The function first calculates scaling factors for height and width of the image based on the target + height and width. Then, based on the chosen resize mode, it either takes the smaller or the larger + scaling factor to estimate the new resolution. + + If the resize mode is OUTER_FIT, the function uses the smaller scaling factor, ensuring the whole image + fits within the target dimensions, potentially leaving some empty space. + + If the resize mode is not OUTER_FIT, the function uses the larger scaling factor, ensuring the target + dimensions are fully filled, potentially cropping the image. + + After calculating the estimated resolution, the function prints some debugging information. + + Args: + image (np.ndarray): A 3D numpy array representing an image. The dimensions represent [height, width, channels]. + target_H (int): The target height for the image. + target_W (int): The target width for the image. + resize_mode (ResizeMode): The mode for resizing. + + Returns: + int: The estimated resolution after resizing. + """ + raw_H, raw_W, _ = image.shape + + k0 = float(target_H) / float(raw_H) + k1 = float(target_W) / float(raw_W) + + if resize_mode == "fill_resize": + estimation = min(k0, k1) * float(min(raw_H, raw_W)) + else: # "crop_resize" or "just_resize" (or possibly "just_resize_simple"?) + estimation = max(k0, k1) * float(min(raw_H, raw_W)) + + # print(f"Pixel Perfect Computation:") + # print(f"resize_mode = {resize_mode}") + # print(f"raw_H = {raw_H}") + # print(f"raw_W = {raw_W}") + # print(f"target_H = {target_H}") + # print(f"target_W = {target_W}") + # print(f"estimation = {estimation}") + + return int(np.round(estimation)) + + ########################################################################### # Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet # modified for InvokeAI ########################################################################### # def detectmap_proc(detected_map, module, resize_mode, h, w): -@staticmethod def np_img_resize( np_img: np.ndarray, resize_mode: str, From 6cb9167a1b4fc04527815f644acfd5115284e4a5 Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 18:52:30 -0700 Subject: [PATCH 07/31] Added controlnet_utils.py with code from lvmin for high quality resize, crop+resize, fill+resize --- invokeai/app/util/controlnet_utils.py | 235 ++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 invokeai/app/util/controlnet_utils.py diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py new file mode 100644 index 0000000000..920bca081b --- /dev/null +++ b/invokeai/app/util/controlnet_utils.py @@ -0,0 +1,235 @@ +import torch +import numpy as np +import cv2 +from PIL import Image +from diffusers.utils import PIL_INTERPOLATION + +from einops import rearrange +from controlnet_aux.util import HWC3, resize_image + +################################################################### +# Copy of scripts/lvminthin.py from Mikubill/sd-webui-controlnet +################################################################### +# High Quality Edge Thinning using Pure Python +# Written by Lvmin Zhangu +# 2023 April +# Stanford University +# If you use this, please Cite "High Quality Edge Thinning using Pure Python", Lvmin Zhang, In Mikubill/sd-webui-controlnet. + +lvmin_kernels_raw = [ + np.array([ + [-1, -1, -1], + [0, 1, 0], + [1, 1, 1] + ], dtype=np.int32), + np.array([ + [0, -1, -1], + [1, 1, -1], + [0, 1, 0] + ], dtype=np.int32) +] + +lvmin_kernels = [] +lvmin_kernels += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_kernels_raw] + +lvmin_prunings_raw = [ + np.array([ + [-1, -1, -1], + [-1, 1, -1], + [0, 0, -1] + ], dtype=np.int32), + np.array([ + [-1, -1, -1], + [-1, 1, -1], + [-1, 0, 0] + ], dtype=np.int32) +] + +lvmin_prunings = [] +lvmin_prunings += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_prunings_raw] + + +def remove_pattern(x, kernel): + objects = cv2.morphologyEx(x, cv2.MORPH_HITMISS, kernel) + objects = np.where(objects > 127) + x[objects] = 0 + return x, objects[0].shape[0] > 0 + + +def thin_one_time(x, kernels): + y = x + is_done = True + for k in kernels: + y, has_update = remove_pattern(y, k) + if has_update: + is_done = False + return y, is_done + + +def lvmin_thin(x, prunings=True): + y = x + for i in range(32): + y, is_done = thin_one_time(y, lvmin_kernels) + if is_done: + break + if prunings: + y, _ = thin_one_time(y, lvmin_prunings) + return y + + +def nake_nms(x): + f1 = np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0]], dtype=np.uint8) + f2 = np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8) + f3 = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.uint8) + f4 = np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=np.uint8) + y = np.zeros_like(x) + for f in [f1, f2, f3, f4]: + np.putmask(y, cv2.dilate(x, kernel=f) == x, x) + return y + + +########################################################################### +# Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet +# modified for InvokeAI +########################################################################### +# def detectmap_proc(detected_map, module, resize_mode, h, w): +@staticmethod +def np_img_resize( + np_img: np.ndarray, + resize_mode: str, + h: int, + w: int, + device: torch.device = torch.device('cpu') +): + print("in np_img_resize") + # if 'inpaint' in module: + # np_img = np_img.astype(np.float32) + # else: + # np_img = HWC3(np_img) + np_img = HWC3(np_img) + + def safe_numpy(x): + # A very safe method to make sure that Apple/Mac works + y = x + + # below is very boring but do not change these. If you change these Apple or Mac may fail. + y = y.copy() + y = np.ascontiguousarray(y) + y = y.copy() + return y + + def get_pytorch_control(x): + # A very safe method to make sure that Apple/Mac works + y = x + + # below is very boring but do not change these. If you change these Apple or Mac may fail. + y = torch.from_numpy(y) + y = y.float() / 255.0 + y = rearrange(y, 'h w c -> 1 c h w') + y = y.clone() + # y = y.to(devices.get_device_for("controlnet")) + y = y.to(device) + y = y.clone() + return y + + def high_quality_resize(x: np.ndarray, + size): + # Written by lvmin + # Super high-quality control map up-scaling, considering binary, seg, and one-pixel edges + inpaint_mask = None + if x.ndim == 3 and x.shape[2] == 4: + inpaint_mask = x[:, :, 3] + x = x[:, :, 0:3] + + new_size_is_smaller = (size[0] * size[1]) < (x.shape[0] * x.shape[1]) + new_size_is_bigger = (size[0] * size[1]) > (x.shape[0] * x.shape[1]) + unique_color_count = np.unique(x.reshape(-1, x.shape[2]), axis=0).shape[0] + is_one_pixel_edge = False + is_binary = False + if unique_color_count == 2: + is_binary = np.min(x) < 16 and np.max(x) > 240 + if is_binary: + xc = x + xc = cv2.erode(xc, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + xc = cv2.dilate(xc, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + one_pixel_edge_count = np.where(xc < x)[0].shape[0] + all_edge_count = np.where(x > 127)[0].shape[0] + is_one_pixel_edge = one_pixel_edge_count * 2 > all_edge_count + + if 2 < unique_color_count < 200: + interpolation = cv2.INTER_NEAREST + elif new_size_is_smaller: + interpolation = cv2.INTER_AREA + else: + interpolation = cv2.INTER_CUBIC # Must be CUBIC because we now use nms. NEVER CHANGE THIS + + y = cv2.resize(x, size, interpolation=interpolation) + if inpaint_mask is not None: + inpaint_mask = cv2.resize(inpaint_mask, size, interpolation=interpolation) + + if is_binary: + y = np.mean(y.astype(np.float32), axis=2).clip(0, 255).astype(np.uint8) + if is_one_pixel_edge: + y = nake_nms(y) + _, y = cv2.threshold(y, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + y = lvmin_thin(y, prunings=new_size_is_bigger) + else: + _, y = cv2.threshold(y, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + y = np.stack([y] * 3, axis=2) + + if inpaint_mask is not None: + inpaint_mask = (inpaint_mask > 127).astype(np.float32) * 255.0 + inpaint_mask = inpaint_mask[:, :, None].clip(0, 255).astype(np.uint8) + y = np.concatenate([y, inpaint_mask], axis=2) + + return y + + # if resize_mode == external_code.ResizeMode.RESIZE: + if resize_mode == "just_resize": # RESIZE + print("just resizing") + np_img = high_quality_resize(np_img, (w, h)) + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img + + old_h, old_w, _ = np_img.shape + old_w = float(old_w) + old_h = float(old_h) + k0 = float(h) / old_h + k1 = float(w) / old_w + + safeint = lambda x: int(np.round(x)) + + # if resize_mode == external_code.ResizeMode.OUTER_FIT: + if resize_mode == "fill_resize": # OUTER_FIT + print("fill + resizing") + k = min(k0, k1) + borders = np.concatenate([np_img[0, :, :], np_img[-1, :, :], np_img[:, 0, :], np_img[:, -1, :]], axis=0) + high_quality_border_color = np.median(borders, axis=0).astype(np_img.dtype) + if len(high_quality_border_color) == 4: + # Inpaint hijack + high_quality_border_color[3] = 255 + high_quality_background = np.tile(high_quality_border_color[None, None], [h, w, 1]) + np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (h - new_h) // 2) + pad_w = max(0, (w - new_w) // 2) + high_quality_background[pad_h:pad_h + new_h, pad_w:pad_w + new_w] = np_img + np_img = high_quality_background + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img + else: # resize_mode == "crop_resize" (INNER_FIT) + print("crop + resizing") + k = max(k0, k1) + np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (new_h - h) // 2) + pad_w = max(0, (new_w - w) // 2) + np_img = np_img[pad_h:pad_h + h, pad_w:pad_w + w] + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img From b8e0810ed1acf1af630c44d8e95caeb64ccb02a5 Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:01:14 -0700 Subject: [PATCH 08/31] Added revised prepare_control_image() that leverages lvmin high quality resizing --- invokeai/app/util/controlnet_utils.py | 61 +++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py index 920bca081b..67fd7bb43e 100644 --- a/invokeai/app/util/controlnet_utils.py +++ b/invokeai/app/util/controlnet_utils.py @@ -107,7 +107,6 @@ def np_img_resize( w: int, device: torch.device = torch.device('cpu') ): - print("in np_img_resize") # if 'inpaint' in module: # np_img = np_img.astype(np.float32) # else: @@ -192,7 +191,6 @@ def np_img_resize( # if resize_mode == external_code.ResizeMode.RESIZE: if resize_mode == "just_resize": # RESIZE - print("just resizing") np_img = high_quality_resize(np_img, (w, h)) np_img = safe_numpy(np_img) return get_pytorch_control(np_img), np_img @@ -207,7 +205,6 @@ def np_img_resize( # if resize_mode == external_code.ResizeMode.OUTER_FIT: if resize_mode == "fill_resize": # OUTER_FIT - print("fill + resizing") k = min(k0, k1) borders = np.concatenate([np_img[0, :, :], np_img[-1, :, :], np_img[:, 0, :], np_img[:, -1, :]], axis=0) high_quality_border_color = np.median(borders, axis=0).astype(np_img.dtype) @@ -224,7 +221,6 @@ def np_img_resize( np_img = safe_numpy(np_img) return get_pytorch_control(np_img), np_img else: # resize_mode == "crop_resize" (INNER_FIT) - print("crop + resizing") k = max(k0, k1) np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) new_h, new_w, _ = np_img.shape @@ -233,3 +229,60 @@ def np_img_resize( np_img = np_img[pad_h:pad_h + h, pad_w:pad_w + w] np_img = safe_numpy(np_img) return get_pytorch_control(np_img), np_img + +def prepare_control_image( + # image used to be Union[PIL.Image.Image, List[PIL.Image.Image], torch.Tensor, List[torch.Tensor]] + # but now should be able to assume that image is a single PIL.Image, which simplifies things + image: Image, + # FIXME: need to fix hardwiring of width and height, change to basing on latents dimensions? + # latents_to_match_resolution, # TorchTensor of shape (batch_size, 3, height, width) + width=512, # should be 8 * latent.shape[3] + height=512, # should be 8 * latent height[2] + # batch_size=1, # currently no batching + # num_images_per_prompt=1, # currently only single image + device="cuda", + dtype=torch.float16, + do_classifier_free_guidance=True, + control_mode="balanced", + resize_mode="just_resize_simple", +): + # FIXME: implement "crop_resize_simple" and "fill_resize_simple", or pull them out + if (resize_mode == "just_resize_simple" or + resize_mode == "crop_resize_simple" or + resize_mode == "fill_resize_simple"): + image = image.convert("RGB") + if (resize_mode == "just_resize_simple"): + image = image.resize((width, height), resample=PIL_INTERPOLATION["lanczos"]) + elif (resize_mode == "crop_resize_simple"): # not yet implemented + pass + elif (resize_mode == "fill_resize_simple"): # not yet implemented + pass + nimage = np.array(image) + nimage = nimage[None, :] + nimage = np.concatenate([nimage], axis=0) + # normalizing RGB values to [0,1] range (in PIL.Image they are [0-255]) + nimage = np.array(nimage).astype(np.float32) / 255.0 + nimage = nimage.transpose(0, 3, 1, 2) + timage = torch.from_numpy(nimage) + + # use fancy lvmin controlnet resizing + elif (resize_mode == "just_resize" or resize_mode == "crop_resize" or resize_mode == "fill_resize"): + nimage = np.array(image) + timage, nimage = np_img_resize( + np_img=nimage, + resize_mode=resize_mode, + h=height, + w=width, + # device=torch.device('cpu') + device=device, + ) + else: + pass + print("ERROR: invalid resize_mode ==> ", resize_mode) + exit(1) + + timage = timage.to(device=device, dtype=dtype) + cfg_injection = (control_mode == "more_control" or control_mode == "unbalanced") + if do_classifier_free_guidance and not cfg_injection: + timage = torch.cat([timage] * 2) + return timage From f2f49bd8d02b04eee31af2d8f2dde13e80b26ac2 Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:17:24 -0700 Subject: [PATCH 09/31] Added resize_mode param to ControlNet node --- invokeai/app/invocations/controlnet_image_processors.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/invokeai/app/invocations/controlnet_image_processors.py b/invokeai/app/invocations/controlnet_image_processors.py index 43cad3dcaf..911fede8fb 100644 --- a/invokeai/app/invocations/controlnet_image_processors.py +++ b/invokeai/app/invocations/controlnet_image_processors.py @@ -85,8 +85,8 @@ CONTROLNET_DEFAULT_MODELS = [ CONTROLNET_NAME_VALUES = Literal[tuple(CONTROLNET_DEFAULT_MODELS)] CONTROLNET_MODE_VALUES = Literal[tuple( ["balanced", "more_prompt", "more_control", "unbalanced"])] -# crop and fill options not ready yet -# CONTROLNET_RESIZE_VALUES = Literal[tuple(["just_resize", "crop_resize", "fill_resize"])] +CONTROLNET_RESIZE_VALUES = Literal[tuple( + ["just_resize", "crop_resize", "fill_resize", "just_resize_simple",])] class ControlNetModelField(BaseModel): @@ -111,7 +111,8 @@ class ControlField(BaseModel): description="When the ControlNet is last applied (% of total steps)") control_mode: CONTROLNET_MODE_VALUES = Field( default="balanced", description="The control mode to use") - # resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use") + resize_mode: CONTROLNET_RESIZE_VALUES = Field( + default="just_resize", description="The resize mode to use") @validator("control_weight") def validate_control_weight(cls, v): @@ -161,6 +162,7 @@ class ControlNetInvocation(BaseInvocation): end_step_percent: float = Field(default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)") control_mode: CONTROLNET_MODE_VALUES = Field(default="balanced", description="The control mode used") + resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode used") # fmt: on class Config(InvocationConfig): @@ -187,6 +189,7 @@ class ControlNetInvocation(BaseInvocation): begin_step_percent=self.begin_step_percent, end_step_percent=self.end_step_percent, control_mode=self.control_mode, + resize_mode=self.resize_mode, ), ) From bab8b6d24048442be0d67a9e37461ed991464aed Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:21:17 -0700 Subject: [PATCH 10/31] Removed diffusers_pipeline prepare_control_image() -- replaced with controlnet_utils.prepare_control_image() Added resize_mode to ControlNetData class. --- .../stable_diffusion/diffusers_pipeline.py | 53 +------------------ 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 228fbd0585..8acfb100a6 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -219,6 +219,7 @@ class ControlNetData: begin_step_percent: float = Field(default=0.0) end_step_percent: float = Field(default=1.0) control_mode: str = Field(default="balanced") + resize_mode: str = Field(default="just_resize") @dataclass @@ -653,7 +654,7 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): if cfg_injection: # Inferred ControlNet only for the conditional batch. # To apply the output of ControlNet to both the unconditional and conditional batches, - # add 0 to the unconditional batch to keep it unchanged. + # prepend zeros for unconditional batch down_samples = [torch.cat([torch.zeros_like(d), d]) for d in down_samples] mid_sample = torch.cat([torch.zeros_like(mid_sample), mid_sample]) @@ -954,53 +955,3 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): debug_image( img, f"latents {msg} {i+1}/{len(decoded)}", debug_status=True ) - - # Copied from diffusers pipeline_stable_diffusion_controlnet.py - # Returns torch.Tensor of shape (batch_size, 3, height, width) - @staticmethod - def prepare_control_image( - image, - # FIXME: need to fix hardwiring of width and height, change to basing on latents dimensions? - # latents, - width=512, # should be 8 * latent.shape[3] - height=512, # should be 8 * latent height[2] - batch_size=1, - num_images_per_prompt=1, - device="cuda", - dtype=torch.float16, - do_classifier_free_guidance=True, - control_mode="balanced" - ): - - if not isinstance(image, torch.Tensor): - if isinstance(image, PIL.Image.Image): - image = [image] - - if isinstance(image[0], PIL.Image.Image): - images = [] - for image_ in image: - image_ = image_.convert("RGB") - image_ = image_.resize((width, height), resample=PIL_INTERPOLATION["lanczos"]) - image_ = np.array(image_) - image_ = image_[None, :] - images.append(image_) - image = images - image = np.concatenate(image, axis=0) - image = np.array(image).astype(np.float32) / 255.0 - image = image.transpose(0, 3, 1, 2) - image = torch.from_numpy(image) - elif isinstance(image[0], torch.Tensor): - image = torch.cat(image, dim=0) - - image_batch_size = image.shape[0] - if image_batch_size == 1: - repeat_by = batch_size - else: - # image batch size is the same as prompt batch size - repeat_by = num_images_per_prompt - image = image.repeat_interleave(repeat_by, dim=0) - image = image.to(device=device, dtype=dtype) - cfg_injection = (control_mode == "more_control" or control_mode == "unbalanced") - if do_classifier_free_guidance and not cfg_injection: - image = torch.cat([image] * 2) - return image From 909f538fb5a62b432c8aa0f61bd2e1a17141766e Mon Sep 17 00:00:00 2001 From: user1 Date: Wed, 19 Jul 2023 19:26:49 -0700 Subject: [PATCH 11/31] Switching over to controlnet_utils prepare_control_image(), with added resize_mode. --- invokeai/app/invocations/latent.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index cd15fe156b..b4c3454c88 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -30,6 +30,7 @@ from .compel import ConditioningField from .controlnet_image_processors import ControlField from .image import ImageOutput from .model import ModelInfo, UNetField, VaeField +from invokeai.app.util.controlnet_utils import prepare_control_image from diffusers.models.attention_processor import ( AttnProcessor2_0, @@ -288,7 +289,7 @@ class TextToLatentsInvocation(BaseInvocation): # and add in batch_size, num_images_per_prompt? # and do real check for classifier_free_guidance? # prepare_control_image should return torch.Tensor of shape(batch_size, 3, height, width) - control_image = model.prepare_control_image( + control_image = prepare_control_image( image=input_image, do_classifier_free_guidance=do_classifier_free_guidance, width=control_width_resize, @@ -298,13 +299,18 @@ class TextToLatentsInvocation(BaseInvocation): device=control_model.device, dtype=control_model.dtype, control_mode=control_info.control_mode, + resize_mode=control_info.resize_mode, ) control_item = ControlNetData( - model=control_model, image_tensor=control_image, + model=control_model, + image_tensor=control_image, weight=control_info.control_weight, begin_step_percent=control_info.begin_step_percent, end_step_percent=control_info.end_step_percent, control_mode=control_info.control_mode, + # any resizing needed should currently be happening in prepare_control_image(), + # but adding resize_mode to ControlNetData in case needed in the future + resize_mode=control_info.resize_mode, ) control_data.append(control_item) # MultiControlNetModel has been refactored out, just need list[ControlNetData] @@ -601,7 +607,7 @@ class ResizeLatentsInvocation(BaseInvocation): antialias: bool = Field( default=False, description="Whether or not to antialias (applied in bilinear and bicubic modes only)") - + class Config(InvocationConfig): schema_extra = { "ui": { @@ -647,7 +653,7 @@ class ScaleLatentsInvocation(BaseInvocation): antialias: bool = Field( default=False, description="Whether or not to antialias (applied in bilinear and bicubic modes only)") - + class Config(InvocationConfig): schema_extra = { "ui": { From 70fec9ddab9c0e3c4f44ecff9e5a2f686ee61461 Mon Sep 17 00:00:00 2001 From: user1 Date: Thu, 20 Jul 2023 00:38:20 -0700 Subject: [PATCH 12/31] Added pixel_perfect_resolution() method to controlnet_utils.py, but not using yet. To be usable this will likely require modification of ControlNet preprocessors --- invokeai/app/util/controlnet_utils.py | 56 ++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py index 67fd7bb43e..342fa147c5 100644 --- a/invokeai/app/util/controlnet_utils.py +++ b/invokeai/app/util/controlnet_utils.py @@ -94,12 +94,66 @@ def nake_nms(x): return y +################################################################################ +# copied from Mikubill/sd-webui-controlnet external_code.py and modified for InvokeAI +################################################################################ +# FIXME: not using yet, if used in the future will most likely require modification of preprocessors +def pixel_perfect_resolution( + image: np.ndarray, + target_H: int, + target_W: int, + resize_mode: str, +) -> int: + """ + Calculate the estimated resolution for resizing an image while preserving aspect ratio. + + The function first calculates scaling factors for height and width of the image based on the target + height and width. Then, based on the chosen resize mode, it either takes the smaller or the larger + scaling factor to estimate the new resolution. + + If the resize mode is OUTER_FIT, the function uses the smaller scaling factor, ensuring the whole image + fits within the target dimensions, potentially leaving some empty space. + + If the resize mode is not OUTER_FIT, the function uses the larger scaling factor, ensuring the target + dimensions are fully filled, potentially cropping the image. + + After calculating the estimated resolution, the function prints some debugging information. + + Args: + image (np.ndarray): A 3D numpy array representing an image. The dimensions represent [height, width, channels]. + target_H (int): The target height for the image. + target_W (int): The target width for the image. + resize_mode (ResizeMode): The mode for resizing. + + Returns: + int: The estimated resolution after resizing. + """ + raw_H, raw_W, _ = image.shape + + k0 = float(target_H) / float(raw_H) + k1 = float(target_W) / float(raw_W) + + if resize_mode == "fill_resize": + estimation = min(k0, k1) * float(min(raw_H, raw_W)) + else: # "crop_resize" or "just_resize" (or possibly "just_resize_simple"?) + estimation = max(k0, k1) * float(min(raw_H, raw_W)) + + # print(f"Pixel Perfect Computation:") + # print(f"resize_mode = {resize_mode}") + # print(f"raw_H = {raw_H}") + # print(f"raw_W = {raw_W}") + # print(f"target_H = {target_H}") + # print(f"target_W = {target_W}") + # print(f"estimation = {estimation}") + + return int(np.round(estimation)) + + ########################################################################### # Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet # modified for InvokeAI ########################################################################### # def detectmap_proc(detected_map, module, resize_mode, h, w): -@staticmethod def np_img_resize( np_img: np.ndarray, resize_mode: str, From b7cdda07812db5dd3ec7a108ddfb5b71014beb36 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Thu, 20 Jul 2023 22:48:35 +1200 Subject: [PATCH 13/31] feat: Add ControlNet Resize Mode to Linear UI --- .../controlNet/components/ControlNet.tsx | 8 +-- .../parameters/ParamControlNetResizeMode.tsx | 62 +++++++++++++++++++ .../controlNet/store/controlNetSlice.ts | 26 ++++++-- .../addControlNetToLinearGraph.ts | 2 + .../frontend/web/src/services/api/schema.d.ts | 26 ++++++-- 5 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index 368b9f727c..3ba702d3bb 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -24,6 +24,7 @@ import ParamControlNetShouldAutoConfig from './ParamControlNetShouldAutoConfig'; import ParamControlNetBeginEnd from './parameters/ParamControlNetBeginEnd'; import ParamControlNetControlMode from './parameters/ParamControlNetControlMode'; import ParamControlNetProcessorSelect from './parameters/ParamControlNetProcessorSelect'; +import ParamControlNetResizeMode from './parameters/ParamControlNetResizeMode'; type ControlNetProps = { controlNetId: string; @@ -151,7 +152,7 @@ const ControlNet = (props: ControlNetProps) => { /> )} - + { )} - - - + + diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx new file mode 100644 index 0000000000..4b31ebfc64 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx @@ -0,0 +1,62 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIMantineSelect from 'common/components/IAIMantineSelect'; +import { + ResizeModes, + controlNetResizeModeChanged, +} from 'features/controlNet/store/controlNetSlice'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ParamControlNetResizeModeProps = { + controlNetId: string; +}; + +const RESIZE_MODE_DATA = [ + { label: 'Resize', value: 'just_resize' }, + { label: 'Crop', value: 'crop_resize' }, + { label: 'Fill', value: 'fill_resize' }, +]; + +export default function ParamControlNetResizeMode( + props: ParamControlNetResizeModeProps +) { + const { controlNetId } = props; + const dispatch = useAppDispatch(); + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ controlNet }) => { + const { resizeMode, isEnabled } = + controlNet.controlNets[controlNetId]; + return { resizeMode, isEnabled }; + }, + defaultSelectorOptions + ), + [controlNetId] + ); + + const { resizeMode, isEnabled } = useAppSelector(selector); + + const { t } = useTranslation(); + + const handleResizeModeChange = useCallback( + (resizeMode: ResizeModes) => { + dispatch(controlNetResizeModeChanged({ controlNetId, resizeMode })); + }, + [controlNetId, dispatch] + ); + + return ( + + ); +} diff --git a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts index 663edfd65f..2f8668115a 100644 --- a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts +++ b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts @@ -3,6 +3,7 @@ import { RootState } from 'app/store/store'; import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas'; import { cloneDeep, forEach } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; +import { components } from 'services/api/schema'; import { isAnySessionRejected } from 'services/api/thunks/session'; import { appSocketInvocationError } from 'services/events/actions'; import { controlNetImageProcessed } from './actions'; @@ -16,11 +17,13 @@ import { RequiredControlNetProcessorNode, } from './types'; -export type ControlModes = - | 'balanced' - | 'more_prompt' - | 'more_control' - | 'unbalanced'; +export type ControlModes = NonNullable< + components['schemas']['ControlNetInvocation']['control_mode'] +>; + +export type ResizeModes = NonNullable< + components['schemas']['ControlNetInvocation']['resize_mode'] +>; export const initialControlNet: Omit = { isEnabled: true, @@ -29,6 +32,7 @@ export const initialControlNet: Omit = { beginStepPct: 0, endStepPct: 1, controlMode: 'balanced', + resizeMode: 'just_resize', controlImage: null, processedControlImage: null, processorType: 'canny_image_processor', @@ -45,6 +49,7 @@ export type ControlNetConfig = { beginStepPct: number; endStepPct: number; controlMode: ControlModes; + resizeMode: ResizeModes; controlImage: string | null; processedControlImage: string | null; processorType: ControlNetProcessorType; @@ -215,6 +220,16 @@ export const controlNetSlice = createSlice({ const { controlNetId, controlMode } = action.payload; state.controlNets[controlNetId].controlMode = controlMode; }, + controlNetResizeModeChanged: ( + state, + action: PayloadAction<{ + controlNetId: string; + resizeMode: ResizeModes; + }> + ) => { + const { controlNetId, resizeMode } = action.payload; + state.controlNets[controlNetId].resizeMode = resizeMode; + }, controlNetProcessorParamsChanged: ( state, action: PayloadAction<{ @@ -342,6 +357,7 @@ export const { controlNetBeginStepPctChanged, controlNetEndStepPctChanged, controlNetControlModeChanged, + controlNetResizeModeChanged, controlNetProcessorParamsChanged, controlNetProcessorTypeChanged, controlNetReset, diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts index 0f882f248d..578c4371f2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts @@ -48,6 +48,7 @@ export const addControlNetToLinearGraph = ( beginStepPct, endStepPct, controlMode, + resizeMode, model, processorType, weight, @@ -60,6 +61,7 @@ export const addControlNetToLinearGraph = ( begin_step_percent: beginStepPct, end_step_percent: endStepPct, control_mode: controlMode, + resize_mode: resizeMode, control_model: model as ControlNetInvocation['control_model'], control_weight: weight, }; diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts index 6a2e176ffd..3ecef092af 100644 --- a/invokeai/frontend/web/src/services/api/schema.d.ts +++ b/invokeai/frontend/web/src/services/api/schema.d.ts @@ -167,7 +167,7 @@ export type paths = { "/api/v1/images/clear-intermediates": { /** * Clear Intermediates - * @description Clears first 100 intermediates + * @description Clears all intermediates */ post: operations["clear_intermediates"]; }; @@ -800,6 +800,13 @@ export type components = { * @enum {string} */ control_mode?: "balanced" | "more_prompt" | "more_control" | "unbalanced"; + /** + * Resize Mode + * @description The resize mode to use + * @default just_resize + * @enum {string} + */ + resize_mode?: "just_resize" | "crop_resize" | "fill_resize" | "just_resize_simple"; }; /** * ControlNetInvocation @@ -859,6 +866,13 @@ export type components = { * @enum {string} */ control_mode?: "balanced" | "more_prompt" | "more_control" | "unbalanced"; + /** + * Resize Mode + * @description The resize mode used + * @default just_resize + * @enum {string} + */ + resize_mode?: "just_resize" | "crop_resize" | "fill_resize" | "just_resize_simple"; }; /** ControlNetModelConfig */ ControlNetModelConfig: { @@ -5324,11 +5338,11 @@ export type components = { image?: components["schemas"]["ImageField"]; }; /** - * StableDiffusion2ModelFormat + * StableDiffusion1ModelFormat * @description An enumeration. * @enum {string} */ - StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; + StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusionXLModelFormat * @description An enumeration. @@ -5336,11 +5350,11 @@ export type components = { */ StableDiffusionXLModelFormat: "checkpoint" | "diffusers"; /** - * StableDiffusion1ModelFormat + * StableDiffusion2ModelFormat * @description An enumeration. * @enum {string} */ - StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; + StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; }; responses: never; parameters: never; @@ -6125,7 +6139,7 @@ export type operations = { }; /** * Clear Intermediates - * @description Clears first 100 intermediates + * @description Clears all intermediates */ clear_intermediates: { responses: { From 2872ae2aab6894534c69e11c6254fc51bf3dc64f Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Thu, 20 Jul 2023 22:53:45 +1200 Subject: [PATCH 14/31] fix: Adjust layout of Resize Mode dropdown Moved it next to ControlMode to make it more compact --- .../web/src/features/controlNet/components/ControlNet.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index 3ba702d3bb..78959e6695 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -184,8 +184,10 @@ const ControlNet = (props: ControlNetProps) => { )} - - + + + + From 190ba5af59075a41593a105ec512ad32405f8ac5 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 21:32:34 +1000 Subject: [PATCH 15/31] feat(ui): boards styling --- .../web/src/common/components/IAIDndImage.tsx | 20 +- .../src/common/components/IAIDroppable.tsx | 3 +- .../components/Boards/BoardContextMenu.tsx | 54 ++-- .../Boards/BoardsList/BoardsList.tsx | 99 ++++--- .../Boards/BoardsList/BoardsSearch.tsx | 65 ++++- .../Boards/BoardsList/GalleryBoard.tsx | 271 +++++++++++------- .../Boards/BoardsList/GenericBoard.tsx | 2 +- .../Boards/BoardsList/SystemBoardButton.tsx | 53 ++++ .../gallery/components/GalleryPanel.tsx | 2 +- .../components/ImageGrid/GalleryImage.tsx | 59 ++-- .../src/features/ui/components/InvokeTabs.tsx | 2 +- .../ui/components/tabs/ResizeHandle.tsx | 2 +- .../src/services/api/hooks/useBoardName.ts | 4 +- .../web/src/theme/components/editable.ts | 56 ++++ invokeai/frontend/web/src/theme/theme.ts | 12 + .../src/theme/util/getInputOutlineStyles.ts | 3 + 16 files changed, 482 insertions(+), 225 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/SystemBoardButton.tsx create mode 100644 invokeai/frontend/web/src/theme/components/editable.ts diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index 6082843c55..57d54e155e 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -17,13 +17,13 @@ import { } from 'common/components/IAIImageFallback'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react'; import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; import { ImageDTO, PostUploadAction } from 'services/api/types'; import { mode } from 'theme/util/mode'; import IAIDraggable from './IAIDraggable'; import IAIDroppable from './IAIDroppable'; -import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; type IAIDndImageProps = { imageDTO: ImageDTO | undefined; @@ -148,7 +148,9 @@ const IAIDndImage = (props: IAIDndImageProps) => { maxH: 'full', borderRadius: 'base', shadow: isSelected ? 'selected.light' : undefined, - _dark: { shadow: isSelected ? 'selected.dark' : undefined }, + _dark: { + shadow: isSelected ? 'selected.dark' : undefined, + }, ...imageSx, }} /> @@ -183,13 +185,6 @@ const IAIDndImage = (props: IAIDndImageProps) => { )} {!imageDTO && isUploadDisabled && noContentFallback} - {!isDropDisabled && ( - - )} {imageDTO && !isDragDisabled && ( { onClick={onClick} /> )} + {!isDropDisabled && ( + + )} {onClickReset && withResetIcon && imageDTO && ( ; }; const IAIDroppable = (props: IAIDroppableProps) => { - const { dropLabel, data, disabled } = props; + const { dropLabel, data, disabled, hoverRef } = props; const dndId = useRef(uuidv4()); const { isOver, setNodeRef, active } = useDroppable({ diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx index fa3a6b03be..3b3303f0c8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -23,34 +23,32 @@ const BoardContextMenu = memo( dispatch(boardIdSelected(board?.board_id ?? board_id)); }, [board?.board_id, board_id, dispatch]); return ( - - - menuProps={{ size: 'sm', isLazy: true }} - menuButtonProps={{ - bg: 'transparent', - _hover: { bg: 'transparent' }, - }} - renderMenu={() => ( - - } onClickCapture={handleSelectBoard}> - Select Board - - {!board && } - {board && ( - - )} - - )} - > - {children} - - + + menuProps={{ size: 'sm', isLazy: true }} + menuButtonProps={{ + bg: 'transparent', + _hover: { bg: 'transparent' }, + }} + renderMenu={() => ( + + } onClickCapture={handleSelectBoard}> + Select Board + + {!board && } + {board && ( + + )} + + )} + > + {children} + ); } ); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index 61b8856ff9..60be0c4ab3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -1,27 +1,21 @@ -import { - Collapse, - Flex, - Grid, - GridItem, - useDisclosure, -} from '@chakra-ui/react'; +import { ButtonGroup, Collapse, Flex, Grid, GridItem } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { AnimatePresence, motion } from 'framer-motion'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; -import { memo, useState } from 'react'; +import { memo, useCallback, useState } from 'react'; +import { FaSearch } from 'react-icons/fa'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import { BoardDTO } from 'services/api/types'; import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus'; +import DeleteBoardModal from '../DeleteBoardModal'; import AddBoardButton from './AddBoardButton'; -import AllAssetsBoard from './AllAssetsBoard'; -import AllImagesBoard from './AllImagesBoard'; -import BatchBoard from './BatchBoard'; import BoardsSearch from './BoardsSearch'; import GalleryBoard from './GalleryBoard'; -import NoBoardBoard from './NoBoardBoard'; -import DeleteBoardModal from '../DeleteBoardModal'; -import { BoardDTO } from 'services/api/types'; +import SystemBoardButton from './SystemBoardButton'; const selector = createSelector( [stateSelector], @@ -48,7 +42,10 @@ const BoardsList = (props: Props) => { ) : boards; const [boardToDelete, setBoardToDelete] = useState(); - const [searchMode, setSearchMode] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const handleClickSearchIcon = useCallback(() => { + setIsSearching((v) => !v); + }, []); return ( <> @@ -64,7 +61,54 @@ const BoardsList = (props: Props) => { }} > - + + {isSearching ? ( + + + + ) : ( + + + + + + + + )} + + } + /> { - {!searchMode && ( - <> - - - - - - - - - - {isBatchEnabled && ( - - - - )} - - )} {filteredBoards && filteredBoards.map((board) => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx index fffe50f6a7..f556b83d24 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx @@ -10,7 +10,14 @@ import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { setBoardSearchText } from 'features/gallery/store/boardSlice'; -import { memo } from 'react'; +import { + ChangeEvent, + KeyboardEvent, + memo, + useCallback, + useEffect, + useRef, +} from 'react'; const selector = createSelector( [stateSelector], @@ -22,31 +29,60 @@ const selector = createSelector( ); type Props = { - setSearchMode: (searchMode: boolean) => void; + setIsSearching: (isSearching: boolean) => void; }; const BoardsSearch = (props: Props) => { - const { setSearchMode } = props; + const { setIsSearching } = props; const dispatch = useAppDispatch(); const { searchText } = useAppSelector(selector); + const inputRef = useRef(null); - const handleBoardSearch = (searchTerm: string) => { - setSearchMode(searchTerm.length > 0); - dispatch(setBoardSearchText(searchTerm)); - }; - const clearBoardSearch = () => { - setSearchMode(false); + const handleBoardSearch = useCallback( + (searchTerm: string) => { + dispatch(setBoardSearchText(searchTerm)); + }, + [dispatch] + ); + + const clearBoardSearch = useCallback(() => { dispatch(setBoardSearchText('')); - }; + setIsSearching(false); + }, [dispatch, setIsSearching]); + + const handleKeydown = useCallback( + (e: KeyboardEvent) => { + // exit search mode on escape + if (e.key === 'Escape') { + clearBoardSearch(); + } + }, + [clearBoardSearch] + ); + + const handleChange = useCallback( + (e: ChangeEvent) => { + handleBoardSearch(e.target.value); + }, + [handleBoardSearch] + ); + + useEffect(() => { + // focus the search box on mount + if (!inputRef.current) { + return; + } + inputRef.current.focus(); + }, []); return ( { - handleBoardSearch(e.target.value); - }} + onKeyDown={handleKeydown} + onChange={handleChange} /> {searchText && searchText.length && ( @@ -55,7 +91,8 @@ const BoardsSearch = (props: Props) => { size="xs" variant="ghost" aria-label="Clear Search" - icon={} + opacity={0.5} + icon={} /> )} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 46e7cbcca8..b41b54fff8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -6,9 +6,9 @@ import { EditableInput, EditablePreview, Flex, + Icon, Image, Text, - useColorMode, } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { skipToken } from '@reduxjs/toolkit/dist/query'; @@ -17,14 +17,12 @@ import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIDroppable from 'common/components/IAIDroppable'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { FaUser } from 'react-icons/fa'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { FaFolder } from 'react-icons/fa'; import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { BoardDTO } from 'services/api/types'; -import { mode } from 'theme/util/mode'; import BoardContextMenu from '../BoardContextMenu'; const AUTO_ADD_BADGE_STYLES: ChakraProps['sx'] = { @@ -66,8 +64,9 @@ const GalleryBoard = memo( board.cover_image_name ?? skipToken ); - const { colorMode } = useColorMode(); const { board_name, board_id } = board; + const [localBoardName, setLocalBoardName] = useState(board_name); + const handleSelectBoard = useCallback(() => { dispatch(boardIdSelected(board_id)); }, [board_id, dispatch]); @@ -75,10 +74,6 @@ const GalleryBoard = memo( const [updateBoard, { isLoading: isUpdateBoardLoading }] = useUpdateBoardMutation(); - const handleUpdateBoardName = (newBoardName: string) => { - updateBoard({ board_id, changes: { board_name: newBoardName } }); - }; - const droppableData: MoveBoardDropData = useMemo( () => ({ id: board_id, @@ -88,59 +83,116 @@ const GalleryBoard = memo( [board_id] ); + const handleSubmit = useCallback( + (newBoardName: string) => { + if (!newBoardName) { + // empty strings are not allowed + setLocalBoardName(board_name); + return; + } + if (newBoardName === board_name) { + // don't updated the board name if it hasn't changed + return; + } + updateBoard({ board_id, changes: { board_name: newBoardName } }) + .unwrap() + .then((response) => { + // update local state + setLocalBoardName(response.board_name); + }) + .catch(() => { + // revert on error + setLocalBoardName(board_name); + }); + }, + [board_id, board_name, updateBoard] + ); + + const handleChange = useCallback((newBoardName: string) => { + setLocalBoardName(newBoardName); + }, []); + return ( - - + - {(ref) => ( - + + {(ref) => ( - {board.cover_image_name && coverImage?.thumbnail_url && ( - - )} - {!(board.cover_image_name && coverImage?.thumbnail_url) && ( - - )} + + {coverImage?.thumbnail_url ? ( + + ) : ( + + + + )} + + + + + + + + + Move} /> - - - { - handleUpdateBoardName(nextValue); - }} - sx={{ maxW: 'full' }} - > - - - - - - )} - + )} + + ); } diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx index 226100c490..99c0a4681f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx @@ -92,7 +92,7 @@ const GenericBoard = (props: GenericBoardProps) => { h: 'full', alignItems: 'center', fontWeight: isSelected ? 600 : undefined, - fontSize: 'xs', + fontSize: 'sm', color: isSelected ? 'base.900' : 'base.700', _dark: { color: isSelected ? 'base.50' : 'base.200' }, }} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/SystemBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/SystemBoardButton.tsx new file mode 100644 index 0000000000..b538eee9d1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/SystemBoardButton.tsx @@ -0,0 +1,53 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIButton from 'common/components/IAIButton'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useBoardName } from 'services/api/hooks/useBoardName'; + +type Props = { + board_id: 'images' | 'assets' | 'no_board'; +}; + +const SystemBoardButton = ({ board_id }: Props) => { + const dispatch = useAppDispatch(); + + const selector = useMemo( + () => + createSelector( + [stateSelector], + ({ gallery }) => { + const { selectedBoardId } = gallery; + return { isSelected: selectedBoardId === board_id }; + }, + defaultSelectorOptions + ), + [board_id] + ); + + const { isSelected } = useAppSelector(selector); + + const boardName = useBoardName(board_id); + + const handleClick = useCallback(() => { + dispatch(boardIdSelected(board_id)); + }, [board_id, dispatch]); + + return ( + + {boardName} + + ); +}; + +export default memo(SystemBoardButton); diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx index 2aa44e50a1..1bbec03f3e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx @@ -109,7 +109,7 @@ const GalleryDrawer = () => { isResizable={true} isOpen={shouldShowGallery} onClose={handleCloseGallery} - minWidth={337} + minWidth={400} > diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index dcce3a1b18..bf627b9591 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,4 +1,4 @@ -import { Box } from '@chakra-ui/react'; +import { Box, Flex } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { stateSelector } from 'app/store/store'; @@ -86,38 +86,31 @@ const GalleryImage = (props: HoverableImageProps) => { return ( - - {(ref) => ( - - } - // resetTooltip="Delete image" - // withResetIcon // removed bc it's too easy to accidentally delete images - /> - - )} - + + } + // resetTooltip="Delete image" + // withResetIcon // removed bc it's too easy to accidentally delete images + /> + ); }; diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index 94195a27c1..6c683470e7 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -105,7 +105,7 @@ const enabledTabsSelector = createSelector( } ); -const MIN_GALLERY_WIDTH = 300; +const MIN_GALLERY_WIDTH = 350; const DEFAULT_GALLERY_PCT = 20; export const NO_GALLERY_TABS: InvokeTabName[] = ['modelManager']; diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx index 7ef0b48784..57f2e89ef0 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx @@ -3,7 +3,7 @@ import { memo } from 'react'; import { PanelResizeHandle } from 'react-resizable-panels'; import { mode } from 'theme/util/mode'; -type ResizeHandleProps = FlexProps & { +type ResizeHandleProps = Omit & { direction?: 'horizontal' | 'vertical'; }; diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts index d63b6e0425..cbe0ec1808 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts @@ -6,9 +6,9 @@ export const useBoardName = (board_id: BoardId | null | undefined) => { selectFromResult: ({ data }) => { let boardName = ''; if (board_id === 'images') { - boardName = 'All Images'; + boardName = 'Images'; } else if (board_id === 'assets') { - boardName = 'All Assets'; + boardName = 'Assets'; } else if (board_id === 'no_board') { boardName = 'No Board'; } else if (board_id === 'batch') { diff --git a/invokeai/frontend/web/src/theme/components/editable.ts b/invokeai/frontend/web/src/theme/components/editable.ts new file mode 100644 index 0000000000..19321e5968 --- /dev/null +++ b/invokeai/frontend/web/src/theme/components/editable.ts @@ -0,0 +1,56 @@ +import { editableAnatomy as parts } from '@chakra-ui/anatomy'; +import { + createMultiStyleConfigHelpers, + defineStyle, +} from '@chakra-ui/styled-system'; +import { mode } from '@chakra-ui/theme-tools'; + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(parts.keys); + +const baseStylePreview = defineStyle({ + borderRadius: 'md', + py: '1', + transitionProperty: 'common', + transitionDuration: 'normal', +}); + +const baseStyleInput = defineStyle((props) => ({ + borderRadius: 'md', + py: '1', + transitionProperty: 'common', + transitionDuration: 'normal', + width: 'full', + _focusVisible: { boxShadow: 'outline' }, + _placeholder: { opacity: 0.6 }, + '::selection': { + color: mode('accent.900', 'accent.50')(props), + bg: mode('accent.200', 'accent.400')(props), + }, +})); + +const baseStyleTextarea = defineStyle({ + borderRadius: 'md', + py: '1', + transitionProperty: 'common', + transitionDuration: 'normal', + width: 'full', + _focusVisible: { boxShadow: 'outline' }, + _placeholder: { opacity: 0.6 }, +}); + +const invokeAI = definePartsStyle((props) => ({ + preview: baseStylePreview, + input: baseStyleInput(props), + textarea: baseStyleTextarea, +})); + +export const editableTheme = defineMultiStyleConfig({ + variants: { + invokeAI, + }, + defaultProps: { + size: 'sm', + variant: 'invokeAI', + }, +}); diff --git a/invokeai/frontend/web/src/theme/theme.ts b/invokeai/frontend/web/src/theme/theme.ts index 42a5a12c3f..7fc515c2fe 100644 --- a/invokeai/frontend/web/src/theme/theme.ts +++ b/invokeai/frontend/web/src/theme/theme.ts @@ -20,6 +20,7 @@ import { tabsTheme } from './components/tabs'; import { textTheme } from './components/text'; import { textareaTheme } from './components/textarea'; import { tooltipTheme } from './components/tooltip'; +import { editableTheme } from './components/editable'; export const theme: ThemeOverride = { config: { @@ -74,12 +75,23 @@ export const theme: ThemeOverride = { '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', }, + hoverSelected: { + light: + '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-500)', + dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-300)', + }, + hoverUnselected: { + light: + '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-200)', + dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-600)', + }, nodeSelectedOutline: `0 0 0 2px var(--invokeai-colors-accent-450)`, }, colors: InvokeAIColors, components: { Button: buttonTheme, // Button and IconButton Input: inputTheme, + Editable: editableTheme, Textarea: textareaTheme, Tabs: tabsTheme, Progress: progressTheme, diff --git a/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts b/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts index 8cf64cbd94..ba5fc9e4c1 100644 --- a/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts +++ b/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts @@ -37,4 +37,7 @@ export const getInputOutlineStyles = (props: StyleFunctionProps) => ({ _placeholder: { color: mode('base.700', 'base.400')(props), }, + '::selection': { + bg: mode('accent.200', 'accent.400')(props), + }, }); From f32bd5dd104c0b48b2c7c6ac2911f9eb4c6016a1 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Thu, 20 Jul 2023 23:48:28 +1200 Subject: [PATCH 16/31] fix: Minor color tweaks to the name plate on boards --- .../gallery/components/Boards/BoardsList/GalleryBoard.tsx | 2 +- invokeai/frontend/web/src/theme/theme.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index b41b54fff8..bcf269ccc2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -241,7 +241,7 @@ const GalleryBoard = memo( borderBottomRadius: 'base', bg: 'accent.400', color: isSelected ? 'base.50' : 'base.100', - _dark: { color: 'base.800', bg: 'accent.200' }, + _dark: { color: 'base.200', bg: 'accent.500' }, lineHeight: 'short', fontSize: 'xs', }} diff --git a/invokeai/frontend/web/src/theme/theme.ts b/invokeai/frontend/web/src/theme/theme.ts index 7fc515c2fe..6f7a719e85 100644 --- a/invokeai/frontend/web/src/theme/theme.ts +++ b/invokeai/frontend/web/src/theme/theme.ts @@ -4,6 +4,7 @@ import { InvokeAIColors } from './colors/colors'; import { accordionTheme } from './components/accordion'; import { buttonTheme } from './components/button'; import { checkboxTheme } from './components/checkbox'; +import { editableTheme } from './components/editable'; import { formLabelTheme } from './components/formLabel'; import { inputTheme } from './components/input'; import { menuTheme } from './components/menu'; @@ -20,7 +21,6 @@ import { tabsTheme } from './components/tabs'; import { textTheme } from './components/text'; import { textareaTheme } from './components/textarea'; import { tooltipTheme } from './components/tooltip'; -import { editableTheme } from './components/editable'; export const theme: ThemeOverride = { config: { @@ -73,7 +73,7 @@ export const theme: ThemeOverride = { selected: { light: '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', - dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', + dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-500)', }, hoverSelected: { light: From ab9b5f3b958d9a6cb134a9992a6c6ddd1279d614 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Thu, 20 Jul 2023 23:52:12 +1200 Subject: [PATCH 17/31] fix: Possible fix to the name plate getting displaced --- .../gallery/components/Boards/BoardsList/GalleryBoard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index bcf269ccc2..d2a4693c36 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -233,6 +233,7 @@ const GalleryBoard = memo( sx={{ position: 'absolute', bottom: 0, + left: 0, p: 1, justifyContent: 'center', alignItems: 'center', From da523fa32fc3430e7798e69014a29f705fd382eb Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Thu, 20 Jul 2023 23:54:11 +1200 Subject: [PATCH 18/31] fix: Editable text aligning left instead of inplace. --- .../gallery/components/Boards/BoardsList/GalleryBoard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index d2a4693c36..d28cb33200 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -272,6 +272,7 @@ const GalleryBoard = memo( p: 0, _focusVisible: { p: 0, + textAlign: 'center', // get rid of the edit border boxShadow: 'none', }, From d52355655817923185e9a0e942ccae0f2f059063 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Fri, 21 Jul 2023 00:03:10 +1200 Subject: [PATCH 19/31] fix: Truncate board name if longer than 20 chars --- .../web/src/features/gallery/components/GalleryBoardName.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx index 12454dd15b..897cf4e7e8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx @@ -58,7 +58,9 @@ const GalleryBoardName = (props: Props) => { }, }} > - {boardName} + {boardName.length > 20 + ? `${boardName.substring(0, 20)}...` + : boardName} From 1e3cebbf423792aaa01039438f19063283ef94c3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 22:18:30 +1000 Subject: [PATCH 20/31] feat(ui): add useBoardTotal hook to get total items in board actually not using it now, but it's there --- .../Boards/BoardsList/GenericBoard.tsx | 2 +- .../gallery/components/GalleryBoardName.tsx | 12 +++-- .../src/services/api/hooks/useBoardTotal.ts | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx index 99c0a4681f..fa7f944a24 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx @@ -17,7 +17,7 @@ type GenericBoardProps = { badgeCount?: number; }; -const formatBadgeCount = (count: number) => +export const formatBadgeCount = (count: number) => Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1, diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx index 897cf4e7e8..2e2efb8e25 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx @@ -4,7 +4,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useBoardName } from 'services/api/hooks/useBoardName'; const selector = createSelector( @@ -26,6 +26,12 @@ const GalleryBoardName = (props: Props) => { const { isOpen, onToggle } = props; const { selectedBoardId } = useAppSelector(selector); const boardName = useBoardName(selectedBoardId); + const formattedBoardName = useMemo(() => { + if (boardName.length > 20) { + return `${boardName.substring(0, 20)}...`; + } + return boardName; + }, [boardName]); return ( { }, }} > - {boardName.length > 20 - ? `${boardName.substring(0, 20)}...` - : boardName} + {formattedBoardName} diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts new file mode 100644 index 0000000000..8deccd8947 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts @@ -0,0 +1,53 @@ +import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { + ASSETS_CATEGORIES, + BoardId, + IMAGE_CATEGORIES, + INITIAL_IMAGE_LIMIT, +} from 'features/gallery/store/gallerySlice'; +import { useMemo } from 'react'; +import { ListImagesArgs, useListImagesQuery } from '../endpoints/images'; + +const baseQueryArg: ListImagesArgs = { + offset: 0, + limit: INITIAL_IMAGE_LIMIT, + is_intermediate: false, +}; + +const imagesQueryArg: ListImagesArgs = { + categories: IMAGE_CATEGORIES, + ...baseQueryArg, +}; + +const assetsQueryArg: ListImagesArgs = { + categories: ASSETS_CATEGORIES, + ...baseQueryArg, +}; + +const noBoardQueryArg: ListImagesArgs = { + board_id: 'none', + ...baseQueryArg, +}; + +export const useBoardTotal = (board_id: BoardId | null | undefined) => { + const queryArg = useMemo(() => { + if (!board_id) { + return; + } + if (board_id === 'images') { + return imagesQueryArg; + } else if (board_id === 'assets') { + return assetsQueryArg; + } else if (board_id === 'no_board') { + return noBoardQueryArg; + } else { + return { board_id, ...baseQueryArg }; + } + }, [board_id]); + + const { total } = useListImagesQuery(queryArg ?? skipToken, { + selectFromResult: ({ currentData }) => ({ total: currentData?.total }), + }); + + return total; +}; From a481607d3fd9ce633a1191a3aa9ff23e6c7722d2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 22:47:59 +1000 Subject: [PATCH 21/31] feat(ui): boards are only punch-you-in-the-face-purple if selected --- .../gallery/components/Boards/BoardsList/GalleryBoard.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index d28cb33200..ef21d51257 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -240,9 +240,12 @@ const GalleryBoard = memo( w: 'full', maxW: 'full', borderBottomRadius: 'base', - bg: 'accent.400', + bg: isSelected ? 'accent.400' : 'base.600', color: isSelected ? 'base.50' : 'base.100', - _dark: { color: 'base.200', bg: 'accent.500' }, + _dark: { + bg: isSelected ? 'accent.500' : 'base.600', + color: isSelected ? 'base.50' : 'base.100', + }, lineHeight: 'short', fontSize: 'xs', }} From 2771328853ca2e63404d090e362d5c240ec7a733 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 22:48:20 +1000 Subject: [PATCH 22/31] feat(ui): reduce saturation by 8% for 1337 contrast --- invokeai/frontend/web/src/theme/colors/colors.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/src/theme/colors/colors.ts b/invokeai/frontend/web/src/theme/colors/colors.ts index bcb2e43c0b..99260ee071 100644 --- a/invokeai/frontend/web/src/theme/colors/colors.ts +++ b/invokeai/frontend/web/src/theme/colors/colors.ts @@ -2,11 +2,16 @@ import { InvokeAIThemeColors } from 'theme/themeTypes'; import { generateColorPalette } from 'theme/util/generateColorPalette'; const BASE = { H: 220, S: 16 }; -const ACCENT = { H: 250, S: 52 }; -const WORKING = { H: 47, S: 50 }; -const WARNING = { H: 28, S: 50 }; -const OK = { H: 113, S: 50 }; -const ERROR = { H: 0, S: 50 }; +const ACCENT = { H: 250, S: 42 }; +// const ACCENT = { H: 250, S: 52 }; +const WORKING = { H: 47, S: 42 }; +// const WORKING = { H: 47, S: 50 }; +const WARNING = { H: 28, S: 42 }; +// const WARNING = { H: 28, S: 50 }; +const OK = { H: 113, S: 42 }; +// const OK = { H: 113, S: 50 }; +const ERROR = { H: 0, S: 42 }; +// const ERROR = { H: 0, S: 50 }; export const InvokeAIColors: InvokeAIThemeColors = { base: generateColorPalette(BASE.H, BASE.S), From 9e27fd9b906520651d4991656f2f61c46369850d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 22:49:27 +1000 Subject: [PATCH 23/31] feat(ui): color tweak on board --- .../gallery/components/Boards/BoardsList/GalleryBoard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index ef21d51257..5d76ad743c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -240,7 +240,7 @@ const GalleryBoard = memo( w: 'full', maxW: 'full', borderBottomRadius: 'base', - bg: isSelected ? 'accent.400' : 'base.600', + bg: isSelected ? 'accent.400' : 'base.500', color: isSelected ? 'base.50' : 'base.100', _dark: { bg: isSelected ? 'accent.500' : 'base.600', From 8dfe196c4fe60c22d88f52950137cd1a73808b26 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Fri, 21 Jul 2023 00:54:03 +1200 Subject: [PATCH 24/31] feat: Add Image Count to Board Name --- .../gallery/components/GalleryBoardName.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx index 2e2efb8e25..27565a52aa 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx @@ -6,6 +6,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { memo, useMemo } from 'react'; import { useBoardName } from 'services/api/hooks/useBoardName'; +import { useBoardTotal } from 'services/api/hooks/useBoardTotal'; const selector = createSelector( [stateSelector], @@ -26,12 +27,17 @@ const GalleryBoardName = (props: Props) => { const { isOpen, onToggle } = props; const { selectedBoardId } = useAppSelector(selector); const boardName = useBoardName(selectedBoardId); + const numOfBoardImages = useBoardTotal(selectedBoardId); + const formattedBoardName = useMemo(() => { - if (boardName.length > 20) { - return `${boardName.substring(0, 20)}...`; + if (!boardName || !numOfBoardImages) { + return ''; } - return boardName; - }, [boardName]); + if (boardName.length > 20) { + return `${boardName.substring(0, 20)}... (${numOfBoardImages})`; + } + return `${boardName} (${numOfBoardImages})`; + }, [boardName, numOfBoardImages]); return ( Date: Fri, 21 Jul 2023 01:04:16 +1200 Subject: [PATCH 25/31] fix: Layout shift on the ControlNet Panel --- .../web/src/features/controlNet/components/ControlNet.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index 78959e6695..4674023c75 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -152,7 +152,7 @@ const ControlNet = (props: ControlNetProps) => { /> )} - + { h: 28, w: 28, aspectRatio: '1/1', - mt: 3, }} > From 8fb970d436f5b3dd3b4fb77dd44eafd884efb9e0 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Fri, 21 Jul 2023 01:07:00 +1200 Subject: [PATCH 26/31] fix: Use layout gap to control layout instead of margin --- .../web/src/features/controlNet/components/ControlNet.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index 4674023c75..e2f8bc125c 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -69,7 +69,7 @@ const ControlNet = (props: ControlNetProps) => { { /> )} - + Date: Fri, 21 Jul 2023 01:14:19 +1200 Subject: [PATCH 27/31] fix: Expand chevron icon being too small --- .../web/src/features/controlNet/components/ControlNet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index e2f8bc125c..da44eba7ab 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -122,7 +122,7 @@ const ControlNet = (props: ControlNetProps) => { icon={ Date: Fri, 21 Jul 2023 01:21:04 +1200 Subject: [PATCH 28/31] fix: Chevron icon styling --- .../src/features/controlNet/components/ControlNet.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index da44eba7ab..d858e46fdb 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -118,11 +118,16 @@ const ControlNet = (props: ControlNetProps) => { tooltip={isExpanded ? 'Hide Advanced' : 'Show Advanced'} aria-label={isExpanded ? 'Hide Advanced' : 'Show Advanced'} onClick={toggleIsExpanded} - variant="link" + variant="ghost" + sx={{ + _hover: { + bg: 'none', + }, + }} icon={ Date: Thu, 20 Jul 2023 09:33:21 -0400 Subject: [PATCH 29/31] if updating intermediate, dont add to gallery list cache --- invokeai/frontend/web/src/services/api/endpoints/images.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 52f410e315..dc17b28798 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -258,7 +258,12 @@ export const imagesApi = api.injectEndpoints({ */ const patches: PatchCollection[] = []; - const { image_name, board_id, image_category } = oldImageDTO; + const { image_name, board_id, image_category, is_intermediate } = oldImageDTO; + + const isChangingFromIntermediate = changes.is_intermediate === false; + // do not add intermediates to gallery cache + if (is_intermediate && !isChangingFromIntermediate) return; + const categories = IMAGE_CATEGORIES.includes(image_category) ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; From 9dc28373d863834d24152c93804a3730ef7fedd2 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 20 Jul 2023 09:38:52 -0400 Subject: [PATCH 30/31] use brackets --- invokeai/frontend/web/src/services/api/endpoints/images.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index dc17b28798..9901003f28 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -262,7 +262,9 @@ export const imagesApi = api.injectEndpoints({ const isChangingFromIntermediate = changes.is_intermediate === false; // do not add intermediates to gallery cache - if (is_intermediate && !isChangingFromIntermediate) return; + if (is_intermediate && !isChangingFromIntermediate) { + return; + } const categories = IMAGE_CATEGORIES.includes(image_category) ? IMAGE_CATEGORIES From cd21d2f2b6bfda74d61372f6215de0b860070516 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:59:55 +1000 Subject: [PATCH 31/31] fix(ui): fix no_board cache not updating two areas marked TODO were not TODONE! --- .../web/src/services/api/endpoints/images.ts | 99 +++++++++---------- 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 9901003f28..5eeb86d9c5 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -169,10 +169,11 @@ export const imagesApi = api.injectEndpoints({ ], async onQueryStarted(imageDTO, { dispatch, queryFulfilled }) { /** - * Cache changes for deleteImage: - * - Remove from "All Images" - * - Remove from image's `board_id` if it has one, or "No Board" if not - * - Remove from "Batch" + * Cache changes for `deleteImage`: + * - *remove* from "All Images" / "All Assets" + * - IF it has a board: + * - THEN *remove* from it's own board + * - ELSE *remove* from "No Board" */ const { image_name, board_id, image_category } = imageDTO; @@ -181,22 +182,23 @@ export const imagesApi = api.injectEndpoints({ // That means constructing the possible query args that are serialized into the cache key... const removeFromCacheKeys: ListImagesArgs[] = []; + + // determine `categories`, i.e. do we update "All Images" or "All Assets" const categories = IMAGE_CATEGORIES.includes(image_category) ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; - // All Images board (e.g. no board) + // remove from "All Images" removeFromCacheKeys.push({ categories }); - // Board specific if (board_id) { + // remove from it's own board removeFromCacheKeys.push({ board_id }); } else { - // TODO: No Board + // remove from "No Board" + removeFromCacheKeys.push({ board_id: 'none' }); } - // TODO: Batch - const patches: PatchCollection[] = []; removeFromCacheKeys.forEach((cacheKey) => { patches.push( @@ -240,25 +242,24 @@ export const imagesApi = api.injectEndpoints({ { imageDTO: oldImageDTO, changes: _changes }, { dispatch, queryFulfilled, getState } ) { - // TODO: Should we handle changes to boards via this mutation? Seems reasonable... - // let's be extra-sure we do not accidentally change categories const changes = omit(_changes, 'image_category'); /** - * Cache changes for `updateImage`: - * - Update the ImageDTO - * - Update the image in "All Images" board: - * - IF it is in the date range represented by the cache: - * - add the image IF it is not already in the cache & update the total - * - ELSE update the image IF it is already in the cache + * Cache changes for "updateImage": + * - *update* "getImageDTO" cache + * - for "All Images" || "All Assets": + * - IF it is not already in the cache + * - THEN *add* it to "All Images" / "All Assets" and update the total + * - ELSE *update* it * - IF the image has a board: - * - Update the image in it's own board - * - ELSE Update the image in the "No Board" board (TODO) + * - THEN *update* it's own board + * - ELSE *update* the "No Board" board */ const patches: PatchCollection[] = []; - const { image_name, board_id, image_category, is_intermediate } = oldImageDTO; + const { image_name, board_id, image_category, is_intermediate } = + oldImageDTO; const isChangingFromIntermediate = changes.is_intermediate === false; // do not add intermediates to gallery cache @@ -266,13 +267,12 @@ export const imagesApi = api.injectEndpoints({ return; } + // determine `categories`, i.e. do we update "All Images" or "All Assets" const categories = IMAGE_CATEGORIES.includes(image_category) ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; - // TODO: No Board - - // Update `getImageDTO` cache + // update `getImageDTO` cache patches.push( dispatch( imagesApi.util.updateQueryData( @@ -288,9 +288,13 @@ export const imagesApi = api.injectEndpoints({ // Update the "All Image" or "All Assets" board const queryArgsToUpdate: ListImagesArgs[] = [{ categories }]; + // IF the image has a board: if (board_id) { - // We also need to update the user board + // THEN update it's own board queryArgsToUpdate.push({ board_id }); + } else { + // ELSE update the "No Board" board + queryArgsToUpdate.push({ board_id: 'none' }); } queryArgsToUpdate.forEach((queryArg) => { @@ -378,12 +382,12 @@ export const imagesApi = api.injectEndpoints({ return; } - // Add the image to the "All Images" / "All Assets" board - const queryArg = { - categories: IMAGE_CATEGORIES.includes(image_category) - ? IMAGE_CATEGORIES - : ASSETS_CATEGORIES, - }; + // determine `categories`, i.e. do we update "All Images" or "All Assets" + const categories = IMAGE_CATEGORIES.includes(image_category) + ? IMAGE_CATEGORIES + : ASSETS_CATEGORIES; + + const queryArg = { categories }; dispatch( imagesApi.util.updateQueryData('listImages', queryArg, (draft) => { @@ -417,16 +421,14 @@ export const imagesApi = api.injectEndpoints({ { dispatch, queryFulfilled, getState } ) { /** - * Cache changes for addImageToBoard: - * - Remove from "No Board" - * - Remove from `old_board_id` if it has one - * - Add to new `board_id` - * - IF the image's `created_at` is within the range of the board's cached images + * Cache changes for `addImageToBoard`: + * - *update* the `getImageDTO` cache + * - *remove* from "No Board" + * - IF the image has an old `board_id`: + * - THEN *remove* from it's old `board_id` + * - IF the image's `created_at` is within the range of the board's cached images * - OR the board cache has length of 0 or 1 - * - Update the `total` for each board whose cache is updated - * - Update the ImageDTO - * - * TODO: maybe total should just be updated in the boards endpoints? + * - THEN *add* it to new `board_id` */ const { image_name, board_id: old_board_id } = oldImageDTO; @@ -434,13 +436,10 @@ export const imagesApi = api.injectEndpoints({ // Figure out the `listImages` caches that we need to update const removeFromQueryArgs: ListImagesArgs[] = []; - // TODO: No Board - // TODO: Batch - - // Remove from No Board + // remove from "No Board" removeFromQueryArgs.push({ board_id: 'none' }); - // Remove from old board + // remove from old board if (old_board_id) { removeFromQueryArgs.push({ board_id: old_board_id }); } @@ -541,17 +540,15 @@ export const imagesApi = api.injectEndpoints({ { dispatch, queryFulfilled, getState } ) { /** - * Cache changes for removeImageFromBoard: - * - Add to "No Board" - * - IF the image's `created_at` is within the range of the board's cached images - * - Remove from `old_board_id` - * - Update the ImageDTO + * Cache changes for `removeImageFromBoard`: + * - *update* `getImageDTO` + * - IF the image's `created_at` is within the range of the board's cached images + * - THEN *add* to "No Board" + * - *remove* from `old_board_id` */ const { image_name, board_id: old_board_id } = imageDTO; - // TODO: Batch - const patches: PatchCollection[] = []; // Updated imageDTO with new board_id