406 lines
17 KiB
Python
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)) |