Files
2025-03-12 07:13:29 -04:00

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