ai_service/scripts/compose_poster_1.py
2025-05-20 17:01:40 +08:00

402 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
图层合成模块 - 将生成的各个图层组合成完整海报 (修改版)
提供增强的图层信息导出便于PSD模块使用
"""
import os
import json
import logging
from typing import List, Dict, Any, Optional, Tuple
import numpy as np
from PIL import Image, ImageFont, ImageDraw
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class PosterComposer:
"""海报合成类,负责将各个图层组合成完整海报"""
def __init__(self, images_dir: str = "./images", output_dir: str = "./outputs"):
"""
初始化海报合成器
参数:
images_dir: 源图像目录
output_dir: 合成图像输出目录
"""
self.images_dir = images_dir
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
logger.info(f"海报合成器初始化完成,图像目录: {images_dir}, 输出目录: {output_dir}")
def apply_golden_ratio_positioning(self,
canvas_size: Tuple[int, int],
element_size: Tuple[int, int],
position: str = "right") -> Tuple[int, int]:
"""
基于黄金分割比例(约1.618)计算位置
参数:
canvas_size: 画布宽度和高度
element_size: 要放置元素的宽度和高度
position: 黄金分割位置 ("left", "right", "top", "bottom")
返回:
元素的(x, y)坐标
"""
width, height = canvas_size
elem_width, elem_height = element_size
# 黄金比例约为1.618
golden_ratio = 1.618
if position == "right":
# 水平方向右侧黄金分割点
x = int((width / golden_ratio) - (elem_width / 2))
y = int((height - elem_height) / 2) # 垂直居中
elif position == "left":
# 水平方向左侧黄金分割点
x = int(width - (width / golden_ratio) - (elem_width / 2))
y = int((height - elem_height) / 2) # 垂直居中
elif position == "top":
# 垂直方向上侧黄金分割点
x = int((width - elem_width) / 2) # 水平居中
y = int((height / golden_ratio) - (elem_height / 2))
elif position == "bottom":
# 垂直方向下侧黄金分割点
x = int((width - elem_width) / 2) # 水平居中
y = int(height - (height / golden_ratio) - (elem_height / 2))
else: # 默认居中
x = int((width - elem_width) / 2)
y = int((height - elem_height) / 2)
return (x, y)
def calculate_position(self,
position_type: str,
canvas_size: Tuple[int, int],
element_size: Tuple[int, int],
offset: Tuple[int, int] = (0, 0),
reference: str = None) -> Tuple[int, int]:
"""
根据指定的位置类型计算位置
参数:
position_type: 位置类型 ("center", "golden_ratio", "corner", "custom")
canvas_size: 画布宽度和高度
element_size: 要放置元素的宽度和高度
offset: 从计算位置的(x, y)偏移量
reference: 额外位置参考 (如 "top_left", "golden_right")
返回:
元素的(x, y)坐标
"""
width, height = canvas_size
elem_width, elem_height = element_size
offset_x, offset_y = offset
if position_type == "center":
# 居中定位
x = int((width - elem_width) / 2) + offset_x
y = int((height - elem_height) / 2) + offset_y
elif position_type == "golden_ratio":
# 黄金分割定位
golden_pos = reference.split("_")[1] if reference else "right"
x, y = self.apply_golden_ratio_positioning(canvas_size, element_size, golden_pos)
x += offset_x
y += offset_y
elif position_type == "corner":
# 角落定位
corner = reference if reference else "top_left"
if corner == "top_left":
x, y = 0, 0
elif corner == "top_right":
x, y = width - elem_width, 0
elif corner == "bottom_left":
x, y = 0, height - elem_height
elif corner == "bottom_right":
x, y = width - elem_width, height - elem_height
x += offset_x
y += offset_y
elif position_type == "custom":
# 自定义位置,偏移量为绝对位置
x, y = offset_x, offset_y
else: # 默认居中
x = int((width - elem_width) / 2) + offset_x
y = int((height - elem_height) / 2) + offset_y
return (x, y)
def fit_image_to_canvas(self,
image: Image.Image,
canvas_size: Tuple[int, int],
fit_method: str = "contain") -> Image.Image:
"""
调整图像大小以适应画布尺寸
参数:
image: 要调整大小的PIL图像
canvas_size: 目标画布尺寸
fit_method: 适应方法 ("contain", "cover", "stretch")
返回:
调整大小后的PIL图像
"""
canvas_width, canvas_height = canvas_size
img_width, img_height = image.size
if fit_method == "contain":
# 缩放图像以适应画布同时保持纵横比
ratio = min(canvas_width / img_width, canvas_height / img_height)
new_width = int(img_width * ratio)
new_height = int(img_height * ratio)
elif fit_method == "cover":
# 缩放图像以覆盖画布同时保持纵横比
ratio = max(canvas_width / img_width, canvas_height / img_height)
new_width = int(img_width * ratio)
new_height = int(img_height * ratio)
elif fit_method == "stretch":
# 拉伸图像以匹配画布尺寸
new_width = canvas_width
new_height = canvas_height
else: # 默认为"contain"
ratio = min(canvas_width / img_width, canvas_height / img_height)
new_width = int(img_width * ratio)
new_height = int(img_height * ratio)
# 调整图像大小
return image.resize((new_width, new_height), Image.LANCZOS)
def apply_overlay(self,
base_image: Image.Image,
overlay_image: Image.Image,
position: Tuple[int, int] = (0, 0),
opacity: float = 1.0) -> Image.Image:
"""
将叠加图像应用到基础图像上
参数:
base_image: 基础PIL图像
overlay_image: 叠加PIL图像
position: 放置叠加图的位置(x, y)
opacity: 叠加图像的不透明度(0.0到1.0)
返回:
合并后的PIL图像
"""
# 创建基础图像的副本
result = base_image.copy()
# 确保叠加图像有alpha通道
if overlay_image.mode != 'RGBA':
overlay_image = overlay_image.convert('RGBA')
# 如果不透明度小于1调整alpha通道
if opacity < 1.0:
overlay_data = list(overlay_image.getdata())
new_data = []
for item in overlay_data:
# 通过不透明度调整alpha通道
new_data.append((item[0], item[1], item[2], int(item[3] * opacity)))
overlay_image.putdata(new_data)
# 将叠加图粘贴到结果上
result.paste(overlay_image, position, overlay_image)
return result
def load_image(self, path: str) -> Optional[Image.Image]:
"""
从文件路径加载图像
参数:
path: 图像文件的路径
返回:
PIL图像对象加载失败则返回None
"""
try:
if os.path.exists(path):
return Image.open(path).convert('RGBA')
else:
logger.warning(f"未找到图像文件: {path}")
return None
except Exception as e:
logger.error(f"{path}加载图像时出错: {e}")
return None
def compose_from_layout(self,
layout_config: Dict[str, Any],
output_filename: str = "composed_poster.png",
canvas_size: Tuple[int, int] = (1200, 1600)) -> Dict[str, Any]:
"""
根据布局配置合成海报
参数:
layout_config: 指定图层及其属性的配置
output_filename: 输出图像文件的名称
canvas_size: 输出画布的大小(宽度,高度)
返回:
包含合成海报信息的字典
"""
# 创建带alpha通道的空白画布
canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0))
# 获取从底到顶的图层顺序
layer_order = layout_config.get("layer_order", [
"background", "main_subject", "secondary_subject",
"decorative_elements", "logo", "title", "subtitle"
])
# 跟踪已处理的图层以供输出信息
processed_layers = {}
# 根据布局配置处理每个图层
for layer_name in layer_order:
layer_config = layout_config.get(layer_name, {})
if not layer_config:
logger.warning(f"未找到图层'{layer_name}'的配置")
continue
# 获取图层图像路径
image_path = layer_config.get("image_path", "")
full_path = os.path.join(self.images_dir, image_path) if image_path else None
if not full_path or not os.path.exists(full_path):
logger.warning(f"未找到图层'{layer_name}'的图像文件: {full_path}")
continue
# 加载图层图像
layer_img = self.load_image(full_path)
if layer_img is None:
continue
# 获取拟合方法
fit_method = layer_config.get("fit_method", "contain")
# 如果指定,调整图层大小以适应画布
if fit_method != "none":
layer_img = self.fit_image_to_canvas(layer_img, canvas_size, fit_method)
# 获取位置信息
position_type = layer_config.get("position_type", "center")
position_reference = layer_config.get("position_reference", None)
position_offset = layer_config.get("position_offset", (0, 0))
# 计算位置
position = self.calculate_position(
position_type,
canvas_size,
layer_img.size,
position_offset,
position_reference
)
# 获取不透明度
opacity = layer_config.get("opacity", 1.0)
# 将图层应用到画布
canvas = self.apply_overlay(canvas, layer_img, position, opacity)
# 记录处理信息
processed_layers[layer_name] = {
"image_path": full_path,
"size": layer_img.size,
"position": position,
"opacity": opacity
}
logger.info(f"已添加图层'{layer_name}',位置{position},大小{layer_img.size}")
# 保存合成图像
output_path = os.path.join(self.output_dir, output_filename)
canvas.save(output_path, format="PNG")
logger.info(f"已保存合成图像到{output_path}")
# 保存图层顺序用于PSD导出
composition_result = {
"output_path": output_path,
"layers": processed_layers,
"canvas_size": canvas_size,
"layer_order": [layer for layer in layer_order if layer in processed_layers] # 只包含实际处理的图层
}
return composition_result
def generate_layer_info_for_psd(self, composition_result: Dict[str, Any]) -> Dict[str, Any]:
"""
生成PSD导出所需的完整图层信息
参数:
composition_result: compose_from_layout的结果
返回:
包含图层位置、路径、透明度等信息的字典供PSD导出使用
"""
# 这个方法直接返回composition_result中已经包含的完整图层信息
# 为了保持与原代码的API兼容性我们保留这个方法
psd_info = {
"layer_order": composition_result.get("layer_order", []),
"layers": composition_result.get("layers", {}),
"canvas_size": composition_result.get("canvas_size", (1200, 1600))
}
logger.info(f"已生成PSD导出信息包含{len(psd_info['layer_order'])}个图层")
return psd_info
def load_layout_config(config_path: str) -> Dict[str, Any]:
"""
从JSON文件加载布局配置
参数:
config_path: 布局配置JSON文件的路径
返回:
包含布局配置的字典
"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"{config_path}加载布局配置时出错: {e}")
return {}
if __name__ == "__main__":
# 示例用法
layout_config_path = "../configs/layout_config.json"
# 加载布局配置
layout_config = load_layout_config(layout_config_path)
# 创建合成器并合成海报
composer = PosterComposer()
composition_result = composer.compose_from_layout(layout_config)
print(f"海报成功合成到: {composition_result['output_path']}")
# 为PSD导出生成图层信息
psd_info = composer.generate_layer_info_for_psd(composition_result)
print(f"PSD导出信息准备完成包含{len(psd_info['layer_order'])}个图层")