From 1256bda865af35cfca23d208c3a7209e4cbb176b Mon Sep 17 00:00:00 2001 From: zejie_chen <1347094647@qq.com> Date: Fri, 24 Oct 2025 11:57:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9F=A9=E9=98=B5=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poster/templates/matrix_template.py | 441 ++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 poster/templates/matrix_template.py diff --git a/poster/templates/matrix_template.py b/poster/templates/matrix_template.py new file mode 100644 index 0000000..67c9176 --- /dev/null +++ b/poster/templates/matrix_template.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Matrix风格(矩阵拼接风格)海报模板 +支持将多张图片按照指定的矩阵布局拼接,带有自定义边框 +""" +import base64 +from io import BytesIO +from typing import List, Tuple, Optional, Dict, Any, Union +from PIL import Image, ImageDraw + +from .base_template import BaseTemplate + + +class MatrixTemplate(BaseTemplate): + """ + 矩阵拼接模板,用于将多张图片按照指定的矩阵布局拼接成一张海报。 + 支持自定义边框宽度和颜色,可以生成PNG或Fabric.js JSON格式。 + """ + + def __init__(self, size: Tuple[int, int] = (1350, 1800)): + super().__init__(size) + + def generate(self, + images: List[Union[str, Image.Image]], + size: Optional[Tuple[int, int]] = None, + layout: Tuple[int, int] = (2, 2), + border_width: Optional[Dict[str, List[int]]] = None, + border_color: Optional[Dict[str, List[Tuple[int, int, int]]]] = None, + generate_fabric_json: bool = False) -> Union[Image.Image, Dict[str, Any]]: + """ + 生成矩阵拼接海报 + + Args: + images (List[Union[str, Image.Image]]): 图片列表,可以是路径或PIL Image对象 + size (Optional[Tuple[int, int]]): 输出海报尺寸 (宽, 高),默认使用初始化时的尺寸 + layout (Tuple[int, int]): 矩阵布局 (行, 列),例如 (2, 3) 表示2行3列 + border_width (Optional[Dict[str, List[int]]]): 边框宽度配置 + 格式: { + 'row': [上边框, 行间1, 行间2, ..., 下边框], # 长度为 rows+1 + 'col': [左边框, 列间1, 列间2, ..., 右边框] # 长度为 cols+1 + } + border_color (Optional[Dict[str, List[Tuple[int, int, int]]]]): 边框颜色配置 + 格式: { + 'row': [(R,G,B), (R,G,B), ...], # 长度为 rows+1 + 'col': [(R,G,B), (R,G,B), ...] # 长度为 cols+1 + } + generate_fabric_json (bool): 是否生成Fabric.js JSON格式 + + Returns: + Union[Image.Image, Dict[str, Any]]: + - 如果 generate_fabric_json=False: 返回PIL Image对象 + - 如果 generate_fabric_json=True: 返回包含 'image' 和 'fabric_json' 的字典 + """ + # 使用指定的尺寸或默认尺寸 + output_size = size or self.size + rows, cols = layout + + # 验证输入 + if not images: + return None + + # 验证图片数量必须与布局匹配 + if len(images) != rows * cols: + return None + + # 加载图片 + loaded_images = [] + for img in images[:rows * cols]: + if isinstance(img, str): + try: + loaded_img = Image.open(img) + if loaded_img.mode != 'RGB': + loaded_img = loaded_img.convert('RGB') + loaded_images.append(loaded_img) + except Exception: + return None + elif isinstance(img, Image.Image): + loaded_images.append(img) + else: + return None + + # 设置默认边框 + if border_width is None: + border_width = { + 'row': [0] * (rows + 1), + 'col': [0] * (cols + 1) + } + + if border_color is None: + border_color = { + 'row': [(255, 255, 255)] * (rows + 1), + 'col': [(255, 255, 255)] * (cols + 1) + } + + # 验证边框配置 + if len(border_width['row']) != rows + 1: + return None + if len(border_width['col']) != cols + 1: + return None + if len(border_color['row']) != rows + 1: + return None + if len(border_color['col']) != cols + 1: + return None + + # 第1步:计算所有单元格的位置和大小 + cells = self._calculate_cell_positions(output_size, layout, border_width) + + # 第2步:裁剪所有子图 + cropped_images = self._prepare_cropped_images(loaded_images, cells) + + # 第3步:准备边框配方(位置和颜色信息) + border_recipe = self._prepare_border_recipe(output_size, layout, border_width, border_color) + + # 第4步:生成输出(只是按配方组装,不做任何计算) + if generate_fabric_json: + # 同时生成PNG和JSON + poster_image = self._render_png( + cropped_images, cells, border_recipe, output_size, border_color + ) + fabric_json = self._render_json( + cropped_images, cells, border_recipe, output_size, border_color + ) + return { + 'image': poster_image, + 'fabric_json': fabric_json + } + else: + # 仅生成PNG + return self._render_png( + cropped_images, cells, border_recipe, output_size, border_color + ) + + def _calculate_cell_positions(self, + canvas_size: Tuple[int, int], + layout: Tuple[int, int], + border_width: Dict[str, List[int]]) -> List[Dict[str, Any]]: + """ + 计算每个单元格的位置和大小 + + Args: + canvas_size: 画布尺寸 (宽, 高) + layout: 矩阵布局 (行, 列) + border_width: 边框宽度配置 + + Returns: + List[Dict[str, Any]]: 每个单元格的位置信息 + [ + { + 'row': 行索引, + 'col': 列索引, + 'x': x坐标, + 'y': y坐标, + 'width': 宽度, + 'height': 高度 + }, + ... + ] + """ + canvas_width, canvas_height = canvas_size + rows, cols = layout + + # 计算可用空间 + total_row_border = sum(border_width['row']) + total_col_border = sum(border_width['col']) + available_width = canvas_width - total_col_border + available_height = canvas_height - total_row_border + + # 计算每个单元格的尺寸 + cell_width = available_width // cols + cell_height = available_height // rows + + # 计算每个单元格的位置 + cells = [] + current_y = border_width['row'][0] + + for row in range(rows): + current_x = border_width['col'][0] + + for col in range(cols): + cells.append({ + 'row': row, + 'col': col, + 'x': current_x, + 'y': current_y, + 'width': cell_width, + 'height': cell_height + }) + + # 移动到下一列 + current_x += cell_width + border_width['col'][col + 1] + + # 移动到下一行 + current_y += cell_height + border_width['row'][row + 1] + + return cells + + def _prepare_cropped_images(self, + images: List[Image.Image], + cells: List[Dict[str, Any]]) -> List[Image.Image]: + """ + 准备所有裁剪后的子图 + + Args: + images: 原始图片列表 + cells: 单元格位置信息 + + Returns: + 裁剪后的图片列表 + """ + cropped_images = [] + for i, cell in enumerate(cells): + if i >= len(images): + break + cropped_img = self._crop_image_to_fit( + images[i], + (cell['width'], cell['height']) + ) + cropped_images.append(cropped_img) + return cropped_images + + def _prepare_border_recipe(self, + canvas_size: Tuple[int, int], + layout: Tuple[int, int], + border_width: Dict[str, List[int]], + border_color: Dict[str, List[Tuple[int, int, int]]]) -> List[Dict[str, Any]]: + """ + 准备边框配方(所有边框的位置和颜色信息) + + Args: + canvas_size: 画布尺寸 + layout: 矩阵布局 + border_width: 边框宽度 + border_color: 边框颜色 + + Returns: + 边框配方列表,每个元素包含边框的位置和颜色 + """ + canvas_width, canvas_height = canvas_size + rows, cols = layout + border_rects = [] + + # 计算可用空间 + total_row_border = sum(border_width['row']) + total_col_border = sum(border_width['col']) + available_width = canvas_width - total_col_border + available_height = canvas_height - total_row_border + cell_width = available_width // cols + cell_height = available_height // rows + + # 准备水平边框(行边框) + current_y = 0 + for i in range(rows + 1): + width = border_width['row'][i] + if width > 0: + border_rects.append({ + 'x': 0, + 'y': current_y, + 'width': canvas_width, + 'height': width, + 'color': border_color['row'][i] + }) + current_y += width + if i < rows: + current_y += cell_height + + # 准备垂直边框(列边框) + current_x = 0 + for i in range(cols + 1): + width = border_width['col'][i] + if width > 0: + border_rects.append({ + 'x': current_x, + 'y': 0, + 'width': width, + 'height': canvas_height, + 'color': border_color['col'][i] + }) + current_x += width + if i < cols: + current_x += cell_width + + return border_rects + + def _crop_image_to_fit(self, image: Image.Image, target_size: Tuple[int, int]) -> Image.Image: + """ + 裁剪图片以适应目标尺寸(参考 resize_image 中的 crop 算法) + + Args: + image: 原始图片 + target_size: 目标尺寸 (宽, 高) + + Returns: + 裁剪后的图片 + """ + target_width, target_height = target_size + width, height = image.size + + # 计算缩放比例 + ratio_w = target_width / width + ratio_h = target_height / height + + # 使用较大的比例进行缩放,确保图片完全覆盖目标区域 + ratio = max(ratio_w, ratio_h) + new_width = int(width * ratio) + new_height = int(height * ratio) + resized = image.resize((new_width, new_height), Image.LANCZOS) + + # 计算裁剪区域(居中裁剪) + left = (new_width - target_width) // 2 + top = (new_height - target_height) // 2 + right = left + target_width + bottom = top + target_height + + return resized.crop((left, top, right, bottom)) + + def _render_png(self, + cropped_images: List[Image.Image], + cells: List[Dict[str, Any]], + border_recipe: List[Dict[str, Any]], + canvas_size: Tuple[int, int], + border_color: Dict[str, List[Tuple[int, int, int]]]) -> Image.Image: + """ + 渲染PNG格式海报(纯组装,不做任何计算) + + Args: + cropped_images: 已裁剪好的图片列表 + cells: 单元格位置信息 + border_recipe: 边框配方(位置和颜色) + canvas_size: 画布尺寸 + border_color: 边框颜色(用于背景) + + Returns: + 拼接后的海报图片 + """ + # 创建画布 + canvas = Image.new('RGB', canvas_size, border_color['row'][0]) + draw = ImageDraw.Draw(canvas) + + # 绘制边框(按配方) + for border in border_recipe: + draw.rectangle( + [(border['x'], border['y']), + (border['x'] + border['width'], border['y'] + border['height'])], + fill=border['color'] + ) + + # 粘贴图片(按配方) + for img, cell in zip(cropped_images, cells): + canvas.paste(img, (cell['x'], cell['y'])) + + return canvas + + def _render_json(self, + cropped_images: List[Image.Image], + cells: List[Dict[str, Any]], + border_recipe: List[Dict[str, Any]], + canvas_size: Tuple[int, int], + border_color: Dict[str, List[Tuple[int, int, int]]]) -> Dict[str, Any]: + """ + 渲染Fabric.js JSON格式(纯组装,不做任何计算) + + Args: + cropped_images: 已裁剪好的图片列表 + cells: 单元格位置信息 + border_recipe: 边框配方(位置和颜色) + canvas_size: 画布尺寸 + border_color: 边框颜色(用于背景) + + Returns: + Fabric.js JSON对象 + """ + canvas_width, canvas_height = canvas_size + fabric_objects = [] + + # 添加背景矩形 + fabric_objects.append({ + 'type': 'rect', + 'left': 0, + 'top': 0, + 'width': canvas_width, + 'height': canvas_height, + 'fill': self._rgb_to_hex(border_color['row'][0]), + 'selectable': False, + 'evented': False + }) + + # 添加边框矩形(按配方) + for border in border_recipe: + fabric_objects.append({ + 'type': 'rect', + 'left': border['x'], + 'top': border['y'], + 'width': border['width'], + 'height': border['height'], + 'fill': self._rgb_to_hex(border['color']), + 'selectable': False, + 'evented': False + }) + + # 添加图片对象(按配方) + for img, cell in zip(cropped_images, cells): + img_base64 = self._image_to_base64(img) + fabric_objects.append({ + 'type': 'image', + 'left': cell['x'], + 'top': cell['y'], + 'width': cell['width'], + 'height': cell['height'], + 'src': f"data:image/png;base64,{img_base64}", + 'scaleX': 1, + 'scaleY': 1, + 'cropX': 0, + 'cropY': 0, + 'selectable': True, + 'evented': True + }) + + # 构建完整的Fabric.js JSON + fabric_json = { + 'version': '5.3.0', + 'objects': fabric_objects, + 'background': self._rgb_to_hex(border_color['row'][0]), + 'width': canvas_width, + 'height': canvas_height + } + + return fabric_json + + def _rgb_to_hex(self, rgb: Tuple[int, int, int]) -> str: + """将RGB颜色转换为十六进制格式""" + return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" + + def _image_to_base64(self, image: Image.Image) -> str: + """将PIL Image转换为base64字符串""" + buffered = BytesIO() + image.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode() + return img_str +