From fcb02de2cb875250c0d54c18c45e874f75cf98fc Mon Sep 17 00:00:00 2001 From: cyborvirtue <2088953655@qq.com> Date: Tue, 20 May 2025 17:01:40 +0800 Subject: [PATCH] psd method init --- .../__pycache__/compose_poster.cpython-38.pyc | Bin 0 -> 2159 bytes scripts/__pycache__/export_psd.cpython-38.pyc | Bin 0 -> 2429 bytes scripts/compose_poster.py | 84 ++++ scripts/compose_poster_1.py | 402 ++++++++++++++++++ scripts/export_psd.py | 91 ++++ scripts/test1.py | 7 + scripts/test2.py | 8 + 7 files changed, 592 insertions(+) create mode 100644 scripts/__pycache__/compose_poster.cpython-38.pyc create mode 100644 scripts/__pycache__/export_psd.cpython-38.pyc create mode 100644 scripts/compose_poster.py create mode 100644 scripts/compose_poster_1.py create mode 100644 scripts/export_psd.py create mode 100644 scripts/test1.py create mode 100644 scripts/test2.py diff --git a/scripts/__pycache__/compose_poster.cpython-38.pyc b/scripts/__pycache__/compose_poster.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2326f4d6626111ddc648977d71478a0b5dd39b71 GIT binary patch literal 2159 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 3: + # 从命令行接收参数 + layers = argv[1:-1] # 现在只需要提供文件名,不需要完整路径 + output = argv[-1] + compose_layers(layers, output) + else: + print("用法: python compose_poster.py layer1.png layer2.png ... output.png") + print(f"图片将从默认目录加载: {DEFAULT_IMAGE_DIR}") \ No newline at end of file diff --git a/scripts/compose_poster_1.py b/scripts/compose_poster_1.py new file mode 100644 index 0000000..60778e9 --- /dev/null +++ b/scripts/compose_poster_1.py @@ -0,0 +1,402 @@ +""" +图层合成模块 - 将生成的各个图层组合成完整海报 (修改版) + +提供增强的图层信息导出,便于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'])}个图层") \ No newline at end of file diff --git a/scripts/export_psd.py b/scripts/export_psd.py new file mode 100644 index 0000000..2800a48 --- /dev/null +++ b/scripts/export_psd.py @@ -0,0 +1,91 @@ +# export_psd.py - PSD导出 (适配psd-tools 1.10.0 API) +import os +from PIL import Image +import logging + +# 设置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# 设置默认目录路径 +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_INPUT_DIR = os.path.join(project_root, 'images') +DEFAULT_OUTPUT_DIR = os.path.join(project_root, 'outputs') + +def export_to_psd(layers, output_path, layer_names=None, canvas_size=(1080, 1920)): + # 先导入compose_poster中的函数 + from compose_poster import compose_layers + + try: + # 导入psd-tools相关模块 + from psd_tools import PSDImage + from psd_tools.api.layers import PixelLayer + from psd_tools.constants import Compression + except ImportError: + logger.error("未安装psd-tools库,无法导出PSD。请安装:pip install psd-tools>=1.10.0") + return "" + + logger.info(f"开始创建PSD文件,包含{len(layers)}个图层...") + + # 1. 先使用compose_layers合成图层 + temp_output = os.path.join(DEFAULT_OUTPUT_DIR, "temp_composed.png") + composed_path = compose_layers(layers, temp_output, canvas_size) + + if not composed_path: + logger.error("图层合成失败") + return "" + + # 2. 处理输出路径 + if not os.path.isabs(output_path): + output_path = os.path.join(DEFAULT_OUTPUT_DIR, output_path) + + if not output_path.lower().endswith('.psd'): + output_path += '.psd' + + # 确保输出目录存在 + output_dir = os.path.dirname(output_path) + if output_dir and not os.path.exists(output_dir): + os.makedirs(output_dir) + + try: + # 3. 创建PSD文件 + psd = PSDImage.new('RGB', canvas_size) + + # 4. 添加合成后的图层 + composed_image = Image.open(composed_path).convert('RGBA') + pixel_layer = PixelLayer.frompil(composed_image, psd, "Composed_Layer") + psd.append(pixel_layer) + + # 5. 保存PSD文件 + psd.save(output_path) + logger.info(f"PSD文件已成功创建并保存到: {output_path}") + + # 6. 清理临时文件 + if os.path.exists(temp_output): + os.remove(temp_output) + + return output_path + + except Exception as e: + logger.error(f"创建PSD文件时出错: {str(e)}") + logger.exception(e) + return "" + +if __name__ == "__main__": + # 简单测试 + from sys import argv + + if len(argv) >= 3: + # 从命令行接收参数 + layers = argv[1:-1] + output = argv[-1] + result = export_to_psd(layers, output) + if result: + print(f"PSD文件已成功导出到: {result}") + else: + print("PSD文件导出失败") + else: + print("用法: python export_psd.py layer1.png layer2.png ... output.psd") + print(f"默认输入目录: {DEFAULT_INPUT_DIR}") + print(f"默认输出目录: {DEFAULT_OUTPUT_DIR}") + print("注意:如果只提供文件名,将从默认输入目录查找图片文件") \ No newline at end of file diff --git a/scripts/test1.py b/scripts/test1.py new file mode 100644 index 0000000..3ff1933 --- /dev/null +++ b/scripts/test1.py @@ -0,0 +1,7 @@ +from compose_poster import compose_layers + +# 将多个图层合成为一个图像 +compose_layers( + ['background.jpg','aaai.png', 'nankai.jpg'], + +) \ No newline at end of file diff --git a/scripts/test2.py b/scripts/test2.py new file mode 100644 index 0000000..78bd2d8 --- /dev/null +++ b/scripts/test2.py @@ -0,0 +1,8 @@ +from export_psd import export_to_psd + +# 将多个图层导出为PSD文件 +export_to_psd( + ['background.jpg','aaai.png', 'nankai.jpg'], + 'output.psd', + ['background', 'middle', 'front'] # 可选的图层名称 +) \ No newline at end of file