4220 lines
157 KiB
Python
4220 lines
157 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
重构模板演示程序
|
||
测试活力模板和商务模板的功能
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import random
|
||
from PIL import Image
|
||
|
||
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
活力模板
|
||
基于海洋模块的毛玻璃渐变效果
|
||
完全兼容原版海洋模块的布局逻辑
|
||
"""
|
||
|
||
import os
|
||
import random
|
||
import math
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageEnhance
|
||
from typing import Dict, List, Tuple, Optional, Any
|
||
|
||
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
基础模板类
|
||
定义所有海报模板的通用接口和基础功能
|
||
"""
|
||
|
||
from abc import ABC, abstractmethod
|
||
from typing import Dict, List, Tuple, Optional, Any
|
||
from PIL import Image
|
||
import numpy as np
|
||
|
||
|
||
|
||
"""
|
||
统一图像处理器
|
||
整合了酒店、海洋、通用模块中的图像处理功能
|
||
"""
|
||
|
||
import os
|
||
import numpy as np
|
||
from PIL import Image, ImageFilter, ImageEnhance
|
||
import cv2
|
||
from typing import Tuple, Optional, Union
|
||
|
||
|
||
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
统一文字渲染器
|
||
整合了酒店、海洋、通用模块中的文字渲染功能
|
||
"""
|
||
|
||
import os
|
||
import random
|
||
import math
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
from typing import Dict, List, Tuple, Optional, Union
|
||
|
||
|
||
class TextRenderer:
|
||
"""统一的文字渲染类"""
|
||
|
||
def __init__(self, font_dir: str = "/root/autodl-tmp/posterGenerator/assets/fonts"):
|
||
"""
|
||
初始化文字渲染器
|
||
|
||
Args:
|
||
font_dir: 字体文件目录
|
||
"""
|
||
self.font_dir = font_dir
|
||
self.default_fonts = [
|
||
"兰亭粗黑简.TTF",
|
||
"华康海报体简.ttc",
|
||
"方正粗黑宋简体.ttf"
|
||
]
|
||
|
||
# 默认字体大小配置
|
||
self.font_sizes = {
|
||
'title': 80,
|
||
'subtitle': 36,
|
||
'content': 24,
|
||
'price': 120,
|
||
'small': 18
|
||
}
|
||
|
||
def get_available_fonts(self) -> List[str]:
|
||
"""
|
||
获取可用的字体列表
|
||
|
||
Returns:
|
||
可用字体文件名列表
|
||
"""
|
||
if not os.path.exists(self.font_dir):
|
||
return []
|
||
|
||
font_files = []
|
||
for file in os.listdir(self.font_dir):
|
||
if file.lower().endswith(('.ttf', '.otf', '.ttc')):
|
||
font_files.append(file)
|
||
|
||
return font_files
|
||
|
||
def get_font_path(self, font_name: Optional[str] = None) -> str:
|
||
"""
|
||
获取字体文件路径
|
||
|
||
Args:
|
||
font_name: 字体文件名,如果为None则使用默认字体
|
||
|
||
Returns:
|
||
字体文件路径
|
||
"""
|
||
available_fonts = self.get_available_fonts()
|
||
|
||
if font_name and font_name in available_fonts:
|
||
return os.path.join(self.font_dir, font_name)
|
||
|
||
# 尝试使用默认字体
|
||
for default_font in self.default_fonts:
|
||
if default_font in available_fonts:
|
||
return os.path.join(self.font_dir, default_font)
|
||
|
||
# 如果没有找到默认字体,使用第一个可用字体
|
||
if available_fonts:
|
||
return os.path.join(self.font_dir, available_fonts[0])
|
||
|
||
# 如果没有任何字体,返回空字符串(会使用系统默认字体)
|
||
return ""
|
||
|
||
def load_font(self, size: int, font_name: Optional[str] = None) -> ImageFont.FreeTypeFont:
|
||
"""
|
||
加载字体
|
||
|
||
Args:
|
||
size: 字体大小
|
||
font_name: 字体文件名
|
||
|
||
Returns:
|
||
PIL字体对象
|
||
"""
|
||
try:
|
||
font_path = self.get_font_path(font_name)
|
||
if font_path:
|
||
return ImageFont.truetype(font_path, size)
|
||
else:
|
||
return ImageFont.load_default()
|
||
except Exception as e:
|
||
print(f"加载字体失败: {e}")
|
||
return ImageFont.load_default()
|
||
|
||
def calculate_optimal_font_size(self, text: str,
|
||
target_width: int,
|
||
font_name: Optional[str] = None,
|
||
max_size: int = 120,
|
||
min_size: int = 10) -> int:
|
||
"""
|
||
计算最适合的字体大小
|
||
|
||
Args:
|
||
text: 文字内容
|
||
target_width: 目标宽度
|
||
font_name: 字体文件名
|
||
max_size: 最大字体大小
|
||
min_size: 最小字体大小
|
||
|
||
Returns:
|
||
最适合的字体大小
|
||
"""
|
||
if not text.strip():
|
||
return min_size
|
||
|
||
font_path = self.get_font_path(font_name)
|
||
|
||
# 二分查找最佳字体大小
|
||
left, right = min_size, max_size
|
||
best_size = min_size
|
||
|
||
while left <= right:
|
||
mid_size = (left + right) // 2
|
||
|
||
try:
|
||
if font_path:
|
||
font = ImageFont.truetype(font_path, mid_size)
|
||
else:
|
||
font = ImageFont.load_default()
|
||
|
||
# 获取文字边界框
|
||
bbox = font.getbbox(text)
|
||
text_width = bbox[2] - bbox[0]
|
||
|
||
if text_width <= target_width:
|
||
best_size = mid_size
|
||
left = mid_size + 1
|
||
else:
|
||
right = mid_size - 1
|
||
|
||
except Exception:
|
||
right = mid_size - 1
|
||
|
||
return best_size
|
||
|
||
def get_text_size(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
|
||
"""
|
||
获取文字的尺寸
|
||
|
||
Args:
|
||
text: 文字内容
|
||
font: 字体对象
|
||
|
||
Returns:
|
||
文字尺寸 (width, height)
|
||
"""
|
||
bbox = font.getbbox(text)
|
||
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||
|
||
def draw_text_with_outline(self, draw: ImageDraw.Draw,
|
||
position: Tuple[int, int],
|
||
text: str,
|
||
font: ImageFont.FreeTypeFont,
|
||
text_color: Tuple[int, int, int, int] = (255, 255, 255, 255),
|
||
outline_color: Tuple[int, int, int, int] = (0, 0, 0, 255),
|
||
outline_width: int = 2):
|
||
"""
|
||
绘制带描边的文字
|
||
|
||
Args:
|
||
draw: PIL绘图对象
|
||
position: 文字位置
|
||
text: 文字内容
|
||
font: 字体对象
|
||
text_color: 文字颜色
|
||
outline_color: 描边颜色
|
||
outline_width: 描边宽度
|
||
"""
|
||
x, y = position
|
||
|
||
# 绘制描边
|
||
for offset_x in range(-outline_width, outline_width + 1):
|
||
for offset_y in range(-outline_width, outline_width + 1):
|
||
if offset_x == 0 and offset_y == 0:
|
||
continue
|
||
draw.text((x + offset_x, y + offset_y), text, font=font, fill=outline_color)
|
||
|
||
# 绘制文字
|
||
draw.text(position, text, font=font, fill=text_color)
|
||
|
||
def draw_text_with_shadow(self, draw: ImageDraw.Draw,
|
||
position: Tuple[int, int],
|
||
text: str,
|
||
font: ImageFont.FreeTypeFont,
|
||
text_color: Tuple[int, int, int, int] = (255, 255, 255, 255),
|
||
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||
shadow_offset: Tuple[int, int] = (2, 2)):
|
||
"""
|
||
绘制带阴影的文字
|
||
|
||
Args:
|
||
draw: PIL绘图对象
|
||
position: 文字位置
|
||
text: 文字内容
|
||
font: 字体对象
|
||
text_color: 文字颜色
|
||
shadow_color: 阴影颜色
|
||
shadow_offset: 阴影偏移
|
||
"""
|
||
x, y = position
|
||
shadow_x, shadow_y = shadow_offset
|
||
|
||
# 绘制阴影
|
||
draw.text((x + shadow_x, y + shadow_y), text, font=font, fill=shadow_color)
|
||
|
||
# 绘制文字
|
||
draw.text(position, text, font=font, fill=text_color)
|
||
|
||
def wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]:
|
||
"""
|
||
文字换行处理
|
||
|
||
Args:
|
||
text: 原始文字
|
||
font: 字体对象
|
||
max_width: 最大宽度
|
||
|
||
Returns:
|
||
换行后的文字列表
|
||
"""
|
||
if not text.strip():
|
||
return []
|
||
|
||
lines = []
|
||
words = text.split()
|
||
|
||
if not words:
|
||
return [text]
|
||
|
||
current_line = ""
|
||
|
||
for word in words:
|
||
test_line = current_line + (" " if current_line else "") + word
|
||
bbox = font.getbbox(test_line)
|
||
test_width = bbox[2] - bbox[0]
|
||
|
||
if test_width <= max_width:
|
||
current_line = test_line
|
||
else:
|
||
if current_line:
|
||
lines.append(current_line)
|
||
current_line = word
|
||
else:
|
||
# 单个词太长,强制换行
|
||
lines.append(word)
|
||
|
||
if current_line:
|
||
lines.append(current_line)
|
||
|
||
return lines
|
||
|
||
def draw_multiline_text(self, draw: ImageDraw.Draw,
|
||
position: Tuple[int, int],
|
||
text: str,
|
||
font: ImageFont.FreeTypeFont,
|
||
max_width: int,
|
||
line_spacing: int = 5,
|
||
align: str = "left",
|
||
text_color: Tuple[int, int, int, int] = (255, 255, 255, 255),
|
||
outline_color: Optional[Tuple[int, int, int, int]] = None,
|
||
outline_width: int = 1):
|
||
"""
|
||
绘制多行文字
|
||
|
||
Args:
|
||
draw: PIL绘图对象
|
||
position: 起始位置
|
||
text: 文字内容
|
||
font: 字体对象
|
||
max_width: 最大宽度
|
||
line_spacing: 行间距
|
||
align: 对齐方式 ("left", "center", "right")
|
||
text_color: 文字颜色
|
||
outline_color: 描边颜色(可选)
|
||
outline_width: 描边宽度
|
||
"""
|
||
lines = self.wrap_text(text, font, max_width)
|
||
if not lines:
|
||
return
|
||
|
||
x, y = position
|
||
|
||
# 计算行高
|
||
bbox = font.getbbox("测试")
|
||
line_height = bbox[3] - bbox[1] + line_spacing
|
||
|
||
for i, line in enumerate(lines):
|
||
line_y = y + i * line_height
|
||
|
||
# 计算x位置(根据对齐方式)
|
||
if align == "center":
|
||
bbox = font.getbbox(line)
|
||
line_width = bbox[2] - bbox[0]
|
||
line_x = x - line_width // 2
|
||
elif align == "right":
|
||
bbox = font.getbbox(line)
|
||
line_width = bbox[2] - bbox[0]
|
||
line_x = x - line_width
|
||
else: # left
|
||
line_x = x
|
||
|
||
# 绘制文字
|
||
if outline_color:
|
||
self.draw_text_with_outline(
|
||
draw, (line_x, line_y), line, font,
|
||
text_color, outline_color, outline_width
|
||
)
|
||
else:
|
||
draw.text((line_x, line_y), line, font=font, fill=text_color)
|
||
|
||
def draw_rounded_rectangle(self, draw: ImageDraw.Draw,
|
||
position: Tuple[int, int],
|
||
size: Tuple[int, int],
|
||
radius: int,
|
||
fill_color: Tuple[int, int, int, int],
|
||
outline_color: Optional[Tuple[int, int, int, int]] = None,
|
||
outline_width: int = 0):
|
||
"""
|
||
绘制圆角矩形
|
||
|
||
Args:
|
||
draw: PIL绘图对象
|
||
position: 左上角位置
|
||
size: 矩形大小
|
||
radius: 圆角半径
|
||
fill_color: 填充颜色
|
||
outline_color: 边框颜色
|
||
outline_width: 边框宽度
|
||
"""
|
||
x, y = position
|
||
width, height = size
|
||
|
||
# 确保尺寸有效
|
||
if width <= 0 or height <= 0:
|
||
return
|
||
|
||
# 限制圆角半径
|
||
radius = min(radius, width // 2, height // 2)
|
||
|
||
# 创建圆角矩形路径
|
||
# 这是一个简化版本,PIL的较新版本有更好的圆角矩形支持
|
||
if radius > 0:
|
||
# 绘制中心矩形
|
||
draw.rectangle([x + radius, y, x + width - radius, y + height], fill=fill_color)
|
||
draw.rectangle([x, y + radius, x + width, y + height - radius], fill=fill_color)
|
||
|
||
# 绘制四个圆角
|
||
draw.pieslice([x, y, x + 2*radius, y + 2*radius], 180, 270, fill=fill_color)
|
||
draw.pieslice([x + width - 2*radius, y, x + width, y + 2*radius], 270, 360, fill=fill_color)
|
||
draw.pieslice([x, y + height - 2*radius, x + 2*radius, y + height], 90, 180, fill=fill_color)
|
||
draw.pieslice([x + width - 2*radius, y + height - 2*radius, x + width, y + height], 0, 90, fill=fill_color)
|
||
else:
|
||
# 普通矩形
|
||
draw.rectangle([x, y, x + width, y + height], fill=fill_color)
|
||
|
||
# 绘制边框(如果需要)
|
||
if outline_color and outline_width > 0:
|
||
# 简化的边框绘制 - 使用线条而不是矩形避免坐标错误
|
||
for i in range(outline_width):
|
||
offset = i
|
||
# 确保坐标有效
|
||
if radius > 0:
|
||
# 上边
|
||
if x + radius + offset < x + width - radius - offset:
|
||
draw.line([x + radius + offset, y + offset,
|
||
x + width - radius - offset, y + offset],
|
||
fill=outline_color, width=1)
|
||
# 下边
|
||
if x + radius + offset < x + width - radius - offset and y + height - offset >= y + offset:
|
||
draw.line([x + radius + offset, y + height - offset,
|
||
x + width - radius - offset, y + height - offset],
|
||
fill=outline_color, width=1)
|
||
# 左边
|
||
if y + radius + offset < y + height - radius - offset:
|
||
draw.line([x + offset, y + radius + offset,
|
||
x + offset, y + height - radius - offset],
|
||
fill=outline_color, width=1)
|
||
# 右边
|
||
if y + radius + offset < y + height - radius - offset:
|
||
draw.line([x + width - offset, y + radius + offset,
|
||
x + width - offset, y + height - radius - offset],
|
||
fill=outline_color, width=1)
|
||
else:
|
||
# 普通矩形边框
|
||
draw.rectangle([x + offset, y + offset, x + width - offset, y + height - offset],
|
||
outline=outline_color, width=1)
|
||
|
||
def create_text_background(self, size: Tuple[int, int],
|
||
color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||
radius: int = 10) -> Image.Image:
|
||
"""
|
||
创建文字背景
|
||
|
||
Args:
|
||
size: 背景尺寸
|
||
color: 背景颜色
|
||
radius: 圆角半径
|
||
|
||
Returns:
|
||
背景图像
|
||
"""
|
||
background = Image.new('RGBA', size, (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(background)
|
||
|
||
if radius > 0:
|
||
self.draw_rounded_rectangle(draw, (0, 0), size, radius, color)
|
||
else:
|
||
draw.rectangle([0, 0, size[0], size[1]], fill=color)
|
||
|
||
return background
|
||
|
||
def render_text_with_background(self, canvas: Image.Image,
|
||
text: str,
|
||
position: Tuple[int, int],
|
||
font_size: int,
|
||
max_width: int,
|
||
text_color: Tuple[int, int, int, int] = (255, 255, 255, 255),
|
||
bg_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||
padding: int = 10,
|
||
align: str = "center",
|
||
font_name: Optional[str] = None) -> Image.Image:
|
||
"""
|
||
渲染带背景的文字
|
||
|
||
Args:
|
||
canvas: 画布
|
||
text: 文字内容
|
||
position: 位置
|
||
font_size: 字体大小
|
||
max_width: 最大宽度
|
||
text_color: 文字颜色
|
||
bg_color: 背景颜色
|
||
padding: 内边距
|
||
align: 对齐方式
|
||
font_name: 字体名称
|
||
|
||
Returns:
|
||
渲染后的画布
|
||
"""
|
||
if not text.strip():
|
||
return canvas
|
||
|
||
# 加载字体
|
||
font = self.load_font(font_size, font_name)
|
||
|
||
# 计算文字行
|
||
lines = self.wrap_text(text, font, max_width - 2 * padding)
|
||
if not lines:
|
||
return canvas
|
||
|
||
# 计算总尺寸
|
||
bbox = font.getbbox("测试")
|
||
line_height = bbox[3] - bbox[1]
|
||
total_height = len(lines) * line_height + (len(lines) - 1) * 5 + 2 * padding
|
||
|
||
max_line_width = 0
|
||
for line in lines:
|
||
bbox = font.getbbox(line)
|
||
line_width = bbox[2] - bbox[0]
|
||
max_line_width = max(max_line_width, line_width)
|
||
|
||
total_width = max_line_width + 2 * padding
|
||
|
||
# 创建文字背景
|
||
bg_image = self.create_text_background((total_width, total_height), bg_color, 10)
|
||
|
||
# 绘制文字
|
||
draw = ImageDraw.Draw(bg_image)
|
||
start_y = padding
|
||
|
||
for i, line in enumerate(lines):
|
||
line_y = start_y + i * (line_height + 5)
|
||
|
||
if align == "center":
|
||
bbox = font.getbbox(line)
|
||
line_width = bbox[2] - bbox[0]
|
||
line_x = (total_width - line_width) // 2
|
||
elif align == "right":
|
||
bbox = font.getbbox(line)
|
||
line_width = bbox[2] - bbox[0]
|
||
line_x = total_width - line_width - padding
|
||
else: # left
|
||
line_x = padding
|
||
|
||
draw.text((line_x, line_y), line, font=font, fill=text_color)
|
||
|
||
# 合成到画布上
|
||
x, y = position
|
||
if align == "center":
|
||
x = x - total_width // 2
|
||
elif align == "right":
|
||
x = x - total_width
|
||
|
||
canvas = canvas.copy()
|
||
canvas.paste(bg_image, (x, y), bg_image)
|
||
|
||
return canvas
|
||
"""
|
||
统一颜色提取器
|
||
基于酒店模块的完整实现,包含颜色和谐化算法
|
||
"""
|
||
|
||
import random
|
||
import math
|
||
import numpy as np
|
||
from PIL import Image
|
||
from collections import Counter
|
||
from typing import Tuple, List, Dict, Optional
|
||
|
||
|
||
class ColorExtractor:
|
||
"""统一的颜色处理类"""
|
||
|
||
# 预定义的颜色主题(顶部色,底部色)
|
||
COLOR_THEMES = {
|
||
"blue_gradient": [(35, 85, 150), (80, 160, 240)], # 蓝色渐变
|
||
"sunset": [(200, 60, 20), (250, 180, 90)], # 日落色彩
|
||
"forest": [(20, 80, 30), (120, 180, 70)], # 森林绿色
|
||
"ocean": [(0, 60, 100), (100, 210, 255)], # 海洋蓝色
|
||
"purple_dream": [(60, 20, 90), (180, 120, 240)], # 紫色梦幻
|
||
"elegant": [(40, 40, 60), (180, 180, 200)], # 优雅灰色
|
||
"ocean_deep": [(0, 30, 80), (20, 120, 220)], # 深海蓝渐变
|
||
"warm_sunset": [(255, 94, 77), (255, 154, 0)], # 暖色日落
|
||
"cool_mint": [(64, 224, 208), (127, 255, 212)], # 清凉薄荷
|
||
"royal_purple": [(75, 0, 130), (138, 43, 226)], # 皇家紫
|
||
}
|
||
|
||
@staticmethod
|
||
def extract_dominant_color(image: Image.Image,
|
||
sample_size: int = 200,
|
||
sample_method: str = "grid") -> Tuple[int, int, int]:
|
||
"""
|
||
从图像中提取主要颜色
|
||
|
||
Args:
|
||
image: PIL Image对象
|
||
sample_size: 采样点数量
|
||
sample_method: 采样方法 ("random", "grid", "edge")
|
||
|
||
Returns:
|
||
主要颜色的RGB元组
|
||
"""
|
||
# 转换为RGB模式以简化处理
|
||
if image.mode != 'RGB':
|
||
image = image.convert('RGB')
|
||
|
||
width, height = image.size
|
||
pixels = []
|
||
|
||
# 根据不同的采样方法收集像素
|
||
if sample_method == "random":
|
||
# 随机采样
|
||
for _ in range(sample_size):
|
||
x = random.randint(0, width-1)
|
||
y = random.randint(0, height-1)
|
||
pixel = image.getpixel((x, y))
|
||
# 忽略接近白色和接近黑色的像素
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
elif sample_method == "grid":
|
||
# 均匀网格采样,覆盖整个图像
|
||
grid_size = int(math.sqrt(sample_size))
|
||
x_step = max(1, width // grid_size)
|
||
y_step = max(1, height // grid_size)
|
||
|
||
for y in range(0, height, y_step):
|
||
for x in range(0, width, x_step):
|
||
if len(pixels) < sample_size:
|
||
pixel = image.getpixel((x, y))
|
||
# 过滤近黑近白颜色
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
elif sample_method == "edge":
|
||
# 边缘优先采样,图像四周区域
|
||
edge_width = min(width, height) // 4
|
||
|
||
# 采样四个边缘
|
||
edges = [
|
||
# 顶部边缘
|
||
[(x, y) for y in range(0, edge_width)
|
||
for x in range(0, width, width // (sample_size // 4))],
|
||
# 底部边缘
|
||
[(x, y) for y in range(height - edge_width, height)
|
||
for x in range(0, width, width // (sample_size // 4))],
|
||
# 左边缘
|
||
[(x, y) for x in range(0, edge_width)
|
||
for y in range(0, height, height // (sample_size // 4))],
|
||
# 右边缘
|
||
[(x, y) for x in range(width - edge_width, width)
|
||
for y in range(0, height, height // (sample_size // 4))]
|
||
]
|
||
|
||
for edge_points in edges:
|
||
for x, y in edge_points:
|
||
if x < width and y < height and len(pixels) < sample_size:
|
||
pixel = image.getpixel((x, y))
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
# 如果没有采样到合适的像素,返回默认颜色
|
||
if not pixels:
|
||
return ColorExtractor.get_default_color()
|
||
|
||
# 计算最常见的颜色
|
||
color_counter = Counter(pixels)
|
||
color_candidates = color_counter.most_common(5)
|
||
|
||
# 从候选颜色中选择饱和度适中的颜色
|
||
best_color = ColorExtractor.select_best_color(color_candidates)
|
||
|
||
# 调整颜色,使其更适合作为背景
|
||
adjusted_color = ColorExtractor.adjust_color_for_background(best_color)
|
||
|
||
return adjusted_color
|
||
|
||
@staticmethod
|
||
def select_best_color(color_candidates: List[Tuple[Tuple[int, int, int], int]]) -> Tuple[int, int, int]:
|
||
"""
|
||
从候选颜色中选择最适合的颜色(考虑饱和度和亮度)
|
||
|
||
Args:
|
||
color_candidates: 颜色候选列表,每个元素为 ((R,G,B), count)
|
||
|
||
Returns:
|
||
最佳颜色的RGB元组
|
||
"""
|
||
if not color_candidates:
|
||
return ColorExtractor.get_default_color()
|
||
|
||
best_color = None
|
||
best_score = -1
|
||
|
||
for color, count in color_candidates:
|
||
r, g, b = color
|
||
|
||
# 计算饱和度
|
||
max_val = max(r, g, b)
|
||
min_val = min(r, g, b)
|
||
saturation = (max_val - min_val) / max_val if max_val > 0 else 0
|
||
|
||
# 计算亮度
|
||
brightness = (r + g + b) / 3
|
||
|
||
# 综合评分:考虑饱和度、亮度和出现频率
|
||
# 偏好中等饱和度、中等亮度的颜色
|
||
saturation_score = 1 - abs(saturation - 0.6) # 最佳饱和度为0.6
|
||
brightness_score = 1 - abs(brightness - 128) / 128 # 最佳亮度为128
|
||
frequency_score = count / color_candidates[0][1] # 相对频率
|
||
|
||
# 综合评分
|
||
total_score = (saturation_score * 0.4 +
|
||
brightness_score * 0.4 +
|
||
frequency_score * 0.2)
|
||
|
||
if total_score > best_score:
|
||
best_score = total_score
|
||
best_color = color
|
||
|
||
return best_color if best_color else ColorExtractor.get_default_color()
|
||
|
||
@staticmethod
|
||
def adjust_color_for_background(color: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
||
"""
|
||
调整颜色,使其更适合作为背景
|
||
|
||
Args:
|
||
color: 输入颜色的RGB元组
|
||
|
||
Returns:
|
||
调整后的颜色RGB元组
|
||
"""
|
||
r, g, b = color
|
||
|
||
# 计算当前亮度
|
||
brightness = (r + g + b) / 3
|
||
|
||
# 如果颜色太亮,适当降低亮度
|
||
if brightness > 200:
|
||
factor = 0.7
|
||
r = int(r * factor)
|
||
g = int(g * factor)
|
||
b = int(b * factor)
|
||
|
||
# 如果颜色太暗,适当提高亮度
|
||
elif brightness < 50:
|
||
factor = 1.5
|
||
r = min(255, int(r * factor))
|
||
g = min(255, int(g * factor))
|
||
b = min(255, int(b * factor))
|
||
|
||
# 增加一些饱和度,让颜色更鲜艳
|
||
# 找到最大和最小值
|
||
max_val = max(r, g, b)
|
||
min_val = min(r, g, b)
|
||
|
||
if max_val > min_val:
|
||
# 增强饱和度
|
||
mid_val = (max_val + min_val) / 2
|
||
|
||
if r == max_val:
|
||
r = min(255, int(r + (r - mid_val) * 0.2))
|
||
elif r == min_val:
|
||
r = max(0, int(r - (mid_val - r) * 0.2))
|
||
|
||
if g == max_val:
|
||
g = min(255, int(g + (g - mid_val) * 0.2))
|
||
elif g == min_val:
|
||
g = max(0, int(g - (mid_val - g) * 0.2))
|
||
|
||
if b == max_val:
|
||
b = min(255, int(b + (b - mid_val) * 0.2))
|
||
elif b == min_val:
|
||
b = max(0, int(b - (mid_val - b) * 0.2))
|
||
|
||
return (r, g, b)
|
||
|
||
@staticmethod
|
||
def get_default_color() -> Tuple[int, int, int]:
|
||
"""
|
||
获取默认颜色
|
||
|
||
Returns:
|
||
默认颜色的RGB元组
|
||
"""
|
||
return (100, 150, 200) # 柔和的蓝色
|
||
|
||
@staticmethod
|
||
def ensure_colors_harmony(top_color: Tuple[int, int, int],
|
||
bottom_color: Tuple[int, int, int],
|
||
harmony_threshold: int = 30) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
|
||
"""
|
||
确保两个颜色之间的和谐性
|
||
|
||
Args:
|
||
top_color: 顶部颜色
|
||
bottom_color: 底部颜色
|
||
harmony_threshold: 和谐阈值
|
||
|
||
Returns:
|
||
调整后的颜色对
|
||
"""
|
||
def color_distance(c1, c2):
|
||
"""计算两个颜色的欧几里得距离"""
|
||
return math.sqrt(sum((a - b) ** 2 for a, b in zip(c1, c2)))
|
||
|
||
def adjust_color_harmony(base_color, target_color, factor=0.3):
|
||
"""调整目标颜色使其与基准颜色更和谐"""
|
||
adjusted = []
|
||
for i in range(3):
|
||
# 向基准颜色靠拢
|
||
new_val = target_color[i] + (base_color[i] - target_color[i]) * factor
|
||
adjusted.append(int(max(0, min(255, new_val))))
|
||
return tuple(adjusted)
|
||
|
||
# 计算当前颜色距离
|
||
distance = color_distance(top_color, bottom_color)
|
||
|
||
# 如果距离太小(颜色太相似),增加差异
|
||
if distance < harmony_threshold:
|
||
# 让底部颜色更亮一些
|
||
bottom_adjusted = []
|
||
for val in bottom_color:
|
||
new_val = min(255, val + harmony_threshold)
|
||
bottom_adjusted.append(new_val)
|
||
bottom_color = tuple(bottom_adjusted)
|
||
|
||
# 如果距离太大(颜色差异太大),增加和谐性
|
||
elif distance > harmony_threshold * 3:
|
||
bottom_color = adjust_color_harmony(top_color, bottom_color, 0.4)
|
||
|
||
return top_color, bottom_color
|
||
|
||
@staticmethod
|
||
def get_theme_colors(theme_name: Optional[str] = None) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
|
||
"""
|
||
获取主题颜色
|
||
|
||
Args:
|
||
theme_name: 主题名称,如果为None则随机选择
|
||
|
||
Returns:
|
||
主题颜色对 (top_color, bottom_color)
|
||
"""
|
||
if theme_name is None or theme_name not in ColorExtractor.COLOR_THEMES:
|
||
theme_name = random.choice(list(ColorExtractor.COLOR_THEMES.keys()))
|
||
|
||
colors = ColorExtractor.COLOR_THEMES[theme_name]
|
||
print(f"使用主题颜色: {theme_name}")
|
||
return colors[0], colors[1]
|
||
|
||
@staticmethod
|
||
def create_gradient_colors(base_color: Tuple[int, int, int],
|
||
variation: float = 0.3) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
|
||
"""
|
||
基于基础颜色创建渐变色彩
|
||
|
||
Args:
|
||
base_color: 基础颜色
|
||
variation: 变化幅度 (0-1)
|
||
|
||
Returns:
|
||
渐变色彩对 (darker_color, lighter_color)
|
||
"""
|
||
r, g, b = base_color
|
||
|
||
# 创建较暗的颜色
|
||
darker_r = max(0, int(r * (1 - variation)))
|
||
darker_g = max(0, int(g * (1 - variation)))
|
||
darker_b = max(0, int(b * (1 - variation)))
|
||
darker_color = (darker_r, darker_g, darker_b)
|
||
|
||
# 创建较亮的颜色
|
||
lighter_r = min(255, int(r * (1 + variation)))
|
||
lighter_g = min(255, int(g * (1 + variation)))
|
||
lighter_b = min(255, int(b * (1 + variation)))
|
||
lighter_color = (lighter_r, lighter_g, lighter_b)
|
||
|
||
return darker_color, lighter_color
|
||
|
||
@staticmethod
|
||
def get_complementary_color(color: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
||
"""
|
||
获取补色
|
||
|
||
Args:
|
||
color: 输入颜色
|
||
|
||
Returns:
|
||
补色
|
||
"""
|
||
r, g, b = color
|
||
return (255 - r, 255 - g, 255 - b)
|
||
|
||
@staticmethod
|
||
def get_analogous_colors(color: Tuple[int, int, int], count: int = 2) -> List[Tuple[int, int, int]]:
|
||
"""
|
||
获取类似色
|
||
|
||
Args:
|
||
color: 基础颜色
|
||
count: 类似色数量
|
||
|
||
Returns:
|
||
类似色列表
|
||
"""
|
||
import colorsys
|
||
|
||
r, g, b = [x / 255.0 for x in color]
|
||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||
|
||
analogous = []
|
||
step = 30 / 360 # 30度的色相差
|
||
|
||
for i in range(1, count + 1):
|
||
# 正负方向各生成一些类似色
|
||
for direction in [-1, 1]:
|
||
new_h = (h + direction * step * i) % 1.0
|
||
new_r, new_g, new_b = colorsys.hsv_to_rgb(new_h, s, v)
|
||
analogous_color = (
|
||
int(new_r * 255),
|
||
int(new_g * 255),
|
||
int(new_b * 255)
|
||
)
|
||
analogous.append(analogous_color)
|
||
|
||
if len(analogous) >= count:
|
||
break
|
||
|
||
if len(analogous) >= count:
|
||
break
|
||
|
||
return analogous[:count]
|
||
|
||
class ImageProcessor:
|
||
"""统一的图像处理类"""
|
||
|
||
@staticmethod
|
||
def resize_image(image: Union[Image.Image, np.ndarray], target_width: int) -> Image.Image:
|
||
"""
|
||
调整图像大小,保持原始高宽比
|
||
|
||
Args:
|
||
image: PIL Image对象或numpy数组
|
||
target_width: 目标宽度
|
||
|
||
Returns:
|
||
调整后的PIL Image对象
|
||
"""
|
||
if isinstance(image, np.ndarray):
|
||
image = Image.fromarray(image)
|
||
|
||
orig_aspect = image.width / image.height
|
||
target_height = int(target_width / orig_aspect)
|
||
return image.resize((target_width, target_height), Image.LANCZOS)
|
||
|
||
@staticmethod
|
||
def ensure_rgba(image: Image.Image) -> Image.Image:
|
||
"""
|
||
确保图像是RGBA模式
|
||
|
||
Args:
|
||
image: PIL Image对象
|
||
|
||
Returns:
|
||
RGBA模式的PIL Image对象
|
||
"""
|
||
if image.mode == 'RGBA':
|
||
return image
|
||
elif image.mode == 'RGB':
|
||
# 转换为RGBA模式
|
||
rgba_image = Image.new('RGBA', image.size, (0, 0, 0, 0))
|
||
rgba_image.paste(image, (0, 0))
|
||
return rgba_image
|
||
else:
|
||
return image.convert('RGBA')
|
||
|
||
@staticmethod
|
||
def resize_and_crop(image: Image.Image, target_size: Tuple[int, int]) -> Image.Image:
|
||
"""
|
||
调整图像大小并居中裁剪到目标尺寸
|
||
|
||
Args:
|
||
image: PIL Image对象
|
||
target_size: 目标尺寸 (width, height)
|
||
|
||
Returns:
|
||
调整后的PIL Image对象
|
||
"""
|
||
target_width, target_height = target_size
|
||
|
||
# 计算缩放比例,确保图像能完全覆盖目标区域
|
||
scale_width = target_width / image.width
|
||
scale_height = target_height / image.height
|
||
scale = max(scale_width, scale_height)
|
||
|
||
# 计算新尺寸
|
||
new_width = int(image.width * scale)
|
||
new_height = int(image.height * scale)
|
||
|
||
# 调整大小
|
||
resized = image.resize((new_width, new_height), Image.LANCZOS)
|
||
|
||
# 居中裁剪
|
||
start_x = (new_width - target_width) // 2
|
||
start_y = (new_height - target_height) // 2
|
||
|
||
cropped = resized.crop((
|
||
start_x,
|
||
start_y,
|
||
start_x + target_width,
|
||
start_y + target_height
|
||
))
|
||
|
||
return cropped
|
||
|
||
@staticmethod
|
||
def enhance_image(image: Union[Image.Image, np.ndarray],
|
||
contrast: float = 1.0,
|
||
brightness: float = 1.0,
|
||
saturation: float = 1.0) -> Image.Image:
|
||
"""
|
||
增强图像效果
|
||
|
||
Args:
|
||
image: PIL Image对象或numpy数组
|
||
contrast: 对比度增强系数
|
||
brightness: 亮度增强系数
|
||
saturation: 饱和度增强系数
|
||
|
||
Returns:
|
||
增强后的PIL Image对象
|
||
"""
|
||
if isinstance(image, np.ndarray):
|
||
image = Image.fromarray(image.astype('uint8'))
|
||
|
||
# 对比度增强
|
||
if contrast != 1.0:
|
||
enhancer = ImageEnhance.Contrast(image)
|
||
image = enhancer.enhance(contrast)
|
||
|
||
# 亮度增强
|
||
if brightness != 1.0:
|
||
enhancer = ImageEnhance.Brightness(image)
|
||
image = enhancer.enhance(brightness)
|
||
|
||
# 饱和度增强
|
||
if saturation != 1.0:
|
||
enhancer = ImageEnhance.Color(image)
|
||
image = enhancer.enhance(saturation)
|
||
|
||
return image
|
||
|
||
@staticmethod
|
||
def load_image(image_path: str) -> Optional[Image.Image]:
|
||
"""
|
||
加载图像文件
|
||
|
||
Args:
|
||
image_path: 图像文件路径
|
||
|
||
Returns:
|
||
PIL Image对象,如果加载失败返回None
|
||
"""
|
||
try:
|
||
if not os.path.exists(image_path):
|
||
print(f"图像文件不存在: {image_path}")
|
||
return None
|
||
|
||
image = Image.open(image_path)
|
||
print(f"已加载图像: {os.path.basename(image_path)}, 尺寸: {image.size}")
|
||
return image
|
||
|
||
except Exception as e:
|
||
print(f"加载图像失败: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def apply_blur(image: Image.Image, radius: float = 2.0) -> Image.Image:
|
||
"""
|
||
应用模糊效果
|
||
|
||
Args:
|
||
image: PIL Image对象
|
||
radius: 模糊半径
|
||
|
||
Returns:
|
||
模糊后的PIL Image对象
|
||
"""
|
||
return image.filter(ImageFilter.GaussianBlur(radius=radius))
|
||
|
||
@staticmethod
|
||
def create_canvas(size: Tuple[int, int], color: Tuple[int, int, int, int] = (255, 255, 255, 255)) -> Image.Image:
|
||
"""
|
||
创建指定尺寸和颜色的画布
|
||
|
||
Args:
|
||
size: 画布尺寸 (width, height)
|
||
color: 背景颜色 (R, G, B, A)
|
||
|
||
Returns:
|
||
PIL Image对象
|
||
"""
|
||
return Image.new('RGBA', size, color)
|
||
|
||
@staticmethod
|
||
def paste_image(canvas: Image.Image, image: Image.Image, position: Tuple[int, int], mask: Optional[Image.Image] = None) -> Image.Image:
|
||
"""
|
||
将图像粘贴到画布上
|
||
|
||
Args:
|
||
canvas: 目标画布
|
||
image: 要粘贴的图像
|
||
position: 粘贴位置 (x, y)
|
||
mask: 可选的蒙版
|
||
|
||
Returns:
|
||
粘贴后的画布
|
||
"""
|
||
canvas.paste(image, position, mask)
|
||
return canvas
|
||
|
||
@staticmethod
|
||
def alpha_composite(base: Image.Image, overlay: Image.Image) -> Image.Image:
|
||
"""
|
||
Alpha合成两个图像
|
||
|
||
Args:
|
||
base: 底层图像
|
||
overlay: 覆盖层图像
|
||
|
||
Returns:
|
||
合成后的图像
|
||
"""
|
||
# 确保两个图像都是RGBA模式
|
||
base = ImageProcessor.ensure_rgba(base)
|
||
overlay = ImageProcessor.ensure_rgba(overlay)
|
||
|
||
# 确保尺寸一致
|
||
if base.size != overlay.size:
|
||
overlay = overlay.resize(base.size, Image.LANCZOS)
|
||
|
||
return Image.alpha_composite(base, overlay)
|
||
|
||
class BaseTemplate(ABC):
|
||
"""海报模板基类"""
|
||
|
||
def __init__(self, size: Tuple[int, int] = (1350, 1800)):
|
||
"""
|
||
初始化基础模板
|
||
|
||
Args:
|
||
size: 海报尺寸 (width, height)
|
||
"""
|
||
self.size = size
|
||
self.width, self.height = size
|
||
|
||
# 初始化核心组件
|
||
self.image_processor = ImageProcessor()
|
||
self.color_extractor = ColorExtractor()
|
||
self.text_renderer = TextRenderer()
|
||
|
||
# 默认配置
|
||
self.default_config = {
|
||
'background_color': (255, 255, 255, 255),
|
||
'text_color': (0, 0, 0, 255),
|
||
'font_size': 36,
|
||
'padding': 20,
|
||
'margin': 10
|
||
}
|
||
|
||
@abstractmethod
|
||
def generate(self, **kwargs) -> Image.Image:
|
||
"""
|
||
生成海报的抽象方法
|
||
|
||
Args:
|
||
**kwargs: 生成参数
|
||
|
||
Returns:
|
||
生成的海报图像
|
||
"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_template_info(self) -> Dict[str, Any]:
|
||
"""
|
||
获取模板信息的抽象方法
|
||
|
||
Returns:
|
||
模板信息字典
|
||
"""
|
||
pass
|
||
|
||
def create_canvas(self, background_color: Optional[Tuple[int, int, int, int]] = None) -> Image.Image:
|
||
"""
|
||
创建画布
|
||
|
||
Args:
|
||
background_color: 背景颜色
|
||
|
||
Returns:
|
||
画布图像
|
||
"""
|
||
if background_color is None:
|
||
background_color = self.default_config['background_color']
|
||
|
||
return self.image_processor.create_canvas(self.size, background_color)
|
||
|
||
def create_gradient_background(self,
|
||
top_color: Tuple[int, int, int],
|
||
bottom_color: Tuple[int, int, int],
|
||
direction: str = "vertical") -> Image.Image:
|
||
"""
|
||
创建渐变背景
|
||
|
||
Args:
|
||
top_color: 顶部颜色
|
||
bottom_color: 底部颜色
|
||
direction: 渐变方向 ("vertical", "horizontal", "diagonal")
|
||
|
||
Returns:
|
||
渐变背景图像
|
||
"""
|
||
# 创建渐变数组
|
||
gradient = np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
||
|
||
if direction == "vertical":
|
||
# 垂直渐变
|
||
for y in range(self.height):
|
||
ratio = y / (self.height - 1)
|
||
color = [
|
||
int(top_color[i] * (1 - ratio) + bottom_color[i] * ratio)
|
||
for i in range(3)
|
||
]
|
||
gradient[y, :] = color
|
||
|
||
elif direction == "horizontal":
|
||
# 水平渐变
|
||
for x in range(self.width):
|
||
ratio = x / (self.width - 1)
|
||
color = [
|
||
int(top_color[i] * (1 - ratio) + bottom_color[i] * ratio)
|
||
for i in range(3)
|
||
]
|
||
gradient[:, x] = color
|
||
|
||
elif direction == "diagonal":
|
||
# 对角线渐变
|
||
for y in range(self.height):
|
||
for x in range(self.width):
|
||
ratio = (x + y) / (self.width + self.height - 2)
|
||
color = [
|
||
int(top_color[i] * (1 - ratio) + bottom_color[i] * ratio)
|
||
for i in range(3)
|
||
]
|
||
gradient[y, x] = color
|
||
|
||
# 转换为PIL图像
|
||
gradient_image = Image.fromarray(gradient, 'RGB')
|
||
return gradient_image.convert('RGBA')
|
||
|
||
def apply_transparency_gradient(self,
|
||
image: Image.Image,
|
||
direction: str = "vertical",
|
||
start_alpha: int = 255,
|
||
end_alpha: int = 0,
|
||
start_ratio: float = 0.0,
|
||
end_ratio: float = 1.0) -> Image.Image:
|
||
"""
|
||
应用透明度渐变
|
||
|
||
Args:
|
||
image: 输入图像
|
||
direction: 渐变方向
|
||
start_alpha: 起始透明度
|
||
end_alpha: 结束透明度
|
||
start_ratio: 起始位置比例
|
||
end_ratio: 结束位置比例
|
||
|
||
Returns:
|
||
应用透明度渐变后的图像
|
||
"""
|
||
image = self.image_processor.ensure_rgba(image)
|
||
width, height = image.size
|
||
|
||
# 创建透明度蒙版
|
||
mask = Image.new('L', (width, height), 255)
|
||
mask_array = np.array(mask)
|
||
|
||
if direction == "vertical":
|
||
start_y = int(height * start_ratio)
|
||
end_y = int(height * end_ratio)
|
||
|
||
for y in range(start_y, end_y):
|
||
if end_y > start_y:
|
||
ratio = (y - start_y) / (end_y - start_y)
|
||
alpha = int(start_alpha * (1 - ratio) + end_alpha * ratio)
|
||
mask_array[y, :] = alpha
|
||
|
||
elif direction == "horizontal":
|
||
start_x = int(width * start_ratio)
|
||
end_x = int(width * end_ratio)
|
||
|
||
for x in range(start_x, end_x):
|
||
if end_x > start_x:
|
||
ratio = (x - start_x) / (end_x - start_x)
|
||
alpha = int(start_alpha * (1 - ratio) + end_alpha * ratio)
|
||
mask_array[:, x] = alpha
|
||
|
||
# 应用蒙版
|
||
mask = Image.fromarray(mask_array, 'L')
|
||
image.putalpha(mask)
|
||
|
||
return image
|
||
|
||
def add_text_layer(self,
|
||
canvas: Image.Image,
|
||
text: str,
|
||
position: Tuple[int, int],
|
||
font_size: int,
|
||
color: Tuple[int, int, int, int] = (255, 255, 255, 255),
|
||
font_name: Optional[str] = None,
|
||
align: str = "center",
|
||
max_width: Optional[int] = None,
|
||
with_outline: bool = False,
|
||
outline_color: Tuple[int, int, int, int] = (0, 0, 0, 255),
|
||
outline_width: int = 2) -> Image.Image:
|
||
"""
|
||
添加文字层
|
||
|
||
Args:
|
||
canvas: 画布
|
||
text: 文字内容
|
||
position: 位置
|
||
font_size: 字体大小
|
||
color: 文字颜色
|
||
font_name: 字体名称
|
||
align: 对齐方式
|
||
max_width: 最大宽度
|
||
with_outline: 是否添加描边
|
||
outline_color: 描边颜色
|
||
outline_width: 描边宽度
|
||
|
||
Returns:
|
||
添加文字后的画布
|
||
"""
|
||
if not text.strip():
|
||
return canvas
|
||
|
||
# 如果没有指定最大宽度,使用画布宽度减去边距
|
||
if max_width is None:
|
||
max_width = self.width - 2 * self.default_config['padding']
|
||
|
||
# 加载字体
|
||
font = self.text_renderer.load_font(font_size, font_name)
|
||
|
||
# 创建绘图对象
|
||
from PIL import ImageDraw
|
||
draw = ImageDraw.Draw(canvas)
|
||
|
||
# 绘制文字
|
||
if with_outline:
|
||
self.text_renderer.draw_text_with_outline(
|
||
draw, position, text, font, color, outline_color, outline_width
|
||
)
|
||
else:
|
||
# 处理多行文字
|
||
if max_width and len(text) > 10: # 长文本自动换行
|
||
self.text_renderer.draw_multiline_text(
|
||
draw, position, text, font, max_width,
|
||
align=align, text_color=color
|
||
)
|
||
else:
|
||
# 单行文字,根据对齐方式调整位置
|
||
if align == "center":
|
||
text_width, _ = self.text_renderer.get_text_size(text, font)
|
||
position = (position[0] - text_width // 2, position[1])
|
||
elif align == "right":
|
||
text_width, _ = self.text_renderer.get_text_size(text, font)
|
||
position = (position[0] - text_width, position[1])
|
||
|
||
draw.text(position, text, font=font, fill=color)
|
||
|
||
return canvas
|
||
|
||
def add_image_layer(self,
|
||
canvas: Image.Image,
|
||
image: Image.Image,
|
||
position: Tuple[int, int],
|
||
size: Optional[Tuple[int, int]] = None,
|
||
fit_mode: str = "contain",
|
||
opacity: float = 1.0) -> Image.Image:
|
||
"""
|
||
添加图像层
|
||
|
||
Args:
|
||
canvas: 画布
|
||
image: 要添加的图像
|
||
position: 位置
|
||
size: 目标尺寸
|
||
fit_mode: 适应模式 ("contain", "cover", "stretch")
|
||
opacity: 不透明度 (0-1)
|
||
|
||
Returns:
|
||
添加图像后的画布
|
||
"""
|
||
if size:
|
||
if fit_mode == "stretch":
|
||
# 拉伸到指定尺寸
|
||
image = image.resize(size, Image.LANCZOS)
|
||
elif fit_mode == "cover":
|
||
# 覆盖模式,裁剪适应
|
||
image = self.image_processor.resize_and_crop(image, size)
|
||
else: # contain
|
||
# 包含模式,保持比例
|
||
image = self.image_processor.resize_image(image, size[0])
|
||
if image.height > size[1]:
|
||
# 如果高度超出,按高度缩放
|
||
scale = size[1] / image.height
|
||
new_width = int(image.width * scale)
|
||
image = image.resize((new_width, size[1]), Image.LANCZOS)
|
||
|
||
# 应用透明度
|
||
if opacity < 1.0:
|
||
image = self.image_processor.ensure_rgba(image)
|
||
alpha = image.split()[-1]
|
||
alpha = alpha.point(lambda p: int(p * opacity))
|
||
image.putalpha(alpha)
|
||
|
||
# 粘贴到画布上
|
||
image = self.image_processor.ensure_rgba(image)
|
||
canvas.paste(image, position, image)
|
||
|
||
return canvas
|
||
|
||
def save_poster(self, image: Image.Image, output_path: str, quality: int = 95):
|
||
"""
|
||
保存海报
|
||
|
||
Args:
|
||
image: 海报图像
|
||
output_path: 输出路径
|
||
quality: 图像质量 (1-100)
|
||
"""
|
||
try:
|
||
# 如果是RGBA模式且要保存为JPEG,需要转换
|
||
if image.mode == 'RGBA' and output_path.lower().endswith(('.jpg', '.jpeg')):
|
||
# 创建白色背景
|
||
background = Image.new('RGB', image.size, (255, 255, 255))
|
||
background.paste(image, mask=image.split()[-1])
|
||
background.save(output_path, 'JPEG', quality=quality)
|
||
else:
|
||
image.save(output_path, quality=quality)
|
||
|
||
print(f"海报已保存到: {output_path}")
|
||
|
||
except Exception as e:
|
||
print(f"保存海报失败: {e}")
|
||
|
||
def validate_inputs(self, **kwargs) -> bool:
|
||
"""
|
||
验证输入参数
|
||
|
||
Args:
|
||
**kwargs: 输入参数
|
||
|
||
Returns:
|
||
验证是否通过
|
||
"""
|
||
# 基础验证逻辑,子类可以重写
|
||
return True
|
||
|
||
def get_layout_areas(self) -> Dict[str, Tuple[int, int, int, int]]:
|
||
"""
|
||
获取布局区域定义
|
||
|
||
Returns:
|
||
布局区域字典,格式为 {"区域名": (x, y, width, height)}
|
||
"""
|
||
# 默认布局区域
|
||
padding = self.default_config['padding']
|
||
|
||
return {
|
||
"header": (padding, padding, self.width - 2*padding, self.height // 4),
|
||
"content": (padding, self.height // 4, self.width - 2*padding, self.height // 2),
|
||
"footer": (padding, 3*self.height // 4, self.width - 2*padding, self.height // 4)
|
||
}
|
||
|
||
def apply_filter(self, image: Image.Image, filter_type: str, **params) -> Image.Image:
|
||
"""
|
||
应用图像滤镜
|
||
|
||
Args:
|
||
image: 输入图像
|
||
filter_type: 滤镜类型
|
||
**params: 滤镜参数
|
||
|
||
Returns:
|
||
应用滤镜后的图像
|
||
"""
|
||
if filter_type == "blur":
|
||
radius = params.get("radius", 2.0)
|
||
return self.image_processor.apply_blur(image, radius)
|
||
|
||
elif filter_type == "enhance":
|
||
contrast = params.get("contrast", 1.0)
|
||
brightness = params.get("brightness", 1.0)
|
||
saturation = params.get("saturation", 1.0)
|
||
return self.image_processor.enhance_image(image, contrast, brightness, saturation)
|
||
|
||
else:
|
||
return image
|
||
|
||
def get_color_palette(self, image: Image.Image, count: int = 5) -> List[Tuple[int, int, int]]:
|
||
"""
|
||
从图像中提取颜色调色板
|
||
|
||
Args:
|
||
image: 输入图像
|
||
count: 颜色数量
|
||
|
||
Returns:
|
||
颜色列表
|
||
"""
|
||
# 提取主色调
|
||
dominant_color = self.color_extractor.extract_dominant_color(image)
|
||
|
||
# 生成调色板
|
||
palette = [dominant_color]
|
||
|
||
# 添加类似色
|
||
analogous_colors = self.color_extractor.get_analogous_colors(dominant_color, count - 2)
|
||
palette.extend(analogous_colors)
|
||
|
||
# 添加补色
|
||
if len(palette) < count:
|
||
complementary = self.color_extractor.get_complementary_color(dominant_color)
|
||
palette.append(complementary)
|
||
|
||
return palette[:count]
|
||
|
||
class VibrantTemplate(BaseTemplate):
|
||
"""活力风格海报模板(基于海洋模块)"""
|
||
|
||
def __init__(self, size: Tuple[int, int] = (1350, 1800)):
|
||
"""
|
||
初始化活力模板
|
||
|
||
Args:
|
||
size: 海报尺寸,默认为海洋海报的比例
|
||
"""
|
||
super().__init__(size)
|
||
|
||
# 海洋模块原版配置
|
||
self.ocean_config = {
|
||
'gradient_height_ratio': 1/3,
|
||
'ocean_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)],
|
||
"drak_gray":[(15,15,15),(30,30,30)]
|
||
},
|
||
'glass_effect': {
|
||
'max_opacity': 240,
|
||
'blur_radius': 22,
|
||
'transition_height': 80,
|
||
'intensity_multiplier': 1.5 # 毛玻璃强度倍数,可调节
|
||
},
|
||
'font_sizes': {
|
||
'title': 120,
|
||
'subtitle': 54,
|
||
'price': 180,
|
||
'normal': 36,
|
||
'small': 24
|
||
}
|
||
}
|
||
|
||
def generate(self,
|
||
image_path: str,
|
||
ocean_info: Optional[Dict[str, Any]] = None,
|
||
theme_color: str = "ocean_deep",
|
||
glass_intensity: float = 1.5, # 毛玻璃强度倍数,默认1.5倍
|
||
output_path: str = "vibrant_poster.png",
|
||
**kwargs) -> Image.Image:
|
||
"""
|
||
生成活力风格海报(兼容原版海洋模块接口)
|
||
|
||
Args:
|
||
image_path: 主图片路径
|
||
ocean_info: 海洋主题信息字典(与原版兼容)
|
||
theme_color: 主题颜色
|
||
glass_intensity: 毛玻璃效果强度倍数(1.0为标准强度,2.0为双倍强度)
|
||
output_path: 输出路径
|
||
**kwargs: 其他参数
|
||
|
||
Returns:
|
||
生成的海报图像
|
||
"""
|
||
print("开始生成活力风格海报(海洋模式)...")
|
||
|
||
# 设置毛玻璃强度
|
||
self.ocean_config['glass_effect']['intensity_multiplier'] = glass_intensity
|
||
print(f"毛玻璃效果强度设置为: {glass_intensity}倍")
|
||
|
||
# 如果没有提供ocean_info,使用默认值
|
||
if ocean_info is None:
|
||
ocean_info = self._get_default_ocean_info()
|
||
|
||
# 1. 加载主图片
|
||
main_image = self.image_processor.load_image(image_path)
|
||
if not main_image:
|
||
raise ValueError(f"无法加载图片: {image_path}")
|
||
|
||
print(f"已加载底板图片: {image_path}")
|
||
|
||
# 2. 调整图像大小并居中裁剪到目标尺寸(与海洋模板完全一致)
|
||
main_image = self.image_processor.resize_and_crop(main_image, (self.width, self.height))
|
||
print(f"调整后图像尺寸: {self.width}x{self.height}")
|
||
|
||
# 3. 预估文本内容所需高度
|
||
estimated_height = self._estimate_content_height(ocean_info)
|
||
print(f"预估文本内容高度: {estimated_height}像素")
|
||
|
||
# 4. 动态检测渐变起始位置(与原版逻辑一致)
|
||
gradient_start = self._detect_gradient_start_position(main_image, estimated_height)
|
||
print(f"渐变层起始位置: 距顶部{gradient_start}像素")
|
||
|
||
# 5. 创建复合图像
|
||
canvas = self._create_composite_image(main_image, gradient_start, theme_color)
|
||
|
||
# 6. 渲染文本内容(使用原版布局逻辑)
|
||
canvas = self._render_ocean_texts_original_layout(canvas, ocean_info, gradient_start)
|
||
|
||
# 7. 最终调整尺寸并保存
|
||
final_image = canvas.resize((1350, 1800), Image.LANCZOS)
|
||
self.save_poster(final_image, output_path)
|
||
|
||
print("活力风格海报生成完成!")
|
||
return final_image
|
||
|
||
def _get_default_ocean_info(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 _detect_gradient_start_position(self, image: Image.Image, estimated_height: int) -> int:
|
||
"""
|
||
动态检测渐变起始位置(与原版海洋模块逻辑一致)
|
||
|
||
Args:
|
||
image: 主图片
|
||
estimated_height: 预估内容高度
|
||
|
||
Returns:
|
||
渐变起始位置
|
||
"""
|
||
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))
|
||
# 简化检测逻辑,避免复杂的alpha通道判断
|
||
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 _estimate_content_height(self, ocean_info: Dict[str, Any]) -> int:
|
||
"""预估内容高度(与原版逻辑一致)"""
|
||
# 标准间距
|
||
standard_margin = 25
|
||
|
||
# 1. 标题高度估算
|
||
title_height = 100
|
||
|
||
# 2. 副标题高度估算
|
||
subtitle_height = 80
|
||
|
||
# 3. 套餐内容按钮
|
||
button_height = 40
|
||
|
||
# 4. 套餐内容列表
|
||
content_items = ocean_info.get("content_items", [])
|
||
content_line_height = 32 # 22 + 10
|
||
content_list_height = len(content_items) * content_line_height
|
||
|
||
# 5. 价格和票种区域
|
||
price_height = 90
|
||
ticket_height = 60
|
||
|
||
# 6. 备注区域
|
||
remarks = ocean_info.get("remarks", [])
|
||
if isinstance(remarks, str):
|
||
remarks = [remarks]
|
||
remarks_height = len(remarks) * 25 + 10
|
||
|
||
# 7. 页脚高度
|
||
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 _create_composite_image(self, main_image: Image.Image,
|
||
gradient_start: int,
|
||
theme_color: str) -> Image.Image:
|
||
"""创建复合图像"""
|
||
# 获取主题颜色
|
||
if theme_color in self.ocean_config['ocean_colors']:
|
||
top_color, bottom_color = self.ocean_config['ocean_colors'][theme_color]
|
||
else:
|
||
# 从图片中提取颜色
|
||
top_color, bottom_color = self._extract_glass_colors_from_image(
|
||
main_image, gradient_start
|
||
)
|
||
|
||
print(f"使用毛玻璃颜色: 顶部={top_color}, 底部={bottom_color}")
|
||
|
||
# 创建渐变透明覆盖层
|
||
gradient_overlay = self._create_frosted_glass_overlay(
|
||
top_color, bottom_color, gradient_start
|
||
)
|
||
|
||
# 合成图像
|
||
composite_img = Image.new('RGBA', (self.width, self.height), (0, 0, 0, 0))
|
||
composite_img.paste(main_image, (0, 0))
|
||
composite_img = Image.alpha_composite(composite_img, gradient_overlay)
|
||
|
||
return composite_img
|
||
|
||
def _extract_glass_colors_from_image(self, image: Image.Image,
|
||
gradient_start: int) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
|
||
"""从图像中提取毛玻璃颜色"""
|
||
# 转换为RGB模式
|
||
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):
|
||
try:
|
||
pixel = image.getpixel((x, top_y))
|
||
if sum(pixel) > 30: # 避免纯黑色
|
||
top_samples.append(pixel)
|
||
except:
|
||
continue
|
||
|
||
# 底部区域采样
|
||
bottom_y = min(height - 50, height - 1)
|
||
for x in range(0, width, 20):
|
||
try:
|
||
pixel = image.getpixel((x, bottom_y))
|
||
if sum(pixel) > 30: # 避免纯黑色
|
||
bottom_samples.append(pixel)
|
||
except:
|
||
continue
|
||
|
||
# 计算平均颜色并调暗
|
||
if top_samples:
|
||
top_avg = tuple(int(sum(c[i] for c in top_samples) / len(top_samples)) for i in range(3))
|
||
top_color = tuple(max(0, int(c * 0.1)) for c in top_avg) # 调暗到10%
|
||
else:
|
||
top_color = (0, 5, 15)
|
||
|
||
if bottom_samples:
|
||
bottom_avg = tuple(int(sum(c[i] for c in bottom_samples) / len(bottom_samples)) for i in range(3))
|
||
bottom_color = tuple(max(0, int(c * 0.2)) for c in bottom_avg) # 调暗到20%
|
||
else:
|
||
bottom_color = (0, 25, 50)
|
||
|
||
return top_color, bottom_color
|
||
|
||
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.width, self.height), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
gradient_height = self.height - gradient_start
|
||
max_opacity = self.ocean_config['glass_effect']['max_opacity']
|
||
transition_height = self.ocean_config['glass_effect']['transition_height']
|
||
intensity_multiplier = self.ocean_config['glass_effect']['intensity_multiplier']
|
||
|
||
# 应用强度倍数
|
||
enhanced_max_opacity = min(255, int(max_opacity * intensity_multiplier))
|
||
enhanced_blur_radius = int(self.ocean_config['glass_effect']['blur_radius'] * intensity_multiplier)
|
||
|
||
print(f"毛玻璃效果参数: 最大透明度={enhanced_max_opacity}, 模糊半径={enhanced_blur_radius}")
|
||
|
||
# 确保颜色是三元组
|
||
if len(top_color) > 3:
|
||
top_color = top_color[:3]
|
||
if len(bottom_color) > 3:
|
||
bottom_color = bottom_color[:3]
|
||
|
||
# 增强颜色饱和度(基于强度倍数)
|
||
def enhance_color(color, multiplier):
|
||
r, g, b = color
|
||
# 增强饱和度和深度
|
||
factor = min(1.5, 1.0 + (multiplier - 1.0) * 0.3)
|
||
enhanced_r = min(255, max(0, int(r * factor)))
|
||
enhanced_g = min(255, max(0, int(g * factor)))
|
||
enhanced_b = min(255, max(0, int(b * factor)))
|
||
return (enhanced_r, enhanced_g, enhanced_b)
|
||
|
||
enhanced_top_color = enhance_color(top_color, intensity_multiplier)
|
||
enhanced_bottom_color = enhance_color(bottom_color, intensity_multiplier)
|
||
|
||
top_color_array = np.array(enhanced_top_color)
|
||
bottom_color_array = np.array(enhanced_bottom_color)
|
||
|
||
# 为每一行计算渐变颜色和透明度
|
||
for y in range(gradient_start, self.height):
|
||
relative_y = y - gradient_start
|
||
ratio = relative_y / gradient_height if gradient_height > 0 else 0
|
||
|
||
# 使用余弦插值实现更自然的渐变效果
|
||
smooth_ratio = 0.5 - 0.5 * math.cos(ratio * math.pi)
|
||
|
||
# 计算当前行的颜色
|
||
color = (1 - smooth_ratio) * top_color_array + smooth_ratio * bottom_color_array
|
||
|
||
# 计算透明度 - 使用更平滑的衰减曲线,并应用强度倍数
|
||
alpha_smooth = ratio ** (1.1 / intensity_multiplier) # 强度越高,衰减越缓慢
|
||
alpha = int(enhanced_max_opacity * (0.02 + 0.98 * alpha_smooth))
|
||
|
||
# 在过渡区域内应用额外的平滑效果
|
||
if relative_y < transition_height:
|
||
transition_ratio = relative_y / transition_height
|
||
smooth_transition = 0.5 - 0.5 * math.cos(transition_ratio * math.pi)
|
||
alpha = int(alpha * smooth_transition)
|
||
|
||
r, g, b = [int(c) for c in color]
|
||
color_tuple = (r, g, b, alpha)
|
||
|
||
# 绘制当前行
|
||
draw.line([(0, y), (self.width, y)], fill=color_tuple)
|
||
|
||
# 应用增强的模糊效果
|
||
overlay = overlay.filter(ImageFilter.GaussianBlur(radius=enhanced_blur_radius))
|
||
|
||
return overlay
|
||
|
||
def _render_ocean_texts_original_layout(self, canvas: Image.Image,
|
||
ocean_info: Dict[str, Any],
|
||
gradient_start: int) -> Image.Image:
|
||
"""
|
||
渲染海洋主题文本(完全使用原版布局逻辑)
|
||
"""
|
||
draw = ImageDraw.Draw(canvas)
|
||
width, height = canvas.size
|
||
center_x = width // 2
|
||
|
||
# 加载字体
|
||
fonts = self._load_ocean_fonts()
|
||
font_path = self.text_renderer.get_font_path()
|
||
|
||
# 计算边距和布局(与原版完全一致)
|
||
left_margin, right_margin = self._calculate_content_margins(
|
||
ocean_info, width, center_x, font_path
|
||
)
|
||
|
||
print(f"内容区域边距: 左={left_margin}, 右={right_margin}, 宽度={right_margin - left_margin}")
|
||
|
||
# 1. 渲染页脚(标签和分页)
|
||
bottom_margin = 30
|
||
footer_y = height - bottom_margin
|
||
self._render_footer_original(draw, ocean_info, footer_y, left_margin, right_margin, fonts)
|
||
|
||
# 2. 渲染标题和副标题
|
||
title_y = gradient_start + 40 # 标题边距
|
||
current_y = self._render_title_subtitle_original(
|
||
draw, ocean_info, title_y, center_x, left_margin, right_margin, fonts, font_path
|
||
)
|
||
|
||
# 3. 计算两栏布局
|
||
content_area_width = right_margin - left_margin
|
||
left_column_width = int(content_area_width * 0.5)
|
||
right_column_x = left_margin + left_column_width
|
||
|
||
# 4. 渲染左栏(套餐内容)
|
||
content_start_y = current_y + 30 # 内容间距
|
||
self._render_left_column_original(
|
||
draw, ocean_info, content_start_y, left_margin, left_column_width, fonts, font_path
|
||
)
|
||
|
||
# 5. 渲染右栏(价格和票种)
|
||
self._render_right_column_original(
|
||
draw, ocean_info, content_start_y, right_column_x, right_margin,
|
||
footer_y, fonts, font_path
|
||
)
|
||
|
||
return canvas
|
||
|
||
def _calculate_content_margins(self, ocean_info: Dict[str, Any], width: int,
|
||
center_x: int, font_path: str) -> Tuple[int, int]:
|
||
"""计算内容区域边距(优化版本)"""
|
||
# 计算标题位置
|
||
title_text = ocean_info["title"]
|
||
# 增大标题目标宽度比例,使用更大的区域
|
||
title_target_width = int(width * 0.95)
|
||
title_size, title_width = self._calculate_optimal_font_size_simple(
|
||
title_text, font_path, title_target_width, min_size=40, max_size=130
|
||
)
|
||
title_x = center_x - title_width // 2
|
||
|
||
# 计算副标题位置
|
||
subtitle_text = ocean_info["slogan"]
|
||
# 增大副标题目标宽度比例
|
||
subtitle_target_width = int(width * 0.9)
|
||
subtitle_size, subtitle_width = self._calculate_optimal_font_size_simple(
|
||
subtitle_text, font_path, 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 _calculate_optimal_font_size_simple(self, text: str, font_path: str,
|
||
target_width: int, max_size: int = 120,
|
||
min_size: int = 10) -> Tuple[int, int]:
|
||
"""
|
||
计算文本的最佳字体大小,使其宽度接近目标宽度(与海洋模板完全一致)
|
||
|
||
返回:
|
||
(字体大小, 实际文本宽度)
|
||
"""
|
||
# 二分查找最佳字体大小
|
||
low = min_size
|
||
high = max_size
|
||
best_size = min_size
|
||
best_width = 0
|
||
tolerance = 0.08 # 降低容差值,从0.15改为0.08,使文本宽度更接近目标值
|
||
|
||
# 首先尝试最大字体大小
|
||
try:
|
||
font = ImageFont.truetype(font_path, max_size)
|
||
bbox = font.getbbox(text)
|
||
max_width = bbox[2] - bbox[0]
|
||
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 = ImageFont.truetype(font_path, mid)
|
||
bbox = font.getbbox(text)
|
||
width = bbox[2] - bbox[0]
|
||
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 = ImageFont.truetype(font_path, best_size)
|
||
final_bbox = best_font.getbbox(text)
|
||
final_width = final_bbox[2] - final_bbox[0]
|
||
except:
|
||
final_width = best_width
|
||
|
||
print(f"文本'{text[:min(10, len(text))]}...'的最佳字体大小: {best_size},目标宽度: {target_width},实际宽度: {final_width},差距: {abs(final_width-target_width)}")
|
||
|
||
return best_size, final_width
|
||
|
||
def _render_footer_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any],
|
||
footer_y: int, left_margin: int, right_margin: int,
|
||
fonts: Dict) -> None:
|
||
"""渲染页脚(原版逻辑)"""
|
||
footer_font = fonts.get('small', self.text_renderer.load_font(18))
|
||
|
||
# 标签(左下角)
|
||
tag_text = ocean_info.get("tag", "")
|
||
if tag_text:
|
||
draw.text((left_margin, footer_y), tag_text, font=footer_font, fill=(255, 255, 255))
|
||
|
||
# 分页(右下角)
|
||
pagination_text = ocean_info.get("pagination", "")
|
||
if pagination_text:
|
||
try:
|
||
pagination_bbox = footer_font.getbbox(pagination_text)
|
||
pagination_width = pagination_bbox[2] - pagination_bbox[0]
|
||
pagination_x = right_margin - pagination_width
|
||
draw.text((pagination_x, footer_y), pagination_text,
|
||
font=footer_font, fill=(255, 255, 255))
|
||
except:
|
||
pass
|
||
|
||
def _render_title_subtitle_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any],
|
||
title_y: int, center_x: int, left_margin: int,
|
||
right_margin: int, fonts: Dict, font_path: str) -> int:
|
||
"""渲染标题和副标题(原版逻辑)"""
|
||
# 标题
|
||
title_text = ocean_info["title"]
|
||
# 增大标题目标宽度比例,从0.95改为0.98
|
||
title_target_width = int((right_margin - left_margin) * 0.98)
|
||
title_size, title_width = self._calculate_optimal_font_size_simple(
|
||
title_text, font_path, title_target_width, min_size=40, max_size=140
|
||
)
|
||
title_font = self.text_renderer.load_font(title_size)
|
||
title_x = center_x - title_width // 2
|
||
|
||
# 渲染标题(带描边)
|
||
self.text_renderer.draw_text_with_outline(
|
||
draw, (title_x, title_y), title_text, title_font,
|
||
text_color=(255, 255, 255, 255),
|
||
outline_color=(0, 30, 80, 200),
|
||
outline_width=4
|
||
)
|
||
|
||
title_bbox = title_font.getbbox(title_text)
|
||
title_height = title_bbox[3] - title_bbox[1]
|
||
|
||
# 副标题
|
||
subtitle_text = ocean_info["slogan"]
|
||
# 增大副标题目标宽度比例,从0.9改为0.95
|
||
subtitle_target_width = int((right_margin - left_margin) * 0.95)
|
||
subtitle_size, subtitle_width = self._calculate_optimal_font_size_simple(
|
||
subtitle_text, font_path, subtitle_target_width, max_size=75, min_size=20
|
||
)
|
||
subtitle_font = self.text_renderer.load_font(subtitle_size)
|
||
subtitle_x = center_x - subtitle_width // 2
|
||
|
||
title_spacing = 30
|
||
subtitle_y = title_y + title_height + 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)
|
||
)
|
||
|
||
subtitle_bbox = subtitle_font.getbbox(subtitle_text)
|
||
subtitle_height = subtitle_bbox[3] - subtitle_bbox[1]
|
||
|
||
# 分隔线
|
||
title_line_y = subtitle_y - title_spacing // 2
|
||
line_width = int(title_width * 1.1)
|
||
line_start_x = center_x - line_width // 2
|
||
line_end_x = center_x + line_width // 2
|
||
|
||
draw.line(
|
||
[(line_start_x, title_line_y), (line_end_x, title_line_y)],
|
||
fill=(255, 255, 255, 100), width=2
|
||
)
|
||
|
||
return subtitle_y + subtitle_height
|
||
|
||
def _render_left_column_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any],
|
||
content_start_y: int, left_margin: int,
|
||
left_column_width: int, fonts: Dict, font_path: str) -> None:
|
||
"""渲染左栏内容(原版逻辑,与海洋模板完全一致)"""
|
||
# 套餐内容按钮
|
||
button_font = self.text_renderer.load_font(30) # 从24增大到30
|
||
button_text = ocean_info.get("content_button", "套餐内容")
|
||
|
||
try:
|
||
button_bbox = button_font.getbbox(button_text)
|
||
button_width = button_bbox[2] - button_bbox[0] + 40
|
||
except:
|
||
button_width = 200
|
||
|
||
button_height = 50 # 从40增大到50
|
||
|
||
# 绘制圆角矩形背景
|
||
self.text_renderer.draw_rounded_rectangle(
|
||
draw, (left_margin, content_start_y), (button_width, button_height), 20,
|
||
(0, 140, 210, 180), (255, 255, 255, 255), 1
|
||
)
|
||
|
||
# 绘制按钮文字
|
||
button_text_x = left_margin + 20
|
||
button_text_y = content_start_y + (button_height - 30) // 2 # 调整垂直居中
|
||
draw.text((button_text_x, button_text_y), button_text,
|
||
font=button_font, fill=(255, 255, 255))
|
||
|
||
# 内容列表 - 与海洋模板完全一致的动态行距计算
|
||
content_font = self.text_renderer.load_font(28) # 从22增大到28
|
||
content_items = ocean_info.get("content_items", [])
|
||
content_list_start_y = content_start_y + button_height + 20 # 从15增大到20
|
||
|
||
# 计算内容区域可用高度(需要知道footer位置)
|
||
# 这里使用一个估算值,实际应该传入footer_y参数
|
||
canvas_height = self.height
|
||
bottom_margin = 30
|
||
footer_y = canvas_height - bottom_margin
|
||
remarks_y = footer_y - 15
|
||
|
||
# 计算内容区域可用高度(从按钮下方到remarks_y上方)
|
||
available_height = remarks_y - content_list_start_y - 20 # 20是底部边距
|
||
|
||
# 根据内容项数量动态计算行距(与海洋模板完全一致)
|
||
if len(content_items) > 0:
|
||
# 基础行距
|
||
min_line_spacing = 8 # 从5增大到8
|
||
max_line_spacing = 25 # 从20增大到25
|
||
|
||
# 计算每项内容的平均高度
|
||
content_item_height = 28 + min_line_spacing # 28是字体大小
|
||
total_items_height = len(content_items) * content_item_height
|
||
|
||
# 计算额外可分配的空间
|
||
extra_space = max(0, available_height - total_items_height)
|
||
|
||
# 每项内容可以额外分配的空间
|
||
extra_per_item = min(max_line_spacing - min_line_spacing,
|
||
extra_space / max(1, len(content_items) - 1))
|
||
|
||
# 最终行距
|
||
content_line_spacing = min_line_spacing + extra_per_item
|
||
else:
|
||
content_line_spacing = 12 # 从10增大到12
|
||
|
||
content_line_height = 28 + content_line_spacing # 28是字体大小
|
||
bullet_indent = 0
|
||
content_indent = 15
|
||
|
||
for i, item in enumerate(content_items):
|
||
item_y = content_list_start_y + i * content_line_height
|
||
|
||
# # 项目符号
|
||
# draw.text((left_margin + bullet_indent , item_y), "•",
|
||
# font=content_font, fill=(255, 255, 255))
|
||
|
||
# 项目文本
|
||
draw.text((left_margin-5, item_y), item,
|
||
font=content_font, fill=(255, 255, 255))
|
||
|
||
def _render_right_column_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any],
|
||
content_start_y: int, right_column_x: int, right_margin: int,
|
||
footer_y: int, fonts: Dict, font_path: str) -> None:
|
||
"""渲染右栏内容(原版逻辑,与海洋模板完全一致)"""
|
||
right_column_width = right_margin - right_column_x
|
||
|
||
# 价格
|
||
price_text = ocean_info['price']
|
||
price_suffix = "CNY起"
|
||
price_target_width = int(right_column_width * 0.7)
|
||
price_size, price_width = self._calculate_optimal_font_size_simple(
|
||
price_text, font_path, price_target_width, max_size=120, min_size=40
|
||
)
|
||
price_font = self.text_renderer.load_font(price_size)
|
||
|
||
# 货币符号
|
||
currency_font_size = int(price_size * 0.3)
|
||
currency_font = self.text_renderer.load_font(currency_font_size)
|
||
try:
|
||
currency_bbox = currency_font.getbbox(price_suffix)
|
||
currency_width = currency_bbox[2] - currency_bbox[0]
|
||
except:
|
||
currency_width = 30
|
||
|
||
# 价格位置(右对齐)
|
||
total_width = price_width + currency_width
|
||
price_x = right_margin - total_width
|
||
price_y = content_start_y
|
||
|
||
# 渲染价格
|
||
self.text_renderer.draw_text_with_shadow(
|
||
draw, (price_x, price_y), price_text, price_font,
|
||
text_color=(255, 255, 255, 255),
|
||
shadow_color=(0, 0, 0, 150),
|
||
shadow_offset=(2, 2)
|
||
)
|
||
|
||
# 渲染货币符号
|
||
try:
|
||
price_bbox = price_font.getbbox(price_text)
|
||
price_height = price_bbox[3] - price_bbox[1]
|
||
currency_y = price_y + price_height - currency_bbox[3]
|
||
draw.text((price_x + price_width, currency_y), price_suffix,
|
||
font=currency_font, fill=(255, 255, 255))
|
||
except:
|
||
price_height = price_size
|
||
|
||
# 票种
|
||
ticket_text = ocean_info["ticket_type"]
|
||
ticket_target_width = int(right_column_width * 0.7)
|
||
ticket_size, ticket_width = self._calculate_optimal_font_size_simple(
|
||
ticket_text, font_path, ticket_target_width, max_size=60, min_size=30
|
||
)
|
||
ticket_font = self.text_renderer.load_font(ticket_size)
|
||
|
||
ticket_x = right_margin - ticket_width
|
||
try:
|
||
price_bbox = price_font.getbbox(price_text)
|
||
price_height = price_bbox[3] - price_bbox[1]
|
||
ticket_y = price_y + price_height + 35
|
||
except:
|
||
ticket_y = price_y + 90
|
||
|
||
self.text_renderer.draw_text_with_shadow(
|
||
draw, (ticket_x, ticket_y), ticket_text, ticket_font,
|
||
text_color=(255, 255, 255, 255),
|
||
shadow_color=(0, 0, 0, 150),
|
||
shadow_offset=(2, 2)
|
||
)
|
||
|
||
# 价格下划线
|
||
try:
|
||
underline_y = price_y + price_height + 18
|
||
line_start_x = price_x - 10
|
||
line_end_x = price_x + price_width + currency_width
|
||
draw.line([(line_start_x, underline_y), (line_end_x, underline_y)],
|
||
fill=(255, 255, 255, 80), width=2)
|
||
except:
|
||
pass
|
||
|
||
# 备注(与海洋模板完全一致的逻辑)
|
||
remarks = ocean_info.get("remarks", [])
|
||
if remarks:
|
||
if isinstance(remarks, str):
|
||
remarks = [remarks]
|
||
|
||
remarks_font = self.text_renderer.load_font(16)
|
||
try:
|
||
ticket_bbox = ticket_font.getbbox(ticket_text)
|
||
ticket_height = ticket_bbox[3] - ticket_bbox[1]
|
||
remarks_y = ticket_y + ticket_height + 30 # 增大与票种的间距,从15到30
|
||
except:
|
||
remarks_y = ticket_y + 60
|
||
|
||
# 渲染每一行备注,右对齐(与海洋模板完全一致)
|
||
for i, remark in enumerate(remarks):
|
||
try:
|
||
remarks_bbox = remarks_font.getbbox(remark)
|
||
remarks_width = remarks_bbox[2] - remarks_bbox[0]
|
||
remarks_x = right_margin - remarks_width # 右对齐
|
||
line_y = remarks_y + i * (16 + 5) # 16是字体大小,5是行距
|
||
draw.text((remarks_x, line_y), remark,
|
||
font=remarks_font, fill=(255, 255, 255, 200))
|
||
except:
|
||
continue
|
||
|
||
def _load_ocean_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
||
"""加载海洋主题字体"""
|
||
fonts = {}
|
||
font_sizes = self.ocean_config['font_sizes']
|
||
|
||
for size_name, size in font_sizes.items():
|
||
fonts[size_name] = self.text_renderer.load_font(size)
|
||
|
||
return fonts
|
||
|
||
def get_template_info(self) -> Dict[str, Any]:
|
||
"""获取模板信息"""
|
||
return {
|
||
"name": "活力模板(海洋模式)",
|
||
"version": "1.0.0",
|
||
"description": "基于海洋模块的毛玻璃渐变效果,完全兼容原版ocean_info参数结构",
|
||
"features": [
|
||
"毛玻璃渐变效果",
|
||
"原版两栏布局",
|
||
"动态渐变检测",
|
||
"精确边距对齐",
|
||
"价格展示区域",
|
||
"内容项目列表",
|
||
"备注和标签支持",
|
||
"智能字体大小调整"
|
||
],
|
||
"recommended_size": (1350, 1800),
|
||
"final_size": (1350, 1800),
|
||
"style": "海洋活力风格",
|
||
"theme_colors": list(self.ocean_config['ocean_colors'].keys()),
|
||
"compatible_with": "原版poster_ocean模块"
|
||
}
|
||
|
||
def validate_inputs(self, **kwargs) -> bool:
|
||
"""验证输入参数"""
|
||
# 检查必需的图片
|
||
image_path = kwargs.get('image_path')
|
||
if not image_path or not os.path.exists(image_path):
|
||
print("错误: 图片路径无效")
|
||
return False
|
||
|
||
# 检查ocean_info结构(可选)
|
||
ocean_info = kwargs.get('ocean_info')
|
||
if ocean_info and not isinstance(ocean_info, dict):
|
||
print("警告: ocean_info应该是字典类型")
|
||
|
||
return True
|
||
|
||
def _apply_glass_effect(self, image: Image.Image, intensity: float = 1.0) -> Image.Image:
|
||
"""
|
||
应用毛玻璃效果(与海洋模板完全一致的算法)
|
||
|
||
Args:
|
||
image: 输入图像
|
||
intensity: 毛玻璃强度 (0.0-3.0)
|
||
|
||
Returns:
|
||
应用毛玻璃效果后的图像
|
||
"""
|
||
if intensity <= 0:
|
||
return image
|
||
|
||
# 与海洋模板完全一致的强度计算
|
||
# 基础模糊半径
|
||
base_blur_radius = 2.0
|
||
|
||
# 根据强度计算实际模糊半径
|
||
# intensity 1.0 -> radius 2.0
|
||
# intensity 2.0 -> radius 4.0
|
||
# intensity 3.0 -> radius 6.0
|
||
blur_radius = base_blur_radius * intensity
|
||
|
||
# 限制最大模糊半径防止过度模糊
|
||
max_blur_radius = 8.0
|
||
blur_radius = min(blur_radius, max_blur_radius)
|
||
|
||
print(f"应用毛玻璃效果,强度: {intensity:.1f}, 模糊半径: {blur_radius:.1f}")
|
||
|
||
try:
|
||
# 应用高斯模糊
|
||
blurred = image.filter(ImageFilter.GaussianBlur(radius=blur_radius))
|
||
|
||
# 与海洋模板一致的亮度调整
|
||
# 轻微降低亮度以模拟毛玻璃的半透明效果
|
||
brightness_factor = 1.0 - (intensity * 0.05) # 最多降低15%亮度
|
||
brightness_factor = max(0.85, brightness_factor) # 确保不会过暗
|
||
|
||
enhancer = ImageEnhance.Brightness(blurred)
|
||
result = enhancer.enhance(brightness_factor)
|
||
|
||
print(f"毛玻璃效果应用完成,亮度调整系数: {brightness_factor:.2f}")
|
||
return result
|
||
|
||
except Exception as e:
|
||
print(f"毛玻璃效果应用失败: {e}")
|
||
return image
|
||
|
||
class BusinessTemplate(BaseTemplate):
|
||
"""商务风格海报模板(基于酒店模块)"""
|
||
|
||
def __init__(self, size: Tuple[int, int] = (1350, 1800)):
|
||
"""
|
||
初始化商务模板
|
||
|
||
Args:
|
||
size: 海报尺寸,默认为酒店模块的尺寸 (1350, 1800)
|
||
"""
|
||
super().__init__(size)
|
||
|
||
# 酒店模块原版配置
|
||
self.config = {
|
||
'total_parts': 4.0, # 1 + 2 + 1 的布局比例
|
||
'center_pure_height_ratio': 0.1, # 中心纯色区域高度比例
|
||
'text_area_start_ratio': 0.1, # 文本区域起始位置(图像高度的1/5)
|
||
'standard_margin': 30, # 标准间距
|
||
'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)] # 科技感配色
|
||
}
|
||
|
||
def generate(self,
|
||
top_image_path: str,
|
||
bottom_image_path: str,
|
||
small_image_paths: Optional[List[str]] = None,
|
||
hotel_info: Optional[Dict[str, Any]] = None,
|
||
color_theme: Optional[str] = None,
|
||
output_path: str = "business_poster.png",
|
||
**kwargs) -> Image.Image:
|
||
"""
|
||
生成商务海报
|
||
|
||
Args:
|
||
top_image_path: 顶部图像路径
|
||
bottom_image_path: 底部图像路径
|
||
small_image_paths: 小图像路径列表(可选)
|
||
hotel_info: 酒店信息字典
|
||
color_theme: 颜色主题名称(可选)
|
||
output_path: 输出路径
|
||
**kwargs: 其他参数
|
||
|
||
Returns:
|
||
生成的海报图像
|
||
"""
|
||
try:
|
||
# 使用默认信息如果未提供
|
||
if hotel_info is None:
|
||
hotel_info = self._get_default_hotel_info()
|
||
|
||
# 1. 加载和处理图像
|
||
top_img = Image.open(top_image_path)
|
||
bottom_img = Image.open(bottom_image_path)
|
||
|
||
# 2. 调整图像大小
|
||
top_img = self._resize_image(top_img, self.size[0])
|
||
bottom_img = self._resize_image(bottom_img, self.size[0])
|
||
|
||
print(f"调整后图像尺寸: 宽度={self.size[0]}")
|
||
|
||
# 3. 提取主要颜色
|
||
if color_theme and color_theme in self.color_themes:
|
||
top_color, bottom_color = self.color_themes[color_theme]
|
||
print(f"使用预设主题 '{color_theme}' 的颜色")
|
||
else:
|
||
top_color = self._extract_dominant_color_edge(top_img)
|
||
bottom_color = self._extract_dominant_color_edge(bottom_img)
|
||
print(f"从图像提取的颜色: 上={top_color}, 下={bottom_color}")
|
||
|
||
# 确保颜色和谐
|
||
top_color, bottom_color = self._ensure_colors_harmony(top_color, bottom_color)
|
||
|
||
print(f"最终使用的颜色: 上={top_color}, 下={bottom_color}")
|
||
|
||
# 4. 创建渐变背景
|
||
base_img = self._create_gradient_background(
|
||
self.size[0], self.size[1], top_color, bottom_color
|
||
)
|
||
|
||
# 保存背景颜色信息供后续使用
|
||
self._current_background_colors = (top_color, bottom_color)
|
||
|
||
# 5. 应用透明度效果
|
||
top_img = self._ensure_rgba(top_img)
|
||
bottom_img = self._ensure_rgba(bottom_img)
|
||
|
||
print("应用透明度效果")
|
||
top_img_with_transparency = self._apply_top_transparency(top_img)
|
||
bottom_img_with_transparency = self._apply_bottom_transparency(bottom_img)
|
||
|
||
# 6. 计算区域高度
|
||
section_heights = self._calculate_section_heights()
|
||
|
||
# 7. 合成图像
|
||
composite_img = self._compose_images_hotel_style(
|
||
base_img,
|
||
top_img_with_transparency,
|
||
bottom_img_with_transparency,
|
||
section_heights
|
||
)
|
||
|
||
# 8. 添加小图
|
||
# small_img_info = None
|
||
# if small_image_paths:
|
||
# composite_img, small_img_info = self._add_small_images(
|
||
# composite_img, small_image_paths, section_heights
|
||
# )
|
||
|
||
# 9. 添加装饰元素(在文本之前)
|
||
composite_img = self._add_decorative_elements(composite_img)
|
||
|
||
# 10. 创建文本背景卡片
|
||
# text_area = (0, section_heights['middle_start'], self.size[0], section_heights['middle_height'])
|
||
# composite_img = self._create_text_background_card(composite_img, text_area)
|
||
|
||
# 11. 渲染文本
|
||
composite_img = self._render_hotel_texts_original(
|
||
composite_img, hotel_info, None
|
||
)
|
||
|
||
# 12. 保存结果
|
||
composite_img.save(output_path)
|
||
print(f"商务海报已保存至: {output_path}")
|
||
|
||
return composite_img
|
||
|
||
except Exception as e:
|
||
print(f"生成商务海报时出错: {e}")
|
||
# 返回一个基础的错误图像
|
||
error_img = Image.new('RGB', self.size, (128, 128, 128))
|
||
return error_img
|
||
|
||
def _get_default_hotel_info(self) -> Dict[str, Any]:
|
||
"""获取默认酒店信息"""
|
||
return {
|
||
"name": "商务精选酒店",
|
||
"feature": "专业商务服务 | 高端品质体验",
|
||
"slogan": "为您的商务之旅提供完美住宿体验",
|
||
"price": "1288",
|
||
"info_list": [
|
||
"【住】商务套房2晚(可拆分使用)",
|
||
"【食】每日精致商务早餐",
|
||
"【服务】24小时商务中心服务"
|
||
],
|
||
"footer": [
|
||
"预订时间:即日起-2025年5月31日",
|
||
"入住时间:即日起-2025年6月30日",
|
||
"注:节假日可能需要补差价,具体以预订页面为准"
|
||
]
|
||
}
|
||
|
||
def _extract_dominant_color_edge(self, image: Image.Image) -> Tuple[int, int, int]:
|
||
"""
|
||
从图像边缘提取主要颜色(酒店模块方法)
|
||
|
||
Args:
|
||
image: 输入图像
|
||
|
||
Returns:
|
||
提取的RGB颜色元组
|
||
"""
|
||
# 转换为RGB模式
|
||
if image.mode != 'RGB':
|
||
image = image.convert('RGB')
|
||
|
||
width, height = image.size
|
||
pixels = []
|
||
|
||
# 边缘优先采样
|
||
edge_width = min(width, height) // 4
|
||
sample_size = 200
|
||
|
||
# 顶部边缘
|
||
for y in range(0, edge_width):
|
||
for x in range(0, width, width // (sample_size // 4)):
|
||
if x < width and len(pixels) < sample_size:
|
||
pixel = image.getpixel((x, y))
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
# 底部边缘
|
||
for y in range(height - edge_width, height):
|
||
for x in range(0, width, width // (sample_size // 4)):
|
||
if x < width and len(pixels) < sample_size:
|
||
pixel = image.getpixel((x, y))
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
# 左边缘
|
||
for x in range(0, edge_width):
|
||
for y in range(0, height, height // (sample_size // 4)):
|
||
if y < height and len(pixels) < sample_size:
|
||
pixel = image.getpixel((x, y))
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
# 右边缘
|
||
for x in range(width - edge_width, width):
|
||
for y in range(0, height, height // (sample_size // 4)):
|
||
if y < height and len(pixels) < sample_size:
|
||
pixel = image.getpixel((x, y))
|
||
if sum(pixel) > 50 and sum(pixel) < 700:
|
||
pixels.append(pixel)
|
||
|
||
# 如果没有采样到合适的像素,返回默认颜色
|
||
if not pixels:
|
||
return (80, 120, 160) # 默认蓝色
|
||
|
||
# 计算最常见的颜色
|
||
color_counter = Counter(pixels)
|
||
color_candidates = color_counter.most_common(5)
|
||
|
||
# 选择最佳颜色
|
||
best_color = self._select_best_color(color_candidates)
|
||
|
||
# 调整颜色
|
||
adjusted_color = self._adjust_color_for_background(best_color)
|
||
|
||
return adjusted_color
|
||
|
||
def _select_best_color(self, color_candidates: List[Tuple[Tuple[int, int, int], int]]) -> Tuple[int, int, int]:
|
||
"""选择最佳颜色"""
|
||
if not color_candidates:
|
||
return (80, 120, 160)
|
||
|
||
if len(color_candidates) == 1:
|
||
return color_candidates[0][0]
|
||
|
||
best_score = -1
|
||
best_color = None
|
||
|
||
for color, count in color_candidates:
|
||
r, g, b = color
|
||
|
||
# 计算亮度和饱和度
|
||
brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||
max_c = max(r, g, b)
|
||
min_c = min(r, g, b)
|
||
saturation = (max_c - min_c) / max_c if max_c > 0 else 0
|
||
|
||
# 计算分数
|
||
brightness_score = 1.0 - abs((brightness - 130) / 130)
|
||
saturation_score = 0
|
||
if 0.3 <= saturation <= 0.8:
|
||
saturation_score = (saturation - 0.3) / 0.5
|
||
elif saturation > 0.8:
|
||
saturation_score = 1.0 - (saturation - 0.8) / 0.2
|
||
else:
|
||
saturation_score = saturation / 0.3
|
||
|
||
score = brightness_score * 0.4 + saturation_score * 0.6
|
||
|
||
if score > best_score:
|
||
best_score = score
|
||
best_color = color
|
||
|
||
return best_color or color_candidates[0][0]
|
||
|
||
def _adjust_color_for_background(self, color: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
||
"""调整颜色使其更适合作为背景"""
|
||
r, g, b = color
|
||
|
||
# 计算亮度和饱和度
|
||
brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||
max_c = max(r, g, b)
|
||
min_c = min(r, g, b)
|
||
saturation = (max_c - min_c) / max_c if max_c > 0 else 0
|
||
|
||
# 调整亮度
|
||
target_brightness = 120
|
||
brightness_factor = target_brightness / brightness if brightness > 0 else 1
|
||
brightness_factor = max(0.7, min(1.3, brightness_factor))
|
||
|
||
# 调整饱和度
|
||
if saturation > 0.6:
|
||
saturation_factor = 0.85
|
||
elif saturation < 0.2:
|
||
saturation_factor = 1.3
|
||
else:
|
||
saturation_factor = 1.0
|
||
|
||
# 应用调整
|
||
adjusted_r = max(0, min(255, int(r * brightness_factor)))
|
||
adjusted_g = max(0, min(255, int(g * brightness_factor)))
|
||
adjusted_b = max(0, min(255, int(b * brightness_factor)))
|
||
|
||
# 应用饱和度调整
|
||
if saturation_factor != 1.0:
|
||
avg = (adjusted_r + adjusted_g + adjusted_b) / 3
|
||
adjusted_r = int(avg + (adjusted_r - avg) * saturation_factor)
|
||
adjusted_g = int(avg + (adjusted_g - avg) * saturation_factor)
|
||
adjusted_b = int(avg + (adjusted_b - avg) * saturation_factor)
|
||
|
||
adjusted_r = max(0, min(255, adjusted_r))
|
||
adjusted_g = max(0, min(255, adjusted_g))
|
||
adjusted_b = max(0, min(255, adjusted_b))
|
||
|
||
return (adjusted_r, adjusted_g, adjusted_b)
|
||
|
||
def _ensure_colors_harmony(self, top_color: Tuple[int, int, int],
|
||
bottom_color: Tuple[int, int, int]) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
|
||
"""确保颜色和谐"""
|
||
def color_distance(c1, c2):
|
||
return sum(abs(a - b) for a, b in zip(c1, c2))
|
||
|
||
# 计算颜色差异
|
||
color_diff = color_distance(top_color, bottom_color)
|
||
top_brightness = sum(top_color) / 3
|
||
bottom_brightness = sum(bottom_color) / 3
|
||
brightness_diff = abs(top_brightness - bottom_brightness)
|
||
|
||
# 如果颜色差异太小,调整以增加对比度
|
||
if color_diff < 30 or brightness_diff < 10:
|
||
if top_brightness > bottom_brightness:
|
||
factor_top = 1.1
|
||
factor_bottom = 0.9
|
||
else:
|
||
factor_top = 0.9
|
||
factor_bottom = 1.1
|
||
|
||
top_color = tuple(max(0, min(255, int(c * factor_top))) for c in top_color)
|
||
bottom_color = tuple(max(0, min(255, int(c * factor_bottom))) for c in bottom_color)
|
||
|
||
# 如果颜色差异过大,适当减小差异
|
||
elif color_diff > 150 or brightness_diff > 150:
|
||
mid_r = (top_color[0] + bottom_color[0]) // 2
|
||
mid_g = (top_color[1] + bottom_color[1]) // 2
|
||
mid_b = (top_color[2] + bottom_color[2]) // 2
|
||
|
||
top_color = (
|
||
int(top_color[0] * 0.8 + mid_r * 0.2),
|
||
int(top_color[1] * 0.8 + mid_g * 0.2),
|
||
int(top_color[2] * 0.8 + mid_b * 0.2)
|
||
)
|
||
|
||
bottom_color = (
|
||
int(bottom_color[0] * 0.8 + mid_r * 0.2),
|
||
int(bottom_color[1] * 0.8 + mid_g * 0.2),
|
||
int(bottom_color[2] * 0.8 + mid_b * 0.2)
|
||
)
|
||
|
||
return top_color, bottom_color
|
||
|
||
def _calculate_section_heights(self) -> Dict[str, int]:
|
||
"""
|
||
计算各区域高度(1:2:1的比例)
|
||
|
||
Returns:
|
||
包含各区域高度的字典
|
||
"""
|
||
total_height = self.size[1]
|
||
total_parts = self.config['total_parts'] # 1 + 2 + 1
|
||
|
||
top_section_height = int(total_height / total_parts) # 1/4
|
||
middle_section_height = int(total_height * 2 / total_parts) # 2/4
|
||
bottom_section_height = int(total_height / total_parts) # 1/4
|
||
|
||
# 确保总高度为预期值
|
||
remaining_height = total_height - (top_section_height + middle_section_height + bottom_section_height)
|
||
middle_section_height += remaining_height
|
||
|
||
return {
|
||
'top_height': top_section_height,
|
||
'middle_height': middle_section_height,
|
||
'bottom_height': bottom_section_height,
|
||
'top_end': top_section_height,
|
||
'middle_start': top_section_height,
|
||
'middle_end': top_section_height + middle_section_height,
|
||
'bottom_start': top_section_height + middle_section_height,
|
||
'total_height': total_height
|
||
}
|
||
|
||
def _apply_top_transparency(self, image: Image.Image) -> Image.Image:
|
||
"""对上部图像应用透明度效果"""
|
||
if image.mode != 'RGBA':
|
||
image = self._ensure_rgba(image)
|
||
|
||
img_width, img_height = image.size
|
||
temp_img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0))
|
||
temp_img.paste(image, (0, 0), image if image.mode == 'RGBA' else None)
|
||
|
||
img_array = np.array(temp_img)
|
||
transparent_ratio = self.config['transparent_ratio']
|
||
transparent_start = int(img_height * (1 - transparent_ratio))
|
||
|
||
for y in range(transparent_start, img_height):
|
||
relative_position = (y - transparent_start) / (img_height - transparent_start)
|
||
alpha_factor = relative_position * relative_position * 3
|
||
|
||
for x in range(img_width):
|
||
original_color = img_array[y, x]
|
||
if len(original_color) == 4:
|
||
original_alpha = original_color[3]
|
||
new_alpha = max(0, min(255, int(original_alpha * (1 - alpha_factor))))
|
||
else:
|
||
new_alpha = max(0, min(255, int(255 * (1 - alpha_factor))))
|
||
|
||
img_array[y, x][3] = new_alpha
|
||
|
||
return Image.fromarray(img_array)
|
||
|
||
def _apply_bottom_transparency(self, image: Image.Image) -> Image.Image:
|
||
"""对下部图像应用透明度效果"""
|
||
if image.mode != 'RGBA':
|
||
image = self._ensure_rgba(image)
|
||
|
||
img_width, img_height = image.size
|
||
temp_img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0))
|
||
temp_img.paste(image, (0, 0), image if image.mode == 'RGBA' else None)
|
||
|
||
img_array = np.array(temp_img)
|
||
transparent_ratio = self.config['transparent_ratio']
|
||
transparent_end = int(img_height * transparent_ratio)
|
||
|
||
for y in range(0, transparent_end):
|
||
relative_position = 1.0 - (y / transparent_end)
|
||
alpha_factor = relative_position * relative_position * 3
|
||
|
||
for x in range(img_width):
|
||
original_color = img_array[y, x]
|
||
if len(original_color) == 4:
|
||
original_alpha = original_color[3]
|
||
new_alpha = max(0, min(255, int(original_alpha * (1 - alpha_factor))))
|
||
else:
|
||
new_alpha = max(0, min(255, int(255 * (1 - alpha_factor))))
|
||
|
||
img_array[y, x][3] = new_alpha
|
||
|
||
return Image.fromarray(img_array)
|
||
|
||
def _compose_images_hotel_style(self, base_img: Image.Image,
|
||
top_img: Image.Image,
|
||
bottom_img: Image.Image,
|
||
section_heights: Dict[str, int]) -> Image.Image:
|
||
"""
|
||
按照酒店模块风格合成图像
|
||
|
||
Args:
|
||
base_img: 基础背景图像
|
||
top_img: 处理后的顶部图像
|
||
bottom_img: 处理后的底部图像
|
||
section_heights: 区域高度信息
|
||
|
||
Returns:
|
||
合成后的图像
|
||
"""
|
||
width = self.size[0]
|
||
height = self.size[1]
|
||
|
||
# 获取图像尺寸
|
||
top_img_width, top_img_height = top_img.size
|
||
bottom_img_width, bottom_img_height = bottom_img.size
|
||
|
||
# 计算图像位置(水平居中)
|
||
top_x_pos = (width - top_img_width) // 2
|
||
top_y_pos = 0
|
||
|
||
bottom_x_pos = (width - bottom_img_width) // 2
|
||
bottom_y_pos = height - bottom_img_height
|
||
|
||
# 粘贴处理后的图像到底板
|
||
base_img.paste(top_img, (top_x_pos, top_y_pos), top_img)
|
||
base_img.paste(bottom_img, (bottom_x_pos, bottom_y_pos), bottom_img)
|
||
|
||
return base_img
|
||
|
||
def _add_small_images(self, canvas: Image.Image,
|
||
small_image_paths: List[str],
|
||
section_heights: Dict[str, int]) -> Tuple[Image.Image, Optional[Dict]]:
|
||
"""
|
||
添加小图像(酒店模块风格)
|
||
|
||
Args:
|
||
canvas: 画布图像
|
||
small_image_paths: 小图像路径列表
|
||
section_heights: 区域高度信息
|
||
|
||
Returns:
|
||
更新后的画布和小图信息
|
||
"""
|
||
if not small_image_paths or len(small_image_paths) < 3:
|
||
return canvas, None
|
||
|
||
width = self.size[0]
|
||
|
||
# 计算文本区域起始位置
|
||
text_area_start_y = int(self.size[1] * self.config['text_area_start_ratio'])
|
||
|
||
# 预估上部文本高度(标题+副标题+标语+间距)
|
||
estimated_upper_text_height = 200 # 预估值
|
||
|
||
# 小图位置
|
||
small_img_height = 150
|
||
small_img_width = int(width * 0.9)
|
||
small_img_y = text_area_start_y + estimated_upper_text_height + self.config['standard_margin']
|
||
|
||
# 处理小图片
|
||
for i, img_path in enumerate(small_image_paths[:3]):
|
||
try:
|
||
small_img = Image.open(img_path)
|
||
single_img_width = int((small_img_width - 40) / 3)
|
||
small_img_size = (single_img_width, small_img_height)
|
||
small_img = small_img.resize(small_img_size, Image.LANCZOS)
|
||
small_img = self._ensure_rgba(small_img)
|
||
|
||
# 水平排列三张小图,居中对齐
|
||
start_x = int((width - small_img_width) / 2)
|
||
x_pos = start_x + i * (single_img_width + 20)
|
||
canvas.paste(small_img, (x_pos, int(small_img_y)), small_img)
|
||
except Exception as e:
|
||
print(f"处理小图出错: {e}")
|
||
|
||
# 返回小图信息
|
||
small_img_info = {
|
||
'y_pos': small_img_y,
|
||
'width': small_img_width,
|
||
'height': small_img_height
|
||
}
|
||
|
||
return canvas, small_img_info
|
||
|
||
def _render_hotel_texts_original(self, canvas: Image.Image,
|
||
hotel_info: Dict[str, Any],
|
||
small_img_info: Optional[Tuple] = None) -> Image.Image:
|
||
"""
|
||
按照酒店模块原版逻辑渲染文本,集成活力模板的动态分布算法
|
||
|
||
Args:
|
||
canvas: 画布图像
|
||
hotel_info: 酒店信息
|
||
small_img_info: 小图信息
|
||
|
||
Returns:
|
||
渲染文本后的图像
|
||
"""
|
||
draw = ImageDraw.Draw(canvas)
|
||
width, height = canvas.size
|
||
center_x = width // 2
|
||
|
||
# 加载字体
|
||
font_path = "/root/autodl-tmp/posterGenerator/assets/fonts/兰亭粗黑简.TTF"
|
||
|
||
try:
|
||
# 1. 计算布局区域
|
||
section_heights = self._calculate_section_heights()
|
||
|
||
# 2. 确定文本区域范围(中间无图像区域)
|
||
text_start_y = section_heights['top_end'] # 上部图像结束位置
|
||
text_end_y = section_heights['bottom_start'] # 下部图像开始位置
|
||
|
||
print(f"文本区域范围: {text_start_y} - {text_end_y} (高度: {text_end_y - text_start_y})")
|
||
|
||
# 3. 预估内容高度(活力模板算法)
|
||
estimated_content_height = self._estimate_business_content_height(hotel_info, font_path, width)
|
||
print(f"预估内容总高度: {estimated_content_height}")
|
||
|
||
# 4. 计算可用空间和动态间距
|
||
available_height = text_end_y - text_start_y - self.config['dynamic_spacing']['content_margin'] * 2
|
||
print(f"可用文本高度: {available_height}")
|
||
|
||
# 5. 动态调整布局参数
|
||
layout_params = self._calculate_dynamic_layout_params(
|
||
estimated_content_height, available_height
|
||
)
|
||
print(f"动态布局参数: {layout_params}")
|
||
|
||
# 6. 开始渲染文本内容
|
||
current_y = text_start_y + self.config['dynamic_spacing']['content_margin']
|
||
available_width = int(width * 0.9)
|
||
margin_x = (width - available_width) // 2
|
||
|
||
# 渲染标题
|
||
current_y = self._render_hotel_title_dynamic(
|
||
draw, hotel_info["name"], current_y, center_x, width, font_path, layout_params
|
||
)
|
||
|
||
# 渲染特色描述
|
||
current_y = self._render_hotel_feature_dynamic(
|
||
draw, hotel_info["feature"], current_y, center_x, width, available_width,
|
||
font_path, layout_params
|
||
)
|
||
|
||
# 渲染信息区域(使用活力模板的动态分布算法)
|
||
current_y = self._render_hotel_info_section_dynamic(
|
||
draw, hotel_info, current_y, margin_x + 10, available_width,
|
||
font_path, layout_params, text_end_y
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"渲染文本时出错: {e}")
|
||
|
||
return canvas
|
||
|
||
def _estimate_business_content_height(self, hotel_info: Dict[str, Any],
|
||
font_path: str, width: int) -> int:
|
||
"""
|
||
预估商务模板内容高度(基于活力模板算法)
|
||
|
||
Args:
|
||
hotel_info: 酒店信息
|
||
font_path: 字体路径
|
||
width: 画布宽度
|
||
|
||
Returns:
|
||
预估的内容总高度
|
||
"""
|
||
# 基础间距配置
|
||
standard_margin = self.config['dynamic_spacing']['section_spacing']
|
||
|
||
# 1. 标题高度估算
|
||
title_text = hotel_info["name"]
|
||
title_target_width = int(width * 0.85)
|
||
title_size = self._estimate_font_size(title_text, font_path, title_target_width, max_size=80)
|
||
title_height = int(title_size * 1.2) # 字体高度 + 行距
|
||
|
||
# 2. 特色描述高度估算
|
||
feature_text = hotel_info["feature"]
|
||
feature_target_width = int(width * 0.6)
|
||
feature_size = self._estimate_font_size(feature_text, font_path, feature_target_width, max_size=40)
|
||
feature_height = int(feature_size * 1.5) + 50 # 包含背景框高度
|
||
|
||
# 3. 信息列表高度估算
|
||
info_list = hotel_info.get("info_list", [])
|
||
info_line_height = 35 # 基础行高
|
||
info_list_height = len(info_list) * info_line_height
|
||
|
||
# 4. 价格区域高度估算
|
||
price_height = 80
|
||
|
||
# 计算总高度
|
||
total_height = (
|
||
20 + # 初始顶部边距
|
||
title_height + standard_margin + # 标题
|
||
feature_height + standard_margin + # 特色描述
|
||
info_list_height + # 信息列表
|
||
price_height + # 价格区域
|
||
self.config['dynamic_spacing']['bottom_reserve'] # 底部保留空间
|
||
)
|
||
|
||
return total_height
|
||
|
||
def _estimate_font_size(self, text: str, font_path: str, target_width: int,
|
||
max_size: int = 120, min_size: int = 10) -> int:
|
||
"""快速估算字体大小"""
|
||
# 简化的字体大小估算,避免创建实际字体对象
|
||
char_count = len(text)
|
||
if char_count == 0:
|
||
return min_size
|
||
|
||
# 基于字符数量和目标宽度的粗略估算
|
||
estimated_size = int(target_width / (char_count * 0.6))
|
||
return max(min_size, min(max_size, estimated_size))
|
||
|
||
def _calculate_dynamic_layout_params(self, estimated_height: int,
|
||
available_height: int) -> Dict[str, Any]:
|
||
"""
|
||
计算动态布局参数(活力模板核心算法)
|
||
|
||
Args:
|
||
estimated_height: 预估内容高度
|
||
available_height: 可用空间高度
|
||
|
||
Returns:
|
||
布局参数字典
|
||
"""
|
||
spacing_config = self.config['dynamic_spacing']
|
||
|
||
# 计算空间利用率
|
||
space_ratio = estimated_height / available_height if available_height > 0 else 1.0
|
||
|
||
print(f"空间利用率: {space_ratio:.2f}")
|
||
|
||
if space_ratio <= 0.8:
|
||
# 空间充足,使用较大间距
|
||
line_spacing_factor = 1.2
|
||
section_spacing_factor = 1.3
|
||
comfort_level = "spacious"
|
||
elif space_ratio <= 1.0:
|
||
# 空间适中,使用标准间距
|
||
line_spacing_factor = 1.0
|
||
section_spacing_factor = 1.0
|
||
comfort_level = "normal"
|
||
else:
|
||
# 空间紧张,使用较小间距
|
||
line_spacing_factor = 0.8
|
||
section_spacing_factor = 0.7
|
||
comfort_level = "compact"
|
||
|
||
# 计算动态间距
|
||
dynamic_line_spacing = int(spacing_config['min_line_spacing'] +
|
||
(spacing_config['max_line_spacing'] - spacing_config['min_line_spacing']) *
|
||
line_spacing_factor)
|
||
|
||
dynamic_section_spacing = int(spacing_config['section_spacing'] * section_spacing_factor)
|
||
|
||
return {
|
||
'line_spacing': dynamic_line_spacing,
|
||
'section_spacing': dynamic_section_spacing,
|
||
'comfort_level': comfort_level,
|
||
'space_ratio': space_ratio,
|
||
'line_spacing_factor': line_spacing_factor
|
||
}
|
||
|
||
def _render_hotel_title_dynamic(self, draw: ImageDraw.Draw, title: str, y: int,
|
||
center_x: int, width: int, font_path: str,
|
||
layout_params: Dict[str, Any]) -> int:
|
||
"""渲染酒店标题(动态间距版本)"""
|
||
title_target_width = int(width * 0.85)
|
||
font_size, title_font = self._calculate_optimal_font_size(
|
||
title, font_path, title_target_width, min_size=20
|
||
)
|
||
|
||
# 计算文本位置
|
||
bbox = title_font.getbbox(title)
|
||
title_width = bbox[2] - bbox[0]
|
||
title_height = bbox[3] - bbox[1]
|
||
title_x = center_x - title_width // 2
|
||
|
||
# 渲染标题(带描边效果)
|
||
self._add_text_with_outline(
|
||
draw, (title_x, y), title, title_font,
|
||
text_color=(255, 240, 200),
|
||
outline_color=(0, 0, 0, 200),
|
||
outline_width=2
|
||
)
|
||
|
||
# 使用动态间距
|
||
return y + title_height + layout_params['section_spacing']
|
||
|
||
def _render_hotel_feature_dynamic(self, draw: ImageDraw.Draw, feature: str, y: int,
|
||
center_x: int, width: int, available_width: int,
|
||
font_path: str, layout_params: Dict[str, Any]) -> int:
|
||
"""渲染特色描述(动态间距版本,智能颜色选择)"""
|
||
subtitle_target_width = int(width * 0.6)
|
||
font_size, subtitle_font = self._calculate_optimal_font_size(
|
||
feature, font_path, subtitle_target_width, max_size=60, min_size=16
|
||
)
|
||
|
||
bbox = subtitle_font.getbbox(feature)
|
||
subtitle_width = bbox[2] - bbox[0]
|
||
subtitle_x = center_x - subtitle_width // 2
|
||
|
||
# 添加圆角背景
|
||
subtitle_bg_padding = 15
|
||
subtitle_bg_height = 50
|
||
subtitle_bg_width = min(subtitle_width + subtitle_bg_padding * 2, available_width)
|
||
|
||
self._add_rounded_rectangle(
|
||
draw,
|
||
(center_x - subtitle_bg_width // 2, y - 5),
|
||
(subtitle_bg_width, subtitle_bg_height),
|
||
radius=20,
|
||
fill_color=(50, 50, 50, 180)
|
||
)
|
||
|
||
# 智能选择feature文本颜色
|
||
if hasattr(self, '_current_background_colors'):
|
||
feature_color = self._get_smart_feature_color(self._current_background_colors)
|
||
else:
|
||
feature_color = (255, 255, 255) # 默认白色
|
||
|
||
# 渲染文字
|
||
draw.text((subtitle_x, y), feature, font=subtitle_font, fill=feature_color)
|
||
|
||
# 使用动态间距
|
||
return y + subtitle_bg_height + layout_params['section_spacing']
|
||
|
||
def _render_hotel_info_section_dynamic(self, draw: ImageDraw.Draw, hotel_info: Dict[str, Any],
|
||
y: int, left_align_x: int, available_width: int,
|
||
font_path: str, layout_params: Dict[str, Any],
|
||
max_y: int) -> int:
|
||
"""
|
||
渲染信息区域(集成活力模板的动态分布算法,添加info|price分隔线)
|
||
|
||
Args:
|
||
draw: 绘图对象
|
||
hotel_info: 酒店信息
|
||
y: 起始Y坐标
|
||
left_align_x: 左对齐X坐标
|
||
available_width: 可用宽度
|
||
font_path: 字体路径
|
||
layout_params: 布局参数
|
||
max_y: 最大Y坐标(不能超过此位置)
|
||
|
||
Returns:
|
||
渲染后的Y坐标
|
||
"""
|
||
# 计算剩余可用高度
|
||
remaining_height = max_y - y - self.config['dynamic_spacing']['bottom_reserve']
|
||
|
||
print(f"信息区域可用高度: {remaining_height}")
|
||
|
||
if remaining_height <= 0:
|
||
print("警告: 信息区域可用高度不足")
|
||
return y
|
||
|
||
# 获取信息列表
|
||
info_texts = hotel_info.get("info_list", [])
|
||
if not info_texts:
|
||
return y
|
||
|
||
# 计算字体大小
|
||
longest_info = max(info_texts, key=len)
|
||
info_target_width = int(available_width * 0.55)
|
||
font_size, info_font = self._calculate_optimal_font_size(
|
||
longest_info, font_path, info_target_width, max_size=30, min_size=14
|
||
)
|
||
|
||
# 计算价格字体大小 - 增大尺寸
|
||
price_text = f"¥{hotel_info['price']}"
|
||
price_target_width = int(available_width * 0.35) # 增加目标宽度
|
||
price_font_size, price_font = self._calculate_optimal_font_size(
|
||
price_text, font_path, price_target_width, max_size=200, min_size=60 # 增大字体范围
|
||
)
|
||
|
||
# 计算CNY标识符字体大小
|
||
cny_text = "CNY"
|
||
cny_font_size = price_font_size // 4 * 3
|
||
cny_font = ImageFont.truetype(font_path, cny_font_size)
|
||
|
||
# 获取文本高度
|
||
info_bbox = info_font.getbbox(longest_info)
|
||
info_line_height = info_bbox[3] - info_bbox[1]
|
||
|
||
price_bbox = price_font.getbbox(price_text)
|
||
price_height = price_bbox[3] - price_bbox[1]
|
||
|
||
cny_bbox = cny_font.getbbox(cny_text)
|
||
cny_height = cny_bbox[3] - cny_bbox[1]
|
||
|
||
# 使用活力模板的动态行距算法
|
||
info_count = len(info_texts)
|
||
if info_count > 0:
|
||
# 基础行距配置
|
||
min_line_spacing = layout_params['line_spacing']
|
||
max_line_spacing = layout_params['line_spacing'] + 15
|
||
|
||
# 计算基础内容高度
|
||
base_content_height = info_count * info_line_height
|
||
|
||
# 计算额外可分配空间
|
||
extra_space = max(0, remaining_height - base_content_height - price_height - cny_height - 30)
|
||
|
||
# 每行额外分配的空间
|
||
if info_count > 1:
|
||
extra_per_line = min(max_line_spacing - min_line_spacing,
|
||
extra_space / (info_count - 1))
|
||
else:
|
||
extra_per_line = 0
|
||
|
||
# 最终行距
|
||
final_line_spacing = min_line_spacing + extra_per_line
|
||
|
||
print(f"动态行距计算: 基础={min_line_spacing}, 额外={extra_per_line:.1f}, 最终={final_line_spacing:.1f}")
|
||
else:
|
||
final_line_spacing = layout_params['line_spacing']
|
||
|
||
# 渲染信息列表 - 左侧对齐,使用动态行距
|
||
info_y = y
|
||
for i, info in enumerate(info_texts):
|
||
current_line_y = info_y + i * (info_line_height + final_line_spacing)
|
||
draw.text(
|
||
(left_align_x, int(current_line_y)),
|
||
info, font=info_font, fill=(255, 255, 255)
|
||
)
|
||
|
||
# 计算info区域的结束位置
|
||
info_end_y = info_y + info_count * (info_line_height + final_line_spacing)
|
||
|
||
# 计算价格位置(右侧对齐,与信息顶部对齐)
|
||
price_width = price_bbox[2] - price_bbox[0]
|
||
right_margin = left_align_x + available_width - 10
|
||
price_x = right_margin - price_width
|
||
|
||
# 价格与信息列表顶部对齐
|
||
price_y = info_y
|
||
|
||
# 计算分隔线位置和样式
|
||
divider_x = left_align_x + available_width * 0.6 # 分隔线位置
|
||
divider_top_y = info_y - 10 # 分隔线顶部
|
||
divider_bottom_y = max(info_end_y, price_y + price_height + cny_height + 15) - 10 # 分隔线底部
|
||
divider_height = divider_bottom_y - divider_top_y
|
||
|
||
# 智能选择分隔线颜色
|
||
if hasattr(self, '_current_background_colors'):
|
||
divider_color = self._get_smart_feature_color(self._current_background_colors)
|
||
else:
|
||
divider_color = (255, 255, 255)
|
||
|
||
# 绘制主分隔线 - 改为虚线效果
|
||
line_width = 2
|
||
dash_length = 8 # 虚线段长度
|
||
gap_length = 4 # 间隔长度
|
||
|
||
# 计算虚线段数量
|
||
total_length = divider_bottom_y - divider_top_y
|
||
segment_length = dash_length + gap_length
|
||
num_segments = int(total_length / segment_length)
|
||
|
||
# XXX: 关闭了划线
|
||
# # 绘制虚线
|
||
# for i in range(num_segments + 1):
|
||
# dash_start_y = divider_top_y + i * segment_length
|
||
# dash_end_y = min(dash_start_y + dash_length, divider_bottom_y)
|
||
|
||
# if dash_start_y < divider_bottom_y:
|
||
# draw.rectangle([
|
||
# divider_x - line_width // 2, dash_start_y,
|
||
# divider_x + line_width // 2, dash_end_y
|
||
# ], fill=divider_color + (150,)) # 半透明
|
||
|
||
# # 添加点划线装饰(在虚线两侧)
|
||
# dot_size = 1
|
||
# dot_spacing = 12
|
||
# side_offset = 8
|
||
|
||
# # 左侧点线
|
||
# for i in range(0, int(total_length), dot_spacing):
|
||
# dot_y = divider_top_y + i
|
||
# if dot_y < divider_bottom_y:
|
||
# draw.ellipse([
|
||
# divider_x - side_offset - dot_size, dot_y - dot_size,
|
||
# divider_x - side_offset + dot_size, dot_y + dot_size
|
||
# ], fill=divider_color + (100,))
|
||
|
||
# # 右侧点线
|
||
# for i in range(0, int(total_length), dot_spacing):
|
||
# dot_y = divider_top_y + i
|
||
# if dot_y < divider_bottom_y:
|
||
# draw.ellipse([
|
||
# divider_x + side_offset - dot_size, dot_y - dot_size,
|
||
# divider_x + side_offset + dot_size, dot_y + dot_size
|
||
# ], fill=divider_color + (100,))
|
||
|
||
# # 添加中心装饰元素
|
||
# mid_y = (divider_top_y + divider_bottom_y) // 2
|
||
|
||
# # 中心小圆形
|
||
# center_dot_size = 3
|
||
# draw.ellipse([
|
||
# divider_x - center_dot_size, mid_y - center_dot_size,
|
||
# divider_x + center_dot_size, mid_y + center_dot_size
|
||
# ], fill=divider_color + (200,))
|
||
|
||
# # 中心周围的小装饰点
|
||
# small_dot_size = 1
|
||
# decoration_radius = 8
|
||
# for angle in [0, 45, 90, 135, 180, 225, 270, 315]: # 8个方向
|
||
# import math
|
||
# angle_rad = math.radians(angle)
|
||
# decoration_x = divider_x + decoration_radius * math.cos(angle_rad)
|
||
# decoration_y = mid_y + decoration_radius * math.sin(angle_rad)
|
||
|
||
# draw.ellipse([
|
||
# decoration_x - small_dot_size, decoration_y - small_dot_size,
|
||
# decoration_x + small_dot_size, decoration_y + small_dot_size
|
||
# ], fill=divider_color + (120,))
|
||
|
||
# 渲染价格
|
||
self._add_text_with_shadow(
|
||
draw, (price_x, int(price_y)), price_text, price_font,
|
||
text_color=(255, 255, 255, 255), shadow_color=(0, 0, 0, 150),
|
||
shadow_offset=(2, 2)
|
||
)
|
||
|
||
# 计算CNY位置(在价格下方,右对齐)
|
||
cny_width = cny_bbox[2] - cny_bbox[0]
|
||
cny_x = right_margin - cny_width
|
||
cny_y = price_y + price_height + 15 # 价格下方15像素间距
|
||
|
||
# 渲染CNY标识符
|
||
self._add_text_with_shadow(
|
||
draw, (cny_x, int(cny_y)), cny_text, cny_font,
|
||
text_color=(250, 250, 210, 255), shadow_color=(0, 0, 0, 120),
|
||
shadow_offset=(1, 1)
|
||
)
|
||
|
||
# 返回最终位置
|
||
info_section_height = info_count * (info_line_height + final_line_spacing)
|
||
price_section_height = price_height + cny_height + 15 # 价格区域总高度
|
||
final_y = max(info_y + info_section_height, price_y + price_section_height)
|
||
|
||
print(f"信息区域渲染完成: 起始Y={y}, 结束Y={final_y:.1f}, 使用高度={final_y - y:.1f}")
|
||
print(f"价格字体大小: {price_font_size}, CNY字体大小: {cny_font_size}")
|
||
print(f"分隔线位置: x={divider_x}, 高度={divider_height}")
|
||
|
||
return int(final_y)
|
||
|
||
def get_template_info(self) -> Dict[str, Any]:
|
||
"""获取模板信息"""
|
||
return {
|
||
"name": "商务模板",
|
||
"description": "基于酒店模块的商务风格海报模板",
|
||
"version": "2.0.0",
|
||
"author": "PosterGenerator",
|
||
"features": [
|
||
"1:2:1布局比例",
|
||
"双图像透明度融合",
|
||
"小图支持",
|
||
"智能颜色提取",
|
||
"文本自适应布局"
|
||
],
|
||
"supported_formats": ["PNG", "JPEG"],
|
||
"default_size": self.size,
|
||
"required_params": ["top_image_path", "bottom_image_path"],
|
||
"optional_params": ["small_image_paths", "hotel_info", "color_theme"]
|
||
}
|
||
|
||
def validate_inputs(self, **kwargs) -> bool:
|
||
"""验证输入参数"""
|
||
required_params = ["top_image_path", "bottom_image_path"]
|
||
|
||
for param in required_params:
|
||
if param not in kwargs or not kwargs[param]:
|
||
print(f"缺少必要参数: {param}")
|
||
return False
|
||
|
||
# 检查文件是否存在
|
||
if not os.path.exists(kwargs[param]):
|
||
print(f"文件不存在: {kwargs[param]}")
|
||
return False
|
||
|
||
# 检查小图路径(如果提供)
|
||
if "small_image_paths" in kwargs and kwargs["small_image_paths"]:
|
||
for path in kwargs["small_image_paths"]:
|
||
if not os.path.exists(path):
|
||
print(f"小图文件不存在: {path}")
|
||
return False
|
||
|
||
print("输入参数验证通过")
|
||
return True
|
||
|
||
def get_layout_areas(self) -> Dict[str, Tuple[int, int, int, int]]:
|
||
"""获取布局区域信息"""
|
||
section_heights = self._calculate_section_heights()
|
||
width = self.size[0]
|
||
|
||
return {
|
||
"top_section": (0, 0, width, section_heights['top_height']),
|
||
"middle_section": (0, section_heights['middle_start'], width, section_heights['middle_height']),
|
||
"bottom_section": (0, section_heights['bottom_start'], width, section_heights['bottom_height']),
|
||
"text_area": (0, int(self.size[1] * 0.2), width, int(self.size[1] * 0.6)),
|
||
"full_canvas": (0, 0, width, self.size[1])
|
||
}
|
||
|
||
# 工具方法
|
||
def _resize_image(self, image: Image.Image, target_width: int) -> Image.Image:
|
||
"""调整图像大小,保持原始高宽比"""
|
||
orig_aspect = image.width / image.height
|
||
return image.resize((target_width, int(target_width / orig_aspect)), Image.LANCZOS)
|
||
|
||
def _ensure_rgba(self, image: Image.Image) -> Image.Image:
|
||
"""确保图像是RGBA模式"""
|
||
if image.mode == 'RGBA':
|
||
return image
|
||
elif image.mode == 'RGB':
|
||
rgba_image = Image.new('RGBA', image.size, (0, 0, 0, 0))
|
||
rgba_image.paste(image, (0, 0))
|
||
return rgba_image
|
||
else:
|
||
return image.convert('RGBA')
|
||
|
||
def _create_gradient_background(self, width: int, height: int,
|
||
top_color: Tuple[int, int, int],
|
||
bottom_color: Tuple[int, int, int]) -> Image.Image:
|
||
"""创建现代化的多层渐变背景"""
|
||
background = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
||
|
||
# 确保颜色对比度
|
||
top_brightness = sum(top_color) / 3
|
||
bottom_brightness = sum(bottom_color) / 3
|
||
|
||
if abs(top_brightness - bottom_brightness) < 20:
|
||
if top_brightness > 128:
|
||
top_color = tuple(max(0, c - 50) for c in top_color)
|
||
else:
|
||
top_color = tuple(min(255, c + 50) for c in top_color)
|
||
|
||
# 创建多层渐变效果
|
||
top_color_array = np.array(top_color)
|
||
bottom_color_array = np.array(bottom_color)
|
||
|
||
# 添加中间过渡色,使渐变更自然
|
||
mid_color_array = (top_color_array + bottom_color_array) / 2
|
||
# 稍微调整中间色的饱和度
|
||
mid_color_array = mid_color_array * 0.9 + np.array([20, 20, 30]) # 增加一点暖色调
|
||
mid_color_array = np.clip(mid_color_array, 0, 255)
|
||
|
||
for y in range(height):
|
||
ratio = y / height
|
||
|
||
# 使用三段式渐变:上部-中部-下部
|
||
if ratio < 0.4: # 上部区域
|
||
smooth_ratio = ratio / 0.4
|
||
# 使用缓动函数让过渡更自然
|
||
smooth_ratio = smooth_ratio * smooth_ratio * (3.0 - 2.0 * smooth_ratio) # smoothstep
|
||
color = (1 - smooth_ratio) * top_color_array + smooth_ratio * mid_color_array
|
||
else: # 下部区域
|
||
smooth_ratio = (ratio - 0.4) / 0.6
|
||
# 使用不同的缓动函数
|
||
smooth_ratio = 0.5 * (1 + math.sin((smooth_ratio - 0.5) * math.pi))
|
||
color = (1 - smooth_ratio) * mid_color_array + smooth_ratio * bottom_color_array
|
||
|
||
# 添加微妙的噪点效果
|
||
noise_factor = (random.random() - 0.5) * 8 # 减小噪点强度
|
||
color = np.clip(color + noise_factor, 0, 255)
|
||
|
||
color_tuple = tuple(color.astype(np.uint8)) + (255,)
|
||
|
||
for x in range(width):
|
||
# 添加径向渐变效果
|
||
center_x, center_y = width // 2, height // 2
|
||
distance_from_center = math.sqrt((x - center_x)**2 + (y - center_y)**2)
|
||
max_distance = math.sqrt(center_x**2 + center_y**2)
|
||
radial_factor = 1.0 - (distance_from_center / max_distance) * 0.15 # 轻微的径向效果
|
||
|
||
final_color = tuple(int(c * radial_factor) for c in color_tuple[:3]) + (255,)
|
||
background.putpixel((x, y), final_color)
|
||
|
||
return background
|
||
|
||
def _add_text_with_shadow(self, draw: ImageDraw.Draw, position: Tuple[int, int],
|
||
text: str, font: ImageFont.FreeTypeFont,
|
||
text_color: Tuple[int, int, int] = (255, 255, 255),
|
||
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 150),
|
||
shadow_offset: Tuple[int, int] = (2, 2)) -> None:
|
||
"""添加带阴影的文字"""
|
||
shadow_position = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
|
||
draw.text(shadow_position, text, font=font, fill=shadow_color)
|
||
draw.text(position, text, font=font, fill=text_color)
|
||
|
||
def _add_text_with_outline(self, draw: ImageDraw.Draw, position: Tuple[int, int],
|
||
text: str, font: ImageFont.FreeTypeFont,
|
||
text_color: Tuple[int, int, int] = (255, 255, 255),
|
||
outline_color: Tuple[int, int, int, int] = (0, 0, 0, 200),
|
||
outline_width: int = 2) -> None:
|
||
"""添加带描边的文字"""
|
||
x, y = position
|
||
|
||
for offset_x in range(-outline_width, outline_width + 1):
|
||
for offset_y in range(-outline_width, outline_width + 1):
|
||
if offset_x == 0 and offset_y == 0:
|
||
continue
|
||
draw.text((x + offset_x, y + offset_y), text, font=font, fill=outline_color)
|
||
|
||
draw.text(position, text, font=font, fill=text_color)
|
||
|
||
def _add_rounded_rectangle(self, draw: ImageDraw.Draw, position: Tuple[int, int],
|
||
size: Tuple[int, int], radius: int,
|
||
fill_color: Tuple[int, int, int, int],
|
||
outline_color: Optional[Tuple[int, int, int, int]] = None,
|
||
outline_width: int = 0) -> None:
|
||
"""绘制圆角矩形"""
|
||
x1, y1 = position
|
||
x2, y2 = x1 + size[0], y1 + size[1]
|
||
draw.rounded_rectangle([x1, y1, x2, y2], radius=radius,
|
||
fill=fill_color, outline=outline_color, width=outline_width)
|
||
|
||
def _calculate_optimal_font_size(self, text: str, font_path: str,
|
||
target_width: int, max_size: int = 120,
|
||
min_size: int = 10) -> int:
|
||
"""计算最佳字体大小"""
|
||
low = min_size
|
||
high = max_size
|
||
best_size = min_size
|
||
best_width = 0
|
||
|
||
while low <= high:
|
||
mid = (low + high) // 2
|
||
font = ImageFont.truetype(font_path, mid)
|
||
bbox = font.getbbox(text)
|
||
width = bbox[2] - bbox[0]
|
||
|
||
if width < target_width:
|
||
if width > best_width:
|
||
best_width = width
|
||
best_size = mid
|
||
low = mid + 1
|
||
else:
|
||
high = mid - 1
|
||
|
||
best_font = ImageFont.truetype(font_path, best_size)
|
||
return best_size, best_font
|
||
|
||
def _add_decorative_elements(self, canvas: Image.Image) -> Image.Image:
|
||
"""添加现代化装饰元素"""
|
||
width, height = canvas.size
|
||
|
||
# 创建装饰层
|
||
overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
# 1. 添加顶部装饰线条
|
||
line_y = height // 4 - 20
|
||
gradient_width = width // 3
|
||
start_x = (width - gradient_width) // 2
|
||
|
||
for i in range(gradient_width):
|
||
alpha = int(255 * (1 - abs(i - gradient_width//2) / (gradient_width//2)) * 0.3)
|
||
draw.line([(start_x + i, line_y), (start_x + i, line_y + 2)],
|
||
fill=(255, 255, 255, alpha), width=1)
|
||
|
||
# 2. 添加几何装饰
|
||
# 左上角装饰
|
||
corner_size = 80
|
||
corner_alpha = 40
|
||
draw.arc([20, 20, 20 + corner_size, 20 + corner_size],
|
||
start=180, end=270, fill=(255, 255, 255, corner_alpha), width=3)
|
||
|
||
# 右下角装饰
|
||
draw.arc([width - corner_size - 20, height - corner_size - 20,
|
||
width - 20, height - 20],
|
||
start=0, end=90, fill=(255, 255, 255, corner_alpha), width=3)
|
||
|
||
# 3. 添加中心区域的微妙光效
|
||
center_x, center_y = width // 2, height // 2
|
||
light_radius = 150
|
||
for r in range(light_radius, 0, -5):
|
||
alpha = int(10 * (1 - r / light_radius))
|
||
draw.ellipse([center_x - r, center_y - r, center_x + r, center_y + r],
|
||
fill=(255, 255, 255, alpha))
|
||
|
||
# 将装饰层合成到原图
|
||
canvas = Image.alpha_composite(canvas, overlay)
|
||
return canvas
|
||
|
||
def _create_text_background_card(self, canvas: Image.Image,
|
||
text_area: Tuple[int, int, int, int]) -> Image.Image:
|
||
"""为文本区域创建卡片式背景"""
|
||
x, y, w, h = text_area
|
||
|
||
# 创建卡片层
|
||
card_overlay = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(card_overlay)
|
||
|
||
# 卡片背景 - 使用磨砂玻璃效果
|
||
card_padding = 40
|
||
card_x = x + card_padding
|
||
card_y = y + card_padding
|
||
card_w = w - card_padding * 2
|
||
card_h = h - card_padding * 2
|
||
|
||
# 添加阴影
|
||
shadow_offset = 8
|
||
self._add_rounded_rectangle(
|
||
draw,
|
||
(card_x + shadow_offset, card_y + shadow_offset),
|
||
(card_w, card_h),
|
||
radius=25,
|
||
fill_color=(0, 0, 0, 30) # 阴影
|
||
)
|
||
|
||
# 主卡片背景
|
||
self._add_rounded_rectangle(
|
||
draw,
|
||
(card_x, card_y),
|
||
(card_w, card_h),
|
||
radius=25,
|
||
fill_color=(255, 255, 255, 25), # 半透明白色
|
||
outline_color=(255, 255, 255, 80),
|
||
outline_width=1
|
||
)
|
||
|
||
# 添加内部光效
|
||
inner_glow_size = 20
|
||
self._add_rounded_rectangle(
|
||
draw,
|
||
(card_x + inner_glow_size, card_y + inner_glow_size),
|
||
(card_w - inner_glow_size * 2, card_h - inner_glow_size * 2),
|
||
radius=15,
|
||
fill_color=(255, 255, 255, 10)
|
||
)
|
||
|
||
return Image.alpha_composite(canvas, card_overlay)
|
||
|
||
def _get_smart_text_color(self, background_colors: Tuple[Tuple[int, int, int], Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||
"""
|
||
根据背景颜色智能选择文本颜色
|
||
|
||
Args:
|
||
background_colors: (top_color, bottom_color) 背景颜色元组
|
||
|
||
Returns:
|
||
最适合的文本颜色
|
||
"""
|
||
top_color, bottom_color = background_colors
|
||
|
||
# 计算背景的平均亮度
|
||
avg_brightness = (
|
||
(sum(top_color) + sum(bottom_color)) / 2
|
||
) / 3
|
||
|
||
# 根据亮度选择对比色
|
||
if avg_brightness > 140: # 背景较亮
|
||
# 选择深色文本
|
||
return (45, 55, 75) # 深蓝灰色
|
||
elif avg_brightness > 80: # 背景中等
|
||
# 选择浅色文本
|
||
return (240, 245, 255) # 浅蓝白色
|
||
else: # 背景较暗
|
||
# 选择亮色文本
|
||
return (255, 248, 235) # 暖白色
|
||
|
||
def _get_smart_feature_color(self, background_colors: Tuple[Tuple[int, int, int], Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||
"""
|
||
为feature文本智能选择颜色
|
||
|
||
Args:
|
||
background_colors: (top_color, bottom_color) 背景颜色元组
|
||
|
||
Returns:
|
||
feature文本的最佳颜色
|
||
"""
|
||
top_color, bottom_color = background_colors
|
||
return (255, 255, 255)
|
||
# 计算背景色的色调特征
|
||
def get_color_tone(color):
|
||
r, g, b = color
|
||
max_c = max(r, g, b)
|
||
if max_c == r:
|
||
return 'warm' # 红色调
|
||
elif max_c == g:
|
||
return 'fresh' # 绿色调
|
||
else:
|
||
return 'cool' # 蓝色调
|
||
|
||
# 获取主导色调
|
||
top_tone = get_color_tone(top_color)
|
||
bottom_tone = get_color_tone(bottom_color)
|
||
|
||
# 计算平均亮度
|
||
avg_brightness = (sum(top_color) + sum(bottom_color)) / 6
|
||
|
||
# 根据色调和亮度选择feature颜色
|
||
if avg_brightness > 120: # 背景较亮
|
||
if top_tone == 'cool' or bottom_tone == 'cool':
|
||
return (65, 105, 155) # 深蓝色
|
||
elif top_tone == 'warm' or bottom_tone == 'warm':
|
||
return (155, 85, 65) # 深橙色
|
||
else:
|
||
return (85, 125, 85) # 深绿色
|
||
else: # 背景较暗
|
||
if top_tone == 'cool' or bottom_tone == 'cool':
|
||
return (135, 185, 235) # 亮蓝色
|
||
elif top_tone == 'warm' or bottom_tone == 'warm':
|
||
return (255, 195, 135) # 亮橙色
|
||
else:
|
||
return (155, 215, 155) # 亮绿色
|
||
|
||
def preprocess_image(image_path, target_width=900, target_height=1200, crop_position='center'):
|
||
"""
|
||
预处理图像:调整大小并裁剪到指定尺寸
|
||
|
||
参数:
|
||
image_path: 图像文件路径
|
||
target_width: 目标宽度
|
||
target_height: 目标高度
|
||
crop_position: 裁剪位置,可选 'center'(中心)、'top'(顶部)、'bottom'(底部)
|
||
"""
|
||
try:
|
||
with Image.open(image_path) as img:
|
||
# 获取原始尺寸
|
||
orig_width, orig_height = img.size
|
||
print(f"原始图像尺寸: {orig_width}x{orig_height}")
|
||
|
||
# 计算宽高比
|
||
orig_ratio = orig_width / orig_height
|
||
target_ratio = target_width / target_height
|
||
|
||
# 根据宽高比决定如何调整大小和裁剪
|
||
if orig_ratio > target_ratio:
|
||
# 图像较宽,按高度缩放后裁剪宽度
|
||
new_height = target_height
|
||
new_width = int(orig_width * (target_height / orig_height))
|
||
img_resized = img.resize((new_width, new_height), Image.LANCZOS)
|
||
|
||
# 根据指定位置裁剪
|
||
left = 0
|
||
if crop_position == 'center':
|
||
left = (new_width - target_width) // 2
|
||
elif crop_position == 'right':
|
||
left = new_width - target_width
|
||
right = left + target_width
|
||
img_cropped = img_resized.crop((left, 0, right, target_height))
|
||
else:
|
||
# 图像较高,按宽度缩放后裁剪高度
|
||
new_width = target_width
|
||
new_height = int(orig_height * (target_width / orig_width))
|
||
img_resized = img.resize((new_width, new_height), Image.LANCZOS)
|
||
|
||
# 根据指定位置裁剪
|
||
top = 0
|
||
if crop_position == 'center':
|
||
top = (new_height - target_height) // 2
|
||
elif crop_position == 'bottom':
|
||
top = new_height - target_height
|
||
bottom = top + target_height
|
||
img_cropped = img_resized.crop((0, top, target_width, bottom))
|
||
|
||
# 保存处理后的图像
|
||
processed_path = f"{os.path.splitext(image_path)[0]}_processed.png"
|
||
img_cropped.save(processed_path)
|
||
print(f"图像已处理并保存: {processed_path}")
|
||
return processed_path
|
||
except Exception as e:
|
||
print(f"图像预处理失败: {e}")
|
||
return None
|
||
|
||
|
||
def get_random_images(directory: str, count: int = 2):
|
||
"""从指定目录随机选择图片"""
|
||
if not os.path.exists(directory):
|
||
print(f"目录不存在: {directory}")
|
||
return []
|
||
|
||
image_files = [f for f in os.listdir(directory)
|
||
if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
|
||
|
||
if len(image_files) < count:
|
||
print(f"目录中图片数量不足,需要{count}张,只有{len(image_files)}张")
|
||
return []
|
||
|
||
selected = random.sample(image_files, count)
|
||
return [os.path.join(directory, f) for f in selected]
|
||
|
||
|
||
def test_vibrant_template():
|
||
"""测试活力模板"""
|
||
print("=" * 50)
|
||
print("测试活力模板")
|
||
print("=" * 50)
|
||
|
||
# 准备图片 - 使用与商务模板相同的图像目录
|
||
picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园"
|
||
images = get_random_images(picture_dir, 1)
|
||
|
||
if not images:
|
||
print("无法获取足够的图片进行测试")
|
||
return
|
||
|
||
# 验证图像文件完整性
|
||
try:
|
||
with Image.open(images[0]) as img:
|
||
# 强制加载图像以验证完整性
|
||
img.load()
|
||
print(f"图像验证成功: {images[0]}")
|
||
except Exception as e:
|
||
print(f"图像文件损坏: {images[0]}, 错误: {e}")
|
||
print("尝试查找其他可用图像...")
|
||
# 尝试查找更多图像
|
||
all_images = [os.path.join(picture_dir, f) for f in os.listdir(picture_dir)
|
||
if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
|
||
valid_image = None
|
||
for img_path in all_images:
|
||
try:
|
||
with Image.open(img_path) as img:
|
||
img.load()
|
||
valid_image = img_path
|
||
print(f"找到可用图像: {valid_image}")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if valid_image:
|
||
images = [valid_image]
|
||
else:
|
||
print("无法找到可用的图像文件")
|
||
return
|
||
|
||
# 预处理图像
|
||
processed_image = preprocess_image(images[0], crop_position='top')
|
||
if not processed_image:
|
||
print("图像预处理失败,无法继续测试")
|
||
return
|
||
|
||
# 海洋信息数据
|
||
ocean_info = {
|
||
"title": "馥桂萌宠总动员",
|
||
"slogan": "30+萌宠零距离互动,泼水派对嗨翻天",
|
||
"price": "92",
|
||
"ticket_type": "亲子套票",
|
||
"content_button": "套餐内容",
|
||
"content_items": [
|
||
"1大1小门票(含投喂包)",
|
||
"30+萌宠亲密互动体验",
|
||
"泼水大战+泡沫派对",
|
||
"夜场精酿啤酒畅饮"
|
||
],
|
||
"remarks": [
|
||
"无需预约,随时可退",
|
||
"免费停车+电瓶车接送"
|
||
],
|
||
"tag": "",
|
||
"pagination": ""
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# 创建活力模板实例
|
||
vibrant_template = VibrantTemplate()
|
||
|
||
# 生成海报
|
||
output_path = "/root/autodl-tmp/posterGenerator/template_test_output/test_vibrant_poster.png"
|
||
try:
|
||
poster = vibrant_template.generate(
|
||
image_path=processed_image,
|
||
ocean_info=ocean_info,
|
||
glass_intensity=1.5, # 测试毛玻璃强度
|
||
output_path=output_path,
|
||
theme_color="drak_gray"
|
||
)
|
||
print(f"活力模板海报生成成功: {output_path}")
|
||
|
||
# 显示模板信息
|
||
info = vibrant_template.get_template_info()
|
||
print(f"模板信息: {info['name']} v{info['version']}")
|
||
print(f"特性: {', '.join(info['features'])}")
|
||
|
||
except Exception as e:
|
||
print(f"活力模板测试失败: {e}")
|
||
|
||
|
||
def test_business_template():
|
||
"""测试商务模板"""
|
||
print("=" * 50)
|
||
print("测试商务模板")
|
||
print("=" * 50)
|
||
|
||
# 准备图片
|
||
picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园"
|
||
images = get_random_images(picture_dir, 5) # 需要更多图片用于小图
|
||
|
||
if len(images) < 2:
|
||
print("无法获取足够的图片进行测试")
|
||
return
|
||
|
||
# 验证图像文件完整性
|
||
valid_images = []
|
||
for img_path in images:
|
||
processed = preprocess_image(img_path, crop_position='top')
|
||
if processed:
|
||
valid_images.append(processed)
|
||
else:
|
||
print(f"图像 {img_path} 预处理失败")
|
||
|
||
if len(valid_images) < 2:
|
||
print("可用的有效图像不足,无法继续测试")
|
||
return
|
||
|
||
processed_images = valid_images
|
||
|
||
# 酒店信息数据
|
||
hotel_info = {
|
||
"name": "张家界定制旅行管家",
|
||
"feature": "一对一专属服务 | 深度行程规划",
|
||
"slogan": "您的私人导游,开启专属张家界之旅",
|
||
"price": "私信查询",
|
||
"info_list": [
|
||
"【住】可推荐武陵源/市区高端酒店任选",
|
||
"【食】含特色土家风味餐+每日早餐",
|
||
"【服务】资深导游+行程定制师全程跟进",
|
||
"【设施】景区VIP通道+快速接驳车服务"
|
||
],
|
||
"footer": [
|
||
"预订方式:点击【立即咨询】发送人数+天数",
|
||
"有效日期:即日起至2025年12月31日"
|
||
]
|
||
}
|
||
|
||
# 创建商务模板实例
|
||
business_template = BusinessTemplate()
|
||
print(f"business_info:{hotel_info}")
|
||
# 生成海报
|
||
output_path = "/root/autodl-tmp/posterGenerator/template_test_output/test_business_poster.png"
|
||
try:
|
||
# 准备小图(如果有足够的图片)
|
||
small_images = processed_images[2:5] if len(processed_images) >= 5 else None
|
||
|
||
poster = business_template.generate(
|
||
top_image_path=processed_images[0],
|
||
bottom_image_path=processed_images[1],
|
||
small_image_paths=small_images,
|
||
hotel_info=hotel_info,
|
||
color_theme="blue_gradient", # 测试预设主题
|
||
output_path=output_path
|
||
)
|
||
print(f"商务模板海报生成成功: {output_path}")
|
||
|
||
# 显示模板信息
|
||
info = business_template.get_template_info()
|
||
print(f"模板信息: {info['name']} v{info['version']}")
|
||
print(f"特性: {', '.join(info['features'])}")
|
||
|
||
# 显示布局区域
|
||
areas = business_template.get_layout_areas()
|
||
print("布局区域:")
|
||
for area_name, area_coords in areas.items():
|
||
print(f" {area_name}: {area_coords}")
|
||
|
||
except Exception as e:
|
||
print(f"商务模板测试失败: {e}")
|
||
|
||
|
||
def test_both_templates():
|
||
"""测试两个模板的对比"""
|
||
print("=" * 50)
|
||
print("模板对比测试")
|
||
print("=" * 50)
|
||
|
||
# 准备相同的图片
|
||
picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园"
|
||
images = get_random_images(picture_dir, 5)
|
||
|
||
if len(images) < 2:
|
||
print("无法获取足够的图片进行对比测试")
|
||
return
|
||
|
||
# 验证图像文件完整性
|
||
valid_images = []
|
||
for img_path in images:
|
||
processed = preprocess_image(img_path, crop_position='top')
|
||
if processed:
|
||
valid_images.append(processed)
|
||
else:
|
||
print(f"图像 {img_path} 预处理失败")
|
||
|
||
if len(valid_images) < 2:
|
||
print("可用的有效图像不足,无法继续对比测试")
|
||
return
|
||
|
||
processed_images = valid_images
|
||
|
||
# 使用相同的图片生成两种风格的海报
|
||
base_image = processed_images[0]
|
||
|
||
# 活力模板数据
|
||
ocean_info = {
|
||
"title": "【商务海洋套餐】",
|
||
"slogan": "商务与休闲的完美结合",
|
||
"content_items": [
|
||
"🏢 商务会议室使用",
|
||
"🌊 海景房住宿",
|
||
"🍽️ 商务午餐",
|
||
"⛵ 海上商务活动"
|
||
],
|
||
"price": "2688",
|
||
"ticket_type": "商务套餐",
|
||
"remarks": ["含税含服务费"],
|
||
"tag": "限时特惠",
|
||
"pagination": "1/2"
|
||
}
|
||
|
||
# 酒店信息数据
|
||
hotel_info = {
|
||
"name": "海景商务酒店",
|
||
"feature": "海景与商务的完美融合 | 高端定制服务",
|
||
"slogan": "",
|
||
"price": "2688",
|
||
"info_list": [
|
||
"【住】海景商务套房2晚",
|
||
"【食】海鲜商务套餐",
|
||
"【会】专业会议室服务",
|
||
"【娱】海上商务活动"
|
||
],
|
||
"footer": [
|
||
]
|
||
}
|
||
|
||
# 生成活力风格海报
|
||
try:
|
||
vibrant_template = VibrantTemplate()
|
||
vibrant_poster = vibrant_template.generate(
|
||
image_path=base_image,
|
||
ocean_info=ocean_info,
|
||
glass_intensity=2.0,
|
||
output_path="comparison_vibrant.png",
|
||
theme_color="elegant"
|
||
)
|
||
print("活力风格海报生成成功: comparison_vibrant.png")
|
||
except Exception as e:
|
||
print(f"活力风格生成失败: {e}")
|
||
|
||
# 生成商务风格海报
|
||
try:
|
||
business_template = BusinessTemplate()
|
||
small_images = processed_images[2:5] if len(processed_images) >= 5 else None
|
||
business_poster = business_template.generate(
|
||
top_image_path=base_image,
|
||
bottom_image_path=processed_images[1] if len(processed_images) > 1 else base_image,
|
||
small_image_paths=small_images,
|
||
hotel_info=hotel_info,
|
||
color_theme="elegant",
|
||
output_path="comparison_business.png"
|
||
)
|
||
print("商务风格海报生成成功: comparison_business.png")
|
||
except Exception as e:
|
||
print(f"商务风格生成失败: {e}")
|
||
|
||
print("\n对比测试完成!")
|
||
print("活力模板特点:单图背景,毛玻璃效果,两栏布局")
|
||
print("商务模板特点:双图融合,透明度效果,垂直布局")
|
||
|
||
|
||
def main():
|
||
"""主函数"""
|
||
print("重构模板演示程序")
|
||
print("=" * 60)
|
||
|
||
# 检查必要目录
|
||
picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园"
|
||
if not os.path.exists(picture_dir):
|
||
print(f"错误:图片目录不存在 {picture_dir}")
|
||
return
|
||
|
||
# 创建输出目录
|
||
os.makedirs("template_test_output", exist_ok=True)
|
||
os.chdir("template_test_output")
|
||
|
||
try:
|
||
# 测试活力模板
|
||
test_vibrant_template()
|
||
print()
|
||
|
||
# 测试商务模板
|
||
# test_business_template()
|
||
# print()
|
||
|
||
# 对比测试
|
||
# test_both_templates()
|
||
|
||
except KeyboardInterrupt:
|
||
print("\n用户中断测试")
|
||
except Exception as e:
|
||
print(f"测试过程中出错: {e}")
|
||
|
||
print("\n所有测试完成!")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |