Files
KarmaSwint 8f178eb0fd 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
2026-03-29 08:21:32 +00:00

353 lines
13 KiB
Python

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