From 73726a0d2ba35c502cf01368b8b7149a59a8f124 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:52:17 +0000 Subject: [PATCH 01/14] Initial plan From 4561217c192cb3218fe245be39bc5b19a10ba31a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:57:29 +0000 Subject: [PATCH 02/14] Refactor ColorCorrectInvocation with histogram matching Co-authored-by: dunkeroni <3298737+dunkeroni@users.noreply.github.com> --- invokeai/app/invocations/image.py | 180 ++++++++++++++++-------------- 1 file changed, 96 insertions(+), 84 deletions(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 10aafb3c52c..4c6112bffd9 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -4,7 +4,7 @@ import cv2 import numpy -from PIL import Image, ImageChops, ImageFilter, ImageOps +from PIL import Image, ImageChops, ImageCms, ImageFilter, ImageOps from invokeai.app.invocations.baseinvocation import ( BaseInvocation, @@ -649,102 +649,114 @@ def invoke(self, context: InvocationContext) -> ImageOutput: title="Color Correct", tags=["image", "color"], category="image", - version="1.2.2", + version="1.3.0", ) class ColorCorrectInvocation(BaseInvocation, WithMetadata, WithBoard): """ - Shifts the colors of a target image to match the reference image, optionally - using a mask to only color-correct certain regions of the target image. + Matches the color histogram of a base image to a reference image, optionally + using a mask to only color-correct certain regions of the base image. """ - image: ImageField = InputField(description="The image to color-correct") - reference: ImageField = InputField(description="Reference image for color-correction") - mask: Optional[ImageField] = InputField(default=None, description="Mask to use when applying color-correction") - mask_blur_radius: float = InputField(default=8, description="Mask blur radius") + base_image: ImageField = InputField(description="The image to color-correct") + color_reference: ImageField = InputField(description="Reference image for color-correction") + mask: Optional[ImageField] = InputField(default=None, description="Optional mask to limit color correction area") + colorspace: Literal["RGB", "LAB", "LAB-Color", "LAB-Luminance"] = InputField( + default="RGB", description="Colorspace in which to apply histogram matching" + ) + + def _match_histogram_channel(self, source: numpy.ndarray, reference: numpy.ndarray) -> numpy.ndarray: + """Match histogram of source channel to reference channel using cumulative distribution functions.""" + # Compute histograms + source_hist, _ = numpy.histogram(source.flatten(), bins=256, range=(0, 256)) + reference_hist, _ = numpy.histogram(reference.flatten(), bins=256, range=(0, 256)) + + # Compute cumulative distribution functions + source_cdf = source_hist.cumsum() + reference_cdf = reference_hist.cumsum() + + # Normalize CDFs + source_cdf = source_cdf / source_cdf[-1] + reference_cdf = reference_cdf / reference_cdf[-1] + + # Create lookup table using linear interpolation + lookup_table = numpy.interp(source_cdf, reference_cdf, numpy.arange(256)) + + # Apply lookup table to source image + return lookup_table[source].astype(numpy.uint8) def invoke(self, context: InvocationContext) -> ImageOutput: - pil_init_mask = None + # Load images as RGBA + base_image = context.images.get_pil(self.base_image.image_name, "RGBA") + color_reference = context.images.get_pil(self.color_reference.image_name, "RGBA") + + # Store original alpha channel + original_alpha = base_image.getchannel("A") + + # Load mask if provided + mask_array = None if self.mask is not None: - pil_init_mask = context.images.get_pil(self.mask.image_name).convert("L") - - init_image = context.images.get_pil(self.reference.image_name) - - result = context.images.get_pil(self.image.image_name).convert("RGBA") - - # if init_image is None or init_mask is None: - # return result - - # Get the original alpha channel of the mask if there is one. - # Otherwise it is some other black/white image format ('1', 'L' or 'RGB') - # pil_init_mask = ( - # init_mask.getchannel("A") - # if init_mask.mode == "RGBA" - # else init_mask.convert("L") - # ) - pil_init_image = init_image.convert("RGBA") # Add an alpha channel if one doesn't exist - - # Build an image with only visible pixels from source to use as reference for color-matching. - init_rgb_pixels = numpy.asarray(init_image.convert("RGB"), dtype=numpy.uint8) - init_a_pixels = numpy.asarray(pil_init_image.getchannel("A"), dtype=numpy.uint8) - init_mask_pixels = numpy.asarray(pil_init_mask, dtype=numpy.uint8) - - # Get numpy version of result - np_image = numpy.asarray(result.convert("RGB"), dtype=numpy.uint8) - - # Mask and calculate mean and standard deviation - mask_pixels = init_a_pixels * init_mask_pixels > 0 - np_init_rgb_pixels_masked = init_rgb_pixels[mask_pixels, :] - np_image_masked = np_image[mask_pixels, :] - - if np_init_rgb_pixels_masked.size > 0: - init_means = np_init_rgb_pixels_masked.mean(axis=0) - init_std = np_init_rgb_pixels_masked.std(axis=0) - gen_means = np_image_masked.mean(axis=0) - gen_std = np_image_masked.std(axis=0) - - # Color correct - np_matched_result = np_image.copy() - np_matched_result[:, :, :] = ( - ( - ( - (np_matched_result[:, :, :].astype(numpy.float32) - gen_means[None, None, :]) - / gen_std[None, None, :] - ) - * init_std[None, None, :] - + init_means[None, None, :] - ) - .clip(0, 255) - .astype(numpy.uint8) - ) - matched_result = Image.fromarray(np_matched_result, mode="RGB") + mask_image = context.images.get_pil(self.mask.image_name, "RGBA") + mask_array = numpy.asarray(mask_image.convert("L"), dtype=numpy.uint8) / 255.0 + + # Convert to working colorspace + if self.colorspace == "RGB": + # Work directly in RGB + base_array = numpy.asarray(base_image.convert("RGB"), dtype=numpy.uint8) + ref_array = numpy.asarray(color_reference.convert("RGB"), dtype=numpy.uint8) + channels_to_match = [0, 1, 2] # R, G, B else: - matched_result = Image.fromarray(np_image, mode="RGB") - - # Blur the mask out (into init image) by specified amount - if self.mask_blur_radius > 0: - nm = numpy.asarray(pil_init_mask, dtype=numpy.uint8) - inverted_nm = 255 - nm - dilation_size = int(round(self.mask_blur_radius) + 20) - dilating_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilation_size, dilation_size)) - inverted_dilated_nm = cv2.dilate(inverted_nm, dilating_kernel) - dilated_nm = 255 - inverted_dilated_nm - nmd = cv2.erode( - dilated_nm, - kernel=numpy.ones((3, 3), dtype=numpy.uint8), - iterations=int(self.mask_blur_radius / 2), + # Convert to LAB colorspace + profile_srgb = ImageCms.createProfile("sRGB") + profile_lab = ImageCms.createProfile("LAB", colorTemp=6500) + xform_to_lab = ImageCms.buildTransformFromOpenProfiles( + profile_srgb, profile_lab, "RGB", "LAB", renderingIntent=2, flags=0x2400 + ) + xform_from_lab = ImageCms.buildTransformFromOpenProfiles( + profile_lab, profile_srgb, "LAB", "RGB", renderingIntent=2, flags=0x2400 ) - pmd = Image.fromarray(nmd, mode="L") - blurred_init_mask = pmd.filter(ImageFilter.BoxBlur(self.mask_blur_radius)) - else: - blurred_init_mask = pil_init_mask - - multiplied_blurred_init_mask = ImageChops.multiply(blurred_init_mask, result.split()[-1]) - # Paste original on color-corrected generation (using blurred mask) - matched_result.paste(init_image, (0, 0), mask=multiplied_blurred_init_mask) + base_lab = ImageCms.applyTransform(base_image.convert("RGB"), xform_to_lab) + ref_lab = ImageCms.applyTransform(color_reference.convert("RGB"), xform_to_lab) + + base_array = numpy.asarray(base_lab, dtype=numpy.uint8) + ref_array = numpy.asarray(ref_lab, dtype=numpy.uint8) + + # Determine which channels to match based on mode + if self.colorspace == "LAB": + channels_to_match = [0, 1, 2] # L, A, B + elif self.colorspace == "LAB-Color": + channels_to_match = [1, 2] # A, B only + else: # LAB-Luminance + channels_to_match = [0] # L only + + # Apply histogram matching to selected channels + corrected_array = base_array.copy() + for channel_idx in channels_to_match: + corrected_array[:, :, channel_idx] = self._match_histogram_channel( + base_array[:, :, channel_idx], ref_array[:, :, channel_idx] + ) - image_dto = context.images.save(image=matched_result) + # Convert back to RGB if we were in LAB + if self.colorspace != "RGB": + corrected_image = Image.fromarray(corrected_array, mode="LAB") + corrected_image = ImageCms.applyTransform(corrected_image, xform_from_lab) + corrected_array = numpy.asarray(corrected_image, dtype=numpy.uint8) + else: + corrected_image = Image.fromarray(corrected_array, mode="RGB") + # Apply mask if provided + if mask_array is not None: + base_rgb_array = numpy.asarray(base_image.convert("RGB"), dtype=numpy.uint8) + # Blend corrected and original based on mask + mask_3d = numpy.stack([mask_array] * 3, axis=2) + corrected_array = (corrected_array * mask_3d + base_rgb_array * (1 - mask_3d)).astype(numpy.uint8) + + # Create final RGBA image with original alpha + result = Image.fromarray(corrected_array, mode="RGB").convert("RGBA") + result.putalpha(original_alpha) + + # Save and return + image_dto = context.images.save(image=result) return ImageOutput.build(image_dto) From f8287f8f8f16ea2eb8cade635d082b8c76e56062 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:59:59 +0000 Subject: [PATCH 03/14] Add safety check for CDF normalization in histogram matching Co-authored-by: dunkeroni <3298737+dunkeroni@users.noreply.github.com> --- invokeai/app/invocations/image.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 4c6112bffd9..13e757e4030 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -674,9 +674,11 @@ def _match_histogram_channel(self, source: numpy.ndarray, reference: numpy.ndarr source_cdf = source_hist.cumsum() reference_cdf = reference_hist.cumsum() - # Normalize CDFs - source_cdf = source_cdf / source_cdf[-1] - reference_cdf = reference_cdf / reference_cdf[-1] + # Normalize CDFs (avoid division by zero) + if source_cdf[-1] > 0: + source_cdf = source_cdf / source_cdf[-1] + if reference_cdf[-1] > 0: + reference_cdf = reference_cdf / reference_cdf[-1] # Create lookup table using linear interpolation lookup_table = numpy.interp(source_cdf, reference_cdf, numpy.arange(256)) From d7feb036058edd92307630020bf041565a9cea28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:12:17 +0000 Subject: [PATCH 04/14] Fix mask loading and blending: load as L, white=original, black=result Co-authored-by: dunkeroni <3298737+dunkeroni@users.noreply.github.com> --- invokeai/app/invocations/image.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 13e757e4030..c6acc289979 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -697,8 +697,8 @@ def invoke(self, context: InvocationContext) -> ImageOutput: # Load mask if provided mask_array = None if self.mask is not None: - mask_image = context.images.get_pil(self.mask.image_name, "RGBA") - mask_array = numpy.asarray(mask_image.convert("L"), dtype=numpy.uint8) / 255.0 + mask_image = context.images.get_pil(self.mask.image_name, "L") + mask_array = numpy.asarray(mask_image, dtype=numpy.uint8) / 255.0 # Convert to working colorspace if self.colorspace == "RGB": @@ -746,12 +746,12 @@ def invoke(self, context: InvocationContext) -> ImageOutput: else: corrected_image = Image.fromarray(corrected_array, mode="RGB") - # Apply mask if provided + # Apply mask if provided (white = original, black = result) if mask_array is not None: base_rgb_array = numpy.asarray(base_image.convert("RGB"), dtype=numpy.uint8) - # Blend corrected and original based on mask + # Blend: white (1.0) = original, black (0.0) = corrected result mask_3d = numpy.stack([mask_array] * 3, axis=2) - corrected_array = (corrected_array * mask_3d + base_rgb_array * (1 - mask_3d)).astype(numpy.uint8) + corrected_array = (base_rgb_array * mask_3d + corrected_array * (1 - mask_3d)).astype(numpy.uint8) # Create final RGBA image with original alpha result = Image.fromarray(corrected_array, mode="RGB").convert("RGBA") From d766ee1572dc9fa2f26726c57ab6b0d6787a5f85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:33:06 +0000 Subject: [PATCH 05/14] Switch from LAB to YCbCr colorspace for simpler conversions Co-authored-by: dunkeroni <3298737+dunkeroni@users.noreply.github.com> --- invokeai/app/invocations/image.py | 41 ++++++++++++------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index c6acc289979..149e29076a9 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -4,7 +4,7 @@ import cv2 import numpy -from PIL import Image, ImageChops, ImageCms, ImageFilter, ImageOps +from PIL import Image, ImageChops, ImageFilter, ImageOps from invokeai.app.invocations.baseinvocation import ( BaseInvocation, @@ -660,7 +660,7 @@ class ColorCorrectInvocation(BaseInvocation, WithMetadata, WithBoard): base_image: ImageField = InputField(description="The image to color-correct") color_reference: ImageField = InputField(description="Reference image for color-correction") mask: Optional[ImageField] = InputField(default=None, description="Optional mask to limit color correction area") - colorspace: Literal["RGB", "LAB", "LAB-Color", "LAB-Luminance"] = InputField( + colorspace: Literal["RGB", "YCbCr", "YCbCr-Chroma", "YCbCr-Luma"] = InputField( default="RGB", description="Colorspace in which to apply histogram matching" ) @@ -707,29 +707,20 @@ def invoke(self, context: InvocationContext) -> ImageOutput: ref_array = numpy.asarray(color_reference.convert("RGB"), dtype=numpy.uint8) channels_to_match = [0, 1, 2] # R, G, B else: - # Convert to LAB colorspace - profile_srgb = ImageCms.createProfile("sRGB") - profile_lab = ImageCms.createProfile("LAB", colorTemp=6500) - xform_to_lab = ImageCms.buildTransformFromOpenProfiles( - profile_srgb, profile_lab, "RGB", "LAB", renderingIntent=2, flags=0x2400 - ) - xform_from_lab = ImageCms.buildTransformFromOpenProfiles( - profile_lab, profile_srgb, "LAB", "RGB", renderingIntent=2, flags=0x2400 - ) - - base_lab = ImageCms.applyTransform(base_image.convert("RGB"), xform_to_lab) - ref_lab = ImageCms.applyTransform(color_reference.convert("RGB"), xform_to_lab) + # Convert to YCbCr colorspace + base_ycbcr = base_image.convert("RGB").convert("YCbCr") + ref_ycbcr = color_reference.convert("RGB").convert("YCbCr") - base_array = numpy.asarray(base_lab, dtype=numpy.uint8) - ref_array = numpy.asarray(ref_lab, dtype=numpy.uint8) + base_array = numpy.asarray(base_ycbcr, dtype=numpy.uint8) + ref_array = numpy.asarray(ref_ycbcr, dtype=numpy.uint8) # Determine which channels to match based on mode - if self.colorspace == "LAB": - channels_to_match = [0, 1, 2] # L, A, B - elif self.colorspace == "LAB-Color": - channels_to_match = [1, 2] # A, B only - else: # LAB-Luminance - channels_to_match = [0] # L only + if self.colorspace == "YCbCr": + channels_to_match = [0, 1, 2] # Y, Cb, Cr + elif self.colorspace == "YCbCr-Chroma": + channels_to_match = [1, 2] # Cb, Cr only + else: # YCbCr-Luma + channels_to_match = [0] # Y only # Apply histogram matching to selected channels corrected_array = base_array.copy() @@ -738,10 +729,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput: base_array[:, :, channel_idx], ref_array[:, :, channel_idx] ) - # Convert back to RGB if we were in LAB + # Convert back to RGB if we were in YCbCr if self.colorspace != "RGB": - corrected_image = Image.fromarray(corrected_array, mode="LAB") - corrected_image = ImageCms.applyTransform(corrected_image, xform_from_lab) + corrected_image = Image.fromarray(corrected_array, mode="YCbCr") + corrected_image = corrected_image.convert("RGB") corrected_array = numpy.asarray(corrected_image, dtype=numpy.uint8) else: corrected_image = Image.fromarray(corrected_array, mode="RGB") From 55de828060c5a60017bae47b752fd7420a629665 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 8 Oct 2025 14:56:46 -0400 Subject: [PATCH 06/14] remove extra conversion --- invokeai/app/invocations/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 149e29076a9..1cbde285a53 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -708,7 +708,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput: channels_to_match = [0, 1, 2] # R, G, B else: # Convert to YCbCr colorspace - base_ycbcr = base_image.convert("RGB").convert("YCbCr") + base_ycbcr = base_image.convert("YCbCr") ref_ycbcr = color_reference.convert("RGB").convert("YCbCr") base_array = numpy.asarray(base_ycbcr, dtype=numpy.uint8) From 7fb3edcf054273706b6dfef041b8fa0c040817d6 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 8 Oct 2025 14:57:00 -0400 Subject: [PATCH 07/14] remove extra conversion --- invokeai/app/invocations/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 1cbde285a53..885f8cb0e38 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -709,7 +709,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput: else: # Convert to YCbCr colorspace base_ycbcr = base_image.convert("YCbCr") - ref_ycbcr = color_reference.convert("RGB").convert("YCbCr") + ref_ycbcr = color_reference.convert("YCbCr") base_array = numpy.asarray(base_ycbcr, dtype=numpy.uint8) ref_array = numpy.asarray(ref_ycbcr, dtype=numpy.uint8) From 23510f796893c7d8c517afb597ec9dab31153e72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:02:03 +0000 Subject: [PATCH 08/14] Use PIL Image.paste() for mask application instead of numpy array blending Co-authored-by: dunkeroni <3298737+dunkeroni@users.noreply.github.com> --- invokeai/app/invocations/image.py | 32 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 885f8cb0e38..5c20e37851f 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -694,12 +694,6 @@ def invoke(self, context: InvocationContext) -> ImageOutput: # Store original alpha channel original_alpha = base_image.getchannel("A") - # Load mask if provided - mask_array = None - if self.mask is not None: - mask_image = context.images.get_pil(self.mask.image_name, "L") - mask_array = numpy.asarray(mask_image, dtype=numpy.uint8) / 255.0 - # Convert to working colorspace if self.colorspace == "RGB": # Work directly in RGB @@ -731,21 +725,25 @@ def invoke(self, context: InvocationContext) -> ImageOutput: # Convert back to RGB if we were in YCbCr if self.colorspace != "RGB": - corrected_image = Image.fromarray(corrected_array, mode="YCbCr") - corrected_image = corrected_image.convert("RGB") - corrected_array = numpy.asarray(corrected_image, dtype=numpy.uint8) + corrected_image = Image.fromarray(corrected_array, mode="YCbCr").convert("RGB") else: corrected_image = Image.fromarray(corrected_array, mode="RGB") # Apply mask if provided (white = original, black = result) - if mask_array is not None: - base_rgb_array = numpy.asarray(base_image.convert("RGB"), dtype=numpy.uint8) - # Blend: white (1.0) = original, black (0.0) = corrected result - mask_3d = numpy.stack([mask_array] * 3, axis=2) - corrected_array = (base_rgb_array * mask_3d + corrected_array * (1 - mask_3d)).astype(numpy.uint8) - - # Create final RGBA image with original alpha - result = Image.fromarray(corrected_array, mode="RGB").convert("RGBA") + if self.mask is not None: + # Load mask as grayscale + mask_image = context.images.get_pil(self.mask.image_name, "L") + # Invert mask: Image.paste uses white to paste, but we want white=original, black=result + # So we paste the corrected image where mask is black (inverted) + inverted_mask = ImageOps.invert(mask_image) + # Start with base image, paste corrected where mask is black (now white in inverted) + result = base_image.convert("RGB").copy() + result.paste(corrected_image, mask=inverted_mask) + else: + result = corrected_image + + # Convert to RGBA and restore original alpha + result = result.convert("RGBA") result.putalpha(original_alpha) # Save and return From ed9221b87d6ea56618324696851198fd2201aed2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:07:47 +0000 Subject: [PATCH 09/14] Simplify mask application by pasting base on corrected instead of inverting mask Co-authored-by: dunkeroni <3298737+dunkeroni@users.noreply.github.com> --- invokeai/app/invocations/image.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 5c20e37851f..f6ab52e384f 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -733,12 +733,9 @@ def invoke(self, context: InvocationContext) -> ImageOutput: if self.mask is not None: # Load mask as grayscale mask_image = context.images.get_pil(self.mask.image_name, "L") - # Invert mask: Image.paste uses white to paste, but we want white=original, black=result - # So we paste the corrected image where mask is black (inverted) - inverted_mask = ImageOps.invert(mask_image) - # Start with base image, paste corrected where mask is black (now white in inverted) - result = base_image.convert("RGB").copy() - result.paste(corrected_image, mask=inverted_mask) + # Start with corrected image, paste base image where mask is white + result = corrected_image.copy() + result.paste(base_image.convert("RGB"), mask=mask_image) else: result = corrected_image From 1454d9ebe45090a1f726158cd0a7b1532bfa4981 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 8 Oct 2025 23:55:45 -0400 Subject: [PATCH 10/14] (chore) cleanup and schema --- invokeai/app/invocations/image.py | 7 +++---- .../frontend/web/src/services/api/schema.ts | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index f6ab52e384f..cfbaca38b09 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -689,21 +689,20 @@ def _match_histogram_channel(self, source: numpy.ndarray, reference: numpy.ndarr def invoke(self, context: InvocationContext) -> ImageOutput: # Load images as RGBA base_image = context.images.get_pil(self.base_image.image_name, "RGBA") - color_reference = context.images.get_pil(self.color_reference.image_name, "RGBA") # Store original alpha channel original_alpha = base_image.getchannel("A") # Convert to working colorspace if self.colorspace == "RGB": - # Work directly in RGB base_array = numpy.asarray(base_image.convert("RGB"), dtype=numpy.uint8) - ref_array = numpy.asarray(color_reference.convert("RGB"), dtype=numpy.uint8) + ref_rgb = context.images.get_pil(self.color_reference.image_name, "RGB") + ref_array = numpy.asarray(ref_rgb, dtype=numpy.uint8) channels_to_match = [0, 1, 2] # R, G, B else: # Convert to YCbCr colorspace base_ycbcr = base_image.convert("YCbCr") - ref_ycbcr = color_reference.convert("YCbCr") + ref_ycbcr = context.images.get_pil(self.color_reference.image_name, "YCbCr") base_array = numpy.asarray(base_ycbcr, dtype=numpy.uint8) ref_array = numpy.asarray(ref_ycbcr, dtype=numpy.uint8) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index a7964ce12ae..44fea49ef6f 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -4776,8 +4776,8 @@ export type components = { }; /** * Color Correct - * @description Shifts the colors of a target image to match the reference image, optionally - * using a mask to only color-correct certain regions of the target image. + * @description Matches the color histogram of a base image to a reference image, optionally + * using a mask to only color-correct certain regions of the base image. */ ColorCorrectInvocation: { /** @@ -4811,23 +4811,24 @@ export type components = { * @description The image to color-correct * @default null */ - image?: components["schemas"]["ImageField"] | null; + base_image?: components["schemas"]["ImageField"] | null; /** * @description Reference image for color-correction * @default null */ - reference?: components["schemas"]["ImageField"] | null; + color_reference?: components["schemas"]["ImageField"] | null; /** - * @description Mask to use when applying color-correction + * @description Optional mask to limit color correction area * @default null */ mask?: components["schemas"]["ImageField"] | null; /** - * Mask Blur Radius - * @description Mask blur radius - * @default 8 + * Colorspace + * @description Colorspace in which to apply histogram matching + * @default RGB + * @enum {string} */ - mask_blur_radius?: number; + colorspace?: "RGB" | "YCbCr" | "YCbCr-Chroma" | "YCbCr-Luma"; /** * type * @default color_correct From 86cf96fe80b77482218e950d35725ee20c262dca Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Thu, 9 Oct 2025 00:15:40 -0400 Subject: [PATCH 11/14] error message for incorrect mask size --- invokeai/app/invocations/image.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index cfbaca38b09..7c98312fb1a 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -734,7 +734,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput: mask_image = context.images.get_pil(self.mask.image_name, "L") # Start with corrected image, paste base image where mask is white result = corrected_image.copy() - result.paste(base_image.convert("RGB"), mask=mask_image) + if mask_image.size != result.size: + raise ValueError("Mask size must match base image size.") + else: + result.paste(base_image.convert("RGB"), mask=mask_image) else: result = corrected_image From 23193b061f0f8a2bb89c586e7050fb3d24c6fe5a Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Thu, 9 Oct 2025 00:57:28 -0400 Subject: [PATCH 12/14] change Colorspace title to "Color Space" --- invokeai/app/invocations/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 7c98312fb1a..7c74880ccfc 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -661,7 +661,7 @@ class ColorCorrectInvocation(BaseInvocation, WithMetadata, WithBoard): color_reference: ImageField = InputField(description="Reference image for color-correction") mask: Optional[ImageField] = InputField(default=None, description="Optional mask to limit color correction area") colorspace: Literal["RGB", "YCbCr", "YCbCr-Chroma", "YCbCr-Luma"] = InputField( - default="RGB", description="Colorspace in which to apply histogram matching" + default="RGB", description="Colorspace in which to apply histogram matching", title="Color Space" ) def _match_histogram_channel(self, source: numpy.ndarray, reference: numpy.ndarray) -> numpy.ndarray: From ce35a9512c480b3d8890159641ec1b06ec07d186 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Thu, 9 Oct 2025 01:24:29 -0400 Subject: [PATCH 13/14] update typegen --- invokeai/frontend/web/src/services/api/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 44fea49ef6f..c5782374794 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -4823,7 +4823,7 @@ export type components = { */ mask?: components["schemas"]["ImageField"] | null; /** - * Colorspace + * Color Space * @description Colorspace in which to apply histogram matching * @default RGB * @enum {string} From fbde279f5b99f3f56fef0bcb96ed514ff3669965 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Thu, 9 Oct 2025 02:12:19 -0400 Subject: [PATCH 14/14] bump node version to 2.0.0 --- invokeai/app/invocations/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 7c74880ccfc..1d5ba44b24c 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -649,7 +649,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput: title="Color Correct", tags=["image", "color"], category="image", - version="1.3.0", + version="2.0.0", ) class ColorCorrectInvocation(BaseInvocation, WithMetadata, WithBoard): """