From 1c44578d9a323fd04d53b406182d66f20c8b8003 Mon Sep 17 00:00:00 2001 From: Cyber Dick Lang <286878701@qq.com> Date: Mon, 23 Jun 2025 19:33:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(nodes):=20=E9=9B=86=E6=88=90=20ImitationHu?= =?UTF-8?q?eNode=20=E8=BF=BD=E8=89=B2=E8=8A=82=E7=82=B9=20(v1.0.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 ComfyUI-MingNodes 项目集成追色功能 - 支持图像色彩迁移和追色 - 支持皮肤保护参数 - 支持自动亮度、对比度、饱和度调节 - 支持影调模仿功能 - 支持区域色彩迁移(通过掩码) - 添加 opencv-python 依赖支持 --- __init__.py | 17 ++- nodes/image_nodes_utk.py | 250 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index 8ce42f4..cd44ee5 100644 --- a/__init__.py +++ b/__init__.py @@ -8,13 +8,23 @@ A comprehensive toolkit for ComfyUI that provides various utility nodes for imag :license: MIT, see LICENSE for more details. """ -__version__ = "1.0.7" +__version__ = "1.0.8" __author__ = "CyberDickLang" __email__ = "286878701@qq.com" __url__ = "https://github.com/whmc76" # 更新日志 CHANGELOG = { + "1.0.8": [ + "新增 ImitationHueNode_UTK 节点(追色节点):", + "- 基于 ComfyUI-MingNodes 项目集成", + "- 支持图像色彩迁移和追色功能", + "- 支持皮肤保护参数,避免肤色失真", + "- 支持自动亮度、对比度、饱和度调节", + "- 支持影调模仿功能", + "- 支持区域色彩迁移(通过掩码)", + "- 添加 opencv-python 依赖支持", + ], "1.0.7": [ "改进 ImagePadForOutpaintMasked (UTK) 节点:", "- 新增数据模式(data_mode)参数,支持 'pixel' 和 'percent' 两种模式", @@ -74,7 +84,7 @@ CHANGELOG = { ] } -from .nodes.image_nodes_utk import EmptyUnitGenerator_UTK, ImageRatioDetector_UTK, DepthMapBlur_UTK, ImageConcatenate_UTK, ImageConcatenateMulti_UTK, ImagePadForOutpaintMasked_UTK, ImageAndMaskPreview_UTK +from .nodes.image_nodes_utk import EmptyUnitGenerator_UTK, ImageRatioDetector_UTK, DepthMapBlur_UTK, ImageConcatenate_UTK, ImageConcatenateMulti_UTK, ImagePadForOutpaintMasked_UTK, ImageAndMaskPreview_UTK, ImitationHueNode_UTK from .nodes.tool_nodes_utk import ShowInt_UTK, ShowFloat_UTK, ShowList_UTK, ShowText_UTK, PreviewMask_UTK, FillMaskedArea_UTK from .nodes.audio_nodes_utk import LoadAudioPlusFromPath_UTK, AudioCropProcessUTK from .nodes.mask_nodes_utk import MaskAnd_UTK, MaskSub_UTK, MaskAdd_UTK @@ -98,6 +108,7 @@ NODE_CLASS_MAPPINGS = { "MaskAnd_UTK": MaskAnd_UTK, "MaskSub_UTK": MaskSub_UTK, "MaskAdd_UTK": MaskAdd_UTK, + "ImitationHueNode_UTK": ImitationHueNode_UTK, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -119,6 +130,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "MaskAnd_UTK": "Mask And (UTK)", "MaskSub_UTK": "Mask Sub (UTK)", "MaskAdd_UTK": "Mask Add (UTK)", + "ImitationHueNode_UTK": "Imitation Hue Node (UTK)", } NODE_CATEGORIES = { @@ -136,6 +148,7 @@ NODE_CATEGORIES = { "MaskAnd_UTK", "MaskSub_UTK", "MaskAdd_UTK", + "ImitationHueNode_UTK", ] } diff --git a/nodes/image_nodes_utk.py b/nodes/image_nodes_utk.py index cdc4e54..e70aa3a 100644 --- a/nodes/image_nodes_utk.py +++ b/nodes/image_nodes_utk.py @@ -6,6 +6,7 @@ import math import random import os import json +import cv2 from comfy.utils import ProgressBar, common_upscale from PIL import Image from PIL.PngImagePlugin import PngInfo @@ -754,4 +755,251 @@ nodes for example. preview, = ImageCompositeMasked.composite(self, image, mask_image, 0, 0, True, mask_adjusted) if pass_through: return (preview, ) - return(self.save_images(preview, filename_prefix, prompt, extra_pnginfo)) \ No newline at end of file + return(self.save_images(preview, filename_prefix, prompt, extra_pnginfo)) + +# ----------------------------------------------------------------------------------- +# ComfyUI-MingNodes - ImitationHueNode +# https://github.com/mingsky-ai/ComfyUI-MingNodes +# ----------------------------------------------------------------------------------- + +def image_stats(image): + return np.mean(image[:, :, 1:], axis=(0, 1)), np.std(image[:, :, 1:], axis=(0, 1)) + + +def is_skin_or_lips(lab_image): + l, a, b = lab_image[:, :, 0], lab_image[:, :, 1], lab_image[:, :, 2] + skin = (l > 20) & (l < 250) & (a > 120) & (a < 180) & (b > 120) & (b < 190) + lips = (l > 20) & (l < 200) & (a > 150) & (b > 140) + return (skin | lips).astype(np.float32) + + +def adjust_brightness(image, factor, mask=None): + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + v = hsv[:, :, 2].astype(np.float32) + if mask is not None: + mask = mask.squeeze() + v = np.where(mask > 0, np.clip(v * factor, 0, 255), v) + else: + v = np.clip(v * factor, 0, 255) + hsv[:, :, 2] = v.astype(np.uint8) + return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + + +def adjust_saturation(image, factor, mask=None): + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + s = hsv[:, :, 1].astype(np.float32) + if mask is not None: + mask = mask.squeeze() + s = np.where(mask > 0, np.clip(s * factor, 0, 255), s) + else: + s = np.clip(s * factor, 0, 255) + hsv[:, :, 1] = s.astype(np.uint8) + return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + + +def adjust_contrast(image, factor, mask=None): + mean = np.mean(image) + adjusted = image.astype(np.float32) + if mask is not None: + mask = mask.squeeze() + mask = np.repeat(mask[:, :, np.newaxis], 3, axis=2) + adjusted = np.where(mask > 0, np.clip((adjusted - mean) * factor + mean, 0, 255), adjusted) + else: + adjusted = np.clip((adjusted - mean) * factor + mean, 0, 255) + return adjusted.astype(np.uint8) + + +def adjust_tone(source, target, tone_strength=0.7, mask=None): + h, w = target.shape[:2] + source = cv2.resize(source, (w, h)) + lab_image = cv2.cvtColor(target, cv2.COLOR_BGR2LAB).astype(np.float32) + lab_source = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32) + l_image = lab_image[:,:,0] + l_source = lab_source[:,:,0] + + if mask is not None: + mask = cv2.resize(mask, (w, h)) + mask = mask.astype(np.float32) / 255.0 + l_adjusted = np.copy(l_image) + mean_source = np.mean(l_source[mask > 0]) + std_source = np.std(l_source[mask > 0]) + mean_target = np.mean(l_image[mask > 0]) + std_target = np.std(l_image[mask > 0]) + l_adjusted[mask > 0] = (l_image[mask > 0] - mean_target) * (std_source / (std_target + 1e-6)) * 0.7 + mean_source + l_adjusted[mask > 0] = np.clip(l_adjusted[mask > 0], 0, 255) + clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8)) + l_enhanced = clahe.apply(l_adjusted.astype(np.uint8)) + l_final = cv2.addWeighted(l_adjusted, 0.7, l_enhanced.astype(np.float32), 0.3, 0) + l_final = np.clip(l_final, 0, 255) + l_contrast = cv2.addWeighted(l_final, 1.3, l_final, 0, -20) + l_contrast = np.clip(l_contrast, 0, 255) + l_image[mask > 0] = l_image[mask > 0] * (1 - tone_strength) + l_contrast[mask > 0] * tone_strength + else: + mean_source = np.mean(l_source) + std_source = np.std(l_source) + l_mean = np.mean(l_image) + l_std = np.std(l_image) + l_adjusted = (l_image - l_mean) * (std_source / (l_std + 1e-6)) * 0.7 + mean_source + l_adjusted = np.clip(l_adjusted, 0, 255) + clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8)) + l_enhanced = clahe.apply(l_adjusted.astype(np.uint8)) + l_final = cv2.addWeighted(l_adjusted, 0.7, l_enhanced.astype(np.float32), 0.3, 0) + l_final = np.clip(l_final, 0, 255) + l_contrast = cv2.addWeighted(l_final, 1.3, l_final, 0, -20) + l_contrast = np.clip(l_contrast, 0, 255) + l_image = l_image * (1 - tone_strength) + l_contrast * tone_strength + + lab_image[:,:,0] = l_image + return cv2.cvtColor(lab_image.astype(np.uint8), cv2.COLOR_LAB2BGR) + + +def tensor2cv2(image: torch.Tensor) -> np.array: + if image.dim() == 4: + image = image.squeeze() + npimage = image.numpy() + cv2image = np.uint8(npimage * 255 / npimage.max()) + return cv2.cvtColor(cv2image, cv2.COLOR_RGB2BGR) + + +def color_transfer(source, target, mask=None, strength=1.0, skin_protection=0.2, auto_brightness=True, + brightness_range=0.5, auto_contrast=False, contrast_range=0.5, + auto_saturation=False, saturation_range=0.5, auto_tone=False, tone_strength=0.7): + source_lab = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32) + target_lab = cv2.cvtColor(target, cv2.COLOR_BGR2LAB).astype(np.float32) + + src_means, src_stds = image_stats(source_lab) + tar_means, tar_stds = image_stats(target_lab) + + skin_lips_mask = is_skin_or_lips(target_lab.astype(np.uint8)) + skin_lips_mask = cv2.GaussianBlur(skin_lips_mask, (5, 5), 0) + + if mask is not None: + mask = cv2.resize(mask, (target.shape[1], target.shape[0])) + mask = mask.astype(np.float32) / 255.0 + + result_lab = target_lab.copy() + for i in range(1, 3): + adjusted_channel = (target_lab[:, :, i] - tar_means[i - 1]) * (src_stds[i - 1] / (tar_stds[i - 1] + 1e-6)) + \ + src_means[i - 1] + adjusted_channel = np.clip(adjusted_channel, 0, 255) + + if mask is not None: + result_lab[:, :, i] = target_lab[:, :, i] * (1 - mask) + \ + (target_lab[:, :, i] * skin_lips_mask * skin_protection + \ + adjusted_channel * skin_lips_mask * (1 - skin_protection) + \ + adjusted_channel * (1 - skin_lips_mask)) * mask + else: + result_lab[:, :, i] = target_lab[:, :, i] * skin_lips_mask * skin_protection + \ + adjusted_channel * skin_lips_mask * (1 - skin_protection) + \ + adjusted_channel * (1 - skin_lips_mask) + + result_bgr = cv2.cvtColor(result_lab.astype(np.uint8), cv2.COLOR_LAB2BGR) + final_result = cv2.addWeighted(target, 1 - strength, result_bgr, strength, 0) + + if mask is not None: + mask = cv2.resize(mask, (target.shape[1], target.shape[0])) + mask = mask.astype(np.float32) / 255.0 + if auto_brightness: + source_brightness = np.mean(cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)) + target_brightness = np.mean(cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)) + brightness_difference = source_brightness - target_brightness + brightness_factor = 1.0 + np.clip(brightness_difference / 255 * brightness_range, brightness_range*-1, brightness_range) + final_result = adjust_brightness(final_result, brightness_factor, mask) + if auto_contrast: + source_gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY) + target_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY) + source_contrast = np.std(source_gray) + target_contrast = np.std(target_gray) + contrast_difference = source_contrast - target_contrast + contrast_factor = 1.0 + np.clip(contrast_difference / 255, contrast_range*-1, contrast_range) + final_result = adjust_contrast(final_result, contrast_factor, mask) + if auto_saturation: + source_hsv = cv2.cvtColor(source, cv2.COLOR_BGR2HSV) + target_hsv = cv2.cvtColor(target, cv2.COLOR_BGR2HSV) + source_saturation = np.mean(source_hsv[:, :, 1]) + target_saturation = np.mean(target_hsv[:, :, 1]) + saturation_difference = source_saturation - target_saturation + saturation_factor = 1.0 + np.clip(saturation_difference / 255, saturation_range*-1, saturation_range) + final_result = adjust_saturation(final_result, saturation_factor, mask) + if auto_tone: + final_result = adjust_tone(source, final_result, tone_strength, mask) + else: + if auto_brightness: + source_brightness = np.mean(cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)) + target_brightness = np.mean(cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)) + brightness_difference = source_brightness - target_brightness + brightness_factor = 1.0 + np.clip(brightness_difference / 255 * brightness_range, brightness_range*-1, brightness_range) + final_result = adjust_brightness(final_result, brightness_factor) + if auto_contrast: + source_gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY) + target_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY) + source_contrast = np.std(source_gray) + target_contrast = np.std(target_gray) + contrast_difference = source_contrast - target_contrast + contrast_factor = 1.0 + np.clip(contrast_difference / 255, contrast_range*-1, contrast_range) + final_result = adjust_contrast(final_result, contrast_factor) + if auto_saturation: + source_hsv = cv2.cvtColor(source, cv2.COLOR_BGR2HSV) + target_hsv = cv2.cvtColor(target, cv2.COLOR_BGR2HSV) + source_saturation = np.mean(source_hsv[:, :, 1]) + target_saturation = np.mean(target_hsv[:, :, 1]) + saturation_difference = source_saturation - target_saturation + saturation_factor = 1.0 + np.clip(saturation_difference / 255, saturation_range*-1, saturation_range) + final_result = adjust_saturation(final_result, saturation_factor) + if auto_tone: + final_result = adjust_tone(source, final_result, tone_strength) + + return final_result + + +class ImitationHueNode_UTK: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "imitation_image": ("IMAGE",), + "target_image": ("IMAGE",), + "strength": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 1.0, "step": 0.1}), + "skin_protection": ("FLOAT", {"default": 0.2, "min": 0, "max": 1.0, "step": 0.1}), + "auto_brightness": ("BOOLEAN", {"default": True}), + "brightness_range": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}), + "auto_contrast": ("BOOLEAN", {"default": False}), + "contrast_range": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}), + "auto_saturation": ("BOOLEAN", {"default": False}), + "saturation_range": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}), + "auto_tone": ("BOOLEAN", {"default": False}), + "tone_strength": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}), + }, + "optional": { + "mask": ("MASK", {"default": None}), + }, + } + + CATEGORY = "UniversalToolkit" + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "imitation_hue" + + def imitation_hue(self, imitation_image, target_image, strength, skin_protection, auto_brightness, brightness_range, + auto_contrast, contrast_range, auto_saturation, saturation_range, auto_tone, tone_strength, + mask=None): + for img in imitation_image: + img_cv1 = tensor2cv2(img) + + for img in target_image: + img_cv2 = tensor2cv2(img) + + img_cv3 = None + if mask is not None: + for img3 in mask: + img_cv3 = img3.cpu().numpy() + img_cv3 = (img_cv3 * 255).astype(np.uint8) + + result_img = color_transfer(img_cv1, img_cv2, img_cv3, strength, skin_protection, auto_brightness, + brightness_range,auto_contrast, contrast_range, auto_saturation, + saturation_range, auto_tone, tone_strength) + result_img = cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB) + rst = torch.from_numpy(result_img.astype(np.float32) / 255.0).unsqueeze(0) + + return (rst,) \ No newline at end of file