Files
KarmaSwint ac78d4b13e Add Karma Film Emulation node
10 film stock presets with full color science:
- Kodak: Portra 400, Ektar 100, Gold 200, Tri-X 400, Vision3 500T
- Fuji: Velvia 50, Pro 400H, Superia 400
- CineStill 800T (with halation)
- Ilford HP5 Plus
2026-03-29 08:21:48 +00:00

605 lines
21 KiB
Python

"""
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)