395 lines
19 KiB
Python
395 lines
19 KiB
Python
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageChops
|
|
import math
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Tuple, Optional
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class LightingEffectConfig:
|
|
"""Configuration for lighting effects"""
|
|
enable_lighting: bool = True
|
|
light_intensity: float = 0.5
|
|
light_direction_x: int = 0
|
|
light_direction_y: int = -100
|
|
light_radius: int = 150
|
|
light_falloff: float = 1.0
|
|
light_color: Tuple[int, int, int] = (255, 255, 220) # Slightly warm white
|
|
light_from_behind: bool = False # Toggle between front and behind lighting
|
|
|
|
# Enhanced lighting parameters
|
|
enable_normal_mapping: bool = False # Use normal mapping for 3D effect
|
|
enable_specular: bool = False # Add specular highlights
|
|
specular_intensity: float = 0.3 # Strength of specular highlights
|
|
specular_shininess: int = 32 # Shininess factor (higher = sharper highlights)
|
|
ambient_light: float = 0.2 # Ambient light factor
|
|
light_source_height: int = 200 # Height of light source for 3D effect
|
|
kelvin_temperature: int = 6500 # Color temperature in Kelvin
|
|
|
|
@dataclass
|
|
class ShadowEffectConfig:
|
|
"""Configuration for shadow effects"""
|
|
enable_shadow: bool = True
|
|
shadow_opacity: float = 0.5
|
|
shadow_blur: int = 10
|
|
shadow_direction_x: int = 5
|
|
shadow_direction_y: int = 5
|
|
shadow_color: Tuple[int, int, int] = (0, 0, 0) # Black
|
|
shadow_expansion: int = 0
|
|
|
|
# Enhanced shadow parameters
|
|
perspective_shadow: bool = False # Use perspective projection for shadow
|
|
light_source_height: int = 200 # Height of light source (affects shadow perspective)
|
|
distance_fade: bool = False # Fade shadow with distance
|
|
fade_distance: int = 100 # Distance at which shadow begins to fade
|
|
soft_edges: bool = True # Use soft edges for shadow
|
|
|
|
class LightShadowProcessor:
|
|
"""Utility class for applying light and shadow effects to images"""
|
|
|
|
@staticmethod
|
|
def generate_simple_normal_map(alpha_channel):
|
|
"""Generate a simple normal map from the alpha channel edges"""
|
|
try:
|
|
# Convert to float and normalize
|
|
alpha_float = alpha_channel.astype(np.float32) / 255.0
|
|
|
|
# Apply Sobel filter to get gradients
|
|
from scipy.ndimage import sobel
|
|
dx = sobel(alpha_float, axis=1) # Horizontal gradient
|
|
dy = sobel(alpha_float, axis=0) # Vertical gradient
|
|
dz = np.ones_like(dx) * 0.1 # Small z-component for flatness
|
|
|
|
# Normalize to get unit vectors
|
|
# Add small epsilon to avoid division by zero
|
|
norm = np.sqrt(dx**2 + dy**2 + dz**2) + 1e-8
|
|
normal_map = np.stack([dx/norm, dy/norm, dz/norm], axis=-1)
|
|
|
|
return normal_map
|
|
except Exception as e:
|
|
logger.error(f"Error generating normal map: {str(e)}")
|
|
# Return a flat normal map in case of error
|
|
height, width = alpha_channel.shape[:2]
|
|
return np.zeros((height, width, 3), dtype=np.float32)
|
|
|
|
@staticmethod
|
|
def kelvin_to_rgb(kelvin):
|
|
"""Convert color temperature in Kelvin to RGB values"""
|
|
try:
|
|
# Clamp temperature to valid range
|
|
temperature = max(1000, min(40000, kelvin)) / 100.0
|
|
|
|
# Calculate red
|
|
if temperature <= 66:
|
|
red = 255
|
|
else:
|
|
red = temperature - 60
|
|
red = 329.698727446 * (red ** -0.1332047592)
|
|
red = max(0, min(255, red))
|
|
|
|
# Calculate green
|
|
if temperature <= 66:
|
|
green = temperature
|
|
green = 99.4708025861 * math.log(green) - 161.1195681661
|
|
else:
|
|
green = temperature - 60
|
|
green = 288.1221695283 * (green ** -0.0755148492)
|
|
green = max(0, min(255, green))
|
|
|
|
# Calculate blue
|
|
if temperature >= 66:
|
|
blue = 255
|
|
elif temperature <= 19:
|
|
blue = 0
|
|
else:
|
|
blue = temperature - 10
|
|
blue = 138.5177312231 * math.log(blue) - 305.0447927307
|
|
blue = max(0, min(255, blue))
|
|
|
|
return (int(red), int(green), int(blue))
|
|
except Exception as e:
|
|
logger.error(f"Error converting Kelvin to RGB: {str(e)}")
|
|
return (255, 255, 255) # Return white in case of error
|
|
|
|
@staticmethod
|
|
def apply_lighting_effect(image: Image.Image, config: LightingEffectConfig) -> Image.Image:
|
|
"""Apply realistic directional lighting effect to an image with normal mapping and specular"""
|
|
try:
|
|
if not config.enable_lighting or config.light_intensity <= 0:
|
|
return image
|
|
|
|
# Ensure we're working with RGBA
|
|
if image.mode != 'RGBA':
|
|
image = image.convert('RGBA')
|
|
|
|
# Get image data
|
|
img_array = np.array(image).astype(np.float32)
|
|
alpha_channel = img_array[:, :, 3]
|
|
|
|
# Skip if no visible pixels
|
|
if not np.any(alpha_channel > 0):
|
|
return image
|
|
|
|
# Convert Kelvin temperature to RGB if enabled
|
|
if hasattr(config, 'kelvin_temperature') and config.kelvin_temperature > 0:
|
|
light_color = LightShadowProcessor.kelvin_to_rgb(config.kelvin_temperature)
|
|
else:
|
|
light_color = config.light_color
|
|
|
|
# Calculate normalized light direction vector
|
|
light_dir_x = config.light_direction_x
|
|
light_dir_y = config.light_direction_y
|
|
light_z = config.light_source_height if hasattr(config, 'light_source_height') else 200
|
|
light_dir_length = math.sqrt(light_dir_x**2 + light_dir_y**2 + light_z**2)
|
|
|
|
# Default to top light if direction is zero
|
|
if light_dir_length < 1:
|
|
light_dir_x, light_dir_y, light_z = 0, -1, 1
|
|
else:
|
|
light_dir_x /= light_dir_length
|
|
light_dir_y /= light_dir_length
|
|
light_z /= light_dir_length
|
|
|
|
# Create light direction vector
|
|
light_dir = np.array([light_dir_x, light_dir_y, light_z])
|
|
|
|
# Create lighting array (same shape as image)
|
|
height, width = img_array.shape[:2]
|
|
|
|
# Generate normal map if normal mapping is enabled
|
|
if hasattr(config, 'enable_normal_mapping') and config.enable_normal_mapping:
|
|
normal_map = LightShadowProcessor.generate_simple_normal_map(alpha_channel)
|
|
|
|
# Calculate diffuse lighting using normal map
|
|
lighting = np.zeros((height, width), dtype=np.float32)
|
|
|
|
# View direction (assuming viewer is looking straight at the image)
|
|
view_dir = np.array([0, 0, 1])
|
|
|
|
# Calculate halfway vector for specular (Blinn-Phong)
|
|
if hasattr(config, 'enable_specular') and config.enable_specular:
|
|
halfway = light_dir + view_dir
|
|
halfway = halfway / np.linalg.norm(halfway)
|
|
|
|
# Apply lighting based on normal map
|
|
for y in range(height):
|
|
for x in range(width):
|
|
if alpha_channel[y, x] > 0:
|
|
# Get normal at this pixel
|
|
normal = normal_map[y, x]
|
|
|
|
# Calculate diffuse lighting (Lambert's law)
|
|
if config.light_from_behind:
|
|
# Backlighting effect
|
|
dot = max(0, -np.dot(normal, light_dir))
|
|
else:
|
|
# Front lighting
|
|
dot = max(0, np.dot(normal, light_dir))
|
|
|
|
lighting[y, x] = dot
|
|
|
|
# Add specular highlight if enabled
|
|
if hasattr(config, 'enable_specular') and config.enable_specular:
|
|
spec_dot = max(0, np.dot(normal, halfway))
|
|
shininess = config.specular_shininess if hasattr(config, 'specular_shininess') else 32
|
|
spec = spec_dot ** shininess
|
|
spec_intensity = config.specular_intensity if hasattr(config, 'specular_intensity') else 0.3
|
|
lighting[y, x] += spec * spec_intensity
|
|
|
|
# Add ambient light term if enabled
|
|
if hasattr(config, 'ambient_light') and config.ambient_light > 0:
|
|
ambient = config.ambient_light
|
|
lighting += ambient
|
|
else:
|
|
# Use the simpler lighting model if normal mapping is disabled
|
|
y_coords, x_coords = np.mgrid[:height, :width]
|
|
x_norm = (x_coords - width/2) / (width/2)
|
|
y_norm = (y_coords - height/2) / (height/2)
|
|
|
|
if config.light_from_behind:
|
|
# Light from behind creates a rim light effect
|
|
rim_strength = np.abs(x_norm * light_dir_x + y_norm * light_dir_y)
|
|
lighting = np.clip(rim_strength, 0, 1) ** (1/config.light_falloff)
|
|
else:
|
|
# Front lighting - brightest where surface normal faces light
|
|
dot_product = -(x_norm * light_dir_x + y_norm * light_dir_y)
|
|
lighting = np.clip(dot_product, 0, 1) ** (1/config.light_falloff)
|
|
|
|
# Add ambient light term if enabled
|
|
if hasattr(config, 'ambient_light') and config.ambient_light > 0:
|
|
lighting += config.ambient_light
|
|
|
|
# Scale by light intensity
|
|
lighting *= config.light_intensity
|
|
|
|
# Apply falloff based on distance from center for more natural look
|
|
center_x, center_y = width // 2, height // 2
|
|
x_dist = (x_coords - center_x) / config.light_radius
|
|
y_dist = (y_coords - center_y) / config.light_radius
|
|
distance = np.sqrt(x_dist**2 + y_dist**2)
|
|
falloff_mask = np.clip(1 - distance, 0, 1) ** 0.5
|
|
|
|
# Combine directional lighting with falloff
|
|
lighting *= falloff_mask
|
|
|
|
# Ensure lighting is within valid range
|
|
lighting = np.clip(lighting, 0, 1)
|
|
|
|
# Only apply lighting to non-transparent pixels
|
|
lighting *= (alpha_channel > 0)
|
|
|
|
# Create RGB lighting array
|
|
light_r = np.ones_like(lighting) * (light_color[0] / 255.0)
|
|
light_g = np.ones_like(lighting) * (light_color[1] / 255.0)
|
|
light_b = np.ones_like(lighting) * (light_color[2] / 255.0)
|
|
|
|
# Apply lighting using screen blend mode
|
|
# Screen formula: Result = 1 - (1 - Light) * (1 - Base)
|
|
result_r = 1 - (1 - (light_r * lighting))[:, :, np.newaxis] * (1 - img_array[:, :, 0:1] / 255.0)
|
|
result_g = 1 - (1 - (light_g * lighting))[:, :, np.newaxis] * (1 - img_array[:, :, 1:2] / 255.0)
|
|
result_b = 1 - (1 - (light_b * lighting))[:, :, np.newaxis] * (1 - img_array[:, :, 2:3] / 255.0)
|
|
|
|
# Combine channels and convert back to uint8
|
|
result = np.concatenate([
|
|
(result_r * 255).astype(np.uint8),
|
|
(result_g * 255).astype(np.uint8),
|
|
(result_b * 255).astype(np.uint8),
|
|
img_array[:, :, 3:4].astype(np.uint8)
|
|
], axis=2)
|
|
|
|
return Image.fromarray(result)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error applying lighting effect: {str(e)}")
|
|
return image
|
|
|
|
@staticmethod
|
|
def create_shadow(image: Image.Image, config: ShadowEffectConfig) -> Optional[Image.Image]:
|
|
"""Create a shadow for the given image with perspective and distance-based fade"""
|
|
try:
|
|
if not config.enable_shadow or config.shadow_opacity <= 0:
|
|
return None
|
|
|
|
# Get alpha channel to use as shadow base
|
|
if image.mode != 'RGBA':
|
|
image = image.convert('RGBA')
|
|
|
|
_, _, _, alpha = image.split()
|
|
alpha_np = np.array(alpha)
|
|
|
|
# Create a mask for the shadow with proper dimensions
|
|
shadow_mask = np.zeros_like(alpha_np, dtype=np.float32)
|
|
|
|
# Calculate shadow parameters
|
|
offset_x = config.shadow_direction_x
|
|
offset_y = config.shadow_direction_y
|
|
|
|
# Use perspective shadow if enabled
|
|
if hasattr(config, 'perspective_shadow') and config.perspective_shadow:
|
|
# Calculate center of image (for perspective calculations)
|
|
height, width = alpha_np.shape
|
|
center_x, center_y = width // 2, height // 2
|
|
|
|
# Get light source height
|
|
light_height = config.light_source_height if hasattr(config, 'light_source_height') else 200
|
|
|
|
# Process each pixel for perspective shadow
|
|
for y in range(height):
|
|
for x in range(width):
|
|
if alpha_np[y, x] > 0:
|
|
# Calculate depth factor (simulating z-depth)
|
|
depth_factor = 1.0 - ((y - center_y) / height) * 0.5
|
|
|
|
# Calculate perspective shadow position
|
|
shadow_x = int(x + offset_x * light_height / (light_height * depth_factor))
|
|
shadow_y = int(y + offset_y * light_height / (light_height * depth_factor))
|
|
|
|
# Check if shadow position is within bounds
|
|
if 0 <= shadow_x < width and 0 <= shadow_y < height:
|
|
# Copy alpha value to shadow mask
|
|
shadow_mask[shadow_y, shadow_x] = alpha_np[y, x]
|
|
else:
|
|
# Standard shadow without perspective
|
|
# Calculate bounds to ensure shadow stays within image
|
|
height, width = alpha_np.shape
|
|
min_x = max(0, -offset_x)
|
|
min_y = max(0, -offset_y)
|
|
max_x = min(width, width - offset_x)
|
|
max_y = min(height, height - offset_y)
|
|
|
|
# Copy the alpha channel to the appropriate position in the shadow mask
|
|
for y in range(min_y, max_y):
|
|
for x in range(min_x, max_x):
|
|
src_x = x
|
|
src_y = y
|
|
dst_x = x + offset_x
|
|
dst_y = y + offset_y
|
|
|
|
# Only copy if both source and destination are in bounds
|
|
if (0 <= src_x < width and 0 <= src_y < height and
|
|
0 <= dst_x < width and 0 <= dst_y < height):
|
|
shadow_mask[dst_y, dst_x] = alpha_np[src_y, src_x]
|
|
|
|
# Apply distance-based fade if enabled
|
|
if hasattr(config, 'distance_fade') and config.distance_fade:
|
|
# Convert shadow mask to binary for distance transform
|
|
from scipy.ndimage import distance_transform_edt
|
|
|
|
# Find non-zero pixels in the alpha channel
|
|
object_mask = (alpha_np > 0).astype(np.uint8)
|
|
|
|
# Calculate distance from the object
|
|
distance = distance_transform_edt(1 - object_mask)
|
|
|
|
# Get fade distance
|
|
fade_dist = config.fade_distance if hasattr(config, 'fade_distance') else 100
|
|
|
|
# Apply fade based on distance
|
|
fade_factor = np.clip(1 - distance / fade_dist, 0, 1)
|
|
shadow_mask = shadow_mask * fade_factor
|
|
|
|
# Convert to PIL Image
|
|
shadow_mask_pil = Image.fromarray(shadow_mask.astype(np.uint8), 'L')
|
|
|
|
# Adjust shadow size if expansion is non-zero
|
|
if config.shadow_expansion != 0:
|
|
if config.shadow_expansion > 0:
|
|
shadow_mask_pil = shadow_mask_pil.filter(ImageFilter.MaxFilter(config.shadow_expansion))
|
|
else:
|
|
shadow_mask_pil = shadow_mask_pil.filter(ImageFilter.MinFilter(abs(config.shadow_expansion)))
|
|
|
|
# Create shadow from alpha
|
|
shadow = Image.new('RGBA', image.size, (0, 0, 0, 0))
|
|
shadow_pixels = shadow.load()
|
|
shadow_mask_pixels = shadow_mask_pil.load()
|
|
|
|
height, width = image.size[1], image.size[0]
|
|
for y in range(height):
|
|
for x in range(width):
|
|
alpha_val = shadow_mask_pixels[x, y]
|
|
if alpha_val > 0:
|
|
shadow_opacity = int(alpha_val * config.shadow_opacity)
|
|
shadow_pixels[x, y] = (*config.shadow_color, shadow_opacity)
|
|
|
|
# Apply shadow blur
|
|
if config.shadow_blur > 0:
|
|
# Apply soft edges if enabled
|
|
if hasattr(config, 'soft_edges') and config.soft_edges:
|
|
# More refined blur with iteration for softer edges
|
|
temp_shadow = shadow
|
|
for _ in range(2): # Multiple passes for softer result
|
|
temp_shadow = temp_shadow.filter(ImageFilter.GaussianBlur(config.shadow_blur / 2))
|
|
shadow = temp_shadow
|
|
else:
|
|
# Standard blur
|
|
shadow = shadow.filter(ImageFilter.GaussianBlur(config.shadow_blur))
|
|
|
|
return shadow
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating shadow: {str(e)}")
|
|
return None |