Initial
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Dynamic Product Image Generator
|
||||
Generates product images with configurable colors, hardware positions, and other options.
|
||||
Uses PIL/Pillow for image manipulation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from PIL import Image, ImageDraw, ImageEnhance, ImageColor
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ProductImageGenerator:
|
||||
"""Handles dynamic generation of product images based on configuration."""
|
||||
|
||||
def __init__(self, config_file='data/image_configs.json'):
|
||||
"""Initialize with configuration file."""
|
||||
self.config_file = config_file
|
||||
self.configs = self._load_configs()
|
||||
self.cache_dir = Path('cache/product_images')
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_configs(self):
|
||||
"""Load image configuration from JSON file."""
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading image configs: {e}")
|
||||
return {}
|
||||
|
||||
def _find_image(self, image_path):
|
||||
"""
|
||||
Find image file, trying different extensions if needed.
|
||||
|
||||
Args:
|
||||
image_path: Path to image (e.g., 'images/cobrai.jpg')
|
||||
|
||||
Returns:
|
||||
Actual path to image file, or None if not found
|
||||
"""
|
||||
# Try the exact path first
|
||||
if os.path.exists(image_path):
|
||||
return image_path
|
||||
|
||||
# Get base path without extension
|
||||
base_path = os.path.splitext(image_path)[0]
|
||||
|
||||
# Try common image extensions
|
||||
for ext in ['.png', '.jpg', '.jpeg', '.PNG', '.JPG', '.JPEG']:
|
||||
test_path = base_path + ext
|
||||
if os.path.exists(test_path):
|
||||
return test_path
|
||||
|
||||
return None
|
||||
|
||||
def generate_product_image(self, product_code, color='white', hinge='right',
|
||||
material='aluminum', use_cache=True):
|
||||
"""
|
||||
Generate a product image with specified configuration.
|
||||
|
||||
Args:
|
||||
product_code: Product identifier (e.g., 'cobrai')
|
||||
color: Color name (e.g., 'white', 'black', 'bronze')
|
||||
hinge: Hinge side ('right' or 'left')
|
||||
material: Material type (for future use)
|
||||
use_cache: Whether to use cached images
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
product_code = product_code.lower()
|
||||
|
||||
# Check if product config exists
|
||||
if product_code not in self.configs:
|
||||
raise ValueError(f"Product {product_code} not found in configuration")
|
||||
|
||||
config = self.configs[product_code]
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cache_key = self._get_cache_key(product_code, color, hinge, material)
|
||||
cached_image = self._get_cached_image(cache_key)
|
||||
if cached_image:
|
||||
return cached_image
|
||||
|
||||
# Load base image
|
||||
base_image_path = config['sourceImage']
|
||||
|
||||
# Try to find the image with different extensions if needed
|
||||
image_path = self._find_image(base_image_path)
|
||||
|
||||
if not image_path:
|
||||
raise FileNotFoundError(f"Could not load base image: {base_image_path}. Tried .jpg, .png, .jpeg")
|
||||
|
||||
try:
|
||||
base_image = Image.open(image_path).convert('RGBA')
|
||||
except Exception as e:
|
||||
raise FileNotFoundError(f"Could not load base image: {image_path}. Error: {e}")
|
||||
|
||||
# Apply color to regions
|
||||
if color in config['availableColors']:
|
||||
color_rgb = tuple(config['availableColors'][color]['rgb'])
|
||||
base_image = self._apply_color_to_regions(
|
||||
base_image,
|
||||
config['colorableRegions'],
|
||||
color_rgb
|
||||
)
|
||||
|
||||
# Apply hardware (handles, locks)
|
||||
if hinge in config.get('hardwarePositions', {}):
|
||||
base_image = self._apply_hardware(
|
||||
base_image,
|
||||
config['hardwarePositions'],
|
||||
hinge
|
||||
)
|
||||
|
||||
# Flip image if left hinge
|
||||
if hinge == 'left':
|
||||
base_image = base_image.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
|
||||
# Cache the result
|
||||
if use_cache:
|
||||
self._cache_image(cache_key, base_image)
|
||||
|
||||
return base_image
|
||||
|
||||
def _apply_color_to_regions(self, image, regions, target_color):
|
||||
"""
|
||||
Apply color to specific regions of the image.
|
||||
|
||||
This method attempts to recolor the frame/panel areas while preserving
|
||||
shadows, highlights, and texture.
|
||||
"""
|
||||
img_copy = image.copy()
|
||||
pixels = img_copy.load()
|
||||
width, height = img_copy.size
|
||||
|
||||
# Get the average brightness of the target color
|
||||
target_brightness = sum(target_color) / 3
|
||||
|
||||
for region in regions:
|
||||
x1, y1 = region['topLeft']
|
||||
x2, y2 = region['bottomRight']
|
||||
|
||||
# Ensure coordinates are within bounds
|
||||
x1 = max(0, min(x1, width - 1))
|
||||
x2 = max(0, min(x2, width))
|
||||
y1 = max(0, min(y1, height - 1))
|
||||
y2 = max(0, min(y2, height))
|
||||
|
||||
# Apply color to region while preserving luminosity
|
||||
for y in range(y1, y2):
|
||||
for x in range(x1, x2):
|
||||
try:
|
||||
r, g, b, a = pixels[x, y]
|
||||
|
||||
# Calculate original brightness
|
||||
original_brightness = (r + g + b) / 3
|
||||
|
||||
# Preserve relative brightness
|
||||
if original_brightness > 0:
|
||||
brightness_factor = original_brightness / 255.0
|
||||
|
||||
# Apply target color with brightness preservation
|
||||
new_r = int(target_color[0] * brightness_factor)
|
||||
new_g = int(target_color[1] * brightness_factor)
|
||||
new_b = int(target_color[2] * brightness_factor)
|
||||
|
||||
# Ensure values are in valid range
|
||||
new_r = max(0, min(255, new_r))
|
||||
new_g = max(0, min(255, new_g))
|
||||
new_b = max(0, min(255, new_b))
|
||||
|
||||
pixels[x, y] = (new_r, new_g, new_b, a)
|
||||
except IndexError:
|
||||
continue
|
||||
|
||||
return img_copy
|
||||
|
||||
def _apply_hardware(self, image, hardware_positions, hinge_side):
|
||||
"""Apply hardware (handles, locks) to the image."""
|
||||
img_copy = image.copy()
|
||||
|
||||
hardware_key = f"handle_{hinge_side}"
|
||||
if hardware_key in hardware_positions:
|
||||
hardware_config = hardware_positions[hardware_key]
|
||||
hardware_path = hardware_config.get('image')
|
||||
|
||||
if hardware_path:
|
||||
# Try to find the hardware image with different extensions
|
||||
actual_path = self._find_image(hardware_path)
|
||||
|
||||
if actual_path:
|
||||
try:
|
||||
hardware_img = Image.open(actual_path).convert('RGBA')
|
||||
|
||||
# Flip if needed
|
||||
if hardware_config.get('flip', False):
|
||||
hardware_img = hardware_img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
|
||||
# Paste hardware at specified position
|
||||
x, y = hardware_config['x'], hardware_config['y']
|
||||
img_copy.paste(hardware_img, (x, y), hardware_img)
|
||||
except Exception as e:
|
||||
print(f"Could not apply hardware: {e}")
|
||||
|
||||
return img_copy
|
||||
|
||||
def _get_cache_key(self, product_code, color, hinge, material):
|
||||
"""Generate a unique cache key for the configuration."""
|
||||
key_string = f"{product_code}_{color}_{hinge}_{material}"
|
||||
return hashlib.md5(key_string.encode()).hexdigest()
|
||||
|
||||
def _get_cached_image(self, cache_key):
|
||||
"""Retrieve cached image if available."""
|
||||
cache_path = self.cache_dir / f"{cache_key}.png"
|
||||
if cache_path.exists():
|
||||
try:
|
||||
return Image.open(cache_path)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _cache_image(self, cache_key, image):
|
||||
"""Save image to cache."""
|
||||
cache_path = self.cache_dir / f"{cache_key}.png"
|
||||
try:
|
||||
image.save(cache_path, 'PNG', optimize=True)
|
||||
except Exception as e:
|
||||
print(f"Could not cache image: {e}")
|
||||
|
||||
def get_product_config(self, product_code):
|
||||
"""Get configuration for a specific product."""
|
||||
return self.configs.get(product_code.lower())
|
||||
|
||||
def clear_cache(self, product_code=None):
|
||||
"""Clear cached images. If product_code provided, only clear that product's cache."""
|
||||
if product_code:
|
||||
# Clear specific product cache (would need to track cache keys)
|
||||
pass
|
||||
else:
|
||||
# Clear all cache
|
||||
for cache_file in self.cache_dir.glob('*.png'):
|
||||
try:
|
||||
cache_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def recolor_image_advanced(image, source_color_range, target_color):
|
||||
"""
|
||||
Advanced recoloring that replaces pixels within a color range.
|
||||
|
||||
Args:
|
||||
image: PIL Image
|
||||
source_color_range: Dict with 'min' and 'max' RGB tuples
|
||||
target_color: Target RGB tuple
|
||||
"""
|
||||
img_copy = image.copy()
|
||||
pixels = img_copy.load()
|
||||
width, height = img_copy.size
|
||||
|
||||
min_r, min_g, min_b = source_color_range['min']
|
||||
max_r, max_g, max_b = source_color_range['max']
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
|
||||
# Check if pixel is within source color range
|
||||
if (min_r <= r <= max_r and
|
||||
min_g <= g <= max_g and
|
||||
min_b <= b <= max_b):
|
||||
|
||||
# Calculate brightness factor
|
||||
brightness = (r + g + b) / 3 / 255.0
|
||||
|
||||
# Apply target color with brightness
|
||||
new_r = int(target_color[0] * brightness)
|
||||
new_g = int(target_color[1] * brightness)
|
||||
new_b = int(target_color[2] * brightness)
|
||||
|
||||
pixels[x, y] = (new_r, new_g, new_b, a)
|
||||
|
||||
return img_copy
|
||||
|
||||
|
||||
def image_to_base64(image, format='PNG'):
|
||||
"""Convert PIL Image to base64 string."""
|
||||
import base64
|
||||
buffered = BytesIO()
|
||||
image.save(buffered, format=format)
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||
return f"data:image/{format.lower()};base64,{img_str}"
|
||||
Reference in New Issue
Block a user