Files
2025-11-19 18:38:07 -05:00

406 lines
17 KiB
Python

import torch
import numpy as np
from PIL import Image, ImageEnhance, ImageFilter
import io
class VintageEffect:
"""
ComfyUI node that applies vintage/retro effects through JPG compression,
color grading, film grain, vignette, and blur to emulate old photo aesthetics.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to apply vintage effect to."}),
"quality": ("INT", {
"default": 70,
"min": 0,
"max": 100,
"step": 1,
"tooltip": "JPG compression quality. Lower = more artifacts (0-100)"
}),
"passes": ("INT", {
"default": 1,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of compression passes. More passes = more degradation"
}),
"grain_strength": ("INT", {
"default": 5,
"min": 0,
"max": 100,
"step": 1,
"tooltip": "Amount of film grain. Set to 0 to disable (0-100)"
}),
"vignette_strength": ("INT", {
"default": 0,
"min": 0,
"max": 100,
"step": 1,
"tooltip": "Vignette darkness around edges. Set to 0 to disable (0-100)"
}),
"color_grade": (["None", "Warm", "Cool", "Faded", "Sepia"], {
"default": "Faded",
"tooltip": "Color grading preset"
}),
"color_grade_strength": ("INT", {
"default": 50,
"min": 0,
"max": 100,
"step": 1,
"tooltip": "Strength of color grade effect. 0 = disabled, 100 = full effect (0-100)"
}),
"saturation": ("INT", {
"default": 70,
"min": 0,
"max": 200,
"step": 1,
"tooltip": "Color saturation (0 = grayscale, 100 = original, 200 = max boost)"
}),
"blur_type": (["None", "Gaussian", "Box", "Motion Horizontal", "Motion Vertical", "Radial", "Lens", "Soft Focus"], {
"default": "None",
"tooltip": "Type of blur to apply"
}),
"blur_strength": ("INT", {
"default": 0,
"min": 0,
"max": 100,
"step": 1,
"tooltip": "Strength of blur effect. Set to 0 to disable (0-100)"
}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply_vintage"
CATEGORY = "image/effects"
DESCRIPTION = "Apply vintage/retro effects with JPG compression artifacts, film grain, vignette, blur, and color grading."
def apply_vintage(self, images, quality=70, passes=1, grain_strength=5,
vignette_strength=0, color_grade="Faded", color_grade_strength=100,
saturation=70, blur_type="None", blur_strength=0):
"""
Apply vintage effect to input images.
Args:
images: Tensor of images in ComfyUI format [B, H, W, C]
quality: JPG compression quality (0-100)
passes: Number of compression passes
grain_strength: Strength of grain effect (0-100, 0 = disabled)
vignette_strength: Strength of vignette effect (0-100, 0 = disabled)
color_grade: Color grading preset
color_grade_strength: Strength of color grade (0-100)
saturation: Color saturation (0-200, 100 = original)
blur_type: Type of blur effect
blur_strength: Strength of blur (0-100)
Returns:
Processed images tensor
"""
# Convert 0-100 scale inputs to their actual ranges
quality_actual = max(1, quality) # Ensure minimum quality of 1
saturation_actual = saturation / 100.0 # Convert to 0.0-2.0 range
vignette_actual = vignette_strength / 100.0 # Convert to 0.0-1.0 range
grain_actual = grain_strength / 2.0 # Convert to 0-50 range (divide by 2)
color_grade_actual = color_grade_strength / 100.0 # Convert to 0.0-1.0 range
result_images = []
for image in images:
# Convert from torch tensor [H, W, C] to numpy array
img_np = 255.0 * image.cpu().numpy()
img_np = np.clip(img_np, 0, 255).astype(np.uint8)
img_pil = Image.fromarray(img_np)
# Convert to RGB if necessary
if img_pil.mode in ('RGBA', 'LA', 'P'):
img_pil = img_pil.convert('RGB')
# Apply color grading and saturation
if color_grade != "None" or saturation != 100:
img_pil = self._apply_color_effects(img_pil, color_grade, saturation_actual, color_grade_actual)
# Add film grain before compression
if grain_strength > 0:
img_pil = self._add_film_grain(img_pil, grain_actual)
# Apply blur effect
if blur_type != "None" and blur_strength > 0:
img_pil = self._apply_blur(img_pil, blur_type, blur_strength)
# Apply vignette effect
if vignette_strength > 0:
img_pil = self._apply_vignette(img_pil, vignette_actual)
# Apply JPG compression artifacts
for _ in range(passes):
buffer = io.BytesIO()
img_pil.save(buffer, format='JPEG', quality=quality_actual)
buffer.seek(0)
img_pil = Image.open(buffer)
# Convert back to torch tensor
img_np = np.array(img_pil).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(img_np)
result_images.append(img_tensor)
# Stack all images back into batch
result_batch = torch.stack(result_images)
return (result_batch,)
def _apply_blur(self, img, blur_type, strength):
"""
Apply various types of blur effects.
Args:
img: PIL Image
blur_type: Type of blur to apply
strength: Blur strength (0-100)
"""
if strength == 0 or blur_type == "None":
return img
# Convert strength (0-100) to appropriate radius/parameters
# Different blur types need different scaling
if blur_type == "Gaussian":
# Gaussian blur - smooth, natural blur
radius = strength / 5.0 # 0-20 range
return img.filter(ImageFilter.GaussianBlur(radius=radius))
elif blur_type == "Box":
# Box blur - uniform averaging, creates softer vintage look
radius = max(1, int(strength / 5.0)) # 1-20 range
return img.filter(ImageFilter.BoxBlur(radius=radius))
elif blur_type == "Motion Horizontal":
# Horizontal motion blur - simulates left-right camera movement
img_array = np.array(img, dtype=np.float32)
kernel_size = max(3, int(strength / 3.0)) # 3-33 range
# Create horizontal motion blur kernel
kernel = np.zeros((kernel_size, kernel_size))
kernel[kernel_size // 2, :] = 1.0 / kernel_size
# Apply convolution for each channel
from scipy.ndimage import convolve
for i in range(img_array.shape[2]):
img_array[:, :, i] = convolve(img_array[:, :, i], kernel, mode='reflect')
return Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
elif blur_type == "Motion Vertical":
# Vertical motion blur - simulates up-down camera movement
img_array = np.array(img, dtype=np.float32)
kernel_size = max(3, int(strength / 3.0)) # 3-33 range
# Create vertical motion blur kernel
kernel = np.zeros((kernel_size, kernel_size))
kernel[:, kernel_size // 2] = 1.0 / kernel_size
# Apply convolution for each channel
from scipy.ndimage import convolve
for i in range(img_array.shape[2]):
img_array[:, :, i] = convolve(img_array[:, :, i], kernel, mode='reflect')
return Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
elif blur_type == "Radial":
# Radial blur - blur radiating from center (zoom effect)
img_array = np.array(img, dtype=np.float32)
height, width = img_array.shape[:2]
center_y, center_x = height / 2, width / 2
# Number of samples for radial blur
samples = max(3, int(strength / 10.0)) # 3-10 samples
blur_amount = strength / 500.0 # 0-0.2 range
result = np.zeros_like(img_array)
for i in range(samples):
scale = 1.0 - (i * blur_amount / samples)
# Create scaled coordinates
y_indices = np.arange(height)
x_indices = np.arange(width)
# Scale from center
new_y = ((y_indices - center_y) * scale + center_y).astype(np.float32)
new_x = ((x_indices - center_x) * scale + center_x).astype(np.float32)
# Clip to valid range
new_y = np.clip(new_y, 0, height - 1)
new_x = np.clip(new_x, 0, width - 1)
# Simple nearest neighbor sampling
new_y_int = new_y.astype(np.int32)
new_x_int = new_x.astype(np.int32)
# Add sampled image
result += img_array[new_y_int[:, np.newaxis], new_x_int[np.newaxis, :]]
result /= samples
return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
elif blur_type == "Lens":
# Lens blur - simulates depth of field with sharp center
img_array = np.array(img, dtype=np.float32)
height, width = img_array.shape[:2]
# Create radial distance mask
center_y, center_x = height / 2, width / 2
Y, X = np.ogrid[:height, :width]
dist_from_center = np.sqrt((X - center_x)**2 + (Y - center_y)**2)
max_dist = np.sqrt(center_x**2 + center_y**2)
dist_normalized = dist_from_center / max_dist
# Apply variable blur based on distance from center
# Center stays sharp, edges get progressively blurrier
blur_amount = (dist_normalized ** 1.5) * (strength / 5.0)
# Apply Gaussian blur and blend based on distance
blurred = np.array(img.filter(ImageFilter.GaussianBlur(radius=strength/5.0)))
# Create blend mask
blend_mask = np.clip(blur_amount, 0, 1)
if len(img_array.shape) == 3:
blend_mask = blend_mask[:, :, np.newaxis]
# Blend sharp center with blurred edges
result = img_array * (1 - blend_mask) + blurred * blend_mask
return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
elif blur_type == "Soft Focus":
# Soft focus - dreamy, glowing effect (like old portrait lenses)
# Combines blur with brightness for a soft, ethereal look
radius = strength / 4.0 # 0-25 range
# Create blurred version
blurred = img.filter(ImageFilter.GaussianBlur(radius=radius))
# Blend original with blurred using screen blend mode for glow
img_array = np.array(img, dtype=np.float32)
blurred_array = np.array(blurred, dtype=np.float32)
# Screen blend: 1 - (1-a)*(1-b) scaled to 0-255
blend_strength = min(0.6, strength / 150.0) # 0-0.6 range
# Apply soft blend
result = img_array * (1 - blend_strength) + (
255 - (255 - img_array) * (255 - blurred_array) / 255
) * blend_strength
return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
return img
def _apply_color_effects(self, img, color_grade, saturation, strength=1.0):
"""Apply color grading and saturation adjustments with controllable strength."""
original_img = img.copy()
# Adjust saturation first
if saturation != 1.0:
enhancer = ImageEnhance.Color(img)
img = enhancer.enhance(saturation)
# Apply color grade preset with full strength
if color_grade == "Warm":
# Boost reds/yellows, reduce blues (70s/80s photos)
img_array = np.array(img, dtype=np.float32)
img_array[:,:,0] = np.clip(img_array[:,:,0] * 1.15, 0, 255) # Red
img_array[:,:,1] = np.clip(img_array[:,:,1] * 1.05, 0, 255) # Green
img_array[:,:,2] = np.clip(img_array[:,:,2] * 0.85, 0, 255) # Blue
img = Image.fromarray(img_array.astype(np.uint8))
elif color_grade == "Cool":
# Boost blues, reduce reds (vintage cool tone)
img_array = np.array(img, dtype=np.float32)
img_array[:,:,0] = np.clip(img_array[:,:,0] * 0.85, 0, 255) # Red
img_array[:,:,1] = np.clip(img_array[:,:,1] * 1.0, 0, 255) # Green
img_array[:,:,2] = np.clip(img_array[:,:,2] * 1.2, 0, 255) # Blue
img = Image.fromarray(img_array.astype(np.uint8))
elif color_grade == "Faded":
# Reduce contrast and add slight brightness (old faded photo)
contrast = ImageEnhance.Contrast(img)
img = contrast.enhance(0.75)
brightness = ImageEnhance.Brightness(img)
img = brightness.enhance(1.15)
elif color_grade == "Sepia":
# Classic sepia tone
img_array = np.array(img, dtype=np.float32)
# Sepia matrix transformation
sepia_r = img_array[:,:,0] * 0.393 + img_array[:,:,1] * 0.769 + img_array[:,:,2] * 0.189
sepia_g = img_array[:,:,0] * 0.349 + img_array[:,:,1] * 0.686 + img_array[:,:,2] * 0.168
sepia_b = img_array[:,:,0] * 0.272 + img_array[:,:,1] * 0.534 + img_array[:,:,2] * 0.131
img_array[:,:,0] = np.clip(sepia_r, 0, 255)
img_array[:,:,1] = np.clip(sepia_g, 0, 255)
img_array[:,:,2] = np.clip(sepia_b, 0, 255)
img = Image.fromarray(img_array.astype(np.uint8))
# Blend graded image with original based on strength
if strength < 1.0 and color_grade != "None":
img_array = np.array(img, dtype=np.float32)
original_array = np.array(original_img, dtype=np.float32)
# Blend: result = original * (1 - strength) + graded * strength
blended = original_array * (1.0 - strength) + img_array * strength
img = Image.fromarray(blended.astype(np.uint8))
return img
def _add_film_grain(self, img, strength):
"""Add film grain/noise to the image."""
img_array = np.array(img, dtype=np.float32)
# Generate noise with normal distribution
noise = np.random.normal(0, strength, img_array.shape)
# Add noise and clip values to valid range
img_array = np.clip(img_array + noise, 0, 255)
return Image.fromarray(img_array.astype(np.uint8))
def _apply_vignette(self, img, strength):
"""
Apply vignette effect (darkening around edges).
Args:
img: PIL Image
strength: Vignette strength (0.0-1.0)
"""
img_array = np.array(img, dtype=np.float32)
height, width = img_array.shape[:2]
# Create radial gradient from center
center_y, center_x = height / 2, width / 2
# Create coordinate grids
Y, X = np.ogrid[:height, :width]
# Calculate distance from center, normalized
dist_from_center = np.sqrt((X - center_x)**2 + (Y - center_y)**2)
max_dist = np.sqrt(center_x**2 + center_y**2)
dist_from_center = dist_from_center / max_dist
# Create vignette mask (1.0 at center, darker at edges)
# Using quadratic falloff for smoother effect
vignette_mask = 1.0 - (dist_from_center ** 2) * strength
vignette_mask = np.clip(vignette_mask, 0, 1)
# Expand mask to match image channels
if len(img_array.shape) == 3:
vignette_mask = vignette_mask[:, :, np.newaxis]
# Apply vignette
img_array = img_array * vignette_mask
img_array = np.clip(img_array, 0, 255)
return Image.fromarray(img_array.astype(np.uint8))