Adding the RS Image Adjustments node
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"id":"147928be-ba9f-496d-8b40-629122a4ad37","revision":0,"last_node_id":8,"last_link_id":12,"nodes":[{"id":3,"type":"PreviewImage","pos":[60,130],"size":[340,440],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":12}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.28.0","Node name for S&R":"PreviewImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":4,"type":"LoadImage","pos":[-1030,80],"size":[370,490],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[11]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.28.0","Node name for S&R":"LoadImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI_00014_ (3).png","image"]},{"id":8,"type":"RS_ImageAdjustments","pos":[-520,-30],"size":[500,741],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":11},{"localized_name":"brightness","name":"brightness","type":"INT","widget":{"name":"brightness"},"link":null},{"localized_name":"contrast","name":"contrast","type":"INT","widget":{"name":"contrast"},"link":null},{"localized_name":"hue","name":"hue","type":"INT","widget":{"name":"hue"},"link":null},{"localized_name":"saturation","name":"saturation","type":"INT","widget":{"name":"saturation"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[12]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"1c7a858cafe7f0e2e581e7527169ec105a6ff474","Node name for S&R":"RS_ImageAdjustments","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[0,0,0,0],"color":"#009933"}],"links":[[11,4,0,8,0,"IMAGE"],[12,8,0,3,0,"IMAGE"]],"groups":[],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":1.2100000000000006,"offset":[1353.2705573948465,117.42593749801291]}},"version":0.4}
|
||||
@@ -0,0 +1,653 @@
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
import cv2
|
||||
import json
|
||||
import time
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
from collections import OrderedDict
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
|
||||
class LRUCache:
|
||||
def __init__(self, max_size: int = 50):
|
||||
self.cache = OrderedDict()
|
||||
self.max_size = max_size
|
||||
|
||||
def get(self, key):
|
||||
if key not in self.cache:
|
||||
return None
|
||||
self.cache.move_to_end(key)
|
||||
return self.cache[key]
|
||||
|
||||
def put(self, key, value):
|
||||
if key in self.cache:
|
||||
self.cache.move_to_end(key)
|
||||
self.cache[key] = value
|
||||
if len(self.cache) > self.max_size:
|
||||
self.cache.popitem(last=False)
|
||||
|
||||
def clear(self):
|
||||
self.cache.clear()
|
||||
|
||||
PENDING_ADJUSTMENTS = LRUCache(max_size=50)
|
||||
PREVIEW_CACHE = LRUCache(max_size=20)
|
||||
LUT_CACHE = LRUCache(max_size=10)
|
||||
|
||||
def tensor2pil(tensor):
|
||||
arr = (tensor.cpu().numpy() * 255.0).astype(np.uint8)
|
||||
if arr.ndim == 4:
|
||||
arr = arr[0]
|
||||
if arr.shape[2] >= 4:
|
||||
return Image.fromarray(arr[:, :, :4], 'RGBA').convert('RGB')
|
||||
return Image.fromarray(arr, 'RGB')
|
||||
|
||||
def pil2tensor(pil_img):
|
||||
arr = np.array(pil_img).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(arr).unsqueeze(0)
|
||||
|
||||
def apply_basic_adjustments(img_bgr, brightness, contrast, hue, saturation):
|
||||
if brightness != 0 or contrast != 0:
|
||||
alpha = 1.0 + (contrast / 100.0)
|
||||
beta = brightness
|
||||
|
||||
img_bgr = img_bgr.astype(np.float32)
|
||||
img_bgr = img_bgr * alpha + beta
|
||||
img_bgr = np.clip(img_bgr, 0, 255).astype(np.uint8)
|
||||
|
||||
if hue != 0 or saturation != 0:
|
||||
img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV).astype(np.float32)
|
||||
img_hsv[:, :, 0] = (img_hsv[:, :, 0] + hue) % 180
|
||||
img_hsv[:, :, 1] = np.clip(img_hsv[:, :, 1] * (1.0 + saturation / 100.0), 0, 255)
|
||||
img_bgr = cv2.cvtColor(img_hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
|
||||
|
||||
return img_bgr
|
||||
|
||||
def parse_cube_lut(lut_path):
|
||||
try:
|
||||
mtime = os.path.getmtime(lut_path)
|
||||
cache_key = f"{lut_path}_{int(mtime)}"
|
||||
except:
|
||||
cache_key = lut_path
|
||||
|
||||
cached = LUT_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
with open(lut_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
size = 33
|
||||
lut_data = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('LUT_3D_SIZE'):
|
||||
size = int(line.split()[1])
|
||||
elif line and not line.startswith('#') and not line.startswith('TITLE') and not line.startswith('DOMAIN'):
|
||||
try:
|
||||
lut_data.append([float(x) for x in line.split()])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
lut_array = np.array(lut_data, dtype=np.float32).reshape((size, size, size, 3))
|
||||
|
||||
lut_array = np.transpose(lut_array, (2, 1, 0, 3))
|
||||
|
||||
LUT_CACHE.put(cache_key, lut_array)
|
||||
|
||||
return lut_array
|
||||
|
||||
def apply_lut(img_bgr, lut_path, intensity):
|
||||
if intensity <= 0.01:
|
||||
return img_bgr
|
||||
|
||||
if not os.path.exists(lut_path):
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
full_path = os.path.join(temp_dir, "rs_adjustments", lut_path)
|
||||
if not os.path.exists(full_path):
|
||||
return img_bgr
|
||||
lut_path = full_path
|
||||
|
||||
lut_array = parse_cube_lut(lut_path)
|
||||
size = lut_array.shape[0]
|
||||
|
||||
img_float = img_bgr.astype(np.float32) / 255.0
|
||||
img_rgb = img_float[:, :, ::-1]
|
||||
|
||||
coords = img_rgb * (size - 1)
|
||||
idx = np.floor(coords).astype(np.int32)
|
||||
idx = np.clip(idx, 0, size - 2)
|
||||
frac = coords - idx
|
||||
|
||||
i0, i1 = idx[:, :, 0], idx[:, :, 0] + 1
|
||||
j0, j1 = idx[:, :, 1], idx[:, :, 1] + 1
|
||||
k0, k1 = idx[:, :, 2], idx[:, :, 2] + 1
|
||||
|
||||
f0, f1, f2 = frac[:, :, 0], frac[:, :, 1], frac[:, :, 2]
|
||||
|
||||
w000 = (1 - f0) * (1 - f1) * (1 - f2)
|
||||
w001 = (1 - f0) * (1 - f1) * f2
|
||||
w010 = (1 - f0) * f1 * (1 - f2)
|
||||
w011 = (1 - f0) * f1 * f2
|
||||
w100 = f0 * (1 - f1) * (1 - f2)
|
||||
w101 = f0 * (1 - f1) * f2
|
||||
w110 = f0 * f1 * (1 - f2)
|
||||
w111 = f0 * f1 * f2
|
||||
|
||||
result = np.zeros_like(img_rgb)
|
||||
for c in range(3):
|
||||
lut_c = lut_array[:, :, :, c]
|
||||
result[:, :, c] = (
|
||||
w000 * lut_c[i0, j0, k0] +
|
||||
w001 * lut_c[i0, j0, k1] +
|
||||
w010 * lut_c[i0, j1, k0] +
|
||||
w011 * lut_c[i0, j1, k1] +
|
||||
w100 * lut_c[i1, j0, k0] +
|
||||
w101 * lut_c[i1, j0, k1] +
|
||||
w110 * lut_c[i1, j1, k0] +
|
||||
w111 * lut_c[i1, j1, k1]
|
||||
)
|
||||
|
||||
result = np.clip(result, 0, 1)
|
||||
|
||||
result_bgr = result[:, :, ::-1]
|
||||
|
||||
if intensity < 1.0:
|
||||
result_bgr = img_float * (1.0 - intensity) + result_bgr * intensity
|
||||
return np.clip(result_bgr * 255, 0, 255).astype(np.uint8)
|
||||
|
||||
return (result_bgr * 255).astype(np.uint8)
|
||||
|
||||
def apply_levels(img_bgr, input_black, gamma, input_white):
|
||||
if input_black == 0 and gamma == 1.0 and input_white == 255:
|
||||
return img_bgr
|
||||
|
||||
img_float = img_bgr.astype(np.float32) / 255.0
|
||||
|
||||
in_min = input_black / 255.0
|
||||
in_max = input_white / 255.0
|
||||
in_range = max(0.001, in_max - in_min)
|
||||
|
||||
normalized = (img_float - in_min) / in_range
|
||||
normalized = np.clip(normalized, 0, 1)
|
||||
|
||||
if gamma != 1.0:
|
||||
normalized = np.power(normalized, 1.0 / gamma)
|
||||
|
||||
result = (normalized * 255).astype(np.uint8)
|
||||
return result
|
||||
|
||||
def apply_exposure(img_bgr, exposure, offset):
|
||||
if exposure == 0 and offset == 0:
|
||||
return img_bgr
|
||||
|
||||
img_float = img_bgr.astype(np.float32) / 255.0
|
||||
|
||||
multiplier = 2.0 ** (exposure / 100.0)
|
||||
img_float = img_float * multiplier + (offset / 100.0)
|
||||
|
||||
result = np.clip(img_float * 255, 0, 255).astype(np.uint8)
|
||||
return result
|
||||
|
||||
def apply_color_balance(img_bgr, shadows_cyan_red, shadows_magenta_green, shadows_yellow_blue,
|
||||
midtones_cyan_red, midtones_magenta_green, midtones_yellow_blue,
|
||||
highlights_cyan_red, highlights_magenta_green, highlights_yellow_blue):
|
||||
if all(v == 0 for v in [shadows_cyan_red, shadows_magenta_green, shadows_yellow_blue,
|
||||
midtones_cyan_red, midtones_magenta_green, midtones_yellow_blue,
|
||||
highlights_cyan_red, highlights_magenta_green, highlights_yellow_blue]):
|
||||
return img_bgr
|
||||
|
||||
img_float = img_bgr.astype(np.float32) / 255.0
|
||||
luminance = 0.299 * img_float[:, :, 2] + 0.587 * img_float[:, :, 1] + 0.114 * img_float[:, :, 0]
|
||||
|
||||
shadows_mask = np.clip(1.0 - luminance * 2.0, 0, 1)[:, :, np.newaxis]
|
||||
midtones_mask = np.clip(1.0 - np.abs(luminance - 0.5) * 4.0, 0, 1)[:, :, np.newaxis]
|
||||
highlights_mask = np.clip(luminance * 2.0 - 1.0, 0, 1)[:, :, np.newaxis]
|
||||
|
||||
result = img_float.copy()
|
||||
|
||||
result[:, :, 2] += shadows_mask[:, :, 0] * (shadows_cyan_red / 100.0)
|
||||
result[:, :, 1] += shadows_mask[:, :, 0] * (shadows_magenta_green / 100.0)
|
||||
result[:, :, 0] += shadows_mask[:, :, 0] * (shadows_yellow_blue / 100.0)
|
||||
|
||||
result[:, :, 2] += midtones_mask[:, :, 0] * (midtones_cyan_red / 100.0)
|
||||
result[:, :, 1] += midtones_mask[:, :, 0] * (midtones_magenta_green / 100.0)
|
||||
result[:, :, 0] += midtones_mask[:, :, 0] * (midtones_yellow_blue / 100.0)
|
||||
|
||||
result[:, :, 2] += highlights_mask[:, :, 0] * (highlights_cyan_red / 100.0)
|
||||
result[:, :, 1] += highlights_mask[:, :, 0] * (highlights_magenta_green / 100.0)
|
||||
result[:, :, 0] += highlights_mask[:, :, 0] * (highlights_yellow_blue / 100.0)
|
||||
|
||||
result = np.clip(result * 255, 0, 255).astype(np.uint8)
|
||||
return result
|
||||
|
||||
def apply_black_and_white(img_bgr, red, yellow, green, cyan, blue, magenta):
|
||||
# Всегда преобразуем в ч/б
|
||||
img_float = img_bgr.astype(np.float32) / 255.0
|
||||
img_rgb = img_float[:, :, ::-1]
|
||||
img_hsv = cv2.cvtColor((img_rgb * 255).astype(np.uint8), cv2.COLOR_RGB2HSV)
|
||||
hue = img_hsv[:, :, 0].astype(np.float32) * 2.0 # 0..358
|
||||
|
||||
w_red = red / 100.0
|
||||
w_yellow = yellow / 100.0
|
||||
w_green = green / 100.0
|
||||
w_cyan = cyan / 100.0
|
||||
w_blue = blue / 100.0
|
||||
w_magenta = magenta / 100.0
|
||||
|
||||
# Опорные точки (градусы) и веса (R,G,B)
|
||||
ref_hues = np.array([0, 30, 60, 90, 120, 150], dtype=np.float32)
|
||||
ref_weights = np.array([
|
||||
[w_red, 0, 0],
|
||||
[w_red, w_green, 0],
|
||||
[0, w_green, 0],
|
||||
[0, w_green, w_blue],
|
||||
[0, 0, w_blue],
|
||||
[w_red, 0, w_blue]
|
||||
], dtype=np.float32)
|
||||
|
||||
# Интерполяция с учётом цикличности (подробно в предыдущем ответе)
|
||||
ref_hues_ext = np.array([0, 30, 60, 90, 120, 150, 360])
|
||||
ref_weights_ext = np.concatenate([ref_weights, ref_weights[:1]], axis=0)
|
||||
|
||||
hue_norm = hue % 360
|
||||
idx = np.digitize(hue_norm, ref_hues_ext, right=False) - 1
|
||||
idx = np.clip(idx, 0, 5)
|
||||
|
||||
left_weights = ref_weights[idx]
|
||||
right_weights = ref_weights_ext[idx + 1] # берём следующий элемент из расширенного массива
|
||||
left_angle = ref_hues[idx]
|
||||
right_angle = ref_hues_ext[idx + 1] # аналогично для углов
|
||||
t = (hue_norm - left_angle) / (right_angle - left_angle + 1e-6)
|
||||
weights = left_weights + t[:, :, np.newaxis] * (right_weights - left_weights)
|
||||
|
||||
r = img_rgb[:, :, 0]
|
||||
g = img_rgb[:, :, 1]
|
||||
b = img_rgb[:, :, 2]
|
||||
gray = r * weights[:, :, 0] + g * weights[:, :, 1] + b * weights[:, :, 2]
|
||||
gray = np.clip(gray, 0, 1)
|
||||
|
||||
result = (gray * 255).astype(np.uint8)
|
||||
result_bgr = cv2.cvtColor(result, cv2.COLOR_GRAY2BGR)
|
||||
return result_bgr
|
||||
|
||||
def apply_channel_mixer(img_bgr, red_red, red_green, red_blue,
|
||||
green_red, green_green, green_blue,
|
||||
blue_red, blue_green, blue_blue):
|
||||
if (red_red == 100 and red_green == 0 and red_blue == 0 and
|
||||
green_red == 0 and green_green == 100 and green_blue == 0 and
|
||||
blue_red == 0 and blue_green == 0 and blue_blue == 100):
|
||||
return img_bgr
|
||||
|
||||
img_float = img_bgr.astype(np.float32) / 255.0
|
||||
img_rgb = img_float[:, :, ::-1]
|
||||
|
||||
r, g, b = img_rgb[:, :, 0], img_rgb[:, :, 1], img_rgb[:, :, 2]
|
||||
|
||||
new_r = (r * (red_red / 100.0) + g * (red_green / 100.0) + b * (red_blue / 100.0))
|
||||
new_g = (r * (green_red / 100.0) + g * (green_green / 100.0) + b * (green_blue / 100.0))
|
||||
new_b = (r * (blue_red / 100.0) + g * (blue_green / 100.0) + b * (blue_blue / 100.0))
|
||||
|
||||
result_rgb = np.stack([new_r, new_g, new_b], axis=-1)
|
||||
result_rgb = np.clip(result_rgb, 0, 1)
|
||||
result_bgr = (result_rgb[:, :, ::-1] * 255).astype(np.uint8)
|
||||
|
||||
return result_bgr
|
||||
|
||||
def apply_selective_color(img_bgr, color_name, cyan, magenta, yellow, black):
|
||||
if cyan == 0 and magenta == 0 and yellow == 0 and black == 0:
|
||||
return img_bgr
|
||||
|
||||
img_float = img_bgr.astype(np.float32) / 255.0
|
||||
img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV).astype(np.float32)
|
||||
h = img_hsv[:, :, 0] # 0..179
|
||||
s = img_hsv[:, :, 1] # 0..255
|
||||
v = img_hsv[:, :, 2] # 0..255
|
||||
|
||||
# Нормируем для удобства
|
||||
h_deg = h * 2.0 # 0..358
|
||||
s_norm = s / 255.0
|
||||
v_norm = v / 255.0
|
||||
|
||||
# Определяем центральный угол и сигму для каждого цвета
|
||||
color_configs = {
|
||||
"Reds": (0, 25),
|
||||
"Yellows": (30, 20),
|
||||
"Greens": (60, 20),
|
||||
"Cyans": (90, 20),
|
||||
"Blues": (120, 20),
|
||||
"Magentas": (150, 20),
|
||||
}
|
||||
|
||||
if color_name in color_configs:
|
||||
center, sigma = color_configs[color_name]
|
||||
# Вычисляем угловое расстояние с учётом цикличности (в градусах)
|
||||
diff = np.abs(h_deg - center)
|
||||
diff = np.minimum(diff, 360 - diff)
|
||||
# Гауссова маска
|
||||
mask = np.exp(-(diff ** 2) / (2 * sigma ** 2))
|
||||
# Дополнительно можно учесть насыщенность – для чистых цветов маска выше
|
||||
# Оставим только по оттенку для простоты
|
||||
elif color_name == "Whites":
|
||||
# Белые: высокая яркость >0.8 и низкая насыщенность <0.2
|
||||
mask = (v_norm > 0.8) * (1 - s_norm) # плавно убывает с насыщенностью
|
||||
# Можно сделать гауссову по яркости и насыщенности
|
||||
mask = np.exp(-((v_norm - 1.0)**2) / (2*0.1**2)) * np.exp(-(s_norm**2) / (2*0.2**2))
|
||||
elif color_name == "Neutrals":
|
||||
# Нейтральные: средняя яркость 0.3..0.7, низкая насыщенность
|
||||
mask = np.exp(-((v_norm - 0.5)**2) / (2*0.15**2)) * np.exp(-(s_norm**2) / (2*0.2**2))
|
||||
elif color_name == "Blacks":
|
||||
# Чёрные: низкая яркость <0.3
|
||||
mask = np.exp(-(v_norm**2) / (2*0.15**2)) * np.exp(-(s_norm**2) / (2*0.2**2))
|
||||
else:
|
||||
mask = np.ones_like(h, dtype=np.float32)
|
||||
|
||||
# Преобразуем RGB в CMYK (как и было)
|
||||
height, width = img_float.shape[:2]
|
||||
img_cmyk = np.zeros((height, width, 4), dtype=np.float32)
|
||||
k = 1.0 - np.max(img_float, axis=2)
|
||||
img_cmyk[:, :, 0] = (1.0 - img_float[:, :, 2] - k) / np.maximum(1.0 - k, 0.001)
|
||||
img_cmyk[:, :, 1] = (1.0 - img_float[:, :, 1] - k) / np.maximum(1.0 - k, 0.001)
|
||||
img_cmyk[:, :, 2] = (1.0 - img_float[:, :, 0] - k) / np.maximum(1.0 - k, 0.001)
|
||||
img_cmyk[:, :, 3] = k
|
||||
|
||||
# Применяем поправки с учётом плавной маски
|
||||
img_cmyk[:, :, 0] += mask * (cyan / 100.0)
|
||||
img_cmyk[:, :, 1] += mask * (magenta / 100.0)
|
||||
img_cmyk[:, :, 2] += mask * (yellow / 100.0)
|
||||
img_cmyk[:, :, 3] += mask * (black / 100.0)
|
||||
|
||||
img_cmyk = np.clip(img_cmyk, 0, 1)
|
||||
|
||||
# Обратное преобразование CMYK -> RGB
|
||||
result_rgb = np.zeros((height, width, 3), dtype=np.float32)
|
||||
result_rgb[:, :, 2] = 1.0 - img_cmyk[:, :, 0] - img_cmyk[:, :, 3]
|
||||
result_rgb[:, :, 1] = 1.0 - img_cmyk[:, :, 1] - img_cmyk[:, :, 3]
|
||||
result_rgb[:, :, 0] = 1.0 - img_cmyk[:, :, 2] - img_cmyk[:, :, 3]
|
||||
|
||||
result_rgb = np.clip(result_rgb, 0, 1)
|
||||
result_bgr = (result_rgb[:, :, ::-1] * 255).astype(np.uint8)
|
||||
return result_bgr
|
||||
|
||||
def process_image(img_bgr, adjustments):
|
||||
brightness = adjustments.get("brightness", 0)
|
||||
contrast = adjustments.get("contrast", 0)
|
||||
hue = adjustments.get("hue", 0)
|
||||
saturation = adjustments.get("saturation", 0)
|
||||
|
||||
result_bgr = apply_basic_adjustments(img_bgr, brightness, contrast, hue, saturation)
|
||||
|
||||
lut_path = adjustments.get("lut_path", "")
|
||||
lut_intensity = adjustments.get("lut_intensity", 100)
|
||||
if lut_path:
|
||||
result_bgr = apply_lut(result_bgr, lut_path, lut_intensity / 100.0)
|
||||
|
||||
levels = adjustments.get("levels", {})
|
||||
result_bgr = apply_levels(
|
||||
result_bgr,
|
||||
levels.get("input_black", 0),
|
||||
levels.get("gamma", 1.0),
|
||||
levels.get("input_white", 255)
|
||||
)
|
||||
|
||||
exposure = adjustments.get("exposure", {})
|
||||
result_bgr = apply_exposure(
|
||||
result_bgr,
|
||||
exposure.get("exposure", 0),
|
||||
exposure.get("offset", 0)
|
||||
)
|
||||
|
||||
color_balance = adjustments.get("color_balance", {})
|
||||
result_bgr = apply_color_balance(
|
||||
result_bgr,
|
||||
color_balance.get("shadows_cyan_red", 0),
|
||||
color_balance.get("shadows_magenta_green", 0),
|
||||
color_balance.get("shadows_yellow_blue", 0),
|
||||
color_balance.get("midtones_cyan_red", 0),
|
||||
color_balance.get("midtones_magenta_green", 0),
|
||||
color_balance.get("midtones_yellow_blue", 0),
|
||||
color_balance.get("highlights_cyan_red", 0),
|
||||
color_balance.get("highlights_magenta_green", 0),
|
||||
color_balance.get("highlights_yellow_blue", 0)
|
||||
)
|
||||
|
||||
bw = adjustments.get("black_white", {})
|
||||
bw_enabled = adjustments.get("bw_enabled", False)
|
||||
if bw_enabled:
|
||||
result_bgr = apply_black_and_white(
|
||||
result_bgr,
|
||||
bw.get("bw_red", 40),
|
||||
bw.get("bw_yellow", 60),
|
||||
bw.get("bw_green", 40),
|
||||
bw.get("bw_cyan", 60),
|
||||
bw.get("bw_blue", 20),
|
||||
bw.get("bw_magenta", 80)
|
||||
)
|
||||
|
||||
channel_mixer = adjustments.get("channel_mixer", {})
|
||||
result_bgr = apply_channel_mixer(
|
||||
result_bgr,
|
||||
channel_mixer.get("red_red", 100),
|
||||
channel_mixer.get("red_green", 0),
|
||||
channel_mixer.get("red_blue", 0),
|
||||
channel_mixer.get("green_red", 0),
|
||||
channel_mixer.get("green_green", 100),
|
||||
channel_mixer.get("green_blue", 0),
|
||||
channel_mixer.get("blue_red", 0),
|
||||
channel_mixer.get("blue_green", 0),
|
||||
channel_mixer.get("blue_blue", 100)
|
||||
)
|
||||
|
||||
selective_color = adjustments.get("selective_color", {})
|
||||
result_bgr = apply_selective_color(
|
||||
result_bgr,
|
||||
selective_color.get("color_name", "Reds"),
|
||||
selective_color.get("sc_cyan", 0),
|
||||
selective_color.get("sc_magenta", 0),
|
||||
selective_color.get("sc_yellow", 0),
|
||||
selective_color.get("sc_black", 0)
|
||||
)
|
||||
|
||||
return result_bgr
|
||||
|
||||
def cleanup_temp_files(node_id, temp_dir, exclude_file=None):
|
||||
prefix = f"rs_adjustments_{node_id}_"
|
||||
for f in os.listdir(temp_dir):
|
||||
if f.startswith(prefix) and f != exclude_file:
|
||||
try:
|
||||
os.remove(os.path.join(temp_dir, f))
|
||||
except:
|
||||
pass
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko/rs_adjustments")
|
||||
async def adjustments_handler(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("id"))
|
||||
adjustments = data.get("adjustments", {})
|
||||
|
||||
if node_id is None or adjustments is None:
|
||||
return web.json_response({"error": "Missing data"}, status=400)
|
||||
|
||||
PENDING_ADJUSTMENTS.put(node_id, {
|
||||
"status": "completed",
|
||||
"adjustments": adjustments
|
||||
})
|
||||
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
cleanup_temp_files(node_id, temp_dir)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko/rs_adjustments/cancel")
|
||||
async def cancel_handler(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("node_id"))
|
||||
if node_id:
|
||||
PENDING_ADJUSTMENTS.put(node_id, {"status": "cancelled"})
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
cleanup_temp_files(node_id, temp_dir)
|
||||
return web.json_response({"status": "cancelled"})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko/rs_adjustments/preview")
|
||||
async def preview_handler(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("node_id", "unknown"))
|
||||
adjustments = data.get("adjustments", {})
|
||||
image_file = data.get("image_file", "")
|
||||
|
||||
cache_key = f"{node_id}_{image_file}_{json.dumps(adjustments, sort_keys=True)}"
|
||||
cached = PREVIEW_CACHE.get(cache_key)
|
||||
if cached:
|
||||
return web.json_response(cached)
|
||||
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
img_path = os.path.join(temp_dir, image_file)
|
||||
|
||||
if not os.path.exists(img_path):
|
||||
return web.json_response({"error": "Image not found"}, status=404)
|
||||
|
||||
img_pil = Image.open(img_path).convert('RGB')
|
||||
img_bgr = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
|
||||
|
||||
result_bgr = process_image(img_bgr, adjustments)
|
||||
|
||||
ts = int(time.time() * 1000)
|
||||
filename = f"rs_adjustments_{node_id}_{ts}_preview.png"
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
|
||||
result_pil = Image.fromarray(cv2.cvtColor(result_bgr, cv2.COLOR_BGR2RGB))
|
||||
result_pil.save(filepath)
|
||||
|
||||
response_data = {
|
||||
"preview_file": filename,
|
||||
"timestamp": ts
|
||||
}
|
||||
|
||||
PREVIEW_CACHE.put(cache_key, response_data)
|
||||
|
||||
return web.json_response(response_data)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
class RS_ImageAdjustments:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"brightness": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
|
||||
"contrast": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
|
||||
"hue": ("INT", {"default": 0, "min": -180, "max": 180, "step": 1}),
|
||||
"saturation": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"advanced_params": "STRING"
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "apply_adjustments"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Interactive image adjustments with real-time preview"
|
||||
|
||||
def apply_adjustments(self, image, brightness, contrast, hue, saturation, unique_id=None, advanced_params=None):
|
||||
unique_id = str(unique_id) if unique_id is not None else "unknown"
|
||||
|
||||
if image.numel() == 0:
|
||||
return (image,)
|
||||
|
||||
h, w = image.shape[1], image.shape[2]
|
||||
if h < 8 or w < 8:
|
||||
return (image,)
|
||||
|
||||
PENDING_ADJUSTMENTS.cache.pop(unique_id, None)
|
||||
|
||||
bg_pil = tensor2pil(image)
|
||||
bg_w, bg_h = bg_pil.size
|
||||
|
||||
ts = int(time.time() * 1000)
|
||||
td = folder_paths.get_temp_directory()
|
||||
|
||||
bfn = f"rs_adjustments_{unique_id}_{ts}_bg.png"
|
||||
bg_pil.save(os.path.join(td, bfn))
|
||||
|
||||
cleanup_temp_files(unique_id, td, exclude_file=bfn)
|
||||
|
||||
PENDING_ADJUSTMENTS.put(unique_id, {"status": "pending"})
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
PromptServer.instance.send_sync("rs-adjustments-start", {
|
||||
"id": unique_id,
|
||||
"bg_file": bfn,
|
||||
"bg_width": bg_w,
|
||||
"bg_height": bg_h,
|
||||
"timestamp": ts,
|
||||
"brightness": brightness,
|
||||
"contrast": contrast,
|
||||
"hue": hue,
|
||||
"saturation": saturation,
|
||||
"advanced_params": advanced_params or "{}"
|
||||
})
|
||||
|
||||
print(f"[RS Image Adjustments] Node {unique_id} waiting for user input (Apply/Cancel)...")
|
||||
|
||||
while unique_id not in PENDING_ADJUSTMENTS.cache or PENDING_ADJUSTMENTS.cache[unique_id].get("status") == "pending":
|
||||
if unique_id in PENDING_ADJUSTMENTS.cache and PENDING_ADJUSTMENTS.cache[unique_id].get("status") == "cancelled":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if unique_id in PENDING_ADJUSTMENTS.cache:
|
||||
if PENDING_ADJUSTMENTS.cache[unique_id].get("status") == "cancelled":
|
||||
print(f"[RS Image Adjustments] Cancelled by user for node {unique_id}")
|
||||
PENDING_ADJUSTMENTS.cache.pop(unique_id, None)
|
||||
cleanup_temp_files(unique_id, td)
|
||||
return (image,)
|
||||
|
||||
print(f"[RS Image Adjustments] Input received for node {unique_id}")
|
||||
|
||||
data = PENDING_ADJUSTMENTS.cache.pop(unique_id, None)
|
||||
if data is None:
|
||||
cleanup_temp_files(unique_id, td)
|
||||
return (image,)
|
||||
|
||||
adjustments = data.get("adjustments", {})
|
||||
|
||||
brightness = adjustments.get("brightness", brightness)
|
||||
contrast = adjustments.get("contrast", contrast)
|
||||
hue = adjustments.get("hue", hue)
|
||||
saturation = adjustments.get("saturation", saturation)
|
||||
|
||||
img_bgr = cv2.cvtColor(np.array(bg_pil), cv2.COLOR_RGB2BGR)
|
||||
|
||||
adjustments["brightness"] = brightness
|
||||
adjustments["contrast"] = contrast
|
||||
adjustments["hue"] = hue
|
||||
adjustments["saturation"] = saturation
|
||||
|
||||
result_bgr = process_image(img_bgr, adjustments)
|
||||
|
||||
result_pil = Image.fromarray(cv2.cvtColor(result_bgr, cv2.COLOR_BGR2RGB))
|
||||
|
||||
cleanup_temp_files(unique_id, td)
|
||||
|
||||
return (pil2tensor(result_pil),)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"RS_ImageAdjustments": RS_ImageAdjustments}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"RS_ImageAdjustments": "🦊 RS Image Adjustments"}
|
||||
@@ -1,70 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import torch
|
||||
from comfy.model_patcher import ModelPatcher
|
||||
|
||||
class RS_SaturationSwitch:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"intensity": ("FLOAT", {
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 3.0,
|
||||
"step": 0.05,
|
||||
"display_name": "Intensity",
|
||||
"description": "0.0=Grayscale, 1.0=Original, 1.5=Subtle Boost"
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "apply_saturation"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
DESCRIPTION = "Artifact-free saturation control with proper tensor handling"
|
||||
|
||||
def apply_saturation(self, image, intensity):
|
||||
if intensity == 1.0:
|
||||
return (image,)
|
||||
|
||||
img = image.clone().float()
|
||||
|
||||
r, g, b = img[..., 0], img[..., 1], img[..., 2]
|
||||
l = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
l = l.unsqueeze(-1)
|
||||
|
||||
if intensity < 1.0:
|
||||
result = l + intensity * (img - l)
|
||||
else:
|
||||
boost = 1.0 + (intensity - 1.0) * 0.7
|
||||
delta = img - l
|
||||
result = l + boost * delta
|
||||
|
||||
result = torch.where(
|
||||
(result < 0) | (result > 1),
|
||||
l + 0.9 * delta,
|
||||
result
|
||||
)
|
||||
|
||||
if result.dim() == 3:
|
||||
result = result.unsqueeze(0)
|
||||
|
||||
return (result.clamp(0, 1).to(image.dtype),)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"RS_Saturation": RS_SaturationSwitch}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"RS_Saturation": "🦊 RS Saturation"}
|
||||
+1
-1
@@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'}
|
||||
REQUIRED_MODULES: Set[str] = set()
|
||||
|
||||
PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio"
|
||||
__version__ = "0.31.3"
|
||||
__version__ = "0.32.0"
|
||||
|
||||
def load_modules():
|
||||
print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ComfyUI_RaykoStudio",
|
||||
"version": "0.31.3",
|
||||
"version": "0.32.0",
|
||||
"description": "Set of custom nodes for ComfyUI providing additional image processing capabilities",
|
||||
"author": "Raykosan",
|
||||
"license": "Apache License 2.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI_RaykoStudio"
|
||||
description = "Set of custom nodes for ComfyUI providing additional image processing capabilities"
|
||||
version = "0.31.3"
|
||||
version = "0.32.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"]
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+108
@@ -0,0 +1,108 @@
|
||||
## Анализ корректности расчетов параметров
|
||||
|
||||
После проверки кода выявлены следующие проблемы:
|
||||
|
||||
### 1. **Black & White** (функция `apply_black_and_white`)
|
||||
❌ **Ошибка**: параметры `yellow`, `cyan`, `magenta` не используются.
|
||||
Формула использует только коэффициенты для красного, зелёного и синего каналов:
|
||||
```python
|
||||
gray = r*(red/100) + g*(green/100) + b*(blue/100)
|
||||
```
|
||||
Это игнорирует цветовые тона жёлтого, циана и пурпурного, что не соответствует фотошопу.
|
||||
|
||||
**Правильный подход** (как в Photoshop):
|
||||
Для каждого пикселя определяется его доминирующий цветовой тон, и веса для преобразования в градации серого интерполируются между соседними цветами. Например, можно использовать HSV и взвешенную сумму яркостей по формуле:
|
||||
```
|
||||
gray = R * w_R + G * w_G + B * w_B
|
||||
```
|
||||
где веса зависят от оттенка:
|
||||
- для красных тонов → `w_R = red/100`, `w_G = 0`, `w_B = 0` (но с плавным переходом)
|
||||
- для жёлтых → комбинация `red` и `green`
|
||||
- для зелёных → `green`
|
||||
- для цианов → `green` и `blue`
|
||||
- для синих → `blue`
|
||||
- для пурпурных → `red` и `blue`
|
||||
|
||||
Реализация через интерполяцию по оттенку (hue):
|
||||
```python
|
||||
hue = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2HSV)[:,:,0] # 0..179
|
||||
# задаём веса для каждого цвета в градусах (0=красный, 30=жёлтый, 60=зелёный, 90=циан, 120=синий, 150=пурпурный)
|
||||
color_weights = {
|
||||
0: (red/100, 0, 0),
|
||||
30: (red/100, green/100, 0),
|
||||
60: (0, green/100, 0),
|
||||
90: (0, green/100, blue/100),
|
||||
120: (0, 0, blue/100),
|
||||
150: (red/100, 0, blue/100)
|
||||
}
|
||||
# для каждого пикселя интерполируем веса по hue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. **Selective Color** (функция `apply_selective_color`)
|
||||
⚠️ **Неточность**: маска для выбранного цвета является **бинарной** (по жёстким порогам HSV), тогда как в фотошопе коррекция применяется плавно, пропорционально близости пикселя к целевому цвету.
|
||||
|
||||
**Улучшение**: использовать не бинарную маску, а весовой коэффициент от 0 до 1, вычисляемый на основе расстояния в цветовом пространстве (например, в HSV).
|
||||
Пример для красного:
|
||||
```python
|
||||
hue_dist = np.minimum(np.abs(h - 0), np.abs(h - 180)) # красный около 0 или 180
|
||||
mask = np.exp(-hue_dist**2 / sigma**2) * (s > 30) # гауссово взвешивание
|
||||
```
|
||||
Аналогично для других цветов.
|
||||
|
||||
Формула преобразования CMYK в RGB **верна**:
|
||||
- `K = 1 - max(R,G,B)`
|
||||
- `C = (1 - R - K) / (1 - K + eps)`
|
||||
- `M = (1 - G - K) / (1 - K + eps)`
|
||||
- `Y = (1 - B - K) / (1 - K + eps)`
|
||||
- После добавления поправок обратный переход:
|
||||
- `R = 1 - C - K`
|
||||
- `G = 1 - M - K`
|
||||
- `B = 1 - Y - K`
|
||||
|
||||
---
|
||||
|
||||
### 3. **Levels** (`apply_levels`)
|
||||
✅ **Формула верна**:
|
||||
```
|
||||
normalized = (img - in_min) / (in_max - in_min)
|
||||
result = clamp(normalized^(1/gamma), 0, 1) * 255
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **Exposure** (`apply_exposure`)
|
||||
✅ **Формула верна** (экспозиция в стопах):
|
||||
```
|
||||
multiplier = 2^(exposure/100)
|
||||
result = img * multiplier + offset/100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. **Color Balance** (`apply_color_balance`)
|
||||
✅ **Маски и формулы коррекции** соответствуют распространённому подходу:
|
||||
- Яркость вычисляется по стандартной формуле `0.299*R + 0.587*G + 0.114*B`.
|
||||
- Маски теней, средних тонов и светов строятся через кусочно-линейные функции.
|
||||
- Коррекция добавляется к соответствующим каналам (R,G,B) с учётом знака (положительное → Red/Green/Blue, отрицательное → Cyan/Magenta/Yellow).
|
||||
|
||||
---
|
||||
|
||||
### 6. **Channel Mixer** (`apply_channel_mixer`)
|
||||
✅ **Формула полностью корректна**:
|
||||
```
|
||||
new_R = R * (red_red/100) + G * (red_green/100) + B * (red_blue/100)
|
||||
new_G = R * (green_red/100) + G * (green_green/100) + B * (green_blue/100)
|
||||
new_B = R * (blue_red/100) + G * (blue_green/100) + B * (blue_blue/100)
|
||||
```
|
||||
с последующим клиппированием в [0,1].
|
||||
|
||||
---
|
||||
|
||||
### Рекомендации по исправлению
|
||||
1. **Переписать `apply_black_and_white`** с учётом всех шести параметров (красный, жёлтый, зелёный, циан, синий, пурпурный) с использованием интерполяции по оттенку.
|
||||
2. **Заменить бинарные маски в `apply_selective_color`** на плавные веса (например, через гауссово расстояние по оттенку) для более точного соответствия фотошопу.
|
||||
3. Остальные функции оставить без изменений – они работают корректно.
|
||||
|
||||
При необходимости могу предоставить готовый исправленный код для `apply_black_and_white` и `apply_selective_color`.
|
||||
Reference in New Issue
Block a user