481 lines
24 KiB
Python
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高级版)",
|
|
}
|