350 lines
16 KiB
Python
350 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
Business风格(商务风格)海报模板
|
||
"""
|
||
|
||
import logging
|
||
import math
|
||
import random
|
||
from collections import Counter
|
||
from typing import Dict, Any, Optional, List, Tuple
|
||
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
import colorsys
|
||
|
||
from .base_template import BaseTemplate
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BusinessTemplate(BaseTemplate):
|
||
"""
|
||
商务风格模板,适用于酒店、房地产、高端服务等场景。
|
||
特点是上下图片融合,中间为信息展示区,整体风格沉稳、专业。
|
||
"""
|
||
|
||
def __init__(self, size: Tuple[int, int] = (900, 1200)):
|
||
super().__init__(size)
|
||
self.config = {
|
||
'total_parts': 4.0,
|
||
'transparent_ratio': 0.5,
|
||
'dynamic_spacing': {
|
||
'min_line_spacing': 8,
|
||
'max_line_spacing': 25,
|
||
'content_margin': 20,
|
||
'section_spacing': 35,
|
||
'bottom_reserve': 40
|
||
}
|
||
}
|
||
self.color_themes = {
|
||
"modern_blue": [(25, 52, 85), (65, 120, 180)],
|
||
"warm_sunset": [(45, 25, 20), (180, 100, 60)],
|
||
"fresh_green": [(15, 45, 25), (90, 140, 80)],
|
||
"deep_ocean": [(20, 40, 70), (70, 140, 200)],
|
||
"elegant_purple": [(35, 25, 55), (120, 90, 160)],
|
||
"classic_gray": [(30, 35, 40), (120, 130, 140)],
|
||
"premium_gold": [(60, 50, 30), (160, 140, 100)],
|
||
"tech_gradient": [(20, 30, 50), (80, 100, 140)]
|
||
}
|
||
self._current_background_colors = None
|
||
|
||
def generate(self,
|
||
top_image_path: str,
|
||
bottom_image_path: str,
|
||
content: Dict[str, Any],
|
||
small_image_paths: Optional[List[str]] = None,
|
||
color_theme: Optional[str] = None,
|
||
**kwargs) -> Image.Image:
|
||
"""
|
||
生成Business风格海报
|
||
|
||
Args:
|
||
top_image_path (str): 顶部图像路径
|
||
bottom_image_path (str): 底部图像路径
|
||
content (Dict[str, Any]): 包含所有文本信息的字典
|
||
small_image_paths (Optional[List[str]]): 中间小图路径列表 (最多3张)
|
||
color_theme (Optional[str]): 预设颜色主题名称
|
||
|
||
Returns:
|
||
Image.Image: 生成的海报图像
|
||
"""
|
||
top_img = self.image_processor.load_image(top_image_path)
|
||
bottom_img = self.image_processor.load_image(bottom_image_path)
|
||
if not top_img or not bottom_img:
|
||
return None
|
||
|
||
top_img = self.image_processor.resize_and_crop(top_img, (self.width, top_img.height))
|
||
bottom_img = self.image_processor.resize_and_crop(bottom_img, (self.width, bottom_img.height))
|
||
|
||
if color_theme and color_theme in self.color_themes:
|
||
top_color, bottom_color = self.color_themes[color_theme]
|
||
else:
|
||
top_color = self._extract_dominant_color_edge(top_img)
|
||
bottom_color = self._extract_dominant_color_edge(bottom_img)
|
||
top_color, bottom_color = self._ensure_colors_harmony(top_color, bottom_color)
|
||
|
||
self._current_background_colors = (top_color, bottom_color)
|
||
canvas = self.create_gradient_background(top_color, bottom_color)
|
||
|
||
top_img_trans = self._apply_top_transparency(top_img)
|
||
bottom_img_trans = self._apply_bottom_transparency(bottom_img)
|
||
|
||
section_heights = self._calculate_section_heights()
|
||
canvas = self._compose_images(canvas, top_img_trans, bottom_img_trans)
|
||
|
||
if small_image_paths:
|
||
canvas = self._add_small_images(canvas, small_image_paths, section_heights)
|
||
|
||
canvas = self._add_decorative_elements(canvas)
|
||
canvas = self._render_texts(canvas, content, section_heights)
|
||
|
||
return canvas
|
||
|
||
def _calculate_section_heights(self) -> Dict[str, int]:
|
||
"""计算各区域高度(1:2:1的比例)"""
|
||
total_height = self.height
|
||
total_parts = self.config['total_parts']
|
||
|
||
top_h = int(total_height / total_parts)
|
||
middle_h = int(total_height * 2 / total_parts)
|
||
bottom_h = total_height - top_h - middle_h
|
||
|
||
return {
|
||
'top_height': top_h, 'middle_height': middle_h, 'bottom_height': bottom_h,
|
||
'middle_start': top_h, 'middle_end': top_h + middle_h
|
||
}
|
||
|
||
def _apply_top_transparency(self, image: Image.Image) -> Image.Image:
|
||
"""对上部图像应用从不透明到透明的渐变"""
|
||
img_rgba = self.image_processor.ensure_rgba(image)
|
||
img_array = np.array(img_rgba)
|
||
h = img_array.shape[0]
|
||
|
||
start_y = int(h * (1 - self.config['transparent_ratio']))
|
||
for y in range(start_y, h):
|
||
ratio = ((y - start_y) / (h - start_y)) ** 2
|
||
img_array[y, :, 3] = np.clip(img_array[y, :, 3] * (1 - ratio), 0, 255)
|
||
|
||
return Image.fromarray(img_array)
|
||
|
||
def _apply_bottom_transparency(self, image: Image.Image) -> Image.Image:
|
||
"""对下部图像应用从透明到不透明的渐变"""
|
||
img_rgba = self.image_processor.ensure_rgba(image)
|
||
img_array = np.array(img_rgba)
|
||
h = img_array.shape[0]
|
||
|
||
end_y = int(h * self.config['transparent_ratio'])
|
||
for y in range(end_y):
|
||
ratio = (1 - y / end_y) ** 2
|
||
img_array[y, :, 3] = np.clip(img_array[y, :, 3] * (1 - ratio), 0, 255)
|
||
|
||
return Image.fromarray(img_array)
|
||
|
||
def _compose_images(self, canvas: Image.Image, top_img: Image.Image, bottom_img: Image.Image) -> Image.Image:
|
||
"""合成背景和上下图像"""
|
||
canvas.paste(top_img, ((self.width - top_img.width) // 2, 0), top_img)
|
||
canvas.paste(bottom_img, ((self.width - bottom_img.width) // 2, self.height - bottom_img.height), bottom_img)
|
||
return canvas
|
||
|
||
def _render_texts(self, canvas: Image.Image, content: Dict[str, Any], heights: Dict[str, int]) -> Image.Image:
|
||
"""渲染所有文本内容"""
|
||
draw = ImageDraw.Draw(canvas)
|
||
center_x = self.width // 2
|
||
|
||
text_start_y, text_end_y = heights['middle_start'], heights['middle_end']
|
||
available_height = text_end_y - text_start_y - self.config['dynamic_spacing']['content_margin'] * 2
|
||
estimated_height = self._estimate_content_height(content, self.width)
|
||
|
||
layout_params = self._calculate_dynamic_layout_params(estimated_height, available_height)
|
||
|
||
current_y = text_start_y + self.config['dynamic_spacing']['content_margin']
|
||
available_width = int(self.width * 0.9)
|
||
margin_x = (self.width - available_width) // 2
|
||
|
||
current_y = self._render_title(draw, content.get("name", ""), current_y, center_x, self.width, layout_params)
|
||
current_y = self._render_feature(draw, content.get("feature", ""), current_y, center_x, available_width, layout_params)
|
||
self._render_info_section(draw, content, current_y, margin_x, available_width, layout_params, text_end_y)
|
||
|
||
return canvas
|
||
|
||
def _add_small_images(self, canvas: Image.Image, image_paths: List[str], heights: Dict[str, int]) -> Image.Image:
|
||
"""在中间区域添加最多三张小图"""
|
||
if not image_paths: return canvas
|
||
|
||
num_images = min(len(image_paths), 3)
|
||
padding = 10
|
||
total_img_width = int(self.width * 0.8)
|
||
img_width = (total_img_width - (num_images - 1) * padding) // num_images
|
||
img_height = int(img_width * 0.75)
|
||
|
||
start_x = (self.width - total_img_width) // 2
|
||
y_pos = heights['middle_start'] + (heights['middle_height'] - img_height) // 2
|
||
|
||
for i, path in enumerate(image_paths[:num_images]):
|
||
if img := self.image_processor.load_image(path):
|
||
img = self.image_processor.resize_and_crop(img, (img_width, img_height))
|
||
x_pos = start_x + i * (img_width + padding)
|
||
canvas.paste(img, (x_pos, y_pos))
|
||
return canvas
|
||
|
||
def _add_decorative_elements(self, canvas: Image.Image) -> Image.Image:
|
||
"""添加装饰性元素增强设计感"""
|
||
overlay = Image.new('RGBA', self.size, (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
# 顶部装饰线条
|
||
line_y = self.height // 4 - 20
|
||
for i in range(w := self.width // 3):
|
||
alpha = int(255 * (1 - abs(i - w//2) / (w//2)) * 0.3)
|
||
draw.line([((self.width-w)//2 + i, line_y), ((self.width-w)//2 + i, line_y + 2)], fill=(255,255,255,alpha), width=1)
|
||
|
||
# 角落装饰
|
||
s, a = 80, 40
|
||
draw.arc([20, 20, 20+s, 20+s], 180, 270, fill=(255,255,255,a), width=3)
|
||
draw.arc([self.width-s-20, self.height-s-20, self.width-20, self.height-20], 0, 90, fill=(255,255,255,a), width=3)
|
||
|
||
return Image.alpha_composite(canvas, overlay)
|
||
|
||
def _estimate_content_height(self, content: Dict[str, Any], width: int) -> int:
|
||
"""预估内容总高度"""
|
||
spacing = self.config['dynamic_spacing']['section_spacing']
|
||
title_h = int(self.text_renderer.load_font(60).getbbox("T")[3] * 1.2)
|
||
feature_h = int(self.text_renderer.load_font(30).getbbox("T")[3] * 1.5) + 50
|
||
info_h = len(content.get("info_list", [])) * 35
|
||
price_h = 80
|
||
return 20 + title_h + spacing + feature_h + spacing + info_h + price_h + self.config['dynamic_spacing']['bottom_reserve']
|
||
|
||
def _calculate_dynamic_layout_params(self, estimated_height: int, available_height: int) -> Dict[str, Any]:
|
||
"""根据空间利用率计算动态布局参数"""
|
||
ratio = estimated_height / available_height if available_height > 0 else 1.0
|
||
factor = 1.2 if ratio <= 0.8 else (0.8 if ratio > 1.0 else 1.0)
|
||
spacing = self.config['dynamic_spacing']
|
||
return {
|
||
'line_spacing': int(spacing['min_line_spacing'] + (spacing['max_line_spacing'] - spacing['min_line_spacing']) * factor),
|
||
'section_spacing': int(spacing['section_spacing'] * factor)
|
||
}
|
||
|
||
def _render_title(self, draw: ImageDraw.Draw, text: str, y: int, center_x: int, width: int, params: Dict[str, Any]) -> int:
|
||
"""渲染主标题"""
|
||
size, calculated_width = self.text_renderer.calculate_font_size_and_width(text, int(width * 0.85), max_size=80)
|
||
font = self.text_renderer.load_font(size)
|
||
text_w, text_h = self.text_renderer.get_text_size(text, font)
|
||
self.text_renderer.draw_text_with_outline(draw, (center_x - text_w // 2, y), text, font, text_color=(255, 240, 200), outline_width=2)
|
||
return y + text_h + params['section_spacing']
|
||
|
||
def _render_feature(self, draw: ImageDraw.Draw, text: str, y: int, center_x: int, width: int, params: Dict[str, Any]) -> int:
|
||
"""渲染特色描述及其背景"""
|
||
size, calculated_width = self.text_renderer.calculate_font_size_and_width(text, int(width * 0.8), max_size=40)
|
||
font = self.text_renderer.load_font(size)
|
||
text_w, text_h = self.text_renderer.get_text_size(text, font)
|
||
|
||
bg_h = 50
|
||
bg_w = min(text_w + 30, width)
|
||
bg_x = center_x - bg_w // 2
|
||
|
||
self.text_renderer.draw_rounded_rectangle(draw, (bg_x, y-5), (bg_w, bg_h), 20, fill=(50, 50, 50, 180))
|
||
color = self._get_smart_feature_color()
|
||
draw.text((center_x - text_w // 2, y), text, font=font, fill=color)
|
||
return y + bg_h + params['section_spacing']
|
||
|
||
def _render_info_section(self, draw, content, y, x, width, params, max_y):
|
||
"""渲染信息列表和价格区域"""
|
||
info_texts = content.get("info_list", [])
|
||
if not info_texts: return y
|
||
|
||
# 为信息列表找到合适的字体
|
||
longest_info_line = max([f"{item.get('title', '')}: {item.get('desc', '')}" for item in info_texts], key=len) if info_texts else ""
|
||
info_size, _ = self.text_renderer.calculate_font_size_and_width(longest_info_line, int(width*0.55), max_size=30)
|
||
info_font = self.text_renderer.load_font(info_size)
|
||
_, line_h = self.text_renderer.get_text_size("T", info_font)
|
||
|
||
# 为价格找到合适的字体
|
||
price_text = f"¥{content.get('price', '')}"
|
||
price_size, price_w = self.text_renderer.calculate_font_size_and_width(price_text, int(width*0.35), max_size=200, min_size=60)
|
||
price_font = self.text_renderer.load_font(price_size)
|
||
|
||
# 渲染信息列表
|
||
for i, info in enumerate(info_texts):
|
||
info_str = f"{info.get('title', '')}: {info.get('desc', '')}" if isinstance(info, dict) else str(info)
|
||
draw.text((x, y + i * (line_h + params['line_spacing'])), info_str, font=info_font, fill=(255, 255, 255))
|
||
|
||
# 渲染价格
|
||
self.text_renderer.draw_text_with_shadow(draw, (x + width - price_w, y), price_text, price_font)
|
||
|
||
# --- Color Extraction Logic (from demo) ---
|
||
def _extract_dominant_color_edge(self, image: Image.Image) -> Tuple[int, int, int]:
|
||
"""从图像边缘提取主要颜色"""
|
||
if image.mode != 'RGB': image = image.convert('RGB')
|
||
w, h = image.size
|
||
pixels = []
|
||
edge_w = min(w, h) // 4
|
||
|
||
points = [(x,y) for y in range(h) for x in range(w) if x<edge_w or x>w-edge_w or y<edge_w or y>h-edge_w]
|
||
sample_points = random.sample(points, min(len(points), 200))
|
||
|
||
for p in sample_points:
|
||
pixel = image.getpixel(p)
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
if not pixels: return (80, 120, 160)
|
||
|
||
best_color = self._select_best_color(Counter(pixels).most_common(5))
|
||
return self._adjust_color_for_background(best_color)
|
||
|
||
def _select_best_color(self, candidates: List[Tuple[Tuple[int, int, int], int]]) -> Tuple[int, int, int]:
|
||
"""从候选颜色中选择最合适的"""
|
||
best_score, best_color = -1, None
|
||
for color, count in candidates:
|
||
r,g,b = color
|
||
brightness = (r*299 + g*587 + b*114) / 1000
|
||
saturation = (max(r,g,b) - min(r,g,b)) / max(r,g,b) if max(r,g,b)>0 else 0
|
||
|
||
b_score = 1.0 - abs((brightness - 130) / 130)
|
||
s_score = 1.0 - abs(saturation - 0.5) / 0.5
|
||
score = b_score * 0.4 + s_score * 0.6
|
||
|
||
if score > best_score:
|
||
best_score, best_color = score, color
|
||
return best_color or candidates[0][0]
|
||
|
||
def _adjust_color_for_background(self, color: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
||
"""调整颜色使其更适合作为背景"""
|
||
r, g, b = color
|
||
h, s, v = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0)
|
||
v = max(0.3, min(0.7, v)) # 限制亮度
|
||
s = max(0.4, min(0.8, s)) # 限制饱和度
|
||
r,g,b = [int(c*255) for c in colorsys.hsv_to_rgb(h,s,v)]
|
||
return (r, g, b)
|
||
|
||
def _ensure_colors_harmony(self, top: Tuple[int, int, int], bottom: Tuple[int, int, int]) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
|
||
"""确保颜色和谐"""
|
||
dist = sum(abs(a-b) for a,b in zip(top,bottom))
|
||
if dist < 30:
|
||
factor = 1.1 if sum(top)/3 > 128 else 0.9
|
||
top = tuple(max(0,min(255,int(c*factor))) for c in top)
|
||
elif dist > 150:
|
||
mid = tuple((a+b)//2 for a,b in zip(top,bottom))
|
||
top = tuple(int(c*0.8+m*0.2) for c,m in zip(top,mid))
|
||
bottom = tuple(int(c*0.8+m*0.2) for c,m in zip(bottom,mid))
|
||
return top, bottom
|
||
|
||
def _get_smart_feature_color(self) -> Tuple[int, int, int]:
|
||
"""为feature文本智能选择颜色"""
|
||
if not self._current_background_colors: return (255, 255, 255)
|
||
top, bottom = self._current_background_colors
|
||
avg_bright = (sum(top)+sum(bottom))/6
|
||
|
||
def get_tone(c):
|
||
r,g,b=c
|
||
return 'w' if r > g and r > b else ('f' if g > r and g > b else 'c')
|
||
|
||
tone = get_tone(tuple((a+b)//2 for a,b in zip(top,bottom)))
|
||
|
||
if avg_bright > 120:
|
||
return {'c':(65,105,155), 'w':(155,85,65)}.get(tone, (85,125,85))
|
||
else:
|
||
return {'c':(135,185,235), 'w':(255,195,135)}.get(tone, (155,215,155)) |