2025-10-24 11:57:01 +08:00

442 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.

#!/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