#!/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] = (900, 1200)): """ 初始化基础模板 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] = (900, 1200)): """ 初始化活力模板 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": (900, 1200), "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] = (900, 1200)): """ 初始化商务模板 Args: size: 海报尺寸,默认为酒店模块的尺寸 (900, 1200) """ 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()