Files
2025-07-26 09:18:11 +05:30

163 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# © 2025 rndnanthu – Licensed under CC BY-NC 4.0 (https://creativecommons.org/licenses/by-nc/4.0/)
import torch
import numpy as np
import os
import glob
BASE_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
LUT_FOLDER = os.path.join(BASE_PATH, "assets", "luts")
class ColorSpaceSimNode:
@classmethod
def INPUT_TYPES(cls):
lut_files = glob.glob(os.path.join(LUT_FOLDER, "*.cube"))
lut_names = [os.path.basename(f) for f in lut_files]
lut_map = dict(zip(lut_names, lut_files))
cls._lut_map = lut_map
return {
"required": {
"image": ("IMAGE",),
"target_profile": (
["Sony S-Log3", "Arri LogC", "Canon C-Log", "Rec.709"],
{"default": "Sony S-Log3"}
),
"simulate_camera_encode": ("BOOLEAN", {"default": False}),
"enable_gamma": ("BOOLEAN", {"default": False}),
"gamma_value": ("FLOAT", {"default": 2.2, "min": 0.1, "max": 5.0, "step": 0.01}),
"enable_lut": ("BOOLEAN", {"default": False}),
"lut_name": (
["None"] + sorted(lut_names) if lut_names else ["None"],
{"default": "None"}
),
"profile_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"enable_tone_map": ("BOOLEAN", {"default": True}),
"contrast_boost": ("FLOAT", {"default": 1.05, "min": 0.5, "max": 2.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("out",)
FUNCTION = "run"
CATEGORY = "rndnanthu/🎨Color Tools"
def run(self, image, target_profile, simulate_camera_encode,
enable_gamma, gamma_value, enable_lut, lut_name,
profile_strength, enable_tone_map, contrast_boost):
img = image # shape (1, H, W, 3)
if img.ndim != 4 or img.shape[-1] != 3:
raise ValueError(f"Expected image shape (1, H, W, 3), got {img.shape}")
img = img.clamp(0, 1).float()
original = img.clone()
if simulate_camera_encode and target_profile != "Rec.709":
img = self.srgb_to_linear_tensor(img)
if target_profile == "Sony S-Log3":
img = self.slog3_curve_tensor(img)
elif target_profile == "Arri LogC":
img = self.logc_curve_tensor(img)
elif target_profile == "Canon C-Log":
img = self.clog_curve_tensor(img)
if enable_tone_map:
img = self.simple_tone_map_tensor(img)
if enable_gamma:
img = torch.pow(img.clamp(0, 1), 1.0 / gamma_value)
if enable_lut and lut_name in self._lut_map:
try:
img = self.apply_cube_lut_gpu(img, self._lut_map[lut_name])
except Exception as e:
print("⚠️ LUT failed:", e)
if contrast_boost != 1.0:
img = self.boost_contrast_tensor(img, contrast_boost)
final = torch.clamp(original * (1.0 - profile_strength) + img * profile_strength, 0, 1)
return (final,)
def srgb_to_linear_tensor(self, img):
return torch.where(
img <= 0.04045, img / 12.92,
torch.pow((img + 0.055) / 1.055, 2.4)
)
def slog3_curve_tensor(self, x):
a, b = 0.432699, 0.037584
return torch.where(
x < 0.011,
0.092864 * x + 0.00404,
a * torch.log10(x + b) + 0.616596
)
def logc_curve_tensor(self, x):
a, b, c, d, e, f, cut = 5.555556, 0.052272, 0.24719, 0.385537, 5.367655, 0.092809, 0.010591
return torch.where(
x > cut,
c * torch.log10(a * x + b) + d,
e * x + f
)
def clog_curve_tensor(self, x):
return torch.log10(500 * x + 1) / np.log10(501)
def simple_tone_map_tensor(self, x):
return x / (x + 0.25)
def boost_contrast_tensor(self, x, boost):
mid = 0.5
return torch.clamp((x - mid) * boost + mid, 0, 1)
def load_cube_lut_torch(self, path):
with open(path, 'r') as f:
lines = [l.strip() for l in f if l.strip() and not l.startswith('#')]
size_line = [l for l in lines if l.startswith('LUT_3D_SIZE')]
if not size_line:
raise ValueError("Missing LUT_3D_SIZE in cube file")
size = int(size_line[0].split()[1])
data = [list(map(float, l.split())) for l in lines if len(l.split()) == 3]
if len(data) != size ** 3:
raise ValueError(f"Invalid LUT size: expected {size**3}, got {len(data)}")
lut_np = np.array(data, dtype=np.float32).reshape((size, size, size, 3)) # (R, G, B, 3)
lut_torch = torch.from_numpy(lut_np).permute(3, 0, 1, 2).contiguous() # (3, R, G, B)
return lut_torch, size
def apply_cube_lut_gpu(self, img, path):
lut, size = self.load_cube_lut_torch(path)
if not torch.is_tensor(img) or img.ndim != 4 or img.shape[-1] != 3:
raise ValueError("Expected image shape (1, H, W, 3)")
device = img.device
B, H, W, C = img.shape
# normalize image to [-1, 1]
norm = img * 2 - 1
grid = norm.view(B, H, W, 1, C) # (B, H, W, 1, 3)
lut = lut.to(device).unsqueeze(0) # shape: (1, 3, size, size, size)
sampled = torch.nn.functional.grid_sample(
lut, grid, mode='bilinear', align_corners=True
) # returns (1, 3, H, W, 1)
out = sampled.squeeze(-1).permute(0, 2, 3, 1) # -> (1, H, W, 3)
return out.clamp(0, 1)
NODE_CLASS_MAPPINGS = {
"ColorSpaceSim": ColorSpaceSimNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ColorSpaceSim": "🎥 Color Space Simulator"
}