TravelContentCreator/poster/templates/vibrant_template.py
2025-07-25 17:13:37 +08:00

386 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Vibrant风格活力风格海报模板
"""
from ast import List
import logging
import math
from typing import Dict, Any, Optional, Tuple
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from .base_template import BaseTemplate
from ..utils import ColorExtractor
logger = logging.getLogger(__name__)
class VibrantTemplate(BaseTemplate):
"""
活力风格模板,适用于色彩鲜艳、内容丰富的场景,如旅游、美食分享。
特点是底部有毛玻璃效果的文案区域。
"""
def __init__(self, size: Tuple[int, int] = (900, 1200)):
super().__init__(size)
self.config = {
'colors': {
'ocean_deep': [(0, 30, 80), (20, 120, 220)],
'sunset_warm': [(255, 94, 77), (255, 154, 0)],
'cool_mint': [(64, 224, 208), (127, 255, 212)],
'royal_purple': [(75, 0, 130), (138, 43, 226)],
'forest_green': [(34, 139, 34), (144, 238, 144)],
'fire_red': [(220, 20, 60), (255, 69, 0)],
"gray_gradient": [(128, 128, 128), (211, 211, 211)],
"dark_gray": [(15, 15, 15), (30, 30, 30)]
},
'glass_effect': {
'max_opacity': 240,
'blur_radius': 22,
'transition_height': 80,
'intensity_multiplier': 1.5
},
}
def generate(self,
images: List,
content: Optional[Dict[str, Any]] = None,
theme_color: Optional[str] = None,
glass_intensity: float = 1.5,
num_variations: int = 1,
**kwargs) -> Image.Image:
"""
生成Vibrant风格海报
Args:
images (List): 主图
content (Optional[Dict[str, Any]]): 包含所有文本信息的字典
theme_color (Optional[str]): 预设颜色主题的名称
glass_intensity (float): 毛玻璃效果强度
num_variations (int): 生成海报数量
Returns:
Image.Image: 生成的海报图像
"""
if content is None:
content = self._get_default_content()
self.config['glass_effect']['intensity_multiplier'] = glass_intensity
main_image = images
logger.info(f"main_image的类型: {np.shape(main_image)}")
if not main_image:
logger.error(f"无法加载图片: ")
return None
main_image = self.image_processor.resize_image(image=main_image, target_size=self.size)
estimated_height = self._estimate_content_height(content)
gradient_start = self._detect_gradient_start_position(main_image, estimated_height)
canvas = self._create_composite_image(main_image, gradient_start, theme_color)
canvas = self._render_texts(canvas, content, gradient_start)
final_image = canvas.resize((1350, 1800), Image.LANCZOS)
return final_image
def _get_default_content(self) -> Dict[str, Any]:
"""获取默认的测试内容"""
return {
"title": "正佳极地海洋世界",
"slogan": "都说海洋馆是约会圣地!那锦峰夜场将是绝杀!",
"price": "199",
"ticket_type": "夜场票",
"content_button": "套餐内容",
"content_items": [
"正佳极地海洋世界夜场票1张",
"有效期至2025.06.02",
"多种动物表演全部免费"
],
"remarks": [
"工作日可直接入园",
"周末请提前1天预约"
],
"tag": "#520特惠",
"pagination": ""
}
def _estimate_content_height(self, content: Dict[str, Any]) -> int:
"""预估内容高度"""
standard_margin = 25
title_height = 100
subtitle_height = 80
button_height = 40
content_items = content.get("content_items", [])
content_line_height = 32
content_list_height = len(content_items) * content_line_height
price_height = 90
ticket_height = 60
remarks = content.get("remarks", [])
if isinstance(remarks, str):
remarks = [remarks]
remarks_height = len(remarks) * 25 + 10
footer_height = 40
total_height = (
20 + title_height + standard_margin + subtitle_height + standard_margin +
button_height + 15 + content_list_height + price_height + ticket_height +
remarks_height + footer_height + 30
)
return total_height
def _detect_gradient_start_position(self, image: Image.Image, estimated_height: int) -> int:
"""动态检测渐变起始位置"""
width, height = image.size
center_x = width // 2
gradient_start = None
for y in range(height // 2, height):
try:
pixel = image.getpixel((center_x, y))
if isinstance(pixel, (tuple, list)) and len(pixel) >= 3:
brightness = sum(pixel[:3]) / 3
if brightness > 50:
gradient_start = max(y - 20, height // 2)
break
except:
continue
if gradient_start is None:
bottom_margin = 60
gradient_start = max(height - estimated_height - bottom_margin, height // 2)
return gradient_start
def _create_composite_image(self, main_image: Image.Image,
gradient_start: int,
theme_color: Optional[str]) -> Image.Image:
"""创建毛玻璃背景和复合图像"""
if theme_color and theme_color in self.config['colors']:
top_color, bottom_color = self.config['colors'][theme_color]
else:
top_color, bottom_color = self._extract_glass_colors_from_image(main_image, gradient_start)
logger.info(f"使用毛玻璃颜色: 顶部={top_color}, 底部={bottom_color}")
gradient_overlay = self._create_frosted_glass_overlay(top_color, bottom_color, gradient_start)
composite_img = Image.new('RGBA', self.size, (0, 0, 0, 0))
composite_img.paste(main_image, (0, 0))
composite_img = Image.alpha_composite(composite_img, gradient_overlay)
return composite_img
def _create_frosted_glass_overlay(self, top_color: Tuple[int, int, int],
bottom_color: Tuple[int, int, int],
gradient_start: int) -> Image.Image:
"""创建高级毛玻璃效果覆盖层"""
overlay = Image.new('RGBA', self.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
gradient_height = self.height - gradient_start
glass_config = self.config['glass_effect']
intensity = glass_config['intensity_multiplier']
enhanced_opacity = min(255, int(glass_config['max_opacity'] * intensity))
enhanced_blur = int(glass_config['blur_radius'] * intensity)
def enhance_color(color, multiplier):
factor = min(1.5, 1.0 + (multiplier - 1.0) * 0.3)
return tuple(min(255, max(0, int(c * factor))) for c in color)
top_c = enhance_color(top_color, intensity)
bottom_c = enhance_color(bottom_color, intensity)
top_c_arr = np.array(top_c)
bottom_c_arr = np.array(bottom_c)
for y in range(gradient_start, self.height):
ratio = (y - gradient_start) / gradient_height if gradient_height > 0 else 0
smooth_ratio = 0.5 - 0.5 * math.cos(ratio * math.pi)
color = (1 - smooth_ratio) * top_c_arr + smooth_ratio * bottom_c_arr
alpha_smooth = ratio ** (1.1 / intensity)
alpha = int(enhanced_opacity * (0.02 + 0.98 * alpha_smooth))
if (y - gradient_start) < glass_config['transition_height']:
transition_ratio = (y - gradient_start) / glass_config['transition_height']
alpha = int(alpha * (0.5 - 0.5 * math.cos(transition_ratio * math.pi)))
color_tuple = tuple(int(c) for c in color) + (alpha,)
draw.line([(0, y), (self.width, y)], fill=color_tuple)
return self.image_processor.apply_blur(overlay, radius=enhanced_blur)
def _extract_glass_colors_from_image(self, image: Image.Image, gradient_start: int) -> tuple:
"""从图片中提取用于毛玻璃背景的颜色"""
if image.mode != 'RGB':
image = image.convert('RGB')
width, height = image.size
top_samples, bottom_samples = [], []
top_y = min(gradient_start + 20, height - 1)
for x in range(0, width, 20):
if sum(pixel := image.getpixel((x, top_y))) > 30:
top_samples.append(pixel)
bottom_y = min(height - 50, height - 1)
for x in range(0, width, 20):
if sum(pixel := image.getpixel((x, bottom_y))) > 30:
bottom_samples.append(pixel)
top_color = tuple(max(0, int(c * 0.1)) for c in np.mean(top_samples, axis=0)) if top_samples else (0, 5, 15)
bottom_color = tuple(max(0, int(c * 0.2)) for c in np.mean(bottom_samples, axis=0)) if bottom_samples else (0, 25, 50)
return top_color, bottom_color
def _render_texts(self, canvas: Image.Image, content: Dict[str, Any], gradient_start: int) -> Image.Image:
"""渲染所有文本元素,采用双栏布局"""
draw = ImageDraw.Draw(canvas)
width, height = canvas.size
center_x = width // 2
left_margin, right_margin = self._calculate_content_margins(content, width, center_x)
footer_y = height - 30
self._render_footer(draw, content, footer_y, left_margin, right_margin)
title_y = gradient_start + 40
current_y = self._render_title_subtitle(draw, content, title_y, center_x, left_margin, right_margin)
content_area_width = right_margin - left_margin
left_column_width = int(content_area_width * 0.5)
right_column_x = left_margin + left_column_width
content_start_y = current_y + 30
self._render_left_column(draw, content, content_start_y, left_margin, left_column_width, height)
self._render_right_column(draw, content, content_start_y, right_column_x, right_margin)
return canvas
def _calculate_content_margins(self, content: Dict[str, Any], width: int, center_x: int) -> Tuple[int, int]:
"""计算内容区域的左右边距"""
title_text = content.get("title", "")
title_size=self.text_renderer.calculate_optimal_font_size(title_text,int(width * 0.95),max_size=130)
title_width,title_height=self.text_renderer.get_text_size(title_text,self.text_renderer._load_default_font(title_size))
title_x = center_x - title_width // 2
slogan_text = content.get("slogan", "")
subtitle_size=self.text_renderer.calculate_optimal_font_size(slogan_text,int(width * 0.9),max_size=50)
subtitle_width,subtitle_height=self.text_renderer.get_text_size(slogan_text,self.text_renderer._load_default_font(subtitle_size))
subtitle_x = center_x - subtitle_width // 2
padding = 20
left_margin = max(40, min(title_x, subtitle_x) - padding)
right_margin = min(width - 40, max(title_x + title_width, subtitle_x + subtitle_width) + padding)
if (right_margin - left_margin) < (min_width := int(width * 0.75)):
extra = min_width - (right_margin - left_margin)
left_margin = max(30, left_margin - extra // 2)
right_margin = min(width - 30, right_margin + extra // 2)
return left_margin, right_margin
def _render_footer(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, left: int, right: int):
"""渲染页脚文本"""
font = self.text_renderer._load_default_font(18)
if tag := content.get("tag"):
draw.text((left, y), tag, font=font, fill=(255, 255, 255))
if pagination := content.get("pagination"):
width, _ = self.text_renderer.get_text_size(pagination, font)
draw.text((right - width, y), pagination, font=font, fill=(255, 255, 255))
def _render_title_subtitle(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, center_x: int, left: int, right: int) -> int:
"""渲染标题和副标题"""
# 标题
title_text = content.get("title", "默认标题")
title_target_width = int((right - left) * 0.98)
title_size=self.text_renderer.calculate_optimal_font_size(title_text,title_target_width,max_size=140,min_size=40)
title_font = self.text_renderer._load_default_font(title_size)
text_w, text_h = self.text_renderer.get_text_size(title_text, title_font)
title_x = center_x - text_w // 2
self.text_renderer.draw_text_with_outline(draw, (title_x, y), title_text, title_font, text_color=(255, 255, 255, 255), outline_color=(0, 30, 80, 200), outline_width=4)
# 副标题 (slogan)
subtitle_text = content.get("slogan", "")
subtitle_target_width = int((right - left) * 0.95)
subtitle_size=self.text_renderer.calculate_optimal_font_size(subtitle_text,subtitle_target_width,max_size=75,min_size=20)
subtitle_font = self.text_renderer._load_default_font(subtitle_size)
sub_text_w, sub_text_h = self.text_renderer.get_text_size(subtitle_text, subtitle_font)
subtitle_x = center_x - sub_text_w // 2
title_spacing = 30
# 在标题下方添加装饰线
line_y = y + text_h + 5
line_start_x = title_x - text_w * 0.025
line_end_x = title_x + text_w * 1.025
draw.line([(line_start_x, line_y), (line_end_x, line_y)], fill=(215, 215, 215, 80), width=3)
subtitle_y = y + text_h + title_spacing
self.text_renderer.draw_text_with_shadow(draw, (subtitle_x, subtitle_y), subtitle_text, subtitle_font, text_color=(255, 255, 255, 255), shadow_color=(0, 0, 0, 180), shadow_offset=(2, 2))
return subtitle_y + sub_text_h
def _render_left_column(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, x: int, width: int, canvas_height: int):
"""渲染左栏内容:按钮和项目列表"""
button_font = self.text_renderer._load_default_font(30)
button_text = content.get("content_button", "套餐内容")
button_width, _ = self.text_renderer.get_text_size(button_text, button_font)
button_width += 40
button_height = 50
self.text_renderer.draw_rounded_rectangle(draw=draw, position=(x, y), size=(button_width, button_height), radius=20, fill_color=(0, 140, 210, 180), outline_color=(255, 255, 255, 255), outline_width=1)
draw.text((x + 20, y + (button_height - 30) // 2), button_text, font=button_font, fill=(255, 255, 255))
items = content.get("content_items", [])
if not items: return
font = self.text_renderer._load_default_font(28)
list_y = y + button_height + 20
available_h = canvas_height - 30 - (len(content.get("remarks", [])) * 25 + 10) - list_y - 20
total_items_h = len(items) * 36
extra_space_per_item = max(0, min(17, (available_h - total_items_h) / max(1, len(items) - 1)))
line_spacing = 8 + extra_space_per_item
for i, item in enumerate(items):
item_y = list_y + i * (28 + line_spacing)
draw.text((x, item_y), " " + item, font=font, fill=(255, 255, 255))
def _render_right_column(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, x: int, right_margin: int):
"""渲染右栏内容:价格、票种和备注"""
price_text = content.get('price', '')
price_size=self.text_renderer.calculate_optimal_font_size(price_text,int((right_margin - x) * 0.7),max_size=120,min_size=40)
price_width,_=self.text_renderer.get_text_size(price_text,self.text_renderer._load_default_font(price_size))
price_font = self.text_renderer._load_default_font(price_size)
suffix_font = self.text_renderer._load_default_font(int(price_size * 0.3))
_, price_height = self.text_renderer.get_text_size(price_text, price_font)
suffix_width, suffix_height = self.text_renderer.get_text_size("CNY起", suffix_font)
price_x = right_margin - price_width - suffix_width
self.text_renderer.draw_text_with_shadow(draw, (price_x, y), price_text, price_font)
suffix_y = y + price_height - suffix_height
draw.text((price_x + price_width, suffix_y), "CNY起", font=suffix_font, fill=(255, 255, 255))
underline_y = y + price_height + 18
draw.line([(price_x - 10, underline_y), (right_margin, underline_y)], fill=(255, 255, 255, 80), width=2)
ticket_text = content.get("ticket_type", "")
ticket_size=self.text_renderer.calculate_optimal_font_size(ticket_text,int((right_margin - x) * 0.7),max_size=60,min_size=30)
ticket_width,_=self.text_renderer.get_text_size(ticket_text,self.text_renderer._load_default_font(ticket_size))
ticket_font = self.text_renderer._load_default_font(ticket_size)
ticket_x = right_margin - ticket_width
ticket_y = y + price_height + 35
self.text_renderer.draw_text_with_shadow(draw, (ticket_x, ticket_y), ticket_text, ticket_font)
_, ticket_height = self.text_renderer.get_text_size(ticket_text, ticket_font)
remarks = content.get("remarks", [])
if remarks:
remarks_font = self.text_renderer._load_default_font(16)
remarks_y = ticket_y + ticket_height + 30
for i, remark in enumerate(remarks):
remark_width, _ = self.text_renderer.get_text_size(remark, remarks_font)
draw.text((right_margin - remark_width, remarks_y + i * 21), remark, font=remarks_font, fill=(255, 255, 255, 200))