Files
overcloud81-ComfyUI-ColorCo…/ComfyUI-ColorConsistencyHSL.py

481 lines
24 KiB
Python

# Version: 5.5 (Final) - Bilingual UI & Comments
# Author: overcloud81(@github @modelscope) with deepseek
# Copyright (c) 2025 overcloud81. All rights reserved.
# 功能:支持亮度(L)、色相(H)、饱和度(S)独立匹配,新增“色彩精确”模式(色相/饱和度像素级精确,亮度不变)
# 所有参数已设为合理默认值,开箱即用
# Features: Independent matching of Luminance (L), Hue (H), Saturation (S). New "Color Exact" mode (pixel-perfect hue/saturation, luminance unchanged).
# All parameters have sensible defaults for out-of-the-box use.
import torch
import numpy as np
from scipy.ndimage import gaussian_filter
import folder_paths
import os
class ColorConsistencyHSLAdvanced:
"""
HSL-based color consistency node.
Supports independent matching of Luminance, Hue, Saturation.
Four anchor modes: statistical match, pixel-perfect, luminance stat + color exact, color exact.
Bilingual UI (Chinese/English) with automatic adaptation based on ComfyUI language setting.
"""
@classmethod
def INPUT_TYPES(cls):
# 检测 ComfyUI 语言设置 / Detect ComfyUI language setting
is_chinese = cls._is_chinese_locale()
# 根据语言设置选择菜单文字 / Select menu text based on language
if is_chinese:
mode_list = ["亮度", "色相", "饱和度", "亮度+色相", "亮度+饱和度", "色相+饱和度", "亮度+色相+饱和度"]
anchor_mode_list = ["统计匹配", "像素级精确", "亮度统计+色彩精确", "色彩精确"]
luma_space_list = ["LAB L通道", "线性RGB亮度"]
interpolation_list = ["bilinear", "nearest"]
mode_default = "亮度+色相+饱和度"
anchor_default = "统计匹配"
luma_default = "LAB L通道"
else:
mode_list = ["luminance", "hue", "saturation", "luminance+hue", "luminance+saturation", "hue+saturation", "luminance+hue+saturation"]
anchor_mode_list = ["statistical match", "pixel-perfect", "luminance stat + color exact", "color exact"]
luma_space_list = ["LAB L channel", "linear RGB luminance"]
interpolation_list = ["bilinear", "nearest"]
mode_default = "luminance+hue+saturation"
anchor_default = "statistical match"
luma_default = "LAB L channel"
return {
"required": {
"reference": ("IMAGE",),
"target": ("IMAGE",),
"mode": (mode_list, {"default": mode_default}),
"anchor_mode": (anchor_mode_list, {
"default": anchor_default,
"tooltip": "色彩精确:色相和饱和度像素级精确,亮度不变 / Color exact: pixel-perfect hue/saturation, luminance unchanged"
}),
"luma_space": (luma_space_list, {
"default": luma_default,
"tooltip": "亮度统计匹配的空间(仅当亮度参与匹配时有效) / Space for luminance statistical matching (only when luminance is matched)"
}),
"align_corners": ("BOOLEAN", {"default": True, "tooltip": "对齐四角,True可减少像素偏移 / Align corners, True reduces pixel shift"}),
"interpolation": (interpolation_list, {"default": "bilinear", "tooltip": "缩放插值方式 / Interpolation method for scaling"}),
"force_match_size": ("BOOLEAN", {"default": False, "tooltip": "强制尺寸一致(不一致时自动缩放) / Force size match (auto-scale if different)"}),
"luma_strength": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "亮度统计匹配强度(仅亮度参与统计匹配时有效) / Strength of luminance statistical matching (only when luminance is matched statistically)"
}),
"strength": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "整图混合强度 / Global blend strength"
}),
"protect_strength": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "编辑区域保留原图的比例 / Proportion of original image retained in edit area"
}),
"feather_radius": ("INT", {
"default": 10,
"min": 0,
"max": 100,
"step": 1,
"display": "slider",
"tooltip": "蒙版羽化半径(像素) / Mask feather radius (pixels)"
}),
"auto_mask": ("BOOLEAN", {"default": False, "tooltip": "启用自动蒙版 / Enable auto mask"}),
"mask_threshold": ("FLOAT", {
"default": 0.1,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "自动蒙版阈值 / Auto mask threshold"
}),
},
"optional": {
"external_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "blend"
CATEGORY = "image/postprocessing"
@staticmethod
def _is_chinese_locale():
"""检测是否为中文界面 / Detect if UI is in Chinese"""
try:
import json
# 方法1: 检查用户目录下的配置文件 / Check user config file
user_dir = os.path.join(folder_paths.base_path, "user", "default")
config_path = os.path.join(user_dir, "comfy.settings.json")
if not os.path.exists(config_path):
# 尝试其他可能的路径 / Try alternative paths
config_path = os.path.join(folder_paths.base_path, "comfy.settings.json")
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# 尝试多种可能的键名 / Try multiple possible key names
lang = None
if "Comfy.Locale" in config:
lang_value = config["Comfy.Locale"]
if isinstance(lang_value, dict):
lang = lang_value.get("value", lang_value.get("selected", "en"))
elif isinstance(lang_value, str):
lang = lang_value
if lang and isinstance(lang, str):
return lang.startswith('zh') or 'chinese' in lang.lower()
# 方法2: 检查环境变量 / Check environment variable
lang_env = os.environ.get('COMFYUI_LANG', os.environ.get('LANG', ''))
if lang_env and (lang_env.startswith('zh') or 'chinese' in lang_env.lower()):
return True
# 默认中文(更符合国内用户使用习惯)/ Default to Chinese (more suitable for Chinese users)
return True
except Exception as e:
print(f"[ColorConsistencyHSL] Language detection error: {e}")
return True # 出错时默认中文 / Default to Chinese on error
def blend(self, reference, target, mode, anchor_mode, luma_space, align_corners, interpolation, force_match_size,
luma_strength, strength, protect_strength, feather_radius, auto_mask, mask_threshold, external_mask=None):
# 参数有效性验证 / Parameter validation
valid_interpolations = ["bilinear", "nearest"]
if interpolation not in valid_interpolations:
print(f"警告: interpolation '{interpolation}' 无效,使用默认 'bilinear' / Warning: interpolation '{interpolation}' invalid, using default 'bilinear'")
interpolation = "bilinear"
# 安全裁剪数值参数 / Safely clip numeric parameters
luma_strength = np.clip(luma_strength, 0.0, 1.0)
protect_strength = np.clip(protect_strength, 0.0, 1.0)
strength = np.clip(strength, 0.0, 1.0)
feather_radius = max(0, int(feather_radius))
mask_threshold = np.clip(mask_threshold, 0.0, 1.0)
# 将中英文模式映射到通道索引列表 / Map Chinese/English mode to channel indices
mode_map = {
"亮度": [0], "luminance": [0],
"色相": [1], "hue": [1],
"饱和度": [2], "saturation": [2],
"亮度+色相": [0, 1], "luminance+hue": [0, 1],
"亮度+饱和度": [0, 2], "luminance+saturation": [0, 2],
"色相+饱和度": [1, 2], "hue+saturation": [1, 2],
"亮度+色相+饱和度": [0, 1, 2], "luminance+hue+saturation": [0, 1, 2],
}
channels_to_match = mode_map.get(mode, [0, 1, 2]) # 默认全通道 / default all channels
# 转换为 (B,H,W,C) float32 0-1 / Convert to (B,H,W,C) float32 0-1
ref_np = self._prepare_image(reference)
tgt_np = self._prepare_image(target)
# 批次兼容 / Batch compatibility
if ref_np.shape[0] == 1 and tgt_np.shape[0] > 1:
ref_np = np.repeat(ref_np, tgt_np.shape[0], axis=0)
elif tgt_np.shape[0] == 1 and ref_np.shape[0] > 1:
tgt_np = np.repeat(tgt_np, ref_np.shape[0], axis=0)
elif ref_np.shape[0] != tgt_np.shape[0]:
raise ValueError(f"批次不匹配: ref {ref_np.shape[0]}, tgt {tgt_np.shape[0]} / Batch mismatch: ref {ref_np.shape[0]}, tgt {tgt_np.shape[0]}")
# 尺寸检查与处理 / Size check and handling
if ref_np.shape[1:3] != tgt_np.shape[1:3]:
if force_match_size:
print(f"警告: force_match_size=True 但尺寸不一致,将自动缩放参考图以匹配目标图。 / Warning: force_match_size=True but sizes differ, auto-scaling reference to match target.")
ref_tensor = torch.from_numpy(ref_np).permute(0, 3, 1, 2)
mode_map_interp = {"bilinear": "bilinear", "nearest": "nearest"}
ref_tensor = torch.nn.functional.interpolate(ref_tensor, size=tgt_np.shape[1:3], mode=mode_map_interp[interpolation], align_corners=align_corners if interpolation == "bilinear" else None)
ref_np = ref_tensor.permute(0, 2, 3, 1).numpy()
# 获取原始蒙版(白色=编辑区域,黑色=稳定区域) / Get raw mask (white=edit area, black=stable area)
raw_mask = self._get_mask(ref_np, tgt_np, auto_mask, mask_threshold, external_mask)
# 羽化蒙版 / Feather mask
mask = self._feather_mask(raw_mask, feather_radius) if raw_mask is not None else None
# 色相/饱和度处理始终在 LAB 空间进行,亮度处理根据 luma_space 选择
# Hue/saturation always processed in LAB space, luminance processing depends on luma_space
ref_lab = self._rgb_to_lab(ref_np)
tgt_lab = self._rgb_to_lab(tgt_np)
# 将 a,b 转换为极坐标:色相 H (角度) 和 饱和度 S (模长)
# Convert a,b to polar: Hue H (angle) and Saturation S (magnitude)
ref_H, ref_S = self._cartesian_to_polar(ref_lab[..., 1], ref_lab[..., 2])
tgt_H, tgt_S = self._cartesian_to_polar(tgt_lab[..., 1], tgt_lab[..., 2])
# 构建 HSL 堆叠:L, H, S(L 可能来自 LAB 或 线性亮度)
# Build HSL stack: L, H, S (L may come from LAB or linear luminance)
if luma_space in ["LAB L通道", "LAB L channel"]:
ref_luma = ref_lab[..., 0]
tgt_luma = tgt_lab[..., 0]
else: # 线性RGB亮度 / linear RGB luminance
ref_lin = self._srgb_to_linear(ref_np)
tgt_lin = self._srgb_to_linear(tgt_np)
ref_luma = 0.2126 * ref_lin[..., 0] + 0.7152 * ref_lin[..., 1] + 0.0722 * ref_lin[..., 2]
tgt_luma = 0.2126 * tgt_lin[..., 0] + 0.7152 * tgt_lin[..., 1] + 0.0722 * tgt_lin[..., 2]
ref_hsl = np.stack([ref_luma, ref_H, ref_S], axis=-1)
tgt_hsl = np.stack([tgt_luma, tgt_H, tgt_S], axis=-1)
# 根据锚定模式生成匹配结果 / Generate matching result based on anchor mode
matched_hsl = tgt_hsl.copy()
if anchor_mode in ["统计匹配", "statistical match"]:
matched_hsl = self._match_channels_hsl(ref_hsl, tgt_hsl, mask, channels_to_match, luma_strength)
elif anchor_mode in ["像素级精确", "pixel-perfect"]:
for ch in channels_to_match:
matched_hsl[..., ch] = ref_hsl[..., ch]
elif anchor_mode in ["亮度统计+色彩精确", "luminance stat + color exact"]:
# 亮度统计匹配,色相/饱和度精确 / luminance statistical match, hue/saturation exact
for ch in channels_to_match:
if ch == 0: # 亮度 / luminance
if mask is None:
ref_mean = ref_hsl[..., ch].mean(axis=(1,2), keepdims=True)
ref_std = ref_hsl[..., ch].std(axis=(1,2), keepdims=True)
tgt_mean = tgt_hsl[..., ch].mean(axis=(1,2), keepdims=True)
tgt_std = tgt_hsl[..., ch].std(axis=(1,2), keepdims=True)
adjusted = (tgt_hsl[..., ch] - tgt_mean.squeeze()) / (tgt_std.squeeze() + 1e-8) * ref_std.squeeze() + ref_mean.squeeze()
matched_hsl[..., ch] = luma_strength * adjusted + (1 - luma_strength) * tgt_hsl[..., ch]
else:
for i in range(ref_hsl.shape[0]):
stable = mask[i] < 0.5
if np.sum(stable) == 0:
stable = np.ones_like(mask[i], dtype=bool)
ref_vals = ref_hsl[i, ..., ch][stable]
tgt_vals = tgt_hsl[i, ..., ch][stable]
ref_mean = ref_vals.mean()
ref_std = ref_vals.std()
tgt_mean = tgt_vals.mean()
tgt_std = tgt_vals.std()
adjusted = (tgt_hsl[i, ..., ch] - tgt_mean) / (tgt_std + 1e-8) * ref_std + ref_mean
matched_hsl[i, ..., ch] = luma_strength * adjusted + (1 - luma_strength) * tgt_hsl[i, ..., ch]
else: # 色相或饱和度 / hue or saturation
matched_hsl[..., ch] = ref_hsl[..., ch]
else: # "色彩精确" / "color exact"
# 只对色相(1)和饱和度(2)进行像素级精确替换 / pixel-perfect replacement only for hue(1) and saturation(2)
for ch in [1, 2]:
if ch in channels_to_match:
matched_hsl[..., ch] = ref_hsl[..., ch]
# 亮度(0)保持原样 / luminance(0) unchanged
# 从匹配后的 HSL 分离出 L、H、S / Extract L, H, S from matched HSL
matched_luma = matched_hsl[..., 0]
matched_H = matched_hsl[..., 1]
matched_S = matched_hsl[..., 2]
# 将极坐标转换回 a,b / Convert polar back to a,b
matched_a, matched_b = self._polar_to_cartesian(matched_H, matched_S)
# 根据 luma_space 重建最终图像 / Reconstruct final image based on luma_space
if luma_space in ["LAB L通道", "LAB L channel"]:
matched_lab = np.stack([matched_luma, matched_a, matched_b], axis=-1)
matched_rgb = self._lab_to_rgb(matched_lab)
else: # 线性RGB亮度 / linear RGB luminance
tgt_lin = self._srgb_to_linear(tgt_np)
eps = 1e-8
scale = matched_luma / (tgt_luma + eps)
scale = scale[..., np.newaxis]
matched_lin = tgt_lin * scale
matched_rgb = self._linear_to_srgb(matched_lin)
# 合成最终结果:背景区域完全使用 matched_rgb,编辑区域根据 protect_strength 混合 matched_rgb 和原图
# Composite final result: background uses matched_rgb fully, edit area blends matched_rgb and original based on protect_strength
if mask is None:
final_rgb = matched_rgb
else:
mask_exp = mask[..., np.newaxis]
edit_blend = protect_strength * tgt_np + (1 - protect_strength) * matched_rgb
final_rgb = (1 - mask_exp) * matched_rgb + mask_exp * edit_blend
# 强度混合(整图混合) / Strength blend (global)
blended_rgb = strength * final_rgb + (1 - strength) * tgt_np
blended_rgb = np.clip(blended_rgb, 0.0, 1.0)
out_tensor = torch.from_numpy(blended_rgb).to(reference.device)
return (out_tensor,)
# ---------- 辅助函数 / Helper functions ----------
def _prepare_image(self, img):
"""将输入图像统一为 (B,H,W,C) float32 0-1 格式 / Normalize input image to (B,H,W,C) float32 0-1"""
if isinstance(img, torch.Tensor):
img = img.cpu().numpy()
img = img.astype(np.float32)
if img.ndim == 4 and img.shape[1] in (1, 3, 4) and img.shape[3] not in (1, 3, 4):
img = np.transpose(img, (0, 2, 3, 1))
elif img.ndim == 3:
img = img[np.newaxis, ...]
if img.max() > 1.0:
img = img / 255.0
return img
def _get_mask(self, ref, tgt, auto_mask, threshold, external_mask):
"""获取蒙版:白色=编辑区域,黑色=稳定区域 / Get mask: white=edit area, black=stable area"""
if external_mask is not None:
mask = self._prepare_mask(external_mask, tgt.shape[1:3])
return mask
elif auto_mask:
diff = np.mean(np.abs(ref - tgt), axis=-1)
mask = (diff > threshold).astype(np.float32)
return mask
else:
return None
def _prepare_mask(self, mask, target_size):
"""将蒙版转为 (B,H,W) float32 0-1 并调整尺寸 / Convert mask to (B,H,W) float32 0-1 and resize"""
if isinstance(mask, torch.Tensor):
mask = mask.cpu().numpy()
mask = mask.astype(np.float32)
if mask.ndim == 4:
if mask.shape[1] == 1:
mask = mask[:, 0, :, :]
elif mask.shape[-1] == 1:
mask = mask[..., 0]
elif mask.ndim == 2:
mask = mask[np.newaxis, ...]
if mask.max() > 1.0:
mask = mask / 255.0
if mask.shape[1:3] != target_size:
mask_tensor = torch.from_numpy(mask).unsqueeze(1)
mask_tensor = torch.nn.functional.interpolate(mask_tensor, size=target_size, mode='bilinear', align_corners=False)
mask = mask_tensor.squeeze(1).numpy()
return mask
def _feather_mask(self, mask, radius):
"""对蒙版进行高斯模糊羽化 / Gaussian blur feathering on mask"""
if radius <= 0 or mask is None:
return mask
B, H, W = mask.shape
feathered = np.zeros_like(mask)
for i in range(B):
feathered[i] = gaussian_filter(mask[i], sigma=radius, mode='reflect')
return feathered
def _cartesian_to_polar(self, a, b):
"""笛卡尔坐标 (a,b) 转极坐标 (角度, 模长) / Cartesian to polar (angle, magnitude)"""
H = np.arctan2(b, a)
S = np.sqrt(a**2 + b**2)
return H, S
def _polar_to_cartesian(self, H, S):
"""极坐标 (角度, 模长) 转笛卡尔坐标 (a,b) / Polar to Cartesian (a,b)"""
a = S * np.cos(H)
b = S * np.sin(H)
return a, b
def _match_channels_hsl(self, ref_hsl, tgt_hsl, mask, channels, luma_strength=1.0):
"""
对指定通道进行统计匹配(带蒙版) / Statistical matching on specified channels (with mask)
channels: list of channel indices to match (0=L, 1=H, 2=S)
"""
result = tgt_hsl.copy()
B, H, W, C = ref_hsl.shape
if mask is None:
for ch in channels:
ref_mean = ref_hsl[..., ch].mean(axis=(1,2), keepdims=True)
ref_std = ref_hsl[..., ch].std(axis=(1,2), keepdims=True)
tgt_mean = tgt_hsl[..., ch].mean(axis=(1,2), keepdims=True)
tgt_std = tgt_hsl[..., ch].std(axis=(1,2), keepdims=True)
adjusted = (tgt_hsl[..., ch] - tgt_mean.squeeze()) / (tgt_std.squeeze() + 1e-8) * ref_std.squeeze() + ref_mean.squeeze()
if ch == 0:
result[..., ch] = luma_strength * adjusted + (1 - luma_strength) * tgt_hsl[..., ch]
else:
result[..., ch] = adjusted
else:
for i in range(B):
stable = mask[i] < 0.5
if np.sum(stable) == 0:
stable = np.ones_like(mask[i], dtype=bool)
for ch in channels:
ref_vals = ref_hsl[i, ..., ch][stable]
tgt_vals = tgt_hsl[i, ..., ch][stable]
ref_mean = ref_vals.mean()
ref_std = ref_vals.std()
tgt_mean = tgt_vals.mean()
tgt_std = tgt_vals.std()
adjusted = (tgt_hsl[i, ..., ch] - tgt_mean) / (tgt_std + 1e-8) * ref_std + ref_mean
if ch == 0:
result[i, ..., ch] = luma_strength * adjusted + (1 - luma_strength) * tgt_hsl[i, ..., ch]
else:
result[i, ..., ch] = adjusted
return result
# ---------- sRGB 与线性 RGB 转换 / sRGB to/from linear RGB ----------
def _srgb_to_linear(self, rgb):
"""sRGB 非线性转线性 RGB (OETF逆变换) / sRGB to linear RGB (inverse OETF)"""
rgb = np.clip(rgb, 0.0, 1.0)
linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
return linear.astype(np.float32)
def _linear_to_srgb(self, linear):
"""线性 RGB 转 sRGB 非线性 (EOTF变换) / Linear RGB to sRGB (EOTF)"""
linear = np.clip(linear, 0.0, 1.0)
srgb = np.where(linear <= 0.0031308, linear * 12.92, 1.055 * (linear ** (1/2.4)) - 0.055)
return srgb.astype(np.float32)
# ---------- LAB 转换 / LAB conversion ----------
def _rgb_to_lab(self, rgb):
"""RGB 转 LAB (近似公式,基于D65) / RGB to LAB (approximate, D65)"""
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
# 转 XYZ / to XYZ
x = 0.412453 * r + 0.357580 * g + 0.180423 * b
y = 0.212671 * r + 0.715160 * g + 0.072169 * b
z = 0.019334 * r + 0.119193 * g + 0.950227 * b
# 归一化 D65 / normalize D65
x /= 0.950456
z /= 1.088754
# 非线性变换 / nonlinear transform
eps = 0.008856
k = 903.3
def f(t):
return np.where(t > eps, t ** (1/3), (k * t + 16) / 116)
fx = f(x)
fy = f(y)
fz = f(z)
L = 116 * fy - 16
a = 500 * (fx - fy)
b_lab = 200 * (fy - fz)
return np.stack([L, a, b_lab], axis=-1)
def _lab_to_rgb(self, lab):
"""LAB 转 RGB / LAB to RGB"""
L, a, b_lab = lab[..., 0], lab[..., 1], lab[..., 2]
fy = (L + 16) / 116
fx = a / 500 + fy
fz = fy - b_lab / 200
eps = 0.008856
k = 903.3
def finv(t):
return np.where(t > eps, t ** 3, (116 * t - 16) / k)
x = finv(fx) * 0.950456
y = finv(fy)
z = finv(fz) * 1.088754
# XYZ 转 RGB / XYZ to RGB
r = 3.240479 * x - 1.537150 * y - 0.498535 * z
g = -0.969256 * x + 1.875992 * y + 0.041556 * z
b = 0.055648 * x - 0.204043 * y + 1.057311 * z
return np.stack([r, g, b], axis=-1)
NODE_CLASS_MAPPINGS = {
"Color Consistency HSL Advanced": ColorConsistencyHSLAdvanced,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Color Consistency HSL Advanced": "色彩亮度一致性混合 (HSL高级版)",
}