From 8f178eb0fd8658e600b79fd94ad79d08fcb36389 Mon Sep 17 00:00:00 2001 From: KarmaSwint Date: Sun, 29 Mar 2026 08:21:32 +0000 Subject: [PATCH 1/5] Add Karma Lens FX node Simulate real-world optical imperfections: - Chromatic aberration with radial weighting - Smooth radial vignetting with adjustable falloff - Barrel/pincushion lens distortion - Halation (highlight bloom) with threshold control --- KarmaLensFX.py | 352 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 KarmaLensFX.py diff --git a/KarmaLensFX.py b/KarmaLensFX.py new file mode 100644 index 0000000..c752e1c --- /dev/null +++ b/KarmaLensFX.py @@ -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)) From 9b41ec2e26a870fd9ff1f842fe02413ee71c0a43 Mon Sep 17 00:00:00 2001 From: KarmaSwint Date: Sun, 29 Mar 2026 08:21:40 +0000 Subject: [PATCH 2/5] Add Karma Tone Curves node Professional tone curve adjustments: - Independent shadow/midtone/highlight control - S-curve midtone contrast - Black/white point clipping - Split toning for shadows and highlights --- KarmaToneCurves.py | 342 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 KarmaToneCurves.py diff --git a/KarmaToneCurves.py b/KarmaToneCurves.py new file mode 100644 index 0000000..feebf34 --- /dev/null +++ b/KarmaToneCurves.py @@ -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) From ac78d4b13e570b499a7454174963b47e7b51354c Mon Sep 17 00:00:00 2001 From: KarmaSwint Date: Sun, 29 Mar 2026 08:21:48 +0000 Subject: [PATCH 3/5] 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 --- KarmaFilmEmulation.py | 604 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 KarmaFilmEmulation.py diff --git a/KarmaFilmEmulation.py b/KarmaFilmEmulation.py new file mode 100644 index 0000000..6f1a2de --- /dev/null +++ b/KarmaFilmEmulation.py @@ -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) From aa98f89d945973f5ca91309712d32fa8049f9a24 Mon Sep 17 00:00:00 2001 From: KarmaSwint Date: Sun, 29 Mar 2026 08:24:21 +0000 Subject: [PATCH 4/5] Register new nodes in __init__.py Add Karma Lens FX, Karma Tone Curves, and Karma Film Emulation to NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS --- __init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index ec3967e..76d7185 100644 --- a/__init__.py +++ b/__init__.py @@ -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", -} \ No newline at end of file + "Karma-Lens-FX": "Karma Lens FX", + "Karma-Tone-Curves": "Karma Tone Curves", + "Karma-Film-Emulation": "Karma Film Emulation", +} From 39a276ad98ea6028e2bc8819a6f5b383a46388de Mon Sep 17 00:00:00 2001 From: KarmaSwint Date: Sun, 29 Mar 2026 08:24:35 +0000 Subject: [PATCH 5/5] Update README with new node documentation Add comprehensive documentation for: - Karma Lens FX (chromatic aberration, vignette, distortion, halation) - Karma Tone Curves (shadow/midtone/highlight, S-curve, split toning) - Karma Film Emulation (10 film stock presets with usage tips) Also add new workflow examples including full camera simulation pipeline. --- README.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4dd5c7d..fd3ae05 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +**Note**: This node is designed for advanced users familiar with ComfyUI workflows. Basic knowledge of diffusion model sampling is recommended.