Merge pull request #1 from KarmaSwint/feat/new-post-processing-nodes
feat: Add Karma Lens FX, Tone Curves, and Film Emulation nodes
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
ComfyUI node for cinematic film stock emulation.
|
||||
|
||||
This module provides a specialized node that emulates the color science and
|
||||
tonal characteristics of iconic analog film stocks. Each preset replicates
|
||||
the unique look of a specific film, including its color response, contrast
|
||||
curve, grain structure, and highlight/shadow behavior.
|
||||
"""
|
||||
|
||||
import torch
|
||||
from PIL import Image, ImageFilter, ImageEnhance
|
||||
import numpy as np
|
||||
|
||||
def tensor2pil(image):
|
||||
"""Convert tensor to PIL image."""
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
def pil2tensor(image):
|
||||
"""Convert PIL image to tensor."""
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
# Film stock preset definitions
|
||||
# Each preset defines the color science of a specific film stock:
|
||||
# temperature - white balance shift (negative=cool, positive=warm)
|
||||
# tint - green/magenta tint shift
|
||||
# contrast - overall contrast adjustment
|
||||
# saturation - color saturation multiplier
|
||||
# shadows_hue - hue of shadow tinting (0-1)
|
||||
# shadows_sat - strength of shadow tinting
|
||||
# highlights_hue - hue of highlight tinting (0-1)
|
||||
# highlights_sat - strength of highlight tinting
|
||||
# gamma - midtone brightness (>1 = brighter, <1 = darker)
|
||||
# black_lift - raise black point for faded look
|
||||
# grain - film grain intensity
|
||||
# grain_size - film grain particle size
|
||||
# halation - highlight bloom intensity
|
||||
FILM_PRESETS = {
|
||||
"Kodak Portra 400": {
|
||||
"description": "Natural skin tones, soft contrast, warm pastels. The gold standard for portrait photography.",
|
||||
"temperature": 0.04,
|
||||
"tint": 0.01,
|
||||
"contrast": 0.95,
|
||||
"saturation": 0.88,
|
||||
"shadows_hue": 0.58,
|
||||
"shadows_sat": 0.06,
|
||||
"highlights_hue": 0.10,
|
||||
"highlights_sat": 0.05,
|
||||
"gamma": 1.05,
|
||||
"black_lift": 0.02,
|
||||
"grain": 0.06,
|
||||
"grain_size": 1.2,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"Kodak Ektar 100": {
|
||||
"description": "Ultra-vivid colors, fine grain, high saturation. Ideal for landscapes and travel.",
|
||||
"temperature": 0.02,
|
||||
"tint": 0.0,
|
||||
"contrast": 1.15,
|
||||
"saturation": 1.25,
|
||||
"shadows_hue": 0.60,
|
||||
"shadows_sat": 0.03,
|
||||
"highlights_hue": 0.08,
|
||||
"highlights_sat": 0.02,
|
||||
"gamma": 0.98,
|
||||
"black_lift": 0.0,
|
||||
"grain": 0.03,
|
||||
"grain_size": 0.8,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"Kodak Gold 200": {
|
||||
"description": "Warm, saturated consumer film. Golden highlights, nostalgic everyday look.",
|
||||
"temperature": 0.06,
|
||||
"tint": 0.01,
|
||||
"contrast": 1.05,
|
||||
"saturation": 1.10,
|
||||
"shadows_hue": 0.08,
|
||||
"shadows_sat": 0.05,
|
||||
"highlights_hue": 0.12,
|
||||
"highlights_sat": 0.08,
|
||||
"gamma": 1.02,
|
||||
"black_lift": 0.01,
|
||||
"grain": 0.08,
|
||||
"grain_size": 1.3,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"Fuji Velvia 50": {
|
||||
"description": "Extreme saturation, deep contrast, vivid greens and blues. Legendary landscape film.",
|
||||
"temperature": -0.02,
|
||||
"tint": 0.0,
|
||||
"contrast": 1.25,
|
||||
"saturation": 1.40,
|
||||
"shadows_hue": 0.55,
|
||||
"shadows_sat": 0.04,
|
||||
"highlights_hue": 0.05,
|
||||
"highlights_sat": 0.02,
|
||||
"gamma": 0.95,
|
||||
"black_lift": 0.0,
|
||||
"grain": 0.02,
|
||||
"grain_size": 0.7,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"Fuji Pro 400H": {
|
||||
"description": "Soft, pastel rendering with subtle greens. Bright, airy skin tones. Wedding favorite.",
|
||||
"temperature": -0.01,
|
||||
"tint": 0.02,
|
||||
"contrast": 0.90,
|
||||
"saturation": 0.85,
|
||||
"shadows_hue": 0.42,
|
||||
"shadows_sat": 0.05,
|
||||
"highlights_hue": 0.15,
|
||||
"highlights_sat": 0.04,
|
||||
"gamma": 1.08,
|
||||
"black_lift": 0.03,
|
||||
"grain": 0.05,
|
||||
"grain_size": 1.0,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"Fuji Superia 400": {
|
||||
"description": "Cool tones, strong greens and blues, punchy contrast. Classic consumer film.",
|
||||
"temperature": -0.03,
|
||||
"tint": 0.01,
|
||||
"contrast": 1.08,
|
||||
"saturation": 1.05,
|
||||
"shadows_hue": 0.55,
|
||||
"shadows_sat": 0.06,
|
||||
"highlights_hue": 0.42,
|
||||
"highlights_sat": 0.04,
|
||||
"gamma": 1.0,
|
||||
"black_lift": 0.01,
|
||||
"grain": 0.09,
|
||||
"grain_size": 1.4,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"CineStill 800T": {
|
||||
"description": "Tungsten-balanced cinema film. Teal shadows, warm highlights, halation around lights.",
|
||||
"temperature": -0.08,
|
||||
"tint": -0.02,
|
||||
"contrast": 1.05,
|
||||
"saturation": 0.95,
|
||||
"shadows_hue": 0.52,
|
||||
"shadows_sat": 0.10,
|
||||
"highlights_hue": 0.08,
|
||||
"highlights_sat": 0.08,
|
||||
"gamma": 1.02,
|
||||
"black_lift": 0.02,
|
||||
"grain": 0.10,
|
||||
"grain_size": 1.5,
|
||||
"halation": 0.15,
|
||||
},
|
||||
"Kodak Tri-X 400": {
|
||||
"description": "Iconic black & white film. Rich tones, beautiful grain, deep blacks. Street photography legend.",
|
||||
"temperature": 0.0,
|
||||
"tint": 0.0,
|
||||
"contrast": 1.20,
|
||||
"saturation": 0.0,
|
||||
"shadows_hue": 0.0,
|
||||
"shadows_sat": 0.0,
|
||||
"highlights_hue": 0.0,
|
||||
"highlights_sat": 0.0,
|
||||
"gamma": 0.98,
|
||||
"black_lift": 0.01,
|
||||
"grain": 0.12,
|
||||
"grain_size": 1.4,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"Ilford HP5 Plus": {
|
||||
"description": "Versatile black & white film. Smooth tones, moderate grain, excellent latitude.",
|
||||
"temperature": 0.0,
|
||||
"tint": 0.0,
|
||||
"contrast": 1.10,
|
||||
"saturation": 0.0,
|
||||
"shadows_hue": 0.0,
|
||||
"shadows_sat": 0.0,
|
||||
"highlights_hue": 0.0,
|
||||
"highlights_sat": 0.0,
|
||||
"gamma": 1.02,
|
||||
"black_lift": 0.02,
|
||||
"grain": 0.08,
|
||||
"grain_size": 1.2,
|
||||
"halation": 0.0,
|
||||
},
|
||||
"Kodak Vision3 500T": {
|
||||
"description": "Professional cinema negative film. Refined color, tungsten-balanced, modern movie look.",
|
||||
"temperature": -0.05,
|
||||
"tint": -0.01,
|
||||
"contrast": 1.0,
|
||||
"saturation": 0.92,
|
||||
"shadows_hue": 0.55,
|
||||
"shadows_sat": 0.07,
|
||||
"highlights_hue": 0.10,
|
||||
"highlights_sat": 0.05,
|
||||
"gamma": 1.03,
|
||||
"black_lift": 0.015,
|
||||
"grain": 0.05,
|
||||
"grain_size": 1.0,
|
||||
"halation": 0.05,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Karma_Film_Emulation:
|
||||
"""
|
||||
Film stock emulation node for one-click cinematic color grading.
|
||||
|
||||
This node applies the color science, tonal characteristics, and texture
|
||||
of iconic analog film stocks to digital images. Each preset is carefully
|
||||
calibrated to replicate the unique rendering of a specific film, including
|
||||
its color response curves, contrast behavior, grain structure, and special
|
||||
characteristics like CineStill's halation.
|
||||
|
||||
The intensity slider allows blending between the original image and the
|
||||
full film emulation, making it easy to dial in exactly the right amount
|
||||
of analog character.
|
||||
|
||||
Supported film stocks:
|
||||
Color Negative:
|
||||
- Kodak Portra 400 (portraits, natural skin tones)
|
||||
- Kodak Ektar 100 (landscapes, vivid color)
|
||||
- Kodak Gold 200 (warm, nostalgic everyday)
|
||||
- Fuji Velvia 50 (extreme saturation, landscapes)
|
||||
- Fuji Pro 400H (soft pastels, weddings)
|
||||
- Fuji Superia 400 (cool tones, consumer)
|
||||
Cinema:
|
||||
- CineStill 800T (tungsten, halation, night photography)
|
||||
- Kodak Vision3 500T (professional cinema)
|
||||
Black & White:
|
||||
- Kodak Tri-X 400 (classic, rich grain)
|
||||
- Ilford HP5 Plus (smooth, versatile)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""
|
||||
Define the input parameters for the film emulation node.
|
||||
|
||||
Returns:
|
||||
Dictionary containing required and optional input specifications
|
||||
"""
|
||||
film_options = list(FILM_PRESETS.keys())
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {"tooltip": "Input image to apply film emulation to"}),
|
||||
"film_stock": (film_options, {
|
||||
"default": "Kodak Portra 400",
|
||||
"tooltip": "Film stock to emulate"
|
||||
}),
|
||||
"intensity": ("FLOAT", {
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 1.5,
|
||||
"step": 0.05,
|
||||
"tooltip": "Blend intensity (0 = original, 1 = full emulation, >1 = exaggerated)"
|
||||
}),
|
||||
"grain_override": ("FLOAT", {
|
||||
"default": -1.0,
|
||||
"min": -1.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Override grain amount (-1 = use film default, 0-1 = custom strength)"
|
||||
}),
|
||||
"seed": ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 2**31 - 1,
|
||||
"tooltip": "Random seed for reproducible grain patterns"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "apply_film_emulation"
|
||||
CATEGORY = "KarmaNodes/Post-Processing"
|
||||
|
||||
def apply_film_emulation(self, image: torch.Tensor, film_stock: str,
|
||||
intensity: float, grain_override: float,
|
||||
seed: int) -> tuple:
|
||||
"""
|
||||
Apply film stock emulation to the input image.
|
||||
|
||||
The emulation pipeline applies effects in the correct order to replicate
|
||||
how analog film actually works: color response first (how the film
|
||||
captures light), then contrast/tone (development characteristics),
|
||||
then physical artifacts (grain, halation).
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
film_stock: Name of the film stock preset to apply
|
||||
intensity: Blend strength (0=original, 1=full, >1=exaggerated)
|
||||
grain_override: Custom grain strength (-1 = use preset default)
|
||||
seed: Random seed for grain reproducibility
|
||||
|
||||
Returns:
|
||||
Tuple containing the processed image tensor
|
||||
"""
|
||||
preset = FILM_PRESETS[film_stock]
|
||||
|
||||
pil_image = tensor2pil(image)
|
||||
original_array = np.array(pil_image, dtype=np.float32) / 255.0
|
||||
img_array = original_array.copy()
|
||||
|
||||
# Step 1: Color temperature and tint
|
||||
if abs(preset["temperature"]) > 0.001 or abs(preset["tint"]) > 0.001:
|
||||
img_array = self.apply_temperature_tint(
|
||||
img_array, preset["temperature"] * intensity, preset["tint"] * intensity
|
||||
)
|
||||
|
||||
# Step 2: Saturation (including B&W conversion for monochrome films)
|
||||
effective_sat = 1.0 + (preset["saturation"] - 1.0) * intensity
|
||||
if effective_sat < 0.01:
|
||||
# Black and white film
|
||||
img_array = self.convert_to_bw(img_array)
|
||||
elif abs(effective_sat - 1.0) > 0.01:
|
||||
img_array = self.adjust_saturation(img_array, effective_sat)
|
||||
|
||||
# Step 3: Gamma / midtone brightness
|
||||
effective_gamma = 1.0 + (preset["gamma"] - 1.0) * intensity
|
||||
if abs(effective_gamma - 1.0) > 0.005:
|
||||
img_array = self.apply_gamma(img_array, effective_gamma)
|
||||
|
||||
# Step 4: Contrast
|
||||
effective_contrast = 1.0 + (preset["contrast"] - 1.0) * intensity
|
||||
if abs(effective_contrast - 1.0) > 0.01:
|
||||
img_array = self.apply_contrast(img_array, effective_contrast)
|
||||
|
||||
# Step 5: Black point lift (faded film look)
|
||||
effective_lift = preset["black_lift"] * intensity
|
||||
if effective_lift > 0.001:
|
||||
img_array = np.clip(img_array * (1.0 - effective_lift) + effective_lift, 0, 1)
|
||||
|
||||
# Step 6: Split toning
|
||||
if preset["shadows_sat"] > 0 and intensity > 0:
|
||||
img_array = self.apply_split_tone(
|
||||
img_array, preset["shadows_hue"],
|
||||
preset["shadows_sat"] * intensity, zone="shadows"
|
||||
)
|
||||
if preset["highlights_sat"] > 0 and intensity > 0:
|
||||
img_array = self.apply_split_tone(
|
||||
img_array, preset["highlights_hue"],
|
||||
preset["highlights_sat"] * intensity, zone="highlights"
|
||||
)
|
||||
|
||||
# Step 7: Halation (for CineStill and cinema stocks)
|
||||
effective_halation = preset["halation"] * intensity
|
||||
if effective_halation > 0.005:
|
||||
img_array = self.apply_halation(img_array, effective_halation)
|
||||
|
||||
# Step 8: Film grain
|
||||
grain_amount = preset["grain"] if grain_override < 0 else grain_override
|
||||
grain_amount *= intensity
|
||||
if grain_amount > 0.005:
|
||||
valid_seed = int(seed) % (2**32)
|
||||
np.random.seed(valid_seed)
|
||||
img_array = self.apply_grain(
|
||||
img_array, grain_amount, preset["grain_size"]
|
||||
)
|
||||
|
||||
result = Image.fromarray(np.clip(img_array * 255, 0, 255).astype(np.uint8))
|
||||
result_tensor = pil2tensor(result)
|
||||
return (result_tensor,)
|
||||
|
||||
@staticmethod
|
||||
def apply_temperature_tint(img: np.ndarray, temperature: float,
|
||||
tint: float) -> np.ndarray:
|
||||
"""
|
||||
Adjust color temperature and green/magenta tint.
|
||||
|
||||
Temperature shifts the blue-yellow axis (positive = warmer/yellow,
|
||||
negative = cooler/blue). Tint shifts the green-magenta axis
|
||||
(positive = more green, negative = more magenta).
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
temperature: Temperature shift (-0.2 to 0.2)
|
||||
tint: Tint shift (-0.1 to 0.1)
|
||||
|
||||
Returns:
|
||||
Color-adjusted image array
|
||||
"""
|
||||
result = img.copy()
|
||||
if len(result.shape) == 3 and result.shape[2] >= 3:
|
||||
# Warm: boost red, reduce blue
|
||||
result[:, :, 0] = np.clip(result[:, :, 0] + temperature * 0.5, 0, 1)
|
||||
result[:, :, 2] = np.clip(result[:, :, 2] - temperature * 0.5, 0, 1)
|
||||
# Tint: adjust green channel
|
||||
result[:, :, 1] = np.clip(result[:, :, 1] + tint * 0.5, 0, 1)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def convert_to_bw(img: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Convert image to black and white using luminance weighting.
|
||||
|
||||
Uses standard BT.601 luminance coefficients for natural-looking
|
||||
monochrome conversion that matches how the human eye perceives
|
||||
brightness.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
|
||||
Returns:
|
||||
Grayscale image array (still 3-channel for compatibility)
|
||||
"""
|
||||
if len(img.shape) == 3 and img.shape[2] >= 3:
|
||||
luminance = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
result = np.stack([luminance, luminance, luminance], axis=2)
|
||||
return result
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def adjust_saturation(img: np.ndarray, factor: float) -> np.ndarray:
|
||||
"""
|
||||
Adjust color saturation.
|
||||
|
||||
Blends between the luminance (grayscale) version and the original
|
||||
image. Factor > 1 increases saturation, < 1 decreases.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
factor: Saturation multiplier
|
||||
|
||||
Returns:
|
||||
Saturation-adjusted image array
|
||||
"""
|
||||
if len(img.shape) == 3 and img.shape[2] >= 3:
|
||||
luminance = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
luminance = luminance[:, :, np.newaxis]
|
||||
result = luminance + (img - luminance) * factor
|
||||
return np.clip(result, 0, 1)
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def apply_gamma(img: np.ndarray, gamma: float) -> np.ndarray:
|
||||
"""
|
||||
Apply gamma correction for midtone brightness adjustment.
|
||||
|
||||
Gamma > 1 brightens midtones (lifts the curve), gamma < 1 darkens
|
||||
them. Black and white points are preserved.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
gamma: Gamma value (typically 0.8-1.2)
|
||||
|
||||
Returns:
|
||||
Gamma-corrected image array
|
||||
"""
|
||||
# Inverse gamma: gamma > 1 should brighten
|
||||
inv_gamma = 1.0 / max(gamma, 0.01)
|
||||
return np.clip(np.power(np.clip(img, 0.0001, 1.0), inv_gamma), 0, 1)
|
||||
|
||||
@staticmethod
|
||||
def apply_contrast(img: np.ndarray, factor: float) -> np.ndarray:
|
||||
"""
|
||||
Adjust image contrast around the midpoint.
|
||||
|
||||
Scales pixel values relative to 0.5 (middle gray). Factor > 1
|
||||
increases contrast, < 1 decreases it.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
factor: Contrast multiplier
|
||||
|
||||
Returns:
|
||||
Contrast-adjusted image array
|
||||
"""
|
||||
return np.clip(0.5 + (img - 0.5) * factor, 0, 1)
|
||||
|
||||
@staticmethod
|
||||
def apply_split_tone(img: np.ndarray, hue: float, strength: float,
|
||||
zone: str = "shadows") -> np.ndarray:
|
||||
"""
|
||||
Apply color tinting to shadows or highlights.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
hue: Color hue (0-1)
|
||||
strength: Tint intensity
|
||||
zone: "shadows" or "highlights"
|
||||
|
||||
Returns:
|
||||
Tinted image array
|
||||
"""
|
||||
if len(img.shape) < 3 or img.shape[2] < 3:
|
||||
return img
|
||||
|
||||
luminance = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
|
||||
if zone == "shadows":
|
||||
mask = np.clip(1.0 - luminance * 2.0, 0, 1)
|
||||
else:
|
||||
mask = np.clip((luminance - 0.5) * 2.0, 0, 1)
|
||||
|
||||
# Hue to RGB
|
||||
hue_360 = hue * 6.0
|
||||
x = 1.0 - abs(hue_360 % 2.0 - 1.0)
|
||||
if hue_360 < 1:
|
||||
color = np.array([1.0, x, 0.0])
|
||||
elif hue_360 < 2:
|
||||
color = np.array([x, 1.0, 0.0])
|
||||
elif hue_360 < 3:
|
||||
color = np.array([0.0, 1.0, x])
|
||||
elif hue_360 < 4:
|
||||
color = np.array([0.0, x, 1.0])
|
||||
elif hue_360 < 5:
|
||||
color = np.array([x, 0.0, 1.0])
|
||||
else:
|
||||
color = np.array([1.0, 0.0, x])
|
||||
|
||||
tint = color[np.newaxis, np.newaxis, :] * np.ones_like(img)
|
||||
blend = mask[:, :, np.newaxis] * strength
|
||||
return np.clip(img * (1.0 - blend) + tint * blend, 0, 1)
|
||||
|
||||
@staticmethod
|
||||
def apply_halation(img: np.ndarray, strength: float) -> np.ndarray:
|
||||
"""
|
||||
Apply halation (highlight bloom) effect.
|
||||
|
||||
Simulates the light-scatter phenomenon where bright highlights bleed
|
||||
into surrounding areas. Characteristic of CineStill and some cinema
|
||||
film stocks where the anti-halation layer is removed.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
strength: Halation intensity
|
||||
|
||||
Returns:
|
||||
Image array with halation applied
|
||||
"""
|
||||
if len(img.shape) == 3 and img.shape[2] >= 3:
|
||||
luminance = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
else:
|
||||
luminance = img[:, :, 0] if len(img.shape) == 3 else img
|
||||
|
||||
# Extract bright areas
|
||||
threshold = 0.75
|
||||
highlights = np.clip((luminance - threshold) / (1.0 - threshold + 0.001), 0, 1)
|
||||
|
||||
if len(img.shape) == 3:
|
||||
highlight_img = img * highlights[:, :, np.newaxis]
|
||||
else:
|
||||
highlight_img = img * highlights
|
||||
|
||||
# Blur highlights using PIL
|
||||
h_pil = Image.fromarray(np.clip(highlight_img * 255, 0, 255).astype(np.uint8))
|
||||
h_blurred = h_pil.filter(ImageFilter.GaussianBlur(radius=15))
|
||||
h_array = np.array(h_blurred, dtype=np.float32) / 255.0
|
||||
|
||||
# Screen blend
|
||||
result = 1.0 - (1.0 - img) * (1.0 - h_array * strength)
|
||||
return np.clip(result, 0, 1)
|
||||
|
||||
@staticmethod
|
||||
def apply_grain(img: np.ndarray, strength: float,
|
||||
grain_size: float) -> np.ndarray:
|
||||
"""
|
||||
Apply realistic film grain with luminance-based intensity.
|
||||
|
||||
Grain is more pronounced in darker areas and less visible in bright
|
||||
highlights, mimicking real analog film behavior. Multiple noise
|
||||
layers at different frequencies create a more organic texture.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
strength: Grain intensity
|
||||
grain_size: Grain particle size
|
||||
|
||||
Returns:
|
||||
Image array with grain applied
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
|
||||
# Calculate luminance for intensity modulation
|
||||
if len(img.shape) == 3 and img.shape[2] >= 3:
|
||||
luminance = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
else:
|
||||
luminance = img[:, :, 0] if len(img.shape) == 3 else img
|
||||
|
||||
# Grain is stronger in shadows, weaker in highlights
|
||||
grain_mask = 1.0 - luminance * 0.5
|
||||
|
||||
# Generate grain at reduced resolution for larger grain size
|
||||
grain_h = max(int(h / grain_size), 1)
|
||||
grain_w = max(int(w / grain_size), 1)
|
||||
|
||||
# Multi-layer grain for organic texture
|
||||
grain = np.random.normal(0, 1, (grain_h, grain_w)).astype(np.float32)
|
||||
|
||||
# Upscale grain to image size if needed
|
||||
if grain_size > 1.0:
|
||||
grain_pil = Image.fromarray(
|
||||
np.clip((grain + 3) / 6 * 255, 0, 255).astype(np.uint8)
|
||||
)
|
||||
grain_pil = grain_pil.resize((w, h), Image.BILINEAR)
|
||||
grain = (np.array(grain_pil, dtype=np.float32) / 255.0 * 6 - 3)
|
||||
|
||||
# Apply grain modulated by luminance
|
||||
grain_final = grain * grain_mask * strength
|
||||
|
||||
if len(img.shape) == 3:
|
||||
grain_final = grain_final[:, :, np.newaxis]
|
||||
|
||||
return np.clip(img + grain_final, 0, 1)
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
ComfyUI node for realistic lens effect simulation.
|
||||
|
||||
This module provides a specialized node that applies authentic optical
|
||||
imperfections to images, simulating the characteristics of real camera lenses.
|
||||
Effects include chromatic aberration, vignetting, barrel/pincushion distortion,
|
||||
and halation (highlight bloom).
|
||||
"""
|
||||
|
||||
import torch
|
||||
from PIL import Image, ImageFilter
|
||||
import numpy as np
|
||||
|
||||
def tensor2pil(image):
|
||||
"""Convert tensor to PIL image."""
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
def pil2tensor(image):
|
||||
"""Convert PIL image to tensor."""
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
class Karma_Lens_FX:
|
||||
"""
|
||||
Professional lens effects node that simulates real-world optical imperfections.
|
||||
|
||||
This node recreates the optical characteristics of physical camera lenses by
|
||||
applying chromatic aberration, vignetting, barrel/pincushion distortion, and
|
||||
halation effects. Each effect can be independently controlled for precise
|
||||
cinematic styling.
|
||||
|
||||
Features:
|
||||
- Chromatic aberration with per-channel offset control
|
||||
- Smooth radial vignette with adjustable falloff
|
||||
- Barrel and pincushion lens distortion
|
||||
- Halation (bloom/glow on highlights) with threshold control
|
||||
- All effects composable and independently adjustable
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""
|
||||
Define the input parameters for the lens effects node.
|
||||
|
||||
Returns:
|
||||
Dictionary containing required and optional input specifications
|
||||
"""
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {"tooltip": "Input image to apply lens effects to"}),
|
||||
"chromatic_aberration": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.5,
|
||||
"tooltip": "Strength of color fringing at image edges (in pixels)"
|
||||
}),
|
||||
"vignette_strength": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Intensity of edge darkening (0 = none, 1 = maximum)"
|
||||
}),
|
||||
"vignette_falloff": ("FLOAT", {
|
||||
"default": 2.0,
|
||||
"min": 0.5,
|
||||
"max": 5.0,
|
||||
"step": 0.1,
|
||||
"tooltip": "Controls how gradually the vignette fades (higher = tighter center)"
|
||||
}),
|
||||
"distortion": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": -1.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Lens distortion: positive = barrel, negative = pincushion"
|
||||
}),
|
||||
"halation_strength": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Intensity of highlight bloom/glow effect"
|
||||
}),
|
||||
"halation_threshold": ("FLOAT", {
|
||||
"default": 0.8,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Brightness threshold above which halation is applied"
|
||||
}),
|
||||
"halation_radius": ("FLOAT", {
|
||||
"default": 10.0,
|
||||
"min": 1.0,
|
||||
"max": 50.0,
|
||||
"step": 1.0,
|
||||
"tooltip": "Spread radius of the halation glow (in pixels)"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "apply_lens_fx"
|
||||
CATEGORY = "KarmaNodes/Post-Processing"
|
||||
|
||||
def apply_lens_fx(self, image: torch.Tensor, chromatic_aberration: float,
|
||||
vignette_strength: float, vignette_falloff: float,
|
||||
distortion: float, halation_strength: float,
|
||||
halation_threshold: float, halation_radius: float) -> tuple:
|
||||
"""
|
||||
Apply lens effects to the input image.
|
||||
|
||||
Effects are applied in optical order: distortion first (physical lens
|
||||
geometry), then chromatic aberration (light separation), halation
|
||||
(light scatter), and finally vignetting (light falloff).
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
chromatic_aberration: Strength of color fringing in pixels
|
||||
vignette_strength: Intensity of edge darkening (0-1)
|
||||
vignette_falloff: Vignette gradient steepness (0.5-5.0)
|
||||
distortion: Barrel (+) or pincushion (-) distortion (-1 to 1)
|
||||
halation_strength: Intensity of highlight bloom (0-1)
|
||||
halation_threshold: Brightness threshold for halation (0-1)
|
||||
halation_radius: Spread of halation glow in pixels
|
||||
|
||||
Returns:
|
||||
Tuple containing the processed image tensor
|
||||
"""
|
||||
pil_image = tensor2pil(image)
|
||||
|
||||
# Apply effects in optical order
|
||||
if abs(distortion) > 0.001:
|
||||
pil_image = self.apply_distortion(pil_image, distortion)
|
||||
|
||||
if chromatic_aberration > 0.1:
|
||||
pil_image = self.apply_chromatic_aberration(pil_image, chromatic_aberration)
|
||||
|
||||
if halation_strength > 0.001:
|
||||
pil_image = self.apply_halation(pil_image, halation_strength,
|
||||
halation_threshold, halation_radius)
|
||||
|
||||
if vignette_strength > 0.001:
|
||||
pil_image = self.apply_vignette(pil_image, vignette_strength,
|
||||
vignette_falloff)
|
||||
|
||||
result_tensor = pil2tensor(pil_image)
|
||||
return (result_tensor,)
|
||||
|
||||
@staticmethod
|
||||
def apply_chromatic_aberration(image: Image.Image, strength: float) -> Image.Image:
|
||||
"""
|
||||
Apply chromatic aberration by offsetting color channels.
|
||||
|
||||
Simulates the failure of a lens to focus all colors to the same point,
|
||||
creating color fringing that increases toward image edges. The red channel
|
||||
is shifted outward and the blue channel inward, mimicking real lateral
|
||||
chromatic aberration.
|
||||
|
||||
Args:
|
||||
image: Input PIL Image
|
||||
strength: Offset strength in pixels
|
||||
|
||||
Returns:
|
||||
Image with chromatic aberration applied
|
||||
"""
|
||||
img_array = np.array(image, dtype=np.float32)
|
||||
h, w = img_array.shape[:2]
|
||||
is_color = len(img_array.shape) == 3 and img_array.shape[2] >= 3
|
||||
|
||||
if not is_color:
|
||||
return image
|
||||
|
||||
# Create coordinate grids for radial-weighted shifts
|
||||
cy, cx = h / 2.0, w / 2.0
|
||||
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
|
||||
# Radial distance from center (normalized to 0-1)
|
||||
max_radius = np.sqrt(cx ** 2 + cy ** 2)
|
||||
dx = (x_coords - cx) / max_radius
|
||||
dy = (y_coords - cy) / max_radius
|
||||
radius = np.sqrt(dx ** 2 + dy ** 2)
|
||||
|
||||
# Scale shift by radial distance (more shift at edges)
|
||||
shift_scale = radius * strength
|
||||
|
||||
# Shift red channel outward, blue channel inward
|
||||
result = img_array.copy()
|
||||
|
||||
# Red channel - shift away from center
|
||||
r_x = x_coords + dx * shift_scale
|
||||
r_y = y_coords + dy * shift_scale
|
||||
r_x = np.clip(r_x, 0, w - 1).astype(np.int32)
|
||||
r_y = np.clip(r_y, 0, h - 1).astype(np.int32)
|
||||
result[:, :, 0] = img_array[r_y, r_x, 0]
|
||||
|
||||
# Blue channel - shift toward center
|
||||
b_x = x_coords - dx * shift_scale
|
||||
b_y = y_coords - dy * shift_scale
|
||||
b_x = np.clip(b_x, 0, w - 1).astype(np.int32)
|
||||
b_y = np.clip(b_y, 0, h - 1).astype(np.int32)
|
||||
result[:, :, 2] = img_array[b_y, b_x, 2]
|
||||
|
||||
return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
|
||||
|
||||
@staticmethod
|
||||
def apply_vignette(image: Image.Image, strength: float,
|
||||
falloff: float) -> Image.Image:
|
||||
"""
|
||||
Apply radial vignette darkening to image edges.
|
||||
|
||||
Creates a smooth radial gradient that darkens the image toward its
|
||||
edges, simulating the natural light falloff of camera lenses. The
|
||||
falloff parameter controls how tight the bright center area is.
|
||||
|
||||
Args:
|
||||
image: Input PIL Image
|
||||
strength: Vignette intensity (0-1)
|
||||
falloff: Gradient steepness (higher = tighter center)
|
||||
|
||||
Returns:
|
||||
Image with vignette applied
|
||||
"""
|
||||
img_array = np.array(image, dtype=np.float32)
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
# Create radial distance map (0 at center, 1 at corners)
|
||||
cy, cx = h / 2.0, w / 2.0
|
||||
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
|
||||
# Normalize to elliptical distance so vignette follows image shape
|
||||
dx = (x_coords - cx) / cx
|
||||
dy = (y_coords - cy) / cy
|
||||
radius = np.sqrt(dx ** 2 + dy ** 2)
|
||||
|
||||
# Apply falloff curve and strength
|
||||
# radius of ~1.0 at edges, ~1.41 at corners
|
||||
vignette_mask = 1.0 - strength * np.clip(radius ** falloff, 0, 1)
|
||||
vignette_mask = np.clip(vignette_mask, 0, 1)
|
||||
|
||||
# Apply to all channels
|
||||
if len(img_array.shape) == 3:
|
||||
vignette_mask = vignette_mask[:, :, np.newaxis]
|
||||
|
||||
result = img_array * vignette_mask
|
||||
return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
|
||||
|
||||
@staticmethod
|
||||
def apply_distortion(image: Image.Image, strength: float) -> Image.Image:
|
||||
"""
|
||||
Apply barrel or pincushion lens distortion.
|
||||
|
||||
Simulates the geometric distortion of real camera lenses. Barrel
|
||||
distortion (positive values) bulges the image center outward, while
|
||||
pincushion distortion (negative values) pinches it inward.
|
||||
|
||||
Args:
|
||||
image: Input PIL Image
|
||||
strength: Distortion amount (positive = barrel, negative = pincushion)
|
||||
|
||||
Returns:
|
||||
Image with lens distortion applied
|
||||
"""
|
||||
img_array = np.array(image, dtype=np.float32)
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
# Create normalized coordinate grid centered at image center
|
||||
cy, cx = h / 2.0, w / 2.0
|
||||
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
|
||||
# Normalize coordinates to -1..1 range
|
||||
nx = (x_coords - cx) / cx
|
||||
ny = (y_coords - cy) / cy
|
||||
|
||||
# Radial distance from center
|
||||
r = np.sqrt(nx ** 2 + ny ** 2)
|
||||
|
||||
# Apply distortion formula: r_distorted = r * (1 + k * r^2)
|
||||
k = strength * 0.5 # Scale for reasonable range
|
||||
r_distorted = r * (1.0 + k * r ** 2)
|
||||
|
||||
# Avoid division by zero
|
||||
safe_r = np.where(r > 0.0001, r, 1.0)
|
||||
scale = r_distorted / safe_r
|
||||
scale = np.where(r > 0.0001, scale, 1.0)
|
||||
|
||||
# Map back to pixel coordinates
|
||||
new_x = cx + nx * scale * cx
|
||||
new_y = cy + ny * scale * cy
|
||||
|
||||
# Clip to valid range
|
||||
new_x = np.clip(new_x, 0, w - 1).astype(np.int32)
|
||||
new_y = np.clip(new_y, 0, h - 1).astype(np.int32)
|
||||
|
||||
# Remap image
|
||||
if len(img_array.shape) == 3:
|
||||
result = img_array[new_y, new_x, :]
|
||||
else:
|
||||
result = img_array[new_y, new_x]
|
||||
|
||||
return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
|
||||
|
||||
@staticmethod
|
||||
def apply_halation(image: Image.Image, strength: float,
|
||||
threshold: float, radius: float) -> Image.Image:
|
||||
"""
|
||||
Apply halation (highlight bloom) effect.
|
||||
|
||||
Simulates the light-scatter phenomenon in analog film where bright
|
||||
highlights bleed into surrounding areas with a soft glow. The effect
|
||||
is isolated to pixels above the brightness threshold and blurred to
|
||||
create a natural bloom.
|
||||
|
||||
Args:
|
||||
image: Input PIL Image
|
||||
strength: Intensity of the glow (0-1)
|
||||
threshold: Brightness threshold for affected pixels (0-1)
|
||||
radius: Blur radius for the glow spread
|
||||
|
||||
Returns:
|
||||
Image with halation applied
|
||||
"""
|
||||
img_array = np.array(image, dtype=np.float32) / 255.0
|
||||
is_color = len(img_array.shape) == 3 and img_array.shape[2] >= 3
|
||||
|
||||
# Calculate luminance
|
||||
if is_color:
|
||||
luminance = 0.299 * img_array[:, :, 0] + 0.587 * img_array[:, :, 1] + 0.114 * img_array[:, :, 2]
|
||||
else:
|
||||
luminance = img_array.copy()
|
||||
|
||||
# Create highlight mask (pixels above threshold)
|
||||
highlight_mask = np.clip((luminance - threshold) / (1.0 - threshold + 0.001), 0, 1)
|
||||
|
||||
# Extract highlight colors and blur them
|
||||
if is_color:
|
||||
highlight_image = img_array * highlight_mask[:, :, np.newaxis]
|
||||
else:
|
||||
highlight_image = img_array * highlight_mask
|
||||
|
||||
# Convert to PIL for Gaussian blur
|
||||
highlight_pil = Image.fromarray(np.clip(highlight_image * 255, 0, 255).astype(np.uint8))
|
||||
blurred_highlight = highlight_pil.filter(ImageFilter.GaussianBlur(radius=radius))
|
||||
blurred_array = np.array(blurred_highlight, dtype=np.float32) / 255.0
|
||||
|
||||
# Blend: screen-like compositing for natural glow
|
||||
# Screen blend: 1 - (1 - a) * (1 - b)
|
||||
result = 1.0 - (1.0 - img_array) * (1.0 - blurred_array * strength)
|
||||
|
||||
return Image.fromarray(np.clip(result * 255, 0, 255).astype(np.uint8))
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
ComfyUI node for professional tone curve adjustments.
|
||||
|
||||
This module provides a specialized node for surgical tonal control,
|
||||
including independent shadow, midtone, and highlight adjustments,
|
||||
split toning, and black/white point management. Designed to complement
|
||||
the Karma Kolors node by offering finer-grained control over the tonal
|
||||
range of an image.
|
||||
"""
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
def tensor2pil(image):
|
||||
"""Convert tensor to PIL image."""
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
def pil2tensor(image):
|
||||
"""Convert PIL image to tensor."""
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
class Karma_Tone_Curves:
|
||||
"""
|
||||
Professional tone curve adjustment node for fine-grained tonal control.
|
||||
|
||||
This node provides independent control over shadows, midtones, and highlights,
|
||||
along with split toning capabilities and black/white point adjustment. It
|
||||
operates like a simplified version of Lightroom's tone curve panel, giving
|
||||
photographers and artists precise control over the luminance distribution
|
||||
of their images.
|
||||
|
||||
Features:
|
||||
- Independent shadow, midtone, and highlight brightness control
|
||||
- Midtone contrast adjustment (S-curve)
|
||||
- Shadow and highlight split toning with hue and saturation
|
||||
- Black point and white point clipping
|
||||
- Smooth tonal transitions with no banding
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""
|
||||
Define the input parameters for the tone curves node.
|
||||
|
||||
Returns:
|
||||
Dictionary containing required and optional input specifications
|
||||
"""
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {"tooltip": "Input image to apply tone adjustments to"}),
|
||||
"shadows": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": -1.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Shadow brightness adjustment (-1 = crush, +1 = lift)"
|
||||
}),
|
||||
"midtones": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": -1.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Midtone brightness adjustment (gamma correction)"
|
||||
}),
|
||||
"highlights": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": -1.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Highlight brightness adjustment (-1 = pull down, +1 = push up)"
|
||||
}),
|
||||
"midtone_contrast": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": -1.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Midtone contrast (S-curve): positive = more contrast, negative = flatter"
|
||||
}),
|
||||
"black_point": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 0.3,
|
||||
"step": 0.005,
|
||||
"tooltip": "Raise the black point to clip shadows (0 = pure black)"
|
||||
}),
|
||||
"white_point": ("FLOAT", {
|
||||
"default": 1.0,
|
||||
"min": 0.7,
|
||||
"max": 1.0,
|
||||
"step": 0.005,
|
||||
"tooltip": "Lower the white point to clip highlights (1 = pure white)"
|
||||
}),
|
||||
"shadow_tint_hue": ("FLOAT", {
|
||||
"default": 0.6,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Hue for shadow split tone (0=red, 0.33=green, 0.6=blue, 0.83=magenta)"
|
||||
}),
|
||||
"shadow_tint_strength": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 0.5,
|
||||
"step": 0.01,
|
||||
"tooltip": "Intensity of shadow color tinting"
|
||||
}),
|
||||
"highlight_tint_hue": ("FLOAT", {
|
||||
"default": 0.1,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Hue for highlight split tone (0=red, 0.1=orange, 0.17=yellow)"
|
||||
}),
|
||||
"highlight_tint_strength": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 0.5,
|
||||
"step": 0.01,
|
||||
"tooltip": "Intensity of highlight color tinting"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "apply_tone_curves"
|
||||
CATEGORY = "KarmaNodes/Post-Processing"
|
||||
|
||||
def apply_tone_curves(self, image: torch.Tensor, shadows: float, midtones: float,
|
||||
highlights: float, midtone_contrast: float,
|
||||
black_point: float, white_point: float,
|
||||
shadow_tint_hue: float, shadow_tint_strength: float,
|
||||
highlight_tint_hue: float, highlight_tint_strength: float) -> tuple:
|
||||
"""
|
||||
Apply tone curve adjustments to the input image.
|
||||
|
||||
Adjustments are applied in a specific order to maintain image quality:
|
||||
black/white point clipping, then shadow/midtone/highlight adjustments,
|
||||
then midtone contrast (S-curve), and finally split toning.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
shadows: Shadow brightness adjustment (-1 to 1)
|
||||
midtones: Midtone brightness / gamma adjustment (-1 to 1)
|
||||
highlights: Highlight brightness adjustment (-1 to 1)
|
||||
midtone_contrast: S-curve contrast for midtones (-1 to 1)
|
||||
black_point: Black point clipping level (0-0.3)
|
||||
white_point: White point clipping level (0.7-1.0)
|
||||
shadow_tint_hue: Hue value for shadow tint (0-1)
|
||||
shadow_tint_strength: Strength of shadow tint (0-0.5)
|
||||
highlight_tint_hue: Hue value for highlight tint (0-1)
|
||||
highlight_tint_strength: Strength of highlight tint (0-0.5)
|
||||
|
||||
Returns:
|
||||
Tuple containing the processed image tensor
|
||||
"""
|
||||
pil_image = tensor2pil(image)
|
||||
img_array = np.array(pil_image, dtype=np.float32) / 255.0
|
||||
is_color = len(img_array.shape) == 3 and img_array.shape[2] >= 3
|
||||
|
||||
# Step 1: Black/white point adjustment
|
||||
if black_point > 0.001 or white_point < 0.999:
|
||||
img_array = self.apply_point_clipping(img_array, black_point, white_point)
|
||||
|
||||
# Step 2: Shadow, midtone, highlight adjustments
|
||||
if abs(shadows) > 0.001 or abs(midtones) > 0.001 or abs(highlights) > 0.001:
|
||||
img_array = self.apply_zone_adjustments(img_array, shadows, midtones, highlights)
|
||||
|
||||
# Step 3: Midtone contrast (S-curve)
|
||||
if abs(midtone_contrast) > 0.001:
|
||||
img_array = self.apply_s_curve(img_array, midtone_contrast)
|
||||
|
||||
# Step 4: Split toning
|
||||
if is_color:
|
||||
if shadow_tint_strength > 0.001:
|
||||
img_array = self.apply_split_tone(img_array, shadow_tint_hue,
|
||||
shadow_tint_strength, zone="shadows")
|
||||
if highlight_tint_strength > 0.001:
|
||||
img_array = self.apply_split_tone(img_array, highlight_tint_hue,
|
||||
highlight_tint_strength, zone="highlights")
|
||||
|
||||
result = Image.fromarray(np.clip(img_array * 255, 0, 255).astype(np.uint8))
|
||||
result_tensor = pil2tensor(result)
|
||||
return (result_tensor,)
|
||||
|
||||
@staticmethod
|
||||
def apply_point_clipping(img: np.ndarray, black_point: float,
|
||||
white_point: float) -> np.ndarray:
|
||||
"""
|
||||
Adjust black and white points by remapping the tonal range.
|
||||
|
||||
This compresses the full tonal range into the window defined by
|
||||
the black and white points, effectively clipping the deepest
|
||||
shadows and brightest highlights.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
black_point: New minimum value (0-0.3)
|
||||
white_point: New maximum value (0.7-1.0)
|
||||
|
||||
Returns:
|
||||
Remapped image array
|
||||
"""
|
||||
range_width = max(white_point - black_point, 0.01)
|
||||
result = (img - black_point) / range_width
|
||||
return np.clip(result, 0, 1)
|
||||
|
||||
@staticmethod
|
||||
def apply_zone_adjustments(img: np.ndarray, shadows: float,
|
||||
midtones: float, highlights: float) -> np.ndarray:
|
||||
"""
|
||||
Apply independent brightness adjustments to shadow, midtone, and highlight zones.
|
||||
|
||||
Uses smooth weighting functions to isolate tonal zones and apply
|
||||
adjustments only to the relevant range. The weighting functions
|
||||
overlap smoothly to prevent visible banding or transitions.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
shadows: Shadow adjustment (-1 to 1)
|
||||
midtones: Midtone adjustment (-1 to 1)
|
||||
highlights: Highlight adjustment (-1 to 1)
|
||||
|
||||
Returns:
|
||||
Adjusted image array
|
||||
"""
|
||||
# Calculate luminance for zone detection
|
||||
if len(img.shape) == 3 and img.shape[2] >= 3:
|
||||
luminance = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
else:
|
||||
luminance = img.copy() if len(img.shape) == 2 else img[:, :, 0]
|
||||
|
||||
# Smooth zone weight functions using cosine-based transitions
|
||||
# Shadows: strongest at 0, fades to 0 by ~0.5
|
||||
shadow_weight = np.clip(1.0 - luminance * 2.5, 0, 1) ** 1.5
|
||||
|
||||
# Highlights: 0 until ~0.5, full strength at 1.0
|
||||
highlight_weight = np.clip((luminance - 0.4) * 2.5, 0, 1) ** 1.5
|
||||
|
||||
# Midtones: bell curve peaking at 0.5
|
||||
midtone_weight = 1.0 - shadow_weight - highlight_weight
|
||||
midtone_weight = np.clip(midtone_weight, 0, 1)
|
||||
|
||||
# Calculate combined adjustment
|
||||
adjustment = (shadow_weight * shadows * 0.3 +
|
||||
midtone_weight * midtones * 0.3 +
|
||||
highlight_weight * highlights * 0.3)
|
||||
|
||||
if len(img.shape) == 3:
|
||||
adjustment = adjustment[:, :, np.newaxis]
|
||||
|
||||
return np.clip(img + adjustment, 0, 1)
|
||||
|
||||
@staticmethod
|
||||
def apply_s_curve(img: np.ndarray, strength: float) -> np.ndarray:
|
||||
"""
|
||||
Apply an S-curve contrast adjustment to the midtones.
|
||||
|
||||
Positive strength increases contrast in the midtone range (steepens
|
||||
the curve around 0.5), while negative strength reduces contrast
|
||||
(flattens the curve). The curve is anchored at the black and white
|
||||
points to avoid clipping.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range
|
||||
strength: S-curve intensity (-1 to 1)
|
||||
|
||||
Returns:
|
||||
Contrast-adjusted image array
|
||||
"""
|
||||
# Use a sigmoid-based S-curve centered at 0.5
|
||||
# The strength parameter controls the steepness
|
||||
contrast_factor = 1.0 + strength * 2.0
|
||||
|
||||
# Apply power-based S-curve: simple and effective
|
||||
if contrast_factor > 0:
|
||||
# Remap around 0.5 pivot point
|
||||
centered = img - 0.5
|
||||
# Apply contrast
|
||||
result = 0.5 + centered * contrast_factor
|
||||
# Smooth clipping using tanh to avoid hard edges
|
||||
result = 0.5 + 0.5 * np.tanh((result - 0.5) * 2.0) / np.tanh(1.0)
|
||||
else:
|
||||
result = img
|
||||
|
||||
return np.clip(result, 0, 1)
|
||||
|
||||
@staticmethod
|
||||
def apply_split_tone(img: np.ndarray, hue: float, strength: float,
|
||||
zone: str = "shadows") -> np.ndarray:
|
||||
"""
|
||||
Apply color tinting to a specific tonal zone.
|
||||
|
||||
Split toning adds a color cast to either shadows or highlights
|
||||
independently. This is a classic photographic technique used to
|
||||
create mood, such as cool blue shadows with warm golden highlights.
|
||||
|
||||
Args:
|
||||
img: Image array in 0-1 float range (must be RGB)
|
||||
hue: Color hue to apply (0-1, where 0=red, 0.33=green, 0.67=blue)
|
||||
strength: Tint intensity (0-0.5)
|
||||
zone: Which zone to tint: "shadows" or "highlights"
|
||||
|
||||
Returns:
|
||||
Tinted image array
|
||||
"""
|
||||
# Calculate luminance
|
||||
luminance = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
|
||||
# Create zone mask
|
||||
if zone == "shadows":
|
||||
mask = np.clip(1.0 - luminance * 2.0, 0, 1)
|
||||
else:
|
||||
mask = np.clip((luminance - 0.5) * 2.0, 0, 1)
|
||||
|
||||
# Convert hue to RGB color
|
||||
# Simple hue-to-RGB conversion (fully saturated colors)
|
||||
hue_360 = hue * 6.0
|
||||
x = 1.0 - abs(hue_360 % 2.0 - 1.0)
|
||||
|
||||
if hue_360 < 1:
|
||||
tint_color = np.array([1.0, x, 0.0])
|
||||
elif hue_360 < 2:
|
||||
tint_color = np.array([x, 1.0, 0.0])
|
||||
elif hue_360 < 3:
|
||||
tint_color = np.array([0.0, 1.0, x])
|
||||
elif hue_360 < 4:
|
||||
tint_color = np.array([0.0, x, 1.0])
|
||||
elif hue_360 < 5:
|
||||
tint_color = np.array([x, 0.0, 1.0])
|
||||
else:
|
||||
tint_color = np.array([1.0, 0.0, x])
|
||||
|
||||
# Apply tint: blend toward tint color based on mask and strength
|
||||
tint_layer = tint_color[np.newaxis, np.newaxis, :] * np.ones_like(img)
|
||||
blend_mask = mask[:, :, np.newaxis] * strength
|
||||
|
||||
result = img * (1.0 - blend_mask) + tint_layer * blend_mask
|
||||
|
||||
return np.clip(result, 0, 1)
|
||||
@@ -9,6 +9,9 @@ ComfyUI-KarmaNodes provides a comprehensive suite of nodes for advanced image ge
|
||||
- **Karma KSampler Cycle**: Specialized KSampler that performs multiple sampling cycles with progressive upscaling between cycles, enabling high-quality, high-resolution image generation with better detail preservation
|
||||
- **Karma Film Grain**: Professional film grain simulation for authentic analog film texture and cinematic aesthetics
|
||||
- **Karma Kolors**: Advanced color grading and correction tools for professional-grade color enhancement
|
||||
- **Karma Lens FX**: Realistic lens effect simulation — chromatic aberration, vignetting, barrel/pincushion distortion, and halation
|
||||
- **Karma Tone Curves**: Surgical tonal control — independent shadow/midtone/highlight adjustments, S-curve contrast, and split toning
|
||||
- **Karma Film Emulation**: One-click cinematic film stock emulation with 10 iconic presets from Kodak, Fuji, CineStill, and Ilford
|
||||
|
||||
## Features
|
||||
|
||||
@@ -35,6 +38,9 @@ ComfyUI-KarmaNodes provides a comprehensive suite of nodes for advanced image ge
|
||||
- **Sharpening Filter**: Optional unsharp mask filter between cycles
|
||||
- **Film Grain Effects**: Realistic analog film grain simulation
|
||||
- **Color Grading**: Professional color correction and enhancement
|
||||
- **Lens Effects**: Chromatic aberration, vignetting, distortion, and halation
|
||||
- **Tone Curves**: Independent shadow/midtone/highlight control with split toning
|
||||
- **Film Emulation**: One-click cinematic looks from iconic film stocks
|
||||
- **Tiled VAE Support**: Handle large images with tiled VAE processing
|
||||
- **Memory Management**: Automatic device management for optimal performance
|
||||
|
||||
@@ -228,6 +234,104 @@ Professional color grading and correction tools for precise image enhancement.
|
||||
- **Landscape Vibrancy**: Increase saturation (+5-10%) with slight contrast boost
|
||||
- **Vintage Look**: Warm temperature (3000K-4000K) with reduced saturation (-5-10%)
|
||||
|
||||
### 🔍 Karma Lens FX
|
||||
|
||||
Simulate real-world optical imperfections for authentic photographic character. Recreates the optical characteristics of physical camera lenses.
|
||||
|
||||
#### Features
|
||||
- **Chromatic Aberration**: Color fringing that increases toward image edges, simulating lateral CA
|
||||
- **Vignetting**: Smooth radial edge darkening with adjustable falloff curve
|
||||
- **Lens Distortion**: Barrel (positive) and pincushion (negative) geometric distortion
|
||||
- **Halation**: Highlight bloom/glow effect that simulates light scatter in film and lenses
|
||||
- **Independent Controls**: Each effect can be dialed in precisely without affecting others
|
||||
|
||||
#### Parameters
|
||||
- **Chromatic Aberration** (0-20): Color fringing strength in pixels
|
||||
- **Vignette Strength** (0-1): Edge darkening intensity
|
||||
- **Vignette Falloff** (0.5-5.0): Gradient steepness (higher = tighter bright center)
|
||||
- **Distortion** (-1 to 1): Barrel (+) or pincushion (-) distortion amount
|
||||
- **Halation Strength** (0-1): Highlight bloom intensity
|
||||
- **Halation Threshold** (0-1): Brightness level above which bloom is applied
|
||||
- **Halation Radius** (1-50): Spread of the bloom glow in pixels
|
||||
|
||||
#### Usage Tips
|
||||
- **Subtle Realism**: Chromatic aberration 1-3, vignette 0.1-0.2 for natural lens character
|
||||
- **Vintage Lens Look**: Chromatic aberration 5-10, vignette 0.3-0.5, distortion 0.1-0.2
|
||||
- **Dreamy/Ethereal**: Halation strength 0.3-0.5 with lower threshold (0.6-0.7)
|
||||
- **Night Photography**: Halation strength 0.2-0.4 with high threshold (0.85-0.95) for light glow
|
||||
- **Cinematic**: Combine vignette (0.2-0.3) with subtle halation (0.1-0.2) for movie look
|
||||
|
||||
### 📈 Karma Tone Curves
|
||||
|
||||
Surgical tonal control with independent shadow, midtone, and highlight adjustments — like a simplified version of Lightroom's tone curve panel.
|
||||
|
||||
#### Features
|
||||
- **Zone-Based Adjustments**: Independent shadow, midtone, and highlight brightness control
|
||||
- **S-Curve Contrast**: Midtone contrast adjustment for punchy or flat looks
|
||||
- **Black/White Point**: Clip shadows and highlights for controlled dynamic range
|
||||
- **Split Toning**: Independently tint shadows and highlights with any hue
|
||||
- **Smooth Transitions**: Cosine-weighted zone blending prevents banding
|
||||
|
||||
#### Parameters
|
||||
- **Shadows** (-1 to 1): Shadow brightness (negative = crush blacks, positive = lift shadows)
|
||||
- **Midtones** (-1 to 1): Midtone brightness (gamma-like adjustment)
|
||||
- **Highlights** (-1 to 1): Highlight brightness (negative = recover, positive = blow out)
|
||||
- **Midtone Contrast** (-1 to 1): S-curve contrast (positive = punchier, negative = flatter)
|
||||
- **Black Point** (0-0.3): Raise the darkest level for a faded/matte look
|
||||
- **White Point** (0.7-1.0): Lower the brightest level to tame highlights
|
||||
- **Shadow Tint Hue** (0-1): Color hue for shadow tinting
|
||||
- **Shadow Tint Strength** (0-0.5): Intensity of shadow color tint
|
||||
- **Highlight Tint Hue** (0-1): Color hue for highlight tinting
|
||||
- **Highlight Tint Strength** (0-0.5): Intensity of highlight color tint
|
||||
|
||||
#### Usage Tips
|
||||
- **Faded Film Look**: Black point 0.05-0.1, shadows +0.1, contrast -0.1
|
||||
- **Punchy Modern**: Midtone contrast +0.3-0.5, shadows -0.1
|
||||
- **Teal & Orange Split Tone**: Shadow tint hue 0.52, highlight tint hue 0.08, both at strength 0.1-0.15
|
||||
- **Matte Finish**: Black point 0.05-0.08, white point 0.92-0.95
|
||||
- **High Key**: Shadows +0.2, midtones +0.1, highlights +0.1
|
||||
|
||||
### 🎞️ Karma Film Emulation
|
||||
|
||||
One-click cinematic film stock emulation with 10 carefully calibrated presets. Each preset replicates the complete color science of an iconic analog film — color response, contrast curve, grain, and special characteristics.
|
||||
|
||||
#### Supported Film Stocks
|
||||
|
||||
**Color Negative:**
|
||||
| Film Stock | Character |
|
||||
|---|---|
|
||||
| **Kodak Portra 400** | Natural skin tones, soft contrast, warm pastels. Portrait gold standard. |
|
||||
| **Kodak Ektar 100** | Ultra-vivid colors, fine grain, high saturation. Landscape and travel. |
|
||||
| **Kodak Gold 200** | Warm, golden highlights, nostalgic everyday look. |
|
||||
| **Fuji Velvia 50** | Extreme saturation, deep contrast, vivid greens/blues. Landscape legend. |
|
||||
| **Fuji Pro 400H** | Soft pastels, airy skin tones, subtle greens. Wedding favorite. |
|
||||
| **Fuji Superia 400** | Cool tones, strong greens/blues, punchy contrast. Classic consumer film. |
|
||||
|
||||
**Cinema:**
|
||||
| Film Stock | Character |
|
||||
|---|---|
|
||||
| **CineStill 800T** | Tungsten-balanced, teal shadows, warm highlights, halation on lights. Night photography icon. |
|
||||
| **Kodak Vision3 500T** | Professional cinema negative. Refined color, modern movie look. |
|
||||
|
||||
**Black & White:**
|
||||
| Film Stock | Character |
|
||||
|---|---|
|
||||
| **Kodak Tri-X 400** | Rich tones, beautiful grain, deep blacks. Street photography legend. |
|
||||
| **Ilford HP5 Plus** | Smooth tones, moderate grain, excellent latitude. Versatile B&W workhorse. |
|
||||
|
||||
#### Parameters
|
||||
- **Film Stock**: Select the film to emulate from the dropdown
|
||||
- **Intensity** (0-1.5): Blend strength (0 = original, 1 = full emulation, >1 = exaggerated)
|
||||
- **Grain Override** (-1 to 1): Override grain amount (-1 = use film's default, 0+ = custom)
|
||||
- **Seed** (0-2³¹-1): Random seed for reproducible grain patterns
|
||||
|
||||
#### Usage Tips
|
||||
- **Natural Look**: Intensity 0.7-0.9 for believable film emulation
|
||||
- **Heavy Stylization**: Intensity 1.2-1.5 for exaggerated film character
|
||||
- **Clean Film Color**: Use grain override of 0 to get film color without grain
|
||||
- **CineStill Night Shots**: Works best with images that have bright light sources (halation glow)
|
||||
- **B&W Conversion**: Tri-X or HP5 presets automatically convert to monochrome with proper grain
|
||||
|
||||
### Post-Processing Workflow Examples
|
||||
|
||||
#### Basic Enhancement Chain
|
||||
@@ -254,6 +358,28 @@ Professional color grading and correction tools for precise image enhancement.
|
||||
Saturation: -3% Seed: 42
|
||||
```
|
||||
|
||||
#### Full Camera Simulation Pipeline
|
||||
```
|
||||
[Generated Image] → [Karma Tone Curves] → [Karma Kolors] → [Karma Lens FX] → [Karma Film Grain] → [Save Image]
|
||||
```
|
||||
Apply tonal adjustments first, then color grading, then optical effects, and finally grain — mimicking the order of a real camera and film pipeline.
|
||||
|
||||
#### One-Click Film Emulation
|
||||
```
|
||||
[Generated Image] → [Karma Film Emulation] → [Save Image]
|
||||
↓
|
||||
Film Stock: CineStill 800T
|
||||
Intensity: 0.85
|
||||
Grain Override: -1 (use default)
|
||||
```
|
||||
For quick cinematic looks without manual tuning.
|
||||
|
||||
#### Stacked Precision + Emulation
|
||||
```
|
||||
[Generated Image] → [Karma Tone Curves] → [Karma Film Emulation] → [Karma Lens FX] → [Save Image]
|
||||
```
|
||||
Use Tone Curves for surgical adjustments, then Film Emulation for the overall look, and Lens FX for optical character.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python**: 3.9+
|
||||
@@ -310,6 +436,9 @@ Professional color grading and correction tools for precise image enhancement.
|
||||
- **Color corrections too harsh**: Use smaller adjustment increments (±1-3%)
|
||||
- **White balance not working**: Ensure temperature value is within 2000K-10000K range
|
||||
- **Scipy warning for film grain**: Install scipy for better grain quality: `pip install scipy`
|
||||
- **Lens FX chromatic aberration not visible**: Increase value above 2-3 for noticeable effect
|
||||
- **Tone Curves banding**: Use smaller adjustment values; the node uses smooth blending to minimize this
|
||||
- **Film Emulation too intense**: Lower the intensity slider to 0.6-0.8 for a more subtle look
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -347,4 +476,4 @@ Thank you for being part of the ComfyUI-KarmaNodes community! 🙏
|
||||
|
||||
---
|
||||
|
||||
**Note**: This node is designed for advanced users familiar with ComfyUI workflows. Basic knowledge of diffusion model sampling is recommended.
|
||||
**Note**: This node is designed for advanced users familiar with ComfyUI workflows. Basic knowledge of diffusion model sampling is recommended.
|
||||
|
||||
+10
-1
@@ -1,15 +1,24 @@
|
||||
from .KarmaKSamplerCycle import Karma_KSampler_Cycle
|
||||
from .KarmaFilmGrain import Karma_Film_Grain
|
||||
from .KarmaKolors import Karma_Kolors
|
||||
from .KarmaLensFX import Karma_Lens_FX
|
||||
from .KarmaToneCurves import Karma_Tone_Curves
|
||||
from .KarmaFilmEmulation import Karma_Film_Emulation
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"Karma-KSampler-Cycle": Karma_KSampler_Cycle,
|
||||
"Karma-Film-Grain": Karma_Film_Grain,
|
||||
"Karma-Kolors": Karma_Kolors,
|
||||
"Karma-Lens-FX": Karma_Lens_FX,
|
||||
"Karma-Tone-Curves": Karma_Tone_Curves,
|
||||
"Karma-Film-Emulation": Karma_Film_Emulation,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Karma-KSampler-Cycle": "Karma KSampler Cycle",
|
||||
"Karma-Film-Grain": "Karma Film Grain",
|
||||
"Karma-Kolors": "Karma Kolors",
|
||||
}
|
||||
"Karma-Lens-FX": "Karma Lens FX",
|
||||
"Karma-Tone-Curves": "Karma Tone Curves",
|
||||
"Karma-Film-Emulation": "Karma Film Emulation",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user