Adding the RS Image Adjustments node

This commit is contained in:
Raykosan
2026-07-27 21:28:52 +03:00
parent 7e61039b99
commit 67f0bbc2c0
10 changed files with 2477 additions and 73 deletions
+1
View File
@@ -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}
+653
View File
@@ -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"}
-70
View File
@@ -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
View File
@@ -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__}")
BIN
View File
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`.