TravelContentCreator/poster/templates/vibrant_template.py

485 lines
22 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, ImageFilter
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 overlay.filter(ImageFilter.GaussianBlur(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 _calculate_optimal_font_size_enhanced(self, text: str, target_width: int,
max_size: int = 120, min_size: int = 10) -> Tuple[int, int]:
"""
计算文本的最佳字体大小使其宽度接近目标宽度增强版本与demo一致
返回:
(字体大小, 实际文本宽度)
"""
# 二分查找最佳字体大小
low = min_size
high = max_size
best_size = min_size
best_width = 0
tolerance = 0.08 # 容差值,使文本宽度更接近目标值
# 首先尝试最大字体大小
try:
font = self.text_renderer._load_default_font(max_size)
max_width, _ = self.text_renderer.get_text_size(text, font)
except:
max_width = target_width * 2 # 如果出错,设置一个大值
# 如果最大字体大小下的宽度仍小于目标宽度的108%,直接使用最大字体
if max_width < target_width * (1 + tolerance):
best_size = max_size
best_width = max_width
else:
# 记录最接近目标宽度的字体大小
closest_size = min_size
closest_diff = target_width
while low <= high:
mid = (low + high) // 2
try:
font = self.text_renderer._load_default_font(mid)
width, _ = self.text_renderer.get_text_size(text, font)
except:
width = target_width * 2 # 如果出错,设置一个大值
# 计算与目标宽度的差距
diff = abs(width - target_width)
# 更新最接近的字体大小
if diff < closest_diff:
closest_diff = diff
closest_size = mid
# 如果宽度在目标宽度的允许范围内,认为找到了最佳匹配
if target_width * (1 - tolerance) <= width <= target_width * (1 + tolerance):
best_size = mid
best_width = width
break
# 如果当前宽度小于目标宽度,尝试更大的字体
if width < target_width:
if width > best_width:
best_width = width
best_size = mid
low = mid + 1
else:
# 如果当前宽度大于目标宽度,尝试更小的字体
high = mid - 1
# 如果没有找到在容差范围内的字体大小,使用最接近的字体大小
if best_width == 0:
best_size = closest_size
# 确保返回的宽度是使用最终字体计算的实际宽度
try:
best_font = self.text_renderer._load_default_font(best_size)
final_width, _ = self.text_renderer.get_text_size(text, best_font)
except:
final_width = best_width
logger.info(f"文本'{text[:min(10, len(text))]}...'的最佳字体大小: {best_size},目标宽度: {target_width},实际宽度: {final_width},差距: {abs(final_width-target_width)}")
return best_size, final_width
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]:
"""计算内容区域的左右边距增强版本与demo一致"""
# 计算标题位置
title_text = content.get("title", "")
title_target_width = int(width * 0.95)
title_size, title_width = self._calculate_optimal_font_size_enhanced(
title_text, title_target_width, min_size=40, max_size=130
)
title_x = center_x - title_width // 2
# 计算副标题位置
slogan_text = content.get("slogan", "")
subtitle_target_width = int(width * 0.9)
subtitle_size, subtitle_width = self._calculate_optimal_font_size_enhanced(
slogan_text, subtitle_target_width, max_size=50, min_size=20
)
subtitle_x = center_x - subtitle_width // 2
# 计算内容区域边距 - 减小额外的边距,让内容区域更宽
padding = 20 # 从30减小到20
content_left_margin = min(title_x, subtitle_x) - padding
content_right_margin = max(title_x + title_width, subtitle_x + subtitle_width) + padding
# 确保边距不超出合理范围,但允许更宽的内容区域
content_left_margin = max(40, content_left_margin)
content_right_margin = min(width - 40, content_right_margin)
# 如果内容区域太窄,强制使用更宽的区域
min_content_width = int(width * 0.75) # 至少使用75%的宽度
current_width = content_right_margin - content_left_margin
if current_width < min_content_width:
extra_width = min_content_width - current_width
content_left_margin = max(30, content_left_margin - extra_width // 2)
content_right_margin = min(width - 30, content_right_margin + extra_width // 2)
return content_left_margin, content_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:
"""渲染标题和副标题增强版本与demo一致"""
# 标题
title_text = content.get("title", "默认标题")
title_target_width = int((right - left) * 0.98)
title_size, title_actual_width = self._calculate_optimal_font_size_enhanced(
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, subtitle_actual_width = self._calculate_optimal_font_size_enhanced(
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):
"""渲染右栏内容价格、票种和备注增强版本与demo一致"""
price_text = content.get('price', '')
price_target_width = int((right_margin - x) * 0.7)
price_size, price_actual_width = self._calculate_optimal_font_size_enhanced(
price_text, price_target_width, max_size=120, min_size=40
)
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_actual_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_actual_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_target_width = int((right_margin - x) * 0.7)
ticket_size, ticket_actual_width = self._calculate_optimal_font_size_enhanced(
ticket_text, ticket_target_width, max_size=60, min_size=30
)
ticket_font = self.text_renderer._load_default_font(ticket_size)
ticket_x = right_margin - ticket_actual_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))