Files
CGW-Quote-Builder/app/image_generator.py
T
2026-04-11 00:04:09 -05:00

298 lines
11 KiB
Python

"""
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}"